Board 3.0: the API as a flow, not a listSUPPORTING MATERIAL
REFERENCE SHELF
Your guided curriculum
SUPPORTING MATERIALGUIDED READING

Board 3.0: the API as a flow, not a list

Bruno, 2026-09-27 21:29 UTC: "I think it would help to have like a form of a tree when it comes to building these api steps for specs. because having it as a list feels linear and its not, there is a split between if it hits the cache or not ... think from a blank point regarding what we want. a multi tree based decision for api spec fits better and might be easier to do actual simulations on." And 21:3x: "it should be a lil more helpful than just click plus on a branch ... board 3.0 because this is completely new. And I expect completely new thought-out code. But keep good quality."

What the reader does

  1. Lists what the API uses (resources), as in 2.x.
  2. Adds an endpoint (method, path). Its flow starts as one empty slot.
  3. Fills slots. A slot offers the steps that make sense there, in the reader's resource names ("read Link cache", "save in Links table if the name is free", "check the expiry", "answer"). Picking a step that can go two ways opens both ways at once, each with a question that makes the reader think: after "read Link cache", "Found in Link cache: then what?" and "Not in Link cache: then what?".
  4. Every way ends in an answer the reader picks (status, words, no-store on a redirect). A way with no ending says so, in red, and the simulations name it when a person walks into it.
  5. Steps that happen after the answer without making the person wait (send the click to Click queue) hang off the answer as "then, without waiting".

The model (state.api.version = 3)

api = { version: 3, resources: [{ id, name, kind }], endpoints: [Endpoint], bind: {} }
Endpoint = { id, method, path, flow: Node | null }          EVENT: { ..., from, by }
Node =
  | { id, kind: "step", do, by, to?, what?, every?, next: Node | null }        one way on
  | { id, kind: "fork", do, by, to?, check?, what?, every?, ways: { [way]: Node | null } }
  | { id, kind: "answer", status, text, noStore?, then: Step[] }             an ending
  | { id, kind: "done", then: Step[] }                                        EVENT ending

forks and their ways:
  read (a store or a cache)      found | missing
  write-if-absent                saved | taken
  check expiry                   valid | expired
  check owner                    owner | not-owner
  check deny-list                allowed | blocked
plain steps: write, delete, send, call (wait or not)

A flow is a tree: no step is reachable two ways, so every path is a sentence the simulation can tell. Ids are stable across edits. Limits cap depth and size so a stored or model-written flow cannot blow up the page.

Placing it on the design (unchanged idea)

bind, binding, the arrows each step needs, the path a card lights, and "draw the missing arrows" work as in 2.x, over every step of the tree.

Running it

The simulations walk the tree: at a fork, the resource models decide the way (found or missing, saved or taken, expired or valid...), and the person gets the answer at the end of that way. A way with no ending is the scene's wrong moment ("Leo opened “launch”; it wasn't in Link cache, and your GET has no answer for that way"). Scenes, stories and the pop-up keep their contract (SCENES-CONTRACT.md), and each frame also names the node, so the pop-up lights the exact way the person took on the flow.

Moving over

A 2.x spec (steps with on: hit|miss, answers as a list) converts into a flow once, when a 3.0 board opens it: reads become forks, on steps go into the right way, answer steps become endings (their response picked from the endpoint's answers by what that way means). What cannot be placed is kept as a note on the endpoint rather than dropped. 2.x boards stay openable in 2.x.

Code

New modules, not edits of 2.x: flow.js (the model: normalise, forks and their ways, validation, the words for a node, conversion from 2.x), flow-run.js (the interpreter; the resource models move into resources-sim.js, shared), flow-view.js + flow.css (the editor), with the scenes, placement and pop-up adapted to a tree. Tested as 2.x was: each check seen red, pictures looked at on Mac Chrome's widths.

The contract, as built (flow.js, base commit flow3-base)

Decided while writing it, and final unless Iris changes it:

  • by is on the endpoint, not on each step. One code resource runs an endpoint's flow ("API service runs GET /{code}"); every node is done by it. A call to other code is a step (do: "call"). An EVENT keeps from (the queue or stream) and by (the code that takes the delivery).
  • No done node. A request's ways must each end in an answer; an EVENT's ways may simply end (nobody is waiting). answer nodes are refused in an EVENT flow.
  • When a node splits (forkWhen): always for a check and a save-if-free (they decide); usual for a read (it opens found/missing when added, and the reader may join it back into one way with setSplit); asked for a write, a delete and a waited call (one way on, until the reader asks "what if it fails?", ways ok/failed); never for a send and a call without waiting.
  • Ways, the one that carries on first (waysOf): read found|missing; write-if-absent saved|taken; check expiry valid|expired, owner owner|not-owner, deny-list allowed|blocked, signed-in signed-in|signed-out, rate-limit under|over, valid-input fine|invalid; write/delete/call ok|failed. Inserting a node above a subtree puts the subtree in the new node's first way (a step's next).
  • The resources each verb can reach (USES): read, write, write-if-absent, delete a database or a cache; send a queue, a stream or code; call code or an outside service; the deny list is read from a database or a cache; a rate limit may keep its counts in a cache or a database. The editor offers only these; flowLines says wrong-kind for anything else a model or a stored board wrote.
  • Then-steps hang off an answer: plain steps (write, delete, send, call), at most 4, run after the answer without delaying it.
state.api = { version: 3, resources: [{ id, name, kind }], endpoints: [Endpoint], bind: {} }
Endpoint  = { id, method, path, by, flow, note? }            EVENT also: from
Node      = { id, kind: "step", do, check?, to?, what?, wait?, every?, next }
          | { id, kind: "fork", do, check?, to?, what?, wait?, every?, ways: { [way]: Node | null } }
          | { id, kind: "answer", status | null, text?, noStore?, then: [{ id, do, to?, what?, wait? }] }

What flow.js gives everyone (all pure; the editing ones return a new API and never change the one they were given):

shape normalizeFlowApi, isFlowSpec, LIMITS, waysOf, forkWhen, USES
walking walkFlow, nodesOf, findNode, openEnds
words flowNames, nodeWords, answerWords, wayLabel, wayQuestion ("Not in Link cache: then what?"), endpointWords
the spec alone flowLines(ep, api) → { head, nodes: Map(id → { words, status, problem }), ends: [{ parent, way, question, problem }], missing }
on the design needsArrow, flowDiagramLines, flowDiagramPath(ep, api, model, binding, walk?), missingFlowArrows; placement is api.js's bindingOf, unchanged
editing insertNode(api, slot, spec), removeNode(api, id, keep?), updateNode, setSplit, copyWay, addThen, moveThen, addEndpoint, updateEndpoint, makeNode, emptyFlowApi
heavy test asSteps(api): the same API as 2.x step lists (on: hit|miss from a cache read's ways)

A slot is { ep, parent: null, way: "flow" }, { ep, parent: stepId, way: "next" } or { ep, parent: forkId, way }.

Placeholders in the base, each owned by one helper: flow-run.js (runFlowTests), flow-convert.js (flowFromSteps, toFlowApi), flow-render.js (flowHtml, read-only). The test fixture test/flow-specs.mjs has the good design written as flows on the same resources as api-specs.mjs's good.

The runner's result for a flow

As 2.x ({ id, title, source, status, trace, why, story }, SCENES-CONTRACT.md), plus: every frame carries node (the node it is at, or null) and way (the way a fork took, or null); did may also be "stuck", the frame where a request walks into a way with no answer; and story.walks is one entry per request: { who, call, at, nodes: [ids in order], end: nodeId | null }, end null when it got no answer.