le contrat

Protocole v1

Version 1. Ce document fait foi. Le coordinateur et l’app Android doivent l’implémenter à l’identique ; toute divergence est un bug, pas une variante.

docs/PROTOCOL.md5950f1a
version
1
articles
16
lignes
590
dernière révision
28 août 2026
Sommaire20 sections

Principe directeur

Le nœud tire, il n’est jamais poussé. Un nœud qui meurt (batterie, doze, HyperOS, réseau coupé) est un non-événement : il cesse simplement de réclamer du travail. Aucun chemin de code ne suppose qu’un nœud est joignable.

Le coordinateur décide. Le nœud annonce son état ; il ne choisit pas ses jobs. C’est le coordinateur qui sait qu’un téléphone à 12 % de batterie et à 47 °C ne doit pas être réquisitionné.

Transport

NATS 2.x avec JetStream, sur le VPS.

UsageAccès
Nœuds (téléphone, laptop, PC)wss://nats-cluster.vpsdashboard.space
Coordinateur (même réseau Docker)nats://nats-cluster:4222
Monitoringhttp://nats-cluster:8222

plus large que l’écran — faites-le glisser

Le nœud ouvre une connexion sortante. Aucun port entrant, aucune IP publique, aucun VPN : le CGNAT de l’opérateur mobile n’est pas un sujet.

Authentification : un token par nœud, révocable individuellement.

Identité d’un nœud

nodeId — UUID v4 généré au premier lancement de l’app, persisté. Il survit aux mises à jour ; il change seulement si l’utilisateur réinitialise le nœud.

Sujets NATS

SujetSensQui publie
cluster.v1.registerrequest/replynœud → coordinateur
cluster.v1.telemetry.{nodeId}publicationnœud
cluster.v1.poprequest/replynœud → coordinateur
cluster.v1.progress.{jobId}publicationnœud
cluster.v1.result.{jobId}publicationnœud
cluster.v1.control.{nodeId}publicationcoordinateur → nœud
cluster.v1.logs.{nodeId}publicationnœud
cluster.v1.state.{nodeId}publicationnœud

plus large que l’écran — faites-le glisser

§1Enregistrement — cluster.v1.register

À chaque connexion (et après toute reconnexion), le nœud annonce ce qu’il sait faire. Idempotent : ré-enregistrer écrase la fiche précédente.

corps du message16 lignes
{
  "protocol": 1,
  "nodeId": "9f2c...",
  "name": "Redmi Ichai",
  "platform": "android",
  "os": "Android 14 / HyperOS 1.0.7.0.UOOMIXM",
  "soc": "mt6878",
  "cpuCores": 8,
  "ramTotalMb": 12288,
  "appVersion": "0.1.0",
  "backends": ["cpu", "vulkan"],
  "models": [
    { "id": "qwen2.5-3b-instruct-q4_k_m", "sizeMb": 1930, "ctxMax": 8192 }
  ],
  "maxCtx": 8192
}

Réponse :

corps du message1 ligne
{ "ok": true, "protocol": 1, "serverTime": 1756329600000, "popIntervalMs": 3000 }

Un protocol non reconnu entraîne { "ok": false, "error": "protocol_unsupported" }. Le nœud doit alors se mettre en pause et le signaler dans son interface.

§2Télémétrie — cluster.v1.telemetry.{nodeId}

Publiée toutes les 10 s tant que le service tourne. C’est le battement de cœur : c’est elle, et rien d’autre, qui prouve qu’un nœud est vivant.

corps du message17 lignes
{
  "protocol": 1,
  "nodeId": "9f2c...",
  "ts": 1756329600000,
  "state": "idle",
  "batteryPct": 87,
  "charging": true,
  "thermalStatus": 1,
  "socTempC": 38.4,
  "cpuLoad": 0.12,
  "ramFreeMb": 6100,
  "network": "wifi",
  "screenOn": false,
  "loadedModel": "qwen2.5-3b-instruct-q4_k_m",
  "currentJobId": null,
  "tokensPerSec": 8.4
}

stateidle | busy | paused | loading | error.

thermalStatus reprend PowerManager.THERMAL_STATUS_* d’Android (0 = aucun, jusqu’à 6 = shutdown). networkwifi | cellular | none.

Un nœud sans télémétrie depuis 45 s est marqué stale et cesse d’être éligible. Ses jobs en vol sont remis en file (voir §6).

