Domain model
Every context is a Boundary sub-boundary declared in lib/relay.ex and listed in
Relay's exports — that list is the authoritative context inventory; this page annotates
it. Schemas live in the Schemas peer (ADR 0002) so web and domain share structs without
sharing behavior.
Contexts
-
Boards — boards and their stage tree (stages, sub-lanes, review gates, WIP limits; AI-enabled is derived —
Relay.Flows.ai_stage_ids/1: a stage some flow works in); renaming a main stage cascades to its sub-lane names ("<name>:Review"/"<name>:Done", one transaction inupdate_stage/2— RE385), the sub-lane vocabulary and order beingSchemas.Stage.sublane_types/0. Stage/config semantics: ADR 0003. Board templates (RE442):create_board/2takes an optionaltemplateslug fromRelay.Boards.Templates(the ONE slug list,slugs/0), which loadsdocs/designs/templates/*.jsonat compile time via@external_resource; the web layer reads them only throughBoards.templates/0. Without a slug the board gets today's default 8-stage pipeline and spec/plan/code flows; an unknown slug is{:error, :unknown_template}and inserts nothing. A template seeds, in one transaction, its main stages (with WIP limits), their sub-lanes, the releases, the seeded agents plus its own, its flows — disabled, every node an inline prompt with noagentand nollm(so it runs on the board default — except Three-brain'sclaude_plan/gpt_plan/gemini_plan, RE443) — and Review'sreject_to= Plan; never cards. The template path also starts the board's setup checklist (Relay.Setup.start/2, same transaction); the default path does not. The reverse direction:Boards.template_parts/1renders a live board's main stages (with sub-lane types) and its flows (Flows.Document, minusversion/enabledand every node'sllm, in trigger-stage order) as a template file's"stages"/"flows"; a board created from a template whose nodes name no agent round-trips to its file.prod pull-demo-flowsreads Pantry's and Streaks' throughRelay.Release.example_template_parts/1(bin/relay rpc) and writes them intodesign-led.json/bare-minimum.jsonwithbin/pull_demo_flows.py, keeping the picker copy, the template's agents and the files' hand formatting; it never touches the example-board JSON or cards. Bare minimum: Backlog · Plan (+Done) · Implement · Review · Deploy · Done, flowsplanandimplement. Design-led: Backlog · Next up · Design (+Review, +Done) · Plan (+Review, +Done) · Code · Review · Deploy · Done, flowsdesign,planandcode. Three-brain: Backlog · Plan (+Review, +Done) · Implement · Review · Deploy · Done, flowsthree-brain-plan(afan_outto Claude / GPT / Gemini planners, joined by a judge) andimplement, plus the agents "GPT" (codex) and "Gemini" (gemini-cli) on each harness's first seeded model. Deploy never has a flow. A stage value outsideSchemas.Stage's sets fails the build; a template flow that fails validation raises and rolls the board back. Board copy (RE447):create_board/2also takescopy_from(a board slug), which wins overtemplate. The source resolves through the membership-scopedget_board/2(a member board, archived or not); anything else is{:error, :not_found}and inserts nothing. In the same one transaction it copies the source's main stages, sub-lanes (inserted directly, so they keep their own names and WIP limits), positions and reject-to wiring remapped to the new stage ids; seeds the default releases; copies harnesses, agents and the default agent (Relay.Agents.copy_from!/2, beforeensure_seeded!/1, which then only adds missing seed harnesses); and copies the flows onto the remapped stages, disabled at v1 (Flows.copy_board_flows!/3). It is a plain copy: no cards, no public intake, no example template and no link back, so later edits to the source never reach it. The.claude/files the flows name are not copied — they live in the source's repo. It starts the setup checklist like a template board, step 1 readingcopied from <source name>(Setup.start/2's:evidenceoverride;Setupmay not callFlows, so the label is passed in).copy_sources/1lists what "New board" can copy from —list_boards/1's boards, each with its main-stage and flow counts andFlows.referenced_files/1of its flows. Board name + card key (RE455):create_board/2takes the card key fromkeywhen given, else derives it from the name viaSchemas.Board.key_from_name/1(the first two A–Z letters, upper-cased) with an "RL" fallback when that yields fewer than two; the slug still derives from the name.Schemas.Boardowns the rules once —key_from_name/1,key_format/0([A-Z]{2}) andname_max_length/0(80) — and its changeset stays the authority; a rejected name or key is{:error, changeset}, inserting nothing. The New board picker (RelayWeb.BoardsLive) requires a name and a complete key for every option, templates and copy alike: the key follows the name (no fallback) until the user edits it, theCardKeyInputhook filters it while typing, both are passed through tocreate_board/2, and a changeset error keeps the picker open with the message under its field. Stage structure (RE384):create_stage/2takes a category atom (Settings "+ Add") or an attrs map whose optionalbefore:/after:anchor (a main stage on the board) places the new stage beside it and adopts its category;place_stage/2moves a main stage before/after an anchor the same way (reorder_stage/2stays the ↑/↓ step).delete_stage/2refuses, in order: the last main stage (:last_stage), any live card in the stage or its substages ({:not_empty, %{live: n}}), and the public intake stage (:public_intake). Archived cards never block (RE453): once every refusal has passed, they are re-homed in the same transaction as the delete (before it —cards.stage_idcascades) to the nearest surviving main stage before the deleted one in board order (the one after it when it is first), staying archived, snapped bySchemas.Stage.arrival_status/2, appended after the target's highest card position, each with one:movedactivity (theRelay.Cardsmeta keys) attributed to the optionalactor(default:agent) and logged withActivity.log/3'sbroadcast: false— the one{:stages_changed, board_id}after the commit is the only announcement (henceBoards's boundary dep onRelay.Activity). A flow never blocks a delete (RE429): the stage's flow goes with it (flows.stage_id on_delete: :delete_all; its runs keepflow_key,flow_idis nilified), and a reject-to target is nilified by the FK.disable_lane/3refuses the same for the lane alone (bare:not_empty, live cards only) and re-homes the lane's archived cards to its parent the same way. Changing a flow-holding stage to a non-work type (Relay.Cards.update_stage/2) is{:holds_flow, %{stage:, flow:}}; a substage passed todelete_stage/2/place_stage/2/enable_lane/2is:not_a_main_stage.stage_refusal_message/1is the one rendering of every refusal (Settings flash and API alike). A stage's type changes only throughRelay.Cards.update_stage/2, which wrapsupdate_stage/2and re-snaps resident cards when the type changed (it lives in Cards becauseBoards → Cardswould be a boundary cycle). Over REST (RelayWeb.Api.StageController, any board key):GET|POST /api/stages,PATCH|DELETE /api/stages/:id,POST /api/stages/:id/place, andPUT|DELETE /api/stages/:id/substages/:lane; the refusals map to 409 (last_stage,not_emptywith thelivecount,holds_flowwithflow,public_intake) and 422 (invalid_anchor,not_a_main_stage), each withstage_refusal_message/1's sentence. The stage JSON'sai_enabledkey is read-only, derived fromFlows.ai_stage_ids/1(RE409); a create/update body namingai_enabled(any value) is 422invalid_requestand writes nothing. Every/api/stagesresponse also carries a read-onlyproblem(RE430): theFlows.Shape.wire/1problem of the stage's flow when that flow is enabled and paused by a broken board shape (Flows.shape_problems/1),nullotherwise.CardJSON.stage/3— the card and board responses — does not carry it. Also holds the RLY-69 public-board settings (public_enabled+public_intake_stage_id, written viaupdate_public_settings/2) andlist_public_cards/1, the public roadmap's card query (non-archived, stage category inStage.public_categories/0). Example boards (RE444):boards.example_template(nullable, unique) marks a board as the public example of one template slug. It is written only bymark_example/2(viaBoard.example_changeset/2, never cast bychangeset/2), which refuses a slug outsideTemplates.slugs/0({:error, :unknown_template}) and one another board already holds ({:error, changeset}).get_example_board/1({:ok, board}with stages in hierarchical order, or:error) andexample_boards/0(%{slug => board name}, marked boards only) are unscoped reads, likeget_public_board/1.list_all_boards_for_admin/0(every board, archived included, with owner email and member/card counts in one query, A–Z by name with no stars) andmember_board_ids/1back the superadmin-only/admin/boardspage (RE353). They are unscoped reads, and the gate is the/adminroute. Personal board stars (RE395) live on the membership (board_members.starred, never cast byMembership.changeset/2), so a star is one flag per user per board and goes with the membership.set_starred/3sets (not toggles) the caller's own row by slug — archived boards allowed,{:error, :not_found}without a resolved membership.list_boards_for_display/1returns the user's non-archived boards as%{board, starred?, muted?}in display order — starred first, then case-insensitive name,idtiebreak — defined once in a privateorder_by_name/1helper (the admin list reuses its name half).list_boards/1deliberately stays creation-ordered (inserted_at, id): the default board,Cards.needs_you_feed/1,Cards.resolve_ref/3andAllController.resolve_board/2depend on it, so starring never changes your default board. The per-board push mute (RE406) lives beside it (board_members.muted, also never cast): one flag per member per board, written only byset_muted/3, which mirrorsset_starred/3(sets, never toggles; only the caller's own row;{:error, :not_found}without a resolved membership). A muted member gets no APNs push for that board; browser notifications, badges and the needs-you counts are unaffected. Muting never moves a row in the display order. -
Examples (
Relay.Examples, RE444) — the public, read-only example board for each template: Streaks (bare-minimum)ST, Pantry (design-led)PAand Tiny Dungeon (three-brain)DU. Content lives indocs/designs/templates/examples/<slug>.json(name, key, pitch, baton and facts lines, WIP How/Why/Here, per-stagefull/whynotes, per-flow blurb + selected node, and the cards top to bottom), loaded at compile time via@external_resourceand checked againstBoards.templates/0: an unknown stage or sub-lane, a statusSchemas.Stage.valid_status?/2refuses, a question on the wrong card, a duplicate ref, afullnote whose stage doesn't hold exactly its WIP limit, or an unknown flow / node key fails the build.note_kinds/0andbatons/0are the closed sets;all/0/get/1read the content.seed!/2creates each missing board (one transaction per board) withBoards.create_board/2+mark_example/2, then the cards throughRelay.Cardswith their mockup ref numbers (by settingcard_seq); it skips existing boards, andreset: truedeletes and recreates them. The template's flows stay disabled, so no runner pulls a sample card. Entry points:mix relay.examples.seed --owner <email> [--reset], andRelay.Release.seed_examples/2on the live node (bin/relay rpc). Its own boundary becauseCardsalready depends onBoards. Web: the public route/examples/:template→RelayWeb.ExampleBoardLive, in the router's:publiclive_session(beside/board/:slug/public), so it renders signed out (current_scopenil). It needs bothBoards.get_example_board/1andExamples.get/1, else it raisesEcto.NoResultsError(a 404 for an unknown or unseeded slug). It is static (no PubSub), renders the main stages through read-onlystage_column/1s with every card'ssub_tasksblanked (no run-derived progress), and handles onlyselect_card/close_card/select_node— a final catch-allhandle_event/3swallows every mutating event. Its "Use the template" button goes to/boards?template=<slug>when signed in, else through/auth/google?return_to=…to the same place. The New board picker (TemplatePickerComponents.template_picker/1'sexamples, fedBoards.example_boards/0byBoardsLive) links each template card with a seeded example to/examples/<slug>in a new window. -
Agents — a board's LLMs (RE433). Harnesses (
Schemas.Harness, tableharnesses): an agent CLI's command template ({placeholder}s fromSchemas.Harness.placeholders/0, optional[ … ]segments), optionalresume_command/session_id_path/signed_in_check, and its closedmodelslist;keyis derived from the name once and never changes. Agents (Schemas.Agent, tableagents): a unique name, a harness and one of its models.boards.default_agent_id(nilify on delete) is the agent a node with nollminherits.ensure_seeded!/1(idempotent; called byBoards.create_board/2,Flows.seed_default_flows!/1,seeds.exs) gives a board theseed_harnesses/0(Claude Code, Codex, Gemini CLI) and, when it has no agents, "Claude Opus" / "Claude Sonnet" / "Claude Haiku" onclaude-codewith Opus the default; theCreateHarnessesAndAgentsmigration seeded every existing board from a frozen copy.red?/1(the agent's model has left its harness's list) is the one red-agent rule. A harness edit may drop a model in use (the agent turns red);delete_harness/1refuses while agents use it,delete_agent/1refuses the default and an agent a node names (node_usage/1). Renaming an agent rewritesllmon the board's flow nodes without bumping their version. At run timeresolve/2turns a node'sllminto its agent ({:ok, _}/{:red, _}/{:error, {:unknown_agent, name}}) forRelay.Runs.build_payload/4, andharness_wire/1+harnesses_digest/1are the runner's view of the definitions — including each harness's embedded usage rule (Schemas.HarnessUsage, RE436, evaluated bySchemas.HarnessUsage.Rule), seeded per harness inseed_harnesses/0(node_jobs.harness_key,runners.harnessesand the claim filter are in runner.md "Harness routing"). Depends onRepo+Schemasonly — it reads/writesSchemas.Flowrows directly, becauseRelay.Flowsdepends on it. Humans edit both lists in Board Settings → Agents (?section=agents,RelayWeb.AgentsSettingsComponentsrendered byBoardSettingsLive, which formats the structured delete refusals into copy). -
Flows — workflow definitions as declarative graph data (ADR 0006 / RLY-131): per-board rows in the
flowstable (key,enabled,isolation,version, and the one stage the flow works in,stage_id) with the node/edge graph embedded as jsonb. One stage (RE429): a flow belongs to exactly one main stage of a work type (Schemas.Stage.work_types/0) —stage_idisNOT NULL, unique (flows_stage_id_index: a stage holds zero or one flows, disabled ones included) andon_delete: :delete_all(deleting a stage deletes its flow;runs.flow_idis nilified,runs.flow_keykept). Where it pulls from (pickup) and lands on (drop-off) are never stored: they are worked out from board order (Schemas.Stage.order_stages/1) bySchemas.Stage.neighbours/2(viaRelay.Flows.neighbours/2) — the stage immediately before it, and the stage immediately after it (its own first substage when it has one, else the next main stage),nilat either end.neighbours/1is the DB convenience the engine asks at the moment it needs the answer (drop-off is resolved when the run lands);list_flows/1/get_flow_with_stages/2fill the virtualpulls_from_stage/lands_on_stagefields on every read. Node LLM (RE433): an agent node'sllmnames one of the board's agents (Relay.Agents), nil = the board default.create_flow/2,update_flow/2,upsert_from_document/3andseed_default_flows!/1refuse a name that is not an agent on the board (node "<key>" names LLM "<name>", which is not an agent on this board). The pre-RE433 nodemodelis a legacy alias (Schemas.Flow.Node.legacy_models/0,"opus"→"Claude Opus") renamed bynormalize_legacy/1; theRenameNodeModelToLlmmigration rewrote storedflowsandflow_versions, raising on an unknown model. The shape rule (RE430):Relay.Flows.Shapeis the ONE owner of whether a flow's board shape can work and of every word explaining a broken one.Shape.problems/2(pure, over the ordered stages and every flow, enabled or not) built onneighbours/2gives a broken flow exactly one problem, of the first kind inShape.kinds/0that applies —:no_upstream(nothing before its stage),:upstream_review(a Review column right before it),:upstream_working(a work/planning column right before it, naming that stage's flow when it has one),:no_downstream(nothing after it) — carryingwhat/why/fixes(actions fromShape.fix_actions/0) /columns(the stage two either side, marked:self/:offending).Shape.wire/1is its one JSON projection andShape.paused_detail/1the "paused" sentence; consumers render the wording verbatim. Nothing is stored:shape_problems/1is the DB convenience, andlist_flows/1/get_flow_with_stages/2fill a virtualproblemfield (nilwhen healthy; computed against all the board's flows, no extra query on the list path). A flow with aproblemis neverRelay.Runs.queued_flow/5's queued flow, andRelay.Runs.Preflightreports it asstages: {:problem, problem}(the enable-preflight row shows itswhat).Relay.Flows.paused?/1is the ONE "paused" predicate (enabled and a non-nilproblem) every consumer calls, andRelay.Boards.apply_shape_fix/2executes one of a problem's structured fixes through the existing stage paths (so it broadcasts{:stages_changed, board_id}). Two web surfaces renderRelay.Flows.Shapeproblems verbatim (RE432): the Board Settings › Stages shape callout (RelayWeb.FlowShapeComponents.shape_callout/1— WHAT / WHY / BOARD ORDER / one-click FIX, eventapply_shape_fix) and the board's paused-flow banners (RelayWeb.FlowShapeComponents.paused_flow_banners/1— one per paused flow, collapsing into one summary banner at 3+, Fix in Stages → on non-archived boards) plus the "AI paused" column chip (stage_column/1'spaused); both refresh on{:stages_changed, board_id}.Schemas.Stage.lane_word/1is the one sub-lane word ("Code · Done"), shared withRelay.Boards.stage_flow/1is the stage's flow (or nil),assignable_stages/2the stages a flow may be put on,copy_flow/2copies a definition onto another empty stage (disabled, v1)."start"/"start"/"done"/"needs_input"are edge sentinels ("needs_input"isto-only and parks the run — RLY-194); nodes carry an optionaltimeout_minutes(validated> 0) and an agent-onlyexpects_commitsboolean (defaultfalse, RLY-194) marking a node whose reported successRunServermay override if it produced no commits. Every node also carries areads/writescard-field contract (RE244; vocabularySchemas.Card.contract_fields/0) —writesis enforced at run time byRunServer.override_missing_writes/4,readsis advisory. Every node may also carry an optionalrole(:do | :check | :fix, RE346; vocabularySchemas.Flow.Node.roles/0) — display-only, never read by the engine or the runner.Schemas.Flow.node_roles/1resolves every node's role, first match wins: the authoredrole; else:fixwhen every inbound edge ison: :failed(the"start"edge counts and is never failed); else:checkfor a:gate; else:do. The default Code flow authorsrole: :checkonly on the six checks the guess can't see (spec_review,quality_review,final_review,smoke,acceptance,deploy). The invariant that no:agent/:gatenode in any shipped flow leaves:failedunrouted is enforced bydefault_library_test.exs.Relay.Flows.seed_default_flows!/1idempotently seeds the default spec/plan/code library (fromRelay.Flows.DefaultLibrary, which loadsdocs/designs/flows/*.jsonat compile time via@external_resource— those files are the source of truth, andRelay.Flows.Documentis the one serializer both they and the API go through) —Boards.create_board/2calls it after enabling theSpec:Review/Spec:Done/Plan:Donesub-lanes so every trigger resolves. Flows seed disabled. Nothing executes yet — the engine is the Runs card (02). A template board's flows go throughRelay.Flows.seed_template_flows!/2instead (RE442): the same trigger-name resolution, changeset, one-stage rule, LLM check and v1 snapshot, but it never skips — an unknown trigger stage raisesArgumentErrorand an invalid flow raisesEcto.InvalidChangesetError, atomically. Versioning (RLY-152, absorbed into the flow editor card): every flow's definition (nodes, edges, isolation) is versioned —flows.versionholds the current number, and each version is snapshotted immutably intoflow_versions(belongs_to :flow,version,isolation, embeddednodes/edges; noupdated_at, never edited after insert). A flow always has a snapshot row for its current version — created oncreate_flow/2,copy_flow/2,seed_default_flows!/1andseed_template_flows!/2, and on every bump — the invariant a future Runs pin-to-version feature relies on.save_definition/2is the one path that changes a flow's definition after creation: it validates likeupdate_flow/2, then bumpsversionto n+1 and writes a new snapshot only if the definition changed; a stage-only change (including a stage rename) saves with no bump, since the stage is per-board wiring and not part of the versioned definition.get_version/2fetches an immutable snapshot by number;mid_run_count/1returns the real count of cards currently mid-run on a flow — runs whose status is active (Schemas.Run.active_statuses/0, i.e. running or parked) — and feeds both the flow-editor save note and the delete-confirm warning, so that number is defined once. Serialization (RLY-241):Relay.Flows.Documentis the one serializer for a flow's canonical JSON document —encode/1(sparse: nil and schema-default fields omitted) anddecode/1(dense: every node/edge field present, filled from the schema default, socustomized?/1's field-by-field comparison against the embedded structs stays exact).decode ∘ encodeis a fixed point, which is what makes pull → push unchanged a no-op. The trigger serializes as one stage name,{"stage": "<name>"}, not an id, so a document is portable across boards; a legacy three-key trigger (pulls_from/works_in/lands_on) still decodes, reading onlyworks_in.Document.derived/1is the read-only{"pulls_from", "lands_on"}name pair the API puts beside the document (GET /api/flows[/:key]and thePUTresponse:"derived");encode/1never emits it anddecode/1accepts and ignores it — and likewise a read-only"problem"key (RE430) — so a pulled file pushes back unchanged.upsert_from_document/3is the write path behindPUT /api/flows/:key: decode → key check → resolve the trigger's stage name → optional compare-and-swap onversion→ refuse a stage another key already holds ({:stage_occupied, %{stage:, flow:}}, API 422stage_occupied, sentence fromstage_occupied_message/1) →create_flow/2orsave_definition/2→ reconcileenabledthroughenable_flow/1/disable_flow/1, all in one transaction so a push never half-applies. Flows are managed from Board Settings › Stages (RE431 — the RLY-142 Flows tab is gone;?section=flowslands on Stages): each main-stage row owns its flow through a FLOW band (RelayWeb.FlowSettingsComponents.flow_band/1— chip,v<n> · <m> nodes, a direct On/Off toggle, a ⋯ menu) and a read-only PULLS FROM → WORKS IN → LANDS ON row worked out byneighbours/2. A paused row (RE432) carries the shape callout for its problem; the board links each paused flow's banner here, anchored on#stage-<id>-row. The band is backed bycustomized?/1(normalized nodes/edges/isolation comparison against the library — stage wiring never counts),default_key?/1,copy_flow/2(a disabled<key>-<stage>copy on another empty stage — the band's Copy to another stage…, which replaces the old Duplicate; its picker lists onlyassignable_stages(board, nil)and previews the key withcopy_key/2, whichcopy_flow/2itself calls so the preview and the copy never disagree),unique_key/2(thebase/base-2/… generator behind copy keys), and — replacing the tab's "+ New flow" create-from-scratch panel (RLY-158), which is gone — the no-flow band's + Add flow, backed byaddable_defaults/1(the library keys, in library order, not yet on the board) andadd_flow/2({:default, key}seeds that library definition,:blanka barestart → donegraph keyed by the stage slug orflow; either way a disabled v1 flow with its snapshot, validated by the same one-stage rule ascreate_flow/2), andreset_to_default/1(restores the shipped definition viasave_definition/2, so a reset bumps the version and snapshots like any other save; stage andenableduntouched), anddelete_flow/1(RLY-221 — removes a flow from this board, disable-first: an enabled flow returns{:error, :flow_enabled}, a disabled one is deleted and the DB cascade nil-s each active run'sflow_id(runs.flow_id on_delete: :nilify_all) and removes its version snapshots (flow_versions.flow_id on_delete: :delete_all); a deleted shipped default does not re-seed on the next deploy).diff_from_default/1structurally diffs a customized default flow against its shipped default (nilfor a non-library key) — nodes grouped added/removed/changed (changed lists the differing fields), edges as{from, to, on}tuples grouped added/removed. Editor (RLY-143):RelayWeb.FlowEditorLive, a full-page LiveView at/board/:slug/flows/:key, edits a flow's working copy (nodes/edges/isolation/stage) with inline validation againstSchemas.Flow.changeset/2, saves throughsave_definition/2behind a "Save as v(n+1)" confirm modal, and offers the diff-vs-default / reset-to-default affordance for customized library flows. The graph is rendered by the sharedRelayWeb.FlowGraphComponents.flow_graph/1function component (absolutely-positioned node divs + an SVG edge layer,interactive?togglesphx-click, acceptsnode_statesfor a later read-only reuse by the run panel) laid out by the pure, unit-testedRelayWeb.FlowLayout.layout/2(a deterministic layered layout — a thin adapter over the vendoreddagre_ex(Dagre) that sizes nodes and edge labels, ranks them top to bottom and returns axis-aligned edge polylines with a reserved label point per edge; the succeeded spine (FlowLayout.spine/1, start → … → done) is edge-weighted so it lays out as one vertical line; no stored coordinates, no dragging). FlowLayout then spreads edge endpoints into per-edge ports along each node's top/bottom border, ordered so lines don't cross at the node (RE340); a spine edge keeps the node's centre port on non-gate nodes so it stays straight. Hovering a node emphasises its incident edges client-side via theFlowFocushook (assets/js/hooks/flow_focus.js, reading the graph'sdata-adjacency); a selected node gets the same emphasis server-side (RE333). A work node's main field (agent RUN PROMPT, shell COMMAND, gate CONDITION) shows in the inspector as a rendered, clamped preview (Relay.Markdown.to_prompt_html/2,{placeholder}chips, thePromptClamphook's fade) whose Expand opensFlowEditorComponents.prompt_sheet/1, a full-height right-edge Read/Edit sheet over the selected node (@prompt_sheet, Edit posting the sameedit_node_field) (RE459). On an archived board the editor graph stays selectable (interactive?is always on) so the read-only inspector and Expand-to-read are reachable, while every mutating event — and the sheet's Edit mode — is refused with the read-only flash. Requirements (RLY-182):Flows.node_requirements/1is a pure graph read — the agent and skill names a flow's nodes name, with no runner knowledge. It lives here rather than in Runs becauseFlowsmay not depend onRuns(a boundary cycle the compiler rejects);Runsreads it to answer whether anyone can actually satisfy it. Referenced files (RE447):Flows.referenced_files/1is the same pure read over one flow or a list, shaped as repo files: one%{kind: :agent | :skill, name, paths, used_by}per distinct agent or skill, in first-appearance order,used_bybeing every%{flow:, node:}naming it. An agent's path is.claude/agents/<name>.md; a skill is present at either.claude/skills/<name>/SKILL.mdor.claude/commands/<name>.md. It sharesnode_requirements/1's extraction (a skill only from an agent node's leading slash command) and is the ONE place those path shapes live: the template picker's copy warning (viaBoards.copy_sources/1) andGET /api/setupboth read it. -
Runs — the workflow execution engine (ADR 0006 card 02 / RLY-132): a run executes a
Schemas.Flowgraph against a card as a supervised, Postgres-backed state machine. This boundary owns five things. The engine (Relay.Runs.Engine/RunServer): outcome routing, per-nodemax_retries, per-edgemax_loops, the visit cap, the failure-signature breaker (whole-run), needs-input parking and restart resume — budgets are per-foreach-iteration and per fan-out branch (runner.md). The scheduler brain:Relay.Runs.Scheduler.plan/1, a pureSnapshot → Planbehind a per-boardScheduler.Server;capacity_diagnosis/1turns an empty/silent roster into a verdict. The read side:list_runs_for_card/1,latest_run/1,run_summaries_for_board/1,run_summary_for_card/1,happy_path/1,queued_flow/5,face_summary/5— one shared private builder, so the summary shape is defined exactly once; a summary carriesbranches(the open region entry's tally and per-branch bars, nil when no region is open — one batched query per board, RE440).RunDetailgroups each fan-out region entry into one:regiontimeline row with a lane per branch, and exposes the branch partition once asbranch_bar/1,branch_tally/1andbar_states/0(RE440; see runtime.md).list_runs_for_card/1join-preloads each run'sflow: :stagein the runs query (no per-run N+1), soRunDetailcarriesstage_name— the run's work stage's name, nil when the flow or its work stage is gone (RE426). Flow metrics read each execution's work / rework / rewind class from ONE function,execution_spans_for_flow/2(RE348; see runner.md). The four metrics roll-ups (node_metrics_for_flow/2,node_waits_for_flow/2,execution_spans_for_flow/2,flow_metrics_summary/2) takecard_id:(one card, RE235) orcard_ids:(a list — RE349's Last-N population,card_id:winning when both are given); either drops the window.first_node_queue_wait/2(RE349) is the meanclaimed_at − inserted_atof theNodeJob.flow_kinds/0job bound to each in-population run's first execution — the value stream's queue triangle. The board-health audit (RE249):Relay.Runs.audit/2/Relay.Runs.Audit.findings/2, a pure function over runs (:node_executionsandcard: :boardpreloaded) on the metrics'metric_windows/0vocabulary, answering is this board's history clean?; ownsseverities/0/checks/0, advisory. Four checks —findings_dropped,verdict_flipped,planner_not_migrated(RE368), reading theruns.tasks_from_planfactstart_runsets when the legacy plan-parse fallback seeded the card's tasks, andoutcomeless_attempts(RE410), one WARNING per{run, node, visit}with at leastRelay.Runs.max_outcomeless_reentries/0outcome-nil executions. Steer now (RE358):steer/4,steer_state/1,steer_receipts/1; the server-only:steeredoutcome (NodeExecution.server_only_outcomes/0) — see runner.md. The dispatcher seam: theRelay.Runs.Dispatcherbehaviour, resolved throughRelay.Runs.Instance(config :relay, :runs_dispatcherin production, a per-test instance under test — RE298 / ADR 0009). Card writes go throughRelay.Cards, so ADR 0003/0004 rules apply automatically. Run/node/job statuses are in state.md; dispatch, the runner, worktrees and the transport in runner.md; the failure grid in failures.md. Why: ADR 0006, ADR 0007; per-function detail in theRelay.Runs@moduledoc. -
Cards — the card lifecycle: create/edit/move/archive, status (
working,needs_input,failed, …), sub-tasks, spec/plan/branch/pr fields, approve/reject (after a decision,next_awaiting_review/4names the next card in the lane awaiting review —Schemas.Card.awaiting_review?/1— instage_column/2order, wrapping, for the review drawer to advance to, RE388), needs-input questions.answer_input/4(image_notes: [{question_number, image_ids}]) andreject/4(image_ids:) post each non-empty image list as an image-only note tagged with its origin ({:answer, n}/:rejection) before the answer / changes-requested comment, all in oneRepo.transaction— a failed answer or reject (or a bad image id) leaves no note behind; empty opts behave exactly as/3(RE428).failed(RLY-179) is set only byRelay.Cards.mark_failed/3when a run ends terminally — a separate path fromneeds_input's genuine question. Tasks (RE355) are addressablesub_tasksrows with a nullable markdownbody:list_tasks/1,get_task/2,add_tasks/2,update_task/3anddelete_task/2write one row at a time.add_tasks/2appends the batch atomically after the current last task, in argument order;delete_task/2decrements every later row'spositionso positions stay0..n-1; both take aFOR UPDATElock on the card row first. No call rewrites another row's id, title, body ordone(donestaysset_sub_task_done/3's).set_sub_tasks/2remains the legacy full-replace path (PATCH /api/cards/:refwithsub_tasks): it deletes and re-inserts every row, nilifying eachnode_executions.sub_task_idbound to them — never use it for a per-task edit. Card state × stage validity is governed by ADR 0003; ownership and the claim rule by ADR 0004 — a user owner must hold a resolved membership on the card's board, checked inRelay.CardsviaRelay.Members.member_user_id?/2on every owner write ({:error, :owner_not_member}, API 422owner_not_member); derived agent health (Cards.health/1, 90sSTALE_AFTER) and the four-bucket needs-you rollup (needs_input/in_review/awaiting_human/agent_stalled— RLY-148) surfaced byGET /api/boardand the boards-home badges. A move that would strand a live run (Cards.stranded_run/2: an active run whose flow'sstageis not the destination) is refused up front with{:error, :would_strand_run}—POST /api/cards/:ref/movemaps it to 409would_strand_run(RLY-217); the board pre-checks and confirms instead of surfacing the raw error. A card'smockups(RE370) is a nullable{:array, :map}column of%{"url", "caption"}entries, written only throughRelay.Cards.set_mockups/2: a full REPLACE, validated so every url is an/attachments/<id>path of an HTML or image attachment on the same card (Schemas.Attachment.mockup_types/0, RE390;422 invalid_mockupsover the API), broadcast as{:card_upserted, card}.:mockupsis a flow contract field (writes: ["mockups"]), blank when nil/empty. It is distinct fromai_result.screens(run-result screenshots). The drawer renders mockups in a Mockups section above Description as a wrapping row of 80px square tiles (CoreComponents.mockup_preview/1, RE374): each tile is a live miniature (an inert<iframe sandbox="allow-scripts">rendered at 1280px and CSS-scaled down) with the caption as its tooltip and aria-label. Clicking a tile opens, in the same tab,RelayWeb.BoardLive's mockup viewer mode (RE380): themockup=<attachment id>param on the card's drawer URL (/board/:slug?card=<ref>&mockup=<id>,/board/:slug/story-map?card=…&mockup=…, and/cards/:ref?board=<slug>&mockup=<id>in the native host). The viewer shows a 340px left sheet (the card's gate panel — review or question stepper — plus the Mockups tiles, the current one ringed) and the sandboxed frame under the breadcrumb top bar, whose card crumb names the card; on phones it is full screen under one bar with no review controls. Switching (a tile, ←/→, ‹ ›, a swipe) replaces the history entry, so one browser Back, Esc or "← Back to card" returns to the drawer; an invalid or stalemockupid falls back to the drawer. Embedded (the native host, RE393), an HTML mockup zooms from Fit to 400% (− · Fit · + over the frame) and its frame scrolls sideways once zoomed, in both phone and desktop render widths. The native card host'snavparam (RE400,RelayWeb.NativeCardNav) — comma-separatedprev/next, e.g./cards/:ref?board=<slug>&nav=prev,next— names the neighbors the native shell has: it drives the drawer's ‹ › chevrons in native mode, where each calls the shell'srelayCardNavbridge ("prev"/"next") instead of patching, binds no arrow keys, and renders disabled for a directionnavomits. Nonav(or no known token), no chevrons;navrides through the viewer's patches likeback, and/board/:slugignores it. The same viewer opens the AI Result's Screenshots (RE390): the drawer shows them as the same 80px tiles, which lead the violet AI Result box with a count and are always visible (RE401 — Show more reveals only the Changes and is absent when there are none), and a tile patches toscreenshot=<n>(1-based among the openable screenshots; an agent-local path is a placeholder tile, never a viewer item) on the same three hosts. BoardLive holds oneviewerassign,nil | %{section: :mockups | :screenshots | :images, key: _}; the sheet shows only the section that was opened and ←/→ stay within it.mockupwins when both params are present; a malformed, out-of-range or stalenfalls back to the drawer (a cold open waits for the async body fill, since the light card has noai_result). Tiles and the viewer pick HTML vs image by content type (Relay.Attachments.content_types/1→RelayWeb.CardMedia): only a same-card/attachments/<id>HTML attachment is framed; everything else is an image, shown in the viewer at its natural size in anoverflow-autoframe. Markdown images in descriptions, specs and comments keep the RE322image_lightbox. Note images (RE427) are the viewer's third section: each posted note renders its images under the body as a wrapping row of 108px-tall thumbnails (CoreComponents.note_image_row/1, outside the.mdbody so the lightbox never claims them; an image-only note has no body box), live in every open drawer via:timeline_appended. A thumbnail patches toimage=<attachment id>on the same three hosts (/board/:slug?card=<ref>&image=<id>,/board/:slug/story-map?card=<ref>&image=<id>,/cards/:ref?board=<slug>&image=<id>), giving%{section: :images, key: id}. Its items areRelayWeb.CardMedia.note_image_items/1over BoardLive's@note_image_comments(the notes with images, kept in lock-step with the:conversationstream bystream_notes/2/insert_note/2): every note image on the card, oldest note first, then upload order. The sheet readsImages · Nwith each filename under its tile and aFrom a note by <author> · <time ago>byline; the header countsImage n of N. Precedence ismockup>screenshot>image; a cold?image=waits for the async body fill (the conversation), then an unknown or malformed id, an unlinked upload, a mockup, or another card's note image falls back to the drawer./attachments/:id/view(RelayWeb.MockupViewerLive,RelayWeb.attachment_view_path/1) is the legacy RE370 link: an authenticated, membership-scoped redirect to that viewer URL in the:require_authenticatedlive_session; it 404s for a non-member, an unknown id, or a non-HTML attachment. Relay's UI never links to a raw HTML attachment as a top-level page. Archive and restore (Cards.archive_card/2/Cards.unarchive_card/2) are reachable from the board-key API asPOST /api/cards/:ref/archiveandPOST /api/cards/:ref/unarchive(RE318,./relay archive/unarchive), attributed to:agent. The API archive refuses a card with an active run (Relay.Runs.active_run/1) with 409active_runand writes nothing; that guard lives inRelayWeb.Api.CardController, not inCards.archive_card/2, so the board UI's Archive button stays unguarded as shipped. Instead of refusing, a board archive cancels the card's active run (RE335):Relay.Runs.Listenerreacts to{:card_archived, card}and closes it:cancelledwithrun cancelled — card archived(theRunnerReapersweep backstops it), which lets the runner release the card's held worktree on its next heartbeat. The drawer's confirm says so ("Its active run will be cancelled.") only when the card has an active run. Unarchiving never revives that run. Both are idempotent through the domain functions (a repeat logs nothing), and neither is on the/api/alluser-token scope. The per-board summary — top-levelstage_count, non-archivedcard_count,ai_active?, and both needs-you counts (needs_you_count, the web's three-type sum, andneeds_you_two_type, the mobile count, ADR 0005), plus the user's personalstarred?(RE395) and per-board pushmuted?(RE406) — isRelay.Cards.list_board_summaries/1(RE376), the one definition both the web boards home (RelayWeb.BoardsLive) and the native board switcher read. Its rows come inBoards.list_boards_for_display/1order (starred first, then A–Z), so both surfaces agree on order and flag by construction. The switcher's list isGET /api/all/boardson the/api/alluser-token scope (AllController.boards,RelayWeb.Api.BoardListJSON), returning{data: [{name, slug, key, needs_you_count, stage_count, card_count, ai_active, starred, muted}]}in display order, withneeds_you_count= the two-type count andstarred/mutedbooleans. The switcher's star isPOST /api/all/boards/:slug/star(RE396,AllController.star) — body{starred: <bool>}, which sets (never toggles) the caller's own star throughBoards.set_starred/3and answers{data: {slug, starred}}; a repeat is a 200 no-op. A non-member or unknown slug is a 404not_found; a missing or non-booleanstarredis a 422invalid_request(validated before the membership lookup). The client never re-sorts — it re-readsGET /api/all/boardsfor the server's order. The switcher's mute isPOST /api/all/boards/:slug/mute(RE406,AllController.mute) — body{muted: <bool>}, which sets (never toggles) the caller's own mute throughBoards.set_muted/3and answers{data: {slug, muted}}; a repeat is a 200 no-op. A non-member or unknown slug is a 404not_found; a missing or non-booleanmutedis a 422invalid_request(validated before the membership lookup). Muting never re-sorts the list. Mute is native-only: the web boards home (/boards) has no mute control, though the mute is still enforced server-side. Card search isRelay.Cards.search/3(RE198) — the one definition of what matches a query: the exact ref (RLY-12,rly-12, or a bare12) ranked first, then whitespace-token-AND, case-insensitiveILIKEmatches ontitlein board order, with%/_escaped so a wildcard query cannot return the board. Board-scoped (a ref can never resolve across boards, the same authorization checkget_card_by_ref/2is), Done included, archived opt-in via:archived, capped by:limit, and carryinglist_cards/1's trimmed projection. Three surfaces call it and none re-implements it:GET /api/cards?q=(withlimit/archived; noqleaves that listing exactly as it was),./relay search, and the board header's results popover. -
Members — board membership; who can see and act on a board.
member_user_id?/2is the one membership query (also the card-owner ruleRelay.Cardsenforces). -
Presence (
Relay.Presence) — who is looking at a board's story map right now, and where their pointer is (RE257); the app's firstPhoenix.Presencecontext, supervised directly afterPhoenix.PubSub. Two board-scoped topics it owns outright:story_map_presence:<board_id>(the roster, via Phoenix.Presence's diff protocol) andstory_map_cursor:<board_id>(the ephemeral cursor stream). Neither goes throughRelay.Events— that bumps the board version on every call, so a 20 Hz cursor would make the CLI refetch the whole board on every mouse twitch;Relay.PresenceTestpins the version as unchanged after a track, a cursor and a view write. Tracked by user id, so one person with three tabs is one avatar; tracking happens only forlive_action == :story_map(the kanban board renders no presence UI), and untracking is the tracked pid's exit. Humans only — no AI/agent avatar and no agent cursor. -
Accounts — users, Google sign-in and Sign in with Apple.
GoogleTokenValidatorverifies native Google ID tokens;AppleTokenValidatorverifies iOS Apple identity tokens locally against Apple's JWKS (RS256 pinned, theniss/aud/exp, a SHA-256 nonce bound to the raw nonce the app sends, and a verified email). They back the two native endpointsPOST /api/auth/native/googleandPOST /api/auth/native/apple, which share one sign-in tail (same session cookie and{success, user, token, feedback_url, billing}body);GET /api/auth/native/mereturns the same shape.feedback_urlisRELAY_FEEDBACK_URL(nullwhen unset or blank), read only viaRelayWeb.Feedback— the native Settings "Suggest an idea" row and the web account menu's "Suggest an idea" link both use it (RE397).billing(RE466) isRelay.Billing.native_status/2, besideuserrather than inside it:{"enabled": bool, "standing": "trial"|"active"|"past_due"|"cancelled"|"comped"|"paused"|null, "through": "YYYY-MM-DD"|null}. It feeds the native Settings ACCOUNT › Plan row (status only, no purchase UI).throughistrial_ends_onfor a trial,paid_throughwhile active or past due,access_untilonce cancelled, andnullwhen comped or paused. Billing disabled sends{"enabled": false, "standing": null, "through": null}. A 401/422/500 body has nobilling. Every provider sign-in goes throughupsert_user_from_provider/2, which looks the user up byprovider_uidfirst, then by normalized verified email (signing in that existing user and keeping itsprovider/provider_uid), and inserts only when neither matches; a nil profile claim never overwrites a stored value. User API tokens for/api/all(the native feed, card actions, card create, the board list, and the board star).get_user_by_email/1is a case-insensitive lookup (the example-board seeders, RE444). The superadmin-only/admin/userspage (RE353) readsRelay.Billing.list_admin_users/1(RE463 moved its row query out of Accounts, which no longer has an admin user list). That page's header carries theUsers | Promo codes | Renewalstab bar (RelayWeb.AdminComponents.user_tabs/1, RE462/RE463); the Promo codes tab is/admin/users/promo-codesand the Renewals tab/admin/users/renewals(see Billing). When billing is on (Relay.Billing.enabled?/0), the insert branch ofupsert_user_from_provider/2starts the new user's 30-day trial (Relay.Billing.ensure_trial/2, RE460) and then redeems the sign-up link'sopts[:promo_code](Relay.Billing.redeem_code/3, RE462; a refusal is ignored, so sign-up never fails over a code, and a returning user never redeems one) — so Accounts depends on Billing, never the reverse.upsert_user_from_google/2passes:promo_codethrough. -
Billing (
Relay.Billing, RE460, ADR 0012) — a board owner pays one monthly price (price_cents/0,:billing_price_cents, default 2000) up front from their own billing date, after a 30-day no-card trial. A board's standing is its owner's (boards.owner_id). OwnsSchemas.BillingAccount(one per user),Schemas.BillingPaymentAttemptandSchemas.BillingNotice(one per email sent, RE464). Standing is derived, never stored:Relay.Billing.Standing.of/3(pure) maps an account, the factsfirst_charge_declined?(≥ 1:failedattempt andpaid_throughnil) andnext_charge_free?(never paid and the next charge is $0 — a 100% promo code, RE462) and today to:trial | :active | :comped | {:past_due, until} | {:cancelled, access_until} | {:paused, reason}— in order: no account or no dates →:trial(pre-backfill);held_on→{:paused, :hold};comped→:comped;cancelled_on→ cancelled throughaccess_until, then{:paused, :cancelled}; never paid →:trialthroughtrial_ends_on(and after it while a card is on file — or the next charge is free — and the first charge was not declined), else{:paused, :trial_ended}; paid →:activethroughpaid_through,{:past_due, paid_through + grace_days()}for the 1-day grace, then{:paused, :unpaid}.Relay.Billing.Periods(pure) is the billing-date arithmetic (renews_on/3clamps a day past the month's end to its last day;period/2,next_renewal/1,kept_billing_day/2, andrenewal_after/3— the period startnperiods on, for the promo's "full price from" date). One off switch,enabled?/0(:billing_enabledviaRelay.Config;BILLING_ENABLEDplus both Stripe keys inconfig/runtime.exs, or dev alone on the Local adapter): off, no trial is started andboard_paused?/1/board_notice/1are always false / nil — a self-hosted install never bills.board_paused?/1takes a board or a board id and costs the account read plus oneexists?only for a never-paid account.ensure_trial/2is get-or-create (on_conflict: :nothingonuser_id, then anupdate_all … where is_nil(trial_ends_on)), so it never moves a date.cancel/2keeps access throughtrial_ends_on/paid_through;resume/2clears the cancellation (billing date unchanged); both broadcast{:billing_changed, user_id}onbilling:<user_id>. Depends on Config, Mailer, Repo and Schemas only — Accounts and Runs call in, so it never reaches Accounts, Boards, Cards or Runs. Emails (RE464):Relay.Billing.Notifierbuilds the trial-ending, card-declined and plan-paused emails (plain text + minimal escaped HTML, each linking tobilling_url/0=:app_url+/account/billing, neverRelayWeb),deliver/1s them throughRelay.Maileronly while:mail_enabled(viaRelay.Config) istrue— otherwise it logs what it would have sent — anddispatch/1runs a delivery underRelay.Billing.TaskSupervisor(inline in test), never raising into the caller.notify_due/1, the clock's last step, sends each email at most once per account and period: it inserts abilling_noticesrow (on_conflict: :nothingon thebilling_notices_onceunique index) outside any transaction and dispatches only when the insert created the row. Trial ending (periodtrial_ends_on) — a never-paid, uncomped, unheld, uncancelled account with no card on file, fromtrial_notice_days_before/0(2) days before its trial ends through its last day; card declined (periodperiod_start) — a:failedclock attempt for a period begun within the lastgrace_days/0(its retry shares the period; a card-saved decline sends nothing); paused —{:paused, :trial_ended}(periodtrial_ends_on + 1) or{:paused, :unpaid}(periodpaid_through + 1), candidates pre-filtered in SQL beforeStanding.of/3. Nothing for cancel, hold or comp; receipts stay with Stripe. Notices are recorded even with mail off, so enabling a provider never sends a backlog. Each notice is isolated (rescued and logged). Payments seam:Relay.Billing.Paymentsis a behaviour (create_setup_checkout/3,fetch_setup/1,charge/5,find_payment/3) resolved byimpl/0(:billing_paymentsviaRelay.Config):Payments.Stripe—Reqagainstapi.stripe.com/v1, bearer:stripe_secret_keyread at call time,{:error, :not_configured}before any HTTP with no key,:stripe_req_optionsmerged in (test'sReq.Testplug), never retried — orPayments.Local, the dev/test stand-in (cardspm_local_4242succeeds,pm_local_0341declines,pm_local_timeouttimes out; its checkout is/dev/billing-checkout). No Stripe Billing subscriptions: our clock makes off-session charges. The charging path (Relay.Billing.Charging, called only fromRelay.Billing): (1) tx 1 locks the account rowFOR UPDATE, re-derives the decision on it and inserts a:pendingattempt (the one-open-per-account index refuses a second) atnext_charge/1's amount, recording thepromo_code/promo_percent_offit applied; (2) outside any transaction,Payments.impl().charge/5under"billing-<account_id>-<period_start>-<attempt_number>"— a $0 attempt skips Stripe and settles:succeededwith no payment intent or receipt; (3) tx 2 re-locks account + attempt and writes the answer only while the attempt is open. Success →:succeeded,paid_through = period_end,billing_daykept (a:returnedcharge also clears a cancellation; an attempt that carried a code decrementspromo_months_left, floored at 0, in the same update — so a decline or an unknown never spends a month); a bank decline →:failed, account untouched; anything else (processing, timeout, 5xx, a raise after tx 1) →:unknown, account untouched, never resent blind. The clock decision charges only a card on file (or an account whose next charge is $0, which needs no card) that is not held, comped, cancelled, paused or holding an open attempt: a never-paid account the day after its trial (:trial_end, a fresh month from that day) unless its first charge was declined; on the grace day (paid_through + 1) the:renewalonce, then one retry on the day's final pass. The card-saved decision (complete_setup/3, after storing the card): past due →:renewalnow; paused for:trial_ended/:unpaid/:cancelled→:returned, a fresh month from today; anything else → no charge.run_clock/2(driven hourly byRelay.Billing.Clock) backfills a trial for every user without one, resolves every:unknownattempt and every:pendingone older thanCharging.pending_overdue_minutes/0(10) throughfind_payment/3, charges each due account in isolation, then notifies (Notifier.notify_due/1), answering%{backfilled:, resolved:, charged:, notified:}; a no-op answering zeros while disabled.setup_checkout_url/2starts Checkout (setup mode) and stores the new Stripe customer;handle_webhook_event/1completes a setup from the verified Stripe webhook;summary/2is the Billing page's read (standing, trial days left, first charge, renewal, card, succeeded payments newest first — $0 ones included, with no receipt —amount_cents= the next charge,full_price_cents, andpromo: nil without a code, else%{code, percent_off, months_left, full_price_from},full_price_frombeing the first full-price charge date —renewal_after/3from the first charge (trial) or the renewal (active), nil with no months left or in any other standing). Promo codes (RE462):Schemas.PromoCode(codestored trimmed and upper-cased, unique, a DB check enforces upper-case;percent_off1–100;months≥ 1; optionalmax_redemptionsandexpires_on;disabled_at, never cast).billing_accountsgainspromo_code_id(the one code the account ever redeemed,on_delete: :restrict) andpromo_months_left;billing_payment_attemptsgainspromo_code/promo_percent_off(what the attempt applied) and its amount check relaxes to>= 0for a 100% code's $0 attempt.redeem_code/3runsensure_trial/2, then in one transaction locks the account row and then the code rowFOR UPDATE(always account, then code — so two redemptions can't over-take a cap) and refuses inpromo_refusals/0's order, first match wins: the account already holds a code (:already_redeemed), no such or a blank code (:unknown),disabled_atset (:disabled), today afterexpires_on(:expired; the expiry day redeems),max_redemptionsaccounts already holding it (:full). Success storespromo_code_id+promo_months_left = monthsand broadcasts{:billing_changed, user_id}; a refusal writes nothing. "Used" is counted frombilling_accounts.promo_code_id, never denormalised. Superadmin functions:list_promo_codes/0(newest first, each withused, one aggregate-subquery query),change_promo_code/2,create_promo_code/1,disable_promo_code/2(stops new redemptions; keeps an earlierdisabled_at; never touches accounts holding the code). The amount isnext_charge/1(viadiscounted_cents/2:price × (100 − percent) / 100, half-up to the cent) — the one function the charging path andsummary/2both call; a disabled code still discounts an account that holds it.native_status/2is the native app's read (%{enabled, standing: Standing.kind/1, through}, served on the Accounts native auth body, RE466), short-circuiting to%{enabled: false, standing: nil, through: nil}with no query while disabled. Admin read model (RE463):list_admin_users/1is every user for/admin/users, newest first (inserted_at desc, id desc), in one query — aggregate subqueries forboard_count(memberships),owned_board_countandmember_count(the distinct people on the boards a user owns, the owner included, invites excluded), a left-joined account + promo code, and a:failed-attempt subquery for the declined fact — with standing derived in memory (Charging.facts/2, the pure half offacts/1, thenStanding.of/3), never per user. Each row addsplan(admin_plan/2::member_onlyfor a user who owns no boards, elseStanding.kind/1; the closed set isadmin_plans/0),paid_through_shown(the trial end for a trial, elsepaid_through),rate_cents(next_charge/1, 0 when comped) andcode_label("RELAYCONF 1/3"— months used / months); a member-only row has none of the three.admin_summary/1(pure) is the page's summary line: users, paying (paying_plans/0= active + past due),mrr_cents(their rates summed) and in trial;format_dollars/1renders it ($10,$12.50,$1,250).RelayWeb.Admin.UsersLiveshows them as the Members / Plan (RelayWeb.AdminComponents.plan_badge/1, which owns the plan → badge class/label map) / Paid through / Rate / Code columns, a snapshot at mount re-read after each admin write. Admin writes (RE463):admin_comp/3,admin_uncomp/3,admin_hold/4,admin_lift_hold/3,admin_extend/5andadmin_retry_charge/3aredefdelegates to the internalRelay.Billing.Admin(not exported). The actor is the signed-in superadmin's%Schemas.User{}, handed in by the web layer (Billing never calls Accounts). Each write runsensure_trial/2, then one transaction that locks the accountFOR UPDATE, checks it, changes it and writes exactly oneSchemas.BillingAdminEntrythrough the one private writer, then broadcasts{:billing_changed, user_id}after commit; a refusal (admin_refusals/0) writes and broadcasts nothing. Comp / uncomp flipcomped(no stored state: un-comping letsStanding.of/3derive the rest — paused until they pay); hold setsheld_on+ the trimmedhold_reason(required) —{:paused, :hold}, never charged; lift clears both; extend adds 1..admin_extend_max_days/0(365) days topaid_throughwith a required reason, billing day untouched, no attempt. Retry charge now is allowed only whenadmin_retryable?/2(a card on file, and past due or paused for anything but a hold) holds on a fresh read, and charges throughCharging.admin_retry/2— the card-saved decision, parameterised by source, recorded as an:adminattempt (Schemas.BillingPaymentAttempt.sources/0); Charging owns its own transactions, so the:retry_chargeentry is written after it returns. An:admindecline sends no card-declined email and never spends the clock's grace-day retry (both count only:clockattempts).Schemas.BillingAdminEntry(billing_admin_entries, append-only):billing_account_id(on_delete: :delete_all),actor_id(on_delete: :nilify_all),action(actions/0),reason,days,paid_through_before/_after,inserted_at.admin_detail/2is the side panel's read (nil for an unknown user): the facts,plan,card(%{brand_label, last4}),code_label,retryable?,stripe_url(stripe_customer_url/1— the/test/dashboard for ansk_test_key), every attempt newest first with itsattempt_outcome/1(the one status → outcome map, closed setattempt_outcomes/0, inverted byattempt_statuses_for/1), and every entry newest first with the actor's email.RelayWeb.Admin.UsersLiveopens it at/admin/users?user=<id>(a row click patches there) as a right-hand panel with the actions (data-confirmon each), hold / extend forms, payment history (RelayWeb.AdminComponents.outcome_badge/1) and admin history; refusal copy lives only inUsersLive.refusal_message/1. On the web:RelayWeb.BillingLive(/account/billing, the avatar menu's Billing item — both only while enabled; the route 404s otherwise) renderssummary/2byRelay.Billing.Standing.kind/1(Standingis exported for that), starts the trial a pre-backfill user lacks, redirects Add card / Replace / Update card tosetup_checkout_url/2and completes the?setup=<id>return against the signed-in user. It previews the next charge (the trial's first charge, or the active plan's#billing-next-charge) atsummary/2's discountedamount_cents, with the code's terms and the date the full price returns, and always shows a Have a code? form ("apply_code"→redeem_code/3; a refusal renders inline as the field error, its copy defined once inBillingLive.refusal_message/1). A sign-up link/?code=RELAYCONFis stashed byPageController.home/2(signed out only; trimmed, ≤ 64 bytes) in the session as:signup_promo_code;AuthController.callback/2reads that key — neverparams["code"], which there is Google's authorization code — beforeAuth.log_in_user/3renews the session, and hands it toAccounts.upsert_user_from_google/2./dev/loginredeems the same stash for the dev user (dev/test only). The superadmin's Promo codes tab (RelayWeb.Admin.PromoCodesLive,/admin/users/promo-codesin the:adminlive_session, RE462) drives the admin functions: a table oflist_promo_codes/0(Used asused / caporused / —, Expires ornever), New code ("new_code"→ achange_promo_code/2form,"validate","create"→create_promo_code/1) and Disable ("disable"→disable_promo_code/2atDateTime.utc_now(:second)); the list is a plain assign re-read after each write — no stream, no PubSub. The Renewals tab (RelayWeb.Admin.RenewalsLive,/admin/users/renewalsin the same live_session, RE463) is read-only — the clock is automatic, so there is no "run billing" button: a stream ofrenewal_log/1— every payment attempt across accounts, newest first (inserted_at desc, id desc), at mostrenewal_log_limit/0(500) rows, in one query (attempt joined to its account and user) — each row the date (resolved_at's, elseinserted_at's — the same helper asadmin_detail/2's attempts), the user, the period, the amount, the promo code text the attempt recorded, itsattempt_outcome/1and the Stripe PaymentIntent id.?outcome=(matched againstattempt_outcomes/0's names, never turned into an atom; anything else means all) filters to the statuses inattempt_statuses_for/1, a patch that re-streams withreset: true.BoardLivereadsboard_notice/1into its read-only mode — a paused board is the archived board's read-only mode (read_only?guard,#read-only-bannerwith billing copy) plus no card opening and no Board settings link (BoardSettingsLiveredirects back onboard_paused?/1); past due shows#billing-past-due-bannerto the owner only, the board staying editable. Embedded in the native app (@embed, RE466) both banners keep their copy but drop the Update card link (#billing-paused-update-card,#billing-past-due-update-card): the app carries no purchase UI and no link to outside payment. -
ApiKeys — per-board agent credentials for the
/apiscope: any number of named keys per board (typically one per machine), each authenticating as Relay AI on its board.list_keys/1(oldest first), board-scopedget_key!/2,create_key/3(blank name →Key N),rename/2,regenerate/1,revoke/1,authenticate/1(RE361). -
Activity — the card timeline: comments, activity entries, and runner log rows.
Activity.LogSinkbatches ref-tagged runner lines into one insert per burst;Activity.Prunerages:actionchatter out after 14 days (RLY-112). Transition rows (:moved,:approved,:rejected) carryfrom_stage_id/to_stage_idinmetabeside the display-name snapshots (RE146) — the names are for the timeline, the ids forRelay.ValueStream; theBackfillActivityStageIdsmigration stamped older rows and deleted those whose names no longer resolved to exactly one stage.add_comment/2takes optionalimage_ids(RE427): ids of unlinked image attachments on the card, linked to the new comment in the given order inside one transaction (≤Schemas.Comment.max_images/0; with images the body may be blank; a foreign, non-image, already-linked or malformed id is"must be images uploaded to this card"on:imagesand nothing persists). Every comment it returns or broadcasts — and every comment fromlist_timeline/1/list_conversation/1— has:imagespreloaded in position order; the{:timeline_appended, …}broadcast is sent only after the comment and its links commit. It also takes an optional programmatic:origin(RE428) —nil|:rejection|{:answer, n}— stored ascomments.origin(Ecto.EnumoverSchemas.Comment.origins/0, the one definition) +comments.origin_question(set only for:answer, ≥ 1; the changeset errors on:origin_questionotherwise). Neither is ever cast from input. A comment's programmatickindis:comment(default) |:question|:changes_requested|:steer(RE358 — a note steering an in-flight agent node).Schemas.Comment.guidance_kinds/0([:comment, :changes_requested, :steer]) is the one definition of which user comment kinds count as live guidance for the agent working the card;Relay.Runs.guidance_for/2reads it. A:steercomment carries its receipt incomments.delivered_execution_id(belongs_to :delivered_execution, Schemas.NodeExecution, nilified if the execution goes; programmatic, never cast): the node execution whose agent job was claimed carrying the steer in its guidance. Only:steercomments ever have it set, and the first delivery wins. -
AgentLog — stateless live relay of runner feed lines to the board's log sheet (subscribe-only; no server buffer, no backfill — RLY-55).
-
Events — the realtime seam: contexts broadcast semantic domain events after each successful mutation (never controllers/LiveViews), so LiveView and REST mutations share one notification path. See runtime.md for the topic/event vocabulary.
-
BoardWatch — per-board monotonic version counter in ETS; bumped on every
Events.broadcast/2, polled by the CLI to cheaply detect change (RLY-12). -
Attachments — file uploads onto cards (images, and since RE370 self-contained HTML mockups; 5 MB cap), served same-origin by
AttachmentControllerat/attachments/:id(Schemas.Attachment.path/1is the one definition of that path — domain-side soRelay.Cardscan parse it).Schemas.Attachmentalso ownsimage_types/0andmockup_types/0(RE390 — what a mockup may be;./relay'sMOCKUP_TYPESis pinned to it byrunner_contract.json'smockups.content_types).Relay.Attachments.content_types/1maps every attachment on a card to its content type in one query — the web layer's only way to learn content types (to render an image mockup differently from an HTML one). A REST API key reads the same bytes at the bearer-authedGET /api/attachments/:id(RelayWeb.Api.AttachmentController, RE373; pathSchemas.Attachment.api_path/1), scoped byRelay.Attachments.get_attachment_for_board/2to the key's board — 404 for anything off-board — sharingAttachmentController.put_content_headers/2and servedprivateas a download. It is how./relay mockups REF --pullhands mockups to the next agent on a card. Note images (RE427): an image may belong to a Note —attachments.comment_id(nullable FK,on_delete: :nilify_all) andattachments.position(its index in the note; upload timestamps are second-precision, so they can't order it) link it to the comment, andSchemas.Commenthas_many :images. An image is uploaded unlinked the moment it lands and linked only byRelay.Activity.add_comment/2; uploads whose note never posts stay unlinked (no cleanup yet). BoardLive has three image uploads sharing ONE helper set and one set of limits (allow_image_upload/2,check_new_images/2,reset_images/2, … keyed by upload name)::note_images(the Notes composer) and (RE428):reject_images(the Request-changes note) and:answer_images(the needs-input answer, per question). Each isauto_uploadwith aprogress:callback →Relay.Attachments.create_attachment/2on the open card, appended to@note_images_pending/@reject_images_pending/@answer_images_pending— the last a map from the 0-based stepper step (the single answer box is step 0) to that question's list, so each question has its own cap; the box'sphx-change(validate_comment/review_reject_change/answer_custom/answer_change) enforces the per-note cap against the pending ones (LiveView'smax_entriesonly counts entries in flight) and refuses bad files by name into@note_image_errors/@reject_image_errors/@answer_image_errors.post_commentpasses the pending ids asimage_ids;review_rejectpasses them toRelay.Cards.reject/4(image_ids:), which posts them as oneorigin: :rejectionnote atomically with the rejection; Send to AI (answer_submit/answer_input) passesimage_notes: [{question, ids}]toRelay.Cards.answer_input/4, which posts oneorigin: :answernote per question with images, atomically with the answer. All refuse to submit while an entry is in flight, and the stepper also holds its step (Back / Next / an option click commit nothing) so a finishing upload belongs to the question on screen. The control isCoreComponents.image_attach_box/1+image_attach_button/1+image_attach_hint/1(drop target, ⌘V.ImagePastehook) — desktop web only: below thedrawer:breakpoint and in the native embed it is view-only. A box with alink_target(the reject note,#review-reject-images→#review-request-note; the answer,#needs-input-images→#needs-input-text/#needs-input-answer) keeps the GitHub-style links: the server pushesimage_link_placeholder%{box, placeholder}when a file is accepted,image_link_done%{box, placeholder, markdown}once stored (markdown=RelayWeb.image_markdown/2over the absoluteRelayWeb.attachment_url/1),image_link_failed%{box, placeholder}on a failed store or a cancelled upload, andimage_link_remove%{box, markdown}on ✕; the hook edits the textarea and dispatchesinput, so the form'sphx-changestores the text server-side. The Notes list tags an image note's origin withCoreComponents.note_origin_tag/1(FROM ANSWER · Q<n>/FROM REJECTION). The policy values live once:Schemas.Comment.max_images/0(6),Schemas.Attachment.max_bytes/0(the 5 MB cap) andSchemas.Attachment.image_type_names/0(display names ofimage_types/0, same order). On the card API every comment timeline entry carriesimages: [%{filename, url, download_path}](url=/attachments/<id>,download_path=/api/attachments/<id>;[]when none) and (RE428)origin—null|{"kind": "answer", "question": n}|{"kind": "rejection"}, always present — the key sets and the origin kinds (origin_kinds) are pinned underrunner_contract.json'snote_images;./relay cardprints the origin after the author (· from answer Q2/· from rejection). Images are served under the app-wide CSP. HTML gets its own branch: the response's CSP is replaced withAttachmentController.html_csp/0—sandbox allow-scripts; default-src 'none'; script-src 'unsafe-inline'; style-src 'unsafe-inline' https://fonts.googleapis.com; img-src data:; font-src data: https://fonts.gstatic.com; form-action 'none'; frame-ancestors 'self'— plusnosniffandReferrer-Policy: no-referrer. Threat model: noallow-same-origin(opaque origin — no cookies, no parent access;_relay_keyisSameSite=Lax), no network except the Google Fonts style/font hosts (default-src 'none'; RE372 widens onlystyle-srcandfont-src, viaAttachmentController.google_fonts_style_origin/0andgoogle_fonts_font_origin/0), no top navigation/popups/forms. Privacy cost (accepted, RE372): opening a mockup that links Google Fonts sends a request to Google, exposing the viewer's IP and the font URL — no referrer, cookie or card content. Residual risk is same-domain phishing, mitigated by never showing a mockup as a bare top-level page from Relay's UI and byframe-ancestors 'self'; a separate user-content origin is a documented follow-up. The sandbox token list isRelayWeb.mockup_sandbox/0, shared with every mockup<iframe sandbox>. -
Push — APNs notifications, dispatched off-caller via a
Task.Supervisorso a status change never waits on Apple (RLY-81). A member'sboard_members.muted(RE406) suppresses APNs for that board only — the browser broadcast and the app-icon badge count are unaffected. -
Votes — public upvotes (RLY-69): a unique
(card_id, user_id)row;toggle_vote/2toggles and broadcasts{:vote_changed, card_id}. A card's supporters are the voting users. -
ValueStream (
Relay.ValueStream, RE146) — the level-1 value-stream derivation behind the card stream map (RE347). Read-only: no processes, no PubSub, no writes — it folds history that already exists (transition rows'from_stage_id/to_stage_id,:needs_input→:input_answeredparks,node_executions).stream_states/1derives the ordered states from the board (stream start = the last queue main stage before the first work/planning main stage, through the terminal stage, minus any AI-enabled (derived from flows) work/planning stage no enabled flow works in, such as RE'sDeploy; kindskinds/0= queue / flow / gate / done).card_stream/1returns one card's spans with a per-span baton split (batons/0: agent = union of the card's node executions inside an AI-enabled (derived from flows) flow stage, human = parks and gates, nobody = the rest), plus lead time, value-add (agent time on:donodes perSchemas.Flow.node_roles/1), flow efficiency, cost and per-gate approve/reject counts. Invariant: spans tile[started_at, done_at]—Σ span.secs == lead_secsand every span'sagent + human + nobody == secs.stream_summary/2averages over the last N (default 20) or aRuns.metric_window_since/1window of done cards (archived included) so per-state means plusoutside_secssum to the mean lead time. RE347 adds per-spancost(executions attributed bystarted_at∈[entered_at, left_at), soΣ span.cost ≤ cost), per-statemean_first_secs/mean_cost/wip/rework_target(a gate's nearest:flowstate before it) in bothcard_stream/1(n = 1, plusoutside_secs) andstream_summary/2, the summary'scards_per_week, anddecision_types/0. View (RE347):RelayWeb.ValueStreamLiveat/board/:slug/value-streamrenders it.?card=<ref>opens one card's stream (linked from the card drawer's Run tab).- Without it, the page shows the board average, the third Board | Story map | Value stream segment.
- State lives in the URL (
card,scope,window). - Geometry is the pure
RelayWeb.ValueStreamLayout, and rendering isRelayWeb.ValueStreamComponents, shared with level 2 (RE349). - A flow box drills into level 2 with the same
card/scope/windowand keeps a secondary "Flow metrics →" link to that flow's Flow Metrics page. - Both maps scroll sideways inside an
overflow-x:autocontainer at every width — there is no phone list (RE349).
Level 2 (RE349):
RelayWeb.ValueStreamFlowLiveat/board/:slug/value-stream/:flow_keyrenders one flow'sflow_stream/2— every node on one line in execution order, coloured bySchemas.Flow.node_roles/1(Do / Check / Fix), fixes above the line, send-back and re-entry arcs sized by minutes (thefinal_fix → precommitREWIND), the VERIFY BLOCK frames, the foreach loop, the queue triangle (Runs.first_node_queue_wait/2), the done / ⏸ needs_input terminals, the aligned ladder, a two-band run lead-time bar and six tiles. Geometry is derived from the flow graph alone by the pureRelayWeb.ValueStreamFlowLayout(any flow drills); URL state is shared with level 1 throughRelayWeb.ValueStreamParams; Last N scopes the roll-up withcard_ids:fromdone_card_ids/2. An unknown flow key redirects to level 1 with a flash. Realtime:Relay.Runs.subscribe/1, recomputing on{:run_changed, card_id}.flow_agent_secs/3reconciles agent time withRuns.node_metrics_for_flow/2(equal when a card's executions don't overlap, smaller when they do).flow_stream/2(RE348) is level 2 — one flow'snode_metrics_for_flow/2rows plus its send-backs (afailedexecution followed by a different node, grouped byfrom → to → returns_towithlaps,to_secs,rewind_secs,secs) and its foreach N and per-copy spread, all from one read ofRuns.execution_spans_for_flow/2, the data behind the Code flow map (RE349). RE349 addsdone_card_ids/2— the ids of the done cardsstream_summary/2averages, the ONE "last N" selector level 2 passes ascard_ids:— and, onflow_stream/2,queue_wait,done_runs,parked_runs(an execution ended inNodeExecution.holding_outcomes/0or the run is parked on:needs_input),first_pass_runs(no failed:checkexecution) and the foreach'sclean_copies. Approximations: flows (and so AI-enabled stages) and node roles are read as they are now. -
StoryMap (
Relay.StoryMap) — the board's second lens (RE265), orthogonal to stages:Schemas.StoryActivity(big user goals, left to right),Schemas.StoryStep(the backbone, ordered within an activity;board_iddenormalized so every read is one board-scopedwhere), andSchemas.Release(the swimlanes — a new axis orthogonal to stage; every board is seeded withSchemas.Release.seed_names/0byBoards.create_board/2, every pre-existing board by thebackfill_story_map_releasesmigration, which carries the one deliberately frozen copy of that list). Cards carry three nilable FKs —story_activity_id,story_step_id,release_id— plus (RE262)story_map_position, all cast only throughSchemas.Card.story_map_changeset/2, starting fully UNMAPPED, with release independent of activity/step. A setstory_step_idimplies the matchingstory_activity_id, enforced by derivation inassign_card/2(the step supplies its activity; a conflicting one passed alongside is ignored), byupdate_step/2(moving a step to another activity rewrites its mapped cards'story_activity_idin the same transaction), and by the changeset as a backstop. Deleting structure unmaps cards, never deletes them (cards → structureisnilify_all,activity → its stepsisdelete_all). Deleting is refused outright ({:error, :not_empty}) while any non-archived card still points at the structure (RE261) — checked in the delete's own transaction, so the cascade above is now only reachable for an already-empty structure.move_step/3is the single step-repositioning entry point (activity change + renumber, one transaction, one broadcast);insert_before/3is the pure "remove and re-insert before the target" ordering rule every header drop shares. Structure writes broadcast{:story_map_changed, board_id}; assignment reuses{:card_upserted, card}viaCards.notify_upserted/1.Relay.Boardsdeliberately does not depend on this context —StoryMap → Cards → Boardsalready exists, so the reverse edge would close a boundary cycle; the release seed therefore lives inBoards. View (RE264, RE263): the backbone × releases grid at/board/:slug/story-map, a:story_maplive_action onRelayWeb.BoardLiverather than its own LiveView — clicking a card must open the card drawer in place, and the drawer's ~50 assigns and ~40 event handlers live inBoardLive; a separate LiveView could only honour that by duplicating them or extracting the drawer's whole state machine. The grid itself is isolated: the pure, unit-testedRelayWeb.StoryMapGrid.build/7((activities, steps, releases, cards, draft, hide_steps?, collapsed)→ bands, columns, lanes, cells, unmapped — every card accounted for exactly once in one of three places: acellsentry, the tray, or thecountof exactly one collapsed stub column; an activity-less card in the tray, a release-less mapped card in the LAST lane, a release-less board in one synthetic(No release)lane) plusRelayWeb.StoryMapComponentsfor the render.RelayWeb.CoreComponents.board_view_tabs/1is the Board ↔ Story map switch. Create (RE263): three affordances — a trailing+add-activity column, a per-activity+ Add step, and an+ Releaserow — all committing through oneinline_name_input/1. Which one is open is the single:story_map_draftassign onBoardLive(nil | :activity | :release | {:step, activity_id}, one draft at a time board-wide), and it is thedraftargument tobuild/7: the draft materializes a"draft:<activity_id>"column that carries nocells, and every column gainsbare?/draft?. Names are trimmed and capped bySchemas.StoryActivity.max_name_length/0— the one definition, shared byStoryStepandRelease, so an over-long paste is an error changeset rather than a Postgrex 22001 crash. On an archived board the four write events are refused and the affordances are not rendered (read_only, the same attr name and behaviour as the stage column's compose+). Assignment (RE262): the map is writable — drag a card between cells, drag it out of or onto the UNMAPPED tray, and an inline+per cell that creates a real board card inBoards.intake_stage/1(the board's first top-level stage by position — the one definition of "where a card created outside a column lands") and places it.StoryMap.assign_card/2takes an optional 0-based:positionand renumbers the whole target DB cell1..nin one transaction;unassign_card/1nils the position with the three FKs, so a card in the tray has no story-map position.RelayWeb.StoryMapGrid.decode_placement/2parses the column/lane keys that same module defines, and theStoryMapDnDhook (assets/js/hooks/story_map_dnd.js) pushes{ref, column, lane, index}— no optimistic client move, so a second tab follows for free. Edit (RE261): the structure is editable in place — click a name to rename (one open rename page-wide, the:story_map_edit/:story_map_edit_nameassigns, mutually exclusive with the create draft, and a blank name cancels rather than writes); a ✕ per structure that isdisabledand greyed while it still holds cards, its tooltip naming the countRelayWeb.StoryMapGridrenders (columns[].countjoins the existingbands[].countandlanes[].count, so display and guard come from one number) and the board's last release showing no ✕ at all; and drag-reorder by header — theStoryMapDnDhook gains a second draggable kind (.story-map-header[data-kind][data-id], dropping on.story-map-header-drop) that pushesstory_map_reorderwith ids only, andBoardLivecomputes the order withStoryMap.insert_before/3and writes throughreorder_activities/2,move_step/3orreorder_releases/2. Reordering releases moves the last-lane fallback with it, which is a display move only — no storedrelease_idchanges. Every new event joins theread_only?guard list and none of the affordances render on an archived board. Zoom (RE260): two view-only chrome controls, both keys of the shared view below — they change grid geometry, and RE257's raw-pixel cursors are measured in it, so viewers who disagree see each other's cursor over the wrong card.:story_map_zoomis the closed setRelayWeb.StoryMapComponents.zoom_levels/0(:map|:compact|:full, defaulting to:compact) parsed off the wire byparse_zoom/1; it reaches only the renderer, which sizes the card face — Map is a title-only chip, Full adds the meta row and progress bar.:story_map_hide_stepsis the sixth argument tobuild/7: it collapses each activity's step columns into one merged"m:<activity_id>"column (the fourth column-key shapedecode_placement/2parses, alongside"s:","ns:"and"draft:"). Dropping a card into a merged column keeps itsstory_step_idwhen that step still belongs to the target activity — a purely vertical drag changes release only and must not silently unset the step; the activity is then derived from the step byStoryMap.resolve_placement/2. Filter & focus (RE259): the artboard's filter bar plus two view-narrowing controls, four more keys of the shared view below.RelayWeb.StoryMapFilteris the pure model besideStoryMapGrid: it owns the owner-key wire format ("agent","u:<user_id>") exactly once, builds the bar's chip set (every owner of a card on this map union every selected key, so a selection stays clearable after its last card moves away), and answers the predicate — owner and Needs-input compose with AND, an empty selection means every owner, and a card passes when any of its owners is selected. "Needs input" is strictlyRelay.Cards.needs_input?/1, the one definition that theNEEDS YOUbadge also reads. Filtering is a pre-pass inBoardLive: the grid is built from the visible cards, so no placement rule knows filtering exists and every count the map already draws narrows with it. Hide complete (RE276): an eighth key, and the first defaulting to ON — the map opens showing only incomplete cards. It is a third AND term inStoryMapFilter.visible/4, aMapSetof stage ids to exclude;BoardLiveresolves what "complete" means at the pre-pass (Relay.Boards.top_level_done_stage_ids/1— any top-level:completestage, deliberately NOTCards.done?/2, which is the terminal stage's:readycards and drives the strikethrough) so the filter module stays pure.Cleartherefore resets to the board's DEFAULTS viaStoryMap.filter_keys/0, not to "everything off", andStoryMap.filters_active?/1is the one answer to "is a filter on" (RelayWeb.StoryMapFilter.active?/2is gone). Collapse and focus reach the grid as one MapSet throughRelayWeb.StoryMapGrid.collapsed_set/3— focus IS a collapse of everything else, and the focused activity is never in the set, so no stored state can blank the map; an unresolvable focus id is no focus (resolve_focus/2). A collapsed activity contributes one"c:<activity_id>"stub column and no band, anddecode_placement/2has no"c:"clause, so a stub can never be a CARD drop target — it stays a RE261 header-reorder source and target, as the artboard's stub does. The map's no-card-can-disappear invariant becomes a three-way partition — cells, tray, or exactly one stub's count — that sums tototal. Like zoom and the tray, none of the seven events is in theread_only?guard list: view state is not board data. Shared view (RE257): the map's view settings are board-wide, stored in theboards.story_map_viewjsonb column and written only throughmerge_view/2, whichput_view/3,toggle_view/2andtoggle_view_member/4all compose.view_defaults/0is the one definition of the key set (the eight keys instate.md),view/1merges it under the stored map dropping unknown keys, and every writer refuses a key outside the set with{:error, :unknown_key}and re-read the row before merging so a concurrent write is not clobbered.toggle_view/2exists so a boolean flip is computed on the committed row: a LiveView's assign only catches up when it handles the broadcast, which lands behind any click already queued, so two fast clicks onput_view(…, not assign)would make one toggle. It broadcasts{:story_map_view_changed, board_id, view}onstory_map_view:<board_id>— again outsideRelay.Events, same no-version-bump rule. There is deliberately no optimistic local assign: the clicker re-renders from the same broadcast everyone else does, so viewers cannot disagree.story_map_draft/story_map_draft_name/story_map_composestay per-tab (RE263) — sharing them would hand one person the ability to clear another's in-progress input. -
Talk (
Relay.Talk, RE268 / ADR 0009 — not yet merged tomain) — a person-driven execution lane, orthogonal to the flow engine: a person types in a card's terminal pane, one turn becomes anode_jobsrow withkind: :talkand no run, claimed through the same long-poll every flow node uses. Three tables:Schemas.TalkSession(one per card —claude_session_id,pinned_runner_name, the seed,last_event_seq/cleared_through_seq),Schemas.TalkTurn(one per human message,queued → claimed → done | stopped | failed;queued → claimedis written by the claim endpoint viaTalk.mark_claimed/1, so the claim path itself needs no knowledge of turns —Relay.Runsknows only that a job can carrykind: :talk, viainsert_talk_job!/3,revoke_talk_job/1,finish_talk_job!/1and thetalk_capable?/1floor on the claim),Schemas.TalkEvent(one per rendered transcript line, append-only). The pin:finish_turn/3records the claiming runner's name onto the session on:doneonly — a:stoppedor:failedturn never finished, so it cannot vouch for the session id or the holder, and leaves both unset;post_message/3copies it onto the next job'srunner_name, soRuns.claim_next_job/1needs no Talk knowledge — the pin rides the same column an exclusive run's pin already uses. Ordering isseq, never a timestamp —append_events/2assigns it inside one transaction locking the session row, so two concurrent batches can't collide; delivery is at-least-once and(talk_turn_id, client_seq)is unique, so a replayed batch stores and broadcasts nothing new.clear/1hides scrollback by bumpingcleared_through_seq; it deletes no row. Broadcasts oncard:<card_id>:talk:{:talk_event, event}and{:talk_turn_changed, turn}. Known step-1 limitation: a turn whose runner dies staysclaimed— the orphan reaper deliberately ignores talk jobs;stop_turn/1revokes unconditionally so a person can always end it. Not here in step 1: receipts,awaitingturns, the write lease, card-level runner pinning — a talk turn never moves the card's baton. -
Scaffold — the five Relay-owned files (
./relay, the threerelay-*skills andrelay.md) the board serves at/api/scaffold,relaywith the board's URL baked into itsBOARD_URLline (RE443), built intopriv/scaffold/bymix relay.build_scaffold(RE304, ADR 0010). -
Setup (
Relay.Setup, RE443) — a board's setup checklist, the guided path from "board created from a template" to "an agent picked up the first card". Owns the ONE step catalog (steps/0, in order 1–5, 5.1–5.8, 6, 7:id,parent,title,done_title,actor—actors/0=:you_here | :you_terminal | :ai_terminal—why,what_this_does,read_only_note; strings carry%{template}/%{llm}/%{skill_dir}/%{skill_path}/%{pointer_file}placeholders that onlychecklist/1resolves) and the onboarding LLMs (onboarding_llms/0,onboarding_llm/1:harness/labelread fromRelay.Agents.seed_harnesses/0, pluscli,blurb,install,skill_dir,skill_path,pointer_file,recommended?,enabled?— Gemini CLI is disabled;skill_pathis derived fromskill_dir, and Claude Code'sskill_dirisRelay.Scaffold.skill_dir/0). It is also the ONE Elixir definition of therelay.mdpointer line (pointer_line/0) and of the default-LLM switch flash (llm_switch_notice/2, RE457: the./relay update --skill-dir … --pointer-file …command, or nil when the skill dir is unchanged). Storage is oneSchemas.BoardSetupSteprow per(board, step);start/2inserts all 15 (step 1 done with"<N> stages · <M> flows · HH:MM"and the template's name andwarning, step 2 active) fromBoards.create_board/2on the template path only, so a board created without a template — or before RE443 — has no checklist (checklist/1is nil).checklist/1returns%{board_id, template, template_warning, llm, current_id, current_sub_id, first_flow_stage, first_card_stage, position, done_count, finished?, total, closed?, callout, started_at, finished_at, elapsed_minutes, steps}(the 7 top-level entries; step 5's inchildren). The payoff keys (RE446) come from the rows alone, sopreview_checklist/2carries them too:totalis the top-level step count (7),started_atstep 1'scompleted_at,finished_atstep 7's once it is finished,elapsed_minutesthe whole minutes between them (nil unless both are set),closed?whether step 7'sdata["closed_at"]is set, andcallout—%{card_ref, card_id, machine, llm, verb},llmthe checklist LLM's label — only while step 7 is done with a claimed card and itsdata["callout_dismissed_at"]is absent;current/1is its first unfinished entry,current_sub_id(RE445) the first unfinished child of that entry (nil when it has none) — derived from finished-ness, so a board that reached step 5 before RE445 still reports 5.1 — andfirst_flow_stagethe NAME of the lowest-position top-level stage holding a flow, andfirst_card_stagethe NAME of the stage that flow pulls its cards from (Schemas.Stage.neighbours/2, whichRelay.Flows.neighbours/2delegates to; both are worked out from ONE stages query, also forlist_for_admin/1).reportable_id/1(current_sub_id || current_id) is the one step a report may move.advance/4(:waiting | :done | :failed | :skipped,advance_statuses/0) moves only that step ({:error, :not_current | :no_checklist | :invalid_status}) — a parent with children is never advanced directly: activating it activates its first child, finishing a child activates the next, and finishing the last finishes the parent ("onboarded HH:MM") and activates the next top-level step.datamerges at the top level only. It is serialized by locking the board row, a no-op on a repeat that changes nothing on the reportable step or the one walked just before it (an older finished step is:not_current), and broadcasts{:setup_changed, board_id}once per change. Step 5's sub-stepdata(RE445): 5.3answers(interview_questions/0id => answer —done,landing,deploy,baton,ui,never_touch,warm_up,capacity,misfits), 5.4changes([%{summary, decision, note}],decisioninchange_decisions/0), 5.5pushed(flow keys), 5.6pr_url/base, andattemptson any failed sub-step. Two sub-steps are board-observed:Relay.Flowscallsobserve_flow_push/2after a committedupsert_from_document/3(while 5.5 is current it records the key and finishes 5.5 —"N flows enabled"— once every board flow has been pushed and is enabled, else waits on"P of N flows pushed"), andRelay.Cardscallsobserve_card_created/1after a committedcreate_card/3(while 5.8 is current, a card infirst_card_stage,first_flow_stageor one of its substages finishes 5.8 —"first card in <stage>"— and with it step 5). Both always return:ok, so setup state never fails a push or a create. Steps 6 and 7 are board-observed the same way (RE446):RelayWeb.Api.NodeJobController.heartbeat/2callsobserve_heartbeat/2with the authedSchemas.ApiKeyand the post-upsertSchemas.Runner(while step 6 is reportable on the key's board it finishes —"Runner online","heartbeat HH:MM · <machine>"then" · <cli> logged_in|logged_out|installed, …"per installed harness,<machine>the runner's host else its name;datahost,runner,harnesses[%{cli, signed_in}]), andRelay.Runs.claim_next_job/4callsobserve_claim/2with the grantedSchemas.NodeJobafter its transaction commits (while step 7 is reportable on the job's board it finishes —"First card claimed","<REF> claimed HH:MM by <runner>"— and with it the checklist;datacard_ref,card_id,runner,machineandverb, the claimed card's stage category at claim time::planning→planning,:in_progress→building, elseworking on). Both return:okwhatever the setup state, and the no-checklist path is one cheapexists?query.close/1({:error, :not_finished | :no_checklist}) stamps step 7'scallout_dismissed_atandclosed_at;reopen/1clearsclosed_atonly, so the callout never returns. Both write step 7'sdatadirectly under the board lock (never throughadvance/4) and broadcast{:setup_changed, board_id}on a change.choose_llm/3(step 2) sets the board default agent to the chosen harness (keeping a default already on it, else reusing the board's first agent on it, else creating one on its first model) and mints the step-3 key"Setup · <user>"viaRelay.ApiKeys;remint_key/2mints a fresh one, revoking the old only if it was never used. Both return{:ok, %{checklist, token}}— the raw token exists only there; step 3'sdataholdskey_id. Step 3 finishes either on the key's first authed call (below) or, first, on the user's say-so:key_saved/1("I've saved it — next", RE455) advances it:donewith"saved HH:MM · confirmed when the install script calls in"anddata.saved, activating step 4.key_unconfirmed?/1(a checklist) is true exactly while step 4 is current and step 3 is done, saved and has nodata.machine— the ONE reading of those keys, which gates "Lost it? Mint a new key". In that stateremint_key/2also succeeds: under the board lock it reopens step 3 (:active, evidence andcompleted_atcleared,saveddropped), puts step 4 back to:pending(reached_atcleared), revokes the unused key, mints a fresh one and broadcasts once. Once step 3 is confirmed (data.machineset by the first authed call or a preflight — RE456's peek)remint_key/2re-keys in place: no step's status, evidence or timestamps move, only step 3'skey_id(and a machine'sapi_key_id) points at the new key; it is{:error, :not_current}only while step 3 is pending.switch_llm/3(RE456, board scope) changes the default LLM after step 2 finished ({:error, :not_finished}before): it makes the harness the default agent aschoose_llm/3does and rewrites step 2'sdata.llm(so its title and the checklist'sllmfollow), never minting a key or moving a step; the same harness is a no-op, a change broadcasts{:setup_changed, board_id}once. Every board or machine checklist carrieskey_hint—%{token_prefix, last_four}of step 3's key, nil before one or once it was revoked — so the wizard can show the masked key without a DB call.install_command/2builds step 4'scurl … /b/<slug>/install | sh.step_id/1(:llm | :key | :install | :onboard | :interview | :customize | :runner | :first_claim) names the catalog id of the steps a surface renders specially, soRelayWeb.SetupComponents(the wizard panel, chip and phones' read-only checklistRelayWeb.BoardLiverenders, plus RE446's payoff callout and "You're set up" box) never re-types one. BoardLive shows the chip and panel while the checklist isn'tclosed?, but swaps the columns for the read-only checklist on phones only while it is unfinished. The install handshake reports throughpreflight/2andinstalled/2, each taking the authenticatedSchemas.ApiKey(board preloaded) and the script's JSON:preflight/2checks the key's board owns the request'sslug(:wrong_board) and that step 2 is done (:step_2_pending), ticks step 3 ("first authed call HH:MM · key …<last_four> · <machine>",data.machine) — or, when step 3 was saved but is unconfirmed, confirms it in step 4's transaction (rewriting its evidence to that same form and mergingdata.machine, leaving it:doneand itscompleted_atas is) — and marks step 4:waiting("preflight passed HH:MM · <machine>",data.checks) or:failed(the first failure'sheadlineaserror, pluschecks/headline/fix/fix_commandindata); it answers the board name, the onboarding LLM,relay_files(Relay.Scaffold.items/1over the LLM'sskill_dir, RE457) and every check with its fix text.preflight_checks/0is the ONE list of the script's local check ids and labels (git_repo,remote,default_branch,python3), whose fix texts live beside it; the server adds thecliandkeylines.installed/2finishes step 4 ("downloaded HH:MM · <repo_dir> · <branch>",data.files). Steps already past are left alone, so a re-run answers 200 and writes nothing; a board with no checklist answers from its default agent's LLM.list_for_admin/0,1(RE448) returns one progress row per board with a checklist, newest board first, from a constant two queries (steps joined to their board and left-joined to its owner, plus every board's stages, each flagged when it holds a flow, for its first flow and first card stage; each checklist built bypreview_checklist/2):board_id,name,slug,owner_email,inserted_at,template,llm(step 2's label, nil before),current(id/title/status/actor— step 5's first current sub-step while on step 5, step 7 once finished),on_stepandtotal(seconds),last(%{kind: :error | :evidence, text}— the latest failed step's error, else evidence),stuck?,stateand the fullchecklist.stuck_after/0is the ONE stuck threshold (7200 s;stuck?= unfinished andon_stepstrictly greater) andadmin_states/0the ONE closed set of row states ([:in_progress, :stuck, :done], a partition). They back the superadmin-only/admin/onboardingpage (RelayWeb.Admin.OnboardingLive);list_for_adminis an unscoped read (the board's own rows only), and the gate is the/adminroute. Connect a machine (RE446) is the same checklist run per person on their own machine, stored as aSchemas.SetupMachinewith its ownboard_setup_stepsrows (machine_idset; the board's own rows have it nil, and every board read filters on that — one privaterows_query/1).connect_machine/2({:error, :no_checklist | :step_2_pending}) mints the member's key"Setup · <name>", inserts the machine and its 15 rows —connect_skips/0(the ONE list:1,2,5.2–5.5,5.7,5.8,7):skipped(step 1"already set up", step 2"board-wide · already chosen"), step 3:activewithdata.key_id, the rest pending — and broadcasts once. A connect checklist carriesconnect?: trueand itsmachine_id(bothfalse/ nil on the board's): each catalog step'sconnect_title/connect_whyreplace its titles / why (%{board}is the board's name), step 7 reads"board-wide · <REF> claimed <D Mon>"or"board-wide · waiting for the first claim"from the board's own step 7, and the tally covers steps 1–6 (total6,position == done_count,finished_atstep 6's completion, neverclosed?, nocallout). Every catalog step also has ashortlabel ("install", …).connect_checklist/2is the user's latest unfinished machine's checklist, else an unsaved preview;machine_checklist/1reads one machine's.target_for_key/1routes a board key to the machine minted for it, else its board, andpreflight/2,installed/2,observe_heartbeat/2andGET /api/setup/POST /api/setup/steps/:stepall go through it — a machine key never touches the board's rows nor a board key a machine's, and a machine's preflight is never:step_2_pending. On a machine, step 3's evidence is"minted for <name> · first authed call HH:MM · key …<four> · <machine>"(step 3'sdata.machineis the preflight's machine on both), step 6's tick also writeshost/runner_nameto thesetup_machinesrow, andchecklist/1,advance/4andremint_key/2take the machine as a target (remint_key/2also movesmachine.api_key_id).machines/1lists every setup on a board for Board settings → Setup — the board's own first (once its step 3 has a key), then each machine oldest first:id(:boardor the machine id),machine,person,key_last_four(nil once revoked),harnesses([%{cli, signed_in}]from the step-6 runner's row),finished?andprogress("7/7"/"6/6", else"<current id> <short>").project_answers/1is 5.3's answered interview questions in order ([%{id, label, answer}]). Both feedBoardSettingsLive's Setup section (?section=setup, only on a board with a checklist, else General): the status line, Reopen setup wizard (reopen/1, then back to the board), the answers, the machines table and Connect a machine, a link to/board/:slug?setup=connect—BoardLive's connect mode, which shows the member'sconnect_checklist/2in the panel whatever the board'sclosed?, never mints on mount, and routes "Mint my key" toconnect_machine/2(thenremint_key/2on that machine). Depends on Agents, ApiKeys, Events and Repo, never on Boards, Flows or Cards (all three depend on it). -
Mailer (
Relay.Mailer, RE464) — the Swoosh mailer, a leaf boundary: Local adapter in dev (/dev/mailbox), Test in test, Resend in prod onceRESEND_API_KEY+MAIL_FROMare set. Called only throughRelay.Billing.Notifier.deliver/1. -
Markdown, Repo, Config — rendering, Ecto plumbing, and
Relay.Config: theProcessTreeseam (cache: false) through which the values tests vary (:apple_client_ids,:runs_auto_start,GIT_SHA, and billing's:billing_enabled,:billing_payments,:billing_price_cents,:stripe_secret_key,:stripe_webhook_secret, and billing email's:mail_enabled) are read, so a test overrides them withProcess.put/2instead of mutating app/OS env (RE419, ADR 0009 rule 1).
Core schemas
erDiagram
User ||--o{ Board : owns
Board ||--o{ Stage : has
Stage |o--o{ Stage : "parent / sublanes"
Stage ||--o{ Card : holds
Board ||--o{ Card : has
Board ||--o{ Flow : "flow definitions"
Stage ||--o| Flow : "works in (one flow per stage; pickup / drop-off derived)"
Flow ||--o{ FlowVersion : "immutable version snapshots"
Card ||--o{ SubTask : has
Card ||--o{ CardDependency : "blocked by (RE93)"
Card ||--o{ CardOwner : "owners (user or agent)"
Card ||--o{ Comment : timeline
Card ||--o{ Activity : timeline
Card ||--o{ Attachment : has
Comment ||--o{ Attachment : images
Card ||--o| CardRejection : "embeds (CHANGES REQUESTED)"
Card ||--o{ Run : "flow traversals"
Flow |o--o{ Run : "live definition (nilified on delete)"
Run ||--o{ NodeExecution : "per-attempt history"
Run ||--o{ RunBranch : "fan-out branches (RE437)"
NodeExecution ||--o{ RunBranch : "fan_out entry opens"
RunBranch |o--o{ NodeExecution : "run_branch_id (nilified on delete)"
NodeExecution ||--o| NodeJob : "dispatch unit"
Board ||--o{ Runner : "registered runners"
Board ||--o{ Membership : has
Board ||--o{ StoryActivity : "story map activities"
Board ||--o{ StoryStep : "story map steps"
StoryActivity ||--o{ StoryStep : "backbone (cascade delete)"
Board ||--o{ Release : "story map swimlanes"
StoryActivity |o--o{ Card : "story_activity_id (nilified on delete)"
StoryStep |o--o{ Card : "story_step_id (nilified on delete)"
Release |o--o{ Card : "release_id (nilified on delete)"
User ||--o{ Membership : has
Board ||--o{ ApiKey : "agent credentials"
Board ||--o{ Harness : "agent CLIs (RE433)"
Board ||--o{ Agent : "named LLMs (RE433)"
Harness ||--o{ Agent : "runs on"
Board |o--o| Agent : "default_agent_id"
Board ||--o{ BoardSetupStep : "setup checklist (RE443)"
Board ||--o{ SetupMachine : "connected machines (RE446)"
SetupMachine ||--o{ BoardSetupStep : "machine checklist (RE446)"
User ||--o{ UserApiToken : "mobile bearer"
User ||--o{ DeviceToken : "push"
User ||--o| BillingAccount : "pays (RE460)"
BillingAccount ||--o{ BillingPaymentAttempt : "charges"
BillingAccount ||--o{ BillingNotice : "emails (RE464)"
Card ||--o{ Vote : upvotes
User ||--o{ Vote : cast
Card ||--o| TalkSession : "one Talk session"
TalkSession ||--o{ TalkTurn : "one per human message"
TalkTurn ||--o{ TalkEvent : "transcript lines"
TalkTurn |o--o| NodeJob : "kind: talk (no run)"
A Harness is an agent CLI (command template + closed models list) and an Agent is a named
harness + model; flow nodes name an agent in their llm and boards.default_agent_id is the
fallback (RE433, Relay.Agents).
A BillingAccount (billing_accounts, owned by Relay.Billing, RE460) is one user's billing
facts, unique on user_id (cascade on user delete): trial_ends_on (the trial's last day),
paid_through (the only grant of paid access), billing_day (check 1..31), comped, held_on
/ hold_reason, cancelled_on / access_until, the Stripe customer and the saved card
(card_payment_method_id, brand, last 4, expiry). A BillingPaymentAttempt
(billing_payment_attempts, cascade on account delete) is one charge, committed before Stripe is
called: status / kind / source (Ecto.Enums owned by statuses/0, kinds/0,
sources/0), amount_cents (check > 0), the period it pays for, a unique idempotency_key,
paid_through_before, Stripe's ids and the outcome. At most one attempt per account is open
(open_statuses/0 = pending / unknown), enforced by the partial unique index
billing_payment_attempts_one_open_per_account. A BillingNotice (billing_notices, cascade on
account delete, RE464) is one billing email sent: kind (Schemas.BillingNotice.kinds/0) and
period_key, unique per account by the index billing_notices_once, inserted before the email
goes out so each is sent at most once per period.
A BoardSetupStep (board_setup_steps, owned by Relay.Setup, RE443) is one step of a board's
setup checklist — or, with machine_id set (RE446), of one connected machine's: the board's own
rows are unique on (board_id, step) WHERE machine_id IS NULL, a machine's on
(machine_id, step) WHERE machine_id IS NOT NULL (cascade on board and machine delete), with status
(Schemas.BoardSetupStep.statuses/0; current_statuses/0 = active / waiting / failed,
finished_statuses/0 = done / skipped), reached_at, completed_at, evidence, error and a
data map — step 7's carries the first claim's facts plus the payoff state
(callout_dismissed_at, closed_at, ISO 8601, RE446). users.setup_explanations_hidden (Relay.Accounts.set_setup_explanations_hidden/2,
cast only by Schemas.User.preferences_changeset/2) is the per-user preference to hide the
checklist's what this does lines.
A SetupMachine (setup_machines, owned by Relay.Setup, RE446) is one person's machine
connected to a board: board_id (cascade on board delete), user_id and api_key_id (the
machine's step-3 key; both nilified on delete, so revoking the key leaves the machine), and the
host / runner_name its step-6 heartbeat recorded. Its checklist is its board_setup_steps
rows. Every field is programmatic, never cast.
A RunBranch (run_branches, owned by Relay.Runs, RE437) is one branch of one fan-out
region entry: it belongs to its Run (cascade) and to the fan_out NodeExecution that
opened the region (fan_out_execution_id, cascade), unique on that execution plus branch (the
fan-out edge's to), so a region re-entered by a foreach lap or a loop-back gets fresh rows. It
carries position, state (Schemas.RunBranch.states/0), the current_node in flight, and the
outcome / detail the fan_in joins. A NodeExecution run inside a branch points back with
the nullable node_executions.run_branch_id (nilified when the row is deleted); control-node
executions and every execution outside a region leave it nil.
Relay.Runs.list_runs_for_card/1 preloads the rows for the run view
(runtime.md).
A Stage may point at a parent (sub-lanes like Spec:Review) and a reject_to_stage
(where a rejection sends the card). Scope (not shown) is the per-request authorization
context threaded through web and API entry points.
A Card additionally carries an optional story-map placement — story_activity_id,
story_step_id and release_id, all nilable, all nilified rather than cascaded when the
structure they point at is deleted. Release is a new axis orthogonal to stage: a card has
both.
A Card also carries story_map_position: its order within its story-map cell, nullable
and independent of cards.position (its order within its stage column). The two orderings never
affect each other — dragging on the map rewrites one, dragging on the board the other. nil
means nobody has ordered the card on the map yet, and nil sorts last inside a cell. Placing a
card renumbers its whole target cell, and the siblings are renumbered with a bare
update_all so their updated_at is untouched — that column is the recency proxy behind the
Done column's render window and the needs-you feed, both board-lens orderings that would
otherwise be silently reshuffled by a drag on the map.
A Card may also be blocked by other cards on the same board (RE93). Each edge is a
CardDependency row (card_id = the dependent, depends_on_card_id = the blocker), cascading
on both FKs and unique on the pair. A blocker counts as satisfied when it sits in a
top-level :complete-category stage — Relay.Boards.top_level_done_stage_ids/1, the same one
definition the board's Done treatment and the API's index filter use; the blocker's status is
irrelevant. Relay.Cards.unmet_dependencies/2 is the single "is this card blocked" read — its
predicate is one private query that Relay.Cards.list_scheduler_cards/2 (the scheduler
snapshot's one-query card projection, RE402) shares for each card's blocked_by — and
Relay.Runs.Policy.pullable?/1 is where it gates: a card with any unmet blocker is skipped by
the scheduler's fresh pulls and by the board's "queued for X" face chip, together. It gates
fresh pulls only, and two starts are named carve-outs on pullable?/1's doc:
Policy.resumable?/2 (a dependency added mid-run must not strand a run in flight) and the run
listener's rejection re-entry (a send-back resumes the loop on a card an agent already worked).
Both continue work in flight rather than pull a fresh card, and gating either would strand the
card, because nothing re-reconciles a dependent when its blocker later reaches Done — the
listener reacts to the blocker's event, not the dependent's. Human moves are never blocked. Archiving a card
deletes the rows pointing AT it, permanently freeing its dependents; its own outgoing rows
survive.
Sources of truth: lib/relay.ex (exports), lib/schemas/*.ex, ADRs 0002–0004, 0006.