Skip to content

Latest commit

 

History

History
672 lines (566 loc) · 43.4 KB

File metadata and controls

672 lines (566 loc) · 43.4 KB

Implementation notes

Non-normative. docs/domain/ is the specification; this file records the shape the code took so that a change lands in the same place every time.

Layout

public/console/             the console (R30), admin and reviewer — ES modules, no build step
packages/domain/            pure domain logic — no Cloudflare imports, no I/O
  shared/                   ids, time, errors, PII, authorization, ports (11)
  events/                   the catalogue from 10 as types + the envelope
  identity/ event-config/ sponsorship/ submissions/ review/ program/
  onboarding/ scheduling/ crm/                       one per bounded context
packages/data/              repository layer over D1 (R16) + the unit of work
packages/plugins/           capability contract implementations (09)
workers/api/src/
  http/                     router, request context, idempotency, input, responses
  ui/                       html kit, layout components, page shells
  contexts/<context>/       service.ts, routes.ts, reactions.ts, cron.ts, views.ts
  consumers/                queue dispatch, reaction registry, cron registry, delivery
  durable/                  Durable Objects
  surfaces/                 home + public site and embeds
migrations/                 D1 migrations, sequential, never edited once applied
tests/unit/                 pure domain, no I/O; every invariant names itself
tests/integration/          @cloudflare/vitest-pool-workers against real bindings

Surfaces (09) are separated as modules and composed into one Worker entry, because a self-hosted deployment is one origin. No surface imports another's handlers.

The unit of work

Every mutation runs through an AppContext (packages/data/src/context.ts):

const app = ctx.app(eventId);
await doSomething(app, input);   // raises events + audit rows on `app`
await app.flush();               // persists the event log + audit, then publishes

flush() writes domain_event_record and audit_log, then hands the events to EVENT_QUEUE. Reactions run in the queue consumer and are idempotent on DomainEvent.id (enforced once, in consumers/dispatch.ts).

Rules that are enforced in one place

Rule Where
INV-11-2 soft delete (INV-11-1 no longer scopes — R9) packages/data/src/db.ts
INV-09-7 idempotency replay workers/api/src/http/idempotency.ts
Authorization matrix, INV-11-7 packages/domain/src/shared/authorization.ts
INV-09-5 / INV-11-4 PII redaction packages/domain/src/shared/pii.ts
Concurrency (compare-and-set), INV-11-14 Db.updateVersioned on row_version; forms round-trip it via http/concurrency.ts
Live updates (per-event room, no payload) durable/room.ts + packages/data/src/live.ts + surfaces/live.ts
Placement serialisation workers/api/src/durable/schedule.ts
Reaction idempotency consumers/dispatch.ts + event_reaction_log
Event replay after a queue-send failure consumers/replay.ts, run by cron platform.replay_unprocessed_events and POST /dev/drain
Dead-letter recording (podium-dlq) consumers/dead-letter.ts + dead_letter
Cron cadence (elapsed time, not epoch modulus) consumers/cron.ts + cron_job_run
INV-01-12 password hashing (Argon2id) packages/domain/src/identity/credentials.ts
INV-01-17 sign-in throttling workers/api/src/http/throttle.ts, called by both password-verifying routes in contexts/identity/auth-routes.ts
INV-01-18 session transport, redirect targets setCookie / safeNext in workers/api/src/http/context.ts
INV-11-15 uploaded files are never served as documents resolveContentType / safeServeContentType in packages/domain/src/content/assets.ts
Response hardening (CSP, nosniff, framing, HSTS) workers/api/src/http/headers.ts, applied to every response in index.ts
INV-02-14…17 event provisioning (starter blueprint, clone) contexts/event-config/provisioning.ts over packages/domain/src/event-config/blueprint.ts
INV-09-17…19 sync field maps, authority and echo suppression packages/domain/src/platform/sync.ts (pure); applied in contexts/platform/sync.ts
INV-09-20 sync writes go through the owning context's service contexts/platform/sync-subjects.ts — one adapter per subject, each calling that context's own service.ts
INV-09-22 erasure propagation to external mirrors erasePersonEverywhere in contexts/platform/sync.ts, driven by the platform.sync_erasure reaction

Password hashing, and the plan that pays for it

ARGON2_PARAMS is m=12 MiB, t=3, p=1 — a configuration on OWASP's Argon2id list. The model requires Argon2id (01, INV-01-12) and names no parameters, so the choice is an implementation one.