§3Réclamation de travail — cluster.v1.pop

Le nœud demande du travail, en joignant son état courant. Il n’y a pas de file par nœud : c’est le coordinateur qui décide, à cet instant précis, si ce nœud mérite un job.

Requête :

corps du message1 ligne
{ "protocol": 1, "nodeId": "9f2c...", "telemetry": { ... }, "canAccept": ["qwen2.5-3b-instruct-q4_k_m"] }

Réponse quand il n’y a rien (ou que le nœud est jugé inéligible) :

corps du message1 ligne
{ "job": null, "retryAfterMs": 3000, "reason": "no_work" }

reasonno_work | battery_low | thermal | paused | network_policy | model_unavailable. Le nœud affiche cette raison dans son interface : c’est ce qui rend le système compréhensible au lieu d’opaque.

Réponse avec travail :

corps du message10 lignes
{
  "job": {
    "jobId": "01J8...",
    "model": "qwen2.5-3b-instruct-q4_k_m",
    "messages": [{ "role": "user", "content": "..." }],
    "params": { "temperature": 0.7, "maxTokens": 512, "topP": 0.95, "stop": [] },
    "stream": true,
    "deadlineMs": 120000
  }
}

Le nœud a deadlineMs pour terminer. Au-delà, le coordinateur considère le job perdu et le remet en file — le nœud doit abandonner sa génération s’il découvre qu’il a dépassé la deadline, pour ne pas produire un résultat en double.

Cadence de pop : popIntervalMs (défaut 3000 ms) en idle. Le nœud n’appelle jamais pop pendant qu’il est busy.

§4Progression — cluster.v1.progress.{jobId}

Publiée pendant la génération quand stream est vrai. Un message par lot de tokens (le nœud regroupe, il ne publie pas un message par token).

corps du message1 ligne
{ "protocol": 1, "jobId": "01J8...", "nodeId": "9f2c...", "seq": 12, "delta": "le texte généré", "done": false }

seq est strictement croissant à partir de 0. Le coordinateur réordonne sur seq et ignore tout doublon.

§5Résultat — cluster.v1.result.{jobId}

Publié une fois, à la fin.

corps du message10 lignes
{
  "protocol": 1,
  "jobId": "01J8...",
  "nodeId": "9f2c...",
  "ok": true,
  "content": "réponse complète",
  "usage": { "promptTokens": 42, "completionTokens": 310 },
  "timings": { "loadMs": 0, "promptMs": 890, "genMs": 36400, "tokensPerSec": 8.5 },
  "finishReason": "stop"
}

finishReasonstop | length | abort | error. En cas d’échec : { "ok": false, "error": "oom" | "load_failed" | "aborted" | "..." }.

§6Reprise après mort d’un nœud

C’est le cœur du système, et la raison pour laquelle NATS/JetStream est là.

  • Un job popé passe en vol avec une deadline.
  • Le coordinateur balaie les jobs en vol toutes les 5 s.
  • Un job dont la deadline est dépassée, ou dont le nœud est stale, retourne en file, attempts += 1.
  • Au-delà de 3 tentatives, le job part en échec définitif et l’appelant reçoit une erreur — jamais une attente infinie silencieuse.
  • Un job remis en file peut repartir sur un autre nœud. Les tokens déjà streamés sont abandonnés : le client reçoit la génération du nœud qui va au bout.

§7Contrôle — cluster.v1.control.{nodeId}

Le seul canal descendant. Le nœud y est abonné tant qu’il est connecté ; s’il est mort, le message est perdu — et c’est acceptable, aucun ordre n’est critique.

corps du message1 ligne
{ "protocol": 1, "action": "pause" | "resume" | "abort" | "reload_model" | "ping", "jobId": "01J8...", "model": "..." }

§8Politique d’éligibilité

Évaluée par le coordinateur à chaque pop, sur la télémétrie jointe. Valeurs par défaut, surchargeables par nœud :

RègleSeuil par défautreason renvoyé
Batterie sous seuil et non branché< 40 %battery_low
Statut thermique Android>= 3 (severe)thermal
Température SoC> 45 °Cthermal
Réseau mobile alors que WiFi exigénetwork_policy
Nœud en pause manuellepaused
Modèle demandé non chargé ni disponiblemodel_unavailable

