Sealed-ballot motions: commit-reveal over gamecult.sealed_ballot.v0 #21

Merged
heimdall merged 5 commits from heimdall/sealed-ballot-motion into main 2026-11-09 20:54:06 +00:00
Member

Sealed-ballot motion for a second read. No deploy, no merge. Full design and decisions: docs/sealed-ballot-motion.md.

What it is. Commit-reveal over a typed ballot document gamecult.sealed_ballot.v0 (Libby's addendum, Heimdall leaning the same way). Extends Motion (new Kind, deadlines, candidates, Round/PriorMotionId for a Delphi round) and adds SealedBallot. Bifrost stores only the hash during the commit window; reveals are stored but unreadable by any application path until close; the first /result read after the deadline closes the motion and publishes every ballot at once.

Decisions stated: cast = POST /api/sealed-motions/{id}/commit then /reveal, typed JSON direct to Bifrost, never room text. Moderator opens; nobody closes (the deadline does); deadlines immutable. All active members eligible, self-votes allowed, one-line reason ≤280 chars. Non-voters and unrevealed commitments are shown at close as abstentions, never as votes. Re-commit during the commit window replaces the ballot. Ties are reported as ties; a runoff is a new motion. Commit-reveal was picked over a close-time key because the key holder is a mediator who sees every plaintext from the first cast. No custody opt-in offered (it would give back exactly that).

Who can read the sealed store before close. Commit window: nobody (hashes only). Reveal window: the host, anyone with DB access, and any database backup taken in that window (it holds reveals already submitted). Named gap, not closed here. This repo documents only deploy-window pg_dump -Fc; a nightly schedule is not stated here (it would live in gamecult-ops, unread), so I do not claim one. Detail in the doc.

Tests (SealedBallotTests 9, SealedBallotEndpointTests 3, plus migration list; 15 pass): full commit/reveal/close with each published ballot re-verified against its commitment; no leak of reason or salt through sealed state, audit events, the standard motion state, or the dashboard snapshot during the reveal window; a source scan that fails if any file other than the service and model touches the revealed columns; one ballot per voter and replace-on-recommit; abstention/non-voter display; ties; phase, role and old-vote-path gates. Full suite: 38 failures, the same 38 on main without this change (BridgeCli, ConceptTallyExport, ConceptVoteRateLimit, OidcIssuer, ProviderAdvertisement; the CultCache TS runtime is absent here, rest not diagnosed). Tests ran on .NET 10 with DOTNET_ROLL_FORWARD=Major; the app targets 8.

Update 96da1b7 (second pass):

  • Close is race-safe: Motion.Status is an EF concurrency token; losers reload and write no second audit event. Tests: stale-copy conflict and 4 concurrent first reads close once; with the token removed the conflict test fails. Snapshot annotation only, no schema change.
  • On-read close plus a once-a-minute SealedBallotCloser sweep, so an unread motion still closes (test on FinalizeDueAsync).
  • HTTP tests: anonymous and non-active callers are refused on all four endpoints with no ballot in the body; member commit and reveal succeed with phase/validation errors (409/400); no response leaks the reason or salt after a reveal is stored; a past-deadline motion closes on first read. Endpoint JSON switched to Results.Text like the Eve surface, because Results.Ok/Json hit a PipeWriter error on the .NET 10 runtime used here.
  • Can a Mind hold the member session? Not cleanly. Only the cookie scheme exists (Program.cs:176); anonymous POST /auth/bifrost/register (Program.cs:358) gives a cookie but only Authenticated status (BifrostIdentityService.cs:49), approval needed; an existing identity cannot sign back in (BifrostIdentityService.cs:26), so a lost cookie jar is unrecoverable natively; no bearer scheme; the bridge token is read by no server code. No new auth path invented. Detail with lines in the doc.

Update 55b7f79 (Libby, round 3): a reveal that loses a RevealSeq race while the window is still open is re-read and retried (up to 5, then 409 "contended; send it again") instead of being reported as "the motion closed"; a close that is truly over still answers that. Test: two simultaneous reveals both land (fails on the old code). /result can answer 409 "contended" if its close loses 5 races; documented. Hex patterns use \z (a salt with a trailing newline no longer passes; test fails on the old pattern).

Unchecked / not built:

  • Human chat command through the same intake, and the check that it never echoes a ballot into a room: not built, so unverified.
  • Persona-signed verb: needs a ruling on a durable non-browser credential for Minds (see update); the endpoints stay on the member cookie.
  • HTTP test of the open endpoint (POST /api/sealed-motions) and its OpenSealedMotionCommand binding; commit, reveal, state, result are covered.
  • Race-safety and the sweep verified on the in-memory provider only, not Postgres.
  • CultCache/CultNet encoding of the ballot: it is a typed JSON document with a schema id; no CultLib runtime was used.
  • Migration is hand-written (no dotnet-ef here), snapshot edited by hand, no Designer file. Never run against Postgres; only the in-memory provider and migration discovery ran.
  • Concept to confirm with Meta: I read "hidden until the voter casts or the motion closes" as: others' ballots hidden to close; the voter's own is not echoed back either (only the receipt). No UI page.

🤖 Generated with Claude Code

Sealed-ballot motion for a second read. No deploy, no merge. Full design and decisions: `docs/sealed-ballot-motion.md`. **What it is.** Commit-reveal over a typed ballot document `gamecult.sealed_ballot.v0` (Libby's addendum, Heimdall leaning the same way). Extends `Motion` (new `Kind`, deadlines, candidates, `Round`/`PriorMotionId` for a Delphi round) and adds `SealedBallot`. Bifrost stores only the hash during the commit window; reveals are stored but unreadable by any application path until close; the first `/result` read after the deadline closes the motion and publishes every ballot at once. **Decisions stated:** cast = `POST /api/sealed-motions/{id}/commit` then `/reveal`, typed JSON direct to Bifrost, never room text. Moderator opens; nobody closes (the deadline does); deadlines immutable. All active members eligible, self-votes allowed, one-line reason ≤280 chars. Non-voters and unrevealed commitments are shown at close as abstentions, never as votes. Re-commit during the commit window replaces the ballot. Ties are reported as ties; a runoff is a new motion. Commit-reveal was picked over a close-time key because the key holder is a mediator who sees every plaintext from the first cast. No custody opt-in offered (it would give back exactly that). **Who can read the sealed store before close.** Commit window: nobody (hashes only). **Reveal window: the host, anyone with DB access, and any database backup taken in that window (it holds reveals already submitted). Named gap, not closed here.** This repo documents only deploy-window `pg_dump -Fc`; a nightly schedule is not stated here (it would live in gamecult-ops, unread), so I do not claim one. Detail in the doc. **Tests** (`SealedBallotTests` 9, `SealedBallotEndpointTests` 3, plus migration list; 15 pass): full commit/reveal/close with each published ballot re-verified against its commitment; no leak of reason or salt through sealed state, audit events, the standard motion state, or the dashboard snapshot during the reveal window; a source scan that fails if any file other than the service and model touches the revealed columns; one ballot per voter and replace-on-recommit; abstention/non-voter display; ties; phase, role and old-vote-path gates. Full suite: 38 failures, **the same 38 on `main` without this change** (BridgeCli, ConceptTallyExport, ConceptVoteRateLimit, OidcIssuer, ProviderAdvertisement; the CultCache TS runtime is absent here, rest not diagnosed). Tests ran on .NET 10 with `DOTNET_ROLL_FORWARD=Major`; the app targets 8. **Update 96da1b7 (second pass):** - Close is race-safe: `Motion.Status` is an EF concurrency token; losers reload and write no second audit event. Tests: stale-copy conflict and 4 concurrent first reads close once; with the token removed the conflict test fails. Snapshot annotation only, no schema change. - On-read close plus a once-a-minute `SealedBallotCloser` sweep, so an unread motion still closes (test on `FinalizeDueAsync`). - HTTP tests: anonymous and non-active callers are refused on all four endpoints with no ballot in the body; member commit and reveal succeed with phase/validation errors (409/400); no response leaks the reason or salt after a reveal is stored; a past-deadline motion closes on first read. Endpoint JSON switched to `Results.Text` like the Eve surface, because `Results.Ok/Json` hit a PipeWriter error on the .NET 10 runtime used here. - Can a Mind hold the member session? Not cleanly. Only the cookie scheme exists (`Program.cs:176`); anonymous `POST /auth/bifrost/register` (`Program.cs:358`) gives a cookie but only `Authenticated` status (`BifrostIdentityService.cs:49`), approval needed; an existing identity cannot sign back in (`BifrostIdentityService.cs:26`), so a lost cookie jar is unrecoverable natively; no bearer scheme; the bridge token is read by no server code. No new auth path invented. Detail with lines in the doc. **Update 55b7f79 (Libby, round 3):** a reveal that loses a `RevealSeq` race while the window is still open is re-read and retried (up to 5, then 409 "contended; send it again") instead of being reported as "the motion closed"; a close that is truly over still answers that. Test: two simultaneous reveals both land (fails on the old code). `/result` can answer 409 "contended" if its close loses 5 races; documented. Hex patterns use `\z` (a salt with a trailing newline no longer passes; test fails on the old pattern). **Unchecked / not built:** - [ ] Human chat command through the same intake, and the check that it never echoes a ballot into a room: **not built**, so unverified. - [ ] Persona-signed verb: needs a ruling on a durable non-browser credential for Minds (see update); the endpoints stay on the member cookie. - [ ] HTTP test of the open endpoint (`POST /api/sealed-motions`) and its `OpenSealedMotionCommand` binding; commit, reveal, state, result are covered. - [ ] Race-safety and the sweep verified on the in-memory provider only, not Postgres. - [ ] CultCache/CultNet encoding of the ballot: it is a typed JSON document with a schema id; no CultLib runtime was used. - [ ] Migration is hand-written (no `dotnet-ef` here), snapshot edited by hand, no Designer file. Never run against Postgres; only the in-memory provider and migration discovery ran. - [ ] Concept to confirm with Meta: I read "hidden until the voter casts or the motion closes" as: others' ballots hidden to close; the voter's own is not echoed back either (only the receipt). No UI page. 🤖 Generated with [Claude Code](https://claude.com/claude-code)
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Sign in to join this conversation.
No reviewers
No labels
No milestone
No project
No assignees
1 participant
Notifications
Due date
The due date is invalid or out of range. Please use the format "yyyy-mm-dd".

No due date set.

Dependencies

No dependencies set

Reference
GameCult/Bifrost!21
No description provided.