Vibecode LimeSurvey Cloud
track this build4 phases, 12 steps, beginner friendly0%The visible survey platform loop is buildable, but a credible replacement needs more than the first screen. LimeSurvey Cloud 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 LimeSurvey Cloud. 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 ===== # LimeSurvey Cloud · indie build A self-hosted survey tool whose hard problems are making anonymity structural rather than promised, keeping branching logic honest when a question is edited after responses exist, and closing a quota at the right moment when several people are answering at once. Estimated effort: **weekend**. Work `BUILD_PLAN.md` top to bottom · every phase ends in a check that has to pass before the next one starts. ## Stack | Part | Choice | Why | | --- | --- | --- | | Runtime | Next.js with TypeScript, server rendered, one deployable | a public survey page must work with no client framework on a bad phone connection | | Storage | SQLite with responses in one table and answers keyed by question version | editing a live survey must not rewrite the meaning of answers already collected | | Anonymity | A per-survey mode set at creation, enforced by which columns exist | a promise enforced by application code is a promise until somebody writes a report | | Logic | Branching evaluated server side from a stored rule tree, never from client state | a respondent can edit anything the browser knows and skip logic is how quotas get gamed | | Quotas | Counted in a transaction at submission, with the close decided by the database | two people finishing at once is the normal case, not the edge case | ## 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 migrations. - Get it: nodejs.org or your version manager. - Verify: node --version prints v22 or higher - [ ] **Anonymous or identified, per survey, written down** · free - Why: It is the only irreversible decision in the build and it determines the schema you generate. - Get it: For each survey you plan to run, write down which mode it is and what you will tell respondents on the first page. - Verify: Every planned survey has a mode and a sentence of respondent-facing wording - [ ] **A host with TLS and a real domain** · a few pounds a month - Why: Respondents are typing answers about themselves into a page you sent them, and a certificate warning ends that immediately. - Get it: Any small instance with an automatic certificate, or a platform that provides one. - Verify: The public survey URL loads with a valid certificate on a phone - [ ] **A retention period per survey** · free - Why: Deciding how long you keep other people's answers after you have collected them means deciding it when deleting is inconvenient. - Get it: Choose a number of days per survey and put it in the configuration before the survey opens. - Verify: Every survey has a retention period set before it accepts a response - [ ] **Somewhere off the host to copy the database** (optional) · pennies per month - Why: Responses cannot be recollected, and a survey that lost half its answers is not a survey you can salvage. - Get it: Any object storage or a second machine with a scheduled copy. - Verify: A restore from the copy reproduces the response count ## Quick start Follow Phase 1 in `BUILD_PLAN.md`; it creates the project. Then copy `.env.example` to `.env` and fill in the values it documents. ## Honest limits This build deliberately does not replace: - Bulk invitation sending. - Panel and respondent marketplace integrations. - Weighting and significance testing. - Multi-administrator roles and approvals. - spam and abuse defense at scale - large integration and template catalog - compliance controls and guaranteed delivery - enterprise workflow logic If one of those is essential to you, that is the reason to keep paying for LimeSurvey Cloud, and the README should say so rather than pretend. ===== BRIEF.md ===== # Build brief · LimeSurvey Cloud 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 self-hosted survey tool where anonymity is a decision made before the first response and enforced by the schema to replace LimeSurvey Cloud. 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 Anonymity is not a checkbox you can tick later. It is a property of the schema, and the moment the first response lands you have either got it or you have not. LimeSurvey itself is blunt about this. Its manual states that with anonymised responses there is no way to connect answers and participants, that even the administrator cannot link response data to participant data, and that the response submission date and the participant completion date are both forced to a fixed placeholder value, specifically so the order of submissions cannot be lined up against invitations or web server logs. That last detail is the one people miss: an accurate timestamp on an anonymous response, beside an accurate invitation timestamp, is an identifier. So build it the same way. A survey declares anonymous or identified at creation. An anonymous survey stores no foreign key from a response to a participant, coarsens the submission timestamp to the day, and writes no address, agent string or request log tied to the response. Switching to identified is not supported; the only path is a new survey, and a test proves the attempt fails. And tell respondents which one they are answering, in words, on the first page. A survey that claims anonymity while keeping a row that could rejoin the two tables is worse than one that never made the claim. ### Stack (fixed, do not substitute) - Runtime: Next.js with TypeScript, server rendered, one deployable · a public survey page must work with no client framework on a bad phone connection - Storage: SQLite with responses in one table and answers keyed by question version · editing a live survey must not rewrite the meaning of answers already collected - Anonymity: A per-survey mode set at creation, enforced by which columns exist · a promise enforced by application code is a promise until somebody writes a report - Logic: Branching evaluated server side from a stored rule tree, never from client state · a respondent can edit anything the browser knows and skip logic is how quotas get gamed - Quotas: Counted in a transaction at submission, with the close decided by the database · two people finishing at once is the normal case, not the edge case ### Data model (create this before Phase 1) - surveys(id, title, mode, state, opens_at, closes_at, created_at) · mode is anonymous or identified and is immutable after the first response - questions(id, survey_id, code, kind, required, position, version, retired_at) · a meaningful edit makes a new version rather than mutating the row - options(id, question_id, question_version, value, label, position) - responses(id, survey_id, state, started_at, submitted_at_day, completed) · identified surveys add participant_id; anonymous surveys do not have the column - answers(response_id, question_id, question_version, value_json) · the version is what makes an old answer still readable - participants(id, survey_id, token_hash, invited_at, completed_at) · exists only for identified surveys - quotas(id, survey_id, name, limit_count, action, current_count) · counted in the same transaction that writes the response - rules(id, survey_id, question_id, question_version, condition_json, effect) · the branching tree, evaluated server side ### Phase 1 · Two kinds of survey, decided once Build: Create the schema so an anonymous survey's response table carries no participant reference and stores the submission date coarsened to the day. Identified surveys get a participant table with hashed tokens. Refuse a mode change once any response exists, at the database level as well as in the application. Show the mode to the respondent in plain words on the first page. Trap: Storing a nullable participant column and leaving it empty for anonymous surveys means the anonymity depends on every future query remembering to ignore it, and one CSV export written in a hurry six months later is enough to break the promise. Make the column absent, so a report that tries to join cannot even be written. Done when: An anonymous survey's response rows have no participant reference available to any query, verified by inspecting the schema. An attempt to change a survey's mode after a response exists fails, tested. The submission timestamp on an anonymous response is coarser than a second and no address or agent string is stored against it, checked in the database. The first page of every survey states in a sentence whether answers can be traced back. Do not build yet: No logic, quotas, exports or notifications yet. ### Phase 2 · Questions that can change without rewriting history Build: Version a question whenever its wording, type or options change in a way that alters meaning. Answers store the question version they were given under. Reordering or renaming for clarity is not a new version; changing an option's meaning is. Show the editor which questions already have answers and what a version bump will do to the report. Trap: Editing an option label in place from 'Weekly' to 'Weekly or more often' silently reinterprets every answer already collected under the old wording, and no one will ever know because the report shows a single tidy bar. Version the question, keep both labels, and make the report show them as separate series until a human merges them deliberately. Done when: Changing an option label on a question with existing answers creates a new version and leaves old answers pointing at the old one, tested. The report renders two series for two versions and never silently merges them. Reordering questions does not create a version, tested. The editor warns before any change that will version a question with existing answers. Do not build yet: No branching, quotas or export yet. ### Phase 3 · Branching that the browser cannot argue with Build: Store conditions as a rule tree against question codes and versions. On each page submission, evaluate server side which questions are now in scope and return the next page. Reject any answer for a question the server considers out of scope. Detect and refuse cycles when saving a rule, rather than when a respondent hits one. Trap: Evaluating skip logic in the browser and trusting the posted answers means anyone can submit answers to questions they were never shown, which quietly corrupts every cross-tabulation in the report and is undetectable afterwards. Evaluate on the server, discard out of scope answers with a recorded reason, and count the discards. Done when: An answer posted for an out of scope question is rejected and the rejection is counted, tested by posting directly. Saving a rule set containing a cycle fails with the cycle named, tested. The next page is decided server side from stored answers only. A respondent who goes back and changes an answer has the now out of scope answers cleared rather than retained. Do not build yet: No quotas, exports or invitations yet. ### Phase 4 · Quotas that close at the right moment Build: Count a quota inside the same transaction that writes the completed response, and decide there whether this response is the one that closes it. On closing, apply the configured action: reject with a message, mark the response as screened out, or close the survey. Show partial and complete counts separately in the admin view because they are not the same number. Trap: Checking the quota before writing the response and then writing it leaves a window where four people submitting in the same second all pass a check that says ninety nine, and the overshoot is only visible weeks later in the data. Do the count and the insert in one transaction, and test it by firing concurrent submissions at a quota with one place left. Done when: Concurrent submissions against a quota with one place remaining produce exactly one accepted response, tested with parallel requests. A screened out respondent sees the configured message and their partial data is kept or discarded according to the setting, tested both ways. Partial and complete counts are shown separately and never summed into one figure. Closing a survey stops new responses immediately and leaves in-flight ones able to finish or not, according to the setting. Do not build yet: No export, invitations or retention work yet. ### Phase 5 · Getting the data out and deleting it Build: Export responses as CSV and JSON with a codebook holding question versions, option labels and the survey mode, so the file is readable without the application. Enforce a retention period per survey that deletes responses and any uploads. Give every respondent in an identified survey a way to request their own data or its deletion, and record what you did. Trap: An export that flattens two versions of a question into one column and omits the labels produces a file that looks fine and is wrong, and it will be the file somebody quotes in a report. Ship a codebook beside every export naming every question version and option label, and refuse to export without it. Done when: Every export is accompanied by a codebook covering all question versions present, and exporting without it is not possible, checked by review. A retention period that has passed deletes the responses and their uploads, tested with a clock override. A deletion request on an identified survey removes the participant and their answers and leaves a record that it happened. Re-importing an export into an empty instance reproduces the same counts per question version. Do not build yet: Nothing after this. ### Out of scope (and why) - Email invitation sending at volume, which is a deliverability project rather than a feature and is better rented. - Panel integrations and respondent marketplaces, which are a commercial relationship rather than code. - Weighting and significance testing in the reporting view, because a statistic produced by a tool nobody validated is worse than no statistic. - Multi-administrator roles and approval workflows, which are the part of the paid product that exists for organisations rather than for surveys. ### README must contain - That a survey's anonymous or identified mode is chosen at creation and cannot be changed afterwards, and why - That anonymous responses store a coarsened submission date on purpose, because precise timings can be joined against invitations - That questions are versioned and every answer records the version it was given under - That skip logic is evaluated on the server and out of scope answers are rejected - Where uploads live, what the retention period is, and how a respondent asks for deletion ===== AGENTS.md ===== # Agent instructions · LimeSurvey Cloud indie build - Read `README.md` and `BUILD_PLAN.md` before writing code. The stack is fixed: Next.js with TypeScript, server rendered, one deployable, SQLite with responses in one table and answers keyed by question version, A per-survey mode set at creation, enforced by which columns exist, Branching evaluated server side from a stored rule tree, never from client state, Counted in a transaction at submission, with the close decided by the database. 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 - Storing a nullable participant column and leaving it empty for anonymous surveys means the anonymity depends on every future query remembering to ignore it, and one CSV export written in a hurry six months later is enough to break the promise. Make the column absent, so a report that tries to join cannot even be written. - Editing an option label in place from 'Weekly' to 'Weekly or more often' silently reinterprets every answer already collected under the old wording, and no one will ever know because the report shows a single tidy bar. Version the question, keep both labels, and make the report show them as separate series until a human merges them deliberately. - Evaluating skip logic in the browser and trusting the posted answers means anyone can submit answers to questions they were never shown, which quietly corrupts every cross-tabulation in the report and is undetectable afterwards. Evaluate on the server, discard out of scope answers with a recorded reason, and count the discards. - Checking the quota before writing the response and then writing it leaves a window where four people submitting in the same second all pass a check that says ninety nine, and the overshoot is only visible weeks later in the data. Do the count and the insert in one transaction, and test it by firing concurrent submissions at a quota with one place left. ===== BUILD_PLAN.md ===== # Build plan · LimeSurvey Cloud A self-hosted survey tool whose hard problems are making anonymity structural rather than promised, keeping branching logic honest when a question is edited after responses exist, and closing a quota at the right moment when several people are answering at once. Phases are in dependency order. Each ends in a "Done when" list; treat an unticked item as a blocker, not a note. ## Phase 1 · Two kinds of survey, decided once Creating a survey chooses anonymous or identified, and the storage differs accordingly. ### Steps 1. Make anonymity a schema difference, not a flag A missing column cannot be joined by accident. Files: `db/schema.sql`, `src/lib/surveys.ts` 2. Refuse the mode change and test the refusal This is the one irreversible decision in the product. Files: `src/lib/surveys.ts`, `tests/anonymity.test.ts` 3. Coarsen the anonymous timestamp Invitation time plus submission time is an identifier. Files: `src/lib/responses.ts` ### Done when - [ ] No participant reference exists on anonymous responses - [ ] A mode change after any response fails - [ ] Anonymous submission times are coarser than a second - [ ] The mode is stated to the respondent in words ### Watch out - Storing a nullable participant column and leaving it empty for anonymous surveys means the anonymity depends on every future query remembering to ignore it, and one CSV export written in a hurry six months later is enough to break the promise. Make the column absent, so a report that tries to join cannot even be written. ## Phase 2 · Questions that can change without rewriting history Editing a live survey never alters the meaning of answers already given. ### Steps 1. Version on meaning, not on every keystroke Otherwise the editor becomes unusable and people stop editing. Files: `src/lib/questions.ts` 2. Store the version with every answer It is the only thing that makes an old answer readable. Files: `src/lib/answers.ts`, `tests/versioning.test.ts` 3. Warn in the editor before a version bump The person editing is the only one who knows if the meaning changed. Files: `src/app/admin/questions/page.tsx` ### Done when - [ ] A meaning change versions the question - [ ] Old answers keep pointing at the old version - [ ] Reports show versions separately until merged - [ ] The editor warns before versioning a live question ### Watch out - Editing an option label in place from 'Weekly' to 'Weekly or more often' silently reinterprets every answer already collected under the old wording, and no one will ever know because the report shows a single tidy bar. Version the question, keep both labels, and make the report show them as separate series until a human merges them deliberately. ## Phase 3 · Branching that the browser cannot argue with Skip logic is evaluated on the server and a skipped question cannot be answered. ### Steps 1. Evaluate scope on the server on every page The client is a display, not an authority. Files: `src/lib/logic.ts`, `tests/logic.test.ts` 2. Refuse cycles at save time A respondent should never be the one who finds the loop. Files: `src/lib/rules.ts` 3. Clear answers that go out of scope on a back step Stale answers to unasked questions are the same corruption, arriving politely. Files: `src/lib/responses.ts` ### Done when - [ ] Out of scope answers are rejected and counted - [ ] A cyclic rule set fails at save time - [ ] Page order is decided only from stored answers - [ ] Going back clears newly out of scope answers ### Watch out - Evaluating skip logic in the browser and trusting the posted answers means anyone can submit answers to questions they were never shown, which quietly corrupts every cross-tabulation in the report and is undetectable afterwards. Evaluate on the server, discard out of scope answers with a recorded reason, and count the discards. ## Phase 4 · Quotas that close at the right moment A quota of one hundred ends with one hundred responses, not one hundred and four. ### Steps 1. Count and insert in one transaction Everything else about quotas is presentation. Files: `src/lib/quotas.ts`, `tests/quota-race.test.ts` 2. Make the screen-out action explicit Discarding a partial answer is a choice, so it should be one. Files: `src/lib/quotas.ts` 3. Show partial and complete separately Collapsing them is how people over-collect. Files: `src/app/admin/responses/page.tsx` ### Done when - [ ] Concurrent submissions never overshoot a quota - [ ] Screen-out behaviour follows the configured setting - [ ] Partial and complete counts are never summed - [ ] Closing stops new responses at once ### Watch out - Checking the quota before writing the response and then writing it leaves a window where four people submitting in the same second all pass a check that says ninety nine, and the overshoot is only visible weeks later in the data. Do the count and the insert in one transaction, and test it by firing concurrent submissions at a quota with one place left. ## Not in this build - Bulk invitation sending. - Panel and respondent marketplace integrations. - Weighting and significance testing. - Multi-administrator roles and approvals. ## After v1, if you want it - Randomised question and option order, recorded per response so the analysis can account for it - A read-only results link that shows aggregate counts and never a single response - Resumable responses by a link the respondent keeps, which is a different thing from an invitation token ===== .env.example ===== # Copy to .env and fill in. Never commit .env; this file documents it. # Required. Surveys, questions, responses and quotas. Back this up on a schedule. DATABASE_PATH=./data/surveys.db # Required. Respondent file uploads. Deleted with the responses at retention. UPLOAD_DIR=./data/uploads # Required · secret. The single administrator login. Generated by the included command. ADMIN_PASSWORD_HASH=argon2id$...redacted # Required. Used in invitation links and in the codebook header. PUBLIC_BASE_URL=https://surveys.example.org # Required. Applied to a survey that does not set its own. Shorter is a reasonable default. DEFAULT_RETENTION_DAYS=365 # Required. Per file. Uploads are the fastest way to fill a small disk. MAX_UPLOAD_BYTES=5242880
You are building a lean indie version of LimeSurvey Cloud. 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 ===== # LimeSurvey Cloud · indie build A self-hosted survey tool whose hard problems are making anonymity structural rather than promised, keeping branching logic honest when a question is edited after responses exist, and closing a quota at the right moment when several people are answering at once. Estimated effort: **weekend**. Work `BUILD_PLAN.md` top to bottom · every phase ends in a check that has to pass before the next one starts. ## Stack | Part | Choice | Why | | --- | --- | --- | | Runtime | Next.js with TypeScript, server rendered, one deployable | a public survey page must work with no client framework on a bad phone connection | | Storage | SQLite with responses in one table and answers keyed by question version | editing a live survey must not rewrite the meaning of answers already collected | | Anonymity | A per-survey mode set at creation, enforced by which columns exist | a promise enforced by application code is a promise until somebody writes a report | | Logic | Branching evaluated server side from a stored rule tree, never from client state | a respondent can edit anything the browser knows and skip logic is how quotas get gamed | | Quotas | Counted in a transaction at submission, with the close decided by the database | two people finishing at once is the normal case, not the edge case | ## 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 migrations. - Get it: nodejs.org or your version manager. - Verify: node --version prints v22 or higher - [ ] **Anonymous or identified, per survey, written down** · free - Why: It is the only irreversible decision in the build and it determines the schema you generate. - Get it: For each survey you plan to run, write down which mode it is and what you will tell respondents on the first page. - Verify: Every planned survey has a mode and a sentence of respondent-facing wording - [ ] **A host with TLS and a real domain** · a few pounds a month - Why: Respondents are typing answers about themselves into a page you sent them, and a certificate warning ends that immediately. - Get it: Any small instance with an automatic certificate, or a platform that provides one. - Verify: The public survey URL loads with a valid certificate on a phone - [ ] **A retention period per survey** · free - Why: Deciding how long you keep other people's answers after you have collected them means deciding it when deleting is inconvenient. - Get it: Choose a number of days per survey and put it in the configuration before the survey opens. - Verify: Every survey has a retention period set before it accepts a response - [ ] **Somewhere off the host to copy the database** (optional) · pennies per month - Why: Responses cannot be recollected, and a survey that lost half its answers is not a survey you can salvage. - Get it: Any object storage or a second machine with a scheduled copy. - Verify: A restore from the copy reproduces the response count ## Quick start Follow Phase 1 in `BUILD_PLAN.md`; it creates the project. Then copy `.env.example` to `.env` and fill in the values it documents. ## Honest limits This build deliberately does not replace: - Bulk invitation sending. - Panel and respondent marketplace integrations. - Weighting and significance testing. - Multi-administrator roles and approvals. - spam and abuse defense at scale - large integration and template catalog - compliance controls and guaranteed delivery - enterprise workflow logic If one of those is essential to you, that is the reason to keep paying for LimeSurvey Cloud, and the README should say so rather than pretend. ===== BRIEF.md ===== # Build brief · LimeSurvey Cloud 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 self-hosted survey tool where anonymity is a decision made before the first response and enforced by the schema to replace LimeSurvey Cloud. 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 Anonymity is not a checkbox you can tick later. It is a property of the schema, and the moment the first response lands you have either got it or you have not. LimeSurvey itself is blunt about this. Its manual states that with anonymised responses there is no way to connect answers and participants, that even the administrator cannot link response data to participant data, and that the response submission date and the participant completion date are both forced to a fixed placeholder value, specifically so the order of submissions cannot be lined up against invitations or web server logs. That last detail is the one people miss: an accurate timestamp on an anonymous response, beside an accurate invitation timestamp, is an identifier. So build it the same way. A survey declares anonymous or identified at creation. An anonymous survey stores no foreign key from a response to a participant, coarsens the submission timestamp to the day, and writes no address, agent string or request log tied to the response. Switching to identified is not supported; the only path is a new survey, and a test proves the attempt fails. And tell respondents which one they are answering, in words, on the first page. A survey that claims anonymity while keeping a row that could rejoin the two tables is worse than one that never made the claim. ### Stack (fixed, do not substitute) - Runtime: Next.js with TypeScript, server rendered, one deployable · a public survey page must work with no client framework on a bad phone connection - Storage: SQLite with responses in one table and answers keyed by question version · editing a live survey must not rewrite the meaning of answers already collected - Anonymity: A per-survey mode set at creation, enforced by which columns exist · a promise enforced by application code is a promise until somebody writes a report - Logic: Branching evaluated server side from a stored rule tree, never from client state · a respondent can edit anything the browser knows and skip logic is how quotas get gamed - Quotas: Counted in a transaction at submission, with the close decided by the database · two people finishing at once is the normal case, not the edge case ### Data model (create this before Phase 1) - surveys(id, title, mode, state, opens_at, closes_at, created_at) · mode is anonymous or identified and is immutable after the first response - questions(id, survey_id, code, kind, required, position, version, retired_at) · a meaningful edit makes a new version rather than mutating the row - options(id, question_id, question_version, value, label, position) - responses(id, survey_id, state, started_at, submitted_at_day, completed) · identified surveys add participant_id; anonymous surveys do not have the column - answers(response_id, question_id, question_version, value_json) · the version is what makes an old answer still readable - participants(id, survey_id, token_hash, invited_at, completed_at) · exists only for identified surveys - quotas(id, survey_id, name, limit_count, action, current_count) · counted in the same transaction that writes the response - rules(id, survey_id, question_id, question_version, condition_json, effect) · the branching tree, evaluated server side ### Phase 1 · Two kinds of survey, decided once Build: Create the schema so an anonymous survey's response table carries no participant reference and stores the submission date coarsened to the day. Identified surveys get a participant table with hashed tokens. Refuse a mode change once any response exists, at the database level as well as in the application. Show the mode to the respondent in plain words on the first page. Trap: Storing a nullable participant column and leaving it empty for anonymous surveys means the anonymity depends on every future query remembering to ignore it, and one CSV export written in a hurry six months later is enough to break the promise. Make the column absent, so a report that tries to join cannot even be written. Done when: An anonymous survey's response rows have no participant reference available to any query, verified by inspecting the schema. An attempt to change a survey's mode after a response exists fails, tested. The submission timestamp on an anonymous response is coarser than a second and no address or agent string is stored against it, checked in the database. The first page of every survey states in a sentence whether answers can be traced back. Do not build yet: No logic, quotas, exports or notifications yet. ### Phase 2 · Questions that can change without rewriting history Build: Version a question whenever its wording, type or options change in a way that alters meaning. Answers store the question version they were given under. Reordering or renaming for clarity is not a new version; changing an option's meaning is. Show the editor which questions already have answers and what a version bump will do to the report. Trap: Editing an option label in place from 'Weekly' to 'Weekly or more often' silently reinterprets every answer already collected under the old wording, and no one will ever know because the report shows a single tidy bar. Version the question, keep both labels, and make the report show them as separate series until a human merges them deliberately. Done when: Changing an option label on a question with existing answers creates a new version and leaves old answers pointing at the old one, tested. The report renders two series for two versions and never silently merges them. Reordering questions does not create a version, tested. The editor warns before any change that will version a question with existing answers. Do not build yet: No branching, quotas or export yet. ### Phase 3 · Branching that the browser cannot argue with Build: Store conditions as a rule tree against question codes and versions. On each page submission, evaluate server side which questions are now in scope and return the next page. Reject any answer for a question the server considers out of scope. Detect and refuse cycles when saving a rule, rather than when a respondent hits one. Trap: Evaluating skip logic in the browser and trusting the posted answers means anyone can submit answers to questions they were never shown, which quietly corrupts every cross-tabulation in the report and is undetectable afterwards. Evaluate on the server, discard out of scope answers with a recorded reason, and count the discards. Done when: An answer posted for an out of scope question is rejected and the rejection is counted, tested by posting directly. Saving a rule set containing a cycle fails with the cycle named, tested. The next page is decided server side from stored answers only. A respondent who goes back and changes an answer has the now out of scope answers cleared rather than retained. Do not build yet: No quotas, exports or invitations yet. ### Phase 4 · Quotas that close at the right moment Build: Count a quota inside the same transaction that writes the completed response, and decide there whether this response is the one that closes it. On closing, apply the configured action: reject with a message, mark the response as screened out, or close the survey. Show partial and complete counts separately in the admin view because they are not the same number. Trap: Checking the quota before writing the response and then writing it leaves a window where four people submitting in the same second all pass a check that says ninety nine, and the overshoot is only visible weeks later in the data. Do the count and the insert in one transaction, and test it by firing concurrent submissions at a quota with one place left. Done when: Concurrent submissions against a quota with one place remaining produce exactly one accepted response, tested with parallel requests. A screened out respondent sees the configured message and their partial data is kept or discarded according to the setting, tested both ways. Partial and complete counts are shown separately and never summed into one figure. Closing a survey stops new responses immediately and leaves in-flight ones able to finish or not, according to the setting. Do not build yet: No export, invitations or retention work yet. ### Phase 5 · Getting the data out and deleting it Build: Export responses as CSV and JSON with a codebook holding question versions, option labels and the survey mode, so the file is readable without the application. Enforce a retention period per survey that deletes responses and any uploads. Give every respondent in an identified survey a way to request their own data or its deletion, and record what you did. Trap: An export that flattens two versions of a question into one column and omits the labels produces a file that looks fine and is wrong, and it will be the file somebody quotes in a report. Ship a codebook beside every export naming every question version and option label, and refuse to export without it. Done when: Every export is accompanied by a codebook covering all question versions present, and exporting without it is not possible, checked by review. A retention period that has passed deletes the responses and their uploads, tested with a clock override. A deletion request on an identified survey removes the participant and their answers and leaves a record that it happened. Re-importing an export into an empty instance reproduces the same counts per question version. Do not build yet: Nothing after this. ### Out of scope (and why) - Email invitation sending at volume, which is a deliverability project rather than a feature and is better rented. - Panel integrations and respondent marketplaces, which are a commercial relationship rather than code. - Weighting and significance testing in the reporting view, because a statistic produced by a tool nobody validated is worse than no statistic. - Multi-administrator roles and approval workflows, which are the part of the paid product that exists for organisations rather than for surveys. ### README must contain - That a survey's anonymous or identified mode is chosen at creation and cannot be changed afterwards, and why - That anonymous responses store a coarsened submission date on purpose, because precise timings can be joined against invitations - That questions are versioned and every answer records the version it was given under - That skip logic is evaluated on the server and out of scope answers are rejected - Where uploads live, what the retention period is, and how a respondent asks for deletion ===== AGENTS.md ===== # Agent instructions · LimeSurvey Cloud indie build - Read `README.md` and `BUILD_PLAN.md` before writing code. The stack is fixed: Next.js with TypeScript, server rendered, one deployable, SQLite with responses in one table and answers keyed by question version, A per-survey mode set at creation, enforced by which columns exist, Branching evaluated server side from a stored rule tree, never from client state, Counted in a transaction at submission, with the close decided by the database. 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 - Storing a nullable participant column and leaving it empty for anonymous surveys means the anonymity depends on every future query remembering to ignore it, and one CSV export written in a hurry six months later is enough to break the promise. Make the column absent, so a report that tries to join cannot even be written. - Editing an option label in place from 'Weekly' to 'Weekly or more often' silently reinterprets every answer already collected under the old wording, and no one will ever know because the report shows a single tidy bar. Version the question, keep both labels, and make the report show them as separate series until a human merges them deliberately. - Evaluating skip logic in the browser and trusting the posted answers means anyone can submit answers to questions they were never shown, which quietly corrupts every cross-tabulation in the report and is undetectable afterwards. Evaluate on the server, discard out of scope answers with a recorded reason, and count the discards. - Checking the quota before writing the response and then writing it leaves a window where four people submitting in the same second all pass a check that says ninety nine, and the overshoot is only visible weeks later in the data. Do the count and the insert in one transaction, and test it by firing concurrent submissions at a quota with one place left. ===== BUILD_PLAN.md ===== # Build plan · LimeSurvey Cloud A self-hosted survey tool whose hard problems are making anonymity structural rather than promised, keeping branching logic honest when a question is edited after responses exist, and closing a quota at the right moment when several people are answering at once. Phases are in dependency order. Each ends in a "Done when" list; treat an unticked item as a blocker, not a note. ## Phase 1 · Two kinds of survey, decided once Creating a survey chooses anonymous or identified, and the storage differs accordingly. ### Steps 1. Make anonymity a schema difference, not a flag A missing column cannot be joined by accident. Files: `db/schema.sql`, `src/lib/surveys.ts` 2. Refuse the mode change and test the refusal This is the one irreversible decision in the product. Files: `src/lib/surveys.ts`, `tests/anonymity.test.ts` 3. Coarsen the anonymous timestamp Invitation time plus submission time is an identifier. Files: `src/lib/responses.ts` ### Done when - [ ] No participant reference exists on anonymous responses - [ ] A mode change after any response fails - [ ] Anonymous submission times are coarser than a second - [ ] The mode is stated to the respondent in words ### Watch out - Storing a nullable participant column and leaving it empty for anonymous surveys means the anonymity depends on every future query remembering to ignore it, and one CSV export written in a hurry six months later is enough to break the promise. Make the column absent, so a report that tries to join cannot even be written. ## Phase 2 · Questions that can change without rewriting history Editing a live survey never alters the meaning of answers already given. ### Steps 1. Version on meaning, not on every keystroke Otherwise the editor becomes unusable and people stop editing. Files: `src/lib/questions.ts` 2. Store the version with every answer It is the only thing that makes an old answer readable. Files: `src/lib/answers.ts`, `tests/versioning.test.ts` 3. Warn in the editor before a version bump The person editing is the only one who knows if the meaning changed. Files: `src/app/admin/questions/page.tsx` ### Done when - [ ] A meaning change versions the question - [ ] Old answers keep pointing at the old version - [ ] Reports show versions separately until merged - [ ] The editor warns before versioning a live question ### Watch out - Editing an option label in place from 'Weekly' to 'Weekly or more often' silently reinterprets every answer already collected under the old wording, and no one will ever know because the report shows a single tidy bar. Version the question, keep both labels, and make the report show them as separate series until a human merges them deliberately. ## Phase 3 · Branching that the browser cannot argue with Skip logic is evaluated on the server and a skipped question cannot be answered. ### Steps 1. Evaluate scope on the server on every page The client is a display, not an authority. Files: `src/lib/logic.ts`, `tests/logic.test.ts` 2. Refuse cycles at save time A respondent should never be the one who finds the loop. Files: `src/lib/rules.ts` 3. Clear answers that go out of scope on a back step Stale answers to unasked questions are the same corruption, arriving politely. Files: `src/lib/responses.ts` ### Done when - [ ] Out of scope answers are rejected and counted - [ ] A cyclic rule set fails at save time - [ ] Page order is decided only from stored answers - [ ] Going back clears newly out of scope answers ### Watch out - Evaluating skip logic in the browser and trusting the posted answers means anyone can submit answers to questions they were never shown, which quietly corrupts every cross-tabulation in the report and is undetectable afterwards. Evaluate on the server, discard out of scope answers with a recorded reason, and count the discards. ## Phase 4 · Quotas that close at the right moment A quota of one hundred ends with one hundred responses, not one hundred and four. ### Steps 1. Count and insert in one transaction Everything else about quotas is presentation. Files: `src/lib/quotas.ts`, `tests/quota-race.test.ts` 2. Make the screen-out action explicit Discarding a partial answer is a choice, so it should be one. Files: `src/lib/quotas.ts` 3. Show partial and complete separately Collapsing them is how people over-collect. Files: `src/app/admin/responses/page.tsx` ### Done when - [ ] Concurrent submissions never overshoot a quota - [ ] Screen-out behaviour follows the configured setting - [ ] Partial and complete counts are never summed - [ ] Closing stops new responses at once ### Watch out - Checking the quota before writing the response and then writing it leaves a window where four people submitting in the same second all pass a check that says ninety nine, and the overshoot is only visible weeks later in the data. Do the count and the insert in one transaction, and test it by firing concurrent submissions at a quota with one place left. ## Not in this build - Bulk invitation sending. - Panel and respondent marketplace integrations. - Weighting and significance testing. - Multi-administrator roles and approvals. ## After v1, if you want it - Randomised question and option order, recorded per response so the analysis can account for it - A read-only results link that shows aggregate counts and never a single response - Resumable responses by a link the respondent keeps, which is a different thing from an invitation token ===== .env.example ===== # Copy to .env and fill in. Never commit .env; this file documents it. # Required. Surveys, questions, responses and quotas. Back this up on a schedule. DATABASE_PATH=./data/surveys.db # Required. Respondent file uploads. Deleted with the responses at retention. UPLOAD_DIR=./data/uploads # Required · secret. The single administrator login. Generated by the included command. ADMIN_PASSWORD_HASH=argon2id$...redacted # Required. Used in invitation links and in the codebook header. PUBLIC_BASE_URL=https://surveys.example.org # Required. Applied to a survey that does not set its own. Shorter is a reasonable default. DEFAULT_RETENTION_DAYS=365 # Required. Per file. Uploads are the fastest way to fill a small disk. MAX_UPLOAD_BYTES=5242880
You are building a production product version of LimeSurvey Cloud. 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 ===== # LimeSurvey Cloud · product brief ## Problem The visible survey platform loop is buildable, but a credible replacement needs more than the first screen. LimeSurvey Cloud 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 anonymity is enforced by which columns exist rather than by a promise, a live survey can be edited without rewriting what past respondents meant, a quota of one hundred closes at one hundred, and every export carries the codebook that makes it readable. ## Target user A builder who needs a maintainable product foundation, not a one-off demo. ## Required capabilities - Node.js 22 - deploy target - SMTP or transactional email key in .env ## Explicit non-goals for v1 - Bulk invitation sending. - Panel and respondent marketplace integrations. - Weighting and significance testing. - Multi-administrator roles and approvals. - spam and abuse defense at scale - large integration and template catalog - compliance controls and guaranteed delivery - enterprise workflow logic ## Success criteria - An anonymous survey is confirmed to have no participant reference in its schema - A mode change after the first response is confirmed to fail - An out of scope answer posted directly is confirmed rejected - Concurrent submissions are confirmed not to overshoot a quota - No export is confirmed possible without its codebook ===== BRIEF.md ===== # Build brief · LimeSurvey Cloud 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 self-hosted survey tool where anonymity is a decision made before the first response and enforced by the schema to replace LimeSurvey Cloud. 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 Anonymity is not a checkbox you can tick later. It is a property of the schema, and the moment the first response lands you have either got it or you have not. LimeSurvey itself is blunt about this. Its manual states that with anonymised responses there is no way to connect answers and participants, that even the administrator cannot link response data to participant data, and that the response submission date and the participant completion date are both forced to a fixed placeholder value, specifically so the order of submissions cannot be lined up against invitations or web server logs. That last detail is the one people miss: an accurate timestamp on an anonymous response, beside an accurate invitation timestamp, is an identifier. So build it the same way. A survey declares anonymous or identified at creation. An anonymous survey stores no foreign key from a response to a participant, coarsens the submission timestamp to the day, and writes no address, agent string or request log tied to the response. Switching to identified is not supported; the only path is a new survey, and a test proves the attempt fails. And tell respondents which one they are answering, in words, on the first page. A survey that claims anonymity while keeping a row that could rejoin the two tables is worse than one that never made the claim. ### Stack (fixed, do not substitute) - Runtime: Next.js with TypeScript, server rendered, one deployable · a public survey page must work with no client framework on a bad phone connection - Storage: SQLite with responses in one table and answers keyed by question version · editing a live survey must not rewrite the meaning of answers already collected - Anonymity: A per-survey mode set at creation, enforced by which columns exist · a promise enforced by application code is a promise until somebody writes a report - Logic: Branching evaluated server side from a stored rule tree, never from client state · a respondent can edit anything the browser knows and skip logic is how quotas get gamed - Quotas: Counted in a transaction at submission, with the close decided by the database · two people finishing at once is the normal case, not the edge case ### Data model (create this before Phase 1) - surveys(id, title, mode, state, opens_at, closes_at, created_at) · mode is anonymous or identified and is immutable after the first response - questions(id, survey_id, code, kind, required, position, version, retired_at) · a meaningful edit makes a new version rather than mutating the row - options(id, question_id, question_version, value, label, position) - responses(id, survey_id, state, started_at, submitted_at_day, completed) · identified surveys add participant_id; anonymous surveys do not have the column - answers(response_id, question_id, question_version, value_json) · the version is what makes an old answer still readable - participants(id, survey_id, token_hash, invited_at, completed_at) · exists only for identified surveys - quotas(id, survey_id, name, limit_count, action, current_count) · counted in the same transaction that writes the response - rules(id, survey_id, question_id, question_version, condition_json, effect) · the branching tree, evaluated server side ### Phase 1 · Two kinds of survey, decided once Build: Create the schema so an anonymous survey's response table carries no participant reference and stores the submission date coarsened to the day. Identified surveys get a participant table with hashed tokens. Refuse a mode change once any response exists, at the database level as well as in the application. Show the mode to the respondent in plain words on the first page. Trap: Storing a nullable participant column and leaving it empty for anonymous surveys means the anonymity depends on every future query remembering to ignore it, and one CSV export written in a hurry six months later is enough to break the promise. Make the column absent, so a report that tries to join cannot even be written. Done when: An anonymous survey's response rows have no participant reference available to any query, verified by inspecting the schema. An attempt to change a survey's mode after a response exists fails, tested. The submission timestamp on an anonymous response is coarser than a second and no address or agent string is stored against it, checked in the database. The first page of every survey states in a sentence whether answers can be traced back. Do not build yet: No logic, quotas, exports or notifications yet. ### Phase 2 · Questions that can change without rewriting history Build: Version a question whenever its wording, type or options change in a way that alters meaning. Answers store the question version they were given under. Reordering or renaming for clarity is not a new version; changing an option's meaning is. Show the editor which questions already have answers and what a version bump will do to the report. Trap: Editing an option label in place from 'Weekly' to 'Weekly or more often' silently reinterprets every answer already collected under the old wording, and no one will ever know because the report shows a single tidy bar. Version the question, keep both labels, and make the report show them as separate series until a human merges them deliberately. Done when: Changing an option label on a question with existing answers creates a new version and leaves old answers pointing at the old one, tested. The report renders two series for two versions and never silently merges them. Reordering questions does not create a version, tested. The editor warns before any change that will version a question with existing answers. Do not build yet: No branching, quotas or export yet. ### Phase 3 · Branching that the browser cannot argue with Build: Store conditions as a rule tree against question codes and versions. On each page submission, evaluate server side which questions are now in scope and return the next page. Reject any answer for a question the server considers out of scope. Detect and refuse cycles when saving a rule, rather than when a respondent hits one. Trap: Evaluating skip logic in the browser and trusting the posted answers means anyone can submit answers to questions they were never shown, which quietly corrupts every cross-tabulation in the report and is undetectable afterwards. Evaluate on the server, discard out of scope answers with a recorded reason, and count the discards. Done when: An answer posted for an out of scope question is rejected and the rejection is counted, tested by posting directly. Saving a rule set containing a cycle fails with the cycle named, tested. The next page is decided server side from stored answers only. A respondent who goes back and changes an answer has the now out of scope answers cleared rather than retained. Do not build yet: No quotas, exports or invitations yet. ### Phase 4 · Quotas that close at the right moment Build: Count a quota inside the same transaction that writes the completed response, and decide there whether this response is the one that closes it. On closing, apply the configured action: reject with a message, mark the response as screened out, or close the survey. Show partial and complete counts separately in the admin view because they are not the same number. Trap: Checking the quota before writing the response and then writing it leaves a window where four people submitting in the same second all pass a check that says ninety nine, and the overshoot is only visible weeks later in the data. Do the count and the insert in one transaction, and test it by firing concurrent submissions at a quota with one place left. Done when: Concurrent submissions against a quota with one place remaining produce exactly one accepted response, tested with parallel requests. A screened out respondent sees the configured message and their partial data is kept or discarded according to the setting, tested both ways. Partial and complete counts are shown separately and never summed into one figure. Closing a survey stops new responses immediately and leaves in-flight ones able to finish or not, according to the setting. Do not build yet: No export, invitations or retention work yet. ### Phase 5 · Getting the data out and deleting it Build: Export responses as CSV and JSON with a codebook holding question versions, option labels and the survey mode, so the file is readable without the application. Enforce a retention period per survey that deletes responses and any uploads. Give every respondent in an identified survey a way to request their own data or its deletion, and record what you did. Trap: An export that flattens two versions of a question into one column and omits the labels produces a file that looks fine and is wrong, and it will be the file somebody quotes in a report. Ship a codebook beside every export naming every question version and option label, and refuse to export without it. Done when: Every export is accompanied by a codebook covering all question versions present, and exporting without it is not possible, checked by review. A retention period that has passed deletes the responses and their uploads, tested with a clock override. A deletion request on an identified survey removes the participant and their answers and leaves a record that it happened. Re-importing an export into an empty instance reproduces the same counts per question version. Do not build yet: Nothing after this. ### Out of scope (and why) - Email invitation sending at volume, which is a deliverability project rather than a feature and is better rented. - Panel integrations and respondent marketplaces, which are a commercial relationship rather than code. - Weighting and significance testing in the reporting view, because a statistic produced by a tool nobody validated is worse than no statistic. - Multi-administrator roles and approval workflows, which are the part of the paid product that exists for organisations rather than for surveys. ### README must contain - That a survey's anonymous or identified mode is chosen at creation and cannot be changed afterwards, and why - That anonymous responses store a coarsened submission date on purpose, because precise timings can be joined against invitations - That questions are versioned and every answer records the version it was given under - That skip logic is evaluated on the server and out of scope answers are rejected - Where uploads live, what the retention period is, and how a respondent asks for deletion ===== ARCHITECTURE.md ===== # Architecture · LimeSurvey Cloud ## Stack | Part | Choice | Why | | --- | --- | --- | | Runtime | Next.js with TypeScript, server rendered, one deployable | a public survey page must work with no client framework on a bad phone connection | | Storage | SQLite with responses in one table and answers keyed by question version | editing a live survey must not rewrite the meaning of answers already collected | | Anonymity | A per-survey mode set at creation, enforced by which columns exist | a promise enforced by application code is a promise until somebody writes a report | | Logic | Branching evaluated server side from a stored rule tree, never from client state | a respondent can edit anything the browser knows and skip logic is how quotas get gamed | | Quotas | Counted in a transaction at submission, with the close decided by the database | two people finishing at once is the normal case, not the edge case | ## Modules Each module has one owner concern and a documented way to replace it. | Module | Owns | How to replace it | | --- | --- | --- | | Surveys | Mode, state, and the immutability of the mode. | Not swappable for a nullable flag, which is the same as no anonymity. | | Questions | Versioning and the editor's warnings. | Any editor, provided answers keep a version reference. | | Logic | Server side scope evaluation and cycle detection. | Any rule format, provided evaluation stays on the server. | | Quotas | Transactional counting and the close action. | Any store with real transactions. Not an application-level check. | | Export | CSV, JSON and the codebook. | Any format, provided the codebook goes with it. | ## Configuration Every runtime setting is an environment variable documented in `.env.example`, validated at startup, with a safe local default wherever one exists. - `DATABASE_PATH` · required · Surveys, questions, responses and quotas. Back this up on a schedule. - `UPLOAD_DIR` · required · Respondent file uploads. Deleted with the responses at retention. - `ADMIN_PASSWORD_HASH` · required, secret · The single administrator login. Generated by the included command. - `PUBLIC_BASE_URL` · required · Used in invitation links and in the codebook header. - `DEFAULT_RETENTION_DAYS` · required · Applied to a survey that does not set its own. Shorter is a reasonable default. - `MAX_UPLOAD_BYTES` · required · Per file. Uploads are the fastest way to fill a small disk. ## 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 · LimeSurvey Cloud product build - Read `PRODUCT.md` and `ARCHITECTURE.md` before changing code. The stack is fixed: Next.js with TypeScript, server rendered, one deployable, SQLite with responses in one table and answers keyed by question version, A per-survey mode set at creation, enforced by which columns exist, Branching evaluated server side from a stored rule tree, never from client state, Counted in a transaction at submission, with the close decided by the database. - 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 - Storing a nullable participant column and leaving it empty for anonymous surveys means the anonymity depends on every future query remembering to ignore it, and one CSV export written in a hurry six months later is enough to break the promise. Make the column absent, so a report that tries to join cannot even be written. - Editing an option label in place from 'Weekly' to 'Weekly or more often' silently reinterprets every answer already collected under the old wording, and no one will ever know because the report shows a single tidy bar. Version the question, keep both labels, and make the report show them as separate series until a human merges them deliberately. - Evaluating skip logic in the browser and trusting the posted answers means anyone can submit answers to questions they were never shown, which quietly corrupts every cross-tabulation in the report and is undetectable afterwards. Evaluate on the server, discard out of scope answers with a recorded reason, and count the discards. - Checking the quota before writing the response and then writing it leaves a window where four people submitting in the same second all pass a check that says ninety nine, and the overshoot is only visible weeks later in the data. Do the count and the insert in one transaction, and test it by firing concurrent submissions at a quota with one place left. - An export that flattens two versions of a question into one column and omits the labels produces a file that looks fine and is wrong, and it will be the file somebody quotes in a report. Ship a codebook beside every export naming every question version and option label, and refuse to export without it. ===== MILESTONES.md ===== # Delivery milestones · LimeSurvey Cloud Estimated effort: **weekend** for the indie phases; the production-only milestones add the trust and operability layer. ## M1 · Two kinds of survey, decided once Creating a survey chooses anonymous or identified, and the storage differs accordingly. ### Steps 1. Make anonymity a schema difference, not a flag A missing column cannot be joined by accident. Files: `db/schema.sql`, `src/lib/surveys.ts` 2. Refuse the mode change and test the refusal This is the one irreversible decision in the product. Files: `src/lib/surveys.ts`, `tests/anonymity.test.ts` 3. Coarsen the anonymous timestamp Invitation time plus submission time is an identifier. Files: `src/lib/responses.ts` ### Done when - [ ] No participant reference exists on anonymous responses - [ ] A mode change after any response fails - [ ] Anonymous submission times are coarser than a second - [ ] The mode is stated to the respondent in words ### Watch out - Storing a nullable participant column and leaving it empty for anonymous surveys means the anonymity depends on every future query remembering to ignore it, and one CSV export written in a hurry six months later is enough to break the promise. Make the column absent, so a report that tries to join cannot even be written. ## M2 · Questions that can change without rewriting history Editing a live survey never alters the meaning of answers already given. ### Steps 1. Version on meaning, not on every keystroke Otherwise the editor becomes unusable and people stop editing. Files: `src/lib/questions.ts` 2. Store the version with every answer It is the only thing that makes an old answer readable. Files: `src/lib/answers.ts`, `tests/versioning.test.ts` 3. Warn in the editor before a version bump The person editing is the only one who knows if the meaning changed. Files: `src/app/admin/questions/page.tsx` ### Done when - [ ] A meaning change versions the question - [ ] Old answers keep pointing at the old version - [ ] Reports show versions separately until merged - [ ] The editor warns before versioning a live question ### Watch out - Editing an option label in place from 'Weekly' to 'Weekly or more often' silently reinterprets every answer already collected under the old wording, and no one will ever know because the report shows a single tidy bar. Version the question, keep both labels, and make the report show them as separate series until a human merges them deliberately. ## M3 · Branching that the browser cannot argue with Skip logic is evaluated on the server and a skipped question cannot be answered. ### Steps 1. Evaluate scope on the server on every page The client is a display, not an authority. Files: `src/lib/logic.ts`, `tests/logic.test.ts` 2. Refuse cycles at save time A respondent should never be the one who finds the loop. Files: `src/lib/rules.ts` 3. Clear answers that go out of scope on a back step Stale answers to unasked questions are the same corruption, arriving politely. Files: `src/lib/responses.ts` ### Done when - [ ] Out of scope answers are rejected and counted - [ ] A cyclic rule set fails at save time - [ ] Page order is decided only from stored answers - [ ] Going back clears newly out of scope answers ### Watch out - Evaluating skip logic in the browser and trusting the posted answers means anyone can submit answers to questions they were never shown, which quietly corrupts every cross-tabulation in the report and is undetectable afterwards. Evaluate on the server, discard out of scope answers with a recorded reason, and count the discards. ## M4 · Quotas that close at the right moment A quota of one hundred ends with one hundred responses, not one hundred and four. ### Steps 1. Count and insert in one transaction Everything else about quotas is presentation. Files: `src/lib/quotas.ts`, `tests/quota-race.test.ts` 2. Make the screen-out action explicit Discarding a partial answer is a choice, so it should be one. Files: `src/lib/quotas.ts` 3. Show partial and complete separately Collapsing them is how people over-collect. Files: `src/app/admin/responses/page.tsx` ### Done when - [ ] Concurrent submissions never overshoot a quota - [ ] Screen-out behaviour follows the configured setting - [ ] Partial and complete counts are never summed - [ ] Closing stops new responses at once ### Watch out - Checking the quota before writing the response and then writing it leaves a window where four people submitting in the same second all pass a check that says ninety nine, and the overshoot is only visible weeks later in the data. Do the count and the insert in one transaction, and test it by firing concurrent submissions at a quota with one place left. ## M5 · Getting the data out and deleting it (production only) Exports carry their own meaning, and a retention date actually removes data. ### Steps 1. Ship a codebook with every export A CSV of option indexes with no labels is not data. Files: `src/lib/export.ts`, `tests/export.test.ts` 2. Make retention delete uploads too The file somebody attached is the most identifying thing in the survey. Files: `src/lib/retention.ts` 3. Record every deletion request and its outcome You will be asked to show that you did it. Files: `src/lib/requests.ts` ### Done when - [ ] No export is possible without a codebook - [ ] Retention deletes responses and uploads - [ ] Deletion requests leave a record of the action - [ ] An export round-trips into an empty instance ### Watch out - An export that flattens two versions of a question into one column and omits the labels produces a file that looks fine and is wrong, and it will be the file somebody quotes in a report. Ship a codebook beside every export naming every question version and option label, and refuse to export without it. ===== OPERATIONS.md ===== # Operations · LimeSurvey Cloud ## Backup The database and the upload directory, on a schedule, off the host. Responses are the one thing here that cannot be regenerated: a lost survey is a survey you have to run again, and the second run will not give the same answers. ## Restore Restore the database and the uploads together, then confirm response counts per survey and per question version. A restore that brings back responses without uploads leaves file questions pointing at nothing, so check both. Do a restore drill before the first real user, and write the date here when it passes. ## Monitoring Completion rate and drop-off page per survey, out of scope answer rejections, quota overshoot which should always be zero, and disk used by uploads. A rising rejection count usually means a rule was edited while people were mid-survey. ## Incident checklist The incidents here are a survey that promised anonymity and did not deliver it, and a quota that overshot. The first is answered by the schema and the second by the transaction test, which is why both are gates rather than nice to have. 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 anonymous survey is confirmed to have no participant reference in its schema - [ ] A mode change after the first response is confirmed to fail - [ ] An out of scope answer posted directly is confirmed rejected - [ ] Concurrent submissions are confirmed not to overshoot a quota - [ ] No export is confirmed possible without its codebook ## Launch constraint Do not market omitted LimeSurvey Cloud capabilities as implemented. The non-goals in `PRODUCT.md` remain user-visible limitations until they are deliberately delivered. ===== .env.example ===== # Copy to .env and fill in. Never commit .env; this file documents it. # Required. Surveys, questions, responses and quotas. Back this up on a schedule. DATABASE_PATH=./data/surveys.db # Required. Respondent file uploads. Deleted with the responses at retention. UPLOAD_DIR=./data/uploads # Required · secret. The single administrator login. Generated by the included command. ADMIN_PASSWORD_HASH=argon2id$...redacted # Required. Used in invitation links and in the codebook header. PUBLIC_BASE_URL=https://surveys.example.org # Required. Applied to a survey that does not set its own. Shorter is a reasonable default. DEFAULT_RETENTION_DAYS=365 # Required. Per file. Uploads are the fastest way to fill a small disk. MAX_UPLOAD_BYTES=5242880
# LimeSurvey Cloud · indie build A self-hosted survey tool whose hard problems are making anonymity structural rather than promised, keeping branching logic honest when a question is edited after responses exist, and closing a quota at the right moment when several people are answering at once. Estimated effort: **weekend**. Work `BUILD_PLAN.md` top to bottom · every phase ends in a check that has to pass before the next one starts. ## Stack | Part | Choice | Why | | --- | --- | --- | | Runtime | Next.js with TypeScript, server rendered, one deployable | a public survey page must work with no client framework on a bad phone connection | | Storage | SQLite with responses in one table and answers keyed by question version | editing a live survey must not rewrite the meaning of answers already collected | | Anonymity | A per-survey mode set at creation, enforced by which columns exist | a promise enforced by application code is a promise until somebody writes a report | | Logic | Branching evaluated server side from a stored rule tree, never from client state | a respondent can edit anything the browser knows and skip logic is how quotas get gamed | | Quotas | Counted in a transaction at submission, with the close decided by the database | two people finishing at once is the normal case, not the edge case | ## 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 migrations. - Get it: nodejs.org or your version manager. - Verify: node --version prints v22 or higher - [ ] **Anonymous or identified, per survey, written down** · free - Why: It is the only irreversible decision in the build and it determines the schema you generate. - Get it: For each survey you plan to run, write down which mode it is and what you will tell respondents on the first page. - Verify: Every planned survey has a mode and a sentence of respondent-facing wording - [ ] **A host with TLS and a real domain** · a few pounds a month - Why: Respondents are typing answers about themselves into a page you sent them, and a certificate warning ends that immediately. - Get it: Any small instance with an automatic certificate, or a platform that provides one. - Verify: The public survey URL loads with a valid certificate on a phone - [ ] **A retention period per survey** · free - Why: Deciding how long you keep other people's answers after you have collected them means deciding it when deleting is inconvenient. - Get it: Choose a number of days per survey and put it in the configuration before the survey opens. - Verify: Every survey has a retention period set before it accepts a response - [ ] **Somewhere off the host to copy the database** (optional) · pennies per month - Why: Responses cannot be recollected, and a survey that lost half its answers is not a survey you can salvage. - Get it: Any object storage or a second machine with a scheduled copy. - Verify: A restore from the copy reproduces the response count ## Quick start Follow Phase 1 in `BUILD_PLAN.md`; it creates the project. Then copy `.env.example` to `.env` and fill in the values it documents. ## Honest limits This build deliberately does not replace: - Bulk invitation sending. - Panel and respondent marketplace integrations. - Weighting and significance testing. - Multi-administrator roles and approvals. - spam and abuse defense at scale - large integration and template catalog - compliance controls and guaranteed delivery - enterprise workflow logic If one of those is essential to you, that is the reason to keep paying for LimeSurvey Cloud, and the README should say so rather than pretend.
# Build brief · LimeSurvey Cloud 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 self-hosted survey tool where anonymity is a decision made before the first response and enforced by the schema to replace LimeSurvey Cloud. 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 Anonymity is not a checkbox you can tick later. It is a property of the schema, and the moment the first response lands you have either got it or you have not. LimeSurvey itself is blunt about this. Its manual states that with anonymised responses there is no way to connect answers and participants, that even the administrator cannot link response data to participant data, and that the response submission date and the participant completion date are both forced to a fixed placeholder value, specifically so the order of submissions cannot be lined up against invitations or web server logs. That last detail is the one people miss: an accurate timestamp on an anonymous response, beside an accurate invitation timestamp, is an identifier. So build it the same way. A survey declares anonymous or identified at creation. An anonymous survey stores no foreign key from a response to a participant, coarsens the submission timestamp to the day, and writes no address, agent string or request log tied to the response. Switching to identified is not supported; the only path is a new survey, and a test proves the attempt fails. And tell respondents which one they are answering, in words, on the first page. A survey that claims anonymity while keeping a row that could rejoin the two tables is worse than one that never made the claim. ### Stack (fixed, do not substitute) - Runtime: Next.js with TypeScript, server rendered, one deployable · a public survey page must work with no client framework on a bad phone connection - Storage: SQLite with responses in one table and answers keyed by question version · editing a live survey must not rewrite the meaning of answers already collected - Anonymity: A per-survey mode set at creation, enforced by which columns exist · a promise enforced by application code is a promise until somebody writes a report - Logic: Branching evaluated server side from a stored rule tree, never from client state · a respondent can edit anything the browser knows and skip logic is how quotas get gamed - Quotas: Counted in a transaction at submission, with the close decided by the database · two people finishing at once is the normal case, not the edge case ### Data model (create this before Phase 1) - surveys(id, title, mode, state, opens_at, closes_at, created_at) · mode is anonymous or identified and is immutable after the first response - questions(id, survey_id, code, kind, required, position, version, retired_at) · a meaningful edit makes a new version rather than mutating the row - options(id, question_id, question_version, value, label, position) - responses(id, survey_id, state, started_at, submitted_at_day, completed) · identified surveys add participant_id; anonymous surveys do not have the column - answers(response_id, question_id, question_version, value_json) · the version is what makes an old answer still readable - participants(id, survey_id, token_hash, invited_at, completed_at) · exists only for identified surveys - quotas(id, survey_id, name, limit_count, action, current_count) · counted in the same transaction that writes the response - rules(id, survey_id, question_id, question_version, condition_json, effect) · the branching tree, evaluated server side ### Phase 1 · Two kinds of survey, decided once Build: Create the schema so an anonymous survey's response table carries no participant reference and stores the submission date coarsened to the day. Identified surveys get a participant table with hashed tokens. Refuse a mode change once any response exists, at the database level as well as in the application. Show the mode to the respondent in plain words on the first page. Trap: Storing a nullable participant column and leaving it empty for anonymous surveys means the anonymity depends on every future query remembering to ignore it, and one CSV export written in a hurry six months later is enough to break the promise. Make the column absent, so a report that tries to join cannot even be written. Done when: An anonymous survey's response rows have no participant reference available to any query, verified by inspecting the schema. An attempt to change a survey's mode after a response exists fails, tested. The submission timestamp on an anonymous response is coarser than a second and no address or agent string is stored against it, checked in the database. The first page of every survey states in a sentence whether answers can be traced back. Do not build yet: No logic, quotas, exports or notifications yet. ### Phase 2 · Questions that can change without rewriting history Build: Version a question whenever its wording, type or options change in a way that alters meaning. Answers store the question version they were given under. Reordering or renaming for clarity is not a new version; changing an option's meaning is. Show the editor which questions already have answers and what a version bump will do to the report. Trap: Editing an option label in place from 'Weekly' to 'Weekly or more often' silently reinterprets every answer already collected under the old wording, and no one will ever know because the report shows a single tidy bar. Version the question, keep both labels, and make the report show them as separate series until a human merges them deliberately. Done when: Changing an option label on a question with existing answers creates a new version and leaves old answers pointing at the old one, tested. The report renders two series for two versions and never silently merges them. Reordering questions does not create a version, tested. The editor warns before any change that will version a question with existing answers. Do not build yet: No branching, quotas or export yet. ### Phase 3 · Branching that the browser cannot argue with Build: Store conditions as a rule tree against question codes and versions. On each page submission, evaluate server side which questions are now in scope and return the next page. Reject any answer for a question the server considers out of scope. Detect and refuse cycles when saving a rule, rather than when a respondent hits one. Trap: Evaluating skip logic in the browser and trusting the posted answers means anyone can submit answers to questions they were never shown, which quietly corrupts every cross-tabulation in the report and is undetectable afterwards. Evaluate on the server, discard out of scope answers with a recorded reason, and count the discards. Done when: An answer posted for an out of scope question is rejected and the rejection is counted, tested by posting directly. Saving a rule set containing a cycle fails with the cycle named, tested. The next page is decided server side from stored answers only. A respondent who goes back and changes an answer has the now out of scope answers cleared rather than retained. Do not build yet: No quotas, exports or invitations yet. ### Phase 4 · Quotas that close at the right moment Build: Count a quota inside the same transaction that writes the completed response, and decide there whether this response is the one that closes it. On closing, apply the configured action: reject with a message, mark the response as screened out, or close the survey. Show partial and complete counts separately in the admin view because they are not the same number. Trap: Checking the quota before writing the response and then writing it leaves a window where four people submitting in the same second all pass a check that says ninety nine, and the overshoot is only visible weeks later in the data. Do the count and the insert in one transaction, and test it by firing concurrent submissions at a quota with one place left. Done when: Concurrent submissions against a quota with one place remaining produce exactly one accepted response, tested with parallel requests. A screened out respondent sees the configured message and their partial data is kept or discarded according to the setting, tested both ways. Partial and complete counts are shown separately and never summed into one figure. Closing a survey stops new responses immediately and leaves in-flight ones able to finish or not, according to the setting. Do not build yet: No export, invitations or retention work yet. ### Phase 5 · Getting the data out and deleting it Build: Export responses as CSV and JSON with a codebook holding question versions, option labels and the survey mode, so the file is readable without the application. Enforce a retention period per survey that deletes responses and any uploads. Give every respondent in an identified survey a way to request their own data or its deletion, and record what you did. Trap: An export that flattens two versions of a question into one column and omits the labels produces a file that looks fine and is wrong, and it will be the file somebody quotes in a report. Ship a codebook beside every export naming every question version and option label, and refuse to export without it. Done when: Every export is accompanied by a codebook covering all question versions present, and exporting without it is not possible, checked by review. A retention period that has passed deletes the responses and their uploads, tested with a clock override. A deletion request on an identified survey removes the participant and their answers and leaves a record that it happened. Re-importing an export into an empty instance reproduces the same counts per question version. Do not build yet: Nothing after this. ### Out of scope (and why) - Email invitation sending at volume, which is a deliverability project rather than a feature and is better rented. - Panel integrations and respondent marketplaces, which are a commercial relationship rather than code. - Weighting and significance testing in the reporting view, because a statistic produced by a tool nobody validated is worse than no statistic. - Multi-administrator roles and approval workflows, which are the part of the paid product that exists for organisations rather than for surveys. ### README must contain - That a survey's anonymous or identified mode is chosen at creation and cannot be changed afterwards, and why - That anonymous responses store a coarsened submission date on purpose, because precise timings can be joined against invitations - That questions are versioned and every answer records the version it was given under - That skip logic is evaluated on the server and out of scope answers are rejected - Where uploads live, what the retention period is, and how a respondent asks for deletion
# Agent instructions · LimeSurvey Cloud indie build - Read `README.md` and `BUILD_PLAN.md` before writing code. The stack is fixed: Next.js with TypeScript, server rendered, one deployable, SQLite with responses in one table and answers keyed by question version, A per-survey mode set at creation, enforced by which columns exist, Branching evaluated server side from a stored rule tree, never from client state, Counted in a transaction at submission, with the close decided by the database. 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 - Storing a nullable participant column and leaving it empty for anonymous surveys means the anonymity depends on every future query remembering to ignore it, and one CSV export written in a hurry six months later is enough to break the promise. Make the column absent, so a report that tries to join cannot even be written. - Editing an option label in place from 'Weekly' to 'Weekly or more often' silently reinterprets every answer already collected under the old wording, and no one will ever know because the report shows a single tidy bar. Version the question, keep both labels, and make the report show them as separate series until a human merges them deliberately. - Evaluating skip logic in the browser and trusting the posted answers means anyone can submit answers to questions they were never shown, which quietly corrupts every cross-tabulation in the report and is undetectable afterwards. Evaluate on the server, discard out of scope answers with a recorded reason, and count the discards. - Checking the quota before writing the response and then writing it leaves a window where four people submitting in the same second all pass a check that says ninety nine, and the overshoot is only visible weeks later in the data. Do the count and the insert in one transaction, and test it by firing concurrent submissions at a quota with one place left.
# Build plan · LimeSurvey Cloud A self-hosted survey tool whose hard problems are making anonymity structural rather than promised, keeping branching logic honest when a question is edited after responses exist, and closing a quota at the right moment when several people are answering at once. Phases are in dependency order. Each ends in a "Done when" list; treat an unticked item as a blocker, not a note. ## Phase 1 · Two kinds of survey, decided once Creating a survey chooses anonymous or identified, and the storage differs accordingly. ### Steps 1. Make anonymity a schema difference, not a flag A missing column cannot be joined by accident. Files: `db/schema.sql`, `src/lib/surveys.ts` 2. Refuse the mode change and test the refusal This is the one irreversible decision in the product. Files: `src/lib/surveys.ts`, `tests/anonymity.test.ts` 3. Coarsen the anonymous timestamp Invitation time plus submission time is an identifier. Files: `src/lib/responses.ts` ### Done when - [ ] No participant reference exists on anonymous responses - [ ] A mode change after any response fails - [ ] Anonymous submission times are coarser than a second - [ ] The mode is stated to the respondent in words ### Watch out - Storing a nullable participant column and leaving it empty for anonymous surveys means the anonymity depends on every future query remembering to ignore it, and one CSV export written in a hurry six months later is enough to break the promise. Make the column absent, so a report that tries to join cannot even be written. ## Phase 2 · Questions that can change without rewriting history Editing a live survey never alters the meaning of answers already given. ### Steps 1. Version on meaning, not on every keystroke Otherwise the editor becomes unusable and people stop editing. Files: `src/lib/questions.ts` 2. Store the version with every answer It is the only thing that makes an old answer readable. Files: `src/lib/answers.ts`, `tests/versioning.test.ts` 3. Warn in the editor before a version bump The person editing is the only one who knows if the meaning changed. Files: `src/app/admin/questions/page.tsx` ### Done when - [ ] A meaning change versions the question - [ ] Old answers keep pointing at the old version - [ ] Reports show versions separately until merged - [ ] The editor warns before versioning a live question ### Watch out - Editing an option label in place from 'Weekly' to 'Weekly or more often' silently reinterprets every answer already collected under the old wording, and no one will ever know because the report shows a single tidy bar. Version the question, keep both labels, and make the report show them as separate series until a human merges them deliberately. ## Phase 3 · Branching that the browser cannot argue with Skip logic is evaluated on the server and a skipped question cannot be answered. ### Steps 1. Evaluate scope on the server on every page The client is a display, not an authority. Files: `src/lib/logic.ts`, `tests/logic.test.ts` 2. Refuse cycles at save time A respondent should never be the one who finds the loop. Files: `src/lib/rules.ts` 3. Clear answers that go out of scope on a back step Stale answers to unasked questions are the same corruption, arriving politely. Files: `src/lib/responses.ts` ### Done when - [ ] Out of scope answers are rejected and counted - [ ] A cyclic rule set fails at save time - [ ] Page order is decided only from stored answers - [ ] Going back clears newly out of scope answers ### Watch out - Evaluating skip logic in the browser and trusting the posted answers means anyone can submit answers to questions they were never shown, which quietly corrupts every cross-tabulation in the report and is undetectable afterwards. Evaluate on the server, discard out of scope answers with a recorded reason, and count the discards. ## Phase 4 · Quotas that close at the right moment A quota of one hundred ends with one hundred responses, not one hundred and four. ### Steps 1. Count and insert in one transaction Everything else about quotas is presentation. Files: `src/lib/quotas.ts`, `tests/quota-race.test.ts` 2. Make the screen-out action explicit Discarding a partial answer is a choice, so it should be one. Files: `src/lib/quotas.ts` 3. Show partial and complete separately Collapsing them is how people over-collect. Files: `src/app/admin/responses/page.tsx` ### Done when - [ ] Concurrent submissions never overshoot a quota - [ ] Screen-out behaviour follows the configured setting - [ ] Partial and complete counts are never summed - [ ] Closing stops new responses at once ### Watch out - Checking the quota before writing the response and then writing it leaves a window where four people submitting in the same second all pass a check that says ninety nine, and the overshoot is only visible weeks later in the data. Do the count and the insert in one transaction, and test it by firing concurrent submissions at a quota with one place left. ## Not in this build - Bulk invitation sending. - Panel and respondent marketplace integrations. - Weighting and significance testing. - Multi-administrator roles and approvals. ## After v1, if you want it - Randomised question and option order, recorded per response so the analysis can account for it - A read-only results link that shows aggregate counts and never a single response - Resumable responses by a link the respondent keeps, which is a different thing from an invitation token
# Copy to .env and fill in. Never commit .env; this file documents it. # Required. Surveys, questions, responses and quotas. Back this up on a schedule. DATABASE_PATH=./data/surveys.db # Required. Respondent file uploads. Deleted with the responses at retention. UPLOAD_DIR=./data/uploads # Required · secret. The single administrator login. Generated by the included command. ADMIN_PASSWORD_HASH=argon2id$...redacted # Required. Used in invitation links and in the codebook header. PUBLIC_BASE_URL=https://surveys.example.org # Required. Applied to a survey that does not set its own. Shorter is a reasonable default. DEFAULT_RETENTION_DAYS=365 # Required. Per file. Uploads are the fastest way to fill a small disk. MAX_UPLOAD_BYTES=5242880
# LimeSurvey Cloud · product brief ## Problem The visible survey platform loop is buildable, but a credible replacement needs more than the first screen. LimeSurvey Cloud 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 anonymity is enforced by which columns exist rather than by a promise, a live survey can be edited without rewriting what past respondents meant, a quota of one hundred closes at one hundred, and every export carries the codebook that makes it readable. ## Target user A builder who needs a maintainable product foundation, not a one-off demo. ## Required capabilities - Node.js 22 - deploy target - SMTP or transactional email key in .env ## Explicit non-goals for v1 - Bulk invitation sending. - Panel and respondent marketplace integrations. - Weighting and significance testing. - Multi-administrator roles and approvals. - spam and abuse defense at scale - large integration and template catalog - compliance controls and guaranteed delivery - enterprise workflow logic ## Success criteria - An anonymous survey is confirmed to have no participant reference in its schema - A mode change after the first response is confirmed to fail - An out of scope answer posted directly is confirmed rejected - Concurrent submissions are confirmed not to overshoot a quota - No export is confirmed possible without its codebook
# Build brief · LimeSurvey Cloud 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 self-hosted survey tool where anonymity is a decision made before the first response and enforced by the schema to replace LimeSurvey Cloud. 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 Anonymity is not a checkbox you can tick later. It is a property of the schema, and the moment the first response lands you have either got it or you have not. LimeSurvey itself is blunt about this. Its manual states that with anonymised responses there is no way to connect answers and participants, that even the administrator cannot link response data to participant data, and that the response submission date and the participant completion date are both forced to a fixed placeholder value, specifically so the order of submissions cannot be lined up against invitations or web server logs. That last detail is the one people miss: an accurate timestamp on an anonymous response, beside an accurate invitation timestamp, is an identifier. So build it the same way. A survey declares anonymous or identified at creation. An anonymous survey stores no foreign key from a response to a participant, coarsens the submission timestamp to the day, and writes no address, agent string or request log tied to the response. Switching to identified is not supported; the only path is a new survey, and a test proves the attempt fails. And tell respondents which one they are answering, in words, on the first page. A survey that claims anonymity while keeping a row that could rejoin the two tables is worse than one that never made the claim. ### Stack (fixed, do not substitute) - Runtime: Next.js with TypeScript, server rendered, one deployable · a public survey page must work with no client framework on a bad phone connection - Storage: SQLite with responses in one table and answers keyed by question version · editing a live survey must not rewrite the meaning of answers already collected - Anonymity: A per-survey mode set at creation, enforced by which columns exist · a promise enforced by application code is a promise until somebody writes a report - Logic: Branching evaluated server side from a stored rule tree, never from client state · a respondent can edit anything the browser knows and skip logic is how quotas get gamed - Quotas: Counted in a transaction at submission, with the close decided by the database · two people finishing at once is the normal case, not the edge case ### Data model (create this before Phase 1) - surveys(id, title, mode, state, opens_at, closes_at, created_at) · mode is anonymous or identified and is immutable after the first response - questions(id, survey_id, code, kind, required, position, version, retired_at) · a meaningful edit makes a new version rather than mutating the row - options(id, question_id, question_version, value, label, position) - responses(id, survey_id, state, started_at, submitted_at_day, completed) · identified surveys add participant_id; anonymous surveys do not have the column - answers(response_id, question_id, question_version, value_json) · the version is what makes an old answer still readable - participants(id, survey_id, token_hash, invited_at, completed_at) · exists only for identified surveys - quotas(id, survey_id, name, limit_count, action, current_count) · counted in the same transaction that writes the response - rules(id, survey_id, question_id, question_version, condition_json, effect) · the branching tree, evaluated server side ### Phase 1 · Two kinds of survey, decided once Build: Create the schema so an anonymous survey's response table carries no participant reference and stores the submission date coarsened to the day. Identified surveys get a participant table with hashed tokens. Refuse a mode change once any response exists, at the database level as well as in the application. Show the mode to the respondent in plain words on the first page. Trap: Storing a nullable participant column and leaving it empty for anonymous surveys means the anonymity depends on every future query remembering to ignore it, and one CSV export written in a hurry six months later is enough to break the promise. Make the column absent, so a report that tries to join cannot even be written. Done when: An anonymous survey's response rows have no participant reference available to any query, verified by inspecting the schema. An attempt to change a survey's mode after a response exists fails, tested. The submission timestamp on an anonymous response is coarser than a second and no address or agent string is stored against it, checked in the database. The first page of every survey states in a sentence whether answers can be traced back. Do not build yet: No logic, quotas, exports or notifications yet. ### Phase 2 · Questions that can change without rewriting history Build: Version a question whenever its wording, type or options change in a way that alters meaning. Answers store the question version they were given under. Reordering or renaming for clarity is not a new version; changing an option's meaning is. Show the editor which questions already have answers and what a version bump will do to the report. Trap: Editing an option label in place from 'Weekly' to 'Weekly or more often' silently reinterprets every answer already collected under the old wording, and no one will ever know because the report shows a single tidy bar. Version the question, keep both labels, and make the report show them as separate series until a human merges them deliberately. Done when: Changing an option label on a question with existing answers creates a new version and leaves old answers pointing at the old one, tested. The report renders two series for two versions and never silently merges them. Reordering questions does not create a version, tested. The editor warns before any change that will version a question with existing answers. Do not build yet: No branching, quotas or export yet. ### Phase 3 · Branching that the browser cannot argue with Build: Store conditions as a rule tree against question codes and versions. On each page submission, evaluate server side which questions are now in scope and return the next page. Reject any answer for a question the server considers out of scope. Detect and refuse cycles when saving a rule, rather than when a respondent hits one. Trap: Evaluating skip logic in the browser and trusting the posted answers means anyone can submit answers to questions they were never shown, which quietly corrupts every cross-tabulation in the report and is undetectable afterwards. Evaluate on the server, discard out of scope answers with a recorded reason, and count the discards. Done when: An answer posted for an out of scope question is rejected and the rejection is counted, tested by posting directly. Saving a rule set containing a cycle fails with the cycle named, tested. The next page is decided server side from stored answers only. A respondent who goes back and changes an answer has the now out of scope answers cleared rather than retained. Do not build yet: No quotas, exports or invitations yet. ### Phase 4 · Quotas that close at the right moment Build: Count a quota inside the same transaction that writes the completed response, and decide there whether this response is the one that closes it. On closing, apply the configured action: reject with a message, mark the response as screened out, or close the survey. Show partial and complete counts separately in the admin view because they are not the same number. Trap: Checking the quota before writing the response and then writing it leaves a window where four people submitting in the same second all pass a check that says ninety nine, and the overshoot is only visible weeks later in the data. Do the count and the insert in one transaction, and test it by firing concurrent submissions at a quota with one place left. Done when: Concurrent submissions against a quota with one place remaining produce exactly one accepted response, tested with parallel requests. A screened out respondent sees the configured message and their partial data is kept or discarded according to the setting, tested both ways. Partial and complete counts are shown separately and never summed into one figure. Closing a survey stops new responses immediately and leaves in-flight ones able to finish or not, according to the setting. Do not build yet: No export, invitations or retention work yet. ### Phase 5 · Getting the data out and deleting it Build: Export responses as CSV and JSON with a codebook holding question versions, option labels and the survey mode, so the file is readable without the application. Enforce a retention period per survey that deletes responses and any uploads. Give every respondent in an identified survey a way to request their own data or its deletion, and record what you did. Trap: An export that flattens two versions of a question into one column and omits the labels produces a file that looks fine and is wrong, and it will be the file somebody quotes in a report. Ship a codebook beside every export naming every question version and option label, and refuse to export without it. Done when: Every export is accompanied by a codebook covering all question versions present, and exporting without it is not possible, checked by review. A retention period that has passed deletes the responses and their uploads, tested with a clock override. A deletion request on an identified survey removes the participant and their answers and leaves a record that it happened. Re-importing an export into an empty instance reproduces the same counts per question version. Do not build yet: Nothing after this. ### Out of scope (and why) - Email invitation sending at volume, which is a deliverability project rather than a feature and is better rented. - Panel integrations and respondent marketplaces, which are a commercial relationship rather than code. - Weighting and significance testing in the reporting view, because a statistic produced by a tool nobody validated is worse than no statistic. - Multi-administrator roles and approval workflows, which are the part of the paid product that exists for organisations rather than for surveys. ### README must contain - That a survey's anonymous or identified mode is chosen at creation and cannot be changed afterwards, and why - That anonymous responses store a coarsened submission date on purpose, because precise timings can be joined against invitations - That questions are versioned and every answer records the version it was given under - That skip logic is evaluated on the server and out of scope answers are rejected - Where uploads live, what the retention period is, and how a respondent asks for deletion
# Architecture · LimeSurvey Cloud ## Stack | Part | Choice | Why | | --- | --- | --- | | Runtime | Next.js with TypeScript, server rendered, one deployable | a public survey page must work with no client framework on a bad phone connection | | Storage | SQLite with responses in one table and answers keyed by question version | editing a live survey must not rewrite the meaning of answers already collected | | Anonymity | A per-survey mode set at creation, enforced by which columns exist | a promise enforced by application code is a promise until somebody writes a report | | Logic | Branching evaluated server side from a stored rule tree, never from client state | a respondent can edit anything the browser knows and skip logic is how quotas get gamed | | Quotas | Counted in a transaction at submission, with the close decided by the database | two people finishing at once is the normal case, not the edge case | ## Modules Each module has one owner concern and a documented way to replace it. | Module | Owns | How to replace it | | --- | --- | --- | | Surveys | Mode, state, and the immutability of the mode. | Not swappable for a nullable flag, which is the same as no anonymity. | | Questions | Versioning and the editor's warnings. | Any editor, provided answers keep a version reference. | | Logic | Server side scope evaluation and cycle detection. | Any rule format, provided evaluation stays on the server. | | Quotas | Transactional counting and the close action. | Any store with real transactions. Not an application-level check. | | Export | CSV, JSON and the codebook. | Any format, provided the codebook goes with it. | ## Configuration Every runtime setting is an environment variable documented in `.env.example`, validated at startup, with a safe local default wherever one exists. - `DATABASE_PATH` · required · Surveys, questions, responses and quotas. Back this up on a schedule. - `UPLOAD_DIR` · required · Respondent file uploads. Deleted with the responses at retention. - `ADMIN_PASSWORD_HASH` · required, secret · The single administrator login. Generated by the included command. - `PUBLIC_BASE_URL` · required · Used in invitation links and in the codebook header. - `DEFAULT_RETENTION_DAYS` · required · Applied to a survey that does not set its own. Shorter is a reasonable default. - `MAX_UPLOAD_BYTES` · required · Per file. Uploads are the fastest way to fill a small disk. ## 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 · LimeSurvey Cloud product build - Read `PRODUCT.md` and `ARCHITECTURE.md` before changing code. The stack is fixed: Next.js with TypeScript, server rendered, one deployable, SQLite with responses in one table and answers keyed by question version, A per-survey mode set at creation, enforced by which columns exist, Branching evaluated server side from a stored rule tree, never from client state, Counted in a transaction at submission, with the close decided by the database. - 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 - Storing a nullable participant column and leaving it empty for anonymous surveys means the anonymity depends on every future query remembering to ignore it, and one CSV export written in a hurry six months later is enough to break the promise. Make the column absent, so a report that tries to join cannot even be written. - Editing an option label in place from 'Weekly' to 'Weekly or more often' silently reinterprets every answer already collected under the old wording, and no one will ever know because the report shows a single tidy bar. Version the question, keep both labels, and make the report show them as separate series until a human merges them deliberately. - Evaluating skip logic in the browser and trusting the posted answers means anyone can submit answers to questions they were never shown, which quietly corrupts every cross-tabulation in the report and is undetectable afterwards. Evaluate on the server, discard out of scope answers with a recorded reason, and count the discards. - Checking the quota before writing the response and then writing it leaves a window where four people submitting in the same second all pass a check that says ninety nine, and the overshoot is only visible weeks later in the data. Do the count and the insert in one transaction, and test it by firing concurrent submissions at a quota with one place left. - An export that flattens two versions of a question into one column and omits the labels produces a file that looks fine and is wrong, and it will be the file somebody quotes in a report. Ship a codebook beside every export naming every question version and option label, and refuse to export without it.
# Delivery milestones · LimeSurvey Cloud Estimated effort: **weekend** for the indie phases; the production-only milestones add the trust and operability layer. ## M1 · Two kinds of survey, decided once Creating a survey chooses anonymous or identified, and the storage differs accordingly. ### Steps 1. Make anonymity a schema difference, not a flag A missing column cannot be joined by accident. Files: `db/schema.sql`, `src/lib/surveys.ts` 2. Refuse the mode change and test the refusal This is the one irreversible decision in the product. Files: `src/lib/surveys.ts`, `tests/anonymity.test.ts` 3. Coarsen the anonymous timestamp Invitation time plus submission time is an identifier. Files: `src/lib/responses.ts` ### Done when - [ ] No participant reference exists on anonymous responses - [ ] A mode change after any response fails - [ ] Anonymous submission times are coarser than a second - [ ] The mode is stated to the respondent in words ### Watch out - Storing a nullable participant column and leaving it empty for anonymous surveys means the anonymity depends on every future query remembering to ignore it, and one CSV export written in a hurry six months later is enough to break the promise. Make the column absent, so a report that tries to join cannot even be written. ## M2 · Questions that can change without rewriting history Editing a live survey never alters the meaning of answers already given. ### Steps 1. Version on meaning, not on every keystroke Otherwise the editor becomes unusable and people stop editing. Files: `src/lib/questions.ts` 2. Store the version with every answer It is the only thing that makes an old answer readable. Files: `src/lib/answers.ts`, `tests/versioning.test.ts` 3. Warn in the editor before a version bump The person editing is the only one who knows if the meaning changed. Files: `src/app/admin/questions/page.tsx` ### Done when - [ ] A meaning change versions the question - [ ] Old answers keep pointing at the old version - [ ] Reports show versions separately until merged - [ ] The editor warns before versioning a live question ### Watch out - Editing an option label in place from 'Weekly' to 'Weekly or more often' silently reinterprets every answer already collected under the old wording, and no one will ever know because the report shows a single tidy bar. Version the question, keep both labels, and make the report show them as separate series until a human merges them deliberately. ## M3 · Branching that the browser cannot argue with Skip logic is evaluated on the server and a skipped question cannot be answered. ### Steps 1. Evaluate scope on the server on every page The client is a display, not an authority. Files: `src/lib/logic.ts`, `tests/logic.test.ts` 2. Refuse cycles at save time A respondent should never be the one who finds the loop. Files: `src/lib/rules.ts` 3. Clear answers that go out of scope on a back step Stale answers to unasked questions are the same corruption, arriving politely. Files: `src/lib/responses.ts` ### Done when - [ ] Out of scope answers are rejected and counted - [ ] A cyclic rule set fails at save time - [ ] Page order is decided only from stored answers - [ ] Going back clears newly out of scope answers ### Watch out - Evaluating skip logic in the browser and trusting the posted answers means anyone can submit answers to questions they were never shown, which quietly corrupts every cross-tabulation in the report and is undetectable afterwards. Evaluate on the server, discard out of scope answers with a recorded reason, and count the discards. ## M4 · Quotas that close at the right moment A quota of one hundred ends with one hundred responses, not one hundred and four. ### Steps 1. Count and insert in one transaction Everything else about quotas is presentation. Files: `src/lib/quotas.ts`, `tests/quota-race.test.ts` 2. Make the screen-out action explicit Discarding a partial answer is a choice, so it should be one. Files: `src/lib/quotas.ts` 3. Show partial and complete separately Collapsing them is how people over-collect. Files: `src/app/admin/responses/page.tsx` ### Done when - [ ] Concurrent submissions never overshoot a quota - [ ] Screen-out behaviour follows the configured setting - [ ] Partial and complete counts are never summed - [ ] Closing stops new responses at once ### Watch out - Checking the quota before writing the response and then writing it leaves a window where four people submitting in the same second all pass a check that says ninety nine, and the overshoot is only visible weeks later in the data. Do the count and the insert in one transaction, and test it by firing concurrent submissions at a quota with one place left. ## M5 · Getting the data out and deleting it (production only) Exports carry their own meaning, and a retention date actually removes data. ### Steps 1. Ship a codebook with every export A CSV of option indexes with no labels is not data. Files: `src/lib/export.ts`, `tests/export.test.ts` 2. Make retention delete uploads too The file somebody attached is the most identifying thing in the survey. Files: `src/lib/retention.ts` 3. Record every deletion request and its outcome You will be asked to show that you did it. Files: `src/lib/requests.ts` ### Done when - [ ] No export is possible without a codebook - [ ] Retention deletes responses and uploads - [ ] Deletion requests leave a record of the action - [ ] An export round-trips into an empty instance ### Watch out - An export that flattens two versions of a question into one column and omits the labels produces a file that looks fine and is wrong, and it will be the file somebody quotes in a report. Ship a codebook beside every export naming every question version and option label, and refuse to export without it.
# Operations · LimeSurvey Cloud ## Backup The database and the upload directory, on a schedule, off the host. Responses are the one thing here that cannot be regenerated: a lost survey is a survey you have to run again, and the second run will not give the same answers. ## Restore Restore the database and the uploads together, then confirm response counts per survey and per question version. A restore that brings back responses without uploads leaves file questions pointing at nothing, so check both. Do a restore drill before the first real user, and write the date here when it passes. ## Monitoring Completion rate and drop-off page per survey, out of scope answer rejections, quota overshoot which should always be zero, and disk used by uploads. A rising rejection count usually means a rule was edited while people were mid-survey. ## Incident checklist The incidents here are a survey that promised anonymity and did not deliver it, and a quota that overshot. The first is answered by the schema and the second by the transaction test, which is why both are gates rather than nice to have. 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 anonymous survey is confirmed to have no participant reference in its schema - [ ] A mode change after the first response is confirmed to fail - [ ] An out of scope answer posted directly is confirmed rejected - [ ] Concurrent submissions are confirmed not to overshoot a quota - [ ] No export is confirmed possible without its codebook ## Launch constraint Do not market omitted LimeSurvey Cloud capabilities as implemented. The non-goals in `PRODUCT.md` remain user-visible limitations until they are deliberately delivered.
# Copy to .env and fill in. Never commit .env; this file documents it. # Required. Surveys, questions, responses and quotas. Back this up on a schedule. DATABASE_PATH=./data/surveys.db # Required. Respondent file uploads. Deleted with the responses at retention. UPLOAD_DIR=./data/uploads # Required · secret. The single administrator login. Generated by the included command. ADMIN_PASSWORD_HASH=argon2id$...redacted # Required. Used in invitation links and in the codebook header. PUBLIC_BASE_URL=https://surveys.example.org # Required. Applied to a survey that does not set its own. Shorter is a reasonable default. DEFAULT_RETENTION_DAYS=365 # Required. Per file. Uploads are the fastest way to fill a small disk. MAX_UPLOAD_BYTES=5242880
$ choose a build depth, inspect the files, then open the complete pack in your agent
LimeSurvey Cloud: Customers pay for dependable collection, spam control, notifications, integrations, and confidence that responses will not disappear.
xspam and abuse defense at scale
xlarge integration and template catalog
xcompliance controls and guaranteed delivery
xenterprise workflow logic
Don't feel like building it? These folks already made it free.
no votes, no pay-to-list · just what's real
LimeSurvey Cloud pricing
| plan | monthly | annual (per mo) | what you get |
|---|---|---|---|
| free | $0/workspace | $0/workspace | 1 admin; 25 responses/month; unlimited surveys and questions; 10 MB upload storage. |
| basic | $39.24/workspace | — | 1 admin; 1,000 responses/month; 250 MB storage. |
| expert | — | $33.47/workspace | 3 admins; 10,000 responses/year; 1 GB storage. |
| business | — | $86.55/workspace | 5-10 admins; 50,000 responses/year; 3 GB storage; 1 alias domain. |
| corporate | custom | — | Custom admins, response volume, storage, domains and service terms. |
free tier1 admin; 25 responses/month; unlimited surveys and questions; 10 MB upload storage.
billingBasic is monthly; Expert and Business are annual; Corporate is contract pricing. Checkout is in EUR and the displayed public prices include 25% VAT.
hidden costsA completed response counts as 1 and a partial response as 0.5; quotas are account-wide; reaching the cap can lock collection/export until reset or upgrade; unused response volume expires; onboarding, training, customization and some plugins can cost extra.
verified 2026-08-12 · source ↗
Vibecode LimeSurvey Cloud
Kinda. The core of LimeSurvey Cloud is buildable in a weekend with the prompt on this page, but there are real gaps: spam and abuse defense at scale, large integration and template catalog. Read the honest list above before committing.
How much does LimeSurvey Cloud cost?
LimeSurvey Cloud'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 LimeSurvey Cloud?
Honestly: spam and abuse defense at scale; large integration and template catalog; compliance controls and guaranteed delivery; enterprise workflow logic. If any of those are load-bearing for you, keep paying.
Is there an open-source alternative to LimeSurvey Cloud?
Yes: LimeSurvey (The same survey engine, minus the hosting bill and plus the PHP chores) The prompt is for when you want it exactly your way.