The tiebreaker is judged on product judgment. Every purposeful deviation from Sessionboard gets a line: what + why. Silent gaps read as bugs.
Calendar invites ship as ICS email attachments (
METHOD:REQUEST), not per-provider OAuth push — native invite in Gmail/Outlook/Apple with zero speaker setup. Contingency (Sat-morning spike): if providers render it as a file attachment instead of an invite, the shipped design becomes a .ics download plus one-click Google/Outlook add-to-calendar deeplinks — decided Saturday, not Tuesday.D1 (SQLite) is the primary store; Airtable is a one-way mirror (cron/queue). Airtable's 5 req/s cap would sink the speed requirement; the mirror still gives the AIE team their data where they browse. Failure mode: mirror retries on a queue and can lag; it never blocks or slows a user write.
Unified session/abstract resource (immutable
is_abstract, composition link) — Sessionboard's own public API model, so API compat comes free and accept→agenda is a link, not a copy. Screenshots confirm: abstracts carry start/end/capacity; the Agenda is a view over records with times.AI-assisted review is a stretch behind a toggle. Organizer guidance during the challenge deprioritized the AI workflow outright, so rubric scoring ships first and the admin UI lands before any agentic interface, which stays a bonus.
Consistent API envelope everywhere. Sessionboard returns different envelopes for POST-search vs CRUD GETs; we don't reproduce the inconsistency.
Adopted the buyer's stated cuts (walkthrough video): payments, multi-language, and the CRM/marketing/CMS side are out of scope.
Min speakers per role defaults to 1. A minimum of 2 broke the incumbent product's own demo during the walkthrough.
Clone the workflow, not the pixels. Organizer guidance during the challenge was explicit that the screenshots are samples of functionality, and that copying screens and forms that do not actually work would miss the point of the exercise.
Field registry is per-event with
org_idreserved. Sessionboard's fields are org-level definitions with per-event overrides (the "This event only / All events" radio visible in the walkthrough). The override machinery is the slowest path to a working field library, so we ship per-event resolution now; the reserved column means adding org sharing is a migration, not a redesign.Subsessions deferred;
parent_session_idreserved in the schema. Modelled in their product and API (expand=subsession_details) but absent from the 9 required features; flat sessions cover the walkthrough.Auth: verified magic links + isolated one-click demo logins, not passwords. Every public identity proves mailbox ownership before receiving a web session; multi-use 15-minute tokens tolerate corporate link-prefetchers, while local development exposes the generated link for a fast test loop. Disposable judge deployments retain separately gated
/demoaccess.Raw HTML in resource pages is sanitized server-side. Sessionboard ships a warning banner saying it doesn't validate custom code; we validate it — with a negative test proving
<script>is stripped.Agenda views: List / Day / Week / Track / Room / Conflicts. The brief asks for Track; the live product has Month instead and no Track. We ship the brief's Track view, keep their excellent dedicated Conflicts screen, and cut Month (low value for a 1-3 day conference).
File uploads always attach to a record. Sessionboard's File Requests store files unattached to any entity; orphaned files are a data-hygiene bug, not a feature.
The 7-state status pipeline is kept as-is (Accepted / Accept Queue / Pending / Decline Queue / Declined / Withdrawn / Drafts). The queue states are the multi-round mechanic — staging decisions before committing them is the workflow's best idea.
Open-sourcing anyway. Organizer guidance confirmed OSS is not a hard requirement; we ship MIT regardless — the artifact has standalone value and it's in the spirit of the event.
CSV import + field mapping is a reserved stretch. The video shows the doc once listed it as feature 10; the current doc dropped it. The doc is live and re-diffed at every sweep; if it returns at the freeze, WS7's CSV machinery extends to import.
Session cookie is
SameSite=Lax, neverStrict. Every login starts as a cross-site navigation from an email client;Strictdrops the cookie on the one flow the product depends on. (WS0)A signed-in non-admin hitting
/admingets 403, not a redirect to login. Bouncing an already-authenticated user back to/loginis a redirect loop that hides the real problem. (WS0)Flat
participants[]only — no legacyspeakers[]/chairpersons[]/moderators[]junction triple. Sessionboard carries both for backwards compatibility; the triple doubles the join surface and their own docs say not to usecore_roleas a label. (WS0)Uploads are proxied through the Worker (25 MB cap), not presigned — extends #14: ownership checks live in one place, every R2 object gets a row in
uploadswith a non-null owner. SigV4 presigning buys nothing at conference file sizes. (WS0)React Router v8, not v7. The official Cloudflare
create-cloudflarereact-router template now ships v8; forcing v7 means fighting Vite 8 +@cloudflare/vite-pluginv1.51. Framework-mode route modules are unchanged — the one API difference hit:meta({ loaderData })replaces deprecatedmeta({ data }). (WS0)Explicit
routes.tsroute table, not filesystem routing. Keeps clean URLs (/e/:slug,/submit/:eventSlug/:formId); the file is split into per-group blocks so parallel lanes append to different lines. (WS0)Accelevents = an in-product Integrations page, not bare CSV downloads. "Sync to Accelevents" with visible sync history; linked CSV pair always (their import links speakers↔sessions by email — the only complete path, since their API cannot link them at all); API upserts additionally when an Enterprise key is configured. (5.6 review #2 — accepted with the API-limitation caveat kept.)
Tracks are THE routing category for CFP category-routing. No separate category table; typed track ids. Portal-form targets
contact/groupare cut from this deadline's UI — the storage model for group forms doesn't exist and the feature isn't required. (5.6 review #8.)One-click demo auth is confined to a separate disposable deployment, never production. Judge access is a product requirement, so
/demomints seeded sessions — but only whenDEPLOYMENT_PROFILE=demoandDEMO_MODE=1. The checked-inwrangler.jsoncsetsproduction/0, so the default deployment returns 404 from/demo. The demo runs fromwrangler.demo.example.jsoncwith its own D1, R2, origin, secrets, and a hard expiry.Submission-confirmation email sends from the submit action (WS1b), not WS5. The buyer's brief marks it a must-have and it is part of the submit seam; WS5 adds templates/reminders/ICS on top. (5.6 review #3.)
Calendar invites verified end-to-end (Sat 03:30 spike): Gmail renders a native RSVP card when Resend attaches the ICS with
content_type: text/calendar; method=REQUEST. ORGANIZER and From must move in lockstep — one shared constant in WS5. Outlook re-test after domain verification.Form builder has no client-side form state — every edit round-trips and works pre-hydration (speed + SSR reliability); field reorder is ↑/↓ buttons (keyboard-reachable), not drag. The omitted Payments step is labelled in the stepper, not silently missing. (WS1a)
Character budgets count text, not markup — wysiwyg counts strip tags; hidden fields are never validated and never count toward combined limits; a combined cap above the sum of per-field maxima is flagged inert in the UI. (WS1a)
Deleting a field drops the rules that reference it (no silently-dead rules); cloned forms start as
draftso a copy can't accidentally go live. (WS1a)Portal Home computes ONE next action (overdue task → soonest-due → headshot → bio) instead of Sessionboard's undifferentiated list — the speaker should never have to triage. Headshot upload lives on the Profile screen, not buried in a task. (WS3)
Impersonation is a second signed cookie, not a session swap — the admin's own session survives untouched, "Back to Admin Mode" is a cookie delete, and the cookie binds to the admin who minted it (replay into another jar fails, tested). (WS3)
The CFP Account step never grants authority before email proof. Treating an unseen address as safe enabled account squatting: an attacker could mint a durable session for a victim before that address appeared in Callboard. New addresses may receive a provisional person row and name default, but no session; known profiles are never changed, and both paths return the same magic-link response.
Admin-authored rich text renders as escaped paragraphs on the public form, never raw HTML — public pages take no HTML from form copy. Follow-up: route welcome/success copy through the WS3 sanitizer for safe formatting. (WS1b)
The status popover offers only the five admin-assignable statuses — an admin cannot fabricate a speaker's
withdrawn, anddraftis speaker-side only. (WS2)The abstracts admin page is router-free zero-JS markup (plain links/forms/details) — renders under static markup with no router context, which keeps its render tests honest and the page instant. (WS2)
Conflicts use half-open intervals — back-to-back sessions (one ends exactly when the next starts) are NOT a conflict; overlap by a minute is. Multi-day boundaries tested both directions. (WS4)
Drag-and-drop and the JS-off form are ONE write path — the drop fills the server-rendered form and submits it, so drag can never diverge from the accessible baseline. (WS4)
Unscheduling a session also unpublishes it — nothing without a time can remain on the public schedule. The Day board's tray is exactly the unscheduled bucket, so counts can't diverge. (WS4)
The status editor sits in normal document flow, not a positioned popover. Measured:
absolutegets cropped to a ~40px sliver by the table's overflow wrapper;fixedlands ~700px off-screen at 375px because it ignores the wrapper's horizontal scroll. In-flow expands the row — nothing clips it, no JS needed. (polish)Copy that names fields to a human uses labels, never keys — enforced by a shared helper with fallback-to-key rather than vanishing. And no internal planning vocabulary can reach a page: an AST gate over all route/component/lib modules fails the build on banned tokens (comments exempt). (polish)
Publishing the programme notifies the speakers.
publish-allsends each newly-public scheduled speaker their calendar invite — a session going public without its speaker knowing is the failure comms exist to prevent. Already-invited speakers get an update, never a duplicate. (WS5)Per-form confirmation copy wins only when it differs from the shipped placeholder — every form is born with the placeholder filled in, so "non-empty" could never let the event-level template apply. (WS5)
/v1keeps one envelope and ships/sessions/searchas primary with the Sessionboard bare-collection POST as a wire-compatible alias — their clients work unmodified, ours get the cleaner path. Optimistic concurrency comparesupdated_atat millisecond precision (second-precision let same-second writes dodge the 409 — caught by e2e). (WS7)The API layer speaks snake_case on purpose — field names in API error messages are the wire contract, exempted from the internal-vocabulary gate with a documented reason that must stay true or the exemption fails the build. (WS7)
The agenda board resolves a drop by POINTER position, not rectangle overlap. dnd-kit's default
rectIntersectionscores by overlap with the dragged card — an 82px card over 24.7px slot rows covers three cells completely, ties to four decimals, and silently lands in the row above.pointerWithinfirst (a point is in at most one cell),rectIntersectiononly as the keyboard/off-grid fallback. An organiser dropping on 09:00 must get 09:00. (fix lane)MAIL_DRIVER=consoleis checked before any key. "No API key" stopped being a safe test default the moment a real key landed in.dev.vars— the Playwright server inherits it, and an e2e run posted six live requests to Resend. The safe path must be explicit, never the absence of configuration. (fix lane)The waived public speaker gallery is removed from navigation rather than shipped as a permanent empty stub. The published schedule remains the attendee-facing output; a dead “Speakers not announced yet” link next to seeded public sessions reads as broken, not intentionally scoped. (release hardening)
People, profiles, and profile assets are deployment-global;
event_peoplerecords participation, not ownership. A person can participate in several events with one mailbox, bio, and headshot, and the single global admin role may manage any existing person. For a person-owned upload,uploads.event_idrecords the event context used for quota/accounting; session-owned uploads remain strictly bound to the owner session's event. Admin authority never permits an upload with a nonexistent owner. (cross-family review #35)Deployed readiness is a zero-row dependency check, not a synthetic write. Both smoke profiles verify the three runtime signing/rate-limit secrets and the exact D1 limiter columns, while creating no session, magic-link token, limiter window, upload, or demo row. The public response is only ready/unavailable; exact failures stay in Worker logs. One bounded zero-row D1 query per probe is accepted at demo scale; public pages already depend on D1, and edge abuse controls remain an external release gate. (release hardening, issue #35)
Accepted speakers may correct speaker-owned programme copy after the CFP closes. The primary submitter can update title, abstract, and private video; accepted title/abstract changes atomically sync to the composed programme session. Track, format, level, room, schedule, publication, capacity, participants, tasks, and decision state remain organizer-owned. Pending corrections still close with the CFP, while accepted corrections stay open because acceptance normally happens after that deadline. (buyer clarification during the challenge; #51 Stage A)
Reviewers get an assigned-only workspace outside organizer chrome. Review authority comes from event-scoped team membership plus an assignment in an open round, re-checked on every read and score write. Organizer preview uses a separate signed, admin-bound reviewer cookie so speaker/reviewer previews cannot bleed across surfaces; it adds no reviewer role, invitation mechanism, or session swap. (Buyer minimum + advisory eval CFP-10)
#49 is REVERSED: the public speaker gallery ships. The brief struck the requirement, so PR #2 removed the stub route and its navigation rather than advertise an empty page, and #49 recorded that. Evaluation weights the speaker widgets as distinct scored features, so the directory, profile, and gallery are now built for real. #49's actual concern — a dead link next to seeded public sessions — is answered by the surface being populated from the same published sessions the schedule reads, not by hiding it.
Speaker gallery ships as a
?view=galleryvariant of the same route rather than a separate page: one loader, one predicate, no chance of the two views disagreeing.The gallery's speaker drill-in is a PAGE, not a modal: SSR with zero client JS is a scored property of the public surfaces, and a page carries the same fields the modal would.
Public speaker photos are deliberately not served: headshot bytes are uploaded under a portal privacy expectation and
/portal/headshot/:personIdstays auth-gated. Public surfaces render a monogram fallback, which is the normal rendering rather than an error path. (NARROWED by #66. The privacy reasoning above is not withdrawn — it is exactly why #66 gates on consent instead of on a product-wide switch, and it still governs every headshot uploaded before that consent could be given.)A public speaker is derived, never flagged. Qualification is the published-session predicate itself — no
is_public_speakercolumn to fall out of sync. A person with no public session does not merely render empty, they 404, so the page cannot confirm that a given person exists in the database.Speaker workflow uses exactly Invited / Confirmed / Onboarding / Ready on the event-person link. These describe event-specific readiness without overloading the person's role or leaking one event's progress into another. (speaker roster lane)
Speaker CSV imports are all-or-nothing when any row is invalid; duplicates are explicit skips. An organizer can fix a file from one complete preview without discovering a partially imported roster or silently duplicated people. (speaker roster lane)
Public schedule search covers session titles and speaker names, not company or job title. Those two fields enrich identity but are not programme subjects; including them would produce results the attendee cannot explain from a title or presenter-name query. (public schedule upgrades)
A public calendar row without an explicit end lasts 60 minutes. Calendar clients need a finite event, and one hour is a predictable attendee-facing fallback while preserving every explicit programme duration. (public schedule upgrades)
The public schedule defaults to All days. The complete published programme remains visible and indexable at the canonical URL; day tabs are refinements, not a new default that hides later sessions. (public schedule upgrades)
An unknown public-schedule day key is an empty selection, not All days. A stale or malformed shared URL should say that nothing matches rather than silently presenting a different programme selection. (public schedule upgrades)
Live public-schedule filtering never writes to the URL. Mirroring each keystroke into the query string revalidates the loader on every character for a filter that is already computed client-side. The URL stays the entry point (and the no-JavaScript path); card links carry the active filter forward so a session detail can restore it, and Reset is the one control that clears the state and the URL together. (public schedule upgrades)
#57 is NARROWED: a public speaker photo is served only with recorded consent. Evaluation weights the speaker directory and gallery on showing a photo, so "no photos, ever" costs real points on two scored surfaces. It does not follow that the bytes already in the bucket may be published. Those were uploaded under the expectation #57 describes, and an owner asking for the rubric gap to be closed is not the depicted person agreeing to be shown — nobody can consent on someone else's behalf by deciding it would be convenient. So the switch is per person, defaults off, and is never set by a migration or a backfill:
people.photo_publishable, default false./e/:slug/speaker-photo/:personId/:versionserves bytes only when that flag is true AND the person qualifies as a public speaker under the same published-session predicate as #58. Every refusal is the same 404 with no reason, so the endpoint cannot be used to discover who has a photo, who opted out, or who exists.- A new portal upload IS the consent event. The upload control on
/portal/profilecarries a plain-language notice, beside the control and not behind a disclosure, saying the photo will appear on the public speaker pages. Uploading with that notice on screen sets the flag. An organizer uploading while impersonating that speaker does not set it: they saw the notice, but they are not the person in the photograph. - Consent is GLOBAL, and the notice says so.
photo_publishablelives onpeople, not onevent_people: there is one flag per person, not one per event. A speaker who qualifies as a public speaker of two events therefore appears on both events' public pages from a single act of consent, andresolvePublicSpeakerPhotoKeyserves the photo for any event where the published-session predicate holds. That is deliberate — a per-event consent table is more surface than the product needs — but it means the notice must not promise "this event's pages". It says the photo publishes "on the public speaker pages … for every event where you speak, not only this one", so the words on screen match what the code does. Per-event scoping is a future option, not a bug in this one; the honest global statement is the contract. - Existing uploads stay private forever by default. The migration adds the column with
DEFAULT falseand backfills nothing. Where consent exists out of band — a signed speaker agreement, an email — the organizer records it with a per-speaker toggle on/admin/speakers/:id, one speaker at a time. There is deliberately no bulk "publish all photos" control, because that is precisely the action that should never be one click. - Replacing a photo does not change its visibility. A speaker replacing their own headshot has not withdrawn anything; an organizer replacing it is correcting a crop, not obtaining permission. Consent attaches to "my headshot may be shown", not to a particular file.
- Consent travels with the key; it is never laundered onto a different one. Three paths change which
headshot_keya person points at, and each keeps the(key, publishable)pair internally consistent so no photo inherits a flag granted for another photo. Merge (/admin/contacts): when the survivor adopts the duplicate's key it also adopts the duplicate's flag; when it keeps its own key it keeps its own flag — otherwise the survivor's standing consent would publish the duplicate's unconsented photo (proven serveable before this fix), or the survivor'sfalsewould bury the duplicate's real consent. Delete (deletePersonUpload): removing the headshot lowers the flag with the key, because the artifact the consent named is gone; a re-upload or admin replace afterward must earn consent again. Toggle (/admin/speakers/:id): the publish control refuses — and the UI hides — on a record with noheadshot_key, so consent can never be pre-armed ahead of the photo it applies to. - The seed's people are fabricated, so the seed may set the flag.
scripts/seed.mjsmarks its synthetic speakers publishable and gives each a deterministic geometric avatar generated byscripts/avatar.mjs— flat shapes, no face, no silhouette, unmistakably not a photograph of anyone. A demo that dressed invented names in photorealistic stock faces would read as a privacy failure whether or not it was one. Three seeded speakers are exceptions on purpose: one has a headshot with the flag off (the pre-notice case) and two have none at all, so both the gate and the monogram fallback are visible in the shipped demo rather than only in tests.
What immutable caching does and does not promise. The URL carries the current
uploads.id, so a replacement is a different URL andmax-age=31536000, immutableis honest rather than a stale-content bug. Turning the toggle off removes the photo from every page and 404s the origin immediately. It cannot recall bytes a visitor's browser or an intermediary already holds — no public image URL can, on any product — so revocation is forward-looking by construction, and the notice says "ask an organizer to take it down" rather than implying erasure.AGENTS.md rule 4 is deliberately deviated from in this lane:
app/db/schema.tsandapp/db/migrations/are edited here, additively. AI triage needs a table, there is no orchestrator turnaround left before the freeze, and the alternative — an opinion stored onreviewsbehind anis_ai_suggestedflag — is the exact coupling this feature must not have. The deviation is bounded: one newai_triagetable plus two indexes, no existing table or column touched, so it rebases onto any other lane's migration by renumbering alone. It was renumbered repeatedly as parallel lanes landed — from idx 5, behind the CRM lane's0005_true_riptide, and each subsequent merge/rebase — and now sits at idx 10 behind the photos lane's0009_mean_timeslip. (ai triage lane)npm run migrations:checkis hardened to name the outcome it reached and to verify the journal against the files on disk, not justgit status.drizzle-kit generateexits 0 when it cannot LOAD the schema — it printed "Cannot find module", generated nothing, and the gate reported "ok: migrations are up to date" over a comparison of an unchanged directory with itself.drizzle-kitalso compares SNAPSHOTS, so a journal entry pointing at a.sqlfile that does not exist stays green through the whole gate; that is precisely the failure a hand-renumbered migration produces, and this lane renumbered its migration several times across parallel merges. A gate whose green cannot distinguish "checked and correct" from "checked nothing" is worse than no gate. (ai triage lane)The model's output is advisory data, and the submitter's text is untrusted data; neither is ever an instruction. AI triage writes only to its own table, never to
reviews, never to a status — enforced by separation rather than by a filter somebody has to remember, so no forgotten flag can promote an opinion into the human aggregate, the score sort, or the reviewer CSV's human columns. Symmetrically, title and abstract are speaker-controlled, so they are wrapped in a per-call sentinel-delimited block that the whitespace clamp makes unclosable, the system prompt states that the block is data and the reply must be the JSON schema alone, and the parser fails closed tostatus = "failed"on anything else. A prompt-injected abstract can at worst produce a wrong advisory number on a card labelled AI-generated; it cannot produce a decision. (ai triage lane)Conflict severity follows whether the programme can physically happen. A room cannot host two sessions at once and a person cannot be in two places at once, so those double-bookings are BLOCKING: the move is refused and the session is held out of the public schedule until it is fixed or explicitly forced. Same-track overlap is a programme-quality judgment — the attendee following that track must choose — so it stays ADVISORY: warned about, never gated. This refines #13 rather than contradicting it; warn-never-block was written about parking a clash and fixing it later, and it continues to bind every advisory conflict, including auto-placement, which still ignores advisory overlap entirely. What changed is that publishing is the moment a conflict reaches the public schedule and the ICS invites, and an impossible schedule should not get there by default. The move path now predicts conflicts BEFORE the write instead of recomputing after it, which is what makes refusal possible at all. Composes with #147's informed gate through two SEPARATE override keys —
overrideclears "the speaker hasn't been told",forceclears a blocking conflict — because one shared key would let an organizer clearing a courtesy hold silently publish a physically impossible schedule; a session held for both reasons lists both and needs both. (agenda intelligence lane)Per-round review blinding hides structured identity, not prose, and cannot retroactively un-reveal. A blinded session's
session_participantsrow never enters the reviewer query — only acount()runs for it — while an unblinded session's full identity query runs unchanged (app/routes/review.index.tsx:114-143), so names, emails, companies, and titles for a blinded round never reach the reviewer payload, let alone the wire. The abstract body itself is untouched: there is no per-field "this text names someone" marker, so a submitter who writes "as the maintainer of X, I…" is still identifiable — a real gap, not a rounding error, which is why the UI says "identity is hidden," never "anonymous." And becauseisRoundBlind()reads the round's ownrubric.blindflag fresh on every request, toggling blinding off — even briefly — hands identity to any reviewer whose browser loads the page in that window; toggling it back on stops future requests, it does not un-show what already rendered. The wire-level check reproduces directly: capture the reviewer payload for the same session with the round'srubric.blindflag on and off — the structured-identity fields are present only in the second.