Aller au contenu

Phase 0 — Architecture

Projet : traçabilité de fûts de bière par QR code (SaaS multi-locataire) Date : 2026-09-18 Statut : proposition soumise à validation. Aucun code n’est écrit avant accord.

Ce document répond point par point au livrable attendu de la Phase 0 : modèle de données complet avec tenant_id et politiques RLS, machine à états, structure du flux d’événements et des projections, arborescence, dépendances justifiées, stratégie de test, contenu du docker compose.

Il s’appuie sur quatre décisions prises en ouverture :

QuestionDécision
Rôle du statut « Reçu »Retour brasserie et état initial à la mise en service
Langue des identifiantsDomaine en français, infrastructure en anglais
GabaritsMaud
Déploiement v1SaaS mutualisé : une instance, un domaine, une base

Aucune décision de la section A.2 de la spécification n’est contestée.


Internet
│ https://app.exemple.fr
▼
┌─────────────┐
│ Caddy │ TLS automatique, en-têtes de sécurité
└──────┬──────┘
│ h2c interne
▼
┌──────────────────────────────────────────────────────┐
│ webqr (Rust) │
│ │
│ crate web routes Axum, gabarits Maud, i18n │
│ crate domaine statuts, transitions, projections │
│ crate persistance SQLx, migrations, garde RLS │
│ crate app binaire : serve + commandes CLI │
└───────┬───────────────────────┬───────────────────────┘
│ rôle webqr_app │ rôle webqr_editeur
│ (soumis à la RLS) │ (back-office éditeur)
▼ ▼
┌──────────────────────────────────────────────────────┐
│ PostgreSQL 17 │
│ RLS active sur chaque table métier │
│ propriétaire des tables : webqr_owner (migrations) │
└──────────────────────────────────────────────────────┘
│
┌──────┴──────┐
│ sauvegarde │ pg_dump -Fc, rotation
└─────────────┘

Le navigateur reçoit du HTML rendu par le serveur. htmx pilote les échanges partiels des écrans de gestion. Une seule page embarque du JavaScript autonome : le scan.


webqr/
├── Cargo.toml # workspace
├── compose.yaml # production
├── compose.dev.yaml # développement (HTTPS inclus)
├── .sqlx/ # données de vérification hors-ligne des requêtes
│
├── crates/
│ ├── domaine/ # cœur métier — aucune dépendance I/O
│ │ ├── statut.rs # enum Statut, transitions, préconditions
│ │ ├── evenement.rs # TypeEvenement, Origine, EvenementACreer
│ │ ├── projection.rs # appliquer(EtatFut, &Evenement) -> EtatFut
│ │ ├── role.rs # Role + capacités
│ │ ├── contenance.rs
│ │ ├── reference.rs # format des références de document
│ │ └── erreur.rs # ErreurMetier, messages français
│ │
│ ├── persistance/
│ │ ├── migrations/ # 0001_roles.sql, 0002_locataire.sql, …
│ │ ├── garde.rs # TransactionLocataire — voir §5
│ │ ├── locataire.rs utilisateur.rs session.rs
│ │ ├── fut.rs evenement.rs document.rs
│ │ ├── client.rs lot.rs referentiel.rs
│ │ ├── audit.rs compteur.rs
│ │ └── reconstruction.rs # rejeu des événements
│ │
│ ├── web/
│ │ ├── routes/ # un module par domaine fonctionnel
│ │ │ └── table.rs # table méthode+chemin -> rôle requis (§9)
│ │ ├── gabarits/ # fonctions Maud
│ │ │ ├── socle.rs composants.rs impression.rs
│ │ │ └── futs.rs clients.rs documents.rs scan.rs …
│ │ ├── middleware/ # session, locataire, csrf, audit, debit
│ │ ├── i18n/
│ │ │ ├── mod.rs # macro t! vérifiée à la compilation
│ │ │ └── fr.rs # catalogue unique
│ │ ├── formulaire.rs # validation + erreurs par champ
│ │ └── erreur.rs # ErreurWeb -> réponse HTML ou fragment htmx
│ │
│ └── app/ # binaire
│ └── commandes/ # serve, provisionner, semer, reconstruire, …
│
├── assets/
│ ├── css/entree.css # source Tailwind
│ ├── js/scan/ # îlot TypeScript
│ │ ├── index.ts douchette.ts camera.ts
│ │ ├── decodeur.ts retour.ts file.ts journal.ts
│ └── vendor/ # htmx, zxing-wasm (aucun CDN)
│
├── statique/ # sortie de build : app.css, scan.js, *.wasm
├── docker/
│ ├── Dockerfile Caddyfile Caddyfile.dev sauvegarde.sh
└── docs/
├── PHASE-0-ARCHITECTURE.md # ce document
├── installation.md exploitation.md guide-utilisateur.md

domaine ne déclare ni sqlx, ni axum, ni tokio. Le compilateur garantit donc que la machine à états et les projections restent testables sans base, et qu’aucune règle métier ne fuit dans un handler HTTP. persistance est séparé de web pour que le test d’isolation multi-locataire attaque la couche données directement, sans passer par HTTP.


Conventions : identifiants uuid (v7 pour les événements, afin que l’ordre d’insertion suive l’ordre temporel), horodatages timestamptz, énumérations PostgreSQL natives en snake_case sans accent, noms de tables au singulier.

locataire — la brasserie.

colonnetypenote
iduuid pk
nomtext
identifiant_courtcitext uniqueslug, utilisé dans les URL d’administration
prefixe_qrtext uniqueunique globalement : deux brasseries ne peuvent pas émettre le même code
emplacement_defaut_iduuid nullFK différée vers emplacement
fuseau_horairetextdéfaut Europe/Paris
seuil_alerte_non_revenu_joursintdéfaut 30
contenances_autoriseesint[]défaut {20,30,50} — voir écart 1 au §14
etatlocataire_etatactif | suspendu
cree_le, modifie_letimestamptz

