The Meta Level

The Spine — generic architecture, and how to reseed it

LFTI is a small general-purpose node graph editor with a participatory education seed planted in it. Which parts are spine, which are seed, and how to replant the essence elsewhere.

Written 2026-08-12, from the state of the codebase at that date.

LFTI is not really an app about participatory education. It is a small general-purpose node graph editor — the spine — with a participatory education seed planted in it. Everything domain-specific grew from that seed. The point of this note is to name which parts are the spine and which are the seed, so the same essence can be replanted in another domain without re-deriving it.


1. What the spine actually is

The generic layer is small. Roughly 460 lines of server and a few thousand of client:

Piece File What it gives you
Node CRUD api/nodes.js one table, one shape, recursive delete
Storage + visibility db/database.js SQLite, relation index, role filter
Auth api/auth.js, middleware/auth.js sessions, roles
Attachments api/images.js files on any node
Views lfti.js tree / position / graph, capture, relations

Everything else — projects, cycles, phases, tools, classes, groups — is seed.

The one data structure

Every single thing in the system is a row in nodes:

id · parent_id · title · body · type · tags · created_at · updated_at · meta · relations

That is the whole schema for content. There is no projects table, no classes table, no tools table. type is a free string, meta is free-form JSON, and relations is a JSON array of { id, type, target, label }.

This is the central bet, and it is the thing worth copying. A new domain concept costs you a new type string and some meta keys — no migration, no new endpoint, no schema change. The cost is paid later, in that nothing is enforced: every invariant lives in code and can drift. Both halves of that trade showed up in LFTI and are documented in §4.

Two axes, deliberately

  • parent_id — the containment tree. Exactly one parent. This is what the left-hand tree renders and what recursive delete follows.
  • relations[] — a directed graph over the same nodes, many-to-many, typed. In LFTI: contains, involves, references, sequence, feeds, hosts.

Almost every interesting question (“what feeds this?”, “what does this reference?”) is a graph question, while navigation and lifecycle are tree questions. Keeping both, and being clear which is which, is most of the architecture. relations_index is just a denormalised copy of relations[] for querying in the other direction.

Levels — reference vs instance

One meta key does an enormous amount of work: _level.

  • _level: 'reference' — canonical, authored-once methodology. The 33 tool definitions. Read-mostly, visible to everyone, never owned by a school.
  • _level: 'instance' — a live running thing. A project, a class doing a tool on a Tuesday, a contribution.

An instance points at its reference with a references relation and copies what it needs to vary. That single distinction is what lets one body of methodology serve many cohorts without either drifting into the other. If you reseed, keep this. It is the most transferable idea in the codebase.

Visibility as a pure function

filterNodesByVisibility(nodes, user) takes the whole node set and a user and returns what they may see, by these rules in order:

  1. superadmin sees everything
  2. anyone authenticated sees _level: 'reference' or _visibility: 'public'
  3. _visibility: 'portal' is visible to any authenticated user
  4. a node whose meta.institution_id matches the user’s
  5. anything under the user’s institution by walking parent_id upward

The client then fetches the whole visible set in one call and renders from memory. That is why the app feels instant and why the tree, graph and position views are cheap — they are all just projections of one in-memory map.

The limit of that design: the client holds everything it is allowed to see, so any UI-side role switching is cosmetic. LFTI’s “view as” switcher is explicitly a preview, not a permission test, for exactly this reason.


2. What the seed is

The domain layer, in dependency order, is about 1,700 lines of server:

institutions (126)  →  projects (451)  →  tool-instances (349)
                                       →  class-instances (648)
                          token-login (80)   filter-options (69)

And a vocabulary of types layered on the generic node:

institution → container → project → cycle → session → tool_instance
                                          → class_instance → group_instance
                                                           → artefact

Plus tool (reference), class, person, block, exchange.

Nothing in the spine knows any of these words. They are conventions the domain routes agree on. You could delete every file in api/ except nodes.js, auth.js and images.js and still have a working generic outliner.

How the seed gets planted

seedLFTI() is a plain function that builds a node map, extracted from source at boot and run in a sandbox when the database is empty. The methodology comes in as data, not as schema. That is why the tools could be re-tagged wholesale (produces / consumes) via one idempotent migration script without touching a single table definition.


