# M0 · Foundations (T00–T04)

Exit gate: `pnpm dev` starts web, admin, api and workers locally against Docker Postgres + Redis; migrations apply; CI is green.

---

## T00 · Monorepo scaffold

**Goal** A pnpm + Turborepo monorepo with all apps and packages as empty, compiling shells, plus CI.

**Depends on** none

**Read first** `annexe-mise-en-place.md`, `build-conventions.md`, `CLAUDE.template.md`

**Build**
- root: `package.json`, `pnpm-workspace.yaml`, `turbo.json`, `tsconfig.base.json`, `.eslintrc.cjs`, `.prettierrc`, `.editorconfig`, `.gitignore`, `.env.example`, `.nvmrc` (22)
- `apps/{web,admin,api,workers}`, `packages/{config,db,core,chain,agents,templates,ui}` each with `package.json`, `tsconfig.json`, `src/index.ts`
- `infra/docker-compose.yml` (postgres:16, redis:7, with healthchecks and named volumes)
- `.github/workflows/ci.yml`

**Steps**
1. Create the workspace and Turborepo pipelines: `build`, `dev`, `lint`, `typecheck`, `test` (with correct `dependsOn`).
2. `apps/web` and `apps/admin`: `create-next-app` equivalent (App Router, TS, Tailwind, no src dir alias conflicts). `apps/api`: Fastify hello on `:4000` with `GET /v1/health → { ok: true }`. `apps/workers`: a process that connects to Redis and logs "workers ready".
3. `packages/config`: `loadEnv()` validating every variable of annexe F with zod; export a typed `env`. Missing required var = crash at boot with the var name.
4. `.env.example`: every variable from annexe F, empty values, grouped with comments.
5. ESLint (typescript-eslint strict), Prettier, Vitest at root with a sample test per package.
6. CI: install, lint, typecheck, test, `forge build` placeholder step skipped until T30, gitleaks secret scan.

**Done when**
- [ ] `docker compose -f infra/docker-compose.yml up -d` → both healthy
- [ ] `pnpm i && pnpm build` succeeds from a clean clone
- [ ] `pnpm dev` serves web :3000, admin :3001, api :4000 (`curl :4000/v1/health` → `{"ok":true}`), workers log "workers ready"
- [ ] `pnpm lint && pnpm typecheck && pnpm test` pass
- [ ] Removing `DATABASE_URL` from `.env` makes `pnpm dev` fail with a clear message
- [ ] CI runs on a PR and is green

**Out of scope** Any business logic, DB schema, UI design.

---

## T01 · Database schema, migrations, seed

**Goal** The full Postgres schema of annexe A in Drizzle, migrated, with an example seed.

**Depends on** T00

**Read first** `annexe-base-de-donnees.md` (all of it), `analyse-des-projets.md` (token lifecycle), `annexe-score-de-reprise.md` (recovery_scores.components)

**Build** `packages/db/src/schema/{enums,tokens,users,cycles,operations,treasury,admin}.ts`, `packages/db/src/client.ts`, `packages/db/drizzle.config.ts`, `packages/db/migrations/*`, `packages/db/src/seed.ts`, root scripts `db:generate`, `db:migrate`, `db:seed`

**Steps**
1. Translate every `create type` and `create table` of annexe A to Drizzle 1:1 (names, nullability, defaults, uniques, indexes, FKs). `numeric(78,0)` → `numeric({ precision: 78, scale: 0, mode: 'bigint' })` via a custom type that returns `bigint`.
2. Add `check` constraints: EVM address regex, `score between 0 and 100`, `position between 1 and 5`, `vote_closes_at > vote_opens_at`.
3. Export typed `db` client (node-postgres pool) and inferred row types.
4. Seed (`example` data only, clearly named): 5 validated example tokens (KEEL, PULSR, NODA, ORBI, WEFT with the values from the dapp mockups), 1 published cycle, 1 operation in progress (GRIDL), the 6 `wallets` rows with placeholder addresses, default `settings` rows (all thresholds from `analyse-des-projets.md` and annexe E).
5. Add a `resetTestDb()` helper for integration tests.

**Done when**
- [ ] `pnpm db:migrate` on an empty DB succeeds; `pnpm db:generate` produces no diff afterwards
- [ ] `pnpm db:seed` runs twice without error (idempotent upserts)
- [ ] Test: inserting a vote twice for the same (cycle, user) fails with a unique violation
- [ ] Test: inserting a token with an invalid address fails the check constraint
- [ ] Test: a `numeric(78,0)` column round-trips `2n ** 200n` exactly

**Out of scope** Business rules in code (they live in packages/core).

---

## T02 · Chain package

**Goal** `@vivra/chain` gives typed read clients, chunked log fetching and a guarded write path for testnet / fork / mainnet.

**Depends on** T00

**Read first** `annexe-contrats.md`, `token-vivra-et-tresorerie.md`, `cto-pons-et-frais.md`

**Build** `packages/chain/src/{config.ts,clients.ts,logs.ts,write.ts,errors.ts}`, `packages/chain/src/abi/` (placeholders + README)