super_admin — comptes éditeur. Table distincte de utilisateur : aucun compte locataire ne peut être promu éditeur par une écriture malencontreuse. Colonnes : id, email citext unique, mot_de_passe_hash, nom, actif, cree_le.

session_super_admin — même structure que session, sans tenant_id.

journal_audit_editeur — id, super_admin_id, action, locataire_id null, details jsonb, ip, survenu_le. Toute usurpation de support y écrit une entrée d’ouverture et une de fermeture, et la session usurpée porte un bandeau permanent côté interface.

Toutes portent tenant_id uuid not null references locataire(id) et sont soumises à la RLS.

utilisateur — email citext, mot_de_passe_hash, pin_hash null, nom, prenom, role (administrateur | production | lecture), actif, derniere_connexion_le, echecs_connexion, verrouille_jusqu_a. Unique (tenant_id, email).

session — utilisateur_id, jeton_hash bytea unique, appareil_libelle, ip_creation inet, cree_le, expire_le, derniere_activite_le, verrouillee bool, echecs_pin int, pin_bloque_jusqu_a, revoquee_le, jeton_csrf.

type_biere — nom, couleur (hex), actif, ordre. Unique (tenant_id, lower(nom)).

emplacement — nom, actif. Unique (tenant_id, lower(nom)).

cuve — nom, actif. Unique (tenant_id, lower(nom)).

client — societe, contact_nom, contact_prenom, telephone, email, archive bool, métadonnées.

adresse_client — client_id, libelle, rue, code_postal, ville, archive. Contrainte clé : unique (tenant_id, client_id, id). Elle permet aux tables qui référencent un couple client+adresse de poser une clé étrangère composite (tenant_id, client_id, adresse_client_id). La cohérence « l’adresse appartient bien au client » devient alors structurellement impossible à violer, y compris par une requête forgée à la main. La validation applicative reste présente pour produire un message clair, mais elle n’est plus la seule barrière.

lot — numero, date_brassage date, type_biere_id, cuve_id, dluo date, commentaire. Unique (tenant_id, numero).

fut — identité et projection réunies sur une ligne.

groupecolonnes
identitécode_qr (unique (tenant_id, code_qr)), contenance_litres int, date_mise_en_service date, commentaire
projectionstatut, emplacement_id, client_id, adresse_client_id, type_biere_id, lot_id, cuve_id, date_remplissage, statut_depuis_le, dernier_lavage_le, dernier_lavage_par
traçagedernier_evenement_id, dernier_evenement_le, sequence_courante bigint
rechercherecherche_txt text — concaténation code QR + lot + société cliente, tenue à jour par la projection, indexée en GIN trigramme
métadonnéescree_le, cree_par, modifie_le, modifie_par

evenement_fut — la source de vérité, en ajout seul.

colonnenote
iduuid v7
tenant_id, fut_id
sequencebigint, unique (tenant_id, fut_id, sequence) — ordre et contrôle de concurrence
type_evenementmise_en_service | transition | transition_forcee | correction | modification_donnees | import
originescan | document | edition_manuelle | import | systeme
statut_avant, statut_apresstatut_fut null / not null
emplacement_id, client_id, adresse_client_id, type_biere_id, lot_id, cuve_idvaleurs portées par l’événement
commentaire, justificationjustification obligatoire si transition_forcee
document_idnull hors document de mouvement
utilisateur_id, survenu_le, enregistre_le

Immuabilité garantie à deux niveaux : REVOKE UPDATE, DELETE pour le rôle applicatif, et un trigger BEFORE UPDATE OR DELETE qui lève une exception quel que soit le rôle. Une erreur se corrige par un événement de type correction, jamais par une réécriture.

document_mouvement — type (livraison | retour | operation_interne), reference (unique (tenant_id, reference)), etat (brouillon | valide | abandonne), la configuration à appliquer (statut_cible, type_biere_id, lot_id, cuve_id, emplacement_id, client_id, adresse_client_id, commentaire), cree_par, cree_le, valide_par, valide_le.

ligne_document — document_id, fut_id, statut_avant_capture, capture_le, capture_par, evenement_id null, ordre. Unique (tenant_id, document_id, fut_id) : le dédoublonnage est garanti par la base, pas seulement par l’interface.

journal_audit — utilisateur_id, action, cible_type, cible_id, details jsonb, ip, survenu_le.

compteur_reference — (tenant_id, cle) pk, valeur bigint. Numérotation des documents par verrou de ligne, sans trou visible et sans séquence globale partagée entre locataires.

  • fut (tenant_id, statut), fut (tenant_id, client_id), fut (tenant_id, lot_id)
  • fut (tenant_id, dernier_evenement_le desc), fut (tenant_id, statut_depuis_le)
  • fut GIN trigramme sur recherche_txt
  • evenement_fut (tenant_id, fut_id, sequence), (tenant_id, lot_id), (tenant_id, survenu_le desc)
  • ligne_document (tenant_id, document_id)
  • session (jeton_hash)

Toutes les listes sont paginées côté serveur, 50 lignes par page, avec un plafond dur de 500 quelle que soit la valeur reçue du client. La pagination est par limit/offset : voir l’écart 6 au §18.


#[derive(Copy, Clone, PartialEq, Eq, Hash, sqlx::Type)]
#[sqlx(type_name = "statut_fut", rename_all = "snake_case")]
pub enum Statut { Recu, Sale, Propre, Rempli, Livre, HorsService, Perdu }