It was not always this. Between the move to the Cloudflare Workers free plan and 2026-08-12 it was m=256 KiB, t=1, roughly two orders of magnitude cheaper to attack offline than it should have been. That was not an oversight and it was documented as a stopgap while it lasted: the free plan allows 10 ms of CPU per invocation, a correctly-sized hash costs far more than that, and every sign-in was being killed with exceededCpu (Cloudflare error 1102). Password login was down in production, intermittently succeeding only when the platform tolerated a burst. A weak hash that works beat a strong one that does not, until the plan changed. The deployment is now on Workers Paid, whose budget is measured in seconds, and the stopgap is reverted.

Why m=12288, t=3 rather than the m=19456, t=2 OWASP lists first. Measured directly, they cost the same — 108 ms against 116 ms per hash — so the choice is not about time. It is about memory. A Workers isolate has 128 MB, each concurrent sign-in holds its own Argon2 buffer for the duration of the hash, and 12 MiB leaves room for roughly ten at once where 19 MiB leaves six. Both are OWASP configurations; this one has more headroom on the runtime that has to run it. Worth revisiting if sign-in concurrency is ever measured rather than guessed at.

Nothing migrates. Parameters live inside each stored PHC string, so:

  • A hash written at m=256 still verifies at its own cost, and needsRehash carries it up to the current parameters on its owner's next successful sign-in, while the plaintext is in hand. There is no batch path and none is needed — nobody holds the plaintexts.
  • beyondCpuBudget refuses a stored hash whose m exceeds MAX_VERIFIABLE_M, because attempting one does not fail the sign-in, it kills the isolate. That ceiling must always sit above ARGON2_PARAMS.m. It was left at 1024 when m dropped to 256, which was correct then and became a trap: raising m back to 12288 alone would have made every newly-set password unverifiable the moment it was written, with no error until somebody tried to sign in. It is now 65536, and credentials.ts throws at module load if the two ever invert.
  • scripts/seed.mjs writes its own hashes and carries its own copy of the parameters, so it moves with ARGON2_PARAMS. It did not, once, and every seeded password became unverifiable; tests/unit/shared/seed-credentials.test.ts holds the two in step in both directions.

Online guessing is separately bounded by INV-01-17 (workers/api/src/http/throttle.ts), which matters less now than it did when the hash was cheap, but is the control that stops the guessing rather than merely making each guess expensive.

row_version is the optimistic-concurrency counter. It is not called version because several entities already use that name with a domain meaning. Every write to a versioned row bumps it, not only the compare-and-set ones — a counter that advances solely on checked writes misses the transition that landed in between, and the next check then passes for an edit that was in fact stale. An edit form renders the version it read (versionField) and returns it on submit; a stale submission is refused with 409 and a warning rather than overwriting the other writer (INV-11-14).

Provisioning a new event

contexts/event-config/provisioning.ts is the one module that reaches across contexts on purpose. Creating an event applies the starter blueprint or clones an earlier edition (02, "Starting an event"), and both need rows that belong to review, onboarding, sponsorship and platform — so each of those exposes its own copier (copyRubricToEvent, copyTaskDefinitionsToEvent, copyTiersToEvent, copyTemplatesToEvent) and provisioning only sequences them. Nothing here writes another context's table directly, and every row goes through the same service a hand-built event would use, so a provisioned event has no second class of row in it.

The pure half — the shipped blueprint data, day generation and the day-shift rebasing of INV-02-16 — is packages/domain/src/event-config/blueprint.ts, with no I/O in it.

Derived fields

Fields marked D have no column. They are computed at read time, in the context that owns them, and are never accepted from a request body (INV-11-6). The two materialised exceptions are the publication snapshot (immutable once live) and schedule_conflict (recomputed on every placement write so acknowledgements survive).

Until the two-way sync, that rule held by omission: every service builds its patch from a named field set, so a derived column simply had no route that could write it. Two surfaces now take a field bag from outside — bulk import and sync — and they carry the rule explicitly instead, as per-subject writable sets (SUBJECT_SPECS in packages/domain/src/platform/sync.ts) validated at save time. derivedFieldWrite in shared/errors.ts had no call site before this and now has one.

Two-way sync

sync_mappingexternal_record_linksync_run, with the domain rules pure in packages/domain/src/platform/sync.ts and the orchestration in contexts/platform/:

Piece File
Field sets, value space, hashing, echo check, link state machine packages/domain/src/platform/sync.ts
Per-subject load / list / derive / apply contexts/platform/sync-subjects.ts
Mappings, push, pull, conflicts, erasure contexts/platform/sync.ts
Debounced enqueue and the delivery handlers contexts/platform/sync-delivery.ts
Admin screens and /v1/sync/… contexts/platform/sync-routes.ts
Providers packages/plugins/src/sync/{airtable,memory}.ts