plus large que l’écran — faites-le glisser

Ces seuils vivent côté coordinateur et sont réglables dans l’app : le nœud a le dernier mot sur lui-même. Un nœud qui se juge inapte ne poppe pas, quoi que pense le coordinateur.

§9API publique du coordinateur

POST /v1/chat/completions, compatible OpenAI, streaming SSE inclus.

Le champ model accepte un identifiant de modèle. Si aucun nœud ne le sert, la requête est mise en file d’attente durable — conformément à la politique arrêtée, il n’y a pas de repli vers une API cloud. Le client peut passer x-cluster-timeout: <ms> pour borner son attente ; sans en-tête, il attend.

GET /v1/models liste l’union des modèles annoncés par les nœuds vivants.

§10Journal distant — cluster.v1.logs.{nodeId}

Ajouté le 27/08/2026. Un nœud vit dans une poche : on ne peut pas aller lire son journal. Il le pousse donc lui-même, en continu, et le coordinateur le conserve. C’est ce qui rend un nœud diagnosticable à distance sans y toucher.

Publié par lots — jamais un message par ligne. Un lot part toutes les 5 s s’il y a quelque chose à dire, ou dès que 50 lignes sont en attente.

corps du message8 lignes
{
  "protocol": 1,
  "nodeId": "9f2c...",
  "lines": [
    { "ts": 1756329600000, "level": "info", "tag": "nats", "msg": "connecté à wss://..." },
    { "ts": 1756329601000, "level": "error", "tag": "llama", "msg": "échec de chargement: ..." }
  ]
}

leveldebug | info | warn | error.

Côté nœud : tampon circulaire en mémoire de 1000 lignes. Si la connexion est coupée, les lignes s’accumulent et partent à la reconnexion — un nœud qui a eu un problème réseau doit pouvoir raconter ce qui s’est passé pendant sa coupure. Au-delà du tampon, les plus anciennes sont perdues : le journal est un outil de diagnostic, pas une archive.

Côté coordinateur : tampon circulaire de 2000 lignes par nœud, en mémoire.

Exposition : GET /api/nodes/{nodeId}/logs?limit=200&level=warn et GET /api/logs pour le flux fusionné de tous les nœuds, le plus récent en tête.

Aucune donnée de conversation dans le journal. Ni prompt, ni réponse générée, jamais — seulement des événements techniques. Le journal traverse le réseau et se lit côté serveur : il ne doit pas devenir une fuite du contenu des requêtes.

§11État complet du nœud — cluster.v1.state.{nodeId}

Ajouté le 27/08/2026. La télémétrie (§2) est un battement de cœur : petite, fréquente, réduite à ce dont le scheduler a besoin pour décider. Cet objet-ci est l’inverse : l’inventaire complet de ce que le nœud est en train de vivre, pour qu’on puisse tout voir à distance sans jamais toucher l’appareil.

Publié toutes les 60 s, et immédiatement à chaque changement notable : modèle chargé ou déchargé, début ou fin de téléchargement, réglage modifié, job commencé ou terminé, permission accordée, reconnexion NATS, passage en pause.

