Vibecode Alchemer
track this build4 phases, 12 steps, beginner friendly0%The visible enterprise surveys loop is buildable, but a credible replacement needs more than the first screen. Alchemer 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 Alchemer. 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 ===== # Alchemer · indie build A survey builder and collector whose hard problems are versioning a questionnaire without invalidating collected answers, evaluating branching on the server, and exporting data that spreadsheets will not execute. 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 with TypeScript, server actions for submission | the collection endpoint is the part that must not be clever | | Storage | SQLite with responses stored as answered, never normalised into the current schema | the schema changes and the answers must not | | Versioning | Immutable questionnaire versions, referenced by every response | an edit to a live survey is a new version, not an update | | Logic | A small declarative rule format evaluated server-side | branching in the browser is decoration | | Export | CSV and JSON written by one module with escaping tested | the export is where a survey tool hurts someone | ## 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 tests. - Get it: nodejs.org, or nvm install 22. - Verify: node --version prints v22 or higher - [ ] **A spreadsheet application** · free - Why: The CSV injection fix cannot be verified by reading code. You need to open a crafted export and see the cell displayed as text. - Get it: Any spreadsheet that people in your organisation actually use to open exports. - Verify: A crafted export opens showing the value as text rather than evaluating it - [ ] **Somewhere to put the collection endpoint** · a few pounds a month - Why: A survey link has to be reachable by respondents, which means a public address and a certificate. - Get it: A small VPS or any platform that serves a Node application over HTTPS. - Verify: A survey link opens from a device on a different network - [ ] **Your retention and cap policy** · free - Why: How long partials live, what the response cap is, and how long submitted responses are kept are all asserted by tests, and choosing them after launch means choosing whatever the code already does. - Get it: Write down three numbers and the reason for each. - Verify: All three are in the 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 integration catalogue. - Panel and audience purchasing. - Compliance certifications and residency guarantees. - Bot defence at public scale. - large integration and template catalog - compliance controls and guaranteed delivery - resilient polling of malformed and rate-limited feeds - enterprise workflow logic - spam and abuse defense at scale If one of those is essential to you, that is the reason to keep paying for Alchemer, and the README should say so rather than pretend. ===== BRIEF.md ===== # Build brief · Alchemer The one-shot brief this plan expands. `BUILD_PLAN.md` (or `MILESTONES.md`) is the same sequence broken into steps and checks; where the two disagree, the plan wins. Build me a survey tool that never loses a submitted response and never ships an export that executes to replace Alchemer. 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. A response that reached your server is never lost and never quietly rewritten. That means the questionnaire is versioned, every response records the version it answered, and editing a live survey creates a new version instead of changing the meaning of answers already collected. A respondent gave you fifteen minutes; losing it or reinterpreting it is not a bug you get to fix later. Second, the logic that decides which questions get asked runs on the server. Branching implemented only in the browser is a suggestion: a crafted request answers questions the respondent never saw, and a validation rule enforced only in the page is not a validation rule at all. ### Stack (fixed, do not substitute) - Runtime: Next.js 15 with TypeScript, server actions for submission · the collection endpoint is the part that must not be clever - Storage: SQLite with responses stored as answered, never normalised into the current schema · the schema changes and the answers must not - Versioning: Immutable questionnaire versions, referenced by every response · an edit to a live survey is a new version, not an update - Logic: A small declarative rule format evaluated server-side · branching in the browser is decoration - Export: CSV and JSON written by one module with escaping tested · the export is where a survey tool hurts someone ### Data model (create this before Phase 1) - surveys(id, name, created_at, current_version_id, state) · draft, collecting or closed - versions(id, survey_id, ordinal, schema_json, logic_json, published_at) · immutable once published - questions(version_id, id, kind, prompt, required, options_json, validation_json) · derived from the schema, kept for querying - responses(id, survey_id, version_id, token, started_at, submitted_at, state, source) · state is partial, submitted or discarded - answers(response_id, question_id, value_json, answered_at) · the value as given, not coerced to the current schema - submissions(token, response_id, claimed_at) · the one-shot token that makes a double post idempotent - events(response_id, at, kind, detail) · page views, saves, validation failures, the trail when someone says their answer vanished - Answers reference a question id within a version, so a later version renaming or removing that question leaves the old answer readable. ### Phase 1 · A questionnaire you can edit while it is collecting Build: Represent a questionnaire as a schema document. Publishing freezes it as a version with an ordinal. Editing a published survey opens a draft that becomes the next version on publish. Every response stores the version id it was answered against. Trap: Editing the question list in place while a survey is collecting silently rewrites history: yesterday's respondents answered a five point scale, today it has seven points, and the export now averages two different questions into one number. Freeze a version on publish and stamp every response with it. Done when: Publishing an edit to a collecting survey produces a new version and leaves the previous one intact, tested by reading an old response back. A response records the version it answered and renders against that version, not the current one. Deleting a question in a new version leaves earlier answers to it readable and exportable. A draft cannot be answered and a published version cannot be mutated, both enforced in the data layer. Do not build yet: No collection page, logic, export or sharing yet. ### Phase 2 · Branching evaluated where it cannot be edited Build: A declarative logic format: show or hide a question when an earlier answer matches a condition. Evaluate it server-side to produce the next page. Validate every submitted answer against the version's rules on the server, including that an answer to a hidden question is rejected. Trap: Implementing skip logic with a hidden attribute in the page means a respondent who opens developer tools, or a script posting the form directly, submits answers to questions that were never shown, and your required-field rule lets them through because the browser is the only thing enforcing it. Decide server-side and reject answers to questions the server did not ask. Done when: A crafted submission containing an answer to a question the logic hid is rejected with a clear error, tested directly against the endpoint. A required question hidden by logic does not block submission, tested. Validation rules for each question kind are enforced server-side and the client rules are only a convenience, verified by disabling the client rules in a test. A logic cycle or a condition referencing a later question is refused at publish time with the offending rule named. Do not build yet: No partial saves, export, webhooks or abuse limits yet. ### Phase 3 · Collecting on a bad connection Build: Give each respondent a token. Save answers per page as they go, marked partial. Mint a submission token with the response and require it on submit, claiming it in the same transaction that marks the response submitted. Allow resume from the token unless the survey forbids it. Trap: A respondent on a train taps submit, the request times out, they tap again, and you now have two responses from one person because the endpoint treats each post as new. Claim a one-shot submission token inside the transaction that records the submission, so the second post finds it already claimed and returns the same result. Done when: Posting the same submission twice produces one response and the second returns the original outcome, tested with concurrent requests. Closing the tab halfway leaves a partial response that resumes with answers intact. A partial response older than the configured window is expired and the expiry is logged rather than silently deleted. The events trail shows page views, saves and validation failures for any response, which is what you read when someone says their answers vanished. Do not build yet: No export, reporting, webhooks or retention policy yet. ### Phase 4 · An export that a spreadsheet will not execute Build: Export one row per response with a stable column order derived from the union of versions. Neutralise formula-leading values: OWASP's guidance on CSV injection is that a cell beginning with an equals sign, plus, minus or at sign is interpreted as a formula by spreadsheet software (checked against OWASP's own page), so no exported cell may begin with one. Quote and escape correctly for multi-line free text. Trap: A respondent types a value starting with an equals sign into a free-text box, and when a colleague opens your export in a spreadsheet it runs as a formula, which OWASP documents as reaching data exfiltration and worse depending on the application. Neutralise every cell whose first character is one of those four, and open a crafted export in a real spreadsheet before you believe your fix. Done when: A response containing a cell beginning with an equals sign exports in a form that a spreadsheet displays as text, verified by opening the file rather than by reading the code. Free text containing commas, quotes and newlines round trips through the CSV without shifting a column. Column order is stable across two schema versions and questions removed in a later version still appear with their earlier answers. A JSON export carries the version id per response so the file is interpretable without the database. Do not build yet: No sharing limits, retention or webhooks yet. ### Phase 5 · Sharing it without giving it away Build: Public links carry an unguessable token and an optional close date and response cap. Rate limit by address with a stated window. Deliver webhooks with retry and a signature, and show delivery state per response. Implement deletion of a single response and of a whole survey, including from exports already staged. Trap: A survey link that is only a sequential id lets anyone enumerate your other surveys, including the internal one you thought was private, and a survey with no cap and no rate limit becomes the target of a script that fills your database overnight. Use unguessable tokens, a response cap and a rate limit, and alert when the cap is reached rather than closing silently. Done when: Survey links are unguessable and enumeration of another survey's link fails, tested. A survey that reaches its response cap or close date stops accepting and shows the respondent an explanation rather than an error. Webhook delivery retries with backoff, is signed, and per-response delivery state is visible. Deleting a response removes it from the database and from any staged export, verified by searching the export directory in the test. Do not build yet: Nothing after this. ### Out of scope (and why) - The integration catalogue, which is the actual product at the enterprise end and is a permanent maintenance commitment rather than a feature. - Panel and audience purchasing, which is a marketplace with its own fraud problem. - Compliance certifications and data residency guarantees, which are audits and contracts, not code you can write. - Spam and bot defence at the scale a public survey attracts, which needs signals you do not have and is why people pay. ### README must contain - That publishing an edit creates a new version and every response records the version it answered - That branching and validation are evaluated on the server, and answers to hidden questions are rejected - How the submission token makes a double post idempotent, and how to resume a partial response - That CSV exports neutralise cells beginning with equals, plus, minus or at sign, and why - How to delete one response or a whole survey, including copies already staged for export ===== AGENTS.md ===== # Agent instructions · Alchemer indie build - Read `README.md` and `BUILD_PLAN.md` before writing code. The stack is fixed: Next.js 15 with TypeScript, server actions for submission, SQLite with responses stored as answered, never normalised into the current schema, Immutable questionnaire versions, referenced by every response, A small declarative rule format evaluated server-side, CSV and JSON written by one module with escaping tested. Do not substitute. - Work one phase at a time, in order. Do not start a phase until every "Done when" item of the previous one passes. - Prefer the fewest moving parts that satisfy the step. No frameworks, services or dependencies the plan does not name. - Secrets live in `.env`, never in source or logs. Keep `.env.example` current when a variable is introduced. - Do not invent cryptography, security guarantees, APIs or compliance claims. - Add a focused test for every destructive, security-sensitive or data-loss path the plan names. - Run the project checks before declaring a phase complete, and record any deliberate shortcut in the README under "Tradeoffs". ## Known traps - Editing the question list in place while a survey is collecting silently rewrites history: yesterday's respondents answered a five point scale, today it has seven points, and the export now averages two different questions into one number. Freeze a version on publish and stamp every response with it. - Implementing skip logic with a hidden attribute in the page means a respondent who opens developer tools, or a script posting the form directly, submits answers to questions that were never shown, and your required-field rule lets them through because the browser is the only thing enforcing it. Decide server-side and reject answers to questions the server did not ask. - A respondent on a train taps submit, the request times out, they tap again, and you now have two responses from one person because the endpoint treats each post as new. Claim a one-shot submission token inside the transaction that records the submission, so the second post finds it already claimed and returns the same result. - A respondent types a value starting with an equals sign into a free-text box, and when a colleague opens your export in a spreadsheet it runs as a formula, which OWASP documents as reaching data exfiltration and worse depending on the application. Neutralise every cell whose first character is one of those four, and open a crafted export in a real spreadsheet before you believe your fix. ===== BUILD_PLAN.md ===== # Build plan · Alchemer A survey builder and collector whose hard problems are versioning a questionnaire without invalidating collected answers, evaluating branching on the server, and exporting data that spreadsheets will not execute. Phases are in dependency order. Each ends in a "Done when" list; treat an unticked item as a blocker, not a note. ## Phase 1 · A questionnaire you can edit while it is collecting Publishing a change creates a version, and answers already collected still mean what they meant. ### Steps 1. Freeze a version on publish Immutability in the schema, not in a code comment. Files: `src/lib/versions.ts`, `src/db/schema.sql` 2. Stamp every response with its version This one column is what makes the whole model work. Files: `src/lib/responses.ts` 3. Render an old response against its own version Prove it by reading one back after two schema changes. Files: `tests/version-integrity.test.ts` ### Done when - [ ] Publishing an edit creates a version and preserves the old one - [ ] An old response renders against its own version - [ ] A removed question's earlier answers stay readable - [ ] Published versions cannot be mutated ### Watch out - Editing the question list in place while a survey is collecting silently rewrites history: yesterday's respondents answered a five point scale, today it has seven points, and the export now averages two different questions into one number. Freeze a version on publish and stamp every response with it. ## Phase 2 · Branching evaluated where it cannot be edited The server decides which questions are asked, which are required, and which answers are acceptable. ### Steps 1. Compute the next page on the server The browser renders what it is told and decides nothing. Files: `src/lib/logic.ts`, `src/app/s/[token]/route.ts` 2. Reject answers to unasked questions The check that turns skip logic from decoration into a rule. Files: `src/lib/validate-submission.ts`, `tests/hidden-question.test.ts` 3. Refuse an impossible rule at publish Cycles and forward references caught before a respondent meets them. Files: `src/lib/logic-check.ts` ### Done when - [ ] An answer to a hidden question is rejected - [ ] A hidden required question does not block submission - [ ] Validation holds with client rules disabled - [ ] Cyclic or forward-referencing logic is refused at publish ### Watch out - Implementing skip logic with a hidden attribute in the page means a respondent who opens developer tools, or a script posting the form directly, submits answers to questions that were never shown, and your required-field rule lets them through because the browser is the only thing enforcing it. Decide server-side and reject answers to questions the server did not ask. ## Phase 3 · Collecting on a bad connection A partial answer survives a closed tab, and a double submit produces one response. ### Steps 1. Save per page, marked partial Fifteen minutes of someone's attention should not depend on their connection. Files: `src/lib/partial.ts` 2. Claim the submission token in the transaction Idempotency belongs in the database, not in a debounce. Files: `src/lib/submit.ts`, `tests/double-submit.test.ts` 3. Record an events trail The answer to 'it did not save' is a query, not a shrug. Files: `src/lib/events.ts` ### Done when - [ ] A double submit yields one response - [ ] A closed tab resumes with answers intact - [ ] Expired partials are logged, not silently dropped - [ ] The events trail explains a missing response ### Watch out - A respondent on a train taps submit, the request times out, they tap again, and you now have two responses from one person because the endpoint treats each post as new. Claim a one-shot submission token inside the transaction that records the submission, so the second post finds it already claimed and returns the same result. ## Phase 4 · An export that a spreadsheet will not execute CSV output is safe to open and stable across schema versions. ### Steps 1. Neutralise formula-leading cells Equals, plus, minus and at sign, per OWASP's list. Files: `src/lib/csv.ts`, `tests/csv-injection.test.ts` 2. Fix a column order across versions An export whose columns move is an export nobody can chart. Files: `src/lib/export-columns.ts` 3. Open the crafted export in a spreadsheet The only test of this that counts is the manual one. Files: `docs/export-verification.md` ### Done when - [ ] A formula-leading answer displays as text in a spreadsheet - [ ] Free text with commas, quotes and newlines round trips - [ ] Column order is stable across versions - [ ] JSON export carries the version id ### Watch out - A respondent types a value starting with an equals sign into a free-text box, and when a colleague opens your export in a spreadsheet it runs as a formula, which OWASP documents as reaching data exfiltration and worse depending on the application. Neutralise every cell whose first character is one of those four, and open a crafted export in a real spreadsheet before you believe your fix. ## Not in this build - The integration catalogue. - Panel and audience purchasing. - Compliance certifications and residency guarantees. - Bot defence at public scale. ## After v1, if you want it - Piping earlier answers into later question text, which reuses the logic evaluator and nothing else - A second language per version, since versions are already immutable and can carry translations - Scheduled exports to a folder, once deletion is proven to reach staged files ===== .env.example ===== # Copy to .env and fill in. Never commit .env; this file documents it. # Required. Versions, responses and the events trail. DATABASE_PATH=./data/surveys.db # Required. Used to build share links. A wrong value produces links nobody can open. PUBLIC_BASE_URL=https://surveys.example.com # Required. Entropy for survey and response tokens. Enumeration is the failure this prevents. TOKEN_BYTES=24 # Required. After this a partial response expires, and the expiry is logged. PARTIAL_TTL_HOURS=72 # Required. Per address, per survey. Reaching it alerts rather than failing silently. RATE_LIMIT_PER_HOUR=60 # Optional · secret. Signs outgoing deliveries so the receiver can verify them. WEBHOOK_SIGNING_SECRET=whsec-...redacted # Required. Staged exports. Deletion has to reach in here too. EXPORT_DIR=./data/exports
You are building a lean indie version of Alchemer. 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 ===== # Alchemer · indie build A survey builder and collector whose hard problems are versioning a questionnaire without invalidating collected answers, evaluating branching on the server, and exporting data that spreadsheets will not execute. 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 with TypeScript, server actions for submission | the collection endpoint is the part that must not be clever | | Storage | SQLite with responses stored as answered, never normalised into the current schema | the schema changes and the answers must not | | Versioning | Immutable questionnaire versions, referenced by every response | an edit to a live survey is a new version, not an update | | Logic | A small declarative rule format evaluated server-side | branching in the browser is decoration | | Export | CSV and JSON written by one module with escaping tested | the export is where a survey tool hurts someone | ## 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 tests. - Get it: nodejs.org, or nvm install 22. - Verify: node --version prints v22 or higher - [ ] **A spreadsheet application** · free - Why: The CSV injection fix cannot be verified by reading code. You need to open a crafted export and see the cell displayed as text. - Get it: Any spreadsheet that people in your organisation actually use to open exports. - Verify: A crafted export opens showing the value as text rather than evaluating it - [ ] **Somewhere to put the collection endpoint** · a few pounds a month - Why: A survey link has to be reachable by respondents, which means a public address and a certificate. - Get it: A small VPS or any platform that serves a Node application over HTTPS. - Verify: A survey link opens from a device on a different network - [ ] **Your retention and cap policy** · free - Why: How long partials live, what the response cap is, and how long submitted responses are kept are all asserted by tests, and choosing them after launch means choosing whatever the code already does. - Get it: Write down three numbers and the reason for each. - Verify: All three are in the 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 integration catalogue. - Panel and audience purchasing. - Compliance certifications and residency guarantees. - Bot defence at public scale. - large integration and template catalog - compliance controls and guaranteed delivery - resilient polling of malformed and rate-limited feeds - enterprise workflow logic - spam and abuse defense at scale If one of those is essential to you, that is the reason to keep paying for Alchemer, and the README should say so rather than pretend. ===== BRIEF.md ===== # Build brief · Alchemer The one-shot brief this plan expands. `BUILD_PLAN.md` (or `MILESTONES.md`) is the same sequence broken into steps and checks; where the two disagree, the plan wins. Build me a survey tool that never loses a submitted response and never ships an export that executes to replace Alchemer. 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. A response that reached your server is never lost and never quietly rewritten. That means the questionnaire is versioned, every response records the version it answered, and editing a live survey creates a new version instead of changing the meaning of answers already collected. A respondent gave you fifteen minutes; losing it or reinterpreting it is not a bug you get to fix later. Second, the logic that decides which questions get asked runs on the server. Branching implemented only in the browser is a suggestion: a crafted request answers questions the respondent never saw, and a validation rule enforced only in the page is not a validation rule at all. ### Stack (fixed, do not substitute) - Runtime: Next.js 15 with TypeScript, server actions for submission · the collection endpoint is the part that must not be clever - Storage: SQLite with responses stored as answered, never normalised into the current schema · the schema changes and the answers must not - Versioning: Immutable questionnaire versions, referenced by every response · an edit to a live survey is a new version, not an update - Logic: A small declarative rule format evaluated server-side · branching in the browser is decoration - Export: CSV and JSON written by one module with escaping tested · the export is where a survey tool hurts someone ### Data model (create this before Phase 1) - surveys(id, name, created_at, current_version_id, state) · draft, collecting or closed - versions(id, survey_id, ordinal, schema_json, logic_json, published_at) · immutable once published - questions(version_id, id, kind, prompt, required, options_json, validation_json) · derived from the schema, kept for querying - responses(id, survey_id, version_id, token, started_at, submitted_at, state, source) · state is partial, submitted or discarded - answers(response_id, question_id, value_json, answered_at) · the value as given, not coerced to the current schema - submissions(token, response_id, claimed_at) · the one-shot token that makes a double post idempotent - events(response_id, at, kind, detail) · page views, saves, validation failures, the trail when someone says their answer vanished - Answers reference a question id within a version, so a later version renaming or removing that question leaves the old answer readable. ### Phase 1 · A questionnaire you can edit while it is collecting Build: Represent a questionnaire as a schema document. Publishing freezes it as a version with an ordinal. Editing a published survey opens a draft that becomes the next version on publish. Every response stores the version id it was answered against. Trap: Editing the question list in place while a survey is collecting silently rewrites history: yesterday's respondents answered a five point scale, today it has seven points, and the export now averages two different questions into one number. Freeze a version on publish and stamp every response with it. Done when: Publishing an edit to a collecting survey produces a new version and leaves the previous one intact, tested by reading an old response back. A response records the version it answered and renders against that version, not the current one. Deleting a question in a new version leaves earlier answers to it readable and exportable. A draft cannot be answered and a published version cannot be mutated, both enforced in the data layer. Do not build yet: No collection page, logic, export or sharing yet. ### Phase 2 · Branching evaluated where it cannot be edited Build: A declarative logic format: show or hide a question when an earlier answer matches a condition. Evaluate it server-side to produce the next page. Validate every submitted answer against the version's rules on the server, including that an answer to a hidden question is rejected. Trap: Implementing skip logic with a hidden attribute in the page means a respondent who opens developer tools, or a script posting the form directly, submits answers to questions that were never shown, and your required-field rule lets them through because the browser is the only thing enforcing it. Decide server-side and reject answers to questions the server did not ask. Done when: A crafted submission containing an answer to a question the logic hid is rejected with a clear error, tested directly against the endpoint. A required question hidden by logic does not block submission, tested. Validation rules for each question kind are enforced server-side and the client rules are only a convenience, verified by disabling the client rules in a test. A logic cycle or a condition referencing a later question is refused at publish time with the offending rule named. Do not build yet: No partial saves, export, webhooks or abuse limits yet. ### Phase 3 · Collecting on a bad connection Build: Give each respondent a token. Save answers per page as they go, marked partial. Mint a submission token with the response and require it on submit, claiming it in the same transaction that marks the response submitted. Allow resume from the token unless the survey forbids it. Trap: A respondent on a train taps submit, the request times out, they tap again, and you now have two responses from one person because the endpoint treats each post as new. Claim a one-shot submission token inside the transaction that records the submission, so the second post finds it already claimed and returns the same result. Done when: Posting the same submission twice produces one response and the second returns the original outcome, tested with concurrent requests. Closing the tab halfway leaves a partial response that resumes with answers intact. A partial response older than the configured window is expired and the expiry is logged rather than silently deleted. The events trail shows page views, saves and validation failures for any response, which is what you read when someone says their answers vanished. Do not build yet: No export, reporting, webhooks or retention policy yet. ### Phase 4 · An export that a spreadsheet will not execute Build: Export one row per response with a stable column order derived from the union of versions. Neutralise formula-leading values: OWASP's guidance on CSV injection is that a cell beginning with an equals sign, plus, minus or at sign is interpreted as a formula by spreadsheet software (checked against OWASP's own page), so no exported cell may begin with one. Quote and escape correctly for multi-line free text. Trap: A respondent types a value starting with an equals sign into a free-text box, and when a colleague opens your export in a spreadsheet it runs as a formula, which OWASP documents as reaching data exfiltration and worse depending on the application. Neutralise every cell whose first character is one of those four, and open a crafted export in a real spreadsheet before you believe your fix. Done when: A response containing a cell beginning with an equals sign exports in a form that a spreadsheet displays as text, verified by opening the file rather than by reading the code. Free text containing commas, quotes and newlines round trips through the CSV without shifting a column. Column order is stable across two schema versions and questions removed in a later version still appear with their earlier answers. A JSON export carries the version id per response so the file is interpretable without the database. Do not build yet: No sharing limits, retention or webhooks yet. ### Phase 5 · Sharing it without giving it away Build: Public links carry an unguessable token and an optional close date and response cap. Rate limit by address with a stated window. Deliver webhooks with retry and a signature, and show delivery state per response. Implement deletion of a single response and of a whole survey, including from exports already staged. Trap: A survey link that is only a sequential id lets anyone enumerate your other surveys, including the internal one you thought was private, and a survey with no cap and no rate limit becomes the target of a script that fills your database overnight. Use unguessable tokens, a response cap and a rate limit, and alert when the cap is reached rather than closing silently. Done when: Survey links are unguessable and enumeration of another survey's link fails, tested. A survey that reaches its response cap or close date stops accepting and shows the respondent an explanation rather than an error. Webhook delivery retries with backoff, is signed, and per-response delivery state is visible. Deleting a response removes it from the database and from any staged export, verified by searching the export directory in the test. Do not build yet: Nothing after this. ### Out of scope (and why) - The integration catalogue, which is the actual product at the enterprise end and is a permanent maintenance commitment rather than a feature. - Panel and audience purchasing, which is a marketplace with its own fraud problem. - Compliance certifications and data residency guarantees, which are audits and contracts, not code you can write. - Spam and bot defence at the scale a public survey attracts, which needs signals you do not have and is why people pay. ### README must contain - That publishing an edit creates a new version and every response records the version it answered - That branching and validation are evaluated on the server, and answers to hidden questions are rejected - How the submission token makes a double post idempotent, and how to resume a partial response - That CSV exports neutralise cells beginning with equals, plus, minus or at sign, and why - How to delete one response or a whole survey, including copies already staged for export ===== AGENTS.md ===== # Agent instructions · Alchemer indie build - Read `README.md` and `BUILD_PLAN.md` before writing code. The stack is fixed: Next.js 15 with TypeScript, server actions for submission, SQLite with responses stored as answered, never normalised into the current schema, Immutable questionnaire versions, referenced by every response, A small declarative rule format evaluated server-side, CSV and JSON written by one module with escaping tested. Do not substitute. - Work one phase at a time, in order. Do not start a phase until every "Done when" item of the previous one passes. - Prefer the fewest moving parts that satisfy the step. No frameworks, services or dependencies the plan does not name. - Secrets live in `.env`, never in source or logs. Keep `.env.example` current when a variable is introduced. - Do not invent cryptography, security guarantees, APIs or compliance claims. - Add a focused test for every destructive, security-sensitive or data-loss path the plan names. - Run the project checks before declaring a phase complete, and record any deliberate shortcut in the README under "Tradeoffs". ## Known traps - Editing the question list in place while a survey is collecting silently rewrites history: yesterday's respondents answered a five point scale, today it has seven points, and the export now averages two different questions into one number. Freeze a version on publish and stamp every response with it. - Implementing skip logic with a hidden attribute in the page means a respondent who opens developer tools, or a script posting the form directly, submits answers to questions that were never shown, and your required-field rule lets them through because the browser is the only thing enforcing it. Decide server-side and reject answers to questions the server did not ask. - A respondent on a train taps submit, the request times out, they tap again, and you now have two responses from one person because the endpoint treats each post as new. Claim a one-shot submission token inside the transaction that records the submission, so the second post finds it already claimed and returns the same result. - A respondent types a value starting with an equals sign into a free-text box, and when a colleague opens your export in a spreadsheet it runs as a formula, which OWASP documents as reaching data exfiltration and worse depending on the application. Neutralise every cell whose first character is one of those four, and open a crafted export in a real spreadsheet before you believe your fix. ===== BUILD_PLAN.md ===== # Build plan · Alchemer A survey builder and collector whose hard problems are versioning a questionnaire without invalidating collected answers, evaluating branching on the server, and exporting data that spreadsheets will not execute. Phases are in dependency order. Each ends in a "Done when" list; treat an unticked item as a blocker, not a note. ## Phase 1 · A questionnaire you can edit while it is collecting Publishing a change creates a version, and answers already collected still mean what they meant. ### Steps 1. Freeze a version on publish Immutability in the schema, not in a code comment. Files: `src/lib/versions.ts`, `src/db/schema.sql` 2. Stamp every response with its version This one column is what makes the whole model work. Files: `src/lib/responses.ts` 3. Render an old response against its own version Prove it by reading one back after two schema changes. Files: `tests/version-integrity.test.ts` ### Done when - [ ] Publishing an edit creates a version and preserves the old one - [ ] An old response renders against its own version - [ ] A removed question's earlier answers stay readable - [ ] Published versions cannot be mutated ### Watch out - Editing the question list in place while a survey is collecting silently rewrites history: yesterday's respondents answered a five point scale, today it has seven points, and the export now averages two different questions into one number. Freeze a version on publish and stamp every response with it. ## Phase 2 · Branching evaluated where it cannot be edited The server decides which questions are asked, which are required, and which answers are acceptable. ### Steps 1. Compute the next page on the server The browser renders what it is told and decides nothing. Files: `src/lib/logic.ts`, `src/app/s/[token]/route.ts` 2. Reject answers to unasked questions The check that turns skip logic from decoration into a rule. Files: `src/lib/validate-submission.ts`, `tests/hidden-question.test.ts` 3. Refuse an impossible rule at publish Cycles and forward references caught before a respondent meets them. Files: `src/lib/logic-check.ts` ### Done when - [ ] An answer to a hidden question is rejected - [ ] A hidden required question does not block submission - [ ] Validation holds with client rules disabled - [ ] Cyclic or forward-referencing logic is refused at publish ### Watch out - Implementing skip logic with a hidden attribute in the page means a respondent who opens developer tools, or a script posting the form directly, submits answers to questions that were never shown, and your required-field rule lets them through because the browser is the only thing enforcing it. Decide server-side and reject answers to questions the server did not ask. ## Phase 3 · Collecting on a bad connection A partial answer survives a closed tab, and a double submit produces one response. ### Steps 1. Save per page, marked partial Fifteen minutes of someone's attention should not depend on their connection. Files: `src/lib/partial.ts` 2. Claim the submission token in the transaction Idempotency belongs in the database, not in a debounce. Files: `src/lib/submit.ts`, `tests/double-submit.test.ts` 3. Record an events trail The answer to 'it did not save' is a query, not a shrug. Files: `src/lib/events.ts` ### Done when - [ ] A double submit yields one response - [ ] A closed tab resumes with answers intact - [ ] Expired partials are logged, not silently dropped - [ ] The events trail explains a missing response ### Watch out - A respondent on a train taps submit, the request times out, they tap again, and you now have two responses from one person because the endpoint treats each post as new. Claim a one-shot submission token inside the transaction that records the submission, so the second post finds it already claimed and returns the same result. ## Phase 4 · An export that a spreadsheet will not execute CSV output is safe to open and stable across schema versions. ### Steps 1. Neutralise formula-leading cells Equals, plus, minus and at sign, per OWASP's list. Files: `src/lib/csv.ts`, `tests/csv-injection.test.ts` 2. Fix a column order across versions An export whose columns move is an export nobody can chart. Files: `src/lib/export-columns.ts` 3. Open the crafted export in a spreadsheet The only test of this that counts is the manual one. Files: `docs/export-verification.md` ### Done when - [ ] A formula-leading answer displays as text in a spreadsheet - [ ] Free text with commas, quotes and newlines round trips - [ ] Column order is stable across versions - [ ] JSON export carries the version id ### Watch out - A respondent types a value starting with an equals sign into a free-text box, and when a colleague opens your export in a spreadsheet it runs as a formula, which OWASP documents as reaching data exfiltration and worse depending on the application. Neutralise every cell whose first character is one of those four, and open a crafted export in a real spreadsheet before you believe your fix. ## Not in this build - The integration catalogue. - Panel and audience purchasing. - Compliance certifications and residency guarantees. - Bot defence at public scale. ## After v1, if you want it - Piping earlier answers into later question text, which reuses the logic evaluator and nothing else - A second language per version, since versions are already immutable and can carry translations - Scheduled exports to a folder, once deletion is proven to reach staged files ===== .env.example ===== # Copy to .env and fill in. Never commit .env; this file documents it. # Required. Versions, responses and the events trail. DATABASE_PATH=./data/surveys.db # Required. Used to build share links. A wrong value produces links nobody can open. PUBLIC_BASE_URL=https://surveys.example.com # Required. Entropy for survey and response tokens. Enumeration is the failure this prevents. TOKEN_BYTES=24 # Required. After this a partial response expires, and the expiry is logged. PARTIAL_TTL_HOURS=72 # Required. Per address, per survey. Reaching it alerts rather than failing silently. RATE_LIMIT_PER_HOUR=60 # Optional · secret. Signs outgoing deliveries so the receiver can verify them. WEBHOOK_SIGNING_SECRET=whsec-...redacted # Required. Staged exports. Deletion has to reach in here too. EXPORT_DIR=./data/exports
You are building a production product version of Alchemer. 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 ===== # Alchemer · product brief ## Problem The visible enterprise surveys loop is buildable, but a credible replacement needs more than the first screen. Alchemer 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 an edit mid-collection does not rewrite yesterday's answers, a crafted post cannot answer a hidden question, a double tap does not double count, and the export is safe to hand to a colleague. ## 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 integration catalogue. - Panel and audience purchasing. - Compliance certifications and residency guarantees. - Bot defence at public scale. - large integration and template catalog - compliance controls and guaranteed delivery - resilient polling of malformed and rate-limited feeds - enterprise workflow logic - spam and abuse defense at scale ## Success criteria - An old response is confirmed to render against its own version - A crafted answer to a hidden question is confirmed rejected - A double submit is confirmed to yield one response - A formula-leading cell is confirmed to display as text in a real spreadsheet - Deletion is confirmed to reach staged exports ===== BRIEF.md ===== # Build brief · Alchemer The one-shot brief this plan expands. `BUILD_PLAN.md` (or `MILESTONES.md`) is the same sequence broken into steps and checks; where the two disagree, the plan wins. Build me a survey tool that never loses a submitted response and never ships an export that executes to replace Alchemer. 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. A response that reached your server is never lost and never quietly rewritten. That means the questionnaire is versioned, every response records the version it answered, and editing a live survey creates a new version instead of changing the meaning of answers already collected. A respondent gave you fifteen minutes; losing it or reinterpreting it is not a bug you get to fix later. Second, the logic that decides which questions get asked runs on the server. Branching implemented only in the browser is a suggestion: a crafted request answers questions the respondent never saw, and a validation rule enforced only in the page is not a validation rule at all. ### Stack (fixed, do not substitute) - Runtime: Next.js 15 with TypeScript, server actions for submission · the collection endpoint is the part that must not be clever - Storage: SQLite with responses stored as answered, never normalised into the current schema · the schema changes and the answers must not - Versioning: Immutable questionnaire versions, referenced by every response · an edit to a live survey is a new version, not an update - Logic: A small declarative rule format evaluated server-side · branching in the browser is decoration - Export: CSV and JSON written by one module with escaping tested · the export is where a survey tool hurts someone ### Data model (create this before Phase 1) - surveys(id, name, created_at, current_version_id, state) · draft, collecting or closed - versions(id, survey_id, ordinal, schema_json, logic_json, published_at) · immutable once published - questions(version_id, id, kind, prompt, required, options_json, validation_json) · derived from the schema, kept for querying - responses(id, survey_id, version_id, token, started_at, submitted_at, state, source) · state is partial, submitted or discarded - answers(response_id, question_id, value_json, answered_at) · the value as given, not coerced to the current schema - submissions(token, response_id, claimed_at) · the one-shot token that makes a double post idempotent - events(response_id, at, kind, detail) · page views, saves, validation failures, the trail when someone says their answer vanished - Answers reference a question id within a version, so a later version renaming or removing that question leaves the old answer readable. ### Phase 1 · A questionnaire you can edit while it is collecting Build: Represent a questionnaire as a schema document. Publishing freezes it as a version with an ordinal. Editing a published survey opens a draft that becomes the next version on publish. Every response stores the version id it was answered against. Trap: Editing the question list in place while a survey is collecting silently rewrites history: yesterday's respondents answered a five point scale, today it has seven points, and the export now averages two different questions into one number. Freeze a version on publish and stamp every response with it. Done when: Publishing an edit to a collecting survey produces a new version and leaves the previous one intact, tested by reading an old response back. A response records the version it answered and renders against that version, not the current one. Deleting a question in a new version leaves earlier answers to it readable and exportable. A draft cannot be answered and a published version cannot be mutated, both enforced in the data layer. Do not build yet: No collection page, logic, export or sharing yet. ### Phase 2 · Branching evaluated where it cannot be edited Build: A declarative logic format: show or hide a question when an earlier answer matches a condition. Evaluate it server-side to produce the next page. Validate every submitted answer against the version's rules on the server, including that an answer to a hidden question is rejected. Trap: Implementing skip logic with a hidden attribute in the page means a respondent who opens developer tools, or a script posting the form directly, submits answers to questions that were never shown, and your required-field rule lets them through because the browser is the only thing enforcing it. Decide server-side and reject answers to questions the server did not ask. Done when: A crafted submission containing an answer to a question the logic hid is rejected with a clear error, tested directly against the endpoint. A required question hidden by logic does not block submission, tested. Validation rules for each question kind are enforced server-side and the client rules are only a convenience, verified by disabling the client rules in a test. A logic cycle or a condition referencing a later question is refused at publish time with the offending rule named. Do not build yet: No partial saves, export, webhooks or abuse limits yet. ### Phase 3 · Collecting on a bad connection Build: Give each respondent a token. Save answers per page as they go, marked partial. Mint a submission token with the response and require it on submit, claiming it in the same transaction that marks the response submitted. Allow resume from the token unless the survey forbids it. Trap: A respondent on a train taps submit, the request times out, they tap again, and you now have two responses from one person because the endpoint treats each post as new. Claim a one-shot submission token inside the transaction that records the submission, so the second post finds it already claimed and returns the same result. Done when: Posting the same submission twice produces one response and the second returns the original outcome, tested with concurrent requests. Closing the tab halfway leaves a partial response that resumes with answers intact. A partial response older than the configured window is expired and the expiry is logged rather than silently deleted. The events trail shows page views, saves and validation failures for any response, which is what you read when someone says their answers vanished. Do not build yet: No export, reporting, webhooks or retention policy yet. ### Phase 4 · An export that a spreadsheet will not execute Build: Export one row per response with a stable column order derived from the union of versions. Neutralise formula-leading values: OWASP's guidance on CSV injection is that a cell beginning with an equals sign, plus, minus or at sign is interpreted as a formula by spreadsheet software (checked against OWASP's own page), so no exported cell may begin with one. Quote and escape correctly for multi-line free text. Trap: A respondent types a value starting with an equals sign into a free-text box, and when a colleague opens your export in a spreadsheet it runs as a formula, which OWASP documents as reaching data exfiltration and worse depending on the application. Neutralise every cell whose first character is one of those four, and open a crafted export in a real spreadsheet before you believe your fix. Done when: A response containing a cell beginning with an equals sign exports in a form that a spreadsheet displays as text, verified by opening the file rather than by reading the code. Free text containing commas, quotes and newlines round trips through the CSV without shifting a column. Column order is stable across two schema versions and questions removed in a later version still appear with their earlier answers. A JSON export carries the version id per response so the file is interpretable without the database. Do not build yet: No sharing limits, retention or webhooks yet. ### Phase 5 · Sharing it without giving it away Build: Public links carry an unguessable token and an optional close date and response cap. Rate limit by address with a stated window. Deliver webhooks with retry and a signature, and show delivery state per response. Implement deletion of a single response and of a whole survey, including from exports already staged. Trap: A survey link that is only a sequential id lets anyone enumerate your other surveys, including the internal one you thought was private, and a survey with no cap and no rate limit becomes the target of a script that fills your database overnight. Use unguessable tokens, a response cap and a rate limit, and alert when the cap is reached rather than closing silently. Done when: Survey links are unguessable and enumeration of another survey's link fails, tested. A survey that reaches its response cap or close date stops accepting and shows the respondent an explanation rather than an error. Webhook delivery retries with backoff, is signed, and per-response delivery state is visible. Deleting a response removes it from the database and from any staged export, verified by searching the export directory in the test. Do not build yet: Nothing after this. ### Out of scope (and why) - The integration catalogue, which is the actual product at the enterprise end and is a permanent maintenance commitment rather than a feature. - Panel and audience purchasing, which is a marketplace with its own fraud problem. - Compliance certifications and data residency guarantees, which are audits and contracts, not code you can write. - Spam and bot defence at the scale a public survey attracts, which needs signals you do not have and is why people pay. ### README must contain - That publishing an edit creates a new version and every response records the version it answered - That branching and validation are evaluated on the server, and answers to hidden questions are rejected - How the submission token makes a double post idempotent, and how to resume a partial response - That CSV exports neutralise cells beginning with equals, plus, minus or at sign, and why - How to delete one response or a whole survey, including copies already staged for export ===== ARCHITECTURE.md ===== # Architecture · Alchemer ## Stack | Part | Choice | Why | | --- | --- | --- | | Runtime | Next.js 15 with TypeScript, server actions for submission | the collection endpoint is the part that must not be clever | | Storage | SQLite with responses stored as answered, never normalised into the current schema | the schema changes and the answers must not | | Versioning | Immutable questionnaire versions, referenced by every response | an edit to a live survey is a new version, not an update | | Logic | A small declarative rule format evaluated server-side | branching in the browser is decoration | | Export | CSV and JSON written by one module with escaping tested | the export is where a survey tool hurts someone | ## Modules Each module has one owner concern and a documented way to replace it. | Module | Owns | How to replace it | | --- | --- | --- | | Versions | Immutable published schemas. | Any store, provided published versions are unmodifiable. | | Logic | Server-side branching and validation. | Any rule format, provided evaluation never moves to the browser. | | Collection | Partials, resume and one-shot submission. | Any transport, provided the token is claimed in the submit transaction. | | Export | CSV and JSON, escaping and column stability. | Any writer, provided the formula-leading test still passes. | | Delivery | Signed webhooks with retry and visible state. | Any queue, provided delivery state stays per response. | ## 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 · Versions, responses and the events trail. - `PUBLIC_BASE_URL` · required · Used to build share links. A wrong value produces links nobody can open. - `TOKEN_BYTES` · required · Entropy for survey and response tokens. Enumeration is the failure this prevents. - `PARTIAL_TTL_HOURS` · required · After this a partial response expires, and the expiry is logged. - `RATE_LIMIT_PER_HOUR` · required · Per address, per survey. Reaching it alerts rather than failing silently. - `WEBHOOK_SIGNING_SECRET` · optional, secret · Signs outgoing deliveries so the receiver can verify them. - `EXPORT_DIR` · required · Staged exports. Deletion has to reach in here too. ## 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 · Alchemer product build - Read `PRODUCT.md` and `ARCHITECTURE.md` before changing code. The stack is fixed: Next.js 15 with TypeScript, server actions for submission, SQLite with responses stored as answered, never normalised into the current schema, Immutable questionnaire versions, referenced by every response, A small declarative rule format evaluated server-side, CSV and JSON written by one module with escaping tested. - Implement milestone by milestone from `MILESTONES.md`; keep each change reviewable and leave the application runnable at every commit. - Treat authentication, payments, encryption, imports, webhooks and destructive actions as high-risk boundaries when present. - Never invent cryptography or silently weaken a requirement to make a check pass. - Put every external service behind an interface with a deterministic fake for tests. - Add migrations and rollback or recovery notes for every persistent data change. - Log useful operational context without credentials, tokens, passwords or personal data. - Update documentation and run every check before completing a milestone. ## Known traps - Editing the question list in place while a survey is collecting silently rewrites history: yesterday's respondents answered a five point scale, today it has seven points, and the export now averages two different questions into one number. Freeze a version on publish and stamp every response with it. - Implementing skip logic with a hidden attribute in the page means a respondent who opens developer tools, or a script posting the form directly, submits answers to questions that were never shown, and your required-field rule lets them through because the browser is the only thing enforcing it. Decide server-side and reject answers to questions the server did not ask. - A respondent on a train taps submit, the request times out, they tap again, and you now have two responses from one person because the endpoint treats each post as new. Claim a one-shot submission token inside the transaction that records the submission, so the second post finds it already claimed and returns the same result. - A respondent types a value starting with an equals sign into a free-text box, and when a colleague opens your export in a spreadsheet it runs as a formula, which OWASP documents as reaching data exfiltration and worse depending on the application. Neutralise every cell whose first character is one of those four, and open a crafted export in a real spreadsheet before you believe your fix. - A survey link that is only a sequential id lets anyone enumerate your other surveys, including the internal one you thought was private, and a survey with no cap and no rate limit becomes the target of a script that fills your database overnight. Use unguessable tokens, a response cap and a rate limit, and alert when the cap is reached rather than closing silently. ===== MILESTONES.md ===== # Delivery milestones · Alchemer Estimated effort: **weekend** for the indie phases; the production-only milestones add the trust and operability layer. ## M1 · A questionnaire you can edit while it is collecting Publishing a change creates a version, and answers already collected still mean what they meant. ### Steps 1. Freeze a version on publish Immutability in the schema, not in a code comment. Files: `src/lib/versions.ts`, `src/db/schema.sql` 2. Stamp every response with its version This one column is what makes the whole model work. Files: `src/lib/responses.ts` 3. Render an old response against its own version Prove it by reading one back after two schema changes. Files: `tests/version-integrity.test.ts` ### Done when - [ ] Publishing an edit creates a version and preserves the old one - [ ] An old response renders against its own version - [ ] A removed question's earlier answers stay readable - [ ] Published versions cannot be mutated ### Watch out - Editing the question list in place while a survey is collecting silently rewrites history: yesterday's respondents answered a five point scale, today it has seven points, and the export now averages two different questions into one number. Freeze a version on publish and stamp every response with it. ## M2 · Branching evaluated where it cannot be edited The server decides which questions are asked, which are required, and which answers are acceptable. ### Steps 1. Compute the next page on the server The browser renders what it is told and decides nothing. Files: `src/lib/logic.ts`, `src/app/s/[token]/route.ts` 2. Reject answers to unasked questions The check that turns skip logic from decoration into a rule. Files: `src/lib/validate-submission.ts`, `tests/hidden-question.test.ts` 3. Refuse an impossible rule at publish Cycles and forward references caught before a respondent meets them. Files: `src/lib/logic-check.ts` ### Done when - [ ] An answer to a hidden question is rejected - [ ] A hidden required question does not block submission - [ ] Validation holds with client rules disabled - [ ] Cyclic or forward-referencing logic is refused at publish ### Watch out - Implementing skip logic with a hidden attribute in the page means a respondent who opens developer tools, or a script posting the form directly, submits answers to questions that were never shown, and your required-field rule lets them through because the browser is the only thing enforcing it. Decide server-side and reject answers to questions the server did not ask. ## M3 · Collecting on a bad connection A partial answer survives a closed tab, and a double submit produces one response. ### Steps 1. Save per page, marked partial Fifteen minutes of someone's attention should not depend on their connection. Files: `src/lib/partial.ts` 2. Claim the submission token in the transaction Idempotency belongs in the database, not in a debounce. Files: `src/lib/submit.ts`, `tests/double-submit.test.ts` 3. Record an events trail The answer to 'it did not save' is a query, not a shrug. Files: `src/lib/events.ts` ### Done when - [ ] A double submit yields one response - [ ] A closed tab resumes with answers intact - [ ] Expired partials are logged, not silently dropped - [ ] The events trail explains a missing response ### Watch out - A respondent on a train taps submit, the request times out, they tap again, and you now have two responses from one person because the endpoint treats each post as new. Claim a one-shot submission token inside the transaction that records the submission, so the second post finds it already claimed and returns the same result. ## M4 · An export that a spreadsheet will not execute CSV output is safe to open and stable across schema versions. ### Steps 1. Neutralise formula-leading cells Equals, plus, minus and at sign, per OWASP's list. Files: `src/lib/csv.ts`, `tests/csv-injection.test.ts` 2. Fix a column order across versions An export whose columns move is an export nobody can chart. Files: `src/lib/export-columns.ts` 3. Open the crafted export in a spreadsheet The only test of this that counts is the manual one. Files: `docs/export-verification.md` ### Done when - [ ] A formula-leading answer displays as text in a spreadsheet - [ ] Free text with commas, quotes and newlines round trips - [ ] Column order is stable across versions - [ ] JSON export carries the version id ### Watch out - A respondent types a value starting with an equals sign into a free-text box, and when a colleague opens your export in a spreadsheet it runs as a formula, which OWASP documents as reaching data exfiltration and worse depending on the application. Neutralise every cell whose first character is one of those four, and open a crafted export in a real spreadsheet before you believe your fix. ## M5 · Sharing it without giving it away (production only) A public link collects responses without becoming an open door, and data can be deleted on request. ### Steps 1. Unguessable tokens and a cap Two lines that prevent the two ways a public form goes wrong. Files: `src/lib/tokens.ts`, `src/lib/limits.ts` 2. Sign and retry webhooks A webhook nobody can verify is a webhook nobody should trust. Files: `src/lib/webhooks.ts` 3. Make deletion reach the exports A deleted response sitting in a CSV on disk is not deleted. Files: `src/lib/delete.ts`, `tests/deletion-reaches-exports.test.ts` ### Done when - [ ] Links are unguessable and enumeration fails - [ ] Caps and close dates stop collection with an explanation - [ ] Webhooks are signed and retried with visible state - [ ] Deletion removes staged export copies too ### Watch out - A survey link that is only a sequential id lets anyone enumerate your other surveys, including the internal one you thought was private, and a survey with no cap and no rate limit becomes the target of a script that fills your database overnight. Use unguessable tokens, a response cap and a rate limit, and alert when the cap is reached rather than closing silently. ===== OPERATIONS.md ===== # Operations · Alchemer ## Backup The database plus the export directory, nightly, with a copy off the machine. A survey's value is entirely in rows that cannot be recollected, so the backup interval is the maximum amount of research you are willing to redo. ## Restore Restore, then confirm an old response still renders against its own version and that the newest version is intact. A restore that renders old answers against the current schema is the failure mode this whole design prevents. Do a restore drill before the first real user, and write the date here when it passes. ## Monitoring Response rate per collecting survey, partials expiring, rate limits reached, webhook failures and any survey approaching its cap. A survey that has stopped receiving responses when it should be busy is the signal that matters. ## Incident checklist A respondent reporting lost answers is answered from the events trail, not from a guess. A suspected spam flood is answered from the rate limit log and the source column, and the response is to cap the survey rather than to delete rows. 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 - [ ] An old response is confirmed to render against its own version - [ ] A crafted answer to a hidden question is confirmed rejected - [ ] A double submit is confirmed to yield one response - [ ] A formula-leading cell is confirmed to display as text in a real spreadsheet - [ ] Deletion is confirmed to reach staged exports ## Launch constraint Do not market omitted Alchemer 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. Versions, responses and the events trail. DATABASE_PATH=./data/surveys.db # Required. Used to build share links. A wrong value produces links nobody can open. PUBLIC_BASE_URL=https://surveys.example.com # Required. Entropy for survey and response tokens. Enumeration is the failure this prevents. TOKEN_BYTES=24 # Required. After this a partial response expires, and the expiry is logged. PARTIAL_TTL_HOURS=72 # Required. Per address, per survey. Reaching it alerts rather than failing silently. RATE_LIMIT_PER_HOUR=60 # Optional · secret. Signs outgoing deliveries so the receiver can verify them. WEBHOOK_SIGNING_SECRET=whsec-...redacted # Required. Staged exports. Deletion has to reach in here too. EXPORT_DIR=./data/exports
# Alchemer · indie build A survey builder and collector whose hard problems are versioning a questionnaire without invalidating collected answers, evaluating branching on the server, and exporting data that spreadsheets will not execute. 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 with TypeScript, server actions for submission | the collection endpoint is the part that must not be clever | | Storage | SQLite with responses stored as answered, never normalised into the current schema | the schema changes and the answers must not | | Versioning | Immutable questionnaire versions, referenced by every response | an edit to a live survey is a new version, not an update | | Logic | A small declarative rule format evaluated server-side | branching in the browser is decoration | | Export | CSV and JSON written by one module with escaping tested | the export is where a survey tool hurts someone | ## 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 tests. - Get it: nodejs.org, or nvm install 22. - Verify: node --version prints v22 or higher - [ ] **A spreadsheet application** · free - Why: The CSV injection fix cannot be verified by reading code. You need to open a crafted export and see the cell displayed as text. - Get it: Any spreadsheet that people in your organisation actually use to open exports. - Verify: A crafted export opens showing the value as text rather than evaluating it - [ ] **Somewhere to put the collection endpoint** · a few pounds a month - Why: A survey link has to be reachable by respondents, which means a public address and a certificate. - Get it: A small VPS or any platform that serves a Node application over HTTPS. - Verify: A survey link opens from a device on a different network - [ ] **Your retention and cap policy** · free - Why: How long partials live, what the response cap is, and how long submitted responses are kept are all asserted by tests, and choosing them after launch means choosing whatever the code already does. - Get it: Write down three numbers and the reason for each. - Verify: All three are in the 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 integration catalogue. - Panel and audience purchasing. - Compliance certifications and residency guarantees. - Bot defence at public scale. - large integration and template catalog - compliance controls and guaranteed delivery - resilient polling of malformed and rate-limited feeds - enterprise workflow logic - spam and abuse defense at scale If one of those is essential to you, that is the reason to keep paying for Alchemer, and the README should say so rather than pretend.
# Build brief · Alchemer The one-shot brief this plan expands. `BUILD_PLAN.md` (or `MILESTONES.md`) is the same sequence broken into steps and checks; where the two disagree, the plan wins. Build me a survey tool that never loses a submitted response and never ships an export that executes to replace Alchemer. 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. A response that reached your server is never lost and never quietly rewritten. That means the questionnaire is versioned, every response records the version it answered, and editing a live survey creates a new version instead of changing the meaning of answers already collected. A respondent gave you fifteen minutes; losing it or reinterpreting it is not a bug you get to fix later. Second, the logic that decides which questions get asked runs on the server. Branching implemented only in the browser is a suggestion: a crafted request answers questions the respondent never saw, and a validation rule enforced only in the page is not a validation rule at all. ### Stack (fixed, do not substitute) - Runtime: Next.js 15 with TypeScript, server actions for submission · the collection endpoint is the part that must not be clever - Storage: SQLite with responses stored as answered, never normalised into the current schema · the schema changes and the answers must not - Versioning: Immutable questionnaire versions, referenced by every response · an edit to a live survey is a new version, not an update - Logic: A small declarative rule format evaluated server-side · branching in the browser is decoration - Export: CSV and JSON written by one module with escaping tested · the export is where a survey tool hurts someone ### Data model (create this before Phase 1) - surveys(id, name, created_at, current_version_id, state) · draft, collecting or closed - versions(id, survey_id, ordinal, schema_json, logic_json, published_at) · immutable once published - questions(version_id, id, kind, prompt, required, options_json, validation_json) · derived from the schema, kept for querying - responses(id, survey_id, version_id, token, started_at, submitted_at, state, source) · state is partial, submitted or discarded - answers(response_id, question_id, value_json, answered_at) · the value as given, not coerced to the current schema - submissions(token, response_id, claimed_at) · the one-shot token that makes a double post idempotent - events(response_id, at, kind, detail) · page views, saves, validation failures, the trail when someone says their answer vanished - Answers reference a question id within a version, so a later version renaming or removing that question leaves the old answer readable. ### Phase 1 · A questionnaire you can edit while it is collecting Build: Represent a questionnaire as a schema document. Publishing freezes it as a version with an ordinal. Editing a published survey opens a draft that becomes the next version on publish. Every response stores the version id it was answered against. Trap: Editing the question list in place while a survey is collecting silently rewrites history: yesterday's respondents answered a five point scale, today it has seven points, and the export now averages two different questions into one number. Freeze a version on publish and stamp every response with it. Done when: Publishing an edit to a collecting survey produces a new version and leaves the previous one intact, tested by reading an old response back. A response records the version it answered and renders against that version, not the current one. Deleting a question in a new version leaves earlier answers to it readable and exportable. A draft cannot be answered and a published version cannot be mutated, both enforced in the data layer. Do not build yet: No collection page, logic, export or sharing yet. ### Phase 2 · Branching evaluated where it cannot be edited Build: A declarative logic format: show or hide a question when an earlier answer matches a condition. Evaluate it server-side to produce the next page. Validate every submitted answer against the version's rules on the server, including that an answer to a hidden question is rejected. Trap: Implementing skip logic with a hidden attribute in the page means a respondent who opens developer tools, or a script posting the form directly, submits answers to questions that were never shown, and your required-field rule lets them through because the browser is the only thing enforcing it. Decide server-side and reject answers to questions the server did not ask. Done when: A crafted submission containing an answer to a question the logic hid is rejected with a clear error, tested directly against the endpoint. A required question hidden by logic does not block submission, tested. Validation rules for each question kind are enforced server-side and the client rules are only a convenience, verified by disabling the client rules in a test. A logic cycle or a condition referencing a later question is refused at publish time with the offending rule named. Do not build yet: No partial saves, export, webhooks or abuse limits yet. ### Phase 3 · Collecting on a bad connection Build: Give each respondent a token. Save answers per page as they go, marked partial. Mint a submission token with the response and require it on submit, claiming it in the same transaction that marks the response submitted. Allow resume from the token unless the survey forbids it. Trap: A respondent on a train taps submit, the request times out, they tap again, and you now have two responses from one person because the endpoint treats each post as new. Claim a one-shot submission token inside the transaction that records the submission, so the second post finds it already claimed and returns the same result. Done when: Posting the same submission twice produces one response and the second returns the original outcome, tested with concurrent requests. Closing the tab halfway leaves a partial response that resumes with answers intact. A partial response older than the configured window is expired and the expiry is logged rather than silently deleted. The events trail shows page views, saves and validation failures for any response, which is what you read when someone says their answers vanished. Do not build yet: No export, reporting, webhooks or retention policy yet. ### Phase 4 · An export that a spreadsheet will not execute Build: Export one row per response with a stable column order derived from the union of versions. Neutralise formula-leading values: OWASP's guidance on CSV injection is that a cell beginning with an equals sign, plus, minus or at sign is interpreted as a formula by spreadsheet software (checked against OWASP's own page), so no exported cell may begin with one. Quote and escape correctly for multi-line free text. Trap: A respondent types a value starting with an equals sign into a free-text box, and when a colleague opens your export in a spreadsheet it runs as a formula, which OWASP documents as reaching data exfiltration and worse depending on the application. Neutralise every cell whose first character is one of those four, and open a crafted export in a real spreadsheet before you believe your fix. Done when: A response containing a cell beginning with an equals sign exports in a form that a spreadsheet displays as text, verified by opening the file rather than by reading the code. Free text containing commas, quotes and newlines round trips through the CSV without shifting a column. Column order is stable across two schema versions and questions removed in a later version still appear with their earlier answers. A JSON export carries the version id per response so the file is interpretable without the database. Do not build yet: No sharing limits, retention or webhooks yet. ### Phase 5 · Sharing it without giving it away Build: Public links carry an unguessable token and an optional close date and response cap. Rate limit by address with a stated window. Deliver webhooks with retry and a signature, and show delivery state per response. Implement deletion of a single response and of a whole survey, including from exports already staged. Trap: A survey link that is only a sequential id lets anyone enumerate your other surveys, including the internal one you thought was private, and a survey with no cap and no rate limit becomes the target of a script that fills your database overnight. Use unguessable tokens, a response cap and a rate limit, and alert when the cap is reached rather than closing silently. Done when: Survey links are unguessable and enumeration of another survey's link fails, tested. A survey that reaches its response cap or close date stops accepting and shows the respondent an explanation rather than an error. Webhook delivery retries with backoff, is signed, and per-response delivery state is visible. Deleting a response removes it from the database and from any staged export, verified by searching the export directory in the test. Do not build yet: Nothing after this. ### Out of scope (and why) - The integration catalogue, which is the actual product at the enterprise end and is a permanent maintenance commitment rather than a feature. - Panel and audience purchasing, which is a marketplace with its own fraud problem. - Compliance certifications and data residency guarantees, which are audits and contracts, not code you can write. - Spam and bot defence at the scale a public survey attracts, which needs signals you do not have and is why people pay. ### README must contain - That publishing an edit creates a new version and every response records the version it answered - That branching and validation are evaluated on the server, and answers to hidden questions are rejected - How the submission token makes a double post idempotent, and how to resume a partial response - That CSV exports neutralise cells beginning with equals, plus, minus or at sign, and why - How to delete one response or a whole survey, including copies already staged for export
# Agent instructions · Alchemer indie build - Read `README.md` and `BUILD_PLAN.md` before writing code. The stack is fixed: Next.js 15 with TypeScript, server actions for submission, SQLite with responses stored as answered, never normalised into the current schema, Immutable questionnaire versions, referenced by every response, A small declarative rule format evaluated server-side, CSV and JSON written by one module with escaping tested. Do not substitute. - Work one phase at a time, in order. Do not start a phase until every "Done when" item of the previous one passes. - Prefer the fewest moving parts that satisfy the step. No frameworks, services or dependencies the plan does not name. - Secrets live in `.env`, never in source or logs. Keep `.env.example` current when a variable is introduced. - Do not invent cryptography, security guarantees, APIs or compliance claims. - Add a focused test for every destructive, security-sensitive or data-loss path the plan names. - Run the project checks before declaring a phase complete, and record any deliberate shortcut in the README under "Tradeoffs". ## Known traps - Editing the question list in place while a survey is collecting silently rewrites history: yesterday's respondents answered a five point scale, today it has seven points, and the export now averages two different questions into one number. Freeze a version on publish and stamp every response with it. - Implementing skip logic with a hidden attribute in the page means a respondent who opens developer tools, or a script posting the form directly, submits answers to questions that were never shown, and your required-field rule lets them through because the browser is the only thing enforcing it. Decide server-side and reject answers to questions the server did not ask. - A respondent on a train taps submit, the request times out, they tap again, and you now have two responses from one person because the endpoint treats each post as new. Claim a one-shot submission token inside the transaction that records the submission, so the second post finds it already claimed and returns the same result. - A respondent types a value starting with an equals sign into a free-text box, and when a colleague opens your export in a spreadsheet it runs as a formula, which OWASP documents as reaching data exfiltration and worse depending on the application. Neutralise every cell whose first character is one of those four, and open a crafted export in a real spreadsheet before you believe your fix.
# Build plan · Alchemer A survey builder and collector whose hard problems are versioning a questionnaire without invalidating collected answers, evaluating branching on the server, and exporting data that spreadsheets will not execute. Phases are in dependency order. Each ends in a "Done when" list; treat an unticked item as a blocker, not a note. ## Phase 1 · A questionnaire you can edit while it is collecting Publishing a change creates a version, and answers already collected still mean what they meant. ### Steps 1. Freeze a version on publish Immutability in the schema, not in a code comment. Files: `src/lib/versions.ts`, `src/db/schema.sql` 2. Stamp every response with its version This one column is what makes the whole model work. Files: `src/lib/responses.ts` 3. Render an old response against its own version Prove it by reading one back after two schema changes. Files: `tests/version-integrity.test.ts` ### Done when - [ ] Publishing an edit creates a version and preserves the old one - [ ] An old response renders against its own version - [ ] A removed question's earlier answers stay readable - [ ] Published versions cannot be mutated ### Watch out - Editing the question list in place while a survey is collecting silently rewrites history: yesterday's respondents answered a five point scale, today it has seven points, and the export now averages two different questions into one number. Freeze a version on publish and stamp every response with it. ## Phase 2 · Branching evaluated where it cannot be edited The server decides which questions are asked, which are required, and which answers are acceptable. ### Steps 1. Compute the next page on the server The browser renders what it is told and decides nothing. Files: `src/lib/logic.ts`, `src/app/s/[token]/route.ts` 2. Reject answers to unasked questions The check that turns skip logic from decoration into a rule. Files: `src/lib/validate-submission.ts`, `tests/hidden-question.test.ts` 3. Refuse an impossible rule at publish Cycles and forward references caught before a respondent meets them. Files: `src/lib/logic-check.ts` ### Done when - [ ] An answer to a hidden question is rejected - [ ] A hidden required question does not block submission - [ ] Validation holds with client rules disabled - [ ] Cyclic or forward-referencing logic is refused at publish ### Watch out - Implementing skip logic with a hidden attribute in the page means a respondent who opens developer tools, or a script posting the form directly, submits answers to questions that were never shown, and your required-field rule lets them through because the browser is the only thing enforcing it. Decide server-side and reject answers to questions the server did not ask. ## Phase 3 · Collecting on a bad connection A partial answer survives a closed tab, and a double submit produces one response. ### Steps 1. Save per page, marked partial Fifteen minutes of someone's attention should not depend on their connection. Files: `src/lib/partial.ts` 2. Claim the submission token in the transaction Idempotency belongs in the database, not in a debounce. Files: `src/lib/submit.ts`, `tests/double-submit.test.ts` 3. Record an events trail The answer to 'it did not save' is a query, not a shrug. Files: `src/lib/events.ts` ### Done when - [ ] A double submit yields one response - [ ] A closed tab resumes with answers intact - [ ] Expired partials are logged, not silently dropped - [ ] The events trail explains a missing response ### Watch out - A respondent on a train taps submit, the request times out, they tap again, and you now have two responses from one person because the endpoint treats each post as new. Claim a one-shot submission token inside the transaction that records the submission, so the second post finds it already claimed and returns the same result. ## Phase 4 · An export that a spreadsheet will not execute CSV output is safe to open and stable across schema versions. ### Steps 1. Neutralise formula-leading cells Equals, plus, minus and at sign, per OWASP's list. Files: `src/lib/csv.ts`, `tests/csv-injection.test.ts` 2. Fix a column order across versions An export whose columns move is an export nobody can chart. Files: `src/lib/export-columns.ts` 3. Open the crafted export in a spreadsheet The only test of this that counts is the manual one. Files: `docs/export-verification.md` ### Done when - [ ] A formula-leading answer displays as text in a spreadsheet - [ ] Free text with commas, quotes and newlines round trips - [ ] Column order is stable across versions - [ ] JSON export carries the version id ### Watch out - A respondent types a value starting with an equals sign into a free-text box, and when a colleague opens your export in a spreadsheet it runs as a formula, which OWASP documents as reaching data exfiltration and worse depending on the application. Neutralise every cell whose first character is one of those four, and open a crafted export in a real spreadsheet before you believe your fix. ## Not in this build - The integration catalogue. - Panel and audience purchasing. - Compliance certifications and residency guarantees. - Bot defence at public scale. ## After v1, if you want it - Piping earlier answers into later question text, which reuses the logic evaluator and nothing else - A second language per version, since versions are already immutable and can carry translations - Scheduled exports to a folder, once deletion is proven to reach staged files
# Copy to .env and fill in. Never commit .env; this file documents it. # Required. Versions, responses and the events trail. DATABASE_PATH=./data/surveys.db # Required. Used to build share links. A wrong value produces links nobody can open. PUBLIC_BASE_URL=https://surveys.example.com # Required. Entropy for survey and response tokens. Enumeration is the failure this prevents. TOKEN_BYTES=24 # Required. After this a partial response expires, and the expiry is logged. PARTIAL_TTL_HOURS=72 # Required. Per address, per survey. Reaching it alerts rather than failing silently. RATE_LIMIT_PER_HOUR=60 # Optional · secret. Signs outgoing deliveries so the receiver can verify them. WEBHOOK_SIGNING_SECRET=whsec-...redacted # Required. Staged exports. Deletion has to reach in here too. EXPORT_DIR=./data/exports
# Alchemer · product brief ## Problem The visible enterprise surveys loop is buildable, but a credible replacement needs more than the first screen. Alchemer 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 an edit mid-collection does not rewrite yesterday's answers, a crafted post cannot answer a hidden question, a double tap does not double count, and the export is safe to hand to a colleague. ## 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 integration catalogue. - Panel and audience purchasing. - Compliance certifications and residency guarantees. - Bot defence at public scale. - large integration and template catalog - compliance controls and guaranteed delivery - resilient polling of malformed and rate-limited feeds - enterprise workflow logic - spam and abuse defense at scale ## Success criteria - An old response is confirmed to render against its own version - A crafted answer to a hidden question is confirmed rejected - A double submit is confirmed to yield one response - A formula-leading cell is confirmed to display as text in a real spreadsheet - Deletion is confirmed to reach staged exports
# Build brief · Alchemer The one-shot brief this plan expands. `BUILD_PLAN.md` (or `MILESTONES.md`) is the same sequence broken into steps and checks; where the two disagree, the plan wins. Build me a survey tool that never loses a submitted response and never ships an export that executes to replace Alchemer. 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. A response that reached your server is never lost and never quietly rewritten. That means the questionnaire is versioned, every response records the version it answered, and editing a live survey creates a new version instead of changing the meaning of answers already collected. A respondent gave you fifteen minutes; losing it or reinterpreting it is not a bug you get to fix later. Second, the logic that decides which questions get asked runs on the server. Branching implemented only in the browser is a suggestion: a crafted request answers questions the respondent never saw, and a validation rule enforced only in the page is not a validation rule at all. ### Stack (fixed, do not substitute) - Runtime: Next.js 15 with TypeScript, server actions for submission · the collection endpoint is the part that must not be clever - Storage: SQLite with responses stored as answered, never normalised into the current schema · the schema changes and the answers must not - Versioning: Immutable questionnaire versions, referenced by every response · an edit to a live survey is a new version, not an update - Logic: A small declarative rule format evaluated server-side · branching in the browser is decoration - Export: CSV and JSON written by one module with escaping tested · the export is where a survey tool hurts someone ### Data model (create this before Phase 1) - surveys(id, name, created_at, current_version_id, state) · draft, collecting or closed - versions(id, survey_id, ordinal, schema_json, logic_json, published_at) · immutable once published - questions(version_id, id, kind, prompt, required, options_json, validation_json) · derived from the schema, kept for querying - responses(id, survey_id, version_id, token, started_at, submitted_at, state, source) · state is partial, submitted or discarded - answers(response_id, question_id, value_json, answered_at) · the value as given, not coerced to the current schema - submissions(token, response_id, claimed_at) · the one-shot token that makes a double post idempotent - events(response_id, at, kind, detail) · page views, saves, validation failures, the trail when someone says their answer vanished - Answers reference a question id within a version, so a later version renaming or removing that question leaves the old answer readable. ### Phase 1 · A questionnaire you can edit while it is collecting Build: Represent a questionnaire as a schema document. Publishing freezes it as a version with an ordinal. Editing a published survey opens a draft that becomes the next version on publish. Every response stores the version id it was answered against. Trap: Editing the question list in place while a survey is collecting silently rewrites history: yesterday's respondents answered a five point scale, today it has seven points, and the export now averages two different questions into one number. Freeze a version on publish and stamp every response with it. Done when: Publishing an edit to a collecting survey produces a new version and leaves the previous one intact, tested by reading an old response back. A response records the version it answered and renders against that version, not the current one. Deleting a question in a new version leaves earlier answers to it readable and exportable. A draft cannot be answered and a published version cannot be mutated, both enforced in the data layer. Do not build yet: No collection page, logic, export or sharing yet. ### Phase 2 · Branching evaluated where it cannot be edited Build: A declarative logic format: show or hide a question when an earlier answer matches a condition. Evaluate it server-side to produce the next page. Validate every submitted answer against the version's rules on the server, including that an answer to a hidden question is rejected. Trap: Implementing skip logic with a hidden attribute in the page means a respondent who opens developer tools, or a script posting the form directly, submits answers to questions that were never shown, and your required-field rule lets them through because the browser is the only thing enforcing it. Decide server-side and reject answers to questions the server did not ask. Done when: A crafted submission containing an answer to a question the logic hid is rejected with a clear error, tested directly against the endpoint. A required question hidden by logic does not block submission, tested. Validation rules for each question kind are enforced server-side and the client rules are only a convenience, verified by disabling the client rules in a test. A logic cycle or a condition referencing a later question is refused at publish time with the offending rule named. Do not build yet: No partial saves, export, webhooks or abuse limits yet. ### Phase 3 · Collecting on a bad connection Build: Give each respondent a token. Save answers per page as they go, marked partial. Mint a submission token with the response and require it on submit, claiming it in the same transaction that marks the response submitted. Allow resume from the token unless the survey forbids it. Trap: A respondent on a train taps submit, the request times out, they tap again, and you now have two responses from one person because the endpoint treats each post as new. Claim a one-shot submission token inside the transaction that records the submission, so the second post finds it already claimed and returns the same result. Done when: Posting the same submission twice produces one response and the second returns the original outcome, tested with concurrent requests. Closing the tab halfway leaves a partial response that resumes with answers intact. A partial response older than the configured window is expired and the expiry is logged rather than silently deleted. The events trail shows page views, saves and validation failures for any response, which is what you read when someone says their answers vanished. Do not build yet: No export, reporting, webhooks or retention policy yet. ### Phase 4 · An export that a spreadsheet will not execute Build: Export one row per response with a stable column order derived from the union of versions. Neutralise formula-leading values: OWASP's guidance on CSV injection is that a cell beginning with an equals sign, plus, minus or at sign is interpreted as a formula by spreadsheet software (checked against OWASP's own page), so no exported cell may begin with one. Quote and escape correctly for multi-line free text. Trap: A respondent types a value starting with an equals sign into a free-text box, and when a colleague opens your export in a spreadsheet it runs as a formula, which OWASP documents as reaching data exfiltration and worse depending on the application. Neutralise every cell whose first character is one of those four, and open a crafted export in a real spreadsheet before you believe your fix. Done when: A response containing a cell beginning with an equals sign exports in a form that a spreadsheet displays as text, verified by opening the file rather than by reading the code. Free text containing commas, quotes and newlines round trips through the CSV without shifting a column. Column order is stable across two schema versions and questions removed in a later version still appear with their earlier answers. A JSON export carries the version id per response so the file is interpretable without the database. Do not build yet: No sharing limits, retention or webhooks yet. ### Phase 5 · Sharing it without giving it away Build: Public links carry an unguessable token and an optional close date and response cap. Rate limit by address with a stated window. Deliver webhooks with retry and a signature, and show delivery state per response. Implement deletion of a single response and of a whole survey, including from exports already staged. Trap: A survey link that is only a sequential id lets anyone enumerate your other surveys, including the internal one you thought was private, and a survey with no cap and no rate limit becomes the target of a script that fills your database overnight. Use unguessable tokens, a response cap and a rate limit, and alert when the cap is reached rather than closing silently. Done when: Survey links are unguessable and enumeration of another survey's link fails, tested. A survey that reaches its response cap or close date stops accepting and shows the respondent an explanation rather than an error. Webhook delivery retries with backoff, is signed, and per-response delivery state is visible. Deleting a response removes it from the database and from any staged export, verified by searching the export directory in the test. Do not build yet: Nothing after this. ### Out of scope (and why) - The integration catalogue, which is the actual product at the enterprise end and is a permanent maintenance commitment rather than a feature. - Panel and audience purchasing, which is a marketplace with its own fraud problem. - Compliance certifications and data residency guarantees, which are audits and contracts, not code you can write. - Spam and bot defence at the scale a public survey attracts, which needs signals you do not have and is why people pay. ### README must contain - That publishing an edit creates a new version and every response records the version it answered - That branching and validation are evaluated on the server, and answers to hidden questions are rejected - How the submission token makes a double post idempotent, and how to resume a partial response - That CSV exports neutralise cells beginning with equals, plus, minus or at sign, and why - How to delete one response or a whole survey, including copies already staged for export
# Architecture · Alchemer ## Stack | Part | Choice | Why | | --- | --- | --- | | Runtime | Next.js 15 with TypeScript, server actions for submission | the collection endpoint is the part that must not be clever | | Storage | SQLite with responses stored as answered, never normalised into the current schema | the schema changes and the answers must not | | Versioning | Immutable questionnaire versions, referenced by every response | an edit to a live survey is a new version, not an update | | Logic | A small declarative rule format evaluated server-side | branching in the browser is decoration | | Export | CSV and JSON written by one module with escaping tested | the export is where a survey tool hurts someone | ## Modules Each module has one owner concern and a documented way to replace it. | Module | Owns | How to replace it | | --- | --- | --- | | Versions | Immutable published schemas. | Any store, provided published versions are unmodifiable. | | Logic | Server-side branching and validation. | Any rule format, provided evaluation never moves to the browser. | | Collection | Partials, resume and one-shot submission. | Any transport, provided the token is claimed in the submit transaction. | | Export | CSV and JSON, escaping and column stability. | Any writer, provided the formula-leading test still passes. | | Delivery | Signed webhooks with retry and visible state. | Any queue, provided delivery state stays per response. | ## 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 · Versions, responses and the events trail. - `PUBLIC_BASE_URL` · required · Used to build share links. A wrong value produces links nobody can open. - `TOKEN_BYTES` · required · Entropy for survey and response tokens. Enumeration is the failure this prevents. - `PARTIAL_TTL_HOURS` · required · After this a partial response expires, and the expiry is logged. - `RATE_LIMIT_PER_HOUR` · required · Per address, per survey. Reaching it alerts rather than failing silently. - `WEBHOOK_SIGNING_SECRET` · optional, secret · Signs outgoing deliveries so the receiver can verify them. - `EXPORT_DIR` · required · Staged exports. Deletion has to reach in here too. ## 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 · Alchemer product build - Read `PRODUCT.md` and `ARCHITECTURE.md` before changing code. The stack is fixed: Next.js 15 with TypeScript, server actions for submission, SQLite with responses stored as answered, never normalised into the current schema, Immutable questionnaire versions, referenced by every response, A small declarative rule format evaluated server-side, CSV and JSON written by one module with escaping tested. - Implement milestone by milestone from `MILESTONES.md`; keep each change reviewable and leave the application runnable at every commit. - Treat authentication, payments, encryption, imports, webhooks and destructive actions as high-risk boundaries when present. - Never invent cryptography or silently weaken a requirement to make a check pass. - Put every external service behind an interface with a deterministic fake for tests. - Add migrations and rollback or recovery notes for every persistent data change. - Log useful operational context without credentials, tokens, passwords or personal data. - Update documentation and run every check before completing a milestone. ## Known traps - Editing the question list in place while a survey is collecting silently rewrites history: yesterday's respondents answered a five point scale, today it has seven points, and the export now averages two different questions into one number. Freeze a version on publish and stamp every response with it. - Implementing skip logic with a hidden attribute in the page means a respondent who opens developer tools, or a script posting the form directly, submits answers to questions that were never shown, and your required-field rule lets them through because the browser is the only thing enforcing it. Decide server-side and reject answers to questions the server did not ask. - A respondent on a train taps submit, the request times out, they tap again, and you now have two responses from one person because the endpoint treats each post as new. Claim a one-shot submission token inside the transaction that records the submission, so the second post finds it already claimed and returns the same result. - A respondent types a value starting with an equals sign into a free-text box, and when a colleague opens your export in a spreadsheet it runs as a formula, which OWASP documents as reaching data exfiltration and worse depending on the application. Neutralise every cell whose first character is one of those four, and open a crafted export in a real spreadsheet before you believe your fix. - A survey link that is only a sequential id lets anyone enumerate your other surveys, including the internal one you thought was private, and a survey with no cap and no rate limit becomes the target of a script that fills your database overnight. Use unguessable tokens, a response cap and a rate limit, and alert when the cap is reached rather than closing silently.
# Delivery milestones · Alchemer Estimated effort: **weekend** for the indie phases; the production-only milestones add the trust and operability layer. ## M1 · A questionnaire you can edit while it is collecting Publishing a change creates a version, and answers already collected still mean what they meant. ### Steps 1. Freeze a version on publish Immutability in the schema, not in a code comment. Files: `src/lib/versions.ts`, `src/db/schema.sql` 2. Stamp every response with its version This one column is what makes the whole model work. Files: `src/lib/responses.ts` 3. Render an old response against its own version Prove it by reading one back after two schema changes. Files: `tests/version-integrity.test.ts` ### Done when - [ ] Publishing an edit creates a version and preserves the old one - [ ] An old response renders against its own version - [ ] A removed question's earlier answers stay readable - [ ] Published versions cannot be mutated ### Watch out - Editing the question list in place while a survey is collecting silently rewrites history: yesterday's respondents answered a five point scale, today it has seven points, and the export now averages two different questions into one number. Freeze a version on publish and stamp every response with it. ## M2 · Branching evaluated where it cannot be edited The server decides which questions are asked, which are required, and which answers are acceptable. ### Steps 1. Compute the next page on the server The browser renders what it is told and decides nothing. Files: `src/lib/logic.ts`, `src/app/s/[token]/route.ts` 2. Reject answers to unasked questions The check that turns skip logic from decoration into a rule. Files: `src/lib/validate-submission.ts`, `tests/hidden-question.test.ts` 3. Refuse an impossible rule at publish Cycles and forward references caught before a respondent meets them. Files: `src/lib/logic-check.ts` ### Done when - [ ] An answer to a hidden question is rejected - [ ] A hidden required question does not block submission - [ ] Validation holds with client rules disabled - [ ] Cyclic or forward-referencing logic is refused at publish ### Watch out - Implementing skip logic with a hidden attribute in the page means a respondent who opens developer tools, or a script posting the form directly, submits answers to questions that were never shown, and your required-field rule lets them through because the browser is the only thing enforcing it. Decide server-side and reject answers to questions the server did not ask. ## M3 · Collecting on a bad connection A partial answer survives a closed tab, and a double submit produces one response. ### Steps 1. Save per page, marked partial Fifteen minutes of someone's attention should not depend on their connection. Files: `src/lib/partial.ts` 2. Claim the submission token in the transaction Idempotency belongs in the database, not in a debounce. Files: `src/lib/submit.ts`, `tests/double-submit.test.ts` 3. Record an events trail The answer to 'it did not save' is a query, not a shrug. Files: `src/lib/events.ts` ### Done when - [ ] A double submit yields one response - [ ] A closed tab resumes with answers intact - [ ] Expired partials are logged, not silently dropped - [ ] The events trail explains a missing response ### Watch out - A respondent on a train taps submit, the request times out, they tap again, and you now have two responses from one person because the endpoint treats each post as new. Claim a one-shot submission token inside the transaction that records the submission, so the second post finds it already claimed and returns the same result. ## M4 · An export that a spreadsheet will not execute CSV output is safe to open and stable across schema versions. ### Steps 1. Neutralise formula-leading cells Equals, plus, minus and at sign, per OWASP's list. Files: `src/lib/csv.ts`, `tests/csv-injection.test.ts` 2. Fix a column order across versions An export whose columns move is an export nobody can chart. Files: `src/lib/export-columns.ts` 3. Open the crafted export in a spreadsheet The only test of this that counts is the manual one. Files: `docs/export-verification.md` ### Done when - [ ] A formula-leading answer displays as text in a spreadsheet - [ ] Free text with commas, quotes and newlines round trips - [ ] Column order is stable across versions - [ ] JSON export carries the version id ### Watch out - A respondent types a value starting with an equals sign into a free-text box, and when a colleague opens your export in a spreadsheet it runs as a formula, which OWASP documents as reaching data exfiltration and worse depending on the application. Neutralise every cell whose first character is one of those four, and open a crafted export in a real spreadsheet before you believe your fix. ## M5 · Sharing it without giving it away (production only) A public link collects responses without becoming an open door, and data can be deleted on request. ### Steps 1. Unguessable tokens and a cap Two lines that prevent the two ways a public form goes wrong. Files: `src/lib/tokens.ts`, `src/lib/limits.ts` 2. Sign and retry webhooks A webhook nobody can verify is a webhook nobody should trust. Files: `src/lib/webhooks.ts` 3. Make deletion reach the exports A deleted response sitting in a CSV on disk is not deleted. Files: `src/lib/delete.ts`, `tests/deletion-reaches-exports.test.ts` ### Done when - [ ] Links are unguessable and enumeration fails - [ ] Caps and close dates stop collection with an explanation - [ ] Webhooks are signed and retried with visible state - [ ] Deletion removes staged export copies too ### Watch out - A survey link that is only a sequential id lets anyone enumerate your other surveys, including the internal one you thought was private, and a survey with no cap and no rate limit becomes the target of a script that fills your database overnight. Use unguessable tokens, a response cap and a rate limit, and alert when the cap is reached rather than closing silently.
# Operations · Alchemer ## Backup The database plus the export directory, nightly, with a copy off the machine. A survey's value is entirely in rows that cannot be recollected, so the backup interval is the maximum amount of research you are willing to redo. ## Restore Restore, then confirm an old response still renders against its own version and that the newest version is intact. A restore that renders old answers against the current schema is the failure mode this whole design prevents. Do a restore drill before the first real user, and write the date here when it passes. ## Monitoring Response rate per collecting survey, partials expiring, rate limits reached, webhook failures and any survey approaching its cap. A survey that has stopped receiving responses when it should be busy is the signal that matters. ## Incident checklist A respondent reporting lost answers is answered from the events trail, not from a guess. A suspected spam flood is answered from the rate limit log and the source column, and the response is to cap the survey rather than to delete rows. 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 - [ ] An old response is confirmed to render against its own version - [ ] A crafted answer to a hidden question is confirmed rejected - [ ] A double submit is confirmed to yield one response - [ ] A formula-leading cell is confirmed to display as text in a real spreadsheet - [ ] Deletion is confirmed to reach staged exports ## Launch constraint Do not market omitted Alchemer 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. Versions, responses and the events trail. DATABASE_PATH=./data/surveys.db # Required. Used to build share links. A wrong value produces links nobody can open. PUBLIC_BASE_URL=https://surveys.example.com # Required. Entropy for survey and response tokens. Enumeration is the failure this prevents. TOKEN_BYTES=24 # Required. After this a partial response expires, and the expiry is logged. PARTIAL_TTL_HOURS=72 # Required. Per address, per survey. Reaching it alerts rather than failing silently. RATE_LIMIT_PER_HOUR=60 # Optional · secret. Signs outgoing deliveries so the receiver can verify them. WEBHOOK_SIGNING_SECRET=whsec-...redacted # Required. Staged exports. Deletion has to reach in here too. EXPORT_DIR=./data/exports
$ choose a build depth, inspect the files, then open the complete pack in your agent
Alchemer: Customers pay for dependable collection, spam control, notifications, integrations, and confidence that responses will not disappear.
xlarge integration and template catalog
xcompliance controls and guaranteed delivery
xresilient polling of malformed and rate-limited feeds
xenterprise workflow logic
xspam and abuse defense at scale
Alchemer pricing
| plan | monthly | annual (per mo) | what you get |
|---|---|---|---|
| free | $0/user | $0/user | 3 active or closed surveys and 100 responses per survey; standard reports plus CSV and Excel export |
| collaborator | $55/user | $26.25/user | Unlimited surveys and questions; 75,000 responses/year per account |
| professional | $165/user | $89.58/user | Unlimited surveys and questions; 100,000 responses/year per account; maximum 3 users on this self-serve tier |
| full access | $275/user | $157.92/user | Unlimited surveys and questions; 125,000 responses/year per account; maximum 3 users on this self-serve tier |
| business platform | custom | — | For more than 3 users, API access, advanced administration and organization-wide programs; limits are custom |
free tier3 active or closed surveys; 100 responses per survey; standard reports; CSV and Excel export
billingmonthly + annual prepaid; 7-day trial is separate from the permanent free plan
hidden costsResponse allowances are account-wide and follow the highest license on the account rather than stacking per seat; more than 3 users and API access require a custom Business Platform plan.
verified 2026-08-12 · source ↗
Vibecode Alchemer
Kinda. The core of Alchemer is buildable in a weekend with the prompt on this page, but there are real gaps: large integration and template catalog, compliance controls and guaranteed delivery. Read the honest list above before committing.
How much does Alchemer cost?
Alchemer'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 Alchemer?
Honestly: large integration and template catalog; compliance controls and guaranteed delivery; resilient polling of malformed and rate-limited feeds; enterprise workflow logic; spam and abuse defense at scale. If any of those are load-bearing for you, keep paying.
Is there an open-source alternative to Alchemer?
Yes: Formbricks (Open-source surveys and experience management.), HeyForm (Open-source form builder and collection backend.). Using prior art is also vibecoding; the prompt is for when you want it exactly your way.