A decision lands when the rule is met — and if it never is, the proposal sits open forever and the group learns nothing from it. Give a proposal a deadline and a clear end.
What done looks like
[ ] propose takes an optional closesInDays (a whole number 1–30); a migration adds closes_at to proposal, null when the proposer set none. The default is a repository setting an admin can change, or none.
[ ] A migration extends the state check so expired is a state alongside open, decided and withdrawn — appended as a new table-and-copy migration if SQLite will not alter the check in place, and src/db.test.ts proves an existing database migrates forward with its rows intact.
[ ] A proposal whose closes_at has passed without meeting its rule becomes expired: no more votes (invalid, with a sentence saying the deadline passed), while remarks continue, because the record continues. proposal.expired is emitted once.
[ ] Expiry happens without a cron: a sweep runs after a response, at most once a minute, and any read of a proposal past its deadline settles it before answering. Deciding always wins a race — a proposal that met its rule is decided, never expired, even if the tally and the deadline land together.
[ ] The page shows the deadline in words on an open proposal ("closes in 3 days", "closes tomorrow"), shows what the tally stood at when an expired one closed, and lists expired proposals with decided ones rather than with open ones. A proposal with no deadline says so.
[ ] Every door carries it: closesInDays in POST /api/proposals and in the MCP propose schema, the deadline in getProposal, and the terminal showing it in the list and asking for it when proposing (Enter for none).
[ ] Tests: a deadline in the past on a fresh proposal is refused; 0 and 31 days are refused; a vote after the deadline is refused; a proposal that meets its rule on its last second is decided, not expired; proposal.expired fires once even when two requests sweep at the same time; an expired proposal still takes remarks.
[ ] README.md says what a deadline does and what it does not (it never decides anything by itself). bun test is green.
Files I expect to change:src/db.ts, src/db.test.ts, src/actions.ts, src/actions.test.ts, src/quorum.ts (read only — expiry is not a rule), src/routes.ts, src/pages/home.ts, src/pages/proposal.ts, src/mcp.ts, src/tty.ts, src/server.test.ts, README.md.
Risks: time is the easiest thing to get wrong — store closes_at as the same ISO-8601 UTC string the rest of the schema uses, compare in SQL against strftime rather than in two places, and let a test inject the clock rather than sleep. Expiry must never silently pick a winner: an expired proposal has no decided_option_id. Do not let the sweep run inside a request's critical path; it belongs after the response, like the trim in issue #9.
Fit: Ledger — a state machine where every transition is a row with a reason, and a race that resolves the same way every time. A person assigns; I do not.
A decision lands when the rule is met — and if it never is, the proposal sits open forever and the group learns nothing from it. Give a proposal a deadline and a clear end.
What done looks like
proposetakes an optionalclosesInDays(a whole number 1–30); a migration addscloses_attoproposal, null when the proposer set none. The default is a repository setting an admin can change, or none.expiredis a state alongsideopen,decidedandwithdrawn— appended as a new table-and-copy migration if SQLite will not alter the check in place, andsrc/db.test.tsproves an existing database migrates forward with its rows intact.closes_athas passed without meeting its rule becomesexpired: no more votes (invalid, with a sentence saying the deadline passed), while remarks continue, because the record continues.proposal.expiredis emitted once.decided, neverexpired, even if the tally and the deadline land together.closesInDaysinPOST /api/proposalsand in the MCPproposeschema, the deadline ingetProposal, and the terminal showing it in the list and asking for it when proposing (Enter for none).decided, notexpired;proposal.expiredfires once even when two requests sweep at the same time; an expired proposal still takes remarks.README.mdsays what a deadline does and what it does not (it never decides anything by itself).bun testis green.Files I expect to change:
src/db.ts,src/db.test.ts,src/actions.ts,src/actions.test.ts,src/quorum.ts(read only — expiry is not a rule),src/routes.ts,src/pages/home.ts,src/pages/proposal.ts,src/mcp.ts,src/tty.ts,src/server.test.ts,README.md.Risks: time is the easiest thing to get wrong — store
closes_atas the same ISO-8601 UTC string the rest of the schema uses, compare in SQL againststrftimerather than in two places, and let a test inject the clock rather than sleep. Expiry must never silently pick a winner: an expired proposal has nodecided_option_id. Do not let the sweep run inside a request's critical path; it belongs after the response, like the trim in issue #9.Fit: Ledger — a state machine where every transition is a row with a reason, and a race that resolves the same way every time. A person assigns; I do not.