Vibecode Cabin
track this build4 phases, 12 steps, beginner friendly0%The visible privacy web analytics loop is buildable, but a credible replacement needs more than the first screen. Cabin earns its keep through ingestion, storage, reliability, so expect a weekend or multi-day build and a narrower personal scope.
You are building a lean indie version of Cabin. 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 ===== # Cabin · indie build A first-party analytics collector whose hard problems are getting the beacon to arrive at all on a phone, deriving a visit identifier that stops being linkable tomorrow, and aggregating at ingestion so the individual rows can expire without taking the reports with them. 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 | | --- | --- | --- | | Collector | One endpoint on your own domain, no third-party host, no identifier shared between sites | a first-party aggregate-only collector is the only shape the analytics exception has room for | | Beacon | sendBeacon with a keepalive fetch fallback, fired on visibility change | the unload event costs the page its back and forward cache and does not fire on a phone | | Storage | An append-only raw table with a life measured in hours, plus aggregates written on receipt | the aggregates are the product and the raw rows are a liability with a timer on them | | Identity | A daily hash of a rotating secret, a truncated address and the user agent, secret destroyed nightly | yesterday's hashes stop being linkable to today's, which is the entire design | | Reports | Visits, pageviews, paths and referrer hosts, with no segment describing a person | the exception covers how the service is used, not who uses it | ## 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 collector, the sweep and the reports. - Get it: nodejs.org or your version manager. - Verify: node --version prints v22 or higher - [ ] **A hostname on the same registrable domain as the site you are measuring** · free if you own the domain - Why: A first-party collector is the shape the exception has room for, and a third-party host reintroduces every question you were avoiding. - Get it: Add a subdomain of the site's own domain pointing at wherever this runs. - Verify: The collector endpoint answers on that hostname and the script loads from it - [ ] **A real phone to test the beacon on** · free - Why: Switching apps rather than closing a tab is the case that unload never covered, and it is not reproducible in a desktop browser. - Get it: Any phone that can reach the site. - Verify: Loading a page and switching to another app produces an event - [ ] **Which regime you are writing the notice for** · free - Why: The retention figures and the opt-out wording differ, and picking after you have built the sweep means picking whatever the sweep already does. - Get it: Decide which visitors you are designing for, read that regulator's current published guidance, and write the retention numbers and the objection mechanism into the configuration file with the date you checked. - Verify: The configuration carries the numbers and the date, and the notice is generated from it ## 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: - Session replay. - Cross-site or cross-device identity. - Advertising attribution and conversion tracking. - Hosting analytics for other people's sites. - bot and identity resolution - session replay privacy tooling - enterprise governance and integrations - high-volume ingestion and retention If one of those is essential to you, that is the reason to keep paying for Cabin, and the README should say so rather than pretend. ===== BRIEF.md ===== # Build brief · Cabin 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 first-party web analytics collector that counts visits without identifying anybody and can show its working to replace Cabin. 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 'Cookieless' is a marketing word, not a legal conclusion, and if you design as though it were one you will build something you have to unpick later. The United Kingdom changed in 2025. The Data (Use and Access) Act added a statistical purposes exception to the electronic communications rules, and the regulator's guidance, finalised in April 2026, calls it the analytics exception. It is narrow. The sole purpose must be collecting statistics about how the service is used with a view to improving it; the guidance says plainly that it 'is about how your service is used, not about who uses it', that it is 'not for identifying, tracking or monitoring people', and that it does not cover advertising. You must give clear information and 'a simple means of objecting, free of charge', and you must not keep individual-level data longer than the aggregation needs. Checked on the regulator's guidance pages on 15 September 2026. France publishes an exemption with harder numbers: the identifier lives at most thirteen months and is not extended by a new visit, the data collected through it at most twenty-five months, the last byte of the address is truncated, and nothing is cross-referenced with other processing or passed to a third party. Checked on the CNIL's audience measurement page, same date. So the design is already decided. Aggregate at ingestion, let the individual rows expire on a clock, publish an objection that costs nothing, never segment a visitor by anything that describes the person, and never let two sites share an identifier. The absence of a visitors table is not a missing feature, it is the argument. And do not promise accuracy you have given up. A collector that refuses to identify people cannot tell you how many people came. It can tell you how many visits there were. Say visits everywhere. ### Stack (fixed, do not substitute) - Collector: One endpoint on your own domain, no third-party host, no identifier shared between sites · a first-party aggregate-only collector is the only shape the analytics exception has room for - Beacon: sendBeacon with a keepalive fetch fallback, fired on visibility change · the unload event costs the page its back and forward cache and does not fire on a phone - Storage: An append-only raw table with a life measured in hours, plus aggregates written on receipt · the aggregates are the product and the raw rows are a liability with a timer on them - Identity: A daily hash of a rotating secret, a truncated address and the user agent, secret destroyed nightly · yesterday's hashes stop being linkable to today's, which is the entire design - Reports: Visits, pageviews, paths and referrer hosts, with no segment describing a person · the exception covers how the service is used, not who uses it ### Data model (create this before Phase 1) - events(id, at, site_id, path, referrer_host, viewport_bucket, visit_hash, country) · raw, short-lived, and the only table holding anything per visitor - daily_salt(day, secret, created_at, destroyed_at) · rotated and destroyed on a schedule, because a salt that outlives its day is a persistent identifier - agg_pageviews(site_id, day, path, views, visits) and agg_referrers(site_id, day, referrer_host, visits) · written on receipt, not computed nightly from raw - agg_entry(site_id, day, entry_path, visits) · enough to answer where people arrive, and nothing about who they are - retention(site_id, raw_max_age_hours, aggregate_max_age_months) · the numbers a regulator asks about, in one readable place - objections(id, kind, value, created_at) · the free and simple opt-out, recorded so that honouring it can be demonstrated - bot_filters(id, rule, added_at) and agg_filtered(site_id, day, rule, views) · what was removed and by which rule, counted separately rather than silently dropped - There is no visitors table, no account identifier and no cross-site identifier in this schema. That absence is the compliance argument, and adding one quietly ends it. ### Phase 1 · A beacon that actually arrives Build: Send on the visibility change event when the document becomes hidden, using sendBeacon and falling back to a fetch with keepalive. Keep each payload small and check the return value. Add no unload or beforeunload handler anywhere in the script. Serve the script from your own domain. Trap: sendBeacon returns false rather than throwing when the browser declines to queue the request, and the Fetch standard caps the total inflight keepalive request bodies for a page at sixty four kibibytes across all of them rather than per request, so a page that batches a session into one large beacon, or fires several at once, loses them silently and your numbers are quietly low forever. Check the return value, keep payloads small, and do not send from unload, which does not fire when someone switches apps on a phone and costs the page its back and forward cache in some browsers. Done when: A false return from sendBeacon is detected and the event is retried through the fallback, tested with a stub. The built script contains no unload or beforeunload handler, asserted by a test that greps the bundle. Each payload is under a stated byte size and the size is asserted in a test. Switching away from the page on a real phone produces an event, checked once by hand and recorded in the README. Do not build yet: No identifier, aggregation, retention, reports or opt-out yet. ### Phase 2 · An identifier that is useless by tomorrow Build: Generate a secret per day. Truncate the address before it is used, then hash the secret, the truncated address, the user agent and the site together to make the visit hash. Never write the address to any table. Destroy the day's secret on a schedule and record the destruction. Trap: Keeping the salt around for a week so you can report returning visitors quietly converts an unlinkable daily hash into a persistent identifier for everyone who visited, which is exactly the processing the exemptions you were relying on do not cover, and nothing in the interface will look any different. Destroy the secret on its schedule, record that you destroyed it, and accept that returning visitors across days is a number this design cannot produce. Done when: No table has a column capable of holding a full address, verified by a test that inspects the schema. Replaying yesterday's raw inputs under today's secret produces different hashes, tested. Secret destruction is recorded with a time and a test asserts no secret older than the configured age exists. The address is truncated before it reaches the hash, tested against a fixture. Do not build yet: No aggregation, retention job, reports or opt-out yet. ### Phase 3 · Aggregate on receipt, expire the rest Build: Update the aggregate tables in the same transaction that writes the raw event. Give raw rows a maximum age in hours and run a job that deletes and records the counts. Put the retention numbers in one configuration file that the privacy notice is generated from. Trap: Keeping raw events indefinitely because storage is cheap and you might re-aggregate later turns a compliant collector into a retained dataset about people, and the re-aggregation you were saving them for happens roughly never. Aggregate inside the write transaction, delete raw rows on a clock, and generate the published retention figures from the same file the deletion job reads so the two cannot drift. Done when: Deleting every raw row changes no figure in any report, tested by comparing reports before and after a forced sweep. Raw rows older than the configured hours are absent after the job runs, tested against a fixed clock. The privacy notice text is generated from the retention configuration, so changing the number changes both, tested. The sweep records how many rows it deleted and when. Do not build yet: No opt-out, reports, bot handling or export yet. ### Phase 4 · Objecting costs nothing, and nothing segments a person Build: Publish an objection page reachable without an account or payment that stores a preference and stops collection on the next pageview. Build reports for visits, pageviews, paths, referrer hosts and entry pages. Use the word visits throughout. Refuse to add any breakdown that describes the person rather than the visit. Trap: Adding a breakdown by anything that describes the person rather than the visit, which is what every analytics product does the moment it has a second screen to fill, is exactly what the regulator's guidance says takes you outside the exception, and it will be added by somebody who read it as a harmless chart. Keep the report builder incapable of grouping by a visitor attribute, and put a test on the list of permitted dimensions. Done when: The report layer accepts only a fixed list of dimensions and rejects anything else, tested by attempting a forbidden grouping. The word users does not appear in the interface, asserted by a test over the rendered pages. An objection stops collection on the next pageview and is recorded with a time, tested end to end. The objection page needs no account, no payment and no third-party script. Do not build yet: No bot handling, export or published compliance page yet. ### Phase 5 · Bots, and being honest about the number Build: Filter obvious automated traffic with named rules and count what each rule removed rather than dropping it silently. Show the filtered total beside the reported one. Generate a page listing what is collected, the retention figures, the opt-out and the filter rules, from the same configuration the code uses. Export aggregates as CSV. Trap: Silently discarding traffic you believe is automated moves the reported numbers for reasons nobody can audit, and six months later a decision gets made on a filter somebody wrote on a Tuesday and forgot. Count per rule, show the filtered total beside the reported one, and make a rule change visible as a step in the chart rather than a mystery. Done when: Every filter rule has its own counter and the filtered total is displayed beside the reported one. Adding or removing a rule is recorded with a date and appears on the compliance page. The compliance page is generated from the same configuration the collector and the sweep read, tested by changing one value and asserting all three change. Export produces aggregates as CSV and contains no raw event rows, tested. Do not build yet: Nothing after this. ### Out of scope (and why) - Session replay, which records what a person did on a page and is the opposite of an analytics build whose defence is that it does not watch individuals. - Cross-site or cross-device identity, which requires consent under every regime discussed here and cannot be reached by hashing harder. - Advertising attribution and conversion tracking, which the regulator's guidance says the analytics exception does not cover. - A hosted service for other people's sites, which makes you a processor for their visitors and brings contracts, sub-processor lists and breach duties with it. ### README must contain - That the analytics exception added to the electronic communications rules is narrow, with the regulator's own wording quoted and the date checked - That the French exemption caps the identifier at thirteen months and the collected data at twenty-five, and requires address truncation, with the date checked - That this collector cannot report people or returning visitors across days, and why that is deliberate - That the raw event rows expire on a clock and the reports survive their deletion - How to object, that it costs nothing and needs no account, and what happens on the next pageview ===== AGENTS.md ===== # Agent instructions · Cabin indie build - Read `README.md` and `BUILD_PLAN.md` before writing code. The stack is fixed: One endpoint on your own domain, no third-party host, no identifier shared between sites, sendBeacon with a keepalive fetch fallback, fired on visibility change, An append-only raw table with a life measured in hours, plus aggregates written on receipt, A daily hash of a rotating secret, a truncated address and the user agent, secret destroyed nightly, Visits, pageviews, paths and referrer hosts, with no segment describing a person. 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 - sendBeacon returns false rather than throwing when the browser declines to queue the request, and the Fetch standard caps the total inflight keepalive request bodies for a page at sixty four kibibytes across all of them rather than per request, so a page that batches a session into one large beacon, or fires several at once, loses them silently and your numbers are quietly low forever. Check the return value, keep payloads small, and do not send from unload, which does not fire when someone switches apps on a phone and costs the page its back and forward cache in some browsers. - Keeping the salt around for a week so you can report returning visitors quietly converts an unlinkable daily hash into a persistent identifier for everyone who visited, which is exactly the processing the exemptions you were relying on do not cover, and nothing in the interface will look any different. Destroy the secret on its schedule, record that you destroyed it, and accept that returning visitors across days is a number this design cannot produce. - Keeping raw events indefinitely because storage is cheap and you might re-aggregate later turns a compliant collector into a retained dataset about people, and the re-aggregation you were saving them for happens roughly never. Aggregate inside the write transaction, delete raw rows on a clock, and generate the published retention figures from the same file the deletion job reads so the two cannot drift. - Adding a breakdown by anything that describes the person rather than the visit, which is what every analytics product does the moment it has a second screen to fill, is exactly what the regulator's guidance says takes you outside the exception, and it will be added by somebody who read it as a harmless chart. Keep the report builder incapable of grouping by a visitor attribute, and put a test on the list of permitted dimensions. ===== BUILD_PLAN.md ===== # Build plan · Cabin A first-party analytics collector whose hard problems are getting the beacon to arrive at all on a phone, deriving a visit identifier that stops being linkable tomorrow, and aggregating at ingestion so the individual rows can expire without taking the reports with them. Phases are in dependency order. Each ends in a "Done when" list; treat an unticked item as a blocker, not a note. ## Phase 1 · A beacon that actually arrives The event reaches the collector from a phone that was switched away from, not closed. ### Steps 1. Send on visibility change, never on unload It is the only event that fires when someone switches apps. Files: `public/collect.js`, `tests/no-unload.test.js` 2. Check the sendBeacon return value and fall back A silent false is how a collector under-reports for months. Files: `public/collect.js` 3. Keep the payload small and assert the size The keepalive budget is shared across every inflight request on the page. Files: `public/collect.js`, `tests/payload-size.test.js` ### Done when - [ ] A refused beacon is detected and retried - [ ] No unload or beforeunload handler exists in the bundle - [ ] Payload size is bounded and asserted - [ ] An event arrives after switching apps on a phone ### Watch out - sendBeacon returns false rather than throwing when the browser declines to queue the request, and the Fetch standard caps the total inflight keepalive request bodies for a page at sixty four kibibytes across all of them rather than per request, so a page that batches a session into one large beacon, or fires several at once, loses them silently and your numbers are quietly low forever. Check the return value, keep payloads small, and do not send from unload, which does not fire when someone switches apps on a phone and costs the page its back and forward cache in some browsers. ## Phase 2 · An identifier that is useless by tomorrow The visit hash cannot be linked across days, and there is nowhere to store an address. ### Steps 1. Truncate the address before it is used at all Not on the way into the table, because it should never reach a table. Files: `src/identity.js`, `tests/identity.test.js` 2. Rotate and destroy the secret on a schedule The destruction is the property the whole design rests on. Files: `src/salt.js`, `tests/salt.test.js` 3. Give the schema nowhere to put an address The enforcement is the absent column, not the careful code. Files: `migrations/001_init.sql` ### Done when - [ ] No column can hold a full address - [ ] Yesterday's inputs hash differently today - [ ] Secret destruction is scheduled and recorded - [ ] Truncation happens before hashing ### Watch out - Keeping the salt around for a week so you can report returning visitors quietly converts an unlinkable daily hash into a persistent identifier for everyone who visited, which is exactly the processing the exemptions you were relying on do not cover, and nothing in the interface will look any different. Destroy the secret on its schedule, record that you destroyed it, and accept that returning visitors across days is a number this design cannot produce. ## Phase 3 · Aggregate on receipt, expire the rest Deleting every raw row leaves the reports intact. ### Steps 1. Aggregate inside the same transaction as the raw write A nightly rollup job is a nightly window in which the raw rows must survive. Files: `src/ingest.js`, `tests/aggregate.test.js` 2. Sweep raw rows on a clock and record the counts So that 'we delete after a day' is a thing you can show. Files: `src/retention.js`, `tests/retention.test.js` 3. Generate the privacy notice from the retention config The published number and the enforced number must be one number. Files: `config/retention.json`, `src/notice.js` ### Done when - [ ] Reports survive deleting every raw row - [ ] The sweep removes rows past the configured age - [ ] The notice is generated from the retention config - [ ] Deletion counts are recorded ### Watch out - Keeping raw events indefinitely because storage is cheap and you might re-aggregate later turns a compliant collector into a retained dataset about people, and the re-aggregation you were saving them for happens roughly never. Aggregate inside the write transaction, delete raw rows on a clock, and generate the published retention figures from the same file the deletion job reads so the two cannot drift. ## Phase 4 · Objecting costs nothing, and nothing segments a person Opting out is one click with no account, and no report groups by an attribute of the visitor. ### Steps 1. Fix the permitted dimensions in code and test the list So the harmless chart cannot be added without someone noticing. Files: `src/reports/dimensions.js`, `tests/dimensions.test.js` 2. Say visits, never users You gave up the ability to count people; say so everywhere. Files: `src/views/report.ejs`, `tests/wording.test.js` 3. Make objecting one click with no account Free and simple is the standard, not a settings page. Files: `src/objections.js`, `src/views/optout.ejs` ### Done when - [ ] Forbidden groupings are rejected by the report layer - [ ] The word users appears nowhere in the interface - [ ] Objecting stops collection on the next pageview - [ ] The opt-out needs no account or payment ### Watch out - Adding a breakdown by anything that describes the person rather than the visit, which is what every analytics product does the moment it has a second screen to fill, is exactly what the regulator's guidance says takes you outside the exception, and it will be added by somebody who read it as a harmless chart. Keep the report builder incapable of grouping by a visitor attribute, and put a test on the list of permitted dimensions. ## Not in this build - Session replay. - Cross-site or cross-device identity. - Advertising attribution and conversion tracking. - Hosting analytics for other people's sites. ## After v1, if you want it - Server-side ingestion from your own access log, which removes the browser script entirely - A public dashboard for one site, which is a read-only view and forces the wording to be honest - Alerting on a traffic collapse, which is the one thing a small site owner actually wants a notification for ===== .env.example ===== # Copy to .env and fill in. Never commit .env; this file documents it. # Required · secret. Raw events with a short life, aggregates with a long one. DATABASE_URL=postgres://localhost:5432/analytics # Required. How often the hashing secret is replaced and the old one destroyed. SALT_ROTATE_HOURS=24 # Required. After this the raw events are deleted. The reports must not notice. RAW_MAX_AGE_HOURS=24 # Required. Check the figure your regime publishes rather than trusting this default. AGGREGATE_MAX_AGE_MONTHS=25 # Required. Must be on the same registrable domain as the measured site. COLLECTOR_ORIGIN=https://stats.example.com # Required. Each beacon. The keepalive budget is shared across every inflight request on the page. MAX_PAYLOAD_BYTES=1024 # Required. Reachable with no account and no payment. It is published in the notice. OPTOUT_URL=https://stats.example.com/objection
You are building a lean indie version of Cabin. 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 ===== # Cabin · indie build A first-party analytics collector whose hard problems are getting the beacon to arrive at all on a phone, deriving a visit identifier that stops being linkable tomorrow, and aggregating at ingestion so the individual rows can expire without taking the reports with them. 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 | | --- | --- | --- | | Collector | One endpoint on your own domain, no third-party host, no identifier shared between sites | a first-party aggregate-only collector is the only shape the analytics exception has room for | | Beacon | sendBeacon with a keepalive fetch fallback, fired on visibility change | the unload event costs the page its back and forward cache and does not fire on a phone | | Storage | An append-only raw table with a life measured in hours, plus aggregates written on receipt | the aggregates are the product and the raw rows are a liability with a timer on them | | Identity | A daily hash of a rotating secret, a truncated address and the user agent, secret destroyed nightly | yesterday's hashes stop being linkable to today's, which is the entire design | | Reports | Visits, pageviews, paths and referrer hosts, with no segment describing a person | the exception covers how the service is used, not who uses it | ## 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 collector, the sweep and the reports. - Get it: nodejs.org or your version manager. - Verify: node --version prints v22 or higher - [ ] **A hostname on the same registrable domain as the site you are measuring** · free if you own the domain - Why: A first-party collector is the shape the exception has room for, and a third-party host reintroduces every question you were avoiding. - Get it: Add a subdomain of the site's own domain pointing at wherever this runs. - Verify: The collector endpoint answers on that hostname and the script loads from it - [ ] **A real phone to test the beacon on** · free - Why: Switching apps rather than closing a tab is the case that unload never covered, and it is not reproducible in a desktop browser. - Get it: Any phone that can reach the site. - Verify: Loading a page and switching to another app produces an event - [ ] **Which regime you are writing the notice for** · free - Why: The retention figures and the opt-out wording differ, and picking after you have built the sweep means picking whatever the sweep already does. - Get it: Decide which visitors you are designing for, read that regulator's current published guidance, and write the retention numbers and the objection mechanism into the configuration file with the date you checked. - Verify: The configuration carries the numbers and the date, and the notice is generated from it ## 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: - Session replay. - Cross-site or cross-device identity. - Advertising attribution and conversion tracking. - Hosting analytics for other people's sites. - bot and identity resolution - session replay privacy tooling - enterprise governance and integrations - high-volume ingestion and retention If one of those is essential to you, that is the reason to keep paying for Cabin, and the README should say so rather than pretend. ===== BRIEF.md ===== # Build brief · Cabin 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 first-party web analytics collector that counts visits without identifying anybody and can show its working to replace Cabin. 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 'Cookieless' is a marketing word, not a legal conclusion, and if you design as though it were one you will build something you have to unpick later. The United Kingdom changed in 2025. The Data (Use and Access) Act added a statistical purposes exception to the electronic communications rules, and the regulator's guidance, finalised in April 2026, calls it the analytics exception. It is narrow. The sole purpose must be collecting statistics about how the service is used with a view to improving it; the guidance says plainly that it 'is about how your service is used, not about who uses it', that it is 'not for identifying, tracking or monitoring people', and that it does not cover advertising. You must give clear information and 'a simple means of objecting, free of charge', and you must not keep individual-level data longer than the aggregation needs. Checked on the regulator's guidance pages on 15 September 2026. France publishes an exemption with harder numbers: the identifier lives at most thirteen months and is not extended by a new visit, the data collected through it at most twenty-five months, the last byte of the address is truncated, and nothing is cross-referenced with other processing or passed to a third party. Checked on the CNIL's audience measurement page, same date. So the design is already decided. Aggregate at ingestion, let the individual rows expire on a clock, publish an objection that costs nothing, never segment a visitor by anything that describes the person, and never let two sites share an identifier. The absence of a visitors table is not a missing feature, it is the argument. And do not promise accuracy you have given up. A collector that refuses to identify people cannot tell you how many people came. It can tell you how many visits there were. Say visits everywhere. ### Stack (fixed, do not substitute) - Collector: One endpoint on your own domain, no third-party host, no identifier shared between sites · a first-party aggregate-only collector is the only shape the analytics exception has room for - Beacon: sendBeacon with a keepalive fetch fallback, fired on visibility change · the unload event costs the page its back and forward cache and does not fire on a phone - Storage: An append-only raw table with a life measured in hours, plus aggregates written on receipt · the aggregates are the product and the raw rows are a liability with a timer on them - Identity: A daily hash of a rotating secret, a truncated address and the user agent, secret destroyed nightly · yesterday's hashes stop being linkable to today's, which is the entire design - Reports: Visits, pageviews, paths and referrer hosts, with no segment describing a person · the exception covers how the service is used, not who uses it ### Data model (create this before Phase 1) - events(id, at, site_id, path, referrer_host, viewport_bucket, visit_hash, country) · raw, short-lived, and the only table holding anything per visitor - daily_salt(day, secret, created_at, destroyed_at) · rotated and destroyed on a schedule, because a salt that outlives its day is a persistent identifier - agg_pageviews(site_id, day, path, views, visits) and agg_referrers(site_id, day, referrer_host, visits) · written on receipt, not computed nightly from raw - agg_entry(site_id, day, entry_path, visits) · enough to answer where people arrive, and nothing about who they are - retention(site_id, raw_max_age_hours, aggregate_max_age_months) · the numbers a regulator asks about, in one readable place - objections(id, kind, value, created_at) · the free and simple opt-out, recorded so that honouring it can be demonstrated - bot_filters(id, rule, added_at) and agg_filtered(site_id, day, rule, views) · what was removed and by which rule, counted separately rather than silently dropped - There is no visitors table, no account identifier and no cross-site identifier in this schema. That absence is the compliance argument, and adding one quietly ends it. ### Phase 1 · A beacon that actually arrives Build: Send on the visibility change event when the document becomes hidden, using sendBeacon and falling back to a fetch with keepalive. Keep each payload small and check the return value. Add no unload or beforeunload handler anywhere in the script. Serve the script from your own domain. Trap: sendBeacon returns false rather than throwing when the browser declines to queue the request, and the Fetch standard caps the total inflight keepalive request bodies for a page at sixty four kibibytes across all of them rather than per request, so a page that batches a session into one large beacon, or fires several at once, loses them silently and your numbers are quietly low forever. Check the return value, keep payloads small, and do not send from unload, which does not fire when someone switches apps on a phone and costs the page its back and forward cache in some browsers. Done when: A false return from sendBeacon is detected and the event is retried through the fallback, tested with a stub. The built script contains no unload or beforeunload handler, asserted by a test that greps the bundle. Each payload is under a stated byte size and the size is asserted in a test. Switching away from the page on a real phone produces an event, checked once by hand and recorded in the README. Do not build yet: No identifier, aggregation, retention, reports or opt-out yet. ### Phase 2 · An identifier that is useless by tomorrow Build: Generate a secret per day. Truncate the address before it is used, then hash the secret, the truncated address, the user agent and the site together to make the visit hash. Never write the address to any table. Destroy the day's secret on a schedule and record the destruction. Trap: Keeping the salt around for a week so you can report returning visitors quietly converts an unlinkable daily hash into a persistent identifier for everyone who visited, which is exactly the processing the exemptions you were relying on do not cover, and nothing in the interface will look any different. Destroy the secret on its schedule, record that you destroyed it, and accept that returning visitors across days is a number this design cannot produce. Done when: No table has a column capable of holding a full address, verified by a test that inspects the schema. Replaying yesterday's raw inputs under today's secret produces different hashes, tested. Secret destruction is recorded with a time and a test asserts no secret older than the configured age exists. The address is truncated before it reaches the hash, tested against a fixture. Do not build yet: No aggregation, retention job, reports or opt-out yet. ### Phase 3 · Aggregate on receipt, expire the rest Build: Update the aggregate tables in the same transaction that writes the raw event. Give raw rows a maximum age in hours and run a job that deletes and records the counts. Put the retention numbers in one configuration file that the privacy notice is generated from. Trap: Keeping raw events indefinitely because storage is cheap and you might re-aggregate later turns a compliant collector into a retained dataset about people, and the re-aggregation you were saving them for happens roughly never. Aggregate inside the write transaction, delete raw rows on a clock, and generate the published retention figures from the same file the deletion job reads so the two cannot drift. Done when: Deleting every raw row changes no figure in any report, tested by comparing reports before and after a forced sweep. Raw rows older than the configured hours are absent after the job runs, tested against a fixed clock. The privacy notice text is generated from the retention configuration, so changing the number changes both, tested. The sweep records how many rows it deleted and when. Do not build yet: No opt-out, reports, bot handling or export yet. ### Phase 4 · Objecting costs nothing, and nothing segments a person Build: Publish an objection page reachable without an account or payment that stores a preference and stops collection on the next pageview. Build reports for visits, pageviews, paths, referrer hosts and entry pages. Use the word visits throughout. Refuse to add any breakdown that describes the person rather than the visit. Trap: Adding a breakdown by anything that describes the person rather than the visit, which is what every analytics product does the moment it has a second screen to fill, is exactly what the regulator's guidance says takes you outside the exception, and it will be added by somebody who read it as a harmless chart. Keep the report builder incapable of grouping by a visitor attribute, and put a test on the list of permitted dimensions. Done when: The report layer accepts only a fixed list of dimensions and rejects anything else, tested by attempting a forbidden grouping. The word users does not appear in the interface, asserted by a test over the rendered pages. An objection stops collection on the next pageview and is recorded with a time, tested end to end. The objection page needs no account, no payment and no third-party script. Do not build yet: No bot handling, export or published compliance page yet. ### Phase 5 · Bots, and being honest about the number Build: Filter obvious automated traffic with named rules and count what each rule removed rather than dropping it silently. Show the filtered total beside the reported one. Generate a page listing what is collected, the retention figures, the opt-out and the filter rules, from the same configuration the code uses. Export aggregates as CSV. Trap: Silently discarding traffic you believe is automated moves the reported numbers for reasons nobody can audit, and six months later a decision gets made on a filter somebody wrote on a Tuesday and forgot. Count per rule, show the filtered total beside the reported one, and make a rule change visible as a step in the chart rather than a mystery. Done when: Every filter rule has its own counter and the filtered total is displayed beside the reported one. Adding or removing a rule is recorded with a date and appears on the compliance page. The compliance page is generated from the same configuration the collector and the sweep read, tested by changing one value and asserting all three change. Export produces aggregates as CSV and contains no raw event rows, tested. Do not build yet: Nothing after this. ### Out of scope (and why) - Session replay, which records what a person did on a page and is the opposite of an analytics build whose defence is that it does not watch individuals. - Cross-site or cross-device identity, which requires consent under every regime discussed here and cannot be reached by hashing harder. - Advertising attribution and conversion tracking, which the regulator's guidance says the analytics exception does not cover. - A hosted service for other people's sites, which makes you a processor for their visitors and brings contracts, sub-processor lists and breach duties with it. ### README must contain - That the analytics exception added to the electronic communications rules is narrow, with the regulator's own wording quoted and the date checked - That the French exemption caps the identifier at thirteen months and the collected data at twenty-five, and requires address truncation, with the date checked - That this collector cannot report people or returning visitors across days, and why that is deliberate - That the raw event rows expire on a clock and the reports survive their deletion - How to object, that it costs nothing and needs no account, and what happens on the next pageview ===== AGENTS.md ===== # Agent instructions · Cabin indie build - Read `README.md` and `BUILD_PLAN.md` before writing code. The stack is fixed: One endpoint on your own domain, no third-party host, no identifier shared between sites, sendBeacon with a keepalive fetch fallback, fired on visibility change, An append-only raw table with a life measured in hours, plus aggregates written on receipt, A daily hash of a rotating secret, a truncated address and the user agent, secret destroyed nightly, Visits, pageviews, paths and referrer hosts, with no segment describing a person. 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 - sendBeacon returns false rather than throwing when the browser declines to queue the request, and the Fetch standard caps the total inflight keepalive request bodies for a page at sixty four kibibytes across all of them rather than per request, so a page that batches a session into one large beacon, or fires several at once, loses them silently and your numbers are quietly low forever. Check the return value, keep payloads small, and do not send from unload, which does not fire when someone switches apps on a phone and costs the page its back and forward cache in some browsers. - Keeping the salt around for a week so you can report returning visitors quietly converts an unlinkable daily hash into a persistent identifier for everyone who visited, which is exactly the processing the exemptions you were relying on do not cover, and nothing in the interface will look any different. Destroy the secret on its schedule, record that you destroyed it, and accept that returning visitors across days is a number this design cannot produce. - Keeping raw events indefinitely because storage is cheap and you might re-aggregate later turns a compliant collector into a retained dataset about people, and the re-aggregation you were saving them for happens roughly never. Aggregate inside the write transaction, delete raw rows on a clock, and generate the published retention figures from the same file the deletion job reads so the two cannot drift. - Adding a breakdown by anything that describes the person rather than the visit, which is what every analytics product does the moment it has a second screen to fill, is exactly what the regulator's guidance says takes you outside the exception, and it will be added by somebody who read it as a harmless chart. Keep the report builder incapable of grouping by a visitor attribute, and put a test on the list of permitted dimensions. ===== BUILD_PLAN.md ===== # Build plan · Cabin A first-party analytics collector whose hard problems are getting the beacon to arrive at all on a phone, deriving a visit identifier that stops being linkable tomorrow, and aggregating at ingestion so the individual rows can expire without taking the reports with them. Phases are in dependency order. Each ends in a "Done when" list; treat an unticked item as a blocker, not a note. ## Phase 1 · A beacon that actually arrives The event reaches the collector from a phone that was switched away from, not closed. ### Steps 1. Send on visibility change, never on unload It is the only event that fires when someone switches apps. Files: `public/collect.js`, `tests/no-unload.test.js` 2. Check the sendBeacon return value and fall back A silent false is how a collector under-reports for months. Files: `public/collect.js` 3. Keep the payload small and assert the size The keepalive budget is shared across every inflight request on the page. Files: `public/collect.js`, `tests/payload-size.test.js` ### Done when - [ ] A refused beacon is detected and retried - [ ] No unload or beforeunload handler exists in the bundle - [ ] Payload size is bounded and asserted - [ ] An event arrives after switching apps on a phone ### Watch out - sendBeacon returns false rather than throwing when the browser declines to queue the request, and the Fetch standard caps the total inflight keepalive request bodies for a page at sixty four kibibytes across all of them rather than per request, so a page that batches a session into one large beacon, or fires several at once, loses them silently and your numbers are quietly low forever. Check the return value, keep payloads small, and do not send from unload, which does not fire when someone switches apps on a phone and costs the page its back and forward cache in some browsers. ## Phase 2 · An identifier that is useless by tomorrow The visit hash cannot be linked across days, and there is nowhere to store an address. ### Steps 1. Truncate the address before it is used at all Not on the way into the table, because it should never reach a table. Files: `src/identity.js`, `tests/identity.test.js` 2. Rotate and destroy the secret on a schedule The destruction is the property the whole design rests on. Files: `src/salt.js`, `tests/salt.test.js` 3. Give the schema nowhere to put an address The enforcement is the absent column, not the careful code. Files: `migrations/001_init.sql` ### Done when - [ ] No column can hold a full address - [ ] Yesterday's inputs hash differently today - [ ] Secret destruction is scheduled and recorded - [ ] Truncation happens before hashing ### Watch out - Keeping the salt around for a week so you can report returning visitors quietly converts an unlinkable daily hash into a persistent identifier for everyone who visited, which is exactly the processing the exemptions you were relying on do not cover, and nothing in the interface will look any different. Destroy the secret on its schedule, record that you destroyed it, and accept that returning visitors across days is a number this design cannot produce. ## Phase 3 · Aggregate on receipt, expire the rest Deleting every raw row leaves the reports intact. ### Steps 1. Aggregate inside the same transaction as the raw write A nightly rollup job is a nightly window in which the raw rows must survive. Files: `src/ingest.js`, `tests/aggregate.test.js` 2. Sweep raw rows on a clock and record the counts So that 'we delete after a day' is a thing you can show. Files: `src/retention.js`, `tests/retention.test.js` 3. Generate the privacy notice from the retention config The published number and the enforced number must be one number. Files: `config/retention.json`, `src/notice.js` ### Done when - [ ] Reports survive deleting every raw row - [ ] The sweep removes rows past the configured age - [ ] The notice is generated from the retention config - [ ] Deletion counts are recorded ### Watch out - Keeping raw events indefinitely because storage is cheap and you might re-aggregate later turns a compliant collector into a retained dataset about people, and the re-aggregation you were saving them for happens roughly never. Aggregate inside the write transaction, delete raw rows on a clock, and generate the published retention figures from the same file the deletion job reads so the two cannot drift. ## Phase 4 · Objecting costs nothing, and nothing segments a person Opting out is one click with no account, and no report groups by an attribute of the visitor. ### Steps 1. Fix the permitted dimensions in code and test the list So the harmless chart cannot be added without someone noticing. Files: `src/reports/dimensions.js`, `tests/dimensions.test.js` 2. Say visits, never users You gave up the ability to count people; say so everywhere. Files: `src/views/report.ejs`, `tests/wording.test.js` 3. Make objecting one click with no account Free and simple is the standard, not a settings page. Files: `src/objections.js`, `src/views/optout.ejs` ### Done when - [ ] Forbidden groupings are rejected by the report layer - [ ] The word users appears nowhere in the interface - [ ] Objecting stops collection on the next pageview - [ ] The opt-out needs no account or payment ### Watch out - Adding a breakdown by anything that describes the person rather than the visit, which is what every analytics product does the moment it has a second screen to fill, is exactly what the regulator's guidance says takes you outside the exception, and it will be added by somebody who read it as a harmless chart. Keep the report builder incapable of grouping by a visitor attribute, and put a test on the list of permitted dimensions. ## Not in this build - Session replay. - Cross-site or cross-device identity. - Advertising attribution and conversion tracking. - Hosting analytics for other people's sites. ## After v1, if you want it - Server-side ingestion from your own access log, which removes the browser script entirely - A public dashboard for one site, which is a read-only view and forces the wording to be honest - Alerting on a traffic collapse, which is the one thing a small site owner actually wants a notification for ===== .env.example ===== # Copy to .env and fill in. Never commit .env; this file documents it. # Required · secret. Raw events with a short life, aggregates with a long one. DATABASE_URL=postgres://localhost:5432/analytics # Required. How often the hashing secret is replaced and the old one destroyed. SALT_ROTATE_HOURS=24 # Required. After this the raw events are deleted. The reports must not notice. RAW_MAX_AGE_HOURS=24 # Required. Check the figure your regime publishes rather than trusting this default. AGGREGATE_MAX_AGE_MONTHS=25 # Required. Must be on the same registrable domain as the measured site. COLLECTOR_ORIGIN=https://stats.example.com # Required. Each beacon. The keepalive budget is shared across every inflight request on the page. MAX_PAYLOAD_BYTES=1024 # Required. Reachable with no account and no payment. It is published in the notice. OPTOUT_URL=https://stats.example.com/objection
You are building a production product version of Cabin. 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 ===== # Cabin · product brief ## Problem The visible privacy web analytics loop is buildable, but a credible replacement needs more than the first screen. Cabin earns its keep through ingestion, storage, reliability, so expect a weekend or multi-day build and a narrower personal scope. ## Product outcome A collector whose beacon arrives from a phone, whose identifier is unusable by the following day, whose raw rows can all be deleted without moving a single reported figure, and whose published notice is generated from the configuration that actually runs. ## Target user A builder who needs a maintainable product foundation, not a one-off demo. ## Required capabilities - deploy target - ClickHouse or SQLite for low volume - first-party tracking script ## Explicit non-goals for v1 - Session replay. - Cross-site or cross-device identity. - Advertising attribution and conversion tracking. - Hosting analytics for other people's sites. - bot and identity resolution - session replay privacy tooling - enterprise governance and integrations - high-volume ingestion and retention ## Success criteria - No column is confirmed capable of holding a full address - Reports are confirmed unchanged after deleting every raw row - The secret is confirmed destroyed on schedule with the destruction recorded - The report layer is confirmed to reject any dimension outside the fixed list - The published notice is confirmed generated from the running configuration ===== BRIEF.md ===== # Build brief · Cabin 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 first-party web analytics collector that counts visits without identifying anybody and can show its working to replace Cabin. 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 'Cookieless' is a marketing word, not a legal conclusion, and if you design as though it were one you will build something you have to unpick later. The United Kingdom changed in 2025. The Data (Use and Access) Act added a statistical purposes exception to the electronic communications rules, and the regulator's guidance, finalised in April 2026, calls it the analytics exception. It is narrow. The sole purpose must be collecting statistics about how the service is used with a view to improving it; the guidance says plainly that it 'is about how your service is used, not about who uses it', that it is 'not for identifying, tracking or monitoring people', and that it does not cover advertising. You must give clear information and 'a simple means of objecting, free of charge', and you must not keep individual-level data longer than the aggregation needs. Checked on the regulator's guidance pages on 15 September 2026. France publishes an exemption with harder numbers: the identifier lives at most thirteen months and is not extended by a new visit, the data collected through it at most twenty-five months, the last byte of the address is truncated, and nothing is cross-referenced with other processing or passed to a third party. Checked on the CNIL's audience measurement page, same date. So the design is already decided. Aggregate at ingestion, let the individual rows expire on a clock, publish an objection that costs nothing, never segment a visitor by anything that describes the person, and never let two sites share an identifier. The absence of a visitors table is not a missing feature, it is the argument. And do not promise accuracy you have given up. A collector that refuses to identify people cannot tell you how many people came. It can tell you how many visits there were. Say visits everywhere. ### Stack (fixed, do not substitute) - Collector: One endpoint on your own domain, no third-party host, no identifier shared between sites · a first-party aggregate-only collector is the only shape the analytics exception has room for - Beacon: sendBeacon with a keepalive fetch fallback, fired on visibility change · the unload event costs the page its back and forward cache and does not fire on a phone - Storage: An append-only raw table with a life measured in hours, plus aggregates written on receipt · the aggregates are the product and the raw rows are a liability with a timer on them - Identity: A daily hash of a rotating secret, a truncated address and the user agent, secret destroyed nightly · yesterday's hashes stop being linkable to today's, which is the entire design - Reports: Visits, pageviews, paths and referrer hosts, with no segment describing a person · the exception covers how the service is used, not who uses it ### Data model (create this before Phase 1) - events(id, at, site_id, path, referrer_host, viewport_bucket, visit_hash, country) · raw, short-lived, and the only table holding anything per visitor - daily_salt(day, secret, created_at, destroyed_at) · rotated and destroyed on a schedule, because a salt that outlives its day is a persistent identifier - agg_pageviews(site_id, day, path, views, visits) and agg_referrers(site_id, day, referrer_host, visits) · written on receipt, not computed nightly from raw - agg_entry(site_id, day, entry_path, visits) · enough to answer where people arrive, and nothing about who they are - retention(site_id, raw_max_age_hours, aggregate_max_age_months) · the numbers a regulator asks about, in one readable place - objections(id, kind, value, created_at) · the free and simple opt-out, recorded so that honouring it can be demonstrated - bot_filters(id, rule, added_at) and agg_filtered(site_id, day, rule, views) · what was removed and by which rule, counted separately rather than silently dropped - There is no visitors table, no account identifier and no cross-site identifier in this schema. That absence is the compliance argument, and adding one quietly ends it. ### Phase 1 · A beacon that actually arrives Build: Send on the visibility change event when the document becomes hidden, using sendBeacon and falling back to a fetch with keepalive. Keep each payload small and check the return value. Add no unload or beforeunload handler anywhere in the script. Serve the script from your own domain. Trap: sendBeacon returns false rather than throwing when the browser declines to queue the request, and the Fetch standard caps the total inflight keepalive request bodies for a page at sixty four kibibytes across all of them rather than per request, so a page that batches a session into one large beacon, or fires several at once, loses them silently and your numbers are quietly low forever. Check the return value, keep payloads small, and do not send from unload, which does not fire when someone switches apps on a phone and costs the page its back and forward cache in some browsers. Done when: A false return from sendBeacon is detected and the event is retried through the fallback, tested with a stub. The built script contains no unload or beforeunload handler, asserted by a test that greps the bundle. Each payload is under a stated byte size and the size is asserted in a test. Switching away from the page on a real phone produces an event, checked once by hand and recorded in the README. Do not build yet: No identifier, aggregation, retention, reports or opt-out yet. ### Phase 2 · An identifier that is useless by tomorrow Build: Generate a secret per day. Truncate the address before it is used, then hash the secret, the truncated address, the user agent and the site together to make the visit hash. Never write the address to any table. Destroy the day's secret on a schedule and record the destruction. Trap: Keeping the salt around for a week so you can report returning visitors quietly converts an unlinkable daily hash into a persistent identifier for everyone who visited, which is exactly the processing the exemptions you were relying on do not cover, and nothing in the interface will look any different. Destroy the secret on its schedule, record that you destroyed it, and accept that returning visitors across days is a number this design cannot produce. Done when: No table has a column capable of holding a full address, verified by a test that inspects the schema. Replaying yesterday's raw inputs under today's secret produces different hashes, tested. Secret destruction is recorded with a time and a test asserts no secret older than the configured age exists. The address is truncated before it reaches the hash, tested against a fixture. Do not build yet: No aggregation, retention job, reports or opt-out yet. ### Phase 3 · Aggregate on receipt, expire the rest Build: Update the aggregate tables in the same transaction that writes the raw event. Give raw rows a maximum age in hours and run a job that deletes and records the counts. Put the retention numbers in one configuration file that the privacy notice is generated from. Trap: Keeping raw events indefinitely because storage is cheap and you might re-aggregate later turns a compliant collector into a retained dataset about people, and the re-aggregation you were saving them for happens roughly never. Aggregate inside the write transaction, delete raw rows on a clock, and generate the published retention figures from the same file the deletion job reads so the two cannot drift. Done when: Deleting every raw row changes no figure in any report, tested by comparing reports before and after a forced sweep. Raw rows older than the configured hours are absent after the job runs, tested against a fixed clock. The privacy notice text is generated from the retention configuration, so changing the number changes both, tested. The sweep records how many rows it deleted and when. Do not build yet: No opt-out, reports, bot handling or export yet. ### Phase 4 · Objecting costs nothing, and nothing segments a person Build: Publish an objection page reachable without an account or payment that stores a preference and stops collection on the next pageview. Build reports for visits, pageviews, paths, referrer hosts and entry pages. Use the word visits throughout. Refuse to add any breakdown that describes the person rather than the visit. Trap: Adding a breakdown by anything that describes the person rather than the visit, which is what every analytics product does the moment it has a second screen to fill, is exactly what the regulator's guidance says takes you outside the exception, and it will be added by somebody who read it as a harmless chart. Keep the report builder incapable of grouping by a visitor attribute, and put a test on the list of permitted dimensions. Done when: The report layer accepts only a fixed list of dimensions and rejects anything else, tested by attempting a forbidden grouping. The word users does not appear in the interface, asserted by a test over the rendered pages. An objection stops collection on the next pageview and is recorded with a time, tested end to end. The objection page needs no account, no payment and no third-party script. Do not build yet: No bot handling, export or published compliance page yet. ### Phase 5 · Bots, and being honest about the number Build: Filter obvious automated traffic with named rules and count what each rule removed rather than dropping it silently. Show the filtered total beside the reported one. Generate a page listing what is collected, the retention figures, the opt-out and the filter rules, from the same configuration the code uses. Export aggregates as CSV. Trap: Silently discarding traffic you believe is automated moves the reported numbers for reasons nobody can audit, and six months later a decision gets made on a filter somebody wrote on a Tuesday and forgot. Count per rule, show the filtered total beside the reported one, and make a rule change visible as a step in the chart rather than a mystery. Done when: Every filter rule has its own counter and the filtered total is displayed beside the reported one. Adding or removing a rule is recorded with a date and appears on the compliance page. The compliance page is generated from the same configuration the collector and the sweep read, tested by changing one value and asserting all three change. Export produces aggregates as CSV and contains no raw event rows, tested. Do not build yet: Nothing after this. ### Out of scope (and why) - Session replay, which records what a person did on a page and is the opposite of an analytics build whose defence is that it does not watch individuals. - Cross-site or cross-device identity, which requires consent under every regime discussed here and cannot be reached by hashing harder. - Advertising attribution and conversion tracking, which the regulator's guidance says the analytics exception does not cover. - A hosted service for other people's sites, which makes you a processor for their visitors and brings contracts, sub-processor lists and breach duties with it. ### README must contain - That the analytics exception added to the electronic communications rules is narrow, with the regulator's own wording quoted and the date checked - That the French exemption caps the identifier at thirteen months and the collected data at twenty-five, and requires address truncation, with the date checked - That this collector cannot report people or returning visitors across days, and why that is deliberate - That the raw event rows expire on a clock and the reports survive their deletion - How to object, that it costs nothing and needs no account, and what happens on the next pageview ===== ARCHITECTURE.md ===== # Architecture · Cabin ## Stack | Part | Choice | Why | | --- | --- | --- | | Collector | One endpoint on your own domain, no third-party host, no identifier shared between sites | a first-party aggregate-only collector is the only shape the analytics exception has room for | | Beacon | sendBeacon with a keepalive fetch fallback, fired on visibility change | the unload event costs the page its back and forward cache and does not fire on a phone | | Storage | An append-only raw table with a life measured in hours, plus aggregates written on receipt | the aggregates are the product and the raw rows are a liability with a timer on them | | Identity | A daily hash of a rotating secret, a truncated address and the user agent, secret destroyed nightly | yesterday's hashes stop being linkable to today's, which is the entire design | | Reports | Visits, pageviews, paths and referrer hosts, with no segment describing a person | the exception covers how the service is used, not who uses it | ## Modules Each module has one owner concern and a documented way to replace it. | Module | Owns | How to replace it | | --- | --- | --- | | Beacon | The browser script and its delivery. | Any transport, provided it fires on visibility change and never on unload. | | Identity | Truncation, daily secret and the visit hash. | Any hash, provided the secret is destroyed on schedule. | | Ingest | Raw write and aggregate update in one transaction. | Any store, provided aggregation is not deferred to a nightly job. | | Retention | The sweep and the published figures. | Any scheduler, provided one file drives both the sweep and the notice. | | Reports | A fixed dimension list and the wording. | Not swappable for anything that can group by a visitor attribute. | ## Configuration Every runtime setting is an environment variable documented in `.env.example`, validated at startup, with a safe local default wherever one exists. - `DATABASE_URL` · required, secret · Raw events with a short life, aggregates with a long one. - `SALT_ROTATE_HOURS` · required · How often the hashing secret is replaced and the old one destroyed. - `RAW_MAX_AGE_HOURS` · required · After this the raw events are deleted. The reports must not notice. - `AGGREGATE_MAX_AGE_MONTHS` · required · Check the figure your regime publishes rather than trusting this default. - `COLLECTOR_ORIGIN` · required · Must be on the same registrable domain as the measured site. - `MAX_PAYLOAD_BYTES` · required · Each beacon. The keepalive budget is shared across every inflight request on the page. - `OPTOUT_URL` · required · Reachable with no account and no payment. It is published in the notice. ## 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 · Cabin product build - Read `PRODUCT.md` and `ARCHITECTURE.md` before changing code. The stack is fixed: One endpoint on your own domain, no third-party host, no identifier shared between sites, sendBeacon with a keepalive fetch fallback, fired on visibility change, An append-only raw table with a life measured in hours, plus aggregates written on receipt, A daily hash of a rotating secret, a truncated address and the user agent, secret destroyed nightly, Visits, pageviews, paths and referrer hosts, with no segment describing a person. - 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 - sendBeacon returns false rather than throwing when the browser declines to queue the request, and the Fetch standard caps the total inflight keepalive request bodies for a page at sixty four kibibytes across all of them rather than per request, so a page that batches a session into one large beacon, or fires several at once, loses them silently and your numbers are quietly low forever. Check the return value, keep payloads small, and do not send from unload, which does not fire when someone switches apps on a phone and costs the page its back and forward cache in some browsers. - Keeping the salt around for a week so you can report returning visitors quietly converts an unlinkable daily hash into a persistent identifier for everyone who visited, which is exactly the processing the exemptions you were relying on do not cover, and nothing in the interface will look any different. Destroy the secret on its schedule, record that you destroyed it, and accept that returning visitors across days is a number this design cannot produce. - Keeping raw events indefinitely because storage is cheap and you might re-aggregate later turns a compliant collector into a retained dataset about people, and the re-aggregation you were saving them for happens roughly never. Aggregate inside the write transaction, delete raw rows on a clock, and generate the published retention figures from the same file the deletion job reads so the two cannot drift. - Adding a breakdown by anything that describes the person rather than the visit, which is what every analytics product does the moment it has a second screen to fill, is exactly what the regulator's guidance says takes you outside the exception, and it will be added by somebody who read it as a harmless chart. Keep the report builder incapable of grouping by a visitor attribute, and put a test on the list of permitted dimensions. - Silently discarding traffic you believe is automated moves the reported numbers for reasons nobody can audit, and six months later a decision gets made on a filter somebody wrote on a Tuesday and forgot. Count per rule, show the filtered total beside the reported one, and make a rule change visible as a step in the chart rather than a mystery. ===== MILESTONES.md ===== # Delivery milestones · Cabin Estimated effort: **weekend** for the indie phases; the production-only milestones add the trust and operability layer. ## M1 · A beacon that actually arrives The event reaches the collector from a phone that was switched away from, not closed. ### Steps 1. Send on visibility change, never on unload It is the only event that fires when someone switches apps. Files: `public/collect.js`, `tests/no-unload.test.js` 2. Check the sendBeacon return value and fall back A silent false is how a collector under-reports for months. Files: `public/collect.js` 3. Keep the payload small and assert the size The keepalive budget is shared across every inflight request on the page. Files: `public/collect.js`, `tests/payload-size.test.js` ### Done when - [ ] A refused beacon is detected and retried - [ ] No unload or beforeunload handler exists in the bundle - [ ] Payload size is bounded and asserted - [ ] An event arrives after switching apps on a phone ### Watch out - sendBeacon returns false rather than throwing when the browser declines to queue the request, and the Fetch standard caps the total inflight keepalive request bodies for a page at sixty four kibibytes across all of them rather than per request, so a page that batches a session into one large beacon, or fires several at once, loses them silently and your numbers are quietly low forever. Check the return value, keep payloads small, and do not send from unload, which does not fire when someone switches apps on a phone and costs the page its back and forward cache in some browsers. ## M2 · An identifier that is useless by tomorrow The visit hash cannot be linked across days, and there is nowhere to store an address. ### Steps 1. Truncate the address before it is used at all Not on the way into the table, because it should never reach a table. Files: `src/identity.js`, `tests/identity.test.js` 2. Rotate and destroy the secret on a schedule The destruction is the property the whole design rests on. Files: `src/salt.js`, `tests/salt.test.js` 3. Give the schema nowhere to put an address The enforcement is the absent column, not the careful code. Files: `migrations/001_init.sql` ### Done when - [ ] No column can hold a full address - [ ] Yesterday's inputs hash differently today - [ ] Secret destruction is scheduled and recorded - [ ] Truncation happens before hashing ### Watch out - Keeping the salt around for a week so you can report returning visitors quietly converts an unlinkable daily hash into a persistent identifier for everyone who visited, which is exactly the processing the exemptions you were relying on do not cover, and nothing in the interface will look any different. Destroy the secret on its schedule, record that you destroyed it, and accept that returning visitors across days is a number this design cannot produce. ## M3 · Aggregate on receipt, expire the rest Deleting every raw row leaves the reports intact. ### Steps 1. Aggregate inside the same transaction as the raw write A nightly rollup job is a nightly window in which the raw rows must survive. Files: `src/ingest.js`, `tests/aggregate.test.js` 2. Sweep raw rows on a clock and record the counts So that 'we delete after a day' is a thing you can show. Files: `src/retention.js`, `tests/retention.test.js` 3. Generate the privacy notice from the retention config The published number and the enforced number must be one number. Files: `config/retention.json`, `src/notice.js` ### Done when - [ ] Reports survive deleting every raw row - [ ] The sweep removes rows past the configured age - [ ] The notice is generated from the retention config - [ ] Deletion counts are recorded ### Watch out - Keeping raw events indefinitely because storage is cheap and you might re-aggregate later turns a compliant collector into a retained dataset about people, and the re-aggregation you were saving them for happens roughly never. Aggregate inside the write transaction, delete raw rows on a clock, and generate the published retention figures from the same file the deletion job reads so the two cannot drift. ## M4 · Objecting costs nothing, and nothing segments a person Opting out is one click with no account, and no report groups by an attribute of the visitor. ### Steps 1. Fix the permitted dimensions in code and test the list So the harmless chart cannot be added without someone noticing. Files: `src/reports/dimensions.js`, `tests/dimensions.test.js` 2. Say visits, never users You gave up the ability to count people; say so everywhere. Files: `src/views/report.ejs`, `tests/wording.test.js` 3. Make objecting one click with no account Free and simple is the standard, not a settings page. Files: `src/objections.js`, `src/views/optout.ejs` ### Done when - [ ] Forbidden groupings are rejected by the report layer - [ ] The word users appears nowhere in the interface - [ ] Objecting stops collection on the next pageview - [ ] The opt-out needs no account or payment ### Watch out - Adding a breakdown by anything that describes the person rather than the visit, which is what every analytics product does the moment it has a second screen to fill, is exactly what the regulator's guidance says takes you outside the exception, and it will be added by somebody who read it as a harmless chart. Keep the report builder incapable of grouping by a visitor attribute, and put a test on the list of permitted dimensions. ## M5 · Bots, and being honest about the number (production only) What was filtered is visible, and the published compliance page comes from the running configuration. ### Steps 1. Count per filter rule, never drop silently A number that moved for an unknown reason is worse than a wrong number. Files: `src/bots.js`, `src/views/report.ejs` 2. Generate the compliance page from the live configuration What you publish and what you enforce should be one file. Files: `src/compliance.js`, `tests/compliance.test.js` 3. Export aggregates only There should be nothing else left to export by then. Files: `src/export.js` ### Done when - [ ] Filtered counts are shown per rule - [ ] Rule changes are dated and published - [ ] One config drives collector, sweep and compliance page - [ ] Export contains no raw event rows ### Watch out - Silently discarding traffic you believe is automated moves the reported numbers for reasons nobody can audit, and six months later a decision gets made on a filter somebody wrote on a Tuesday and forgot. Count per rule, show the filtered total beside the reported one, and make a rule change visible as a step in the chart rather than a mystery. ===== OPERATIONS.md ===== # Operations · Cabin ## Backup Aggregates only. The raw table should never be in a backup, because a backup is exactly the retained copy the retention rule exists to prevent, and restoring one would resurrect deleted rows. ## Restore Restore aggregates and confirm the sweep, the secret rotation and the notice generation all start from the restored configuration. Check that no raw rows came back with it. Do a restore drill before the first real user, and write the date here when it passes. ## Monitoring Beacon failures reported by the fallback, the age of the oldest raw row, the time since the last secret destruction, and the filtered proportion. A rising oldest-raw-row age means the sweep has stopped and the clock is running. ## Incident checklist The incident here is retaining something you said you did not, and it is discovered by somebody reading your own notice. The retention configuration, the deletion counts and the destruction record are the three things that turn that into a five minute answer. 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 - [ ] No column is confirmed capable of holding a full address - [ ] Reports are confirmed unchanged after deleting every raw row - [ ] The secret is confirmed destroyed on schedule with the destruction recorded - [ ] The report layer is confirmed to reject any dimension outside the fixed list - [ ] The published notice is confirmed generated from the running configuration ## Launch constraint Do not market omitted Cabin capabilities as implemented. The non-goals in `PRODUCT.md` remain user-visible limitations until they are deliberately delivered. ===== .env.example ===== # Copy to .env and fill in. Never commit .env; this file documents it. # Required · secret. Raw events with a short life, aggregates with a long one. DATABASE_URL=postgres://localhost:5432/analytics # Required. How often the hashing secret is replaced and the old one destroyed. SALT_ROTATE_HOURS=24 # Required. After this the raw events are deleted. The reports must not notice. RAW_MAX_AGE_HOURS=24 # Required. Check the figure your regime publishes rather than trusting this default. AGGREGATE_MAX_AGE_MONTHS=25 # Required. Must be on the same registrable domain as the measured site. COLLECTOR_ORIGIN=https://stats.example.com # Required. Each beacon. The keepalive budget is shared across every inflight request on the page. MAX_PAYLOAD_BYTES=1024 # Required. Reachable with no account and no payment. It is published in the notice. OPTOUT_URL=https://stats.example.com/objection
# Cabin · indie build A first-party analytics collector whose hard problems are getting the beacon to arrive at all on a phone, deriving a visit identifier that stops being linkable tomorrow, and aggregating at ingestion so the individual rows can expire without taking the reports with them. 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 | | --- | --- | --- | | Collector | One endpoint on your own domain, no third-party host, no identifier shared between sites | a first-party aggregate-only collector is the only shape the analytics exception has room for | | Beacon | sendBeacon with a keepalive fetch fallback, fired on visibility change | the unload event costs the page its back and forward cache and does not fire on a phone | | Storage | An append-only raw table with a life measured in hours, plus aggregates written on receipt | the aggregates are the product and the raw rows are a liability with a timer on them | | Identity | A daily hash of a rotating secret, a truncated address and the user agent, secret destroyed nightly | yesterday's hashes stop being linkable to today's, which is the entire design | | Reports | Visits, pageviews, paths and referrer hosts, with no segment describing a person | the exception covers how the service is used, not who uses it | ## 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 collector, the sweep and the reports. - Get it: nodejs.org or your version manager. - Verify: node --version prints v22 or higher - [ ] **A hostname on the same registrable domain as the site you are measuring** · free if you own the domain - Why: A first-party collector is the shape the exception has room for, and a third-party host reintroduces every question you were avoiding. - Get it: Add a subdomain of the site's own domain pointing at wherever this runs. - Verify: The collector endpoint answers on that hostname and the script loads from it - [ ] **A real phone to test the beacon on** · free - Why: Switching apps rather than closing a tab is the case that unload never covered, and it is not reproducible in a desktop browser. - Get it: Any phone that can reach the site. - Verify: Loading a page and switching to another app produces an event - [ ] **Which regime you are writing the notice for** · free - Why: The retention figures and the opt-out wording differ, and picking after you have built the sweep means picking whatever the sweep already does. - Get it: Decide which visitors you are designing for, read that regulator's current published guidance, and write the retention numbers and the objection mechanism into the configuration file with the date you checked. - Verify: The configuration carries the numbers and the date, and the notice is generated from it ## 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: - Session replay. - Cross-site or cross-device identity. - Advertising attribution and conversion tracking. - Hosting analytics for other people's sites. - bot and identity resolution - session replay privacy tooling - enterprise governance and integrations - high-volume ingestion and retention If one of those is essential to you, that is the reason to keep paying for Cabin, and the README should say so rather than pretend.
# Build brief · Cabin 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 first-party web analytics collector that counts visits without identifying anybody and can show its working to replace Cabin. 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 'Cookieless' is a marketing word, not a legal conclusion, and if you design as though it were one you will build something you have to unpick later. The United Kingdom changed in 2025. The Data (Use and Access) Act added a statistical purposes exception to the electronic communications rules, and the regulator's guidance, finalised in April 2026, calls it the analytics exception. It is narrow. The sole purpose must be collecting statistics about how the service is used with a view to improving it; the guidance says plainly that it 'is about how your service is used, not about who uses it', that it is 'not for identifying, tracking or monitoring people', and that it does not cover advertising. You must give clear information and 'a simple means of objecting, free of charge', and you must not keep individual-level data longer than the aggregation needs. Checked on the regulator's guidance pages on 15 September 2026. France publishes an exemption with harder numbers: the identifier lives at most thirteen months and is not extended by a new visit, the data collected through it at most twenty-five months, the last byte of the address is truncated, and nothing is cross-referenced with other processing or passed to a third party. Checked on the CNIL's audience measurement page, same date. So the design is already decided. Aggregate at ingestion, let the individual rows expire on a clock, publish an objection that costs nothing, never segment a visitor by anything that describes the person, and never let two sites share an identifier. The absence of a visitors table is not a missing feature, it is the argument. And do not promise accuracy you have given up. A collector that refuses to identify people cannot tell you how many people came. It can tell you how many visits there were. Say visits everywhere. ### Stack (fixed, do not substitute) - Collector: One endpoint on your own domain, no third-party host, no identifier shared between sites · a first-party aggregate-only collector is the only shape the analytics exception has room for - Beacon: sendBeacon with a keepalive fetch fallback, fired on visibility change · the unload event costs the page its back and forward cache and does not fire on a phone - Storage: An append-only raw table with a life measured in hours, plus aggregates written on receipt · the aggregates are the product and the raw rows are a liability with a timer on them - Identity: A daily hash of a rotating secret, a truncated address and the user agent, secret destroyed nightly · yesterday's hashes stop being linkable to today's, which is the entire design - Reports: Visits, pageviews, paths and referrer hosts, with no segment describing a person · the exception covers how the service is used, not who uses it ### Data model (create this before Phase 1) - events(id, at, site_id, path, referrer_host, viewport_bucket, visit_hash, country) · raw, short-lived, and the only table holding anything per visitor - daily_salt(day, secret, created_at, destroyed_at) · rotated and destroyed on a schedule, because a salt that outlives its day is a persistent identifier - agg_pageviews(site_id, day, path, views, visits) and agg_referrers(site_id, day, referrer_host, visits) · written on receipt, not computed nightly from raw - agg_entry(site_id, day, entry_path, visits) · enough to answer where people arrive, and nothing about who they are - retention(site_id, raw_max_age_hours, aggregate_max_age_months) · the numbers a regulator asks about, in one readable place - objections(id, kind, value, created_at) · the free and simple opt-out, recorded so that honouring it can be demonstrated - bot_filters(id, rule, added_at) and agg_filtered(site_id, day, rule, views) · what was removed and by which rule, counted separately rather than silently dropped - There is no visitors table, no account identifier and no cross-site identifier in this schema. That absence is the compliance argument, and adding one quietly ends it. ### Phase 1 · A beacon that actually arrives Build: Send on the visibility change event when the document becomes hidden, using sendBeacon and falling back to a fetch with keepalive. Keep each payload small and check the return value. Add no unload or beforeunload handler anywhere in the script. Serve the script from your own domain. Trap: sendBeacon returns false rather than throwing when the browser declines to queue the request, and the Fetch standard caps the total inflight keepalive request bodies for a page at sixty four kibibytes across all of them rather than per request, so a page that batches a session into one large beacon, or fires several at once, loses them silently and your numbers are quietly low forever. Check the return value, keep payloads small, and do not send from unload, which does not fire when someone switches apps on a phone and costs the page its back and forward cache in some browsers. Done when: A false return from sendBeacon is detected and the event is retried through the fallback, tested with a stub. The built script contains no unload or beforeunload handler, asserted by a test that greps the bundle. Each payload is under a stated byte size and the size is asserted in a test. Switching away from the page on a real phone produces an event, checked once by hand and recorded in the README. Do not build yet: No identifier, aggregation, retention, reports or opt-out yet. ### Phase 2 · An identifier that is useless by tomorrow Build: Generate a secret per day. Truncate the address before it is used, then hash the secret, the truncated address, the user agent and the site together to make the visit hash. Never write the address to any table. Destroy the day's secret on a schedule and record the destruction. Trap: Keeping the salt around for a week so you can report returning visitors quietly converts an unlinkable daily hash into a persistent identifier for everyone who visited, which is exactly the processing the exemptions you were relying on do not cover, and nothing in the interface will look any different. Destroy the secret on its schedule, record that you destroyed it, and accept that returning visitors across days is a number this design cannot produce. Done when: No table has a column capable of holding a full address, verified by a test that inspects the schema. Replaying yesterday's raw inputs under today's secret produces different hashes, tested. Secret destruction is recorded with a time and a test asserts no secret older than the configured age exists. The address is truncated before it reaches the hash, tested against a fixture. Do not build yet: No aggregation, retention job, reports or opt-out yet. ### Phase 3 · Aggregate on receipt, expire the rest Build: Update the aggregate tables in the same transaction that writes the raw event. Give raw rows a maximum age in hours and run a job that deletes and records the counts. Put the retention numbers in one configuration file that the privacy notice is generated from. Trap: Keeping raw events indefinitely because storage is cheap and you might re-aggregate later turns a compliant collector into a retained dataset about people, and the re-aggregation you were saving them for happens roughly never. Aggregate inside the write transaction, delete raw rows on a clock, and generate the published retention figures from the same file the deletion job reads so the two cannot drift. Done when: Deleting every raw row changes no figure in any report, tested by comparing reports before and after a forced sweep. Raw rows older than the configured hours are absent after the job runs, tested against a fixed clock. The privacy notice text is generated from the retention configuration, so changing the number changes both, tested. The sweep records how many rows it deleted and when. Do not build yet: No opt-out, reports, bot handling or export yet. ### Phase 4 · Objecting costs nothing, and nothing segments a person Build: Publish an objection page reachable without an account or payment that stores a preference and stops collection on the next pageview. Build reports for visits, pageviews, paths, referrer hosts and entry pages. Use the word visits throughout. Refuse to add any breakdown that describes the person rather than the visit. Trap: Adding a breakdown by anything that describes the person rather than the visit, which is what every analytics product does the moment it has a second screen to fill, is exactly what the regulator's guidance says takes you outside the exception, and it will be added by somebody who read it as a harmless chart. Keep the report builder incapable of grouping by a visitor attribute, and put a test on the list of permitted dimensions. Done when: The report layer accepts only a fixed list of dimensions and rejects anything else, tested by attempting a forbidden grouping. The word users does not appear in the interface, asserted by a test over the rendered pages. An objection stops collection on the next pageview and is recorded with a time, tested end to end. The objection page needs no account, no payment and no third-party script. Do not build yet: No bot handling, export or published compliance page yet. ### Phase 5 · Bots, and being honest about the number Build: Filter obvious automated traffic with named rules and count what each rule removed rather than dropping it silently. Show the filtered total beside the reported one. Generate a page listing what is collected, the retention figures, the opt-out and the filter rules, from the same configuration the code uses. Export aggregates as CSV. Trap: Silently discarding traffic you believe is automated moves the reported numbers for reasons nobody can audit, and six months later a decision gets made on a filter somebody wrote on a Tuesday and forgot. Count per rule, show the filtered total beside the reported one, and make a rule change visible as a step in the chart rather than a mystery. Done when: Every filter rule has its own counter and the filtered total is displayed beside the reported one. Adding or removing a rule is recorded with a date and appears on the compliance page. The compliance page is generated from the same configuration the collector and the sweep read, tested by changing one value and asserting all three change. Export produces aggregates as CSV and contains no raw event rows, tested. Do not build yet: Nothing after this. ### Out of scope (and why) - Session replay, which records what a person did on a page and is the opposite of an analytics build whose defence is that it does not watch individuals. - Cross-site or cross-device identity, which requires consent under every regime discussed here and cannot be reached by hashing harder. - Advertising attribution and conversion tracking, which the regulator's guidance says the analytics exception does not cover. - A hosted service for other people's sites, which makes you a processor for their visitors and brings contracts, sub-processor lists and breach duties with it. ### README must contain - That the analytics exception added to the electronic communications rules is narrow, with the regulator's own wording quoted and the date checked - That the French exemption caps the identifier at thirteen months and the collected data at twenty-five, and requires address truncation, with the date checked - That this collector cannot report people or returning visitors across days, and why that is deliberate - That the raw event rows expire on a clock and the reports survive their deletion - How to object, that it costs nothing and needs no account, and what happens on the next pageview
# Agent instructions · Cabin indie build - Read `README.md` and `BUILD_PLAN.md` before writing code. The stack is fixed: One endpoint on your own domain, no third-party host, no identifier shared between sites, sendBeacon with a keepalive fetch fallback, fired on visibility change, An append-only raw table with a life measured in hours, plus aggregates written on receipt, A daily hash of a rotating secret, a truncated address and the user agent, secret destroyed nightly, Visits, pageviews, paths and referrer hosts, with no segment describing a person. 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 - sendBeacon returns false rather than throwing when the browser declines to queue the request, and the Fetch standard caps the total inflight keepalive request bodies for a page at sixty four kibibytes across all of them rather than per request, so a page that batches a session into one large beacon, or fires several at once, loses them silently and your numbers are quietly low forever. Check the return value, keep payloads small, and do not send from unload, which does not fire when someone switches apps on a phone and costs the page its back and forward cache in some browsers. - Keeping the salt around for a week so you can report returning visitors quietly converts an unlinkable daily hash into a persistent identifier for everyone who visited, which is exactly the processing the exemptions you were relying on do not cover, and nothing in the interface will look any different. Destroy the secret on its schedule, record that you destroyed it, and accept that returning visitors across days is a number this design cannot produce. - Keeping raw events indefinitely because storage is cheap and you might re-aggregate later turns a compliant collector into a retained dataset about people, and the re-aggregation you were saving them for happens roughly never. Aggregate inside the write transaction, delete raw rows on a clock, and generate the published retention figures from the same file the deletion job reads so the two cannot drift. - Adding a breakdown by anything that describes the person rather than the visit, which is what every analytics product does the moment it has a second screen to fill, is exactly what the regulator's guidance says takes you outside the exception, and it will be added by somebody who read it as a harmless chart. Keep the report builder incapable of grouping by a visitor attribute, and put a test on the list of permitted dimensions.
# Build plan · Cabin A first-party analytics collector whose hard problems are getting the beacon to arrive at all on a phone, deriving a visit identifier that stops being linkable tomorrow, and aggregating at ingestion so the individual rows can expire without taking the reports with them. Phases are in dependency order. Each ends in a "Done when" list; treat an unticked item as a blocker, not a note. ## Phase 1 · A beacon that actually arrives The event reaches the collector from a phone that was switched away from, not closed. ### Steps 1. Send on visibility change, never on unload It is the only event that fires when someone switches apps. Files: `public/collect.js`, `tests/no-unload.test.js` 2. Check the sendBeacon return value and fall back A silent false is how a collector under-reports for months. Files: `public/collect.js` 3. Keep the payload small and assert the size The keepalive budget is shared across every inflight request on the page. Files: `public/collect.js`, `tests/payload-size.test.js` ### Done when - [ ] A refused beacon is detected and retried - [ ] No unload or beforeunload handler exists in the bundle - [ ] Payload size is bounded and asserted - [ ] An event arrives after switching apps on a phone ### Watch out - sendBeacon returns false rather than throwing when the browser declines to queue the request, and the Fetch standard caps the total inflight keepalive request bodies for a page at sixty four kibibytes across all of them rather than per request, so a page that batches a session into one large beacon, or fires several at once, loses them silently and your numbers are quietly low forever. Check the return value, keep payloads small, and do not send from unload, which does not fire when someone switches apps on a phone and costs the page its back and forward cache in some browsers. ## Phase 2 · An identifier that is useless by tomorrow The visit hash cannot be linked across days, and there is nowhere to store an address. ### Steps 1. Truncate the address before it is used at all Not on the way into the table, because it should never reach a table. Files: `src/identity.js`, `tests/identity.test.js` 2. Rotate and destroy the secret on a schedule The destruction is the property the whole design rests on. Files: `src/salt.js`, `tests/salt.test.js` 3. Give the schema nowhere to put an address The enforcement is the absent column, not the careful code. Files: `migrations/001_init.sql` ### Done when - [ ] No column can hold a full address - [ ] Yesterday's inputs hash differently today - [ ] Secret destruction is scheduled and recorded - [ ] Truncation happens before hashing ### Watch out - Keeping the salt around for a week so you can report returning visitors quietly converts an unlinkable daily hash into a persistent identifier for everyone who visited, which is exactly the processing the exemptions you were relying on do not cover, and nothing in the interface will look any different. Destroy the secret on its schedule, record that you destroyed it, and accept that returning visitors across days is a number this design cannot produce. ## Phase 3 · Aggregate on receipt, expire the rest Deleting every raw row leaves the reports intact. ### Steps 1. Aggregate inside the same transaction as the raw write A nightly rollup job is a nightly window in which the raw rows must survive. Files: `src/ingest.js`, `tests/aggregate.test.js` 2. Sweep raw rows on a clock and record the counts So that 'we delete after a day' is a thing you can show. Files: `src/retention.js`, `tests/retention.test.js` 3. Generate the privacy notice from the retention config The published number and the enforced number must be one number. Files: `config/retention.json`, `src/notice.js` ### Done when - [ ] Reports survive deleting every raw row - [ ] The sweep removes rows past the configured age - [ ] The notice is generated from the retention config - [ ] Deletion counts are recorded ### Watch out - Keeping raw events indefinitely because storage is cheap and you might re-aggregate later turns a compliant collector into a retained dataset about people, and the re-aggregation you were saving them for happens roughly never. Aggregate inside the write transaction, delete raw rows on a clock, and generate the published retention figures from the same file the deletion job reads so the two cannot drift. ## Phase 4 · Objecting costs nothing, and nothing segments a person Opting out is one click with no account, and no report groups by an attribute of the visitor. ### Steps 1. Fix the permitted dimensions in code and test the list So the harmless chart cannot be added without someone noticing. Files: `src/reports/dimensions.js`, `tests/dimensions.test.js` 2. Say visits, never users You gave up the ability to count people; say so everywhere. Files: `src/views/report.ejs`, `tests/wording.test.js` 3. Make objecting one click with no account Free and simple is the standard, not a settings page. Files: `src/objections.js`, `src/views/optout.ejs` ### Done when - [ ] Forbidden groupings are rejected by the report layer - [ ] The word users appears nowhere in the interface - [ ] Objecting stops collection on the next pageview - [ ] The opt-out needs no account or payment ### Watch out - Adding a breakdown by anything that describes the person rather than the visit, which is what every analytics product does the moment it has a second screen to fill, is exactly what the regulator's guidance says takes you outside the exception, and it will be added by somebody who read it as a harmless chart. Keep the report builder incapable of grouping by a visitor attribute, and put a test on the list of permitted dimensions. ## Not in this build - Session replay. - Cross-site or cross-device identity. - Advertising attribution and conversion tracking. - Hosting analytics for other people's sites. ## After v1, if you want it - Server-side ingestion from your own access log, which removes the browser script entirely - A public dashboard for one site, which is a read-only view and forces the wording to be honest - Alerting on a traffic collapse, which is the one thing a small site owner actually wants a notification for
# Copy to .env and fill in. Never commit .env; this file documents it. # Required · secret. Raw events with a short life, aggregates with a long one. DATABASE_URL=postgres://localhost:5432/analytics # Required. How often the hashing secret is replaced and the old one destroyed. SALT_ROTATE_HOURS=24 # Required. After this the raw events are deleted. The reports must not notice. RAW_MAX_AGE_HOURS=24 # Required. Check the figure your regime publishes rather than trusting this default. AGGREGATE_MAX_AGE_MONTHS=25 # Required. Must be on the same registrable domain as the measured site. COLLECTOR_ORIGIN=https://stats.example.com # Required. Each beacon. The keepalive budget is shared across every inflight request on the page. MAX_PAYLOAD_BYTES=1024 # Required. Reachable with no account and no payment. It is published in the notice. OPTOUT_URL=https://stats.example.com/objection
# Cabin · product brief ## Problem The visible privacy web analytics loop is buildable, but a credible replacement needs more than the first screen. Cabin earns its keep through ingestion, storage, reliability, so expect a weekend or multi-day build and a narrower personal scope. ## Product outcome A collector whose beacon arrives from a phone, whose identifier is unusable by the following day, whose raw rows can all be deleted without moving a single reported figure, and whose published notice is generated from the configuration that actually runs. ## Target user A builder who needs a maintainable product foundation, not a one-off demo. ## Required capabilities - deploy target - ClickHouse or SQLite for low volume - first-party tracking script ## Explicit non-goals for v1 - Session replay. - Cross-site or cross-device identity. - Advertising attribution and conversion tracking. - Hosting analytics for other people's sites. - bot and identity resolution - session replay privacy tooling - enterprise governance and integrations - high-volume ingestion and retention ## Success criteria - No column is confirmed capable of holding a full address - Reports are confirmed unchanged after deleting every raw row - The secret is confirmed destroyed on schedule with the destruction recorded - The report layer is confirmed to reject any dimension outside the fixed list - The published notice is confirmed generated from the running configuration
# Build brief · Cabin 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 first-party web analytics collector that counts visits without identifying anybody and can show its working to replace Cabin. 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 'Cookieless' is a marketing word, not a legal conclusion, and if you design as though it were one you will build something you have to unpick later. The United Kingdom changed in 2025. The Data (Use and Access) Act added a statistical purposes exception to the electronic communications rules, and the regulator's guidance, finalised in April 2026, calls it the analytics exception. It is narrow. The sole purpose must be collecting statistics about how the service is used with a view to improving it; the guidance says plainly that it 'is about how your service is used, not about who uses it', that it is 'not for identifying, tracking or monitoring people', and that it does not cover advertising. You must give clear information and 'a simple means of objecting, free of charge', and you must not keep individual-level data longer than the aggregation needs. Checked on the regulator's guidance pages on 15 September 2026. France publishes an exemption with harder numbers: the identifier lives at most thirteen months and is not extended by a new visit, the data collected through it at most twenty-five months, the last byte of the address is truncated, and nothing is cross-referenced with other processing or passed to a third party. Checked on the CNIL's audience measurement page, same date. So the design is already decided. Aggregate at ingestion, let the individual rows expire on a clock, publish an objection that costs nothing, never segment a visitor by anything that describes the person, and never let two sites share an identifier. The absence of a visitors table is not a missing feature, it is the argument. And do not promise accuracy you have given up. A collector that refuses to identify people cannot tell you how many people came. It can tell you how many visits there were. Say visits everywhere. ### Stack (fixed, do not substitute) - Collector: One endpoint on your own domain, no third-party host, no identifier shared between sites · a first-party aggregate-only collector is the only shape the analytics exception has room for - Beacon: sendBeacon with a keepalive fetch fallback, fired on visibility change · the unload event costs the page its back and forward cache and does not fire on a phone - Storage: An append-only raw table with a life measured in hours, plus aggregates written on receipt · the aggregates are the product and the raw rows are a liability with a timer on them - Identity: A daily hash of a rotating secret, a truncated address and the user agent, secret destroyed nightly · yesterday's hashes stop being linkable to today's, which is the entire design - Reports: Visits, pageviews, paths and referrer hosts, with no segment describing a person · the exception covers how the service is used, not who uses it ### Data model (create this before Phase 1) - events(id, at, site_id, path, referrer_host, viewport_bucket, visit_hash, country) · raw, short-lived, and the only table holding anything per visitor - daily_salt(day, secret, created_at, destroyed_at) · rotated and destroyed on a schedule, because a salt that outlives its day is a persistent identifier - agg_pageviews(site_id, day, path, views, visits) and agg_referrers(site_id, day, referrer_host, visits) · written on receipt, not computed nightly from raw - agg_entry(site_id, day, entry_path, visits) · enough to answer where people arrive, and nothing about who they are - retention(site_id, raw_max_age_hours, aggregate_max_age_months) · the numbers a regulator asks about, in one readable place - objections(id, kind, value, created_at) · the free and simple opt-out, recorded so that honouring it can be demonstrated - bot_filters(id, rule, added_at) and agg_filtered(site_id, day, rule, views) · what was removed and by which rule, counted separately rather than silently dropped - There is no visitors table, no account identifier and no cross-site identifier in this schema. That absence is the compliance argument, and adding one quietly ends it. ### Phase 1 · A beacon that actually arrives Build: Send on the visibility change event when the document becomes hidden, using sendBeacon and falling back to a fetch with keepalive. Keep each payload small and check the return value. Add no unload or beforeunload handler anywhere in the script. Serve the script from your own domain. Trap: sendBeacon returns false rather than throwing when the browser declines to queue the request, and the Fetch standard caps the total inflight keepalive request bodies for a page at sixty four kibibytes across all of them rather than per request, so a page that batches a session into one large beacon, or fires several at once, loses them silently and your numbers are quietly low forever. Check the return value, keep payloads small, and do not send from unload, which does not fire when someone switches apps on a phone and costs the page its back and forward cache in some browsers. Done when: A false return from sendBeacon is detected and the event is retried through the fallback, tested with a stub. The built script contains no unload or beforeunload handler, asserted by a test that greps the bundle. Each payload is under a stated byte size and the size is asserted in a test. Switching away from the page on a real phone produces an event, checked once by hand and recorded in the README. Do not build yet: No identifier, aggregation, retention, reports or opt-out yet. ### Phase 2 · An identifier that is useless by tomorrow Build: Generate a secret per day. Truncate the address before it is used, then hash the secret, the truncated address, the user agent and the site together to make the visit hash. Never write the address to any table. Destroy the day's secret on a schedule and record the destruction. Trap: Keeping the salt around for a week so you can report returning visitors quietly converts an unlinkable daily hash into a persistent identifier for everyone who visited, which is exactly the processing the exemptions you were relying on do not cover, and nothing in the interface will look any different. Destroy the secret on its schedule, record that you destroyed it, and accept that returning visitors across days is a number this design cannot produce. Done when: No table has a column capable of holding a full address, verified by a test that inspects the schema. Replaying yesterday's raw inputs under today's secret produces different hashes, tested. Secret destruction is recorded with a time and a test asserts no secret older than the configured age exists. The address is truncated before it reaches the hash, tested against a fixture. Do not build yet: No aggregation, retention job, reports or opt-out yet. ### Phase 3 · Aggregate on receipt, expire the rest Build: Update the aggregate tables in the same transaction that writes the raw event. Give raw rows a maximum age in hours and run a job that deletes and records the counts. Put the retention numbers in one configuration file that the privacy notice is generated from. Trap: Keeping raw events indefinitely because storage is cheap and you might re-aggregate later turns a compliant collector into a retained dataset about people, and the re-aggregation you were saving them for happens roughly never. Aggregate inside the write transaction, delete raw rows on a clock, and generate the published retention figures from the same file the deletion job reads so the two cannot drift. Done when: Deleting every raw row changes no figure in any report, tested by comparing reports before and after a forced sweep. Raw rows older than the configured hours are absent after the job runs, tested against a fixed clock. The privacy notice text is generated from the retention configuration, so changing the number changes both, tested. The sweep records how many rows it deleted and when. Do not build yet: No opt-out, reports, bot handling or export yet. ### Phase 4 · Objecting costs nothing, and nothing segments a person Build: Publish an objection page reachable without an account or payment that stores a preference and stops collection on the next pageview. Build reports for visits, pageviews, paths, referrer hosts and entry pages. Use the word visits throughout. Refuse to add any breakdown that describes the person rather than the visit. Trap: Adding a breakdown by anything that describes the person rather than the visit, which is what every analytics product does the moment it has a second screen to fill, is exactly what the regulator's guidance says takes you outside the exception, and it will be added by somebody who read it as a harmless chart. Keep the report builder incapable of grouping by a visitor attribute, and put a test on the list of permitted dimensions. Done when: The report layer accepts only a fixed list of dimensions and rejects anything else, tested by attempting a forbidden grouping. The word users does not appear in the interface, asserted by a test over the rendered pages. An objection stops collection on the next pageview and is recorded with a time, tested end to end. The objection page needs no account, no payment and no third-party script. Do not build yet: No bot handling, export or published compliance page yet. ### Phase 5 · Bots, and being honest about the number Build: Filter obvious automated traffic with named rules and count what each rule removed rather than dropping it silently. Show the filtered total beside the reported one. Generate a page listing what is collected, the retention figures, the opt-out and the filter rules, from the same configuration the code uses. Export aggregates as CSV. Trap: Silently discarding traffic you believe is automated moves the reported numbers for reasons nobody can audit, and six months later a decision gets made on a filter somebody wrote on a Tuesday and forgot. Count per rule, show the filtered total beside the reported one, and make a rule change visible as a step in the chart rather than a mystery. Done when: Every filter rule has its own counter and the filtered total is displayed beside the reported one. Adding or removing a rule is recorded with a date and appears on the compliance page. The compliance page is generated from the same configuration the collector and the sweep read, tested by changing one value and asserting all three change. Export produces aggregates as CSV and contains no raw event rows, tested. Do not build yet: Nothing after this. ### Out of scope (and why) - Session replay, which records what a person did on a page and is the opposite of an analytics build whose defence is that it does not watch individuals. - Cross-site or cross-device identity, which requires consent under every regime discussed here and cannot be reached by hashing harder. - Advertising attribution and conversion tracking, which the regulator's guidance says the analytics exception does not cover. - A hosted service for other people's sites, which makes you a processor for their visitors and brings contracts, sub-processor lists and breach duties with it. ### README must contain - That the analytics exception added to the electronic communications rules is narrow, with the regulator's own wording quoted and the date checked - That the French exemption caps the identifier at thirteen months and the collected data at twenty-five, and requires address truncation, with the date checked - That this collector cannot report people or returning visitors across days, and why that is deliberate - That the raw event rows expire on a clock and the reports survive their deletion - How to object, that it costs nothing and needs no account, and what happens on the next pageview
# Architecture · Cabin ## Stack | Part | Choice | Why | | --- | --- | --- | | Collector | One endpoint on your own domain, no third-party host, no identifier shared between sites | a first-party aggregate-only collector is the only shape the analytics exception has room for | | Beacon | sendBeacon with a keepalive fetch fallback, fired on visibility change | the unload event costs the page its back and forward cache and does not fire on a phone | | Storage | An append-only raw table with a life measured in hours, plus aggregates written on receipt | the aggregates are the product and the raw rows are a liability with a timer on them | | Identity | A daily hash of a rotating secret, a truncated address and the user agent, secret destroyed nightly | yesterday's hashes stop being linkable to today's, which is the entire design | | Reports | Visits, pageviews, paths and referrer hosts, with no segment describing a person | the exception covers how the service is used, not who uses it | ## Modules Each module has one owner concern and a documented way to replace it. | Module | Owns | How to replace it | | --- | --- | --- | | Beacon | The browser script and its delivery. | Any transport, provided it fires on visibility change and never on unload. | | Identity | Truncation, daily secret and the visit hash. | Any hash, provided the secret is destroyed on schedule. | | Ingest | Raw write and aggregate update in one transaction. | Any store, provided aggregation is not deferred to a nightly job. | | Retention | The sweep and the published figures. | Any scheduler, provided one file drives both the sweep and the notice. | | Reports | A fixed dimension list and the wording. | Not swappable for anything that can group by a visitor attribute. | ## Configuration Every runtime setting is an environment variable documented in `.env.example`, validated at startup, with a safe local default wherever one exists. - `DATABASE_URL` · required, secret · Raw events with a short life, aggregates with a long one. - `SALT_ROTATE_HOURS` · required · How often the hashing secret is replaced and the old one destroyed. - `RAW_MAX_AGE_HOURS` · required · After this the raw events are deleted. The reports must not notice. - `AGGREGATE_MAX_AGE_MONTHS` · required · Check the figure your regime publishes rather than trusting this default. - `COLLECTOR_ORIGIN` · required · Must be on the same registrable domain as the measured site. - `MAX_PAYLOAD_BYTES` · required · Each beacon. The keepalive budget is shared across every inflight request on the page. - `OPTOUT_URL` · required · Reachable with no account and no payment. It is published in the notice. ## 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 · Cabin product build - Read `PRODUCT.md` and `ARCHITECTURE.md` before changing code. The stack is fixed: One endpoint on your own domain, no third-party host, no identifier shared between sites, sendBeacon with a keepalive fetch fallback, fired on visibility change, An append-only raw table with a life measured in hours, plus aggregates written on receipt, A daily hash of a rotating secret, a truncated address and the user agent, secret destroyed nightly, Visits, pageviews, paths and referrer hosts, with no segment describing a person. - 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 - sendBeacon returns false rather than throwing when the browser declines to queue the request, and the Fetch standard caps the total inflight keepalive request bodies for a page at sixty four kibibytes across all of them rather than per request, so a page that batches a session into one large beacon, or fires several at once, loses them silently and your numbers are quietly low forever. Check the return value, keep payloads small, and do not send from unload, which does not fire when someone switches apps on a phone and costs the page its back and forward cache in some browsers. - Keeping the salt around for a week so you can report returning visitors quietly converts an unlinkable daily hash into a persistent identifier for everyone who visited, which is exactly the processing the exemptions you were relying on do not cover, and nothing in the interface will look any different. Destroy the secret on its schedule, record that you destroyed it, and accept that returning visitors across days is a number this design cannot produce. - Keeping raw events indefinitely because storage is cheap and you might re-aggregate later turns a compliant collector into a retained dataset about people, and the re-aggregation you were saving them for happens roughly never. Aggregate inside the write transaction, delete raw rows on a clock, and generate the published retention figures from the same file the deletion job reads so the two cannot drift. - Adding a breakdown by anything that describes the person rather than the visit, which is what every analytics product does the moment it has a second screen to fill, is exactly what the regulator's guidance says takes you outside the exception, and it will be added by somebody who read it as a harmless chart. Keep the report builder incapable of grouping by a visitor attribute, and put a test on the list of permitted dimensions. - Silently discarding traffic you believe is automated moves the reported numbers for reasons nobody can audit, and six months later a decision gets made on a filter somebody wrote on a Tuesday and forgot. Count per rule, show the filtered total beside the reported one, and make a rule change visible as a step in the chart rather than a mystery.
# Delivery milestones · Cabin Estimated effort: **weekend** for the indie phases; the production-only milestones add the trust and operability layer. ## M1 · A beacon that actually arrives The event reaches the collector from a phone that was switched away from, not closed. ### Steps 1. Send on visibility change, never on unload It is the only event that fires when someone switches apps. Files: `public/collect.js`, `tests/no-unload.test.js` 2. Check the sendBeacon return value and fall back A silent false is how a collector under-reports for months. Files: `public/collect.js` 3. Keep the payload small and assert the size The keepalive budget is shared across every inflight request on the page. Files: `public/collect.js`, `tests/payload-size.test.js` ### Done when - [ ] A refused beacon is detected and retried - [ ] No unload or beforeunload handler exists in the bundle - [ ] Payload size is bounded and asserted - [ ] An event arrives after switching apps on a phone ### Watch out - sendBeacon returns false rather than throwing when the browser declines to queue the request, and the Fetch standard caps the total inflight keepalive request bodies for a page at sixty four kibibytes across all of them rather than per request, so a page that batches a session into one large beacon, or fires several at once, loses them silently and your numbers are quietly low forever. Check the return value, keep payloads small, and do not send from unload, which does not fire when someone switches apps on a phone and costs the page its back and forward cache in some browsers. ## M2 · An identifier that is useless by tomorrow The visit hash cannot be linked across days, and there is nowhere to store an address. ### Steps 1. Truncate the address before it is used at all Not on the way into the table, because it should never reach a table. Files: `src/identity.js`, `tests/identity.test.js` 2. Rotate and destroy the secret on a schedule The destruction is the property the whole design rests on. Files: `src/salt.js`, `tests/salt.test.js` 3. Give the schema nowhere to put an address The enforcement is the absent column, not the careful code. Files: `migrations/001_init.sql` ### Done when - [ ] No column can hold a full address - [ ] Yesterday's inputs hash differently today - [ ] Secret destruction is scheduled and recorded - [ ] Truncation happens before hashing ### Watch out - Keeping the salt around for a week so you can report returning visitors quietly converts an unlinkable daily hash into a persistent identifier for everyone who visited, which is exactly the processing the exemptions you were relying on do not cover, and nothing in the interface will look any different. Destroy the secret on its schedule, record that you destroyed it, and accept that returning visitors across days is a number this design cannot produce. ## M3 · Aggregate on receipt, expire the rest Deleting every raw row leaves the reports intact. ### Steps 1. Aggregate inside the same transaction as the raw write A nightly rollup job is a nightly window in which the raw rows must survive. Files: `src/ingest.js`, `tests/aggregate.test.js` 2. Sweep raw rows on a clock and record the counts So that 'we delete after a day' is a thing you can show. Files: `src/retention.js`, `tests/retention.test.js` 3. Generate the privacy notice from the retention config The published number and the enforced number must be one number. Files: `config/retention.json`, `src/notice.js` ### Done when - [ ] Reports survive deleting every raw row - [ ] The sweep removes rows past the configured age - [ ] The notice is generated from the retention config - [ ] Deletion counts are recorded ### Watch out - Keeping raw events indefinitely because storage is cheap and you might re-aggregate later turns a compliant collector into a retained dataset about people, and the re-aggregation you were saving them for happens roughly never. Aggregate inside the write transaction, delete raw rows on a clock, and generate the published retention figures from the same file the deletion job reads so the two cannot drift. ## M4 · Objecting costs nothing, and nothing segments a person Opting out is one click with no account, and no report groups by an attribute of the visitor. ### Steps 1. Fix the permitted dimensions in code and test the list So the harmless chart cannot be added without someone noticing. Files: `src/reports/dimensions.js`, `tests/dimensions.test.js` 2. Say visits, never users You gave up the ability to count people; say so everywhere. Files: `src/views/report.ejs`, `tests/wording.test.js` 3. Make objecting one click with no account Free and simple is the standard, not a settings page. Files: `src/objections.js`, `src/views/optout.ejs` ### Done when - [ ] Forbidden groupings are rejected by the report layer - [ ] The word users appears nowhere in the interface - [ ] Objecting stops collection on the next pageview - [ ] The opt-out needs no account or payment ### Watch out - Adding a breakdown by anything that describes the person rather than the visit, which is what every analytics product does the moment it has a second screen to fill, is exactly what the regulator's guidance says takes you outside the exception, and it will be added by somebody who read it as a harmless chart. Keep the report builder incapable of grouping by a visitor attribute, and put a test on the list of permitted dimensions. ## M5 · Bots, and being honest about the number (production only) What was filtered is visible, and the published compliance page comes from the running configuration. ### Steps 1. Count per filter rule, never drop silently A number that moved for an unknown reason is worse than a wrong number. Files: `src/bots.js`, `src/views/report.ejs` 2. Generate the compliance page from the live configuration What you publish and what you enforce should be one file. Files: `src/compliance.js`, `tests/compliance.test.js` 3. Export aggregates only There should be nothing else left to export by then. Files: `src/export.js` ### Done when - [ ] Filtered counts are shown per rule - [ ] Rule changes are dated and published - [ ] One config drives collector, sweep and compliance page - [ ] Export contains no raw event rows ### Watch out - Silently discarding traffic you believe is automated moves the reported numbers for reasons nobody can audit, and six months later a decision gets made on a filter somebody wrote on a Tuesday and forgot. Count per rule, show the filtered total beside the reported one, and make a rule change visible as a step in the chart rather than a mystery.
# Operations · Cabin ## Backup Aggregates only. The raw table should never be in a backup, because a backup is exactly the retained copy the retention rule exists to prevent, and restoring one would resurrect deleted rows. ## Restore Restore aggregates and confirm the sweep, the secret rotation and the notice generation all start from the restored configuration. Check that no raw rows came back with it. Do a restore drill before the first real user, and write the date here when it passes. ## Monitoring Beacon failures reported by the fallback, the age of the oldest raw row, the time since the last secret destruction, and the filtered proportion. A rising oldest-raw-row age means the sweep has stopped and the clock is running. ## Incident checklist The incident here is retaining something you said you did not, and it is discovered by somebody reading your own notice. The retention configuration, the deletion counts and the destruction record are the three things that turn that into a five minute answer. 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 - [ ] No column is confirmed capable of holding a full address - [ ] Reports are confirmed unchanged after deleting every raw row - [ ] The secret is confirmed destroyed on schedule with the destruction recorded - [ ] The report layer is confirmed to reject any dimension outside the fixed list - [ ] The published notice is confirmed generated from the running configuration ## Launch constraint Do not market omitted Cabin capabilities as implemented. The non-goals in `PRODUCT.md` remain user-visible limitations until they are deliberately delivered.
# Copy to .env and fill in. Never commit .env; this file documents it. # Required · secret. Raw events with a short life, aggregates with a long one. DATABASE_URL=postgres://localhost:5432/analytics # Required. How often the hashing secret is replaced and the old one destroyed. SALT_ROTATE_HOURS=24 # Required. After this the raw events are deleted. The reports must not notice. RAW_MAX_AGE_HOURS=24 # Required. Check the figure your regime publishes rather than trusting this default. AGGREGATE_MAX_AGE_MONTHS=25 # Required. Must be on the same registrable domain as the measured site. COLLECTOR_ORIGIN=https://stats.example.com # Required. Each beacon. The keepalive budget is shared across every inflight request on the page. MAX_PAYLOAD_BYTES=1024 # Required. Reachable with no account and no payment. It is published in the notice. OPTOUT_URL=https://stats.example.com/objection
$ choose a build depth, inspect the files, then open the complete pack in your agent
Cabin: Customers pay for trusted numbers, retention, privacy controls, and a pipeline that remains accurate while traffic and schemas change.
xbot and identity resolution
xsession replay privacy tooling
xenterprise governance and integrations
xhigh-volume ingestion and retention
Don't feel like building it? These folks already made it free.
all 3 free alternatives to Cabin →· no votes, no pay-to-list · just what's real
Cabin pricing
| plan | monthly | annual (per mo) | what you get |
|---|---|---|---|
| basic | $0/workspace | $0/workspace | 1 website; unlimited pageviews subject to fair use; 30-day data retention |
| pro | $19/workspace | — | Unlimited websites, pageviews and data retention, all subject to fair use |
free tier1 website, unlimited pageviews subject to fair use, and 30-day data retention
billingmonthly only on the public pricing page; no annual plan published
hidden costsBoth plans' 'unlimited' pageviews are subject to an undefined fair-use policy.
verified 2026-08-12 · source ↗
Vibecode Cabin
Kinda. The core of Cabin is buildable in a weekend with the prompt on this page, but there are real gaps: bot and identity resolution, session replay privacy tooling. Read the honest list above before committing.
How much does Cabin cost?
Cabin'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 Cabin?
Honestly: bot and identity resolution; session replay privacy tooling; enterprise governance and integrations; high-volume ingestion and retention. If any of those are load-bearing for you, keep paying.
Is there an open-source alternative to Cabin?
Yes: Umami (A clean cookieless dashboard, minus Cabin's hosting bill.) Plausible Community Edition (Privacy-friendly traffic analytics in one Compose stack; the cloud-only extras stay elsewhere.) GoatCounter (Tiny traffic analytics that tells you what happened and declines to become a growth platform.) All 3 curated free alternatives are at vibecodeit.com/cabin/alternatives. The prompt is for when you want it exactly your way.