forked from yvv/decision
Compare commits
284
Commits
db156884c9
..
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
fadfff5564 | ||
|
|
7819187b0b | ||
|
|
266a443dc6 | ||
|
|
a03473a01e | ||
|
|
7d8be18c3b | ||
|
|
8b92837327 | ||
|
|
842a546796 | ||
|
|
0e266241d0 | ||
|
|
d66823b2f6 | ||
|
|
1938496cea | ||
|
|
db6041d32f | ||
|
|
a6f5e22bea | ||
|
|
848968b154 | ||
|
|
7425766640 | ||
|
|
233cc45ff8 | ||
|
|
0d087d2b81 | ||
|
|
c626f1aded | ||
|
|
5730d782fe | ||
|
|
a47aed0f72 | ||
|
|
a6000f92ae | ||
|
|
d15d46e4fc | ||
|
|
8528a269fc | ||
|
|
9d478d668a | ||
|
|
7734e2dbc7 | ||
|
|
2bbd017087 | ||
|
|
d6cf8fdfed | ||
|
|
590ccd7267 | ||
|
|
87730d670d | ||
|
|
3996f6f99d | ||
|
|
093d1ca471 | ||
|
|
dda1ca85dd | ||
|
|
4365ed607a | ||
|
|
b26c00fbd6 | ||
|
|
27995b9eee | ||
|
|
8d6c35da56 | ||
|
|
d9a583c223 | ||
|
|
4f070e7b77 | ||
|
|
9d366c9afe | ||
|
|
7be6396186 | ||
|
|
ef36861953 | ||
|
|
cc8ba88d02 | ||
|
|
c976f33fa5 | ||
|
|
416364475f | ||
|
|
591ed37837 | ||
|
|
de1b7eb5d7 | ||
|
|
3b38280169 | ||
|
|
42c803e544 | ||
|
|
077c87c9db | ||
|
|
3383f28739 | ||
|
|
ca0c729481 | ||
|
|
aaacfaa332 | ||
|
|
77bc79d9ed | ||
|
|
d17a5f6d92 | ||
|
|
e7ab82f48f | ||
|
|
a8939b20c5 | ||
|
|
582d6babd0 | ||
|
|
581f7c3c9e | ||
|
|
1fe31f567b | ||
|
|
a4d4b15bc1 | ||
|
|
c05319dc4a | ||
|
|
cc24e6f487 | ||
|
|
953637ffc4 | ||
|
|
2ce2028eb2 | ||
|
|
0b4923f12a | ||
|
|
7147cb4735 | ||
|
|
b2d653ea4f | ||
|
|
6db4f900a7 | ||
|
|
aba86200c6 | ||
|
|
03a7e19da0 | ||
|
|
58c2c5d2ab | ||
|
|
8c1e7af12f | ||
|
|
a83f1af5f9 | ||
|
|
fe6d0c99cf | ||
|
|
ba6a051fd1 | ||
|
|
16ffae514e | ||
|
|
23d666195c | ||
|
|
8e3aaa313b | ||
|
|
d56b978af4 | ||
|
|
6f6197cc23 | ||
|
|
484f263ec8 | ||
|
|
bb4e7b796f | ||
|
|
1fadc1ecb6 | ||
|
|
b0e849b9cb | ||
|
|
b39f7678b0 | ||
|
|
cbf477c562 | ||
|
|
77fe70d978 | ||
|
|
72943b0223 | ||
|
|
b9eb33bec1 | ||
|
|
eb596bda45 | ||
|
|
8f0f8c532a | ||
|
|
63c22db422 | ||
|
|
9d8f2369c3 | ||
|
|
84f015ad58 | ||
|
|
770672caa3 | ||
|
|
f27ee088f6 | ||
|
|
dc0c477f88 | ||
|
|
a32912373d | ||
|
|
f6c110a60c | ||
|
|
6a6bb0153b | ||
|
|
3956a3ee83 | ||
|
|
a555d4a71e | ||
|
|
82fc531d24 | ||
|
|
2731a91d8d | ||
|
|
0953237f54 | ||
|
|
4c548016f9 | ||
|
|
d76d40b465 | ||
|
|
e1c7f890d4 | ||
|
|
b2e597b653 | ||
|
|
d709e17afe | ||
|
|
6fb82382c0 | ||
|
|
6aeb69063f | ||
|
|
90b95f9b5c | ||
|
|
7030729a06 | ||
|
|
5d3b5152ff | ||
|
|
5bbcfcdfd5 | ||
|
|
2cd94ee686 | ||
|
|
4cf4e47ee9 | ||
|
|
ce5d8dc9b9 | ||
|
|
de8add7f70 | ||
|
|
70f7bb7247 | ||
|
|
ab1a3ecbd7 | ||
|
|
de8281f7f6 | ||
|
|
af08fdaa8b | ||
|
|
d367d73a9e | ||
|
|
0a63bd7876 | ||
|
|
33f149bc9b | ||
|
|
bb4bdf58c1 | ||
|
|
abc9c44a55 | ||
|
|
92ba485f71 | ||
|
|
be72a0e188 | ||
|
|
a152652a07 | ||
|
|
df24ce1d80 | ||
|
|
9bb6023e70 | ||
|
|
bc5a3737f1 | ||
|
|
32fcf229ef | ||
|
|
39d5cd2633 | ||
|
|
5c9307bb9f | ||
|
|
4115b1c8ac | ||
|
|
bf9478039b | ||
|
|
94ddb17564 | ||
|
|
d57326d8ad | ||
|
|
fad726e7b4 | ||
|
|
1f9f6a8f77 | ||
|
|
98b4530f43 | ||
|
|
e8fbc07c9a | ||
|
|
c06c37a24a | ||
|
|
a0d90305dc | ||
|
|
e3c1c362a4 | ||
|
|
f62347cc6b | ||
|
|
e8e9633b75 | ||
|
|
4c9e796137 | ||
|
|
9b7345346e | ||
|
|
29d985ec0a | ||
|
|
0cde5bdbd0 | ||
|
|
03682b1912 | ||
|
|
c08f06233f | ||
|
|
29e62b246c | ||
|
|
c0c4d129cb | ||
|
|
4b81a2e909 | ||
|
|
0c017f11d5 | ||
|
|
349c8edf25 | ||
|
|
5cd10d16c6 | ||
|
|
40ab6e41c5 | ||
|
|
17addf8d9f | ||
|
|
8d1b07d8de | ||
|
|
f3d79eb388 | ||
|
|
01b7841472 | ||
|
|
37147d1f59 | ||
|
|
e49f7fb98f | ||
|
|
9a9c99b93b | ||
|
|
aaa653e1a7 | ||
|
|
ef461617de | ||
|
|
e2445f6467 | ||
|
|
d07883eca0 | ||
|
|
a3c0a76ea2 | ||
|
|
48529e5409 | ||
|
|
c1275512e1 | ||
|
|
0d3dd416d2 | ||
|
|
d76598b41b | ||
|
|
723242e0a6 | ||
|
|
5637452040 | ||
|
|
1027155091 | ||
|
|
6228d3913a | ||
|
|
f4f8d5850e | ||
|
|
d7f5f6d13c | ||
|
|
d4531da9be | ||
|
|
ee37c3b4d5 | ||
|
|
049f3c65b2 | ||
|
|
e7dda2ff47 | ||
|
|
5cf30e0bfa | ||
|
|
94fd2fa480 | ||
|
|
3f10a0e860 | ||
|
|
0919b467c9 | ||
|
|
f47a685b5f | ||
|
|
70aa9ba874 | ||
|
|
adb64ddb0e | ||
|
|
5d1f8652fc | ||
|
|
76b8e9f7be | ||
|
|
238c7db93f | ||
|
|
61547c11c0 | ||
|
|
2514804d38 | ||
|
|
e16c0bd3e0 | ||
|
|
875a3541df | ||
|
|
3b6e171489 | ||
|
|
4d4adcc281 | ||
|
|
033b331228 | ||
|
|
9eff22f493 | ||
|
|
924a40d790 | ||
|
|
4bdaf0e995 | ||
|
|
cd7e683a4b | ||
|
|
25af01154a | ||
|
|
9c8f4cc568 | ||
|
|
785580c78f | ||
|
|
06c99cb0db | ||
|
|
83f8ca0981 | ||
|
|
8ff5a2b573 | ||
|
|
7ffa4fdeeb | ||
|
|
7b8dd1714f | ||
|
|
c7d2791ad1 | ||
|
|
9cc093f283 | ||
|
|
51c9a4771d | ||
|
|
3f1f968f16 | ||
|
|
e63e2ec283 | ||
|
|
3831ddb1bf | ||
|
|
c3a3c6923f | ||
|
|
70dbd712c8 | ||
|
|
1a35d0f521 | ||
|
|
74f96becf7 | ||
|
|
032fcbb535 | ||
|
|
ef271652df | ||
|
|
b260596cfd | ||
|
|
3541e1b1a6 | ||
|
|
dbd71faf63 | ||
|
|
3fff7f8332 | ||
|
|
71819f9012 | ||
|
|
a576b68570 | ||
|
|
3bc60a1e18 | ||
|
|
341df4df01 | ||
|
|
a57a1ff642 | ||
|
|
db64405e1e | ||
|
|
0557af195c | ||
|
|
a3a3ee5826 | ||
|
|
d762301706 | ||
|
|
1593e50a29 | ||
|
|
6bbc282cb1 | ||
|
|
8727315612 | ||
|
|
68115fb9d7 | ||
|
|
1e0433664c | ||
|
|
c223c5c5ac | ||
|
|
1503e579ba | ||
|
|
81b604e5f2 | ||
|
|
edc88111e0 | ||
|
|
a5b92faa96 | ||
|
|
c640413a70 | ||
|
|
f504010c0f | ||
|
|
d400bfc92e | ||
|
|
68111c351e | ||
|
|
125ec6614c | ||
|
|
7e6d243b46 | ||
|
|
f5635b13b3 | ||
|
|
dc2de0947c | ||
|
|
decbc0ba7b | ||
|
|
cf634c25d2 | ||
|
|
422b45ab75 | ||
|
|
8ec0b48e8f | ||
|
|
744d157b01 | ||
|
|
850f9c46b0 | ||
|
|
8a3216037c | ||
|
|
753b52202b | ||
|
|
fd19dbf4ec | ||
|
|
c3d6bf88b9 | ||
|
|
fa8e8dc1f0 | ||
|
|
6c0754a5c3 | ||
|
|
1eac6f856a | ||
|
|
9203fd2501 | ||
|
|
ac9c904e7e | ||
|
|
94907afb20 | ||
|
|
1f9c241b41 | ||
|
|
c2669a3d0b | ||
|
|
7a022dd35e | ||
|
|
67ddee28b2 | ||
|
|
4b1732916d | ||
|
|
ef0347b6aa | ||
|
|
a80f82cb64 |
@@ -0,0 +1,19 @@
|
||||
# Build context of docker/app.Dockerfile (the repository root).
|
||||
# The maquette builds from its own context (maquette/), not from here.
|
||||
.git
|
||||
.remember
|
||||
.env
|
||||
.env.*
|
||||
!.env.example
|
||||
data
|
||||
target
|
||||
maquette
|
||||
docs
|
||||
web/node_modules
|
||||
web/build
|
||||
web/.svelte-kit
|
||||
web/test-results
|
||||
web/playwright-report
|
||||
**/*.db
|
||||
**/*.db-wal
|
||||
**/*.db-shm
|
||||
+28
-43
@@ -1,49 +1,34 @@
|
||||
# PostgreSQL
|
||||
POSTGRES_DB=libredecision
|
||||
POSTGRES_USER=libredecision
|
||||
POSTGRES_PASSWORD=change-me-in-production
|
||||
DATABASE_URL=postgresql+asyncpg://libredecision:change-me-in-production@localhost:5432/libredecision
|
||||
# libreDecision v3 — variables du serveur (ld-server). Toutes facultatives en dev.
|
||||
# Le serveur lit son environnement, pas de fichier : `set -a; . ./.env; set +a` avant
|
||||
# `cargo run -p ld-server`. Le .env n'est jamais commité.
|
||||
|
||||
# Backend
|
||||
SECRET_KEY=change-me-in-production-with-a-real-secret-key
|
||||
DEBUG=true
|
||||
# Écoute et dossiers
|
||||
LD_BIND=0.0.0.0:8002
|
||||
LD_DATA_DIR=data # SQLite, clone du dépôt des textes, clé de publication — données runtime
|
||||
LD_WEB_DIR=web/build # interface compilée (npm run build)
|
||||
LD_SEED_DIR=seeds
|
||||
LD_PUBLIC_URL=http://127.0.0.1:3002
|
||||
|
||||
# Environment: development, staging, production
|
||||
ENVIRONMENT=development
|
||||
# Connexion Ğ1 — client OAuth2 de connect.monnaie-libre.fr (les deux sont requis, sinon désactivée)
|
||||
# LD_CONNECT_URL=https://connect.monnaie-libre.fr
|
||||
# LD_CONNECT_CLIENT_ID=
|
||||
# LD_CONNECT_CLIENT_SECRET=
|
||||
|
||||
# Logging: DEBUG, INFO, WARNING, ERROR
|
||||
LOG_LEVEL=INFO
|
||||
# Accès (trustinWallet)
|
||||
LD_ADMIN_G1_IDS= # ex. idty:8352 — administrateurs des accès, séparés par des virgules
|
||||
LD_DEV_LOGIN=1 # dev local uniquement : connexion sans clé, membre de tous les groupes
|
||||
LD_BOOTSTRAP_ADMIN=0 # 1 = au premier démarrage sans admin, écrit data/bootstrap-admin.txt (0600)
|
||||
|
||||
# CORS
|
||||
CORS_ORIGINS=["http://localhost:3002"]
|
||||
# Publication des textes en vigueur (dépôt git lu par les applis clientes)
|
||||
# LD_PUBLISH_REMOTE=gitea@git.open.us.org:yvv/decision-depot-textes-reference.git
|
||||
# LD_PUBLISH_BRANCH=main
|
||||
# LD_PUBLISH_KEY=data/keys/publish_ed25519 # générée au démarrage si absente
|
||||
# LD_PUBLISH_AUTHOR=libreDecision
|
||||
# LD_PUBLISH_EMAIL=decision@librodrome.org
|
||||
|
||||
# Database connection pool
|
||||
DATABASE_POOL_SIZE=20
|
||||
DATABASE_MAX_OVERFLOW=10
|
||||
# Taille de la toile de confiance (W) — lue sur la chaîne, repli sur l'indexeur puis sur la valeur fixe
|
||||
# LD_RPC_ENDPOINTS=https://rpc.duniter.org,https://g1.axiom-team.fr/ws/
|
||||
# LD_INDEXERS=https://indexer.duniter.org/v1/graphql
|
||||
# LD_WOT_FALLBACK=5745
|
||||
|
||||
# Auth / Sessions
|
||||
SESSION_TTL_HOURS=24
|
||||
CHALLENGE_EXPIRE_SECONDS=300
|
||||
|
||||
# Rate limiting (requests per minute)
|
||||
RATE_LIMIT_DEFAULT=60
|
||||
RATE_LIMIT_AUTH=10
|
||||
RATE_LIMIT_VOTE=30
|
||||
|
||||
# Duniter V2 RPC
|
||||
DUNITER_RPC_URL=wss://gdev.p2p.legal/ws
|
||||
DUNITER_RPC_TIMEOUT_SECONDS=10
|
||||
|
||||
# Blockchain cache TTL (seconds)
|
||||
BLOCKCHAIN_CACHE_TTL_SECONDS=3600
|
||||
|
||||
# IPFS
|
||||
IPFS_API_URL=http://localhost:5001
|
||||
IPFS_GATEWAY_URL=http://localhost:8080
|
||||
IPFS_TIMEOUT_SECONDS=30
|
||||
|
||||
# Frontend
|
||||
NUXT_PUBLIC_API_BASE=http://localhost:8002/api/v1
|
||||
|
||||
# Docker / Production
|
||||
DOMAIN=decision.librodrome.org
|
||||
RUST_LOG=info,sqlx=warn
|
||||
|
||||
+11
@@ -42,3 +42,14 @@ htmlcov/
|
||||
# IPFS
|
||||
ipfs-data/
|
||||
docs/*.pdf
|
||||
|
||||
# v3 — Rust + Svelte
|
||||
/target/
|
||||
/data/
|
||||
web/build/
|
||||
web/.svelte-kit/
|
||||
web/test-results/
|
||||
web/playwright-report/
|
||||
*.db-wal
|
||||
*.db-shm
|
||||
web/node_modules/
|
||||
|
||||
@@ -0,0 +1,250 @@
|
||||
# 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,7 +1,15 @@
|
||||
# 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
|
||||
@@ -18,7 +26,7 @@ steps:
|
||||
- name: test-backend
|
||||
image: python:3.11-slim
|
||||
commands:
|
||||
- cd backend
|
||||
- cd maquette/backend
|
||||
- pip install --no-cache-dir -r requirements.txt
|
||||
- pytest app/tests/ -v --tb=short
|
||||
|
||||
@@ -27,7 +35,7 @@ steps:
|
||||
commands:
|
||||
# git requis : dépendance @yvv/nuxt-base en git+https (node:20-slim n'a pas git)
|
||||
- apt-get update && apt-get install -y --no-install-recommends git ca-certificates
|
||||
- cd frontend
|
||||
- cd maquette/frontend
|
||||
- npm ci
|
||||
- npm run build
|
||||
|
||||
@@ -39,7 +47,7 @@ steps:
|
||||
volumes:
|
||||
- /var/run/docker.sock:/var/run/docker.sock
|
||||
commands:
|
||||
- docker build -t libredecision-backend:latest -f docker/backend.Dockerfile --target production .
|
||||
- docker build -t libredecision-backend:latest -f maquette/docker/backend.Dockerfile --target production maquette
|
||||
- echo "Image backend construite"
|
||||
|
||||
# NOTE: volumes + pas de from_secret : compatible
|
||||
@@ -50,7 +58,7 @@ steps:
|
||||
volumes:
|
||||
- /var/run/docker.sock:/var/run/docker.sock
|
||||
commands:
|
||||
- docker build -t libredecision-frontend:latest -f docker/frontend.Dockerfile --target production .
|
||||
- docker build -t libredecision-frontend:latest -f maquette/docker/frontend.Dockerfile --target production maquette
|
||||
- echo "Image frontend construite"
|
||||
|
||||
# NOTE: volumes + pas de from_secret : compatible
|
||||
@@ -63,10 +71,12 @@ steps:
|
||||
- /var/run/docker.sock:/var/run/docker.sock
|
||||
commands:
|
||||
- apk add --no-cache curl
|
||||
- curl -sSfL https://raw.githubusercontent.com/anchore/syft/main/install.sh | sh -s -- -b /usr/local/bin latest
|
||||
# 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
|
||||
- syft libredecision-backend:latest -o cyclonedx-json --file .reports/sbom-backend.cyclonedx.json
|
||||
- syft libredecision-frontend:latest -o cyclonedx-json --file .reports/sbom-frontend.cyclonedx.json
|
||||
# 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
|
||||
@@ -82,8 +92,12 @@ steps:
|
||||
- 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:
|
||||
@@ -111,14 +125,12 @@ steps:
|
||||
depends_on:
|
||||
- sbom-publish
|
||||
environment:
|
||||
APP_DOMAIN:
|
||||
from_secret: app_domain
|
||||
POSTGRES_PASSWORD:
|
||||
from_secret: postgres_password
|
||||
SECRET_KEY:
|
||||
from_secret: secret_key
|
||||
commands:
|
||||
- echo "APP_DOMAIN=$APP_DOMAIN" > .env.deploy
|
||||
- echo "APP_DOMAIN=maquette.decision.librodrome.org" > .env.deploy
|
||||
- echo "POSTGRES_PASSWORD=$POSTGRES_PASSWORD" >> .env.deploy
|
||||
- echo "SECRET_KEY=$SECRET_KEY" >> .env.deploy
|
||||
- OWNER=$(echo "$CI_REPO_OWNER" | tr 'A-Z' 'a-z') && REPO=$(echo "$CI_REPO_NAME" | tr 'A-Z' 'a-z') && BRANCH=$(echo "$CI_COMMIT_BRANCH" | tr 'A-Z/' 'a-z-') && echo "COMPOSE_PROJECT_NAME=$OWNER-$REPO-$BRANCH" >> .env.deploy
|
||||
@@ -153,8 +165,12 @@ steps:
|
||||
commands:
|
||||
- cp .env.deploy /opt/libredecision/.env
|
||||
- chmod 600 /opt/libredecision/.env
|
||||
- cp docker/docker-compose.yml /opt/libredecision/docker-compose.yml
|
||||
- cp maquette/docker/docker-compose.yml /opt/libredecision/docker-compose.yml
|
||||
# Arreter avant le challenge ACME : libere le webroot pour sonic-acme-1
|
||||
# L'org forge a ete renommee (EHV -> LPK) : le nom de projet compose a change,
|
||||
# on arrete donc aussi l'ancienne stack (ehv-decision-main-*) pour eviter la
|
||||
# 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)
|
||||
@@ -165,13 +181,18 @@ steps:
|
||||
--webroot /usr/share/nginx/html \
|
||||
--server letsencrypt \
|
||||
--accountemail support+acme@asycn.io || ACME_EXIT=$?
|
||||
if [ "$ACME_EXIT" -ne 0 ] && [ "$ACME_EXIT" -ne 2 ]; then
|
||||
echo "ERREUR: acme.sh a echoue (exit $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
|
||||
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)"
|
||||
# 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
|
||||
@@ -235,6 +256,6 @@ steps:
|
||||
- name: notify-failure
|
||||
image: alpine:3.20
|
||||
commands:
|
||||
- 'echo "ECHEC pipeline #$CI_BUILD_NUMBER sur $CI_COMMIT_BRANCH ($CI_COMMIT_SHA)"'
|
||||
- 'echo "ECHEC pipeline #$CI_PIPELINE_NUMBER sur $CI_COMMIT_BRANCH ($CI_COMMIT_SHA)"'
|
||||
when:
|
||||
- status: failure
|
||||
@@ -1,100 +1,173 @@
|
||||
# libreDecision
|
||||
|
||||
Boîte à outils de la première démocratie — décider ensemble, du trio à la population.
|
||||
v2 local-first : 6 routes de décision, 5 modalités, documents sous vote permanent, mandats,
|
||||
observatoire. Marque blanche par construction (seeds Ğ1 + Atelier du Canal).
|
||||
**Référence produit : `docs/dev/BLUEPRINT-V2.md`** — toute décision de comportement s'y adosse.
|
||||
Rendre possible la décision collective — en fonction du contexte, processus et modalités,
|
||||
boîte à outils. **v3 : Rust + Svelte**, un chantier à la fois, chacun livré en production avant
|
||||
le suivant (`docs/chantiers/README.md`).
|
||||
**Référence du chantier en cours : `docs/chantiers/acte-certification/CHANTIER.md`** — toute
|
||||
décision de comportement s'y adosse ; les décisions durables dans `docs/adr/`.
|
||||
La maquette v2 (Nuxt + FastAPI) vit dans `maquette/`, **intacte** (Yvv la présente) — ne pas y
|
||||
développer.
|
||||
|
||||
## Protocole de début de session
|
||||
|
||||
1. `git pull --rebase origin main`
|
||||
2. Si l'objectif de la session n'est pas précisé, le demander
|
||||
1. `git pull --rebase origin main` (puis la branche du chantier : `chantier/acte-certification`)
|
||||
2. Lire `HANDOFF.md` (où on en est, ce qui attend Yvv)
|
||||
3. Si l'objectif de la session n'est pas précisé, le demander
|
||||
|
||||
## Stack
|
||||
|
||||
- **Frontend** : Nuxt 4 (Vue 3, TypeScript, SPA — SSR off) + Nuxt UI v3 + Pinia + UnoCSS ;
|
||||
package manager : npm ; port **3002** strict
|
||||
- **Local-first** : IndexedDB (idb-keyval) via `app/data/persistence.ts` — une clé
|
||||
`ld2:<collectiveId>` par collectif, export/import de bundles JSON, seeds par le même chemin
|
||||
- **Backend v1** (FastAPI + SQLAlchemy async, port 8002) : conservé pour la **future synchro**
|
||||
— non requis pour le dev frontend ; `runtimeConfig.public.apiBase` gardé dans nuxt.config
|
||||
en prévision
|
||||
- Déploiement : Docker multi-stage + Traefik ; CI Woodpecker
|
||||
- **Serveur** : Rust edition 2024, toolchain **1.99** épinglée (`rust-toolchain.toml`) ;
|
||||
rustup installé sans PATH → `export PATH=$HOME/.cargo/bin:$PATH`. axum 0.8, sqlx 0.8
|
||||
(SQLite, requêtes runtime + `migrate!`), tokio, reqwest rustls.
|
||||
- **Interface** : SvelteKit 2 / Svelte 5 (runes), adapter-static SPA, vite 7, vitest,
|
||||
Playwright ; npm. Polices Literata (textes) + Nunito (interface), fontsource.
|
||||
- **Ports** : dev interface **3002** (strict, proxy `/api` → 8002) · serveur **8002** ·
|
||||
serveur jetable des e2e **8092** (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
|
||||
|
||||
```
|
||||
frontend/
|
||||
app/
|
||||
engine/ # moteurs PURS (zéro dépendance Nuxt) : threshold (+electionResult),
|
||||
# nuanced, modeParams, parametric (+crystallize), settings, state (canTransition),
|
||||
# triage, impact — import UNIQUEMENT via le barrel ~/engine
|
||||
data/
|
||||
persistence.ts # IndexedDB local-first
|
||||
templates.ts # 7 gabarits de collectif (invariant : protocole Consentement partout)
|
||||
seeds/ # duniter-g1.bundle.json (324 Ko) + atelier-du-canal.bundle.json
|
||||
stores/
|
||||
collective.ts # le tenant : collectif actif, état complet, cycle de vie des bundles
|
||||
decisions.ts # façade d'actions sans état propre (LWW updatedAt, persist debounced)
|
||||
composables/ # useMood (useLibreMood), useFeed (le Fil = sélecteur pur), useSearch
|
||||
components/ # par domaine : chemin/ decisions/ votes/ texts/ mandates/ feed/
|
||||
# onboarding/ common/ (primitives Ld*)
|
||||
pages/ # 14 routes : / · decider · decisions (+[id], vote, observatoire) ·
|
||||
# textes (+[slug], formules) · mandats (+[id], nouveau) ·
|
||||
# creer (layout bare) · donnees
|
||||
lexicon.ts # SOURCE UNIQUE des libellés FR + FORBIDDEN_UI_TERMS
|
||||
types/domain.ts # le noyau du modèle
|
||||
tests/ # 342 tests vitest : engine/, stores/, seeds/ (non-perte), lexicon.spec.ts
|
||||
backend/ # v1 FastAPI conservé pour la synchro future — ne pas développer dessus
|
||||
scripts/export_seed_bundle.py # extrait seed.py → duniter-g1.bundle.json (source vivante = bundle)
|
||||
docker/ # compose + Dockerfiles
|
||||
docs/dev/BLUEPRINT-V2.md # la référence produit v2
|
||||
public/hexagram-tsing*.svg # sceau 井 (#48 Tsing)
|
||||
crates/
|
||||
ld-engine/ # PUR (zéro I/O) : threshold (formule inertielle sur un quorum, judged_voters,
|
||||
# yes_needed / no_needed), inertia (cinq crans, leurs quorums, bornes, médiane,
|
||||
# référence, échelle), edit (coquille vs fond, diff), assist (découpage, similarité,
|
||||
# longueur), compose (adoption, aperçus), ballot (bulletin scellé ld1:, le dernier
|
||||
# fait foi, jugement d'une transaction), address (SS58, préfixe g1), objection
|
||||
# (redit / contredit : la dernière arrivée retenue, les tours, l'échéance), correction
|
||||
# (la coquille d'un autre : attendre, valider, refuser, l'échéance), order (l'ordre
|
||||
# d'une section : qui en est, complété à la fin, places), pending (modules à voter),
|
||||
# buffer (le tampon des votes, ADR 0012 : réconciliation, poids des états,
|
||||
# SIMULATED_ADOPTS)
|
||||
trustin-access/ # règles d'accès de trustinWallet : sees / opens / manages_access, vote_way (serveur ou
|
||||
# chaîne), answers_typo, lifts_objection / reiterates_objection, reads_message,
|
||||
# sets_quorum, casts_simulated (le vote simulé, chaîne fermée), groupes par défaut
|
||||
# (TEST_GROUP)
|
||||
ld-server/
|
||||
migrations/ # schéma SQLite — additif seulement
|
||||
src/ # lib.rs (routes, csrf, run) · config.rs (LD_*) · auth.rs (connect OAuth2, invitations,
|
||||
# dev, bootstrap) · team.rs (liens d'équipe) · docs.rs (lecture, aperçus) · proposals.rs (écrire, voter,
|
||||
# traduire) · objections.rs (faire, lever, réitérer, la version retenue) · typos.rs
|
||||
# (les coquilles des autres) · messages.rs (la boîte « Messages ») · deadlines.rs
|
||||
# (les échéances de 3 jours) · clock.rs (horloge injectable) · adoption.rs
|
||||
# (balayage, verrou) · participation.rs (p̃, une fois par
|
||||
# jour) · publish.rs (file → git) · wot.rs (W sur la chaîne) · seed.rs ·
|
||||
# seed_amend.rs / inertia_amend.rs / seed_proposal_amend.rs (amendements gardés ;
|
||||
# le dernier fait des Propositions seed, ADR 0011) · ballots.rs (vote groupé,
|
||||
# bulletins de la chaîne Ğ1, le tampon : intention, preuve, réconciliation ; le vote
|
||||
# simulé, « ton vote ») · simulation.rs (l'acte « supprime » de la fin de la simulation :
|
||||
# `ld-server discard-simulation [--apply]`, ADR 0012) · indexer.rs (txComments)
|
||||
# · modules.rs / order.rs / doc_title.rs (les modules, ADR 0009 : l'ordre d'une
|
||||
# section, le titre du document) · module_amend.rs (ajout gardé) · quorum.rs (le quorum
|
||||
# d'un article, PUT /units/{id}/quorum ; « un vote existe » : voted_units, ADR 0013) ·
|
||||
# quorum_amend.rs (les quorums par cran, amendement gardé) · article_amend.rs (un
|
||||
# article du seed ajouté à un document existant, gardé : E12, lot T4) · retire.rs
|
||||
# (formule, inertie, ordonnancement sortis du document sans rien effacer :
|
||||
# units.retired_at, ADR 0014) · web.rs (sert web/build)
|
||||
tests/ # api.rs (scénarios bout en bout) · ballots.rs (vote par la chaîne, indexeur simulé) ·
|
||||
# objections.rs · typos.rs (lot F, horloge avancée, migration 0008) · modules.rs (ordre,
|
||||
# titre, ajout gardé) · seed_proposals.rs (Propositions seed, publication suspendue) ·
|
||||
# quorum.rs (quorums par cran, la route, « un vote existe », l'amendement) ·
|
||||
# fixtures/seed-production-2026-11-02/ (le seed que la production fait tourner :
|
||||
# I1/E1/E2 en vigueur, pour rejouer les amendements sur une base comme elle) ·
|
||||
# seed_quorum_texts.rs (lots T, T2 et T3 : F1.1, F1.2, N1.1 ; aucun chiffre daté, chaque
|
||||
# phrase vraie pour tout W et tout quorum ; fixtures/seed-quorum-m6b.json et
|
||||
# seed-quorum-t2.json, leurs textes depuis M6b et M6c, pour rejouer T2 et T3 sur une
|
||||
# base comme la production ; depuis T4, sur le seed de M8) · amorce.rs (lot T4 : les
|
||||
# textes d'Yvv, E12, le retrait ; après M6c et après M8, chaque ligne de chaque table
|
||||
# gardée, le retour arrière) · fixtures/seed-m8/ (le seed de M8 : les derniers textes
|
||||
# de formule, inertie, ordonnancement, référence du lot U3)
|
||||
seeds/ # acte-engagement-certification.json (trilingue, 27 articles, 5 sections, aucun en
|
||||
# vigueur ; I1/E1/E12/E2 Propositions seed au cran 2 (seed::SEED_PROPOSAL_LEVEL),
|
||||
# inertie 3 ailleurs ; modules en vigueur sans vote : le titre à 4, l'ordre de chaque
|
||||
# section à 2 ;
|
||||
# quorums par cran : 12 · 12 · 50 · 50 · 50 Monnaie libre, 1 partout essai)
|
||||
# upcoming/<groupe>.json : les « À venir » des outils d'un groupe (fr, en, es), lus au démarrage
|
||||
web/src/
|
||||
lib/i18n/ # fr.ts SOURCE (types Key) · en.ts · es.ts — parité testée
|
||||
lib/components/ # Sidebar, DocPanel, VoteFold (le vote déplié sous l'article), BallotPanel (panier),
|
||||
# ChainSign (signature, Ğecko), VersionDetails, ProposeView, UnitBlock, DeepList,
|
||||
# OrderLine / Reorder (l'ordre d'une section : une ligne en fin de section, les poignées
|
||||
# sur ses articles, sur place), QuorumSetting (le quorum de l'auteur), Crans (l'inertie dessinée),
|
||||
# TitleBar / TitlePropose (le titre du document), OpeningInfo (« Info ouverture doc de réf. », lot U3), Seal…
|
||||
lib/wallet/ # injected.ts (portefeuille injectedWeb3, polkadot-api chargé à la signature) · gecko.ts
|
||||
lib/basket.ts # PUR : sections, tout pour / tout contre, versions en concurrence, versions retenues, modules
|
||||
lib/objections.ts # où l'auteur répond à une objection · lib/diff.ts : le diff resserré d'une coquille
|
||||
lib/order.ts # PUR : placer, monter / descendre, ce qui a bougé
|
||||
lib/opening.ts # PUR : le chapitre d'ouverture, lu dans l'API (cran d'ouverture, Propositions d'amorce, quorums)
|
||||
routes/ # / (accueil + choix du groupe) · connexion · invitation · equipe · g/[group] (piliers, en cours) ·
|
||||
# g/[group]/docs · g/[group]/docs/[doc] (lecteur,
|
||||
# vote déplié ?vote=, panneau sans onglets ?onglet=panier|proposer|details|reglages ;
|
||||
# l'ancien onglet=propositions déplie le vote ; note : docs/chantiers/acte-certification/UX-lecteur.md)
|
||||
# · g/[group]/decisions · g/[group]/mandats ·
|
||||
# g/[group]/protocoles (à venir) · acces (trustinWallet) · messages (la boîte) ;
|
||||
# /groupes, /decisions… redirigent
|
||||
web/e2e/ # Playwright C1–C44 + M1, M2 ; serve.sh = serveur jetable /tmp/ld-e2e sur :8092 et
|
||||
# chain-mock.mjs (nœud et indexeur Ğ1 simulés) ; faux portefeuille dans helpers.ts
|
||||
docker/ # app.Dockerfile · app-compose.yml (sonic)
|
||||
.woodpecker/ # app.yml · maquette.yml
|
||||
docs/ # chantiers/ · adr/ · dev/BLUEPRINT-V2.md (doctrine de la maquette) · recherche/
|
||||
maquette/ # v2 intacte (frontend Nuxt, backend FastAPI, docker, docs-v1)
|
||||
```
|
||||
|
||||
## Données runtime
|
||||
## Données runtime — jamais écrasées
|
||||
|
||||
- **postgres-data** / **ipfs-data** : volumes Docker du backend v1 — jamais écrasés
|
||||
- `.env` racine : secrets backend — jamais commité
|
||||
- **Production** : volume `lpk-libredecision-app_data` (`/data` : `libredecision.db`, `textes/`
|
||||
= clone du dépôt publié, `keys/` = clé de publication, `bootstrap-admin.txt`). Jamais
|
||||
`down -v`, jamais recréé.
|
||||
- **Maquette** : volumes `lpk-decision-main_postgres-data` / `_ipfs-data` (inchangés).
|
||||
- **Local** : `data/` (gitignoré) — le supprimer repart du seed. Les e2e utilisent `/tmp/ld-e2e`.
|
||||
- Un document seedé n'est **jamais** réinstancié : dès qu'il existe en base, c'est de la donnée.
|
||||
|
||||
## Commandes
|
||||
## Commandes — la boucle (tout vert = fait)
|
||||
|
||||
```bash
|
||||
# Frontend (tout le dev v2)
|
||||
cd frontend && npm run dev # :3002
|
||||
npm test # vitest — 342 tests, référence à maintenir
|
||||
npm run build # zéro erreur exigé avant commit
|
||||
export PATH=$HOME/.cargo/bin:$PATH
|
||||
cargo fmt --all --check && cargo clippy --workspace --all-targets -- -D warnings && cargo test --workspace
|
||||
cd web && npm run check && npm test && npm run build && npm run e2e # e2e : 46 contrôles (48 tests, C25 et C35 en deux)
|
||||
|
||||
# Seeds : le bundle est la source vivante ; seed.py (backend) est remplacé par
|
||||
cd backend && .venv/bin/python scripts/export_seed_bundle.py # régénère duniter-g1.bundle.json
|
||||
# → test de non-perte bloquant : tests/seeds/duniter-g1.spec.ts
|
||||
|
||||
# Docker
|
||||
docker compose -f docker/docker-compose.yml up
|
||||
LD_DEV_LOGIN=1 cargo run -p ld-server # :8002, sert web/build
|
||||
cd web && npm run dev # :3002 à chaud
|
||||
docker build -f docker/app.Dockerfile --target production . # rejoue toute la boucle
|
||||
```
|
||||
|
||||
## Conventions / pièges
|
||||
|
||||
- **UI français, code anglais** (variables, commentaires, docstrings)
|
||||
- **lexicon.ts d'abord** : aucun libellé UI en dur si le lexicon peut le porter ; ton = tutoyer
|
||||
la personne, sujet grammatical = la personne ou le collectif, jamais la formule ni le système
|
||||
- **Anti-lexique** (`tests/lexicon.spec.ts`) : `FORBIDDEN_UI_TERMS` scanné sur lexicon +
|
||||
templates `.vue` (insensible casse/accents) — « triage », « verdict », « déléguer »… restent
|
||||
des identifiants de code, jamais des textes affichés
|
||||
- **Invariants doctrinaux** : jamais de score sur une personne (attributs jamais affichés à
|
||||
autrui, re-votes visibles du seul auteur) ; jamais de contenu adopté sans geste humain
|
||||
(cristallisation, clôture de dossier, départage = gestes datés) ; repli universel → consent ;
|
||||
dérogation asymétrique (alourdir libre, alléger motivé)
|
||||
- **Budget 400 lignes max par page** (`app/pages/`)
|
||||
- **Moteurs purs** : engine/ sans Nuxt/Pinia/DOM, import via `~/engine` seulement ; stores en
|
||||
imports explicites (testables sous vitest nu) ; toute mutation d'état passe par
|
||||
`canTransition(decision, to, ctx)`
|
||||
- **Formule inertie** : `R = C + B^W + (M + (1−M)(1−(T/W)^G))·max(0, T−C)` —
|
||||
`frontend/app/engine/threshold.ts` ; l'Atelier (`/textes/formules`) est la seule maison des
|
||||
lettres W/T/M/B/G/C et de KaTeX
|
||||
- **Mood system** : `useLibreMood()` (`composables/useMood.ts`) — **jamais** de `:global()`
|
||||
dans `<style scoped>` pour les styles mood-dépendants (causa le bug dark mode veil)
|
||||
- **Sceau** `井` (#48 Tsing) : `LdSeal` dans les layouts ; SVGs dans `public/`
|
||||
- **CSS `drop-shadow()`** safe pour l'emboss ; `<filter>` SVG inline cause des artefacts
|
||||
- **Composants** : `pathPrefix: false` — noms de fichiers uniques, auto-import
|
||||
- **Domaine** : decision.librodrome.org (Woodpecker CI ; ancien dossier : Glibredecision)
|
||||
- **UI français, code anglais** ; **trilingue** : aucune chaîne affichée en dur, tout passe par
|
||||
`web/src/lib/i18n/fr.ts` puis en/es (test de parité + `FORBIDDEN_UI_TERMS`). Tutoyer ; le sujet
|
||||
est la personne ou le groupe, jamais le système. Le français du texte voté fait foi.
|
||||
- **Moteur pur** : une règle de vote / édition / composition s'écrit et se teste dans
|
||||
`ld-engine` avant d'être câblée. Le serveur ne décide jamais l'accès seul : `trustin-access`.
|
||||
- **Invariants** : les informations de fonctionnement (formule, inertie, ordonnancement) ne se
|
||||
votent pas : hors du document, une unité retirée (`units.retired_at`) n'est ni lue, ni votée, ni
|
||||
comptée, ni publiée, et rien n'est effacé (ADR 0014) ; au départ, aucun article n'est en
|
||||
vigueur : I1, E1, E12 et E2 sont des Propositions
|
||||
seed (ouvertes, mises en avant, au cran 2 ; seuls les modules sont des *seeds a minima*), et la
|
||||
publication d'un document attend son premier texte en vigueur (ADR 0011) ; un quorum par cran
|
||||
(12 · 12 · 50 · 50 · 50 dans Monnaie libre), que l'auteur d'un article change avant le premier
|
||||
vote : sous le quorum, les votants qui manquent comptent comme « non », et une seule voix ne fait
|
||||
jamais passer un texte (quorum ≥ 2), sauf dans le groupe d'essai (quorum 1 ; ADR 0013) ;
|
||||
une coquille ne demande pas de vote, un changement de fond si ; la coquille d'un autre attend
|
||||
son auteur (un administrateur pour le seed) ou 3 jours, et en vigueur seule une coquille validée
|
||||
change le texte ; une objection retient la dernière arrivée jusqu'à sa levée (jamais un texte en
|
||||
vigueur ; ADR 0010) : elle n'entre pas en vigueur, et on peut y retirer son vote ou passer à
|
||||
« contre », jamais ajouter un « pour » (Yvv, 01/11/2026 : « Retirer ou "contre" ») ; l'inertie de l'inertie hérite du cran de l'article (cinq crans, ADR 0007) ;
|
||||
le groupe d'essai ne publie jamais ; dans Monnaie libre, un vote est une transaction Ğ1 et le
|
||||
dernier fait foi dans l'ordre de la chaîne (ADR 0008) — **jamais de transaction réelle en test**
|
||||
(chaîne simulée ; un essai réel est un acte « paye » d'Yvv).
|
||||
- **Écritures** : en-tête `x-requested-with: libredecision` obligatoire (CSRF) ; cookie
|
||||
`ld_session`. Les tests d'API passent par `Client` (`tests/api.rs`).
|
||||
- **Pièges vus** : `pkill -f target/debug/ld-server` tue son propre shell → `pkill -x ld-server` ;
|
||||
un `from_secret` absent fait refuser tout le pipeline Woodpecker ; la route hôte `/*` de fabio
|
||||
masque le webroot ACME → arrêter le service avant `acme.sh --issue` ; Playwright en locale
|
||||
`fr-FR` (sinon l'interface part en anglais).
|
||||
- **Design** : sans bordures, arrondis 16/12/20/14, quatre ambiances `pierre`, `calcaire`
|
||||
(claires), `nuit`, `ardoise` (sombres) (`data-mood`, posé avant le premier rendu, clé `ld:mood`), logo
|
||||
« Clé de voûte » (`Logo`, jamais incliné, `--cle` par ambiance), sceau hexagramme 井 #48 en SVG (`Seal` —
|
||||
jamais le glyphe, qui s'affiche mal). Mouvement réduit respecté.
|
||||
- **Domaine** : decision.librodrome.org ; dépôt des textes `git.open.us.org/yvv/decision-depot-textes-reference`
|
||||
(provisoire : puis un dépôt pour l'ouverture, puis le dépôt officiel pour les textes en vigueur).
|
||||
|
||||
+52
-80
@@ -1,91 +1,63 @@
|
||||
# Contribuer à libreDecision
|
||||
|
||||
La référence produit est `docs/dev/BLUEPRINT-V2.md` — toute contribution qui touche au
|
||||
comportement doit s'y conformer (ou proposer un amendement du blueprint, argumenté).
|
||||
On travaille **un chantier à la fois**, livré complet et en production avant le suivant
|
||||
(`docs/chantiers/README.md`). Le chantier en cours a sa fiche :
|
||||
`docs/chantiers/acte-certification/CHANTIER.md` — toute décision de comportement s'y adosse,
|
||||
ou propose de l'amender, argumentée.
|
||||
|
||||
## Environnement
|
||||
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
|
||||
# Frontend — suffit pour tout le développement v2 (local-first, aucun serveur requis)
|
||||
cd frontend
|
||||
npm install
|
||||
npm run dev # http://localhost:3002 — port strict, jamais de fallback
|
||||
npm test # vitest — 342 tests
|
||||
npm run build # doit passer sans erreur avant tout commit
|
||||
|
||||
# Backend v1 (optionnel — conservé pour la future synchro)
|
||||
cd backend
|
||||
python -m venv venv && source venv/bin/activate
|
||||
pip install -r requirements.txt
|
||||
export PATH=$HOME/.cargo/bin:$PATH
|
||||
cargo fmt --all --check
|
||||
cargo clippy --workspace --all-targets -- -D warnings
|
||||
cargo test --workspace
|
||||
cd web
|
||||
npm run check # svelte-check, zéro avertissement
|
||||
npm test # vitest : markdown, parité des catalogues i18n, anti-lexique
|
||||
npm run build
|
||||
npm run e2e # Playwright : C1–C12 (bureau) + M1 (mobile), serveur jetable sur :8092
|
||||
```
|
||||
|
||||
L'image Docker rejoue les mêmes portes : un contrôle rouge, pas d'image, pas de déploiement.
|
||||
|
||||
## Conventions
|
||||
|
||||
- **UI en français, code en anglais** (variables, commentaires, docstrings).
|
||||
- **`app/lexicon.ts` est la source unique des libellés français.** Aucun libellé UI en dur
|
||||
dans un composant s'il existe (ou devrait exister) dans le lexicon. Ton : tutoyer la
|
||||
personne dans ses gestes ; actes collectifs à l'infinitif ; le sujet grammatical est
|
||||
toujours la personne ou le collectif — jamais la formule ni le système.
|
||||
- **Test anti-lexique** (`tests/lexicon.spec.ts`) : la liste `FORBIDDEN_UI_TERMS` interdit
|
||||
certains mots dans toute chaîne visible (lexicon + templates `.vue`), insensible à la casse
|
||||
et aux accents. « Triage », « verdict », « déléguer »… peuvent exister comme identifiants de
|
||||
code, jamais à l'écran.
|
||||
- **CSS** : borderless (profondeur via background + box-shadow), palettes par classes
|
||||
`.mood-*` sur `<html>`, composants natifs (`<input>`, `<select>`, `<button>` custom).
|
||||
- **Composants** : `pathPrefix: false` — noms de fichiers uniques et parlants, auto-import.
|
||||
- **Interface en français, code en anglais** (identifiants, commentaires, messages de log).
|
||||
- **Trilingue dès le départ** : toute chaîne affichée vit dans `web/src/lib/i18n/fr.ts`
|
||||
(la source), puis `en.ts` et `es.ts`. Le test de parité refuse une clé manquante ou un
|
||||
paramètre `{x}` qui diffère. Le texte voté est le français ; les traductions sont éditoriales.
|
||||
- **Ton** : tutoyer la personne ; le sujet est la personne ou le collectif, jamais le système.
|
||||
Mots interdits à l'écran (`FORBIDDEN_UI_TERMS`) : « triage », « verdict », « déléguer »…
|
||||
- **Moteur pur** : `crates/ld-engine` n'a ni I/O, ni base, ni réseau. Toute règle de vote,
|
||||
d'édition ou de composition s'y écrit et s'y teste d'abord.
|
||||
- **Accès** : le serveur ne décide jamais seul qui voit quoi — il demande à `trustin-access`.
|
||||
- **Données runtime** (`data/`, volume `lpk-libredecision-app_data`) : jamais écrasées, jamais
|
||||
réinitialisées ; une migration ajoute, elle ne réécrit pas.
|
||||
- **Design** : sans bordures (profondeur par fond et ombre), arrondis 16 / 12 / 20 / 14 px,
|
||||
deux ambiances (pierre, nuit), Literata pour les textes, Nunito pour l'interface.
|
||||
- **Commits** : en français, une intention par commit, `git add` avec chemins explicites.
|
||||
|
||||
## Invariants doctrinaux (non négociables)
|
||||
## Signaler
|
||||
|
||||
- **Jamais de score sur une personne.** Pas de note, pas de réputation, pas d'agrégat
|
||||
individuel. `Person.attributes` ne sont jamais affichés à autrui ni agrégés ; l'historique
|
||||
de re-vote n'est visible que de son auteur.
|
||||
- **Jamais de contenu adopté sans geste humain.** Le moteur constate des seuils sur des
|
||||
contenus formulés par des humains ; la cristallisation paramétrique, la clôture d'un
|
||||
dossier découpé, le départage d'une égalité sont des gestes datés — jamais des automatismes.
|
||||
- **Le repli universel est le consentement** : tout collectif a un protocole Consentement
|
||||
(`protocolByRange.consent`), tout repli de résolution y mène.
|
||||
- **Dérogation asymétrique** : alourdir libre (1 tap journalisé) ; alléger = note obligatoire
|
||||
+ fenêtre d'objection.
|
||||
- **Budget 400 lignes maximum par page** (`app/pages/`). Au-delà, extraire des composants.
|
||||
- **Moteurs purs** : `app/engine/` ne dépend de rien (ni Nuxt, ni Pinia, ni DOM), s'importe
|
||||
uniquement via le barrel `~/engine`, et chaque module a ses tests vitest. Les stores
|
||||
s'écrivent avec des imports explicites (pas d'auto-import Nuxt) pour rester testables sous
|
||||
vitest pur.
|
||||
|
||||
## Ajouter une modalité de vote
|
||||
|
||||
1. `app/types/domain.ts` — étendre `VoteMethod` et les types de session/vote nécessaires.
|
||||
2. `app/engine/` — le calcul dans un module pur + ses tests (`tests/engine/`). Exporter via
|
||||
`engine/index.ts`.
|
||||
3. `app/engine/state.ts` — la règle de clôture (automatique à `closesAt`, ou geste humain).
|
||||
4. `app/lexicon.ts` — les libellés (et vérifier l'anti-lexique).
|
||||
5. `app/components/votes/VoteX.vue` — l'écran de la modalité, branché dans
|
||||
`pages/decisions/[id]/vote.vue`.
|
||||
6. Seeds et gabarits — un protocole nommé qui la référence (le modifier = amender le Pacte).
|
||||
|
||||
## Ajouter un gabarit de collectif
|
||||
|
||||
Dans `app/data/templates.ts` : une entrée `TEMPLATE_CARDS` + le montage dans
|
||||
`buildTemplateBundle()`. Invariants : protocole Consentement au minimum, clause A1
|
||||
(« Notre finalité »), préambule Autonomie–Équilibre–Liaison proposé et modifiable, cercles
|
||||
typés `kind`. Un bundle seed passe par le même `importBundle()` que l'import utilisateur.
|
||||
|
||||
## Tests
|
||||
|
||||
```bash
|
||||
cd frontend && npm test
|
||||
```
|
||||
|
||||
- `tests/engine/` — les moteurs purs (seuils, triage, états, médiane, impact…).
|
||||
- `tests/stores/` — la façade decisions sous Pinia nu.
|
||||
- `tests/seeds/` — tests de non-perte : le bundle Ğ1 doit conserver 33+59 clauses, décomptes
|
||||
et provenance exacts ; l'Atelier du Canal doit couvrir tous les états.
|
||||
- `tests/lexicon.spec.ts` — l'anti-lexique.
|
||||
|
||||
Le compteur de référence est **342 tests verts**. Toute PR qui en casse un est refusée ;
|
||||
toute fonctionnalité de moteur arrive avec les siens.
|
||||
|
||||
## Build
|
||||
|
||||
`npm run build` doit rester vert (zéro erreur, zéro warning nouveau) avant chaque commit.
|
||||
Vérifier mobile + desktop (zéro régression CSS).
|
||||
**« Faire un retour »** (dans le menu de gauche, sur chaque page) envoie une note datée avec la
|
||||
page, la langue et la taille de l'écran ; elles se lisent dans trustinWallet (`/acces`).
|
||||
Pour un bug reproductible, une issue sur la forge avec les étapes.
|
||||
|
||||
Generated
+2752
File diff suppressed because it is too large
Load Diff
+23
@@ -0,0 +1,23 @@
|
||||
[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
@@ -0,0 +1,182 @@
|
||||
# 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`.
|
||||
@@ -0,0 +1,661 @@
|
||||
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,11 +1,10 @@
|
||||
# libreDecision
|
||||
|
||||
Boîte à outils de la première démocratie — décider ensemble, du trio à la population.
|
||||
Rendre possible la décision collective — en fonction du contexte, processus et modalités,
|
||||
boîte à outils.
|
||||
|
||||
libreDecision fait de la décision un geste quotidien : chaque question tient en une phrase,
|
||||
l'outil propose le chemin le plus court — jamais plus lourd que l'enjeu — et tout ce qui est
|
||||
décidé reste révisable, mesuré, consigné. Le même outil sert un trio d'amis, une association,
|
||||
une coopérative ou la toile de confiance Ğ1 (marque blanche par construction).
|
||||
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
|
||||
|
||||
@@ -24,90 +23,95 @@ une coopérative ou la toile de confiance Ğ1 (marque blanche par construction).
|
||||
> 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 6 routes de décision
|
||||
## Les quatre outils
|
||||
|
||||
Le triage (moteur pur, toujours expliqué en une phrase, toujours contournable) oriente chaque
|
||||
décision vers l'une de six routes :
|
||||
| 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 |
|
||||
|
||||
| Route | Geste |
|
||||
|---|---|
|
||||
| **solo** | « Personne d'autre n'est concerné — décide. » 2 gestes, moins de 10 secondes, trace facultative |
|
||||
| **record** | « Déjà tranché — je le consigne. » L'observatoire en un geste, adopté immédiatement |
|
||||
| **mandate** | « Ton mandat couvre — décide, c'est tracé. » Trace obligatoire + fenêtre d'objection 48 h |
|
||||
| **transmit** | « Le mandat d'un autre couvre — transmets-lui. » (ou conteste le mandat) |
|
||||
| **advice** | « Réversible et petit cercle — demande leur avis, puis décide. » 72 h |
|
||||
| **collective** | « Nous décidons. » Formulation, liste des concernés arrêtée, session de vote |
|
||||
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.
|
||||
|
||||
Dérogation asymétrique : alourdir est libre (1 tap journalisé) ; alléger exige une note motivée
|
||||
et une fenêtre d'objection.
|
||||
## Comment marche un doc de référence
|
||||
|
||||
## Les 5 modalités
|
||||
- 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.
|
||||
|
||||
Chaque protocole est nommé et référencé par une clause du Pacte — le modifier, c'est amender
|
||||
le Pacte.
|
||||
|
||||
1. **Consentement** — « Ça me va / J'objecte » : zéro objection maintenue = adopté. Le repli
|
||||
universel : tout collectif en a un (invariant).
|
||||
2. **Nuancé 6 niveaux** — de « Pas du tout » à « Tout à fait », jamais « contre » ;
|
||||
histogramme visible, commentaire obligatoire sur les niveaux bas.
|
||||
3. **Paramétrique** — décider au curseur : faisceau des positions, médiane vivante, carte
|
||||
« Pour moi » ; la cristallisation finale est un geste humain daté du garant, jamais un
|
||||
automatisme.
|
||||
4. **Élection** — réservée aux mandats et aux problématiques d'échelle (6 méthodes de
|
||||
nomination, vote blanc, jamais de départage par l'outil).
|
||||
5. **Binaire inertiel** — le seuil `C + B^W + (M + (1−M)(1−(T/W)^G))·max(0, T−C)`, héritage
|
||||
Ğ1 remis à sa place : l'outil de dernier recours des très grands corps. L'Atelier des
|
||||
formules (`/textes/formules`) reste son école.
|
||||
|
||||
## Local-first
|
||||
|
||||
- **Aucun serveur requis** : tout l'état vit dans le navigateur (IndexedDB via idb-keyval,
|
||||
une clé par collectif).
|
||||
- **Export / import** de bundles JSON — la fédération, c'est l'échange de bundles, jamais une
|
||||
table.
|
||||
- **Seeds** livrés et importés par le même chemin que l'import utilisateur :
|
||||
- `duniter-g1.bundle.json` — la toile Ğ1 : 33 clauses Certification + 59 clauses Forgeron
|
||||
(textes votés intacts, provenance et décomptes conservés), protocoles, 3 mandats ;
|
||||
- `atelier-du-canal.bundle.json` — association fictive de 12 membres, le test permanent de
|
||||
marque blanche : décisions à tous les états, paramétrique vivant, épreuve du réel due.
|
||||
- **7 gabarits** de collectif à la création (`/creer`) : page blanche, informel, association,
|
||||
coopérative, commune, monnaie libre, institution symétrique.
|
||||
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
|
||||
|
||||
- **Frontend** : Nuxt 4 (SPA, SSR off) + Vue 3 + TypeScript + Nuxt UI v3 + Pinia + UnoCSS ;
|
||||
npm ; port **3002**
|
||||
- **Moteurs purs** : `frontend/app/engine/` — triage, machine à états, seuils, médiane
|
||||
paramétrique, nuancé, périmètres — zéro dépendance Nuxt, testés vitest (**342 tests**)
|
||||
- **Backend v1** (FastAPI + SQLAlchemy async) : conservé dans `backend/` pour la future
|
||||
synchronisation multi-appareils — non requis pour utiliser ou développer le frontend
|
||||
- **Serveur** : Rust (edition 2024, toolchain 1.99 épinglée) — axum 0.8, sqlx/SQLite, tokio.
|
||||
Trois crates : `ld-engine` (moteur pur : seuils, édition, assistant, composition),
|
||||
`trustin-access` (règles d'accès de trustinWallet), `ld-server` (API + publication + service
|
||||
de l'interface).
|
||||
- **Interface** : SvelteKit 2 / Svelte 5 (runes), SPA statique servie par le serveur ;
|
||||
trilingue fr · en · es dès le départ (le français fait foi).
|
||||
- **Déploiement** : une seule image Docker, Woodpecker → sonic (fabio), volume de données nommé.
|
||||
|
||||
## Démarrage
|
||||
## Démarrer
|
||||
|
||||
```bash
|
||||
cd frontend
|
||||
npm install
|
||||
npm run dev # http://localhost:3002
|
||||
npm test # vitest — 342 tests
|
||||
npm run build # build de production
|
||||
export PATH=$HOME/.cargo/bin:$PATH
|
||||
cd web && npm ci && npm run build && cd .. # l'interface compilée
|
||||
LD_DEV_LOGIN=1 cargo run -p ld-server # API + interface sur :8002
|
||||
cd web && npm run dev # interface à chaud sur :3002 (proxy /api → :8002)
|
||||
```
|
||||
|
||||
## Structure v2
|
||||
`LD_DEV_LOGIN=1` ouvre une connexion de développement (sans clé, membre de tous les groupes) —
|
||||
jamais en production. Toutes les variables : [`.env.example`](.env.example).
|
||||
|
||||
## Vérifier (la boucle)
|
||||
|
||||
```bash
|
||||
cargo fmt --all --check && cargo clippy --workspace --all-targets -- -D warnings && cargo test --workspace
|
||||
cd web && npm run check && npm test && npm run build && npm run e2e
|
||||
```
|
||||
|
||||
Les contrôles d'usage (C1–C12, M1) sont décrits dans
|
||||
[`docs/chantiers/acte-certification/CONTROLES.md`](docs/chantiers/acte-certification/CONTROLES.md) ;
|
||||
les tests de Chiara en sont la version humaine.
|
||||
|
||||
## Structure
|
||||
|
||||
```
|
||||
frontend/app/
|
||||
engine/ # moteurs purs (barrel index.ts — seul point d'import)
|
||||
data/ # persistence.ts (IndexedDB), templates.ts (7 gabarits), seeds/*.bundle.json
|
||||
stores/ # collective.ts (le tenant) + decisions.ts (façade d'actions)
|
||||
composables/ # useMood, useFeed, useSearch
|
||||
components/ # par domaine : chemin/ decisions/ votes/ texts/ mandates/ feed/ onboarding/ common/
|
||||
pages/ # 14 routes : / decider decisions(+id, vote, observatoire)
|
||||
# textes(+slug, formules) mandats(+id, nouveau) creer donnees
|
||||
lexicon.ts # source unique des libellés FR + test anti-lexique
|
||||
types/domain.ts # le noyau du modèle
|
||||
docs/dev/BLUEPRINT-V2.md # la référence produit
|
||||
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. Le fichier LICENSE sera posé avant la première publication.
|
||||
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`.
|
||||
|
||||
@@ -0,0 +1,185 @@
|
||||
# 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.
|
||||
@@ -0,0 +1,139 @@
|
||||
# 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.
|
||||
@@ -0,0 +1,132 @@
|
||||
# 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.
|
||||
@@ -0,0 +1,240 @@
|
||||
# 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.
|
||||
@@ -0,0 +1,159 @@
|
||||
# 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.
|
||||
@@ -0,0 +1,249 @@
|
||||
# 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).
|
||||
@@ -0,0 +1,177 @@
|
||||
# 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.
|
||||
@@ -0,0 +1,199 @@
|
||||
# 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.
|
||||
@@ -0,0 +1,264 @@
|
||||
# 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).
|
||||
@@ -0,0 +1,182 @@
|
||||
# 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.
|
||||
@@ -0,0 +1,341 @@
|
||||
# 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.
|
||||
@@ -0,0 +1,273 @@
|
||||
# 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)
|
||||
@@ -0,0 +1,19 @@
|
||||
[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
|
||||
@@ -0,0 +1,185 @@
|
||||
//! Ğ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}");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,232 @@
|
||||
//! 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 { .. })));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,487 @@
|
||||
//! 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)));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,387 @@
|
||||
//! 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));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,428 @@
|
||||
//! 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"));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,223 @@
|
||||
//! 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);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,642 @@
|
||||
//! Edit classification — a typo fix is not a decision.
|
||||
//!
|
||||
//! Once a proposal is under vote, its author may still correct it. The engine
|
||||
//! tells two kinds of corrections apart:
|
||||
//!
|
||||
//! - **typographic**: letters and spaces only — accents, case, the style of
|
||||
//! apostrophes and quotes, words glued or split, or at most two misspelt
|
||||
//! words fixed by a single slip each (one letter added, removed or swapped
|
||||
//! in the middle of the word, never on a negation or a quantity). Never a
|
||||
//! punctuation mark, a figure, a sign, an emphasis, a formula or an address.
|
||||
//! The votes stay; the correction is shown with its diff.
|
||||
//! - **substantive**: anything else. It opens a new revision and every vote
|
||||
//! cast on the previous one waits for its author to reconfirm it.
|
||||
//!
|
||||
//! The rule is deliberately conservative: when in doubt, it is substantive.
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
use unicode_normalization::UnicodeNormalization;
|
||||
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "lowercase")]
|
||||
pub enum EditKind {
|
||||
Unchanged,
|
||||
Typographic,
|
||||
Substantive,
|
||||
}
|
||||
|
||||
/// Words whose presence flips a meaning; touching one is never a typo.
|
||||
const NEGATIONS: &[&str] = &[
|
||||
// fr
|
||||
"ne", "n", "pas", "jamais", "non", "sans", "aucun", "aucune", "ni", "plus", "rien", "nul", "nulle", "personne",
|
||||
"guere", "point", // en
|
||||
"not", "no", "never", "without", "none", "nor", "nothing", "nobody", "nowhere", "neither", // es
|
||||
"nunca", "sin", "ningun", "ninguna", "ninguno", "tampoco", "nada", "nadie", "jamas", "ni",
|
||||
];
|
||||
|
||||
/// Words that carry a quantity, a scope or a relation (folded); changing one is
|
||||
/// never a typo.
|
||||
#[rustfmt::skip]
|
||||
const QUANTITIES: &[&str] = &[
|
||||
// fr
|
||||
"un", "une", "deux", "trois", "quatre", "cinq", "six", "sept", "huit", "neuf", "dix", "onze", "douze", "treize",
|
||||
"quatorze", "quinze", "seize", "vingt", "trente", "quarante", "cinquante", "soixante", "septante", "huitante",
|
||||
"octante", "nonante", "cent", "cents", "mille", "million", "millions", "milliard", "milliards", "premier",
|
||||
"premiere", "premiers", "premieres", "second", "seconde", "deuxieme", "troisieme", "dernier", "derniere", "double",
|
||||
"triple", "moitie", "tiers", "quart", "moins", "minimum", "maximum", "majorite", "minorite", "unanimite", "tous",
|
||||
"toutes", "tout", "toute", "chaque", "seul", "seule", "seuls", "seules", "dessus", "dessous", "superieur",
|
||||
"superieure", "inferieur", "inferieure", "avant", "apres", "sous", "entre", "hors", "sauf", "inclus", "exclus",
|
||||
// en
|
||||
"one", "two", "three", "four", "five", "six", "seven", "eight", "nine", "ten", "eleven", "twelve", "twenty",
|
||||
"thirty", "forty", "fifty", "sixty", "hundred", "thousand", "million", "first", "second", "third", "last", "half",
|
||||
"twice", "less", "more", "least", "most", "majority", "minority", "all", "every", "each", "only", "above", "below",
|
||||
"over", "under", "before", "after", "within", "except",
|
||||
// es
|
||||
"uno", "dos", "tres", "cuatro", "cinco", "seis", "siete", "ocho", "nueve", "diez", "once", "doce", "veinte",
|
||||
"treinta", "cuarenta", "cincuenta", "cien", "ciento", "mil", "millon", "primero", "primera", "segundo", "segunda",
|
||||
"ultimo", "ultima", "mitad", "doble", "menos", "mas", "minimo", "maximo", "mayoria", "minoria", "todos", "todas",
|
||||
"todo", "toda", "cada", "solo", "sola", "encima", "debajo", "antes", "despues", "sobre", "bajo", "excepto",
|
||||
];
|
||||
|
||||
const MAX_TYPO_WORDS: usize = 2;
|
||||
/// Shorter words have too many neighbours one letter away ("can" / "cant").
|
||||
const MIN_TYPO_WORD: usize = 5;
|
||||
/// Letters at each end of a word that a slip never touches: the start carries
|
||||
/// prefixes ("valide" / "invalide"), the end carries number, gender, tense and
|
||||
/// mood ("certifierai" / "certifierais", "será" / "sería").
|
||||
const FIXED_ENDS: usize = 2;
|
||||
|
||||
/// Lowercase, strip diacritics.
|
||||
fn fold(s: &str) -> String {
|
||||
s.nfd().filter(|c| !is_combining(*c)).flat_map(char::to_lowercase).collect()
|
||||
}
|
||||
|
||||
fn is_combining(c: char) -> bool {
|
||||
matches!(c as u32, 0x0300..=0x036F | 0x1AB0..=0x1AFF | 0x1DC0..=0x1DFF | 0x20D0..=0x20FF | 0xFE20..=0xFE2F)
|
||||
}
|
||||
|
||||
/// Letters and digits only, folded: what survives every typographic change.
|
||||
fn skeleton(s: &str) -> String {
|
||||
fold(s).chars().filter(|c| c.is_alphanumeric()).collect()
|
||||
}
|
||||
|
||||
/// A letter of the Latin alphabet as French, English and Spanish write it,
|
||||
/// precomposed accents included (after NFC, Latin-1 and Latin Extended-A): the
|
||||
/// only letters a typo fix may touch. Any other letter may be a look-alike
|
||||
/// (Cyrillic « а », « ǃ » for « ! », « Ʒ » for « 3 ») or reorder the display
|
||||
/// (Hebrew, Arabic): it is kept as it is.
|
||||
fn is_latin_letter(c: char) -> bool {
|
||||
c.is_alphabetic() && matches!(c as u32, 0x41..=0x5A | 0x61..=0x7A | 0xC0..=0x17F)
|
||||
}
|
||||
|
||||
/// The only spaces free to change between words; any other (carriage return,
|
||||
/// tab, hair, thin, ogham…) is kept as it is, and so are line breaks and the
|
||||
/// indentation that opens a line: in Markdown they make lists and paragraphs.
|
||||
const FREE_SPACES: &[char] = &[' ', '\u{a0}', '\u{202f}'];
|
||||
|
||||
/// Invisible characters: the Unicode default-ignorable set (zero-width,
|
||||
/// bidirectional overrides and isolates, soft hyphen, variation selectors,
|
||||
/// tags, byte order mark…) and control characters other than line breaks and
|
||||
/// tabs (an escape sequence rewrites a terminal). They can make "12" display as
|
||||
/// "21" ("Trojan Source") or hide text in the published files. A text of
|
||||
/// reference never needs them; they are refused.
|
||||
pub fn has_hidden_controls(s: &str) -> bool {
|
||||
s.chars().any(|c| {
|
||||
(c.is_control() && !matches!(c, '\n' | '\r' | '\t'))
|
||||
|| matches!(
|
||||
c as u32,
|
||||
0x00AD
|
||||
| 0x034F
|
||||
| 0x061C
|
||||
| 0x115F..=0x1160
|
||||
| 0x17B4..=0x17B5
|
||||
| 0x180B..=0x180F
|
||||
| 0x200B..=0x200F
|
||||
| 0x202A..=0x202E
|
||||
| 0x2060..=0x206F
|
||||
| 0x3164
|
||||
| 0xFE00..=0xFE0F
|
||||
| 0xFEFF
|
||||
| 0xFFA0
|
||||
| 0xFFF0..=0xFFF8
|
||||
| 0x1BCA0..=0x1BCA3
|
||||
| 0x1D173..=0x1D17A
|
||||
| 0xE0000..=0xE0FFF
|
||||
)
|
||||
})
|
||||
}
|
||||
|
||||
/// Apostrophes and quotation marks of every style are one and the same sign:
|
||||
/// « Je m'engage » → « Je m’engage » is typography.
|
||||
fn same_mark(c: char) -> char {
|
||||
match c {
|
||||
'’' | '‘' | 'ʼ' => '\'',
|
||||
'“' | '”' | '«' | '»' => '"',
|
||||
other => other,
|
||||
}
|
||||
}
|
||||
|
||||
/// The shape of a text: every run of Latin letters becomes one mark, free spaces
|
||||
/// vanish, and **everything else stays as it is, in order** — punctuation,
|
||||
/// figures, signs, emphasis, brackets, look-alikes, other alphabets. A typo fix
|
||||
/// keeps the shape: it touches letters and spaces only (2026-10-21, after six
|
||||
/// independent reviews showed that any free punctuation can carry a meaning:
|
||||
/// a minus sign before a bold number, a list merged into one condition…).
|
||||
fn shape(s: &str) -> String {
|
||||
let cs: Vec<char> = s.nfc().collect();
|
||||
let is_figure = |i: Option<usize>| i.and_then(|i| cs.get(i)).is_some_and(|c| c.is_numeric());
|
||||
let mut out = String::new();
|
||||
let mut in_word = false;
|
||||
let mut line_start = true;
|
||||
for (i, &c) in cs.iter().enumerate() {
|
||||
if c == '\n' || (line_start && c.is_whitespace()) {
|
||||
// Line breaks and indentation are structure: « - a\n- b » is a list.
|
||||
out.push(c);
|
||||
in_word = false;
|
||||
line_start = true;
|
||||
continue;
|
||||
}
|
||||
line_start = false;
|
||||
if is_latin_letter(c) {
|
||||
if !in_word {
|
||||
out.push('a');
|
||||
}
|
||||
in_word = true;
|
||||
} else if FREE_SPACES.contains(&c) && !is_figure(i.checked_sub(1)) && !is_figure(Some(i + 1)) {
|
||||
// A space between letters may come and go: « latransmission » and
|
||||
// « la transmission » share a shape (segmentation is checked apart).
|
||||
// Next to a figure it stays: « 1 5 » is not « 15 ».
|
||||
} else {
|
||||
out.push(same_mark(c));
|
||||
in_word = false;
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// What stands between backticks: formulas and identifiers, compared exactly.
|
||||
fn code_spans(s: &str) -> Vec<&str> {
|
||||
s.split('`').skip(1).step_by(2).collect()
|
||||
}
|
||||
|
||||
/// Addresses (links, domains, mails, paths) and identifiers that hold a figure
|
||||
/// (keys, fingerprints, references): compared exactly, case included.
|
||||
fn exact_tokens(s: &str) -> Vec<String> {
|
||||
s.split_whitespace()
|
||||
.map(|t| t.trim_matches(|c: char| !c.is_alphanumeric()))
|
||||
.filter(|t| {
|
||||
let cs: Vec<char> = t.chars().collect();
|
||||
t.contains("://")
|
||||
|| t.contains('@')
|
||||
|| cs.iter().any(|c| c.is_numeric()) && cs.iter().any(|c| c.is_alphabetic())
|
||||
|| (1..cs.len().saturating_sub(1))
|
||||
.any(|i| matches!(cs[i], '.' | '/') && cs[i - 1].is_alphanumeric() && cs[i + 1].is_alphanumeric())
|
||||
})
|
||||
.map(str::to_owned)
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// A single slip of the keyboard inside a word: one letter added, removed, or
|
||||
/// two neighbours swapped — never at either end (see `FIXED_ENDS`), and never a
|
||||
/// letter replaced by another ("deux" / "dix", "majorité" / "minorité").
|
||||
fn one_slip(x: &str, y: &str) -> bool {
|
||||
let (a, b): (Vec<char>, Vec<char>) = (x.chars().collect(), y.chars().collect());
|
||||
if a.len().min(b.len()) < MIN_TYPO_WORD {
|
||||
return false;
|
||||
}
|
||||
let inside = |i: usize, len: usize| i >= FIXED_ENDS && i + FIXED_ENDS < len;
|
||||
match a.len().abs_diff(b.len()) {
|
||||
1 => {
|
||||
let (short, long) = if a.len() < b.len() { (&a, &b) } else { (&b, &a) };
|
||||
(0..long.len()).any(|i| {
|
||||
let mut l = long.clone();
|
||||
l.remove(i);
|
||||
inside(i, long.len()) && &l == short
|
||||
})
|
||||
}
|
||||
0 => {
|
||||
let at: Vec<usize> = (0..a.len()).filter(|&i| a[i] != b[i]).collect();
|
||||
at.len() == 2
|
||||
&& at[1] == at[0] + 1
|
||||
&& a[at[0]] == b[at[1]]
|
||||
&& a[at[1]] == b[at[0]]
|
||||
&& inside(at[0], a.len())
|
||||
&& inside(at[1], a.len())
|
||||
}
|
||||
_ => false,
|
||||
}
|
||||
}
|
||||
|
||||
/// Folded words (runs of letters/digits).
|
||||
fn words(s: &str) -> Vec<String> {
|
||||
fold(s).split(|c: char| !c.is_alphanumeric()).filter(|w| !w.is_empty()).map(str::to_owned).collect()
|
||||
}
|
||||
|
||||
/// Same letters, at most two words glued or split, never a negation:
|
||||
/// « latransmission » → « la transmission ». A boundary moved is not a typo
|
||||
/// (« les avoir » → « le savoir »), nor « Si non » → « Sinon ».
|
||||
fn only_glued_or_split(a: &[String], b: &[String]) -> bool {
|
||||
// Neither the word glued or split nor its pieces may be a negation or a
|
||||
// quantity (« ja mais », « sur tout » → « surtout », « par tout » → « partout »).
|
||||
let joined = |w: &str, x: &str, y: &str| ![w, x, y].iter().any(|t| NEGATIONS.contains(t) || QUANTITIES.contains(t));
|
||||
let (mut i, mut j, mut moves) = (0, 0, 0);
|
||||
while i < a.len() && j < b.len() {
|
||||
if a[i] == b[j] {
|
||||
(i, j) = (i + 1, j + 1);
|
||||
continue;
|
||||
}
|
||||
if j + 1 < b.len() && a[i] == format!("{}{}", b[j], b[j + 1]) && joined(&a[i], &b[j], &b[j + 1]) {
|
||||
(i, j) = (i + 1, j + 2);
|
||||
} else if i + 1 < a.len() && b[j] == format!("{}{}", a[i], a[i + 1]) && joined(&b[j], &a[i], &a[i + 1]) {
|
||||
(i, j) = (i + 2, j + 1);
|
||||
} else {
|
||||
return false;
|
||||
}
|
||||
moves += 1;
|
||||
if moves > MAX_TYPO_WORDS {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
i == a.len() && j == b.len()
|
||||
}
|
||||
|
||||
/// Classify `new` against `old`. For a text under vote, `old` must be the text
|
||||
/// that was **voted** (its last initial or substantive revision), not the
|
||||
/// current one: typo fixes are measured together, so slips never add up.
|
||||
pub fn classify(old: &str, new: &str) -> EditKind {
|
||||
if old == new {
|
||||
return EditKind::Unchanged;
|
||||
}
|
||||
if has_hidden_controls(new)
|
||||
|| shape(old) != shape(new)
|
||||
|| code_spans(old) != code_spans(new)
|
||||
|| exact_tokens(old) != exact_tokens(new)
|
||||
{
|
||||
return EditKind::Substantive;
|
||||
}
|
||||
let (a, b) = (words(old), words(new));
|
||||
if skeleton(old) == skeleton(new) {
|
||||
// Accents, case, apostrophes; words glued or split, never re-cut.
|
||||
return if only_glued_or_split(&a, &b) { EditKind::Typographic } else { EditKind::Substantive };
|
||||
}
|
||||
if a.len() != b.len() {
|
||||
return EditKind::Substantive;
|
||||
}
|
||||
let mut changed = 0;
|
||||
for (x, y) in a.iter().zip(&b) {
|
||||
if x == y {
|
||||
continue;
|
||||
}
|
||||
changed += 1;
|
||||
let touches_meaning = changed > MAX_TYPO_WORDS
|
||||
|| x.chars().any(char::is_numeric)
|
||||
|| y.chars().any(char::is_numeric)
|
||||
|| [x, y].iter().any(|w| NEGATIONS.contains(&w.as_str()) || QUANTITIES.contains(&w.as_str()))
|
||||
|| !one_slip(x, y);
|
||||
if touches_meaning {
|
||||
return EditKind::Substantive;
|
||||
}
|
||||
}
|
||||
EditKind::Typographic
|
||||
}
|
||||
|
||||
pub fn levenshtein(a: &str, b: &str) -> usize {
|
||||
let b: Vec<char> = b.chars().collect();
|
||||
let mut prev: Vec<usize> = (0..=b.len()).collect();
|
||||
let mut cur = vec![0; b.len() + 1];
|
||||
for (i, ca) in a.chars().enumerate() {
|
||||
cur[0] = i + 1;
|
||||
for (j, cb) in b.iter().enumerate() {
|
||||
let cost = usize::from(ca != *cb);
|
||||
cur[j + 1] = (prev[j] + cost).min(prev[j + 1] + 1).min(cur[j] + 1);
|
||||
}
|
||||
std::mem::swap(&mut prev, &mut cur);
|
||||
}
|
||||
prev[b.len()]
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "lowercase")]
|
||||
pub enum DiffOp {
|
||||
Same,
|
||||
Added,
|
||||
Removed,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub struct DiffPart {
|
||||
pub op: DiffOp,
|
||||
pub text: String,
|
||||
}
|
||||
|
||||
/// Tokens that keep every character: words, and the runs between them.
|
||||
fn tokens(s: &str) -> Vec<&str> {
|
||||
let mut out = Vec::new();
|
||||
let mut start = 0;
|
||||
let mut in_word: Option<bool> = None;
|
||||
for (i, c) in s.char_indices() {
|
||||
let w = c.is_alphanumeric();
|
||||
if in_word.is_some_and(|prev| prev != w) {
|
||||
out.push(&s[start..i]);
|
||||
start = i;
|
||||
}
|
||||
in_word = Some(w);
|
||||
}
|
||||
if start < s.len() {
|
||||
out.push(&s[start..]);
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
const MAX_DIFF_TOKENS: usize = 4000;
|
||||
|
||||
/// Word-level diff for display. Concatenating `Same` + `Added` gives `new`,
|
||||
/// `Same` + `Removed` gives `old`.
|
||||
pub fn diff(old: &str, new: &str) -> Vec<DiffPart> {
|
||||
let (a, b) = (tokens(old), tokens(new));
|
||||
if a.len() * b.len() > MAX_DIFF_TOKENS * MAX_DIFF_TOKENS / 4 {
|
||||
return vec![
|
||||
DiffPart { op: DiffOp::Removed, text: old.to_owned() },
|
||||
DiffPart { op: DiffOp::Added, text: new.to_owned() },
|
||||
];
|
||||
}
|
||||
// LCS table, suffix form.
|
||||
let (n, m) = (a.len(), b.len());
|
||||
let mut lcs = vec![vec![0u32; m + 1]; n + 1];
|
||||
for i in (0..n).rev() {
|
||||
for j in (0..m).rev() {
|
||||
lcs[i][j] = if a[i] == b[j] { lcs[i + 1][j + 1] + 1 } else { lcs[i + 1][j].max(lcs[i][j + 1]) };
|
||||
}
|
||||
}
|
||||
let mut parts: Vec<DiffPart> = Vec::new();
|
||||
let mut push = |op: DiffOp, text: &str| match parts.last_mut() {
|
||||
Some(last) if last.op == op => last.text.push_str(text),
|
||||
_ => parts.push(DiffPart { op, text: text.to_owned() }),
|
||||
};
|
||||
let (mut i, mut j) = (0, 0);
|
||||
while i < n && j < m {
|
||||
if a[i] == b[j] {
|
||||
push(DiffOp::Same, a[i]);
|
||||
i += 1;
|
||||
j += 1;
|
||||
} else if lcs[i + 1][j] >= lcs[i][j + 1] {
|
||||
push(DiffOp::Removed, a[i]);
|
||||
i += 1;
|
||||
} else {
|
||||
push(DiffOp::Added, b[j]);
|
||||
j += 1;
|
||||
}
|
||||
}
|
||||
for t in &a[i..] {
|
||||
push(DiffOp::Removed, t);
|
||||
}
|
||||
for t in &b[j..] {
|
||||
push(DiffOp::Added, t);
|
||||
}
|
||||
parts
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn the_glued_word_of_the_seed_is_a_typo() {
|
||||
let old = "Toute Certification doit s'accompagner de latransmission de ce document";
|
||||
let new = "Toute Certification doit s'accompagner de la transmission de ce document";
|
||||
assert_eq!(classify(old, new), EditKind::Typographic);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn glued_or_split_words_stay_few_and_never_touch_a_negation() {
|
||||
// Seventh independent review, 2026-10-21: a text in force disfigured at will.
|
||||
for (old, new) in [
|
||||
("à ne certifier que", "à necertifier que"),
|
||||
("à ne certifier que", "àne certifier que"),
|
||||
("Si non, la demande attend", "Sinon, la demande attend"),
|
||||
("la transmission de ce document à chacun", "latransmission dece documentà chacun"),
|
||||
] {
|
||||
assert_eq!(classify(old, new), EditKind::Substantive, "{old} → {new}");
|
||||
}
|
||||
assert_eq!(
|
||||
classify("de latransmission dece document", "de la transmission de ce document"),
|
||||
EditKind::Typographic
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_negation_or_a_quantity_is_never_cut_nor_formed() {
|
||||
// Eighth independent review, 2026-10-21: the pieces were checked, not the word.
|
||||
for (old, new) in [
|
||||
("n'avoir jamais qu'un seul compte", "n'avoir ja mais qu'un seul compte"),
|
||||
("aucun compte", "au cun compte"),
|
||||
("sans retour", "san s retour"),
|
||||
("rien de plus", "ri en de plus"),
|
||||
("nowhere else", "now here else"),
|
||||
// Ninth review: a quantity glued changes a scope.
|
||||
("Je vérifie sur tout le territoire", "Je vérifie surtout le territoire"),
|
||||
("par tout membre", "partout membre"),
|
||||
] {
|
||||
assert_eq!(classify(old, new), EditKind::Substantive, "{old} → {new}");
|
||||
assert_eq!(classify(new, old), EditKind::Substantive, "{new} → {old}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn line_breaks_and_indentation_are_structure() {
|
||||
for (old, new) in [
|
||||
("- une garantie\n- une autre", "- une garantie - une autre"),
|
||||
("- une garantie\n - une précision", "- une garantie\n- une précision"),
|
||||
("la\ntransmission", "la transmission"),
|
||||
] {
|
||||
assert_eq!(classify(old, new), EditKind::Substantive, "{old:?} → {new:?}");
|
||||
}
|
||||
assert_eq!(
|
||||
classify("- une garantie\n- latransmission", "- une garantie\n- la transmission"),
|
||||
EditKind::Typographic
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn odd_spaces_and_look_alike_letters_are_kept() {
|
||||
assert_eq!(classify("un texte\tde référence", "un texte de référence"), EditKind::Substantive);
|
||||
assert_eq!(classify("un texte\rde référence", "un texte de référence"), EditKind::Substantive);
|
||||
assert_eq!(classify("toute certification", "toute certiǃfication"), EditKind::Substantive);
|
||||
assert_eq!(classify("toute certification", "toute certifiƷcation"), EditKind::Substantive);
|
||||
assert_eq!(classify("le coeur de l'ŒUVRE, niño", "le Coeur de l'œuvre, Niño"), EditKind::Typographic);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn accents_case_apostrophes_quotes() {
|
||||
assert_eq!(classify("Je m'engage a verifier.", "Je m’engage à vérifier."), EditKind::Typographic);
|
||||
assert_eq!(classify("le \"Pacte\" de la monnaie", "le « Pacte » de la Monnaie"), EditKind::Typographic);
|
||||
assert_eq!(classify("same", "same"), EditKind::Unchanged);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn punctuation_and_emphasis_go_through_a_proposal() {
|
||||
// Sixth independent review: free punctuation can always carry a meaning.
|
||||
for (old, new) in [
|
||||
("Je m'engage à vérifier.", "Je m'engage à vérifier !"),
|
||||
("Je m'engage à vérifier.", "Je m'engage à vérifier ?"),
|
||||
("un **être humain**", "un être humain"),
|
||||
("tous les **5 jours**", "tous les -**5 jours**"),
|
||||
("Seuil = C + B^W", "Seuil = C + B^-W"),
|
||||
("max(0, T-C)", "max(0, TC)"),
|
||||
("(Y = ceil(N))", "(Y = -ceil(N))"),
|
||||
("- avoir un compte\n- être joignable", "- avoir un compte, être joignable"),
|
||||
("(sauf le cas a) et le cas b", "(sauf le cas a et le cas b)"),
|
||||
("libre - le texte", "libre — le texte"),
|
||||
("- une liste", "* une liste"),
|
||||
("clé DsEx1pS33abc", "clé dSeX1Ps33abc"),
|
||||
("les avoir vérifiés", "le savoir vérifiés"),
|
||||
] {
|
||||
assert_eq!(classify(old, new), EditKind::Substantive, "{old} → {new}");
|
||||
}
|
||||
assert_eq!(classify("toute latransmission", "toute la transmission"), EditKind::Typographic);
|
||||
assert_eq!(classify("la trans mission", "la transmission"), EditKind::Typographic);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_figure_a_sign_or_a_relation_is_never_a_typo() {
|
||||
// Same letters, different meaning (independent reviews, 2026-10-21).
|
||||
for (old, new) in [
|
||||
("au moins 50 voix", "au moins 5,0 voix"),
|
||||
("1 5 jours", "15 jours"),
|
||||
("10 000 membres", "10,000 membres"),
|
||||
("un écart de -5", "un écart de 5"),
|
||||
("1/5 des voix", "1.5 des voix"),
|
||||
("50 % des voix", "50 ‰ des voix"),
|
||||
("avoir >= 5 certifications", "avoir <= 5 certifications"),
|
||||
("DU(n) + c² × M(n)", "DU(n) - c² × M(n)"),
|
||||
("182.625 jours", "182 625 jours"),
|
||||
("c²", "c2"),
|
||||
] {
|
||||
assert_eq!(classify(old, new), EditKind::Substantive, "{old} → {new}");
|
||||
}
|
||||
assert_eq!(classify("50 voix pour", "50 voix Pour"), EditKind::Typographic);
|
||||
// A bracket always counts, even as a list marker: conservative.
|
||||
assert_eq!(classify("1. premier point", "1) premier point"), EditKind::Substantive);
|
||||
assert_eq!(classify("1. premier point", "1. Premier point"), EditKind::Typographic);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_word_turned_around_is_never_a_typo() {
|
||||
// Third independent review, 2026-10-21: one or two letters that invert a meaning.
|
||||
for (old, new) in [
|
||||
("ces deux présents engagements", "ces dix présents engagements"),
|
||||
("Nouveau compte valide si", "Nouveau compte invalide si"),
|
||||
("100 certifications possibles", "100 certifications impossibles"),
|
||||
("à la majorité des voix", "à la minorité des voix"),
|
||||
("un minimum de garanties", "un maximum de garanties"),
|
||||
("les membres inclus", "les membres exclus"),
|
||||
("une personne majeure", "une personne mineure"),
|
||||
("une durée limitée", "une durée illimitée"),
|
||||
("un mandat révocable", "un mandat irrévocable"),
|
||||
("des comptes indépendants", "des comptes dépendants"),
|
||||
("pendant six mois", "pendant dix mois"),
|
||||
("sept certifications", "cent certifications"),
|
||||
("seize jours", "treize jours"),
|
||||
("five members", "nine members"),
|
||||
("you can certify", "you cant certify"),
|
||||
("Y = ceil(N^(1/5))", "Y = ceil(N^1/5)"),
|
||||
("M(n)/N(n)", "M(n)N(n)"),
|
||||
("DU(n) - c", "DU(n) / c"),
|
||||
("la formule `a - b`", "la formule `a * b`"),
|
||||
// Fourth independent review: look-alikes, overlays, addresses, endings.
|
||||
("au moins 50 voix", "au moins 5\u{201a}0 voix"),
|
||||
("**12 mois**", "**1\u{201a}2 mois**"),
|
||||
("15 jours", "1\u{2044}5 jours"),
|
||||
("2 ans", "\u{ff0d}2 ans"),
|
||||
(">= 5", "\u{226f}= 5"),
|
||||
("a = b", "a \u{2260} b"),
|
||||
("a = b", "a =\u{0338} b"),
|
||||
("ne jamais certifier", "ne j\u{0336}a\u{0336}m\u{0336}a\u{0336}i\u{0336}s\u{0336} certifier"),
|
||||
("la clé est vérifiée", "la clé est ~~vérifiée~~"),
|
||||
("une personne connue", "une personne conn\u{05d5}ue"),
|
||||
("une personne connue", "une personne c\u{043e}nnue"),
|
||||
(
|
||||
"https://git.duniter.org/documents/g1_monetary_license",
|
||||
"https://git.dunniter.org/documents/g1_monetary_license",
|
||||
),
|
||||
("au-dessus de 50 %", "au-dessous de 50 %"),
|
||||
("je certifierai", "je certifierais"),
|
||||
("será válido", "sería válido"),
|
||||
("they must certify", "they mustn certify"),
|
||||
// Fifth independent review: anything touching a figure, special spaces, signs across spaces.
|
||||
("au moins 50 voix", "au moins 5,\u{202f}0 voix"),
|
||||
("**12 mois**", "**1,\u{202f}2 mois**"),
|
||||
("100 certifications", "10,\u{200a}0 certifications"),
|
||||
("80%", "8,\u{200a}0%"),
|
||||
("15 jours", "1:5 jours"),
|
||||
("15 jours", "1…5 jours"),
|
||||
("15 jours", "1*5 jours"),
|
||||
("15 jours", "1;5 jours"),
|
||||
("15 jours", "1‘5 jours"),
|
||||
("un écart de 5", "un écart de -\u{200a}5"),
|
||||
("un écart de 5", "un écart de - 5"),
|
||||
("un écart de 5", "un écart de \u{1680}5"),
|
||||
] {
|
||||
assert_eq!(classify(old, new), EditKind::Substantive, "{old} → {new}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn hidden_controls_are_spotted() {
|
||||
assert!(has_hidden_controls("**\u{202e}12\u{202c} mois**"));
|
||||
assert!(has_hidden_controls("5\u{200b}0"));
|
||||
assert!(!has_hidden_controls("Je m’engage — « à 50 % »."));
|
||||
assert!(!has_hidden_controls("une ligne\nune autre\tavec tabulation"));
|
||||
// The whole default-ignorable set and the control characters (eighth review).
|
||||
for hidden in ["\u{ad}", "\u{180e}", "\u{fe0f}", "\u{e0041}", "\u{206e}", "\u{1b}[2J", "\u{7f}", "\u{85}"] {
|
||||
assert!(has_hidden_controls(&format!("cer{hidden}tification")), "{hidden:?}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn misspelt_words() {
|
||||
// The end of a word carries number and tense: a plural fixed is a revision.
|
||||
assert_eq!(classify("un certifieurs existant", "un certifieur existant"), EditKind::Substantive);
|
||||
assert_eq!(classify("toute certifcation", "toute certification"), EditKind::Typographic);
|
||||
assert_eq!(classify("un acceuil chaleureux", "un accueil chaleureux"), EditKind::Typographic);
|
||||
// A letter replaced by another is not a slip: the author revises.
|
||||
assert_eq!(classify("une certifixation", "une certification"), EditKind::Substantive);
|
||||
// « personne » is also a negation (ne … personne): conservative.
|
||||
assert_eq!(classify("la persone", "la personne"), EditKind::Substantive);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn meaning_changes() {
|
||||
// A number.
|
||||
assert_eq!(classify("tous les 5 jours", "tous les 6 jours"), EditKind::Substantive);
|
||||
// A negation, even one letter apart.
|
||||
assert_eq!(classify("je peux certifier", "je ne peux certifier"), EditKind::Substantive);
|
||||
assert_eq!(classify("plus de deux", "pas de deux"), EditKind::Substantive);
|
||||
// A different word.
|
||||
assert_eq!(classify("au moins deux canaux", "au moins trois canaux"), EditKind::Substantive);
|
||||
// Three fixes are no longer a typo.
|
||||
assert_eq!(classify("aaaa bbbb cccc", "aaab bbbc cccd"), EditKind::Substantive);
|
||||
// A word added.
|
||||
assert_eq!(classify("en visio", "en visio directe"), EditKind::Substantive);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn diff_reconstructs_both_sides() {
|
||||
let old = "J'ai rencontré la personne physiquement (préférable).";
|
||||
let new = "J'ai rencontré la personne en visio (préférable).";
|
||||
let parts = diff(old, new);
|
||||
let rebuild = |keep: DiffOp| -> String {
|
||||
parts.iter().filter(|p| p.op == DiffOp::Same || p.op == keep).map(|p| p.text.as_str()).collect()
|
||||
};
|
||||
assert_eq!(rebuild(DiffOp::Removed), old);
|
||||
assert_eq!(rebuild(DiffOp::Added), new);
|
||||
assert!(parts.iter().any(|p| p.op == DiffOp::Removed && p.text.contains("physiquement")));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn levenshtein_basics() {
|
||||
assert_eq!(levenshtein("kitten", "sitting"), 3);
|
||||
assert_eq!(levenshtein("", "abc"), 3);
|
||||
assert_eq!(levenshtein("été", "ete"), 2);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,661 @@
|
||||
//! Inertia in five levels, on a scale that follows the group (ADR 0007).
|
||||
//!
|
||||
//! Yvv, 2026-10-30: « L'indice d'inertie doit avoir 5 niveaux » ; « une échelle
|
||||
//! cohérente qui s'adapte aux nombres de certifiés et à la participation
|
||||
//! médiane aux votes du groupe » — the scale chosen is « calée sur la
|
||||
//! participation ».
|
||||
//!
|
||||
//! The formula stays the Ğ1 one (`threshold`); M, G and the quorum Q depend on
|
||||
//! the level k:
|
||||
//!
|
||||
//! - M_k, the majority at full participation: 50, 50, 55, 60, 66 %;
|
||||
//! - s_k, the share of *for* votes asked of the voters at the group's usual
|
||||
//! participation p̃: 60, 67, 75, 83, 90 %;
|
||||
//! - G_k follows from `s_k = M_k + (1 − M_k)(1 − p̃^G_k)`:
|
||||
//! `G_k = ln(1 − (s_k − M_k)/(1 − M_k)) / ln(p̃)`;
|
||||
//! - Q_k, the quorum of the level, set by the document (Monnaie libre: 12, 12,
|
||||
//! 50, 50, 50; the test group: 1 everywhere — ADR 0013); an article may hold its
|
||||
//! own, set by its author before the first vote (`Scale::quorum`).
|
||||
//!
|
||||
//! p̃ is the median number of voters on the group's proposals, over W
|
||||
//! (`Participation`). Until the group has voted on enough proposals, a
|
||||
//! reference `p0 = 2Q₀/W` stands in, Q₀ the document's reference quorum (its
|
||||
//! `vote_floor`, never an article's quorum: an author who lowers his would move
|
||||
//! the curve of everyone). Both are kept within [1 %, 50 %]: ln(p̃) stays
|
||||
//! negative and finite, so G is finite and positive, and the threshold rises
|
||||
//! with the level and relaxes toward M as participation grows.
|
||||
|
||||
use crate::threshold::{Evaluation, FormulaParams, Tally, ThresholdError, evaluate};
|
||||
use serde::de::Error as _;
|
||||
use serde::{Deserialize, Deserializer, Serialize, Serializer};
|
||||
|
||||
/// One of the five levels of inertia, 1 (lightest) to 5 (heaviest).
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, PartialOrd, Ord)]
|
||||
pub struct InertiaLevel(u8);
|
||||
|
||||
/// M_k, in percent.
|
||||
const MAJORITY_PCT: [f64; 5] = [50.0, 50.0, 55.0, 60.0, 66.0];
|
||||
/// s_k, in percent.
|
||||
const SHARE_PCT: [f64; 5] = [60.0, 67.0, 75.0, 83.0, 90.0];
|
||||
|
||||
impl InertiaLevel {
|
||||
pub const LOWEST: Self = Self(1);
|
||||
pub const HIGHEST: Self = Self(5);
|
||||
/// The middle level: every proposal starts there (Yvv, 2026-10-30).
|
||||
pub const DEFAULT: Self = Self(3);
|
||||
pub const ALL: [Self; 5] = [Self(1), Self(2), Self(3), Self(4), Self(5)];
|
||||
|
||||
pub fn new(k: u8) -> Option<Self> {
|
||||
(1..=5).contains(&k).then_some(Self(k))
|
||||
}
|
||||
|
||||
pub fn get(self) -> u8 {
|
||||
self.0
|
||||
}
|
||||
|
||||
fn index(self) -> usize {
|
||||
usize::from(self.0 - 1)
|
||||
}
|
||||
|
||||
/// M_k, in percent.
|
||||
pub fn majority_pct(self) -> f64 {
|
||||
MAJORITY_PCT[self.index()]
|
||||
}
|
||||
|
||||
/// s_k, in percent: the share of *for* asked at the usual participation.
|
||||
pub fn share_pct(self) -> f64 {
|
||||
SHARE_PCT[self.index()]
|
||||
}
|
||||
|
||||
/// How a level is kept in the database: its digit.
|
||||
pub fn as_db(self) -> String {
|
||||
self.0.to_string()
|
||||
}
|
||||
|
||||
/// A digit from 1 to 5, or a word of the four levels before 2026-10-30, read
|
||||
/// as the level it became: low → 2, standard → 3, high → 4, max → 5.
|
||||
pub fn parse(raw: &str) -> Option<Self> {
|
||||
match raw.trim() {
|
||||
"low" => Some(Self(2)),
|
||||
"standard" => Some(Self(3)),
|
||||
"high" => Some(Self(4)),
|
||||
"max" => Some(Self(5)),
|
||||
digits => digits.parse::<u8>().ok().and_then(Self::new),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Default for InertiaLevel {
|
||||
fn default() -> Self {
|
||||
Self::DEFAULT
|
||||
}
|
||||
}
|
||||
|
||||
impl Serialize for InertiaLevel {
|
||||
fn serialize<S: Serializer>(&self, s: S) -> Result<S::Ok, S::Error> {
|
||||
s.serialize_u8(self.0)
|
||||
}
|
||||
}
|
||||
|
||||
impl<'de> Deserialize<'de> for InertiaLevel {
|
||||
/// A number from 1 to 5, or a string `parse` reads (a seed or a client of before).
|
||||
fn deserialize<D: Deserializer<'de>>(d: D) -> Result<Self, D::Error> {
|
||||
#[derive(Deserialize)]
|
||||
#[serde(untagged)]
|
||||
enum Raw {
|
||||
Number(u64),
|
||||
Text(String),
|
||||
}
|
||||
let level = match Raw::deserialize(d)? {
|
||||
Raw::Number(n) => u8::try_from(n).ok().and_then(Self::new),
|
||||
Raw::Text(s) => Self::parse(&s),
|
||||
};
|
||||
level.ok_or_else(|| D::Error::custom("an inertia level is 1 to 5"))
|
||||
}
|
||||
}
|
||||
|
||||
/// Where p̃ comes from.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "lowercase")]
|
||||
pub enum ParticipationSource {
|
||||
/// The median of the group's voted proposals.
|
||||
Median,
|
||||
/// Too few voted proposals yet: `p0 = 2Q₀/W`, Q₀ the document's reference quorum.
|
||||
Reference,
|
||||
}
|
||||
|
||||
/// p̃ — the usual participation of a group, as a share of its electorate.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct Participation {
|
||||
/// p̃, within [`Participation::LOWEST`, `Participation::HIGHEST`].
|
||||
pub ratio: f64,
|
||||
pub source: ParticipationSource,
|
||||
/// Voted proposals the median rests on (counted even when too few).
|
||||
pub sample: u32,
|
||||
/// The median number of voters measured, when at least one proposal was voted on.
|
||||
pub median: Option<f64>,
|
||||
/// The measured (or reference) share fell outside the bounds and was brought back.
|
||||
pub bounded: bool,
|
||||
}
|
||||
|
||||
impl Participation {
|
||||
/// Fewer voted proposals than this, and the reference stands in.
|
||||
pub const MIN_SAMPLE: u32 = 5;
|
||||
pub const LOWEST: f64 = 0.01;
|
||||
pub const HIGHEST: f64 = 0.5;
|
||||
|
||||
/// `2Q₀/W`, before the bounds: the reference quorum of the document twice
|
||||
/// over (`documents.vote_floor`: 50 in Monnaie libre, 100 voters). It never
|
||||
/// rests on an article's quorum, which its author may lower (ADR 0013).
|
||||
pub fn reference_ratio(electorate: u32, reference_quorum: f64) -> f64 {
|
||||
2.0 * reference_quorum.max(0.0) / f64::from(electorate.max(1))
|
||||
}
|
||||
|
||||
/// p̃ from what was measured: the median when it rests on enough proposals,
|
||||
/// the reference otherwise; brought back within the bounds.
|
||||
pub fn of(electorate: u32, reference_quorum: f64, median: Option<f64>, sample: u32) -> Self {
|
||||
let (raw, source) = match median {
|
||||
Some(m) if sample >= Self::MIN_SAMPLE => (m / f64::from(electorate.max(1)), ParticipationSource::Median),
|
||||
_ => (Self::reference_ratio(electorate, reference_quorum), ParticipationSource::Reference),
|
||||
};
|
||||
let ratio = bounded(raw);
|
||||
Self { ratio, source, sample, median, bounded: ratio != raw }
|
||||
}
|
||||
|
||||
/// p̃ · W — the number of voters p̃ stands for.
|
||||
pub fn voters(&self, electorate: u32) -> f64 {
|
||||
self.ratio * f64::from(electorate.max(1))
|
||||
}
|
||||
}
|
||||
|
||||
/// The median of the numbers of voters, `None` for none.
|
||||
pub fn median(values: &[u32]) -> Option<f64> {
|
||||
let mut sorted = values.to_vec();
|
||||
sorted.sort_unstable();
|
||||
let n = sorted.len();
|
||||
let mid = n / 2;
|
||||
match n {
|
||||
0 => None,
|
||||
_ if n % 2 == 1 => Some(f64::from(sorted[mid])),
|
||||
_ => Some((f64::from(sorted[mid - 1]) + f64::from(sorted[mid])) / 2.0),
|
||||
}
|
||||
}
|
||||
|
||||
/// Within [1 %, 50 %]; anything that is not a number goes to the lowest bound.
|
||||
fn bounded(ratio: f64) -> f64 {
|
||||
if ratio.is_nan() { Participation::LOWEST } else { ratio.clamp(Participation::LOWEST, Participation::HIGHEST) }
|
||||
}
|
||||
|
||||
/// G_k at the usual participation p̃ (brought back within the bounds).
|
||||
pub fn gradient(level: InertiaLevel, participation: f64) -> f64 {
|
||||
let m = level.majority_pct() / 100.0;
|
||||
let s = level.share_pct() / 100.0;
|
||||
// Both logarithms are negative: 0 < 1 − (s − M)/(1 − M) < 1 since M < s < 1,
|
||||
// and 0 < p̃ < 1 within the bounds.
|
||||
(1.0 - (s - m) / (1.0 - m)).ln() / bounded(participation).ln()
|
||||
}
|
||||
|
||||
/// What the votes of a document are judged by: its electorate W, its usual
|
||||
/// participation p̃ and the quorum of each level.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct Scale {
|
||||
pub electorate: u32,
|
||||
/// p̃.
|
||||
pub participation: f64,
|
||||
/// Q_k, the quorum of each level, 1 to 5: the default of an article at that level.
|
||||
pub quorums: [u32; 5],
|
||||
}
|
||||
|
||||
impl Scale {
|
||||
/// The quorum an article is judged on: its own, set by its author before the first
|
||||
/// vote, else the one of its level (Yvv, 2026-11-02: « C suit le cran, mais c'est une
|
||||
/// proposition par défaut, variable modifiable par l'auteur initial du texte »). A
|
||||
/// change of inertia inherits the level of its article, and so its quorum.
|
||||
pub fn quorum(&self, level: InertiaLevel, own: Option<u32>) -> u32 {
|
||||
own.unwrap_or(self.quorums[level.index()])
|
||||
}
|
||||
|
||||
/// The letters of the formula for a level, on a quorum.
|
||||
pub fn params(&self, level: InertiaLevel, quorum: u32) -> FormulaParams {
|
||||
FormulaParams {
|
||||
majority_pct: level.majority_pct(),
|
||||
gradient: gradient(level, self.participation),
|
||||
base: FormulaParams::DEFAULT_BASE,
|
||||
quorum,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn evaluate(&self, tally: Tally, level: InertiaLevel, quorum: u32) -> Result<Evaluation, ThresholdError> {
|
||||
evaluate(tally, self.electorate, &self.params(level, quorum))
|
||||
}
|
||||
|
||||
/// ⌈R⌉ for a level and a quorum at `cast` votes.
|
||||
pub fn required(&self, cast: u32, level: InertiaLevel, quorum: u32) -> Result<u32, ThresholdError> {
|
||||
crate::threshold::wot_threshold(self.electorate, cast, &self.params(level, quorum))
|
||||
}
|
||||
}
|
||||
|
||||
/// The quorums an author may set (ADR 0013): from 2 — one voice never passes a text
|
||||
/// alone — to W; from 1 in the test group, where one voice passes to try things out. A
|
||||
/// tiny electorate never brings the lowest under 2.
|
||||
pub fn quorum_bounds(electorate: u32, test_group: bool) -> (u32, u32) {
|
||||
let lowest = if test_group { 1 } else { 2 };
|
||||
(lowest, electorate.max(lowest))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::threshold::{judged_voters, required_ratio};
|
||||
|
||||
const W: u32 = 7224;
|
||||
/// The reference quorum of Monnaie libre (`vote_floor`): p0 = 2 × 50 / W.
|
||||
const Q0: f64 = 50.0;
|
||||
/// The quorums of Monnaie libre by level (Yvv, 2026-11-02: « 12 · 12 · 50 · 50 · 50 »).
|
||||
const QUORUMS: [u32; 5] = [12, 12, 50, 50, 50];
|
||||
|
||||
fn level(k: u8) -> InertiaLevel {
|
||||
InertiaLevel::new(k).expect("1 to 5")
|
||||
}
|
||||
|
||||
fn at_median(voters: u32) -> Scale {
|
||||
Scale { electorate: W, participation: f64::from(voters) / f64::from(W), quorums: QUORUMS }
|
||||
}
|
||||
|
||||
/// ⌈R⌉ at `cast` votes, each level on its own quorum.
|
||||
fn row(scale: &Scale, cast: u32) -> Vec<u32> {
|
||||
InertiaLevel::ALL.iter().map(|k| scale.required(cast, *k, scale.quorum(*k, None)).expect("valid")).collect()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn five_levels_three_in_the_middle() {
|
||||
let m: Vec<f64> = InertiaLevel::ALL.iter().map(|k| k.majority_pct()).collect();
|
||||
let s: Vec<f64> = InertiaLevel::ALL.iter().map(|k| k.share_pct()).collect();
|
||||
assert_eq!(m, [50.0, 50.0, 55.0, 60.0, 66.0]);
|
||||
assert_eq!(s, [60.0, 67.0, 75.0, 83.0, 90.0]);
|
||||
for k in InertiaLevel::ALL {
|
||||
assert!(k.share_pct() > k.majority_pct(), "s_k above M_k keeps G positive");
|
||||
assert_eq!(InertiaLevel::parse(&k.as_db()), Some(k));
|
||||
}
|
||||
assert_eq!(InertiaLevel::default().get(), 3);
|
||||
assert_eq!(InertiaLevel::new(0), None);
|
||||
assert_eq!(InertiaLevel::new(6), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_four_words_of_before_read_as_levels() {
|
||||
let read = |w: &str| InertiaLevel::parse(w).map(InertiaLevel::get);
|
||||
assert_eq!([read("low"), read("standard"), read("high"), read("max")], [Some(2), Some(3), Some(4), Some(5)]);
|
||||
assert_eq!(read("maximale"), None);
|
||||
let from_json = |v: &str| serde_json::from_str::<InertiaLevel>(v).ok().map(InertiaLevel::get);
|
||||
assert_eq!([from_json("4"), from_json("\"standard\""), from_json("\"5\"")], [Some(4), Some(3), Some(5)]);
|
||||
assert_eq!(from_json("0"), None);
|
||||
assert_eq!(from_json("\"6\""), None);
|
||||
assert_eq!(serde_json::to_string(&InertiaLevel::HIGHEST).expect("json"), "5");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_control_figures_of_the_quorum() {
|
||||
// W 5 707, before any median: p0 = 2 × 50 / W = 100 / W (orch, 2026-11-03).
|
||||
let w = 5707;
|
||||
let scale = Scale { electorate: w, participation: Participation::of(w, Q0, None, 0).ratio, quorums: QUORUMS };
|
||||
let yes = |k: u8, q: u32, against: u32| {
|
||||
crate::threshold::yes_needed(against, w, &scale.params(level(k), q)).expect("valid")
|
||||
};
|
||||
// At the quorum: level 2, Q 12 → 9 « pour »; level 3, Q 50 → 39; level 5, Q 50 → 46.
|
||||
assert_eq!((yes(2, 12, 0), yes(3, 50, 0), yes(5, 50, 0)), (9, 39, 46));
|
||||
// 9 « pour » pass at level 2 with 3 « contre » at most (Yvv, 2026-11-03); with a 4th,
|
||||
// 14 voters, 11 « pour » are asked (73,8 % of 14 is 10,2).
|
||||
assert_eq!((yes(2, 12, 3), yes(2, 12, 4)), (9, 11));
|
||||
// Ten voters, no quorum in the way: 7, 8, 9, 9 and 10 « pour », level 1 to 5.
|
||||
let ten: Vec<u32> = InertiaLevel::ALL.iter().map(|k| scale.required(10, *k, 1).expect("valid")).collect();
|
||||
assert_eq!(ten, [7, 8, 9, 9, 10]);
|
||||
// The share asked is highest at the quorum, never near-unanimity (review of M6b, lot
|
||||
// T2): 64,4 · 73,5 · 77,4 · 85,3 · 91,9 % of the quorum, level 1 to 5 — the review said
|
||||
// 74 at level 2; rounded up to whole voters, 9 « pour » of 12 (75 %).
|
||||
let at_quorum: Vec<f64> = InertiaLevel::ALL
|
||||
.iter()
|
||||
.map(|k| {
|
||||
let q = scale.quorum(*k, None);
|
||||
(required_ratio(w, judged_voters(0, q), &scale.params(*k, q)) * 1000.0).round() / 10.0
|
||||
})
|
||||
.collect();
|
||||
assert_eq!(at_quorum, [64.4, 73.5, 77.4, 85.3, 91.9]);
|
||||
assert_eq!((yes(2, 12, 0), scale.quorum(level(2), None)), (9, 12));
|
||||
// Beyond the quorum the share only falls, down to M when everyone votes; under it,
|
||||
// the voters counted are the quorum, and so is the share.
|
||||
for k in InertiaLevel::ALL {
|
||||
let q = scale.quorum(k, None);
|
||||
let p = scale.params(k, q);
|
||||
let share = |t: u32| required_ratio(w, judged_voters(t, q), &p);
|
||||
let mut last = share(0);
|
||||
for t in 0..=w {
|
||||
let s = share(t);
|
||||
assert!(s <= last, "level {}: the share never rises with the voters ({t})", k.get());
|
||||
if t <= q {
|
||||
assert_eq!(s, share(0), "level {}: under the quorum, the share of the quorum", k.get());
|
||||
}
|
||||
last = s;
|
||||
}
|
||||
assert!((share(w) - k.majority_pct() / 100.0).abs() < 1e-12, "level {}: M at full turnout", k.get());
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_values_shown_to_yvv() {
|
||||
// W 7224, quorums 12 · 12 · 50 · 50 · 50, a median of 120 voters (the smith vote of 2024).
|
||||
let scale = at_median(120);
|
||||
assert_eq!(row(&scale, 120), [72, 81, 90, 100, 108]);
|
||||
assert_eq!(row(&scale, 500), [284, 310, 347, 386, 424]);
|
||||
assert_eq!(row(&scale, 2000), [1068, 1123, 1252, 1389, 1537]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_median_of_360_voters() {
|
||||
let scale = at_median(360);
|
||||
assert_eq!(row(&scale, 120), [76, 86, 96, 106, 113]);
|
||||
assert_eq!(row(&scale, 360), [216, 242, 270, 299, 324]);
|
||||
assert_eq!(row(&scale, 500), [296, 328, 367, 407, 443]);
|
||||
assert_eq!(row(&scale, 2000), [1092, 1164, 1301, 1446, 1598]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn at_the_usual_participation_the_share_is_s_k() {
|
||||
for (w, voters) in [(7224, 120), (7224, 360), (5745, 300), (40, 9), (2, 1)] {
|
||||
let scale = Scale { electorate: w, participation: f64::from(voters) / f64::from(w), quorums: QUORUMS };
|
||||
for k in InertiaLevel::ALL {
|
||||
let ratio = required_ratio(w, voters, &scale.params(k, 0));
|
||||
assert!((ratio - k.share_pct() / 100.0).abs() < 1e-12, "W {w}, k {}: {ratio}", k.get());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_threshold_landing_on_a_whole_number_stays_on_it() {
|
||||
// Level 5 at the median: 90 % of 120 voters is exactly 108.
|
||||
assert_eq!(at_median(120).required(120, InertiaLevel::HIGHEST, 50), Ok(108));
|
||||
// Level 1: 60 % of 120 is exactly 72.
|
||||
assert_eq!(at_median(120).required(120, InertiaLevel::LOWEST, 12), Ok(72));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_threshold_rises_with_the_level() {
|
||||
for (w, q0, quorums) in [
|
||||
(7224, Q0, QUORUMS),
|
||||
(5745, Q0, QUORUMS),
|
||||
(5745, Q0, [50; 5]),
|
||||
(40, 1.0, [1; 5]),
|
||||
(2, 1.0, [1; 5]),
|
||||
(1, 1.0, [1; 5]),
|
||||
] {
|
||||
for voters in [1, 2, 9, 60, 120, 360, 3000] {
|
||||
let scale = Scale {
|
||||
electorate: w,
|
||||
participation: Participation::of(w, q0, Some(f64::from(voters)), 9).ratio,
|
||||
quorums,
|
||||
};
|
||||
for cast in (0..=w.min(4000)).step_by(7).chain([w]) {
|
||||
let r = row(&scale, cast);
|
||||
assert!(r.windows(2).all(|p| p[0] <= p[1]), "W {w}, p̃ {voters}, T {cast}: {r:?}");
|
||||
let ratios: Vec<f64> =
|
||||
InertiaLevel::ALL.iter().map(|k| required_ratio(w, cast, &scale.params(*k, 0))).collect();
|
||||
if cast > 0 && cast < w {
|
||||
assert!(
|
||||
ratios.windows(2).all(|p| p[0] < p[1]),
|
||||
"strictly below full participation: {ratios:?}"
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_share_relaxes_toward_m_as_participation_rises() {
|
||||
for voters in [120, 360] {
|
||||
let scale = at_median(voters);
|
||||
for k in InertiaLevel::ALL {
|
||||
let p = scale.params(k, 0);
|
||||
let mut last = 1.0;
|
||||
for cast in (1..=W).step_by(13).chain([W]) {
|
||||
let ratio = required_ratio(W, cast, &p);
|
||||
assert!(ratio <= last, "k {}: the share never rises with participation", k.get());
|
||||
assert!(ratio >= k.majority_pct() / 100.0 - 1e-12);
|
||||
last = ratio;
|
||||
}
|
||||
assert!((required_ratio(W, W, &p) - k.majority_pct() / 100.0).abs() < 1e-12, "M at full participation");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Lot U3c (review of d66823b), `formula.reading`: « quand tout le monde vote, la part exigée est
|
||||
/// la majorité M ». On every W from 1 to 10 000 000 — the groups the server counts by their
|
||||
/// members (the test group: 2) and the web of trust it takes as true (`wot::PLAUSIBLE`, 100 to
|
||||
/// 10 000 000) — at the five levels, on both bounds of the quorum an author may set (in Monnaie
|
||||
/// libre and in the test group: 1, 2, W) and at both bounds of the usual participation (G at its
|
||||
/// lowest and at its highest): when the W vote, the voters judged are T′ = max(W, Q), never fewer
|
||||
/// than W, and the share asked of them is M_k, exactly.
|
||||
///
|
||||
/// The phrase of before, « quand tout le monde vote, la majorité M suffit », read as « ⌈M·W⌉
|
||||
/// « pour » pass », was false: R = B^W + M·W, and B^W adds one « pour » where M·W is a whole
|
||||
/// number, on 17 (W, level) pairs, every W up to 15 (W 4, level 2: 3 « pour » of 4, not 2; W 5,
|
||||
/// level 4: 4, not 3; W 10, level 4: 7, not 6) — none from W = 16 to 200, where it is checked.
|
||||
/// The new phrase says nothing of B^W: `formula.B.wot` does, in the web of trust only.
|
||||
#[test]
|
||||
fn when_everyone_votes_the_share_asked_is_m_on_every_electorate() {
|
||||
const LARGEST: u32 = 10_000_000;
|
||||
let params: Vec<(InertiaLevel, f64, FormulaParams)> = InertiaLevel::ALL
|
||||
.iter()
|
||||
.flat_map(|k| {
|
||||
[Participation::LOWEST, Participation::HIGHEST].map(|p| {
|
||||
let scale = Scale { electorate: 1, participation: p, quorums: [1; 5] };
|
||||
(*k, k.majority_pct() / 100.0, scale.params(*k, 1))
|
||||
})
|
||||
})
|
||||
.collect();
|
||||
let mut cases = 0u64;
|
||||
for w in 1..=LARGEST {
|
||||
let ((lowest, highest), (lowest_essai, _)) = (quorum_bounds(w, false), quorum_bounds(w, true));
|
||||
// The share reads the voters judged only: W for every quorum up to W, the quorum above
|
||||
// it (2 on a web of 1, outside the test group).
|
||||
let judged = [lowest_essai, lowest, highest].map(|q| judged_voters(w, q));
|
||||
assert!(judged.iter().all(|v| *v >= w), "W {w}: {judged:?}");
|
||||
for (i, voters) in judged.iter().enumerate() {
|
||||
if judged[..i].contains(voters) {
|
||||
continue;
|
||||
}
|
||||
for (k, m, p) in ¶ms {
|
||||
let share = required_ratio(w, *voters, p);
|
||||
assert_eq!(share, *m, "W {w}, level {}, G {}, {voters} voters judged", k.get(), p.gradient);
|
||||
cases += 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
// Every W, ten (level, G) each, and on a web of 1 the quorum of 2 besides.
|
||||
assert_eq!(cases, u64::from(LARGEST) * 10 + 10);
|
||||
// The share the evaluation gives (`Evaluation::required_ratio`), on every split of the W
|
||||
// voters, on the smallest electorates and on the web of trust.
|
||||
for w in (1..=40).chain([100, 101, 5707, 7224, LARGEST]) {
|
||||
let scale =
|
||||
Scale { electorate: w, participation: Participation::of(w, 50.0, None, 0).ratio, quorums: [2; 5] };
|
||||
for k in InertiaLevel::ALL {
|
||||
for q in [quorum_bounds(w, false).0, w] {
|
||||
for against in [0, w / 3, w / 2, w] {
|
||||
let tally = Tally { for_votes: w - against, against };
|
||||
let e = scale.evaluate(tally, k, q).expect("valid");
|
||||
assert_eq!(e.required_ratio, k.majority_pct() / 100.0, "W {w}, level {}, Q {q}", k.get());
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
// « La majorité M suffit » was false: the « pour » asked when the W vote, against ⌈M·W⌉.
|
||||
let mut more = Vec::new();
|
||||
for w in 1..=200u32 {
|
||||
for k in InertiaLevel::ALL {
|
||||
let p = Scale { electorate: w, participation: 0.1, quorums: [1; 5] }.params(k, 1);
|
||||
let asked = crate::threshold::wot_threshold(w, w, &p).expect("valid");
|
||||
let majority = (k.majority_pct() as u32 * w).div_ceil(100);
|
||||
if asked != majority {
|
||||
assert_eq!(asked, majority + 1, "W {w}, level {}: one « pour » more, B^W", k.get());
|
||||
assert_eq!(k.majority_pct() as u32 * w % 100, 0, "W {w}: M·W is a whole number");
|
||||
more.push((w, k.get()));
|
||||
}
|
||||
}
|
||||
}
|
||||
assert_eq!(
|
||||
more,
|
||||
[
|
||||
(2, 1),
|
||||
(2, 2),
|
||||
(4, 1),
|
||||
(4, 2),
|
||||
(5, 4),
|
||||
(6, 1),
|
||||
(6, 2),
|
||||
(8, 1),
|
||||
(8, 2),
|
||||
(10, 1),
|
||||
(10, 2),
|
||||
(10, 4),
|
||||
(12, 1),
|
||||
(12, 2),
|
||||
(14, 1),
|
||||
(14, 2),
|
||||
(15, 4)
|
||||
]
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_author_sets_a_quorum_from_two_to_w_from_one_in_the_test_group() {
|
||||
assert_eq!(quorum_bounds(5745, false), (2, 5745));
|
||||
assert_eq!(quorum_bounds(2, true), (1, 2));
|
||||
assert_eq!(quorum_bounds(1, true), (1, 1));
|
||||
assert_eq!(quorum_bounds(1, false), (2, 2), "never under two, even on a tiny electorate");
|
||||
// At the lowest bound outside the test group, one voice never passes, whatever the
|
||||
// level, the electorate and the usual participation.
|
||||
for w in [1, 2, 3, 10, 120, 5745, 7224] {
|
||||
let (lowest, _) = quorum_bounds(w, false);
|
||||
for p in [Participation::LOWEST, 0.1, Participation::HIGHEST] {
|
||||
let scale = Scale { electorate: w, participation: p, quorums: [lowest; 5] };
|
||||
for k in InertiaLevel::ALL {
|
||||
let lone = scale.evaluate(Tally { for_votes: 1, against: 0 }, k, lowest).expect("valid");
|
||||
assert!(!lone.reached, "W {w}, p̃ {p}, k {}", k.get());
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn under_the_quorum_one_vote_never_passes_in_monnaie_libre() {
|
||||
for voters in [1, 72, 120, 360, 3612] {
|
||||
let scale = Scale {
|
||||
electorate: W,
|
||||
participation: Participation::of(W, Q0, Some(f64::from(voters)), 9).ratio,
|
||||
quorums: QUORUMS,
|
||||
};
|
||||
for k in InertiaLevel::ALL {
|
||||
let q = scale.quorum(k, None);
|
||||
let lone = scale.evaluate(Tally { for_votes: 1, against: 0 }, k, q).expect("valid");
|
||||
assert!(!lone.reached, "k {}", k.get());
|
||||
// Under the quorum, the voters missing count as against: the bar of Q voters.
|
||||
let at_q = scale.required(q, k, q).expect("valid");
|
||||
assert!(at_q >= 2, "one voice is never enough");
|
||||
for cast in 0..=q {
|
||||
assert_eq!(scale.required(cast, k, q).expect("valid"), at_q, "under the quorum, k {}", k.get());
|
||||
}
|
||||
let short = Tally { for_votes: at_q - 1, against: 0 };
|
||||
assert!(!scale.evaluate(short, k, q).expect("valid").reached);
|
||||
let enough = scale.evaluate(Tally { for_votes: at_q, against: 0 }, k, q).expect("valid");
|
||||
assert!(enough.reached);
|
||||
assert_eq!(enough.yes_needed, at_q);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_article_s_own_quorum_stands_for_its_level() {
|
||||
let scale = at_median(120);
|
||||
assert_eq!(scale.quorum(InertiaLevel::DEFAULT, None), 50);
|
||||
assert_eq!(scale.quorum(level(2), None), 12);
|
||||
assert_eq!(scale.quorum(InertiaLevel::DEFAULT, Some(20)), 20);
|
||||
// The curve stays the level's: only the quorum moves.
|
||||
let own = scale.params(InertiaLevel::DEFAULT, 20);
|
||||
let default = scale.params(InertiaLevel::DEFAULT, 50);
|
||||
assert_eq!((own.majority_pct, own.gradient, own.quorum), (default.majority_pct, default.gradient, 20));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_median() {
|
||||
assert_eq!(median(&[]), None);
|
||||
assert_eq!(median(&[7]), Some(7.0));
|
||||
assert_eq!(median(&[3, 1, 2]), Some(2.0));
|
||||
assert_eq!(median(&[4, 1, 3, 2]), Some(2.5));
|
||||
assert_eq!(median(&[1, 1, 1, 200, 300]), Some(1.0), "one big vote does not move it");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_reference_stands_in_until_five_proposals_are_voted_on() {
|
||||
// Monnaie libre: 2 × 50 / 7224 = 1.38 %, a hundred voters.
|
||||
assert!((Participation::reference_ratio(W, Q0) - 100.0 / 7224.0).abs() < 1e-15);
|
||||
let early = Participation::of(W, Q0, Some(120.0), 4);
|
||||
assert_eq!(early.source, ParticipationSource::Reference);
|
||||
assert!((early.voters(W) - 100.0).abs() < 1e-9);
|
||||
assert_eq!((early.sample, early.median, early.bounded), (4, Some(120.0), false));
|
||||
let none = Participation::of(W, Q0, None, 0);
|
||||
assert_eq!(none.source, ParticipationSource::Reference);
|
||||
let measured = Participation::of(W, Q0, Some(120.0), 5);
|
||||
assert_eq!(measured.source, ParticipationSource::Median);
|
||||
assert!((measured.voters(W) - 120.0).abs() < 1e-9);
|
||||
assert!(!measured.bounded);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn participation_is_kept_within_one_and_fifty_percent() {
|
||||
// Two voters on 7224: brought up to 1 %.
|
||||
let low = Participation::of(W, Q0, Some(2.0), 9);
|
||||
assert_eq!((low.ratio, low.bounded), (0.01, true));
|
||||
// A group where everyone votes: brought down to half.
|
||||
let all = Participation::of(12, 1.0, Some(12.0), 9);
|
||||
assert_eq!((all.ratio, all.bounded), (0.5, true));
|
||||
// The test group, W = 2, C = 1: the reference is 100 %, kept at half.
|
||||
let essai = Participation::of(2, 1.0, None, 0);
|
||||
assert_eq!((essai.ratio, essai.source, essai.bounded), (0.5, ParticipationSource::Reference, true));
|
||||
// A tiny electorate never breaks G.
|
||||
for w in [1, 2, 3] {
|
||||
let p = Participation::of(w, 1.0, Some(1.0), 9);
|
||||
for k in InertiaLevel::ALL {
|
||||
let g = gradient(k, p.ratio);
|
||||
assert!(g.is_finite() && g > 0.0, "W {w}, k {}: {g}", k.get());
|
||||
}
|
||||
}
|
||||
// Whatever reaches it, G stays finite and positive.
|
||||
for bad in [0.0, 1.0, -3.0, 7.0, f64::NAN, f64::INFINITY] {
|
||||
let g = gradient(InertiaLevel::DEFAULT, bad);
|
||||
assert!(g.is_finite() && g > 0.0, "{bad}: {g}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_test_group_at_a_quorum_of_one() {
|
||||
// W = 2, quorum 1 everywhere (Yvv, 2026-11-02): one « pour » alone passes, at every
|
||||
// level; one « pour » and one « contre » never (half of all, under any M of the levels).
|
||||
let scale = Scale { electorate: 2, participation: Participation::of(2, 1.0, None, 0).ratio, quorums: [1; 5] };
|
||||
for k in InertiaLevel::ALL {
|
||||
assert!(scale.evaluate(Tally { for_votes: 1, against: 0 }, k, 1).expect("valid").reached);
|
||||
assert!(!scale.evaluate(Tally { for_votes: 1, against: 1 }, k, 1).expect("valid").reached);
|
||||
assert!(scale.evaluate(Tally { for_votes: 2, against: 0 }, k, 1).expect("valid").reached);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn gradients_at_a_median_of_120() {
|
||||
let g: Vec<f64> = InertiaLevel::ALL.iter().map(|k| gradient(*k, 120.0 / 7224.0)).collect();
|
||||
let expected = [0.054_455, 0.101_404, 0.143_442, 0.208_818, 0.298_651];
|
||||
for (got, want) in g.iter().zip(expected) {
|
||||
assert!((got - want).abs() < 1e-5, "{g:?}");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
//! Pure engines of libreDecision — no IO, no clock, no randomness.
|
||||
//!
|
||||
//! Everything that decides an outcome lives here so it can be tested in
|
||||
//! isolation and ported verbatim: the inertial threshold (parity with the
|
||||
//! Python/TypeScript reference of the v2 maquette), the edit classifier that
|
||||
//! separates a typo fix from a change of meaning, the rule-based editing
|
||||
//! assistant, and the composition of a document under permanent vote.
|
||||
|
||||
pub mod address;
|
||||
pub mod assist;
|
||||
pub mod ballot;
|
||||
pub mod buffer;
|
||||
pub mod compose;
|
||||
pub mod correction;
|
||||
pub mod edit;
|
||||
pub mod inertia;
|
||||
pub mod names;
|
||||
pub mod objection;
|
||||
pub mod order;
|
||||
pub mod pending;
|
||||
pub mod threshold;
|
||||
|
||||
pub use inertia::{InertiaLevel, Participation, Scale};
|
||||
pub use threshold::{FormulaParams, Tally};
|
||||
@@ -0,0 +1,84 @@
|
||||
//! Names people give themselves (team links): what is accepted, and the key two
|
||||
//! names collide on.
|
||||
//!
|
||||
//! A name is shown as the author of proposals, revisions and signals, so nobody
|
||||
//! may take a name already there, even dressed differently: « émile » for
|
||||
//! « Émile », an accent written apart, a Cyrillic « в » or a Greek « Υ » for a
|
||||
//! Latin letter, full-width « Yvv ». Hence a short alphabet — Latin letters
|
||||
//! as French, English and Spanish write them, figures, space, hyphen,
|
||||
//! apostrophe, dot — and a key that forgets case, accents and punctuation.
|
||||
|
||||
use unicode_normalization::UnicodeNormalization;
|
||||
use unicode_normalization::char::is_combining_mark;
|
||||
|
||||
const MIN_NAME: usize = 2;
|
||||
const MAX_NAME: usize = 60;
|
||||
|
||||
#[derive(Debug, PartialEq, Eq)]
|
||||
pub struct Name {
|
||||
/// As shown: NFC, spaces collapsed.
|
||||
pub display: String,
|
||||
/// What two names collide on.
|
||||
pub key: String,
|
||||
}
|
||||
|
||||
fn allowed(c: char) -> bool {
|
||||
c.is_ascii_alphanumeric()
|
||||
|| (c.is_alphabetic() && matches!(c as u32, 0xC0..=0x17F))
|
||||
|| matches!(c, ' ' | '-' | '\'' | '’' | '.')
|
||||
}
|
||||
|
||||
/// Lowercase, no accents, letters and figures only: « Émile », « EMILE »,
|
||||
/// « É-mile » share the key « emile ».
|
||||
pub fn key(s: &str) -> String {
|
||||
s.nfkd().filter(|c| !is_combining_mark(*c)).flat_map(char::to_lowercase).filter(|c| c.is_alphanumeric()).collect()
|
||||
}
|
||||
|
||||
/// A name a person may take, or `None`.
|
||||
pub fn check(raw: &str) -> Option<Name> {
|
||||
let raw = raw.trim();
|
||||
// One line, ordinary spaces only (no line or paragraph separator).
|
||||
if raw.chars().any(|c| c.is_whitespace() && c != ' ') {
|
||||
return None;
|
||||
}
|
||||
let display = raw.nfc().collect::<String>().split(' ').filter(|w| !w.is_empty()).collect::<Vec<_>>().join(" ");
|
||||
let n = display.chars().count();
|
||||
if !(MIN_NAME..=MAX_NAME).contains(&n) || !display.chars().all(allowed) {
|
||||
return None;
|
||||
}
|
||||
let key = key(&display);
|
||||
if key.chars().count() < MIN_NAME {
|
||||
return None;
|
||||
}
|
||||
Some(Name { display, key })
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn ordinary_names() {
|
||||
assert_eq!(check(" Émile Zola ").map(|n| n.display), Some("Émile Zola".into()));
|
||||
assert_eq!(check("Jean-Marc O'Neil").map(|n| n.key), Some("jeanmarconeil".into()));
|
||||
assert_eq!(check("Núria").map(|n| n.key), Some("nuria".into()));
|
||||
assert!(check("Chiara 2").is_some());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_same_name_dressed_differently_collides() {
|
||||
let emile = key("Émile");
|
||||
for other in ["émile", "EMILE", "E\u{301}mile", "É-mile", "Emile."] {
|
||||
assert_eq!(check(other).map(|n| n.key), Some(emile.clone()), "{other}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn look_alikes_and_invisibles_are_refused() {
|
||||
// Eighth and ninth independent reviews, 2026-10-21.
|
||||
for name in ["Yvв", "Υvv", "Yvv", "Yv\u{2028}v", "Yvv\u{fff9}", "Yv\u{200b}v", "Y\nvv", "Y", "--", "Yvv 🙂"]
|
||||
{
|
||||
assert_eq!(check(name), None, "{name:?}");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,486 @@
|
||||
//! Objections — « redit » and « contredit » (Yvv, 2026-10-30, L12).
|
||||
//!
|
||||
//! « Faudrait un "redit" en option avec "contredit" → faut rendre plus explicite
|
||||
//! que l'objection doit être levée par l'auteur de la dernière arrivée pour être
|
||||
//! une proposition votable. Pour ce qui constitue une répétition → modifier
|
||||
//! l'article cible ; pour une contradiction → reformuler. → ping messagerie
|
||||
//! TrustinWallet → Lever l'objection ou Réitérer l'objection ; si pas de réponse
|
||||
//! sous 3j, alors "levée". »
|
||||
//!
|
||||
//! A member objects to a pair of versions (A, B), with a reason: B **repeats** A
|
||||
//! (`Redit`) or B **contradicts** A (`Contredit`). The rules, purely:
|
||||
//!
|
||||
//! - **the latest arrival** of the two (submitted last; same instant: written
|
||||
//! last) is no longer votable while the objection stands — unless it is in
|
||||
//! force: a version in force is never made non-votable, the other one is
|
||||
//! (`target`). Two versions both in force are not an objection: one of them
|
||||
//! is changed by a proposal. The target is fixed when the objection is raised;
|
||||
//! - the votes already cast on it stay, but it cannot enter into force
|
||||
//! (`votable` and the adoption: a non-votable version is blocked);
|
||||
//! - while it is held, one may still **vote against it or take one's vote
|
||||
//! back**, never add a *for* (`may_cast`, Yvv 2026-11-01: « Retirer ou
|
||||
//! "contre" »): it can only lose votes, and once lifted it enters with the
|
||||
//! votes still there;
|
||||
//! - its author **answers**: a substantive revision of it, or a proposal linked
|
||||
//! to the objection (on the article it repeats, for a « redit »; a reworded
|
||||
//! version, for a version without author). A substantive revision of **the
|
||||
//! other side** answers too: the pair changed, the objector judges again
|
||||
//! (`answers`). A typo fix answers nothing;
|
||||
//! - then the **objector** is asked, by a message: lift the objection (the
|
||||
//! version is votable again) or reiterate it (it stands, the author is told
|
||||
//! again). **Without an answer within 3 days, it is lifted** by itself;
|
||||
//! - **one cycle at a time**: while the objector is asked, another answer of the
|
||||
//! author changes nothing, and the deadline does not move;
|
||||
//! - the objector may lift at any time; an administrator of trustinWallet may
|
||||
//! lift too (never reiterate: the objection is not theirs); withdrawing either
|
||||
//! version ends it.
|
||||
|
||||
use crate::ballot::Choice;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::cmp::Ordering;
|
||||
|
||||
/// Days the objector has to lift or reiterate once the author answered.
|
||||
pub const ANSWER_DAYS: i64 = 3;
|
||||
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "lowercase")]
|
||||
pub enum ObjectionKind {
|
||||
/// The latest repeats the other: its author changes the article it repeats.
|
||||
Redit,
|
||||
/// The latest contradicts the other: its author rewords it.
|
||||
Contredit,
|
||||
}
|
||||
|
||||
impl ObjectionKind {
|
||||
pub fn as_str(self) -> &'static str {
|
||||
match self {
|
||||
Self::Redit => "redit",
|
||||
Self::Contredit => "contredit",
|
||||
}
|
||||
}
|
||||
|
||||
pub fn parse(raw: &str) -> Option<Self> {
|
||||
[Self::Redit, Self::Contredit].into_iter().find(|k| k.as_str() == raw)
|
||||
}
|
||||
}
|
||||
|
||||
/// One version of the pair, as it stands when the objection is raised.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
pub struct Side<'a> {
|
||||
pub id: &'a str,
|
||||
/// When it arrived (submitted), as a sortable stamp (RFC 3339 UTC).
|
||||
pub arrived_at: &'a str,
|
||||
/// The order it was written in, for versions of the same instant (a seed).
|
||||
pub seq: i64,
|
||||
pub in_force: bool,
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
pub enum PairError {
|
||||
/// A version does not object to itself.
|
||||
Same,
|
||||
/// Both are in force: nothing to hold back; a proposal changes one of them.
|
||||
BothInForce,
|
||||
}
|
||||
|
||||
fn arrival<'a>(s: &Side<'a>) -> (&'a str, i64) {
|
||||
(s.arrived_at, s.seq)
|
||||
}
|
||||
|
||||
/// The version an objection makes non-votable: the latest arrival of the two,
|
||||
/// unless it is in force — then the other one.
|
||||
pub fn target<'a>(a: &Side<'a>, b: &Side<'a>) -> Result<&'a str, PairError> {
|
||||
if a.id == b.id {
|
||||
return Err(PairError::Same);
|
||||
}
|
||||
if a.in_force && b.in_force {
|
||||
return Err(PairError::BothInForce);
|
||||
}
|
||||
let latest = match arrival(a).cmp(&arrival(b)) {
|
||||
Ordering::Greater => a,
|
||||
Ordering::Less => b,
|
||||
// Same instant, same rank: the larger id, so that the answer never depends on the order given.
|
||||
Ordering::Equal => {
|
||||
if a.id > b.id {
|
||||
a
|
||||
} else {
|
||||
b
|
||||
}
|
||||
}
|
||||
};
|
||||
let other = if latest.id == a.id { b } else { a };
|
||||
Ok(if latest.in_force { other.id } else { latest.id })
|
||||
}
|
||||
|
||||
/// Why an objection was lifted.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "lowercase")]
|
||||
pub enum Lift {
|
||||
/// By the person who raised it.
|
||||
Objector,
|
||||
/// By an administrator of trustinWallet.
|
||||
Admin,
|
||||
/// Nobody answered within the days given: lifted by itself.
|
||||
Deadline,
|
||||
/// One of the two versions was withdrawn.
|
||||
Withdrawn,
|
||||
/// A contradiction signal of before 2026-10-31 that lapsed when the text changed.
|
||||
Revision,
|
||||
}
|
||||
|
||||
impl Lift {
|
||||
pub fn as_str(self) -> &'static str {
|
||||
match self {
|
||||
Self::Objector => "objector",
|
||||
Self::Admin => "admin",
|
||||
Self::Deadline => "deadline",
|
||||
Self::Withdrawn => "withdrawn",
|
||||
Self::Revision => "revision",
|
||||
}
|
||||
}
|
||||
|
||||
pub fn parse(raw: &str) -> Option<Self> {
|
||||
[Self::Objector, Self::Admin, Self::Deadline, Self::Withdrawn, Self::Revision]
|
||||
.into_iter()
|
||||
.find(|l| l.as_str() == raw)
|
||||
}
|
||||
}
|
||||
|
||||
/// Where an objection stands. Cycles count from 1: a reiteration opens the next.
|
||||
#[derive(Clone, Debug, PartialEq, Eq)]
|
||||
pub enum Phase {
|
||||
/// Waiting for the author of the target to answer. No deadline: the version
|
||||
/// stays non-votable until the author answers or the objection is lifted.
|
||||
Author {
|
||||
cycle: u32,
|
||||
},
|
||||
/// The author answered: waiting for the objector, until `due` (sortable stamp).
|
||||
Objector {
|
||||
cycle: u32,
|
||||
due: String,
|
||||
},
|
||||
Lifted(Lift),
|
||||
}
|
||||
|
||||
impl Phase {
|
||||
pub fn standing(&self) -> bool {
|
||||
!matches!(self, Self::Lifted(_))
|
||||
}
|
||||
|
||||
pub fn cycle(&self) -> Option<u32> {
|
||||
match self {
|
||||
Self::Author { cycle } | Self::Objector { cycle, .. } => Some(*cycle),
|
||||
Self::Lifted(_) => None,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Who acts on an objection.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
pub enum Who {
|
||||
/// The person who raised it (an administrator who raised it acts as such).
|
||||
Objector,
|
||||
/// An administrator of trustinWallet who did not raise it.
|
||||
Admin,
|
||||
Other,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Eq)]
|
||||
pub enum Event<'a> {
|
||||
/// The author answered (`answers`); `due` = now + `ANSWER_DAYS`.
|
||||
Answered {
|
||||
due: &'a str,
|
||||
},
|
||||
Lift(Who),
|
||||
Reiterate(Who),
|
||||
/// The clock, as a sortable stamp.
|
||||
Tick {
|
||||
now: &'a str,
|
||||
},
|
||||
/// One of the two versions was withdrawn.
|
||||
Withdrawn,
|
||||
}
|
||||
|
||||
/// What a step asks the server to do besides storing the phase.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
pub enum Effect {
|
||||
/// Ask the objector: lift or reiterate, by the deadline of the phase.
|
||||
AskObjector,
|
||||
/// Tell the author of the target (again): answer the objection.
|
||||
TellAuthor,
|
||||
/// The objection ended: every message about it is done.
|
||||
Ended,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Eq)]
|
||||
pub struct Step {
|
||||
pub phase: Phase,
|
||||
pub effects: Vec<Effect>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
pub enum Refusal {
|
||||
/// The objection no longer stands.
|
||||
Lifted,
|
||||
/// Only the objector (or an administrator, to lift) may.
|
||||
NotYours,
|
||||
/// Reiterating answers an answer of the author: there is none yet.
|
||||
NothingToReiterate,
|
||||
}
|
||||
|
||||
/// One step of an objection. `Ok(None)`: nothing changes (an answer while the
|
||||
/// objector is already asked — one cycle at a time —, a tick before the
|
||||
/// deadline, an answer once lifted).
|
||||
pub fn step(phase: &Phase, event: &Event<'_>) -> Result<Option<Step>, Refusal> {
|
||||
let ended = |lift| Some(Step { phase: Phase::Lifted(lift), effects: vec![Effect::Ended] });
|
||||
match (phase, event) {
|
||||
(Phase::Lifted(_), Event::Answered { .. } | Event::Tick { .. } | Event::Withdrawn) => Ok(None),
|
||||
(Phase::Lifted(_), Event::Lift(_) | Event::Reiterate(_)) => Err(Refusal::Lifted),
|
||||
(Phase::Author { cycle }, Event::Answered { due }) => Ok(Some(Step {
|
||||
phase: Phase::Objector { cycle: *cycle, due: (*due).to_owned() },
|
||||
effects: vec![Effect::AskObjector],
|
||||
})),
|
||||
(Phase::Objector { .. }, Event::Answered { .. }) => Ok(None),
|
||||
(_, Event::Lift(Who::Objector)) => Ok(ended(Lift::Objector)),
|
||||
(_, Event::Lift(Who::Admin)) => Ok(ended(Lift::Admin)),
|
||||
(_, Event::Lift(Who::Other) | Event::Reiterate(Who::Admin | Who::Other)) => Err(Refusal::NotYours),
|
||||
(Phase::Author { .. }, Event::Reiterate(Who::Objector)) => Err(Refusal::NothingToReiterate),
|
||||
(Phase::Objector { cycle, .. }, Event::Reiterate(Who::Objector)) => {
|
||||
Ok(Some(Step { phase: Phase::Author { cycle: cycle + 1 }, effects: vec![Effect::TellAuthor] }))
|
||||
}
|
||||
(Phase::Objector { due, .. }, Event::Tick { now }) if due.as_str() <= *now => Ok(ended(Lift::Deadline)),
|
||||
(Phase::Author { .. } | Phase::Objector { .. }, Event::Tick { .. }) => Ok(None),
|
||||
(Phase::Author { .. } | Phase::Objector { .. }, Event::Withdrawn) => Ok(ended(Lift::Withdrawn)),
|
||||
}
|
||||
}
|
||||
|
||||
/// Which side of the pair a change touches.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
pub enum Touched {
|
||||
/// The version the objection holds back.
|
||||
Target,
|
||||
/// The version it is said to repeat or contradict.
|
||||
Other,
|
||||
}
|
||||
|
||||
/// A change made to the pair.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
pub enum Change {
|
||||
/// A substantive revision (a new revision, votes to reconfirm).
|
||||
Revision(Touched),
|
||||
/// A typo fix: the meaning stays, nothing is answered.
|
||||
Typo(Touched),
|
||||
/// A proposal linked to the objection was submitted (a draft answers nothing).
|
||||
LinkedProposal,
|
||||
}
|
||||
|
||||
/// Does this change answer the objection (and ask the objector)?
|
||||
pub fn answers(change: Change) -> bool {
|
||||
match change {
|
||||
Change::Revision(_) | Change::LinkedProposal => true,
|
||||
Change::Typo(_) => false,
|
||||
}
|
||||
}
|
||||
|
||||
/// Who may answer with a linked proposal: the author of the target; anyone of
|
||||
/// the group when the target has no author (a starting text).
|
||||
pub fn may_link(target_author: Option<&str>, who: &str) -> bool {
|
||||
target_author.is_none_or(|a| a == who)
|
||||
}
|
||||
|
||||
/// Where a linked proposal may go: on the article the target repeats or
|
||||
/// contradicts (« modifier l'article visé »), or on the target's own article (a
|
||||
/// reworded version, for a target without author).
|
||||
pub fn link_fits(unit: &str, target_unit: &str, other_unit: &str) -> bool {
|
||||
unit == other_unit || unit == target_unit
|
||||
}
|
||||
|
||||
/// Is a version votable? A version in force always is; any other is not while a
|
||||
/// standing objection holds it back. `held` = the targets of the standing objections.
|
||||
pub fn votable(id: &str, in_force: bool, held: &[&str]) -> bool {
|
||||
in_force || !held.contains(&id)
|
||||
}
|
||||
|
||||
/// May this choice be cast on a version? `held` = a standing objection holds it
|
||||
/// back now (never a version in force, `votable`). Yvv, 2026-11-01, to « pendant
|
||||
/// qu'une objection retient une version, que permet-on ? »: « Retirer ou
|
||||
/// "contre" ». On a held version one may take one's vote back or vote against,
|
||||
/// never add a *for*: it can only lose votes while it is held, and once lifted it
|
||||
/// enters with the votes still there. A version not held takes any choice.
|
||||
pub fn may_cast(held: bool, choice: Choice) -> bool {
|
||||
!held || choice != Choice::For
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn side<'a>(id: &'a str, at: &'a str, seq: i64, in_force: bool) -> Side<'a> {
|
||||
Side { id, arrived_at: at, seq, in_force }
|
||||
}
|
||||
|
||||
const T1: &str = "2026-10-30T10:00:00Z";
|
||||
const T2: &str = "2026-10-31T09:00:00Z";
|
||||
const DUE: &str = "2026-11-03T09:00:00Z";
|
||||
|
||||
#[test]
|
||||
fn the_latest_arrival_is_held_back() {
|
||||
let a = side("a", T1, 5, false);
|
||||
let b = side("b", T2, 3, false);
|
||||
assert_eq!(target(&a, &b), Ok("b"));
|
||||
assert_eq!(target(&b, &a), Ok("b"), "whatever the order given");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn same_instant_the_one_written_last() {
|
||||
// The versions of a seed share one second: the order they were written in decides.
|
||||
let a = side("zz", T1, 1, false);
|
||||
let b = side("aa", T1, 2, false);
|
||||
assert_eq!(target(&a, &b), Ok("aa"));
|
||||
assert_eq!(target(&b, &a), Ok("aa"));
|
||||
// Same rank too: the larger id, never the order of the arguments.
|
||||
let c = side("c", T1, 7, false);
|
||||
let d = side("d", T1, 7, false);
|
||||
assert_eq!((target(&c, &d), target(&d, &c)), (Ok("d"), Ok("d")));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_version_in_force_is_never_held_back() {
|
||||
// The latest arrival is in force (it entered after the other was submitted): the other is held.
|
||||
let older = side("old", T1, 1, false);
|
||||
let in_force = side("inf", T2, 2, true);
|
||||
assert_eq!(target(&older, &in_force), Ok("old"));
|
||||
// The usual case: a new proposal against the text in force.
|
||||
let seed = side("seed", T1, 1, true);
|
||||
let fresh = side("new", T2, 9, false);
|
||||
assert_eq!(target(&seed, &fresh), Ok("new"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn two_versions_in_force_or_one_alone_are_no_objection() {
|
||||
let a = side("a", T1, 1, true);
|
||||
let b = side("b", T2, 2, true);
|
||||
assert_eq!(target(&a, &b), Err(PairError::BothInForce));
|
||||
assert_eq!(target(&a, &a), Err(PairError::Same));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn votable_unless_held_and_never_in_force() {
|
||||
assert!(votable("x", false, &[]));
|
||||
assert!(!votable("x", false, &["x"]));
|
||||
assert!(votable("y", false, &["x"]));
|
||||
assert!(votable("x", true, &["x"]), "a version in force is never made non-votable");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_held_version_takes_against_and_withdrawal_never_for() {
|
||||
// Yvv, 2026-11-01: « Retirer ou "contre" » — it can only lose votes while held.
|
||||
assert!(!may_cast(true, Choice::For), "never a new *for* while held");
|
||||
assert!(may_cast(true, Choice::Against), "against, yes");
|
||||
assert!(may_cast(true, Choice::None), "taking one's vote back, yes");
|
||||
for c in [Choice::For, Choice::Against, Choice::None] {
|
||||
assert!(may_cast(false, c), "a version not held takes any choice: {c:?}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_full_cycle_answer_reiterate_answer_lift() {
|
||||
let start = Phase::Author { cycle: 1 };
|
||||
assert!(start.standing());
|
||||
// The objector cannot reiterate before the author answered.
|
||||
assert_eq!(step(&start, &Event::Reiterate(Who::Objector)), Err(Refusal::NothingToReiterate));
|
||||
// No deadline while the author has not answered.
|
||||
assert_eq!(step(&start, &Event::Tick { now: "2027-01-01T00:00:00Z" }), Ok(None));
|
||||
let asked = step(&start, &Event::Answered { due: DUE }).expect("ok").expect("changes");
|
||||
assert_eq!(asked.phase, Phase::Objector { cycle: 1, due: DUE.into() });
|
||||
assert_eq!(asked.effects, vec![Effect::AskObjector]);
|
||||
let again = step(&asked.phase, &Event::Reiterate(Who::Objector)).expect("ok").expect("changes");
|
||||
assert_eq!(again.phase, Phase::Author { cycle: 2 });
|
||||
assert_eq!(again.effects, vec![Effect::TellAuthor], "the author is told again");
|
||||
let asked2 = step(&again.phase, &Event::Answered { due: DUE }).expect("ok").expect("changes");
|
||||
assert_eq!(asked2.phase.cycle(), Some(2));
|
||||
let lifted = step(&asked2.phase, &Event::Lift(Who::Objector)).expect("ok").expect("changes");
|
||||
assert_eq!(lifted.phase, Phase::Lifted(Lift::Objector));
|
||||
assert_eq!(lifted.effects, vec![Effect::Ended]);
|
||||
assert!(!lifted.phase.standing());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn one_cycle_at_a_time() {
|
||||
let asked = Phase::Objector { cycle: 1, due: DUE.into() };
|
||||
// A second answer while the objector is asked: nothing changes, the deadline stays.
|
||||
assert_eq!(step(&asked, &Event::Answered { due: "2026-11-09T00:00:00Z" }), Ok(None));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn without_an_answer_of_the_objector_it_is_lifted_at_the_deadline() {
|
||||
let asked = Phase::Objector { cycle: 1, due: DUE.into() };
|
||||
assert_eq!(step(&asked, &Event::Tick { now: "2026-11-03T08:59:59Z" }), Ok(None));
|
||||
let done = step(&asked, &Event::Tick { now: DUE }).expect("ok").expect("changes");
|
||||
assert_eq!(done.phase, Phase::Lifted(Lift::Deadline));
|
||||
// A deadline passed while the server was stopped is taken at the next tick.
|
||||
let late = step(&asked, &Event::Tick { now: "2026-12-25T00:00:00Z" }).expect("ok").expect("changes");
|
||||
assert_eq!(late.phase, Phase::Lifted(Lift::Deadline));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn who_may_lift_and_reiterate() {
|
||||
for phase in [Phase::Author { cycle: 1 }, Phase::Objector { cycle: 1, due: DUE.into() }] {
|
||||
// The objector may change their mind at any time; an administrator may lift.
|
||||
assert_eq!(
|
||||
step(&phase, &Event::Lift(Who::Objector)).expect("ok").map(|s| s.phase),
|
||||
Some(Phase::Lifted(Lift::Objector))
|
||||
);
|
||||
assert_eq!(
|
||||
step(&phase, &Event::Lift(Who::Admin)).expect("ok").map(|s| s.phase),
|
||||
Some(Phase::Lifted(Lift::Admin))
|
||||
);
|
||||
assert_eq!(step(&phase, &Event::Lift(Who::Other)), Err(Refusal::NotYours));
|
||||
// Never reiterate someone else's objection, even as an administrator.
|
||||
assert_eq!(step(&phase, &Event::Reiterate(Who::Admin)), Err(Refusal::NotYours));
|
||||
assert_eq!(step(&phase, &Event::Reiterate(Who::Other)), Err(Refusal::NotYours));
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn withdrawing_either_version_ends_it_and_a_lifted_one_stays_lifted() {
|
||||
let w = step(&Phase::Author { cycle: 1 }, &Event::Withdrawn).expect("ok").expect("changes");
|
||||
assert_eq!(w.phase, Phase::Lifted(Lift::Withdrawn));
|
||||
let lifted = Phase::Lifted(Lift::Deadline);
|
||||
assert_eq!(step(&lifted, &Event::Lift(Who::Objector)), Err(Refusal::Lifted));
|
||||
assert_eq!(step(&lifted, &Event::Reiterate(Who::Objector)), Err(Refusal::Lifted));
|
||||
assert_eq!(step(&lifted, &Event::Answered { due: DUE }), Ok(None));
|
||||
assert_eq!(step(&lifted, &Event::Tick { now: DUE }), Ok(None));
|
||||
assert_eq!(step(&lifted, &Event::Withdrawn), Ok(None));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn what_answers_an_objection() {
|
||||
assert!(answers(Change::Revision(Touched::Target)), "the author rewords");
|
||||
assert!(answers(Change::Revision(Touched::Other)), "the other side changed: the objector judges again");
|
||||
assert!(answers(Change::LinkedProposal), "the article it repeats is changed");
|
||||
assert!(!answers(Change::Typo(Touched::Target)), "a typo keeps the meaning");
|
||||
assert!(!answers(Change::Typo(Touched::Other)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn who_answers_with_a_linked_proposal_and_where() {
|
||||
assert!(may_link(Some("chiara"), "chiara"));
|
||||
assert!(!may_link(Some("chiara"), "yvv"));
|
||||
assert!(may_link(None, "yvv"), "a starting text has no author: anyone of the group");
|
||||
assert!(link_fits("other-unit", "target-unit", "other-unit"), "the article it repeats");
|
||||
assert!(link_fits("target-unit", "target-unit", "other-unit"), "a reworded version");
|
||||
assert!(!link_fits("third", "target-unit", "other-unit"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn words_round_trip() {
|
||||
for k in [ObjectionKind::Redit, ObjectionKind::Contredit] {
|
||||
assert_eq!(ObjectionKind::parse(k.as_str()), Some(k));
|
||||
}
|
||||
for l in [Lift::Objector, Lift::Admin, Lift::Deadline, Lift::Withdrawn, Lift::Revision] {
|
||||
assert_eq!(Lift::parse(l.as_str()), Some(l));
|
||||
}
|
||||
assert_eq!(ObjectionKind::parse("signal"), None);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,333 @@
|
||||
//! The order of the articles of a section — a module of its own.
|
||||
//!
|
||||
//! Yvv, 2026-10-30: « En fin de section, il y a un "module" qui permet de
|
||||
//! changer l'ordre des "articles". Lui-même à (2), avec une simple liste
|
||||
//! clipable. » The order of a section is voted like an article: its version in
|
||||
//! force is the order the section reads in now; a proposal is another order.
|
||||
//!
|
||||
//! The rules, purely:
|
||||
//!
|
||||
//! - **who is in the order** (`members`): the articles of the section that a
|
||||
//! person can see — a text in force, or an open version. A draft is nobody's
|
||||
//! but its author's; an article whose versions were all withdrawn, or that
|
||||
//! was removed with nothing left open, is out;
|
||||
//! - **an order proposed** names every article of the section, each once
|
||||
//! (`check`);
|
||||
//! - **articles come and go** while an order is under vote: the proposal is
|
||||
//! **completed at the end** (`fit`). The articles it names that are still in
|
||||
//! the section keep its order; those that entered since go at the end, in the
|
||||
//! order of the section; those that left are dropped. Its votes stay: the
|
||||
//! voters chose the relative order of the articles they saw, and a newcomer
|
||||
//! at the end is where a new article goes anyway (`place_at_end`). Neither
|
||||
//! lapsing nor asking every voter again would let an order ever pass in a
|
||||
//! section that lives;
|
||||
//! - an order that, so completed, is the order in force changes nothing and
|
||||
//! cannot enter into force (the engine compares `canonical` texts);
|
||||
//! - **entering into force** gives the section's places out again in the new
|
||||
//! order (`positions`): the section keeps the positions it holds, so it never
|
||||
//! mingles with its neighbours;
|
||||
//! - an order of fewer than two articles has nothing to decide (`is_moot`).
|
||||
//!
|
||||
//! The vote itself — threshold, margin over the version in force (ADR 0002) —
|
||||
//! is that of any version (`compose`).
|
||||
|
||||
/// An article as the order of its section sees it.
|
||||
#[derive(Clone, Copy, Debug, PartialEq)]
|
||||
pub struct Article<'a> {
|
||||
pub id: &'a str,
|
||||
pub section: Option<&'a str>,
|
||||
pub position: f64,
|
||||
/// A text in force, or at least one open version (text or removal).
|
||||
pub visible: bool,
|
||||
}
|
||||
|
||||
/// Below this many articles, an order has nothing to decide.
|
||||
pub const MIN_ARTICLES: usize = 2;
|
||||
|
||||
pub fn is_moot(members: &[String]) -> bool {
|
||||
members.len() < MIN_ARTICLES
|
||||
}
|
||||
|
||||
/// The articles in the order of `section`, as it reads now: by position, then id.
|
||||
pub fn members(articles: &[Article<'_>], section: &str) -> Vec<String> {
|
||||
let mut inside: Vec<&Article<'_>> = articles.iter().filter(|a| a.visible && a.section == Some(section)).collect();
|
||||
inside.sort_by(|a, b| a.position.total_cmp(&b.position).then(a.id.cmp(b.id)));
|
||||
inside.into_iter().map(|a| a.id.to_owned()).collect()
|
||||
}
|
||||
|
||||
/// What an order proposal gives the section now.
|
||||
#[derive(Clone, Debug, Default, PartialEq, Eq)]
|
||||
pub struct Fitted {
|
||||
/// The order as it would read now.
|
||||
pub order: Vec<String>,
|
||||
/// Articles that entered the section since it was proposed: at the end.
|
||||
pub added: Vec<String>,
|
||||
/// Articles it names that left the section: dropped.
|
||||
pub dropped: Vec<String>,
|
||||
}
|
||||
|
||||
/// An order proposal against the section as it is now: completed at the end.
|
||||
pub fn fit(proposed: &[String], members: &[String]) -> Fitted {
|
||||
let mut order: Vec<String> = Vec::with_capacity(members.len());
|
||||
let mut dropped = Vec::new();
|
||||
for id in proposed {
|
||||
if order.contains(id) {
|
||||
continue;
|
||||
}
|
||||
if members.contains(id) {
|
||||
order.push(id.clone());
|
||||
} else if !dropped.contains(id) {
|
||||
dropped.push(id.clone());
|
||||
}
|
||||
}
|
||||
let added: Vec<String> = members.iter().filter(|m| !order.contains(m)).cloned().collect();
|
||||
order.extend(added.iter().cloned());
|
||||
Fitted { order, added, dropped }
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Eq)]
|
||||
pub enum OrderError {
|
||||
/// Not every article of the section, each once.
|
||||
NotTheSection,
|
||||
/// The order the section already reads in.
|
||||
Unchanged,
|
||||
}
|
||||
|
||||
/// An order proposed now: every article of the section, each once, in another order.
|
||||
pub fn check(proposed: &[String], members: &[String]) -> Result<(), OrderError> {
|
||||
let mut seen: Vec<&String> = Vec::with_capacity(proposed.len());
|
||||
for id in proposed {
|
||||
if seen.contains(&id) || !members.contains(id) {
|
||||
return Err(OrderError::NotTheSection);
|
||||
}
|
||||
seen.push(id);
|
||||
}
|
||||
if proposed.len() != members.len() {
|
||||
return Err(OrderError::NotTheSection);
|
||||
}
|
||||
if proposed == members {
|
||||
return Err(OrderError::Unchanged);
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// The order as a text: one article per line. It is the body of an order
|
||||
/// proposal, and what the engine compares to tell an order from another.
|
||||
pub fn canonical(order: &[String]) -> String {
|
||||
order.join("\n")
|
||||
}
|
||||
|
||||
/// The order a proposal's body holds; `None` when it holds none.
|
||||
pub fn decode(body: &str) -> Option<Vec<String>> {
|
||||
let ids: Vec<String> = body.lines().map(str::trim).map(str::to_owned).collect();
|
||||
let readable = !ids.is_empty() && ids.iter().all(|id| !id.is_empty() && !id.contains(char::is_whitespace));
|
||||
readable.then_some(ids)
|
||||
}
|
||||
|
||||
/// The places of the section, given out again in the new order: the section
|
||||
/// keeps the positions it holds (`current`, any order), the first of them goes
|
||||
/// to the first article of `order`, and so on. Articles of `order` that hold no
|
||||
/// place, and places beyond `order`, are left alone.
|
||||
pub fn positions(order: &[String], current: &[(String, f64)]) -> Vec<(String, f64)> {
|
||||
let ranked: Vec<&String> = order.iter().filter(|id| current.iter().any(|(c, _)| c == *id)).collect();
|
||||
let mut places: Vec<f64> = current.iter().filter(|(c, _)| ranked.contains(&c)).map(|(_, p)| *p).collect();
|
||||
places.sort_by(f64::total_cmp);
|
||||
ranked.into_iter().zip(places).map(|(id, p)| (id.clone(), p)).collect()
|
||||
}
|
||||
|
||||
/// Where a new article goes: at the end of its section — after the last article
|
||||
/// of the section (seen or not: a draft keeps its place), before the first one
|
||||
/// that follows it in the document. Without a section, or in a section not seen
|
||||
/// yet, after everything.
|
||||
pub fn place_at_end(articles: &[(Option<&str>, f64)], section: Option<&str>) -> f64 {
|
||||
let last_of_doc = articles.iter().map(|(_, p)| *p).fold(None, |m: Option<f64>, p| Some(m.map_or(p, |m| m.max(p))));
|
||||
let after_all = last_of_doc.map_or(1.0, |m| m + 1.0);
|
||||
let Some(section) = section else { return after_all };
|
||||
let Some(last) = articles
|
||||
.iter()
|
||||
.filter(|(s, _)| *s == Some(section))
|
||||
.map(|(_, p)| *p)
|
||||
.fold(None, |m: Option<f64>, p| Some(m.map_or(p, |m| m.max(p))))
|
||||
else {
|
||||
return after_all;
|
||||
};
|
||||
let next = articles
|
||||
.iter()
|
||||
.map(|(_, p)| *p)
|
||||
.filter(|p| *p > last)
|
||||
.fold(None, |m: Option<f64>, p| Some(m.map_or(p, |m| m.min(p))));
|
||||
next.map_or(last + 1.0, |next| (last + next) / 2.0)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn ids(list: &[&str]) -> Vec<String> {
|
||||
list.iter().map(|s| (*s).to_owned()).collect()
|
||||
}
|
||||
|
||||
fn art<'a>(id: &'a str, section: &'a str, position: f64, visible: bool) -> Article<'a> {
|
||||
Article { id, section: Some(section), position, visible }
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_order_holds_the_articles_one_can_see_in_the_order_they_read() {
|
||||
let doc = [
|
||||
art("I1", "introduction", 1.0, true),
|
||||
art("T1", "fondamental", 3.0, true),
|
||||
art("E2", "fondamental", 5.0, true),
|
||||
art("E1", "fondamental", 4.0, true),
|
||||
// A draft, or an article with nothing left open: out.
|
||||
art("N1", "fondamental", 5.5, false),
|
||||
];
|
||||
assert_eq!(members(&doc, "fondamental"), ids(&["T1", "E1", "E2"]));
|
||||
assert_eq!(members(&doc, "introduction"), ids(&["I1"]));
|
||||
assert!(members(&doc, "annexe").is_empty());
|
||||
// The same place: the identifier settles it.
|
||||
let tie = [art("b", "s", 1.0, true), art("a", "s", 1.0, true)];
|
||||
assert_eq!(members(&tie, "s"), ids(&["a", "b"]));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_order_of_fewer_than_two_articles_has_nothing_to_decide() {
|
||||
assert!(is_moot(&[]));
|
||||
assert!(is_moot(&ids(&["O1"])));
|
||||
assert!(!is_moot(&ids(&["K1", "K2"])));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_order_is_proposed_with_every_article_of_the_section_each_once() {
|
||||
let section = ids(&["T1", "E1", "E2"]);
|
||||
assert_eq!(check(&ids(&["E2", "E1", "T1"]), §ion), Ok(()));
|
||||
assert_eq!(check(&ids(&["T1", "E1", "E2"]), §ion), Err(OrderError::Unchanged));
|
||||
for wrong in [&["E2", "E1"][..], &["E2", "E1", "T1", "T1"], &["E2", "E1", "X9"], &["E2", "E2", "T1"], &[]] {
|
||||
assert_eq!(check(&ids(wrong), §ion), Err(OrderError::NotTheSection), "{wrong:?}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_proposal_still_fitting_the_section_is_read_as_it_was_proposed() {
|
||||
let section = ids(&["T1", "E1", "E2"]);
|
||||
let f = fit(&ids(&["E2", "T1", "E1"]), §ion);
|
||||
assert_eq!(f, Fitted { order: ids(&["E2", "T1", "E1"]), added: vec![], dropped: vec![] });
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_article_that_enters_the_section_goes_at_the_end_of_a_proposal() {
|
||||
// Proposed when the section held T1, E1, E2; N4 then N7 entered since.
|
||||
let now = ids(&["T1", "N4", "E1", "E2", "N7"]);
|
||||
let f = fit(&ids(&["E2", "E1", "T1"]), &now);
|
||||
assert_eq!(f.order, ids(&["E2", "E1", "T1", "N4", "N7"]), "the newcomers at the end, in the section's order");
|
||||
assert_eq!(f.added, ids(&["N4", "N7"]));
|
||||
assert!(f.dropped.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_article_that_leaves_the_section_is_dropped_from_a_proposal() {
|
||||
let now = ids(&["T1", "E2"]);
|
||||
let f = fit(&ids(&["E2", "E1", "T1"]), &now);
|
||||
assert_eq!(f.order, ids(&["E2", "T1"]));
|
||||
assert_eq!(f.dropped, ids(&["E1"]));
|
||||
assert!(f.added.is_empty());
|
||||
// Both at once; a repeated or unknown line never doubles an article.
|
||||
let f = fit(&ids(&["E2", "E2", "E1", "X1", "T1"]), &ids(&["T1", "E2", "N4"]));
|
||||
assert_eq!((f.order, f.added, f.dropped), (ids(&["E2", "T1", "N4"]), ids(&["N4"]), ids(&["E1", "X1"])));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_proposal_that_comes_back_to_the_order_in_force_changes_nothing() {
|
||||
// [E1, T1] proposed over [T1, E1]; E1 left: completed, it is the order in force.
|
||||
let now = ids(&["T1"]);
|
||||
assert_eq!(canonical(&fit(&ids(&["E1", "T1"]), &now).order), canonical(&now));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_body_of_an_order_proposal_is_one_article_per_line() {
|
||||
let order = ids(&["a1f0", "77c2", "09bd"]);
|
||||
assert_eq!(canonical(&order), "a1f0\n77c2\n09bd");
|
||||
assert_eq!(decode(&canonical(&order)), Some(order));
|
||||
assert_eq!(decode("a1\r\nb2"), Some(ids(&["a1", "b2"])));
|
||||
for unreadable in ["", "a1\n\nb2", "a1 b2", "\n"] {
|
||||
assert_eq!(decode(unreadable), None, "{unreadable:?}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn entering_into_force_gives_the_places_of_the_section_out_again() {
|
||||
let current = vec![("T1".to_owned(), 3.0), ("E1".to_owned(), 4.0), ("E2".to_owned(), 5.0)];
|
||||
let mut moved = positions(&ids(&["E2", "T1", "E1"]), ¤t);
|
||||
moved.sort_by(|a, b| a.0.cmp(&b.0));
|
||||
assert_eq!(moved, vec![("E1".to_owned(), 5.0), ("E2".to_owned(), 3.0), ("T1".to_owned(), 4.0)]);
|
||||
// Uneven places stay the section's own: the neighbours never mingle with it.
|
||||
let uneven = vec![("a".to_owned(), 6.0), ("b".to_owned(), 6.5), ("c".to_owned(), 11.0)];
|
||||
assert_eq!(
|
||||
positions(&ids(&["c", "a", "b"]), &uneven),
|
||||
vec![("c".to_owned(), 6.0), ("a".to_owned(), 6.5), ("b".to_owned(), 11.0)]
|
||||
);
|
||||
// An article of the order without a place is left out; a place without an article is kept.
|
||||
assert_eq!(positions(&ids(&["z", "b"]), &uneven), vec![("b".to_owned(), 6.5)]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_order_is_voted_like_any_version_with_the_margin_of_the_one_in_force() {
|
||||
use crate::compose::{ProposalKind, ProposalState, UnitState, VersionText, adoptions};
|
||||
use crate::inertia::{InertiaLevel, Scale};
|
||||
use crate::threshold::Tally;
|
||||
// Monnaie libre before any median: W 5745, p0 = 100 / W; the order of a section at
|
||||
// level 2, on the quorum of that level, 12 (ADR 0013).
|
||||
let scale = Scale { electorate: 5745, participation: 100.0 / 5745.0, quorums: [12, 12, 50, 50, 50] };
|
||||
let section = ids(&["T1", "E1", "E2"]);
|
||||
let module = |tally: Tally| UnitState {
|
||||
id: "ordre.fondamental".into(),
|
||||
position: 0.0,
|
||||
inertia: InertiaLevel::new(2).expect("level"),
|
||||
quorum: None,
|
||||
in_force: Some(VersionText { id: "seed".into(), title: String::new(), body: canonical(§ion), tally }),
|
||||
};
|
||||
let proposal = |id: &str, order: &[&str], for_votes: u32| ProposalState {
|
||||
id: id.into(),
|
||||
unit_id: "ordre.fondamental".into(),
|
||||
kind: ProposalKind::Text,
|
||||
title: String::new(),
|
||||
body: canonical(&fit(&ids(order), §ion).order),
|
||||
tally: Tally { for_votes, against: 0 },
|
||||
submitted_at: "2026-10-31T00:00:00Z".into(),
|
||||
blocked: false,
|
||||
};
|
||||
let seed = [module(Tally::default())];
|
||||
// Never under the bar of the quorum (9 « pour » of 12 voters); the order in force at zero
|
||||
// votes is a seed a minima: the margin of zero votes, −9.
|
||||
assert!(adoptions(&seed, &[proposal("p", &["E2", "E1", "T1"], 8)], &scale).expect("valid").is_empty());
|
||||
assert_eq!(adoptions(&seed, &[proposal("p", &["E2", "E1", "T1"], 9)], &scale).expect("valid"), ["p"]);
|
||||
// An order that is the one in force changes nothing, whatever its votes.
|
||||
assert!(adoptions(&seed, &[proposal("same", &["T1", "E1", "E2"], 80)], &scale).expect("valid").is_empty());
|
||||
// Adopted by a vote, the order in force holds its margin (70 − 48): a weaker order waits
|
||||
// (60 − 42), a stronger one passes (90 − 61).
|
||||
let voted = [module(Tally { for_votes: 70, against: 0 })];
|
||||
assert!(adoptions(&voted, &[proposal("w", &["E2", "E1", "T1"], 60)], &scale).expect("valid").is_empty());
|
||||
assert_eq!(adoptions(&voted, &[proposal("s", &["E2", "E1", "T1"], 90)], &scale).expect("valid"), ["s"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_new_article_goes_at_the_end_of_its_section() {
|
||||
let doc = [
|
||||
(Some("introduction"), 1.0),
|
||||
(Some("introduction"), 2.0),
|
||||
(Some("fondamental"), 3.0),
|
||||
(Some("fondamental"), 5.0),
|
||||
(Some("technique"), 6.0),
|
||||
];
|
||||
// Between the last of its section and the first that follows it.
|
||||
assert_eq!(place_at_end(&doc, Some("introduction")), 2.5);
|
||||
assert_eq!(place_at_end(&doc, Some("fondamental")), 5.5);
|
||||
// The last section of the document, an unknown section, or none: after everything.
|
||||
assert_eq!(place_at_end(&doc, Some("technique")), 7.0);
|
||||
assert_eq!(place_at_end(&doc, Some("annexe")), 7.0);
|
||||
assert_eq!(place_at_end(&doc, None), 7.0);
|
||||
assert_eq!(place_at_end(&[], Some("introduction")), 1.0);
|
||||
// After the ones already placed at its end.
|
||||
let again = [(Some("fondamental"), 5.0), (Some("fondamental"), 5.5), (Some("technique"), 6.0)];
|
||||
assert_eq!(place_at_end(&again, Some("fondamental")), 5.75);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,122 @@
|
||||
//! What is left to vote in a document — the line of « Décisions ».
|
||||
//!
|
||||
//! A module is an article, the title of the document, or the order of a
|
||||
//! section (ADR 0009) — a unit. It stays **to vote** as long as no version
|
||||
//! of it was adopted by a vote: its version in force is a seed a minima not yet
|
||||
//! confirmed, or it has no version in force and at least one open version.
|
||||
//! Variants of an article count once. The order of a section of fewer than two
|
||||
//! articles has nothing to decide: never to vote. For a person who votes, a module is
|
||||
//! **without their vote** when they hold no valid vote (one not « to
|
||||
//! reconfirm ») on any of its versions.
|
||||
|
||||
/// One article, as one person sees it.
|
||||
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
|
||||
pub struct Module {
|
||||
/// The version in force, if any: `Some(true)` when a vote adopted or
|
||||
/// confirmed it, `Some(false)` for a seed a minima.
|
||||
pub in_force: Option<bool>,
|
||||
/// Open versions of the article (proposals to replace, add or remove it).
|
||||
pub open: u32,
|
||||
/// The person holds a valid vote on one of its versions, in force or open.
|
||||
pub voted: bool,
|
||||
/// Nothing to decide: the order of a section of fewer than two articles
|
||||
/// (`order::is_moot`). Never to vote, and not shown.
|
||||
pub moot: bool,
|
||||
}
|
||||
|
||||
impl Module {
|
||||
pub fn to_vote(&self) -> bool {
|
||||
if self.moot {
|
||||
return false;
|
||||
}
|
||||
match self.in_force {
|
||||
Some(adopted) => !adopted,
|
||||
None => self.open > 0,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
|
||||
pub struct Pending {
|
||||
/// Modules not yet adopted by a vote.
|
||||
pub to_vote: u32,
|
||||
/// Among them, those where the person has no valid vote.
|
||||
pub without_my_vote: u32,
|
||||
}
|
||||
|
||||
pub fn pending(modules: &[Module]) -> Pending {
|
||||
modules.iter().filter(|m| m.to_vote()).fold(Pending::default(), |acc, m| Pending {
|
||||
to_vote: acc.to_vote + 1,
|
||||
without_my_vote: acc.without_my_vote + u32::from(!m.voted),
|
||||
})
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
const SEED: Option<bool> = Some(false);
|
||||
const ADOPTED: Option<bool> = Some(true);
|
||||
|
||||
fn m(in_force: Option<bool>, open: u32, voted: bool) -> Module {
|
||||
Module { in_force, open, voted, moot: false }
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_seed_a_minima_stays_to_vote_until_a_vote_confirms_it() {
|
||||
assert!(m(SEED, 0, false).to_vote());
|
||||
assert!(m(SEED, 2, true).to_vote());
|
||||
assert!(!m(ADOPTED, 0, false).to_vote());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_adopted_article_is_no_longer_to_vote_even_with_rivals() {
|
||||
assert!(!m(ADOPTED, 3, false).to_vote());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_empty_slot_is_to_vote_only_with_an_open_version() {
|
||||
assert!(m(None, 1, false).to_vote());
|
||||
assert!(!m(None, 0, false).to_vote());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn variants_count_once_and_my_vote_on_any_version_counts() {
|
||||
// Seed of the certification act: 3 seeds a minima, 30 open articles (two with a variant).
|
||||
let mut modules = vec![m(SEED, 0, false); 3];
|
||||
modules.extend(vec![m(None, 1, false); 28]);
|
||||
modules.extend(vec![m(None, 2, false); 2]);
|
||||
assert_eq!(pending(&modules), Pending { to_vote: 33, without_my_vote: 33 });
|
||||
modules[0].voted = true;
|
||||
modules[31].voted = true;
|
||||
assert_eq!(pending(&modules), Pending { to_vote: 33, without_my_vote: 31 });
|
||||
// Once adopted, an article leaves both counts, voted or not.
|
||||
modules[1].in_force = ADOPTED;
|
||||
modules[31].in_force = ADOPTED;
|
||||
assert_eq!(pending(&modules), Pending { to_vote: 31, without_my_vote: 30 });
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_order_with_nothing_to_decide_is_never_to_vote() {
|
||||
let one_article = Module { moot: true, ..m(SEED, 1, false) };
|
||||
assert!(!one_article.to_vote());
|
||||
assert_eq!(pending(&[one_article]), Pending::default());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_modules_of_the_certification_act() {
|
||||
// 33 articles; the title of the document; the order of each of the 8 sections,
|
||||
// all seeds a minima — but « ordonnancement » holds one article: 41 to vote.
|
||||
let mut modules = vec![m(SEED, 0, false); 3];
|
||||
modules.extend(vec![m(None, 1, false); 30]);
|
||||
modules.push(m(SEED, 0, false));
|
||||
modules.extend(vec![m(SEED, 0, false); 7]);
|
||||
modules.push(Module { moot: true, ..m(SEED, 0, false) });
|
||||
assert_eq!(pending(&modules), Pending { to_vote: 41, without_my_vote: 41 });
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn nothing_to_vote_in_an_empty_document() {
|
||||
assert_eq!(pending(&[]), Pending::default());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,438 @@
|
||||
//! Inertial threshold for binary votes — the Ğ1 "g1vote" formula, judged on a quorum.
|
||||
//!
|
||||
//! ```text
|
||||
//! T' = max(T, Q)
|
||||
//! R = B^W + (M + (1 − M)(1 − (T'/W)^G)) · T'
|
||||
//! ```
|
||||
//!
|
||||
//! R is the minimum number of *for* votes; a version passes when `for ≥ ⌈R⌉`.
|
||||
//! W is the size of the electorate, T the votes cast (for + against). The share of
|
||||
//! *for* asked is judged on T' voters, never fewer than the quorum: it is at its
|
||||
//! highest at the quorum (the share of the level at T' = Q), then falls as turnout
|
||||
//! rises above it, down to the simple majority M when the whole electorate votes.
|
||||
//! Below the quorum it does not rise any further: the voters missing count as
|
||||
//! against. Q is the **quorum** (ADR 0013, Yvv, 2026-11-02:
|
||||
//! « ce n'est pas un nombre de vote pour a priori, c'est plutôt un quorum, à partir
|
||||
//! de ce nombre, alors il faut la bonne proportion attendue par l'indice ») — and
|
||||
//! below it the voters missing count as against (`judged_voters`). It replaces the
|
||||
//! floor C of *for* votes added in front of the formula (ADR 0002, 0007).
|
||||
//!
|
||||
//! Parity with `maquette/frontend/app/engine/threshold.ts` at Q = 0 (the C = 0 of
|
||||
//! the maquette), with two deliberate guards — the participation ratio is capped
|
||||
//! at 1, so a stale W can never lower the bar below M; and a count that lands on a
|
||||
//! whole number stays on it (`settle`), so a rounding error never adds a vote.
|
||||
//! Which M, G and Q a text is judged by: `inertia` (five levels, their quorums).
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
/// The letters of the formula, named.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Serialize, Deserialize)]
|
||||
pub struct FormulaParams {
|
||||
/// M, in percent (0–100).
|
||||
pub majority_pct: f64,
|
||||
/// G.
|
||||
pub gradient: f64,
|
||||
/// B.
|
||||
pub base: f64,
|
||||
/// Q — the quorum: the voters (for + against) from which the share of the inertia
|
||||
/// applies; below it, the voters missing count as against (`judged_voters`).
|
||||
pub quorum: u32,
|
||||
}
|
||||
|
||||
impl FormulaParams {
|
||||
pub const DEFAULT_BASE: f64 = 0.1;
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum ThresholdError {
|
||||
EmptyElectorate,
|
||||
MajorityOutOfRange,
|
||||
}
|
||||
|
||||
/// T' — the voters a version is judged on. **The rule of the quorum, here alone.**
|
||||
///
|
||||
/// Yvv, 2026-11-03, to « Sous le quorum, les votants qui manquent comptent-ils comme des
|
||||
/// « non » ? » : « Ils comptent comme non ». Below the quorum Q, the voters who did not
|
||||
/// come count as against: the share of the inertia is asked of max(T, Q) voters. At level
|
||||
/// 2, quorum 12, 9 « pour » pass with 3 « contre » at most; Yvv's own example, 60 % and
|
||||
/// Q = 10, takes 6 « pour ». So an « against » never helps a version pass — completing
|
||||
/// the quorum with a « non » adds nothing the missing voters did not already count.
|
||||
pub fn judged_voters(cast: u32, quorum: u32) -> u32 {
|
||||
cast.max(quorum)
|
||||
}
|
||||
|
||||
/// The share of *for* votes asked of `voters` voters (T' when judging a version).
|
||||
pub fn required_ratio(electorate: u32, voters: u32, params: &FormulaParams) -> f64 {
|
||||
let m = params.majority_pct / 100.0;
|
||||
let participation = (f64::from(voters) / f64::from(electorate.max(1))).min(1.0);
|
||||
m + (1.0 - m) * (1.0 - participation.powf(params.gradient))
|
||||
}
|
||||
|
||||
/// ⌈R⌉ — the minimum number of *for* votes for adoption, at `cast` votes cast.
|
||||
pub fn wot_threshold(electorate: u32, cast: u32, params: &FormulaParams) -> Result<u32, ThresholdError> {
|
||||
if electorate == 0 {
|
||||
return Err(ThresholdError::EmptyElectorate);
|
||||
}
|
||||
if !(0.0..=100.0).contains(¶ms.majority_pct) {
|
||||
return Err(ThresholdError::MajorityOutOfRange);
|
||||
}
|
||||
let voters = judged_voters(cast, params.quorum);
|
||||
let offset = params.base.powf(f64::from(electorate));
|
||||
let raw = if voters == 0 {
|
||||
offset
|
||||
} else {
|
||||
offset + settle(required_ratio(electorate, voters, params) * f64::from(voters))
|
||||
};
|
||||
// `raw` is finite and non-negative for any valid input; saturate rather than wrap.
|
||||
Ok(clamp_ceil(raw))
|
||||
}
|
||||
|
||||
/// Does a version with this tally pass? At least one vote cast, and its *for* reach
|
||||
/// ⌈R⌉ on the voters it is judged on (`judged_voters`).
|
||||
pub fn passes(tally: Tally, electorate: u32, params: &FormulaParams) -> Result<bool, ThresholdError> {
|
||||
let cast = tally.cast();
|
||||
Ok(cast > 0 && tally.for_votes >= wot_threshold(electorate, cast, params)?)
|
||||
}
|
||||
|
||||
/// A count of votes that lands on a whole number stays on it: a rounding error
|
||||
/// of the logarithms (a billionth of a vote) must never raise a threshold by
|
||||
/// one. At the usual participation, 90 % of 70 votes is exactly 63.
|
||||
fn settle(votes: f64) -> f64 {
|
||||
let whole = votes.round();
|
||||
if (votes - whole).abs() < 1e-9 { whole } else { votes }
|
||||
}
|
||||
|
||||
fn clamp_ceil(raw: f64) -> u32 {
|
||||
let ceiled = raw.ceil();
|
||||
if ceiled <= 0.0 {
|
||||
0
|
||||
} else if ceiled >= f64::from(u32::MAX) {
|
||||
u32::MAX
|
||||
} else {
|
||||
// Exact: `ceiled` is an integer in (0, u32::MAX).
|
||||
ceiled as u32
|
||||
}
|
||||
}
|
||||
|
||||
/// Votes counted on one proposal.
|
||||
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub struct Tally {
|
||||
#[serde(rename = "for")]
|
||||
pub for_votes: u32,
|
||||
pub against: u32,
|
||||
}
|
||||
|
||||
impl Tally {
|
||||
pub fn cast(&self) -> u32 {
|
||||
self.for_votes.saturating_add(self.against)
|
||||
}
|
||||
}
|
||||
|
||||
/// Where a proposal stands, in words a screen can say.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct Evaluation {
|
||||
pub cast: u32,
|
||||
/// Q, the quorum it is judged on.
|
||||
pub quorum: u32,
|
||||
/// ⌈R⌉ at the current participation (on T' = max(T, Q) voters).
|
||||
pub required: u32,
|
||||
/// `passes`: `for ≥ ⌈R⌉` with at least one vote cast.
|
||||
pub reached: bool,
|
||||
/// *for* votes still missing with the *against* of now: `yes_needed − for` (0 once reached).
|
||||
pub missing: u32,
|
||||
/// The share of *for* the inertia asks of T' voters.
|
||||
pub required_ratio: f64,
|
||||
/// « Bien cotée » : it holds the share its inertia asks of the T voters who came,
|
||||
/// it only lacks voters (the quorum ignored). Used by the "if the well-rated pass" preview.
|
||||
pub well_rated: bool,
|
||||
/// The total of *for* that passes with the *against* cast now.
|
||||
pub yes_needed: u32,
|
||||
/// The total of *against* that holds the *for* cast now in check: 0 when the quorum
|
||||
/// already holds them (they do not pass even without any *against*).
|
||||
pub no_needed: u32,
|
||||
}
|
||||
|
||||
/// The number of votes searched for never goes past this: the five levels always find
|
||||
/// theirs well before (`yes_needed`, `no_needed`).
|
||||
const SEARCH_LIMIT: u32 = u32::MAX / 4;
|
||||
|
||||
/// The smallest `n` in `0..` for which `holds(n)`, `holds` being monotone (false, then
|
||||
/// true); `u32::MAX` if it never holds up to `SEARCH_LIMIT`.
|
||||
fn first(mut holds: impl FnMut(u32) -> Result<bool, ThresholdError>) -> Result<u32, ThresholdError> {
|
||||
if holds(0)? {
|
||||
return Ok(0);
|
||||
}
|
||||
let (mut lo, mut hi) = (0u32, 1u32);
|
||||
while !holds(hi)? {
|
||||
if hi >= SEARCH_LIMIT {
|
||||
return Ok(u32::MAX);
|
||||
}
|
||||
lo = hi;
|
||||
hi = hi.saturating_mul(2).min(SEARCH_LIMIT);
|
||||
}
|
||||
// holds(lo) is false, holds(hi) true.
|
||||
while hi - lo > 1 {
|
||||
let mid = lo + (hi - lo) / 2;
|
||||
if holds(mid)? { hi = mid } else { lo = mid }
|
||||
}
|
||||
Ok(hi)
|
||||
}
|
||||
|
||||
/// The total of *for* that passes with `against` votes against. A *for* more never
|
||||
/// hurts (`passes` is monotone): every total from this one on passes.
|
||||
pub fn yes_needed(against: u32, electorate: u32, params: &FormulaParams) -> Result<u32, ThresholdError> {
|
||||
first(|y| passes(Tally { for_votes: y, against }, electorate, params))
|
||||
}
|
||||
|
||||
/// The total of *against* that holds `for_votes` in check; 0 when they do not pass even
|
||||
/// without any *against* (the quorum holds them). An *against* more never helps: every
|
||||
/// total from this one on holds them.
|
||||
pub fn no_needed(for_votes: u32, electorate: u32, params: &FormulaParams) -> Result<u32, ThresholdError> {
|
||||
first(|n| passes(Tally { for_votes, against: n }, electorate, params).map(|p| !p))
|
||||
}
|
||||
|
||||
pub fn evaluate(tally: Tally, electorate: u32, params: &FormulaParams) -> Result<Evaluation, ThresholdError> {
|
||||
let cast = tally.cast();
|
||||
let required = wot_threshold(electorate, cast, params)?;
|
||||
let reached = passes(tally, electorate, params)?;
|
||||
let without_quorum = FormulaParams { quorum: 0, ..*params };
|
||||
let required_without_quorum = wot_threshold(electorate, cast, &without_quorum)?;
|
||||
let yes = yes_needed(tally.against, electorate, params)?;
|
||||
Ok(Evaluation {
|
||||
cast,
|
||||
quorum: params.quorum,
|
||||
required,
|
||||
reached,
|
||||
missing: if reached { 0 } else { yes.saturating_sub(tally.for_votes) },
|
||||
required_ratio: required_ratio(electorate, judged_voters(cast, params.quorum), params),
|
||||
well_rated: cast > 0 && tally.for_votes >= required_without_quorum,
|
||||
yes_needed: yes,
|
||||
no_needed: no_needed(tally.for_votes, electorate, params)?,
|
||||
})
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn g1vote(m: f64, g: f64) -> FormulaParams {
|
||||
FormulaParams { majority_pct: m, gradient: g, base: 0.1, quorum: 0 }
|
||||
}
|
||||
|
||||
/// The letters of the « standard » inertia before the five levels: M 50 %, G 0.2.
|
||||
fn standard(quorum: u32) -> FormulaParams {
|
||||
FormulaParams { quorum, ..g1vote(50.0, 0.2) }
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn reference_case_of_the_smith_vote() {
|
||||
// Engagement forgeron v2.0.0: W 7224, 97 for, 23 against, M50 B.1 G.2 → 94.
|
||||
// The formula's own reference, letters given, without a quorum (Q = 0, the C = 0
|
||||
// of the maquette): the five levels choose M, G and Q (ADR 0007, 0013).
|
||||
let p = g1vote(50.0, 0.2);
|
||||
assert_eq!(wot_threshold(7224, 120, &p), Ok(94));
|
||||
let e = evaluate(Tally { for_votes: 97, against: 23 }, 7224, &p).expect("valid");
|
||||
assert!(e.reached);
|
||||
assert_eq!(e.missing, 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_share_is_highest_at_the_quorum_then_falls_to_the_majority() {
|
||||
// What the head of this module says (lot U2, review of M6c): judged on T' voters, the
|
||||
// share of *for* is at its highest at the quorum, falls as turnout rises, and is M when
|
||||
// the whole electorate votes; below the quorum it stays that of the quorum.
|
||||
let (w, q) = (5745, 12);
|
||||
let p = standard(q);
|
||||
let share = |cast: u32| required_ratio(w, judged_voters(cast, q), &p);
|
||||
for cast in 0..q {
|
||||
assert!((share(cast) - share(q)).abs() < 1e-12, "below the quorum, at {cast}");
|
||||
}
|
||||
let mut last = share(q);
|
||||
for cast in (q + 1..w).step_by(7).chain([w]) {
|
||||
let now = share(cast);
|
||||
assert!(now < last, "falls at {cast}: {now} after {last}");
|
||||
last = now;
|
||||
}
|
||||
assert!((share(w) - 0.5).abs() < 1e-12, "M at full turnout");
|
||||
// Never « near-unanimity »: at this level, 85 % of the quorum.
|
||||
assert!(share(q) > 0.8 && share(q) < 0.9, "at the quorum: {}", share(q));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parity_with_the_typescript_reference() {
|
||||
// Values computed by maquette/frontend/app/engine/threshold.ts (C = 0).
|
||||
let p = g1vote(50.0, 0.2);
|
||||
assert_eq!(wot_threshold(7224, 0, &p), Ok(0));
|
||||
assert_eq!(wot_threshold(7224, 1, &p), Ok(1));
|
||||
assert_eq!(wot_threshold(100, 100, &p), Ok(50));
|
||||
assert_eq!(wot_threshold(5, 5, &p), Ok(3));
|
||||
let high = g1vote(60.0, 0.4);
|
||||
assert_eq!(wot_threshold(7224, 120, &high), Ok(111));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn yvv_s_example_sixty_percent_and_a_quorum_of_ten() {
|
||||
// Yvv, 2026-11-02: « Si c'est 60% et c=10, alors il faut 6 voix pour et ça passe. »
|
||||
// A share of 60 % whatever the participation (G = 0), a large web (B^W vanishes).
|
||||
let p = FormulaParams { majority_pct: 60.0, gradient: 0.0, base: 0.1, quorum: 10 };
|
||||
// G = 0: (T/W)^0 = 1, the share is M, 60 %.
|
||||
assert!((required_ratio(7224, 10, &p) - 0.6).abs() < 1e-12);
|
||||
for against in 0..=4 {
|
||||
let t = Tally { for_votes: 6, against };
|
||||
assert_eq!(passes(t, 7224, &p), Ok(true), "6 pour, {against} contre");
|
||||
}
|
||||
assert_eq!(passes(Tally { for_votes: 5, against: 0 }, 7224, &p), Ok(false), "5 of the 10 counted");
|
||||
assert_eq!(wot_threshold(7224, 6, &p), Ok(6));
|
||||
// Beyond the quorum, the share of all who came: 6 pour and 5 contre are 11 voters.
|
||||
assert_eq!(passes(Tally { for_votes: 6, against: 5 }, 7224, &p), Ok(false));
|
||||
assert_eq!(passes(Tally { for_votes: 7, against: 4 }, 7224, &p), Ok(true));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn under_the_quorum_the_voters_missing_count_as_against() {
|
||||
// Yvv, 2026-11-03: « Ils comptent comme non ». T' = max(T, Q).
|
||||
assert_eq!(
|
||||
(judged_voters(0, 12), judged_voters(9, 12), judged_voters(12, 12), judged_voters(30, 12)),
|
||||
(12, 12, 12, 30)
|
||||
);
|
||||
let p = standard(50);
|
||||
// One « pour » alone is judged on 50 voters; so are 1 « pour » and 49 « contre ».
|
||||
assert_eq!(wot_threshold(5745, 1, &p), wot_threshold(5745, 50, &p));
|
||||
assert!(!passes(Tally { for_votes: 1, against: 0 }, 5745, &p).expect("valid"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_against_never_helps_a_version_pass() {
|
||||
// Without the rule, an « against » that completes the quorum could make a text pass.
|
||||
for (m, g, q, w) in [(50.0, 0.1, 12, 5707), (55.0, 0.15, 50, 5707), (66.0, 0.3, 50, 5745), (50.0, 0.6, 1, 2)] {
|
||||
let p = FormulaParams { majority_pct: m, gradient: g, base: 0.1, quorum: q };
|
||||
for y in 0..80 {
|
||||
let mut passed = passes(Tally { for_votes: y, against: 0 }, w, &p).expect("valid");
|
||||
for n in 1..80 {
|
||||
let now = passes(Tally { for_votes: y, against: n }, w, &p).expect("valid");
|
||||
assert!(passed || !now, "M {m} Q {q} W {w}: {y} pour, {n} contre passes, {} contre did not", n - 1);
|
||||
passed = now;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn one_vote_never_passes_with_a_quorum_of_two_or_more() {
|
||||
// The invariant of Monnaie libre (quorum 2 at least): one voice alone never passes.
|
||||
for q in [2, 12, 50] {
|
||||
for g in [0.05, 0.1, 0.3, 1.77] {
|
||||
let p = FormulaParams { majority_pct: 50.0, gradient: g, base: 0.1, quorum: q };
|
||||
let e = evaluate(Tally { for_votes: 1, against: 0 }, 5745, &p).expect("valid");
|
||||
assert!(!e.reached, "Q {q}, G {g}");
|
||||
assert!(e.well_rated, "unanimous, it only lacks voters");
|
||||
}
|
||||
}
|
||||
// Without a quorum, the trap of a permanent vote: a lone « pour » meets the inertial bar.
|
||||
assert!(evaluate(Tally { for_votes: 1, against: 0 }, 5745, &standard(0)).expect("valid").reached);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn yes_needed_and_no_needed_say_where_the_line_is() {
|
||||
// At level 2 on the scale of Monnaie libre (G 0.10274, W 5707), quorum 12.
|
||||
let p = FormulaParams { majority_pct: 50.0, gradient: 0.102_742, base: 0.1, quorum: 12 };
|
||||
let e = evaluate(Tally::default(), 5707, &p).expect("valid");
|
||||
assert_eq!((e.quorum, e.required, e.yes_needed, e.missing, e.no_needed), (12, 9, 9, 9, 0));
|
||||
// 9 « pour »: they pass with 3 « contre » at most; the 4th holds them.
|
||||
let e = evaluate(Tally { for_votes: 9, against: 0 }, 5707, &p).expect("valid");
|
||||
assert!(e.reached);
|
||||
assert_eq!((e.yes_needed, e.no_needed, e.missing), (9, 4, 0));
|
||||
// With a 4th « contre », 14 voters at least: 11 « pour » are asked (10,2 rounded up).
|
||||
let e = evaluate(Tally { for_votes: 9, against: 4 }, 5707, &p).expect("valid");
|
||||
assert!(!e.reached);
|
||||
assert_eq!(e.yes_needed, 11);
|
||||
assert_eq!(e.missing, 2);
|
||||
// 8 « pour »: the quorum already holds them, no « contre » is needed.
|
||||
let e = evaluate(Tally { for_votes: 8, against: 0 }, 5707, &p).expect("valid");
|
||||
assert_eq!((e.reached, e.no_needed, e.missing), (false, 0, 1));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn passes_means_yes_needed_reached_means_no_needed_not_reached() {
|
||||
// On a grid: levels (M, G as at the reference participation and beyond), quorums,
|
||||
// electorates, « pour », « contre ». passes ⇔ for ≥ yesNeeded(against) ⇔ against < noNeeded(for).
|
||||
let levels = [(50.0, 0.055), (50.0, 0.103), (55.0, 0.145), (60.0, 0.212), (66.0, 0.303), (66.0, 1.77)];
|
||||
for (m, g) in levels {
|
||||
for q in [0, 1, 2, 12, 50] {
|
||||
for w in [1, 2, 16, 40, 500, 5707] {
|
||||
let p = FormulaParams { majority_pct: m, gradient: g, base: 0.1, quorum: q };
|
||||
let yes: Vec<u32> = (0..70).map(|n| yes_needed(n, w, &p).expect("valid")).collect();
|
||||
for y in 0..70 {
|
||||
let no = no_needed(y, w, &p).expect("valid");
|
||||
for n in 0..70 {
|
||||
let t = Tally { for_votes: y, against: n };
|
||||
let ok = passes(t, w, &p).expect("valid");
|
||||
assert_eq!(ok, y >= yes[n as usize], "M {m} G {g} Q {q} W {w}: {y} pour {n} contre");
|
||||
assert_eq!(ok, n < no, "M {m} G {g} Q {q} W {w}: {y} pour {n} contre, noNeeded {no}");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn well_rated_holds_the_share_on_those_who_came() {
|
||||
let p = standard(50);
|
||||
// 30 « pour », 2 « contre »: the share holds on 32 voters, the quorum is not there.
|
||||
let e = evaluate(Tally { for_votes: 30, against: 2 }, 5745, &p).expect("valid");
|
||||
assert!(!e.reached && e.well_rated);
|
||||
// 10 « pour », 10 « contre »: the share does not hold.
|
||||
assert!(!evaluate(Tally { for_votes: 10, against: 10 }, 5745, &p).expect("valid").well_rated);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn no_vote_is_never_reached() {
|
||||
for q in [0, 1, 12] {
|
||||
let p = FormulaParams { quorum: q, ..g1vote(50.0, 0.1) };
|
||||
let e = evaluate(Tally::default(), 3, &p).expect("valid");
|
||||
assert!(!e.reached);
|
||||
assert!(!e.well_rated);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_test_group_passes_with_one_voice_at_a_quorum_of_one() {
|
||||
// Two people, quorum 1 (Yvv, 2026-11-02: the test group stays at 1 everywhere): one
|
||||
// « pour » of one voter holds any share; a « contre » beside it does not.
|
||||
let p = standard(1);
|
||||
assert!(evaluate(Tally { for_votes: 1, against: 0 }, 2, &p).expect("valid").reached);
|
||||
assert!(!evaluate(Tally { for_votes: 1, against: 1 }, 2, &p).expect("valid").reached);
|
||||
assert!(evaluate(Tally { for_votes: 2, against: 0 }, 2, &p).expect("valid").reached);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn stale_electorate_cannot_lower_the_bar_below_majority() {
|
||||
let p = g1vote(50.0, 0.2);
|
||||
// 30 votes cast for an electorate believed to be 10: the reference
|
||||
// would ask 12 (ratio below M), the cap keeps the simple majority:
|
||||
// ⌈B^W + 0.5 × 30⌉ = 16, the tiny B^W offset rounding up.
|
||||
let r = wot_threshold(10, 30, &p).expect("valid");
|
||||
assert_eq!(r, 16);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_whole_number_of_votes_never_gains_one_by_rounding() {
|
||||
// 100 members, everyone votes, M 55 %: 55 % of 100 = 55. In floating point 0.55 × 100
|
||||
// is 55.000000000000007, which would ask for 56 (B^W = 10⁻¹⁰⁰ adds nothing).
|
||||
let p = FormulaParams { majority_pct: 55.0, gradient: 0.3, base: 0.1, quorum: 1 };
|
||||
assert_eq!(wot_threshold(100, 100, &p), Ok(55));
|
||||
// A genuine fraction still rounds up.
|
||||
assert_eq!(wot_threshold(101, 99, &FormulaParams { quorum: 0, ..p }), Ok(55));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn errors() {
|
||||
let p = g1vote(50.0, 0.2);
|
||||
assert_eq!(wot_threshold(0, 1, &p), Err(ThresholdError::EmptyElectorate));
|
||||
let bad = g1vote(120.0, 0.2);
|
||||
assert_eq!(wot_threshold(10, 1, &bad), Err(ThresholdError::MajorityOutOfRange));
|
||||
assert_eq!(yes_needed(0, 0, &p), Err(ThresholdError::EmptyElectorate));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
[package]
|
||||
name = "ld-server"
|
||||
description = "libreDecision server: API, Ğ1 sign-in, storage, publication of adopted texts, static web."
|
||||
version.workspace = true
|
||||
edition.workspace = true
|
||||
rust-version.workspace = true
|
||||
publish.workspace = true
|
||||
license.workspace = true
|
||||
|
||||
[dependencies]
|
||||
ld-engine = { path = "../ld-engine" }
|
||||
trustin-access = { path = "../trustin-access" }
|
||||
|
||||
anyhow = "1"
|
||||
axum = { version = "0.8", features = ["macros"] }
|
||||
base64 = "0.22"
|
||||
hex = "0.4"
|
||||
rand = "0.9"
|
||||
reqwest = { version = "0.12", default-features = false, features = ["json", "rustls-tls"] }
|
||||
serde = { version = "1", features = ["derive"] }
|
||||
serde_json = "1"
|
||||
sha2 = "0.10"
|
||||
sqlx = { version = "0.8", default-features = false, features = ["runtime-tokio", "sqlite", "macros", "migrate"] }
|
||||
thiserror = "2"
|
||||
time = { version = "0.3", features = ["formatting", "parsing", "macros"] }
|
||||
tokio = { version = "1", features = ["rt-multi-thread", "macros", "signal", "time", "process", "fs"] }
|
||||
tower-http = { version = "0.6", features = ["fs", "trace", "compression-gzip", "set-header", "limit"] }
|
||||
tower = { version = "0.5", features = ["util"] }
|
||||
tracing = "0.1"
|
||||
tracing-subscriber = { version = "0.3", features = ["env-filter"] }
|
||||
twox-hash = { version = "2", default-features = false, features = ["xxhash64"] }
|
||||
url = "2"
|
||||
|
||||
[dev-dependencies]
|
||||
http-body-util = "0.1"
|
||||
|
||||
[lints]
|
||||
workspace = true
|
||||
@@ -0,0 +1,182 @@
|
||||
-- libreDecision v3 — initial schema. Runtime data: never dropped, never rewritten.
|
||||
-- Dates are RFC 3339 UTC strings (sortable).
|
||||
|
||||
CREATE TABLE identities (
|
||||
id TEXT PRIMARY KEY, -- idty:<index> | inv:<hex> | dev:<name>
|
||||
kind TEXT NOT NULL, -- g1 | invite | dev
|
||||
display_name TEXT NOT NULL,
|
||||
address TEXT, -- SS58 seen at the last Ğ1 sign-in, display only
|
||||
g1_member INTEGER NOT NULL DEFAULT 0,
|
||||
admin INTEGER NOT NULL DEFAULT 0,
|
||||
created_at TEXT NOT NULL,
|
||||
last_seen_at TEXT
|
||||
);
|
||||
|
||||
CREATE TABLE sessions (
|
||||
token_hash TEXT PRIMARY KEY,
|
||||
identity_id TEXT NOT NULL REFERENCES identities(id),
|
||||
created_at TEXT NOT NULL,
|
||||
expires_at TEXT NOT NULL
|
||||
);
|
||||
|
||||
CREATE TABLE invitations (
|
||||
token_hash TEXT PRIMARY KEY,
|
||||
label TEXT NOT NULL,
|
||||
admin INTEGER NOT NULL DEFAULT 0,
|
||||
groups TEXT NOT NULL DEFAULT '[]', -- JSON array of group slugs granted on acceptance
|
||||
created_by TEXT,
|
||||
created_at TEXT NOT NULL,
|
||||
expires_at TEXT NOT NULL,
|
||||
used_at TEXT,
|
||||
identity_id TEXT
|
||||
);
|
||||
|
||||
CREATE TABLE groups (
|
||||
slug TEXT PRIMARY KEY,
|
||||
name TEXT NOT NULL,
|
||||
parent TEXT,
|
||||
policy TEXT NOT NULL, -- g1-members | managed
|
||||
hidden INTEGER NOT NULL DEFAULT 0,
|
||||
position INTEGER NOT NULL
|
||||
);
|
||||
|
||||
CREATE TABLE memberships (
|
||||
group_slug TEXT NOT NULL REFERENCES groups(slug),
|
||||
identity_id TEXT NOT NULL REFERENCES identities(id),
|
||||
granted_by TEXT,
|
||||
granted_at TEXT NOT NULL,
|
||||
PRIMARY KEY (group_slug, identity_id)
|
||||
);
|
||||
|
||||
CREATE TABLE documents (
|
||||
id TEXT PRIMARY KEY,
|
||||
group_slug TEXT NOT NULL REFERENCES groups(slug),
|
||||
slug TEXT NOT NULL,
|
||||
title TEXT NOT NULL, -- JSON {lang: text}
|
||||
summary TEXT NOT NULL, -- JSON {lang: text}
|
||||
source_lang TEXT NOT NULL,
|
||||
vote_floor REAL NOT NULL, -- C, minimal number of "for" votes
|
||||
publish_path TEXT, -- path in the texts repository; NULL = never published
|
||||
created_at TEXT NOT NULL,
|
||||
UNIQUE (group_slug, slug)
|
||||
);
|
||||
|
||||
CREATE TABLE units (
|
||||
id TEXT PRIMARY KEY,
|
||||
doc_id TEXT NOT NULL REFERENCES documents(id),
|
||||
code TEXT NOT NULL,
|
||||
section TEXT, -- grouping key of the seed (introduction, technique…)
|
||||
position REAL NOT NULL,
|
||||
inertia TEXT NOT NULL, -- low | standard | high | max
|
||||
in_force_id TEXT, -- proposals.id of the version in force, NULL = empty slot
|
||||
created_at TEXT NOT NULL
|
||||
);
|
||||
CREATE INDEX units_doc ON units(doc_id);
|
||||
|
||||
-- A proposal is a version of a unit: proposed, in force, or past.
|
||||
CREATE TABLE proposals (
|
||||
id TEXT PRIMARY KEY,
|
||||
unit_id TEXT NOT NULL REFERENCES units(id),
|
||||
kind TEXT NOT NULL, -- text | remove | inertia
|
||||
inertia_target TEXT,
|
||||
title TEXT NOT NULL,
|
||||
body TEXT NOT NULL,
|
||||
status TEXT NOT NULL, -- draft | open | in_force | superseded | withdrawn
|
||||
origin TEXT NOT NULL, -- seed | member
|
||||
label TEXT,
|
||||
author_id TEXT REFERENCES identities(id),
|
||||
revision INTEGER NOT NULL DEFAULT 1,
|
||||
created_at TEXT NOT NULL,
|
||||
updated_at TEXT NOT NULL,
|
||||
submitted_at TEXT,
|
||||
entered_at TEXT, -- entered into force by vote; NULL for a seed a minima
|
||||
left_at TEXT,
|
||||
confirmed_at TEXT -- a seed a minima that later reached its threshold
|
||||
);
|
||||
CREATE INDEX proposals_unit ON proposals(unit_id);
|
||||
|
||||
CREATE TABLE revisions (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
proposal_id TEXT NOT NULL REFERENCES proposals(id),
|
||||
revision INTEGER NOT NULL,
|
||||
kind TEXT NOT NULL, -- initial | typo | substantive
|
||||
title TEXT NOT NULL,
|
||||
body TEXT NOT NULL,
|
||||
author_id TEXT,
|
||||
created_at TEXT NOT NULL
|
||||
);
|
||||
CREATE INDEX revisions_proposal ON revisions(proposal_id);
|
||||
|
||||
CREATE TABLE votes (
|
||||
proposal_id TEXT NOT NULL REFERENCES proposals(id),
|
||||
identity_id TEXT NOT NULL REFERENCES identities(id),
|
||||
choice TEXT NOT NULL, -- for | against
|
||||
revision INTEGER NOT NULL, -- counted only while equal to proposals.revision
|
||||
cast_at TEXT NOT NULL,
|
||||
PRIMARY KEY (proposal_id, identity_id)
|
||||
);
|
||||
|
||||
CREATE TABLE signals (
|
||||
id TEXT PRIMARY KEY,
|
||||
proposal_id TEXT NOT NULL REFERENCES proposals(id),
|
||||
target_proposal_id TEXT NOT NULL REFERENCES proposals(id),
|
||||
reason TEXT NOT NULL,
|
||||
author_id TEXT NOT NULL REFERENCES identities(id),
|
||||
created_at TEXT NOT NULL,
|
||||
lifted_at TEXT,
|
||||
lifted_by TEXT -- identity id, or 'revision' when the text changed
|
||||
);
|
||||
|
||||
CREATE TABLE translations (
|
||||
proposal_id TEXT NOT NULL REFERENCES proposals(id),
|
||||
lang TEXT NOT NULL,
|
||||
title TEXT NOT NULL,
|
||||
body TEXT NOT NULL,
|
||||
source_hash TEXT NOT NULL, -- hash of the source title + body it translates
|
||||
origin TEXT NOT NULL, -- seed | member
|
||||
updated_by TEXT,
|
||||
updated_at TEXT NOT NULL,
|
||||
PRIMARY KEY (proposal_id, lang)
|
||||
);
|
||||
|
||||
CREATE TABLE adoptions (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
proposal_id TEXT NOT NULL,
|
||||
unit_id TEXT NOT NULL,
|
||||
doc_id TEXT NOT NULL,
|
||||
kind TEXT NOT NULL,
|
||||
votes_for INTEGER NOT NULL,
|
||||
votes_against INTEGER NOT NULL,
|
||||
electorate INTEGER NOT NULL,
|
||||
required INTEGER NOT NULL,
|
||||
params TEXT NOT NULL,
|
||||
at TEXT NOT NULL
|
||||
);
|
||||
|
||||
CREATE TABLE outbox (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
doc_id TEXT NOT NULL,
|
||||
reason TEXT NOT NULL,
|
||||
created_at TEXT NOT NULL,
|
||||
attempts INTEGER NOT NULL DEFAULT 0,
|
||||
last_error TEXT,
|
||||
next_at TEXT NOT NULL,
|
||||
done_at TEXT,
|
||||
commit_sha TEXT
|
||||
);
|
||||
|
||||
CREATE TABLE feedback (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
identity_id TEXT,
|
||||
route TEXT NOT NULL,
|
||||
body TEXT NOT NULL,
|
||||
context TEXT,
|
||||
created_at TEXT NOT NULL,
|
||||
status TEXT NOT NULL DEFAULT 'new'
|
||||
);
|
||||
|
||||
CREATE TABLE settings (
|
||||
key TEXT PRIMARY KEY,
|
||||
value TEXT NOT NULL,
|
||||
updated_at TEXT NOT NULL
|
||||
);
|
||||
@@ -0,0 +1,17 @@
|
||||
-- Team links (2026-10-21): one link shared with a team until a date; each opening
|
||||
-- makes its own invited identity, never an administrator. Additive only.
|
||||
|
||||
CREATE TABLE team_links (
|
||||
token_hash TEXT PRIMARY KEY,
|
||||
label TEXT NOT NULL, -- what the link is for, shown to whoever opens it
|
||||
groups TEXT NOT NULL DEFAULT '[]', -- JSON array of group slugs granted on each opening
|
||||
created_by TEXT NOT NULL,
|
||||
created_at TEXT NOT NULL,
|
||||
expires_at TEXT NOT NULL,
|
||||
max_uses INTEGER NOT NULL,
|
||||
uses INTEGER NOT NULL DEFAULT 0,
|
||||
revoked_at TEXT
|
||||
);
|
||||
|
||||
-- The team link an identity came from, if any (shown to administrators).
|
||||
ALTER TABLE identities ADD COLUMN team_link TEXT;
|
||||
@@ -0,0 +1,5 @@
|
||||
-- The key a name collides on (ld_engine::names), set for the names people give
|
||||
-- themselves through a team link: two people never share a name. Additive only.
|
||||
|
||||
ALTER TABLE identities ADD COLUMN name_key TEXT;
|
||||
CREATE UNIQUE INDEX identities_name_key ON identities(name_key) WHERE name_key IS NOT NULL;
|
||||
@@ -0,0 +1,15 @@
|
||||
-- Seed amendments (2026-10-30): a starting text replaced by a newer one of the
|
||||
-- seed, decided once per document and only on an article nobody has touched
|
||||
-- (src/seed_amend.rs). Each decision is kept here: applied, refused (and why),
|
||||
-- or already present. Additive only.
|
||||
|
||||
CREATE TABLE seed_amendments (
|
||||
amendment_id TEXT NOT NULL,
|
||||
doc_id TEXT NOT NULL REFERENCES documents(id),
|
||||
unit_code TEXT NOT NULL,
|
||||
proposal_id TEXT, -- the version in force that was examined
|
||||
outcome TEXT NOT NULL, -- applied | refused | present
|
||||
reason TEXT, -- why it was refused
|
||||
at TEXT NOT NULL,
|
||||
PRIMARY KEY (amendment_id, doc_id)
|
||||
);
|
||||
@@ -0,0 +1,50 @@
|
||||
-- Inertia in five levels (2026-10-30, ADR 0007). Additive: nothing is lost.
|
||||
--
|
||||
-- The four words of before become levels: low → 2, standard → 3, high → 4,
|
||||
-- max → 5 (level 1 is new, below them). Each word is first kept as it was, in a
|
||||
-- column of its own, then its level is written in its place. The adoptions are
|
||||
-- left as they are: their `params` hold the M and G each was judged by. The
|
||||
-- server still reads the four words (a row written by an older image).
|
||||
|
||||
ALTER TABLE units ADD COLUMN inertia_legacy TEXT; -- the word before 2026-10-30, as it was
|
||||
UPDATE units SET inertia_legacy = inertia WHERE inertia IN ('low', 'standard', 'high', 'max');
|
||||
UPDATE units SET inertia = CASE inertia
|
||||
WHEN 'low' THEN '2' WHEN 'standard' THEN '3' WHEN 'high' THEN '4' WHEN 'max' THEN '5' END
|
||||
WHERE inertia IN ('low', 'standard', 'high', 'max');
|
||||
|
||||
ALTER TABLE proposals ADD COLUMN inertia_target_legacy TEXT; -- the word before 2026-10-30, as it was
|
||||
UPDATE proposals SET inertia_target_legacy = inertia_target WHERE inertia_target IN ('low', 'standard', 'high', 'max');
|
||||
UPDATE proposals SET inertia_target = CASE inertia_target
|
||||
WHEN 'low' THEN '2' WHEN 'standard' THEN '3' WHEN 'high' THEN '4' WHEN 'max' THEN '5' END
|
||||
WHERE inertia_target IN ('low', 'standard', 'high', 'max');
|
||||
|
||||
-- Every change of the inertia of an article from now on, by a vote or by an
|
||||
-- amendment of the seed: what the article says of its own inertia.
|
||||
CREATE TABLE inertia_changes (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
unit_id TEXT NOT NULL REFERENCES units(id),
|
||||
doc_id TEXT NOT NULL REFERENCES documents(id),
|
||||
from_level TEXT NOT NULL,
|
||||
to_level TEXT NOT NULL,
|
||||
cause TEXT NOT NULL, -- vote | amendment
|
||||
proposal_id TEXT, -- the change of inertia voted
|
||||
amendment_id TEXT, -- the amendment applied
|
||||
at TEXT NOT NULL
|
||||
);
|
||||
CREATE INDEX inertia_changes_unit ON inertia_changes(unit_id);
|
||||
|
||||
-- Inertia amendments (src/inertia_amend.rs): a starting level of the seed raised
|
||||
-- in an existing base, decided once per document and only on an article whose
|
||||
-- inertia no vote ever changed. Each decision is kept here: applied, refused
|
||||
-- (and why), or already present.
|
||||
CREATE TABLE inertia_amendments (
|
||||
amendment_id TEXT NOT NULL,
|
||||
doc_id TEXT NOT NULL REFERENCES documents(id),
|
||||
unit_code TEXT NOT NULL,
|
||||
from_level TEXT, -- the level found
|
||||
to_level TEXT NOT NULL,
|
||||
outcome TEXT NOT NULL, -- applied | refused | present
|
||||
reason TEXT, -- why it was refused
|
||||
at TEXT NOT NULL,
|
||||
PRIMARY KEY (amendment_id, doc_id)
|
||||
);
|
||||
@@ -0,0 +1,51 @@
|
||||
-- The vote on the Ğ1 chain (2026-10-30, ADR 0008). Additive only: nothing is
|
||||
-- rewritten, every vote of before stays as it was (source 'server').
|
||||
--
|
||||
-- A ballot is asked by a session, sealed by the server (salt, digest H) and
|
||||
-- signed by a wallet: one transaction, a transfer to the vote account and the
|
||||
-- remark `ld1:<H>`. The server confirms it by reading the indexer, then writes
|
||||
-- each of its lines into `votes` for the Ğ1 identity of the signer — unless a
|
||||
-- more recent ballot of that identity, in the order of the chain, already
|
||||
-- decided that proposal (the last one stands).
|
||||
|
||||
CREATE TABLE ballots (
|
||||
id TEXT PRIMARY KEY,
|
||||
doc_id TEXT NOT NULL REFERENCES documents(id),
|
||||
requested_by TEXT NOT NULL REFERENCES identities(id), -- the identity of the session that asked
|
||||
author TEXT, -- the account bound to sign (Ğ1 address), when the page knew it
|
||||
salt TEXT NOT NULL, -- 128 random bits, 32 hex digits
|
||||
digest TEXT NOT NULL UNIQUE, -- H = blake2b-256 of the canonical ballot
|
||||
remark_hash TEXT NOT NULL UNIQUE, -- 0x + blake2b-256 of `ld1:<H>`: what the indexer finds it by
|
||||
account TEXT NOT NULL, -- the vote account at the time (Ğ1 address)
|
||||
amount INTEGER NOT NULL, -- cents of Ğ1 asked
|
||||
status TEXT NOT NULL, -- pending | confirmed | refused | expired
|
||||
reason TEXT, -- refused: no_identity | not_member | transfer
|
||||
voter_id TEXT REFERENCES identities(id), -- idty:<index> of the signer, once confirmed
|
||||
signer TEXT, -- the account that signed (Ğ1 address)
|
||||
block INTEGER, -- where the remark sits on the chain
|
||||
event_index INTEGER,
|
||||
event_id TEXT,
|
||||
entered TEXT, -- JSON: what entered into force at the confirmation
|
||||
created_at TEXT NOT NULL,
|
||||
expires_at TEXT NOT NULL,
|
||||
checked_at TEXT, -- last reading of the indexer
|
||||
settled_at TEXT -- confirmed, refused or expired
|
||||
);
|
||||
CREATE INDEX ballots_pending ON ballots(status, created_at);
|
||||
CREATE INDEX ballots_requested ON ballots(requested_by);
|
||||
CREATE INDEX ballots_voter ON ballots(voter_id);
|
||||
|
||||
CREATE TABLE ballot_lines (
|
||||
ballot_id TEXT NOT NULL REFERENCES ballots(id),
|
||||
proposal_id TEXT NOT NULL REFERENCES proposals(id),
|
||||
revision INTEGER NOT NULL, -- the revision voted: counted only while it is the proposal's
|
||||
choice TEXT NOT NULL, -- for | against | none (withdraws)
|
||||
PRIMARY KEY (ballot_id, proposal_id)
|
||||
);
|
||||
CREATE INDEX ballot_lines_proposal ON ballot_lines(proposal_id);
|
||||
|
||||
-- Where a vote comes from, and for a vote of the chain, where it sits on it.
|
||||
ALTER TABLE votes ADD COLUMN source TEXT NOT NULL DEFAULT 'server'; -- server | chain
|
||||
ALTER TABLE votes ADD COLUMN block INTEGER;
|
||||
ALTER TABLE votes ADD COLUMN event_index INTEGER;
|
||||
ALTER TABLE votes ADD COLUMN ballot_id TEXT;
|
||||
@@ -0,0 +1,26 @@
|
||||
-- Modules (2026-10-31, ADR 0009): the title of a document and the order of the
|
||||
-- articles of each of its sections, voted like its articles. Additive only:
|
||||
-- every unit of before is an article, nothing else changes.
|
||||
--
|
||||
-- A module is a unit of its own kind. Its versions are proposals, voted,
|
||||
-- counted, signed on the chain and brought into force like those of an
|
||||
-- article; their kind says what they hold:
|
||||
-- title — `title` is the title of the document in its source language;
|
||||
-- order — `body` is the order of the section, one article (unit id) per line.
|
||||
|
||||
ALTER TABLE units ADD COLUMN kind TEXT NOT NULL DEFAULT 'article'; -- article | title | order
|
||||
|
||||
-- The modules added to an existing document (src/module_amend.rs): decided
|
||||
-- once per document, from what it holds then (its title, the order of each
|
||||
-- section), never touching an article or a vote. Each decision is kept here:
|
||||
-- applied (and what was created), refused (and why), or present (the document
|
||||
-- was born with its modules).
|
||||
CREATE TABLE module_amendments (
|
||||
amendment_id TEXT NOT NULL,
|
||||
doc_id TEXT NOT NULL REFERENCES documents(id),
|
||||
outcome TEXT NOT NULL, -- applied | refused | present
|
||||
reason TEXT, -- why it was refused
|
||||
created TEXT, -- JSON array of the codes of the modules created
|
||||
at TEXT NOT NULL,
|
||||
PRIMARY KEY (amendment_id, doc_id)
|
||||
);
|
||||
@@ -0,0 +1,106 @@
|
||||
-- Objections « redit » / « contredit », typo fixes validated, and the messages
|
||||
-- of trustinWallet (2026-10-31, lot F, ADR 0010). Additive only: nothing is
|
||||
-- dropped, nothing is rewritten that an older image needs.
|
||||
--
|
||||
-- The contradiction signals of before become objections « contredit », in the
|
||||
-- same table: an older image (rollback) still reads them as signals, and every
|
||||
-- lifting still writes `lifted_at` / `lifted_by` as before.
|
||||
|
||||
ALTER TABLE signals ADD COLUMN kind TEXT NOT NULL DEFAULT 'contredit'; -- redit | contredit
|
||||
ALTER TABLE signals ADD COLUMN held_id TEXT; -- the version held back (not votable): the latest arrival, never one in force
|
||||
ALTER TABLE signals ADD COLUMN phase TEXT NOT NULL DEFAULT 'author'; -- author | objector | lifted
|
||||
ALTER TABLE signals ADD COLUMN cycle INTEGER NOT NULL DEFAULT 1; -- a reiteration opens the next
|
||||
ALTER TABLE signals ADD COLUMN due_at TEXT; -- phase objector: lifted by itself at this time
|
||||
ALTER TABLE signals ADD COLUMN answered_at TEXT; -- the last answer of the author
|
||||
ALTER TABLE signals ADD COLUMN answer TEXT; -- revision | other | proposal
|
||||
ALTER TABLE signals ADD COLUMN answer_id TEXT; -- the version revised, or the proposal linked
|
||||
ALTER TABLE signals ADD COLUMN lift TEXT; -- objector | admin | deadline | withdrawn | revision
|
||||
|
||||
-- The signals of before that were lifted stay lifted: by their author, by an
|
||||
-- administrator, or because the text changed (the rule of before).
|
||||
UPDATE signals SET phase = 'lifted', lift = CASE
|
||||
WHEN lifted_by = 'revision' THEN 'revision'
|
||||
WHEN lifted_by = author_id THEN 'objector'
|
||||
ELSE 'admin' END
|
||||
WHERE lifted_at IS NOT NULL;
|
||||
|
||||
-- A standing one whose other side left (withdrawn, or never submitted) ends: a
|
||||
-- withdrawal ends the objection, which the rule of before did not do.
|
||||
UPDATE signals SET phase = 'lifted', lift = 'withdrawn', lifted_by = 'withdrawn',
|
||||
lifted_at = strftime('%Y-%m-%dT%H:%M:%SZ', 'now')
|
||||
WHERE lifted_at IS NULL AND EXISTS (
|
||||
SELECT 1 FROM proposals p
|
||||
WHERE p.id IN (signals.proposal_id, signals.target_proposal_id) AND p.status NOT IN ('open', 'in_force'));
|
||||
|
||||
-- The standing ones wait for the author of the version they now hold back: the
|
||||
-- latest arrival of the two (submitted last; the same instant: written last),
|
||||
-- unless it is in force — then the other. Both in force: none is held.
|
||||
UPDATE signals SET held_id = (
|
||||
SELECT CASE
|
||||
WHEN a.status = 'in_force' AND b.status = 'in_force' THEN NULL
|
||||
WHEN (IFNULL(a.submitted_at, a.created_at), a.rowid, a.id) > (IFNULL(b.submitted_at, b.created_at), b.rowid, b.id)
|
||||
THEN CASE WHEN a.status = 'in_force' THEN b.id ELSE a.id END
|
||||
ELSE CASE WHEN b.status = 'in_force' THEN a.id ELSE b.id END
|
||||
END
|
||||
FROM proposals a, proposals b
|
||||
WHERE a.id = signals.proposal_id AND b.id = signals.target_proposal_id)
|
||||
WHERE lifted_at IS NULL;
|
||||
|
||||
CREATE INDEX signals_proposal ON signals(proposal_id);
|
||||
CREATE INDEX signals_target ON signals(target_proposal_id);
|
||||
CREATE INDEX signals_due ON signals(phase, due_at);
|
||||
-- One person, one standing objection on a pair, whichever way it was written
|
||||
-- (relecture of cd7e683: sent four times at once, four stood).
|
||||
CREATE UNIQUE INDEX signals_one_standing
|
||||
ON signals(author_id, min(proposal_id, target_proposal_id), max(proposal_id, target_proposal_id))
|
||||
WHERE lifted_at IS NULL;
|
||||
|
||||
-- A proposal written to answer an objection (« modifier l'article visé »).
|
||||
ALTER TABLE proposals ADD COLUMN objection_id TEXT;
|
||||
|
||||
-- A typo fix written by someone other than the one who answers for the version:
|
||||
-- it waits for them (or for the deadline), then applies — or not.
|
||||
CREATE TABLE typos (
|
||||
id TEXT PRIMARY KEY,
|
||||
proposal_id TEXT NOT NULL REFERENCES proposals(id),
|
||||
doc_id TEXT NOT NULL REFERENCES documents(id),
|
||||
writer_id TEXT NOT NULL REFERENCES identities(id), -- who wrote the fix
|
||||
answerer_id TEXT, -- the author of the version, who answers; NULL = administrators (no author)
|
||||
revision INTEGER NOT NULL, -- the revision it was written over
|
||||
base_title TEXT NOT NULL, -- the text it was written over
|
||||
base_body TEXT NOT NULL,
|
||||
title TEXT NOT NULL, -- the fix
|
||||
body TEXT NOT NULL,
|
||||
status TEXT NOT NULL, -- pending | applied | refused | stale
|
||||
created_at TEXT NOT NULL,
|
||||
due_at TEXT NOT NULL, -- applied by itself then, if nobody answered
|
||||
settled_at TEXT,
|
||||
settled_by TEXT -- identity id, 'deadline', or 'changed' (stale)
|
||||
);
|
||||
CREATE INDEX typos_proposal ON typos(proposal_id, status);
|
||||
CREATE INDEX typos_due ON typos(status, due_at);
|
||||
|
||||
-- The revision a validated fix wrote, or the one that took it back (kind 'revert'),
|
||||
-- and who let it in: the identity that validated it, or 'deadline'.
|
||||
ALTER TABLE revisions ADD COLUMN typo_id TEXT;
|
||||
ALTER TABLE revisions ADD COLUMN answered_by TEXT;
|
||||
|
||||
-- The messages of trustinWallet: one person, one box. The text is told by the
|
||||
-- interface from the subject (in the language of the reader); the gestures
|
||||
-- follow the subject's state, so a message dealt with offers none.
|
||||
CREATE TABLE messages (
|
||||
id TEXT PRIMARY KEY,
|
||||
recipient_id TEXT NOT NULL REFERENCES identities(id),
|
||||
doc_id TEXT NOT NULL REFERENCES documents(id),
|
||||
topic TEXT NOT NULL, -- objection | typo
|
||||
subject_id TEXT NOT NULL, -- signals.id | typos.id
|
||||
kind TEXT NOT NULL, -- objection_raised | objection_reiterated | objection_answered | typo_pending
|
||||
cycle INTEGER NOT NULL DEFAULT 1,
|
||||
created_at TEXT NOT NULL,
|
||||
due_at TEXT,
|
||||
read_at TEXT,
|
||||
done_at TEXT,
|
||||
outcome TEXT -- answered | lifted | reiterated | deadline | admin | withdrawn | applied | refused | stale
|
||||
);
|
||||
CREATE INDEX messages_box ON messages(recipient_id, created_at);
|
||||
CREATE INDEX messages_subject ON messages(topic, subject_id);
|
||||
@@ -0,0 +1,36 @@
|
||||
-- Propositions seed (2026-11-02, ADR 0011). Additive only: nothing is dropped,
|
||||
-- nothing an older image needs is rewritten.
|
||||
--
|
||||
-- Yvv, 2026-11-02: « pour le démarrage, conserve un statut particulier pour les
|
||||
-- 3 premiers modules. Mais ils ne sont pas en vigueur. Appelle les "Proposition
|
||||
-- seed" (intégré par un ou deux clients pour amorcer le mouvement de vote). »
|
||||
--
|
||||
-- A seed proposal is an open proposal of the seed put forward to start the vote
|
||||
-- (I1, E1, E2 of the certification act). It is not in force: it enters like any
|
||||
-- proposal, by its threshold. The mark stays on the row once written; what the
|
||||
-- API calls `seedProposal` is read from it while the version is open and was
|
||||
-- never voted into force (src/docs.rs). An older image ignores the column and
|
||||
-- reads the version as an open proposal of the seed.
|
||||
ALTER TABLE proposals ADD COLUMN seed_featured INTEGER NOT NULL DEFAULT 0;
|
||||
|
||||
-- The starting texts in force made seed proposals in an existing base
|
||||
-- (src/seed_proposal_amend.rs): decided once per document and article, and
|
||||
-- only where nobody has touched the text. With the status, the article's level
|
||||
-- of inertia goes low (Yvv, 2026-11-02: « l'amorcer avec une inertie basse »),
|
||||
-- only where nobody has touched it either. Each decision is kept here, for the
|
||||
-- status and for the level: applied, refused (and why), or present (the base
|
||||
-- was born with the seed proposal, at the low level).
|
||||
CREATE TABLE seed_proposal_amendments (
|
||||
amendment_id TEXT NOT NULL,
|
||||
doc_id TEXT NOT NULL REFERENCES documents(id),
|
||||
unit_code TEXT NOT NULL,
|
||||
proposal_id TEXT, -- the version examined: in force, or the seed proposal found
|
||||
outcome TEXT NOT NULL, -- applied | refused | present (the status)
|
||||
reason TEXT, -- why it was refused
|
||||
from_level TEXT, -- the level found
|
||||
to_level TEXT NOT NULL, -- the level seed proposals start at
|
||||
level_outcome TEXT NOT NULL, -- applied | refused | present (the level)
|
||||
level_reason TEXT, -- why it was refused
|
||||
at TEXT NOT NULL,
|
||||
PRIMARY KEY (amendment_id, doc_id)
|
||||
);
|
||||
@@ -0,0 +1,19 @@
|
||||
-- The buffer of the votes (2026-11-02, lot V, ADR 0012). Additive only: no
|
||||
-- existing row is rewritten; an older image (rollback) reads the same tables.
|
||||
--
|
||||
-- A ballot is the intention of a vote, seen at once; its proof comes from the
|
||||
-- chain (mode 'chain'), or, while the chain is closed, from the simulated
|
||||
-- confirmer (mode 'simulated': no account, no amount, the voice of the session).
|
||||
-- After each proof, the reconciliation keeps one voice per identity and per
|
||||
-- version, the last in the order of the chain, and tells each proven line what
|
||||
-- became of it.
|
||||
|
||||
ALTER TABLE ballots ADD COLUMN mode TEXT NOT NULL DEFAULT 'chain'; -- chain | simulated
|
||||
ALTER TABLE ballot_lines ADD COLUMN outcome TEXT; -- counted | superseded | objection (NULL: not proven yet, or proven before 0010)
|
||||
|
||||
-- The intentions of a document, read with every view of it.
|
||||
CREATE INDEX ballots_doc ON ballots(doc_id, status);
|
||||
|
||||
-- `votes.source` takes one more value, 'simulated'; `messages.topic` one more,
|
||||
-- 'ballot' (kinds ballot_expired | ballot_refused | ballot_objection): text
|
||||
-- columns, nothing to change.
|
||||
@@ -0,0 +1,48 @@
|
||||
-- The quorum (2026-11-03, ADR 0013). Additive only: nothing is dropped, nothing
|
||||
-- an older image needs is rewritten. (0010 is kept for lot V: the gap is meant.)
|
||||
--
|
||||
-- Yvv, 2026-11-02: « C suit le cran, mais c'est une proposition par défaut,
|
||||
-- variable modifiable par l'auteur initial du texte… c'est plutôt un quorum ».
|
||||
-- The floor C of *for* votes put in front of the formula gives way to a quorum Q
|
||||
-- by level: from Q voters on, the share of the inertia applies; below it, the
|
||||
-- voters missing count as against (2026-11-03: « Ils comptent comme non »).
|
||||
-- `documents.vote_floor` stays: the reference quorum Q₀, read for the reference
|
||||
-- participation p0 = 2Q₀/W only (never an article's quorum), and as every
|
||||
-- level's quorum while `quorums` is NULL. An older image ignores both columns.
|
||||
|
||||
-- The quorum of each level, 1 to 5, as JSON `[Q1, Q2, Q3, Q4, Q5]`: the default
|
||||
-- of an article at that level (Yvv: « 12 · 12 · 50 · 50 · 50 » in Monnaie libre,
|
||||
-- 1 everywhere in the test group). NULL: a base the quorum amendment
|
||||
-- (src/quorum_amend.rs) has not reached yet.
|
||||
ALTER TABLE documents ADD COLUMN quorums TEXT;
|
||||
|
||||
-- The quorum of an article, set by the author of its first version before the
|
||||
-- first vote (src/quorum.rs; Yvv: « L'auteur, avant le 1er vote »). NULL: the
|
||||
-- quorum of its level. A change of its inertia is judged on it too.
|
||||
ALTER TABLE units ADD COLUMN quorum INTEGER;
|
||||
|
||||
-- Every change of an article's own quorum: who, from what, to what.
|
||||
CREATE TABLE quorum_changes (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
unit_id TEXT NOT NULL REFERENCES units(id),
|
||||
doc_id TEXT NOT NULL REFERENCES documents(id),
|
||||
from_quorum INTEGER, -- NULL: the quorum of its level
|
||||
to_quorum INTEGER, -- NULL: back to the quorum of its level
|
||||
identity_id TEXT NOT NULL REFERENCES identities(id),
|
||||
at TEXT NOT NULL
|
||||
);
|
||||
CREATE INDEX quorum_changes_unit ON quorum_changes(unit_id);
|
||||
|
||||
-- Quorum amendments (src/quorum_amend.rs): the quorums by level of the seed,
|
||||
-- written into an existing document that holds none, decided once per document.
|
||||
-- Each decision is kept here: applied, refused (and why), or present.
|
||||
CREATE TABLE quorum_amendments (
|
||||
amendment_id TEXT NOT NULL,
|
||||
doc_id TEXT NOT NULL REFERENCES documents(id),
|
||||
from_quorums TEXT, -- what the document held: NULL, or its JSON
|
||||
to_quorums TEXT NOT NULL, -- the seed's, JSON
|
||||
outcome TEXT NOT NULL, -- applied | refused | present
|
||||
reason TEXT, -- why it was refused
|
||||
at TEXT NOT NULL,
|
||||
PRIMARY KEY (amendment_id, doc_id)
|
||||
);
|
||||
@@ -0,0 +1,49 @@
|
||||
-- The information on how the vote works leaves the document (2026-11-03, lot T4,
|
||||
-- ADR 0014). Additive only: no row is dropped, no column an older image reads is
|
||||
-- rewritten.
|
||||
--
|
||||
-- Yvv, 2026-11-03: « les 3 derniers modules ne sont pas de votes : ordonnancement,
|
||||
-- inertie, et formule de vote sont des infos de fonctionnement. Les votes se font à
|
||||
-- leur place, et les infos doivent trouver leur place dans l'ux. » The articles of the
|
||||
-- sections `formule`, `inertie` and `ordonnancement`, and the order of each, are no
|
||||
-- texts to vote. A new base is born without them (the seed no longer holds them); an
|
||||
-- existing base keeps every row of them — units, versions, revisions, translations —
|
||||
-- and marks the units retired: the document no longer lists them, counts them,
|
||||
-- votes on them or publishes them. An older image (rollback) ignores the column and
|
||||
-- shows them again exactly as they were.
|
||||
|
||||
-- When the unit left the document (NULL: it is in it), and the amendment that took it out.
|
||||
ALTER TABLE units ADD COLUMN retired_at TEXT;
|
||||
ALTER TABLE units ADD COLUMN retired_reason TEXT;
|
||||
|
||||
-- The retirement of an existing document's units (src/retire.rs): decided once per
|
||||
-- document, only where no person left a trace on any of them. Each decision is kept
|
||||
-- here: applied (and the codes retired), refused (why, and on which unit), or present
|
||||
-- (the document was born without them).
|
||||
CREATE TABLE retirement_amendments (
|
||||
amendment_id TEXT NOT NULL,
|
||||
doc_id TEXT NOT NULL REFERENCES documents(id),
|
||||
outcome TEXT NOT NULL, -- applied | refused | present
|
||||
reason TEXT, -- why it was refused
|
||||
unit_code TEXT, -- the unit the trace was found on
|
||||
retired TEXT, -- JSON array of the codes retired
|
||||
at TEXT NOT NULL,
|
||||
PRIMARY KEY (amendment_id, doc_id)
|
||||
);
|
||||
|
||||
-- An article the seed now holds, added to an existing document (src/article_amend.rs):
|
||||
-- decided once per document. Each decision is kept here: applied (the unit created,
|
||||
-- where it was placed and why), refused (why), or present (born with it).
|
||||
CREATE TABLE article_amendments (
|
||||
amendment_id TEXT NOT NULL,
|
||||
doc_id TEXT NOT NULL REFERENCES documents(id),
|
||||
unit_code TEXT NOT NULL,
|
||||
unit_id TEXT, -- the unit created, or found
|
||||
outcome TEXT NOT NULL, -- applied | refused | present
|
||||
reason TEXT, -- why it was refused
|
||||
placement TEXT, -- between (where the seed puts it) | end (of its section)
|
||||
placement_reason TEXT, -- why at the end
|
||||
position REAL,
|
||||
at TEXT NOT NULL,
|
||||
PRIMARY KEY (amendment_id, doc_id)
|
||||
);
|
||||
@@ -0,0 +1,330 @@
|
||||
//! trustinWallet — access management, for its administrators only — and the
|
||||
//! « Retour » channel that turns usage tests into controls.
|
||||
|
||||
use crate::AppState;
|
||||
use crate::auth::{self, IDENTITY_COLUMNS, IdentityRow, User};
|
||||
use crate::error::{AppError, AppResult};
|
||||
use crate::util;
|
||||
use axum::Json;
|
||||
use axum::extract::{Path, State};
|
||||
use axum::http::HeaderMap;
|
||||
use serde::Deserialize;
|
||||
use serde_json::json;
|
||||
|
||||
fn admin_only(user: &IdentityRow) -> AppResult<()> {
|
||||
if trustin_access::manages_access(&user.to_identity()) { Ok(()) } else { Err(AppError::Forbidden) }
|
||||
}
|
||||
|
||||
pub async fn overview(
|
||||
State(state): State<AppState>,
|
||||
headers: HeaderMap,
|
||||
User(user): User,
|
||||
) -> AppResult<Json<serde_json::Value>> {
|
||||
admin_only(&user)?;
|
||||
#[derive(sqlx::FromRow)]
|
||||
struct Member {
|
||||
group_slug: String,
|
||||
identity_id: String,
|
||||
granted_at: String,
|
||||
}
|
||||
let groups: Vec<(String, String, String, bool)> =
|
||||
sqlx::query_as("SELECT slug, name, policy, hidden FROM groups ORDER BY position").fetch_all(&state.db).await?;
|
||||
let members = sqlx::query_as::<_, Member>("SELECT group_slug, identity_id, granted_at FROM memberships")
|
||||
.fetch_all(&state.db)
|
||||
.await?;
|
||||
let identities = sqlx::query_as::<_, IdentityRow>(&format!(
|
||||
"SELECT {IDENTITY_COLUMNS} FROM identities ORDER BY admin DESC, display_name COLLATE NOCASE"
|
||||
))
|
||||
.fetch_all(&state.db)
|
||||
.await?;
|
||||
let last_seen: Vec<(String, Option<String>)> =
|
||||
sqlx::query_as("SELECT id, last_seen_at FROM identities").fetch_all(&state.db).await?;
|
||||
// Open sessions, but the one this page is read with.
|
||||
let sessions: Vec<(String, i64)> = sqlx::query_as(
|
||||
"SELECT identity_id, COUNT(*) FROM sessions WHERE expires_at > ? AND token_hash != ? GROUP BY identity_id",
|
||||
)
|
||||
.bind(util::now())
|
||||
.bind(auth::session_hash(&state, &headers).unwrap_or_default())
|
||||
.fetch_all(&state.db)
|
||||
.await?;
|
||||
let came_by: Vec<(String, String)> =
|
||||
sqlx::query_as("SELECT i.id, t.label FROM identities i JOIN team_links t ON t.token_hash = i.team_link")
|
||||
.fetch_all(&state.db)
|
||||
.await?;
|
||||
let invitations: Vec<(String, String, bool, String, String, Option<String>)> = sqlx::query_as(
|
||||
"SELECT token_hash, label, admin, groups, expires_at, identity_id FROM invitations
|
||||
WHERE used_at IS NULL AND expires_at > ? ORDER BY created_at DESC",
|
||||
)
|
||||
.bind(util::now())
|
||||
.fetch_all(&state.db)
|
||||
.await?;
|
||||
let mut publication = json!({
|
||||
"remote": state.cfg.publish.as_ref().map(|p| p.remote.clone()),
|
||||
"publicKey": crate::publish::public_key(&state),
|
||||
});
|
||||
if state.cfg.publish.is_some() {
|
||||
publication["status"] = crate::publish::status(&state).await?;
|
||||
}
|
||||
Ok(Json(json!({
|
||||
"publication": publication,
|
||||
"groups": groups.iter().map(|(slug, name, policy, hidden)| json!({
|
||||
"slug": slug, "name": name, "policy": policy, "hidden": hidden,
|
||||
"members": members.iter().filter(|m| &m.group_slug == slug)
|
||||
.map(|m| json!({ "identityId": m.identity_id, "grantedAt": m.granted_at })).collect::<Vec<_>>(),
|
||||
})).collect::<Vec<_>>(),
|
||||
"identities": identities.iter().map(|i| json!({
|
||||
"id": i.id, "kind": i.kind, "displayName": i.display_name, "g1Member": i.g1_member, "admin": i.admin,
|
||||
"lastSeenAt": last_seen.iter().find(|(id, _)| id == &i.id).and_then(|(_, at)| at.clone()),
|
||||
"teamLink": came_by.iter().find(|(id, _)| id == &i.id).map(|(_, label)| label.clone()),
|
||||
"sessions": sessions.iter().find(|(id, _)| id == &i.id).map_or(0, |(_, n)| *n),
|
||||
})).collect::<Vec<_>>(),
|
||||
"teamLinks": crate::team::list(&state).await?,
|
||||
"invitations": invitations.iter().map(|(hash, label, admin, groups, expires, identity)| json!({
|
||||
"id": hash, "label": label, "admin": admin,
|
||||
"groups": serde_json::from_str::<Vec<String>>(groups).unwrap_or_default(),
|
||||
"expiresAt": expires, "identityId": identity,
|
||||
})).collect::<Vec<_>>(),
|
||||
})))
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct GrantBody {
|
||||
identity_id: String,
|
||||
}
|
||||
|
||||
pub async fn grant(
|
||||
State(state): State<AppState>,
|
||||
User(user): User,
|
||||
Path(slug): Path<String>,
|
||||
Json(b): Json<GrantBody>,
|
||||
) -> AppResult<Json<serde_json::Value>> {
|
||||
admin_only(&user)?;
|
||||
auth::load_identity(&state, &b.identity_id).await?;
|
||||
let done = sqlx::query(
|
||||
"INSERT OR IGNORE INTO memberships (group_slug, identity_id, granted_by, granted_at)
|
||||
SELECT slug, ?, ?, ? FROM groups WHERE slug = ?",
|
||||
)
|
||||
.bind(&b.identity_id)
|
||||
.bind(&user.id)
|
||||
.bind(util::now())
|
||||
.bind(&slug)
|
||||
.execute(&state.db)
|
||||
.await?;
|
||||
tracing::info!(group = %slug, identity = %b.identity_id, by = %user.id, "access granted");
|
||||
Ok(Json(json!({ "granted": done.rows_affected() == 1 })))
|
||||
}
|
||||
|
||||
pub async fn revoke(
|
||||
State(state): State<AppState>,
|
||||
User(user): User,
|
||||
Path((slug, identity)): Path<(String, String)>,
|
||||
) -> AppResult<Json<serde_json::Value>> {
|
||||
admin_only(&user)?;
|
||||
let done = sqlx::query("DELETE FROM memberships WHERE group_slug = ? AND identity_id = ?")
|
||||
.bind(&slug)
|
||||
.bind(&identity)
|
||||
.execute(&state.db)
|
||||
.await?;
|
||||
tracing::info!(group = %slug, identity = %identity, by = %user.id, "access revoked");
|
||||
Ok(Json(json!({ "revoked": done.rows_affected() == 1 })))
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct InviteBody {
|
||||
label: String,
|
||||
#[serde(default)]
|
||||
admin: bool,
|
||||
#[serde(default)]
|
||||
groups: Vec<String>,
|
||||
identity_id: Option<String>,
|
||||
}
|
||||
|
||||
/// The link is returned once and never stored in clear.
|
||||
pub async fn invite(
|
||||
State(state): State<AppState>,
|
||||
User(user): User,
|
||||
Json(b): Json<InviteBody>,
|
||||
) -> AppResult<Json<serde_json::Value>> {
|
||||
admin_only(&user)?;
|
||||
let label = b.label.trim();
|
||||
if label.is_empty()
|
||||
|| label.chars().count() > 60
|
||||
|| label.chars().any(char::is_control)
|
||||
|| ld_engine::edit::has_hidden_controls(label)
|
||||
{
|
||||
return Err(AppError::Invalid("label"));
|
||||
}
|
||||
// A new link for an existing person is for invitation identities only: a link
|
||||
// to a Ğ1 identity would let an administrator open a session in its name.
|
||||
if let Some(id) = &b.identity_id
|
||||
&& auth::load_identity(&state, id).await?.kind != "invite"
|
||||
{
|
||||
return Err(AppError::Invalid("identity_kind"));
|
||||
}
|
||||
let token =
|
||||
auth::create_invitation(&state, label, b.admin, &b.groups, Some(&user.id), b.identity_id.as_deref()).await?;
|
||||
Ok(Json(json!({ "link": auth::invitation_link(&state, &token) })))
|
||||
}
|
||||
|
||||
pub async fn cancel_invitation(
|
||||
State(state): State<AppState>,
|
||||
User(user): User,
|
||||
Path(hash): Path<String>,
|
||||
) -> AppResult<Json<serde_json::Value>> {
|
||||
admin_only(&user)?;
|
||||
sqlx::query("UPDATE invitations SET expires_at = ? WHERE token_hash = ? AND used_at IS NULL")
|
||||
.bind(util::now())
|
||||
.bind(&hash)
|
||||
.execute(&state.db)
|
||||
.await?;
|
||||
Ok(Json(json!({ "ok": true })))
|
||||
}
|
||||
|
||||
/// « Publier maintenant » : a key just added, a remote just created — no need
|
||||
/// to wait for the backoff.
|
||||
pub async fn publish_now(State(state): State<AppState>, User(user): User) -> AppResult<Json<serde_json::Value>> {
|
||||
admin_only(&user)?;
|
||||
if state.cfg.publish.is_none() {
|
||||
return Err(AppError::Conflict("publication_off"));
|
||||
}
|
||||
crate::publish::retry_now(&state).await?;
|
||||
tracing::info!(by = %user.id, "publication retried now");
|
||||
Ok(Json(json!({ "ok": true })))
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
pub struct AdminBody {
|
||||
admin: bool,
|
||||
}
|
||||
|
||||
pub async fn set_admin(
|
||||
State(state): State<AppState>,
|
||||
User(user): User,
|
||||
Path(id): Path<String>,
|
||||
Json(b): Json<AdminBody>,
|
||||
) -> AppResult<Json<serde_json::Value>> {
|
||||
admin_only(&user)?;
|
||||
auth::load_identity(&state, &id).await?;
|
||||
if !b.admin {
|
||||
let others: i64 = sqlx::query_scalar("SELECT COUNT(*) FROM identities WHERE admin = 1 AND id != ?")
|
||||
.bind(&id)
|
||||
.fetch_one(&state.db)
|
||||
.await?;
|
||||
if others == 0 {
|
||||
return Err(AppError::Conflict("last_admin"));
|
||||
}
|
||||
}
|
||||
sqlx::query("UPDATE identities SET admin = ? WHERE id = ?").bind(b.admin).bind(&id).execute(&state.db).await?;
|
||||
tracing::info!(identity = %id, admin = b.admin, by = %user.id, "administrator changed");
|
||||
Ok(Json(json!({ "admin": b.admin })))
|
||||
}
|
||||
|
||||
/// A lost device, a person who left: every session of this identity closes,
|
||||
/// but the one this request comes with, and so does a sign-in link still
|
||||
/// waiting for it (it may sit on the lost device).
|
||||
pub async fn close_sessions(
|
||||
State(state): State<AppState>,
|
||||
headers: HeaderMap,
|
||||
User(user): User,
|
||||
Path(id): Path<String>,
|
||||
) -> AppResult<Json<serde_json::Value>> {
|
||||
admin_only(&user)?;
|
||||
auth::load_identity(&state, &id).await?;
|
||||
let current = auth::session_hash(&state, &headers).unwrap_or_default();
|
||||
// The waiting link first: opened in between, it would open a session that stays.
|
||||
let links = sqlx::query(
|
||||
"UPDATE invitations SET expires_at = ? WHERE identity_id = ? AND used_at IS NULL AND expires_at > ?",
|
||||
)
|
||||
.bind(util::now())
|
||||
.bind(&id)
|
||||
.bind(util::now())
|
||||
.execute(&state.db)
|
||||
.await?
|
||||
.rows_affected();
|
||||
// Counted as the overview counts them; the expired ones go too.
|
||||
let open = sqlx::query("DELETE FROM sessions WHERE identity_id = ? AND token_hash != ? AND expires_at > ?")
|
||||
.bind(&id)
|
||||
.bind(¤t)
|
||||
.bind(util::now())
|
||||
.execute(&state.db)
|
||||
.await?
|
||||
.rows_affected();
|
||||
sqlx::query("DELETE FROM sessions WHERE identity_id = ? AND token_hash != ?")
|
||||
.bind(&id)
|
||||
.bind(¤t)
|
||||
.execute(&state.db)
|
||||
.await?;
|
||||
tracing::info!(identity = %id, closed = open, links, by = %user.id, "sessions closed");
|
||||
Ok(Json(json!({ "closed": open, "links": links })))
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
pub struct FeedbackBody {
|
||||
route: String,
|
||||
body: String,
|
||||
context: Option<serde_json::Value>,
|
||||
}
|
||||
|
||||
pub async fn send_feedback(
|
||||
State(state): State<AppState>,
|
||||
User(user): User,
|
||||
Json(b): Json<FeedbackBody>,
|
||||
) -> AppResult<Json<serde_json::Value>> {
|
||||
let body = b.body.trim();
|
||||
if body.is_empty() || body.chars().count() > 4000 || b.route.len() > 300 {
|
||||
return Err(AppError::Invalid("body"));
|
||||
}
|
||||
let context = b.context.map(|c| c.to_string()).filter(|c| c.len() < 4000);
|
||||
sqlx::query("INSERT INTO feedback (identity_id, route, body, context, created_at) VALUES (?, ?, ?, ?, ?)")
|
||||
.bind(&user.id)
|
||||
.bind(&b.route)
|
||||
.bind(body)
|
||||
.bind(context)
|
||||
.bind(util::now())
|
||||
.execute(&state.db)
|
||||
.await?;
|
||||
Ok(Json(json!({ "ok": true })))
|
||||
}
|
||||
|
||||
pub async fn feedback_list(State(state): State<AppState>, User(user): User) -> AppResult<Json<serde_json::Value>> {
|
||||
admin_only(&user)?;
|
||||
#[derive(sqlx::FromRow, serde::Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
struct Row {
|
||||
id: i64,
|
||||
author: Option<String>,
|
||||
route: String,
|
||||
body: String,
|
||||
context: Option<String>,
|
||||
created_at: String,
|
||||
status: String,
|
||||
}
|
||||
let rows = sqlx::query_as::<_, Row>(
|
||||
"SELECT f.id, i.display_name AS author, f.route, f.body, f.context, f.created_at, f.status
|
||||
FROM feedback f LEFT JOIN identities i ON i.id = f.identity_id ORDER BY f.id DESC LIMIT 500",
|
||||
)
|
||||
.fetch_all(&state.db)
|
||||
.await?;
|
||||
Ok(Json(json!({ "feedback": rows })))
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
pub struct StatusBody {
|
||||
status: String,
|
||||
}
|
||||
|
||||
pub async fn feedback_status(
|
||||
State(state): State<AppState>,
|
||||
User(user): User,
|
||||
Path(id): Path<i64>,
|
||||
Json(b): Json<StatusBody>,
|
||||
) -> AppResult<Json<serde_json::Value>> {
|
||||
admin_only(&user)?;
|
||||
if !matches!(b.status.as_str(), "new" | "seen" | "done") {
|
||||
return Err(AppError::Invalid("status"));
|
||||
}
|
||||
sqlx::query("UPDATE feedback SET status = ? WHERE id = ?").bind(&b.status).bind(id).execute(&state.db).await?;
|
||||
Ok(Json(json!({ "ok": true })))
|
||||
}
|
||||
@@ -0,0 +1,245 @@
|
||||
//! Entering into force — automatic, as soon as a proposal reaches its
|
||||
//! threshold (Yvv, 2026-10-21: « Dès qu'une proposition atteint le score, elle
|
||||
//! remonte en vigueur »). The human gesture is the vote itself; the engine
|
||||
//! only counts. Every entry is recorded with its numbers — the level of
|
||||
//! inertia, M, G, s_k and the usual participation it was judged by — and
|
||||
//! queued for publication to the texts repository.
|
||||
|
||||
use crate::AppState;
|
||||
use crate::docs::{self, DocRow, DocState};
|
||||
use crate::error::AppResult;
|
||||
use crate::util;
|
||||
use ld_engine::compose::{self, ProposalKind, UnitState};
|
||||
use serde::Serialize;
|
||||
use std::time::Duration;
|
||||
use tokio::sync::Mutex;
|
||||
|
||||
/// One adoption pass at a time per process: SQLite has a single writer, and
|
||||
/// two concurrent votes must not both promote rivals of the same unit.
|
||||
static PASS: Mutex<()> = Mutex::const_new(());
|
||||
|
||||
#[derive(Debug, Clone, Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct Entered {
|
||||
pub proposal_id: String,
|
||||
pub unit_id: String,
|
||||
pub code: String,
|
||||
pub kind: &'static str,
|
||||
}
|
||||
|
||||
pub async fn enqueue_publication(state: &AppState, doc: &DocRow, reason: &str) -> AppResult<()> {
|
||||
// A text in force changed: the document's title follows its title in force (ADR 0009).
|
||||
crate::doc_title::sync(state, &doc.id).await?;
|
||||
if doc.publish_path.is_none() {
|
||||
return Ok(());
|
||||
}
|
||||
let now = util::now();
|
||||
sqlx::query("INSERT INTO outbox (doc_id, reason, created_at, next_at) VALUES (?, ?, ?, ?)")
|
||||
.bind(&doc.id)
|
||||
.bind(reason)
|
||||
.bind(&now)
|
||||
.bind(&now)
|
||||
.execute(&state.db)
|
||||
.await?;
|
||||
state.publish_wake.notify_one();
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Seeds a minima that later reach their own threshold are marked confirmed.
|
||||
async fn confirm_seeds(state: &AppState, st: &DocState) -> AppResult<()> {
|
||||
for unit in &st.units {
|
||||
let Some(fid) = &unit.in_force_id else { continue };
|
||||
let Some(p) = st.proposals.iter().find(|p| &p.id == fid) else { continue };
|
||||
if p.entered_at.is_some() || p.confirmed_at.is_some() {
|
||||
continue;
|
||||
}
|
||||
if st.evaluation(unit, p).is_some_and(|e| e.reached) {
|
||||
sqlx::query("UPDATE proposals SET confirmed_at = ? WHERE id = ? AND confirmed_at IS NULL")
|
||||
.bind(util::now())
|
||||
.bind(&p.id)
|
||||
.execute(&state.db)
|
||||
.await?;
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub async fn run(state: &AppState, doc_id: &str) -> AppResult<Vec<Entered>> {
|
||||
let _pass = PASS.lock().await;
|
||||
let st = docs::load_state(state, doc_id).await?;
|
||||
confirm_seeds(state, &st).await?;
|
||||
let units = st.unit_states();
|
||||
let scale = st.scale();
|
||||
let winners = compose::adoptions(&units, &st.proposal_states(), &scale)
|
||||
.map_err(|_| crate::error::AppError::Invalid("electorate"))?;
|
||||
let mut entered = Vec::new();
|
||||
for id in winners {
|
||||
let Some(p) = st.proposals.iter().find(|p| p.id == id) else { continue };
|
||||
let Some(unit) = st.units.iter().find(|u| u.id == p.unit_id) else { continue };
|
||||
let tally = st.tally(&p.id);
|
||||
let eval = st.evaluation(unit, p);
|
||||
let params = kept_params(&st, unit, p);
|
||||
let now = util::now();
|
||||
let mut tx = state.db.begin().await?;
|
||||
// The text that enters is the text that was counted: a revision that landed
|
||||
// since the state was read stops it (the next pass will judge it again).
|
||||
let unchanged = sqlx::query(
|
||||
"UPDATE proposals SET updated_at = updated_at
|
||||
WHERE id = ? AND revision = ? AND title = ? AND body = ? AND status = ?",
|
||||
)
|
||||
.bind(&p.id)
|
||||
.bind(p.revision)
|
||||
.bind(&p.title)
|
||||
.bind(&p.body)
|
||||
.bind(&p.status)
|
||||
.execute(&mut *tx)
|
||||
.await?;
|
||||
if unchanged.rows_affected() != 1 {
|
||||
continue;
|
||||
}
|
||||
let kind = match p.kind() {
|
||||
ProposalKind::Text | ProposalKind::Remove => {
|
||||
// The displaced version goes back among the proposals, with its votes.
|
||||
if let Some(old) = &unit.in_force_id {
|
||||
sqlx::query("UPDATE proposals SET status = 'open', left_at = ?, updated_at = ? WHERE id = ?")
|
||||
.bind(&now)
|
||||
.bind(&now)
|
||||
.bind(old)
|
||||
.execute(&mut *tx)
|
||||
.await?;
|
||||
}
|
||||
if p.kind() == ProposalKind::Text {
|
||||
sqlx::query("UPDATE proposals SET status = 'in_force', entered_at = ?, left_at = NULL, updated_at = ? WHERE id = ?")
|
||||
.bind(&now)
|
||||
.bind(&now)
|
||||
.bind(&p.id)
|
||||
.execute(&mut *tx)
|
||||
.await?;
|
||||
sqlx::query("UPDATE units SET in_force_id = ? WHERE id = ?")
|
||||
.bind(&p.id)
|
||||
.bind(&unit.id)
|
||||
.execute(&mut *tx)
|
||||
.await?;
|
||||
// A module does more: an order moves its articles, a title renames the document.
|
||||
crate::modules::on_entry(&mut tx, &st, unit, p).await?
|
||||
} else {
|
||||
sqlx::query("UPDATE proposals SET status = 'applied', entered_at = ?, updated_at = ? WHERE id = ?")
|
||||
.bind(&now)
|
||||
.bind(&now)
|
||||
.bind(&p.id)
|
||||
.execute(&mut *tx)
|
||||
.await?;
|
||||
sqlx::query("UPDATE units SET in_force_id = NULL WHERE id = ?")
|
||||
.bind(&unit.id)
|
||||
.execute(&mut *tx)
|
||||
.await?;
|
||||
"remove"
|
||||
}
|
||||
}
|
||||
ProposalKind::Inertia(target) => {
|
||||
sqlx::query("UPDATE proposals SET status = 'applied', entered_at = ?, updated_at = ? WHERE id = ?")
|
||||
.bind(&now)
|
||||
.bind(&now)
|
||||
.bind(&p.id)
|
||||
.execute(&mut *tx)
|
||||
.await?;
|
||||
sqlx::query("UPDATE units SET inertia = ? WHERE id = ?")
|
||||
.bind(target.as_db())
|
||||
.bind(&unit.id)
|
||||
.execute(&mut *tx)
|
||||
.await?;
|
||||
sqlx::query(
|
||||
"INSERT INTO inertia_changes (unit_id, doc_id, from_level, to_level, cause, proposal_id, at)
|
||||
VALUES (?, ?, ?, ?, 'vote', ?, ?)",
|
||||
)
|
||||
.bind(&unit.id)
|
||||
.bind(&st.doc.id)
|
||||
.bind(docs::inertia_of(unit).as_db())
|
||||
.bind(target.as_db())
|
||||
.bind(&p.id)
|
||||
.bind(&now)
|
||||
.execute(&mut *tx)
|
||||
.await?;
|
||||
"inertia"
|
||||
}
|
||||
};
|
||||
sqlx::query(
|
||||
"INSERT INTO adoptions (proposal_id, unit_id, doc_id, kind, votes_for, votes_against, electorate, required, params, at)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)",
|
||||
)
|
||||
.bind(&p.id)
|
||||
.bind(&unit.id)
|
||||
.bind(&st.doc.id)
|
||||
.bind(kind)
|
||||
.bind(i64::from(tally.for_votes))
|
||||
.bind(i64::from(tally.against))
|
||||
.bind(i64::from(st.electorate))
|
||||
.bind(eval.map_or(0, |e| i64::from(e.required)))
|
||||
.bind(params.to_string())
|
||||
.bind(&now)
|
||||
.execute(&mut *tx)
|
||||
.await?;
|
||||
tx.commit().await?;
|
||||
tracing::info!(doc = %st.doc.id, unit = %unit.code, proposal = %p.id, kind, "entered into force");
|
||||
entered.push(Entered { proposal_id: p.id.clone(), unit_id: unit.id.clone(), code: unit.code.clone(), kind });
|
||||
}
|
||||
if !entered.is_empty() {
|
||||
let codes: Vec<&str> = entered.iter().map(|e| e.code.as_str()).collect();
|
||||
enqueue_publication(state, &st.doc, &format!("entrée en vigueur : {}", codes.join(", "))).await?;
|
||||
}
|
||||
Ok(entered)
|
||||
}
|
||||
|
||||
/// What an adoption keeps of the formula it was judged by. The letters stay where
|
||||
/// the rows of before have them (`majority_pct`, `gradient`, `base`; a row before
|
||||
/// 2026-11-03 holds `constant`, the C of before, read as it was written); the quorum
|
||||
/// (ADR 0013), the level and the scale join them — and, for the act that ends the
|
||||
/// simulation (ADR 0012, `simulation::discard`), the simulated share of the votes it
|
||||
/// entered with, the version it displaced and the level of inertia of before.
|
||||
fn kept_params(st: &DocState, unit: &docs::UnitRow, p: &docs::ProposalRow) -> serde_json::Value {
|
||||
let level = st.voting_level(unit, p);
|
||||
let f = st.params(unit, p);
|
||||
let simulated = st.simulated_counted(&p.id);
|
||||
serde_json::json!({
|
||||
"majority_pct": f.majority_pct, "gradient": f.gradient, "base": f.base, "quorum": f.quorum,
|
||||
"level": level, "share_pct": level.share_pct(),
|
||||
"participation": st.participation.ratio, "participation_source": st.participation.source,
|
||||
"participation_sample": st.participation.sample, "participation_median": st.participation.median,
|
||||
"simulated": simulated, "displaced": unit.in_force_id, "from_inertia": docs::inertia_of(unit),
|
||||
})
|
||||
}
|
||||
|
||||
/// The electorate changes (the web of trust grows or shrinks): every few
|
||||
/// minutes, every document is passed through the engine again — after the
|
||||
/// usual participation of each group is measured again, once a day, and after
|
||||
/// the deadlines of three days are passed (`deadlines`).
|
||||
pub async fn sweeper(state: AppState) {
|
||||
let mut tick = tokio::time::interval(Duration::from_secs(300));
|
||||
loop {
|
||||
tick.tick().await;
|
||||
if let Err(e) = crate::participation::refresh(&state, false).await {
|
||||
tracing::warn!(error = %e, "usual participation");
|
||||
}
|
||||
// Objections lifted and typo fixes applied at their deadline (three days).
|
||||
if let Err(e) = crate::deadlines::sweep(&state).await {
|
||||
tracing::warn!(error = %e, "deadlines");
|
||||
}
|
||||
let ids: Vec<String> = match sqlx::query_scalar("SELECT id FROM documents").fetch_all(&state.db).await {
|
||||
Ok(ids) => ids,
|
||||
Err(e) => {
|
||||
tracing::warn!(error = %e, "sweeper");
|
||||
continue;
|
||||
}
|
||||
};
|
||||
for id in ids {
|
||||
if let Err(e) = run(&state, &id).await {
|
||||
tracing::warn!(doc = %id, error = %e, "sweeper pass");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Unit states, re-exported for tests.
|
||||
pub fn unit_states(st: &DocState) -> Vec<UnitState> {
|
||||
st.unit_states()
|
||||
}
|
||||
@@ -0,0 +1,378 @@
|
||||
//! Article amendments — an article the seed now holds, added to a document that was
|
||||
//! born without it.
|
||||
//!
|
||||
//! Yvv, 2026-11-03 (lot T4): « AJOUT d'un texte : RESPONSABILITE CIVIQUE → 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. » A new base gets it from the seed:
|
||||
//! E12 « Responsabilité civique », a seed proposal (ADR 0011) at `seed::SEED_PROPOSAL_LEVEL`,
|
||||
//! between E1 and E2 in the section `fondamental`. An existing base — production holds the
|
||||
//! certification act twice, Monnaie libre and the test group — gets it through an amendment
|
||||
//! listed here, decided once per document, on the model of `module_amend`: the article of
|
||||
//! the seed is written as a new base writes it (`seed::write_unit`: its unit, its version
|
||||
//! open and put forward, its first revision, its translations), and nothing else is
|
||||
//! written — no text changes, no vote is touched, no other article moves.
|
||||
//!
|
||||
//! **Where it goes.** The order of a section is a module (ADR 0009). In force at the start
|
||||
//! without a vote (a seed a minima), it reads the positions of the articles. While no vote
|
||||
//! exists on that order (`quorum::voted_units`: a vote, a line of a ballot, an adoption), the
|
||||
//! article takes the place the seed gives it: between `after` and `before`, adjacent in the
|
||||
//! order of the section now, half-way between their positions — so the order in force says
|
||||
//! it at once. Once someone voted on the order, or when the two are no longer adjacent, or
|
||||
//! one of them is gone from the section, its place is that of any new article: the end of
|
||||
//! its section (`ld_engine::order::place_at_end`); the order of the section is then voted
|
||||
//! as always (an open order completes it at its end, ADR 0009).
|
||||
//!
|
||||
//! The document holds a unit of that code already: present when it is the seed's (born
|
||||
//! with it), refused otherwise (`code_taken`); no such section in the document: refused
|
||||
//! (`no_section`). Each decision is kept in `article_amendments` (where the article went,
|
||||
//! and why at the end) and in the logs. Applied where the document publishes, the
|
||||
//! publication queue gets a row, which waits for the first text (ADR 0011).
|
||||
|
||||
use crate::AppState;
|
||||
use crate::quorum;
|
||||
use crate::seed::{self, SeedDoc, SeedUnit};
|
||||
use crate::seed_amend::fingerprint;
|
||||
use crate::util;
|
||||
use sqlx::SqliteConnection;
|
||||
|
||||
/// One article to add to every document instantiated from a seed.
|
||||
pub struct ArticleAmendment {
|
||||
/// Stable identifier: each document's decision is kept under it.
|
||||
pub id: &'static str,
|
||||
/// Slug of the seed document.
|
||||
pub doc: &'static str,
|
||||
/// Code of the article: one no document holds yet.
|
||||
pub unit: &'static str,
|
||||
/// Fingerprint of its text in the seed (source language), which must hash to it: a seed
|
||||
/// edited later never reaches a base by this way.
|
||||
pub text: &'static str,
|
||||
/// The two articles of its section it goes between, in this order.
|
||||
pub after: &'static str,
|
||||
pub before: &'static str,
|
||||
/// Reason given to the publication queue, and so to the commit of the texts.
|
||||
pub reason: &'static str,
|
||||
}
|
||||
|
||||
pub const AMENDMENTS: &[ArticleAmendment] = &[ArticleAmendment {
|
||||
id: "2026-11-03-e12-responsabilite-civique",
|
||||
doc: "acte-engagement-certification",
|
||||
unit: "E12",
|
||||
text: "3fa5a8e5df24e9ebb6b2882968bd0b6989c820a3f60d7c67bdb7fccf17ae1296",
|
||||
after: "E1",
|
||||
before: "E2",
|
||||
reason: "Proposition d'amorce ajoutée : E12, Responsabilité civique (texte d'Yvv du 03/11)",
|
||||
}];
|
||||
|
||||
/// What the database says of the document.
|
||||
#[derive(Debug, Clone, Default, PartialEq, Eq)]
|
||||
pub struct Facts {
|
||||
/// A unit of that code: `Some(true)` the seed's, `Some(false)` another one.
|
||||
pub existing: Option<bool>,
|
||||
/// The section is in the document (an article of it, not retired).
|
||||
pub section: bool,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum Verdict {
|
||||
Apply,
|
||||
/// Born with it: nothing to do.
|
||||
Present,
|
||||
Refuse(&'static str),
|
||||
}
|
||||
|
||||
/// Whether the article is added, apart from the database.
|
||||
pub fn verdict(f: &Facts) -> Verdict {
|
||||
match f.existing {
|
||||
Some(true) => Verdict::Present,
|
||||
Some(false) => Verdict::Refuse("code_taken"),
|
||||
None if !f.section => Verdict::Refuse("no_section"),
|
||||
None => Verdict::Apply,
|
||||
}
|
||||
}
|
||||
|
||||
/// Where the article goes.
|
||||
#[derive(Debug, Clone, Copy, PartialEq)]
|
||||
pub enum Placement {
|
||||
/// Where the seed puts it, at this position: the order in force says it at once.
|
||||
Between(f64),
|
||||
/// At the end of its section, as any new article (ADR 0009), and why.
|
||||
End(&'static str),
|
||||
}
|
||||
|
||||
/// Where it goes, apart from the database: `voted`, whether a vote exists on the order of
|
||||
/// its section; `members`, the section in its order now (code, position); `positions`,
|
||||
/// every article of the document.
|
||||
pub fn placement(voted: bool, members: &[(String, f64)], after: &str, before: &str, positions: &[f64]) -> Placement {
|
||||
if voted {
|
||||
return Placement::End("order_voted");
|
||||
}
|
||||
let at = |code: &str| members.iter().position(|(c, _)| c == code);
|
||||
let (Some(i), Some(j)) = (at(after), at(before)) else { return Placement::End("not_in_section") };
|
||||
let (a, b) = (members[i].1, members[j].1);
|
||||
// Adjacent in the order, and nothing of the document between their places.
|
||||
if j != i + 1 || a >= b || positions.iter().any(|p| *p > a && *p < b) {
|
||||
return Placement::End("not_adjacent");
|
||||
}
|
||||
Placement::Between(f64::midpoint(a, b))
|
||||
}
|
||||
|
||||
/// A decision taken at this start.
|
||||
#[derive(Debug, Clone, PartialEq)]
|
||||
pub struct Outcome {
|
||||
pub amendment: &'static str,
|
||||
pub group: String,
|
||||
pub verdict: Verdict,
|
||||
pub placement: Option<Placement>,
|
||||
}
|
||||
|
||||
/// Decides every amendment not yet decided, document by document.
|
||||
pub async fn apply(state: &AppState, seeds: &[SeedDoc], list: &[ArticleAmendment]) -> anyhow::Result<Vec<Outcome>> {
|
||||
let mut out = Vec::new();
|
||||
for a in list {
|
||||
let Some((seed, unit)) = seed_says(seeds, a) else { continue };
|
||||
let docs: Vec<(String, String, Option<String>)> =
|
||||
sqlx::query_as("SELECT id, group_slug, publish_path FROM documents WHERE slug = ? ORDER BY group_slug")
|
||||
.bind(a.doc)
|
||||
.fetch_all(&state.db)
|
||||
.await?;
|
||||
for (doc_id, group, publish_path) in docs {
|
||||
let Some((v, placement)) = decide(state, a, seed, unit, &doc_id, publish_path.is_some()).await? else {
|
||||
continue;
|
||||
};
|
||||
match (v, placement) {
|
||||
(Verdict::Apply, Some(p)) => {
|
||||
tracing::info!(amendment = a.id, %group, unit = a.unit, placement = ?p, "article added")
|
||||
}
|
||||
(Verdict::Refuse(why), _) => {
|
||||
tracing::warn!(amendment = a.id, %group, unit = a.unit, reason = why, "article amendment refused: the document stays as it is")
|
||||
}
|
||||
_ => tracing::info!(amendment = a.id, %group, unit = a.unit, "article amendment: already there"),
|
||||
}
|
||||
if v == Verdict::Apply && publish_path.is_some() {
|
||||
state.publish_wake.notify_one();
|
||||
}
|
||||
out.push(Outcome { amendment: a.id, group, verdict: v, placement });
|
||||
}
|
||||
}
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
/// The seed must hold the article as a seed proposal with the text the amendment names,
|
||||
/// between the two it names, in their section.
|
||||
fn seed_says<'a>(seeds: &'a [SeedDoc], a: &ArticleAmendment) -> Option<(&'a SeedDoc, &'a SeedUnit)> {
|
||||
let found = seeds.iter().find(|s| s.slug == a.doc).and_then(|s| {
|
||||
let src = s.source_lang.as_str();
|
||||
let of = |code: &str| s.units.iter().find(|u| u.code == code);
|
||||
let (u, after, before) = (of(a.unit)?, of(a.after)?, of(a.before)?);
|
||||
let v = u.versions.iter().find(|v| v.seed_proposal)?;
|
||||
let print = fingerprint(v.title.get(src)?, v.body.get(src)?);
|
||||
let placed = u.section.is_some()
|
||||
&& after.section == u.section
|
||||
&& before.section == u.section
|
||||
&& after.position < u.position
|
||||
&& u.position < before.position;
|
||||
(print == a.text && u.inertia.is_none() && placed).then_some((s, u))
|
||||
});
|
||||
if found.is_none() {
|
||||
tracing::error!(
|
||||
amendment = a.id,
|
||||
doc = a.doc,
|
||||
unit = a.unit,
|
||||
"article amendment: the seed does not hold this article where it names it, nothing decided"
|
||||
);
|
||||
}
|
||||
found
|
||||
}
|
||||
|
||||
async fn facts(conn: &mut SqliteConnection, doc_id: &str, a: &ArticleAmendment, section: &str) -> sqlx::Result<Facts> {
|
||||
let existing: Option<(String, i64)> = sqlx::query_as(
|
||||
"SELECT u.id, (SELECT COUNT(*) FROM proposals p WHERE p.unit_id = u.id AND p.origin = 'seed')
|
||||
FROM units u WHERE u.doc_id = ? AND u.code = ?",
|
||||
)
|
||||
.bind(doc_id)
|
||||
.bind(a.unit)
|
||||
.fetch_optional(&mut *conn)
|
||||
.await?;
|
||||
let in_section: i64 = sqlx::query_scalar(
|
||||
"SELECT COUNT(*) FROM units WHERE doc_id = ? AND kind = 'article' AND section = ? AND retired_at IS NULL",
|
||||
)
|
||||
.bind(doc_id)
|
||||
.bind(section)
|
||||
.fetch_one(&mut *conn)
|
||||
.await?;
|
||||
Ok(Facts { existing: existing.map(|(_, seed)| seed > 0), section: in_section > 0 })
|
||||
}
|
||||
|
||||
/// The placement, and the end of the section (the place of any new article there).
|
||||
async fn where_to(
|
||||
conn: &mut SqliteConnection,
|
||||
doc_id: &str,
|
||||
a: &ArticleAmendment,
|
||||
section: &str,
|
||||
) -> sqlx::Result<(Placement, f64)> {
|
||||
let articles: Vec<(String, String, Option<String>, f64)> = sqlx::query_as(
|
||||
"SELECT id, code, section, position FROM units WHERE doc_id = ? AND kind = 'article' AND retired_at IS NULL",
|
||||
)
|
||||
.bind(doc_id)
|
||||
.fetch_all(&mut *conn)
|
||||
.await?;
|
||||
let order: Option<String> = sqlx::query_scalar(
|
||||
"SELECT id FROM units WHERE doc_id = ? AND kind = 'order' AND section = ? AND retired_at IS NULL",
|
||||
)
|
||||
.bind(doc_id)
|
||||
.bind(section)
|
||||
.fetch_optional(&mut *conn)
|
||||
.await?;
|
||||
let voted = match &order {
|
||||
Some(id) => quorum::voted_units(conn, doc_id).await?.contains(id),
|
||||
None => false,
|
||||
};
|
||||
let members: Vec<(String, f64)> = crate::order::sections_now(conn, doc_id)
|
||||
.await?
|
||||
.into_iter()
|
||||
.find(|(s, _)| s == section)
|
||||
.map(|(_, ids)| {
|
||||
ids.iter().filter_map(|id| articles.iter().find(|x| &x.0 == id).map(|x| (x.1.clone(), x.3))).collect()
|
||||
})
|
||||
.unwrap_or_default();
|
||||
let positions: Vec<f64> = articles.iter().map(|x| x.3).collect();
|
||||
let places: Vec<(Option<&str>, f64)> = articles.iter().map(|x| (x.2.as_deref(), x.3)).collect();
|
||||
let end = ld_engine::order::place_at_end(&places, Some(section));
|
||||
Ok((placement(voted, &members, a.after, a.before, &positions), end))
|
||||
}
|
||||
|
||||
/// One document, in one transaction: `None` when it was decided at an earlier start.
|
||||
async fn decide(
|
||||
state: &AppState,
|
||||
a: &ArticleAmendment,
|
||||
seed: &SeedDoc,
|
||||
unit: &SeedUnit,
|
||||
doc_id: &str,
|
||||
publishes: bool,
|
||||
) -> anyhow::Result<Option<(Verdict, Option<Placement>)>> {
|
||||
let mut tx = state.db.begin().await?;
|
||||
let decided: i64 =
|
||||
sqlx::query_scalar("SELECT COUNT(*) FROM article_amendments WHERE amendment_id = ? AND doc_id = ?")
|
||||
.bind(a.id)
|
||||
.bind(doc_id)
|
||||
.fetch_one(&mut *tx)
|
||||
.await?;
|
||||
if decided > 0 {
|
||||
return Ok(None);
|
||||
}
|
||||
let section = unit.section.clone().unwrap_or_default();
|
||||
let found = facts(&mut tx, doc_id, a, §ion).await?;
|
||||
let v = verdict(&found);
|
||||
let now = util::now();
|
||||
let (mut placement, mut position, mut unit_id) = (None, None, None);
|
||||
if v == Verdict::Apply {
|
||||
let (p, end) = where_to(&mut tx, doc_id, a, §ion).await?;
|
||||
let at = match p {
|
||||
Placement::Between(at) => at,
|
||||
Placement::End(_) => end,
|
||||
};
|
||||
unit_id = Some(seed::write_unit(&mut tx, doc_id, &seed.source_lang, unit, at, &now).await?);
|
||||
(placement, position) = (Some(p), Some(at));
|
||||
if publishes {
|
||||
let where_ = match p {
|
||||
Placement::Between(_) => format!("entre {} et {}", a.after, a.before),
|
||||
Placement::End(_) => "en fin de section".to_owned(),
|
||||
};
|
||||
sqlx::query("INSERT INTO outbox (doc_id, reason, created_at, next_at) VALUES (?, ?, ?, ?)")
|
||||
.bind(doc_id)
|
||||
.bind(format!("{}, {where_}", a.reason))
|
||||
.bind(&now)
|
||||
.bind(&now)
|
||||
.execute(&mut *tx)
|
||||
.await?;
|
||||
}
|
||||
} else if v == Verdict::Present {
|
||||
unit_id = sqlx::query_scalar("SELECT id FROM units WHERE doc_id = ? AND code = ?")
|
||||
.bind(doc_id)
|
||||
.bind(a.unit)
|
||||
.fetch_optional(&mut *tx)
|
||||
.await?;
|
||||
}
|
||||
let (outcome, reason) = match v {
|
||||
Verdict::Apply => ("applied", None),
|
||||
Verdict::Present => ("present", None),
|
||||
Verdict::Refuse(why) => ("refused", Some(why)),
|
||||
};
|
||||
let (kind, why_end) = match placement {
|
||||
Some(Placement::Between(_)) => (Some("between"), None),
|
||||
Some(Placement::End(why)) => (Some("end"), Some(why)),
|
||||
None => (None, None),
|
||||
};
|
||||
sqlx::query(
|
||||
"INSERT INTO article_amendments (amendment_id, doc_id, unit_code, unit_id, outcome, reason, placement,
|
||||
placement_reason, position, at)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)",
|
||||
)
|
||||
.bind(a.id)
|
||||
.bind(doc_id)
|
||||
.bind(a.unit)
|
||||
.bind(&unit_id)
|
||||
.bind(outcome)
|
||||
.bind(reason)
|
||||
.bind(kind)
|
||||
.bind(why_end)
|
||||
.bind(position)
|
||||
.bind(&now)
|
||||
.execute(&mut *tx)
|
||||
.await?;
|
||||
tx.commit().await?;
|
||||
Ok(Some((v, placement)))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn section(codes: &[(&str, f64)]) -> Vec<(String, f64)> {
|
||||
codes.iter().map(|(c, p)| ((*c).to_owned(), *p)).collect()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_document_without_the_article_receives_it() {
|
||||
assert_eq!(verdict(&Facts { existing: None, section: true }), Verdict::Apply);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn born_with_it_or_its_code_taken_it_is_not_written_again() {
|
||||
assert_eq!(verdict(&Facts { existing: Some(true), section: true }), Verdict::Present);
|
||||
assert_eq!(verdict(&Facts { existing: Some(false), section: true }), Verdict::Refuse("code_taken"));
|
||||
assert_eq!(verdict(&Facts { existing: None, section: false }), Verdict::Refuse("no_section"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn it_goes_between_its_two_while_the_order_was_never_voted() {
|
||||
let fondamental = section(&[("T1", 3.0), ("E1", 4.0), ("E2", 5.0)]);
|
||||
let all = [1.0, 2.0, 3.0, 4.0, 5.0, 6.0];
|
||||
assert_eq!(placement(false, &fondamental, "E1", "E2", &all), Placement::Between(4.5));
|
||||
// A member's article at the end of the section changes nothing between them.
|
||||
let longer = section(&[("T1", 3.0), ("E1", 4.0), ("E2", 5.0), ("N3", 5.5)]);
|
||||
assert_eq!(placement(false, &longer, "E1", "E2", &[3.0, 4.0, 5.0, 5.5, 6.0]), Placement::Between(4.5));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn otherwise_at_the_end_of_its_section_and_why() {
|
||||
let fondamental = section(&[("T1", 3.0), ("E1", 4.0), ("E2", 5.0)]);
|
||||
let all = [3.0, 4.0, 5.0];
|
||||
assert_eq!(placement(true, &fondamental, "E1", "E2", &all), Placement::End("order_voted"));
|
||||
// An order voted into force that put E2 first.
|
||||
let moved = section(&[("T1", 3.0), ("E2", 4.0), ("E1", 5.0)]);
|
||||
assert_eq!(placement(false, &moved, "E1", "E2", &all), Placement::End("not_adjacent"));
|
||||
let apart = section(&[("E1", 4.0), ("T1", 4.5), ("E2", 5.0)]);
|
||||
assert_eq!(placement(false, &apart, "E1", "E2", &all), Placement::End("not_adjacent"));
|
||||
// Something of another section between their places.
|
||||
assert_eq!(placement(false, &fondamental, "E1", "E2", &[4.0, 4.2, 5.0]), Placement::End("not_adjacent"));
|
||||
let gone = section(&[("T1", 3.0), ("E1", 4.0)]);
|
||||
assert_eq!(placement(false, &gone, "E1", "E2", &all), Placement::End("not_in_section"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_shipped_amendment_adds_e12_between_e1_and_e2() {
|
||||
let a = &AMENDMENTS[0];
|
||||
assert_eq!((a.unit, a.after, a.before), ("E12", "E1", "E2"));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,620 @@
|
||||
//! Sign-in: the Ğ1 access of the Monnaie libre forum, invitation links, and a
|
||||
//! local development login.
|
||||
//!
|
||||
//! **Ğ1.** libreDecision is an OAuth2 client of `connect.monnaie-libre.fr`
|
||||
//! (sso-connect): the person signs a one-time challenge in Ğecko, the service
|
||||
//! reads the chain and answers with the on-chain identity. The account is
|
||||
//! keyed on `idty:<index>`, which survives a change of wallet key. Members of
|
||||
//! the web of trust only — sso-connect refuses the others before we see them.
|
||||
//!
|
||||
//! **Invitation.** A single-use link created in trustinWallet by an access
|
||||
//! administrator; it signs a person in as an invited identity (tests, or a
|
||||
//! person without Ğ1 sign-in yet), with the groups the link grants.
|
||||
//!
|
||||
//! **Dev.** `LD_DEV_LOGIN=1` only, never in production.
|
||||
|
||||
use crate::AppState;
|
||||
use crate::error::{AppError, AppResult};
|
||||
use crate::util;
|
||||
use axum::Json;
|
||||
use axum::extract::{FromRequestParts, Query, State};
|
||||
use axum::http::header::{COOKIE, LOCATION, SET_COOKIE};
|
||||
use axum::http::request::Parts;
|
||||
use axum::http::{HeaderMap, HeaderValue, StatusCode};
|
||||
use axum::response::{IntoResponse, Response};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use serde_json::json;
|
||||
use trustin_access::{Identity, IdentityKind};
|
||||
|
||||
pub const SESSION_COOKIE: &str = "ld_session";
|
||||
const OAUTH_COOKIE: &str = "ld_oauth";
|
||||
|
||||
/// Over https, the prefixed names: a neighbouring site under the same parent
|
||||
/// domain cannot plant a `__Host-` cookie (session fixation), nor a
|
||||
/// `__Secure-` one without https.
|
||||
fn session_cookie(state: &AppState) -> &'static str {
|
||||
if state.cfg.secure_cookies() { "__Host-ld_session" } else { SESSION_COOKIE }
|
||||
}
|
||||
|
||||
fn oauth_cookie(state: &AppState) -> &'static str {
|
||||
if state.cfg.secure_cookies() { "__Secure-ld_oauth" } else { OAUTH_COOKIE }
|
||||
}
|
||||
/// A session lasts until it is closed (sign-out, or an administrator): 400 days
|
||||
/// is the longest a browser keeps a cookie, and each visit starts them again.
|
||||
const SESSION_DAYS: i64 = 400;
|
||||
const INVITATION_DAYS: i64 = 14;
|
||||
|
||||
fn cookie_value(headers: &HeaderMap, name: &str) -> Option<String> {
|
||||
headers
|
||||
.get_all(COOKIE)
|
||||
.iter()
|
||||
.filter_map(|v| v.to_str().ok())
|
||||
.flat_map(|v| v.split(';'))
|
||||
.filter_map(|pair| pair.trim().split_once('='))
|
||||
.find(|(k, _)| *k == name)
|
||||
.map(|(_, v)| v.to_owned())
|
||||
}
|
||||
|
||||
fn cookie(state: &AppState, name: &str, value: &str, path: &str, max_age: i64) -> HeaderValue {
|
||||
let secure = if state.cfg.secure_cookies() { "; Secure" } else { "" };
|
||||
let raw = format!("{name}={value}; Path={path}; HttpOnly; SameSite=Lax; Max-Age={max_age}{secure}");
|
||||
HeaderValue::from_str(&raw).unwrap_or_else(|_| HeaderValue::from_static(""))
|
||||
}
|
||||
|
||||
#[derive(Serialize, sqlx::FromRow)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct IdentityRow {
|
||||
pub id: String,
|
||||
pub kind: String,
|
||||
pub display_name: String,
|
||||
pub address: Option<String>,
|
||||
pub g1_member: bool,
|
||||
pub admin: bool,
|
||||
}
|
||||
|
||||
impl IdentityRow {
|
||||
pub fn to_identity(&self) -> Identity {
|
||||
Identity {
|
||||
id: self.id.clone(),
|
||||
kind: IdentityKind::parse(&self.kind).unwrap_or(IdentityKind::Invite),
|
||||
display_name: self.display_name.clone(),
|
||||
g1_member: self.g1_member,
|
||||
admin: self.admin,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub const IDENTITY_COLUMNS: &str = "id, kind, display_name, address, g1_member, admin";
|
||||
|
||||
/// The signed-in person, if any.
|
||||
pub struct Viewer(pub Option<IdentityRow>);
|
||||
|
||||
/// The signed-in person; 401 otherwise.
|
||||
pub struct User(pub IdentityRow);
|
||||
|
||||
impl FromRequestParts<AppState> for Viewer {
|
||||
type Rejection = AppError;
|
||||
|
||||
async fn from_request_parts(parts: &mut Parts, state: &AppState) -> Result<Self, Self::Rejection> {
|
||||
let Some(token) = cookie_value(&parts.headers, session_cookie(state)) else {
|
||||
return Ok(Self(None));
|
||||
};
|
||||
let row = sqlx::query_as::<_, IdentityRow>(&format!(
|
||||
"SELECT i.{} FROM sessions s JOIN identities i ON i.id = s.identity_id
|
||||
WHERE s.token_hash = ? AND s.expires_at > ?",
|
||||
IDENTITY_COLUMNS.replace(", ", ", i.")
|
||||
))
|
||||
.bind(util::sha256_hex(&token))
|
||||
.bind(util::now())
|
||||
.fetch_optional(&state.db)
|
||||
.await?;
|
||||
Ok(Self(row))
|
||||
}
|
||||
}
|
||||
|
||||
impl FromRequestParts<AppState> for User {
|
||||
type Rejection = AppError;
|
||||
|
||||
async fn from_request_parts(parts: &mut Parts, state: &AppState) -> Result<Self, Self::Rejection> {
|
||||
match Viewer::from_request_parts(parts, state).await?.0 {
|
||||
Some(row) => Ok(Self(row)),
|
||||
None => Err(AppError::Unauthenticated),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) async fn open_session(state: &AppState, identity_id: &str) -> AppResult<HeaderValue> {
|
||||
let token = util::token();
|
||||
sqlx::query("INSERT INTO sessions (token_hash, identity_id, created_at, expires_at) VALUES (?, ?, ?, ?)")
|
||||
.bind(util::sha256_hex(&token))
|
||||
.bind(identity_id)
|
||||
.bind(util::now())
|
||||
.bind(util::in_days(SESSION_DAYS))
|
||||
.execute(&state.db)
|
||||
.await?;
|
||||
sqlx::query("UPDATE identities SET last_seen_at = ? WHERE id = ?")
|
||||
.bind(util::now())
|
||||
.bind(identity_id)
|
||||
.execute(&state.db)
|
||||
.await?;
|
||||
Ok(cookie(state, session_cookie(state), &token, "/", SESSION_DAYS * 86_400))
|
||||
}
|
||||
|
||||
pub async fn load_identity(state: &AppState, id: &str) -> AppResult<IdentityRow> {
|
||||
sqlx::query_as::<_, IdentityRow>(&format!("SELECT {IDENTITY_COLUMNS} FROM identities WHERE id = ?"))
|
||||
.bind(id)
|
||||
.fetch_optional(&state.db)
|
||||
.await?
|
||||
.ok_or(AppError::NotFound)
|
||||
}
|
||||
|
||||
pub(crate) async fn grant_groups(
|
||||
state: &AppState,
|
||||
identity_id: &str,
|
||||
groups: &[String],
|
||||
by: Option<&str>,
|
||||
) -> AppResult<()> {
|
||||
for slug in groups {
|
||||
sqlx::query(
|
||||
"INSERT OR IGNORE INTO memberships (group_slug, identity_id, granted_by, granted_at)
|
||||
SELECT slug, ?, ?, ? FROM groups WHERE slug = ?",
|
||||
)
|
||||
.bind(identity_id)
|
||||
.bind(by)
|
||||
.bind(util::now())
|
||||
.bind(slug)
|
||||
.execute(&state.db)
|
||||
.await?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[derive(Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
struct AuthConfig {
|
||||
g1: bool,
|
||||
dev: bool,
|
||||
}
|
||||
|
||||
pub async fn config(State(state): State<AppState>) -> Json<serde_json::Value> {
|
||||
Json(json!(AuthConfig { g1: state.cfg.connect.is_some(), dev: state.cfg.dev_login }))
|
||||
}
|
||||
|
||||
/// The hash of the session this request comes with, if any.
|
||||
pub(crate) fn session_hash(state: &AppState, headers: &HeaderMap) -> Option<String> {
|
||||
cookie_value(headers, session_cookie(state)).map(|token| util::sha256_hex(&token))
|
||||
}
|
||||
|
||||
/// Who is signed in. Once a day at most, the visit starts the session's days
|
||||
/// again, in the database and in the browser. A renewal that fails is logged,
|
||||
/// never answered as signed out.
|
||||
pub async fn me(State(state): State<AppState>, headers: HeaderMap, Viewer(viewer): Viewer) -> Response {
|
||||
let mut res = Json(json!({ "me": viewer })).into_response();
|
||||
if let (Some(me), Some(token)) = (&viewer, cookie_value(&headers, session_cookie(&state))) {
|
||||
match renew(&state, &token).await {
|
||||
Ok(true) => {
|
||||
res.headers_mut()
|
||||
.append(SET_COOKIE, cookie(&state, session_cookie(&state), &token, "/", SESSION_DAYS * 86_400));
|
||||
let seen = sqlx::query("UPDATE identities SET last_seen_at = ? WHERE id = ?")
|
||||
.bind(util::now())
|
||||
.bind(&me.id)
|
||||
.execute(&state.db)
|
||||
.await;
|
||||
if let Err(e) = seen {
|
||||
tracing::warn!(error = %e, "last visit not recorded");
|
||||
}
|
||||
}
|
||||
Ok(false) => {}
|
||||
Err(e) => tracing::warn!(error = %e, "session not renewed"),
|
||||
}
|
||||
}
|
||||
res
|
||||
}
|
||||
|
||||
/// Whether the session was renewed: still open, and renewed more than a day ago.
|
||||
async fn renew(state: &AppState, token: &str) -> AppResult<bool> {
|
||||
let renewed =
|
||||
sqlx::query("UPDATE sessions SET expires_at = ? WHERE token_hash = ? AND expires_at > ? AND expires_at < ?")
|
||||
.bind(util::in_days(SESSION_DAYS))
|
||||
.bind(util::sha256_hex(token))
|
||||
.bind(util::now())
|
||||
.bind(util::in_days(SESSION_DAYS - 1))
|
||||
.execute(&state.db)
|
||||
.await?;
|
||||
Ok(renewed.rows_affected() == 1)
|
||||
}
|
||||
|
||||
/// Only local paths, never `//host` nor a scheme.
|
||||
fn safe_return(raw: Option<&str>) -> String {
|
||||
match raw {
|
||||
// Browsers drop tabs and newlines in URLs: "/\t/evil" would become "//evil".
|
||||
Some(p)
|
||||
if p.starts_with('/')
|
||||
&& !p.starts_with("//")
|
||||
&& !p.contains('\\')
|
||||
&& !p.chars().any(char::is_control)
|
||||
&& p.len() < 512 =>
|
||||
{
|
||||
p.to_owned()
|
||||
}
|
||||
_ => "/".to_owned(),
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
pub struct StartQuery {
|
||||
#[serde(rename = "return")]
|
||||
return_to: Option<String>,
|
||||
}
|
||||
|
||||
fn redirect(to: &str, cookies: Vec<HeaderValue>) -> Response {
|
||||
let mut res = StatusCode::FOUND.into_response();
|
||||
if let Ok(v) = HeaderValue::from_str(to) {
|
||||
res.headers_mut().insert(LOCATION, v);
|
||||
}
|
||||
for c in cookies {
|
||||
res.headers_mut().append(SET_COOKIE, c);
|
||||
}
|
||||
res
|
||||
}
|
||||
|
||||
/// Step 1: send the browser to the approval page of connect.monnaie-libre.fr.
|
||||
pub async fn g1_start(State(state): State<AppState>, Query(q): Query<StartQuery>) -> AppResult<Response> {
|
||||
let connect = state.cfg.connect.as_ref().ok_or(AppError::Unavailable("g1_not_configured"))?;
|
||||
let csrf = util::token();
|
||||
let back = safe_return(q.return_to.as_deref());
|
||||
let mut url = url::Url::parse(&format!("{}/oauth/authorize", connect.base_url)).map_err(anyhow::Error::from)?;
|
||||
url.query_pairs_mut()
|
||||
.append_pair("response_type", "code")
|
||||
.append_pair("client_id", &connect.client_id)
|
||||
.append_pair("redirect_uri", &state.cfg.connect_callback())
|
||||
.append_pair("state", &csrf);
|
||||
let stash = format!("{csrf}.{}", url::form_urlencoded::byte_serialize(back.as_bytes()).collect::<String>());
|
||||
Ok(redirect(url.as_str(), vec![cookie(&state, oauth_cookie(&state), &stash, "/api/auth/g1", 600)]))
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
pub struct CallbackQuery {
|
||||
code: Option<String>,
|
||||
state: Option<String>,
|
||||
error: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
struct TokenResponse {
|
||||
access_token: String,
|
||||
}
|
||||
|
||||
/// What sso-connect's `/oauth/userinfo` answers — and nothing else.
|
||||
#[derive(Deserialize)]
|
||||
pub struct UserInfo {
|
||||
pub id: String,
|
||||
pub username: String,
|
||||
pub address: Option<String>,
|
||||
#[serde(default)]
|
||||
pub member: bool,
|
||||
}
|
||||
|
||||
/// Step 3: check the state, exchange the code, read the identity, open a session.
|
||||
pub async fn g1_callback(
|
||||
State(state): State<AppState>,
|
||||
headers: HeaderMap,
|
||||
Query(q): Query<CallbackQuery>,
|
||||
) -> Response {
|
||||
let clear = cookie(&state, oauth_cookie(&state), "", "/api/auth/g1", 0);
|
||||
let stash = cookie_value(&headers, oauth_cookie(&state)).unwrap_or_default();
|
||||
let (expected, back) = stash.split_once('.').unwrap_or(("", ""));
|
||||
let back =
|
||||
safe_return(url::form_urlencoded::parse(format!("p={back}").as_bytes()).next().map(|(_, v)| v).as_deref());
|
||||
let fail = |code: &str| redirect(&format!("/connexion?erreur={code}"), vec![clear.clone()]);
|
||||
if let Some(err) = q.error.as_deref() {
|
||||
tracing::warn!(error = err, "connect returned an error");
|
||||
return fail("refus");
|
||||
}
|
||||
let (Some(code), Some(got)) = (q.code, q.state) else { return fail("incomplet") };
|
||||
if expected.is_empty() || expected != got {
|
||||
return fail("etat");
|
||||
}
|
||||
match exchange(&state, &code).await {
|
||||
Ok(info) => match sign_in_g1(&state, info).await {
|
||||
Ok(session) => redirect(&back, vec![clear, session]),
|
||||
Err(e) => {
|
||||
tracing::error!(error = %e, "g1 sign-in");
|
||||
fail("interne")
|
||||
}
|
||||
},
|
||||
Err(e) => {
|
||||
tracing::warn!(error = %e, "code exchange");
|
||||
fail("echange")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async fn exchange(state: &AppState, code: &str) -> anyhow::Result<UserInfo> {
|
||||
let connect = state.cfg.connect.as_ref().ok_or_else(|| anyhow::anyhow!("not configured"))?;
|
||||
let token: TokenResponse = state
|
||||
.http
|
||||
.post(format!("{}/oauth/token", connect.base_url))
|
||||
.basic_auth(&connect.client_id, Some(&connect.client_secret))
|
||||
.form(&[("grant_type", "authorization_code"), ("code", code), ("redirect_uri", &state.cfg.connect_callback())])
|
||||
.send()
|
||||
.await?
|
||||
.error_for_status()?
|
||||
.json()
|
||||
.await?;
|
||||
let info: UserInfo = state
|
||||
.http
|
||||
.get(format!("{}/oauth/userinfo", connect.base_url))
|
||||
.bearer_auth(token.access_token)
|
||||
.send()
|
||||
.await?
|
||||
.error_for_status()?
|
||||
.json()
|
||||
.await?;
|
||||
anyhow::ensure!(info.id.starts_with("idty:"), "unexpected identity key");
|
||||
Ok(info)
|
||||
}
|
||||
|
||||
pub async fn sign_in_g1(state: &AppState, info: UserInfo) -> AppResult<HeaderValue> {
|
||||
let admin = state.cfg.admin_g1_ids.iter().any(|a| a == &info.id);
|
||||
sqlx::query(
|
||||
"INSERT INTO identities (id, kind, display_name, address, g1_member, admin, created_at)
|
||||
VALUES (?, 'g1', ?, ?, ?, ?, ?)
|
||||
ON CONFLICT(id) DO UPDATE SET display_name = excluded.display_name, address = excluded.address,
|
||||
g1_member = excluded.g1_member, admin = MAX(identities.admin, excluded.admin)",
|
||||
)
|
||||
.bind(&info.id)
|
||||
.bind(&info.username)
|
||||
.bind(&info.address)
|
||||
.bind(info.member)
|
||||
.bind(admin)
|
||||
.bind(util::now())
|
||||
.execute(&state.db)
|
||||
.await?;
|
||||
open_session(state, &info.id).await
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
pub struct InvitationBody {
|
||||
token: String,
|
||||
}
|
||||
|
||||
#[derive(sqlx::FromRow)]
|
||||
struct InvitationRow {
|
||||
token_hash: String,
|
||||
label: String,
|
||||
admin: bool,
|
||||
groups: String,
|
||||
created_by: Option<String>,
|
||||
identity_id: Option<String>,
|
||||
}
|
||||
|
||||
pub async fn accept_invitation(State(state): State<AppState>, Json(body): Json<InvitationBody>) -> AppResult<Response> {
|
||||
let hash = util::sha256_hex(body.token.trim());
|
||||
let now = util::now();
|
||||
// Reserve the link first: of two simultaneous acceptances, one only goes on.
|
||||
let reserved =
|
||||
sqlx::query("UPDATE invitations SET used_at = ? WHERE token_hash = ? AND used_at IS NULL AND expires_at > ?")
|
||||
.bind(&now)
|
||||
.bind(&hash)
|
||||
.bind(&now)
|
||||
.execute(&state.db)
|
||||
.await?;
|
||||
if reserved.rows_affected() != 1 {
|
||||
return Err(AppError::Invalid("invitation_invalid"));
|
||||
}
|
||||
let inv = sqlx::query_as::<_, InvitationRow>(
|
||||
"SELECT token_hash, label, admin, groups, created_by, identity_id FROM invitations WHERE token_hash = ?",
|
||||
)
|
||||
.bind(&hash)
|
||||
.fetch_optional(&state.db)
|
||||
.await?
|
||||
.ok_or(AppError::Invalid("invitation_invalid"))?;
|
||||
|
||||
let identity_id = match inv.identity_id.clone() {
|
||||
Some(existing) => {
|
||||
if load_identity(&state, &existing).await?.kind != "invite" {
|
||||
return Err(AppError::Invalid("invitation_invalid"));
|
||||
}
|
||||
existing
|
||||
}
|
||||
None => {
|
||||
let id = format!("inv:{}", util::id());
|
||||
sqlx::query(
|
||||
"INSERT INTO identities (id, kind, display_name, g1_member, admin, created_at) VALUES (?, 'invite', ?, 0, ?, ?)",
|
||||
)
|
||||
.bind(&id)
|
||||
.bind(&inv.label)
|
||||
.bind(inv.admin)
|
||||
.bind(util::now())
|
||||
.execute(&state.db)
|
||||
.await?;
|
||||
id
|
||||
}
|
||||
};
|
||||
if inv.admin {
|
||||
sqlx::query("UPDATE identities SET admin = 1 WHERE id = ?").bind(&identity_id).execute(&state.db).await?;
|
||||
}
|
||||
let groups: Vec<String> = serde_json::from_str(&inv.groups).unwrap_or_default();
|
||||
grant_groups(&state, &identity_id, &groups, inv.created_by.as_deref()).await?;
|
||||
sqlx::query("UPDATE invitations SET identity_id = ? WHERE token_hash = ?")
|
||||
.bind(&identity_id)
|
||||
.bind(&inv.token_hash)
|
||||
.execute(&state.db)
|
||||
.await?;
|
||||
let session = open_session(&state, &identity_id).await?;
|
||||
let me = load_identity(&state, &identity_id).await?;
|
||||
let mut res = Json(json!({ "me": me })).into_response();
|
||||
res.headers_mut().append(SET_COOKIE, session);
|
||||
Ok(res)
|
||||
}
|
||||
|
||||
/// Create an invitation; returns the raw token, shown once.
|
||||
pub async fn create_invitation(
|
||||
state: &AppState,
|
||||
label: &str,
|
||||
admin: bool,
|
||||
groups: &[String],
|
||||
created_by: Option<&str>,
|
||||
identity_id: Option<&str>,
|
||||
) -> AppResult<String> {
|
||||
let token = util::token();
|
||||
sqlx::query(
|
||||
"INSERT INTO invitations (token_hash, label, admin, groups, created_by, created_at, expires_at, identity_id)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?)",
|
||||
)
|
||||
.bind(util::sha256_hex(&token))
|
||||
.bind(label)
|
||||
.bind(admin)
|
||||
.bind(serde_json::to_string(groups).unwrap_or_else(|_| "[]".into()))
|
||||
.bind(created_by)
|
||||
.bind(util::now())
|
||||
.bind(util::in_days(INVITATION_DAYS))
|
||||
.bind(identity_id)
|
||||
.execute(&state.db)
|
||||
.await?;
|
||||
Ok(token)
|
||||
}
|
||||
|
||||
pub fn invitation_link(state: &AppState, token: &str) -> String {
|
||||
format!("{}/invitation#{token}", state.cfg.public_url)
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
pub struct DevBody {
|
||||
name: String,
|
||||
#[serde(default)]
|
||||
admin: bool,
|
||||
}
|
||||
|
||||
/// Local development: any name, member of every group. Refused unless LD_DEV_LOGIN=1.
|
||||
pub async fn dev_login(State(state): State<AppState>, Json(body): Json<DevBody>) -> AppResult<Response> {
|
||||
if !state.cfg.dev_login {
|
||||
return Err(AppError::NotFound);
|
||||
}
|
||||
let name = body.name.trim();
|
||||
if name.is_empty() || name.len() > 40 {
|
||||
return Err(AppError::Invalid("name"));
|
||||
}
|
||||
let slug: String = name.to_lowercase().chars().map(|c| if c.is_alphanumeric() { c } else { '-' }).collect();
|
||||
let id = format!("dev:{slug}");
|
||||
sqlx::query(
|
||||
"INSERT INTO identities (id, kind, display_name, g1_member, admin, created_at) VALUES (?, 'dev', ?, 0, ?, ?)
|
||||
ON CONFLICT(id) DO UPDATE SET admin = excluded.admin",
|
||||
)
|
||||
.bind(&id)
|
||||
.bind(name)
|
||||
.bind(body.admin)
|
||||
.bind(util::now())
|
||||
.execute(&state.db)
|
||||
.await?;
|
||||
let all: Vec<String> = sqlx::query_scalar("SELECT slug FROM groups").fetch_all(&state.db).await?;
|
||||
grant_groups(&state, &id, &all, Some("dev")).await?;
|
||||
let session = open_session(&state, &id).await?;
|
||||
let me = load_identity(&state, &id).await?;
|
||||
let mut res = Json(json!({ "me": me })).into_response();
|
||||
res.headers_mut().append(SET_COOKIE, session);
|
||||
Ok(res)
|
||||
}
|
||||
|
||||
pub async fn logout(State(state): State<AppState>, headers: HeaderMap) -> AppResult<Response> {
|
||||
if let Some(token) = cookie_value(&headers, session_cookie(&state)) {
|
||||
sqlx::query("DELETE FROM sessions WHERE token_hash = ?")
|
||||
.bind(util::sha256_hex(&token))
|
||||
.execute(&state.db)
|
||||
.await?;
|
||||
}
|
||||
let mut res = Json(json!({ "me": null })).into_response();
|
||||
res.headers_mut().append(SET_COOKIE, cookie(&state, session_cookie(&state), "", "/", 0));
|
||||
Ok(res)
|
||||
}
|
||||
|
||||
/// First start with LD_BOOTSTRAP_ADMIN=1 and no administrator yet: write a
|
||||
/// single-use administrator invitation to `<data>/bootstrap-admin.txt` (0600).
|
||||
/// Nothing is printed to the logs.
|
||||
pub async fn bootstrap(state: &AppState) -> anyhow::Result<()> {
|
||||
if !state.cfg.bootstrap_admin {
|
||||
return Ok(());
|
||||
}
|
||||
let admins: i64 =
|
||||
sqlx::query_scalar("SELECT COUNT(*) FROM identities WHERE admin = 1").fetch_one(&state.db).await?;
|
||||
let pending: i64 =
|
||||
sqlx::query_scalar("SELECT COUNT(*) FROM invitations WHERE admin = 1 AND used_at IS NULL AND expires_at > ?")
|
||||
.bind(util::now())
|
||||
.fetch_one(&state.db)
|
||||
.await?;
|
||||
if admins > 0 || pending > 0 {
|
||||
return Ok(());
|
||||
}
|
||||
let groups: Vec<String> = sqlx::query_scalar("SELECT slug FROM groups").fetch_all(&state.db).await?;
|
||||
let token = create_invitation(state, "Yvv", true, &groups, None, None)
|
||||
.await
|
||||
.map_err(|e| anyhow::anyhow!("bootstrap invitation: {e}"))?;
|
||||
let path = state.cfg.data_dir.join("bootstrap-admin.txt");
|
||||
write_private(&path, &format!("{}\n", invitation_link(state, &token)))?;
|
||||
tracing::info!(path = %path.display(), "administrator invitation written");
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn write_private(path: &std::path::Path, content: &str) -> std::io::Result<()> {
|
||||
use std::io::Write;
|
||||
#[cfg(unix)]
|
||||
{
|
||||
use std::os::unix::fs::OpenOptionsExt;
|
||||
let mut f = std::fs::OpenOptions::new().write(true).create(true).truncate(true).mode(0o600).open(path)?;
|
||||
f.write_all(content.as_bytes())
|
||||
}
|
||||
#[cfg(not(unix))]
|
||||
{
|
||||
std::fs::write(path, content)
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn return_paths_stay_local() {
|
||||
assert_eq!(safe_return(Some("/g/monnaie-libre")), "/g/monnaie-libre");
|
||||
assert_eq!(safe_return(Some("//evil.example")), "/");
|
||||
assert_eq!(safe_return(Some("https://evil.example")), "/");
|
||||
assert_eq!(safe_return(Some("/\\evil")), "/");
|
||||
assert_eq!(safe_return(Some("/\t/evil.example")), "/");
|
||||
assert_eq!(safe_return(Some("/\n/evil.example")), "/");
|
||||
assert_eq!(safe_return(None), "/");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn a_session_over_is_never_renewed() {
|
||||
let db = crate::open_db("sqlite::memory:").await.expect("db");
|
||||
let state = AppState::new(crate::Config::for_tests(), db).await.expect("state");
|
||||
sqlx::query("INSERT INTO identities (id, kind, display_name, g1_member, admin, created_at) VALUES ('dev:a', 'dev', 'A', 0, 0, ?)")
|
||||
.bind(util::now())
|
||||
.execute(&state.db)
|
||||
.await
|
||||
.expect("identity");
|
||||
for (token, expires) in [("over", util::in_seconds(-1)), ("open", util::in_days(10))] {
|
||||
sqlx::query(
|
||||
"INSERT INTO sessions (token_hash, identity_id, created_at, expires_at) VALUES (?, 'dev:a', ?, ?)",
|
||||
)
|
||||
.bind(util::sha256_hex(token))
|
||||
.bind(util::now())
|
||||
.bind(expires)
|
||||
.execute(&state.db)
|
||||
.await
|
||||
.expect("session");
|
||||
}
|
||||
assert!(!renew(&state, "over").await.expect("renew"));
|
||||
assert!(renew(&state, "open").await.expect("renew"));
|
||||
assert!(!renew(&state, "open").await.expect("renew"), "once a day at most");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cookie_parsing() {
|
||||
let mut h = HeaderMap::new();
|
||||
h.insert(COOKIE, HeaderValue::from_static("a=1; ld_session=abc; b=2"));
|
||||
assert_eq!(cookie_value(&h, SESSION_COOKIE).as_deref(), Some("abc"));
|
||||
assert_eq!(cookie_value(&h, "zz"), None);
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,57 @@
|
||||
//! The clock of the deadlines (objections, typo fixes): the time of the
|
||||
//! machine, plus an offset that only tests move — the API tests directly, the
|
||||
//! end-to-end tests through `POST /api/dev/clock`, open with the dev login only.
|
||||
//! A deadline is never waited for with a sleep.
|
||||
|
||||
use crate::util;
|
||||
use std::sync::Arc;
|
||||
use std::sync::atomic::{AtomicI64, Ordering};
|
||||
use time::{Duration, OffsetDateTime};
|
||||
|
||||
#[derive(Clone, Debug, Default)]
|
||||
pub struct Clock {
|
||||
offset_secs: Arc<AtomicI64>,
|
||||
}
|
||||
|
||||
impl Clock {
|
||||
pub fn at(&self) -> OffsetDateTime {
|
||||
OffsetDateTime::now_utc() + Duration::seconds(self.offset_secs.load(Ordering::Relaxed))
|
||||
}
|
||||
|
||||
/// Now, as a sortable stamp (RFC 3339 UTC, whole seconds).
|
||||
pub fn now(&self) -> String {
|
||||
util::stamp(self.at())
|
||||
}
|
||||
|
||||
pub fn in_days(&self, days: i64) -> String {
|
||||
util::stamp(self.at() + Duration::days(days))
|
||||
}
|
||||
|
||||
/// Moves the clock forward (tests only).
|
||||
pub fn advance(&self, secs: i64) {
|
||||
self.offset_secs.fetch_add(secs, Ordering::Relaxed);
|
||||
}
|
||||
|
||||
pub fn offset(&self) -> i64 {
|
||||
self.offset_secs.load(Ordering::Relaxed)
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn advancing_moves_now_and_the_deadlines() {
|
||||
let c = Clock::default();
|
||||
let before = c.now();
|
||||
c.advance(3 * 86_400);
|
||||
assert!(c.now() > before);
|
||||
assert!(c.in_days(0) >= c.now());
|
||||
assert_eq!(c.offset(), 3 * 86_400);
|
||||
// Clones share the offset: the server and its tasks read one clock.
|
||||
let other = c.clone();
|
||||
other.advance(1);
|
||||
assert_eq!(c.offset(), 3 * 86_400 + 1);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,207 @@
|
||||
//! Configuration, from the environment only. Secrets never have a default.
|
||||
|
||||
use std::path::PathBuf;
|
||||
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct Connect {
|
||||
/// Base URL of the sso-connect instance (no trailing slash).
|
||||
pub base_url: String,
|
||||
pub client_id: String,
|
||||
pub client_secret: String,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct Publish {
|
||||
/// Remote of the texts repository, e.g. `gitea@git.open.us.org:yvv/decision-depot-textes-reference.git`.
|
||||
pub remote: String,
|
||||
pub branch: String,
|
||||
/// Private SSH key allowed to push there (deploy key).
|
||||
pub key: PathBuf,
|
||||
pub author_name: String,
|
||||
pub author_email: String,
|
||||
/// Publish a document even when none of its articles is in force
|
||||
/// (`LD_PUBLISH_WITHOUT_ARTICLES`). Off by default — Yvv, 2026-11-02: « Suspendre
|
||||
/// jusqu'au 1er texte »: the queue waits, and the first text that enters sends it
|
||||
/// (ADR 0011).
|
||||
pub without_articles: bool,
|
||||
/// Publish a document even while a text, an order, a title or a level of inertia
|
||||
/// in effect entered by simulated votes does not hold on the proven votes alone
|
||||
/// (`LD_PUBLISH_SIMULATED`). Off — Yvv, 2026-11-03: « Pas avant des votes
|
||||
/// prouvés »: in force on the site, never published before the proven votes carry
|
||||
/// it (ADR 0012, `publish::waits_for_proven_votes`).
|
||||
pub simulated: bool,
|
||||
}
|
||||
|
||||
/// The vote on the Ğ1 chain (ADR 0008): where the 0.1 Ğ1 of each vote go, how
|
||||
/// much, and the nodes the page sends the transaction to. Without
|
||||
/// `LD_VOTE_ACCOUNT`, voting through the chain is off, and the interface says so.
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct VoteChain {
|
||||
/// The vote account, as a Ğ1 address (`g1…`), whatever form it was given in.
|
||||
pub account: String,
|
||||
pub account_key: [u8; 32],
|
||||
/// Cents of Ğ1 per vote (`LD_VOTE_AMOUNT`, 10 = 0.1 Ğ1 by default).
|
||||
pub amount: u64,
|
||||
/// WebSocket endpoints of Ğ1 nodes, for the page (`LD_VOTE_NODES`).
|
||||
pub nodes: Vec<String>,
|
||||
/// Tests only, with the dev login: the page sends to this fake node instead
|
||||
/// of building the transaction with polkadot-api (`LD_VOTE_TEST_NODE`).
|
||||
pub test_node: Option<String>,
|
||||
}
|
||||
|
||||
impl VoteChain {
|
||||
pub const DEFAULT_AMOUNT: u64 = 10;
|
||||
|
||||
/// From the account in any SS58 form; `None` if it is not an address.
|
||||
pub fn new(account: &str, amount: u64, nodes: Vec<String>, test_node: Option<String>) -> Option<Self> {
|
||||
let (_, key) = ld_engine::address::decode(account.trim())?;
|
||||
Some(Self {
|
||||
account: ld_engine::address::encode(ld_engine::address::G1_PREFIX, &key),
|
||||
account_key: key,
|
||||
amount: amount.max(1),
|
||||
nodes,
|
||||
test_node,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct Config {
|
||||
pub bind: String,
|
||||
pub data_dir: PathBuf,
|
||||
pub web_dir: PathBuf,
|
||||
pub seed_dir: PathBuf,
|
||||
/// Public origin, e.g. `https://decision.librodrome.org` — callbacks and cookies.
|
||||
pub public_url: String,
|
||||
pub connect: Option<Connect>,
|
||||
pub admin_g1_ids: Vec<String>,
|
||||
pub dev_login: bool,
|
||||
pub bootstrap_admin: bool,
|
||||
pub publish: Option<Publish>,
|
||||
pub rpc_endpoints: Vec<String>,
|
||||
pub indexers: Vec<String>,
|
||||
pub wot_fallback: u32,
|
||||
pub build: String,
|
||||
/// The vote on the chain; `None` = off (no `LD_VOTE_ACCOUNT`).
|
||||
pub vote: Option<VoteChain>,
|
||||
/// Whether a simulated vote counts for adoption (ADR 0012):
|
||||
/// `ld_engine::buffer::SIMULATED_ADOPTS` — Yvv, 2026-11-02: « Il compte, puis
|
||||
/// s'efface »: it counts until the act « supprime » sets it aside.
|
||||
pub simulated_adopts: bool,
|
||||
}
|
||||
|
||||
fn var(name: &str) -> Option<String> {
|
||||
std::env::var(name).ok().map(|v| v.trim().to_owned()).filter(|v| !v.is_empty())
|
||||
}
|
||||
|
||||
fn flag(name: &str) -> bool {
|
||||
var(name).is_some_and(|v| matches!(v.as_str(), "1" | "true" | "yes" | "on"))
|
||||
}
|
||||
|
||||
fn list(name: &str, default: &[&str]) -> Vec<String> {
|
||||
match var(name) {
|
||||
Some(v) => v.split(',').map(|s| s.trim().to_owned()).filter(|s| !s.is_empty()).collect(),
|
||||
None => default.iter().map(|s| (*s).to_owned()).collect(),
|
||||
}
|
||||
}
|
||||
|
||||
impl Config {
|
||||
pub fn from_env() -> Self {
|
||||
let data_dir = PathBuf::from(var("LD_DATA_DIR").unwrap_or_else(|| "data".into()));
|
||||
let connect = match (var("LD_CONNECT_CLIENT_ID"), var("LD_CONNECT_CLIENT_SECRET")) {
|
||||
(Some(client_id), Some(client_secret)) => Some(Connect {
|
||||
base_url: var("LD_CONNECT_URL")
|
||||
.unwrap_or_else(|| "https://connect.monnaie-libre.fr".into())
|
||||
.trim_end_matches('/')
|
||||
.to_owned(),
|
||||
client_id,
|
||||
client_secret,
|
||||
}),
|
||||
_ => None,
|
||||
};
|
||||
let publish = var("LD_PUBLISH_REMOTE").map(|remote| Publish {
|
||||
remote,
|
||||
branch: var("LD_PUBLISH_BRANCH").unwrap_or_else(|| "main".into()),
|
||||
key: var("LD_PUBLISH_KEY").map(PathBuf::from).unwrap_or_else(|| data_dir.join("keys/publish_ed25519")),
|
||||
author_name: var("LD_PUBLISH_AUTHOR").unwrap_or_else(|| "libreDecision".into()),
|
||||
author_email: var("LD_PUBLISH_EMAIL").unwrap_or_else(|| "decision@librodrome.org".into()),
|
||||
without_articles: flag("LD_PUBLISH_WITHOUT_ARTICLES"),
|
||||
simulated: flag("LD_PUBLISH_SIMULATED"),
|
||||
});
|
||||
let dev_login = flag("LD_DEV_LOGIN");
|
||||
let vote = var("LD_VOTE_ACCOUNT").and_then(|account| {
|
||||
let amount = var("LD_VOTE_AMOUNT").and_then(|v| v.parse().ok()).unwrap_or(VoteChain::DEFAULT_AMOUNT);
|
||||
let nodes = list("LD_VOTE_NODES", &["wss://rpc.duniter.org", "wss://g1.axiom-team.fr/ws"]);
|
||||
// A fake node for the page, never outside the local dev login.
|
||||
let test_node = var("LD_VOTE_TEST_NODE").filter(|_| dev_login);
|
||||
let chain = VoteChain::new(&account, amount, nodes, test_node);
|
||||
if chain.is_none() {
|
||||
tracing::error!("LD_VOTE_ACCOUNT is not a Ğ1 address: the vote through the chain stays off");
|
||||
}
|
||||
chain
|
||||
});
|
||||
Self {
|
||||
bind: var("LD_BIND").unwrap_or_else(|| "0.0.0.0:8002".into()),
|
||||
web_dir: PathBuf::from(var("LD_WEB_DIR").unwrap_or_else(|| "web/build".into())),
|
||||
seed_dir: PathBuf::from(var("LD_SEED_DIR").unwrap_or_else(|| "seeds".into())),
|
||||
public_url: var("LD_PUBLIC_URL")
|
||||
.unwrap_or_else(|| "http://127.0.0.1:3002".into())
|
||||
.trim_end_matches('/')
|
||||
.to_owned(),
|
||||
connect,
|
||||
admin_g1_ids: list("LD_ADMIN_G1_IDS", &[]),
|
||||
dev_login,
|
||||
bootstrap_admin: flag("LD_BOOTSTRAP_ADMIN"),
|
||||
publish,
|
||||
rpc_endpoints: list(
|
||||
"LD_RPC_ENDPOINTS",
|
||||
&[
|
||||
"https://rpc.duniter.org",
|
||||
"https://g1.axiom-team.fr/ws/",
|
||||
"https://g1.asycn.io/ws/",
|
||||
"https://g1.coinduf.eu",
|
||||
],
|
||||
),
|
||||
indexers: list(
|
||||
"LD_INDEXERS",
|
||||
&["https://indexer.duniter.org/v1/graphql", "https://g1-squid.axiom-team.fr/v1/graphql"],
|
||||
),
|
||||
wot_fallback: var("LD_WOT_FALLBACK").and_then(|v| v.parse().ok()).unwrap_or(5745),
|
||||
build: var("LD_BUILD").unwrap_or_else(|| "dev".into()),
|
||||
vote,
|
||||
simulated_adopts: ld_engine::buffer::SIMULATED_ADOPTS,
|
||||
data_dir,
|
||||
}
|
||||
}
|
||||
|
||||
/// Cookies are `Secure` as soon as the public origin is https.
|
||||
pub fn secure_cookies(&self) -> bool {
|
||||
self.public_url.starts_with("https://")
|
||||
}
|
||||
|
||||
pub fn connect_callback(&self) -> String {
|
||||
format!("{}/api/auth/g1/callback", self.public_url)
|
||||
}
|
||||
|
||||
/// For tests: an in-memory configuration with dev login.
|
||||
pub fn for_tests() -> Self {
|
||||
Self {
|
||||
bind: "127.0.0.1:0".into(),
|
||||
data_dir: std::env::temp_dir().join("ld-tests"),
|
||||
web_dir: PathBuf::from("web/build"),
|
||||
seed_dir: PathBuf::from(concat!(env!("CARGO_MANIFEST_DIR"), "/../../seeds")),
|
||||
public_url: "http://127.0.0.1:3002".into(),
|
||||
connect: None,
|
||||
admin_g1_ids: vec!["idty:8352".into()],
|
||||
dev_login: true,
|
||||
bootstrap_admin: false,
|
||||
publish: None,
|
||||
rpc_endpoints: vec![],
|
||||
indexers: vec![],
|
||||
wot_fallback: 5745,
|
||||
build: "test".into(),
|
||||
vote: None,
|
||||
simulated_adopts: ld_engine::buffer::SIMULATED_ADOPTS,
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,57 @@
|
||||
//! The deadlines of three days (2026-10-31, lot F): an objection whose author
|
||||
//! answered and whose objector said nothing is lifted; a typo fix nobody
|
||||
//! answered applies. Run by the adoption sweeper (every 5 minutes) and once at
|
||||
//! start, so a deadline passed while the server was stopped is dealt with as
|
||||
//! soon as it starts again. Tests move the clock (`AppState::clock`), never sleep.
|
||||
|
||||
use crate::AppState;
|
||||
use crate::adoption;
|
||||
use crate::error::{AppError, AppResult};
|
||||
use crate::{objections, typos};
|
||||
use axum::Json;
|
||||
use axum::extract::State;
|
||||
use serde::Deserialize;
|
||||
use serde_json::{Value, json};
|
||||
|
||||
#[derive(Debug, Default, Clone, Copy, PartialEq, Eq)]
|
||||
pub struct Swept {
|
||||
/// Objections lifted at their deadline.
|
||||
pub lifted: usize,
|
||||
/// Typo fixes applied at their deadline.
|
||||
pub applied: usize,
|
||||
/// Typo fixes that could not apply: their text was gone.
|
||||
pub stale: usize,
|
||||
}
|
||||
|
||||
/// One pass: the objections, then the typo fixes, then the adoption of the
|
||||
/// documents where an objection was lifted (a version may now enter into force).
|
||||
pub async fn sweep(state: &AppState) -> AppResult<Swept> {
|
||||
let (lifted, docs) = objections::due(state).await?;
|
||||
let fixes = typos::due(state).await?;
|
||||
for doc in &docs {
|
||||
adoption::run(state, doc).await?;
|
||||
}
|
||||
Ok(Swept { lifted, applied: fixes.applied, stale: fixes.stale })
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
pub struct ClockBody {
|
||||
seconds: i64,
|
||||
}
|
||||
|
||||
/// `POST /api/dev/clock` — the end-to-end tests move the server's clock forward
|
||||
/// and pass the deadlines at once. Open with the dev login only, like it.
|
||||
pub async fn dev_clock(State(state): State<AppState>, Json(b): Json<ClockBody>) -> AppResult<Json<Value>> {
|
||||
if !state.cfg.dev_login {
|
||||
return Err(AppError::NotFound);
|
||||
}
|
||||
if b.seconds <= 0 || b.seconds > 400 * 86_400 {
|
||||
return Err(AppError::Invalid("seconds"));
|
||||
}
|
||||
state.clock.advance(b.seconds);
|
||||
let swept = sweep(&state).await?;
|
||||
Ok(Json(json!({
|
||||
"now": state.clock.now(), "offset": state.clock.offset(),
|
||||
"lifted": swept.lifted, "applied": swept.applied, "stale": swept.stale,
|
||||
})))
|
||||
}
|
||||
@@ -0,0 +1,171 @@
|
||||
//! The title of a document, a module (ADR 0009, level 4).
|
||||
//!
|
||||
//! Its version in force is the title in the source language, the one that
|
||||
//! holds. `documents.title` keeps the title as every list and the publication
|
||||
//! read it: the source language from the version in force, the other languages
|
||||
//! from its editorial translations (`sync`).
|
||||
//!
|
||||
//! When another title enters into force, the document changes title in the
|
||||
//! source language. Its translations stay editorial (Yvv, 2026-10-21: the
|
||||
//! French is voted, the translations are not): the new version takes those of
|
||||
//! the title it replaces, pinned to that title's text, so they read « à
|
||||
//! revoir » until a member translates the new one.
|
||||
|
||||
use crate::AppState;
|
||||
use crate::auth::User;
|
||||
use crate::docs::{self, ProposalRow, UnitRow};
|
||||
use crate::error::{AppError, AppResult};
|
||||
use crate::modules::{self, ModuleKind, NewVersion};
|
||||
use crate::util::{self, LangMap};
|
||||
use axum::Json;
|
||||
use axum::extract::{Path, State};
|
||||
use ld_engine::edit;
|
||||
use serde::Deserialize;
|
||||
use serde_json::{Value, json};
|
||||
use sqlx::SqliteConnection;
|
||||
|
||||
/// The longest title, as for an article.
|
||||
const MAX_TITLE: usize = 120;
|
||||
|
||||
/// The translations of the title a document starts with, from its seed or its row.
|
||||
pub async fn seed_translations(
|
||||
conn: &mut SqliteConnection,
|
||||
proposal_id: &str,
|
||||
title: &str,
|
||||
titles: &LangMap,
|
||||
src: &str,
|
||||
now: &str,
|
||||
) -> sqlx::Result<()> {
|
||||
let hash = util::source_hash(title, "");
|
||||
for lang in util::LANGS.iter().filter(|l| **l != src) {
|
||||
let Some(t) = titles.get(*lang).filter(|t| !t.trim().is_empty()) else { continue };
|
||||
sqlx::query(
|
||||
"INSERT INTO translations (proposal_id, lang, title, body, source_hash, origin, updated_by, updated_at)
|
||||
VALUES (?, ?, ?, '', ?, 'seed', NULL, ?)",
|
||||
)
|
||||
.bind(proposal_id)
|
||||
.bind(lang)
|
||||
.bind(t)
|
||||
.bind(&hash)
|
||||
.bind(now)
|
||||
.execute(&mut *conn)
|
||||
.await?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// `documents.title` from the title in force and its translations; a language
|
||||
/// the version in force does not translate keeps the title it had.
|
||||
pub async fn sync_conn(conn: &mut SqliteConnection, doc_id: &str) -> sqlx::Result<()> {
|
||||
let row: Option<(String, String, String, String)> = sqlx::query_as(
|
||||
"SELECT p.id, p.title, d.title, d.source_lang FROM units u
|
||||
JOIN proposals p ON p.id = u.in_force_id JOIN documents d ON d.id = u.doc_id
|
||||
WHERE u.doc_id = ? AND u.kind = 'title'",
|
||||
)
|
||||
.bind(doc_id)
|
||||
.fetch_optional(&mut *conn)
|
||||
.await?;
|
||||
let Some((pid, title, raw, src)) = row else { return Ok(()) };
|
||||
let mut titles = util::parse_lang(&raw);
|
||||
titles.insert(src, title);
|
||||
let translated: Vec<(String, String)> =
|
||||
sqlx::query_as("SELECT lang, title FROM translations WHERE proposal_id = ?")
|
||||
.bind(&pid)
|
||||
.fetch_all(&mut *conn)
|
||||
.await?;
|
||||
for (lang, t) in translated {
|
||||
titles.insert(lang, t);
|
||||
}
|
||||
let next = util::lang_json(&titles);
|
||||
if next != raw {
|
||||
sqlx::query("UPDATE documents SET title = ? WHERE id = ?").bind(&next).bind(doc_id).execute(&mut *conn).await?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// After any change to a text in force (a typo, a translation, an entry): the
|
||||
/// document's title follows its title in force.
|
||||
pub async fn sync(state: &AppState, doc_id: &str) -> sqlx::Result<()> {
|
||||
let mut conn = state.db.acquire().await?;
|
||||
sync_conn(&mut conn, doc_id).await
|
||||
}
|
||||
|
||||
/// Another title enters into force: it takes the translations of the one it
|
||||
/// replaces where it has none — « à revoir », pinned to the former title — and
|
||||
/// becomes the document's title.
|
||||
pub async fn enter(conn: &mut SqliteConnection, doc_id: &str, unit: &UnitRow, p: &ProposalRow) -> AppResult<()> {
|
||||
if let Some(before) = unit.in_force_id.as_deref().filter(|b| *b != p.id) {
|
||||
sqlx::query(
|
||||
"INSERT INTO translations (proposal_id, lang, title, body, source_hash, origin, updated_by, updated_at)
|
||||
SELECT ?1, t.lang, t.title, t.body, t.source_hash, t.origin, t.updated_by, t.updated_at
|
||||
FROM translations t
|
||||
WHERE t.proposal_id = ?2
|
||||
AND NOT EXISTS (SELECT 1 FROM translations n WHERE n.proposal_id = ?1 AND n.lang = t.lang)",
|
||||
)
|
||||
.bind(&p.id)
|
||||
.bind(before)
|
||||
.execute(&mut *conn)
|
||||
.await?;
|
||||
}
|
||||
sync_conn(conn, doc_id).await?;
|
||||
tracing::info!(doc = %doc_id, proposal = %p.id, "title of the document in force");
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
pub struct TitleBody {
|
||||
title: String,
|
||||
}
|
||||
|
||||
/// Propose another title for the document. An open proposal of the same title
|
||||
/// is given back rather than doubled.
|
||||
pub async fn propose(
|
||||
State(state): State<AppState>,
|
||||
User(user): User,
|
||||
Path(unit_id): Path<String>,
|
||||
Json(b): Json<TitleBody>,
|
||||
) -> AppResult<Json<Value>> {
|
||||
let doc_id: String = sqlx::query_scalar("SELECT doc_id FROM units WHERE id = ?")
|
||||
.bind(&unit_id)
|
||||
.fetch_optional(&state.db)
|
||||
.await?
|
||||
.ok_or(AppError::NotFound)?;
|
||||
let st = docs::load_state(&state, &doc_id).await?;
|
||||
docs::require_open(&state, &user, &st.group).await?;
|
||||
let unit = st.units.iter().find(|u| u.id == unit_id).ok_or(AppError::NotFound)?;
|
||||
if st.module_of(&unit.id) != Some(ModuleKind::Title) {
|
||||
return Err(AppError::Invalid("not_a_title"));
|
||||
}
|
||||
if edit::has_hidden_controls(&b.title) {
|
||||
return Err(AppError::Invalid("hidden_characters"));
|
||||
}
|
||||
let title = b.title.trim();
|
||||
if title.is_empty() || title.chars().count() > MAX_TITLE || title.contains(['\n', '\r']) {
|
||||
return Err(AppError::Invalid("title"));
|
||||
}
|
||||
let in_force = unit.in_force_id.as_deref().and_then(|id| st.proposals.iter().find(|p| p.id == id));
|
||||
if in_force.is_some_and(|p| p.title == title) {
|
||||
return Err(AppError::Invalid("same_title"));
|
||||
}
|
||||
if let Some(existing) = st.proposals.iter().find(|p| {
|
||||
p.unit_id == unit.id && p.status == "open" && p.kind == ModuleKind::Title.as_str() && p.title == title
|
||||
}) {
|
||||
return Ok(Json(json!({ "id": existing.id, "existing": true })));
|
||||
}
|
||||
let now = util::now();
|
||||
let mut conn = state.db.acquire().await?;
|
||||
let id = modules::insert_version(
|
||||
&mut conn,
|
||||
NewVersion {
|
||||
unit_id: &unit.id,
|
||||
kind: ModuleKind::Title,
|
||||
title,
|
||||
body: "",
|
||||
author: Some(&user.id),
|
||||
in_force: false,
|
||||
now: &now,
|
||||
},
|
||||
)
|
||||
.await?;
|
||||
Ok(Json(json!({ "id": id, "existing": false })))
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,50 @@
|
||||
//! API errors: a stable machine code the interface translates, never a stack.
|
||||
|
||||
use axum::Json;
|
||||
use axum::http::StatusCode;
|
||||
use axum::response::{IntoResponse, Response};
|
||||
use serde_json::json;
|
||||
|
||||
#[derive(Debug, thiserror::Error)]
|
||||
pub enum AppError {
|
||||
#[error("unauthenticated")]
|
||||
Unauthenticated,
|
||||
#[error("forbidden")]
|
||||
Forbidden,
|
||||
#[error("not found")]
|
||||
NotFound,
|
||||
#[error("invalid: {0}")]
|
||||
Invalid(&'static str),
|
||||
#[error("conflict: {0}")]
|
||||
Conflict(&'static str),
|
||||
#[error("unavailable: {0}")]
|
||||
Unavailable(&'static str),
|
||||
#[error(transparent)]
|
||||
Db(#[from] sqlx::Error),
|
||||
#[error(transparent)]
|
||||
Other(#[from] anyhow::Error),
|
||||
}
|
||||
|
||||
pub type AppResult<T> = Result<T, AppError>;
|
||||
|
||||
impl IntoResponse for AppError {
|
||||
fn into_response(self) -> Response {
|
||||
let (status, code) = match &self {
|
||||
Self::Unauthenticated => (StatusCode::UNAUTHORIZED, "unauthenticated"),
|
||||
Self::Forbidden => (StatusCode::FORBIDDEN, "forbidden"),
|
||||
Self::NotFound => (StatusCode::NOT_FOUND, "not_found"),
|
||||
Self::Invalid(code) => (StatusCode::BAD_REQUEST, *code),
|
||||
Self::Conflict(code) => (StatusCode::CONFLICT, *code),
|
||||
Self::Unavailable(code) => (StatusCode::SERVICE_UNAVAILABLE, *code),
|
||||
Self::Db(e) => {
|
||||
tracing::error!(error = %e, "database");
|
||||
(StatusCode::INTERNAL_SERVER_ERROR, "internal")
|
||||
}
|
||||
Self::Other(e) => {
|
||||
tracing::error!(error = %e, "internal");
|
||||
(StatusCode::INTERNAL_SERVER_ERROR, "internal")
|
||||
}
|
||||
};
|
||||
(status, Json(json!({ "error": code }))).into_response()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,384 @@
|
||||
//! Reading the Ğ1 indexer (duniter-squid, GraphQL): the comments that carry a
|
||||
//! given remark, with their author, the author's identity and the transfers of
|
||||
//! the same transaction.
|
||||
//!
|
||||
//! A comment is found by `hash`, the blake2b-256 of the remark's bytes that the
|
||||
//! chain publishes in its `System.Remarked` event. The indexers of
|
||||
//! `LD_INDEXERS` are asked in turn: one that does not answer, or answers with
|
||||
//! errors, is skipped; one that answers « nothing » may be behind, so the next
|
||||
//! is asked too. Nobody answering is not a « no »: the ballot stays pending.
|
||||
//!
|
||||
//! **Finality.** The indexer serves the best chain, blocks not yet final
|
||||
//! included. `squidStatus` exposes `height` and `hash` only (no
|
||||
//! `finalizedHeight`); `height` is the last final block it processed — relevé
|
||||
//! on indexer.duniter.org on 2026-10-31: always 3 blocks behind the newest block
|
||||
//! it serves (`blocks(orderBy: HEIGHT_DESC, first: 1)`), the 2 to 3 blocks of the
|
||||
//! finality of Duniter v2. Only a comment at or below it settles a ballot: a
|
||||
//! comment above may still vanish with its block.
|
||||
//!
|
||||
//! **What is asked.** Only what may settle the ballot: for a ballot bound to an
|
||||
//! account, that account's comments; for one bound to none (a Ğecko code), the
|
||||
//! comments paid to the vote account — a bare copy of a remark costs nothing to
|
||||
//! send, and must never hide the real one. The engine judges again what comes
|
||||
//! back (`ld_engine::ballot::judge`): an indexer that ignores the filter changes
|
||||
//! nothing but the number of pages.
|
||||
//!
|
||||
//! **Pages.** In the order of the chain, 50 comments a page, page after page
|
||||
//! until a comment settles the ballot, a block not yet final is reached, or the
|
||||
//! last page — at most 20 pages a reading.
|
||||
|
||||
use crate::AppState;
|
||||
use ld_engine::address;
|
||||
use ld_engine::ballot::{ChainIdentity, ChainPosition, Comment, Transfer};
|
||||
use serde::Deserialize;
|
||||
use serde_json::json;
|
||||
use std::time::Duration;
|
||||
|
||||
/// Validated on the indexer of the Ğ1 chain (indexer.duniter.org), 2026-10-31.
|
||||
pub const BALLOT_QUERY: &str = "query Ballot($hash: String!, $filter: TxCommentFilter, $after: Cursor) { \
|
||||
squidStatus { height } txComments(condition: {hash: $hash}, filter: $filter, \
|
||||
orderBy: [BLOCK_NUMBER_ASC, ID_ASC], first: 50, after: $after) { pageInfo { hasNextPage endCursor } \
|
||||
nodes { id blockNumber remark author { id identity { index name isMember } } \
|
||||
transfersByCommentId { nodes { fromId toId amount } } } } }";
|
||||
|
||||
/// Pages read at most for one ballot, one reading.
|
||||
pub const MAX_PAGES: usize = 20;
|
||||
/// The largest answer read: a page of 50 comments weighs about 30 KB.
|
||||
const MAX_BODY: usize = 1 << 20;
|
||||
/// A Ğ1 identity name is 42 characters at most.
|
||||
const MAX_NAME: usize = 64;
|
||||
const TIMEOUT: Duration = Duration::from_secs(6);
|
||||
|
||||
#[derive(Deserialize)]
|
||||
struct Answer {
|
||||
data: Option<Data>,
|
||||
#[serde(default)]
|
||||
errors: Option<serde_json::Value>,
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
struct Data {
|
||||
squid_status: Status,
|
||||
tx_comments: Connection,
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
struct Status {
|
||||
height: u32,
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
struct Connection {
|
||||
#[serde(default)]
|
||||
page_info: Option<PageInfo>,
|
||||
nodes: Vec<Node>,
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
struct PageInfo {
|
||||
has_next_page: bool,
|
||||
end_cursor: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
struct Nodes<T> {
|
||||
nodes: Vec<T>,
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
struct Node {
|
||||
id: String,
|
||||
block_number: u32,
|
||||
remark: String,
|
||||
author: Author,
|
||||
transfers_by_comment_id: Nodes<TransferNode>,
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
struct Author {
|
||||
id: String,
|
||||
identity: Option<IdentityNode>,
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
struct IdentityNode {
|
||||
index: u32,
|
||||
name: String,
|
||||
is_member: bool,
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
struct TransferNode {
|
||||
from_id: String,
|
||||
to_id: String,
|
||||
/// A big integer, served as a string.
|
||||
amount: serde_json::Value,
|
||||
}
|
||||
|
||||
/// A comment of the chain, as the engine judges it, with what the server keeps.
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct Found {
|
||||
pub comment: Comment,
|
||||
pub remark: String,
|
||||
pub event_id: String,
|
||||
/// The account that signed, as a Ğ1 address.
|
||||
pub signer: String,
|
||||
/// The name of the signer's identity, if any.
|
||||
pub name: Option<String>,
|
||||
}
|
||||
|
||||
/// One page of an answer.
|
||||
#[derive(Debug)]
|
||||
pub struct Page {
|
||||
/// The last final block (`squidStatus.height`).
|
||||
pub finalized: u32,
|
||||
pub comments: Vec<Found>,
|
||||
/// The cursor of the next page, if there is one.
|
||||
pub next: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
pub enum Reading {
|
||||
/// No indexer answered.
|
||||
Silent,
|
||||
/// What an indexer answered: the comments in final blocks, in the order of the
|
||||
/// chain (possibly none), and whether others wait in blocks not yet final.
|
||||
Found { comments: Vec<Found>, unfinal: bool },
|
||||
}
|
||||
|
||||
fn amount(v: &serde_json::Value) -> Option<u64> {
|
||||
match v {
|
||||
serde_json::Value::String(s) => s.parse().ok(),
|
||||
serde_json::Value::Number(n) => n.as_u64(),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
fn found(node: Node) -> Option<Found> {
|
||||
let position = ChainPosition::from_event_id(&node.id)?;
|
||||
if position.block != node.block_number {
|
||||
return None;
|
||||
}
|
||||
if node.author.identity.as_ref().is_some_and(|i| i.name.len() > MAX_NAME) {
|
||||
return None;
|
||||
}
|
||||
let (_, author) = address::decode(&node.author.id)?;
|
||||
let transfers = node
|
||||
.transfers_by_comment_id
|
||||
.nodes
|
||||
.iter()
|
||||
.map(|t| {
|
||||
Some(Transfer {
|
||||
from: address::decode(&t.from_id)?.1,
|
||||
to: address::decode(&t.to_id)?.1,
|
||||
amount: amount(&t.amount)?,
|
||||
})
|
||||
})
|
||||
.collect::<Option<Vec<_>>>()?;
|
||||
Some(Found {
|
||||
comment: Comment {
|
||||
position,
|
||||
author,
|
||||
identity: node.author.identity.as_ref().map(|i| ChainIdentity { index: i.index, member: i.is_member }),
|
||||
transfers,
|
||||
},
|
||||
remark: node.remark,
|
||||
event_id: node.id,
|
||||
signer: address::encode(address::G1_PREFIX, &author),
|
||||
name: node.author.identity.map(|i| i.name),
|
||||
})
|
||||
}
|
||||
|
||||
/// One page of an answer of the indexer; `None` if it is not one.
|
||||
pub fn parse(body: &serde_json::Value) -> Option<Page> {
|
||||
let answer: Answer = serde_json::from_value(body.clone()).ok()?;
|
||||
if answer.errors.as_ref().is_some_and(|e| !e.is_null()) {
|
||||
return None;
|
||||
}
|
||||
let data = answer.data?;
|
||||
let nodes = data.tx_comments.nodes;
|
||||
let count = nodes.len();
|
||||
let comments: Vec<Found> = nodes.into_iter().filter_map(found).collect();
|
||||
if comments.len() != count {
|
||||
tracing::warn!(read = comments.len(), served = count, "indexer: comments that do not read were left aside");
|
||||
}
|
||||
let next = data.tx_comments.page_info.filter(|p| p.has_next_page).and_then(|p| p.end_cursor);
|
||||
Some(Page { finalized: data.squid_status.height, comments, next })
|
||||
}
|
||||
|
||||
/// What may settle a ballot, asked of the indexer: the comments of the bound
|
||||
/// account; unbound, those paid to the vote account — at least the amount.
|
||||
pub fn filter(account: &str, amount: u64, author: Option<&str>) -> serde_json::Value {
|
||||
match author {
|
||||
Some(a) => json!({ "authorId": { "equalTo": a } }),
|
||||
None => json!({ "transfersByCommentId": { "some": {
|
||||
"toId": { "equalTo": account },
|
||||
"amount": { "greaterThanOrEqualTo": amount.to_string() },
|
||||
} } }),
|
||||
}
|
||||
}
|
||||
|
||||
/// Reads a body of at most `MAX_BODY` bytes as JSON.
|
||||
async fn read_json(mut res: reqwest::Response) -> Option<serde_json::Value> {
|
||||
if res.content_length().is_some_and(|n| n > MAX_BODY as u64) {
|
||||
return None;
|
||||
}
|
||||
let mut buf = Vec::new();
|
||||
while let Some(chunk) = res.chunk().await.ok()? {
|
||||
if buf.len() + chunk.len() > MAX_BODY {
|
||||
return None;
|
||||
}
|
||||
buf.extend_from_slice(&chunk);
|
||||
}
|
||||
serde_json::from_slice(&buf).ok()
|
||||
}
|
||||
|
||||
async fn page(state: &AppState, url: &str, body: &serde_json::Value) -> Option<Page> {
|
||||
let res = state.http.post(url).timeout(TIMEOUT).json(body).send().await;
|
||||
let value = match res {
|
||||
Ok(r) if r.status().is_success() => read_json(r).await,
|
||||
Ok(r) => {
|
||||
tracing::debug!(%url, status = %r.status(), "indexer");
|
||||
None
|
||||
}
|
||||
Err(e) => {
|
||||
tracing::debug!(%url, error = %e, "indexer");
|
||||
None
|
||||
}
|
||||
};
|
||||
value.as_ref().and_then(parse)
|
||||
}
|
||||
|
||||
/// The comments in final blocks that carry the remark of hash `remark_hash`
|
||||
/// (`0x…`) and pass `filter`, page after page until `settled` says one settles
|
||||
/// the ballot.
|
||||
pub async fn comments(
|
||||
state: &AppState,
|
||||
remark_hash: &str,
|
||||
filter: &serde_json::Value,
|
||||
settled: impl Fn(&[Found]) -> bool,
|
||||
) -> Reading {
|
||||
let mut answered = false;
|
||||
for url in &state.cfg.indexers {
|
||||
let mut kept: Vec<Found> = Vec::new();
|
||||
let mut unfinal = false;
|
||||
let mut after: Option<String> = None;
|
||||
let mut finalized: Option<u32> = None;
|
||||
for n in 0..MAX_PAGES {
|
||||
let body = json!({ "query": BALLOT_QUERY, "variables": { "hash": remark_hash, "filter": filter, "after": after } });
|
||||
let Some(p) = page(state, url, &body).await else { break };
|
||||
// The finality of the first page holds for the reading: later pages may only see more.
|
||||
let last_final = *finalized.get_or_insert(p.finalized);
|
||||
answered = true;
|
||||
for f in p.comments {
|
||||
if f.comment.position.block > last_final {
|
||||
unfinal = true;
|
||||
} else {
|
||||
kept.push(f);
|
||||
}
|
||||
}
|
||||
if unfinal || settled(&kept) {
|
||||
break;
|
||||
}
|
||||
match p.next {
|
||||
Some(cursor) => after = Some(cursor),
|
||||
None => break,
|
||||
}
|
||||
if n + 1 == MAX_PAGES {
|
||||
tracing::warn!(%url, hash = remark_hash, "indexer: more pages than read; the ballot stays pending");
|
||||
}
|
||||
}
|
||||
if !kept.is_empty() || unfinal {
|
||||
return Reading::Found { comments: kept, unfinal };
|
||||
}
|
||||
}
|
||||
if answered { Reading::Found { comments: Vec::new(), unfinal: false } } else { Reading::Silent }
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
/// A real answer of the Ğ1 indexer to `BALLOT_QUERY` (2026-10-31), for the remark « Merci ».
|
||||
const REAL: &str = include_str!("../tests/fixtures/indexer-merci.json");
|
||||
|
||||
#[test]
|
||||
fn a_real_answer_reads() {
|
||||
let body: serde_json::Value = serde_json::from_str(REAL).expect("json");
|
||||
let page = parse(&body).expect("an answer");
|
||||
assert_eq!(page.finalized, 3_407_492);
|
||||
assert_eq!(page.next, None, "one page: 26 comments, fewer than 50");
|
||||
let list = page.comments;
|
||||
assert_eq!(list.len(), 26, "the old query stopped at 20");
|
||||
let first = &list[0];
|
||||
assert_eq!(first.comment.position, ChainPosition { block: 196_944, event: 3 });
|
||||
assert_eq!(first.remark, "Merci ");
|
||||
assert_eq!(first.signer, "g1NrqKgJW9FoFabaYW82zhS9tDWdQcL1DGpivaJdj7GW9M6iv");
|
||||
assert_eq!(first.name.as_deref(), Some("SIMONEANTONELLI"));
|
||||
assert_eq!(first.comment.identity, Some(ChainIdentity { index: 1304, member: true }));
|
||||
assert_eq!(first.comment.transfers.len(), 1);
|
||||
assert_eq!(first.comment.transfers[0].amount, 30_000);
|
||||
assert_eq!(first.comment.transfers[0].from, first.comment.author);
|
||||
// An account without identity.
|
||||
assert_eq!(list[2].comment.identity, None);
|
||||
assert_eq!(list[2].name, None);
|
||||
// In the order of the chain.
|
||||
assert!(list.windows(2).all(|w| w[0].comment.position < w[1].comment.position));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn errors_or_a_strange_shape_are_no_answer() {
|
||||
assert!(parse(&json!({ "errors": [{ "message": "boom" }] })).is_none());
|
||||
assert!(parse(&json!({ "data": null })).is_none());
|
||||
assert!(parse(&json!({ "nope": 1 })).is_none());
|
||||
// Without the finality, an answer cannot settle anything: no answer.
|
||||
assert!(parse(&json!({ "data": { "txComments": { "nodes": [] } } })).is_none());
|
||||
let empty = json!({ "data": { "squidStatus": { "height": 7 }, "txComments": { "nodes": [] } } });
|
||||
assert_eq!(parse(&empty).map(|p| (p.finalized, p.comments.len(), p.next)), Some((7, 0, None)));
|
||||
let more = json!({ "data": { "squidStatus": { "height": 7 }, "txComments": {
|
||||
"pageInfo": { "hasNextPage": true, "endCursor": "abc" }, "nodes": [] } } });
|
||||
assert_eq!(parse(&more).and_then(|p| p.next).as_deref(), Some("abc"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_node_that_does_not_read_is_left_aside() {
|
||||
let author = "g1NrqKgJW9FoFabaYW82zhS9tDWdQcL1DGpivaJdj7GW9M6iv";
|
||||
let body = json!({ "data": { "squidStatus": { "height": 100 }, "txComments": { "nodes": [
|
||||
{ "id": "0000000010-abcde-000002", "blockNumber": 11, "remark": "x",
|
||||
"author": { "id": author, "identity": null },
|
||||
"transfersByCommentId": { "nodes": [] } },
|
||||
{ "id": "0000000010-abcde-000002", "blockNumber": 10, "remark": "x",
|
||||
"author": { "id": "not an address", "identity": null },
|
||||
"transfersByCommentId": { "nodes": [] } },
|
||||
{ "id": "0000000010-abcde-000002", "blockNumber": 10, "remark": "x",
|
||||
"author": { "id": "z".repeat(100_000), "identity": null },
|
||||
"transfersByCommentId": { "nodes": [] } },
|
||||
{ "id": "0000000010-abcde-000002", "blockNumber": 10, "remark": "x",
|
||||
"author": { "id": author, "identity": { "index": 1, "name": "n".repeat(65), "isMember": true } },
|
||||
"transfersByCommentId": { "nodes": [] } },
|
||||
] } } });
|
||||
assert_eq!(
|
||||
parse(&body).map(|p| p.comments.len()),
|
||||
Some(0),
|
||||
"block and id disagree; a bad address; a long one; a name no identity has"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn what_is_asked_is_what_may_settle() {
|
||||
let vote = "g1L5EYvvSBaYuQCPW78bHxYcvHfmWAqHYe3Gsrp9SV4kWZaEM";
|
||||
let alice = "g1Pn1geRZnHcVdWdKkoYwQrAARDTXK1D7H3zuXj7GNayT1thu";
|
||||
assert_eq!(filter(vote, 10, Some(alice)), json!({ "authorId": { "equalTo": alice } }));
|
||||
assert_eq!(
|
||||
filter(vote, 10, None),
|
||||
json!({ "transfersByCommentId": { "some": {
|
||||
"toId": { "equalTo": vote }, "amount": { "greaterThanOrEqualTo": "10" } } } })
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,336 @@
|
||||
//! Inertia amendments — a starting level of the seed raised in an existing base.
|
||||
//!
|
||||
//! Yvv, 2026-10-30: « Tu mets par défaut au milieu (3) pour toutes les
|
||||
//! propositions, (4) pour les 2 engagements structurels » — Unicité du compte
|
||||
//! (E1) and Réciprocité (E2). New bases get level 4 from the seed. An existing
|
||||
//! base, where both are in force at the starting level (`standard`, now 3),
|
||||
//! gets it through an amendment listed here, and **only if no vote ever
|
||||
//! changed the article's inertia**:
|
||||
//!
|
||||
//! - no adoption of a change of inertia on the article (`adoptions`, and the
|
||||
//! journal `inertia_changes`);
|
||||
//! - its level is still the one the amendment raises (the seed's 3).
|
||||
//!
|
||||
//! An open proposal to change the inertia does not stop it (orch, 2026-10-30:
|
||||
//! two such proposals exist in production, I1 → 5 and E1 → 5, at zero votes):
|
||||
//! it stays open, with its target, and is voted at the article's new level,
|
||||
//! since a change of inertia inherits it (ADR 0007).
|
||||
//!
|
||||
//! Like the seed amendments (`seed_amend`), each document is decided once, at
|
||||
//! the first start that knows the amendment; the decision is kept in
|
||||
//! `inertia_amendments` (applied, refused and why, or present) and in the
|
||||
//! logs. Applied, the level is written in one transaction with a line in
|
||||
//! `inertia_changes` (the article says so) and, where the document publishes,
|
||||
//! a row in the publication queue (`document.json` carries the levels).
|
||||
//!
|
||||
//! Since 2026-11-02 the seed gives E1 and E2 another starting level: put forward
|
||||
//! as seed proposals, they start low (ADR 0011, `seed_proposal_amend`). The two
|
||||
//! amendments of 30/10 are then superseded: decided in production, they decide
|
||||
//! nothing more, and say so only in the debug logs.
|
||||
|
||||
use crate::AppState;
|
||||
use crate::seed::SeedDoc;
|
||||
use crate::util;
|
||||
use ld_engine::inertia::InertiaLevel;
|
||||
use sqlx::SqliteConnection;
|
||||
|
||||
/// One starting level to raise, in every document instantiated from a seed.
|
||||
pub struct InertiaAmendment {
|
||||
/// Stable identifier: each document's decision is kept under it.
|
||||
pub id: &'static str,
|
||||
/// Slug of the seed document.
|
||||
pub doc: &'static str,
|
||||
/// Code of the article.
|
||||
pub unit: &'static str,
|
||||
/// The level it must still hold.
|
||||
pub from: u8,
|
||||
/// The level it gets: the seed's today, which must say so.
|
||||
pub to: u8,
|
||||
/// Reason given to the publication queue, and so to the commit of the texts.
|
||||
pub reason: &'static str,
|
||||
/// The later decision whose level the seed gives instead, if any: this one then
|
||||
/// decides nothing more, without an error.
|
||||
pub superseded_by: Option<&'static str>,
|
||||
}
|
||||
|
||||
pub const AMENDMENTS: &[InertiaAmendment] = &[
|
||||
InertiaAmendment {
|
||||
id: "2026-10-30-e1-inertie-4",
|
||||
doc: "acte-engagement-certification",
|
||||
unit: "E1",
|
||||
from: 3,
|
||||
to: 4,
|
||||
reason: "inertie de départ relevée : E1, 3 → 4",
|
||||
superseded_by: Some("2026-11-02-e1-proposition-seed"),
|
||||
},
|
||||
InertiaAmendment {
|
||||
id: "2026-10-30-e2-inertie-4",
|
||||
doc: "acte-engagement-certification",
|
||||
unit: "E2",
|
||||
from: 3,
|
||||
to: 4,
|
||||
reason: "inertie de départ relevée : E2, 3 → 4",
|
||||
superseded_by: Some("2026-11-02-e2-proposition-seed"),
|
||||
},
|
||||
];
|
||||
|
||||
/// What the database says of the article.
|
||||
#[derive(Debug, Clone, Default, PartialEq, Eq)]
|
||||
pub struct Facts {
|
||||
/// `units.inertia` as written.
|
||||
pub raw: String,
|
||||
/// Changes of its inertia that a vote brought into force.
|
||||
pub voted_changes: i64,
|
||||
/// Open proposals to change its inertia (they stay open; told in the logs).
|
||||
pub open_proposals: i64,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum Verdict {
|
||||
Apply,
|
||||
/// The level is already there: nothing to do.
|
||||
Present,
|
||||
Refuse(&'static str),
|
||||
}
|
||||
|
||||
/// The rule, apart from the database: a vote on the inertia keeps it.
|
||||
pub fn verdict(facts: Option<&Facts>, a: &InertiaAmendment) -> Verdict {
|
||||
let Some(f) = facts else { return Verdict::Refuse("no_article") };
|
||||
let Some(level) = InertiaLevel::parse(&f.raw) else { return Verdict::Refuse("level_unreadable") };
|
||||
if level.get() == a.to {
|
||||
return Verdict::Present;
|
||||
}
|
||||
if f.voted_changes > 0 {
|
||||
Verdict::Refuse("changed_by_vote")
|
||||
} else if level.get() != a.from {
|
||||
Verdict::Refuse("level_differs")
|
||||
} else {
|
||||
Verdict::Apply
|
||||
}
|
||||
}
|
||||
|
||||
/// A decision taken at this start.
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct Outcome {
|
||||
pub amendment: &'static str,
|
||||
pub group: String,
|
||||
pub verdict: Verdict,
|
||||
}
|
||||
|
||||
/// Decides every amendment not yet decided, document by document.
|
||||
pub async fn apply(state: &AppState, seeds: &[SeedDoc], list: &[InertiaAmendment]) -> anyhow::Result<Vec<Outcome>> {
|
||||
let mut out = Vec::new();
|
||||
for a in list {
|
||||
if !seed_says(seeds, a) {
|
||||
continue;
|
||||
}
|
||||
let docs: Vec<(String, String, Option<String>)> =
|
||||
sqlx::query_as("SELECT id, group_slug, publish_path FROM documents WHERE slug = ? ORDER BY group_slug")
|
||||
.bind(a.doc)
|
||||
.fetch_all(&state.db)
|
||||
.await?;
|
||||
for (doc_id, group, publish_path) in docs {
|
||||
let Some((v, open)) = decide(state, a, &doc_id, publish_path.is_some()).await? else { continue };
|
||||
match v {
|
||||
Verdict::Apply => tracing::info!(
|
||||
amendment = a.id, %group, unit = a.unit, from = a.from, to = a.to, open_inertia_proposals = open,
|
||||
"inertia amendment applied"
|
||||
),
|
||||
Verdict::Present => {
|
||||
tracing::info!(amendment = a.id, %group, unit = a.unit, "inertia amendment: level already there")
|
||||
}
|
||||
Verdict::Refuse(why) => tracing::warn!(
|
||||
amendment = a.id, %group, unit = a.unit, reason = why,
|
||||
"inertia amendment refused: the article keeps its inertia"
|
||||
),
|
||||
}
|
||||
if v == Verdict::Apply && publish_path.is_some() {
|
||||
state.publish_wake.notify_one();
|
||||
}
|
||||
out.push(Outcome { amendment: a.id, group, verdict: v });
|
||||
}
|
||||
}
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
/// The seed must hold the level the amendment gives: a seed edited later never
|
||||
/// reaches a base by this way.
|
||||
fn seed_says(seeds: &[SeedDoc], a: &InertiaAmendment) -> bool {
|
||||
let level = seeds.iter().find(|s| s.slug == a.doc).and_then(|s| s.units.iter().find(|u| u.code == a.unit));
|
||||
match level {
|
||||
Some(u) if u.level().get() == a.to => true,
|
||||
Some(u) if a.superseded_by.is_some() => {
|
||||
tracing::debug!(
|
||||
amendment = a.id,
|
||||
unit = a.unit,
|
||||
seed_level = u.level().get(),
|
||||
superseded_by = a.superseded_by,
|
||||
"inertia amendment superseded: nothing decided"
|
||||
);
|
||||
false
|
||||
}
|
||||
_ => {
|
||||
tracing::error!(
|
||||
amendment = a.id,
|
||||
doc = a.doc,
|
||||
unit = a.unit,
|
||||
"inertia amendment: the seed does not give this level, nothing decided"
|
||||
);
|
||||
false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async fn facts(conn: &mut SqliteConnection, unit_id: &str, raw: String) -> anyhow::Result<Facts> {
|
||||
let voted_changes: i64 = sqlx::query_scalar(
|
||||
"SELECT (SELECT COUNT(*) FROM adoptions WHERE unit_id = ?1 AND kind = 'inertia')
|
||||
+ (SELECT COUNT(*) FROM inertia_changes WHERE unit_id = ?1 AND cause = 'vote')",
|
||||
)
|
||||
.bind(unit_id)
|
||||
.fetch_one(&mut *conn)
|
||||
.await?;
|
||||
let open_proposals: i64 =
|
||||
sqlx::query_scalar("SELECT COUNT(*) FROM proposals WHERE unit_id = ? AND kind = 'inertia' AND status = 'open'")
|
||||
.bind(unit_id)
|
||||
.fetch_one(&mut *conn)
|
||||
.await?;
|
||||
Ok(Facts { raw, voted_changes, open_proposals })
|
||||
}
|
||||
|
||||
/// One document, in one transaction: `None` when it was decided at an earlier start.
|
||||
async fn decide(
|
||||
state: &AppState,
|
||||
a: &InertiaAmendment,
|
||||
doc_id: &str,
|
||||
publishes: bool,
|
||||
) -> anyhow::Result<Option<(Verdict, i64)>> {
|
||||
let mut tx = state.db.begin().await?;
|
||||
let decided: i64 =
|
||||
sqlx::query_scalar("SELECT COUNT(*) FROM inertia_amendments WHERE amendment_id = ? AND doc_id = ?")
|
||||
.bind(a.id)
|
||||
.bind(doc_id)
|
||||
.fetch_one(&mut *tx)
|
||||
.await?;
|
||||
if decided > 0 {
|
||||
return Ok(None);
|
||||
}
|
||||
let unit: Option<(String, String)> = sqlx::query_as("SELECT id, inertia FROM units WHERE doc_id = ? AND code = ?")
|
||||
.bind(doc_id)
|
||||
.bind(a.unit)
|
||||
.fetch_optional(&mut *tx)
|
||||
.await?;
|
||||
let found = match &unit {
|
||||
Some((id, raw)) => Some(facts(&mut tx, id, raw.clone()).await?),
|
||||
None => None,
|
||||
};
|
||||
let mut v = verdict(found.as_ref(), a);
|
||||
let now = util::now();
|
||||
let to = InertiaLevel::new(a.to).map(InertiaLevel::as_db).unwrap_or_default();
|
||||
if let (Verdict::Apply, Some((unit_id, raw))) = (v, &unit) {
|
||||
// Written only over the level that was judged.
|
||||
let done = sqlx::query("UPDATE units SET inertia = ? WHERE id = ? AND inertia = ?")
|
||||
.bind(&to)
|
||||
.bind(unit_id)
|
||||
.bind(raw)
|
||||
.execute(&mut *tx)
|
||||
.await?;
|
||||
if done.rows_affected() == 1 {
|
||||
sqlx::query(
|
||||
"INSERT INTO inertia_changes (unit_id, doc_id, from_level, to_level, cause, amendment_id, at)
|
||||
VALUES (?, ?, ?, ?, 'amendment', ?, ?)",
|
||||
)
|
||||
.bind(unit_id)
|
||||
.bind(doc_id)
|
||||
.bind(a.from.to_string())
|
||||
.bind(&to)
|
||||
.bind(a.id)
|
||||
.bind(&now)
|
||||
.execute(&mut *tx)
|
||||
.await?;
|
||||
if publishes {
|
||||
sqlx::query("INSERT INTO outbox (doc_id, reason, created_at, next_at) VALUES (?, ?, ?, ?)")
|
||||
.bind(doc_id)
|
||||
.bind(a.reason)
|
||||
.bind(&now)
|
||||
.bind(&now)
|
||||
.execute(&mut *tx)
|
||||
.await?;
|
||||
}
|
||||
} else {
|
||||
v = Verdict::Refuse("changed");
|
||||
}
|
||||
}
|
||||
let (outcome, reason) = match v {
|
||||
Verdict::Apply => ("applied", None),
|
||||
Verdict::Present => ("present", None),
|
||||
Verdict::Refuse(why) => ("refused", Some(why)),
|
||||
};
|
||||
sqlx::query(
|
||||
"INSERT INTO inertia_amendments (amendment_id, doc_id, unit_code, from_level, to_level, outcome, reason, at)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?)",
|
||||
)
|
||||
.bind(a.id)
|
||||
.bind(doc_id)
|
||||
.bind(a.unit)
|
||||
.bind(unit.as_ref().map(|(_, raw)| raw.clone()))
|
||||
.bind(&to)
|
||||
.bind(outcome)
|
||||
.bind(reason)
|
||||
.bind(&now)
|
||||
.execute(&mut *tx)
|
||||
.await?;
|
||||
tx.commit().await?;
|
||||
Ok(Some((v, found.map_or(0, |f| f.open_proposals))))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
const A: InertiaAmendment = InertiaAmendment {
|
||||
id: "test",
|
||||
doc: "doc",
|
||||
unit: "E1",
|
||||
from: 3,
|
||||
to: 4,
|
||||
reason: "inertie de départ relevée",
|
||||
superseded_by: None,
|
||||
};
|
||||
|
||||
fn at(raw: &str) -> Facts {
|
||||
Facts { raw: raw.into(), ..Facts::default() }
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_starting_level_is_raised() {
|
||||
assert_eq!(verdict(Some(&at("3")), &A), Verdict::Apply);
|
||||
// The word of before, if an older image wrote it.
|
||||
assert_eq!(verdict(Some(&at("standard")), &A), Verdict::Apply);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_open_change_of_inertia_does_not_stop_it() {
|
||||
let f = Facts { open_proposals: 1, ..at("3") };
|
||||
assert_eq!(verdict(Some(&f), &A), Verdict::Apply);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_level_already_there_is_left_as_it_is() {
|
||||
assert_eq!(verdict(Some(&at("4")), &A), Verdict::Present);
|
||||
assert_eq!(verdict(Some(&at("high")), &A), Verdict::Present);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_vote_on_the_inertia_keeps_it() {
|
||||
let cases = [
|
||||
(None, "no_article"),
|
||||
(Some(Facts { voted_changes: 1, ..at("3") }), "changed_by_vote"),
|
||||
(Some(at("5")), "level_differs"),
|
||||
(Some(at("2")), "level_differs"),
|
||||
(Some(at("haute")), "level_unreadable"),
|
||||
];
|
||||
for (facts, reason) in cases {
|
||||
assert_eq!(verdict(facts.as_ref(), &A), Verdict::Refuse(reason), "{facts:?}");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,290 @@
|
||||
//! libreDecision server.
|
||||
//!
|
||||
//! One binary serves the JSON API under `/api`, the compiled Svelte interface,
|
||||
//! and runs four background workers: the electorate refresher (size of the Ğ1
|
||||
//! web of trust), the adoption sweeper (with the deadlines of three days of
|
||||
//! objections and typo fixes), the ballot watcher (votes signed on the Ğ1 chain,
|
||||
//! confirmed on the indexer, or simulated while the chain is closed — ADR 0012)
|
||||
//! and the publisher (adopted texts pushed to the git
|
||||
//! repository that client applications read).
|
||||
|
||||
pub mod access;
|
||||
pub mod adoption;
|
||||
pub mod article_amend;
|
||||
pub mod auth;
|
||||
pub mod ballots;
|
||||
pub mod clock;
|
||||
pub mod config;
|
||||
pub mod deadlines;
|
||||
pub mod doc_title;
|
||||
pub mod docs;
|
||||
pub mod error;
|
||||
pub mod indexer;
|
||||
pub mod inertia_amend;
|
||||
pub mod messages;
|
||||
pub mod module_amend;
|
||||
pub mod modules;
|
||||
pub mod objections;
|
||||
pub mod order;
|
||||
pub mod overview;
|
||||
pub mod participation;
|
||||
pub mod proposals;
|
||||
pub mod publish;
|
||||
pub mod quorum;
|
||||
pub mod quorum_amend;
|
||||
pub mod retire;
|
||||
pub mod seed;
|
||||
pub mod seed_amend;
|
||||
pub mod seed_proposal_amend;
|
||||
pub mod simulation;
|
||||
pub mod team;
|
||||
pub mod typos;
|
||||
pub mod util;
|
||||
pub mod web;
|
||||
pub mod wot;
|
||||
|
||||
use axum::Router;
|
||||
use axum::extract::{Request, State};
|
||||
use axum::http::{Method, StatusCode};
|
||||
use axum::middleware::{self, Next};
|
||||
use axum::response::{IntoResponse, Response};
|
||||
use axum::routing::{delete, get, post, put};
|
||||
use sqlx::SqlitePool;
|
||||
use sqlx::sqlite::{SqliteConnectOptions, SqliteJournalMode, SqlitePoolOptions};
|
||||
use std::str::FromStr;
|
||||
use std::sync::Arc;
|
||||
use std::sync::atomic::{AtomicBool, Ordering};
|
||||
use std::time::Duration;
|
||||
use tokio::sync::Notify;
|
||||
|
||||
pub use config::Config;
|
||||
|
||||
#[derive(Clone)]
|
||||
pub struct AppState {
|
||||
pub db: SqlitePool,
|
||||
pub cfg: Arc<Config>,
|
||||
pub http: reqwest::Client,
|
||||
/// Wakes the publisher as soon as an adoption is recorded.
|
||||
pub publish_wake: Arc<Notify>,
|
||||
/// What is coming in the tools of each group (`seeds/upcoming/`), read at start.
|
||||
pub upcoming: Arc<overview::Upcoming>,
|
||||
/// The clock of the deadlines of three days (objections, typo fixes); tests move it.
|
||||
pub clock: clock::Clock,
|
||||
/// Tests only, with the dev login: the chain of this server closed for a while
|
||||
/// (`POST /api/dev/vote-chain`), to try the simulation (ADR 0012).
|
||||
pub chain_closed: Arc<AtomicBool>,
|
||||
}
|
||||
|
||||
impl AppState {
|
||||
pub async fn new(cfg: Config, db: SqlitePool) -> anyhow::Result<Self> {
|
||||
let http = reqwest::Client::builder()
|
||||
.timeout(Duration::from_secs(15))
|
||||
.user_agent(concat!("libreDecision/", env!("CARGO_PKG_VERSION")))
|
||||
.build()?;
|
||||
let upcoming = Arc::new(overview::Upcoming::load(&cfg.seed_dir).await?);
|
||||
Ok(Self {
|
||||
db,
|
||||
cfg: Arc::new(cfg),
|
||||
http,
|
||||
publish_wake: Arc::new(Notify::new()),
|
||||
upcoming,
|
||||
clock: clock::Clock::default(),
|
||||
chain_closed: Arc::new(AtomicBool::new(false)),
|
||||
})
|
||||
}
|
||||
|
||||
/// The vote on the chain, as it is now: `None` while it is closed (no vote
|
||||
/// account, or closed for a test) — the vote is then simulated (ADR 0012).
|
||||
pub fn chain(&self) -> Option<&config::VoteChain> {
|
||||
self.cfg.vote.as_ref().filter(|_| !self.chain_closed.load(Ordering::Relaxed))
|
||||
}
|
||||
|
||||
/// What each stage of a vote weighs (ADR 0012).
|
||||
pub fn vote_rules(&self) -> ld_engine::buffer::Rules {
|
||||
ld_engine::buffer::Rules { simulated_adopts: self.cfg.simulated_adopts }
|
||||
}
|
||||
}
|
||||
|
||||
/// Open (or create) the database and apply migrations.
|
||||
pub async fn open_db(url: &str) -> anyhow::Result<SqlitePool> {
|
||||
let options = SqliteConnectOptions::from_str(url)?
|
||||
.create_if_missing(true)
|
||||
.journal_mode(SqliteJournalMode::Wal)
|
||||
.foreign_keys(true)
|
||||
.busy_timeout(Duration::from_secs(5));
|
||||
let max = if url.contains(":memory:") { 1 } else { 8 };
|
||||
let pool = SqlitePoolOptions::new().max_connections(max).connect_with(options).await?;
|
||||
// A previous image (rollback) must still start on a database a newer one has
|
||||
// migrated: migrations are additive, the unknown ones are tolerated.
|
||||
let mut migrator = sqlx::migrate!("./migrations");
|
||||
migrator.set_ignore_missing(true);
|
||||
migrator.run(&pool).await?;
|
||||
Ok(pool)
|
||||
}
|
||||
|
||||
/// Mutating API calls must carry this header: a cross-site form cannot set it,
|
||||
/// and no CORS is ever granted, so it closes CSRF together with SameSite=Lax.
|
||||
pub const CSRF_HEADER: &str = "x-requested-with";
|
||||
pub const CSRF_VALUE: &str = "libredecision";
|
||||
|
||||
async fn csrf_guard(req: Request, next: Next) -> Response {
|
||||
let safe = matches!(*req.method(), Method::GET | Method::HEAD | Method::OPTIONS);
|
||||
let has_header = req.headers().get(CSRF_HEADER).and_then(|v| v.to_str().ok()) == Some(CSRF_VALUE);
|
||||
if safe || has_header {
|
||||
next.run(req).await
|
||||
} else {
|
||||
(StatusCode::FORBIDDEN, axum::Json(serde_json::json!({ "error": "csrf" }))).into_response()
|
||||
}
|
||||
}
|
||||
|
||||
async fn health(State(state): State<AppState>) -> impl IntoResponse {
|
||||
let db_ok = sqlx::query_scalar::<_, i64>("SELECT 1").fetch_one(&state.db).await.is_ok();
|
||||
axum::Json(serde_json::json!({ "ok": db_ok, "build": state.cfg.build }))
|
||||
}
|
||||
|
||||
pub fn api_router() -> Router<AppState> {
|
||||
Router::new()
|
||||
.route("/health", get(health))
|
||||
.route("/auth/config", get(auth::config))
|
||||
.route("/auth/g1/start", get(auth::g1_start))
|
||||
.route("/auth/g1/callback", get(auth::g1_callback))
|
||||
.route("/auth/invitation", post(auth::accept_invitation))
|
||||
.route("/auth/team", post(team::join))
|
||||
.route("/auth/team/peek", post(team::peek))
|
||||
.route("/auth/dev", post(auth::dev_login))
|
||||
.route("/auth/logout", post(auth::logout))
|
||||
.route("/me", get(auth::me))
|
||||
.route("/groups", get(docs::groups))
|
||||
.route("/groups/{slug}", get(overview::group))
|
||||
.route("/groups/{slug}/decisions", get(overview::decisions))
|
||||
.route("/docs/{id}", get(docs::document))
|
||||
.route("/docs/{id}/preview", get(docs::preview))
|
||||
.route("/docs/{id}/assist", post(proposals::assist))
|
||||
.route("/docs/{id}/proposals", post(proposals::create))
|
||||
.route("/docs/{id}/votes", post(ballots::vote_many))
|
||||
.route("/docs/{id}/ballots", post(ballots::create))
|
||||
.route("/ballots", get(ballots::mine))
|
||||
.route("/ballots/{id}", get(ballots::status).delete(ballots::drop))
|
||||
.route("/ballots/{id}/receipt", get(ballots::receipt))
|
||||
.route("/proposals/{id}", get(proposals::detail).patch(proposals::edit))
|
||||
.route("/proposals/{id}/submit", post(proposals::submit))
|
||||
.route("/proposals/{id}/withdraw", post(proposals::withdraw))
|
||||
.route("/proposals/{id}/split", post(proposals::split))
|
||||
.route("/proposals/{id}/vote", post(proposals::vote))
|
||||
.route("/proposals/{id}/objections", post(objections::raise))
|
||||
.route("/proposals/{id}/translations/{lang}", put(proposals::translate))
|
||||
.route("/objections/{id}/lift", post(objections::lift))
|
||||
.route("/objections/{id}/reiterate", post(objections::reiterate))
|
||||
.route("/typos/{id}/validate", post(typos::validate))
|
||||
.route("/typos/{id}/refuse", post(typos::refuse))
|
||||
.route("/messages", get(messages::list))
|
||||
.route("/messages/unread", get(messages::unread))
|
||||
.route("/messages/read", post(messages::read_all))
|
||||
.route("/messages/{id}/read", post(messages::read))
|
||||
.route("/dev/clock", post(deadlines::dev_clock))
|
||||
.route("/dev/vote-chain", post(ballots::dev_chain))
|
||||
.route("/units/{id}/inertia", post(proposals::propose_inertia))
|
||||
.route("/units/{id}/quorum", put(quorum::set))
|
||||
.route("/units/{id}/order", post(order::propose))
|
||||
.route("/units/{id}/title", post(doc_title::propose))
|
||||
.route("/feedback", post(access::send_feedback))
|
||||
.route("/access", get(access::overview))
|
||||
.route("/access/groups/{slug}/members", post(access::grant))
|
||||
.route("/access/groups/{slug}/members/{identity}", delete(access::revoke))
|
||||
.route("/access/invitations", post(access::invite))
|
||||
.route("/access/invitations/{hash}", delete(access::cancel_invitation))
|
||||
.route("/access/publication/retry", post(access::publish_now))
|
||||
.route("/access/team-links", post(team::create))
|
||||
.route("/access/team-links/{hash}", delete(team::revoke))
|
||||
.route("/access/identities/{id}/admin", post(access::set_admin))
|
||||
.route("/access/identities/{id}/sessions", delete(access::close_sessions))
|
||||
.route("/access/feedback", get(access::feedback_list))
|
||||
.route("/access/feedback/{id}", post(access::feedback_status))
|
||||
.layer(middleware::from_fn(csrf_guard))
|
||||
}
|
||||
|
||||
pub fn app(state: AppState) -> Router {
|
||||
use axum::http::{HeaderName, HeaderValue, header};
|
||||
use tower_http::set_header::SetResponseHeaderLayer;
|
||||
// Neighbouring sites under *.librodrome.org are same-site: none may frame us.
|
||||
let security: [(HeaderName, &'static str); 4] = [
|
||||
(header::X_FRAME_OPTIONS, "DENY"),
|
||||
(header::CONTENT_SECURITY_POLICY, "frame-ancestors 'none'"),
|
||||
(header::X_CONTENT_TYPE_OPTIONS, "nosniff"),
|
||||
(header::REFERRER_POLICY, "same-origin"),
|
||||
];
|
||||
let mut router = Router::new().nest("/api", api_router()).fallback_service(web::service(&state.cfg.web_dir));
|
||||
for (name, value) in security {
|
||||
router = router.layer(SetResponseHeaderLayer::if_not_present(name, HeaderValue::from_static(value)));
|
||||
}
|
||||
router
|
||||
.layer(tower_http::limit::RequestBodyLimitLayer::new(256 * 1024))
|
||||
.layer(tower_http::compression::CompressionLayer::new())
|
||||
.layer(tower_http::trace::TraceLayer::new_for_http())
|
||||
.with_state(state)
|
||||
}
|
||||
|
||||
/// Ctrl-C, or SIGTERM from `docker stop`.
|
||||
async fn shutdown() {
|
||||
let ctrl_c = async {
|
||||
let _ = tokio::signal::ctrl_c().await;
|
||||
};
|
||||
#[cfg(unix)]
|
||||
let term = async {
|
||||
match tokio::signal::unix::signal(tokio::signal::unix::SignalKind::terminate()) {
|
||||
Ok(mut s) => {
|
||||
s.recv().await;
|
||||
}
|
||||
Err(_) => std::future::pending::<()>().await,
|
||||
}
|
||||
};
|
||||
#[cfg(not(unix))]
|
||||
let term = std::future::pending::<()>();
|
||||
tokio::select! {
|
||||
() = ctrl_c => {}
|
||||
() = term => {}
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn run() -> anyhow::Result<()> {
|
||||
tracing_subscriber::fmt()
|
||||
.with_env_filter(
|
||||
tracing_subscriber::EnvFilter::try_from_default_env()
|
||||
.unwrap_or_else(|_| "info,sqlx=warn,tower_http=warn".into()),
|
||||
)
|
||||
.with_ansi(std::io::IsTerminal::is_terminal(&std::io::stdout()))
|
||||
.init();
|
||||
let cfg = Config::from_env();
|
||||
tokio::fs::create_dir_all(&cfg.data_dir).await?;
|
||||
let db_url = format!("sqlite://{}", cfg.data_dir.join("libredecision.db").display());
|
||||
let db = open_db(&db_url).await?;
|
||||
let state = AppState::new(cfg, db).await?;
|
||||
|
||||
seed::ensure(&state).await?;
|
||||
// A measure missed leaves the reference in place; the sweeper takes it again.
|
||||
if let Err(e) = participation::refresh(&state, false).await {
|
||||
tracing::warn!(error = %e, "usual participation");
|
||||
}
|
||||
auth::bootstrap(&state).await?;
|
||||
// A deadline passed while the server was stopped is dealt with now.
|
||||
match deadlines::sweep(&state).await {
|
||||
Ok(s) if s != deadlines::Swept::default() => tracing::info!(?s, "deadlines passed while stopped"),
|
||||
Ok(_) => {}
|
||||
Err(e) => tracing::warn!(error = %e, "deadlines"),
|
||||
}
|
||||
if let Err(e) = publish::ensure_key(&state).await {
|
||||
tracing::warn!(error = %e, "publication key");
|
||||
}
|
||||
publish::resync(&state).await?;
|
||||
tokio::spawn(wot::refresher(state.clone()));
|
||||
tokio::spawn(publish::worker(state.clone()));
|
||||
tokio::spawn(adoption::sweeper(state.clone()));
|
||||
tokio::spawn(ballots::watcher(state.clone()));
|
||||
|
||||
let listener = tokio::net::TcpListener::bind(&state.cfg.bind).await?;
|
||||
tracing::info!(bind = %state.cfg.bind, build = %state.cfg.build, "libreDecision listening");
|
||||
axum::serve(listener, app(state).into_make_service_with_connect_info::<std::net::SocketAddr>())
|
||||
.with_graceful_shutdown(shutdown())
|
||||
.await?;
|
||||
Ok(())
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
//! libreDecision server — entry point. Everything else lives in the library.
|
||||
//!
|
||||
//! `ld-server discard-simulation [--apply]` is the act « supprime » that ends the
|
||||
//! simulation of the vote (ADR 0012): never run by the server itself.
|
||||
|
||||
#[tokio::main]
|
||||
async fn main() -> anyhow::Result<()> {
|
||||
let args: Vec<String> = std::env::args().skip(1).collect();
|
||||
match args.first().map(String::as_str) {
|
||||
Some("discard-simulation") => ld_server::simulation::run_cli(args.iter().any(|a| a == "--apply")).await,
|
||||
_ => ld_server::run().await,
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,247 @@
|
||||
//! The messages of trustinWallet (2026-10-31, lot F, ADR 0010): a box in the
|
||||
//! application, in the menu, with the number of unread messages — no mail.
|
||||
//!
|
||||
//! A message is about a subject (an objection, a typo fix, a ballot set aside —
|
||||
//! ADR 0012) and carries the
|
||||
//! gestures that subject asks of its recipient now — lift or reiterate,
|
||||
//! validate or refuse — and its deadline. A message dealt with offers no
|
||||
//! gesture any more. Its text is told by the interface, in the language of the
|
||||
//! reader, from the subject. Each person reads their own messages only
|
||||
//! (`trustin_access::reads_message`); acting on a subject marks one's messages
|
||||
//! about it as read.
|
||||
|
||||
use crate::AppState;
|
||||
use crate::auth::{IdentityRow, User};
|
||||
use crate::error::{AppError, AppResult};
|
||||
use crate::{ballots, docs, objections, typos, util};
|
||||
use axum::Json;
|
||||
use axum::extract::{Path, State};
|
||||
use serde_json::{Value, json};
|
||||
|
||||
/// The most recent messages shown in the box.
|
||||
const SHOWN: i64 = 100;
|
||||
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
pub enum Topic {
|
||||
Objection,
|
||||
Typo,
|
||||
/// A ballot set aside: never seen on the chain in time, refused, or its *for*
|
||||
/// proven while an objection held the version back (ADR 0012).
|
||||
Ballot,
|
||||
}
|
||||
|
||||
impl Topic {
|
||||
pub fn as_str(self) -> &'static str {
|
||||
match self {
|
||||
Self::Objection => "objection",
|
||||
Self::Typo => "typo",
|
||||
Self::Ballot => "ballot",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Puts a message in someone's box.
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
pub async fn notify(
|
||||
state: &AppState,
|
||||
recipient: &str,
|
||||
doc_id: &str,
|
||||
topic: Topic,
|
||||
subject_id: &str,
|
||||
kind: &str,
|
||||
cycle: i64,
|
||||
due: Option<&str>,
|
||||
) -> AppResult<()> {
|
||||
sqlx::query(
|
||||
"INSERT INTO messages (id, recipient_id, doc_id, topic, subject_id, kind, cycle, created_at, due_at)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)",
|
||||
)
|
||||
.bind(util::id())
|
||||
.bind(recipient)
|
||||
.bind(doc_id)
|
||||
.bind(topic.as_str())
|
||||
.bind(subject_id)
|
||||
.bind(kind)
|
||||
.bind(cycle)
|
||||
.bind(state.clock.now())
|
||||
.bind(due)
|
||||
.execute(&state.db)
|
||||
.await?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// The messages about a subject (of some kinds, or all) are dealt with: what became of it.
|
||||
pub async fn close(
|
||||
state: &AppState,
|
||||
topic: Topic,
|
||||
subject_id: &str,
|
||||
kinds: Option<&[&str]>,
|
||||
outcome: &str,
|
||||
) -> AppResult<()> {
|
||||
let now = state.clock.now();
|
||||
match kinds {
|
||||
None => {
|
||||
sqlx::query(
|
||||
"UPDATE messages SET done_at = ?, outcome = ? WHERE topic = ? AND subject_id = ? AND done_at IS NULL",
|
||||
)
|
||||
.bind(&now)
|
||||
.bind(outcome)
|
||||
.bind(topic.as_str())
|
||||
.bind(subject_id)
|
||||
.execute(&state.db)
|
||||
.await?;
|
||||
}
|
||||
Some(kinds) => {
|
||||
for kind in kinds {
|
||||
sqlx::query(
|
||||
"UPDATE messages SET done_at = ?, outcome = ?
|
||||
WHERE topic = ? AND subject_id = ? AND kind = ? AND done_at IS NULL",
|
||||
)
|
||||
.bind(&now)
|
||||
.bind(outcome)
|
||||
.bind(topic.as_str())
|
||||
.bind(subject_id)
|
||||
.bind(kind)
|
||||
.execute(&state.db)
|
||||
.await?;
|
||||
}
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Someone acted on a subject: their messages about it are read.
|
||||
pub async fn read_subject(state: &AppState, recipient: &str, topic: Topic, subject_id: &str) -> AppResult<()> {
|
||||
sqlx::query(
|
||||
"UPDATE messages SET read_at = ? WHERE recipient_id = ? AND topic = ? AND subject_id = ? AND read_at IS NULL",
|
||||
)
|
||||
.bind(state.clock.now())
|
||||
.bind(recipient)
|
||||
.bind(topic.as_str())
|
||||
.bind(subject_id)
|
||||
.execute(&state.db)
|
||||
.await?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn unread_of(state: &AppState, recipient: &str) -> AppResult<i64> {
|
||||
Ok(sqlx::query_scalar("SELECT COUNT(*) FROM messages WHERE recipient_id = ? AND read_at IS NULL")
|
||||
.bind(recipient)
|
||||
.fetch_one(&state.db)
|
||||
.await?)
|
||||
}
|
||||
|
||||
#[derive(sqlx::FromRow)]
|
||||
struct MessageRow {
|
||||
id: String,
|
||||
recipient_id: String,
|
||||
doc_id: String,
|
||||
topic: String,
|
||||
subject_id: String,
|
||||
kind: String,
|
||||
cycle: i64,
|
||||
created_at: String,
|
||||
due_at: Option<String>,
|
||||
read_at: Option<String>,
|
||||
done_at: Option<String>,
|
||||
outcome: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(sqlx::FromRow)]
|
||||
struct DocLine {
|
||||
group_slug: String,
|
||||
slug: String,
|
||||
title: String,
|
||||
}
|
||||
|
||||
async fn view(state: &AppState, me: &IdentityRow, m: &MessageRow) -> AppResult<Option<Value>> {
|
||||
let doc = sqlx::query_as::<_, DocLine>("SELECT group_slug, slug, title FROM documents WHERE id = ?")
|
||||
.bind(&m.doc_id)
|
||||
.fetch_optional(&state.db)
|
||||
.await?;
|
||||
let Some(doc) = doc else { return Ok(None) };
|
||||
// A message tells what is in a group: shown only while its recipient opens it.
|
||||
if !docs::opens(state, me, &docs::load_group(state, &doc.group_slug).await?).await? {
|
||||
return Ok(None);
|
||||
}
|
||||
let (subject, gestures) = match m.topic.as_str() {
|
||||
"objection" => match objections::for_message(state, me, &m.subject_id, &m.kind, m.cycle).await? {
|
||||
Some((v, g)) => (json!({ "objection": v }), g),
|
||||
None => return Ok(None),
|
||||
},
|
||||
"typo" => match typos::for_message(state, me, &m.subject_id).await? {
|
||||
Some((v, g)) => (json!({ "typo": v }), g),
|
||||
None => return Ok(None),
|
||||
},
|
||||
"ballot" => match ballots::for_message(state, me, &m.subject_id).await? {
|
||||
Some(v) => (json!({ "ballot": v }), Vec::new()),
|
||||
None => return Ok(None),
|
||||
},
|
||||
_ => return Ok(None),
|
||||
};
|
||||
let mut out = json!({
|
||||
"id": m.id, "topic": m.topic, "kind": m.kind, "cycle": m.cycle, "createdAt": m.created_at,
|
||||
"dueAt": m.due_at, "read": m.read_at.is_some(), "done": m.done_at.is_some() || gestures.is_empty(),
|
||||
"outcome": m.outcome, "gestures": gestures,
|
||||
"doc": { "id": m.doc_id, "groupSlug": doc.group_slug, "slug": doc.slug, "title": util::parse_lang(&doc.title) },
|
||||
});
|
||||
if let (Some(o), Value::Object(s)) = (out.as_object_mut(), subject) {
|
||||
o.extend(s);
|
||||
}
|
||||
Ok(Some(out))
|
||||
}
|
||||
|
||||
/// `GET /api/messages` — one's own box, newest first.
|
||||
pub async fn list(State(state): State<AppState>, User(me): User) -> AppResult<Json<Value>> {
|
||||
let rows = sqlx::query_as::<_, MessageRow>(
|
||||
"SELECT id, recipient_id, doc_id, topic, subject_id, kind, cycle, created_at, due_at, read_at, done_at, outcome
|
||||
FROM messages WHERE recipient_id = ? ORDER BY created_at DESC, rowid DESC LIMIT ?",
|
||||
)
|
||||
.bind(&me.id)
|
||||
.bind(SHOWN)
|
||||
.fetch_all(&state.db)
|
||||
.await?;
|
||||
let mut out = Vec::with_capacity(rows.len());
|
||||
for m in &rows {
|
||||
if !trustin_access::reads_message(&me.to_identity(), &m.recipient_id) {
|
||||
continue;
|
||||
}
|
||||
if let Some(v) = view(&state, &me, m).await? {
|
||||
out.push(v);
|
||||
}
|
||||
}
|
||||
Ok(Json(json!({ "unread": unread_of(&state, &me.id).await?, "messages": out })))
|
||||
}
|
||||
|
||||
/// `GET /api/messages/unread` — the number next to « Messages » in the menu.
|
||||
pub async fn unread(State(state): State<AppState>, User(me): User) -> AppResult<Json<Value>> {
|
||||
Ok(Json(json!({ "unread": unread_of(&state, &me.id).await? })))
|
||||
}
|
||||
|
||||
/// `POST /api/messages/{id}/read` — one's own message only (404 otherwise).
|
||||
pub async fn read(State(state): State<AppState>, User(me): User, Path(id): Path<String>) -> AppResult<Json<Value>> {
|
||||
let recipient: String = sqlx::query_scalar("SELECT recipient_id FROM messages WHERE id = ?")
|
||||
.bind(&id)
|
||||
.fetch_optional(&state.db)
|
||||
.await?
|
||||
.ok_or(AppError::NotFound)?;
|
||||
if !trustin_access::reads_message(&me.to_identity(), &recipient) {
|
||||
return Err(AppError::NotFound);
|
||||
}
|
||||
sqlx::query("UPDATE messages SET read_at = ? WHERE id = ? AND read_at IS NULL")
|
||||
.bind(state.clock.now())
|
||||
.bind(&id)
|
||||
.execute(&state.db)
|
||||
.await?;
|
||||
Ok(Json(json!({ "unread": unread_of(&state, &me.id).await? })))
|
||||
}
|
||||
|
||||
/// `POST /api/messages/read` — every message of one's box.
|
||||
pub async fn read_all(State(state): State<AppState>, User(me): User) -> AppResult<Json<Value>> {
|
||||
sqlx::query("UPDATE messages SET read_at = ? WHERE recipient_id = ? AND read_at IS NULL")
|
||||
.bind(state.clock.now())
|
||||
.bind(&me.id)
|
||||
.execute(&state.db)
|
||||
.await?;
|
||||
Ok(Json(json!({ "unread": 0 })))
|
||||
}
|
||||
@@ -0,0 +1,221 @@
|
||||
//! Module amendments — the modules a seed now declares, added to a document
|
||||
//! that was born without them (ADR 0009).
|
||||
//!
|
||||
//! Yvv, 2026-10-30: the title of the document at level 4, the order of the
|
||||
//! articles of each section at level 2. A new base gets them from the seed
|
||||
//! (`modules` in the seed file). An existing base — production holds two
|
||||
//! instances of the certification act, Monnaie libre and the test group — gets
|
||||
//! them through an amendment listed here, built **from what the document holds
|
||||
//! then**: its title as it reads, and the order each section reads in. Nothing
|
||||
//! else is written: no article moves, no text changes, no vote is touched.
|
||||
//!
|
||||
//! Like the seed and inertia amendments, each document is decided once, at the
|
||||
//! first start that knows the amendment, and the decision is kept in
|
||||
//! `module_amendments` (applied with the codes created, refused and why, or
|
||||
//! present) and in the logs. Applied where the document publishes, the
|
||||
//! publication queue gets a row (`document.json` carries the modules).
|
||||
|
||||
use crate::AppState;
|
||||
use crate::modules::{self, Levels};
|
||||
use crate::seed::SeedDoc;
|
||||
use crate::util;
|
||||
use ld_engine::inertia::InertiaLevel;
|
||||
use sqlx::SqliteConnection;
|
||||
|
||||
/// Modules to add to every document instantiated from a seed.
|
||||
pub struct ModuleAmendment {
|
||||
/// Stable identifier: each document's decision is kept under it.
|
||||
pub id: &'static str,
|
||||
/// Slug of the seed document.
|
||||
pub doc: &'static str,
|
||||
/// The level of the title module: the seed's today, which must say so.
|
||||
pub title: u8,
|
||||
/// The level of the order modules: the seed's today, which must say so.
|
||||
pub order: u8,
|
||||
/// Reason given to the publication queue, and so to the commit of the texts.
|
||||
pub reason: &'static str,
|
||||
}
|
||||
|
||||
pub const AMENDMENTS: &[ModuleAmendment] = &[ModuleAmendment {
|
||||
id: "2026-10-31-modules-titre-ordre",
|
||||
doc: "acte-engagement-certification",
|
||||
title: 4,
|
||||
order: 2,
|
||||
reason: "modules ajoutés : le titre du document, l'ordre des articles de chaque section",
|
||||
}];
|
||||
|
||||
/// What the database says of the document.
|
||||
#[derive(Debug, Clone, Default, PartialEq, Eq)]
|
||||
pub struct Facts {
|
||||
/// Its title in its source language.
|
||||
pub title: String,
|
||||
/// Modules it holds already, of either kind.
|
||||
pub modules: i64,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum Verdict {
|
||||
Apply,
|
||||
/// Born with its modules: nothing to do.
|
||||
Present,
|
||||
Refuse(&'static str),
|
||||
}
|
||||
|
||||
/// The rule, apart from the database.
|
||||
pub fn verdict(facts: Option<&Facts>) -> Verdict {
|
||||
let Some(f) = facts else { return Verdict::Refuse("no_document") };
|
||||
if f.modules > 0 {
|
||||
Verdict::Present
|
||||
} else if f.title.trim().is_empty() {
|
||||
Verdict::Refuse("no_title")
|
||||
} else {
|
||||
Verdict::Apply
|
||||
}
|
||||
}
|
||||
|
||||
/// A decision taken at this start.
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct Outcome {
|
||||
pub amendment: &'static str,
|
||||
pub group: String,
|
||||
pub verdict: Verdict,
|
||||
pub created: Vec<String>,
|
||||
}
|
||||
|
||||
/// Decides every amendment not yet decided, document by document.
|
||||
pub async fn apply(state: &AppState, seeds: &[SeedDoc], list: &[ModuleAmendment]) -> anyhow::Result<Vec<Outcome>> {
|
||||
let mut out = Vec::new();
|
||||
for a in list {
|
||||
let Some(levels) = seed_says(seeds, a) else { continue };
|
||||
let docs: Vec<(String, String, Option<String>)> =
|
||||
sqlx::query_as("SELECT id, group_slug, publish_path FROM documents WHERE slug = ? ORDER BY group_slug")
|
||||
.bind(a.doc)
|
||||
.fetch_all(&state.db)
|
||||
.await?;
|
||||
for (doc_id, group, publish_path) in docs {
|
||||
let Some((v, created)) = decide(state, a, levels, &doc_id, publish_path.is_some()).await? else {
|
||||
continue;
|
||||
};
|
||||
match v {
|
||||
Verdict::Apply => tracing::info!(amendment = a.id, %group, created = ?created, "modules added"),
|
||||
Verdict::Present => tracing::info!(amendment = a.id, %group, "modules: already there"),
|
||||
Verdict::Refuse(why) => {
|
||||
tracing::warn!(amendment = a.id, %group, reason = why, "modules refused: the document stays as it is")
|
||||
}
|
||||
}
|
||||
if v == Verdict::Apply && publish_path.is_some() {
|
||||
state.publish_wake.notify_one();
|
||||
}
|
||||
out.push(Outcome { amendment: a.id, group, verdict: v, created });
|
||||
}
|
||||
}
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
/// The seed must declare the modules at the levels the amendment gives: a seed
|
||||
/// edited later never reaches a base by this way.
|
||||
fn seed_says(seeds: &[SeedDoc], a: &ModuleAmendment) -> Option<Levels> {
|
||||
let levels = seeds.iter().find(|s| s.slug == a.doc).and_then(|s| s.modules.as_ref()).map(Levels::from);
|
||||
let wanted = Levels { title: InertiaLevel::new(a.title), order: InertiaLevel::new(a.order) };
|
||||
match levels {
|
||||
Some(l) if l == wanted && l.title.is_some() && l.order.is_some() => Some(l),
|
||||
_ => {
|
||||
tracing::error!(
|
||||
amendment = a.id,
|
||||
doc = a.doc,
|
||||
"module amendment: the seed does not declare these modules, nothing decided"
|
||||
);
|
||||
None
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async fn facts(conn: &mut SqliteConnection, doc_id: &str) -> sqlx::Result<Option<Facts>> {
|
||||
let doc: Option<(String, String)> = sqlx::query_as("SELECT title, source_lang FROM documents WHERE id = ?")
|
||||
.bind(doc_id)
|
||||
.fetch_optional(&mut *conn)
|
||||
.await?;
|
||||
let Some((raw, src)) = doc else { return Ok(None) };
|
||||
let modules: i64 = sqlx::query_scalar("SELECT COUNT(*) FROM units WHERE doc_id = ? AND kind <> 'article'")
|
||||
.bind(doc_id)
|
||||
.fetch_one(&mut *conn)
|
||||
.await?;
|
||||
Ok(Some(Facts { title: util::parse_lang(&raw).get(&src).cloned().unwrap_or_default(), modules }))
|
||||
}
|
||||
|
||||
/// One document, in one transaction: `None` when it was decided at an earlier start.
|
||||
async fn decide(
|
||||
state: &AppState,
|
||||
a: &ModuleAmendment,
|
||||
levels: Levels,
|
||||
doc_id: &str,
|
||||
publishes: bool,
|
||||
) -> anyhow::Result<Option<(Verdict, Vec<String>)>> {
|
||||
let mut tx = state.db.begin().await?;
|
||||
let decided: i64 =
|
||||
sqlx::query_scalar("SELECT COUNT(*) FROM module_amendments WHERE amendment_id = ? AND doc_id = ?")
|
||||
.bind(a.id)
|
||||
.bind(doc_id)
|
||||
.fetch_one(&mut *tx)
|
||||
.await?;
|
||||
if decided > 0 {
|
||||
return Ok(None);
|
||||
}
|
||||
let found = facts(&mut tx, doc_id).await?;
|
||||
let v = verdict(found.as_ref());
|
||||
let now = util::now();
|
||||
let created = if v == Verdict::Apply { modules::create(&mut tx, doc_id, levels, &now).await? } else { Vec::new() };
|
||||
if v == Verdict::Apply && publishes {
|
||||
sqlx::query("INSERT INTO outbox (doc_id, reason, created_at, next_at) VALUES (?, ?, ?, ?)")
|
||||
.bind(doc_id)
|
||||
.bind(a.reason)
|
||||
.bind(&now)
|
||||
.bind(&now)
|
||||
.execute(&mut *tx)
|
||||
.await?;
|
||||
}
|
||||
let (outcome, reason) = match v {
|
||||
Verdict::Apply => ("applied", None),
|
||||
Verdict::Present => ("present", None),
|
||||
Verdict::Refuse(why) => ("refused", Some(why)),
|
||||
};
|
||||
sqlx::query(
|
||||
"INSERT INTO module_amendments (amendment_id, doc_id, outcome, reason, created, at) VALUES (?, ?, ?, ?, ?, ?)",
|
||||
)
|
||||
.bind(a.id)
|
||||
.bind(doc_id)
|
||||
.bind(outcome)
|
||||
.bind(reason)
|
||||
.bind((v == Verdict::Apply).then(|| serde_json::to_string(&created).unwrap_or_else(|_| "[]".into())))
|
||||
.bind(&now)
|
||||
.execute(&mut *tx)
|
||||
.await?;
|
||||
tx.commit().await?;
|
||||
Ok(Some((v, created)))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn doc(title: &str, modules: i64) -> Facts {
|
||||
Facts { title: title.into(), modules }
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_document_without_modules_receives_them() {
|
||||
assert_eq!(verdict(Some(&doc("Acte d'engagement Certification", 0))), Verdict::Apply);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_document_born_with_its_modules_is_left_as_it_is() {
|
||||
assert_eq!(verdict(Some(&doc("Acte", 9))), Verdict::Present);
|
||||
assert_eq!(verdict(Some(&doc("Acte", 1))), Verdict::Present, "never a second set");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn nothing_without_a_document_or_a_title() {
|
||||
assert_eq!(verdict(None), Verdict::Refuse("no_document"));
|
||||
assert_eq!(verdict(Some(&doc(" ", 0))), Verdict::Refuse("no_title"));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,240 @@
|
||||
//! Modules — what a document decides besides its articles (ADR 0009).
|
||||
//!
|
||||
//! Yvv, 2026-10-30: « En fin de section, il y a un "module" qui permet de
|
||||
//! changer l'ordre des "articles". Lui-même à (2) » ; « Le titre du doc à (4) »
|
||||
//! — and, asked whether the title of an article is voted apart: « Non, titre et
|
||||
//! texte ensemble ». So two modules, each a unit of its own kind:
|
||||
//!
|
||||
//! - the **title of the document** (`title`, level 4): its version in force is
|
||||
//! the title in the source language (`doc_title`);
|
||||
//! - the **order of the articles of each section** (`order`, level 2): its
|
||||
//! version in force is the order the section reads in now (`order`).
|
||||
//!
|
||||
//! A module is voted like an article — unfolded, in the basket, on the Ğ1
|
||||
//! chain — because its versions are proposals like any other: the same
|
||||
//! threshold, the same margin over the version in force, the same seed a
|
||||
//! minima at the start. What differs is only what entering into force does.
|
||||
//!
|
||||
//! A new document gets its modules from the seed (`modules` in the seed file);
|
||||
//! an existing one, once, from what it holds (`module_amend`). Both go through
|
||||
//! `create`, which reads the document as it is and touches no article and no
|
||||
//! vote.
|
||||
|
||||
use crate::docs::{DocState, ProposalRow, UnitRow};
|
||||
use crate::error::{AppError, AppResult};
|
||||
use crate::seed::SeedModules;
|
||||
use crate::util;
|
||||
use crate::{doc_title, order};
|
||||
use ld_engine::inertia::InertiaLevel;
|
||||
use sqlx::SqliteConnection;
|
||||
|
||||
/// The code of the title module of a document.
|
||||
pub const TITLE_CODE: &str = "titre";
|
||||
|
||||
/// The code of the order module of a section.
|
||||
pub fn order_code(section: &str) -> String {
|
||||
format!("ordre.{section}")
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
pub enum ModuleKind {
|
||||
Title,
|
||||
Order,
|
||||
}
|
||||
|
||||
impl ModuleKind {
|
||||
/// `units.kind`; an article is no module.
|
||||
pub fn parse(raw: &str) -> Option<Self> {
|
||||
match raw {
|
||||
"title" => Some(Self::Title),
|
||||
"order" => Some(Self::Order),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn as_str(self) -> &'static str {
|
||||
match self {
|
||||
Self::Title => "title",
|
||||
Self::Order => "order",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// The levels a document's modules start at; `None`: no such module.
|
||||
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
|
||||
pub struct Levels {
|
||||
pub title: Option<InertiaLevel>,
|
||||
pub order: Option<InertiaLevel>,
|
||||
}
|
||||
|
||||
impl From<&SeedModules> for Levels {
|
||||
fn from(m: &SeedModules) -> Self {
|
||||
Self { title: m.title.as_ref().map(|t| t.inertia), order: m.order.as_ref().map(|o| o.inertia) }
|
||||
}
|
||||
}
|
||||
|
||||
/// A version of a module, written directly in force (a seed a minima) or open.
|
||||
pub(crate) struct NewVersion<'a> {
|
||||
pub unit_id: &'a str,
|
||||
pub kind: ModuleKind,
|
||||
pub title: &'a str,
|
||||
pub body: &'a str,
|
||||
pub author: Option<&'a str>,
|
||||
pub in_force: bool,
|
||||
pub now: &'a str,
|
||||
}
|
||||
|
||||
pub(crate) async fn insert_version(conn: &mut SqliteConnection, v: NewVersion<'_>) -> sqlx::Result<String> {
|
||||
let id = util::id();
|
||||
let (status, origin) = if v.in_force { ("in_force", "seed") } else { ("open", "member") };
|
||||
sqlx::query(
|
||||
"INSERT INTO proposals (id, unit_id, kind, title, body, status, origin, label, author_id, revision,
|
||||
created_at, updated_at, submitted_at)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, NULL, ?, 1, ?, ?, ?)",
|
||||
)
|
||||
.bind(&id)
|
||||
.bind(v.unit_id)
|
||||
.bind(v.kind.as_str())
|
||||
.bind(v.title)
|
||||
.bind(v.body)
|
||||
.bind(status)
|
||||
.bind(origin)
|
||||
.bind(v.author)
|
||||
.bind(v.now)
|
||||
.bind(v.now)
|
||||
.bind(v.now)
|
||||
.execute(&mut *conn)
|
||||
.await?;
|
||||
sqlx::query(
|
||||
"INSERT INTO revisions (proposal_id, revision, kind, title, body, author_id, created_at)
|
||||
VALUES (?, 1, 'initial', ?, ?, ?, ?)",
|
||||
)
|
||||
.bind(&id)
|
||||
.bind(v.title)
|
||||
.bind(v.body)
|
||||
.bind(v.author)
|
||||
.bind(v.now)
|
||||
.execute(&mut *conn)
|
||||
.await?;
|
||||
if v.in_force {
|
||||
sqlx::query("UPDATE units SET in_force_id = ? WHERE id = ?")
|
||||
.bind(&id)
|
||||
.bind(v.unit_id)
|
||||
.execute(&mut *conn)
|
||||
.await?;
|
||||
}
|
||||
Ok(id)
|
||||
}
|
||||
|
||||
async fn insert_unit(
|
||||
conn: &mut SqliteConnection,
|
||||
doc_id: &str,
|
||||
kind: ModuleKind,
|
||||
code: &str,
|
||||
section: Option<&str>,
|
||||
level: InertiaLevel,
|
||||
now: &str,
|
||||
) -> sqlx::Result<String> {
|
||||
let id = util::id();
|
||||
// Its place is not among the articles: the reader puts each module where it belongs.
|
||||
sqlx::query(
|
||||
"INSERT INTO units (id, doc_id, code, section, position, inertia, in_force_id, created_at, kind)
|
||||
VALUES (?, ?, ?, ?, 0, ?, NULL, ?, ?)",
|
||||
)
|
||||
.bind(&id)
|
||||
.bind(doc_id)
|
||||
.bind(code)
|
||||
.bind(section)
|
||||
.bind(level.as_db())
|
||||
.bind(now)
|
||||
.bind(kind.as_str())
|
||||
.execute(&mut *conn)
|
||||
.await?;
|
||||
Ok(id)
|
||||
}
|
||||
|
||||
/// Creates the modules of a document from what it holds now — its title, and
|
||||
/// the order each of its sections reads in — each in force as a seed a minima.
|
||||
/// No article, no vote is touched. Returns the codes created.
|
||||
pub async fn create(conn: &mut SqliteConnection, doc_id: &str, levels: Levels, now: &str) -> sqlx::Result<Vec<String>> {
|
||||
let mut created = Vec::new();
|
||||
if let Some(level) = levels.title {
|
||||
let (raw, src): (String, String) = sqlx::query_as("SELECT title, source_lang FROM documents WHERE id = ?")
|
||||
.bind(doc_id)
|
||||
.fetch_one(&mut *conn)
|
||||
.await?;
|
||||
let titles = util::parse_lang(&raw);
|
||||
let title = titles.get(&src).cloned().unwrap_or_default();
|
||||
let unit = insert_unit(conn, doc_id, ModuleKind::Title, TITLE_CODE, None, level, now).await?;
|
||||
let pid = insert_version(
|
||||
conn,
|
||||
NewVersion {
|
||||
unit_id: &unit,
|
||||
kind: ModuleKind::Title,
|
||||
title: &title,
|
||||
body: "",
|
||||
author: None,
|
||||
in_force: true,
|
||||
now,
|
||||
},
|
||||
)
|
||||
.await?;
|
||||
doc_title::seed_translations(conn, &pid, &title, &titles, &src, now).await?;
|
||||
created.push(TITLE_CODE.to_owned());
|
||||
}
|
||||
if let Some(level) = levels.order {
|
||||
for (section, members) in order::sections_now(conn, doc_id).await? {
|
||||
let code = order_code(§ion);
|
||||
let unit = insert_unit(conn, doc_id, ModuleKind::Order, &code, Some(§ion), level, now).await?;
|
||||
let body = ld_engine::order::canonical(&members);
|
||||
insert_version(
|
||||
conn,
|
||||
NewVersion {
|
||||
unit_id: &unit,
|
||||
kind: ModuleKind::Order,
|
||||
title: &code,
|
||||
body: &body,
|
||||
author: None,
|
||||
in_force: true,
|
||||
now,
|
||||
},
|
||||
)
|
||||
.await?;
|
||||
created.push(code);
|
||||
}
|
||||
}
|
||||
Ok(created)
|
||||
}
|
||||
|
||||
/// What entering into force does, besides the version taking its place: the
|
||||
/// order of a section moves its articles, the title of a document becomes its
|
||||
/// title. Returns the kind kept in `adoptions` (`text` for an article).
|
||||
pub async fn on_entry(
|
||||
conn: &mut SqliteConnection,
|
||||
st: &DocState,
|
||||
unit: &UnitRow,
|
||||
p: &ProposalRow,
|
||||
) -> AppResult<&'static str> {
|
||||
match st.module_of(&unit.id) {
|
||||
None => Ok("text"),
|
||||
Some(ModuleKind::Order) => {
|
||||
order::enter(conn, st, unit, p).await?;
|
||||
Ok("order")
|
||||
}
|
||||
Some(ModuleKind::Title) => {
|
||||
doc_title::enter(conn, &st.doc.id, unit, p).await?;
|
||||
Ok("title")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// The generic writing of a version stops at a module: one proposes a module
|
||||
/// through its own door (`order::propose`, `doc_title::propose`).
|
||||
pub fn require_article(st: &DocState, unit_id: &str) -> AppResult<()> {
|
||||
if st.module_of(unit_id).is_some() { Err(AppError::Invalid("module")) } else { Ok(()) }
|
||||
}
|
||||
|
||||
/// An order is never corrected nor translated: another order is another proposal.
|
||||
pub fn require_text(p: &ProposalRow) -> AppResult<()> {
|
||||
if p.kind == ModuleKind::Order.as_str() { Err(AppError::Conflict("order_fixed")) } else { Ok(()) }
|
||||
}
|
||||
@@ -0,0 +1,593 @@
|
||||
//! Objections « redit » / « contredit » (2026-10-31, lot F, L12, ADR 0010).
|
||||
//!
|
||||
//! The rules are the engine's (`ld_engine::objection`): the latest arrival of
|
||||
//! the pair, never a version in force, takes no new *for* while the objection
|
||||
//! stands — against or a withdrawal only (`may_cast`, Yvv 2026-11-01) — and
|
||||
//! cannot enter into force; its author answers (a substantive revision, or a proposal linked to
|
||||
//! the objection — a revision of the other side answers too); the objector is
|
||||
//! then asked, by a message, to lift or reiterate; without an answer within 3
|
||||
//! days the objection is lifted (`due`, run by the sweeper and at start).
|
||||
//!
|
||||
//! Objections live in the table of the contradiction signals of before
|
||||
//! (`signals`, migration 0008): an older image still reads them as signals, and
|
||||
//! every lifting writes `lifted_at` / `lifted_by` as it did.
|
||||
|
||||
use crate::AppState;
|
||||
use crate::adoption;
|
||||
use crate::auth::{IdentityRow, User};
|
||||
use crate::docs::{self, DocState, UnitRow};
|
||||
use crate::error::{AppError, AppResult};
|
||||
use crate::messages::{self, Topic};
|
||||
use crate::proposals;
|
||||
use crate::util;
|
||||
use axum::Json;
|
||||
use axum::extract::{Path, State};
|
||||
use ld_engine::edit;
|
||||
use ld_engine::objection::{
|
||||
self, Change, Effect, Event, Lift, ObjectionKind, Phase, Refusal, Side, Step, Touched, Who,
|
||||
};
|
||||
use serde::Deserialize;
|
||||
use serde_json::{Value, json};
|
||||
use std::collections::{HashMap, HashSet};
|
||||
|
||||
pub const MIN_REASON: usize = 10;
|
||||
pub const MAX_REASON: usize = 600;
|
||||
|
||||
#[derive(Clone, Debug, sqlx::FromRow)]
|
||||
pub struct ObjectionRow {
|
||||
pub id: String,
|
||||
/// The version the objection was raised from.
|
||||
pub proposal_id: String,
|
||||
/// The version it was raised against.
|
||||
pub target_proposal_id: String,
|
||||
pub reason: String,
|
||||
/// The objector.
|
||||
pub author_id: String,
|
||||
pub created_at: String,
|
||||
pub lifted_at: Option<String>,
|
||||
pub lifted_by: Option<String>,
|
||||
pub kind: String,
|
||||
/// The version held back (not votable).
|
||||
pub held_id: Option<String>,
|
||||
pub phase: String,
|
||||
pub cycle: i64,
|
||||
pub due_at: Option<String>,
|
||||
pub answered_at: Option<String>,
|
||||
pub answer: Option<String>,
|
||||
pub answer_id: Option<String>,
|
||||
pub lift: Option<String>,
|
||||
}
|
||||
|
||||
const SELECT: &str = "SELECT id, proposal_id, target_proposal_id, reason, author_id, created_at, lifted_at, lifted_by,
|
||||
kind, held_id, phase, cycle, due_at, answered_at, answer, answer_id, lift FROM signals";
|
||||
|
||||
impl ObjectionRow {
|
||||
pub fn phase(&self) -> Phase {
|
||||
let cycle = u32::try_from(self.cycle).unwrap_or(1);
|
||||
match self.phase.as_str() {
|
||||
"objector" => Phase::Objector { cycle, due: self.due_at.clone().unwrap_or_default() },
|
||||
"lifted" => Phase::Lifted(self.lift.as_deref().and_then(Lift::parse).unwrap_or(Lift::Objector)),
|
||||
_ => Phase::Author { cycle },
|
||||
}
|
||||
}
|
||||
|
||||
pub fn kind(&self) -> ObjectionKind {
|
||||
ObjectionKind::parse(&self.kind).unwrap_or(ObjectionKind::Contredit)
|
||||
}
|
||||
|
||||
/// The version the held one is said to repeat or contradict.
|
||||
pub fn other_id(&self) -> &str {
|
||||
if self.held_id.as_deref() == Some(self.proposal_id.as_str()) {
|
||||
&self.target_proposal_id
|
||||
} else {
|
||||
&self.proposal_id
|
||||
}
|
||||
}
|
||||
|
||||
fn touches(&self, id: &str) -> Option<Touched> {
|
||||
if self.held_id.as_deref() == Some(id) {
|
||||
Some(Touched::Target)
|
||||
} else if self.proposal_id == id || self.target_proposal_id == id {
|
||||
Some(Touched::Other)
|
||||
} else {
|
||||
None
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async fn load(state: &AppState, id: &str) -> AppResult<ObjectionRow> {
|
||||
sqlx::query_as::<_, ObjectionRow>(&format!("{SELECT} WHERE id = ?"))
|
||||
.bind(id)
|
||||
.fetch_optional(&state.db)
|
||||
.await?
|
||||
.ok_or(AppError::NotFound)
|
||||
}
|
||||
|
||||
/// Standing objections touching one version, either side.
|
||||
async fn standing_around(state: &AppState, proposal_id: &str) -> AppResult<Vec<ObjectionRow>> {
|
||||
Ok(sqlx::query_as::<_, ObjectionRow>(&format!(
|
||||
"{SELECT} WHERE phase <> 'lifted' AND (proposal_id = ?1 OR target_proposal_id = ?1) ORDER BY created_at, rowid"
|
||||
))
|
||||
.bind(proposal_id)
|
||||
.fetch_all(&state.db)
|
||||
.await?)
|
||||
}
|
||||
|
||||
/// For a document: the versions held back by a standing objection (never one in
|
||||
/// force), and how many standing objections touch each version.
|
||||
pub async fn held(
|
||||
state: &AppState,
|
||||
doc_id: &str,
|
||||
units: &[UnitRow],
|
||||
) -> AppResult<(HashSet<String>, HashMap<String, i64>)> {
|
||||
let rows = sqlx::query_as::<_, ObjectionRow>(&format!(
|
||||
"{SELECT} WHERE phase <> 'lifted' AND proposal_id IN
|
||||
(SELECT p.id FROM proposals p JOIN units u ON u.id = p.unit_id WHERE u.doc_id = ?)"
|
||||
))
|
||||
.bind(doc_id)
|
||||
.fetch_all(&state.db)
|
||||
.await?;
|
||||
let in_force: HashSet<&str> = units.iter().filter_map(|u| u.in_force_id.as_deref()).collect();
|
||||
let mut held = HashSet::new();
|
||||
let mut counts: HashMap<String, i64> = HashMap::new();
|
||||
for o in &rows {
|
||||
*counts.entry(o.proposal_id.clone()).or_default() += 1;
|
||||
*counts.entry(o.target_proposal_id.clone()).or_default() += 1;
|
||||
if let Some(h) = &o.held_id
|
||||
&& !objection::votable(h, in_force.contains(h.as_str()), &[h.as_str()])
|
||||
{
|
||||
held.insert(h.clone());
|
||||
}
|
||||
}
|
||||
Ok((held, counts))
|
||||
}
|
||||
|
||||
/// The order a version arrived in: its submission (or creation), then the order it was written in.
|
||||
async fn arrival(state: &AppState, p: &docs::ProposalRow) -> AppResult<(String, i64)> {
|
||||
let seq: i64 =
|
||||
sqlx::query_scalar("SELECT rowid FROM proposals WHERE id = ?").bind(&p.id).fetch_one(&state.db).await?;
|
||||
Ok((p.submitted_at.clone().unwrap_or_else(|| p.created_at.clone()), seq))
|
||||
}
|
||||
|
||||
fn is_in_force(st: &DocState, id: &str) -> bool {
|
||||
st.units.iter().any(|u| u.in_force_id.as_deref() == Some(id))
|
||||
}
|
||||
|
||||
fn who(user: &IdentityRow, objector: &str) -> Who {
|
||||
let me = user.to_identity();
|
||||
if trustin_access::reiterates_objection(&me, objector) {
|
||||
Who::Objector
|
||||
} else if trustin_access::lifts_objection(&me, objector) {
|
||||
Who::Admin
|
||||
} else {
|
||||
Who::Other
|
||||
}
|
||||
}
|
||||
|
||||
fn refused(r: Refusal) -> AppError {
|
||||
match r {
|
||||
Refusal::Lifted => AppError::Conflict("lifted"),
|
||||
Refusal::NotYours => AppError::Forbidden,
|
||||
Refusal::NothingToReiterate => AppError::Conflict("nothing_to_reiterate"),
|
||||
}
|
||||
}
|
||||
|
||||
async fn author_of(state: &AppState, proposal_id: &str) -> AppResult<Option<String>> {
|
||||
Ok(sqlx::query_scalar::<_, Option<String>>("SELECT author_id FROM proposals WHERE id = ?")
|
||||
.bind(proposal_id)
|
||||
.fetch_optional(&state.db)
|
||||
.await?
|
||||
.flatten())
|
||||
}
|
||||
|
||||
async fn doc_of(state: &AppState, proposal_id: &str) -> AppResult<String> {
|
||||
sqlx::query_scalar("SELECT u.doc_id FROM proposals p JOIN units u ON u.id = p.unit_id WHERE p.id = ?")
|
||||
.bind(proposal_id)
|
||||
.fetch_optional(&state.db)
|
||||
.await?
|
||||
.ok_or(AppError::NotFound)
|
||||
}
|
||||
|
||||
/// Stores a step, only over the phase it was computed from (two people acting at
|
||||
/// once: the second gets a conflict), then does its effects. Returns false when
|
||||
/// the phase had changed meanwhile.
|
||||
async fn store(
|
||||
state: &AppState,
|
||||
o: &ObjectionRow,
|
||||
step: &Step,
|
||||
by: &str,
|
||||
extra: Option<(&str, &str)>,
|
||||
) -> AppResult<bool> {
|
||||
let now = state.clock.now();
|
||||
let (phase, cycle, due, lift) = match &step.phase {
|
||||
Phase::Author { cycle } => ("author", i64::from(*cycle), None, None),
|
||||
Phase::Objector { cycle, due } => ("objector", i64::from(*cycle), Some(due.clone()), None),
|
||||
Phase::Lifted(l) => ("lifted", o.cycle, None, Some(*l)),
|
||||
};
|
||||
let (answer, answer_id) =
|
||||
extra.map_or((o.answer.clone(), o.answer_id.clone()), |(a, id)| (Some(a.into()), Some(id.into())));
|
||||
let answered_at = if extra.is_some() { Some(now.clone()) } else { o.answered_at.clone() };
|
||||
let done = sqlx::query(
|
||||
"UPDATE signals SET phase = ?, cycle = ?, due_at = ?, lift = ?, answer = ?, answer_id = ?, answered_at = ?,
|
||||
lifted_at = CASE WHEN ? = 'lifted' THEN ? ELSE lifted_at END,
|
||||
lifted_by = CASE WHEN ? = 'lifted' THEN ? ELSE lifted_by END
|
||||
WHERE id = ? AND phase = ? AND cycle = ?",
|
||||
)
|
||||
.bind(phase)
|
||||
.bind(cycle)
|
||||
.bind(&due)
|
||||
.bind(lift.map(Lift::as_str))
|
||||
.bind(&answer)
|
||||
.bind(&answer_id)
|
||||
.bind(&answered_at)
|
||||
.bind(phase)
|
||||
.bind(&now)
|
||||
.bind(phase)
|
||||
.bind(by)
|
||||
.bind(&o.id)
|
||||
.bind(&o.phase)
|
||||
.bind(o.cycle)
|
||||
.execute(&state.db)
|
||||
.await?;
|
||||
if done.rows_affected() != 1 {
|
||||
return Ok(false);
|
||||
}
|
||||
let doc_id = doc_of(state, &o.proposal_id).await?;
|
||||
let held_author = match &o.held_id {
|
||||
Some(h) => author_of(state, h).await?,
|
||||
None => None,
|
||||
};
|
||||
for effect in &step.effects {
|
||||
match effect {
|
||||
Effect::AskObjector => {
|
||||
// The author's question of this cycle is answered.
|
||||
messages::close(
|
||||
state,
|
||||
Topic::Objection,
|
||||
&o.id,
|
||||
Some(&["objection_raised", "objection_reiterated"]),
|
||||
"answered",
|
||||
)
|
||||
.await?;
|
||||
if held_author.as_deref() != Some(o.author_id.as_str()) {
|
||||
messages::notify(
|
||||
state,
|
||||
&o.author_id,
|
||||
&doc_id,
|
||||
Topic::Objection,
|
||||
&o.id,
|
||||
"objection_answered",
|
||||
cycle,
|
||||
due.as_deref(),
|
||||
)
|
||||
.await?;
|
||||
}
|
||||
}
|
||||
Effect::TellAuthor => {
|
||||
messages::close(state, Topic::Objection, &o.id, Some(&["objection_answered"]), "reiterated").await?;
|
||||
if let Some(author) = held_author.as_deref().filter(|a| *a != o.author_id) {
|
||||
messages::notify(
|
||||
state,
|
||||
author,
|
||||
&doc_id,
|
||||
Topic::Objection,
|
||||
&o.id,
|
||||
"objection_reiterated",
|
||||
cycle,
|
||||
None,
|
||||
)
|
||||
.await?;
|
||||
}
|
||||
}
|
||||
Effect::Ended => {
|
||||
let outcome = match lift {
|
||||
Some(Lift::Admin) => "admin",
|
||||
Some(Lift::Deadline) => "deadline",
|
||||
Some(Lift::Withdrawn) => "withdrawn",
|
||||
_ => "lifted",
|
||||
};
|
||||
messages::close(state, Topic::Objection, &o.id, None, outcome).await?;
|
||||
}
|
||||
}
|
||||
}
|
||||
Ok(true)
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Raise, lift, reiterate
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
#[derive(Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct RaiseBody {
|
||||
other_id: String,
|
||||
kind: String,
|
||||
reason: String,
|
||||
}
|
||||
|
||||
/// `POST /api/proposals/{id}/objections` — « this version repeats (or
|
||||
/// contradicts) that one ». The latest arrival of the two is held back.
|
||||
pub async fn raise(
|
||||
State(state): State<AppState>,
|
||||
User(user): User,
|
||||
Path(id): Path<String>,
|
||||
Json(b): Json<RaiseBody>,
|
||||
) -> AppResult<Json<Value>> {
|
||||
let (p, st) = proposals::guarded(&state, &user, &id).await?;
|
||||
let reason = b.reason.trim();
|
||||
if reason.chars().count() < MIN_REASON || reason.chars().count() > MAX_REASON || edit::has_hidden_controls(reason) {
|
||||
return Err(AppError::Invalid("reason"));
|
||||
}
|
||||
let kind = ObjectionKind::parse(&b.kind).ok_or(AppError::Invalid("kind"))?;
|
||||
let other = st.proposals.iter().find(|x| x.id == b.other_id).ok_or(AppError::Invalid("other"))?;
|
||||
let voted = |x: &docs::ProposalRow| matches!(x.status.as_str(), "open" | "in_force") && x.kind == "text";
|
||||
if other.id == p.id || !voted(other) || !voted(&p) {
|
||||
return Err(AppError::Invalid("other"));
|
||||
}
|
||||
if other.unit_id == p.unit_id {
|
||||
// Rivals on one article never stand together anyway.
|
||||
return Err(AppError::Invalid("same_unit"));
|
||||
}
|
||||
let (pa, oa) = (arrival(&state, &p).await?, arrival(&state, other).await?);
|
||||
let held = objection::target(
|
||||
&Side { id: &p.id, arrived_at: &pa.0, seq: pa.1, in_force: is_in_force(&st, &p.id) },
|
||||
&Side { id: &other.id, arrived_at: &oa.0, seq: oa.1, in_force: is_in_force(&st, &other.id) },
|
||||
)
|
||||
.map_err(|e| match e {
|
||||
objection::PairError::BothInForce => AppError::Invalid("both_in_force"),
|
||||
objection::PairError::Same => AppError::Invalid("other"),
|
||||
})?
|
||||
.to_owned();
|
||||
let exists: i64 = sqlx::query_scalar(
|
||||
"SELECT COUNT(*) FROM signals WHERE phase <> 'lifted' AND author_id = ?
|
||||
AND ((proposal_id = ?2 AND target_proposal_id = ?3) OR (proposal_id = ?3 AND target_proposal_id = ?2))",
|
||||
)
|
||||
.bind(&user.id)
|
||||
.bind(&p.id)
|
||||
.bind(&other.id)
|
||||
.fetch_one(&state.db)
|
||||
.await?;
|
||||
if exists > 0 {
|
||||
return Err(AppError::Conflict("objection_exists"));
|
||||
}
|
||||
let oid = util::id();
|
||||
let inserted = sqlx::query(
|
||||
"INSERT INTO signals (id, proposal_id, target_proposal_id, reason, author_id, created_at, kind, held_id, phase, cycle)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, 'author', 1)",
|
||||
)
|
||||
.bind(&oid)
|
||||
.bind(&p.id)
|
||||
.bind(&other.id)
|
||||
.bind(reason)
|
||||
.bind(&user.id)
|
||||
.bind(state.clock.now())
|
||||
.bind(kind.as_str())
|
||||
.bind(&held)
|
||||
.execute(&state.db)
|
||||
.await;
|
||||
match inserted {
|
||||
// The same one sent at the same instant: it stands already (`signals_one_standing`).
|
||||
Err(sqlx::Error::Database(e)) if e.is_unique_violation() => return Err(AppError::Conflict("objection_exists")),
|
||||
other => other?,
|
||||
};
|
||||
if let Some(author) = author_of(&state, &held).await?.filter(|a| *a != user.id) {
|
||||
messages::notify(&state, &author, &st.doc.id, Topic::Objection, &oid, "objection_raised", 1, None).await?;
|
||||
}
|
||||
tracing::info!(objection = %oid, held = %held, kind = kind.as_str(), "objection raised");
|
||||
Ok(Json(json!({ "id": oid, "heldId": held })))
|
||||
}
|
||||
|
||||
async fn act(state: &AppState, user: &IdentityRow, id: &str, lift: bool) -> AppResult<ObjectionRow> {
|
||||
let o = load(state, id).await?;
|
||||
proposals::guarded(state, user, &o.proposal_id).await?;
|
||||
let event = if lift { Event::Lift(who(user, &o.author_id)) } else { Event::Reiterate(who(user, &o.author_id)) };
|
||||
let step = objection::step(&o.phase(), &event).map_err(refused)?.ok_or(AppError::Conflict("lifted"))?;
|
||||
if !store(state, &o, &step, &user.id, None).await? {
|
||||
return Err(AppError::Conflict("changed"));
|
||||
}
|
||||
messages::read_subject(state, &user.id, Topic::Objection, &o.id).await?;
|
||||
tracing::info!(objection = %o.id, lift, by = %user.id, "objection lifted or reiterated");
|
||||
Ok(o)
|
||||
}
|
||||
|
||||
/// `POST /api/objections/{id}/lift` — by the objector, or an administrator.
|
||||
pub async fn lift(State(state): State<AppState>, User(user): User, Path(id): Path<String>) -> AppResult<Json<Value>> {
|
||||
let o = act(&state, &user, &id, true).await?;
|
||||
let entered = adoption::run(&state, &doc_of(&state, &o.proposal_id).await?).await?;
|
||||
Ok(Json(json!({ "entered": entered })))
|
||||
}
|
||||
|
||||
/// `POST /api/objections/{id}/reiterate` — by the objector only, once the author answered.
|
||||
pub async fn reiterate(
|
||||
State(state): State<AppState>,
|
||||
User(user): User,
|
||||
Path(id): Path<String>,
|
||||
) -> AppResult<Json<Value>> {
|
||||
act(&state, &user, &id, false).await?;
|
||||
Ok(Json(json!({ "ok": true })))
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// What answers an objection
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// A version changed (by `by`): the objections around it whose author it
|
||||
/// answers move on; the messages of the one who answered are read.
|
||||
pub async fn on_change(state: &AppState, proposal_id: &str, substantive: bool, by: &str) -> AppResult<()> {
|
||||
for o in standing_around(state, proposal_id).await? {
|
||||
let Some(side) = o.touches(proposal_id) else { continue };
|
||||
let change = if substantive { Change::Revision(side) } else { Change::Typo(side) };
|
||||
if !objection::answers(change) {
|
||||
continue;
|
||||
}
|
||||
let due = state.clock.in_days(objection::ANSWER_DAYS);
|
||||
if let Some(step) = objection::step(&o.phase(), &Event::Answered { due: &due }).map_err(refused)? {
|
||||
let answer = if side == Touched::Target { "revision" } else { "other" };
|
||||
if store(state, &o, &step, proposal_id, Some((answer, proposal_id))).await? {
|
||||
messages::read_subject(state, by, Topic::Objection, &o.id).await?;
|
||||
}
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// May this person write a proposal linked to this objection, on this article?
|
||||
pub async fn check_link(state: &AppState, user: &IdentityRow, objection_id: &str, unit_id: &str) -> AppResult<()> {
|
||||
let o = load(state, objection_id).await.map_err(|_| AppError::Invalid("link"))?;
|
||||
let held = o.held_id.as_deref().ok_or(AppError::Invalid("link"))?;
|
||||
let unit_of = |id: String| async move {
|
||||
sqlx::query_scalar::<_, String>("SELECT unit_id FROM proposals WHERE id = ?")
|
||||
.bind(id)
|
||||
.fetch_one(&state.db)
|
||||
.await
|
||||
};
|
||||
let (held_unit, other_unit) = (unit_of(held.to_owned()).await?, unit_of(o.other_id().to_owned()).await?);
|
||||
let author = author_of(state, held).await?;
|
||||
if !o.phase().standing()
|
||||
|| !objection::may_link(author.as_deref(), &user.id)
|
||||
|| !objection::link_fits(unit_id, &held_unit, &other_unit)
|
||||
{
|
||||
return Err(AppError::Invalid("link"));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// A proposal linked to an objection was submitted (by `by`): it answers the objection.
|
||||
pub async fn on_linked(state: &AppState, objection_id: &str, proposal_id: &str, by: &str) -> AppResult<()> {
|
||||
let o = load(state, objection_id).await?;
|
||||
if !objection::answers(Change::LinkedProposal) {
|
||||
return Ok(());
|
||||
}
|
||||
let due = state.clock.in_days(objection::ANSWER_DAYS);
|
||||
if let Some(step) = objection::step(&o.phase(), &Event::Answered { due: &due }).map_err(refused)?
|
||||
&& store(state, &o, &step, proposal_id, Some(("proposal", proposal_id))).await?
|
||||
{
|
||||
messages::read_subject(state, by, Topic::Objection, &o.id).await?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// A version was withdrawn: the objections around it end.
|
||||
pub async fn on_withdrawn(state: &AppState, proposal_id: &str, by: &str) -> AppResult<()> {
|
||||
for o in standing_around(state, proposal_id).await? {
|
||||
if let Some(step) = objection::step(&o.phase(), &Event::Withdrawn).map_err(refused)? {
|
||||
store(state, &o, &step, by, None).await?;
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// The objections whose deadline passed are lifted by themselves; returns how
|
||||
/// many, and the documents touched.
|
||||
pub async fn due(state: &AppState) -> AppResult<(usize, Vec<String>)> {
|
||||
let now = state.clock.now();
|
||||
let rows = sqlx::query_as::<_, ObjectionRow>(&format!("{SELECT} WHERE phase = 'objector' AND due_at <= ?"))
|
||||
.bind(&now)
|
||||
.fetch_all(&state.db)
|
||||
.await?;
|
||||
let mut docs = Vec::new();
|
||||
let mut lifted = 0;
|
||||
for o in rows {
|
||||
if let Some(step) = objection::step(&o.phase(), &Event::Tick { now: &now }).map_err(refused)?
|
||||
&& store(state, &o, &step, "deadline", None).await?
|
||||
{
|
||||
tracing::info!(objection = %o.id, "objection lifted at its deadline");
|
||||
lifted += 1;
|
||||
let doc = doc_of(state, &o.proposal_id).await?;
|
||||
if !docs.contains(&doc) {
|
||||
docs.push(doc);
|
||||
}
|
||||
}
|
||||
}
|
||||
Ok((lifted, docs))
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// What the screen shows
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
fn version_json(st: &DocState, id: &str) -> Value {
|
||||
let p = st.proposals.iter().find(|p| p.id == id);
|
||||
let unit = p.and_then(|p| st.units.iter().find(|u| u.id == p.unit_id));
|
||||
json!({
|
||||
"id": id, "title": p.map(|p| &p.title), "status": p.map(|p| &p.status), "unitId": p.map(|p| &p.unit_id),
|
||||
"code": unit.map(|u| &u.code), "author": p.and_then(|p| p.author_name.as_ref()),
|
||||
"authorId": p.and_then(|p| p.author_id.as_ref()),
|
||||
})
|
||||
}
|
||||
|
||||
async fn name_of(state: &AppState, id: &str) -> AppResult<(Option<String>, bool)> {
|
||||
let row: Option<(String, bool)> =
|
||||
sqlx::query_as("SELECT display_name, kind = 'invite' FROM identities WHERE id = ?")
|
||||
.bind(id)
|
||||
.fetch_optional(&state.db)
|
||||
.await?;
|
||||
Ok(row.map_or((None, false), |(n, i)| (Some(n), i)))
|
||||
}
|
||||
|
||||
/// One objection, as `viewer` sees it: who raised it, what it holds back, where
|
||||
/// it stands, and what the viewer may do.
|
||||
pub async fn view(state: &AppState, st: &DocState, viewer: &IdentityRow, o: &ObjectionRow) -> AppResult<Value> {
|
||||
let (objector, invited) = name_of(state, &o.author_id).await?;
|
||||
let phase = o.phase();
|
||||
let held_author = match &o.held_id {
|
||||
Some(h) => author_of(state, h).await?,
|
||||
None => None,
|
||||
};
|
||||
let me = viewer.to_identity();
|
||||
let standing = phase.standing();
|
||||
let i_answer = standing && o.held_id.is_some() && objection::may_link(held_author.as_deref(), &viewer.id);
|
||||
let asked = matches!(phase, Phase::Objector { .. });
|
||||
Ok(json!({
|
||||
"id": o.id, "kind": o.kind(), "reason": o.reason, "objector": objector, "objectorInvited": invited,
|
||||
"mine": o.author_id == viewer.id, "at": o.created_at,
|
||||
"phase": o.phase, "cycle": o.cycle, "dueAt": o.due_at, "answer": o.answer, "answerId": o.answer_id,
|
||||
"answeredAt": o.answered_at, "lift": o.lift, "liftedAt": o.lifted_at,
|
||||
"heldVersion": o.held_id.as_ref().map(|h| version_json(st, h)),
|
||||
"other": version_json(st, o.other_id()),
|
||||
"iAnswer": i_answer,
|
||||
"canLift": standing && trustin_access::lifts_objection(&me, &o.author_id),
|
||||
"canReiterate": asked && trustin_access::reiterates_objection(&me, &o.author_id),
|
||||
}))
|
||||
}
|
||||
|
||||
/// The objections around one version, newest first, each as `viewer` sees it;
|
||||
/// `held` tells whether this version is the one held back.
|
||||
pub async fn around(state: &AppState, st: &DocState, viewer: &IdentityRow, proposal_id: &str) -> AppResult<Vec<Value>> {
|
||||
let rows = sqlx::query_as::<_, ObjectionRow>(&format!(
|
||||
"{SELECT} WHERE proposal_id = ?1 OR target_proposal_id = ?1 ORDER BY created_at DESC, rowid DESC"
|
||||
))
|
||||
.bind(proposal_id)
|
||||
.fetch_all(&state.db)
|
||||
.await?;
|
||||
let mut out = Vec::with_capacity(rows.len());
|
||||
for o in rows {
|
||||
let mut v = view(state, st, viewer, &o).await?;
|
||||
v["held"] = json!(o.held_id.as_deref() == Some(proposal_id));
|
||||
out.push(v);
|
||||
}
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
/// An objection, for a message of its box: what it is about, and the gestures
|
||||
/// the message offers now (none once dealt with).
|
||||
pub async fn for_message(
|
||||
state: &AppState,
|
||||
viewer: &IdentityRow,
|
||||
subject_id: &str,
|
||||
kind: &str,
|
||||
cycle: i64,
|
||||
) -> AppResult<Option<(Value, Vec<&'static str>)>> {
|
||||
let Ok(o) = load(state, subject_id).await else { return Ok(None) };
|
||||
let st = docs::load_state(state, &doc_of(state, &o.proposal_id).await?).await?;
|
||||
let v = view(state, &st, viewer, &o).await?;
|
||||
let current = o.cycle == cycle;
|
||||
let gestures = match (o.phase(), kind) {
|
||||
(Phase::Author { .. }, "objection_raised" | "objection_reiterated") if current => vec!["respond"],
|
||||
(Phase::Objector { .. }, "objection_answered") if current && o.author_id == viewer.id => {
|
||||
vec!["lift", "reiterate"]
|
||||
}
|
||||
_ => vec![],
|
||||
};
|
||||
Ok(Some((v, gestures)))
|
||||
}
|
||||
@@ -0,0 +1,168 @@
|
||||
//! The order of the articles of a section, a module (ADR 0009) — the rules are
|
||||
//! the engine's (`ld_engine::order`); here, the database and the door to
|
||||
//! propose one.
|
||||
//!
|
||||
//! - The version in force is the order the section reads in now: its articles
|
||||
//! one can see, by position. The text the engine compares for it is that live
|
||||
//! order, so it always reads what the reader shows.
|
||||
//! - A proposal is another order of the same articles, each once. While it is
|
||||
//! under vote, articles may enter the section or leave it: it is completed at
|
||||
//! the end (`ld_engine::order::fit`), and its votes stay.
|
||||
//! - Entering into force gives the section's places out again in the new order;
|
||||
//! the articles keep their texts, their votes, their sections.
|
||||
|
||||
use crate::AppState;
|
||||
use crate::auth::User;
|
||||
use crate::docs::{self, DocState, ProposalRow, UnitRow};
|
||||
use crate::error::{AppError, AppResult};
|
||||
use crate::modules::{self, ModuleKind, NewVersion};
|
||||
use crate::util;
|
||||
use axum::Json;
|
||||
use axum::extract::{Path, State};
|
||||
use ld_engine::order::{self, Article, OrderError};
|
||||
use serde::Deserialize;
|
||||
use serde_json::{Value, json};
|
||||
use sqlx::SqliteConnection;
|
||||
|
||||
/// More articles than any section will hold: a longer list is refused unread.
|
||||
const MAX_ARTICLES: usize = 500;
|
||||
|
||||
/// Every section of a document, in the order of the document, with the articles
|
||||
/// of its order now (read inside a transaction: the seed writes them in it).
|
||||
pub async fn sections_now(conn: &mut SqliteConnection, doc_id: &str) -> sqlx::Result<Vec<(String, Vec<String>)>> {
|
||||
#[derive(sqlx::FromRow)]
|
||||
struct Row {
|
||||
id: String,
|
||||
section: Option<String>,
|
||||
position: f64,
|
||||
visible: bool,
|
||||
}
|
||||
// Seen: a text in force, or an open version (`DocState::members` says the same). It reads
|
||||
// only the columns of before 0012: a document born now, or given its modules by an
|
||||
// amendment, holds no retired unit (ADR 0014); `article_amend` keeps the live ones.
|
||||
let rows = sqlx::query_as::<_, Row>(
|
||||
"SELECT u.id, u.section, u.position,
|
||||
(u.in_force_id IS NOT NULL OR EXISTS (SELECT 1 FROM proposals p
|
||||
WHERE p.unit_id = u.id AND p.status = 'open' AND p.kind IN ('text', 'remove'))) AS visible
|
||||
FROM units u WHERE u.doc_id = ? AND u.kind = 'article' ORDER BY u.position, u.id",
|
||||
)
|
||||
.bind(doc_id)
|
||||
.fetch_all(&mut *conn)
|
||||
.await?;
|
||||
let articles: Vec<Article<'_>> = rows
|
||||
.iter()
|
||||
.map(|r| Article { id: &r.id, section: r.section.as_deref(), position: r.position, visible: r.visible })
|
||||
.collect();
|
||||
let mut sections: Vec<String> = Vec::new();
|
||||
for r in &rows {
|
||||
if let Some(s) = &r.section
|
||||
&& !sections.contains(s)
|
||||
{
|
||||
sections.push(s.clone());
|
||||
}
|
||||
}
|
||||
Ok(sections
|
||||
.into_iter()
|
||||
.map(|s| {
|
||||
let members = order::members(&articles, &s);
|
||||
(s, members)
|
||||
})
|
||||
.collect())
|
||||
}
|
||||
|
||||
/// The order an order version gives its section now, as the screen tells it.
|
||||
pub fn version_json(st: &DocState, unit: &UnitRow, p: &ProposalRow) -> Value {
|
||||
let members = st.members(unit.section.as_deref().unwrap_or_default());
|
||||
if p.status == "in_force" {
|
||||
return json!({ "ids": members, "added": [], "dropped": [], "same": true });
|
||||
}
|
||||
match order::decode(&p.body) {
|
||||
Some(proposed) => {
|
||||
let f = order::fit(&proposed, &members);
|
||||
let same = f.order == members;
|
||||
json!({ "ids": f.order, "added": f.added, "dropped": f.dropped, "same": same })
|
||||
}
|
||||
None => json!({ "ids": [], "added": [], "dropped": [], "same": false, "unreadable": true }),
|
||||
}
|
||||
}
|
||||
|
||||
/// The order enters into force: its articles take the section's places in its order.
|
||||
pub async fn enter(conn: &mut SqliteConnection, st: &DocState, unit: &UnitRow, p: &ProposalRow) -> AppResult<()> {
|
||||
let members = st.members(unit.section.as_deref().unwrap_or_default());
|
||||
// An unreadable order is blocked: it never comes this far.
|
||||
let Some(proposed) = order::decode(&p.body) else { return Ok(()) };
|
||||
let fitted = order::fit(&proposed, &members);
|
||||
let current: Vec<(String, f64)> = members
|
||||
.iter()
|
||||
.filter_map(|id| st.units.iter().find(|u| &u.id == id).map(|u| (id.clone(), u.position)))
|
||||
.collect();
|
||||
for (id, position) in order::positions(&fitted.order, ¤t) {
|
||||
sqlx::query("UPDATE units SET position = ? WHERE id = ? AND doc_id = ? AND kind = 'article'")
|
||||
.bind(position)
|
||||
.bind(&id)
|
||||
.bind(&st.doc.id)
|
||||
.execute(&mut *conn)
|
||||
.await?;
|
||||
}
|
||||
tracing::info!(doc = %st.doc.id, unit = %unit.code, added = fitted.added.len(), dropped = fitted.dropped.len(), "order of a section in force");
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
pub struct OrderBody {
|
||||
/// Every article of the section (unit ids), each once, in the order proposed.
|
||||
order: Vec<String>,
|
||||
}
|
||||
|
||||
/// Propose another order for a section. An open proposal that already reads in
|
||||
/// this order is given back rather than doubled.
|
||||
pub async fn propose(
|
||||
State(state): State<AppState>,
|
||||
User(user): User,
|
||||
Path(unit_id): Path<String>,
|
||||
Json(b): Json<OrderBody>,
|
||||
) -> AppResult<Json<Value>> {
|
||||
let doc_id: String = sqlx::query_scalar("SELECT doc_id FROM units WHERE id = ?")
|
||||
.bind(&unit_id)
|
||||
.fetch_optional(&state.db)
|
||||
.await?
|
||||
.ok_or(AppError::NotFound)?;
|
||||
let st = docs::load_state(&state, &doc_id).await?;
|
||||
docs::require_open(&state, &user, &st.group).await?;
|
||||
let unit = st.units.iter().find(|u| u.id == unit_id).ok_or(AppError::NotFound)?;
|
||||
if st.module_of(&unit.id) != Some(ModuleKind::Order) {
|
||||
return Err(AppError::Invalid("not_an_order"));
|
||||
}
|
||||
if b.order.len() > MAX_ARTICLES {
|
||||
return Err(AppError::Invalid("order"));
|
||||
}
|
||||
let members = st.members(unit.section.as_deref().unwrap_or_default());
|
||||
order::check(&b.order, &members).map_err(|e| match e {
|
||||
OrderError::NotTheSection => AppError::Invalid("order"),
|
||||
OrderError::Unchanged => AppError::Invalid("same_order"),
|
||||
})?;
|
||||
if let Some(existing) = st.proposals.iter().find(|p| {
|
||||
p.unit_id == unit.id
|
||||
&& p.status == "open"
|
||||
&& p.kind == ModuleKind::Order.as_str()
|
||||
&& order::decode(&p.body).is_some_and(|o| order::fit(&o, &members).order == b.order)
|
||||
}) {
|
||||
return Ok(Json(json!({ "id": existing.id, "existing": true })));
|
||||
}
|
||||
let now = util::now();
|
||||
let mut conn = state.db.acquire().await?;
|
||||
let id = modules::insert_version(
|
||||
&mut conn,
|
||||
NewVersion {
|
||||
unit_id: &unit.id,
|
||||
kind: ModuleKind::Order,
|
||||
title: &unit.code,
|
||||
body: &order::canonical(&b.order),
|
||||
author: Some(&user.id),
|
||||
in_force: false,
|
||||
now: &now,
|
||||
},
|
||||
)
|
||||
.await?;
|
||||
Ok(Json(json!({ "id": id, "existing": false })))
|
||||
}
|
||||
@@ -0,0 +1,242 @@
|
||||
//! A group at a glance: what is coming in each tool, and what is left to vote.
|
||||
//!
|
||||
//! What is coming is data, not code: `seeds/upcoming/<group>.json`, one file
|
||||
//! per group, read once at start (titles in fr, en and es). It is served with
|
||||
//! the group (`GET /api/groups/{slug}`, field `upcoming`), only to those who
|
||||
//! open it. What is left to vote (`GET /api/groups/{slug}/decisions`) is one
|
||||
//! line per document, counted by `ld_engine::pending`.
|
||||
|
||||
use crate::AppState;
|
||||
use crate::auth::User;
|
||||
use crate::docs;
|
||||
use crate::error::AppResult;
|
||||
use crate::util::{self, LangMap};
|
||||
use anyhow::{Context, bail};
|
||||
use axum::Json;
|
||||
use axum::extract::{Path, State};
|
||||
use ld_engine::pending::{Module, pending};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use serde_json::{Value, json};
|
||||
use std::collections::{BTreeMap, HashSet};
|
||||
|
||||
/// Something announced in a tool of a group, not open yet.
|
||||
#[derive(Clone, Debug, Deserialize, Serialize)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
pub struct UpcomingItem {
|
||||
pub key: String,
|
||||
pub title: LangMap,
|
||||
}
|
||||
|
||||
/// What is coming in the tools of one group.
|
||||
#[derive(Clone, Debug, Default, Deserialize, Serialize)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
pub struct GroupUpcoming {
|
||||
#[serde(default)]
|
||||
pub docs: Vec<UpcomingItem>,
|
||||
#[serde(default)]
|
||||
pub mandats: Vec<UpcomingItem>,
|
||||
#[serde(default)]
|
||||
pub protocoles: Vec<UpcomingItem>,
|
||||
}
|
||||
|
||||
impl GroupUpcoming {
|
||||
fn check(&self) -> anyhow::Result<()> {
|
||||
for (tool, items) in [("docs", &self.docs), ("mandats", &self.mandats), ("protocoles", &self.protocoles)] {
|
||||
let mut keys = HashSet::new();
|
||||
for item in items {
|
||||
if item.key.trim().is_empty() || !keys.insert(item.key.as_str()) {
|
||||
bail!("{tool}: empty or repeated key « {} »", item.key);
|
||||
}
|
||||
for lang in util::LANGS {
|
||||
if item.title.get(lang).is_none_or(|t| t.trim().is_empty()) {
|
||||
bail!("{tool}/{}: no title in {lang}", item.key);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
/// Every group's announcements, by group slug.
|
||||
#[derive(Clone, Debug, Default)]
|
||||
pub struct Upcoming(BTreeMap<String, GroupUpcoming>);
|
||||
|
||||
impl Upcoming {
|
||||
/// Reads `<seed_dir>/upcoming/*.json`; no directory, nothing coming. A file
|
||||
/// that does not read, or lacks a language, stops the start.
|
||||
pub async fn load(seed_dir: &std::path::Path) -> anyhow::Result<Self> {
|
||||
let dir = seed_dir.join("upcoming");
|
||||
let mut entries = match tokio::fs::read_dir(&dir).await {
|
||||
Ok(e) => e,
|
||||
Err(e) if e.kind() == std::io::ErrorKind::NotFound => return Ok(Self::default()),
|
||||
Err(e) => return Err(e).context(dir.display().to_string()),
|
||||
};
|
||||
let mut out = BTreeMap::new();
|
||||
while let Some(entry) = entries.next_entry().await? {
|
||||
let path = entry.path();
|
||||
if path.extension().is_none_or(|x| x != "json") {
|
||||
continue;
|
||||
}
|
||||
let Some(group) = path.file_stem().and_then(|s| s.to_str()).map(str::to_owned) else { continue };
|
||||
let raw = tokio::fs::read_to_string(&path).await?;
|
||||
let parsed: GroupUpcoming = serde_json::from_str(&raw).with_context(|| path.display().to_string())?;
|
||||
parsed.check().with_context(|| path.display().to_string())?;
|
||||
out.insert(group, parsed);
|
||||
}
|
||||
Ok(Self(out))
|
||||
}
|
||||
|
||||
pub fn of(&self, group: &str) -> GroupUpcoming {
|
||||
self.0.get(group).cloned().unwrap_or_default()
|
||||
}
|
||||
}
|
||||
|
||||
/// The group, its tools and documents (`docs::group`), plus what is coming.
|
||||
pub async fn group(State(state): State<AppState>, user: User, Path(slug): Path<String>) -> AppResult<Json<Value>> {
|
||||
let Json(mut view) = docs::group(State(state.clone()), user, Path(slug.clone())).await?;
|
||||
let open = view["group"]["open"].as_bool().unwrap_or(false);
|
||||
let upcoming = if open { state.upcoming.of(&slug) } else { GroupUpcoming::default() };
|
||||
view["upcoming"] = serde_json::to_value(upcoming).unwrap_or(Value::Null);
|
||||
Ok(Json(view))
|
||||
}
|
||||
|
||||
/// « Décisions »: one line per document in progress, with its modules left to vote.
|
||||
pub async fn decisions(
|
||||
State(state): State<AppState>,
|
||||
User(user): User,
|
||||
Path(slug): Path<String>,
|
||||
) -> AppResult<Json<Value>> {
|
||||
let group = docs::load_group(&state, &slug).await?;
|
||||
docs::require_open(&state, &user, &group).await?;
|
||||
let can_vote = crate::ballots::access(&state, &user, &group).await?["open"].as_bool().unwrap_or(false);
|
||||
#[derive(sqlx::FromRow)]
|
||||
struct DocRow {
|
||||
id: String,
|
||||
slug: String,
|
||||
title: String,
|
||||
}
|
||||
let rows =
|
||||
sqlx::query_as::<_, DocRow>("SELECT id, slug, title FROM documents WHERE group_slug = ? ORDER BY created_at")
|
||||
.bind(&slug)
|
||||
.fetch_all(&state.db)
|
||||
.await?;
|
||||
let mut out = Vec::with_capacity(rows.len());
|
||||
for d in rows {
|
||||
let p = pending(&modules(&state, &d.id, &user.id).await?);
|
||||
out.push(json!({
|
||||
"id": d.id, "slug": d.slug, "title": util::parse_lang(&d.title),
|
||||
"toVote": p.to_vote,
|
||||
"withoutMyVote": can_vote.then_some(p.without_my_vote),
|
||||
}));
|
||||
}
|
||||
Ok(Json(json!({ "canVote": can_vote, "docs": out })))
|
||||
}
|
||||
|
||||
/// The modules of a document as `me` sees them — its articles, its title, the
|
||||
/// order of each section (ADR 0009): the version in force (adopted by a vote,
|
||||
/// or a seed a minima), the open versions, and whether `me` holds a valid vote
|
||||
/// on one of them. A change of inertia is not a version of the text; the order
|
||||
/// of a section of fewer than two articles has nothing to decide; a unit out of the
|
||||
/// document (ADR 0014) is no module to vote; a version held
|
||||
/// by an objection is not counted as one to vote while it is held: it takes no
|
||||
/// new *for*, only against or a withdrawal (ADR 0010).
|
||||
async fn modules(state: &AppState, doc_id: &str, me: &str) -> AppResult<Vec<Module>> {
|
||||
#[derive(sqlx::FromRow)]
|
||||
struct VersionRow {
|
||||
id: String,
|
||||
unit_id: String,
|
||||
in_force: bool,
|
||||
adopted: bool,
|
||||
voted: bool,
|
||||
}
|
||||
let st = docs::load_state(state, doc_id).await?;
|
||||
let moot: HashSet<String> = st
|
||||
.modules(crate::modules::ModuleKind::Order)
|
||||
.into_iter()
|
||||
.filter(|u| ld_engine::order::is_moot(&st.members(u.section.as_deref().unwrap_or_default())))
|
||||
.map(|u| u.id.clone())
|
||||
.collect();
|
||||
let rows = sqlx::query_as::<_, VersionRow>(
|
||||
"SELECT p.id AS id, u.id AS unit_id,
|
||||
IFNULL(p.id = u.in_force_id, 0) AS in_force,
|
||||
(p.entered_at IS NOT NULL OR p.confirmed_at IS NOT NULL OR p.origin <> 'seed') AS adopted,
|
||||
(EXISTS (SELECT 1 FROM votes v
|
||||
WHERE v.proposal_id = p.id AND v.revision = p.revision
|
||||
AND (v.identity_id = ?1 OR v.ballot_id IN
|
||||
(SELECT b.id FROM ballots b WHERE b.requested_by = ?1 AND b.status = 'confirmed')))
|
||||
-- An intention still waiting for the chain is one's vote already (ADR 0012).
|
||||
OR EXISTS (SELECT 1 FROM ballots b JOIN ballot_lines l ON l.ballot_id = b.id
|
||||
WHERE b.requested_by = ?1 AND b.status = 'pending' AND b.mode = 'chain'
|
||||
AND l.proposal_id = p.id AND l.revision = p.revision AND l.choice <> 'none')) AS voted
|
||||
FROM units u JOIN proposals p ON p.unit_id = u.id
|
||||
WHERE u.doc_id = ?2 AND u.retired_at IS NULL AND p.kind <> 'inertia'
|
||||
AND (p.id = u.in_force_id OR p.status = 'open')
|
||||
ORDER BY u.position, u.id",
|
||||
)
|
||||
.bind(me)
|
||||
.bind(doc_id)
|
||||
.fetch_all(&state.db)
|
||||
.await?;
|
||||
let mut out: Vec<Module> = Vec::new();
|
||||
let mut current: Option<String> = None;
|
||||
for r in rows {
|
||||
if current.as_deref() != Some(r.unit_id.as_str()) {
|
||||
out.push(Module { moot: moot.contains(&r.unit_id), ..Module::default() });
|
||||
current = Some(r.unit_id);
|
||||
}
|
||||
if let Some(m) = out.last_mut() {
|
||||
if r.in_force {
|
||||
m.in_force = Some(r.adopted);
|
||||
} else if st.proposals.iter().any(|p| p.id == r.id && st.votable(p)) {
|
||||
m.open += 1;
|
||||
}
|
||||
m.voted |= r.voted;
|
||||
}
|
||||
}
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::Config;
|
||||
|
||||
async fn load(files: &[(&str, &str)]) -> anyhow::Result<Upcoming> {
|
||||
let dir = std::env::temp_dir().join(format!("ld-upcoming-{}", util::id()));
|
||||
tokio::fs::create_dir_all(dir.join("upcoming")).await?;
|
||||
for (name, body) in files {
|
||||
tokio::fs::write(dir.join("upcoming").join(name), body).await?;
|
||||
}
|
||||
let out = Upcoming::load(&dir).await;
|
||||
let _ = tokio::fs::remove_dir_all(&dir).await;
|
||||
out
|
||||
}
|
||||
|
||||
const ITEM: &str = r#"{"key": "k", "title": {"fr": "Un", "en": "One", "es": "Uno"}}"#;
|
||||
|
||||
#[tokio::test]
|
||||
async fn one_file_per_group_and_nothing_elsewhere() {
|
||||
let up =
|
||||
load(&[("axiom.json", &format!(r#"{{"mandats": [{ITEM}]}}"#)), ("notes.txt", "x")]).await.expect("load");
|
||||
assert_eq!(up.of("axiom").mandats.len(), 1);
|
||||
assert!(up.of("axiom").docs.is_empty());
|
||||
assert!(up.of("librodrome").mandats.is_empty());
|
||||
let none = Upcoming::load(&std::env::temp_dir().join("ld-no-such-dir")).await.expect("load");
|
||||
assert!(none.of("axiom").docs.is_empty());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn a_missing_language_an_unknown_tool_or_a_repeated_key_stops_the_start() {
|
||||
let no_es = r#"{"docs": [{"key": "k", "title": {"fr": "Un", "en": "One"}}]}"#;
|
||||
assert!(load(&[("axiom.json", no_es)]).await.is_err());
|
||||
assert!(load(&[("axiom.json", &format!(r#"{{"outils": [{ITEM}]}}"#))]).await.is_err());
|
||||
assert!(load(&[("axiom.json", &format!(r#"{{"docs": [{ITEM}, {ITEM}]}}"#))]).await.is_err());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn the_seeds_of_the_repository_read() {
|
||||
let up = Upcoming::load(&Config::for_tests().seed_dir).await.expect("load");
|
||||
assert_eq!(up.of("monnaie-libre").mandats.len(), 2);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,141 @@
|
||||
//! p̃ — the usual participation of each group, the anchor of the five levels
|
||||
//! of inertia (ADR 0007, `ld_engine::inertia`).
|
||||
//!
|
||||
//! What is measured: the proposals of the group (every document, every kind,
|
||||
//! modules included) that hold at least one vote on their current revision, the
|
||||
//! last one cast within the past year — those whose status is named here, and
|
||||
//! only those: `open` (under vote), `in_force` (in force, still under vote) and
|
||||
//! `applied` (a removal or a change of inertia that passed: a decision taken).
|
||||
//! Drafts (nobody votes on them) and proposals withdrawn by their author (their
|
||||
//! votes can no longer be taken back) stay out, as would any status to come
|
||||
//! until it is named. For each, T is the number of those
|
||||
//! votes **at the time of measuring** — the same T the formula sees, since the
|
||||
//! vote is permanent. The median of these T, and how many proposals it rests
|
||||
//! on, are kept in `settings` under `participation:<group>`.
|
||||
//!
|
||||
//! When: once a day (at start, then by the sweeper), never at each vote — a
|
||||
//! threshold that moved with every vote cast elsewhere could not be read. W
|
||||
//! moves on its own (the web of trust, every six hours): p̃ = T̃ / W follows it
|
||||
//! at once, so the scale still asks s_k of the same usual number of voters.
|
||||
|
||||
use crate::AppState;
|
||||
use crate::util;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use time::OffsetDateTime;
|
||||
use time::format_description::well_known::Rfc3339;
|
||||
|
||||
/// Only proposals voted on within this many days count.
|
||||
pub const WINDOW_DAYS: i64 = 365;
|
||||
/// A measure older than this is taken again.
|
||||
pub const EVERY_HOURS: i64 = 24;
|
||||
|
||||
const KEY: &str = "participation:";
|
||||
|
||||
/// What was measured for a group.
|
||||
#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct Measure {
|
||||
/// The median number of voters, when at least one proposal was voted on.
|
||||
pub median: Option<f64>,
|
||||
/// Proposals it rests on.
|
||||
pub sample: u32,
|
||||
pub measured_at: String,
|
||||
}
|
||||
|
||||
/// The numbers of voters of the group's proposals voted on since `since`, for
|
||||
/// the statuses counted only (the list is explicit: see the head of this file).
|
||||
pub async fn sample(state: &AppState, group: &str, since: &str) -> sqlx::Result<Vec<u32>> {
|
||||
let rows: Vec<i64> = sqlx::query_scalar(
|
||||
"SELECT COUNT(*) FROM votes v
|
||||
JOIN proposals p ON p.id = v.proposal_id
|
||||
JOIN units u ON u.id = p.unit_id
|
||||
JOIN documents d ON d.id = u.doc_id
|
||||
WHERE d.group_slug = ? AND p.status IN ('open', 'in_force', 'applied') AND v.revision = p.revision
|
||||
AND (v.source <> 'simulated' OR ?)
|
||||
GROUP BY v.proposal_id
|
||||
HAVING MAX(v.cast_at) >= ?",
|
||||
)
|
||||
.bind(group)
|
||||
// The simulated votes weigh here as they weigh in adoption (ADR 0012).
|
||||
.bind(state.vote_rules().adopts(ld_engine::buffer::Stage::Simulated))
|
||||
.bind(since)
|
||||
.fetch_all(&state.db)
|
||||
.await?;
|
||||
Ok(rows.into_iter().map(|n| u32::try_from(n).unwrap_or(u32::MAX)).collect())
|
||||
}
|
||||
|
||||
/// Measures the group now.
|
||||
pub async fn measure(state: &AppState, group: &str) -> sqlx::Result<Measure> {
|
||||
let now = OffsetDateTime::now_utc();
|
||||
let since = util::stamp(now - time::Duration::days(WINDOW_DAYS));
|
||||
let votes = sample(state, group, &since).await?;
|
||||
Ok(Measure {
|
||||
median: ld_engine::inertia::median(&votes),
|
||||
sample: u32::try_from(votes.len()).unwrap_or(u32::MAX),
|
||||
measured_at: util::stamp(now),
|
||||
})
|
||||
}
|
||||
|
||||
/// The last measure kept for the group, if any.
|
||||
pub async fn stored(state: &AppState, group: &str) -> sqlx::Result<Option<Measure>> {
|
||||
let raw: Option<String> = sqlx::query_scalar("SELECT value FROM settings WHERE key = ?")
|
||||
.bind(format!("{KEY}{group}"))
|
||||
.fetch_optional(&state.db)
|
||||
.await?;
|
||||
Ok(raw.and_then(|v| serde_json::from_str(&v).ok()))
|
||||
}
|
||||
|
||||
async fn keep(state: &AppState, group: &str, m: &Measure) -> anyhow::Result<()> {
|
||||
sqlx::query(
|
||||
"INSERT INTO settings (key, value, updated_at) VALUES (?, ?, ?)
|
||||
ON CONFLICT(key) DO UPDATE SET value = excluded.value, updated_at = excluded.updated_at",
|
||||
)
|
||||
.bind(format!("{KEY}{group}"))
|
||||
.bind(serde_json::to_string(m)?)
|
||||
.bind(&m.measured_at)
|
||||
.execute(&state.db)
|
||||
.await?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn is_stale(m: &Measure, now: OffsetDateTime) -> bool {
|
||||
OffsetDateTime::parse(&m.measured_at, &Rfc3339)
|
||||
.map_or(true, |at| now - at >= time::Duration::hours(EVERY_HOURS) || at > now)
|
||||
}
|
||||
|
||||
/// Measures again every group whose measure is missing or a day old (all of
|
||||
/// them with `force`). Returns the groups measured.
|
||||
pub async fn refresh(state: &AppState, force: bool) -> anyhow::Result<Vec<String>> {
|
||||
let groups: Vec<String> =
|
||||
sqlx::query_scalar("SELECT slug FROM groups ORDER BY position").fetch_all(&state.db).await?;
|
||||
let now = OffsetDateTime::now_utc();
|
||||
let mut done = Vec::new();
|
||||
for group in groups {
|
||||
let due = force || stored(state, &group).await?.is_none_or(|m| is_stale(&m, now));
|
||||
if !due {
|
||||
continue;
|
||||
}
|
||||
let m = measure(state, &group).await?;
|
||||
keep(state, &group, &m).await?;
|
||||
tracing::info!(%group, median = ?m.median, sample = m.sample, "usual participation measured");
|
||||
done.push(group);
|
||||
}
|
||||
Ok(done)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn a_measure_is_taken_again_after_a_day() {
|
||||
let now = OffsetDateTime::now_utc();
|
||||
let at =
|
||||
|hours: i64| Measure { measured_at: util::stamp(now - time::Duration::hours(hours)), ..Measure::default() };
|
||||
assert!(!is_stale(&at(1), now));
|
||||
assert!(!is_stale(&at(23), now));
|
||||
assert!(is_stale(&at(24), now));
|
||||
assert!(is_stale(&at(-2), now), "a measure from the future is taken again");
|
||||
assert!(is_stale(&Measure::default(), now), "unreadable");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,766 @@
|
||||
//! Writing: drafts, submission, corrections, votes, inertia changes, editorial
|
||||
//! translations. Objections live in `objections`, typo fixes of others in `typos`.
|
||||
//!
|
||||
//! Editing is not voting (Yvv, 2026-10-21: « il ne faut pas que ça devienne un
|
||||
//! système qui nécessite un vote pour corriger une coquille »):
|
||||
//! - a **draft** is free: its author edits it at will, nobody votes on it;
|
||||
//! - once **submitted**, a correction classified *typographic* by the engine
|
||||
//! keeps every vote and is shown with its diff; a *substantive* one opens a
|
||||
//! new revision and every earlier vote waits for its author to reconfirm;
|
||||
//! - any member of the group may write a typo fix on a version; it applies at
|
||||
//! once when its writer answers for the version (its author; an administrator
|
||||
//! for a text without author), and waits for them otherwise — three days at
|
||||
//! most (2026-10-31, `typos`). A change of meaning goes through a proposal.
|
||||
|
||||
use crate::AppState;
|
||||
use crate::adoption;
|
||||
use crate::auth::{IdentityRow, User};
|
||||
use crate::ballots;
|
||||
use crate::docs::{self, DocState, PROPOSAL_SELECT, ProposalRow};
|
||||
use crate::error::{AppError, AppResult};
|
||||
use crate::{modules, objections, typos, util};
|
||||
use axum::Json;
|
||||
use axum::extract::{Path, State};
|
||||
use ld_engine::assist::{self, Hint};
|
||||
use ld_engine::ballot::Choice;
|
||||
use ld_engine::edit::{self, EditKind};
|
||||
use ld_engine::inertia::InertiaLevel;
|
||||
use serde::Deserialize;
|
||||
use serde_json::json;
|
||||
|
||||
const MAX_TITLE: usize = 120;
|
||||
const MAX_BODY: usize = 4000;
|
||||
|
||||
pub(crate) async fn load_proposal(state: &AppState, id: &str) -> AppResult<ProposalRow> {
|
||||
sqlx::query_as::<_, ProposalRow>(&format!("{PROPOSAL_SELECT} WHERE p.id = ?"))
|
||||
.bind(id)
|
||||
.fetch_optional(&state.db)
|
||||
.await?
|
||||
.ok_or(AppError::NotFound)
|
||||
}
|
||||
|
||||
async fn doc_of_unit(state: &AppState, unit_id: &str) -> AppResult<String> {
|
||||
sqlx::query_scalar("SELECT doc_id FROM units WHERE id = ?")
|
||||
.bind(unit_id)
|
||||
.fetch_optional(&state.db)
|
||||
.await?
|
||||
.ok_or(AppError::NotFound)
|
||||
}
|
||||
|
||||
/// Loads the document of a proposal and checks the person may open its group.
|
||||
pub(crate) async fn guarded(
|
||||
state: &AppState,
|
||||
user: &IdentityRow,
|
||||
proposal_id: &str,
|
||||
) -> AppResult<(ProposalRow, DocState)> {
|
||||
let p = load_proposal(state, proposal_id).await?;
|
||||
let doc_id = doc_of_unit(state, &p.unit_id).await?;
|
||||
let st = docs::load_state(state, &doc_id).await?;
|
||||
docs::require_open(state, user, &st.group).await?;
|
||||
if p.status == "draft" && p.author_id.as_deref() != Some(user.id.as_str()) {
|
||||
return Err(AppError::NotFound);
|
||||
}
|
||||
// A version of a unit out of the document (ADR 0014) is no longer in it: nothing is
|
||||
// read, voted, corrected, translated or objected on it.
|
||||
if !st.units.iter().any(|u| u.id == p.unit_id) {
|
||||
return Err(AppError::NotFound);
|
||||
}
|
||||
Ok((p, st))
|
||||
}
|
||||
|
||||
fn check_text(title: &str, body: &str, needs_body: bool) -> AppResult<()> {
|
||||
if edit::has_hidden_controls(title) || edit::has_hidden_controls(body) {
|
||||
return Err(AppError::Invalid("hidden_characters"));
|
||||
}
|
||||
if title.trim().is_empty() || title.chars().count() > MAX_TITLE || title.contains(['\n', '\r']) {
|
||||
return Err(AppError::Invalid("title"));
|
||||
}
|
||||
if (needs_body && body.trim().is_empty()) || body.chars().count() > MAX_BODY {
|
||||
return Err(AppError::Invalid("body"));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// The text the votes were cast on: its last initial or substantive revision, or the
|
||||
/// starting text a seed amendment put in place (`seed_amend`).
|
||||
async fn voted_text(state: &AppState, p: &ProposalRow) -> AppResult<(String, String)> {
|
||||
let row: Option<(String, String)> = sqlx::query_as(
|
||||
"SELECT title, body FROM revisions WHERE proposal_id = ? AND kind IN ('initial', 'substantive', 'amendment')
|
||||
ORDER BY id DESC LIMIT 1",
|
||||
)
|
||||
.bind(&p.id)
|
||||
.fetch_optional(&state.db)
|
||||
.await?;
|
||||
Ok(row.unwrap_or_else(|| (p.title.clone(), p.body.clone())))
|
||||
}
|
||||
|
||||
async fn record_revision(
|
||||
state: &AppState,
|
||||
proposal_id: &str,
|
||||
revision: i64,
|
||||
kind: &str,
|
||||
title: &str,
|
||||
body: &str,
|
||||
author: Option<&str>,
|
||||
) -> AppResult<()> {
|
||||
sqlx::query(
|
||||
"INSERT INTO revisions (proposal_id, revision, kind, title, body, author_id, created_at) VALUES (?, ?, ?, ?, ?, ?, ?)",
|
||||
)
|
||||
.bind(proposal_id)
|
||||
.bind(revision)
|
||||
.bind(kind)
|
||||
.bind(title)
|
||||
.bind(body)
|
||||
.bind(author)
|
||||
.bind(util::now())
|
||||
.execute(&state.db)
|
||||
.await?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Assistant
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
#[derive(Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct AssistBody {
|
||||
text: String,
|
||||
proposal_id: Option<String>,
|
||||
}
|
||||
|
||||
pub async fn assist(
|
||||
State(state): State<AppState>,
|
||||
User(user): User,
|
||||
Path(doc_id): Path<String>,
|
||||
Json(body): Json<AssistBody>,
|
||||
) -> AppResult<Json<serde_json::Value>> {
|
||||
let st = docs::load_state(&state, &doc_id).await?;
|
||||
docs::require_open(&state, &user, &st.group).await?;
|
||||
let others: Vec<(String, String)> = st
|
||||
.proposals
|
||||
.iter()
|
||||
.filter(|p| p.status != "draft" && Some(&p.id) != body.proposal_id.as_ref() && p.kind == "text")
|
||||
.map(|p| (p.id.clone(), format!("{}\n{}", p.title, p.body)))
|
||||
.collect();
|
||||
let hints: Vec<_> = assist::hints(&body.text, &others)
|
||||
.into_iter()
|
||||
.map(|h| match h {
|
||||
Hint::Similar { target, score } => {
|
||||
let p = st.proposals.iter().find(|p| p.id == target);
|
||||
let unit = p.and_then(|p| st.units.iter().find(|u| u.id == p.unit_id));
|
||||
json!({
|
||||
"kind": "similar", "score": score, "proposalId": target,
|
||||
"unitId": unit.map(|u| &u.id), "code": unit.map(|u| &u.code),
|
||||
"title": p.map(|p| &p.title), "inForce": p.is_some_and(|p| p.status == "in_force"),
|
||||
})
|
||||
}
|
||||
other => serde_json::to_value(other).unwrap_or_default(),
|
||||
})
|
||||
.collect();
|
||||
Ok(Json(json!({ "hints": hints })))
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Create, edit, submit, withdraw, split
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
#[derive(Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct CreateBody {
|
||||
kind: Option<String>,
|
||||
unit_id: Option<String>,
|
||||
/// The section a new article goes in, at its end (ADR 0009).
|
||||
section: Option<String>,
|
||||
/// Or an article of that section (an older client).
|
||||
after_unit_id: Option<String>,
|
||||
title: String,
|
||||
#[serde(default)]
|
||||
body: String,
|
||||
#[serde(default)]
|
||||
submit: bool,
|
||||
/// Written to answer an objection (« modifier l'article visé »): on the
|
||||
/// article the held version repeats or contradicts, or on its own.
|
||||
objection_id: Option<String>,
|
||||
}
|
||||
|
||||
/// A new article goes at the end of its section (ADR 0009): the order of the
|
||||
/// section is a module of its own, voted apart.
|
||||
async fn new_unit(state: &AppState, st: &DocState, section: Option<&str>) -> AppResult<String> {
|
||||
let id = util::id();
|
||||
let n: i64 = sqlx::query_scalar("SELECT COUNT(*) FROM units WHERE doc_id = ? AND code LIKE 'N%'")
|
||||
.bind(&st.doc.id)
|
||||
.fetch_one(&state.db)
|
||||
.await?;
|
||||
let places: Vec<(Option<&str>, f64)> = st.articles().map(|u| (u.section.as_deref(), u.position)).collect();
|
||||
let position = ld_engine::order::place_at_end(&places, section);
|
||||
sqlx::query(
|
||||
"INSERT INTO units (id, doc_id, code, section, position, inertia, in_force_id, created_at)
|
||||
VALUES (?, ?, ?, ?, ?, ?, NULL, ?)",
|
||||
)
|
||||
.bind(&id)
|
||||
.bind(&st.doc.id)
|
||||
.bind(format!("N{}", n + 1))
|
||||
.bind(section)
|
||||
.bind(position)
|
||||
.bind(InertiaLevel::DEFAULT.as_db())
|
||||
.bind(util::now())
|
||||
.execute(&state.db)
|
||||
.await?;
|
||||
Ok(id)
|
||||
}
|
||||
|
||||
struct NewProposal<'a> {
|
||||
unit_id: &'a str,
|
||||
kind: &'a str,
|
||||
inertia_target: Option<&'a str>,
|
||||
title: &'a str,
|
||||
body: &'a str,
|
||||
author: &'a str,
|
||||
submit: bool,
|
||||
}
|
||||
|
||||
async fn insert_proposal(state: &AppState, n: NewProposal<'_>) -> AppResult<String> {
|
||||
let NewProposal { unit_id, kind, inertia_target, title, body, author, submit } = n;
|
||||
let id = util::id();
|
||||
let now = util::now();
|
||||
sqlx::query(
|
||||
"INSERT INTO proposals (id, unit_id, kind, inertia_target, title, body, status, origin, author_id, revision,
|
||||
created_at, updated_at, submitted_at)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, 'member', ?, 1, ?, ?, ?)",
|
||||
)
|
||||
.bind(&id)
|
||||
.bind(unit_id)
|
||||
.bind(kind)
|
||||
.bind(inertia_target)
|
||||
.bind(title.trim())
|
||||
.bind(body.trim())
|
||||
.bind(if submit { "open" } else { "draft" })
|
||||
.bind(author)
|
||||
.bind(&now)
|
||||
.bind(&now)
|
||||
.bind(submit.then_some(&now))
|
||||
.execute(&state.db)
|
||||
.await?;
|
||||
record_revision(state, &id, 1, "initial", title.trim(), body.trim(), Some(author)).await?;
|
||||
Ok(id)
|
||||
}
|
||||
|
||||
pub async fn create(
|
||||
State(state): State<AppState>,
|
||||
User(user): User,
|
||||
Path(doc_id): Path<String>,
|
||||
Json(b): Json<CreateBody>,
|
||||
) -> AppResult<Json<serde_json::Value>> {
|
||||
let st = docs::load_state(&state, &doc_id).await?;
|
||||
docs::require_open(&state, &user, &st.group).await?;
|
||||
let kind = match b.kind.as_deref().unwrap_or("text") {
|
||||
"text" => "text",
|
||||
"remove" => "remove",
|
||||
_ => return Err(AppError::Invalid("kind")),
|
||||
};
|
||||
check_text(&b.title, &b.body, kind == "text")?;
|
||||
let unit_id = match b.unit_id.as_deref() {
|
||||
Some(u) => {
|
||||
let unit = st.units.iter().find(|x| x.id == u).ok_or(AppError::NotFound)?;
|
||||
modules::require_article(&st, &unit.id)?;
|
||||
if kind == "remove" && unit.in_force_id.is_none() {
|
||||
return Err(AppError::Invalid("nothing_to_remove"));
|
||||
}
|
||||
unit.id.clone()
|
||||
}
|
||||
None if kind == "text" && b.objection_id.is_none() => {
|
||||
// A new article belongs to the section asked for, or to that of the article named.
|
||||
let after = b.after_unit_id.as_deref().and_then(|a| st.articles().find(|u| u.id == a));
|
||||
let section = match b.section.as_deref() {
|
||||
Some(s) if st.sections().iter().any(|x| x == s) => Some(s),
|
||||
Some(_) => return Err(AppError::Invalid("section")),
|
||||
None => after.and_then(|u| u.section.as_deref()),
|
||||
};
|
||||
new_unit(&state, &st, section).await?
|
||||
}
|
||||
None if kind == "text" => return Err(AppError::Invalid("link")),
|
||||
None => return Err(AppError::Invalid("unit")),
|
||||
};
|
||||
if let Some(oid) = &b.objection_id {
|
||||
objections::check_link(&state, &user, oid, &unit_id).await?;
|
||||
}
|
||||
let id = insert_proposal(
|
||||
&state,
|
||||
NewProposal {
|
||||
unit_id: &unit_id,
|
||||
kind,
|
||||
inertia_target: None,
|
||||
title: &b.title,
|
||||
body: &b.body,
|
||||
author: &user.id,
|
||||
submit: b.submit,
|
||||
},
|
||||
)
|
||||
.await?;
|
||||
if let Some(oid) = &b.objection_id {
|
||||
sqlx::query("UPDATE proposals SET objection_id = ? WHERE id = ?")
|
||||
.bind(oid)
|
||||
.bind(&id)
|
||||
.execute(&state.db)
|
||||
.await?;
|
||||
if b.submit {
|
||||
objections::on_linked(&state, oid, &id, &user.id).await?;
|
||||
}
|
||||
}
|
||||
Ok(Json(json!({ "id": id, "unitId": unit_id })))
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
pub struct EditBody {
|
||||
title: String,
|
||||
body: String,
|
||||
}
|
||||
|
||||
pub async fn edit(
|
||||
State(state): State<AppState>,
|
||||
User(user): User,
|
||||
Path(id): Path<String>,
|
||||
Json(b): Json<EditBody>,
|
||||
) -> AppResult<Json<serde_json::Value>> {
|
||||
let (p, st) = guarded(&state, &user, &id).await?;
|
||||
modules::require_text(&p)?;
|
||||
check_text(&b.title, &b.body, p.kind == "text")?;
|
||||
let (title, body) = (b.title.trim(), b.body.trim());
|
||||
let is_author = p.author_id.as_deref() == Some(user.id.as_str());
|
||||
let now = util::now();
|
||||
|
||||
if p.status == "draft" {
|
||||
if !is_author {
|
||||
return Err(AppError::Forbidden);
|
||||
}
|
||||
sqlx::query("UPDATE proposals SET title = ?, body = ?, updated_at = ? WHERE id = ?")
|
||||
.bind(title)
|
||||
.bind(body)
|
||||
.bind(&now)
|
||||
.bind(&id)
|
||||
.execute(&state.db)
|
||||
.await?;
|
||||
return Ok(Json(json!({ "edit": "draft" })));
|
||||
}
|
||||
if !matches!(p.status.as_str(), "open" | "in_force") {
|
||||
return Err(AppError::Conflict("closed"));
|
||||
}
|
||||
// Measured against the text that was voted, not the current one: successive
|
||||
// typo fixes are judged together and never add up to a change of meaning.
|
||||
// Title and body are judged apart (a title never absorbs the start of the
|
||||
// body) and together (two slips at most in all).
|
||||
let next = format!("{title}\n{body}");
|
||||
let kind = if title == p.title && body == p.body {
|
||||
EditKind::Unchanged
|
||||
} else {
|
||||
let (voted_title, voted_body) = voted_text(&state, &p).await?;
|
||||
let kinds = [
|
||||
edit::classify(&voted_title, title),
|
||||
edit::classify(&voted_body, body),
|
||||
edit::classify(&format!("{voted_title}\n{voted_body}"), &next),
|
||||
];
|
||||
// All unchanged: back to the voted text, itself a correction.
|
||||
if kinds.contains(&EditKind::Substantive) { EditKind::Substantive } else { EditKind::Typographic }
|
||||
};
|
||||
match kind {
|
||||
EditKind::Unchanged => Ok(Json(json!({ "edit": "unchanged" }))),
|
||||
// Anyone in the group may fix a typo; who applies it, and when, is `typos`.
|
||||
EditKind::Typographic => typos::write(&state, &user, &p, &st, title, body).await,
|
||||
EditKind::Substantive => {
|
||||
if p.status == "in_force" {
|
||||
return Err(AppError::Conflict("substantive_in_force"));
|
||||
}
|
||||
if !is_author {
|
||||
return Err(AppError::Conflict("substantive_not_author"));
|
||||
}
|
||||
let revision = p.revision + 1;
|
||||
// Only on the open proposal that was read: never on a text that entered into
|
||||
// force in the meantime.
|
||||
let done = sqlx::query(
|
||||
"UPDATE proposals SET title = ?, body = ?, revision = ?, updated_at = ?
|
||||
WHERE id = ? AND revision = ? AND status = 'open' AND title = ? AND body = ?",
|
||||
)
|
||||
.bind(title)
|
||||
.bind(body)
|
||||
.bind(revision)
|
||||
.bind(&now)
|
||||
.bind(&id)
|
||||
.bind(p.revision)
|
||||
.bind(&p.title)
|
||||
.bind(&p.body)
|
||||
.execute(&state.db)
|
||||
.await?;
|
||||
if done.rows_affected() != 1 {
|
||||
return Err(AppError::Conflict("changed"));
|
||||
}
|
||||
record_revision(&state, &id, revision, "substantive", title, body, Some(&user.id)).await?;
|
||||
// It answers the objections around it (the objector judges again), and
|
||||
// the typo fixes waiting on the former text no longer fit.
|
||||
objections::on_change(&state, &id, true, &user.id).await?;
|
||||
typos::on_text_changed(&state, &id).await?;
|
||||
Ok(Json(json!({ "edit": "substantive", "revision": revision })))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn submit(
|
||||
State(state): State<AppState>,
|
||||
User(user): User,
|
||||
Path(id): Path<String>,
|
||||
) -> AppResult<Json<serde_json::Value>> {
|
||||
let (p, st) = guarded(&state, &user, &id).await?;
|
||||
if p.author_id.as_deref() != Some(user.id.as_str()) {
|
||||
return Err(AppError::Forbidden);
|
||||
}
|
||||
if p.status != "draft" {
|
||||
return Err(AppError::Conflict("not_draft"));
|
||||
}
|
||||
// Drafts written under older rules are checked again before the vote opens.
|
||||
check_text(&p.title, &p.body, p.kind == "text")?;
|
||||
let now = util::now();
|
||||
sqlx::query("UPDATE proposals SET status = 'open', submitted_at = ?, updated_at = ? WHERE id = ?")
|
||||
.bind(&now)
|
||||
.bind(&now)
|
||||
.bind(&id)
|
||||
.execute(&state.db)
|
||||
.await?;
|
||||
// A draft is edited freely: the text submitted is the one votes will be cast on.
|
||||
let initial = sqlx::query("UPDATE revisions SET title = ?, body = ? WHERE proposal_id = ? AND kind = 'initial'")
|
||||
.bind(&p.title)
|
||||
.bind(&p.body)
|
||||
.bind(&id)
|
||||
.execute(&state.db)
|
||||
.await?;
|
||||
if initial.rows_affected() == 0 {
|
||||
record_revision(&state, &id, p.revision, "initial", &p.title, &p.body, Some(&user.id)).await?;
|
||||
}
|
||||
// Written to answer an objection: submitted, it answers it (a draft answers nothing).
|
||||
let linked: Option<String> =
|
||||
sqlx::query_scalar("SELECT objection_id FROM proposals WHERE id = ?").bind(&id).fetch_one(&state.db).await?;
|
||||
if let Some(oid) = linked {
|
||||
objections::on_linked(&state, &oid, &id, &user.id).await?;
|
||||
}
|
||||
let entered = adoption::run(&state, &st.doc.id).await?;
|
||||
Ok(Json(json!({ "status": "open", "entered": entered })))
|
||||
}
|
||||
|
||||
pub async fn withdraw(
|
||||
State(state): State<AppState>,
|
||||
User(user): User,
|
||||
Path(id): Path<String>,
|
||||
) -> AppResult<Json<serde_json::Value>> {
|
||||
let (p, st) = guarded(&state, &user, &id).await?;
|
||||
if p.author_id.as_deref() != Some(user.id.as_str()) {
|
||||
return Err(AppError::Forbidden);
|
||||
}
|
||||
if !matches!(p.status.as_str(), "draft" | "open") {
|
||||
return Err(AppError::Conflict("closed"));
|
||||
}
|
||||
sqlx::query("UPDATE proposals SET status = 'withdrawn', left_at = ?, updated_at = ? WHERE id = ?")
|
||||
.bind(util::now())
|
||||
.bind(util::now())
|
||||
.bind(&id)
|
||||
.execute(&state.db)
|
||||
.await?;
|
||||
// Nothing left to object to, nor to correct.
|
||||
objections::on_withdrawn(&state, &id, &user.id).await?;
|
||||
typos::on_text_changed(&state, &id).await?;
|
||||
// A version its objection held back may enter now, with its votes (relecture of cd7e683).
|
||||
adoption::run(&state, &st.doc.id).await?;
|
||||
Ok(Json(json!({ "status": "withdrawn" })))
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
pub struct SplitBody {
|
||||
parts: Vec<String>,
|
||||
}
|
||||
|
||||
/// One draft becomes several: the first part stays, each other part becomes a
|
||||
/// draft on a new unit at the end of its section, in turn. Drafts only — nobody
|
||||
/// voted yet.
|
||||
pub async fn split(
|
||||
State(state): State<AppState>,
|
||||
User(user): User,
|
||||
Path(id): Path<String>,
|
||||
Json(b): Json<SplitBody>,
|
||||
) -> AppResult<Json<serde_json::Value>> {
|
||||
let (p, st) = guarded(&state, &user, &id).await?;
|
||||
if p.author_id.as_deref() != Some(user.id.as_str()) {
|
||||
return Err(AppError::Forbidden);
|
||||
}
|
||||
if p.status != "draft" || p.kind != "text" {
|
||||
return Err(AppError::Conflict("not_draft"));
|
||||
}
|
||||
let parts: Vec<&str> = b.parts.iter().map(|s| s.trim()).filter(|s| !s.is_empty()).collect();
|
||||
if parts.len() < 2 || parts.len() > 12 || parts.iter().any(|s| s.chars().count() > MAX_BODY) {
|
||||
return Err(AppError::Invalid("parts"));
|
||||
}
|
||||
if parts.iter().any(|s| edit::has_hidden_controls(s)) {
|
||||
return Err(AppError::Invalid("hidden_characters"));
|
||||
}
|
||||
sqlx::query("UPDATE proposals SET body = ?, updated_at = ? WHERE id = ?")
|
||||
.bind(parts[0])
|
||||
.bind(util::now())
|
||||
.bind(&id)
|
||||
.execute(&state.db)
|
||||
.await?;
|
||||
let mut created = vec![id.clone()];
|
||||
let section = st.articles().find(|u| u.id == p.unit_id).and_then(|u| u.section.clone());
|
||||
for (n, part) in parts.iter().enumerate().skip(1) {
|
||||
let fresh = docs::load_state(&state, &st.doc.id).await?;
|
||||
let unit = new_unit(&state, &fresh, section.as_deref()).await?;
|
||||
let title = format!("{} ({})", p.title, n + 1);
|
||||
let title: String = title.chars().take(MAX_TITLE).collect();
|
||||
created.push(
|
||||
insert_proposal(
|
||||
&state,
|
||||
NewProposal {
|
||||
unit_id: &unit,
|
||||
kind: "text",
|
||||
inertia_target: None,
|
||||
title: &title,
|
||||
body: part,
|
||||
author: &user.id,
|
||||
submit: false,
|
||||
},
|
||||
)
|
||||
.await?,
|
||||
);
|
||||
}
|
||||
Ok(Json(json!({ "drafts": created })))
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Detail
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
pub async fn detail(
|
||||
State(state): State<AppState>,
|
||||
User(user): User,
|
||||
Path(id): Path<String>,
|
||||
) -> AppResult<Json<serde_json::Value>> {
|
||||
let (p, st) = guarded(&state, &user, &id).await?;
|
||||
let unit = st.units.iter().find(|u| u.id == p.unit_id).ok_or(AppError::NotFound)?;
|
||||
let in_force = match &unit.in_force_id {
|
||||
Some(fid) if *fid != p.id => Some(load_proposal(&state, fid).await?),
|
||||
_ => None,
|
||||
};
|
||||
|
||||
#[derive(sqlx::FromRow, serde::Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
struct Rev {
|
||||
revision: i64,
|
||||
kind: String,
|
||||
title: String,
|
||||
body: String,
|
||||
author_name: Option<String>,
|
||||
author_invited: Option<bool>,
|
||||
created_at: String,
|
||||
typo_id: Option<String>,
|
||||
answered_by: Option<String>,
|
||||
answered_name: Option<String>,
|
||||
}
|
||||
let revs = sqlx::query_as::<_, Rev>(
|
||||
"SELECT r.revision, r.kind, r.title, r.body, i.display_name AS author_name,
|
||||
i.kind = 'invite' AS author_invited, r.created_at, r.typo_id, r.answered_by, a.display_name AS answered_name
|
||||
FROM revisions r LEFT JOIN identities i ON i.id = r.author_id LEFT JOIN identities a ON a.id = r.answered_by
|
||||
WHERE r.proposal_id = ? ORDER BY r.id",
|
||||
)
|
||||
.bind(&id)
|
||||
.fetch_all(&state.db)
|
||||
.await?;
|
||||
// Each correction with the diff from the text before it; a fix someone else
|
||||
// wrote says who let it in (or that it passed at its deadline).
|
||||
let mut history = Vec::new();
|
||||
let mut previous: Option<&Rev> = None;
|
||||
for r in &revs {
|
||||
// A change of status (a seed proposal, ADR 0011) changes no text: no diff.
|
||||
let diff = previous
|
||||
.filter(|_| r.kind != "seed_proposal")
|
||||
.map(|prev| edit::diff(&format!("{}\n{}", prev.title, prev.body), &format!("{}\n{}", r.title, r.body)));
|
||||
let typo = r.typo_id.as_ref().map(|t| {
|
||||
json!({ "id": t, "answeredBy": r.answered_name, "deadline": r.answered_by.as_deref() == Some("deadline") })
|
||||
});
|
||||
history.push(json!({
|
||||
"revision": r.revision, "kind": r.kind, "author": r.author_name,
|
||||
"authorInvited": r.author_invited.unwrap_or(false), "at": r.created_at, "diff": diff, "typo": typo,
|
||||
}));
|
||||
previous = Some(r);
|
||||
}
|
||||
let objections = objections::around(&state, &st, &user, &id).await?;
|
||||
let typos = typos::of_proposal(&state, &user, &p).await?;
|
||||
|
||||
let my_vote = ballots::my_votes(&state, &user.id, &st.doc.id).await?.remove(&id);
|
||||
let my_ballot = ballots::my_ballots(&state, &user.id, &st.doc.id).await?.remove(&id);
|
||||
let seen = ballots::seen(p.revision, my_vote.as_ref(), my_ballot.as_ref());
|
||||
let diff = match (&in_force, p.kind.as_str()) {
|
||||
(Some(v), "text") => Some(edit::diff(&v.body, &p.body)),
|
||||
_ => None,
|
||||
};
|
||||
let eval = st.shown_evaluation(unit, &p);
|
||||
let voted_at = st.voting_level(unit, &p);
|
||||
let target = p.inertia_target.as_deref().and_then(InertiaLevel::parse);
|
||||
let tr = docs::translations_for(&state, &[&p]).await?;
|
||||
Ok(Json(json!({
|
||||
"proposal": {
|
||||
"id": p.id, "kind": p.kind, "inertiaTarget": target, "title": p.title, "body": p.body,
|
||||
"status": p.status, "origin": p.origin, "label": p.label, "revision": p.revision,
|
||||
"author": p.author_name, "authorInvited": p.author_invited.unwrap_or(false),
|
||||
"mine": p.author_id.as_deref() == Some(user.id.as_str()),
|
||||
"submittedAt": p.submitted_at, "enteredAt": p.entered_at, "confirmedAt": p.confirmed_at,
|
||||
"seedMinimal": p.seed_minimal(), "seedProposal": p.seed_proposal(),
|
||||
"tally": st.shown_tally(&p.id), "tallyPending": st.pending_tally(&p.id),
|
||||
"tallySimulated": st.simulated_tally(&p.id), "evaluation": eval, "votedAtInertia": voted_at,
|
||||
"blocked": st.blocked.contains(&p.id), "votable": st.votable(&p),
|
||||
"myVote": seen.choice, "myVoteState": seen.state, "myVoteStale": seen.stale,
|
||||
"myVoteChain": seen.chain, "myBallot": my_ballot,
|
||||
"translations": tr.get(&p.id).cloned().unwrap_or_default(),
|
||||
},
|
||||
"unit": { "id": unit.id, "code": unit.code, "inertia": docs::inertia_of(unit) },
|
||||
"inForce": in_force.map(|v| json!({ "id": v.id, "title": v.title, "body": v.body })),
|
||||
"diff": diff,
|
||||
"history": history,
|
||||
"objections": objections,
|
||||
"typos": typos,
|
||||
"electorate": st.electorate,
|
||||
"electorateSource": st.electorate_source,
|
||||
"voteFloor": st.doc.vote_floor,
|
||||
"participation": st.participation_json(),
|
||||
// The five levels, with the *for* votes each would ask at this version's participation.
|
||||
"levels": st.levels_json(st.shown_tally(&p.id).cast(), unit.own_quorum()),
|
||||
})))
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Votes
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
#[derive(Deserialize)]
|
||||
pub struct VoteBody {
|
||||
choice: Option<String>,
|
||||
}
|
||||
|
||||
/// One vote written by the server: in a managed group, or in simulation while
|
||||
/// the chain is closed (ADR 0012). Where the vote goes through the chain and the
|
||||
/// chain is open (Monnaie libre), it is a ballot (`ballots::create`), never this.
|
||||
pub async fn vote(
|
||||
State(state): State<AppState>,
|
||||
User(user): User,
|
||||
Path(id): Path<String>,
|
||||
Json(b): Json<VoteBody>,
|
||||
) -> AppResult<Json<serde_json::Value>> {
|
||||
let (p, st) = guarded(&state, &user, &id).await?;
|
||||
let way = ballots::server_way(&state, &st.group)?;
|
||||
if !matches!(p.status.as_str(), "open" | "in_force") {
|
||||
return Err(AppError::Conflict("not_votable"));
|
||||
}
|
||||
let choice = match b.choice.as_deref() {
|
||||
Some("for") => Choice::For,
|
||||
Some("against") => Choice::Against,
|
||||
None => Choice::None,
|
||||
Some(_) => return Err(AppError::Invalid("choice")),
|
||||
};
|
||||
// Held back by an objection: its votes stay; against or a withdrawal, never a
|
||||
// new *for* (ADR 0010, Yvv 2026-11-01: « Retirer ou "contre" »).
|
||||
if !st.admits(&p, choice) {
|
||||
return Err(AppError::Conflict("objection"));
|
||||
}
|
||||
if !ballots::may_write(&state, &user, &st.group, way).await? {
|
||||
return Err(AppError::Conflict("members_only"));
|
||||
}
|
||||
let cast = ballots::Cast { proposal_id: p.id.clone(), revision: p.revision, choice };
|
||||
let entered = ballots::write(&state, &user, &st, way, &[cast]).await?;
|
||||
Ok(Json(json!({ "entered": entered, "simulated": way == ballots::ServerWay::Simulated })))
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Inertia
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
#[derive(Deserialize)]
|
||||
pub struct InertiaBody {
|
||||
/// A level, 1 to 5 (a word of before is read as its level).
|
||||
target: serde_json::Value,
|
||||
}
|
||||
|
||||
/// Propose a change of inertia; it is voted at the level of the unit (it
|
||||
/// inherits it, ADR 0007). An open proposal for the same target is reused
|
||||
/// rather than duplicated.
|
||||
pub async fn propose_inertia(
|
||||
State(state): State<AppState>,
|
||||
User(user): User,
|
||||
Path(unit_id): Path<String>,
|
||||
Json(b): Json<InertiaBody>,
|
||||
) -> AppResult<Json<serde_json::Value>> {
|
||||
let target = serde_json::from_value::<InertiaLevel>(b.target).map_err(|_| AppError::Invalid("inertia"))?;
|
||||
let doc_id = doc_of_unit(&state, &unit_id).await?;
|
||||
let st = docs::load_state(&state, &doc_id).await?;
|
||||
docs::require_open(&state, &user, &st.group).await?;
|
||||
let unit = st.units.iter().find(|u| u.id == unit_id).ok_or(AppError::NotFound)?;
|
||||
if docs::inertia_of(unit) == target {
|
||||
return Err(AppError::Invalid("same_inertia"));
|
||||
}
|
||||
if let Some(existing) = st.proposals.iter().find(|p| {
|
||||
p.unit_id == unit_id
|
||||
&& p.kind == "inertia"
|
||||
&& p.status == "open"
|
||||
&& p.inertia_target.as_deref().and_then(InertiaLevel::parse) == Some(target)
|
||||
}) {
|
||||
return Ok(Json(json!({ "id": existing.id, "existing": true })));
|
||||
}
|
||||
let title = format!("inertia:{}", target.get());
|
||||
let level = target.as_db();
|
||||
let id = insert_proposal(
|
||||
&state,
|
||||
NewProposal {
|
||||
unit_id: &unit_id,
|
||||
kind: "inertia",
|
||||
inertia_target: Some(&level),
|
||||
title: &title,
|
||||
body: "",
|
||||
author: &user.id,
|
||||
submit: true,
|
||||
},
|
||||
)
|
||||
.await?;
|
||||
Ok(Json(json!({ "id": id, "existing": false })))
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Editorial translations
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
pub async fn translate(
|
||||
State(state): State<AppState>,
|
||||
User(user): User,
|
||||
Path((id, lang)): Path<(String, String)>,
|
||||
Json(b): Json<EditBody>,
|
||||
) -> AppResult<Json<serde_json::Value>> {
|
||||
let (p, st) = guarded(&state, &user, &id).await?;
|
||||
modules::require_text(&p)?;
|
||||
if !util::is_lang(&lang) || lang == st.doc.source_lang {
|
||||
return Err(AppError::Invalid("lang"));
|
||||
}
|
||||
check_text(&b.title, &b.body, p.kind == "text")?;
|
||||
sqlx::query(
|
||||
"INSERT INTO translations (proposal_id, lang, title, body, source_hash, origin, updated_by, updated_at)
|
||||
VALUES (?, ?, ?, ?, ?, 'member', ?, ?)
|
||||
ON CONFLICT(proposal_id, lang) DO UPDATE SET title = excluded.title, body = excluded.body,
|
||||
source_hash = excluded.source_hash, origin = 'member', updated_by = excluded.updated_by,
|
||||
updated_at = excluded.updated_at",
|
||||
)
|
||||
.bind(&id)
|
||||
.bind(&lang)
|
||||
.bind(b.title.trim())
|
||||
.bind(b.body.trim())
|
||||
.bind(util::source_hash(&p.title, &p.body))
|
||||
.bind(&user.id)
|
||||
.bind(util::now())
|
||||
.execute(&state.db)
|
||||
.await?;
|
||||
if p.status == "in_force" {
|
||||
adoption::enqueue_publication(&state, &st.doc, "traduction").await?;
|
||||
}
|
||||
Ok(Json(json!({ "ok": true })))
|
||||
}
|
||||
@@ -0,0 +1,518 @@
|
||||
//! Publication of the texts in force to the git repository that client
|
||||
//! applications read to update themselves (Yvv, 2026-10-21: « Le tout est
|
||||
//! automatique poussé sur le dépôt git exploité par les applis clientes »).
|
||||
//!
|
||||
//! Every adoption, typo fix or translation of a text in force queues an
|
||||
//! outbox row. The worker renders the document — one Markdown file per
|
||||
//! language plus a JSON file — commits it in the server's own clone and pushes
|
||||
//! with a deploy key. A failure is retried with backoff; the vote never waits
|
||||
//! for the network.
|
||||
|
||||
use crate::AppState;
|
||||
use crate::docs::{self, DocState};
|
||||
use crate::util;
|
||||
use serde_json::json;
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::time::Duration;
|
||||
use tokio::process::Command;
|
||||
|
||||
const IDLE: Duration = Duration::from_secs(60);
|
||||
const MAX_BACKOFF_SECS: i64 = 6 * 3600;
|
||||
|
||||
/// The deploy key of the server: generated on first start, never leaves the
|
||||
/// data volume. Its public half is shown to access administrators, who add it
|
||||
/// as a deploy key (write) on the texts repository.
|
||||
pub async fn ensure_key(state: &AppState) -> anyhow::Result<()> {
|
||||
let Some(publish) = &state.cfg.publish else { return Ok(()) };
|
||||
if publish.key.exists() {
|
||||
return Ok(());
|
||||
}
|
||||
if let Some(dir) = publish.key.parent() {
|
||||
tokio::fs::create_dir_all(dir).await?;
|
||||
}
|
||||
let out = Command::new("ssh-keygen")
|
||||
.args(["-q", "-t", "ed25519", "-N", "", "-C", "libredecision-publication", "-f"])
|
||||
.arg(&publish.key)
|
||||
.output()
|
||||
.await?;
|
||||
anyhow::ensure!(out.status.success(), "ssh-keygen: {}", String::from_utf8_lossy(&out.stderr).trim());
|
||||
tracing::info!(key = %publish.key.display(), "publication key generated");
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn public_key(state: &AppState) -> Option<String> {
|
||||
let publish = state.cfg.publish.as_ref()?;
|
||||
let mut path = publish.key.clone().into_os_string();
|
||||
path.push(".pub");
|
||||
std::fs::read_to_string(path).ok().map(|k| k.trim().to_owned())
|
||||
}
|
||||
|
||||
pub async fn worker(state: AppState) {
|
||||
loop {
|
||||
// A pass first: what was queued before the start goes out without waiting.
|
||||
if state.cfg.publish.is_some()
|
||||
&& let Err(e) = pass(&state).await
|
||||
{
|
||||
tracing::warn!(error = %e, "publication pass");
|
||||
}
|
||||
tokio::select! {
|
||||
() = state.publish_wake.notified() => {}
|
||||
() = tokio::time::sleep(IDLE) => {}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(sqlx::FromRow)]
|
||||
struct Due {
|
||||
id: i64,
|
||||
doc_id: String,
|
||||
attempts: i64,
|
||||
}
|
||||
|
||||
/// One publication pass, now (tests, and the worker).
|
||||
pub async fn pass(state: &AppState) -> anyhow::Result<()> {
|
||||
let due = sqlx::query_as::<_, Due>(
|
||||
"SELECT id, doc_id, attempts FROM outbox WHERE done_at IS NULL AND next_at <= ? ORDER BY id",
|
||||
)
|
||||
.bind(util::now())
|
||||
.fetch_all(&state.db)
|
||||
.await?;
|
||||
let mut docs_due: Vec<String> = due.iter().map(|d| d.doc_id.clone()).collect();
|
||||
docs_due.dedup();
|
||||
for doc_id in docs_due {
|
||||
let rows: Vec<&Due> = due.iter().filter(|d| d.doc_id == doc_id).collect();
|
||||
let last = rows.iter().map(|d| d.id).max().unwrap_or(0);
|
||||
let attempts = rows.iter().map(|d| d.attempts).max().unwrap_or(0);
|
||||
if let Some(wait) = waits(state, &doc_id).await? {
|
||||
// Not a failure: no attempt counted, no error. The queue stays; the first
|
||||
// text that enters queues a row due now, and everything goes out with it —
|
||||
// or, waiting for proven votes, it is asked again a while later.
|
||||
let again = match wait {
|
||||
Wait::Text => MAX_BACKOFF_SECS,
|
||||
Wait::Proven => PROVEN_AGAIN_SECS,
|
||||
};
|
||||
sqlx::query("UPDATE outbox SET next_at = ? WHERE doc_id = ? AND done_at IS NULL")
|
||||
.bind(util::in_seconds(again))
|
||||
.bind(&doc_id)
|
||||
.execute(&state.db)
|
||||
.await?;
|
||||
match wait {
|
||||
Wait::Text => tracing::info!(
|
||||
doc = %doc_id, waiting = rows.len(),
|
||||
"publication suspended: no article in force, the queue waits for the first text (LD_PUBLISH_WITHOUT_ARTICLES=1 publishes anyway)"
|
||||
),
|
||||
Wait::Proven => tracing::info!(
|
||||
doc = %doc_id, waiting = rows.len(),
|
||||
"publication suspended: something in effect entered by simulated votes does not hold on the proven votes (LD_PUBLISH_SIMULATED=1 publishes anyway)"
|
||||
),
|
||||
}
|
||||
continue;
|
||||
}
|
||||
match publish_doc(state, &doc_id).await {
|
||||
Ok(sha) => {
|
||||
sqlx::query("UPDATE outbox SET done_at = ?, commit_sha = ?, last_error = NULL WHERE doc_id = ? AND done_at IS NULL AND id <= ?")
|
||||
.bind(util::now())
|
||||
.bind(&sha)
|
||||
.bind(&doc_id)
|
||||
.bind(last)
|
||||
.execute(&state.db)
|
||||
.await?;
|
||||
tracing::info!(doc = %doc_id, commit = sha.as_deref().unwrap_or("unchanged"), "published");
|
||||
}
|
||||
Err(e) => {
|
||||
let wait = (60_i64 << attempts.min(12)).min(MAX_BACKOFF_SECS);
|
||||
sqlx::query("UPDATE outbox SET attempts = attempts + 1, last_error = ?, next_at = ? WHERE doc_id = ? AND done_at IS NULL")
|
||||
.bind(format!("{e:#}").chars().take(500).collect::<String>())
|
||||
.bind(util::in_seconds(wait))
|
||||
.bind(&doc_id)
|
||||
.execute(&state.db)
|
||||
.await?;
|
||||
tracing::warn!(doc = %doc_id, error = %e, retry_in = wait, "publication failed");
|
||||
}
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Why a document waits before it is published.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
pub enum Wait {
|
||||
/// None of its articles is in force (ADR 0011).
|
||||
Text,
|
||||
/// Something in effect entered by simulated votes and does not hold on the
|
||||
/// proven votes alone (ADR 0012).
|
||||
Proven,
|
||||
}
|
||||
|
||||
impl Wait {
|
||||
/// As the API says it (`publication.waitsFor`).
|
||||
pub fn as_str(self) -> &'static str {
|
||||
match self {
|
||||
Self::Text => "text",
|
||||
Self::Proven => "proven",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Waiting for proven votes, the queue is asked again this often: a proven vote that
|
||||
/// carries an entry already in force queues nothing by itself.
|
||||
const PROVEN_AGAIN_SECS: i64 = 600;
|
||||
|
||||
/// Does the document wait before it is published, and for what: a first text
|
||||
/// (`waits_for_a_text`), then proven votes (`waits_for_proven_votes`).
|
||||
pub async fn waits(state: &AppState, doc_id: &str) -> anyhow::Result<Option<Wait>> {
|
||||
if waits_for_a_text(state, doc_id).await? {
|
||||
return Ok(Some(Wait::Text));
|
||||
}
|
||||
if waits_for_proven_votes(state, doc_id).await? {
|
||||
return Ok(Some(Wait::Proven));
|
||||
}
|
||||
Ok(None)
|
||||
}
|
||||
|
||||
/// Does the document wait for proven votes before it is published? Yvv, 2026-11-03:
|
||||
/// « Pas avant des votes prouvés ». A text — an order, a title, a level of inertia, a
|
||||
/// removal — that entered by simulated votes is in force on the site, and sends
|
||||
/// nothing to the repository of the texts while anything in effect that entered by
|
||||
/// simulated votes does not hold on the proven votes alone, by the re-examination of
|
||||
/// the act « supprime » (`simulation::unproven`, told only). The chain being closed,
|
||||
/// nothing goes out of Monnaie libre before it opens. The one place of this rule;
|
||||
/// `LD_PUBLISH_SIMULATED=1` publishes anyway (`Publish::simulated`).
|
||||
pub async fn waits_for_proven_votes(state: &AppState, doc_id: &str) -> anyhow::Result<bool> {
|
||||
if state.cfg.publish.as_ref().is_none_or(|p| p.simulated) {
|
||||
return Ok(false);
|
||||
}
|
||||
let unproven = crate::simulation::unproven(state, doc_id).await.map_err(|e| anyhow::anyhow!("{e}"))?;
|
||||
Ok(!unproven.is_empty())
|
||||
}
|
||||
|
||||
/// Does the document wait for a text before it is published? Yes when none of its
|
||||
/// articles is in force — the seed proposals are not (ADR 0011) — unless the
|
||||
/// publication is told to go out anyway (`LD_PUBLISH_WITHOUT_ARTICLES`). The default is
|
||||
/// Yvv's (2026-11-02, « Suspendre jusqu'au 1er texte »): never a document without a text.
|
||||
pub async fn waits_for_a_text(state: &AppState, doc_id: &str) -> anyhow::Result<bool> {
|
||||
if state.cfg.publish.as_ref().is_none_or(|p| p.without_articles) {
|
||||
return Ok(false);
|
||||
}
|
||||
let in_force: i64 = sqlx::query_scalar(
|
||||
"SELECT COUNT(*) FROM units WHERE doc_id = ? AND kind = 'article' AND in_force_id IS NOT NULL
|
||||
AND retired_at IS NULL",
|
||||
)
|
||||
.bind(doc_id)
|
||||
.fetch_one(&state.db)
|
||||
.await?;
|
||||
Ok(in_force == 0)
|
||||
}
|
||||
|
||||
async fn git(dir: &Path, state: &AppState, args: &[&str]) -> anyhow::Result<String> {
|
||||
let publish = state.cfg.publish.as_ref().ok_or_else(|| anyhow::anyhow!("publication not configured"))?;
|
||||
let known_hosts = state.cfg.data_dir.join("keys/known_hosts");
|
||||
let ssh = format!(
|
||||
"ssh -i {} -o IdentitiesOnly=yes -o BatchMode=yes -o StrictHostKeyChecking=accept-new -o UserKnownHostsFile={}",
|
||||
publish.key.display(),
|
||||
known_hosts.display()
|
||||
);
|
||||
let out = Command::new("git")
|
||||
.args([
|
||||
"-c",
|
||||
&format!("user.name={}", publish.author_name),
|
||||
"-c",
|
||||
&format!("user.email={}", publish.author_email),
|
||||
])
|
||||
.args(args)
|
||||
.current_dir(dir)
|
||||
.env("GIT_SSH_COMMAND", ssh)
|
||||
.env("GIT_TERMINAL_PROMPT", "0")
|
||||
.output()
|
||||
.await?;
|
||||
if !out.status.success() {
|
||||
anyhow::bail!("git {}: {}", args.first().unwrap_or(&""), String::from_utf8_lossy(&out.stderr).trim());
|
||||
}
|
||||
Ok(String::from_utf8_lossy(&out.stdout).trim().to_owned())
|
||||
}
|
||||
|
||||
/// The server's own clone: a cache of the remote, realigned on it before each
|
||||
/// commit so a push never needs a merge. Returns the clone and the commit the
|
||||
/// remote branch points to, if it exists.
|
||||
async fn checkout(state: &AppState) -> anyhow::Result<(PathBuf, Option<String>)> {
|
||||
let publish = state.cfg.publish.as_ref().ok_or_else(|| anyhow::anyhow!("publication not configured"))?;
|
||||
let dir = state.cfg.data_dir.join("textes");
|
||||
if !dir.join(".git").exists() {
|
||||
tokio::fs::create_dir_all(&dir).await?;
|
||||
git(&dir, state, &["init", "-q"]).await?;
|
||||
git(&dir, state, &["remote", "add", "origin", &publish.remote]).await?;
|
||||
}
|
||||
git(&dir, state, &["remote", "set-url", "origin", &publish.remote]).await?;
|
||||
let has_remote_branch = git(&dir, state, &["ls-remote", "--heads", "origin", &publish.branch]).await?;
|
||||
let remote_head = has_remote_branch.split_whitespace().next().map(str::to_owned);
|
||||
if remote_head.is_none() {
|
||||
git(&dir, state, &["checkout", "-q", "-f", "-B", &publish.branch]).await?;
|
||||
} else {
|
||||
// This clone is a cache: whatever a failed pass left is re-rendered from the database.
|
||||
git(&dir, state, &["fetch", "-q", "origin", &publish.branch]).await?;
|
||||
git(&dir, state, &["checkout", "-q", "-f", "-B", &publish.branch, &format!("origin/{}", publish.branch)])
|
||||
.await?;
|
||||
}
|
||||
Ok((dir, remote_head))
|
||||
}
|
||||
|
||||
async fn publish_doc(state: &AppState, doc_id: &str) -> anyhow::Result<Option<String>> {
|
||||
let st = docs::load_state(state, doc_id).await.map_err(|e| anyhow::anyhow!("{e}"))?;
|
||||
let Some(path) = st.doc.publish_path.clone() else { return Ok(None) };
|
||||
let rendered = render(state, &st).await?;
|
||||
let (dir, remote_head) = checkout(state).await?;
|
||||
let branch = state.cfg.publish.as_ref().map(|p| p.branch.clone()).unwrap_or_else(|| "main".into());
|
||||
let target = dir.join(&path);
|
||||
tokio::fs::create_dir_all(&target).await?;
|
||||
for (name, content) in &rendered {
|
||||
tokio::fs::write(target.join(name), content).await?;
|
||||
}
|
||||
let readme = dir.join("README.md");
|
||||
if !readme.exists() {
|
||||
tokio::fs::write(&readme, README).await?;
|
||||
}
|
||||
git(&dir, state, &["add", "-A", "--", &path, "README.md"]).await?;
|
||||
let staged = git(&dir, state, &["diff", "--cached", "--name-only"]).await?;
|
||||
if staged.is_empty() {
|
||||
// Nothing new — but a commit the remote never received still goes out: a
|
||||
// first push refused (a key without write access), or a new, empty
|
||||
// repository after a change of remote.
|
||||
let Ok(head) = git(&dir, state, &["rev-parse", "--verify", "-q", "HEAD"]).await else { return Ok(None) };
|
||||
if remote_head.as_deref() == Some(head.as_str()) {
|
||||
return Ok(None);
|
||||
}
|
||||
git(&dir, state, &["push", "-q", "origin", &format!("HEAD:{branch}")]).await?;
|
||||
return Ok(Some(head));
|
||||
}
|
||||
let reasons: Vec<String> =
|
||||
sqlx::query_scalar("SELECT reason FROM outbox WHERE doc_id = ? AND done_at IS NULL ORDER BY id")
|
||||
.bind(doc_id)
|
||||
.fetch_all(&state.db)
|
||||
.await?;
|
||||
let title = util::parse_lang(&st.doc.title).get(&st.doc.source_lang).cloned().unwrap_or_default();
|
||||
let mut message = format!("{title} — {}", reasons.last().cloned().unwrap_or_else(|| "mise à jour".into()));
|
||||
if reasons.len() > 1 {
|
||||
message.push_str("\n\n");
|
||||
for r in &reasons {
|
||||
message.push_str(&format!("- {r}\n"));
|
||||
}
|
||||
}
|
||||
git(&dir, state, &["commit", "-q", "-m", &message]).await?;
|
||||
git(&dir, state, &["push", "-q", "origin", &format!("HEAD:{branch}")]).await?;
|
||||
Ok(Some(git(&dir, state, &["rev-parse", "HEAD"]).await?))
|
||||
}
|
||||
|
||||
const README: &str = "# Textes en vigueur — libreDecision\n\n\
|
||||
Ce dépôt est écrit par libreDecision (https://decision.librodrome.org) : chaque fois qu'un article \
|
||||
entre en vigueur par le vote, qu'une coquille est corrigée ou qu'une traduction change, le texte en \
|
||||
vigueur est republié ici. Les applications clientes le lisent pour se mettre à jour.\n\n\
|
||||
- `<groupe>/<document>/fr.md` : le texte qui fait foi (langue source) ;\n\
|
||||
- `en.md`, `es.md` : traductions éditoriales, sans vote ;\n\
|
||||
- `document.json` : le même contenu, structuré, avec l'état du vote de chaque article.\n\n\
|
||||
Ne pas modifier à la main : le prochain passage de libreDecision réécrit ces fichiers.\n";
|
||||
|
||||
/// The date of the last change to what is published (an entry into force, a
|
||||
/// typo fix, a translation) — never "now", so that publishing an unchanged
|
||||
/// document changes nothing and commits nothing.
|
||||
async fn last_change(state: &AppState, doc_id: &str) -> anyhow::Result<String> {
|
||||
let at: Option<String> = sqlx::query_scalar(
|
||||
"SELECT MAX(d) FROM (
|
||||
SELECT MAX(at) AS d FROM adoptions WHERE doc_id = ?1
|
||||
UNION ALL SELECT MAX(p.updated_at) FROM proposals p JOIN units u ON u.in_force_id = p.id
|
||||
WHERE u.doc_id = ?1 AND u.retired_at IS NULL
|
||||
UNION ALL SELECT MAX(t.updated_at) FROM translations t JOIN units u ON u.in_force_id = t.proposal_id
|
||||
WHERE u.doc_id = ?1 AND u.retired_at IS NULL
|
||||
UNION ALL SELECT created_at FROM documents WHERE id = ?1)",
|
||||
)
|
||||
.bind(doc_id)
|
||||
.fetch_one(&state.db)
|
||||
.await?;
|
||||
Ok(at.unwrap_or_else(util::now))
|
||||
}
|
||||
|
||||
/// On start, every published document is queued once more: a change that
|
||||
/// entered into force just before a restart, or a new rendering format, reaches
|
||||
/// the repository. Unchanged documents produce no commit. What was waiting
|
||||
/// after failures is tried again at once (a key just added, a new remote).
|
||||
pub async fn resync(state: &AppState) -> anyhow::Result<()> {
|
||||
if state.cfg.publish.is_none() {
|
||||
return Ok(());
|
||||
}
|
||||
retry_now(state).await?;
|
||||
let now = util::now();
|
||||
sqlx::query(
|
||||
"INSERT INTO outbox (doc_id, reason, created_at, next_at)
|
||||
SELECT d.id, 'reprise au démarrage', ?1, ?1 FROM documents d
|
||||
WHERE d.publish_path IS NOT NULL
|
||||
AND NOT EXISTS (SELECT 1 FROM outbox o WHERE o.doc_id = d.id AND o.done_at IS NULL)",
|
||||
)
|
||||
.bind(&now)
|
||||
.execute(&state.db)
|
||||
.await?;
|
||||
state.publish_wake.notify_one();
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Everything still waiting goes out at the next pass, backoff forgotten.
|
||||
pub async fn retry_now(state: &AppState) -> anyhow::Result<()> {
|
||||
sqlx::query("UPDATE outbox SET next_at = ?, attempts = 0 WHERE done_at IS NULL")
|
||||
.bind(util::now())
|
||||
.execute(&state.db)
|
||||
.await?;
|
||||
state.publish_wake.notify_one();
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// What administrators see of the publication in trustinWallet.
|
||||
pub async fn status(state: &AppState) -> anyhow::Result<serde_json::Value> {
|
||||
let pending: i64 =
|
||||
sqlx::query_scalar("SELECT COUNT(*) FROM outbox WHERE done_at IS NULL").fetch_one(&state.db).await?;
|
||||
let error: Option<String> = sqlx::query_scalar(
|
||||
"SELECT last_error FROM outbox WHERE done_at IS NULL AND last_error IS NOT NULL ORDER BY id DESC LIMIT 1",
|
||||
)
|
||||
.fetch_optional(&state.db)
|
||||
.await?;
|
||||
let last: Option<(String, Option<String>)> = sqlx::query_as(
|
||||
"SELECT done_at, commit_sha FROM outbox WHERE done_at IS NOT NULL AND commit_sha IS NOT NULL
|
||||
ORDER BY done_at DESC LIMIT 1",
|
||||
)
|
||||
.fetch_optional(&state.db)
|
||||
.await?;
|
||||
Ok(json!({
|
||||
"pending": pending,
|
||||
"lastError": error,
|
||||
"lastPublishedAt": last.as_ref().map(|(at, _)| at.clone()),
|
||||
"lastCommit": last.and_then(|(_, sha)| sha),
|
||||
}))
|
||||
}
|
||||
|
||||
/// Markdown per language + JSON. Only the articles in force, in order; the
|
||||
/// title of the document is its title in force (ADR 0009).
|
||||
pub async fn render(state: &AppState, st: &DocState) -> anyhow::Result<Vec<(String, String)>> {
|
||||
let titles = util::parse_lang(&st.doc.title);
|
||||
let src = st.doc.source_lang.clone();
|
||||
let modules = modules_json(state, st).await?;
|
||||
let stale_title = |lang: &str| modules["title"]["translations"][lang]["stale"].as_bool().unwrap_or(false);
|
||||
let in_force: Vec<_> = st
|
||||
.articles()
|
||||
.filter_map(|u| {
|
||||
let fid = u.in_force_id.as_ref()?;
|
||||
st.proposals.iter().find(|p| &p.id == fid).map(|p| (u, p))
|
||||
})
|
||||
.collect();
|
||||
let refs: Vec<&docs::ProposalRow> = in_force.iter().map(|(_, p)| *p).collect();
|
||||
let tr = docs::translations_for(state, &refs).await.map_err(|e| anyhow::anyhow!("{e}"))?;
|
||||
let updated = last_change(state, &st.doc.id).await?;
|
||||
let source_url = format!("{}/g/{}/docs/{}", state.cfg.public_url, st.group.slug, st.doc.slug);
|
||||
let mut files = Vec::new();
|
||||
for lang in util::LANGS {
|
||||
let title = titles.get(lang).or_else(|| titles.get(&src)).cloned().unwrap_or_default();
|
||||
let mut md = format!(
|
||||
"---\ndocument: {}\nlangue: {lang}\nsource: {source_url}\nmise_a_jour: {updated}\n{}---\n\n# {title}\n",
|
||||
st.doc.slug,
|
||||
if lang == src { "fait_foi: oui\n".to_owned() } else { "traduction: editoriale\n".to_owned() },
|
||||
);
|
||||
if lang != src && stale_title(lang) {
|
||||
md.push_str(&format!("\n<!-- {lang} : titre à revoir, le titre source a changé -->\n"));
|
||||
}
|
||||
for (_, p) in &in_force {
|
||||
let t = tr.get(&p.id).and_then(|m| m.get(lang));
|
||||
let (ut, ub, fallback) = match t {
|
||||
Some(t) if lang != src => (
|
||||
t["title"].as_str().unwrap_or(&p.title).to_owned(),
|
||||
t["body"].as_str().unwrap_or(&p.body).to_owned(),
|
||||
t["stale"].as_bool().unwrap_or(false),
|
||||
),
|
||||
_ => (p.title.clone(), p.body.clone(), lang != src),
|
||||
};
|
||||
md.push_str(&format!("\n## {ut}\n\n{ub}\n"));
|
||||
if fallback {
|
||||
md.push_str(&format!(
|
||||
"\n<!-- {lang} : traduction à revoir ou absente, texte source affiché si absente -->\n"
|
||||
));
|
||||
}
|
||||
}
|
||||
files.push((format!("{lang}.md"), md));
|
||||
}
|
||||
let units: Vec<_> = in_force
|
||||
.iter()
|
||||
.map(|(u, p)| {
|
||||
let mut title = serde_json::Map::new();
|
||||
let mut body = serde_json::Map::new();
|
||||
title.insert(src.clone(), json!(p.title));
|
||||
body.insert(src.clone(), json!(p.body));
|
||||
if let Some(m) = tr.get(&p.id) {
|
||||
for (lang, t) in m {
|
||||
title.insert(lang.clone(), t["title"].clone());
|
||||
body.insert(lang.clone(), t["body"].clone());
|
||||
}
|
||||
}
|
||||
let tally = st.tally(&p.id);
|
||||
json!({
|
||||
"code": u.code, "title": title, "body": body, "inertia": crate::docs::inertia_of(u),
|
||||
"status": if p.entered_at.is_some() { "adopted" } else if p.confirmed_at.is_some() { "confirmed" } else { "seed-minimal" },
|
||||
"enteredAt": p.entered_at, "votes": { "for": tally.for_votes, "against": tally.against },
|
||||
})
|
||||
})
|
||||
.collect();
|
||||
let json = json!({
|
||||
"document": st.doc.slug, "group": st.group.slug, "title": titles, "sourceLang": src,
|
||||
"source": source_url, "updatedAt": updated, "electorate": st.electorate,
|
||||
"voteFloor": st.doc.vote_floor, "quorums": st.doc.quorums(), "units": units, "modules": modules,
|
||||
});
|
||||
files.push(("document.json".into(), format!("{}\n", serde_json::to_string_pretty(&json)?)));
|
||||
Ok(files)
|
||||
}
|
||||
|
||||
/// The modules of the document as published: the title and the order of each
|
||||
/// section, each with its level, its state and its votes (ADR 0009).
|
||||
async fn modules_json(state: &AppState, st: &DocState) -> anyhow::Result<serde_json::Value> {
|
||||
use crate::modules::ModuleKind;
|
||||
let status = |p: &docs::ProposalRow| {
|
||||
if p.entered_at.is_some() {
|
||||
"adopted"
|
||||
} else if p.confirmed_at.is_some() {
|
||||
"confirmed"
|
||||
} else {
|
||||
"seed-minimal"
|
||||
}
|
||||
};
|
||||
let version = |u: &docs::UnitRow| u.in_force_id.as_ref().and_then(|id| st.proposals.iter().find(|p| &p.id == id));
|
||||
let title = match st.modules(ModuleKind::Title).first().and_then(|u| Some((*u, version(u)?))) {
|
||||
Some((u, p)) => {
|
||||
let tr = docs::translations_for(state, &[p]).await.map_err(|e| anyhow::anyhow!("{e}"))?;
|
||||
let translations: serde_json::Map<String, serde_json::Value> = tr
|
||||
.get(&p.id)
|
||||
.map(|m| {
|
||||
m.iter().map(|(l, t)| (l.clone(), json!({ "title": t["title"], "stale": t["stale"] }))).collect()
|
||||
})
|
||||
.unwrap_or_default();
|
||||
let tally = st.recorded_tally(&p.id);
|
||||
json!({
|
||||
"inertia": crate::docs::inertia_of(u), "status": status(p), "enteredAt": p.entered_at,
|
||||
"votes": { "for": tally.for_votes, "against": tally.against }, "translations": translations,
|
||||
})
|
||||
}
|
||||
None => serde_json::Value::Null,
|
||||
};
|
||||
let orders: Vec<_> = st
|
||||
.modules(ModuleKind::Order)
|
||||
.into_iter()
|
||||
.filter_map(|u| {
|
||||
let p = version(u)?;
|
||||
let section = u.section.clone().unwrap_or_default();
|
||||
let codes: Vec<&str> = st
|
||||
.members(§ion)
|
||||
.iter()
|
||||
.filter_map(|id| st.units.iter().find(|x| &x.id == id).map(|x| x.code.as_str()))
|
||||
.collect();
|
||||
let tally = st.recorded_tally(&p.id);
|
||||
Some(json!({
|
||||
"section": section, "inertia": crate::docs::inertia_of(u), "status": status(p),
|
||||
"enteredAt": p.entered_at, "votes": { "for": tally.for_votes, "against": tally.against },
|
||||
"articles": codes,
|
||||
}))
|
||||
})
|
||||
.collect();
|
||||
Ok(json!({ "title": title, "orders": orders }))
|
||||
}
|
||||
@@ -0,0 +1,187 @@
|
||||
//! The quorum of an article (ADR 0013).
|
||||
//!
|
||||
//! Yvv, 2026-11-02: « C suit le cran, mais c'est une proposition par défaut, variable
|
||||
//! modifiable par l'auteur initial du texte » — « L'auteur, avant le 1er vote ». Each
|
||||
//! level has its quorum (`documents.quorums`); an article judged on another one holds
|
||||
//! it in `units.quorum`, set here by the author of its first version (an administrator
|
||||
//! for a text of the seed, `trustin_access::sets_quorum`), and only while no vote
|
||||
//! exists on it (`voted_units`). From 2 to W; from 1 in the test group
|
||||
//! (`ld_engine::inertia::quorum_bounds`). Each change is journaled (`quorum_changes`).
|
||||
//! An article's own quorum is not published: `document.json` carries the quorums by level.
|
||||
|
||||
use crate::AppState;
|
||||
use crate::auth::{IdentityRow, User};
|
||||
use crate::docs::{self, DocState, UnitRow};
|
||||
use crate::error::{AppError, AppResult};
|
||||
use crate::util;
|
||||
use axum::Json;
|
||||
use axum::extract::{Path, State};
|
||||
use ld_engine::inertia::quorum_bounds;
|
||||
use serde::Deserialize;
|
||||
use serde_json::json;
|
||||
use sqlx::SqliteConnection;
|
||||
use std::collections::{HashMap, HashSet};
|
||||
|
||||
/// **« Un vote existe »** — the units of a document on which a vote exists: the one
|
||||
/// place this is asked (ADR 0013; lot V extends it here, nowhere else).
|
||||
///
|
||||
/// A vote exists on an article when any version of it (its texts, a removal, a change
|
||||
/// of its inertia; any revision) holds
|
||||
/// - a vote that counts (`votes`): a vote of a managed group, a vote proven by the
|
||||
/// chain, a **simulated** vote (ADR 0012);
|
||||
/// - a line of a ballot — the buffer of the votes keeps their history (ADR 0012) —
|
||||
/// of the chain or of the simulation: **an intention still pending** (signed, not yet
|
||||
/// read on the chain: Yvv, 2026-11-03), or a ballot proven, **whatever became of its
|
||||
/// line**: the voice that counts, a vote overtaken by a more recent one, a *for* set
|
||||
/// aside by an objection, **a withdrawal** (`none`). A vote withdrawn on the chain or
|
||||
/// in simulation, before any adoption, thus still says that someone voted. A ballot
|
||||
/// the wallet never signed (let go at once), never seen on the chain in time, or
|
||||
/// refused by the chain, is no vote;
|
||||
/// - an adoption: a version that entered into force by the vote keeps that trace once
|
||||
/// its votes are withdrawn.
|
||||
///
|
||||
/// Two limits, known. In a managed group there is no buffer: a vote of the server
|
||||
/// withdrawn before any adoption leaves no row (`votes` keeps the last choice only),
|
||||
/// and from then on no vote exists (ADR 0013). The act « supprime » that ends the
|
||||
/// simulation (`simulation::discard`) takes the simulated ballots away with their
|
||||
/// trace: an article voted in simulation only has no vote any more, unless an adoption
|
||||
/// that holds on the proven votes stays.
|
||||
pub async fn voted_units(conn: &mut SqliteConnection, doc_id: &str) -> sqlx::Result<HashSet<String>> {
|
||||
let ids: Vec<String> = sqlx::query_scalar(
|
||||
"SELECT p.unit_id FROM votes v
|
||||
JOIN proposals p ON p.id = v.proposal_id JOIN units u ON u.id = p.unit_id
|
||||
WHERE u.doc_id = ?1
|
||||
UNION
|
||||
-- Every ballot of the buffer, chain or simulated, pending or proven: its history.
|
||||
SELECT p.unit_id FROM ballot_lines l
|
||||
JOIN ballots b ON b.id = l.ballot_id
|
||||
JOIN proposals p ON p.id = l.proposal_id JOIN units u ON u.id = p.unit_id
|
||||
WHERE u.doc_id = ?1 AND b.status IN ('pending', 'confirmed')
|
||||
UNION
|
||||
SELECT a.unit_id FROM adoptions a WHERE a.doc_id = ?1",
|
||||
)
|
||||
.bind(doc_id)
|
||||
.fetch_all(&mut *conn)
|
||||
.await?;
|
||||
Ok(ids.into_iter().collect())
|
||||
}
|
||||
|
||||
/// Does a vote exist on this unit (`voted_units`)?
|
||||
pub async fn a_vote_exists(state: &AppState, unit_id: &str) -> AppResult<bool> {
|
||||
let doc_id = doc_of_unit(state, unit_id).await?;
|
||||
let mut conn = state.db.acquire().await?;
|
||||
Ok(voted_units(&mut conn, &doc_id).await?.contains(unit_id))
|
||||
}
|
||||
|
||||
async fn doc_of_unit(state: &AppState, unit_id: &str) -> AppResult<String> {
|
||||
sqlx::query_scalar("SELECT doc_id FROM units WHERE id = ?")
|
||||
.bind(unit_id)
|
||||
.fetch_optional(&state.db)
|
||||
.await?
|
||||
.ok_or(AppError::NotFound)
|
||||
}
|
||||
|
||||
/// The author of each unit's first version (`None`: a text of the seed, without author).
|
||||
/// A first version is a text — of an article or a module —, never a removal or a change
|
||||
/// of inertia, whatever became of it since.
|
||||
pub async fn first_authors(state: &AppState, doc_id: &str) -> AppResult<HashMap<String, Option<String>>> {
|
||||
let rows: Vec<(String, Option<String>)> = sqlx::query_as(
|
||||
"SELECT p.unit_id, p.author_id FROM proposals p JOIN units u ON u.id = p.unit_id
|
||||
WHERE u.doc_id = ? AND p.kind NOT IN ('remove', 'inertia') ORDER BY p.created_at, p.rowid",
|
||||
)
|
||||
.bind(doc_id)
|
||||
.fetch_all(&state.db)
|
||||
.await?;
|
||||
let mut out = HashMap::new();
|
||||
for (unit, author) in rows {
|
||||
out.entry(unit).or_insert(author);
|
||||
}
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
/// What a person may do with the quorums of a document, read once for its view.
|
||||
pub struct Rights {
|
||||
first_authors: HashMap<String, Option<String>>,
|
||||
voted: HashSet<String>,
|
||||
}
|
||||
|
||||
impl Rights {
|
||||
pub async fn of(state: &AppState, doc_id: &str) -> AppResult<Self> {
|
||||
let first_authors = first_authors(state, doc_id).await?;
|
||||
let mut conn = state.db.acquire().await?;
|
||||
let voted = voted_units(&mut conn, doc_id).await?;
|
||||
Ok(Self { first_authors, voted })
|
||||
}
|
||||
|
||||
fn first_author(&self, unit: &UnitRow) -> Option<&str> {
|
||||
self.first_authors.get(&unit.id).and_then(|a| a.as_deref())
|
||||
}
|
||||
|
||||
/// May `user` set the quorum of `unit` now (`trustin_access::sets_quorum`)?
|
||||
pub fn sets(&self, user: &IdentityRow, unit: &UnitRow) -> bool {
|
||||
trustin_access::sets_quorum(&user.to_identity(), self.first_author(unit), self.voted.contains(&unit.id))
|
||||
}
|
||||
}
|
||||
|
||||
/// The quorum of a unit, as its view says it: its own (`null`: its level's), its level's,
|
||||
/// and whether `user` may set it.
|
||||
pub fn unit_json(st: &DocState, rights: &Rights, user: &IdentityRow, unit: &UnitRow) -> serde_json::Value {
|
||||
json!({
|
||||
"quorum": unit.own_quorum(), "quorumDefault": st.quorum_default(unit), "canSetQuorum": rights.sets(user, unit),
|
||||
})
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
pub struct QuorumBody {
|
||||
/// A whole number of voters; `null`: back to the quorum of its level.
|
||||
quorum: Option<i64>,
|
||||
}
|
||||
|
||||
/// `PUT /units/{id}/quorum`, body `{ "quorum": n | null }`.
|
||||
pub async fn set(
|
||||
State(state): State<AppState>,
|
||||
User(user): User,
|
||||
Path(unit_id): Path<String>,
|
||||
Json(b): Json<QuorumBody>,
|
||||
) -> AppResult<Json<serde_json::Value>> {
|
||||
let doc_id = doc_of_unit(&state, &unit_id).await?;
|
||||
let st = docs::load_state(&state, &doc_id).await?;
|
||||
docs::require_open(&state, &user, &st.group).await?;
|
||||
let unit = st.units.iter().find(|u| u.id == unit_id).ok_or(AppError::NotFound)?;
|
||||
let authors = first_authors(&state, &doc_id).await?;
|
||||
let first = authors.get(&unit.id).and_then(|a| a.as_deref());
|
||||
let identity = user.to_identity();
|
||||
if !trustin_access::sets_quorum(&identity, first, false) {
|
||||
return Err(AppError::Forbidden);
|
||||
}
|
||||
let (lowest, highest) = quorum_bounds(st.electorate, st.group.slug == trustin_access::TEST_GROUP);
|
||||
let to = match b.quorum {
|
||||
None => None,
|
||||
Some(q) if q >= i64::from(lowest) && q <= i64::from(highest) => Some(q),
|
||||
Some(_) => return Err(AppError::Invalid("quorum")),
|
||||
};
|
||||
let mut tx = state.db.begin().await?;
|
||||
// Asked within the writing transaction: a vote that arrives first wins.
|
||||
let voted = voted_units(&mut tx, &doc_id).await?.contains(&unit.id);
|
||||
if !trustin_access::sets_quorum(&identity, first, voted) {
|
||||
return Err(AppError::Conflict("voted"));
|
||||
}
|
||||
if unit.quorum != to {
|
||||
let now = util::now();
|
||||
sqlx::query("UPDATE units SET quorum = ? WHERE id = ?").bind(to).bind(&unit.id).execute(&mut *tx).await?;
|
||||
sqlx::query(
|
||||
"INSERT INTO quorum_changes (unit_id, doc_id, from_quorum, to_quorum, identity_id, at) VALUES (?, ?, ?, ?, ?, ?)",
|
||||
)
|
||||
.bind(&unit.id)
|
||||
.bind(&doc_id)
|
||||
.bind(unit.quorum)
|
||||
.bind(to)
|
||||
.bind(&user.id)
|
||||
.bind(&now)
|
||||
.execute(&mut *tx)
|
||||
.await?;
|
||||
tracing::info!(doc = %doc_id, unit = %unit.code, from = ?unit.quorum, to = ?to, by = %user.id, "quorum set");
|
||||
}
|
||||
tx.commit().await?;
|
||||
Ok(Json(json!({ "quorum": to, "quorumDefault": st.quorum_default(unit), "canSetQuorum": true })))
|
||||
}
|
||||
@@ -0,0 +1,195 @@
|
||||
//! Quorum amendments — the quorums by level a seed now gives a document, written into
|
||||
//! a document born without them (ADR 0013).
|
||||
//!
|
||||
//! Yvv, 2026-11-02: « 12 · 12 · 50 · 50 · 50 » in Monnaie libre, the test group at 1
|
||||
//! everywhere. A new base gets them from the seed (`quorums` of each instance). An
|
||||
//! existing base — production holds two instances of the certification act — reads
|
||||
//! `vote_floor` at every level until then (the C of before: 50 and 1), and gets them
|
||||
//! through an amendment listed here: written **only where the document holds none**;
|
||||
//! nothing else is touched (no article's own quorum, no vote, no text).
|
||||
//!
|
||||
//! Like the other amendments of the seed, each document is decided once, at the first
|
||||
//! start that knows the amendment, and the decision is kept in `quorum_amendments`
|
||||
//! (applied, refused and why, or present) and in the logs. Applied where the document
|
||||
//! publishes, the publication queue gets a row (`document.json` carries the quorums).
|
||||
|
||||
use crate::AppState;
|
||||
use crate::seed::{SeedDoc, quorums_json};
|
||||
use crate::util;
|
||||
|
||||
/// The quorums by level to write into every document instantiated from a seed.
|
||||
pub struct QuorumAmendment {
|
||||
/// Stable identifier: each document's decision is kept under it.
|
||||
pub id: &'static str,
|
||||
/// Slug of the seed document.
|
||||
pub doc: &'static str,
|
||||
/// The quorums of each group's instance: the seed's today, which must say so.
|
||||
pub quorums: &'static [(&'static str, [u32; 5])],
|
||||
/// Reason given to the publication queue, and so to the commit of the texts.
|
||||
pub reason: &'static str,
|
||||
}
|
||||
|
||||
pub const AMENDMENTS: &[QuorumAmendment] = &[QuorumAmendment {
|
||||
id: "2026-11-03-quorums-par-cran",
|
||||
doc: "acte-engagement-certification",
|
||||
quorums: &[("monnaie-libre", [12, 12, 50, 50, 50]), ("essai", [1, 1, 1, 1, 1])],
|
||||
reason: "quorums par cran : 12 · 12 · 50 · 50 · 50 (ADR 0013)",
|
||||
}];
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum Verdict {
|
||||
Apply,
|
||||
/// Born with these quorums: nothing to do.
|
||||
Present,
|
||||
Refuse(&'static str),
|
||||
}
|
||||
|
||||
/// The rule, apart from the database: `found` is what the document holds.
|
||||
pub fn verdict(found: Option<&str>, wanted: &[u32; 5]) -> Verdict {
|
||||
match found {
|
||||
None => Verdict::Apply,
|
||||
Some(raw) if serde_json::from_str::<[u32; 5]>(raw).ok().as_ref() == Some(wanted) => Verdict::Present,
|
||||
// Written otherwise: kept as it is.
|
||||
Some(_) => Verdict::Refuse("quorums_set"),
|
||||
}
|
||||
}
|
||||
|
||||
/// A decision taken at this start.
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct Outcome {
|
||||
pub amendment: &'static str,
|
||||
pub group: String,
|
||||
pub verdict: Verdict,
|
||||
}
|
||||
|
||||
/// Decides every amendment not yet decided, document by document.
|
||||
pub async fn apply(state: &AppState, seeds: &[SeedDoc], list: &[QuorumAmendment]) -> anyhow::Result<Vec<Outcome>> {
|
||||
let mut out = Vec::new();
|
||||
for a in list {
|
||||
let docs: Vec<(String, String, Option<String>)> =
|
||||
sqlx::query_as("SELECT id, group_slug, publish_path FROM documents WHERE slug = ? ORDER BY group_slug")
|
||||
.bind(a.doc)
|
||||
.fetch_all(&state.db)
|
||||
.await?;
|
||||
for (doc_id, group, publish_path) in docs {
|
||||
let Some(wanted) = seed_says(seeds, a, &group) else { continue };
|
||||
let Some(v) = decide(state, a, &wanted, &doc_id, publish_path.is_some()).await? else { continue };
|
||||
match v {
|
||||
Verdict::Apply => tracing::info!(amendment = a.id, %group, quorums = ?wanted, "quorums written"),
|
||||
Verdict::Present => tracing::info!(amendment = a.id, %group, "quorums: already there"),
|
||||
Verdict::Refuse(why) => {
|
||||
tracing::warn!(amendment = a.id, %group, reason = why, "quorums refused: the document keeps its own")
|
||||
}
|
||||
}
|
||||
if v == Verdict::Apply && publish_path.is_some() {
|
||||
state.publish_wake.notify_one();
|
||||
}
|
||||
out.push(Outcome { amendment: a.id, group, verdict: v });
|
||||
}
|
||||
}
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
/// The seed must give the group's instance the quorums the amendment writes: a seed
|
||||
/// edited later never reaches a base by this way, and a seed without them decides nothing.
|
||||
fn seed_says(seeds: &[SeedDoc], a: &QuorumAmendment, group: &str) -> Option<[u32; 5]> {
|
||||
let wanted = a.quorums.iter().find(|(g, _)| *g == group).map(|(_, q)| *q)?;
|
||||
let declared = seeds
|
||||
.iter()
|
||||
.find(|s| s.slug == a.doc)
|
||||
.and_then(|s| s.instances.iter().find(|i| i.group == group))
|
||||
.and_then(|i| i.quorums);
|
||||
if declared == Some(wanted) {
|
||||
Some(wanted)
|
||||
} else {
|
||||
tracing::warn!(amendment = a.id, doc = a.doc, %group, "quorum amendment: the seed does not give these quorums, nothing decided");
|
||||
None
|
||||
}
|
||||
}
|
||||
|
||||
/// One document, in one transaction: `None` when it was decided at an earlier start.
|
||||
async fn decide(
|
||||
state: &AppState,
|
||||
a: &QuorumAmendment,
|
||||
wanted: &[u32; 5],
|
||||
doc_id: &str,
|
||||
publishes: bool,
|
||||
) -> anyhow::Result<Option<Verdict>> {
|
||||
let mut tx = state.db.begin().await?;
|
||||
let decided: i64 =
|
||||
sqlx::query_scalar("SELECT COUNT(*) FROM quorum_amendments WHERE amendment_id = ? AND doc_id = ?")
|
||||
.bind(a.id)
|
||||
.bind(doc_id)
|
||||
.fetch_one(&mut *tx)
|
||||
.await?;
|
||||
if decided > 0 {
|
||||
return Ok(None);
|
||||
}
|
||||
let found: Option<String> =
|
||||
sqlx::query_scalar("SELECT quorums FROM documents WHERE id = ?").bind(doc_id).fetch_one(&mut *tx).await?;
|
||||
let v = verdict(found.as_deref(), wanted);
|
||||
let now = util::now();
|
||||
let to = quorums_json(wanted);
|
||||
if v == Verdict::Apply {
|
||||
sqlx::query("UPDATE documents SET quorums = ? WHERE id = ? AND quorums IS NULL")
|
||||
.bind(&to)
|
||||
.bind(doc_id)
|
||||
.execute(&mut *tx)
|
||||
.await?;
|
||||
if publishes {
|
||||
sqlx::query("INSERT INTO outbox (doc_id, reason, created_at, next_at) VALUES (?, ?, ?, ?)")
|
||||
.bind(doc_id)
|
||||
.bind(a.reason)
|
||||
.bind(&now)
|
||||
.bind(&now)
|
||||
.execute(&mut *tx)
|
||||
.await?;
|
||||
}
|
||||
}
|
||||
let (outcome, reason) = match v {
|
||||
Verdict::Apply => ("applied", None),
|
||||
Verdict::Present => ("present", None),
|
||||
Verdict::Refuse(why) => ("refused", Some(why)),
|
||||
};
|
||||
sqlx::query(
|
||||
"INSERT INTO quorum_amendments (amendment_id, doc_id, from_quorums, to_quorums, outcome, reason, at)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?)",
|
||||
)
|
||||
.bind(a.id)
|
||||
.bind(doc_id)
|
||||
.bind(&found)
|
||||
.bind(&to)
|
||||
.bind(outcome)
|
||||
.bind(reason)
|
||||
.bind(&now)
|
||||
.execute(&mut *tx)
|
||||
.await?;
|
||||
tx.commit().await?;
|
||||
Ok(Some(v))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
const ML: [u32; 5] = [12, 12, 50, 50, 50];
|
||||
|
||||
#[test]
|
||||
fn written_where_none_kept_where_other() {
|
||||
assert_eq!(verdict(None, &ML), Verdict::Apply);
|
||||
assert_eq!(verdict(Some("[12,12,50,50,50]"), &ML), Verdict::Present);
|
||||
assert_eq!(verdict(Some("[12, 12, 50, 50, 50]"), &ML), Verdict::Present, "the same numbers");
|
||||
assert_eq!(verdict(Some("[3,3,3,3,3]"), &ML), Verdict::Refuse("quorums_set"));
|
||||
assert_eq!(verdict(Some("not json"), &ML), Verdict::Refuse("quorums_set"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_amendment_says_what_the_seed_says() {
|
||||
let seed: serde_json::Value =
|
||||
serde_json::from_str(include_str!("../../../seeds/acte-engagement-certification.json")).expect("seed");
|
||||
for (group, quorums) in AMENDMENTS[0].quorums {
|
||||
let inst = seed["instances"].as_array().expect("instances").iter().find(|i| i["group"] == *group);
|
||||
assert_eq!(inst.map(|i| i["quorums"].clone()), Some(serde_json::json!(quorums)), "{group}");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,373 @@
|
||||
//! Retirements — the units a seed no longer holds, taken out of an existing document
|
||||
//! without erasing anything (ADR 0014).
|
||||
//!
|
||||
//! Yvv, 2026-11-03 (lot T4): « Attention les 3 derniers modules ne sont pas de votes :
|
||||
//! ordonnancement, inertie, et formule de vote sont des infos de fonctionnement. Les votes
|
||||
//! se font à leur place, et les infos doivent trouver leur place dans l'ux. […] Vire les 3
|
||||
//! du bas de la liste. » The articles of the sections `formule` (F1, F1.1, F1.2, F1.3),
|
||||
//! `inertie` (N1, N1.1) and `ordonnancement` (O1), and the order of each section, say how
|
||||
//! the vote works: they are no texts to vote. A new base is born without them (the seed no
|
||||
//! longer holds them). An existing base keeps every row of them — units, versions,
|
||||
//! revisions, translations, the publication queue — and marks the units retired
|
||||
//! (`units.retired_at`, `retired_reason`): nothing else is written on them.
|
||||
//!
|
||||
//! **Out of the document.** A unit retired is not read with its document (`docs::load_state`
|
||||
//! reads the units with `retired_at IS NULL`, and their versions only), so it is neither
|
||||
//! listed, nor voted on (a vote, a grouped vote, a ballot), nor proposed, corrected,
|
||||
//! translated or objected on (`proposals::guarded`: 404), nor given a change of inertia, a
|
||||
//! quorum or an order (the unit is not found: 404), nor counted (`docs::group`,
|
||||
//! `overview::modules`), nor published (`publish`: `document.json` and the Markdown read the
|
||||
//! document's state; `waits_for_a_text` counts the articles in it). The order of a section
|
||||
//! reads its articles in it (`DocState::members`). An older image
|
||||
//! (rollback) ignores the column and shows them again, exactly as they were.
|
||||
//!
|
||||
//! **The guard**, decided once per document, all of them or none: they leave only if no
|
||||
//! person left a trace on any of them, in any of its versions, whatever became of it —
|
||||
//! a vote (simulated included), a line of a ballot (whatever its fate), an adoption, a
|
||||
//! version written by a member (a text, a removal, a change of inertia, an order), a
|
||||
//! revision written by a person (a typo fix, a substantive change), a typo fix waiting or
|
||||
//! refused, a translation written by a member, an objection naming it, a quorum set. Their
|
||||
//! texts may be at any revision the seed amendments wrote (F1.2 and N1.1 at 4 after M6c, 5
|
||||
//! after M8): an amendment is no trace of a person. Refused, nothing moves; the reason and
|
||||
//! the unit it was found on are kept (`retirement_amendments`) and in the logs. Applied
|
||||
//! where the document publishes, the queue gets a row, which waits for the first text
|
||||
//! (ADR 0011); the rows queued before for those texts stay, and go out with it.
|
||||
|
||||
use crate::AppState;
|
||||
use crate::seed::SeedDoc;
|
||||
use crate::util;
|
||||
use sqlx::SqliteConnection;
|
||||
|
||||
/// The retirement of lot T4: the information on how the vote works (ADR 0014).
|
||||
pub const RETIREMENT: &str = "2026-11-03-fonctionnement-hors-vote";
|
||||
|
||||
/// Units to take out of every document instantiated from a seed, by section.
|
||||
pub struct Retirement {
|
||||
/// Stable identifier: each document's decision is kept under it.
|
||||
pub id: &'static str,
|
||||
/// Slug of the seed document.
|
||||
pub doc: &'static str,
|
||||
/// The sections that leave: their articles, and the order of each.
|
||||
pub sections: &'static [&'static str],
|
||||
/// Reason given to the publication queue, and so to the commit of the texts.
|
||||
pub reason: &'static str,
|
||||
}
|
||||
|
||||
pub const AMENDMENTS: &[Retirement] = &[Retirement {
|
||||
id: RETIREMENT,
|
||||
doc: "acte-engagement-certification",
|
||||
sections: &["formule", "inertie", "ordonnancement"],
|
||||
reason: "informations de fonctionnement sorties du document, qui ne se votent pas : formule de vote (F1, F1.1, F1.2, F1.3), inertie (N1, N1.1), ordonnancement (O1), et l'ordre de chaque section (ADR 0014)",
|
||||
}];
|
||||
|
||||
/// What the database says of one unit that would leave, all its versions together.
|
||||
#[derive(Debug, Clone, Default, PartialEq, Eq)]
|
||||
pub struct UnitFacts {
|
||||
pub code: String,
|
||||
/// Votes that count, of the server, proven or simulated.
|
||||
pub votes: i64,
|
||||
/// Lines of ballots, whatever their fate.
|
||||
pub ballots: i64,
|
||||
/// Adoptions, versions voted into force or confirmed, changes of inertia by a vote.
|
||||
pub adopted: i64,
|
||||
/// Versions written by a member: a text, a removal, a change of inertia, an order.
|
||||
pub member_versions: i64,
|
||||
/// Revisions written by a person, or of a kind no seed amendment writes; typo fixes.
|
||||
pub corrected: i64,
|
||||
/// Translations written or corrected by a member.
|
||||
pub member_translations: i64,
|
||||
/// Objections naming one of its versions, standing or lifted.
|
||||
pub signals: i64,
|
||||
/// Its own quorum set, or a change of it journaled.
|
||||
pub quorum_set: i64,
|
||||
}
|
||||
|
||||
impl UnitFacts {
|
||||
/// The first trace of a person found on it, if any.
|
||||
pub fn trace(&self) -> Option<&'static str> {
|
||||
[
|
||||
(self.votes, "votes"),
|
||||
(self.ballots, "ballots"),
|
||||
(self.adopted, "adopted"),
|
||||
(self.member_versions, "member_proposal"),
|
||||
(self.corrected, "corrected"),
|
||||
(self.member_translations, "translation_edited"),
|
||||
(self.signals, "signalled"),
|
||||
(self.quorum_set, "quorum_set"),
|
||||
]
|
||||
.into_iter()
|
||||
.find(|(n, _)| *n > 0)
|
||||
.map(|(_, why)| why)
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub enum Verdict {
|
||||
Apply,
|
||||
/// Born without them, or already out: nothing to do.
|
||||
Present,
|
||||
/// A trace of a person, and the unit it was found on.
|
||||
Refuse(&'static str, String),
|
||||
}
|
||||
|
||||
/// The rule, apart from the database: all of them leave, or none.
|
||||
pub fn verdict(units: &[UnitFacts]) -> Verdict {
|
||||
if units.is_empty() {
|
||||
return Verdict::Present;
|
||||
}
|
||||
for u in units {
|
||||
if let Some(why) = u.trace() {
|
||||
return Verdict::Refuse(why, u.code.clone());
|
||||
}
|
||||
}
|
||||
Verdict::Apply
|
||||
}
|
||||
|
||||
/// A decision taken at this start.
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct Outcome {
|
||||
pub amendment: &'static str,
|
||||
pub group: String,
|
||||
pub verdict: Verdict,
|
||||
pub retired: Vec<String>,
|
||||
}
|
||||
|
||||
/// Decides every retirement not yet decided, document by document.
|
||||
pub async fn apply(state: &AppState, seeds: &[SeedDoc], list: &[Retirement]) -> anyhow::Result<Vec<Outcome>> {
|
||||
let mut out = Vec::new();
|
||||
for a in list {
|
||||
if !seed_says(seeds, a) {
|
||||
continue;
|
||||
}
|
||||
let docs: Vec<(String, String, Option<String>)> =
|
||||
sqlx::query_as("SELECT id, group_slug, publish_path FROM documents WHERE slug = ? ORDER BY group_slug")
|
||||
.bind(a.doc)
|
||||
.fetch_all(&state.db)
|
||||
.await?;
|
||||
for (doc_id, group, publish_path) in docs {
|
||||
let Some((v, retired)) = decide(state, a, &doc_id, publish_path.is_some()).await? else { continue };
|
||||
match &v {
|
||||
Verdict::Apply => {
|
||||
tracing::info!(amendment = a.id, %group, retired = ?retired, "units out of the document")
|
||||
}
|
||||
Verdict::Present => {
|
||||
tracing::info!(amendment = a.id, %group, "retirement: none of them in the document")
|
||||
}
|
||||
Verdict::Refuse(why, unit) => tracing::warn!(
|
||||
amendment = a.id, %group, reason = why, %unit,
|
||||
"retirement refused: a person left a trace, they stay in the document"
|
||||
),
|
||||
}
|
||||
if v == Verdict::Apply && publish_path.is_some() {
|
||||
state.publish_wake.notify_one();
|
||||
}
|
||||
out.push(Outcome { amendment: a.id, group, verdict: v, retired });
|
||||
}
|
||||
}
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
/// The seed must no longer hold any unit of those sections: a seed that still gives them
|
||||
/// never takes them out.
|
||||
fn seed_says(seeds: &[SeedDoc], a: &Retirement) -> bool {
|
||||
let says = seeds
|
||||
.iter()
|
||||
.find(|s| s.slug == a.doc)
|
||||
.is_some_and(|s| s.units.iter().all(|u| u.section.as_deref().is_none_or(|x| !a.sections.contains(&x))));
|
||||
if !says {
|
||||
tracing::error!(
|
||||
amendment = a.id,
|
||||
doc = a.doc,
|
||||
"retirement: the seed still holds these sections, or no such document, nothing decided"
|
||||
);
|
||||
}
|
||||
says
|
||||
}
|
||||
|
||||
async fn unit_facts(conn: &mut SqliteConnection, unit_id: &str, code: &str) -> sqlx::Result<UnitFacts> {
|
||||
let n = |sql: &'static str| sqlx::query_scalar::<_, i64>(sql).bind(unit_id);
|
||||
Ok(UnitFacts {
|
||||
code: code.to_owned(),
|
||||
votes: n("SELECT COUNT(*) FROM votes WHERE proposal_id IN (SELECT id FROM proposals WHERE unit_id = ?1)")
|
||||
.fetch_one(&mut *conn)
|
||||
.await?,
|
||||
ballots: n(
|
||||
"SELECT COUNT(*) FROM ballot_lines WHERE proposal_id IN (SELECT id FROM proposals WHERE unit_id = ?1)",
|
||||
)
|
||||
.fetch_one(&mut *conn)
|
||||
.await?,
|
||||
adopted: n("SELECT (SELECT COUNT(*) FROM adoptions WHERE unit_id = ?1)
|
||||
+ (SELECT COUNT(*) FROM proposals WHERE unit_id = ?1
|
||||
AND (entered_at IS NOT NULL OR confirmed_at IS NOT NULL))
|
||||
+ (SELECT COUNT(*) FROM inertia_changes WHERE unit_id = ?1 AND cause = 'vote')")
|
||||
.fetch_one(&mut *conn)
|
||||
.await?,
|
||||
member_versions: n(
|
||||
"SELECT COUNT(*) FROM proposals WHERE unit_id = ?1 AND (origin <> 'seed' OR author_id IS NOT NULL)",
|
||||
)
|
||||
.fetch_one(&mut *conn)
|
||||
.await?,
|
||||
corrected: n(
|
||||
"SELECT (SELECT COUNT(*) FROM typos
|
||||
WHERE proposal_id IN (SELECT id FROM proposals WHERE unit_id = ?1))
|
||||
+ (SELECT COUNT(*) FROM revisions
|
||||
WHERE proposal_id IN (SELECT id FROM proposals WHERE unit_id = ?1)
|
||||
AND (author_id IS NOT NULL OR kind NOT IN ('initial', 'amendment', 'seed_proposal')))",
|
||||
)
|
||||
.fetch_one(&mut *conn)
|
||||
.await?,
|
||||
member_translations: n("SELECT COUNT(*) FROM translations
|
||||
WHERE proposal_id IN (SELECT id FROM proposals WHERE unit_id = ?1)
|
||||
AND (origin <> 'seed' OR updated_by IS NOT NULL)")
|
||||
.fetch_one(&mut *conn)
|
||||
.await?,
|
||||
signals: n("SELECT COUNT(*) FROM signals
|
||||
WHERE proposal_id IN (SELECT id FROM proposals WHERE unit_id = ?1)
|
||||
OR target_proposal_id IN (SELECT id FROM proposals WHERE unit_id = ?1)")
|
||||
.fetch_one(&mut *conn)
|
||||
.await?,
|
||||
quorum_set: n("SELECT (SELECT COUNT(*) FROM units WHERE id = ?1 AND quorum IS NOT NULL)
|
||||
+ (SELECT COUNT(*) FROM quorum_changes WHERE unit_id = ?1)")
|
||||
.fetch_one(&mut *conn)
|
||||
.await?,
|
||||
})
|
||||
}
|
||||
|
||||
/// One document, in one transaction: `None` when it was decided at an earlier start.
|
||||
async fn decide(
|
||||
state: &AppState,
|
||||
a: &Retirement,
|
||||
doc_id: &str,
|
||||
publishes: bool,
|
||||
) -> anyhow::Result<Option<(Verdict, Vec<String>)>> {
|
||||
let mut tx = state.db.begin().await?;
|
||||
let decided: i64 =
|
||||
sqlx::query_scalar("SELECT COUNT(*) FROM retirement_amendments WHERE amendment_id = ? AND doc_id = ?")
|
||||
.bind(a.id)
|
||||
.bind(doc_id)
|
||||
.fetch_one(&mut *tx)
|
||||
.await?;
|
||||
if decided > 0 {
|
||||
return Ok(None);
|
||||
}
|
||||
// The units of those sections still in the document: the articles in their order, then
|
||||
// the order of each section.
|
||||
let marks = vec!["?"; a.sections.len()].join(", ");
|
||||
let sql = format!(
|
||||
"SELECT id, code FROM units WHERE doc_id = ? AND retired_at IS NULL AND section IN ({marks})
|
||||
ORDER BY kind <> 'article', position, code"
|
||||
);
|
||||
let mut q = sqlx::query_as::<_, (String, String)>(&sql).bind(doc_id);
|
||||
for s in a.sections {
|
||||
q = q.bind(*s);
|
||||
}
|
||||
let units = q.fetch_all(&mut *tx).await?;
|
||||
let mut facts = Vec::with_capacity(units.len());
|
||||
for (id, code) in &units {
|
||||
facts.push(unit_facts(&mut tx, id, code).await?);
|
||||
}
|
||||
let mut v = verdict(&facts);
|
||||
let now = util::now();
|
||||
let mut retired = Vec::new();
|
||||
if v == Verdict::Apply {
|
||||
for (id, code) in &units {
|
||||
// Only the two columns of the retirement; nothing else of the unit, nothing of its
|
||||
// versions, revisions or translations.
|
||||
let done =
|
||||
sqlx::query("UPDATE units SET retired_at = ?, retired_reason = ? WHERE id = ? AND retired_at IS NULL")
|
||||
.bind(&now)
|
||||
.bind(a.id)
|
||||
.bind(id)
|
||||
.execute(&mut *tx)
|
||||
.await?;
|
||||
if done.rows_affected() == 1 {
|
||||
retired.push(code.clone());
|
||||
}
|
||||
}
|
||||
if retired.len() != units.len() {
|
||||
// What was examined changed under us: nothing is written.
|
||||
tx.rollback().await?;
|
||||
tx = state.db.begin().await?;
|
||||
v = Verdict::Refuse("changed", String::new());
|
||||
retired.clear();
|
||||
} else if publishes {
|
||||
sqlx::query("INSERT INTO outbox (doc_id, reason, created_at, next_at) VALUES (?, ?, ?, ?)")
|
||||
.bind(doc_id)
|
||||
.bind(a.reason)
|
||||
.bind(&now)
|
||||
.bind(&now)
|
||||
.execute(&mut *tx)
|
||||
.await?;
|
||||
}
|
||||
}
|
||||
let (outcome, reason, unit) = match &v {
|
||||
Verdict::Apply => ("applied", None, None),
|
||||
Verdict::Present => ("present", None, None),
|
||||
Verdict::Refuse(why, unit) => ("refused", Some(*why), Some(unit.clone()).filter(|u| !u.is_empty())),
|
||||
};
|
||||
sqlx::query(
|
||||
"INSERT INTO retirement_amendments (amendment_id, doc_id, outcome, reason, unit_code, retired, at)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?)",
|
||||
)
|
||||
.bind(a.id)
|
||||
.bind(doc_id)
|
||||
.bind(outcome)
|
||||
.bind(reason)
|
||||
.bind(unit)
|
||||
.bind((v == Verdict::Apply).then(|| serde_json::to_string(&retired).unwrap_or_else(|_| "[]".into())))
|
||||
.bind(&now)
|
||||
.execute(&mut *tx)
|
||||
.await?;
|
||||
tx.commit().await?;
|
||||
Ok(Some((v, retired)))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn untouched(code: &str) -> UnitFacts {
|
||||
UnitFacts { code: code.into(), ..UnitFacts::default() }
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn untouched_they_all_leave() {
|
||||
assert_eq!(verdict(&[untouched("F1"), untouched("F1.2"), untouched("ordre.formule")]), Verdict::Apply);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn born_without_them_nothing_to_do() {
|
||||
assert_eq!(verdict(&[]), Verdict::Present);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn any_trace_of_a_person_keeps_them_all_and_says_where() {
|
||||
let cases = [
|
||||
(UnitFacts { votes: 1, ..untouched("N1") }, "votes"),
|
||||
(UnitFacts { ballots: 1, ..untouched("N1") }, "ballots"),
|
||||
(UnitFacts { adopted: 1, ..untouched("N1") }, "adopted"),
|
||||
(UnitFacts { member_versions: 1, ..untouched("N1") }, "member_proposal"),
|
||||
(UnitFacts { corrected: 1, ..untouched("N1") }, "corrected"),
|
||||
(UnitFacts { member_translations: 1, ..untouched("N1") }, "translation_edited"),
|
||||
(UnitFacts { signals: 1, ..untouched("N1") }, "signalled"),
|
||||
(UnitFacts { quorum_set: 1, ..untouched("N1") }, "quorum_set"),
|
||||
];
|
||||
for (facts, why) in cases {
|
||||
let all = [untouched("F1"), facts.clone(), untouched("O1")];
|
||||
assert_eq!(verdict(&all), Verdict::Refuse(why, "N1".into()), "{facts:?}");
|
||||
}
|
||||
// The first trace, in the order of the units, then of the kinds.
|
||||
let two = [
|
||||
untouched("F1"),
|
||||
UnitFacts { signals: 1, votes: 2, ..untouched("F1.1") },
|
||||
UnitFacts { quorum_set: 1, ..untouched("N1") },
|
||||
];
|
||||
assert_eq!(verdict(&two), Verdict::Refuse("votes", "F1.1".into()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_shipped_retirement_takes_out_the_three_sections_of_operation() {
|
||||
assert_eq!(AMENDMENTS[0].id, RETIREMENT);
|
||||
assert_eq!(AMENDMENTS[0].sections, ["formule", "inertie", "ordonnancement"]);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,376 @@
|
||||
//! Seeds — so as not to start from a blank page.
|
||||
//!
|
||||
//! On start, missing groups are created and every `seeds/*.json` document is
|
||||
//! instantiated in each group it names, **only if that group does not hold it
|
||||
//! yet**. An existing document is never instantiated again: from its first
|
||||
//! vote on, it is runtime data. The one way a newer seed text reaches an
|
||||
//! existing document is a seed amendment (`seed_amend`): listed, decided once,
|
||||
//! and applied only to an article nobody has touched; a newer starting level of
|
||||
//! inertia, an inertia amendment (`inertia_amend`), on an article whose inertia
|
||||
//! no vote changed; modules the seed declares (the title, the order of each
|
||||
//! section, ADR 0009), a module amendment (`module_amend`), built from what the
|
||||
//! document holds; a starting text in force the seed now puts forward as a seed
|
||||
//! proposal (ADR 0011), a seed proposal amendment (`seed_proposal_amend`), on an
|
||||
//! article nobody has touched; the quorums by level the seed now gives a document
|
||||
//! (ADR 0013), a quorum amendment (`quorum_amend`), where the document holds none; an
|
||||
//! article the seed now holds, an article amendment (`article_amend`), placed where the
|
||||
//! seed puts it while the order of its section was never voted on; the units the seed no
|
||||
//! longer holds — the information on how the vote works (ADR 0014) —, a retirement
|
||||
//! (`retire`), where no person left a trace on them: they stay in the database, out of
|
||||
//! the document.
|
||||
|
||||
use crate::AppState;
|
||||
use crate::article_amend;
|
||||
use crate::inertia_amend;
|
||||
use crate::module_amend;
|
||||
use crate::modules;
|
||||
use crate::quorum_amend;
|
||||
use crate::retire;
|
||||
use crate::seed_amend;
|
||||
use crate::seed_proposal_amend;
|
||||
use crate::util::{self, LangMap};
|
||||
use ld_engine::inertia::{InertiaLevel, quorum_bounds};
|
||||
use serde::Deserialize;
|
||||
use sqlx::SqliteConnection;
|
||||
use std::path::Path;
|
||||
|
||||
/// The level of inertia a seed proposal starts at (ADR 0011). Yvv, 2026-11-02: « on
|
||||
/// pouvait l'amorcer avec une inertie basse, pour que le nombre de votes initiaux soit
|
||||
/// assez petit pour commencer l'expérience en mode prod » — level 2 (« basse »),
|
||||
/// confirmed by Yvv the same day (level 1, « très basse », was the other choice). The
|
||||
/// one place to change:
|
||||
/// the seed gives no level to an article it puts forward, and the seed proposal
|
||||
/// amendment (`seed_proposal_amend`) writes this one.
|
||||
pub const SEED_PROPOSAL_LEVEL: u8 = 2;
|
||||
|
||||
#[derive(Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct SeedDoc {
|
||||
pub slug: String,
|
||||
pub title: LangMap,
|
||||
pub summary: LangMap,
|
||||
pub source_lang: String,
|
||||
pub instances: Vec<SeedInstance>,
|
||||
/// The modules of the document (ADR 0009): its title, the order of each section.
|
||||
#[serde(default)]
|
||||
pub modules: Option<SeedModules>,
|
||||
pub units: Vec<SeedUnit>,
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
pub struct SeedModules {
|
||||
pub title: Option<SeedModule>,
|
||||
pub order: Option<SeedModule>,
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
pub struct SeedModule {
|
||||
/// The level it starts at, 1 to 5.
|
||||
pub inertia: InertiaLevel,
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct SeedInstance {
|
||||
pub group: String,
|
||||
/// The reference quorum Q₀ (the C of before): p0 = 2Q₀/W while too few proposals are
|
||||
/// voted on; every level's quorum where `quorums` is missing.
|
||||
pub vote_floor: f64,
|
||||
pub publish_path: Option<String>,
|
||||
/// The quorum of each level, 1 to 5 (ADR 0013): the default of an article at that level.
|
||||
#[serde(default)]
|
||||
pub quorums: Option<[u32; 5]>,
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
pub struct SeedUnit {
|
||||
pub code: String,
|
||||
pub section: Option<String>,
|
||||
pub position: f64,
|
||||
/// A level, 1 to 5 (a word of before is read as its level). None for an article
|
||||
/// the seed puts forward: it starts at `SEED_PROPOSAL_LEVEL`.
|
||||
#[serde(default)]
|
||||
pub inertia: Option<InertiaLevel>,
|
||||
pub versions: Vec<SeedVersion>,
|
||||
}
|
||||
|
||||
impl SeedUnit {
|
||||
/// Holds a seed proposal (ADR 0011).
|
||||
pub fn puts_forward(&self) -> bool {
|
||||
self.versions.iter().any(|v| v.seed_proposal)
|
||||
}
|
||||
|
||||
/// The level it starts at: its own, or the one of seed proposals.
|
||||
pub fn level(&self) -> InertiaLevel {
|
||||
match self.inertia {
|
||||
Some(level) => level,
|
||||
None => InertiaLevel::new(SEED_PROPOSAL_LEVEL).unwrap_or_default(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct SeedVersion {
|
||||
pub label: Option<String>,
|
||||
/// `in_force` (seed a minima: the title and the orders) or `open` (a proposal at
|
||||
/// zero votes).
|
||||
pub status: String,
|
||||
/// A seed proposal (ADR 0011): open, put forward to start the vote, not in force.
|
||||
#[serde(default)]
|
||||
pub seed_proposal: bool,
|
||||
pub title: LangMap,
|
||||
pub body: LangMap,
|
||||
}
|
||||
|
||||
pub async fn ensure_groups(state: &AppState) -> anyhow::Result<()> {
|
||||
for (position, g) in trustin_access::default_groups().into_iter().enumerate() {
|
||||
sqlx::query(
|
||||
"INSERT OR IGNORE INTO groups (slug, name, parent, policy, hidden, position) VALUES (?, ?, ?, ?, ?, ?)",
|
||||
)
|
||||
.bind(&g.slug)
|
||||
.bind(&g.name)
|
||||
.bind(&g.parent)
|
||||
.bind(g.policy.as_str())
|
||||
.bind(g.hidden)
|
||||
.bind(i64::try_from(position).unwrap_or(0))
|
||||
.execute(&state.db)
|
||||
.await?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// On start: the groups, the documents missing from them, then the seed, inertia,
|
||||
/// module, seed proposal, quorum, article amendments and the retirements (each decided
|
||||
/// once per document).
|
||||
pub async fn ensure(state: &AppState) -> anyhow::Result<()> {
|
||||
let seeds = documents(state).await?;
|
||||
seed_amend::apply(state, &seeds, seed_amend::AMENDMENTS).await?;
|
||||
inertia_amend::apply(state, &seeds, inertia_amend::AMENDMENTS).await?;
|
||||
module_amend::apply(state, &seeds, module_amend::AMENDMENTS).await?;
|
||||
// After the text amendments: a starting text replaced first, then put forward.
|
||||
seed_proposal_amend::apply(state, &seeds, seed_proposal_amend::AMENDMENTS).await?;
|
||||
quorum_amend::apply(state, &seeds, quorum_amend::AMENDMENTS).await?;
|
||||
article_amend::apply(state, &seeds, article_amend::AMENDMENTS).await?;
|
||||
retire::apply(state, &seeds, retire::AMENDMENTS).await?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// The groups and every seed document missing from its groups; returns the seeds read.
|
||||
pub async fn documents(state: &AppState) -> anyhow::Result<Vec<SeedDoc>> {
|
||||
ensure_groups(state).await?;
|
||||
let dir = state.cfg.seed_dir.clone();
|
||||
let mut entries = match tokio::fs::read_dir(&dir).await {
|
||||
Ok(e) => e,
|
||||
Err(e) => {
|
||||
tracing::warn!(dir = %dir.display(), error = %e, "no seed directory");
|
||||
return Ok(Vec::new());
|
||||
}
|
||||
};
|
||||
let mut files = Vec::new();
|
||||
while let Some(entry) = entries.next_entry().await? {
|
||||
let path = entry.path();
|
||||
if path.extension().is_some_and(|x| x == "json") {
|
||||
files.push(path);
|
||||
}
|
||||
}
|
||||
files.sort();
|
||||
let mut seeds = Vec::new();
|
||||
for path in files {
|
||||
seeds.push(load_file(state, &path).await?);
|
||||
}
|
||||
Ok(seeds)
|
||||
}
|
||||
|
||||
async fn load_file(state: &AppState, path: &Path) -> anyhow::Result<SeedDoc> {
|
||||
let raw = tokio::fs::read_to_string(path).await?;
|
||||
let seed: SeedDoc = serde_json::from_str(&raw).map_err(|e| anyhow::anyhow!("{}: {e}", path.display()))?;
|
||||
check(&seed).map_err(|e| anyhow::anyhow!("{}: {e}", path.display()))?;
|
||||
for inst in &seed.instances {
|
||||
let group_exists: i64 = sqlx::query_scalar("SELECT COUNT(*) FROM groups WHERE slug = ?")
|
||||
.bind(&inst.group)
|
||||
.fetch_one(&state.db)
|
||||
.await?;
|
||||
if group_exists == 0 {
|
||||
tracing::warn!(group = %inst.group, slug = %seed.slug, "seed names an unknown group");
|
||||
continue;
|
||||
}
|
||||
let present: i64 = sqlx::query_scalar("SELECT COUNT(*) FROM documents WHERE group_slug = ? AND slug = ?")
|
||||
.bind(&inst.group)
|
||||
.bind(&seed.slug)
|
||||
.fetch_one(&state.db)
|
||||
.await?;
|
||||
if present > 0 {
|
||||
continue;
|
||||
}
|
||||
instantiate(state, &seed, inst).await?;
|
||||
tracing::info!(group = %inst.group, slug = %seed.slug, "seed instantiated");
|
||||
}
|
||||
Ok(seed)
|
||||
}
|
||||
|
||||
/// A seed proposal is open, and its article takes its level from `SEED_PROPOSAL_LEVEL`
|
||||
/// alone; every other article says its level. A quorum is never under the lowest an
|
||||
/// author may set (`quorum_bounds`): 2, or 1 in the test group.
|
||||
fn check(seed: &SeedDoc) -> anyhow::Result<()> {
|
||||
for inst in &seed.instances {
|
||||
let (lowest, _) = quorum_bounds(1, inst.group == trustin_access::TEST_GROUP);
|
||||
if inst.quorums.is_some_and(|q| q.iter().any(|q| *q < lowest)) {
|
||||
anyhow::bail!("{}: a quorum under {lowest}", inst.group);
|
||||
}
|
||||
}
|
||||
for u in &seed.units {
|
||||
if u.versions.iter().any(|v| v.seed_proposal && v.status != "open") {
|
||||
anyhow::bail!("{}: a seed proposal is open, never in force", u.code);
|
||||
}
|
||||
match (u.puts_forward(), u.inertia.is_some()) {
|
||||
(true, true) => {
|
||||
anyhow::bail!("{}: a seed proposal starts at SEED_PROPOSAL_LEVEL, give it no level", u.code)
|
||||
}
|
||||
(false, false) => anyhow::bail!("{}: no level of inertia", u.code),
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn instantiate(state: &AppState, seed: &SeedDoc, inst: &SeedInstance) -> anyhow::Result<()> {
|
||||
let src = seed.source_lang.as_str();
|
||||
let now = util::now();
|
||||
let doc_id = util::id();
|
||||
let mut tx = state.db.begin().await?;
|
||||
sqlx::query(
|
||||
"INSERT INTO documents (id, group_slug, slug, title, summary, source_lang, vote_floor, publish_path, created_at)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)",
|
||||
)
|
||||
.bind(&doc_id)
|
||||
.bind(&inst.group)
|
||||
.bind(&seed.slug)
|
||||
.bind(util::lang_json(&seed.title))
|
||||
.bind(util::lang_json(&seed.summary))
|
||||
.bind(src)
|
||||
.bind(inst.vote_floor)
|
||||
.bind(&inst.publish_path)
|
||||
.bind(&now)
|
||||
.execute(&mut *tx)
|
||||
.await?;
|
||||
// The quorums by level, written apart: only where the seed gives them (ADR 0013).
|
||||
if let Some(q) = &inst.quorums {
|
||||
sqlx::query("UPDATE documents SET quorums = ? WHERE id = ?")
|
||||
.bind(quorums_json(q))
|
||||
.bind(&doc_id)
|
||||
.execute(&mut *tx)
|
||||
.await?;
|
||||
}
|
||||
for unit in &seed.units {
|
||||
write_unit(&mut tx, &doc_id, src, unit, unit.position, &now).await?;
|
||||
}
|
||||
// The modules, from the document just written: its title, the order of each section.
|
||||
if let Some(m) = &seed.modules {
|
||||
modules::create(&mut tx, &doc_id, m.into(), &now).await?;
|
||||
}
|
||||
if inst.publish_path.is_some() {
|
||||
sqlx::query(
|
||||
"INSERT INTO outbox (doc_id, reason, created_at, next_at) VALUES (?, 'publication initiale', ?, ?)",
|
||||
)
|
||||
.bind(&doc_id)
|
||||
.bind(&now)
|
||||
.bind(&now)
|
||||
.execute(&mut *tx)
|
||||
.await?;
|
||||
}
|
||||
tx.commit().await?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Writes one article of the seed into a document, at `position`: its unit (at its level),
|
||||
/// its versions — in force, open, put forward —, their first revision and their
|
||||
/// translations. A new base (`instantiate`), or an article a seed adds later
|
||||
/// (`article_amend`). Returns the unit's id.
|
||||
pub(crate) async fn write_unit(
|
||||
conn: &mut SqliteConnection,
|
||||
doc_id: &str,
|
||||
src: &str,
|
||||
unit: &SeedUnit,
|
||||
position: f64,
|
||||
now: &str,
|
||||
) -> sqlx::Result<String> {
|
||||
let pick = |m: &LangMap| m.get(src).cloned().unwrap_or_default();
|
||||
let unit_id = util::id();
|
||||
sqlx::query(
|
||||
"INSERT INTO units (id, doc_id, code, section, position, inertia, in_force_id, created_at)
|
||||
VALUES (?, ?, ?, ?, ?, ?, NULL, ?)",
|
||||
)
|
||||
.bind(&unit_id)
|
||||
.bind(doc_id)
|
||||
.bind(&unit.code)
|
||||
.bind(&unit.section)
|
||||
.bind(position)
|
||||
.bind(unit.level().as_db())
|
||||
.bind(now)
|
||||
.execute(&mut *conn)
|
||||
.await?;
|
||||
for v in &unit.versions {
|
||||
let pid = util::id();
|
||||
let (title, body) = (pick(&v.title), pick(&v.body));
|
||||
let status = if v.status == "in_force" { "in_force" } else { "open" };
|
||||
sqlx::query(
|
||||
"INSERT INTO proposals (id, unit_id, kind, title, body, status, origin, label, author_id, revision,
|
||||
created_at, updated_at, submitted_at)
|
||||
VALUES (?, ?, 'text', ?, ?, ?, 'seed', ?, NULL, 1, ?, ?, ?)",
|
||||
)
|
||||
.bind(&pid)
|
||||
.bind(&unit_id)
|
||||
.bind(&title)
|
||||
.bind(&body)
|
||||
.bind(status)
|
||||
.bind(&v.label)
|
||||
.bind(now)
|
||||
.bind(now)
|
||||
.bind(now)
|
||||
.execute(&mut *conn)
|
||||
.await?;
|
||||
sqlx::query(
|
||||
"INSERT INTO revisions (proposal_id, revision, kind, title, body, author_id, created_at) VALUES (?, 1, 'initial', ?, ?, NULL, ?)",
|
||||
)
|
||||
.bind(&pid)
|
||||
.bind(&title)
|
||||
.bind(&body)
|
||||
.bind(now)
|
||||
.execute(&mut *conn)
|
||||
.await?;
|
||||
// The mark, written apart: only where the seed puts the version forward.
|
||||
if v.seed_proposal && status == "open" {
|
||||
sqlx::query("UPDATE proposals SET seed_featured = 1 WHERE id = ?").bind(&pid).execute(&mut *conn).await?;
|
||||
}
|
||||
if status == "in_force" {
|
||||
sqlx::query("UPDATE units SET in_force_id = ? WHERE id = ?")
|
||||
.bind(&pid)
|
||||
.bind(&unit_id)
|
||||
.execute(&mut *conn)
|
||||
.await?;
|
||||
}
|
||||
let hash = util::source_hash(&title, &body);
|
||||
for lang in util::LANGS.iter().filter(|l| **l != src) {
|
||||
let (Some(t), Some(b)) = (v.title.get(*lang), v.body.get(*lang)) else { continue };
|
||||
sqlx::query(
|
||||
"INSERT INTO translations (proposal_id, lang, title, body, source_hash, origin, updated_by, updated_at)
|
||||
VALUES (?, ?, ?, ?, ?, 'seed', NULL, ?)",
|
||||
)
|
||||
.bind(&pid)
|
||||
.bind(lang)
|
||||
.bind(t)
|
||||
.bind(b)
|
||||
.bind(&hash)
|
||||
.bind(now)
|
||||
.execute(&mut *conn)
|
||||
.await?;
|
||||
}
|
||||
}
|
||||
Ok(unit_id)
|
||||
}
|
||||
|
||||
/// The quorums by level as `documents.quorums` holds them: `[12,12,50,50,50]`.
|
||||
pub fn quorums_json(quorums: &[u32; 5]) -> String {
|
||||
serde_json::to_string(quorums).unwrap_or_else(|_| "null".into())
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,588 @@
|
||||
//! Seed proposal amendments — a starting text in force made a seed proposal in
|
||||
//! an existing base (ADR 0011).
|
||||
//!
|
||||
//! Yvv, 2026-11-02: « pour le démarrage, conserve un statut particulier pour les
|
||||
//! 3 premiers modules. Mais ils ne sont pas en vigueur. Appelle les "Proposition
|
||||
//! seed" » — then: « on pouvait l'amorcer avec une inertie basse, pour que le
|
||||
//! nombre de votes initiaux soit assez petit pour commencer l'expérience en mode
|
||||
//! prod ». New bases get I1, E1 and E2 from the seed as open proposals put
|
||||
//! forward, at `seed::SEED_PROPOSAL_LEVEL`. An existing base — production holds
|
||||
//! them in force, seeds a minima, in Monnaie libre and in the test group — gets
|
||||
//! them through an amendment listed here, decided once per document and article.
|
||||
//!
|
||||
//! **The status**, only if nobody has touched the text in force:
|
||||
//!
|
||||
//! - it is the seed's own (origin `seed`), never voted into force, never
|
||||
//! confirmed (`entered_at`, `confirmed_at` empty);
|
||||
//! - nobody ever voted on it, on the server or in a ballot of the chain;
|
||||
//! - no typo fix (applied, waiting or refused), no member translation, no
|
||||
//! objection names it; its history is the seed's alone: its initial text, and
|
||||
//! at most the seed amendment that replaced it (I1, the « Raison d'être »);
|
||||
//! - its text is the seed proposal's of today (fingerprint).
|
||||
//!
|
||||
//! Applied, the same version stays — its text, translations, history and votes
|
||||
//! (none) — and becomes open and marked; the article has no text in force any
|
||||
//! more; its history gets a line (kind `seed_proposal`).
|
||||
//!
|
||||
//! **The level**, with the status only, and only if nobody has touched it: no
|
||||
//! change of inertia proposed by a member that is open, applied or voted on, no
|
||||
//! vote that changed it, and a level the seed gave the article before (I1: 3;
|
||||
//! E1 and E2: 3, then 4 since 30/10). Applied, the article says so
|
||||
//! (`inertia_changes`, « réglage de départ du texte »). Refused, it keeps its
|
||||
//! level and the status still changes.
|
||||
//!
|
||||
//! Each decision is kept in `seed_proposal_amendments` (status and level:
|
||||
//! applied, refused and why, or present) and in the logs. Where the document
|
||||
//! publishes, the publication queue gets a row — and waits while no article is
|
||||
//! in force (`publish`, ADR 0011).
|
||||
|
||||
use crate::AppState;
|
||||
use crate::seed::{self, SeedDoc};
|
||||
use crate::seed_amend::{self, fingerprint};
|
||||
use crate::util;
|
||||
use ld_engine::inertia::InertiaLevel;
|
||||
use sqlx::SqliteConnection;
|
||||
|
||||
/// One starting text in force to put forward, in every document instantiated from a seed.
|
||||
pub struct SeedProposalAmendment {
|
||||
/// Stable identifier: each document's decision is kept under it.
|
||||
pub id: &'static str,
|
||||
/// Slug of the seed document.
|
||||
pub doc: &'static str,
|
||||
/// Code of the article.
|
||||
pub unit: &'static str,
|
||||
/// Fingerprint of the text (source language): the seed proposal of the seed
|
||||
/// today, which must hash to it, and the text in force it expects.
|
||||
pub text: &'static str,
|
||||
/// The levels the article may still hold for its level to go low: those the seed
|
||||
/// gave it before.
|
||||
pub levels: &'static [u8],
|
||||
/// Reason given to the publication queue, and so to the texts repository.
|
||||
pub reason: &'static str,
|
||||
/// The seed amendment that gives the seed proposal another text since (lot T4): the seed
|
||||
/// no longer puts this one forward, and this amendment decides nothing more, without
|
||||
/// an error (the decisions already taken stay).
|
||||
pub superseded_by: Option<&'static str>,
|
||||
}
|
||||
|
||||
pub const AMENDMENTS: &[SeedProposalAmendment] = &[
|
||||
SeedProposalAmendment {
|
||||
id: "2026-11-02-i1-proposition-seed",
|
||||
doc: "acte-engagement-certification",
|
||||
unit: "I1",
|
||||
text: "a7095d70602a0fc60d0904e9ffbce6d8520914ce3cb424d8f75e48bf6cfdf41a",
|
||||
levels: &[3],
|
||||
reason: "Proposition seed : I1 sort de la vigueur",
|
||||
superseded_by: Some("2026-11-03-i1-amorce"),
|
||||
},
|
||||
SeedProposalAmendment {
|
||||
id: "2026-11-02-e1-proposition-seed",
|
||||
doc: "acte-engagement-certification",
|
||||
unit: "E1",
|
||||
text: "bdca2713fdb1186bc59ac130f13a205a35376986f14f7cfba7223b37ed9a41bd",
|
||||
levels: &[3, 4],
|
||||
reason: "Proposition seed : E1 sort de la vigueur",
|
||||
superseded_by: Some("2026-11-03-e1-amorce"),
|
||||
},
|
||||
SeedProposalAmendment {
|
||||
id: "2026-11-02-e2-proposition-seed",
|
||||
doc: "acte-engagement-certification",
|
||||
unit: "E2",
|
||||
text: "bde76a30edaf8b623f3de0ff7eb94ec57eb5134b3c8aeae1590798fb6b9956f0",
|
||||
levels: &[3, 4],
|
||||
reason: "Proposition seed : E2 sort de la vigueur",
|
||||
superseded_by: Some("2026-11-03-e2-amorce"),
|
||||
},
|
||||
];
|
||||
|
||||
/// What the database says of the version in force.
|
||||
#[derive(Debug, Clone, Default, PartialEq, Eq)]
|
||||
pub struct Facts {
|
||||
/// As a seed amendment reads it.
|
||||
pub text: seed_amend::Facts,
|
||||
/// Lines of ballots of the chain naming it, whatever became of them.
|
||||
pub ballots: i64,
|
||||
}
|
||||
|
||||
/// What the article holds.
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub enum Found {
|
||||
/// A version in force.
|
||||
InForce(Facts),
|
||||
/// No text in force, and its seed proposal: born so, or already amended.
|
||||
SeedProposal,
|
||||
/// Neither.
|
||||
Nothing,
|
||||
}
|
||||
|
||||
/// What the database says of the article's level.
|
||||
#[derive(Debug, Clone, Default, PartialEq, Eq)]
|
||||
pub struct LevelFacts {
|
||||
/// `units.inertia` as written.
|
||||
pub raw: String,
|
||||
/// Changes of inertia proposed on it that are open or applied, or hold a vote.
|
||||
pub inertia_proposals: i64,
|
||||
/// Changes of its inertia that a vote brought into force.
|
||||
pub voted_changes: i64,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum Verdict {
|
||||
Apply,
|
||||
/// Already so: nothing to do.
|
||||
Present,
|
||||
Refuse(&'static str),
|
||||
}
|
||||
|
||||
impl Verdict {
|
||||
fn outcome(self) -> (&'static str, Option<&'static str>) {
|
||||
match self {
|
||||
Verdict::Apply => ("applied", None),
|
||||
Verdict::Present => ("present", None),
|
||||
Verdict::Refuse(why) => ("refused", Some(why)),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// The status, apart from the database: any trace of a person keeps the text in force.
|
||||
pub fn verdict(found: &Found, a: &SeedProposalAmendment) -> Verdict {
|
||||
let f = match found {
|
||||
Found::Nothing => return Verdict::Refuse("no_text_in_force"),
|
||||
Found::SeedProposal => return Verdict::Present,
|
||||
Found::InForce(f) => f,
|
||||
};
|
||||
let t = &f.text;
|
||||
let kinds: Vec<&str> = t.revisions.iter().map(String::as_str).collect();
|
||||
let seed_history = matches!((kinds.as_slice(), t.revision), (["initial"], 1) | (["initial", "amendment"], 2));
|
||||
let refusal = if t.origin != "seed" {
|
||||
"not_a_seed"
|
||||
} else if t.entered {
|
||||
"voted_into_force"
|
||||
} else if t.confirmed {
|
||||
"confirmed"
|
||||
} else if t.votes > 0 {
|
||||
"votes"
|
||||
} else if f.ballots > 0 {
|
||||
"ballots"
|
||||
} else if t.typos > 0 || kinds.iter().any(|k| matches!(*k, "typo" | "revert")) {
|
||||
"corrected"
|
||||
} else if !seed_history {
|
||||
"revised"
|
||||
} else if t.member_translations > 0 {
|
||||
"translation_edited"
|
||||
} else if t.signals > 0 {
|
||||
"signalled"
|
||||
} else if t.fingerprint != a.text {
|
||||
"text_differs"
|
||||
} else {
|
||||
return Verdict::Apply;
|
||||
};
|
||||
Verdict::Refuse(refusal)
|
||||
}
|
||||
|
||||
/// The level, apart from the database: it goes low with the status only, and any
|
||||
/// change of inertia a person proposed or voted keeps it.
|
||||
pub fn level_verdict(status: Verdict, facts: Option<&LevelFacts>, a: &SeedProposalAmendment, to: u8) -> Verdict {
|
||||
if let Verdict::Refuse(_) = status {
|
||||
return Verdict::Refuse("not_a_seed_proposal");
|
||||
}
|
||||
let Some(f) = facts else { return Verdict::Refuse("no_article") };
|
||||
let Some(level) = InertiaLevel::parse(&f.raw) else { return Verdict::Refuse("level_unreadable") };
|
||||
if level.get() == to {
|
||||
Verdict::Present
|
||||
} else if f.voted_changes > 0 {
|
||||
Verdict::Refuse("changed_by_vote")
|
||||
} else if f.inertia_proposals > 0 {
|
||||
Verdict::Refuse("inertia_proposal")
|
||||
} else if !a.levels.contains(&level.get()) {
|
||||
Verdict::Refuse("level_differs")
|
||||
} else {
|
||||
Verdict::Apply
|
||||
}
|
||||
}
|
||||
|
||||
/// A decision taken at this start.
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct Outcome {
|
||||
pub amendment: &'static str,
|
||||
pub group: String,
|
||||
pub status: Verdict,
|
||||
pub level: Verdict,
|
||||
}
|
||||
|
||||
/// Decides every amendment not yet decided, document by document.
|
||||
pub async fn apply(
|
||||
state: &AppState,
|
||||
seeds: &[SeedDoc],
|
||||
list: &[SeedProposalAmendment],
|
||||
) -> anyhow::Result<Vec<Outcome>> {
|
||||
let mut out = Vec::new();
|
||||
let to = seed::SEED_PROPOSAL_LEVEL;
|
||||
for a in list {
|
||||
if !seed_says(seeds, a) {
|
||||
continue;
|
||||
}
|
||||
let docs: Vec<(String, String, Option<String>)> =
|
||||
sqlx::query_as("SELECT id, group_slug, publish_path FROM documents WHERE slug = ? ORDER BY group_slug")
|
||||
.bind(a.doc)
|
||||
.fetch_all(&state.db)
|
||||
.await?;
|
||||
for (doc_id, group, publish_path) in docs {
|
||||
let Some((status, level)) = decide(state, a, to, &doc_id, publish_path.is_some()).await? else {
|
||||
continue;
|
||||
};
|
||||
let (s, sr) = status.outcome();
|
||||
let (l, lr) = level.outcome();
|
||||
if matches!(status, Verdict::Refuse(_)) || matches!(level, Verdict::Refuse(_)) {
|
||||
tracing::warn!(
|
||||
amendment = a.id, %group, unit = a.unit, status = s, reason = sr, level = l, level_reason = lr,
|
||||
"seed proposal amendment: refused where the article was touched, it keeps what it had"
|
||||
);
|
||||
} else {
|
||||
tracing::info!(
|
||||
amendment = a.id, %group, unit = a.unit, status = s, level = l, to_level = to,
|
||||
"seed proposal amendment"
|
||||
);
|
||||
}
|
||||
if (status == Verdict::Apply || level == Verdict::Apply) && publish_path.is_some() {
|
||||
state.publish_wake.notify_one();
|
||||
}
|
||||
out.push(Outcome { amendment: a.id, group, status, level });
|
||||
}
|
||||
}
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
/// The seed must put the article forward, with the text the amendment names and
|
||||
/// no level of its own: a seed edited later never reaches a base by this way.
|
||||
fn seed_says(seeds: &[SeedDoc], a: &SeedProposalAmendment) -> bool {
|
||||
let unit = seeds.iter().find(|s| s.slug == a.doc).and_then(|s| {
|
||||
let src = s.source_lang.as_str();
|
||||
let u = s.units.iter().find(|u| u.code == a.unit)?;
|
||||
let v = u.versions.iter().find(|v| v.seed_proposal)?;
|
||||
Some((u, fingerprint(v.title.get(src)?, v.body.get(src)?)))
|
||||
});
|
||||
match unit {
|
||||
Some((u, print)) if print == a.text && u.inertia.is_none() => true,
|
||||
_ if a.superseded_by.is_some() => {
|
||||
tracing::debug!(
|
||||
amendment = a.id,
|
||||
unit = a.unit,
|
||||
superseded_by = a.superseded_by,
|
||||
"seed proposal amendment superseded: nothing decided"
|
||||
);
|
||||
false
|
||||
}
|
||||
_ => {
|
||||
tracing::error!(
|
||||
amendment = a.id,
|
||||
doc = a.doc,
|
||||
unit = a.unit,
|
||||
"seed proposal amendment: the seed does not put this text forward, nothing decided"
|
||||
);
|
||||
false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// What the article holds, and the version examined: in force, or its seed proposal.
|
||||
async fn found(
|
||||
conn: &mut SqliteConnection,
|
||||
unit_id: &str,
|
||||
in_force: Option<&str>,
|
||||
) -> anyhow::Result<(Found, Option<String>)> {
|
||||
if let Some(id) = in_force {
|
||||
let Some(text) = seed_amend::facts(&mut *conn, id, "in_force").await? else {
|
||||
return Ok((Found::Nothing, Some(id.to_owned())));
|
||||
};
|
||||
let ballots: i64 = sqlx::query_scalar("SELECT COUNT(*) FROM ballot_lines WHERE proposal_id = ?")
|
||||
.bind(id)
|
||||
.fetch_one(&mut *conn)
|
||||
.await?;
|
||||
return Ok((Found::InForce(Facts { text, ballots }), Some(id.to_owned())));
|
||||
}
|
||||
let featured: Option<String> = sqlx::query_scalar(
|
||||
"SELECT id FROM proposals WHERE unit_id = ? AND status = 'open' AND origin = 'seed' AND seed_featured = 1
|
||||
ORDER BY rowid LIMIT 1",
|
||||
)
|
||||
.bind(unit_id)
|
||||
.fetch_optional(&mut *conn)
|
||||
.await?;
|
||||
Ok(match featured {
|
||||
Some(id) => (Found::SeedProposal, Some(id)),
|
||||
None => (Found::Nothing, None),
|
||||
})
|
||||
}
|
||||
|
||||
async fn level_facts(conn: &mut SqliteConnection, unit_id: &str, raw: String) -> anyhow::Result<LevelFacts> {
|
||||
let inertia_proposals: i64 = sqlx::query_scalar(
|
||||
"SELECT COUNT(*) FROM proposals p WHERE p.unit_id = ? AND p.kind = 'inertia'
|
||||
AND (p.status IN ('open', 'applied') OR EXISTS (SELECT 1 FROM votes v WHERE v.proposal_id = p.id))",
|
||||
)
|
||||
.bind(unit_id)
|
||||
.fetch_one(&mut *conn)
|
||||
.await?;
|
||||
let voted_changes: i64 = sqlx::query_scalar(
|
||||
"SELECT (SELECT COUNT(*) FROM adoptions WHERE unit_id = ?1 AND kind = 'inertia')
|
||||
+ (SELECT COUNT(*) FROM inertia_changes WHERE unit_id = ?1 AND cause = 'vote')",
|
||||
)
|
||||
.bind(unit_id)
|
||||
.fetch_one(&mut *conn)
|
||||
.await?;
|
||||
Ok(LevelFacts { raw, inertia_proposals, voted_changes })
|
||||
}
|
||||
|
||||
/// One document, in one transaction: `None` when it was decided at an earlier start.
|
||||
async fn decide(
|
||||
state: &AppState,
|
||||
a: &SeedProposalAmendment,
|
||||
to: u8,
|
||||
doc_id: &str,
|
||||
publishes: bool,
|
||||
) -> anyhow::Result<Option<(Verdict, Verdict)>> {
|
||||
let mut tx = state.db.begin().await?;
|
||||
let decided: i64 =
|
||||
sqlx::query_scalar("SELECT COUNT(*) FROM seed_proposal_amendments WHERE amendment_id = ? AND doc_id = ?")
|
||||
.bind(a.id)
|
||||
.bind(doc_id)
|
||||
.fetch_one(&mut *tx)
|
||||
.await?;
|
||||
if decided > 0 {
|
||||
return Ok(None);
|
||||
}
|
||||
let unit: Option<(String, String, Option<String>)> =
|
||||
sqlx::query_as("SELECT id, inertia, in_force_id FROM units WHERE doc_id = ? AND code = ?")
|
||||
.bind(doc_id)
|
||||
.bind(a.unit)
|
||||
.fetch_optional(&mut *tx)
|
||||
.await?;
|
||||
let ((status_found, examined), level_found) = match &unit {
|
||||
Some((id, raw, in_force)) => {
|
||||
(found(&mut tx, id, in_force.as_deref()).await?, Some(level_facts(&mut tx, id, raw.clone()).await?))
|
||||
}
|
||||
None => ((Found::Nothing, None), None),
|
||||
};
|
||||
let mut status = verdict(&status_found, a);
|
||||
let mut level = level_verdict(status, level_found.as_ref(), a, to);
|
||||
let now = util::now();
|
||||
let to_db = InertiaLevel::new(to).map(InertiaLevel::as_db).unwrap_or_default();
|
||||
if let (Verdict::Apply, Some((unit_id, _, Some(pid)))) = (status, &unit) {
|
||||
// Written only over the version that was judged, still untouched and in force.
|
||||
let revision = match &status_found {
|
||||
Found::InForce(f) => f.text.revision,
|
||||
_ => 0,
|
||||
};
|
||||
let left = sqlx::query("UPDATE units SET in_force_id = NULL WHERE id = ? AND in_force_id = ?")
|
||||
.bind(unit_id)
|
||||
.bind(pid)
|
||||
.execute(&mut *tx)
|
||||
.await?;
|
||||
let opened = sqlx::query(
|
||||
"UPDATE proposals SET status = 'open', seed_featured = 1, left_at = ?, updated_at = ?
|
||||
WHERE id = ? AND status = 'in_force' AND origin = 'seed' AND entered_at IS NULL
|
||||
AND confirmed_at IS NULL AND revision = ?",
|
||||
)
|
||||
.bind(&now)
|
||||
.bind(&now)
|
||||
.bind(pid)
|
||||
.bind(revision)
|
||||
.execute(&mut *tx)
|
||||
.await?;
|
||||
if left.rows_affected() == 1 && opened.rows_affected() == 1 {
|
||||
// The history says it: the same text, at the same revision, now a seed proposal.
|
||||
sqlx::query(
|
||||
"INSERT INTO revisions (proposal_id, revision, kind, title, body, author_id, created_at)
|
||||
SELECT id, revision, 'seed_proposal', title, body, NULL, ? FROM proposals WHERE id = ?",
|
||||
)
|
||||
.bind(&now)
|
||||
.bind(pid)
|
||||
.execute(&mut *tx)
|
||||
.await?;
|
||||
} else {
|
||||
// Nothing is written: what was examined changed under us.
|
||||
tx.rollback().await?;
|
||||
tx = state.db.begin().await?;
|
||||
status = Verdict::Refuse("changed");
|
||||
level = level_verdict(status, level_found.as_ref(), a, to);
|
||||
}
|
||||
}
|
||||
if let (Verdict::Apply, Some((unit_id, raw, _))) = (level, &unit) {
|
||||
let done = sqlx::query("UPDATE units SET inertia = ? WHERE id = ? AND inertia = ?")
|
||||
.bind(&to_db)
|
||||
.bind(unit_id)
|
||||
.bind(raw)
|
||||
.execute(&mut *tx)
|
||||
.await?;
|
||||
if done.rows_affected() == 1 {
|
||||
let from = InertiaLevel::parse(raw).map_or_else(|| raw.clone(), InertiaLevel::as_db);
|
||||
sqlx::query(
|
||||
"INSERT INTO inertia_changes (unit_id, doc_id, from_level, to_level, cause, amendment_id, at)
|
||||
VALUES (?, ?, ?, ?, 'amendment', ?, ?)",
|
||||
)
|
||||
.bind(unit_id)
|
||||
.bind(doc_id)
|
||||
.bind(from)
|
||||
.bind(&to_db)
|
||||
.bind(a.id)
|
||||
.bind(&now)
|
||||
.execute(&mut *tx)
|
||||
.await?;
|
||||
} else {
|
||||
level = Verdict::Refuse("changed");
|
||||
}
|
||||
}
|
||||
if publishes && (status == Verdict::Apply || level == Verdict::Apply) {
|
||||
sqlx::query("INSERT INTO outbox (doc_id, reason, created_at, next_at) VALUES (?, ?, ?, ?)")
|
||||
.bind(doc_id)
|
||||
.bind(a.reason)
|
||||
.bind(&now)
|
||||
.bind(&now)
|
||||
.execute(&mut *tx)
|
||||
.await?;
|
||||
}
|
||||
let (outcome, reason) = status.outcome();
|
||||
let (level_outcome, level_reason) = level.outcome();
|
||||
sqlx::query(
|
||||
"INSERT INTO seed_proposal_amendments (amendment_id, doc_id, unit_code, proposal_id, outcome, reason,
|
||||
from_level, to_level, level_outcome, level_reason, at)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)",
|
||||
)
|
||||
.bind(a.id)
|
||||
.bind(doc_id)
|
||||
.bind(a.unit)
|
||||
.bind(examined)
|
||||
.bind(outcome)
|
||||
.bind(reason)
|
||||
.bind(unit.as_ref().map(|(_, raw, _)| raw.clone()))
|
||||
.bind(&to_db)
|
||||
.bind(level_outcome)
|
||||
.bind(level_reason)
|
||||
.bind(&now)
|
||||
.execute(&mut *tx)
|
||||
.await?;
|
||||
tx.commit().await?;
|
||||
Ok(Some((status, level)))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
const A: SeedProposalAmendment = SeedProposalAmendment {
|
||||
id: "test",
|
||||
doc: "doc",
|
||||
unit: "E1",
|
||||
text: "seed",
|
||||
levels: &[3, 4],
|
||||
reason: "Proposition seed : E1 sort de la vigueur",
|
||||
superseded_by: None,
|
||||
};
|
||||
|
||||
fn intact() -> Facts {
|
||||
Facts {
|
||||
text: seed_amend::Facts {
|
||||
origin: "seed".into(),
|
||||
revision: 1,
|
||||
revisions: vec!["initial".into()],
|
||||
fingerprint: "seed".into(),
|
||||
..seed_amend::Facts::default()
|
||||
},
|
||||
ballots: 0,
|
||||
}
|
||||
}
|
||||
|
||||
fn with(f: impl FnOnce(&mut seed_amend::Facts)) -> Found {
|
||||
let mut facts = intact();
|
||||
f(&mut facts.text);
|
||||
Found::InForce(facts)
|
||||
}
|
||||
|
||||
fn at(raw: &str) -> LevelFacts {
|
||||
LevelFacts { raw: raw.into(), ..LevelFacts::default() }
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_untouched_text_in_force_is_put_forward() {
|
||||
assert_eq!(verdict(&Found::InForce(intact()), &A), Verdict::Apply);
|
||||
// I1: its initial text, then the seed amendment of 30/10.
|
||||
let amended = with(|t| {
|
||||
t.revision = 2;
|
||||
t.revisions = vec!["initial".into(), "amendment".into()];
|
||||
});
|
||||
assert_eq!(verdict(&amended, &A), Verdict::Apply);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_seed_proposal_already_there_is_left_as_it_is() {
|
||||
assert_eq!(verdict(&Found::SeedProposal, &A), Verdict::Present);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn any_trace_of_a_person_keeps_the_text_in_force() {
|
||||
let cases = [
|
||||
(Found::Nothing, "no_text_in_force"),
|
||||
(with(|t| t.origin = "member".into()), "not_a_seed"),
|
||||
(with(|t| t.entered = true), "voted_into_force"),
|
||||
(with(|t| t.confirmed = true), "confirmed"),
|
||||
(with(|t| t.votes = 1), "votes"),
|
||||
(Found::InForce(Facts { ballots: 1, ..intact() }), "ballots"),
|
||||
(with(|t| t.typos = 1), "corrected"),
|
||||
(
|
||||
with(|t| {
|
||||
t.revision = 2;
|
||||
t.revisions = vec!["initial".into(), "typo".into()];
|
||||
}),
|
||||
"corrected",
|
||||
),
|
||||
(
|
||||
with(|t| {
|
||||
t.revision = 2;
|
||||
t.revisions = vec!["initial".into(), "substantive".into()];
|
||||
}),
|
||||
"revised",
|
||||
),
|
||||
(with(|t| t.revision = 2), "revised"),
|
||||
(with(|t| t.revisions = vec![]), "revised"),
|
||||
(with(|t| t.member_translations = 1), "translation_edited"),
|
||||
(with(|t| t.signals = 1), "signalled"),
|
||||
(with(|t| t.fingerprint = "other".into()), "text_differs"),
|
||||
];
|
||||
for (found, reason) in cases {
|
||||
assert_eq!(verdict(&found, &A), Verdict::Refuse(reason), "{found:?}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_level_goes_low_with_the_status_where_nobody_touched_it() {
|
||||
assert_eq!(level_verdict(Verdict::Apply, Some(&at("4")), &A, 2), Verdict::Apply);
|
||||
assert_eq!(level_verdict(Verdict::Apply, Some(&at("3")), &A, 2), Verdict::Apply);
|
||||
// The word of before, if an older image wrote it.
|
||||
assert_eq!(level_verdict(Verdict::Apply, Some(&at("standard")), &A, 2), Verdict::Apply);
|
||||
assert_eq!(level_verdict(Verdict::Present, Some(&at("2")), &A, 2), Verdict::Present);
|
||||
assert_eq!(level_verdict(Verdict::Apply, Some(&at("1")), &A, 1), Verdict::Present, "level 1, if Yvv says so");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_change_of_inertia_a_person_proposed_or_voted_keeps_the_level() {
|
||||
let cases = [
|
||||
(Verdict::Refuse("votes"), Some(at("4")), "not_a_seed_proposal"),
|
||||
(Verdict::Apply, None, "no_article"),
|
||||
(Verdict::Apply, Some(at("haute")), "level_unreadable"),
|
||||
(Verdict::Apply, Some(LevelFacts { voted_changes: 1, ..at("4") }), "changed_by_vote"),
|
||||
(Verdict::Apply, Some(LevelFacts { inertia_proposals: 1, ..at("4") }), "inertia_proposal"),
|
||||
(Verdict::Apply, Some(at("5")), "level_differs"),
|
||||
(Verdict::Apply, Some(at("1")), "level_differs"),
|
||||
];
|
||||
for (status, facts, reason) in cases {
|
||||
assert_eq!(level_verdict(status, facts.as_ref(), &A, 2), Verdict::Refuse(reason), "{facts:?}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_shipped_amendments_name_the_three_first_modules_and_their_levels_of_before() {
|
||||
let units: Vec<&str> = AMENDMENTS.iter().map(|a| a.unit).collect();
|
||||
assert_eq!(units, ["I1", "E1", "E2"]);
|
||||
assert!(AMENDMENTS.iter().all(|a| !a.levels.contains(&seed::SEED_PROPOSAL_LEVEL)));
|
||||
assert!(InertiaLevel::new(seed::SEED_PROPOSAL_LEVEL).is_some_and(|l| l.get() <= 2), "a low level");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,326 @@
|
||||
//! The end of the simulation (ADR 0012).
|
||||
//!
|
||||
//! Yvv, 2026-11-02: « Il compte, puis s'efface ». A simulated vote counts like a
|
||||
//! proven one, marked as simulated, until an act « supprime » of Yvv sets every
|
||||
//! one of them aside; what they brought into force is then examined again, with
|
||||
//! the proven votes only. Opening the chain does none of this by itself: the act
|
||||
//! calls `discard` — `ld-server discard-simulation` tells what it would do,
|
||||
//! `ld-server discard-simulation --apply` does it. Never run on its own.
|
||||
//!
|
||||
//! - **The sort**: the simulated ballots, their lines, their votes and the
|
||||
//! messages about them are deleted; each identity that voted in simulation is
|
||||
//! reconciled again, so that a proven vote of its own, if any, stands.
|
||||
//! - **The re-examination**: every entry into force that counted simulated votes
|
||||
//! (`adoptions.params.simulated`), the most recent first, still in effect, is
|
||||
//! judged again on the proven votes alone, at the level it was voted at. Below
|
||||
//! its threshold, it **returns among the proposals** and the version it had
|
||||
//! displaced comes back into force (a removed text comes back, a level of
|
||||
//! inertia goes back, the order and the title of before apply again); its
|
||||
//! adoption is void. At or above, it stays, and its adoption says so
|
||||
//! (`params.reexamined`): the act run again examines it no more.
|
||||
//!
|
||||
//! Told only (no `--apply`), the re-examination sets the simulated votes apart
|
||||
//! without removing them: an entry that would return only once a more recent one
|
||||
//! returned before it is told as no longer in effect.
|
||||
|
||||
use crate::AppState;
|
||||
use crate::adoption;
|
||||
use crate::ballots;
|
||||
use crate::docs::{self, DocState, ProposalRow, UnitRow};
|
||||
use crate::error::AppResult;
|
||||
use crate::modules;
|
||||
use crate::proposals;
|
||||
use crate::util;
|
||||
use ld_engine::inertia::InertiaLevel;
|
||||
use ld_engine::threshold::Tally;
|
||||
use serde::Serialize;
|
||||
use serde_json::Value;
|
||||
|
||||
/// One entry into force that counted simulated votes, judged again.
|
||||
#[derive(Debug, Clone, Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct Reexamined {
|
||||
pub doc_id: String,
|
||||
pub proposal_id: String,
|
||||
pub unit_id: String,
|
||||
pub code: String,
|
||||
/// `text`, `remove`, `inertia`, `order` or `title`.
|
||||
pub kind: String,
|
||||
pub at: String,
|
||||
/// The votes it entered with, and the simulated share of them.
|
||||
pub entered_with: Tally,
|
||||
pub simulated: Tally,
|
||||
/// The proven votes it holds now, and the *for* votes its level asks at them.
|
||||
pub proven: Tally,
|
||||
pub required: u32,
|
||||
/// Still in force (or still applied): something else may have taken its place since.
|
||||
pub in_effect: bool,
|
||||
/// It holds on the proven votes alone: it stays.
|
||||
pub holds: bool,
|
||||
/// Returned among the proposals (`--apply` only).
|
||||
pub returned: bool,
|
||||
/// The version it had displaced, back in force when it returns.
|
||||
pub displaced: Option<String>,
|
||||
}
|
||||
|
||||
/// What the act does, or would do.
|
||||
#[derive(Debug, Default, Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct Discarded {
|
||||
/// Done (`--apply`), or only told.
|
||||
pub applied: bool,
|
||||
pub ballots: i64,
|
||||
pub lines: i64,
|
||||
pub votes: i64,
|
||||
pub messages: i64,
|
||||
pub reexamined: Vec<Reexamined>,
|
||||
}
|
||||
|
||||
#[derive(sqlx::FromRow)]
|
||||
struct AdoptionRow {
|
||||
id: i64,
|
||||
proposal_id: String,
|
||||
unit_id: String,
|
||||
doc_id: String,
|
||||
kind: String,
|
||||
votes_for: i64,
|
||||
votes_against: i64,
|
||||
params: String,
|
||||
at: String,
|
||||
}
|
||||
|
||||
fn tally_of(v: &Value) -> Tally {
|
||||
serde_json::from_value(v.clone()).unwrap_or_default()
|
||||
}
|
||||
|
||||
fn minus(a: Tally, b: Tally) -> Tally {
|
||||
Tally { for_votes: a.for_votes.saturating_sub(b.for_votes), against: a.against.saturating_sub(b.against) }
|
||||
}
|
||||
|
||||
/// Sets every simulated vote aside and examines again what they brought into
|
||||
/// force — or, `apply` false, tells what it would do and changes nothing.
|
||||
pub async fn discard(state: &AppState, apply: bool) -> AppResult<Discarded> {
|
||||
const SIMULATED: &str = "SELECT id FROM ballots WHERE mode = 'simulated'";
|
||||
let (ballots_n, lines, votes, messages): (i64, i64, i64, i64) = sqlx::query_as(&format!(
|
||||
"SELECT (SELECT COUNT(*) FROM ballots WHERE mode = 'simulated'),
|
||||
(SELECT COUNT(*) FROM ballot_lines WHERE ballot_id IN ({SIMULATED})),
|
||||
(SELECT COUNT(*) FROM votes WHERE source = 'simulated'),
|
||||
(SELECT COUNT(*) FROM messages WHERE topic = 'ballot' AND subject_id IN ({SIMULATED}))"
|
||||
))
|
||||
.fetch_one(&state.db)
|
||||
.await?;
|
||||
let mut out = Discarded { applied: apply, ballots: ballots_n, lines, votes, messages, reexamined: Vec::new() };
|
||||
|
||||
if apply {
|
||||
// The sort: who voted in simulation, on what — reconciled again once their simulated lines are gone.
|
||||
let pairs: Vec<(String, String)> = sqlx::query_as(
|
||||
"SELECT DISTINCT b.voter_id, l.proposal_id FROM ballots b JOIN ballot_lines l ON l.ballot_id = b.id
|
||||
WHERE b.mode = 'simulated' AND b.voter_id IS NOT NULL",
|
||||
)
|
||||
.fetch_all(&state.db)
|
||||
.await?;
|
||||
let now = util::now();
|
||||
let mut tx = state.db.begin().await?;
|
||||
for sql in [
|
||||
format!("DELETE FROM messages WHERE topic = 'ballot' AND subject_id IN ({SIMULATED})"),
|
||||
"DELETE FROM votes WHERE source = 'simulated'".to_owned(),
|
||||
format!("DELETE FROM ballot_lines WHERE ballot_id IN ({SIMULATED})"),
|
||||
"DELETE FROM ballots WHERE mode = 'simulated'".to_owned(),
|
||||
] {
|
||||
sqlx::query(&sql).execute(&mut *tx).await?;
|
||||
}
|
||||
for (voter, proposal_id) in &pairs {
|
||||
ballots::reconcile(&mut tx, voter, proposal_id, &now).await?;
|
||||
}
|
||||
tx.commit().await?;
|
||||
tracing::warn!(ballots = ballots_n, votes, "simulated votes set aside (act « supprime »)");
|
||||
}
|
||||
|
||||
// The re-examination, the most recent entry first.
|
||||
for (a, params, simulated) in to_examine(state, None).await? {
|
||||
if let Some(r) = reexamine(state, &a, ¶ms, simulated, apply).await? {
|
||||
out.reexamined.push(r);
|
||||
}
|
||||
}
|
||||
if apply {
|
||||
// What waited for the proven votes may go out now (`publish::waits_for_proven_votes`).
|
||||
sqlx::query("UPDATE outbox SET next_at = ? WHERE done_at IS NULL").bind(util::now()).execute(&state.db).await?;
|
||||
state.publish_wake.notify_one();
|
||||
}
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
/// The entries into force still to examine, the most recent first: those that
|
||||
/// counted simulated votes, not yet examined by an act — of one document, or all.
|
||||
async fn to_examine(state: &AppState, doc_id: Option<&str>) -> AppResult<Vec<(AdoptionRow, Value, Tally)>> {
|
||||
let adoptions = sqlx::query_as::<_, AdoptionRow>(
|
||||
"SELECT id, proposal_id, unit_id, doc_id, kind, votes_for, votes_against, params, at FROM adoptions
|
||||
WHERE ?1 IS NULL OR doc_id = ?1
|
||||
ORDER BY at DESC, id DESC",
|
||||
)
|
||||
.bind(doc_id)
|
||||
.fetch_all(&state.db)
|
||||
.await?;
|
||||
Ok(adoptions
|
||||
.into_iter()
|
||||
.filter_map(|a| {
|
||||
let params: Value = serde_json::from_str(&a.params).unwrap_or(Value::Null);
|
||||
let simulated = tally_of(¶ms["simulated"]);
|
||||
// Without simulated votes, or examined by an act already: nothing to examine.
|
||||
(simulated.cast() > 0 && params["reexamined"].is_null()).then_some((a, params, simulated))
|
||||
})
|
||||
.collect())
|
||||
}
|
||||
|
||||
/// What, in effect in a document, entered by simulated votes and does not hold on
|
||||
/// the proven votes alone: the re-examination of the act, told only — nothing is
|
||||
/// written. Empty: everything in effect holds on the proven votes. The publication
|
||||
/// waits on it (Yvv, 2026-11-03: « Pas avant des votes prouvés »,
|
||||
/// `publish::waits_for_proven_votes`).
|
||||
pub async fn unproven(state: &AppState, doc_id: &str) -> AppResult<Vec<Reexamined>> {
|
||||
let mut out = Vec::new();
|
||||
for (a, params, simulated) in to_examine(state, Some(doc_id)).await? {
|
||||
if let Some(r) = reexamine(state, &a, ¶ms, simulated, false).await?
|
||||
&& r.in_effect
|
||||
&& !r.holds
|
||||
{
|
||||
out.push(r);
|
||||
}
|
||||
}
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
async fn reexamine(
|
||||
state: &AppState,
|
||||
a: &AdoptionRow,
|
||||
params: &Value,
|
||||
simulated: Tally,
|
||||
apply: bool,
|
||||
) -> AppResult<Option<Reexamined>> {
|
||||
let st = docs::load_state(state, &a.doc_id).await?;
|
||||
let Some(unit) = st.units.iter().find(|u| u.id == a.unit_id) else { return Ok(None) };
|
||||
let Ok(p) = proposals::load_proposal(state, &a.proposal_id).await else { return Ok(None) };
|
||||
let in_effect = match a.kind.as_str() {
|
||||
"remove" => p.status == "applied" && unit.in_force_id.is_none(),
|
||||
"inertia" => {
|
||||
p.status == "applied"
|
||||
&& p.inertia_target.as_deref().and_then(InertiaLevel::parse) == Some(docs::inertia_of(unit))
|
||||
}
|
||||
_ => unit.in_force_id.as_deref() == Some(p.id.as_str()),
|
||||
};
|
||||
// Applied: the simulated votes are gone. Told only: they still count, set them apart.
|
||||
let proven = if apply { st.tally(&p.id) } else { minus(st.tally(&p.id), st.simulated_counted(&p.id)) };
|
||||
// At the level and on the quorum it was voted at (ADR 0013; an adoption of before
|
||||
// holds the C of before, `constant`, which is no quorum: the article's quorum now).
|
||||
let level =
|
||||
serde_json::from_value::<InertiaLevel>(params["level"].clone()).unwrap_or_else(|_| st.voting_level(unit, &p));
|
||||
let quorum = params["quorum"].as_u64().and_then(|q| u32::try_from(q).ok()).unwrap_or_else(|| st.quorum_of(unit));
|
||||
let eval = st.scale().evaluate(proven, level, quorum).ok();
|
||||
let holds = eval.is_some_and(|e| e.reached);
|
||||
let displaced = params["displaced"].as_str().map(str::to_owned);
|
||||
let mut r = Reexamined {
|
||||
doc_id: a.doc_id.clone(),
|
||||
proposal_id: p.id.clone(),
|
||||
unit_id: unit.id.clone(),
|
||||
code: unit.code.clone(),
|
||||
kind: a.kind.clone(),
|
||||
at: a.at.clone(),
|
||||
entered_with: Tally {
|
||||
for_votes: u32::try_from(a.votes_for).unwrap_or(0),
|
||||
against: u32::try_from(a.votes_against).unwrap_or(0),
|
||||
},
|
||||
simulated,
|
||||
proven,
|
||||
required: eval.map_or(0, |e| e.required),
|
||||
in_effect,
|
||||
holds,
|
||||
returned: false,
|
||||
displaced: displaced.clone(),
|
||||
};
|
||||
if apply && in_effect && !holds {
|
||||
give_back(state, &st, unit, &p, a, params, displaced.as_deref()).await?;
|
||||
r.returned = true;
|
||||
} else if apply {
|
||||
// It stays (or something else took its place since): examined once, never again.
|
||||
let mut kept = params.clone();
|
||||
kept["reexamined"] = serde_json::json!({ "at": util::now(), "proven": r.proven, "required": r.required });
|
||||
sqlx::query("UPDATE adoptions SET params = ? WHERE id = ?")
|
||||
.bind(kept.to_string())
|
||||
.bind(a.id)
|
||||
.execute(&state.db)
|
||||
.await?;
|
||||
}
|
||||
Ok(Some(r))
|
||||
}
|
||||
|
||||
/// An entry that does not hold on the proven votes: the version returns among the
|
||||
/// proposals, the one it displaced comes back, and its adoption is void.
|
||||
async fn give_back(
|
||||
state: &AppState,
|
||||
st: &DocState,
|
||||
unit: &UnitRow,
|
||||
p: &ProposalRow,
|
||||
a: &AdoptionRow,
|
||||
params: &Value,
|
||||
displaced: Option<&str>,
|
||||
) -> AppResult<()> {
|
||||
let now = util::now();
|
||||
let back = match displaced {
|
||||
Some(d) => proposals::load_proposal(state, d).await.ok().filter(|v| v.status == "open"),
|
||||
None => None,
|
||||
};
|
||||
let mut tx = state.db.begin().await?;
|
||||
sqlx::query("UPDATE proposals SET status = 'open', entered_at = NULL, left_at = ?, updated_at = ? WHERE id = ?")
|
||||
.bind(&now)
|
||||
.bind(&now)
|
||||
.bind(&p.id)
|
||||
.execute(&mut *tx)
|
||||
.await?;
|
||||
if a.kind == "inertia" {
|
||||
let from = serde_json::from_value::<InertiaLevel>(params["from_inertia"].clone()).unwrap_or_default();
|
||||
sqlx::query("UPDATE units SET inertia = ? WHERE id = ?")
|
||||
.bind(from.as_db())
|
||||
.bind(&unit.id)
|
||||
.execute(&mut *tx)
|
||||
.await?;
|
||||
sqlx::query("DELETE FROM inertia_changes WHERE proposal_id = ? AND cause = 'vote'")
|
||||
.bind(&p.id)
|
||||
.execute(&mut *tx)
|
||||
.await?;
|
||||
} else {
|
||||
if let Some(b) = &back {
|
||||
sqlx::query("UPDATE proposals SET status = 'in_force', left_at = NULL, updated_at = ? WHERE id = ?")
|
||||
.bind(&now)
|
||||
.bind(&b.id)
|
||||
.execute(&mut *tx)
|
||||
.await?;
|
||||
}
|
||||
sqlx::query("UPDATE units SET in_force_id = ? WHERE id = ?")
|
||||
.bind(back.as_ref().map(|b| &b.id))
|
||||
.bind(&unit.id)
|
||||
.execute(&mut *tx)
|
||||
.await?;
|
||||
// A module does more: the order of before moves its articles back, the title of before names the document.
|
||||
if let (Some(b), Some(_)) = (&back, st.module_of(&unit.id)) {
|
||||
let leaving = UnitRow { in_force_id: Some(p.id.clone()), ..unit.clone() };
|
||||
modules::on_entry(&mut tx, st, &leaving, b).await?;
|
||||
}
|
||||
}
|
||||
sqlx::query("DELETE FROM adoptions WHERE id = ?").bind(a.id).execute(&mut *tx).await?;
|
||||
tx.commit().await?;
|
||||
tracing::warn!(doc = %st.doc.id, unit = %unit.code, proposal = %p.id, "returned among the proposals: it does not hold on the proven votes");
|
||||
adoption::enqueue_publication(state, &st.doc, &format!("votes simulés écartés : {} réexaminé", unit.code)).await?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// `ld-server discard-simulation [--apply]`: the act « supprime » of the end of
|
||||
/// the simulation (ADR 0012). Without `--apply`, it tells what it would do.
|
||||
pub async fn run_cli(apply: bool) -> anyhow::Result<()> {
|
||||
let cfg = crate::Config::from_env();
|
||||
let db_url = format!("sqlite://{}", cfg.data_dir.join("libredecision.db").display());
|
||||
let db = crate::open_db(&db_url).await?;
|
||||
let state = AppState::new(cfg, db).await?;
|
||||
let done = discard(&state, apply).await.map_err(|e| anyhow::anyhow!("{e}"))?;
|
||||
println!("{}", serde_json::to_string_pretty(&done)?);
|
||||
Ok(())
|
||||
}
|
||||
@@ -0,0 +1,287 @@
|
||||
//! Team links — one link an administrator shares with a team, until a date and
|
||||
//! for a number of openings. Each opening asks the person's name and makes
|
||||
//! **their own** invited identity, never an administrator: one person, one
|
||||
//! voice, and access management stays with trustinWallet's administrators.
|
||||
//! Someone already signed in keeps their identity and receives the groups; it
|
||||
//! takes a place only if it adds a group.
|
||||
//!
|
||||
//! Limit, assumed: a person who opens the link twice under two names has two
|
||||
//! identities. Where invited identities vote (the test group), administrators
|
||||
//! see in trustinWallet which link each identity came from.
|
||||
|
||||
use crate::AppState;
|
||||
use crate::auth::{self, IdentityRow, User, Viewer};
|
||||
use crate::error::{AppError, AppResult};
|
||||
use crate::util;
|
||||
use axum::Json;
|
||||
use axum::extract::{Path, State};
|
||||
use axum::http::header::SET_COOKIE;
|
||||
use axum::response::{IntoResponse, Response};
|
||||
use ld_engine::{edit, names};
|
||||
use serde::Deserialize;
|
||||
use serde_json::json;
|
||||
|
||||
const MAX_DAYS: i64 = 90;
|
||||
const MAX_USES: i64 = 500;
|
||||
const MAX_LABEL: usize = 60;
|
||||
|
||||
pub fn link(state: &AppState, token: &str) -> String {
|
||||
format!("{}/equipe#{token}", state.cfg.public_url)
|
||||
}
|
||||
|
||||
#[derive(sqlx::FromRow)]
|
||||
struct TeamLink {
|
||||
token_hash: String,
|
||||
label: String,
|
||||
groups: String,
|
||||
created_by: String,
|
||||
expires_at: String,
|
||||
}
|
||||
|
||||
/// A link that still opens: not revoked, not expired, openings left.
|
||||
async fn open_link(state: &AppState, token: &str) -> AppResult<TeamLink> {
|
||||
sqlx::query_as::<_, TeamLink>(
|
||||
"SELECT token_hash, label, groups, created_by, expires_at FROM team_links
|
||||
WHERE token_hash = ? AND revoked_at IS NULL AND expires_at > ? AND uses < max_uses",
|
||||
)
|
||||
.bind(util::sha256_hex(token.trim()))
|
||||
.bind(util::now())
|
||||
.fetch_optional(&state.db)
|
||||
.await?
|
||||
.ok_or(AppError::Invalid("invitation_invalid"))
|
||||
}
|
||||
|
||||
fn slugs(raw: &str) -> Vec<String> {
|
||||
serde_json::from_str(raw).unwrap_or_default()
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
pub struct PeekBody {
|
||||
token: String,
|
||||
}
|
||||
|
||||
/// What the link opens, before the person gives a name.
|
||||
pub async fn peek(
|
||||
State(state): State<AppState>,
|
||||
Viewer(viewer): Viewer,
|
||||
Json(b): Json<PeekBody>,
|
||||
) -> AppResult<Json<serde_json::Value>> {
|
||||
let l = open_link(&state, &b.token).await?;
|
||||
let mut names = Vec::new();
|
||||
for slug in slugs(&l.groups) {
|
||||
if let Some(name) = sqlx::query_scalar::<_, String>("SELECT name FROM groups WHERE slug = ?")
|
||||
.bind(&slug)
|
||||
.fetch_optional(&state.db)
|
||||
.await?
|
||||
{
|
||||
names.push(name);
|
||||
}
|
||||
}
|
||||
Ok(Json(json!({
|
||||
"label": l.label,
|
||||
"groups": names,
|
||||
"expiresAt": l.expires_at,
|
||||
"signedInAs": viewer.map(|v| v.display_name),
|
||||
})))
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
pub struct JoinBody {
|
||||
token: String,
|
||||
#[serde(default)]
|
||||
name: Option<String>,
|
||||
}
|
||||
|
||||
/// Nobody joins under the name of someone already there, however it is dressed
|
||||
/// (case, accents, punctuation): compared on `names::key`. The unique index on
|
||||
/// `name_key` settles two openings at the same instant.
|
||||
async fn free_name(state: &AppState, raw: &str) -> AppResult<names::Name> {
|
||||
let name = names::check(raw).ok_or(AppError::Invalid("name"))?;
|
||||
// People already there, and those an invitation still waits for. A Ğ1
|
||||
// identity known only from a vote on the chain (never signed in here) is not
|
||||
// there: as for any member of the web of trust who never came, its name stays free.
|
||||
let mut taken: Vec<String> =
|
||||
sqlx::query_scalar("SELECT display_name FROM identities WHERE NOT (kind = 'g1' AND last_seen_at IS NULL)")
|
||||
.fetch_all(&state.db)
|
||||
.await?;
|
||||
taken.extend(
|
||||
sqlx::query_scalar::<_, String>(
|
||||
"SELECT label FROM invitations WHERE used_at IS NULL AND identity_id IS NULL AND expires_at > ?",
|
||||
)
|
||||
.bind(util::now())
|
||||
.fetch_all(&state.db)
|
||||
.await?,
|
||||
);
|
||||
if taken.iter().any(|t| names::key(t) == name.key) {
|
||||
return Err(AppError::Conflict("name_taken"));
|
||||
}
|
||||
Ok(name)
|
||||
}
|
||||
|
||||
/// The groups of the link this identity does not have yet.
|
||||
async fn missing_groups(state: &AppState, identity_id: &str, groups: &[String]) -> AppResult<usize> {
|
||||
let mine: Vec<String> = sqlx::query_scalar("SELECT group_slug FROM memberships WHERE identity_id = ?")
|
||||
.bind(identity_id)
|
||||
.fetch_all(&state.db)
|
||||
.await?;
|
||||
Ok(groups.iter().filter(|g| !mine.contains(g)).count())
|
||||
}
|
||||
|
||||
pub async fn join(
|
||||
State(state): State<AppState>,
|
||||
Viewer(viewer): Viewer,
|
||||
Json(b): Json<JoinBody>,
|
||||
) -> AppResult<Response> {
|
||||
let l = open_link(&state, &b.token).await?;
|
||||
let groups = slugs(&l.groups);
|
||||
// Checked before an opening is counted.
|
||||
let name = match &viewer {
|
||||
Some(me) => {
|
||||
// Back with the session already open: nothing to add, no place taken.
|
||||
if missing_groups(&state, &me.id, &groups).await? == 0 {
|
||||
return Ok(Json(json!({ "me": me })).into_response());
|
||||
}
|
||||
None
|
||||
}
|
||||
None => Some(free_name(&state, b.name.as_deref().unwrap_or_default()).await?),
|
||||
};
|
||||
// Count the opening first: of two at the last place, one only goes on.
|
||||
let counted = sqlx::query(
|
||||
"UPDATE team_links SET uses = uses + 1
|
||||
WHERE token_hash = ? AND revoked_at IS NULL AND expires_at > ? AND uses < max_uses",
|
||||
)
|
||||
.bind(&l.token_hash)
|
||||
.bind(util::now())
|
||||
.execute(&state.db)
|
||||
.await?;
|
||||
if counted.rows_affected() != 1 {
|
||||
return Err(AppError::Invalid("invitation_invalid"));
|
||||
}
|
||||
let (identity_id, session) = match (viewer, name) {
|
||||
(Some(me), _) => (me.id, None),
|
||||
(None, Some(name)) => {
|
||||
let id = format!("inv:{}", util::id());
|
||||
let inserted = sqlx::query(
|
||||
"INSERT INTO identities (id, kind, display_name, g1_member, admin, created_at, team_link, name_key)
|
||||
VALUES (?, 'invite', ?, 0, 0, ?, ?, ?)",
|
||||
)
|
||||
.bind(&id)
|
||||
.bind(&name.display)
|
||||
.bind(util::now())
|
||||
.bind(&l.token_hash)
|
||||
.bind(&name.key)
|
||||
.execute(&state.db)
|
||||
.await;
|
||||
if let Err(sqlx::Error::Database(e)) = &inserted
|
||||
&& e.is_unique_violation()
|
||||
{
|
||||
// Same name at the same instant: give the place back.
|
||||
sqlx::query("UPDATE team_links SET uses = uses - 1 WHERE token_hash = ?")
|
||||
.bind(&l.token_hash)
|
||||
.execute(&state.db)
|
||||
.await?;
|
||||
return Err(AppError::Conflict("name_taken"));
|
||||
}
|
||||
inserted?;
|
||||
let session = auth::open_session(&state, &id).await?;
|
||||
(id, Some(session))
|
||||
}
|
||||
(None, None) => return Err(AppError::Invalid("name")),
|
||||
};
|
||||
auth::grant_groups(&state, &identity_id, &groups, Some(&l.created_by)).await?;
|
||||
tracing::info!(link = %&l.token_hash[..8], identity = %identity_id, "team link opened");
|
||||
let me = auth::load_identity(&state, &identity_id).await?;
|
||||
let mut res = Json(json!({ "me": me })).into_response();
|
||||
if let Some(cookie) = session {
|
||||
res.headers_mut().append(SET_COOKIE, cookie);
|
||||
}
|
||||
Ok(res)
|
||||
}
|
||||
|
||||
fn admin_only(user: &IdentityRow) -> AppResult<()> {
|
||||
if trustin_access::manages_access(&user.to_identity()) { Ok(()) } else { Err(AppError::Forbidden) }
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct CreateBody {
|
||||
label: String,
|
||||
groups: Vec<String>,
|
||||
days: i64,
|
||||
max_uses: i64,
|
||||
}
|
||||
|
||||
/// The link is returned once and never stored in clear.
|
||||
pub async fn create(
|
||||
State(state): State<AppState>,
|
||||
User(user): User,
|
||||
Json(b): Json<CreateBody>,
|
||||
) -> AppResult<Json<serde_json::Value>> {
|
||||
admin_only(&user)?;
|
||||
let label = b.label.trim();
|
||||
if label.is_empty()
|
||||
|| label.chars().count() > MAX_LABEL
|
||||
|| label.chars().any(char::is_control)
|
||||
|| edit::has_hidden_controls(label)
|
||||
{
|
||||
return Err(AppError::Invalid("label"));
|
||||
}
|
||||
if !(1..=MAX_DAYS).contains(&b.days) || !(1..=MAX_USES).contains(&b.max_uses) {
|
||||
return Err(AppError::Invalid("team_limits"));
|
||||
}
|
||||
let known: Vec<String> = sqlx::query_scalar("SELECT slug FROM groups").fetch_all(&state.db).await?;
|
||||
if b.groups.is_empty() || b.groups.iter().any(|g| !known.contains(g)) {
|
||||
return Err(AppError::Invalid("groups"));
|
||||
}
|
||||
let token = util::token();
|
||||
sqlx::query(
|
||||
"INSERT INTO team_links (token_hash, label, groups, created_by, created_at, expires_at, max_uses)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?)",
|
||||
)
|
||||
.bind(util::sha256_hex(&token))
|
||||
.bind(label)
|
||||
.bind(serde_json::to_string(&b.groups).unwrap_or_else(|_| "[]".into()))
|
||||
.bind(&user.id)
|
||||
.bind(util::now())
|
||||
.bind(util::in_days(b.days))
|
||||
.bind(b.max_uses)
|
||||
.execute(&state.db)
|
||||
.await?;
|
||||
tracing::info!(by = %user.id, days = b.days, max_uses = b.max_uses, "team link created");
|
||||
Ok(Json(json!({ "link": link(&state, &token) })))
|
||||
}
|
||||
|
||||
pub async fn revoke(
|
||||
State(state): State<AppState>,
|
||||
User(user): User,
|
||||
Path(hash): Path<String>,
|
||||
) -> AppResult<Json<serde_json::Value>> {
|
||||
admin_only(&user)?;
|
||||
sqlx::query("UPDATE team_links SET revoked_at = ? WHERE token_hash = ? AND revoked_at IS NULL")
|
||||
.bind(util::now())
|
||||
.bind(&hash)
|
||||
.execute(&state.db)
|
||||
.await?;
|
||||
tracing::info!(by = %user.id, "team link revoked");
|
||||
Ok(Json(json!({ "ok": true })))
|
||||
}
|
||||
|
||||
/// The links still open, for trustinWallet.
|
||||
pub async fn list(state: &AppState) -> AppResult<Vec<serde_json::Value>> {
|
||||
let rows: Vec<(String, String, String, String, i64, i64)> = sqlx::query_as(
|
||||
"SELECT token_hash, label, groups, expires_at, max_uses, uses FROM team_links
|
||||
WHERE revoked_at IS NULL AND expires_at > ? ORDER BY created_at DESC",
|
||||
)
|
||||
.bind(util::now())
|
||||
.fetch_all(&state.db)
|
||||
.await?;
|
||||
Ok(rows
|
||||
.into_iter()
|
||||
.map(|(id, label, groups, expires, max_uses, uses)| {
|
||||
json!({
|
||||
"id": id, "label": label, "groups": slugs(&groups),
|
||||
"expiresAt": expires, "maxUses": max_uses, "uses": uses,
|
||||
})
|
||||
})
|
||||
.collect())
|
||||
}
|
||||
@@ -0,0 +1,551 @@
|
||||
//! Typo fixes validated (2026-10-31, lot F, L13, ADR 0010).
|
||||
//!
|
||||
//! What a typo is does not change (`ld_engine::edit`, judged against the voted
|
||||
//! text). Who applies it, and when, is `ld_engine::correction`: the one who
|
||||
//! answers for the version (its author; for a text without author, an
|
||||
//! administrator of trustinWallet — `trustin_access::answers_typo`) applies a
|
||||
//! fix at once; anyone else's waits for them, told by a message, and applies by
|
||||
//! itself after 3 days (`due`, run by the sweeper and at start). While the
|
||||
//! version is not in force its author keeps the hand: a fix applied may be taken
|
||||
//! back, a fix refused validated after all. A fix is written only over the text
|
||||
//! it was written over: once another one applies, or the substance changes, it
|
||||
//! is stale. Translations stay out of this: editorial, without validation.
|
||||
|
||||
use crate::AppState;
|
||||
use crate::adoption;
|
||||
use crate::auth::{IDENTITY_COLUMNS, IdentityRow, User};
|
||||
use crate::docs::{self, DocState, ProposalRow};
|
||||
use crate::error::{AppError, AppResult};
|
||||
use crate::messages::{self, Topic};
|
||||
use crate::{proposals, util};
|
||||
use axum::Json;
|
||||
use axum::extract::{Path, State};
|
||||
use ld_engine::correction::{self, Answer, Route, Settle, Text, TextChange, TypoStatus};
|
||||
use ld_engine::edit;
|
||||
use serde_json::{Value, json};
|
||||
use sqlx::SqliteConnection;
|
||||
|
||||
#[derive(Clone, Debug, sqlx::FromRow)]
|
||||
pub struct TypoRow {
|
||||
pub id: String,
|
||||
pub proposal_id: String,
|
||||
pub doc_id: String,
|
||||
pub writer_id: String,
|
||||
pub answerer_id: Option<String>,
|
||||
pub revision: i64,
|
||||
pub base_title: String,
|
||||
pub base_body: String,
|
||||
pub title: String,
|
||||
pub body: String,
|
||||
pub status: String,
|
||||
pub created_at: String,
|
||||
pub due_at: String,
|
||||
pub settled_at: Option<String>,
|
||||
pub settled_by: Option<String>,
|
||||
}
|
||||
|
||||
const SELECT: &str =
|
||||
"SELECT id, proposal_id, doc_id, writer_id, answerer_id, revision, base_title, base_body, title, body,
|
||||
status, created_at, due_at, settled_at, settled_by FROM typos";
|
||||
|
||||
impl TypoRow {
|
||||
fn status(&self) -> TypoStatus {
|
||||
TypoStatus::parse(&self.status).unwrap_or(TypoStatus::Stale)
|
||||
}
|
||||
|
||||
fn base(&self) -> Text<'_> {
|
||||
Text { revision: self.revision, title: &self.base_title, body: &self.base_body }
|
||||
}
|
||||
|
||||
fn fixed(&self) -> Text<'_> {
|
||||
Text { revision: self.revision, title: &self.title, body: &self.body }
|
||||
}
|
||||
}
|
||||
|
||||
fn text_of(p: &ProposalRow) -> Text<'_> {
|
||||
Text { revision: p.revision, title: &p.title, body: &p.body }
|
||||
}
|
||||
|
||||
async fn load(state: &AppState, id: &str) -> AppResult<TypoRow> {
|
||||
sqlx::query_as::<_, TypoRow>(&format!("{SELECT} WHERE id = ?"))
|
||||
.bind(id)
|
||||
.fetch_optional(&state.db)
|
||||
.await?
|
||||
.ok_or(AppError::NotFound)
|
||||
}
|
||||
|
||||
/// Writes `to` over a version, only where its text is still `from` — and, when
|
||||
/// `status` is given, only while its status is that one; records the revision and
|
||||
/// keeps up-to-date translations up to date. False when the text was not there.
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
async fn write_text(
|
||||
tx: &mut SqliteConnection,
|
||||
proposal_id: &str,
|
||||
from: Text<'_>,
|
||||
to: (&str, &str),
|
||||
status: Option<&str>,
|
||||
author: &str,
|
||||
link: Option<(&str, &str)>,
|
||||
kind: &str,
|
||||
now: &str,
|
||||
) -> AppResult<bool> {
|
||||
let done = sqlx::query(
|
||||
"UPDATE proposals SET title = ?, body = ?, updated_at = ?
|
||||
WHERE id = ? AND revision = ? AND title = ? AND body = ? AND status IN ('open', 'in_force')
|
||||
AND (?8 IS NULL OR status = ?8)",
|
||||
)
|
||||
.bind(to.0)
|
||||
.bind(to.1)
|
||||
.bind(now)
|
||||
.bind(proposal_id)
|
||||
.bind(from.revision)
|
||||
.bind(from.title)
|
||||
.bind(from.body)
|
||||
.bind(status)
|
||||
.execute(&mut *tx)
|
||||
.await?;
|
||||
if done.rows_affected() != 1 {
|
||||
return Ok(false);
|
||||
}
|
||||
sqlx::query(
|
||||
"INSERT INTO revisions (proposal_id, revision, kind, title, body, author_id, created_at, typo_id, answered_by)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)",
|
||||
)
|
||||
.bind(proposal_id)
|
||||
.bind(from.revision)
|
||||
.bind(kind)
|
||||
.bind(to.0)
|
||||
.bind(to.1)
|
||||
.bind(author)
|
||||
.bind(now)
|
||||
.bind(link.map(|l| l.0))
|
||||
.bind(link.map(|l| l.1))
|
||||
.execute(&mut *tx)
|
||||
.await?;
|
||||
// A typo fix does not change the meaning: up-to-date translations stay up to date.
|
||||
sqlx::query("UPDATE translations SET source_hash = ? WHERE proposal_id = ? AND source_hash = ?")
|
||||
.bind(util::source_hash(to.0, to.1))
|
||||
.bind(proposal_id)
|
||||
.bind(util::source_hash(from.title, from.body))
|
||||
.execute(&mut *tx)
|
||||
.await?;
|
||||
Ok(true)
|
||||
}
|
||||
|
||||
/// After the text of a version changed, or it closed: the fixes waiting on a text
|
||||
/// that is gone are stale, and their messages say so.
|
||||
pub async fn on_text_changed(state: &AppState, proposal_id: &str) -> AppResult<()> {
|
||||
let p = proposals::load_proposal(state, proposal_id).await?;
|
||||
let open = matches!(p.status.as_str(), "open" | "in_force");
|
||||
let pending = sqlx::query_as::<_, TypoRow>(&format!("{SELECT} WHERE proposal_id = ? AND status = 'pending'"))
|
||||
.bind(proposal_id)
|
||||
.fetch_all(&state.db)
|
||||
.await?;
|
||||
for t in pending {
|
||||
if open && correction::fits(t.base(), text_of(&p)) {
|
||||
continue;
|
||||
}
|
||||
stale(state, &t, "pending").await?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn stale(state: &AppState, t: &TypoRow, from: &str) -> AppResult<()> {
|
||||
let done = sqlx::query(
|
||||
"UPDATE typos SET status = 'stale', settled_at = ?, settled_by = 'changed' WHERE id = ? AND status = ?",
|
||||
)
|
||||
.bind(state.clock.now())
|
||||
.bind(&t.id)
|
||||
.bind(from)
|
||||
.execute(&state.db)
|
||||
.await?;
|
||||
if done.rows_affected() == 1 {
|
||||
messages::close(state, Topic::Typo, &t.id, None, "stale").await?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn republish(state: &AppState, proposal_id: &str) -> AppResult<()> {
|
||||
let p = proposals::load_proposal(state, proposal_id).await?;
|
||||
if p.status == "in_force" {
|
||||
let doc_id: String =
|
||||
sqlx::query_scalar("SELECT doc_id FROM units WHERE id = ?").bind(&p.unit_id).fetch_one(&state.db).await?;
|
||||
adoption::enqueue_publication(state, &docs::load_doc(state, &doc_id).await?, "correction typographique")
|
||||
.await?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// A fix the engine judged typographic, written by `user` on `p`: applied at once
|
||||
/// by the one who answers for the version, waiting for them otherwise.
|
||||
pub async fn write(
|
||||
state: &AppState,
|
||||
user: &IdentityRow,
|
||||
p: &ProposalRow,
|
||||
st: &DocState,
|
||||
title: &str,
|
||||
body: &str,
|
||||
) -> AppResult<Json<Value>> {
|
||||
let diff = edit::diff(&format!("{}\n{}", p.title, p.body), &format!("{title}\n{body}"));
|
||||
let answers = trustin_access::answers_typo(&user.to_identity(), p.author_id.as_deref());
|
||||
let now = state.clock.now();
|
||||
match correction::route(answers) {
|
||||
Route::Apply => {
|
||||
// Written only over the text that was judged: an entry into force or another
|
||||
// correction in between makes it a conflict, never a silent change.
|
||||
let mut tx = state.db.begin().await?;
|
||||
if !write_text(&mut tx, &p.id, text_of(p), (title, body), Some(&p.status), &user.id, None, "typo", &now)
|
||||
.await?
|
||||
{
|
||||
return Err(AppError::Conflict("changed"));
|
||||
}
|
||||
tx.commit().await?;
|
||||
if p.status == "in_force" {
|
||||
adoption::enqueue_publication(state, &st.doc, "correction typographique").await?;
|
||||
}
|
||||
on_text_changed(state, &p.id).await?;
|
||||
Ok(Json(json!({ "edit": "typo", "diff": diff })))
|
||||
}
|
||||
Route::Await => {
|
||||
let id = util::id();
|
||||
let due = state.clock.in_days(correction::ANSWER_DAYS);
|
||||
sqlx::query(
|
||||
"INSERT INTO typos (id, proposal_id, doc_id, writer_id, answerer_id, revision, base_title, base_body,
|
||||
title, body, status, created_at, due_at)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, 'pending', ?, ?)",
|
||||
)
|
||||
.bind(&id)
|
||||
.bind(&p.id)
|
||||
.bind(&st.doc.id)
|
||||
.bind(&user.id)
|
||||
.bind(&p.author_id)
|
||||
.bind(p.revision)
|
||||
.bind(&p.title)
|
||||
.bind(&p.body)
|
||||
.bind(title)
|
||||
.bind(body)
|
||||
.bind(&now)
|
||||
.bind(&due)
|
||||
.execute(&state.db)
|
||||
.await?;
|
||||
// Who answers is told: the author, or for a text without author, every
|
||||
// administrator who opens the group (the others could not answer).
|
||||
let recipients: Vec<String> = match &p.author_id {
|
||||
Some(a) => vec![a.clone()],
|
||||
None => {
|
||||
let admins = sqlx::query_as::<_, IdentityRow>(&format!(
|
||||
"SELECT {IDENTITY_COLUMNS} FROM identities WHERE admin = 1 AND id <> ? ORDER BY created_at"
|
||||
))
|
||||
.bind(&user.id)
|
||||
.fetch_all(&state.db)
|
||||
.await?;
|
||||
let mut open = Vec::new();
|
||||
for a in admins {
|
||||
if docs::opens(state, &a, &st.group).await? {
|
||||
open.push(a.id);
|
||||
}
|
||||
}
|
||||
open
|
||||
}
|
||||
};
|
||||
for r in recipients {
|
||||
messages::notify(state, &r, &st.doc.id, Topic::Typo, &id, "typo_pending", 1, Some(&due)).await?;
|
||||
}
|
||||
let answerer = if p.author_id.is_some() { "author" } else { "admin" };
|
||||
tracing::info!(typo = %id, proposal = %p.id, answerer, "typo fix waiting for validation");
|
||||
Ok(Json(json!({
|
||||
"edit": "typo_pending", "diff": diff, "typo": { "id": id, "dueAt": due, "answerer": answerer },
|
||||
})))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Settles one fix, in one transaction: its status (only over the one read), and
|
||||
/// its text. `Ok(Err("settled"))`: answered meanwhile; `Ok(Err("changed"))`: the
|
||||
/// text it needs is gone — nothing is written.
|
||||
async fn settle(state: &AppState, t: &TypoRow, s: Settle, by: &str, now: &str) -> AppResult<Result<(), &'static str>> {
|
||||
let mut tx = state.db.begin().await?;
|
||||
let claimed =
|
||||
sqlx::query("UPDATE typos SET status = ?, settled_at = ?, settled_by = ? WHERE id = ? AND status = ?")
|
||||
.bind(s.status.as_str())
|
||||
.bind(now)
|
||||
.bind(by)
|
||||
.bind(&t.id)
|
||||
.bind(&t.status)
|
||||
.execute(&mut *tx)
|
||||
.await?;
|
||||
if claimed.rows_affected() != 1 {
|
||||
return Ok(Err("settled"));
|
||||
}
|
||||
// The author's change of mind lands only on a version still open (relecture of cd7e683).
|
||||
let only = s.while_open.then_some("open");
|
||||
let written = match s.text {
|
||||
TextChange::Write => {
|
||||
write_text(
|
||||
&mut tx,
|
||||
&t.proposal_id,
|
||||
t.base(),
|
||||
(&t.title, &t.body),
|
||||
only,
|
||||
&t.writer_id,
|
||||
Some((&t.id, by)),
|
||||
"typo",
|
||||
now,
|
||||
)
|
||||
.await?
|
||||
}
|
||||
TextChange::Restore => {
|
||||
write_text(
|
||||
&mut tx,
|
||||
&t.proposal_id,
|
||||
t.fixed(),
|
||||
(&t.base_title, &t.base_body),
|
||||
only,
|
||||
by,
|
||||
Some((&t.id, by)),
|
||||
"revert",
|
||||
now,
|
||||
)
|
||||
.await?
|
||||
}
|
||||
TextChange::Keep => true,
|
||||
};
|
||||
if !written {
|
||||
tx.rollback().await?;
|
||||
return Ok(Err("changed"));
|
||||
}
|
||||
tx.commit().await?;
|
||||
if s.text != TextChange::Keep {
|
||||
republish(state, &t.proposal_id).await?;
|
||||
on_text_changed(state, &t.proposal_id).await?;
|
||||
}
|
||||
Ok(Ok(()))
|
||||
}
|
||||
|
||||
async fn answer(state: &AppState, user: &IdentityRow, id: &str, a: Answer) -> AppResult<Json<Value>> {
|
||||
let t = load(state, id).await?;
|
||||
let (p, _) = proposals::guarded(state, user, &t.proposal_id).await?;
|
||||
if !trustin_access::answers_typo(&user.to_identity(), t.answerer_id.as_deref()) {
|
||||
return Err(AppError::Forbidden);
|
||||
}
|
||||
let status = t.status();
|
||||
if status == TypoStatus::Stale {
|
||||
return Err(AppError::Conflict("changed"));
|
||||
}
|
||||
let s = correction::answer(status, a, p.status == "in_force").map_err(|_| AppError::Conflict("settled"))?;
|
||||
let now = state.clock.now();
|
||||
match settle(state, &t, s, &user.id, &now).await? {
|
||||
Ok(()) => {}
|
||||
Err("changed") => {
|
||||
// Written over a text that is gone: a pending fix is stale; one already decided stays as it is.
|
||||
if status == TypoStatus::Pending {
|
||||
stale(state, &t, "pending").await?;
|
||||
}
|
||||
return Err(AppError::Conflict("changed"));
|
||||
}
|
||||
Err(_) => return Err(AppError::Conflict("settled")),
|
||||
}
|
||||
if status == TypoStatus::Pending {
|
||||
let outcome = if s.status == TypoStatus::Applied { "applied" } else { "refused" };
|
||||
messages::close(state, Topic::Typo, &t.id, None, outcome).await?;
|
||||
}
|
||||
messages::read_subject(state, &user.id, Topic::Typo, &t.id).await?;
|
||||
tracing::info!(typo = %t.id, status = s.status.as_str(), by = %user.id, "typo fix answered");
|
||||
Ok(Json(json!({ "status": s.status })))
|
||||
}
|
||||
|
||||
/// `POST /api/typos/{id}/validate`
|
||||
pub async fn validate(
|
||||
State(state): State<AppState>,
|
||||
User(user): User,
|
||||
Path(id): Path<String>,
|
||||
) -> AppResult<Json<Value>> {
|
||||
answer(&state, &user, &id, Answer::Validate).await
|
||||
}
|
||||
|
||||
/// `POST /api/typos/{id}/refuse` — refuses a pending fix, or takes back an applied
|
||||
/// one while the version is not in force.
|
||||
pub async fn refuse(State(state): State<AppState>, User(user): User, Path(id): Path<String>) -> AppResult<Json<Value>> {
|
||||
answer(&state, &user, &id, Answer::Refuse).await
|
||||
}
|
||||
|
||||
#[derive(Debug, Default, Clone, Copy, PartialEq, Eq)]
|
||||
pub struct Due {
|
||||
pub applied: usize,
|
||||
pub stale: usize,
|
||||
}
|
||||
|
||||
/// The fixes whose deadline passed apply by themselves — where their text is still there.
|
||||
pub async fn due(state: &AppState) -> AppResult<Due> {
|
||||
let now = state.clock.now();
|
||||
let rows = sqlx::query_as::<_, TypoRow>(&format!(
|
||||
"{SELECT} WHERE status = 'pending' AND due_at <= ? ORDER BY created_at, rowid"
|
||||
))
|
||||
.bind(&now)
|
||||
.fetch_all(&state.db)
|
||||
.await?;
|
||||
let mut out = Due::default();
|
||||
for t in rows {
|
||||
let Some(s) = correction::tick(t.status(), &t.due_at, &now) else { continue };
|
||||
match settle(state, &t, s, "deadline", &now).await? {
|
||||
Ok(()) => {
|
||||
messages::close(state, Topic::Typo, &t.id, None, "deadline").await?;
|
||||
tracing::info!(typo = %t.id, "typo fix applied at its deadline");
|
||||
out.applied += 1;
|
||||
}
|
||||
Err("changed") => {
|
||||
stale(state, &t, "pending").await?;
|
||||
out.stale += 1;
|
||||
}
|
||||
Err(_) => {}
|
||||
}
|
||||
}
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// What the screen shows
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
async fn name_of(state: &AppState, id: &str) -> AppResult<(Option<String>, bool)> {
|
||||
let row: Option<(String, bool)> =
|
||||
sqlx::query_as("SELECT display_name, kind = 'invite' FROM identities WHERE id = ?")
|
||||
.bind(id)
|
||||
.fetch_optional(&state.db)
|
||||
.await?;
|
||||
Ok(row.map_or((None, false), |(n, i)| (Some(n), i)))
|
||||
}
|
||||
|
||||
/// What `viewer` may do with a fix now.
|
||||
fn gestures(viewer: &IdentityRow, t: &TypoRow, p: &ProposalRow) -> Vec<&'static str> {
|
||||
if !trustin_access::answers_typo(&viewer.to_identity(), t.answerer_id.as_deref()) {
|
||||
return vec![];
|
||||
}
|
||||
let in_force = p.status == "in_force";
|
||||
let open = matches!(p.status.as_str(), "open" | "in_force");
|
||||
match t.status() {
|
||||
TypoStatus::Pending => vec!["validate", "refuse"],
|
||||
TypoStatus::Applied if open && !in_force && correction::fits(t.fixed(), text_of(p)) => vec!["refuse"],
|
||||
TypoStatus::Refused if open && !in_force && correction::fits(t.base(), text_of(p)) => vec!["validate"],
|
||||
_ => vec![],
|
||||
}
|
||||
}
|
||||
|
||||
async fn view(state: &AppState, viewer: &IdentityRow, t: &TypoRow, p: &ProposalRow) -> AppResult<Value> {
|
||||
let (writer, invited) = name_of(state, &t.writer_id).await?;
|
||||
let settled_by = match t.settled_by.as_deref() {
|
||||
Some(by @ ("deadline" | "changed")) => Some(by.to_owned()),
|
||||
Some(id) => name_of(state, id).await?.0,
|
||||
None => None,
|
||||
};
|
||||
Ok(json!({
|
||||
"id": t.id, "status": t.status, "writer": writer, "writerInvited": invited, "at": t.created_at,
|
||||
"dueAt": t.due_at, "settledAt": t.settled_at, "settledBy": settled_by,
|
||||
"answerer": if t.answerer_id.is_some() { "author" } else { "admin" },
|
||||
"diff": edit::diff(&format!("{}\n{}", t.base_title, t.base_body), &format!("{}\n{}", t.title, t.body)),
|
||||
"gestures": gestures(viewer, t, p),
|
||||
}))
|
||||
}
|
||||
|
||||
/// The fixes written by others on one version, newest first, as `viewer` sees them.
|
||||
pub async fn of_proposal(state: &AppState, viewer: &IdentityRow, p: &ProposalRow) -> AppResult<Vec<Value>> {
|
||||
let rows =
|
||||
sqlx::query_as::<_, TypoRow>(&format!("{SELECT} WHERE proposal_id = ? ORDER BY created_at DESC, rowid DESC"))
|
||||
.bind(&p.id)
|
||||
.fetch_all(&state.db)
|
||||
.await?;
|
||||
let mut out = Vec::with_capacity(rows.len());
|
||||
for t in &rows {
|
||||
out.push(view(state, viewer, t, p).await?);
|
||||
}
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
/// A fix, for a message of its box: the gestures only while it waits.
|
||||
pub async fn for_message(
|
||||
state: &AppState,
|
||||
viewer: &IdentityRow,
|
||||
subject_id: &str,
|
||||
) -> AppResult<Option<(Value, Vec<&'static str>)>> {
|
||||
let Ok(t) = load(state, subject_id).await else { return Ok(None) };
|
||||
let p = proposals::load_proposal(state, &t.proposal_id).await?;
|
||||
let mut v = view(state, viewer, &t, &p).await?;
|
||||
let code: Option<String> =
|
||||
sqlx::query_scalar("SELECT code FROM units WHERE id = ?").bind(&p.unit_id).fetch_optional(&state.db).await?;
|
||||
v["version"] = json!({ "id": p.id, "title": p.title, "status": p.status, "unitId": p.unit_id, "code": code });
|
||||
let gestures = if t.status() == TypoStatus::Pending { gestures(viewer, &t, &p) } else { vec![] };
|
||||
v["gestures"] = json!(gestures);
|
||||
Ok(Some((v, gestures)))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::{Config, open_db, seed};
|
||||
|
||||
/// Relecture of cd7e683 (M1): the author takes back a fix applied, read while her
|
||||
/// version was open; a vote puts it in force in between. The text in force stays.
|
||||
#[tokio::test]
|
||||
async fn a_change_of_mind_never_writes_over_a_text_just_in_force() {
|
||||
let db = open_db("sqlite::memory:").await.expect("db");
|
||||
let state = AppState::new(Config::for_tests(), db).await.expect("state");
|
||||
seed::ensure(&state).await.expect("seed");
|
||||
let now = "2026-10-31T00:00:00Z";
|
||||
// A seed proposal (ADR 0011) the vote has just put in force.
|
||||
let (id, doc, revision, title, body): (String, String, i64, String, String) = sqlx::query_as(
|
||||
"SELECT p.id, u.doc_id, p.revision, p.title, p.body FROM proposals p JOIN units u ON u.id = p.unit_id
|
||||
WHERE p.status = 'open' AND p.seed_featured = 1 AND p.kind = 'text' LIMIT 1",
|
||||
)
|
||||
.fetch_one(&state.db)
|
||||
.await
|
||||
.expect("a seed proposal");
|
||||
sqlx::query("UPDATE proposals SET status = 'in_force', entered_at = ? WHERE id = ?")
|
||||
.bind(now)
|
||||
.bind(&id)
|
||||
.execute(&state.db)
|
||||
.await
|
||||
.expect("in force");
|
||||
sqlx::query("UPDATE units SET in_force_id = ?1 WHERE id = (SELECT unit_id FROM proposals WHERE id = ?1)")
|
||||
.bind(&id)
|
||||
.execute(&state.db)
|
||||
.await
|
||||
.expect("unit");
|
||||
sqlx::query(
|
||||
"INSERT INTO identities (id, kind, display_name, created_at) VALUES ('dev:aude', 'dev', 'Aude', ?)",
|
||||
)
|
||||
.bind(now)
|
||||
.execute(&state.db)
|
||||
.await
|
||||
.expect("identity");
|
||||
// Its text is the one a fix wrote over « Un texte d'avant ».
|
||||
sqlx::query(
|
||||
"INSERT INTO typos (id, proposal_id, doc_id, writer_id, answerer_id, revision, base_title, base_body,
|
||||
title, body, status, created_at, due_at)
|
||||
VALUES ('t1', ?, ?, 'dev:aude', 'dev:aude', ?, ?, 'Un texte d''avant', ?, ?, 'applied', ?, ?)",
|
||||
)
|
||||
.bind(&id)
|
||||
.bind(&doc)
|
||||
.bind(revision)
|
||||
.bind(&title)
|
||||
.bind(&title)
|
||||
.bind(&body)
|
||||
.bind(now)
|
||||
.bind(now)
|
||||
.execute(&state.db)
|
||||
.await
|
||||
.expect("typo");
|
||||
let t = load(&state, "t1").await.expect("load");
|
||||
// What `answer` decided when it read the version, still open.
|
||||
let s = correction::answer(TypoStatus::Applied, Answer::Refuse, false).expect("refuse");
|
||||
assert_eq!(settle(&state, &t, s, "dev:aude", now).await.expect("settle"), Err("changed"));
|
||||
let kept: String = sqlx::query_scalar("SELECT body FROM proposals WHERE id = ?")
|
||||
.bind(&id)
|
||||
.fetch_one(&state.db)
|
||||
.await
|
||||
.expect("body");
|
||||
assert_eq!(kept, body, "the text in force stays");
|
||||
let status: String =
|
||||
sqlx::query_scalar("SELECT status FROM typos WHERE id = 't1'").fetch_one(&state.db).await.expect("status");
|
||||
assert_eq!(status, "applied", "nothing settled");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,63 @@
|
||||
//! Small shared helpers: clock, randomness, hashing, language maps.
|
||||
|
||||
use rand::RngCore;
|
||||
use sha2::{Digest, Sha256};
|
||||
use std::collections::BTreeMap;
|
||||
use time::OffsetDateTime;
|
||||
use time::format_description::well_known::Rfc3339;
|
||||
|
||||
/// Languages of the interface and of the editorial translations (Yvv, 2026-10-21).
|
||||
pub const LANGS: [&str; 3] = ["fr", "en", "es"];
|
||||
|
||||
pub fn now() -> String {
|
||||
stamp(OffsetDateTime::now_utc())
|
||||
}
|
||||
|
||||
pub fn stamp(at: OffsetDateTime) -> String {
|
||||
at.replace_nanosecond(0).unwrap_or(at).format(&Rfc3339).unwrap_or_default()
|
||||
}
|
||||
|
||||
pub fn in_days(days: i64) -> String {
|
||||
stamp(OffsetDateTime::now_utc() + time::Duration::days(days))
|
||||
}
|
||||
|
||||
pub fn in_seconds(secs: i64) -> String {
|
||||
stamp(OffsetDateTime::now_utc() + time::Duration::seconds(secs))
|
||||
}
|
||||
|
||||
/// 32 random bytes, hex — session tokens, invitation tokens, OAuth state.
|
||||
pub fn token() -> String {
|
||||
let mut bytes = [0u8; 32];
|
||||
rand::rng().fill_bytes(&mut bytes);
|
||||
hex::encode(bytes)
|
||||
}
|
||||
|
||||
/// 16 random bytes, hex — row identifiers.
|
||||
pub fn id() -> String {
|
||||
let mut bytes = [0u8; 16];
|
||||
rand::rng().fill_bytes(&mut bytes);
|
||||
hex::encode(bytes)
|
||||
}
|
||||
|
||||
pub fn sha256_hex(data: &str) -> String {
|
||||
hex::encode(Sha256::digest(data.as_bytes()))
|
||||
}
|
||||
|
||||
/// The hash a translation is pinned to: it goes stale when the source changes.
|
||||
pub fn source_hash(title: &str, body: &str) -> String {
|
||||
sha256_hex(&format!("{title}\n\u{1f}\n{body}"))
|
||||
}
|
||||
|
||||
pub type LangMap = BTreeMap<String, String>;
|
||||
|
||||
pub fn lang_json(map: &LangMap) -> String {
|
||||
serde_json::to_string(map).unwrap_or_else(|_| "{}".into())
|
||||
}
|
||||
|
||||
pub fn parse_lang(raw: &str) -> LangMap {
|
||||
serde_json::from_str(raw).unwrap_or_default()
|
||||
}
|
||||
|
||||
pub fn is_lang(lang: &str) -> bool {
|
||||
LANGS.contains(&lang)
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
//! The compiled Svelte interface: static files, SPA fallback, cache policy.
|
||||
|
||||
use axum::body::Body;
|
||||
use axum::http::Request;
|
||||
use axum::http::header::{CACHE_CONTROL, HeaderValue};
|
||||
use axum::response::Response;
|
||||
use std::path::Path;
|
||||
use tower::ServiceExt;
|
||||
use tower::util::BoxCloneSyncService;
|
||||
use tower_http::services::{ServeDir, ServeFile};
|
||||
|
||||
pub type WebService = BoxCloneSyncService<Request<Body>, Response, std::convert::Infallible>;
|
||||
|
||||
/// Hashed assets are immutable; everything else (index.html) is revalidated.
|
||||
pub fn service(dir: &Path) -> WebService {
|
||||
let index = dir.join("index.html");
|
||||
let serve = ServeDir::new(dir).precompressed_gzip().fallback(ServeFile::new(index));
|
||||
let svc = tower::service_fn(move |req: Request<Body>| {
|
||||
let immutable = req.uri().path().starts_with("/_app/immutable/");
|
||||
let serve = serve.clone();
|
||||
async move {
|
||||
let res = serve.oneshot(req).await.unwrap_or_else(|e| match e {});
|
||||
let mut res = res.map(Body::new);
|
||||
let policy = if immutable { "public, max-age=31536000, immutable" } else { "no-cache" };
|
||||
res.headers_mut().insert(CACHE_CONTROL, HeaderValue::from_static(policy));
|
||||
Ok::<_, std::convert::Infallible>(res)
|
||||
}
|
||||
});
|
||||
BoxCloneSyncService::new(svc)
|
||||
}
|
||||
@@ -0,0 +1,131 @@
|
||||
//! W — the size of the Ğ1 web of trust, the electorate of Monnaie libre.
|
||||
//!
|
||||
//! Read on chain first (`Membership.CounterForMembership`, JSON-RPC
|
||||
//! `state_getStorage` on a Duniter v2s node), from an indexer otherwise, and
|
||||
//! kept in `settings`. A failed refresh keeps the last known value; the very
|
||||
//! first start falls back to `LD_WOT_FALLBACK`.
|
||||
|
||||
use crate::AppState;
|
||||
use crate::util;
|
||||
use serde_json::json;
|
||||
use std::time::Duration;
|
||||
|
||||
/// W is read again this often.
|
||||
pub const REFRESH: Duration = Duration::from_secs(6 * 3600);
|
||||
/// The sizes of the web of trust taken as true; anything else read is ignored. The texts of
|
||||
/// the seed are written true for every W in it (lot T3).
|
||||
pub const PLAUSIBLE: std::ops::RangeInclusive<u32> = 100..=10_000_000;
|
||||
|
||||
fn twox_128(data: &[u8]) -> [u8; 16] {
|
||||
let mut out = [0u8; 16];
|
||||
out[..8].copy_from_slice(&twox_hash::XxHash64::oneshot(0, data).to_le_bytes());
|
||||
out[8..].copy_from_slice(&twox_hash::XxHash64::oneshot(1, data).to_le_bytes());
|
||||
out
|
||||
}
|
||||
|
||||
/// Storage key of a plain storage value: `twox_128(pallet) ++ twox_128(item)`.
|
||||
pub fn storage_key(pallet: &str, item: &str) -> String {
|
||||
let mut key = Vec::with_capacity(32);
|
||||
key.extend_from_slice(&twox_128(pallet.as_bytes()));
|
||||
key.extend_from_slice(&twox_128(item.as_bytes()));
|
||||
format!("0x{}", hex::encode(key))
|
||||
}
|
||||
|
||||
/// SCALE `u32`, little endian.
|
||||
pub fn decode_u32(hex_value: &str) -> Option<u32> {
|
||||
let bytes = hex::decode(hex_value.trim_start_matches("0x")).ok()?;
|
||||
let arr: [u8; 4] = bytes.get(..4)?.try_into().ok()?;
|
||||
Some(u32::from_le_bytes(arr))
|
||||
}
|
||||
|
||||
async fn from_chain(state: &AppState) -> Option<(u32, String)> {
|
||||
let key = storage_key("Membership", "CounterForMembership");
|
||||
for node in &state.cfg.rpc_endpoints {
|
||||
let body = json!({ "jsonrpc": "2.0", "id": 1, "method": "state_getStorage", "params": [key] });
|
||||
let res = state.http.post(node).json(&body).send().await;
|
||||
let value: Option<serde_json::Value> = match res {
|
||||
Ok(r) if r.status().is_success() => r.json().await.ok(),
|
||||
_ => None,
|
||||
};
|
||||
if let Some(n) = value.as_ref().and_then(|v| v["result"].as_str()).and_then(decode_u32)
|
||||
&& PLAUSIBLE.contains(&n)
|
||||
{
|
||||
return Some((n, format!("chain:{node}")));
|
||||
}
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
async fn from_indexer(state: &AppState) -> Option<(u32, String)> {
|
||||
let query = json!({ "query": "{ identities(filter:{isMember:{equalTo:true}}) { totalCount } }" });
|
||||
for url in &state.cfg.indexers {
|
||||
let res = state.http.post(url).json(&query).send().await;
|
||||
let value: Option<serde_json::Value> = match res {
|
||||
Ok(r) if r.status().is_success() => r.json().await.ok(),
|
||||
_ => None,
|
||||
};
|
||||
let n = value
|
||||
.as_ref()
|
||||
.and_then(|v| v["data"]["identities"]["totalCount"].as_u64())
|
||||
.and_then(|n| u32::try_from(n).ok());
|
||||
if let Some(n) = n.filter(|n| PLAUSIBLE.contains(n)) {
|
||||
return Some((n, format!("indexer:{url}")));
|
||||
}
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
pub async fn refresh(state: &AppState) -> anyhow::Result<Option<u32>> {
|
||||
let found = match from_chain(state).await {
|
||||
Some(v) => Some(v),
|
||||
None => from_indexer(state).await,
|
||||
};
|
||||
let Some((n, source)) = found else {
|
||||
tracing::warn!("web of trust size: no node nor indexer answered, keeping the last value");
|
||||
return Ok(None);
|
||||
};
|
||||
let now = util::now();
|
||||
for (key, value) in [("wot_size", n.to_string()), ("wot_source", source.clone())] {
|
||||
sqlx::query(
|
||||
"INSERT INTO settings (key, value, updated_at) VALUES (?, ?, ?)
|
||||
ON CONFLICT(key) DO UPDATE SET value = excluded.value, updated_at = excluded.updated_at",
|
||||
)
|
||||
.bind(key)
|
||||
.bind(value)
|
||||
.bind(&now)
|
||||
.execute(&state.db)
|
||||
.await?;
|
||||
}
|
||||
tracing::info!(size = n, %source, "web of trust size");
|
||||
Ok(Some(n))
|
||||
}
|
||||
|
||||
pub async fn refresher(state: AppState) {
|
||||
let mut tick = tokio::time::interval(REFRESH);
|
||||
loop {
|
||||
tick.tick().await;
|
||||
if let Err(e) = refresh(&state).await {
|
||||
tracing::warn!(error = %e, "web of trust refresh");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn storage_keys_match_substrate() {
|
||||
// Well-known key of System.Number (block number) on any Substrate chain.
|
||||
assert_eq!(
|
||||
storage_key("System", "Number"),
|
||||
"0x26aa394eea5630e07c48ae0c9558cef702a5c1b19ab7a04f536c519aca4983ac"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn scale_u32() {
|
||||
assert_eq!(decode_u32("0x71160000"), Some(5745));
|
||||
assert_eq!(decode_u32("0x01"), None);
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
+575
@@ -0,0 +1,575 @@
|
||||
{
|
||||
"data": {
|
||||
"squidStatus": {
|
||||
"height": 3407492
|
||||
},
|
||||
"txComments": {
|
||||
"pageInfo": {
|
||||
"hasNextPage": false,
|
||||
"endCursor": "WyJibG9ja19udW1iZXJfYXNjIiwiaWRfYXNjIixbMzM5OTU2NiwiMDAwMzM5OTU2Ni1kNDNlNy0wMDAwMDMiXV0="
|
||||
},
|
||||
"nodes": [
|
||||
{
|
||||
"id": "0000196944-60692-000003",
|
||||
"blockNumber": 196944,
|
||||
"remark": "Merci ",
|
||||
"author": {
|
||||
"id": "g1NrqKgJW9FoFabaYW82zhS9tDWdQcL1DGpivaJdj7GW9M6iv",
|
||||
"identity": {
|
||||
"index": 1304,
|
||||
"name": "SIMONEANTONELLI",
|
||||
"isMember": true
|
||||
}
|
||||
},
|
||||
"transfersByCommentId": {
|
||||
"nodes": [
|
||||
{
|
||||
"fromId": "g1NrqKgJW9FoFabaYW82zhS9tDWdQcL1DGpivaJdj7GW9M6iv",
|
||||
"toId": "g1MuCTPqrcsESdhj6vZeApbqGmJG5V3Hj56cwMUmyiM7WYQe7",
|
||||
"amount": "30000"
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "0000243074-e094a-000007",
|
||||
"blockNumber": 243074,
|
||||
"remark": "Merci ",
|
||||
"author": {
|
||||
"id": "g1Kdm8ffnMrk3p8btJhfV48Kd4fUkyMi6nD7NtFpCCPfcQsoB",
|
||||
"identity": {
|
||||
"index": 3947,
|
||||
"name": "Jade971",
|
||||
"isMember": true
|
||||
}
|
||||
},
|
||||
"transfersByCommentId": {
|
||||
"nodes": [
|
||||
{
|
||||
"fromId": "g1Kdm8ffnMrk3p8btJhfV48Kd4fUkyMi6nD7NtFpCCPfcQsoB",
|
||||
"toId": "g1KDbaL72ZYii33nVgDeC7vAn7sJcHeddNHM7RWJ65sYTEwPN",
|
||||
"amount": "2000"
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "0000455693-45609-000003",
|
||||
"blockNumber": 455693,
|
||||
"remark": "Merci ",
|
||||
"author": {
|
||||
"id": "g1PaLtaGwJKWfmU2usSdpP9Ztu9Yy3F1CB1XXvxmL1oGgLzYB",
|
||||
"identity": null
|
||||
},
|
||||
"transfersByCommentId": {
|
||||
"nodes": [
|
||||
{
|
||||
"fromId": "g1PaLtaGwJKWfmU2usSdpP9Ztu9Yy3F1CB1XXvxmL1oGgLzYB",
|
||||
"toId": "g1Md8DVDZN3SsgaC4ywoQXbRAdsWrFgfbt1XhdoBfMidndTVp",
|
||||
"amount": "11100"
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "0000499132-ee3d5-000006",
|
||||
"blockNumber": 499132,
|
||||
"remark": "Merci ",
|
||||
"author": {
|
||||
"id": "g1N1RtPV8RMvN3CtmSwMHyzBkL7EG7DgtPmbda8ebPnRG79tS",
|
||||
"identity": {
|
||||
"index": 12625,
|
||||
"name": "AgnesJs",
|
||||
"isMember": true
|
||||
}
|
||||
},
|
||||
"transfersByCommentId": {
|
||||
"nodes": [
|
||||
{
|
||||
"fromId": "g1N1RtPV8RMvN3CtmSwMHyzBkL7EG7DgtPmbda8ebPnRG79tS",
|
||||
"toId": "g1QNHCsN8tEKwX9sutP1zRJM8CrfL64Ycq2z9A44bE9ajUe58",
|
||||
"amount": "10000"
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "0000821130-de04d-000008",
|
||||
"blockNumber": 821130,
|
||||
"remark": "Merci ",
|
||||
"author": {
|
||||
"id": "g1Kdm8ffnMrk3p8btJhfV48Kd4fUkyMi6nD7NtFpCCPfcQsoB",
|
||||
"identity": {
|
||||
"index": 3947,
|
||||
"name": "Jade971",
|
||||
"isMember": true
|
||||
}
|
||||
},
|
||||
"transfersByCommentId": {
|
||||
"nodes": [
|
||||
{
|
||||
"fromId": "g1Kdm8ffnMrk3p8btJhfV48Kd4fUkyMi6nD7NtFpCCPfcQsoB",
|
||||
"toId": "g1QSMqeY9gCJEVMgVV7kWzkTAJCXkzcxaYpATxXrCZw88qJzc",
|
||||
"amount": "5000"
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "0001281169-c9002-000006",
|
||||
"blockNumber": 1281169,
|
||||
"remark": "Merci ",
|
||||
"author": {
|
||||
"id": "g1QDnEWYZTQQrdXaEeFoEXSwdx5zva1wZSDRGyw1qD6qewZwM",
|
||||
"identity": {
|
||||
"index": 7210,
|
||||
"name": "Tell",
|
||||
"isMember": true
|
||||
}
|
||||
},
|
||||
"transfersByCommentId": {
|
||||
"nodes": [
|
||||
{
|
||||
"fromId": "g1QDnEWYZTQQrdXaEeFoEXSwdx5zva1wZSDRGyw1qD6qewZwM",
|
||||
"toId": "g1NsKyiEPXBoG8KWFy4fi3kkHcVeVFC8keaiCH8K2h9SjpaXq",
|
||||
"amount": "18000"
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "0001303965-1b924-000006",
|
||||
"blockNumber": 1303965,
|
||||
"remark": "Merci ",
|
||||
"author": {
|
||||
"id": "g1PV5UHNx1tNKfkmcAbf8VAzmdkkSWrGynLGvHic5TtjercEQ",
|
||||
"identity": {
|
||||
"index": 13370,
|
||||
"name": "Florence76",
|
||||
"isMember": true
|
||||
}
|
||||
},
|
||||
"transfersByCommentId": {
|
||||
"nodes": [
|
||||
{
|
||||
"fromId": "g1PV5UHNx1tNKfkmcAbf8VAzmdkkSWrGynLGvHic5TtjercEQ",
|
||||
"toId": "g1Peh1S6ZPUZLCEygASbBbWf9TmGE22WcXftq4Epya4rw4v9J",
|
||||
"amount": "1500"
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "0001810083-37092-000004",
|
||||
"blockNumber": 1810083,
|
||||
"remark": "Merci ",
|
||||
"author": {
|
||||
"id": "g1QDCYEZPGkNoFYjaEmFRTVSQ8nsHmeAcgp9tsxxt9DURpwSj",
|
||||
"identity": null
|
||||
},
|
||||
"transfersByCommentId": {
|
||||
"nodes": [
|
||||
{
|
||||
"fromId": "g1QDCYEZPGkNoFYjaEmFRTVSQ8nsHmeAcgp9tsxxt9DURpwSj",
|
||||
"toId": "g1K5i6Ldr3sJofb9xEXH3M6XNzsy2pfjz3DgwnhHoSh6ThYgK",
|
||||
"amount": "58900"
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "0001810417-7e2d8-000003",
|
||||
"blockNumber": 1810417,
|
||||
"remark": "Merci ",
|
||||
"author": {
|
||||
"id": "g1QDCYEZPGkNoFYjaEmFRTVSQ8nsHmeAcgp9tsxxt9DURpwSj",
|
||||
"identity": null
|
||||
},
|
||||
"transfersByCommentId": {
|
||||
"nodes": [
|
||||
{
|
||||
"fromId": "g1QDCYEZPGkNoFYjaEmFRTVSQ8nsHmeAcgp9tsxxt9DURpwSj",
|
||||
"toId": "g1PBG6waKKvtSUqGBKP52gqutoyirZX9GgchRSkumZ6sECDK6",
|
||||
"amount": "15000"
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "0001850707-782ab-000003",
|
||||
"blockNumber": 1850707,
|
||||
"remark": "Merci ",
|
||||
"author": {
|
||||
"id": "g1QZevwRUPPwgA5Mq9LuwJB38UhiY6VKgbxkbPLeX64gi6pSp",
|
||||
"identity": {
|
||||
"index": 13722,
|
||||
"name": "LibrA",
|
||||
"isMember": true
|
||||
}
|
||||
},
|
||||
"transfersByCommentId": {
|
||||
"nodes": [
|
||||
{
|
||||
"fromId": "g1QZevwRUPPwgA5Mq9LuwJB38UhiY6VKgbxkbPLeX64gi6pSp",
|
||||
"toId": "g1LjiNamBTfmm8p7EH2HwNZhqgf8onVKZNYdw6QQQ8nLai8Vb",
|
||||
"amount": "70680"
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "0002197191-e9abb-000006",
|
||||
"blockNumber": 2197191,
|
||||
"remark": "Merci ",
|
||||
"author": {
|
||||
"id": "g1QHsCpSPwgY8Ze5Df7JoGCG65W6VVVoc6Aj6PQjqump7v4U1",
|
||||
"identity": {
|
||||
"index": 17203,
|
||||
"name": "Elodietexier",
|
||||
"isMember": true
|
||||
}
|
||||
},
|
||||
"transfersByCommentId": {
|
||||
"nodes": [
|
||||
{
|
||||
"fromId": "g1QHsCpSPwgY8Ze5Df7JoGCG65W6VVVoc6Aj6PQjqump7v4U1",
|
||||
"toId": "g1NGTsjwpVfEqjMZ7ZC6f3i38SQsWA2AjujN3dpbRkRiWrJSn",
|
||||
"amount": "3534"
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "0002727091-bd26d-000003",
|
||||
"blockNumber": 2727091,
|
||||
"remark": "Merci ",
|
||||
"author": {
|
||||
"id": "g1NTRfeLwTDzSYcmRxhaPNuumQKYHFAebTzPRKQBS2kRR9jkc",
|
||||
"identity": {
|
||||
"index": 18509,
|
||||
"name": "RobBiovoix",
|
||||
"isMember": true
|
||||
}
|
||||
},
|
||||
"transfersByCommentId": {
|
||||
"nodes": [
|
||||
{
|
||||
"fromId": "g1NTRfeLwTDzSYcmRxhaPNuumQKYHFAebTzPRKQBS2kRR9jkc",
|
||||
"toId": "g1KYGxSQfq1rTwiPhbrDudJfiUKNYG7aXXARnV8hmwdFCiY9r",
|
||||
"amount": "5000"
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "0002825411-05f96-000003",
|
||||
"blockNumber": 2825411,
|
||||
"remark": "Merci ",
|
||||
"author": {
|
||||
"id": "g1MqKBKJqrDastVpTGgxGobbUScSmdn8vM4XLSCWS4nwbU73s",
|
||||
"identity": {
|
||||
"index": 18506,
|
||||
"name": "BrunoAlmeida",
|
||||
"isMember": false
|
||||
}
|
||||
},
|
||||
"transfersByCommentId": {
|
||||
"nodes": [
|
||||
{
|
||||
"fromId": "g1MqKBKJqrDastVpTGgxGobbUScSmdn8vM4XLSCWS4nwbU73s",
|
||||
"toId": "g1MBoTn915Zqfrgkm6MWha7ig3ER5L1mNLmkaR2P6gpiGEZHk",
|
||||
"amount": "5000"
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "0002939378-e9fef-000006",
|
||||
"blockNumber": 2939378,
|
||||
"remark": "Merci ",
|
||||
"author": {
|
||||
"id": "g1KmwArk1Gdru3LJySvR5NWDZCUWWo1TjTjn6zjUcYs6ZW2pb",
|
||||
"identity": {
|
||||
"index": 18467,
|
||||
"name": "Vavafra",
|
||||
"isMember": true
|
||||
}
|
||||
},
|
||||
"transfersByCommentId": {
|
||||
"nodes": [
|
||||
{
|
||||
"fromId": "g1KmwArk1Gdru3LJySvR5NWDZCUWWo1TjTjn6zjUcYs6ZW2pb",
|
||||
"toId": "g1LQM6MfnjdPTrgz3ubwhwnPdbWUUq2GGQCqRQuGgrke9vRMA",
|
||||
"amount": "15000"
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "0002945277-0f2cb-000003",
|
||||
"blockNumber": 2945277,
|
||||
"remark": "Merci ",
|
||||
"author": {
|
||||
"id": "g1LtU76UpdqqQqbT9LZamFhm3vh883rtWjJhzcuVYnDkSEK2k",
|
||||
"identity": {
|
||||
"index": 18493,
|
||||
"name": "moidann",
|
||||
"isMember": false
|
||||
}
|
||||
},
|
||||
"transfersByCommentId": {
|
||||
"nodes": [
|
||||
{
|
||||
"fromId": "g1LtU76UpdqqQqbT9LZamFhm3vh883rtWjJhzcuVYnDkSEK2k",
|
||||
"toId": "g1KfFkBre7UU1JMjRoqRGFjFALWxBGf8sVcmptL4Qo7N4j87k",
|
||||
"amount": "3000"
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "0003114157-3fc38-000006",
|
||||
"blockNumber": 3114157,
|
||||
"remark": "Merci ",
|
||||
"author": {
|
||||
"id": "g1KmwArk1Gdru3LJySvR5NWDZCUWWo1TjTjn6zjUcYs6ZW2pb",
|
||||
"identity": {
|
||||
"index": 18467,
|
||||
"name": "Vavafra",
|
||||
"isMember": true
|
||||
}
|
||||
},
|
||||
"transfersByCommentId": {
|
||||
"nodes": [
|
||||
{
|
||||
"fromId": "g1KmwArk1Gdru3LJySvR5NWDZCUWWo1TjTjn6zjUcYs6ZW2pb",
|
||||
"toId": "g1QQo98nwG45NzPuPBnU28RG3WmF8vFg1skeddB19XBbn6PxB",
|
||||
"amount": "4000"
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "0003124959-a6247-000003",
|
||||
"blockNumber": 3124959,
|
||||
"remark": "Merci ",
|
||||
"author": {
|
||||
"id": "g1NQS83v9KraF965AGerQvKnFkK4dWRKhN1AKYLWXxh1awcdR",
|
||||
"identity": {
|
||||
"index": 7377,
|
||||
"name": "Sylvaine",
|
||||
"isMember": true
|
||||
}
|
||||
},
|
||||
"transfersByCommentId": {
|
||||
"nodes": [
|
||||
{
|
||||
"fromId": "g1NQS83v9KraF965AGerQvKnFkK4dWRKhN1AKYLWXxh1awcdR",
|
||||
"toId": "g1LkqPTk1oPzaPUKn7gMe182RQJpnTBPXH86Ngf5WXHQ7ccV3",
|
||||
"amount": "5000"
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "0003128170-4521a-000003",
|
||||
"blockNumber": 3128170,
|
||||
"remark": "Merci ",
|
||||
"author": {
|
||||
"id": "g1QdY8UNn8wCeq8ajCmMxS9imkk9TzVDJXFmEN7T9bXrGxUE1",
|
||||
"identity": {
|
||||
"index": 8912,
|
||||
"name": "Altheaagathe",
|
||||
"isMember": true
|
||||
}
|
||||
},
|
||||
"transfersByCommentId": {
|
||||
"nodes": [
|
||||
{
|
||||
"fromId": "g1QdY8UNn8wCeq8ajCmMxS9imkk9TzVDJXFmEN7T9bXrGxUE1",
|
||||
"toId": "g1NPyQDARSabTC4tEuEzxTiKKo4jhmPrJTeRa1wbFwJHZz2fK",
|
||||
"amount": "40000"
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "0003226425-d15c3-000009",
|
||||
"blockNumber": 3226425,
|
||||
"remark": "Merci ",
|
||||
"author": {
|
||||
"id": "g1PfpxGW31F9V5PAsPDvvC9fvjmLa4kAVCp16irihqzMzG87W",
|
||||
"identity": {
|
||||
"index": 11102,
|
||||
"name": "Philp",
|
||||
"isMember": true
|
||||
}
|
||||
},
|
||||
"transfersByCommentId": {
|
||||
"nodes": [
|
||||
{
|
||||
"fromId": "g1PfpxGW31F9V5PAsPDvvC9fvjmLa4kAVCp16irihqzMzG87W",
|
||||
"toId": "g1QWG9wugqtbPAiJYtiQeGcaJHx6bRzVM8m2y32FtJyiWgTdU",
|
||||
"amount": "14604"
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "0003227886-5270d-000003",
|
||||
"blockNumber": 3227886,
|
||||
"remark": "Merci ",
|
||||
"author": {
|
||||
"id": "g1LFH4E3FGaRP7X72ebSMMg2xcLq633WXpq7P2YZt2D6JQH5b",
|
||||
"identity": {
|
||||
"index": 16572,
|
||||
"name": "zola",
|
||||
"isMember": true
|
||||
}
|
||||
},
|
||||
"transfersByCommentId": {
|
||||
"nodes": [
|
||||
{
|
||||
"fromId": "g1LFH4E3FGaRP7X72ebSMMg2xcLq633WXpq7P2YZt2D6JQH5b",
|
||||
"toId": "g1QUqUZs9dAk8KzKnhHNsW3WoioBTmorC3HVzK5cgSJ4FsgDx",
|
||||
"amount": "6000"
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "0003229458-31a61-000006",
|
||||
"blockNumber": 3229458,
|
||||
"remark": "Merci ",
|
||||
"author": {
|
||||
"id": "g1PfpxGW31F9V5PAsPDvvC9fvjmLa4kAVCp16irihqzMzG87W",
|
||||
"identity": {
|
||||
"index": 11102,
|
||||
"name": "Philp",
|
||||
"isMember": true
|
||||
}
|
||||
},
|
||||
"transfersByCommentId": {
|
||||
"nodes": [
|
||||
{
|
||||
"fromId": "g1PfpxGW31F9V5PAsPDvvC9fvjmLa4kAVCp16irihqzMzG87W",
|
||||
"toId": "g1QWG9wugqtbPAiJYtiQeGcaJHx6bRzVM8m2y32FtJyiWgTdU",
|
||||
"amount": "12170"
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "0003297346-087b4-000006",
|
||||
"blockNumber": 3297346,
|
||||
"remark": "Merci ",
|
||||
"author": {
|
||||
"id": "g1KmwArk1Gdru3LJySvR5NWDZCUWWo1TjTjn6zjUcYs6ZW2pb",
|
||||
"identity": {
|
||||
"index": 18467,
|
||||
"name": "Vavafra",
|
||||
"isMember": true
|
||||
}
|
||||
},
|
||||
"transfersByCommentId": {
|
||||
"nodes": [
|
||||
{
|
||||
"fromId": "g1KmwArk1Gdru3LJySvR5NWDZCUWWo1TjTjn6zjUcYs6ZW2pb",
|
||||
"toId": "g1LQM6MfnjdPTrgz3ubwhwnPdbWUUq2GGQCqRQuGgrke9vRMA",
|
||||
"amount": "10000"
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "0003360722-61edb-000006",
|
||||
"blockNumber": 3360722,
|
||||
"remark": "Merci ",
|
||||
"author": {
|
||||
"id": "g1MHgWchNfT3v2go2cgQdFPsGx3EdjB1SRNSYFBpjHoQJbtqy",
|
||||
"identity": {
|
||||
"index": 14694,
|
||||
"name": "Coca-Cola",
|
||||
"isMember": true
|
||||
}
|
||||
},
|
||||
"transfersByCommentId": {
|
||||
"nodes": [
|
||||
{
|
||||
"fromId": "g1MHgWchNfT3v2go2cgQdFPsGx3EdjB1SRNSYFBpjHoQJbtqy",
|
||||
"toId": "g1MTM2ShLoB2zBGHk2iMELnPPYkij4txVgZLYC5MqzHnHB5Qv",
|
||||
"amount": "6085"
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "0003384655-8fbc0-000003",
|
||||
"blockNumber": 3384655,
|
||||
"remark": "Merci ",
|
||||
"author": {
|
||||
"id": "g1Q9CMSRUpMRYiad1CeiBXfSNxxC64ZsxmVQZj3uigMk2Z2vL",
|
||||
"identity": {
|
||||
"index": 4018,
|
||||
"name": "GeraldineGarrigues",
|
||||
"isMember": true
|
||||
}
|
||||
},
|
||||
"transfersByCommentId": {
|
||||
"nodes": [
|
||||
{
|
||||
"fromId": "g1Q9CMSRUpMRYiad1CeiBXfSNxxC64ZsxmVQZj3uigMk2Z2vL",
|
||||
"toId": "g1NQRicYR9j5r46FbgR5vxB5F9wMrjU1NaXAFAoKMseXekuNm",
|
||||
"amount": "3000"
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "0003399415-51dbe-000003",
|
||||
"blockNumber": 3399415,
|
||||
"remark": "Merci ",
|
||||
"author": {
|
||||
"id": "g1LtU76UpdqqQqbT9LZamFhm3vh883rtWjJhzcuVYnDkSEK2k",
|
||||
"identity": {
|
||||
"index": 18493,
|
||||
"name": "moidann",
|
||||
"isMember": false
|
||||
}
|
||||
},
|
||||
"transfersByCommentId": {
|
||||
"nodes": [
|
||||
{
|
||||
"fromId": "g1LtU76UpdqqQqbT9LZamFhm3vh883rtWjJhzcuVYnDkSEK2k",
|
||||
"toId": "g1LQM6MfnjdPTrgz3ubwhwnPdbWUUq2GGQCqRQuGgrke9vRMA",
|
||||
"amount": "10000"
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "0003399566-d43e7-000003",
|
||||
"blockNumber": 3399566,
|
||||
"remark": "Merci ",
|
||||
"author": {
|
||||
"id": "g1Q6zsaVPZuYDRDh651j8X3hwyyvvpD7rGovPGpHt4cd5XGDs",
|
||||
"identity": {
|
||||
"index": 18630,
|
||||
"name": "moidann12",
|
||||
"isMember": false
|
||||
}
|
||||
},
|
||||
"transfersByCommentId": {
|
||||
"nodes": [
|
||||
{
|
||||
"fromId": "g1Q6zsaVPZuYDRDh651j8X3hwyyvvpD7rGovPGpHt4cd5XGDs",
|
||||
"toId": "g1LQM6MfnjdPTrgz3ubwhwnPdbWUUq2GGQCqRQuGgrke9vRMA",
|
||||
"amount": "15000"
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,793 @@
|
||||
{
|
||||
"_source": "maquette/frontend/app/data/seeds/duniter-g1.bundle.json (doc engagement-certification, 33 clauses + 2 variantes). Aucun article en vigueur au départ. I1, E1, E2 : Propositions seed (« seedProposal »), ouvertes, mises en avant pour amorcer le vote, sans niveau d'inertie ici : elles démarrent au cran SEED_PROPOSAL_LEVEL (crates/ld-server/src/seed.rs : 2 « basse », choix d'Yvv du 2026-11-02, qui remontera leur inertie par un vote une fois les textes passés ; ADR 0011). Tant qu'aucun article n'est en vigueur, la publication attend le premier texte. Tout le reste : propositions ouvertes à 0 vote (Yvv, 2026-10-21), inertie 3 / 5. I1 « Raison d'être » remplace le Préambule (texte d'Yvv, revue du 2026-10-30). Modules : le titre du document à 4, l'ordre des articles de chaque section à 2, en vigueur sans vote (seed a minima ; revue du 2026-10-30, ADR 0009). F1.2, N1, N1.1, O1 réécrits selon les règles appliquées (Yvv, 2026-11-02). Quorums par cran (« quorums », ADR 0013, Yvv, 2026-11-02 et 03) : 12 · 12 · 50 · 50 · 50 dans Monnaie libre, 1 partout dans le groupe d'essai ; sous le quorum, les votants qui manquent comptent comme « non » ; voteFloor reste le quorum de référence de la participation p0 = 2 × voteFloor / W. Une base existante reçoit ces changements par des amendements gardés (seed_amend, seed_proposal_amend, quorum_amend), jamais en réinstanciant le document. F1.1, F1.2 et N1.1 réécrits pour le quorum par cran (ADR 0013 ; Yvv, 2026-11-02 et 03 ; lot T, textes relus dans docs/chantiers/acte-certification/textes-quorum-seed.md) : sous le quorum, les votants qui manquent comptent comme « non » ; au-delà, la part du cran. Trois phrases reprises après la relecture de M6b (lot T2) : la part du cran, la plus haute au quorum, baisse quand la participation monte (F1.1) ; l'auteur fixe le quorum tant que l'article ne garde la trace d'aucun vote (F1.2) ; le quorum n'est pas un nombre minimal de votants (N1.1). Lot T3 (Yvv, 03/11 : « pas de chiffre datés dans un texte voté ») : l'exemple chiffré de N1.1 retiré (aucun nombre des textes ne dépend de W ni d'une participation) ; N1.1, sous le quorum, il faut autant de « pour » que si le quorum avait voté (vrai pour tout quorum) ; N1.1 et F1.2, la part du cran comme d'habitude au-delà du quorum. Après la relecture de 2ce2028 (lot T3b, même amendement) : N1.1, G se calcule pour que la part exigée, au-delà du quorum, soit celle du cran quand le groupe vote comme d'habitude (vrai pour tout W et tout quorum). Après la relecture de e7ab82f (lot T3c, mêmes amendements) : F1.2 et N1.1 écrivent le terme défini, « à la participation habituelle » (la médiane ramenée entre 1 % et 50 % de W, celle sur laquelle G se calcule), à la place de « comme d'habitude », faux quand la médiane sort de ses bornes ; N1.1, « sous le quorum de l'article », « la part exigée », « Tant qu'elle repose sur moins de 5 propositions », « Elle est ramenée entre 1 % et 50 % » (liste fermée des phrases : docs/chantiers/acte-certification/t3c-liste-fermee.md).",
|
||||
"slug": "acte-engagement-certification",
|
||||
"title": {
|
||||
"fr": "Acte d'engagement Certification",
|
||||
"en": "Certification Commitment Act",
|
||||
"es": "Acta de compromiso de Certificación"
|
||||
},
|
||||
"summary": {
|
||||
"fr": "Ce que chaque membre de la toile de confiance Ğ1 s'engage à respecter quand il certifie. Texte modulaire sous vote permanent : chaque article entre en vigueur dès qu'il atteint son seuil.",
|
||||
"en": "What every member of the Ğ1 web of trust commits to uphold when certifying. A modular text under permanent vote: each article comes into force as soon as it reaches its threshold.",
|
||||
"es": "Lo que todo miembro de la red de confianza Ğ1 se compromete a respetar cuando certifica. Texto modular en votación permanente: cada artículo entra en vigor en cuanto alcanza su umbral."
|
||||
},
|
||||
"sourceLang": "fr",
|
||||
"instances": [
|
||||
{
|
||||
"group": "monnaie-libre",
|
||||
"voteFloor": 50,
|
||||
"quorums": [12, 12, 50, 50, 50],
|
||||
"publishPath": "monnaie-libre/acte-engagement-certification"
|
||||
},
|
||||
{
|
||||
"group": "essai",
|
||||
"voteFloor": 1,
|
||||
"quorums": [1, 1, 1, 1, 1],
|
||||
"publishPath": null
|
||||
}
|
||||
],
|
||||
"modules": {
|
||||
"title": {
|
||||
"inertia": 4
|
||||
},
|
||||
"order": {
|
||||
"inertia": 2
|
||||
}
|
||||
},
|
||||
"units": [
|
||||
{
|
||||
"code": "I1",
|
||||
"section": "introduction",
|
||||
"position": 1,
|
||||
"versions": [
|
||||
{
|
||||
"label": "1.0.0",
|
||||
"status": "open",
|
||||
"seedProposal": true,
|
||||
"title": {
|
||||
"fr": "Raison d'être",
|
||||
"en": "Purpose",
|
||||
"es": "Razón de ser"
|
||||
},
|
||||
"body": {
|
||||
"fr": "*« 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. »*\n\nC'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.\n\nCet acte d'engagement est par ailleurs le seul acte de nature contractuelle qui fonde l'usage de la première monnaie libre.",
|
||||
"en": "*“Behind a key there is only one living human being; behind a human being, there is only one certifying key.”*\n\nThis is the one and only purpose of certification, which gives our fiduciary web its value. We can rely on the uniqueness and integrity of every action signed with this key. That is what is at stake in certification.\n\nThis commitment act is, moreover, the only act of a contractual nature on which the use of the first libre currency is founded.",
|
||||
"es": "*«Detrás de una clave no hay más que un solo ser humano vivo; detrás de un ser humano, no hay más que una sola clave certificadora.»*\n\nEste es el propósito único y exclusivo de la certificación, que da su valor a nuestra red fiduciaria. Podemos fiarnos de la unicidad y la integridad de toda acción firmada con esta clave. Eso es lo que está en juego en la certificación.\n\nEsta acta de compromiso es, además, el único acto de naturaleza contractual en el que se funda el uso de la primera moneda libre."
|
||||
}
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"code": "I2",
|
||||
"section": "introduction",
|
||||
"position": 2,
|
||||
"inertia": 3,
|
||||
"versions": [
|
||||
{
|
||||
"label": "1.0.0",
|
||||
"status": "open",
|
||||
"title": {
|
||||
"fr": "Les deux garanties réciproques",
|
||||
"en": "The two reciprocal guarantees",
|
||||
"es": "Las dos garantías recíprocas"
|
||||
},
|
||||
"body": {
|
||||
"fr": "La Certification repose sur deux garanties réciproques :\n\n**1.** Derrière une clé publique créatrice de monnaie se trouve un **être humain vivant**.\n\n**2.** Derrière cet être humain se trouve **une seule et unique** clé publique créatrice de monnaie.\n\nLa Certification est un acte technique et fiduciaire, pas un acte d'adhésion morale ou de sympathie.",
|
||||
"en": "Certification rests on two reciprocal guarantees:\n\n**1.** Behind a money-creating public key there is a **living human being**.\n\n**2.** Behind this human being there is **one single and unique** money-creating public key.\n\nCertification is a technical and fiduciary act, not an act of moral endorsement or of sympathy.",
|
||||
"es": "La Certificación se basa en dos garantías recíprocas:\n\n**1.** Detrás de una clave pública creadora de moneda hay un **ser humano vivo**.\n\n**2.** Detrás de este ser humano hay **una sola y única** clave pública creadora de moneda.\n\nLa Certificación es un acto técnico y fiduciario, no un acto de adhesión moral ni de simpatía."
|
||||
}
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"code": "T1",
|
||||
"section": "fondamental",
|
||||
"position": 3,
|
||||
"inertia": 3,
|
||||
"versions": [
|
||||
{
|
||||
"label": "1.0.0",
|
||||
"status": "open",
|
||||
"title": {
|
||||
"fr": "Engagements fondamentaux",
|
||||
"en": "Fundamental commitments",
|
||||
"es": "Compromisos fundamentales"
|
||||
},
|
||||
"body": {
|
||||
"fr": "Les engagements fondamentaux ci-dessous constituent le socle irréductible de l'acte d'engagement. Ils sont pris sur l'honneur par tout membre de la toile de confiance.",
|
||||
"en": "The fundamental commitments below form the irreducible foundation of the commitment act. They are made on their honour by every member of the web of trust.",
|
||||
"es": "Los compromisos fundamentales que siguen constituyen la base irreductible del acta de compromiso. Todo miembro de la red de confianza los asume por su honor."
|
||||
}
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"code": "E1",
|
||||
"section": "fondamental",
|
||||
"position": 4,
|
||||
"versions": [
|
||||
{
|
||||
"label": "1.0.0",
|
||||
"status": "open",
|
||||
"seedProposal": true,
|
||||
"title": {
|
||||
"fr": "Unicité du compte",
|
||||
"en": "Uniqueness of the account",
|
||||
"es": "Unicidad de la cuenta"
|
||||
},
|
||||
"body": {
|
||||
"fr": "Je m'engage sur l'honneur à n'avoir et n'avoir jamais qu'un seul et unique compte cocréateur de monnaie G1.",
|
||||
"en": "I commit on my honour to have, now and ever, no more than one single and unique account co-creating G1 money.",
|
||||
"es": "Me comprometo por mi honor a no tener, ahora ni nunca, más que una sola y única cuenta cocreadora de moneda G1."
|
||||
}
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"code": "E2",
|
||||
"section": "fondamental",
|
||||
"position": 5,
|
||||
"versions": [
|
||||
{
|
||||
"label": "1.0.0",
|
||||
"status": "open",
|
||||
"seedProposal": true,
|
||||
"title": {
|
||||
"fr": "Réciprocité",
|
||||
"en": "Reciprocity",
|
||||
"es": "Reciprocidad"
|
||||
},
|
||||
"body": {
|
||||
"fr": "Je m'engage sur l'honneur à ne certifier que des personnes physiques qui respectent scrupuleusement ces deux présents engagements fondamentaux.",
|
||||
"en": "I commit on my honour to certify only natural persons who scrupulously respect these two fundamental commitments.",
|
||||
"es": "Me comprometo por mi honor a certificar únicamente a personas físicas que respeten escrupulosamente estos dos compromisos fundamentales."
|
||||
}
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"code": "T2",
|
||||
"section": "technique",
|
||||
"position": 6,
|
||||
"inertia": 3,
|
||||
"versions": [
|
||||
{
|
||||
"label": "1.0.0",
|
||||
"status": "open",
|
||||
"title": {
|
||||
"fr": "Engagements techniques",
|
||||
"en": "Technical commitments",
|
||||
"es": "Compromisos técnicos"
|
||||
},
|
||||
"body": {
|
||||
"fr": "Les engagements techniques définissent les obligations pratiques et vérifiables du certificateur pour garantir la qualité de la toile de confiance.",
|
||||
"en": "The technical commitments define the practical and verifiable obligations of the certifier to guarantee the quality of the web of trust.",
|
||||
"es": "Los compromisos técnicos definen las obligaciones prácticas y verificables del certificador para garantizar la calidad de la red de confianza."
|
||||
}
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"code": "E3",
|
||||
"section": "technique",
|
||||
"position": 7,
|
||||
"inertia": 3,
|
||||
"versions": [
|
||||
{
|
||||
"label": "1.0.0",
|
||||
"status": "open",
|
||||
"title": {
|
||||
"fr": "Connaissance suffisante",
|
||||
"en": "Sufficient knowledge",
|
||||
"es": "Conocimiento suficiente"
|
||||
},
|
||||
"body": {
|
||||
"fr": "Je me suis assuré de connaître suffisamment la personne qui gère cette clé publique. Connaître suffisamment ne signifie pas « avoir vu » ; c'est assurer à la communauté G1 que je pourrai la contacter facilement et être en mesure de repérer un double-compte ou tout autre problème.",
|
||||
"en": "I have made sure that I know the person who manages this public key well enough. Knowing well enough does not mean “having seen”; it means assuring the G1 community that I will be able to contact them easily and to detect a duplicate account or any other problem.",
|
||||
"es": "Me he asegurado de conocer suficientemente a la persona que gestiona esta clave pública. Conocer suficientemente no significa «haber visto»; significa asegurar a la comunidad G1 que podré contactarla fácilmente y estar en condiciones de detectar una cuenta duplicada o cualquier otro problema."
|
||||
}
|
||||
},
|
||||
{
|
||||
"label": "0.4.0-draft",
|
||||
"status": "open",
|
||||
"title": {
|
||||
"fr": "Connaissance suffisante",
|
||||
"en": "Sufficient knowledge",
|
||||
"es": "Conocimiento suficiente"
|
||||
},
|
||||
"body": {
|
||||
"fr": "Je me suis assuré de connaître suffisamment la personne qui gère cette clé publique : je sais la recontacter par au moins deux canaux indépendants, je suis en mesure de repérer un double-compte, et je m'engage à signaler tout doute aux experts de la communauté. « Avoir vu » quelqu'un ne suffit pas.",
|
||||
"en": "I have made sure that I know the person who manages this public key well enough: I can get back in touch with them through at least two independent channels, I am able to detect a duplicate account, and I commit to reporting any doubt to the community experts. “Having seen” someone is not enough.",
|
||||
"es": "Me he asegurado de conocer suficientemente a la persona que gestiona esta clave pública: puedo volver a contactarla por al menos dos canales independientes, estoy en condiciones de detectar una cuenta duplicada y me comprometo a comunicar cualquier duda a los expertos de la comunidad. «Haber visto» a alguien no basta."
|
||||
}
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"code": "E4",
|
||||
"section": "technique",
|
||||
"position": 8,
|
||||
"inertia": 3,
|
||||
"versions": [
|
||||
{
|
||||
"label": "1.0.0",
|
||||
"status": "open",
|
||||
"title": {
|
||||
"fr": "Vérification personnelle de la clé",
|
||||
"en": "Personal verification of the key",
|
||||
"es": "Verificación personal de la clave"
|
||||
},
|
||||
"body": {
|
||||
"fr": "J'ai personnellement vérifié que c'est bien cette clé publique que je m'apprête à certifier, en la comparant avec la personne concernée et non par un intermédiaire.",
|
||||
"en": "I have personally verified that this is indeed the public key I am about to certify, by comparing it with the person concerned and not through an intermediary.",
|
||||
"es": "He verificado personalmente que esta es efectivamente la clave pública que me dispongo a certificar, comparándola con la persona interesada y no a través de un intermediario."
|
||||
}
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"code": "E5",
|
||||
"section": "technique",
|
||||
"position": 9,
|
||||
"inertia": 3,
|
||||
"versions": [
|
||||
{
|
||||
"label": "1.0.0",
|
||||
"status": "open",
|
||||
"title": {
|
||||
"fr": "Joignabilité réciproque",
|
||||
"en": "Mutual reachability",
|
||||
"es": "Contacto recíproco"
|
||||
},
|
||||
"body": {
|
||||
"fr": "Je suis joignable rapidement et facilement par mes certifieurs, et je peux joindre rapidement et facilement les personnes que je certifie, par plusieurs moyens de communication différents et indépendants (physique, téléphone, email, messagerie, etc.).",
|
||||
"en": "I can be reached quickly and easily by my certifiers, and I can quickly and easily reach the people I certify, through several different and independent means of communication (in person, phone, email, messaging, etc.).",
|
||||
"es": "Soy localizable de forma rápida y sencilla por mis certificadores, y puedo localizar de forma rápida y sencilla a las personas que certifico, por varios medios de comunicación diferentes e independientes (en persona, teléfono, correo electrónico, mensajería, etc.)."
|
||||
}
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"code": "E6",
|
||||
"section": "technique",
|
||||
"position": 10,
|
||||
"inertia": 3,
|
||||
"versions": [
|
||||
{
|
||||
"label": "1.0.0",
|
||||
"status": "open",
|
||||
"title": {
|
||||
"fr": "Document de révocation",
|
||||
"en": "Revocation document",
|
||||
"es": "Documento de revocación"
|
||||
},
|
||||
"body": {
|
||||
"fr": "J'ai vérifié avec la personne certifiée qu'elle a bien généré son document de révocation de compte et qu'elle le conserve en lieu sûr.",
|
||||
"en": "I have checked with the certified person that they have indeed generated their account revocation document and that they keep it in a safe place.",
|
||||
"es": "He verificado con la persona certificada que ha generado efectivamente su documento de revocación de cuenta y que lo conserva en un lugar seguro."
|
||||
}
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"code": "E7",
|
||||
"section": "technique",
|
||||
"position": 11,
|
||||
"inertia": 3,
|
||||
"versions": [
|
||||
{
|
||||
"label": "1.0.0",
|
||||
"status": "open",
|
||||
"title": {
|
||||
"fr": "Rencontre physique ou vérification multi-canaux",
|
||||
"en": "In-person meeting or multi-channel verification",
|
||||
"es": "Encuentro en persona o verificación multicanal"
|
||||
},
|
||||
"body": {
|
||||
"fr": "J'ai rencontré la personne physiquement (préférable), **OU** j'ai vérifié à distance le lien personne / clé publique par plusieurs moyens de communication différents et indépendants : courrier + réseau social + forum + email + visio + téléphone.",
|
||||
"en": "I have met the person face to face (preferable), **OR** I have remotely verified the person / public key link through several different and independent means of communication: postal mail + social network + forum + email + video call + phone.",
|
||||
"es": "Me he encontrado físicamente con la persona (preferible), **O** he verificado a distancia el vínculo persona / clave pública por varios medios de comunicación diferentes e independientes: correo postal + red social + foro + correo electrónico + videollamada + teléfono."
|
||||
}
|
||||
},
|
||||
{
|
||||
"label": "0.4.0-draft",
|
||||
"status": "open",
|
||||
"title": {
|
||||
"fr": "Rencontre physique ou vérification multi-canaux",
|
||||
"en": "In-person meeting or multi-channel verification",
|
||||
"es": "Encuentro en persona o verificación multicanal"
|
||||
},
|
||||
"body": {
|
||||
"fr": "J'ai rencontré la personne physiquement (préférable), **OU** j'ai vérifié à distance le lien personne / clé publique par au moins trois moyens de communication différents et indépendants, dont un échange en visio en direct.",
|
||||
"en": "I have met the person face to face (preferable), **OR** I have remotely verified the person / public key link through at least three different and independent means of communication, including a live video call.",
|
||||
"es": "Me he encontrado físicamente con la persona (preferible), **O** he verificado a distancia el vínculo persona / clave pública por al menos tres medios de comunicación diferentes e independientes, entre ellos una videollamada en directo."
|
||||
}
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"code": "T3",
|
||||
"section": "technique",
|
||||
"position": 12,
|
||||
"inertia": 3,
|
||||
"versions": [
|
||||
{
|
||||
"label": "1.0.0",
|
||||
"status": "open",
|
||||
"title": {
|
||||
"fr": "Conseils et bonnes pratiques",
|
||||
"en": "Advice and good practices",
|
||||
"es": "Consejos y buenas prácticas"
|
||||
},
|
||||
"body": {
|
||||
"fr": "Les pratiques suivantes sont fortement recommandées pour garantir la qualité et la sécurité de la toile de confiance.",
|
||||
"en": "The following practices are strongly recommended to guarantee the quality and security of the web of trust.",
|
||||
"es": "Las siguientes prácticas se recomiendan encarecidamente para garantizar la calidad y la seguridad de la red de confianza."
|
||||
}
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"code": "E8",
|
||||
"section": "technique",
|
||||
"position": 13,
|
||||
"inertia": 3,
|
||||
"versions": [
|
||||
{
|
||||
"label": "1.0.0",
|
||||
"status": "open",
|
||||
"title": {
|
||||
"fr": "Ne jamais certifier seul",
|
||||
"en": "Never certify alone",
|
||||
"es": "No certificar nunca en solitario"
|
||||
},
|
||||
"body": {
|
||||
"fr": "Ne certifiez jamais seul, mais accompagné d'au moins un autre membre de la toile de confiance G1, pour garantir un double contrôle.",
|
||||
"en": "Never certify alone, but accompanied by at least one other member of the G1 web of trust, to guarantee a double check.",
|
||||
"es": "No certifique nunca en solitario, sino junto con al menos otro miembro de la red de confianza G1, para garantizar un doble control."
|
||||
}
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"code": "E9",
|
||||
"section": "technique",
|
||||
"position": 14,
|
||||
"inertia": 3,
|
||||
"versions": [
|
||||
{
|
||||
"label": "1.0.0",
|
||||
"status": "open",
|
||||
"title": {
|
||||
"fr": "Vérification des certifications existantes",
|
||||
"en": "Verification of existing certifications",
|
||||
"es": "Verificación de las certificaciones existentes"
|
||||
},
|
||||
"body": {
|
||||
"fr": "Avant toute Certification, vérifiez si le compte a déjà reçu des certifications et de qui elles proviennent. Contactez les certifieurs existants en cas de doute. Si un certifieurs existant ne connaît pas la personne, alertez immédiatement les experts de la communauté.",
|
||||
"en": "Before any Certification, check whether the account has already received certifications and who they come from. Contact the existing certifiers if in doubt. If an existing certifier does not know the person, alert the community experts immediately.",
|
||||
"es": "Antes de cualquier Certificación, verifique si la cuenta ya ha recibido certificaciones y de quién proceden. En caso de duda, contacte con los certificadores existentes. Si un certificador existente no conoce a la persona, alerte de inmediato a los expertos de la comunidad."
|
||||
}
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"code": "E10",
|
||||
"section": "technique",
|
||||
"position": 15,
|
||||
"inertia": 3,
|
||||
"versions": [
|
||||
{
|
||||
"label": "1.0.0",
|
||||
"status": "open",
|
||||
"title": {
|
||||
"fr": "Vérification de maîtrise du compte",
|
||||
"en": "Verification of account control",
|
||||
"es": "Verificación del control de la cuenta"
|
||||
},
|
||||
"body": {
|
||||
"fr": "Vérifiez la maîtrise du compte par un transfert-test : envoyez quelques G1 et demandez le renvoi, afin de vous assurer que la personne contrôle bien sa clé privée.",
|
||||
"en": "Verify control of the account with a test transfer: send a few G1 and ask for them to be sent back, to make sure that the person really controls their private key.",
|
||||
"es": "Verifique el control de la cuenta mediante una transferencia de prueba: envíe unos pocos G1 y pida que se los devuelvan, para asegurarse de que la persona controla realmente su clave privada."
|
||||
}
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"code": "E11",
|
||||
"section": "technique",
|
||||
"position": 16,
|
||||
"inertia": 3,
|
||||
"versions": [
|
||||
{
|
||||
"label": "1.0.0",
|
||||
"status": "open",
|
||||
"title": {
|
||||
"fr": "Transmission et compréhension du document",
|
||||
"en": "Transmission and understanding of the document",
|
||||
"es": "Transmisión y comprensión del documento"
|
||||
},
|
||||
"body": {
|
||||
"fr": "Vérifiez que vos contacts ont bien étudié et compris le présent acte d'engagement dans sa version à jour avant de procéder à la Certification.",
|
||||
"en": "Check that your contacts have indeed studied and understood the current version of this commitment act before proceeding with the Certification.",
|
||||
"es": "Verifique que sus contactos han estudiado y comprendido efectivamente la versión vigente de la presente acta de compromiso antes de proceder a la Certificación."
|
||||
}
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"code": "K1",
|
||||
"section": "conclusion",
|
||||
"position": 17,
|
||||
"inertia": 3,
|
||||
"versions": [
|
||||
{
|
||||
"label": "1.0.0",
|
||||
"status": "open",
|
||||
"title": {
|
||||
"fr": "Règles abrégées de la toile de confiance",
|
||||
"en": "Abridged rules of the web of trust",
|
||||
"es": "Reglas abreviadas de la red de confianza"
|
||||
},
|
||||
"body": {
|
||||
"fr": "**Paramètres protocolaires en vigueur :**\n\n- Stock de **100 certifications** possibles\n- 1 certification émissible tous les **5 jours**\n- Nouveau compte valide si **>= 5 certifications** reçues en **2 mois**\n- Condition de distance : **<= 5 pas** de **80% des sentinelles**\n- Sentinelle : membre ayant reçu et émis >= Y[N] certifs (Y = ceil(N^(1/5)))\n- Certifications actives valables **2 ans**\n- Renouvellement de l'accord tous les **12 mois**\n\n*Note : le vote porte sur l'inclusion de ces règles dans le document, pas sur les valeurs des variables protocolaires elles-mêmes, qui sont fixées par le protocole Duniter.*",
|
||||
"en": "**Protocol parameters in force:**\n\n- Stock of **100 certifications** available\n- 1 certification can be issued every **5 days**\n- New account valid if **>= 5 certifications** received within **2 months**\n- Distance rule: **<= 5 steps** from **80% of the sentries**\n- Sentry: member who has received and issued >= Y[N] certifications (Y = ceil(N^(1/5)))\n- Active certifications valid for **2 years**\n- Renewal of the agreement every **12 months**\n\n*Note: the vote concerns the inclusion of these rules in the document, not the values of the protocol variables themselves, which are set by the Duniter protocol.*",
|
||||
"es": "**Parámetros de protocolo vigentes:**\n\n- Reserva de **100 certificaciones** posibles\n- 1 certificación emitible cada **5 días**\n- Cuenta nueva válida si recibe **>= 5 certificaciones** en **2 meses**\n- Regla de distancia: **<= 5 pasos** del **80% de los centinelas**\n- Centinela: miembro que ha recibido y emitido >= Y[N] certificaciones (Y = ceil(N^(1/5)))\n- Certificaciones activas válidas durante **2 años**\n- Renovación del acuerdo cada **12 meses**\n\n*Nota: la votación se refiere a la inclusión de estas reglas en el documento, no a los valores de las variables del protocolo en sí, que fija el protocolo Duniter.*"
|
||||
}
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"code": "K2",
|
||||
"section": "conclusion",
|
||||
"position": 18,
|
||||
"inertia": 3,
|
||||
"versions": [
|
||||
{
|
||||
"label": "1.0.0",
|
||||
"status": "open",
|
||||
"title": {
|
||||
"fr": "Monnaie G1",
|
||||
"en": "G1 currency",
|
||||
"es": "Moneda G1"
|
||||
},
|
||||
"body": {
|
||||
"fr": "**Paramètres monétaires :**\n\n- 1 Dividende Universel (DU) par personne par jour\n- Réévaluation à chaque équinoxe : `DU(n+1) = DU(n) + c² × (M/N) / 182.625` avec c = 4.88%\n- DU(0) = 10.00 G1\n\n*Note : le vote porte sur l'inclusion de ces paramètres dans le document, pas sur les valeurs monétaires elles-mêmes, qui découlent de la TRM et du bloc 0.*",
|
||||
"en": "**Monetary parameters:**\n\n- 1 Universal Dividend (UD) per person per day\n- Revaluation at each equinox: `DU(n+1) = DU(n) + c² × (M/N) / 182.625` with c = 4.88%\n- DU(0) = 10.00 G1\n\n*Note: the vote concerns the inclusion of these parameters in the document, not the monetary values themselves, which derive from the RTM and block 0.*",
|
||||
"es": "**Parámetros monetarios:**\n\n- 1 Dividendo Universal (DU) por persona y día\n- Revalorización en cada equinoccio: `DU(n+1) = DU(n) + c² × (M/N) / 182.625` con c = 4.88%\n- DU(0) = 10.00 G1\n\n*Nota: la votación se refiere a la inclusión de estos parámetros en el documento, no a los valores monetarios en sí, que se derivan de la TRM y del bloque 0.*"
|
||||
}
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"code": "X1",
|
||||
"section": "annexe",
|
||||
"position": 19,
|
||||
"inertia": 3,
|
||||
"versions": [
|
||||
{
|
||||
"label": "1.0.0",
|
||||
"status": "open",
|
||||
"title": {
|
||||
"fr": "Annexe 1 : Intégration logicielle",
|
||||
"en": "Annex 1: Software integration",
|
||||
"es": "Anexo 1: Integración en el software"
|
||||
},
|
||||
"body": {
|
||||
"fr": "Les obligations pour les logiciels implémentant la Certification G1.",
|
||||
"en": "The obligations for software implementing G1 Certification.",
|
||||
"es": "Las obligaciones del software que implementa la Certificación G1."
|
||||
}
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"code": "X1.1",
|
||||
"section": "annexe",
|
||||
"position": 20,
|
||||
"inertia": 3,
|
||||
"versions": [
|
||||
{
|
||||
"label": "1.0.0",
|
||||
"status": "open",
|
||||
"title": {
|
||||
"fr": "Dépôt de référence",
|
||||
"en": "Reference repository",
|
||||
"es": "Repositorio de referencia"
|
||||
},
|
||||
"body": {
|
||||
"fr": "Le texte de référence de l'acte d'engagement Certification est hébergé dans le dépôt git officiel : https://git.duniter.org/documents/g1_monetary_license\n\nLes applications Cesium, Gecko et toute application de Certification doivent pointer vers ce dépôt pour afficher la version en vigueur.",
|
||||
"en": "The reference text of the Certification commitment act is hosted in the official git repository: https://git.duniter.org/documents/g1_monetary_license\n\nThe Cesium and Gecko applications, and any Certification application, must point to this repository to display the version in force.",
|
||||
"es": "El texto de referencia del acta de compromiso de Certificación está alojado en el repositorio git oficial: https://git.duniter.org/documents/g1_monetary_license\n\nLas aplicaciones Cesium, Gecko y cualquier aplicación de Certificación deben apuntar a este repositorio para mostrar la versión vigente."
|
||||
}
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"code": "X1.2",
|
||||
"section": "annexe",
|
||||
"position": 21,
|
||||
"inertia": 3,
|
||||
"versions": [
|
||||
{
|
||||
"label": "1.0.0",
|
||||
"status": "open",
|
||||
"title": {
|
||||
"fr": "Obligation de transmission",
|
||||
"en": "Transmission obligation",
|
||||
"es": "Obligación de transmisión"
|
||||
},
|
||||
"body": {
|
||||
"fr": "Tout logiciel G1 permettant la Certification doit transmettre le présent acte d'engagement au certifié et en afficher les paramètres du bloc 0 de la blockchain Duniter.",
|
||||
"en": "Any G1 software that enables Certification must transmit this commitment act to the certified person and display the block 0 parameters of the Duniter blockchain.",
|
||||
"es": "Todo software G1 que permita la Certificación debe transmitir la presente acta de compromiso a la persona certificada y mostrar los parámetros del bloque 0 de la blockchain Duniter."
|
||||
}
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"code": "X1.3",
|
||||
"section": "annexe",
|
||||
"position": 22,
|
||||
"inertia": 3,
|
||||
"versions": [
|
||||
{
|
||||
"label": "1.0.0",
|
||||
"status": "open",
|
||||
"title": {
|
||||
"fr": "Logiciel auditable",
|
||||
"en": "Auditable software",
|
||||
"es": "Software auditable"
|
||||
},
|
||||
"body": {
|
||||
"fr": "Tout logiciel utilisé pour la Certification ou la création monétaire doit être publié sous licence libre, afin de permettre son audit par la communauté.",
|
||||
"en": "Any software used for Certification or money creation must be published under a free licence, so that it can be audited by the community.",
|
||||
"es": "Todo software utilizado para la Certificación o la creación monetaria debe publicarse bajo licencia libre, para permitir su auditoría por parte de la comunidad."
|
||||
}
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"code": "X2",
|
||||
"section": "annexe",
|
||||
"position": 23,
|
||||
"inertia": 3,
|
||||
"versions": [
|
||||
{
|
||||
"label": "1.0.0",
|
||||
"status": "open",
|
||||
"title": {
|
||||
"fr": "Annexe 2 : Questions à la Certification",
|
||||
"en": "Annex 2: Questions for Certification",
|
||||
"es": "Anexo 2: Preguntas para la Certificación"
|
||||
},
|
||||
"body": {
|
||||
"fr": "Liste de questions à présenter dans les logiciels de Certification.Inspirée de la checklist de la Charte 1.0 et des discussions forum (topic 32412).",
|
||||
"en": "List of questions to be presented in Certification software. Inspired by the checklist of Charter 1.0 and by forum discussions (topic 32412).",
|
||||
"es": "Lista de preguntas que deben presentarse en el software de Certificación. Inspirada en la lista de verificación de la Carta 1.0 y en los debates del foro (tema 32412)."
|
||||
}
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"code": "X2.1",
|
||||
"section": "annexe",
|
||||
"position": 24,
|
||||
"inertia": 3,
|
||||
"versions": [
|
||||
{
|
||||
"label": "1.0.0",
|
||||
"status": "open",
|
||||
"title": {
|
||||
"fr": "Questions piège (réponse attendue : NON)",
|
||||
"en": "Trap questions (expected answer: NO)",
|
||||
"es": "Preguntas trampa (respuesta esperada: NO)"
|
||||
},
|
||||
"body": {
|
||||
"fr": "**Si OUI à l'une de ces questions, la Certification doit être refusée.**\n\n- La personne m'a contacté uniquement pour obtenir ma Certification\n- Je certifie sous la pression ou pour faire plaisir\n- Je n'ai aucun moyen de vérifier l'identité de la personne\n- La personne refuse de me fournir plusieurs moyens de contact\n- Je soupçonne que la personne possède déjà un compte membre\n- Je ne connais aucun de ses autres certifieurs\n- La personne ne comprend pas ce qu'est la monnaie libre\n- La personne n'a pas lu le présent acte d'engagement",
|
||||
"en": "**If YES to any of these questions, the Certification must be refused.**\n\n- The person contacted me only to obtain my Certification\n- I am certifying under pressure or to please someone\n- I have no way of verifying the person's identity\n- The person refuses to give me several means of contact\n- I suspect that the person already has a member account\n- I do not know any of their other certifiers\n- The person does not understand what libre currency is\n- The person has not read this commitment act",
|
||||
"es": "**Si la respuesta es SÍ a alguna de estas preguntas, la Certificación debe rechazarse.**\n\n- La persona me ha contactado únicamente para obtener mi Certificación\n- Certifico bajo presión o por complacer\n- No tengo ningún medio para verificar la identidad de la persona\n- La persona se niega a proporcionarme varios medios de contacto\n- Sospecho que la persona ya posee una cuenta de miembro\n- No conozco a ninguno de sus otros certificadores\n- La persona no entiende qué es la moneda libre\n- La persona no ha leído la presente acta de compromiso"
|
||||
}
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"code": "X2.2",
|
||||
"section": "annexe",
|
||||
"position": 25,
|
||||
"inertia": 3,
|
||||
"versions": [
|
||||
{
|
||||
"label": "1.0.0",
|
||||
"status": "open",
|
||||
"title": {
|
||||
"fr": "Questions identité (réponse attendue : OUI)",
|
||||
"en": "Identity questions (expected answer: YES)",
|
||||
"es": "Preguntas de identidad (respuesta esperada: SÍ)"
|
||||
},
|
||||
"body": {
|
||||
"fr": "**Si NON à l'une de ces questions, la Certification doit être refusée.**\n\n- Je connais cette personne suffisamment pour la recontacter\n- J'ai vérifié personnellement le lien personne / clé publique\n- La personne est une personne physique vivante (pas une entité morale)\n- Je pourrais reconnaître cette personne si je la croisais\n- J'ai au moins 2 moyens de contact différents pour cette personne",
|
||||
"en": "**If NO to any of these questions, the Certification must be refused.**\n\n- I know this person well enough to get back in touch with them\n- I have personally verified the person / public key link\n- The person is a living natural person (not a legal entity)\n- I would recognise this person if I came across them\n- I have at least 2 different means of contact for this person",
|
||||
"es": "**Si la respuesta es NO a alguna de estas preguntas, la Certificación debe rechazarse.**\n\n- Conozco a esta persona suficientemente para volver a contactarla\n- He verificado personalmente el vínculo persona / clave pública\n- La persona es una persona física viva (no una persona jurídica)\n- Reconocería a esta persona si me la cruzara\n- Tengo al menos 2 medios de contacto diferentes para esta persona"
|
||||
}
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"code": "X2.3",
|
||||
"section": "annexe",
|
||||
"position": 26,
|
||||
"inertia": 3,
|
||||
"versions": [
|
||||
{
|
||||
"label": "1.0.0",
|
||||
"status": "open",
|
||||
"title": {
|
||||
"fr": "Questions sécurité (réponse attendue : OUI)",
|
||||
"en": "Security questions (expected answer: YES)",
|
||||
"es": "Preguntas de seguridad (respuesta esperada: SÍ)"
|
||||
},
|
||||
"body": {
|
||||
"fr": "**Si NON, la Certification est déconseillée.**\n\n- La personne a généré son document de révocation\n- La personne maîtrise effectivement son compte (test de transfert effectué)\n- La personne sait où retrouver le présent acte d'engagement dans sa version à jour",
|
||||
"en": "**If NO, the Certification is not recommended.**\n\n- The person has generated their revocation document\n- The person actually controls their account (test transfer carried out)\n- The person knows where to find the current version of this commitment act",
|
||||
"es": "**Si la respuesta es NO, se desaconseja la Certificación.**\n\n- La persona ha generado su documento de revocación\n- La persona controla efectivamente su cuenta (transferencia de prueba realizada)\n- La persona sabe dónde encontrar la versión vigente de la presente acta de compromiso"
|
||||
}
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"code": "F1",
|
||||
"section": "formule",
|
||||
"position": 27,
|
||||
"inertia": 3,
|
||||
"versions": [
|
||||
{
|
||||
"label": "1.0.0",
|
||||
"status": "open",
|
||||
"title": {
|
||||
"fr": "Formule de vote",
|
||||
"en": "Voting formula",
|
||||
"es": "Fórmula de votación"
|
||||
},
|
||||
"body": {
|
||||
"fr": "La formule qui régit l'adoption ou le rejet de chaque modification du présent document. Référence : g1vote (https://g1vote-view-237903.pages.duniter.org/).",
|
||||
"en": "The formula that governs the adoption or rejection of each amendment to this document. Reference: g1vote (https://g1vote-view-237903.pages.duniter.org/).",
|
||||
"es": "La fórmula que rige la adopción o el rechazo de cada modificación del presente documento. Referencia: g1vote (https://g1vote-view-237903.pages.duniter.org/)."
|
||||
}
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"code": "F1.1",
|
||||
"section": "formule",
|
||||
"position": 28,
|
||||
"inertia": 3,
|
||||
"versions": [
|
||||
{
|
||||
"label": "1.0.0",
|
||||
"status": "open",
|
||||
"title": {
|
||||
"fr": "Formule du seuil WoT",
|
||||
"en": "WoT threshold formula",
|
||||
"es": "Fórmula del umbral WoT"
|
||||
},
|
||||
"body": {
|
||||
"fr": "Le seuil est le nombre de voix pour qu'il faut à une version pour entrer en vigueur. Sous le quorum Q, les votants qui manquent comptent comme « non » : le seuil se calcule comme si Q personnes avaient voté. Au-delà, il faut la part de « pour » que demande le cran d'inertie de l'article, parmi tous les votants : cette part est la plus haute au quorum et baisse quand la participation monte, jusqu'à la majorité M quand tout le monde vote.\n\n`T' = max(T, Q)`\n`Seuil = B^W + (M + (1 − M) × (1 − (T'/W)^G)) × T'`\n\nUne version passe quand ses voix pour atteignent le seuil, arrondi à l'entier au-dessus.\n\n- **T** : les votes exprimés sur la version, pour et contre.\n- **Q** : le quorum de l'article ; **T'** : les votants retenus, T, ou Q s'il en manque.\n- **W** : le nombre de membres de la toile de confiance Ğ1.\n- **B** : la base ; à la puissance W, elle est si petite qu'elle ne compte pas.\n- **M** : la majorité demandée quand tout le monde vote.\n- **G** : le gradient : plus il est grand, plus la part demandée monte quand peu de gens votent.\n\nLeurs valeurs sont dans « Paramètres par défaut » et « Niveaux d'inertie ».",
|
||||
"en": "The threshold is the number of votes in favour a version needs to come into force. Below the quorum Q, the voters who are missing count as votes against: the threshold is worked out as if Q people had voted. Beyond it, it takes the share of votes in favour that the article's level of inertia asks for, among all the voters: that share is highest at the quorum and falls as turnout rises, down to the majority M when everyone votes.\n\n`T' = max(T, Q)`\n`Threshold = B^W + (M + (1 − M) × (1 − (T'/W)^G)) × T'`\n\nA version passes when its votes in favour reach the threshold, rounded up to a whole number.\n\n- **T**: the votes cast on the version, for and against.\n- **Q**: the article's quorum; **T'**: the voters counted, T, or Q if some are missing.\n- **W**: the number of members of the Ğ1 web of trust.\n- **B**: the base; raised to the power W, it is so small that it does not count.\n- **M**: the majority asked for when everyone votes.\n- **G**: the gradient: the larger it is, the higher the share asked for when few people vote.\n\nTheir values are in “Default parameters” and “Inertia levels”.",
|
||||
"es": "El umbral es el número de votos a favor que necesita una versión para entrar en vigor. Por debajo del quórum Q, los votantes que faltan cuentan como «no»: el umbral se calcula como si hubieran votado Q personas. Más allá, hace falta la proporción de votos a favor que pide el nivel de inercia del artículo, entre todos los votantes: esa proporción es la más alta en el quórum y baja cuando sube la participación, hasta la mayoría M cuando vota todo el mundo.\n\n`T' = max(T, Q)`\n`Umbral = B^W + (M + (1 − M) × (1 − (T'/W)^G)) × T'`\n\nUna versión pasa cuando sus votos a favor alcanzan el umbral, redondeado al entero superior.\n\n- **T**: los votos emitidos sobre la versión, a favor y en contra.\n- **Q**: el quórum del artículo; **T'**: los votantes contados, T, o Q si faltan.\n- **W**: el número de miembros de la red de confianza Ğ1.\n- **B**: la base; elevada a la potencia W, es tan pequeña que no cuenta.\n- **M**: la mayoría pedida cuando vota todo el mundo.\n- **G**: el gradiente: cuanto mayor es, más alta es la proporción pedida cuando vota poca gente.\n\nSus valores están en «Parámetros por defecto» y «Niveles de inercia»."
|
||||
}
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"code": "F1.2",
|
||||
"section": "formule",
|
||||
"position": 29,
|
||||
"inertia": 3,
|
||||
"versions": [
|
||||
{
|
||||
"label": "1.0.0",
|
||||
"status": "open",
|
||||
"title": {
|
||||
"fr": "Paramètres par défaut",
|
||||
"en": "Default parameters",
|
||||
"es": "Parámetros por defecto"
|
||||
},
|
||||
"body": {
|
||||
"fr": "Les valeurs des lettres de la formule (voir « Formule du seuil WoT ») et la façon dont le vote se déroule.\n\n**Un vote permanent, sans durée**\n\n- Chaque version se vote à tout moment, sans échéance. Chacun peut changer ou retirer son vote : le dernier fait foi.\n- Une proposition entre en vigueur dès qu'elle atteint son seuil. S'il y a déjà un texte en vigueur, il lui faut en plus une marge (voix pour moins voix exigées) plus large que celle de ce texte. Le texte remplacé redevient une proposition, avec ses votes : il peut revenir.\n- Si plusieurs propositions y parviennent, celle qui a la marge la plus large entre en vigueur.\n\n**Les lettres**\n\n- **W** : le nombre de membres de la toile de confiance Ğ1, relu sur la chaîne toutes les 6 heures.\n- **T** : les votes exprimés sur la version, pour et contre.\n- **Q**, le quorum : sous ce nombre de votants, ceux qui manquent comptent comme « non ». Chaque article prend celui de son cran : 12, 12, 50, 50 et 50 dans Monnaie libre, du cran 1 au cran 5. L'auteur de la première version d'un article, un administrateur pour les textes de départ, peut en fixer un autre tant que l'article ne garde la trace d'aucun vote. Dans Monnaie libre, jamais moins de 2 : une seule voix ne fait jamais passer un texte.\n- **B = 0,1** : dans la toile de confiance, `B^W` est si petit qu'il ne compte pas.\n- **M** et **G** : donnés par le cran d'inertie de l'article (voir « Niveaux d'inertie »). G n'est pas fixé à la main : il se déduit de la participation habituelle du groupe.\n\n**Par défaut**\n\n- 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.\n- Au départ, aucun article n'est en vigueur. Raison d'être, Unicité du compte et Réciprocité sont des Propositions seed, mises en avant pour lancer le vote, au cran 2 (basse). Le titre du document est au cran 4 (haute), l'ordre des articles de chaque section au cran 2, tous les autres articles au cran 3.",
|
||||
"en": "The values of the letters of the formula (see “WoT threshold formula”) and how the vote runs.\n\n**A permanent vote, with no duration**\n\n- Every version can be voted on at any time, with no deadline. Anyone can change or withdraw their vote: the last one is the one that counts.\n- A proposal comes into force as soon as it reaches its threshold. If a text is already in force, it also needs a wider margin (votes in favour minus votes required) than that text has. The replaced text becomes a proposal again, with its votes: it can come back.\n- If several proposals get there, the one with the widest margin comes into force.\n\n**The letters**\n\n- **W**: the number of members of the Ğ1 web of trust, read again from the chain every 6 hours.\n- **T**: the votes cast on the version, for and against.\n- **Q**, the quorum: below this number of voters, those who are missing count as votes against. Each article takes the one of its level: 12, 12, 50, 50 and 50 in Monnaie libre, from level 1 to level 5. The author of the first version of an article, an administrator for the starting texts, can set another one as long as the article keeps no trace of any vote. In Monnaie libre, never less than 2: a single vote never passes a text.\n- **B = 0.1**: in the web of trust, `B^W` is so small that it does not count.\n- **M** and **G**: given by the article's level of inertia (see “Inertia levels”). G is not set by hand: it follows from the group's usual turnout.\n\n**By default**\n\n- The default level is 3 (medium): M = 55%, a quorum of 50 in Monnaie libre, and, beyond the quorum, the group asks for 75% of votes in favour at its usual turnout. Every new article starts at this level.\n- At the start, no article is in force. Purpose, Uniqueness of the account and Reciprocity are seed proposals, put forward to start the vote, at level 2 (low). The title of the document is at level 4 (high), the order of the articles of each section at level 2, all other articles at level 3.",
|
||||
"es": "Los valores de las letras de la fórmula (ver «Fórmula del umbral WoT») y cómo se desarrolla la votación.\n\n**Una votación permanente, sin duración**\n\n- Cada versión se vota en cualquier momento, sin plazo. Cada cual puede cambiar o retirar su voto: el último es el que cuenta.\n- Una propuesta entra en vigor en cuanto alcanza su umbral. Si ya hay un texto en vigor, necesita además un margen (votos a favor menos votos exigidos) más amplio que el de ese texto. El texto sustituido vuelve a ser una propuesta, con sus votos: puede volver.\n- Si varias propuestas lo consiguen, entra en vigor la que tiene el margen más amplio.\n\n**Las letras**\n\n- **W**: el número de miembros de la red de confianza Ğ1, leído de nuevo en la cadena cada 6 horas.\n- **T**: los votos emitidos sobre la versión, a favor y en contra.\n- **Q**, el quórum: por debajo de este número de votantes, los que faltan cuentan como «no». Cada artículo toma el de su nivel: 12, 12, 50, 50 y 50 en Monnaie libre, del nivel 1 al nivel 5. El autor de la primera versión de un artículo, un administrador para los textos de partida, puede fijar otro mientras el artículo no guarde rastro de ningún voto. En Monnaie libre, nunca menos de 2: un solo voto nunca hace pasar un texto.\n- **B = 0,1**: en la red de confianza, `B^W` es tan pequeño que no cuenta.\n- **M** y **G**: los da el nivel de inercia del artículo (ver «Niveles de inercia»). G no se fija a mano: se deduce de la participación habitual del grupo.\n\n**Por defecto**\n\n- El nivel por defecto es el 3 (media): M = 55 %, un quórum de 50 en Monnaie libre, y, más allá del quórum, el grupo pide un 75 % de votos a favor con su participación habitual. Todo artículo nuevo empieza en este nivel.\n- Al inicio, ningún artículo está en vigor. Razón de ser, Unicidad de la cuenta y Reciprocidad son Propuestas seed, puestas en primer plano para lanzar la votación, en el nivel 2 (baja). El título del documento está en el nivel 4 (alta), el orden de los artículos de cada sección en el nivel 2, todos los demás artículos en el nivel 3."
|
||||
}
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"code": "F1.3",
|
||||
"section": "formule",
|
||||
"position": 30,
|
||||
"inertia": 3,
|
||||
"versions": [
|
||||
{
|
||||
"label": "1.0.0",
|
||||
"status": "open",
|
||||
"title": {
|
||||
"fr": "Processus de dépôt officiel",
|
||||
"en": "Official filing process",
|
||||
"es": "Proceso de depósito oficial"
|
||||
},
|
||||
"body": {
|
||||
"fr": "Lorsqu'une proposition alternative atteint le seuil d'adoption :\n\n1. Le texte de remplacement est intégré au document officiel\n2. Le hash IPFS du document mis à jour est calculé\n3. Le hash est ancré on-chain via `system.remark`\n4. Le dépôt git officiel est synchronisé\n5. Les applications (Cesium, Gecko) mettent à jour automatiquement",
|
||||
"en": "When an alternative proposal reaches the adoption threshold:\n\n1. The replacement text is incorporated into the official document\n2. The IPFS hash of the updated document is computed\n3. The hash is anchored on-chain via `system.remark`\n4. The official git repository is synchronised\n5. The applications (Cesium, Gecko) update automatically",
|
||||
"es": "Cuando una propuesta alternativa alcanza el umbral de adopción:\n\n1. El texto de sustitución se integra en el documento oficial\n2. Se calcula el hash IPFS del documento actualizado\n3. El hash se ancla on-chain mediante `system.remark`\n4. Se sincroniza el repositorio git oficial\n5. Las aplicaciones (Cesium, Gecko) se actualizan automáticamente"
|
||||
}
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"code": "N1",
|
||||
"section": "inertie",
|
||||
"position": 31,
|
||||
"inertia": 3,
|
||||
"versions": [
|
||||
{
|
||||
"label": "1.0.0",
|
||||
"status": "open",
|
||||
"title": {
|
||||
"fr": "Réglage de l'inertie",
|
||||
"en": "Inertia setting",
|
||||
"es": "Ajuste de la inercia"
|
||||
},
|
||||
"body": {
|
||||
"fr": "Chaque article porte son inertie : un cran de 1 à 5 (voir « Niveaux d'inertie »). Plus le cran est haut, plus il faut de voix pour changer le texte quand peu de gens votent. Le titre du document et l'ordre des articles de chaque section ont aussi la leur, qui se change de la même façon.\n\nTout membre du groupe peut proposer un autre cran. Cette proposition se vote au cran actuel de l'article, comme son texte : l'inertie de l'inertie hérite du cran de l'article. Ainsi, un article au cran 4 ne descend qu'avec les voix que demande le cran 4.",
|
||||
"en": "Every article carries its inertia: a level from 1 to 5 (see “Inertia levels”). The higher the level, the more votes it takes to change the text when few people vote. The title of the document and the order of the articles of each section have theirs too, changed in the same way.\n\nAny member of the group can propose another level. That proposal is voted at the article's current level, like its text: the inertia of the inertia inherits the article's level. So an article at level 4 only comes down with the votes that level 4 asks for.",
|
||||
"es": "Cada artículo lleva su inercia: un nivel del 1 al 5 (ver «Niveles de inercia»). Cuanto más alto es el nivel, más votos hacen falta para cambiar el texto cuando vota poca gente. El título del documento y el orden de los artículos de cada sección también tienen la suya, que se cambia del mismo modo.\n\nCualquier miembro del grupo puede proponer otro nivel. Esa propuesta se vota con el nivel actual del artículo, como su texto: la inercia de la inercia hereda el nivel del artículo. Así, un artículo en el nivel 4 solo baja con los votos que pide el nivel 4."
|
||||
}
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"code": "N1.1",
|
||||
"section": "inertie",
|
||||
"position": 32,
|
||||
"inertia": 3,
|
||||
"versions": [
|
||||
{
|
||||
"label": "1.0.0",
|
||||
"status": "open",
|
||||
"title": {
|
||||
"fr": "Niveaux d'inertie",
|
||||
"en": "Inertia levels",
|
||||
"es": "Niveles de inercia"
|
||||
},
|
||||
"body": {
|
||||
"fr": "À chaque cran, le groupe demande une part de « pour » parmi les votants (voir « Formule du seuil WoT »). 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é. 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.\n\n`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.\n\n**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.\n\n- **Cran 1 · très basse** : quorum 12 ; 60 % à la participation habituelle, 50 % quand tout le monde vote. Aucun article au départ.\n- **Cran 2 · basse** : quorum 12 ; 67 % à la participation habituelle, 50 % quand tout le monde vote. Raison d'être, Unicité du compte, Réciprocité, et l'ordre des articles de chaque section.\n- **Cran 3 · moyenne** : quorum 50 ; 75 % à la participation habituelle, 55 % quand tout le monde vote. Le cran par défaut : tous les autres articles, et chaque nouvel article.\n- **Cran 4 · haute** : quorum 50 ; 83 % à la participation habituelle, 60 % quand tout le monde vote. Le titre du document.\n- **Cran 5 · très haute** : quorum 50 ; 90 % à la participation habituelle, 66 % quand tout le monde vote. Aucun article au départ.\n\n**La participation habituelle** est la médiane du nombre de votants des propositions du groupe votées depuis un an. 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. Elle ne dépend pas du quorum d'un article. Elle est ramenée entre 1 % et 50 % de l'électorat, et se mesure une fois par jour.\n\n**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.",
|
||||
"en": "At each level, the group asks for a share of votes in favour among the voters (see “WoT threshold formula”). Each level also has its quorum: below the article's quorum, the voters who are missing count as votes against, and it takes as many votes in favour as if that quorum had voted. The share asked depends on the level and on the number of voters, compared with the group's usual turnout: beyond that quorum, the more voters, the lower it gets, down to the majority M when everyone votes.\n\n`Share asked = M + (1 − M) × (1 − (T'/W)^G)`, where T' is the number of voters, or the article's quorum if some are missing.\n\n**The five levels**: the quorum in Monnaie libre, the share asked beyond the article's quorum, at the usual turnout, then when everyone votes, and what is at that level at the start.\n\n- **Level 1 · very low**: quorum 12; 60% at the usual turnout, 50% when everyone votes. No article at the start.\n- **Level 2 · low**: quorum 12; 67% at the usual turnout, 50% when everyone votes. Purpose, Uniqueness of the account, Reciprocity, and the order of the articles of each section.\n- **Level 3 · medium**: quorum 50; 75% at the usual turnout, 55% when everyone votes. The default level: all other articles, and every new article.\n- **Level 4 · high**: quorum 50; 83% at the usual turnout, 60% when everyone votes. The title of the document.\n- **Level 5 · very high**: quorum 50; 90% at the usual turnout, 66% when everyone votes. No article at the start.\n\n**The usual turnout** is the median number of voters on the group's proposals voted on over the past year. While it rests on fewer than 5 proposals, it is twice the document's reference quorum: 100 voters in Monnaie libre. It does not depend on the quorum of an article. It is kept between 1% and 50% of the electorate, and is measured once a day.\n\n**G** is computed so that the share asked, beyond the article's quorum, is exactly that of the level at the usual turnout: `G = ln(1 − (s − M)/(1 − M)) / ln(p̃)`, where s is that share of the level (60, 67, 75, 83 or 90%) and p̃ the usual turnout divided by W.",
|
||||
"es": "En cada nivel, el grupo pide una proporción de votos a favor entre los votantes (ver «Fórmula del umbral WoT»). Cada nivel tiene además su quórum: por debajo del quórum del artículo, los votantes que faltan cuentan como «no», y hacen falta tantos votos a favor como si hubiera votado ese quórum. La proporción exigida depende del nivel y del número de votantes, comparado con la participación habitual del grupo: más allá de ese quórum, cuantos más votantes, más baja, hasta la mayoría M cuando vota todo el mundo.\n\n`Proporción exigida = M + (1 − M) × (1 − (T'/W)^G)`, donde T' es el número de votantes, o el quórum del artículo si faltan.\n\n**Los cinco niveles**: el quórum en Monnaie libre, la proporción exigida más allá del quórum del artículo, con la participación habitual, luego cuando vota todo el mundo, y lo que está en ese nivel al inicio.\n\n- **Nivel 1 · muy baja**: quórum 12; 60 % con la participación habitual, 50 % cuando vota todo el mundo. Ningún artículo al inicio.\n- **Nivel 2 · baja**: quórum 12; 67 % con la participación habitual, 50 % cuando vota todo el mundo. Razón de ser, Unicidad de la cuenta, Reciprocidad, y el orden de los artículos de cada sección.\n- **Nivel 3 · media**: quórum 50; 75 % con la participación habitual, 55 % cuando vota todo el mundo. El nivel por defecto: todos los demás artículos, y todo artículo nuevo.\n- **Nivel 4 · alta**: quórum 50; 83 % con la participación habitual, 60 % cuando vota todo el mundo. El título del documento.\n- **Nivel 5 · muy alta**: quórum 50; 90 % con la participación habitual, 66 % cuando vota todo el mundo. Ningún artículo al inicio.\n\n**La participación habitual** es la mediana del número de votantes de las propuestas del grupo votadas en el último año. Mientras se base en menos de 5 propuestas, vale dos veces el quórum de referencia del documento: 100 votantes en Monnaie libre. No depende del quórum de un artículo. Se acota entre el 1 % y el 50 % del electorado, y se mide una vez al día.\n\n**G** se calcula para que la proporción exigida, más allá del quórum del artículo, sea exactamente la del nivel con la participación habitual: `G = ln(1 − (s − M)/(1 − M)) / ln(p̃)`, donde s es esa proporción del nivel (60, 67, 75, 83 o 90 %) y p̃ la participación habitual dividida por W."
|
||||
}
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"code": "O1",
|
||||
"section": "ordonnancement",
|
||||
"position": 33,
|
||||
"inertia": 3,
|
||||
"versions": [
|
||||
{
|
||||
"label": "1.0.0",
|
||||
"status": "open",
|
||||
"title": {
|
||||
"fr": "Ordonnancement du document",
|
||||
"en": "Ordering of the document",
|
||||
"es": "Ordenación del documento"
|
||||
},
|
||||
"body": {
|
||||
"fr": "L'ordre des articles de chaque section est lui-même soumis au vote. En fin de section, le module « Ordre des articles » se vote comme un article, au cran 2 (basse) au départ : chaque proposition est la liste des articles de la section, dans un autre ordre. Quand un ordre entre en vigueur, seuls les articles de cette section changent de place ; aucun texte ne change.\n\nUn nouvel article se place à la fin de sa section ; sa place se vote ensuite par l'ordre de la section. Un ordre proposé garde ses votes quand la section change : un article arrivé depuis s'y ajoute à la fin, un article sorti en disparaît. Une section d'un seul article n'a pas d'ordre à voter.\n\nLe titre du document est lui aussi un module, voté au cran 4 (haute) au départ. Le titre de chaque article, lui, se vote avec son texte.",
|
||||
"en": "The order of the articles of each section is itself put to the vote. At the end of each section, the “Order of the articles” module is voted on like an article, at level 2 (low) at the start: each proposal is the list of the section's articles, in another order. When an order comes into force, only the articles of that section change place; no text changes.\n\nA new article goes at the end of its section; its place is then voted on through the order of the section. A proposed order keeps its votes when the section changes: an article that has arrived since is added at the end, an article that has left drops out of it. A section with a single article has no order to vote on.\n\nThe title of the document is also a module, voted on at level 4 (high) at the start. The title of each article is voted on with its text.",
|
||||
"es": "El orden de los artículos de cada sección está a su vez sometido a votación. Al final de cada sección, el módulo «Orden de los artículos» se vota como un artículo, con el nivel 2 (baja) al inicio: cada propuesta es la lista de los artículos de la sección, en otro orden. Cuando un orden entra en vigor, solo los artículos de esa sección cambian de lugar; ningún texto cambia.\n\nUn artículo nuevo se coloca al final de su sección; su lugar se vota después mediante el orden de la sección. Un orden propuesto conserva sus votos cuando la sección cambia: un artículo llegado después se añade al final, un artículo que ha salido desaparece de él. Una sección de un solo artículo no tiene orden que votar.\n\nEl título del documento es también un módulo, votado con el nivel 4 (alta) al inicio. El título de cada artículo, en cambio, se vota con su texto."
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
Vendored
+791
@@ -0,0 +1,791 @@
|
||||
{
|
||||
"_source": "maquette/frontend/app/data/seeds/duniter-g1.bundle.json (doc engagement-certification, 33 clauses + 2 variantes). En vigueur au départ : I1, E1, E2 (seed a minima, aucun vote). Tout le reste : propositions ouvertes à 0 vote (Yvv, 2026-10-21). Inertie 3 / 5 partout, 4 pour E1 et E2 (revue du 2026-10-30). I1 « Raison d'être » remplace le Préambule (texte d'Yvv, revue du 2026-10-30). Modules : le titre du document à 4, l'ordre des articles de chaque section à 2 (revue du 2026-10-30, ADR 0009). F1.2, N1, N1.1, O1 réécrits selon les règles appliquées (Yvv, 2026-11-02).",
|
||||
"slug": "acte-engagement-certification",
|
||||
"title": {
|
||||
"fr": "Acte d'engagement Certification",
|
||||
"en": "Certification Commitment Act",
|
||||
"es": "Acta de compromiso de Certificación"
|
||||
},
|
||||
"summary": {
|
||||
"fr": "Ce que chaque membre de la toile de confiance Ğ1 s'engage à respecter quand il certifie. Texte modulaire sous vote permanent : chaque article entre en vigueur dès qu'il atteint son seuil.",
|
||||
"en": "What every member of the Ğ1 web of trust commits to uphold when certifying. A modular text under permanent vote: each article comes into force as soon as it reaches its threshold.",
|
||||
"es": "Lo que todo miembro de la red de confianza Ğ1 se compromete a respetar cuando certifica. Texto modular en votación permanente: cada artículo entra en vigor en cuanto alcanza su umbral."
|
||||
},
|
||||
"sourceLang": "fr",
|
||||
"instances": [
|
||||
{
|
||||
"group": "monnaie-libre",
|
||||
"voteFloor": 50,
|
||||
"publishPath": "monnaie-libre/acte-engagement-certification"
|
||||
},
|
||||
{
|
||||
"group": "essai",
|
||||
"voteFloor": 1,
|
||||
"publishPath": null
|
||||
}
|
||||
],
|
||||
"modules": {
|
||||
"title": {
|
||||
"inertia": 4
|
||||
},
|
||||
"order": {
|
||||
"inertia": 2
|
||||
}
|
||||
},
|
||||
"units": [
|
||||
{
|
||||
"code": "I1",
|
||||
"section": "introduction",
|
||||
"position": 1,
|
||||
"inertia": 3,
|
||||
"versions": [
|
||||
{
|
||||
"label": "1.0.0",
|
||||
"status": "in_force",
|
||||
"title": {
|
||||
"fr": "Raison d'être",
|
||||
"en": "Purpose",
|
||||
"es": "Razón de ser"
|
||||
},
|
||||
"body": {
|
||||
"fr": "*« 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. »*\n\nC'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.\n\nCet acte d'engagement est par ailleurs le seul acte de nature contractuelle qui fonde l'usage de la première monnaie libre.",
|
||||
"en": "*“Behind a key there is only one living human being; behind a human being, there is only one certifying key.”*\n\nThis is the one and only purpose of certification, which gives our fiduciary web its value. We can rely on the uniqueness and integrity of every action signed with this key. That is what is at stake in certification.\n\nThis commitment act is, moreover, the only act of a contractual nature on which the use of the first libre currency is founded.",
|
||||
"es": "*«Detrás de una clave no hay más que un solo ser humano vivo; detrás de un ser humano, no hay más que una sola clave certificadora.»*\n\nEste es el propósito único y exclusivo de la certificación, que da su valor a nuestra red fiduciaria. Podemos fiarnos de la unicidad y la integridad de toda acción firmada con esta clave. Eso es lo que está en juego en la certificación.\n\nEsta acta de compromiso es, además, el único acto de naturaleza contractual en el que se funda el uso de la primera moneda libre."
|
||||
}
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"code": "I2",
|
||||
"section": "introduction",
|
||||
"position": 2,
|
||||
"inertia": 3,
|
||||
"versions": [
|
||||
{
|
||||
"label": "1.0.0",
|
||||
"status": "open",
|
||||
"title": {
|
||||
"fr": "Les deux garanties réciproques",
|
||||
"en": "The two reciprocal guarantees",
|
||||
"es": "Las dos garantías recíprocas"
|
||||
},
|
||||
"body": {
|
||||
"fr": "La Certification repose sur deux garanties réciproques :\n\n**1.** Derrière une clé publique créatrice de monnaie se trouve un **être humain vivant**.\n\n**2.** Derrière cet être humain se trouve **une seule et unique** clé publique créatrice de monnaie.\n\nLa Certification est un acte technique et fiduciaire, pas un acte d'adhésion morale ou de sympathie.",
|
||||
"en": "Certification rests on two reciprocal guarantees:\n\n**1.** Behind a money-creating public key there is a **living human being**.\n\n**2.** Behind this human being there is **one single and unique** money-creating public key.\n\nCertification is a technical and fiduciary act, not an act of moral endorsement or of sympathy.",
|
||||
"es": "La Certificación se basa en dos garantías recíprocas:\n\n**1.** Detrás de una clave pública creadora de moneda hay un **ser humano vivo**.\n\n**2.** Detrás de este ser humano hay **una sola y única** clave pública creadora de moneda.\n\nLa Certificación es un acto técnico y fiduciario, no un acto de adhesión moral ni de simpatía."
|
||||
}
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"code": "T1",
|
||||
"section": "fondamental",
|
||||
"position": 3,
|
||||
"inertia": 3,
|
||||
"versions": [
|
||||
{
|
||||
"label": "1.0.0",
|
||||
"status": "open",
|
||||
"title": {
|
||||
"fr": "Engagements fondamentaux",
|
||||
"en": "Fundamental commitments",
|
||||
"es": "Compromisos fundamentales"
|
||||
},
|
||||
"body": {
|
||||
"fr": "Les engagements fondamentaux ci-dessous constituent le socle irréductible de l'acte d'engagement. Ils sont pris sur l'honneur par tout membre de la toile de confiance.",
|
||||
"en": "The fundamental commitments below form the irreducible foundation of the commitment act. They are made on their honour by every member of the web of trust.",
|
||||
"es": "Los compromisos fundamentales que siguen constituyen la base irreductible del acta de compromiso. Todo miembro de la red de confianza los asume por su honor."
|
||||
}
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"code": "E1",
|
||||
"section": "fondamental",
|
||||
"position": 4,
|
||||
"inertia": 4,
|
||||
"versions": [
|
||||
{
|
||||
"label": "1.0.0",
|
||||
"status": "in_force",
|
||||
"title": {
|
||||
"fr": "Unicité du compte",
|
||||
"en": "Uniqueness of the account",
|
||||
"es": "Unicidad de la cuenta"
|
||||
},
|
||||
"body": {
|
||||
"fr": "Je m'engage sur l'honneur à n'avoir et n'avoir jamais qu'un seul et unique compte cocréateur de monnaie G1.",
|
||||
"en": "I commit on my honour to have, now and ever, no more than one single and unique account co-creating G1 money.",
|
||||
"es": "Me comprometo por mi honor a no tener, ahora ni nunca, más que una sola y única cuenta cocreadora de moneda G1."
|
||||
}
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"code": "E2",
|
||||
"section": "fondamental",
|
||||
"position": 5,
|
||||
"inertia": 4,
|
||||
"versions": [
|
||||
{
|
||||
"label": "1.0.0",
|
||||
"status": "in_force",
|
||||
"title": {
|
||||
"fr": "Réciprocité",
|
||||
"en": "Reciprocity",
|
||||
"es": "Reciprocidad"
|
||||
},
|
||||
"body": {
|
||||
"fr": "Je m'engage sur l'honneur à ne certifier que des personnes physiques qui respectent scrupuleusement ces deux présents engagements fondamentaux.",
|
||||
"en": "I commit on my honour to certify only natural persons who scrupulously respect these two fundamental commitments.",
|
||||
"es": "Me comprometo por mi honor a certificar únicamente a personas físicas que respeten escrupulosamente estos dos compromisos fundamentales."
|
||||
}
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"code": "T2",
|
||||
"section": "technique",
|
||||
"position": 6,
|
||||
"inertia": 3,
|
||||
"versions": [
|
||||
{
|
||||
"label": "1.0.0",
|
||||
"status": "open",
|
||||
"title": {
|
||||
"fr": "Engagements techniques",
|
||||
"en": "Technical commitments",
|
||||
"es": "Compromisos técnicos"
|
||||
},
|
||||
"body": {
|
||||
"fr": "Les engagements techniques définissent les obligations pratiques et vérifiables du certificateur pour garantir la qualité de la toile de confiance.",
|
||||
"en": "The technical commitments define the practical and verifiable obligations of the certifier to guarantee the quality of the web of trust.",
|
||||
"es": "Los compromisos técnicos definen las obligaciones prácticas y verificables del certificador para garantizar la calidad de la red de confianza."
|
||||
}
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"code": "E3",
|
||||
"section": "technique",
|
||||
"position": 7,
|
||||
"inertia": 3,
|
||||
"versions": [
|
||||
{
|
||||
"label": "1.0.0",
|
||||
"status": "open",
|
||||
"title": {
|
||||
"fr": "Connaissance suffisante",
|
||||
"en": "Sufficient knowledge",
|
||||
"es": "Conocimiento suficiente"
|
||||
},
|
||||
"body": {
|
||||
"fr": "Je me suis assuré de connaître suffisamment la personne qui gère cette clé publique. Connaître suffisamment ne signifie pas « avoir vu » ; c'est assurer à la communauté G1 que je pourrai la contacter facilement et être en mesure de repérer un double-compte ou tout autre problème.",
|
||||
"en": "I have made sure that I know the person who manages this public key well enough. Knowing well enough does not mean “having seen”; it means assuring the G1 community that I will be able to contact them easily and to detect a duplicate account or any other problem.",
|
||||
"es": "Me he asegurado de conocer suficientemente a la persona que gestiona esta clave pública. Conocer suficientemente no significa «haber visto»; significa asegurar a la comunidad G1 que podré contactarla fácilmente y estar en condiciones de detectar una cuenta duplicada o cualquier otro problema."
|
||||
}
|
||||
},
|
||||
{
|
||||
"label": "0.4.0-draft",
|
||||
"status": "open",
|
||||
"title": {
|
||||
"fr": "Connaissance suffisante",
|
||||
"en": "Sufficient knowledge",
|
||||
"es": "Conocimiento suficiente"
|
||||
},
|
||||
"body": {
|
||||
"fr": "Je me suis assuré de connaître suffisamment la personne qui gère cette clé publique : je sais la recontacter par au moins deux canaux indépendants, je suis en mesure de repérer un double-compte, et je m'engage à signaler tout doute aux experts de la communauté. « Avoir vu » quelqu'un ne suffit pas.",
|
||||
"en": "I have made sure that I know the person who manages this public key well enough: I can get back in touch with them through at least two independent channels, I am able to detect a duplicate account, and I commit to reporting any doubt to the community experts. “Having seen” someone is not enough.",
|
||||
"es": "Me he asegurado de conocer suficientemente a la persona que gestiona esta clave pública: puedo volver a contactarla por al menos dos canales independientes, estoy en condiciones de detectar una cuenta duplicada y me comprometo a comunicar cualquier duda a los expertos de la comunidad. «Haber visto» a alguien no basta."
|
||||
}
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"code": "E4",
|
||||
"section": "technique",
|
||||
"position": 8,
|
||||
"inertia": 3,
|
||||
"versions": [
|
||||
{
|
||||
"label": "1.0.0",
|
||||
"status": "open",
|
||||
"title": {
|
||||
"fr": "Vérification personnelle de la clé",
|
||||
"en": "Personal verification of the key",
|
||||
"es": "Verificación personal de la clave"
|
||||
},
|
||||
"body": {
|
||||
"fr": "J'ai personnellement vérifié que c'est bien cette clé publique que je m'apprête à certifier, en la comparant avec la personne concernée et non par un intermédiaire.",
|
||||
"en": "I have personally verified that this is indeed the public key I am about to certify, by comparing it with the person concerned and not through an intermediary.",
|
||||
"es": "He verificado personalmente que esta es efectivamente la clave pública que me dispongo a certificar, comparándola con la persona interesada y no a través de un intermediario."
|
||||
}
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"code": "E5",
|
||||
"section": "technique",
|
||||
"position": 9,
|
||||
"inertia": 3,
|
||||
"versions": [
|
||||
{
|
||||
"label": "1.0.0",
|
||||
"status": "open",
|
||||
"title": {
|
||||
"fr": "Joignabilité réciproque",
|
||||
"en": "Mutual reachability",
|
||||
"es": "Contacto recíproco"
|
||||
},
|
||||
"body": {
|
||||
"fr": "Je suis joignable rapidement et facilement par mes certifieurs, et je peux joindre rapidement et facilement les personnes que je certifie, par plusieurs moyens de communication différents et indépendants (physique, téléphone, email, messagerie, etc.).",
|
||||
"en": "I can be reached quickly and easily by my certifiers, and I can quickly and easily reach the people I certify, through several different and independent means of communication (in person, phone, email, messaging, etc.).",
|
||||
"es": "Soy localizable de forma rápida y sencilla por mis certificadores, y puedo localizar de forma rápida y sencilla a las personas que certifico, por varios medios de comunicación diferentes e independientes (en persona, teléfono, correo electrónico, mensajería, etc.)."
|
||||
}
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"code": "E6",
|
||||
"section": "technique",
|
||||
"position": 10,
|
||||
"inertia": 3,
|
||||
"versions": [
|
||||
{
|
||||
"label": "1.0.0",
|
||||
"status": "open",
|
||||
"title": {
|
||||
"fr": "Document de révocation",
|
||||
"en": "Revocation document",
|
||||
"es": "Documento de revocación"
|
||||
},
|
||||
"body": {
|
||||
"fr": "J'ai vérifié avec la personne certifiée qu'elle a bien généré son document de révocation de compte et qu'elle le conserve en lieu sûr.",
|
||||
"en": "I have checked with the certified person that they have indeed generated their account revocation document and that they keep it in a safe place.",
|
||||
"es": "He verificado con la persona certificada que ha generado efectivamente su documento de revocación de cuenta y que lo conserva en un lugar seguro."
|
||||
}
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"code": "E7",
|
||||
"section": "technique",
|
||||
"position": 11,
|
||||
"inertia": 3,
|
||||
"versions": [
|
||||
{
|
||||
"label": "1.0.0",
|
||||
"status": "open",
|
||||
"title": {
|
||||
"fr": "Rencontre physique ou vérification multi-canaux",
|
||||
"en": "In-person meeting or multi-channel verification",
|
||||
"es": "Encuentro en persona o verificación multicanal"
|
||||
},
|
||||
"body": {
|
||||
"fr": "J'ai rencontré la personne physiquement (préférable), **OU** j'ai vérifié à distance le lien personne / clé publique par plusieurs moyens de communication différents et indépendants : courrier + réseau social + forum + email + visio + téléphone.",
|
||||
"en": "I have met the person face to face (preferable), **OR** I have remotely verified the person / public key link through several different and independent means of communication: postal mail + social network + forum + email + video call + phone.",
|
||||
"es": "Me he encontrado físicamente con la persona (preferible), **O** he verificado a distancia el vínculo persona / clave pública por varios medios de comunicación diferentes e independientes: correo postal + red social + foro + correo electrónico + videollamada + teléfono."
|
||||
}
|
||||
},
|
||||
{
|
||||
"label": "0.4.0-draft",
|
||||
"status": "open",
|
||||
"title": {
|
||||
"fr": "Rencontre physique ou vérification multi-canaux",
|
||||
"en": "In-person meeting or multi-channel verification",
|
||||
"es": "Encuentro en persona o verificación multicanal"
|
||||
},
|
||||
"body": {
|
||||
"fr": "J'ai rencontré la personne physiquement (préférable), **OU** j'ai vérifié à distance le lien personne / clé publique par au moins trois moyens de communication différents et indépendants, dont un échange en visio en direct.",
|
||||
"en": "I have met the person face to face (preferable), **OR** I have remotely verified the person / public key link through at least three different and independent means of communication, including a live video call.",
|
||||
"es": "Me he encontrado físicamente con la persona (preferible), **O** he verificado a distancia el vínculo persona / clave pública por al menos tres medios de comunicación diferentes e independientes, entre ellos una videollamada en directo."
|
||||
}
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"code": "T3",
|
||||
"section": "technique",
|
||||
"position": 12,
|
||||
"inertia": 3,
|
||||
"versions": [
|
||||
{
|
||||
"label": "1.0.0",
|
||||
"status": "open",
|
||||
"title": {
|
||||
"fr": "Conseils et bonnes pratiques",
|
||||
"en": "Advice and good practices",
|
||||
"es": "Consejos y buenas prácticas"
|
||||
},
|
||||
"body": {
|
||||
"fr": "Les pratiques suivantes sont fortement recommandées pour garantir la qualité et la sécurité de la toile de confiance.",
|
||||
"en": "The following practices are strongly recommended to guarantee the quality and security of the web of trust.",
|
||||
"es": "Las siguientes prácticas se recomiendan encarecidamente para garantizar la calidad y la seguridad de la red de confianza."
|
||||
}
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"code": "E8",
|
||||
"section": "technique",
|
||||
"position": 13,
|
||||
"inertia": 3,
|
||||
"versions": [
|
||||
{
|
||||
"label": "1.0.0",
|
||||
"status": "open",
|
||||
"title": {
|
||||
"fr": "Ne jamais certifier seul",
|
||||
"en": "Never certify alone",
|
||||
"es": "No certificar nunca en solitario"
|
||||
},
|
||||
"body": {
|
||||
"fr": "Ne certifiez jamais seul, mais accompagné d'au moins un autre membre de la toile de confiance G1, pour garantir un double contrôle.",
|
||||
"en": "Never certify alone, but accompanied by at least one other member of the G1 web of trust, to guarantee a double check.",
|
||||
"es": "No certifique nunca en solitario, sino junto con al menos otro miembro de la red de confianza G1, para garantizar un doble control."
|
||||
}
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"code": "E9",
|
||||
"section": "technique",
|
||||
"position": 14,
|
||||
"inertia": 3,
|
||||
"versions": [
|
||||
{
|
||||
"label": "1.0.0",
|
||||
"status": "open",
|
||||
"title": {
|
||||
"fr": "Vérification des certifications existantes",
|
||||
"en": "Verification of existing certifications",
|
||||
"es": "Verificación de las certificaciones existentes"
|
||||
},
|
||||
"body": {
|
||||
"fr": "Avant toute Certification, vérifiez si le compte a déjà reçu des certifications et de qui elles proviennent. Contactez les certifieurs existants en cas de doute. Si un certifieurs existant ne connaît pas la personne, alertez immédiatement les experts de la communauté.",
|
||||
"en": "Before any Certification, check whether the account has already received certifications and who they come from. Contact the existing certifiers if in doubt. If an existing certifier does not know the person, alert the community experts immediately.",
|
||||
"es": "Antes de cualquier Certificación, verifique si la cuenta ya ha recibido certificaciones y de quién proceden. En caso de duda, contacte con los certificadores existentes. Si un certificador existente no conoce a la persona, alerte de inmediato a los expertos de la comunidad."
|
||||
}
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"code": "E10",
|
||||
"section": "technique",
|
||||
"position": 15,
|
||||
"inertia": 3,
|
||||
"versions": [
|
||||
{
|
||||
"label": "1.0.0",
|
||||
"status": "open",
|
||||
"title": {
|
||||
"fr": "Vérification de maîtrise du compte",
|
||||
"en": "Verification of account control",
|
||||
"es": "Verificación del control de la cuenta"
|
||||
},
|
||||
"body": {
|
||||
"fr": "Vérifiez la maîtrise du compte par un transfert-test : envoyez quelques G1 et demandez le renvoi, afin de vous assurer que la personne contrôle bien sa clé privée.",
|
||||
"en": "Verify control of the account with a test transfer: send a few G1 and ask for them to be sent back, to make sure that the person really controls their private key.",
|
||||
"es": "Verifique el control de la cuenta mediante una transferencia de prueba: envíe unos pocos G1 y pida que se los devuelvan, para asegurarse de que la persona controla realmente su clave privada."
|
||||
}
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"code": "E11",
|
||||
"section": "technique",
|
||||
"position": 16,
|
||||
"inertia": 3,
|
||||
"versions": [
|
||||
{
|
||||
"label": "1.0.0",
|
||||
"status": "open",
|
||||
"title": {
|
||||
"fr": "Transmission et compréhension du document",
|
||||
"en": "Transmission and understanding of the document",
|
||||
"es": "Transmisión y comprensión del documento"
|
||||
},
|
||||
"body": {
|
||||
"fr": "Vérifiez que vos contacts ont bien étudié et compris le présent acte d'engagement dans sa version à jour avant de procéder à la Certification.",
|
||||
"en": "Check that your contacts have indeed studied and understood the current version of this commitment act before proceeding with the Certification.",
|
||||
"es": "Verifique que sus contactos han estudiado y comprendido efectivamente la versión vigente de la presente acta de compromiso antes de proceder a la Certificación."
|
||||
}
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"code": "K1",
|
||||
"section": "conclusion",
|
||||
"position": 17,
|
||||
"inertia": 3,
|
||||
"versions": [
|
||||
{
|
||||
"label": "1.0.0",
|
||||
"status": "open",
|
||||
"title": {
|
||||
"fr": "Règles abrégées de la toile de confiance",
|
||||
"en": "Abridged rules of the web of trust",
|
||||
"es": "Reglas abreviadas de la red de confianza"
|
||||
},
|
||||
"body": {
|
||||
"fr": "**Paramètres protocolaires en vigueur :**\n\n- Stock de **100 certifications** possibles\n- 1 certification émissible tous les **5 jours**\n- Nouveau compte valide si **>= 5 certifications** reçues en **2 mois**\n- Condition de distance : **<= 5 pas** de **80% des sentinelles**\n- Sentinelle : membre ayant reçu et émis >= Y[N] certifs (Y = ceil(N^(1/5)))\n- Certifications actives valables **2 ans**\n- Renouvellement de l'accord tous les **12 mois**\n\n*Note : le vote porte sur l'inclusion de ces règles dans le document, pas sur les valeurs des variables protocolaires elles-mêmes, qui sont fixées par le protocole Duniter.*",
|
||||
"en": "**Protocol parameters in force:**\n\n- Stock of **100 certifications** available\n- 1 certification can be issued every **5 days**\n- New account valid if **>= 5 certifications** received within **2 months**\n- Distance rule: **<= 5 steps** from **80% of the sentries**\n- Sentry: member who has received and issued >= Y[N] certifications (Y = ceil(N^(1/5)))\n- Active certifications valid for **2 years**\n- Renewal of the agreement every **12 months**\n\n*Note: the vote concerns the inclusion of these rules in the document, not the values of the protocol variables themselves, which are set by the Duniter protocol.*",
|
||||
"es": "**Parámetros de protocolo vigentes:**\n\n- Reserva de **100 certificaciones** posibles\n- 1 certificación emitible cada **5 días**\n- Cuenta nueva válida si recibe **>= 5 certificaciones** en **2 meses**\n- Regla de distancia: **<= 5 pasos** del **80% de los centinelas**\n- Centinela: miembro que ha recibido y emitido >= Y[N] certificaciones (Y = ceil(N^(1/5)))\n- Certificaciones activas válidas durante **2 años**\n- Renovación del acuerdo cada **12 meses**\n\n*Nota: la votación se refiere a la inclusión de estas reglas en el documento, no a los valores de las variables del protocolo en sí, que fija el protocolo Duniter.*"
|
||||
}
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"code": "K2",
|
||||
"section": "conclusion",
|
||||
"position": 18,
|
||||
"inertia": 3,
|
||||
"versions": [
|
||||
{
|
||||
"label": "1.0.0",
|
||||
"status": "open",
|
||||
"title": {
|
||||
"fr": "Monnaie G1",
|
||||
"en": "G1 currency",
|
||||
"es": "Moneda G1"
|
||||
},
|
||||
"body": {
|
||||
"fr": "**Paramètres monétaires :**\n\n- 1 Dividende Universel (DU) par personne par jour\n- Réévaluation à chaque équinoxe : `DU(n+1) = DU(n) + c² × (M/N) / 182.625` avec c = 4.88%\n- DU(0) = 10.00 G1\n\n*Note : le vote porte sur l'inclusion de ces paramètres dans le document, pas sur les valeurs monétaires elles-mêmes, qui découlent de la TRM et du bloc 0.*",
|
||||
"en": "**Monetary parameters:**\n\n- 1 Universal Dividend (UD) per person per day\n- Revaluation at each equinox: `DU(n+1) = DU(n) + c² × (M/N) / 182.625` with c = 4.88%\n- DU(0) = 10.00 G1\n\n*Note: the vote concerns the inclusion of these parameters in the document, not the monetary values themselves, which derive from the RTM and block 0.*",
|
||||
"es": "**Parámetros monetarios:**\n\n- 1 Dividendo Universal (DU) por persona y día\n- Revalorización en cada equinoccio: `DU(n+1) = DU(n) + c² × (M/N) / 182.625` con c = 4.88%\n- DU(0) = 10.00 G1\n\n*Nota: la votación se refiere a la inclusión de estos parámetros en el documento, no a los valores monetarios en sí, que se derivan de la TRM y del bloque 0.*"
|
||||
}
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"code": "X1",
|
||||
"section": "annexe",
|
||||
"position": 19,
|
||||
"inertia": 3,
|
||||
"versions": [
|
||||
{
|
||||
"label": "1.0.0",
|
||||
"status": "open",
|
||||
"title": {
|
||||
"fr": "Annexe 1 : Intégration logicielle",
|
||||
"en": "Annex 1: Software integration",
|
||||
"es": "Anexo 1: Integración en el software"
|
||||
},
|
||||
"body": {
|
||||
"fr": "Les obligations pour les logiciels implémentant la Certification G1.",
|
||||
"en": "The obligations for software implementing G1 Certification.",
|
||||
"es": "Las obligaciones del software que implementa la Certificación G1."
|
||||
}
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"code": "X1.1",
|
||||
"section": "annexe",
|
||||
"position": 20,
|
||||
"inertia": 3,
|
||||
"versions": [
|
||||
{
|
||||
"label": "1.0.0",
|
||||
"status": "open",
|
||||
"title": {
|
||||
"fr": "Dépôt de référence",
|
||||
"en": "Reference repository",
|
||||
"es": "Repositorio de referencia"
|
||||
},
|
||||
"body": {
|
||||
"fr": "Le texte de référence de l'acte d'engagement Certification est hébergé dans le dépôt git officiel : https://git.duniter.org/documents/g1_monetary_license\n\nLes applications Cesium, Gecko et toute application de Certification doivent pointer vers ce dépôt pour afficher la version en vigueur.",
|
||||
"en": "The reference text of the Certification commitment act is hosted in the official git repository: https://git.duniter.org/documents/g1_monetary_license\n\nThe Cesium and Gecko applications, and any Certification application, must point to this repository to display the version in force.",
|
||||
"es": "El texto de referencia del acta de compromiso de Certificación está alojado en el repositorio git oficial: https://git.duniter.org/documents/g1_monetary_license\n\nLas aplicaciones Cesium, Gecko y cualquier aplicación de Certificación deben apuntar a este repositorio para mostrar la versión vigente."
|
||||
}
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"code": "X1.2",
|
||||
"section": "annexe",
|
||||
"position": 21,
|
||||
"inertia": 3,
|
||||
"versions": [
|
||||
{
|
||||
"label": "1.0.0",
|
||||
"status": "open",
|
||||
"title": {
|
||||
"fr": "Obligation de transmission",
|
||||
"en": "Transmission obligation",
|
||||
"es": "Obligación de transmisión"
|
||||
},
|
||||
"body": {
|
||||
"fr": "Tout logiciel G1 permettant la Certification doit transmettre le présent acte d'engagement au certifié et en afficher les paramètres du bloc 0 de la blockchain Duniter.",
|
||||
"en": "Any G1 software that enables Certification must transmit this commitment act to the certified person and display the block 0 parameters of the Duniter blockchain.",
|
||||
"es": "Todo software G1 que permita la Certificación debe transmitir la presente acta de compromiso a la persona certificada y mostrar los parámetros del bloque 0 de la blockchain Duniter."
|
||||
}
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"code": "X1.3",
|
||||
"section": "annexe",
|
||||
"position": 22,
|
||||
"inertia": 3,
|
||||
"versions": [
|
||||
{
|
||||
"label": "1.0.0",
|
||||
"status": "open",
|
||||
"title": {
|
||||
"fr": "Logiciel auditable",
|
||||
"en": "Auditable software",
|
||||
"es": "Software auditable"
|
||||
},
|
||||
"body": {
|
||||
"fr": "Tout logiciel utilisé pour la Certification ou la création monétaire doit être publié sous licence libre, afin de permettre son audit par la communauté.",
|
||||
"en": "Any software used for Certification or money creation must be published under a free licence, so that it can be audited by the community.",
|
||||
"es": "Todo software utilizado para la Certificación o la creación monetaria debe publicarse bajo licencia libre, para permitir su auditoría por parte de la comunidad."
|
||||
}
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"code": "X2",
|
||||
"section": "annexe",
|
||||
"position": 23,
|
||||
"inertia": 3,
|
||||
"versions": [
|
||||
{
|
||||
"label": "1.0.0",
|
||||
"status": "open",
|
||||
"title": {
|
||||
"fr": "Annexe 2 : Questions à la Certification",
|
||||
"en": "Annex 2: Questions for Certification",
|
||||
"es": "Anexo 2: Preguntas para la Certificación"
|
||||
},
|
||||
"body": {
|
||||
"fr": "Liste de questions à présenter dans les logiciels de Certification.Inspirée de la checklist de la Charte 1.0 et des discussions forum (topic 32412).",
|
||||
"en": "List of questions to be presented in Certification software. Inspired by the checklist of Charter 1.0 and by forum discussions (topic 32412).",
|
||||
"es": "Lista de preguntas que deben presentarse en el software de Certificación. Inspirada en la lista de verificación de la Carta 1.0 y en los debates del foro (tema 32412)."
|
||||
}
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"code": "X2.1",
|
||||
"section": "annexe",
|
||||
"position": 24,
|
||||
"inertia": 3,
|
||||
"versions": [
|
||||
{
|
||||
"label": "1.0.0",
|
||||
"status": "open",
|
||||
"title": {
|
||||
"fr": "Questions piège (réponse attendue : NON)",
|
||||
"en": "Trap questions (expected answer: NO)",
|
||||
"es": "Preguntas trampa (respuesta esperada: NO)"
|
||||
},
|
||||
"body": {
|
||||
"fr": "**Si OUI à l'une de ces questions, la Certification doit être refusée.**\n\n- La personne m'a contacté uniquement pour obtenir ma Certification\n- Je certifie sous la pression ou pour faire plaisir\n- Je n'ai aucun moyen de vérifier l'identité de la personne\n- La personne refuse de me fournir plusieurs moyens de contact\n- Je soupçonne que la personne possède déjà un compte membre\n- Je ne connais aucun de ses autres certifieurs\n- La personne ne comprend pas ce qu'est la monnaie libre\n- La personne n'a pas lu le présent acte d'engagement",
|
||||
"en": "**If YES to any of these questions, the Certification must be refused.**\n\n- The person contacted me only to obtain my Certification\n- I am certifying under pressure or to please someone\n- I have no way of verifying the person's identity\n- The person refuses to give me several means of contact\n- I suspect that the person already has a member account\n- I do not know any of their other certifiers\n- The person does not understand what libre currency is\n- The person has not read this commitment act",
|
||||
"es": "**Si la respuesta es SÍ a alguna de estas preguntas, la Certificación debe rechazarse.**\n\n- La persona me ha contactado únicamente para obtener mi Certificación\n- Certifico bajo presión o por complacer\n- No tengo ningún medio para verificar la identidad de la persona\n- La persona se niega a proporcionarme varios medios de contacto\n- Sospecho que la persona ya posee una cuenta de miembro\n- No conozco a ninguno de sus otros certificadores\n- La persona no entiende qué es la moneda libre\n- La persona no ha leído la presente acta de compromiso"
|
||||
}
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"code": "X2.2",
|
||||
"section": "annexe",
|
||||
"position": 25,
|
||||
"inertia": 3,
|
||||
"versions": [
|
||||
{
|
||||
"label": "1.0.0",
|
||||
"status": "open",
|
||||
"title": {
|
||||
"fr": "Questions identité (réponse attendue : OUI)",
|
||||
"en": "Identity questions (expected answer: YES)",
|
||||
"es": "Preguntas de identidad (respuesta esperada: SÍ)"
|
||||
},
|
||||
"body": {
|
||||
"fr": "**Si NON à l'une de ces questions, la Certification doit être refusée.**\n\n- Je connais cette personne suffisamment pour la recontacter\n- J'ai vérifié personnellement le lien personne / clé publique\n- La personne est une personne physique vivante (pas une entité morale)\n- Je pourrais reconnaître cette personne si je la croisais\n- J'ai au moins 2 moyens de contact différents pour cette personne",
|
||||
"en": "**If NO to any of these questions, the Certification must be refused.**\n\n- I know this person well enough to get back in touch with them\n- I have personally verified the person / public key link\n- The person is a living natural person (not a legal entity)\n- I would recognise this person if I came across them\n- I have at least 2 different means of contact for this person",
|
||||
"es": "**Si la respuesta es NO a alguna de estas preguntas, la Certificación debe rechazarse.**\n\n- Conozco a esta persona suficientemente para volver a contactarla\n- He verificado personalmente el vínculo persona / clave pública\n- La persona es una persona física viva (no una persona jurídica)\n- Reconocería a esta persona si me la cruzara\n- Tengo al menos 2 medios de contacto diferentes para esta persona"
|
||||
}
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"code": "X2.3",
|
||||
"section": "annexe",
|
||||
"position": 26,
|
||||
"inertia": 3,
|
||||
"versions": [
|
||||
{
|
||||
"label": "1.0.0",
|
||||
"status": "open",
|
||||
"title": {
|
||||
"fr": "Questions sécurité (réponse attendue : OUI)",
|
||||
"en": "Security questions (expected answer: YES)",
|
||||
"es": "Preguntas de seguridad (respuesta esperada: SÍ)"
|
||||
},
|
||||
"body": {
|
||||
"fr": "**Si NON, la Certification est déconseillée.**\n\n- La personne a généré son document de révocation\n- La personne maîtrise effectivement son compte (test de transfert effectué)\n- La personne sait où retrouver le présent acte d'engagement dans sa version à jour",
|
||||
"en": "**If NO, the Certification is not recommended.**\n\n- The person has generated their revocation document\n- The person actually controls their account (test transfer carried out)\n- The person knows where to find the current version of this commitment act",
|
||||
"es": "**Si la respuesta es NO, se desaconseja la Certificación.**\n\n- La persona ha generado su documento de revocación\n- La persona controla efectivamente su cuenta (transferencia de prueba realizada)\n- La persona sabe dónde encontrar la versión vigente de la presente acta de compromiso"
|
||||
}
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"code": "F1",
|
||||
"section": "formule",
|
||||
"position": 27,
|
||||
"inertia": 3,
|
||||
"versions": [
|
||||
{
|
||||
"label": "1.0.0",
|
||||
"status": "open",
|
||||
"title": {
|
||||
"fr": "Formule de vote",
|
||||
"en": "Voting formula",
|
||||
"es": "Fórmula de votación"
|
||||
},
|
||||
"body": {
|
||||
"fr": "La formule qui régit l'adoption ou le rejet de chaque modification du présent document. Référence : g1vote (https://g1vote-view-237903.pages.duniter.org/).",
|
||||
"en": "The formula that governs the adoption or rejection of each amendment to this document. Reference: g1vote (https://g1vote-view-237903.pages.duniter.org/).",
|
||||
"es": "La fórmula que rige la adopción o el rechazo de cada modificación del presente documento. Referencia: g1vote (https://g1vote-view-237903.pages.duniter.org/)."
|
||||
}
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"code": "F1.1",
|
||||
"section": "formule",
|
||||
"position": 28,
|
||||
"inertia": 3,
|
||||
"versions": [
|
||||
{
|
||||
"label": "1.0.0",
|
||||
"status": "open",
|
||||
"title": {
|
||||
"fr": "Formule du seuil WoT",
|
||||
"en": "WoT threshold formula",
|
||||
"es": "Fórmula del umbral WoT"
|
||||
},
|
||||
"body": {
|
||||
"fr": "**Seuil = C + B^W + (M + (1-M) × (1 - (T/W)^G)) × max(0, T-C)**\n\nOù :\n- **C** = constante de base (plancher fixe)\n- **B** = exposant de base (B^W tend vers 0)\n- **W** = taille de la toile de confiance (électeurs éligibles)\n- **T** = total des votes exprimés (pour + contre)\n- **M** = ratio de majorité cible (M = majority_pct / 100)\n- **G** = exposant du gradient d'inertie\n\n**Comportement :** Faible participation → quasi-unanimité requise. Forte participation → majorité simple M suffit.",
|
||||
"en": "**Seuil = C + B^W + (M + (1-M) × (1 - (T/W)^G)) × max(0, T-C)**\n\nWhere:\n- **C** = base constant (fixed floor)\n- **B** = base exponent (B^W tends towards 0)\n- **W** = size of the web of trust (eligible voters)\n- **T** = total votes cast (for + against)\n- **M** = target majority ratio (M = majority_pct / 100)\n- **G** = inertia gradient exponent\n\n**Behaviour:** Low participation → near-unanimity required. High participation → simple majority M is enough.",
|
||||
"es": "**Seuil = C + B^W + (M + (1-M) × (1 - (T/W)^G)) × max(0, T-C)**\n\nDonde:\n- **C** = constante de base (mínimo fijo)\n- **B** = exponente de base (B^W tiende a 0)\n- **W** = tamaño de la red de confianza (votantes habilitados)\n- **T** = total de votos emitidos (a favor + en contra)\n- **M** = proporción de mayoría objetivo (M = majority_pct / 100)\n- **G** = exponente del gradiente de inercia\n\n**Comportamiento:** Baja participación → se requiere casi unanimidad. Alta participación → basta la mayoría simple M."
|
||||
}
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"code": "F1.2",
|
||||
"section": "formule",
|
||||
"position": 29,
|
||||
"inertia": 3,
|
||||
"versions": [
|
||||
{
|
||||
"label": "1.0.0",
|
||||
"status": "open",
|
||||
"title": {
|
||||
"fr": "Paramètres par défaut",
|
||||
"en": "Default parameters",
|
||||
"es": "Parámetros por defecto"
|
||||
},
|
||||
"body": {
|
||||
"fr": "Les valeurs des lettres de la formule (voir « Formule du seuil WoT ») et la façon dont le vote se déroule.\n\n**Un vote permanent, sans durée**\n\n- Chaque version se vote à tout moment, sans échéance. Chacun peut changer ou retirer son vote : le dernier fait foi.\n- Une proposition entre en vigueur dès qu'elle atteint son seuil. S'il y a déjà un texte en vigueur, il lui faut en plus une marge (voix pour moins voix exigées) plus large que celle de ce texte. Le texte remplacé redevient une proposition, avec ses votes : il peut revenir.\n- Si plusieurs propositions y parviennent, celle qui a la marge la plus large entre en vigueur.\n\n**Les lettres**\n\n- **W** : le nombre de membres de la toile de confiance Ğ1, relu sur la chaîne toutes les 6 heures.\n- **T** : les votes exprimés sur la version, pour et contre.\n- **C = 50** : le nombre minimal de votes « pour » dans Monnaie libre. Sous 50 voix pour, rien ne passe : une seule voix ne fait jamais passer un texte.\n- **B = 0,1** : dans la toile de confiance, `B^W` est si petit qu'il ne compte pas.\n- **M** et **G** : donnés par le cran d'inertie de l'article (voir « Niveaux d'inertie »). G n'est pas fixé à la main : il se déduit de la participation habituelle du groupe.\n\n**Par défaut**\n\n- Le cran par défaut est le 3 (moyenne) : M = 55 %, et le groupe demande 75 % de « pour » au-delà de C quand il vote comme d'habitude. Tout nouvel article commence à ce cran.\n- Au départ, Unicité du compte, Réciprocité et le titre du document sont au cran 4 (haute), l'ordre des articles de chaque section au cran 2 (basse), tous les autres articles au cran 3.",
|
||||
"en": "The values of the letters of the formula (see “WoT threshold formula”) and how the vote runs.\n\n**A permanent vote, with no duration**\n\n- Every version can be voted on at any time, with no deadline. Anyone can change or withdraw their vote: the last one is the one that counts.\n- A proposal comes into force as soon as it reaches its threshold. If a text is already in force, it also needs a wider margin (votes in favour minus votes required) than that text has. The replaced text becomes a proposal again, with its votes: it can come back.\n- If several proposals get there, the one with the widest margin comes into force.\n\n**The letters**\n\n- **W**: the number of members of the Ğ1 web of trust, read again from the chain every 6 hours.\n- **T**: the votes cast on the version, for and against.\n- **C = 50**: the minimum number of votes in favour in Monnaie libre. Below 50 votes in favour, nothing passes: a single vote never passes a text.\n- **B = 0.1**: in the web of trust, `B^W` is so small that it does not count.\n- **M** and **G**: given by the article's level of inertia (see “Inertia levels”). G is not set by hand: it follows from the group's usual turnout.\n\n**By default**\n\n- The default level is 3 (medium): M = 55%, and the group asks for 75% of votes in favour beyond C when it votes as usual. Every new article starts at this level.\n- At the start, Uniqueness of the account, Reciprocity and the title of the document are at level 4 (high), the order of the articles of each section at level 2 (low), all other articles at level 3.",
|
||||
"es": "Los valores de las letras de la fórmula (ver «Fórmula del umbral WoT») y cómo se desarrolla la votación.\n\n**Una votación permanente, sin duración**\n\n- Cada versión se vota en cualquier momento, sin plazo. Cada cual puede cambiar o retirar su voto: el último es el que cuenta.\n- Una propuesta entra en vigor en cuanto alcanza su umbral. Si ya hay un texto en vigor, necesita además un margen (votos a favor menos votos exigidos) más amplio que el de ese texto. El texto sustituido vuelve a ser una propuesta, con sus votos: puede volver.\n- Si varias propuestas lo consiguen, entra en vigor la que tiene el margen más amplio.\n\n**Las letras**\n\n- **W**: el número de miembros de la red de confianza Ğ1, leído de nuevo en la cadena cada 6 horas.\n- **T**: los votos emitidos sobre la versión, a favor y en contra.\n- **C = 50**: el número mínimo de votos a favor en Monnaie libre. Con menos de 50 votos a favor, nada pasa: un solo voto nunca hace pasar un texto.\n- **B = 0,1**: en la red de confianza, `B^W` es tan pequeño que no cuenta.\n- **M** y **G**: los da el nivel de inercia del artículo (ver «Niveles de inercia»). G no se fija a mano: se deduce de la participación habitual del grupo.\n\n**Por defecto**\n\n- El nivel por defecto es el 3 (media): M = 55 %, y el grupo pide un 75 % de votos a favor más allá de C cuando vota como de costumbre. Todo artículo nuevo empieza en este nivel.\n- Al inicio, Unicidad de la cuenta, Reciprocidad y el título del documento están en el nivel 4 (alta), el orden de los artículos de cada sección en el nivel 2 (baja), todos los demás artículos en el nivel 3."
|
||||
}
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"code": "F1.3",
|
||||
"section": "formule",
|
||||
"position": 30,
|
||||
"inertia": 3,
|
||||
"versions": [
|
||||
{
|
||||
"label": "1.0.0",
|
||||
"status": "open",
|
||||
"title": {
|
||||
"fr": "Processus de dépôt officiel",
|
||||
"en": "Official filing process",
|
||||
"es": "Proceso de depósito oficial"
|
||||
},
|
||||
"body": {
|
||||
"fr": "Lorsqu'une proposition alternative atteint le seuil d'adoption :\n\n1. Le texte de remplacement est intégré au document officiel\n2. Le hash IPFS du document mis à jour est calculé\n3. Le hash est ancré on-chain via `system.remark`\n4. Le dépôt git officiel est synchronisé\n5. Les applications (Cesium, Gecko) mettent à jour automatiquement",
|
||||
"en": "When an alternative proposal reaches the adoption threshold:\n\n1. The replacement text is incorporated into the official document\n2. The IPFS hash of the updated document is computed\n3. The hash is anchored on-chain via `system.remark`\n4. The official git repository is synchronised\n5. The applications (Cesium, Gecko) update automatically",
|
||||
"es": "Cuando una propuesta alternativa alcanza el umbral de adopción:\n\n1. El texto de sustitución se integra en el documento oficial\n2. Se calcula el hash IPFS del documento actualizado\n3. El hash se ancla on-chain mediante `system.remark`\n4. Se sincroniza el repositorio git oficial\n5. Las aplicaciones (Cesium, Gecko) se actualizan automáticamente"
|
||||
}
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"code": "N1",
|
||||
"section": "inertie",
|
||||
"position": 31,
|
||||
"inertia": 3,
|
||||
"versions": [
|
||||
{
|
||||
"label": "1.0.0",
|
||||
"status": "open",
|
||||
"title": {
|
||||
"fr": "Réglage de l'inertie",
|
||||
"en": "Inertia setting",
|
||||
"es": "Ajuste de la inercia"
|
||||
},
|
||||
"body": {
|
||||
"fr": "Chaque article porte son inertie : un cran de 1 à 5 (voir « Niveaux d'inertie »). Plus le cran est haut, plus il faut de voix pour changer le texte quand peu de gens votent. Le titre du document et l'ordre des articles de chaque section ont aussi la leur, qui se change de la même façon.\n\nTout membre du groupe peut proposer un autre cran. Cette proposition se vote au cran actuel de l'article, comme son texte : l'inertie de l'inertie hérite du cran de l'article. Ainsi, un article au cran 4 ne descend qu'avec les voix que demande le cran 4.",
|
||||
"en": "Every article carries its inertia: a level from 1 to 5 (see “Inertia levels”). The higher the level, the more votes it takes to change the text when few people vote. The title of the document and the order of the articles of each section have theirs too, changed in the same way.\n\nAny member of the group can propose another level. That proposal is voted at the article's current level, like its text: the inertia of the inertia inherits the article's level. So an article at level 4 only comes down with the votes that level 4 asks for.",
|
||||
"es": "Cada artículo lleva su inercia: un nivel del 1 al 5 (ver «Niveles de inercia»). Cuanto más alto es el nivel, más votos hacen falta para cambiar el texto cuando vota poca gente. El título del documento y el orden de los artículos de cada sección también tienen la suya, que se cambia del mismo modo.\n\nCualquier miembro del grupo puede proponer otro nivel. Esa propuesta se vota con el nivel actual del artículo, como su texto: la inercia de la inercia hereda el nivel del artículo. Así, un artículo en el nivel 4 solo baja con los votos que pide el nivel 4."
|
||||
}
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"code": "N1.1",
|
||||
"section": "inertie",
|
||||
"position": 32,
|
||||
"inertia": 3,
|
||||
"versions": [
|
||||
{
|
||||
"label": "1.0.0",
|
||||
"status": "open",
|
||||
"title": {
|
||||
"fr": "Niveaux d'inertie",
|
||||
"en": "Inertia levels",
|
||||
"es": "Niveles de inercia"
|
||||
},
|
||||
"body": {
|
||||
"fr": "À chaque cran, le groupe demande d'abord C voix pour, puis une part de « pour » parmi les votes au-delà de C. Cette part dépend du cran et du nombre de votants, comparé à la participation habituelle du groupe : plus il y a de votants, plus elle baisse, jusqu'à la majorité M quand tout le monde vote.\n\n`Part exigée = M + (1 − M) × (1 − (T/W)^G)`\n\n**Les cinq crans** : 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.\n\n- **Cran 1 · très basse** : 60 % comme d'habitude, 50 % quand tout le monde vote. Aucun article au départ.\n- **Cran 2 · basse** : 67 % comme d'habitude, 50 % quand tout le monde vote. L'ordre des articles de chaque section.\n- **Cran 3 · moyenne** : 75 % comme d'habitude, 55 % quand tout le monde vote. Le cran par défaut : tous les autres articles, et chaque nouvel article.\n- **Cran 4 · haute** : 83 % comme d'habitude, 60 % quand tout le monde vote. Unicité du compte, Réciprocité, le titre du document.\n- **Cran 5 · très haute** : 90 % comme d'habitude, 66 % quand tout le monde vote. Aucun article au départ.\n\n**La participation habituelle** est la médiane du nombre de votants des propositions du groupe votées depuis un an. Tant que le groupe n'a pas voté sur 5 propositions, elle vaut deux fois C : 100 votants dans Monnaie libre. Elle reste entre 1 % et 50 % de l'électorat, et se mesure une fois par jour.\n\n**G** se calcule pour que la part exigée, à la participation habituelle p̃, soit exactement celle du cran : `G = ln(1 − (s − M)/(1 − M)) / ln(p̃)`, où s est la part exigée comme d'habitude.\n\nExemple : avec 7 224 membres et une participation habituelle de 120 votants, quand 120 personnes votent, il faut 92, 97, 103, 109 ou 113 voix pour, du cran 1 au cran 5. Au cran 3 : 50 voix pour, plus 75 % des 70 autres votes, soit 102,5, arrondi à 103.",
|
||||
"en": "At each level, the group first asks for C votes in favour, then for a share of votes in favour among the votes beyond C. That share depends on the level and on the number of voters, compared with the group's usual turnout: the more voters, the lower it gets, down to the majority M when everyone votes.\n\n`Share asked = M + (1 − M) × (1 − (T/W)^G)`\n\n**The five levels**: the share asked when the group votes as usual, then when everyone votes, and what is at that level at the start.\n\n- **Level 1 · very low**: 60% as usual, 50% when everyone votes. No article at the start.\n- **Level 2 · low**: 67% as usual, 50% when everyone votes. The order of the articles of each section.\n- **Level 3 · medium**: 75% as usual, 55% when everyone votes. The default level: all other articles, and every new article.\n- **Level 4 · high**: 83% as usual, 60% when everyone votes. Uniqueness of the account, Reciprocity, the title of the document.\n- **Level 5 · very high**: 90% as usual, 66% when everyone votes. No article at the start.\n\n**The usual turnout** is the median number of voters on the group's proposals voted on over the past year. Until the group has voted on 5 proposals, it is twice C: 100 voters in Monnaie libre. It stays between 1% and 50% of the electorate, and is measured once a day.\n\n**G** is computed so that the share asked, at the usual turnout p̃, is exactly that of the level: `G = ln(1 − (s − M)/(1 − M)) / ln(p̃)`, where s is the share asked as usual.\n\nExample: with 7,224 members and a usual turnout of 120 voters, when 120 people vote, it takes 92, 97, 103, 109 or 113 votes in favour, from level 1 to level 5. At level 3: 50 votes in favour, plus 75% of the 70 other votes, i.e. 102.5, rounded up to 103.",
|
||||
"es": "En cada nivel, el grupo pide primero C votos a favor, y luego una proporción de votos a favor entre los votos más allá de C. Esa proporción depende del nivel y del número de votantes, comparado con la participación habitual del grupo: cuantos más votantes, más baja, hasta la mayoría M cuando vota todo el mundo.\n\n`Proporción exigida = M + (1 − M) × (1 − (T/W)^G)`\n\n**Los cinco niveles**: la proporción exigida cuando el grupo vota como de costumbre, luego cuando vota todo el mundo, y lo que está en ese nivel al inicio.\n\n- **Nivel 1 · muy baja**: 60 % como de costumbre, 50 % cuando vota todo el mundo. Ningún artículo al inicio.\n- **Nivel 2 · baja**: 67 % como de costumbre, 50 % cuando vota todo el mundo. El orden de los artículos de cada sección.\n- **Nivel 3 · media**: 75 % como de costumbre, 55 % cuando vota todo el mundo. El nivel por defecto: todos los demás artículos, y todo artículo nuevo.\n- **Nivel 4 · alta**: 83 % como de costumbre, 60 % cuando vota todo el mundo. Unicidad de la cuenta, Reciprocidad, el título del documento.\n- **Nivel 5 · muy alta**: 90 % como de costumbre, 66 % cuando vota todo el mundo. Ningún artículo al inicio.\n\n**La participación habitual** es la mediana del número de votantes de las propuestas del grupo votadas en el último año. Hasta que el grupo haya votado 5 propuestas, vale dos veces C: 100 votantes en Monnaie libre. Se mantiene entre el 1 % y el 50 % del electorado, y se mide una vez al día.\n\n**G** se calcula para que la proporción exigida, con la participación habitual p̃, sea exactamente la del nivel: `G = ln(1 − (s − M)/(1 − M)) / ln(p̃)`, donde s es la proporción exigida como de costumbre.\n\nEjemplo: con 7224 miembros y una participación habitual de 120 votantes, cuando votan 120 personas, hacen falta 92, 97, 103, 109 o 113 votos a favor, del nivel 1 al nivel 5. En el nivel 3: 50 votos a favor, más el 75 % de los otros 70 votos, es decir 102,5, redondeado a 103."
|
||||
}
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"code": "O1",
|
||||
"section": "ordonnancement",
|
||||
"position": 33,
|
||||
"inertia": 3,
|
||||
"versions": [
|
||||
{
|
||||
"label": "1.0.0",
|
||||
"status": "open",
|
||||
"title": {
|
||||
"fr": "Ordonnancement du document",
|
||||
"en": "Ordering of the document",
|
||||
"es": "Ordenación del documento"
|
||||
},
|
||||
"body": {
|
||||
"fr": "L'ordre des articles de chaque section est lui-même soumis au vote. En fin de section, le module « Ordre des articles » se vote comme un article, au cran 2 (basse) au départ : chaque proposition est la liste des articles de la section, dans un autre ordre. Quand un ordre entre en vigueur, seuls les articles de cette section changent de place ; aucun texte ne change.\n\nUn nouvel article se place à la fin de sa section ; sa place se vote ensuite par l'ordre de la section. Un ordre proposé garde ses votes quand la section change : un article arrivé depuis s'y ajoute à la fin, un article sorti en disparaît. Une section d'un seul article n'a pas d'ordre à voter.\n\nLe titre du document est lui aussi un module, voté au cran 4 (haute) au départ. Le titre de chaque article, lui, se vote avec son texte.",
|
||||
"en": "The order of the articles of each section is itself put to the vote. At the end of each section, the “Order of the articles” module is voted on like an article, at level 2 (low) at the start: each proposal is the list of the section's articles, in another order. When an order comes into force, only the articles of that section change place; no text changes.\n\nA new article goes at the end of its section; its place is then voted on through the order of the section. A proposed order keeps its votes when the section changes: an article that has arrived since is added at the end, an article that has left drops out of it. A section with a single article has no order to vote on.\n\nThe title of the document is also a module, voted on at level 4 (high) at the start. The title of each article is voted on with its text.",
|
||||
"es": "El orden de los artículos de cada sección está a su vez sometido a votación. Al final de cada sección, el módulo «Orden de los artículos» se vota como un artículo, con el nivel 2 (baja) al inicio: cada propuesta es la lista de los artículos de la sección, en otro orden. Cuando un orden entra en vigor, solo los artículos de esa sección cambian de lugar; ningún texto cambia.\n\nUn artículo nuevo se coloca al final de su sección; su lugar se vota después mediante el orden de la sección. Un orden propuesto conserva sus votos cuando la sección cambia: un artículo llegado después se añade al final, un artículo que ha salido desaparece de él. Una sección de un solo artículo no tiene orden que votar.\n\nEl título del documento es también un módulo, votado con el nivel 4 (alta) al inicio. El título de cada artículo, en cambio, se vota con su texto."
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
{
|
||||
"_source": "seeds/acte-engagement-certification.json au commit 3956a3e : F1.1, F1.2 et N1.1 tels que la production les tient depuis M6b (bb4bdf5, le 03/11 à 08 h 02 ; lot T, le quorum par cran), inchangés par M7 (b2e597b)",
|
||||
"units": {
|
||||
"F1.1": {
|
||||
"label": "1.0.0",
|
||||
"title": {
|
||||
"fr": "Formule du seuil WoT",
|
||||
"en": "WoT threshold formula",
|
||||
"es": "Fórmula del umbral WoT"
|
||||
},
|
||||
"body": {
|
||||
"fr": "Le seuil est le nombre de voix pour qu'il faut à une version pour entrer en vigueur. Sous le quorum Q, les votants qui manquent comptent comme « non » : le seuil se calcule comme si Q personnes avaient voté. Au-delà, il faut la part de « pour » que demande le cran d'inertie de l'article, parmi tous les votants : presque l'unanimité quand peu de gens votent, la majorité M quand tout le monde vote.\n\n`T' = max(T, Q)`\n`Seuil = B^W + (M + (1 − M) × (1 − (T'/W)^G)) × T'`\n\nUne version passe quand ses voix pour atteignent le seuil, arrondi à l'entier au-dessus.\n\n- **T** : les votes exprimés sur la version, pour et contre.\n- **Q** : le quorum de l'article ; **T'** : les votants retenus, T, ou Q s'il en manque.\n- **W** : le nombre de membres de la toile de confiance Ğ1.\n- **B** : la base ; à la puissance W, elle est si petite qu'elle ne compte pas.\n- **M** : la majorité demandée quand tout le monde vote.\n- **G** : le gradient : plus il est grand, plus la part demandée monte quand peu de gens votent.\n\nLeurs valeurs sont dans « Paramètres par défaut » et « Niveaux d'inertie ».",
|
||||
"en": "The threshold is the number of votes in favour a version needs to come into force. Below the quorum Q, the voters who are missing count as votes against: the threshold is worked out as if Q people had voted. Beyond it, it takes the share of votes in favour that the article's level of inertia asks for, among all the voters: near-unanimity when few people vote, the majority M when everyone votes.\n\n`T' = max(T, Q)`\n`Threshold = B^W + (M + (1 − M) × (1 − (T'/W)^G)) × T'`\n\nA version passes when its votes in favour reach the threshold, rounded up to a whole number.\n\n- **T**: the votes cast on the version, for and against.\n- **Q**: the article's quorum; **T'**: the voters counted, T, or Q if some are missing.\n- **W**: the number of members of the Ğ1 web of trust.\n- **B**: the base; raised to the power W, it is so small that it does not count.\n- **M**: the majority asked for when everyone votes.\n- **G**: the gradient: the larger it is, the higher the share asked for when few people vote.\n\nTheir values are in “Default parameters” and “Inertia levels”.",
|
||||
"es": "El umbral es el número de votos a favor que necesita una versión para entrar en vigor. Por debajo del quórum Q, los votantes que faltan cuentan como «no»: el umbral se calcula como si hubieran votado Q personas. Más allá, hace falta la proporción de votos a favor que pide el nivel de inercia del artículo, entre todos los votantes: casi la unanimidad cuando vota poca gente, la mayoría M cuando vota todo el mundo.\n\n`T' = max(T, Q)`\n`Umbral = B^W + (M + (1 − M) × (1 − (T'/W)^G)) × T'`\n\nUna versión pasa cuando sus votos a favor alcanzan el umbral, redondeado al entero superior.\n\n- **T**: los votos emitidos sobre la versión, a favor y en contra.\n- **Q**: el quórum del artículo; **T'**: los votantes contados, T, o Q si faltan.\n- **W**: el número de miembros de la red de confianza Ğ1.\n- **B**: la base; elevada a la potencia W, es tan pequeña que no cuenta.\n- **M**: la mayoría pedida cuando vota todo el mundo.\n- **G**: el gradiente: cuanto mayor es, más alta es la proporción pedida cuando vota poca gente.\n\nSus valores están en «Parámetros por defecto» y «Niveles de inercia»."
|
||||
}
|
||||
},
|
||||
"F1.2": {
|
||||
"label": "1.0.0",
|
||||
"title": {
|
||||
"fr": "Paramètres par défaut",
|
||||
"en": "Default parameters",
|
||||
"es": "Parámetros por defecto"
|
||||
},
|
||||
"body": {
|
||||
"fr": "Les valeurs des lettres de la formule (voir « Formule du seuil WoT ») et la façon dont le vote se déroule.\n\n**Un vote permanent, sans durée**\n\n- Chaque version se vote à tout moment, sans échéance. Chacun peut changer ou retirer son vote : le dernier fait foi.\n- Une proposition entre en vigueur dès qu'elle atteint son seuil. S'il y a déjà un texte en vigueur, il lui faut en plus une marge (voix pour moins voix exigées) plus large que celle de ce texte. Le texte remplacé redevient une proposition, avec ses votes : il peut revenir.\n- Si plusieurs propositions y parviennent, celle qui a la marge la plus large entre en vigueur.\n\n**Les lettres**\n\n- **W** : le nombre de membres de la toile de confiance Ğ1, relu sur la chaîne toutes les 6 heures.\n- **T** : les votes exprimés sur la version, pour et contre.\n- **Q**, le quorum : sous ce nombre de votants, ceux qui manquent comptent comme « non ». Chaque article prend celui de son cran : 12, 12, 50, 50 et 50 dans Monnaie libre, du cran 1 au cran 5. L'auteur de la première version d'un article, un administrateur pour les textes de départ, peut en fixer un autre tant que personne n'a voté sur l'article ; ensuite, il ne change plus. Dans Monnaie libre, jamais moins de 2 : une seule voix ne fait jamais passer un texte.\n- **B = 0,1** : dans la toile de confiance, `B^W` est si petit qu'il ne compte pas.\n- **M** et **G** : donnés par le cran d'inertie de l'article (voir « Niveaux d'inertie »). G n'est pas fixé à la main : il se déduit de la participation habituelle du groupe.\n\n**Par défaut**\n\n- 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.\n- Au départ, aucun article n'est en vigueur. Raison d'être, Unicité du compte et Réciprocité sont des Propositions seed, mises en avant pour lancer le vote, au cran 2 (basse). Le titre du document est au cran 4 (haute), l'ordre des articles de chaque section au cran 2, tous les autres articles au cran 3.",
|
||||
"en": "The values of the letters of the formula (see “WoT threshold formula”) and how the vote runs.\n\n**A permanent vote, with no duration**\n\n- Every version can be voted on at any time, with no deadline. Anyone can change or withdraw their vote: the last one is the one that counts.\n- A proposal comes into force as soon as it reaches its threshold. If a text is already in force, it also needs a wider margin (votes in favour minus votes required) than that text has. The replaced text becomes a proposal again, with its votes: it can come back.\n- If several proposals get there, the one with the widest margin comes into force.\n\n**The letters**\n\n- **W**: the number of members of the Ğ1 web of trust, read again from the chain every 6 hours.\n- **T**: the votes cast on the version, for and against.\n- **Q**, the quorum: below this number of voters, those who are missing count as votes against. Each article takes the one of its level: 12, 12, 50, 50 and 50 in Monnaie libre, from level 1 to level 5. The author of the first version of an article, an administrator for the starting texts, can set another one as long as nobody has voted on the article; after that, it no longer changes. In Monnaie libre, never less than 2: a single vote never passes a text.\n- **B = 0.1**: in the web of trust, `B^W` is so small that it does not count.\n- **M** and **G**: given by the article's level of inertia (see “Inertia levels”). G is not set by hand: it follows from the group's usual turnout.\n\n**By default**\n\n- The default level is 3 (medium): M = 55%, a quorum of 50 in Monnaie libre, and the group asks for 75% of votes in favour when it votes as usual. Every new article starts at this level.\n- At the start, no article is in force. Purpose, Uniqueness of the account and Reciprocity are seed proposals, put forward to start the vote, at level 2 (low). The title of the document is at level 4 (high), the order of the articles of each section at level 2, all other articles at level 3.",
|
||||
"es": "Los valores de las letras de la fórmula (ver «Fórmula del umbral WoT») y cómo se desarrolla la votación.\n\n**Una votación permanente, sin duración**\n\n- Cada versión se vota en cualquier momento, sin plazo. Cada cual puede cambiar o retirar su voto: el último es el que cuenta.\n- Una propuesta entra en vigor en cuanto alcanza su umbral. Si ya hay un texto en vigor, necesita además un margen (votos a favor menos votos exigidos) más amplio que el de ese texto. El texto sustituido vuelve a ser una propuesta, con sus votos: puede volver.\n- Si varias propuestas lo consiguen, entra en vigor la que tiene el margen más amplio.\n\n**Las letras**\n\n- **W**: el número de miembros de la red de confianza Ğ1, leído de nuevo en la cadena cada 6 horas.\n- **T**: los votos emitidos sobre la versión, a favor y en contra.\n- **Q**, el quórum: por debajo de este número de votantes, los que faltan cuentan como «no». Cada artículo toma el de su nivel: 12, 12, 50, 50 y 50 en Monnaie libre, del nivel 1 al nivel 5. El autor de la primera versión de un artículo, un administrador para los textos de partida, puede fijar otro mientras nadie haya votado sobre el artículo; después, ya no cambia. En Monnaie libre, nunca menos de 2: un solo voto nunca hace pasar un texto.\n- **B = 0,1**: en la red de confianza, `B^W` es tan pequeño que no cuenta.\n- **M** y **G**: los da el nivel de inercia del artículo (ver «Niveles de inercia»). G no se fija a mano: se deduce de la participación habitual del grupo.\n\n**Por defecto**\n\n- El nivel por defecto es el 3 (media): M = 55 %, un quórum de 50 en Monnaie libre, y el grupo pide un 75 % de votos a favor cuando vota como de costumbre. Todo artículo nuevo empieza en este nivel.\n- Al inicio, ningún artículo está en vigor. Razón de ser, Unicidad de la cuenta y Reciprocidad son Propuestas seed, puestas en primer plano para lanzar la votación, en el nivel 2 (baja). El título del documento está en el nivel 4 (alta), el orden de los artículos de cada sección en el nivel 2, todos los demás artículos en el nivel 3."
|
||||
}
|
||||
},
|
||||
"N1.1": {
|
||||
"label": "1.0.0",
|
||||
"title": {
|
||||
"fr": "Niveaux d'inertie",
|
||||
"en": "Inertia levels",
|
||||
"es": "Niveles de inercia"
|
||||
},
|
||||
"body": {
|
||||
"fr": "À chaque cran, le groupe demande un quorum de votants, puis une part de « pour » parmi eux (voir « Formule du seuil WoT »). Sous le quorum, les votants qui manquent comptent comme « non ». La part dépend du cran et du nombre de votants, comparé à la participation habituelle du groupe : plus il y a de votants, plus elle baisse, jusqu'à la majorité M quand tout le monde vote.\n\n`Part exigée = M + (1 − M) × (1 − (T'/W)^G)`, où T' est le nombre de votants, ou le quorum s'il en manque.\n\n**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.\n\n- **Cran 1 · très basse** : quorum 12 ; 60 % comme d'habitude, 50 % quand tout le monde vote. Aucun article au départ.\n- **Cran 2 · basse** : quorum 12 ; 67 % comme d'habitude, 50 % quand tout le monde vote. Raison d'être, Unicité du compte, Réciprocité, et l'ordre des articles de chaque section.\n- **Cran 3 · moyenne** : quorum 50 ; 75 % comme d'habitude, 55 % quand tout le monde vote. Le cran par défaut : tous les autres articles, et chaque nouvel article.\n- **Cran 4 · haute** : quorum 50 ; 83 % comme d'habitude, 60 % quand tout le monde vote. Le titre du document.\n- **Cran 5 · très haute** : quorum 50 ; 90 % comme d'habitude, 66 % quand tout le monde vote. Aucun article au départ.\n\n**La participation habituelle** est la médiane du nombre de votants des propositions du groupe votées depuis un an. 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. Elle ne dépend pas du quorum d'un article. Elle reste entre 1 % et 50 % de l'électorat, et se mesure une fois par jour.\n\n**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.\n\nExemple : 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.",
|
||||
"en": "At each level, the group asks for a quorum of voters, then for a share of votes in favour among them (see “WoT threshold formula”). Below the quorum, the voters who are missing count as votes against. The share depends on the level and on the number of voters, compared with the group's usual turnout: the more voters, the lower it gets, down to the majority M when everyone votes.\n\n`Share asked = M + (1 − M) × (1 − (T'/W)^G)`, where T' is the number of voters, or the quorum if some are missing.\n\n**The five levels**: the quorum in Monnaie libre, the share asked when the group votes as usual, then when everyone votes, and what is at that level at the start.\n\n- **Level 1 · very low**: quorum 12; 60% as usual, 50% when everyone votes. No article at the start.\n- **Level 2 · low**: quorum 12; 67% as usual, 50% when everyone votes. Purpose, Uniqueness of the account, Reciprocity, and the order of the articles of each section.\n- **Level 3 · medium**: quorum 50; 75% as usual, 55% when everyone votes. The default level: all other articles, and every new article.\n- **Level 4 · high**: quorum 50; 83% as usual, 60% when everyone votes. The title of the document.\n- **Level 5 · very high**: quorum 50; 90% as usual, 66% when everyone votes. No article at the start.\n\n**The usual turnout** is the median number of voters on the group's proposals voted on over the past year. Until the group has voted on 5 proposals, it is twice the document's reference quorum: 100 voters in Monnaie libre. It does not depend on the quorum of an article. It stays between 1% and 50% of the electorate, and is measured once a day.\n\n**G** is computed so that the share asked, at the usual turnout, is exactly that of the level: `G = ln(1 − (s − M)/(1 − M)) / ln(p̃)`, where s is the share asked as usual and p̃ the usual turnout divided by W.\n\nExample: with 7,224 members and a usual turnout of 120 voters, when 120 people vote, it takes 72, 81, 90, 100 or 108 votes in favour, from level 1 to level 5; at level 3, 75% of the 120 voters, i.e. 90. Below the quorum, those missing count as votes against: at level 2 (quorum 12), 9 votes in favour pass with 3 votes against at most; at level 3 (quorum 50), it takes 39 votes in favour, with 11 votes against at most.",
|
||||
"es": "En cada nivel, el grupo pide un quórum de votantes, y luego una proporción de votos a favor entre ellos (ver «Fórmula del umbral WoT»). Por debajo del quórum, los votantes que faltan cuentan como «no». La proporción depende del nivel y del número de votantes, comparado con la participación habitual del grupo: cuantos más votantes, más baja, hasta la mayoría M cuando vota todo el mundo.\n\n`Proporción exigida = M + (1 − M) × (1 − (T'/W)^G)`, donde T' es el número de votantes, o el quórum si faltan.\n\n**Los cinco niveles**: el quórum en Monnaie libre, la proporción exigida cuando el grupo vota como de costumbre, luego cuando vota todo el mundo, y lo que está en ese nivel al inicio.\n\n- **Nivel 1 · muy baja**: quórum 12; 60 % como de costumbre, 50 % cuando vota todo el mundo. Ningún artículo al inicio.\n- **Nivel 2 · baja**: quórum 12; 67 % como de costumbre, 50 % cuando vota todo el mundo. Razón de ser, Unicidad de la cuenta, Reciprocidad, y el orden de los artículos de cada sección.\n- **Nivel 3 · media**: quórum 50; 75 % como de costumbre, 55 % cuando vota todo el mundo. El nivel por defecto: todos los demás artículos, y todo artículo nuevo.\n- **Nivel 4 · alta**: quórum 50; 83 % como de costumbre, 60 % cuando vota todo el mundo. El título del documento.\n- **Nivel 5 · muy alta**: quórum 50; 90 % como de costumbre, 66 % cuando vota todo el mundo. Ningún artículo al inicio.\n\n**La participación habitual** es la mediana del número de votantes de las propuestas del grupo votadas en el último año. Hasta que el grupo haya votado 5 propuestas, vale dos veces el quórum de referencia del documento: 100 votantes en Monnaie libre. No depende del quórum de un artículo. Se mantiene entre el 1 % y el 50 % del electorado, y se mide una vez al día.\n\n**G** se calcula para que la proporción exigida, con la participación habitual, sea exactamente la del nivel: `G = ln(1 − (s − M)/(1 − M)) / ln(p̃)`, donde s es la proporción exigida como de costumbre y p̃ la participación habitual dividida por W.\n\nEjemplo: con 7224 miembros y una participación habitual de 120 votantes, cuando votan 120 personas, hacen falta 72, 81, 90, 100 o 108 votos a favor, del nivel 1 al nivel 5; en el nivel 3, el 75 % de los 120 votantes, es decir 90. Por debajo del quórum, los que faltan cuentan como «no»: en el nivel 2 (quórum 12), 9 votos a favor pasan con 3 en contra como mucho; en el nivel 3 (quórum 50), hacen falta 39 votos a favor, con 11 en contra como mucho."
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
{
|
||||
"_source": "seeds/acte-engagement-certification.json au commit 8c1e7af : F1.1, F1.2 et N1.1 tels que la production les tient depuis M6c (84f015a, le 03/11 à 11 h 05 ; lot T2, les trois phrases), inchangés par M7b (6f6197c)",
|
||||
"units": {
|
||||
"F1.1": {
|
||||
"label": "1.0.0",
|
||||
"title": {
|
||||
"fr": "Formule du seuil WoT",
|
||||
"en": "WoT threshold formula",
|
||||
"es": "Fórmula del umbral WoT"
|
||||
},
|
||||
"body": {
|
||||
"fr": "Le seuil est le nombre de voix pour qu'il faut à une version pour entrer en vigueur. Sous le quorum Q, les votants qui manquent comptent comme « non » : le seuil se calcule comme si Q personnes avaient voté. Au-delà, il faut la part de « pour » que demande le cran d'inertie de l'article, parmi tous les votants : cette part est la plus haute au quorum et baisse quand la participation monte, jusqu'à la majorité M quand tout le monde vote.\n\n`T' = max(T, Q)`\n`Seuil = B^W + (M + (1 − M) × (1 − (T'/W)^G)) × T'`\n\nUne version passe quand ses voix pour atteignent le seuil, arrondi à l'entier au-dessus.\n\n- **T** : les votes exprimés sur la version, pour et contre.\n- **Q** : le quorum de l'article ; **T'** : les votants retenus, T, ou Q s'il en manque.\n- **W** : le nombre de membres de la toile de confiance Ğ1.\n- **B** : la base ; à la puissance W, elle est si petite qu'elle ne compte pas.\n- **M** : la majorité demandée quand tout le monde vote.\n- **G** : le gradient : plus il est grand, plus la part demandée monte quand peu de gens votent.\n\nLeurs valeurs sont dans « Paramètres par défaut » et « Niveaux d'inertie ».",
|
||||
"en": "The threshold is the number of votes in favour a version needs to come into force. Below the quorum Q, the voters who are missing count as votes against: the threshold is worked out as if Q people had voted. Beyond it, it takes the share of votes in favour that the article's level of inertia asks for, among all the voters: that share is highest at the quorum and falls as turnout rises, down to the majority M when everyone votes.\n\n`T' = max(T, Q)`\n`Threshold = B^W + (M + (1 − M) × (1 − (T'/W)^G)) × T'`\n\nA version passes when its votes in favour reach the threshold, rounded up to a whole number.\n\n- **T**: the votes cast on the version, for and against.\n- **Q**: the article's quorum; **T'**: the voters counted, T, or Q if some are missing.\n- **W**: the number of members of the Ğ1 web of trust.\n- **B**: the base; raised to the power W, it is so small that it does not count.\n- **M**: the majority asked for when everyone votes.\n- **G**: the gradient: the larger it is, the higher the share asked for when few people vote.\n\nTheir values are in “Default parameters” and “Inertia levels”.",
|
||||
"es": "El umbral es el número de votos a favor que necesita una versión para entrar en vigor. Por debajo del quórum Q, los votantes que faltan cuentan como «no»: el umbral se calcula como si hubieran votado Q personas. Más allá, hace falta la proporción de votos a favor que pide el nivel de inercia del artículo, entre todos los votantes: esa proporción es la más alta en el quórum y baja cuando sube la participación, hasta la mayoría M cuando vota todo el mundo.\n\n`T' = max(T, Q)`\n`Umbral = B^W + (M + (1 − M) × (1 − (T'/W)^G)) × T'`\n\nUna versión pasa cuando sus votos a favor alcanzan el umbral, redondeado al entero superior.\n\n- **T**: los votos emitidos sobre la versión, a favor y en contra.\n- **Q**: el quórum del artículo; **T'**: los votantes contados, T, o Q si faltan.\n- **W**: el número de miembros de la red de confianza Ğ1.\n- **B**: la base; elevada a la potencia W, es tan pequeña que no cuenta.\n- **M**: la mayoría pedida cuando vota todo el mundo.\n- **G**: el gradiente: cuanto mayor es, más alta es la proporción pedida cuando vota poca gente.\n\nSus valores están en «Parámetros por defecto» y «Niveles de inercia»."
|
||||
}
|
||||
},
|
||||
"F1.2": {
|
||||
"label": "1.0.0",
|
||||
"title": {
|
||||
"fr": "Paramètres par défaut",
|
||||
"en": "Default parameters",
|
||||
"es": "Parámetros por defecto"
|
||||
},
|
||||
"body": {
|
||||
"fr": "Les valeurs des lettres de la formule (voir « Formule du seuil WoT ») et la façon dont le vote se déroule.\n\n**Un vote permanent, sans durée**\n\n- Chaque version se vote à tout moment, sans échéance. Chacun peut changer ou retirer son vote : le dernier fait foi.\n- Une proposition entre en vigueur dès qu'elle atteint son seuil. S'il y a déjà un texte en vigueur, il lui faut en plus une marge (voix pour moins voix exigées) plus large que celle de ce texte. Le texte remplacé redevient une proposition, avec ses votes : il peut revenir.\n- Si plusieurs propositions y parviennent, celle qui a la marge la plus large entre en vigueur.\n\n**Les lettres**\n\n- **W** : le nombre de membres de la toile de confiance Ğ1, relu sur la chaîne toutes les 6 heures.\n- **T** : les votes exprimés sur la version, pour et contre.\n- **Q**, le quorum : sous ce nombre de votants, ceux qui manquent comptent comme « non ». Chaque article prend celui de son cran : 12, 12, 50, 50 et 50 dans Monnaie libre, du cran 1 au cran 5. L'auteur de la première version d'un article, un administrateur pour les textes de départ, peut en fixer un autre tant que l'article ne garde la trace d'aucun vote. Dans Monnaie libre, jamais moins de 2 : une seule voix ne fait jamais passer un texte.\n- **B = 0,1** : dans la toile de confiance, `B^W` est si petit qu'il ne compte pas.\n- **M** et **G** : donnés par le cran d'inertie de l'article (voir « Niveaux d'inertie »). G n'est pas fixé à la main : il se déduit de la participation habituelle du groupe.\n\n**Par défaut**\n\n- 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.\n- Au départ, aucun article n'est en vigueur. Raison d'être, Unicité du compte et Réciprocité sont des Propositions seed, mises en avant pour lancer le vote, au cran 2 (basse). Le titre du document est au cran 4 (haute), l'ordre des articles de chaque section au cran 2, tous les autres articles au cran 3.",
|
||||
"en": "The values of the letters of the formula (see “WoT threshold formula”) and how the vote runs.\n\n**A permanent vote, with no duration**\n\n- Every version can be voted on at any time, with no deadline. Anyone can change or withdraw their vote: the last one is the one that counts.\n- A proposal comes into force as soon as it reaches its threshold. If a text is already in force, it also needs a wider margin (votes in favour minus votes required) than that text has. The replaced text becomes a proposal again, with its votes: it can come back.\n- If several proposals get there, the one with the widest margin comes into force.\n\n**The letters**\n\n- **W**: the number of members of the Ğ1 web of trust, read again from the chain every 6 hours.\n- **T**: the votes cast on the version, for and against.\n- **Q**, the quorum: below this number of voters, those who are missing count as votes against. Each article takes the one of its level: 12, 12, 50, 50 and 50 in Monnaie libre, from level 1 to level 5. The author of the first version of an article, an administrator for the starting texts, can set another one as long as the article keeps no trace of any vote. In Monnaie libre, never less than 2: a single vote never passes a text.\n- **B = 0.1**: in the web of trust, `B^W` is so small that it does not count.\n- **M** and **G**: given by the article's level of inertia (see “Inertia levels”). G is not set by hand: it follows from the group's usual turnout.\n\n**By default**\n\n- The default level is 3 (medium): M = 55%, a quorum of 50 in Monnaie libre, and the group asks for 75% of votes in favour when it votes as usual. Every new article starts at this level.\n- At the start, no article is in force. Purpose, Uniqueness of the account and Reciprocity are seed proposals, put forward to start the vote, at level 2 (low). The title of the document is at level 4 (high), the order of the articles of each section at level 2, all other articles at level 3.",
|
||||
"es": "Los valores de las letras de la fórmula (ver «Fórmula del umbral WoT») y cómo se desarrolla la votación.\n\n**Una votación permanente, sin duración**\n\n- Cada versión se vota en cualquier momento, sin plazo. Cada cual puede cambiar o retirar su voto: el último es el que cuenta.\n- Una propuesta entra en vigor en cuanto alcanza su umbral. Si ya hay un texto en vigor, necesita además un margen (votos a favor menos votos exigidos) más amplio que el de ese texto. El texto sustituido vuelve a ser una propuesta, con sus votos: puede volver.\n- Si varias propuestas lo consiguen, entra en vigor la que tiene el margen más amplio.\n\n**Las letras**\n\n- **W**: el número de miembros de la red de confianza Ğ1, leído de nuevo en la cadena cada 6 horas.\n- **T**: los votos emitidos sobre la versión, a favor y en contra.\n- **Q**, el quórum: por debajo de este número de votantes, los que faltan cuentan como «no». Cada artículo toma el de su nivel: 12, 12, 50, 50 y 50 en Monnaie libre, del nivel 1 al nivel 5. El autor de la primera versión de un artículo, un administrador para los textos de partida, puede fijar otro mientras el artículo no guarde rastro de ningún voto. En Monnaie libre, nunca menos de 2: un solo voto nunca hace pasar un texto.\n- **B = 0,1**: en la red de confianza, `B^W` es tan pequeño que no cuenta.\n- **M** y **G**: los da el nivel de inercia del artículo (ver «Niveles de inercia»). G no se fija a mano: se deduce de la participación habitual del grupo.\n\n**Por defecto**\n\n- El nivel por defecto es el 3 (media): M = 55 %, un quórum de 50 en Monnaie libre, y el grupo pide un 75 % de votos a favor cuando vota como de costumbre. Todo artículo nuevo empieza en este nivel.\n- Al inicio, ningún artículo está en vigor. Razón de ser, Unicidad de la cuenta y Reciprocidad son Propuestas seed, puestas en primer plano para lanzar la votación, en el nivel 2 (baja). El título del documento está en el nivel 4 (alta), el orden de los artículos de cada sección en el nivel 2, todos los demás artículos en el nivel 3."
|
||||
}
|
||||
},
|
||||
"N1.1": {
|
||||
"label": "1.0.0",
|
||||
"title": {
|
||||
"fr": "Niveaux d'inertie",
|
||||
"en": "Inertia levels",
|
||||
"es": "Niveles de inercia"
|
||||
},
|
||||
"body": {
|
||||
"fr": "À chaque cran, le groupe demande une part de « pour » parmi les votants (voir « Formule du seuil WoT »). 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 ». 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.\n\n`Part exigée = M + (1 − M) × (1 − (T'/W)^G)`, où T' est le nombre de votants, ou le quorum s'il en manque.\n\n**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.\n\n- **Cran 1 · très basse** : quorum 12 ; 60 % comme d'habitude, 50 % quand tout le monde vote. Aucun article au départ.\n- **Cran 2 · basse** : quorum 12 ; 67 % comme d'habitude, 50 % quand tout le monde vote. Raison d'être, Unicité du compte, Réciprocité, et l'ordre des articles de chaque section.\n- **Cran 3 · moyenne** : quorum 50 ; 75 % comme d'habitude, 55 % quand tout le monde vote. Le cran par défaut : tous les autres articles, et chaque nouvel article.\n- **Cran 4 · haute** : quorum 50 ; 83 % comme d'habitude, 60 % quand tout le monde vote. Le titre du document.\n- **Cran 5 · très haute** : quorum 50 ; 90 % comme d'habitude, 66 % quand tout le monde vote. Aucun article au départ.\n\n**La participation habituelle** est la médiane du nombre de votants des propositions du groupe votées depuis un an. 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. Elle ne dépend pas du quorum d'un article. Elle reste entre 1 % et 50 % de l'électorat, et se mesure une fois par jour.\n\n**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.\n\nExemple : 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.",
|
||||
"en": "At each level, the group asks for a share of votes in favour among the voters (see “WoT threshold formula”). Each level also has its quorum, which is not a minimum number of voters: below the quorum, the voters who are missing count as votes against. The share depends on the level and on the number of voters, compared with the group's usual turnout: beyond the quorum, the more voters, the lower it gets, down to the majority M when everyone votes.\n\n`Share asked = M + (1 − M) × (1 − (T'/W)^G)`, where T' is the number of voters, or the quorum if some are missing.\n\n**The five levels**: the quorum in Monnaie libre, the share asked when the group votes as usual, then when everyone votes, and what is at that level at the start.\n\n- **Level 1 · very low**: quorum 12; 60% as usual, 50% when everyone votes. No article at the start.\n- **Level 2 · low**: quorum 12; 67% as usual, 50% when everyone votes. Purpose, Uniqueness of the account, Reciprocity, and the order of the articles of each section.\n- **Level 3 · medium**: quorum 50; 75% as usual, 55% when everyone votes. The default level: all other articles, and every new article.\n- **Level 4 · high**: quorum 50; 83% as usual, 60% when everyone votes. The title of the document.\n- **Level 5 · very high**: quorum 50; 90% as usual, 66% when everyone votes. No article at the start.\n\n**The usual turnout** is the median number of voters on the group's proposals voted on over the past year. Until the group has voted on 5 proposals, it is twice the document's reference quorum: 100 voters in Monnaie libre. It does not depend on the quorum of an article. It stays between 1% and 50% of the electorate, and is measured once a day.\n\n**G** is computed so that the share asked, at the usual turnout, is exactly that of the level: `G = ln(1 − (s − M)/(1 − M)) / ln(p̃)`, where s is the share asked as usual and p̃ the usual turnout divided by W.\n\nExample: with 7,224 members and a usual turnout of 120 voters, when 120 people vote, it takes 72, 81, 90, 100 or 108 votes in favour, from level 1 to level 5; at level 3, 75% of the 120 voters, i.e. 90. Below the quorum, those missing count as votes against: at level 2 (quorum 12), 9 votes in favour pass with 3 votes against at most; at level 3 (quorum 50), it takes 39 votes in favour, with 11 votes against at most.",
|
||||
"es": "En cada nivel, el grupo pide una proporción de votos a favor entre los votantes (ver «Fórmula del umbral WoT»). Cada nivel tiene además su quórum, que no es un número mínimo de votantes: por debajo del quórum, los votantes que faltan cuentan como «no». La proporción depende del nivel y del número de votantes, comparado con la participación habitual del grupo: más allá del quórum, cuantos más votantes, más baja, hasta la mayoría M cuando vota todo el mundo.\n\n`Proporción exigida = M + (1 − M) × (1 − (T'/W)^G)`, donde T' es el número de votantes, o el quórum si faltan.\n\n**Los cinco niveles**: el quórum en Monnaie libre, la proporción exigida cuando el grupo vota como de costumbre, luego cuando vota todo el mundo, y lo que está en ese nivel al inicio.\n\n- **Nivel 1 · muy baja**: quórum 12; 60 % como de costumbre, 50 % cuando vota todo el mundo. Ningún artículo al inicio.\n- **Nivel 2 · baja**: quórum 12; 67 % como de costumbre, 50 % cuando vota todo el mundo. Razón de ser, Unicidad de la cuenta, Reciprocidad, y el orden de los artículos de cada sección.\n- **Nivel 3 · media**: quórum 50; 75 % como de costumbre, 55 % cuando vota todo el mundo. El nivel por defecto: todos los demás artículos, y todo artículo nuevo.\n- **Nivel 4 · alta**: quórum 50; 83 % como de costumbre, 60 % cuando vota todo el mundo. El título del documento.\n- **Nivel 5 · muy alta**: quórum 50; 90 % como de costumbre, 66 % cuando vota todo el mundo. Ningún artículo al inicio.\n\n**La participación habitual** es la mediana del número de votantes de las propuestas del grupo votadas en el último año. Hasta que el grupo haya votado 5 propuestas, vale dos veces el quórum de referencia del documento: 100 votantes en Monnaie libre. No depende del quórum de un artículo. Se mantiene entre el 1 % y el 50 % del electorado, y se mide una vez al día.\n\n**G** se calcula para que la proporción exigida, con la participación habitual, sea exactamente la del nivel: `G = ln(1 − (s − M)/(1 − M)) / ln(p̃)`, donde s es la proporción exigida como de costumbre y p̃ la participación habitual dividida por W.\n\nEjemplo: con 7224 miembros y una participación habitual de 120 votantes, cuando votan 120 personas, hacen falta 72, 81, 90, 100 o 108 votos a favor, del nivel 1 al nivel 5; en el nivel 3, el 75 % de los 120 votantes, es decir 90. Por debajo del quórum, los que faltan cuentan como «no»: en el nivel 2 (quórum 12), 9 votos a favor pasan con 3 en contra como mucho; en el nivel 3 (quórum 50), hacen falta 39 votos a favor, con 11 en contra como mucho."
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,57 @@
|
||||
{
|
||||
"_source": "seeds/acte-engagement-certification.json au commit 70aa9ba : les quatre articles des règles tels que la production les tient (seed du 21/10, inchangé)",
|
||||
"units": {
|
||||
"F1.2": {
|
||||
"label": "1.0.0",
|
||||
"title": {
|
||||
"fr": "Paramètres par défaut",
|
||||
"en": "Default parameters",
|
||||
"es": "Parámetros por defecto"
|
||||
},
|
||||
"body": {
|
||||
"fr": "**Seuil = M + (1-M) × (1 - (T/W)^G)**\n\n- **T** = votes exprimés (pour + contre)\n- **W** = taille de la toile de confiance\n- **M** = majorité cible (50% par défaut)\n- **G** = gradient d'inertie (0.2 par défaut)\n\n**Valeurs par défaut (inertie standard) :**\n- Durée : 30 jours (vote permanent)\n- Majorité cible M = 50%\n- Gradient G = 0.2\n- Base B = 0.1, Constante C = 0\n\n**Lecture du curseur d'inertie :**\nLe curseur affiché sur chaque section représente le niveau G. Plus le curseur est à droite, plus le remplacement est difficile. La courbe associée montre le seuil requis en fonction de la participation.\n\nMode compact : **D30M50B.1G.2**",
|
||||
"en": "**Seuil = M + (1-M) × (1 - (T/W)^G)**\n\n- **T** = votes cast (for + against)\n- **W** = size of the web of trust\n- **M** = target majority (50% by default)\n- **G** = inertia gradient (0.2 by default)\n\n**Default values (standard inertia):**\n- Duration: 30 days (permanent vote)\n- Target majority M = 50%\n- Gradient G = 0.2\n- Base B = 0.1, Constant C = 0\n\n**Reading the inertia slider:**\nThe slider shown on each section represents the level G. The further to the right the slider, the harder the replacement. The associated curve shows the required threshold as a function of participation.\n\nCompact mode: **D30M50B.1G.2**",
|
||||
"es": "**Seuil = M + (1-M) × (1 - (T/W)^G)**\n\n- **T** = votos emitidos (a favor + en contra)\n- **W** = tamaño de la red de confianza\n- **M** = mayoría objetivo (50% por defecto)\n- **G** = gradiente de inercia (0.2 por defecto)\n\n**Valores por defecto (inercia estándar):**\n- Duración: 30 días (votación permanente)\n- Mayoría objetivo M = 50%\n- Gradiente G = 0.2\n- Base B = 0.1, Constante C = 0\n\n**Lectura del cursor de inercia:**\nEl cursor que se muestra en cada sección representa el nivel G. Cuanto más a la derecha está el cursor, más difícil es la sustitución. La curva asociada muestra el umbral requerido en función de la participación.\n\nModo compacto: **D30M50B.1G.2**"
|
||||
}
|
||||
},
|
||||
"N1": {
|
||||
"label": "1.0.0",
|
||||
"title": {
|
||||
"fr": "Réglage de l'inertie",
|
||||
"en": "Inertia setting",
|
||||
"es": "Ajuste de la inercia"
|
||||
},
|
||||
"body": {
|
||||
"fr": "Le réglage de l'inertie définit la difficulté de remplacement de chaque section du document. Ce réglage est lui-même soumis à l'inertie la plus élevée, pour empêcher la modification des règles de modification.",
|
||||
"en": "The inertia setting defines how difficult it is to replace each section of the document. This setting is itself subject to the highest inertia, to prevent the rules of modification from being modified.",
|
||||
"es": "El ajuste de la inercia define la dificultad de sustitución de cada sección del documento. Este ajuste está a su vez sometido a la inercia más elevada, para impedir la modificación de las reglas de modificación."
|
||||
}
|
||||
},
|
||||
"N1.1": {
|
||||
"label": "1.0.0",
|
||||
"title": {
|
||||
"fr": "Niveaux d'inertie",
|
||||
"en": "Inertia levels",
|
||||
"es": "Niveles de inercia"
|
||||
},
|
||||
"body": {
|
||||
"fr": "**Formule simplifiée :** `Seuil = M + (1-M) × (1 - (T/W)^G)`\n\nLe **gradient G** contrôle la courbe d'inertie : plus G est élevé, plus le seuil reste haut même avec une participation croissante.\n\n**Basse** — G=0.1, M=50%\n- La courbe descend très vite vers 50%\n- Dès 10% de participation, le seuil est proche de la majorité simple\n- *Usage : annexes, recommandations, conseils*\n\n**Standard** — G=0.2, M=50%\n- Descente progressive, équilibre entre stabilité et réactivité\n- À 25% de participation, le seuil avoisine 65%\n- *Usage : engagements fondamentaux et techniques*\n\n**Haute** — G=0.4, M=60%\n- Courbe résistante, le seuil reste élevé longtemps\n- Super-majorité de 60% même à pleine participation\n- *Usage : formule de vote elle-même*\n\n**Très haute** — G=0.6, M=66%\n- Quasi-unanimité requise à tout niveau de participation\n- Seul un consensus massif (66%+) peut modifier ce réglage\n- *Usage : réglage de l'inertie (méta-protection)*\n\nLe curseur d'inertie associé à chaque section du document reflète visuellement ces niveaux.",
|
||||
"en": "**Simplified formula:** `Seuil = M + (1-M) × (1 - (T/W)^G)`\n\nThe **gradient G** controls the inertia curve: the higher G is, the higher the threshold remains, even as participation increases.\n\n**Low** — G=0.1, M=50%\n- The curve drops very quickly towards 50%\n- From 10% participation, the threshold is close to a simple majority\n- *Use: annexes, recommendations, advice*\n\n**Standard** — G=0.2, M=50%\n- Gradual descent, balance between stability and responsiveness\n- At 25% participation, the threshold is around 65%\n- *Use: fundamental and technical commitments*\n\n**High** — G=0.4, M=60%\n- Resistant curve, the threshold stays high for a long time\n- Supermajority of 60% even at full participation\n- *Use: the voting formula itself*\n\n**Very high** — G=0.6, M=66%\n- Near-unanimity required at every level of participation\n- Only a massive consensus (66%+) can modify this setting\n- *Use: the inertia setting (meta-protection)*\n\nThe inertia slider associated with each section of the document visually reflects these levels.",
|
||||
"es": "**Fórmula simplificada:** `Seuil = M + (1-M) × (1 - (T/W)^G)`\n\nEl **gradiente G** controla la curva de inercia: cuanto más alto es G, más alto se mantiene el umbral, incluso con una participación creciente.\n\n**Baja** — G=0.1, M=50%\n- La curva desciende muy rápido hacia el 50%\n- A partir del 10% de participación, el umbral se acerca a la mayoría simple\n- *Uso: anexos, recomendaciones, consejos*\n\n**Estándar** — G=0.2, M=50%\n- Descenso progresivo, equilibrio entre estabilidad y reactividad\n- Con un 25% de participación, el umbral ronda el 65%\n- *Uso: compromisos fundamentales y técnicos*\n\n**Alta** — G=0.4, M=60%\n- Curva resistente, el umbral se mantiene elevado durante mucho tiempo\n- Supermayoría del 60% incluso con participación plena\n- *Uso: la propia fórmula de votación*\n\n**Muy alta** — G=0.6, M=66%\n- Se requiere casi unanimidad con cualquier nivel de participación\n- Solo un consenso masivo (66%+) puede modificar este ajuste\n- *Uso: el ajuste de la inercia (metaprotección)*\n\nEl cursor de inercia asociado a cada sección del documento refleja visualmente estos niveles."
|
||||
}
|
||||
},
|
||||
"O1": {
|
||||
"label": "1.0.0",
|
||||
"title": {
|
||||
"fr": "Ordonnancement du document",
|
||||
"en": "Ordering of the document",
|
||||
"es": "Ordenación del documento"
|
||||
},
|
||||
"body": {
|
||||
"fr": "L'ordre de présentation des items dans le document est lui-même soumis au vote. Toute proposition de réorganisation doit atteindre le seuil d'adoption avec l'inertie standard.",
|
||||
"en": "The order in which items are presented in the document is itself subject to vote. Any reorganisation proposal must reach the adoption threshold with standard inertia.",
|
||||
"es": "El orden de presentación de los elementos en el documento está a su vez sometido a votación. Toda propuesta de reorganización debe alcanzar el umbral de adopción con la inercia estándar."
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,700 @@
|
||||
//! Inertia in five levels (ADR 0007): the levels and the scale served, the
|
||||
//! usual participation of a group, the change of inertia voted at the level
|
||||
//! of its article, the migration of the four words of before, and the
|
||||
//! amendment that raised E1 and E2 to level 4 in an existing base (30/10; since
|
||||
//! 2026-11-02 the seed puts them forward at a low level, ADR 0011: the scenarios
|
||||
//! of 30/10 are replayed on the seed production ran then).
|
||||
|
||||
use axum::body::Body;
|
||||
use axum::http::{Request, StatusCode, header};
|
||||
use http_body_util::BodyExt;
|
||||
use ld_engine::inertia::{InertiaLevel, Scale, gradient};
|
||||
use ld_engine::threshold::{judged_voters, required_ratio};
|
||||
use ld_server::{AppState, CSRF_HEADER, CSRF_VALUE, Config, app, open_db, seed};
|
||||
use serde_json::{Value, json};
|
||||
use sqlx::SqlitePool;
|
||||
use std::borrow::Cow;
|
||||
use tower::ServiceExt;
|
||||
|
||||
struct Client {
|
||||
router: axum::Router,
|
||||
cookie: Option<String>,
|
||||
}
|
||||
|
||||
impl Client {
|
||||
fn of(state: &AppState) -> Self {
|
||||
Self { router: app(state.clone()), cookie: None }
|
||||
}
|
||||
|
||||
async fn new() -> (Self, AppState) {
|
||||
let db = open_db("sqlite::memory:").await.expect("db");
|
||||
let state = AppState::new(Config::for_tests(), db).await.expect("state");
|
||||
seed::ensure(&state).await.expect("seed");
|
||||
(Self::of(&state), state)
|
||||
}
|
||||
|
||||
fn other(&self) -> Self {
|
||||
Self { router: self.router.clone(), cookie: None }
|
||||
}
|
||||
|
||||
async fn call(&mut self, method: &str, uri: &str, body: Option<Value>) -> (StatusCode, Value) {
|
||||
let mut req = Request::builder().method(method).uri(uri).header(CSRF_HEADER, CSRF_VALUE);
|
||||
if let Some(c) = &self.cookie {
|
||||
req = req.header(header::COOKIE, c);
|
||||
}
|
||||
let req = match body {
|
||||
Some(b) => req.header(header::CONTENT_TYPE, "application/json").body(Body::from(b.to_string())),
|
||||
None => req.body(Body::empty()),
|
||||
}
|
||||
.expect("request");
|
||||
let res = self.router.clone().oneshot(req).await.expect("response");
|
||||
let status = res.status();
|
||||
if let Some(set) = res.headers().get(header::SET_COOKIE) {
|
||||
let raw = set.to_str().expect("cookie").split(';').next().unwrap_or_default().to_owned();
|
||||
if raw.starts_with("ld_session=") {
|
||||
self.cookie = Some(raw);
|
||||
}
|
||||
}
|
||||
let bytes = res.into_body().collect().await.expect("body").to_bytes();
|
||||
(status, serde_json::from_slice(&bytes).unwrap_or(Value::Null))
|
||||
}
|
||||
|
||||
async fn get(&mut self, uri: &str) -> Value {
|
||||
let (s, v) = self.call("GET", uri, None).await;
|
||||
assert_eq!(s, StatusCode::OK, "{uri}: {v}");
|
||||
v
|
||||
}
|
||||
|
||||
async fn login(&mut self, name: &str) {
|
||||
let (s, v) = self.call("POST", "/api/auth/dev", Some(json!({ "name": name, "admin": false }))).await;
|
||||
assert_eq!(s, StatusCode::OK, "{v}");
|
||||
}
|
||||
|
||||
async fn vote(&mut self, proposal: &str, choice: &str) -> Value {
|
||||
let (s, v) =
|
||||
self.call("POST", &format!("/api/proposals/{proposal}/vote"), Some(json!({ "choice": choice }))).await;
|
||||
assert_eq!(s, StatusCode::OK, "{v}");
|
||||
v
|
||||
}
|
||||
|
||||
async fn doc(&mut self, group: &str) -> Value {
|
||||
let g = self.get(&format!("/api/groups/{group}")).await;
|
||||
let id = g["docs"][0]["id"].as_str().expect("doc").to_owned();
|
||||
self.get(&format!("/api/docs/{id}")).await
|
||||
}
|
||||
}
|
||||
|
||||
fn unit<'a>(doc: &'a Value, code: &str) -> &'a Value {
|
||||
doc["units"].as_array().expect("units").iter().find(|u| u["code"] == code).expect("unit")
|
||||
}
|
||||
|
||||
fn level(k: u8) -> InertiaLevel {
|
||||
InertiaLevel::new(k).expect("1 to 5")
|
||||
}
|
||||
|
||||
/// The level seed proposals start at (ADR 0011).
|
||||
const LOW: u8 = ld_server::seed::SEED_PROPOSAL_LEVEL;
|
||||
|
||||
/// The quorums of Monnaie libre by level (ADR 0013, Yvv, 2026-11-02).
|
||||
const QUORUMS: [u32; 5] = [12, 12, 50, 50, 50];
|
||||
|
||||
/// The seed production runs on 2026-11-02 (3f10a0e): I1, E1, E2 in force, E1 and E2 at 4.
|
||||
fn production_seed_dir() -> std::path::PathBuf {
|
||||
std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("tests/fixtures/seed-production-2026-11-02")
|
||||
}
|
||||
|
||||
fn numbers(levels: &Value, key: &str) -> Vec<f64> {
|
||||
levels.as_array().expect("levels").iter().map(|l| l[key].as_f64().expect(key)).collect()
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn the_five_levels_and_the_scale_are_served() {
|
||||
let (mut c, _) = Client::new().await;
|
||||
c.login("Chiara").await;
|
||||
let doc = c.doc("monnaie-libre").await;
|
||||
// 3 everywhere, low for the four seed proposals (ADR 0011; E12 since lot T4).
|
||||
let other: Vec<&str> = doc["units"]
|
||||
.as_array()
|
||||
.expect("units")
|
||||
.iter()
|
||||
.filter(|u| u["inertia"] != 3)
|
||||
.map(|u| u["code"].as_str().expect("code"))
|
||||
.collect();
|
||||
assert_eq!(other, ["I1", "E1", "E12", "E2"]);
|
||||
assert!(["I1", "E1", "E12", "E2"].iter().all(|c| unit(&doc, c)["inertia"] == LOW));
|
||||
|
||||
// No vote yet: the reference, 2C/W = 100 voters out of 5745.
|
||||
let p = &doc["doc"]["participation"];
|
||||
assert_eq!(
|
||||
(p["source"].as_str(), p["sample"].as_u64(), p["voters"].as_u64()),
|
||||
(Some("reference"), Some(0), Some(100))
|
||||
);
|
||||
assert_eq!(
|
||||
(p["minSample"].as_u64(), p["windowDays"].as_u64(), p["everyHours"].as_u64()),
|
||||
(Some(5), Some(365), Some(24))
|
||||
);
|
||||
let ratio = p["ratio"].as_f64().expect("ratio");
|
||||
assert!((ratio - 100.0 / 5745.0).abs() < 1e-12);
|
||||
|
||||
let levels = &doc["levels"];
|
||||
assert_eq!(numbers(levels, "level"), [1.0, 2.0, 3.0, 4.0, 5.0]);
|
||||
assert_eq!(numbers(levels, "majorityPct"), [50.0, 50.0, 55.0, 60.0, 66.0]);
|
||||
assert_eq!(numbers(levels, "sharePct"), [60.0, 67.0, 75.0, 83.0, 90.0]);
|
||||
// The quorums by level (ADR 0013, Yvv: « 12 · 12 · 50 · 50 · 50 »).
|
||||
assert_eq!(numbers(levels, "quorum"), [12.0, 12.0, 50.0, 50.0, 50.0]);
|
||||
let scale = Scale { electorate: 5745, participation: ratio, quorums: QUORUMS };
|
||||
for (l, k) in levels.as_array().expect("levels").iter().zip(InertiaLevel::ALL) {
|
||||
assert!((l["gradient"].as_f64().expect("G") - gradient(k, ratio)).abs() < 1e-12);
|
||||
// At the usual participation, above every quorum: s_k of the voters, nothing in front.
|
||||
assert_eq!(l["cast"], 100);
|
||||
let q = scale.quorum(k, None);
|
||||
assert_eq!(l["required"].as_u64(), Some(u64::from(scale.required(100, k, q).expect("valid"))));
|
||||
assert_eq!(l["required"].as_f64(), Some(k.share_pct().ceil()));
|
||||
}
|
||||
assert_eq!(numbers(levels, "required"), [60.0, 67.0, 75.0, 83.0, 90.0]);
|
||||
|
||||
// A version: the five levels at its own participation, and the level it is voted at.
|
||||
let e1 = unit(&doc, "E1")["proposals"][0]["id"].as_str().expect("e1").to_owned();
|
||||
let d = c.get(&format!("/api/proposals/{e1}")).await;
|
||||
assert_eq!(d["proposal"]["votedAtInertia"], LOW);
|
||||
assert_eq!(numbers(&d["levels"], "cast"), [0.0; 5]);
|
||||
// No vote: judged on the quorum, the voters missing counted as against (ADR 0013).
|
||||
assert_eq!(numbers(&d["levels"], "required"), [8.0, 9.0, 39.0, 43.0, 46.0], "the quorum, not C");
|
||||
assert_eq!(d["participation"]["source"], "reference");
|
||||
assert_eq!(d["electorateSource"], "wot");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn a_change_of_inertia_is_voted_at_the_level_of_its_article_and_journaled() {
|
||||
let (mut a, state) = Client::new().await;
|
||||
a.login("Yvv").await;
|
||||
let mut b = a.other();
|
||||
b.login("Chiara").await;
|
||||
let doc = a.doc("essai").await;
|
||||
let e2 = unit(&doc, "E2")["id"].as_str().expect("e2").to_owned();
|
||||
let to = |t: Value| json!({ "target": t });
|
||||
for bad in [json!(0), json!(6), json!("haute"), json!(null)] {
|
||||
let (s, v) = a.call("POST", &format!("/api/units/{e2}/inertia"), Some(to(bad.clone()))).await;
|
||||
assert_eq!((s, v["error"].as_str()), (StatusCode::BAD_REQUEST, Some("inertia")), "{bad}");
|
||||
}
|
||||
let (s, v) = a.call("POST", &format!("/api/units/{e2}/inertia"), Some(to(json!(LOW)))).await;
|
||||
assert_eq!((s, v["error"].as_str()), (StatusCode::BAD_REQUEST, Some("same_inertia")));
|
||||
let (s, v) = a.call("POST", &format!("/api/units/{e2}/inertia"), Some(to(json!(5)))).await;
|
||||
assert_eq!(s, StatusCode::OK, "{v}");
|
||||
let p = v["id"].as_str().expect("id").to_owned();
|
||||
let d = a.get(&format!("/api/proposals/{p}")).await;
|
||||
assert_eq!(
|
||||
(d["proposal"]["inertiaTarget"].as_u64(), d["proposal"]["votedAtInertia"].as_u64()),
|
||||
(Some(5), Some(u64::from(LOW)))
|
||||
);
|
||||
// W(essai) = 2, the quorum 1 everywhere in the test group (ADR 0013): one voice
|
||||
// passes, the article goes to level 5.
|
||||
let v = a.vote(&p, "for").await;
|
||||
assert_eq!(v["entered"][0]["kind"], "inertia");
|
||||
let doc = a.doc("essai").await;
|
||||
let e2u = unit(&doc, "E2");
|
||||
assert_eq!(e2u["inertia"], 5);
|
||||
let changes = e2u["inertiaChanges"].as_array().expect("changes");
|
||||
assert_eq!(changes.len(), 1);
|
||||
assert_eq!(
|
||||
(changes[0]["from"].as_u64(), changes[0]["to"].as_u64(), changes[0]["cause"].as_str()),
|
||||
(Some(u64::from(LOW)), Some(5), Some("vote"))
|
||||
);
|
||||
// The adoption keeps the level it was judged at, and the scale.
|
||||
let params: String = sqlx::query_scalar("SELECT params FROM adoptions WHERE proposal_id = ?")
|
||||
.bind(&p)
|
||||
.fetch_one(&state.db)
|
||||
.await
|
||||
.expect("adoption");
|
||||
let params: Value = serde_json::from_str(¶ms).expect("json");
|
||||
let judged = Scale { electorate: 2, participation: 0.5, quorums: [1; 5] }.params(level(LOW), 1);
|
||||
assert_eq!(
|
||||
(params["level"].as_u64(), params["majority_pct"].as_f64(), params["share_pct"].as_f64()),
|
||||
(Some(u64::from(LOW)), Some(judged.majority_pct), Some(level(LOW).share_pct()))
|
||||
);
|
||||
assert_eq!((params["quorum"].as_u64(), params.get("constant")), (Some(1), None), "the quorum, no C");
|
||||
assert_eq!(
|
||||
(params["participation"].as_f64(), params["participation_source"].as_str()),
|
||||
(Some(0.5), Some("reference"))
|
||||
);
|
||||
assert!(params["gradient"].as_f64().is_some_and(|g| (g - gradient(level(LOW), 0.5)).abs() < 1e-12));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn the_usual_participation_is_the_median_of_five_voted_proposals() {
|
||||
let (mut first, state) = Client::new().await;
|
||||
// Ten people, all members of the test group: W = 10, C = 1.
|
||||
let mut people = Vec::new();
|
||||
for name in ["P1", "P2", "P3", "P4", "P5", "P6", "P7", "P8", "P9", "P10"] {
|
||||
let mut c = first.other();
|
||||
c.login(name).await;
|
||||
people.push(c);
|
||||
}
|
||||
first.login("P1").await;
|
||||
let doc = first.doc("essai").await;
|
||||
let open: Vec<String> = ["I2", "T1", "E4", "E5", "E6"]
|
||||
.iter()
|
||||
.map(|code| unit(&doc, code)["proposals"][0]["id"].as_str().expect("open").to_owned())
|
||||
.collect();
|
||||
// 1, 2, 3, 3 and 4 voters (against: nothing enters into force, the count is what matters).
|
||||
for (p, n) in open.iter().zip([1, 2, 3, 3, 4]) {
|
||||
for c in people.iter_mut().take(n) {
|
||||
c.vote(p, "against").await;
|
||||
}
|
||||
}
|
||||
// Measured once a day, not at each vote: until then, the reference.
|
||||
let doc = first.doc("essai").await;
|
||||
assert_eq!(doc["doc"]["participation"]["source"], "reference");
|
||||
assert_eq!(doc["doc"]["electorate"], 10);
|
||||
|
||||
let measured = ld_server::participation::refresh(&state, false).await.expect("refresh");
|
||||
assert!(measured.contains(&"essai".to_owned()));
|
||||
let doc = first.doc("essai").await;
|
||||
let p = &doc["doc"]["participation"];
|
||||
assert_eq!(
|
||||
(p["source"].as_str(), p["sample"].as_u64(), p["median"].as_f64()),
|
||||
(Some("median"), Some(5), Some(3.0))
|
||||
);
|
||||
assert_eq!((p["ratio"].as_f64(), p["voters"].as_u64(), p["bounded"].as_bool()), (Some(0.3), Some(3), Some(false)));
|
||||
assert!(p["measuredAt"].is_string());
|
||||
for (l, k) in doc["levels"].as_array().expect("levels").iter().zip(InertiaLevel::ALL) {
|
||||
assert!((l["gradient"].as_f64().expect("G") - gradient(k, 0.3)).abs() < 1e-12);
|
||||
}
|
||||
// Thresholds follow: the proposal with 4 voters is judged on this scale.
|
||||
let d = first.get(&format!("/api/proposals/{}", open[4])).await;
|
||||
let scale = Scale { electorate: 10, participation: 0.3, quorums: [1; 5] };
|
||||
let ratio = d["proposal"]["evaluation"]["requiredRatio"].as_f64().expect("ratio");
|
||||
assert!((ratio - required_ratio(10, 4, &scale.params(level(3), 1))).abs() < 1e-12);
|
||||
|
||||
// A new vote does not move it within the day…
|
||||
people[4].vote(&open[0], "against").await;
|
||||
assert!(ld_server::participation::refresh(&state, false).await.expect("refresh").is_empty());
|
||||
assert_eq!(first.doc("essai").await["doc"]["participation"]["median"], 3.0);
|
||||
// …the next measure sees it: 2, 2, 3, 3, 4.
|
||||
ld_server::participation::refresh(&state, true).await.expect("refresh");
|
||||
assert_eq!(first.doc("essai").await["doc"]["participation"]["median"], 3.0);
|
||||
|
||||
// A proposal last voted on more than a year ago leaves the sample: four are too few.
|
||||
sqlx::query("UPDATE votes SET cast_at = '2025-01-01T00:00:00Z' WHERE proposal_id = ?")
|
||||
.bind(&open[2])
|
||||
.execute(&state.db)
|
||||
.await
|
||||
.expect("age");
|
||||
ld_server::participation::refresh(&state, true).await.expect("refresh");
|
||||
let p = first.doc("essai").await["doc"]["participation"].clone();
|
||||
assert_eq!(
|
||||
(p["source"].as_str(), p["sample"].as_u64(), p["median"].as_f64()),
|
||||
(Some("reference"), Some(4), Some(2.5))
|
||||
);
|
||||
// The reference of the test group: 2C/W = 20 %.
|
||||
assert_eq!(p["ratio"].as_f64(), Some(0.2));
|
||||
// Monnaie libre is measured apart, from its own votes (none).
|
||||
let ml = first.doc("monnaie-libre").await;
|
||||
assert_eq!(
|
||||
(ml["doc"]["participation"]["sample"].as_u64(), ml["doc"]["participation"]["source"].as_str()),
|
||||
(Some(0), Some("reference"))
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn a_withdrawn_proposal_leaves_the_usual_participation() {
|
||||
// Relecture of a576b68: withdrawn proposals vanish from the document and their
|
||||
// votes can no longer be taken back, so they must not weigh on the median.
|
||||
let (mut first, state) = Client::new().await;
|
||||
let mut people = Vec::new();
|
||||
for name in ["W1", "W2", "W3", "W4"] {
|
||||
let mut c = first.other();
|
||||
c.login(name).await;
|
||||
people.push(c);
|
||||
}
|
||||
first.login("W1").await;
|
||||
let doc = first.doc("essai").await;
|
||||
let open: Vec<String> = ["I2", "T1", "E4", "E5", "E6"]
|
||||
.iter()
|
||||
.map(|code| unit(&doc, code)["proposals"][0]["id"].as_str().expect("open").to_owned())
|
||||
.collect();
|
||||
for p in &open {
|
||||
for c in people.iter_mut().take(2) {
|
||||
c.vote(p, "against").await;
|
||||
}
|
||||
}
|
||||
ld_server::participation::refresh(&state, true).await.expect("refresh");
|
||||
let p = first.doc("essai").await["doc"]["participation"].clone();
|
||||
assert_eq!((p["source"].as_str(), p["sample"].as_u64()), (Some("median"), Some(5)));
|
||||
|
||||
sqlx::query("UPDATE proposals SET status = 'withdrawn' WHERE id = ?")
|
||||
.bind(&open[0])
|
||||
.execute(&state.db)
|
||||
.await
|
||||
.expect("withdraw");
|
||||
ld_server::participation::refresh(&state, true).await.expect("refresh");
|
||||
let p = first.doc("essai").await["doc"]["participation"].clone();
|
||||
assert_eq!((p["source"].as_str(), p["sample"].as_u64()), (Some("reference"), Some(4)));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn the_usual_participation_counts_the_statuses_it_names_and_only_those() {
|
||||
// Relecture of 71819f9: the list is explicit — open, in force, applied. A decision
|
||||
// taken (a change of inertia applied) keeps its voters; a status not named stays out.
|
||||
let (mut first, state) = Client::new().await;
|
||||
let mut people = Vec::new();
|
||||
for name in ["S1", "S2"] {
|
||||
let mut c = first.other();
|
||||
c.login(name).await;
|
||||
people.push(c);
|
||||
}
|
||||
first.login("S1").await;
|
||||
let doc = first.doc("essai").await;
|
||||
let open: Vec<String> = ["I2", "T1", "E4", "E5", "E6", "E8"]
|
||||
.iter()
|
||||
.map(|code| unit(&doc, code)["proposals"][0]["id"].as_str().expect("open").to_owned())
|
||||
.collect();
|
||||
for p in &open {
|
||||
for c in &mut people {
|
||||
c.vote(p, "against").await;
|
||||
}
|
||||
}
|
||||
let sample = |state: AppState| async move {
|
||||
ld_server::participation::refresh(&state, true).await.expect("refresh");
|
||||
ld_server::participation::stored(&state, "essai").await.expect("stored").expect("measure").sample
|
||||
};
|
||||
assert_eq!(sample(state.clone()).await, 6);
|
||||
for (id, status) in [(&open[0], "applied"), (&open[1], "in_force")] {
|
||||
sqlx::query("UPDATE proposals SET status = ? WHERE id = ?")
|
||||
.bind(status)
|
||||
.bind(id)
|
||||
.execute(&state.db)
|
||||
.await
|
||||
.expect("status");
|
||||
}
|
||||
assert_eq!(sample(state.clone()).await, 6, "applied and in force stay counted");
|
||||
sqlx::query("UPDATE proposals SET status = 'superseded' WHERE id = ?")
|
||||
.bind(&open[2])
|
||||
.execute(&state.db)
|
||||
.await
|
||||
.expect("status");
|
||||
assert_eq!(sample(state.clone()).await, 5, "a status not named is out");
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// The base as production holds it on 2026-10-30, then the new version
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// The seed as it was then: no modules yet (ADR 0009 came after), I1, E1 and E2 in force.
|
||||
fn seed_of_the_time(tag: &str) -> std::path::PathBuf {
|
||||
let today = production_seed_dir().join("acte-engagement-certification.json");
|
||||
let mut seed: Value = serde_json::from_str(&std::fs::read_to_string(today).expect("seed")).expect("json");
|
||||
seed.as_object_mut().expect("object").remove("modules");
|
||||
let dir = std::env::temp_dir().join(format!("ld-levels-seed-{tag}-{}", std::process::id()));
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
std::fs::create_dir_all(&dir).expect("dir");
|
||||
std::fs::write(dir.join("acte-engagement-certification.json"), seed.to_string()).expect("write");
|
||||
dir
|
||||
}
|
||||
|
||||
/// Migrations up to 0004: the schema production runs before this version.
|
||||
async fn base_before_levels(tag: &str) -> (SqlitePool, AppState) {
|
||||
use sqlx::sqlite::{SqliteConnectOptions, SqlitePoolOptions};
|
||||
use std::str::FromStr;
|
||||
let options = SqliteConnectOptions::from_str("sqlite::memory:").expect("url").foreign_keys(true);
|
||||
let pool = SqlitePoolOptions::new().max_connections(1).connect_with(options).await.expect("db");
|
||||
let mut before = sqlx::migrate!("./migrations");
|
||||
before.migrations = Cow::Owned(before.migrations.iter().filter(|m| m.version <= 4).cloned().collect());
|
||||
before.run(&pool).await.expect("migrations 1 to 4");
|
||||
let mut cfg = Config::for_tests();
|
||||
cfg.data_dir = std::env::temp_dir().join(format!("ld-levels-{tag}-{}", std::process::id()));
|
||||
cfg.seed_dir = seed_of_the_time(tag);
|
||||
let state = AppState::new(cfg, pool.clone()).await.expect("state");
|
||||
seed::documents(&state).await.expect("documents");
|
||||
// The words of the time: every article `standard`.
|
||||
sqlx::query("UPDATE units SET inertia = 'standard'").execute(&pool).await.expect("words");
|
||||
(pool, state)
|
||||
}
|
||||
|
||||
/// The new version starts: migration 0005, then the seed and its amendments.
|
||||
async fn start_new_version(pool: &SqlitePool) -> AppState {
|
||||
let mut all = sqlx::migrate!("./migrations");
|
||||
all.set_ignore_missing(true);
|
||||
all.run(pool).await.expect("migrations");
|
||||
restart(pool).await
|
||||
}
|
||||
|
||||
/// Started again with the seed production ran after 30/10 (E1 and E2 at 4).
|
||||
async fn restart(pool: &SqlitePool) -> AppState {
|
||||
let mut cfg = Config::for_tests();
|
||||
cfg.seed_dir = production_seed_dir();
|
||||
let state = AppState::new(cfg, pool.clone()).await.expect("state");
|
||||
seed::ensure(&state).await.expect("seed");
|
||||
state
|
||||
}
|
||||
|
||||
async fn unit_id(pool: &SqlitePool, group: &str, code: &str) -> String {
|
||||
sqlx::query_scalar(
|
||||
"SELECT u.id FROM units u JOIN documents d ON d.id = u.doc_id WHERE d.group_slug = ? AND u.code = ?",
|
||||
)
|
||||
.bind(group)
|
||||
.bind(code)
|
||||
.fetch_one(pool)
|
||||
.await
|
||||
.expect("unit")
|
||||
}
|
||||
|
||||
/// An open change of inertia written as production holds it (a word, no vote).
|
||||
async fn legacy_inertia_proposal(pool: &SqlitePool, unit: &str, word: &str) -> String {
|
||||
let id = format!("legacy-{unit}-{word}");
|
||||
sqlx::query(
|
||||
"INSERT INTO proposals (id, unit_id, kind, inertia_target, title, body, status, origin, author_id, revision,
|
||||
created_at, updated_at, submitted_at)
|
||||
VALUES (?, ?, 'inertia', ?, ?, '', 'open', 'member', NULL, 1, '2026-10-29T21:54:00Z', '2026-10-29T21:54:00Z',
|
||||
'2026-10-29T21:54:00Z')",
|
||||
)
|
||||
.bind(&id)
|
||||
.bind(unit)
|
||||
.bind(word)
|
||||
.bind(format!("inertia:{word}"))
|
||||
.execute(pool)
|
||||
.await
|
||||
.expect("proposal");
|
||||
id
|
||||
}
|
||||
|
||||
type Decision = (String, String, String, Option<String>);
|
||||
|
||||
async fn inertia_decisions(pool: &SqlitePool) -> Vec<Decision> {
|
||||
sqlx::query_as(
|
||||
"SELECT d.group_slug, a.unit_code, a.outcome, a.reason FROM inertia_amendments a
|
||||
JOIN documents d ON d.id = a.doc_id ORDER BY d.group_slug, a.unit_code",
|
||||
)
|
||||
.fetch_all(pool)
|
||||
.await
|
||||
.expect("decisions")
|
||||
}
|
||||
|
||||
fn decided(rows: &[Decision]) -> Vec<(&str, &str, &str, Option<&str>)> {
|
||||
rows.iter().map(|(g, u, o, r)| (g.as_str(), u.as_str(), o.as_str(), r.as_deref())).collect()
|
||||
}
|
||||
|
||||
/// Since 2026-11-02 the seed gives E1 and E2 the low level of seed proposals: the
|
||||
/// raise of 30/10 is superseded, decided in production and nowhere else.
|
||||
#[tokio::test]
|
||||
async fn a_new_base_starts_low_and_the_raise_of_30_10_decides_nothing() {
|
||||
let (mut c, state) = Client::new().await;
|
||||
assert_eq!(decided(&inertia_decisions(&state.db).await), []);
|
||||
let changes: i64 =
|
||||
sqlx::query_scalar("SELECT COUNT(*) FROM inertia_changes").fetch_one(&state.db).await.expect("n");
|
||||
assert_eq!(changes, 0, "born low: nothing changed");
|
||||
c.login("Chiara").await;
|
||||
let doc = c.doc("monnaie-libre").await;
|
||||
assert!(["E1", "E2"].iter().all(|code| unit(&doc, code)["inertia"] == LOW));
|
||||
// On the seed of production, the raise is still there to decide: present at birth.
|
||||
let mut cfg = Config::for_tests();
|
||||
cfg.seed_dir = production_seed_dir();
|
||||
let db = open_db("sqlite::memory:").await.expect("db");
|
||||
let born = AppState::new(cfg, db).await.expect("state");
|
||||
seed::ensure(&born).await.expect("seed");
|
||||
assert_eq!(
|
||||
decided(&inertia_decisions(&born.db).await),
|
||||
[
|
||||
("essai", "E1", "present", None),
|
||||
("essai", "E2", "present", None),
|
||||
("monnaie-libre", "E1", "present", None),
|
||||
("monnaie-libre", "E2", "present", None),
|
||||
]
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn the_migration_turns_the_four_words_into_levels_and_keeps_them() {
|
||||
let (pool, _) = base_before_levels("migration").await;
|
||||
let i1 = unit_id(&pool, "monnaie-libre", "I1").await;
|
||||
let e3 = unit_id(&pool, "monnaie-libre", "E3").await;
|
||||
for (unit, word) in [(&i1, "low"), (&e3, "high")] {
|
||||
sqlx::query("UPDATE units SET inertia = ? WHERE id = ?")
|
||||
.bind(word)
|
||||
.bind(unit)
|
||||
.execute(&pool)
|
||||
.await
|
||||
.expect("word");
|
||||
}
|
||||
let p = legacy_inertia_proposal(&pool, &e3, "max").await;
|
||||
// An adoption of before: its params are the letters it was judged by.
|
||||
let old_params = r#"{"majority_pct":50.0,"gradient":0.2,"base":0.1,"constant":50.0}"#;
|
||||
sqlx::query(
|
||||
"INSERT INTO adoptions (proposal_id, unit_id, doc_id, kind, votes_for, votes_against, electorate, required, params, at)
|
||||
SELECT 'past', ?, doc_id, 'text', 60, 0, 7224, 58, ?, '2026-10-25T00:00:00Z' FROM units WHERE id = ?",
|
||||
)
|
||||
.bind(&e3)
|
||||
.bind(old_params)
|
||||
.bind(&e3)
|
||||
.execute(&pool)
|
||||
.await
|
||||
.expect("adoption");
|
||||
|
||||
let mut all = sqlx::migrate!("./migrations");
|
||||
all.set_ignore_missing(true);
|
||||
all.run(&pool).await.expect("migration 0005");
|
||||
|
||||
let words: Vec<(String, String, Option<String>)> =
|
||||
sqlx::query_as("SELECT code, inertia, inertia_legacy FROM units WHERE doc_id = (SELECT doc_id FROM units WHERE id = ?) AND code IN ('I1', 'E1', 'E3') ORDER BY code")
|
||||
.bind(&i1)
|
||||
.fetch_all(&pool)
|
||||
.await
|
||||
.expect("units");
|
||||
assert_eq!(
|
||||
words,
|
||||
[
|
||||
("E1".into(), "3".into(), Some("standard".into())),
|
||||
("E3".into(), "4".into(), Some("high".into())),
|
||||
("I1".into(), "2".into(), Some("low".into())),
|
||||
]
|
||||
);
|
||||
let target: (Option<String>, Option<String>) =
|
||||
sqlx::query_as("SELECT inertia_target, inertia_target_legacy FROM proposals WHERE id = ?")
|
||||
.bind(&p)
|
||||
.fetch_one(&pool)
|
||||
.await
|
||||
.expect("proposal");
|
||||
assert_eq!(target, (Some("5".into()), Some("max".into())));
|
||||
let kept: String = sqlx::query_scalar("SELECT params FROM adoptions WHERE proposal_id = 'past'")
|
||||
.fetch_one(&pool)
|
||||
.await
|
||||
.expect("params");
|
||||
assert_eq!(kept, old_params, "the history is never rewritten");
|
||||
}
|
||||
|
||||
/// Production on 2026-10-30 (orch): every article `standard`, no vote, and two open
|
||||
/// changes of inertia at zero votes, I1 → max and E1 → max.
|
||||
#[tokio::test]
|
||||
async fn e1_and_e2_go_to_level_4_once_and_an_open_change_stays_open_voted_at_4() {
|
||||
let (pool, _) = base_before_levels("production").await;
|
||||
let mut proposals = Vec::new();
|
||||
for group in ["monnaie-libre", "essai"] {
|
||||
for code in ["I1", "E1"] {
|
||||
let u = unit_id(&pool, group, code).await;
|
||||
proposals.push((group, code, legacy_inertia_proposal(&pool, &u, "max").await));
|
||||
}
|
||||
}
|
||||
let state = start_new_version(&pool).await;
|
||||
let first = inertia_decisions(&pool).await;
|
||||
assert_eq!(
|
||||
decided(&first),
|
||||
[
|
||||
("essai", "E1", "applied", None),
|
||||
("essai", "E2", "applied", None),
|
||||
("monnaie-libre", "E1", "applied", None),
|
||||
("monnaie-libre", "E2", "applied", None),
|
||||
]
|
||||
);
|
||||
let mut c = Client::of(&state);
|
||||
c.login("Yvv").await;
|
||||
for group in ["monnaie-libre", "essai"] {
|
||||
let doc = c.doc(group).await;
|
||||
let levels: Vec<(&str, u64)> = ["I1", "E1", "E2", "E3"]
|
||||
.iter()
|
||||
.map(|code| (*code, unit(&doc, code)["inertia"].as_u64().expect("level")))
|
||||
.collect();
|
||||
assert_eq!(levels, [("I1", 3), ("E1", 4), ("E2", 4), ("E3", 3)], "{group}");
|
||||
// The article says so.
|
||||
let changes = unit(&doc, "E1")["inertiaChanges"].as_array().expect("changes").clone();
|
||||
assert_eq!(changes.len(), 1);
|
||||
assert_eq!(
|
||||
(changes[0]["from"].as_u64(), changes[0]["to"].as_u64(), changes[0]["cause"].as_str()),
|
||||
(Some(3), Some(4), Some("amendment"))
|
||||
);
|
||||
}
|
||||
// The open changes stay open, their target read as 5, voted at the article's level.
|
||||
for (group, code, id) in &proposals {
|
||||
let d = c.get(&format!("/api/proposals/{id}")).await;
|
||||
assert_eq!(d["proposal"]["status"], "open", "{group} {code}");
|
||||
assert_eq!(d["proposal"]["inertiaTarget"], 5, "{group} {code}");
|
||||
let at = if *code == "E1" { 4 } else { 3 };
|
||||
assert_eq!(d["proposal"]["votedAtInertia"], at, "{group} {code}");
|
||||
}
|
||||
// And votable: in Monnaie libre, judged at level 4. A vote there is confirmed on the
|
||||
// chain (tests/ballots.rs); here, one such vote is written as the confirmation writes it.
|
||||
let (_, _, e1_ml) = &proposals[1];
|
||||
sqlx::query(
|
||||
"INSERT INTO identities (id, kind, display_name, g1_member, admin, created_at)
|
||||
VALUES ('idty:9001', 'g1', 'Alice', 1, 0, '2026-10-30T00:00:00Z')",
|
||||
)
|
||||
.execute(&pool)
|
||||
.await
|
||||
.expect("identity");
|
||||
sqlx::query(
|
||||
"INSERT INTO votes (proposal_id, identity_id, choice, revision, cast_at, source, block, event_index)
|
||||
VALUES (?, 'idty:9001', 'for', 1, '2026-10-30T00:00:00Z', 'chain', 100, 1)",
|
||||
)
|
||||
.bind(e1_ml)
|
||||
.execute(&pool)
|
||||
.await
|
||||
.expect("vote");
|
||||
let d = c.get(&format!("/api/proposals/{e1_ml}")).await;
|
||||
let ratio = d["proposal"]["evaluation"]["requiredRatio"].as_f64().expect("ratio");
|
||||
let p = d["participation"]["ratio"].as_f64().expect("p");
|
||||
// One voter, under the quorum of level 4 (50): judged on 50 voters, 49 counted against.
|
||||
let scale = Scale { electorate: 5745, participation: p, quorums: QUORUMS };
|
||||
let voters = judged_voters(1, 50);
|
||||
assert!((ratio - required_ratio(5745, voters, &scale.params(level(4), 50))).abs() < 1e-12);
|
||||
assert!((ratio - required_ratio(5745, voters, &scale.params(level(3), 50))).abs() > 1e-6, "not at the old level");
|
||||
assert_eq!(d["proposal"]["evaluation"]["quorum"], 50);
|
||||
assert_eq!(d["proposal"]["evaluation"]["required"], 43);
|
||||
// Republished where the document publishes; never from the test group.
|
||||
let queued: Vec<(String, String)> = sqlx::query_as(
|
||||
"SELECT d.group_slug, o.reason FROM outbox o JOIN documents d ON d.id = o.doc_id
|
||||
WHERE o.reason LIKE 'inertie%' ORDER BY o.id",
|
||||
)
|
||||
.fetch_all(&pool)
|
||||
.await
|
||||
.expect("outbox");
|
||||
assert_eq!(
|
||||
queued,
|
||||
[
|
||||
("monnaie-libre".to_owned(), "inertie de départ relevée : E1, 3 → 4".to_owned()),
|
||||
("monnaie-libre".to_owned(), "inertie de départ relevée : E2, 3 → 4".to_owned()),
|
||||
]
|
||||
);
|
||||
// Once only: the next start changes nothing.
|
||||
let again = restart(&pool).await;
|
||||
assert_eq!(inertia_decisions(&again.db).await, first);
|
||||
let changes: i64 = sqlx::query_scalar("SELECT COUNT(*) FROM inertia_changes").fetch_one(&pool).await.expect("n");
|
||||
assert_eq!(changes, 4);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn an_inertia_changed_by_a_vote_keeps_its_level() {
|
||||
let (pool, _) = base_before_levels("voted").await;
|
||||
// The migration runs; a vote changes E2's inertia in the test group, before the
|
||||
// version that knows the amendment starts.
|
||||
let mut all = sqlx::migrate!("./migrations");
|
||||
all.set_ignore_missing(true);
|
||||
all.run(&pool).await.expect("migrations");
|
||||
let between = AppState::new(Config::for_tests(), pool.clone()).await.expect("state");
|
||||
let mut a = Client::of(&between);
|
||||
a.login("Yvv").await;
|
||||
let mut b = a.other();
|
||||
b.login("Chiara").await;
|
||||
let e2 = unit_id(&pool, "essai", "E2").await;
|
||||
let (s, v) = a.call("POST", &format!("/api/units/{e2}/inertia"), Some(json!({ "target": 5 }))).await;
|
||||
assert_eq!(s, StatusCode::OK, "{v}");
|
||||
let p = v["id"].as_str().expect("id").to_owned();
|
||||
// One voice of the two: before the quorum amendment, the C = 1 of the test group is
|
||||
// read as the quorum of every level (ADR 0013).
|
||||
assert_eq!(a.vote(&p, "for").await["entered"][0]["kind"], "inertia");
|
||||
let (s, v) = b.call("POST", &format!("/api/proposals/{p}/vote"), Some(json!({ "choice": "for" }))).await;
|
||||
assert_eq!((s, v["error"].as_str()), (StatusCode::CONFLICT, Some("not_votable")), "already decided");
|
||||
|
||||
let state = restart(&pool).await;
|
||||
assert_eq!(
|
||||
decided(&inertia_decisions(&state.db).await),
|
||||
[
|
||||
("essai", "E1", "applied", None),
|
||||
("essai", "E2", "refused", Some("changed_by_vote")),
|
||||
("monnaie-libre", "E1", "applied", None),
|
||||
("monnaie-libre", "E2", "applied", None),
|
||||
]
|
||||
);
|
||||
let mut c = Client::of(&state);
|
||||
c.login("Yvv").await;
|
||||
let doc = c.doc("essai").await;
|
||||
assert_eq!(unit(&doc, "E2")["inertia"], 5, "the vote holds");
|
||||
}
|
||||
@@ -0,0 +1,675 @@
|
||||
//! Modules (ADR 0009): the title of the document (level 4) and the order of the
|
||||
//! articles of each section (level 2), voted like articles; the guarded
|
||||
//! addition that gives them to a document born without them.
|
||||
|
||||
use axum::body::Body;
|
||||
use axum::http::{Request, StatusCode, header};
|
||||
use http_body_util::BodyExt;
|
||||
use ld_server::{AppState, CSRF_HEADER, CSRF_VALUE, Config, app, open_db, seed};
|
||||
use serde_json::{Value, json};
|
||||
use tower::ServiceExt;
|
||||
|
||||
struct Client {
|
||||
router: axum::Router,
|
||||
cookie: Option<String>,
|
||||
}
|
||||
|
||||
impl Client {
|
||||
fn of(state: &AppState) -> Self {
|
||||
Self { router: app(state.clone()), cookie: None }
|
||||
}
|
||||
|
||||
async fn new() -> (Self, AppState) {
|
||||
let db = open_db("sqlite::memory:").await.expect("db");
|
||||
let state = AppState::new(Config::for_tests(), db).await.expect("state");
|
||||
seed::ensure(&state).await.expect("seed");
|
||||
(Self::of(&state), state)
|
||||
}
|
||||
|
||||
fn other(&self) -> Self {
|
||||
Self { router: self.router.clone(), cookie: None }
|
||||
}
|
||||
|
||||
async fn call(&mut self, method: &str, uri: &str, body: Option<Value>) -> (StatusCode, Value) {
|
||||
let mut req = Request::builder().method(method).uri(uri).header(CSRF_HEADER, CSRF_VALUE);
|
||||
if let Some(c) = &self.cookie {
|
||||
req = req.header(header::COOKIE, c);
|
||||
}
|
||||
let req = match body {
|
||||
Some(b) => req.header(header::CONTENT_TYPE, "application/json").body(Body::from(b.to_string())),
|
||||
None => req.body(Body::empty()),
|
||||
}
|
||||
.expect("request");
|
||||
let res = self.router.clone().oneshot(req).await.expect("response");
|
||||
let status = res.status();
|
||||
if let Some(set) = res.headers().get(header::SET_COOKIE) {
|
||||
let raw = set.to_str().expect("cookie").split(';').next().unwrap_or_default().to_owned();
|
||||
if raw.starts_with("ld_session=") {
|
||||
self.cookie = Some(raw);
|
||||
}
|
||||
}
|
||||
let bytes = res.into_body().collect().await.expect("body").to_bytes();
|
||||
(status, serde_json::from_slice(&bytes).unwrap_or(Value::Null))
|
||||
}
|
||||
|
||||
async fn ok(&mut self, method: &str, uri: &str, body: Option<Value>) -> Value {
|
||||
let (s, v) = self.call(method, uri, body).await;
|
||||
assert_eq!(s, StatusCode::OK, "{method} {uri}: {v}");
|
||||
v
|
||||
}
|
||||
|
||||
async fn login(&mut self, name: &str) {
|
||||
self.ok("POST", "/api/auth/dev", Some(json!({ "name": name, "admin": false }))).await;
|
||||
}
|
||||
|
||||
async fn doc(&mut self, group: &str) -> Value {
|
||||
let g = self.ok("GET", &format!("/api/groups/{group}"), None).await;
|
||||
let id = g["docs"][0]["id"].as_str().expect("doc").to_owned();
|
||||
self.ok("GET", &format!("/api/docs/{id}"), None).await
|
||||
}
|
||||
|
||||
async fn vote(&mut self, proposal: &str, choice: &str) -> Value {
|
||||
self.ok("POST", &format!("/api/proposals/{proposal}/vote"), Some(json!({ "choice": choice }))).await
|
||||
}
|
||||
}
|
||||
|
||||
fn unit<'a>(doc: &'a Value, code: &str) -> &'a Value {
|
||||
doc["units"].as_array().expect("units").iter().find(|u| u["code"] == code).expect("unit")
|
||||
}
|
||||
|
||||
fn id_of(doc: &Value, code: &str) -> String {
|
||||
unit(doc, code)["id"].as_str().expect("id").to_owned()
|
||||
}
|
||||
|
||||
fn order_of<'a>(doc: &'a Value, section: &str) -> &'a Value {
|
||||
doc["modules"]["orders"].as_array().expect("orders").iter().find(|o| o["section"] == section).expect("order")
|
||||
}
|
||||
|
||||
/// The codes of the articles of a section one can see (a text in force or an open
|
||||
/// version), in the order the document reads them.
|
||||
fn read_order(doc: &Value, section: &str) -> Vec<String> {
|
||||
let seen = |u: &Value| {
|
||||
!u["inForce"].is_null()
|
||||
|| u["proposals"]
|
||||
.as_array()
|
||||
.is_some_and(|p| p.iter().any(|p| p["status"] == "open" && p["kind"] != "inertia"))
|
||||
};
|
||||
let mut units: Vec<&Value> =
|
||||
doc["units"].as_array().expect("units").iter().filter(|u| u["section"] == section && seen(u)).collect();
|
||||
units.sort_by(|a, b| a["position"].as_f64().unwrap_or(0.0).total_cmp(&b["position"].as_f64().unwrap_or(0.0)));
|
||||
units.iter().map(|u| u["code"].as_str().expect("code").to_owned()).collect()
|
||||
}
|
||||
|
||||
fn codes(doc: &Value, ids: &Value) -> Vec<String> {
|
||||
ids.as_array()
|
||||
.expect("ids")
|
||||
.iter()
|
||||
.map(|id| {
|
||||
doc["units"]
|
||||
.as_array()
|
||||
.expect("units")
|
||||
.iter()
|
||||
.find(|u| u["id"] == *id)
|
||||
.map(|u| u["code"].as_str().expect("code").to_owned())
|
||||
.unwrap_or_else(|| format!("?{id}"))
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// Two people of the test group (W = 2, C = 1): two « for » adopt.
|
||||
/// The quorum of a module or an article set to 2, as its author — an administrator, for a
|
||||
/// text of the seed — sets it before the first vote (ADR 0013). In the test group, where
|
||||
/// the quorum of every level is 1 and one voice passes, one voice of two then no longer does.
|
||||
async fn two_voices(state: &AppState, code: &str) {
|
||||
let n = sqlx::query(
|
||||
"UPDATE units SET quorum = 2 WHERE code = ? AND doc_id IN (SELECT id FROM documents WHERE group_slug = 'essai')",
|
||||
)
|
||||
.bind(code)
|
||||
.execute(&state.db)
|
||||
.await
|
||||
.expect("quorum")
|
||||
.rows_affected();
|
||||
assert_eq!(n, 1, "{code}");
|
||||
}
|
||||
|
||||
async fn two() -> (Client, Client, AppState) {
|
||||
let (mut a, state) = Client::new().await;
|
||||
a.login("Yvv").await;
|
||||
let mut b = a.other();
|
||||
b.login("Chiara").await;
|
||||
(a, b, state)
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn a_new_base_has_its_title_and_the_order_of_each_section() {
|
||||
let (mut c, state) = Client::new().await;
|
||||
c.login("Chiara").await;
|
||||
for group in ["monnaie-libre", "essai"] {
|
||||
let doc = c.doc(group).await;
|
||||
// The articles stay the articles: 27 of them, none of the modules (since lot T4, the
|
||||
// rules of operation are out of the document, ADR 0014).
|
||||
assert_eq!(doc["units"].as_array().expect("units").len(), 27, "{group}");
|
||||
let title = &doc["modules"]["title"];
|
||||
assert_eq!(
|
||||
(title["kind"].as_str(), title["code"].as_str(), title["inertia"].as_u64()),
|
||||
(Some("title"), Some("titre"), Some(4))
|
||||
);
|
||||
let v = &title["inForce"];
|
||||
assert_eq!(
|
||||
(v["title"].as_str(), v["seedMinimal"].as_bool()),
|
||||
(Some("Acte d'engagement Certification"), Some(true))
|
||||
);
|
||||
assert_eq!(v["translations"]["en"]["title"], "Certification Commitment Act");
|
||||
assert_eq!(v["translations"]["es"]["stale"], false);
|
||||
// One order per section, in the order of the document, each at level 2.
|
||||
let orders = doc["modules"]["orders"].as_array().expect("orders");
|
||||
let sections: Vec<&str> = orders.iter().filter_map(|o| o["section"].as_str()).collect();
|
||||
assert_eq!(sections, ["introduction", "fondamental", "technique", "conclusion", "annexe"]);
|
||||
assert!(orders.iter().all(|o| o["inertia"] == 2 && o["inForce"]["seedMinimal"] == true));
|
||||
// Its version in force is the order the section reads in now.
|
||||
let fondamental = order_of(&doc, "fondamental");
|
||||
assert_eq!(codes(&doc, &fondamental["members"]), ["T1", "E1", "E12", "E2"]);
|
||||
assert_eq!(fondamental["inForce"]["order"]["ids"], fondamental["members"]);
|
||||
// Every section holds two articles or more: each order has something to decide
|
||||
// (one of a single article: `an_order_of_one_article_has_nothing_to_decide`).
|
||||
assert!(orders.iter().all(|o| o["moot"] == false), "{group}");
|
||||
}
|
||||
// Born with them: the addition says so, once per document.
|
||||
let rows: Vec<(String, String)> = sqlx::query_as(
|
||||
"SELECT d.group_slug, m.outcome FROM module_amendments m JOIN documents d ON d.id = m.doc_id ORDER BY d.group_slug",
|
||||
)
|
||||
.fetch_all(&state.db)
|
||||
.await
|
||||
.expect("decisions");
|
||||
assert_eq!(rows, [("essai".into(), "present".into()), ("monnaie-libre".into(), "present".into())]);
|
||||
// The previews show articles only.
|
||||
let doc = c.doc("monnaie-libre").await;
|
||||
let id = doc["doc"]["id"].as_str().expect("id");
|
||||
let all = c.ok("GET", &format!("/api/docs/{id}/preview?mode=all"), None).await;
|
||||
assert_eq!(all["units"].as_array().expect("units").len(), 27);
|
||||
// The list of the group: the articles in force — none, I1, E1, E12 and E2 are seed
|
||||
// proposals (ADR 0011) —, one open version per article and two more (E3, E7); no module
|
||||
// has an open proposal yet.
|
||||
let g = c.ok("GET", "/api/groups/monnaie-libre", None).await;
|
||||
assert_eq!((g["docs"][0]["inForce"].as_u64(), g["docs"][0]["open"].as_u64()), (Some(0), Some(29)));
|
||||
}
|
||||
|
||||
/// The seed of M8 (tests/fixtures/seed-m8): « Ordonnancement » held one article, O1. Lot T4
|
||||
/// takes that section out of the document (ADR 0014); a base born on that seed still has it.
|
||||
fn m8_config() -> Config {
|
||||
let mut cfg = Config::for_tests();
|
||||
cfg.seed_dir = std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("tests/fixtures/seed-m8");
|
||||
cfg
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn an_order_of_one_article_has_nothing_to_decide() {
|
||||
let db = open_db("sqlite::memory:").await.expect("db");
|
||||
let state = AppState::new(m8_config(), db).await.expect("state");
|
||||
seed::ensure(&state).await.expect("seed");
|
||||
let mut c = Client::of(&state);
|
||||
c.login("Chiara").await;
|
||||
for group in ["monnaie-libre", "essai"] {
|
||||
let doc = c.doc(group).await;
|
||||
assert_eq!(codes(&doc, &order_of(&doc, "ordonnancement")["members"]), ["O1"], "{group}");
|
||||
assert_eq!(order_of(&doc, "ordonnancement")["moot"], true, "{group}");
|
||||
assert_eq!(order_of(&doc, "conclusion")["moot"], false, "{group}");
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn an_order_is_proposed_with_the_articles_of_its_section_then_voted_into_force() {
|
||||
let (mut yvv, mut chiara, state) = two().await;
|
||||
two_voices(&state, "ordre.conclusion").await;
|
||||
let doc = yvv.doc("essai").await;
|
||||
let conclusion = order_of(&doc, "conclusion").clone();
|
||||
let module = conclusion["id"].as_str().expect("id").to_owned();
|
||||
let (k1, k2) = (id_of(&doc, "K1"), id_of(&doc, "K2"));
|
||||
let uri = format!("/api/units/{module}/order");
|
||||
// Every article of the section, each once, in another order.
|
||||
for (wrong, code) in [
|
||||
(json!([k1]), "order"),
|
||||
(json!([k2, k1, k1]), "order"),
|
||||
(json!([k2, id_of(&doc, "E1")]), "order"),
|
||||
(json!([k1, k2]), "same_order"),
|
||||
] {
|
||||
let (s, v) = yvv.call("POST", &uri, Some(json!({ "order": wrong }))).await;
|
||||
assert_eq!((s, v["error"].as_str()), (StatusCode::BAD_REQUEST, Some(code)), "{wrong}");
|
||||
}
|
||||
let first = yvv.ok("POST", &uri, Some(json!({ "order": [k2, k1] }))).await;
|
||||
assert_eq!(first["existing"], false);
|
||||
// The same order again: the open one is given back.
|
||||
let again = chiara.ok("POST", &uri, Some(json!({ "order": [k2, k1] }))).await;
|
||||
assert_eq!((again["id"].as_str(), again["existing"].as_bool()), (first["id"].as_str(), Some(true)));
|
||||
let p = first["id"].as_str().expect("id").to_owned();
|
||||
let doc = yvv.doc("essai").await;
|
||||
let proposal = &order_of(&doc, "conclusion")["proposals"][0];
|
||||
assert_eq!((proposal["kind"].as_str(), proposal["votedAtInertia"].as_u64()), (Some("order"), Some(2)));
|
||||
assert_eq!(codes(&doc, &proposal["order"]["ids"]), ["K2", "K1"]);
|
||||
assert_eq!(proposal["order"]["same"], false);
|
||||
// Voted like an article: one voice of two is not enough, the second brings it into force.
|
||||
assert!(yvv.vote(&p, "for").await["entered"].as_array().expect("e").is_empty());
|
||||
let v = chiara.vote(&p, "for").await;
|
||||
assert_eq!(
|
||||
(v["entered"][0]["code"].as_str(), v["entered"][0]["kind"].as_str()),
|
||||
(Some("ordre.conclusion"), Some("order"))
|
||||
);
|
||||
let doc = yvv.doc("essai").await;
|
||||
assert_eq!(read_order(&doc, "conclusion"), ["K2", "K1"]);
|
||||
let now = order_of(&doc, "conclusion");
|
||||
assert_eq!(now["inForce"]["id"], p.as_str());
|
||||
assert_eq!(codes(&doc, &now["inForce"]["order"]["ids"]), ["K2", "K1"]);
|
||||
// The order of before goes back among the proposals, with its votes, and could return.
|
||||
let back = &now["proposals"][0];
|
||||
assert_eq!((back["status"].as_str(), back["origin"].as_str()), (Some("open"), Some("seed")));
|
||||
assert_eq!(codes(&doc, &back["order"]["ids"]), ["K1", "K2"]);
|
||||
// Nothing else moved: the other sections read as before, the texts are the same.
|
||||
assert_eq!(read_order(&doc, "fondamental"), ["T1", "E1", "E12", "E2"]);
|
||||
assert_eq!(read_order(&doc, "technique")[0], "T2");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn articles_that_enter_or_leave_a_section_complete_an_order_under_vote() {
|
||||
let (mut yvv, mut chiara, state) = two().await;
|
||||
// The order stays under vote with one voice.
|
||||
two_voices(&state, "ordre.conclusion").await;
|
||||
let doc = yvv.doc("essai").await;
|
||||
let module = order_of(&doc, "conclusion")["id"].as_str().expect("id").to_owned();
|
||||
let doc_id = doc["doc"]["id"].as_str().expect("id").to_owned();
|
||||
let (k1, k2) = (id_of(&doc, "K1"), id_of(&doc, "K2"));
|
||||
let p = yvv.ok("POST", &format!("/api/units/{module}/order"), Some(json!({ "order": [k2, k1] }))).await["id"]
|
||||
.as_str()
|
||||
.expect("id")
|
||||
.to_owned();
|
||||
yvv.vote(&p, "for").await;
|
||||
// The list of the group counts it among the proposals in progress, never among the articles
|
||||
// (none in force: I1, E1 and E2 are seed proposals, ADR 0011).
|
||||
let g = yvv.ok("GET", "/api/groups/essai", None).await;
|
||||
let before = doc["units"]
|
||||
.as_array()
|
||||
.expect("u")
|
||||
.iter()
|
||||
.map(|u| u["proposals"].as_array().expect("p").iter().filter(|x| x["status"] == "open").count() as u64);
|
||||
assert_eq!(
|
||||
(g["docs"][0]["inForce"].as_u64(), g["docs"][0]["open"].as_u64()),
|
||||
(Some(0), Some(before.sum::<u64>() + 1))
|
||||
);
|
||||
// A new article enters the section: at its end, and at the end of the order under vote.
|
||||
let created = chiara
|
||||
.ok(
|
||||
"POST",
|
||||
&format!("/api/docs/{doc_id}/proposals"),
|
||||
Some(
|
||||
json!({ "section": "conclusion", "title": "Rappel", "body": "Un rappel pour finir.", "submit": true }),
|
||||
),
|
||||
)
|
||||
.await;
|
||||
let doc = yvv.doc("essai").await;
|
||||
let fresh = unit(&doc, "K1")["code"]
|
||||
.as_str()
|
||||
.map(|_| ())
|
||||
.and_then(|()| doc["units"].as_array().expect("u").iter().find(|u| u["id"] == created["unitId"]).cloned());
|
||||
let fresh = fresh.expect("the new article");
|
||||
let new_code = fresh["code"].as_str().expect("code").to_owned();
|
||||
assert_eq!(read_order(&doc, "conclusion"), ["K1", "K2", new_code.as_str()], "a new article goes at the end");
|
||||
let module_now = order_of(&doc, "conclusion");
|
||||
let open = module_now["proposals"].as_array().expect("p").iter().find(|x| x["id"] == p.as_str()).expect("p");
|
||||
assert_eq!(codes(&doc, &open["order"]["ids"]), ["K2", "K1", new_code.as_str()]);
|
||||
assert_eq!(codes(&doc, &open["order"]["added"]), [new_code.as_str()]);
|
||||
// Its vote stays: completing an order never asks the voters again.
|
||||
assert_eq!((open["tally"]["for"].as_u64(), open["myVote"].as_str()), (Some(1), Some("for")));
|
||||
// The new article leaves again (withdrawn by its author): dropped, as if never there.
|
||||
let added = created["id"].as_str().expect("id");
|
||||
chiara.ok("POST", &format!("/api/proposals/{added}/withdraw"), None).await;
|
||||
let doc = yvv.doc("essai").await;
|
||||
let open = order_of(&doc, "conclusion")["proposals"]
|
||||
.as_array()
|
||||
.expect("p")
|
||||
.iter()
|
||||
.find(|x| x["id"] == p.as_str())
|
||||
.cloned()
|
||||
.expect("p");
|
||||
assert_eq!(codes(&doc, &open["order"]["ids"]), ["K2", "K1"]);
|
||||
assert!(open["order"]["added"].as_array().expect("a").is_empty());
|
||||
// An order proposed with three articles, one of which then leaves: dropped from it.
|
||||
let created = chiara
|
||||
.ok(
|
||||
"POST",
|
||||
&format!("/api/docs/{doc_id}/proposals"),
|
||||
Some(json!({ "section": "conclusion", "title": "Autre", "body": "Un article de passage dans la section.", "submit": true })),
|
||||
)
|
||||
.await;
|
||||
let other = created["unitId"].as_str().expect("unit").to_owned();
|
||||
let three = yvv.ok("POST", &format!("/api/units/{module}/order"), Some(json!({ "order": [other, k2, k1] }))).await
|
||||
["id"]
|
||||
.as_str()
|
||||
.expect("id")
|
||||
.to_owned();
|
||||
chiara.ok("POST", &format!("/api/proposals/{}/withdraw", created["id"].as_str().expect("id")), None).await;
|
||||
let doc = yvv.doc("essai").await;
|
||||
let o = order_of(&doc, "conclusion")["proposals"]
|
||||
.as_array()
|
||||
.expect("p")
|
||||
.iter()
|
||||
.find(|x| x["id"] == three.as_str())
|
||||
.cloned()
|
||||
.expect("o");
|
||||
assert_eq!(codes(&doc, &o["order"]["ids"]), ["K2", "K1"]);
|
||||
assert_eq!(o["order"]["dropped"], json!([other]));
|
||||
// Completed, it reads like the first one: the second voice brings that order in.
|
||||
let v = chiara.vote(&p, "for").await;
|
||||
assert_eq!(v["entered"][0]["kind"], "order");
|
||||
let doc = yvv.doc("essai").await;
|
||||
assert_eq!(read_order(&doc, "conclusion"), ["K2", "K1"]);
|
||||
// Now the order in force: the other one changes nothing more and cannot enter.
|
||||
let o = order_of(&doc, "conclusion")["proposals"]
|
||||
.as_array()
|
||||
.expect("p")
|
||||
.iter()
|
||||
.find(|x| x["id"] == three.as_str())
|
||||
.cloned()
|
||||
.expect("o");
|
||||
assert_eq!(o["order"]["same"], true);
|
||||
yvv.vote(&three, "for").await;
|
||||
assert!(chiara.vote(&three, "for").await["entered"].as_array().expect("e").is_empty());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn the_generic_writing_stops_at_a_module_and_an_order_is_never_corrected() {
|
||||
let (mut yvv, _, _) = two().await;
|
||||
let doc = yvv.doc("essai").await;
|
||||
let doc_id = doc["doc"]["id"].as_str().expect("id").to_owned();
|
||||
let conclusion = order_of(&doc, "conclusion");
|
||||
let module = conclusion["id"].as_str().expect("id");
|
||||
let seed_order = conclusion["inForce"]["id"].as_str().expect("id");
|
||||
let (s, v) = yvv
|
||||
.call(
|
||||
"POST",
|
||||
&format!("/api/docs/{doc_id}/proposals"),
|
||||
Some(json!({ "unitId": module, "title": "Texte", "body": "Un texte sur un module.", "submit": true })),
|
||||
)
|
||||
.await;
|
||||
assert_eq!((s, v["error"].as_str()), (StatusCode::BAD_REQUEST, Some("module")));
|
||||
let (s, v) = yvv
|
||||
.call(
|
||||
"PATCH",
|
||||
&format!("/api/proposals/{seed_order}"),
|
||||
Some(json!({ "title": "ordre.conclusion", "body": "x\ny" })),
|
||||
)
|
||||
.await;
|
||||
assert_eq!((s, v["error"].as_str()), (StatusCode::CONFLICT, Some("order_fixed")));
|
||||
let (s, v) = yvv
|
||||
.call(
|
||||
"PUT",
|
||||
&format!("/api/proposals/{seed_order}/translations/en"),
|
||||
Some(json!({ "title": "order", "body": "" })),
|
||||
)
|
||||
.await;
|
||||
assert_eq!((s, v["error"].as_str()), (StatusCode::CONFLICT, Some("order_fixed")));
|
||||
// A title and an order each have their own door, and only that one.
|
||||
let title = doc["modules"]["title"]["id"].as_str().expect("title");
|
||||
let (s, v) = yvv.call("POST", &format!("/api/units/{title}/order"), Some(json!({ "order": [] }))).await;
|
||||
assert_eq!((s, v["error"].as_str()), (StatusCode::BAD_REQUEST, Some("not_an_order")));
|
||||
let (s, v) = yvv.call("POST", &format!("/api/units/{module}/title"), Some(json!({ "title": "Autre" }))).await;
|
||||
assert_eq!((s, v["error"].as_str()), (StatusCode::BAD_REQUEST, Some("not_a_title")));
|
||||
// An objection (« redit » / « contredit », ADR 0010) is between two articles: never a module.
|
||||
let i2 = unit(&doc, "I2")["proposals"][0]["id"].as_str().expect("i2").to_owned();
|
||||
let title_v = doc["modules"]["title"]["inForce"]["id"].as_str().expect("title").to_owned();
|
||||
for (from, other) in [(seed_order, i2.as_str()), (i2.as_str(), seed_order), (i2.as_str(), title_v.as_str())] {
|
||||
let (s, v) = yvv
|
||||
.call(
|
||||
"POST",
|
||||
&format!("/api/proposals/{from}/objections"),
|
||||
Some(json!({ "otherId": other, "kind": "contredit", "reason": "Les deux disent l'inverse." })),
|
||||
)
|
||||
.await;
|
||||
assert_eq!((s, v["error"].as_str()), (StatusCode::BAD_REQUEST, Some("other")), "{from} → {other}");
|
||||
}
|
||||
// A module's inertia is voted like an article's.
|
||||
let i = yvv.ok("POST", &format!("/api/units/{module}/inertia"), Some(json!({ "target": 3 }))).await;
|
||||
assert_eq!(i["existing"], false);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn another_title_enters_into_force_and_its_translations_are_to_review() {
|
||||
let (mut yvv, mut chiara, state) = two().await;
|
||||
two_voices(&state, "titre").await;
|
||||
let doc = yvv.doc("essai").await;
|
||||
let module = doc["modules"]["title"]["id"].as_str().expect("id").to_owned();
|
||||
let uri = format!("/api/units/{module}/title");
|
||||
for (wrong, code) in [
|
||||
(" ", "title"),
|
||||
("Deux\nlignes", "title"),
|
||||
("Acte d'engagement Certification", "same_title"),
|
||||
("Acte\u{200b}d'engagement", "hidden_characters"),
|
||||
] {
|
||||
let (s, v) = yvv.call("POST", &uri, Some(json!({ "title": wrong }))).await;
|
||||
assert_eq!((s, v["error"].as_str()), (StatusCode::BAD_REQUEST, Some(code)), "{wrong:?}");
|
||||
}
|
||||
let new_title = "Acte d'engagement de la certification";
|
||||
let p = yvv.ok("POST", &uri, Some(json!({ "title": new_title }))).await["id"].as_str().expect("id").to_owned();
|
||||
let doc = yvv.doc("essai").await;
|
||||
let proposal = &doc["modules"]["title"]["proposals"][0];
|
||||
assert_eq!((proposal["kind"].as_str(), proposal["votedAtInertia"].as_u64()), (Some("title"), Some(4)));
|
||||
yvv.vote(&p, "for").await;
|
||||
let v = chiara.vote(&p, "for").await;
|
||||
assert_eq!((v["entered"][0]["code"].as_str(), v["entered"][0]["kind"].as_str()), (Some("titre"), Some("title")));
|
||||
// The document changes title in French; English and Spanish stay, « à revoir ».
|
||||
let doc = yvv.doc("essai").await;
|
||||
assert_eq!(doc["doc"]["title"]["fr"], new_title);
|
||||
assert_eq!(doc["doc"]["title"]["en"], "Certification Commitment Act");
|
||||
let tr = &doc["modules"]["title"]["inForce"]["translations"];
|
||||
assert_eq!((tr["en"]["stale"].as_bool(), tr["es"]["stale"].as_bool()), (Some(true), Some(true)));
|
||||
let g = yvv.ok("GET", "/api/groups/essai", None).await;
|
||||
assert_eq!(g["docs"][0]["title"]["fr"], new_title);
|
||||
let d = yvv.ok("GET", "/api/groups/essai/decisions", None).await;
|
||||
assert_eq!(d["docs"][0]["title"]["fr"], new_title);
|
||||
// A member translates it again: the document's English title follows, up to date.
|
||||
yvv.ok(
|
||||
"PUT",
|
||||
&format!("/api/proposals/{p}/translations/en"),
|
||||
Some(json!({ "title": "Commitment Act of Certification", "body": "" })),
|
||||
)
|
||||
.await;
|
||||
let doc = yvv.doc("essai").await;
|
||||
assert_eq!(doc["doc"]["title"]["en"], "Commitment Act of Certification");
|
||||
assert_eq!(doc["modules"]["title"]["inForce"]["translations"]["en"]["stale"], false);
|
||||
// A typo of the title in force is corrected without a vote, by the path of any typo
|
||||
// (ADR 0010): someone else's waits for the title's author, then the document follows.
|
||||
let fixed = "Acte d’engagement de la certification";
|
||||
let r = chiara.ok("PATCH", &format!("/api/proposals/{p}"), Some(json!({ "title": fixed, "body": "" }))).await;
|
||||
assert_eq!((r["edit"].as_str(), r["typo"]["answerer"].as_str()), (Some("typo_pending"), Some("author")));
|
||||
assert_eq!(yvv.doc("essai").await["doc"]["title"]["fr"], new_title, "nothing written yet");
|
||||
let tid = r["typo"]["id"].as_str().expect("typo").to_owned();
|
||||
assert_eq!(yvv.ok("POST", &format!("/api/typos/{tid}/validate"), None).await["status"], "applied");
|
||||
assert_eq!(yvv.doc("essai").await["doc"]["title"]["fr"], fixed);
|
||||
// An order is never corrected: neither by its author nor by anyone else.
|
||||
let doc = yvv.doc("essai").await;
|
||||
let order = order_of(&doc, "conclusion")["inForce"]["id"].as_str().expect("order").to_owned();
|
||||
let (s, v) =
|
||||
chiara.call("PATCH", &format!("/api/proposals/{order}"), Some(json!({ "title": "", "body": "x" }))).await;
|
||||
assert_eq!((s, v["error"].as_str()), (StatusCode::CONFLICT, Some("order_fixed")));
|
||||
// The test group never publishes; the title of the publication is covered by `render`.
|
||||
let n: i64 = sqlx::query_scalar(
|
||||
"SELECT COUNT(*) FROM outbox o JOIN documents d ON d.id = o.doc_id WHERE d.group_slug = 'essai'",
|
||||
)
|
||||
.fetch_one(&state.db)
|
||||
.await
|
||||
.expect("outbox");
|
||||
assert_eq!(n, 0);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn a_new_article_goes_at_the_end_of_the_section_asked_for() {
|
||||
let (mut yvv, _, _) = two().await;
|
||||
let doc = yvv.doc("essai").await;
|
||||
let doc_id = doc["doc"]["id"].as_str().expect("id").to_owned();
|
||||
let new = |section: Option<&str>, after: Option<String>| json!({ "section": section, "afterUnitId": after, "title": "Nouveau", "body": "Un nouvel article.", "submit": true });
|
||||
let a = yvv.ok("POST", &format!("/api/docs/{doc_id}/proposals"), Some(new(Some("fondamental"), None))).await;
|
||||
// An older client names an article: the end of its section, not right after it.
|
||||
let b = yvv.ok("POST", &format!("/api/docs/{doc_id}/proposals"), Some(new(None, Some(id_of(&doc, "I1"))))).await;
|
||||
let (s, v) = yvv.call("POST", &format!("/api/docs/{doc_id}/proposals"), Some(new(Some("nulle-part"), None))).await;
|
||||
assert_eq!((s, v["error"].as_str()), (StatusCode::BAD_REQUEST, Some("section")));
|
||||
let doc = yvv.doc("essai").await;
|
||||
let code = |created: &Value| {
|
||||
doc["units"].as_array().expect("u").iter().find(|u| u["id"] == created["unitId"]).expect("unit")["code"]
|
||||
.as_str()
|
||||
.expect("code")
|
||||
.to_owned()
|
||||
};
|
||||
let (ca, cb) = (code(&a), code(&b));
|
||||
assert_eq!(read_order(&doc, "fondamental"), ["T1", "E1", "E12", "E2", ca.as_str()]);
|
||||
assert_eq!(read_order(&doc, "introduction"), ["I1", "I2", cb.as_str()]);
|
||||
// It joins the order of its section, at its end.
|
||||
assert_eq!(codes(&doc, &order_of(&doc, "fondamental")["members"]), ["T1", "E1", "E12", "E2", ca.as_str()]);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// A base born without modules, as production holds it, then the new version
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// The seed production runs on 2026-11-02 (3f10a0e): I1, E1 and E2 in force, seeds a
|
||||
/// minima. Since that day the seed puts them forward as seed proposals (ADR 0011,
|
||||
/// tests/seed_proposals.rs); the addition of the modules is replayed on the seed of then.
|
||||
fn production_config() -> Config {
|
||||
let mut cfg = Config::for_tests();
|
||||
cfg.seed_dir = std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("tests/fixtures/seed-production-2026-11-02");
|
||||
cfg
|
||||
}
|
||||
|
||||
fn seed_without_modules(tag: &str) -> std::path::PathBuf {
|
||||
let today = production_config().seed_dir.join("acte-engagement-certification.json");
|
||||
let mut seed: Value = serde_json::from_str(&std::fs::read_to_string(today).expect("seed")).expect("json");
|
||||
seed.as_object_mut().expect("object").remove("modules");
|
||||
let dir = std::env::temp_dir().join(format!("ld-modules-seed-{tag}-{}", std::process::id()));
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
std::fs::create_dir_all(&dir).expect("dir");
|
||||
std::fs::write(dir.join("acte-engagement-certification.json"), seed.to_string()).expect("write");
|
||||
dir
|
||||
}
|
||||
|
||||
/// What must not move: every unit of the base (place, text in force, section,
|
||||
/// inertia), every version (status, text, revision) and every vote.
|
||||
async fn snapshot(
|
||||
state: &AppState,
|
||||
) -> (Vec<(String, f64, Option<String>, Option<String>, String)>, Vec<(String, String, String, i64)>, i64) {
|
||||
let units = sqlx::query_as(
|
||||
"SELECT id, position, in_force_id, section, inertia FROM units WHERE kind = 'article' ORDER BY id",
|
||||
)
|
||||
.fetch_all(&state.db)
|
||||
.await
|
||||
.expect("units");
|
||||
let proposals = sqlx::query_as(
|
||||
"SELECT p.id, p.status, p.body, p.revision FROM proposals p JOIN units u ON u.id = p.unit_id
|
||||
WHERE u.kind = 'article' ORDER BY p.id",
|
||||
)
|
||||
.fetch_all(&state.db)
|
||||
.await
|
||||
.expect("proposals");
|
||||
let votes = sqlx::query_scalar("SELECT COUNT(*) FROM votes").fetch_one(&state.db).await.expect("votes");
|
||||
(units, proposals, votes)
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn an_existing_document_receives_its_modules_once_from_what_it_holds() {
|
||||
// Production before this version: the documents, no module.
|
||||
let mut cfg = Config::for_tests();
|
||||
cfg.seed_dir = seed_without_modules("applied");
|
||||
let db = open_db("sqlite::memory:").await.expect("db");
|
||||
let before = AppState::new(cfg, db).await.expect("state");
|
||||
seed::documents(&before).await.expect("documents");
|
||||
let mut c = Client::of(&before);
|
||||
c.login("Yvv").await;
|
||||
let mut chiara = c.other();
|
||||
chiara.login("Chiara").await;
|
||||
let doc = c.doc("essai").await;
|
||||
assert!(doc["modules"]["title"].is_null());
|
||||
// Life goes on there: votes, an article adopted, another one proposed in « conclusion ».
|
||||
let e3 = unit(&doc, "E3")["proposals"][0]["id"].as_str().expect("e3").to_owned();
|
||||
c.vote(&e3, "for").await;
|
||||
chiara.vote(&e3, "for").await;
|
||||
let doc_id = doc["doc"]["id"].as_str().expect("id").to_owned();
|
||||
c.ok(
|
||||
"POST",
|
||||
&format!("/api/docs/{doc_id}/proposals"),
|
||||
Some(json!({ "section": "conclusion", "title": "Rappel", "body": "Un rappel final, proposé avant les modules.", "submit": true })),
|
||||
)
|
||||
.await;
|
||||
let kept = snapshot(&before).await;
|
||||
let reads = read_order(&c.doc("essai").await, "conclusion");
|
||||
|
||||
// The new version starts: each document receives its modules, from what it holds.
|
||||
let state = AppState::new(production_config(), before.db.clone()).await.expect("state");
|
||||
seed::ensure(&state).await.expect("seed");
|
||||
let rows: Vec<(String, String, Option<String>, Option<String>)> = sqlx::query_as(
|
||||
"SELECT d.group_slug, m.outcome, m.reason, m.created FROM module_amendments m JOIN documents d ON d.id = m.doc_id
|
||||
ORDER BY d.group_slug",
|
||||
)
|
||||
.fetch_all(&state.db)
|
||||
.await
|
||||
.expect("decisions");
|
||||
let created = r#"["titre","ordre.introduction","ordre.fondamental","ordre.technique","ordre.conclusion","ordre.annexe","ordre.formule","ordre.inertie","ordre.ordonnancement"]"#;
|
||||
assert_eq!(
|
||||
rows,
|
||||
[
|
||||
("essai".into(), "applied".into(), None, Some(created.into())),
|
||||
("monnaie-libre".into(), "applied".into(), None, Some(created.into())),
|
||||
]
|
||||
);
|
||||
// Nothing else moved: articles, versions, votes.
|
||||
assert_eq!(snapshot(&state).await, kept);
|
||||
let mut c = Client::of(&state);
|
||||
c.login("Yvv").await;
|
||||
let doc = c.doc("essai").await;
|
||||
assert_eq!(doc["modules"]["title"]["inForce"]["title"], "Acte d'engagement Certification");
|
||||
assert_eq!(
|
||||
doc["modules"]["title"]["inForce"]["translations"]["es"]["title"],
|
||||
"Acta de compromiso de Certificación"
|
||||
);
|
||||
// The order of each section in force is the one it read in, the new article included.
|
||||
let conclusion = order_of(&doc, "conclusion");
|
||||
assert_eq!(codes(&doc, &conclusion["inForce"]["order"]["ids"]), reads);
|
||||
assert_eq!(reads.len(), 3);
|
||||
assert_eq!(conclusion["inForce"]["seedMinimal"], true);
|
||||
// « Décisions » counts them: 41 + the new article in the test group, E3 adopted there.
|
||||
let d = c.ok("GET", "/api/groups/essai/decisions", None).await;
|
||||
assert_eq!(d["docs"][0]["toVote"], 41);
|
||||
// Republished where the document publishes, never from the test group.
|
||||
let queued: Vec<(String, String)> = sqlx::query_as(
|
||||
"SELECT d.group_slug, o.reason FROM outbox o JOIN documents d ON d.id = o.doc_id WHERE o.reason LIKE 'modules%'",
|
||||
)
|
||||
.fetch_all(&state.db)
|
||||
.await
|
||||
.expect("outbox");
|
||||
assert_eq!(
|
||||
queued,
|
||||
[(
|
||||
"monnaie-libre".to_owned(),
|
||||
"modules ajoutés : le titre du document, l'ordre des articles de chaque section".to_owned()
|
||||
)]
|
||||
);
|
||||
// What is published: the same text, and the modules in document.json.
|
||||
let ml = c.doc("monnaie-libre").await;
|
||||
let st = ld_server::docs::load_state(&state, ml["doc"]["id"].as_str().expect("id")).await.expect("state");
|
||||
let files = ld_server::publish::render(&state, &st).await.expect("render");
|
||||
let file = |name: &str| files.iter().find(|(n, _)| n == name).map(|(_, c)| c.clone()).expect("file");
|
||||
assert!(!file("fr.md").contains("ordre.") && file("fr.md").contains("# Acte d'engagement Certification"));
|
||||
let json: Value = serde_json::from_str(&file("document.json")).expect("json");
|
||||
assert_eq!(json["units"].as_array().expect("units").len(), 3, "the articles in force, no module");
|
||||
assert_eq!(
|
||||
(json["modules"]["title"]["inertia"].as_u64(), json["modules"]["title"]["status"].as_str()),
|
||||
(Some(4), Some("seed-minimal"))
|
||||
);
|
||||
assert_eq!(json["modules"]["orders"].as_array().expect("orders").len(), 8);
|
||||
assert_eq!(json["modules"]["orders"][1]["articles"], json!(["T1", "E1", "E2"]));
|
||||
|
||||
// Once only: the next start adds nothing.
|
||||
let again = AppState::new(production_config(), state.db.clone()).await.expect("state");
|
||||
seed::ensure(&again).await.expect("seed");
|
||||
let n: i64 =
|
||||
sqlx::query_scalar("SELECT COUNT(*) FROM units WHERE kind <> 'article'").fetch_one(&again.db).await.expect("n");
|
||||
assert_eq!(n, 18, "9 modules in each of the two documents");
|
||||
let decided: i64 =
|
||||
sqlx::query_scalar("SELECT COUNT(*) FROM module_amendments").fetch_one(&again.db).await.expect("n");
|
||||
assert_eq!(decided, 2);
|
||||
}
|
||||
@@ -0,0 +1,894 @@
|
||||
//! Objections « redit » / « contredit » and the messages of trustinWallet
|
||||
//! (2026-10-31, lot F, ADR 0010), end to end on an in-memory database. The
|
||||
//! three days of a deadline pass on the server's clock, never with a sleep.
|
||||
|
||||
use axum::body::Body;
|
||||
use axum::http::{Request, StatusCode, header};
|
||||
use http_body_util::BodyExt;
|
||||
use ld_server::{AppState, CSRF_HEADER, CSRF_VALUE, Config, adoption, app, deadlines, open_db, seed};
|
||||
use serde_json::{Value, json};
|
||||
use tower::ServiceExt;
|
||||
|
||||
/// The seed of production before its seed proposal amendment (ADR 0011): texts in force.
|
||||
fn production() -> Config {
|
||||
let mut cfg = Config::for_tests();
|
||||
cfg.seed_dir = std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("tests/fixtures/seed-production-2026-11-02");
|
||||
cfg
|
||||
}
|
||||
|
||||
struct Client {
|
||||
router: axum::Router,
|
||||
cookie: Option<String>,
|
||||
}
|
||||
|
||||
impl Client {
|
||||
/// A base seeded as production is before its seed proposal amendment (ADR 0011):
|
||||
/// I1, E1 and E2 in force, seeds a minima — as they stay wherever it is refused.
|
||||
/// These scenarios need texts in force; the seed of today has none.
|
||||
async fn new() -> (Self, AppState) {
|
||||
let db = open_db("sqlite::memory:").await.expect("db");
|
||||
let state = AppState::new(production(), db).await.expect("state");
|
||||
seed::ensure(&state).await.expect("seed");
|
||||
(Self { router: app(state.clone()), cookie: None }, state)
|
||||
}
|
||||
|
||||
fn other(&self) -> Self {
|
||||
Self { router: self.router.clone(), cookie: None }
|
||||
}
|
||||
|
||||
async fn call(&mut self, method: &str, uri: &str, body: Option<Value>) -> (StatusCode, Value) {
|
||||
let mut req = Request::builder().method(method).uri(uri).header(CSRF_HEADER, CSRF_VALUE);
|
||||
if let Some(c) = &self.cookie {
|
||||
req = req.header(header::COOKIE, c);
|
||||
}
|
||||
let req = match body {
|
||||
Some(b) => req.header(header::CONTENT_TYPE, "application/json").body(Body::from(b.to_string())),
|
||||
None => req.body(Body::empty()),
|
||||
}
|
||||
.expect("request");
|
||||
let res = self.router.clone().oneshot(req).await.expect("response");
|
||||
let status = res.status();
|
||||
if let Some(set) = res.headers().get(header::SET_COOKIE) {
|
||||
let raw = set.to_str().expect("cookie").split(';').next().unwrap_or_default().to_owned();
|
||||
if raw.starts_with("ld_session=") {
|
||||
self.cookie = Some(raw);
|
||||
}
|
||||
}
|
||||
let bytes = res.into_body().collect().await.expect("body").to_bytes();
|
||||
(status, serde_json::from_slice(&bytes).unwrap_or(Value::Null))
|
||||
}
|
||||
|
||||
async fn ok(&mut self, method: &str, uri: &str, body: Option<Value>) -> Value {
|
||||
let (s, v) = self.call(method, uri, body).await;
|
||||
assert_eq!(s, StatusCode::OK, "{method} {uri}: {v}");
|
||||
v
|
||||
}
|
||||
|
||||
/// The status and the error code of a call expected to fail.
|
||||
async fn err(&mut self, method: &str, uri: &str, body: Option<Value>) -> (StatusCode, String) {
|
||||
let (s, v) = self.call(method, uri, body).await;
|
||||
(s, v["error"].as_str().unwrap_or_default().to_owned())
|
||||
}
|
||||
|
||||
async fn login(&mut self, name: &str, admin: bool) {
|
||||
self.ok("POST", "/api/auth/dev", Some(json!({ "name": name, "admin": admin }))).await;
|
||||
}
|
||||
|
||||
async fn doc(&mut self, group: &str) -> Value {
|
||||
let g = self.ok("GET", &format!("/api/groups/{group}"), None).await;
|
||||
let id = g["docs"][0]["id"].as_str().expect("doc").to_owned();
|
||||
self.ok("GET", &format!("/api/docs/{id}"), None).await
|
||||
}
|
||||
|
||||
async fn propose(&mut self, doc: &Value, body: Value) -> String {
|
||||
let id = doc["doc"]["id"].as_str().expect("id");
|
||||
self.ok("POST", &format!("/api/docs/{id}/proposals"), Some(body)).await["id"].as_str().expect("id").to_owned()
|
||||
}
|
||||
|
||||
async fn object(&mut self, from: &str, other: &str, kind: &str, reason: &str) -> Value {
|
||||
self.ok(
|
||||
"POST",
|
||||
&format!("/api/proposals/{from}/objections"),
|
||||
Some(json!({ "otherId": other, "kind": kind, "reason": reason })),
|
||||
)
|
||||
.await
|
||||
}
|
||||
|
||||
async fn messages(&mut self) -> Value {
|
||||
self.ok("GET", "/api/messages", None).await
|
||||
}
|
||||
|
||||
async fn detail(&mut self, id: &str) -> Value {
|
||||
self.ok("GET", &format!("/api/proposals/{id}"), None).await
|
||||
}
|
||||
}
|
||||
|
||||
fn unit<'a>(doc: &'a Value, code: &str) -> &'a Value {
|
||||
doc["units"].as_array().expect("units").iter().find(|u| u["code"] == code).expect("unit")
|
||||
}
|
||||
|
||||
fn find<'a>(doc: &'a Value, id: &str) -> &'a Value {
|
||||
doc["units"]
|
||||
.as_array()
|
||||
.expect("units")
|
||||
.iter()
|
||||
.flat_map(|u| std::iter::once(&u["inForce"]).chain(u["proposals"].as_array().expect("p").iter()))
|
||||
.find(|p| p["id"] == id)
|
||||
.expect("version")
|
||||
}
|
||||
|
||||
fn gestures(m: &Value) -> Vec<&str> {
|
||||
m["gestures"].as_array().expect("gestures").iter().filter_map(Value::as_str).collect()
|
||||
}
|
||||
|
||||
const REPEATS: &str = "Elle répète l'unicité du compte, déjà en vigueur.";
|
||||
const CONTRADICTS: &str = "Elle dit le contraire de la réciprocité en vigueur.";
|
||||
|
||||
/// C28, by the API: a « redit », the proposal no longer votable, its author changes
|
||||
/// the article it repeats, the objector lifts.
|
||||
#[tokio::test]
|
||||
async fn a_redit_holds_the_latest_back_until_its_author_answers_and_the_objector_lifts() {
|
||||
let (mut chiara, _) = Client::new().await;
|
||||
chiara.login("Chiara", false).await;
|
||||
let mut yvv = chiara.other();
|
||||
yvv.login("Yvv", false).await;
|
||||
let doc = chiara.doc("essai").await;
|
||||
let doc_id = doc["doc"]["id"].as_str().expect("id").to_owned();
|
||||
let e1 = unit(&doc, "E1");
|
||||
let (e1_unit, e1_id) =
|
||||
(e1["id"].as_str().expect("u").to_owned(), e1["inForce"]["id"].as_str().expect("v").to_owned());
|
||||
let b = chiara
|
||||
.propose(
|
||||
&doc,
|
||||
json!({ "title": "Un seul compte", "body": "Je n'ai qu'un seul compte membre, et je m'y tiens.", "submit": true }),
|
||||
)
|
||||
.await;
|
||||
let o = yvv.object(&b, &e1_id, "redit", REPEATS).await;
|
||||
assert_eq!(o["heldId"], b.as_str(), "the latest arrival, never the text in force");
|
||||
let oid = o["id"].as_str().expect("id").to_owned();
|
||||
|
||||
let d = chiara.doc("essai").await;
|
||||
assert_eq!((find(&d, &b)["votable"].as_bool(), find(&d, &b)["blocked"].as_bool()), (Some(false), Some(true)));
|
||||
assert_eq!(find(&d, &e1_id)["votable"], true, "a version in force is never made non-votable");
|
||||
assert_eq!(find(&d, &e1_id)["objections"], 1);
|
||||
|
||||
// Nobody votes *for* it any more: neither one vote nor a grouped one (against and a
|
||||
// withdrawal still pass: `a_held_version_takes_against_and_withdrawal_never_for`).
|
||||
let one = yvv.err("POST", &format!("/api/proposals/{b}/vote"), Some(json!({ "choice": "for" }))).await;
|
||||
assert_eq!(one, (StatusCode::CONFLICT, "objection".into()));
|
||||
let many = yvv
|
||||
.err(
|
||||
"POST",
|
||||
&format!("/api/docs/{doc_id}/votes"),
|
||||
Some(json!({ "lines": [{ "proposalId": b, "choice": "for" }] })),
|
||||
)
|
||||
.await;
|
||||
assert_eq!(many, (StatusCode::CONFLICT, "objection".into()));
|
||||
|
||||
// Its author is told, in her messages, what to do; the objector has nothing yet.
|
||||
let m = chiara.messages().await;
|
||||
assert_eq!(m["unread"], 1);
|
||||
let raised = &m["messages"][0];
|
||||
assert_eq!((raised["kind"].as_str(), raised["topic"].as_str()), (Some("objection_raised"), Some("objection")));
|
||||
assert_eq!(gestures(raised), ["respond"]);
|
||||
assert_eq!(raised["objection"]["kind"], "redit");
|
||||
assert_eq!(raised["objection"]["heldVersion"]["id"], b.as_str());
|
||||
assert_eq!(raised["objection"]["other"]["id"], e1_id.as_str());
|
||||
assert_eq!(raised["objection"]["objector"], "Yvv");
|
||||
assert_eq!(raised["doc"]["groupSlug"], "essai");
|
||||
assert_eq!(yvv.messages().await["messages"].as_array().expect("m").len(), 0);
|
||||
let det = chiara.detail(&b).await;
|
||||
let ob = &det["objections"][0];
|
||||
assert_eq!(
|
||||
(ob["held"].as_bool(), ob["iAnswer"].as_bool(), ob["phase"].as_str()),
|
||||
(Some(true), Some(true), Some("author"))
|
||||
);
|
||||
|
||||
// Before an answer there is nothing to reiterate; only the objector (or an administrator) lifts.
|
||||
let r = yvv.err("POST", &format!("/api/objections/{oid}/reiterate"), None).await;
|
||||
assert_eq!(r, (StatusCode::CONFLICT, "nothing_to_reiterate".into()));
|
||||
assert_eq!(chiara.err("POST", &format!("/api/objections/{oid}/lift"), None).await.0, StatusCode::FORBIDDEN);
|
||||
|
||||
// A linked proposal must go on the article repeated (or her own): never elsewhere, never by someone else.
|
||||
let e2_unit = unit(&doc, "E2")["id"].as_str().expect("u").to_owned();
|
||||
let elsewhere =
|
||||
json!({ "unitId": e2_unit, "objectionId": oid, "title": "Réciprocité", "body": "Autre.", "submit": true });
|
||||
let (s, _) = chiara.call("POST", &format!("/api/docs/{doc_id}/proposals"), Some(elsewhere)).await;
|
||||
assert_eq!(s, StatusCode::BAD_REQUEST);
|
||||
let by_yvv = json!({ "unitId": e1_unit, "objectionId": oid, "title": "Unicité", "body": "Autre.", "submit": true });
|
||||
assert_eq!(
|
||||
yvv.call("POST", &format!("/api/docs/{doc_id}/proposals"), Some(by_yvv)).await.0,
|
||||
StatusCode::BAD_REQUEST
|
||||
);
|
||||
|
||||
// Chiara changes the article it repeats: a proposal on E1, linked to the objection.
|
||||
let linked = chiara
|
||||
.propose(
|
||||
&doc,
|
||||
json!({
|
||||
"unitId": e1_unit, "objectionId": oid, "title": "Unicité du compte", "submit": true,
|
||||
"body": "Je m'engage sur l'honneur à n'avoir qu'un seul compte membre, et à m'y tenir.",
|
||||
}),
|
||||
)
|
||||
.await;
|
||||
let det = yvv.detail(&b).await;
|
||||
let ob = &det["objections"][0];
|
||||
assert_eq!((ob["phase"].as_str(), ob["answer"].as_str()), (Some("objector"), Some("proposal")));
|
||||
assert_eq!(ob["answerId"], linked.as_str());
|
||||
assert_eq!((ob["canLift"].as_bool(), ob["canReiterate"].as_bool()), (Some(true), Some(true)));
|
||||
|
||||
// The objector is asked: lift or reiterate, by three days; her message is dealt with.
|
||||
let m = yvv.messages().await;
|
||||
assert_eq!(m["unread"], 1);
|
||||
let asked = &m["messages"][0];
|
||||
assert_eq!(asked["kind"], "objection_answered");
|
||||
assert_eq!(gestures(asked), ["lift", "reiterate"]);
|
||||
assert!(asked["dueAt"].as_str().is_some_and(|d| d > asked["createdAt"].as_str().expect("at")), "{asked}");
|
||||
let m = chiara.messages().await;
|
||||
assert_eq!(gestures(&m["messages"][0]), Vec::<&str>::new());
|
||||
assert_eq!(m["messages"][0]["outcome"], "answered");
|
||||
assert_eq!(m["unread"], 0, "answering reads one's message");
|
||||
|
||||
// Yvv lifts from his message: the proposal is votable again, the message offers nothing more.
|
||||
yvv.ok("POST", &format!("/api/objections/{oid}/lift"), None).await;
|
||||
let d = yvv.doc("essai").await;
|
||||
assert_eq!(find(&d, &b)["votable"], true);
|
||||
let m = yvv.messages().await;
|
||||
assert_eq!((m["unread"].as_i64(), gestures(&m["messages"][0]).len()), (Some(0), 0), "acting reads it");
|
||||
assert_eq!(m["messages"][0]["outcome"], "lifted");
|
||||
yvv.ok("POST", &format!("/api/proposals/{b}/vote"), Some(json!({ "choice": "for" }))).await;
|
||||
// Lifted is lifted.
|
||||
let again = yvv.err("POST", &format!("/api/objections/{oid}/lift"), None).await;
|
||||
assert_eq!(again, (StatusCode::CONFLICT, "lifted".into()));
|
||||
}
|
||||
|
||||
/// C29, by the API: a « contredit » answered by a rewording; one cycle at a time;
|
||||
/// a reiteration tells the author again; without an answer, lifted at the deadline.
|
||||
#[tokio::test]
|
||||
async fn a_contredit_answered_by_a_rewording_is_lifted_alone_at_the_deadline() {
|
||||
let (mut chiara, state) = Client::new().await;
|
||||
chiara.login("Chiara", false).await;
|
||||
let mut yvv = chiara.other();
|
||||
yvv.login("Yvv", false).await;
|
||||
let doc = chiara.doc("essai").await;
|
||||
let e2 = unit(&doc, "E2")["inForce"]["id"].as_str().expect("e2").to_owned();
|
||||
let title = "Certifier largement";
|
||||
let body = "Je m'engage à certifier aussi des personnes qui ne respectent pas ces engagements.";
|
||||
let b = chiara.propose(&doc, json!({ "title": title, "body": body, "submit": true })).await;
|
||||
let oid = yvv.object(&b, &e2, "contredit", CONTRADICTS).await["id"].as_str().expect("id").to_owned();
|
||||
let phase =
|
||||
|d: &Value| (d["objections"][0]["phase"].as_str().map(str::to_owned), d["objections"][0]["cycle"].as_i64());
|
||||
|
||||
// A typo fix answers nothing.
|
||||
let typo = chiara
|
||||
.ok(
|
||||
"PATCH",
|
||||
&format!("/api/proposals/{b}"),
|
||||
Some(json!({ "title": title, "body": body.replace('\'', "\u{2019}") })),
|
||||
)
|
||||
.await;
|
||||
assert_eq!(typo["edit"], "typo");
|
||||
assert_eq!(phase(&chiara.detail(&b).await), (Some("author".into()), Some(1)));
|
||||
|
||||
// Rewording answers: the objector is asked.
|
||||
let reworded =
|
||||
"Je m'engage à ne certifier que des personnes qui respectent ces engagements, après les avoir rencontrées.";
|
||||
let r = chiara.ok("PATCH", &format!("/api/proposals/{b}"), Some(json!({ "title": title, "body": reworded }))).await;
|
||||
assert_eq!(r["edit"], "substantive");
|
||||
assert_eq!(chiara.ok("GET", "/api/messages/unread", None).await["unread"], 0, "rewording reads one's message");
|
||||
let det = yvv.detail(&b).await;
|
||||
assert_eq!(phase(&det), (Some("objector".into()), Some(1)));
|
||||
assert_eq!(det["objections"][0]["answer"], "revision");
|
||||
let due = det["objections"][0]["dueAt"].as_str().expect("due").to_owned();
|
||||
|
||||
// One cycle at a time: another rewording changes neither the messages nor the deadline.
|
||||
state.clock.advance(3600);
|
||||
let again = format!("{reworded} Et à les revoir.");
|
||||
chiara.ok("PATCH", &format!("/api/proposals/{b}"), Some(json!({ "title": title, "body": again }))).await;
|
||||
assert_eq!(yvv.detail(&b).await["objections"][0]["dueAt"], due.as_str());
|
||||
assert_eq!(yvv.messages().await["messages"].as_array().expect("m").len(), 1);
|
||||
|
||||
// Reiterated: the objection stands, the author is told again, a second cycle opens.
|
||||
yvv.ok("POST", &format!("/api/objections/{oid}/reiterate"), None).await;
|
||||
assert_eq!(phase(&yvv.detail(&b).await), (Some("author".into()), Some(2)));
|
||||
assert_eq!(find(&chiara.doc("essai").await, &b)["votable"], false);
|
||||
let m = chiara.messages().await;
|
||||
let kinds: Vec<&str> = m["messages"].as_array().expect("m").iter().filter_map(|x| x["kind"].as_str()).collect();
|
||||
assert_eq!(kinds, ["objection_reiterated", "objection_raised"], "newest first");
|
||||
assert_eq!(gestures(&m["messages"][0]), ["respond"]);
|
||||
let m = yvv.messages().await;
|
||||
assert_eq!((m["messages"][0]["outcome"].as_str(), gestures(&m["messages"][0]).len()), (Some("reiterated"), 0));
|
||||
|
||||
// A new answer, then nobody: three days later it is lifted by itself.
|
||||
let third = "Je m'engage à ne certifier que des personnes rencontrées qui respectent ces engagements.";
|
||||
chiara.ok("PATCH", &format!("/api/proposals/{b}"), Some(json!({ "title": title, "body": third }))).await;
|
||||
assert_eq!(phase(&yvv.detail(&b).await), (Some("objector".into()), Some(2)));
|
||||
state.clock.advance(3 * 86_400 - 60);
|
||||
deadlines::sweep(&state).await.expect("sweep");
|
||||
assert_eq!(yvv.detail(&b).await["objections"][0]["phase"], "objector", "not yet");
|
||||
state.clock.advance(120);
|
||||
let swept = deadlines::sweep(&state).await.expect("sweep");
|
||||
assert_eq!(swept.lifted, 1);
|
||||
let det = yvv.detail(&b).await;
|
||||
assert_eq!(
|
||||
(det["objections"][0]["phase"].as_str(), det["objections"][0]["lift"].as_str()),
|
||||
(Some("lifted"), Some("deadline"))
|
||||
);
|
||||
assert_eq!(find(&chiara.doc("essai").await, &b)["votable"], true);
|
||||
let m = yvv.messages().await;
|
||||
assert_eq!((m["messages"][0]["outcome"].as_str(), gestures(&m["messages"][0]).len()), (Some("deadline"), 0));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn a_rewording_of_the_other_side_asks_the_objector_too() {
|
||||
let (mut luna, _) = Client::new().await;
|
||||
luna.login("Luna", false).await;
|
||||
let mut chiara = luna.other();
|
||||
chiara.login("Chiara", false).await;
|
||||
let mut yvv = luna.other();
|
||||
yvv.login("Yvv", false).await;
|
||||
let doc = luna.doc("essai").await;
|
||||
let a = luna
|
||||
.propose(
|
||||
&doc,
|
||||
json!({ "title": "Joignable", "body": "Je reste joignable par mes certifieurs.", "submit": true }),
|
||||
)
|
||||
.await;
|
||||
let b = chiara
|
||||
.propose(
|
||||
&doc,
|
||||
json!({ "title": "Injoignable", "body": "Je ne suis pas tenu de rester joignable.", "submit": true }),
|
||||
)
|
||||
.await;
|
||||
let o = yvv.object(&a, &b, "contredit", "Ces deux articles disent le contraire.").await;
|
||||
assert_eq!(o["heldId"], b.as_str(), "the latest arrival, whichever side the objection was raised from");
|
||||
// Luna rewords the earlier one: the pair changed, the objector judges again.
|
||||
luna.ok(
|
||||
"PATCH",
|
||||
&format!("/api/proposals/{a}"),
|
||||
Some(json!({ "title": "Joignable", "body": "Je reste joignable par mes certifieurs, sauf absence annoncée." })),
|
||||
)
|
||||
.await;
|
||||
let det = yvv.detail(&b).await;
|
||||
assert_eq!(
|
||||
(det["objections"][0]["phase"].as_str(), det["objections"][0]["answer"].as_str()),
|
||||
(Some("objector"), Some("other"))
|
||||
);
|
||||
assert_eq!(yvv.messages().await["messages"][0]["kind"], "objection_answered");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn an_administrator_lifts_but_never_reiterates_and_withdrawing_ends_it() {
|
||||
let (mut chiara, _) = Client::new().await;
|
||||
chiara.login("Chiara", false).await;
|
||||
let mut yvv = chiara.other();
|
||||
yvv.login("Yvv", false).await;
|
||||
let mut admin = chiara.other();
|
||||
admin.login("Admin", true).await;
|
||||
let doc = chiara.doc("essai").await;
|
||||
let e1 = unit(&doc, "E1")["inForce"]["id"].as_str().expect("e1").to_owned();
|
||||
let b = chiara
|
||||
.propose(&doc, json!({ "title": "Un compte", "body": "Un seul compte membre par personne.", "submit": true }))
|
||||
.await;
|
||||
let oid = yvv.object(&b, &e1, "redit", REPEATS).await["id"].as_str().expect("id").to_owned();
|
||||
chiara
|
||||
.ok(
|
||||
"PATCH",
|
||||
&format!("/api/proposals/{b}"),
|
||||
Some(json!({ "title": "Un compte", "body": "Un seul compte membre par être humain." })),
|
||||
)
|
||||
.await;
|
||||
// An administrator never speaks for the objector...
|
||||
assert_eq!(admin.err("POST", &format!("/api/objections/{oid}/reiterate"), None).await.0, StatusCode::FORBIDDEN);
|
||||
// ...but may lift.
|
||||
admin.ok("POST", &format!("/api/objections/{oid}/lift"), None).await;
|
||||
let det = admin.detail(&b).await;
|
||||
assert_eq!(det["objections"][0]["lift"], "admin");
|
||||
assert_eq!(yvv.messages().await["messages"][0]["outcome"], "admin");
|
||||
|
||||
// Withdrawing either version ends a standing objection.
|
||||
let c = chiara
|
||||
.propose(&doc, json!({ "title": "Deux comptes", "body": "Deux comptes membres par personne.", "submit": true }))
|
||||
.await;
|
||||
let oid2 = yvv.object(&c, &e1, "contredit", CONTRADICTS).await["id"].as_str().expect("id").to_owned();
|
||||
chiara.ok("POST", &format!("/api/proposals/{c}/withdraw"), None).await;
|
||||
let lifted: (String, Option<String>) = {
|
||||
let det = yvv.detail(&c).await;
|
||||
let ob = det["objections"].as_array().expect("o").iter().find(|o| o["id"] == oid2.as_str()).expect("o").clone();
|
||||
(ob["phase"].as_str().expect("p").to_owned(), ob["lift"].as_str().map(str::to_owned))
|
||||
};
|
||||
assert_eq!(lifted, ("lifted".into(), Some("withdrawn".into())));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn what_an_objection_may_be_about() {
|
||||
let (mut chiara, _) = Client::new().await;
|
||||
chiara.login("Chiara", false).await;
|
||||
let mut yvv = chiara.other();
|
||||
yvv.login("Yvv", false).await;
|
||||
let doc = chiara.doc("essai").await;
|
||||
let e1 = unit(&doc, "E1")["inForce"]["id"].as_str().expect("e1").to_owned();
|
||||
let e2 = unit(&doc, "E2")["inForce"]["id"].as_str().expect("e2").to_owned();
|
||||
let e3 = unit(&doc, "E3");
|
||||
let (v1, v2) = (e3["proposals"][0]["id"].as_str().expect("v1"), e3["proposals"][1]["id"].as_str().expect("v2"));
|
||||
let post = |from: &str, other: &str, kind: &str, reason: &str| {
|
||||
(format!("/api/proposals/{from}/objections"), json!({ "otherId": other, "kind": kind, "reason": reason }))
|
||||
};
|
||||
let cases = [
|
||||
(post(&e1, &e2, "contredit", CONTRADICTS), "both_in_force"),
|
||||
(post(v1, v2, "redit", REPEATS), "same_unit"),
|
||||
(post(&e1, &e1, "redit", REPEATS), "other"),
|
||||
(post(v1, &e1, "signal", REPEATS), "kind"),
|
||||
(post(v1, &e1, "redit", "court"), "reason"),
|
||||
];
|
||||
for ((uri, body), code) in cases {
|
||||
let (s, e) = yvv.err("POST", &uri, Some(body)).await;
|
||||
assert_eq!((s, e.as_str()), (StatusCode::BAD_REQUEST, code));
|
||||
}
|
||||
// A draft is nobody's business yet.
|
||||
let draft = chiara
|
||||
.propose(&doc, json!({ "title": "Brouillon", "body": "Pas encore soumis au vote.", "submit": false }))
|
||||
.await;
|
||||
let (s, _) = yvv
|
||||
.call(
|
||||
"POST",
|
||||
&format!("/api/proposals/{v1}/objections"),
|
||||
Some(json!({ "otherId": draft, "kind": "redit", "reason": REPEATS })),
|
||||
)
|
||||
.await;
|
||||
assert_eq!(s, StatusCode::BAD_REQUEST);
|
||||
// One standing objection per person and pair.
|
||||
yvv.object(v1, &e1, "redit", REPEATS).await;
|
||||
let twice = yvv
|
||||
.err(
|
||||
"POST",
|
||||
&format!("/api/proposals/{e1}/objections"),
|
||||
Some(json!({ "otherId": v1, "kind": "contredit", "reason": CONTRADICTS })),
|
||||
)
|
||||
.await;
|
||||
assert_eq!(twice, (StatusCode::CONFLICT, "objection_exists".into()));
|
||||
// Someone else may object to the same pair.
|
||||
chiara.object(v1, &e1, "contredit", CONTRADICTS).await;
|
||||
}
|
||||
|
||||
/// Votes cast before the objection stay counted; the version cannot enter into
|
||||
/// force while it is held, and enters once the objection is lifted.
|
||||
#[tokio::test]
|
||||
async fn votes_already_cast_stay_but_a_held_version_cannot_enter() {
|
||||
let (mut chiara, state) = Client::new().await;
|
||||
chiara.login("Chiara", false).await;
|
||||
let mut yvv = chiara.other();
|
||||
yvv.login("Yvv", false).await;
|
||||
let doc = chiara.doc("essai").await;
|
||||
let doc_id = doc["doc"]["id"].as_str().expect("id").to_owned();
|
||||
let e1 = unit(&doc, "E1")["inForce"]["id"].as_str().expect("e1").to_owned();
|
||||
let b = chiara
|
||||
.propose(&doc, json!({ "title": "Un compte", "body": "Un seul compte membre par personne.", "submit": true }))
|
||||
.await;
|
||||
let oid = yvv.object(&b, &e1, "redit", REPEATS).await["id"].as_str().expect("id").to_owned();
|
||||
// Every member of the group for it (written as they would have been before the objection).
|
||||
let members: Vec<String> = sqlx::query_scalar("SELECT identity_id FROM memberships WHERE group_slug = 'essai'")
|
||||
.fetch_all(&state.db)
|
||||
.await
|
||||
.expect("members");
|
||||
for m in &members {
|
||||
sqlx::query("INSERT INTO votes (proposal_id, identity_id, choice, revision, cast_at) VALUES (?, ?, 'for', 1, '2026-10-31T00:00:00Z')")
|
||||
.bind(&b)
|
||||
.bind(m)
|
||||
.execute(&state.db)
|
||||
.await
|
||||
.expect("vote");
|
||||
}
|
||||
assert!(adoption::run(&state, &doc_id).await.expect("pass").is_empty(), "held back, whatever its votes");
|
||||
let d = chiara.doc("essai").await;
|
||||
assert_eq!(find(&d, &b)["tally"]["for"].as_u64(), Some(members.len() as u64), "the votes stay");
|
||||
assert!(find(&d, &b)["evaluation"]["reached"].as_bool().expect("reached"));
|
||||
let lifted = yvv.ok("POST", &format!("/api/objections/{oid}/lift"), None).await;
|
||||
assert_eq!(lifted["entered"][0]["proposalId"], b.as_str(), "lifted: it enters");
|
||||
}
|
||||
|
||||
/// Yvv, 2026-11-01, « pendant qu'une objection retient une version, que permet-on ? » —
|
||||
/// « Retirer ou "contre" »: on a held version, against and a withdrawal pass, by one vote
|
||||
/// or a grouped one; a *for* never does, alone or among free lines (all or none).
|
||||
#[tokio::test]
|
||||
async fn a_held_version_takes_against_and_withdrawal_never_for() {
|
||||
let (mut chiara, state) = Client::new().await;
|
||||
chiara.login("Chiara", false).await;
|
||||
let mut yvv = chiara.other();
|
||||
yvv.login("Yvv", false).await;
|
||||
let mut zoe = chiara.other();
|
||||
zoe.login("Zoé", false).await;
|
||||
let doc = chiara.doc("essai").await;
|
||||
let doc_id = doc["doc"]["id"].as_str().expect("id").to_owned();
|
||||
let e1 = unit(&doc, "E1")["inForce"]["id"].as_str().expect("e1").to_owned();
|
||||
let e2 = unit(&doc, "E2")["inForce"]["id"].as_str().expect("e2").to_owned();
|
||||
let b = chiara
|
||||
.propose(&doc, json!({ "title": "Un compte", "body": "Un seul compte membre par personne.", "submit": true }))
|
||||
.await;
|
||||
zoe.object(&b, &e1, "redit", REPEATS).await;
|
||||
// Yvv voted for it before the objection (written as it would have been then).
|
||||
sqlx::query(
|
||||
"INSERT INTO votes (proposal_id, identity_id, choice, revision, cast_at)
|
||||
VALUES (?, 'dev:yvv', 'for', 1, '2026-10-31T00:00:00Z')",
|
||||
)
|
||||
.bind(&b)
|
||||
.execute(&state.db)
|
||||
.await
|
||||
.expect("vote");
|
||||
let tally = |d: &Value| (find(d, &b)["tally"]["for"].as_u64(), find(d, &b)["tally"]["against"].as_u64());
|
||||
let vote = |c: Option<&str>| Some(json!({ "choice": c }));
|
||||
let uri = format!("/api/proposals/{b}/vote");
|
||||
let refused = (StatusCode::CONFLICT, "objection".to_owned());
|
||||
|
||||
// One vote: never for — not even the one he already holds.
|
||||
assert_eq!(yvv.err("POST", &uri, vote(Some("for"))).await, refused);
|
||||
assert_eq!(chiara.err("POST", &uri, vote(Some("for"))).await, refused);
|
||||
// Against: his for becomes against.
|
||||
assert_eq!(yvv.ok("POST", &uri, vote(Some("against"))).await["entered"], json!([]));
|
||||
let d = yvv.doc("essai").await;
|
||||
assert_eq!(tally(&d), (Some(0), Some(1)));
|
||||
assert_eq!(find(&d, &b)["myVote"], "against");
|
||||
assert_eq!((find(&d, &b)["votable"].as_bool(), find(&d, &b)["blocked"].as_bool()), (Some(false), Some(true)));
|
||||
// And back to for: still not.
|
||||
assert_eq!(yvv.err("POST", &uri, vote(Some("for"))).await, refused);
|
||||
// A withdrawal.
|
||||
yvv.ok("POST", &uri, vote(None)).await;
|
||||
let d = yvv.doc("essai").await;
|
||||
assert_eq!((tally(&d), find(&d, &b)["myVote"].is_null()), ((Some(0), Some(0)), true));
|
||||
|
||||
// Grouped: a for on the held version refuses the whole vote, the free line too.
|
||||
let votes = format!("/api/docs/{doc_id}/votes");
|
||||
let mixed = json!({ "lines": [{ "proposalId": e2, "choice": "for" }, { "proposalId": b, "choice": "for" }] });
|
||||
assert_eq!(chiara.err("POST", &votes, Some(mixed)).await, refused);
|
||||
let d = chiara.doc("essai").await;
|
||||
assert!(find(&d, &e2)["myVote"].is_null(), "all or none: nothing written");
|
||||
// Against the held one, for the free one: both written.
|
||||
let mixed = json!({ "lines": [{ "proposalId": e2, "choice": "for" }, { "proposalId": b, "choice": "against" }] });
|
||||
assert_eq!(chiara.ok("POST", &votes, Some(mixed)).await["count"], 2);
|
||||
let d = chiara.doc("essai").await;
|
||||
assert_eq!((find(&d, &e2)["myVote"].as_str(), find(&d, &b)["myVote"].as_str()), (Some("for"), Some("against")));
|
||||
assert_eq!(tally(&d), (Some(0), Some(1)));
|
||||
// A grouped withdrawal on the held one.
|
||||
chiara.ok("POST", &votes, Some(json!({ "lines": [{ "proposalId": b, "choice": null }] }))).await;
|
||||
let d = chiara.doc("essai").await;
|
||||
assert_eq!((tally(&d), find(&d, &b)["myVote"].is_null()), ((Some(0), Some(0)), true));
|
||||
}
|
||||
|
||||
/// While held, a version can only lose votes; lifted, it enters with the votes still
|
||||
/// there — not with those it had when the objection was raised.
|
||||
#[tokio::test]
|
||||
async fn lifted_a_version_enters_only_with_the_votes_still_there() {
|
||||
let (mut chiara, state) = Client::new().await;
|
||||
chiara.login("Chiara", false).await;
|
||||
let mut yvv = chiara.other();
|
||||
yvv.login("Yvv", false).await;
|
||||
let mut zoe = chiara.other();
|
||||
zoe.login("Zoé", false).await;
|
||||
let doc = chiara.doc("essai").await;
|
||||
let doc_id = doc["doc"]["id"].as_str().expect("id").to_owned();
|
||||
let e1 = unit(&doc, "E1")["inForce"]["id"].as_str().expect("e1").to_owned();
|
||||
let b = chiara
|
||||
.propose(&doc, json!({ "title": "Un compte", "body": "Un seul compte membre par personne.", "submit": true }))
|
||||
.await;
|
||||
let oid = zoe.object(&b, &e1, "redit", REPEATS).await["id"].as_str().expect("id").to_owned();
|
||||
// Every member of the group for it, as before the objection: enough to enter.
|
||||
let members: Vec<String> = sqlx::query_scalar("SELECT identity_id FROM memberships WHERE group_slug = 'essai'")
|
||||
.fetch_all(&state.db)
|
||||
.await
|
||||
.expect("members");
|
||||
assert_eq!(members.len(), 3);
|
||||
for m in &members {
|
||||
sqlx::query("INSERT INTO votes (proposal_id, identity_id, choice, revision, cast_at) VALUES (?, ?, 'for', 1, '2026-10-31T00:00:00Z')")
|
||||
.bind(&b)
|
||||
.bind(m)
|
||||
.execute(&state.db)
|
||||
.await
|
||||
.expect("vote");
|
||||
}
|
||||
assert!(adoption::run(&state, &doc_id).await.expect("pass").is_empty(), "held back, whatever its votes");
|
||||
// While it is held, each one takes back or turns against.
|
||||
let uri = format!("/api/proposals/{b}/vote");
|
||||
chiara.ok("POST", &uri, Some(json!({ "choice": "against" }))).await;
|
||||
yvv.ok("POST", &uri, Some(json!({ "choice": null }))).await;
|
||||
zoe.ok("POST", &uri, Some(json!({ "choice": "against" }))).await;
|
||||
let lifted = zoe.ok("POST", &format!("/api/objections/{oid}/lift"), None).await;
|
||||
assert_eq!(lifted["entered"], json!([]), "lifted: no for left, it does not enter");
|
||||
let d = chiara.doc("essai").await;
|
||||
assert_eq!((find(&d, &b)["status"].as_str(), find(&d, &b)["votable"].as_bool()), (Some("open"), Some(true)));
|
||||
assert_eq!(find(&d, &b)["tally"], json!({ "for": 0, "against": 2 }));
|
||||
}
|
||||
|
||||
/// Withdrawing the other side frees the held version at once: with its votes, it
|
||||
/// enters then, not at the next pass of the sweeper (relecture of cd7e683).
|
||||
#[tokio::test]
|
||||
async fn withdrawing_the_other_side_lets_the_held_version_enter_at_once() {
|
||||
let (mut chiara, state) = Client::new().await;
|
||||
chiara.login("Chiara", false).await;
|
||||
let mut yvv = chiara.other();
|
||||
yvv.login("Yvv", false).await;
|
||||
let mut zoe = chiara.other();
|
||||
zoe.login("Zoé", false).await;
|
||||
let doc = chiara.doc("essai").await;
|
||||
let a = yvv
|
||||
.propose(&doc, json!({ "title": "Un compte", "body": "Un seul compte membre par personne.", "submit": true }))
|
||||
.await;
|
||||
let b = chiara
|
||||
.propose(
|
||||
&doc,
|
||||
json!({ "title": "Un seul compte", "body": "Une personne, un seul compte membre.", "submit": true }),
|
||||
)
|
||||
.await;
|
||||
let held = zoe.object(&b, &a, "redit", "Elle répète la proposition d'Yvv.").await;
|
||||
assert_eq!(held["heldId"], b.as_str(), "the latest arrival");
|
||||
let members: Vec<String> = sqlx::query_scalar("SELECT identity_id FROM memberships WHERE group_slug = 'essai'")
|
||||
.fetch_all(&state.db)
|
||||
.await
|
||||
.expect("members");
|
||||
for m in &members {
|
||||
sqlx::query("INSERT INTO votes (proposal_id, identity_id, choice, revision, cast_at) VALUES (?, ?, 'for', 1, '2026-10-31T00:00:00Z')")
|
||||
.bind(&b)
|
||||
.bind(m)
|
||||
.execute(&state.db)
|
||||
.await
|
||||
.expect("vote");
|
||||
}
|
||||
yvv.ok("POST", &format!("/api/proposals/{a}/withdraw"), None).await;
|
||||
let status: String =
|
||||
sqlx::query_scalar("SELECT status FROM proposals WHERE id = ?").bind(&b).fetch_one(&state.db).await.expect("b");
|
||||
assert_eq!(status, "in_force");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn a_message_is_read_by_its_recipient_only() {
|
||||
let (mut chiara, _) = Client::new().await;
|
||||
chiara.login("Chiara", false).await;
|
||||
let mut yvv = chiara.other();
|
||||
yvv.login("Yvv", true).await;
|
||||
let doc = chiara.doc("essai").await;
|
||||
let e1 = unit(&doc, "E1")["inForce"]["id"].as_str().expect("e1").to_owned();
|
||||
let b = chiara
|
||||
.propose(&doc, json!({ "title": "Un compte", "body": "Un seul compte membre par personne.", "submit": true }))
|
||||
.await;
|
||||
yvv.object(&b, &e1, "redit", REPEATS).await;
|
||||
let m = chiara.messages().await;
|
||||
let mid = m["messages"][0]["id"].as_str().expect("id").to_owned();
|
||||
assert_eq!(chiara.ok("GET", "/api/messages/unread", None).await["unread"], 1);
|
||||
// Not even an administrator reads or marks someone else's message.
|
||||
assert_eq!(yvv.messages().await["messages"].as_array().expect("m").len(), 0);
|
||||
assert_eq!(yvv.call("POST", &format!("/api/messages/{mid}/read"), None).await.0, StatusCode::NOT_FOUND);
|
||||
assert_eq!(chiara.ok("GET", "/api/messages/unread", None).await["unread"], 1);
|
||||
let mut nobody = chiara.other();
|
||||
assert_eq!(nobody.call("GET", "/api/messages", None).await.0, StatusCode::UNAUTHORIZED);
|
||||
// Marked as read, it stays in the box.
|
||||
assert_eq!(chiara.ok("POST", &format!("/api/messages/{mid}/read"), None).await["unread"], 0);
|
||||
let m = chiara.messages().await;
|
||||
assert_eq!((m["messages"][0]["read"].as_bool(), gestures(&m["messages"][0])), (Some(true), vec!["respond"]));
|
||||
// All at once.
|
||||
let c = chiara
|
||||
.propose(&doc, json!({ "title": "Deux", "body": "Deux comptes membres par personne.", "submit": true }))
|
||||
.await;
|
||||
yvv.object(&c, &e1, "contredit", CONTRADICTS).await;
|
||||
assert_eq!(chiara.ok("GET", "/api/messages/unread", None).await["unread"], 1);
|
||||
assert_eq!(chiara.ok("POST", "/api/messages/read", None).await["unread"], 0);
|
||||
}
|
||||
|
||||
/// A deadline passed while the server was stopped is dealt with at the next start.
|
||||
/// One person, one objection on a pair: sent several times at once, one stands
|
||||
/// (relecture of cd7e683: four simultaneous requests made four).
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 4)]
|
||||
async fn the_same_objection_sent_at_once_stands_once() {
|
||||
let path = std::env::temp_dir().join(format!("ld-lot-f-twice-{}.db", std::process::id()));
|
||||
let _ = std::fs::remove_file(&path);
|
||||
let db = open_db(&format!("sqlite://{}", path.display())).await.expect("db");
|
||||
let state = AppState::new(production(), db).await.expect("state");
|
||||
seed::ensure(&state).await.expect("seed");
|
||||
let mut chiara = Client { router: app(state.clone()), cookie: None };
|
||||
chiara.login("Chiara", false).await;
|
||||
let mut yvv = chiara.other();
|
||||
yvv.login("Yvv", false).await;
|
||||
let doc = chiara.doc("essai").await;
|
||||
let e1 = unit(&doc, "E1")["inForce"]["id"].as_str().expect("e1").to_owned();
|
||||
let b = chiara
|
||||
.propose(&doc, json!({ "title": "Un compte", "body": "Un seul compte membre par personne.", "submit": true }))
|
||||
.await;
|
||||
let sends = (0..8).map(|_| {
|
||||
let mut c = Client { router: yvv.router.clone(), cookie: yvv.cookie.clone() };
|
||||
let (b, e1) = (b.clone(), e1.clone());
|
||||
tokio::spawn(async move {
|
||||
c.call(
|
||||
"POST",
|
||||
&format!("/api/proposals/{b}/objections"),
|
||||
Some(json!({ "otherId": e1, "kind": "redit", "reason": REPEATS })),
|
||||
)
|
||||
.await
|
||||
})
|
||||
});
|
||||
let mut answers = Vec::new();
|
||||
for s in sends.collect::<Vec<_>>() {
|
||||
let (status, v) = s.await.expect("join");
|
||||
answers.push((status, v["error"].as_str().unwrap_or_default().to_owned()));
|
||||
}
|
||||
assert_eq!(answers.iter().filter(|(s, _)| *s == StatusCode::OK).count(), 1, "{answers:?}");
|
||||
assert!(
|
||||
answers.iter().all(|(s, e)| *s == StatusCode::OK || (*s == StatusCode::CONFLICT && e == "objection_exists")),
|
||||
"{answers:?}"
|
||||
);
|
||||
let standing: i64 = sqlx::query_scalar("SELECT COUNT(*) FROM signals WHERE lifted_at IS NULL")
|
||||
.fetch_one(&state.db)
|
||||
.await
|
||||
.expect("count");
|
||||
assert_eq!(standing, 1);
|
||||
state.db.close().await;
|
||||
let _ = std::fs::remove_file(&path);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn a_deadline_passed_while_stopped_is_taken_at_start() {
|
||||
let path = std::env::temp_dir().join(format!("ld-lot-f-{}.db", std::process::id()));
|
||||
let _ = std::fs::remove_file(&path);
|
||||
let url = format!("sqlite://{}", path.display());
|
||||
let (b, oid) = {
|
||||
let db = open_db(&url).await.expect("db");
|
||||
let state = AppState::new(production(), db).await.expect("state");
|
||||
seed::ensure(&state).await.expect("seed");
|
||||
let mut chiara = Client { router: app(state.clone()), cookie: None };
|
||||
chiara.login("Chiara", false).await;
|
||||
let mut yvv = chiara.other();
|
||||
yvv.login("Yvv", false).await;
|
||||
let doc = chiara.doc("essai").await;
|
||||
let e1 = unit(&doc, "E1")["inForce"]["id"].as_str().expect("e1").to_owned();
|
||||
let b = chiara
|
||||
.propose(
|
||||
&doc,
|
||||
json!({ "title": "Un compte", "body": "Un seul compte membre par personne.", "submit": true }),
|
||||
)
|
||||
.await;
|
||||
let oid = yvv.object(&b, &e1, "redit", REPEATS).await["id"].as_str().expect("id").to_owned();
|
||||
let reworded = "Un seul compte membre par être humain pour la certifcation.";
|
||||
chiara
|
||||
.ok("PATCH", &format!("/api/proposals/{b}"), Some(json!({ "title": "Un compte", "body": reworded })))
|
||||
.await;
|
||||
// And a typo fix of Yvv's on it, waiting for Chiara.
|
||||
let fixed = reworded.replace("certifcation", "certification");
|
||||
let fix =
|
||||
yvv.ok("PATCH", &format!("/api/proposals/{b}"), Some(json!({ "title": "Un compte", "body": fixed }))).await;
|
||||
assert_eq!(fix["edit"], "typo_pending");
|
||||
state.db.close().await;
|
||||
(b, oid)
|
||||
};
|
||||
// Four days later, the server starts again.
|
||||
let db = open_db(&url).await.expect("db");
|
||||
let state = AppState::new(Config::for_tests(), db).await.expect("state");
|
||||
state.clock.advance(4 * 86_400);
|
||||
let swept = deadlines::sweep(&state).await.expect("start");
|
||||
assert_eq!((swept.lifted, swept.applied), (1, 1));
|
||||
let (phase, lift): (String, Option<String>) = sqlx::query_as("SELECT phase, lift FROM signals WHERE id = ?")
|
||||
.bind(&oid)
|
||||
.fetch_one(&state.db)
|
||||
.await
|
||||
.expect("o");
|
||||
assert_eq!((phase.as_str(), lift.as_deref()), ("lifted", Some("deadline")));
|
||||
let body: String =
|
||||
sqlx::query_scalar("SELECT body FROM proposals WHERE id = ?").bind(&b).fetch_one(&state.db).await.expect("b");
|
||||
assert_eq!(body, "Un seul compte membre par être humain pour la certification.");
|
||||
state.db.close().await;
|
||||
let _ = std::fs::remove_file(&path);
|
||||
}
|
||||
|
||||
/// The contradiction signals of before become objections « contredit », without
|
||||
/// loss, on a base migrated up to the version before (0001 to 0007).
|
||||
#[tokio::test]
|
||||
async fn contradiction_signals_of_before_become_contredits() {
|
||||
use sqlx::sqlite::SqliteConnectOptions;
|
||||
use std::str::FromStr;
|
||||
let options = SqliteConnectOptions::from_str("sqlite::memory:").expect("url").foreign_keys(true);
|
||||
let pool = sqlx::sqlite::SqlitePoolOptions::new().max_connections(1).connect_with(options).await.expect("pool");
|
||||
let mut before = sqlx::migrate!("./migrations");
|
||||
before.migrations = before.migrations.iter().filter(|m| m.version < 8).cloned().collect::<Vec<_>>().into();
|
||||
before.run(&pool).await.expect("up to 0007");
|
||||
let state = AppState::new(production(), pool.clone()).await.expect("state");
|
||||
// The documents as the image of before wrote them (its amendments read tables of now).
|
||||
seed::documents(&state).await.expect("seed");
|
||||
let id = |code: &'static str, which: &'static str| {
|
||||
let pool = pool.clone();
|
||||
async move {
|
||||
let sql = if which == "in_force" {
|
||||
"SELECT p.id FROM proposals p JOIN units u ON u.id = p.unit_id JOIN documents d ON d.id = u.doc_id
|
||||
WHERE d.group_slug = 'essai' AND u.code = ? AND p.status = 'in_force'"
|
||||
} else {
|
||||
"SELECT p.id FROM proposals p JOIN units u ON u.id = p.unit_id JOIN documents d ON d.id = u.doc_id
|
||||
WHERE d.group_slug = 'essai' AND u.code = ? AND p.status = 'open' ORDER BY p.rowid LIMIT 1"
|
||||
};
|
||||
sqlx::query_scalar::<_, String>(sql).bind(code).fetch_one(&pool).await.expect("id")
|
||||
}
|
||||
};
|
||||
let (e2, k1, e4, e5) =
|
||||
(id("E2", "in_force").await, id("K1", "open").await, id("E4", "open").await, id("E5", "open").await);
|
||||
sqlx::query("INSERT INTO identities (id, kind, display_name, created_at) VALUES ('dev:chiara', 'dev', 'Chiara', '2026-10-30T00:00:00Z')")
|
||||
.execute(&pool)
|
||||
.await
|
||||
.expect("identity");
|
||||
let signal = |sid: &'static str, from: String, to: String, lifted: Option<&'static str>| {
|
||||
let pool = pool.clone();
|
||||
async move {
|
||||
sqlx::query(
|
||||
"INSERT INTO signals (id, proposal_id, target_proposal_id, reason, author_id, created_at, lifted_at, lifted_by)
|
||||
VALUES (?, ?, ?, 'Ces deux textes se contredisent.', 'dev:chiara', '2026-10-30T00:00:00Z', ?, ?)",
|
||||
)
|
||||
.bind(sid)
|
||||
.bind(from)
|
||||
.bind(to)
|
||||
.bind(lifted.map(|_| "2026-10-30T01:00:00Z"))
|
||||
.bind(lifted)
|
||||
.execute(&pool)
|
||||
.await
|
||||
.expect("signal");
|
||||
}
|
||||
};
|
||||
signal("s1", k1.clone(), e2.clone(), None).await;
|
||||
signal("s2", e4.clone(), e5.clone(), None).await;
|
||||
signal("s3", k1.clone(), e2.clone(), Some("dev:chiara")).await;
|
||||
signal("s4", e5.clone(), e2.clone(), Some("revision")).await;
|
||||
// Standing against a version withdrawn since: the rule of before lifted nothing
|
||||
// on a withdrawal; now a withdrawal ends the objection (relecture of cd7e683).
|
||||
let spare: Vec<String> = sqlx::query_scalar(
|
||||
"SELECT p.id FROM proposals p JOIN units u ON u.id = p.unit_id JOIN documents d ON d.id = u.doc_id
|
||||
WHERE d.group_slug = 'essai' AND p.status = 'open' AND p.id NOT IN (?, ?, ?) ORDER BY p.rowid LIMIT 2",
|
||||
)
|
||||
.bind(&k1)
|
||||
.bind(&e4)
|
||||
.bind(&e5)
|
||||
.fetch_all(&pool)
|
||||
.await
|
||||
.expect("two more");
|
||||
let (live, gone) = (spare[0].clone(), spare[1].clone());
|
||||
sqlx::query("UPDATE proposals SET status = 'withdrawn' WHERE id = ?")
|
||||
.bind(&gone)
|
||||
.execute(&pool)
|
||||
.await
|
||||
.expect("withdraw");
|
||||
signal("s5", live.clone(), gone.clone(), None).await;
|
||||
// The image of now starts on it.
|
||||
let mut all = sqlx::migrate!("./migrations");
|
||||
all.set_ignore_missing(true);
|
||||
all.run(&pool).await.expect("0008");
|
||||
type Row = (String, String, String, Option<String>, Option<String>);
|
||||
let rows: Vec<Row> = sqlx::query_as("SELECT id, kind, phase, held_id, lift FROM signals ORDER BY id")
|
||||
.fetch_all(&pool)
|
||||
.await
|
||||
.expect("rows");
|
||||
assert_eq!(
|
||||
rows,
|
||||
[
|
||||
("s1".into(), "contredit".into(), "author".into(), Some(k1.clone()), None),
|
||||
("s2".into(), "contredit".into(), "author".into(), Some(e5.clone()), None),
|
||||
("s3".into(), "contredit".into(), "lifted".into(), None, Some("objector".into())),
|
||||
("s4".into(), "contredit".into(), "lifted".into(), None, Some("revision".into())),
|
||||
("s5".into(), "contredit".into(), "lifted".into(), None, Some("withdrawn".into())),
|
||||
],
|
||||
"K1 against the text in force; E5 written after E4 in the same second"
|
||||
);
|
||||
// Nothing lost: reasons, authors and dates as they were.
|
||||
let kept: i64 = sqlx::query_scalar(
|
||||
"SELECT COUNT(*) FROM signals WHERE reason = 'Ces deux textes se contredisent.' AND author_id = 'dev:chiara'
|
||||
AND created_at = '2026-10-30T00:00:00Z'",
|
||||
)
|
||||
.fetch_one(&pool)
|
||||
.await
|
||||
.expect("count");
|
||||
assert_eq!(kept, 5);
|
||||
// And the server reads them as objections: K1 and E5 are no longer votable.
|
||||
let st = ld_server::docs::load_state(
|
||||
&state,
|
||||
&sqlx::query_scalar::<_, String>("SELECT id FROM documents WHERE group_slug = 'essai'")
|
||||
.fetch_one(&pool)
|
||||
.await
|
||||
.expect("doc"),
|
||||
)
|
||||
.await
|
||||
.expect("state");
|
||||
assert!(
|
||||
st.blocked.contains(&k1) && st.blocked.contains(&e5) && !st.blocked.contains(&e4) && !st.blocked.contains(&e2)
|
||||
);
|
||||
assert!(!st.blocked.contains(&live), "nothing holds a version back against one withdrawn");
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user