Institution hierarchy, class instances and student allocation
This is a substantial structural prompt. Read the full codebase before starting — particularly the existing node structure, auth system, project wizard, tool instance view, and…
This is a substantial structural prompt. Read the full codebase before starting — particularly the existing node structure, auth system, project wizard, tool instance view, and group instance nodes. Understand what exists before changing anything.
Build and verify each phase before starting the next. Do not proceed to Phase B until Phase A is confirmed working.
OVERVIEW
This prompt does four things in sequence:
- Phase A — Restructure institution hierarchy (Root → Country → Institution) and fix person node placement
- Phase B — Class instance node type and class assignment
- Phase C — Live student allocation with role-based group limits and teacher override
- Phase D — Cross-class view at tool instance level and feedforward to Collate
PHASE A — Institution Hierarchy & Person Node Placement
Target structure
LFTI (root)
├── Reference Instance
└── Country (UK)
└── Institution (Bristol Academy)
├── Staff
│ ├── Institution Admin
│ ├── Teacher
│ └── [assigned facilitators via involves relation]
├── Students
│ └── Class (11S1)
│ ├── Tom P (person node)
│ └── Sarah K (person node)
└── Projects
└── LFTI 2026
Database migration
The existing uk and zm nodes are country groupings, not institutions.
Specific institution nodes need to sit under them.
Run a migration in db/database.js on startup (check if already run via a
migrations table):
CREATE TABLE IF NOT EXISTS migrations (
id TEXT PRIMARY KEY,
run_at TEXT NOT NULL
);
Migration: inst_hierarchy_v1
- Create
StaffandStudentscontainer nodes under each existing institution node (uk, zm, inst_lfti). These are simple nodes with type:container. - Move existing person nodes: reparent them under the correct institution’s Staff or Students container. Match by institution_id in their meta.
- Dev seed users: reparent to inst_lfti Staff container.
- UK-institution person nodes (UK School Admin, Mr J Clark): reparent to uk Staff container for now (will move to specific school when school nodes are created).
- Set
parent_idcorrectly on all person nodes — never null for a person node.
Migration: project_container_v1
- Under each institution node, create a
Projectscontainer node (type: container). - Reparent existing project nodes to sit under their institution’s Projects container.
New node types
Add to the recognised type list:
country— top-level geographic grouping (UK, NZ, Zambia)container— organisational wrapper (Staff, Students, Projects, Classes) Containers have no content of their own — they exist to organise children. In the tree, containers render without a type badge, just their title. In the content section, containers show only their children grid, no body editor.
Creating a new institution (system admin only)
System admin creates a Country node first (if it doesn’t exist), then an Institution node under it. On Institution node creation, automatically generate:
- Staff container node
- Students container node
- Projects container node
This should happen via a dedicated endpoint:
POST /api/institutions
body: { name, country_id, admin_name, admin_email }
Auth: superadmin only
Creates: institution node + 3 container nodes + institution admin person node
under Staff container
Returns: { institution_id, admin_person_id }
The institution admin person node is created with meta.invite_status: "pending".
Print their credentials to the server console (same pattern as dev seed).
Institution admin scoping
Institution admins can only create projects under their institution’s Projects
container. Enforce this in POST /api/projects:
- If role is institution_admin, verify that the target parent node is the Projects container of their institution. Reject with 403 if not.
- Superadmin can create projects anywhere.
Project wizard changes
In the project wizard Step 1, the institution field is now read-only for institution_admin role — pre-filled with their institution. Only superadmin sees a dropdown to choose any institution.
Facilitator assignment
Facilitators are person nodes that may exist at country or root level (they
work across multiple institutions). They are assigned to an institution or
project via an involves relation — not moved in the tree.
In the Participants tab of the project dashboard, when adding a facilitator:
- Search first among person nodes that have
involvesrelations to this institution or its country ancestor - Also search all person nodes with role: facilitator system-wide
- Selecting one adds an
involvesrelation from the facilitator’s person node to the project node — does not change parent_id
Verify Phase A
-
As superadmin, create a new institution “Bristol Academy” under UK. Staff, Students, Projects containers auto-generate. Institution admin person node created under Staff. Credentials printed to console.
-
Log in as the new institution admin. Project wizard Step 1 shows Bristol Academy pre-filled, not editable. Creating a project places it under Bristol Academy → Projects container.
-
Attempting to create a project under UK Schools (wrong institution) as institution_admin returns 403.
-
All existing person nodes are now under institution Staff or Students containers, not at root level.
PHASE B — Class Instance Node & Class Assignment
Class instance node
A class instance is a thin wrapper that represents a specific class doing a specific tool instance. It sits between the tool instance and the group instances.
Tool Instance (Transect Walk)
├── Class Instance (11S1 — Transect Walk) ← new node type
│ ├── Group 1
│ │ └── Contributions
│ └── Group 2
└── Class Instance (9J1 — Transect Walk)
└── Groups...
Class instance node structure:
{
"id": "ci_[timestamp]_[random]",
"type": "class_instance",
"title": "[Class name] — [Tool name]",
"parent_id": "[tool_instance_id]",
"relations": [
{ "type": "involves", "target": "[class_node_id]" },
{ "type": "contains", "target": "[group_instance_id]" }
],
"meta": {
"tool_instance_id": "[tool_instance_id]",
"class_node_id": "[class_node_id]",
"class_name": "[class name for display]",
"status": "not_started",
"allocation_locked": false,
"approval_status": "pending",
"teacher_node_id": null,
"num_groups": 3
}
}
Status values: not_started → active → submitted → approved
Group instances are now children of class_instance nodes, not tool_instance
nodes directly. Update POST /api/tool-instances and the regenerate-groups
endpoint accordingly.
Class assignment at project level
In the project wizard Step 3 (Participants), add a Classes section:
CLASSES ASSIGNED TO THIS PROJECT
[ + Assign class ]
11S1 · Mr J Clark · ~30 students [×]
9J1 · Ms Ahmed · ~25 students [×]
“+ Assign class” searches class nodes under this institution’s Students container.
Selecting a class stores it in project.meta.assigned_classes: ["class_node_id"].
When the project is created, class assignment does NOT automatically create class_instance nodes under every tool instance — that happens at the tool instance level. Project-level assignment is just a pre-selection for convenience.
Class assignment at tool instance level
In the tool instance Setup tab, add a Class Assignment section above Group Configuration:
CLASSES DOING THIS TOOL
[ + Add class ] (searches institution's classes, pre-selects project's assigned classes)
┌─────────────────────────────────────┐
│ 11S1 · Mr J Clark · 30 students │
│ Groups: 6 [configure →] [×] │
└─────────────────────────────────────┘
┌─────────────────────────────────────┐
│ 9J1 · Ms Ahmed · 25 students │
│ Groups: 5 [configure →] [×] │
└─────────────────────────────────────┘
Adding a class here creates a class_instance node as a child of the tool instance, with group_instance children based on num_groups for that class.
Removing a class (×) deletes the class_instance node and all its group_instance children. Confirm first: “This will remove all groups and contributions for 11S1 from this tool instance. Are you sure?”
“Configure →” opens the group configuration for that class (replaces the current single-class group configuration in Setup tab).
Approval at class instance level
The class instance has its own approval state. The flow is:
Group contribution (pending)
→ approved by teacher/facilitator at class instance level
→ class instance status: submitted (all groups have ≥1 approved contribution)
→ approved by facilitator at tool instance level (cross-class review)
→ feeds into phase-level Collate
In the tool instance Contributions tab, add a class filter above the group filter:
[ All classes ] [ 11S1 ] [ 9J1 ]
[ All groups ] [ Group 1 ] [ Group 2 ] ...
Class-level approval: a “Mark class as submitted” button appears in the class instance view when all groups in that class have ≥1 approved contribution. Sets class_instance.meta.status = “submitted”.
Verify Phase B
-
In project wizard, assign 11S1 and 9J1 to the project. project.meta.assigned_classes contains both class node ids.
-
In a tool instance Setup tab, both classes appear pre-selected. Adding a class creates a class_instance node under the tool instance. Each class_instance has group_instance children.
-
In the tool instance Contributions tab, class filter tabs appear. Filtering by 11S1 shows only 11S1’s contributions.
-
Removing a class from the tool instance (after confirmation) removes the class_instance and all its group_instance children from the tree.
PHASE C — Live Student Allocation
The allocation model
Students self-assign to groups using a group token on their device. Teachers can override any assignment. Teacher can lock allocation.
Authority hierarchy:
Student token → can self-assign if space available (role slots not full)
Teacher/facilitator → can assign/unassign any student, overrides student choice
Lock (teacher sets on class_instance) → freezes student self-assignment;
only teacher can move students after lock
Role-based group size limit: Group capacity = number of roles defined on the tool instance’s steps meta. If a tool instance has roles: [Navigator, Sketcher, Note-taker, Interviewer], group capacity is 4. A student trying to join a full group sees: “This group is full (4/4 roles filled).” If no roles are defined, default capacity is the num_groups divisor (class size ÷ num_groups, rounded up).
Group token system
Each group_instance gets a short unique token on creation:
function generateGroupToken() {
const chars = 'ABCDEFGHJKLMNPQRSTUVWXYZ23456789'; // no ambiguous chars
return Array.from({length: 6}, () =>
chars[Math.floor(Math.random() * chars.length)]
).join('');
}
Token stored in group_instance.meta.token.
Tokens are unique within a tool instance (check on generation).
Token login endpoint
POST /api/token-login
body: { token }
No auth required — this is the student entry point
Finds the group_instance with matching meta.token
Returns: {
group_instance_id,
group_name,
class_instance_id,
class_name,
tool_instance_id,
tool_name,
institution_name,
session_token ← a short-lived session (24hr) with role: "group_token"
}
Sets cookie: lfti_group_session=<session_token>
Add group_token as a new role to the auth system.
Group token sessions are stored in the sessions table with
meta.group_instance_id and meta.class_instance_id.
A group_token session can:
- View their class instance’s unallocated student list
- Self-assign to their group (if space and not locked)
- Add contributions to their group_instance (when allocation is complete)
- View their group’s contributions and approval status
- View class-example contributions
A group_token session cannot:
- See other groups’ contributions
- Access any other project, institution, or node
- Approve contributions
Unallocated student list
The class instance carries the authoritative allocation state:
"meta": {
...
"allocation": {
"unallocated": ["Tom P", "Sarah K", "Amara D", ...],
"locked": false
}
}
When a class is assigned to a tool instance (Phase B), the unallocated list is populated from the class node’s student person nodes (first name + last initial). If no student roster exists for the class yet, unallocated list starts empty and students can add their name when joining via token.
Unallocated list API:
GET /api/class-instances/:id/allocation
Returns: { unallocated: [...], groups: [{id, name, members, capacity}], locked: bool }
Auth: any session with access to this class_instance (group_token, facilitator+)
Polls every 10 seconds on the token login view
POST /api/class-instances/:id/assign
body: { student_name, group_instance_id }
Auth: group_token (can only assign to their own group if not locked)
facilitator+ (can assign to any group)
Validates: group not at capacity, not locked (unless facilitator+)
Moves student_name from unallocated to group_instance.meta.members
Returns: updated allocation state
POST /api/class-instances/:id/unassign
body: { student_name, group_instance_id }
Auth: group_token (can unassign from their own group if not locked)
facilitator+ (can unassign from any group, ignores lock)
Returns: updated allocation state
POST /api/class-instances/:id/lock
body: { locked: true|false }
Auth: facilitator+ only
Sets class_instance.meta.allocation.locked
Returns: { locked: bool }
Token login UI
A separate minimal page at /join — no main app chrome, no tree, no nav.
URL: http://localhost:3000/join
┌─────────────────────────────────────────┐
│ LFTI │
│ │
│ Enter your group code │
│ │
│ [ X K 7 2 A 3 ] │
│ │
│ [ Join → ] │
│ │
│ Ask your teacher for the code │
│ printed on your group's worksheet. │
│ │
└─────────────────────────────────────────┘
On successful token entry, show the group allocation view:
┌─────────────────────────────────────────┐
│ LFTI · Bristol Academy │
│ Transect Walk · 11S1 · Group 2 │
├─────────────────────────────────────────┤
│ WHO'S IN YOUR GROUP? │
│ │
│ Tap your name to join this group. │
│ Not on the list? Type your name below. │
│ │
│ UNALLOCATED (tap to join): │
│ [ Tom P ] [ Sarah K ] [ Amara D ] │
│ [ James W ] [ Priya N ] [ ... ] │
│ │
│ YOUR GROUP (Group 2): │
│ ✓ Marcus T — Navigator │
│ ✓ Yemi O — Sketcher │
│ ○ [empty] — Note-taker │
│ ○ [empty] — Interviewer │
│ │
│ [2/4 members joined] [Not locked] │
│ │
│ [ I'm done — start the activity ] │
│ (appears when ≥1 member in this group) │
└─────────────────────────────────────────┘
Tapping a name in the unallocated list calls POST /api/class-instances/:id/assign. The list updates immediately on all polling devices (10s poll).
When teacher locks: unallocated list becomes read-only on group token devices. Show: “🔒 Groups are locked. Ask your teacher if you need to change groups.”
Teacher’s allocation view (in the tool instance Groups tab, per class):
11S1 · [🔒 Lock groups] [Unlock]
UNALLOCATED (3): Tom P · Sarah K · Amara D
[Assign Tom P to: Group 1 ▾] [Mark absent]
GROUP 1 (Navigator, Sketcher, Note-taker, Interviewer)
✓ Marcus T — Navigator [move] [remove]
✓ Yemi O — Sketcher [move] [remove]
○ [empty] — Note-taker
○ [empty] — Interviewer
GROUP 2 ...
“Mark absent” removes from unallocated, sets person node
meta.absent_this_session: true. Can be reversed.
Verify Phase C
-
Create a tool instance with 2 group token codes visible. Navigate to
http://localhost:3000/joinin an incognito window. Enter the group 1 token. See the allocation view with unallocated students. -
Tap “Tom P” — Tom moves from unallocated to Group 1 on this device. Open another incognito window with the Group 2 token. Within 10 seconds, Tom P is gone from the unallocated list on Group 2’s device.
-
As facilitator in the main app, move Tom P from Group 1 to Group 2. Within 10 seconds, Group 1 token device shows Tom P gone from their group. Group 2 token device shows Tom P in their group.
-
Facilitator locks the class instance. Group token devices show the lock icon. Tapping unallocated names does nothing. Facilitator can still move students. Unlock restores self-assignment.
-
As facilitator, mark Sarah K as absent. Sarah K disappears from the unallocated list on all devices.
PHASE D — Cross-Class View & Feedforward to Collate
Tool instance cross-class view
When a tool instance has multiple class instances, the tool instance Contributions tab gains a class-level summary above the existing filters:
CLASSES 2
┌──────────────────────────────────┐ ┌──────────────────────────────────┐
│ 11S1 │ │ 9J1 │
│ 6 groups · 18 contributions │ │ 5 groups · 12 contributions │
│ ● submitted │ │ ○ in progress │
│ [View class →] │ │ [View class →] │
└──────────────────────────────────┘ └──────────────────────────────────┘
CROSS-CLASS VIEW
[ All ] [ 11S1 ] [ 9J1 ] [ pending ] [ approved ] [ all ]
[contribution cards — filterable by class and approval status]
“View class →” navigates to the class_instance node (which has its own dedicated view — same tab pattern as tool instance: Groups / Contributions / Allocation / Summary).
Feedforward to Collate
When a Collate phase node is active, the phase panel (from Phase 3) already shows a “From Gather” section. Extend this to show contributions at the class instance level:
FROM GATHER
[ All classes ] [ 11S1 ] [ 9J1 ] Show: [ approved only ● ] [ all ○ ]
[contribution cards from the matching Gather tool instances]
[each card shows: group name, class name, content preview, approval status]
The Collate class instance (when a class does Collate) sees:
- Primary: their own class’s approved Gather contributions (auto-surfaced)
- Secondary: other classes’ approved Gather contributions (available to browse and include via a “bring into our Collate” action)
“Bring into our Collate” creates a responds_to relation from the Collate
class instance to the selected Gather contribution node. It appears in their
Collate view marked as “included from [class name]”.
Verify Phase D
-
Two class instances on one tool instance, both with approved contributions. Tool instance Contributions tab shows the class summary cards with counts. Class filter tabs show contributions from each class separately.
-
Navigate to a Collate phase node. “From Gather” section shows contributions filterable by class. 11S1’s contributions are shown by default.
-
Click “bring into our Collate” on a 9J1 contribution. It appears in the Collate view marked “included from 9J1”. A responds_to relation is created in the graph.
What does NOT change
- Reference instance — unchanged
- Auth system (except adding group_token role) — unchanged
- Image maps — unchanged
- Temporal navigation — unchanged
- Standard node view — unchanged (accessible via node view ↗ on all custom views)
- Quick capture — unchanged
File changes
db/database.js — migrations table, inst_hierarchy_v1,
project_container_v1, group token generation
api/institutions.js — POST /api/institutions (new)
api/class-instances.js — GET/POST allocation, assign, unassign, lock (new)
api/auth.js — group_token role, POST /api/token-login (new)
api/tool-instances.js — class_instance creation, regenerate-groups update
api/projects.js — institution scoping enforcement, assigned_classes
server.js — mount new routers, serve /join route
lfti-spine.html — class assignment in Setup tab, cross-class view,
teacher allocation view, feedforward Collate section,
container node rendering
public/join.html — NEW: minimal token login page (separate from main app)
Note: /join is a separate minimal HTML file served at that route.
It does not share the main app’s JS — it only needs: token input,
POST /api/token-login, allocation polling (GET /api/class-instances/:id/allocation),
assign/unassign calls, and a simple contribution upload for when allocation is done.
Keep it under 400 lines. Style to match the main app’s design tokens via a
shared CSS variables approach (serve the same CSS variables, minimal additional styles).
When you’re done — full verification
Run through this sequence across multiple browser windows:
-
Phase A: Superadmin creates Bristol Academy. Staff/Students/Projects containers appear. Bristol Academy admin logs in, can only create projects under Bristol Academy.
-
Phase B: Project wizard pre-selects assigned classes. Tool instance Setup tab shows class assignment section. Adding 11S1 creates a class_instance with group children. Removing it (after confirmation) cleans up completely.
-
Phase C: Two group tokens visible on a tool instance. Navigate to /join in incognito. Enter token. See allocation view. Self-assign. Second incognito window with other group token sees the change within 10 seconds. Facilitator locks — self-assignment blocked on token devices.
-
Phase D: Two classes with approved contributions on one tool instance. Cross-class summary cards visible. Collate phase feedforward shows both classes’ contributions filterable and includable.