corps du message68 lignes
{
  "protocol": 1,
  "nodeId": "9f2c...",
  "ts": 1756329600000,

  "identity": {
    "name": "Redmi Ichai",
    "appVersion": "0.3.0", "versionCode": 3,
    "installedAt": 1756300000000
  },

  "device": {
    "manufacturer": "Xiaomi", "model": "24090RA29G",
    "soc": "mt6878", "abi": "arm64-v8a",
    "android": "14", "sdkInt": 34, "rom": "HyperOS 1.0.7.0.UOOMIXM",
    "cpuCores": 8, "ramTotalMb": 12288,
    "storageFreeMb": 41200
  },

  "runtime": {
    "serviceUptimeS": 4210, "serviceStartCount": 3,
    "connectedSince": 1756325400000, "natsReconnects": 2,
    "lastCrash": { "ts": 1756200000000, "message": "...", "stack": "..." }
  },

  "models": {
    "installed": [
      { "id": "qwen2.5-3b-instruct-q4_k_m", "fileName": "...gguf",
        "sizeBytes": 1929903264, "downloadedAt": 1756310000000, "sizeVerified": true }
    ],
    "loaded": {
      "id": "qwen2.5-3b-instruct-q4_k_m",
      "backend": "cpu", "ctxSize": 4096, "loadMs": 2400, "ramUsedMb": 2100
    },
    "downloading": {
      "id": "llama-3.2-3b-instruct-q4_k_m",
      "receivedBytes": 812000000, "totalBytes": 2019377696, "bytesPerSec": 1900000
    }
  },

  "settings": {
    "natsHost": "nats-cluster.vpsdashboard.space",
    "tokenSet": true,
    "minBatteryPct": 40, "wifiOnly": true, "allowWhenScreenOn": false,
    "paused": false, "logsEnabled": true, "contextSize": 4096
  },

  "permissions": {
    "notifications": true, "installPackages": true,
    "ignoringBatteryOptimizations": true, "bootCompleted": true
  },

  "power": {
    "batteryPct": 87, "charging": true, "plugged": "ac",
    "thermalStatus": 1, "socTempC": 38.4,
    "screenOn": false, "network": "wifi", "deviceIdleMode": false
  },

  "jobs": {
    "completed": 42, "failed": 1, "requeued": 0,
    "current": { "jobId": "01J8...", "startedAt": 1756329580000, "tokensSoFar": 120 },
    "last": { "jobId": "01J8...", "model": "qwen2.5-3b-instruct-q4_k_m",
              "promptTokens": 42, "completionTokens": 310,
              "tokensPerSec": 8.5, "totalMs": 37290, "finishReason": "stop" }
  },

  "lastReject": { "ts": 1756329500000, "reason": "thermal", "detail": "SoC 47 °C > 45 °C" }
}

Jamais de contenu de conversation ici non plus — ni prompt, ni réponse, ni extrait. Des compteurs, des identifiants, des mesures. Le champ tokenSet est un booléen : le token lui-même ne quitte jamais l’appareil.

settings.natsHost est l’hôte seul, sans le token.

Côté coordinateur : conserver le dernier état de chaque nœud plus un historique des 200 derniers jobs par nœud (identifiants, timings, débits — pas de contenu). Exposition :

  • GET /api/nodes/{nodeId}/state — l’inventaire complet ci-dessus
  • GET /api/nodes/{nodeId}/jobs?limit=50 — l’historique des jobs
  • GET /api/overview — une vue unique rassemblant, pour tous les nœuds : état, modèle chargé, éligibilité et sa raison, dernier job, erreurs récentes, profondeur de file. C’est la page à lire quand on veut tout savoir d’un coup.

§12Calibration — mesurer au lieu de croire les fiches techniques

Ajouté le 28/08/2026. Les specs mentent : le Redmi Note 14 Pro annonce 12 Go de RAM, ce qui laisse croire qu’un 8B passe, alors que sa bande passante de 51,2 Go/s le plafonne — mesuré à 4 à 5 tok/s sur un 3B. On ne déduit donc plus le modèle des specs : on mesure.

Au premier lancement, puis après tout changement matériel notable (nouvelle ROM, modèle différent), le nœud exécute une génération de calibration courte (~64 tokens, prompt fixe) et publie le résultat dans register :

corps du message9 lignes
"capability": {
  "measuredAt": 1756329600000,
  "model": "qwen2.5-3b-instruct-q4_k_m",
  "tokPerSec": 4.6,
  "promptTokPerSec": 8.1,
  "loadMs": 1460,
  "backend": "cpu",
  "tier": "leger"
}

tierleger (< 8 tok/s) · moyen (8–25) · rapide (25–80) · gros (> 80). Le palier est déduit du débit MESURÉ, jamais de la RAM.

§13Identité, propriété et cloisonnement

Ajouté le 28/08/2026. Le cluster devient multi-utilisateur.

Deux familles de clés, révocables séparément. Une clé de nœud autorise un appareil à se connecter à NATS. Une clé d’API autorise une personne à envoyer des requêtes. Elles ne se mélangent jamais : perdre un téléphone ne compromet pas les requêtes, et retirer l’accès à quelqu’un ne coupe pas les appareils.

Codes d’invitation. Un code à usage unique généré par l’administrateur s’échange sur le site contre une clé d’API. Aucun compte, aucun mot de passe.

Propriétaire. Chaque nœud déclare un ownerId dans register. Chaque clé d’API porte le même identifiant pour son détenteur.

