Walkthrough: the todo app
This page shows the whole process once, on the smallest app anyone could ask for — "I want to create a todo app" — using complete files from the repository rather than excerpts. An excerpt of generated code is unconvincing, because the reader assumes the messy parts were cropped. Every block below is the file as it sits in demos/todo/ at the version of these docs, pulled in at build time; nothing is retyped for the page. The one thing dropped is the title line of each markdown file — the section heading above the block stands in for it, so the page keeps a single top-level heading — and everything from the first line after it onward is shown.
Todo was chosen for the reason a todo app is usually a bad demo: the reader spends zero attention on the domain, so the process is the only thing on the page. Every other demo vertical makes you learn field service or bike repair first.
The chain is five files and one derivation step:
| step | file | who writes it |
|---|---|---|
| 1. The brief | spec/prompt.md | the customer, in one line |
| 2. The interview | spec/answers.md | the customer, answering the builder |
| 3. The concept | spec/concept.md | the builder; approved before any code |
| 4. The model | spec/model.ts | the builder — what exists, declared once |
| 5. What is derived | migrations, manifest, permissions, API, client | nobody — re-emitted, and gated |
| 6. The business logic | src/module.ts | the builder — the only code that is about todo |
Read it top to bottom. The point is at the end: the last file is the whole of the application code, and none of the tenancy, permissions or audit it runs under appears in it.
1. The brief
The prompt is one line, deliberately. A real brief is underspecified, and everything that follows was either asked for in the interview or assumed by the builder and written down in the concept's Assumptions section — never silently.
I want to create a todo app.
That is the whole prompt, deliberately. A real brief is underspecified — everything below it in spec/ was either asked for in the interview (see answers.md) or assumed by the builder and recorded in the concept's Assumptions section.
Keep this file short. Intent enters here; derived decisions belong in concept.md. If a correction cannot be expressed as something a customer would actually have said, that is a finding about the pipeline, not a reason to grow this file.
2. The interview
The design skill asks the questions the brief left open, in two rounds of a few each, recommending an answer to every one. Here every recommendation was accepted as offered, which is what a builder should expect for an app this plain.
The interview asked two rounds, five questions. Delivered as one block so a replay does not have to match question wording — the interview skill reads this, finds its frontier covered, and proposes.
- Who uses it: I share lists with people. I own my lists and can invite specific people to some of them; the rest stay private.
- Screens: a web UI.
- Anything automatic: no. Nothing on a timer — no due dates, no reminders, no recurring tasks.
- What a share allows: they can add items and tick them off, but not delete the list or share it onward.
- Accounts: I invite them by email. Nobody signs themselves up.
Every one of the five was the builder's recommended answer, accepted as offered.
3. The concept
The interview produces a concept — the design document the customer approves before a line of code is written. It is written for the customer, in their vocabulary, and it is the contract every later step is checked against: the cast in §6 becomes the seed, the scenario in §9 becomes the test, and the assumptions in §10 are the decisions taken on the customer's behalf, listed so they can be reversed while reversing is cheap.
Notice §3. Accounts, invitations, isolation between accounts, a permission check on every operation and an audit trail are listed under free from the platform. What is left under yours to build is lists, items and sharing — and that is the whole build.
1. What we're building & who uses it
A todo app. Anyone with an account owns lists; a list is private until its owner shares it with someone by email. There are two kinds of people, and they use the same app: the person who owns a list, and the person it was shared with.
2. The thing that moves through the system
Two nouns — a list, and the items on it. An item is open or done, and it can go back; that is the entire lifecycle. There are no stages to skip because there are no stages.
This is a record, not a workflow. It composes no engine, and that is a fact about the app rather than an omission.
3. What already exists vs. what's yours
Free from the platform: accounts and login, invitations, isolation between accounts, a permission check on every operation, an audit trail of every change, the database and its migrations, hosting.
Yours to build: lists, items, sharing.
That is the whole build. No engine is composed — nothing here has invariants beyond "you cannot touch a list nobody shared with you", and that is the platform's own.
4. Who can do what
The owner does everything to their own list: create it, rename it, delete it, add items, complete them, delete them, share it, revoke a share.
Someone the list is shared with can see it, add items, and tick items off — including un-ticking. They cannot delete items, delete the list, or share it onward.
Everyone else sees nothing. A list you have not been shared with is invisible: not its contents, not its name, not the fact that it exists. Asking for it directly by id is refused, not answered with an empty result.
Another account entirely reaches nothing at all.
Nobody can see money, because there is none.
5. Money & sign-off
Neither. Nothing is invoiced, quoted or paid for, and nothing needs approving before a step can happen.
6. The cast, roles, tenancy
Two tenants, always — the second exists to be attacked, which is how isolation is proven rather than claimed.
| who | where | what they are |
|---|---|---|
| Ada | tenant one | owns lists |
| Björn | tenant one | Ada shares a list with him |
| Cleo | tenant two | unrelated account; must reach nothing |
One role: member — anyone with an account can own lists. Sharing is per-list, not a role, which is the interesting part of this app's permission model: what Björn may do is decided by the share on that list, not by anything about Björn.
7. The data we'll store
- People — one row per person with an account here: who they are, when they joined. Lists hang off it, which is how "your lists" is a fact the system can follow rather than a filter the code has to remember.
- Lists — name, who owns it, when it was created.
- Items — which list they are on, what they say, done or not, who added them, when.
- Shares — which list, which person, and the email it was sent to.
The email is load-bearing. The sharing screen promises "shared with björn@example.com", and accounts are opaque ids, so the address needs a table of its own to come from. A promised human-readable string with no source table is a missing table.
8. The screens
- Your lists — the ones you own and the ones shared with you, marked which is which.
- A list — its items, tick to complete, add an item.
- Sharing — enter an email, see who the list is shared with, revoke.
9. The scenario the test will replay
The happy path. Ada creates "Groceries" and adds milk. She shares it with Björn. Björn sees the list, adds bread, and ticks milk off. Ada sees both changes.
The denials that prove it.
- Björn cannot delete the list, and is refused when he tries.
- Ada's other list, "Work", is not shared. It does not appear in Björn's lists, and asking for it directly by id is refused.
- Cleo reaches nothing at all — not Ada's lists, not Björn's, not by id.
- A control alongside each: Björn can add and tick, so the closed doors are not passing because every door is shut.
10. Assumptions
Decisions taken on the builder's behalf. Each is cheap to reverse now and expensive later.
- The product is called Todo.
- People you share with can add and complete, but cannot delete items or the list, and cannot share it onward.
- Ticking is reversible — completing something is not final.
- One role: anyone with an account can own lists. Sharing is per-list.
- Everyone uses the same app; there is no separate portal for people you share with.
- A list is shared with a person, not with a public link.
- Revoking a share is possible, and takes effect immediately.
- Item text is ordinary personal data — nothing needing erasure beyond deleting it.
- English only.
11. Out of scope
Due dates, reminders and recurrence (the builder said no). Sub-tasks, attachments, comments, re-sharing, public links, search, and a mobile app.
4. The model
The approved concept becomes one TypeScript module declaring what exists: the entities and their fields, the operations over them, and the permission each operation checks. It is TypeScript rather than a schema language because the compiler checks the joins between those three things — an operation naming an entity that does not exist, an entityIdFrom naming no output field, a payload carrying an erasable field — and those joins are where the defects live. The model explains each declaration; this is what a complete one looks like.
Two lines carry the permission model. parents: ['owner'] on list is the edge the permission walk follows, so "your lists" is a fact the kernel can prove rather than a filter the code has to remember. And permission: { key: 'list:manage', entity: 'list', idFrom: 'listId' } on rename-list declares that the check is on that list, which the conformance kit then drives against the handler.
/**
* Todo's model — what exists, declared once (#697/#707).
*
* The concept is approved (`spec/concept.md`); this is its entity and operation
* surface, and everything downstream is derived from it: the migrations, the
* manifest, the route table, the permission registry, the API document.
*
* This vertical composes **no engine**. Nothing here has an invariant beyond
* "you cannot touch a list nobody shared with you", and that one is the
* platform's. An absent `engines` argument is a fact about the app, not an
* omission.
*/
import {
defineEntities,
defineOperations,
emitModel,
z,
} from '@substrat-run/contracts';
import { MAX_SEARCH_LIMIT } from '@substrat-run/kernel';
/**
* How much wider than the answer the handlers ask the index for.
*
* Every search here filters hits AFTER ranking — by list, or by which lists the
* caller reaches — and a ranked top-N filtered afterwards returns fewer than N.
* Asking for more is the only defence.
*/
export const SEARCH_OVERFETCH = 4;
/**
* This vertical's own cap, and deliberately NOT the kernel's.
*
* `ctx.search` clamps every ask to `MAX_SEARCH_LIMIT`, so a caller allowed to
* request the ceiling leaves the over-fetch no headroom at all: the ask and the
* answer become the same number and the filter above eats into the result. Deriving
* the bound keeps the two from drifting apart — raise the ceiling and this follows.
*/
export const TODO_SEARCH_MAX = Math.floor(MAX_SEARCH_LIMIT / SEARCH_OVERFETCH);
export const todoEntities = defineEntities({
/**
* A person who has an account in this scope — the thing their lists hang off.
*
* It exists because ownership has to be reachable by the permission walk. A
* list's owner cannot be a grant made by the operation that creates it:
* `ctx.grant` delegates what the caller already holds, and nobody holds
* anything on a list that did not exist a moment ago. So the grant is made
* once, per person, on THIS entity when they join, and every list they create
* links to it. The walk reaches list → owner and the answer follows.
*
* `id` is the principal id. One row per person, not per login.
*/
owner: {
table: 'todo_owners',
fields: z.object({
id: z.string(),
email: z.string(),
display_name: z.string(),
created_at: z.string(),
}),
key: ['email'],
erasable: ['email', 'display_name'],
},
/** A list. Private to its owner until a share exists for someone else. */
list: {
table: 'todo_lists',
fields: z.object({
id: z.string(),
owner_id: z.string(),
name: z.string(),
created_at: z.string(),
}),
parents: ['owner'],
},
/**
* An item on a list. `done` is a number because SQLite has no boolean and the
* row comes back as 0/1 — declaring `z.boolean()` here would emit the same
* INTEGER column while promising the reader a type the database cannot return.
*/
item: {
table: 'todo_items',
fields: z.object({
id: z.string(),
list_id: z.string(),
text: z.string(),
done: z.number(),
added_by: z.string(),
created_at: z.string(),
}),
parents: ['list'],
},
/**
* One person's access to one list.
*
* The email is here because the sharing screen promises "shared with
* björn@example.com" and principals are opaque ids — a promised string with no
* source table is a missing table. It is the only directly personal field in
* the app, so it is `erasable`, which also makes it uncarryable by any event.
*
* `key` is the composite it reads as: one share per person per list.
*/
share: {
table: 'todo_shares',
fields: z.object({
id: z.string(),
list_id: z.string(),
principal: z.string(),
email: z.string(),
created_at: z.string(),
}),
parents: ['list'],
key: ['list_id', 'principal'],
erasable: ['email'],
},
});
/**
* Three keys, and the split is the app's whole permission model.
*
* - `list:create` is held scope-wide by every member. It is the only one that
* can be, because creating a list is the one act with no entity to narrow to.
* - `list:manage` and `list:contribute` are granted per person on their OWN
* `owner` entity when they join, and reach their lists through the declared
* parent edge. Nobody holds either one scope-wide, which is what makes one
* member's lists unreachable to another.
* - Sharing narrows `list:contribute` onto ONE list for one person, so what
* Björn may do is a fact about *that list* rather than about Björn.
*/
export const TODO_PERMISSIONS = ['list:create', 'list:manage', 'list:contribute'] as const;
export const todoOperations = defineOperations(todoEntities, TODO_PERMISSIONS)({
/**
* Claim your account in this scope — the row every list hangs off.
*
* It exists because ownership is an entity, and an entity needs a moment it
* comes into being. Self-service and idempotent: joining twice is joining.
*/
'todo/join': {
summary: 'Claim your account in this workspace',
permission: 'list:create',
input: z.object({ email: z.string().email(), displayName: z.string().min(1) }),
output: todoEntities.owner.fields,
http: { method: 'POST', path: '/join' },
emits: {
entity: 'owner',
entityIdFrom: 'id',
type: 'todo.owner-joined',
schemaVersion: 1,
piiClass: 'pseudonymous',
subjectId: 'id',
// `email` and `display_name` are erasable, so neither can ride here.
payload: ['id'],
},
},
'todo/create-list': {
summary: 'Create a list',
permission: 'list:create',
input: z.object({ name: z.string().min(1) }),
output: todoEntities.list.fields,
http: { method: 'POST', path: '/lists' },
emits: {
entity: 'list',
entityIdFrom: 'id',
type: 'todo.list-created',
schemaVersion: 1,
piiClass: 'none',
payload: ['id', 'name', 'owner_id'],
},
},
/**
* The proof walk. Owned lists and shared-with lists arrive in one answer, and
* a list nobody shared is not in it — filtered by a per-entity check, never by
* a WHERE clause on ownership.
*/
'todo/my-lists': {
summary: 'List the lists you own or have been shared',
narrows: {
reason: 'Returns only lists the caller owns or has been shared',
checks: ['list:contribute'],
},
output: todoEntities.list.fields,
// #811. The underlying walk IS kernel-composed — `created_at` with the id
// tie-break is exactly the `ORDER BY created_at, id` this shipped with — but
// visibility is decided by a per-row proof walk on top of it, so the handler
// over-fetches with `pageVisible`. Pages may come back short; the walk ends at
// the absent `Link`.
paged: { over: { entity: 'list', sortable: ['created_at', 'name'] } },
http: { method: 'GET', path: '/lists' },
},
'todo/rename-list': {
summary: 'Rename a list',
permission: { key: 'list:manage', entity: 'list', idFrom: 'listId' },
input: z.object({ listId: z.string(), name: z.string().min(1) }),
output: todoEntities.list.fields,
http: { method: 'PATCH', path: '/lists/{listId}' },
emits: {
entity: 'list',
entityIdFrom: 'id',
type: 'todo.list-renamed',
schemaVersion: 1,
piiClass: 'none',
payload: ['id', 'name'],
},
},
'todo/delete-list': {
summary: 'Delete a list and everything on it',
permission: { key: 'list:manage', entity: 'list', idFrom: 'listId' },
input: z.object({ listId: z.string() }),
output: z.object({ id: z.string(), deleted: z.boolean() }),
http: { method: 'DELETE', path: '/lists/{listId}' },
emits: {
entity: 'list',
entityIdFrom: 'id',
type: 'todo.list-deleted',
schemaVersion: 1,
piiClass: 'none',
payload: ['id'],
},
},
'todo/list-items': {
summary: 'The items on a list',
permission: { key: 'list:contribute', entity: 'list', idFrom: 'listId' },
// `limit` and `cursor` are NOT declared here: since #811 the platform parses
// the page trio with the one shared schema and merges it into the input, so
// the default and the `LIST_PAGE_MAX` ceiling are true of every paged read
// rather than of the ones whose author remembered to restate them.
input: z.object({ listId: z.string() }),
// The ENTRY, not the envelope: `paged` is what wraps it, here and in the document.
output: todoEntities.item.fields,
// A list's items are the one table here that grows without bound — one per line of
// shopping, forever. Keyset over the ULID id, which is creation-ordered for free.
// `total` because the app renders a count beside the list; it costs a second
// query per request, which is why it is asked for rather than assumed.
paged: { sortKey: 'id', total: true },
http: { method: 'GET', path: '/lists/{listId}/items' },
},
/**
* Search on ONE list (#827) — the read that `paged` above took away.
*
* Filtering `list-items` in the browser searched whatever page had loaded, which
* at forty items looked like search and at four thousand looked like a bug. This
* asks the index instead.
*
* A separate operation rather than a `q` on `list-items`: that read is sorted and
* paged, this one is ranked and capped, and one endpoint cannot carry both
* contracts. `GET /lists/{listId}/items/search` does not collide with the paged
* read — `mountOperations` registers a static segment ahead of its parameter
* sibling (#785).
*
* Permission is the ordinary entity-narrowed one: the caller either reaches this
* list or they do not, and one check settles it.
*/
'todo/search-list-items': {
summary: 'Find items on a list by text',
permission: { key: 'list:contribute', entity: 'list', idFrom: 'listId' },
input: z.object({
listId: z.string(),
// Two characters is the prefix index's floor. Declared here so a short term is
// a 400 naming the field, not a throw from inside the kernel.
q: z.string().min(2),
limit: z.number().int().positive().max(TODO_SEARCH_MAX).optional(),
}),
// Not a bare array: a capped read has to say it was capped, or the screen shows
// the first twenty of two hundred matches as though that were all of them.
output: z.object({
results: z.array(todoEntities.item.fields),
limit: z.number().int(),
capped: z.boolean(),
}),
http: { method: 'GET', path: '/lists/{listId}/items/search' },
},
/**
* Search across every list the caller can reach — "where did I put milk?".
*
* The same `narrows` shape as `my-lists`, and for the same reason: nobody holds
* `list:contribute` scope-wide, so the answer is assembled by asking, per list,
* whether this caller reaches it. The index is scope-wide and checks nothing —
* `ctx.search` never does — so the filter after it is what keeps one member's
* items out of another's results.
*
* The trap here is real and documented (concepts/reads.md): a ranked top-N
* filtered afterwards returns FEWER than N. The handler over-fetches on purpose.
*/
'todo/search-items': {
summary: 'Find items across the lists you can see',
narrows: {
reason: 'Returns only items on lists the caller owns or has been shared',
checks: ['list:contribute'],
},
input: z.object({
q: z.string().min(2),
limit: z.number().int().positive().max(TODO_SEARCH_MAX).optional(),
}),
output: z.object({
results: z.array(todoEntities.item.fields),
limit: z.number().int(),
capped: z.boolean(),
}),
http: { method: 'GET', path: '/items/search' },
},
'todo/add-item': {
summary: 'Add an item to a list',
permission: { key: 'list:contribute', entity: 'list', idFrom: 'listId' },
input: z.object({ listId: z.string(), text: z.string().min(1) }),
output: todoEntities.item.fields,
http: { method: 'POST', path: '/lists/{listId}/items' },
emits: {
entity: 'item',
entityIdFrom: 'id',
type: 'todo.item-added',
schemaVersion: 1,
piiClass: 'none',
payload: ['id', 'list_id', 'text', 'added_by'],
},
},
/** Ticking is reversible, so this sets the state rather than completing. */
'todo/set-item-done': {
summary: 'Tick an item off, or put it back',
// The id is not in the input: the check is on the LIST this item sits on.
permission: { key: 'list:contribute', entity: 'list', resolved: 'the list the item is on' },
input: z.object({ itemId: z.string(), done: z.boolean() }),
output: todoEntities.item.fields,
http: { method: 'POST', path: '/items/{itemId}/done' },
emits: {
entity: 'item',
entityIdFrom: 'id',
type: 'todo.item-done-changed',
schemaVersion: 1,
piiClass: 'none',
payload: ['id', 'list_id', 'done'],
},
},
'todo/delete-item': {
summary: 'Delete an item',
permission: { key: 'list:manage', entity: 'list', resolved: 'the list the item is on' },
input: z.object({ itemId: z.string() }),
output: z.object({ id: z.string(), deleted: z.boolean() }),
http: { method: 'DELETE', path: '/items/{itemId}' },
emits: {
entity: 'item',
entityIdFrom: 'id',
type: 'todo.item-deleted',
schemaVersion: 1,
piiClass: 'none',
payload: ['id'],
},
},
/**
* Sharing. The event is about a share, whose subject is a person, so it is
* classified and keyed — and the address itself cannot ride along, because
* `share.email` is erasable. `principal` is what an erasure would key on.
*/
'todo/share-list': {
summary: 'Share a list with someone by email',
permission: { key: 'list:manage', entity: 'list', idFrom: 'listId' },
input: z.object({ listId: z.string(), email: z.string().email() }),
output: todoEntities.share.fields,
http: { method: 'POST', path: '/lists/{listId}/shares' },
emits: {
entity: 'share',
entityIdFrom: 'id',
type: 'todo.list-shared',
schemaVersion: 1,
piiClass: 'pseudonymous',
subjectId: 'principal',
payload: ['id', 'list_id', 'principal'],
},
},
/**
* Who a list is shared with. Owner-only: the members of a share are the
* owner's business, not each other's.
*/
'todo/list-shares': {
summary: 'Who this list is shared with',
permission: { key: 'list:manage', entity: 'list', idFrom: 'listId' },
input: z.object({ listId: z.string() }),
output: todoEntities.share.fields,
// A share list is short in every plausible app and unbounded all the same —
// one row per person a list is shared with, and nothing caps that. `list_id`
// is the filter the route already narrows by, declared so the kernel composes
// it and indexes `(list_id, created_at, id)` behind it.
paged: { over: { entity: 'share', sortable: ['created_at'], filterable: ['list_id'] } },
http: { method: 'GET', path: '/lists/{listId}/shares' },
},
'todo/revoke-share': {
summary: 'Revoke someone’s access to a list',
permission: { key: 'list:manage', entity: 'list', resolved: 'the list the share is on' },
input: z.object({ shareId: z.string() }),
output: z.object({ id: z.string(), revoked: z.boolean() }),
http: { method: 'DELETE', path: '/shares/{shareId}' },
emits: {
entity: 'share',
entityIdFrom: 'id',
type: 'todo.share-revoked',
schemaVersion: 1,
piiClass: 'none',
payload: ['id'],
},
},
});
export const todoModel = emitModel(todoEntities);5. What is derived
From spec/model.ts, without another line being written:
- The migrations —
src/migrations.generated.ts, theCREATE TABLEfor each entity and the indexes behind each declared sort and filter, re-emitted bypnpm lint:migrations. - The manifest — assembled from the model's two halves at mount time: the permission registry, the entity relations the permission walk follows, the events each operation emits, the paged lists.
PERMISSIONS.md— the human checkpoint: every key, what it means, and which role holds it, rendered bypnpm lint:permissionsand checked in, so a widened role cannot merge without appearing in the diff.model.json— the model as data, for tools that do not run TypeScript; gated bypnpm lint:model --check.- The HTTP surface — each operation's
http: { method, path }becomes a route, its input schema becomes the request validation the host applies before the handler runs, andopenapi.jsonwith a Scalar page is served from the same catalogue (pnpm lint:api). - The browser client —
app/src/api.generated.ts: typed methods for every operation, the entity interfaces, the pagedLinkwalk (pnpm lint:client). CONFORMANCE.md— the receipt saying which declared entity checks the kit drives against the running handler, and which it cannot, by name (pnpm lint:conformance).
Each of those carries the three marks a generated file must: the .generated suffix or a GENERATED first line, a header naming the producer and the source, and a --check re-emit in CI. The third is the only one that enforces anything.
6. The business logic
What is left is the one file that is about todo: what it means to share a list, and who may do what to one. Every handler opens with the check its declaration promised, and the satisfies clause at the bottom is what makes a handler that disagrees with its declaration — or one declared and not implemented — a compile error naming the method.
Sharing is the part worth reading twice. share-list is one ctx.grant and revoke-share is one ctx.revoke: the kernel narrows list:contribute onto this list for this person, re-checks that the caller holds it there, and records it transactionally with the operation. Nothing else in the app has to remember that Björn may touch this list — the grant is the fact, and every check reads it.
/**
* Todo's operations — the business logic, and nothing else.
*
* Everything structural is derived from `spec/model.ts`: the migrations were
* emitted from the entities, the manifest is assembled from both halves of the
* model, and the route table is derived at mount time. What is left in this file
* is what only a person could decide — what it MEANS to share a list, and who
* may do what to one.
*
* `satisfies OperationImpl<…>` is the join: a handler whose input or return
* disagrees with the declared operation, one declared and not implemented, or
* one implemented and not declared, is a compile error naming the exact method.
*/
import {
countedPageOf,
pageVisible,
dataSubjectId,
LIST_PAGE_DEFAULT,
operationInputsOf,
substratError,
type HandlerInput,
type HandlerOutput,
z,
type EntityRow,
type PrincipalId,
} from '@substrat-run/contracts';
import {
assertAllowed,
DEFAULT_SEARCH_LIMIT,
MAX_SEARCH_LIMIT,
ulid,
type ModuleRegistration,
type OperationContext,
type OperationHandler,
} from '@substrat-run/kernel';
import { SEARCH_OVERFETCH, todoEntities, todoOperations } from '../spec/model.js';
import { TODO_PERM, todoManifest } from './manifest.js';
import { todoMigrations } from './migrations.generated.js';
type OwnerRow = EntityRow<typeof todoEntities, 'owner'>;
type ListRow = EntityRow<typeof todoEntities, 'list'>;
type ItemRow = EntityRow<typeof todoEntities, 'item'>;
type ShareRow = EntityRow<typeof todoEntities, 'share'>;
const listRef = (id: string) => ({ entityType: 'list', entityId: id });
/**
* Ask the index for more than the caller wants (#827).
*
* `SEARCH_OVERFETCH` is a guess, and an honest one: it is not a guarantee, which is
* why both handlers still report `capped` rather than pretending the answer is
* complete. The `min` is a backstop rather than the design — `TODO_SEARCH_MAX` is
* derived so that the widened ask stays inside the kernel's ceiling, and the clamp
* only ever fires if that derivation is broken.
*/
const overfetch = (limit: number) => Math.min(limit * SEARCH_OVERFETCH, MAX_SEARCH_LIMIT);
/** Hits carry the rank; rows carry the shape. Re-join them in the index's order. */
function rankOrder(hits: readonly { id: string }[], rows: readonly ItemRow[]): ItemRow[] {
const byId = new Map(rows.map((row) => [row.id, row]));
return hits.map((hit) => byId.get(hit.id)).filter((row): row is ItemRow => row !== undefined);
}
/** The list, or a refusal — never a silent empty answer. */
function listOrThrow(ctx: OperationContext, id: string): ListRow {
const row = ctx.sql.query<ListRow>('SELECT * FROM todo_lists WHERE id = ?', [id])[0];
if (!row) throw substratError('not_found', `list not found: ${id}`);
return row;
}
/** The list an item sits on — every item permission is really the list's. */
function itemAndList(ctx: OperationContext, itemId: string): { item: ItemRow; list: ListRow } {
const item = ctx.sql.query<ItemRow>('SELECT * FROM todo_items WHERE id = ?', [itemId])[0];
if (!item) throw substratError('not_found', `item not found: ${itemId}`);
return { item, list: listOrThrow(ctx, item.list_id) };
}
const operations = {
'todo/join': async (ctx, input) => {
assertAllowed(await ctx.check(TODO_PERM.listCreate));
const existing = ctx.sql.query<OwnerRow>('SELECT * FROM todo_owners WHERE id = ?', [
ctx.principal,
])[0];
if (existing) return existing;
ctx.sql.exec(
'INSERT INTO todo_owners (id, email, display_name, created_at) VALUES (?, ?, ?, ?)',
[ctx.principal, input.email, input.displayName, ctx.now()],
);
const row = ctx.sql.query<OwnerRow>('SELECT * FROM todo_owners WHERE id = ?', [
ctx.principal,
])[0]!;
ctx.emit({
type: 'todo.owner-joined',
schemaVersion: 1,
entity: { entityType: 'owner', entityId: row.id },
piiClass: 'pseudonymous',
subjectId: dataSubjectId.parse(row.id),
payload: { id: row.id },
});
return row;
},
'todo/create-list': async (ctx, input) => {
assertAllowed(await ctx.check(TODO_PERM.listCreate));
// The person's own entity must exist: it is what their lists hang off, and
// what carries the grant that reaches them. Created when they join.
const owner = ctx.sql.query<OwnerRow>('SELECT * FROM todo_owners WHERE id = ?', [
ctx.principal,
])[0];
if (!owner)
throw substratError(
'precondition_failed',
'no account here — join before creating a list',
);
const id = ulid();
ctx.sql.exec(
'INSERT INTO todo_lists (id, owner_id, name, created_at) VALUES (?, ?, ?, ?)',
[id, owner.id, input.name, ctx.now()],
);
// The edge the permission walk follows. Without it the owner's grant on
// their own entity would reach nothing.
ctx.link(listRef(id), { entityType: 'owner', entityId: owner.id });
const row = listOrThrow(ctx, id);
ctx.emit({
type: 'todo.list-created',
schemaVersion: 1,
entity: listRef(id),
piiClass: 'none',
payload: { id: row.id, name: row.name, owner_id: row.owner_id },
});
return row;
},
/**
* The proof walk. Every list is a candidate and each one is asked; a list
* nobody shared simply never answers yes.
*
* Deliberately NOT a `WHERE owner_id = ?` — that would be a second, hand-kept
* description of who may see what, and the one that gets forgotten.
*/
// #811. The walk is the kernel's; the per-row proof check is this vertical's,
// and `pageVisible` is what keeps the two honest — it filters the batch and
// advances the cursor by the last row EXAMINED, so a page of rows the caller
// cannot see still moves the walk forward instead of stalling on it.
'todo/my-lists': async (ctx, input) =>
pageVisible(
(p) => ctx.page<ListRow>('list', p),
input,
async (list) => (await ctx.check(TODO_PERM.listContribute, listRef(list.id))).allowed,
),
'todo/rename-list': async (ctx, input) => {
assertAllowed(await ctx.check(TODO_PERM.listManage, listRef(input.listId)));
listOrThrow(ctx, input.listId);
ctx.sql.exec('UPDATE todo_lists SET name = ? WHERE id = ?', [input.name, input.listId]);
const row = listOrThrow(ctx, input.listId);
ctx.emit({
type: 'todo.list-renamed',
schemaVersion: 1,
entity: listRef(row.id),
piiClass: 'none',
payload: { id: row.id, name: row.name },
});
return row;
},
'todo/delete-list': async (ctx, input) => {
assertAllowed(await ctx.check(TODO_PERM.listManage, listRef(input.listId)));
listOrThrow(ctx, input.listId);
ctx.sql.exec('DELETE FROM todo_items WHERE list_id = ?', [input.listId]);
ctx.sql.exec('DELETE FROM todo_shares WHERE list_id = ?', [input.listId]);
ctx.sql.exec('DELETE FROM todo_lists WHERE id = ?', [input.listId]);
ctx.emit({
type: 'todo.list-deleted',
schemaVersion: 1,
entity: listRef(input.listId),
piiClass: 'none',
payload: { id: input.listId },
});
return { id: input.listId, deleted: true };
},
'todo/list-items': async (ctx, input) => {
assertAllowed(await ctx.check(TODO_PERM.listContribute, listRef(input.listId)));
// Keyset, not offset: the cursor is the last row's id and the walk is exclusive, so
// an item added mid-walk cannot push a row onto a page the caller already read.
// `ORDER BY id` alone — a ULID is creation-ordered, so the old `created_at, id`
// ordering is the same sequence with a second column the cursor could not name.
const limit = input.limit ?? LIST_PAGE_DEFAULT;
const rows = input.cursor
? ctx.sql.query<ItemRow>(
'SELECT * FROM todo_items WHERE list_id = ? AND id > ? ORDER BY id LIMIT ?',
[input.listId, input.cursor, limit],
)
: ctx.sql.query<ItemRow>('SELECT * FROM todo_items WHERE list_id = ? ORDER BY id LIMIT ?', [
input.listId,
limit,
]);
// Counted over the SAME filter the page ran under. Counting the table instead
// would put a number beside the list that is wrong the moment there are two lists.
const total = ctx.sql.query<{ n: number }>(
'SELECT COUNT(*) AS n FROM todo_items WHERE list_id = ?',
[input.listId],
)[0]!.n;
return countedPageOf(rows, limit, (row) => row.id, total);
},
/**
* Search on one list (#827) — one check, then hydrate.
*
* The index is SCOPE-wide: `ctx.search` knows nothing about lists, so the hits
* arrive from every list in the scope and the `list_id` in the WHERE is what
* narrows them. That filter runs AFTER the ranking, which is why the index is
* asked for more than the caller wants — a top-20 that loses 18 rows to the
* filter would answer with two and call it the whole result.
*/
'todo/search-list-items': async (ctx, input) => {
assertAllowed(await ctx.check(TODO_PERM.listContribute, listRef(input.listId)));
const limit = input.limit ?? DEFAULT_SEARCH_LIMIT;
const fetch = overfetch(limit);
const hits = ctx.search('item', input.q, { limit: fetch });
if (hits.length === 0) return { results: [], limit, capped: false };
const rows = ctx.sql.query<ItemRow>(
`SELECT * FROM todo_items WHERE list_id = ? AND id IN (${hits.map(() => '?').join(', ')})`,
[input.listId, ...hits.map((h) => h.id)],
);
// `IN (…)` returns rows in whatever order the table hands them back, so the
// rank has to be put back deliberately: a search that lists the best match
// third is a search people stop using.
const ordered = rankOrder(hits, rows);
return {
results: ordered.slice(0, limit),
limit,
capped: ordered.length > limit || hits.length === fetch,
};
},
/**
* Search across every list the caller reaches — the same proof walk as
* `my-lists`, run over search hits instead of over every row.
*
* `narrows` rather than a node-level check, because there is no node-level
* answer: nobody holds `list:contribute` scope-wide, so "may you see this item"
* is only ever a question about the list it sits on. Asked once per distinct
* list rather than once per hit — forty matching items on one list is one
* question, not forty.
*
* Stopping at `limit` is what makes this cheap: the walk ends as soon as the
* answer is full, so a scope with many unreachable matches costs the checks it
* takes to find `limit` reachable ones, not one per hit.
*/
'todo/search-items': async (ctx, input) => {
const limit = input.limit ?? DEFAULT_SEARCH_LIMIT;
const fetch = overfetch(limit);
const hits = ctx.search('item', input.q, { limit: fetch });
if (hits.length === 0) return { results: [], limit, capped: false };
const rows = ctx.sql.query<ItemRow>(
`SELECT * FROM todo_items WHERE id IN (${hits.map(() => '?').join(', ')})`,
hits.map((h) => h.id),
);
const byId = new Map(rows.map((row) => [row.id, row]));
const reachable = new Map<string, boolean>();
const results: ItemRow[] = [];
for (const hit of hits) {
const row = byId.get(hit.id);
if (!row) continue;
let allowed = reachable.get(row.list_id);
if (allowed === undefined) {
allowed = (await ctx.check(TODO_PERM.listContribute, listRef(row.list_id))).allowed;
reachable.set(row.list_id, allowed);
}
if (!allowed) continue;
results.push(row);
if (results.length === limit) break;
}
// Full either because the walk stopped early or because the index itself
// capped — both mean there may be more, and neither can be paged past.
return { results, limit, capped: results.length === limit || hits.length === fetch };
},
'todo/add-item': async (ctx, input) => {
assertAllowed(await ctx.check(TODO_PERM.listContribute, listRef(input.listId)));
listOrThrow(ctx, input.listId);
const id = ulid();
ctx.sql.exec(
'INSERT INTO todo_items (id, list_id, text, done, added_by, created_at) VALUES (?, ?, ?, 0, ?, ?)',
[id, input.listId, input.text, ctx.principal, ctx.now()],
);
const row = ctx.sql.query<ItemRow>('SELECT * FROM todo_items WHERE id = ?', [id])[0]!;
ctx.emit({
type: 'todo.item-added',
schemaVersion: 1,
entity: { entityType: 'item', entityId: id },
piiClass: 'none',
payload: { id: row.id, list_id: row.list_id, text: row.text, added_by: row.added_by },
});
return row;
},
'todo/set-item-done': async (ctx, input) => {
const { list } = itemAndList(ctx, input.itemId);
assertAllowed(await ctx.check(TODO_PERM.listContribute, listRef(list.id)));
ctx.sql.exec('UPDATE todo_items SET done = ? WHERE id = ?', [input.done ? 1 : 0, input.itemId]);
const row = ctx.sql.query<ItemRow>('SELECT * FROM todo_items WHERE id = ?', [input.itemId])[0]!;
ctx.emit({
type: 'todo.item-done-changed',
schemaVersion: 1,
entity: { entityType: 'item', entityId: row.id },
piiClass: 'none',
payload: { id: row.id, list_id: row.list_id, done: row.done },
});
return row;
},
/** Deleting is the owner's, which is what `list:manage` means here. */
'todo/delete-item': async (ctx, input) => {
const { list } = itemAndList(ctx, input.itemId);
assertAllowed(await ctx.check(TODO_PERM.listManage, listRef(list.id)));
ctx.sql.exec('DELETE FROM todo_items WHERE id = ?', [input.itemId]);
ctx.emit({
type: 'todo.item-deleted',
schemaVersion: 1,
entity: { entityType: 'item', entityId: input.itemId },
piiClass: 'none',
payload: { id: input.itemId },
});
return { id: input.itemId, deleted: true };
},
/**
* Sharing — the whole point of the app, and one line of access control.
*
* `ctx.grant` narrows `list:contribute` onto THIS list for THIS person. It
* delegates: the kernel re-checks that the caller holds it there, so an
* operation can never hand out more than it was given. Nothing else in the
* app has to remember that Björn may touch this list — the grant is the fact,
* and every check reads it.
*/
'todo/share-list': async (ctx, input) => {
assertAllowed(await ctx.check(TODO_PERM.listManage, listRef(input.listId)));
listOrThrow(ctx, input.listId);
const invitee = ctx.sql.query<OwnerRow>('SELECT * FROM todo_owners WHERE email = ?', [
input.email,
])[0];
if (!invitee) throw substratError('not_found', `nobody here with that address: ${input.email}`);
const existing = ctx.sql.query<ShareRow>(
'SELECT * FROM todo_shares WHERE list_id = ? AND principal = ?',
[input.listId, invitee.id],
)[0];
if (existing) return existing;
const id = ulid();
ctx.sql.exec(
'INSERT INTO todo_shares (id, list_id, principal, email, created_at) VALUES (?, ?, ?, ?, ?)',
[id, input.listId, invitee.id, invitee.email, ctx.now()],
);
await ctx.grant(invitee.id as PrincipalId, TODO_PERM.listContribute, listRef(input.listId));
const row = ctx.sql.query<ShareRow>('SELECT * FROM todo_shares WHERE id = ?', [id])[0]!;
ctx.emit({
type: 'todo.list-shared',
schemaVersion: 1,
entity: { entityType: 'share', entityId: row.id },
piiClass: 'pseudonymous',
subjectId: dataSubjectId.parse(row.principal),
// The address cannot ride along: `share.email` is erasable, and an
// immutable event is the one place an erasure cannot reach.
payload: { id: row.id, list_id: row.list_id, principal: row.principal },
});
return row;
},
'todo/list-shares': async (ctx, input) => {
assertAllowed(await ctx.check(TODO_PERM.listManage, listRef(input.listId)));
// The kernel composes it: `ORDER BY created_at, id` is the declared sort plus
// its tie-break, which is the ordering this shipped with, written once.
return ctx.page<ShareRow>('share', { ...input, filters: { list_id: input.listId } });
},
'todo/revoke-share': async (ctx, input) => {
const share = ctx.sql.query<ShareRow>('SELECT * FROM todo_shares WHERE id = ?', [
input.shareId,
])[0];
if (!share) throw substratError('not_found', `share not found: ${input.shareId}`);
assertAllowed(await ctx.check(TODO_PERM.listManage, listRef(share.list_id)));
ctx.sql.exec('DELETE FROM todo_shares WHERE id = ?', [input.shareId]);
await ctx.revoke(share.principal as PrincipalId, TODO_PERM.listContribute, listRef(share.list_id));
ctx.emit({
type: 'todo.share-revoked',
schemaVersion: 1,
entity: { entityType: 'share', entityId: share.id },
piiClass: 'none',
payload: { id: share.id },
});
return { id: share.id, revoked: true };
},
} satisfies {
// Derived by the platform, not restated here — `HandlerOutput` is what knows that a
// `paged` declaration means the handler returns a Page of the declared entry.
[K in keyof typeof todoOperations]: OperationHandler<
HandlerInput<(typeof todoOperations)[K]>,
HandlerOutput<(typeof todoOperations)[K]>
>;
};
export const todoModule: ModuleRegistration = {
manifest: todoManifest,
migrations: todoMigrations,
// The host parses every invocation against the same declaration the routes and
// the document come from, so "parse, don't trust" holds on every path in — HTTP,
// test, seed — rather than in the handlers that remembered (#953).
operationInputs: operationInputsOf(todoOperations),
operations: operations as ModuleRegistration['operations'],
};What this shows, and what it does not
What it does not show. Todo composes no engine. Its defineOperations takes no engine argument, and that is a fact about the app rather than an omission: nothing here has an invariant beyond "you cannot touch a list nobody shared with you", and that one is the platform's own. So this page shows the model → code pipeline and not engine composition, which is a large part of what a real vertical does. For that, read Callout — a work order that composes the workorder and protocol engines inside its own transaction.
What it does show. Here is the smallest app anyone could write, and it is already multi-tenant, permission-checked on every operation, and audited on every change — and none of that appears in the code above. "A todo app makes the platform look like overkill" is true only if you show the app. Show what you did not write and it is the point.
The honest comparison: Wasp
Wasp's TodoAppTs is the same app, and it is worth putting next to this one because Wasp is good and the difference is real.
On line count, Wasp wins. main.wasp.ts is about 30 lines, schema.prisma a dozen, actions.ts 34, queries.ts 13 — under a hundred lines to Substrat's roughly 800 across model.ts and module.ts. Their terseness is genuine, and this page is not structured as though it were not. A good part of the difference is that Substrat's two files carry the things Wasp leaves to convention: which permission each operation checks, what each mutation emits, what is personal data, how a list is paged.
The axis where Substrat is different is what a handler looks like. Wasp's update action is this:
return context.entities.Task.updateMany({
where: { id: args.id, user: { id: context.user.id } }, // forget this and anyone edits anything
data: { isDone: args.isDone },
});The ownership clause is the whole access control, it is restated in every handler, and nothing in the framework notices if one of them drops it — the types stay perfect. Their schema even has userId Int?, so a task can exist with no owner at all.
In todo the same edge is parents: ['list'] in the model plus one ctx.check(perm, entityRef) at the top of each handler, declared once rather than remembered per handler — and the declaration is what the conformance kit drives, so a handler that checked the node instead of the entity goes red in CI rather than shipping. That is not a claim that Substrat's code is shorter. It is a claim about where the mistake can live.