The field model is deliberately not lowest-common-denominator. A subject declares semantic kinds — select, multi_select, link, attachment — and only the adapter knows those are singleSelect, multipleSelects, multipleRecordLinks and multipleAttachments. Speakers are pushed as real links to the Speakers table, tracks as a dropdown matched by name, headshots as files the provider fetches. A base whose Track column is text cannot group by track, and a grid that cannot group is the spreadsheet the organizer was trying to leave.

Three rules fall out of that: a relationship is declared on one side only (providers create the reverse column themselves); link and attachment are never hashed, because their values are provider record ids and fetched file copies this system cannot reproduce; and neither is ever accepted back (INV-09-24). ensure_table creates the columns with the right types, so setup is not twenty hand-typed names.

Two more things to know before changing any of it. The push half never calls a provider from a reaction — it flips links to pending_push and enqueues one debounced sweep per mapping, because a decision.published batch over four hundred proposals is four hundred events. Accepting an inbound change leaves the link pending_push, not in_sync, so Podium re-pushes what it actually stored rather than what the spreadsheet proposed; that cannot loop, because the re-push's own hash lands in last_pushed_hash and the provider's echo of it fails the check in INV-09-19.

sync.memory is a working provider against an in-memory store, in the same spirit as email.log. Install it in npm run dev to watch the loop close without an Airtable account; resetExternalTables, editExternally and insertExternally are what the integration tests drive it with.

URL map

