The core loop is small enough for a capable coding agent to produce a useful local version in one sitting. For Nolt, publish a feedback board with voting, comments, statuses, and a roadmap. The hard boundary is hosted simplicity, custom domains, integrations, and low maintenance, plus participant network, synthesis workflow, and collaboration.
You are building a lean indie version of Nolt. Create the following project files first, then implement the application by following them. Keep the files updated as decisions change. Do not collapse this into a single README or prompt. ===== README.md ===== # Nolt · indie build A feedback board where the engineering problem is vote integrity on a public page without demanding accounts, and the design problem is that a roadmap built from raw vote counts is a popularity contest that will mislead you about what your users actually need. Estimated effort: **weekend**. Work `BUILD_PLAN.md` top to bottom · every phase ends in a check that has to pass before the next one starts. ## Stack | Part | Choice | Why | | --- | --- | --- | | Runtime | TypeScript with Fastify and PostgreSQL | boards, posts and votes are relational and small | | Identity | Optional accounts, with a durable per-browser identity for anonymous voting | requiring an account collapses submission volume, and allowing none collapses integrity | | Integrity | Rate limits, duplicate detection and vote weighting by segment | a raw count is trivially gamed and misleading even when it is not | | Merging | Duplicate posts merged with votes combined and voters deduplicated | the same request arrives ten times in ten wordings | | Status | Explicit statuses with a stated meaning, and no implied commitment | a roadmap column is read as a promise whether or not you meant one | ## Before you start Have every one of these ready. The plan assumes them from step one. - [ ] **Node.js 22 or newer** · free - Why: Runs the server and the board. - Get it: nodejs.org, or nvm install 22. - Verify: node --version prints v22 or higher - [ ] **PostgreSQL 16 or newer** · free locally - Why: Vote uniqueness is enforced by a constraint, and merging correctly needs set operations. - Get it: Docker or your package manager. - Verify: A unique constraint rejects a duplicate vote insert - [ ] **Your statuses and what each one means** · free - Why: Users read a roadmap column as a promise, and an undefined status is read optimistically. Deciding the meanings after publishing means changing what you appeared to promise. - Get it: Write one sentence per status, including explicitly what planned does not mean. Decide whether you will ever show dates. - Verify: A written meaning exists for every status and appears on the board - [ ] **How you will segment voters** · free - Why: Ranking by raw count hands your roadmap to whoever is loudest, and segmentation is only possible if you decide early what you can actually know about a voter. - Get it: Decide what distinction matters to you and what evidence you will have for it without demanding accounts. - Verify: A written note names the segments and how a voter is assigned to one - [ ] **A hundred real feedback items** · free - Why: Duplicate detection and merging only become interesting at volume, and a board with ten posts has no duplicates to find. - Get it: Export from wherever feedback currently lands, including the ten near-identical versions of the same request. - Verify: A hundred items exist including several genuine duplicates ## Quick start Follow Phase 1 in `BUILD_PLAN.md`; it creates the project. Then copy `.env.example` to `.env` and fill in the values it documents. ## Honest limits This build deliberately does not replace: - Single sign-on with your product. - Moderation at scale. - Multi-language boards. - Deep issue tracker integrations. - hosted simplicity, custom domains, integrations, and low maintenance - large participant panel - recording and transcription infrastructure - advanced research repository - enterprise governance If one of those is essential to you, that is the reason to keep paying for Nolt, and the README should say so rather than pretend. ===== BRIEF.md ===== # Build brief · Nolt The one-shot brief this plan expands. `BUILD_PLAN.md` (or `MILESTONES.md`) is the same sequence broken into steps and checks; where the two disagree, the plan wins. Build me a public feedback board with voting, where the votes mean something to replace Nolt. Build it in phases, in the order below. Do not write the whole thing in one pass. Finish a phase, run its "Done when" check, fix what fails, and only then start the next phase. ### Stack (fixed, do not substitute) - Runtime: TypeScript with Fastify and PostgreSQL · boards, posts and votes are relational and small - Identity: Optional accounts, with a durable per-browser identity for anonymous voting · requiring an account collapses submission volume, and allowing none collapses integrity - Integrity: Rate limits, duplicate detection and vote weighting by segment · a raw count is trivially gamed and misleading even when it is not - Merging: Duplicate posts merged with votes combined and voters deduplicated · the same request arrives ten times in ten wordings - Status: Explicit statuses with a stated meaning, and no implied commitment · a roadmap column is read as a promise whether or not you meant one ### Data model (create this before Phase 1) - boards(id, slug, title, visibility, allow_anonymous, created_at) - posts(id, board_id, title, body, author_id, status, created_at, merged_into_id) - votes(post_id, voter_id, weight, at) · one row per voter per post, enforced by a constraint - voters(id, kind, account_id, fingerprint_hash, first_seen_at, segment) · kind is account or anonymous - merges(id, kept_post_id, merged_post_id, votes_combined, at) - comments(id, post_id, voter_id, body, at) - status_changes(post_id, from_status, to_status, note, at) · append only, because a status that quietly reverts is worse than one that never moved ### Phase 1 · A board people will actually post on Build: A public board with post submission, requiring nothing but the request itself. Assign a durable anonymous identity for later voting. Offer an optional account for people who want to be notified. Trap: Requiring an account before someone can post removes most of the feedback you would have received, and the people who do sign up are not a representative sample of your users. Let them post immediately, and offer the account for the thing accounts are actually good for, which is being told what happened. Done when: A post is submitted in under a minute without an account, timed by hand. An anonymous voter identity persists across a browser restart. An optional account can be created later and claims the anonymous identity's votes. A submission with no content is rejected with a useful message rather than a validation error. Do not build yet: No voting integrity, merging or statuses. ### Phase 2 · Votes that are hard to fake Build: One vote per voter per post, enforced in the database. Rate limit by network and by identity. Detect clusters of votes arriving together from one source and flag them rather than silently discarding. Trap: Silently discarding suspected fraudulent votes means you cannot tell the difference between an attack and a legitimate burst from a customer who told their team to go and vote, and the second is exactly the signal you wanted. Flag and quarantine for review rather than deleting, so the distinction stays available. Done when: A duplicate vote is impossible, enforced by a database constraint rather than application logic. Rate limits apply by network and identity. A burst of votes from one source is flagged and quarantined rather than deleted, with counts shown both including and excluding quarantined votes. Clearing browser storage does not grant a second vote from the same source. Do not build yet: No merging or statuses yet. ### Phase 3 · The same request, ten times over Build: Suggest likely duplicates on submission and to the operator afterwards. Merge posts, combining votes and deduplicating voters who voted for both. Keep the merged post reachable so its author can see where it went. Trap: Merging by adding the vote counts double-counts everyone who voted for both posts, which inflates the merged total and moves a request up the board on votes that do not exist. Combine the voter sets and count the union. Done when: Merging two posts produces a vote count equal to the union of their voters, not the sum, asserted in a test with overlapping voters. The merged post remains reachable and points at the surviving one. Likely duplicates are suggested at submission time. A merge can be undone, restoring both posts and their original votes. Do not build yet: No statuses or roadmap yet. ### Phase 4 · Statuses that do not promise things Build: Explicit statuses with a written meaning shown on the board. Append-only status history so a reversal is visible rather than silent. No dates unless you enter one deliberately, and a stale status surfaced to the operator. Trap: A planned column with no dates and no movement for eight months is read by users as a broken promise, and by the operator as an accurate representation of intent, and both are behaving reasonably. Show how long an item has held its status, and surface the stale ones so the board reflects what is actually happening. Done when: Every status has a written meaning visible on the board. Status history is append only and a reversal is visible rather than silent. An item held in one status past a threshold is surfaced to the operator. No date appears anywhere unless deliberately entered. Do not build yet: No notifications or segmentation yet. ### Phase 5 · Reading the board honestly Build: Segment voters by whatever you actually know, such as customer or not, and show vote counts by segment alongside the raw total. Notify voters on status change. Export everything. Trap: Ranking by raw vote count means the loudest audience decides the roadmap, which is frequently not the paying one and almost never the silent majority. Show the breakdown by segment beside the total, so a request with forty votes from non-customers and one from your largest account is legible as what it is. Done when: Vote counts display by segment alongside the total. Notifying voters of a status change works and respects an unsubscribe. Export contains posts, votes, merges and status history. A board with a thousand posts and ten thousand votes loads and sorts quickly. Do not build yet: Nothing after this. ### Out of scope (and why) - Single sign-on with your product so voters are automatically identified, which is what makes segmentation genuinely useful and is an integration per product. - Moderation at scale, which becomes a real job the moment a board is public and popular. - Multi-language boards, where machine translation of feature requests loses precisely the nuance that matters. - Deep integrations with issue trackers, which is the part the hosted products sell. ### README must contain - That posting requires no account, because the friction costs you the feedback - That one vote per voter is enforced by a database constraint rather than application logic - That suspicious bursts are quarantined and visible rather than silently deleted - That merging counts the union of voters rather than adding totals - That vote counts are shown by segment because a raw total is a popularity contest ===== AGENTS.md ===== # Agent instructions · Nolt indie build - Read `README.md` and `BUILD_PLAN.md` before writing code. The stack is fixed: TypeScript with Fastify and PostgreSQL, Optional accounts, with a durable per-browser identity for anonymous voting, Rate limits, duplicate detection and vote weighting by segment, Duplicate posts merged with votes combined and voters deduplicated, Explicit statuses with a stated meaning, and no implied commitment. Do not substitute. - Work one phase at a time, in order. Do not start a phase until every "Done when" item of the previous one passes. - Prefer the fewest moving parts that satisfy the step. No frameworks, services or dependencies the plan does not name. - Secrets live in `.env`, never in source or logs. Keep `.env.example` current when a variable is introduced. - Do not invent cryptography, security guarantees, APIs or compliance claims. - Add a focused test for every destructive, security-sensitive or data-loss path the plan names. - Run the project checks before declaring a phase complete, and record any deliberate shortcut in the README under "Tradeoffs". ## Known traps - Requiring an account before someone can post removes most of the feedback you would have received, and the people who do sign up are not a representative sample of your users. Let them post immediately, and offer the account for the thing accounts are actually good for, which is being told what happened. - Silently discarding suspected fraudulent votes means you cannot tell the difference between an attack and a legitimate burst from a customer who told their team to go and vote, and the second is exactly the signal you wanted. Flag and quarantine for review rather than deleting, so the distinction stays available. - Merging by adding the vote counts double-counts everyone who voted for both posts, which inflates the merged total and moves a request up the board on votes that do not exist. Combine the voter sets and count the union. - A planned column with no dates and no movement for eight months is read by users as a broken promise, and by the operator as an accurate representation of intent, and both are behaving reasonably. Show how long an item has held its status, and surface the stale ones so the board reflects what is actually happening. ===== BUILD_PLAN.md ===== # Build plan · Nolt A feedback board where the engineering problem is vote integrity on a public page without demanding accounts, and the design problem is that a roadmap built from raw vote counts is a popularity contest that will mislead you about what your users actually need. Phases are in dependency order. Each ends in a "Done when" list; treat an unticked item as a blocker, not a note. ## Phase 1 · A board people will actually post on Someone can submit a request in under a minute without creating an account. ### Steps 1. Allow posting with no account The friction is what you are trading feedback volume against. Files: `src/posts.ts` 2. Assign a durable anonymous identity Persisting across restarts, so voting can be one per person. Files: `src/voters.ts` 3. Let an account claim prior activity Someone signing up later should not lose their votes. Files: `src/claim.ts` ### Done when - [ ] A post is submitted in under a minute with no account - [ ] Anonymous identity survives a browser restart - [ ] An account claims prior anonymous votes - [ ] Empty submissions are rejected usefully ### Watch out - Requiring an account before someone can post removes most of the feedback you would have received, and the people who do sign up are not a representative sample of your users. Let them post immediately, and offer the account for the thing accounts are actually good for, which is being told what happened. ## Phase 2 · Votes that are hard to fake Voting is easy for a person and tedious for a script. ### Steps 1. Enforce one vote per voter in the schema A unique constraint, not an application check. Files: `src/schema.sql` 2. Rate limit by network and identity Both, since either alone is trivially avoided. Files: `src/ratelimit.ts` 3. Quarantine rather than delete A burst may be an attack or a customer's whole team. Keep the distinction. Files: `src/integrity.ts` ### Done when - [ ] Duplicate votes are impossible at the database level - [ ] Rate limits apply by network and identity - [ ] Suspicious bursts are quarantined and visible - [ ] Clearing storage does not grant a second vote ### Watch out - Silently discarding suspected fraudulent votes means you cannot tell the difference between an attack and a legitimate burst from a customer who told their team to go and vote, and the second is exactly the signal you wanted. Flag and quarantine for review rather than deleting, so the distinction stays available. ## Phase 3 · The same request, ten times over Duplicates are found and merged without losing anyone's vote. ### Steps 1. Count the union of voters on merge Adding the totals double-counts anyone who voted for both. Files: `src/merge.ts`, `src/merge.test.ts` 2. Suggest duplicates at submission The cheapest moment to prevent one is before it is written. Files: `src/duplicates.ts` 3. Keep merged posts reachable Their author should find out where their request went. Files: `src/posts.ts` ### Done when - [ ] A merge counts the union of voters - [ ] The merged post stays reachable - [ ] Duplicates are suggested at submission - [ ] A merge can be undone completely ### Watch out - Merging by adding the vote counts double-counts everyone who voted for both posts, which inflates the merged total and moves a request up the board on votes that do not exist. Combine the voter sets and count the union. ## Phase 4 · Statuses that do not promise things A roadmap communicates intent without being read as a commitment. ### Steps 1. Publish what each status means On the board itself. Users infer meanings otherwise, usually optimistic ones. Files: `src/statuses.ts` 2. Keep status history append only A silent reversal from planned to open is worse than never planning it. Files: `src/status-changes.ts` 3. Surface stale items Eight months in planned is information, and mostly for you. Files: `src/staleness.ts` ### Done when - [ ] Each status has a published meaning - [ ] Status history is append only and reversals visible - [ ] Stale items are surfaced to the operator - [ ] No dates appear unless deliberately entered ### Watch out - A planned column with no dates and no movement for eight months is read by users as a broken promise, and by the operator as an accurate representation of intent, and both are behaving reasonably. Show how long an item has held its status, and surface the stale ones so the board reflects what is actually happening. ## Not in this build - Single sign-on with your product. - Moderation at scale. - Multi-language boards. - Deep issue tracker integrations. ## After v1, if you want it - Private boards for a specific customer, which changes the segmentation question - Linking a post to what actually shipped, which is what closes the loop - A digest of what moved this month, which is the notification people want most ===== .env.example ===== # Copy to .env and fill in. Never commit .env; this file documents it. # Required · secret. Posts, votes and voter identities. DATABASE_URL=postgres://localhost:5432/feedback # Required. Requiring accounts collapses submission volume. This is the trade, made explicit. ALLOW_ANONYMOUS_POSTS=true # Required. Per identity and per network. Generous for a person, tedious for a script. VOTE_RATE_LIMIT_PER_HOUR=20 # Required. Votes from one source in a short window before quarantine. Quarantine, not deletion. BURST_QUARANTINE_THRESHOLD=10 # Required. How long an item may hold a status before being surfaced to you as stale. STALE_STATUS_DAYS=120 # Optional · secret. Status change notifications. Absent means the board works without them. SMTP_URL=smtp://user:pass@host:587
You are building a lean indie version of Nolt. Create the following project files first, then implement the application by following them. Keep the files updated as decisions change. Do not collapse this into a single README or prompt. ===== README.md ===== # Nolt · indie build A feedback board where the engineering problem is vote integrity on a public page without demanding accounts, and the design problem is that a roadmap built from raw vote counts is a popularity contest that will mislead you about what your users actually need. Estimated effort: **weekend**. Work `BUILD_PLAN.md` top to bottom · every phase ends in a check that has to pass before the next one starts. ## Stack | Part | Choice | Why | | --- | --- | --- | | Runtime | TypeScript with Fastify and PostgreSQL | boards, posts and votes are relational and small | | Identity | Optional accounts, with a durable per-browser identity for anonymous voting | requiring an account collapses submission volume, and allowing none collapses integrity | | Integrity | Rate limits, duplicate detection and vote weighting by segment | a raw count is trivially gamed and misleading even when it is not | | Merging | Duplicate posts merged with votes combined and voters deduplicated | the same request arrives ten times in ten wordings | | Status | Explicit statuses with a stated meaning, and no implied commitment | a roadmap column is read as a promise whether or not you meant one | ## Before you start Have every one of these ready. The plan assumes them from step one. - [ ] **Node.js 22 or newer** · free - Why: Runs the server and the board. - Get it: nodejs.org, or nvm install 22. - Verify: node --version prints v22 or higher - [ ] **PostgreSQL 16 or newer** · free locally - Why: Vote uniqueness is enforced by a constraint, and merging correctly needs set operations. - Get it: Docker or your package manager. - Verify: A unique constraint rejects a duplicate vote insert - [ ] **Your statuses and what each one means** · free - Why: Users read a roadmap column as a promise, and an undefined status is read optimistically. Deciding the meanings after publishing means changing what you appeared to promise. - Get it: Write one sentence per status, including explicitly what planned does not mean. Decide whether you will ever show dates. - Verify: A written meaning exists for every status and appears on the board - [ ] **How you will segment voters** · free - Why: Ranking by raw count hands your roadmap to whoever is loudest, and segmentation is only possible if you decide early what you can actually know about a voter. - Get it: Decide what distinction matters to you and what evidence you will have for it without demanding accounts. - Verify: A written note names the segments and how a voter is assigned to one - [ ] **A hundred real feedback items** · free - Why: Duplicate detection and merging only become interesting at volume, and a board with ten posts has no duplicates to find. - Get it: Export from wherever feedback currently lands, including the ten near-identical versions of the same request. - Verify: A hundred items exist including several genuine duplicates ## Quick start Follow Phase 1 in `BUILD_PLAN.md`; it creates the project. Then copy `.env.example` to `.env` and fill in the values it documents. ## Honest limits This build deliberately does not replace: - Single sign-on with your product. - Moderation at scale. - Multi-language boards. - Deep issue tracker integrations. - hosted simplicity, custom domains, integrations, and low maintenance - large participant panel - recording and transcription infrastructure - advanced research repository - enterprise governance If one of those is essential to you, that is the reason to keep paying for Nolt, and the README should say so rather than pretend. ===== BRIEF.md ===== # Build brief · Nolt The one-shot brief this plan expands. `BUILD_PLAN.md` (or `MILESTONES.md`) is the same sequence broken into steps and checks; where the two disagree, the plan wins. Build me a public feedback board with voting, where the votes mean something to replace Nolt. Build it in phases, in the order below. Do not write the whole thing in one pass. Finish a phase, run its "Done when" check, fix what fails, and only then start the next phase. ### Stack (fixed, do not substitute) - Runtime: TypeScript with Fastify and PostgreSQL · boards, posts and votes are relational and small - Identity: Optional accounts, with a durable per-browser identity for anonymous voting · requiring an account collapses submission volume, and allowing none collapses integrity - Integrity: Rate limits, duplicate detection and vote weighting by segment · a raw count is trivially gamed and misleading even when it is not - Merging: Duplicate posts merged with votes combined and voters deduplicated · the same request arrives ten times in ten wordings - Status: Explicit statuses with a stated meaning, and no implied commitment · a roadmap column is read as a promise whether or not you meant one ### Data model (create this before Phase 1) - boards(id, slug, title, visibility, allow_anonymous, created_at) - posts(id, board_id, title, body, author_id, status, created_at, merged_into_id) - votes(post_id, voter_id, weight, at) · one row per voter per post, enforced by a constraint - voters(id, kind, account_id, fingerprint_hash, first_seen_at, segment) · kind is account or anonymous - merges(id, kept_post_id, merged_post_id, votes_combined, at) - comments(id, post_id, voter_id, body, at) - status_changes(post_id, from_status, to_status, note, at) · append only, because a status that quietly reverts is worse than one that never moved ### Phase 1 · A board people will actually post on Build: A public board with post submission, requiring nothing but the request itself. Assign a durable anonymous identity for later voting. Offer an optional account for people who want to be notified. Trap: Requiring an account before someone can post removes most of the feedback you would have received, and the people who do sign up are not a representative sample of your users. Let them post immediately, and offer the account for the thing accounts are actually good for, which is being told what happened. Done when: A post is submitted in under a minute without an account, timed by hand. An anonymous voter identity persists across a browser restart. An optional account can be created later and claims the anonymous identity's votes. A submission with no content is rejected with a useful message rather than a validation error. Do not build yet: No voting integrity, merging or statuses. ### Phase 2 · Votes that are hard to fake Build: One vote per voter per post, enforced in the database. Rate limit by network and by identity. Detect clusters of votes arriving together from one source and flag them rather than silently discarding. Trap: Silently discarding suspected fraudulent votes means you cannot tell the difference between an attack and a legitimate burst from a customer who told their team to go and vote, and the second is exactly the signal you wanted. Flag and quarantine for review rather than deleting, so the distinction stays available. Done when: A duplicate vote is impossible, enforced by a database constraint rather than application logic. Rate limits apply by network and identity. A burst of votes from one source is flagged and quarantined rather than deleted, with counts shown both including and excluding quarantined votes. Clearing browser storage does not grant a second vote from the same source. Do not build yet: No merging or statuses yet. ### Phase 3 · The same request, ten times over Build: Suggest likely duplicates on submission and to the operator afterwards. Merge posts, combining votes and deduplicating voters who voted for both. Keep the merged post reachable so its author can see where it went. Trap: Merging by adding the vote counts double-counts everyone who voted for both posts, which inflates the merged total and moves a request up the board on votes that do not exist. Combine the voter sets and count the union. Done when: Merging two posts produces a vote count equal to the union of their voters, not the sum, asserted in a test with overlapping voters. The merged post remains reachable and points at the surviving one. Likely duplicates are suggested at submission time. A merge can be undone, restoring both posts and their original votes. Do not build yet: No statuses or roadmap yet. ### Phase 4 · Statuses that do not promise things Build: Explicit statuses with a written meaning shown on the board. Append-only status history so a reversal is visible rather than silent. No dates unless you enter one deliberately, and a stale status surfaced to the operator. Trap: A planned column with no dates and no movement for eight months is read by users as a broken promise, and by the operator as an accurate representation of intent, and both are behaving reasonably. Show how long an item has held its status, and surface the stale ones so the board reflects what is actually happening. Done when: Every status has a written meaning visible on the board. Status history is append only and a reversal is visible rather than silent. An item held in one status past a threshold is surfaced to the operator. No date appears anywhere unless deliberately entered. Do not build yet: No notifications or segmentation yet. ### Phase 5 · Reading the board honestly Build: Segment voters by whatever you actually know, such as customer or not, and show vote counts by segment alongside the raw total. Notify voters on status change. Export everything. Trap: Ranking by raw vote count means the loudest audience decides the roadmap, which is frequently not the paying one and almost never the silent majority. Show the breakdown by segment beside the total, so a request with forty votes from non-customers and one from your largest account is legible as what it is. Done when: Vote counts display by segment alongside the total. Notifying voters of a status change works and respects an unsubscribe. Export contains posts, votes, merges and status history. A board with a thousand posts and ten thousand votes loads and sorts quickly. Do not build yet: Nothing after this. ### Out of scope (and why) - Single sign-on with your product so voters are automatically identified, which is what makes segmentation genuinely useful and is an integration per product. - Moderation at scale, which becomes a real job the moment a board is public and popular. - Multi-language boards, where machine translation of feature requests loses precisely the nuance that matters. - Deep integrations with issue trackers, which is the part the hosted products sell. ### README must contain - That posting requires no account, because the friction costs you the feedback - That one vote per voter is enforced by a database constraint rather than application logic - That suspicious bursts are quarantined and visible rather than silently deleted - That merging counts the union of voters rather than adding totals - That vote counts are shown by segment because a raw total is a popularity contest ===== AGENTS.md ===== # Agent instructions · Nolt indie build - Read `README.md` and `BUILD_PLAN.md` before writing code. The stack is fixed: TypeScript with Fastify and PostgreSQL, Optional accounts, with a durable per-browser identity for anonymous voting, Rate limits, duplicate detection and vote weighting by segment, Duplicate posts merged with votes combined and voters deduplicated, Explicit statuses with a stated meaning, and no implied commitment. Do not substitute. - Work one phase at a time, in order. Do not start a phase until every "Done when" item of the previous one passes. - Prefer the fewest moving parts that satisfy the step. No frameworks, services or dependencies the plan does not name. - Secrets live in `.env`, never in source or logs. Keep `.env.example` current when a variable is introduced. - Do not invent cryptography, security guarantees, APIs or compliance claims. - Add a focused test for every destructive, security-sensitive or data-loss path the plan names. - Run the project checks before declaring a phase complete, and record any deliberate shortcut in the README under "Tradeoffs". ## Known traps - Requiring an account before someone can post removes most of the feedback you would have received, and the people who do sign up are not a representative sample of your users. Let them post immediately, and offer the account for the thing accounts are actually good for, which is being told what happened. - Silently discarding suspected fraudulent votes means you cannot tell the difference between an attack and a legitimate burst from a customer who told their team to go and vote, and the second is exactly the signal you wanted. Flag and quarantine for review rather than deleting, so the distinction stays available. - Merging by adding the vote counts double-counts everyone who voted for both posts, which inflates the merged total and moves a request up the board on votes that do not exist. Combine the voter sets and count the union. - A planned column with no dates and no movement for eight months is read by users as a broken promise, and by the operator as an accurate representation of intent, and both are behaving reasonably. Show how long an item has held its status, and surface the stale ones so the board reflects what is actually happening. ===== BUILD_PLAN.md ===== # Build plan · Nolt A feedback board where the engineering problem is vote integrity on a public page without demanding accounts, and the design problem is that a roadmap built from raw vote counts is a popularity contest that will mislead you about what your users actually need. Phases are in dependency order. Each ends in a "Done when" list; treat an unticked item as a blocker, not a note. ## Phase 1 · A board people will actually post on Someone can submit a request in under a minute without creating an account. ### Steps 1. Allow posting with no account The friction is what you are trading feedback volume against. Files: `src/posts.ts` 2. Assign a durable anonymous identity Persisting across restarts, so voting can be one per person. Files: `src/voters.ts` 3. Let an account claim prior activity Someone signing up later should not lose their votes. Files: `src/claim.ts` ### Done when - [ ] A post is submitted in under a minute with no account - [ ] Anonymous identity survives a browser restart - [ ] An account claims prior anonymous votes - [ ] Empty submissions are rejected usefully ### Watch out - Requiring an account before someone can post removes most of the feedback you would have received, and the people who do sign up are not a representative sample of your users. Let them post immediately, and offer the account for the thing accounts are actually good for, which is being told what happened. ## Phase 2 · Votes that are hard to fake Voting is easy for a person and tedious for a script. ### Steps 1. Enforce one vote per voter in the schema A unique constraint, not an application check. Files: `src/schema.sql` 2. Rate limit by network and identity Both, since either alone is trivially avoided. Files: `src/ratelimit.ts` 3. Quarantine rather than delete A burst may be an attack or a customer's whole team. Keep the distinction. Files: `src/integrity.ts` ### Done when - [ ] Duplicate votes are impossible at the database level - [ ] Rate limits apply by network and identity - [ ] Suspicious bursts are quarantined and visible - [ ] Clearing storage does not grant a second vote ### Watch out - Silently discarding suspected fraudulent votes means you cannot tell the difference between an attack and a legitimate burst from a customer who told their team to go and vote, and the second is exactly the signal you wanted. Flag and quarantine for review rather than deleting, so the distinction stays available. ## Phase 3 · The same request, ten times over Duplicates are found and merged without losing anyone's vote. ### Steps 1. Count the union of voters on merge Adding the totals double-counts anyone who voted for both. Files: `src/merge.ts`, `src/merge.test.ts` 2. Suggest duplicates at submission The cheapest moment to prevent one is before it is written. Files: `src/duplicates.ts` 3. Keep merged posts reachable Their author should find out where their request went. Files: `src/posts.ts` ### Done when - [ ] A merge counts the union of voters - [ ] The merged post stays reachable - [ ] Duplicates are suggested at submission - [ ] A merge can be undone completely ### Watch out - Merging by adding the vote counts double-counts everyone who voted for both posts, which inflates the merged total and moves a request up the board on votes that do not exist. Combine the voter sets and count the union. ## Phase 4 · Statuses that do not promise things A roadmap communicates intent without being read as a commitment. ### Steps 1. Publish what each status means On the board itself. Users infer meanings otherwise, usually optimistic ones. Files: `src/statuses.ts` 2. Keep status history append only A silent reversal from planned to open is worse than never planning it. Files: `src/status-changes.ts` 3. Surface stale items Eight months in planned is information, and mostly for you. Files: `src/staleness.ts` ### Done when - [ ] Each status has a published meaning - [ ] Status history is append only and reversals visible - [ ] Stale items are surfaced to the operator - [ ] No dates appear unless deliberately entered ### Watch out - A planned column with no dates and no movement for eight months is read by users as a broken promise, and by the operator as an accurate representation of intent, and both are behaving reasonably. Show how long an item has held its status, and surface the stale ones so the board reflects what is actually happening. ## Not in this build - Single sign-on with your product. - Moderation at scale. - Multi-language boards. - Deep issue tracker integrations. ## After v1, if you want it - Private boards for a specific customer, which changes the segmentation question - Linking a post to what actually shipped, which is what closes the loop - A digest of what moved this month, which is the notification people want most ===== .env.example ===== # Copy to .env and fill in. Never commit .env; this file documents it. # Required · secret. Posts, votes and voter identities. DATABASE_URL=postgres://localhost:5432/feedback # Required. Requiring accounts collapses submission volume. This is the trade, made explicit. ALLOW_ANONYMOUS_POSTS=true # Required. Per identity and per network. Generous for a person, tedious for a script. VOTE_RATE_LIMIT_PER_HOUR=20 # Required. Votes from one source in a short window before quarantine. Quarantine, not deletion. BURST_QUARANTINE_THRESHOLD=10 # Required. How long an item may hold a status before being surfaced to you as stale. STALE_STATUS_DAYS=120 # Optional · secret. Status change notifications. Absent means the board works without them. SMTP_URL=smtp://user:pass@host:587
You are building a production product version of Nolt. Create the following project files first, then implement the application by following them. Keep the files updated as decisions change. Do not collapse this into a single README or prompt. ===== PRODUCT.md ===== # Nolt · product brief ## Problem The core loop is small enough for a capable coding agent to produce a useful local version in one sitting. For Nolt, publish a feedback board with voting, comments, statuses, and a roadmap. The hard boundary is hosted simplicity, custom domains, integrations, and low maintenance, plus participant network, synthesis workflow, and collaboration. ## Product outcome A public board people will actually post on, where a vote is hard to fake, a duplicate does not fragment the signal, and the roadmap does not accidentally promise anything. ## Target user A builder who needs a maintainable product foundation, not a one-off demo. ## Required capabilities - PostgreSQL - public HTTPS deployment - Resend API key - optional video-call links ## Explicit non-goals for v1 - Single sign-on with your product. - Moderation at scale. - Multi-language boards. - Deep issue tracker integrations. - hosted simplicity, custom domains, integrations, and low maintenance - large participant panel - recording and transcription infrastructure - advanced research repository - enterprise governance ## Success criteria - A duplicate vote is confirmed impossible at the database level - A merge is confirmed to count the union of voters - Suspicious bursts are confirmed quarantined rather than deleted - Every status is confirmed to have a published meaning - Vote counts are confirmed shown by segment ===== BRIEF.md ===== # Build brief · Nolt The one-shot brief this plan expands. `BUILD_PLAN.md` (or `MILESTONES.md`) is the same sequence broken into steps and checks; where the two disagree, the plan wins. Build me a public feedback board with voting, where the votes mean something to replace Nolt. Build it in phases, in the order below. Do not write the whole thing in one pass. Finish a phase, run its "Done when" check, fix what fails, and only then start the next phase. ### Stack (fixed, do not substitute) - Runtime: TypeScript with Fastify and PostgreSQL · boards, posts and votes are relational and small - Identity: Optional accounts, with a durable per-browser identity for anonymous voting · requiring an account collapses submission volume, and allowing none collapses integrity - Integrity: Rate limits, duplicate detection and vote weighting by segment · a raw count is trivially gamed and misleading even when it is not - Merging: Duplicate posts merged with votes combined and voters deduplicated · the same request arrives ten times in ten wordings - Status: Explicit statuses with a stated meaning, and no implied commitment · a roadmap column is read as a promise whether or not you meant one ### Data model (create this before Phase 1) - boards(id, slug, title, visibility, allow_anonymous, created_at) - posts(id, board_id, title, body, author_id, status, created_at, merged_into_id) - votes(post_id, voter_id, weight, at) · one row per voter per post, enforced by a constraint - voters(id, kind, account_id, fingerprint_hash, first_seen_at, segment) · kind is account or anonymous - merges(id, kept_post_id, merged_post_id, votes_combined, at) - comments(id, post_id, voter_id, body, at) - status_changes(post_id, from_status, to_status, note, at) · append only, because a status that quietly reverts is worse than one that never moved ### Phase 1 · A board people will actually post on Build: A public board with post submission, requiring nothing but the request itself. Assign a durable anonymous identity for later voting. Offer an optional account for people who want to be notified. Trap: Requiring an account before someone can post removes most of the feedback you would have received, and the people who do sign up are not a representative sample of your users. Let them post immediately, and offer the account for the thing accounts are actually good for, which is being told what happened. Done when: A post is submitted in under a minute without an account, timed by hand. An anonymous voter identity persists across a browser restart. An optional account can be created later and claims the anonymous identity's votes. A submission with no content is rejected with a useful message rather than a validation error. Do not build yet: No voting integrity, merging or statuses. ### Phase 2 · Votes that are hard to fake Build: One vote per voter per post, enforced in the database. Rate limit by network and by identity. Detect clusters of votes arriving together from one source and flag them rather than silently discarding. Trap: Silently discarding suspected fraudulent votes means you cannot tell the difference between an attack and a legitimate burst from a customer who told their team to go and vote, and the second is exactly the signal you wanted. Flag and quarantine for review rather than deleting, so the distinction stays available. Done when: A duplicate vote is impossible, enforced by a database constraint rather than application logic. Rate limits apply by network and identity. A burst of votes from one source is flagged and quarantined rather than deleted, with counts shown both including and excluding quarantined votes. Clearing browser storage does not grant a second vote from the same source. Do not build yet: No merging or statuses yet. ### Phase 3 · The same request, ten times over Build: Suggest likely duplicates on submission and to the operator afterwards. Merge posts, combining votes and deduplicating voters who voted for both. Keep the merged post reachable so its author can see where it went. Trap: Merging by adding the vote counts double-counts everyone who voted for both posts, which inflates the merged total and moves a request up the board on votes that do not exist. Combine the voter sets and count the union. Done when: Merging two posts produces a vote count equal to the union of their voters, not the sum, asserted in a test with overlapping voters. The merged post remains reachable and points at the surviving one. Likely duplicates are suggested at submission time. A merge can be undone, restoring both posts and their original votes. Do not build yet: No statuses or roadmap yet. ### Phase 4 · Statuses that do not promise things Build: Explicit statuses with a written meaning shown on the board. Append-only status history so a reversal is visible rather than silent. No dates unless you enter one deliberately, and a stale status surfaced to the operator. Trap: A planned column with no dates and no movement for eight months is read by users as a broken promise, and by the operator as an accurate representation of intent, and both are behaving reasonably. Show how long an item has held its status, and surface the stale ones so the board reflects what is actually happening. Done when: Every status has a written meaning visible on the board. Status history is append only and a reversal is visible rather than silent. An item held in one status past a threshold is surfaced to the operator. No date appears anywhere unless deliberately entered. Do not build yet: No notifications or segmentation yet. ### Phase 5 · Reading the board honestly Build: Segment voters by whatever you actually know, such as customer or not, and show vote counts by segment alongside the raw total. Notify voters on status change. Export everything. Trap: Ranking by raw vote count means the loudest audience decides the roadmap, which is frequently not the paying one and almost never the silent majority. Show the breakdown by segment beside the total, so a request with forty votes from non-customers and one from your largest account is legible as what it is. Done when: Vote counts display by segment alongside the total. Notifying voters of a status change works and respects an unsubscribe. Export contains posts, votes, merges and status history. A board with a thousand posts and ten thousand votes loads and sorts quickly. Do not build yet: Nothing after this. ### Out of scope (and why) - Single sign-on with your product so voters are automatically identified, which is what makes segmentation genuinely useful and is an integration per product. - Moderation at scale, which becomes a real job the moment a board is public and popular. - Multi-language boards, where machine translation of feature requests loses precisely the nuance that matters. - Deep integrations with issue trackers, which is the part the hosted products sell. ### README must contain - That posting requires no account, because the friction costs you the feedback - That one vote per voter is enforced by a database constraint rather than application logic - That suspicious bursts are quarantined and visible rather than silently deleted - That merging counts the union of voters rather than adding totals - That vote counts are shown by segment because a raw total is a popularity contest ===== ARCHITECTURE.md ===== # Architecture · Nolt ## Stack | Part | Choice | Why | | --- | --- | --- | | Runtime | TypeScript with Fastify and PostgreSQL | boards, posts and votes are relational and small | | Identity | Optional accounts, with a durable per-browser identity for anonymous voting | requiring an account collapses submission volume, and allowing none collapses integrity | | Integrity | Rate limits, duplicate detection and vote weighting by segment | a raw count is trivially gamed and misleading even when it is not | | Merging | Duplicate posts merged with votes combined and voters deduplicated | the same request arrives ten times in ten wordings | | Status | Explicit statuses with a stated meaning, and no implied commitment | a roadmap column is read as a promise whether or not you meant one | ## Modules Each module has one owner concern and a documented way to replace it. | Module | Owns | How to replace it | | --- | --- | --- | | Identity | Anonymous and account voters. | Any scheme, provided anonymous posting stays possible and identity is durable enough to vote once. | | Integrity | Uniqueness, rate limits and quarantine. | Any implementation, provided uniqueness is a constraint and suspicious votes are kept rather than deleted. | | Merging | Combining duplicates by voter union. | Any implementation. Adding totals is the mistake to avoid. | | Statuses | Published meanings and append-only history. | Any set of statuses, provided each has a stated meaning and reversals are visible. | | Segments | Vote breakdown beside the total. | Any segmentation. Showing only the total is what makes a board misleading. | ## Configuration Every runtime setting is an environment variable documented in `.env.example`, validated at startup, with a safe local default wherever one exists. - `DATABASE_URL` · required, secret · Posts, votes and voter identities. - `ALLOW_ANONYMOUS_POSTS` · required · Requiring accounts collapses submission volume. This is the trade, made explicit. - `VOTE_RATE_LIMIT_PER_HOUR` · required · Per identity and per network. Generous for a person, tedious for a script. - `BURST_QUARANTINE_THRESHOLD` · required · Votes from one source in a short window before quarantine. Quarantine, not deletion. - `STALE_STATUS_DAYS` · required · How long an item may hold a status before being surfaced to you as stale. - `SMTP_URL` · optional, secret · Status change notifications. Absent means the board works without them. ## Production baseline - Security: least privilege, input validation at every boundary, secret redaction in logs, rate limits on abuse-prone paths, no invented security primitives. - Data: explicit schema and migrations, transactional writes where integrity matters, backup and restore procedures that have been exercised. - Integrations: adapters around third-party providers, idempotent webhook or job processing, bounded retries, timeouts. - Observability: structured logs with request or operation ids, an error-tracking hook, and health and readiness checks where a server exists. - Quality: unit tests for domain rules, integration tests at module boundaries, one end-to-end test of the critical path. ## Decision records For each dependency in the stack table, keep a short note: why it was chosen, its failure mode, and how it is replaced. Do not add infrastructure until a requirement in `PRODUCT.md` justifies it. ===== AGENTS.md ===== # Agent instructions · Nolt product build - Read `PRODUCT.md` and `ARCHITECTURE.md` before changing code. The stack is fixed: TypeScript with Fastify and PostgreSQL, Optional accounts, with a durable per-browser identity for anonymous voting, Rate limits, duplicate detection and vote weighting by segment, Duplicate posts merged with votes combined and voters deduplicated, Explicit statuses with a stated meaning, and no implied commitment. - Implement milestone by milestone from `MILESTONES.md`; keep each change reviewable and leave the application runnable at every commit. - Treat authentication, payments, encryption, imports, webhooks and destructive actions as high-risk boundaries when present. - Never invent cryptography or silently weaken a requirement to make a check pass. - Put every external service behind an interface with a deterministic fake for tests. - Add migrations and rollback or recovery notes for every persistent data change. - Log useful operational context without credentials, tokens, passwords or personal data. - Update documentation and run every check before completing a milestone. ## Known traps - Requiring an account before someone can post removes most of the feedback you would have received, and the people who do sign up are not a representative sample of your users. Let them post immediately, and offer the account for the thing accounts are actually good for, which is being told what happened. - Silently discarding suspected fraudulent votes means you cannot tell the difference between an attack and a legitimate burst from a customer who told their team to go and vote, and the second is exactly the signal you wanted. Flag and quarantine for review rather than deleting, so the distinction stays available. - Merging by adding the vote counts double-counts everyone who voted for both posts, which inflates the merged total and moves a request up the board on votes that do not exist. Combine the voter sets and count the union. - A planned column with no dates and no movement for eight months is read by users as a broken promise, and by the operator as an accurate representation of intent, and both are behaving reasonably. Show how long an item has held its status, and surface the stale ones so the board reflects what is actually happening. - Ranking by raw vote count means the loudest audience decides the roadmap, which is frequently not the paying one and almost never the silent majority. Show the breakdown by segment beside the total, so a request with forty votes from non-customers and one from your largest account is legible as what it is. ===== MILESTONES.md ===== # Delivery milestones · Nolt Estimated effort: **weekend** for the indie phases; the production-only milestones add the trust and operability layer. ## M1 · A board people will actually post on Someone can submit a request in under a minute without creating an account. ### Steps 1. Allow posting with no account The friction is what you are trading feedback volume against. Files: `src/posts.ts` 2. Assign a durable anonymous identity Persisting across restarts, so voting can be one per person. Files: `src/voters.ts` 3. Let an account claim prior activity Someone signing up later should not lose their votes. Files: `src/claim.ts` ### Done when - [ ] A post is submitted in under a minute with no account - [ ] Anonymous identity survives a browser restart - [ ] An account claims prior anonymous votes - [ ] Empty submissions are rejected usefully ### Watch out - Requiring an account before someone can post removes most of the feedback you would have received, and the people who do sign up are not a representative sample of your users. Let them post immediately, and offer the account for the thing accounts are actually good for, which is being told what happened. ## M2 · Votes that are hard to fake Voting is easy for a person and tedious for a script. ### Steps 1. Enforce one vote per voter in the schema A unique constraint, not an application check. Files: `src/schema.sql` 2. Rate limit by network and identity Both, since either alone is trivially avoided. Files: `src/ratelimit.ts` 3. Quarantine rather than delete A burst may be an attack or a customer's whole team. Keep the distinction. Files: `src/integrity.ts` ### Done when - [ ] Duplicate votes are impossible at the database level - [ ] Rate limits apply by network and identity - [ ] Suspicious bursts are quarantined and visible - [ ] Clearing storage does not grant a second vote ### Watch out - Silently discarding suspected fraudulent votes means you cannot tell the difference between an attack and a legitimate burst from a customer who told their team to go and vote, and the second is exactly the signal you wanted. Flag and quarantine for review rather than deleting, so the distinction stays available. ## M3 · The same request, ten times over Duplicates are found and merged without losing anyone's vote. ### Steps 1. Count the union of voters on merge Adding the totals double-counts anyone who voted for both. Files: `src/merge.ts`, `src/merge.test.ts` 2. Suggest duplicates at submission The cheapest moment to prevent one is before it is written. Files: `src/duplicates.ts` 3. Keep merged posts reachable Their author should find out where their request went. Files: `src/posts.ts` ### Done when - [ ] A merge counts the union of voters - [ ] The merged post stays reachable - [ ] Duplicates are suggested at submission - [ ] A merge can be undone completely ### Watch out - Merging by adding the vote counts double-counts everyone who voted for both posts, which inflates the merged total and moves a request up the board on votes that do not exist. Combine the voter sets and count the union. ## M4 · Statuses that do not promise things A roadmap communicates intent without being read as a commitment. ### Steps 1. Publish what each status means On the board itself. Users infer meanings otherwise, usually optimistic ones. Files: `src/statuses.ts` 2. Keep status history append only A silent reversal from planned to open is worse than never planning it. Files: `src/status-changes.ts` 3. Surface stale items Eight months in planned is information, and mostly for you. Files: `src/staleness.ts` ### Done when - [ ] Each status has a published meaning - [ ] Status history is append only and reversals visible - [ ] Stale items are surfaced to the operator - [ ] No dates appear unless deliberately entered ### Watch out - A planned column with no dates and no movement for eight months is read by users as a broken promise, and by the operator as an accurate representation of intent, and both are behaving reasonably. Show how long an item has held its status, and surface the stale ones so the board reflects what is actually happening. ## M5 · Reading the board honestly (production only) You can see who wants what, not just how loud the total is. ### Steps 1. Show votes by segment Beside the total, not instead of it. The breakdown is the useful part. Files: `src/segments.ts` 2. Notify on status change It is the main reason anyone creates an account here. Files: `src/notify.ts` 3. Export the whole board Posts, votes, merges and history. It is your users' input, not yours. Files: `src/export.ts` ### Done when - [ ] Vote counts display by segment and total - [ ] Status change notifications send and honour unsubscribe - [ ] Export contains posts, votes, merges and history - [ ] A thousand posts sort quickly ### Watch out - Ranking by raw vote count means the loudest audience decides the roadmap, which is frequently not the paying one and almost never the silent majority. Show the breakdown by segment beside the total, so a request with forty votes from non-customers and one from your largest account is legible as what it is. ===== OPERATIONS.md ===== # Operations · Nolt ## Backup A feedback board is your users' input and a record of what you said you would do. Back up posts, votes and status history, since the last is the part you may be asked about. ## Restore Restore and confirm merges and status history survived, since losing a merge fragments requests back into duplicates and losing status history erases the record of what was promised. Do a restore drill before the first real user, and write the date here when it passes. ## Monitoring Quarantined vote volume and submission rate. A sudden burst of both usually means the board has been linked somewhere public, which is worth knowing before you interpret the numbers. ## Incident checklist A request that jumped the board on inflated votes means either merging added totals or quarantine is not catching a source. Both are visible in the vote table, which is why quarantined votes are kept. 1. Contain the issue without destroying evidence or user data. 2. Record the timeline and affected scope. 3. Rotate exposed secrets and revoke compromised sessions or credentials. 4. Restore from a verified backup when needed. 5. Document the root cause, the remediation and the regression test. ## Release gate - [ ] A duplicate vote is confirmed impossible at the database level - [ ] A merge is confirmed to count the union of voters - [ ] Suspicious bursts are confirmed quarantined rather than deleted - [ ] Every status is confirmed to have a published meaning - [ ] Vote counts are confirmed shown by segment ## Launch constraint Do not market omitted Nolt capabilities as implemented. The non-goals in `PRODUCT.md` remain user-visible limitations until they are deliberately delivered. ===== .env.example ===== # Copy to .env and fill in. Never commit .env; this file documents it. # Required · secret. Posts, votes and voter identities. DATABASE_URL=postgres://localhost:5432/feedback # Required. Requiring accounts collapses submission volume. This is the trade, made explicit. ALLOW_ANONYMOUS_POSTS=true # Required. Per identity and per network. Generous for a person, tedious for a script. VOTE_RATE_LIMIT_PER_HOUR=20 # Required. Votes from one source in a short window before quarantine. Quarantine, not deletion. BURST_QUARANTINE_THRESHOLD=10 # Required. How long an item may hold a status before being surfaced to you as stale. STALE_STATUS_DAYS=120 # Optional · secret. Status change notifications. Absent means the board works without them. SMTP_URL=smtp://user:pass@host:587
# Nolt · indie build A feedback board where the engineering problem is vote integrity on a public page without demanding accounts, and the design problem is that a roadmap built from raw vote counts is a popularity contest that will mislead you about what your users actually need. Estimated effort: **weekend**. Work `BUILD_PLAN.md` top to bottom · every phase ends in a check that has to pass before the next one starts. ## Stack | Part | Choice | Why | | --- | --- | --- | | Runtime | TypeScript with Fastify and PostgreSQL | boards, posts and votes are relational and small | | Identity | Optional accounts, with a durable per-browser identity for anonymous voting | requiring an account collapses submission volume, and allowing none collapses integrity | | Integrity | Rate limits, duplicate detection and vote weighting by segment | a raw count is trivially gamed and misleading even when it is not | | Merging | Duplicate posts merged with votes combined and voters deduplicated | the same request arrives ten times in ten wordings | | Status | Explicit statuses with a stated meaning, and no implied commitment | a roadmap column is read as a promise whether or not you meant one | ## Before you start Have every one of these ready. The plan assumes them from step one. - [ ] **Node.js 22 or newer** · free - Why: Runs the server and the board. - Get it: nodejs.org, or nvm install 22. - Verify: node --version prints v22 or higher - [ ] **PostgreSQL 16 or newer** · free locally - Why: Vote uniqueness is enforced by a constraint, and merging correctly needs set operations. - Get it: Docker or your package manager. - Verify: A unique constraint rejects a duplicate vote insert - [ ] **Your statuses and what each one means** · free - Why: Users read a roadmap column as a promise, and an undefined status is read optimistically. Deciding the meanings after publishing means changing what you appeared to promise. - Get it: Write one sentence per status, including explicitly what planned does not mean. Decide whether you will ever show dates. - Verify: A written meaning exists for every status and appears on the board - [ ] **How you will segment voters** · free - Why: Ranking by raw count hands your roadmap to whoever is loudest, and segmentation is only possible if you decide early what you can actually know about a voter. - Get it: Decide what distinction matters to you and what evidence you will have for it without demanding accounts. - Verify: A written note names the segments and how a voter is assigned to one - [ ] **A hundred real feedback items** · free - Why: Duplicate detection and merging only become interesting at volume, and a board with ten posts has no duplicates to find. - Get it: Export from wherever feedback currently lands, including the ten near-identical versions of the same request. - Verify: A hundred items exist including several genuine duplicates ## Quick start Follow Phase 1 in `BUILD_PLAN.md`; it creates the project. Then copy `.env.example` to `.env` and fill in the values it documents. ## Honest limits This build deliberately does not replace: - Single sign-on with your product. - Moderation at scale. - Multi-language boards. - Deep issue tracker integrations. - hosted simplicity, custom domains, integrations, and low maintenance - large participant panel - recording and transcription infrastructure - advanced research repository - enterprise governance If one of those is essential to you, that is the reason to keep paying for Nolt, and the README should say so rather than pretend.
# Build brief · Nolt The one-shot brief this plan expands. `BUILD_PLAN.md` (or `MILESTONES.md`) is the same sequence broken into steps and checks; where the two disagree, the plan wins. Build me a public feedback board with voting, where the votes mean something to replace Nolt. Build it in phases, in the order below. Do not write the whole thing in one pass. Finish a phase, run its "Done when" check, fix what fails, and only then start the next phase. ### Stack (fixed, do not substitute) - Runtime: TypeScript with Fastify and PostgreSQL · boards, posts and votes are relational and small - Identity: Optional accounts, with a durable per-browser identity for anonymous voting · requiring an account collapses submission volume, and allowing none collapses integrity - Integrity: Rate limits, duplicate detection and vote weighting by segment · a raw count is trivially gamed and misleading even when it is not - Merging: Duplicate posts merged with votes combined and voters deduplicated · the same request arrives ten times in ten wordings - Status: Explicit statuses with a stated meaning, and no implied commitment · a roadmap column is read as a promise whether or not you meant one ### Data model (create this before Phase 1) - boards(id, slug, title, visibility, allow_anonymous, created_at) - posts(id, board_id, title, body, author_id, status, created_at, merged_into_id) - votes(post_id, voter_id, weight, at) · one row per voter per post, enforced by a constraint - voters(id, kind, account_id, fingerprint_hash, first_seen_at, segment) · kind is account or anonymous - merges(id, kept_post_id, merged_post_id, votes_combined, at) - comments(id, post_id, voter_id, body, at) - status_changes(post_id, from_status, to_status, note, at) · append only, because a status that quietly reverts is worse than one that never moved ### Phase 1 · A board people will actually post on Build: A public board with post submission, requiring nothing but the request itself. Assign a durable anonymous identity for later voting. Offer an optional account for people who want to be notified. Trap: Requiring an account before someone can post removes most of the feedback you would have received, and the people who do sign up are not a representative sample of your users. Let them post immediately, and offer the account for the thing accounts are actually good for, which is being told what happened. Done when: A post is submitted in under a minute without an account, timed by hand. An anonymous voter identity persists across a browser restart. An optional account can be created later and claims the anonymous identity's votes. A submission with no content is rejected with a useful message rather than a validation error. Do not build yet: No voting integrity, merging or statuses. ### Phase 2 · Votes that are hard to fake Build: One vote per voter per post, enforced in the database. Rate limit by network and by identity. Detect clusters of votes arriving together from one source and flag them rather than silently discarding. Trap: Silently discarding suspected fraudulent votes means you cannot tell the difference between an attack and a legitimate burst from a customer who told their team to go and vote, and the second is exactly the signal you wanted. Flag and quarantine for review rather than deleting, so the distinction stays available. Done when: A duplicate vote is impossible, enforced by a database constraint rather than application logic. Rate limits apply by network and identity. A burst of votes from one source is flagged and quarantined rather than deleted, with counts shown both including and excluding quarantined votes. Clearing browser storage does not grant a second vote from the same source. Do not build yet: No merging or statuses yet. ### Phase 3 · The same request, ten times over Build: Suggest likely duplicates on submission and to the operator afterwards. Merge posts, combining votes and deduplicating voters who voted for both. Keep the merged post reachable so its author can see where it went. Trap: Merging by adding the vote counts double-counts everyone who voted for both posts, which inflates the merged total and moves a request up the board on votes that do not exist. Combine the voter sets and count the union. Done when: Merging two posts produces a vote count equal to the union of their voters, not the sum, asserted in a test with overlapping voters. The merged post remains reachable and points at the surviving one. Likely duplicates are suggested at submission time. A merge can be undone, restoring both posts and their original votes. Do not build yet: No statuses or roadmap yet. ### Phase 4 · Statuses that do not promise things Build: Explicit statuses with a written meaning shown on the board. Append-only status history so a reversal is visible rather than silent. No dates unless you enter one deliberately, and a stale status surfaced to the operator. Trap: A planned column with no dates and no movement for eight months is read by users as a broken promise, and by the operator as an accurate representation of intent, and both are behaving reasonably. Show how long an item has held its status, and surface the stale ones so the board reflects what is actually happening. Done when: Every status has a written meaning visible on the board. Status history is append only and a reversal is visible rather than silent. An item held in one status past a threshold is surfaced to the operator. No date appears anywhere unless deliberately entered. Do not build yet: No notifications or segmentation yet. ### Phase 5 · Reading the board honestly Build: Segment voters by whatever you actually know, such as customer or not, and show vote counts by segment alongside the raw total. Notify voters on status change. Export everything. Trap: Ranking by raw vote count means the loudest audience decides the roadmap, which is frequently not the paying one and almost never the silent majority. Show the breakdown by segment beside the total, so a request with forty votes from non-customers and one from your largest account is legible as what it is. Done when: Vote counts display by segment alongside the total. Notifying voters of a status change works and respects an unsubscribe. Export contains posts, votes, merges and status history. A board with a thousand posts and ten thousand votes loads and sorts quickly. Do not build yet: Nothing after this. ### Out of scope (and why) - Single sign-on with your product so voters are automatically identified, which is what makes segmentation genuinely useful and is an integration per product. - Moderation at scale, which becomes a real job the moment a board is public and popular. - Multi-language boards, where machine translation of feature requests loses precisely the nuance that matters. - Deep integrations with issue trackers, which is the part the hosted products sell. ### README must contain - That posting requires no account, because the friction costs you the feedback - That one vote per voter is enforced by a database constraint rather than application logic - That suspicious bursts are quarantined and visible rather than silently deleted - That merging counts the union of voters rather than adding totals - That vote counts are shown by segment because a raw total is a popularity contest
# Agent instructions · Nolt indie build - Read `README.md` and `BUILD_PLAN.md` before writing code. The stack is fixed: TypeScript with Fastify and PostgreSQL, Optional accounts, with a durable per-browser identity for anonymous voting, Rate limits, duplicate detection and vote weighting by segment, Duplicate posts merged with votes combined and voters deduplicated, Explicit statuses with a stated meaning, and no implied commitment. Do not substitute. - Work one phase at a time, in order. Do not start a phase until every "Done when" item of the previous one passes. - Prefer the fewest moving parts that satisfy the step. No frameworks, services or dependencies the plan does not name. - Secrets live in `.env`, never in source or logs. Keep `.env.example` current when a variable is introduced. - Do not invent cryptography, security guarantees, APIs or compliance claims. - Add a focused test for every destructive, security-sensitive or data-loss path the plan names. - Run the project checks before declaring a phase complete, and record any deliberate shortcut in the README under "Tradeoffs". ## Known traps - Requiring an account before someone can post removes most of the feedback you would have received, and the people who do sign up are not a representative sample of your users. Let them post immediately, and offer the account for the thing accounts are actually good for, which is being told what happened. - Silently discarding suspected fraudulent votes means you cannot tell the difference between an attack and a legitimate burst from a customer who told their team to go and vote, and the second is exactly the signal you wanted. Flag and quarantine for review rather than deleting, so the distinction stays available. - Merging by adding the vote counts double-counts everyone who voted for both posts, which inflates the merged total and moves a request up the board on votes that do not exist. Combine the voter sets and count the union. - A planned column with no dates and no movement for eight months is read by users as a broken promise, and by the operator as an accurate representation of intent, and both are behaving reasonably. Show how long an item has held its status, and surface the stale ones so the board reflects what is actually happening.
# Build plan · Nolt A feedback board where the engineering problem is vote integrity on a public page without demanding accounts, and the design problem is that a roadmap built from raw vote counts is a popularity contest that will mislead you about what your users actually need. Phases are in dependency order. Each ends in a "Done when" list; treat an unticked item as a blocker, not a note. ## Phase 1 · A board people will actually post on Someone can submit a request in under a minute without creating an account. ### Steps 1. Allow posting with no account The friction is what you are trading feedback volume against. Files: `src/posts.ts` 2. Assign a durable anonymous identity Persisting across restarts, so voting can be one per person. Files: `src/voters.ts` 3. Let an account claim prior activity Someone signing up later should not lose their votes. Files: `src/claim.ts` ### Done when - [ ] A post is submitted in under a minute with no account - [ ] Anonymous identity survives a browser restart - [ ] An account claims prior anonymous votes - [ ] Empty submissions are rejected usefully ### Watch out - Requiring an account before someone can post removes most of the feedback you would have received, and the people who do sign up are not a representative sample of your users. Let them post immediately, and offer the account for the thing accounts are actually good for, which is being told what happened. ## Phase 2 · Votes that are hard to fake Voting is easy for a person and tedious for a script. ### Steps 1. Enforce one vote per voter in the schema A unique constraint, not an application check. Files: `src/schema.sql` 2. Rate limit by network and identity Both, since either alone is trivially avoided. Files: `src/ratelimit.ts` 3. Quarantine rather than delete A burst may be an attack or a customer's whole team. Keep the distinction. Files: `src/integrity.ts` ### Done when - [ ] Duplicate votes are impossible at the database level - [ ] Rate limits apply by network and identity - [ ] Suspicious bursts are quarantined and visible - [ ] Clearing storage does not grant a second vote ### Watch out - Silently discarding suspected fraudulent votes means you cannot tell the difference between an attack and a legitimate burst from a customer who told their team to go and vote, and the second is exactly the signal you wanted. Flag and quarantine for review rather than deleting, so the distinction stays available. ## Phase 3 · The same request, ten times over Duplicates are found and merged without losing anyone's vote. ### Steps 1. Count the union of voters on merge Adding the totals double-counts anyone who voted for both. Files: `src/merge.ts`, `src/merge.test.ts` 2. Suggest duplicates at submission The cheapest moment to prevent one is before it is written. Files: `src/duplicates.ts` 3. Keep merged posts reachable Their author should find out where their request went. Files: `src/posts.ts` ### Done when - [ ] A merge counts the union of voters - [ ] The merged post stays reachable - [ ] Duplicates are suggested at submission - [ ] A merge can be undone completely ### Watch out - Merging by adding the vote counts double-counts everyone who voted for both posts, which inflates the merged total and moves a request up the board on votes that do not exist. Combine the voter sets and count the union. ## Phase 4 · Statuses that do not promise things A roadmap communicates intent without being read as a commitment. ### Steps 1. Publish what each status means On the board itself. Users infer meanings otherwise, usually optimistic ones. Files: `src/statuses.ts` 2. Keep status history append only A silent reversal from planned to open is worse than never planning it. Files: `src/status-changes.ts` 3. Surface stale items Eight months in planned is information, and mostly for you. Files: `src/staleness.ts` ### Done when - [ ] Each status has a published meaning - [ ] Status history is append only and reversals visible - [ ] Stale items are surfaced to the operator - [ ] No dates appear unless deliberately entered ### Watch out - A planned column with no dates and no movement for eight months is read by users as a broken promise, and by the operator as an accurate representation of intent, and both are behaving reasonably. Show how long an item has held its status, and surface the stale ones so the board reflects what is actually happening. ## Not in this build - Single sign-on with your product. - Moderation at scale. - Multi-language boards. - Deep issue tracker integrations. ## After v1, if you want it - Private boards for a specific customer, which changes the segmentation question - Linking a post to what actually shipped, which is what closes the loop - A digest of what moved this month, which is the notification people want most
# Copy to .env and fill in. Never commit .env; this file documents it. # Required · secret. Posts, votes and voter identities. DATABASE_URL=postgres://localhost:5432/feedback # Required. Requiring accounts collapses submission volume. This is the trade, made explicit. ALLOW_ANONYMOUS_POSTS=true # Required. Per identity and per network. Generous for a person, tedious for a script. VOTE_RATE_LIMIT_PER_HOUR=20 # Required. Votes from one source in a short window before quarantine. Quarantine, not deletion. BURST_QUARANTINE_THRESHOLD=10 # Required. How long an item may hold a status before being surfaced to you as stale. STALE_STATUS_DAYS=120 # Optional · secret. Status change notifications. Absent means the board works without them. SMTP_URL=smtp://user:pass@host:587
# Nolt · product brief ## Problem The core loop is small enough for a capable coding agent to produce a useful local version in one sitting. For Nolt, publish a feedback board with voting, comments, statuses, and a roadmap. The hard boundary is hosted simplicity, custom domains, integrations, and low maintenance, plus participant network, synthesis workflow, and collaboration. ## Product outcome A public board people will actually post on, where a vote is hard to fake, a duplicate does not fragment the signal, and the roadmap does not accidentally promise anything. ## Target user A builder who needs a maintainable product foundation, not a one-off demo. ## Required capabilities - PostgreSQL - public HTTPS deployment - Resend API key - optional video-call links ## Explicit non-goals for v1 - Single sign-on with your product. - Moderation at scale. - Multi-language boards. - Deep issue tracker integrations. - hosted simplicity, custom domains, integrations, and low maintenance - large participant panel - recording and transcription infrastructure - advanced research repository - enterprise governance ## Success criteria - A duplicate vote is confirmed impossible at the database level - A merge is confirmed to count the union of voters - Suspicious bursts are confirmed quarantined rather than deleted - Every status is confirmed to have a published meaning - Vote counts are confirmed shown by segment
# Build brief · Nolt The one-shot brief this plan expands. `BUILD_PLAN.md` (or `MILESTONES.md`) is the same sequence broken into steps and checks; where the two disagree, the plan wins. Build me a public feedback board with voting, where the votes mean something to replace Nolt. Build it in phases, in the order below. Do not write the whole thing in one pass. Finish a phase, run its "Done when" check, fix what fails, and only then start the next phase. ### Stack (fixed, do not substitute) - Runtime: TypeScript with Fastify and PostgreSQL · boards, posts and votes are relational and small - Identity: Optional accounts, with a durable per-browser identity for anonymous voting · requiring an account collapses submission volume, and allowing none collapses integrity - Integrity: Rate limits, duplicate detection and vote weighting by segment · a raw count is trivially gamed and misleading even when it is not - Merging: Duplicate posts merged with votes combined and voters deduplicated · the same request arrives ten times in ten wordings - Status: Explicit statuses with a stated meaning, and no implied commitment · a roadmap column is read as a promise whether or not you meant one ### Data model (create this before Phase 1) - boards(id, slug, title, visibility, allow_anonymous, created_at) - posts(id, board_id, title, body, author_id, status, created_at, merged_into_id) - votes(post_id, voter_id, weight, at) · one row per voter per post, enforced by a constraint - voters(id, kind, account_id, fingerprint_hash, first_seen_at, segment) · kind is account or anonymous - merges(id, kept_post_id, merged_post_id, votes_combined, at) - comments(id, post_id, voter_id, body, at) - status_changes(post_id, from_status, to_status, note, at) · append only, because a status that quietly reverts is worse than one that never moved ### Phase 1 · A board people will actually post on Build: A public board with post submission, requiring nothing but the request itself. Assign a durable anonymous identity for later voting. Offer an optional account for people who want to be notified. Trap: Requiring an account before someone can post removes most of the feedback you would have received, and the people who do sign up are not a representative sample of your users. Let them post immediately, and offer the account for the thing accounts are actually good for, which is being told what happened. Done when: A post is submitted in under a minute without an account, timed by hand. An anonymous voter identity persists across a browser restart. An optional account can be created later and claims the anonymous identity's votes. A submission with no content is rejected with a useful message rather than a validation error. Do not build yet: No voting integrity, merging or statuses. ### Phase 2 · Votes that are hard to fake Build: One vote per voter per post, enforced in the database. Rate limit by network and by identity. Detect clusters of votes arriving together from one source and flag them rather than silently discarding. Trap: Silently discarding suspected fraudulent votes means you cannot tell the difference between an attack and a legitimate burst from a customer who told their team to go and vote, and the second is exactly the signal you wanted. Flag and quarantine for review rather than deleting, so the distinction stays available. Done when: A duplicate vote is impossible, enforced by a database constraint rather than application logic. Rate limits apply by network and identity. A burst of votes from one source is flagged and quarantined rather than deleted, with counts shown both including and excluding quarantined votes. Clearing browser storage does not grant a second vote from the same source. Do not build yet: No merging or statuses yet. ### Phase 3 · The same request, ten times over Build: Suggest likely duplicates on submission and to the operator afterwards. Merge posts, combining votes and deduplicating voters who voted for both. Keep the merged post reachable so its author can see where it went. Trap: Merging by adding the vote counts double-counts everyone who voted for both posts, which inflates the merged total and moves a request up the board on votes that do not exist. Combine the voter sets and count the union. Done when: Merging two posts produces a vote count equal to the union of their voters, not the sum, asserted in a test with overlapping voters. The merged post remains reachable and points at the surviving one. Likely duplicates are suggested at submission time. A merge can be undone, restoring both posts and their original votes. Do not build yet: No statuses or roadmap yet. ### Phase 4 · Statuses that do not promise things Build: Explicit statuses with a written meaning shown on the board. Append-only status history so a reversal is visible rather than silent. No dates unless you enter one deliberately, and a stale status surfaced to the operator. Trap: A planned column with no dates and no movement for eight months is read by users as a broken promise, and by the operator as an accurate representation of intent, and both are behaving reasonably. Show how long an item has held its status, and surface the stale ones so the board reflects what is actually happening. Done when: Every status has a written meaning visible on the board. Status history is append only and a reversal is visible rather than silent. An item held in one status past a threshold is surfaced to the operator. No date appears anywhere unless deliberately entered. Do not build yet: No notifications or segmentation yet. ### Phase 5 · Reading the board honestly Build: Segment voters by whatever you actually know, such as customer or not, and show vote counts by segment alongside the raw total. Notify voters on status change. Export everything. Trap: Ranking by raw vote count means the loudest audience decides the roadmap, which is frequently not the paying one and almost never the silent majority. Show the breakdown by segment beside the total, so a request with forty votes from non-customers and one from your largest account is legible as what it is. Done when: Vote counts display by segment alongside the total. Notifying voters of a status change works and respects an unsubscribe. Export contains posts, votes, merges and status history. A board with a thousand posts and ten thousand votes loads and sorts quickly. Do not build yet: Nothing after this. ### Out of scope (and why) - Single sign-on with your product so voters are automatically identified, which is what makes segmentation genuinely useful and is an integration per product. - Moderation at scale, which becomes a real job the moment a board is public and popular. - Multi-language boards, where machine translation of feature requests loses precisely the nuance that matters. - Deep integrations with issue trackers, which is the part the hosted products sell. ### README must contain - That posting requires no account, because the friction costs you the feedback - That one vote per voter is enforced by a database constraint rather than application logic - That suspicious bursts are quarantined and visible rather than silently deleted - That merging counts the union of voters rather than adding totals - That vote counts are shown by segment because a raw total is a popularity contest
# Architecture · Nolt ## Stack | Part | Choice | Why | | --- | --- | --- | | Runtime | TypeScript with Fastify and PostgreSQL | boards, posts and votes are relational and small | | Identity | Optional accounts, with a durable per-browser identity for anonymous voting | requiring an account collapses submission volume, and allowing none collapses integrity | | Integrity | Rate limits, duplicate detection and vote weighting by segment | a raw count is trivially gamed and misleading even when it is not | | Merging | Duplicate posts merged with votes combined and voters deduplicated | the same request arrives ten times in ten wordings | | Status | Explicit statuses with a stated meaning, and no implied commitment | a roadmap column is read as a promise whether or not you meant one | ## Modules Each module has one owner concern and a documented way to replace it. | Module | Owns | How to replace it | | --- | --- | --- | | Identity | Anonymous and account voters. | Any scheme, provided anonymous posting stays possible and identity is durable enough to vote once. | | Integrity | Uniqueness, rate limits and quarantine. | Any implementation, provided uniqueness is a constraint and suspicious votes are kept rather than deleted. | | Merging | Combining duplicates by voter union. | Any implementation. Adding totals is the mistake to avoid. | | Statuses | Published meanings and append-only history. | Any set of statuses, provided each has a stated meaning and reversals are visible. | | Segments | Vote breakdown beside the total. | Any segmentation. Showing only the total is what makes a board misleading. | ## Configuration Every runtime setting is an environment variable documented in `.env.example`, validated at startup, with a safe local default wherever one exists. - `DATABASE_URL` · required, secret · Posts, votes and voter identities. - `ALLOW_ANONYMOUS_POSTS` · required · Requiring accounts collapses submission volume. This is the trade, made explicit. - `VOTE_RATE_LIMIT_PER_HOUR` · required · Per identity and per network. Generous for a person, tedious for a script. - `BURST_QUARANTINE_THRESHOLD` · required · Votes from one source in a short window before quarantine. Quarantine, not deletion. - `STALE_STATUS_DAYS` · required · How long an item may hold a status before being surfaced to you as stale. - `SMTP_URL` · optional, secret · Status change notifications. Absent means the board works without them. ## Production baseline - Security: least privilege, input validation at every boundary, secret redaction in logs, rate limits on abuse-prone paths, no invented security primitives. - Data: explicit schema and migrations, transactional writes where integrity matters, backup and restore procedures that have been exercised. - Integrations: adapters around third-party providers, idempotent webhook or job processing, bounded retries, timeouts. - Observability: structured logs with request or operation ids, an error-tracking hook, and health and readiness checks where a server exists. - Quality: unit tests for domain rules, integration tests at module boundaries, one end-to-end test of the critical path. ## Decision records For each dependency in the stack table, keep a short note: why it was chosen, its failure mode, and how it is replaced. Do not add infrastructure until a requirement in `PRODUCT.md` justifies it.
# Agent instructions · Nolt product build - Read `PRODUCT.md` and `ARCHITECTURE.md` before changing code. The stack is fixed: TypeScript with Fastify and PostgreSQL, Optional accounts, with a durable per-browser identity for anonymous voting, Rate limits, duplicate detection and vote weighting by segment, Duplicate posts merged with votes combined and voters deduplicated, Explicit statuses with a stated meaning, and no implied commitment. - Implement milestone by milestone from `MILESTONES.md`; keep each change reviewable and leave the application runnable at every commit. - Treat authentication, payments, encryption, imports, webhooks and destructive actions as high-risk boundaries when present. - Never invent cryptography or silently weaken a requirement to make a check pass. - Put every external service behind an interface with a deterministic fake for tests. - Add migrations and rollback or recovery notes for every persistent data change. - Log useful operational context without credentials, tokens, passwords or personal data. - Update documentation and run every check before completing a milestone. ## Known traps - Requiring an account before someone can post removes most of the feedback you would have received, and the people who do sign up are not a representative sample of your users. Let them post immediately, and offer the account for the thing accounts are actually good for, which is being told what happened. - Silently discarding suspected fraudulent votes means you cannot tell the difference between an attack and a legitimate burst from a customer who told their team to go and vote, and the second is exactly the signal you wanted. Flag and quarantine for review rather than deleting, so the distinction stays available. - Merging by adding the vote counts double-counts everyone who voted for both posts, which inflates the merged total and moves a request up the board on votes that do not exist. Combine the voter sets and count the union. - A planned column with no dates and no movement for eight months is read by users as a broken promise, and by the operator as an accurate representation of intent, and both are behaving reasonably. Show how long an item has held its status, and surface the stale ones so the board reflects what is actually happening. - Ranking by raw vote count means the loudest audience decides the roadmap, which is frequently not the paying one and almost never the silent majority. Show the breakdown by segment beside the total, so a request with forty votes from non-customers and one from your largest account is legible as what it is.
# Delivery milestones · Nolt Estimated effort: **weekend** for the indie phases; the production-only milestones add the trust and operability layer. ## M1 · A board people will actually post on Someone can submit a request in under a minute without creating an account. ### Steps 1. Allow posting with no account The friction is what you are trading feedback volume against. Files: `src/posts.ts` 2. Assign a durable anonymous identity Persisting across restarts, so voting can be one per person. Files: `src/voters.ts` 3. Let an account claim prior activity Someone signing up later should not lose their votes. Files: `src/claim.ts` ### Done when - [ ] A post is submitted in under a minute with no account - [ ] Anonymous identity survives a browser restart - [ ] An account claims prior anonymous votes - [ ] Empty submissions are rejected usefully ### Watch out - Requiring an account before someone can post removes most of the feedback you would have received, and the people who do sign up are not a representative sample of your users. Let them post immediately, and offer the account for the thing accounts are actually good for, which is being told what happened. ## M2 · Votes that are hard to fake Voting is easy for a person and tedious for a script. ### Steps 1. Enforce one vote per voter in the schema A unique constraint, not an application check. Files: `src/schema.sql` 2. Rate limit by network and identity Both, since either alone is trivially avoided. Files: `src/ratelimit.ts` 3. Quarantine rather than delete A burst may be an attack or a customer's whole team. Keep the distinction. Files: `src/integrity.ts` ### Done when - [ ] Duplicate votes are impossible at the database level - [ ] Rate limits apply by network and identity - [ ] Suspicious bursts are quarantined and visible - [ ] Clearing storage does not grant a second vote ### Watch out - Silently discarding suspected fraudulent votes means you cannot tell the difference between an attack and a legitimate burst from a customer who told their team to go and vote, and the second is exactly the signal you wanted. Flag and quarantine for review rather than deleting, so the distinction stays available. ## M3 · The same request, ten times over Duplicates are found and merged without losing anyone's vote. ### Steps 1. Count the union of voters on merge Adding the totals double-counts anyone who voted for both. Files: `src/merge.ts`, `src/merge.test.ts` 2. Suggest duplicates at submission The cheapest moment to prevent one is before it is written. Files: `src/duplicates.ts` 3. Keep merged posts reachable Their author should find out where their request went. Files: `src/posts.ts` ### Done when - [ ] A merge counts the union of voters - [ ] The merged post stays reachable - [ ] Duplicates are suggested at submission - [ ] A merge can be undone completely ### Watch out - Merging by adding the vote counts double-counts everyone who voted for both posts, which inflates the merged total and moves a request up the board on votes that do not exist. Combine the voter sets and count the union. ## M4 · Statuses that do not promise things A roadmap communicates intent without being read as a commitment. ### Steps 1. Publish what each status means On the board itself. Users infer meanings otherwise, usually optimistic ones. Files: `src/statuses.ts` 2. Keep status history append only A silent reversal from planned to open is worse than never planning it. Files: `src/status-changes.ts` 3. Surface stale items Eight months in planned is information, and mostly for you. Files: `src/staleness.ts` ### Done when - [ ] Each status has a published meaning - [ ] Status history is append only and reversals visible - [ ] Stale items are surfaced to the operator - [ ] No dates appear unless deliberately entered ### Watch out - A planned column with no dates and no movement for eight months is read by users as a broken promise, and by the operator as an accurate representation of intent, and both are behaving reasonably. Show how long an item has held its status, and surface the stale ones so the board reflects what is actually happening. ## M5 · Reading the board honestly (production only) You can see who wants what, not just how loud the total is. ### Steps 1. Show votes by segment Beside the total, not instead of it. The breakdown is the useful part. Files: `src/segments.ts` 2. Notify on status change It is the main reason anyone creates an account here. Files: `src/notify.ts` 3. Export the whole board Posts, votes, merges and history. It is your users' input, not yours. Files: `src/export.ts` ### Done when - [ ] Vote counts display by segment and total - [ ] Status change notifications send and honour unsubscribe - [ ] Export contains posts, votes, merges and history - [ ] A thousand posts sort quickly ### Watch out - Ranking by raw vote count means the loudest audience decides the roadmap, which is frequently not the paying one and almost never the silent majority. Show the breakdown by segment beside the total, so a request with forty votes from non-customers and one from your largest account is legible as what it is.
# Operations · Nolt ## Backup A feedback board is your users' input and a record of what you said you would do. Back up posts, votes and status history, since the last is the part you may be asked about. ## Restore Restore and confirm merges and status history survived, since losing a merge fragments requests back into duplicates and losing status history erases the record of what was promised. Do a restore drill before the first real user, and write the date here when it passes. ## Monitoring Quarantined vote volume and submission rate. A sudden burst of both usually means the board has been linked somewhere public, which is worth knowing before you interpret the numbers. ## Incident checklist A request that jumped the board on inflated votes means either merging added totals or quarantine is not catching a source. Both are visible in the vote table, which is why quarantined votes are kept. 1. Contain the issue without destroying evidence or user data. 2. Record the timeline and affected scope. 3. Rotate exposed secrets and revoke compromised sessions or credentials. 4. Restore from a verified backup when needed. 5. Document the root cause, the remediation and the regression test. ## Release gate - [ ] A duplicate vote is confirmed impossible at the database level - [ ] A merge is confirmed to count the union of voters - [ ] Suspicious bursts are confirmed quarantined rather than deleted - [ ] Every status is confirmed to have a published meaning - [ ] Vote counts are confirmed shown by segment ## Launch constraint Do not market omitted Nolt capabilities as implemented. The non-goals in `PRODUCT.md` remain user-visible limitations until they are deliberately delivered.
# Copy to .env and fill in. Never commit .env; this file documents it. # Required · secret. Posts, votes and voter identities. DATABASE_URL=postgres://localhost:5432/feedback # Required. Requiring accounts collapses submission volume. This is the trade, made explicit. ALLOW_ANONYMOUS_POSTS=true # Required. Per identity and per network. Generous for a person, tedious for a script. VOTE_RATE_LIMIT_PER_HOUR=20 # Required. Votes from one source in a short window before quarantine. Quarantine, not deletion. BURST_QUARANTINE_THRESHOLD=10 # Required. How long an item may hold a status before being surfaced to you as stale. STALE_STATUS_DAYS=120 # Optional · secret. Status change notifications. Absent means the board works without them. SMTP_URL=smtp://user:pass@host:587
$ choose a build depth, inspect the files, then open the complete pack in your agent
People still pay for Nolt because people pay either for access to participants or for a research repository that keeps evidence usable across an organization. The recurring cost buys recruitment, consent, scheduling, media storage, transcription, tagging consistency, notifications, search, and retention, not just the visible interface.
xhosted simplicity, custom domains, integrations, and low maintenance
xlarge participant panel
xrecording and transcription infrastructure
xadvanced research repository
xenterprise governance
Don't feel like building it? These folks already made it free.
all 4 free alternatives to Nolt →· no votes, no pay-to-list · just what's real
Nolt pricing
| plan | monthly | annual (per mo) | what you get |
|---|---|---|---|
| essential | — | $29/workspace | 1 board; unlimited users, content and admins. |
| pro | — | $69/workspace | 5 boards; unlimited users, content and admins. |
| enterprise | custom | — | Unlimited boards plus custom security, support and procurement terms. |
free tierno free tier
billingAnnual prices are public and save more than 20%; a monthly toggle exists, but exact month-to-month prices were not exposed. A 10-day Pro trial needs no card.
hidden costsSwitching plans is prorated; refunds are not offered.
verified 2026-08-12 · source ↗
Vibecode Nolt
Yes. A competent AI coding agent (Claude Code, Codex, Cursor) can build a usable personal Nolt replacement in one session with the prompt on this page. It runs on your own machine or server with no subscription.
How much does Nolt cost?
Nolt costs about $29/month (Essential, checked 2026-07-31), which is $348 per year. That's what you save by replacing it with one prompt.
What do I lose by replacing Nolt?
Honestly: hosted simplicity, custom domains, integrations, and low maintenance; large participant panel; recording and transcription infrastructure; advanced research repository; enterprise governance. If any of those are load-bearing for you, keep paying.
Is there an open-source alternative to Nolt?
Yes: ClearFlask (The full board-and-roadmap job, self-hosted with more infrastructure than Nolt ever mentions.) Quackback (Voting, comments, statuses and a public roadmap in one Compose stack.) Sleekplan Indie (Unlimited feedback, voting and a roadmap for one admin; custom workflow controls are paid.) All 4 curated free alternatives are at vibecodeit.com/nolt/alternatives. The prompt is for when you want it exactly your way.