---
title: Redesign website contribution follow-up as one conversation
type: task
status: completed
description: >-
  Replace xo.football intake's question-and-answer rounds with one append-only conversation per
  submission: the submitter can write any time, every outbound message passes a publication gate,
  submitters are notified by email, and lifecycle is separate from whose turn it is.
base_uri: user:task/league/redesign-contribution-follow-up-as-conversation.md
created_at: '2026-10-27T02:09:58.279Z'
entity_id: c7becf98-e872-4eef-906d-7eacea992fd0
finished_at: '2026-10-27T21:53:35.823Z'
observations: []
owner_identity_uri: █████████████████████████
priority: Medium
public_read: true
relations:
  - relates [[user:text/league/contribution-pipeline.md]]
  - relates [[██████████████████████-████████████-██████-███████]]
started_at: '2026-10-27T14:37:15.476Z'
tags:
  - user:tag/league-xo-football.md
  - user:tag/software-task.md
updated_at: '2026-10-27T21:53:35.823Z'
visibility_analyzed_at: '2026-11-05T23:49:57.145Z'
---

## Overview

Replace the website intake's question-and-answer rounds with one conversation per submission. Each conversation is an append-only message log that the submitter, the intake agent, the operator and the pipeline all write to. Every message to the submitter passes a publication gate, and the submitter is told when something needs them. Status and "whose turn" become two separate facts instead of one overloaded status column.

Done looks like this:
- A submitter can add information at any time, not only answer questions.
- Every reply that reaches the submitter has passed the publication gate, or the operator approved it.
- A submitter who left an email or has an account is notified when they are asked something and when the item ships or closes.
- A closed item tells the submitter why, in product terms.

## Background

The current design (`contribution_questions` and `contribution_answers` in league Postgres, `api/routes/contributions.mjs`, `libs-server/contribution/dispatch-intake-session.mjs`, the base relay and reconciler in `extension/contribution/`):

- **Questions only.** The agent asks up to 3 questions in total, and the submitter can only answer them, all in one round. There is no way to add information, correct a playback later, or reply after the item is accepted. A late answer to a closed row is stored and reopens nothing.
- **One column carries two facts.** `submission_status` mixes lifecycle (received, accepted, in progress, shipped) with whose turn it is (`awaiting_information`, `expired`). Every new interaction needs a new status value, which is a CHECK-constraint change.
- **Expiry hangs off each question.** Each question has its own `expires_at`, and the reconciler reasons over question sets. Its round-two defect was fixed on 2026-10-27, but the shape invites that kind of bug.
- **No notification.** The status page is the only channel, so a question asked of an anonymous submitter who never returns is a dead end.
- **No reason on close.** `rejected` and `duplicate` show a bare status.
- **No operator voice.** The operator cannot reply to a submitter at all. Discord DMs already have one (outbound drafts approved on the phone), and the website lacks it.
- **The intake contract is split across two places.** The live contract is the `█████████████` profile prompt. `workflow/league/triage-contribution-submission.md` is a stale earlier contract that nothing runs.

Hardening already landed on 2026-10-27 and is reused here, not redone:
- the publication check on agent text;
- the fenced and redacted prompt history;
- the durable rate limits;
- the global intake cap;
- the anonymous-never-auto-builds gate;
- the purge entry point;
- the relay pinned to its own league URL.

## Design

### One conversation per submission

`contribution_messages` replaces `contribution_questions` and `contribution_answers`:
- `message_id`, `submission_id`, `created_at`;
- `author_role`: `submitter`, `agent`, `operator` or `system`;
- `message_kind`: `report`, `comment`, `question`, `reply` or `notice`;
- `body`;
- `reply_to_message_id`, nullable;
- `publication`: `published`, `held` or `redacted`;
- `published_at`.

The report body becomes the first message, written by the submitter with kind `report`, so purge, history and rendering treat every message the same way. The submission row keeps only the header: title, kind, trust tier, captured context, screenshot, claim token and lifecycle status.

### Lifecycle and turn are separate

- **`submission_status` is lifecycle only:** `received`, `planned`, `building`, `shipped`, `closed`. A close reason column holds `not_planned`, `duplicate`, `no_response` or `withdrawn`. The current `accepted`, `rejected`, `duplicate`, `in_progress` and `expired` values fold into those.
- **Whose turn it is is derived, never stored.** The submitter's turn means the latest published message from the agent or operator is a `question` with no later submitter message. Otherwise it is the system's turn. Nothing else has to keep the two in step.

### The submitter may write at any time

- **Before a plan exists,** a submitter `comment` wakes intake. This is the same one-shot session as today, carrying the whole conversation inside the untrusted fence.
- **After a plan exists,** the comment is stored and raised to the operator as a `contribution.submitter_comment` round, with an agent-rewritten summary. The frozen plan never changes underneath the pipeline. The operator chooses `replan` (existing), `reply` or `dismiss`.
- **Limits:** per-submission and per-key rate limits (the durable limiter), and the same size caps.

