CLAUDE.md
Rama main · réplica de lectura del repositorio original.
# 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 `<input type="time">`, 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:<version>`
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=<origin>` 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.