Compare commits
2
Commits
main
..
f87cbc0f2f
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
f87cbc0f2f | ||
|
|
6fe0b41e7f |
@@ -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
@@ -1,34 +1,49 @@
|
|||||||
# libreDecision v3 — variables du serveur (ld-server). Toutes facultatives en dev.
|
# PostgreSQL
|
||||||
# Le serveur lit son environnement, pas de fichier : `set -a; . ./.env; set +a` avant
|
POSTGRES_DB=glibredecision
|
||||||
# `cargo run -p ld-server`. Le .env n'est jamais commité.
|
POSTGRES_USER=glibredecision
|
||||||
|
POSTGRES_PASSWORD=change-me-in-production
|
||||||
|
DATABASE_URL=postgresql+asyncpg://glibredecision:change-me-in-production@localhost:5432/glibredecision
|
||||||
|
|
||||||
# Écoute et dossiers
|
# Backend
|
||||||
LD_BIND=0.0.0.0:8002
|
SECRET_KEY=change-me-in-production-with-a-real-secret-key
|
||||||
LD_DATA_DIR=data # SQLite, clone du dépôt des textes, clé de publication — données runtime
|
DEBUG=true
|
||||||
LD_WEB_DIR=web/build # interface compilée (npm run build)
|
|
||||||
LD_SEED_DIR=seeds
|
|
||||||
LD_PUBLIC_URL=http://127.0.0.1:3002
|
|
||||||
|
|
||||||
# Connexion Ğ1 — client OAuth2 de connect.monnaie-libre.fr (les deux sont requis, sinon désactivée)
|
# Environment: development, staging, production
|
||||||
# LD_CONNECT_URL=https://connect.monnaie-libre.fr
|
ENVIRONMENT=development
|
||||||
# LD_CONNECT_CLIENT_ID=
|
|
||||||
# LD_CONNECT_CLIENT_SECRET=
|
|
||||||
|
|
||||||
# Accès (trustinWallet)
|
# Logging: DEBUG, INFO, WARNING, ERROR
|
||||||
LD_ADMIN_G1_IDS= # ex. idty:8352 — administrateurs des accès, séparés par des virgules
|
LOG_LEVEL=INFO
|
||||||
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)
|
|
||||||
|
|
||||||
# Publication des textes en vigueur (dépôt git lu par les applis clientes)
|
# CORS
|
||||||
# LD_PUBLISH_REMOTE=gitea@git.open.us.org:yvv/decision-depot-textes-reference.git
|
CORS_ORIGINS=["http://localhost:3002"]
|
||||||
# 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
|
|
||||||
|
|
||||||
# Taille de la toile de confiance (W) — lue sur la chaîne, repli sur l'indexeur puis sur la valeur fixe
|
# Database connection pool
|
||||||
# LD_RPC_ENDPOINTS=https://rpc.duniter.org,https://g1.axiom-team.fr/ws/
|
DATABASE_POOL_SIZE=20
|
||||||
# LD_INDEXERS=https://indexer.duniter.org/v1/graphql
|
DATABASE_MAX_OVERFLOW=10
|
||||||
# LD_WOT_FALLBACK=5745
|
|
||||||
|
|
||||||
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
@@ -25,7 +25,6 @@ Thumbs.db
|
|||||||
|
|
||||||
# Database
|
# Database
|
||||||
*.db
|
*.db
|
||||||
*.db.bak*
|
|
||||||
*.sqlite
|
*.sqlite
|
||||||
|
|
||||||
# Docker volumes
|
# Docker volumes
|
||||||
@@ -41,15 +40,3 @@ htmlcov/
|
|||||||
|
|
||||||
# IPFS
|
# IPFS
|
||||||
ipfs-data/
|
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/
|
|
||||||
|
|||||||
@@ -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
|
||||||
@@ -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
|
|
||||||
@@ -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
|
|
||||||
@@ -1,173 +1,88 @@
|
|||||||
# libreDecision
|
# libreDecision
|
||||||
|
|
||||||
Rendre possible la décision collective — en fonction du contexte, processus et modalités,
|
Boîte à outils de gouvernance collective pour la communauté Duniter/G1.
|
||||||
boîte à outils. **v3 : Rust + Svelte**, un chantier à la fois, chacun livré en production avant
|
Documents modulaires sous vote permanent + protocoles de vote + mandats.
|
||||||
le suivant (`docs/chantiers/README.md`).
|
Architecture marque blanche — vocation à être intégré dans sweethomeCloud et librodrome.
|
||||||
**Référence du chantier en cours : `docs/chantiers/acte-certification/CHANTIER.md`** — toute
|
|
||||||
décision de comportement s'y adosse ; les décisions durables dans `docs/adr/`.
|
|
||||||
La maquette v2 (Nuxt + FastAPI) vit dans `maquette/`, **intacte** (Yvv la présente) — ne pas y
|
|
||||||
développer.
|
|
||||||
|
|
||||||
## Protocole de début de session
|
## Protocole de début de session
|
||||||
|
|
||||||
1. `git pull --rebase origin main` (puis la branche du chantier : `chantier/acte-certification`)
|
1. `git pull --rebase origin main`
|
||||||
2. Lire `HANDOFF.md` (où on en est, ce qui attend Yvv)
|
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
|
3. Si l'objectif de la session n'est pas précisé, le demander
|
||||||
|
|
||||||
## Stack
|
## Stack
|
||||||
|
|
||||||
- **Serveur** : Rust edition 2024, toolchain **1.99** épinglée (`rust-toolchain.toml`) ;
|
- **Frontend** : Nuxt 4 (Vue 3, TypeScript) + Nuxt UI v3 + Pinia + UnoCSS ; package manager : npm
|
||||||
rustup installé sans PATH → `export PATH=$HOME/.cargo/bin:$PATH`. axum 0.8, sqlx 0.8
|
- **Backend** : Python FastAPI + SQLAlchemy 2.0 async + PostgreSQL asyncpg ; migrations Alembic
|
||||||
(SQLite, requêtes runtime + `migrate!`), tokio, reqwest rustls.
|
- **Auth** : Duniter V2 Ed25519 challenge-response (substrate-interface — stub en dev)
|
||||||
- **Interface** : SvelteKit 2 / Svelte 5 (runes), adapter-static SPA, vite 7, vitest,
|
- **Sanctuaire** : IPFS kubo + hash on-chain (system.remark) — TODO sprint 2
|
||||||
Playwright ; npm. Polices Literata (textes) + Nunito (interface), fontsource.
|
- Déploiement : Docker multi-stage + Traefik (postgres + backend + frontend + ipfs) ; CI Woodpecker
|
||||||
- **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.
|
|
||||||
|
|
||||||
## Structure
|
## Structure
|
||||||
|
|
||||||
```
|
```
|
||||||
crates/
|
frontend/
|
||||||
ld-engine/ # PUR (zéro I/O) : threshold (formule inertielle sur un quorum, judged_voters,
|
app/
|
||||||
# yes_needed / no_needed), inertia (cinq crans, leurs quorums, bornes, médiane,
|
components/ # composants Vue
|
||||||
# référence, échelle), edit (coquille vs fond, diff), assist (découpage, similarité,
|
layouts/ # layouts Nuxt
|
||||||
# longueur), compose (adoption, aperçus), ballot (bulletin scellé ld1:, le dernier
|
pages/ # routing file-based (9 pages sprint 1)
|
||||||
# fait foi, jugement d'une transaction), address (SS58, préfixe g1), objection
|
composables/ # (1 composable sprint 1)
|
||||||
# (redit / contredit : la dernière arrivée retenue, les tours, l'échéance), correction
|
stores/ # 5 Pinia stores (auth, ...)
|
||||||
# (la coquille d'un autre : attendre, valider, refuser, l'échéance), order (l'ordre
|
assets/css/
|
||||||
# d'une section : qui en est, complété à la fin, places), pending (modules à voter),
|
moods.css # système de palettes (.mood-* sur <html>)
|
||||||
# buffer (le tampon des votes, ADR 0012 : réconciliation, poids des états,
|
utils/ # (2 utils sprint 1)
|
||||||
# SIMULATED_ADOPTS)
|
nuxt.config.ts # port 3002, host 0.0.0.0, apiBase via NUXT_PUBLIC_API_BASE
|
||||||
trustin-access/ # règles d'accès de trustinWallet : sees / opens / manages_access, vote_way (serveur ou
|
backend/
|
||||||
# chaîne), answers_typo, lifts_objection / reiterates_objection, reads_message,
|
app/
|
||||||
# sets_quorum, casts_simulated (le vote simulé, chaîne fermée), groupes par défaut
|
routers/ # 8 routers : auth, communes, documents, protocols, votes, ...
|
||||||
# (TEST_GROUP)
|
services/ # 6 services
|
||||||
ld-server/
|
engine/ # 5 modules : formule inertie, critères Smith/TechComm, médiane
|
||||||
migrations/ # schéma SQLite — additif seulement
|
models/ # 14 tables SQLAlchemy
|
||||||
src/ # lib.rs (routes, csrf, run) · config.rs (LD_*) · auth.rs (connect OAuth2, invitations,
|
alembic/versions/ # migrations
|
||||||
# dev, bootstrap) · team.rs (liens d'équipe) · docs.rs (lecture, aperçus) · proposals.rs (écrire, voter,
|
tests/ # 186 tests (63 intégration TDD sprint 1)
|
||||||
# traduire) · objections.rs (faire, lever, réitérer, la version retenue) · typos.rs
|
seed.py # Engagement Certification (33 items) + Forgeron (51 items) + Runtime Upgrade
|
||||||
# (les coquilles des autres) · messages.rs (la boîte « Messages ») · deadlines.rs
|
docker/
|
||||||
# (les échéances de 3 jours) · clock.rs (horloge injectable) · adoption.rs
|
docker-compose.yml # postgres + backend + frontend + ipfs
|
||||||
# (balayage, verrou) · participation.rs (p̃, une fois par
|
backend.Dockerfile
|
||||||
# jour) · publish.rs (file → git) · wot.rs (W sur la chaîne) · seed.rs ·
|
frontend.Dockerfile
|
||||||
# seed_amend.rs / inertia_amend.rs / seed_proposal_amend.rs (amendements gardés ;
|
docs/content/ # 7 docs dev + 8 docs user
|
||||||
# le dernier fait des Propositions seed, ADR 0011) · ballots.rs (vote groupé,
|
public/
|
||||||
# bulletins de la chaîne Ğ1, le tampon : intention, preuve, réconciliation ; le vote
|
hexagram-tsing.svg # sceau 井 (embossed)
|
||||||
# simulé, « ton vote ») · simulation.rs (l'acte « supprime » de la fin de la simulation :
|
hexagram-tsing-flat.svg
|
||||||
# `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)
|
|
||||||
```
|
```
|
||||||
|
|
||||||
## Données runtime — jamais écrasées
|
## Données runtime
|
||||||
|
|
||||||
- **Production** : volume `lpk-libredecision-app_data` (`/data` : `libredecision.db`, `textes/`
|
- **postgres-data** : volume Docker PostgreSQL — jamais écrasé par les builds
|
||||||
= clone du dépôt publié, `keys/` = clé de publication, `bootstrap-admin.txt`). Jamais
|
- **ipfs-data** : volume Docker IPFS kubo
|
||||||
`down -v`, jamais recréé.
|
- `.env` à la racine : `POSTGRES_USER`, `POSTGRES_PASSWORD`, `POSTGRES_DB`, `SECRET_KEY`, `DOMAIN`, `DUNITER_RPC_URL`
|
||||||
- **Maquette** : volumes `lpk-decision-main_postgres-data` / `_ipfs-data` (inchangés).
|
|
||||||
- **Local** : `data/` (gitignoré) — le supprimer repart du seed. Les e2e utilisent `/tmp/ld-e2e`.
|
|
||||||
- Un document seedé n'est **jamais** réinstancié : dès qu'il existe en base, c'est de la donnée.
|
|
||||||
|
|
||||||
## Commandes — la boucle (tout vert = fait)
|
## Commandes
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
export PATH=$HOME/.cargo/bin:$PATH
|
# Backend
|
||||||
cargo fmt --all --check && cargo clippy --workspace --all-targets -- -D warnings && cargo test --workspace
|
cd backend && . venv/bin/activate
|
||||||
cd web && npm run check && npm test && npm run build && npm run e2e # e2e : 46 contrôles (48 tests, C25 et C35 en deux)
|
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
|
# Frontend
|
||||||
cd web && npm run dev # :3002 à chaud
|
cd frontend && npm run dev # :3002
|
||||||
docker build -f docker/app.Dockerfile --target production . # rejoue toute la boucle
|
npm run build
|
||||||
|
|
||||||
|
# Docker
|
||||||
|
docker compose -f docker/docker-compose.yml up
|
||||||
```
|
```
|
||||||
|
|
||||||
## Conventions / pièges
|
## Conventions / pièges
|
||||||
|
|
||||||
- **UI français, code anglais** ; **trilingue** : aucune chaîne affichée en dur, tout passe par
|
- **UI français, code anglais** (variables, commentaires, docstrings)
|
||||||
`web/src/lib/i18n/fr.ts` puis en/es (test de parité + `FORBIDDEN_UI_TERMS`). Tutoyer ; le sujet
|
- **API** : préfixe `/api/v1/`, Pydantic v2 pour tous les schémas, async partout (SQLAlchemy + FastAPI)
|
||||||
est la personne ou le groupe, jamais le système. Le français du texte voté fait foi.
|
- **Auth** : `get_current_admin` (24h), `get_current_citizen` (4h), `require_super_admin`
|
||||||
- **Moteur pur** : une règle de vote / édition / composition s'écrit et se teste dans
|
- **Formule inertie** : `Result = C + B^W + (M + (1-M) × (1 - (T/W)^G)) × max(0, T-C)` — voir `backend/app/engine/`
|
||||||
`ld-engine` avant d'être câblée. Le serveur ne décide jamais l'accès seul : `trustin-access`.
|
- **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)
|
||||||
- **Invariants** : les informations de fonctionnement (formule, inertie, ordonnancement) ne se
|
- **Sceau** `井` (#48 Tsing) : `.app-seal` dans `app.vue`, right-aligned ; SVGs dans `public/`
|
||||||
votent pas : hors du document, une unité retirée (`units.retired_at`) n'est ni lue, ni votée, ni
|
- **CSS drop-shadow()** safe pour effets emboss ; `<filter>` SVG inline cause des artefacts de rendu
|
||||||
comptée, ni publiée, et rien n'est effacé (ADR 0014) ; au départ, aucun article n'est en
|
- **Domaine** : libredecision.org (Woodpecker CI ; ancien dossier : Glibredecision)
|
||||||
vigueur : I1, E1, E12 et E2 sont des Propositions
|
- **Ed25519 verification** : stub en dev (substrate-interface), autoritaire en prod — ne pas bypasser sans test
|
||||||
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).
|
|
||||||
|
|||||||
+42
-53
@@ -1,63 +1,52 @@
|
|||||||
# Contribuer à libreDecision
|
# Contribuer à libreDecision
|
||||||
|
|
||||||
On travaille **un chantier à la fois**, livré complet et en production avant le suivant
|
## Environnement
|
||||||
(`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
|
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
export PATH=$HOME/.cargo/bin:$PATH
|
# Backend (Python 3.11+)
|
||||||
cargo fmt --all --check
|
cd backend
|
||||||
cargo clippy --workspace --all-targets -- -D warnings
|
python -m venv .venv && source .venv/bin/activate
|
||||||
cargo test --workspace
|
pip install -r requirements.txt
|
||||||
cd web
|
alembic upgrade head
|
||||||
npm run check # svelte-check, zéro avertissement
|
uvicorn app.main:app --host 0.0.0.0 --port 8002 --reload
|
||||||
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
|
|
||||||
```
|
|
||||||
|
|
||||||
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
|
## Conventions
|
||||||
|
|
||||||
- **Interface en français, code en anglais** (identifiants, commentaires, messages de log).
|
- **UI** : français — **Code** : anglais (variables, commentaires, docstrings)
|
||||||
- **Trilingue dès le départ** : toute chaîne affichée vit dans `web/src/lib/i18n/fr.ts`
|
- **CSS** : scoped, sans bordures (`border: none`), profondeur via `box-shadow`
|
||||||
(la source), puis `en.ts` et `es.ts`. Le test de parité refuse une clé manquante ou un
|
- **Composants** : `pathPrefix: false` — noms courts, auto-import
|
||||||
paramètre `{x}` qui diffère. Le texte voté est le français ; les traductions sont éditoriales.
|
- **API** : versionnée `/api/v1/`, Pydantic v2, async partout
|
||||||
- **Ton** : tutoyer la personne ; le sujet est la personne ou le collectif, jamais le système.
|
- **Ports stricts** : frontend=3002, backend=8002 — jamais de fallback
|
||||||
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.
|
|
||||||
|
|
||||||
## Signaler
|
## Architecture toolbox
|
||||||
|
|
||||||
**« Faire un retour »** (dans le menu de gauche, sur chaque page) envoie une note datée avec la
|
Chaque section expose une `<SectionLayout>` avec :
|
||||||
page, la langue et la taille de l'écran ; elles se lisent dans trustinWallet (`/acces`).
|
- Contenu principal (slot `#default`)
|
||||||
Pour un bug reproductible, une issue sur la forge avec les étapes.
|
- 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
File diff suppressed because it is too large
Load Diff
-23
@@ -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
@@ -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`.
|
|
||||||
@@ -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/>.
|
|
||||||
@@ -1,117 +1,32 @@
|
|||||||
# libreDecision
|
# libreDecision
|
||||||
|
|
||||||
Rendre possible la décision collective — en fonction du contexte, processus et modalités,
|
Plateforme de décisions collectives pour la communauté Duniter/G1.
|
||||||
boîte à outils.
|
Boîte à outils gouvernance multi-collectifs, architecture white-label.
|
||||||
|
|
||||||
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/).
|
|
||||||
|
|
||||||
## Stack
|
## Stack
|
||||||
|
|
||||||
- **Serveur** : Rust (edition 2024, toolchain 1.99 épinglée) — axum 0.8, sqlx/SQLite, tokio.
|
- **Frontend** : Nuxt 4 + Vue 3 + Pinia + UnoCSS (port 3002)
|
||||||
Trois crates : `ld-engine` (moteur pur : seuils, édition, assistant, composition),
|
- **Backend** : Python FastAPI + SQLAlchemy async + SQLite (port 8002)
|
||||||
`trustin-access` (règles d'accès de trustinWallet), `ld-server` (API + publication + service
|
- **Auth** : Duniter V2 Ed25519 challenge-response
|
||||||
de l'interface).
|
- **Sanctuaire** : IPFS (kubo) + hash on-chain (system.remark)
|
||||||
- **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é.
|
|
||||||
|
|
||||||
## Démarrer
|
## Démarrage
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
export PATH=$HOME/.cargo/bin:$PATH
|
# Backend
|
||||||
cd web && npm ci && npm run build && cd .. # l'interface compilée
|
cd backend && .venv/bin/uvicorn app.main:app --host 0.0.0.0 --port 8002 --reload
|
||||||
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)
|
# Frontend
|
||||||
|
cd frontend && npm run dev
|
||||||
```
|
```
|
||||||
|
|
||||||
`LD_DEV_LOGIN=1` ouvre une connexion de développement (sans clé, membre de tous les groupes) —
|
## Sections
|
||||||
jamais en production. Toutes les variables : [`.env.example`](.env.example).
|
|
||||||
|
|
||||||
## 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
|
## Architecture
|
||||||
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
|
|
||||||
```
|
|
||||||
|
|
||||||
Les contrôles d'usage (C1–C12, M1) sont décrits dans
|
Voir `CLAUDE.md` pour les conventions et `docs/content/dev/` pour la documentation technique.
|
||||||
[`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`.
|
|
||||||
|
|||||||
@@ -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.
|
|
||||||
@@ -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.
|
|
||||||
@@ -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).
|
|
||||||
@@ -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.
|
|
||||||
@@ -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.
|
|
||||||
@@ -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).
|
|
||||||
@@ -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.
|
|
||||||
@@ -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.
|
|
||||||
@@ -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
|
from app.models import ( # noqa: F401
|
||||||
DuniterIdentity,
|
DuniterIdentity,
|
||||||
Session,
|
Session,
|
||||||
Organization,
|
|
||||||
OrgMember,
|
|
||||||
Document,
|
Document,
|
||||||
DocumentItem,
|
DocumentItem,
|
||||||
ItemVersion,
|
ItemVersion,
|
||||||
@@ -39,7 +37,6 @@ from app.models import ( # noqa: F401
|
|||||||
MandateStep,
|
MandateStep,
|
||||||
VotingProtocol,
|
VotingProtocol,
|
||||||
FormulaConfig,
|
FormulaConfig,
|
||||||
QualificationProtocol,
|
|
||||||
SanctuaryEntry,
|
SanctuaryEntry,
|
||||||
BlockchainCache,
|
BlockchainCache,
|
||||||
)
|
)
|
||||||
@@ -39,9 +39,6 @@ class Settings(BaseSettings):
|
|||||||
RATE_LIMIT_AUTH: int = 10
|
RATE_LIMIT_AUTH: int = 10
|
||||||
RATE_LIMIT_VOTE: int = 30
|
RATE_LIMIT_VOTE: int = 30
|
||||||
|
|
||||||
# AI — Qwen3.6 (MacStudio) endpoint, branché plus tard
|
|
||||||
QWEN_API_URL: str = ""
|
|
||||||
|
|
||||||
# Blockchain cache
|
# Blockchain cache
|
||||||
BLOCKCHAIN_CACHE_TTL_SECONDS: int = 3600
|
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.middleware.security_headers import SecurityHeadersMiddleware
|
||||||
from app.routers import auth, documents, decisions, votes, mandates, protocols, sanctuary, websocket
|
from app.routers import auth, documents, decisions, votes, mandates, protocols, sanctuary, websocket
|
||||||
from app.routers import public
|
from app.routers import public
|
||||||
from app.routers import organizations
|
|
||||||
from app.routers import qualify
|
|
||||||
from app.routers import groups
|
|
||||||
|
|
||||||
|
|
||||||
# ── Structured logging setup ───────────────────────────────────────────────
|
# ── Structured logging setup ───────────────────────────────────────────────
|
||||||
@@ -88,28 +85,8 @@ app = FastAPI(
|
|||||||
|
|
||||||
|
|
||||||
# ── Middleware stack ──────────────────────────────────────────────────────
|
# ── Middleware stack ──────────────────────────────────────────────────────
|
||||||
# add_middleware is LIFO: last added = outermost = first to execute on request,
|
# Middleware is applied in reverse order: last added = first executed.
|
||||||
# last to execute on response (wraps everything inside it).
|
# Order: SecurityHeaders -> RateLimiter -> CORS -> Application
|
||||||
#
|
|
||||||
# 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,
|
|
||||||
)
|
|
||||||
|
|
||||||
app.add_middleware(
|
app.add_middleware(
|
||||||
CORSMiddleware,
|
CORSMiddleware,
|
||||||
@@ -119,6 +96,15 @@ app.add_middleware(
|
|||||||
allow_headers=["*"],
|
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 ──────────────────────────────────────────────────────────────
|
# ── 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(sanctuary.router, prefix="/api/v1/sanctuary", tags=["sanctuary"])
|
||||||
app.include_router(websocket.router, prefix="/api/v1/ws", tags=["websocket"])
|
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(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 ─────────────────────────────────────────────────────────
|
# ── Health check ─────────────────────────────────────────────────────────
|
||||||
+11
-18
@@ -64,6 +64,14 @@ class RateLimiterMiddleware(BaseHTTPMiddleware):
|
|||||||
self._last_cleanup: float = time.time()
|
self._last_cleanup: float = time.time()
|
||||||
self._lock = asyncio.Lock()
|
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:
|
def _get_client_ip(self, request: Request) -> str:
|
||||||
"""Extract the client IP from the request, respecting X-Forwarded-For."""
|
"""Extract the client IP from the request, respecting X-Forwarded-For."""
|
||||||
forwarded = request.headers.get("x-forwarded-for")
|
forwarded = request.headers.get("x-forwarded-for")
|
||||||
@@ -93,22 +101,6 @@ class RateLimiterMiddleware(BaseHTTPMiddleware):
|
|||||||
if ips_to_delete:
|
if ips_to_delete:
|
||||||
logger.debug("Nettoyage rate limiter: %d IPs supprimees", len(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:
|
async def dispatch(self, request: Request, call_next) -> Response:
|
||||||
"""Check rate limit and either allow the request or return 429."""
|
"""Check rate limit and either allow the request or return 429."""
|
||||||
# Skip rate limiting for WebSocket upgrades
|
# Skip rate limiting for WebSocket upgrades
|
||||||
@@ -119,7 +111,8 @@ class RateLimiterMiddleware(BaseHTTPMiddleware):
|
|||||||
await self._cleanup_old_entries()
|
await self._cleanup_old_entries()
|
||||||
|
|
||||||
client_ip = self._get_client_ip(request)
|
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()
|
now = time.time()
|
||||||
window_start = now - 60
|
window_start = now - 60
|
||||||
|
|
||||||
@@ -140,7 +133,7 @@ class RateLimiterMiddleware(BaseHTTPMiddleware):
|
|||||||
|
|
||||||
logger.warning(
|
logger.warning(
|
||||||
"Rate limit depasse pour %s sur %s (%d/%d)",
|
"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(
|
return JSONResponse(
|
||||||
@@ -1,25 +1,19 @@
|
|||||||
from app.models.user import DuniterIdentity, Session
|
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.document import Document, DocumentItem, ItemVersion
|
||||||
from app.models.decision import Decision, DecisionStep
|
from app.models.decision import Decision, DecisionStep
|
||||||
from app.models.vote import VoteSession, Vote
|
from app.models.vote import VoteSession, Vote
|
||||||
from app.models.mandate import Mandate, MandateStep
|
from app.models.mandate import Mandate, MandateStep
|
||||||
from app.models.protocol import VotingProtocol, FormulaConfig
|
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.sanctuary import SanctuaryEntry
|
||||||
from app.models.cache import BlockchainCache
|
from app.models.cache import BlockchainCache
|
||||||
|
|
||||||
__all__ = [
|
__all__ = [
|
||||||
"DuniterIdentity", "Session",
|
"DuniterIdentity", "Session",
|
||||||
"Organization", "OrgMember",
|
|
||||||
"Document", "DocumentItem", "ItemVersion",
|
"Document", "DocumentItem", "ItemVersion",
|
||||||
"Decision", "DecisionStep",
|
"Decision", "DecisionStep",
|
||||||
"VoteSession", "Vote",
|
"VoteSession", "Vote",
|
||||||
"Mandate", "MandateStep",
|
"Mandate", "MandateStep",
|
||||||
"VotingProtocol", "FormulaConfig",
|
"VotingProtocol", "FormulaConfig",
|
||||||
"QualificationProtocol",
|
|
||||||
"Group", "GroupMember",
|
|
||||||
"SanctuaryEntry",
|
"SanctuaryEntry",
|
||||||
"BlockchainCache",
|
"BlockchainCache",
|
||||||
]
|
]
|
||||||
@@ -16,7 +16,6 @@ class Decision(Base):
|
|||||||
context: Mapped[str | None] = mapped_column(Text)
|
context: Mapped[str | None] = mapped_column(Text)
|
||||||
decision_type: Mapped[str] = mapped_column(String(64), nullable=False) # runtime_upgrade, document_change, mandate_vote, custom
|
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
|
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"))
|
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_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())
|
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")
|
version: Mapped[str] = mapped_column(String(32), default="0.1.0")
|
||||||
status: Mapped[str] = mapped_column(String(32), default="draft") # draft, active, archived
|
status: Mapped[str] = mapped_column(String(32), default="draft") # draft, active, archived
|
||||||
description: Mapped[str | None] = mapped_column(Text)
|
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))
|
ipfs_cid: Mapped[str | None] = mapped_column(String(128))
|
||||||
chain_anchor: 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
|
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)
|
id: Mapped[uuid.UUID] = mapped_column(Uuid, primary_key=True, default=uuid.uuid4)
|
||||||
title: Mapped[str] = mapped_column(String(256), nullable=False)
|
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)
|
description: Mapped[str | None] = mapped_column(Text)
|
||||||
mandate_type: Mapped[str] = mapped_column(String(64), nullable=False)
|
mandate_type: Mapped[str] = mapped_column(String(64), nullable=False) # techcomm, smith, custom
|
||||||
status: Mapped[str] = mapped_column(String(32), default="draft")
|
status: Mapped[str] = mapped_column(String(32), default="draft") # draft, candidacy, voting, active, reporting, completed, revoked
|
||||||
organization_id: Mapped[uuid.UUID | None] = mapped_column(ForeignKey("organizations.id"), nullable=True, index=True)
|
|
||||||
mandatee_id: Mapped[uuid.UUID | None] = mapped_column(ForeignKey("duniter_identities.id"))
|
mandatee_id: Mapped[uuid.UUID | None] = mapped_column(ForeignKey("duniter_identities.id"))
|
||||||
decision_id: Mapped[uuid.UUID | None] = mapped_column(ForeignKey("decisions.id"))
|
decision_id: Mapped[uuid.UUID | None] = mapped_column(ForeignKey("decisions.id"))
|
||||||
starts_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True))
|
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())
|
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())
|
updated_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now(), onupdate=func.now())
|
||||||
|
|
||||||
steps: Mapped[list["MandateStep"]] = relationship(
|
steps: Mapped[list["MandateStep"]] = relationship(back_populates="mandate", cascade="all, delete-orphan", order_by="MandateStep.step_order")
|
||||||
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
|
|
||||||
|
|
||||||
|
|
||||||
class MandateStep(Base):
|
class MandateStep(Base):
|
||||||
@@ -53,16 +31,12 @@ class MandateStep(Base):
|
|||||||
id: Mapped[uuid.UUID] = mapped_column(Uuid, primary_key=True, default=uuid.uuid4)
|
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)
|
mandate_id: Mapped[uuid.UUID] = mapped_column(ForeignKey("mandates.id"), nullable=False)
|
||||||
step_order: Mapped[int] = mapped_column(Integer, 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))
|
title: Mapped[str | None] = mapped_column(String(256))
|
||||||
description: Mapped[str | None] = mapped_column(Text)
|
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"))
|
vote_session_id: Mapped[uuid.UUID | None] = mapped_column(ForeignKey("vote_sessions.id"))
|
||||||
outcome: Mapped[str | None] = mapped_column(Text)
|
outcome: Mapped[str | None] = mapped_column(Text)
|
||||||
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now())
|
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now())
|
||||||
|
|
||||||
mandate: Mapped["Mandate"] = relationship(back_populates="steps")
|
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)
|
description: Mapped[str | None] = mapped_column(Text)
|
||||||
vote_type: Mapped[str] = mapped_column(String(32), nullable=False) # binary, nuanced
|
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)
|
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"
|
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)
|
is_meta_governed: Mapped[bool] = mapped_column(Boolean, default=False)
|
||||||
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now())
|
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now())
|
||||||
@@ -5,8 +5,7 @@ from __future__ import annotations
|
|||||||
import secrets
|
import secrets
|
||||||
from datetime import datetime, timedelta, timezone
|
from datetime import datetime, timedelta, timezone
|
||||||
|
|
||||||
from fastapi import APIRouter, Depends, HTTPException, Query, Response, status
|
from fastapi import APIRouter, Depends, HTTPException, Response, status
|
||||||
from sqlalchemy import or_, select
|
|
||||||
from sqlalchemy.ext.asyncio import AsyncSession
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
|
||||||
from app.config import settings
|
from app.config import settings
|
||||||
@@ -46,15 +45,15 @@ DEV_PROFILES = [
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
"address": "5FLSigC9HGRKVhB9FiEo4Y3koPsNmBmkP7j4bJa3zN7d8tY",
|
"address": "5FLSigC9HGRKVhB9FiEo4Y3koPsNmBmkP7j4bJa3zN7d8tY",
|
||||||
"display_name": "Charlie (Référent structure)",
|
"display_name": "Charlie (Comite Tech)",
|
||||||
"wot_status": "member",
|
"wot_status": "member",
|
||||||
"is_smith": True,
|
"is_smith": True,
|
||||||
"is_techcomm": True,
|
"is_techcomm": True,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"address": "5DAAnrj7VHTznn2AWBemMuyBwZWs6FNFjdyVXUeYum3PTXFy",
|
"address": "5DAAnrj7VHTznn2AWBemMuyBwZWs6FNFjdyVXUeYum3PTXFy",
|
||||||
"display_name": "Dave (Auteur)",
|
"display_name": "Dave (Observateur)",
|
||||||
"wot_status": "member",
|
"wot_status": "unknown",
|
||||||
"is_smith": False,
|
"is_smith": False,
|
||||||
"is_techcomm": False,
|
"is_techcomm": False,
|
||||||
},
|
},
|
||||||
@@ -133,44 +132,15 @@ async def verify_challenge(
|
|||||||
detail="Challenge invalide",
|
detail="Challenge invalide",
|
||||||
)
|
)
|
||||||
|
|
||||||
# 4. Verify signature
|
# 4. Verify Ed25519 signature
|
||||||
# TODO: trustWallet — déléguer la vérification au protocole trustWallet (librodrome)
|
# TODO: Implement actual Ed25519 verification using substrate-interface
|
||||||
# Quand trustWallet sera disponible : remplacer le bloc ci-dessous par une vérification
|
# For now we accept any signature to allow development/testing.
|
||||||
# du token signé fourni par trustWallet (JWT ou preuve Ed25519 via iframe postMessage).
|
# In production this MUST verify: verify(address_pubkey, challenge_bytes, signature_bytes)
|
||||||
# Le bypass DEMO_MODE sera alors supprimé.
|
#
|
||||||
_demo_addresses = {p["address"] for p in DEV_PROFILES}
|
# from substrateinterface import Keypair
|
||||||
is_demo_bypass = (settings.DEMO_MODE or settings.ENVIRONMENT == "development") and payload.address in _demo_addresses
|
# keypair = Keypair(ss58_address=payload.address)
|
||||||
|
# if not keypair.verify(payload.challenge.encode(), bytes.fromhex(payload.signature)):
|
||||||
if not is_demo_bypass:
|
# raise HTTPException(status_code=401, detail="Signature invalide")
|
||||||
# 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",
|
|
||||||
)
|
|
||||||
|
|
||||||
# 5. Consume the challenge
|
# 5. Consume the challenge
|
||||||
del _pending_challenges[payload.address]
|
del _pending_challenges[payload.address]
|
||||||
@@ -233,24 +203,3 @@ async def logout(
|
|||||||
for session in sessions:
|
for session in sessions:
|
||||||
await db.delete(session)
|
await db.delete(session)
|
||||||
await db.commit()
|
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,
|
DecisionUpdate,
|
||||||
)
|
)
|
||||||
from app.schemas.vote import VoteSessionOut
|
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.auth_service import get_current_identity
|
||||||
from app.services.decision_service import advance_decision, create_vote_session_for_step
|
from app.services.decision_service import advance_decision, create_vote_session_for_step
|
||||||
|
|
||||||
@@ -31,20 +30,13 @@ router = APIRouter()
|
|||||||
# ── Helpers ─────────────────────────────────────────────────────────────────
|
# ── Helpers ─────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
|
||||||
async def _get_decision(
|
async def _get_decision(db: AsyncSession, decision_id: uuid.UUID) -> Decision:
|
||||||
db: AsyncSession, decision_id: uuid.UUID, org_id: uuid.UUID | None = None
|
"""Fetch a decision by ID with its steps eagerly loaded, or raise 404."""
|
||||||
) -> Decision:
|
result = await db.execute(
|
||||||
"""Fetch a decision by ID within the active org scope, or raise 404."""
|
|
||||||
stmt = (
|
|
||||||
select(Decision)
|
select(Decision)
|
||||||
.options(selectinload(Decision.steps))
|
.options(selectinload(Decision.steps))
|
||||||
.where(Decision.id == decision_id)
|
.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()
|
decision = result.scalar_one_or_none()
|
||||||
if decision is None:
|
if decision is None:
|
||||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Decision introuvable")
|
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])
|
@router.get("/", response_model=list[DecisionOut])
|
||||||
async def list_decisions(
|
async def list_decisions(
|
||||||
db: AsyncSession = Depends(get_db),
|
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"),
|
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"),
|
status_filter: str | None = Query(default=None, alias="status", description="Filtrer par statut"),
|
||||||
skip: int = Query(default=0, ge=0),
|
skip: int = Query(default=0, ge=0),
|
||||||
@@ -66,10 +57,6 @@ async def list_decisions(
|
|||||||
"""List all decisions with optional filters."""
|
"""List all decisions with optional filters."""
|
||||||
stmt = select(Decision).options(selectinload(Decision.steps))
|
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:
|
if decision_type is not None:
|
||||||
stmt = stmt.where(Decision.decision_type == decision_type)
|
stmt = stmt.where(Decision.decision_type == decision_type)
|
||||||
if status_filter is not None:
|
if status_filter is not None:
|
||||||
@@ -87,20 +74,18 @@ async def create_decision(
|
|||||||
payload: DecisionCreate,
|
payload: DecisionCreate,
|
||||||
db: AsyncSession = Depends(get_db),
|
db: AsyncSession = Depends(get_db),
|
||||||
identity: DuniterIdentity = Depends(get_current_identity),
|
identity: DuniterIdentity = Depends(get_current_identity),
|
||||||
org_id: uuid.UUID | None = Depends(get_active_org_id),
|
|
||||||
) -> DecisionOut:
|
) -> DecisionOut:
|
||||||
"""Create a new decision process."""
|
"""Create a new decision process."""
|
||||||
decision = Decision(
|
decision = Decision(
|
||||||
**payload.model_dump(),
|
**payload.model_dump(),
|
||||||
created_by_id=identity.id,
|
created_by_id=identity.id,
|
||||||
organization_id=org_id,
|
|
||||||
)
|
)
|
||||||
db.add(decision)
|
db.add(decision)
|
||||||
await db.commit()
|
await db.commit()
|
||||||
await db.refresh(decision)
|
await db.refresh(decision)
|
||||||
|
|
||||||
# Reload with steps (empty at creation)
|
# 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)
|
return DecisionOut.model_validate(decision)
|
||||||
|
|
||||||
|
|
||||||
@@ -108,10 +93,9 @@ async def create_decision(
|
|||||||
async def get_decision(
|
async def get_decision(
|
||||||
id: uuid.UUID,
|
id: uuid.UUID,
|
||||||
db: AsyncSession = Depends(get_db),
|
db: AsyncSession = Depends(get_db),
|
||||||
org_id: uuid.UUID | None = Depends(get_active_org_id),
|
|
||||||
) -> DecisionOut:
|
) -> DecisionOut:
|
||||||
"""Get a single decision with all its steps."""
|
"""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)
|
return DecisionOut.model_validate(decision)
|
||||||
|
|
||||||
|
|
||||||
@@ -121,10 +105,9 @@ async def update_decision(
|
|||||||
payload: DecisionUpdate,
|
payload: DecisionUpdate,
|
||||||
db: AsyncSession = Depends(get_db),
|
db: AsyncSession = Depends(get_db),
|
||||||
identity: DuniterIdentity = Depends(get_current_identity),
|
identity: DuniterIdentity = Depends(get_current_identity),
|
||||||
org_id: uuid.UUID | None = Depends(get_active_org_id),
|
|
||||||
) -> DecisionOut:
|
) -> DecisionOut:
|
||||||
"""Update a decision's metadata (title, description, status, protocol)."""
|
"""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)
|
update_data = payload.model_dump(exclude_unset=True)
|
||||||
for field, value in update_data.items():
|
for field, value in update_data.items():
|
||||||
@@ -134,7 +117,7 @@ async def update_decision(
|
|||||||
await db.refresh(decision)
|
await db.refresh(decision)
|
||||||
|
|
||||||
# Reload with steps
|
# Reload with steps
|
||||||
decision = await _get_decision(db, decision.id, org_id)
|
decision = await _get_decision(db, decision.id)
|
||||||
return DecisionOut.model_validate(decision)
|
return DecisionOut.model_validate(decision)
|
||||||
|
|
||||||
|
|
||||||
@@ -147,11 +130,10 @@ async def add_step(
|
|||||||
payload: DecisionStepCreate,
|
payload: DecisionStepCreate,
|
||||||
db: AsyncSession = Depends(get_db),
|
db: AsyncSession = Depends(get_db),
|
||||||
identity: DuniterIdentity = Depends(get_current_identity),
|
identity: DuniterIdentity = Depends(get_current_identity),
|
||||||
org_id: uuid.UUID | None = Depends(get_active_org_id),
|
|
||||||
) -> DecisionStepOut:
|
) -> DecisionStepOut:
|
||||||
"""Add a step to a decision process."""
|
"""Add a step to a decision process."""
|
||||||
# Verify decision exists
|
# Verify decision exists
|
||||||
decision = await _get_decision(db, id, org_id)
|
decision = await _get_decision(db, id)
|
||||||
|
|
||||||
step = DecisionStep(
|
step = DecisionStep(
|
||||||
decision_id=decision.id,
|
decision_id=decision.id,
|
||||||
@@ -172,7 +154,6 @@ async def advance_decision_endpoint(
|
|||||||
id: uuid.UUID,
|
id: uuid.UUID,
|
||||||
db: AsyncSession = Depends(get_db),
|
db: AsyncSession = Depends(get_db),
|
||||||
identity: DuniterIdentity = Depends(get_current_identity),
|
identity: DuniterIdentity = Depends(get_current_identity),
|
||||||
org_id: uuid.UUID | None = Depends(get_active_org_id),
|
|
||||||
) -> DecisionAdvanceOut:
|
) -> DecisionAdvanceOut:
|
||||||
"""Advance a decision to its next step or status."""
|
"""Advance a decision to its next step or status."""
|
||||||
try:
|
try:
|
||||||
@@ -181,7 +162,7 @@ async def advance_decision_endpoint(
|
|||||||
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=str(exc))
|
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=str(exc))
|
||||||
|
|
||||||
# Reload with steps for complete output
|
# 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 = DecisionOut.model_validate(decision).model_dump()
|
||||||
data["message"] = f"Decision avancee au statut : {decision.status}"
|
data["message"] = f"Decision avancee au statut : {decision.status}"
|
||||||
return DecisionAdvanceOut(**data)
|
return DecisionAdvanceOut(**data)
|
||||||
@@ -197,7 +178,6 @@ async def create_vote_session_for_step_endpoint(
|
|||||||
step_id: uuid.UUID,
|
step_id: uuid.UUID,
|
||||||
db: AsyncSession = Depends(get_db),
|
db: AsyncSession = Depends(get_db),
|
||||||
identity: DuniterIdentity = Depends(get_current_identity),
|
identity: DuniterIdentity = Depends(get_current_identity),
|
||||||
org_id: uuid.UUID | None = Depends(get_active_org_id),
|
|
||||||
) -> VoteSessionOut:
|
) -> VoteSessionOut:
|
||||||
"""Create a vote session linked to a decision step."""
|
"""Create a vote session linked to a decision step."""
|
||||||
try:
|
try:
|
||||||
@@ -213,10 +193,9 @@ async def delete_decision(
|
|||||||
id: uuid.UUID,
|
id: uuid.UUID,
|
||||||
db: AsyncSession = Depends(get_db),
|
db: AsyncSession = Depends(get_db),
|
||||||
identity: DuniterIdentity = Depends(get_current_identity),
|
identity: DuniterIdentity = Depends(get_current_identity),
|
||||||
org_id: uuid.UUID | None = Depends(get_active_org_id),
|
|
||||||
) -> None:
|
) -> None:
|
||||||
"""Delete a decision (only if in draft status)."""
|
"""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":
|
if decision.status != "draft":
|
||||||
raise HTTPException(
|
raise HTTPException(
|
||||||
@@ -25,7 +25,6 @@ from app.schemas.document import (
|
|||||||
ItemVersionCreate,
|
ItemVersionCreate,
|
||||||
ItemVersionOut,
|
ItemVersionOut,
|
||||||
)
|
)
|
||||||
from app.dependencies.org import get_active_org_id
|
|
||||||
from app.services import document_service
|
from app.services import document_service
|
||||||
from app.services.auth_service import get_current_identity
|
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])
|
@router.get("/", response_model=list[DocumentOut])
|
||||||
async def list_documents(
|
async def list_documents(
|
||||||
db: AsyncSession = Depends(get_db),
|
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"),
|
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"),
|
status_filter: str | None = Query(default=None, alias="status", description="Filtrer par statut"),
|
||||||
skip: int = Query(default=0, ge=0),
|
skip: int = Query(default=0, ge=0),
|
||||||
@@ -75,10 +73,6 @@ async def list_documents(
|
|||||||
"""List all reference documents, with optional filters."""
|
"""List all reference documents, with optional filters."""
|
||||||
stmt = select(Document)
|
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:
|
if doc_type is not None:
|
||||||
stmt = stmt.where(Document.doc_type == doc_type)
|
stmt = stmt.where(Document.doc_type == doc_type)
|
||||||
if status_filter is not None:
|
if status_filter is not None:
|
||||||
@@ -107,7 +101,6 @@ async def create_document(
|
|||||||
payload: DocumentCreate,
|
payload: DocumentCreate,
|
||||||
db: AsyncSession = Depends(get_db),
|
db: AsyncSession = Depends(get_db),
|
||||||
identity: DuniterIdentity = Depends(get_current_identity),
|
identity: DuniterIdentity = Depends(get_current_identity),
|
||||||
org_id: uuid.UUID | None = Depends(get_active_org_id),
|
|
||||||
) -> DocumentOut:
|
) -> DocumentOut:
|
||||||
"""Create a new reference document."""
|
"""Create a new reference document."""
|
||||||
# Check slug uniqueness
|
# Check slug uniqueness
|
||||||
@@ -118,7 +111,7 @@ async def create_document(
|
|||||||
detail="Un document avec ce slug existe deja",
|
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)
|
db.add(doc)
|
||||||
await db.commit()
|
await db.commit()
|
||||||
await db.refresh(doc)
|
await db.refresh(doc)
|
||||||
@@ -22,7 +22,6 @@ from app.schemas.mandate import (
|
|||||||
MandateUpdate,
|
MandateUpdate,
|
||||||
)
|
)
|
||||||
from app.schemas.vote import VoteSessionOut
|
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.auth_service import get_current_identity
|
||||||
from app.services.mandate_service import (
|
from app.services.mandate_service import (
|
||||||
advance_mandate,
|
advance_mandate,
|
||||||
@@ -37,58 +36,33 @@ router = APIRouter()
|
|||||||
# ── Helpers ─────────────────────────────────────────────────────────────────
|
# ── Helpers ─────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
|
||||||
async def _get_mandate(
|
async def _get_mandate(db: AsyncSession, mandate_id: uuid.UUID) -> Mandate:
|
||||||
db: AsyncSession, mandate_id: uuid.UUID, org_id: uuid.UUID | None = None
|
"""Fetch a mandate by ID with its steps eagerly loaded, or raise 404."""
|
||||||
) -> Mandate:
|
result = await db.execute(
|
||||||
stmt = (
|
|
||||||
select(Mandate)
|
select(Mandate)
|
||||||
.options(
|
.options(selectinload(Mandate.steps))
|
||||||
selectinload(Mandate.steps),
|
|
||||||
selectinload(Mandate.origin_identity),
|
|
||||||
selectinload(Mandate.mandatee),
|
|
||||||
)
|
|
||||||
.where(Mandate.id == mandate_id)
|
.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()
|
mandate = result.scalar_one_or_none()
|
||||||
if mandate is None:
|
if mandate is None:
|
||||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Mandat introuvable")
|
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Mandat introuvable")
|
||||||
return mandate
|
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 ──────────────────────────────────────────────────────────
|
# ── Mandate routes ──────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
|
||||||
@router.get("/", response_model=list[MandateOut])
|
@router.get("/", response_model=list[MandateOut])
|
||||||
async def list_mandates(
|
async def list_mandates(
|
||||||
db: AsyncSession = Depends(get_db),
|
db: AsyncSession = Depends(get_db),
|
||||||
org_id: uuid.UUID | None = Depends(get_active_org_id),
|
mandate_type: str | None = Query(default=None, description="Filtrer par type de mandat"),
|
||||||
mandate_type: str | None = Query(default=None),
|
status_filter: str | None = Query(default=None, alias="status", description="Filtrer par statut"),
|
||||||
status_filter: str | None = Query(default=None, alias="status"),
|
|
||||||
skip: int = Query(default=0, ge=0),
|
skip: int = Query(default=0, ge=0),
|
||||||
limit: int = Query(default=50, ge=1, le=200),
|
limit: int = Query(default=50, ge=1, le=200),
|
||||||
) -> list[MandateOut]:
|
) -> list[MandateOut]:
|
||||||
stmt = select(Mandate).options(
|
"""List all mandates with optional filters."""
|
||||||
selectinload(Mandate.steps),
|
stmt = select(Mandate).options(selectinload(Mandate.steps))
|
||||||
selectinload(Mandate.origin_identity),
|
|
||||||
selectinload(Mandate.mandatee),
|
|
||||||
)
|
|
||||||
|
|
||||||
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:
|
if mandate_type is not None:
|
||||||
stmt = stmt.where(Mandate.mandate_type == mandate_type)
|
stmt = stmt.where(Mandate.mandate_type == mandate_type)
|
||||||
if status_filter is not None:
|
if status_filter is not None:
|
||||||
@@ -98,7 +72,7 @@ async def list_mandates(
|
|||||||
result = await db.execute(stmt)
|
result = await db.execute(stmt)
|
||||||
mandates = result.scalars().unique().all()
|
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)
|
@router.post("/", response_model=MandateOut, status_code=status.HTTP_201_CREATED)
|
||||||
@@ -106,32 +80,26 @@ async def create_mandate(
|
|||||||
payload: MandateCreate,
|
payload: MandateCreate,
|
||||||
db: AsyncSession = Depends(get_db),
|
db: AsyncSession = Depends(get_db),
|
||||||
identity: DuniterIdentity = Depends(get_current_identity),
|
identity: DuniterIdentity = Depends(get_current_identity),
|
||||||
org_id: uuid.UUID | None = Depends(get_active_org_id),
|
|
||||||
) -> MandateOut:
|
) -> MandateOut:
|
||||||
data = payload.model_dump()
|
"""Create a new mandate."""
|
||||||
nomination_mode = data.pop("nomination_mode", "postpone")
|
mandate = Mandate(**payload.model_dump())
|
||||||
|
|
||||||
mandate = Mandate(**data, organization_id=org_id)
|
|
||||||
|
|
||||||
if nomination_mode == "auto":
|
|
||||||
mandate.mandatee_id = identity.id
|
|
||||||
|
|
||||||
db.add(mandate)
|
db.add(mandate)
|
||||||
await db.commit()
|
await db.commit()
|
||||||
await db.refresh(mandate)
|
await db.refresh(mandate)
|
||||||
|
|
||||||
mandate = await _get_mandate(db, mandate.id, org_id)
|
# Reload with steps (empty at creation)
|
||||||
return _mandate_out(mandate)
|
mandate = await _get_mandate(db, mandate.id)
|
||||||
|
return MandateOut.model_validate(mandate)
|
||||||
|
|
||||||
|
|
||||||
@router.get("/{id}", response_model=MandateOut)
|
@router.get("/{id}", response_model=MandateOut)
|
||||||
async def get_mandate(
|
async def get_mandate(
|
||||||
id: uuid.UUID,
|
id: uuid.UUID,
|
||||||
db: AsyncSession = Depends(get_db),
|
db: AsyncSession = Depends(get_db),
|
||||||
org_id: uuid.UUID | None = Depends(get_active_org_id),
|
|
||||||
) -> MandateOut:
|
) -> MandateOut:
|
||||||
mandate = await _get_mandate(db, id, org_id)
|
"""Get a single mandate with all its steps."""
|
||||||
return _mandate_out(mandate)
|
mandate = await _get_mandate(db, id)
|
||||||
|
return MandateOut.model_validate(mandate)
|
||||||
|
|
||||||
|
|
||||||
@router.put("/{id}", response_model=MandateOut)
|
@router.put("/{id}", response_model=MandateOut)
|
||||||
@@ -140,16 +108,20 @@ async def update_mandate(
|
|||||||
payload: MandateUpdate,
|
payload: MandateUpdate,
|
||||||
db: AsyncSession = Depends(get_db),
|
db: AsyncSession = Depends(get_db),
|
||||||
identity: DuniterIdentity = Depends(get_current_identity),
|
identity: DuniterIdentity = Depends(get_current_identity),
|
||||||
org_id: uuid.UUID | None = Depends(get_active_org_id),
|
|
||||||
) -> MandateOut:
|
) -> 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)
|
setattr(mandate, field, value)
|
||||||
|
|
||||||
await db.commit()
|
await db.commit()
|
||||||
mandate = await _get_mandate(db, mandate.id, org_id)
|
await db.refresh(mandate)
|
||||||
return _mandate_out(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)
|
@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,
|
id: uuid.UUID,
|
||||||
db: AsyncSession = Depends(get_db),
|
db: AsyncSession = Depends(get_db),
|
||||||
identity: DuniterIdentity = Depends(get_current_identity),
|
identity: DuniterIdentity = Depends(get_current_identity),
|
||||||
org_id: uuid.UUID | None = Depends(get_active_org_id),
|
|
||||||
) -> None:
|
) -> 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":
|
if mandate.status != "draft":
|
||||||
raise HTTPException(
|
raise HTTPException(
|
||||||
@@ -180,11 +152,14 @@ async def add_step(
|
|||||||
payload: MandateStepCreate,
|
payload: MandateStepCreate,
|
||||||
db: AsyncSession = Depends(get_db),
|
db: AsyncSession = Depends(get_db),
|
||||||
identity: DuniterIdentity = Depends(get_current_identity),
|
identity: DuniterIdentity = Depends(get_current_identity),
|
||||||
org_id: uuid.UUID | None = Depends(get_active_org_id),
|
|
||||||
) -> MandateStepOut:
|
) -> 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)
|
db.add(step)
|
||||||
await db.commit()
|
await db.commit()
|
||||||
await db.refresh(step)
|
await db.refresh(step)
|
||||||
@@ -196,9 +171,9 @@ async def add_step(
|
|||||||
async def list_steps(
|
async def list_steps(
|
||||||
id: uuid.UUID,
|
id: uuid.UUID,
|
||||||
db: AsyncSession = Depends(get_db),
|
db: AsyncSession = Depends(get_db),
|
||||||
org_id: uuid.UUID | None = Depends(get_active_org_id),
|
|
||||||
) -> list[MandateStepOut]:
|
) -> 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]
|
return [MandateStepOut.model_validate(s) for s in mandate.steps]
|
||||||
|
|
||||||
|
|
||||||
@@ -210,19 +185,18 @@ async def advance_mandate_endpoint(
|
|||||||
id: uuid.UUID,
|
id: uuid.UUID,
|
||||||
db: AsyncSession = Depends(get_db),
|
db: AsyncSession = Depends(get_db),
|
||||||
identity: DuniterIdentity = Depends(get_current_identity),
|
identity: DuniterIdentity = Depends(get_current_identity),
|
||||||
org_id: uuid.UUID | None = Depends(get_active_org_id),
|
|
||||||
) -> MandateAdvanceOut:
|
) -> MandateAdvanceOut:
|
||||||
|
"""Advance a mandate to its next step or status."""
|
||||||
try:
|
try:
|
||||||
mandate = await advance_mandate(id, db)
|
mandate = await advance_mandate(id, db)
|
||||||
except ValueError as exc:
|
except ValueError as exc:
|
||||||
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=str(exc))
|
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=str(exc))
|
||||||
|
|
||||||
mandate = await _get_mandate(db, mandate.id, org_id)
|
# Reload with steps for complete output
|
||||||
out = _mandate_out(mandate)
|
mandate = await _get_mandate(db, mandate.id)
|
||||||
return MandateAdvanceOut(
|
data = MandateOut.model_validate(mandate).model_dump()
|
||||||
**out.model_dump(),
|
data["message"] = f"Mandat avance au statut : {mandate.status}"
|
||||||
message=f"Mandat avance au statut : {mandate.status}",
|
return MandateAdvanceOut(**data)
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
@router.post("/{id}/assign", response_model=MandateOut)
|
@router.post("/{id}/assign", response_model=MandateOut)
|
||||||
@@ -231,15 +205,16 @@ async def assign_mandatee_endpoint(
|
|||||||
payload: MandateAssignRequest,
|
payload: MandateAssignRequest,
|
||||||
db: AsyncSession = Depends(get_db),
|
db: AsyncSession = Depends(get_db),
|
||||||
identity: DuniterIdentity = Depends(get_current_identity),
|
identity: DuniterIdentity = Depends(get_current_identity),
|
||||||
org_id: uuid.UUID | None = Depends(get_active_org_id),
|
|
||||||
) -> MandateOut:
|
) -> MandateOut:
|
||||||
|
"""Assign a mandatee to a mandate."""
|
||||||
try:
|
try:
|
||||||
mandate = await assign_mandatee(id, payload.mandatee_id, db)
|
mandate = await assign_mandatee(id, payload.mandatee_id, db)
|
||||||
except ValueError as exc:
|
except ValueError as exc:
|
||||||
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=str(exc))
|
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=str(exc))
|
||||||
|
|
||||||
mandate = await _get_mandate(db, mandate.id, org_id)
|
# Reload with steps
|
||||||
return _mandate_out(mandate)
|
mandate = await _get_mandate(db, mandate.id)
|
||||||
|
return MandateOut.model_validate(mandate)
|
||||||
|
|
||||||
|
|
||||||
@router.post("/{id}/revoke", response_model=MandateOut)
|
@router.post("/{id}/revoke", response_model=MandateOut)
|
||||||
@@ -247,15 +222,16 @@ async def revoke_mandate_endpoint(
|
|||||||
id: uuid.UUID,
|
id: uuid.UUID,
|
||||||
db: AsyncSession = Depends(get_db),
|
db: AsyncSession = Depends(get_db),
|
||||||
identity: DuniterIdentity = Depends(get_current_identity),
|
identity: DuniterIdentity = Depends(get_current_identity),
|
||||||
org_id: uuid.UUID | None = Depends(get_active_org_id),
|
|
||||||
) -> MandateOut:
|
) -> MandateOut:
|
||||||
|
"""Revoke an active mandate."""
|
||||||
try:
|
try:
|
||||||
mandate = await revoke_mandate(id, db)
|
mandate = await revoke_mandate(id, db)
|
||||||
except ValueError as exc:
|
except ValueError as exc:
|
||||||
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=str(exc))
|
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=str(exc))
|
||||||
|
|
||||||
mandate = await _get_mandate(db, mandate.id, org_id)
|
# Reload with steps
|
||||||
return _mandate_out(mandate)
|
mandate = await _get_mandate(db, mandate.id)
|
||||||
|
return MandateOut.model_validate(mandate)
|
||||||
|
|
||||||
|
|
||||||
@router.post(
|
@router.post(
|
||||||
@@ -268,8 +244,8 @@ async def create_vote_session_for_step_endpoint(
|
|||||||
step_id: uuid.UUID,
|
step_id: uuid.UUID,
|
||||||
db: AsyncSession = Depends(get_db),
|
db: AsyncSession = Depends(get_db),
|
||||||
identity: DuniterIdentity = Depends(get_current_identity),
|
identity: DuniterIdentity = Depends(get_current_identity),
|
||||||
org_id: uuid.UUID | None = Depends(get_active_org_id),
|
|
||||||
) -> VoteSessionOut:
|
) -> VoteSessionOut:
|
||||||
|
"""Create a vote session linked to a mandate step."""
|
||||||
try:
|
try:
|
||||||
session = await create_vote_session_for_step(id, step_id, db)
|
session = await create_vote_session_for_step(id, step_id, db)
|
||||||
except ValueError as exc:
|
except ValueError as exc:
|
||||||
@@ -25,7 +25,6 @@ from app.schemas.protocol import (
|
|||||||
VotingProtocolOut,
|
VotingProtocolOut,
|
||||||
VotingProtocolUpdate,
|
VotingProtocolUpdate,
|
||||||
)
|
)
|
||||||
from app.dependencies.org import get_active_org_id
|
|
||||||
from app.services.auth_service import get_current_identity
|
from app.services.auth_service import get_current_identity
|
||||||
|
|
||||||
router = APIRouter()
|
router = APIRouter()
|
||||||
@@ -34,20 +33,13 @@ router = APIRouter()
|
|||||||
# ── Helpers ─────────────────────────────────────────────────────────────────
|
# ── Helpers ─────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
|
||||||
async def _get_protocol(
|
async def _get_protocol(db: AsyncSession, protocol_id: uuid.UUID) -> VotingProtocol:
|
||||||
db: AsyncSession, protocol_id: uuid.UUID, org_id: uuid.UUID | None = None
|
"""Fetch a voting protocol by ID with its formula config, or raise 404."""
|
||||||
) -> VotingProtocol:
|
result = await db.execute(
|
||||||
"""Fetch a voting protocol by ID within the active org scope, or raise 404."""
|
|
||||||
stmt = (
|
|
||||||
select(VotingProtocol)
|
select(VotingProtocol)
|
||||||
.options(selectinload(VotingProtocol.formula_config))
|
.options(selectinload(VotingProtocol.formula_config))
|
||||||
.where(VotingProtocol.id == protocol_id)
|
.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()
|
protocol = result.scalar_one_or_none()
|
||||||
if protocol is None:
|
if protocol is None:
|
||||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Protocole introuvable")
|
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])
|
@router.get("/", response_model=list[VotingProtocolOut])
|
||||||
async def list_protocols(
|
async def list_protocols(
|
||||||
db: AsyncSession = Depends(get_db),
|
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)"),
|
vote_type: str | None = Query(default=None, description="Filtrer par type de vote (binary, nuanced)"),
|
||||||
skip: int = Query(default=0, ge=0),
|
skip: int = Query(default=0, ge=0),
|
||||||
limit: int = Query(default=50, ge=1, le=200),
|
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."""
|
"""List all voting protocols with their formula configurations."""
|
||||||
stmt = select(VotingProtocol).options(selectinload(VotingProtocol.formula_config))
|
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:
|
if vote_type is not None:
|
||||||
stmt = stmt.where(VotingProtocol.vote_type == vote_type)
|
stmt = stmt.where(VotingProtocol.vote_type == vote_type)
|
||||||
|
|
||||||
@@ -98,7 +85,6 @@ async def create_protocol(
|
|||||||
payload: VotingProtocolCreate,
|
payload: VotingProtocolCreate,
|
||||||
db: AsyncSession = Depends(get_db),
|
db: AsyncSession = Depends(get_db),
|
||||||
identity: DuniterIdentity = Depends(get_current_identity),
|
identity: DuniterIdentity = Depends(get_current_identity),
|
||||||
org_id: uuid.UUID | None = Depends(get_active_org_id),
|
|
||||||
) -> VotingProtocolOut:
|
) -> VotingProtocolOut:
|
||||||
"""Create a new voting protocol.
|
"""Create a new voting protocol.
|
||||||
|
|
||||||
@@ -114,13 +100,13 @@ async def create_protocol(
|
|||||||
detail="Configuration de formule introuvable",
|
detail="Configuration de formule introuvable",
|
||||||
)
|
)
|
||||||
|
|
||||||
protocol = VotingProtocol(**payload.model_dump(), organization_id=org_id)
|
protocol = VotingProtocol(**payload.model_dump())
|
||||||
db.add(protocol)
|
db.add(protocol)
|
||||||
await db.commit()
|
await db.commit()
|
||||||
await db.refresh(protocol)
|
await db.refresh(protocol)
|
||||||
|
|
||||||
# Reload with formula config
|
# 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)
|
return VotingProtocolOut.model_validate(protocol)
|
||||||
|
|
||||||
|
|
||||||
@@ -128,10 +114,9 @@ async def create_protocol(
|
|||||||
async def get_protocol(
|
async def get_protocol(
|
||||||
id: uuid.UUID,
|
id: uuid.UUID,
|
||||||
db: AsyncSession = Depends(get_db),
|
db: AsyncSession = Depends(get_db),
|
||||||
org_id: uuid.UUID | None = Depends(get_active_org_id),
|
|
||||||
) -> VotingProtocolOut:
|
) -> VotingProtocolOut:
|
||||||
"""Get a single voting protocol with its formula configuration."""
|
"""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)
|
return VotingProtocolOut.model_validate(protocol)
|
||||||
|
|
||||||
|
|
||||||
@@ -141,13 +126,12 @@ async def update_protocol(
|
|||||||
payload: VotingProtocolUpdate,
|
payload: VotingProtocolUpdate,
|
||||||
db: AsyncSession = Depends(get_db),
|
db: AsyncSession = Depends(get_db),
|
||||||
identity: DuniterIdentity = Depends(get_current_identity),
|
identity: DuniterIdentity = Depends(get_current_identity),
|
||||||
org_id: uuid.UUID | None = Depends(get_active_org_id),
|
|
||||||
) -> VotingProtocolOut:
|
) -> VotingProtocolOut:
|
||||||
"""Update a voting protocol (meta-governance).
|
"""Update a voting protocol (meta-governance).
|
||||||
|
|
||||||
Only provided fields will be updated. Requires authentication.
|
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)
|
update_data = payload.model_dump(exclude_unset=True)
|
||||||
for field, value in update_data.items():
|
for field, value in update_data.items():
|
||||||
@@ -156,7 +140,7 @@ async def update_protocol(
|
|||||||
await db.commit()
|
await db.commit()
|
||||||
|
|
||||||
# Reload with formula config
|
# 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)
|
return VotingProtocolOut.model_validate(protocol)
|
||||||
|
|
||||||
|
|
||||||
@@ -25,7 +25,6 @@ from app.schemas.vote import (
|
|||||||
VoteSessionListOut,
|
VoteSessionListOut,
|
||||||
VoteSessionOut,
|
VoteSessionOut,
|
||||||
)
|
)
|
||||||
from app.dependencies.org import get_active_org_id
|
|
||||||
from app.services.auth_service import get_current_identity
|
from app.services.auth_service import get_current_identity
|
||||||
from app.services.vote_service import (
|
from app.services.vote_service import (
|
||||||
close_session as svc_close_session,
|
close_session as svc_close_session,
|
||||||
@@ -171,22 +170,14 @@ def _compute_result(
|
|||||||
@router.get("/sessions", response_model=list[VoteSessionListOut])
|
@router.get("/sessions", response_model=list[VoteSessionListOut])
|
||||||
async def list_vote_sessions(
|
async def list_vote_sessions(
|
||||||
db: AsyncSession = Depends(get_db),
|
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)"),
|
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"),
|
decision_id: uuid.UUID | None = Query(default=None, description="Filtrer par decision_id"),
|
||||||
skip: int = Query(default=0, ge=0),
|
skip: int = Query(default=0, ge=0),
|
||||||
limit: int = Query(default=50, ge=1, le=200),
|
limit: int = Query(default=50, ge=1, le=200),
|
||||||
) -> list[VoteSessionListOut]:
|
) -> list[VoteSessionListOut]:
|
||||||
"""List all vote sessions with optional filters by status and decision_id."""
|
"""List all vote sessions with optional filters by status and decision_id."""
|
||||||
stmt = (
|
stmt = select(VoteSession)
|
||||||
select(VoteSession)
|
|
||||||
.join(VotingProtocol, VoteSession.voting_protocol_id == VotingProtocol.id)
|
|
||||||
)
|
|
||||||
|
|
||||||
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:
|
if session_status is not None:
|
||||||
stmt = stmt.where(VoteSession.status == session_status)
|
stmt = stmt.where(VoteSession.status == session_status)
|
||||||
if decision_id is not None:
|
if decision_id is not None:
|
||||||
@@ -10,13 +10,21 @@ from pydantic import BaseModel, ConfigDict, Field
|
|||||||
|
|
||||||
|
|
||||||
class MandateStepCreate(BaseModel):
|
class MandateStepCreate(BaseModel):
|
||||||
|
"""Payload for creating a step within a mandate process."""
|
||||||
|
|
||||||
step_order: int = Field(..., ge=0)
|
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)
|
title: str | None = Field(default=None, max_length=256)
|
||||||
description: str | None = None
|
description: str | None = None
|
||||||
|
|
||||||
|
|
||||||
class MandateStepOut(BaseModel):
|
class MandateStepOut(BaseModel):
|
||||||
|
"""Full mandate step representation."""
|
||||||
|
|
||||||
model_config = ConfigDict(from_attributes=True)
|
model_config = ConfigDict(from_attributes=True)
|
||||||
|
|
||||||
id: UUID
|
id: UUID
|
||||||
@@ -35,45 +43,40 @@ class MandateStepOut(BaseModel):
|
|||||||
|
|
||||||
|
|
||||||
class MandateCreate(BaseModel):
|
class MandateCreate(BaseModel):
|
||||||
|
"""Payload for creating a new mandate."""
|
||||||
|
|
||||||
title: str = Field(..., min_length=1, max_length=256)
|
title: str = Field(..., min_length=1, max_length=256)
|
||||||
origin_id: UUID | None = None
|
|
||||||
description: str | None = None
|
description: str | None = None
|
||||||
mandate_type: str = Field(..., max_length=64)
|
mandate_type: str = Field(..., max_length=64, description="techcomm, smith, custom")
|
||||||
nomination_mode: str = Field(
|
|
||||||
default="postpone",
|
|
||||||
description="auto (auto-assign author), collective, postpone",
|
|
||||||
)
|
|
||||||
decision_id: UUID | None = None
|
decision_id: UUID | None = None
|
||||||
starts_at: datetime | None = None
|
|
||||||
ends_at: datetime | None = None
|
|
||||||
|
|
||||||
|
|
||||||
class MandateUpdate(BaseModel):
|
class MandateUpdate(BaseModel):
|
||||||
|
"""Partial update for a mandate."""
|
||||||
|
|
||||||
title: str | None = Field(default=None, max_length=256)
|
title: str | None = Field(default=None, max_length=256)
|
||||||
origin_id: UUID | None = None
|
|
||||||
description: str | None = None
|
description: str | None = None
|
||||||
mandate_type: str | None = Field(default=None, max_length=64)
|
mandate_type: str | None = Field(default=None, max_length=64)
|
||||||
decision_id: UUID | None = None
|
decision_id: UUID | None = None
|
||||||
starts_at: datetime | None = None
|
|
||||||
ends_at: datetime | None = None
|
|
||||||
|
|
||||||
|
|
||||||
class MandateAssignRequest(BaseModel):
|
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):
|
class MandateOut(BaseModel):
|
||||||
|
"""Full mandate representation returned by the API."""
|
||||||
|
|
||||||
model_config = ConfigDict(from_attributes=True)
|
model_config = ConfigDict(from_attributes=True)
|
||||||
|
|
||||||
id: UUID
|
id: UUID
|
||||||
title: str
|
title: str
|
||||||
origin_id: UUID | None = None
|
|
||||||
origin_display_name: str | None = None
|
|
||||||
description: str | None = None
|
description: str | None = None
|
||||||
mandate_type: str
|
mandate_type: str
|
||||||
status: str
|
status: str
|
||||||
mandatee_id: UUID | None = None
|
mandatee_id: UUID | None = None
|
||||||
mandatee_display_name: str | None = None
|
|
||||||
decision_id: UUID | None = None
|
decision_id: UUID | None = None
|
||||||
starts_at: datetime | None = None
|
starts_at: datetime | None = None
|
||||||
ends_at: datetime | None = None
|
ends_at: datetime | None = None
|
||||||
@@ -82,5 +85,21 @@ class MandateOut(BaseModel):
|
|||||||
steps: list[MandateStepOut] = Field(default_factory=list)
|
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")
|
message: str = Field(..., description="Message decrivant l'avancement effectue")
|
||||||
@@ -32,8 +32,6 @@ from app.models.protocol import FormulaConfig, VotingProtocol
|
|||||||
from app.models.document import Document, DocumentItem
|
from app.models.document import Document, DocumentItem
|
||||||
from app.models.decision import Decision, DecisionStep
|
from app.models.decision import Decision, DecisionStep
|
||||||
from app.models.mandate import Mandate, MandateStep
|
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.user import DuniterIdentity
|
||||||
from app.models.vote import VoteSession, Vote
|
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(
|
async def seed_voting_protocols(
|
||||||
session: AsyncSession,
|
session: AsyncSession,
|
||||||
formulas: dict[str, FormulaConfig],
|
formulas: dict[str, FormulaConfig],
|
||||||
org_id: uuid.UUID | None = None,
|
|
||||||
) -> dict[str, VotingProtocol]:
|
) -> dict[str, VotingProtocol]:
|
||||||
protocols: dict[str, dict] = {
|
protocols: dict[str, dict] = {
|
||||||
"Vote WoT standard": {
|
"Vote WoT standard": {
|
||||||
@@ -209,7 +206,6 @@ async def seed_voting_protocols(
|
|||||||
instance, created = await get_or_create(
|
instance, created = await get_or_create(
|
||||||
session, VotingProtocol, "name", name, **params,
|
session, VotingProtocol, "name", name, **params,
|
||||||
)
|
)
|
||||||
instance.organization_id = org_id
|
|
||||||
status = "created" if created else "exists"
|
status = "created" if created else "exists"
|
||||||
print(f" VotingProtocol '{name}': {status}")
|
print(f" VotingProtocol '{name}': {status}")
|
||||||
result[name] = instance
|
result[name] = instance
|
||||||
@@ -833,7 +829,6 @@ ENGAGEMENT_CERTIFICATION_ITEMS: list[dict] = [
|
|||||||
async def seed_document_engagement_certification(
|
async def seed_document_engagement_certification(
|
||||||
session: AsyncSession,
|
session: AsyncSession,
|
||||||
protocols: dict[str, VotingProtocol],
|
protocols: dict[str, VotingProtocol],
|
||||||
org_id: uuid.UUID | None = None,
|
|
||||||
) -> Document:
|
) -> Document:
|
||||||
genesis = json.dumps(GENESIS_CERTIFICATION, ensure_ascii=False, indent=2)
|
genesis = json.dumps(GENESIS_CERTIFICATION, ensure_ascii=False, indent=2)
|
||||||
|
|
||||||
@@ -855,7 +850,6 @@ async def seed_document_engagement_certification(
|
|||||||
),
|
),
|
||||||
genesis_json=genesis,
|
genesis_json=genesis,
|
||||||
)
|
)
|
||||||
doc.organization_id = org_id
|
|
||||||
print(f" Document 'Acte d'engagement Certification': {'created' if created else 'exists'}")
|
print(f" Document 'Acte d'engagement Certification': {'created' if created else 'exists'}")
|
||||||
|
|
||||||
if created:
|
if created:
|
||||||
@@ -1899,7 +1893,6 @@ ENGAGEMENT_FORGERON_ITEMS: list[dict] = [
|
|||||||
async def seed_document_engagement_forgeron(
|
async def seed_document_engagement_forgeron(
|
||||||
session: AsyncSession,
|
session: AsyncSession,
|
||||||
protocols: dict[str, VotingProtocol],
|
protocols: dict[str, VotingProtocol],
|
||||||
org_id: uuid.UUID | None = None,
|
|
||||||
) -> Document:
|
) -> Document:
|
||||||
genesis = json.dumps(GENESIS_FORGERON, ensure_ascii=False, indent=2)
|
genesis = json.dumps(GENESIS_FORGERON, ensure_ascii=False, indent=2)
|
||||||
|
|
||||||
@@ -1923,7 +1916,6 @@ async def seed_document_engagement_forgeron(
|
|||||||
),
|
),
|
||||||
genesis_json=genesis,
|
genesis_json=genesis,
|
||||||
)
|
)
|
||||||
doc.organization_id = org_id
|
|
||||||
print(f" Document 'Acte d'engagement forgeron': {'created' if created else 'exists'}")
|
print(f" Document 'Acte d'engagement forgeron': {'created' if created else 'exists'}")
|
||||||
|
|
||||||
if created:
|
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(
|
decision, created = await get_or_create(
|
||||||
session,
|
session,
|
||||||
Decision,
|
Decision,
|
||||||
@@ -2017,7 +2009,6 @@ async def seed_decision_runtime_upgrade(session: AsyncSession, org_id: uuid.UUID
|
|||||||
decision_type="runtime_upgrade",
|
decision_type="runtime_upgrade",
|
||||||
status="draft",
|
status="draft",
|
||||||
)
|
)
|
||||||
decision.organization_id = org_id
|
|
||||||
print(f" Decision 'Runtime Upgrade': {'created' if created else 'exists'}")
|
print(f" Decision 'Runtime Upgrade': {'created' if created else 'exists'}")
|
||||||
|
|
||||||
if created:
|
if created:
|
||||||
@@ -2157,7 +2148,7 @@ async def seed_votes_on_items(
|
|||||||
# Seed: Additional decisions (demo content)
|
# 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."""
|
"""Seed a community decision: evolution of the G1 monetary license."""
|
||||||
decision, created = await get_or_create(
|
decision, created = await get_or_create(
|
||||||
session,
|
session,
|
||||||
@@ -2179,7 +2170,6 @@ async def seed_decision_licence_evolution(session: AsyncSession, org_id: uuid.UU
|
|||||||
decision_type="community",
|
decision_type="community",
|
||||||
status="draft",
|
status="draft",
|
||||||
)
|
)
|
||||||
decision.organization_id = org_id
|
|
||||||
print(f" Decision 'Évolution Licence G1 v0.4.0': {'created' if created else 'exists'}")
|
print(f" Decision 'Évolution Licence G1 v0.4.0': {'created' if created else 'exists'}")
|
||||||
|
|
||||||
if created:
|
if created:
|
||||||
@@ -2235,7 +2225,7 @@ async def seed_decision_licence_evolution(session: AsyncSession, org_id: uuid.UU
|
|||||||
# Seed: Mandates (Comité Technique + Admin Forgerons)
|
# 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."""
|
"""Seed example mandates: TechComm and Smith Admin."""
|
||||||
now = datetime.now(timezone.utc)
|
now = datetime.now(timezone.utc)
|
||||||
|
|
||||||
@@ -2407,7 +2397,6 @@ async def seed_mandates(session: AsyncSession, voters: list[DuniterIdentity], or
|
|||||||
m_data["title"],
|
m_data["title"],
|
||||||
**{k: v for k, v in m_data.items() if k != "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"
|
status_str = "created" if created else "exists"
|
||||||
print(f" Mandate '{mandate.title[:50]}': {status_str}")
|
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")
|
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
|
# 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():
|
async def run_seed():
|
||||||
print("=" * 60)
|
print("=" * 60)
|
||||||
print("libreDecision - Seed Database")
|
print("libreDecision - Seed Database")
|
||||||
@@ -2502,30 +2423,23 @@ async def run_seed():
|
|||||||
|
|
||||||
async with async_session() as session:
|
async with async_session() as session:
|
||||||
async with session.begin():
|
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...")
|
print("\n[1/10] Formula Configs...")
|
||||||
formulas = await seed_formula_configs(session)
|
formulas = await seed_formula_configs(session)
|
||||||
|
|
||||||
print("\n[2/10] Voting Protocols...")
|
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...")
|
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...")
|
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...")
|
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...")
|
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...")
|
print("\n[7/10] Simulated voters...")
|
||||||
voters = await seed_voters(session)
|
voters = await seed_voters(session)
|
||||||
@@ -2539,7 +2453,7 @@ async def run_seed():
|
|||||||
)
|
)
|
||||||
|
|
||||||
print("\n[9/10] Mandates...")
|
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.")
|
print("\n[10/10] Done.")
|
||||||
|
|
||||||
@@ -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
|
|
||||||
@@ -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}");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -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 { .. })));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -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(©, &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)));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -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));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -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, ¶ms_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, ¶ms_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"));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -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
Reference in New Issue
Block a user