Skip to content

Commit 219d161

Browse files
committed
docs(W5): invert coupons ownership narrative — app owns catalog, pipeline supplies via ingest; retire degraded-mode + drift-gate docs
1 parent 7181600 commit 219d161

12 files changed

Lines changed: 456 additions & 185 deletions

File tree

CLAUDE.md

Lines changed: 7 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -71,23 +71,23 @@
7171
- Setup: `pnpm install``cp apps/caramel-app/.env.example apps/caramel-app/.env` (secrets table in README) → `pnpm dev` (F-016 one-root-compose: `docker compose up --build` builds web, boots pg :58005, runs `prisma migrate deploy` in-container).
7272
- Run: `pnpm dev` (docker compose up --build → app :58000; one root compose = local/CI/prod graph, hot reload traded away 2026-07-09) · host escape hatches (need `docker compose up postgres -d` first): `pnpm dev:next` (app :58000 with hot reload), `pnpm dev:extension` (web-ext). See docs/LOCAL-DEV.md.
7373
- Test: `pnpm test` (turbo → vitest: app `tests/unit/**/*.test.{ts,tsx}` + extension `tests/*.mjs`) · single file: `pnpm --filter caramel-app exec vitest run tests/unit/<file>` · e2e: `pnpm --filter caramel-app test:e2e` (needs DB+migrations) · evals (live LLM, costs money): `pnpm --filter caramel-app eval` (needs `OPENROUTER_API_KEY`).
74-
- Gates (all also run in husky pre-commit + CI): `pnpm lint` · `pnpm lint:oxlint` · `pnpm prettier-check` · `pnpm --filter caramel-app knip` · `pnpm -r run type-check`. Ops: `pnpm --filter caramel-app smoke`, `... check:coupons-schema` (see RUNBOOK.md).
74+
- Gates (all also run in husky pre-commit + CI): `pnpm lint` · `pnpm lint:oxlint` · `pnpm prettier-check` · `pnpm --filter caramel-app knip` · `pnpm -r run type-check`. Ops: `pnpm --filter caramel-app smoke`, `... test:integration` (needs local pg), `... bridge:sync` (migration-period external→app catalog feed; needs `COUPONS_DATABASE_URL`) — see RUNBOOK.md.
7575

7676
## Architecture (10 lines)
7777

78-
- pnpm@9 monorepo, two packages. `apps/caramel-app` = Next.js 16 App Router: Prisma → auth/user Postgres; the coupon catalog lives in a SECOND, externally-owned Postgres (`caramel_coupons`, written only by an out-of-repo Python service, +3 sanctioned mutations from this app — see DESIGN.md §2 "Write-ownership") read via porsager `postgres` — connection/fragments/zod schemas in `src/lib/couponsDb.ts`, all 13 query fns + the structural drift-gate registry in `src/lib/couponsRepo.ts` (routes never write inline SQL) — every read zod-parsed by `parseCouponRows` (schema drift throws loudly).
79-
- All env access via `src/lib/env.ts` / `env.client.ts` (zod, boot fail-fast via instrumentation.ts). Every API route declares itself through `src/lib/api/withRoute.ts` (CORS/rate-limit/origin/bearer/zod-body/OPTIONS) and errors through `handleRouteError` → Sentry. Coupon domain vocabulary/predicates live ONLY in `src/lib/coupons.ts` (+ SQL fragment factories in couponsDb.ts).
78+
- pnpm@9 monorepo, two packages. `apps/caramel-app` = Next.js 16 App Router: Prisma → ONE Postgres (`DATABASE_URL`) holding BOTH auth/user AND the app-OWNED coupon catalog (`coupons`/`store_configs`/`sources`, created by the `catalog_tables` migration + a synthetic seed; app owns it since the Coupons Ownership Inversion — see DESIGN.md §2 "Write-ownership"). The external Python pipeline is now a SUPPLIER: it pushes catalog deltas to `POST /api/ingest/catalog` (`applyCatalogRows` — only-if-newer delta upsert, one transaction, >20% tombstone gate; migration-period feed = the read-only `bridge:sync` job). Reads run via `prisma.$queryRaw` — 10 read fns + 2 sanctioned writes (`expireCoupons`/`requestSource`) in `src/lib/couponsRepo.ts` (routes never write inline SQL), zod row schemas + `parseCouponRows` in `src/lib/couponsDb.ts` (schema drift throws loudly at parse time).
79+
- All env access via `src/lib/env.ts` / `env.client.ts` (zod, boot fail-fast via instrumentation.ts). Every API route declares itself through `src/lib/api/withRoute.ts` (CORS/rate-limit/origin/bearer/zod-body/OPTIONS) and errors through `handleRouteError` → Sentry. Coupon domain vocabulary/predicates live ONLY in `src/lib/coupons.ts` (+ the `Prisma.sql` fragment factories in couponsRepo.ts).
8080
- Extension OAuth session mint: `src/lib/auth/extensionOAuthSession.ts` (one module, deliberately not better-auth — see DESIGN.md).
8181
- `apps/caramel-extension` = plain-JS MV3, NO bundler: content scripts share one global scope, load order = manifest order (`coupon-constants.generated.js``caramel-base``dom-utils``store-detect``coupon-apply``coupon-fetch``coupon-runner`); `background.js` is a separate service-worker realm. LLM surface: `cartClassifier.ts``/api/classify-cart` (OpenRouter), eval-gated.
82-
- Deploys: Dokploy/Nixpacks → grabcaramel.com. Local coupons DB does NOT exist — coupon routes 500 `{error}` and health reports `coupons_db: error`; that degraded mode is EXPECTED locally.
82+
- Deploys: Dokploy/Nixpacks → grabcaramel.com. `pnpm dev` migrates AND seeds the catalog locally, so coupon routes return 200 and `/api/health/db` reports app-catalog freshness (`catalog: ok` + `{count,freshestUpdatedAt,ageMinutes,stale}`); the old externally-owned-DB "degraded mode" is RETIRED. App-owned telemetry (worked/used/failed) lives in `coupon_signals` (`src/lib/couponSignals.ts`), split out of the catalog so a usage bump never touches `coupons.updated_at`.
8383

