diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..b77c882 --- /dev/null +++ b/.dockerignore @@ -0,0 +1,19 @@ +# Build context of docker/app.Dockerfile (the repository root). +# The maquette builds from its own context (maquette/), not from here. +.git +.remember +.env +.env.* +!.env.example +data +target +maquette +docs +web/node_modules +web/build +web/.svelte-kit +web/test-results +web/playwright-report +**/*.db +**/*.db-wal +**/*.db-shm diff --git a/.env.example b/.env.example index 565aa4d..81342a3 100644 --- a/.env.example +++ b/.env.example @@ -1,49 +1,34 @@ -# PostgreSQL -POSTGRES_DB=libredecision -POSTGRES_USER=libredecision -POSTGRES_PASSWORD=change-me-in-production -DATABASE_URL=postgresql+asyncpg://libredecision:change-me-in-production@localhost:5432/libredecision +# libreDecision v3 — variables du serveur (ld-server). Toutes facultatives en dev. +# Le serveur lit son environnement, pas de fichier : `set -a; . ./.env; set +a` avant +# `cargo run -p ld-server`. Le .env n'est jamais commité. -# Backend -SECRET_KEY=change-me-in-production-with-a-real-secret-key -DEBUG=true +# Écoute et dossiers +LD_BIND=0.0.0.0:8002 +LD_DATA_DIR=data # SQLite, clone du dépôt des textes, clé de publication — données runtime +LD_WEB_DIR=web/build # interface compilée (npm run build) +LD_SEED_DIR=seeds +LD_PUBLIC_URL=http://127.0.0.1:3002 -# Environment: development, staging, production -ENVIRONMENT=development +# Connexion Ğ1 — client OAuth2 de connect.monnaie-libre.fr (les deux sont requis, sinon désactivée) +# LD_CONNECT_URL=https://connect.monnaie-libre.fr +# LD_CONNECT_CLIENT_ID= +# LD_CONNECT_CLIENT_SECRET= -# Logging: DEBUG, INFO, WARNING, ERROR -LOG_LEVEL=INFO +# Accès (trustinWallet) +LD_ADMIN_G1_IDS= # ex. idty:8352 — administrateurs des accès, séparés par des virgules +LD_DEV_LOGIN=1 # dev local uniquement : connexion sans clé, membre de tous les groupes +LD_BOOTSTRAP_ADMIN=0 # 1 = au premier démarrage sans admin, écrit data/bootstrap-admin.txt (0600) -# CORS -CORS_ORIGINS=["http://localhost:3002"] +# Publication des textes en vigueur (dépôt git lu par les applis clientes) +# LD_PUBLISH_REMOTE=git@git.duniter.org:Yvv/libredecision-textes.git +# LD_PUBLISH_BRANCH=main +# LD_PUBLISH_KEY=data/keys/publish_ed25519 # générée au démarrage si absente +# LD_PUBLISH_AUTHOR=libreDecision +# LD_PUBLISH_EMAIL=decision@librodrome.org -# Database connection pool -DATABASE_POOL_SIZE=20 -DATABASE_MAX_OVERFLOW=10 +# Taille de la toile de confiance (W) — lue sur la chaîne, repli sur l'indexeur puis sur la valeur fixe +# LD_RPC_ENDPOINTS=https://rpc.duniter.org,https://g1.axiom-team.fr/ws/ +# LD_INDEXERS=https://indexer.duniter.org/v1/graphql +# LD_WOT_FALLBACK=5745 -# Auth / Sessions -SESSION_TTL_HOURS=24 -CHALLENGE_EXPIRE_SECONDS=300 - -# Rate limiting (requests per minute) -RATE_LIMIT_DEFAULT=60 -RATE_LIMIT_AUTH=10 -RATE_LIMIT_VOTE=30 - -# Duniter V2 RPC -DUNITER_RPC_URL=wss://gdev.p2p.legal/ws -DUNITER_RPC_TIMEOUT_SECONDS=10 - -# Blockchain cache TTL (seconds) -BLOCKCHAIN_CACHE_TTL_SECONDS=3600 - -# IPFS -IPFS_API_URL=http://localhost:5001 -IPFS_GATEWAY_URL=http://localhost:8080 -IPFS_TIMEOUT_SECONDS=30 - -# Frontend -NUXT_PUBLIC_API_BASE=http://localhost:8002/api/v1 - -# Docker / Production -DOMAIN=decision.librodrome.org +RUST_LOG=info,sqlx=warn diff --git a/.woodpecker/app.yml b/.woodpecker/app.yml new file mode 100644 index 0000000..0025906 --- /dev/null +++ b/.woodpecker/app.yml @@ -0,0 +1,183 @@ +# libreDecision v3 — le produit (Rust + Svelte) sur decision.librodrome.org. +# Une seule image : la construction rejoue toute la boucle (fmt, clippy -D warnings, +# tests Rust, svelte-check, vitest) — un contrôle rouge, pas d'image. +# Données runtime : volume nommé lpk-libredecision-app_data (SQLite, clone du dépôt +# des textes, clé de publication). Jamais `down -v`, jamais recréé. + +when: + - branch: main + event: push + +# La maquette libère d'abord la racine (elle part sur maquette.decision.librodrome.org) ; +# le produit se déploie ensuite, même si la maquette a échoué. +depends_on: + - maquette +runs_on: [success, failure] + +steps: + + - name: security-check + image: alpine:3.20 + commands: + - | + if [ -f .env ]; then + echo "ERREUR: .env ne doit pas etre commite dans le depot" + exit 1 + fi + - 'grep -q "^\.env$" .gitignore || (echo "ERREUR: .env manquant dans .gitignore" && exit 1)' + - echo "Security check OK" + + # NOTE: volumes + pas de from_secret : compatible + - name: build-app + image: docker:27-cli + depends_on: + - security-check + volumes: + - /var/run/docker.sock:/var/run/docker.sock + commands: + - BUILD=$(echo "$CI_COMMIT_SHA" | cut -c1-8) + - docker build -t libredecision-app:latest -f docker/app.Dockerfile --target production --build-arg LD_BUILD=$BUILD . + - echo "Image app construite ($BUILD)" + + # NOTE: volumes + pas de from_secret : compatible + - name: sbom-generate + image: alpine:3.20 + depends_on: + - build-app + volumes: + - /var/run/docker.sock:/var/run/docker.sock + commands: + - apk add --no-cache curl + - curl -sSfL https://raw.githubusercontent.com/anchore/syft/main/install.sh | sh -s -- -b /usr/local/bin latest + - mkdir -p .reports + # spec 1.5 épinglée : syft « latest » émet plus récent que ce que le trivy du runner décode + - syft libredecision-app:latest -o cyclonedx-json@1.5 --file .reports/sbom-app.cyclonedx.json + - echo "SBOM genere" + + # NOTE: volumes + pas de from_secret : compatible + - name: sbom-scan + image: aquasec/trivy:latest + depends_on: + - sbom-generate + volumes: + - /home/syoul/trivy-cache:/root/.cache/trivy + commands: + - trivy sbom --format json --output .reports/trivy-app.json .reports/sbom-app.cyclonedx.json + - echo "Scan CVE termine" + + # NOTE: from_secret + pas de volumes : compatible + # failure: ignore — télémétrie, ne bloque pas le déploiement (cf. maquette.yml). + - name: sbom-publish + image: alpine/curl:latest + failure: ignore + depends_on: + - sbom-scan + environment: + DTRACK_TOKEN: + from_secret: dependency_track_token + DTRACK_DOMAIN: + from_secret: dtrack_domain + commands: + - | + VERSION=$(date +%Y-%m-%d)-$(echo "$CI_COMMIT_SHA" | cut -c1-8) + HTTP=$(curl -s -o /tmp/dtrack-resp.txt -w "%{http_code}" -X POST "https://$DTRACK_DOMAIN/api/v1/bom" \ + -H "X-Api-Key: $DTRACK_TOKEN" \ + -F "autoCreate=true" \ + -F "projectName=libredecision-app" \ + -F "projectVersion=$VERSION" \ + -F "bom=@.reports/sbom-app.cyclonedx.json") + echo "HTTP $HTTP (app) : $(cat /tmp/dtrack-resp.txt)" + [ "$HTTP" -ge 200 ] && [ "$HTTP" -lt 300 ] || exit 1 + + # NOTE: volumes + pas de from_secret : compatible + # Fabio route via les labels SERVICE_* de docker/app-compose.yml. + - name: deploy + image: docker:27-cli + depends_on: + - sbom-publish + volumes: + - /var/run/docker.sock:/var/run/docker.sock + - /opt/libredecision-app:/opt/libredecision-app + commands: + - DOMAIN=decision.librodrome.org + - echo "APP_DOMAIN=$DOMAIN" > /opt/libredecision-app/.env + - cp docker/app-compose.yml /opt/libredecision-app/docker-compose.yml + # Tout conteneur qui revendique encore la racine (maquette pas encore déplacée, + # ou en échec) est arrêté : la racine est au produit, et fabio partagerait le trafic. + - | + for TAG in "urlprefix-$DOMAIN/*" "urlprefix-$DOMAIN/api/*"; do + for C in $(docker ps -q --filter "label=SERVICE_3000_TAGS=$TAG") $(docker ps -q --filter "label=SERVICE_8002_TAGS=$TAG"); do + NAME=$(docker inspect --format '{{.Name}}' "$C") + [ "$NAME" = "/lpk-libredecision-app" ] && continue + echo "Arret de $NAME (revendique $TAG)" + docker stop "$C" + done + done + # Arrêter avant le challenge ACME : la route hôte /* masquerait le webroot de sonic-acme-1. + - cd /opt/libredecision-app && docker compose stop + - | + ACME_EXIT=0 + docker exec sonic-acme-1 /app/acme.sh \ + --home /etc/acme.sh \ + --issue -d "$DOMAIN" \ + --webroot /usr/share/nginx/html \ + --server letsencrypt \ + --accountemail support+acme@asycn.io || ACME_EXIT=$? + if [ "$ACME_EXIT" -eq 0 ] || [ "$ACME_EXIT" -eq 2 ]; then + docker exec sonic-acme-1 cp /etc/acme.sh/$DOMAIN/fullchain.cer /host/certs/$DOMAIN-cert.pem + docker exec sonic-acme-1 cp /etc/acme.sh/$DOMAIN/$DOMAIN.key /host/certs/$DOMAIN-key.pem + echo "TLS OK (acme exit $ACME_EXIT)" + elif docker exec sonic-acme-1 test -f "/host/certs/$DOMAIN-cert.pem"; then + echo "AVERTISSEMENT: emission ACME en echec (exit $ACME_EXIT) - certificat existant conserve" + else + echo "ERREUR: acme.sh a echoue (exit $ACME_EXIT) et aucun certificat existant pour $DOMAIN" + exit 1 + fi + # Image construite localement dans la pipeline : pas de docker compose pull + - cd /opt/libredecision-app && docker compose up -d --remove-orphans + - cd /opt/libredecision-app && docker compose ps + + - name: test-deploy + image: docker:27-cli + depends_on: + - deploy + volumes: + - /var/run/docker.sock:/var/run/docker.sock + commands: + - | + STATUS=$(docker inspect --format '{{.State.Status}}' lpk-libredecision-app 2>/dev/null || echo "absent") + echo "lpk-libredecision-app : $STATUS" + [ "$STATUS" = "running" ] || { docker logs --tail 50 lpk-libredecision-app 2>&1; echo "FAIL: non running"; exit 1; } + echo "PASS: conteneur running" + + # Le produit répond, et c'est bien ce commit qui répond. + - name: healthcheck + image: alpine:3.20 + depends_on: + - test-deploy + commands: + - apk add --no-cache --quiet curl + - | + BUILD=$(echo "$CI_COMMIT_SHA" | cut -c1-8) + TARGET="https://decision.librodrome.org/api/health" + MAX=60 + i=0 + until [ $i -ge $MAX ]; do + BODY=$(curl -sS -m 10 "$TARGET" 2>/dev/null || true) + echo "Tentative $((i+1))/$MAX - $BODY" + case "$BODY" in + *'"ok":true'*"\"build\":\"$BUILD\""*|*"\"build\":\"$BUILD\""*'"ok":true'*) + echo "PASS: $BUILD repond sur $TARGET"; exit 0 ;; + esac + i=$((i+1)) + sleep 10 + done + echo "FAIL: $BUILD ne repond pas apres 10 minutes" + exit 1 + + - name: notify-failure + image: alpine:3.20 + commands: + - 'echo "ECHEC pipeline app #$CI_PIPELINE_NUMBER sur $CI_COMMIT_BRANCH ($CI_COMMIT_SHA)"' + when: + - status: failure diff --git a/.woodpecker.yml b/.woodpecker/maquette.yml similarity index 91% rename from .woodpecker.yml rename to .woodpecker/maquette.yml index 6d2b3b5..85ca973 100644 --- a/.woodpecker.yml +++ b/.woodpecker/maquette.yml @@ -1,3 +1,8 @@ +# La maquette Nuxt v2 (FastAPI + Postgres + IPFS), conservée telle quelle pour +# présenter le projet. Depuis la v3 elle vit sur maquette.decision.librodrome.org ; +# le produit (.woodpecker/app.yml) prend la racine decision.librodrome.org et +# attend la fin de ce workflow, qui libère la racine. +# Le nom de projet compose reste lpk-decision-main : mêmes volumes, aucune donnée perdue. when: - branch: main event: push @@ -18,7 +23,7 @@ steps: - name: test-backend image: python:3.11-slim commands: - - cd backend + - cd maquette/backend - pip install --no-cache-dir -r requirements.txt - pytest app/tests/ -v --tb=short @@ -27,7 +32,7 @@ steps: commands: # git requis : dépendance @yvv/nuxt-base en git+https (node:20-slim n'a pas git) - apt-get update && apt-get install -y --no-install-recommends git ca-certificates - - cd frontend + - cd maquette/frontend - npm ci - npm run build @@ -39,7 +44,7 @@ steps: volumes: - /var/run/docker.sock:/var/run/docker.sock commands: - - docker build -t libredecision-backend:latest -f docker/backend.Dockerfile --target production . + - docker build -t libredecision-backend:latest -f maquette/docker/backend.Dockerfile --target production maquette - echo "Image backend construite" # NOTE: volumes + pas de from_secret : compatible @@ -50,7 +55,7 @@ steps: volumes: - /var/run/docker.sock:/var/run/docker.sock commands: - - docker build -t libredecision-frontend:latest -f docker/frontend.Dockerfile --target production . + - docker build -t libredecision-frontend:latest -f maquette/docker/frontend.Dockerfile --target production maquette - echo "Image frontend construite" # NOTE: volumes + pas de from_secret : compatible @@ -116,14 +121,12 @@ steps: depends_on: - sbom-publish environment: - APP_DOMAIN: - from_secret: app_domain POSTGRES_PASSWORD: from_secret: postgres_password SECRET_KEY: from_secret: secret_key commands: - - echo "APP_DOMAIN=$APP_DOMAIN" > .env.deploy + - echo "APP_DOMAIN=maquette.decision.librodrome.org" > .env.deploy - echo "POSTGRES_PASSWORD=$POSTGRES_PASSWORD" >> .env.deploy - echo "SECRET_KEY=$SECRET_KEY" >> .env.deploy - OWNER=$(echo "$CI_REPO_OWNER" | tr 'A-Z' 'a-z') && REPO=$(echo "$CI_REPO_NAME" | tr 'A-Z' 'a-z') && BRANCH=$(echo "$CI_COMMIT_BRANCH" | tr 'A-Z/' 'a-z-') && echo "COMPOSE_PROJECT_NAME=$OWNER-$REPO-$BRANCH" >> .env.deploy @@ -158,7 +161,7 @@ steps: commands: - cp .env.deploy /opt/libredecision/.env - chmod 600 /opt/libredecision/.env - - cp docker/docker-compose.yml /opt/libredecision/docker-compose.yml + - cp maquette/docker/docker-compose.yml /opt/libredecision/docker-compose.yml # Arreter avant le challenge ACME : libere le webroot pour sonic-acme-1 # L'org forge a ete renommee (EHV -> LPK) : le nom de projet compose a change, # on arrete donc aussi l'ancienne stack (ehv-decision-main-*) pour eviter la diff --git a/CLAUDE.md b/CLAUDE.md index f0a41b2..4a806e0 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -1,100 +1,100 @@ # libreDecision -Boîte à outils de la première démocratie — décider ensemble, du trio à la population. -v2 local-first : 6 routes de décision, 5 modalités, documents sous vote permanent, mandats, -observatoire. Marque blanche par construction (seeds Ğ1 + Atelier du Canal). -**Référence produit : `docs/dev/BLUEPRINT-V2.md`** — toute décision de comportement s'y adosse. +Rendre possible la décision collective — en fonction du contexte, processus et modalités, +boîte à outils. **v3 : Rust + Svelte**, un chantier à la fois, chacun livré en production avant +le suivant (`docs/chantiers/README.md`). +**Référence du chantier en cours : `docs/chantiers/acte-certification/CHANTIER.md`** — toute +décision de comportement s'y adosse ; les décisions durables dans `docs/adr/`. +La maquette v2 (Nuxt + FastAPI) vit dans `maquette/`, **intacte** (Yvv la présente) — ne pas y +développer. ## Protocole de début de session -1. `git pull --rebase origin main` -2. Si l'objectif de la session n'est pas précisé, le demander +1. `git pull --rebase origin main` (puis la branche du chantier : `chantier/acte-certification`) +2. Lire `HANDOFF.md` (où on en est, ce qui attend Yvv) +3. Si l'objectif de la session n'est pas précisé, le demander ## Stack -- **Frontend** : Nuxt 4 (Vue 3, TypeScript, SPA — SSR off) + Nuxt UI v3 + Pinia + UnoCSS ; - package manager : npm ; port **3002** strict -- **Local-first** : IndexedDB (idb-keyval) via `app/data/persistence.ts` — une clé - `ld2:` par collectif, export/import de bundles JSON, seeds par le même chemin -- **Backend v1** (FastAPI + SQLAlchemy async, port 8002) : conservé pour la **future synchro** - — non requis pour le dev frontend ; `runtimeConfig.public.apiBase` gardé dans nuxt.config - en prévision -- Déploiement : Docker multi-stage + Traefik ; CI Woodpecker +- **Serveur** : Rust edition 2024, toolchain **1.99** épinglée (`rust-toolchain.toml`) ; + rustup installé sans PATH → `export PATH=$HOME/.cargo/bin:$PATH`. axum 0.8, sqlx 0.8 + (SQLite, requêtes runtime + `migrate!`), tokio, reqwest rustls. +- **Interface** : SvelteKit 2 / Svelte 5 (runes), adapter-static SPA, vite 7, vitest, + Playwright ; npm. Polices Literata (textes) + Nunito (interface), fontsource. +- **Ports** : dev interface **3002** (strict, proxy `/api` → 8002) · serveur **8002** · + serveur jetable des e2e **8092**. +- **Déploiement** : une image (`docker/app.Dockerfile`), Woodpecker `ci.open.us.org` dépôt + LPK/decision → sonic (fabio). `.woodpecker/app.yml` = le produit sur + **decision.librodrome.org** ; `.woodpecker/maquette.yml` = la maquette sur + **maquette.decision.librodrome.org** (le produit attend la maquette, qui libère la racine). + Push sur `prod` (`gitea@git.open.us.org:EHV/decision.git`) branche `main` = déploiement. ## Structure ``` -frontend/ - app/ - engine/ # moteurs PURS (zéro dépendance Nuxt) : threshold (+electionResult), - # nuanced, modeParams, parametric (+crystallize), settings, state (canTransition), - # triage, impact — import UNIQUEMENT via le barrel ~/engine - data/ - persistence.ts # IndexedDB local-first - templates.ts # 7 gabarits de collectif (invariant : protocole Consentement partout) - seeds/ # duniter-g1.bundle.json (324 Ko) + atelier-du-canal.bundle.json - stores/ - collective.ts # le tenant : collectif actif, état complet, cycle de vie des bundles - decisions.ts # façade d'actions sans état propre (LWW updatedAt, persist debounced) - composables/ # useMood (useLibreMood), useFeed (le Fil = sélecteur pur), useSearch - components/ # par domaine : chemin/ decisions/ votes/ texts/ mandates/ feed/ - # onboarding/ common/ (primitives Ld*) - pages/ # 14 routes : / · decider · decisions (+[id], vote, observatoire) · - # textes (+[slug], formules) · mandats (+[id], nouveau) · - # creer (layout bare) · donnees - lexicon.ts # SOURCE UNIQUE des libellés FR + FORBIDDEN_UI_TERMS - types/domain.ts # le noyau du modèle - tests/ # 342 tests vitest : engine/, stores/, seeds/ (non-perte), lexicon.spec.ts -backend/ # v1 FastAPI conservé pour la synchro future — ne pas développer dessus - scripts/export_seed_bundle.py # extrait seed.py → duniter-g1.bundle.json (source vivante = bundle) -docker/ # compose + Dockerfiles -docs/dev/BLUEPRINT-V2.md # la référence produit v2 -public/hexagram-tsing*.svg # sceau 井 (#48 Tsing) +crates/ + ld-engine/ # PUR (zéro I/O) : threshold (formule inertielle, presets), edit (coquille vs fond, + # diff), assist (découpage, similarité, longueur), compose (adoption, aperçus) + trustin-access/ # règles d'accès de trustinWallet : sees / opens / manages_access, groupes par défaut + ld-server/ + migrations/ # schéma SQLite — additif seulement + src/ # lib.rs (routes, csrf, run) · config.rs (LD_*) · auth.rs (connect OAuth2, invitations, + # dev, bootstrap) · docs.rs (lecture, aperçus) · proposals.rs (écrire, voter, signaler, + # traduire) · adoption.rs (balayage, verrou) · publish.rs (file → git) · wot.rs (W + # sur la chaîne) · seed.rs · web.rs (sert web/build) + tests/api.rs # scénarios bout en bout +seeds/ # acte-engagement-certification.json (trilingue, 33 articles, I1/E1/E2 en vigueur) +web/src/ + lib/i18n/ # fr.ts SOURCE (types Key) · en.ts · es.ts — parité testée + lib/components/ # Sidebar, DocPanel, VoteView, ProposeView, UnitBlock, DeepList, Seal, Stamp… + routes/ # / · connexion · invitation · groupes · g/[group] · g/[group]/docs/[doc] (lecteur, + # panneau dans l'URL ?onglet=&article=) · acces (trustinWallet) · decisions/mandats/ + # protocoles (à venir) +web/e2e/ # Playwright C1–C10 + M1 ; serve.sh = serveur jetable /tmp/ld-e2e sur :8092 +docker/ # app.Dockerfile · app-compose.yml (sonic) +.woodpecker/ # app.yml · maquette.yml +docs/ # chantiers/ · adr/ · dev/BLUEPRINT-V2.md (doctrine de la maquette) · recherche/ +maquette/ # v2 intacte (frontend Nuxt, backend FastAPI, docker, docs-v1) ``` -## Données runtime +## Données runtime — jamais écrasées -- **postgres-data** / **ipfs-data** : volumes Docker du backend v1 — jamais écrasés -- `.env` racine : secrets backend — jamais commité +- **Production** : volume `lpk-libredecision-app_data` (`/data` : `libredecision.db`, `textes/` + = clone du dépôt publié, `keys/` = clé de publication, `bootstrap-admin.txt`). Jamais + `down -v`, jamais recréé. +- **Maquette** : volumes `lpk-decision-main_postgres-data` / `_ipfs-data` (inchangés). +- **Local** : `data/` (gitignoré) — le supprimer repart du seed. Les e2e utilisent `/tmp/ld-e2e`. +- Un document seedé n'est **jamais** réinstancié : dès qu'il existe en base, c'est de la donnée. -## Commandes +## Commandes — la boucle (tout vert = fait) ```bash -# Frontend (tout le dev v2) -cd frontend && npm run dev # :3002 -npm test # vitest — 342 tests, référence à maintenir -npm run build # zéro erreur exigé avant commit +export PATH=$HOME/.cargo/bin:$PATH +cargo fmt --all --check && cargo clippy --workspace --all-targets -- -D warnings && cargo test --workspace +cd web && npm run check && npm test && npm run build && npm run e2e # e2e : 11 contrôles -# Seeds : le bundle est la source vivante ; seed.py (backend) est remplacé par -cd backend && .venv/bin/python scripts/export_seed_bundle.py # régénère duniter-g1.bundle.json -# → test de non-perte bloquant : tests/seeds/duniter-g1.spec.ts - -# Docker -docker compose -f docker/docker-compose.yml up +LD_DEV_LOGIN=1 cargo run -p ld-server # :8002, sert web/build +cd web && npm run dev # :3002 à chaud +docker build -f docker/app.Dockerfile --target production . # rejoue toute la boucle ``` ## Conventions / pièges -- **UI français, code anglais** (variables, commentaires, docstrings) -- **lexicon.ts d'abord** : aucun libellé UI en dur si le lexicon peut le porter ; ton = tutoyer - la personne, sujet grammatical = la personne ou le collectif, jamais la formule ni le système -- **Anti-lexique** (`tests/lexicon.spec.ts`) : `FORBIDDEN_UI_TERMS` scanné sur lexicon + - templates `.vue` (insensible casse/accents) — « triage », « verdict », « déléguer »… restent - des identifiants de code, jamais des textes affichés -- **Invariants doctrinaux** : jamais de score sur une personne (attributs jamais affichés à - autrui, re-votes visibles du seul auteur) ; jamais de contenu adopté sans geste humain - (cristallisation, clôture de dossier, départage = gestes datés) ; repli universel → consent ; - dérogation asymétrique (alourdir libre, alléger motivé) -- **Budget 400 lignes max par page** (`app/pages/`) -- **Moteurs purs** : engine/ sans Nuxt/Pinia/DOM, import via `~/engine` seulement ; stores en - imports explicites (testables sous vitest nu) ; toute mutation d'état passe par - `canTransition(decision, to, ctx)` -- **Formule inertie** : `R = C + B^W + (M + (1−M)(1−(T/W)^G))·max(0, T−C)` — - `frontend/app/engine/threshold.ts` ; l'Atelier (`/textes/formules`) est la seule maison des - lettres W/T/M/B/G/C et de KaTeX -- **Mood system** : `useLibreMood()` (`composables/useMood.ts`) — **jamais** de `:global()` - dans `