### Every outbound message passes one publication gate

- **Agent questions and notices:** published automatically when the publication check passes. Otherwise they are held, and the operator gets a round to approve, edit or drop the message.
- **Operator replies:** go through outbound drafts with a new `xo-football` platform. A reply is drafted, proposed and approved on the phone, exactly like a Discord DM. Approval writes an `operator` message.
- **Pipeline notices:** shipped, closed with reason, and moved to building. They replace the bare status mirror. The mirror endpoint writes a `system` notice message beside the lifecycle change, so the submitter reads why, not just a status word.

### Notifications

- **An outbox,** `contribution_notifications`, has one row per message that needs the submitter, with an idempotency key. A league worker sends it through the existing `libs-server/send-email.mjs`. This is local-first: it persists, then sends, then marks sent, and a backlog that isn't draining raises a failure.
- **Who gets notified:**
  - Signed-in submitters get their account email when they opt in at submit time.
  - Anonymous submitters may leave an optional email. It is stored apart from the report, never shown to the agent, and kept after close. Purge deletes it.
  - With neither, the status page is the only channel. This is the current behaviour.
- **An anonymous address is confirmed before it is used.** The only email an unconfirmed address receives is one fixed-text confirmation. No email carries submitter-written text; every subject and body is fixed per notification kind.
- **Every email carries a claim link.** Each email mints a fresh claim token and stores only its hash in `contribution_claim_tokens`, so the submit-time link keeps working and nothing recoverable is held at rest. Email-issued tokens expire after 30 days; the submit-time token never does. A "send me my link" action is one more minted email, limited to one per submission and three per address per day. The `claim_token_hash` column moves into that table and is dropped.

### Waiting on the submitter

- **There is one clock:** the time of the open question.
- **One reminder per turn** goes out at day 7, if there is a channel, for any open question from the agent or the operator.
- **At day 14 a submission with no plan closes** with `no_response`. A planned submission never closes because its submitter went quiet.
- **Any later submitter message reopens it.** Expiry per question goes away.

### Budget instead of a question cap

- **The intake budget is counted in agent turns per conversation** (default 4) and in the existing global daily cap.
- **An exhausted budget routes to the operator,** as `operator_queue` does today.
- **The 3-question rule becomes the turn budget,** so a turn may ask one or two focused questions.

### One intake contract

- **The `█████████████` profile prompt is the single contract.** The still-valid rules from the triage workflow fold into it: check the product-scope rejection ledger first, and route feature ideas that need a product ruling to the operator.
- **`workflow/league/triage-contribution-submission.md` is retired,** along with every reference to it.

### Channel-agnostic notices (future-proofing, built only as far as this task needs)

The pipeline's "tell the contributor" step becomes one interface with three adapters:
- website: a message plus email;
- Discord: a thread post or DM draft;
- base session: a DM.

`due-notices` already does this for Discord. This task routes the website through the same step, so a fourth front door adds an adapter rather than a new path.

### What it removes

- `contribution_questions` and `contribution_answers`;
- the status values `awaiting_information` and `expired`;
- per-question expiry in `extension/contribution/reconcile-stalled.mjs`;
- the answer-round endpoint, replaced by `POST /api/contributions/:id/messages`;
- the triage workflow.

The migration moves existing questions and answers into messages in one pass. No compatibility path is kept.

### Knowledge-base impact

- `text/league/contribution-pipeline.md` § the chain, website front door, and Notices: rewrite.
- `workflow/league/ABOUT.md` and `review-contribution-plan.md`: drop the triage workflow references.
- `██████████████████████████████████████████`: add the `xo-football` platform.
- The league repo's `docs/` coverage of the contributions page, if any.
- No scratch tier is needed.
- This is bounded enough for one task, so no orchestrator is needed.

## Notes

- **Schema change.** This is a schema change, which is operator-only in the pipeline's own rules. This task is the operator's authorization once the design is approved. Apply it per league `docs/guides/schema.md`.
- **Breaking API change.** The request body changes in a way the deployed bundle depends on. League's ship guide accepts the reload-then-rsync window for retryable actions. Answering a question is retryable, so no shim is needed.
- **Anonymous email retention (operator ruling, 2026-10-27).** Keep it after close; only purge deletes it. Holding it costs little, and it keeps link recovery working and lets a duplicate's submitter hear when the original ships. The ruling is "for now", so revisit it if the table grows or a retention policy lands.

## Plan

Production held 3 submissions, 6 questions and 3 answers on 2026-10-27, so the data migration is a single small transaction run straight after the deploy, with no dual-read period.