8484
## Conventions in force → their enforcing check
8585

86-
- Exact-pinned deps, one root lockfile → `tests/unit/deps-pinned.test.ts` + CI `audit` job (`--audit-level=high`). No `any` → eslint `@typescript-eslint/no-explicit-any` (error). Raw coupon-status literals banned outside `coupons.ts`/generated file → `tests/unit/no-raw-coupon-status.test.ts`. Generated extension constants byte-synced → `tests/unit/coupon-constants.generated.test.ts` (regenerate via `pnpm --filter caramel-app generate:coupon-constants`, NEVER hand-edit; codegen must emit prettier's fixed point — format via prettier API). Root-file allowlist + manifest integrity → `tests/unit/repo-integrity.test.ts`. Extension payload budgets → size-limit (92 KB summed content-scripts group; ~0.9 KB headroom). Eval gate ≥0.85 primary-match, PR path-filtered + nightly → `ai-evals.yml`; model/prompt changes need `pnpm eval` green ×2 + a dated `evals/SCOREBOARD.md` row. knip green with zero unjustified ignores. Coupons SQL lives only in `src/lib/couponsRepo.ts` (never inline in a route/page) → the structural drift gate (`pnpm --filter caramel-app check:coupons-schema`, `tests/drift/coupons-schema.drift.ts`) runs every registered query for real against a live coupons DB, replacing the old hand-maintained `EXPECTED_COLUMNS` mirror (deleted — it missed JOIN/WHERE columns absent from every zod output). No coupons/sources/verification\_\* schema in `prisma/schema.prisma`, ever (secrecy forward-rule, DESIGN.md §2(k)) → no automated check yet, `TODO:` a CI grep gate. Unenforced (memory only): new routes must use `withRoute`; env reads only via the env modules; new `.eval.ts` stays out of the unit glob.
86+
- Exact-pinned deps, one root lockfile → `tests/unit/deps-pinned.test.ts` + CI `audit` job (`--audit-level=high`). No `any` → eslint `@typescript-eslint/no-explicit-any` (error). Raw coupon-status literals banned outside `coupons.ts`/generated file → `tests/unit/no-raw-coupon-status.test.ts`. Generated extension constants byte-synced → `tests/unit/coupon-constants.generated.test.ts` (regenerate via `pnpm --filter caramel-app generate:coupon-constants`, NEVER hand-edit; codegen must emit prettier's fixed point — format via prettier API). Root-file allowlist + manifest integrity → `tests/unit/repo-integrity.test.ts`. Extension payload budgets → size-limit (98 KB summed content-scripts group). Eval gate ≥0.85 primary-match, PR path-filtered + nightly → `ai-evals.yml`; model/prompt changes need `pnpm eval` green ×2 + a dated `evals/SCOREBOARD.md` row. knip green with zero unjustified ignores. Coupons SQL lives only in `src/lib/couponsRepo.ts` (never inline in a route/page) → exercised by the integration suite (`tests/integration/coupons-read.itest.ts`, `coupons-write.itest.ts`, `ingest-catalog.itest.ts`, `bridge-sync.itest.ts`; `pnpm --filter caramel-app test:integration`) running the real queries against a live app Postgres — this REPLACED the deleted structural drift gate (`check:coupons-schema`, `tests/drift/coupons-schema.drift.ts`, `vitest.drift.config.ts`, `EXPECTED_COLUMNS`, `couponsQueryProbes`, all removed in W4); every read is still zod-parsed by `parseCouponRows` (schema drift throws loudly). Secrecy forward-rule INVERTED (W3): the app now LEGITIMATELY owns `coupons`/`sources`/`store_configs` in `prisma/schema.prisma`; what stays out is the scraper's PIPELINE-INTERNAL machinery (`verification_stores`, `store_verification_configs`, `verification_*`, `agent_*`/`scraper_*`) → enforced by `tests/unit/prisma-schema-secrecy.test.ts` (map-target based: bans those @@map/@map targets, allowlists `verification_message`/`verification_tokens`). Unenforced (memory only): new routes must use `withRoute`; env reads only via the env modules; new `.eval.ts` stays out of the unit glob.
8787

8888
## Gotchas (each cost a real debugging round)
8989

90-
- `vi.mock('@/lib/couponsDb', {...importActual})` does NOT intercept internal calls of re-exported functions (closure binds the real module) — read the header comment in `tests/unit/coupons-visibility.test.ts` before touching such mocks.
90+
- Coupon-read tests now mock `@/lib/prisma` (not couponsDb) and assert on the composed `.sql` string: a `Prisma.sql` fragment nested into a `$queryRaw` template INLINES into the parent's flattened `?`-placeholder text, so proving a route uses a shared predicate is a direct string match — read the header in `tests/unit/coupons-visibility.test.ts` / `couponsRepo.test.ts` before touching such mocks.
9191
- `.gitattributes` pins `eol=lf` (NF-04) so fresh clones are safe, but a clone predating it with `core.autocrlf=true` breaks the byte-exact generated-file test — `git config core.autocrlf false && git checkout-index -a -f` (`git reset --hard` won't rewrite round-trip-clean files; docs/LOCAL-DEV.md troubleshooting).
9292
- `openai/gpt-5-mini` is a REASONING model: completion budget must include hidden reasoning tokens (`maxTokens: 600`, see F-017 in `evals/SCOREBOARD.md`) — never trim it back to "just enough JSON".
9393
- Extension cross-file globals: a function used only from a sibling content-script file needs `// oxlint-disable-next-line no-unused-vars` as the LAST comment line above it (prettier reorders otherwise); `_isDevInstall` must stay in `caramel-base.js` (called at module-eval time — cross-file hoisting doesn't exist).
@@ -97,5 +97,5 @@
9797
## Hard boundaries (never without explicit human direction)
9898

9999
- Never push to or merge into `dev`/`main`; audit PRs are merged by humans only. Never mutate GitHub repo settings.
100-
- The coupons DB is read-only by discipline — no new write paths (`increment`/`expire`/`sources POST` are the sanctioned exceptions); its schema is owned by the external Python service.
100+
- The app OWNS the coupon catalog (`DATABASE_URL`): the ONE sanctioned supplier write path is `POST /api/ingest/catalog` (`applyCatalogRows`, tombstone-gated); the app's own sanctioned writes are `expireCoupons` + `requestSource` (usage moved to `coupon_signals.recordUsage`, NOT the catalog — `incrementCouponUsage` retired). The EXTERNAL `caramel_coupons` DB stays strictly read-only — SELECT-only, via the migration-period `bridge:sync` only. Don't add other write paths to either.
101101
- Don't convert the extension to a bundler/ESM, don't re-flag the deliberate designs listed in DESIGN.md §standoffs. F-016 one-root-compose landed the root `docker-compose.yml` (`pnpm dev`) for local/CI; the prod cutover onto it stays gated and human-run (never touch prod without explicit in-session confirmation).

0 commit comments

Comments
 (0)