Côté base, un type énuméré natif statut_fut aux valeurs sans accent (recu, sale, propre, rempli, livre, hors_service, perdu). L’accent n’existe que dans le catalogue de traduction. La classe de bugs de l’ancienne version disparaît par construction.

stateDiagram-v2
    [*] --> Recu : mise en service
    Recu --> Sale : tri
    Sale --> Propre : lavage
    Propre --> Rempli : remplissage
    Rempli --> Livre : livraison
    Livre --> Recu : retour brasserie
    Livre --> Sale : retour direct atelier
    Rempli --> Sale : vidé sur place
    Propre --> Sale : recontaminé
    Recu --> Propre : contrôlé propre

Table complète, Hors service et Perdu inclus :

DepuisVers autorisés
(création)Recu — ou n’importe quel statut par import de reprise
RecuSale, Propre, HorsService, Perdu
SalePropre, HorsService, Perdu
PropreRempli, Sale, HorsService, Perdu
RempliLivre, Sale, HorsService, Perdu
LivreRecu, Sale, HorsService, Perdu
HorsServiceSale, Propre, Perdu
PerduRecu, Sale, HorsService

Perdu et HorsService sont réversibles : un fût se retrouve, un fût se répare. Aucun statut n’est un puits.

TransitionExigences
→ Remplitype de bière, lot et cuve fournis ; le type de bière du lot doit correspondre ; lot appartenant au locataire
→ Livreclient et adresse fournis ; adresse appartenant au client (garanti par clé étrangère composite) ; client non archivé
→ Perdujustification non vide
→ HorsServicejustification non vide
forcéerôle Administrateur et justification non vide
Statut atteintEffet
Recuclient et adresse remis à null ; emplacement = celui de l’événement ou emplacement par défaut. Le contenu est conservé : un fût peut revenir plein.
Salebière, lot, cuve, date de remplissage et client remis à null
Propreenregistre dernier_lavage_le et dernier_lavage_par (traçabilité sanitaire, A.4.8)
Remplibière, lot, cuve posés ; date_remplissage = date de l’événement
Livreclient et adresse posés ; emplacement remis à null — le fût n’est plus dans la brasserie
HorsService, Perducontenu et client conservés : l’histoire reste lisible

Le graphe ci-dessus refuse par exemple Propre → Livre (livrer un fût vide à un client en échange). C’est un cas réel mais marginal : il passe par la transition forcée, réservée aux administrateurs, avec justification obligatoire, et produit un événement transition_forcee visible comme tel dans la chronologie. C’est exactement le rôle que la spécification lui donne — la réalité de l’atelier dépasse le modèle, mais elle laisse une trace.


RôleUsageRLS
webqr_ownerpropriétaire des tables, joue les migrations au démarrage puis n’est plus utilisécontourne (propriétaire)
webqr_apptoutes les requêtes des locatairessoumis
webqr_editeurback-office éditeur uniquementpolitique dédiée USING (true)
webqr_authrésolution de session et vérification de mot de passepolitique dédiée, droits strictement énumérés — voir écart 7 au §18

Aucun de ces rôles n’est superutilisateur et aucun ne porte BYPASSRLS — un test le vérifie explicitement. Le point décisif : webqr_app n’est pas propriétaire des tables, sans quoi PostgreSQL lui ferait contourner les politiques silencieusement. C’est l’erreur classique d’implémentation de la RLS, et la raison pour laquelle le test du §11.1 est écrit en premier.

Trois pools distincts dans l’application ; celui de webqr_owner est fermé une fois les migrations jouées.

Appliquée à l’identique sur chaque table métier :

alter table fut enable row level security;
create policy isolation_locataire on fut
for all to webqr_app
using (tenant_id = nullif(current_setting('app.locataire_id', true), '')::uuid)
with check (tenant_id = nullif(current_setting('app.locataire_id', true), '')::uuid);
create policy acces_editeur on fut for all to webqr_editeur using (true);

Si le paramètre n’est pas positionné, current_setting(…, true) renvoie NULL, la comparaison vaut NULL, et aucune ligne ne remonte. L’échec est fermé par défaut, pas ouvert.

Chaque requête HTTP ouvre une transaction et y positionne le locataire :

crates/persistance/src/garde.rs
pub struct TransactionLocataire<'t>(Transaction<'t, Postgres>);
impl<'t> TransactionLocataire<'t> {
pub async fn ouvrir(pool: &PgPool, locataire: Uuid, utilisateur: Uuid)
-> Result<Self, Erreur>
{
let mut tx = pool.begin().await?;
sqlx::query!("select set_config('app.locataire_id', $1, true)",
locataire.to_string()).execute(&mut *tx).await?;
sqlx::query!("select set_config('app.utilisateur_id', $1, true)",
utilisateur.to_string()).execute(&mut *tx).await?;
Ok(Self(tx))
}
pub fn conn(&mut self) -> &mut PgConnection { &mut self.0 }
pub async fn valider(self) -> Result<(), Erreur> { Ok(self.0.commit().await?) }
}

Le champ interne est privé et aucun autre constructeur n’existe. Il n’est pas possible d’obtenir une connexion applicative sans avoir déclaré le locataire. set_config(…, true) équivaut à SET LOCAL : la valeur est annulée à la fin de la transaction, donc rien ne fuit vers la requête suivante qui réutilisera la même connexion du pool.

Défense en profondeur telle qu’exigée : la colonne tenant_id est présente et utilisée dans les requêtes et la politique la fait respecter. Un WHERE oublié ne donne rien au lieu de tout donner.


Toute modification d’un fût suit la même séquence, dans une seule transaction :