Cloisonnement d’une requête. Le champ visibility d’un job vaut public (défaut) ou private.

  • public : le job va sur n’importe quel nœud éligible. C’est le cercle de confiance assumé — le nœud qui génère voit le texte, et c’est dit clairement.
  • private : le job n’est servi que par les nœuds dont l’ownerId est celui de l’appelant. S’il n’en a aucun de disponible, le job attend — il ne bascule JAMAIS sur l’appareil de quelqu’un d’autre. Un refus de pop pour cette raison renvoie reason: "private_no_node".

Côté API OpenAI : en-tête x-cluster-private: 1, ou "visibility": "private" dans le corps.

§14Correctif de télémétrie — température

Ajouté le 28/08/2026. socTempC est inexploitable : Android interdit à une app ordinaire de lire /sys/class/thermal, le champ remonte vide. cpuLoad est mort pour la même raison (/proc/stat est bloqué).

La télémétrie (§2) porte donc désormais batteryTempC, lu via BatteryManager.EXTRA_TEMPERATURE — accessible sans privilège, au dixième de degré. Ce n’est pas le SoC, mais sur un téléphone les deux sont couplés, et c’est un signal continu là où thermalStatus n’est qu’un palier grossier qui ne bouge qu’une fois le bridage déjà commencé.

Seuil d’éligibilité : batteryTempC > 42 °Cthermal. socTempC et cpuLoad restent facultatifs et ne sont plus utilisés pour décider.

§15Profilage d’appareil — prédire au lieu d’essayer

Ajouté le 28/08/2026, remplace la calibration naïve du §12.

Le problème. On ne peut pas télécharger cinq modèles de 2 Go chacun pour découvrir lequel convient. Il faut mesurer l’APPAREIL une fois, puis PRÉDIRE le débit de chaque modèle du catalogue sans en télécharger aucun.

Le principe. La génération de tokens est bornée par la bande passante mémoire, pas par le processeur : produire un token oblige à relire tout le modèle. Le traitement du prompt, lui, est borné par le calcul. Ces deux grandeurs se mesurent sans aucun modèle, en quelques secondes.

§15.1Le profil — six mesures, aucun modèle requis

MesureMéthodeCe qu’elle prédit
memBandwidthGBslecture en flux d’un tampon nettement plus grand que le cache de dernier niveau, en montant le nombre de fils jusqu’au plateaule débit de génération
computeGFLOPSproduit matriciel dense dont les opérandes tiennent en cachela vitesse de traitement du prompt
usableMemMBplus grande allocation réellement obtenue, par doublements puis dichotomie, immédiatement libéréequels modèles tiennent vraiment
sustainRatiola même charge tenue 90 s, débit échantillonné toutes les 5 s : rapport fin/début, et instant du décrochagece qu’il reste après l’échauffement
storageReadMBs + storageFreeMBlecture séquentielle d’un fichier temporairele temps de chargement, et si le fichier tient
backends[]pour CHAQUE backend présent (cpu, vulkan, opencl, metal, cuda, rocm), le même produit matriciel, et le gain RÉEL mesurélequel utiliser — jamais déduit de sa présence

plus large que l’écran — faites-le glisser

Durée totale visée : moins de 30 secondes, sans réseau, sans modèle. Le profil est daté et refait si le matériel ou le système change.

§15.2L’étalonnage — une seule génération réelle

Les mesures brutes surestiment : un moteur d’inférence n’atteint jamais la bande passante théorique. Une seule génération réelle, sur le premier modèle installé, donne les deux rendements :

formules2 lignes
etaDecode  = tokPerSecMesure  / (memBandwidthGBs * 1e9 / tailleModeleOctets)
etaPrefill = promptTokPerSec / (computeGFLOPS * 1e9 / (2 * nbParametres))

Typiquement etaDecode tombe entre 0,15 et 0,45. Il est propre au couple appareil + moteur, et il se réutilise pour tous les autres modèles.

§15.3La prédiction — pour chaque modèle du catalogue

formules4 lignes
decodePrevu_tokS  = etaDecode  * memBandwidthGBs * 1e9 / tailleModeleOctets
prefillPrevu_tokS = etaPrefill * computeGFLOPS  * 1e9 / (2 * nbParametres)
decodeSoutenu     = decodePrevu * sustainRatio
memoireRequise    = tailleModele + cacheKV(ctx) + surcout moteur

