# Curfew A personal habit tracker with evidence. People track their own activities and keep a streak for each. Groups are an opt-in accountability layer on top: private, invite-only, and they only ever see what a member chooses to share. Money is optional and can be switched off entirely. ## Read these first - `.planning/v3/SCOPE.md` — v3's reframe, what ships, and 76 numbered decisions. It indexes the rest of `.planning/v3/`. - `.planning/v3/PLAN.md` — build order. Nine phases, each with a done condition. - `.planning/v3/ARCHITECTURE.md` — runtime, schema, the module spec, scoring jobs, uploads. - `.planning/v4/SCREENS.md` — every mock, its route, and the review gate that stops the build drifting from the design. - `.planning/v3/OPEN.md` — **what is not done.** Every defect found and how it was closed, what the security round cannot say, what is untested, what needs a person rather than code, and the cutover. Start here. - `.planning/RELEASE.md` — **the production runbook.** Preflight, the order, rollback, and the rule that decides it: an additive migration goes before the tag and a hostile one after the promote, because the live version is still running between them. Read it before every release, not only the first. - `.planning/ROADMAP.md` — the four themes after v3.2, and nothing in it is decided. Each one becomes its own scope file when it starts, and each has to name the settled decision it overturns before it overturns it. - `.planning/PRD.md` — v1's what and why. Still the reasoning behind the invariants; superseded on scope. - `.planning/schema.sql` — the data model, with the immutability rules in comments. - `.design/` — the approved mocks, generated by `.design/build-v3.mjs`. Design reference, never code to copy. **Tracked, since v3.2 (item 27).** It was gitignored, and thirteen artboards existed only on the canvas because of it: nothing in the repo knew they were there, and regenerating would have destroyed them silently. The seeded canvas itself, `curfew-redesign.html`, is still ignored: it is the generator plus four megabytes of editor, rebuilt whole every time. ## Current phase **v4 is being built. Phases 1 and 2 are CODE COMPLETE; Phase 2 cannot close until a person has opened its five screens beside their artboards.** `3.4.8` is what production runs; everything below this block is v3's record and is still true of the deployed app. Read these three before touching anything: - `.planning/v4/DECIDED.md` — 1.1 to 1.61 settled, 3.3 open. **The authority.** Every other v4 file cites it by number. - `.planning/v4/ACTIVITIES.md` — **what every activity actually is**: the number it carries, its schedule, how often it can be fined, what it asks for, whether it wants a photograph, and when. **Generated** by `bun run doc:activities` from `src/domain/`, so it cannot drift. If a row looks wrong, the module is wrong. - `.planning/v4/COACH.md` — why Ren is a coach and not a chatbot, and the research behind 1.46 to 1.51. C1 to C12 are proposals in it, not decisions; a proposal becomes one only when `DECIDED.md` says so. It also records where C5 was wrong, because a proposal that was corrected is worth more standing than quietly replaced. - `.planning/v4/PLAN.md` — eight phases, each with a done condition, and the four mechanisms that keep the build from drifting away from DECIDED. - `.planning/v4/SCHEMA.md` — six additive migrations, 0033 to 0038. **v4 has no hostile migration and it must stay that way.** **What v4 is:** the app becomes a coach called Ren (1.1), one member can nudge another (1.5), Monk mode gives a day a percentage over what you already track (1.16), five new activity types arrive under one Home row (1.19, 3.1), and every screen is redrawn to the v5 canvas (1.8). **And since 2026-09-24, the streak stops being the headline (1.49).** Home leads with a consistency percentage for the twelve do-something types and keeps the flame for the six abstinence types, where a consecutive count is the achievement rather than an artifact. The argument is in this repo rather than in a paper: **five mechanisms exist to soften one.** Repair, grey, settling, away days and the Restore screen. When a rule needs five exceptions it is wrong for something it is being asked to do. Two of the five shrink to the abstinence types and two were never streak mechanisms at all (1.50). **It touches no scoring, verified rather than assumed.** Money and reputation never read a streak: fines come from `activity_outcomes` and the curve from daily completion. `activity_streaks` keeps being written for every type, so the number stops being SHOWN rather than computed, `verify` is unaffected, and putting it back is one commit. **Two things to know before reading the older plans.** The monk bar was priced against a foreign key that migration 0012 deleted, so `SCHEMA.md` 0035 is one nullable column rather than the second scope 1.16 first described; and 1.19, 1.20 and 3.1 were once silently truncated out of `DECIDED.md` by an append and had to be recovered from git. That is why `DECIDED.md` is now assembled from sorted blocks and why `check:decided` exists. **Phase 2 built what 1.49 decided.** Home leads with a consistency percentage for the twelve do-something types, keeps the flame for the six abstinences, and collapses the latter into one row. `consistency.ts` is the number, `consistency-read.ts` is the three batched queries behind it, and `bun run sim:consistency` is the gate on its behaviour. Three things about it are worth knowing before touching that code: - **The FIRST press of a period is the cue**, not the last. Water's eighth glass at 9 PM says when somebody finished and the first at 7 AM says when they start. - **The group hub computes its percentage from the GROUP'S own outcomes**, not from the member's scores. A group sees a shared activity only from the day somebody joined, and reading `activity_scores` for another member would reopen the leak v3.1 closed and be a cross-member read with no `assertMember` over it. - **Repair and grey follow the streak and need no rule naming types**, because both are properties OF one. The repair pool counts tracked activities that carry a streak, which is the same rule over a smaller set. **The design is done and approved**, 41 boards at https://claude.ai/artifact/V5Q54R7heSttj1aXT5PVqP, with sources in `.design/v5/`. `.planning/v4/SCREENS.md` is the review gate and **a phase cannot close with an unticked row it touched.** --- **v3, Phases 0 to 9 done.** v1, v2 and v2.5 are built and deployed. v3 is fully designed and specified: twelve activity types with per-user schedules and thresholds, photo evidence, a 0 to 1000 reputation per group with six ranks, group stats, an admin console that can switch whole systems off, and a fresh start on data. Every screen is mocked in `.design/` and listed in `.planning/v4/SCREENS.md`. - **Phase 0** ground: APAC Neon migrated from empty, `activity_types`, `bun run sync:activities` gating CI, `sin1`, R2 and Upstash in place. - **Phase 1** the engine: periods, schedules, streaks, grace, the module interface and the registry. - **Phase 2** config and control: app settings resolved as-of, the cached read, admin Controls and Groups, the blocking notice. - **Phase 3** the twelve modules and one configure screen drawn from their declared fields. - **Phase 4** check-in: one screen for all twelve, idempotent on a key the press carries, rate limited, nothing recorded on a GET. - **Phase 5** evidence: in-app camera, canvas compression, presigned PUT to R2, the check-in as the callback, and the nightly retention and orphan sweeps. - **Phase 6** scoring and reputation: the pass rewritten per user and per type, the lazy close on read, the reputation curve, the global score, and `verify` extended to cover it. - **Phase 7** groups: the model rebuilt on types, sharing with its two toggles, the four hub tabs, group stats, the full ledger, fines split among the members who passed, and the join flow. - **Phase 8** surfaces: Home rebuilt around every tracked activity, the catalog, personal stats at `/stats` with its four chart kinds, settings, sharing, the ranks page and deleting your data. - **Phase 9** before anyone uses it: the consent gate carrying both what Curfew stores and the rules, reporting and bans, the security round as `bun run break-in`, and CONTRIBUTING with the add-an-activity-type walkthrough. **One thing still needs a person, not code: use it on a phone.** Photo evidence has never been driven end to end through a real camera, and no test can hold one up to the screen. That single pass is also the `SCREENS.md` review, because you cannot photograph a meal without opening Home, the configure screen and the camera and looking at all three. One gate, not two. **All nine phases are done, and so are the fourteen items in `.planning/v3.1/SCOPE.md`**, a round of defects and redesigns found by using the dev build. Two of them were leaks: a group saw a member's whole back catalogue on joining, and shared evidence never reached the group at all. That file is the record; the work is in `main`. **This ships as v3.0.0, not v3.1.0.** v3 never reached production, so the nine phases and the fourteen items are one release. `v3.1` names when the work happened, not a version anyone ran. **What remains is the cutover**, below, and the `SCREENS.md` review gate, which is a person opening each screen beside its artboard. **v3.2 is built.** All thirteen items in `.planning/v3.2/SCOPE.md`, numbered 15 to 27 to continue from v3.1. That file is the record: every decision, what shipped where it differs, the five defects the work walked into, and the three decisions taken while building. **Shipped as v3.2.0 on 2026-09-16.** **v3.3 is being built**, seven items numbered 28 to 34 in `.planning/v3.3/SCOPE.md`. One feature: Curfew can speak first. Push reminders before a window closes, a count on the home-screen icon, and a line saying what the rest of your group has already logged. It reverses two decisions that were written down as permanent, push being out of scope and the clerk voice, and that file is where both reversals are argued rather than assumed. The two that were more than they looked, and what they changed: - **Sleep's confirm window is anchored to the wake PRESS**, and every window in the app until now was a clock time in config. `windows()` takes the period's check-ins, and a window can come back marked `waitingOn`: not started, its times the widest it could turn out to be, and nothing recordable against it. - **Grace is one pool for the account**, two a month for each activity tracked, spent by hand after a streak has already ended, recorded as a `grace.spent` event. Nothing is stored and every number is derived, so there is no counter to drift and no month rollover to run. **Two commands are run by hand after the 3.2.0 tag, in this order:** `bun run migrate:sleep`, then `bun run publish:notice`. See Commands below. **Vercel Preview now names `curfew-apac-dev`, done 2026-09-15.** It named `curfew-apac` for twelve days, which would have left the dev site reading and writing the live database the moment the cutover finished. `--force` is not enough to change one of these: it overwrites the value and keeps the record's created date, and since the values are sensitive and never returned, that date is the only evidence anybody gets. Remove and re-add, then check the date moved. **The Configure and Check-in rows in `SCREENS.md` are unticked on purpose.** Ticking one means a person has opened the screen beside its artboard. Both sets are now built to their mocks control by control, with three known differences, all of them waiting on later phases: - Every time control is the platform's ``, so whether it reads "10:00 PM" or "22:00" follows the device. The artboards show 12-hour. - Streak, best and grace-left are live. **The cutover ran on 2026-09-15.** Both branches were emptied with `drop schema public cascade` and rebuilt from the numbered migrations: 5 users, 4 groups, 99 events and 55 evidence rows went from `curfew-apac`, and 4, 3, 29 and 39 from `curfew-apac-dev`. Nothing from v1 or v2 was carried across (decision 22). Both now carry twelve `activity_types`, all disabled. Vercel binds environment variables at deploy time, so pointing Production at the new branch changed nothing until the tag shipped a build that reads it. That is also why an admin can only be made AFTER the tag, corrected in `PLAN.md` step 4. The old `curfew` Neon project is still there and still holds v1 and v2's data. Delete it a week after nobody complains, and not before: it is the only copy. ## Deploying Production ships on a version tag, never on a push. `main` is a Preview deployment served at `dev.curfew.amanarya.com` with deployment protection off, so anyone with the link can open it. Vercel's production branch is `production`, which nobody pushes. **Both deployments come from GitHub Actions, not from Vercel's GitHub App.** `deploy.yml` ships production on a `v*` tag; `preview.yml` ships `main` to the dev site on every push. `vercel.json` sets `deploymentEnabled` to false for both branches, so the App deploys nothing and one system owns each. Two systems deploying one branch is two deployments per push and a race for the alias. The App was the silent dependency this removes: when its installation is suspended or waiting on a permissions approval, push events stop arriving and nothing anywhere says so. It cost an hour on 2026-09-07, and on 2026-09-15 the v3.0.0 release commit got no Preview at all, leaving the dev site on an older build still bound to the database production was about to become. A deployment that fails is fine; one that silently does not happen is not. `preview.yml` is NOT gated on CI, which is the one place it differs from `deploy.yml`. The dev site is where a change is looked at, including a change CI is unhappy about. A failed build produces no new deployment and the previous one keeps serving, so the failure mode is "dev is behind" rather than "dev is broken". It finishes by running `check:signin` against the dev domain, which is the only check that exercises the sign-in gate at all: LOCAL_MODE makes the middleware stand aside in every CI job that serves the app. **An environment file says nothing about what a deployment reads.** `.env.*` is read by the commands in this repo and by nothing else; what a deployment connects to lives in Vercel, per environment, and is set in the dashboard. The two can disagree silently and did: `.env.preview` was pointed at the `curfew-apac-dev` branch and Vercel Preview was not, so for days `dev.curfew.amanarya.com` read and wrote `curfew-apac`'s default branch, the database production is about to become, while every local measurement described a branch nobody was using. It cost an afternoon and a defect report written about the wrong 39 rows. `bunx vercel env ls preview` shows the dates, which is enough to catch it: a variable older than the branch it is supposed to name has never been updated. The values are encrypted and never returned, so the dates are all you get and all you need. **Nothing is scheduled by Vercel any more.** `vercel.json` has no `crons` key and `check:cron` fails if one comes back. Every tick is a QStash schedule declared in `scripts/schedule-jobs.ts`, and `bun run schedule` reconciles the environment against that table. The reason is that a Vercel cron on Hobby is once a day, UTC only, guaranteed only to the hour, and **one daily firing in UTC cannot serve members in more than one span of timezones.** 07:00 UTC is comfortably late in Kolkata and too early in Berlin, where a sleep period shuts at 08:00 UTC. That member was scored a day late every day for as long as the daily cron existed: the job ran, truly reported nothing to do, and the window shut half an hour later. Moving the hour only moves the cliff west. Scoring runs HOURLY now, so the worst wait between a period closing and being judged is one hour anywhere on earth, and `check:cron` asserts exactly that across six zones rather than only `DEFAULT_ZONE`, which is what it used to do and why it missed this. QStash points at one environment at a time, whichever `BETTER_AUTH_URL` the loaded env file names, so dev is scheduled by `bun run schedule` and production by `bun run schedule:production`. `package.json` carries `3.4.8`, cut as a tag on 2026-09-20. The admin header reads that number, so the next version bump is the next release: add the `-dev` suffix back while the following version is being built, and take it off again in the commit that gets tagged. **3.4.1 and 3.4.2 were both bug fixes found by using production**, and between them they are a lesson about what the checks were asking. 3.4.1: a settling day counted as a clean day, so somebody who had missed everything was told they had five clean days; the Ops drift report labelled three of its five kinds "Reputation" and printed "Invalid Date" for two of them; the group tabs stacked history so leaving a group took as many presses as tabs you had opened; and a fine owed to nobody vanished with no record. Two more turned up while shipping it, because CI ran on a Sunday for the first time: a first session on a Sunday read 0, and two check scripts could not pass at the weekend at all. 3.4.2: scoring moved to an hourly QStash job. See the schedule section above. The through line is that every one of these passed CI for days. `check:cron` asked whether the daily firing was late **for Kolkata**; `check:streak` and `check:offer` only ever ran on weekdays. A check that cannot fail is not a check, and the cheapest way to find out is to run it somewhere it might. **v3.3 is fully live and the reminder tick is running.** Nothing is held back. - Migration 0027 applied to both databases. - `PUSH_REMINDERS=1` in Vercel Production, `0` in Preview. **Dev never sends.** Proved rather than assumed: `/api/cron/remind` on dev answers `{ ok: true, skipped: "disabled" }` to a valid `CRON_SECRET`. - QStash schedule `scd_6fChaPXD1rssMQaF1VjebR5XEgTD`, `*/15 * * * *`, pointed at production. Re-running `bun run schedule:production` says "all scheduled" rather than adding a second, which is the thing that script exists to prevent. - The 3.3.0 release note is published to both, 3 accounts on production. **v3.4.0 rewrote what those notifications SAY**, after the first day in production sent copy that told somebody with nothing logged that they were almost there. `.planning/v3.3/SCOPE.md` ends with the six defects and the six changes; the short version is that a digest became one notification per activity, the daily cap became three rules that are not counts, and quiet hours became a real setting that nothing overrides. - Migration 0028 (`notification_settings`) applied to both databases. - No new environment variables, no schedule change, no release note. - **`bun run sim:push` is the review gate for this copy.** It prints a simulated day with no database. Read its output before changing a word of `notification-copy.ts`. **There is exactly one QStash schedule PER DESTINATION and it must stay that way.** Three destinations: `/api/cron/remind` every fifteen minutes, `/api/cron/score` hourly, `/api/cron/nightly` at 07:00 UTC. Two schedules pointing at the same URL looks like nothing at all: the job runs twice a tick, the idempotency claim absorbs the second, and the only symptom is the bill. Always change them through `bun run schedule`, never the dashboard. The script replaces rather than adds, and removes any schedule on this origin that the `JOBS` table does not declare, which is how a renamed route stops leaving a tick behind. **A job that fails now says so, and a job that stops running is noticed (3.4.7).** Two different faults that both used to be invisible, and they need different mechanisms because one of them cannot report anything at all. - **Ran and broke.** Every schedule names `/api/cron/failed` as its `Upstash-Failure-Callback`. QStash POSTs there after the last retry and the route writes an `ops.job.failed` event. Before it, the record went to Upstash's dead letter queue, which the free plan keeps for **three days**, and nothing in this repo had ever read it. The only symptom was drift on the Ops page the next day, which names the consequence and not the cause. - **Stopped being called.** No Upstash feature can tell you this: a deleted or paused schedule is not a failed delivery, it is the absence of one. So the signal is an absence too. Each job writes a heartbeat (`ops.score.ran`, `ops.push.ran`, `ops.verify.ran`) and `schedulerHealth()` calls it late past its own cadence. The reminder tick now beats on its skip paths as well, so an environment with `PUSH_REMINDERS` unset no longer reads identically to one QStash has abandoned. Both land in **SCHEDULER**, first on Admin Ops, above Evidence and Drift, because a job that did not run explains every other number under it. **`Upstash-Failure-Callback` cannot be read back**, on the list endpoint or on a single-schedule GET. A schedule listing carries the cron, the destination, the retries and the forwarded Authorization header, and nothing about either callback. So `bun run schedule` cannot see that a live schedule is missing one, and will say "all scheduled". `bun run schedule -- --rewrite` recreates every declared job, and is the only way to change that field. `check:cron` asserts the header is declared in the source, which is the only place that still knows. **A streak rule change repairs itself too, since 3.4.8 (migration 0032).** `activity_streaks.logic_version` is the streak half of reputation's `LOGIC_VERSION`, which had no equivalent for four months. The gap was not theoretical. `needsClosing` will not rebuild a DAILY counter until something new is scored for its type, so when 3.4.4 changed the daily rule sixteen counters sat wrong for as long as nobody pressed anything, and `verify` could not see it: it diffs stored against a recompute, and no close had run to make them disagree yet. It took reading the table by hand, twice in one week. Now `needsClosing` returns true for a row whose version it does not recognise, before it asks whether anything new has happened, which is the wrong question after a rule change. `scoreAll` closes every user's streaks every hour, so the repair reaches somebody who never opens the app. The column defaults to **0** rather than to the current version, so every row written before it existed is stale exactly once. **Bumping it covers two files, not one.** `streakOver` in `src/domain/streak.ts` decides what a counter should be from history, and `bumpStreak` in `src/server/streak.ts` does the same arithmetic incrementally when a press lands and mirrors the grey and restart rules by hand. Change either and bump `STREAK_LOGIC_VERSION`. **It fixes STALE, not WRONG,** and the difference already cost a release. A rebuild under mistaken rules reproduces the mistake: stored and recomputed agree, `verify` reports nothing, and both are wrong the same way. That is 3.4.5, where a daily miss set grey on a run that did not exist and production came out with sixteen rows reading grey with a count of zero. Nothing here would have caught it. **The steps are in `.planning/RELEASE.md`, and the order is not obvious.** Read it rather than working from this section. To release: bump `version` in `package.json`, commit, then `git tag vX.Y.Z && git push origin vX.Y.Z`. That runs `.github/workflows/deploy.yml`, which typechecks, tests, checks the tag against `package.json` (the admin header shows that number, so they must agree), deploys, and promotes. Four things about it are not obvious: - **The build happens on Vercel.** Vercel marks environment variables as sensitive and never returns their values to `vercel pull`, so a local `vercel build` fails on the first one it reads. - **A production deployment is staged until promoted.** Deploying alone leaves the live domains on the previous build. The promote step is what makes a release live; Instant Rollback is the way back. - **R2's CORS allowlist is a fourth environment, and nothing in this repo can see it.** The browser PUTs the photo straight to R2, so every origin the app is served from has to be named in the bucket's CORS policy in the Cloudflare dashboard. A missing origin fails the preflight and the browser reports a bare network error, indistinguishable from bad credentials or a dead bucket. It has already cost an evening: uploads were broken on `dev.curfew.amanarya.com` while every check passed, because the two origins anyone tested from, `localhost:3000` and `curfew.amanarya.com`, were the two on the list. Adding a domain means adding it there too. `bun run check:cors` says. - **A Preview deployment that never happens looks exactly like nothing happening**, which is why neither deployment comes from Vercel's GitHub App any more. When that installation is suspended or waiting on a permissions approval the push events simply stop arriving: every setting still reads correctly, the repo is still linked, the project is not paused, and there is no error anywhere, because Vercel never learned there was anything to build. It cost an hour on 2026-09-07 and happened again on 2026-09-15, when the v3.0.0 release commit got no Preview at all. `Deploy: Preview` owns `main` now and `Deploy: Production` owns the tag, both from the Actions tab, and `vercel.json` turns the App's own deploys off for both branches. `bunx vercel ls` still tells you what Vercel actually built, and github.com/settings/installations is where an approval would sit. - **`vercel.json` pins `sin1`, and production's database is still in `us-east-2`.** That pairing is wrong, and it is safe only because no tag is cut until v3 is code complete. A tag before the cutover would put every production request across the Pacific. If you ever need to ship a v2.5 fix before then, drop `regions` in that commit. ## Invariants These are not style preferences. Breaking one is a bug even if tests pass. 1. **`events` is the only source of truth.** `activity_scores`, `activity_outcomes` and every view are rebuildable from it. If you find yourself needing a fact that isn't derivable from events, say so rather than adding mutable state. 2. **Scoring reads only `checkin.*` events.** Never sessions, never `last_seen`, never login events. Ambient telemetry must not affect a fine — if it can, the app rewards not opening the site. 3. **`ledger_entries` is append-only.** No `UPDATE`, no `DELETE`. Corrections are compensating rows. Settlements are rows. 4. **Scoring config is insert-only with a future `effective_from`.** A user's windows and targets, a group's fines and grace. The app rejects `effective_from <= CURRENT_DATE`, because changing them mid-period rewrites how a period in progress is judged. Nothing in the DB enforces this. **App-wide settings are the one exception**: they are still append-only and still resolved as-of, but they carry `effective_at timestamptz` and take effect immediately. See `.planning/v3/CONFIG.md`. 5. **Resolve config as it stood on the period being scored**, never as it stands now. See the immutability block in `schema.sql`. This applies to `/verify` too, or it reports all history as drift after any config change. 6. **Nothing outside an activity module knows what a type means.** The engine consumes `{ passed, detail }` and never inspects `detail`. No `night_ok` anywhere except inside the sleep module, and no `switch` on a type key outside the registry. 7. **Money is integer minor units plus a separate currency code.** Never float. The decimal exponent comes from the currency — never a hardcoded `/100`. Split shares must sum exactly to the fine. 8. **Server timestamps only.** Client clocks are editable. 9. **A check-in is an explicit button press.** Never recorded on page load. GETs fire from prefetch, tab restore and link previews. See PRD §6b. 10. **Membership is enforced in the query layer**, via one `assertMember()` helper, on every query. RLS is deferred and is not a substitute. 11. **Turning something off never rewrites history.** Every calculation resolves the on/off state as it stood on the period being scored, and a period is judged against the settings as they stood when it closed. An admin switch may remove a requirement, never a user's ability to check in. ## Conventions - `periodStart()` is the single place a period boundary is computed. It takes the activity's boundary and schedule; nothing is hardcoded to noon or to a day. Never inline date math elsewhere. - Event types are namespaced and stable: `checkin.sleep.night`, not `night_checkin`. Renaming later means a `CASE` mapping forever. - All events go through one `recordEvent()` helper. No inline inserts. - Better Auth ids are `text`, not uuid. Table names are plural — the default singular creates a table called `user`, a reserved word. - Two Neon connection strings, `DATABASE_URL_POOLED` for the app and `DATABASE_URL_DIRECT` for migrations. No aliases and no fallbacks: the value changes per environment, the name never does. - An activity type is a **declarative module**: one file, no React. The engine draws every screen from `checkin.kind`. Adding a type never edits the engine. - **The React Compiler is on.** It memoises components for us, and it silently skips any component it cannot prove obeys the rules of React. The lint is the only place that shows, which is why `eslint-plugin-react-hooks` runs at its full ruleset and why lint failures there are not style opinions. A browser value the server cannot know goes through `useClientValue`, never through an effect that calls `setState`. - A type is only offered when it has a row in `activity_types` and that row is enabled. `bun run sync:activities` reconciles the registry, disabled by default, and runs inside `bun run migrate`. ## Not in v4 Do not build these even if you're already in the area: - Objections, meaning flagging a shared log as false. Still deferred, and flag-only when it lands. - Open signup. Groups stay invite-only. - A native app. `.planning/research/native-app/FINDINGS.md` says Google Play takes the site as-is and Apple will not, and that an API over `src/server/` is the real first deliverable. Not v4. - WhatsApp for anything. Researched and parked: no free allowance, and Meta charges for utility messages inside the 24h window from 1 October 2026. - Evidence types beyond images, though the model leaves room. - Health integrations. Steps and Screen stay manual. - Paid tiers. - Any payment integration. Money is IOU tracking only (legal constraint, PRD §8). - An app-wide leaderboard. Reputation is per group, the global score is visible only to its owner, and Monk mode is shared only where its owner shares it. - RLS, still deferred. - A third verdict on a period. v4 has exactly two, your own and the monk bar, and the shape chosen for the second one deliberately does not generalise. A third is the day to pay for a discriminator column, with the read sites already mapped in `.planning/v4/SCHEMA.md`. - A conversation history for Ren. 1.25 chose a rolling summary over the turns, so there is nothing per-question to store, show or delete. - Per-person nudge muting. 1.13: one switch, because in a group of three a blocklist is a thing people work out. **Removed from this list because v4 builds them:** AI-derived nutrition from a food photo (1.2 and 1.22 — Ren reads the pictures), and a coach voice anywhere but a push notification (1.1). ## Commands Package manager is bun. ``` dev bun run dev — Next.js against .env.preview (APAC) local bun run local — Next.js against .env.local (docker, mock data) test bun run test — Vitest, domain core, no database lint bun run lint — ESLint, type-aware, --max-warnings=0 deps bun run check:deps — fails on a deprecated dependency actions bun run check:actions — fails on an archived or out-of-date GitHub Action version bun run check:logic-version — a curve change and a streak rule change both repair themselves zones bun run check:timezones — a day belongs to the member, not to UTC streak bun run check:streak — the first thing you ever do counts offer bun run check:offer — a button is there when a press would count evidence bun run check:evidence — what you shared reaches the group cron bun run check:cron — no period waits over an hour to be scored decided bun run check:decided — the code still says what DECIDED.md says member bun run check:member — every group-scoped query checks membership browser bun run browser — every screen and every form, against a running server audit bun audit — published advisories against the lockfile migrate bun run migrate — migrations, then sync, against .env.preview break-in bun run break-in — the security round; non-zero if anything gives break-in bun run break-in:local — the same round against docker, and it sweeps HTTP sync bun run sync:activities — registry into activity_types, disabled verify bun run verify — recompute a range and diff stored rows seed bun run local:seed — mock data into the local database mocks node .design/build-v3.mjs — regenerate every artboard cors bun run check:cors — can a browser upload from this origin signin bun run check:signin — can a stranger see the sign-in page shards bun run check:shards — CI still runs every browser suite dead bun run check:dead — nothing is exported that nothing imports acts bun run doc:activities — rewrite ACTIVITIES.md from the registry sheet bun run check:activities — and CI fails if it went stale tokens bun run check:tokens — no custom CSS values, use the design system notice bun run publish:notice — announce a release to the people already here sleep bun run migrate:sleep — move existing members onto the anchored confirm remind bun run check:reminders — a reminder is sent only when a press would count sim bun run sim:push — a day of notifications, printed, no database estab bun run sim:consistency — a year of the habit number, printed, no database push bun run check:push — what Curfew actually said to people schedule bun run schedule — reconcile every QStash tick with JOBS rewrite bun run schedule -- --rewrite — recreate all; the failure callback is the one field QStash never reports back ``` **`bun run sim:consistency` is the same gate for the number that replaces the streak (1.49).** It walks six lives at the real function and prints what the row would have read: a perfect run from nothing, forty days then one missed, a week away, a three-in-five life, Gym where a period is a week, and a scattered cue. It needs nothing running. It exists for the reason `sim:push` does. Properties cannot tell you a number FEELS wrong, the same way a unit test cannot tell you a sentence is false, and reading the output found two things twelve passing property tests did not: a row saying "established 77%, 0 to automatic", which is a countdown that had finished and kept printing, and a column of weeks labelled days. Read it before changing a constant in `consistency.ts`. **`bun run sim:push` is the review gate for notification copy, and reading its output is the point of it.** It walks seven fabricated days at the real fifteen-minute tick through the real `decide`, the real kinds and the real module `remind()` functions, and prints every notification as it would appear on a lock screen. It needs nothing running. It exists because v3.3's notifications passed typecheck, lint, 301 tests and their own unit tests, and still shipped this to somebody who had logged nothing: *"Almost there on Water. 0 of 8 today. Closes at 11:59 PM. Food, Supplements and Reading and 5 more are open too."* No function in that chain was wrong on its own. The SENTENCE was, and a unit test cannot notice a sentence: it asserts the string it was told to expect. Writing the simulator then found four more defects in an afternoon, including a notification naming a deadline already in the past and a gym streak counted in days when gym's period is a week. The assertions it exits non-zero on are the mechanical half: lengths, em-dashes, repeats, silence in quiet hours. They cannot tell you a sentence is clear. Read the output. `bun run check:push` is the same question after the fact, against a real database: it prints what was delivered, word for word, from the `title` and `body` now stored on every `push.sent` event. Run the `:production` twin the morning after a release. The first version stored only THAT a send had happened, so the words nobody could read were also the words nobody could retrieve. **Two things are run by hand after the 3.2.0 tag, in this order: `migrate:sleep`, then `publish:notice`.** The first is the change and the second is what tells people about it, so announcing before moving them says something that is not true yet. Both take `--dry`, both are idempotent, and both have to be run twice: once for dev and once with the `:production` variant. `migrate:sleep` exists because sleep's confirm window stopped being two clock times in config and became half an hour that opens half an hour after the WAKE press. Config is insert-only and resolved as it stood on the period being judged (invariant 5), so the old rows are not wrong: the module still reads them and still judges those nights the way they were judged. Nothing changes for an existing member until a row without the retired pair takes effect, which is what this writes, dated tomorrow in that member's own zone. **Forgetting it is silent**: the app works, the release note has been read, and nobody's confirm window has moved. **A notice is never typed at the moment of publishing.** A controls change composes its own words from `SETTING_COPY`, and a release note is written in `release-notes.json` at the repo root in the same commit as the change it describes, so it goes through review with the code. A notice typed into a box at the moment of publishing can say something the change did not do, and nobody is reviewing it. The notes are CONTENT, not code, and live at the root rather than under `src` for two reasons: they are rewritten every release, so a `const` would have claimed to be constant while being the most-edited thing in the repo, and nothing in the app ever reads them. The app reads `notices.body` out of the database; the only caller is `scripts/publish-notice.ts`, and the reader and its schema sit beside it. `scripts/release-notes.test.ts` validates the real file on every CI run, because otherwise the first thing to read it is the release itself, and a malformed file fails at the worst possible moment. `bun run publish:notice -- --version 3.2.0 --as you@example.com --dry` prints what would be published and to how many people. Drop `--dry` to send it, and use `publish:notice:production` for the live one. Run it by hand after the tag; most releases change nothing a person would notice, and announcing those is how an overlay that blocks the whole app becomes something people dismiss unread. **`--as` is attribution, not access control, and nothing there could be.** Running it needs the connection string, and whoever holds that can insert a notice with one line of SQL. What the capability check buys is an honest record: it refuses to attribute a notice to an account that could not have published one through the app. The real guard is `--dry`, because publishing is final. There is no dismiss, only Got it, and an ack cannot be taken back. Publishing twice does nothing: a release note carries `key = release:` and a partial unique index (migration 0023). Without it the second run inserts a second row and everybody who had already acknowledged gets the overlay again. **`check:dead` is knip, and it is the one check that asks whether code RUNS at all.** Everything else asks whether running code is right. `finished()` in `haptics.ts` was written, documented and exported, and nothing called it: item 25 shipped one of its two patterns while typecheck, lint, 301 tests and eight browser suites stayed green over it. ESLint owns everything inside one file, at `--max-warnings=0`, so this is only the part that needs two: an export nobody imports, a file nobody imports, a dependency that is used and undeclared. It found `sharp` imported by `make:icons` and never declared, which worked only because Next happens to depend on it. Prefer deleting what it finds to silencing it; if something really is reached another way, say so by making it an `entry` in `knip.json`, where the reason is visible. `bun run browser` takes suite names: `bun run browser admin counter` runs those two. Order on the command line means nothing. `suites.mjs` holds the canonical order and the runner walks it, skipping what was not named, because `pause` scrubs the clock into the future and the three suites that answer for today have to precede it. CI runs the eight suites as **four parallel shards**, split by measured weight, so the job that was the pipeline's wall clock is no longer it. Each shard is its own database, server and seed, which is what makes them safe to reorder across. A suite that no shard names would never run and CI would be green and quicker for it, so `check:shards` compares the matrix against `suites.mjs` and fails on a suite that is missing, unknown, or run twice. CI runs typecheck, lint, test, build, the migration job, `break-in`, the simulation scenarios, `verify` against a seeded database, the seven narrow script checks (money, logic version, timezones, the cron schedule, that a first session counts, that a control is offered exactly when a press would count, and that a shared photograph reaches the group), the browser suite against a running app, and the three dependency and pipeline checks. A version tag will not deploy unless that whole run passed on the same SHA. `bun run browser` needs a server and a seeded database: `bun run local:seed`, then `bun run local` in another terminal. In CI the `browser` job starts a DEV server, not a built one, because LOCAL_MODE is gated on `NODE_ENV` not being "production" and `next start` sets exactly that. **Never `bun run build` while `bun run local` is running.** Both write `.next`, and the build pulls it out from under the dev server: every route then 500s and the browser suite fails thirteen checks with hundreds of page errors, which reads exactly like the change under test having broken the app. Stop the server, `rm -rf .next`, start it again. **Reseed after `bun run browser`.** Its pause suite scrubs the preview clock into a future trip, and every read on a scrubbed page closes periods, so the derived tables come out carrying days that have not happened. `verify` reports those as drift, correctly: a stored day beyond the replay is the balance `resumePointFor` would carry forward, skipping every real day in between. CI keeps the two in separate jobs for this reason. `break-in` builds its own people and groups and deletes them again, so it is safe against any database and runs in CI on every push. Its HTTP half needs a server: `break-in:local` sweeps `localhost:3000` beside `bun run local`, and `bun run break-in -- --http=` sweeps a real-auth one. Without a server that half skips itself and says so. **`check:signin` is the one check CI cannot run**, and that is why it exists. Both CI jobs that touch a running app set LOCAL_MODE, and `previewEnabled()` makes the middleware stand aside entirely, so the gate is the one code path the whole suite never takes. The sign-in page's three photographs sat behind the gate for a day because of it: a signed-out visitor got the page, and every image on it answered 307 with HTML. Run it against a deployment after anything touches `src/middleware.ts` or `public/`: `bun run check:signin -- --http=https://dev.curfew.amanarya.com`. `check:cors` takes origins as arguments and defaults to the one in `BETTER_AUTH_URL`; `check:cors:production` is the same against the live bucket. Each environment has its own bucket and its own allowlist, and they are deliberately not interchangeable: `curfew-evidence-dev` accepts `dev.curfew.amanarya.com` and `localhost:3000`, `curfew-evidence` accepts only `curfew.amanarya.com`. Per-commit `*.vercel.app` preview URLs are on neither, so uploads do not work from them. Three environment files, all gitignored, all carrying the same keys in the same order. Only the values differ, and `.env.example` is the key list. | File | Database | Used by | |---|---|---| | `.env.local` | docker Postgres, `LOCAL_MODE=1` | `bun run local`, `local:*` | | `.env.preview` | `curfew-apac-dev`, a Neon branch | `bun run dev`, `migrate` | | `.env.production` | the live project | `migrate:production` | The right-hand column is local commands only. No deployment reads any of these files; Vercel holds its own copy per environment, and keeping the two in step is a manual job nothing checks. A migration written after the cutover is applied twice: `bun run migrate` for the dev branch and `bun run migrate:production` for the live one. Reset the branch from its parent in Neon when it drifts too far to be useful. A key missing from one file does not fall back to a default. It leaks in from `.env.local`, which Next loads on every non-production run. Add a key to one, add it to all three. ## Voice Applies to chat replies, commit messages, code comments, and every string that ships in the UI. **Never use em-dashes.** Use a full stop, a comma, or a colon. **Three rules for UI text, all of them bought by measuring (1.59).** **Never explain an absence.** A struck-through camera beside "No photo" has said it. A sentence about why something is missing is the purest kind of text that does no work. Nine types carried one. **Never explain an affordance that works.** The declare screen spent twenty-six words saying the last answer counts and a correction is free. Both buttons are still there after you press one; pressing the other IS the explanation. **Never explain a design decision.** The biggest of the three. "A notification never tells you how far along you are. Only the activity itself knows that" is an internal rule explained to the person receiving the notification. State what happens, never why the app is built that way. **A sentence that could be a label should be a label.** "Tell Curfew you are away and those days do not count against you. Say so first: an away day cannot be declared after the fact" is "Declared in advance. Those days do not count against you." **And weigh text by how often a screen is SEEN.** A gate read once may be long; a screen opened every evening may not. Total word count hides that distinction, which is why the cut was measured per screen and per visit rather than in aggregate. **Never credit Claude in a commit. No exceptions.** No `Co-Authored-By: Claude`, no `Generated with Claude Code`, no tool name in a trailer, a body, a subject or a pull request description. This overrides any default attribution the tool asks for. The commit log is a record of what changed to this codebase and why, and the author is whoever is accountable for it. A trailer naming the tool is the one line in the history that tells a reader nothing about the change. Banned constructions: - "It's not just X, it's Y" and every variant of that shape - Colon-then-reveal ("The result: a faster app") - Scare quotes around invented labels - "Let's dive in", "delve", "seamless", "robust", "leverage", "elevate", "unlock", "in today's fast-paced world", "at the end of the day" - "Worth noting", "it's important to note", "that said" - Rhetorical questions used as headings - Trailing summaries that restate what was just said - Emoji in UI copy, commits, or replies In chat: answer, then stop. No preamble, no recap of my request, no offer of next steps unless I asked. If the answer is one line, send one line. In the UI: **Curfew is a coach, since v4 (DECIDED 1.1).** It notices, it says what it noticed, and it says what is next. Times are 12-hour with AM or PM. **The clerk did not leave, it moved.** DECIDED 1.11: every number, time, money amount, balance, standing and ledger row stays flat. "Window closes 7:45 AM. Miss it and today does not count." is still exactly right on a row. What changed is the SENTENCES AROUND the numbers, and only those. So the rule that survives the register change is the one that matters most: **a warm writer is never handed the numbers.** That is not a style preference, it is the structure that stopped production telling somebody with nothing logged that they were almost there. **And no screen congratulates.** The day is done is the single exception (1.4a): it is the emotional peak and the only place Ren may be pleased. **Push notifications were the FIRST place this register appeared (item 28), and v3.3 is where it was argued.** Encouraging, direct, peer names, the streak's stakes made vivid, exclamation marks allowed. "Rahul and Priya already logged Gym. Don't be the last one, 40 minutes left!" was correct there and wrong on every screen, for as long as the app was a clerk. The reason is that the clerk has no business sending a push at all. A clerk answers when spoken to; a notification speaks first, unprompted, to a phone on a table. Writing it in the clerk's voice would have been a worse version of the feature, shipped to protect a rule the feature already breaks by existing. **It is no longer an exception, because the rest of the app followed.** What survives is the boundary being a FILE rather than a judgement call: copy lives in `src/server/notification-copy.ts` and Ren's lives in `src/server/coach/`, and neither is ever handed a raw count. Two warm writers now, the same structure around both. A push is still the only thing that speaks first to a phone on a table, so it stays the loudest of them. Exclamation marks are still for lock screens. **Theme 3 answered yes on 2026-09-20 and v4 is building it.** `.planning/v4/DECIDED.md` is the authority on voice now, 1.1 to 1.61. Read it before writing a line of UI copy; this section is the mechanical half and that file is the register. **Ren speaks in five places and is silent everywhere else** (1.4a): his own tab, Home, After a miss, Your record, and the day is done. He is silent on check-in and Declare, because time-to-press is on a six-second budget and he must never be in the way of one. He is silent in Groups, which is other people's space, and on Configure, Standing and Ranks, which are forms and reference. **One line per screen, never two.** That holds even where 1.28 lets him mention a nudge: what he says about it IS his line for that screen, not a second one on top. A voice that comments on everything is noise. **Two hard rules inside that file, both bought with a bad release (v3.4).** **One notification is about one activity, and it names it.** The first version sent a digest listing everything still open, which with nine activities was a pile of nouns with a number attached to none of them. If a title does not say which activity it is about, it is not finished: "Don't break a 24 day streak" was caught by a test for exactly this. **No line in that file may describe progress.** Not "almost", not "nearly", not "one more", not a count. The only sentence in a notification that says how far along something is comes from the activity module, through `remind()`, and the copy may place it and nothing else. This is enforced structurally, by not giving any writer the numbers, and by tests over every seed in `notification-copy.test.ts`. The reason is what happens when it is not. The bank used to be chosen by asking whether the module's `hint` was a non-empty string, and water's hint at zero is `"0 of 8 today."`, which is non-empty. So production told somebody with eight glasses outstanding that they were almost there, twice, half an hour apart. The words were not vague, they were false, and no test in the repo could have said so because every function involved was doing its job. `hint` and `remind` are different sentences for different readers and must never be substituted for each other. `hint` sits under a control on a screen that already shows the name and counts UP; `remind` is for a lock screen, counts DOWN, and is what the notification path reads. ## The design system `tailwind.config.ts` is it, and `bun run check:tokens` is what stops it being a suggestion. **Never write a custom CSS value.** Not `text-[13px]`, not `p-[7px]`, not `[#ff7a2f]`, not a hardcoded px inside `style={{}}`. | | | |---|---| | colour | 20 semantic tokens over CSS variables, both themes | | font size | `text-micro 2xs xs sm base lg xl 2xl 3xl 4xl` | | line height | `leading-tight snug normal relaxed loose` | | letter spacing | `tracking-tight normal wide wider caps label widest` | | radius | zero, and Tailwind has no other value to write | | spacing | Tailwind's own 4px scale. `gap-2.5` is 10px, `p-3` is 12px | **The check has two tiers and they are two different problems.** Font size, line height, letter spacing, colour and family have COMPLETE token sets, so any escape fails at any count. Spacing and sizing are a RATCHET: a 560px reading column is layout rather than drift, so the count may fall and may never rise. **Lower `CEILING` in `scripts/check-tokens.ts` when it drops.** A ratchet only works if somebody turns it. **The measurement that produced all this (1.61).** 98 component files carried 829 arbitrary font sizes across 23 distinct sizes, 167 line heights, 152 letter spacings with 86 of them identical, and 654 spacing values beside a 4px scale that 615 other uses already obeyed. Colour was the exception at three escapes, because a semantic token is easier to write than a hex, and radius held because `rounded-lg` does not exist. **That is the whole lesson: a token holds when the wrong thing is harder to write than the right one.** Everything above is now in that shape. ## Visual tells to avoid The mock is deliberately not a default AI-generated interface. Do not drift back toward: - Purple or indigo gradients, glassmorphism, glow effects. **One exception**: IMMACULATE, which is UNBROKEN plus 60 days with nothing missed, carries a gold halo. It is the only glow in the app, which is what makes it mean anything - Cards with rounded corners and drop shadows on everything - Centred hero text with a large gradient headline - Emoji or generic line icons as section markers - Inter or system-sans everywhere. This project is IBM Plex Mono throughout. - Pill buttons. Zero border radius is the house style. - Colour as the only carrier of meaning. Signs and labels carry it too. ## Working style - Raise contradictions between the docs rather than picking one silently. - Prefer saying a design is wrong over working around it. - **Never block on a slow check.** Background it, or push and let CI run it, and carry on with the next thing meanwhile. The slow ones are the browser suite, `simulate`, `verify`, `break-in` and anything else wanting a server or a database. Sitting and waiting for one is the wrong trade every time: typecheck, lint and the unit tests answer in seconds and catch most of what a change breaks, and **CI runs all of the slow ones on every push anyway**, in four parallel shards, against its own throwaway Postgres. So pushing IS the way to run them. It costs nothing, it needs no Docker on this machine, and a version tag will not deploy unless that whole run passed on the same SHA, which is the thing that actually protects production. Two rules that make this safe rather than sloppy: - **Say what was proved and what is still running.** "Typecheck, lint and 267 tests pass; the browser suite is running in CI" is honest. Reporting a pass that has not come back is not, and neither is going quiet until it does. - **Do not start Docker, a dev server or a seed just to verify something CI will verify.** A local run that needs three services to be up is three more things that can fail for reasons that are not the change, and diagnosing those is the time the waiting was supposed to save. Push to `main` freely for this. `main` is the dev site, not production, `Deploy: Preview` ships it, and a failed build leaves the previous deployment serving rather than breaking anything.