1. select … from fut where id = $1 for update -- sérialise les concurrents
2. domaine::statut::verifier(avant, apres, contexte) -- transition + préconditions
3. insert into evenement_fut (…, sequence = fut.sequence_courante + 1)
4. update fut set <projection = domaine::projection::appliquer(etat, evenement)>,
sequence_courante = …, dernier_evenement_id = …
5. commit

Le verrou FOR UPDATE sur la ligne du fût sérialise deux opérateurs qui scanneraient le même fût en même temps : le second voit l’état réel produit par le premier, et sa transition est réévaluée. L’unicité de (fut_id, sequence) sert de filet en cas d’erreur de programmation.

La fonction de projection vit dans domaine et a la signature d’un pli :

pub fn appliquer(etat: EtatFut, e: &Evenement) -> EtatFut

Pure, sans I/O, donc testable exhaustivement et utilisable telle quelle par la reconstruction.

webqr reconstruire-projections [--locataire <slug>] [--verifier]

Rejoue evenement_fut ordonné par (fut_id, sequence) en repartant de l’état initial, et réécrit les projections. Avec --verifier, calcule dans une table temporaire et produit un diff sans rien écrire, en sortant avec un code non nul s’il existe un écart. Cette variante tourne en intégration continue sur le jeu de démonstration : c’est le test de cohérence décrit en A.4.1.

« Où était ce fût le 12 mars » est une lecture de evenement_fut filtrée par date. La durée de rotation est une différence entre événements successifs. Le temps passé chez un client est la somme des intervalles Livre → Recu. Les fûts immobilisés sont un filtre sur statut_depuis_le. Le rappel produit est une jointure evenement_fut × lot × client. Aucune de ces fonctions ne demande de table supplémentaire.


Configuration Capture en rafale Validation
(statut cible, → POST /documents/:id/lignes → une transaction,
bière, lot, cuve, une ligne par scan, tout ou rien
client, adresse, persistée immédiatement
commentaire) côté serveur

Le brouillon vit en base dès l’étape 1. Rafraîchissement, veille du téléphone, batterie à plat, changement d’appareil : la capture est reprise telle quelle. C’est ce qui rend la couche hors-ligne inutile (A.7).

POST /documents-mouvement/:id/lignes reçoit { code_qr } et renvoie un fragment htmx : la nouvelle ligne, le compteur mis à jour, et un en-tête HX-Trigger que l’îlot de scan traduit en retour sonore et haptique.

Quatre issues, toutes traitées à la capture :

CasRéponse
Fût trouvé, transition possibleligne ajoutée, retour vert
Fût déjà présent dans le brouillonavertissement, aucune ligne créée (unicité en base)
Code QR inconnuerreur rouge nommée, aucune ligne, le scan continue
Transition impossiblerefus immédiat avec la raison (« fût Sale, ne peut pas partir en livraison »)

Le refus anticipé est le point important : l’opérateur l’apprend pendant qu’il a le fût en main, pas dix minutes plus tard devant l’écran de validation.

Une transaction unique :

  1. select … from fut where id = any($1) order by id for update — l’ordre par identifiant évite les interblocages entre deux validations concurrentes ;
  2. réévaluation de chaque transition, car l’état a pu changer depuis la capture ;
  3. un evenement_fut par ligne, mise à jour des projections, ligne_document.evenement_id renseigné ;
  4. passage du document à valide ;
  5. en cas d’échec sur une seule ligne : rollback complet, rapport détaillé ligne par ligne. Rien n’est écrit.

Références : LIV-2026-0001, RET-2026-0001, INT-2026-0001, allouées par verrou de ligne sur compteur_reference, remises à zéro chaque année, par locataire.

Le bon de livraison est une page HTML servie sur GET /documents-mouvement/:id/bon-de-livraison, avec une feuille de style @media print soignée et un emplacement de signature. Pas de dépendance PDF en v1.


ÉlémentChoix
Mot de passeArgon2id, m = 19 456 Kio, t = 2, p = 1 (recommandation OWASP), sel par entrée
Cookie de sessionwq_session, 32 octets aléatoires, stocké haché (SHA-256) en base ; HttpOnly, Secure, SameSite=Lax, Max-Age 30 jours
Verrouillageinactivité configurable (défaut 15 min) → session.verrouillee = true, redirection vers l’écran de code PIN
Code PIN6 chiffres, Argon2id, déverrouille seulement une session valide ; 5 échecs → session révoquée, retour au mot de passe
Définition du PINdepuis la fiche de l’utilisateur, confirmation par mot de passe courant
Limitation de débitpar IP sur /connexion et /verrouillage (fenêtre glissante en mémoire) et par compte en base (echecs_connexion, verrouille_jusqu_a) — la seconde survit à un redémarrage
CSRFjeton par session stocké en base, champ caché dans chaque formulaire et en-tête htmx global ; vérification de Origin / Sec-Fetch-Site en complément
Méthodesaucune action modifiante en GET. POST / PUT / DELETE exclusivement

Le PIN ne crée jamais de session : il en réveille une déjà authentifiée sur un appareil déjà connu. C’est la propriété qui rend le compromis acceptable pour un atelier.


Trois rôles fixes, appliqués côté serveur sur chaque route.

RôlePortée
Administrateurtout dans sa brasserie, y compris utilisateurs, paramètres et transitions forcées
Productionscan, documents de mouvement, modification de fûts, lecture clients et lots
Lectureconsultation et export, aucune écriture
crates/web/src/routes/table.rs
pub const ROUTES: &[Route] = &[
Route::get ("/futs", Role::Lecture, futs::liste),
Route::post("/futs/:id/transition", Role::Production, futs::transition),
Route::post("/futs/:id/forcer", Role::Administrateur, futs::forcer),
Route::get ("/administration/utilisateurs", Role::Administrateur, admin::utilisateurs),
// …
];