Le cache KV n’est pas négligeable : il croît avec la taille de contexte et peut dépasser le modèle lui-même sur un contexte long. Il entre dans le calcul.

§15.4Le choix — une fonction d’aptitude, pas un seuil

Un modèle est réalisable si memoireRequise <= usableMemMB * 0,8 — la marge de 20 % existe parce que le système peut reprendre de la mémoire à tout moment, et qu’un nœud tué en pleine génération est pire qu’un nœud lent.

Parmi les réalisables, on retient le plus grand dont decodeSoutenu reste au-dessus du plancher d’usage :

UsagePlancherPourquoi
Interactif6 tok/sen dessous, l’attente devient pénible
Assistant3 tok/sacceptable si la réponse arrive
Traitement par lots1 tok/sla nuit, personne ne regarde

plus large que l’écran — faites-le glisser

Le plus grand modèle qui tient, pas le plus rapide : à débit suffisant, la qualité prime. C’est l’inverse de la règle naïve actuelle.

§15.5Portabilité

Ces six mesures ne dépendent d’aucune plateforme : ce sont de la mémoire, du calcul, du disque et de la chaleur. La même spécification vaut pour Android aujourd’hui, et demain pour un portable, une tour avec GPU, un iPhone ou un Raspberry Pi. L’implémentation doit donc isoler la mesure du système : un noyau natif portable, et une fine couche par plateforme pour l’énumération des backends et la lecture de la mémoire disponible.

Le profil est publié dans register sous deviceProfile, à côté de capability (§12) qui reste le résultat étalonné.

§16Enrôlement sans friction — un code court, ou rien du tout

Ajouté le 28/08/2026. Aujourd’hui, rejoindre le parc demande de coller à la main une clé de nœud de 48 caractères hexadécimaux, une clé d’API, et de vérifier deux URL. Sur un téléphone, chacune de ces étapes perd des gens. Le but de cette section est qu’il n’en reste aucune.

§16.1Le code d’enrôlement — court, lisible, jetable

Un code de la forme FLK-4H2K : préfixe fixe, puis 4 à 6 caractères d’un alphabet sans ambiguïté visuelle (ni 0/O, ni 1/I/l). Il se dicte au téléphone, se lit sur un écran, se tape sans erreur.

POST /api/enroll { code } — sans authentification, mais strictement limité en débit et à usage unique. Il renvoie TOUT ce dont l’appareil a besoin :

corps du message8 lignes
{
  "nodeKey": "...",
  "apiKey": "...",
  "ownerId": "own_...",
  "natsUrl": "wss://nats.floky.dev:443",
  "coordinatorUrl": "https://api.floky.dev",
  "nodeName": "Téléphone de Malka"
}

L’appareil n’a donc plus jamais rien à saisir : ni URL, ni clé, ni propriétaire. Le code expire (24 h par défaut) et meurt à la première utilisation.

§16.2Le lien profond — zéro saisie

https://floky.dev/rejoindre/FLK-4H2K

  • App installée : le lien l’ouvre directement, elle lit le code et s’enrôle seule.
  • App absente : la page propose le téléchargement et retient le code, pour que le premier lancement le retrouve.

C’est le chemin normal : on envoie un lien par message, la personne appuie, c’est fini.

§16.3Le QR — quand on est dans la même pièce

L’app affiche un QR contenant ce même lien. L’autre le scanne à l’installation. Aucun réseau tiers, aucun message à envoyer.

§16.4Ce que ça impose à l’interface

  • Les champs « clé du nœud », « clé d’API », « URL NATS » et « URL du coordinateur » quittent le chemin principal et passent dans une section « Avancé », repliée. Ils restent modifiables — on ne retire pas le contrôle, on retire l’obligation.
  • Le parcours de premier lancement propose d’abord « J’ai un code » et « Scanner un QR ». La saisie manuelle est un troisième choix, discret.
  • Un code refusé dit pourquoi : expiré, déjà utilisé, inconnu — jamais un échec muet.

§16.5Sécurité

Le code est un secret de courte durée qui donne accès au parc : usage unique, expiration, limitation de débit par IP, et révocable depuis l’administration. Il ne remplace pas les clés — il sert à les livrer, une fois.

fin du protocole v1 · 16 articles · révision 5950f1a du 28 août 2026
Toute divergence entre ce texte, le coordinateur et l’app est un bug, pas une variante.