3. Roadmap for a new seed

If you wanted to plant a different domain in the same spine, this is the order that would have saved LFTI the most rework. Roughly the order it happened, with the mistakes taken out.

Phase 0 — decide the vocabulary before writing code. Write the type list and the relation list as a flat document. For each type: is it reference or instance? What is its parent? What does it point at? An afternoon here is worth weeks later. LFTI’s single most expensive defect — two rival “class” entities, one under the project and one under the school — is a Phase 0 omission, and it was still blocking work months later.

Phase 1 — spine only. Node CRUD, tree view, capture, relations, delete. No domain words at all. You should be able to build an arbitrary outline.

Phase 2 — auth and visibility. Roles, sessions, and the visibility function. Do this before the domain, because visibility rules shape what the domain is allowed to assume. Write the role ladder down.

Phase 3 — the reference layer. Author the canonical content as a seed function: your equivalent of the 33 tools. Mark it _level: 'reference' and _visibility: 'public'. Resist adding instance concepts here.

Phase 4 — one instance chain, end to end, for one user. Pick the single most important path — for LFTI: institution → project → phase → tool instance → group → contribution — and build it all the way through for one role. Do not build breadth. A chain that works end to end tells you more about the model than five half-built layers.

Phase 5 — the second actor. Add the role that consumes what the first produces (teacher watching students). This is where the model gets tested, because it forces the question of what rolls up and what stays local.

Phase 6 — the working surface. The page the end user actually touches, with its own affordances. LFTI got here late: group pages existed for months as bare contribution lists before becoming worksheets, so nothing could actually be run in a classroom.

Phase 7 — feedback and learning. Only once real usage exists. Anything that learns from history needs history, and history needs the working surface from Phase 6 to have been used in anger.

Three ordering lessons, dearly bought

  1. Write side before read side. LFTI rendered “in progress · step 3” and stuck-timer warnings in seven places for months while nothing ever wrote a step trace. The display was finished and the data never arrived. Build the writer first, even if it is ugly, then the readers have something true to render.
  2. Gate on the thing the user actually produces. The step-completion gate was first built against a separate step-tagged contribution, which meant nothing the user naturally did could unlock it. Moving the gate to the note in the worksheet — the thing they were already typing — made it work.
  3. A feature with no entry point does not exist. The responds_to relation, the whole cross-phase feed-forward mechanism, was fully built on both sides and had zero instances in the database because the button was somewhere nobody went.

4. Where the flat model bites

Worth knowing in advance, because all of these are consequences of the schema bet in §1 and every one of them cost real time here.

  • Nothing is enforced. meta.institution_id may be a slug ('uk') or a node id ('inst_uk_schools') depending on which code path wrote it. Every reader carries a fallback map. Pick one representation on day one.
  • Deletion cascades the tree, not the graph. Deleting a node removes its descendants and its outbound relations, but inbound relations from elsewhere survive and dangle. Sweep them or accept the debt knowingly.
  • Single parent_id forces a choice the domain may not want. A class genuinely belongs to both a school and a tool run. LFTI resolves this with class_instance as a projection of a class into a tool — which works, but is the kind of thing to decide deliberately rather than discover.
  • Seeds drift from their loader. seedLFTI() moved from the HTML into lfti.js and the loader kept reading the HTML, so a fresh database silently seeded zero nodes for months behind a console.warn. If a code path only runs on first boot, it is untested by definition — test it explicitly.
  • Docs rot faster than code. KNOWN_ISSUES.md asserted that tool-to-tool relations already existed in the graph (there were none) and that students were read-only (they had not been for some time). Design notes describing intent age badly once they start describing state.

5. The transferable essence, in one paragraph

One node table with a free-form type and JSON meta; a containment tree and a typed graph over the same nodes; a hard split between authored reference content and live instances that reference it; visibility as a pure function of node plus user; and the whole visible graph shipped to the client so views are just projections. Plant a domain in that as seed data rather than as schema, build one chain end to end before building breadth, and always build the thing that writes before the thing that displays.


← All notes · More from LFTI