Le Router Axum est assemblé en itérant cette table, jamais à la main. Ajouter une route sans déclarer son rôle ne compile pas, et le test du §11.4 itère la même table pour vérifier chaque combinaison rôle × route. Masquer un bouton dans le gabarit reste du confort, jamais une protection.


Maud produit le HTML depuis des fonctions Rust. Un fragment htmx est un appel de fonction : il n’existe pas de fichier de gabarit à synchroniser, et clippy et rustfmt s’appliquent au balisage comme au reste.

Une seule couche, dès le départ, même si la seule langue livrée est le français :

crates/web/src/i18n/fr.rs
pub const CATALOGUE: &[(&str, &str)] = &[
("fut.statut.rempli", "Rempli"),
("fut.erreur.transition", "Un fût {avant} ne peut pas passer à {apres}."),
// …
];

La macro t!("fut.statut.rempli") résout la clé à la compilation contre ce catalogue : une clé absente est une erreur de compilation, pas une chaîne vide découverte en production. Aucune chaîne visible par l’utilisateur n’est écrite en dur dans un gabarit. Ajouter une langue consiste à ajouter un fichier et un sélecteur ; c’est mécanique.

Ce ne sont pas deux largeurs d’écran, ce sont deux jeux de routes et de gabarits. Le tableau de bord sert une vue de pilotage dense à un Administrateur ou Lecture sur poste fixe, et un écran tactile centré sur le scan à un compte Production. Aucune détection de largeur en JavaScript : le serveur sait qui demande.

Les cibles tactiles de l’atelier font 56 px minimum, le contraste est élevé, et chaque scan produit trois retours simultanés — couleur plein écran, son, et vibration — parce que l’opérateur ne regarde pas l’écran en continu.

TypeScript compilé par esbuild (binaire autonome épinglé, aucune chaîne Node), un seul paquet, chargé uniquement sur les pages de scan.

ModuleRôle
douchette.tschamp invisible qui garde le focus ; une saisie est reconnue comme douchette si l’intervalle médian entre frappes est inférieur à 35 ms sur au moins 4 caractères et se termine par Entrée en moins de 500 ms
camera.tsgetUserMedia en facingMode: environment, bascule avant/arrière, torche si disponible
decodeur.tsBarcodeDetector natif si getSupportedFormats() contient qr_code, sinon chargement paresseux de zxing-wasm ; bascule invisible pour l’utilisateur
retour.tsflash pleine largeur, bips WebAudio, navigator.vibrate
file.tsfile d’envoi sérialisée : deux scans rapprochés ne se doublent pas
journal.tsliste des derniers scans avec leur issue

Le serveur reste seul détenteur de l’état. Le JavaScript capture et envoie ; il ne décide de rien. C’est ce qui permet de ne pas construire de couche hors-ligne.

La douchette est active dès le premier écran de scan, avant même la caméra : à quelques dizaines de lignes, c’est le meilleur rapport effort/valeur du projet, et elle rend la caméra secondaire plutôt qu’indispensable.


Priorité stricte à ce qui coûte cher en cas de bug.

11.1 Isolation multi-locataire — écrit en premier, ne casse jamais

Section intitulée « 11.1 Isolation multi-locataire — écrit en premier, ne casse jamais »

Le test énumère les tables depuis information_schema plutôt que depuis une liste écrite à la main. Conséquence : une table ajoutée plus tard sans politique RLS fait échouer le test automatiquement, sans que personne n’ait pensé à le mettre à jour.

Pour chaque table métier, avec deux locataires peuplés :

  1. transaction avec app.locataire_id = A, SELECT * sans aucun WHERE → seules les lignes de A remontent ;
  2. transaction sans paramètre positionné → zéro ligne ;
  3. INSERT d’une ligne portant tenant_id = B alors que le paramètre vaut A → rejeté par WITH CHECK ;
  4. UPDATE … SET tenant_id = B → rejeté.

Les 49 couples (depuis, vers) testés exhaustivement contre la table du §4.2, plus les préconditions, plus du test basé sur les propriétés (proptest) : pour toute séquence aléatoire d’événements valides, l’état final est atteignable et la reconstruction redonne le même résultat que l’application incrémentale.

Atomicité : sur 30 lignes, une transition rendue impossible juste avant la validation ⇒ zéro événement écrit, document toujours en brouillon, rapport nommant la ligne fautive. Dédoublonnage. Refus anticipé à la capture. Reprise du brouillon après redémarrage du processus.

Itération sur routes::table::ROUTES × les trois rôles : chaque combinaison attend 200 ou 403, jamais autre chose. Une route non couverte fait échouer le test.

Requête forgée à la main portant l’adresse d’un autre client : rejetée. Vérifié à deux niveaux — message applicatif clair, et clé étrangère composite qui rend l’écriture impossible même si la validation était contournée.

Sur le jeu de démonstration complet : reconstruire-projections --verifier ne produit aucun écart.

sqlx::test fournit une base neuve par test. Les tests d’intégration démarrent l’application sur un port éphémère et l’attaquent en HTTP réel. Les trois rôles PostgreSQL sont créés par la migration 0001, donc le test d’isolation s’exécute dans les mêmes conditions que la production.

cargo fmt --check · cargo clippy -- -D warnings · cargo sqlx prepare --check · cargo test · cargo deny check · cargo audit · build Tailwind et esbuild · build de l’image Docker · exercice de restauration : la dernière sauvegarde est restaurée dans une base jetable et un contrôle de cohérence est exécuté.


