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 withpackage.json,tsconfig.json,src/index.tsinfra/docker-compose.yml(postgres:16, redis:7, with healthchecks and named volumes).github/workflows/ci.yml
Steps
- Create the workspace and Turborepo pipelines:
build,dev,lint,typecheck,test(with correctdependsOn). apps/webandapps/admin:create-next-appequivalent (App Router, TS, Tailwind, no src dir alias conflicts).apps/api: Fastify hello on:4000withGET /v1/health → { ok: true }.apps/workers: a process that connects to Redis and logs "workers ready".packages/config:loadEnv()validating every variable of annexe F with zod; export a typedenv. Missing required var = crash at boot with the var name..env.example: every variable from annexe F, empty values, grouped with comments.- ESLint (typescript-eslint strict), Prettier, Vitest at root with a sample test per package.
- CI: install, lint, typecheck, test,
forge buildplaceholder step skipped until T30, gitleaks secret scan.
Done when
-
docker compose -f infra/docker-compose.yml up -d→ both healthy -
pnpm i && pnpm buildsucceeds from a clean clone -
pnpm devserves web :3000, admin :3001, api :4000 (curl :4000/v1/health→{"ok":true}), workers log "workers ready" -
pnpm lint && pnpm typecheck && pnpm testpass - Removing
DATABASE_URLfrom.envmakespnpm devfail 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
- Translate every
create typeandcreate tableof 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 returnsbigint. - Add
checkconstraints: EVM address regex,score between 0 and 100,position between 1 and 5,vote_closes_at > vote_opens_at. - Export typed
dbclient (node-postgres pool) and inferred row types. - Seed (
exampledata 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 6walletsrows with placeholder addresses, defaultsettingsrows (all thresholds fromanalyse-des-projets.mdand annexe E). - Add a
resetTestDb()helper for integration tests.
Done when
-
pnpm db:migrateon an empty DB succeeds;pnpm db:generateproduces no diff afterwards -
pnpm db:seedruns 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-trips2n ** 200nexactly
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
config.ts: perCHAIN_ENV→ chain id (4663 mainnet, 46630 testnet), RPC + fallback, Pons factory/escrow/hook addresses (from annexe D), burn address, BuybackBurner address (env).clients.ts: viempublicClientwith fallback transport (primary then fallback RPC, 2 retries);walletClient(keyRef)that loads a private key from the secrets manager adapter (interfaceSecretStore.get(ref); local dev implementation reads from env var named by the ref).logs.ts:getLogsChunked({ address, event, fromBlock, toBlock, chunk=10_000 })that splits ranges, retries on "range too large" by halving, returns logs in order.write.ts:sendTx({ request, capCheck, ledger }): refuse ifCHAIN_ENV !== 'mainnet'and chain id is mainnet;simulateContract; runcapCheck(); send; wait 1 confirmation; callledger(txHash, receipt); return hash. Idempotency key param: if a ledger row with that key has a tx_hash, return it without sending.abi/: put a README listing the ABIs needed (Pons factory v2, escrow, Meme hook, curve, ERC-20) and anerc20.json. Pons ABIs are fetched from the explorer in T10 (⚠️ Pons).
Done when
- Unit test:
getLogsChunkedsplits 0→25,000 into 3 calls with chunk 10,000, and halves on a mocked "range too large" error - Unit test:
sendTxon a mocked mainnet chain withCHAIN_ENV=testnetthrowsWRONG_CHAIN_ENVand never sends - Unit test:
sendTxwith 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
ApiError+ global error handler producing{ error: { code, message } }; zod validation errors →invalid_input400; unknown →internal500 withrequest_id.- pino logger with
request_id; redactcookie,authorization,email,code. - CORS limited to
APP_URLand admin URL. Rate limits per annexe B (per route config). - Session plugin: reads
vivra_session, loadssessionsrow (not revoked, not expired), setsreq.user. HelperrequireUser(). - 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_inputwith the field name inmessage - Test: 121 requests/min on a public route → 429 with
Retry-After - Test: a message published on Redis channel
ws:operation:1reaches a WS client subscribed tooperation: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
- 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. - Repeatables: every minute for
cycles.clock,orchestrator.tick,social.publish,alerts.check; every 15 mintracking.candidates; hourlytracking.followup; daily 00:30 UTCtreasury.daily; dailybuyback.refill. idempotent(jobKey, fn): Postgres advisory lock + ajob_runstable (add it to packages/db in this ticket) storing key, status, result.- Retry policy from conventions. On final failure:
alert({ level, message, context })→ Telegram team chat (TELEGRAM_ALERTS_CHAT_ID) and pino. - Graceful shutdown on SIGTERM (finish current jobs, 30s max).
Done when
-
pnpm --filter workers devlogs every queue name once at boot - Test: running the same
idempotent('x', fn)twice concurrently executesfnonce - Test: a job that throws 3 times calls
alertonce 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).