A proposal — a title, a paragraph and two to eight options — becomes the thing this app is about, at every door, and the template's note goes.
What done looks like
[ ] A migration appended to migrations in src/db.ts creates proposal (id, title 1–120, body 1–2000, rule TEXT NOT NULL DEFAULT 'majority', state in open/decided/withdrawn default open, decided_option_id nullable, author_id, author_name, created_at, updated_at) and option (id, proposal_id REFERENCES proposal(id) ON DELETE CASCADE, label 1–80, position), with an index on option(proposal_id). A second appended migration drops the template's note table. No migration that has run is edited.
[ ] src/actions.ts loses the note functions and gains listProposals(db, who, {state?, limit?}), getProposal(db, who, {id}) (the proposal with its options), propose(db, who, {title, body, options, rule}) and withdrawProposal(db, who, {id}).
[ ] propose refuses anon with signin_required; refuses fewer than two or more than eight options, blank or duplicate labels, a title over 120 or a paragraph over 2000 characters, each with invalid and a sentence a person can act on; it trims every field and writes the proposal and its options in one transaction.
[ ] withdrawProposal is the author or an admin only — the rule an exported canWithdraw(who, proposal), the way canDelete was — and only while the state is open; a second withdrawal is invalid.
[ ] propose emits proposal.opened, withdrawProposal emits proposal.withdrawn, and the list region carries data-live for both.
[ ] Two pages, so src/page.ts becomes src/pages/layout.ts, src/pages/home.ts and src/pages/proposal.ts with one layout: / lists open proposals newest first (title, author, when, how many options, state) and carries the composer for a signed-in person; /p/:id shows the title, the paragraph, the options in order, the author, the state and a Withdraw form for whoever may. A guest sees both pages and no forms. Every value rendered goes through escape.
[ ] Routes in src/routes.ts: POST /proposals and POST /proposals/:id/withdraw (forms, back to the page with the message on failure), GET /api/proposals, GET /api/proposals/:id, POST /api/proposals (201) and POST /api/proposals/:id/withdraw — statuses follow Failure through answer. GET /p/:id for something that is not a proposal answers 404 in the layout.
[ ] MCP tools in src/mcp.ts are list_proposals, get_proposal, propose and withdraw_proposal, each with a JSON schema that states its limits; the note tools go.
[ ] src/tty.ts lists proposals, j/k moves, Enter opens the one under the cursor (paragraph and options), n proposes by asking for a title, then the paragraph, then options one per line until a blank line, Esc cancels, w withdraws when allowed, q quits; a guest at the terminal is told to sign in rather than shown a composer.
[ ] src/actions.test.ts covers: anon cannot propose; one option refused; nine refused; duplicate labels refused; a 2001-character paragraph refused; another user cannot withdraw; an admin can; withdrawing twice refused. src/server.test.ts covers /, /p/:id, the JSON routes, a 404 for a missing proposal, and a cross-origin POST still refused.
[ ] README.md describes proposals rather than notes in its table and its "Any interface" section, and bun test is green.
Files I expect to change:src/db.ts, src/actions.ts, src/actions.test.ts, src/routes.ts, src/pages/ (new, from src/page.ts), src/mcp.ts, src/tty.ts, src/server.test.ts, static/app.css, README.md.
Risks: this is the widest change on the map — settle the schema and the actions first and let the four doors follow, or they drift apart. Dropping note is a new migration, never an edit to migration 1. Option labels are user text at four doors: escape at the page, bind every parameter, build no SQL by concatenation. Keep src/server.ts's same-origin refusal and rate limit exactly as they are.
Fit: Ledger — a state machine whose rows carry a reason, validated at the boundary in the first commit. A person assigns; I do not.
A proposal — a title, a paragraph and two to eight options — becomes the thing this app is about, at every door, and the template's note goes.
What done looks like
migrationsinsrc/db.tscreatesproposal(id, title 1–120, body 1–2000,ruleTEXT NOT NULL DEFAULT'majority',stateinopen/decided/withdrawndefaultopen,decided_option_idnullable, author_id, author_name, created_at, updated_at) andoption(id, proposal_id REFERENCES proposal(id) ON DELETE CASCADE, label 1–80, position), with an index onoption(proposal_id). A second appended migration drops the template'snotetable. No migration that has run is edited.src/actions.tsloses the note functions and gainslistProposals(db, who, {state?, limit?}),getProposal(db, who, {id})(the proposal with its options),propose(db, who, {title, body, options, rule})andwithdrawProposal(db, who, {id}).proposerefusesanonwithsignin_required; refuses fewer than two or more than eight options, blank or duplicate labels, a title over 120 or a paragraph over 2000 characters, each withinvalidand a sentence a person can act on; it trims every field and writes the proposal and its options in one transaction.withdrawProposalis the author or an admin only — the rule an exportedcanWithdraw(who, proposal), the waycanDeletewas — and only while the state isopen; a second withdrawal isinvalid.proposeemitsproposal.opened,withdrawProposalemitsproposal.withdrawn, and the list region carriesdata-livefor both.src/page.tsbecomessrc/pages/layout.ts,src/pages/home.tsandsrc/pages/proposal.tswith onelayout:/lists open proposals newest first (title, author, when, how many options, state) and carries the composer for a signed-in person;/p/:idshows the title, the paragraph, the options in order, the author, the state and a Withdraw form for whoever may. A guest sees both pages and no forms. Every value rendered goes throughescape.src/routes.ts:POST /proposalsandPOST /proposals/:id/withdraw(forms, back to the page with the message on failure),GET /api/proposals,GET /api/proposals/:id,POST /api/proposals(201) andPOST /api/proposals/:id/withdraw— statuses followFailurethroughanswer.GET /p/:idfor something that is not a proposal answers 404 in the layout.src/mcp.tsarelist_proposals,get_proposal,proposeandwithdraw_proposal, each with a JSON schema that states its limits; the note tools go.src/tty.tslists proposals,j/kmoves, Enter opens the one under the cursor (paragraph and options),nproposes by asking for a title, then the paragraph, then options one per line until a blank line, Esc cancels,wwithdraws when allowed,qquits; a guest at the terminal is told to sign in rather than shown a composer.src/actions.test.tscovers: anon cannot propose; one option refused; nine refused; duplicate labels refused; a 2001-character paragraph refused; another user cannot withdraw; an admin can; withdrawing twice refused.src/server.test.tscovers/,/p/:id, the JSON routes, a 404 for a missing proposal, and a cross-origin POST still refused.README.mddescribes proposals rather than notes in its table and its "Any interface" section, andbun testis green.Files I expect to change:
src/db.ts,src/actions.ts,src/actions.test.ts,src/routes.ts,src/pages/(new, fromsrc/page.ts),src/mcp.ts,src/tty.ts,src/server.test.ts,static/app.css,README.md.Risks: this is the widest change on the map — settle the schema and the actions first and let the four doors follow, or they drift apart. Dropping
noteis a new migration, never an edit to migration 1. Option labels are user text at four doors: escape at the page, bind every parameter, build no SQL by concatenation. Keepsrc/server.ts's same-origin refusal and rate limit exactly as they are.Fit: Ledger — a state machine whose rows carry a reason, validated at the boundary in the first commit. A person assigns; I do not.