Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
21 commits
Select commit Hold shift + click to select a range
7d2836c
feat(consent): record SMS consent as an append-only ledger
codyjohnsontx Sep 26, 2026
33ff215
no-mistakes(review): Check consent CHECK constraints against Postgres…
codyjohnsontx Sep 26, 2026
3c37628
no-mistakes(review): Backfill skips stop phrases; database enforces a…
codyjohnsontx Sep 26, 2026
0967177
no-mistakes(review): Fold non-ASCII whitespace in SQL consent classif…
codyjohnsontx Sep 26, 2026
3fba040
no-mistakes(document): Note consent refusal on the verify skill's sen…
codyjohnsontx Sep 26, 2026
af184d1
fix(consent): close the second review's ledger ordering and deletion …
codyjohnsontx Sep 26, 2026
94b605b
no-mistakes(document): Correct decision log's stale demo reseed tradeoff
codyjohnsontx Sep 26, 2026
5ac2f84
fix(consent): break an exact legacy tie by the customer's final flags
codyjohnsontx Sep 26, 2026
0c723f6
no-mistakes(review): Say a possible stop text awaits review in consen…
codyjohnsontx Sep 26, 2026
36b3665
no-mistakes(review): Note the inbound pause seam exists for the race …
codyjohnsontx Sep 26, 2026
37cc90b
no-mistakes(review): Pass over everyday STOP phrasings in the review …
codyjohnsontx Sep 26, 2026
518bbbc
no-mistakes(review): Stop everyday STOP exclusions from matching acro…
codyjohnsontx Sep 26, 2026
f1efc64
no-mistakes(review): Stop everyday STOP exclusions from matching acro…
codyjohnsontx Sep 26, 2026
721efdb
no-mistakes(review): Apply owner's rules for everyday STOP phrases an…
codyjohnsontx Sep 26, 2026
dc52414
no-mistakes(review): Strip wrapping quotes and brackets before keywor…
codyjohnsontx Sep 26, 2026
4d2ba84
no-mistakes(review): Strip remaining wrapping punctuation before keyw…
codyjohnsontx Sep 26, 2026
3b6c1c4
no-mistakes(document): Record wrapped-punctuation keyword matching in…
codyjohnsontx Sep 26, 2026
dac74c8
no-mistakes(ci): The Build and Vercel failures had one cause, and thi…
codyjohnsontx Sep 26, 2026
3802539
fix(consent): read every text in one canonical form, and never let an…
codyjohnsontx Sep 26, 2026
618faaa
no-mistakes(review): Drop "stop at" from everyday STOP exemption in J…
codyjohnsontx Sep 27, 2026
fc55dc3
no-mistakes(document): Rewrap everyday STOP phrase doc comment and PR…
codyjohnsontx Sep 27, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion .claude/skills/verify/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ description: How to run and drive Attend locally to verify changes end-to-end (d

- `npm run dev` (port 3000). Next 16 refuses a second dev server for the same dir — if one is already running, reuse it (it hot-reloads code AND `.env` changes) or kill it first.
- Gotcha: a long-running dev server keeps a **stale Prisma client** in its module graph after `prisma generate` / new migrations — Prisma validation errors like "Unknown argument `<compoundUnique>`" mean restart the dev server, not a code bug.
- A `.next` directory left from a dev server run under another path (a reused worktree) makes Turbopack resolve from the wrong root: `Can't resolve 'tailwindcss'` in the parent directory and `Cannot find module 'next-auth'` on `/api/auth/*`, so sign-in silently does nothing. Stop the server, `rm -rf .next`, start again.
- `npm run build` fails locally at `prisma generate` (`ERR_REQUIRE_ESM` in `@prisma/dev` on Node 20) — pre-existing; run `npx --no-install next build` directly to validate the app build against the existing generated client in `src/generated/`.
- `next build` needs `DATABASE_URL` **in the environment** — it does not load `.env` for the Prisma-client module eval during page-data collection, so a clean build errors with "DATABASE_URL is required to create the Prisma client" on every Prisma-importing route (`/login`, etc.). `next build` only needs `DATABASE_URL` (that's all `src/lib/prisma.ts` reads). Export it first: `export $(grep -E '^DATABASE_URL=' .env | sed 's/\"//g')` then `npx --no-install next build`. (On Vercel it's a real build env var, so CI/prod builds fine.)
- `npm run prisma:seed` reseeds demo data (destructive-recreate of seeded conversations; user IDs stable). With `OPENAI_API_KEY` set it also regenerates every seeded AI brief through the real model, so the seed costs money: `SEED_AI_BRIEFS=false` skips that step (see the README's seed section).
Expand All @@ -25,7 +26,7 @@ description: How to run and drive Attend locally to verify changes end-to-end (d

`DEMO_USER_EMAIL` is often set to `service@ctxchat.local` locally - the primary-user account. `isDemo` is stamped into the JWT at sign-in from that variable, so the service advisor is demo-capped and `POST /api/messages/send` returns 403 before it reaches any send logic. To exercise sending, repoint `DEMO_USER_EMAIL` at something else, then **sign out and back in** (editing `.env` alone does not restamp an existing token).

Twilio is normally unconfigured locally, so a send is persisted and then marked `FAILED` with a 503 rather than reaching a carrier. That is the cheapest way to produce a failed-delivery state to look at.
Twilio is normally unconfigured locally, so a send is persisted and then marked `FAILED` with a 503 rather than reaching a carrier. That is the cheapest way to produce a failed-delivery state to look at. Only a customer whose `smsConsent` is `GRANTED` gets that far: anyone else is refused with 403 (`reason` `NO_CONSENT` or `REVOKED`) before anything is persisted, so pick a thread other than the seeded opted-out customer (Lena Ortiz).

"View demo" signs in as whichever account `DEMO_USER_EMAIL` names, and that account has to exist in the database you are pointed at: if it does not, the button bounces back to `/login?reason=inactive` with no server-side error. Restart the dev server with `DEMO_USER_EMAIL=service@ctxchat.local` to get a passwordless login as the service advisor against seeded data.

Expand Down
1 change: 1 addition & 0 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -1306,6 +1306,7 @@ copy of one of these rules is how the screen and the database end up disagreeing
* How long a reply may be, and what to refuse it with: [`src/lib/sms-length.ts`](./src/lib/sms-length.ts). There is no single limit. Twilio picks the encoding from the body, so one emoji or pasted curly quote moves a 1600-character reply to a 700-character one; the module carries the GSM 03.38 alphabet to work that out, and the box and the send route both call it. A constant here is the bug.
* Where a conversation opens, and when it moves on its own: [`src/lib/thread-scroll.ts`](./src/lib/thread-scroll.ts). The thread renders oldest first, so something has to move it. The rule is written against where content **ends on screen** rather than one box's `scrollTop`, because the conversation is not always its own window: from `lg` up it scrolls inside the thread column, and on a phone it scrolls with the page. One tested function serves both; a `max-h` or an unprefixed `overflow-y-auto` on the message list is what put a 287px porthole on a 390px screen.
* When the dealership's day ends, and whose day a due date is counted on: [`src/lib/dealership-day.ts`](./src/lib/dealership-day.ts). A due date is an instant, and an instant is not a day until something picks a timezone. The **Command Center** due-today metric, the list behind it and the sweep that raises `FOLLOW_UP_DUE` all read the **dealership's** day - `DEALERSHIP_TIME_ZONE`, default `America/Chicago` - because a cron sweep has no viewer to ask. Unset or blank keeps that default deliberately; a non-blank value that is not an IANA zone throws at module load, so a typo fails `next build` rather than counting the day in a zone nobody chose. What the **viewer's** clock owns is the printed moment, and that is `LocalTimestamp` in a client component: a server render is UTC on Vercel, which printed a 10pm follow-up as due 3am the next day. The two disagree by design; `tests/dealership-day.test.ts` pins both, including a scan that fails any non-client file that formats a moment - `toLocale*`, `formatTimestamp`, date-fns `format`, or an `Intl.DateTimeFormat(...).format(...)` built and read in one breath, but not `formatDistanceToNow`, which is a gap between two instants and has no timezone in it.
* Whether a customer may be texted: [`src/lib/consent.ts`](./src/lib/consent.ts). Consent is an append-only `ConsentEvent` ledger with evidence, never a flag; `consentState` derives the status, `describeConsent` is the one sentence every badge and card shows, and the STOP/START word rule lives here, not in `phone.ts`. `Customer.smsConsent` is a cache written only by `recordConsentEvent` in `src/lib/consent-ledger.ts`, in the event's transaction; the database refuses to update, delete or truncate an event (a correction is a new event) and customer deletion is restricted. The inbound webhook (`src/lib/inbound-text.ts`) takes the customer's row lock before it writes anything and dates the text from the database clock under it, so what a text means and where its event sorts follow one order; the demo seed writes a customer's consent only while they have no history. The default is `NONE`, and `NONE` is not textable. Those triggers, the method-to-kind pairing, the staff evidence rules and the backfill's `consent_classify_reply` exist only in `prisma/migrations/20260925090000_consent_ledger`; `tests/consent-cache.test.ts` fails if code and database disagree. Background: [content/decisions/2026-09-24-consent-is-a-ledger-not-a-flag.md](./content/decisions/2026-09-24-consent-is-a-ledger-not-a-flag.md).

Five habits this repo has paid for more than once:

Expand Down
1 change: 1 addition & 0 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -1304,6 +1304,7 @@ copy of one of these rules is how the screen and the database end up disagreeing
* How long a reply may be, and what to refuse it with: [`src/lib/sms-length.ts`](./src/lib/sms-length.ts). There is no single limit. Twilio picks the encoding from the body, so one emoji or pasted curly quote moves a 1600-character reply to a 700-character one; the module carries the GSM 03.38 alphabet to work that out, and the box and the send route both call it. A constant here is the bug.
* Where a conversation opens, and when it moves on its own: [`src/lib/thread-scroll.ts`](./src/lib/thread-scroll.ts). The thread renders oldest first, so something has to move it. The rule is written against where content **ends on screen** rather than one box's `scrollTop`, because the conversation is not always its own window: from `lg` up it scrolls inside the thread column, and on a phone it scrolls with the page. One tested function serves both; a `max-h` or an unprefixed `overflow-y-auto` on the message list is what put a 287px porthole on a 390px screen.
* When the dealership's day ends, and whose day a due date is counted on: [`src/lib/dealership-day.ts`](./src/lib/dealership-day.ts). A due date is an instant, and an instant is not a day until something picks a timezone. The **Command Center** due-today metric, the list behind it and the sweep that raises `FOLLOW_UP_DUE` all read the **dealership's** day - `DEALERSHIP_TIME_ZONE`, default `America/Chicago` - because a cron sweep has no viewer to ask. Unset or blank keeps that default deliberately; a non-blank value that is not an IANA zone throws at module load, so a typo fails `next build` rather than counting the day in a zone nobody chose. What the **viewer's** clock owns is the printed moment, and that is `LocalTimestamp` in a client component: a server render is UTC on Vercel, which printed a 10pm follow-up as due 3am the next day. The two disagree by design; `tests/dealership-day.test.ts` pins both, including a scan that fails any non-client file that formats a moment - `toLocale*`, `formatTimestamp`, date-fns `format`, or an `Intl.DateTimeFormat(...).format(...)` built and read in one breath, but not `formatDistanceToNow`, which is a gap between two instants and has no timezone in it.
* Whether a customer may be texted: [`src/lib/consent.ts`](./src/lib/consent.ts). Consent is an append-only `ConsentEvent` ledger with evidence, never a flag; `consentState` derives the status, `describeConsent` is the one sentence every badge and card shows, and the STOP/START word rule lives here, not in `phone.ts`. `Customer.smsConsent` is a cache written only by `recordConsentEvent` in `src/lib/consent-ledger.ts`, in the event's transaction; the database refuses to update, delete or truncate an event (a correction is a new event) and customer deletion is restricted. The inbound webhook (`src/lib/inbound-text.ts`) takes the customer's row lock before it writes anything and dates the text from the database clock under it, so what a text means and where its event sorts follow one order; the demo seed writes a customer's consent only while they have no history. The default is `NONE`, and `NONE` is not textable. Those triggers, the method-to-kind pairing, the staff evidence rules and the backfill's `consent_classify_reply` exist only in `prisma/migrations/20260925090000_consent_ledger`; `tests/consent-cache.test.ts` fails if code and database disagree. Background: [content/decisions/2026-09-24-consent-is-a-ledger-not-a-flag.md](./content/decisions/2026-09-24-consent-is-a-ledger-not-a-flag.md).

Five habits this repo has paid for more than once:

Expand Down
70 changes: 70 additions & 0 deletions content/decisions/2026-09-24-consent-is-a-ledger-not-a-flag.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,70 @@
# Decision: Consent Is a Ledger, Not a Flag

## Date

2026-09-24

## Status

Accepted

## Context

Attend recorded SMS consent as two booleans on `Customer`: `smsOptedIn`,
defaulting to true, and `smsOptedOut`. Any row created by any path was
consented at birth with no evidence, and nothing said why a customer could be
texted or since when. The owner settled the rule on 2026-09-24: consent is a
hard rule, not a judgement call, enforced by plain code with no model involved.
A rule like that needs something to read, and a boolean with a permissive
default is not a record of anything.

## Options Considered

1. Keep the booleans, flip the default to false, and add a "consent source"
column.
2. An append-only `ConsentEvent` ledger, with the status derived from it and
cached on `Customer` in the same transaction.
3. The ledger with no cache, computing the status on every read.

## Decision

Option 2. Every grant and revocation is an event with the phone number, the
method, when the customer acted, and the evidence (the inbound text, or for a
staff-recorded method, the recorder and what the customer said or signed). The
status is `consentState` over those events, in `src/lib/consent.ts`, and
`Customer.smsConsent` caches it, written only by `recordConsentEvent` in the
event's transaction. A customer with no event is `NONE`, and `NONE` is not
textable.

## Reasoning

- Option 1 records one fact at a time and overwrites it. A dispute asks what
the customer did and when, including what they did before the last change,
and an overwritten column cannot answer that.
- The ledger matches how the repo already treats shared rules: one
database-free module and its tests, read by every surface.
- The cache (owner decision 9) keeps the customers list and the queue to one
query, and a database-backed test holds it equal to the ledger.
- Events are never updated or deleted and customer deletion is restricted
(owner decision 7), so a STOP outlives any edit to the customer.

## Tradeoffs

- More rows and one more table than a flag, and a customer the ledger holds
evidence about can no longer be deleted.
- Behaviour tightens on migration: a customer who once texted "REVOKE", "OPT
OUT" or "Stop." is now opted out, and any customer with no text and no event
becomes unsendable. That is the intent, and the pre-migration count in the
PRD is how the owner sees it before it happens.
- The demo reseed can no longer wipe consent history. A seeded customer who
already has one keeps it exactly as it stands; only a customer with no
history gets the seed's events.

## Portfolio Notes

Turning a legal requirement into a data model: the question "may we text this
person?" became a pure function over an evidence ledger, so the answer can be
tested exhaustively and explained on screen ("Opted out since 3 Sep - they
replied STOP") instead of trusted.

PRD: [2026-09-24 A Text Goes Out Only on Recorded Consent](../prds/2026-09-24-a-text-goes-out-only-on-recorded-consent.md).
Loading
Loading