CrateUsageJustification
axum 0.8serveur HTTPdécision arrêtée
tokio 1exécution asynchronerequis par Axum
tower, tower-httpintergicielstrace, compression, délai, identifiant de requête, en-têtes de sécurité
sqlx 0.8PostgreSQLdécision arrêtée ; requêtes vérifiées à la compilation, migrations intégrées
maud 0.27gabaritsdécision validée en ouverture
serde, serde_jsonformulaires, jsonbstandard
uuid (v4 + v7)identifiantsv7 pour les événements : ordre d’insertion = ordre temporel
chrono, chrono-tzdates et fuseauxchaque locataire a son fuseau ; DLUO et seuils s’y calculent
argon2hachageexigé par A.8
randjetons de session et CSRFgénérateur cryptographique
sha2empreinte des jetons de sessionun vol de base ne donne pas les sessions
clapcommandes d’exploitationprovisionnement, semis, reconstruction, restauration
csvexportsans dépendance lourde
rust_xlsxwriterexport ExcelRust pur, aucune dépendance système
calamineimport de repriselit xlsx et xls
qrcodeplanches d’étiquettesgénère du SVG inséré directement dans la page d’impression
tracing, tracing-subscriberjournalisation structuréeA.8
metrics-exporter-prometheusmétriquesA.8
qrcode (déjà listé)planches d’étiquettescorrection d’erreur élevée : l’atelier est humide
thiserrortypes d’erreur
proptest (dev)test basé sur les propriétésmachine à états, §11.2
reqwest (dev)tests d’intégration HTTP

Hors Rust, embarqués dans le binaire par include_str! : htmx 2.x et zxing-wasm (34 Kio de colle + 919 Kio de wasm, chargés paresseusement et seulement là où le décodeur natif manque). Aucun CDN, aucune chaîne de construction front : la politique de sécurité de contenu est stricte et l’image Docker est autosuffisante.

Pas de crate de validation généraliste : les messages doivent être en français, par champ, et réinjectables dans un fragment htmx. Un accumulateur d’erreurs maison dans web/formulaire.rs fait le travail en moins de code qu’il n’en faudrait pour contraindre une bibliothèque.

Pas de tower-sessions : la session porte le verrouillage, le PIN, les compteurs d’échec et le jeton CSRF, tous liés au schéma métier. Une table et cinquante lignes sont plus lisibles qu’une implémentation de magasin personnalisé.


services:
caddy:
image: caddy:2-alpine
ports: ["80:80", "443:443", "443:443/udp"]
volumes:
- ./docker/Caddyfile:/etc/caddy/Caddyfile:ro
- caddy_data:/data # certificats, à conserver
- caddy_config:/config
depends_on: [app]
restart: unless-stopped
app:
build: .
environment:
DATABASE_URL_OWNER: ${DATABASE_URL_OWNER:?variable obligatoire}
DATABASE_URL_APP: ${DATABASE_URL_APP:?variable obligatoire}
DATABASE_URL_EDITEUR: ${DATABASE_URL_EDITEUR:?variable obligatoire}
SECRET_SESSION: ${SECRET_SESSION:?variable obligatoire}
DOMAINE_PUBLIC: ${DOMAINE_PUBLIC:?variable obligatoire}
FUSEAU_DEFAUT: ${FUSEAU_DEFAUT:-Europe/Paris}
RUST_LOG: ${RUST_LOG:-info}
depends_on:
postgres: { condition: service_healthy }
restart: unless-stopped
postgres:
image: postgres:17-alpine
environment:
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:?variable obligatoire}
POSTGRES_DB: webqr
volumes: [pg_data:/var/lib/postgresql/data]
healthcheck:
test: ["CMD-SHELL", "pg_isready -U postgres -d webqr"]
interval: 5s
retries: 20
restart: unless-stopped
sauvegarde:
image: postgres:17-alpine
volumes:
- ./docker/sauvegarde.sh:/sauvegarde.sh:ro
- sauvegardes:/sauvegardes
environment:
PGPASSWORD: ${POSTGRES_PASSWORD}
RETENTION_QUOTIDIENNE: ${RETENTION_QUOTIDIENNE:-7}
RETENTION_HEBDOMADAIRE: ${RETENTION_HEBDOMADAIRE:-4}
RETENTION_MENSUELLE: ${RETENTION_MENSUELLE:-6}
entrypoint: ["/bin/sh", "/sauvegarde.sh"]
depends_on: [postgres]
restart: unless-stopped
volumes: { pg_data: , caddy_data: , caddy_config: , sauvegardes: }

La syntaxe ${VAR:?message} fait échouer bruyamment docker compose up si une variable obligatoire manque, avant même que le conteneur ne démarre. Symétriquement, Config::depuis_env() collecte toutes les variables manquantes et les signale ensemble avant de sortir en code 1 — pas une par redémarrage.

Jouées au démarrage du conteneur applicatif, avec le rôle webqr_owner, avant l’ouverture du port d’écoute. Un verrou consultatif PostgreSQL évite que deux instances ne les jouent en même temps. Le pool owner est fermé ensuite : le service tourne uniquement sous webqr_app et webqr_editeur.

13.3 Le blocage caméra — traité maintenant, pas le jour de la démo

Section intitulée « 13.3 Le blocage caméra — traité maintenant, pas le jour de la démo »

getUserMedia exige un contexte sécurisé. Servir sur http://192.168.1.x:8080 bloque la caméra et le produit ne fonctionne plus. Trois situations, trois réponses documentées :

