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.
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.
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 publishesflush() 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).
| 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 |
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=256still verifies at its own cost, andneedsRehashcarries 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. beyondCpuBudgetrefuses a stored hash whosemexceedsMAX_VERIFIABLE_M, because attempting one does not fail the sign-in, it kills the isolate. That ceiling must always sit aboveARGON2_PARAMS.m. It was left at 1024 whenmdropped to 256, which was correct then and became a trap: raisingmback 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, andcredentials.tsthrows at module load if the two ever invert.scripts/seed.mjswrites its own hashes and carries its own copy of the parameters, so it moves withARGON2_PARAMS. It did not, once, and every seeded password became unverifiable;tests/unit/shared/seed-credentials.test.tsholds 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).
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.
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.
sync_mapping → external_record_link → sync_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.
| 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/… |
- Server-rendered HTML through
ui/html.tsandui/layout.tson the applicant side — public, embeds,/portaland/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 in13for where the line falls and why/reviewsits on the server-rendered side of it. - Every transactional email — system-triggered and campaign alike — is wrapped by
ui/email-layout.ts, ported fromdocs/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_keyaudiencepicks the header label and the footer's permission-reason line.contexts/platform/notifications.ts'sattemptSendandpreviewTemplateare 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.
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.
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 axis —
opsz 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).
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.
resolveOrgreturns the row and the id comes off it — a fix9b40968on 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
byIdwould have applied and arawread 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_arraysubqueries (PRINCIPAL_FACTS_SQL).UNION ALLis the obvious shape and does not fit: D1's SQLite is built withSQLITE_MAX_COMPOUND_SELECTat 5, so six arms fails outright — worth knowing before reaching for the same trick elsewhere. auth_session.last_seen_atandapi_key.last_used_atwere 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 rows — rawRun 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.
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".
/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/reviewstakes 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.
eventForMatchasksownsAssignmentbefore booting, so/review/:someone-elsesfalls 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 in05must not reach one of these two surfaces and not the other.
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.
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=Laxwith no CSRF token is the console's defence. It holds because Lax withholds the cookie from a cross-site POST andapplication/jsonis not a form-encodable content type, so a cross-origin write needs a preflight that will not be granted. Every write inapi.jstherefore sends JSON and neverFormData.- Permissions are recomputed per request, so the console caches no authority. The boot
payload's
can/can_writemaps 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.
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 onrole_grant.revokedand 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_broadcastpokes it again off the queue (seconds, durable). The room dedupes onDomainEvent.id. Topics are an allowlist inpackages/domain/src/events/catalogue.ts, besidePII_EVENT_TYPES— not*, which would cost anevent_reaction_loginsert 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 viaPageOptions.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.jsfile 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) carryinvariant; the HTML layer renders them as a page and the JSON layer as the documented body shape. - Enum members live as
as constarrays inpackages/domain/src/<context>/types.ts, so a drift checker can compare them with the model.
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
livepublication and nothing else (INV-09-6), soscripts/dev.mjssigns 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
assetrows — it has to, because it also writes thespeaker_profileandsponsorrows that point at them — with the sameslot_keyandstorage_keyshapeuploadAssetDirectderives, plus the generated PNGs on disk.scripts/seed-assets.mjsthen pushes those files throughPUT /dev/assets/:id, which puts each one at the key its own row already names. R2 has no SQL surface, andwrangler r2 object putspends 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.
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()inscripts/seed.mjsemits aDELETEper table, derived from the tablesinsert()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, andINSERT OR REPLACEwould 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_atis pinned to 2020-01-01, notnow.resolveOrgserves 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, anddevflowconf.exampleis 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.