Vibecode Survicate
track this build4 phases, 12 steps, beginner friendly0%The visible customer feedback loop is buildable, but a credible replacement needs more than the first screen. Survicate 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 Survicate. 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 ===== # Survicate · indie build A survey tool whose hard problems are versioning questions so historical answers stay meaningful, keeping partial responses rather than losing them at the close of a tab, and embedding on someone else's site without breaking it. 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 15 and TypeScript, one process, public collection route separated from the admin | the public route takes untrusted traffic and should share as little as possible | | Storage | SQLite, one answer row per question written as it arrives | a response that is abandoned halfway is still data | | Versioning | Published survey versions are immutable, responses point at one | this is the whole reason the numbers can be trusted later | | Embed | One small script that renders into a shadow root | the host page's styles and yours must not meet | | Identity | Opaque invite tokens, stored hashed | the invite table should not read as a list of who said what | ## 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 collection endpoint. - Get it: nodejs.org, or nvm install 22. - Verify: node --version prints v22 or higher - [ ] **A place to serve the public collection route** · a few pounds a month, or free on a small tier - Why: A survey nobody can reach is a form, and the embed must be loaded from a real origin over HTTPS to test the policy behaviour. - Get it: Any small host or tunnel that gives you an HTTPS origin. - Verify: The widget script loads on an external page over HTTPS - [ ] **A page with aggressive CSS and a strict content security policy** · free - Why: Embed isolation is only provable against a hostile page, and every real site is one. - Get it: A static page with global element selectors and a restrictive policy header, served locally. - Verify: The widget renders correctly and reports clearly if blocked - [ ] **What you call the recommendation metric** · free - Why: Naming it after the registered mark in anything commercial needs written consent, and the decision is easier before the labels are in the code. - Get it: Read the current licensing terms, then write down the name you will use in the interface and the exports. - Verify: The chosen name appears in the schema, interface and README - [ ] **Your retention period and minimum sample** · free - Why: Both are enforced by code and both are tempting to set after seeing the data, which is the wrong order. - Get it: Write down how long responses are kept and the smallest sample that may be shown as a percentage. - Verify: Both are in configuration and named in tests ## 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: - The Net Promoter name and marks. - Visitor identification and cross-site tracking. - Email and mobile survey channels. - A template and integration catalogue. - spam and abuse defense at scale - large integration and template catalog - compliance controls and guaranteed delivery - resilient polling of malformed and rate-limited feeds - enterprise workflow logic If one of those is essential to you, that is the reason to keep paying for Survicate, and the README should say so rather than pretend. ===== BRIEF.md ===== # Build brief · Survicate 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 whose answers stay comparable after you edit the questions to replace Survicate. 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 about honesty, one legal and one statistical. The zero to ten 'how likely are you to recommend' question is yours to ask. The name is not. Net Promoter, NPS and the related marks are registered trademarks of Bain and Company, NICE Systems and Fred Reichheld, and Bain's own licensing page states that they may not be used commercially without Bain's prior written consent in the form of a licence. Call the metric what it measures, or read the licensing terms first and decide deliberately. Second, a published question is immutable. Editing the wording of a live question and keeping its key silently merges answers to two different questions into one chart, and nobody looking at that chart can tell. Publishing creates a new version, a response records which version it answered, and every export and every chart carries the version. ### Stack (fixed, do not substitute) - Runtime: Next.js 15 and TypeScript, one process, public collection route separated from the admin · the public route takes untrusted traffic and should share as little as possible - Storage: SQLite, one answer row per question written as it arrives · a response that is abandoned halfway is still data - Versioning: Published survey versions are immutable, responses point at one · this is the whole reason the numbers can be trusted later - Embed: One small script that renders into a shadow root · the host page's styles and yours must not meet - Identity: Opaque invite tokens, stored hashed · the invite table should not read as a list of who said what ### Data model (create this before Phase 1) - surveys(id, name, status, created_at) - survey_versions(id, survey_id, version, schema_json, published_at) · immutable once published - responses(id, survey_id, version_id, token_hash, started_at, last_seen_at, completed_at, source, locale) - answers(response_id, question_key, value_json, answered_at) · written as each question is answered - invites(id, survey_id, token_hash, label, expires_at, used_at) · the raw token is never stored - events(id, response_id, kind, at) · started, resumed, submitted, abandoned, with nothing else recorded - question_keys are stable identifiers whose meaning never changes; changing the wording means a new version, not a new meaning for an old key. ### Phase 1 · Questions that stay the same question Build: Model a survey as an ordered schema of typed questions with stable keys. Publishing writes an immutable version row. Editing a published survey creates a draft that becomes the next version on publish. Every response stores its version id, and every chart is grouped by it. Trap: Editing a live question in place because it was only a typo means the fifty answers collected yesterday and the fifty collected tomorrow were given to different questions, and the chart that averages them is wrong in a way no test and no reviewer will notice. Freeze on publish and version the schema, even for a typo. Done when: A published version cannot be modified, tested by attempting it through the API. Editing a survey with existing responses produces a new version and leaves the old answers attached to the old one. A chart that spans two versions either separates them or refuses, and never silently merges. Question keys are stable across versions and reusing a key for a different question is rejected at publish. Do not build yet: No public collection, logic, embedding or export. ### Phase 2 · Answers saved the moment they are given Build: Write each answer as its own row as it is answered, against a response created when the survey is opened. Resume by opaque token. Make submission idempotent so a double tap or a retried request does not create a second response. Mark a response abandoned on a timer rather than deleting it. Trap: Collecting the whole survey in the browser and posting it at the end loses every long response to a closed tab, a flat battery or a lost signal, and long responses come disproportionately from the people who had the most to say. Persist per answer, and treat submit as a state change rather than as the moment the data arrives. Done when: Closing the tab at question nine of twelve leaves nine answers stored and the response marked abandoned, tested end to end. Submitting twice produces one completed response, tested with a repeated request. Resuming with a valid token returns the respondent to the first unanswered question. A response whose token is unknown or expired is refused without revealing which of the two it was. Do not build yet: No branching logic, embedding or export yet. ### Phase 3 · Logic evaluated where it cannot be edited Build: Express branching as rules on the version schema: show a question when an earlier answer matches. Evaluate the next question on the server on each submitted answer. Detect at publish time any question that no path can reach, and any rule that refers to a later question. Trap: Enforcing required questions and branch conditions in the browser means anyone with the developer tools open can submit whatever they like, and more commonly a stale client from a previous version submits answers to questions the current branch never showed, producing rows nobody can explain. Decide the next question server side and reject an answer to a question the server did not ask. Done when: An answer posted for a question the server did not ask is rejected, tested by crafting the request. A required question cannot be skipped through the API. Publishing a schema with an unreachable question fails with the question named. A rule referring forwards to a later question is rejected at publish rather than looping at runtime. Do not build yet: No embedding, reporting or export yet. ### Phase 4 · An embed that cannot break someone's site Build: Ship one small script that creates a shadow root and renders the survey inside it, with all styles scoped there and no global selectors. Load it with a plain script tag and no inline code, so a strict content security policy on the host page does not silently block it. Add targeting by path and a sampling rate, and a frequency cap per visitor stored locally. Trap: Injecting the widget into the host page's DOM inherits their styles, so a survey that looks correct on your demo page renders as unstyled text on a real site, and any inline script you rely on is blocked outright by a content security policy with no error the site owner will ever see. Render into a shadow root, keep the script external, and log a clear failure to the console when the policy blocks you. Done when: The widget renders identically on a page with aggressive global CSS, tested against a hostile stylesheet fixture. Nothing the widget injects matches a selector outside its shadow root, verified in the test. The script loads with no inline execution and works under a strict content security policy, tested with the header set. Sampling and the per-visitor frequency cap are honoured across reloads. Do not build yet: No reporting or export yet. ### Phase 5 · Numbers that do not flatter you Build: Report completion as completed responses over responses started, and show both numbers beside every rate. Export to CSV with the version and question key on every row. Implement deletion by token that removes the response and its answers. Set a retention period and enforce it on a schedule. Trap: Computing a completion rate over completed responses only makes it ninety nine per cent forever, and quoting an average score without the number of responses behind it turns four answers into a company metric. Show the denominator next to every rate, and refuse to draw a chart below a stated minimum. Done when: Every rate in the interface displays its numerator and denominator. A chart with fewer responses than the stated minimum shows the count instead of a percentage. CSV export carries version, question key and answered time on every row and reimports into a spreadsheet without column drift. Deletion by token removes the response and its answers, and the deletion is recorded without retaining the answer content. Do not build yet: Nothing after this. ### Out of scope (and why) - Anything called Net Promoter or NPS by name, because the marks are registered and commercial use needs a licence from Bain; the question itself is fine to ask under a plain description. - Visitor identification and cross-site tracking, which turn a feedback tool into a surveillance tool and bring consent obligations with them. - Email and mobile distribution channels, each of which is a delivery project with its own reputation and deliverability problems. - A template and integration catalogue, which is the part of the paid product that is genuinely years of work. ### README must contain - That publishing freezes a version and every response records which version it answered - That answers are written as they are given, so abandoned responses keep their data - That branching and required questions are decided by the server and client claims are refused - That the embed renders into a shadow root, loads no inline script, and how to check it under a content security policy - The trademark position on the zero to ten recommendation question and why the metric is not named after it here ===== AGENTS.md ===== # Agent instructions · Survicate indie build - Read `README.md` and `BUILD_PLAN.md` before writing code. The stack is fixed: Next.js 15 and TypeScript, one process, public collection route separated from the admin, SQLite, one answer row per question written as it arrives, Published survey versions are immutable, responses point at one, One small script that renders into a shadow root, Opaque invite tokens, stored hashed. 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 a live question in place because it was only a typo means the fifty answers collected yesterday and the fifty collected tomorrow were given to different questions, and the chart that averages them is wrong in a way no test and no reviewer will notice. Freeze on publish and version the schema, even for a typo. - Collecting the whole survey in the browser and posting it at the end loses every long response to a closed tab, a flat battery or a lost signal, and long responses come disproportionately from the people who had the most to say. Persist per answer, and treat submit as a state change rather than as the moment the data arrives. - Enforcing required questions and branch conditions in the browser means anyone with the developer tools open can submit whatever they like, and more commonly a stale client from a previous version submits answers to questions the current branch never showed, producing rows nobody can explain. Decide the next question server side and reject an answer to a question the server did not ask. - Injecting the widget into the host page's DOM inherits their styles, so a survey that looks correct on your demo page renders as unstyled text on a real site, and any inline script you rely on is blocked outright by a content security policy with no error the site owner will ever see. Render into a shadow root, keep the script external, and log a clear failure to the console when the policy blocks you. ===== BUILD_PLAN.md ===== # Build plan · Survicate A survey tool whose hard problems are versioning questions so historical answers stay meaningful, keeping partial responses rather than losing them at the close of a tab, and embedding on someone else's site without breaking it. Phases are in dependency order. Each ends in a "Done when" list; treat an unticked item as a blocker, not a note. ## Phase 1 · Questions that stay the same question Publishing freezes a version and every response names the version it answered. ### Steps 1. Freeze the schema on publish An immutable row is the only version control that survives a busy afternoon. Files: `src/lib/versions.ts`, `tests/immutable-version.test.ts` 2. Point every response at a version Without this the data is uninterpretable within a month. Files: `src/db/schema.sql` 3. Reject a reused key with a new meaning The one mistake that quietly corrupts a year of answers. Files: `src/lib/publish.ts` ### Done when - [ ] A published version cannot be modified - [ ] Editing with responses creates a new version - [ ] Charts never merge two versions silently - [ ] Reusing a key for a different question is rejected ### Watch out - Editing a live question in place because it was only a typo means the fifty answers collected yesterday and the fifty collected tomorrow were given to different questions, and the chart that averages them is wrong in a way no test and no reviewer will notice. Freeze on publish and version the schema, even for a typo. ## Phase 2 · Answers saved the moment they are given A response abandoned at question nine still contains nine answers. ### Steps 1. Write one row per answer, as it happens The abandoned responses are data, and often the interesting kind. Files: `src/app/api/respond/route.ts` 2. Make submit idempotent Mobile networks retry, and people tap twice. Files: `tests/idempotent-submit.test.ts` 3. Hash invite tokens The table should not be readable as a roster of who answered what. Files: `src/lib/tokens.ts` ### Done when - [ ] Abandoning midway keeps the answers given - [ ] Double submission creates one response - [ ] Resume returns to the first unanswered question - [ ] Unknown and expired tokens fail identically ### Watch out - Collecting the whole survey in the browser and posting it at the end loses every long response to a closed tab, a flat battery or a lost signal, and long responses come disproportionately from the people who had the most to say. Persist per answer, and treat submit as a state change rather than as the moment the data arrives. ## Phase 3 · Logic evaluated where it cannot be edited Branching and required questions are decided by the server. ### Steps 1. Decide the next question on the server The client asks what comes next; it does not decide. Files: `src/lib/logic.ts`, `tests/logic.test.ts` 2. Reject answers to unasked questions This is the check that makes the rest of the logic mean anything. Files: `src/app/api/respond/route.ts` 3. Catch unreachable questions at publish Better than discovering it when the data has a hole in it. Files: `src/lib/publish.ts` ### Done when - [ ] Answers to unasked questions are rejected - [ ] Required questions cannot be skipped through the API - [ ] Unreachable questions fail publication - [ ] Forward references are rejected at publish ### Watch out - Enforcing required questions and branch conditions in the browser means anyone with the developer tools open can submit whatever they like, and more commonly a stale client from a previous version submits answers to questions the current branch never showed, producing rows nobody can explain. Decide the next question server side and reject an answer to a question the server did not ask. ## Phase 4 · An embed that cannot break someone's site The widget renders inside a shadow root, styles nothing outside it, and fails quietly. ### Steps 1. Render into a shadow root The only reliable boundary between your CSS and theirs. Files: `src/embed/widget.ts` 2. Keep every line of script external Inline code is what a strict policy blocks first. Files: `src/embed/loader.ts` 3. Test against a hostile stylesheet A demo page with no CSS proves nothing. Files: `tests/embed-isolation.test.ts` ### Done when - [ ] Rendering survives a hostile host stylesheet - [ ] Nothing matches selectors outside the shadow root - [ ] The widget works under a strict content security policy - [ ] Sampling and frequency caps hold across reloads ### Watch out - Injecting the widget into the host page's DOM inherits their styles, so a survey that looks correct on your demo page renders as unstyled text on a real site, and any inline script you rely on is blocked outright by a content security policy with no error the site owner will ever see. Render into a shadow root, keep the script external, and log a clear failure to the console when the policy blocks you. ## Not in this build - The Net Promoter name and marks. - Visitor identification and cross-site tracking. - Email and mobile survey channels. - A template and integration catalogue. ## After v1, if you want it - Translations per version, which the immutable schema makes safe to add - A sampling report showing who was eligible and who was shown the survey - Scheduled exports to a folder, so the data outlives the app without anyone remembering to click ===== .env.example ===== # Copy to .env and fill in. Never commit .env; this file documents it. # Required. Surveys, versions, responses and answers. DATABASE_PATH=./data/surveys.db # Required. The origin the embed script and share links are built from. PUBLIC_BASE_URL=https://feedback.example.com # Required · secret. The admin side. The collection route needs no login and must not share the session. ADMIN_PASSWORD_HASH=argon2id$...redacted # Required · secret. Mixed in before hashing invite tokens so the table is not a lookup list. TOKEN_PEPPER=a long random string, redacted # Required. Responses older than this are deleted on a schedule. RETENTION_DAYS=365 # Required. Below this a report shows counts rather than a percentage. MIN_SAMPLE_FOR_PERCENT=20
You are building a lean indie version of Survicate. 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 ===== # Survicate · indie build A survey tool whose hard problems are versioning questions so historical answers stay meaningful, keeping partial responses rather than losing them at the close of a tab, and embedding on someone else's site without breaking it. 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 15 and TypeScript, one process, public collection route separated from the admin | the public route takes untrusted traffic and should share as little as possible | | Storage | SQLite, one answer row per question written as it arrives | a response that is abandoned halfway is still data | | Versioning | Published survey versions are immutable, responses point at one | this is the whole reason the numbers can be trusted later | | Embed | One small script that renders into a shadow root | the host page's styles and yours must not meet | | Identity | Opaque invite tokens, stored hashed | the invite table should not read as a list of who said what | ## 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 collection endpoint. - Get it: nodejs.org, or nvm install 22. - Verify: node --version prints v22 or higher - [ ] **A place to serve the public collection route** · a few pounds a month, or free on a small tier - Why: A survey nobody can reach is a form, and the embed must be loaded from a real origin over HTTPS to test the policy behaviour. - Get it: Any small host or tunnel that gives you an HTTPS origin. - Verify: The widget script loads on an external page over HTTPS - [ ] **A page with aggressive CSS and a strict content security policy** · free - Why: Embed isolation is only provable against a hostile page, and every real site is one. - Get it: A static page with global element selectors and a restrictive policy header, served locally. - Verify: The widget renders correctly and reports clearly if blocked - [ ] **What you call the recommendation metric** · free - Why: Naming it after the registered mark in anything commercial needs written consent, and the decision is easier before the labels are in the code. - Get it: Read the current licensing terms, then write down the name you will use in the interface and the exports. - Verify: The chosen name appears in the schema, interface and README - [ ] **Your retention period and minimum sample** · free - Why: Both are enforced by code and both are tempting to set after seeing the data, which is the wrong order. - Get it: Write down how long responses are kept and the smallest sample that may be shown as a percentage. - Verify: Both are in configuration and named in tests ## 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: - The Net Promoter name and marks. - Visitor identification and cross-site tracking. - Email and mobile survey channels. - A template and integration catalogue. - spam and abuse defense at scale - large integration and template catalog - compliance controls and guaranteed delivery - resilient polling of malformed and rate-limited feeds - enterprise workflow logic If one of those is essential to you, that is the reason to keep paying for Survicate, and the README should say so rather than pretend. ===== BRIEF.md ===== # Build brief · Survicate 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 whose answers stay comparable after you edit the questions to replace Survicate. 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 about honesty, one legal and one statistical. The zero to ten 'how likely are you to recommend' question is yours to ask. The name is not. Net Promoter, NPS and the related marks are registered trademarks of Bain and Company, NICE Systems and Fred Reichheld, and Bain's own licensing page states that they may not be used commercially without Bain's prior written consent in the form of a licence. Call the metric what it measures, or read the licensing terms first and decide deliberately. Second, a published question is immutable. Editing the wording of a live question and keeping its key silently merges answers to two different questions into one chart, and nobody looking at that chart can tell. Publishing creates a new version, a response records which version it answered, and every export and every chart carries the version. ### Stack (fixed, do not substitute) - Runtime: Next.js 15 and TypeScript, one process, public collection route separated from the admin · the public route takes untrusted traffic and should share as little as possible - Storage: SQLite, one answer row per question written as it arrives · a response that is abandoned halfway is still data - Versioning: Published survey versions are immutable, responses point at one · this is the whole reason the numbers can be trusted later - Embed: One small script that renders into a shadow root · the host page's styles and yours must not meet - Identity: Opaque invite tokens, stored hashed · the invite table should not read as a list of who said what ### Data model (create this before Phase 1) - surveys(id, name, status, created_at) - survey_versions(id, survey_id, version, schema_json, published_at) · immutable once published - responses(id, survey_id, version_id, token_hash, started_at, last_seen_at, completed_at, source, locale) - answers(response_id, question_key, value_json, answered_at) · written as each question is answered - invites(id, survey_id, token_hash, label, expires_at, used_at) · the raw token is never stored - events(id, response_id, kind, at) · started, resumed, submitted, abandoned, with nothing else recorded - question_keys are stable identifiers whose meaning never changes; changing the wording means a new version, not a new meaning for an old key. ### Phase 1 · Questions that stay the same question Build: Model a survey as an ordered schema of typed questions with stable keys. Publishing writes an immutable version row. Editing a published survey creates a draft that becomes the next version on publish. Every response stores its version id, and every chart is grouped by it. Trap: Editing a live question in place because it was only a typo means the fifty answers collected yesterday and the fifty collected tomorrow were given to different questions, and the chart that averages them is wrong in a way no test and no reviewer will notice. Freeze on publish and version the schema, even for a typo. Done when: A published version cannot be modified, tested by attempting it through the API. Editing a survey with existing responses produces a new version and leaves the old answers attached to the old one. A chart that spans two versions either separates them or refuses, and never silently merges. Question keys are stable across versions and reusing a key for a different question is rejected at publish. Do not build yet: No public collection, logic, embedding or export. ### Phase 2 · Answers saved the moment they are given Build: Write each answer as its own row as it is answered, against a response created when the survey is opened. Resume by opaque token. Make submission idempotent so a double tap or a retried request does not create a second response. Mark a response abandoned on a timer rather than deleting it. Trap: Collecting the whole survey in the browser and posting it at the end loses every long response to a closed tab, a flat battery or a lost signal, and long responses come disproportionately from the people who had the most to say. Persist per answer, and treat submit as a state change rather than as the moment the data arrives. Done when: Closing the tab at question nine of twelve leaves nine answers stored and the response marked abandoned, tested end to end. Submitting twice produces one completed response, tested with a repeated request. Resuming with a valid token returns the respondent to the first unanswered question. A response whose token is unknown or expired is refused without revealing which of the two it was. Do not build yet: No branching logic, embedding or export yet. ### Phase 3 · Logic evaluated where it cannot be edited Build: Express branching as rules on the version schema: show a question when an earlier answer matches. Evaluate the next question on the server on each submitted answer. Detect at publish time any question that no path can reach, and any rule that refers to a later question. Trap: Enforcing required questions and branch conditions in the browser means anyone with the developer tools open can submit whatever they like, and more commonly a stale client from a previous version submits answers to questions the current branch never showed, producing rows nobody can explain. Decide the next question server side and reject an answer to a question the server did not ask. Done when: An answer posted for a question the server did not ask is rejected, tested by crafting the request. A required question cannot be skipped through the API. Publishing a schema with an unreachable question fails with the question named. A rule referring forwards to a later question is rejected at publish rather than looping at runtime. Do not build yet: No embedding, reporting or export yet. ### Phase 4 · An embed that cannot break someone's site Build: Ship one small script that creates a shadow root and renders the survey inside it, with all styles scoped there and no global selectors. Load it with a plain script tag and no inline code, so a strict content security policy on the host page does not silently block it. Add targeting by path and a sampling rate, and a frequency cap per visitor stored locally. Trap: Injecting the widget into the host page's DOM inherits their styles, so a survey that looks correct on your demo page renders as unstyled text on a real site, and any inline script you rely on is blocked outright by a content security policy with no error the site owner will ever see. Render into a shadow root, keep the script external, and log a clear failure to the console when the policy blocks you. Done when: The widget renders identically on a page with aggressive global CSS, tested against a hostile stylesheet fixture. Nothing the widget injects matches a selector outside its shadow root, verified in the test. The script loads with no inline execution and works under a strict content security policy, tested with the header set. Sampling and the per-visitor frequency cap are honoured across reloads. Do not build yet: No reporting or export yet. ### Phase 5 · Numbers that do not flatter you Build: Report completion as completed responses over responses started, and show both numbers beside every rate. Export to CSV with the version and question key on every row. Implement deletion by token that removes the response and its answers. Set a retention period and enforce it on a schedule. Trap: Computing a completion rate over completed responses only makes it ninety nine per cent forever, and quoting an average score without the number of responses behind it turns four answers into a company metric. Show the denominator next to every rate, and refuse to draw a chart below a stated minimum. Done when: Every rate in the interface displays its numerator and denominator. A chart with fewer responses than the stated minimum shows the count instead of a percentage. CSV export carries version, question key and answered time on every row and reimports into a spreadsheet without column drift. Deletion by token removes the response and its answers, and the deletion is recorded without retaining the answer content. Do not build yet: Nothing after this. ### Out of scope (and why) - Anything called Net Promoter or NPS by name, because the marks are registered and commercial use needs a licence from Bain; the question itself is fine to ask under a plain description. - Visitor identification and cross-site tracking, which turn a feedback tool into a surveillance tool and bring consent obligations with them. - Email and mobile distribution channels, each of which is a delivery project with its own reputation and deliverability problems. - A template and integration catalogue, which is the part of the paid product that is genuinely years of work. ### README must contain - That publishing freezes a version and every response records which version it answered - That answers are written as they are given, so abandoned responses keep their data - That branching and required questions are decided by the server and client claims are refused - That the embed renders into a shadow root, loads no inline script, and how to check it under a content security policy - The trademark position on the zero to ten recommendation question and why the metric is not named after it here ===== AGENTS.md ===== # Agent instructions · Survicate indie build - Read `README.md` and `BUILD_PLAN.md` before writing code. The stack is fixed: Next.js 15 and TypeScript, one process, public collection route separated from the admin, SQLite, one answer row per question written as it arrives, Published survey versions are immutable, responses point at one, One small script that renders into a shadow root, Opaque invite tokens, stored hashed. 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 a live question in place because it was only a typo means the fifty answers collected yesterday and the fifty collected tomorrow were given to different questions, and the chart that averages them is wrong in a way no test and no reviewer will notice. Freeze on publish and version the schema, even for a typo. - Collecting the whole survey in the browser and posting it at the end loses every long response to a closed tab, a flat battery or a lost signal, and long responses come disproportionately from the people who had the most to say. Persist per answer, and treat submit as a state change rather than as the moment the data arrives. - Enforcing required questions and branch conditions in the browser means anyone with the developer tools open can submit whatever they like, and more commonly a stale client from a previous version submits answers to questions the current branch never showed, producing rows nobody can explain. Decide the next question server side and reject an answer to a question the server did not ask. - Injecting the widget into the host page's DOM inherits their styles, so a survey that looks correct on your demo page renders as unstyled text on a real site, and any inline script you rely on is blocked outright by a content security policy with no error the site owner will ever see. Render into a shadow root, keep the script external, and log a clear failure to the console when the policy blocks you. ===== BUILD_PLAN.md ===== # Build plan · Survicate A survey tool whose hard problems are versioning questions so historical answers stay meaningful, keeping partial responses rather than losing them at the close of a tab, and embedding on someone else's site without breaking it. Phases are in dependency order. Each ends in a "Done when" list; treat an unticked item as a blocker, not a note. ## Phase 1 · Questions that stay the same question Publishing freezes a version and every response names the version it answered. ### Steps 1. Freeze the schema on publish An immutable row is the only version control that survives a busy afternoon. Files: `src/lib/versions.ts`, `tests/immutable-version.test.ts` 2. Point every response at a version Without this the data is uninterpretable within a month. Files: `src/db/schema.sql` 3. Reject a reused key with a new meaning The one mistake that quietly corrupts a year of answers. Files: `src/lib/publish.ts` ### Done when - [ ] A published version cannot be modified - [ ] Editing with responses creates a new version - [ ] Charts never merge two versions silently - [ ] Reusing a key for a different question is rejected ### Watch out - Editing a live question in place because it was only a typo means the fifty answers collected yesterday and the fifty collected tomorrow were given to different questions, and the chart that averages them is wrong in a way no test and no reviewer will notice. Freeze on publish and version the schema, even for a typo. ## Phase 2 · Answers saved the moment they are given A response abandoned at question nine still contains nine answers. ### Steps 1. Write one row per answer, as it happens The abandoned responses are data, and often the interesting kind. Files: `src/app/api/respond/route.ts` 2. Make submit idempotent Mobile networks retry, and people tap twice. Files: `tests/idempotent-submit.test.ts` 3. Hash invite tokens The table should not be readable as a roster of who answered what. Files: `src/lib/tokens.ts` ### Done when - [ ] Abandoning midway keeps the answers given - [ ] Double submission creates one response - [ ] Resume returns to the first unanswered question - [ ] Unknown and expired tokens fail identically ### Watch out - Collecting the whole survey in the browser and posting it at the end loses every long response to a closed tab, a flat battery or a lost signal, and long responses come disproportionately from the people who had the most to say. Persist per answer, and treat submit as a state change rather than as the moment the data arrives. ## Phase 3 · Logic evaluated where it cannot be edited Branching and required questions are decided by the server. ### Steps 1. Decide the next question on the server The client asks what comes next; it does not decide. Files: `src/lib/logic.ts`, `tests/logic.test.ts` 2. Reject answers to unasked questions This is the check that makes the rest of the logic mean anything. Files: `src/app/api/respond/route.ts` 3. Catch unreachable questions at publish Better than discovering it when the data has a hole in it. Files: `src/lib/publish.ts` ### Done when - [ ] Answers to unasked questions are rejected - [ ] Required questions cannot be skipped through the API - [ ] Unreachable questions fail publication - [ ] Forward references are rejected at publish ### Watch out - Enforcing required questions and branch conditions in the browser means anyone with the developer tools open can submit whatever they like, and more commonly a stale client from a previous version submits answers to questions the current branch never showed, producing rows nobody can explain. Decide the next question server side and reject an answer to a question the server did not ask. ## Phase 4 · An embed that cannot break someone's site The widget renders inside a shadow root, styles nothing outside it, and fails quietly. ### Steps 1. Render into a shadow root The only reliable boundary between your CSS and theirs. Files: `src/embed/widget.ts` 2. Keep every line of script external Inline code is what a strict policy blocks first. Files: `src/embed/loader.ts` 3. Test against a hostile stylesheet A demo page with no CSS proves nothing. Files: `tests/embed-isolation.test.ts` ### Done when - [ ] Rendering survives a hostile host stylesheet - [ ] Nothing matches selectors outside the shadow root - [ ] The widget works under a strict content security policy - [ ] Sampling and frequency caps hold across reloads ### Watch out - Injecting the widget into the host page's DOM inherits their styles, so a survey that looks correct on your demo page renders as unstyled text on a real site, and any inline script you rely on is blocked outright by a content security policy with no error the site owner will ever see. Render into a shadow root, keep the script external, and log a clear failure to the console when the policy blocks you. ## Not in this build - The Net Promoter name and marks. - Visitor identification and cross-site tracking. - Email and mobile survey channels. - A template and integration catalogue. ## After v1, if you want it - Translations per version, which the immutable schema makes safe to add - A sampling report showing who was eligible and who was shown the survey - Scheduled exports to a folder, so the data outlives the app without anyone remembering to click ===== .env.example ===== # Copy to .env and fill in. Never commit .env; this file documents it. # Required. Surveys, versions, responses and answers. DATABASE_PATH=./data/surveys.db # Required. The origin the embed script and share links are built from. PUBLIC_BASE_URL=https://feedback.example.com # Required · secret. The admin side. The collection route needs no login and must not share the session. ADMIN_PASSWORD_HASH=argon2id$...redacted # Required · secret. Mixed in before hashing invite tokens so the table is not a lookup list. TOKEN_PEPPER=a long random string, redacted # Required. Responses older than this are deleted on a schedule. RETENTION_DAYS=365 # Required. Below this a report shows counts rather than a percentage. MIN_SAMPLE_FOR_PERCENT=20
You are building a production product version of Survicate. 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 ===== # Survicate · product brief ## Problem The visible customer feedback loop is buildable, but a credible replacement needs more than the first screen. Survicate 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 a chart from March still means what it meant in March, an abandoned response is still nine answers, the embed cannot break the site it sits on, and no figure appears without the number of people behind it. ## 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 - The Net Promoter name and marks. - Visitor identification and cross-site tracking. - Email and mobile survey channels. - A template and integration catalogue. - spam and abuse defense at scale - large integration and template catalog - compliance controls and guaranteed delivery - resilient polling of malformed and rate-limited feeds - enterprise workflow logic ## Success criteria - A published version is confirmed immutable - Abandoning at question nine is confirmed to keep nine answers - An answer to an unasked question is confirmed rejected - The embed is confirmed to render under a hostile stylesheet and a strict policy - Every reported rate is confirmed to show its denominator ===== BRIEF.md ===== # Build brief · Survicate 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 whose answers stay comparable after you edit the questions to replace Survicate. 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 about honesty, one legal and one statistical. The zero to ten 'how likely are you to recommend' question is yours to ask. The name is not. Net Promoter, NPS and the related marks are registered trademarks of Bain and Company, NICE Systems and Fred Reichheld, and Bain's own licensing page states that they may not be used commercially without Bain's prior written consent in the form of a licence. Call the metric what it measures, or read the licensing terms first and decide deliberately. Second, a published question is immutable. Editing the wording of a live question and keeping its key silently merges answers to two different questions into one chart, and nobody looking at that chart can tell. Publishing creates a new version, a response records which version it answered, and every export and every chart carries the version. ### Stack (fixed, do not substitute) - Runtime: Next.js 15 and TypeScript, one process, public collection route separated from the admin · the public route takes untrusted traffic and should share as little as possible - Storage: SQLite, one answer row per question written as it arrives · a response that is abandoned halfway is still data - Versioning: Published survey versions are immutable, responses point at one · this is the whole reason the numbers can be trusted later - Embed: One small script that renders into a shadow root · the host page's styles and yours must not meet - Identity: Opaque invite tokens, stored hashed · the invite table should not read as a list of who said what ### Data model (create this before Phase 1) - surveys(id, name, status, created_at) - survey_versions(id, survey_id, version, schema_json, published_at) · immutable once published - responses(id, survey_id, version_id, token_hash, started_at, last_seen_at, completed_at, source, locale) - answers(response_id, question_key, value_json, answered_at) · written as each question is answered - invites(id, survey_id, token_hash, label, expires_at, used_at) · the raw token is never stored - events(id, response_id, kind, at) · started, resumed, submitted, abandoned, with nothing else recorded - question_keys are stable identifiers whose meaning never changes; changing the wording means a new version, not a new meaning for an old key. ### Phase 1 · Questions that stay the same question Build: Model a survey as an ordered schema of typed questions with stable keys. Publishing writes an immutable version row. Editing a published survey creates a draft that becomes the next version on publish. Every response stores its version id, and every chart is grouped by it. Trap: Editing a live question in place because it was only a typo means the fifty answers collected yesterday and the fifty collected tomorrow were given to different questions, and the chart that averages them is wrong in a way no test and no reviewer will notice. Freeze on publish and version the schema, even for a typo. Done when: A published version cannot be modified, tested by attempting it through the API. Editing a survey with existing responses produces a new version and leaves the old answers attached to the old one. A chart that spans two versions either separates them or refuses, and never silently merges. Question keys are stable across versions and reusing a key for a different question is rejected at publish. Do not build yet: No public collection, logic, embedding or export. ### Phase 2 · Answers saved the moment they are given Build: Write each answer as its own row as it is answered, against a response created when the survey is opened. Resume by opaque token. Make submission idempotent so a double tap or a retried request does not create a second response. Mark a response abandoned on a timer rather than deleting it. Trap: Collecting the whole survey in the browser and posting it at the end loses every long response to a closed tab, a flat battery or a lost signal, and long responses come disproportionately from the people who had the most to say. Persist per answer, and treat submit as a state change rather than as the moment the data arrives. Done when: Closing the tab at question nine of twelve leaves nine answers stored and the response marked abandoned, tested end to end. Submitting twice produces one completed response, tested with a repeated request. Resuming with a valid token returns the respondent to the first unanswered question. A response whose token is unknown or expired is refused without revealing which of the two it was. Do not build yet: No branching logic, embedding or export yet. ### Phase 3 · Logic evaluated where it cannot be edited Build: Express branching as rules on the version schema: show a question when an earlier answer matches. Evaluate the next question on the server on each submitted answer. Detect at publish time any question that no path can reach, and any rule that refers to a later question. Trap: Enforcing required questions and branch conditions in the browser means anyone with the developer tools open can submit whatever they like, and more commonly a stale client from a previous version submits answers to questions the current branch never showed, producing rows nobody can explain. Decide the next question server side and reject an answer to a question the server did not ask. Done when: An answer posted for a question the server did not ask is rejected, tested by crafting the request. A required question cannot be skipped through the API. Publishing a schema with an unreachable question fails with the question named. A rule referring forwards to a later question is rejected at publish rather than looping at runtime. Do not build yet: No embedding, reporting or export yet. ### Phase 4 · An embed that cannot break someone's site Build: Ship one small script that creates a shadow root and renders the survey inside it, with all styles scoped there and no global selectors. Load it with a plain script tag and no inline code, so a strict content security policy on the host page does not silently block it. Add targeting by path and a sampling rate, and a frequency cap per visitor stored locally. Trap: Injecting the widget into the host page's DOM inherits their styles, so a survey that looks correct on your demo page renders as unstyled text on a real site, and any inline script you rely on is blocked outright by a content security policy with no error the site owner will ever see. Render into a shadow root, keep the script external, and log a clear failure to the console when the policy blocks you. Done when: The widget renders identically on a page with aggressive global CSS, tested against a hostile stylesheet fixture. Nothing the widget injects matches a selector outside its shadow root, verified in the test. The script loads with no inline execution and works under a strict content security policy, tested with the header set. Sampling and the per-visitor frequency cap are honoured across reloads. Do not build yet: No reporting or export yet. ### Phase 5 · Numbers that do not flatter you Build: Report completion as completed responses over responses started, and show both numbers beside every rate. Export to CSV with the version and question key on every row. Implement deletion by token that removes the response and its answers. Set a retention period and enforce it on a schedule. Trap: Computing a completion rate over completed responses only makes it ninety nine per cent forever, and quoting an average score without the number of responses behind it turns four answers into a company metric. Show the denominator next to every rate, and refuse to draw a chart below a stated minimum. Done when: Every rate in the interface displays its numerator and denominator. A chart with fewer responses than the stated minimum shows the count instead of a percentage. CSV export carries version, question key and answered time on every row and reimports into a spreadsheet without column drift. Deletion by token removes the response and its answers, and the deletion is recorded without retaining the answer content. Do not build yet: Nothing after this. ### Out of scope (and why) - Anything called Net Promoter or NPS by name, because the marks are registered and commercial use needs a licence from Bain; the question itself is fine to ask under a plain description. - Visitor identification and cross-site tracking, which turn a feedback tool into a surveillance tool and bring consent obligations with them. - Email and mobile distribution channels, each of which is a delivery project with its own reputation and deliverability problems. - A template and integration catalogue, which is the part of the paid product that is genuinely years of work. ### README must contain - That publishing freezes a version and every response records which version it answered - That answers are written as they are given, so abandoned responses keep their data - That branching and required questions are decided by the server and client claims are refused - That the embed renders into a shadow root, loads no inline script, and how to check it under a content security policy - The trademark position on the zero to ten recommendation question and why the metric is not named after it here ===== ARCHITECTURE.md ===== # Architecture · Survicate ## Stack | Part | Choice | Why | | --- | --- | --- | | Runtime | Next.js 15 and TypeScript, one process, public collection route separated from the admin | the public route takes untrusted traffic and should share as little as possible | | Storage | SQLite, one answer row per question written as it arrives | a response that is abandoned halfway is still data | | Versioning | Published survey versions are immutable, responses point at one | this is the whole reason the numbers can be trusted later | | Embed | One small script that renders into a shadow root | the host page's styles and yours must not meet | | Identity | Opaque invite tokens, stored hashed | the invite table should not read as a list of who said what | ## Modules Each module has one owner concern and a documented way to replace it. | Module | Owns | How to replace it | | --- | --- | --- | | Versions | Immutable published schemas and stable keys. | Any storage, provided publish freezes and responses reference a version. | | Collection | Per answer writes, resume and idempotent submit. | Any transport, provided answers persist before submission. | | Logic | Server side branching and required question enforcement. | Not swappable for client side evaluation, which is advisory at best. | | Embed | Shadow root rendering and external script loading. | Any renderer inside the shadow root. | | Reporting | Denominators, minimum samples and export. | Any presentation, provided rates keep their counts. | ## 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 answers. - `PUBLIC_BASE_URL` · required · The origin the embed script and share links are built from. - `ADMIN_PASSWORD_HASH` · required, secret · The admin side. The collection route needs no login and must not share the session. - `TOKEN_PEPPER` · required, secret · Mixed in before hashing invite tokens so the table is not a lookup list. - `RETENTION_DAYS` · required · Responses older than this are deleted on a schedule. - `MIN_SAMPLE_FOR_PERCENT` · required · Below this a report shows counts rather than a percentage. ## 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 · Survicate product build - Read `PRODUCT.md` and `ARCHITECTURE.md` before changing code. The stack is fixed: Next.js 15 and TypeScript, one process, public collection route separated from the admin, SQLite, one answer row per question written as it arrives, Published survey versions are immutable, responses point at one, One small script that renders into a shadow root, Opaque invite tokens, stored hashed. - 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 a live question in place because it was only a typo means the fifty answers collected yesterday and the fifty collected tomorrow were given to different questions, and the chart that averages them is wrong in a way no test and no reviewer will notice. Freeze on publish and version the schema, even for a typo. - Collecting the whole survey in the browser and posting it at the end loses every long response to a closed tab, a flat battery or a lost signal, and long responses come disproportionately from the people who had the most to say. Persist per answer, and treat submit as a state change rather than as the moment the data arrives. - Enforcing required questions and branch conditions in the browser means anyone with the developer tools open can submit whatever they like, and more commonly a stale client from a previous version submits answers to questions the current branch never showed, producing rows nobody can explain. Decide the next question server side and reject an answer to a question the server did not ask. - Injecting the widget into the host page's DOM inherits their styles, so a survey that looks correct on your demo page renders as unstyled text on a real site, and any inline script you rely on is blocked outright by a content security policy with no error the site owner will ever see. Render into a shadow root, keep the script external, and log a clear failure to the console when the policy blocks you. - Computing a completion rate over completed responses only makes it ninety nine per cent forever, and quoting an average score without the number of responses behind it turns four answers into a company metric. Show the denominator next to every rate, and refuse to draw a chart below a stated minimum. ===== MILESTONES.md ===== # Delivery milestones · Survicate Estimated effort: **weekend** for the indie phases; the production-only milestones add the trust and operability layer. ## M1 · Questions that stay the same question Publishing freezes a version and every response names the version it answered. ### Steps 1. Freeze the schema on publish An immutable row is the only version control that survives a busy afternoon. Files: `src/lib/versions.ts`, `tests/immutable-version.test.ts` 2. Point every response at a version Without this the data is uninterpretable within a month. Files: `src/db/schema.sql` 3. Reject a reused key with a new meaning The one mistake that quietly corrupts a year of answers. Files: `src/lib/publish.ts` ### Done when - [ ] A published version cannot be modified - [ ] Editing with responses creates a new version - [ ] Charts never merge two versions silently - [ ] Reusing a key for a different question is rejected ### Watch out - Editing a live question in place because it was only a typo means the fifty answers collected yesterday and the fifty collected tomorrow were given to different questions, and the chart that averages them is wrong in a way no test and no reviewer will notice. Freeze on publish and version the schema, even for a typo. ## M2 · Answers saved the moment they are given A response abandoned at question nine still contains nine answers. ### Steps 1. Write one row per answer, as it happens The abandoned responses are data, and often the interesting kind. Files: `src/app/api/respond/route.ts` 2. Make submit idempotent Mobile networks retry, and people tap twice. Files: `tests/idempotent-submit.test.ts` 3. Hash invite tokens The table should not be readable as a roster of who answered what. Files: `src/lib/tokens.ts` ### Done when - [ ] Abandoning midway keeps the answers given - [ ] Double submission creates one response - [ ] Resume returns to the first unanswered question - [ ] Unknown and expired tokens fail identically ### Watch out - Collecting the whole survey in the browser and posting it at the end loses every long response to a closed tab, a flat battery or a lost signal, and long responses come disproportionately from the people who had the most to say. Persist per answer, and treat submit as a state change rather than as the moment the data arrives. ## M3 · Logic evaluated where it cannot be edited Branching and required questions are decided by the server. ### Steps 1. Decide the next question on the server The client asks what comes next; it does not decide. Files: `src/lib/logic.ts`, `tests/logic.test.ts` 2. Reject answers to unasked questions This is the check that makes the rest of the logic mean anything. Files: `src/app/api/respond/route.ts` 3. Catch unreachable questions at publish Better than discovering it when the data has a hole in it. Files: `src/lib/publish.ts` ### Done when - [ ] Answers to unasked questions are rejected - [ ] Required questions cannot be skipped through the API - [ ] Unreachable questions fail publication - [ ] Forward references are rejected at publish ### Watch out - Enforcing required questions and branch conditions in the browser means anyone with the developer tools open can submit whatever they like, and more commonly a stale client from a previous version submits answers to questions the current branch never showed, producing rows nobody can explain. Decide the next question server side and reject an answer to a question the server did not ask. ## M4 · An embed that cannot break someone's site The widget renders inside a shadow root, styles nothing outside it, and fails quietly. ### Steps 1. Render into a shadow root The only reliable boundary between your CSS and theirs. Files: `src/embed/widget.ts` 2. Keep every line of script external Inline code is what a strict policy blocks first. Files: `src/embed/loader.ts` 3. Test against a hostile stylesheet A demo page with no CSS proves nothing. Files: `tests/embed-isolation.test.ts` ### Done when - [ ] Rendering survives a hostile host stylesheet - [ ] Nothing matches selectors outside the shadow root - [ ] The widget works under a strict content security policy - [ ] Sampling and frequency caps hold across reloads ### Watch out - Injecting the widget into the host page's DOM inherits their styles, so a survey that looks correct on your demo page renders as unstyled text on a real site, and any inline script you rely on is blocked outright by a content security policy with no error the site owner will ever see. Render into a shadow root, keep the script external, and log a clear failure to the console when the policy blocks you. ## M5 · Numbers that do not flatter you (production only) Every reported figure states its denominator, and a respondent can be removed. ### Steps 1. Print the denominator beside every rate A percentage without a count is a decoration. Files: `src/app/reports/page.tsx` 2. Export with version and key on every row Otherwise the export is only readable by this app, this month. Files: `src/lib/export.ts` 3. Delete by token, and record that you did Without keeping the content you were asked to remove. Files: `src/lib/deletion.ts`, `tests/deletion.test.ts` ### Done when - [ ] Rates show numerator and denominator - [ ] Small samples show counts, not percentages - [ ] Exports carry version and question key per row - [ ] Deletion removes answers and keeps only the fact ### Watch out - Computing a completion rate over completed responses only makes it ninety nine per cent forever, and quoting an average score without the number of responses behind it turns four answers into a company metric. Show the denominator next to every rate, and refuse to draw a chart below a stated minimum. ===== OPERATIONS.md ===== # Operations · Survicate ## Backup The database on a schedule, with the versions table treated as the important part: responses without their schema versions are uninterpretable, and the schema is small enough to back up often. ## Restore Restore and confirm that a response from before the last publish still resolves to its own version and renders with the wording it was actually shown. Do a restore drill before the first real user, and write the date here when it passes. ## Monitoring Responses started against responses completed per survey, the abandonment point distribution, and any spike in submissions from one source, which is usually automated traffic rather than enthusiasm. ## Incident checklist A sudden flood of responses is the common one. The response is a rate limit on the collection route and a review of the source field, not deletion, since the rows are the evidence of what happened. 1. Contain the issue without destroying evidence or user data. 2. Record the timeline and affected scope. 3. Rotate exposed secrets and revoke compromised sessions or credentials. 4. Restore from a verified backup when needed. 5. Document the root cause, the remediation and the regression test. ## Release gate - [ ] A published version is confirmed immutable - [ ] Abandoning at question nine is confirmed to keep nine answers - [ ] An answer to an unasked question is confirmed rejected - [ ] The embed is confirmed to render under a hostile stylesheet and a strict policy - [ ] Every reported rate is confirmed to show its denominator ## Launch constraint Do not market omitted Survicate 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 answers. DATABASE_PATH=./data/surveys.db # Required. The origin the embed script and share links are built from. PUBLIC_BASE_URL=https://feedback.example.com # Required · secret. The admin side. The collection route needs no login and must not share the session. ADMIN_PASSWORD_HASH=argon2id$...redacted # Required · secret. Mixed in before hashing invite tokens so the table is not a lookup list. TOKEN_PEPPER=a long random string, redacted # Required. Responses older than this are deleted on a schedule. RETENTION_DAYS=365 # Required. Below this a report shows counts rather than a percentage. MIN_SAMPLE_FOR_PERCENT=20
# Survicate · indie build A survey tool whose hard problems are versioning questions so historical answers stay meaningful, keeping partial responses rather than losing them at the close of a tab, and embedding on someone else's site without breaking it. 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 15 and TypeScript, one process, public collection route separated from the admin | the public route takes untrusted traffic and should share as little as possible | | Storage | SQLite, one answer row per question written as it arrives | a response that is abandoned halfway is still data | | Versioning | Published survey versions are immutable, responses point at one | this is the whole reason the numbers can be trusted later | | Embed | One small script that renders into a shadow root | the host page's styles and yours must not meet | | Identity | Opaque invite tokens, stored hashed | the invite table should not read as a list of who said what | ## 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 collection endpoint. - Get it: nodejs.org, or nvm install 22. - Verify: node --version prints v22 or higher - [ ] **A place to serve the public collection route** · a few pounds a month, or free on a small tier - Why: A survey nobody can reach is a form, and the embed must be loaded from a real origin over HTTPS to test the policy behaviour. - Get it: Any small host or tunnel that gives you an HTTPS origin. - Verify: The widget script loads on an external page over HTTPS - [ ] **A page with aggressive CSS and a strict content security policy** · free - Why: Embed isolation is only provable against a hostile page, and every real site is one. - Get it: A static page with global element selectors and a restrictive policy header, served locally. - Verify: The widget renders correctly and reports clearly if blocked - [ ] **What you call the recommendation metric** · free - Why: Naming it after the registered mark in anything commercial needs written consent, and the decision is easier before the labels are in the code. - Get it: Read the current licensing terms, then write down the name you will use in the interface and the exports. - Verify: The chosen name appears in the schema, interface and README - [ ] **Your retention period and minimum sample** · free - Why: Both are enforced by code and both are tempting to set after seeing the data, which is the wrong order. - Get it: Write down how long responses are kept and the smallest sample that may be shown as a percentage. - Verify: Both are in configuration and named in tests ## 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: - The Net Promoter name and marks. - Visitor identification and cross-site tracking. - Email and mobile survey channels. - A template and integration catalogue. - spam and abuse defense at scale - large integration and template catalog - compliance controls and guaranteed delivery - resilient polling of malformed and rate-limited feeds - enterprise workflow logic If one of those is essential to you, that is the reason to keep paying for Survicate, and the README should say so rather than pretend.
# Build brief · Survicate 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 whose answers stay comparable after you edit the questions to replace Survicate. 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 about honesty, one legal and one statistical. The zero to ten 'how likely are you to recommend' question is yours to ask. The name is not. Net Promoter, NPS and the related marks are registered trademarks of Bain and Company, NICE Systems and Fred Reichheld, and Bain's own licensing page states that they may not be used commercially without Bain's prior written consent in the form of a licence. Call the metric what it measures, or read the licensing terms first and decide deliberately. Second, a published question is immutable. Editing the wording of a live question and keeping its key silently merges answers to two different questions into one chart, and nobody looking at that chart can tell. Publishing creates a new version, a response records which version it answered, and every export and every chart carries the version. ### Stack (fixed, do not substitute) - Runtime: Next.js 15 and TypeScript, one process, public collection route separated from the admin · the public route takes untrusted traffic and should share as little as possible - Storage: SQLite, one answer row per question written as it arrives · a response that is abandoned halfway is still data - Versioning: Published survey versions are immutable, responses point at one · this is the whole reason the numbers can be trusted later - Embed: One small script that renders into a shadow root · the host page's styles and yours must not meet - Identity: Opaque invite tokens, stored hashed · the invite table should not read as a list of who said what ### Data model (create this before Phase 1) - surveys(id, name, status, created_at) - survey_versions(id, survey_id, version, schema_json, published_at) · immutable once published - responses(id, survey_id, version_id, token_hash, started_at, last_seen_at, completed_at, source, locale) - answers(response_id, question_key, value_json, answered_at) · written as each question is answered - invites(id, survey_id, token_hash, label, expires_at, used_at) · the raw token is never stored - events(id, response_id, kind, at) · started, resumed, submitted, abandoned, with nothing else recorded - question_keys are stable identifiers whose meaning never changes; changing the wording means a new version, not a new meaning for an old key. ### Phase 1 · Questions that stay the same question Build: Model a survey as an ordered schema of typed questions with stable keys. Publishing writes an immutable version row. Editing a published survey creates a draft that becomes the next version on publish. Every response stores its version id, and every chart is grouped by it. Trap: Editing a live question in place because it was only a typo means the fifty answers collected yesterday and the fifty collected tomorrow were given to different questions, and the chart that averages them is wrong in a way no test and no reviewer will notice. Freeze on publish and version the schema, even for a typo. Done when: A published version cannot be modified, tested by attempting it through the API. Editing a survey with existing responses produces a new version and leaves the old answers attached to the old one. A chart that spans two versions either separates them or refuses, and never silently merges. Question keys are stable across versions and reusing a key for a different question is rejected at publish. Do not build yet: No public collection, logic, embedding or export. ### Phase 2 · Answers saved the moment they are given Build: Write each answer as its own row as it is answered, against a response created when the survey is opened. Resume by opaque token. Make submission idempotent so a double tap or a retried request does not create a second response. Mark a response abandoned on a timer rather than deleting it. Trap: Collecting the whole survey in the browser and posting it at the end loses every long response to a closed tab, a flat battery or a lost signal, and long responses come disproportionately from the people who had the most to say. Persist per answer, and treat submit as a state change rather than as the moment the data arrives. Done when: Closing the tab at question nine of twelve leaves nine answers stored and the response marked abandoned, tested end to end. Submitting twice produces one completed response, tested with a repeated request. Resuming with a valid token returns the respondent to the first unanswered question. A response whose token is unknown or expired is refused without revealing which of the two it was. Do not build yet: No branching logic, embedding or export yet. ### Phase 3 · Logic evaluated where it cannot be edited Build: Express branching as rules on the version schema: show a question when an earlier answer matches. Evaluate the next question on the server on each submitted answer. Detect at publish time any question that no path can reach, and any rule that refers to a later question. Trap: Enforcing required questions and branch conditions in the browser means anyone with the developer tools open can submit whatever they like, and more commonly a stale client from a previous version submits answers to questions the current branch never showed, producing rows nobody can explain. Decide the next question server side and reject an answer to a question the server did not ask. Done when: An answer posted for a question the server did not ask is rejected, tested by crafting the request. A required question cannot be skipped through the API. Publishing a schema with an unreachable question fails with the question named. A rule referring forwards to a later question is rejected at publish rather than looping at runtime. Do not build yet: No embedding, reporting or export yet. ### Phase 4 · An embed that cannot break someone's site Build: Ship one small script that creates a shadow root and renders the survey inside it, with all styles scoped there and no global selectors. Load it with a plain script tag and no inline code, so a strict content security policy on the host page does not silently block it. Add targeting by path and a sampling rate, and a frequency cap per visitor stored locally. Trap: Injecting the widget into the host page's DOM inherits their styles, so a survey that looks correct on your demo page renders as unstyled text on a real site, and any inline script you rely on is blocked outright by a content security policy with no error the site owner will ever see. Render into a shadow root, keep the script external, and log a clear failure to the console when the policy blocks you. Done when: The widget renders identically on a page with aggressive global CSS, tested against a hostile stylesheet fixture. Nothing the widget injects matches a selector outside its shadow root, verified in the test. The script loads with no inline execution and works under a strict content security policy, tested with the header set. Sampling and the per-visitor frequency cap are honoured across reloads. Do not build yet: No reporting or export yet. ### Phase 5 · Numbers that do not flatter you Build: Report completion as completed responses over responses started, and show both numbers beside every rate. Export to CSV with the version and question key on every row. Implement deletion by token that removes the response and its answers. Set a retention period and enforce it on a schedule. Trap: Computing a completion rate over completed responses only makes it ninety nine per cent forever, and quoting an average score without the number of responses behind it turns four answers into a company metric. Show the denominator next to every rate, and refuse to draw a chart below a stated minimum. Done when: Every rate in the interface displays its numerator and denominator. A chart with fewer responses than the stated minimum shows the count instead of a percentage. CSV export carries version, question key and answered time on every row and reimports into a spreadsheet without column drift. Deletion by token removes the response and its answers, and the deletion is recorded without retaining the answer content. Do not build yet: Nothing after this. ### Out of scope (and why) - Anything called Net Promoter or NPS by name, because the marks are registered and commercial use needs a licence from Bain; the question itself is fine to ask under a plain description. - Visitor identification and cross-site tracking, which turn a feedback tool into a surveillance tool and bring consent obligations with them. - Email and mobile distribution channels, each of which is a delivery project with its own reputation and deliverability problems. - A template and integration catalogue, which is the part of the paid product that is genuinely years of work. ### README must contain - That publishing freezes a version and every response records which version it answered - That answers are written as they are given, so abandoned responses keep their data - That branching and required questions are decided by the server and client claims are refused - That the embed renders into a shadow root, loads no inline script, and how to check it under a content security policy - The trademark position on the zero to ten recommendation question and why the metric is not named after it here
# Agent instructions · Survicate indie build - Read `README.md` and `BUILD_PLAN.md` before writing code. The stack is fixed: Next.js 15 and TypeScript, one process, public collection route separated from the admin, SQLite, one answer row per question written as it arrives, Published survey versions are immutable, responses point at one, One small script that renders into a shadow root, Opaque invite tokens, stored hashed. 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 a live question in place because it was only a typo means the fifty answers collected yesterday and the fifty collected tomorrow were given to different questions, and the chart that averages them is wrong in a way no test and no reviewer will notice. Freeze on publish and version the schema, even for a typo. - Collecting the whole survey in the browser and posting it at the end loses every long response to a closed tab, a flat battery or a lost signal, and long responses come disproportionately from the people who had the most to say. Persist per answer, and treat submit as a state change rather than as the moment the data arrives. - Enforcing required questions and branch conditions in the browser means anyone with the developer tools open can submit whatever they like, and more commonly a stale client from a previous version submits answers to questions the current branch never showed, producing rows nobody can explain. Decide the next question server side and reject an answer to a question the server did not ask. - Injecting the widget into the host page's DOM inherits their styles, so a survey that looks correct on your demo page renders as unstyled text on a real site, and any inline script you rely on is blocked outright by a content security policy with no error the site owner will ever see. Render into a shadow root, keep the script external, and log a clear failure to the console when the policy blocks you.
# Build plan · Survicate A survey tool whose hard problems are versioning questions so historical answers stay meaningful, keeping partial responses rather than losing them at the close of a tab, and embedding on someone else's site without breaking it. Phases are in dependency order. Each ends in a "Done when" list; treat an unticked item as a blocker, not a note. ## Phase 1 · Questions that stay the same question Publishing freezes a version and every response names the version it answered. ### Steps 1. Freeze the schema on publish An immutable row is the only version control that survives a busy afternoon. Files: `src/lib/versions.ts`, `tests/immutable-version.test.ts` 2. Point every response at a version Without this the data is uninterpretable within a month. Files: `src/db/schema.sql` 3. Reject a reused key with a new meaning The one mistake that quietly corrupts a year of answers. Files: `src/lib/publish.ts` ### Done when - [ ] A published version cannot be modified - [ ] Editing with responses creates a new version - [ ] Charts never merge two versions silently - [ ] Reusing a key for a different question is rejected ### Watch out - Editing a live question in place because it was only a typo means the fifty answers collected yesterday and the fifty collected tomorrow were given to different questions, and the chart that averages them is wrong in a way no test and no reviewer will notice. Freeze on publish and version the schema, even for a typo. ## Phase 2 · Answers saved the moment they are given A response abandoned at question nine still contains nine answers. ### Steps 1. Write one row per answer, as it happens The abandoned responses are data, and often the interesting kind. Files: `src/app/api/respond/route.ts` 2. Make submit idempotent Mobile networks retry, and people tap twice. Files: `tests/idempotent-submit.test.ts` 3. Hash invite tokens The table should not be readable as a roster of who answered what. Files: `src/lib/tokens.ts` ### Done when - [ ] Abandoning midway keeps the answers given - [ ] Double submission creates one response - [ ] Resume returns to the first unanswered question - [ ] Unknown and expired tokens fail identically ### Watch out - Collecting the whole survey in the browser and posting it at the end loses every long response to a closed tab, a flat battery or a lost signal, and long responses come disproportionately from the people who had the most to say. Persist per answer, and treat submit as a state change rather than as the moment the data arrives. ## Phase 3 · Logic evaluated where it cannot be edited Branching and required questions are decided by the server. ### Steps 1. Decide the next question on the server The client asks what comes next; it does not decide. Files: `src/lib/logic.ts`, `tests/logic.test.ts` 2. Reject answers to unasked questions This is the check that makes the rest of the logic mean anything. Files: `src/app/api/respond/route.ts` 3. Catch unreachable questions at publish Better than discovering it when the data has a hole in it. Files: `src/lib/publish.ts` ### Done when - [ ] Answers to unasked questions are rejected - [ ] Required questions cannot be skipped through the API - [ ] Unreachable questions fail publication - [ ] Forward references are rejected at publish ### Watch out - Enforcing required questions and branch conditions in the browser means anyone with the developer tools open can submit whatever they like, and more commonly a stale client from a previous version submits answers to questions the current branch never showed, producing rows nobody can explain. Decide the next question server side and reject an answer to a question the server did not ask. ## Phase 4 · An embed that cannot break someone's site The widget renders inside a shadow root, styles nothing outside it, and fails quietly. ### Steps 1. Render into a shadow root The only reliable boundary between your CSS and theirs. Files: `src/embed/widget.ts` 2. Keep every line of script external Inline code is what a strict policy blocks first. Files: `src/embed/loader.ts` 3. Test against a hostile stylesheet A demo page with no CSS proves nothing. Files: `tests/embed-isolation.test.ts` ### Done when - [ ] Rendering survives a hostile host stylesheet - [ ] Nothing matches selectors outside the shadow root - [ ] The widget works under a strict content security policy - [ ] Sampling and frequency caps hold across reloads ### Watch out - Injecting the widget into the host page's DOM inherits their styles, so a survey that looks correct on your demo page renders as unstyled text on a real site, and any inline script you rely on is blocked outright by a content security policy with no error the site owner will ever see. Render into a shadow root, keep the script external, and log a clear failure to the console when the policy blocks you. ## Not in this build - The Net Promoter name and marks. - Visitor identification and cross-site tracking. - Email and mobile survey channels. - A template and integration catalogue. ## After v1, if you want it - Translations per version, which the immutable schema makes safe to add - A sampling report showing who was eligible and who was shown the survey - Scheduled exports to a folder, so the data outlives the app without anyone remembering to click
# Copy to .env and fill in. Never commit .env; this file documents it. # Required. Surveys, versions, responses and answers. DATABASE_PATH=./data/surveys.db # Required. The origin the embed script and share links are built from. PUBLIC_BASE_URL=https://feedback.example.com # Required · secret. The admin side. The collection route needs no login and must not share the session. ADMIN_PASSWORD_HASH=argon2id$...redacted # Required · secret. Mixed in before hashing invite tokens so the table is not a lookup list. TOKEN_PEPPER=a long random string, redacted # Required. Responses older than this are deleted on a schedule. RETENTION_DAYS=365 # Required. Below this a report shows counts rather than a percentage. MIN_SAMPLE_FOR_PERCENT=20
# Survicate · product brief ## Problem The visible customer feedback loop is buildable, but a credible replacement needs more than the first screen. Survicate 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 a chart from March still means what it meant in March, an abandoned response is still nine answers, the embed cannot break the site it sits on, and no figure appears without the number of people behind it. ## 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 - The Net Promoter name and marks. - Visitor identification and cross-site tracking. - Email and mobile survey channels. - A template and integration catalogue. - spam and abuse defense at scale - large integration and template catalog - compliance controls and guaranteed delivery - resilient polling of malformed and rate-limited feeds - enterprise workflow logic ## Success criteria - A published version is confirmed immutable - Abandoning at question nine is confirmed to keep nine answers - An answer to an unasked question is confirmed rejected - The embed is confirmed to render under a hostile stylesheet and a strict policy - Every reported rate is confirmed to show its denominator
# Build brief · Survicate 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 whose answers stay comparable after you edit the questions to replace Survicate. 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 about honesty, one legal and one statistical. The zero to ten 'how likely are you to recommend' question is yours to ask. The name is not. Net Promoter, NPS and the related marks are registered trademarks of Bain and Company, NICE Systems and Fred Reichheld, and Bain's own licensing page states that they may not be used commercially without Bain's prior written consent in the form of a licence. Call the metric what it measures, or read the licensing terms first and decide deliberately. Second, a published question is immutable. Editing the wording of a live question and keeping its key silently merges answers to two different questions into one chart, and nobody looking at that chart can tell. Publishing creates a new version, a response records which version it answered, and every export and every chart carries the version. ### Stack (fixed, do not substitute) - Runtime: Next.js 15 and TypeScript, one process, public collection route separated from the admin · the public route takes untrusted traffic and should share as little as possible - Storage: SQLite, one answer row per question written as it arrives · a response that is abandoned halfway is still data - Versioning: Published survey versions are immutable, responses point at one · this is the whole reason the numbers can be trusted later - Embed: One small script that renders into a shadow root · the host page's styles and yours must not meet - Identity: Opaque invite tokens, stored hashed · the invite table should not read as a list of who said what ### Data model (create this before Phase 1) - surveys(id, name, status, created_at) - survey_versions(id, survey_id, version, schema_json, published_at) · immutable once published - responses(id, survey_id, version_id, token_hash, started_at, last_seen_at, completed_at, source, locale) - answers(response_id, question_key, value_json, answered_at) · written as each question is answered - invites(id, survey_id, token_hash, label, expires_at, used_at) · the raw token is never stored - events(id, response_id, kind, at) · started, resumed, submitted, abandoned, with nothing else recorded - question_keys are stable identifiers whose meaning never changes; changing the wording means a new version, not a new meaning for an old key. ### Phase 1 · Questions that stay the same question Build: Model a survey as an ordered schema of typed questions with stable keys. Publishing writes an immutable version row. Editing a published survey creates a draft that becomes the next version on publish. Every response stores its version id, and every chart is grouped by it. Trap: Editing a live question in place because it was only a typo means the fifty answers collected yesterday and the fifty collected tomorrow were given to different questions, and the chart that averages them is wrong in a way no test and no reviewer will notice. Freeze on publish and version the schema, even for a typo. Done when: A published version cannot be modified, tested by attempting it through the API. Editing a survey with existing responses produces a new version and leaves the old answers attached to the old one. A chart that spans two versions either separates them or refuses, and never silently merges. Question keys are stable across versions and reusing a key for a different question is rejected at publish. Do not build yet: No public collection, logic, embedding or export. ### Phase 2 · Answers saved the moment they are given Build: Write each answer as its own row as it is answered, against a response created when the survey is opened. Resume by opaque token. Make submission idempotent so a double tap or a retried request does not create a second response. Mark a response abandoned on a timer rather than deleting it. Trap: Collecting the whole survey in the browser and posting it at the end loses every long response to a closed tab, a flat battery or a lost signal, and long responses come disproportionately from the people who had the most to say. Persist per answer, and treat submit as a state change rather than as the moment the data arrives. Done when: Closing the tab at question nine of twelve leaves nine answers stored and the response marked abandoned, tested end to end. Submitting twice produces one completed response, tested with a repeated request. Resuming with a valid token returns the respondent to the first unanswered question. A response whose token is unknown or expired is refused without revealing which of the two it was. Do not build yet: No branching logic, embedding or export yet. ### Phase 3 · Logic evaluated where it cannot be edited Build: Express branching as rules on the version schema: show a question when an earlier answer matches. Evaluate the next question on the server on each submitted answer. Detect at publish time any question that no path can reach, and any rule that refers to a later question. Trap: Enforcing required questions and branch conditions in the browser means anyone with the developer tools open can submit whatever they like, and more commonly a stale client from a previous version submits answers to questions the current branch never showed, producing rows nobody can explain. Decide the next question server side and reject an answer to a question the server did not ask. Done when: An answer posted for a question the server did not ask is rejected, tested by crafting the request. A required question cannot be skipped through the API. Publishing a schema with an unreachable question fails with the question named. A rule referring forwards to a later question is rejected at publish rather than looping at runtime. Do not build yet: No embedding, reporting or export yet. ### Phase 4 · An embed that cannot break someone's site Build: Ship one small script that creates a shadow root and renders the survey inside it, with all styles scoped there and no global selectors. Load it with a plain script tag and no inline code, so a strict content security policy on the host page does not silently block it. Add targeting by path and a sampling rate, and a frequency cap per visitor stored locally. Trap: Injecting the widget into the host page's DOM inherits their styles, so a survey that looks correct on your demo page renders as unstyled text on a real site, and any inline script you rely on is blocked outright by a content security policy with no error the site owner will ever see. Render into a shadow root, keep the script external, and log a clear failure to the console when the policy blocks you. Done when: The widget renders identically on a page with aggressive global CSS, tested against a hostile stylesheet fixture. Nothing the widget injects matches a selector outside its shadow root, verified in the test. The script loads with no inline execution and works under a strict content security policy, tested with the header set. Sampling and the per-visitor frequency cap are honoured across reloads. Do not build yet: No reporting or export yet. ### Phase 5 · Numbers that do not flatter you Build: Report completion as completed responses over responses started, and show both numbers beside every rate. Export to CSV with the version and question key on every row. Implement deletion by token that removes the response and its answers. Set a retention period and enforce it on a schedule. Trap: Computing a completion rate over completed responses only makes it ninety nine per cent forever, and quoting an average score without the number of responses behind it turns four answers into a company metric. Show the denominator next to every rate, and refuse to draw a chart below a stated minimum. Done when: Every rate in the interface displays its numerator and denominator. A chart with fewer responses than the stated minimum shows the count instead of a percentage. CSV export carries version, question key and answered time on every row and reimports into a spreadsheet without column drift. Deletion by token removes the response and its answers, and the deletion is recorded without retaining the answer content. Do not build yet: Nothing after this. ### Out of scope (and why) - Anything called Net Promoter or NPS by name, because the marks are registered and commercial use needs a licence from Bain; the question itself is fine to ask under a plain description. - Visitor identification and cross-site tracking, which turn a feedback tool into a surveillance tool and bring consent obligations with them. - Email and mobile distribution channels, each of which is a delivery project with its own reputation and deliverability problems. - A template and integration catalogue, which is the part of the paid product that is genuinely years of work. ### README must contain - That publishing freezes a version and every response records which version it answered - That answers are written as they are given, so abandoned responses keep their data - That branching and required questions are decided by the server and client claims are refused - That the embed renders into a shadow root, loads no inline script, and how to check it under a content security policy - The trademark position on the zero to ten recommendation question and why the metric is not named after it here
# Architecture · Survicate ## Stack | Part | Choice | Why | | --- | --- | --- | | Runtime | Next.js 15 and TypeScript, one process, public collection route separated from the admin | the public route takes untrusted traffic and should share as little as possible | | Storage | SQLite, one answer row per question written as it arrives | a response that is abandoned halfway is still data | | Versioning | Published survey versions are immutable, responses point at one | this is the whole reason the numbers can be trusted later | | Embed | One small script that renders into a shadow root | the host page's styles and yours must not meet | | Identity | Opaque invite tokens, stored hashed | the invite table should not read as a list of who said what | ## Modules Each module has one owner concern and a documented way to replace it. | Module | Owns | How to replace it | | --- | --- | --- | | Versions | Immutable published schemas and stable keys. | Any storage, provided publish freezes and responses reference a version. | | Collection | Per answer writes, resume and idempotent submit. | Any transport, provided answers persist before submission. | | Logic | Server side branching and required question enforcement. | Not swappable for client side evaluation, which is advisory at best. | | Embed | Shadow root rendering and external script loading. | Any renderer inside the shadow root. | | Reporting | Denominators, minimum samples and export. | Any presentation, provided rates keep their counts. | ## 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 answers. - `PUBLIC_BASE_URL` · required · The origin the embed script and share links are built from. - `ADMIN_PASSWORD_HASH` · required, secret · The admin side. The collection route needs no login and must not share the session. - `TOKEN_PEPPER` · required, secret · Mixed in before hashing invite tokens so the table is not a lookup list. - `RETENTION_DAYS` · required · Responses older than this are deleted on a schedule. - `MIN_SAMPLE_FOR_PERCENT` · required · Below this a report shows counts rather than a percentage. ## 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 · Survicate product build - Read `PRODUCT.md` and `ARCHITECTURE.md` before changing code. The stack is fixed: Next.js 15 and TypeScript, one process, public collection route separated from the admin, SQLite, one answer row per question written as it arrives, Published survey versions are immutable, responses point at one, One small script that renders into a shadow root, Opaque invite tokens, stored hashed. - 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 a live question in place because it was only a typo means the fifty answers collected yesterday and the fifty collected tomorrow were given to different questions, and the chart that averages them is wrong in a way no test and no reviewer will notice. Freeze on publish and version the schema, even for a typo. - Collecting the whole survey in the browser and posting it at the end loses every long response to a closed tab, a flat battery or a lost signal, and long responses come disproportionately from the people who had the most to say. Persist per answer, and treat submit as a state change rather than as the moment the data arrives. - Enforcing required questions and branch conditions in the browser means anyone with the developer tools open can submit whatever they like, and more commonly a stale client from a previous version submits answers to questions the current branch never showed, producing rows nobody can explain. Decide the next question server side and reject an answer to a question the server did not ask. - Injecting the widget into the host page's DOM inherits their styles, so a survey that looks correct on your demo page renders as unstyled text on a real site, and any inline script you rely on is blocked outright by a content security policy with no error the site owner will ever see. Render into a shadow root, keep the script external, and log a clear failure to the console when the policy blocks you. - Computing a completion rate over completed responses only makes it ninety nine per cent forever, and quoting an average score without the number of responses behind it turns four answers into a company metric. Show the denominator next to every rate, and refuse to draw a chart below a stated minimum.
# Delivery milestones · Survicate Estimated effort: **weekend** for the indie phases; the production-only milestones add the trust and operability layer. ## M1 · Questions that stay the same question Publishing freezes a version and every response names the version it answered. ### Steps 1. Freeze the schema on publish An immutable row is the only version control that survives a busy afternoon. Files: `src/lib/versions.ts`, `tests/immutable-version.test.ts` 2. Point every response at a version Without this the data is uninterpretable within a month. Files: `src/db/schema.sql` 3. Reject a reused key with a new meaning The one mistake that quietly corrupts a year of answers. Files: `src/lib/publish.ts` ### Done when - [ ] A published version cannot be modified - [ ] Editing with responses creates a new version - [ ] Charts never merge two versions silently - [ ] Reusing a key for a different question is rejected ### Watch out - Editing a live question in place because it was only a typo means the fifty answers collected yesterday and the fifty collected tomorrow were given to different questions, and the chart that averages them is wrong in a way no test and no reviewer will notice. Freeze on publish and version the schema, even for a typo. ## M2 · Answers saved the moment they are given A response abandoned at question nine still contains nine answers. ### Steps 1. Write one row per answer, as it happens The abandoned responses are data, and often the interesting kind. Files: `src/app/api/respond/route.ts` 2. Make submit idempotent Mobile networks retry, and people tap twice. Files: `tests/idempotent-submit.test.ts` 3. Hash invite tokens The table should not be readable as a roster of who answered what. Files: `src/lib/tokens.ts` ### Done when - [ ] Abandoning midway keeps the answers given - [ ] Double submission creates one response - [ ] Resume returns to the first unanswered question - [ ] Unknown and expired tokens fail identically ### Watch out - Collecting the whole survey in the browser and posting it at the end loses every long response to a closed tab, a flat battery or a lost signal, and long responses come disproportionately from the people who had the most to say. Persist per answer, and treat submit as a state change rather than as the moment the data arrives. ## M3 · Logic evaluated where it cannot be edited Branching and required questions are decided by the server. ### Steps 1. Decide the next question on the server The client asks what comes next; it does not decide. Files: `src/lib/logic.ts`, `tests/logic.test.ts` 2. Reject answers to unasked questions This is the check that makes the rest of the logic mean anything. Files: `src/app/api/respond/route.ts` 3. Catch unreachable questions at publish Better than discovering it when the data has a hole in it. Files: `src/lib/publish.ts` ### Done when - [ ] Answers to unasked questions are rejected - [ ] Required questions cannot be skipped through the API - [ ] Unreachable questions fail publication - [ ] Forward references are rejected at publish ### Watch out - Enforcing required questions and branch conditions in the browser means anyone with the developer tools open can submit whatever they like, and more commonly a stale client from a previous version submits answers to questions the current branch never showed, producing rows nobody can explain. Decide the next question server side and reject an answer to a question the server did not ask. ## M4 · An embed that cannot break someone's site The widget renders inside a shadow root, styles nothing outside it, and fails quietly. ### Steps 1. Render into a shadow root The only reliable boundary between your CSS and theirs. Files: `src/embed/widget.ts` 2. Keep every line of script external Inline code is what a strict policy blocks first. Files: `src/embed/loader.ts` 3. Test against a hostile stylesheet A demo page with no CSS proves nothing. Files: `tests/embed-isolation.test.ts` ### Done when - [ ] Rendering survives a hostile host stylesheet - [ ] Nothing matches selectors outside the shadow root - [ ] The widget works under a strict content security policy - [ ] Sampling and frequency caps hold across reloads ### Watch out - Injecting the widget into the host page's DOM inherits their styles, so a survey that looks correct on your demo page renders as unstyled text on a real site, and any inline script you rely on is blocked outright by a content security policy with no error the site owner will ever see. Render into a shadow root, keep the script external, and log a clear failure to the console when the policy blocks you. ## M5 · Numbers that do not flatter you (production only) Every reported figure states its denominator, and a respondent can be removed. ### Steps 1. Print the denominator beside every rate A percentage without a count is a decoration. Files: `src/app/reports/page.tsx` 2. Export with version and key on every row Otherwise the export is only readable by this app, this month. Files: `src/lib/export.ts` 3. Delete by token, and record that you did Without keeping the content you were asked to remove. Files: `src/lib/deletion.ts`, `tests/deletion.test.ts` ### Done when - [ ] Rates show numerator and denominator - [ ] Small samples show counts, not percentages - [ ] Exports carry version and question key per row - [ ] Deletion removes answers and keeps only the fact ### Watch out - Computing a completion rate over completed responses only makes it ninety nine per cent forever, and quoting an average score without the number of responses behind it turns four answers into a company metric. Show the denominator next to every rate, and refuse to draw a chart below a stated minimum.
# Operations · Survicate ## Backup The database on a schedule, with the versions table treated as the important part: responses without their schema versions are uninterpretable, and the schema is small enough to back up often. ## Restore Restore and confirm that a response from before the last publish still resolves to its own version and renders with the wording it was actually shown. Do a restore drill before the first real user, and write the date here when it passes. ## Monitoring Responses started against responses completed per survey, the abandonment point distribution, and any spike in submissions from one source, which is usually automated traffic rather than enthusiasm. ## Incident checklist A sudden flood of responses is the common one. The response is a rate limit on the collection route and a review of the source field, not deletion, since the rows are the evidence of what happened. 1. Contain the issue without destroying evidence or user data. 2. Record the timeline and affected scope. 3. Rotate exposed secrets and revoke compromised sessions or credentials. 4. Restore from a verified backup when needed. 5. Document the root cause, the remediation and the regression test. ## Release gate - [ ] A published version is confirmed immutable - [ ] Abandoning at question nine is confirmed to keep nine answers - [ ] An answer to an unasked question is confirmed rejected - [ ] The embed is confirmed to render under a hostile stylesheet and a strict policy - [ ] Every reported rate is confirmed to show its denominator ## Launch constraint Do not market omitted Survicate 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 answers. DATABASE_PATH=./data/surveys.db # Required. The origin the embed script and share links are built from. PUBLIC_BASE_URL=https://feedback.example.com # Required · secret. The admin side. The collection route needs no login and must not share the session. ADMIN_PASSWORD_HASH=argon2id$...redacted # Required · secret. Mixed in before hashing invite tokens so the table is not a lookup list. TOKEN_PEPPER=a long random string, redacted # Required. Responses older than this are deleted on a schedule. RETENTION_DAYS=365 # Required. Below this a report shows counts rather than a percentage. MIN_SAMPLE_FOR_PERCENT=20
$ choose a build depth, inspect the files, then open the complete pack in your agent
Survicate: Customers pay for dependable collection, spam control, notifications, integrations, and confidence that responses will not disappear.
xspam and abuse defense at scale
xlarge integration and template catalog
xcompliance controls and guaranteed delivery
xresilient polling of malformed and rate-limited feeds
xenterprise workflow logic
Don't feel like building it? These folks already made it free.
no votes, no pay-to-list · just what's real
Survicate pricing
| plan | monthly | annual (per mo) | what you get |
|---|---|---|---|
| free | $0/workspace | $0/workspace | 25 responses/month; maximum 1,000 stored responses across active surveys; maximum 300 stored responses/survey; no data export. |
| starter | $56/workspace | — | 500 responses/month; 1,500 data points/month; 2 active surveys; 6-month retention; 5 seats; 1 team; 1 workspace; 1 folder; 3 AI chats; 25 integrations. |
| growth | — | $114/workspace | Starts at 250 responses/month; 1,000 data points/month; unlimited surveys; 10 seats; 2 teams; 1 dashboard. |
| pro | $349/workspace | — | Custom annual response/data allowance; custom seats; 10 dashboards. |
| enterprise | $569/workspace | — | Custom response/data allowance, seats, dashboards, security and service levels. |
free tier25 responses/month; maximum 1,000 stored responses across active surveys; maximum 300 stored responses/survey; no data export.
billingStarter is monthly; Growth is publicly shown as annual; Pro and Enterprise can be monthly or annual, with exact annual discounts dependent on configuration.
hidden costsStarter incurs response overages after 500 responses/month, but the rate is not publicly stated; higher-tier prices rise with response/data volume; the free plan stops collecting when its cap is reached.
verified 2026-08-12 · source ↗
Vibecode Survicate
Kinda. The core of Survicate is buildable in a weekend with the prompt on this page, but there are real gaps: spam and abuse defense at scale, large integration and template catalog. Read the honest list above before committing.
How much does Survicate cost?
Survicate'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 Survicate?
Honestly: spam and abuse defense at scale; large integration and template catalog; compliance controls and guaranteed delivery; resilient polling of malformed and rate-limited feeds; enterprise workflow logic. If any of those are load-bearing for you, keep paying.
Is there an open-source alternative to Survicate?
Yes: Formbricks (Serious survey machinery without the enterprise invoice; self-hosting still means owning the machinery) The prompt is for when you want it exactly your way.