ADR-012: Pre-READY Speaker Proposals Live in `session_proposals`, Never on `speaker_pool`
Status: Accepted
Context
Story 7.2 lets a logged-in ATTENDEE self-nominate as a speaker. A self-nomination enters
the speaker workflow at IDENTIFIED (ADR-009: READY is the provisioning gate, organizer-only)
and carries a proposed talk β sessionTitle + abstract β that the attendee wrote.
The first implementation (Flyway V109) stored that proposal by adding four columns to
speaker_pool: source, proposed_by_username, proposed_session_title, proposed_abstract.
This collides head-on with the normalization established in Epic 11.E.8 / ADR-009:
speaker_poolis the workflow state machine only. Content β title, abstract, materials β lives insession_users+content_submissions/session_content_history.- V103 deliberately dropped
speaker_pool.username/.emailto enforce that line; canonical identity post-READY isPrimarySpeakerResolver.resolve(pool).
So proposed_session_title / proposed_abstract are content back on the state table β the
exact thing 11.E.8 removed. They are also NULL for ~99% of rows (every organizer-added candidate),
i.e. a sparse content annex bolted onto the state machine. The risk is concrete: the next
contributor sees content columns on speaker_pool and treats it as a sanctioned place to hang
more content, unwinding the normalization.
The competing constraint: a self-nomination is not yet a session. sessions + session_users
rows are created only at the CONTACTED β READY hook. Creating a real session per nomination
would flood the canonical tables with unvetted applicants. So the proposal genuinely has no
home in the canonical post-READY tables until an organizer accepts it.
Decision
Model a self-nomination for what it is β an application / pitch, distinct from both the speaker (workflow) and the session (content) β and give it its own table.
Schema
session_proposals
id uuid PK
speaker_pool_id uuid FK β speaker_pool(id) -- pointer; intra-service, no cross-service FK
event_id uuid -- intra-service
proposed_by_username varchar(100) -- ADR-003 meaningful id (who pitched)
proposed_title varchar(255)
proposed_abstract text
created_at timestamptz NOT NULL
UNIQUE (event_id, proposed_by_username) -- one self-nomination per attendee per event
speaker_pool keeps only source (organizer_added | self_nomination) β provenance
metadata, not content; it lets the kanban badge/filter without a join. It does not keep
proposed_by_username, proposed_session_title, or proposed_abstract. The FK points at
speaker_pool, so speaker_pool gains nothing content-shaped.
Three rules that keep this stable
- No second state machine.
session_proposalscarries no status of its own. Acceptance / rejection is read from the linkedspeaker_poolrow (IDENTIFIED β β¦ β READY= accepted;DECLINED= rejected). The single source of workflow truth remainsSpeakerWorkflowService.transition(...). - Promote is the hand-off point. At
CONTACTED β READY, after the session is created, the READY hook (SpeakerWorkflowService.provisionSessionAndPrimarySpeaker) looks upsession_proposalsbyspeaker_pool_id; if present it seedssessions.title=proposed_titleand the first content submission =proposed_abstract. From that instant the session is canonical; the proposal row becomes immutable audit (βwhat was pitchedβ, distinct from what the session became). No competing source of truth post-READY. - No content on
speaker_pool, ever. Pre-READY pitches live insession_proposals; post-READY content lives insession_users/content_submissions.speaker_poolis state (+ thesourceprovenance flag) only.
Guard against re-denormalization
A code comment will not stop the next agent. Two durable guards:
- This ADR + a line in
_bmad-output/project-context.mdstating the rule in rule 3 above. - A schema-fitness test asserting
speaker_poolhas no content columns (title / abstract / materials). It fails in CI, not in review, if someone re-adds them.
Consequences
- V109 is rewritten, not patched forward. Nothing is deployed (branch
feat/epic-7-attendee-contributionis unpushed; staging/prod never saw V109). As a dev-only exception to the βnever edit an applied migrationβ rule, the dev DBβs V109 is rolled back and V109 is re-authored to (a) add onlysourcetospeaker_pooland (b) createsession_proposals. This is permissible only because V109 exists nowhere but one dev database. Once this branch is pushed, V109βs content is frozen forever per the standard rule. - The self-nominate write path creates a
session_proposalsrow + thespeaker_poolrow (IDENTIFIED,source='self_nomination'); the one-per-event uniqueness moves to thesession_proposalsunique constraint. - The organizer kanban reads the proposed talk via a left join
speaker_pool β session_proposals. - The promote picker must allow selecting a non-SPEAKER user (the self-nominee is an
ATTENDEE);provisionUserWithRolealready grants SPEAKER to an existing user idempotently by email, so no duplicate user is created. (Tracked in Story 7.2βs amendment, not this ADR.)
Alternatives considered
- A β keep
proposed_*onspeaker_pool, carry into the session on promote. Cheapest now, but reverses the 11.E.8 normalization and leaves the content-on-state-table landmine. The carry-into-session step is required either way, so Aβs only saving over this decision is βno new tableβ β which, with nothing deployed, is negligible. Rejected. - Rule of Three / YAGNI (donβt add a table yet). Doesnβt apply: the separation is justified by the existing normalization invariant, not by speculative reuse. Weβre not abstracting ahead of need; weβre refusing to violate a line already drawn.