Compare commits

2 Commits
Author SHA1 Message Date
YvvandClaude Sonnet 4.6 f87cbc0f2f Typo Plus Jakarta Sans + renommage libreDecision + mode démo prod + seed mandats
- Fonte : Nunito → Plus Jakarta Sans (moderne, ronde sans être toy)
- Logo : ğ(Decision) → libreDecision (libre italic/muted + Decision bold)
- Footer et watermark DocumentPreview mis à jour
- Mode démo : DEMO_MODE flag dans config.py + auth.py (profils rapides en prod)
- docker-compose : ENVIRONMENT=production explicite + DEMO_MODE=true par défaut
- Seed : +décision Licence G1 v0.4.0, +3 mandats (ComTech, Admin Forgerons, Modération)
  runner 7→10 étapes, import Mandate/MandateStep ajouté

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-24 01:22:36 +01:00
YvvandClaude Sonnet 4.6 6fe0b41e7f Menu : Boîte à outils en premier + sidebar collapsible icônes
- Boîte à outils (/tools) en 1ère position dans la nav (desktop + mobile)
- Sidebar desktop repliable (toggle pictos-only, 14rem → 3.75rem)
  - labels masqués par transition CSS fluide (max-width + opacity)
  - état persisté en localStorage (libred-sidebar-collapsed)
- Page document : toggle Vue structurée / Aperçu document
  - sous-toggle En vigueur / Selon les votes
  - composant DocumentPreview (rendu PDF-like)
    - filigrane discret, items ordonnés par sort_order
    - mode projection : proposed_text substitu + encadrement orange
    - footer horodaté, print-friendly

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-17 04:21:21 +01:00
602 changed files with 19462 additions and 116511 deletions
-19
View File
@@ -1,19 +0,0 @@
# 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
+43 -28
View File
@@ -1,34 +1,49 @@
# 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é.
# PostgreSQL
POSTGRES_DB=glibredecision
POSTGRES_USER=glibredecision
POSTGRES_PASSWORD=change-me-in-production
DATABASE_URL=postgresql+asyncpg://glibredecision:change-me-in-production@localhost:5432/glibredecision
# É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
# Backend
SECRET_KEY=change-me-in-production-with-a-real-secret-key
DEBUG=true
# 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=
# Environment: development, staging, production
ENVIRONMENT=development
# 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)
# Logging: DEBUG, INFO, WARNING, ERROR
LOG_LEVEL=INFO
# Publication des textes en vigueur (dépôt git lu par les applis clientes)
# LD_PUBLISH_REMOTE=gitea@git.open.us.org:yvv/decision-depot-textes-reference.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
# CORS
CORS_ORIGINS=["http://localhost:3002"]
# 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
# Database connection pool
DATABASE_POOL_SIZE=20
DATABASE_MAX_OVERFLOW=10
RUST_LOG=info,sqlx=warn
# 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=glibredecision.org
-13
View File
@@ -25,7 +25,6 @@ Thumbs.db
# Database
*.db
*.db.bak*
*.sqlite
# Docker volumes
@@ -41,15 +40,3 @@ htmlcov/
# IPFS
ipfs-data/
docs/*.pdf
# v3 — Rust + Svelte
/target/
/data/
web/build/
web/.svelte-kit/
web/test-results/
web/playwright-report/
*.db-wal
*.db-shm
web/node_modules/
+75
View File
@@ -0,0 +1,75 @@
when:
branch: main
event: push
steps:
test-backend:
image: python:3.11-slim
commands:
- cd backend
- pip install --no-cache-dir -r requirements.txt
- pytest app/tests/ -v --tb=short
test-frontend:
image: node:20-slim
commands:
- cd frontend
- npm ci
- npm run build
docker-backend:
image: woodpeckerci/plugin-docker-buildx
depends_on:
- test-backend
settings:
repo: ${CI_FORGE_URL}/${CI_REPO}
dockerfile: docker/backend.Dockerfile
context: .
tag:
- latest
- ${CI_COMMIT_SHA:0:8}
target: production
registry:
from_secret: docker_registry
username:
from_secret: docker_username
password:
from_secret: docker_password
docker-frontend:
image: woodpeckerci/plugin-docker-buildx
depends_on:
- test-frontend
settings:
repo: ${CI_FORGE_URL}/${CI_REPO}
dockerfile: docker/frontend.Dockerfile
context: .
tag:
- latest
- ${CI_COMMIT_SHA:0:8}
target: production
registry:
from_secret: docker_registry
username:
from_secret: docker_username
password:
from_secret: docker_password
deploy:
image: appleboy/drone-ssh
depends_on:
- docker-backend
- docker-frontend
settings:
host:
from_secret: deploy_host
username:
from_secret: deploy_username
key:
from_secret: deploy_key
port: 22
script:
- cd /opt/libredecision
- docker compose -f docker/docker-compose.yml pull
- docker compose -f docker/docker-compose.yml up -d --remove-orphans
- docker image prune -f
-250
View File
@@ -1,250 +0,0 @@
# 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
# Le produit passe en premier : tant que son image n'est pas construite et testée, la
# maquette continue de servir la racine. La maquette (maquette.yml) attend ce workflow
# et ne part sur maquette.decision.librodrome.org qu'après un déploiement réussi.
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
# Version épinglée : ce conteneur a le socket Docker, il n'exécute pas « main » de syft.
- curl -sSfL https://raw.githubusercontent.com/anchore/syft/v1.55.0/install.sh | sh -s -- -b /usr/local/bin v1.55.0
- 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
# La version en service (celle qui tourne, validée par son propre healthcheck) est
# gardée sous « previous » : « retablir » la relance si ce déploiement échoue.
# Un témoin resté d'un pipeline annulé : ce qui tourne n'a pas été validé, on garde l'ancien « previous ».
- |
IMG=$(docker inspect --format '{{if .State.Running}}{{.Image}}{{end}}' lpk-libredecision-app 2>/dev/null || true)
if [ -n "$IMG" ] && [ ! -e /opt/libredecision-app/deploying ]; then
docker tag "$IMG" libredecision-app:previous && echo "Version en service gardee ($IMG)"
fi
# Témoin : à partir d'ici, un échec appelle « retablir ». Le healthcheck réussi l'efface.
- touch /opt/libredecision-app/deploying
- echo "APP_DOMAIN=$DOMAIN" > /opt/libredecision-app/.env
- cp docker/app-compose.yml /opt/libredecision-app/docker-compose.yml
# Tout conteneur, même arrêté, qui revendique encore la racine (la maquette avant son
# déplacement, d'anciennes stacks ehv-decision-main-*) est arrêté et sort de
# « restart: always » : sinon un redémarrage de Docker le ramènerait, et fabio lui
# donnerait /api/* (route la plus longue). Rien n'est supprimé ; la maquette est
# recréée par son propre workflow, sous son nouveau domaine.
# Ceux qui tournaient sont ajoutés à /opt/libredecision-app/retires (jamais vidé ici :
# un pipeline annulé ne perd pas sa liste) ; si la suite échoue, « retablir » les relance.
- |
RETIRES=/opt/libredecision-app/retires
touch "$RETIRES"
for TAG in "urlprefix-$DOMAIN/*" "urlprefix-$DOMAIN/api/*"; do
for C in $(docker ps -aq --filter "label=SERVICE_3000_TAGS=$TAG") $(docker ps -aq --filter "label=SERVICE_8002_TAGS=$TAG"); do
NAME=$(docker inspect --format '{{.Name}}' "$C")
[ "$NAME" = "/lpk-libredecision-app" ] && continue
echo "Retrait de $NAME de la racine (revendique $TAG)"
if [ "$(docker inspect --format '{{.State.Running}}' "$C")" = "true" ]; then
echo "$C" >> "$RETIRES"
fi
docker update --restart=no "$C" >/dev/null
docker stop "$C" >/dev/null
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 --force-recreate
- 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.
# NOTE: volumes + pas de from_secret : compatible
- name: healthcheck
image: alpine:3.20
depends_on:
- test-deploy
volumes:
- /opt/libredecision-app:/opt/libredecision-app
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"
: > /opt/libredecision-app/retires
rm -f /opt/libredecision-app/deploying
exit 0 ;;
esac
i=$((i+1))
sleep 10
done
echo "FAIL: $BUILD ne repond pas apres 10 minutes"
exit 1
# Échec avant le déploiement (construction, tests, SBOM) : rien n'a bougé, rien à faire.
# Échec après, au premier déploiement : le produit s'arrête et ce qu'il avait retiré de la
# racine repart (restart: always rendu). Échec après, plus tard : la version précédente du
# produit (image « previous ») est relancée.
# NOTE: volumes + pas de from_secret : compatible
- name: retablir
image: docker:27-cli
# En graphe (depends_on), une étape sans dépendance démarrerait au début : celle-ci
# attend la fin du healthcheck (fait, échoué ou sauté).
depends_on:
- healthcheck
volumes:
- /var/run/docker.sock:/var/run/docker.sock
- /opt/libredecision-app:/opt/libredecision-app
commands:
- |
D=/opt/libredecision-app
if [ ! -e "$D/deploying" ]; then echo "Echec avant le deploiement : rien a retablir"; exit 0; fi
if [ -s "$D/retires" ]; then
# Hors de « restart: always » aussi, sinon un redémarrage de Docker le ramènerait
# à côté de ce qui revient ; le prochain déploiement le recrée (--force-recreate).
docker update --restart=no lpk-libredecision-app >/dev/null 2>&1 || true
docker stop lpk-libredecision-app >/dev/null 2>&1 || true
for C in $(cat "$D/retires"); do
echo "Retour de $(docker inspect --format '{{.Name}}' "$C" 2>/dev/null || echo "$C")"
docker update --restart=always "$C" >/dev/null && docker start "$C" >/dev/null || echo " echec pour $C"
done
: > "$D/retires"
elif docker image inspect libredecision-app:previous >/dev/null 2>&1; then
echo "Retour a la version precedente du produit"
docker tag libredecision-app:previous libredecision-app:latest
cd "$D" && docker compose up -d --force-recreate
else
echo "Aucune version precedente a relancer"
fi
rm -f "$D/deploying"
when:
- status: failure
- name: notify-failure
image: alpine:3.20
depends_on:
- retablir
commands:
- 'echo "ECHEC pipeline app #$CI_PIPELINE_NUMBER sur $CI_COMMIT_BRANCH ($CI_COMMIT_SHA)"'
when:
- status: failure
-261
View File
@@ -1,261 +0,0 @@
# 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.
# Ce workflow attend le produit : si le produit échoue, la maquette ne bouge pas.
# Le nom de projet compose reste lpk-decision-main : mêmes volumes, aucune donnée perdue.
when:
- branch: main
event: push
depends_on:
- app
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"
- name: test-backend
image: python:3.11-slim
commands:
- cd maquette/backend
- pip install --no-cache-dir -r requirements.txt
- pytest app/tests/ -v --tb=short
- name: test-frontend
image: node:20-slim
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 maquette/frontend
- npm ci
- npm run build
# NOTE: volumes + pas de from_secret : compatible
- name: build-backend
image: docker:27-cli
depends_on:
- test-backend
volumes:
- /var/run/docker.sock:/var/run/docker.sock
commands:
- 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
- name: build-frontend
image: docker:27-cli
depends_on:
- test-frontend
volumes:
- /var/run/docker.sock:/var/run/docker.sock
commands:
- 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
- name: sbom-generate
image: alpine:3.20
depends_on:
- build-backend
- build-frontend
volumes:
- /var/run/docker.sock:/var/run/docker.sock
commands:
- apk add --no-cache curl
# Version épinglée : ce conteneur a le socket Docker, il n'exécute pas « main » de syft.
- curl -sSfL https://raw.githubusercontent.com/anchore/syft/v1.55.0/install.sh | sh -s -- -b /usr/local/bin v1.55.0
- 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-backend:latest -o cyclonedx-json@1.5 --file .reports/sbom-backend.cyclonedx.json
- syft libredecision-frontend:latest -o cyclonedx-json@1.5 --file .reports/sbom-frontend.cyclonedx.json
- echo "SBOM generes"
# 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-backend.json .reports/sbom-backend.cyclonedx.json
- trivy sbom --format json --output .reports/trivy-frontend.json .reports/sbom-frontend.cyclonedx.json
- echo "Scan CVE termine"
# NOTE: from_secret + pas de volumes : compatible
# failure: ignore — la publication vers Dependency-Track est de la télémétrie :
# elle ne doit pas bloquer le déploiement quand DTrack est éteint ou le secret
# dtrack_domain absent (le scan trivy, lui, reste bloquant). Vu en panne le 12/08.
- 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)
for COMPONENT in backend frontend; do
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-$COMPONENT" \
-F "projectVersion=$VERSION" \
-F "bom=@.reports/sbom-$COMPONENT.cyclonedx.json")
echo "HTTP $HTTP ($COMPONENT) : $(cat /tmp/dtrack-resp.txt)"
[ "$HTTP" -ge 200 ] && [ "$HTTP" -lt 300 ] || exit 1
done
# NOTE: from_secret + pas de volumes : compatible
- name: write-env
image: alpine:3.20
depends_on:
- sbom-publish
environment:
POSTGRES_PASSWORD:
from_secret: postgres_password
SECRET_KEY:
from_secret: secret_key
commands:
- 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
- echo ".env.deploy cree ($(wc -c < .env.deploy) octets)"
- name: test-env
image: alpine:3.20
depends_on:
- write-env
commands:
- |
[ -f .env.deploy ] || { echo "FAIL: .env.deploy introuvable"; exit 1; }
echo "PASS: .env.deploy present"
- |
VAL=$(grep '^COMPOSE_PROJECT_NAME=' .env.deploy | cut -d= -f2)
[ -z "$VAL" ] && echo "FAIL: COMPOSE_PROJECT_NAME vide" && exit 1
echo "PASS: COMPOSE_PROJECT_NAME = $VAL"
- |
VAL=$(grep '^APP_DOMAIN=' .env.deploy | cut -d= -f2)
[ -z "$VAL" ] && echo "FAIL: APP_DOMAIN vide" && exit 1
echo "PASS: APP_DOMAIN = $VAL"
# NOTE: volumes + pas de from_secret : compatible
# Fabio/Traefik routing géré via labels du docker-compose.yml
- name: deploy
image: docker:27-cli
depends_on:
- test-env
volumes:
- /var/run/docker.sock:/var/run/docker.sock
- /opt/libredecision:/opt/libredecision
commands:
- cp .env.deploy /opt/libredecision/.env
- chmod 600 /opt/libredecision/.env
- 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
# double inscription fabio et le trafic partage v1/v2.
- docker ps -q --filter "name=decision-main" | xargs -r docker stop
- cd /opt/libredecision && docker compose stop
- |
DOMAIN=$(grep '^APP_DOMAIN=' /opt/libredecision/.env | cut -d= -f2)
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"
echo " Cause vue le 12/08 : challenge HTTP-01 invalide en IPv6 (AAAA present mais webroot"
echo " non servi en v6) - corriger frontal/DNS avant l'expiration du certificat."
else
echo "ERREUR: acme.sh a echoue (exit $ACME_EXIT) et aucun certificat existant pour $DOMAIN"
exit 1
fi
# Images construites localement dans la pipeline : pas de docker compose pull
- cd /opt/libredecision && docker compose up -d --remove-orphans
- cd /opt/libredecision && docker compose ps
# Vérification que les containers sont running
- name: test-deploy
image: docker:27-cli
depends_on:
- deploy
volumes:
- /var/run/docker.sock:/var/run/docker.sock
- /opt/libredecision:/opt/libredecision
commands:
- |
PROJECT=$(grep '^COMPOSE_PROJECT_NAME=' /opt/libredecision/.env | cut -d= -f2)
for SVC in backend frontend; do
STATUS=$(docker inspect --format '{{.State.Status}}' "$PROJECT-$SVC" 2>/dev/null || echo "absent")
echo "$PROJECT-$SVC : $STATUS"
[ "$STATUS" = "running" ] || { echo "FAIL: $PROJECT-$SVC non running"; exit 1; }
done
echo "PASS: tous les containers running"
- name: seed
image: docker:27-cli
depends_on:
- test-deploy
volumes:
- /var/run/docker.sock:/var/run/docker.sock
- /opt/libredecision:/opt/libredecision
commands:
- |
PROJECT=$(grep '^COMPOSE_PROJECT_NAME=' /opt/libredecision/.env | cut -d= -f2)
docker exec "$PROJECT-backend" python seed.py
echo "Seed terminée"
- name: healthcheck
image: alpine:3.20
depends_on:
- seed
commands:
- apk add --no-cache --quiet curl
- |
SITE=$(grep '^APP_DOMAIN=' .env.deploy | cut -d= -f2)
TARGET="https://$SITE"
echo "Healthcheck $TARGET..."
MAX=60
i=0
until [ $i -ge $MAX ]; do
CODE=$(curl -sSo /dev/null -w "%{http_code}" "$TARGET" 2>/dev/null)
echo "Tentative $((i+1))/$MAX - HTTP $CODE"
if [ "$CODE" = "200" ] || [ "$CODE" = "301" ] || [ "$CODE" = "302" ]; then
echo "PASS: app repond sur $TARGET"
exit 0
fi
i=$((i+1))
sleep 10
done
echo "FAIL: app ne repond pas apres 10 minutes"
exit 1
- name: notify-failure
image: alpine:3.20
commands:
- 'echo "ECHEC pipeline #$CI_PIPELINE_NUMBER sur $CI_COMMIT_BRANCH ($CI_COMMIT_SHA)"'
when:
- status: failure
+64 -149
View File
@@ -1,173 +1,88 @@
# libreDecision
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.
Boîte à outils de gouvernance collective pour la communauté Duniter/G1.
Documents modulaires sous vote permanent + protocoles de vote + mandats.
Architecture marque blanche — vocation à être intégré dans sweethomeCloud et librodrome.
## Protocole de début de session
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)
1. `git pull --rebase origin main`
2. Si des migrations DB sont attendues : `cd backend && alembic upgrade head`
3. Si l'objectif de la session n'est pas précisé, le demander
## Stack
- **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** (sa chaîne Ğ1 simulée prend un port local éphémère).
- **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 passe en premier et retire de la racine tout conteneur qui la revendique ; la maquette attend un déploiement réussi du produit).
Push sur `prod` (`gitea@git.open.us.org:EHV/decision.git`) branche `main` = déploiement.
- **Frontend** : Nuxt 4 (Vue 3, TypeScript) + Nuxt UI v3 + Pinia + UnoCSS ; package manager : npm
- **Backend** : Python FastAPI + SQLAlchemy 2.0 async + PostgreSQL asyncpg ; migrations Alembic
- **Auth** : Duniter V2 Ed25519 challenge-response (substrate-interface — stub en dev)
- **Sanctuaire** : IPFS kubo + hash on-chain (system.remark) — TODO sprint 2
- Déploiement : Docker multi-stage + Traefik (postgres + backend + frontend + ipfs) ; CI Woodpecker
## Structure
```
crates/
ld-engine/ # PUR (zéro I/O) : threshold (formule inertielle sur un quorum, judged_voters,
# yes_needed / no_needed), inertia (cinq crans, leurs quorums, bornes, médiane,
# référence, échelle), edit (coquille vs fond, diff), assist (découpage, similarité,
# longueur), compose (adoption, aperçus), ballot (bulletin scellé ld1:, le dernier
# fait foi, jugement d'une transaction), address (SS58, préfixe g1), objection
# (redit / contredit : la dernière arrivée retenue, les tours, l'échéance), correction
# (la coquille d'un autre : attendre, valider, refuser, l'échéance), order (l'ordre
# d'une section : qui en est, complété à la fin, places), pending (modules à voter),
# buffer (le tampon des votes, ADR 0012 : réconciliation, poids des états,
# SIMULATED_ADOPTS)
trustin-access/ # règles d'accès de trustinWallet : sees / opens / manages_access, vote_way (serveur ou
# chaîne), answers_typo, lifts_objection / reiterates_objection, reads_message,
# sets_quorum, casts_simulated (le vote simulé, chaîne fermée), groupes par défaut
# (TEST_GROUP)
ld-server/
migrations/ # schéma SQLite — additif seulement
src/ # lib.rs (routes, csrf, run) · config.rs (LD_*) · auth.rs (connect OAuth2, invitations,
# dev, bootstrap) · team.rs (liens d'équipe) · docs.rs (lecture, aperçus) · proposals.rs (écrire, voter,
# traduire) · objections.rs (faire, lever, réitérer, la version retenue) · typos.rs
# (les coquilles des autres) · messages.rs (la boîte « Messages ») · deadlines.rs
# (les échéances de 3 jours) · clock.rs (horloge injectable) · adoption.rs
# (balayage, verrou) · participation.rs (p̃, une fois par
# jour) · publish.rs (file → git) · wot.rs (W sur la chaîne) · seed.rs ·
# seed_amend.rs / inertia_amend.rs / seed_proposal_amend.rs (amendements gardés ;
# le dernier fait des Propositions seed, ADR 0011) · ballots.rs (vote groupé,
# bulletins de la chaîne Ğ1, le tampon : intention, preuve, réconciliation ; le vote
# simulé, « ton vote ») · simulation.rs (l'acte « supprime » de la fin de la simulation :
# `ld-server discard-simulation [--apply]`, ADR 0012) · indexer.rs (txComments)
# · modules.rs / order.rs / doc_title.rs (les modules, ADR 0009 : l'ordre d'une
# section, le titre du document) · module_amend.rs (ajout gardé) · quorum.rs (le quorum
# d'un article, PUT /units/{id}/quorum ; « un vote existe » : voted_units, ADR 0013) ·
# quorum_amend.rs (les quorums par cran, amendement gardé) · article_amend.rs (un
# article du seed ajouté à un document existant, gardé : E12, lot T4) · retire.rs
# (formule, inertie, ordonnancement sortis du document sans rien effacer :
# units.retired_at, ADR 0014) · web.rs (sert web/build)
tests/ # api.rs (scénarios bout en bout) · ballots.rs (vote par la chaîne, indexeur simulé) ·
# objections.rs · typos.rs (lot F, horloge avancée, migration 0008) · modules.rs (ordre,
# titre, ajout gardé) · seed_proposals.rs (Propositions seed, publication suspendue) ·
# quorum.rs (quorums par cran, la route, « un vote existe », l'amendement) ·
# fixtures/seed-production-2026-11-02/ (le seed que la production fait tourner :
# I1/E1/E2 en vigueur, pour rejouer les amendements sur une base comme elle) ·
# seed_quorum_texts.rs (lots T, T2 et T3 : F1.1, F1.2, N1.1 ; aucun chiffre daté, chaque
# phrase vraie pour tout W et tout quorum ; fixtures/seed-quorum-m6b.json et
# seed-quorum-t2.json, leurs textes depuis M6b et M6c, pour rejouer T2 et T3 sur une
# base comme la production ; depuis T4, sur le seed de M8) · amorce.rs (lot T4 : les
# textes d'Yvv, E12, le retrait ; après M6c et après M8, chaque ligne de chaque table
# gardée, le retour arrière) · fixtures/seed-m8/ (le seed de M8 : les derniers textes
# de formule, inertie, ordonnancement, référence du lot U3)
seeds/ # acte-engagement-certification.json (trilingue, 27 articles, 5 sections, aucun en
# vigueur ; I1/E1/E12/E2 Propositions seed au cran 2 (seed::SEED_PROPOSAL_LEVEL),
# inertie 3 ailleurs ; modules en vigueur sans vote : le titre à 4, l'ordre de chaque
# section à 2 ;
# quorums par cran : 12 · 12 · 50 · 50 · 50 Monnaie libre, 1 partout essai)
# upcoming/<groupe>.json : les « À venir » des outils d'un groupe (fr, en, es), lus au démarrage
web/src/
lib/i18n/ # fr.ts SOURCE (types Key) · en.ts · es.ts — parité testée
lib/components/ # Sidebar, DocPanel, VoteFold (le vote déplié sous l'article), BallotPanel (panier),
# ChainSign (signature, Ğecko), VersionDetails, ProposeView, UnitBlock, DeepList,
# OrderLine / Reorder (l'ordre d'une section : une ligne en fin de section, les poignées
# sur ses articles, sur place), QuorumSetting (le quorum de l'auteur), Crans (l'inertie dessinée),
# TitleBar / TitlePropose (le titre du document), OpeningInfo (« Info ouverture doc de réf. », lot U3), Seal…
lib/wallet/ # injected.ts (portefeuille injectedWeb3, polkadot-api chargé à la signature) · gecko.ts
lib/basket.ts # PUR : sections, tout pour / tout contre, versions en concurrence, versions retenues, modules
lib/objections.ts # où l'auteur répond à une objection · lib/diff.ts : le diff resserré d'une coquille
lib/order.ts # PUR : placer, monter / descendre, ce qui a bougé
lib/opening.ts # PUR : le chapitre d'ouverture, lu dans l'API (cran d'ouverture, Propositions d'amorce, quorums)
routes/ # / (accueil + choix du groupe) · connexion · invitation · equipe · g/[group] (piliers, en cours) ·
# g/[group]/docs · g/[group]/docs/[doc] (lecteur,
# vote déplié ?vote=, panneau sans onglets ?onglet=panier|proposer|details|reglages ;
# l'ancien onglet=propositions déplie le vote ; note : docs/chantiers/acte-certification/UX-lecteur.md)
# · g/[group]/decisions · g/[group]/mandats ·
# g/[group]/protocoles (à venir) · acces (trustinWallet) · messages (la boîte) ;
# /groupes, /decisions… redirigent
web/e2e/ # Playwright C1–C44 + M1, M2 ; serve.sh = serveur jetable /tmp/ld-e2e sur :8092 et
# chain-mock.mjs (nœud et indexeur Ğ1 simulés) ; faux portefeuille dans helpers.ts
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)
frontend/
app/
components/ # composants Vue
layouts/ # layouts Nuxt
pages/ # routing file-based (9 pages sprint 1)
composables/ # (1 composable sprint 1)
stores/ # 5 Pinia stores (auth, ...)
assets/css/
moods.css # système de palettes (.mood-* sur <html>)
utils/ # (2 utils sprint 1)
nuxt.config.ts # port 3002, host 0.0.0.0, apiBase via NUXT_PUBLIC_API_BASE
backend/
app/
routers/ # 8 routers : auth, communes, documents, protocols, votes, ...
services/ # 6 services
engine/ # 5 modules : formule inertie, critères Smith/TechComm, médiane
models/ # 14 tables SQLAlchemy
alembic/versions/ # migrations
tests/ # 186 tests (63 intégration TDD sprint 1)
seed.py # Engagement Certification (33 items) + Forgeron (51 items) + Runtime Upgrade
docker/
docker-compose.yml # postgres + backend + frontend + ipfs
backend.Dockerfile
frontend.Dockerfile
docs/content/ # 7 docs dev + 8 docs user
public/
hexagram-tsing.svg # sceau 井 (embossed)
hexagram-tsing-flat.svg
```
## Données runtime — jamais écrasées
## Données runtime
- **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.
- **postgres-data** : volume Docker PostgreSQL — jamais écrasé par les builds
- **ipfs-data** : volume Docker IPFS kubo
- `.env` à la racine : `POSTGRES_USER`, `POSTGRES_PASSWORD`, `POSTGRES_DB`, `SECRET_KEY`, `DOMAIN`, `DUNITER_RPC_URL`
## Commandes — la boucle (tout vert = fait)
## Commandes
```bash
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 : 46 contrôles (48 tests, C25 et C35 en deux)
# Backend
cd backend && . venv/bin/activate
uvicorn app.main:app --port 8002 --host 0.0.0.0 --reload
pytest tests/ -v
alembic upgrade head
python seed.py # reseed Engagement Certification + Forgeron + Runtime Upgrade
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
# Frontend
cd frontend && npm run dev # :3002
npm run build
# Docker
docker compose -f docker/docker-compose.yml up
```
## Conventions / pièges
- **UI français, code anglais** ; **trilingue** : aucune chaîne affichée en dur, tout passe par
`web/src/lib/i18n/fr.ts` puis en/es (test de parité + `FORBIDDEN_UI_TERMS`). Tutoyer ; le sujet
est la personne ou le groupe, jamais le système. Le français du texte voté fait foi.
- **Moteur pur** : une règle de vote / édition / composition s'écrit et se teste dans
`ld-engine` avant d'être câblée. Le serveur ne décide jamais l'accès seul : `trustin-access`.
- **Invariants** : les informations de fonctionnement (formule, inertie, ordonnancement) ne se
votent pas : hors du document, une unité retirée (`units.retired_at`) n'est ni lue, ni votée, ni
comptée, ni publiée, et rien n'est effacé (ADR 0014) ; au départ, aucun article n'est en
vigueur : I1, E1, E12 et E2 sont des Propositions
seed (ouvertes, mises en avant, au cran 2 ; seuls les modules sont des *seeds a minima*), et la
publication d'un document attend son premier texte en vigueur (ADR 0011) ; un quorum par cran
(12 · 12 · 50 · 50 · 50 dans Monnaie libre), que l'auteur d'un article change avant le premier
vote : sous le quorum, les votants qui manquent comptent comme « non », et une seule voix ne fait
jamais passer un texte (quorum ≥ 2), sauf dans le groupe d'essai (quorum 1 ; ADR 0013) ;
une coquille ne demande pas de vote, un changement de fond si ; la coquille d'un autre attend
son auteur (un administrateur pour le seed) ou 3 jours, et en vigueur seule une coquille validée
change le texte ; une objection retient la dernière arrivée jusqu'à sa levée (jamais un texte en
vigueur ; ADR 0010) : elle n'entre pas en vigueur, et on peut y retirer son vote ou passer à
« contre », jamais ajouter un « pour » (Yvv, 01/11/2026 : « Retirer ou "contre" ») ; l'inertie de l'inertie hérite du cran de l'article (cinq crans, ADR 0007) ;
le groupe d'essai ne publie jamais ; dans Monnaie libre, un vote est une transaction Ğ1 et le
dernier fait foi dans l'ordre de la chaîne (ADR 0008) — **jamais de transaction réelle en test**
(chaîne simulée ; un essai réel est un acte « paye » d'Yvv).
- **Écritures** : en-tête `x-requested-with: libredecision` obligatoire (CSRF) ; cookie
`ld_session`. Les tests d'API passent par `Client` (`tests/api.rs`).
- **Pièges vus** : `pkill -f target/debug/ld-server` tue son propre shell → `pkill -x ld-server` ;
un `from_secret` absent fait refuser tout le pipeline Woodpecker ; la route hôte `/*` de fabio
masque le webroot ACME → arrêter le service avant `acme.sh --issue` ; Playwright en locale
`fr-FR` (sinon l'interface part en anglais).
- **Design** : sans bordures, arrondis 16/12/20/14, quatre ambiances `pierre`, `calcaire`
(claires), `nuit`, `ardoise` (sombres) (`data-mood`, posé avant le premier rendu, clé `ld:mood`), logo
« Clé de voûte » (`Logo`, jamais incliné, `--cle` par ambiance), sceau hexagramme 井 #48 en SVG (`Seal` —
jamais le glyphe, qui s'affiche mal). Mouvement réduit respecté.
- **Domaine** : decision.librodrome.org ; dépôt des textes `git.open.us.org/yvv/decision-depot-textes-reference`
(provisoire : puis un dépôt pour l'ouverture, puis le dépôt officiel pour les textes en vigueur).
- **UI français, code anglais** (variables, commentaires, docstrings)
- **API** : préfixe `/api/v1/`, Pydantic v2 pour tous les schémas, async partout (SQLAlchemy + FastAPI)
- **Auth** : `get_current_admin` (24h), `get_current_citizen` (4h), `require_super_admin`
- **Formule inertie** : `Result = C + B^W + (M + (1-M) × (1 - (T/W)^G)) × max(0, T-C)` — voir `backend/app/engine/`
- **Mood system** : `useMood.ts` synchronise `colorMode.preference` avec la palette — **jamais** de `:global()` dans `<style scoped>` pour les styles mood-dépendants (causa le bug dark mode veil)
- **Sceau** `井` (#48 Tsing) : `.app-seal` dans `app.vue`, right-aligned ; SVGs dans `public/`
- **CSS drop-shadow()** safe pour effets emboss ; `<filter>` SVG inline cause des artefacts de rendu
- **Domaine** : libredecision.org (Woodpecker CI ; ancien dossier : Glibredecision)
- **Ed25519 verification** : stub en dev (substrate-interface), autoritaire en prod — ne pas bypasser sans test
+42 -53
View File
@@ -1,63 +1,52 @@
# Contribuer à libreDecision
On travaille **un chantier à la fois**, livré complet et en production avant le suivant
(`docs/chantiers/README.md`). Le chantier en cours a sa fiche :
`docs/chantiers/acte-certification/CHANTIER.md` — toute décision de comportement s'y adosse,
ou propose de l'amender, argumentée.
Première fois ? Commence par `docs/chantiers/acte-certification/ONBOARDING.md`.
## Les branches — travailler à deux
```
main = la production. Tout push sur prod/main déploie decision.librodrome.org.
chantier/acte-certification = la branche commune du binôme (Yvv · Chiara), toujours verte.
chiara/<sujet>, yvv/<sujet> = une branche courte par sujet, partie de la branche du chantier.
```
1. `git switch chantier/acte-certification && git pull --rebase origin chantier/acte-certification`
2. `git switch -c chiara/<sujet>` — un sujet, une branche, quelques commits.
3. Boucle verte (ci-dessous), puis push de la branche et demande de fusion vers
`chantier/acte-certification` sur la forge (`git.open.us.org/yvv/decision`).
4. Le chantier passe dans `main` quand la boucle est verte et les contrôles d'usage passés.
On ne pousse jamais directement sur `main`, et jamais de `--force` sur une branche partagée.
## La boucle — une tâche n'est faite que si tout est vert
## Environnement
```bash
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 # svelte-check, zéro avertissement
npm test # vitest : markdown, parité des catalogues i18n, anti-lexique
npm run build
npm run e2e # Playwright : C1–C12 (bureau) + M1 (mobile), serveur jetable sur :8092
```
# Backend (Python 3.11+)
cd backend
python -m venv .venv && source .venv/bin/activate
pip install -r requirements.txt
alembic upgrade head
uvicorn app.main:app --host 0.0.0.0 --port 8002 --reload
L'image Docker rejoue les mêmes portes : un contrôle rouge, pas d'image, pas de déploiement.
# Frontend (Node 20+)
cd frontend
npm install
npm run dev
```
## Conventions
- **Interface en français, code en anglais** (identifiants, commentaires, messages de log).
- **Trilingue dès le départ** : toute chaîne affichée vit dans `web/src/lib/i18n/fr.ts`
(la source), puis `en.ts` et `es.ts`. Le test de parité refuse une clé manquante ou un
paramètre `{x}` qui diffère. Le texte voté est le français ; les traductions sont éditoriales.
- **Ton** : tutoyer la personne ; le sujet est la personne ou le collectif, jamais le système.
Mots interdits à l'écran (`FORBIDDEN_UI_TERMS`) : « triage », « verdict », « déléguer »…
- **Moteur pur** : `crates/ld-engine` n'a ni I/O, ni base, ni réseau. Toute règle de vote,
d'édition ou de composition s'y écrit et s'y teste d'abord.
- **Accès** : le serveur ne décide jamais seul qui voit quoi — il demande à `trustin-access`.
- **Données runtime** (`data/`, volume `lpk-libredecision-app_data`) : jamais écrasées, jamais
réinitialisées ; une migration ajoute, elle ne réécrit pas.
- **Design** : sans bordures (profondeur par fond et ombre), arrondis 16 / 12 / 20 / 14 px,
deux ambiances (pierre, nuit), Literata pour les textes, Nunito pour l'interface.
- **Commits** : en français, une intention par commit, `git add` avec chemins explicites.
- **UI** : français — **Code** : anglais (variables, commentaires, docstrings)
- **CSS** : scoped, sans bordures (`border: none`), profondeur via `box-shadow`
- **Composants** : `pathPrefix: false` — noms courts, auto-import
- **API** : versionnée `/api/v1/`, Pydantic v2, async partout
- **Ports stricts** : frontend=3002, backend=8002 — jamais de fallback
## Signaler
## Architecture toolbox
**« Faire un retour »** (dans le menu de gauche, sur chaque page) envoie une note datée avec la
page, la langue et la taille de l'écran ; elles se lisent dans trustinWallet (`/acces`).
Pour un bug reproductible, une issue sur la forge avec les étapes.
Chaque section expose une `<SectionLayout>` avec :
- Contenu principal (slot `#default`)
- Boîte à outils sticky (slot `#toolbox`) — 30rem, flottante, zéro scroll
Composants toolbox :
- `ToolboxSection` : accordéon collapsible générique
- `ToolboxVignette` : carte compacte avec bullets toggleables
- `toolbox/ContextMapper` : recommandeur de méthode (4 questions → méthode optimale)
- `toolbox/SocioElection` : guide élection sociocratique + advice process
- `toolbox/WorkflowMilestones` : jalons de protocole (Ostrom)
## Tests
```bash
cd backend && pytest tests/ -v
```
186 tests, zéro dette technique acceptée depuis le sprint 1.
## Formule de vote inertiel
`R = C + B^W + (M + (1-M)·(1-(T/W)^G))·max(0, T-C)`
Voir `docs/content/dev/` pour la documentation complète.
Generated
-2752
View File
File diff suppressed because it is too large Load Diff
-23
View File
@@ -1,23 +0,0 @@
[workspace]
resolver = "3"
members = ["crates/ld-engine", "crates/trustin-access", "crates/ld-server"]
[workspace.package]
version = "0.3.0"
edition = "2024"
rust-version = "1.90"
repository = "https://git.open.us.org/LPK/decision"
publish = false
license = "AGPL-3.0-or-later"
[workspace.lints.rust]
unsafe_code = "forbid"
[workspace.lints.clippy]
all = { level = "warn", priority = -1 }
unwrap_used = "warn"
[profile.release]
lto = "thin"
codegen-units = 1
strip = true
-182
View File
@@ -1,182 +0,0 @@
# HANDOFF — libreDecision
> Dernière mise à jour : 2026-11-03 à 22 h 41, intégrateur libredecision-d4 (orch : dev-63, session
> racine /DEV, mandat « déploie » et « pousse » du 03/11 au 08/11). État vivant : le tableau
> `docs/chantiers/acte-certification/tableau.md`.
## Où on en est (03/11)
- **M6 en ligne** depuis le 03/11 à 07 h 03 (`94ddb17`, pipeline 37 ;
`actes/2026-11-03-m6-propositions-seed-quorum-tampon.md`) :
- l'accueil sous la voûte (lot H) ;
- les Propositions seed : I1, E1, E2 ouvertes, au cran 2, aucun article en vigueur (ADR 0011) ;
- le quorum par cran, 12 · 12 · 50 · 50 · 50 en Monnaie libre, les votants manquants comptent
« non » (ADR 0013) ; la licence `AGPL-3.0-or-later` (lot S) ;
- le tampon des votes et le **vote simulé ouvert dans Monnaie libre** (pas de `LD_VOTE_ACCOUNT`),
marqué « simulé », qui compte puis s'efface par l'acte « supprime » à l'ouverture de la chaîne
(`ld-server discard-simulation`, serveur arrêté) ; la publication **pas avant des votes prouvés**
(lot V, ADR 0012). Le dépôt des textes reste à `9e0f45a`.
- **À dire à Yvv** : pas de lien d'équipe vers Monnaie libre pendant la simulation (chaque ouverture
est une voix simulée de plus).
- **M6b en ligne** depuis le 03/11 à 08 h 02 (`bb4bdf5`, pipeline 38 ;
`actes/2026-11-03-m6b-textes-quorum.md`) : F1.1, F1.2 et N1.1 disent le quorum par cran, en fr, en,
es, dans les deux groupes. Pour Yvv : `docs/chantiers/acte-certification/textes-quorum-seed.md`
(Q13, quatre défauts appliqués) et trois phrases à reprendre (Q14).
- **M7 en ligne** depuis le 03/11 à 09 h 42 (`b2e597b`, pipeline 39 ; `actes/2026-11-03-m7-lecteur.md`) :
le lecteur refait (lot U), page 2,4 fois plus courte, l'ordre en une ligne au bout de sa section avec
poignées et vote sur place, Propositions seed en tête, « 9 oui sur 12 votants pour passer », votes
simulés dits simulés. **À dire à Yvv** : « Fixer ce quorum » est visible pour lui (administrateur) sur
les 33 articles du seed tant que personne n'y a voté, et écrit en base.
- **M6c en ligne** depuis le 03/11 à 11 h 05 (`84f015a`, pipeline 40 ;
`actes/2026-11-03-m6c-phrases-quorum.md`) : les trois phrases de F1.1, F1.2 et N1.1 justes (lot T2),
l'interface sans « presque l'unanimité ». Pour Yvv (`textes-quorum-seed.md`, section « Lot T2 ») :
F1.2 « tant que l'article ne garde la trace d'aucun vote » (point 8) ; N1.1 « pas un nombre minimal de
votants », faux pour un quorum d'auteur bas.
- **M7b en ligne** depuis le 03/11 à 12 h 23 (`6f6197c`, pipeline 41 ;
`actes/2026-11-03-m7b-lecteur-mineurs.md`) : les mineurs du lecteur (lot U2) : cibles de 44 px sur
téléphone, les trois lectures entières, « Proposer cet ordre » dit pourquoi il est éteint, une
information à un seul endroit dans le vote déplié, l'en-tête « 3 Propositions seed · 32 propositions en
cours », la pastille d'un changement d'inertie de module.
- **M8 en ligne** depuis le 03/11 à 20 h 31 (`de1b7eb`, pipeline 42 ; `actes/2026-11-03-m8-chiffres-dates.md`) :
lots T3, T3b et T3c, sur la réponse d'Yvv du 03/11 (« pas de chiffre datés dans un texte voté »). L'exemple chiffré de
N1.1 sort ; F1.2 et N1.1 écrivent le terme défini (« à la participation habituelle », « le quorum de
l'article ») et chaque phrase est vraie pour tout W et tout quorum ; F1.1 inchangé. Trois relectures :
« 2ce2028 : à reprendre », « e7ab82f : à reprendre », « de1b7eb : ok ». En tête du dossier, pour Yvv,
chaque phrase avant / après. Copies retirées (orch à 20 h 43, l'intégrateur à 20 h 45).
- **Lots T4 et U3 fusionnés** (03/11, `590ccd7`, `a47aed0`) sur la demande d'Yvv de 20 h 38 : I1, E1, E2 réécrits, E12
« Responsabilité civique » ajouté entre E1 et E2, les sections formule, inertie et ordonnancement sortent
du document (rien effacé, ADR 0014). Relecture d'Yvv : `docs/chantiers/acte-certification/textes-amorce.md`.
**U3** (fusionné à 22 h 40) : titres, séparation, « Info ouverture doc de réf. », informations
dans les panneaux, « Proposition d'amorce », texte pour qui n'est pas invité ; U3b fusionné (23 h 00, `d66823b`, le tableau des crans lisible sur bureau). Un seul acte **M9** sur `d66823b`, préparé (`actes/2026-11-03-m9-amorce-hors-vote.md`), relecture neuve en cours
(T4 et U3 ensemble). Défauts et questions à Yvv : au tableau.
- H, S, V, T, U, T2, U2, T3, T3b et T3c sont en ligne. Listes d'attente (relectures de M6, M6c, M7, M7b, M8) : au tableau, section Actes. Le plus
visible : le vote d'un changement d'inertie de module ne dit pas le cran visé.
- **Purge faite** le 03/11 à 12 h 30-12 h 31 (ordre d'orch PURGE-LD) : les sept copies de travail des lots
fusionnés et leurs 13 branches (`-d`, rien poussé) ; les bases de test et captures des sous-agents sous
`/tmp` (synthétiques, aucune de la production) ; 688 restes des tests cargo et scripts e2e. Restent
`/tmp/ld-e2e`, son verrou et `/tmp/ld-h-shots` (captures du lot H, hors de l'ordre).
## Avant le 03/11
**Revue d'Yvv du 2026-10-30** : treize points (L1 à L13), suivis dans
`docs/chantiers/acte-certification/tableau.md` (lots, questions, actes ; source d'état).
- **M1 en ligne** depuis le 2026-10-30 à 15 h 45 (`8727315`) :
- accueil sur le choix du groupe ;
- logo « Clé de voûte » et quatre ambiances ;
- menu, « En cours » et « À venir » ;
- Décisions ;
- Raison d'être, publiée dans le dépôt des textes, commit `35864eb` ;
- versions groupées.
- **L9 à L13 fusionnés et relus** sur `chantier/acte-certification` :
- le vote déplié et groupé, et le vote par la chaîne Ğ1, corrigé mais livré éteint (B, B2) ;
- l'inertie à cinq crans (C) ;
- l'ordre des articles et le titre du document en modules (D) ;
- les objections, les coquilles validées et la boîte « Messages » (F).
Boucle verte (230 tests Rust, 55 vitest, e2e 35 / 35), image de production construite.
- **M4 en ligne** depuis le 01/11 à 23 h 06 (`4d4adcc`) : L9 à L13.
- **M5 en ligne** depuis le 02/11 à 03 h 04 (`3f10a0e`) :
- sur une version retenue, « contre » et retrait, jamais « pour » (lot G) ;
- F1.2, N1, N1.1 et O1 réécrits selon les règles appliquées, qu'Yvv relit en ligne ;
- la licence AGPL-3.0.
- **Ce qui n'est pas actif en production** :
- la connexion par la clé Ğ1 (`/api/auth/config` → `"g1":false`) : il faut le client OAuth de
connect.monnaie-libre.fr (`DEMANDE-CONNECT.md`) ;
- le vote par la chaîne : il faut l'adresse du compte de vote qu'Yvv crée dans Ğecko.
- Le vote par la chaîne reste éteint tant qu'Yvv n'a pas tranché Q5 à Q8 (tableau ;
`docs/recherche/vote-g1-chaine.md`).
- Q5 à Q8 tranchées le 01/11 (registre d'Yvv). Attendu d'Yvv : l'adresse du compte de vote, l'identifiant
et le courriel de Chiara (acte « accorde »), sa relecture des quatre textes en ligne.
**Chantier 1 — Acte d'engagement Certification** : **en ligne** sur decision.librodrome.org,
version `dc2de09` depuis le 2026-10-24 à 01h37 (sessions jusqu'à ce qu'on les ferme ; avant :
`744d157`, le 2026-10-22) ;
la maquette est sur maquette.decision.librodrome.org (certificat jusqu'au 19/01/2027). En ligne :
liens d'équipe, nouveau lien de connexion, noms uniques et mention « sur invitation », règles de
coquilles des relectures 7 à 10, publication vers `yvv/decision-depot-textes-reference` (état et
« Publier maintenant » dans `/acces`). Première publication le 2026-10-22 à 03 h 22 (commit
`4cc0fdd` : `monnaie-libre/acte-engagement-certification/` fr, en, es, document.json, README).
**Prêt, pas encore en ligne** (2026-10-23) : les sessions durent **jusqu'à ce qu'on les ferme**
(400 jours recomptés à chaque visite, au lieu de 30 jours fixes) ; dans `/acces`, « Fermer ses
sessions » par identité (appareil perdu, départ), jamais celle d'où l'on agit, avec le lien de
connexion encore en attente pour elle. Raison : une personne invitée n'a pas de clé Ğ1 pour
revenir, une session expirée lui coûtait un nouveau lien. Les sessions ouvertes pour 30 jours
passent à 400 jours à leur prochaine visite.
Incident du 2026-10-21 : Docker a redémarré sur sonic à 21 h 47 ; l'agent Woodpecker
`aya-woodpeckerci-local-agent-1` gardait l'ancienne socket (pipeline n° 31 : « Cannot connect to
the Docker daemon », rien construit). Remède : `docker restart aya-woodpeckerci-local-agent-1`
(fait avec l'accord d'Yvv), puis « Redémarrer » le pipeline dans l'interface Woodpecker.
- v3 Rust + Svelte à la racine du dépôt ; la maquette v2 intacte dans `maquette/`.
- Boucle : Rust (fmt, clippy `-D warnings`, 71 tests dont 17 scénarios d'API et la
publication vers un dépôt nu) · interface (svelte-check 0 avertissement, 9 vitest) ·
e2e 13 / 13 (C1–C12, M1). L'image Docker rejoue tout et a été essayée localement :
santé, interface, volume non-root, clé de publication, lien admin initial (0600, absent
des journaux), publication immédiate au démarrage, redémarrage sans commit superflu.
- Déploiement : `.woodpecker/app.yml` d'abord (le produit à la racine, qui retire de la
racine tout conteneur qui la revendique), puis `.woodpecker/maquette.yml` (la maquette part
sur maquette.decision.librodrome.org, seulement si le produit a réussi).
- Relectures indépendantes avant mise en ligne, chacune traitée avant de livrer : `1f9c241 :
ok`, `94907af : ok`, puis quatre « à reprendre » (`ac9c904`, `9203fd2`, `1eac6f8`, `6c0754a`),
toutes sur la même question : une « coquille » (sans vote, publiée) qui change un sens, puis
`fa8e8dc : ok` (mis en ligne), `c3d6bf8 : ok`, `fd19dbf : ok`, `753b522 : ok`, `8a32160 : ok`,
`850f9c4 : ok`, `744d157 : ok` (en ligne) ; pour les sessions : `cf634c2 : ok`, `decbc0b : à
reprendre`, `dc2de09 : ok` (en ligne le 2026-10-24). La règle est désormais simple et explicable : **une coquille touche les lettres et les espaces,
rien d'autre** (CHANTIER.md, ADR 0003). Autres points traités : ordre des workflows, anciens
conteneurs en `restart: always`, vote réservé aux membres de la toile dans Monnaie libre (une
personne, une voix), lien d'invitation jamais vers une identité Ğ1, réservation du lien avant
usage, redirection après connexion, en-têtes anti-encadrement, cookies `__Host-`, retour
arrière automatique du déploiement (maquette au premier, version précédente ensuite), courses
entre correction et entrée en vigueur, syft épinglé.
## Ce qui attend Yvv
1. **Envoyer la demande à poka** : `docs/chantiers/acte-certification/DEMANDE-CONNECT.md`.
2. ~~Dépôt des textes~~ : fait le 2026-10-22 — `yvv/decision-depot-textes-reference` (privé), clé de
déploiement en écriture ajoutée par Yvv. À l'ouverture : rendre le dépôt public (Paramètres ›
Dépôt), ou le transférer ; changer de dépôt : `LD_PUBLISH_REMOTE` dans `docker/app-compose.yml`.
3. **Ouvrir trustinWallet** avec le lien administrateur initial (remis hors conversation, un
seul usage : il crée l'identité administratrice). Pour l'équipe : un **lien d'équipe**
(`/acces` → Liens d'équipe ; chaque personne qui l'ouvre a sa propre identité), ou une
invitation par personne. Pour soi sur un autre appareil : « Nouveau lien de connexion ».
4. Donner à Chiara un compte **git.open.us.org** collaboratrice de `yvv/decision`.
5. Décider : la **licence** du code ; le texte F1.2 du seed (annonce C = 0 et 30 jours).
6. **Connexion Ğ1 par sso-connect** (2026-10-23) : plan dans
`trustinWallet/docs/PLAN-sso-connect.md`. Les portefeuilles ne signent que pour
`connect.monnaie-libre.fr` : libreDecision en sera client (point 1), jamais son propre
serveur. Première contribution **ouverte le 2026-10-23** :
https://git.duniter.org/clients/sso-connect/-/merge_requests/7 (depuis ton fork
`Yvv/sso-connect`, branche `feat/page-neutre` : page neutre entre portefeuilles, et la page
nomme le site qui demande la connexion). Commentaire posté à 1000i100 sur sa !6 (faille des
adresses locales). Restent à toi : révoquer le jeton `mr-sso-connect`, poster
`trustinWallet/docs/MESSAGE-forum-sso-connect.md` sur le fil, et trancher la stratégie de
trustinWallet (couche de confiance qui fait signer par les portefeuilles existants, ou
portefeuille complet ; bulletins de vote signés et ancrés par une remarque sur la chaîne
plutôt qu'une transaction par vote).
## Pour reprendre
- **Premier geste de la session suivante** (Yvv, 2026-10-22 : « redonne cette consigne quand je
relance ce chantier ») : rappeler à Yvv le compte git.open.us.org de Chiara (collaboratrice
de `yvv/decision`) et les deux choix en attente, la licence du code et le texte F1.2 du seed.
- Lire `docs/chantiers/acte-certification/CHANTIER.md` puis `CONTROLES.md`.
- Les retours de Chiara arrivent dans `/acces` → Retours : chacun est un contrôle à repasser.
- Quand les identifiants connect arrivent : `DEMANDE-CONNECT.md` § « À la réponse ».
- Chantier suivant (après la mise en production et les retours) : Acte d'engagement forgeron.
- Restes des relectures, non bloquants, pour la prochaine série :
- `publish.rs` : motif `ls-remote` en `refs/heads/<branche>` (une branche `x/main` le trompe) ;
« Dernière publication » rattachée au dépôt affiché ; compter les documents en attente, pas
les lignes de la file ;
- mention « sur invitation » : en `kind <> 'g1'` (tout type futur) ; tests de ce qui s'affiche
(révision visible, signalement, bout en bout) ; auteur des retours dans `/acces` avec son type ;
- noms : sosies latins étendus et ligatures (ı, ł, œ, ß…), invitations créées par un
administrateur sans contrôle de nom ;
- `sbom-publish` échoue (curl 60 : certificat du serveur Dependency-Track refusé) — étape en
`failure: ignore`, déjà le cas avant la v3 ;
- sessions (relecture de `dc2de09`) : un contrôle e2e « nouveau lien, fermer ses sessions, le
lien disparaît » ; la réservation d'un lien et l'ouverture de sa session dans une même
transaction (sinon un lien ouvert à l'instant de la fermeture laisse une session) ; après
« Annuler » une invitation dans la liste, retirer aussi le lien affiché (défaut déjà en
ligne) ; `Cache-Control: no-store` sur `/api`.
-661
View File
@@ -1,661 +0,0 @@
GNU AFFERO GENERAL PUBLIC LICENSE
Version 3, 19 November 2007
Copyright (C) 2007 Free Software Foundation, Inc. <https://fsf.org/>
Everyone is permitted to copy and distribute verbatim copies
of this license document, but changing it is not allowed.
Preamble
The GNU Affero General Public License is a free, copyleft license for
software and other kinds of works, specifically designed to ensure
cooperation with the community in the case of network server software.
The licenses for most software and other practical works are designed
to take away your freedom to share and change the works. By contrast,
our General Public Licenses are intended to guarantee your freedom to
share and change all versions of a program--to make sure it remains free
software for all its users.
When we speak of free software, we are referring to freedom, not
price. Our General Public Licenses are designed to make sure that you
have the freedom to distribute copies of free software (and charge for
them if you wish), that you receive source code or can get it if you
want it, that you can change the software or use pieces of it in new
free programs, and that you know you can do these things.
Developers that use our General Public Licenses protect your rights
with two steps: (1) assert copyright on the software, and (2) offer
you this License which gives you legal permission to copy, distribute
and/or modify the software.
A secondary benefit of defending all users' freedom is that
improvements made in alternate versions of the program, if they
receive widespread use, become available for other developers to
incorporate. Many developers of free software are heartened and
encouraged by the resulting cooperation. However, in the case of
software used on network servers, this result may fail to come about.
The GNU General Public License permits making a modified version and
letting the public access it on a server without ever releasing its
source code to the public.
The GNU Affero General Public License is designed specifically to
ensure that, in such cases, the modified source code becomes available
to the community. It requires the operator of a network server to
provide the source code of the modified version running there to the
users of that server. Therefore, public use of a modified version, on
a publicly accessible server, gives the public access to the source
code of the modified version.
An older license, called the Affero General Public License and
published by Affero, was designed to accomplish similar goals. This is
a different license, not a version of the Affero GPL, but Affero has
released a new version of the Affero GPL which permits relicensing under
this license.
The precise terms and conditions for copying, distribution and
modification follow.
TERMS AND CONDITIONS
0. Definitions.
"This License" refers to version 3 of the GNU Affero General Public License.
"Copyright" also means copyright-like laws that apply to other kinds of
works, such as semiconductor masks.
"The Program" refers to any copyrightable work licensed under this
License. Each licensee is addressed as "you". "Licensees" and
"recipients" may be individuals or organizations.
To "modify" a work means to copy from or adapt all or part of the work
in a fashion requiring copyright permission, other than the making of an
exact copy. The resulting work is called a "modified version" of the
earlier work or a work "based on" the earlier work.
A "covered work" means either the unmodified Program or a work based
on the Program.
To "propagate" a work means to do anything with it that, without
permission, would make you directly or secondarily liable for
infringement under applicable copyright law, except executing it on a
computer or modifying a private copy. Propagation includes copying,
distribution (with or without modification), making available to the
public, and in some countries other activities as well.
To "convey" a work means any kind of propagation that enables other
parties to make or receive copies. Mere interaction with a user through
a computer network, with no transfer of a copy, is not conveying.
An interactive user interface displays "Appropriate Legal Notices"
to the extent that it includes a convenient and prominently visible
feature that (1) displays an appropriate copyright notice, and (2)
tells the user that there is no warranty for the work (except to the
extent that warranties are provided), that licensees may convey the
work under this License, and how to view a copy of this License. If
the interface presents a list of user commands or options, such as a
menu, a prominent item in the list meets this criterion.
1. Source Code.
The "source code" for a work means the preferred form of the work
for making modifications to it. "Object code" means any non-source
form of a work.
A "Standard Interface" means an interface that either is an official
standard defined by a recognized standards body, or, in the case of
interfaces specified for a particular programming language, one that
is widely used among developers working in that language.
The "System Libraries" of an executable work include anything, other
than the work as a whole, that (a) is included in the normal form of
packaging a Major Component, but which is not part of that Major
Component, and (b) serves only to enable use of the work with that
Major Component, or to implement a Standard Interface for which an
implementation is available to the public in source code form. A
"Major Component", in this context, means a major essential component
(kernel, window system, and so on) of the specific operating system
(if any) on which the executable work runs, or a compiler used to
produce the work, or an object code interpreter used to run it.
The "Corresponding Source" for a work in object code form means all
the source code needed to generate, install, and (for an executable
work) run the object code and to modify the work, including scripts to
control those activities. However, it does not include the work's
System Libraries, or general-purpose tools or generally available free
programs which are used unmodified in performing those activities but
which are not part of the work. For example, Corresponding Source
includes interface definition files associated with source files for
the work, and the source code for shared libraries and dynamically
linked subprograms that the work is specifically designed to require,
such as by intimate data communication or control flow between those
subprograms and other parts of the work.
The Corresponding Source need not include anything that users
can regenerate automatically from other parts of the Corresponding
Source.
The Corresponding Source for a work in source code form is that
same work.
2. Basic Permissions.
All rights granted under this License are granted for the term of
copyright on the Program, and are irrevocable provided the stated
conditions are met. This License explicitly affirms your unlimited
permission to run the unmodified Program. The output from running a
covered work is covered by this License only if the output, given its
content, constitutes a covered work. This License acknowledges your
rights of fair use or other equivalent, as provided by copyright law.
You may make, run and propagate covered works that you do not
convey, without conditions so long as your license otherwise remains
in force. You may convey covered works to others for the sole purpose
of having them make modifications exclusively for you, or provide you
with facilities for running those works, provided that you comply with
the terms of this License in conveying all material for which you do
not control copyright. Those thus making or running the covered works
for you must do so exclusively on your behalf, under your direction
and control, on terms that prohibit them from making any copies of
your copyrighted material outside their relationship with you.
Conveying under any other circumstances is permitted solely under
the conditions stated below. Sublicensing is not allowed; section 10
makes it unnecessary.
3. Protecting Users' Legal Rights From Anti-Circumvention Law.
No covered work shall be deemed part of an effective technological
measure under any applicable law fulfilling obligations under article
11 of the WIPO copyright treaty adopted on 20 December 1996, or
similar laws prohibiting or restricting circumvention of such
measures.
When you convey a covered work, you waive any legal power to forbid
circumvention of technological measures to the extent such circumvention
is effected by exercising rights under this License with respect to
the covered work, and you disclaim any intention to limit operation or
modification of the work as a means of enforcing, against the work's
users, your or third parties' legal rights to forbid circumvention of
technological measures.
4. Conveying Verbatim Copies.
You may convey verbatim copies of the Program's source code as you
receive it, in any medium, provided that you conspicuously and
appropriately publish on each copy an appropriate copyright notice;
keep intact all notices stating that this License and any
non-permissive terms added in accord with section 7 apply to the code;
keep intact all notices of the absence of any warranty; and give all
recipients a copy of this License along with the Program.
You may charge any price or no price for each copy that you convey,
and you may offer support or warranty protection for a fee.
5. Conveying Modified Source Versions.
You may convey a work based on the Program, or the modifications to
produce it from the Program, in the form of source code under the
terms of section 4, provided that you also meet all of these conditions:
a) The work must carry prominent notices stating that you modified
it, and giving a relevant date.
b) The work must carry prominent notices stating that it is
released under this License and any conditions added under section
7. This requirement modifies the requirement in section 4 to
"keep intact all notices".
c) You must license the entire work, as a whole, under this
License to anyone who comes into possession of a copy. This
License will therefore apply, along with any applicable section 7
additional terms, to the whole of the work, and all its parts,
regardless of how they are packaged. This License gives no
permission to license the work in any other way, but it does not
invalidate such permission if you have separately received it.
d) If the work has interactive user interfaces, each must display
Appropriate Legal Notices; however, if the Program has interactive
interfaces that do not display Appropriate Legal Notices, your
work need not make them do so.
A compilation of a covered work with other separate and independent
works, which are not by their nature extensions of the covered work,
and which are not combined with it such as to form a larger program,
in or on a volume of a storage or distribution medium, is called an
"aggregate" if the compilation and its resulting copyright are not
used to limit the access or legal rights of the compilation's users
beyond what the individual works permit. Inclusion of a covered work
in an aggregate does not cause this License to apply to the other
parts of the aggregate.
6. Conveying Non-Source Forms.
You may convey a covered work in object code form under the terms
of sections 4 and 5, provided that you also convey the
machine-readable Corresponding Source under the terms of this License,
in one of these ways:
a) Convey the object code in, or embodied in, a physical product
(including a physical distribution medium), accompanied by the
Corresponding Source fixed on a durable physical medium
customarily used for software interchange.
b) Convey the object code in, or embodied in, a physical product
(including a physical distribution medium), accompanied by a
written offer, valid for at least three years and valid for as
long as you offer spare parts or customer support for that product
model, to give anyone who possesses the object code either (1) a
copy of the Corresponding Source for all the software in the
product that is covered by this License, on a durable physical
medium customarily used for software interchange, for a price no
more than your reasonable cost of physically performing this
conveying of source, or (2) access to copy the
Corresponding Source from a network server at no charge.
c) Convey individual copies of the object code with a copy of the
written offer to provide the Corresponding Source. This
alternative is allowed only occasionally and noncommercially, and
only if you received the object code with such an offer, in accord
with subsection 6b.
d) Convey the object code by offering access from a designated
place (gratis or for a charge), and offer equivalent access to the
Corresponding Source in the same way through the same place at no
further charge. You need not require recipients to copy the
Corresponding Source along with the object code. If the place to
copy the object code is a network server, the Corresponding Source
may be on a different server (operated by you or a third party)
that supports equivalent copying facilities, provided you maintain
clear directions next to the object code saying where to find the
Corresponding Source. Regardless of what server hosts the
Corresponding Source, you remain obligated to ensure that it is
available for as long as needed to satisfy these requirements.
e) Convey the object code using peer-to-peer transmission, provided
you inform other peers where the object code and Corresponding
Source of the work are being offered to the general public at no
charge under subsection 6d.
A separable portion of the object code, whose source code is excluded
from the Corresponding Source as a System Library, need not be
included in conveying the object code work.
A "User Product" is either (1) a "consumer product", which means any
tangible personal property which is normally used for personal, family,
or household purposes, or (2) anything designed or sold for incorporation
into a dwelling. In determining whether a product is a consumer product,
doubtful cases shall be resolved in favor of coverage. For a particular
product received by a particular user, "normally used" refers to a
typical or common use of that class of product, regardless of the status
of the particular user or of the way in which the particular user
actually uses, or expects or is expected to use, the product. A product
is a consumer product regardless of whether the product has substantial
commercial, industrial or non-consumer uses, unless such uses represent
the only significant mode of use of the product.
"Installation Information" for a User Product means any methods,
procedures, authorization keys, or other information required to install
and execute modified versions of a covered work in that User Product from
a modified version of its Corresponding Source. The information must
suffice to ensure that the continued functioning of the modified object
code is in no case prevented or interfered with solely because
modification has been made.
If you convey an object code work under this section in, or with, or
specifically for use in, a User Product, and the conveying occurs as
part of a transaction in which the right of possession and use of the
User Product is transferred to the recipient in perpetuity or for a
fixed term (regardless of how the transaction is characterized), the
Corresponding Source conveyed under this section must be accompanied
by the Installation Information. But this requirement does not apply
if neither you nor any third party retains the ability to install
modified object code on the User Product (for example, the work has
been installed in ROM).
The requirement to provide Installation Information does not include a
requirement to continue to provide support service, warranty, or updates
for a work that has been modified or installed by the recipient, or for
the User Product in which it has been modified or installed. Access to a
network may be denied when the modification itself materially and
adversely affects the operation of the network or violates the rules and
protocols for communication across the network.
Corresponding Source conveyed, and Installation Information provided,
in accord with this section must be in a format that is publicly
documented (and with an implementation available to the public in
source code form), and must require no special password or key for
unpacking, reading or copying.
7. Additional Terms.
"Additional permissions" are terms that supplement the terms of this
License by making exceptions from one or more of its conditions.
Additional permissions that are applicable to the entire Program shall
be treated as though they were included in this License, to the extent
that they are valid under applicable law. If additional permissions
apply only to part of the Program, that part may be used separately
under those permissions, but the entire Program remains governed by
this License without regard to the additional permissions.
When you convey a copy of a covered work, you may at your option
remove any additional permissions from that copy, or from any part of
it. (Additional permissions may be written to require their own
removal in certain cases when you modify the work.) You may place
additional permissions on material, added by you to a covered work,
for which you have or can give appropriate copyright permission.
Notwithstanding any other provision of this License, for material you
add to a covered work, you may (if authorized by the copyright holders of
that material) supplement the terms of this License with terms:
a) Disclaiming warranty or limiting liability differently from the
terms of sections 15 and 16 of this License; or
b) Requiring preservation of specified reasonable legal notices or
author attributions in that material or in the Appropriate Legal
Notices displayed by works containing it; or
c) Prohibiting misrepresentation of the origin of that material, or
requiring that modified versions of such material be marked in
reasonable ways as different from the original version; or
d) Limiting the use for publicity purposes of names of licensors or
authors of the material; or
e) Declining to grant rights under trademark law for use of some
trade names, trademarks, or service marks; or
f) Requiring indemnification of licensors and authors of that
material by anyone who conveys the material (or modified versions of
it) with contractual assumptions of liability to the recipient, for
any liability that these contractual assumptions directly impose on
those licensors and authors.
All other non-permissive additional terms are considered "further
restrictions" within the meaning of section 10. If the Program as you
received it, or any part of it, contains a notice stating that it is
governed by this License along with a term that is a further
restriction, you may remove that term. If a license document contains
a further restriction but permits relicensing or conveying under this
License, you may add to a covered work material governed by the terms
of that license document, provided that the further restriction does
not survive such relicensing or conveying.
If you add terms to a covered work in accord with this section, you
must place, in the relevant source files, a statement of the
additional terms that apply to those files, or a notice indicating
where to find the applicable terms.
Additional terms, permissive or non-permissive, may be stated in the
form of a separately written license, or stated as exceptions;
the above requirements apply either way.
8. Termination.
You may not propagate or modify a covered work except as expressly
provided under this License. Any attempt otherwise to propagate or
modify it is void, and will automatically terminate your rights under
this License (including any patent licenses granted under the third
paragraph of section 11).
However, if you cease all violation of this License, then your
license from a particular copyright holder is reinstated (a)
provisionally, unless and until the copyright holder explicitly and
finally terminates your license, and (b) permanently, if the copyright
holder fails to notify you of the violation by some reasonable means
prior to 60 days after the cessation.
Moreover, your license from a particular copyright holder is
reinstated permanently if the copyright holder notifies you of the
violation by some reasonable means, this is the first time you have
received notice of violation of this License (for any work) from that
copyright holder, and you cure the violation prior to 30 days after
your receipt of the notice.
Termination of your rights under this section does not terminate the
licenses of parties who have received copies or rights from you under
this License. If your rights have been terminated and not permanently
reinstated, you do not qualify to receive new licenses for the same
material under section 10.
9. Acceptance Not Required for Having Copies.
You are not required to accept this License in order to receive or
run a copy of the Program. Ancillary propagation of a covered work
occurring solely as a consequence of using peer-to-peer transmission
to receive a copy likewise does not require acceptance. However,
nothing other than this License grants you permission to propagate or
modify any covered work. These actions infringe copyright if you do
not accept this License. Therefore, by modifying or propagating a
covered work, you indicate your acceptance of this License to do so.
10. Automatic Licensing of Downstream Recipients.
Each time you convey a covered work, the recipient automatically
receives a license from the original licensors, to run, modify and
propagate that work, subject to this License. You are not responsible
for enforcing compliance by third parties with this License.
An "entity transaction" is a transaction transferring control of an
organization, or substantially all assets of one, or subdividing an
organization, or merging organizations. If propagation of a covered
work results from an entity transaction, each party to that
transaction who receives a copy of the work also receives whatever
licenses to the work the party's predecessor in interest had or could
give under the previous paragraph, plus a right to possession of the
Corresponding Source of the work from the predecessor in interest, if
the predecessor has it or can get it with reasonable efforts.
You may not impose any further restrictions on the exercise of the
rights granted or affirmed under this License. For example, you may
not impose a license fee, royalty, or other charge for exercise of
rights granted under this License, and you may not initiate litigation
(including a cross-claim or counterclaim in a lawsuit) alleging that
any patent claim is infringed by making, using, selling, offering for
sale, or importing the Program or any portion of it.
11. Patents.
A "contributor" is a copyright holder who authorizes use under this
License of the Program or a work on which the Program is based. The
work thus licensed is called the contributor's "contributor version".
A contributor's "essential patent claims" are all patent claims
owned or controlled by the contributor, whether already acquired or
hereafter acquired, that would be infringed by some manner, permitted
by this License, of making, using, or selling its contributor version,
but do not include claims that would be infringed only as a
consequence of further modification of the contributor version. For
purposes of this definition, "control" includes the right to grant
patent sublicenses in a manner consistent with the requirements of
this License.
Each contributor grants you a non-exclusive, worldwide, royalty-free
patent license under the contributor's essential patent claims, to
make, use, sell, offer for sale, import and otherwise run, modify and
propagate the contents of its contributor version.
In the following three paragraphs, a "patent license" is any express
agreement or commitment, however denominated, not to enforce a patent
(such as an express permission to practice a patent or covenant not to
sue for patent infringement). To "grant" such a patent license to a
party means to make such an agreement or commitment not to enforce a
patent against the party.
If you convey a covered work, knowingly relying on a patent license,
and the Corresponding Source of the work is not available for anyone
to copy, free of charge and under the terms of this License, through a
publicly available network server or other readily accessible means,
then you must either (1) cause the Corresponding Source to be so
available, or (2) arrange to deprive yourself of the benefit of the
patent license for this particular work, or (3) arrange, in a manner
consistent with the requirements of this License, to extend the patent
license to downstream recipients. "Knowingly relying" means you have
actual knowledge that, but for the patent license, your conveying the
covered work in a country, or your recipient's use of the covered work
in a country, would infringe one or more identifiable patents in that
country that you have reason to believe are valid.
If, pursuant to or in connection with a single transaction or
arrangement, you convey, or propagate by procuring conveyance of, a
covered work, and grant a patent license to some of the parties
receiving the covered work authorizing them to use, propagate, modify
or convey a specific copy of the covered work, then the patent license
you grant is automatically extended to all recipients of the covered
work and works based on it.
A patent license is "discriminatory" if it does not include within
the scope of its coverage, prohibits the exercise of, or is
conditioned on the non-exercise of one or more of the rights that are
specifically granted under this License. You may not convey a covered
work if you are a party to an arrangement with a third party that is
in the business of distributing software, under which you make payment
to the third party based on the extent of your activity of conveying
the work, and under which the third party grants, to any of the
parties who would receive the covered work from you, a discriminatory
patent license (a) in connection with copies of the covered work
conveyed by you (or copies made from those copies), or (b) primarily
for and in connection with specific products or compilations that
contain the covered work, unless you entered into that arrangement,
or that patent license was granted, prior to 28 March 2007.
Nothing in this License shall be construed as excluding or limiting
any implied license or other defenses to infringement that may
otherwise be available to you under applicable patent law.
12. No Surrender of Others' Freedom.
If conditions are imposed on you (whether by court order, agreement or
otherwise) that contradict the conditions of this License, they do not
excuse you from the conditions of this License. If you cannot convey a
covered work so as to satisfy simultaneously your obligations under this
License and any other pertinent obligations, then as a consequence you may
not convey it at all. For example, if you agree to terms that obligate you
to collect a royalty for further conveying from those to whom you convey
the Program, the only way you could satisfy both those terms and this
License would be to refrain entirely from conveying the Program.
13. Remote Network Interaction; Use with the GNU General Public License.
Notwithstanding any other provision of this License, if you modify the
Program, your modified version must prominently offer all users
interacting with it remotely through a computer network (if your version
supports such interaction) an opportunity to receive the Corresponding
Source of your version by providing access to the Corresponding Source
from a network server at no charge, through some standard or customary
means of facilitating copying of software. This Corresponding Source
shall include the Corresponding Source for any work covered by version 3
of the GNU General Public License that is incorporated pursuant to the
following paragraph.
Notwithstanding any other provision of this License, you have
permission to link or combine any covered work with a work licensed
under version 3 of the GNU General Public License into a single
combined work, and to convey the resulting work. The terms of this
License will continue to apply to the part which is the covered work,
but the work with which it is combined will remain governed by version
3 of the GNU General Public License.
14. Revised Versions of this License.
The Free Software Foundation may publish revised and/or new versions of
the GNU Affero General Public License from time to time. Such new versions
will be similar in spirit to the present version, but may differ in detail to
address new problems or concerns.
Each version is given a distinguishing version number. If the
Program specifies that a certain numbered version of the GNU Affero General
Public License "or any later version" applies to it, you have the
option of following the terms and conditions either of that numbered
version or of any later version published by the Free Software
Foundation. If the Program does not specify a version number of the
GNU Affero General Public License, you may choose any version ever published
by the Free Software Foundation.
If the Program specifies that a proxy can decide which future
versions of the GNU Affero General Public License can be used, that proxy's
public statement of acceptance of a version permanently authorizes you
to choose that version for the Program.
Later license versions may give you additional or different
permissions. However, no additional obligations are imposed on any
author or copyright holder as a result of your choosing to follow a
later version.
15. Disclaimer of Warranty.
THERE IS NO WARRANTY FOR THE PROGRAM, TO THE EXTENT PERMITTED BY
APPLICABLE LAW. EXCEPT WHEN OTHERWISE STATED IN WRITING THE COPYRIGHT
HOLDERS AND/OR OTHER PARTIES PROVIDE THE PROGRAM "AS IS" WITHOUT WARRANTY
OF ANY KIND, EITHER EXPRESSED OR IMPLIED, INCLUDING, BUT NOT LIMITED TO,
THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR
PURPOSE. THE ENTIRE RISK AS TO THE QUALITY AND PERFORMANCE OF THE PROGRAM
IS WITH YOU. SHOULD THE PROGRAM PROVE DEFECTIVE, YOU ASSUME THE COST OF
ALL NECESSARY SERVICING, REPAIR OR CORRECTION.
16. Limitation of Liability.
IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING
WILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MODIFIES AND/OR CONVEYS
THE PROGRAM AS PERMITTED ABOVE, BE LIABLE TO YOU FOR DAMAGES, INCLUDING ANY
GENERAL, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES ARISING OUT OF THE
USE OR INABILITY TO USE THE PROGRAM (INCLUDING BUT NOT LIMITED TO LOSS OF
DATA OR DATA BEING RENDERED INACCURATE OR LOSSES SUSTAINED BY YOU OR THIRD
PARTIES OR A FAILURE OF THE PROGRAM TO OPERATE WITH ANY OTHER PROGRAMS),
EVEN IF SUCH HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE POSSIBILITY OF
SUCH DAMAGES.
17. Interpretation of Sections 15 and 16.
If the disclaimer of warranty and limitation of liability provided
above cannot be given local legal effect according to their terms,
reviewing courts shall apply local law that most closely approximates
an absolute waiver of all civil liability in connection with the
Program, unless a warranty or assumption of liability accompanies a
copy of the Program in return for a fee.
END OF TERMS AND CONDITIONS
How to Apply These Terms to Your New Programs
If you develop a new program, and you want it to be of the greatest
possible use to the public, the best way to achieve this is to make it
free software which everyone can redistribute and change under these terms.
To do so, attach the following notices to the program. It is safest
to attach them to the start of each source file to most effectively
state the exclusion of warranty; and each file should have at least
the "copyright" line and a pointer to where the full notice is found.
<one line to give the program's name and a brief idea of what it does.>
Copyright (C) <year> <name of author>
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU Affero General Public License as published by
the Free Software Foundation, either version 3 of the License, or
(at your option) any later version.
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU Affero General Public License for more details.
You should have received a copy of the GNU Affero General Public License
along with this program. If not, see <https://www.gnu.org/licenses/>.
Also add information on how to contact you by electronic and paper mail.
If your software can interact with users remotely through a computer
network, you should also make sure that it provides a way for users to
get its source. For example, if your program is a web application, its
interface could display a "Source" link that leads users to an archive
of the code. There are many ways you could offer source, and different
solutions will be better for different programs; see section 13 for the
specific requirements.
You should also get your employer (if you work as a programmer) or school,
if any, to sign a "copyright disclaimer" for the program, if necessary.
For more information on this, and how to apply and follow the GNU AGPL, see
<https://www.gnu.org/licenses/>.
+19 -104
View File
@@ -1,117 +1,32 @@
# libreDecision
Rendre possible la décision collective — en fonction du contexte, processus et modalités,
boîte à outils.
En ligne : **https://decision.librodrome.org** (le produit, v3) · la maquette v2, conservée pour
présenter le projet : https://maquette.decision.librodrome.org (`maquette/`).
## Manifeste
> Personne n'a jamais vécu en démocratie — alors nous la rêvons de jour, et nous la commençons
> à notre échelle. libreDecision attrape chaque décision en une phrase et propose le chemin le
> plus court : décide seul et trace-le, décide sous mandat, écoute puis décide, ou décidez
> ensemble — jamais plus lourd que l'enjeu. Ici on ne délègue pas, on mandate, et on reprend ;
> on n'est pas consulté, on s'instruit, puis on se prononce ; on ne s'affronte pas en deux
> camps, on règle des curseurs — et la position de chacun reste visible dans le faisceau, la
> médiane n'écrase personne, la dernière main est toujours humaine. Les concernés en premier
> lieu ont voix par construction, chacun voit pourquoi ; lever la main « ça me concerne » est
> un droit, pas une demande. Chaque décision dit ce qu'elle engage, ce qu'elle cherche à
> changer, et repasse l'épreuve du réel : rien n'est définitif, toute décision en vigueur garde
> sa poignée, se tromper est une donnée qu'on consigne. Les règles du jeu elles-mêmes — le
> Pacte — s'écrivent au fil de l'eau, par ceux qu'elles gouvernent, sous les boussoles
> Autonomie, Équilibre, Liaison. Du trio d'amis à la toile Ğ1 : le même geste, dix secondes,
> et l'observatoire qui apprend de nous. La ville change, le puits demeure.
## Les quatre outils
| Outil | Ce qu'il fait | État |
|---|---|---|
| **Décisions** | funnel assisté pour choisir les modalités, journal des décisions | à venir |
| **Docs de référence** | acte d'engagement, licence, charte, constitution… sous vote permanent | **actif** |
| **Mandats** | création et révocation de tout mandat | à venir |
| **Protocoles** | workflows sans organe statutaire, rôles tournants, automatisations | à venir |
Le produit avance **un chantier à la fois**, chacun livré complet et en production avant le
suivant — feuille de route : [`docs/chantiers/README.md`](docs/chantiers/README.md).
Chantier en cours : **Acte d'engagement Certification** (Monnaie libre), en binôme Yvv + Chiara.
## Comment marche un doc de référence
- On arrive sur **le texte en vigueur**, seul, lisible. Trois gestes contextuels : consulter le
vote, voir les propositions, proposer une modification.
- Chaque article est sous **vote permanent** : une proposition qui atteint son seuil (formule
inertielle Ğ1, plancher C de votants « pour ») **remonte en vigueur** et remplace la version
en place — si elle tient une marge plus large. Les textes de départ sont des *seeds a minima*,
à zéro vote.
- **Corriger n'est pas voter** : une coquille se corrige sans vote ; un changement de fond
ouvre une nouvelle révision, dont les votes antérieurs sont à reconfirmer.
- Deux propositions qui **se contredisent** ne passent pas ensemble : un signalement bloque la
seconde tant qu'il n'est pas levé.
- L'**inertie** de chaque article (cinq crans, 3 par défaut, calés sur la participation
habituelle du groupe) se vote aussi, au cran de l'article.
- Tout ce qui entre en vigueur est **publié automatiquement** dans un dépôt git
(fr/en/es + JSON) lu par les applications clientes.
Le détail, les décisions et leurs raisons : [`docs/chantiers/acte-certification/CHANTIER.md`](docs/chantiers/acte-certification/CHANTIER.md)
et [`docs/adr/`](docs/adr/).
Plateforme de décisions collectives pour la communauté Duniter/G1.
Boîte à outils gouvernance multi-collectifs, architecture white-label.
## Stack
- **Serveur** : Rust (edition 2024, toolchain 1.99 épinglée) — axum 0.8, sqlx/SQLite, tokio.
Trois crates : `ld-engine` (moteur pur : seuils, édition, assistant, composition),
`trustin-access` (règles d'accès de trustinWallet), `ld-server` (API + publication + service
de l'interface).
- **Interface** : SvelteKit 2 / Svelte 5 (runes), SPA statique servie par le serveur ;
trilingue fr · en · es dès le départ (le français fait foi).
- **Déploiement** : une seule image Docker, Woodpecker → sonic (fabio), volume de données nommé.
- **Frontend** : Nuxt 4 + Vue 3 + Pinia + UnoCSS (port 3002)
- **Backend** : Python FastAPI + SQLAlchemy async + SQLite (port 8002)
- **Auth** : Duniter V2 Ed25519 challenge-response
- **Sanctuaire** : IPFS (kubo) + hash on-chain (system.remark)
## Démarrer
## Démarrage
```bash
export PATH=$HOME/.cargo/bin:$PATH
cd web && npm ci && npm run build && cd .. # l'interface compilée
LD_DEV_LOGIN=1 cargo run -p ld-server # API + interface sur :8002
cd web && npm run dev # interface à chaud sur :3002 (proxy /api → :8002)
# Backend
cd backend && .venv/bin/uvicorn app.main:app --host 0.0.0.0 --port 8002 --reload
# Frontend
cd frontend && npm run dev
```
`LD_DEV_LOGIN=1` ouvre une connexion de développement (sans clé, membre de tous les groupes) —
jamais en production. Toutes les variables : [`.env.example`](.env.example).
## Sections
## Vérifier (la boucle)
- **Décisions** : processus de vote collectif avec boîte à outils (ContextMapper, consentement, advice process)
- **Mandats** : élection sociocratique, cycle de mandat, révocation
- **Documents** : documents de référence sous vote permanent, niveaux d'inertie, sanctuaire IPFS
- **Protocoles** : protocoles opérationnels, jalons de workflow, formules de vote
```bash
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
```
## Architecture
Les contrôles d'usage (C1–C12, M1) sont décrits dans
[`docs/chantiers/acte-certification/CONTROLES.md`](docs/chantiers/acte-certification/CONTROLES.md) ;
les tests de Chiara en sont la version humaine.
## Structure
```
crates/
ld-engine/ # moteur pur, sans I/O : threshold, edit, assist, compose
trustin-access/ # qui voit, qui ouvre, qui gère les accès
ld-server/ # axum : auth Ğ1 + invitations, docs, propositions, votes, adoption, publication
migrations/ # schéma SQLite (données runtime : jamais réécrites)
tests/api.rs # scénarios bout en bout de l'API
seeds/ # documents de départ (JSON trilingue), instanciés une seule fois par groupe
web/
src/lib/ # api, session, i18n (fr source, en, es), composants
src/routes/ # accueil, groupes, lecteur du texte, trustinWallet (/acces)…
e2e/ # Playwright : C1–C12, M1
docker/ # app.Dockerfile + app-compose.yml (sonic)
.woodpecker/ # app.yml (le produit) · maquette.yml (la maquette)
docs/ # chantiers/, adr/, dev/ (blueprint v2), recherche/
maquette/ # la v2 Nuxt + FastAPI, telle quelle
```
## Licence
Logiciel libre sous **GNU Affero General Public License, version 3 ou toute version ultérieure**
(`AGPL-3.0-or-later`, choix d'Yvv du 2026-11-03) : quiconque fait tourner une version modifiée en
ligne doit en publier le code.
Le texte est dans `LICENSE`.
Voir `CLAUDE.md` pour les conventions et `docs/content/dev/` pour la documentation technique.
-185
View File
@@ -1,185 +0,0 @@
# Acte — Mise en ligne M1 (revue du 30/10 : entrée, DA, menu, listes, Raison d'être, versions)
Classe : déploie
Projet : libreDecision Date : 2026-10-30
**État : EXÉCUTÉ** — armé le 30/10/2026 à 15 h 29 par Yvv (« pousse déploie »), sur `8727315`, relecture « 8727315 : ok » inscrite
## Intention
Mettre en ligne sur decision.librodrome.org les points L1 à L8 de la revue d'Yvv du 30/10 (tableau
du chantier) :
- accueil qui ouvre directement sur le choix du groupe, avec le leitmotiv en trois lignes ;
- logo « Clé de voûte » et quatre ambiances ;
- page de groupe avec les quatre piliers et « En cours » ;
- menu avec le nombre de docs, les « À venir » des outils et Décisions, une ligne par doc ;
- « Raison d'être » à la place du Préambule ;
- une seule ligne par article dans la liste, même avec plusieurs versions.
Maintenant, parce que ces points ne dépendent d'aucune question ouverte, et qu'il vaut mieux
livrer petit (le vote, M2, viendra ensuite).
## Ce qui part
Version : `8727315` (branche `chantier/acte-certification`), qui contient la version en
production `dc2de09`. Commits emportés (`git log --oneline dc2de09..8727315`) :
- les fusions des lots R (`68115fb`) et A (`8727315`) ;
- lot R : `a5b92fa` (Raison d'être, amendement de seed gardé), `edc8811` (versions groupées),
`1503e57` (ordre stable des variantes), `c223c5c` (contrôles) ;
- lot A : `c640413` (Décisions et « À venir » côté serveur), `81b604e` (entrée, logo, ambiances,
menu, pages d'outil), `1e04336` (nom accessible du menu) ;
- documents : HANDOFF, tableau, note `docs/recherche/vote-g1-chaine.md`.
Aucune configuration ni aucun secret ne change : `docker/app-compose.yml` et `.woodpecker/`
sont identiques à ceux de `dc2de09` (prémisse 4).
## Ce que l'acte écrit dans la base de production (donnée runtime)
- La migration `0004` ajoute la table `seed_amendments`. Elle est additive : rien n'est
supprimé ni réécrit.
- L'amendement « 2026-10-30-i1-raison-d-etre » remplace le Préambule par la Raison d'être,
dans Monnaie libre et dans le groupe d'essai, **seulement si l'article est intact** :
- texte de départ, en vigueur sans vote, jamais confirmé ;
- révision 1, 0 vote, aucune correction ;
- aucune traduction écrite par un membre, aucun signalement ;
- texte identique au Préambule du seed.
Sinon, il ne touche à rien et journalise la raison. Il consigne chaque décision dans
`seed_amendments` et ajoute à l'historique de l'article la ligne « texte de départ remplacé »
avec son diff. Il met une republication en file, pour Monnaie libre seulement.
- État de la production lu le 2026-10-30 à 13 h 43, sur une copie de la base :
- le Préambule est intact dans les deux groupes : 0 vote, révision 1, traductions du seed,
aucun signalement ;
- il y a une identité (invitation) et aucun vote en tout ;
- deux propositions d'inertie ouvertes, d'Yvv le 29/10, n'en sont pas touchées.
- Répétition faite sur cette copie avec le serveur de `8727315` : démarrage sans erreur, puis
« seed amendment applied » pour l'essai et pour Monnaie libre. Le titre est devenu « Raison
d'être », en révision 2. La file d'envoi contient « texte de départ remplacé : I1 » pour
Monnaie libre seulement.
## Avant — prémisses vivantes
1. La production sert `dc2de09` :
`curl -s https://decision.librodrome.org/api/health` → `"build":"dc2de09…"`.
2. `prod/main` et `origin/main` sont à `dc2de09` :
`git fetch prod && git fetch origin && git rev-parse --short prod/main && git rev-parse --short origin/main`
→ `dc2de09` deux fois (vérifié le 2026-10-30 à 14 h, avec la santé `"build":"dc2de094"`).
3. La relecture de `8727315` est inscrite « ok » dans `~/harness/etat/relectures.json` ; et à
la question « une autre relecture de ce code est-elle en cours ? », la réponse est non.
4. Rien ne change la configuration :
`git diff --stat dc2de09 8727315 -- docker/app-compose.yml .woodpecker/ .env* data/` →
vide.
5. La boucle est verte sur `8727315` : fait le 2026-10-30 (91 tests Rust, svelte-check
0 avertissement, 16 vitest, e2e 20 / 20), et l'image de production se construit
(`docker build --target production`, sortie 0, `seeds/upcoming/` présent dans l'image).
6. Le Préambule est toujours intact en production, sans proposition de texte rivale. Sur une
copie fraîche de la base (`docker exec lpk-libredecision-app cat /data/libredecision.db*`
vers `/tmp/ld-prod-copie/`, lecture seule), la requête suivante :
```sql
SELECT d.group_slug, p.kind, p.status, p.origin,
(SELECT COUNT(*) FROM votes v WHERE v.proposal_id=p.id),
(SELECT group_concat(kind) FROM revisions r WHERE r.proposal_id=p.id)
FROM proposals p JOIN units u ON u.id=p.unit_id JOIN documents d ON d.id=u.doc_id
WHERE u.code='I1';
```
doit rendre, comme le 30/10 à 13 h 43 :
- `monnaie-libre|text|in_force|seed|0|initial` ;
- `monnaie-libre|inertia|open|member|0|initial`, la proposition d'Yvv du 29/10 ;
- `essai|text|in_force|seed|0|initial`.
Toute autre ligne `text` ou `remove` arrête l'acte : la relecture a montré que l'amendement
ne regarde pas les propositions rivales.
## Commandes exactes
```sh
cd /home/yvv/Documents/PROD/DEV/libreDecision
git push prod 8727315:main
git push origin 8727315:main
git push origin chantier/acte-certification
```
Le pipeline Woodpecker (dépôt LPK/decision, `ci.open.us.org`) construit l'image (qui rejoue la
boucle), garde la version en service sous `libredecision-app:previous`, déploie, et revient
seul à la version précédente si le contrôle d'après déploiement échoue.
## Effet attendu
Les visiteurs de decision.librodrome.org voient la nouvelle entrée, le logo, les ambiances, le
menu, les pages d'outil, Décisions, la Raison d'être et les versions groupées. Le dépôt des
textes reçoit un commit : la Raison d'être en fr, en et es, dans
`monnaie-libre/acte-engagement-certification/`.
Irréversibilité : **partielle**. Le code se remet en arrière. Le remplacement du Préambule
reste en base, et le commit du dépôt des textes reste publié.
## Vérifications après
- [ ] `curl -s https://decision.librodrome.org/api/health` → `"build":"8727315…"`.
- [ ] `curl -s https://decision.librodrome.org/ | grep -c 'favicon'` ≥ 1, et la page répond
200.
- [ ] `ssh sonic 'docker logs lpk-libredecision-app 2>&1 | grep "seed amendment"'` → deux
lignes « applied » (essai, monnaie-libre).
- [ ] Dans `/acces` → Publication : « Dernière publication » datée du jour, sans envoi en
attente.
- [ ] À l'écran (Yvv) : l'accueil ouvre sur les groupes ; Monnaie libre montre les quatre
piliers et l'Acte d'engagement ; la Raison d'être ouvre le texte ; « Connaissance
suffisante » tient une ligne avec « 2 versions ».
## Retour
- **Le code** : pousser l'ancienne tête rejoue le pipeline avec la version précédente.
```sh
git push --force-with-lease=main:8727315 prod dc2de09:main
```
C'est une réécriture de branche : elle demande le mot « déploie » pour ce retour précis.
Plus rapide, sur sonic : `docker tag libredecision-app:previous libredecision-app:latest`,
puis `cd /opt/libredecision-app && docker compose up -d --force-recreate`.
L'ancienne version démarre sur la base migrée (la table ajoutée ne la gêne pas). Elle
affiche la clé brute « history.amendment » dans l'historique de I1. Elle mesure les
coquilles de I1 contre le Préambule, ce qui a deux effets :
- une vraie coquille sur la Raison d'être est refusée ;
- n'importe quel membre peut ramener le Préambule par une « coquille », qui serait alors
publiée. La relecture l'a prouvé.
Un retour de code se fait donc au plus court, puis on revient sur une version corrigée.
Tant qu'il dure, on surveille l'historique de I1.
- **La Raison d'être en base** : **pas de retour automatique**. Revenir au Préambule
demanderait un amendement inverse, écrit et relu comme celui-ci.
- **Le commit publié** dans le dépôt des textes : **IMPOSSIBLE à retirer sans réécrire
l'historique** de ce dépôt (geste « supprime »). On publierait par-dessus.
## Exécution
Acte exécuté le 30/10/2026, 15h29-15h52, par orch (session libreDecision), sur le mot d'Yvv
« pousse déploie ».
- Prémisses revérifiées à 15 h 29 :
- santé `{"build":"dc2de094","ok":true}` ;
- `prod/main` et `origin/main` à `dc2de09` ;
- aucun écart de configuration ;
- relecture inscrite ;
- copie fraîche de la base : I1 rend exactement les trois lignes attendues.
- Commandes : `git push prod 8727315:main` (`dc2de09..8727315`),
`git push origin 8727315:main` (`dc2de09..8727315`),
`git push origin chantier/acte-certification` (`68111c3..1593e50`), à 15 h 35.
- Pipeline :
- le site est injoignable un instant à 15 h 44 min 52 s ;
- l'ancienne version répond encore à 15 h 45 min 09 s ;
- `{"build":"87273156","ok":true}` à 15 h 45 min 45 s.
- Vérifications :
- [x] santé `87273156` ;
- [x] `/` répond 200 et la page cite la favicon (4 occurrences) ;
- [x] journaux du conteneur, à 14:45:32Z :
- `seed amendment applied … group=essai unit="I1"` ;
- `seed amendment applied … group=monnaie-libre unit="I1"` ;
- [x] publication à 14:45:36Z : `published doc=0a4c0663… commit="35864eb3dccebf8c7e38accb69107fa3823f8ab4"`, la
Raison d'être est publiée dans le dépôt des textes ;
- [x] maquette.decision.librodrome.org répond 200 ;
- [ ] vérification à l'écran par Yvv (accueil, Monnaie libre, Raison d'être, versions
groupées).
- Image de retour : `libredecision-app:previous` (la version `dc2de09`), gardée par le
pipeline.
- Redémarrage à froid : celui du pipeline, qui recrée le conteneur (`up --force-recreate`).
Aucun autre, il n'était pas au dossier.
- Copies de la base de production (`/tmp/ld-prod-copie`, `/tmp/ld-prod-recette`) retirées du
poste à 15 h 52.
-139
View File
@@ -1,139 +0,0 @@
# Acte — Mise en ligne M3a (inertie à cinq crans, échelle calée sur la participation, réglages)
Classe : déploie
Projet : libreDecision Date : 2026-10-30
**État : CADUC, emporté par M4** (constaté le 03/11/2026) : jamais exécuté ; `71819f9` est un ancêtre de `4d4adcc` (M4, en ligne depuis le 01/11 à 23 h 06) et de `3f10a0e` (M5, en ligne depuis le 02/11 à 03 h 04), son contenu est donc en production. Ne pas exécuter.
État d'avant : **FIGÉ (2026-10-30, 20 h 20)** sur `71819f9` — relectures « a576b68 : ok » et « 71819f9 : ok » inscrites
## Intention
Mettre en ligne le point L10 de la revue d'Yvv du 30/10 :
- l'inertie à cinq crans : 3 par défaut, 4 pour Unicité du compte et Réciprocité ;
- l'échelle calée sur la participation médiane du groupe (choix d'Yvv au formulaire) ;
- l'inertie de l'inertie qui hérite du cran de l'article ;
- les réglages de la formule, à portée de main.
Ce sont les règles sur lesquelles le vote (M2, lot B) va compter : mieux vaut qu'elles soient
en place avant que les premiers votes arrivent. Le module d'ordre des sections (L11) et le
titre du doc en module viendront avec le lot D.
## Ce qui part
Version : `71819f9`, qui contient la version en production `8727315`. Commits emportés
(`git log --oneline 8727315..71819f9`) :
- lot C : `d762301` (moteur, données, serveur), `db64405` (interface), `a57a1ff` (ADR 0007,
chantier, contrôles), `341df4d` (mesure manquée au démarrage), `3bc60a1` (contrôles) ;
- la fusion `a576b68` ;
- le correctif de relecture `71819f9` (les propositions retirées sortent de la médiane ; les
réglages montrent la vraie participation) ;
- des documents : l'acte M1 exécuté, HANDOFF, tableau, note `vote-g1-chaine.md`.
Aucune configuration ni aucun secret ne change (prémisse 4).
## Ce que l'acte écrit dans la base de production (donnée runtime)
- **La migration `0005`.**
- Elle ajoute `units.inertia_legacy` et `proposals.inertia_target_legacy`, et y recopie
l'ancien mot de chaque ligne.
- Elle **réécrit** `units.inertia` et `proposals.inertia_target` en crans : `low` → 2,
`standard` → 3, `high` → 4, `max` → 5. Rien n'est perdu : le mot reste à côté.
- Elle crée les tables `inertia_changes` et `inertia_amendments`.
- Les `adoptions` passées ne sont pas touchées (testé).
- **Les amendements « 2026-10-30-e1-inertie-4 » et « …-e2-inertie-4 ».**
- Ils font passer E1 et E2 du cran 3 au cran 4, dans Monnaie libre et dans le groupe d'essai.
- Ils ne s'appliquent que si l'article n'a jamais changé d'inertie par un vote, et si son
cran est encore celui du seed. Une proposition d'inertie ouverte ne les arrête pas.
- Chaque décision est consignée dans `inertia_amendments`, avec une ligne dans
`inertia_changes` : l'article affiche « Passée de 3 à 4 le … : réglage de départ du texte ».
- Ils mettent une republication en file, pour Monnaie libre seulement. Le `document.json`
publié porte désormais l'inertie en nombre.
- **La mesure de participation** de chaque groupe est gardée dans `settings`
(`participation:<groupe>`). Sans votes, c'est la référence 2C/W, soit 100 votants pour
Monnaie libre.
- **Répétition faite le 30/10 à 18 h 23**, avec le serveur de `a576b68`, sur une copie fraîche
de la base de production :
- 62 articles au cran 3 et 4 au cran 4 (E1 et E2 des deux groupes), tous avec le mot
`standard` gardé à côté ;
- les deux propositions d'Yvv du 29/10 restent ouvertes, cible 5, mot `max` gardé ;
- 0 adoption réécrite ;
- la file contient « inertie de départ relevée : E1, 3 → 4 » et « … E2, 3 → 4 ».
## Ce que voient les visiteurs
- L'inertie en crans, de 1 à 5, avec un nom court (très basse, basse, moyenne, haute, très
haute).
- Les réglages de la formule : W, C, B, la participation habituelle, et le tableau des cinq
crans.
- Les seuils bougent un peu. Au cran 3, avec la référence de Monnaie libre, il faut 103 voix
pour à 120 votants (au lieu de 105 en `standard`), et 1 265 à 2 000 votants (au lieu de
1 246). Le détail est dans l'ADR 0007.
- Tes deux propositions d'inertie :
- « Raison d'être → 5 » se vote au cran 3 : 103 voix pour à 120 votants, au lieu de 115 quand
on votait au cran du dessus ;
- « Unicité → 5 » se vote au cran 4 : 108 voix pour.
## Avant — prémisses vivantes
1. La production sert `8727315` : `curl -s https://decision.librodrome.org/api/health` →
`"build":"87273156"`.
2. `git fetch prod && git fetch origin && git rev-parse --short prod/main && git rev-parse --short origin/main`
→ `8727315` deux fois.
3. Les relectures `a576b68 : ok` et `71819f9 : ok` sont inscrites dans
`~/harness/etat/relectures.json`, et aucune autre relecture de ce code n'est en cours.
4. `git diff --stat 8727315 71819f9 -- docker/app-compose.yml .woodpecker/ .env data/` → vide.
5. La boucle est verte sur `71819f9` : fait le 30/10 vers 19 h (118 tests Rust, svelte-check
0 avertissement, 23 vitest, e2e 21 / 21). L'image de production de `a576b68` se construit
(sortie 0).
6. Une copie fraîche de la base de production rend :
- `SELECT inertia, COUNT(*) FROM units GROUP BY 1` → `standard|66` ;
- `SELECT COUNT(*) FROM adoptions WHERE kind = 'inertia'` → `0` ;
- deux propositions `inertia` ouvertes (I1 et E1, cible `max`, 0 vote).
Toute autre valeur arrête l'acte, le temps de revoir la garde.
## Commandes exactes
```sh
cd /home/yvv/Documents/PROD/DEV/libreDecision
git push prod 71819f9:main
git push origin 71819f9:main
git push origin chantier/acte-certification
```
## Effet attendu
Les règles d'inertie à cinq crans, l'échelle et les réglages sont en ligne. E1 et E2 sont au
cran 4. Le dépôt des textes reçoit un `document.json` qui porte les crans.
Irréversibilité : **partielle**. Le code se remet en arrière. Ce qui reste :
- les crans écrits en base, avec le mot d'origine à côté ;
- E1 et E2 au cran 4 ;
- le commit publié dans le dépôt des textes.
## Vérifications après
- [ ] `curl -s https://decision.librodrome.org/api/health` → `"build":"71819f90"`.
- [ ] `ssh sonic 'docker logs lpk-libredecision-app 2>&1 | grep -E "inertia amendment|participation measured|published"'`
→ quatre « inertia amendment applied » (E1, E2 × essai, monnaie-libre), les mesures,
une publication.
- [ ] À l'écran (Yvv) : Monnaie libre → Acte d'engagement → « Réglages de la formule ». On y
lit la référence de 100 votants et le tableau des cinq crans. Unicité et Réciprocité
sont au cran 4, la Raison d'être au cran 3.
## Retour
- **Le code**, au plus court, sur sonic : `docker tag libredecision-app:previous libredecision-app:latest`,
puis `cd /opt/libredecision-app && docker compose up -d --force-recreate`. L'image
précédente est `8727315`.
- **Rejoué par la relecture** : base de `8727315`, montée en `a576b68`, puis ramenée. Aucune
perte, aucune écriture ni adoption à tort. Mais l'affichage se dégrade :
- la clé brute « inertia.3 » ou « inertia.4 » sur chaque ligne d'article ;
- les propositions vers 5 se lisent « vers standard », et ne s'adoptent pas tant qu'on reste
sur l'ancienne image ;
- l'ancienne image laisse créer des propositions d'inertie en double, ou sans effet.
On n'y reste donc pas : on revient vite sur une version corrigée.
- **Les crans en base** : pas de retour automatique ; les mots d'origine restent dans les
colonnes `*_legacy`.
- **E1 et E2 au cran 4** : pas de retour automatique. Il faudrait un amendement inverse, relu.
- **Le commit publié** dans le dépôt des textes : on ne le retire pas sans réécrire
l'historique de ce dépôt (« supprime »). On publierait par-dessus.
-132
View File
@@ -1,132 +0,0 @@
# Acte — Mise en ligne M2 + M3a (le vote déplié et groupé, l'inertie à cinq crans)
Classe : déploie
Projet : libreDecision Date : 2026-10-31
**État : CADUC, emporté par M4** (constaté le 03/11/2026) : jamais exécuté ; `74f96be` est un ancêtre de `4d4adcc` (M4, en ligne depuis le 01/11 à 23 h 06) et de `3f10a0e` (M5, en ligne depuis le 02/11 à 03 h 04), son contenu est donc en production. Ne pas exécuter.
État d'avant : **FIGÉ (2026-10-31, 01 h 30)** sur `74f96be` — relectures « a576b68 : ok », « 71819f9 : ok »,
« 74f96be : ok » inscrites
## Intention
Mettre en ligne en une fois les points L9 et L10 de la revue d'Yvv du 30/10 :
- L10 (lot C, déjà relu, dossier M3a) : l'inertie à cinq crans, l'échelle calée sur la
participation, les réglages de la formule ;
- L9 (lot B) :
- le vote qui se déplie sous l'article, avec ton vote, les totaux et la distance au seuil ;
- le vote groupé (document, section, article) dans un panier ;
- le vote par transaction Ğ1, livré **éteint**.
**Le vote par la chaîne reste éteint** : `LD_VOTE_ACCOUNT` n'est pas posé. La relecture de
`74f96be` a relevé trois défauts à corriger avant de l'ouvrir (lot B2, en cours), et Yvv doit
encore trancher le secret du vote, l'extension et le compte de vote. Dans Monnaie libre,
l'écran dit pourquoi on ne peut pas encore voter. Dans le groupe d'essai, le vote marche côté
serveur, déplié et groupé.
Si cet acte est armé, il **remplace** le dossier M3a (`actes/2026-10-30-m3-inertie-cinq-crans.md`),
qui n'aura plus d'objet. Tout ce que M3a écrit en base est repris ici.
## Ce qui part
Version : `74f96be`, qui contient la version en production `8727315`. En plus de M3a
(`8727315..71819f9`, voir son dossier) :
- lot B : `3541e1b` (le vote par la chaîne, moteur et serveur), `b260596` (le vote déplié, le
panier, la signature), `ef27165` (« Voter en lot »), `032fcbb` (ADR 0008, contrôles C21–C24) ;
- la fusion `74f96be`.
Ce qui change hors du code : `docker/app-compose.yml` reçoit **trois lignes de commentaire** (où
poser `LD_VOTE_ACCOUNT`), et rien d'autre (prémisse 4). Nouvelles dépendances :
- `polkadot-api` (67 Ko compressés, chargé seulement au moment de signer) ;
- `uqr` (4 Ko, le QR code) ;
- la crate `blake2`.
## Ce que l'acte écrit dans la base de production (donnée runtime)
- **Tout ce que dit le dossier M3a** :
- migration `0005` (crans, avec les anciens mots gardés) ;
- amendements gardés « E1 et E2 au cran 4 » ;
- mesure de participation dans `settings` ;
- republication de Monnaie libre.
- **La migration `0006`**, additive :
- tables `ballots` et `ballot_lines` ;
- quatre colonnes ajoutées à `votes` (`source`, `server` par défaut ; `block`, `event_index`,
`ballot_id`) ;
- aucun vote réécrit (il n'y en a aucun en production).
- **Répétition faite le 31/10 à 01 h 25**, avec le serveur de cette version, sur une copie
fraîche de la base de production (la santé disait `87273156`) :
- migrations 1 à 6 appliquées ;
- 4 « inertia amendment applied » ;
- 62 articles au cran 3, 4 au cran 4 ;
- 0 bulletin, 0 vote, une identité ;
- `integrity_check` ok, `foreign_key_check` vide.
## Avant — prémisses vivantes
1. La production sert `8727315` : `curl -s https://decision.librodrome.org/api/health` →
`"build":"87273156"`. Si elle sert `71819f9` (M3a déjà en ligne), l'acte reste valable :
seule la migration `0006` s'applique alors.
2. `git fetch prod && git fetch origin && git rev-parse --short prod/main && git rev-parse --short origin/main`
→ la même version que la santé, deux fois.
3. Les relectures `a576b68`, `71819f9` et `74f96be` sont inscrites « ok » dans
`~/harness/etat/relectures.json`. Aucune autre relecture de ce code n'est en cours.
4. `git diff 8727315 74f96be -- docker/app-compose.yml .woodpecker/ .env data/` ne montre que
les trois lignes de commentaire sur `LD_VOTE_ACCOUNT` (vérifié le 31/10 à 01 h 30). Et sur sonic,
`grep -c LD_VOTE_ACCOUNT /opt/libredecision-app/secrets.env` → `0`, ou fichier absent : le
vote par la chaîne reste éteint (fichier absent le 31/10 à 01 h 30).
5. La boucle est verte sur `74f96be` : fait le 31/10 vers 0 h 15 (149 tests Rust, svelte-check
0 avertissement, 32 vitest, e2e 25 / 25). L'image de production se construit (sortie 0).
6. Sur une copie fraîche de la base de production :
- `SELECT inertia, COUNT(*) FROM units GROUP BY 1` → `standard|66` (ou, si M3a est en
ligne, `3|62` et `4|4`) ;
- `SELECT COUNT(*) FROM votes` → `0` ;
- `SELECT COUNT(*) FROM adoptions WHERE kind = 'inertia'` → `0`.
## Commandes exactes
```sh
cd /home/yvv/Documents/PROD/DEV/libreDecision
git push prod 74f96be:main
git push origin 74f96be:main
git push origin chantier/acte-certification
```
## Effet attendu
Les visiteurs voient :
- l'inertie en crans et les réglages de la formule ;
- « Le vote » qui se déplie sous l'article ;
- « Voter en lot » et « Voter cette section », et le panier ;
- dans Monnaie libre, la raison pour laquelle on ne vote pas encore.
E1 et E2 passent au cran 4. Le dépôt des textes reçoit un `document.json` qui porte les crans.
Irréversibilité : **partielle**. Le code se remet en arrière. Ce qui reste :
- les crans écrits en base, avec les mots d'origine à côté ;
- E1 et E2 au cran 4 ;
- les tables du vote, vides ;
- le commit publié dans le dépôt des textes.
## Vérifications après
- [ ] `curl -s https://decision.librodrome.org/api/health` → `"build":"74f96bec"`.
- [ ] `ssh sonic 'docker logs lpk-libredecision-app 2>&1 | grep -E "inertia amendment|participation measured|published|ballot"'`
→ quatre « inertia amendment applied », les mesures, une publication, aucune erreur de
bulletin.
- [ ] À l'écran (Yvv) :
- Monnaie libre → Acte d'engagement → « Le vote » sous la Raison d'être se déplie, et dit
que le vote par la chaîne n'est pas encore ouvert ;
- les réglages de la formule montrent la référence de 100 votants ;
- dans le groupe d'essai, voter « Pour » sous un article marche, et le panier groupe une
section.
## Retour
- **Le code**, au plus court, sur sonic : `docker tag libredecision-app:previous libredecision-app:latest`,
puis `cd /opt/libredecision-app && docker compose up -d --force-recreate`.
- **Vers `71819f9`** : la relecture a montré qu'elle tolère la migration `0006` et continue
d'écrire ses votes.
- **Vers `8727315`** : pas de perte, mais l'affichage se dégrade. Ce sont les clés brutes
« inertia.3 », les propositions vers 5 lues « vers standard », d'adoption impossible. Voir le
dossier M3a. On n'y reste pas.
- **Les crans, E1 et E2 au cran 4, les tables du vote** : pas de retour automatique. Elles sont
additives, ou gardent l'ancien mot à côté.
- **Le commit publié** dans le dépôt des textes : on ne le retire pas sans réécrire l'historique
de ce dépôt, ce qui serait un acte « supprime ». On publierait par-dessus.
@@ -1,240 +0,0 @@
# Acte — Mise en ligne M4 (le vote, l'inertie à cinq crans, les modules d'ordre et de titre, les objections, les coquilles validées)
Classe : déploie
Projet : libreDecision Date : 2026-10-31
**État : EXÉCUTÉ** le 01/11/2026 (armé par Yvv : « déploie. », le 01/11 vers 22 h 50) — figé le 2026-10-31 à 10 h 47 sur `4d4adcc`
`74f96be`, `9c8f4cc`, `cd7e683`, `924a40d`, `033b331`, `4d4adcc`
## Intention
Mettre en ligne en une fois le reste de la revue d'Yvv du 30/10, L9 à L13 :
- L9 : le vote déplié et groupé ; le vote par la chaîne, corrigé (B2) mais livré **éteint** ;
- L10 : l'inertie à cinq crans ;
- L11 : l'ordre des articles en fin de section (cran 2) et le titre du document en module (cran 4) ;
- L12 : les objections « redit » et « contredit » ;
- L13 : les coquilles validées par leur auteur, la boîte « Messages », le délai de 3 jours.
Cet acte **remplace** les dossiers M2 (`actes/2026-10-31-m2-vote.md`, figé sur `74f96be`) et M3a
(`actes/2026-10-30-m3-inertie-cinq-crans.md`, figé sur `71819f9`). `4d4adcc` contient l'un et
l'autre, et ce que M2 et M3a écrivent en base est repris ici. Si M2 ou M3a est armé avant celui-ci,
cet acte reste valable : seules les migrations manquantes s'appliquent (prémisse 1).
**Le vote par la chaîne reste éteint** : `LD_VOTE_ACCOUNT` n'est pas posé. Yvv doit encore trancher :
- le secret du vote face aux totaux en direct (Q8) ;
- le commentaire scellé (Q5) ;
- l'extension (Q6) ;
- le compte de vote (Q7).
Dans Monnaie libre, l'écran dit pourquoi on ne vote pas encore. Dans le groupe d'essai, tout marche
côté serveur.
## Ce qui part
Version : `4d4adcc`, qui contient la version en production `8727315`. Lots et relectures :
| Lot | Commits | Relecture |
|---|---|---|
| C (cinq crans) | `a576b68`, `71819f9` | « ok » ×2 |
| B (vote) | `74f96be` | « ok » |
| B2 (corrections du vote) | `7ffa4fd` | « à reprendre », repris en `9c8f4cc` |
| F (objections, coquilles, messages) | `cd7e683` | « ok », avec `9c8f4cc` et `924a40d` |
| D (modules) et sa fusion | `033b331` | « ok », avec `4d4adcc` |
| Corrections M1–M4 de la relecture de F | `4d4adcc` | « ok », avec `033b331` |
Ce qui change hors du code (vérifié le 31/10 à 10 h 10) :
- `docker/app-compose.yml` reçoit **trois lignes de commentaire** (où poser `LD_VOTE_ACCOUNT`), et
rien d'autre ;
- `seeds/acte-engagement-certification.json` passe aux crans et décrit les modules. Il n'est lu que pour une base neuve :
un document qui existe en base n'est jamais réinstancié ;
- aucun fichier `.env`, `.db`, clé ou secret.
Nouvelles dépendances (celles de M2) : `polkadot-api`, chargé seulement au moment de signer ; `uqr` ;
la crate `blake2`.
## Ce que l'acte écrit dans la base de production (donnée runtime)
- **Migrations 0005 à 0008**, additives :
- 0005 : les crans, avec les anciens mots gardés ;
- 0006 : les tables du vote, vides ;
- 0007 : `units.kind` et le journal `module_amendments` ;
- 0008 : les objections, les coquilles et les messages, et index ; en production, aucun
signalement à convertir (0 le 31/10 à 06 h 58).
- **Ajouts gardés, journalisés, une seule fois** :
- E1 et E2 au cran 4, dans les deux groupes (4 lignes) ;
- un module titre (cran 4) et un module d'ordre par section (cran 2, 8 sections) par document,
soit 18 modules, chacun avec sa version en vigueur, l'état présent.
- **La mesure de participation** dans `settings`.
- **La republication de Monnaie libre** : trois publications en file (crans E1 et E2, modules) ;
rien pour le groupe d'essai.
- **Aucun article, aucun vote, aucune proposition existante n'est touché.**
**Répétition faite le 31/10 de 10 h 02 à 10 h 06**, avec le serveur de `4d4adcc` (`target/debug`),
sur une copie fraîche de la base de production (la santé disait `87273156`), sans publication
configurée. Avant et après la montée :
| | Avant | Après |
|---|---|---|
| Migrations | 1–4 | 1–8 |
| Unités | 66 articles `standard` | 66 articles (62 au cran 3, 4 au cran 4), 16 ordres au cran 2, 2 titres au cran 4 |
| Propositions d'article | 6 en vigueur, 66 ouvertes | 6 en vigueur, 64 textes ouverts et 2 propositions d'inertie ouvertes (les mêmes 72) |
| Modules | — | 18, en vigueur |
| Révisions | 74 | 92 |
| Traductions | 140 | 144 |
| Votes, adoptions, signalements | 0, 0, 0 | 0, 0, 0 |
| Identités | 1 | 1 |
Ce que montrent les journaux :
- 4 lignes « inertia amendment applied » et 2 lignes « modules added » ;
- dans la file : 3 publications, toutes pour le document de Monnaie libre ;
- `integrity_check` ok, `foreign_key_check` vide.
Un second démarrage ne rejoue rien : mêmes comptes, journaux 4 / 2, file à 6.
## Avant — prémisses vivantes
1. La production sert `8727315` : `curl -s https://decision.librodrome.org/api/health` →
`"build":"87273156"`. Si elle sert `71819f9` (M3a) ou `74f96be` (M2), l'acte reste valable.
2. `git fetch prod && git fetch origin && git rev-parse --short prod/main && git rev-parse --short origin/main`
→ la même version que la santé, deux fois.
3. Dans `~/harness/etat/relectures.json`, ces relectures sont inscrites « ok » :
`a576b68`, `71819f9`, `74f96be`, `9c8f4cc`, `cd7e683`, `924a40d`, `033b331`, `4d4adcc`.
Aucune autre relecture de ce code n'est en cours.
4. `git diff 8727315 4d4adcc -- docker/ .woodpecker/ .env data/` ne montre que les trois lignes de
commentaire sur `LD_VOTE_ACCOUNT`. Sur sonic, `grep -c LD_VOTE_ACCOUNT /opt/libredecision-app/secrets.env`
→ `0`, ou fichier absent.
5. La boucle est verte sur `4d4adcc` : faite le 31/10 vers 10 h (230 tests Rust, svelte-check sans
avertissement, 55 vitest, e2e 35 / 35). L'image de production se construit :
`docker build -f docker/app.Dockerfile --target production .`, sortie 0 le 31/10 à 10 h 09 (tests
rejoués dans l'image : Rust verts, 55 vitest).
6. Sur une copie fraîche de la base de production, **juste avant la poussée** :
- `SELECT group_concat(version) FROM _sqlx_migrations` → `1,2,3,4` (ou plus si M2 / M3a est en ligne) ;
- `SELECT COUNT(*) FROM votes` → `0` ;
- `SELECT COUNT(*) FROM signals` → `0`. Cette prémisse compte : deux signalements debout d'une même
personne sur une même paire feraient échouer la migration 0008 (index `signals_one_standing`,
relecture de `033b331+4d4adcc`). Le serveur ne démarrerait pas, et Woodpecker relancerait
`8727315` sans aucun module, sans dégât.
## Commandes exactes
```sh
cd /home/yvv/Documents/PROD/DEV/libreDecision
git push prod 4d4adcc:main
git push origin 4d4adcc:main
git push origin chantier/acte-certification
```
## Effet attendu
Les visiteurs voient :
- l'inertie en crans et les réglages de la formule ;
- « Le vote » qui se déplie sous l'article, « Voter en lot », « Voter cette section », et le panier ;
- l'ordre des articles en fin de section, et le titre du document qui se vote ;
- « Faire une objection » (redit, contredit), la boîte « Messages », les coquilles proposées ;
- dans Monnaie libre, la raison pour laquelle on ne vote pas encore.
E1 et E2 passent au cran 4. Le dépôt des textes reçoit un `document.json` qui porte les crans et les
modules.
Irréversibilité : **partielle**. Le code se remet en arrière. Ce qui reste :
- les crans en base, avec les mots d'origine à côté ;
- les 18 modules ;
- les tables nouvelles, vides ou presque ;
- les commits publiés dans le dépôt des textes.
## Vérifications après
- [ ] `curl -s https://decision.librodrome.org/api/health` → `"build":"4d4adcc…"`, les huit premiers
caractères de l'empreinte.
- [ ] `ssh sonic 'docker logs lpk-libredecision-app 2>&1 | grep -E "inertia amendment|modules added|participation measured|published|ERROR"'`
→ quatre « inertia amendment applied », deux « modules added », les mesures, des
publications, aucune erreur.
- [ ] Redémarrage à froid (`cd /opt/libredecision-app && docker compose restart`), puis les mêmes
journaux : aucun ajout rejoué.
- [ ] À l'écran (Yvv) :
- Monnaie libre → Acte d'engagement : en fin de section, l'ordre des articles ; « Le vote » sous
la Raison d'être dit que le vote par la chaîne n'est pas encore ouvert ;
- la boîte « Messages » s'ouvre depuis le menu ;
- dans le groupe d'essai : voter « Pour » sous un article ; le panier groupe une section ; « Faire
une objection » dans « Détails » d'une proposition.
## Retour
- **Le code**, au plus court, sur sonic : `docker tag libredecision-app:previous libredecision-app:latest`,
puis `cd /opt/libredecision-app && docker compose up -d --force-recreate`.
- **Revenir à `8727315` une fois les modules créés abîme l'affichage et la publication** (relecture
de `033b331+4d4adcc`, prouvé sur une base faite par `8727315`). `open_db` tolère les migrations
inconnues, mais l'image ancienne lit toute la table `units` :
- le titre et les ordres apparaissent comme 9 faux articles par document (un titre au corps vide,
des listes d'identifiants), comptés « en vigueur » et « à voter » ;
- à son démarrage, elle republie Monnaie libre avec eux (`publish::resync`) ;
- elle lit les crans « 3 » et « 4 » comme `standard`, ce qui est sans effet tant qu'il n'y a aucun vote.
Ce retour peut aussi venir seul : l'étape `retablir` de `.woodpecker/app.yml` relance
`libredecision-app:previous` si la nouvelle version ne répond pas dans les 10 minutes.
**Conduite retenue par défaut** (Yvv peut la corriger) :
- on ne revient pas à la main vers `8727315` : on corrige en avant, par un nouveau déploiement ;
- si `retablir` joue, on laisse `8727315` servir le temps de corriger. La publication fausse ne
perd rien : elle part dans le dépôt des textes provisoire, et le déploiement suivant republie le bon
texte par-dessus. Le contexte : 1 identité et 0 vote en production.
- **Les crans, les modules, les tables nouvelles** : pas de retour automatique. Ils sont additifs, ou
gardent l'ancien mot à côté.
- **Les commits publiés** dans le dépôt des textes : on ne les retire pas sans réécrire l'historique
de ce dépôt, ce qui serait un acte « supprime ». On publierait par-dessus.
## Exécution
Acte exécuté le 01/11/2026, 22h52-23h40, par orch (session libreDecision), armé par Yvv dans cette
session (« déploie. »).
**Écart au dossier** : seule `git push prod 4d4adcc:main` est partie. Les deux poussées vers
`origin` (`4d4adcc:main` et `chantier/acte-certification`) relèvent de « pousse », absent du message
d'armement ; elles attendent ce mot. Leçon : un dossier « déploie » ne liste que la poussée qui met en
ligne. Une poussée vers `origin` est un acte « pousse » à part.
Prémisses revérifiées de 22 h 52 à 22 h 53 :
1. santé `87273156` ;
2. `prod/main` = `origin/main` = `8727315` ;
3. les huit relectures inscrites « ok », aucune autre en cours ;
4. trois lignes de commentaire seulement dans `docker/`, `.woodpecker/`, `.env` et `data/` ;
`secrets.env` absent sur sonic ;
5. boucle et image du 31/10, inchangées depuis ;
6. sur une copie fraîche : migrations `1,2,3,4`, 0 vote, 0 signalement, 1 identité, 72 propositions.
La copie a été retirée aussitôt.
Mise en ligne :
- 22 h 53 : `git push prod 4d4adcc:main` → `8727315..4d4adcc 4d4adcc -> main` ;
- 23 h 06 : la santé répond `{"build":"4d4adcc2","ok":true}` ; elle répondait `87273156` jusqu'à 22 h 54.
Journaux du démarrage (22 h 06 UTC) :
- 4 lignes « inertia amendment applied » (E1 et E2, essai et Monnaie libre) ;
- 2 lignes « modules added » ;
- les mesures de participation (médiane absente, échantillon 0) ;
- une publication, `published … commit="12134cd49eccadbf25a157bffa8b627e869099e9"`, qui regroupe les
trois mises en file ;
- aucune ligne ERROR ni WARN.
Image de retour : `libredecision-app:previous` = `sha256:625646c7e73d…`, construite le 30/10 à 15 h 44
(`8727315`).
Redémarrage à froid (`docker compose restart`, 23 h 35) :
- le serveur écoute en `4d4adcc2`, `RestartCount` 0 ;
- aucun ajout rejoué ;
- la publication de reprise dit `commit="unchanged"`.
La sonde de santé du script, faite de l'intérieur du conteneur, a échoué : l'image n'a ni `wget` ni
`curl`. Le script s'est donc arrêté avant de se retirer ; je l'ai retiré à la main. La santé a été
vérifiée de l'extérieur : `4d4adcc2`.
Invariants sur une copie fraîche, après le redémarrage, identiques à la répétition du 31/10 à la ligne
près :
- migrations `1,…,8` ;
- 62 articles au cran 3, 4 au cran 4, 16 ordres au cran 2, 2 titres au cran 4 ;
- 90 propositions, dont les mêmes 72 d'article ;
- 92 révisions, 144 traductions, 0 vote, 1 identité ;
- journaux 4 et 2 ;
- `integrity_check` ok, `foreign_key_check` vide.
Retirés, parce qu'ils portaient des données réelles : la copie de la prémisse 6, la copie d'après, la
répétition `/tmp/ld-repetition-m4` et le journal de construction.
Reste à l'écran pour Yvv : les vérifications « À l'écran » ci-dessus.
@@ -1,159 +0,0 @@
# Acte — Mise en ligne M5 (une version retenue : « contre » et retrait ; les articles des règles réécrits ; la licence)
Classe : déploie
Projet : libreDecision Date : 2026-11-02
**État : EXÉCUTÉ** le 02/11/2026 — armé d'avance par Yvv (« Pousse. Déploie. », le 02/11, pour relire en ligne), relecture `0919b46+3f10a0e` « ok »
ligne) ; on exécute à l'« ok » de la relecture de `0919b46+3f10a0e`
## Intention
Mettre en ligne ce qu'Yvv veut relire à l'écran : « Pour que je fasse une review, faut que je vois en
ligne » (02/11). Trois choses partent :
- **le lot G** : sur une version retenue par une objection, on peut voter « contre » ou retirer son
vote, jamais ajouter un « pour » (Yvv, 01/11) ;
- **les articles des règles** : F1.2, N1, N1.1 et O1 réécrits selon les règles appliquées (Yvv,
02/11). Ce sont des propositions ouvertes à 0 vote ; l'amendement de seed gardé remplace leur texte
et leurs traductions dans les deux groupes. Yvv les relit en ligne ;
- **la licence** AGPL-3.0 (`LICENSE`, champs des manifestes).
Le vote par la chaîne reste éteint : `LD_VOTE_ACCOUNT` n'est pas posé.
## Ce qui part
Version : `3f10a0e`, qui contient la version en ligne `4d4adcc` ; 13 commits, 41 fichiers. Hors du
code (vérifié le 02/11 à 02 h 03) : rien dans `docker/`, `.woodpecker/`, `.env` ou `data/`, et aucun
fichier sensible.
## Ce que l'acte écrit dans la base de production (donnée runtime)
- **Aucune migration.**
- **Quatre amendements de seed gardés**, décidés une fois par document et journalisés dans
`seed_amendments` (8 lignes). Chacun remplace le texte de la proposition ouverte `1.0.0` du seed
(révision 1 → 2, une ligne d'historique `amendment`) et ses traductions en, es. Seulement si
personne n'y a touché : 0 vote, révision 1, aucune coquille, traduction ni objection, et le texte
du seed du 21/10 (empreinte).
- **La republication de Monnaie libre** : 4 lignes en file ; rien pour le groupe d'essai.
- **Aucun article en vigueur, aucun vote, aucune autre proposition n'est touché.**
**Répétition faite le 02/11 de 01 h 58 à 02 h 01**, avec le serveur de `3f10a0e`, sur une copie fraîche
de la base de production (santé `4d4adcc2`), sans publication configurée.
| | Avant | Après |
|---|---|---|
| Migrations | 1–8 | 1–8 |
| Propositions | 90 | 90 |
| Les quatre, dans chaque groupe | révision 1, 0 vote, textes du 21/10 | révision 2, nouveaux textes |
| Autres propositions à une révision autre que 1 | I1 ×2 (Raison d'être, M1) | les mêmes |
| Révisions | 92 | 100 |
| Traductions | 144 | 144 (8 réécrites) |
| `seed_amendments` | 2 | 10, toutes « applied » |
| File de publication | 7 | 11 (4 pour Monnaie libre : « proposition de départ réécrite : … ») |
| Votes, signalements, identités | 0, 0, 1 | 0, 0, 1 |
`integrity_check` ok, `foreign_key_check` vide. Un second démarrage ne rejoue rien (10 / 100 / 11).
## Avant — prémisses vivantes
1. La production sert `4d4adcc` : `curl -s https://decision.librodrome.org/api/health` → `"build":"4d4adcc2"`.
2. `git fetch prod && git rev-parse --short prod/main` → `4d4adcc`.
3. La relecture `0919b46+3f10a0e` est inscrite « ok » dans `~/harness/etat/relectures.json`, ainsi
que celles de `4d4adcc` (M4) ; aucune autre relecture de ce code n'est en cours.
4. `git diff 4d4adcc 3f10a0e -- docker/ .woodpecker/ .env data/` → vide.
5. Boucle verte sur `3f10a0e` le 02/11 vers 01 h 50 : 237 tests Rust, svelte-check sans
avertissement, 55 vitest, e2e 35 / 35. Image de production construite le 02/11 à 02 h 02, sortie 0,
avec les tests rejoués dedans.
6. Sur une copie fraîche de la base de production : les quatre propositions `1.0.0` du seed
(F1.2, N1, N1.1, O1) sont à la révision 1 et à 0 vote dans les deux groupes. Si l'une a bougé,
l'amendement la laisse telle quelle, la refuse avec sa raison au journal, et l'acte reste valable.
## Commande exacte
Selon la règle du canon du 02/11, un dossier « déploie » ne liste que la poussée qui met en ligne :
```sh
cd /home/yvv/Documents/PROD/DEV/libreDecision
git push prod 3f10a0e:main
```
## Effet attendu
- Dans les deux groupes, F1.2, N1, N1.1 et O1 montrent les nouveaux textes, en fr, en et es.
- Sur une version retenue par une objection, « Contre » et « Retirer mon vote » ; jamais « Pour ».
- Le dépôt des textes reçoit une publication de Monnaie libre.
Irréversibilité : **partielle**. Le code se remet en arrière. Ce qui reste :
- les nouveaux textes des quatre propositions, à la révision 2, l'ancien texte étant gardé dans
l'historique ;
- le commit publié.
## Vérifications après
- [ ] Santé → `"build":"3f10a0e…"`, les huit premiers caractères.
- [ ] `ssh sonic 'docker logs lpk-libredecision-app 2>&1 | grep -E "seed amendment|published|ERROR|WARN"'`
→ huit « seed amendment applied », une publication, aucune erreur.
- [ ] Redémarrage à froid (`docker compose restart`), puis santé vue de l'extérieur et journaux : aucun
amendement rejoué. L'image n'a ni `wget` ni `curl` : la santé se lit depuis le poste.
- [ ] Sur une copie fraîche : les comptes de la répétition, à la ligne près.
- [ ] À l'écran (Yvv) : Monnaie libre → Acte d'engagement → « Propositions en cours » : Paramètres par
défaut, Réglage de l'inertie, Niveaux d'inertie, Ordonnancement du document.
## Retour
- **Le code** : `docker tag libredecision-app:previous libredecision-app:latest`, puis
`cd /opt/libredecision-app && docker compose up -d --force-recreate`. `previous` sera alors `4d4adcc`,
qui lit les modules ; le risque d'un retour vers `8727315`, décrit dans l'acte M4, ne se pose plus.
- **Les quatre textes** : pas de retour automatique. L'ancien texte reste dans l'historique de chaque
proposition ; un nouvel amendement gardé pourrait le remettre.
- **Le commit publié** : on publierait par-dessus.
## Exécution
Acte exécuté le 02/11/2026, 02h53-03h10, par orch (session libreDecision).
- **Armement** : par Yvv dans cette session, d'avance (« Pousse. Déploie. »). Il voulait relire en
ligne : « Pour que je fasse une review, faut que je vois en ligne ».
- **Relecture** : « 0919b46+3f10a0e : ok », inscrite à 02 h 53, sans bloquant.
Prémisses revérifiées à 02 h 53 :
1. santé `4d4adcc2` ;
2. `prod/main` = `4d4adcc` ;
3. relectures inscrites ;
4. aucune configuration changée ;
5. boucle et image du 02/11 vers 02 h ;
6. sur une copie fraîche, les quatre propositions `1.0.0` à la révision 1 et à 0 vote dans les deux
groupes. La copie a été retirée.
Mise en ligne :
- 02 h 58 : `git push prod 3f10a0e:main` → `4d4adcc..3f10a0e 3f10a0e -> main` ;
- la santé répondait `4d4adcc2`, puis n'a pas répondu à la sonde de 03 h 00 min 32, pendant la
bascule ; à 03 h 04 min 15 : `{"build":"3f10a0e8","ok":true}`.
Journaux du démarrage (02 h 04 UTC) :
- huit lignes « seed amendment applied » (F1.2, N1, N1.1, O1 × essai, Monnaie libre) ;
- `published … commit="unchanged"` : la publication ne rend que les textes en vigueur, comme l'avait
annoncé la relecture ;
- aucune ligne ERROR ni WARN.
Image de retour : `libredecision-app:previous`, construite le 01/11 à 23 h 05 (`4d4adcc`).
Redémarrage à froid (03 h 07) :
- santé `3f10a0e8` vue de l'extérieur, `RestartCount` 0 ;
- aucun amendement rejoué ;
- la reprise a publié un commit `17c4db2`, qui ne change qu'une ligne de `document.json` :
`"electorate": 5708` → `5707`, le nombre de membres relu sur la chaîne. C'est le fonctionnement
prévu, sans rapport avec cet acte.
Invariants sur une copie fraîche, identiques à la répétition :
- migrations `1,…,8` ;
- 90 propositions ; les quatre à la révision 2 dans les deux groupes ; les seules autres révisées
sont I1 ×2 ;
- 100 révisions, 144 traductions, 0 vote, 0 signalement, 1 identité ;
- `seed_amendments` : 10 lignes, toutes « applied » ;
- `integrity_check` ok, `foreign_key_check` vide.
Retirés, parce qu'ils portaient des données réelles : la copie de la prémisse 6, la copie d'après et
la répétition `/tmp/ld-repetition-m5`. Image de contrôle et journal de construction retirés le 02/11
à 02 h 03.
À reprendre au prochain tour, avec la relecture d'Yvv en ligne :
- dans N1.1, G se calcule sur p̃ rapporté à W, pas sur un nombre de votants ;
- un second amendement d'un texte déjà amendé demande d'étendre la garde : la révision 2 et
l'historique initial puis amendement doivent être admis.
@@ -1,249 +0,0 @@
# Acte — Mise en ligne M6 (Propositions seed, quorum par cran, tampon des votes et vote simulé, accueil sous la voûte)
Classe : déploie
Projet : libreDecision Date : 2026-11-03
**État : EXÉCUTÉ (03/11, 06h45-07h04, par orch dev-63 sous mandat ; pipeline 37)** — figé le 03/11/2026 à 06 h 22 sur `94ddb17` (`94ddb175643c8d523a083bbfee3882f22b82f628`) — préparé par
l'intégrateur libredecision-d4 ; exécuté par orch (dev-63) sous son mandat (« déploie » et « pousse », du
03/11 au 08/11). Relecture indépendante : « 94ddb17 : ok », inscrite le 03/11 à 06 h 42 (prémisse 4).
## Intention
Mettre en ligne la revue d'Yvv du 02/11 et ses décisions du 02/11 et du 03/11 (registre) :
- **H** : l'accueil sous la voûte, les trois points sous l'arche, le titre centré en deux lignes
(`a3c0a76`) ;
- **S** : les Propositions seed (I1, E1, E2 ouvertes, au cran 2, ADR 0011), la publication suspendue sans
texte en vigueur, le quorum par cran (ADR 0013, `PUT /units/{id}/quorum`), la licence
`AGPL-3.0-or-later` (`349c8ed`) ;
- **V** : le tampon des votes (ADR 0012), le vote simulé partout tant que la chaîne est fermée, marqué
« simulé », qui compte puis s'efface par l'acte « supprime » ; la publication « pas avant des votes
prouvés » (`94ddb17`).
Hors de M6 : T (les articles du seed qui décrivent encore l'ancien plancher C : F1.1, F1.2, N1.1), puis U
(le lecteur) en M7. Yvv, à 06 h 07 : « J'ai retiré les propositions. tu peux livrer Decision. »
## Décisions d'Yvv que l'acte applique
- Propositions seed au cran 2 ; aucun article en vigueur au départ (02/11).
- Quorum par cran 12 · 12 · 50 · 50 · 50 dans Monnaie libre, 1 à l'essai ; l'auteur le change avant le
premier vote (02/11) ; sous le quorum, les votants manquants comptent comme « non » (03/11).
- Un vote simulé compte, puis s'efface à l'ouverture de la chaîne par un acte « supprime » (02/11).
- Q11 (03/11, « Pas avant des votes prouvés ») : un texte entré en vigueur par des votes simulés est en
vigueur sur le site, mais ne se publie pas. Règle stricte, confirmée par orch le 03/11 à 06 h 11 : rien
ne part tant qu'une entrée simulée ne tient pas sur les seuls votes prouvés.
- Q12 (03/11, « Oui, retire-les ») : les propositions d'inertie I1 → 5 et E1 → 5 retirées avant le
démarrage du nouveau serveur. **Fait par Yvv à l'écran, annoncé à 06 h 07** ; vu sur la copie de
06 h 11 (prémisse 2).
## Ce qui part
Version `94ddb17`, qui contient la version en ligne `3f10a0e` : 51 commits, 84 fichiers. Hors du code
(vérifié le 03/11 à 06 h 22) : rien dans `docker/`, `.woodpecker/`, `.env` ou `data/`, aucun fichier
sensible. Le seed JSON change (bases neuves seulement : un document seedé n'est jamais réinstancié).
## Ce que l'acte écrit dans la base de production (donnée runtime)
- **Migrations additives** `0009` (Propositions seed : `proposals.seed_featured`, la table
`seed_proposal_amendments`), `0010` (tampon : `ballots.mode` défaut `'chain'`, `ballot_lines.outcome`,
l'index `ballots_doc` ; aucune ligne réécrite), `0011` (quorums : `documents.quorums`, `units.quorum`,
la table `quorum_amendments`).
- **Amendement Propositions seed** (`2026-11-02-i1/e1/e2-proposition-seed`), par document et par article,
seulement si personne n'y a touché : la version passe d'en vigueur à ouverte et marquée ;
`units.in_force_id` à NULL ; une ligne `revisions` de type `seed_proposal` ; le cran à 2 avec une ligne
`inertia_changes`, si personne n'a touché au cran ; une ligne `seed_proposal_amendments` ; dans Monnaie
libre, une ligne `outbox`, qui attend.
- **Amendement quorums** (`2026-11-03-quorums-par-cran`), une fois par document : `documents.quorums`
là où il est NULL ; une ligne `quorum_amendments` ; une ligne `outbox` dans Monnaie libre.
- **Le tampon** : la première mise en route n'en écrit rien.
- **La publication** : suspendue (aucun article en vigueur, puis « pas avant des votes prouvés »). Le
dépôt des textes garde son dernier état.
- **Aucun vote, aucune identité, aucune autre proposition n'est touché.**
**Répétition faite le 03/11 de 06 h 21 à 06 h 22**, avec le serveur de `94ddb17` (binaire de la boucle),
sur une copie de la copie de production de 06 h 11 min 12 (santé `3f10a0e8`), publication vers un dépôt nu
local (`m6/repetition.sh`, scratchpad de l'intégrateur ; journal `m6/repetition.log`).
| | Avant | Après |
|---|---|---|
| Migrations | 1–8 | 1–11 |
| Propositions | inertie retirées 3 · ordres en vigueur 16 · textes en vigueur 6 · textes ouverts 64 · titres en vigueur 2 | inertie retirées 3 · ordres en vigueur 16 · textes ouverts 70 · titres en vigueur 2 |
| Unités en vigueur | essai 12 · Monnaie libre 12 | essai 9 · Monnaie libre 9 |
| I1, E1, E2 (deux groupes) | en vigueur, crans 3, 4, 4 | ouvertes, Propositions seed, cran 2, 2, 2 |
| Révisions | 101 | 107 |
| Traductions | 144 | 144 |
| `inertia_changes` | 4 | 10 |
| `seed_proposal_amendments` | absente | 6 lignes, statut et cran « applied » |
| `quorum_amendments` / `documents.quorums` | absentes | 2 lignes « applied » ; Monnaie libre `[12,12,50,50,50]`, essai `[1,1,1,1,1]` |
| File de publication (Monnaie libre) | 0 en attente, 13 faites | 4 en attente, 13 faites |
| Commits poussés vers le dépôt des textes (local) | — | 0 (« publication suspended … waiting=4 ») |
| Votes, bulletins, signalements, identités | 0, 0, 0, 1 | 0, 0, 0, 1 |
`integrity_check` ok, `foreign_key_check` vide, avant et après. Journal : six « seed proposal amendment …
status=applied level=applied to_level=2 », deux « quorums written », « publication suspended », aucune
ligne ERROR ni WARN. **Un second démarrage ne rejoue rien** (mêmes comptes, la file reste à 4, 0 commit).
## Effet attendu
- Les deux groupes : I1, E1 et E2 sont des « Propositions seed », ouvertes, au cran 2 ; aucun article n'est
en vigueur ; les modules (titre du document au cran 4, ordre de chaque section au cran 2) restent en
vigueur.
- Le seuil se lit en quorum : au cran 2, 9 « pour » passent avec 3 « contre » au plus.
- **Le vote simulé s'ouvre dans Monnaie libre dès la mise en ligne** (pas de `LD_VOTE_ACCOUNT`) : toute
personne qui ouvre le groupe y vote, une personne invitée comprise ; le vote compte aussitôt, marqué
« simulé » sous l'article ; 9 « pour » simulés font entrer I1, E1 ou E2 **sur le site**, sans
publication. Effet de « simule le vote à tout endroit » et « il compte, puis s'efface » ; orch le porte à
son rapport (03/11, 06 h 11). À l'ouverture de la chaîne, l'acte « supprime » (`ld-server
discard-simulation`, à blanc, puis `--apply`) écartera ces votes et réexaminera ce qu'ils ont fait entrer.
**Cet acte se jouera serveur arrêté** (relecture : `give_back` écrit sans garde, à côté du serveur).
- **Un lien d'équipe multiplie les voix simulées** (relecture, vu : un lien vers Monnaie libre ouvert
9 fois sous 9 noms donne 9 « pour », et I1 entre sur le site). En production le 03/11 à 06 h 11 :
aucun lien d'équipe ; 2 invitations vers Monnaie libre, dont 1 pas encore ouverte (expire le 05/11).
**À dire à Yvv** : ne pas créer de lien d'équipe vers Monnaie libre pendant la simulation, ou le
borner à quelques ouvertures.
- Après un vote simulé, le message bref dit encore « Vote enregistré. » et le panier « N votes
enregistrés. » ; sous l'article, c'est bien « simulé ». Repris par U (M7).
- L'accueil sous la voûte.
Irréversibilité : **partielle**. Le code se remet en arrière ; les données changées restent.
## Avant — prémisses vivantes
1. La production sert `3f10a0e` : `curl -s https://decision.librodrome.org/api/health` →
`"build":"3f10a0e8"` ; `git fetch prod && git rev-parse --short prod/main` → `3f10a0e`.
2. Sur une copie fraîche de la base (commande ci-dessous) : aucune proposition d'inertie `open` ; 0 vote,
0 bulletin ; I1, E1, E2 en vigueur, aux crans 3, 4, 4, dans les deux groupes. **Lu sur la copie de
06 h 11 min 12** : 3 propositions d'inertie, toutes `withdrawn` (I1 → 5, E1 → 5, et une ancienne sur
l'ordre de l'introduction) ; 0 vote, 0 bulletin, 0 signalement, 1 identité ; I1, E1, E2 en vigueur,
crans 3, 4, 4, dans les deux groupes. À relire sur la seconde copie, juste avant la poussée. Si un de
ces articles a bougé, l'amendement le refuse avec sa raison au journal, et l'acte reste valable.
3. Sur la même copie : 0 vote, donc aucune proposition n'entre au premier balayage.
4. La relecture indépendante de `94ddb17`, inscrite « ok » dans `~/harness/etat/relectures.json` ; aucune
autre relecture de ce code en cours. **Faite** : « 94ddb17 : ok », sous-agent neuf, rendue et inscrite le
03/11 à 06 h 42 (transcript `~/.claude/projects/-home-yvv-Documents-PROD-DEV-libreDecision/d0a7108b-7f2f-484a-bcb0-de339238199d/subagents/agent-ab9f269c254b56f31.jsonl`).
Aucun bloquant. Elle a rejoué la mise en route sur une base faite par `3f10a0e` (écritures exactement
celles annoncées, second démarrage sans rejeu, publication suspendue), sondé la formule sur 25 millions
de cas (une voix seule ne passe jamais, un « contre » n'aide jamais, `yesNeeded` / `noNeeded` exacts),
la route du quorum (bornes, CSRF, auteur, 409 après un vote), les routes de développement (404 sans
`LD_DEV_LOGIN`), la simulation et l'acte « supprime » (à blanc : la base inchangée). Ses constats « à
reprendre » sont traités dans ce dossier (le retour, le lien d'équipe, l'acte « supprime ») ou
renvoyés à U (« Vote enregistré. »).
5. `git diff 3f10a0e 94ddb17 -- docker/ .woodpecker/ .env data/` → vide (vérifié à 06 h 22).
6. Boucle verte sur `94ddb17` le 03/11 de 06 h 16 à 06 h 21 : fmt, clippy `-D warnings`, 286 tests Rust,
svelte-check 0 erreur 0 avertissement, 56 vitest, build, e2e 36 / 36. Image de production construite
le 03/11 à 06 h 25 (`docker build -f docker/app.Dockerfile --target production`, sortie 0, les tests
rejoués dedans : 56 vitest, tests Rust tous « ok ») ; image de contrôle retirée à 06 h 26.
## Copie de la base (prise par orch, lecture seule, aucune écriture côté production)
```sh
set -euo pipefail
d=/tmp/ld-m6-copie; mkdir -m 700 -p "$d"
for f in libredecision.db libredecision.db-wal libredecision.db-shm; do
ssh sonic "docker exec lpk-libredecision-app sh -c 'test -f /data/$f && cat /data/$f'" > "$d/$f" </dev/null || rm -f "$d/$f"
done
chmod 600 "$d"/*
date '+%H:%M:%S'; curl -s https://decision.librodrome.org/api/health; echo
sqlite3 -readonly "$d/libredecision.db" 'PRAGMA integrity_check; PRAGMA foreign_key_check;'
```
Pas un instantané atomique (la base, puis son journal WAL) : `integrity_check` le dirait, et l'on reprend
la copie. Lecture : `m6/counts.sh <db>` (scratchpad de l'intégrateur), en `sqlite3 -readonly`.
## Commande exacte
Selon la règle du canon du 02/11, un dossier « déploie » ne liste que la poussée qui met en ligne :
```sh
cd /home/yvv/Documents/PROD/DEV/libreDecision
git push prod 94ddb175643c8d523a083bbfee3882f22b82f628:main
```
(remote `prod` = `gitea@git.open.us.org:EHV/decision.git` ; Woodpecker `.woodpecker/app.yml`, puis
`maquette.yml`.)
## Vérifications après
- [x] Santé vue de l'extérieur → `"build":"94ddb175"`.
- [x] Journaux : `ssh sonic 'docker logs lpk-libredecision-app 2>&1 | grep -E "amendment|quorums written|publication|ERROR|WARN"'`
→ six « seed proposal amendment … status=applied level=applied », deux « quorums written »,
« publication suspended », aucune erreur.
- [x] Redémarrage à froid (`docker compose restart` dans `/opt/libredecision-app`), santé vue de
l'extérieur, journaux : aucun amendement rejoué.
- [x] Sur une copie fraîche : les comptes « Après » de la répétition, à la ligne près ; `integrity_check`
ok, `foreign_key_check` vide ; copie retirée ensuite.
- [x] Le dépôt des textes n'a reçu aucun commit : `git ls-remote gitea@git.open.us.org:yvv/decision-depot-textes-reference.git main`
→ le même sha qu'avant la poussée (le relever avant).
- [ ] À l'écran (Yvv, à son retour) : l'accueil sous la voûte ; l'Acte d'engagement : les Propositions
seed, aucun article en vigueur ; voter dans Monnaie libre affiche « simulé ».
## Retour
**Avant tout retour vers `3f10a0e`, couper la publication.** `94ddb17` met 4 lignes en file au premier
démarrage (`seed_proposal_amend.rs:419`, `quorum_amend.rs:140`) ; `3f10a0e` ne connaît pas l'attente du
premier texte et publierait aussitôt un document **sans aucun article** (vu par la relecture : commit de
`fr.md`, `en.md`, `es.md` et `document.json` vides, 118 lignes retirées). Le compose prend
`${LD_PUBLISH_REMOTE:-…}` : une valeur vide reprend le défaut, il faut donc retirer la ligne.
```sh
ssh sonic
cd /opt/libredecision-app
cp docker-compose.yml docker-compose.yml.m6-avant-retour
sed -i '/LD_PUBLISH_REMOTE/d' docker-compose.yml
docker tag libredecision-app:previous libredecision-app:latest
docker compose up -d --force-recreate
```
- **Répété le 03/11 à 06 h 45** : le serveur de `3f10a0e`, sans `LD_PUBLISH_REMOTE`, sur la base issue de
la répétition M6 : démarrage sans erreur, aucune publication (dépôt nu local : 0 commit), la file reste
à 4 en attente. Le serveur tolère les migrations qu'il ne connaît pas (`set_ignore_missing(true)`,
`lib.rs:100`). La prochaine mise en ligne réécrit `docker-compose.yml` depuis le dépôt (`app.yml`).
- **Les données** : pas de retour automatique. `3f10a0e` montrerait I1, E1, E2 comme propositions
ouvertes sans la marque, et aucun article en vigueur ; les votes simulés éventuels y compteraient comme
des votes du serveur. Un retour du code au-delà de quelques minutes demande un acte de données à part.
- **Risque connu, le retour automatique du pipeline** : si le nouveau conteneur échoue après avoir
démarré, `.woodpecker/app.yml` relance `previous` (`3f10a0e`) **avec** la publication, qui partirait
vide. La répétition et l'image ne montrent aucun échec de démarrage ; si le pipeline échoue quand même,
regarder aussitôt le dépôt des textes (`git ls-remote … main`) et, s'il a reçu le document vide, couper
la publication comme ci-dessus, puis republier le dernier état par-dessus (acte à part).
- **La publication** : en marche avant, rien ne part (suspendue).
## Exécution
Acte exécuté le 03/11/2026, 06h45-07h04, par orch (session dev-63, e66db8ca), sous mandat
« déploie » (inscrit du 03/11 au 08/11) ; consignes d'Yvv du 03/11 à 06h00 (« livre et déploie tout
ce que tu peux ») et à 06h07 (« tu peux livrer Decision »).
- Dossier relu en entier à 06h45 ; relecture « 94ddb17 : ok » vue inscrite ; aucune autre relecture
de ce code en cours (libredecision-d4, PRÊT M6).
- Prémisse 1, 06h45:51 : santé `{"build":"3f10a0e8","ok":true}`, `prod/main` = `3f10a0e`.
- Seconde copie, 06h46:32 (`/tmp/ld-m6-copie2`, 700/600), santé `3f10a0e8` : `integrity_check` ok,
`foreign_key_check` vide. Prémisses 2 et 3 : la colonne « Avant » à la ligne près ; 3 propositions
d'inertie, toutes `withdrawn` ; 0 vote, 0 bulletin, 0 signalement, 1 identité ; I1, E1, E2 en vigueur,
crans 3, 4, 4, dans les deux groupes ; file 0 en attente, 13 faites.
- Dépôt des textes avant : `main` = `9e0f45a9e8d4af356a0c42daa5f4fb9eb9e6dfb3`. Pipelines : 36
`success` en tête, rien en cours.
- Poussée `git push prod 94ddb175643c8d523a083bbfee3882f22b82f628:main` à 06h46:5x, passée du premier
coup (`3f10a0e..94ddb17`).
- Pipeline n° 37 : `running` à 06h47:06 ; `app` vert (healthcheck `success`, `retablir` sauté) ;
`maquette` verte ; `success` à 07h02:59. L'étape `sbom-publish` échoue dans les deux workflows, comme
au pipeline 36 : écart ancien, sans effet sur le déploiement.
- Après :
- santé `{"build":"94ddb175","ok":true}` ;
- journaux : six « seed proposal amendment … status="applied" level="applied" to_level=2 », deux
« quorums written » (essai `[1, 1, 1, 1, 1]`, Monnaie libre `[12, 12, 50, 50, 50]`),
« publication suspended … waiting=4 », aucune ligne ERROR ni WARN ;
- redémarrage à froid (`docker compose restart` dans `/opt/libredecision-app`) à 07h03:07 :
« listening … build=94ddb175 », « publication suspended … waiting=4 », aucun amendement rejoué ;
santé `94ddb175` ;
- copie fraîche, 07h03:31 (`/tmp/ld-m6-apres`) : la colonne « Après » de la répétition à la ligne
près. Migrations 1–11 ; inertie retirées 3, ordres en vigueur 16, textes ouverts 70, titres en
vigueur 2 ; unités en vigueur 9 et 9 ; I1, E1, E2 ouvertes au cran 2 dans les deux groupes ;
révisions 107 ; traductions 144 ; `inertia_changes` 10 ; 6 `seed_proposal_amendments` « applied » ;
2 `quorum_amendments` « applied » ; file 4 en attente, 13 faites ; 0, 0, 0, 1 ; `integrity_check`
ok, `foreign_key_check` vide ;
- dépôt des textes : `main` = `9e0f45a9e8d4af356a0c42daa5f4fb9eb9e6dfb3`, inchangé : aucun commit.
- `main` local avancé sur `94ddb17` (`git fetch . 94ddb17…:main`, depuis `fa8e8dc`).
- Les trois copies de la base (`/tmp/ld-m6-copie` de 06h11, `/tmp/ld-m6-copie2`, `/tmp/ld-m6-apres`)
retirées à 07h03:43. Les copies de répétition de l'intégrateur sont à retirer par lui.
- Reste à l'écran, pour Yvv à son retour : l'accueil sous la voûte ; l'Acte d'engagement (les
Propositions seed, aucun article en vigueur) ; un vote dans Monnaie libre affiche « simulé ».
- Image de retour : `libredecision-app:previous` (`3f10a0e`), avec la coupure de la publication
d'abord (§ Retour).
-177
View File
@@ -1,177 +0,0 @@
# Acte — Mise en ligne M6b (F1.1, F1.2 et N1.1 disent le quorum par cran)
Classe : déploie
Projet : libreDecision Date : 2026-11-03
**État : EXÉCUTÉ (03/11, 07h56-08h13, par orch dev-63 sous mandat ; pipeline 38)** — figé sur `bb4bdf5` (`bb4bdf58c114fb88ac4e154a7ffe0b06ff359b3e`) le 03/11 à 07 h 55 — préparé
par l'intégrateur libredecision-d4 ; exécuté par orch (dev-63) sous son mandat (« déploie » et « pousse »,
du 03/11 au 08/11). Relecture indépendante : « bb4bdf5 : ok » (prémisse 3).
## Intention
Mettre en ligne le lot T : les trois articles du seed qui décrivaient encore l'ancien plancher C
(F1.1 « Formule du seuil WoT », F1.2 « Paramètres par défaut », N1.1 « Niveaux d'inertie ») disent le
quorum par cran (ADR 0013), en fr, en et es.
**Décision d'orch (dev-63, 03/11 à 07 h 36)** : T part en M6b sans attendre la relecture d'Yvv, avec les
quatre défauts de l'intégrateur (Q13). Raison : le vote simulé est ouvert depuis M6 ; un vote, même
simulé, sur un de ces articles avant la mise en ligne figerait pour de bon le texte faux (l'amendement le
refuserait). Avec T en ligne, un vote figerait au pire un défaut de formulation, qu'un nouvel amendement
gardé corrigera tant qu'aucun vote n'a touché l'article. Les quatre questions vont à Yvv avec
`docs/chantiers/acte-certification/textes-quorum-seed.md`.
Défauts appliqués (Q13) : F1.2 n'annonce pas l'intention de remonter l'inertie ; « Propositions seed »
dans le texte voté ; l'exemple de N1.1 (7 224 membres, 120 votants) gardé comme illustration ; le groupe
d'essai non nommé.
## Ce qui part
Version `bb4bdf5`, qui contient la version en ligne `94ddb17`. Code touché :
`crates/ld-server/src/seed_amend.rs` (l'amendement), `crates/ld-server/src/wot.rs` (`REFRESH` rendu
public pour un test), `seeds/acte-engagement-certification.json` (bases neuves), des tests ; le reste est
documentation. Configuration (`docker/`, `.woodpecker/`, `.env`, `data/`) : rien (vérifié le 03/11 à
07 h 37).
## Ce que l'acte écrit dans la base de production (donnée runtime)
- **Aucune migration.**
- **Amendement de seed gardé** `2026-11-03-f1-1-quorum`, `-f1-2-quorum`, `-n1-1-quorum`, décidé une fois
par document, dans une transaction, seulement si personne n'a touché l'article : 0 vote (simulé
compris), 0 ligne de bulletin, aucune coquille, traduction de membre ni objection, texte d'avant
identique à l'empreinte attendue ; pour F1.2 et N1.1, la révision 2 admise seulement si c'est
l'amendement de M5, et lui seul.
- `proposals` : titre et texte fr, révision + 1 (F1.1 → 2 ; F1.2 et N1.1 → 3) ;
- `revisions` : une ligne `amendment` (l'ancien texte gardé, diff) ;
- `translations` : en et es remplacés ;
- `seed_amendments` : la décision ;
- `outbox` : une ligne par article dans Monnaie libre, qui attend (publication suspendue) ; rien pour
l'essai.
Refus possibles, le texte d'avant reste : `not_a_seed`, `voted_into_force`, `confirmed`, `votes`,
`ballots`, `corrected`, `translation_edited`, `signalled`, `revised`, `text_differs`.
- Les amendements de M5 sur F1.2 et N1.1 sont marqués dépassés : ils ne décident plus rien.
- **Aucun vote, aucune autre proposition, aucun autre article n'est touché ; la publication reste
suspendue.**
## Répétition
**Faite le 03/11 de 07 h 43 à 07 h 44**, avec le serveur de `bb4bdf5` (binaire de la boucle), sur une copie
de la copie de production prise par orch à 07 h 36 min 53 (santé `94ddb175`), publication vers un dépôt nu
local (`m6/repetition.sh` ; journal `m6/repetition-m6b.log`, scratchpad de l'intégrateur).
| | Avant | Après |
|---|---|---|
| F1.1 (deux groupes) | ouverte, seed, révision 1, 0 vote, 0 bulletin | révision 2 |
| F1.2, N1.1 (deux groupes) | ouvertes, seed, révision 2, 0 vote, 0 bulletin | révision 3 |
| Révisions | 107 | 113 |
| Traductions | 144 | 144 (en, es des trois articles remplacés) |
| `seed_amendments` | 10 | 16 |
| File de publication (Monnaie libre) | 4 en attente, 13 faites | 7 en attente, 13 faites |
| Commits vers le dépôt des textes (local) | — | 0 (« publication suspended ») |
| Tout le reste (`m6/counts.sh` : migrations 1–11, propositions, unités en vigueur, I1/E1/E2, `inertia_changes`, amendements de M6, votes, bulletins, signalements, identités) | | identique à la ligne près |
`integrity_check` ok, `foreign_key_check` vide. Journal : six « seed amendment applied »
(`2026-11-03-f1-1-quorum`, `-f1-2-quorum`, `-n1-1-quorum` × essai, Monnaie libre), « publication
suspended », aucune ligne ERROR ni WARN. **Un second démarrage ne rejoue rien** (comptes identiques).
## Effet attendu
- Dans les deux groupes, F1.1, F1.2 et N1.1 montrent les nouveaux textes, en fr, en et es.
- Rien d'autre ne change ; le dépôt des textes reste à `9e0f45a`.
Irréversibilité : **partielle**. Le code se remet en arrière ; les nouveaux textes restent (l'ancien est
dans l'historique de chaque proposition).
## Avant — prémisses vivantes
1. La production sert `94ddb17` : santé `"build":"94ddb175"` ; `prod/main` → `94ddb17`.
2. Sur une copie fraîche, `m6/m6b-premisse.sh <db>` (scratchpad de l'intégrateur, lecture seule) :
6 lignes, toutes `open`, origine `seed`, révision 1 pour F1.1 et 2 pour F1.2 et N1.1, votes,
bulletins, coquilles et objections à 0 ; migration 11. Un article touché est refusé par l'amendement
avec sa raison, et l'acte reste valable pour les autres. **Lu par orch sur la copie de 07 h 36 min 53**
(santé `94ddb175`) : l'attendu exact ; 0 vote et 0 ligne de bulletin en tout. À relire sur la copie
fraîche juste avant la poussée.
3. La relecture indépendante de `bb4bdf5`, inscrite « ok » dans `~/harness/etat/relectures.json`.
**Faite** : sous-agent neuf, sans le contexte de l'intégrateur, sur `94ddb17..bb4bdf5`, rendue le 03/11
à 07 h 53, inscrite à 07 h 55 (transcript
`~/.claude/projects/-home-yvv-Documents-PROD-DEV-libreDecision/d0a7108b-7f2f-484a-bcb0-de339238199d/subagents/agent-ad97443391b59fb37.jsonl`,
dernière ligne « bb4bdf5 : ok »). Ce qu'elle a exécuté :
- une base faite par les vrais binaires `dc2de09`, `8727315`, `4d4adcc`, `3f10a0e`, `94ddb17`, puis le
candidat ; écritures exactement celles annoncées, base entière comparée ;
- un second démarrage et un retour vers `94ddb17` sans rejeu ;
- la garde avec de vrais votes : refus `votes`, `ballots`, `no_open_version`.
Ce qu'elle laisse **à reprendre, dans le texte** (aucun bloquant ; mettre en ligne reste un gain net,
les textes de M5 décrivent encore C = 50) :
- F1.2 « ensuite, il ne change plus » : faux après l'acte « supprime » (les votes simulés effacés ne
laissent pas de trace, le quorum redevient réglable) ;
- F1.1 « presque l'unanimité quand peu de gens votent » : faux aux crans 1 à 3 avec le quorum (à la
production, 64 · 74 · 77 · 85 · 92 % du cran 1 au cran 5 ; 9 « pour » sur 12 suffisent au cran 2).
Mineur : N1.1, première phrase (« un quorum de votants » laisse croire à un minimum) ; base née avant
M5 (pas la production) ; essai sans trace d'un vote direct retiré ; ADR 0013 et 0011 à mettre à jour.
Suite : un amendement gardé qui suit celui-ci (`follows`) peut corriger ces phrases tant que personne
n'a voté sur l'article. Proposé à orch comme lot T2 (M6c).
4. `git diff 94ddb17 bb4bdf5 -- docker/ .woodpecker/ .env data/` → vide (vérifié à 07 h 37).
5. Boucle verte sur le code de `bb4bdf5` (arbre de `33f149b`, documentation seule en plus) le 03/11 de
07 h 36 à 07 h 43 : fmt, clippy `-D warnings`, 294 tests Rust, svelte-check 0 / 0, 56 vitest, build,
e2e 36 / 36. Image de production construite le 03/11 à 07 h 48, sortie 0 (tests Rust rejoués dedans,
tous « ok » ; l'étape web reprise du cache, le web n'ayant pas changé depuis `94ddb17`) ; image de
contrôle retirée à 07 h 48.
## Commande exacte
```sh
cd /home/yvv/Documents/PROD/DEV/libreDecision
git push prod bb4bdf58c114fb88ac4e154a7ffe0b06ff359b3e:main
```
## Vérifications après
- [x] Santé vue de l'extérieur → `"build":"bb4bdf58"`.
- [x] Journaux : six « seed amendment applied » (F1.1, F1.2, N1.1 × essai, Monnaie libre),
« publication suspended », aucune ligne ERROR ni WARN.
- [x] Redémarrage à froid : aucun amendement rejoué.
- [x] Sur une copie fraîche : les trois articles à la nouvelle révision dans les deux groupes ; le reste
identique à la ligne près à la copie d'avant (comptes de `m6/counts.sh`) ; `integrity_check` ok.
- [x] Le dépôt des textes reste à `9e0f45a`.
## Retour
- **Le code** : `docker tag libredecision-app:previous libredecision-app:latest`, puis
`cd /opt/libredecision-app && docker compose up -d --force-recreate`. `previous` sera `94ddb17`, qui
attend aussi le premier texte pour publier : pas de coupure de la publication à faire.
- **Les textes** : pas de retour automatique ; l'ancien texte reste dans l'historique de chaque
proposition, un nouvel amendement gardé pourrait le remettre.
## Exécution
Acte exécuté le 03/11/2026, 07h56-08h13, par orch (session dev-63, e66db8ca), sous mandat
« déploie » (inscrit du 03/11 au 08/11) ; décision d'orch de 07h36 (T sans attendre Yvv).
- Dossier relu en entier à 07h56 ; relecture « bb4bdf5 : ok » vue inscrite (07h55) ; aucune autre
relecture de ce code en cours (PRÊT M6b de libredecision-d4).
- Prémisses, 07h57:04 : santé `{"build":"94ddb175","ok":true}`, `prod/main` = `94ddb17` ;
`git diff 94ddb17 bb4bdf5 -- docker/ .woodpecker/ .env data/` vide ; pipeline 37 `success` en tête.
- Copie fraîche, 07h57:11 (`/tmp/ld-m6b-avant`, 700/600) : `integrity_check` ok, `foreign_key_check`
vide ; `m6b-premisse.sh` : l'attendu exact (6 lignes `open`, `seed`, révisions 1 / 2 / 2, tous les
compteurs à 0 ; 0 vote, 0 bulletin en tout ; migration 11). Dépôt des textes avant : `9e0f45a`.
- Poussée `git push prod bb4bdf58c114fb88ac4e154a7ffe0b06ff359b3e:main` à 07h57:2x, passée du premier
coup (`94ddb17..bb4bdf5`).
- Pipeline n° 38 : `running` à 07h57:34 ; santé `bb4bdf58` à 08h02:03 ; `app` vert (`retablir` sauté) ;
`maquette` verte ; `success` à 08h12:29. `sbom-publish` échoue comme aux pipelines 36 et 37.
- Après :
- journaux : six « seed amendment applied » (`2026-11-03-f1-1-quorum`, `-f1-2-quorum`,
`-n1-1-quorum`, essai et Monnaie libre), « publication suspended … waiting=7 », aucune ligne ERROR
ni WARN ;
- redémarrage à froid à 08h12:44 : « listening … build=bb4bdf58 », « publication suspended …
waiting=7 », aucun amendement rejoué ; santé `bb4bdf58` ;
- copie fraîche, 08h13:02 (`/tmp/ld-m6b-apres`) : F1.1 en révision 2, F1.2 et N1.1 en révision 3,
dans les deux groupes, toujours `open`, 0 vote ; `counts.sh` comparé à la copie d'avant : seuls
changent révisions 107 → 113, `seed_amendments` 10 → 16, file 4 → 7 en attente (13 faites), comme
la répétition ; le reste identique à la ligne près ; `integrity_check` ok, `foreign_key_check`
vide ;
- dépôt des textes : `9e0f45a`, inchangé.
- `main` local avancé sur `bb4bdf5` (`git fetch . bb4bdf5…:main`, depuis `94ddb17`).
- Copies retirées à 08h13:16 : `/tmp/ld-m6b-copie` (07h36:53, fournie à l'intégrateur),
`/tmp/ld-m6b-avant`, `/tmp/ld-m6b-apres`. Les copies de répétition de l'intégrateur sont à retirer
par lui.
- Image de retour : `libredecision-app:previous` (`94ddb17`), sans coupure de la publication (§ Retour).
- Copies de répétition de l'intégrateur retirées le 03/11 à 08 h 14 : `m6/rep` (la copie de la copie de
production de 07 h 36 min 53, avec son dépôt nu local) dans le scratchpad de libredecision-d4 ; aussi
`repetition-t` (base synthétique du lot T, née du seed, 0 identité). Plus aucune base dans ce scratchpad.
-199
View File
@@ -1,199 +0,0 @@
# Acte — Mise en ligne M6c (trois phrases de F1.1, F1.2 et N1.1 écrites justes, lot T2)
Classe : déploie
Projet : libreDecision Date : 2026-11-03
**État : EXÉCUTÉ (03/11, 10h48-11h06, par orch dev-63 sous mandat ; pipeline 40)** — figé sur `84f015a` (`84f015ad58a2e87803dd1100d55f271f014339de`) le 03/11 à 10 h 48 — préparé
par l'intégrateur libredecision-d4 ; exécuté par orch (dev-63) sous son mandat (« déploie » et « pousse »,
du 03/11 au 08/11). Relecture indépendante : « 84f015a : ok » (prémisse 3).
## Intention
Mettre en ligne le lot T2 : les trois phrases que la relecture de M6b (« bb4bdf5 : ok », 03/11) a laissées
inexactes dans les textes du seed réécrits par le lot T, en ligne depuis le 03/11 à 08 h 02 ; et, dans
l'interface, la même imprécision (« presque l'unanimité ») et un espace perdu (« 0 contredont 1 simulé »).
Décision d'orch (dev-63, 03/11 à 08 h 07, puis 09 h 45) : T2 après U, un sous-agent à la fois.
- **F1.1** : « presque l'unanimité quand peu de gens votent » → « cette part est la plus haute au quorum
et baisse quand la participation monte, jusqu'à la majorité M quand tout le monde vote ».
- **F1.2** : « tant que personne n'a voté sur l'article ; ensuite, il ne change plus » → « tant que
l'article ne garde la trace d'aucun vote ». (Le défaut proposé, « compté », se trompait dans l'autre
sens : un vote retiré sur la chaîne n'est plus compté, mais garde la trace et fige le quorum. Le choix
reste à Yvv : `textes-quorum-seed.md`, point 8.)
- **N1.1** : « le groupe demande un quorum de votants, puis une part de « pour » » → « une part de « pour »
parmi les votants […]. Chaque cran a aussi son quorum, qui n'est pas un nombre minimal de votants ».
- Interface : `inertia.explain`, `formula.reading`, `quorum.error.voted` (fr, en, es) ; espaces perdus en
tête d'un bloc Svelte (`VoteFold`, `VersionDetails`, menu).
## Ce qui part
Version `84f015a`, qui contient la version en ligne `b2e597b`. Code touché : `crates/ld-server/src/seed_amend.rs`
(l'amendement), `seeds/acte-engagement-certification.json` (bases neuves), `web/` (chaînes fr, en, es ;
`VoteFold.svelte`, `VersionDetails.svelte`, `+layout.svelte`), des tests ; le reste est documentation.
`crates/ld-engine` : des tests seulement. Configuration (`docker/`, `.woodpecker/`, `.env`, `data/`) et
migrations : rien (prémisse 4).
## Ce que l'acte écrit dans la base de production (donnée runtime)
- **Aucune migration.**
- **Amendement de seed gardé** `2026-11-03-f1-1-phrases`, `-f1-2-phrases`, `-n1-1-phrases`, qui suit
(`follows`) ceux de M6b, décidé une fois par document, dans une transaction, seulement si personne n'a
touché l'article : 0 vote (simulé compris), 0 ligne de bulletin, aucune coquille, traduction de membre ni
objection, texte d'avant identique à l'empreinte posée par M6b ; F1.1 admis à la révision 2, F1.2 et
N1.1 à la révision 3, seulement si c'est l'amendement de M6b, et lui seul, qui l'a écrite.
- `proposals` : texte fr, révision + 1 (F1.1 → 3 ; F1.2 et N1.1 → 4) ;
- `revisions` : une ligne `amendment` ;
- `translations` : en et es remplacés ;
- `seed_amendments` : la décision ;
- `outbox` : une ligne par article dans Monnaie libre, qui attend (publication suspendue) ; rien pour
l'essai.
Refus possibles, le texte d'avant reste : `not_a_seed`, `no_open_version` (une version entrée en
vigueur, par exemple d'une voix dans l'essai), `voted_into_force`, `confirmed`, `votes`, `ballots`,
`corrected`, `translation_edited`, `signalled`, `revised`, `text_differs`, `changed`
(`seed_amend.rs:283`, `:589`).
- Les amendements de M6b sont marqués dépassés : ils ne décident plus rien.
- **Aucun vote, aucune autre proposition, aucun autre article n'est touché ; la publication reste
suspendue.**
## Répétition
**Faite le 03/11 de 10 h 23 à 10 h 24**, avec le serveur de `84f015a` (binaire de la boucle, copié en
`m6c/ld-server-84f015a`), sur une copie de la copie de production prise par orch à 10 h 23 min 10 (santé
`b2e597b6`), publication vers un dépôt nu local (`m6c/repetition.sh` ; journal `m6c/repetition-m6c.log`,
scratchpad de l'intégrateur). Deux démarrages.
| | Avant | Après |
|---|---|---|
| F1.1 (deux groupes) | ouverte, seed, révision 2, 0 vote, 0 bulletin | révision 3 |
| F1.2, N1.1 (deux groupes) | ouvertes, seed, révision 3, 0 vote, 0 bulletin | révision 4 |
| Révisions | 113 | 119 |
| Traductions | 144 | 144 (en, es des trois articles remplacés) |
| `seed_amendments` | 16 | 22 |
| File de publication (Monnaie libre) | 7 en attente, 13 faites | 10 en attente, 13 faites |
| Commits vers le dépôt des textes (local) | — | 0 (« publication suspended ») |
| Tout le reste (`m6c/counts.sh` : migrations 1–11, propositions, unités en vigueur, I1/E1/E2, `inertia_changes`, votes, bulletins, signalements, identités) | | identique |
**La base entière comparée** (`.dump`) : changent exactement les 6 propositions de F1.1, F1.2, N1.1
(identifiants de la prémisse 2), leurs 12 traductions en et es, 6 révisions et 6 décisions ajoutées, 3
lignes de file ajoutées et les 7 lignes en attente reportées (`next_at`), les deux compteurs
(`sqlite_sequence` : révisions 119, file 23). Rien d'autre. `integrity_check` ok, `foreign_key_check`
vide. Journal : six « seed amendment applied » (`2026-11-03-f1-1-phrases`, `-f1-2-phrases`,
`-n1-1-phrases` × essai, Monnaie libre), « publication suspended », aucune ligne ERROR ni WARN. **Un second
démarrage ne rejoue rien** (comptes identiques, aucun « applied »).
## Effet attendu
- Dans les deux groupes, F1.1, F1.2 et N1.1 montrent les phrases corrigées, en fr, en et es ; la note
d'inertie et la lecture de la formule de l'interface disent la même chose ; « 0 contre dont 1 simulé ».
- Rien d'autre ne change ; le dépôt des textes reste à `9e0f45a`.
Irréversibilité : **partielle**. Le code se remet en arrière ; les nouveaux textes restent (l'ancien est
dans l'historique de chaque proposition).
## Avant — prémisses vivantes
1. La production sert `b2e597b` : santé `"build":"b2e597b6"` ; `prod/main` → `b2e597b`.
2. Sur une copie fraîche, `m6c/premisse.sh <db>` (scratchpad de l'intégrateur, lecture seule) : 6 lignes,
toutes `open`, origine `seed`, révision 2 pour F1.1 et 3 pour F1.2 et N1.1, votes, bulletins,
coquilles et objections à 0 ; migration 11. Un article touché est refusé par l'amendement avec sa
raison, et l'acte reste valable pour les autres. **Lu par orch sur la copie de 10 h 23 min 10** (santé
`b2e597b6`), puis par l'intégrateur sur sa copie de cette copie : l'attendu exact ; 0 vote et 0 ligne
de bulletin en tout. À relire sur la copie fraîche juste avant la poussée.
3. La relecture indépendante de `84f015a`, inscrite « ok » dans `~/harness/etat/relectures.json`.
**Faite** : sous-agent neuf, sans le contexte de l'intégrateur, sur `b2e597b..84f015a`, lancée à
10 h 26, rendue à 10 h 46, inscrite à 10 h 47 (transcript
`~/.claude/projects/-home-yvv-Documents-PROD-DEV-libreDecision/d0a7108b-7f2f-484a-bcb0-de339238199d/subagents/agent-a59503237fefa982a.jsonl`,
dernière ligne « 84f015a : ok »). Aucun bloquant, aucun « à reprendre ». Ce qu'elle a exécuté, sur des
copies de la copie de 10 h 23 (supprimées depuis), réseau coupé :
- les écritures, base entière comparée : exactement celles annoncées (colonnes `body`, `revision`,
`updated_at` des 6 propositions ; `body`, `source_hash`, `updated_at` des 12 traductions) ;
- second démarrage, retour vers `b2e597b` (qu'elle a construit) et réavance : rien de rejoué, les
nouveaux textes servis par `b2e597b` ;
- la garde avec de vrais votes par l'API de `b2e597b` et des états forgés : refus `votes`, `ballots`,
`corrected`, `translation_edited`, `signalled`, `no_open_version`, `voted_into_force`, `revised`,
`text_differs` ; appliqué après un vote direct retiré dans l'essai et après « supprime » (limites
écrites) ;
- F1.2, « tant que l'article ne garde la trace d'aucun vote », vrai face à `PUT /units/{id}/quorum`
dans tous les cas (409 sur un vote simulé tenu ou retiré ; 200 après « supprime » et après un vote
direct retiré dans l'essai) ;
- les chiffres recalculés (64,4 · 73,5 · 77,4 · 85,3 · 91,9 % ; 8/12, 9/12, 39/50, 43/50, 46/50 ;
l'exemple de N1.1) ; en et es fidèles au français ;
- 220 tests Rust ciblés, svelte-check 0 / 0, vitest 69 / 69, build, C34 et C37.
Mineurs, **liste d'attente** (pour Yvv le premier, U2 ou un prochain tour de textes les autres) :
- N1.1, « qui n'est pas un nombre minimal de votants » : vrai pour les quorums par cran ; faux pour
un quorum d'auteur bas (Q = 2 ou 3 à tout cran, 6 au cran 3…), où le moteur demande Q « pour » ;
- `settings.reading` (fr, en, es) sans « au-delà du quorum » ;
- vocabulaire anglais mêlé (“yes”/“no”, « against », favor/favour) ;
- deux raisons de refus absentes de ce dossier (`no_open_version`, `changed`) : **ajoutées** ;
- `spaces.test.ts` : un faux positif théorique dans `<pre>`, deux angles morts (absents du code) ;
- commentaire `crates/ld-engine/src/threshold.rs:9-11` (« near-unanimity ») ;
- N1 garde « quand peu de gens votent », que l'interface ne dit plus.
Non vérifié par elle : la production (prémisses 1 et 6), le binaire bit pour bit, l'image, la boucle
complète, un vote prouvé par la chaîne puis retiré.
4. `git diff b2e597b 84f015a -- docker/ .woodpecker/ .env data/ crates/ld-server/migrations/` → vide (vu par
l'intégrateur à 10 h 22 et par orch à 10 h 23).
5. Boucle verte sur `84f015a`, le 03/11 de 10 h 22 à 10 h 26 (journal `m6c/loop.log`), du premier coup :
fmt, clippy `-D warnings`, 301 tests Rust, svelte-check 0 / 0, 69 vitest, build, e2e 40 / 40 (10 h 23
à 10 h 26). Image de production construite le 03/11 de 10 h 26 à 10 h 29, sortie 0 : étage serveur
rejoué (fmt, clippy, 301 tests Rust « ok », 0 échec), étage web rejoué (svelte-check 0 / 0, 69 vitest,
build) ; image de contrôle retirée à 10 h 29 (journal `m6c/docker-m6c.log`).
6. Sur sonic, aucun témoin `/opt/libredecision-app/deploying`.
## Commande exacte
```sh
cd /home/yvv/Documents/PROD/DEV/libreDecision
git push prod 84f015ad58a2e87803dd1100d55f271f014339de:main
```
## Vérifications après
- [x] Santé vue de l'extérieur → `"build":"84f015ad"`.
- [x] Journaux : six « seed amendment applied » (`2026-11-03-f1-1-phrases`, `-f1-2-phrases`,
`-n1-1-phrases` × essai, Monnaie libre), « publication suspended », aucune ligne ERROR ni WARN.
- [x] Redémarrage à froid : aucun amendement rejoué.
- [x] Sur une copie fraîche : F1.1 en révision 3, F1.2 et N1.1 en révision 4, dans les deux groupes ; le
reste identique à la ligne près à la copie d'avant (comptes de `counts.sh`) ; `integrity_check` ok.
- [x] Le dépôt des textes reste à `9e0f45a`.
## Retour
- **Le code** : `docker tag libredecision-app:previous libredecision-app:latest`, puis
`cd /opt/libredecision-app && docker compose up -d --force-recreate`. `previous` sera `b2e597b`, qui
attend aussi le premier texte pour publier : pas de coupure de la publication à faire.
- **Les textes** : pas de retour automatique ; l'ancien texte reste dans l'historique de chaque
proposition, un nouvel amendement gardé pourrait le remettre.
## Exécution
Acte exécuté le 03/11/2026, 10h48-11h06, par orch (session dev-63, e66db8ca), sous mandat
« déploie » (inscrit du 03/11 au 08/11) ; décision d'orch de 08h07 (T2 après U).
- Dossier relu en entier à 10h48 ; relecture « 84f015a : ok » vue inscrite (10h47) ; aucune autre
relecture de ce code en cours (PRÊT M6c).
- Prémisses, 10h48:38 : santé `{"build":"b2e597b6","ok":true}` ; `prod/main` = `b2e597b` ;
`git diff b2e597b 84f015a -- docker/ .woodpecker/ .env data/ crates/ld-server/migrations/` vide ; sur
sonic, aucun témoin `deploying` ; pipeline 39 `success` en tête.
- Copie d'avant, 10h48:46 (`/tmp/ld-m6c-avant`, 700/600) : `integrity_check` ok, `foreign_key_check`
vide ; `premisse.sh` : l'attendu exact (6 lignes `open`, `seed`, révisions 2 / 3 / 3, compteurs à 0 ;
0 vote, 0 bulletin ; migration 11). Dépôt des textes : `9e0f45a`.
- Poussée `git push prod 84f015ad58a2e87803dd1100d55f271f014339de:main` à 10h49:0x, passée du premier
coup (`b2e597b..84f015a`).
- Pipeline n° 40 : `created` à 10h49:12 ; santé `84f015ad` à 10h54:19 ; `app` puis `maquette` verts ;
`success` à 11h05:26.
- Après :
- journaux : six « seed amendment applied » (`2026-11-03-f1-1-phrases`, `-f1-2-phrases`,
`-n1-1-phrases`, essai et Monnaie libre), « publication suspended … waiting=10 », aucune ligne ERROR
ni WARN ;
- redémarrage à froid à 11h05:42 : « listening … build=84f015ad », « publication suspended …
waiting=10 », aucun amendement rejoué ; santé `84f015ad` ;
- copie fraîche, 11h05:59 (`/tmp/ld-m6c-apres`) : F1.1 en révision 3, F1.2 et N1.1 en révision 4,
dans les deux groupes, toujours `open`, 0 vote ; `counts.sh` comparé à la copie d'avant : seuls
changent révisions 113 → 119, `seed_amendments` 16 → 22, file 7 → 10 en attente (13 faites), comme
la répétition ; `integrity_check` ok, `foreign_key_check` vide ;
- dépôt des textes : `9e0f45a`, inchangé.
- `main` local avancé sur `84f015a` (depuis `b2e597b`).
- Copies retirées à 11h06:08 : `/tmp/ld-m6c-copie` (10h23:10, fournie à l'intégrateur),
`/tmp/ld-m6c-avant`, `/tmp/ld-m6c-apres`. Celles de l'intégrateur sont à retirer par lui.
- Image de retour : `libredecision-app:previous` (`b2e597b`), rien à couper.
- Copies de l'intégrateur retirées le 03/11 à 11 h 07 (scratchpad de libredecision-d4) : `m6c/base` et
`m6c/rep` (copie de la copie de production de 10 h 23, avec son dépôt nu local) et les deux `.dump`.
Plus aucune base dans ce scratchpad.
-264
View File
@@ -1,264 +0,0 @@
# Acte — Mise en ligne M7 (le lecteur refait, lot U)
Classe : déploie
Projet : libreDecision Date : 2026-11-03
**État : EXÉCUTÉ (03/11, 09h28-09h44, par orch dev-63 sous mandat ; pipeline 39)** — figé sur `b2e597b` (`b2e597b65356cac14fbffdbf33318d620940b6f3`) le 03/11 à 09 h 28 — préparé
par l'intégrateur libredecision-d4 ; exécuté par orch (dev-63) sous son mandat (« déploie » et « pousse »,
du 03/11 au 08/11). Relecture indépendante : « b2e597b : à reprendre » (le dossier), dossier corrigé, puis
« b2e597b : ok » (prémisse 3).
## Intention
Mettre en ligne le lot U, la réponse à la revue d'Yvv du 02/11 (registre, revue en ligne de M5) :
« Améliore l'ux : la page est trop longue, trop fournie et on ne comprends pas tout de suite. » ;
l'ordonnancement « en fin de section, qui ajoute des "poignées" aux modules de la section. Et permette de
voter. » ; « distingue avec habileté les différentes nature des modules » ; pas de redondance entre
« consoles locales » et celles du bas ; « la distinction entre un déplier façon toggle et un menu qui
ouvre à droite doit être pertinente » ; « Simule le vote à tout endroit. ». Avec l'ajout du 02/11 à
06 h 26 : « combien il faut de oui pour passer et combien de non pour maintenir l'opposition ».
## Ce qui part
Version `b2e597b`, qui contient la version en ligne `bb4bdf5`. **L'interface seule** : `web/` (lecteur,
composants `OrderLine`, `Reorder`, `QuorumSetting`, `Crans`, chaînes fr, en, es, contrôles C35 à C37) ;
le reste est documentation (`CLAUDE.md`, `HANDOFF.md`, actes, ADR, `CONTROLES.md`, `UX-lecteur.md`,
tableau). `git diff bb4bdf5 b2e597b -- crates seeds docker .woodpecker .env data` → **vide** (vu par
l'intégrateur et par orch le 03/11 à 08 h 49) : le serveur est celui qui est en ligne.
## Ce que l'acte écrit dans la base de production (donnée runtime)
**Rien.** Aucune migration, aucun amendement, aucun code serveur nouveau. Au démarrage, comme à chaque
démarrage depuis M6, la file de publication suspendue reporte ses 7 lignes (`next_at`).
## Répétition
**Faite le 03/11 de 08 h 56 à 08 h 57**, avec le serveur de `b2e597b` (binaire de la boucle, copié en
`m7/ld-server-b2e597b`), sur une copie de la copie de production prise par orch à 08 h 49 min 02 (santé
`bb4bdf58`), publication vers un dépôt nu local (`m7/repetition.sh` ; journal `m7/repetition-m7.log`,
scratchpad de l'intégrateur). Deux démarrages.
- `m7/counts.sh` avant, après le premier démarrage, après le second : **identiques** (migrations 1–11,
1 identité, 0 vote, 0 bulletin, 0 signalement, propositions `inertia/withdrawn:3 order/in_force:16
text/open:70 title/in_force:2`, 113 révisions, 144 traductions, `seed_amendments` 16, quorums par cran,
I1, E1, E2 au cran 2 sans texte en vigueur, file 7 en attente et 13 faites).
- **La base entière comparée** (`.dump`, 868 lignes avant et après) : seules changent les 7 lignes de la
file en attente, par leur `next_at` (la publication suspendue les reporte à chaque démarrage, comme
depuis M6).
- Journal : « publication suspended … waiting=7 », aucun amendement, aucune ligne ERROR ni WARN ; 0 commit
vers le dépôt des textes.
## Recette écran
**Faite le 03/11 de 09 h 00 à 09 h 03**, sur deux autres copies de la copie de 08 h 49 : l'interface de
`b2e597b` (« après ») et celle de `bb4bdf5`, en ligne (« avant », construite dans une copie de travail
jetable), servies par le même serveur, connexion de développement (identités `dev:` écrites dans ces
copies seulement). Bureau 1360 × 900 et téléphone (Pixel 7), `fr-FR`. Script `m7/recette.mjs`, rapports
`m7/shots/avant-rapport.json` et `m7/shots/apres-rapport.json` : 38 étapes, 0 échec. Captures, toutes dans
`/tmp/claude-1001/-home-yvv-Documents-PROD-DEV-libreDecision/d0a7108b-7f2f-484a-bcb0-de339238199d/scratchpad/m7/shots/`,
regardées une à une.
La revue d'Yvv du 02/11, ligne par ligne :
1. **« la page est trop longue, trop fournie et on ne comprends pas tout de suite »**
- Hauteur de la page d'un membre, Monnaie libre, mêmes données : **6 079 → 2 529 px** sur bureau,
**6 744 → 3 365 px** sur téléphone.
- Premier écran : avant, « Aucun article n'est en vigueur. Les propositions ci-dessous remontent… »
puis les blocs « Ordre des articles » ; après, « Aucun article n'est encore en vigueur. Commence par
les Propositions seed… », puis Raison d'être avec la marque « Proposition seed », Unicité du compte,
Réciprocité, puis « Propositions en cours » repliées par section (« Tout déplier », « Voter cette
section »).
- Captures `avant-*-membre-ml-ecran.png` et `-page.png`, `apres-*-membre-ml-ecran.png` et `-page.png`,
`geste-*-3a-propositions-repliees.png`, `geste-*-3b-propositions-depliees.png`.
2. **« la répétition de "ordonnancement" est nulle. […] en fin de section, qui ajoute des "poignées" aux
modules de la section. Et permette de voter. »**
- Avant : sept blocs « Ordre des articles · <section> » en tête (Introduction à Inertie), chacun
répétant la liste de ses articles. Après : aucun ; une ligne « Ordre de la section » au bout de chaque section.
- « Réordonner » sur Engagements techniques : 11 articles, 11 poignées, sur place (le panneau reste
fermé) ; un article monté, « Proposer cet ordre », le vote se déplie à la ligne, « Pour » → « Vote
simulé. », la ligne dit « 1 ordre proposé ».
- Captures `geste-*-5a-ligne-ordre.png`, `-5b-poignees.png`, `-5c-ordre-vote-simule.png`.
3. **« distingue avec habileté les différentes nature des modules : les textes ; l'inertie ;
l'odonnancement »**
- Texte : la carte de l'article. Inertie : les crans dessinés (cinq barres, « basse ») sur la carte, et
« Inertie · 2 / 5 · basse » dans le vote déplié. Ordre : l'icône poignée et « Ordre de la section ».
Titre du document : « Titre du document · seed a minima · 0 vote », crans « inertie 4 / 5 · haute »,
« Le vote », « Proposer un autre titre », sous le titre.
- Captures `apres-*-ecran.png`, `geste-*-6-titre.png`.
4. **« vérifie qu'il n'y a pas de redondance entre les "consoles locales" et celles du bas »**
- Vote déplié et panneau ouvert : 15 titres visibles, **0 doublon** (étape 8, bureau et téléphone).
Le panneau n'a plus d'onglets, il porte un titre : « Panier de vote », « Réglages de la formule ».
- Captures `geste-*-7a-panneau-panier.png`, `geste-*-9-panneau-reglages.png`.
5. **« la distinction entre un déplier façon toggle et un menu qui ouvre à droite doit être
pertinente »**
- La règle (`UX-lecteur.md`) : sur place, ce qui se lit et se décide sur un article (le vote, l'ordre
de sa section, le titre) ; à droite, ce qui travaille sur plusieurs articles ou demande de la place
(panier, réglages, détails, proposer). Vue : vote d'une proposition depuis la liste et vote d'un
ordre, panneau fermé (étapes 4 et 5) ; « Voter en lot » et « Réglages de la formule » ouvrent le
panneau.
6. **« Simule le vote à tout endroit. »** et le tampon (« je vois ce que j'ai fait sans attendre »)
- Monnaie libre (chaîne fermée) : sur une Proposition seed, une proposition de la liste, un ordre,
chaque « Pour » donne « Vote simulé. » ; aussitôt « Ton vote : pour · simulé », « 1 pour · 0 contre
dont 1 simulé », la note « tant qu'une décision humaine n'efface pas les votes simulés » ; la marque
« vote simulé » près de « Voter en lot ». « Vote enregistré. » : jamais vu.
- Groupe d'essai (vote sur le serveur) : le vote est enregistré, pas simulé, comme il se doit.
- Captures `geste-*-1-seed-vote-deplie.png`, `-2-seed-vote-simule.png`, `-4-proposition-vote-simule.png`,
`-11-essai-vote.png`. Les captures 4 et 5c de la première passe avaient été prises pendant la requête
(boutons grisés, « Tu n'as pas voté ») : **reprises le 03/11 à 09 h 22-09 h 23** sur une nouvelle
copie, après la réponse du serveur (« Ton vote : pour · simulé », « 1 pour · 0 contre dont 1
simulé », panneau fermé ; `m7/recette-reprise.mjs`, `shots/reprise-rapport.json`, 6 étapes, 0 échec).
La reprise de 5c porte sur l'ordre d'une autre section, **Annexes** (8 articles), pas sur
Engagements techniques.
7. **L'ajout du 02/11 à 06 h 26, « combien il faut de oui pour passer et combien de non pour maintenir
l'opposition »**
- Avant tout vote : « 9 oui sur 12 votants pour passer » (Propositions seed, cran 2), le quorum dans
l'infobulle (« Quorum : 12 votants. En dessous, les votants qui manquent comptent comme « non ». ») ;
dans la liste, « 9 oui sur 12 » et « 39 oui sur 50 » (cran 3). Après un vote : « 9 oui pour passer ·
le quorum la bloque », « Il manque 8 voix pour. ». Essai : « 1 oui sur 1 votant pour passer ».
- **Le quorum de l'auteur** (corrigé après la relecture, voir prémisse 3) : la seule identité de la
production est **administratrice** (une invitation). Or l'administrateur fixe le quorum d'un texte du
seed tant que personne n'y a voté (décision d'Yvv du 02/11, registre : « L'auteur, avant le 1er vote
», un administrateur pour les textes du seed ; `trustin_access::sets_quorum`, ADR 0013). Sur les
données de production, `canSetQuorum` est donc **vrai sur les 33 articles pour l'administrateur**
(vu par le relecteur sur une copie fraîche ; 32 sur ma copie de reprise, où un vote de test venait
d'en fermer un) et faux pour un membre (étape 10). Après M7, chaque vote déplié lui montre « Quorum ·
12 votants », un champ et « Fixer ce quorum » : **un geste qui écrit** dans la base de production
(`units.quorum`, `quorum_changes`). La route est ouverte depuis M6 ; M7 la rend visible. Captures
`geste-bureau-12-admin-quorum.png`, `geste-telephone-12-admin-quorum.png` (reprise du 03/11 à 09 h 23,
le bouton jamais cliqué). L'auteur d'un nouvel article : prouvé par C37 sur une base de test.
Vu en passant, **pas du lot U, déjà en ligne** :
- « 0 contre » collé à « dont 1 simulé » (« 0 contredont 1 simulé ») : l'espace avant la marque se perd
(`VoteFold.svelte:163`, lot V, `d57326d`, en ligne depuis M6) → lot T2 ;
- la note d'inertie du vote déplié, « plus il faut de voix pour changer ce texte quand peu de gens
votent », a l'imprécision relevée sur F1.1 → lot T2 ;
- le bas du menu latéral (langues, ambiances) touche le bord droit : liste d'attente (vu par H).
- Le serveur de recette signale une fois « web of trust size: no node nor indexer answered, keeping the
last value » : pas de réseau vers les nœuds dans la recette.
## Effet attendu
- Le lecteur d'un document change pour tout le monde (les deux groupes, fr, en, es) ; les données, les
votes, les textes et la publication ne changent pas à la mise en ligne.
- **Un geste d'écriture apparaît pour l'administrateur** (Yvv) : sur chacun des 33 articles, tant que
personne n'y a voté, le réglage du quorum dans le vote déplié (« Fixer ce quorum », « Revenir au
défaut »). Il écrit `units.quorum` et `quorum_changes` quand on s'en sert. Voulu (décision d'Yvv du
02/11) ; à lui dire.
- Le dépôt des textes reste à `9e0f45a`.
Irréversibilité : **aucune** côté données ; le code se remet en arrière.
## Avant — prémisses vivantes
1. La production sert `bb4bdf5` : santé `"build":"bb4bdf58"` ; `prod/main` → `bb4bdf5`.
2. `git diff bb4bdf5 b2e597b -- crates seeds docker .woodpecker .env data` → vide.
3. La relecture indépendante de `b2e597b`, inscrite « ok » dans `~/harness/etat/relectures.json`.
**Première relecture** (sous-agent neuf, lancée à 09 h 05, rendue à 09 h 22) : « b2e597b : à reprendre
», **le dossier, pas le code** (« Le code peut partir tel quel »). Aucun bloquant.
- À reprendre : le dossier disait que personne ne règle le quorum sur les données de production ;
l'administrateur, seule identité, le peut sur les 33 articles. **Corrigé** (recette, point 7 ; Effet
attendu), captures de l'administrateur jointes.
- Ce qu'elle a vérifié de son côté : `needOf` reprend `yesNeeded` / `noNeeded` du serveur sans rien
recalculer ; réglage du quorum seulement si `canSetQuorum` ; le vote simulé jamais dit « enregistré »
(« Voter cette section » sur Conclusion : « 3 votes simulés. ») ; objections, coquilles, panier,
chaîne simulée, réglages tenus ; anciennes adresses `?onglet=` ; en et es sur téléphone sans reste de
français ni défilement ; ambiances sombres, mouvement réduit ; boucle web 0 / 0, 64 / 64, build ; e2e
38 / 40 puis 40 / 40 (au premier passage, C1 et C2 ont échoué sur leurs assertions de la page
d'accueil, au bout de 5 s, l'instantané montrant une autre page avec une session ouverte : **cause non
établie** ; `reader.spec.ts` seul 15 / 15, puis la suite complète 40 / 40) ; sa
propre répétition (`.dump` : seules les 7 dates de reprise de la file) ; le retour.
- Mineurs, **liste d'attente** (lot U2 proposé) : deux captures prises trop tôt (reprises) ; cibles de
36 px sur téléphone (« Tout déplier » `DeepList.svelte:225`, « Autres versions » `VoteFold.svelte:345`,
« Fixer ce quorum » `QuorumSetting.svelte:64`) ; « Si tout pa » coupé sur téléphone, sans indice de
défilement (`+page.svelte:525`) ; « Proposer cet ordre » éteint sans dire pourquoi
(`Reorder.svelte:196`, `order.edit.unchanged` jamais affiché) ; redites dans le vote déplié
(l'inertie deux fois, la ligne d'une Proposition seed au-dessus de son vote, l'autre version deux
fois) ; « N propositions en cours » compte les ordres ; un changement d'inertie proposé sur un ordre
ou le titre absent de « Propositions en cours » (aucun ouvert en production).
- Non vérifié par elle : la production (dont l'absence du marqueur `deploying`), la chaîne réelle, le
doigt sur un vrai téléphone, un lecteur d'écran, Firefox et Safari. Ses copies de la base de
production sont supprimées.
**Revérification du dossier corrigé** (`0953237`, même relecteur, rendue à 09 h 27) : « Le dossier dit
maintenant vrai, et le code de `b2e597b` n'a pas changé » ; la décision citée existe ; la route est
dans `bb4bdf5` (`crates/ld-server/src/lib.rs:185`) ; les captures reprises montrent ce que le dossier
en dit. Deux précisions faites (la section de la reprise 5c ; la cause de C1 et C2 non établie).
**« b2e597b : ok »**, inscrite le 03/11 à 09 h 28 (transcript
`~/.claude/projects/-home-yvv-Documents-PROD-DEV-libreDecision/d0a7108b-7f2f-484a-bcb0-de339238199d/subagents/agent-a2784cea7fe56b925.jsonl`,
dernière ligne « b2e597b : ok »).
Avant la poussée, vérifier sur sonic qu'aucun marqueur `/opt/libredecision-app/deploying` ne traîne
(sinon le pipeline ne garde pas l'image en service sous `previous`, `.woodpecker/app.yml:106-108`).
4. Boucle verte sur `b2e597b`, le 03/11 (journaux `m6/loop-m7.log`, `m7/e2e-c25.log`, `m7/e2e-all.log`) :
- de 08 h 48 à 08 h 55 : fmt, clippy `-D warnings`, 294 tests Rust, svelte-check 0 / 0, 64 vitest,
build ; e2e 39 / 40, **C25 (chaîne Ğ1 et téléphone) hors délai à 1,5 min**, la machine à une charge
de 11 à 12 (les sessions HCO) ;
- à 08 h 55, `modules.spec.ts` seul : 4 / 4, ce C25 en 7,3 s ;
- de 08 h 56 à 08 h 59, **la suite e2e complète : 40 / 40**.
Image de production construite le 03/11 de 09 h 00 à 09 h 01, sortie 0 : l'étage serveur repris du
cache (code inchangé depuis `bb4bdf5`, dont l'image a rejoué les tests Rust à 07 h 48) ; l'étage web
rejoué (svelte-check 0 / 0, 64 vitest, build). Image de contrôle retirée à 09 h 01.
5. Sur sonic, aucun témoin `/opt/libredecision-app/deploying` (`test ! -e …`) : sinon le pipeline ne
garde pas l'image en service sous `previous` (`.woodpecker/app.yml:105-108`) et le retour ne
ramènerait pas `bb4bdf5`.
## Commande exacte
```sh
cd /home/yvv/Documents/PROD/DEV/libreDecision
git push prod b2e597b65356cac14fbffdbf33318d620940b6f3:main
```
## Vérifications après
- [x] Santé vue de l'extérieur → `"build":"b2e597b6"`.
- [x] Journaux : « publication suspended … waiting=7 », aucune ligne ERROR ni WARN, aucun amendement.
- [x] La page d'un document, vue de l'extérieur, sert la nouvelle interface (le paquet JS contient
« Réordonner » et « oui pour passer »).
- [x] Sur une copie fraîche : comptes de `m6/counts.sh` identiques à la copie d'avant ; `integrity_check`
ok.
- [x] Le dépôt des textes reste à `9e0f45a`.
## Retour
`docker tag libredecision-app:previous libredecision-app:latest`, puis
`cd /opt/libredecision-app && docker compose up -d --force-recreate`. `previous` sera `bb4bdf5`, le même
serveur, qui attend aussi le premier texte pour publier : rien à couper, aucune donnée à reprendre.
## Exécution
Acte exécuté le 03/11/2026, 09h28-09h44, par orch (session dev-63, e66db8ca), sous mandat
« déploie » (inscrit du 03/11 au 08/11) ; « Pousse. Déploie. » d'Yvv du 02/11 pour les lots de sa revue,
après relecture indépendante ; consigne du 03/11 à 06h00.
- Dossier relu en entier à 09h28 ; relectures inscrites : « b2e597b : à reprendre » (09h22, le dossier)
puis « b2e597b : ok » (09h28, même relecteur, le dossier corrigé, le code inchangé) ; aucune autre
relecture de ce code en cours (PRÊT M7).
- Prémisses, 09h28:18 : santé `{"build":"bb4bdf58","ok":true}` ; `prod/main` = `bb4bdf5` ;
`git diff bb4bdf5 b2e597b -- crates seeds docker .woodpecker .env data` vide ; sur sonic, aucun témoin
`/opt/libredecision-app/deploying` ; images `latest` (08h01) et `previous` (06h51) ; pipeline 38
`success` en tête.
- Copie d'avant, 09h28:27 (`/tmp/ld-m7-avant`, 700/600) : `integrity_check` ok, `foreign_key_check`
vide ; 1 identité, 0 vote, 113 révisions, `seed_amendments` 16, file 7 en attente et 13 faites. Dépôt
des textes : `9e0f45a`.
- Poussée `git push prod b2e597b65356cac14fbffdbf33318d620940b6f3:main` à 09h28:4x, passée du premier
coup (`bb4bdf5..b2e597b`).
- Pipeline n° 39 : `running` à 09h28:54 ; santé `b2e597b6` à 09h30:38 ; `app` puis `maquette` verts ;
`success` à 09h42:01.
- Après :
- journaux : « listening … build=b2e597b6 », « publication suspended … waiting=7 », aucun amendement,
aucune ligne ERROR ni WARN ;
- redémarrage à froid à 09h42:09 : mêmes deux lignes, rien d'autre ; santé `b2e597b6` ;
- vu de l'extérieur, 09h43:40 : les paquets servis (19 nœuds, 40 morceaux, lus par `curl -4`)
contiennent « Réordonner », « oui pour passer » et « Fixer ce quorum » ;
- copie fraîche, 09h42:30 (`/tmp/ld-m7-apres`) : `counts.sh` identique à la copie d'avant, ligne à
ligne ; `integrity_check` ok, `foreign_key_check` vide ;
- dépôt des textes : `9e0f45a`, inchangé.
- `main` local avancé sur `b2e597b` (depuis `bb4bdf5`).
- Copies retirées à 09h42:46 : `/tmp/ld-m7-copie` (08h49:02, fournie à l'intégrateur),
`/tmp/ld-m7-avant`, `/tmp/ld-m7-apres`. Celles de l'intégrateur (`m7/base`, `rep`, recette) sont à
retirer par lui.
- Image de retour : `libredecision-app:previous` (`bb4bdf5`), rien à couper.
- À dire à Yvv : le réglage du quorum (« Fixer ce quorum ») est visible pour lui, administrateur, sur les
33 articles tant que personne n'y a voté ; il écrit en base quand on s'en sert.
- Copies de l'intégrateur retirées le 03/11 à 09 h 45 (scratchpad de libredecision-d4) : `m7/base`,
`m7/rep` (répétition), `m7/recette-avant`, `m7/recette-apres`, `m7/recette-reprise` (recette écran) et
les deux `.dump` ; la copie de travail jetable de `bb4bdf5` (interface d'avant). Plus aucune base dans ce
scratchpad. Restent les captures et les scripts (`m7/shots/`, aucune donnée de la base).
-182
View File
@@ -1,182 +0,0 @@
# Acte — Mise en ligne M7b (les mineurs du lecteur, lot U2)
Classe : déploie
Projet : libreDecision Date : 2026-11-03
**État : EXÉCUTÉ** le 03/11 de 12 h 07 à 12 h 26 — **FIGÉ** sur `6f6197c` (`6f6197cc231837df55d6b3e1986f61be5d708a1f`) le 03/11 à 12 h 07 — préparé
par l'intégrateur libredecision-d4 ; exécuté par orch (dev-63) sous son mandat (« déploie » et « pousse »,
du 03/11 au 08/11). Relecture indépendante : « 6f6197c : ok » (prémisse 3).
## Intention
Mettre en ligne le lot U2 : les mineurs d'interface des relectures de M7 (`b2e597b`) et de M6c
(`84f015a`), défaut retenu par orch le 03/11 à 09 h 45 (U2 après T2).
1. Cibles d'au moins 44 px sur téléphone : « Tout déplier », « Autres versions », « Fixer ce quorum », et
tous les gestes du vote déplié et du réordonnancement.
2. Les trois lectures (« En vigueur », « Si les bien cotées passent », « Si tout passe ») passent à la
ligne sur téléphone au lieu de cacher « Si tout passe ».
3. « Proposer cet ordre » éteint dit pourquoi : « C'est l'ordre en vigueur : déplace au moins un
article. »
4. Une information à un seul endroit dans le vote déplié : l'inertie dite une fois (ses crans dans
« Inertie ») ; la ligne d'une Proposition seed s'efface sous son vote déplié ; l'autre version d'un
article n'est plus redite dans « Autres versions ».
5. L'en-tête compte ce que montre la liste : « 0 article en vigueur · 3 Propositions seed · 32
propositions en cours ».
6. Un changement d'inertie proposé sur un ordre ou sur le titre a sa pastille sur la ligne du module.
7. `settings.reading` dit « au-delà du quorum » (fr, en, es) ; l'anglais en un seul vocabulaire (« for »,
« against », « favour ») ; le commentaire de `crates/ld-engine/src/threshold.rs` écrit juste, et un test.
## Ce qui part
Version `6f6197c`, qui contient la version en ligne `84f015a`. **L'interface**, plus un commentaire et un
test dans `crates/ld-engine` ; le reste est documentation (`CONTROLES.md`, `UX-lecteur.md`, `CLAUDE.md`,
actes, tableau, HANDOFF). `git diff 84f015a 6f6197c -- crates/ld-server crates/trustin-access seeds docker
.woodpecker .env data` → **vide** (vu par l'intégrateur à 11 h 39 et par orch à 11 h 40) : le serveur est
celui qui est en ligne.
## Ce que l'acte écrit dans la base de production (donnée runtime)
**Rien.** Aucune migration, aucun amendement, aucun code serveur nouveau. Au démarrage, comme à chaque
démarrage depuis M6, la file de publication suspendue reporte ses lignes en attente (`next_at`).
## Répétition
**Faite le 03/11 de 11 h 43 à 11 h 44**, avec le serveur de `6f6197c` (binaire de la boucle, copié en
`m7b/ld-server-6f6197c`), sur une copie de la copie de production prise par orch à 11 h 40 min 27 (santé
`84f015ad`), publication vers un dépôt nu local (`m7b/repetition.sh` ; journal `m7b/repetition-m7b.log`,
scratchpad de l'intégrateur). Deux démarrages.
- `m7b/counts.sh` avant et après le second démarrage : **identiques**.
- **La base entière comparée** (`.dump`) : seules changent les 10 lignes de la file en attente, par leur
`next_at`.
- Journal : « publication suspended », aucun amendement, aucune ligne ERROR ni WARN ; 0 commit vers le
dépôt des textes.
## Recette écran
**Faite le 03/11 de 11 h 45 à 11 h 50**, sur d'autres copies de la copie de 11 h 40, interface de
`6f6197c`, connexion de développement (identités `dev:` dans ces copies seulement), bureau 1360 × 900 et
téléphone (Pixel 7), `fr-FR`, vue membre et vue administrateur. Scripts `m7b/recette.mjs` et
`m7b/probe.mjs` ; rapport `m7b/shots/rapport.json` ; captures dans
`/tmp/claude-1001/-home-yvv-Documents-PROD-DEV-libreDecision/d0a7108b-7f2f-484a-bcb0-de339238199d/scratchpad/m7b/shots/`,
regardées. `recette.mjs` : 12 étapes, **4 en échec** par délai dépassé (« 2 les trois lectures » et
« 1-4b » sur bureau et téléphone) : mes sélecteurs, pas l'interface (les lectures sont des `radio`, pas
des `button` ; une ligne à deux versions s'ouvre d'abord sur ses variantes). Ces mesures ont été refaites
par `probe.mjs` (sortie dans le transcript de l'intégrateur, non conservée en fichier) : téléphone, rangée
des lectures 370 / 370, « Tout déplier » 44 px, vote de la variante 1.0.0 sans « Autres versions » ni
0.4.0, gestes du vote 44 px ; bureau, rangée 412 / 412, « Tout déplier » 36 px. Le relecteur les a
refaites de son côté, vraies (prémisse 3).
| Point | Mesuré sur les données de production | Captures |
|---|---|---|
| 1 | Téléphone : « Tout déplier » 44 px ; dans le vote déplié, Pour, Contre, « Réglages de la formule », « Coquilles… » 44 px ; « Proposer cet ordre » 44 px ; administrateur : « Fixer ce quorum » 44 px, le champ 44 px. (Bureau : 36 à 39 px, la règle vaut pour le téléphone.) | `telephone-1c-admin-quorum`, `telephone-4b-deux-versions`, `telephone-3-ordre-inchange` |
| 2 | Téléphone : la rangée des lectures tient (370 px de contenu pour 370 visibles ; avant M7b, 412 pour 370 selon la relecture de M7, non remesuré ici), « Si tout passe » entier sur sa ligne ; la page ne déborde pas. | `telephone-5-entete` |
| 3 | « Proposer cet ordre » éteint, et la phrase « C'est l'ordre en vigueur : déplace au moins un article. » visible (bureau et téléphone). | `*-3-ordre-inchange` |
| 4 | Vote déplié de Raison d'être : « Inertie · ▮▮▯▯▯ 2 / 5 · basse » une fois (l'autre « 2 / 5 » est la ligne d'historique) ; la ligne « 0 pour · 0 contre · 9 oui sur 12 » n'est plus redite au-dessus. Rencontre physique (deux variantes) : le vote de la variante 1.0.0 ne redit pas l'autre (0 « Autres versions », 0 mention de 0.4.0). | `*-4a-seed-deplie`, `*-4b-deux-versions` |
| 5 | « 0 article en vigueur · 3 Propositions seed · 32 propositions en cours » dans la lecture « En vigueur » (avant : « 35 propositions en cours » ; la production n'a aucun ordre proposé ouvert, l'écart de 35 à 32 est celui des 3 Propositions seed, comptées à part). | `*-5-entete` |
| 6 | Aucun changement d'inertie de module ouvert en production : prouvé par C38 sur une base de test, pas vu sur ces données. | — |
Vu en passant, **pas du lot U2, déjà en ligne, en liste d'attente** :
- sur bureau, le bas du menu latéral (langues, ambiances, « Se déconnecter ») déborde au bord droit
quand le nom affiché est long (« Recette U2 bur… » : la quatrième pastille et « Se déconnecter »
coupés ; « Sonde bur… » : tout tient) ;
- sur téléphone, le sceau fixe chevauche « Réordonner » en bas à droite.
Aussi : la carte du document sur la page du groupe compte toujours le total du serveur (35, Propositions
seed comprises) ; changer ce compte toucherait le serveur (rapport de U2).
## Effet attendu
- Le lecteur change pour tout le monde sur ces sept points ; les données, les votes, les textes et la
publication ne changent pas.
- Le dépôt des textes reste à `9e0f45a`.
Irréversibilité : **aucune** côté données ; le code se remet en arrière.
## Avant — prémisses vivantes
1. La production sert `84f015a` : santé `"build":"84f015ad"` ; `prod/main` → `84f015a`.
2. `git diff 84f015a 6f6197c -- crates/ld-server crates/trustin-access seeds docker .woodpecker .env data` →
vide.
3. La relecture indépendante de `6f6197c`, inscrite « ok » dans `~/harness/etat/relectures.json`.
**Faite** : sous-agent neuf, sans le contexte de l'intégrateur, sur `84f015a..6f6197c`, lancée à
11 h 51, rendue à 12 h 05, inscrite à 12 h 06 (transcript
`~/.claude/projects/-home-yvv-Documents-PROD-DEV-libreDecision/d0a7108b-7f2f-484a-bcb0-de339238199d/subagents/agent-afbf95d4047008567.jsonl`,
dernière ligne « 6f6197c : ok »). Aucun bloquant, aucun « à reprendre ». Ce qu'elle a exécuté : le diff
entier ; e2e 42 / 42 du premier coup (11 h 58 à 12 h 01, binaire identique octet pour octet) ; vitest
78 / 78 ; tests du moteur ; sondes Playwright sur deux copies de la copie de 11 h 40 (bureau, Pixel 7,
360 et 320 px ; membre et administrateur ; fr, en, es ; écritures dans le groupe d'essai pour voir la
pastille d'un changement d'inertie de module) : les neuf points tenus, rien de cassé (vote simulé,
objections, panier) ; sa propre répétition (seules les `next_at` de la file) ; le retour.
Mineurs, **liste d'attente** :
- le vote d'un changement d'inertie de module (ouvert par la nouvelle pastille) ne dit pas le cran
visé : seulement « inertie 2 / 5 · basse », le cran où il se vote ; « vers 3 / 5 » n'est que dans
l'étiquette pour lecteur d'écran (`VoteFold.svelte:167`, `:177` ; `OrderLine.svelte:58-61`,
`TitleBar.svelte:53-56`). Défaut déjà là dans `84f015a`, plus accessible depuis U2 ; aucun
changement de ce type ouvert en production ;
- l'en-tête ne suit la liste qu'en lecture « En vigueur » (`+page.svelte:159-160`) : nuancer
`UX-lecteur.md` ou suivre la lecture ;
- le commentaire du test moteur (`threshold.rs`, « 85 % of the quorum ») parle des paramètres de
référence, pas du cran 2 ;
- trois inexactitudes de ce dossier (4 étapes de recette en échec non dites, « 35, ordres compris »,
la phrase de la vérification après) : **corrigées** ;
- les copies de production de l'intégrateur : à retirer après l'acte (prévu).
Non vérifié par elle : sonic (image, témoin `deploying`, santé), la mesure « avant » sur `84f015a`,
les ambiances sombres, un vrai lecteur d'écran. Ses copies de la base de production sont supprimées.
4. Boucle verte sur `6f6197c`, le 03/11 de 11 h 39 à 11 h 43, du premier coup (journal `m7b/loop.log`) :
fmt, clippy `-D warnings`, 302 tests Rust, svelte-check 0 / 0, 78 vitest, build, e2e 42 / 42 (11 h 40 à
11 h 43). Image de production construite de 11 h 43 à 11 h 45, sortie 0 (302 tests Rust « ok »,
svelte-check 0 / 0, 78 vitest, build) ; image de contrôle retirée à 11 h 50.
5. Sur sonic, aucun témoin `/opt/libredecision-app/deploying`.
## Commande exacte
```sh
cd /home/yvv/Documents/PROD/DEV/libreDecision
git push prod 6f6197cc231837df55d6b3e1986f61be5d708a1f:main
```
## Vérifications après
- [x] Santé vue de l'extérieur → `"build":"6f6197cc"`.
- [x] Journaux : « publication suspended … waiting=10 », aucune ligne ERROR ni WARN, aucun amendement.
- [x] Le paquet JS servi, vu de l'extérieur, contient « changement d'inertie proposé » et « au-delà du
quorum » (nouveaux dans `6f6197c` ; « déplace au moins un article » existait déjà dans `84f015a`).
- [x] Sur une copie fraîche : comptes de `counts.sh` identiques à la copie d'avant ; `integrity_check` ok.
- [x] Le dépôt des textes reste à `9e0f45a`.
## Retour
`docker tag libredecision-app:previous libredecision-app:latest`, puis
`cd /opt/libredecision-app && docker compose up -d --force-recreate`. `previous` sera `84f015a`, le même
serveur, qui attend aussi le premier texte pour publier : rien à couper, aucune donnée à reprendre.
## Exécution
Acte exécuté le 03/11/2026, 12h07-12h26, par orch (session dev-63, e66db8ca), sous mandat « déploie »
(03/11 → 08/11, `~/harness/etat/mandats.json`).
- **Prémisses** (12 h 07 min 36 s) : santé `"build":"84f015ad"` ; `prod/main` → `84f015a` ; diff
`84f015a..6f6197c` sur `crates/ld-server crates/trustin-access seeds docker .woodpecker .env data` vide ;
« 6f6197c : ok » inscrit (12 h 06) ; aucun témoin `deploying` sur sonic.
- **Copie d'avant** (12 h 07 min 45 s) : `integrity_check` ok, `foreign_key_check` vide ; identities 1,
votes 0, revisions 119, translations 144, inertia_changes 10, file `pending=10 done=13`.
- **Poussée** (12 h 08) : la commande du dossier, telle quelle → `84f015a..6f6197c 6f6197cc… -> main`.
- **Pipeline 41** : créé à 12 h 08 min 13 s ; santé `"build":"6f6197cc"` dès 12 h 12 min 39 s ; `success`
à 12 h 23 min 26 s (app puis maquette ; image `latest` 2637acd8a06e construite à 12 h 12 min 04 s).
- **Redémarrage à froid** (12 h 23 min 46 s) : « libreDecision listening bind=0.0.0.0:8002
build=6f6197cc », puis « publication suspended: no article in force … waiting=10 » ; aucune ligne
ERROR, WARN ni amendement. Santé ensuite `{"build":"6f6197cc","ok":true}`.
- **Copie d'après** (12 h 24 min 03 s) : `integrity_check` ok, `foreign_key_check` vide ; sortie de
`counts.sh` **identique à la ligne près** à celle d'avant (migrations 1 à 11, quorums
`[12,12,50,50,50]` / `[1,1,1,1,1]`, 22 amendements du seed, file `pending=10 done=13`).
- **Paquet servi** (12 h 24 min 18 s, `curl -4` depuis le poste, 19 nœuds et 40 morceaux) :
« changement d'inertie proposé » et « au-delà du quorum » présents, chacun dans un fichier.
- **Dépôt des textes** : `main` → `9e0f45a9e8d4af356a0c42daa5f4fb9eb9e6dfb3`, inchangé.
- **Retour** : `previous` = 7cc0a31053dc (construite à 10 h 53 min 40 s, `84f015a`) ; le conteneur tourne
sur `latest` 2637acd8a06e.
- **Copies retirées** à 12 h 26 min 19 s : `/tmp/ld-m7b-copie` (11 h 40, celle de la répétition),
`/tmp/ld-m7b-avant`, `/tmp/ld-m7b-apres`.
- **Branche locale** : `main` avancée `84f015a..6f6197c` à 12 h 26 min 19 s.
- Écart au déroulé : aucun.
- Copies de l'intégrateur retirées le 03/11 à 12 h 28 (scratchpad de libredecision-d4) : `m7b/base`,
`m7b/rep` (répétition), `m7b/recette-apres`, `m7b/recette-sonde` (recette écran) et les deux `.dump`.
Plus aucune base dans ce scratchpad.
-341
View File
@@ -1,341 +0,0 @@
# Acte — Mise en ligne M8 (aucun chiffre daté dans les textes votés, lots T3, T3b et T3c)
Classe : déploie
Projet : libreDecision Date : 2026-11-03
**État : EXÉCUTÉ** le 03/11 de 20 h 26 à 20 h 44 (voir « Exécution ») ; figé sur `de1b7eb` (`de1b7eb5d7e982aee7c70a41c9f5799fb171493e`) le 03/11 à 20 h 23 — préparé par
l'intégrateur libredecision-d4 ; exécuté par orch (dev-63) sous son mandat (« déploie » et « pousse », du
03/11 au 08/11). Relecture indépendante : « de1b7eb : ok » (prémisse 3).
## Pour Yvv : ce que tu liras de changé
Deux textes votés de départ changent, dans les deux groupes : N1.1 « Niveaux d'inertie » et F1.2
« Paramètres par défaut ». F1.1 « Formule du seuil WoT » n'est pas touché. Chaque phrase finale, avant (le
texte en ligne aujourd'hui) et après, en français, qui fait foi.
**N1.1 « Niveaux d'inertie »**
1. Le quorum :
- avant : « Chaque cran a aussi son quorum, qui n'est pas un nombre minimal de votants : sous le quorum,
les votants qui manquent comptent comme « non ». »
- après : « Chaque cran a aussi son quorum : sous le quorum de l'article, les votants qui manquent
comptent comme « non », et il faut autant de « pour » que si ce quorum avait voté. »
2. La part :
- avant : « La part dépend du cran et du nombre de votants, comparé à la participation habituelle du
groupe : au-delà du quorum, plus il y a de votants, plus elle baisse, jusqu'à la majorité M quand tout
le monde vote. »
- après : « La part exigée dépend du cran et du nombre de votants, comparé à la participation habituelle
du groupe : au-delà de ce quorum, plus il y a de votants, plus elle baisse, jusqu'à la majorité M quand
tout le monde vote. »
3. La formule :
- avant : « `Part exigée = M + (1 − M) × (1 − (T'/W)^G)`, où T' est le nombre de votants, ou le quorum
s'il en manque. »
- après : « `Part exigée = M + (1 − M) × (1 − (T'/W)^G)`, où T' est le nombre de votants, ou le quorum de
l'article s'il en manque. »
4. L'introduction des cinq crans :
- avant : « **Les cinq crans** : le quorum dans Monnaie libre, la part exigée quand le groupe vote comme
d'habitude, puis quand tout le monde vote, et ce qui est à ce cran au départ. »
- après : « **Les cinq crans** : le quorum dans Monnaie libre, la part exigée au-delà du quorum de
l'article, à la participation habituelle, puis quand tout le monde vote, et ce qui est à ce cran au
départ. »
5. Les cinq lignes des crans, la même retouche dans chacune :
- avant : « … quorum 12 ; 60 % comme d'habitude, 50 % quand tout le monde vote. … » (de même 67, 75,
83 et 90 %) ;
- après : « … quorum 12 ; 60 % à la participation habituelle, 50 % quand tout le monde vote. … ». Le
reste de chaque ligne ne change pas.
6. La référence :
- avant : « Tant que le groupe n'a pas voté sur 5 propositions, elle vaut deux fois le quorum de
référence du document : 100 votants dans Monnaie libre. »
- après : « Tant qu'elle repose sur moins de 5 propositions, elle vaut deux fois le quorum de référence
du document : 100 votants dans Monnaie libre. »
7. Les bornes :
- avant : « Elle reste entre 1 % et 50 % de l'électorat, et se mesure une fois par jour. »
- après : « Elle est ramenée entre 1 % et 50 % de l'électorat, et se mesure une fois par jour. »
8. G :
- avant : « **G** se calcule pour que la part exigée, à la participation habituelle, soit exactement
celle du cran : `G = ln(1 − (s − M)/(1 − M)) / ln(p̃)`, où s est la part exigée comme d'habitude et p̃
la participation habituelle divisée par W. »
- après : « **G** se calcule pour que la part exigée, au-delà du quorum de l'article, soit exactement
celle du cran à la participation habituelle : `G = ln(1 − (s − M)/(1 − M)) / ln(p̃)`, où s est cette
part du cran (60, 67, 75, 83 ou 90 %) et p̃ la participation habituelle divisée par W. »
9. L'exemple, retiré (rien ne le remplace) : « Exemple : avec 7 224 membres et une participation
habituelle de 120 votants, quand 120 personnes votent, il faut 72, 81, 90, 100 ou 108 voix pour, du
cran 1 au cran 5 ; au cran 3, 75 % des 120 votants, soit 90. Sous le quorum, les absents comptent comme
« non » : au cran 2 (quorum 12), 9 voix pour passent avec 3 « contre » au plus ; au cran 3 (quorum 50),
il faut 39 voix pour, avec 11 « contre » au plus. »
**F1.2 « Paramètres par défaut »**
10. Le cran par défaut :
- avant : « Le cran par défaut est le 3 (moyenne) : M = 55 %, un quorum de 50 dans Monnaie libre, et le
groupe demande 75 % de « pour » quand il vote comme d'habitude. Tout nouvel article commence à ce
cran. »
- après : « Le cran par défaut est le 3 (moyenne) : M = 55 %, un quorum de 50 dans Monnaie libre, et,
au-delà du quorum, le groupe demande 75 % de « pour » à sa participation habituelle. Tout nouvel
article commence à ce cran. »
**Pourquoi.**
- **L'exemple (9)** : ta décision du 03/11, aucun chiffre daté dans un texte voté. 7 224 membres et
120 votants sont un état de la toile et une participation d'une date ; ce qui en était calculé sort avec
eux.
- **Le quorum (1)** : « pas un nombre minimal de votants » est faux pour un quorum bas fixé par l'auteur.
Au quorum 2 (le plus bas dans Monnaie libre), il faut 2 « pour », à tout cran et sur toute toile : le
quorum y est bien un minimum. La phrase nouvelle est vraie pour tout quorum : sous le quorum de
l'article, le moteur demande exactement le seuil de ce quorum. « Autant de « pour » » plutôt que
« autant de voix pour », qui se lisait d'abord « pour que » ; « de l'article », parce qu'un article peut
avoir le quorum que son auteur a fixé, pas celui du cran.
- **« À la participation habituelle » (4, 5, 8, 10)** : les textes disaient la part du cran « comme
d'habitude », ce qui se lit comme le nombre de votants que le groupe a vraiment d'habitude. Le moteur,
lui, prend la participation habituelle telle que N1.1 la définit : la médiane, **ramenée entre 1 % et
50 % de l'électorat**. Dès que la médiane sort de ces bornes, ou reste sous le quorum de l'article, la
part n'est plus celle du cran. Deux exemples, calculés par le moteur :
- 7 224 membres, médiane de 40 votants (sous 1 %, ramenée à 72,24), cran 2, quorum 12 : à 40 votants,
il faut 28 « pour » (68,7 %), pas 27 (67 %) ;
- 3 000 membres, médiane de 40 (dans les bornes), cran 3, quorum 50 : la part se calcule sur le quorum,
il faut 38 « pour » de ces 40 votants (95 %), pas 75 %.
Le terme défini, « à la participation habituelle », et « au-delà du quorum de l'article » rendent ces
phrases vraies dans tous les cas.
- **La part exigée, le quorum de l'article (2, 3)** : le terme défini, au lieu de « la part » et « le
quorum » nus. La part exigée baisse au-delà du quorum ; le nombre de « pour », arrondi à l'entier, peut
remonter d'une voix (9 sur 12, puis 10 sur 13).
- **La référence (6)** : la médiane ne compte que l'année écoulée. Avec moins de 5 propositions dans
l'année, la référence de 100 votants revient, même si le groupe en a voté plus avant ; « tant que le
groupe n'a pas voté sur 5 propositions » était faux.
- **Les bornes (7)** : « est ramenée » dit ce que fait le moteur (une médiane hors des bornes est remplacée
par la borne) ; « reste » se lisait comme un constat.
Trois tours de relecture indépendante y ont mené : T3 (le retrait et les phrases 1, 4, 10), puis T3b (G, et
« autant de « pour » ») après « 2ce2028 : à reprendre », puis T3c (le terme défini partout, 2, 3, 5, 6, 7)
après « e7ab82f : à reprendre ». Orch a donné son accord sur chaque tour : à 17 h 46, après 18 h 13 et
après 19 h 18, avec ces mots : « c'est une correction de vérité, qui reste dans ce qu'il a validé ». Le
détail et les textes entiers en fr, en, es : `docs/chantiers/acte-certification/textes-quorum-seed.md`
(section « Lot T3 »). Chaque phrase de F1.2 et de N1.1, avec le test qui la vérifie :
`docs/chantiers/acte-certification/t3c-liste-fermee.md`.
## Intention
Mettre en ligne les lots T3, T3b et T3c. Yvv, le 03/11 avant 16 h 56, mot pour mot : « Je valide
textes-quorum-seed (dont le point 3, pas de chiffre datés dans un texte voté). Tu peux lancer la livraison
de decision. » (registre `~/harness/etat/reponses-yvv.md`, commit `9b701da`). Ordre T3 d'orch (dev-63, 03/11
vers 16 h 57) : retirer des textes votés F1.1, F1.2, N1.1 (fr, en, es ; deux groupes) tout chiffre qui
dépend de l'état de la toile ou d'une participation à une date ; le texte reste vrai pour tout W ; la phrase
de N1.1 « pas un nombre minimal de votants », fausse pour un quorum d'auteur bas, écrite vraie. Points 1, 2,
4 et 8 de `textes-quorum-seed.md` : le défaut reste.
**Les preuves** (`crates/ld-server/tests/seed_quorum_texts.rs`, `crates/ld-server/tests/quorum.rs`) : la
liste fermée (`t3c-liste-fermee.md`) nomme, pour chaque phrase qui affirme un nombre, une part ou un sens de
variation, le test qui la vérifie contre le moteur sur une grille commune de 85 100 cas. Cette grille
couvre W de 100 à 10 000 000, des médianes sous 1 % et au-dessus de 50 % de W ramenées par le moteur,
chaque quorum d'auteur de 2 à W jusqu'à W = 1 000, et les cinq crans. Huit tests sont vus rouges sur le
texte de `e7ab82f`, et quatre mutations du code sont vues rouges. Aucun chiffre daté : le test des nombres
n'admet que des paramètres.
## Ce qui part
Version `de1b7eb` (lots T3, T3b et T3c), qui contient la version en ligne `6f6197c`. Code touché : `crates/ld-server/src/seed_amend.rs`
(les deux amendements), `crates/ld-server/src/wot.rs` (une constante rendue publique pour les tests, valeur
inchangée), `seeds/acte-engagement-certification.json` (bases neuves), des tests (`tests/seed_quorum_texts.rs`, `tests/quorum.rs`) et une fixture
(`tests/fixtures/seed-quorum-t2.json`, les textes de T2) ; le reste est documentation. `web/` et
`crates/ld-engine` : rien. Configuration (`docker/`, `.woodpecker/`, `.env`, `data/`) et migrations : rien
(prémisse 4).
## Ce que l'acte écrit dans la base de production (donnée runtime)
- **Aucune migration.**
- **Amendement de seed gardé** `2026-11-03-f1-2-chiffres` et `2026-11-03-n1-1-chiffres`, qui suivent
(`follows`) ceux de T2 (`2026-11-03-f1-2-phrases`, `-n1-1-phrases`), décidé une fois par document, dans une
transaction, seulement si personne n'a touché l'article : 0 vote (simulé compris), 0 ligne de bulletin,
aucune coquille, traduction de membre ni objection, texte d'avant identique à l'empreinte posée par T2
(F1.2 `96caf353…`, N1.1 `7fbedcb8…`) ; textes d'après : F1.2 `0c3b800b…`, N1.1 `8107c5fc…` ; F1.2 et N1.1 admis à la révision 4 seulement si ce sont les
amendements suivis (M5, M6b, M6c), et eux seuls, qui l'ont écrite.
- `proposals` : texte fr, révision + 1 (F1.2 et N1.1 → 5) ;
- `revisions` : une ligne `amendment` ;
- `translations` : en et es remplacés ;
- `seed_amendments` : la décision ;
- `outbox` : une ligne par article dans Monnaie libre, qui attend (publication suspendue) ; rien pour
l'essai.
Refus possibles, le texte d'avant reste : `not_a_seed`, `no_open_version`, `voted_into_force`,
`confirmed`, `votes`, `ballots`, `corrected`, `translation_edited`, `signalled`, `revised`,
`text_differs`, `changed`.
- Les amendements de T2 sur F1.2 et N1.1 sont marqués dépassés : ils ne décident plus rien.
- **F1.1 n'est pas touché. Aucun vote, aucune autre proposition, aucun autre article n'est touché ; la
publication reste suspendue.**
## Répétition
**Faite le 03/11 de 20 h 03 min 37 à 20 h 04 min 20, réseau coupé** (`unshare -rn`, boucle locale seule :
`m8/repetition-isolee.sh`), avec le serveur de `de1b7eb` (binaire de la boucle, construit à 20 h 03, copié en
`m8/ld-server-de1b7eb` ; il porte l'empreinte `8107c5fc…`), sur une copie (18 h 47 min 52, empreintes
inchangées depuis) de la copie de production prise par orch de 17 h 46 min 22 à 17 h 46 min 30 (production
sur `6f6197cc`), publication vers un dépôt nu local (journal `m8/repetition-m8c.log`, scratchpad de
l'intégrateur). Deux démarrages. Orch comparera les comptes de la production à ceux de cette copie avant de
pousser.
| | Avant | Après |
|---|---|---|
| F1.1 (deux groupes) | ouverte, seed, révision 3, 0 vote, 0 bulletin | inchangée |
| F1.2, N1.1 (deux groupes) | ouvertes, seed, révision 4, 0 vote, 0 bulletin | révision 5 |
| Révisions | 119 | 123 |
| Traductions | 144 | 144 (en, es des deux articles remplacés) |
| `seed_amendments` | 22 | 26 |
| File de publication (Monnaie libre) | 10 en attente, 13 faites | 12 en attente, 13 faites |
| Commits vers le dépôt des textes (local) | — | 0 (« publication suspended … waiting=12 ») |
| Tout le reste (`m8/counts.sh` : migrations 1–11, propositions, unités en vigueur, I1/E1/E2, `inertia_changes`, votes, bulletins, signalements, identités, quorums) | | identique |
**La base entière comparée** (`.dump`, et colonne par colonne) : changent exactement les 4 propositions de
F1.2 et N1.1 (`body`, `revision`, `updated_at` : `ff53cbdc`, `13ee215f` dans Monnaie libre, `e7ae6bcb`,
`d75f7e90` dans l'essai), leurs 8 traductions en et es (`body`, `source_hash`, `updated_at`), 4 révisions et
4 décisions ajoutées, 2 lignes de file ajoutées et les 10 lignes en attente reportées (`next_at`), les deux
compteurs (`sqlite_sequence` : révisions 123, file 25). Rien d'autre (`settings` inchangé : W n'a pas pu être
relu, réseau coupé). `integrity_check` ok, `foreign_key_check` vide. Les textes écrits sont ceux du seed de
`de1b7eb`, en fr, en et es, dans les deux groupes (comparés). Journal : quatre « seed amendment applied »
(`2026-11-03-f1-2-chiffres`, `-n1-1-chiffres` × essai, Monnaie libre), « publication suspended …
waiting=12 », aucune ligne ERROR ; un seul WARN, attendu réseau coupé : « web of trust size: no node nor
indexer answered, keeping the last value ». **Un second démarrage ne rejoue rien** (comptes identiques,
aucun « applied »).
Deux répétitions précédentes, sur `2ce2028` (17 h 54, réseau non coupé : W relu sur `rpc.duniter.org`,
`settings.wot_size` 5 709 → 5 711) et sur `e7ab82f` (18 h 54, réseau coupé), avaient donné les mêmes comptes ;
seuls les textes écrits diffèrent.
## Effet attendu
- Dans les deux groupes, N1.1 et F1.2 montrent les phrases de « Pour Yvv » (1 à 10), en fr, en et es :
N1.1 n'a plus d'exemple chiffré, dit le quorum de l'article et la part du cran « au-delà du quorum de
l'article, à la participation habituelle » ; F1.2 de même. F1.1 inchangé.
- Rien d'autre ne change ; le dépôt des textes reste à `9e0f45a`.
Irréversibilité : **partielle**. Le code se remet en arrière ; les nouveaux textes restent (l'ancien est
dans l'historique de chaque proposition).
## Avant — prémisses vivantes
1. La production sert `6f6197c` : santé `"build":"6f6197cc"` ; `prod/main` → `6f6197c`.
2. Sur une copie fraîche, `m8/premisse.sh <db>` (scratchpad de l'intégrateur, lecture seule) : 6 lignes,
toutes `open`, origine `seed`, révision 3 pour F1.1 et 4 pour F1.2 et N1.1, votes, bulletins, coquilles
et objections à 0 ; migration 11. Un article touché est refusé par l'amendement avec sa raison, et l'acte
reste valable pour l'autre. **Lu par l'intégrateur le 03/11 à 17 h 47, puis à 18 h 54**, sur ses copies de
la copie d'orch de 17 h 46 : l'attendu exact ; 0 vote et 0 ligne de bulletin en tout ; une identité. À
relire sur la copie fraîche juste avant la poussée, et comparer `counts.sh` à la colonne « Avant » de la
répétition.
3. La relecture indépendante de `de1b7eb`, inscrite « ok » dans `~/harness/etat/relectures.json`.
- Première relecture, **`2ce2028` : à reprendre** (sous-agent neuf, sans le contexte de l'intégrateur,
lancé à 17 h 55, inscrit à 18 h 12 ; transcript
`~/.claude/projects/-home-yvv-Documents-PROD-DEV-libreDecision/d0a7108b-7f2f-484a-bcb0-de339238199d/subagents/agent-a3341da520d4c5530.jsonl`) :
la phrase de G de N1.1 fausse sous le quorum ; mineurs : « autant de voix pour que si » ambigu, le test
de « 100 votants » qui ne pouvait pas échouer, la répétition qui avait lu W sur la chaîne. Tout repris
par le lot T3b (`e7ab82f`).
- Deuxième relecture, **`e7ab82f` : à reprendre** (sous-agent neuf, sans le contexte de l'intégrateur,
lancé à 18 h 56, inscrit à 19 h 16 ; transcript
`~/.claude/projects/-home-yvv-Documents-PROD-DEV-libreDecision/d0a7108b-7f2f-484a-bcb0-de339238199d/subagents/agent-a92ddff6f85c88f90.jsonl`) :
« quand le groupe vote comme d'habitude » se lit comme la médiane réelle, alors que le moteur cale G sur
la participation habituelle bornée (W 7 224, médiane 40, cran 2 : 28 « pour » sur 40, pas 27) ; la
grille des tests sautait ces cas. Repris par le lot T3c (lancé à 19 h 18, fusionné à 19 h 57 :
`de1b7eb`), avec la liste fermée des phrases demandée par orch (`t3c-liste-fermee.md`).
- **Relecture de `de1b7eb` : ok.** Sous-agent neuf, sans le contexte de l'intégrateur, lancé le 03/11 à
20 h 05 sur la liste fermée (consigne `m8/relis-m8c.md` du scratchpad de l'intégrateur), rendu vers
20 h 22, inscrit à 20 h 23 (transcript
`~/.claude/projects/-home-yvv-Documents-PROD-DEV-libreDecision/d0a7108b-7f2f-484a-bcb0-de339238199d/subagents/agent-a95bc78d7d3d1e898.jsonl`,
dernière ligne « de1b7eb : ok »). Aucun bloquant, aucun « à reprendre ». Ce qu'elle a exécuté :
- diff mot à mot des neuf textes entre `6f6197c` (identique à la base de production) et `de1b7eb` :
F1.1 inchangé, F1.2 et N1.1 comme annoncé, il ne reste que des paramètres ; en et es fidèles ;
- la liste fermée, complète (24 entrées de F1.2, 15 de N1.1), relue une à une contre le moteur ; le
terme défini partout ;
- une sonde à elle sur `ld-engine`, 0 faux : sous le quorum (absents « non », autant de « pour » que si
ce quorum avait voté, jamais une voix seule) sur 808 436 970 cas ; la part qui baisse au-delà du
quorum et vaut M à W, 142 988 400 cas ; ⌈s × P⌉ « pour » à la participation habituelle P,
947 070 cas ; B^W qui ne compte pas, 175 465 570 cas ;
- fmt, clippy, 316 tests ; empreintes recalculées ; la mutation « participation non bornée » rejouée
(6 tests rouges) ;
- l'amendement sur des copies de `base-b`, réseau coupé : appliqué une fois par groupe, rien d'autre que
le report de la file ; second démarrage sans rejeu ; retour sur `6f6197c` : 200 partout, nouveaux
textes servis, JSON identique à celui de `de1b7eb` ; gardes `votes`, `ballots`, `corrected`,
`translation_edited`, `signalled`, `text_differs`, le texte intact à chaque refus.
Mineurs, **liste d'attente** (aucun ne rend une phrase fausse ; un changement après la mise en ligne
demande un nouvel amendement gardé, possible tant qu'aucun vote n'existe sur l'article) :
- F1.2, « au-delà du quorum » sans « de l'article » (vrai au défaut ; à aligner sur N1.1) ;
- un quorum fixé à W : deux phrases sans objet (F1.1, G ; N1.1, « dépend du nombre de votants ») ;
- en, « It is kept between » : « is brought back within » serait plus fidèle ;
- une proposition rivale d'un membre ne bloque pas la garde (conception depuis le lot T ; aucune en
production).
Non vérifié par elle : npm, e2e, image (aucun changement sous `web/` ; faits par l'intégrateur,
prémisse 5), la production elle-même (prémisses 1, 2, 6).
4. `git diff 6f6197c de1b7eb -- docker/ .woodpecker/ .env data/ crates/ld-server/migrations/ web/ crates/ld-engine/`
→ vide (vu par l'intégrateur le 03/11 à 20 h 04).
5. Boucle verte sur `de1b7eb`, le 03/11 de 19 h 57 à 20 h 03 (journal `m8/loop-c.log`), du premier coup :
fmt, clippy `-D warnings`, 316 tests Rust, svelte-check 0 / 0 (648 fichiers), 78 vitest, build, e2e
42 / 42 (19 h 59 à 20 h 03). Image de production construite le 03/11 de 20 h 05 à 20 h 10 (copie
principale sur `591ed37`, même code que `de1b7eb` : le dossier seul en plus), sortie 0 : étage serveur
rejoué (fmt, clippy, 316 tests Rust « ok », 0 échec, build release) ; étage web repris du cache (`web/`
identique depuis `6f6197c`) ; image de contrôle retirée à 20 h 10 (journal `m8/docker-m8c.log`).
6. Sur sonic, aucun témoin `/opt/libredecision-app/deploying`.
## Commande exacte
```sh
cd /home/yvv/Documents/PROD/DEV/libreDecision
git push prod de1b7eb5d7e982aee7c70a41c9f5799fb171493e:main
```
## Vérifications après
- [ ] Santé vue de l'extérieur → `"build":"de1b7eb5"`.
- [ ] Journaux : quatre « seed amendment applied » (`2026-11-03-f1-2-chiffres`, `-n1-1-chiffres` × essai,
Monnaie libre), « publication suspended … waiting=12 », aucune ligne ERROR ni WARN.
- [ ] Redémarrage à froid : aucun amendement rejoué.
- [ ] Sur une copie fraîche : F1.2 et N1.1 en révision 5, F1.1 en révision 3, dans les deux groupes ; le
reste identique à la ligne près à la copie d'avant (comptes de `counts.sh`) ; `integrity_check` ok.
- [ ] Le dépôt des textes reste à `9e0f45a`.
## Retour
- **Le code** : `docker tag libredecision-app:previous libredecision-app:latest`, puis
`cd /opt/libredecision-app && docker compose up -d --force-recreate`. `previous` sera `6f6197c`, qui
attend aussi le premier texte pour publier : pas de coupure de la publication à faire. `6f6197c` sert les
nouveaux textes tels quels (ce sont des données ; vérifié par la relecture de `de1b7eb` : 200 partout,
JSON identique à celui de `de1b7eb`).
- **Les textes** : pas de retour automatique ; l'ancien texte reste dans l'historique de chaque
proposition, un nouvel amendement gardé pourrait le remettre.
## Exécution
Acte exécuté le 03/11/2026, 20h26-20h44, par orch (session dev-63, e66db8ca), sous mandat « déploie »
(03/11 → 08/11, `~/harness/etat/mandats.json`).
- **Prémisses** (20 h 26 min 26 s) : santé `{"build":"6f6197cc","ok":true}` ; `prod/main` → `6f6197c`,
ancêtre de `de1b7eb` ; diff `6f6197c..de1b7eb` sur `docker/ .woodpecker/ .env data/
crates/ld-server/migrations/ web/ crates/ld-engine/` : 0 fichier ; « de1b7eb : ok » inscrit (20 h 23) ;
aucune autre relecture de ce code en cours (intégrateur) ; aucun témoin `deploying` sur sonic ; dépôt des
textes `main` → `9e0f45a9e8d4af356a0c42daa5f4fb9eb9e6dfb3` ; CI : 41 `success` `6f6197cc` en tête.
- **Copie d'avant** (20 h 26 min 35 s, `/tmp/ld-m8-avant`) : sortie de `m8/counts.sh` **identique à la ligne
près** à celle de la copie de 17 h 46, base de la répétition (migrations 1 à 11, integrity ok, fk vide,
identities 1, votes 0, revisions 119, translations 144, file `pending=10 done=13`, `seed_amendments` 22) ;
`m8/premisse.sh` : les 6 lignes attendues (F1.1 révision 3, F1.2 et N1.1 révision 4, `open`, `seed`,
votes, bulletins, coquilles et objections à 0), 0 vote et 0 bulletin en tout, migration 11.
- **Poussée** (20 h 27) : la commande du dossier, telle quelle → `6f6197c..de1b7eb de1b7eb5… -> main`.
- **Pipeline 42** : créé à 20 h 27 min 06 s ; image `latest` 78ca8a1d10e4 construite à 20 h 31 min 25 s ;
santé `"build":"de1b7eb5"` dès 20 h 31 min 57 s ; `success` à 20 h 42 min 11 s.
- **Journal du premier démarrage** (19:31:53Z) : quatre « seed amendment applied »
(`2026-11-03-f1-2-chiffres` essai et monnaie-libre, `2026-11-03-n1-1-chiffres` essai et monnaie-libre),
« libreDecision listening … build=de1b7eb5 », « publication suspended … waiting=12 » ; aucune ligne ERROR
ni WARN.
- **Redémarrage à froid** (20 h 42 min 50 s) : « libreDecision listening … build=de1b7eb5 », puis
« publication suspended … waiting=12 » ; aucun amendement rejoué, aucune ligne ERROR ni WARN. Santé ensuite
`{"build":"de1b7eb5","ok":true}`.
- **Copie d'après** (20 h 43 min 08 s, `/tmp/ld-m8-apres`) : `counts.sh` diffère de la copie d'avant
exactement comme la répétition : revisions 119 → 123, file `pending=10` → `pending=12` (`done=13`),
`seed_amendments` 22 → 26 ; tout le reste identique à la ligne près (integrity ok, fk vide, translations 144,
votes 0, identities 1). `premisse.sh` : F1.2 et N1.1 en révision 5, F1.1 en révision 3, dans les deux
groupes, toujours 0 vote, bulletin, coquille et objection. Les traductions en et es de F1.2 sont épinglées
à l'empreinte `0c3b800b…`, celles de N1.1 à `8107c5fc…` (les `to` des amendements) ; les quatre
décisions `2026-11-03-f1-2-chiffres` et `-n1-1-chiffres` inscrites ; aucun « 7 224 » dans les textes fr,
en et es des quatre propositions ; « participation habituelle » présent, « comme d'habitude » absent.
- **Dépôt des textes** : `main` → `9e0f45a9e8d4af356a0c42daa5f4fb9eb9e6dfb3`, inchangé.
- **Retour** : `previous` = 2637acd8a06e (construite le 03/11 à 12 h 12 min 04 s, `6f6197c`) ; le conteneur
tourne sur `latest` 78ca8a1d10e4.
- **Copies retirées** à 20 h 43 min 58 s : `/tmp/ld-m8-copie` (17 h 46, base des répétitions),
`/tmp/ld-m8-avant`, `/tmp/ld-m8-apres`. Les copies de l'intégrateur (`m8/base-b`, `m8/lecture`, `m8/rep`,
`m8/dump`) sont à retirer par lui, sur ce message.
- **Branche locale** : `main` avancée `6f6197c..de1b7eb` à 20 h 43 min 58 s.
- Écart au déroulé : aucun.
- Copies de l'intégrateur retirées le 03/11 à 20 h 45 (scratchpad de libredecision-d4) : `m8/base-b` et
`m8/lecture` (copies de la copie de 17 h 46), `m8/rep` (la base de la répétition, avec son dépôt nu local)
et `m8/dump` (les deux `.dump` et leur diff). Plus aucune base dans ce scratchpad.
-273
View File
@@ -1,273 +0,0 @@
# Acte — Mise en ligne M9 (l'Acte d'engagement d'Yvv et les informations de fonctionnement hors vote, lots T4, U3 et U3b)
Classe : déploie
Projet : libreDecision Date : 2026-11-03
**État : PRÉPARÉ, non figé** — sur `d66823b` (`d66823b2f626b4833b9422c0bbf418fa698cfcd9`, lot U3b fusionné à
23 h 00) ; en attente de la boucle, de l'image et de la relecture indépendante de `d66823b` — préparé par l'intégrateur libredecision-d4 ; exécuté par orch (dev-63) sous son
mandat (« déploie » et « pousse », du 03/11 au 08/11). Mot : **« déploie »** (rien n'est effacé ; voir
« Ce que l'acte écrit »).
## Pour Yvv : ce que tu liras de changé
Dans les deux groupes (Monnaie libre et l'essai), en français ici ; en anglais et en espagnol dans
`docs/chantiers/acte-certification/textes-amorce.md`. Tous restent des **Propositions d'amorce** ouvertes,
0 vote, au cran 2 ; aucun article n'est en vigueur.
**I1 « Raison d'être »**
- Avant : *« Derrière une clé il n'y a qu'un seul être humain vivant ; derrière un être humain, il n'y a
qu'une seule clé certifiante. »* / C'est le seul et unique propos de la certification, qui donne à notre
toile fiduciaire sa valeur. Nous pouvons nous fier à l'unicité et l'intégrité de tout geste signé par cette
clé. C'est l'enjeu de la certification. / Cet acte d'engagement est par ailleurs le seul acte de nature
contractuelle qui fonde l'usage de la première monnaie libre.
- Après : *« Derrière une clé il y a un et un seul être humain vivant ; derrière un être humain, il y a une
et une seule clé certifiante. »* / C'est l'unique enjeu de la certification et de votre engagement, il
donne à notre toile fiduciaire sa valeur : pouvoir nous fier à l'unicité et l'intégrité de tout geste signé
par cette clé. / Cet acte d'engagement est par ailleurs le seul acte de nature contractuelle, qui fonde
l'usage de la première monnaie libre.
**E1 « Unicité du compte »**
- Avant : Je m'engage sur l'honneur à n'avoir et n'avoir jamais qu'un seul et unique compte cocréateur de
monnaie G1.
- Après : Je m'engage sur l'honneur à n'avoir et n'avoir jamais qu'un seul et unique compte certifiant.
**E12 « Responsabilité civique »** (nouveau, entre E1 et E2)
- Je déclare sur l'honneur avoir la capacité légale et physique de signer un contrat et d'en assumer la
responsabilité civique, pour certifier à mon tour.
**E2 « Réciprocité »**
- Avant : Je m'engage sur l'honneur à ne certifier que des personnes physiques qui respectent
scrupuleusement ces deux présents engagements fondamentaux.
- Après : Je m'engage sur l'honneur à ne certifier que des personnes physiques qui s'engagent elles-mêmes, à
scrupuleusement respecter ces trois principes fondamentaux.
**Ce qui sort du document** (rien n'est effacé) : les sections « Formule de vote » (F1, F1.1, F1.2, F1.3),
« Inertie » (N1, N1.1) et « Ordonnancement » (O1), avec l'ordre de chacune. Elles ne se lisent plus, ne se
votent plus, ne se publient plus. Leurs informations sont maintenant dans l'interface, là où elles servent :
au vote déplié, dans le panneau « Réglages de la formule », sur la ligne d'ordre et au réglage du quorum.
**Ce que tu verras à l'écran**
- En haut du document, avant le texte, l'encadré **« Info ouverture doc de réf. »**, hors vote : ton
paragraphe (« Aucun article n'est encore en vigueur. Les Propositions d'amorce y entrent par le vote… »),
l'inertie à l'ouverture (Propositions d'amorce 2 / 5, titre 4 / 5, ordre de chaque section 2 / 5, autres
articles 3 / 5) et le quorum (12 · 12 · 50 · 50 · 50 votants, du cran 1 au cran 5), lus dans le document.
- Un filet entre cet encadré et le texte ; plus les titres « Introduction » ni « Engagements fondamentaux ».
- « Proposition d'amorce » à la place de « Proposition seed ».
- Sans être connecté, l'accueil dit : « Pour rentrer dans un groupe de décisions, il faut pour l'instant
demander un lien d'invitation à Chiara ou à Yvv. Ce prototype n'est pas encore câblé sur la chaîne. »
**Les choix faits pour toi** (appliqués ; dis lequel tu changes, il se change par un nouvel amendement tant
qu'aucun vote n'existe) : « il y a une et une seule clé » (« n'y a » lu comme une coquille, la seule
correction de tes mots) ; I1 en trois paragraphes, le premier en italique ; tes virgules gardées ; « ces
trois principes » = E1, E12, E2 ; E12 au cran 2 ; le chapitre titré comme tu l'as écrit ; « @ chiara ou Yvv »
lu « à Chiara ou à Yvv » ; une fois un article en vigueur, la première phrase de ton paragraphe disparaît et « y entrent
par le vote » devient « entrent en vigueur par le vote ».
Tes deux propositions d'inertie retirées le 03/11 (sur I1 et E1, Q12) ne bloquent pas la réécriture de ces
textes : elles portent sur l'inertie, pas sur le texte, et restent dans l'historique.
## Intention
Mettre en ligne la demande d'Yvv du 03/11, faite dans la session de l'intégrateur (vers 20 h 38, puis avant
20 h 44), mot pour mot dans `docs/chantiers/acte-certification/tableau.md` et
`textes-amorce.md` : les textes I1, E1, E2, l'article nouveau « Responsabilité civique » ; « ordonnancement,
inertie, et formule de vote sont des infos de fonctionnement […] Vire les 3 du bas de la liste » ; le
chapitre « Info ouverture doc de réf. » ; la séparation avant le texte ; les titres « Introduction » et
« Engagements fondamentaux » retirés ; le texte pour qui n'est pas invité. Un seul acte pour T4 et U3 (accord
d'orch le 03/11 à 21 h 58) : T4 seul retirerait les informations du document avant que l'interface ne les
dise.
## Ce qui part
Version `d66823b` (lots T4, U3, U3b), qui contient la version en ligne `de1b7eb`.
- **Serveur** : migration `0012_retired.sql` (additive) ; `retire.rs` (le retrait) ; `article_amend.rs`
(E12) ; `seed_amend.rs` (trois amendements, I1, E1, E2) ; les lectures, votes, propositions, objections,
coquilles, traductions, comptes et la publication ignorent une unité retirée ; `docs.rs` : `seedFeatured`.
- **Seed** (`seeds/acte-engagement-certification.json`, bases neuves) : les textes d'Yvv, E12, sans les
sections formule, inertie, ordonnancement.
- **Interface** (`web/`) : le chapitre, la séparation, les titres, les informations dans le vote et les
panneaux, « Proposition d'amorce », le texte pour qui n'est pas invité, le réordonnancement sur plusieurs
lignes, le tableau des crans lisible sur bureau.
- Configuration (`docker/`, `.woodpecker/`, `.env`, `data/`) : rien (prémisse 4).
## Ce que l'acte écrit dans la base de production (donnée runtime)
Au premier démarrage, dans une transaction par amendement et par document, une fois :
- **Migration 0012, additive** : deux colonnes vides `units.retired_at`, `units.retired_reason` ; deux tables
de journal `retirement_amendments`, `article_amendments`. Aucune colonne retirée, aucune ligne effacée.
- **`2026-11-03-i1-amorce`, `-e1-amorce`, `-e2-amorce`** (amendements de seed gardés, `Version::SeedProposal`) :
texte fr, révision + 1 (I1 → 3, E1 et E2 → 2), une ligne `revisions` `amendment`, en et es remplacés, la
décision dans `seed_amendments`, une ligne de file (Monnaie libre). Empreintes : I1 `a7095d70…` →
`32eb36b4…`, E1 `bdca2713…` → `df31d0ec…`, E2 `bde76a30…` → `a7dd4690…`. Refusé à la moindre trace d'une
personne (vote, simulé compris ; bulletin ; coquille ; traduction de membre ; objection ; texte différent de
l'empreinte) : le texte d'avant reste.
- **`2026-11-03-e12-responsabilite-civique`** (`article_amend.rs`) : une unité E12 (section
`fondamental`, position 4,5, entre E1 et E2 tant que l'ordre de la section n'a pas de vote, sinon en fin de
section), sa Proposition d'amorce ouverte (cran 2, `seed_featured`), sa révision initiale, en et es
(empreinte `3fa5a8e5…`), la décision dans `article_amendments`, une ligne de file (Monnaie libre).
- **`2026-11-03-fonctionnement-hors-vote`** (`retire.rs`) : `UPDATE units SET retired_at, retired_reason`
sur dix unités par document (F1, F1.1, F1.2, F1.3, N1, N1.1, O1, `ordre.formule`, `ordre.inertie`,
`ordre.ordonnancement`), **tous ou aucun**, seulement si aucune trace d'une personne n'existe sur elles
(raisons de refus : `votes`, `ballots`, `adopted`, `member_proposal`, `corrected`,
`translation_edited`, `signalled`, `quorum_set`, `changed`) ; la décision dans `retirement_amendments` ;
une ligne de file (Monnaie libre). Leurs propositions, révisions, traductions, changements d'inertie et
lignes de file restent.
- La file de publication : les lignes en attente sont reportées (`next_at`) ; la publication reste
suspendue (aucun texte en vigueur). Le groupe d'essai ne publie jamais.
- **Rien d'autre** : aucun vote, aucune identité, aucun réglage, aucun autre article.
## Répétition
**Faite le 03/11 de 22 h 45 min 39 à 22 h 47 min 46, réseau coupé** (`unshare -rn`, boucle locale seule :
`m9/repetition-isolee.sh`, scratchpad de l'intégrateur), avec le serveur construit sur `5730d78` (même code
serveur et même seed que `d66823b` : prémisse 4), copié en `m9/ld-server-a47aed0`, sur une copie de la copie
de production prise par orch de 22 h 41 min 52 à 22 h 42 min 10 (production sur `de1b7eb5` ; empreintes
vérifiées : `05058b76…`, `fd4c9fda…`, WAL vide), publication vers un dépôt nu local. Journal
`m9/repetition-1.log` ; comparaison ligne à ligne de chaque table par `m9/compare.py` (lecture seule, par
`rowid`).
| Table | Avant | Après | Ce qui change |
|---|---|---|---|
| `_sqlx_migrations` | 11 | 12 | + la migration 12 |
| `units` | 84 | 86 | + E12 × 2 ; 20 unités marquées (`retired_at`, `retired_reason`) ; les 64 autres : deux colonnes nouvelles vides |
| `proposals` | 91 | 93 | + E12 × 2 (ouvertes, seed, `seed_featured`) ; I1, E1, E2 × 2 : `body`, `revision`, `updated_at` |
| `revisions` | 123 | 131 | + 6 `amendment` (I1, E1, E2 × 2), + 2 `initial` (E12 × 2) |
| `translations` | 144 | 148 | + 4 (E12 en, es × 2) ; 12 remplacées (I1, E1, E2 en, es × 2 : `body`, `source_hash`, `updated_at`) |
| `seed_amendments` | 26 | 32 | + 6 décisions `applied` |
| `article_amendments` | — | 2 | table nouvelle, 2 décisions `applied` (`between`, 4,5) |
| `retirement_amendments` | — | 2 | table nouvelle, 2 décisions `applied` |
| `outbox` | 25 (12 en attente) | 30 (17 en attente) | + 5 (Monnaie libre) ; les 12 en attente reportées (`next_at`) |
| Les 22 autres tables (votes 0, bulletins 0, identités 1, réglages, quorums, inertie, invitations, groupes, sessions…) | | | identiques |
**Aucune ligne supprimée, dans aucune table.** `integrity_check` ok, `foreign_key_check` vide. Dans chaque
groupe, l'ordre de « fondamental » lu : T1, E1, E12, E2. Journal : six « seed amendment applied », deux
« article added … placement=Between(4.5) », deux « units out of the document … retired=[F1, F1.1, F1.2,
F1.3, N1, N1.1, O1, ordre.formule, ordre.inertie, ordre.ordonnancement] », « publication suspended …
waiting=17 », aucune ligne ERROR ; un seul WARN, attendu réseau coupé (« web of trust size: no node nor
indexer answered, keeping the last value »). Dépôt nu : 0 commit.
**Ce qu'on lit ensuite** (connexion de développement sur une copie à part de l'état d'après) : dans chaque
groupe, 27 unités, cinq ordres de section (introduction, fondamental, technique, conclusion, annexe) ; I1,
E1, E12, E2 au cran 2, Propositions d'amorce (`seedProposal`, `seedFeatured`) ; ni F, ni N, ni O.
**Un second démarrage ne rejoue rien** : toutes les tables identiques, seul le report de la file (`next_at`).
**Le retour, rejoué** : le serveur de `de1b7eb` (`m8/ld-server-de1b7eb`, celui de la répétition de M8) sur
une copie de l'état d'après : démarre, santé ok ; il n'écrit rien d'autre que le report de la file ; il
montre, dans chaque groupe, **34 unités** : les sept articles F1 à O1 et les huit ordres de section
reviennent, avec les nouveaux textes d'I1, E1, E2 et E12 entre E1 et E2 (ce sont des données). **Un nouveau
passage** au serveur de `d66823b` ensuite ne rejoue rien (seul le report de la file) : les dix unités restent
retirées, puisque leur marque est restée.
## Effet attendu
- Dans les deux groupes : I1, E1, E2 aux textes d'Yvv, E12 entre E1 et E2, en fr, en, es ; ni formule, ni
inertie, ni ordonnancement dans le document ; l'encadré « Info ouverture doc de réf. » en tête.
- L'accueil sans session dit le texte pour qui n'est pas invité.
- La publication reste suspendue ; le dépôt des textes reste à `9e0f45a`.
Irréversibilité : **partielle**. Le code se remet en arrière (`previous` = `de1b7eb`) et les sept articles
réapparaissent ; les nouveaux textes et E12 restent (l'ancien texte est dans l'historique de chaque
proposition) ; la migration 0012 reste (ignorée par `de1b7eb`, `set_ignore_missing`).
## Avant — prémisses vivantes
1. La production sert `de1b7eb` : santé `"build":"de1b7eb5"` ; `prod/main` → `de1b7eb`.
2. Sur une copie fraîche, `m8/counts.sh <db>` identique à la ligne près à la colonne « Avant » de la
répétition (migrations 1 à 11, integrity ok, identities 1, votes 0, revisions 123, translations 144, file
`pending=12 done=13`, `seed_amendments` 26) ; et `m9/traces.sh <db>` (scratchpad de l'intégrateur,
lecture seule) : pour I1, E1, E2 et les dix unités, dans les deux groupes, votes, bulletins, coquilles,
objections, traductions de membre et quorums fixés à 0 ; propositions de membre : seulement
`inertia/withdrawn` sur I1 et E1 dans Monnaie libre (les deux propositions d'inertie d'Yvv du 29/10,
retirées par lui le 03/11, Q12). Elles ne touchent pas le retrait : I1 et E1 ne sont pas retirés, et la
garde du retrait n'a aucune exception (une proposition de membre, de toute sorte et de tout statut, sur
une des dix unités, refuse le retrait : `retire.rs`, `member_versions`). Elles ne touchent pas non plus
la réécriture d'I1 et d'E1 : sa garde lit les traces sur la Proposition d'amorce elle-même (votes,
bulletins, coquilles, traductions de membre, objections, révision, empreinte), pas les autres
propositions de l'article (`seed_amend.rs`, `facts`) ; elles portent sur l'inertie, pas sur le texte, et
restent dans l'historique de l'article. En vigueur : seuls les trois ordres (modules en vigueur sans
vote) ; en tout 0 vote, 0 bulletin, aucune E12, migration 11. **Lu par l'intégrateur le
03/11 à 22 h 50** sur sa copie de la copie d'orch de 22 h 41 : l'attendu exact. Une trace fait refuser
l'amendement concerné avec sa raison, et rien ne bouge pour lui.
3. La relecture indépendante, inscrite « ok » dans `~/harness/etat/relectures.json`.
- **`d66823b` : à reprendre** (sous-agent neuf, sans le contexte de l'intégrateur, lancé le 03/11 à
23 h 00 sur la consigne `m9/relis-m9.md` du scratchpad de l'intégrateur, inscrit à 23 h 25 ; transcript
`~/.claude/projects/-home-yvv-Documents-PROD-DEV-libreDecision/d0a7108b-7f2f-484a-bcb0-de339238199d/subagents/agent-a81f5c0481c04ce9b.jsonl`).
Aucun bloquant. **À reprendre** : `formula.reading` (« La formule du seuil » du vote déplié) finit par
« quand tout le monde vote, la majorité M suffit », faux pour W ≤ 15 quand M·W est entier (B^W ajoute
une voix ; aucun groupe actuel touché : l'essai a un membre, Monnaie libre 5 745) → lot U3c, lancé à
23 h 26, avec les raisons de la file (« Proposition d'amorce » au lieu de « Proposition seed », elles
iront dans le message de commit public). **Tient**, prouvé par elle :
- le retrait : chaque route sondée sur les 10 unités des deux groupes répond 404, 400 ou 409 ; aucune
vue ne contient une unité retirée ; une trace de membre posée à la main (quatre essais : inertie
retirée sur F1.3, ordre retiré, brouillon sur N1.1, texte remplacé sur F1) refuse le retrait de son
groupe, `member_proposal`, l'autre groupe appliqué ;
- la migration 0012 additive ; « AUCUNE LIGNE SUPPRIMÉE » à chaque étape ;
- le retour sur `de1b7eb` (binaire reconstruit par elle) : 34 articles, 8 ordres, JSON des 10 unités
identique à avant M9 ; repassage sans écriture ; les deux propositions d'inertie retirées d'Yvv,
JSON identique avant M9, après M9 et après le retour ;
- les textes d'Yvv octet par octet : seule différence « n'y a » → « y a » ; empreintes recalculées ;
- boucle : 336 tests Rust, 113 vitest, e2e 48 / 48.
Mineurs, **liste d'attente** (aucun ne touche la production d'aujourd'hui) :
- la garde de réécriture d'I1, E1, E2 ne lit que la Proposition d'amorce : un quorum fixé sur
l'article, un texte rival d'un membre ou un vote sur une proposition d'inertie retirée la laissent
passer (la garde du retrait compte le quorum fixé) ; la prémisse 2 lit ces traces avant l'acte ;
- `inertia.explain`, première phrase : une tendance, pas une règle ;
- `vote.others.rule` ne dit pas qu'une version retenue par une objection n'entre pas en vigueur ;
- après un retour, une trace laissée sur F1 à O1 n'empêche pas le repassage de les tenir retirées
(limite connue de l'ADR 0014).
4. `git diff de1b7eb d66823b -- docker/ .woodpecker/ .env data/` → vide ; sous
`crates/ld-server/migrations/`, le seul ajout `0012_retired.sql` ; `git diff 5730d78 d66823b -- crates/
seeds/` → vide (la répétition vaut pour `d66823b`). **Vu par l'intégrateur le 03/11 à 23 h 01.**
5. Boucle verte sur `d66823b`, le 03/11 de 23 h 00 à 23 h 07 (journal `m9/loop.log`), du premier coup :
fmt, clippy `-D warnings`, 336 tests Rust, svelte-check 0 / 0 (652 fichiers), 113 vitest, build, e2e
48 / 48 (46 contrôles C1 à C44, M1, M2 ; 23 h 02 à 23 h 07). Image de production construite le 03/11 de
23 h 07 à 23 h 11 (copie principale sur `0e26624`, même code que `d66823b` : le dossier, le tableau et le
HANDOFF seuls en plus), sortie 0 : étage serveur rejoué (fmt, clippy, 336 tests Rust, 0 échec, build
release) ; étage web rejoué (svelte-check 0 erreur 0 avertissement, vitest, build) ; image de contrôle
retirée à 23 h 11 (journal `m9/docker.log`).
**Recette écran sur la copie de production** (l'état d'après M9 de la répétition, serveur de `d66823b`,
chaîne coupée, connexion de développement sur cette copie seulement ; 32 captures,
`m9/recette/shots/`, bureau 1360 et Pixel 7, pierre et nuit) : l'accueil sans session dit le texte pour
qui n'est pas invité ; en tête du document, « Info ouverture doc de réf. » avec le paragraphe d'Yvv, les
crans 2 · 4 · 2 · 3 et le quorum 12 · 12 · 50 · 50 · 50 lus dans la base ; le filet ; puis « Raison
d'être » sans titre de section, « Unicité du compte », « Responsabilité civique », « Réciprocité »,
chacune « Proposition d'amorce », cran 2, « 9 oui sur 12 » ; ni formule, ni inertie, ni ordonnancement
dans la page ; le vote déplié d'E12 dit le vote permanent, l'inertie et le quorum de l'article ; le
panneau des réglages se lit en entier (W 5 709, la valeur de la copie).
6. Sur sonic, aucun témoin `/opt/libredecision-app/deploying`.
## Commande exacte
```sh
cd /home/yvv/Documents/PROD/DEV/libreDecision
git push prod d66823b2f626b4833b9422c0bbf418fa698cfcd9:main
```
Puis la branche du chantier (orch, même mandat) : `git push origin chantier/acte-certification`.
## Vérifications après
- [ ] Santé vue de l'extérieur → `"build":"d66823b2"`.
- [ ] Journaux : six « seed amendment applied » (`2026-11-03-i1-amorce`, `-e1-amorce`, `-e2-amorce` × essai,
Monnaie libre), deux « article added … Between(4.5) », deux « units out of the document »,
« publication suspended … waiting=17 », aucune ligne ERROR ni WARN.
- [ ] Redémarrage à froid : aucun amendement rejoué.
- [ ] Sur une copie fraîche : `m9/compare.py <copie d'avant> <copie d'après>` donne exactement la table de
la répétition, « AUCUNE LIGNE SUPPRIMÉE » ; `integrity_check` ok.
- [ ] Vue de l'extérieur, `https://decision.librodrome.org/` sans session : le texte pour qui n'est pas invité.
- [ ] Le dépôt des textes reste à `9e0f45a`.
## Retour
- **Le code** : `docker tag libredecision-app:previous libredecision-app:latest`, puis
`cd /opt/libredecision-app && docker compose up -d --force-recreate`. `previous` sera `de1b7eb`, qui ignore
la migration 0012 et la colonne : les sept articles et les trois ordres réapparaissent, rien d'effacé
(rejoué en répétition). Un nouveau passage à `d66823b` ne rejoue rien.
- **Les textes** : pas de retour automatique. L'ancien texte français reste dans l'historique de chaque
proposition (`revisions`) ; les traductions en et es d'I1, E1, E2 sont remplacées sur place : les
anciennes ne restent que dans git (le seed de `de1b7eb`, identiques 12 sur 12, vérifié par la relecture).
Un nouvel amendement gardé pourrait les remettre. E12 reste (donnée).
## Exécution
(à remplir par orch)
@@ -26,8 +26,6 @@ from app.database import Base
from app.models import ( # noqa: F401
DuniterIdentity,
Session,
Organization,
OrgMember,
Document,
DocumentItem,
ItemVersion,
@@ -39,7 +37,6 @@ from app.models import ( # noqa: F401
MandateStep,
VotingProtocol,
FormulaConfig,
QualificationProtocol,
SanctuaryEntry,
BlockchainCache,
)
@@ -39,9 +39,6 @@ class Settings(BaseSettings):
RATE_LIMIT_AUTH: int = 10
RATE_LIMIT_VOTE: int = 30
# AI — Qwen3.6 (MacStudio) endpoint, branché plus tard
QWEN_API_URL: str = ""
# Blockchain cache
BLOCKCHAIN_CACHE_TTL_SECONDS: int = 3600
@@ -13,9 +13,6 @@ from app.middleware.rate_limiter import RateLimiterMiddleware
from app.middleware.security_headers import SecurityHeadersMiddleware
from app.routers import auth, documents, decisions, votes, mandates, protocols, sanctuary, websocket
from app.routers import public
from app.routers import organizations
from app.routers import qualify
from app.routers import groups
# ── Structured logging setup ───────────────────────────────────────────────
@@ -88,28 +85,8 @@ app = FastAPI(
# ── Middleware stack ──────────────────────────────────────────────────────
# add_middleware is LIFO: last added = outermost = first to execute on request,
# last to execute on response (wraps everything inside it).
#
# Required order so CORS headers appear on ALL responses including 429:
# CORS (outermost) → RateLimiter → SecurityHeaders → Application
#
# If RateLimiter were outside CORS, its 429 responses would have no CORS
# headers and the browser would silently discard them as network errors.
app.add_middleware(SecurityHeadersMiddleware)
# Prototype mode: use RATE_LIMIT_DEFAULT for auth so demos/testing don't hit
# the stricter RATE_LIMIT_AUTH (10/min). Set RATE_LIMIT_AUTH >= RATE_LIMIT_DEFAULT
# in .env only when going to real production.
_auth_rate_limit = settings.RATE_LIMIT_DEFAULT
app.add_middleware(
RateLimiterMiddleware,
rate_limit_default=settings.RATE_LIMIT_DEFAULT,
rate_limit_auth=_auth_rate_limit,
rate_limit_vote=settings.RATE_LIMIT_VOTE,
)
# Middleware is applied in reverse order: last added = first executed.
# Order: SecurityHeaders -> RateLimiter -> CORS -> Application
app.add_middleware(
CORSMiddleware,
@@ -119,6 +96,15 @@ app.add_middleware(
allow_headers=["*"],
)
app.add_middleware(
RateLimiterMiddleware,
rate_limit_default=settings.RATE_LIMIT_DEFAULT,
rate_limit_auth=settings.RATE_LIMIT_AUTH,
rate_limit_vote=settings.RATE_LIMIT_VOTE,
)
app.add_middleware(SecurityHeadersMiddleware)
# ── Routers ──────────────────────────────────────────────────────────────
@@ -131,9 +117,6 @@ app.include_router(protocols.router, prefix="/api/v1/protocols", tags=["protocol
app.include_router(sanctuary.router, prefix="/api/v1/sanctuary", tags=["sanctuary"])
app.include_router(websocket.router, prefix="/api/v1/ws", tags=["websocket"])
app.include_router(public.router, prefix="/api/v1/public", tags=["public"])
app.include_router(organizations.router, prefix="/api/v1/organizations", tags=["organizations"])
app.include_router(qualify.router, prefix="/api/v1/qualify", tags=["qualify"])
app.include_router(groups.router, prefix="/api/v1/groups", tags=["groups"])
# ── Health check ─────────────────────────────────────────────────────────
@@ -64,6 +64,14 @@ class RateLimiterMiddleware(BaseHTTPMiddleware):
self._last_cleanup: float = time.time()
self._lock = asyncio.Lock()
def _get_limit_for_path(self, path: str) -> int:
"""Return the rate limit applicable to the given request path."""
if "/auth" in path:
return self.rate_limit_auth
if "/vote" in path:
return self.rate_limit_vote
return self.rate_limit_default
def _get_client_ip(self, request: Request) -> str:
"""Extract the client IP from the request, respecting X-Forwarded-For."""
forwarded = request.headers.get("x-forwarded-for")
@@ -93,22 +101,6 @@ class RateLimiterMiddleware(BaseHTTPMiddleware):
if ips_to_delete:
logger.debug("Nettoyage rate limiter: %d IPs supprimees", len(ips_to_delete))
def _get_limit_for_request(self, request: Request) -> int:
"""Return the rate limit applicable to the given request.
CORS preflight (OPTIONS) requests are never rate-limited — blocking them
breaks authenticated cross-origin requests in browsers.
Strict auth limit applies only to POST (login flows), not to GET /auth/me.
"""
if request.method == "OPTIONS":
return 10_000 # effectively unlimited for preflights
path = request.url.path
if request.method == "POST" and "/auth" in path:
return self.rate_limit_auth
if "/vote" in path:
return self.rate_limit_vote
return self.rate_limit_default
async def dispatch(self, request: Request, call_next) -> Response:
"""Check rate limit and either allow the request or return 429."""
# Skip rate limiting for WebSocket upgrades
@@ -119,7 +111,8 @@ class RateLimiterMiddleware(BaseHTTPMiddleware):
await self._cleanup_old_entries()
client_ip = self._get_client_ip(request)
limit = self._get_limit_for_request(request)
path = request.url.path
limit = self._get_limit_for_path(path)
now = time.time()
window_start = now - 60
@@ -140,7 +133,7 @@ class RateLimiterMiddleware(BaseHTTPMiddleware):
logger.warning(
"Rate limit depasse pour %s sur %s (%d/%d)",
client_ip, request.url.path, request_count, limit,
client_ip, path, request_count, limit,
)
return JSONResponse(
@@ -1,25 +1,19 @@
from app.models.user import DuniterIdentity, Session
from app.models.organization import Organization, OrgMember
from app.models.document import Document, DocumentItem, ItemVersion
from app.models.decision import Decision, DecisionStep
from app.models.vote import VoteSession, Vote
from app.models.mandate import Mandate, MandateStep
from app.models.protocol import VotingProtocol, FormulaConfig
from app.models.qualification import QualificationProtocol
from app.models.group import Group, GroupMember
from app.models.sanctuary import SanctuaryEntry
from app.models.cache import BlockchainCache
__all__ = [
"DuniterIdentity", "Session",
"Organization", "OrgMember",
"Document", "DocumentItem", "ItemVersion",
"Decision", "DecisionStep",
"VoteSession", "Vote",
"Mandate", "MandateStep",
"VotingProtocol", "FormulaConfig",
"QualificationProtocol",
"Group", "GroupMember",
"SanctuaryEntry",
"BlockchainCache",
]
@@ -16,7 +16,6 @@ class Decision(Base):
context: Mapped[str | None] = mapped_column(Text)
decision_type: Mapped[str] = mapped_column(String(64), nullable=False) # runtime_upgrade, document_change, mandate_vote, custom
status: Mapped[str] = mapped_column(String(32), default="draft") # draft, qualification, review, voting, executed, closed
organization_id: Mapped[uuid.UUID | None] = mapped_column(ForeignKey("organizations.id"), nullable=True, index=True)
voting_protocol_id: Mapped[uuid.UUID | None] = mapped_column(ForeignKey("voting_protocols.id"))
created_by_id: Mapped[uuid.UUID | None] = mapped_column(ForeignKey("duniter_identities.id"))
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now())
@@ -17,7 +17,6 @@ class Document(Base):
version: Mapped[str] = mapped_column(String(32), default="0.1.0")
status: Mapped[str] = mapped_column(String(32), default="draft") # draft, active, archived
description: Mapped[str | None] = mapped_column(Text)
organization_id: Mapped[uuid.UUID | None] = mapped_column(ForeignKey("organizations.id"), nullable=True, index=True)
ipfs_cid: Mapped[str | None] = mapped_column(String(128))
chain_anchor: Mapped[str | None] = mapped_column(String(128))
genesis_json: Mapped[str | None] = mapped_column(Text) # JSON: source files, repos, forum URLs, formula trigger
@@ -12,11 +12,9 @@ class Mandate(Base):
id: Mapped[uuid.UUID] = mapped_column(Uuid, primary_key=True, default=uuid.uuid4)
title: Mapped[str] = mapped_column(String(256), nullable=False)
origin_id: Mapped[uuid.UUID | None] = mapped_column(ForeignKey("duniter_identities.id"), nullable=True, index=True)
description: Mapped[str | None] = mapped_column(Text)
mandate_type: Mapped[str] = mapped_column(String(64), nullable=False)
status: Mapped[str] = mapped_column(String(32), default="draft")
organization_id: Mapped[uuid.UUID | None] = mapped_column(ForeignKey("organizations.id"), nullable=True, index=True)
mandate_type: Mapped[str] = mapped_column(String(64), nullable=False) # techcomm, smith, custom
status: Mapped[str] = mapped_column(String(32), default="draft") # draft, candidacy, voting, active, reporting, completed, revoked
mandatee_id: Mapped[uuid.UUID | None] = mapped_column(ForeignKey("duniter_identities.id"))
decision_id: Mapped[uuid.UUID | None] = mapped_column(ForeignKey("decisions.id"))
starts_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True))
@@ -24,27 +22,7 @@ class Mandate(Base):
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now())
updated_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now(), onupdate=func.now())
steps: Mapped[list["MandateStep"]] = relationship(
back_populates="mandate", cascade="all, delete-orphan", order_by="MandateStep.step_order"
)
origin_identity: Mapped["DuniterIdentity | None"] = relationship( # type: ignore[name-defined]
"DuniterIdentity", foreign_keys=[origin_id]
)
mandatee: Mapped["DuniterIdentity | None"] = relationship( # type: ignore[name-defined]
"DuniterIdentity", foreign_keys=[mandatee_id]
)
@property
def origin_display_name(self) -> str | None:
if self.origin_identity is not None:
return self.origin_identity.display_name or self.origin_identity.address
return None
@property
def mandatee_display_name(self) -> str | None:
if self.mandatee is not None:
return self.mandatee.display_name or self.mandatee.address
return None
steps: Mapped[list["MandateStep"]] = relationship(back_populates="mandate", cascade="all, delete-orphan", order_by="MandateStep.step_order")
class MandateStep(Base):
@@ -53,16 +31,12 @@ class MandateStep(Base):
id: Mapped[uuid.UUID] = mapped_column(Uuid, primary_key=True, default=uuid.uuid4)
mandate_id: Mapped[uuid.UUID] = mapped_column(ForeignKey("mandates.id"), nullable=False)
step_order: Mapped[int] = mapped_column(Integer, nullable=False)
step_type: Mapped[str] = mapped_column(String(32), nullable=False)
step_type: Mapped[str] = mapped_column(String(32), nullable=False) # formulation, candidacy, vote, assignment, reporting, completion, revocation
title: Mapped[str | None] = mapped_column(String(256))
description: Mapped[str | None] = mapped_column(Text)
status: Mapped[str] = mapped_column(String(32), default="pending")
status: Mapped[str] = mapped_column(String(32), default="pending") # pending, active, completed, skipped
vote_session_id: Mapped[uuid.UUID | None] = mapped_column(ForeignKey("vote_sessions.id"))
outcome: Mapped[str | None] = mapped_column(Text)
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now())
mandate: Mapped["Mandate"] = relationship(back_populates="steps")
# Avoid circular import — DuniterIdentity imported at runtime by SQLAlchemy relationship resolution
from app.models.user import DuniterIdentity # noqa: E402, F401
@@ -44,7 +44,6 @@ class VotingProtocol(Base):
description: Mapped[str | None] = mapped_column(Text)
vote_type: Mapped[str] = mapped_column(String(32), nullable=False) # binary, nuanced
formula_config_id: Mapped[uuid.UUID] = mapped_column(ForeignKey("formula_configs.id"), nullable=False)
organization_id: Mapped[uuid.UUID | None] = mapped_column(ForeignKey("organizations.id"), nullable=True, index=True)
mode_params: Mapped[str | None] = mapped_column(String(64)) # e.g. "D30M50B.1G.2T.1"
is_meta_governed: Mapped[bool] = mapped_column(Boolean, default=False)
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now())
@@ -5,8 +5,7 @@ from __future__ import annotations
import secrets
from datetime import datetime, timedelta, timezone
from fastapi import APIRouter, Depends, HTTPException, Query, Response, status
from sqlalchemy import or_, select
from fastapi import APIRouter, Depends, HTTPException, Response, status
from sqlalchemy.ext.asyncio import AsyncSession
from app.config import settings
@@ -46,15 +45,15 @@ DEV_PROFILES = [
},
{
"address": "5FLSigC9HGRKVhB9FiEo4Y3koPsNmBmkP7j4bJa3zN7d8tY",
"display_name": "Charlie (Référent structure)",
"display_name": "Charlie (Comite Tech)",
"wot_status": "member",
"is_smith": True,
"is_techcomm": True,
},
{
"address": "5DAAnrj7VHTznn2AWBemMuyBwZWs6FNFjdyVXUeYum3PTXFy",
"display_name": "Dave (Auteur)",
"wot_status": "member",
"display_name": "Dave (Observateur)",
"wot_status": "unknown",
"is_smith": False,
"is_techcomm": False,
},
@@ -133,44 +132,15 @@ async def verify_challenge(
detail="Challenge invalide",
)
# 4. Verify signature
# TODO: trustWallet — déléguer la vérification au protocole trustWallet (librodrome)
# Quand trustWallet sera disponible : remplacer le bloc ci-dessous par une vérification
# du token signé fourni par trustWallet (JWT ou preuve Ed25519 via iframe postMessage).
# Le bypass DEMO_MODE sera alors supprimé.
_demo_addresses = {p["address"] for p in DEV_PROFILES}
is_demo_bypass = (settings.DEMO_MODE or settings.ENVIRONMENT == "development") and payload.address in _demo_addresses
if not is_demo_bypass:
# polkadot.js / Cesium2 signRaw(type='bytes') wraps: <Bytes>{challenge}</Bytes>
message = f"<Bytes>{payload.challenge}</Bytes>".encode("utf-8")
sig_hex = payload.signature.removeprefix("0x")
try:
sig_bytes = bytes.fromhex(sig_hex)
except ValueError:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="Format de signature invalide (hex attendu)",
)
from substrateinterface import Keypair, KeypairType
verified = False
# Try Sr25519 first (default Substrate/Cesium2), then Ed25519 (Duniter v1 migration)
for key_type in [KeypairType.SR25519, KeypairType.ED25519]:
try:
kp = Keypair(ss58_address=payload.address, crypto_type=key_type)
if kp.verify(message, sig_bytes):
verified = True
break
except Exception:
continue
if not verified:
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Signature invalide",
)
# 4. Verify Ed25519 signature
# TODO: Implement actual Ed25519 verification using substrate-interface
# For now we accept any signature to allow development/testing.
# In production this MUST verify: verify(address_pubkey, challenge_bytes, signature_bytes)
#
# from substrateinterface import Keypair
# keypair = Keypair(ss58_address=payload.address)
# if not keypair.verify(payload.challenge.encode(), bytes.fromhex(payload.signature)):
# raise HTTPException(status_code=401, detail="Signature invalide")
# 5. Consume the challenge
del _pending_challenges[payload.address]
@@ -233,24 +203,3 @@ async def logout(
for session in sessions:
await db.delete(session)
await db.commit()
@router.get("/identities", response_model=list[IdentityOut])
async def search_identities(
q: str = Query(..., min_length=1, description="Recherche par adresse ou nom"),
limit: int = Query(default=10, ge=1, le=50),
db: AsyncSession = Depends(get_db),
) -> list[IdentityOut]:
"""Search Duniter identities by address prefix or display_name."""
result = await db.execute(
select(DuniterIdentity)
.where(
or_(
DuniterIdentity.address.ilike(f"{q}%"),
DuniterIdentity.display_name.ilike(f"%{q}%"),
)
)
.order_by(DuniterIdentity.display_name)
.limit(limit)
)
return [IdentityOut.model_validate(i) for i in result.scalars().all()]
@@ -21,7 +21,6 @@ from app.schemas.decision import (
DecisionUpdate,
)
from app.schemas.vote import VoteSessionOut
from app.dependencies.org import get_active_org_id
from app.services.auth_service import get_current_identity
from app.services.decision_service import advance_decision, create_vote_session_for_step
@@ -31,20 +30,13 @@ router = APIRouter()
# ── Helpers ─────────────────────────────────────────────────────────────────
async def _get_decision(
db: AsyncSession, decision_id: uuid.UUID, org_id: uuid.UUID | None = None
) -> Decision:
"""Fetch a decision by ID within the active org scope, or raise 404."""
stmt = (
async def _get_decision(db: AsyncSession, decision_id: uuid.UUID) -> Decision:
"""Fetch a decision by ID with its steps eagerly loaded, or raise 404."""
result = await db.execute(
select(Decision)
.options(selectinload(Decision.steps))
.where(Decision.id == decision_id)
)
if org_id is not None:
stmt = stmt.where(Decision.organization_id == org_id)
else:
stmt = stmt.where(Decision.organization_id.is_(None))
result = await db.execute(stmt)
decision = result.scalar_one_or_none()
if decision is None:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Decision introuvable")
@@ -57,7 +49,6 @@ async def _get_decision(
@router.get("/", response_model=list[DecisionOut])
async def list_decisions(
db: AsyncSession = Depends(get_db),
org_id: uuid.UUID | None = Depends(get_active_org_id),
decision_type: str | None = Query(default=None, description="Filtrer par type de decision"),
status_filter: str | None = Query(default=None, alias="status", description="Filtrer par statut"),
skip: int = Query(default=0, ge=0),
@@ -66,10 +57,6 @@ async def list_decisions(
"""List all decisions with optional filters."""
stmt = select(Decision).options(selectinload(Decision.steps))
if org_id is not None:
stmt = stmt.where(Decision.organization_id == org_id)
else:
stmt = stmt.where(Decision.organization_id.is_(None))
if decision_type is not None:
stmt = stmt.where(Decision.decision_type == decision_type)
if status_filter is not None:
@@ -87,20 +74,18 @@ async def create_decision(
payload: DecisionCreate,
db: AsyncSession = Depends(get_db),
identity: DuniterIdentity = Depends(get_current_identity),
org_id: uuid.UUID | None = Depends(get_active_org_id),
) -> DecisionOut:
"""Create a new decision process."""
decision = Decision(
**payload.model_dump(),
created_by_id=identity.id,
organization_id=org_id,
)
db.add(decision)
await db.commit()
await db.refresh(decision)
# Reload with steps (empty at creation)
decision = await _get_decision(db, decision.id, org_id)
decision = await _get_decision(db, decision.id)
return DecisionOut.model_validate(decision)
@@ -108,10 +93,9 @@ async def create_decision(
async def get_decision(
id: uuid.UUID,
db: AsyncSession = Depends(get_db),
org_id: uuid.UUID | None = Depends(get_active_org_id),
) -> DecisionOut:
"""Get a single decision with all its steps."""
decision = await _get_decision(db, id, org_id)
decision = await _get_decision(db, id)
return DecisionOut.model_validate(decision)
@@ -121,10 +105,9 @@ async def update_decision(
payload: DecisionUpdate,
db: AsyncSession = Depends(get_db),
identity: DuniterIdentity = Depends(get_current_identity),
org_id: uuid.UUID | None = Depends(get_active_org_id),
) -> DecisionOut:
"""Update a decision's metadata (title, description, status, protocol)."""
decision = await _get_decision(db, id, org_id)
decision = await _get_decision(db, id)
update_data = payload.model_dump(exclude_unset=True)
for field, value in update_data.items():
@@ -134,7 +117,7 @@ async def update_decision(
await db.refresh(decision)
# Reload with steps
decision = await _get_decision(db, decision.id, org_id)
decision = await _get_decision(db, decision.id)
return DecisionOut.model_validate(decision)
@@ -147,11 +130,10 @@ async def add_step(
payload: DecisionStepCreate,
db: AsyncSession = Depends(get_db),
identity: DuniterIdentity = Depends(get_current_identity),
org_id: uuid.UUID | None = Depends(get_active_org_id),
) -> DecisionStepOut:
"""Add a step to a decision process."""
# Verify decision exists
decision = await _get_decision(db, id, org_id)
decision = await _get_decision(db, id)
step = DecisionStep(
decision_id=decision.id,
@@ -172,7 +154,6 @@ async def advance_decision_endpoint(
id: uuid.UUID,
db: AsyncSession = Depends(get_db),
identity: DuniterIdentity = Depends(get_current_identity),
org_id: uuid.UUID | None = Depends(get_active_org_id),
) -> DecisionAdvanceOut:
"""Advance a decision to its next step or status."""
try:
@@ -181,7 +162,7 @@ async def advance_decision_endpoint(
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=str(exc))
# Reload with steps for complete output
decision = await _get_decision(db, decision.id, org_id)
decision = await _get_decision(db, decision.id)
data = DecisionOut.model_validate(decision).model_dump()
data["message"] = f"Decision avancee au statut : {decision.status}"
return DecisionAdvanceOut(**data)
@@ -197,7 +178,6 @@ async def create_vote_session_for_step_endpoint(
step_id: uuid.UUID,
db: AsyncSession = Depends(get_db),
identity: DuniterIdentity = Depends(get_current_identity),
org_id: uuid.UUID | None = Depends(get_active_org_id),
) -> VoteSessionOut:
"""Create a vote session linked to a decision step."""
try:
@@ -213,10 +193,9 @@ async def delete_decision(
id: uuid.UUID,
db: AsyncSession = Depends(get_db),
identity: DuniterIdentity = Depends(get_current_identity),
org_id: uuid.UUID | None = Depends(get_active_org_id),
) -> None:
"""Delete a decision (only if in draft status)."""
decision = await _get_decision(db, id, org_id)
decision = await _get_decision(db, id)
if decision.status != "draft":
raise HTTPException(
@@ -25,7 +25,6 @@ from app.schemas.document import (
ItemVersionCreate,
ItemVersionOut,
)
from app.dependencies.org import get_active_org_id
from app.services import document_service
from app.services.auth_service import get_current_identity
@@ -66,7 +65,6 @@ async def _get_item(db: AsyncSession, document_id: uuid.UUID, item_id: uuid.UUID
@router.get("/", response_model=list[DocumentOut])
async def list_documents(
db: AsyncSession = Depends(get_db),
org_id: uuid.UUID | None = Depends(get_active_org_id),
doc_type: str | None = Query(default=None, description="Filtrer par type de document"),
status_filter: str | None = Query(default=None, alias="status", description="Filtrer par statut"),
skip: int = Query(default=0, ge=0),
@@ -75,10 +73,6 @@ async def list_documents(
"""List all reference documents, with optional filters."""
stmt = select(Document)
if org_id is not None:
stmt = stmt.where(Document.organization_id == org_id)
else:
stmt = stmt.where(Document.organization_id.is_(None))
if doc_type is not None:
stmt = stmt.where(Document.doc_type == doc_type)
if status_filter is not None:
@@ -107,7 +101,6 @@ async def create_document(
payload: DocumentCreate,
db: AsyncSession = Depends(get_db),
identity: DuniterIdentity = Depends(get_current_identity),
org_id: uuid.UUID | None = Depends(get_active_org_id),
) -> DocumentOut:
"""Create a new reference document."""
# Check slug uniqueness
@@ -118,7 +111,7 @@ async def create_document(
detail="Un document avec ce slug existe deja",
)
doc = Document(**payload.model_dump(), organization_id=org_id)
doc = Document(**payload.model_dump())
db.add(doc)
await db.commit()
await db.refresh(doc)
@@ -22,7 +22,6 @@ from app.schemas.mandate import (
MandateUpdate,
)
from app.schemas.vote import VoteSessionOut
from app.dependencies.org import get_active_org_id
from app.services.auth_service import get_current_identity
from app.services.mandate_service import (
advance_mandate,
@@ -37,58 +36,33 @@ router = APIRouter()
# ── Helpers ─────────────────────────────────────────────────────────────────
async def _get_mandate(
db: AsyncSession, mandate_id: uuid.UUID, org_id: uuid.UUID | None = None
) -> Mandate:
stmt = (
async def _get_mandate(db: AsyncSession, mandate_id: uuid.UUID) -> Mandate:
"""Fetch a mandate by ID with its steps eagerly loaded, or raise 404."""
result = await db.execute(
select(Mandate)
.options(
selectinload(Mandate.steps),
selectinload(Mandate.origin_identity),
selectinload(Mandate.mandatee),
)
.options(selectinload(Mandate.steps))
.where(Mandate.id == mandate_id)
)
if org_id is not None:
stmt = stmt.where(Mandate.organization_id == org_id)
else:
stmt = stmt.where(Mandate.organization_id.is_(None))
result = await db.execute(stmt)
mandate = result.scalar_one_or_none()
if mandate is None:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Mandat introuvable")
return mandate
def _mandate_out(mandate: Mandate) -> MandateOut:
out = MandateOut.model_validate(mandate)
out.origin_display_name = mandate.origin_display_name
out.mandatee_display_name = mandate.mandatee_display_name
return out
# ── Mandate routes ──────────────────────────────────────────────────────────
@router.get("/", response_model=list[MandateOut])
async def list_mandates(
db: AsyncSession = Depends(get_db),
org_id: uuid.UUID | None = Depends(get_active_org_id),
mandate_type: str | None = Query(default=None),
status_filter: str | None = Query(default=None, alias="status"),
mandate_type: str | None = Query(default=None, description="Filtrer par type de mandat"),
status_filter: str | None = Query(default=None, alias="status", description="Filtrer par statut"),
skip: int = Query(default=0, ge=0),
limit: int = Query(default=50, ge=1, le=200),
) -> list[MandateOut]:
stmt = select(Mandate).options(
selectinload(Mandate.steps),
selectinload(Mandate.origin_identity),
selectinload(Mandate.mandatee),
)
"""List all mandates with optional filters."""
stmt = select(Mandate).options(selectinload(Mandate.steps))
if org_id is not None:
stmt = stmt.where(Mandate.organization_id == org_id)
else:
stmt = stmt.where(Mandate.organization_id.is_(None))
if mandate_type is not None:
stmt = stmt.where(Mandate.mandate_type == mandate_type)
if status_filter is not None:
@@ -98,7 +72,7 @@ async def list_mandates(
result = await db.execute(stmt)
mandates = result.scalars().unique().all()
return [_mandate_out(m) for m in mandates]
return [MandateOut.model_validate(m) for m in mandates]
@router.post("/", response_model=MandateOut, status_code=status.HTTP_201_CREATED)
@@ -106,32 +80,26 @@ async def create_mandate(
payload: MandateCreate,
db: AsyncSession = Depends(get_db),
identity: DuniterIdentity = Depends(get_current_identity),
org_id: uuid.UUID | None = Depends(get_active_org_id),
) -> MandateOut:
data = payload.model_dump()
nomination_mode = data.pop("nomination_mode", "postpone")
mandate = Mandate(**data, organization_id=org_id)
if nomination_mode == "auto":
mandate.mandatee_id = identity.id
"""Create a new mandate."""
mandate = Mandate(**payload.model_dump())
db.add(mandate)
await db.commit()
await db.refresh(mandate)
mandate = await _get_mandate(db, mandate.id, org_id)
return _mandate_out(mandate)
# Reload with steps (empty at creation)
mandate = await _get_mandate(db, mandate.id)
return MandateOut.model_validate(mandate)
@router.get("/{id}", response_model=MandateOut)
async def get_mandate(
id: uuid.UUID,
db: AsyncSession = Depends(get_db),
org_id: uuid.UUID | None = Depends(get_active_org_id),
) -> MandateOut:
mandate = await _get_mandate(db, id, org_id)
return _mandate_out(mandate)
"""Get a single mandate with all its steps."""
mandate = await _get_mandate(db, id)
return MandateOut.model_validate(mandate)
@router.put("/{id}", response_model=MandateOut)
@@ -140,16 +108,20 @@ async def update_mandate(
payload: MandateUpdate,
db: AsyncSession = Depends(get_db),
identity: DuniterIdentity = Depends(get_current_identity),
org_id: uuid.UUID | None = Depends(get_active_org_id),
) -> MandateOut:
mandate = await _get_mandate(db, id, org_id)
"""Update a mandate's metadata."""
mandate = await _get_mandate(db, id)
for field, value in payload.model_dump(exclude_unset=True).items():
update_data = payload.model_dump(exclude_unset=True)
for field, value in update_data.items():
setattr(mandate, field, value)
await db.commit()
mandate = await _get_mandate(db, mandate.id, org_id)
return _mandate_out(mandate)
await db.refresh(mandate)
# Reload with steps
mandate = await _get_mandate(db, mandate.id)
return MandateOut.model_validate(mandate)
@router.delete("/{id}", status_code=status.HTTP_204_NO_CONTENT, response_class=Response, response_model=None)
@@ -157,9 +129,9 @@ async def delete_mandate(
id: uuid.UUID,
db: AsyncSession = Depends(get_db),
identity: DuniterIdentity = Depends(get_current_identity),
org_id: uuid.UUID | None = Depends(get_active_org_id),
) -> None:
mandate = await _get_mandate(db, id, org_id)
"""Delete a mandate (only if in draft status)."""
mandate = await _get_mandate(db, id)
if mandate.status != "draft":
raise HTTPException(
@@ -180,11 +152,14 @@ async def add_step(
payload: MandateStepCreate,
db: AsyncSession = Depends(get_db),
identity: DuniterIdentity = Depends(get_current_identity),
org_id: uuid.UUID | None = Depends(get_active_org_id),
) -> MandateStepOut:
mandate = await _get_mandate(db, id, org_id)
"""Add a step to a mandate process."""
mandate = await _get_mandate(db, id)
step = MandateStep(mandate_id=mandate.id, **payload.model_dump())
step = MandateStep(
mandate_id=mandate.id,
**payload.model_dump(),
)
db.add(step)
await db.commit()
await db.refresh(step)
@@ -196,9 +171,9 @@ async def add_step(
async def list_steps(
id: uuid.UUID,
db: AsyncSession = Depends(get_db),
org_id: uuid.UUID | None = Depends(get_active_org_id),
) -> list[MandateStepOut]:
mandate = await _get_mandate(db, id, org_id)
"""List all steps for a mandate, ordered by step_order."""
mandate = await _get_mandate(db, id)
return [MandateStepOut.model_validate(s) for s in mandate.steps]
@@ -210,19 +185,18 @@ async def advance_mandate_endpoint(
id: uuid.UUID,
db: AsyncSession = Depends(get_db),
identity: DuniterIdentity = Depends(get_current_identity),
org_id: uuid.UUID | None = Depends(get_active_org_id),
) -> MandateAdvanceOut:
"""Advance a mandate to its next step or status."""
try:
mandate = await advance_mandate(id, db)
except ValueError as exc:
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=str(exc))
mandate = await _get_mandate(db, mandate.id, org_id)
out = _mandate_out(mandate)
return MandateAdvanceOut(
**out.model_dump(),
message=f"Mandat avance au statut : {mandate.status}",
)
# Reload with steps for complete output
mandate = await _get_mandate(db, mandate.id)
data = MandateOut.model_validate(mandate).model_dump()
data["message"] = f"Mandat avance au statut : {mandate.status}"
return MandateAdvanceOut(**data)
@router.post("/{id}/assign", response_model=MandateOut)
@@ -231,15 +205,16 @@ async def assign_mandatee_endpoint(
payload: MandateAssignRequest,
db: AsyncSession = Depends(get_db),
identity: DuniterIdentity = Depends(get_current_identity),
org_id: uuid.UUID | None = Depends(get_active_org_id),
) -> MandateOut:
"""Assign a mandatee to a mandate."""
try:
mandate = await assign_mandatee(id, payload.mandatee_id, db)
except ValueError as exc:
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=str(exc))
mandate = await _get_mandate(db, mandate.id, org_id)
return _mandate_out(mandate)
# Reload with steps
mandate = await _get_mandate(db, mandate.id)
return MandateOut.model_validate(mandate)
@router.post("/{id}/revoke", response_model=MandateOut)
@@ -247,15 +222,16 @@ async def revoke_mandate_endpoint(
id: uuid.UUID,
db: AsyncSession = Depends(get_db),
identity: DuniterIdentity = Depends(get_current_identity),
org_id: uuid.UUID | None = Depends(get_active_org_id),
) -> MandateOut:
"""Revoke an active mandate."""
try:
mandate = await revoke_mandate(id, db)
except ValueError as exc:
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=str(exc))
mandate = await _get_mandate(db, mandate.id, org_id)
return _mandate_out(mandate)
# Reload with steps
mandate = await _get_mandate(db, mandate.id)
return MandateOut.model_validate(mandate)
@router.post(
@@ -268,8 +244,8 @@ async def create_vote_session_for_step_endpoint(
step_id: uuid.UUID,
db: AsyncSession = Depends(get_db),
identity: DuniterIdentity = Depends(get_current_identity),
org_id: uuid.UUID | None = Depends(get_active_org_id),
) -> VoteSessionOut:
"""Create a vote session linked to a mandate step."""
try:
session = await create_vote_session_for_step(id, step_id, db)
except ValueError as exc:
@@ -25,7 +25,6 @@ from app.schemas.protocol import (
VotingProtocolOut,
VotingProtocolUpdate,
)
from app.dependencies.org import get_active_org_id
from app.services.auth_service import get_current_identity
router = APIRouter()
@@ -34,20 +33,13 @@ router = APIRouter()
# ── Helpers ─────────────────────────────────────────────────────────────────
async def _get_protocol(
db: AsyncSession, protocol_id: uuid.UUID, org_id: uuid.UUID | None = None
) -> VotingProtocol:
"""Fetch a voting protocol by ID within the active org scope, or raise 404."""
stmt = (
async def _get_protocol(db: AsyncSession, protocol_id: uuid.UUID) -> VotingProtocol:
"""Fetch a voting protocol by ID with its formula config, or raise 404."""
result = await db.execute(
select(VotingProtocol)
.options(selectinload(VotingProtocol.formula_config))
.where(VotingProtocol.id == protocol_id)
)
if org_id is not None:
stmt = stmt.where(VotingProtocol.organization_id == org_id)
else:
stmt = stmt.where(VotingProtocol.organization_id.is_(None))
result = await db.execute(stmt)
protocol = result.scalar_one_or_none()
if protocol is None:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Protocole introuvable")
@@ -71,7 +63,6 @@ async def _get_formula(db: AsyncSession, formula_id: uuid.UUID) -> FormulaConfig
@router.get("/", response_model=list[VotingProtocolOut])
async def list_protocols(
db: AsyncSession = Depends(get_db),
org_id: uuid.UUID | None = Depends(get_active_org_id),
vote_type: str | None = Query(default=None, description="Filtrer par type de vote (binary, nuanced)"),
skip: int = Query(default=0, ge=0),
limit: int = Query(default=50, ge=1, le=200),
@@ -79,10 +70,6 @@ async def list_protocols(
"""List all voting protocols with their formula configurations."""
stmt = select(VotingProtocol).options(selectinload(VotingProtocol.formula_config))
if org_id is not None:
stmt = stmt.where(VotingProtocol.organization_id == org_id)
else:
stmt = stmt.where(VotingProtocol.organization_id.is_(None))
if vote_type is not None:
stmt = stmt.where(VotingProtocol.vote_type == vote_type)
@@ -98,7 +85,6 @@ async def create_protocol(
payload: VotingProtocolCreate,
db: AsyncSession = Depends(get_db),
identity: DuniterIdentity = Depends(get_current_identity),
org_id: uuid.UUID | None = Depends(get_active_org_id),
) -> VotingProtocolOut:
"""Create a new voting protocol.
@@ -114,13 +100,13 @@ async def create_protocol(
detail="Configuration de formule introuvable",
)
protocol = VotingProtocol(**payload.model_dump(), organization_id=org_id)
protocol = VotingProtocol(**payload.model_dump())
db.add(protocol)
await db.commit()
await db.refresh(protocol)
# Reload with formula config
protocol = await _get_protocol(db, protocol.id, org_id)
protocol = await _get_protocol(db, protocol.id)
return VotingProtocolOut.model_validate(protocol)
@@ -128,10 +114,9 @@ async def create_protocol(
async def get_protocol(
id: uuid.UUID,
db: AsyncSession = Depends(get_db),
org_id: uuid.UUID | None = Depends(get_active_org_id),
) -> VotingProtocolOut:
"""Get a single voting protocol with its formula configuration."""
protocol = await _get_protocol(db, id, org_id)
protocol = await _get_protocol(db, id)
return VotingProtocolOut.model_validate(protocol)
@@ -141,13 +126,12 @@ async def update_protocol(
payload: VotingProtocolUpdate,
db: AsyncSession = Depends(get_db),
identity: DuniterIdentity = Depends(get_current_identity),
org_id: uuid.UUID | None = Depends(get_active_org_id),
) -> VotingProtocolOut:
"""Update a voting protocol (meta-governance).
Only provided fields will be updated. Requires authentication.
"""
protocol = await _get_protocol(db, id, org_id)
protocol = await _get_protocol(db, id)
update_data = payload.model_dump(exclude_unset=True)
for field, value in update_data.items():
@@ -156,7 +140,7 @@ async def update_protocol(
await db.commit()
# Reload with formula config
protocol = await _get_protocol(db, protocol.id, org_id)
protocol = await _get_protocol(db, protocol.id)
return VotingProtocolOut.model_validate(protocol)
@@ -25,7 +25,6 @@ from app.schemas.vote import (
VoteSessionListOut,
VoteSessionOut,
)
from app.dependencies.org import get_active_org_id
from app.services.auth_service import get_current_identity
from app.services.vote_service import (
close_session as svc_close_session,
@@ -171,22 +170,14 @@ def _compute_result(
@router.get("/sessions", response_model=list[VoteSessionListOut])
async def list_vote_sessions(
db: AsyncSession = Depends(get_db),
org_id: uuid.UUID | None = Depends(get_active_org_id),
session_status: str | None = Query(default=None, alias="status", description="Filtrer par statut (open, closed, tallied)"),
decision_id: uuid.UUID | None = Query(default=None, description="Filtrer par decision_id"),
skip: int = Query(default=0, ge=0),
limit: int = Query(default=50, ge=1, le=200),
) -> list[VoteSessionListOut]:
"""List all vote sessions with optional filters by status and decision_id."""
stmt = (
select(VoteSession)
.join(VotingProtocol, VoteSession.voting_protocol_id == VotingProtocol.id)
)
stmt = select(VoteSession)
if org_id is not None:
stmt = stmt.where(VotingProtocol.organization_id == org_id)
else:
stmt = stmt.where(VotingProtocol.organization_id.is_(None))
if session_status is not None:
stmt = stmt.where(VoteSession.status == session_status)
if decision_id is not None:
@@ -10,13 +10,21 @@ from pydantic import BaseModel, ConfigDict, Field
class MandateStepCreate(BaseModel):
"""Payload for creating a step within a mandate process."""
step_order: int = Field(..., ge=0)
step_type: str = Field(..., max_length=32)
step_type: str = Field(
...,
max_length=32,
description="formulation, candidacy, vote, assignment, reporting, completion, revocation",
)
title: str | None = Field(default=None, max_length=256)
description: str | None = None
class MandateStepOut(BaseModel):
"""Full mandate step representation."""
model_config = ConfigDict(from_attributes=True)
id: UUID
@@ -35,45 +43,40 @@ class MandateStepOut(BaseModel):
class MandateCreate(BaseModel):
"""Payload for creating a new mandate."""
title: str = Field(..., min_length=1, max_length=256)
origin_id: UUID | None = None
description: str | None = None
mandate_type: str = Field(..., max_length=64)
nomination_mode: str = Field(
default="postpone",
description="auto (auto-assign author), collective, postpone",
)
mandate_type: str = Field(..., max_length=64, description="techcomm, smith, custom")
decision_id: UUID | None = None
starts_at: datetime | None = None
ends_at: datetime | None = None
class MandateUpdate(BaseModel):
"""Partial update for a mandate."""
title: str | None = Field(default=None, max_length=256)
origin_id: UUID | None = None
description: str | None = None
mandate_type: str | None = Field(default=None, max_length=64)
decision_id: UUID | None = None
starts_at: datetime | None = None
ends_at: datetime | None = None
class MandateAssignRequest(BaseModel):
mandatee_id: UUID = Field(..., description="UUID de l'identite Duniter du mandataire")
"""Request body for assigning a mandatee to a mandate."""
mandatee_id: UUID = Field(..., description="ID de l'identite Duniter du mandataire")
class MandateOut(BaseModel):
"""Full mandate representation returned by the API."""
model_config = ConfigDict(from_attributes=True)
id: UUID
title: str
origin_id: UUID | None = None
origin_display_name: str | None = None
description: str | None = None
mandate_type: str
status: str
mandatee_id: UUID | None = None
mandatee_display_name: str | None = None
decision_id: UUID | None = None
starts_at: datetime | None = None
ends_at: datetime | None = None
@@ -82,5 +85,21 @@ class MandateOut(BaseModel):
steps: list[MandateStepOut] = Field(default_factory=list)
class MandateAdvanceOut(MandateOut):
class MandateAdvanceOut(BaseModel):
"""Output after advancing a mandate through its workflow."""
model_config = ConfigDict(from_attributes=True)
id: UUID
title: str
description: str | None = None
mandate_type: str
status: str
mandatee_id: UUID | None = None
decision_id: UUID | None = None
starts_at: datetime | None = None
ends_at: datetime | None = None
created_at: datetime
updated_at: datetime
steps: list[MandateStepOut] = Field(default_factory=list)
message: str = Field(..., description="Message decrivant l'avancement effectue")
+9 -95
View File
@@ -32,8 +32,6 @@ from app.models.protocol import FormulaConfig, VotingProtocol
from app.models.document import Document, DocumentItem
from app.models.decision import Decision, DecisionStep
from app.models.mandate import Mandate, MandateStep
from app.models.organization import Organization
from app.models.qualification import QualificationProtocol
from app.models.user import DuniterIdentity
from app.models.vote import VoteSession, Vote
@@ -163,7 +161,6 @@ async def seed_formula_configs(session: AsyncSession) -> dict[str, FormulaConfig
async def seed_voting_protocols(
session: AsyncSession,
formulas: dict[str, FormulaConfig],
org_id: uuid.UUID | None = None,
) -> dict[str, VotingProtocol]:
protocols: dict[str, dict] = {
"Vote WoT standard": {
@@ -209,7 +206,6 @@ async def seed_voting_protocols(
instance, created = await get_or_create(
session, VotingProtocol, "name", name, **params,
)
instance.organization_id = org_id
status = "created" if created else "exists"
print(f" VotingProtocol '{name}': {status}")
result[name] = instance
@@ -833,7 +829,6 @@ ENGAGEMENT_CERTIFICATION_ITEMS: list[dict] = [
async def seed_document_engagement_certification(
session: AsyncSession,
protocols: dict[str, VotingProtocol],
org_id: uuid.UUID | None = None,
) -> Document:
genesis = json.dumps(GENESIS_CERTIFICATION, ensure_ascii=False, indent=2)
@@ -855,7 +850,6 @@ async def seed_document_engagement_certification(
),
genesis_json=genesis,
)
doc.organization_id = org_id
print(f" Document 'Acte d'engagement Certification': {'created' if created else 'exists'}")
if created:
@@ -1899,7 +1893,6 @@ ENGAGEMENT_FORGERON_ITEMS: list[dict] = [
async def seed_document_engagement_forgeron(
session: AsyncSession,
protocols: dict[str, VotingProtocol],
org_id: uuid.UUID | None = None,
) -> Document:
genesis = json.dumps(GENESIS_FORGERON, ensure_ascii=False, indent=2)
@@ -1923,7 +1916,6 @@ async def seed_document_engagement_forgeron(
),
genesis_json=genesis,
)
doc.organization_id = org_id
print(f" Document 'Acte d'engagement forgeron': {'created' if created else 'exists'}")
if created:
@@ -1996,7 +1988,7 @@ RUNTIME_UPGRADE_STEPS: list[dict] = [
]
async def seed_decision_runtime_upgrade(session: AsyncSession, org_id: uuid.UUID | None = None) -> Decision:
async def seed_decision_runtime_upgrade(session: AsyncSession) -> Decision:
decision, created = await get_or_create(
session,
Decision,
@@ -2017,7 +2009,6 @@ async def seed_decision_runtime_upgrade(session: AsyncSession, org_id: uuid.UUID
decision_type="runtime_upgrade",
status="draft",
)
decision.organization_id = org_id
print(f" Decision 'Runtime Upgrade': {'created' if created else 'exists'}")
if created:
@@ -2157,7 +2148,7 @@ async def seed_votes_on_items(
# Seed: Additional decisions (demo content)
# ---------------------------------------------------------------------------
async def seed_decision_licence_evolution(session: AsyncSession, org_id: uuid.UUID | None = None) -> Decision:
async def seed_decision_licence_evolution(session: AsyncSession) -> Decision:
"""Seed a community decision: evolution of the G1 monetary license."""
decision, created = await get_or_create(
session,
@@ -2179,7 +2170,6 @@ async def seed_decision_licence_evolution(session: AsyncSession, org_id: uuid.UU
decision_type="community",
status="draft",
)
decision.organization_id = org_id
print(f" Decision 'Évolution Licence G1 v0.4.0': {'created' if created else 'exists'}")
if created:
@@ -2235,7 +2225,7 @@ async def seed_decision_licence_evolution(session: AsyncSession, org_id: uuid.UU
# Seed: Mandates (Comité Technique + Admin Forgerons)
# ---------------------------------------------------------------------------
async def seed_mandates(session: AsyncSession, voters: list[DuniterIdentity], org_id: uuid.UUID | None = None) -> None:
async def seed_mandates(session: AsyncSession, voters: list[DuniterIdentity]) -> None:
"""Seed example mandates: TechComm and Smith Admin."""
now = datetime.now(timezone.utc)
@@ -2407,7 +2397,6 @@ async def seed_mandates(session: AsyncSession, voters: list[DuniterIdentity], or
m_data["title"],
**{k: v for k, v in m_data.items() if k != "title"},
)
mandate.organization_id = org_id
status_str = "created" if created else "exists"
print(f" Mandate '{mandate.title[:50]}': {status_str}")
@@ -2419,78 +2408,10 @@ async def seed_mandates(session: AsyncSession, voters: list[DuniterIdentity], or
print(f" -> {len(steps_data)} steps created")
# ---------------------------------------------------------------------------
# Seed: Organizations
# ---------------------------------------------------------------------------
async def seed_organizations(session: AsyncSession) -> dict[str, Organization]:
"""Create the two base transparent organizations (idempotent)."""
orgs_data = [
{
"slug": "duniter-g1",
"name": "Duniter G1",
"org_type": "community",
"is_transparent": True,
"color": "#22c55e",
"icon": "i-lucide-globe",
"description": "Communauté Duniter — monnaie libre G1. Accessible à tous les membres authentifiés.",
},
{
"slug": "axiom-team",
"name": "Axiom Team",
"org_type": "collective",
"is_transparent": True,
"color": "#3b82f6",
"icon": "i-lucide-users",
"description": "Équipe Axiom — développement et gouvernance des outils communs.",
},
]
orgs: dict[str, Organization] = {}
for data in orgs_data:
org, created = await get_or_create(session, Organization, "slug", data["slug"], **{k: v for k, v in data.items() if k != "slug"})
status_str = "created" if created else "exists"
print(f" Organisation '{org.name}': {status_str}")
orgs[org.slug] = org
return orgs
# ---------------------------------------------------------------------------
# Main seed runner
# ---------------------------------------------------------------------------
async def seed_qualification_protocol(session: AsyncSession) -> QualificationProtocol:
"""Seed the default qualification protocol (thresholds for the Décider tunnel)."""
stmt = select(QualificationProtocol).where(QualificationProtocol.is_active == True) # noqa: E712
result = await session.execute(stmt)
existing = result.scalar_one_or_none()
if existing is not None:
print(" [skip] Protocole de qualification déjà présent")
return existing
proto = QualificationProtocol(
name="Protocole de qualification par défaut",
description=(
"Seuils utilisés par le tunnel Décider pour router vers "
"individual/collective et proposer les modalités de vote."
),
small_group_max=5,
collective_wot_min=50,
default_modalities_json=json.dumps([
"vote_wot",
"vote_smith",
"consultation_avis",
"election",
]),
is_active=True,
)
session.add(proto)
await session.flush()
print(" [ok] Protocole de qualification créé")
return proto
async def run_seed():
print("=" * 60)
print("libreDecision - Seed Database")
@@ -2502,30 +2423,23 @@ async def run_seed():
async with async_session() as session:
async with session.begin():
print("\n[0/10] Organizations...")
orgs = await seed_organizations(session)
duniter_g1_id = orgs["duniter-g1"].id
print("\n[0b] Protocole de qualification...")
await seed_qualification_protocol(session)
print("\n[1/10] Formula Configs...")
formulas = await seed_formula_configs(session)
print("\n[2/10] Voting Protocols...")
protocols = await seed_voting_protocols(session, formulas, org_id=duniter_g1_id)
protocols = await seed_voting_protocols(session, formulas)
print("\n[3/10] Document: Acte d'engagement Certification...")
await seed_document_engagement_certification(session, protocols, org_id=duniter_g1_id)
await seed_document_engagement_certification(session, protocols)
print("\n[4/10] Document: Acte d'engagement forgeron v2.0.0...")
doc_forgeron = await seed_document_engagement_forgeron(session, protocols, org_id=duniter_g1_id)
doc_forgeron = await seed_document_engagement_forgeron(session, protocols)
print("\n[5/10] Decision: Runtime Upgrade...")
await seed_decision_runtime_upgrade(session, org_id=duniter_g1_id)
await seed_decision_runtime_upgrade(session)
print("\n[6/10] Decision: Évolution Licence G1 v0.4.0...")
await seed_decision_licence_evolution(session, org_id=duniter_g1_id)
await seed_decision_licence_evolution(session)
print("\n[7/10] Simulated voters...")
voters = await seed_voters(session)
@@ -2539,7 +2453,7 @@ async def run_seed():
)
print("\n[9/10] Mandates...")
await seed_mandates(session, voters, org_id=duniter_g1_id)
await seed_mandates(session, voters)
print("\n[10/10] Done.")
-19
View File
@@ -1,19 +0,0 @@
[package]
name = "ld-engine"
description = "Pure decision engines of libreDecision: inertial threshold, edit classification, editing assistant, document composition."
version.workspace = true
edition.workspace = true
rust-version.workspace = true
publish.workspace = true
license.workspace = true
[dependencies]
blake2 = "0.10"
serde = { version = "1", features = ["derive"] }
unicode-normalization = "0.1"
[dev-dependencies]
serde_json = "1"
[lints]
workspace = true
-185
View File
@@ -1,185 +0,0 @@
//! Ğ1 account addresses (SS58).
//!
//! The same public key reads `g1…` on the Ğ1 chain (prefix 4450, what the
//! indexer and Ğecko show) and `5…` in the generic Substrate form (what
//! duniter-connect shows). Accounts are compared by key, never by text.
/// The SS58 prefix of the Ğ1 chain.
pub const G1_PREFIX: u16 = 4450;
/// The longest text read as an address. An SS58 address of a 32-byte key is
/// about 48 characters; base58 decoding costs the square of the length, so a
/// longer text is refused before it is decoded (review of 74f96be: 250 000
/// characters took 32 s on a thread of the server).
pub const MAX_LEN: usize = 64;
const ALPHABET: &[u8; 58] = b"123456789ABCDEFGHJKLMNPQRSTUVWXYZabcdefghijkmnopqrstuvwxyz";
fn base58_decode(s: &str) -> Option<Vec<u8>> {
// Big-endian bytes of the number, grown symbol by symbol.
let mut number: Vec<u8> = Vec::new();
for c in s.bytes() {
let mut carry = u32::try_from(ALPHABET.iter().position(|a| *a == c)?).ok()?;
for b in number.iter_mut().rev() {
carry += u32::from(*b) * 58;
*b = (carry & 0xff) as u8;
carry >>= 8;
}
while carry > 0 {
number.insert(0, (carry & 0xff) as u8);
carry >>= 8;
}
}
let mut out = vec![0u8; s.bytes().take_while(|c| *c == b'1').count()];
out.extend(number);
Some(out)
}
fn base58_encode(data: &[u8]) -> String {
// Little-endian base-58 symbols.
let mut symbols: Vec<u8> = Vec::new();
for byte in data {
let mut carry = u32::from(*byte);
for d in symbols.iter_mut() {
carry += u32::from(*d) << 8;
*d = (carry % 58) as u8;
carry /= 58;
}
while carry > 0 {
symbols.push((carry % 58) as u8);
carry /= 58;
}
}
let zeros = data.iter().take_while(|b| **b == 0).count();
std::iter::repeat_n('1', zeros).chain(symbols.iter().rev().map(|d| char::from(ALPHABET[usize::from(*d)]))).collect()
}
/// The two bytes that close an address: blake2b-512 of `SS58PRE`, the prefix and the key.
fn checksum(payload: &[u8]) -> [u8; 2] {
use blake2::{Blake2b512, Digest};
let mut h = Blake2b512::new();
h.update(b"SS58PRE");
h.update(payload);
let out = h.finalize();
[out[0], out[1]]
}
fn prefix_bytes(prefix: u16) -> Vec<u8> {
if prefix < 64 {
vec![prefix as u8]
} else {
// Two bytes for the prefixes 64 to 16383, as Substrate writes them.
vec![
(((prefix & 0b1111_1100) >> 2) as u8) | 0b0100_0000,
((prefix >> 8) as u8) | (((prefix & 0b11) as u8) << 6),
]
}
}
/// The prefix and the 32-byte public key of an SS58 address, if it is one.
pub fn decode(addr: &str) -> Option<(u16, [u8; 32])> {
if addr.len() > MAX_LEN {
return None;
}
let data = base58_decode(addr)?;
let (prefix, at) = match *data.first()? {
b @ 0..=63 => (u16::from(b), 1),
b @ 64..=127 => {
let b1 = *data.get(1)?;
let lower = ((b & 0b0011_1111) << 2) | (b1 >> 6);
let upper = b1 & 0b0011_1111;
(u16::from(lower) | (u16::from(upper) << 8), 2)
}
_ => return None,
};
if data.len() != at + 32 + 2 {
return None;
}
let (payload, sum) = data.split_at(at + 32);
if checksum(payload) != sum {
return None;
}
Some((prefix, payload[at..].try_into().ok()?))
}
/// The SS58 address of a public key under a prefix (up to 16383).
pub fn encode(prefix: u16, key: &[u8; 32]) -> String {
let mut data = prefix_bytes(prefix & 0x3fff);
data.extend_from_slice(key);
let sum = checksum(&data);
data.extend_from_slice(&sum);
base58_encode(&data)
}
/// The Ğ1 form of an address in any SS58 form.
pub fn to_g1(addr: &str) -> Option<String> {
decode(addr).map(|(_, key)| encode(G1_PREFIX, &key))
}
#[cfg(test)]
mod tests {
use super::*;
fn hex32(s: &str) -> [u8; 32] {
let bytes: Vec<u8> = (0..64).step_by(2).map(|i| u8::from_str_radix(&s[i..i + 2], 16).expect("hex")).collect();
bytes.try_into().expect("32 bytes")
}
// Computed with @polkadot/util-crypto (decodeAddress / encodeAddress), 2026-10-30.
const VECTORS: [(&str, &str, &str); 3] = [
(
"g1NrqKgJW9FoFabaYW82zhS9tDWdQcL1DGpivaJdj7GW9M6iv",
"aba6b0dcf21a0ddff2e87b52225ddddf06e8c83afb7cd874e7438dd9aa7c2ee8",
"5FwmZc7BSxiLyBDCuB6U6oW9sQNmXk5vNRRYEy8qXxo2FAkn",
),
(
"g1LQM6MfnjdPTrgz3ubwhwnPdbWUUq2GGQCqRQuGgrke9vRMA",
"3efad05b01361fe102ce8bff5e5fd1be7554af684c7e741aaab256cdf950b196",
"5DVHLHUU3LJZFGciJf1BM9juFQDqkSLyVoY35ZmoHSw2pZ1s",
),
(
"g1Pn1geRZnHcVdWdKkoYwQrAARDTXK1D7H3zuXj7GNayT1thu",
"d43593c715fdd31c61141abd04a99fd6822c8558854ccde39a5684e7a56da27d",
"5GrwvaEF5zXb26Fz9rcQpDWS57CtERHpNehXCPcNoHGKutQY",
),
];
#[test]
fn g1_and_generic_forms_of_the_same_key() {
for (g1, key, generic) in VECTORS {
assert_eq!(decode(g1), Some((G1_PREFIX, hex32(key))), "{g1}");
assert_eq!(decode(generic), Some((42, hex32(key))), "{generic}");
assert_eq!(encode(G1_PREFIX, &hex32(key)), g1);
assert_eq!(encode(42, &hex32(key)), generic);
assert_eq!(to_g1(generic).as_deref(), Some(g1));
}
}
#[test]
fn a_long_text_is_refused_before_it_is_decoded() {
// Base58 decoding costs the square of the length, on a thread of the server:
// 250 000 characters took 32 s (review of 74f96be). An SS58 address is about 48.
for (g1, _, generic) in VECTORS {
assert!(g1.len() <= 64 && generic.len() <= 64);
}
let long = "z".repeat(40_000);
let t = std::time::Instant::now();
assert_eq!(decode(&long), None);
assert_eq!(to_g1(&long), None);
assert!(t.elapsed() < std::time::Duration::from_millis(20), "{:?}", t.elapsed());
// One character too many, even of leading ones (zero bytes), is refused.
assert_eq!(decode(&format!("{}{}", "1".repeat(65 - VECTORS[0].0.len()), VECTORS[0].0)), None);
}
#[test]
fn what_is_not_an_address() {
let (g1, _, _) = VECTORS[0];
// One character changed: the checksum no longer holds.
let mut tampered = g1.to_owned();
tampered.replace_range(10..11, if &g1[10..11] == "a" { "b" } else { "a" });
for bad in ["", "g1", "0OIl", &tampered, &g1[..g1.len() - 1], "5GrwvaEF5zXb26Fz9rcQpDWS57CtERHpNehXCPcNoHGKutQ"]
{
assert_eq!(decode(bad), None, "{bad}");
}
}
}
-232
View File
@@ -1,232 +0,0 @@
//! Rule-based editing assistant — no AI yet (Yvv, 2026-10-21: « D'abord des
//! règles simples, ensuite on mettra de l'IA, mais locale »).
//!
//! It never decides. It says, while the author writes:
//! - « this text seems to carry several decisions — split it? » with the parts;
//! - « this text is close to <unit> — make it a variant instead? »;
//! - « this text is long ».
//!
//! The local AI planned later plugs in behind the same `Hint` type.
use serde::{Deserialize, Serialize};
use unicode_normalization::UnicodeNormalization;
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
#[serde(tag = "kind", rename_all = "camelCase")]
pub enum Hint {
/// Several decisions in one text: the suggested parts, in order.
Split { parts: Vec<String> },
/// Close to an existing unit or proposal.
Similar { target: String, score: f64 },
/// Longer than a unit usually is.
Long { chars: usize },
}
pub const LONG_UNIT_CHARS: usize = 700;
pub const SIMILARITY_HINT: f64 = 0.45;
/// Markers of a commitment or a rule, folded (lowercase, no accents).
const DECISION_MARKERS: &[&str] = &[
"je m'engage",
"je m engage",
"j'ai ",
"je suis ",
"je me suis",
"je verifie",
"je ne ",
"doit ",
"doivent ",
"s'engage",
"est tenu",
"est interdit",
"obligatoire",
"il est interdit",
"verifiez",
"ne certifiez",
"contactez",
"assurez",
"i commit",
"i have ",
"must ",
"shall ",
"me comprometo",
"debe ",
];
const STOPWORDS: &[&str] = &[
"le", "la", "les", "un", "une", "des", "de", "du", "d", "l", "et", "ou", "a", "au", "aux", "en", "que", "qui",
"je", "j", "me", "m", "se", "s", "ce", "cet", "cette", "ces", "par", "pour", "sur", "dans", "avec", "est", "sont",
"the", "of", "and", "to", "in", "is", "el", "los", "las", "y", "que", "con", "por", "para",
];
fn fold(s: &str) -> String {
s.nfd()
.filter(|c| !matches!(*c as u32, 0x0300..=0x036F))
.flat_map(char::to_lowercase)
.map(|c| if c == '’' { '\'' } else { c })
.collect()
}
fn is_decision(segment: &str) -> bool {
let f = fold(segment);
DECISION_MARKERS.iter().any(|m| f.contains(m))
}
/// Paragraphs, list items: the natural cut lines of a modular text.
fn segments(text: &str) -> Vec<String> {
let mut out: Vec<String> = Vec::new();
let mut current = String::new();
let flush = |current: &mut String, out: &mut Vec<String>| {
let t = current.trim();
if !t.is_empty() {
out.push(t.to_owned());
}
current.clear();
};
for line in text.lines() {
let trimmed = line.trim_start();
let is_item = trimmed.starts_with("- ")
|| trimmed.starts_with("* ")
|| trimmed
.split_once(['.', ')'])
.is_some_and(|(n, _)| !n.is_empty() && n.chars().all(|c| c.is_ascii_digit()));
if trimmed.is_empty() || is_item {
flush(&mut current, &mut out);
}
if !trimmed.is_empty() {
if !current.is_empty() {
current.push('\n');
}
current.push_str(line);
}
}
flush(&mut current, &mut out);
out
}
/// Sentences of a single paragraph (split after . ! ? followed by a space).
fn sentences(paragraph: &str) -> Vec<String> {
let mut out = Vec::new();
let mut current = String::new();
let mut chars = paragraph.chars().peekable();
while let Some(c) = chars.next() {
current.push(c);
if matches!(c, '.' | '!' | '?') && chars.peek().is_some_and(|n| n.is_whitespace()) {
out.push(current.trim().to_owned());
current.clear();
}
}
if !current.trim().is_empty() {
out.push(current.trim().to_owned());
}
out
}
/// The parts to propose separately, or `None` when the text holds one decision.
pub fn split_suggestion(text: &str) -> Option<Vec<String>> {
let segs = segments(text);
if segs.len() >= 2 && segs.iter().filter(|s| is_decision(s)).count() >= 2 {
return Some(segs);
}
if segs.len() == 1 {
let sents = sentences(&segs[0]);
let committing = sents.iter().filter(|s| is_decision(s)).count();
if sents.len() >= 2 && committing >= 2 {
return Some(sents);
}
}
None
}
fn content_words(text: &str) -> Vec<String> {
fold(text)
.split(|c: char| !c.is_alphanumeric())
.filter(|w| w.chars().count() > 2 && !STOPWORDS.contains(w))
.map(str::to_owned)
.collect()
}
/// Jaccard similarity over content words and their bigrams.
pub fn similarity(a: &str, b: &str) -> f64 {
use std::collections::BTreeSet;
let grams = |t: &str| -> BTreeSet<String> {
let w = content_words(t);
let mut set: BTreeSet<String> = w.iter().cloned().collect();
set.extend(w.windows(2).map(|p| format!("{} {}", p[0], p[1])));
set
};
let (x, y) = (grams(a), grams(b));
if x.is_empty() || y.is_empty() {
return 0.0;
}
let inter = x.intersection(&y).count() as f64;
let union = x.union(&y).count() as f64;
inter / union
}
/// All hints for a draft, given the texts it may collide with (`(id, text)`).
pub fn hints(text: &str, others: &[(String, String)]) -> Vec<Hint> {
let mut out = Vec::new();
if let Some(parts) = split_suggestion(text) {
out.push(Hint::Split { parts });
}
let mut close: Vec<(String, f64)> =
others.iter().map(|(id, t)| (id.clone(), similarity(text, t))).filter(|(_, s)| *s >= SIMILARITY_HINT).collect();
close.sort_by(|a, b| b.1.total_cmp(&a.1));
out.extend(close.into_iter().take(3).map(|(target, score)| Hint::Similar { target, score }));
let chars = text.chars().count();
if chars > LONG_UNIT_CHARS {
out.push(Hint::Long { chars });
}
out
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn two_commitments_in_one_text_are_flagged() {
let text = "Je m'engage sur l'honneur à n'avoir qu'un seul compte.\n\nJe m'engage à ne certifier que des personnes qui respectent cet engagement.";
let parts = split_suggestion(text).expect("two decisions");
assert_eq!(parts.len(), 2);
assert!(parts[0].starts_with("Je m'engage sur"));
}
#[test]
fn two_commitments_in_one_paragraph() {
let text = "Je m'engage à vérifier la clé. Je m'engage aussi à rester joignable.";
assert_eq!(split_suggestion(text).map(|p| p.len()), Some(2));
}
#[test]
fn a_list_of_rules_is_flagged_but_one_explained_commitment_is_not() {
let list = "- Vérifiez la clé avec la personne.\n- Contactez les certifieurs existants.";
assert_eq!(split_suggestion(list).map(|p| p.len()), Some(2));
let single = "Je m'engage sur l'honneur à n'avoir qu'un seul compte. C'est le socle de la toile.";
assert_eq!(split_suggestion(single), None);
}
#[test]
fn preamble_like_text_is_not_split() {
let text = "Le présent acte définit les obligations réciproques des membres. Cet acte est l'unique relation contractuelle de notre toile.";
assert_eq!(split_suggestion(text), None);
}
#[test]
fn similarity_finds_a_variant() {
let current = "Je m'engage sur l'honneur à n'avoir et n'avoir jamais qu'un seul et unique compte cocréateur de monnaie G1.";
let variant = "Je m'engage sur l'honneur à n'avoir jamais qu'un seul compte cocréateur de monnaie G1.";
let other = "J'ai vérifié avec la personne certifiée qu'elle a généré son document de révocation.";
assert!(similarity(current, variant) >= SIMILARITY_HINT);
assert!(similarity(current, other) < 0.2);
let h = hints(variant, &[("E1".into(), current.into()), ("E6".into(), other.into())]);
assert!(matches!(&h[..], [Hint::Similar { target, .. }] if target == "E1"));
}
#[test]
fn long_text() {
let text = "mot ".repeat(200);
assert!(hints(&text, &[]).iter().any(|h| matches!(h, Hint::Long { .. })));
}
}
-487
View File
@@ -1,487 +0,0 @@
//! The sealed ballot of a vote on the Ğ1 chain (ADR 0008).
//!
//! Yvv, 2026-10-30: « une personne une voix, mais la voix peut s'exprimer
//! plusieurs fois […] c'est le dernier qui fait foi. Pour garantir l'intégrité
//! d'un vote, je crois qu'il n'y a que la blockchain, donc une transaction par
//! vote. » And: the other votes are seen as totals only.
//!
//! So a vote is one transaction, signed by the person's wallet: a transfer of
//! 0.1 Ğ1 to the vote account and, in the same transaction, a remark that seals
//! the ballot — `ld1:` followed by the 64 hex digits of
//! `H = blake2b-256(canonical ballot)`. The canonical ballot names the document,
//! the account bound to sign it (when known), every line (proposal, revision,
//! choice) and a random salt of 128 bits. The chain shows who voted and when,
//! never what: without the salt, a choice among three could be guessed.
//!
//! The chain also orders the votes: **the last one stands**, per proposal and
//! per identity, in the order of the chain — block, then rank of the event in
//! the block. A ballot confirmed late never overrides a more recent one.
//!
//! Anyone who sees a remark may copy it. A ballot bound to an account is settled
//! by that account's comment only; a ballot bound to none (a Ğecko code) by a
//! **paid** comment only — a bare remark costs nothing to send, a transfer of
//! the amount does.
//!
//! Pure: the salt comes from the caller, the facts of the chain too.
use crate::address::{self, G1_PREFIX};
use blake2::digest::consts::U32;
use blake2::{Blake2b, Digest};
use serde::{Deserialize, Serialize};
/// What every sealed remark starts with: the version of this format.
pub const PREFIX: &str = "ld1:";
/// A salt is 16 bytes, written as 32 hex digits.
pub const SALT_HEX: usize = 32;
/// blake2b with a 256-bit output — the `BlakeTwo256` of Substrate, which hashes a
/// remark in the `System.Remarked` event, and the one the indexer keys comments on.
pub fn blake2_256(data: &[u8]) -> [u8; 32] {
Blake2b::<U32>::digest(data).into()
}
pub fn to_hex(bytes: &[u8]) -> String {
bytes.iter().map(|b| format!("{b:02x}")).collect()
}
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "lowercase")]
pub enum Choice {
For,
Against,
/// No vote: withdraws an earlier one.
None,
}
impl Choice {
pub fn as_str(self) -> &'static str {
match self {
Self::For => "for",
Self::Against => "against",
Self::None => "none",
}
}
pub fn parse(raw: &str) -> Option<Self> {
[Self::For, Self::Against, Self::None].into_iter().find(|c| c.as_str() == raw)
}
}
/// One line of a ballot: a version of an article, the revision voted, the choice.
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct Line {
pub proposal_id: String,
pub revision: u32,
pub choice: Choice,
}
#[derive(Debug, PartialEq, Eq)]
pub enum BallotError {
/// No line, or the same proposal twice.
Lines,
/// An identifier that would not read back (empty, spaces, line breaks).
Identifier,
/// The salt is not 32 hex digits.
Salt,
/// The bound account is not an SS58 address.
Author,
}
/// The ballot as it is hashed: one field per line, the lines in the order of
/// their proposal identifiers, the author as a Ğ1 address (or `-`).
pub fn canonical(doc_id: &str, author: Option<&str>, lines: &[Line], salt_hex: &str) -> Result<String, BallotError> {
let plain = |s: &str| !s.is_empty() && s.chars().all(|c| c.is_ascii_graphic());
if !plain(doc_id) || lines.iter().any(|l| !plain(&l.proposal_id)) {
return Err(BallotError::Identifier);
}
if salt_hex.len() != SALT_HEX || !salt_hex.bytes().all(|b| b.is_ascii_hexdigit() && !b.is_ascii_uppercase()) {
return Err(BallotError::Salt);
}
let author = match author {
Some(a) => address::decode(a).map(|(_, key)| address::encode(G1_PREFIX, &key)).ok_or(BallotError::Author)?,
None => "-".to_owned(),
};
let mut sorted: Vec<&Line> = lines.iter().collect();
sorted.sort_by(|a, b| a.proposal_id.cmp(&b.proposal_id));
if sorted.is_empty() || sorted.windows(2).any(|w| w[0].proposal_id == w[1].proposal_id) {
return Err(BallotError::Lines);
}
let mut out = format!("ld1\ndoc {doc_id}\nauthor {author}\n");
for l in sorted {
out.push_str(&format!("line {} {} {}\n", l.proposal_id, l.revision, l.choice.as_str()));
}
out.push_str(&format!("salt {salt_hex}\n"));
Ok(out)
}
/// The seal of a ballot, and what the chain sees of it.
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct Sealed {
/// H, 64 hex digits.
pub digest: String,
/// The remark of the transaction: `ld1:` + H, 68 ASCII bytes.
pub comment: String,
/// blake2b-256 of the remark's bytes, `0x` + 64 hex digits: what the
/// indexer finds the comment by.
pub remark_hash: String,
}
pub fn seal(doc_id: &str, author: Option<&str>, lines: &[Line], salt_hex: &str) -> Result<Sealed, BallotError> {
let text = canonical(doc_id, author, lines, salt_hex)?;
let digest = to_hex(&blake2_256(text.as_bytes()));
let comment = format!("{PREFIX}{digest}");
let remark_hash = format!("0x{}", to_hex(&blake2_256(comment.as_bytes())));
Ok(Sealed { digest, comment, remark_hash })
}
// ---------------------------------------------------------------------------
// The order of the chain
// ---------------------------------------------------------------------------
/// Where an event sits on the chain: its block, then its rank among the events
/// of that block. The order of votes.
#[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)]
pub struct ChainPosition {
pub block: u32,
pub event: u32,
}
impl ChainPosition {
/// The identifier the indexer gives an event: `<block, 10 digits>-<5 hex of
/// the block hash>-<rank of the event, 6 digits>`, e.g. `0003399566-d43e7-000001`.
pub fn from_event_id(id: &str) -> Option<Self> {
let mut parts = id.split('-');
let (block, hash, event) = (parts.next()?, parts.next()?, parts.next()?);
if parts.next().is_some() || hash.is_empty() || !hash.bytes().all(|b| b.is_ascii_hexdigit()) {
return None;
}
let digits = |s: &str| !s.is_empty() && s.bytes().all(|b| b.is_ascii_digit());
if !digits(block) || !digits(event) {
return None;
}
Some(Self { block: block.parse().ok()?, event: event.parse().ok()? })
}
}
/// The last one stands: does a vote at `candidate` decide, given the positions of
/// the other confirmed votes of the same identity on the same proposal? Only if
/// it comes after all of them.
pub fn decides(candidate: ChainPosition, others: &[ChainPosition]) -> bool {
others.iter().all(|o| *o < candidate)
}
// ---------------------------------------------------------------------------
// What the chain says of a comment
// ---------------------------------------------------------------------------
/// A Ğ1 identity, as the chain has it now.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub struct ChainIdentity {
pub index: u32,
pub member: bool,
}
/// A transfer in the same transaction as the comment.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub struct Transfer {
pub from: [u8; 32],
pub to: [u8; 32],
/// In cents of Ğ1.
pub amount: u64,
}
/// A comment found on the chain with the remark of a ballot.
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct Comment {
pub position: ChainPosition,
/// The account that signed the transaction.
pub author: [u8; 32],
/// The identity whose account it is, if any.
pub identity: Option<ChainIdentity>,
pub transfers: Vec<Transfer>,
}
/// What a ballot asks of its transaction.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub struct Expected {
/// The vote account.
pub account: [u8; 32],
/// At least this many cents.
pub amount: u64,
/// The account bound to sign, when the page knew it.
pub author: Option<[u8; 32]>,
}
/// What the chain says of a ballot's transaction. Whether a paid ballot counts
/// is an access rule — membership of the web of trust, now — that belongs to
/// trustinWallet (`trustin_access::chain_vote_counts`).
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum Verdict {
/// The transaction carries the transfer, and its signer is the account of
/// this identity.
Paid { identity: ChainIdentity },
/// Signed by another account than the one bound: a copy of the remark, ignored.
OtherAuthor,
/// A ballot bound to no account (a Ğecko code), and a comment without the
/// transfer: a copy of the remark anyone may send for free, ignored. Only a
/// paid comment settles such a ballot.
Unpaid,
/// The account is not that of an identity.
NoIdentity,
/// No transfer of the amount to the vote account in the same transaction.
NoTransfer,
}
impl Verdict {
/// Why a ballot is refused, as stored and translated.
pub fn reason(self) -> Option<&'static str> {
match self {
Self::Paid { .. } | Self::OtherAuthor | Self::Unpaid => None,
Self::NoIdentity => Some("no_identity"),
Self::NoTransfer => Some("transfer"),
}
}
/// Whether this comment settles the ballot (counted or refused), or is a copy to pass over.
pub fn settles(self) -> bool {
!matches!(self, Self::OtherAuthor | Self::Unpaid)
}
}
/// Whether the comment's transaction carries the transfer the ballot asks:
/// from its signer, to the vote account, at least the amount.
pub fn paid(c: &Comment, e: &Expected) -> bool {
c.transfers.iter().any(|t| t.from == c.author && t.to == e.account && t.amount >= e.amount)
}
pub fn verdict(c: &Comment, e: &Expected) -> Verdict {
if e.author.is_some_and(|a| a != c.author) {
return Verdict::OtherAuthor;
}
let paid = paid(c, e);
if e.author.is_none() && !paid {
return Verdict::Unpaid;
}
let Some(identity) = c.identity else { return Verdict::NoIdentity };
if !paid {
return Verdict::NoTransfer;
}
Verdict::Paid { identity }
}
/// The comment that settles a ballot: the first in the order of the chain that is
/// not a copy — by another account than the one bound, or, unbound, without the
/// transfer. `None`: nothing yet, keep waiting.
pub fn judge(comments: &[Comment], e: &Expected) -> Option<(ChainPosition, Verdict)> {
let mut sorted: Vec<&Comment> = comments.iter().collect();
sorted.sort_by_key(|c| c.position);
sorted.into_iter().map(|c| (c.position, verdict(c, e))).find(|(_, v)| v.settles())
}
#[cfg(test)]
mod tests {
use super::*;
const ALICE: &str = "g1Pn1geRZnHcVdWdKkoYwQrAARDTXK1D7H3zuXj7GNayT1thu";
const SIMONE: &str = "g1NrqKgJW9FoFabaYW82zhS9tDWdQcL1DGpivaJdj7GW9M6iv";
fn line(p: &str, revision: u32, choice: Choice) -> Line {
Line { proposal_id: p.into(), revision, choice }
}
fn key(addr: &str) -> [u8; 32] {
address::decode(addr).expect("address").1
}
#[test]
fn the_remark_hash_is_the_one_the_chain_publishes() {
// Real comment « Merci » of the Ğ1 chain; its hash as the indexer serves it (2026-10-30).
assert_eq!(to_hex(&blake2_256(b"Merci ")), "870dc594c5b4daabcc2a3c7149dc2af418376cde62d15cf28a3622fd900c87ce");
}
#[test]
fn the_canonical_ballot_and_its_seal() {
let lines = [line("p2", 1, Choice::Against), line("p1", 3, Choice::For)];
let salt = "00112233445566778899aabbccddeeff";
assert_eq!(
canonical("d0c", None, &lines, salt).expect("valid"),
"ld1\ndoc d0c\nauthor -\nline p1 3 for\nline p2 1 against\nsalt 00112233445566778899aabbccddeeff\n"
);
// Computed apart (Python hashlib.blake2b, digest_size=32).
let s = seal("d0c", None, &lines, salt).expect("valid");
assert_eq!(s.digest, "20448d496744292981c8586856afb58a86e37da2470f764b4e6ed84eade71340");
assert_eq!(s.comment, "ld1:20448d496744292981c8586856afb58a86e37da2470f764b4e6ed84eade71340");
assert_eq!(s.comment.len(), 68);
assert!(s.comment.is_ascii());
assert_eq!(s.remark_hash, "0xe765221a75716dde863a7b53df3a97acf6db38a2118edd525d54c8a2979b7bc3");
// The order of the lines does not matter; every field does.
let swapped = [line("p1", 3, Choice::For), line("p2", 1, Choice::Against)];
assert_eq!(seal("d0c", None, &swapped, salt).expect("valid"), s);
for other in [
seal("d0d", None, &lines, salt),
seal("d0c", Some(ALICE), &lines, salt),
seal("d0c", None, &[line("p2", 2, Choice::Against), line("p1", 3, Choice::For)], salt),
seal("d0c", None, &[line("p2", 1, Choice::For), line("p1", 3, Choice::For)], salt),
seal("d0c", None, &lines, "00112233445566778899aabbccddeef0"),
] {
assert_ne!(other.expect("valid").digest, s.digest);
}
}
#[test]
fn a_bound_author_is_written_as_a_g1_address_whatever_its_form() {
let lines = [line("p1", 3, Choice::None)];
let salt = "ffeeddccbbaa99887766554433221100";
let s = seal("d0c", Some(SIMONE), &lines, salt).expect("valid");
assert_eq!(s.digest, "6a6d8a90b5003ff3c4d050dbd36199e8bbf34c4cc11620618399226c9237c05e");
assert_eq!(s.remark_hash, "0x846495fe62b9a5184a0d3090d4d26f621887c334890404169c00575ee74540e4");
// The same key in the generic Substrate form (what duniter-connect shows).
let generic = seal("d0c", Some("5FwmZc7BSxiLyBDCuB6U6oW9sQNmXk5vNRRYEy8qXxo2FAkn"), &lines, salt);
assert_eq!(generic.expect("valid"), s);
}
#[test]
fn what_cannot_be_sealed() {
let ok = [line("p1", 1, Choice::For)];
let salt = "00112233445566778899aabbccddeeff";
assert_eq!(seal("d0c", None, &[], salt), Err(BallotError::Lines));
assert_eq!(
seal("d0c", None, &[line("p1", 1, Choice::For), line("p1", 2, Choice::Against)], salt),
Err(BallotError::Lines)
);
assert_eq!(seal("d 0c", None, &ok, salt), Err(BallotError::Identifier));
assert_eq!(seal("d0c", None, &[line("p\n1", 1, Choice::For)], salt), Err(BallotError::Identifier));
assert_eq!(seal("d0c", None, &ok, "0011"), Err(BallotError::Salt));
assert_eq!(seal("d0c", None, &ok, "00112233445566778899AABBCCDDEEFF"), Err(BallotError::Salt));
assert_eq!(seal("d0c", Some("g1Nope"), &ok, salt), Err(BallotError::Author));
}
#[test]
fn choices_read_back() {
for c in [Choice::For, Choice::Against, Choice::None] {
assert_eq!(Choice::parse(c.as_str()), Some(c));
}
assert_eq!(Choice::parse("abstain"), None);
}
#[test]
fn the_position_of_an_event_reads_in_its_identifier() {
assert_eq!(
ChainPosition::from_event_id("0003399566-d43e7-000001"),
Some(ChainPosition { block: 3_399_566, event: 1 })
);
assert_eq!(
ChainPosition::from_event_id("0000196944-60692-000003"),
Some(ChainPosition { block: 196_944, event: 3 })
);
for bad in [
"",
"0003399566-d43e7",
"0003399566-d43e7-000001-2",
"x-d43e7-000001",
"0003399566--000001",
"0003399566-zz-000001",
] {
assert_eq!(ChainPosition::from_event_id(bad), None, "{bad}");
}
}
#[test]
fn the_last_one_stands_in_the_order_of_the_chain() {
let at = |block, event| ChainPosition { block, event };
// Block first, then the rank of the event in the block.
assert!(at(100, 9) < at(101, 0));
assert!(at(100, 2) < at(100, 3));
assert!(decides(at(101, 0), &[]));
assert!(decides(at(101, 0), &[at(100, 9), at(100, 2)]));
// Confirmed after a more recent one, an older ballot does not override it.
assert!(!decides(at(100, 9), &[at(101, 0)]));
assert!(!decides(at(100, 2), &[at(100, 3)]));
}
fn comment(block: u32, author: &str, identity: Option<(u32, bool)>, transfers: &[(&str, &str, u64)]) -> Comment {
Comment {
position: ChainPosition { block, event: 1 },
author: key(author),
identity: identity.map(|(index, member)| ChainIdentity { index, member }),
transfers: transfers
.iter()
.map(|(f, t, amount)| Transfer { from: key(f), to: key(t), amount: *amount })
.collect(),
}
}
const SIMONE_MEMBER: ChainIdentity = ChainIdentity { index: 1304, member: true };
#[test]
fn a_ballot_is_paid_by_the_account_of_an_identity() {
let e = Expected { account: key(ALICE), amount: 10, author: None };
let paid = [(SIMONE, ALICE, 10)];
let ok = Verdict::Paid { identity: SIMONE_MEMBER };
assert_eq!(verdict(&comment(1, SIMONE, Some((1304, true)), &paid), &e), ok);
// More than asked is fine.
assert_eq!(verdict(&comment(1, SIMONE, Some((1304, true)), &[(SIMONE, ALICE, 25)]), &e), ok);
// Membership is not the engine's to decide (`trustin_access::chain_vote_counts`): it is passed on.
let former = Verdict::Paid { identity: ChainIdentity { index: 1304, member: false } };
assert_eq!(verdict(&comment(1, SIMONE, Some((1304, false)), &paid), &e), former);
assert_eq!(verdict(&comment(1, SIMONE, None, &paid), &e), Verdict::NoIdentity);
// Too little, elsewhere, or nothing: refused when bound to the signer; a copy to pass over when unbound.
let bound = Expected { author: Some(key(SIMONE)), ..e };
for unpaid in [&[(SIMONE, ALICE, 9)][..], &[(SIMONE, SIMONE, 10)], &[], &[(ALICE, ALICE, 10)]] {
let c = comment(1, SIMONE, Some((1304, true)), unpaid);
assert_eq!(verdict(&c, &bound), Verdict::NoTransfer, "{unpaid:?}");
assert_eq!(verdict(&c, &e), Verdict::Unpaid, "{unpaid:?}");
}
assert_eq!(Verdict::NoTransfer.reason(), Some("transfer"));
assert_eq!(Verdict::NoIdentity.reason(), Some("no_identity"));
assert_eq!(ok.reason(), None);
assert_eq!(Verdict::Unpaid.reason(), None);
assert!(!Verdict::Unpaid.settles() && !Verdict::OtherAuthor.settles() && Verdict::NoTransfer.settles());
}
#[test]
fn a_copy_of_the_remark_by_another_account_is_ignored_when_the_author_is_bound() {
let e = Expected { account: key(ALICE), amount: 10, author: Some(key(SIMONE)) };
let copy = comment(10, ALICE, Some((1, true)), &[(ALICE, ALICE, 10)]);
let own = comment(11, SIMONE, Some((1304, true)), &[(SIMONE, ALICE, 10)]);
assert_eq!(verdict(&copy, &e), Verdict::OtherAuthor);
// The copy came first on the chain: it is skipped, the ballot counts for its author.
assert_eq!(
judge(&[own.clone(), copy.clone()], &e),
Some((own.position, Verdict::Paid { identity: SIMONE_MEMBER }))
);
assert_eq!(judge(&[copy], &e), None, "only a copy: keep waiting");
// Unbound (a Ğecko code): the first on the chain settles it.
let free = Expected { author: None, ..e };
let early = comment(9, SIMONE, None, &[(SIMONE, ALICE, 10)]);
assert_eq!(judge(&[own, early.clone()], &free), Some((early.position, Verdict::NoIdentity)));
assert_eq!(judge(&[], &free), None);
}
#[test]
fn an_unbound_ballot_is_settled_by_a_paid_comment_only() {
// A Ğecko code binds no account. A remark alone costs nothing to send:
// a bare copy must not settle the ballot (review of 74f96be, P3).
let free = Expected { account: key(ALICE), amount: 10, author: None };
let bare = comment(8, SIMONE, None, &[]);
let short = comment(9, SIMONE, Some((1304, true)), &[(SIMONE, ALICE, 9)]);
let elsewhere = comment(10, ALICE, Some((1, true)), &[(ALICE, SIMONE, 10)]);
assert_eq!(judge(&[bare.clone(), short.clone(), elsewhere.clone()], &free), None, "nothing paid: keep waiting");
let paid = comment(11, SIMONE, Some((1304, true)), &[(SIMONE, ALICE, 10)]);
assert_eq!(
judge(&[paid.clone(), elsewhere, short, bare], &free),
Some((paid.position, Verdict::Paid { identity: SIMONE_MEMBER }))
);
// Paid by an account without identity: paid, so it settles — refused.
let nobody = comment(7, ALICE, None, &[(ALICE, ALICE, 10)]);
assert_eq!(judge(&[paid.clone(), nobody.clone()], &free), Some((nobody.position, Verdict::NoIdentity)));
// The limit that stays (ADR 0008): a paid copy, earlier on the chain, takes the ballot.
let copy = comment(6, ALICE, Some((1, true)), &[(ALICE, ALICE, 10)]);
let theirs = ChainIdentity { index: 1, member: true };
assert_eq!(judge(&[paid, copy.clone()], &free), Some((copy.position, Verdict::Paid { identity: theirs })));
// Bound to an account: its own comment without the transfer is refused, and told.
let bound = Expected { author: Some(key(SIMONE)), ..free };
let own = comment(12, SIMONE, Some((1304, true)), &[(SIMONE, ALICE, 9)]);
assert_eq!(judge(std::slice::from_ref(&own), &bound), Some((own.position, Verdict::NoTransfer)));
}
}
-387
View File
@@ -1,387 +0,0 @@
//! The buffer between the votes and the chain (ADR 0012).
//!
//! Yvv, 2026-11-02: « il faut prévoir le "tampon" entre la base des votes et la
//! mise à jour par les indexeurs de la chaîne. Il faut prévoir le bon process
//! pour une ux moderne "je vois ce que j'ai fait sans attendre", avec un
//! contrôle a posteriori de l'intégrité du vote (bien unique, bien le dernier
//! qui vaut). »
//!
//! A vote goes through three times:
//! - **the intention**, the gesture itself (a sealed ballot, `ballot`): seen at
//! once by the person and in the totals shown, never counted for adoption;
//! - **the proof**, the chain read by the indexer (or, while the chain is
//! closed, the simulated confirmer);
//! - **the reconciliation**, after each proof: one voice per identity and per
//! version, the last in the order of the chain, whatever the order the proofs
//! were read in (`reconcile`). What the chain never confirms in time, refuses,
//! or what a more recent vote of the same identity overtook, is set aside — and
//! a *for* proven while an objection holds its version back.
//!
//! What each stage of a vote weighs, in the totals shown and in adoption, is
//! `Rules`. Pure: the facts come from the caller.
use crate::ballot::{ChainPosition, Choice};
use crate::objection;
use crate::threshold::Tally;
use serde::Serialize;
/// Whether a simulated vote counts for adoption. Yvv, 2026-11-02: « Il compte,
/// puis s'efface » — like a proven vote, marked as simulated, until an act
/// « supprime » sets every simulated vote aside and examines again, with the
/// proven votes only, what they brought into force (`ld_server::simulation`).
/// `false` would turn it to « shown, never counted for adoption »; the totals
/// shown would not change.
pub const SIMULATED_ADOPTS: bool = true;
/// Where a vote stands.
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize)]
#[serde(rename_all = "lowercase")]
pub enum Stage {
/// Proven by the chain, or written by the server in a managed group.
Recorded,
/// An intention still waiting for its proof, within its window.
Pending,
/// A vote of the simulation (cast while the chain was closed), until the act
/// that sets them aside.
Simulated,
}
impl Stage {
pub fn as_str(self) -> &'static str {
match self {
Self::Recorded => "recorded",
Self::Pending => "pending",
Self::Simulated => "simulated",
}
}
/// The stage of a vote that counts (`votes.source`): `simulated`, or recorded
/// (`server`, `chain`). An intention is never in `votes`.
pub fn of_source(source: &str) -> Self {
if source == "simulated" { Self::Simulated } else { Self::Recorded }
}
}
/// What the stages weigh. Opening the chain changes nothing by itself: the
/// simulated votes weigh until the act that sets them aside (Yvv, 2026-11-02).
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub struct Rules {
/// See `SIMULATED_ADOPTS`.
pub simulated_adopts: bool,
}
impl Default for Rules {
fn default() -> Self {
Self { simulated_adopts: SIMULATED_ADOPTS }
}
}
impl Rules {
/// Does a vote at this stage weigh in the totals shown? Every stage: the
/// intention at once, the simulated vote with its mark.
pub fn shown(&self, stage: Stage) -> bool {
match stage {
Stage::Recorded | Stage::Pending | Stage::Simulated => true,
}
}
/// Does a vote at this stage weigh in adoption (and in the usual participation)?
/// Never an intention without proof.
pub fn adopts(&self, stage: Stage) -> bool {
match stage {
Stage::Recorded => true,
Stage::Pending => false,
Stage::Simulated => self.simulated_adopts,
}
}
}
/// A proven line of one identity on one version: where its proof sits on the
/// chain, its choice, and whether an objection held the version back when the
/// proof arrived.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub struct Proven {
pub position: ChainPosition,
pub choice: Choice,
pub held: bool,
}
/// What became of a proven line.
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize)]
#[serde(rename_all = "lowercase")]
pub enum Outcome {
/// The voice of the identity on this version (a withdrawal too: then no voice).
Counted,
/// A more recent proven line of the same identity decided.
Superseded,
/// A *for* proven while an objection held the version back: never counts.
Objection,
}
impl Outcome {
pub fn as_str(self) -> &'static str {
match self {
Self::Counted => "counted",
Self::Superseded => "superseded",
Self::Objection => "objection",
}
}
pub fn parse(raw: &str) -> Option<Self> {
[Self::Counted, Self::Superseded, Self::Objection].into_iter().find(|o| o.as_str() == raw)
}
}
/// A *for* whose proof arrives while an objection holds its version back is set
/// aside (ADR 0012, revising 0010): against and a withdrawal still count (Yvv,
/// 2026-11-01: « Retirer ou "contre" »).
pub fn discarded_by_objection(choice: Choice, held: bool) -> bool {
!objection::may_cast(held, choice)
}
/// The reconciliation of one identity on one version.
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct Reconciled {
/// One per line, in the order given.
pub outcomes: Vec<Outcome>,
/// The line that stands, if any: `None` when every line was set aside (the
/// vote of before, if any, is left as it is).
pub standing: Option<usize>,
}
/// One voice per identity and per version, the last in the order of the chain:
/// the block, then the rank of the event — whatever the order the lines come in.
/// A *for* proven while held is set aside first and never takes a place in the
/// order. Two lines at the same place (never on the chain): the later given.
pub fn reconcile(lines: &[Proven]) -> Reconciled {
let mut outcomes = vec![Outcome::Superseded; lines.len()];
let mut standing: Option<usize> = None;
for (i, l) in lines.iter().enumerate() {
if discarded_by_objection(l.choice, l.held) {
outcomes[i] = Outcome::Objection;
continue;
}
if standing.is_none_or(|j| lines[j].position <= l.position) {
standing = Some(i);
}
}
if let Some(i) = standing {
outcomes[i] = Outcome::Counted;
}
Reconciled { outcomes, standing }
}
/// A change of the totals shown, for and against.
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
pub struct Shift {
pub for_votes: i64,
pub against: i64,
}
impl Shift {
/// The totals, shifted; never below zero.
pub fn apply(self, t: Tally) -> Tally {
let add = |n: u32, d: i64| u32::try_from((i64::from(n) + d).clamp(0, i64::from(u32::MAX))).unwrap_or(n);
Tally { for_votes: add(t.for_votes, self.for_votes), against: add(t.against, self.against) }
}
}
/// What an intention weighs in the totals shown.
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
pub struct Weighed {
/// What it changes in the totals shown.
pub shift: Shift,
/// The part of the totals shown it adds that still waits for its proof.
pub pending: Tally,
}
/// The latest intention of a session on a version, still without proof, takes
/// the place — in the totals shown only — of the vote this session sees as its
/// own:
/// - `seen`: that vote, if it counts in the totals shown now (*for* or *against*);
/// - `intent`: the choice of the intention (`None`: a withdrawal);
/// - `current`: it votes the revision in place (otherwise it would be « to
/// reconfirm »: it adds nothing, but still takes the vote of before away);
/// - `held`: an objection holds the version back — a *for* then weighs nothing
/// at all (it would be set aside), the vote of before stays.
pub fn weigh_intent(seen: Option<Choice>, intent: Choice, current: bool, held: bool) -> Weighed {
if discarded_by_objection(intent, held) {
return Weighed::default();
}
let one = |c: Option<Choice>| match c {
Some(Choice::For) => Tally { for_votes: 1, against: 0 },
Some(Choice::Against) => Tally { for_votes: 0, against: 1 },
Some(Choice::None) | None => Tally::default(),
};
let new = one(current.then_some(intent));
let old = one(seen);
Weighed {
shift: Shift {
for_votes: i64::from(new.for_votes) - i64::from(old.for_votes),
against: i64::from(new.against) - i64::from(old.against),
},
pending: new,
}
}
#[cfg(test)]
mod tests {
use super::*;
const FOR: Choice = Choice::For;
const AGAINST: Choice = Choice::Against;
const NONE: Choice = Choice::None;
fn at(block: u32, event: u32) -> ChainPosition {
ChainPosition { block, event }
}
fn line(block: u32, event: u32, choice: Choice) -> Proven {
Proven { position: at(block, event), choice, held: false }
}
/// The outcome of each line, keyed by its position: comparable across orders.
fn by_position(lines: &[Proven]) -> (Vec<(ChainPosition, Outcome)>, Option<ChainPosition>) {
let r = reconcile(lines);
let mut out: Vec<(ChainPosition, Outcome)> =
lines.iter().zip(&r.outcomes).map(|(l, o)| (l.position, *o)).collect();
out.sort_by_key(|(p, _)| *p);
(out, r.standing.map(|i| lines[i].position))
}
#[test]
fn the_last_in_the_order_of_the_chain_stands_whatever_the_order_of_reading() {
let a = line(200, 2, FOR);
let b = line(200, 4, AGAINST);
let c = line(300, 1, NONE);
let expected = (
vec![(a.position, Outcome::Superseded), (b.position, Outcome::Superseded), (c.position, Outcome::Counted)],
Some(c.position),
);
// Every order the indexer may read them in gives the same voice.
for order in [[a, b, c], [a, c, b], [b, a, c], [b, c, a], [c, a, b], [c, b, a]] {
assert_eq!(by_position(&order), expected, "{order:?}");
}
// The withdrawal stands: no voice.
let r = reconcile(&[a, b, c]);
assert_eq!(r.standing.map(|i| [a, b, c][i].choice), Some(NONE));
}
#[test]
fn one_voice_per_identity_never_two() {
for lines in [
vec![],
vec![line(5, 1, FOR)],
vec![line(5, 1, FOR), line(5, 2, FOR), line(4, 9, AGAINST)],
vec![Proven { held: true, ..line(9, 1, FOR) }, line(8, 1, AGAINST), line(7, 1, FOR)],
] {
let r = reconcile(&lines);
assert_eq!(r.outcomes.len(), lines.len());
let counted = r.outcomes.iter().filter(|o| **o == Outcome::Counted).count();
assert_eq!(counted, usize::from(r.standing.is_some()), "{lines:?}");
assert!(counted <= 1);
}
assert_eq!(reconcile(&[]).standing, None);
}
#[test]
fn a_for_proven_while_held_is_set_aside_and_the_vote_of_before_stays() {
let before = line(100, 1, AGAINST);
let held_for = Proven { held: true, ..line(200, 1, FOR) };
for lines in [[before, held_for], [held_for, before]] {
let (outcomes, standing) = by_position(&lines);
assert_eq!(outcomes, vec![(before.position, Outcome::Counted), (held_for.position, Outcome::Objection)]);
assert_eq!(standing, Some(before.position), "the *for* takes no place in the order");
}
// Alone, it leaves nothing standing: the vote of before, if any, is left as it is.
let r = reconcile(&[held_for]);
assert_eq!((r.outcomes, r.standing), (vec![Outcome::Objection], None));
// Against and a withdrawal, proven while held, count.
for choice in [AGAINST, NONE] {
let held = Proven { held: true, ..line(300, 1, choice) };
let r = reconcile(&[before, held_for, held]);
assert_eq!(r.standing, Some(2), "{choice:?}");
assert_eq!(r.outcomes, vec![Outcome::Superseded, Outcome::Objection, Outcome::Counted]);
}
assert!(discarded_by_objection(FOR, true));
assert!(!discarded_by_objection(FOR, false));
assert!(!discarded_by_objection(AGAINST, true) && !discarded_by_objection(NONE, true));
}
#[test]
fn two_lines_at_the_same_place_the_later_given_stands() {
let r = reconcile(&[line(0, 3, FOR), line(0, 3, AGAINST)]);
assert_eq!(r.standing, Some(1));
}
#[test]
fn a_simulated_vote_at_block_zero_comes_before_any_vote_of_the_chain() {
// The simulation proves at block 0, in the order votes arrive (ADR 0012).
let simulated = line(0, 41, FOR);
let chain = line(1, 0, AGAINST);
assert_eq!(by_position(&[chain, simulated]).1, Some(chain.position));
}
#[test]
fn what_each_stage_weighs() {
let rules = Rules::default();
assert_eq!(rules.simulated_adopts, SIMULATED_ADOPTS);
for stage in [Stage::Recorded, Stage::Pending, Stage::Simulated] {
assert!(rules.shown(stage), "{stage:?}: everything is seen, at once");
}
assert!(rules.adopts(Stage::Recorded));
assert!(!rules.adopts(Stage::Pending), "never an intention without proof");
// Yvv, 2026-11-02: « Il compte, puis s'efface » — like a proven vote, until the act.
assert!(SIMULATED_ADOPTS && rules.adopts(Stage::Simulated));
// The other way, kept at hand: shown, never counted for adoption.
let shown_only = Rules { simulated_adopts: false };
assert!(shown_only.shown(Stage::Simulated) && !shown_only.adopts(Stage::Simulated));
assert!(shown_only.adopts(Stage::Recorded) && !shown_only.adopts(Stage::Pending));
assert_eq!(Stage::of_source("simulated"), Stage::Simulated);
assert_eq!(Stage::of_source("chain"), Stage::Recorded);
assert_eq!(Stage::of_source("server"), Stage::Recorded);
for o in [Outcome::Counted, Outcome::Superseded, Outcome::Objection] {
assert_eq!(Outcome::parse(o.as_str()), Some(o));
}
assert_eq!(Outcome::parse("maybe"), None);
}
fn shift(for_votes: i64, against: i64) -> Shift {
Shift { for_votes, against }
}
fn tally(for_votes: u32, against: u32) -> Tally {
Tally { for_votes, against }
}
#[test]
fn an_intention_takes_the_place_of_the_vote_one_sees_as_one_s_own() {
let w = |seen, intent, current, held| weigh_intent(seen, intent, current, held);
// A first vote.
assert_eq!(w(None, FOR, true, false), Weighed { shift: shift(1, 0), pending: tally(1, 0) });
// From *for* to *against*: one *for* less, one *against* more, that one waiting.
assert_eq!(w(Some(FOR), AGAINST, true, false), Weighed { shift: shift(-1, 1), pending: tally(0, 1) });
// The same choice again: nothing moves, the voice now waits for its proof.
assert_eq!(w(Some(FOR), FOR, true, false), Weighed { shift: shift(0, 0), pending: tally(1, 0) });
// A withdrawal takes the vote away at once.
assert_eq!(w(Some(AGAINST), NONE, true, false), Weighed { shift: shift(0, -1), pending: tally(0, 0) });
assert_eq!(w(None, NONE, true, false), Weighed::default());
// On a revision gone by: it adds nothing, it still takes the vote of before away.
assert_eq!(w(Some(FOR), AGAINST, false, false), Weighed { shift: shift(-1, 0), pending: tally(0, 0) });
// Held back by an objection: a *for* weighs nothing (it would be set aside)...
assert_eq!(w(Some(AGAINST), FOR, true, true), Weighed::default());
assert_eq!(w(None, FOR, true, true), Weighed::default());
// ...against and a withdrawal do.
assert_eq!(w(Some(FOR), AGAINST, true, true), Weighed { shift: shift(-1, 1), pending: tally(0, 1) });
assert_eq!(w(Some(FOR), NONE, true, true), Weighed { shift: shift(-1, 0), pending: tally(0, 0) });
// A « seen » withdrawal is no vote.
assert_eq!(w(Some(NONE), FOR, true, false), w(None, FOR, true, false));
}
#[test]
fn a_shift_never_takes_the_totals_below_zero() {
assert_eq!(shift(-1, 2).apply(tally(3, 0)), tally(2, 2));
assert_eq!(shift(-1, -1).apply(tally(0, 0)), tally(0, 0));
assert_eq!(shift(1, 0).apply(tally(u32::MAX, 0)), tally(u32::MAX, 0));
}
}
-428
View File
@@ -1,428 +0,0 @@
//! Composition of a modular document under permanent vote.
//!
//! A document is an ordered list of **units** (preamble, articles, headings).
//! A unit is a slot: it may hold one version in force, or none yet. Every
//! **proposal** targets exactly one unit — one proposal, one decision — and is
//! one of: a text for the unit (new or replacing), its removal, or a change of
//! its inertia. Proposals on the same unit and the same dimension (content or
//! inertia) are rivals: at most one of them enters into force at a time.
//!
//! A rival replaces the version in force only if it reached its threshold
//! **and** holds a wider margin than the version in force (for − required).
//! The displaced version goes back among the proposals with its votes; it can
//! only return by widening its margin again — no oscillation. A seed a minima
//! (in force without any vote) has the margin of zero votes, so the first
//! rival that reaches its threshold replaces it.
//!
//! This module answers three questions, purely:
//! - which proposals enter into force now (`adoptions`);
//! - what the document would read if the well-rated, or all, proposals passed
//! (`preview`);
//! - which formula a proposal is judged by (`params_for`): the level of
//! inertia of its unit and its quorum, on the scale of its document
//! (`inertia::Scale`).
use crate::inertia::{InertiaLevel, Scale};
use crate::threshold::{FormulaParams, Tally, ThresholdError, evaluate};
use serde::{Deserialize, Serialize};
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
pub struct VersionText {
pub id: String,
pub title: String,
pub body: String,
/// Votes still cast on the version in force (permanent vote).
#[serde(default)]
pub tally: Tally,
}
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
pub struct UnitState {
pub id: String,
pub position: f64,
pub inertia: InertiaLevel,
/// Its own quorum, set by the author of its first version before the first vote
/// (ADR 0013); `None`: the quorum of its level.
#[serde(default)]
pub quorum: Option<u32>,
pub in_force: Option<VersionText>,
}
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
#[serde(tag = "kind", content = "inertia", rename_all = "lowercase")]
pub enum ProposalKind {
/// A text for the unit: an addition if the unit is empty, a replacement otherwise.
Text,
/// Remove the unit from the text in force.
Remove,
/// Change the inertia of the unit.
Inertia(InertiaLevel),
}
impl ProposalKind {
fn is_content(self) -> bool {
!matches!(self, Self::Inertia(_))
}
}
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
pub struct ProposalState {
pub id: String,
pub unit_id: String,
pub kind: ProposalKind,
pub title: String,
pub body: String,
pub tally: Tally,
/// Sortable submission stamp (RFC 3339): the earlier wins a perfect tie.
pub submitted_at: String,
/// An active contradiction signal forbids entering into force.
pub blocked: bool,
}
/// The level a proposal is voted at. A change of inertia inherits the level of
/// its unit, as its text does (Yvv, 2026-10-30: « par défaut l'inertie de
/// l'indice d'inertie lui-même hérite de l'inertie de l'article ») — this
/// replaces « inertie + », one level above (2026-10-21).
pub fn voting_level(unit: &UnitState, kind: ProposalKind) -> InertiaLevel {
match kind {
ProposalKind::Text | ProposalKind::Remove | ProposalKind::Inertia(_) => unit.inertia,
}
}
/// The formula a proposal is judged by: the level it is voted at, and the quorum of its
/// unit — its own, or that level's (a change of inertia inherits both).
pub fn params_for(unit: &UnitState, kind: ProposalKind, scale: &Scale) -> FormulaParams {
let level = voting_level(unit, kind);
scale.params(level, scale.quorum(level, unit.quorum))
}
/// A proposal that changes nothing cannot enter into force.
fn is_effective(unit: &UnitState, p: &ProposalState) -> bool {
match p.kind {
ProposalKind::Text => unit.in_force.as_ref().is_none_or(|v| v.title != p.title || v.body != p.body),
ProposalKind::Remove => unit.in_force.is_some(),
ProposalKind::Inertia(target) => target != unit.inertia,
}
}
#[derive(Clone, Debug)]
struct Scored<'a> {
proposal: &'a ProposalState,
margin: i64,
reached: bool,
well_rated: bool,
}
fn score<'a>(unit: &UnitState, p: &'a ProposalState, scale: &Scale) -> Result<Scored<'a>, ThresholdError> {
let e = evaluate(p.tally, scale.electorate, &params_for(unit, p.kind, scale))?;
Ok(Scored {
proposal: p,
margin: i64::from(p.tally.for_votes) - i64::from(e.required),
reached: e.reached,
well_rated: e.well_rated,
})
}
/// Best of rivals: widest margin, then most *for* votes, then earliest.
fn best<'a>(mut rivals: Vec<Scored<'a>>) -> Option<&'a ProposalState> {
rivals.sort_by(|a, b| {
b.margin
.cmp(&a.margin)
.then(b.proposal.tally.for_votes.cmp(&a.proposal.tally.for_votes))
.then(a.proposal.submitted_at.cmp(&b.proposal.submitted_at))
.then(a.proposal.id.cmp(&b.proposal.id))
});
rivals.first().map(|s| s.proposal)
}
/// Margin of the version in force, judged like a text proposal on its unit.
fn in_force_margin(unit: &UnitState, scale: &Scale) -> Result<Option<i64>, ThresholdError> {
let Some(v) = &unit.in_force else { return Ok(None) };
let e = evaluate(v.tally, scale.electorate, &params_for(unit, ProposalKind::Text, scale))?;
Ok(Some(i64::from(v.tally.for_votes) - i64::from(e.required)))
}
/// The proposals that enter into force now: per unit and dimension, the best
/// of those that reached their threshold, are effective and not blocked — and,
/// for content, hold a wider margin than the version in force.
pub fn adoptions(
units: &[UnitState],
proposals: &[ProposalState],
scale: &Scale,
) -> Result<Vec<String>, ThresholdError> {
let mut out = Vec::new();
for unit in units {
let incumbent = in_force_margin(unit, scale)?;
for content in [true, false] {
let mut rivals = Vec::new();
for p in proposals.iter().filter(|p| p.unit_id == unit.id && p.kind.is_content() == content) {
if p.blocked || !is_effective(unit, p) {
continue;
}
let s = score(unit, p, scale)?;
let beats_incumbent = !content || incumbent.is_none_or(|m| s.margin > m);
if s.reached && beats_incumbent {
rivals.push(s);
}
}
if let Some(winner) = best(rivals) {
out.push(winner.id.clone());
}
}
}
Ok(out)
}
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "lowercase")]
pub enum PreviewMode {
/// Only the in-force text.
InForce,
/// As if every well-rated proposal passed (« si les bien cotées passent »).
WellRated,
/// As if every proposal passed, whatever its rating (« si tout passe »).
All,
}
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "lowercase")]
pub enum PreviewMark {
Unchanged,
Added,
Replaced,
Removed,
}
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct PreviewUnit {
pub unit_id: String,
pub title: String,
pub body: String,
pub mark: PreviewMark,
/// The proposal that would bring the change.
pub proposal_id: Option<String>,
}
/// The document as it would read. Removed units are kept with their mark so
/// the screen can show them struck through.
pub fn preview(
units: &[UnitState],
proposals: &[ProposalState],
scale: &Scale,
mode: PreviewMode,
) -> Result<Vec<PreviewUnit>, ThresholdError> {
let mut ordered: Vec<&UnitState> = units.iter().collect();
ordered.sort_by(|a, b| a.position.total_cmp(&b.position).then(a.id.cmp(&b.id)));
let mut out = Vec::new();
for unit in ordered {
let chosen = if mode == PreviewMode::InForce {
None
} else {
let mut rivals = Vec::new();
for p in proposals.iter().filter(|p| p.unit_id == unit.id && p.kind.is_content()) {
if !is_effective(unit, p) {
continue;
}
let s = score(unit, p, scale)?;
if mode == PreviewMode::All || s.well_rated {
rivals.push(s);
}
}
if mode == PreviewMode::All {
// Whatever its rating: the most supported, then the earliest.
for r in &mut rivals {
r.margin = 0;
}
}
best(rivals)
};
match (chosen, &unit.in_force) {
(Some(p), current) => {
let mark = match (p.kind, current) {
(ProposalKind::Remove, _) => PreviewMark::Removed,
(_, Some(_)) => PreviewMark::Replaced,
(_, None) => PreviewMark::Added,
};
let (title, body) = match (p.kind, current) {
(ProposalKind::Remove, Some(v)) => (v.title.clone(), v.body.clone()),
_ => (p.title.clone(), p.body.clone()),
};
out.push(PreviewUnit { unit_id: unit.id.clone(), title, body, mark, proposal_id: Some(p.id.clone()) });
}
(None, Some(v)) => out.push(PreviewUnit {
unit_id: unit.id.clone(),
title: v.title.clone(),
body: v.body.clone(),
mark: PreviewMark::Unchanged,
proposal_id: None,
}),
(None, None) => {}
}
}
Ok(out)
}
#[cfg(test)]
mod tests {
use super::*;
fn unit(id: &str, position: f64, in_force: Option<&str>) -> UnitState {
UnitState {
id: id.into(),
position,
inertia: InertiaLevel::DEFAULT,
quorum: None,
in_force: in_force.map(|b| VersionText {
id: format!("{id}-v"),
title: id.into(),
body: b.into(),
tally: Tally::default(),
}),
}
}
fn prop(id: &str, unit_id: &str, kind: ProposalKind, body: &str, for_votes: u32, against: u32) -> ProposalState {
ProposalState {
id: id.into(),
unit_id: unit_id.into(),
kind,
title: unit_id.into(),
body: body.into(),
tally: Tally { for_votes, against },
submitted_at: format!("2026-10-21T00:00:0{}Z", id.len() % 10),
blocked: false,
}
}
/// Monnaie libre before any median: W 5745, the reference p0 = 2 × 50 / W, the quorums
/// 12 · 12 · 50 · 50 · 50 (ADR 0013).
const SCALE: Scale = Scale { electorate: 5745, participation: 100.0 / 5745.0, quorums: [12, 12, 50, 50, 50] };
fn level(k: u8) -> InertiaLevel {
InertiaLevel::new(k).expect("1 to 5")
}
#[test]
fn nothing_passes_at_zero_votes() {
let units = [unit("I1", 1.0, Some("préambule")), unit("E3", 7.0, None)];
let props = [prop("p1", "E3", ProposalKind::Text, "connaître", 0, 0)];
assert!(adoptions(&units, &props, &SCALE).expect("valid").is_empty());
}
#[test]
fn the_quorum_holds_then_releases() {
// Level 3, quorum 50: under it the voters missing count as against, 39 « pour » pass.
let units = [unit("E3", 7.0, None)];
let few = [prop("p1", "E3", ProposalKind::Text, "connaître", 38, 0)];
assert!(adoptions(&units, &few, &SCALE).expect("valid").is_empty());
let enough = [prop("p1", "E3", ProposalKind::Text, "connaître", 39, 0)];
assert_eq!(adoptions(&units, &enough, &SCALE).expect("valid"), vec!["p1".to_string()]);
// Its own quorum, lower, set by its author: the same curve on fewer voters.
let mut own = unit("E3", 7.0, None);
own.quorum = Some(12);
let lower = [prop("p1", "E3", ProposalKind::Text, "connaître", 11, 0)];
assert_eq!(adoptions(&[own], &lower, &SCALE).expect("valid"), vec!["p1".to_string()]);
}
#[test]
fn rivals_on_one_unit_yield_one_winner() {
let units = [unit("E3", 7.0, None)];
let props = [
prop("a", "E3", ProposalKind::Text, "variante a", 60, 0),
prop("bb", "E3", ProposalKind::Text, "variante b", 70, 0),
];
assert_eq!(adoptions(&units, &props, &SCALE).expect("valid"), vec!["bb".to_string()]);
}
#[test]
fn inertia_and_content_are_independent_dimensions() {
let units = [unit("E1", 4.0, Some("unicité"))];
let props = [
prop("t", "E1", ProposalKind::Text, "unicité reformulée", 60, 0),
prop("i", "E1", ProposalKind::Inertia(level(4)), "", 60, 0),
];
let won = adoptions(&units, &props, &SCALE).expect("valid");
assert_eq!(won, vec!["t".to_string(), "i".to_string()]);
}
#[test]
fn inertia_change_is_voted_at_the_level_of_the_unit() {
let mut u = unit("E1", 4.0, Some("x"));
u.inertia = level(4);
for target in [1, 3, 5] {
let kind = ProposalKind::Inertia(level(target));
assert_eq!(voting_level(&u, kind), level(4), "inherits the level of the article");
assert_eq!(params_for(&u, kind, &SCALE), params_for(&u, ProposalKind::Text, &SCALE));
}
let p = params_for(&u, ProposalKind::Text, &SCALE);
assert_eq!((p.majority_pct, p.quorum), (60.0, 50), "the level, and so its quorum");
u.quorum = Some(20);
assert_eq!(params_for(&u, ProposalKind::Inertia(level(1)), &SCALE).quorum, 20, "and the article's own");
}
#[test]
fn blocked_or_ineffective_never_pass() {
let units = [unit("E1", 4.0, Some("unicité"))];
let mut blocked = prop("t", "E1", ProposalKind::Text, "autre", 80, 0);
blocked.blocked = true;
let same = prop("s", "E1", ProposalKind::Text, "unicité", 80, 0);
let same_inertia = prop("i", "E1", ProposalKind::Inertia(level(3)), "", 80, 0);
assert!(adoptions(&units, &[blocked, same, same_inertia], &SCALE).expect("valid").is_empty());
}
#[test]
fn a_rival_must_beat_the_version_in_force() {
let mut units = [unit("E1", 4.0, Some("unicité"))];
// The seed a minima has no vote: the first rival that reaches its threshold wins.
let rival = [prop("r", "E1", ProposalKind::Text, "unicité reformulée", 50, 0)];
assert_eq!(adoptions(&units, &rival, &SCALE).expect("valid"), vec!["r".to_string()]);
// A version in force by vote, margin 60 − 47 = 13: a rival at 55 (margin 55 − 43 = 12) waits.
if let Some(v) = units[0].in_force.as_mut() {
v.tally = Tally { for_votes: 60, against: 0 };
}
let weaker = [prop("w", "E1", ProposalKind::Text, "autre", 55, 0)];
assert!(adoptions(&units, &weaker, &SCALE).expect("valid").is_empty());
let stronger = [prop("s", "E1", ProposalKind::Text, "autre", 70, 0)];
assert_eq!(adoptions(&units, &stronger, &SCALE).expect("valid"), vec!["s".to_string()]);
// The same rule holds for a removal; inertia does not compete with the text.
let removal = [prop("x", "E1", ProposalKind::Remove, "", 55, 0)];
assert!(adoptions(&units, &removal, &SCALE).expect("valid").is_empty());
let inertia = [prop("i", "E1", ProposalKind::Inertia(level(4)), "", 55, 0)];
assert_eq!(adoptions(&units, &inertia, &SCALE).expect("valid"), vec!["i".to_string()]);
}
#[test]
fn previews() {
let units = [unit("I1", 1.0, Some("préambule")), unit("E3", 7.0, None), unit("E1", 4.0, Some("unicité"))];
let props = [
prop("well", "E3", ProposalKind::Text, "connaître", 3, 0),
prop("weak", "E1", ProposalKind::Remove, "", 1, 5),
];
let in_force = preview(&units, &props, &SCALE, PreviewMode::InForce).expect("valid");
assert_eq!(in_force.iter().map(|u| u.unit_id.as_str()).collect::<Vec<_>>(), ["I1", "E1"]);
let likely = preview(&units, &props, &SCALE, PreviewMode::WellRated).expect("valid");
assert_eq!(
likely.iter().map(|u| (u.unit_id.as_str(), u.mark)).collect::<Vec<_>>(),
[("I1", PreviewMark::Unchanged), ("E1", PreviewMark::Unchanged), ("E3", PreviewMark::Added),]
);
let all = preview(&units, &props, &SCALE, PreviewMode::All).expect("valid");
assert_eq!(
all.iter().map(|u| (u.unit_id.as_str(), u.mark)).collect::<Vec<_>>(),
[("I1", PreviewMark::Unchanged), ("E1", PreviewMark::Removed), ("E3", PreviewMark::Added),]
);
}
#[test]
fn all_mode_prefers_the_most_supported_rival() {
let units = [unit("E3", 7.0, None)];
let props = [
prop("a", "E3", ProposalKind::Text, "variante a", 0, 0),
prop("bb", "E3", ProposalKind::Text, "variante b", 1, 3),
];
let all = preview(&units, &props, &SCALE, PreviewMode::All).expect("valid");
assert_eq!(all[0].proposal_id.as_deref(), Some("bb"));
}
}
-223
View File
@@ -1,223 +0,0 @@
//! Who applies a typo fix, and when (Yvv, 2026-10-30, L13). What a typo is
//! does not change: `edit` (letters and spaces, judged against the voted text).
//!
//! « Corriger coquille → ping auteur pour valid → idem 3j ça passe auto, mais
//! auteur a toujours la main tant que c'est pas le texte en vote. Sinon seule
//! une coquille validée corrige le texte en vote. »
//!
//! - The person who **answers for the version** — its author; for a text
//! without author (a starting text), an administrator of trustinWallet
//! (`trustin_access::answers_typo`) — applies a fix at once (`route`).
//! - Anyone else's fix **waits**: the one who answers is told by a message,
//! validates or refuses, and **without an answer within 3 days it applies by
//! itself** (`tick`). On a text in force, only a validated fix (or one passed
//! at the deadline) corrects it.
//! - **The author keeps the hand while the version is not in force**: a fix
//! applied (validated or passed at the deadline) may still be refused — the
//! text it was written over comes back —, and a refused one validated after
//! all. Once in force, an answer is final.
//! - A fix is written only over the text it was written over (`fits`): two
//! fixes waiting on one text are each judged against the voted text; once one
//! applies, the other no longer fits and is **stale** (« recharge, puis
//! recommence »). Slips never add up.
//! - Translations stay out of this: editorial, without validation.
use serde::{Deserialize, Serialize};
/// Days the one who answers for the version has, before a fix applies by itself.
pub const ANSWER_DAYS: i64 = 3;
/// Where a fix goes when it is written.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum Route {
/// Applied at once: the writer answers for the version.
Apply,
/// Waits for the one who answers for the version, or for the deadline.
Await,
}
pub fn route(writer_answers: bool) -> Route {
if writer_answers { Route::Apply } else { Route::Await }
}
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "lowercase")]
pub enum TypoStatus {
/// Written, waiting for an answer or the deadline.
Pending,
/// In the text.
Applied,
/// Not in the text: refused (or taken back by the author before the text was in force).
Refused,
/// The text changed before it could apply: written over a text that is no longer there.
Stale,
}
impl TypoStatus {
pub fn as_str(self) -> &'static str {
match self {
Self::Pending => "pending",
Self::Applied => "applied",
Self::Refused => "refused",
Self::Stale => "stale",
}
}
pub fn parse(raw: &str) -> Option<Self> {
[Self::Pending, Self::Applied, Self::Refused, Self::Stale].into_iter().find(|s| s.as_str() == raw)
}
}
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum Answer {
Validate,
Refuse,
}
/// What a step does to the text of the version.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum TextChange {
/// Write the fix (only where `fits`).
Write,
/// Put back the text the fix was written over (only where the fix is still the text).
Restore,
Keep,
}
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub struct Settle {
pub status: TypoStatus,
pub text: TextChange,
/// Holds only while the version is not in force: the author's change of mind,
/// decided on a version read open, must not land on one that entered meanwhile.
pub while_open: bool,
}
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum TypoRefusal {
/// Already answered (or stale): nothing left to decide.
Settled,
}
/// An answer of the one who answers for the version. `in_force`: the version is in force now.
pub fn answer(status: TypoStatus, answer: Answer, in_force: bool) -> Result<Settle, TypoRefusal> {
let to = |status, text, while_open| Ok(Settle { status, text, while_open });
match (status, answer) {
(TypoStatus::Pending, Answer::Validate) => to(TypoStatus::Applied, TextChange::Write, false),
(TypoStatus::Pending, Answer::Refuse) => to(TypoStatus::Refused, TextChange::Keep, false),
// The author keeps the hand while the version is not in force.
(TypoStatus::Applied, Answer::Refuse) if !in_force => to(TypoStatus::Refused, TextChange::Restore, true),
(TypoStatus::Refused, Answer::Validate) if !in_force => to(TypoStatus::Applied, TextChange::Write, true),
_ => Err(TypoRefusal::Settled),
}
}
/// The clock (sortable stamps): a pending fix applies by itself at its deadline.
pub fn tick(status: TypoStatus, due: &str, now: &str) -> Option<Settle> {
(status == TypoStatus::Pending && due <= now).then_some(Settle {
status: TypoStatus::Applied,
text: TextChange::Write,
while_open: false,
})
}
/// A text as a fix sees it: the revision it belongs to, its title and body.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub struct Text<'a> {
pub revision: i64,
pub title: &'a str,
pub body: &'a str,
}
/// A fix is written only over the very text it was written over: same revision
/// (no change of substance since), same title and body (no other fix since).
pub fn fits(base: Text<'_>, current: Text<'_>) -> bool {
base == current
}
#[cfg(test)]
mod tests {
use super::*;
use crate::edit::{EditKind, classify};
#[test]
fn the_one_who_answers_applies_at_once_anyone_else_waits() {
assert_eq!(route(true), Route::Apply, "the author, or an administrator for a starting text");
assert_eq!(route(false), Route::Await);
}
#[test]
fn a_pending_fix_is_validated_or_refused() {
for in_force in [false, true] {
assert_eq!(
answer(TypoStatus::Pending, Answer::Validate, in_force),
Ok(Settle { status: TypoStatus::Applied, text: TextChange::Write, while_open: false })
);
assert_eq!(
answer(TypoStatus::Pending, Answer::Refuse, in_force),
Ok(Settle { status: TypoStatus::Refused, text: TextChange::Keep, while_open: false })
);
}
}
#[test]
fn the_author_keeps_the_hand_until_the_text_is_in_force() {
// A proposal: an applied fix may be taken back, a refused one validated after all.
assert_eq!(
answer(TypoStatus::Applied, Answer::Refuse, false),
Ok(Settle { status: TypoStatus::Refused, text: TextChange::Restore, while_open: true })
);
assert_eq!(
answer(TypoStatus::Refused, Answer::Validate, false),
Ok(Settle { status: TypoStatus::Applied, text: TextChange::Write, while_open: true })
);
// In force: an answer is final.
assert_eq!(answer(TypoStatus::Applied, Answer::Refuse, true), Err(TypoRefusal::Settled));
assert_eq!(answer(TypoStatus::Refused, Answer::Validate, true), Err(TypoRefusal::Settled));
// Nothing to answer twice the same way, and never a stale fix.
assert_eq!(answer(TypoStatus::Applied, Answer::Validate, false), Err(TypoRefusal::Settled));
assert_eq!(answer(TypoStatus::Refused, Answer::Refuse, false), Err(TypoRefusal::Settled));
for a in [Answer::Validate, Answer::Refuse] {
assert_eq!(answer(TypoStatus::Stale, a, false), Err(TypoRefusal::Settled));
}
}
#[test]
fn without_an_answer_it_applies_at_the_deadline() {
let due = "2026-11-03T09:00:00Z";
assert_eq!(tick(TypoStatus::Pending, due, "2026-11-03T08:59:59Z"), None);
assert_eq!(
tick(TypoStatus::Pending, due, due),
Some(Settle { status: TypoStatus::Applied, text: TextChange::Write, while_open: false })
);
// A deadline passed while the server was stopped is taken at the next tick.
assert!(tick(TypoStatus::Pending, due, "2027-02-01T00:00:00Z").is_some());
for s in [TypoStatus::Applied, TypoStatus::Refused, TypoStatus::Stale] {
assert_eq!(tick(s, due, "2027-02-01T00:00:00Z"), None, "{s:?}");
}
}
#[test]
fn two_fixes_waiting_on_one_text_the_second_goes_stale() {
let voted = Text { revision: 1, title: "Réciprocité", body: "des persones qui respectent" };
let first = Text { revision: 1, title: "Réciprocité", body: "des personnes qui respectent" };
let second = Text { revision: 1, title: "Réciprocité", body: "des persones qui repectent" };
// Each is judged against the voted text, as before: each is a typo there.
assert_eq!(classify(voted.body, first.body), EditKind::Typographic);
assert_eq!(classify(voted.body, second.body), EditKind::Typographic);
// Both were written over the voted text; the first applies.
assert!(fits(voted, voted));
// Then the text is the first fix: the second no longer fits — stale, never merged.
assert!(!fits(voted, first));
// A change of substance in between (a new revision) does the same, even back to the same words.
assert!(!fits(voted, Text { revision: 2, ..voted }));
}
#[test]
fn words_round_trip() {
for s in [TypoStatus::Pending, TypoStatus::Applied, TypoStatus::Refused, TypoStatus::Stale] {
assert_eq!(TypoStatus::parse(s.as_str()), Some(s));
}
assert_eq!(TypoStatus::parse("typo"), None);
}
}

Some files were not shown because too many files have changed in this diff Show More