**Steps**
1. `config.ts`: per `CHAIN_ENV` → chain id (4663 mainnet, 46630 testnet), RPC + fallback, Pons factory/escrow/hook addresses (from annexe D), burn address, BuybackBurner address (env).
2. `clients.ts`: viem `publicClient` with fallback transport (primary then fallback RPC, 2 retries); `walletClient(keyRef)` that loads a private key from the secrets manager adapter (interface `SecretStore.get(ref)`; local dev implementation reads from env var named by the ref).
3. `logs.ts`: `getLogsChunked({ address, event, fromBlock, toBlock, chunk=10_000 })` that splits ranges, retries on "range too large" by halving, returns logs in order.
4. `write.ts`: `sendTx({ request, capCheck, ledger })`: refuse if `CHAIN_ENV !== 'mainnet'` and chain id is mainnet; `simulateContract`; run `capCheck()`; send; wait 1 confirmation; call `ledger(txHash, receipt)`; return hash. Idempotency key param: if a ledger row with that key has a tx_hash, return it without sending.
5. `abi/`: put a README listing the ABIs needed (Pons factory v2, escrow, Meme hook, curve, ERC-20) and an `erc20.json`. Pons ABIs are fetched from the explorer in T10 (⚠️ Pons).

**Done when**
- [ ] Unit test: `getLogsChunked` splits 0→25,000 into 3 calls with chunk 10,000, and halves on a mocked "range too large" error
- [ ] Unit test: `sendTx` on a mocked mainnet chain with `CHAIN_ENV=testnet` throws `WRONG_CHAIN_ENV` and never sends
- [ ] Unit test: `sendTx` with an existing idempotency key returns the stored hash and never sends
- [ ] Testnet smoke (`TESTNET=1 pnpm --filter @vivra/chain test`): reads latest block number on chain 46630

**Out of scope** Pons-specific reads (T10), BuybackBurner (T30).

---

## T03 · API skeleton

**Goal** Fastify app with config, logging, error format, rate limiting, CORS, sessions plumbing and a WebSocket hub.

**Depends on** T00, T01

**Read first** `annexe-api.md` (Généralités, format d'erreur, limites), `build-conventions.md` (API)

**Build** `apps/api/src/{server.ts,errors.ts,plugins/{db,session,rateLimit,cors,ws}.ts,routes/health.ts}`

**Steps**
1. `ApiError` + global error handler producing `{ error: { code, message } }`; zod validation errors → `invalid_input` 400; unknown → `internal` 500 with `request_id`.
2. pino logger with `request_id`; redact `cookie`, `authorization`, `email`, `code`.
3. CORS limited to `APP_URL` and admin URL. Rate limits per annexe B (per route config).
4. Session plugin: reads `vivra_session`, loads `sessions` row (not revoked, not expired), sets `req.user`. Helper `requireUser()`.
5. WebSocket hub at `/v1/ws`: clients send `{ subscribe: "cycle:<id>" | "operation:<id>", since?: number }`; server keeps rooms; `publish(room, message)` exported for other modules; ping every 25s. Use Redis pub/sub so workers can publish.

**Done when**
- [ ] Test: unknown route → 404 `{ error: { code: "not_found" } }`
- [ ] Test: bad body → 400 `invalid_input` with the field name in `message`
- [ ] Test: 121 requests/min on a public route → 429 with `Retry-After`
- [ ] Test: a message published on Redis channel `ws:operation:1` reaches a WS client subscribed to `operation:1`
- [ ] Logs never contain the string of a cookie value (test with a fake cookie)

**Out of scope** Auth flows (T40), domain routes.

---

## T04 · Workers skeleton

**Goal** BullMQ worker process with every queue of annexe F registered, a scheduler for repeatable jobs, idempotency and alert helpers.

**Depends on** T00, T01

**Read first** `annexe-mise-en-place.md` (Files et tâches planifiées), `securite-et-observabilite.md` (alertes)

**Build** `apps/workers/src/{index.ts,scheduler.ts,queues.ts,lib/{idempotent.ts,alert.ts,lock.ts}}`, one stub job file per queue

**Steps**
1. Register queues: `projects.analyze`, `tracking.candidates`, `tracking.live`, `tracking.followup`, `cycles.clock`, `orchestrator.tick`, `agents.run`, `tg.updates`, `buyback.slices`, `buyback.refill`, `treasury.daily`, `social.publish`, `alerts.check`.
2. Repeatables: every minute for `cycles.clock`, `orchestrator.tick`, `social.publish`, `alerts.check`; every 15 min `tracking.candidates`; hourly `tracking.followup`; daily 00:30 UTC `treasury.daily`; daily `buyback.refill`.
3. `idempotent(jobKey, fn)`: Postgres advisory lock + a `job_runs` table (add it to packages/db in this ticket) storing key, status, result.
4. Retry policy from conventions. On final failure: `alert({ level, message, context })` → Telegram team chat (`TELEGRAM_ALERTS_CHAT_ID`) and pino.
5. Graceful shutdown on SIGTERM (finish current jobs, 30s max).

**Done when**
- [ ] `pnpm --filter workers dev` logs every queue name once at boot
- [ ] Test: running the same `idempotent('x', fn)` twice concurrently executes `fn` once
- [ ] Test: a job that throws 3 times calls `alert` once with the job name
- [ ] SIGTERM during a 5s fake job lets it finish, then exits 0

**Out of scope** Real job logic (later tickets fill each stub).
