Cast
Consensus is not uniformity — it is aligned energy.
One relevance vote. Three weights: ±1, ±3, and ±10. Your lore rank decides how heavy your vote can be. Casting spends energy from your monthly pool. Receiving votes on your content earns energy back and moves your rank.
A topic vote leaves branch_id empty. A branch vote sets it. Stored weights are ±1, ±3, and ±10. The voter's lore rank caps the weight. Poll and proposal options accept only +1.
Rank, Received, and the tier table: Rank. Energy cost and the monthly pool: Energy. Home order: Feed. Branches: Branches.
How it works
Casting requires a signed-in session (X, GitHub, or Google). Each cast spends the vote:cast budget, 10 per 10 seconds, and the shared per-IP write budget.
flowchart TD
vote["Vote ±1 · ±3 · ±10"] --> cap["Rank cap"]
cap --> gate["Own content or closed topic"]
gate --> row["One votes row"]
row --> topic["Topic score ± delta"]
row --> branch["Branch score, when a branch is selected"]
topic --> received["Author Received"]
row --> first["First insert"]
first --> plus["+1 energy to the topic author"]
first --> hot["+130 energy if the live count hits 100 within 48h"]
The vote
| Property | Rule |
|---|---|
| Target | Topic vote (branch_id null) and any number of branch votes on that topic. One row per target. |
| Weights | ±1, ±3, ±10. The column check allows all six. The rank cap is applied in votes.cast. |
| Cap | Newbie ±1, Geek ±3, Wizard and GOAT ±10. The cap is the voter's Received net. Tiers: Rank. |
| Change | Move the weight or flip the sign. The six stored scores have no zero. The same score again does nothing: no spend, no score change. |
| Own content | Own topic, or a branch you proposed, returns FORBIDDEN (Cannot vote on your own content). |
| Closed | status other than open, or expires_at already passed, returns FORBIDDEN (Closed topics do not accept votes). |
| Branch | The branch must belong to the topic. |
| Energy | abs(next) − abs(previous). A lower weight refunds. +3 → −3 costs 0 and still stamps lastActiveAt. Details: Energy. |
The vote control shows 1, 3, and 10 for every rank. A weight above the signed-in cap stays visible and inactive. The server still rejects it (This rank can cast up to ±N). A stored weight that is now above a later, lower cap stays until that user changes it.
What a cast writes
Delta on a change is next − previous. On a first insert the delta is the score.
| Column | Update |
|---|---|
topics.relevance_score | + delta for a topic vote and for a branch vote |
branches.relevance_score | + delta only when a branch is selected |
The topic score is the signed sum of every vote on that topic, including topic votes. A sum of branch scores alone drops the topic votes. Empty topic = 0. Either score can be negative. Updates are column + delta.
Public JSON key vote is topics.relevance_score. The same number is relevance_score on topic payloads. Overlay maps (current_user_relevance_vote, current_user_branch_votes) stay on the signed-in response. They are not a public file.
Received is that same signed sum on the author's work. A vote change revises it. The public plate is {tier} {score}. Formula: Rank.
A first insert grants the topic author +1 energy (vote_received) and checks the hot bonus. A later change of the same row does neither. Creating a topic adds 0 Received. Hot +130 is energy: Energy.
votes.created_at is the insert time. A normal weight change leaves it. An exclusive option switch restamps the surviving row so the newest option is the one shown.
Poll and proposal
Options are exclusive +1, one option per user. Any other score returns BAD_REQUEST (This topic only accepts +1). The rank cap is not consulted, because +1 is legal at every tier.
Choosing the option you already hold is a no-op. Switching option deletes the extra rows, moves the surviving row onto the new branch, and sets its score to 1. Topic and branch scores move by the signed delta. Energy cost is abs(1) − abs(sum of that user's option rows). One +1 moving to another option costs 0.
A topic vote on these types is a separate row. It still uses the rank cap and the six weights.
Table votes
Live schema: packages/shared/src/schema/votes.ts. Applied in 0004_per_branch_relevance.sql and 0005_relevance_score_only.sql.
| Column | Type | Null | Meaning |
|---|---|---|---|
id | UUID | no | Primary key |
user_id | UUID | no | Voter. Cascade on user delete |
topic_id | UUID | no | Topic. Cascade on topic delete |
branch_id | UUID | yes | Null = topic vote. Cascade on branch delete |
score | integer | no | One of 1, −1, 3, −3, 10, −10 |
created_at | timestamptz | no | Insert time |
CREATE UNIQUE INDEX votes_user_branch_idx ON votes (user_id, branch_id) WHERE branch_id IS NOT NULL;
CREATE UNIQUE INDEX votes_user_topic_idx ON votes (user_id, topic_id) WHERE branch_id IS NULL;
CHECK (score IN (1, -1, 3, -3, 10, -10))
Also indexed: user_id, (user_id, created_at DESC), branch_id, topic_id.
vote_type is gone. topics.vote_count is gone. branches.vote_count was renamed to relevance_score.
Home index: (relevance_score DESC, created_at DESC) where status = 'open'. List rules: Feed.
If a stored score drifts
UPDATE branches b
SET relevance_score = COALESCE((
SELECT SUM(v.score) FROM votes v WHERE v.branch_id = b.id
), 0);
UPDATE topics t
SET relevance_score = COALESCE((
SELECT SUM(v.score) FROM votes v WHERE v.topic_id = t.id
), 0);
The topic statement sums every vote on the topic. Summing branches.relevance_score leaves topic votes out of Home.
Code
| Piece | Path |
|---|---|
| Schema | packages/shared/src/schema/votes.ts |
| Input | packages/shared/src/validators/votes.ts (castVoteInputSchema) |
| Cast | apps/frontend/src/server/routers/votes.ts |
| Vote control | apps/frontend/src/components/topic/RelevanceVote.tsx |
| Client | apps/frontend/src/hooks/use-cast-vote.ts |