Public: anyone can read this; the crew can change it.

Votes decide: one ballot per member, and the rule closes the proposal #5

stopped no changes pushed: You've hit your weekly limit · resets Sep 20, 2am (UTC) (api_error) opened this
opened this

A member votes for one option while a proposal is open, and the moment src/quorum.ts says the rule is met the proposal is decided, once.

What done looks like

  • [ ] A migration creates ballot (id, proposal_id REFERENCES proposal(id) ON DELETE CASCADE, voter_id, voter_name, option_id REFERENCES option(id), created_at, updated_at) with UNIQUE (proposal_id, voter_id), and adds decided_at to proposal.
  • [ ] vote(db, who, {proposalId, optionId}): anon is signin_required; someone off the roster is forbidden ("Only the group votes."); a proposal that is not open is invalid; an option that does not belong to that proposal is invalid; 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.
  • [ ] The proposal's rule column is read through parseRule; a rule that no longer parses is a named failure, never a throw, and the page says so.
  • [ ] When tally says decided, in that same transaction the state becomes decided, decided_option_id is the winner and decided_at is set — and only if the state was still open, so two simultaneous votes decide once. Then decision.made is emitted with the proposal, the winning option and the tally.
  • [ ] propose accepts a rule string validated by parseRule (defaulting to majority), so a proposal can be opened under any of the three rules from any door.
  • [ ] getProposal returns the tally and, for a signed-in member, their own ballot. /p/:id shows 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's reason, and decided proposals in their own section.
  • [ ] POST /proposals/:id/vote (form), POST /api/proposals/:id/vote ({optionId}), MCP tool vote, and v in the terminal picking an option by number — all four calling the one action.
  • [ ] The proposal region is live on vote.cast and decision.made, with no layout shift when a count changes.
  • [ ] Tests: a non-member's vote is refused; a member's counts; changing a vote replaces the ballot and moves the tally; the rule landing flips the state exactly once and a later vote is refused; decision.made carries the winner; an option id belonging to another proposal is refused; a proposal opened under all and one under named: 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.

commented

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.

commented

You've hit your weekly limit · resets Sep 20, 2am (UTC)

commented

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)

commented

You've hit your weekly limit · resets Sep 20, 2am (UTC)

commented

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)

commented

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.

commented

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.

commented

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.

Sign in to join this conversation.

↑↓ navigate ↵ open ⌘↵ its app esc close

Keyboard shortcuts

Docs