Surface Paths
Public /, /e/:slug, /e/:slug/cfp/:cfpSlug, /e/:slug/schedule, /e/:slug/sessions, /e/:slug/sessions/:id, /e/:slug/speakers, /e/:slug/speakers/:personId, /e/:slug/gallery, /e/:slug/schedule.ics, /embed/:key
Auth /login, /signup, /logout, /invite/:token
Portal /portal, /portal/proposals, /portal/proposals/:id, /portal/sessions/:id, /portal/tasks/:id, /portal/profile
Reviewer /review, /review/:assignmentId
Admin /admin, /admin/events/:eventId/…, /admin/sponsors, /admin/contacts, /admin/team, /admin/settings
Live /live/subscribe (WebSocket upgrade), /live.js (static asset)
Public API /v1/public/…
Management API /v1/…
Provider callbacks /integrations/:id/inbound (signed URL, no session — 09; dispatched on the integration's capability)
Sync /admin/sync (conflict queue), /admin/sync/:mappingId, /admin/integrations/:id/sync, /v1/sync/…

Conventions

  • Server-rendered HTML through ui/html.ts and ui/layout.ts on the applicant side — public, embeds, /portal and /review — with no client framework. Public surfaces must render fully with scripts blocked (08, "Degrade gracefully"). The admin console is the exception and is client-rendered over /v1; see R30 in 13 for where the line falls and why /review sits on the server-rendered side of it.
  • Every transactional email — system-triggered and campaign alike — is wrapped by ui/email-layout.ts, ported from docs/design/emails/ (09, "Conference-first rendering and audience"): the event or organization owns the header and voice, Podium is a one-line footer credit, and the per-template_key audience picks the header label and the footer's permission-reason line. contexts/platform/notifications.ts's attemptSend and previewTemplate are its only two callers, so what an organizer previews is what a recipient gets.
  • Code enforcing an invariant names it in a comment; its test names it in the title.

Two design languages

There is no single stylesheet. PageOptions.surface picks one of two, in ui/layout.ts::stylesheet(), and the two share no colour, no typeface and no component:

Surface Stylesheet Language
admin, reviewer public/admin.css The console. Dark rail, Public Sans + JetBrains Mono, mono numerals, keyboard accelerators. Dense, for the two or three people in it daily.
portal, public, auth public/portal.css The program book. Source Serif 4 + Karla, paper ground, terracotta accent, one job per page. For a speaker three times a year and a reader once.

Both ship a complete dark palette under prefers-color-scheme, not a filter over the light one. The console's rail is the one surface that does not move between them: it is already the darkest thing in the light theme, and inverting it would leave the page with no fixed point. Typefaces are self-hosted in public/fonts/ (scripts/fetch-fonts.mjs, run by hand) because the CSP names 'self' and a public page must not need a third-party origin to render.

public/console.css still loads after admin.css and still only adds. Its opening block maps the token names it already used (--indigo, --navy, --line) onto the console language's (--accent, --ink, --hairline) — a bridge, not a second palette, so the console's own components follow the language including into dark mode.

What the redesign cost, measured

Profiled against the budgets in implementer.md, G. Client numbers are Lighthouse's mobile profile (1.6 Mbps, 150 ms RTT, 4× CPU) driven through CDP on a cold cache; server numbers are best-of-seven against wrangler dev on local D1.

Client — first render, budget < 1.5 s. The first cut shipped the font declarations as an @import inside each stylesheet, which is a serial chain: HTML, then the stylesheet, then fonts.css, then the typeface. Measured, the first byte of Source Serif 4 was not requested until 543 ms and did not arrive until 1492 ms.

before after
/e/:slug/schedule load 1497 ms 761 ms
/e/:slug load 1414 ms 775 ms
CSS + font bytes 143 KiB 73 KiB
typeface arrives 1492 ms 757 ms

Two changes did it. The @import became a <link> in the head beside a preload of the two faces each language opens with, so five requests that used to chain now leave together (ui/layout.ts::stylesheet()). And Source Serif 4 ships without its optical-size axisopsz 8..60 costs 119.5 KiB against 49.6 KiB for the roman latin face. That is a stated departure from the design handoff, taken on the measurement: optical sizing is a real refinement, and it is not worth two-thirds of the first-render budget on the one surface that has one. Every surface now loads in 723–861 ms except the console's boot document at 1228 ms, which additionally fetches its ES modules and its dashboard.

first-contentful-paint came back null on most runs in headless Chromium under CDP throttling, so the table reports load — a strictly later event, and therefore a conservative reading of a first-render budget.

Server — D1 statements per request. Every response outside production carries x-podium-d1-queries and x-podium-d1-roundtrips (index.ts::withQueryCount), and tests/integration/foundation/query-budget.test.ts holds the hot screens to a ceiling.

Route before after
/admin (Today, server-rendered) 94 35
GET /v1/events/:id/dashboard 90 37
/admin/events/:id/publications 55 20
/review, /portal, /e/:slug 15, 17, 4 6, 9, 4

Four N+1s were the first cause and are gone: eleven COUNT(*) … WHERE status = ? per table became one GROUP BY; four statements per review round and one per call for papers became one grouped query each; pendingChanges loaded the schedule, handed it to buildSnapshot, and then loaded it again; and that snapshot assembled a full publication payload — speakers, profiles, sponsors, tiers, assets — to derive a count it then discarded (verdictsOnly).

Profiling every action, not the screens we suspected

npm run perf:db (scripts/perf-db.mjs) walks every route against a seeded local instance as each persona, and joins the count on each response to the statement text behind it, drained from /dev/profile. npm run perf:db:check compares against .claude/skills/db-performance/baseline.json and exits non-zero on a regression, which is what makes this a loop rather than a one-off; the db-performance skill carries the procedure and the catalogue of shapes to look for. The route list comes from the security skill's attack_surface.py inventory rather than a list kept beside it, so a route cannot be added without being profiled. It reports three things a single number cannot: the shared buildContext baseline, the statements a request repeated (the N+1 detector — identical parameterised SQL sent k times is a loop over rows, whatever the code looks like), and where the budget went by table.

Run across 162 routes, it found that 60% of every statement in the product was request context, not any screen. The result of acting on it:

before after
statements across the 162-route walk 2533 1229
median per action 14 6
p90 · max 25 · 68 15 · 37
buildContext baseline, paid by every request 11 3

The baseline is where most of it came from, and it was four separate mistakes:

  • The organization's id was selected, and then the row that id names. resolveOrg returns the row and the id comes off it — a fix 9b40968 on main reached independently and at the same time, which is its own evidence that the baseline was where to look.
  • The session was looked up by token hash and the person by the id it holds — two round trips for a pair that is never useful apart. One join, stating the soft-delete exclusion (INV-11-2) that byId would have applied and a raw read does not get. The merge pointer (INV-01-9) is followed with a second statement only when it is set.
  • Grants plus the five relationship tables were six round trips before any route ran. They are now one row of json_group_array subqueries (PRINCIPAL_FACTS_SQL). UNION ALL is the obvious shape and does not fit: D1's SQLite is built with SQLITE_MAX_COMPOUND_SELECT at 5, so six arms fails outright — worth knowing before reaching for the same trick elsewhere.
  • auth_session.last_seen_at and api_key.last_used_at were written on every request, making every read of the product also a write. Both are display-only; they now refresh at most once a minute.

Beyond the baseline, the profile named the loops directly:

Route before after what it was
/admin/events/:id/files 68 11 a COUNT(*) and a label lookup per slot
/admin/cfps/:id/form/preview 46 15 per-option format/track reads, plus a whole publicCfpView fetched for one column
/v1/me/assignments/:id 43 29 the same event and round re-read by four layers
/v1/events/:id/schedule 40 20 the schedule fact set loaded twice
/admin/rounds/:id/assignments/auto 39 25 three statements per proposal to grade conflicts
/admin/events/:id/decisions 35 16 six statements per proposal to find its score
/admin/events/:id/sponsorships 28 13 three statements per sponsorship

The per-request identity map (packages/data/src/db.ts) is the one structural change rather than a local fix. A request builds several AppContexts — ctx.app() returns a fresh one each call — so nothing was in a position to notice that the route guard, the page shell and the handler had all just read the same event row; it was the single most repeated statement in the product. byId now answers from a map keyed on the per-request binding. Three rules keep it from becoming a staleness bug: primary key reads only (a select is a question about a set), any write to a table drops that table's rowsrawRun and batch drop everything, since this layer does not parse their SQL — and opt-in for safe methods only, so no mutating request, queue consumer or cron sweep has a cache at all. tests/integration/foundation/row-cache.test.ts pins all three.

Statements are not round trips. d1.batch() sends any number of prepared statements in one call, so publishing a 200-session schedule through insertMany prepares 200 statements and makes one call. The profiler and the response headers now report both, because an optimisation that moves work into a batch shows up in one and not the other. Publishing was also doing a read and a write per session to move it to published (INV-08-5) — 400 statements for a 200-session conference — which is now one read and one batched write, and all-or-nothing as a bonus.

Two costs remain and neither is a defect. 3 statements of request context are the floor for any signed-in page. ~20 statements answer "what is not published yet": the diff has to read the schedule to be exact, and it is flat in the number of sessions rather than N+1. The single-digit budget is reachable only by making one of those approximate, which is a product decision rather than an optimisation.

The rail, and where its counts come from

ui/shell.ts::adminRail() replaced a flat row of fourteen section tabs with the event's own workflow in five phases. The counts beside each destination load once per admin page view in ui/rail-counts.ts — one query, in the same pre-router seam consoleDocument uses, read off ctx.rail by a synchronous adminPage. public/console/app.js::railGroups() is the same list for the client-rendered console and takes its counts from the boot payload. The two lists have to agree: they share URLs, and a reader moving between them must not find the navigation rearranged.

Two deliberate departures from the design handoff, both because the prototype assumed a screen list this product does not have: there is no Inbox (nothing here is one, and the slot went to the event's overview), and Sessions and Speakers are two screens rather than the prototype's single "Sessions & speakers".

The reviewer surface is not the speaker portal

/review and /review/:assignmentId render through reviewerPage, not portalPage. They used to share the portal's chrome, which made a reviewer's queue a tab of the speaker portal — but a reviewer is doing operations work on a deadline, and the two roles are frequently the same person wearing different hats, so sharing chrome meant the hat was never stated. The reviewer now gets the console's language and its keyboard, a rail of its own (My queue / Submitted / Declined, which are ?show= filters on one route), and exactly one quiet link back to the portal that says it is leaving.

It is now the console's, as of R30's amendment — the same client, the same components, its own rail. What it is not is a screen of the admin console: boot.surface says reviewer, and on that value the shell draws the three-item rail instead of the event's workflow, flies no event bar or event switcher, and omits ⌘K (reviewerPage passes search: false for the same reason — a palette over three destinations is a menu pretending to be a search box).

The server-rendered pages are still registered and still answer ?nojs=1, which is what R30's original argument for keeping this surface server-rendered — reviewers are on tablets and phones — actually needed. The two are one read model apart, not two:

contexts/review/reviewer-model.ts   loads the queue and the scorecard, once
  → reviewer-views.ts               renders them as HTML
  → GET /v1/me/assignments[/:id]    sends them as JSON  (09, "The reviewer's own queue")
public/console/views/reviewer.js    renders that JSON

The read model, not the view, is where the withholding lives — reviewer identity under a non-open round, other reviews before INV-05-6 opens them, conflicts counted rather than listed. A rule enforced in a view is a rule the other surface does not have, and this surface now has two.

Three things about it are worth knowing before changing it:

  • Writes go through endpoints that already existed. POST /v1/reviews takes the draft and the submission, from a signed-in person and never a key (INV-09-27). Only declining needed a route of its own, and it is under /v1/me/ for the same reason the reads are.
  • The boot document refuses an assignment that is not the reader's. eventForMatch asks ownsAssignment before booting, so /review/:someone-elses falls through to the server-rendered route and is denied by name. INV-05-18 says such a request is denied, not merely unlinked, and a 200 carrying an empty shell is neither.
  • The scorecard's enums arrive with the scorecard. recommendation, confidence, the flags and the decline reasons are in the payload rather than in the JavaScript, because an enum is additive and a member added in 05 must not reach one of these two surfaces and not the other.

The keyboard layer

public/keys.js, loaded on admin and reviewer only. ⌘K opens a palette, J/K move a selection through [data-keylist] [data-keyrow], opens it, g then a letter jumps to the rail item starting with it, ? lists the lot.

The palette searches navigation and actions, not records. There is no endpoint behind it and no query leaves the browser; it reads its contents out of the DOM — the rail is the list of destinations, and the page's own action buttons are the list of things you can do here. That is what keeps it from drifting from the rail, from offering what the reader may not do (the server already decided whether to draw each one), and from needing a second implementation for the client-rendered console.

Nothing in it is load-bearing. Every destination it reaches is a link already in the rail, so with scripts blocked the console is the same set of pages one click further apart. It builds DOM nodes rather than assigning innerHTML, so it adds no HTML sink to security-audit's baseline.

The admin console

R30's client-rendered console, built. It lives in public/console/ and is served by workers/api/src/surfaces/console.ts.

No build step. These are ES modules the browser loads directly, served from the edge like /app.css and /live.js. That is what keeps R30's accepted cost — two UI stacks — from also meaning two toolchains: npm run dev, npm test and npm run deploy are unchanged, and there is no bundler, transpiler or second lockfile.

public/console/kit.js       ~200 lines of keyed virtual DOM + the redraw loop
public/console/api.js       the /v1 client; every write is JSON (see below)
public/console/router.js    the client route table and link interception
public/console/store.js     boot payload, toasts, drawer, async resources
public/console/dnd.js       pointer-event dragging, with keyboard equivalents beside it
public/console/live.js      the same socket as /live.js, invalidating instead of nudging
public/console/ui.js        the components layout.ts renders on the server, as vnodes
public/console/views/       one file per screen (`reviewer.js` is the other surface's two)
public/console.css          only what the console added; app.css is still the shared artifact

It shares URLs with the screens it is replacing. consoleDocument runs before the router in index.ts and takes a request only when the path is in CONSOLE_PATHS, the caller is a signed-in person with the capability, and ?nojs=1 is absent. Anything else falls through to the server-rendered page, which is still registered and still works. So the port is incremental rather than a flag day, <noscript> has somewhere to point, and tests/integration/foundation/concurrency.test.ts still drives the real HTML forms.

Ported so far — seventeen screens. Fifteen are the organizer's daily loop end to end: /admin, the event dashboard, the proposal board and a proposal, the agenda grid, the form builder, and the events / setup / calls / sessions / review / speakers / onboarding / publish lists. The other two are the reviewer's, /review and /review/:assignmentId — the same client under a different rail, described above. Navigating between any of them is same-document.

Still server-rendered, deliberately: the write-heavy detail forms — a session, a call's settings, a decision, a round's assignments, and the organization-wide settings screens. Each already round-trips row_version and refuses a stale write (INV-11-14), and a form reimplemented badly is worse than a form that reloads. The console links to them rather than hiding them.

public/console/app.js and surfaces/console.ts each hold the route list and the two have to agree — a path the server boots and the client cannot match renders an empty shell. Each entry on the server side also names the capability that gates it; the two reviewer paths name none, deliberately, because that surface is scoped by whose assignments these are (INV-05-18) and not by a matrix row, which is exactly what the server-rendered /review does. Gating the console harder than the page it shares a URL with would hand two readers of the same link two different products.

Testing a ported path. An integration test asserting server-rendered HTML for a URL the console owns must ask for ?nojs=1, or it will assert against the boot document. That is not a workaround: the server-rendered page is still the fallback and still has to work, so the test is exercising something real. Where the console reaches the same guarantee by a different route — conflicts in a placement's response, compare-and-set on a PATCH, PII withheld from a list endpoint — assert both, because each surface has its own way of losing it.

Ids are not a presentation. Several /v1 list endpoints grew display names beside their ids (track_name, speaker_names, assignee_name). The entity shape is right for an integration syncing into another system and unreadable as a table — nobody can scan a column of trk_01J…. The names are resolved in one pass in the route, never per row, and never past a visibility rule: /v1/tasks adds them only to rows this reader may see in full, because INV-07-10 keeps a restricted row to its title and status and a name is neither.

Two properties R30 named as making this cheap are now relied upon rather than assumed, and should not be changed without reading it first:

  • SameSite=Lax with no CSRF token is the console's defence. It holds because Lax withholds the cookie from a cross-site POST and application/json is not a form-encodable content type, so a cross-origin write needs a preflight that will not be granted. Every write in api.js therefore sends JSON and never FormData.
  • Permissions are recomputed per request, so the console caches no authority. The boot payload's can / can_write maps decide whether a control is drawn; the server decides again on the write.

The event in context follows the route. boot.event is the answer for the path the document was opened at, and a console that never reloads outlives it. Every client-side arrival that crosses out of that event re-reads the payload through GET /v1/console/bootstrap?path=…, which resolves the path with the same matchConsolePath / eventForMatch pair the boot document uses — so the two agree by construction rather than by review, including for the paths that reach their event indirectly (/admin/cfps/:cfpId/form through the call, /admin/proposals/:proposalId through the proposal). It carries the boot document's capability check with it. The rule, in syncEventContext in app.js: a route that names an event switches to it; a route that names none — /admin, /admin/events — keeps the one already in context, and a cold load of those has nothing to keep, which is the one place the two arrivals differ and they differ only by keeping more context. Permissions come back with the event, because can / can_write are computed against it; adopting an event without them is the stale authority R30 says the console must never hold. tests/integration/foundation/console-context.test.ts asserts each path's bootstrap answer against its own boot document, so a new console path that resolves differently in the two places fails the build.

The module graph is preloaded, not discovered. CONSOLE_MODULES in surfaces/console.ts lists every file the console loads, and shell() emits a modulepreload for each. Preloading only app.js left the browser finding the views after parsing it and their shared imports after parsing those — two idle round trips, 84–177 ms of the cold load. A view added to views/ belongs in that list; leaving it out costs a round trip and nothing else, so this decays quietly rather than breaking.

Reads the console alone needs are GET /v1/console/bootstrap, GET /v1/events/:eventId/dashboard (surfaces/dashboard.ts — a cross-context read model, beside admin-home.ts for the same reason) and GET /v1/cfps/:cfpId/builder. The reviewer surface's two reads are not among them: /v1/me/assignments and /v1/me/assignments/:id are the portal surface as 09 defines it — a session cookie and relationship-derived scope — and the server-rendered pages read the same model. Everything else it does goes through the ordinary management surface.

The board asks for its columns. GET /v1/proposals takes ?fields= (http/projection.ts, specified in 09 under the list-endpoint rules) and the board sends the columns the reader has picked. Projection is applied to the serialised row, after includePii has decided its contents, which is what makes it unable to widen anything; tests/integration/submissions/wizard.test.ts asserts that naming a withheld field does not un-withhold it.

Measuring it. node scripts/perf-console.mjs walks the fourteen organizer console routes in a real browser and reports three numbers per screen — a cold load with an empty cache, a warm reload, and a client-side navigation into the screen — plus the server time for every document and every /v1 endpoint the console was seen to call, harvested from the run rather than listed by hand. It needs Playwright, which is deliberately not a dependency of this repository (npm i -D playwright); nothing else here drives a browser.

"Ready" is not load: it is the first frame on which the screen's skeleton is gone and no /v1 request is in flight, which works uniformly because every view in views/ renders .console-skeleton while its resource loads. The three numbers exist because they answer different questions — the cold one is what a morning URL costs, the in-app one is what R30 actually bought, and the gap between them is the module waterfall and the boot document. A screen marked * fetched nothing when navigated into although its cold load fetched: it is either reusing the previous screen's resource or rendering a different branch, and the two columns are not comparable until you know which.

node scripts/perf-seed-scale.mjs --proposals 800 inflates the local database to the size an open call actually reaches, because the seed is sized to make every screen true rather than to make a benchmark strain. It writes copied rows straight into D1, below the domain layer — allowed precisely because nothing about behaviour is being asserted — and npm run db:reset puts the seed back.

Live updates

One Durable Object per event (ROOM_DO, durable/room.ts), holding hibernatable WebSockets. Four properties, in the order they matter:

  • A frame carries no domain data — only {type, subject, occurred_at}. The client refetches through the ordinary authorized route, so blinding and PII redaction are inherited rather than reimplemented on a broadcast path where a divergence would fail silently. This is also what makes a long-lived socket tractable: authorization happens once at the handshake, and the side channel's worst case is bounded by a 30-minute lifetime cap plus an immediate kick on role_grant.revoked and sign-out.
  • No payload is not no information, so every frame carries an audience and every socket is tagged role:staff / role:member. Review-internal types are staff-only: a reviewer is deliberately not staff here, because 05 blinds them from their peers and when a review landed correlates.
  • Two transports, one frame. AppContext.flush() pokes the room directly (~100ms, allowed to fail); platform.room_broadcast pokes it again off the queue (seconds, durable). The room dedupes on DomainEvent.id. Topics are an allowlist in packages/domain/src/events/catalogue.ts, beside PII_EVENT_TYPES — not *, which would cost an event_reaction_log insert per event to decide it had nothing to say.
  • The client is one vanilla file, public/live.js, loaded only on screens that opt in via PageOptions.live. No framework and no build step; it either shows a "N changes · Reload" bar or, on read-only dashboards, refetches the current URL and swaps <main> — never while a control in it is dirty. Public surfaces do not opt in and still render fully with scripts blocked. It is the first .js file on disk; the rule it has to keep is "no framework, no build step, public surfaces work without scripts", and it keeps all three.
  • Typed errors (DomainError) carry invariant; the HTML layer renders them as a page and the JSON layer as the documented body shape.
  • Enum members live as as const arrays in packages/domain/src/<context>/types.ts, so a drift checker can compare them with the model.

The seed, and the two things it cannot do in SQL

scripts/seed.mjs emits one SQL file and applies it. Two parts of a populated conference are not reachable that way, and both are finished afterwards, against the running Worker, through the surface an organizer would use:

  • The publication. Every public surface serves the live publication and nothing else (INV-09-6), so scripts/dev.mjs signs in as the seeded organizer and publishes — now for both editions, since the archived one's agenda, speakers and ICS feed read the same snapshot the current one's do. Faking a snapshot in SQL would be the one place the seed asserted a shape the publisher never produced.
  • The bytes behind the images. The seed writes asset rows — it has to, because it also writes the speaker_profile and sponsor rows that point at them — with the same slot_key and storage_key shape uploadAssetDirect derives, plus the generated PNGs on disk. scripts/seed-assets.mjs then pushes those files through PUT /dev/assets/:id, which puts each one at the key its own row already names. R2 has no SQL surface, and wrangler r2 object put spends about six seconds of process startup per object, which is most of a minute for two dozen images; the running Worker already holds the binding.

The images themselves come from scripts/lib/placeholder-image.mjs, a PNG encoder over node:zlib and a 5×7 bitmap font, deterministic in the seed string so a re-seed overwrites the same objects rather than accumulating versions of the same face. They are placeholders, but they are files: they exercise /assets/:id, the scan gate (INV-11-3) and the content-length the row claims, all of which a hardcoded <svg> fallback would route around.

Seeding a deployment

npm run seed:production points the same seed at the deployed D1 and R2. It exists because the hosted demo at app.podiumstack.com is seed data that nothing resets, so it has to be re-runnable rather than a thing somebody assembles by hand each time.

Three properties make that safe, and each one is load-bearing:

  • The SQL clears its own rows first. teardown() in scripts/seed.mjs emits a DELETE per table, derived from the tables insert() actually wrote rather than maintained beside them, so a table the seed starts writing is one the teardown starts clearing in the same commit. Re-applying without it is not an option: ids come from a counter, so inserting a row in the middle shifts every id after it — adding the 2026 edition moved 441 of the previous seed's 491 ids, and INSERT OR REPLACE would have left all 441 old rows behind. The deletes match on _01JQ0000, the fixed epoch every seeded id carries and nothing real does, so they cannot touch a row the seed did not write.
  • The organization's created_at is pinned to 2020-01-01, not now. resolveOrg serves the oldest organization to every request, so a seed stamped with today's date would hand a deployment that has ever held another org to that other org, and render the seeded conference unreachable.
  • The from address is SEED_FROM_EMAIL. The seeded Resend integration is active, and devflowconf.example is reserved and unroutable — right locally, wrong anywhere that can actually send, because Resend refuses a from address outside a verified domain.

Images take the second path there: /dev/* is refused when ENVIRONMENT === "production", so scripts/seed-assets.mjs --remote falls back to wrangler r2 object put, eight at a time to hide the process startup the dev route exists to avoid.