SituationRéponse
Déploiement retenu : tunnel CloudflareLe tunnel publie https://qr.selfhostr.io vers http://<machine>:6666 et fournit le certificat. Caddy sert en clair derrière lui, sur le port 6666, et transmet X-Forwarded-Proto: https pour que les cookies restent Secure.
Production, nom de domaine pointé directementCaddy obtient et renouvelle le certificat automatiquement. Rien à faire.
Développement, poste localcompose.dev.yaml sert https://webqr.localhost avec tls internal. caddy trust installe l’autorité locale.
Test sur un vrai téléphoneun tunnel — tailscale funnel ou cloudflared tunnel — donne une URL publique avec un certificat valide, sans ouvrir de port. C’est la voie recommandée, et la seule qui fonctionne aussi sur iOS sans manipuler de profil de configuration. mkcert reste documenté en repli pour Android.

Le compose.dev.yaml sert en HTTPS dès le premier jour ; le mode HTTP n’existe pas, pour qu’aucun développement ne se fasse dans des conditions que la production ne reproduit pas.

pg_dump -Fc quotidien, rotation 7 quotidiennes / 4 hebdomadaires / 6 mensuelles, configurable. Restauration par webqr restaurer --fichier <chemin>, documentée dans docs/exploitation.md, et exercée en intégration continue : le travail échoue si la dernière sauvegarde ne se restaure pas. Une sauvegarde jamais restaurée n’est pas une sauvegarde.

webqr serve
webqr provisionner-brasserie --nom "…" --slug … --prefixe-qr … --admin-email …
webqr semer-demonstration [--futs 3000]
webqr reconstruire-projections [--locataire …] [--verifier]
webqr exporter-locataire --locataire … --sortie archive.zip # sortie de données
webqr supprimer-locataire --locataire … --confirmer
webqr restaurer --fichier …

provisionner-brasserie impose un mot de passe fort, ou émet un lien d’invitation à usage unique. Aucun compte par défaut n’existe nulle part — ni dans une migration, ni dans une image, ni dans le dépôt.

L’export et la suppression d’un locataire sont prévus dès le modèle comme le demande A.3, même si leur écran arrive plus tard.


Cinq points où je m’écarte de la lettre de la spécification, ou où je tranche une zone qu’elle ne couvre pas. Chacun se renverse d’un mot.

  1. Contenance — stockée en litres (int) avec une liste de valeurs autorisées par brasserie (défaut 20 / 30 / 50) plutôt qu’un type fermé à trois valeurs. Certaines brasseries utilisent du 15 ou du 25 L, et un type fermé imposerait une migration pour les accueillir.
  2. Propre → Livre refusé dans le graphe validé. Livrer un fût vide passe donc par une transition forcée d’administrateur. C’est volontaire : l’exception laisse une trace explicite au lieu de se fondre dans le flux normal.
  3. Justification obligatoire pour Perdu et HorsService. La spécification ne l’exige que pour les transitions forcées ; sortir un actif de 130 € du parc mérite le même traitement.
  4. Troisième état de document : abandonne, en plus de brouillon et valide. Vider un brouillon ne l’efface pas, il le clôt — sinon la piste d’audit a un trou.
  5. Deux rôles PostgreSQL applicatifs (webqr_app, webqr_editeur) plutôt qu’un seul portant BYPASSRLS pour le back-office. Le code du back-office ne peut pas se tromper de pool : il n’a accès qu’au sien.

L’absence de fonctionnement hors-ligne (A.7) repose sur l’hypothèse que le réseau est disponible partout où l’on scanne. Les chambres froides et les caves sont les endroits où cette hypothèse tombe le plus souvent. Le brouillon serveur couvre la perte de session, pas la perte de réseau pendant la capture.

Je construis conformément à la décision — elle est ferme et je ne la conteste pas. Deux précautions gratuites la rendent réversible : l’îlot de scan sérialise ses envois dans une file (file.ts), et l’API de capture est idempotente sur (document_id, code_qr). Le jour où le hors-ligne deviendrait nécessaire, il se brancherait sur cette file sans toucher au serveur. À vérifier sur site avant la première installation.


16. Réponse aux douze écueils de la version précédente

Section intitulée « 16. Réponse aux douze écueils de la version précédente »
#ÉcueilRéponse
1Aucun historiqueevenement_fut en ajout seul, source de vérité — §6
2Champs métier en texte libretrois référentiels par locataire, listes déroulantes — §3.2
3Statuts en chaînes accentuéestype fermé Rust + énumération PostgreSQL sans accent — §4.1
4Parc chargé d’un blocpagination par curseur, index dédiés, recherche trigramme — §3.3
5Suppressions par GET, sans CSRFméthodes non idempotentes, jeton par session, contrôle d’Origin — §8
6Secrets et comptes en duréchec bruyant au démarrage, aucun compte par défaut — §13.1, §13.5
7Aucune validation serveurvalidation systématique, préconditions, contraintes en base — §4.3, §11
8Incohérence client / adresseclé étrangère composite : structurellement impossible — §3.2
9Capture de lot perduebrouillon persisté côté serveur — §7
10Distinction desktop/mobile en JSdeux jeux de routes et de gabarits selon le rôle — §10.3
11Ni tests, ni migrations, ni déploiementsix familles de tests, migrations versionnées, compose complet — §11, §13
12Aucun exportCSV et Excel sur toute vue filtrée, export intégral du locataire — §12, §13.5

  1. Workspace, compose.dev.yaml en HTTPS, Config::depuis_env() avec échec bruyant.
  2. Migration 0001 : les trois rôles PostgreSQL. 0002 : locataire, utilisateur, session avec RLS.
  3. Le test d’isolation du §11.1, avant tout écran. Il doit passer sur un schéma quasi vide, puis ne plus jamais casser.
  4. Authentification complète : mot de passe, session, PIN, limitation de débit, CSRF.
  5. Table des routes et extracteur de rôle, avec le test §11.4.
  6. Back-office éditeur minimal et provisionner-brasserie.
  7. Jeu de démonstration.
  8. Intégration continue complète.