**Ownership after the cut.** League owns everything submitter-facing: the conversation, the waiting clock (reminder and `no_response` close) and the email outbox. Base owns operator rounds and reads league's tables directly, the same way the reconciler does today. The base `expire_once` sweep goes away.

**Contracts both sides build against.**
- Intake verdict `ask` keeps `questions: [string]`, at most 2 per turn; the route stores each as an agent `question` message. The route checks the turn budget (4 agent turns per conversation) and the publication check. A message that fails is stored `held` and routed to the operator, not refused.
- Intake verdict `notice` is not accepted. Only the pipeline writes notices.
- `POST /api/contributions/:id/messages` (claim token or owner session) adds a submitter `comment`. Before a plan exists it moves the turn to the system and dispatches intake. After a plan exists it is only stored, and base raises a round.
- `POST /api/contributions/by-task/:task_entity_id/status` (pipeline token) takes `{submission_status, close_reason?, notice_body?}` and writes a `system` notice message beside the lifecycle change.
- `POST /api/contributions/:id/operator-messages` (pipeline token) writes an `operator` reply, or publishes, edits or drops a `held` message: `{action: 'reply'|'publish'|'drop', message_id?, body?}`.

### Schema (league, applied per docs/guides/schema.md)
- [x] Add `contribution_messages` (fields per the Design), `contribution_notifications` (outbox: `notification_id`, `submission_id`, `message_id`, `notification_kind`, `idempotency_key` unique, `queued_at`, `sent_at`, `attempts`, `last_error`), and `contribution_submitter_contacts` (`submission_id` primary key, `email`, `notify_opt_in`, `created_at`), kept apart from the report.
- [x] Add `close_reason` to `contribution_submissions`, with a CHECK over `not_planned`, `duplicate`, `no_response` and `withdrawn`.
- [x] Migration script: reports, questions and answers become messages; statuses map `accepted` to `planned`, `in_progress` to `building`, `rejected` to `closed`/`not_planned`, `duplicate` to `closed`/`duplicate`, `expired` and `awaiting_information` to `closed`/`no_response` (or `received` if the open question is still inside its clock).
- [x] After the code cutover: replace the status CHECK with `received`, `planned`, `building`, `shipped` and `closed`, and drop `contribution_questions` and `contribution_answers`. Update the reader grants and the table-domain manifest.

### League API and intake
- [x] Message store module: append, derive whose turn it is, and the turn budget.
- [x] Rewrite the intake route for `ask`, `plan` and `operator_queue` over messages, with held-on-refusal and the budget.
- [x] Submitter messages endpoint, behind the durable rate limits and size caps; it dispatches intake before a plan exists.
- [x] Rewrite the status mirror for the new lifecycle, close reasons and system notices.
- [x] Operator-messages endpoint (pipeline token).
- [x] The intake prompt renders the conversation inside the untrusted fence, replacing the rounds.
- [x] Purge covers messages and contacts.
- [x] Specs for every route, with negative controls; the full suite green.

### Notifications and the waiting clock (league)
- [x] Optional email and opt-in at submit time; account submitters opt in to their account email.
- [x] Outbox worker job: persist, then send through `libs-server/send-email.mjs`, then mark sent; idempotent, with a failure signal when the backlog stops draining.
- [x] Waiting-clock job: a reminder at day 7, then `closed`/`no_response` at day 14; any later submitter message reopens the submission.
- [x] "Send me my link" recovery to the stored email.

### Status page (league app)
- [x] Conversation view, a comment box, close reasons, and the email field on the submit dialog.

### Base side
- [x] The relay forwards the new `ask` shape; the intake profile prompt becomes the single contract, with the triage rules folded in.
- [x] A `contribution.submitter_comment` round (`replan`, `reply`, `dismiss`) for comments after a plan exists, and a `contribution.held_message` round (`publish`, `edit`, `drop`) for held agent text.
- [x] An `xo-football` outbound-draft platform whose sender posts to the operator-messages endpoint.
- [x] The status mirror sends close reasons and notice bodies; remove `expire_once` and its tests.
- [x] Retire `workflow/league/triage-contribution-submission.md` and every reference to it.

### Ship and docs
- [x] The tightening commit also deletes the one-off migration script and its spec, removes the two dropped tables from the domain manifest and the grant exclusions, re-exports the schema, and bumps the `private` pointer.
- [x] Add every contribution table to `db_user_tables` in `scripts/postgres-backup.sh`; none is in the user-data backup today.
- [x] Drop `contribution_submissions.submission_body`, which duplicates the report message, once plan-task quote detection reads the report message instead.
- [x] Deploy in order: additive DDL, code, then the migration and the tightening DDL; verify on the hosts per the league ship guide.
- [x] Rewrite `text/league/contribution-pipeline.md` for the conversation model; update `workflow/league/ABOUT.md`, `review-contribution-plan.md` and the outbound-draft extension doc.
