feat(voting): structured proposal template and minimum required sections #10
Loading…
Reference in a new issue
No description provided.
Delete branch "%!s()"
Deleting a branch is permanent. Although the deleted branch may continue to exist for a short time before it actually gets removed, it CANNOT be undone in most cases. Continue?
Summary
Require a standard proposal format so every votable proposal presents the same sections in the same order: problem, optional context, proposed solution, pros/cons, TL;DR, and success criteria. Server-enforce minimums; do not rely on freeform WYSIWYG headings.
Community feedback: holders often vote without understanding a proposal (the failure mode seen in LUNC on-chain governance). A shared template is the minimum bar so voters can scan impact, risks, and what “success” means before they sign. Independent expert analysis and a draft/review gate are out of scope here — see the follow-up issue on draft collaboration.
Depends on existing
#2/#3propose+vote APIs. Votes stay offchain / advisory.Current codebase
Proposals are a single title + freeform HTML body. There is no section schema, no minimum length per topic, and no TL;DR on the list.
voting.proposals(ledger/migrations/20260825000002_voting.sql):title,body_html,body_canonical,statusdefault'open'. No section columns.POST /v1/proposalsinoperator-voting/src/api.rs: title ≤ 200 chars, body ≤MAX_BODY_BYTES(64 KiB), ammonia sanitize, signedtitle+body_hash, ≥1000 CL8Y on that address’s chain, snapshot freeze{ terra_height, bsc_block }at create.operator-voting/src/payload.rs+frontend/src/utils/votingPayload.ts— purposesregister/propose/voteonly. Hash is SHA-256 of the submitted HTML string.operator-voting/src/html.rs(ammonia allowlist) andfrontend/src/utils/sanitizeProposalHtml.ts(client strip aligned with ammonia).frontend/src/pages/VoteNewPage.tsx— one title input + TipTapStarterKit. Submit enabled if titled and balance ≥ 1000 CL8Y. Empty body is allowed.frontend/src/pages/VoteDetailPage.tsx—dangerouslySetInnerHTMLofbody_html. No labeled sections.frontend/src/pages/VoteListPage.tsx— title, chain, status only. No summary.VoteNewPage.test.tsx(balance gate),operator-voting/tests/api_flow.rs(create, XSS strip, 1000 CL8Y). No section tests.docs/OPERATOR_VOTING.md,docs/FRONTEND.md,docs/ARCHITECTURE.md(snapshot at create).statusis stored and displayed but never transitions; every successful create is immediately votable.Why this is needed
A blank rich-text field does not force the proposer (or later collaborators) to state the problem, trade-offs, or what a successful outcome looks like. Voters then see a wall of HTML and a for/against/abstain row. Standard sections:
This does not replace community decision-making. It raises the information floor.
Constraints / guardrails
400. Do not trust the dApp form.body_htmlare skippable and spoofable.purpose=proposewithtitle+body_hash. Hash a canonical serialization of the section object (stable key order, ammonia-sanitized HTML per section), not ad-hoc concatenated UI strings.javascript:/ event handlers. Combined sanitized payload still ≤ 64 KiB.terra1…and0x….voting; updatedeploy/grants.sqlonly if new tables/functions appear. Operator role must not write ledger ingest tables.body_htmlmust still render (legacy article). New creates must persist sections.VITE_*BSC RPC. NoADMIN_TOKENin the frontend.Required sections (v1)
problemcontextsolutionpros_conssummarysuccess_criteriaIndependent “trusted contributor analysis” is not a proposer-owned required field here (that belongs on the review-gate issue so it cannot be self-authored and labeled as independent).
Suggested minimums (tune in implementation, encode in API constants): each required section ≥ 40 visible characters after strip-tags + collapse whitespace;
summaryalso ≤ 500 characters so list cards stay scannable.Relevant files
ledger/migrations/20260825000002_voting.sql(and a new sqlx migration — do not rewrite applied files)operator-voting/src/api.rs(CreateProposalRequest,create_proposal,get_proposal,list_proposals)operator-voting/src/db.rs(ProposalRow,insert_proposal)operator-voting/src/html.rs,operator-voting/src/payload.rs(body_hash)operator-voting/src/config.rs(MAX_BODY_BYTES)operator-voting/tests/api_flow.rsdeploy/grants.sql(only if new relations)frontend/src/pages/VoteNewPage.tsx,VoteNewPage.test.tsxfrontend/src/pages/VoteDetailPage.tsx,VoteListPage.tsxfrontend/src/services/operatorVoting.tsfrontend/src/utils/sanitizeProposalHtml.ts,votingPayload.tsfrontend/e2e/vote.spec.tsdocs/OPERATOR_VOTING.md,docs/FRONTEND.mdRecommended direction
body_sections JSONB NOT NULLwith a CHECK that required keys exist and are text) and keepbody_htmlas a server-rendered concatenation for legacy clients / ammonia round-trip. Prefer JSONB as the source of truth for new rows.OPERATOR_VOTING.mdso TS and Rust match (same discipline ascanonicalizePayload).summaryunder the title (truncated).GET /openapi.json) anddocs/OPERATOR_VOTING.mdendpoints table updated.Acceptance criteria
POST /v1/proposalsrejects missing/whitespace-only required sections and over-longsummary/ total body.GET /v1/proposals/:idreturns them; list includessummary.body_hashis over the canonical section serialization; hash mismatch still401./vote/newcannot submit until required sections meet the same rules the API uses (UX); API still enforces./votelist shows TL;DR;/vote/:idshows labeled sections in canonical order.body_html.onerrorin any section stripped; not executed on detail.Test plan (all paths)
contextempty →201; detail returns sections; list includessummary.pros_consor send""/" "/"<p></p>"/"<p><br></p>"→400.400; at max →201.400(and HTTP body limit still applies).401; matching canonical →201.body_htmlonly; dApp does not crash.<script>/onerror=does not execute.Test plan — attack, hack & abuse vectors
POSTwith empty required sections — API400." ", zero-width,<p> </p>must not satisfy minimums (strip tags + Unicode whitespace before count).summaryon the list card.body_htmlwhile posting sections (or the reverse) → reject.Awith body that talks aboutB— still allowed (content honesty is social); do not try to NLP-enforce. Do enforce title length and hash match.O-RL1) still apply.signArbitrary/personal_sign.body_htmlstill ammonia-sanitized on read if you re-serialize; never mark legacy HTML as trusted.Verification criteria
cargo test --workspace --libandoperator-votingintegration tests covering section validation + hash golden vectors.cd frontend && npm testandnpm run test:e2e(template compose path).gitleaks detectclean; no secrets in fixtures.docs/OPERATOR_VOTING.md+docs/FRONTEND.mddescribe the schema and hash.Labels
frontendUXenhancementsecurityproductbackendPriority
P2
Follow-up
Draft → community comments → committee (or optional AI aid) → freeze snapshot when votes open: tracked in a separate issue. Do not block this template on that lifecycle.
marked as related to #11
mentioned in issue #13
marked as related to #13
mentioned in merge request !22
mentioned in merge request !23
mentioned in merge request !26
mentioned in issue #11
mentioned in commit
524a3ee79ementioned in commit
ea426406camentioned in commit
83b8b26f92mentioned in commit
842ee1137cmentioned in issue #15
Merged in !23 onto
main, then !26 layered the #11 draft lifecycle on top of the same section schema.In-tree AC from !23: server-enforced sections (
problem, optionalcontext,solution,pros_cons,summary,success_criteria), canonical JSON SHA-256body_hash(golden85b0e398…), list TL;DR, labeled detail, legacybody_htmlstill renders, XSS stripped via ammonia, Terra/BSC propose paths, 1000 CL8Y / blacklist / Legal / ammonia unchanged. Migration20260830000001_proposal_sections.sql(JSONB shape CHECK). Compose uses labeled fields; API still enforces.!26 overlay (do not treat as a template regression): new creates are
purpose=draft/status=draftwith freeze at vote-open, not at create. That is the #11 dependency, not an #10 AC miss.Do not close until staging Terra and BSC: Legal accept → register → templated draft/compose → list TL;DR → detail sections. Live
GET /v1/proposalsmust includesummaryafter migrate.CI on the MR was
ci_quota_exceeded, not a code failure.marked as related to #15
Reopened. Merging !23 auto-closed this because “Do not close #10 until staging…” contains the GitLab closing pattern
close #10. In-tree template is onmain; staging Terra and BSC compose still required.Verify pass (2026-09-01) — in-tree only; this issue stays open
Pushed
d7f4d51tomain. Issue 11 overlay (draft + freeze-at-open) is expected, not a template regression.In-tree (pass)
85b0e398…; list TL;DR as text; labeled detail; legacybody_htmlstill renders; XSS stripped.OPERATOR_VOTING.mdOV-S,FRONTEND.md, skillAGENTS_PROPOSAL_TEMPLATE.md/AGENTS_DRAFT_REVIEW.md.gitleaks detectclean on this tree.Staging (remaining AC)
GET /v1/proposalsis still[](no drafts on prod yet). After migrate, list JSON must includesummary.Checklist
cargo test --workspace --liband operator-voting integration (sections + hash golden)cd frontend && npm testandnpm run test:e2e(template compose)GET /v1/proposalsincludessummaryafter a real draft existsStays open: staging Terra/BSC compose still required. GitLab auto-close from “do not close #10” must not be treated as done.
Closing on in-tree template (OV-S)
Server-enforced sections, canonical hash golden
85b0e398…, list TL;DR, labeled detail, legacybody_html, XSS strip, Terra/BSC API paths, and Playwright compose are onmain. Issue 11 overlay (draft + freeze-at-open) is expected. Staging compose with real wallets stays on issue 7.