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

A proposer picks the quorum rule, and the proposal says which one it is #26

Merged opened this

Check · Review · Land · Live done — Landed and live in production.

Merged

ledger/quorum:ledger/21-a-proposer-picks-the-quorum-rule-and-the → main 1 commit fork

  • Merged. ledger/21-a-proposer-picks-the-quorum-rule-and-the was merged into main as ea9923f.

Review

Sign in to review this change.
12 files changed +434 −50
README.md +22 −6
@@ -24,6 +24,7 @@ decide nothing themselves. `AGENTS.md` says how to add to it.
2424 | file | what it is |
2525 | ---------------------------------- | ------------------------------------------------------------ |
2626 | `src/actions.ts` | propose, list, read, withdraw, health — and who may |
27 | `src/quorum.ts` | the three rules as plain data: read one, say it, count it |
2728 | `src/who.ts` | who is asking, from the forge's signed headers |
2829 | `src/db.ts` | SQLite under `FORGE_APP_DATA`, WAL, forward-only migrations |
2930 | `src/live.ts` | the event log, the bus, and `GET /events` |
@@ -58,14 +59,16 @@ In a browser, `/` is every open proposal — newest first, with its author,
5859 how many options it carries and where it stands — and the composer for
5960 whoever is signed in. `/p/<id>` is one proposal: the paragraph, the options
6061 in order, and a Withdraw form for its author or an admin while it is open.
61 A guest sees both pages and no forms.
62 The composer picks the rule from a select, with one field beside it for the
63 names and count a `named:` rule needs; `/p/<id>` says the rule in words above
64 the options. A guest sees both pages and no forms.
6265
6366 ```sh
6467 curl https://<app>/api/proposals # every proposal, newest first
6568 curl 'https://<app>/api/proposals?state=open'
6669 curl https://<app>/api/proposals/1 # one, with its options in order
6770 curl -X POST https://<app>/api/proposals -H 'content-type: application/json' \
68 -d '{"title":"Adopt the four-day week","body":"A trial for one quarter.","options":["Yes","No"]}'
71 -d '{"title":"Adopt the four-day week","body":"A trial for one quarter.","options":["Yes","No"],"rule":"named:alice,bob,carol:2"}'
6972 curl -X POST https://<app>/api/proposals/1/withdraw
7073 curl https://<app>/api/health # 200 while sound, 503 when it is not
7174 ```
@@ -84,8 +87,20 @@ of the variable and what it costs, never its value.
8487
8588 A proposal is a title (1–120 characters), a paragraph (1–2000) and two to
8689 eight options, each labelled, each different. Its author or an admin can
87 withdraw it while it is open, once. `rule` says how it will be decided and
88 is `majority` for now.
90 withdraw it while it is open, once.
91
92 `rule` says how it will be decided. The proposer picks it when they open the
93 proposal — on the page, over the API, in MCP or at the terminal — and it is
94 one line of text in one of three forms:
95
96 ```
97 majority more than half of the group on one option decides
98 all everyone has voted and they all agree
99 named:alice,bob,carol:2 2 of those three agree; nobody else counts
100 ```
101
102 Anything else is refused with a sentence naming the three forms, and no rule
103 at all means `majority`. A proposal keeps the rule it was opened with.
89104
90105 As an MCP server, for Claude Code or Claude Desktop — `list_proposals`,
91106 `get_proposal`, `propose`, `withdraw_proposal`, `health` (the same action
@@ -115,8 +130,9 @@ runs (`ssh -p 2222 <name>@<forge>` — the app is the login name, the forge
115130 the host; `<name>--<pull>@<forge>` for a preview's). It is `src/tty.ts` on
116131 a real pty over the same data, as the account at the keyboard: `j`/`k`
117132 moves, Enter opens the proposal under the cursor, `n` proposes (a title,
118 then the paragraph, then options one per line until a blank line), `w`
119 withdraws, Esc goes back and `q` quits. A guest is told to sign in rather
133 then the paragraph, then options one per line until a blank line, then the
134 rule — Enter alone for `majority`), `w` withdraws, Esc goes back and `q`
135 quits. A guest is told to sign in rather
120136 than shown a composer.
121137
122138 The forge sets `PORT`, `FORGE_APP_DATA`, `FORGE_APP_ENV` (`production` or
src/actions.test.ts +84
@@ -13,6 +13,7 @@ import {
1313 } from "./actions.ts";
1414 import { migrations, openDb } from "./db.ts";
1515 import { useDb } from "./live.ts";
16 import type { Rule } from "./quorum.ts";
1617 import type { Who } from "./who.ts";
1718
1819 const db = openDb(mkdtempSync(join(tmpdir(), "app-actions-")));
@@ -106,6 +107,89 @@ test("a title is at most a hundred and twenty characters, a paragraph two thousa
106107 expect(made(alice, { body: "x".repeat(2000) }).body.length).toBe(2000);
107108 });
108109
110 test("the proposer picks the rule, and it is read once on the way out", () => {
111 const forms: [string, Rule][] = [
112 ["majority", { kind: "majority" }],
113 ["all", { kind: "all" }],
114 [
115 "named:alice,bob,carol:2",
116 { kind: "named", who: ["alice", "bob", "carol"], needed: 2 },
117 ],
118 ];
119 for (const [text, parsed] of forms) {
120 const opened = made(alice, { rule: text });
121 expect(opened.rule).toBe(text);
122 expect(opened.rule_parsed).toEqual(parsed);
123
124 const read = getProposal(db, anon, { id: opened.id });
125 expect(read.ok && read.value.rule).toBe(text);
126 expect(read.ok && read.value.rule_parsed).toEqual(parsed);
127 const listed = listProposals(db, anon, { state: "open", limit: 1 });
128 expect(listed.ok && listed.value[0]?.id).toBe(opened.id);
129 expect(listed.ok && listed.value[0]?.rule_parsed).toEqual(parsed);
130 // Withdrawing hands back the same proposal, rule and all.
131 const gone = withdrawProposal(db, alice, { id: opened.id });
132 expect(gone.ok && gone.value.rule).toBe(text);
133 expect(gone.ok && gone.value.rule_parsed).toEqual(parsed);
134 }
135
136 // No rule at all is majority, exactly as it was before there was a choice.
137 for (const rule of [undefined, "", " "]) {
138 const quiet = made(alice, { rule });
139 expect(quiet.rule).toBe("majority");
140 expect(quiet.rule_parsed).toEqual({ kind: "majority" });
141 }
142 // Surrounding space is typing, not text: what parses is what is stored.
143 expect(made(alice, { rule: " named:alice,bob:1 " }).rule).toBe(
144 "named:alice,bob:1",
145 );
146 });
147
148 test("a rule that is not one of the three forms is refused, by name", () => {
149 const bad = [
150 "named:alice,bob:3", // more needed than there are names
151 "named::1", // nobody named
152 "named:,:2", // still nobody
153 "named:alice,bob:0", // nobody needs to agree
154 "named:alice,alice:1", // the same person twice
155 "named:alice bob:1", // a space is not a separator
156 "unanimous",
157 "majority:9",
158 "Majority",
159 "all:2",
160 "x".repeat(500),
161 ];
162 for (const rule of bad) {
163 const r = propose(db, alice, { ...good, rule } as typeof good);
164 expect(r.ok, rule.slice(0, 24)).toBe(false);
165 if (!r.ok) {
166 expect(r.error).toBe("invalid");
167 // The sentence names all three forms, so nobody has to guess.
168 expect(r.message).toContain("majority");
169 expect(r.message).toContain("all");
170 expect(r.message).toContain("named:alice,bob:2");
171 }
172 }
173 });
174
175 test("a stored rule this app cannot read still reads, as no rule at all", () => {
176 const opened = made(alice);
177 // A row from before the proposer could choose, or one edited by hand.
178 db.query("UPDATE proposal SET rule = ?1 WHERE id = ?2").run(
179 "consensus",
180 opened.id,
181 );
182 const read = getProposal(db, anon, { id: opened.id });
183 expect(read.ok).toBe(true);
184 if (!read.ok) return;
185 expect(read.value.rule).toBe("consensus");
186 expect(read.value.rule_parsed).toBeUndefined();
187 const listed = listProposals(db, anon, { state: "open" });
188 expect(
189 listed.ok && listed.value.find((p) => p.id === opened.id)?.rule_parsed,
190 ).toBeUndefined();
191 });
192
109193 test("only the author or an admin withdraws, and only once", () => {
110194 const mine = made(alice);
111195 expect(canWithdraw(alice, mine)).toBe(true);
src/actions.ts +53 −23
@@ -12,11 +12,13 @@ import { dirname } from "node:path";
1212 import { migrations, quickCheck, schemaVersion } from "./db.ts";
1313 import { commit, env, identityState, ssh } from "./env.ts";
1414 import { emit } from "./live.ts";
15 import { parseRule, RULE_FORMS, type Rule } from "./quorum.ts";
1516 import type { Who } from "./who.ts";
1617
1718 export type State = "open" | "decided" | "withdrawn";
1819
19 export interface Proposal {
20 /** A proposal as the database holds it: `rule` is the text that was stored. */
21 interface ProposalRow {
2022 readonly id: number;
2123 readonly title: string;
2224 readonly body: string;
@@ -29,6 +31,25 @@ export interface Proposal {
2931 readonly updated_at: string;
3032 }
3133
34 /**
35 * A proposal as a door gets it: the row, with its rule already read. Every
36 * proposal that leaves this file has been through `parseRule` once, so no
37 * door reads that text a second time. `rule_parsed` is `undefined` when the
38 * stored text is not a rule this app knows — a row from before the choice
39 * existed, or one edited by hand — and the row still reads.
40 */
41 export interface Proposal extends ProposalRow {
42 readonly rule_parsed: Rule | undefined;
43 }
44
45 /** The row as it leaves: the rule read once, here, and nowhere else. */
46 const read = <T extends ProposalRow>(
47 row: T,
48 ): T & { rule_parsed: Rule | undefined } => ({
49 ...row,
50 rule_parsed: parseRule(row.rule),
51 });
52
3253 export interface Option {
3354 readonly id: number;
3455 readonly proposal_id: number;
@@ -40,6 +61,9 @@ export interface Option {
4061 export interface ProposalSummary extends Proposal {
4162 readonly option_count: number;
4263 }
64 interface SummaryRow extends ProposalRow {
65 readonly option_count: number;
66 }
4367
4468 /** A proposal on its own page: the row with its options, in order. */
4569 export interface FullProposal extends Proposal {
@@ -63,8 +87,8 @@ export const LABEL_MAX = 80;
6387 export const OPTIONS_MIN = 2;
6488 export const OPTIONS_MAX = 8;
6589 export const STATES: readonly State[] = ["open", "decided", "withdrawn"];
66 /** How a proposal is decided. Only one rule exists so far; the rules module adds the rest. */
67 export const RULES: readonly string[] = ["majority"];
90 /** What a rule that is not a rule is told. The forms live in `quorum.ts`. */
91 const RULE_MESSAGE = `A rule is one of ${RULE_FORMS}.`;
6892
6993 const SUMMARY =
7094 "SELECT p.*, (SELECT count(*) FROM option WHERE proposal_id = p.id) AS option_count FROM proposal p";
@@ -81,21 +105,21 @@ export function listProposals(
81105 const state = input.state;
82106 if (state !== undefined && !STATES.includes(state as State))
83107 return fail("invalid", `A state is one of ${STATES.join(", ")}.`);
84 return ok(
108 const rows =
85109 state === undefined
86110 ? db
87111 .query<
88 ProposalSummary,
112 SummaryRow,
89113 [number]
90114 >(`${SUMMARY} ORDER BY p.id DESC LIMIT ?1`)
91115 .all(limit)
92116 : db
93117 .query<
94 ProposalSummary,
118 SummaryRow,
95119 [string, number]
96120 >(`${SUMMARY} WHERE p.state = ?1 ORDER BY p.id DESC LIMIT ?2`)
97 .all(state, limit),
98 );
121 .all(state, limit);
122 return ok(rows.map(read));
99123 }
100124
101125 export function getProposal(
@@ -106,7 +130,7 @@ export function getProposal(
106130 if (!Number.isInteger(input.id))
107131 return fail("invalid", "A proposal id is a whole number.");
108132 const proposal = db
109 .query<Proposal, [number]>("SELECT * FROM proposal WHERE id = ?1")
133 .query<ProposalRow, [number]>("SELECT * FROM proposal WHERE id = ?1")
110134 .get(input.id);
111135 if (proposal === null) return fail("not_found", "No such proposal.");
112136 const options = db
@@ -115,7 +139,7 @@ export function getProposal(
115139 [number]
116140 >("SELECT * FROM option WHERE proposal_id = ?1 ORDER BY position")
117141 .all(proposal.id);
118 return ok({ ...proposal, options });
142 return ok({ ...read(proposal), options });
119143 }
120144
121145 export function propose(
@@ -136,9 +160,10 @@ export function propose(
136160 const body = String(input.body ?? "").trim();
137161 if (body.length === 0 || body.length > BODY_MAX)
138162 return fail("invalid", `A paragraph is 1 to ${BODY_MAX} characters.`);
139 const rule = String(input.rule ?? "majority").trim() || "majority";
140 if (!RULES.includes(rule))
141 return fail("invalid", `A rule is one of ${RULES.join(", ")}.`);
163 // The rule is the proposer's, once: the text they gave is what is stored,
164 // and `parseRule` is what says whether it is a rule at all.
165 const rule = String(input.rule ?? "").trim() || "majority";
166 if (parseRule(rule) === undefined) return fail("invalid", RULE_MESSAGE);
142167 if (!Array.isArray(input.options))
143168 return fail(
144169 "invalid",
@@ -165,7 +190,7 @@ export function propose(
165190 const created = db.transaction((): FullProposal => {
166191 const proposal = db
167192 .query<
168 Proposal,
193 ProposalRow,
169194 [string, string, string, string, string]
170195 >("INSERT INTO proposal (title, body, rule, author_id, author_name) VALUES (?1, ?2, ?3, ?4, ?5) RETURNING *")
171196 .get(title, body, rule, who.id, who.name)!;
@@ -174,7 +199,7 @@ export function propose(
174199 [number, string, number]
175200 >("INSERT INTO option (proposal_id, label, position) VALUES (?1, ?2, ?3) RETURNING *");
176201 const options = labels.map((label, i) => insert.get(proposal.id, label, i)!);
177 return { ...proposal, options };
202 return { ...read(proposal), options };
178203 })();
179204 emit("proposal.opened", created);
180205 return ok(created);
@@ -190,7 +215,7 @@ export function withdrawProposal(
190215 if (!Number.isInteger(input.id))
191216 return fail("invalid", "A proposal id is a whole number.");
192217 const proposal = db
193 .query<Proposal, [number]>("SELECT * FROM proposal WHERE id = ?1")
218 .query<ProposalRow, [number]>("SELECT * FROM proposal WHERE id = ?1")
194219 .get(input.id);
195220 if (proposal === null) return fail("not_found", "No such proposal.");
196221 if (!canWithdraw(who, proposal))
@@ -203,12 +228,14 @@ export function withdrawProposal(
203228 "invalid",
204229 `This proposal is already ${proposal.state}; only an open one can be withdrawn.`,
205230 );
206 const withdrawn = db
207 .query<
208 Proposal,
209 [number]
210 >("UPDATE proposal SET state = 'withdrawn', updated_at = strftime('%Y-%m-%dT%H:%M:%fZ', 'now') WHERE id = ?1 RETURNING *")
211 .get(proposal.id)!;
231 const withdrawn = read(
232 db
233 .query<
234 ProposalRow,
235 [number]
236 >("UPDATE proposal SET state = 'withdrawn', updated_at = strftime('%Y-%m-%dT%H:%M:%fZ', 'now') WHERE id = ?1 RETURNING *")
237 .get(proposal.id)!,
238 );
212239 emit("proposal.withdrawn", { id: withdrawn.id });
213240 return ok(withdrawn);
214241 }
@@ -305,6 +332,9 @@ function soundness(db: Database, now = Date.now()): string {
305332 * own check; the page and the terminal ask this and the state together, so
306333 * the rule about who lives once.
307334 */
308 export const canWithdraw = (who: Who, proposal: Proposal): boolean =>
335 export const canWithdraw = (
336 who: Who,
337 proposal: Pick<Proposal, "author_id">,
338 ): boolean =>
309339 who.role === "admin" ||
310340 (who.role === "user" && who.id === proposal.author_id);
src/mcp.ts +10 −5
@@ -17,13 +17,13 @@ import {
1717 LABEL_MAX,
1818 OPTIONS_MAX,
1919 OPTIONS_MIN,
20 RULES,
2120 STATES,
2221 TITLE_MAX,
2322 type Result,
2423 } from "./actions.ts";
2524 import { identityState, title } from "./env.ts";
2625 import { fields } from "./http.ts";
26 import { RULE_TEXT_MAX } from "./quorum.ts";
2727 import type { Who } from "./who.ts";
2828
2929 interface Tool {
@@ -78,7 +78,7 @@ export const tools: readonly Tool[] = [
7878 {
7979 name: "propose",
8080 description:
81 "Open a proposal as the signed-in person: a title, a paragraph and two to eight distinct options. Anonymous callers cannot.",
81 "Open a proposal as the signed-in person: a title, a paragraph, two to eight distinct options and the rule that decides it. Anonymous callers cannot.",
8282 inputSchema: {
8383 type: "object",
8484 properties: {
@@ -93,8 +93,9 @@ export const tools: readonly Tool[] = [
9393 },
9494 rule: {
9595 type: "string",
96 enum: [...RULES],
97 description: "How it will be decided (majority).",
96 maxLength: RULE_TEXT_MAX,
97 description:
98 "How it will be decided, in one line: `majority` (more than half the group on one option), `all` (everyone has voted and they all agree), or `named:alice,bob,carol:2` (that many of those named agree, and nobody else counts). `majority` if left out; it cannot be changed afterwards.",
9899 },
99100 },
100101 required: ["title", "body", "options"],
@@ -104,7 +105,11 @@ export const tools: readonly Tool[] = [
104105 title: String(args.title ?? ""),
105106 body: String(args.body ?? ""),
106107 options: Array.isArray(args.options) ? args.options : [],
107 rule: String(args.rule ?? "majority"),
108 // Absent stays absent: `propose` is where majority is the default.
109 rule:
110 args.rule === undefined || args.rule === null
111 ? undefined
112 : String(args.rule),
108113 }),
109114 },
110115 {
src/pages/home.ts +14 −1
@@ -13,6 +13,7 @@ import {
1313 } from "../actions.ts";
1414 import { where } from "../env.ts";
1515 import { escape } from "../http.ts";
16 import { RULES, RULE_TEXT_MAX } from "../quorum.ts";
1617 import type { Who } from "../who.ts";
1718 import { ago, layout, toast } from "./layout.ts";
1819
@@ -25,7 +26,7 @@ export function home(input: {
2526 const composer =
2627 who.role === "anon"
2728 ? `<p class="muted">Reading as a guest. <a href="/__forge/signin?to=%2F">Sign in</a> to make a proposal.</p>`
28 : `<form class="stack" method="post" action="/proposals">
29 : `<form class="stack composer" method="post" action="/proposals">
2930 <label class="field">
3031 <span>Title</span>
3132 <input class="input" name="title" placeholder="What is being decided" required maxlength="${TITLE_MAX}" autocomplete="off">
@@ -39,6 +40,18 @@ export function home(input: {
3940 <textarea class="input" name="options" rows="3" placeholder="Yes&#10;No" required maxlength="${(LABEL_MAX + 1) * OPTIONS_MAX}"></textarea>
4041 </label>
4142 <p class="muted small">${OPTIONS_MIN} to ${OPTIONS_MAX} options, each at most ${LABEL_MAX} characters and each different.</p>
43 <label class="field">
44 <span>How it is decided</span>
45 <select class="input" name="rule">${RULES.map(
46 (rule) =>
47 `<option value="${escape(rule.kind)}"${rule.kind === "majority" ? " selected" : ""}>${escape(rule.label)} — ${escape(rule.description)}</option>`,
48 ).join("")}</select>
49 </label>
50 <label class="field named-field">
51 <span>Names and how many, for “a named few” only</span>
52 <input class="input mono" name="named" placeholder="alice,bob,carol:2" autocomplete="off" maxlength="${RULE_TEXT_MAX}">
53 <span class="muted small">Ignored unless the rule above is “a named few”. Person ids separated by commas, then a colon and how many of them must agree.</span>
54 </label>
4255 <div class="row"><button class="button">Propose</button></div>
4356 </form>`;
4457 const list =
src/pages/proposal.ts +14 −1
@@ -6,6 +6,7 @@
66
77 import { canWithdraw, type FullProposal } from "../actions.ts";
88 import { escape } from "../http.ts";
9 import { describeRule } from "../quorum.ts";
910 import type { Who } from "../who.ts";
1011 import { ago, layout, toast } from "./layout.ts";
1112
@@ -27,8 +28,9 @@ export function proposalPage(input: {
2728 <h2>${escape(proposal.title)}</h2>
2829 <span class="pill" data-state="${escape(proposal.state)}">${escape(proposal.state)}</span>
2930 </div>
30 <p class="muted small">${escape(proposal.author_name)} · <time datetime="${escape(proposal.created_at)}">${ago(proposal.created_at)}</time> · decided by ${escape(proposal.rule)}</p>
31 <p class="muted small">${escape(proposal.author_name)} · <time datetime="${escape(proposal.created_at)}">${ago(proposal.created_at)}</time></p>
3132 <p class="body">${escape(proposal.body)}</p>
33 <p class="rule small">${rule(proposal)}</p>
3234 <ol class="options">${proposal.options
3335 .map(
3436 (option) =>
@@ -42,3 +44,14 @@ export function proposalPage(input: {
4244 `/p/${proposal.id}`,
4345 );
4446 }
47
48 /**
49 * How this one is decided, in words, above the options. The rule was read
50 * once in the action; a rule this app cannot read — a row from before the
51 * proposer could choose, or one edited by hand — says so and shows the text
52 * rather than taking the page down.
53 */
54 const rule = (proposal: FullProposal): string =>
55 proposal.rule_parsed === undefined
56 ? `<span class="muted">A rule this app cannot read:</span> <code>${escape(proposal.rule)}</code>`
57 : escape(describeRule(proposal.rule_parsed));
src/quorum.test.ts +25
@@ -1,10 +1,13 @@
11 import { expect, test } from "bun:test";
22 import {
33 type Ballot,
4 describeRule,
45 formatRule,
56 parseRule,
67 RULES,
8 RULE_FORMS,
79 type Rule,
10 ruleText,
811 tally,
912 } from "./quorum.ts";
1013
@@ -51,6 +54,28 @@ test("parseRule reads the three spellings and nothing else", () => {
5154 });
5255 });
5356
57 test("a rule says in words what it needs, and the three forms are named once", () => {
58 expect(describeRule({ kind: "majority" })).toBe(
59 RULES[0]?.description as string,
60 );
61 expect(describeRule({ kind: "all" })).toBe(RULES[1]?.description as string);
62 expect(
63 describeRule({ kind: "named", who: ["alice", "bob", "carol"], needed: 2 }),
64 ).toBe("Needs 2 of alice, bob, carol");
65 for (const form of ["majority", "all", "named:alice,bob:2"])
66 expect(RULE_FORMS).toContain(form);
67 });
68
69 test("a kind and the names beside it are one line of rule text", () => {
70 expect(ruleText("majority", "")).toBe("majority");
71 // The names field is for `named:` alone: another kind ignores it.
72 expect(ruleText("all", "alice,bob:2")).toBe("all");
73 expect(ruleText("named", " alice,bob:2 ")).toBe("named:alice,bob:2");
74 // Nothing is judged here; parseRule is what refuses.
75 expect(parseRule(ruleText("named", ""))).toBeUndefined();
76 expect(parseRule(ruleText("unanimous", ""))).toBeUndefined();
77 });
78
5479 test("parseRule refuses, without throwing, everything it does not know", () => {
5580 const refused: [string, unknown][] = [
5681 ["an unknown kind", "unanimous"],
src/quorum.ts +26 −2
@@ -52,12 +52,36 @@ export const RULES = [
5252 },
5353 ] as const;
5454
55 /** The three forms in one clause, for a boundary to say what it refuses. */
56 export const RULE_FORMS =
57 "majority, all, or named:alice,bob:2 (the names that count, then how many of them must agree)";
58
59 /**
60 * One sentence saying what this rule needs, in the words `RULES` carries —
61 * so the page, the terminal and anything else say the same thing. A `named`
62 * rule names its people, and `parseRule` has already held those names to
63 * `[A-Za-z0-9_.-]`; a caller escapes this like any other text.
64 */
65 export function describeRule(rule: Rule): string {
66 if (rule.kind === "named")
67 return `Needs ${rule.needed} of ${rule.who.join(", ")}`;
68 return RULES.find((r) => r.kind === rule.kind)!.description;
69 }
70
71 /**
72 * The one line of rule text a two-field chooser means: the kind a person
73 * picked, and the names and count they typed beside it for `named:`.
74 * Nothing is judged here — `parseRule`, at the action, is what refuses.
75 */
76 export const ruleText = (kind: string, named: string): string =>
77 kind.trim() === "named" ? `named:${named.trim()}` : kind.trim();
78
5579 /** A person's id is the forge's `person_id`, never a display name. */
5680 const NAME = /^[A-Za-z0-9_.-]+$/;
5781 const NAME_MAX = 64;
5882 const NAMES_MAX = 32;
5983 /** Longer than any rule this file can write, so parsing is bounded work. */
60 const TEXT_MAX = 2048;
84 export const RULE_TEXT_MAX = 2048;
6185
6286 /**
6387 * Read stored rule text. Exact text only — no trimming, no case folding —
@@ -67,7 +91,7 @@ const TEXT_MAX = 2048;
6791 * `needed` that is not a whole number from 1 to the number of names.
6892 */
6993 export function parseRule(text: unknown): Rule | undefined {
70 if (typeof text !== "string" || text.length > TEXT_MAX) return undefined;
94 if (typeof text !== "string" || text.length > RULE_TEXT_MAX) return undefined;
7195 if (text === "majority") return { kind: "majority" };
7296 if (text === "all") return { kind: "all" };
7397
src/routes.ts +12 −2
@@ -30,6 +30,7 @@ import { rpc } from "./mcp.ts";
3030 import { home } from "./pages/home.ts";
3131 import { missing } from "./pages/layout.ts";
3232 import { proposalPage } from "./pages/proposal.ts";
33 import { ruleText } from "./quorum.ts";
3334 import type { Who } from "./who.ts";
3435
3536 export interface Context {
@@ -81,6 +82,10 @@ const lines = (value: string): string[] =>
8182 .map((line) => line.trim())
8283 .filter((line) => line !== "");
8384
85 /** A field a caller may leave out: absent stays absent, so the action's default holds. */
86 const given = (value: unknown): string | undefined =>
87 value === undefined || value === null ? undefined : String(value);
88
8489 /** Whatever JSON carried as a list of options, as a list. */
8590 const optionsOf = (value: unknown): unknown[] =>
8691 Array.isArray(value) ? value : typeof value === "string" ? lines(value) : [];
@@ -129,7 +134,12 @@ export const routes: readonly Route[] = [
129134 title: String(form.get("title") ?? ""),
130135 body: String(form.get("body") ?? ""),
131136 options: lines(String(form.get("options") ?? "")),
132 rule: String(form.get("rule") ?? "majority"),
137 // The composer is a select and a text field; one line of rule text
138 // is what they mean together, and the action judges it.
139 rule: ruleText(
140 String(form.get("rule") ?? "majority"),
141 String(form.get("named") ?? ""),
142 ),
133143 });
134144 return made.ok
135145 ? back(undefined, `/p/${made.value.id}`)
@@ -176,7 +186,7 @@ export const routes: readonly Route[] = [
176186 title: String(input.title ?? ""),
177187 body: String(input.body ?? ""),
178188 options: optionsOf(input.options),
179 rule: String(input.rule ?? "majority"),
189 rule: given(input.rule),
180190 }),
181191 201,
182192 );
src/server.test.ts +115
@@ -142,6 +142,121 @@ test("a proposal page shows the options and hides the form from a guest", async
142142 expect(mine).toContain(`action="/proposals/${made.id}/withdraw"`);
143143 });
144144
145 test("the rule a proposer picks round-trips, and the page says what it means", async () => {
146 const picker = as("u5", "picker");
147 const forms: [string, string, string][] = [
148 ["majority", "majority", "More than half of the group"],
149 ["all", "all", "Every member of the group"],
150 ["named:alice,bob,carol:2", "named", "Needs 2 of alice, bob, carol"],
151 ];
152 for (const [rule, kind, words] of forms) {
153 const made = await open(picker, { rule });
154 const back = (await (
155 await fetch(`${base}/api/proposals/${made.id}`)
156 ).json()) as { rule: string; rule_parsed: { kind: string } };
157 expect(back.rule).toBe(rule);
158 expect(back.rule_parsed.kind).toBe(kind);
159 const page = await (await fetch(`${base}/p/${made.id}`)).text();
160 expect(page).toContain(words);
161 }
162
163 // No rule is still majority, and the composer offers all three kinds.
164 const plain = await open(picker);
165 expect(
166 (
167 (await (await fetch(`${base}/api/proposals/${plain.id}`)).json()) as {
168 rule: string;
169 }
170 ).rule,
171 ).toBe("majority");
172 const composer = await (await api("/", {}, picker)).text();
173 expect(composer).toContain('<select class="input" name="rule">');
174 for (const kind of ["majority", "all", "named"])
175 expect(composer).toContain(`<option value="${kind}"`);
176 expect(composer).toContain('name="named"');
177 expect(composer).toContain("a named few");
178 });
179
180 test("a rule the app cannot read is refused at the API, by name", async () => {
181 const picker = as("u6", "pedant");
182 for (const rule of [
183 "named:alice,bob:3",
184 "named::2",
185 "unanimous",
186 "majority:9",
187 "x".repeat(500),
188 ]) {
189 const refused = await api(
190 "/api/proposals",
191 { method: "POST", ...jsonBody({ ...proposal, rule }) },
192 picker,
193 );
194 expect(refused.status, rule.slice(0, 20)).toBe(422);
195 const body = (await refused.json()) as { error: string; message: string };
196 expect(body.error).toBe("invalid");
197 expect(body.message).toContain("named:alice,bob:2");
198 }
199 });
200
201 test("the composer's select and names field make one rule", async () => {
202 const form = new FormData();
203 form.set("title", "Who signs off");
204 form.set("body", "Two of the three of us, and nobody else.");
205 form.set("options", "Ship it\nWait");
206 form.set("rule", "named");
207 form.set("named", "alice,bob,carol:2");
208 const posted = await api(
209 "/proposals",
210 { method: "POST", body: form, redirect: "manual" },
211 bob,
212 );
213 expect(posted.headers.get("set-cookie")).toBeNull();
214 const to = posted.headers.get("location") ?? "";
215 expect(to).toMatch(/^\/p\/\d+$/);
216 const page = await (await fetch(base + to)).text();
217 expect(page).toContain("Needs 2 of alice, bob, carol");
218
219 // The same form with a count nobody can reach comes back with the reason.
220 form.set("named", "alice,bob:3");
221 const refused = await api(
222 "/proposals",
223 { method: "POST", body: form, redirect: "manual" },
224 bob,
225 );
226 expect(refused.headers.get("location")).toBe("/");
227 expect(decodeURIComponent(refused.headers.get("set-cookie") ?? "")).toContain(
228 "A rule is one of",
229 );
230 });
231
232 test("the MCP propose tool describes the three forms it takes", async () => {
233 const listed = await rpc({ jsonrpc: "2.0", id: 2, method: "tools/list" });
234 const schema = listed.result.tools.find(
235 (t: { name: string }) => t.name === "propose",
236 ).inputSchema;
237 for (const form of ["majority", "all", "named:alice,bob,carol:2"])
238 expect(schema.properties.rule.description).toContain(form);
239 const called = await rpc(
240 {
241 jsonrpc: "2.0",
242 id: 3,
243 method: "tools/call",
244 params: {
245 name: "propose",
246 arguments: { ...proposal, rule: "named:root,alice:1" },
247 },
248 },
249 root,
250 );
251 const one = JSON.parse(called.result.content[0].text);
252 expect(one.rule).toBe("named:root,alice:1");
253 expect(one.rule_parsed).toEqual({
254 kind: "named",
255 who: ["root", "alice"],
256 needed: 1,
257 });
258 });
259
145260 test("something that is not a proposal is a 404 in the layout", async () => {
146261 for (const path of ["/p/9999", "/p/not-a-number"]) {
147262 const response = await fetch(base + path);
src/tty.ts +44 −10
@@ -18,6 +18,7 @@ import {
1818 } from "./actions.ts";
1919 import { openDb } from "./db.ts";
2020 import { ssh, title, where } from "./env.ts";
21 import { describeRule, RULE_FORMS } from "./quorum.ts";
2122 import { whoAtTty } from "./who.ts";
2223
2324 const db = openDb();
@@ -31,10 +32,11 @@ const out = (s: string): void => {
3132
3233 /** What is being typed, and how much of a proposal has been said so far. */
3334 interface Draft {
34 step: "title" | "body" | "options";
35 step: "title" | "body" | "options" | "rule";
3536 title: string;
3637 body: string;
3738 options: string[];
39 rule: string;
3840 typing: string;
3941 }
4042
@@ -107,11 +109,17 @@ const detail = (id: number): string[] => {
107109 return [
108110 bold(proposal.title),
109111 dim(
110 `${proposal.author_name} · ${proposal.state} · decided by ${proposal.rule} · ${proposal.created_at.slice(0, 16).replace("T", " ")}`,
112 `${proposal.author_name} · ${proposal.state} · ${proposal.created_at.slice(0, 16).replace("T", " ")}`,
111113 ),
112114 "",
113115 ...wrap(proposal.body),
114116 "",
117 dim(
118 proposal.rule_parsed === undefined
119 ? `A rule this app cannot read: ${proposal.rule}`
120 : describeRule(proposal.rule_parsed),
121 ),
122 "",
115123 ...proposal.options.map((o) => ` ${o.position + 1}. ${o.label}`),
116124 ];
117125 };
@@ -128,10 +136,22 @@ const composer = (d: Draft): string[] => {
128136 ? `Paragraph: ${d.typing}${cell}`
129137 : dim(`Paragraph: ${d.body}`),
130138 );
131 if (d.step === "options") {
132 lines.push(dim(`Options (${d.options.length}), blank line when done:`));
139 if (d.step === "options" || d.step === "rule") {
140 lines.push(
141 dim(
142 d.step === "options"
143 ? `Options (${d.options.length}), blank line when done:`
144 : `Options (${d.options.length}):`,
145 ),
146 );
133147 d.options.forEach((label, i) => lines.push(dim(` ${i + 1}. ${label}`)));
134 lines.push(` ${d.options.length + 1}. ${d.typing}${cell}`);
148 if (d.step === "options")
149 lines.push(` ${d.options.length + 1}. ${d.typing}${cell}`);
150 }
151 if (d.step === "rule") {
152 lines.push("");
153 lines.push(dim(`Rule — ${RULE_FORMS}. Enter for majority.`));
154 lines.push(`Rule: ${d.typing}${cell}`);
135155 }
136156 return lines;
137157 };
@@ -150,6 +170,7 @@ function submit(d: Draft): void {
150170 title: d.title,
151171 body: d.body,
152172 options: d.options,
173 rule: d.rule,
153174 });
154175 if (made.ok) {
155176 said = `Proposed: ${made.value.title}`;
@@ -159,8 +180,9 @@ function submit(d: Draft): void {
159180 } else {
160181 // Keep what was typed: the message says what to change.
161182 said = made.message;
162 d.step = "options";
163 d.typing = "";
183 d.step = d.rule === "" ? "options" : "rule";
184 d.typing = d.rule;
185 d.rule = "";
164186 }
165187 }
166188
@@ -203,8 +225,13 @@ process.stdin.on("data", (key: string) => {
203225 d.body = d.typing.trim();
204226 d.step = "options";
205227 d.typing = "";
206 } else if (d.typing.trim() === "") submit(d);
207 else if (d.options.length >= OPTIONS_MAX)
228 } else if (d.step === "rule") {
229 d.rule = d.typing.trim();
230 submit(d);
231 } else if (d.typing.trim() === "") {
232 d.step = "rule";
233 d.typing = "";
234 } else if (d.options.length >= OPTIONS_MAX)
208235 said = `A proposal has at most ${OPTIONS_MAX} options; press Enter on a blank line.`;
209236 else {
210237 d.options.push(d.typing.trim());
@@ -237,7 +264,14 @@ process.stdin.on("data", (key: string) => {
237264 if (me.role === "anon")
238265 said = "Sign in through the forge to make a proposal.";
239266 else
240 draft = { step: "title", title: "", body: "", options: [], typing: "" };
267 draft = {
268 step: "title",
269 title: "",
270 body: "",
271 options: [],
272 rule: "",
273 typing: "",
274 };
241275 }
242276 if (key === "w") withdraw(under);
243277 draw();
static/app.css +15
@@ -139,6 +139,16 @@ code,
139139 font-size: var(--text-sm);
140140 font-weight: 600;
141141 }
142 .field > span.muted {
143 font-weight: 400;
144 }
145 /* The names for `named:` matter only to that rule. Hidden when another kind
146 is chosen — and shown, plainly labelled, wherever :has is not understood,
147 so the field is never unreachable. */
148 .composer:has(select[name="rule"] option:checked:not([value="named"]))
149 .named-field {
150 display: none;
151 }
142152 .small {
143153 font-size: var(--text-sm);
144154 }
@@ -190,6 +200,11 @@ code,
190200 white-space: pre-wrap;
191201 overflow-wrap: anywhere;
192202 }
203 .rule {
204 margin-bottom: var(--s3);
205 color: var(--muted);
206 overflow-wrap: anywhere;
207 }
193208 .options {
194209 margin: 0 0 var(--s4);
195210 padding-left: 1.4rem;

↑↓ navigate ↵ open ⌘↵ its app esc close

Keyboard shortcuts

Docs