mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-08 04:32:42 +00:00
docs(ios-handoff): A-Foundation — README skeleton + philosophy + repo-tour + architecture + API contracts
This commit is contained in:
1 parent
9bfbbdf345
commit
a756c51be8
5 files changed
+2141
No files matched your search
@@ -0,0 +1,187 @@
|
||||
---
|
||||
file: 00-philosophy.md
|
||||
purpose: Why-questions — every load-bearing project decision and the reasoning behind it, so iOS code reads in the same dialect as the server
|
||||
when_to_read: First read after README. Skip only if you have shipped two features here already.
|
||||
prerequisites: none
|
||||
estimated_tokens: ~3000
|
||||
version_anchor: v1.4.25 / sha 49f71c92
|
||||
---
|
||||
|
||||
## TL;DR
|
||||
|
||||
HealthLog is a self-hosted personal-health PWA with AI insights, Withings + Apple Health ingest, and a doctor-report PDF. The decisions below are not negotiable inside the codebase — they are how this project survives a fourteenth release without a rewrite. iOS code that contradicts any of them will fail review even when the diff is technically correct.
|
||||
|
||||
## The ten load-bearing rules
|
||||
|
||||
| # | Rule | Why |
|
||||
| --- | --- | --- |
|
||||
| 1 | Conservative semver — patch beats minor | Releases are bugfix-heavy with additive features; the v1.5.0 → v1.4.14 reroute confirmed users prefer many small patches over occasional minor jumps |
|
||||
| 2 | Marc-Voice everywhere user-facing | Every artifact (commits, CHANGELOG, in-app copy, release notes) reads as Marc's authorship; never expose "Claude/AI/agent/marathon/phase" |
|
||||
| 3 | AI insights are the differentiator | Visual, evidence-grounded, dynamic, multi-provider; not generic chat |
|
||||
| 4 | Six locales from day 1 | EN + DE are Marc-maintained; FR / ES / IT / PL ship native first-party translation with a maintainership banner — no machine-translated fallbacks |
|
||||
| 5 | `develop` → `main` via PR; tags on `main` only | Long-lived develop branch; main is release-only; GHCR + Coolify build only from main tags |
|
||||
| 6 | No AI tells in user-facing copy | The user never sees the model name, the chain-of-thought, the "as an AI" disclaimer, the marathon phrasing, or the wave number |
|
||||
| 7 | Multi-provider AI routing with failover | Anthropic → OpenAI → Codex → local; one failing provider doesn't kill the Coach |
|
||||
| 8 | Recharts stays | No Apple Charts swap, no Lightweight-Charts, no D3 rewrite — visual identity is locked, replacement requires explicit approval |
|
||||
| 9 | Postgres + OAuth, not SQLite + biometric | The server is a multi-user database, not a phone-shaped key-value store; iOS is a client of it |
|
||||
| 10 | MDR 2017/745 boundary on medical features | Coach refuses GLP-1 dose recommendations (GROUND RULE 9) and drug-level estimates (GROUND RULE 15) with MDR + MDCG 2021-24 cites — Research Mode is opt-in observational only |
|
||||
|
||||
## Rule 1 — Conservative semver
|
||||
|
||||
| Symptom | Action |
|
||||
| --- | --- |
|
||||
| A release adds a new endpoint | Patch bump (`1.4.24 → 1.4.25`) — endpoint is additive |
|
||||
| A release breaks an existing endpoint | Minor bump (`1.4.x → 1.5.0`) — only with deliberate migration |
|
||||
| A release drops support for a measurement type | Major bump (`1.x.y → 2.0.0`) — never executed |
|
||||
|
||||
The v1.5 milestone exists for the iOS app + Apple Health rebase, not as a number-bump. v1.4.25 fits 90 commits past v1.4.24 baseline (insights sub-pages, GLP-1 end-to-end, six locales, source-priority two-axis) into a patch because everything is additive — old clients keep working unchanged.
|
||||
|
||||
## Rule 2 — Marc-Voice
|
||||
|
||||
| Channel | Voice rule |
|
||||
| --- | --- |
|
||||
| Git commit messages | English, terse, professional, no agent-voice |
|
||||
| CHANGELOG.md | Marc-as-author, "Adds X" not "Claude added X" |
|
||||
| GitHub release notes | Identical to CHANGELOG entry, no agent mentions |
|
||||
| In-app copy (UI strings) | English-default with the rest of `messages/en.json`; same voice |
|
||||
| Doc-site (`docs/`) | Marc-as-author |
|
||||
| Audit reports under `docs/audit/` | Marc-as-author |
|
||||
| `.planning/` working notes | Internal — can be candid; never published |
|
||||
|
||||
A code review that finds `// generated by agent` or `// marathon phase X added Y` will reject the diff. v1.4.20 retroactively scrubbed the prior CHANGELOG to remove inherited voice tells; the rule is permanent.
|
||||
|
||||
## Rule 3 — AI insights are the differentiator
|
||||
|
||||
The Coach drawer + per-status insight cards + Daily Briefing + correlation cards + weekly report are the selling point. They are:
|
||||
|
||||
- **Visual** — every claim renders with a chart context, not a paragraph
|
||||
- **Evidence-grounded** — the model receives a real SNAPSHOT with the user's data, never invents numbers (the `---KEYVALUES---` sentinel exists to surface the load-bearing numbers verbatim back to the user)
|
||||
- **Dynamic** — fresh insights evict stale ones; mutations invalidate every read query touching the resource
|
||||
- **Multi-provider** — fallback chain is structural (see Rule 7), not "if Claude is down we panic"
|
||||
|
||||
iOS code that bypasses the snapshot to do "smart" local computation defeats the entire pipeline.
|
||||
|
||||
## Rule 4 — Six locales from day 1
|
||||
|
||||
| Locale | Maintainer | Coach prompt | Banner |
|
||||
| --- | --- | --- | --- |
|
||||
| EN | Marc | Native | None |
|
||||
| DE | Marc | Native | None |
|
||||
| FR | Community | Native | `<MaintainershipBanner>` + translation-feedback issue template |
|
||||
| ES | Community | Native | Banner + template |
|
||||
| IT | Community | Native | Banner + template |
|
||||
| PL | Community | Native | Banner + template |
|
||||
|
||||
Pre-v1.4.25 the Coach used an English body with a `REPLY LANGUAGE: <locale>` footer. v1.4.25 flipped this to **native per-locale system prompts** with a YAML-encoded safety-contract matrix per locale (single source of truth across drafting + tests). The refusal-probe matrix in CI catches cross-locale regressions.
|
||||
|
||||
iOS strings live in the same `messages/{locale}.json` bundles. The `i18n-locale-integrity.test.ts` blocks any PR that drops a key in one locale without dropping it everywhere.
|
||||
|
||||
## Rule 5 — Branch model
|
||||
|
||||
```
|
||||
feature/* ──┐
|
||||
├──► develop ──► (release-merge) ──► main ──► tag vX.Y.Z ──► GHCR build ──► Coolify deploy
|
||||
fix/* ──┘ │
|
||||
└──► CHANGELOG entry pinned to the tag
|
||||
```
|
||||
|
||||
Daily work goes on develop. `main` only receives release merges and tags. The GHCR docker workflow builds **from main only** so a dev push to develop never poisons the production image. Coolify auto-deploy listens on the GHCR tag.
|
||||
|
||||
Pre-v1.4.20 there was no main/develop split; the v1.4.20 marathon introduced it and every release since (v1.4.20 through v1.4.25) followed the pattern.
|
||||
|
||||
## Rule 6 — No AI tells
|
||||
|
||||
Forbidden in user-facing surfaces:
|
||||
|
||||
| Phrase | Replacement |
|
||||
| --- | --- |
|
||||
| "As an AI…" | (delete the entire disclaimer line) |
|
||||
| "I'm Claude / I'm GPT-4 / I'm an LLM" | (delete) |
|
||||
| "marathon / phase / wave / session / agent" | Rephrase or delete |
|
||||
| "I was trained on…" | (delete) |
|
||||
| "I cannot help with that, however…" | A real refusal: "This is medical advice that needs a physician — open the doctor report and bring it to your appointment." |
|
||||
|
||||
The Coach refusal copy is hand-written per locale in `messages/{locale}.json` under the `coach.refusal.*` keys; the prompt-pattern guard re-emits the localised refusal without ever hitting a provider, so the LLM cannot leak its own identity even under prompt injection.
|
||||
|
||||
## Rule 7 — Multi-provider AI routing
|
||||
|
||||
Order of fallback (see `src/lib/ai/provider-runner.ts` → `runRawCompletionWithFallback()`):
|
||||
|
||||
1. **User-configured providers** in their saved priority order (Settings → AI Provider)
|
||||
2. **Admin-configured fallback** (typically the operator's OpenAI key)
|
||||
3. **No-key fallback** — rule-based copy, never claims to be the AI
|
||||
|
||||
Each attempt is logged as a Wide-Event with `provider`, `attempts`, `firstStatus`. v1.4.25 W5 split provider-down from rate-limited: every-attempt-429 surfaces the `coach.provider.rate_limited` copy with a warning toast; all-attempts-failed surfaces `coach.provider.unavailable`.
|
||||
|
||||
iOS calls `/api/insights/chat` and reads `error.code` from the SSE `error` frame to render the right copy — the iOS app never picks the provider directly.
|
||||
|
||||
## Rule 8 — Recharts stays
|
||||
|
||||
The visual identity of every chart in the app is locked. Pre-v1.4.20 there was a Lightweight-Charts evaluation that got reverted for accessibility (no aria-labels, no high-contrast tokens). The chart library swap is in the "explicit-approval-required" category — design + product sign-off before any PR.
|
||||
|
||||
For iOS, this means **Swift Charts is the equivalent of Recharts** — pick the OS-native one, never roll a custom canvas renderer. The visual aesthetic (axis tokens, color rules, legend placement) is what the user remembers — the renderer is what makes it stable.
|
||||
|
||||
## Rule 9 — Postgres + OAuth, not SQLite + biometric
|
||||
|
||||
Server-side decisions:
|
||||
|
||||
| Choice | Rejected | Why |
|
||||
| --- | --- | --- |
|
||||
| Postgres | SQLite | Multi-user from v1.0; concurrent writes from Withings webhook + iOS batch + manual web entry |
|
||||
| OAuth + session cookies + Bearer tokens | Biometric-only | Cross-device, web-first, audit-friendly |
|
||||
| Argon2 password hashing | bcrypt / PBKDF2 | OWASP 2024 recommendation; libargon2 is mature |
|
||||
| Passkeys via SimpleWebAuthn | Apple-only WebAuthn | Cross-browser support |
|
||||
|
||||
For iOS:
|
||||
|
||||
- The phone keychain stores the **refresh token**, not the user's password
|
||||
- Apple Health is a **client-side data source**, not a primary store — every HealthKit sample flows through `POST /api/measurements/batch` into the canonical Postgres
|
||||
- Biometric unlock is a **UX shortcut** for accessing the locally-cached refresh token, not a security primitive
|
||||
|
||||
## Rule 10 — MDR boundary
|
||||
|
||||
Two GROUND RULES make this binding inside the Coach prompt:
|
||||
|
||||
| Rule | Cite | Behaviour |
|
||||
| --- | --- | --- |
|
||||
| GROUND RULE 9 | EU MDR 2017/745 + MDCG 2021-24 | Coach refuses GLP-1 dose recommendations; offers EMA EPAR titration ladder as observational reference only |
|
||||
| GROUND RULE 15 | EU MDR + EMA EPAR + Schneck/Urva 2024 | Coach refuses drug-level estimates; Research Mode chart is opt-in, behind a versioned acknowledgment dialog (Migration 0058), observational use only |
|
||||
|
||||
Research Mode lifecycle: `GET / POST / DELETE /api/auth/me/research-mode`. The acknowledgment carries a version string (`RESEARCH_MODE_DISCLAIMER_VERSION`); a stale version forces re-prompt. The drug-level chart is a one-compartment PK approximation — qualitative trace only, no numeric "your current level is X ng/mL" claim.
|
||||
|
||||
iOS replicates this verbatim:
|
||||
|
||||
- The GLP-1 detail screen surfaces the Research Mode toggle in Settings → Advanced
|
||||
- Tapping the chart on a non-acknowledged account presents the dialog with the **live** server-side disclaimer copy (fetch via `GET /api/auth/me/research-mode`, do not hardcode)
|
||||
- Re-prompt is automatic when the server's `currentDisclaimerVersion` differs from the user's `acknowledgedVersion`
|
||||
|
||||
## Cross-cutting consequences
|
||||
|
||||
### Cache invalidation on new content
|
||||
|
||||
A successful mutation invalidates every read query touching the resource. TanStack Query keys are namespaced per resource (see `src/lib/query-keys.ts`). The convention: same `queryKey` MUST map to the same unwrap shape — `(await res.json()).data`. Two endpoints accidentally sharing a key but returning different shapes is a cache-poisoning bug that hides until production.
|
||||
|
||||
For iOS, the equivalent rule: every mutation that writes a measurement / workout / medication / mood MUST trigger a refetch of the matching list query. If the iOS state layer uses a different cache model (e.g. SwiftData), preserve the rule: never serve stale data after a write.
|
||||
|
||||
### No personal data in user-facing artifacts
|
||||
|
||||
Marc's name, BD-Zielbereich values, measurement counts, lab values — none of these appear in CHANGELOG / GH releases / `docs/audit/v*-summary.md` / docs site / landing. The retroactive scrub of v1.4.14–v1.4.19 (in v1.4.20) and the pre-release PII-grep gate enforce this. For iOS: never put user data into commit messages, release notes, or screenshots checked into the repo.
|
||||
|
||||
### Umlauts everywhere
|
||||
|
||||
UTF-8 end-to-end. `"Nrnberg"` instead of `"Nürnberg"` is a bug. iOS string-handling must preserve umlauts through every encoding step; this includes URL parameters (use `URLComponents`, not string concatenation) and POST body bytes (always send `application/json; charset=utf-8`).
|
||||
|
||||
### Research before complex features
|
||||
|
||||
AI / insights / chart work runs through a research agent first. The agent benchmarks Apple Health / Withings / Oura, proposes an ecosystem-fit plan, then the implementation phase starts. The research artifacts live under `.planning/research/`. For iOS, mirror this: before building the Coach drawer or the dashboard, read the equivalent research note under `.planning/research/`.
|
||||
|
||||
## STOP HERE if…
|
||||
|
||||
| If your task is… | …skip the rest and read… |
|
||||
| --- | --- |
|
||||
| "Just call one endpoint" | `03-api-contracts.md` |
|
||||
| "Understand the data shape" | `04-data-model.md` |
|
||||
| "Wire the Coach drawer" | `10-coach-pipeline.md` |
|
||||
| "Build a chart" | `06-ui-conventions.md` |
|
||||
|
||||
Otherwise: continue to `01-repo-tour.md`.
|
||||
@@ -0,0 +1,283 @@
|
||||
---
|
||||
file: 01-repo-tour.md
|
||||
purpose: Where everything lives — src/ layout, naming conventions, migrations, locales, tests, CI, deploy config
|
||||
when_to_read: After 00-philosophy.md. Re-read whenever you need to find a file and the search-fu fails.
|
||||
prerequisites: 00-philosophy.md
|
||||
estimated_tokens: ~3000
|
||||
version_anchor: v1.4.25 / sha 49f71c92
|
||||
---
|
||||
|
||||
## TL;DR
|
||||
|
||||
Top-level is a stock Next.js 15 + Prisma + pnpm monorepo. Source lives in `src/`. Migrations live in `prisma/migrations/` and are SQL-only. Translations live in `messages/{locale}.json`. Tests split three ways: Vitest unit (`src/**/__tests__/`), Vitest integration (`tests/integration/`), Playwright e2e (`e2e/`). Planning artifacts live in `.planning/`. CI workflows live in `.github/workflows/`.
|
||||
|
||||
## Top-level
|
||||
|
||||
```
|
||||
/
|
||||
├── src/ # All application code (see § src/ layout)
|
||||
├── prisma/
|
||||
│ ├── schema.prisma # Single Prisma schema
|
||||
│ └── migrations/ # SQL migrations 0001 → 0060 (additive, forward-only)
|
||||
├── messages/ # i18n bundles
|
||||
│ ├── en.json # English (Marc-maintained, default)
|
||||
│ ├── de.json # German (Marc-maintained)
|
||||
│ ├── fr.json es.json it.json pl.json # Community-maintained
|
||||
│ └── (see 08-locales-i18n.md for hygiene rules)
|
||||
├── scripts/
|
||||
│ ├── generate-openapi.ts # OpenAPI generation from Zod schemas
|
||||
│ ├── check-openapi.ts # Drift gate — fails CI on schema-vs-spec drift
|
||||
│ ├── i18n/ # i18n integrity helpers
|
||||
│ └── restore-backup.ts
|
||||
├── e2e/ # Playwright (24 spec files at v1.4.25)
|
||||
├── tests/ # Vitest integration (testcontainers + real Postgres)
|
||||
├── public/ # Static assets, manifest.webmanifest, icons
|
||||
├── docs/ # User-facing docs site sources
|
||||
├── .planning/ # Internal working notes — not published
|
||||
│ ├── v15-ios-handoff/ # ← this doc-pack lives here
|
||||
│ ├── research/ # Research notes for complex features
|
||||
│ ├── phase-*-report.md # Per-feature delivery reports
|
||||
│ └── codebase/ # Output of /gsd:map-codebase
|
||||
├── .github/workflows/ # CI definitions
|
||||
├── docker-compose.yml # Local dev (Postgres + app)
|
||||
├── Dockerfile # Production image (multi-stage)
|
||||
├── docker-entrypoint.sh
|
||||
├── package.json # Single workspace; pnpm 10.31.0
|
||||
├── prisma.config.ts
|
||||
├── next.config.ts # Next.js 16.2.6 config (turbopack)
|
||||
├── tsconfig.json # strict mode
|
||||
├── vitest.config.mts # Unit-test config (jsdom)
|
||||
├── vitest.integration.config.mts # Integration-test config (node + testcontainers)
|
||||
├── playwright.config.ts
|
||||
├── eslint.config.mjs # ESLint 9 flat config
|
||||
├── postcss.config.mjs # Tailwind CSS v4
|
||||
├── components.json # shadcn/ui registry config
|
||||
├── CHANGELOG.md # Single canonical changelog
|
||||
├── CONTRIBUTING.md # External-contributor guide
|
||||
├── CONTRIBUTING-AI.md # Internal-agent guide
|
||||
├── AGENTS.md # Per-area agent ownership map
|
||||
├── README.md # Marketing + 30-second-read intro
|
||||
└── LICENSE # AGPL-3.0-only
|
||||
```
|
||||
|
||||
## src/ layout
|
||||
|
||||
```
|
||||
src/
|
||||
├── app/ # Next.js App Router (RSC + route handlers)
|
||||
│ ├── (page routes) # /, /dashboard, /insights/*, /measurements, /medications, /settings, /admin, …
|
||||
│ ├── api/ # Route handlers — see § API layout
|
||||
│ ├── layout.tsx # Root layout (theme, providers, locale)
|
||||
│ ├── globals.css # Tailwind + Dracula tokens
|
||||
│ ├── favicon.ico robots.ts
|
||||
│ ├── error.tsx # Per-route error boundary
|
||||
│ └── global-error.tsx # Root error boundary
|
||||
│
|
||||
├── components/
|
||||
│ ├── ui/ # shadcn primitives (Button, Sheet, Dialog, …)
|
||||
│ ├── charts/ # Recharts wrappers (LineChart, BarChart, StackedBar, sparklines)
|
||||
│ ├── dashboard/ # Dashboard tiles + grid
|
||||
│ ├── insights/ # Insights mother-page + sub-pages + Coach drawer
|
||||
│ ├── measurements/ # Manual-entry forms + lists
|
||||
│ ├── medications/ # Med list, intake, GLP-1 specialist surfaces
|
||||
│ ├── mood/ # Mood logger + chart
|
||||
│ ├── onboarding/ # Wizard primitives (OnboardingShell + per-step components)
|
||||
│ ├── settings/ # 23 settings sections
|
||||
│ ├── admin/ # Admin-only screens (user list, login overview, …)
|
||||
│ ├── targets/ # /insights/zielwerte
|
||||
│ ├── doctor-report/ # PDF preview + per-section toggles
|
||||
│ ├── gamification/ # Achievements + streaks
|
||||
│ ├── monitoring/ # Wide-event viewer (admin)
|
||||
│ ├── comparison/ # Comparison-overlay primitives
|
||||
│ ├── layout/ # AppShell + AuthShell + AdminShell
|
||||
│ ├── i18n/ # LocaleProvider + Trans component
|
||||
│ ├── app-settings-provider.tsx
|
||||
│ ├── error-details.tsx
|
||||
│ └── providers.tsx # TanStack Query + Theme + Toast providers
|
||||
│
|
||||
├── lib/ # Server + isomorphic helpers
|
||||
│ ├── ai/ # AI provider chain + Coach + insights prompts (see 02-server-architecture.md)
|
||||
│ ├── analytics/ # summarize(), correlations, classifications, health-score
|
||||
│ ├── auth/ # session, passkey, password, issue-token, refresh-token, audit
|
||||
│ ├── coach/ # (Note: lives under lib/ai/coach/ — see 02)
|
||||
│ ├── insights/ # status writers (general/bp/weight/pulse/bmi/mood/compliance)
|
||||
│ ├── jobs/ # pg-boss workers + cron schedules (see 02)
|
||||
│ ├── medications/ # GLP-1 PK helpers, drug-knowledge layer, titration
|
||||
│ ├── notifications/ # dispatcher, senders (Telegram / NTFY / Web-Push / APNs)
|
||||
│ ├── validations/ # Zod schemas — the single source of truth for every wire shape
|
||||
│ ├── withings/ # OAuth client, sync (measure + activity + sleep v2), webhook
|
||||
│ ├── i18n/ # server-locale resolver, server-translator
|
||||
│ ├── tz/ # per-user timezone resolver + helpers
|
||||
│ ├── monitoring/ # glitchtip sender
|
||||
│ ├── logging/ # Wide-Event builder + context + transports
|
||||
│ ├── openapi/ # Zod-to-OpenAPI generator
|
||||
│ ├── api-handler.ts # apiHandler() + requireAuth() + requireAdmin() + HttpError
|
||||
│ ├── api-response.ts # apiSuccess() + apiError() + safeJson() + getClientIp()
|
||||
│ ├── db.ts # Prisma client singleton
|
||||
│ ├── db-compat.ts # Schema-version guard (legacy callsite)
|
||||
│ ├── crypto.ts # encrypt/decrypt (per-user secrets in the DB)
|
||||
│ ├── rate-limit.ts # checkRateLimit() + rateLimitHeaders()
|
||||
│ ├── idempotency.ts # withIdempotency() — HTTP-level retry replay
|
||||
│ ├── query-keys.ts # TanStack Query keys — never duplicate, never collide
|
||||
│ ├── format.ts format-locale.ts time-window-format.ts timezone.ts
|
||||
│ ├── doctor-report-*.ts # PDF rendering
|
||||
│ ├── export.ts # CSV export
|
||||
│ ├── gravatar.ts geo.ts glucose.ts
|
||||
│ └── (more — see src/lib/ listing)
|
||||
│
|
||||
├── hooks/ # React hooks (TanStack-Query consumers, ResizeObserver helpers, etc.)
|
||||
├── generated/ # Prisma client output (gitignored — regenerated on pnpm db:generate)
|
||||
├── instrumentation.ts # Next.js instrumentation hook — boots pg-boss worker
|
||||
├── proxy.ts # Next.js middleware (auth redirect, onboarding-pending cookie, locale)
|
||||
└── __tests__/ # Unit tests live colocated under each module's __tests__/
|
||||
```
|
||||
|
||||
## API layout — `src/app/api/`
|
||||
|
||||
| Path | Purpose |
|
||||
| --- | --- |
|
||||
| `admin/` | Admin-only routes |
|
||||
| `ai/` | AI provider + Codex OAuth |
|
||||
| `analytics/` | Trends + health-score aggregator |
|
||||
| `audit-log/` | Audit-log read API |
|
||||
| `auth/` | Login, logout, register, profile, refresh, passkey, codex, me/* (timezone, source-priority, doctor-report-prefs, coach-prefs, research-mode, devices) |
|
||||
| `bugreport/` | User-submitted bug reports |
|
||||
| `dashboard/` | summary, glp1, widgets, chart-overlay-prefs |
|
||||
| `devices/` | Device list + revoke |
|
||||
| `doctor-report/` | PDF generation |
|
||||
| `export/` | CSV export |
|
||||
| `feedback/` | AI-insight feedback (thumbs + free text) |
|
||||
| `gamification/` | Achievements, streaks |
|
||||
| `health/` | `/api/health` liveness probe (200 / 503) |
|
||||
| `import/` | CSV + Apple Health XML import |
|
||||
| `ingest/` | Generic ingest endpoints (legacy) |
|
||||
| `insights/` | chat (SSE), generate, cards, comprehensive, correlations, targets, feedback, provider-chain, settings, glp1-timeline, per-status (blood-pressure / weight / pulse / mood / bmi / medication-compliance) |
|
||||
| `integrations/` | Withings + Apple-Health status reconciliation |
|
||||
| `internal/` | Server-internal probes |
|
||||
| `measurements/` | route.ts, batch, by-external-ids, [id], series |
|
||||
| `medications/` | route.ts, intake, intake-summary, [id]/* (intake, glp1, inventory, side-effects, cadence, titration, compliance, phase-config, api-endpoint) |
|
||||
| `monitoring/` | Wide-event read API |
|
||||
| `mood/ mood-entries/` | Mood logging |
|
||||
| `notifications/` | preferences, status, vapid, web-push |
|
||||
| `onboarding/` | step, complete, tour |
|
||||
| `personal-records/` | PR list |
|
||||
| `send/` | Generic notification dispatch (admin) |
|
||||
| `settings/` | App-level settings (admin) |
|
||||
| `telegram/` | Telegram bot test |
|
||||
| `tokens/` | API token list + create + revoke |
|
||||
| `user/` | User CRUD (admin) |
|
||||
| `version/` | App version probe |
|
||||
| `withings/` | connect, callback, disconnect, status, sync, credentials, webhook |
|
||||
| `workouts/` | batch |
|
||||
|
||||
Full HTTP-verb-by-verb contract: see `03-api-contracts.md`.
|
||||
|
||||
## Naming conventions
|
||||
|
||||
| Convention | Example | Notes |
|
||||
| --- | --- | --- |
|
||||
| Route handlers | `src/app/api/{path}/route.ts` | Next.js App Router; one file per HTTP path, export `GET`/`POST`/`PUT`/`DELETE`/`PATCH` |
|
||||
| Dynamic segments | `[id]`, `[token]`, `[step]` | Bracket-folder names; accessed via `params` argument |
|
||||
| Zod schemas | `src/lib/validations/{resource}.ts` | One file per resource; `createXSchema`, `updateXSchema`, `listXSchema` |
|
||||
| Prisma model | `PascalCase` in `schema.prisma` | Generated client at `src/generated/prisma/client` |
|
||||
| DB column case | snake_case in DB, camelCase in code | Via Prisma `@map` |
|
||||
| Migration files | `prisma/migrations/{NNNN}_{snake_case_description}/migration.sql` | Sequential numbering 0001 → 0060 at v1.4.25 |
|
||||
| Test files | colocated under `__tests__/` directories | `unit.test.ts` for vitest unit, `integration.test.ts` under `tests/` |
|
||||
| Translation keys | `dot.notation.lowercase` | Same key in every locale; the integrity test blocks drift |
|
||||
| Audit-log actions | `resource.verb` | `measurement.create`, `auth.login.password`, `medication.intake.update` |
|
||||
| Wide-Event names | same as audit-log when applicable | Annotated via `annotate({ action: { name } })` |
|
||||
|
||||
## Where to find…
|
||||
|
||||
| Need | Location |
|
||||
| --- | --- |
|
||||
| The list of `pnpm` commands | `package.json` § scripts |
|
||||
| The list of measurement types | `src/lib/validations/measurement.ts` (Zod enum) and `prisma/schema.prisma` (`MeasurementType`) |
|
||||
| The Coach system prompt | `src/lib/ai/coach/system-prompt.ts` + `src/lib/ai/prompts/safety-contracts.{locale}.yaml` |
|
||||
| The Coach refusal copy | `messages/{locale}.json` under `coach.refusal.*` |
|
||||
| The current PROMPT_VERSION | `src/lib/ai/prompts/insight-generator.ts` → `PROMPT_VERSION` |
|
||||
| Default rate-limit values | Per-route; see the route file for the `checkRateLimit()` call |
|
||||
| The cron schedule for X | `src/lib/jobs/reminder-worker.ts` § schedules table |
|
||||
| Withings OAuth client | `src/lib/withings/client.ts` |
|
||||
| Apple Health identifier map | `src/lib/measurements/apple-health-mapping.ts` |
|
||||
| GLP-1 drug-knowledge layer | `src/lib/medications/glp1-knowledge.ts` and the EMA / EPAR references in `docs/` |
|
||||
| GLP-1 PK helper | `src/lib/medications/glp1-pk.ts` |
|
||||
| PR detection logic | `src/lib/jobs/pr-detection.ts` |
|
||||
| Per-user timezone resolver | `src/lib/tz/resolver.ts` → `userDayKey()`, `DEFAULT_TIMEZONE` |
|
||||
| Source-priority resolver | `src/lib/sources/` + `src/lib/validations/source-priority.ts` |
|
||||
| The `apiHandler` + `requireAuth` helpers | `src/lib/api-handler.ts` |
|
||||
| Idempotency wrapper | `src/lib/idempotency.ts` → `withIdempotency()` |
|
||||
| Wide-Event annotation | `src/lib/logging/context.ts` → `annotate({ action, meta })` |
|
||||
| Audit-log writer | `src/lib/auth/audit.ts` → `auditLog(action, { userId, ipAddress, details })` |
|
||||
|
||||
## package.json scripts
|
||||
|
||||
| Command | Purpose |
|
||||
| --- | --- |
|
||||
| `pnpm dev` | Start Next.js dev server on http://localhost:3000 (Turbopack) |
|
||||
| `pnpm build` | Production build |
|
||||
| `pnpm start` | Run production build |
|
||||
| `pnpm lint` | ESLint 9 flat-config |
|
||||
| `pnpm typecheck` | TypeScript strict-mode no-emit |
|
||||
| `pnpm format` / `pnpm format:check` | Prettier write / check |
|
||||
| `pnpm test` / `pnpm test:watch` | Vitest unit suite |
|
||||
| `pnpm test:integration` | Vitest integration suite (testcontainers + Postgres) |
|
||||
| `pnpm e2e` / `pnpm e2e:ui` | Playwright |
|
||||
| `pnpm db:generate` | Regenerate Prisma client |
|
||||
| `pnpm db:push` | Push schema without a migration (dev only) |
|
||||
| `pnpm db:migrate` | Create + apply a new migration |
|
||||
| `pnpm db:migrate:deploy` | Apply migrations in CI / production |
|
||||
| `pnpm db:studio` | Open Prisma Studio |
|
||||
| `pnpm openapi:generate` | Generate `docs/openapi.yaml` from Zod schemas |
|
||||
| `pnpm openapi:check` | Drift gate — fails when schema and spec diverge |
|
||||
|
||||
The CI one-liner (mirrors `.github/workflows/`):
|
||||
|
||||
```bash
|
||||
pnpm typecheck && pnpm lint && pnpm test && pnpm format:check && pnpm build
|
||||
```
|
||||
|
||||
## CI workflows — `.github/workflows/`
|
||||
|
||||
| File | Trigger | What it does |
|
||||
| --- | --- | --- |
|
||||
| `dependabot-auto-merge.yml` | Dependabot PR | Auto-merges minor / patch bumps after CI green |
|
||||
| `docker-publish.yml` | Push tag `v*` on `main` | Builds multi-arch GHCR image (linux/amd64 on ubuntu-latest + linux/arm64 on ubuntu-24.04-arm), merges manifest |
|
||||
| `e2e.yml` | PR + push | Playwright suite against ephemeral Postgres |
|
||||
| `integration.yml` | PR + push | Vitest integration suite |
|
||||
| `post-publish-verify.yml` | After GHCR build | Pulls the published image, runs health-check probes |
|
||||
| `security.yml` | Daily + PR | npm audit, secret scanning |
|
||||
|
||||
## Coolify deploy config
|
||||
|
||||
The runtime is **Coolify** on a self-hosted server. The deploy config lives:
|
||||
|
||||
- **In Coolify's UI**, not in the repo — the project + environment + tag-based auto-deploy listener is configured there
|
||||
- The GHCR image tag controls deploys — Coolify polls the tag and redeploys on a new digest
|
||||
- Environment variables ship via Coolify's env panel (POSTGRES_PASSWORD, DATABASE_URL, SESSION_SECRET, ENCRYPTION_KEY, API_TOKEN_HMAC_KEY, plus optional AI provider keys + Withings + Apple-Health bundles)
|
||||
|
||||
For iOS purposes: the public URL is the production HealthLog instance; the API base URL is configurable in the app's Settings (the iOS app supports self-hosted operators, not just Marc's instance).
|
||||
|
||||
Detail in `19-deploy-runtime.md`.
|
||||
|
||||
## Planning artifacts — `.planning/`
|
||||
|
||||
| Subdir | What it holds |
|
||||
| --- | --- |
|
||||
| `phase-*-report.md` | Per-feature delivery reports — read these for a feature's "why" |
|
||||
| `research/` | Research notes that precede complex features |
|
||||
| `codebase/` | Output of `/gsd:map-codebase` — mechanical structure reports |
|
||||
| `v15-ios-handoff/` | This doc-pack |
|
||||
|
||||
When investigating a feature: search `.planning/` first — a report explains "why" in a way the code never can.
|
||||
|
||||
## STOP HERE if…
|
||||
|
||||
| If your task is… | …skip the rest and read… |
|
||||
| --- | --- |
|
||||
| "What does endpoint X return?" | `03-api-contracts.md` |
|
||||
| "What does the DB look like?" | `04-data-model.md` |
|
||||
| "How do I run the dev server?" | `CONTRIBUTING.md` (root) |
|
||||
| "What's the deploy story?" | `19-deploy-runtime.md` |
|
||||
|
||||
Otherwise: continue to `02-server-architecture.md`.
|
||||
@@ -0,0 +1,511 @@
|
||||
---
|
||||
file: 02-server-architecture.md
|
||||
purpose: Backend services architecture — Next.js, Prisma, pg-boss queues, cron jobs, multi-provider AI routing, Coach / Insights module structure, Health Score, source-priority two-axis
|
||||
when_to_read: After 01-repo-tour.md. Before building any backend-integrated feature.
|
||||
prerequisites: 00-philosophy.md, 01-repo-tour.md
|
||||
estimated_tokens: ~5000
|
||||
version_anchor: v1.4.25 / sha 49f71c92
|
||||
---
|
||||
|
||||
## TL;DR
|
||||
|
||||
Single Next.js 16 process serves the web UI (RSC + App Router) and the API (route handlers). A pg-boss worker boots in-process via `src/instrumentation.ts` and runs ~20 recurring crons against the same Postgres. The AI Coach and the per-status insight cards run on a multi-provider routing layer with structural fallback. Source-priority is a two-axis resolver: per-metric ladder + per-device-type override. Health Score is server-deterministic with a per-component provenance accordion.
|
||||
|
||||
## Stack at a glance
|
||||
|
||||
| Layer | Tech | Version |
|
||||
| --- | --- | --- |
|
||||
| Runtime | Node.js | 20+ |
|
||||
| Framework | Next.js | 16.2.6 (App Router + RSC + Turbopack) |
|
||||
| Language | TypeScript | strict mode |
|
||||
| UI | React 19 + shadcn/ui + Tailwind v4 | — |
|
||||
| Charts | Recharts | 3.8.1 |
|
||||
| ORM | Prisma | 7.8.0 (pg adapter) |
|
||||
| DB | PostgreSQL | 16+ |
|
||||
| Queues | pg-boss | 12.18.2 (uses same Postgres) |
|
||||
| Validation | Zod v4 | 4.4.3 |
|
||||
| Auth — passwords | Argon2 (`@node-rs/argon2`) | 2.0.2 |
|
||||
| Auth — passkeys | SimpleWebAuthn | 13.3.0 |
|
||||
| Push | web-push (VAPID) + APNs (`@parse/node-apn`) | 3.6.7 / 8.1.0 |
|
||||
| State | TanStack Query | 5.100.9 |
|
||||
| Forms | react-hook-form + Zod resolver | 7.75.0 / 5.2.2 |
|
||||
| Test — unit | Vitest 4 + jsdom | — |
|
||||
| Test — integration | Vitest + testcontainers (real Postgres) | — |
|
||||
| Test — e2e | Playwright | 1.59.1 |
|
||||
| OpenAPI | zod-openapi | 5.4.6 — drift-checked in CI |
|
||||
|
||||
## Request lifecycle
|
||||
|
||||
```
|
||||
HTTP request
|
||||
│
|
||||
▼
|
||||
src/proxy.ts (Next.js middleware) ← auth redirect, locale cookie, hl_onboarding cookie short-circuit
|
||||
│
|
||||
▼
|
||||
src/app/api/<route>/route.ts ← exports GET / POST / PUT / DELETE / PATCH
|
||||
│
|
||||
▼
|
||||
apiHandler(handler) ← src/lib/api-handler.ts
|
||||
│ ▶ start Wide-Event builder
|
||||
│ ▶ propagate x-request-id
|
||||
│ ▶ run handler inside AsyncLocalStorage
|
||||
│ ▶ catch HttpError / SyntaxError → JSON error envelope
|
||||
│ ▶ emit Wide Event on completion (sampled)
|
||||
▼
|
||||
handler body
|
||||
│ 1. requireAuth() / requireAdmin() ← cookie session OR Bearer hlk_*
|
||||
│ 2. checkRateLimit(key, max, ms)
|
||||
│ 3. safeJson() + Zod schema.safeParse()
|
||||
│ 4. business logic (Prisma queries, optional pg-boss enqueue, external calls)
|
||||
│ 5. auditLog(action, { userId, ipAddress, details })
|
||||
│ 6. annotate({ action: { name }, meta })
|
||||
│ 7. return apiSuccess(data, status) or apiError(message, status, { errorCode, headers })
|
||||
▼
|
||||
Response envelope: { data, error } (+ optional meta on error)
|
||||
```
|
||||
|
||||
## Auth precedence (cookie-first)
|
||||
|
||||
```
|
||||
1. Valid session cookie → cookie path (full user access; requiredPermission ignored)
|
||||
2. No cookie + Bearer hlk_* → ApiToken path (scope-gated)
|
||||
3. Neither → 401
|
||||
```
|
||||
|
||||
`requireAdmin()` is cookie-only — Bearer tokens never elevate. See `05-auth-flows.md` for the full token lifecycle.
|
||||
|
||||
## Database — Prisma + Postgres
|
||||
|
||||
- Single Prisma schema at `prisma/schema.prisma`
|
||||
- One-way forward-only migrations under `prisma/migrations/`
|
||||
- 60 migrations at v1.4.25 (migrations 0043–0060 are the v1.4.25 additions; all additive)
|
||||
- Generated client emitted to `src/generated/prisma/client` (gitignored, regenerated via `pnpm db:generate`)
|
||||
- `src/lib/db.ts` exports a singleton — never instantiate `PrismaClient` ad-hoc
|
||||
- Cross-user safety is enforced by `userId` predicates in every query; the unique indexes (`(userId, type, source, externalId)` on `Measurement`, `(userId, source, externalId)` on `Workout`) carry NULL-distinct semantics
|
||||
|
||||
Detail in `04-data-model.md`.
|
||||
|
||||
## pg-boss — queue + scheduler
|
||||
|
||||
pg-boss runs in-process alongside Next.js. It uses the same Postgres for queue storage (its own schema `pgboss`), so deployment is one container, one DB.
|
||||
|
||||
Boot lifecycle:
|
||||
|
||||
```
|
||||
src/instrumentation.ts
|
||||
└─ src/lib/jobs/reminder-worker.ts → startWorker()
|
||||
├─ boss.start()
|
||||
├─ boss.schedule(name, cron, {}, { tz: "Europe/Berlin" }) ← for every recurring queue
|
||||
└─ boss.work(name, { localConcurrency: 1 }, handler) ← for every consumer
|
||||
```
|
||||
|
||||
### Recurring schedules
|
||||
|
||||
All cron schedules use `Europe/Berlin` TZ. Source: `src/lib/jobs/reminder-worker.ts` schedules table.
|
||||
|
||||
| Queue | Cron | Frequency | Handler |
|
||||
| --- | --- | --- | --- |
|
||||
| `medication-reminder-check` | `*/15 * * * *` | Every 15 min | Reminder dispatcher — phase-config aware |
|
||||
| `withings-measure-sync` | `0 * * * *` | Hourly :00 | Fallback measure sync (webhook is primary since v1.4.25) |
|
||||
| `withings-activity-sync` | `0 * * * *` | Hourly :00 | Activity (steps + distance + active-energy) sync — Since v1.4.25 |
|
||||
| `withings-sleep-v2-sync` | `15 * * * *` | Hourly :15 | Sleep v2 stage-level segments — Since v1.4.25 |
|
||||
| `mood-log-sync` | `30 * * * *` | Hourly :30 | Mood-log secret refresh |
|
||||
| `pr-detection-fallback` | `*/30 * * * *` | Every 30 min | Personal-record sweep across all users (the per-user job is enqueued on every batch ingest) — Since v1.4.25 |
|
||||
| `general-status` | `0 2 * * *` | Daily 02:00 | Generate `insights.general-status.{locale}` cards |
|
||||
| `blood-pressure-status` | `5 2 * * *` | Daily 02:05 | Per-status BP card |
|
||||
| `weight-status` | `10 2 * * *` | Daily 02:10 | Per-status weight card |
|
||||
| `pulse-status` | `15 2 * * *` | Daily 02:15 | Per-status pulse card |
|
||||
| `bmi-status` | `20 2 * * *` | Daily 02:20 | Per-status BMI card |
|
||||
| `medication-compliance-status` | `25 2 * * *` | Daily 02:25 | Per-status compliance card |
|
||||
| `host-metric-sample` | `* * * * *` | Every minute | Host CPU / memory sample |
|
||||
| `rate-limit-cleanup` | `*/5 * * * *` | Every 5 min | Drop expired rate-limit rows |
|
||||
| `idempotency-cleanup` | `0 3 * * *` | Daily 03:00 | Drop expired idempotency-key rows |
|
||||
| `audit-log-cleanup` | `15 3 * * *` | Daily 03:15 | Retention sweep |
|
||||
| `medication-inventory-expire` | `30 3 * * *` | Daily 03:30 | Flip IN_USE pens past their 30-day clock to EXPIRED — Since v1.4.25 |
|
||||
| `data-backup` | `0 3 * * 0` | Weekly Sun 03:00 | Local backup |
|
||||
| `offhost-backup` | `30 2 * * *` | Daily 02:30 | S3-compatible offhost backup |
|
||||
| `feedback-aggregator` | `0 4 * * *` | Daily 04:00 | Roll up AI-insight feedback into daily aggregates |
|
||||
|
||||
### Per-user enqueue paths (not on a cron)
|
||||
|
||||
| Queue | Triggered by | Purpose |
|
||||
| --- | --- | --- |
|
||||
| `pr-detection` (per-user) | `POST /api/measurements/batch`, `POST /api/workouts/batch` | Run PR sweep with `silent` flag for historical backfills (>50 entries) |
|
||||
| `withings-activity-sync` (per-user) | Withings webhook (subscription channel) | Webhook-primary; cron is the catch-net |
|
||||
| `withings-sleep-v2-sync` (per-user) | Withings webhook | Same pattern |
|
||||
|
||||
### Worker concurrency
|
||||
|
||||
Every queue uses `localConcurrency: 1` by default. The PR detection worker bumps to 5 in the per-user enqueue path (concurrent users; each user serial). Worker status is exported via `src/lib/jobs/worker-status.ts` and surfaces on `/api/health` as `worker: running | stopped`.
|
||||
|
||||
## Multi-provider AI routing
|
||||
|
||||
```
|
||||
src/lib/ai/
|
||||
├── provider.ts # resolveProvider() (legacy single-provider) + resolveProviderChain()
|
||||
├── provider-chain.ts # Per-user chain resolution (Settings → AI Provider)
|
||||
├── provider-runner.ts # runRawCompletionWithFallback() + AllProvidersFailedError
|
||||
├── anthropic-client.ts # Anthropic Messages API
|
||||
├── openai-client.ts # OpenAI Chat Completions
|
||||
├── codex-client.ts # ChatGPT (Codex) via OAuth
|
||||
├── codex-oauth.ts # Codex OAuth dance + token refresh
|
||||
├── codex-slug-cache.ts # Slug→model-name cache
|
||||
├── local-client.ts # Local LLM (Ollama / LM Studio)
|
||||
├── mock-client.ts # Test fixture
|
||||
├── no-key-fallbacks.ts # Rule-based copy when zero providers configured
|
||||
├── citation-coverage.ts # Citation-presence check
|
||||
├── confidence.ts # Confidence scoring helper
|
||||
├── feedback-attribution.ts # Per-provider feedback attribution
|
||||
├── generate-insight.ts # Top-level insight generation (used by per-status writers)
|
||||
├── legacy-payload.ts # Pre-strict-schema payload detector
|
||||
├── medical-references.ts # MDR + EMA EPAR + EMA citation library
|
||||
├── provider.ts
|
||||
├── schema.ts # Zod schema for insight payloads
|
||||
├── types.ts
|
||||
├── coach/
|
||||
│ ├── budget.ts # enforceBudget() + recordSpend() — daily token cap per user
|
||||
│ ├── glp1-snapshot.ts # GLP-1-aware snapshot enrichment
|
||||
│ ├── keyvalues.ts # ---KEYVALUES--- sentinel parser
|
||||
│ ├── persistence.ts # CoachConversation + CoachMessage CRUD (encrypted at rest)
|
||||
│ ├── refusal.ts # Pattern-based prompt-injection + off-topic guard
|
||||
│ ├── snapshot.ts # buildCoachSnapshot(userId, scope) → SNAPSHOT block
|
||||
│ ├── system-prompt.ts # getCoachSystemPrompt(locale, prefs) — native per-locale
|
||||
│ ├── target-prompts.ts # Per-target prompt overlays
|
||||
│ ├── target-scope.ts # Scope-resolver helpers
|
||||
│ └── types.ts # CoachStreamEvent, CoachProvenance, CoachKeyValue
|
||||
├── prompts/
|
||||
│ ├── base-system.ts # Shared safety contract
|
||||
│ ├── insight-generator.ts # PROMPT_VERSION + strict insights system prompt
|
||||
│ ├── native-prompts.ts # Native per-locale prompt bodies
|
||||
│ ├── safety-contracts.{en,de,fr,es,it,pl}.yaml ← YAML matrix per locale
|
||||
│ ├── safety-contracts.ts # YAML loader
|
||||
│ ├── general-status.ts blood-pressure.ts weight.ts pulse.ts bmi.ts mood.ts medication-compliance.ts
|
||||
```
|
||||
|
||||
### Provider chain resolution
|
||||
|
||||
```
|
||||
resolveProviderChain(userId)
|
||||
↓
|
||||
[providerType, instance] pairs in user-priority order
|
||||
↓
|
||||
runRawCompletionWithFallback({ providers, params })
|
||||
↓ on each attempt:
|
||||
- call provider with params
|
||||
- on 429 → record attempt, continue to next
|
||||
- on other 5xx → record attempt, continue to next
|
||||
- on success → return { result, workingProvider }
|
||||
↓ on all fail:
|
||||
- throw AllProvidersFailedError(attempts)
|
||||
```
|
||||
|
||||
The route reads `attempts.every(a => a.httpStatus === 429)` to distinguish provider rate-limit from generic unavailability — surfaces `coach.provider.rate_limited` vs `coach.provider.unavailable` to iOS via the SSE `error` frame.
|
||||
|
||||
### Daily token budget
|
||||
|
||||
`enforceBudget(userId)` consults `CoachUsage` (per-day token ledger). Exceeded → 429 with `coach.budget.exceeded`. iOS surfaces the daily-limit copy from `messages/{locale}.json`. Budget resets at midnight in the user's timezone.
|
||||
|
||||
### PROMPT_VERSION
|
||||
|
||||
Live constant at `src/lib/ai/prompts/insight-generator.ts` → `PROMPT_VERSION`. Currently `4.25.0`. Bumps when ANY prompt body, safety-contract YAML, or refusal copy changes. Stored on every persisted Coach message (`CoachMessage.promptVersion`) so a future regression can be attributed.
|
||||
|
||||
GROUND RULE 9 (no GLP-1 dose recommendations) and GROUND RULE 15 (no drug-level estimates) live in `safety-contracts.{locale}.yaml` and are mirrored verbatim in CI refusal probes.
|
||||
|
||||
## Coach module
|
||||
|
||||
Top-level flow on `POST /api/insights/chat`:
|
||||
|
||||
```
|
||||
1. requireAuth() (cookie OR Bearer)
|
||||
2. coachChatRequestSchema.safeParse()
|
||||
3. enforceBudget()
|
||||
4. resolveServerLocale({ request, override, userLocale })
|
||||
5. detectRefusal({ message, locale }) ← pattern-based; on refuse short-circuit (no provider call)
|
||||
6. fetch/create conversation
|
||||
7. persist user message (encrypted)
|
||||
8. parseCoachPrefs(user.coachPrefsJson) ← defaultWindow, source toggles
|
||||
9. buildCoachSnapshot(userId, effectiveScope) ← SNAPSHOT block (metric tables + provenance windows)
|
||||
10. getCoachSystemPrompt(locale, coachPrefs) ← native per-locale; safety contract folded in
|
||||
11. buildHistoryWindow(turns) ← 20-turn cap with synthetic summary placeholder
|
||||
12. runRawCompletionWithFallback({...})
|
||||
13. parseKeyValuesSentinel(rawReply) ← extract ---KEYVALUES--- block
|
||||
14. persist assistant message (encrypted) + recordSpend(tokens)
|
||||
15. stream SSE: token frames → provenance frame → done frame
|
||||
```
|
||||
|
||||
The `provenance` SSE frame carries the enriched envelope:
|
||||
|
||||
```
|
||||
{ type: "provenance", metricSource: { windows, metrics, counts?, keyValues? } }
|
||||
```
|
||||
|
||||
`keyValues` is the parsed `---KEYVALUES---` block — load-bearing numbers the Coach drew on. iOS surfaces these in the collapsible "What is this based on?" disclosure under the assistant bubble.
|
||||
|
||||
Detail in `10-coach-pipeline.md`.
|
||||
|
||||
## Insights module
|
||||
|
||||
```
|
||||
src/lib/insights/
|
||||
├── features.ts # extractFeatures(userId) — every dashboard tile pulls from here
|
||||
├── prompt.ts # buildUserPrompt({ features, comparison? }) — strict schema
|
||||
├── memory.ts # Per-status cache lookups + writes (audit_logs row reuse)
|
||||
├── correlations.ts # pairByTimestamp + pearsonCorrelation wrappers
|
||||
├── chart-tokens.ts # Token map for chart citations
|
||||
├── bucket-series.ts # Time bucketing
|
||||
├── week-iso.ts # ISO week math
|
||||
├── sanitize.ts # Strip PII before sending to provider
|
||||
├── sub-page-metric.ts # Per-sub-page metric resolver
|
||||
├── general-status.ts blood-pressure-status.ts weight-status.ts pulse-status.ts
|
||||
├── mood-status.ts bmi-status.ts medication-compliance-status.ts
|
||||
└── glp1-plateau.ts # 4-week plateau detection (GLP-1 specific) ← Since v1.4.25
|
||||
```
|
||||
|
||||
Each per-status writer:
|
||||
|
||||
1. Reads the user's last 90 days from `Measurement` (+ mood + intake events as relevant)
|
||||
2. Runs `summarize()` from `src/lib/analytics/trends.ts`
|
||||
3. Builds a strict-schema prompt
|
||||
4. Calls the provider chain via `generateInsight()`
|
||||
5. Validates the response against `insightResultSchema`
|
||||
6. Caches the result in `audit_logs` keyed on `action = "insights.{scope}-status.{locale}"`
|
||||
|
||||
The per-status cards (`/api/insights/cards`) compose the cached blobs into the iOS-friendly `InsightCard` DTO with `severity ∈ { alert, caution, info, good }`.
|
||||
|
||||
Detail in `11-insights-pipeline.md`.
|
||||
|
||||
## Health Score
|
||||
|
||||
Server-deterministic four-component score with provenance:
|
||||
|
||||
| Component | Source | Range |
|
||||
| --- | --- | --- |
|
||||
| Blood pressure | BP-status classification × in-target proportion | 0–25 |
|
||||
| Weight | Distance from target band × stability | 0–25 |
|
||||
| Mood | 30-day average mood score | 0–25 |
|
||||
| Compliance | 30-day medication intake % | 0–25 |
|
||||
|
||||
Total 0–100. Each component carries an `asOf` timestamp and a canonical source (`WITHINGS` / `APPLE_HEALTH` / `MANUAL`) — the provenance accordion under the score number renders these inline with `aria-labelledby` panel pairing.
|
||||
|
||||
Computation: `src/lib/analytics/health-score.ts` → `computeHealthScore(input)`. The input is built in `src/app/api/analytics/route.ts` (see lines ~698-720 for the canonical builder) by pulling the same last-30-day metric set the dashboard tiles consume — single source of truth for the displayed numbers vs the score.
|
||||
|
||||
Detail in `16-health-score.md`.
|
||||
|
||||
## Source priority — two-axis resolver (Since v1.4.25)
|
||||
|
||||
Persisted as `User.sourcePriorityJson` (nullable JSONB). Null → use `DEFAULT_SOURCE_PRIORITY` verbatim.
|
||||
|
||||
```
|
||||
{
|
||||
// Axis 1 — per-metric ladder
|
||||
metricPriority: {
|
||||
weight: ["WITHINGS", "APPLE_HEALTH", "MANUAL"],
|
||||
bp_sys: ["WITHINGS", "APPLE_HEALTH", "MANUAL"],
|
||||
steps: ["APPLE_HEALTH", "WITHINGS", "MANUAL"],
|
||||
// …
|
||||
},
|
||||
// Axis 2 — per-device-type override (within the chosen source)
|
||||
deviceTypePriority: {
|
||||
weight: ["scale", "watch", "phone"], // Withings Body+ wins over a watch-derived weight
|
||||
bp_sys: ["band", "watch", "phone"],
|
||||
default: ["watch", "phone", "band"]
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
The resolver `pickCanonicalSource(rows, type)`:
|
||||
|
||||
1. Walk `metricPriority[type]` (falling back to `DEFAULT_SOURCE_PRIORITY[type]`).
|
||||
2. For each source in order, find candidate rows.
|
||||
3. If multiple rows from the same source, apply `deviceTypePriority[type]` (falling back to `deviceTypePriority.default`) to tiebreak.
|
||||
4. Return the surviving row; treat `deviceType = null` as `unknown` (always lowest priority).
|
||||
|
||||
Defaults at v1.4.25:
|
||||
|
||||
| Class | Default ladder |
|
||||
| --- | --- |
|
||||
| Cumulative (steps, distance, active-energy) | APPLE_HEALTH → WITHINGS → MANUAL |
|
||||
| Sleep | APPLE_HEALTH → WITHINGS → MANUAL |
|
||||
| HRV | APPLE_HEALTH → WITHINGS → MANUAL |
|
||||
| Resting HR | APPLE_HEALTH → WITHINGS → MANUAL |
|
||||
| Point measurements (weight, BP, pulse, body fat, …) | WITHINGS → APPLE_HEALTH → MANUAL |
|
||||
|
||||
iOS replicates the resolver behaviour client-side ONLY for dashboard fast-path display; the canonical resolution always happens server-side, and the iOS app pulls from `/api/dashboard/summary` for the displayed numbers.
|
||||
|
||||
## Logging — Wide-Event + audit-log
|
||||
|
||||
Two parallel logging surfaces:
|
||||
|
||||
| Surface | What it records | Sink |
|
||||
| --- | --- | --- |
|
||||
| **Wide-Event** | Per-request structured event (route, status, latency, auth method, action.name, meta) | `audit_logs` table + optional GlitchTip + sampling to a generic transport |
|
||||
| **Audit-log** | Security-relevant events (login, token use, mutations, settings changes) | `audit_logs` table |
|
||||
|
||||
`apiHandler()` wraps every route handler so the Wide-Event builder lives inside an AsyncLocalStorage; `annotate({ action, meta })` adds fields anywhere in the handler call tree. `auditLog(action, { userId, ipAddress, details })` is fire-and-forget — never blocks the response.
|
||||
|
||||
## Idempotency
|
||||
|
||||
Header `Idempotency-Key: <opaque-string>` on POST/DELETE replays the cached response within a 24h window. Implementation: `src/lib/idempotency.ts` → `withIdempotency(handler)`. The cache key is `(userId, route, method, idempotencyKey)`.
|
||||
|
||||
Routes that wrap with `withIdempotency`:
|
||||
|
||||
| Route | Verb |
|
||||
| --- | --- |
|
||||
| `/api/measurements` (single + batch sub-mode) | POST |
|
||||
| `/api/measurements/batch` | POST |
|
||||
| `/api/workouts/batch` | POST |
|
||||
| `/api/medications/[id]/intake` | POST |
|
||||
|
||||
Routes that intentionally do NOT (SSE streams cannot replay through the JSON-based cache):
|
||||
|
||||
- `/api/insights/chat` — the conversationId existence check + 20-turn cap serves the dedup contract instead.
|
||||
|
||||
## Rate limits
|
||||
|
||||
Defined per-route via `checkRateLimit(key, max, windowMs)`. The key is typically `resource:action:{userId}` for authed routes or `resource:action:{ip}` for pre-auth routes. The hardened batch endpoints (Since v1.4.25 W10):
|
||||
|
||||
| Route | Key | Limit |
|
||||
| --- | --- | --- |
|
||||
| `/api/measurements/batch` | `measurements:batch:{userId}` | 60 / min |
|
||||
| `/api/workouts/batch` | `workouts:batch:{userId}` | 60 / min |
|
||||
| `/api/auth/login` | `auth:login:{ip}` | 5 / 15 min |
|
||||
| `/api/auth/refresh` | `auth:refresh:{ip}` | 60 / 15 min |
|
||||
| `/api/auth/passkey/login-verify` | `auth:passkey-verify:{ip}` | 10 / 15 min |
|
||||
| `/api/onboarding/step` | `onboarding-step:{userId}` | 30 / 10 min |
|
||||
| `/api/auth/me/research-mode` (POST) | `research-mode:post:{userId}` | 5 / min |
|
||||
| `/api/insights/generate` | `insights:generate:{userId}` | env-tunable, default 10 / h |
|
||||
|
||||
429 responses carry `X-RateLimit-*` headers via `rateLimitHeaders(rl)`.
|
||||
|
||||
## Notifications
|
||||
|
||||
```
|
||||
src/lib/notifications/
|
||||
├── dispatcher.ts # Per-event-type dispatcher with channel cascade
|
||||
├── retry-policy.ts # Exponential backoff
|
||||
├── channel-state.ts # NotificationChannel CRUD
|
||||
├── vapid-config.ts # VAPID key generation + persistence
|
||||
├── types.ts # EVENT_TYPES, CHANNEL_TYPE_LABELS
|
||||
└── senders/
|
||||
├── telegram.ts # Telegram bot
|
||||
├── ntfy.ts # ntfy.sh (or self-hosted ntfy)
|
||||
├── web-push.ts # Browser Web Push (VAPID)
|
||||
└── apns.ts # Apple Push Notification Service (Since v1.4.23 scaffolding)
|
||||
```
|
||||
|
||||
Channel cascade: each user has zero-to-many `NotificationChannel` rows. The dispatcher walks them in order and respects per-channel + per-event-type `NotificationPreference` toggles. Global admin toggles (`AppSettings.{telegramGlobal, ntfyGlobal, webPushGlobal}`) act as hard stops.
|
||||
|
||||
For iOS — the APNs sender wires into the existing dispatcher, so the same `eventType` (`PERSONAL_RECORD_ACHIEVED`, `MEDICATION_REMINDER`, `INSIGHT_READY`) reaches the device through the registered APNs token. iOS registers via the `devices/` endpoint family — detail in `05-auth-flows.md`.
|
||||
|
||||
## Withings integration
|
||||
|
||||
```
|
||||
src/lib/withings/
|
||||
├── client.ts # OAuth code-exchange, refresh, getAuthorizationUrl, hasActivityScope
|
||||
├── credentials.ts # Per-user Client ID/Secret (encrypted in DB)
|
||||
├── sync.ts # Measure sync (BP, weight, body composition, temperature, …)
|
||||
├── sync-activity.ts # Activity sync (steps + distance + active-energy) — Since v1.4.25
|
||||
├── sync-sleep.ts # Sleep v2 stage-level segments — Since v1.4.25
|
||||
├── webhook-handler.ts # POST /api/withings/webhook handler
|
||||
└── mapping.md # Withings measure type → Measurement enum mapping (human-readable)
|
||||
```
|
||||
|
||||
Webhook subscription channels (Since v1.4.25):
|
||||
|
||||
| Channel | Event type |
|
||||
| --- | --- |
|
||||
| 1 | New measure (weight, BP, pulse, body composition) |
|
||||
| 2 | New activity (steps + distance + calories) |
|
||||
| 3 | Activity activity update |
|
||||
| 4 | New sleep segment (sleep v2) |
|
||||
| 16 | New temperature reading |
|
||||
| 50 | Auth revoked |
|
||||
|
||||
OAuth scope upgrade banner: when `WithingsConnection.scope` is null (legacy v1.4.24 connection) or doesn't include `user.activity`, the Settings → Integrations card surfaces a reconnect banner. iOS surfaces the same banner; on tap, opens the Withings OAuth URL in `SFSafariViewController`.
|
||||
|
||||
Detail in `15-withings-bridge.md`.
|
||||
|
||||
## Apple Health bridge
|
||||
|
||||
```
|
||||
src/lib/measurements/apple-health-mapping.ts
|
||||
```
|
||||
|
||||
Server-side identifier map (ported from `k0rventen/apple-health-grafana` + `dogsheep/healthkit-to-sqlite` with MIT + Apache-2.0 attribution recorded in source headers and `NOTICE`). Covers the v1.4.25 ingest surface; iOS-18 long-tail mappings (sleep apnea, GAD-7, paddle/row sports, FHIR clinical) carry inline release-window comments.
|
||||
|
||||
```
|
||||
mapAppleHealthEntry({ hkIdentifier, value, unit, startDate, endDate, sleepStage? })
|
||||
→ { type, value, unit, takenAt, sleepStage? } | null
|
||||
```
|
||||
|
||||
Returns `null` when the identifier is unknown — the batch endpoint surfaces that as a per-entry `skipped` with `reason: "unmappable_identifier"`. iOS does not need to lock its identifier set to the server's — unknown identifiers degrade gracefully.
|
||||
|
||||
## Onboarding wizard (Since v1.4.25)
|
||||
|
||||
| Step | Persisted | Endpoint |
|
||||
| --- | --- | --- |
|
||||
| 0 | Welcome (server-rendered) | — |
|
||||
| 1 | Goals chip-picker | `POST /api/onboarding/step { step: 1 }` |
|
||||
| 2 | Source selection 4-card grid | `POST /api/onboarding/step { step: 2 }` |
|
||||
| 3 | Baseline form (height / DOB / gender) | `POST /api/onboarding/step { step: 3 }` |
|
||||
| 4 | Source-connect step + Done — flips `onboardingCompletedAt` | `POST /api/onboarding/step { step: 4 }` |
|
||||
|
||||
State machine guards:
|
||||
|
||||
- Step out-of-order → 409
|
||||
- Already-completed user → 409
|
||||
- Concurrent advance (read-then-write race) → 409 via `updateMany` with conditional WHERE
|
||||
|
||||
Detail in `18-onboarding.md`.
|
||||
|
||||
## Personal Records (Since v1.4.25)
|
||||
|
||||
| Component | Location |
|
||||
| --- | --- |
|
||||
| Schema | `PersonalRecord` table (Migration 0054) |
|
||||
| Detector | `src/lib/jobs/pr-detection.ts` — MAX / MIN per metric + workout slots |
|
||||
| Trigger | Per-user enqueue on batch ingest; 30-min fallback cron |
|
||||
| Warm-up gate | First datapoint per metric does NOT promote itself |
|
||||
| Silent flag | Historical backfills (>50 entries) suppress push |
|
||||
| Push opt-in | Per-user toggle (default off) |
|
||||
| Trend tile badge | Renders when record landed in last 30 days; WCAG-AA contrast |
|
||||
|
||||
Detail in `17-personal-records.md`.
|
||||
|
||||
## Per-user timezone (Since v1.4.25)
|
||||
|
||||
`User.timezone` column (Migration 0043, default `Europe/Berlin`). Threaded through ten surfaces:
|
||||
|
||||
- CSV exporter — ISO-8601 with offset
|
||||
- Formatters (`src/lib/format-locale.ts`, `src/lib/time-window-format.ts`)
|
||||
- Profile picker — covers all IANA zones
|
||||
- Admin sets the default for new accounts
|
||||
- Signup detects browser tz
|
||||
- Doctor-report PDF
|
||||
- Chart x-axes — `timezone` prop on every chart wrapper
|
||||
- Coach snapshot timestamps
|
||||
- `MoodEntry.tz` records local-day grouping (Migration 0044)
|
||||
- Weight-weekday correlator
|
||||
|
||||
`src/lib/tz/resolver.ts`:
|
||||
|
||||
```
|
||||
userDayKey(date, tz) // → "YYYY-MM-DD" in tz
|
||||
DEFAULT_TIMEZONE = "Europe/Berlin"
|
||||
isValidTimezone(tz) // → boolean (runtime Intl.DateTimeFormat probe)
|
||||
invalidateUserTimezone(userId) // → cache evict
|
||||
```
|
||||
|
||||
iOS reads `user.timezone` from `/api/auth/me` and uses it as the canonical display tz. Server is the source of truth; iOS never overrides without writing back via `PUT /api/auth/me/timezone`.
|
||||
|
||||
## STOP HERE if…
|
||||
|
||||
| If your task is… | …skip the rest and read… |
|
||||
| --- | --- |
|
||||
| "How do I call endpoint X?" | `03-api-contracts.md` |
|
||||
| "How does the Coach really work?" | `10-coach-pipeline.md` |
|
||||
| "How do I wire the AI providers?" | `12-ai-providers.md` |
|
||||
| "How does Withings sync?" | `15-withings-bridge.md` |
|
||||
|
||||
Otherwise: continue to `03-api-contracts.md`.
|
||||
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,103 @@
|
||||
---
|
||||
file: README.md
|
||||
purpose: Navigation map for the v1.5 iOS handoff doc-pack — what each file is for, which order to read in, and which questions map to which file
|
||||
when_to_read: First. Always. Before opening any other file in this directory.
|
||||
prerequisites: none
|
||||
estimated_tokens: ~1800
|
||||
version_anchor: v1.4.25 / sha 49f71c92
|
||||
---
|
||||
|
||||
## TL;DR
|
||||
|
||||
This doc-pack is the v1.4.25 contract handoff for the v1.5 iOS native app. Start with `00-philosophy.md` if you have never touched the codebase before. Skip to `03-api-contracts.md` if you only need an endpoint shape.
|
||||
|
||||
The pack is twenty-one files split across four authors (A through D) plus a final cross-link pass (E). Files prefixed with the same digit form a thematic cluster (00–03 foundation, 04–05 data and auth, 06–08 UI and state, 09–11 AI). Each file is self-contained: read one in isolation and the YAML frontmatter tells you what context to pre-load.
|
||||
|
||||
## Inventory
|
||||
|
||||
| File | Owner | Purpose | ~Tokens |
|
||||
| --- | --- | --- | --- |
|
||||
| `README.md` | A | This file. Navigation map. | 1800 |
|
||||
| `00-philosophy.md` | A | Why-questions — every load-bearing project decision and the reasoning behind it | 3000 |
|
||||
| `01-repo-tour.md` | A | `src/` layout, where to find migrations / messages / e2e / planning artifacts | 3000 |
|
||||
| `02-server-architecture.md` | A | Backend services — Next.js, Prisma, pg-boss queues, cron jobs, Coach + Insights module structure | 5000 |
|
||||
| `03-api-contracts.md` | A | Every HTTP endpoint iOS will call, with Zod schema excerpts, rate limits, error codes, and curl self-tests | 8000 |
|
||||
| `04-data-model.md` | B | Prisma schema reference — every table iOS touches, every enum, every composite index | 5000 |
|
||||
| `05-auth-flows.md` | B | Session cookie vs Bearer vs refresh token, passkey + password + native-client paths, scope rules | 4000 |
|
||||
| `06-ui-conventions.md` | C | Component primitives, shadcn/ui style, Dracula tokens, Recharts decisions, full-width grid rule | 3000 |
|
||||
| `07-state-management.md` | C | TanStack Query keys, mutation invalidation, idempotency-key wiring, optimistic-update playbook | 3000 |
|
||||
| `08-locales-i18n.md` | C | Six-locale matrix, message-key hygiene, Coach native-prompt locale routing, maintainership banner | 2500 |
|
||||
| `09-recommended-flow.md` | C | How to build a feature end-to-end — the proven order across this codebase | 2500 |
|
||||
| `10-coach-pipeline.md` | D | Coach snapshot + prompt + provider chain + refusal + budget + KEYVALUES sentinel | 5000 |
|
||||
| `11-insights-pipeline.md` | D | Insights generation, strict schema, per-status caching, comparison snapshot | 4000 |
|
||||
| `12-ai-providers.md` | D | Provider routing — Anthropic, OpenAI, Codex (ChatGPT), local — and failover rules | 3000 |
|
||||
| `13-medical-content.md` | D | MDR boundary, GROUND RULES, refusal probes, citation surfacing | 3000 |
|
||||
| `14-glp1-surfaces.md` | D | GLP-1 specialist endpoints, treatment-class flag, pen inventory, side-effect taxonomy, drug-level Research Mode | 4000 |
|
||||
| `15-withings-bridge.md` | B | Withings OAuth, webhook subscription, activity + sleep v2 sync, scope upgrade banner | 3500 |
|
||||
| `16-health-score.md` | D | Health Score component derivation, provenance accordion, four-component scoring | 2000 |
|
||||
| `17-personal-records.md` | D | PR detection worker, push opt-in, warmup gate, trend-badge contract | 1500 |
|
||||
| `18-onboarding.md` | C | Nested-route wizard, step state machine, race-safe step advance | 1500 |
|
||||
| `19-deploy-runtime.md` | A | Coolify config location, GHCR multi-arch image, environment variables, secret rotation | 2500 |
|
||||
| `20-cross-link-index.md` | E | Master question-to-file map, stop-here markers, and Wave-4-5 diff summary against v1.4.24 | 3500 |
|
||||
|
||||
## Question → File (seed; Agent E finalises)
|
||||
|
||||
| Question | File |
|
||||
| --- | --- |
|
||||
| "Which auth method should iOS use?" | `05-auth-flows.md` |
|
||||
| "How do I post a HealthKit batch?" | `03-api-contracts.md` § Measurements |
|
||||
| "What does a workout DTO look like?" | `03-api-contracts.md` § Workouts |
|
||||
| "Why is the Coach chat SSE instead of one-shot JSON?" | `10-coach-pipeline.md` |
|
||||
| "Why six locales?" | `08-locales-i18n.md` and `00-philosophy.md` |
|
||||
| "What is the MDR boundary?" | `13-medical-content.md` |
|
||||
| "What is `treatmentClass = GLP1`?" | `14-glp1-surfaces.md` |
|
||||
| "Why Postgres and not SQLite?" | `00-philosophy.md` |
|
||||
| "Where do feature flags live?" | `02-server-architecture.md` |
|
||||
| "What is the develop → main branch model?" | `00-philosophy.md` |
|
||||
|
||||
(Agent E expands this to a 50+ row decision tree.)
|
||||
|
||||
## Reading orders by goal (seed; Agent E finalises)
|
||||
|
||||
| Goal | Order |
|
||||
| --- | --- |
|
||||
| First-time read, build mental model | 00 → 01 → 02 → 04 → 05 → 03 |
|
||||
| Wire HealthKit batch ingest | 05 → 04 → 03 § Measurements → 03 § Workouts |
|
||||
| Build the Coach drawer | 10 → 12 → 13 → 03 § Coach |
|
||||
| Build the dashboard | 02 → 03 § Dashboard → 06 → 07 → 16 |
|
||||
| Build GLP-1 surfaces | 14 → 04 → 13 → 03 § Medications |
|
||||
| Set up Withings reconnect | 15 → 03 § Withings → 04 |
|
||||
| Onboarding flow | 18 → 03 § Onboarding |
|
||||
| Set up CI / Coolify | 19 → 01 |
|
||||
|
||||
## Stop-here markers index (Agent E finalises)
|
||||
|
||||
Every file carries `STOP HERE if …` markers so a narrowly-scoped reader can exit early. Agent E will publish the master index here once A–D files land.
|
||||
|
||||
## Marathon-pattern handoff section
|
||||
|
||||
Detail in `09-recommended-flow.md`. The pattern this server-side codebase uses for feature delivery (parallel sub-agent waves, decision-relay communication, quality gates) maps cleanly onto a Swift app — Agent E cross-links there.
|
||||
|
||||
## Conventions in this pack
|
||||
|
||||
- YAML frontmatter on every file — read it.
|
||||
- TL;DR at the top of every body — 2-3 sentences, before any H2.
|
||||
- Tables and decision trees outrank prose.
|
||||
- Code excerpts use `// from path:line` headers — copy-paste-ready.
|
||||
- `Since v1.4.24` diff markers flag Wave-4-5 additions iOS-Claude familiar with the previous baseline can fast-skim.
|
||||
- English, terse, professional. No emojis.
|
||||
|
||||
## Version anchor
|
||||
|
||||
Every file in this pack is locked to **v1.4.25 / sha 49f71c92**. If you read a file and your local code differs from the cited excerpt, you are on a different version — check `git log` for the sha first, do not assume the doc is wrong.
|
||||
|
||||
## Cross-link tables (Agent E finalises)
|
||||
|
||||
After Agents B / C / D land their files, Agent E will populate:
|
||||
|
||||
- Master question → file decision tree (50+ rows)
|
||||
- Stop-here-marker master index
|
||||
- Wave-4-5 diff table against v1.4.24
|
||||
- Reading orders by goal (expanded to 15+ scenarios)
|
||||
|
||||
Until E lands, treat the seed tables above as the working draft.
|
||||
Reference in new issue
Block a user