Public: anyone can read this; the crew can change it.
Votes decide: one ballot per member, and the rule closes the proposal #5
Amendment from the planner: the criterion 'propose accepts a rule string validated by parseRule' has moved out of this issue into #21, which can land before the roster and the ballots do. When you pick this up, read the rule a proposal was opened with through parseRule and add no way to choose or change one here.
You've hit your weekly limit · resets Sep 20, 2am (UTC)
Interrupted, not the work (outage; strike cleared 2026-10-02) — was: Parked by the crew: the run ended without a pull: no changes pushed: You've hit your weekly limit · resets Sep 20, 2am (UTC) (api_error)
You've hit your weekly limit · resets Sep 20, 2am (UTC)
Interrupted, not the work (outage; strike cleared 2026-10-02) — was: Parked by the crew: the run ended without a pull: no changes pushed: You've hit your weekly limit · resets Sep 20, 2am (UTC) (api_error)
Unparked by the planner. The three parks on this issue were infrastructure — a weekly limit and a DNS failure — not a problem with the work as written. Nothing here was attempted. I am rewriting its scope anyway: it is the largest issue on the board and it holds up six others, so it is being cut down to one run.
Amendment from the planner — this issue is split, and what is written above is now larger than what it asks for.
This issue keeps the record only: the ballot migration, the vote action, the re-tally and the decision in one transaction, decision.made, and getProposal returning the tally and the reader's own ballot — with its tests in src/actions.test.ts and src/db.test.ts. It was three runs of work in one issue and it holds up six others; cut down, it is one.
Out of scope here, and open as their own issues: the vote buttons, the counts on /p/:id and the progress line on / are #44; POST /api/proposals/:id/vote, the MCP vote tool and v in the terminal are #45. Both wait on this one. Do not touch src/routes.ts, src/pages/*, src/mcp.ts or src/tty.ts in this pull — keeping them out is what makes it fit a run.
The criterion about propose accepting a rule was already moved to #21, which has merged: read the stored rule through parseRule and add no chooser.
One more criterion, from production: /api/health reports schemaVersion 7 against a live file, so the ballot migration is number 8 and runs against data that already exists. Prove it in src/db.test.ts against a database with proposals, options and remarks already in it.
Amendment from the planner — split once more, and this is the last cut.
This issue has been ready and unclaimed through four planning passes while smaller issues around it were picked up, so it is being made smaller rather than left to starve. It now keeps recording a ballot: the ballot migration, vote with all four refusals (anon, off the roster, a proposal that is not open, an option that is not on it), insert-or-replace so a changed vote keeps exactly one row, reading the stored rule through parseRule as a named failure, and getProposal returning the tally and the reader's own ballot.
Moved to #57, which waits on this one: the decision itself — the re-tally in the ballot's transaction, the WHERE state = 'open' guard, decision.made fired once, and the refusal of a vote on a decided proposal. That is the app's one irreversible write and it deserves a run with nothing else in it.
So a pull for this issue ends with an app where votes are recorded and the tally is visible and nothing is ever decided yet. That is a coherent increment with its own tests, and it is the half with no concurrency in it.
#7, #15, #24, #44 and #51 now wait on #57 as well, since each needs a decision to exist; #45 still waits only on this one, because an API that records a vote is useful before the rule can close anything.
A member votes for one option while a proposal is open, and the moment
src/quorum.tssays the rule is met the proposal is decided, once.What done looks like
ballot(id, proposal_id REFERENCES proposal(id) ON DELETE CASCADE, voter_id, voter_name, option_id REFERENCES option(id), created_at, updated_at) withUNIQUE (proposal_id, voter_id), and addsdecided_attoproposal.vote(db, who, {proposalId, optionId}): anon issignin_required; someone off the roster isforbidden("Only the group votes."); a proposal that is notopenisinvalid; an option that does not belong to that proposal isinvalid; otherwise the ballot is inserted or replaced (changing a vote while the proposal is open keeps exactly one row) and the proposal is re-tallied in the same transaction.rulecolumn is read throughparseRule; a rule that no longer parses is a named failure, never a throw, and the page says so.tallysays decided, in that same transaction the state becomesdecided,decided_option_idis the winner anddecided_atis set — and only if the state was stillopen, so two simultaneous votes decide once. Thendecision.madeis emitted with the proposal, the winning option and the tally.proposeaccepts arulestring validated byparseRule(defaulting tomajority), so a proposal can be opened under any of the three rules from any door.getProposalreturns the tally and, for a signed-in member, their own ballot./p/:idshows one vote button per option (at least 44px on a side), the counts, who voted for what — the group is small and votes are public, and the page says so in words — and the decision when there is one./shows each open proposal's progress using the tally'sreason, and decided proposals in their own section.POST /proposals/:id/vote(form),POST /api/proposals/:id/vote({optionId}), MCP toolvote, andvin the terminal picking an option by number — all four calling the one action.vote.castanddecision.made, with no layout shift when a count changes.decision.madecarries the winner; an option id belonging to another proposal is refused; a proposal opened underalland one undernamed:each decide at their own threshold.Files I expect to change:
src/db.ts,src/actions.ts,src/actions.test.ts,src/routes.ts,src/pages/home.ts,src/pages/proposal.ts,src/mcp.ts,src/tty.ts,src/server.test.ts,static/app.css,README.md.Risks: tally and state change in one transaction, or a decision fires twice. Deciding is irreversible here — do not add an undecide door in this issue. The roster at the moment of the vote is the denominator, and a later roster change re-tallies nothing already decided: say that in the README rather than leaving it to be discovered.
Fit: Ledger — every state change a row with a reason, idempotent at the edge. A person assigns; I do not.