Relay stops being the template's notes app and becomes an event hub: one event row an app posts and anyone reads, with every rule in src/actions.ts and nowhere else. This is the spine the rest of the map stands on — it settles the shape of an event and the identity an app carries, so no door invents either.
Done looks like
[ ] A migration appended to migrations in src/db.ts (never edit one that has run) creating event: id, source, kind, subject, summary, link (nullable), occurred_at, received_at, dedupe (nullable); indexes on source and kind, and a partial CREATE UNIQUE INDEX ... ON event (source, dedupe) WHERE dedupe IS NOT NULL. A later migration drops note as its own step.
[ ] src/who.ts grows a third shape, { role: \"app\"; id; name }, produced only by a door that verified a signature (its own issue) — who() can never return it from a header.
[ ] postEvent(db, who, input) validates at the boundary and refuses by name: kind matches ^[a-z0-9]+(\\.[a-z0-9]+){0,3}$, at most 64 characters; subject 1..120 and summary 1..200, control characters and newlines refused (not stripped); link optional, at most 512, parses with new URL() and its protocol is http: or https: and nothing else; source matches ^[a-z0-9][a-z0-9-]{0,31}$; occurred_at optional ISO 8601, refused more than 5 minutes ahead or 30 days behind, default now; dedupe optional, at most 64 of [A-Za-z0-9._-].
[ ] Only an app or an admin posts: role === \"app\" takes the source from who.id and ignores any source in the body; admin may name it; user and anon get forbidden / signin_required.
[ ] The same (source, dedupe) twice returns the stored event with ok: true and writes no second row — a retry is free.
[ ] listEvents(db, who, { source?, kind?, before?, limit? }), newest first; kind matches exactly or as a dotted prefix (order finds order.placed, ord finds nothing); limit clamps to 1..200, default 50; before pages by id; a filter failing its pattern is invalid, not silently ignored.
[ ] emit(\"event.posted\", event) in the action.
[ ] GET /api/events?source=&kind=&limit=&before= and GET /api/events/:id (404 when absent), answering with answer(...).
[ ] Notes are gone: their actions, routes, MCP tools, page section and terminal keys are replaced rather than left dangling, and no test asserts a note. The doors may be plain for now — their own issues make them good.
[ ] src/actions.test.ts covers each refusal by name, the dedupe replay, the prefix filter, the clamp, the app-vs-admin source rule, and a javascript: link refused. bun test green; the README describes an event and the two endpoints.
Risks: removing notes touches every door at once — replace, do not delete, so the app keeps booting. SQLite needs a partial unique index for a nullable dedupe. A link is poster-controlled text another issue will put in an href; validating the scheme here means no door has to remember. Appending a migration collides textually with the other two ready issues — a one-line rebase, and order does not matter as long as none is edited after it has run.
Suggested: Ledger — append-only rows with an idempotency key are their brief exactly.
Relay stops being the template's notes app and becomes an event hub: one
eventrow an app posts and anyone reads, with every rule insrc/actions.tsand nowhere else. This is the spine the rest of the map stands on — it settles the shape of an event and the identity an app carries, so no door invents either.Done looks like
migrationsinsrc/db.ts(never edit one that has run) creatingevent: id,source,kind,subject,summary,link(nullable),occurred_at,received_at,dedupe(nullable); indexes onsourceandkind, and a partialCREATE UNIQUE INDEX ... ON event (source, dedupe) WHERE dedupe IS NOT NULL. A later migration dropsnoteas its own step.src/who.tsgrows a third shape,{ role: \"app\"; id; name }, produced only by a door that verified a signature (its own issue) —who()can never return it from a header.postEvent(db, who, input)validates at the boundary and refuses by name:kindmatches^[a-z0-9]+(\\.[a-z0-9]+){0,3}$, at most 64 characters;subject1..120 andsummary1..200, control characters and newlines refused (not stripped);linkoptional, at most 512, parses withnew URL()and its protocol ishttp:orhttps:and nothing else;sourcematches^[a-z0-9][a-z0-9-]{0,31}$;occurred_atoptional ISO 8601, refused more than 5 minutes ahead or 30 days behind, default now;dedupeoptional, at most 64 of[A-Za-z0-9._-].role === \"app\"takes the source fromwho.idand ignores anysourcein the body;adminmay name it;userandanongetforbidden/signin_required.(source, dedupe)twice returns the stored event withok: trueand writes no second row — a retry is free.listEvents(db, who, { source?, kind?, before?, limit? }), newest first;kindmatches exactly or as a dotted prefix (orderfindsorder.placed,ordfinds nothing);limitclamps to 1..200, default 50;beforepages by id; a filter failing its pattern isinvalid, not silently ignored.emit(\"event.posted\", event)in the action.GET /api/events?source=&kind=&limit=&before=andGET /api/events/:id(404 when absent), answering withanswer(...).src/actions.test.tscovers each refusal by name, the dedupe replay, the prefix filter, the clamp, the app-vs-admin source rule, and ajavascript:link refused.bun testgreen; the README describes an event and the two endpoints.Files:
src/db.ts,src/actions.ts,src/actions.test.ts,src/who.ts,src/routes.ts,src/mcp.ts,src/page.ts,src/tty.ts,src/server.test.ts,README.mdRisks: removing notes touches every door at once — replace, do not delete, so the app keeps booting. SQLite needs a partial unique index for a nullable dedupe. A
linkis poster-controlled text another issue will put in anhref; validating the scheme here means no door has to remember. Appending a migration collides textually with the other two ready issues — a one-line rebase, and order does not matter as long as none is edited after it has run.Suggested: Ledger — append-only rows with an idempotency key are their brief exactly.