Vibecode SurveySparrow
track this build4 phases, 12 steps, beginner friendly0%The visible experience surveys loop is buildable, but a credible replacement needs more than the first screen. SurveySparrow earns its keep through delivery, integrations, compliance, so expect a weekend or multi-day build and a narrower personal scope.
You are building a lean indie version of SurveySparrow. 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 ===== # SurveySparrow · indie build A self-hosted survey tool whose hard problems are editing a live survey without rewriting the meaning of yesterday's answers, never losing or double counting a submission from an open public endpoint, and reporting standard metrics by their real definitions with the base count attached. 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 | Next.js with the response endpoint as the only publicly reachable surface | everything else can sit behind one local login | | Storage | SQLite, responses immutable once submitted | a corrected response is a new response, not a rewritten one | | Versioning | Surveys frozen on publish, every version retained, responses pinned to one | otherwise a wording change quietly rewrites last week | | Submission | A per response token with idempotent submit and server side partial saves | a retried request must not become a second answer | | Abuse | Per link caps, address rate limits and a payload size limit, no hidden trap fields | an open endpoint gets found, and honeypots punish assistive technology | ## 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 application and the response endpoint. - Get it: nodejs.org, or nvm install 22. - Verify: node --version prints v22 or higher - [ ] **A host the public can reach** · a few pounds a month - Why: The response endpoint has to be reachable by respondents, which also means reachable by everyone else, which is what phase four is about. - Get it: A small VPS or any platform that will run a Node process with a persistent disk for SQLite. - Verify: A share link opens from a phone on mobile data - [ ] **Your retention window and anonymity default** · free - Why: Both are asserted by tests, and choosing them after seeing the data means choosing whatever is convenient. - Get it: Write down how long responses live, what an anonymous link collects, and what the response cap is by default. - Verify: All three appear in the configuration and are named in the tests - [ ] **Three people who will actually answer it** · free - Why: Resume, idempotency and the partial save path only fail in the ways that matter when a real person closes a laptop mid survey. - Get it: Ask three people to answer a long survey on their own devices, and ask one to abandon it halfway. - Verify: One response resumes from a partial and one was submitted twice without duplicating - [ ] **An SMTP credential you already own** (optional) · free or pennies - Why: Only for notifying yourself that responses arrived; sending to a list is explicitly out of scope. - Get it: Any mail account with SMTP access, or leave it unset and use the interface. - Verify: A test notification arrives in your own inbox ## 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: - Bulk sending to a contact list. - Employee engagement and 360 workflows. - An integration catalogue. - Respondent file uploads. - enterprise workflow logic - spam and abuse defense at scale - large integration and template catalog - compliance controls and guaranteed delivery If one of those is essential to you, that is the reason to keep paying for SurveySparrow, and the README should say so rather than pretend. ===== BRIEF.md ===== # Build brief · SurveySparrow 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 survey tool that versions its questions and reports numbers you can defend to replace SurveySparrow. 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. ### Rule zero Two rules, and they are the same rule pointed at different things. A response answers one specific version of a survey. Editing a live question changes what every earlier answer meant, so an edit publishes a new version and each stored response records the version it answered. A chart that mixes versions without saying so is not defensible. If you report a Net Promoter Score, compute it the way its owners define it: promoters answered 9 or 10, detractors answered 0 to 6, the 7 and 8 passives count toward the base only, and the score is the promoter percentage minus the detractor percentage, landing between -100 and +100. It is not the mean of the answers. Net Promoter and NPS are registered trademarks of Bain and Company, NICE Systems and Fred Reichheld, so if your calculation differs in any way, give your number a different name and define it on the page where it appears. ### Stack (fixed, do not substitute) - Runtime: Next.js with the response endpoint as the only publicly reachable surface · everything else can sit behind one local login - Storage: SQLite, responses immutable once submitted · a corrected response is a new response, not a rewritten one - Versioning: Surveys frozen on publish, every version retained, responses pinned to one · otherwise a wording change quietly rewrites last week - Submission: A per response token with idempotent submit and server side partial saves · a retried request must not become a second answer - Abuse: Per link caps, address rate limits and a payload size limit, no hidden trap fields · an open endpoint gets found, and honeypots punish assistive technology ### Data model (create this before Phase 1) - surveys(id, title, created_at, archived_at) - survey_versions(id, survey_id, version, schema_json, published_at, closed_at, response_cap) · immutable once published - questions inside schema_json(id, kind, prompt, required, options_json, scale_min, scale_max) · question ids stay stable across versions - responses(id, survey_version_id, token, started_at, submitted_at, partial_json, answers_json, locale) · submitted rows are never updated - answers inside answers_json(question_id, value, skipped) · keyed by the stable question id, never by position - events(id, response_id, kind, at, reason) · started, resumed, rejected, submitted, with the reason a rejection happened - Because question ids are stable, a chart can follow one question across three versions and mark honestly where the wording changed. ### Phase 1 · A survey you can edit without rewriting history Build: An editor that produces a schema. Publishing freezes it as a version with its own share link. Editing a published survey creates the next version rather than changing the current one, and both stay answerable until closed. Question ids persist across versions. Trap: Editing the wording of a live question in place means last week's answers now sit under a question nobody was asked, and this is discovered in the meeting where the chart is presented. Freeze on publish, version on edit, keep question ids stable, and show the version beside every count. Done when: Editing a published survey produces a new version and leaves the old schema byte identical, tested. A response stores the version id it answered and that link cannot be changed afterwards. Question ids survive an edit that changes wording, so answers stay attached to the question. The interface shows which versions are open and how many responses each holds. Do not build yet: No responses, scoring, limits or export yet. ### Phase 2 · A response that survives a closed laptop Build: Issue a token on first load and save partial answers server side as the respondent moves through pages. Submission writes the response and its event in one transaction. A second submission with the same token returns the first result instead of creating another response. Trap: Holding partial answers only in the browser loses a ten minute survey to a tab crash and nobody starts again, while accepting a retried submission as a fresh row inflates every count by however many people had a flaky connection. Persist partials against the token and make submit idempotent on that token. Done when: Submitting twice with the same token produces exactly one response, tested by replaying the request. A partial response resumes from another browser using the link with its token and keeps every answer already given. A submitted response cannot be updated by any code path, asserted in the suite. Every rejection writes an event with the reason, and nothing is silently discarded. Do not build yet: No scoring, caps or export yet. ### Phase 3 · Numbers with a definition and a base Build: Compute the promoter score exactly as rule zero defines it, showing the promoter, passive and detractor counts alongside it. Offer a mean only for scales where a mean has meaning. Print the base count beside every figure and mark any chart that spans a wording change. Trap: Reporting a score from eleven responses as one number with a decimal place invites a decision it cannot carry, and computing a promoter score as the average of the zero to ten answers gives a figure in the wrong range that nobody can reconcile with anyone else's. Compute from the bands, show the base, and round to a whole number. Done when: The promoter score for a known response set matches a hand calculation, with 9 and 10 as promoters and 0 to 6 as detractors, tested with a fixture. The result is a whole number between -100 and +100 and the three band counts are shown with it. Every figure in the interface displays its base count. A chart spanning two versions marks where the wording changed rather than smoothing over it. Do not build yet: No caps, retention or export yet. ### Phase 4 · An open endpoint on the public internet Build: Rate limit per link and per address, cap the payload size, and let a link close at a response cap or a date. Reject loudly with a reason recorded rather than dropping quietly. Use no hidden trap fields and no third party challenge. Trap: An uncapped public form is a free database write for whoever finds it, and the reflex fix is a hidden field that penalises anyone using a screen reader or a password manager. Cap the link, rate limit by address and token, record why each rejection happened, and keep every anti abuse measure visible to the respondent. Done when: A link at its response cap refuses new submissions with a clear message and records the refusals. Requests over the rate limit are rejected with a reason in events, tested by hammering the endpoint. A payload above the size limit is refused before parsing. No hidden field, timing trap or third party challenge exists anywhere in the response form, verified by review. Do not build yet: No retention, anonymity handling or export yet. ### Phase 5 · Answers you can take away, and promises you can keep Build: Export CSV and JSON keyed by stable question ids with the version on every row. A retention setting that deletes rather than hides. A per link anonymity flag where, when it is on, the collection code never receives the address at all and the interface says so to the respondent. Trap: Telling respondents a survey is anonymous while the web server writes their address to an access log makes the promise false in a way that only surfaces when somebody asks for the logs. Decide anonymity per link, and when it is on, do not collect the address anywhere in the stack rather than collecting and discarding it. Done when: An export round trips: exported CSV re-imported reproduces the same answer values keyed by question id. An anonymous link leaves no address in the database or in any log the application writes, verified by grepping both after a test submission. Retention deletes rows past the window and writes one line saying how many, exercised in the suite. The respondent sees which kind of link they are answering before they start. Do not build yet: Nothing after this. ### Out of scope (and why) - Sending the survey to a large contact list yourself, which turns a survey tool into a bulk mail sender with a reputation to maintain and a separate set of rules to follow. - Employee engagement and 360 review workflows, which are a permissions and confidentiality problem rather than a forms problem. - A catalogue of integrations, where each one is a credential to store, a schema to track and a support burden forever. - File uploads from respondents, which hands anonymous strangers a write path into your storage and needs scanning and quarantine to be safe. ### README must contain - That publishing freezes a version and editing creates a new one, and why answers are pinned to versions - The exact promoter score calculation used, the band boundaries, and that the trademark belongs to its owners - That submission is idempotent on a token, so a retry cannot become a second response - Which anti abuse measures are in place, and that no hidden trap fields are used - What an anonymous link does and does not collect, including what reaches the application logs ===== AGENTS.md ===== # Agent instructions · SurveySparrow indie build - Read `README.md` and `BUILD_PLAN.md` before writing code. The stack is fixed: Next.js with the response endpoint as the only publicly reachable surface, SQLite, responses immutable once submitted, Surveys frozen on publish, every version retained, responses pinned to one, A per response token with idempotent submit and server side partial saves, Per link caps, address rate limits and a payload size limit, no hidden trap fields. 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 - Editing the wording of a live question in place means last week's answers now sit under a question nobody was asked, and this is discovered in the meeting where the chart is presented. Freeze on publish, version on edit, keep question ids stable, and show the version beside every count. - Holding partial answers only in the browser loses a ten minute survey to a tab crash and nobody starts again, while accepting a retried submission as a fresh row inflates every count by however many people had a flaky connection. Persist partials against the token and make submit idempotent on that token. - Reporting a score from eleven responses as one number with a decimal place invites a decision it cannot carry, and computing a promoter score as the average of the zero to ten answers gives a figure in the wrong range that nobody can reconcile with anyone else's. Compute from the bands, show the base, and round to a whole number. - An uncapped public form is a free database write for whoever finds it, and the reflex fix is a hidden field that penalises anyone using a screen reader or a password manager. Cap the link, rate limit by address and token, record why each rejection happened, and keep every anti abuse measure visible to the respondent. ===== BUILD_PLAN.md ===== # Build plan · SurveySparrow A self-hosted survey tool whose hard problems are editing a live survey without rewriting the meaning of yesterday's answers, never losing or double counting a submission from an open public endpoint, and reporting standard metrics by their real definitions with the base count attached. 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 survey you can edit without rewriting history Publishing freezes a version and every response points at exactly one. ### Steps 1. Freeze the schema on publish A published version is read only for the rest of its life. Files: `src/versions.ts`, `tests/version-immutable.test.ts` 2. Keep question ids stable across versions Otherwise answers are keyed to a position that moved. Files: `src/schema.ts` 3. Show the version everywhere a count appears The number is meaningless without knowing which question produced it. Files: `src/app/admin/page.tsx` ### Done when - [ ] Editing creates a new version, old schema unchanged - [ ] Each response records its version id permanently - [ ] Question ids survive a wording edit - [ ] Counts display the version they came from ### Watch out - Editing the wording of a live question in place means last week's answers now sit under a question nobody was asked, and this is discovered in the meeting where the chart is presented. Freeze on publish, version on edit, keep question ids stable, and show the version beside every count. ## Phase 2 · A response that survives a closed laptop A half finished response resumes and a submitted one is never duplicated. ### Steps 1. Persist partials against a token Server side, because the browser is where the answers get lost. Files: `src/app/api/respond/route.ts` 2. Make submit idempotent on the token A retry after a timeout is the common case, not the rare one. Files: `src/submit.ts`, `tests/idempotent-submit.test.ts` 3. Record every rejection with a reason A response you refused is still something you need to be able to explain. Files: `src/events.ts` ### Done when - [ ] A replayed submission creates one response - [ ] A partial resumes on another browser - [ ] Submitted responses are immutable - [ ] Rejections are recorded with reasons ### Watch out - Holding partial answers only in the browser loses a ten minute survey to a tab crash and nobody starts again, while accepting a retried submission as a fresh row inflates every count by however many people had a flaky connection. Persist partials against the token and make submit idempotent on that token. ## Phase 3 · Numbers with a definition and a base Every figure reported carries its calculation and how many people it came from. ### Steps 1. Compute from the bands, not the mean The definition is public and the wrong calculation is obvious to anyone who knows it. Files: `src/scoring.ts`, `tests/nps.test.ts` 2. Show the base count beside every number Eleven responses and eleven thousand should not look the same. Files: `src/components/Figure.tsx` 3. Mark version boundaries on charts A wording change is a discontinuity, so draw it as one. Files: `src/charts.ts` ### Done when - [ ] The score matches a hand calculation on a fixture - [ ] The result is a whole number in range with band counts - [ ] Every figure shows its base count - [ ] Charts mark version boundaries ### Watch out - Reporting a score from eleven responses as one number with a decimal place invites a decision it cannot carry, and computing a promoter score as the average of the zero to ten answers gives a figure in the wrong range that nobody can reconcile with anyone else's. Compute from the bands, show the base, and round to a whole number. ## Phase 4 · An open endpoint on the public internet Collecting without accounts survives being discovered. ### Steps 1. Cap the link by responses and by date The simplest defence is a survey that is closed. Files: `src/limits.ts` 2. Limit size before parsing Refuse the body you have not read rather than the object you built from it. Files: `src/app/api/respond/route.ts` 3. Refuse visibly, never silently A respondent who thinks they submitted and did not is worse than one who was told no. Files: `src/app/respond/page.tsx` ### Done when - [ ] A capped link refuses and records refusals - [ ] Rate limited requests are rejected with reasons - [ ] Oversized payloads are refused before parsing - [ ] No hidden trap fields or third party challenges exist ### Watch out - An uncapped public form is a free database write for whoever finds it, and the reflex fix is a hidden field that penalises anyone using a screen reader or a password manager. Cap the link, rate limit by address and token, record why each rejection happened, and keep every anti abuse measure visible to the respondent. ## Not in this build - Bulk sending to a contact list. - Employee engagement and 360 workflows. - An integration catalogue. - Respondent file uploads. ## After v1, if you want it - One question per screen as a presentation option, which is a rendering change and nothing more - Branching logic expressed inside the frozen schema, so a branch is versioned like everything else - A public results page per version, once you are sure what the base counts say ===== .env.example ===== # Copy to .env and fill in. Never commit .env; this file documents it. # Required. Surveys, versions, responses and events. DATABASE_PATH=./data/surveys.db # Required. Used to build share links. Wrong here means links that go nowhere. PUBLIC_BASE_URL=https://surveys.example.com # Required · secret. The only login. The response endpoint is the only thing outside it. ADMIN_PASSWORD=a long random string # Required · secret. Signs response tokens, which carry idempotency and resume. RESPONSE_TOKEN_SECRET=sk-...redacted # Required. Per address, per link. Rejections are recorded with a reason. RATE_LIMIT_PER_HOUR=60 # Required. Refused before parsing rather than after. MAX_PAYLOAD_BYTES=65536 # Required. Responses are deleted past this, and the count is logged. RETENTION_DAYS=730 # Optional · secret. Optional, and only for notifying yourself. Unset means the interface only. SMTP_URL=smtps://user:pass@smtp.example.com:465
You are building a lean indie version of SurveySparrow. 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 ===== # SurveySparrow · indie build A self-hosted survey tool whose hard problems are editing a live survey without rewriting the meaning of yesterday's answers, never losing or double counting a submission from an open public endpoint, and reporting standard metrics by their real definitions with the base count attached. 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 | Next.js with the response endpoint as the only publicly reachable surface | everything else can sit behind one local login | | Storage | SQLite, responses immutable once submitted | a corrected response is a new response, not a rewritten one | | Versioning | Surveys frozen on publish, every version retained, responses pinned to one | otherwise a wording change quietly rewrites last week | | Submission | A per response token with idempotent submit and server side partial saves | a retried request must not become a second answer | | Abuse | Per link caps, address rate limits and a payload size limit, no hidden trap fields | an open endpoint gets found, and honeypots punish assistive technology | ## 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 application and the response endpoint. - Get it: nodejs.org, or nvm install 22. - Verify: node --version prints v22 or higher - [ ] **A host the public can reach** · a few pounds a month - Why: The response endpoint has to be reachable by respondents, which also means reachable by everyone else, which is what phase four is about. - Get it: A small VPS or any platform that will run a Node process with a persistent disk for SQLite. - Verify: A share link opens from a phone on mobile data - [ ] **Your retention window and anonymity default** · free - Why: Both are asserted by tests, and choosing them after seeing the data means choosing whatever is convenient. - Get it: Write down how long responses live, what an anonymous link collects, and what the response cap is by default. - Verify: All three appear in the configuration and are named in the tests - [ ] **Three people who will actually answer it** · free - Why: Resume, idempotency and the partial save path only fail in the ways that matter when a real person closes a laptop mid survey. - Get it: Ask three people to answer a long survey on their own devices, and ask one to abandon it halfway. - Verify: One response resumes from a partial and one was submitted twice without duplicating - [ ] **An SMTP credential you already own** (optional) · free or pennies - Why: Only for notifying yourself that responses arrived; sending to a list is explicitly out of scope. - Get it: Any mail account with SMTP access, or leave it unset and use the interface. - Verify: A test notification arrives in your own inbox ## 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: - Bulk sending to a contact list. - Employee engagement and 360 workflows. - An integration catalogue. - Respondent file uploads. - enterprise workflow logic - spam and abuse defense at scale - large integration and template catalog - compliance controls and guaranteed delivery If one of those is essential to you, that is the reason to keep paying for SurveySparrow, and the README should say so rather than pretend. ===== BRIEF.md ===== # Build brief · SurveySparrow 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 survey tool that versions its questions and reports numbers you can defend to replace SurveySparrow. 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. ### Rule zero Two rules, and they are the same rule pointed at different things. A response answers one specific version of a survey. Editing a live question changes what every earlier answer meant, so an edit publishes a new version and each stored response records the version it answered. A chart that mixes versions without saying so is not defensible. If you report a Net Promoter Score, compute it the way its owners define it: promoters answered 9 or 10, detractors answered 0 to 6, the 7 and 8 passives count toward the base only, and the score is the promoter percentage minus the detractor percentage, landing between -100 and +100. It is not the mean of the answers. Net Promoter and NPS are registered trademarks of Bain and Company, NICE Systems and Fred Reichheld, so if your calculation differs in any way, give your number a different name and define it on the page where it appears. ### Stack (fixed, do not substitute) - Runtime: Next.js with the response endpoint as the only publicly reachable surface · everything else can sit behind one local login - Storage: SQLite, responses immutable once submitted · a corrected response is a new response, not a rewritten one - Versioning: Surveys frozen on publish, every version retained, responses pinned to one · otherwise a wording change quietly rewrites last week - Submission: A per response token with idempotent submit and server side partial saves · a retried request must not become a second answer - Abuse: Per link caps, address rate limits and a payload size limit, no hidden trap fields · an open endpoint gets found, and honeypots punish assistive technology ### Data model (create this before Phase 1) - surveys(id, title, created_at, archived_at) - survey_versions(id, survey_id, version, schema_json, published_at, closed_at, response_cap) · immutable once published - questions inside schema_json(id, kind, prompt, required, options_json, scale_min, scale_max) · question ids stay stable across versions - responses(id, survey_version_id, token, started_at, submitted_at, partial_json, answers_json, locale) · submitted rows are never updated - answers inside answers_json(question_id, value, skipped) · keyed by the stable question id, never by position - events(id, response_id, kind, at, reason) · started, resumed, rejected, submitted, with the reason a rejection happened - Because question ids are stable, a chart can follow one question across three versions and mark honestly where the wording changed. ### Phase 1 · A survey you can edit without rewriting history Build: An editor that produces a schema. Publishing freezes it as a version with its own share link. Editing a published survey creates the next version rather than changing the current one, and both stay answerable until closed. Question ids persist across versions. Trap: Editing the wording of a live question in place means last week's answers now sit under a question nobody was asked, and this is discovered in the meeting where the chart is presented. Freeze on publish, version on edit, keep question ids stable, and show the version beside every count. Done when: Editing a published survey produces a new version and leaves the old schema byte identical, tested. A response stores the version id it answered and that link cannot be changed afterwards. Question ids survive an edit that changes wording, so answers stay attached to the question. The interface shows which versions are open and how many responses each holds. Do not build yet: No responses, scoring, limits or export yet. ### Phase 2 · A response that survives a closed laptop Build: Issue a token on first load and save partial answers server side as the respondent moves through pages. Submission writes the response and its event in one transaction. A second submission with the same token returns the first result instead of creating another response. Trap: Holding partial answers only in the browser loses a ten minute survey to a tab crash and nobody starts again, while accepting a retried submission as a fresh row inflates every count by however many people had a flaky connection. Persist partials against the token and make submit idempotent on that token. Done when: Submitting twice with the same token produces exactly one response, tested by replaying the request. A partial response resumes from another browser using the link with its token and keeps every answer already given. A submitted response cannot be updated by any code path, asserted in the suite. Every rejection writes an event with the reason, and nothing is silently discarded. Do not build yet: No scoring, caps or export yet. ### Phase 3 · Numbers with a definition and a base Build: Compute the promoter score exactly as rule zero defines it, showing the promoter, passive and detractor counts alongside it. Offer a mean only for scales where a mean has meaning. Print the base count beside every figure and mark any chart that spans a wording change. Trap: Reporting a score from eleven responses as one number with a decimal place invites a decision it cannot carry, and computing a promoter score as the average of the zero to ten answers gives a figure in the wrong range that nobody can reconcile with anyone else's. Compute from the bands, show the base, and round to a whole number. Done when: The promoter score for a known response set matches a hand calculation, with 9 and 10 as promoters and 0 to 6 as detractors, tested with a fixture. The result is a whole number between -100 and +100 and the three band counts are shown with it. Every figure in the interface displays its base count. A chart spanning two versions marks where the wording changed rather than smoothing over it. Do not build yet: No caps, retention or export yet. ### Phase 4 · An open endpoint on the public internet Build: Rate limit per link and per address, cap the payload size, and let a link close at a response cap or a date. Reject loudly with a reason recorded rather than dropping quietly. Use no hidden trap fields and no third party challenge. Trap: An uncapped public form is a free database write for whoever finds it, and the reflex fix is a hidden field that penalises anyone using a screen reader or a password manager. Cap the link, rate limit by address and token, record why each rejection happened, and keep every anti abuse measure visible to the respondent. Done when: A link at its response cap refuses new submissions with a clear message and records the refusals. Requests over the rate limit are rejected with a reason in events, tested by hammering the endpoint. A payload above the size limit is refused before parsing. No hidden field, timing trap or third party challenge exists anywhere in the response form, verified by review. Do not build yet: No retention, anonymity handling or export yet. ### Phase 5 · Answers you can take away, and promises you can keep Build: Export CSV and JSON keyed by stable question ids with the version on every row. A retention setting that deletes rather than hides. A per link anonymity flag where, when it is on, the collection code never receives the address at all and the interface says so to the respondent. Trap: Telling respondents a survey is anonymous while the web server writes their address to an access log makes the promise false in a way that only surfaces when somebody asks for the logs. Decide anonymity per link, and when it is on, do not collect the address anywhere in the stack rather than collecting and discarding it. Done when: An export round trips: exported CSV re-imported reproduces the same answer values keyed by question id. An anonymous link leaves no address in the database or in any log the application writes, verified by grepping both after a test submission. Retention deletes rows past the window and writes one line saying how many, exercised in the suite. The respondent sees which kind of link they are answering before they start. Do not build yet: Nothing after this. ### Out of scope (and why) - Sending the survey to a large contact list yourself, which turns a survey tool into a bulk mail sender with a reputation to maintain and a separate set of rules to follow. - Employee engagement and 360 review workflows, which are a permissions and confidentiality problem rather than a forms problem. - A catalogue of integrations, where each one is a credential to store, a schema to track and a support burden forever. - File uploads from respondents, which hands anonymous strangers a write path into your storage and needs scanning and quarantine to be safe. ### README must contain - That publishing freezes a version and editing creates a new one, and why answers are pinned to versions - The exact promoter score calculation used, the band boundaries, and that the trademark belongs to its owners - That submission is idempotent on a token, so a retry cannot become a second response - Which anti abuse measures are in place, and that no hidden trap fields are used - What an anonymous link does and does not collect, including what reaches the application logs ===== AGENTS.md ===== # Agent instructions · SurveySparrow indie build - Read `README.md` and `BUILD_PLAN.md` before writing code. The stack is fixed: Next.js with the response endpoint as the only publicly reachable surface, SQLite, responses immutable once submitted, Surveys frozen on publish, every version retained, responses pinned to one, A per response token with idempotent submit and server side partial saves, Per link caps, address rate limits and a payload size limit, no hidden trap fields. 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 - Editing the wording of a live question in place means last week's answers now sit under a question nobody was asked, and this is discovered in the meeting where the chart is presented. Freeze on publish, version on edit, keep question ids stable, and show the version beside every count. - Holding partial answers only in the browser loses a ten minute survey to a tab crash and nobody starts again, while accepting a retried submission as a fresh row inflates every count by however many people had a flaky connection. Persist partials against the token and make submit idempotent on that token. - Reporting a score from eleven responses as one number with a decimal place invites a decision it cannot carry, and computing a promoter score as the average of the zero to ten answers gives a figure in the wrong range that nobody can reconcile with anyone else's. Compute from the bands, show the base, and round to a whole number. - An uncapped public form is a free database write for whoever finds it, and the reflex fix is a hidden field that penalises anyone using a screen reader or a password manager. Cap the link, rate limit by address and token, record why each rejection happened, and keep every anti abuse measure visible to the respondent. ===== BUILD_PLAN.md ===== # Build plan · SurveySparrow A self-hosted survey tool whose hard problems are editing a live survey without rewriting the meaning of yesterday's answers, never losing or double counting a submission from an open public endpoint, and reporting standard metrics by their real definitions with the base count attached. 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 survey you can edit without rewriting history Publishing freezes a version and every response points at exactly one. ### Steps 1. Freeze the schema on publish A published version is read only for the rest of its life. Files: `src/versions.ts`, `tests/version-immutable.test.ts` 2. Keep question ids stable across versions Otherwise answers are keyed to a position that moved. Files: `src/schema.ts` 3. Show the version everywhere a count appears The number is meaningless without knowing which question produced it. Files: `src/app/admin/page.tsx` ### Done when - [ ] Editing creates a new version, old schema unchanged - [ ] Each response records its version id permanently - [ ] Question ids survive a wording edit - [ ] Counts display the version they came from ### Watch out - Editing the wording of a live question in place means last week's answers now sit under a question nobody was asked, and this is discovered in the meeting where the chart is presented. Freeze on publish, version on edit, keep question ids stable, and show the version beside every count. ## Phase 2 · A response that survives a closed laptop A half finished response resumes and a submitted one is never duplicated. ### Steps 1. Persist partials against a token Server side, because the browser is where the answers get lost. Files: `src/app/api/respond/route.ts` 2. Make submit idempotent on the token A retry after a timeout is the common case, not the rare one. Files: `src/submit.ts`, `tests/idempotent-submit.test.ts` 3. Record every rejection with a reason A response you refused is still something you need to be able to explain. Files: `src/events.ts` ### Done when - [ ] A replayed submission creates one response - [ ] A partial resumes on another browser - [ ] Submitted responses are immutable - [ ] Rejections are recorded with reasons ### Watch out - Holding partial answers only in the browser loses a ten minute survey to a tab crash and nobody starts again, while accepting a retried submission as a fresh row inflates every count by however many people had a flaky connection. Persist partials against the token and make submit idempotent on that token. ## Phase 3 · Numbers with a definition and a base Every figure reported carries its calculation and how many people it came from. ### Steps 1. Compute from the bands, not the mean The definition is public and the wrong calculation is obvious to anyone who knows it. Files: `src/scoring.ts`, `tests/nps.test.ts` 2. Show the base count beside every number Eleven responses and eleven thousand should not look the same. Files: `src/components/Figure.tsx` 3. Mark version boundaries on charts A wording change is a discontinuity, so draw it as one. Files: `src/charts.ts` ### Done when - [ ] The score matches a hand calculation on a fixture - [ ] The result is a whole number in range with band counts - [ ] Every figure shows its base count - [ ] Charts mark version boundaries ### Watch out - Reporting a score from eleven responses as one number with a decimal place invites a decision it cannot carry, and computing a promoter score as the average of the zero to ten answers gives a figure in the wrong range that nobody can reconcile with anyone else's. Compute from the bands, show the base, and round to a whole number. ## Phase 4 · An open endpoint on the public internet Collecting without accounts survives being discovered. ### Steps 1. Cap the link by responses and by date The simplest defence is a survey that is closed. Files: `src/limits.ts` 2. Limit size before parsing Refuse the body you have not read rather than the object you built from it. Files: `src/app/api/respond/route.ts` 3. Refuse visibly, never silently A respondent who thinks they submitted and did not is worse than one who was told no. Files: `src/app/respond/page.tsx` ### Done when - [ ] A capped link refuses and records refusals - [ ] Rate limited requests are rejected with reasons - [ ] Oversized payloads are refused before parsing - [ ] No hidden trap fields or third party challenges exist ### Watch out - An uncapped public form is a free database write for whoever finds it, and the reflex fix is a hidden field that penalises anyone using a screen reader or a password manager. Cap the link, rate limit by address and token, record why each rejection happened, and keep every anti abuse measure visible to the respondent. ## Not in this build - Bulk sending to a contact list. - Employee engagement and 360 workflows. - An integration catalogue. - Respondent file uploads. ## After v1, if you want it - One question per screen as a presentation option, which is a rendering change and nothing more - Branching logic expressed inside the frozen schema, so a branch is versioned like everything else - A public results page per version, once you are sure what the base counts say ===== .env.example ===== # Copy to .env and fill in. Never commit .env; this file documents it. # Required. Surveys, versions, responses and events. DATABASE_PATH=./data/surveys.db # Required. Used to build share links. Wrong here means links that go nowhere. PUBLIC_BASE_URL=https://surveys.example.com # Required · secret. The only login. The response endpoint is the only thing outside it. ADMIN_PASSWORD=a long random string # Required · secret. Signs response tokens, which carry idempotency and resume. RESPONSE_TOKEN_SECRET=sk-...redacted # Required. Per address, per link. Rejections are recorded with a reason. RATE_LIMIT_PER_HOUR=60 # Required. Refused before parsing rather than after. MAX_PAYLOAD_BYTES=65536 # Required. Responses are deleted past this, and the count is logged. RETENTION_DAYS=730 # Optional · secret. Optional, and only for notifying yourself. Unset means the interface only. SMTP_URL=smtps://user:pass@smtp.example.com:465
You are building a production product version of SurveySparrow. 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 ===== # SurveySparrow · product brief ## Problem The visible experience surveys loop is buildable, but a credible replacement needs more than the first screen. SurveySparrow earns its keep through delivery, integrations, compliance, so expect a weekend or multi-day build and a narrower personal scope. ## Product outcome A survey tool where editing a question never rewrites the meaning of old answers, a retried submission cannot become a second response, every reported number carries its definition and its base count, and an anonymous link really is anonymous. ## Target user A builder who needs a maintainable product foundation, not a one-off demo. ## Required capabilities - Node.js 22 - deploy target - SMTP or transactional email key in .env ## Explicit non-goals for v1 - Bulk sending to a contact list. - Employee engagement and 360 workflows. - An integration catalogue. - Respondent file uploads. - enterprise workflow logic - spam and abuse defense at scale - large integration and template catalog - compliance controls and guaranteed delivery ## Success criteria - Editing is confirmed to create a version rather than mutate one - A replayed submission is confirmed to create one response - The promoter score is confirmed against a hand calculation - Anonymous links are confirmed to store no address anywhere - Retention is confirmed to delete and log the count ===== BRIEF.md ===== # Build brief · SurveySparrow 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 survey tool that versions its questions and reports numbers you can defend to replace SurveySparrow. 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. ### Rule zero Two rules, and they are the same rule pointed at different things. A response answers one specific version of a survey. Editing a live question changes what every earlier answer meant, so an edit publishes a new version and each stored response records the version it answered. A chart that mixes versions without saying so is not defensible. If you report a Net Promoter Score, compute it the way its owners define it: promoters answered 9 or 10, detractors answered 0 to 6, the 7 and 8 passives count toward the base only, and the score is the promoter percentage minus the detractor percentage, landing between -100 and +100. It is not the mean of the answers. Net Promoter and NPS are registered trademarks of Bain and Company, NICE Systems and Fred Reichheld, so if your calculation differs in any way, give your number a different name and define it on the page where it appears. ### Stack (fixed, do not substitute) - Runtime: Next.js with the response endpoint as the only publicly reachable surface · everything else can sit behind one local login - Storage: SQLite, responses immutable once submitted · a corrected response is a new response, not a rewritten one - Versioning: Surveys frozen on publish, every version retained, responses pinned to one · otherwise a wording change quietly rewrites last week - Submission: A per response token with idempotent submit and server side partial saves · a retried request must not become a second answer - Abuse: Per link caps, address rate limits and a payload size limit, no hidden trap fields · an open endpoint gets found, and honeypots punish assistive technology ### Data model (create this before Phase 1) - surveys(id, title, created_at, archived_at) - survey_versions(id, survey_id, version, schema_json, published_at, closed_at, response_cap) · immutable once published - questions inside schema_json(id, kind, prompt, required, options_json, scale_min, scale_max) · question ids stay stable across versions - responses(id, survey_version_id, token, started_at, submitted_at, partial_json, answers_json, locale) · submitted rows are never updated - answers inside answers_json(question_id, value, skipped) · keyed by the stable question id, never by position - events(id, response_id, kind, at, reason) · started, resumed, rejected, submitted, with the reason a rejection happened - Because question ids are stable, a chart can follow one question across three versions and mark honestly where the wording changed. ### Phase 1 · A survey you can edit without rewriting history Build: An editor that produces a schema. Publishing freezes it as a version with its own share link. Editing a published survey creates the next version rather than changing the current one, and both stay answerable until closed. Question ids persist across versions. Trap: Editing the wording of a live question in place means last week's answers now sit under a question nobody was asked, and this is discovered in the meeting where the chart is presented. Freeze on publish, version on edit, keep question ids stable, and show the version beside every count. Done when: Editing a published survey produces a new version and leaves the old schema byte identical, tested. A response stores the version id it answered and that link cannot be changed afterwards. Question ids survive an edit that changes wording, so answers stay attached to the question. The interface shows which versions are open and how many responses each holds. Do not build yet: No responses, scoring, limits or export yet. ### Phase 2 · A response that survives a closed laptop Build: Issue a token on first load and save partial answers server side as the respondent moves through pages. Submission writes the response and its event in one transaction. A second submission with the same token returns the first result instead of creating another response. Trap: Holding partial answers only in the browser loses a ten minute survey to a tab crash and nobody starts again, while accepting a retried submission as a fresh row inflates every count by however many people had a flaky connection. Persist partials against the token and make submit idempotent on that token. Done when: Submitting twice with the same token produces exactly one response, tested by replaying the request. A partial response resumes from another browser using the link with its token and keeps every answer already given. A submitted response cannot be updated by any code path, asserted in the suite. Every rejection writes an event with the reason, and nothing is silently discarded. Do not build yet: No scoring, caps or export yet. ### Phase 3 · Numbers with a definition and a base Build: Compute the promoter score exactly as rule zero defines it, showing the promoter, passive and detractor counts alongside it. Offer a mean only for scales where a mean has meaning. Print the base count beside every figure and mark any chart that spans a wording change. Trap: Reporting a score from eleven responses as one number with a decimal place invites a decision it cannot carry, and computing a promoter score as the average of the zero to ten answers gives a figure in the wrong range that nobody can reconcile with anyone else's. Compute from the bands, show the base, and round to a whole number. Done when: The promoter score for a known response set matches a hand calculation, with 9 and 10 as promoters and 0 to 6 as detractors, tested with a fixture. The result is a whole number between -100 and +100 and the three band counts are shown with it. Every figure in the interface displays its base count. A chart spanning two versions marks where the wording changed rather than smoothing over it. Do not build yet: No caps, retention or export yet. ### Phase 4 · An open endpoint on the public internet Build: Rate limit per link and per address, cap the payload size, and let a link close at a response cap or a date. Reject loudly with a reason recorded rather than dropping quietly. Use no hidden trap fields and no third party challenge. Trap: An uncapped public form is a free database write for whoever finds it, and the reflex fix is a hidden field that penalises anyone using a screen reader or a password manager. Cap the link, rate limit by address and token, record why each rejection happened, and keep every anti abuse measure visible to the respondent. Done when: A link at its response cap refuses new submissions with a clear message and records the refusals. Requests over the rate limit are rejected with a reason in events, tested by hammering the endpoint. A payload above the size limit is refused before parsing. No hidden field, timing trap or third party challenge exists anywhere in the response form, verified by review. Do not build yet: No retention, anonymity handling or export yet. ### Phase 5 · Answers you can take away, and promises you can keep Build: Export CSV and JSON keyed by stable question ids with the version on every row. A retention setting that deletes rather than hides. A per link anonymity flag where, when it is on, the collection code never receives the address at all and the interface says so to the respondent. Trap: Telling respondents a survey is anonymous while the web server writes their address to an access log makes the promise false in a way that only surfaces when somebody asks for the logs. Decide anonymity per link, and when it is on, do not collect the address anywhere in the stack rather than collecting and discarding it. Done when: An export round trips: exported CSV re-imported reproduces the same answer values keyed by question id. An anonymous link leaves no address in the database or in any log the application writes, verified by grepping both after a test submission. Retention deletes rows past the window and writes one line saying how many, exercised in the suite. The respondent sees which kind of link they are answering before they start. Do not build yet: Nothing after this. ### Out of scope (and why) - Sending the survey to a large contact list yourself, which turns a survey tool into a bulk mail sender with a reputation to maintain and a separate set of rules to follow. - Employee engagement and 360 review workflows, which are a permissions and confidentiality problem rather than a forms problem. - A catalogue of integrations, where each one is a credential to store, a schema to track and a support burden forever. - File uploads from respondents, which hands anonymous strangers a write path into your storage and needs scanning and quarantine to be safe. ### README must contain - That publishing freezes a version and editing creates a new one, and why answers are pinned to versions - The exact promoter score calculation used, the band boundaries, and that the trademark belongs to its owners - That submission is idempotent on a token, so a retry cannot become a second response - Which anti abuse measures are in place, and that no hidden trap fields are used - What an anonymous link does and does not collect, including what reaches the application logs ===== ARCHITECTURE.md ===== # Architecture · SurveySparrow ## Stack | Part | Choice | Why | | --- | --- | --- | | Runtime | Next.js with the response endpoint as the only publicly reachable surface | everything else can sit behind one local login | | Storage | SQLite, responses immutable once submitted | a corrected response is a new response, not a rewritten one | | Versioning | Surveys frozen on publish, every version retained, responses pinned to one | otherwise a wording change quietly rewrites last week | | Submission | A per response token with idempotent submit and server side partial saves | a retried request must not become a second answer | | Abuse | Per link caps, address rate limits and a payload size limit, no hidden trap fields | an open endpoint gets found, and honeypots punish assistive technology | ## Modules Each module has one owner concern and a documented way to replace it. | Module | Owns | How to replace it | | --- | --- | --- | | Versions | Frozen schemas and stable question ids. | Any editor, provided publishing freezes and editing versions. | | Collection | Tokens, partial saves and idempotent submit. | Not swappable for accept and insert, which is how counts inflate. | | Scoring | Band based calculations with base counts. | Any metric, provided its definition is shown where the number is. | | Limits | Caps, rate limits and recorded rejections. | Any mechanism that stays visible to the respondent. | | Privacy | Per link anonymity at collection time. | Not swappable for collect and delete later. | ## Configuration Every runtime setting is an environment variable documented in `.env.example`, validated at startup, with a safe local default wherever one exists. - `DATABASE_PATH` · required · Surveys, versions, responses and events. - `PUBLIC_BASE_URL` · required · Used to build share links. Wrong here means links that go nowhere. - `ADMIN_PASSWORD` · required, secret · The only login. The response endpoint is the only thing outside it. - `RESPONSE_TOKEN_SECRET` · required, secret · Signs response tokens, which carry idempotency and resume. - `RATE_LIMIT_PER_HOUR` · required · Per address, per link. Rejections are recorded with a reason. - `MAX_PAYLOAD_BYTES` · required · Refused before parsing rather than after. - `RETENTION_DAYS` · required · Responses are deleted past this, and the count is logged. - `SMTP_URL` · optional, secret · Optional, and only for notifying yourself. Unset means the interface only. ## 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 · SurveySparrow product build - Read `PRODUCT.md` and `ARCHITECTURE.md` before changing code. The stack is fixed: Next.js with the response endpoint as the only publicly reachable surface, SQLite, responses immutable once submitted, Surveys frozen on publish, every version retained, responses pinned to one, A per response token with idempotent submit and server side partial saves, Per link caps, address rate limits and a payload size limit, no hidden trap fields. - 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 - Editing the wording of a live question in place means last week's answers now sit under a question nobody was asked, and this is discovered in the meeting where the chart is presented. Freeze on publish, version on edit, keep question ids stable, and show the version beside every count. - Holding partial answers only in the browser loses a ten minute survey to a tab crash and nobody starts again, while accepting a retried submission as a fresh row inflates every count by however many people had a flaky connection. Persist partials against the token and make submit idempotent on that token. - Reporting a score from eleven responses as one number with a decimal place invites a decision it cannot carry, and computing a promoter score as the average of the zero to ten answers gives a figure in the wrong range that nobody can reconcile with anyone else's. Compute from the bands, show the base, and round to a whole number. - An uncapped public form is a free database write for whoever finds it, and the reflex fix is a hidden field that penalises anyone using a screen reader or a password manager. Cap the link, rate limit by address and token, record why each rejection happened, and keep every anti abuse measure visible to the respondent. - Telling respondents a survey is anonymous while the web server writes their address to an access log makes the promise false in a way that only surfaces when somebody asks for the logs. Decide anonymity per link, and when it is on, do not collect the address anywhere in the stack rather than collecting and discarding it. ===== MILESTONES.md ===== # Delivery milestones · SurveySparrow Estimated effort: **weekend** for the indie phases; the production-only milestones add the trust and operability layer. ## M1 · A survey you can edit without rewriting history Publishing freezes a version and every response points at exactly one. ### Steps 1. Freeze the schema on publish A published version is read only for the rest of its life. Files: `src/versions.ts`, `tests/version-immutable.test.ts` 2. Keep question ids stable across versions Otherwise answers are keyed to a position that moved. Files: `src/schema.ts` 3. Show the version everywhere a count appears The number is meaningless without knowing which question produced it. Files: `src/app/admin/page.tsx` ### Done when - [ ] Editing creates a new version, old schema unchanged - [ ] Each response records its version id permanently - [ ] Question ids survive a wording edit - [ ] Counts display the version they came from ### Watch out - Editing the wording of a live question in place means last week's answers now sit under a question nobody was asked, and this is discovered in the meeting where the chart is presented. Freeze on publish, version on edit, keep question ids stable, and show the version beside every count. ## M2 · A response that survives a closed laptop A half finished response resumes and a submitted one is never duplicated. ### Steps 1. Persist partials against a token Server side, because the browser is where the answers get lost. Files: `src/app/api/respond/route.ts` 2. Make submit idempotent on the token A retry after a timeout is the common case, not the rare one. Files: `src/submit.ts`, `tests/idempotent-submit.test.ts` 3. Record every rejection with a reason A response you refused is still something you need to be able to explain. Files: `src/events.ts` ### Done when - [ ] A replayed submission creates one response - [ ] A partial resumes on another browser - [ ] Submitted responses are immutable - [ ] Rejections are recorded with reasons ### Watch out - Holding partial answers only in the browser loses a ten minute survey to a tab crash and nobody starts again, while accepting a retried submission as a fresh row inflates every count by however many people had a flaky connection. Persist partials against the token and make submit idempotent on that token. ## M3 · Numbers with a definition and a base Every figure reported carries its calculation and how many people it came from. ### Steps 1. Compute from the bands, not the mean The definition is public and the wrong calculation is obvious to anyone who knows it. Files: `src/scoring.ts`, `tests/nps.test.ts` 2. Show the base count beside every number Eleven responses and eleven thousand should not look the same. Files: `src/components/Figure.tsx` 3. Mark version boundaries on charts A wording change is a discontinuity, so draw it as one. Files: `src/charts.ts` ### Done when - [ ] The score matches a hand calculation on a fixture - [ ] The result is a whole number in range with band counts - [ ] Every figure shows its base count - [ ] Charts mark version boundaries ### Watch out - Reporting a score from eleven responses as one number with a decimal place invites a decision it cannot carry, and computing a promoter score as the average of the zero to ten answers gives a figure in the wrong range that nobody can reconcile with anyone else's. Compute from the bands, show the base, and round to a whole number. ## M4 · An open endpoint on the public internet Collecting without accounts survives being discovered. ### Steps 1. Cap the link by responses and by date The simplest defence is a survey that is closed. Files: `src/limits.ts` 2. Limit size before parsing Refuse the body you have not read rather than the object you built from it. Files: `src/app/api/respond/route.ts` 3. Refuse visibly, never silently A respondent who thinks they submitted and did not is worse than one who was told no. Files: `src/app/respond/page.tsx` ### Done when - [ ] A capped link refuses and records refusals - [ ] Rate limited requests are rejected with reasons - [ ] Oversized payloads are refused before parsing - [ ] No hidden trap fields or third party challenges exist ### Watch out - An uncapped public form is a free database write for whoever finds it, and the reflex fix is a hidden field that penalises anyone using a screen reader or a password manager. Cap the link, rate limit by address and token, record why each rejection happened, and keep every anti abuse measure visible to the respondent. ## M5 · Answers you can take away, and promises you can keep (production only) Export, retention and the anonymity claim all mean exactly what they say. ### Steps 1. Export keyed by question id and version Column positions change between versions; ids do not. Files: `src/export.ts`, `tests/export-roundtrip.test.ts` 2. Make anonymity a collection decision Not a display decision, and not a deletion after the fact. Files: `src/anonymity.ts`, `tests/no-address-stored.test.ts` 3. Delete on retention and log the count A retention setting nobody has watched delete anything is a setting, not a policy. Files: `src/retention.ts` ### Done when - [ ] Export and re-import reproduce the same answers - [ ] An anonymous link stores no address in database or logs - [ ] Retention deletes and logs the count - [ ] Respondents see which kind of link they are on ### Watch out - Telling respondents a survey is anonymous while the web server writes their address to an access log makes the promise false in a way that only surfaces when somebody asks for the logs. Decide anonymity per link, and when it is on, do not collect the address anywhere in the stack rather than collecting and discarding it. ===== OPERATIONS.md ===== # Operations · SurveySparrow ## Backup The SQLite file, including the events table, because when somebody disputes a count the rejection reasons are what answer them. Back up before every deployment that touches the schema. ## Restore Restore, then confirm response counts per version match the last export and that no partial rows have been promoted to submitted. Run the export round trip once after restoring. Do a restore drill before the first real user, and write the date here when it passes. ## Monitoring Rejection counts by reason, responses per link against the cap, and the age of the oldest response against the retention window. A spike of rejections on one link usually means the link leaked somewhere public. ## Incident checklist The two incidents are a count that looks wrong, which the events table and the version pinning exist to explain, and an anonymous link that turned out to collect something, which is a code path that received an address it should never have seen. 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 - [ ] Editing is confirmed to create a version rather than mutate one - [ ] A replayed submission is confirmed to create one response - [ ] The promoter score is confirmed against a hand calculation - [ ] Anonymous links are confirmed to store no address anywhere - [ ] Retention is confirmed to delete and log the count ## Launch constraint Do not market omitted SurveySparrow 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. Surveys, versions, responses and events. DATABASE_PATH=./data/surveys.db # Required. Used to build share links. Wrong here means links that go nowhere. PUBLIC_BASE_URL=https://surveys.example.com # Required · secret. The only login. The response endpoint is the only thing outside it. ADMIN_PASSWORD=a long random string # Required · secret. Signs response tokens, which carry idempotency and resume. RESPONSE_TOKEN_SECRET=sk-...redacted # Required. Per address, per link. Rejections are recorded with a reason. RATE_LIMIT_PER_HOUR=60 # Required. Refused before parsing rather than after. MAX_PAYLOAD_BYTES=65536 # Required. Responses are deleted past this, and the count is logged. RETENTION_DAYS=730 # Optional · secret. Optional, and only for notifying yourself. Unset means the interface only. SMTP_URL=smtps://user:pass@smtp.example.com:465
# SurveySparrow · indie build A self-hosted survey tool whose hard problems are editing a live survey without rewriting the meaning of yesterday's answers, never losing or double counting a submission from an open public endpoint, and reporting standard metrics by their real definitions with the base count attached. 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 | Next.js with the response endpoint as the only publicly reachable surface | everything else can sit behind one local login | | Storage | SQLite, responses immutable once submitted | a corrected response is a new response, not a rewritten one | | Versioning | Surveys frozen on publish, every version retained, responses pinned to one | otherwise a wording change quietly rewrites last week | | Submission | A per response token with idempotent submit and server side partial saves | a retried request must not become a second answer | | Abuse | Per link caps, address rate limits and a payload size limit, no hidden trap fields | an open endpoint gets found, and honeypots punish assistive technology | ## 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 application and the response endpoint. - Get it: nodejs.org, or nvm install 22. - Verify: node --version prints v22 or higher - [ ] **A host the public can reach** · a few pounds a month - Why: The response endpoint has to be reachable by respondents, which also means reachable by everyone else, which is what phase four is about. - Get it: A small VPS or any platform that will run a Node process with a persistent disk for SQLite. - Verify: A share link opens from a phone on mobile data - [ ] **Your retention window and anonymity default** · free - Why: Both are asserted by tests, and choosing them after seeing the data means choosing whatever is convenient. - Get it: Write down how long responses live, what an anonymous link collects, and what the response cap is by default. - Verify: All three appear in the configuration and are named in the tests - [ ] **Three people who will actually answer it** · free - Why: Resume, idempotency and the partial save path only fail in the ways that matter when a real person closes a laptop mid survey. - Get it: Ask three people to answer a long survey on their own devices, and ask one to abandon it halfway. - Verify: One response resumes from a partial and one was submitted twice without duplicating - [ ] **An SMTP credential you already own** (optional) · free or pennies - Why: Only for notifying yourself that responses arrived; sending to a list is explicitly out of scope. - Get it: Any mail account with SMTP access, or leave it unset and use the interface. - Verify: A test notification arrives in your own inbox ## 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: - Bulk sending to a contact list. - Employee engagement and 360 workflows. - An integration catalogue. - Respondent file uploads. - enterprise workflow logic - spam and abuse defense at scale - large integration and template catalog - compliance controls and guaranteed delivery If one of those is essential to you, that is the reason to keep paying for SurveySparrow, and the README should say so rather than pretend.
# Build brief · SurveySparrow 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 survey tool that versions its questions and reports numbers you can defend to replace SurveySparrow. 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. ### Rule zero Two rules, and they are the same rule pointed at different things. A response answers one specific version of a survey. Editing a live question changes what every earlier answer meant, so an edit publishes a new version and each stored response records the version it answered. A chart that mixes versions without saying so is not defensible. If you report a Net Promoter Score, compute it the way its owners define it: promoters answered 9 or 10, detractors answered 0 to 6, the 7 and 8 passives count toward the base only, and the score is the promoter percentage minus the detractor percentage, landing between -100 and +100. It is not the mean of the answers. Net Promoter and NPS are registered trademarks of Bain and Company, NICE Systems and Fred Reichheld, so if your calculation differs in any way, give your number a different name and define it on the page where it appears. ### Stack (fixed, do not substitute) - Runtime: Next.js with the response endpoint as the only publicly reachable surface · everything else can sit behind one local login - Storage: SQLite, responses immutable once submitted · a corrected response is a new response, not a rewritten one - Versioning: Surveys frozen on publish, every version retained, responses pinned to one · otherwise a wording change quietly rewrites last week - Submission: A per response token with idempotent submit and server side partial saves · a retried request must not become a second answer - Abuse: Per link caps, address rate limits and a payload size limit, no hidden trap fields · an open endpoint gets found, and honeypots punish assistive technology ### Data model (create this before Phase 1) - surveys(id, title, created_at, archived_at) - survey_versions(id, survey_id, version, schema_json, published_at, closed_at, response_cap) · immutable once published - questions inside schema_json(id, kind, prompt, required, options_json, scale_min, scale_max) · question ids stay stable across versions - responses(id, survey_version_id, token, started_at, submitted_at, partial_json, answers_json, locale) · submitted rows are never updated - answers inside answers_json(question_id, value, skipped) · keyed by the stable question id, never by position - events(id, response_id, kind, at, reason) · started, resumed, rejected, submitted, with the reason a rejection happened - Because question ids are stable, a chart can follow one question across three versions and mark honestly where the wording changed. ### Phase 1 · A survey you can edit without rewriting history Build: An editor that produces a schema. Publishing freezes it as a version with its own share link. Editing a published survey creates the next version rather than changing the current one, and both stay answerable until closed. Question ids persist across versions. Trap: Editing the wording of a live question in place means last week's answers now sit under a question nobody was asked, and this is discovered in the meeting where the chart is presented. Freeze on publish, version on edit, keep question ids stable, and show the version beside every count. Done when: Editing a published survey produces a new version and leaves the old schema byte identical, tested. A response stores the version id it answered and that link cannot be changed afterwards. Question ids survive an edit that changes wording, so answers stay attached to the question. The interface shows which versions are open and how many responses each holds. Do not build yet: No responses, scoring, limits or export yet. ### Phase 2 · A response that survives a closed laptop Build: Issue a token on first load and save partial answers server side as the respondent moves through pages. Submission writes the response and its event in one transaction. A second submission with the same token returns the first result instead of creating another response. Trap: Holding partial answers only in the browser loses a ten minute survey to a tab crash and nobody starts again, while accepting a retried submission as a fresh row inflates every count by however many people had a flaky connection. Persist partials against the token and make submit idempotent on that token. Done when: Submitting twice with the same token produces exactly one response, tested by replaying the request. A partial response resumes from another browser using the link with its token and keeps every answer already given. A submitted response cannot be updated by any code path, asserted in the suite. Every rejection writes an event with the reason, and nothing is silently discarded. Do not build yet: No scoring, caps or export yet. ### Phase 3 · Numbers with a definition and a base Build: Compute the promoter score exactly as rule zero defines it, showing the promoter, passive and detractor counts alongside it. Offer a mean only for scales where a mean has meaning. Print the base count beside every figure and mark any chart that spans a wording change. Trap: Reporting a score from eleven responses as one number with a decimal place invites a decision it cannot carry, and computing a promoter score as the average of the zero to ten answers gives a figure in the wrong range that nobody can reconcile with anyone else's. Compute from the bands, show the base, and round to a whole number. Done when: The promoter score for a known response set matches a hand calculation, with 9 and 10 as promoters and 0 to 6 as detractors, tested with a fixture. The result is a whole number between -100 and +100 and the three band counts are shown with it. Every figure in the interface displays its base count. A chart spanning two versions marks where the wording changed rather than smoothing over it. Do not build yet: No caps, retention or export yet. ### Phase 4 · An open endpoint on the public internet Build: Rate limit per link and per address, cap the payload size, and let a link close at a response cap or a date. Reject loudly with a reason recorded rather than dropping quietly. Use no hidden trap fields and no third party challenge. Trap: An uncapped public form is a free database write for whoever finds it, and the reflex fix is a hidden field that penalises anyone using a screen reader or a password manager. Cap the link, rate limit by address and token, record why each rejection happened, and keep every anti abuse measure visible to the respondent. Done when: A link at its response cap refuses new submissions with a clear message and records the refusals. Requests over the rate limit are rejected with a reason in events, tested by hammering the endpoint. A payload above the size limit is refused before parsing. No hidden field, timing trap or third party challenge exists anywhere in the response form, verified by review. Do not build yet: No retention, anonymity handling or export yet. ### Phase 5 · Answers you can take away, and promises you can keep Build: Export CSV and JSON keyed by stable question ids with the version on every row. A retention setting that deletes rather than hides. A per link anonymity flag where, when it is on, the collection code never receives the address at all and the interface says so to the respondent. Trap: Telling respondents a survey is anonymous while the web server writes their address to an access log makes the promise false in a way that only surfaces when somebody asks for the logs. Decide anonymity per link, and when it is on, do not collect the address anywhere in the stack rather than collecting and discarding it. Done when: An export round trips: exported CSV re-imported reproduces the same answer values keyed by question id. An anonymous link leaves no address in the database or in any log the application writes, verified by grepping both after a test submission. Retention deletes rows past the window and writes one line saying how many, exercised in the suite. The respondent sees which kind of link they are answering before they start. Do not build yet: Nothing after this. ### Out of scope (and why) - Sending the survey to a large contact list yourself, which turns a survey tool into a bulk mail sender with a reputation to maintain and a separate set of rules to follow. - Employee engagement and 360 review workflows, which are a permissions and confidentiality problem rather than a forms problem. - A catalogue of integrations, where each one is a credential to store, a schema to track and a support burden forever. - File uploads from respondents, which hands anonymous strangers a write path into your storage and needs scanning and quarantine to be safe. ### README must contain - That publishing freezes a version and editing creates a new one, and why answers are pinned to versions - The exact promoter score calculation used, the band boundaries, and that the trademark belongs to its owners - That submission is idempotent on a token, so a retry cannot become a second response - Which anti abuse measures are in place, and that no hidden trap fields are used - What an anonymous link does and does not collect, including what reaches the application logs
# Agent instructions · SurveySparrow indie build - Read `README.md` and `BUILD_PLAN.md` before writing code. The stack is fixed: Next.js with the response endpoint as the only publicly reachable surface, SQLite, responses immutable once submitted, Surveys frozen on publish, every version retained, responses pinned to one, A per response token with idempotent submit and server side partial saves, Per link caps, address rate limits and a payload size limit, no hidden trap fields. 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 - Editing the wording of a live question in place means last week's answers now sit under a question nobody was asked, and this is discovered in the meeting where the chart is presented. Freeze on publish, version on edit, keep question ids stable, and show the version beside every count. - Holding partial answers only in the browser loses a ten minute survey to a tab crash and nobody starts again, while accepting a retried submission as a fresh row inflates every count by however many people had a flaky connection. Persist partials against the token and make submit idempotent on that token. - Reporting a score from eleven responses as one number with a decimal place invites a decision it cannot carry, and computing a promoter score as the average of the zero to ten answers gives a figure in the wrong range that nobody can reconcile with anyone else's. Compute from the bands, show the base, and round to a whole number. - An uncapped public form is a free database write for whoever finds it, and the reflex fix is a hidden field that penalises anyone using a screen reader or a password manager. Cap the link, rate limit by address and token, record why each rejection happened, and keep every anti abuse measure visible to the respondent.
# Build plan · SurveySparrow A self-hosted survey tool whose hard problems are editing a live survey without rewriting the meaning of yesterday's answers, never losing or double counting a submission from an open public endpoint, and reporting standard metrics by their real definitions with the base count attached. 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 survey you can edit without rewriting history Publishing freezes a version and every response points at exactly one. ### Steps 1. Freeze the schema on publish A published version is read only for the rest of its life. Files: `src/versions.ts`, `tests/version-immutable.test.ts` 2. Keep question ids stable across versions Otherwise answers are keyed to a position that moved. Files: `src/schema.ts` 3. Show the version everywhere a count appears The number is meaningless without knowing which question produced it. Files: `src/app/admin/page.tsx` ### Done when - [ ] Editing creates a new version, old schema unchanged - [ ] Each response records its version id permanently - [ ] Question ids survive a wording edit - [ ] Counts display the version they came from ### Watch out - Editing the wording of a live question in place means last week's answers now sit under a question nobody was asked, and this is discovered in the meeting where the chart is presented. Freeze on publish, version on edit, keep question ids stable, and show the version beside every count. ## Phase 2 · A response that survives a closed laptop A half finished response resumes and a submitted one is never duplicated. ### Steps 1. Persist partials against a token Server side, because the browser is where the answers get lost. Files: `src/app/api/respond/route.ts` 2. Make submit idempotent on the token A retry after a timeout is the common case, not the rare one. Files: `src/submit.ts`, `tests/idempotent-submit.test.ts` 3. Record every rejection with a reason A response you refused is still something you need to be able to explain. Files: `src/events.ts` ### Done when - [ ] A replayed submission creates one response - [ ] A partial resumes on another browser - [ ] Submitted responses are immutable - [ ] Rejections are recorded with reasons ### Watch out - Holding partial answers only in the browser loses a ten minute survey to a tab crash and nobody starts again, while accepting a retried submission as a fresh row inflates every count by however many people had a flaky connection. Persist partials against the token and make submit idempotent on that token. ## Phase 3 · Numbers with a definition and a base Every figure reported carries its calculation and how many people it came from. ### Steps 1. Compute from the bands, not the mean The definition is public and the wrong calculation is obvious to anyone who knows it. Files: `src/scoring.ts`, `tests/nps.test.ts` 2. Show the base count beside every number Eleven responses and eleven thousand should not look the same. Files: `src/components/Figure.tsx` 3. Mark version boundaries on charts A wording change is a discontinuity, so draw it as one. Files: `src/charts.ts` ### Done when - [ ] The score matches a hand calculation on a fixture - [ ] The result is a whole number in range with band counts - [ ] Every figure shows its base count - [ ] Charts mark version boundaries ### Watch out - Reporting a score from eleven responses as one number with a decimal place invites a decision it cannot carry, and computing a promoter score as the average of the zero to ten answers gives a figure in the wrong range that nobody can reconcile with anyone else's. Compute from the bands, show the base, and round to a whole number. ## Phase 4 · An open endpoint on the public internet Collecting without accounts survives being discovered. ### Steps 1. Cap the link by responses and by date The simplest defence is a survey that is closed. Files: `src/limits.ts` 2. Limit size before parsing Refuse the body you have not read rather than the object you built from it. Files: `src/app/api/respond/route.ts` 3. Refuse visibly, never silently A respondent who thinks they submitted and did not is worse than one who was told no. Files: `src/app/respond/page.tsx` ### Done when - [ ] A capped link refuses and records refusals - [ ] Rate limited requests are rejected with reasons - [ ] Oversized payloads are refused before parsing - [ ] No hidden trap fields or third party challenges exist ### Watch out - An uncapped public form is a free database write for whoever finds it, and the reflex fix is a hidden field that penalises anyone using a screen reader or a password manager. Cap the link, rate limit by address and token, record why each rejection happened, and keep every anti abuse measure visible to the respondent. ## Not in this build - Bulk sending to a contact list. - Employee engagement and 360 workflows. - An integration catalogue. - Respondent file uploads. ## After v1, if you want it - One question per screen as a presentation option, which is a rendering change and nothing more - Branching logic expressed inside the frozen schema, so a branch is versioned like everything else - A public results page per version, once you are sure what the base counts say
# Copy to .env and fill in. Never commit .env; this file documents it. # Required. Surveys, versions, responses and events. DATABASE_PATH=./data/surveys.db # Required. Used to build share links. Wrong here means links that go nowhere. PUBLIC_BASE_URL=https://surveys.example.com # Required · secret. The only login. The response endpoint is the only thing outside it. ADMIN_PASSWORD=a long random string # Required · secret. Signs response tokens, which carry idempotency and resume. RESPONSE_TOKEN_SECRET=sk-...redacted # Required. Per address, per link. Rejections are recorded with a reason. RATE_LIMIT_PER_HOUR=60 # Required. Refused before parsing rather than after. MAX_PAYLOAD_BYTES=65536 # Required. Responses are deleted past this, and the count is logged. RETENTION_DAYS=730 # Optional · secret. Optional, and only for notifying yourself. Unset means the interface only. SMTP_URL=smtps://user:pass@smtp.example.com:465
# SurveySparrow · product brief ## Problem The visible experience surveys loop is buildable, but a credible replacement needs more than the first screen. SurveySparrow earns its keep through delivery, integrations, compliance, so expect a weekend or multi-day build and a narrower personal scope. ## Product outcome A survey tool where editing a question never rewrites the meaning of old answers, a retried submission cannot become a second response, every reported number carries its definition and its base count, and an anonymous link really is anonymous. ## Target user A builder who needs a maintainable product foundation, not a one-off demo. ## Required capabilities - Node.js 22 - deploy target - SMTP or transactional email key in .env ## Explicit non-goals for v1 - Bulk sending to a contact list. - Employee engagement and 360 workflows. - An integration catalogue. - Respondent file uploads. - enterprise workflow logic - spam and abuse defense at scale - large integration and template catalog - compliance controls and guaranteed delivery ## Success criteria - Editing is confirmed to create a version rather than mutate one - A replayed submission is confirmed to create one response - The promoter score is confirmed against a hand calculation - Anonymous links are confirmed to store no address anywhere - Retention is confirmed to delete and log the count
# Build brief · SurveySparrow 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 survey tool that versions its questions and reports numbers you can defend to replace SurveySparrow. 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. ### Rule zero Two rules, and they are the same rule pointed at different things. A response answers one specific version of a survey. Editing a live question changes what every earlier answer meant, so an edit publishes a new version and each stored response records the version it answered. A chart that mixes versions without saying so is not defensible. If you report a Net Promoter Score, compute it the way its owners define it: promoters answered 9 or 10, detractors answered 0 to 6, the 7 and 8 passives count toward the base only, and the score is the promoter percentage minus the detractor percentage, landing between -100 and +100. It is not the mean of the answers. Net Promoter and NPS are registered trademarks of Bain and Company, NICE Systems and Fred Reichheld, so if your calculation differs in any way, give your number a different name and define it on the page where it appears. ### Stack (fixed, do not substitute) - Runtime: Next.js with the response endpoint as the only publicly reachable surface · everything else can sit behind one local login - Storage: SQLite, responses immutable once submitted · a corrected response is a new response, not a rewritten one - Versioning: Surveys frozen on publish, every version retained, responses pinned to one · otherwise a wording change quietly rewrites last week - Submission: A per response token with idempotent submit and server side partial saves · a retried request must not become a second answer - Abuse: Per link caps, address rate limits and a payload size limit, no hidden trap fields · an open endpoint gets found, and honeypots punish assistive technology ### Data model (create this before Phase 1) - surveys(id, title, created_at, archived_at) - survey_versions(id, survey_id, version, schema_json, published_at, closed_at, response_cap) · immutable once published - questions inside schema_json(id, kind, prompt, required, options_json, scale_min, scale_max) · question ids stay stable across versions - responses(id, survey_version_id, token, started_at, submitted_at, partial_json, answers_json, locale) · submitted rows are never updated - answers inside answers_json(question_id, value, skipped) · keyed by the stable question id, never by position - events(id, response_id, kind, at, reason) · started, resumed, rejected, submitted, with the reason a rejection happened - Because question ids are stable, a chart can follow one question across three versions and mark honestly where the wording changed. ### Phase 1 · A survey you can edit without rewriting history Build: An editor that produces a schema. Publishing freezes it as a version with its own share link. Editing a published survey creates the next version rather than changing the current one, and both stay answerable until closed. Question ids persist across versions. Trap: Editing the wording of a live question in place means last week's answers now sit under a question nobody was asked, and this is discovered in the meeting where the chart is presented. Freeze on publish, version on edit, keep question ids stable, and show the version beside every count. Done when: Editing a published survey produces a new version and leaves the old schema byte identical, tested. A response stores the version id it answered and that link cannot be changed afterwards. Question ids survive an edit that changes wording, so answers stay attached to the question. The interface shows which versions are open and how many responses each holds. Do not build yet: No responses, scoring, limits or export yet. ### Phase 2 · A response that survives a closed laptop Build: Issue a token on first load and save partial answers server side as the respondent moves through pages. Submission writes the response and its event in one transaction. A second submission with the same token returns the first result instead of creating another response. Trap: Holding partial answers only in the browser loses a ten minute survey to a tab crash and nobody starts again, while accepting a retried submission as a fresh row inflates every count by however many people had a flaky connection. Persist partials against the token and make submit idempotent on that token. Done when: Submitting twice with the same token produces exactly one response, tested by replaying the request. A partial response resumes from another browser using the link with its token and keeps every answer already given. A submitted response cannot be updated by any code path, asserted in the suite. Every rejection writes an event with the reason, and nothing is silently discarded. Do not build yet: No scoring, caps or export yet. ### Phase 3 · Numbers with a definition and a base Build: Compute the promoter score exactly as rule zero defines it, showing the promoter, passive and detractor counts alongside it. Offer a mean only for scales where a mean has meaning. Print the base count beside every figure and mark any chart that spans a wording change. Trap: Reporting a score from eleven responses as one number with a decimal place invites a decision it cannot carry, and computing a promoter score as the average of the zero to ten answers gives a figure in the wrong range that nobody can reconcile with anyone else's. Compute from the bands, show the base, and round to a whole number. Done when: The promoter score for a known response set matches a hand calculation, with 9 and 10 as promoters and 0 to 6 as detractors, tested with a fixture. The result is a whole number between -100 and +100 and the three band counts are shown with it. Every figure in the interface displays its base count. A chart spanning two versions marks where the wording changed rather than smoothing over it. Do not build yet: No caps, retention or export yet. ### Phase 4 · An open endpoint on the public internet Build: Rate limit per link and per address, cap the payload size, and let a link close at a response cap or a date. Reject loudly with a reason recorded rather than dropping quietly. Use no hidden trap fields and no third party challenge. Trap: An uncapped public form is a free database write for whoever finds it, and the reflex fix is a hidden field that penalises anyone using a screen reader or a password manager. Cap the link, rate limit by address and token, record why each rejection happened, and keep every anti abuse measure visible to the respondent. Done when: A link at its response cap refuses new submissions with a clear message and records the refusals. Requests over the rate limit are rejected with a reason in events, tested by hammering the endpoint. A payload above the size limit is refused before parsing. No hidden field, timing trap or third party challenge exists anywhere in the response form, verified by review. Do not build yet: No retention, anonymity handling or export yet. ### Phase 5 · Answers you can take away, and promises you can keep Build: Export CSV and JSON keyed by stable question ids with the version on every row. A retention setting that deletes rather than hides. A per link anonymity flag where, when it is on, the collection code never receives the address at all and the interface says so to the respondent. Trap: Telling respondents a survey is anonymous while the web server writes their address to an access log makes the promise false in a way that only surfaces when somebody asks for the logs. Decide anonymity per link, and when it is on, do not collect the address anywhere in the stack rather than collecting and discarding it. Done when: An export round trips: exported CSV re-imported reproduces the same answer values keyed by question id. An anonymous link leaves no address in the database or in any log the application writes, verified by grepping both after a test submission. Retention deletes rows past the window and writes one line saying how many, exercised in the suite. The respondent sees which kind of link they are answering before they start. Do not build yet: Nothing after this. ### Out of scope (and why) - Sending the survey to a large contact list yourself, which turns a survey tool into a bulk mail sender with a reputation to maintain and a separate set of rules to follow. - Employee engagement and 360 review workflows, which are a permissions and confidentiality problem rather than a forms problem. - A catalogue of integrations, where each one is a credential to store, a schema to track and a support burden forever. - File uploads from respondents, which hands anonymous strangers a write path into your storage and needs scanning and quarantine to be safe. ### README must contain - That publishing freezes a version and editing creates a new one, and why answers are pinned to versions - The exact promoter score calculation used, the band boundaries, and that the trademark belongs to its owners - That submission is idempotent on a token, so a retry cannot become a second response - Which anti abuse measures are in place, and that no hidden trap fields are used - What an anonymous link does and does not collect, including what reaches the application logs
# Architecture · SurveySparrow ## Stack | Part | Choice | Why | | --- | --- | --- | | Runtime | Next.js with the response endpoint as the only publicly reachable surface | everything else can sit behind one local login | | Storage | SQLite, responses immutable once submitted | a corrected response is a new response, not a rewritten one | | Versioning | Surveys frozen on publish, every version retained, responses pinned to one | otherwise a wording change quietly rewrites last week | | Submission | A per response token with idempotent submit and server side partial saves | a retried request must not become a second answer | | Abuse | Per link caps, address rate limits and a payload size limit, no hidden trap fields | an open endpoint gets found, and honeypots punish assistive technology | ## Modules Each module has one owner concern and a documented way to replace it. | Module | Owns | How to replace it | | --- | --- | --- | | Versions | Frozen schemas and stable question ids. | Any editor, provided publishing freezes and editing versions. | | Collection | Tokens, partial saves and idempotent submit. | Not swappable for accept and insert, which is how counts inflate. | | Scoring | Band based calculations with base counts. | Any metric, provided its definition is shown where the number is. | | Limits | Caps, rate limits and recorded rejections. | Any mechanism that stays visible to the respondent. | | Privacy | Per link anonymity at collection time. | Not swappable for collect and delete later. | ## Configuration Every runtime setting is an environment variable documented in `.env.example`, validated at startup, with a safe local default wherever one exists. - `DATABASE_PATH` · required · Surveys, versions, responses and events. - `PUBLIC_BASE_URL` · required · Used to build share links. Wrong here means links that go nowhere. - `ADMIN_PASSWORD` · required, secret · The only login. The response endpoint is the only thing outside it. - `RESPONSE_TOKEN_SECRET` · required, secret · Signs response tokens, which carry idempotency and resume. - `RATE_LIMIT_PER_HOUR` · required · Per address, per link. Rejections are recorded with a reason. - `MAX_PAYLOAD_BYTES` · required · Refused before parsing rather than after. - `RETENTION_DAYS` · required · Responses are deleted past this, and the count is logged. - `SMTP_URL` · optional, secret · Optional, and only for notifying yourself. Unset means the interface only. ## 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 · SurveySparrow product build - Read `PRODUCT.md` and `ARCHITECTURE.md` before changing code. The stack is fixed: Next.js with the response endpoint as the only publicly reachable surface, SQLite, responses immutable once submitted, Surveys frozen on publish, every version retained, responses pinned to one, A per response token with idempotent submit and server side partial saves, Per link caps, address rate limits and a payload size limit, no hidden trap fields. - 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 - Editing the wording of a live question in place means last week's answers now sit under a question nobody was asked, and this is discovered in the meeting where the chart is presented. Freeze on publish, version on edit, keep question ids stable, and show the version beside every count. - Holding partial answers only in the browser loses a ten minute survey to a tab crash and nobody starts again, while accepting a retried submission as a fresh row inflates every count by however many people had a flaky connection. Persist partials against the token and make submit idempotent on that token. - Reporting a score from eleven responses as one number with a decimal place invites a decision it cannot carry, and computing a promoter score as the average of the zero to ten answers gives a figure in the wrong range that nobody can reconcile with anyone else's. Compute from the bands, show the base, and round to a whole number. - An uncapped public form is a free database write for whoever finds it, and the reflex fix is a hidden field that penalises anyone using a screen reader or a password manager. Cap the link, rate limit by address and token, record why each rejection happened, and keep every anti abuse measure visible to the respondent. - Telling respondents a survey is anonymous while the web server writes their address to an access log makes the promise false in a way that only surfaces when somebody asks for the logs. Decide anonymity per link, and when it is on, do not collect the address anywhere in the stack rather than collecting and discarding it.
# Delivery milestones · SurveySparrow Estimated effort: **weekend** for the indie phases; the production-only milestones add the trust and operability layer. ## M1 · A survey you can edit without rewriting history Publishing freezes a version and every response points at exactly one. ### Steps 1. Freeze the schema on publish A published version is read only for the rest of its life. Files: `src/versions.ts`, `tests/version-immutable.test.ts` 2. Keep question ids stable across versions Otherwise answers are keyed to a position that moved. Files: `src/schema.ts` 3. Show the version everywhere a count appears The number is meaningless without knowing which question produced it. Files: `src/app/admin/page.tsx` ### Done when - [ ] Editing creates a new version, old schema unchanged - [ ] Each response records its version id permanently - [ ] Question ids survive a wording edit - [ ] Counts display the version they came from ### Watch out - Editing the wording of a live question in place means last week's answers now sit under a question nobody was asked, and this is discovered in the meeting where the chart is presented. Freeze on publish, version on edit, keep question ids stable, and show the version beside every count. ## M2 · A response that survives a closed laptop A half finished response resumes and a submitted one is never duplicated. ### Steps 1. Persist partials against a token Server side, because the browser is where the answers get lost. Files: `src/app/api/respond/route.ts` 2. Make submit idempotent on the token A retry after a timeout is the common case, not the rare one. Files: `src/submit.ts`, `tests/idempotent-submit.test.ts` 3. Record every rejection with a reason A response you refused is still something you need to be able to explain. Files: `src/events.ts` ### Done when - [ ] A replayed submission creates one response - [ ] A partial resumes on another browser - [ ] Submitted responses are immutable - [ ] Rejections are recorded with reasons ### Watch out - Holding partial answers only in the browser loses a ten minute survey to a tab crash and nobody starts again, while accepting a retried submission as a fresh row inflates every count by however many people had a flaky connection. Persist partials against the token and make submit idempotent on that token. ## M3 · Numbers with a definition and a base Every figure reported carries its calculation and how many people it came from. ### Steps 1. Compute from the bands, not the mean The definition is public and the wrong calculation is obvious to anyone who knows it. Files: `src/scoring.ts`, `tests/nps.test.ts` 2. Show the base count beside every number Eleven responses and eleven thousand should not look the same. Files: `src/components/Figure.tsx` 3. Mark version boundaries on charts A wording change is a discontinuity, so draw it as one. Files: `src/charts.ts` ### Done when - [ ] The score matches a hand calculation on a fixture - [ ] The result is a whole number in range with band counts - [ ] Every figure shows its base count - [ ] Charts mark version boundaries ### Watch out - Reporting a score from eleven responses as one number with a decimal place invites a decision it cannot carry, and computing a promoter score as the average of the zero to ten answers gives a figure in the wrong range that nobody can reconcile with anyone else's. Compute from the bands, show the base, and round to a whole number. ## M4 · An open endpoint on the public internet Collecting without accounts survives being discovered. ### Steps 1. Cap the link by responses and by date The simplest defence is a survey that is closed. Files: `src/limits.ts` 2. Limit size before parsing Refuse the body you have not read rather than the object you built from it. Files: `src/app/api/respond/route.ts` 3. Refuse visibly, never silently A respondent who thinks they submitted and did not is worse than one who was told no. Files: `src/app/respond/page.tsx` ### Done when - [ ] A capped link refuses and records refusals - [ ] Rate limited requests are rejected with reasons - [ ] Oversized payloads are refused before parsing - [ ] No hidden trap fields or third party challenges exist ### Watch out - An uncapped public form is a free database write for whoever finds it, and the reflex fix is a hidden field that penalises anyone using a screen reader or a password manager. Cap the link, rate limit by address and token, record why each rejection happened, and keep every anti abuse measure visible to the respondent. ## M5 · Answers you can take away, and promises you can keep (production only) Export, retention and the anonymity claim all mean exactly what they say. ### Steps 1. Export keyed by question id and version Column positions change between versions; ids do not. Files: `src/export.ts`, `tests/export-roundtrip.test.ts` 2. Make anonymity a collection decision Not a display decision, and not a deletion after the fact. Files: `src/anonymity.ts`, `tests/no-address-stored.test.ts` 3. Delete on retention and log the count A retention setting nobody has watched delete anything is a setting, not a policy. Files: `src/retention.ts` ### Done when - [ ] Export and re-import reproduce the same answers - [ ] An anonymous link stores no address in database or logs - [ ] Retention deletes and logs the count - [ ] Respondents see which kind of link they are on ### Watch out - Telling respondents a survey is anonymous while the web server writes their address to an access log makes the promise false in a way that only surfaces when somebody asks for the logs. Decide anonymity per link, and when it is on, do not collect the address anywhere in the stack rather than collecting and discarding it.
# Operations · SurveySparrow ## Backup The SQLite file, including the events table, because when somebody disputes a count the rejection reasons are what answer them. Back up before every deployment that touches the schema. ## Restore Restore, then confirm response counts per version match the last export and that no partial rows have been promoted to submitted. Run the export round trip once after restoring. Do a restore drill before the first real user, and write the date here when it passes. ## Monitoring Rejection counts by reason, responses per link against the cap, and the age of the oldest response against the retention window. A spike of rejections on one link usually means the link leaked somewhere public. ## Incident checklist The two incidents are a count that looks wrong, which the events table and the version pinning exist to explain, and an anonymous link that turned out to collect something, which is a code path that received an address it should never have seen. 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 - [ ] Editing is confirmed to create a version rather than mutate one - [ ] A replayed submission is confirmed to create one response - [ ] The promoter score is confirmed against a hand calculation - [ ] Anonymous links are confirmed to store no address anywhere - [ ] Retention is confirmed to delete and log the count ## Launch constraint Do not market omitted SurveySparrow 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. Surveys, versions, responses and events. DATABASE_PATH=./data/surveys.db # Required. Used to build share links. Wrong here means links that go nowhere. PUBLIC_BASE_URL=https://surveys.example.com # Required · secret. The only login. The response endpoint is the only thing outside it. ADMIN_PASSWORD=a long random string # Required · secret. Signs response tokens, which carry idempotency and resume. RESPONSE_TOKEN_SECRET=sk-...redacted # Required. Per address, per link. Rejections are recorded with a reason. RATE_LIMIT_PER_HOUR=60 # Required. Refused before parsing rather than after. MAX_PAYLOAD_BYTES=65536 # Required. Responses are deleted past this, and the count is logged. RETENTION_DAYS=730 # Optional · secret. Optional, and only for notifying yourself. Unset means the interface only. SMTP_URL=smtps://user:pass@smtp.example.com:465
$ choose a build depth, inspect the files, then open the complete pack in your agent
SurveySparrow: Customers pay for dependable collection, spam control, notifications, integrations, and confidence that responses will not disappear.
xenterprise workflow logic
xspam and abuse defense at scale
xlarge integration and template catalog
xcompliance controls and guaranteed delivery
Don't feel like building it? These folks already made it free.
no votes, no pay-to-list · just what's real
SurveySparrow pricing
| plan | monthly | annual (per mo) | what you get |
|---|---|---|---|
| free | $0/workspace | $0/workspace | 75 responses/quarter; 3 active Classic surveys; 1 active CX survey; 10 questions/survey; 1 user; 1,000 contacts; 200 emails; 0 API calls/day. |
| basic | — | $19/workspace | 2,500 responses/year; 1 user; 100 Classic surveys; 1 CX survey; 10,000 contacts; 7,500 emails; 500 API calls/day. |
| starter | — | $39/workspace | 15,000 responses/year; 1 user; 1,000 Classic surveys; 1 CX survey; 20,000 contacts; 45,000 emails; 1,000 API calls/day. |
| business | — | $79/workspace | 36,000 responses/year; 3 users; 1,000 Classic surveys; 1 CX survey; 200,000 contacts; 108,000 emails; 40 document credits; 1,000 API calls/day. |
| enterprise | custom | — | 3+ users with custom responses, contacts, emails, API limits, security and support. |
free tier75 responses/quarter; 3 active Classic surveys; 1 active CX survey; 10 questions/survey; 1 user; 1,000 contacts; 200 emails; 0 API calls/day. An official FAQ conflicts by stating 50 responses/month.
billingQuarterly + annual; the public card checked exposed annual-equivalent prices but not exact quarterly charges.
hidden costsAdditional users cost $49/user/month; extra response/API capacity is quote-based; purchased extra responses expire rather than rolling indefinitely; subscriptions auto-renew.
verified 2026-08-12 · source ↗
Vibecode SurveySparrow
Kinda. The core of SurveySparrow is buildable in a weekend with the prompt on this page, but there are real gaps: enterprise workflow logic, spam and abuse defense at scale. Read the honest list above before committing.
How much does SurveySparrow cost?
SurveySparrow's pricing is usage-based or varies by plan · The official pricing URL is recorded, but a stable current amount was not safely recoverable in this pass. Do not publish a number until rechecked..
What do I lose by replacing SurveySparrow?
Honestly: enterprise workflow logic; spam and abuse defense at scale; large integration and template catalog; compliance controls and guaranteed delivery. If any of those are load-bearing for you, keep paying.
Is there an open-source alternative to SurveySparrow?
Yes: Formbricks (Serious survey machinery without the enterprise invoice; self-hosting still means owning the machinery) HeyForm (Typeform's manners, minus the monthly rent) The prompt is for when you want it exactly your way.