La Phase 0 était une proposition. Voici, en toute transparence, les sept points où la réalisation s’est écartée du document, et pourquoi.

#ÉcartRaison
1Tailwind remplacé par une feuille de style écrite à la main (~410 lignes, tokens compris)C’était le seul choix que la spécification autorisait à remplacer. L’atelier demande des composants très spécifiques — cibles de 56 px, retour plein écran, planche d’étiquettes au millimètre — que l’on écrit plus vite directement. Gain : plus de binaire à télécharger, plus d’étape de construction, et une feuille plus petite que ce que Tailwind aurait produit.
2Pas de crate de limitation de débittower_governor a été remplacé par une fenêtre glissante maison de quarante lignes, parce que le vrai rempart est le comptage par compte en base, qui survit à un redémarrage. La partie en mémoire n’est qu’un amortisseur.
3Les gabarits vivent dans les modules de routes, pas dans un dossier gabarits/ séparé par pagegabarits/ garde l’ossature et les composants partagés. Éclater chaque page en deux fichiers doublait le nombre de fichiers sans rien clarifier : avec Maud, le gabarit est du code, et il se lit mieux à côté de son gestionnaire.
4fut.recherche_txt abandonnéLa colonne dénormalisée aurait dérivé. La recherche joint client et lot, chacun indexé en trigramme. À 5 000 fûts la différence est inmesurable, et il y a un risque d’incohérence en moins.
5JavaScript au lieu de TypeScriptLa spécification demandait un îlot « vanilla, sans framework ». En supprimant esbuild, on supprime un binaire épinglé, une étape de construction et une source de divergence entre ce qu’on lit et ce qui s’exécute. Le fichier fait 409 lignes et se lit d’un bloc.
6Pagination limit/offset, pas par curseurAnnoncé par curseur au §3.3, réalisé en offset, avec un plafond dur de 500 lignes. À l’échelle visée — 5 000 fûts, 50 lignes par page — les deux sont instantanés, et l’offset autorise le tri par n’importe quelle colonne sans multiplier les requêtes. Mesuré : 13 à 25 ms par page sur le jeu de démonstration de 3 000 fûts et 190 000 événements. À revoir si un client dépasse 100 000 fûts.
7Un quatrième rôle PostgreSQL, webqr_authNon prévu. Résoudre un cookie impose de lire avant de savoir de quel locataire il s’agit. Plutôt que d’élargir le rôle éditeur — qui voit tout — ce besoin précis a reçu un rôle aux droits énumérés : lecture de locataire, utilisateur, session, et écriture des seuls compteurs d’échec. Il ne peut lire ni le parc, ni les clients, ni les lots, ni même modifier la colonne role. Un test le vérifie.

Trois défauts ont par ailleurs été trouvés par les tests et les vérifications, pas par relecture — ils méritent d’être notés :

  • l’export CSV était plafonné à 500 lignes, parce qu’il réutilisait le plafond de pagination de l’affichage ;
  • la création en masse laissait sequence_courante à zéro : dans PostgreSQL, une CTE modifiante ne voit pas les lignes qu’une autre vient d’écrire, donc l’update de la projection ne trouvait rien ;
  • la première sauvegarde s’exécutait pendant les migrations et archivait une base vide. Le script attend désormais le schéma et relit chaque archive avant de la conserver.

Les sept phases sont réalisées. Vérifications exécutées sur le jeu de démonstration (3 000 fûts, 190 155 événements) :

Critère d’acceptationRésultat
docker compose up sert l’application sans étape manuelle✅ port 6666, quatre services healthy
Une commande crée une brasserie et son administrateur✅ mot de passe fort imposé ou généré
Le test d’isolation multi-locataire passe✅ 6 tests, tables énumérées depuis le catalogue
Accès refusé côté serveur sans le rôle requis✅ inventaire complet × 3 rôles
Chaîne d’intégration : compilation, clippy, format, tests✅ 53 tests, clippy -D warnings
3 000 fûts se chargent et se filtrent sans latence✅ 13–25 ms par page
Transitions invalides refusées avec un message clair✅ matrice 7 × 7 testée exhaustivement
La reconstruction redonne un état identique✅ 0 divergence sur 190 155 événements
L’export reflète exactement les filtres actifs✅ 3 000 lignes brutes, 564 filtrées sur « Propre »
Un client détenteur de fûts ne peut pas être supprimé✅ vérifié côté serveur
Une adresse d’un autre client est rejetée même forgée✅ refusée par l’application et par la base
Une capture de 30 fûts survit à un rechargement✅ et à un redémarrage du processus
Validation atomique : une ligne en erreur n’écrit rien✅ testé avec un conflit injecté sur la 17ᵉ ligne
Une restauration de sauvegarde est effectuée et vérifiée✅ 3 000 fûts / 190 155 événements restaurés, 0 erreur

Reste à faire à la main, parce que cela demande du matériel réel :

  • la démonstration sur un vrai téléphone Android et un vrai iPhone (critère de la Phase 4). Le code gère les trois chemins — BarcodeDetector natif, repli zxing-wasm, douchette — mais seul un appareil physique le prouve ;
  • l’impression d’une planche d’étiquettes et sa relecture par l’application (critère de la Phase 6) : la planche est calée sur un format 70 × 37 mm, à confirmer sur l’imprimante et le support retenus.

Fin de la Phase 0. Le document reste la référence ; les §18 et §19 enregistrent l’écart entre la proposition et la réalisation.