---
from: claude (Agent C — chantier v11.5)
to: dom
date: 2026-06-05T19:30:00+02:00
topic: planC-licence-d14
status: open
priority: high
references:
  - decision: docs/coordination/decisions/2026-06-02_dom_d14-plateforme-licence-architecture.md
  - plan: docs/coordination/inbox/for-dom/2026-06-05_claude_plan-v11-5-parallele.md
scope: CONCEPTION uniquement — aucun code de prod, aucun déploiement
---

# Plan C — Plateforme licence (D-14) : conception détaillée

> Sous-plan de l'Agent C. **Lecture seule** sur le code existant. Aucune ligne de
> `license.py` ni de `platform/` n'est écrite ici : ce document est la spec qui
> sera codée APRÈS le GO bêta (D-16), Phase 1.1 puis 1.2.
>
> Cadre D-14 **respecté à la lettre** (FastAPI + PostgreSQL + HTMX/Jinja2, OVH HDS,
> `app.aivanov.fr`, fastapi-users, Brevo, RSA-PSS 2048 + SHA256, `license.dat` DPAPI,
> phone home ≤ 30 j, 1 licence = 1 poste, grace 15 j, offline 30 j, révocation au check).

## 0. Ancrage dans l'existant (vérifié, read-only)

- Pas de `license.py` ni de `platform/` aujourd'hui → **fichiers/dossiers 100 % neufs**,
  zéro conflit avec le moteur (`anonymizer_core_refactored_onnx.py`) ou la GUI.
- `cryptography==41.0.7` **déjà installée** → pas de nouvelle dépendance lourde côté client
  (RSA-PSS/SHA256 fournis par `cryptography.hazmat`). Aucun ajout au risque
  `numpy<2.0` / `gliner==0.2.18`.
- Le `.spec` PyInstaller bundle déjà `config/` → la **clé publique** s'embarque
  naturellement comme `config/license_pubkey.pem` (ajout d'une seule ligne `datas`
  au moment du codage, pas maintenant).
- `admin_mode.py` fournit le patron `is_admin()` / `admin_required()` et
  `_project_root()` (résolution `sys._MEIPASS` en frozen) → `license.py` réutilise la
  même logique de résolution de chemins en mode EXE.

---

## 1. Architecture serveur (`platform/`, Phase 1.2 ~50h)

### 1.1 Arborescence du nouveau dossier (repo séparé ou sous-dossier `platform/`)

```
platform/
├── app/
│   ├── main.py                 # FastAPI app + routers
│   ├── config.py               # settings (env: DB URL, Brevo key, clé privée path)
│   ├── db.py                   # SQLAlchemy async engine + session
│   ├── models.py               # tables ORM (clients, licences, postes, activations, revocations)
│   ├── auth.py                 # fastapi-users (UserManager, JWT/cookie)
│   ├── crypto/
│   │   ├── signer.py           # signature RSA-PSS d'une licence (clé PRIVÉE, serveur only)
│   │   └── private_key.pem     # JAMAIS commité (.gitignore + secret CI) — monté via volume OVH
│   ├── routers/
│   │   ├── pages.py            # pages HTMX/Jinja2 (login, mes licences, activation, DL)
│   │   ├── api_client.py       # endpoints appelés par l'EXE (/activate, /check, /download)
│   │   └── api_admin.py        # endpoints admin Dom (create licence, revoke, parc)
│   ├── services/
│   │   ├── licensing.py        # logique métier : 1 licence=1 poste, expiration, grace
│   │   ├── activation.py       # bind machine_id ↔ licence, anti-réactivation
│   │   └── email.py            # Brevo (activation, renouvellement, expiration J-30/J-7)
│   ├── templates/              # Jinja2 + fragments HTMX
│   └── static/
├── migrations/                 # Alembic
├── tests/
├── Caddyfile                   # reverse proxy + Let's Encrypt (app.aivanov.fr)
├── docker-compose.yml          # api + postgres + caddy
└── .github/workflows/deploy.yml
```

### 1.2 Schéma DB PostgreSQL

Cinq tables. fastapi-users gère `users` (= comptes de connexion). Le **client métier**
(`clients`) est distinct du `user` pour autoriser plusieurs comptes par organisation
plus tard, mais en MVP `user ↔ client` est 1:1.

```
users                         (géré par fastapi-users)
  id            UUID  PK
  email         TEXT  UNIQUE
  hashed_password TEXT
  is_active     BOOL
  is_superuser  BOOL          -- Dom = superuser (back-office)
  is_verified   BOOL

clients                       -- l'organisation cliente (hôpital, cabinet)
  id            UUID  PK
  user_id       UUID  FK→users.id   (1:1 en MVP)
  raison_sociale TEXT
  finess        TEXT  NULL     -- optionnel, cohérent métier santé
  contact_email TEXT
  created_at    TIMESTAMPTZ

licences                      -- 1 abonnement annuel = N postes achetés
  id            UUID  PK
  client_id     UUID  FK→clients.id
  ref           TEXT  UNIQUE   -- ex. LIC-2026-000123 (humain)
  postes_max    INT            -- nb de postes autorisés (souvent 1)
  version_max   TEXT  NULL     -- version max couverte par l'abo (ex "11.x")
  issued_at     TIMESTAMPTZ
  expires_at    TIMESTAMPTZ    -- date de fin d'abonnement annuel
  status        ENUM(active, suspended, expired, cancelled)
  created_at    TIMESTAMPTZ

postes                        -- 1 ligne = 1 machine_id activée sous une licence
  id            UUID  PK
  licence_id    UUID  FK→licences.id
  machine_id    TEXT          -- empreinte poste (voir §3.2)
  label         TEXT  NULL     -- nom donné par le client ("Poste accueil")
  os_info       TEXT  NULL     -- diag (Windows build), non PII
  activated_at  TIMESTAMPTZ
  last_seen_at  TIMESTAMPTZ    -- dernier phone home réussi
  status        ENUM(active, revoked)
  UNIQUE(licence_id, machine_id)        -- 1 machine ne s'active qu'une fois/licence
  -- contrainte applicative : COUNT(active) ≤ licences.postes_max

activations                   -- journal d'audit (immuable, append-only)
  id            UUID  PK
  poste_id      UUID  FK→postes.id NULL
  licence_id    UUID  FK→licences.id
  machine_id    TEXT
  event         ENUM(activate, check, refuse_quota, refuse_revoked, revoke, renew)
  ip            INET  NULL
  user_agent    TEXT  NULL
  detail        JSONB NULL
  created_at    TIMESTAMPTZ
```

**Règle "1 licence = 1 poste" (D-14)** : implémentée par `postes_max` (défaut 1) +
contrainte applicative dans `services/licensing.py` : refus d'activation si
`COUNT(postes WHERE status=active) >= postes_max`. La table reste générique (permet
un futur multi-postes) sans casser le modèle MVP.

**Révocation au prochain check (D-14)** : `postes.status = revoked` → le `/check`
suivant renvoie `revoked`, l'EXE supprime son cache et repasse non-licencié. Pas de
push, pas de connexion permanente requise.

### 1.3 Endpoints FastAPI

**API client (appelés par l'EXE) — `routers/api_client.py`**

| Méthode | Route | Auth | Rôle |
|---|---|---|---|
| POST | `/api/v1/activate` | token client (clé licence + email/mdp ou jeton d'activation) | Lie `machine_id` à la licence, renvoie la **licence signée** (§4) |
| POST | `/api/v1/check` | machine_id + ref licence | Phone home : renvoie statut (active/expired/grace/revoked) + éventuelle licence re-signée (renouvellement) |
| GET | `/api/v1/download/{version}` | session client | Téléchargement de l'EXE (remplace OwnCloud) |
| GET | `/api/v1/version` | public | Dernière version dispo (pour notif maj) |

**Pages HTMX (humain) — `routers/pages.py`**

| Route | Page |
|---|---|
| `GET /` `GET /login` | Connexion (fastapi-users, cookie) |
| `GET /licences` | « Mes licences » : liste, expiration, postes consommés/max |
| `POST /licences/{id}/activate-token` (HTMX) | Génère un **jeton d'activation à usage unique** à coller dans l'EXE |
| `GET /licences/{id}/postes` (HTMX fragment) | Liste des postes activés, bouton « révoquer » |
| `POST /postes/{id}/revoke` (HTMX) | Passe le poste en `revoked` (effectif au prochain check) |
| `GET /download` | Page de téléchargement + checksum |

**API admin (Dom, superuser) — `routers/api_admin.py`**

| Route | Rôle |
|---|---|
| `POST /admin/clients` | Créer un client + compte |
| `POST /admin/licences` | Émettre une licence (postes_max, expires_at) |
| `POST /admin/licences/{id}/renew` | Prolonger d'un an |
| `POST /admin/licences/{id}/cancel` | Suspendre/annuler |
| `GET /admin/parc` | Vue parc : clients, licences, postes, last_seen |

### 1.4 Pages HTMX (UX MVP, Phase 1.2)

- **Login** (fastapi-users, cookie session) → redirige vers `/licences`.
- **Mes licences** : carte par licence (réf, statut, expiration, jauge postes
  `2/3`), bouton « Activer un poste » qui ouvre un fragment HTMX affichant le
  **jeton d'activation** (copier-coller dans l'EXE).
- **Postes** : tableau (label, machine_id tronqué, last_seen, statut) + révoquer.
- **Téléchargement** : dernier EXE + checksum SHA256.
- Back-office Dom (superuser) : parc global + actions admin.

> HTMX = fragments HTML renvoyés par FastAPI, zéro SPA, déploiement simple (D-14).

---

## 2. Module client `license.py` (Phase 1.1 ~12h, fichier neuf)

### 2.1 Principe

`license.py` est **autonome** : il ne dépend que de `cryptography` (déjà présente) et
de la lib standard. Il n'importe NI le moteur NI la GUI → testable seul, zéro conflit.
La GUI (Agent A) ne fait qu'appeler son **API publique de statut** (§7).

### 2.2 Interface publique (contrat figé exposé à la GUI)

```python
# --- Types ---
class LicenseState(Enum):
    ACTIVE          # licence valide, dans la période
    GRACE           # expirée mais < 15 j → mode dégradé autorisé
    EXPIRED         # > 15 j après expiration → bloquant (sauf bêta)
    OFFLINE_STALE   # pas de phone home depuis > 30 j → exige reconnexion
    REVOKED         # révoquée côté serveur
    UNLICENSED      # aucune licence (ex. bêta, ou avant activation)
    INVALID         # signature falsifiée / fichier corrompu / machine_id divergent

@dataclass(frozen=True)
class LicenseStatus:
    state: LicenseState
    client_id: str | None
    expires_at: datetime | None
    days_remaining: int | None     # négatif si en grace
    last_check_at: datetime | None
    machine_id: str
    message_fr: str                # texte prêt pour la bannière GUI
    can_anonymize: bool            # ACTIVE et GRACE → True ; sinon False (hors bêta)

# --- API que la GUI appelle ---
def get_status(force_refresh: bool = False) -> LicenseStatus: ...
    # Lit license.dat (cache), valide signature + machine_id + dates SANS réseau.
    # Si dernier check > 30 j → tente un phone home ; sinon reste offline.

def activate(license_token: str) -> LicenseStatus: ...
    # Appelle POST /activate, reçoit la licence signée, la chiffre dans license.dat.

def check_now() -> LicenseStatus: ...
    # Force un phone home (POST /check) ; met à jour last_check_at + re-signature.

def deactivate() -> None: ...
    # Supprime license.dat local (libère le poste après révocation côté serveur).

def is_beta_build() -> bool: ...
    # True si BETA (pas de licence) → court-circuite tout (Phase 0 Réunion).
```

> **Phase 0 / bêta** : `is_beta_build()` renvoie True (flag de build), `get_status()`
> renvoie `UNLICENSED` avec `can_anonymize=True`. Aucun appel réseau, aucun blocage.
> C'est le mode livré au testeur Réunion (D-14, Phase 0).

### 2.3 Algo de vérification RSA-PSS (offline, cœur de la sécu)

```
verify(license_json, signature, pubkey):
    1. Recomposer le payload canonique = json.dumps(license_obj, sort_keys=True,
       separators=(',',':')) encodé UTF-8   # canonicalisation déterministe obligatoire
    2. public_key.verify(
           signature,                        # bytes (base64-décodés)
           canonical_payload,
           padding.PSS(mgf=MGF1(SHA256()), salt_length=PSS.MAX_LENGTH),
           SHA256())
    3. Si InvalidSignature → state = INVALID (refus)
    4. Vérifier machine_id(payload) == machine_id(local)   # anti-recopie sur autre PC
    5. Vérifier version couverte (payload.version_max ≥ version courante si présent)
    6. Calcul d'état temporel (now vs expires_at, last_check) → ACTIVE/GRACE/EXPIRED/...
```

Clé publique embarquée : `config/license_pubkey.pem` (PEM SubjectPublicKeyInfo,
RSA 2048). Clé privée : **jamais** dans le repo client, uniquement sur OVH
(`platform/app/crypto/private_key.pem`, monté en volume/secret CI).

### 2.4 `machine_id` (empreinte poste, §3.2 pour le flow)

```
machine_id = SHA256( os_uuid || cpu_id || mac_primaire )[:32]   # hex tronqué
```

- **Windows** : `MachineGuid` (registre `HKLM\SOFTWARE\Microsoft\Cryptography`) +
  `wmic csproduct uuid` + 1ʳᵉ MAC non virtuelle.
- **Linux/Mac** (dev/tests) : `/etc/machine-id` + MAC.
- Hashé (SHA256 tronqué) → **non réversible**, pas un identifiant PII brut.
- Tolérance : on hashe des composants stables ; pas le n° de disque (changé au
  reformatage). Si dérive (carte réseau changée) → `INVALID` → ré-activation
  nécessaire (cas rare, géré par le support).

### 2.5 Cache local chiffré `license.dat`

- Contenu : la licence signée (JSON+signature) **+** métadonnées locales
  (`last_check_at`, `machine_id`).
- **Windows** : chiffré via **DPAPI** (`win32crypt.CryptProtectData`, scope
  `CRYPTPROTECT_LOCAL_MACHINE`) → déchiffrable seulement sur ce poste/compte.
- **Linux/Mac** : chiffrement symétrique simple (Fernet) avec clé dérivée du
  `machine_id` (suffisant hors prod Windows ; D-14 dit « chiffré simple »).
- Emplacement : à côté de l'EXE en frozen (réutilise `_project_root()` du patron
  `admin_mode.py`), `%LOCALAPPDATA%\Aivanov\Anonymisation\license.dat` recommandé
  pour survivre aux mises à jour.
- **Anti-rollback horloge** : on stocke `last_check_at` ET on refuse un `now` <
  `last_check_at` (recul d'horloge) → bascule `OFFLINE_STALE` plutôt que prolonger
  frauduleusement la grace.

### 2.6 Logique grace / offline / révocation (machine à états)

```
À get_status():
  charger+vérifier license.dat
  si INVALID/REVOKED/absent → état correspondant (can_anonymize=False, sauf bêta)
  sinon:
     age_check = now - last_check_at
     si age_check > 30 j → tenter check_now()
        succès → repartir avec licence fraîche
        échec réseau → état OFFLINE_STALE (can_anonymize=False : exige reconnexion)
     calc temporel:
        now <= expires_at                         → ACTIVE   (can_anonymize=True)
        expires_at < now <= expires_at+15 j       → GRACE    (can_anonymize=True, bannière)
        now > expires_at+15 j                     → EXPIRED  (can_anonymize=False)
```

- **Grace 15 j** : `can_anonymize=True` + `message_fr` = « Licence expirée — pensez à
  renouveler (J-X) ». Mode dégradé = juste la bannière (D-14), le moteur ne change pas.
- **Offline 30 j** : tant que `age_check ≤ 30 j`, **aucun réseau requis** (full offline).
  Au-delà, un phone home est exigé ; s'il échoue → `OFFLINE_STALE` bloquant jusqu'à
  reconnexion (évite usage illimité hors-ligne).
- **Révocation** : détectée au `/check` (serveur renvoie `revoked`) → `deactivate()`
  local → `REVOKED`. Pas instantané par design (D-14), effectif au prochain check.

---

## 3. Format exact de la licence signée

### 3.1 Objet JSON (payload signé)

```json
{
  "v": 1,
  "license_ref": "LIC-2026-000123",
  "client_id": "5f3a...uuid",
  "machine_id": "9b1c2d...32hex",
  "issued_at": "2026-06-10T09:00:00Z",
  "expires_at": "2027-06-10T09:00:00Z",
  "version_max": "11.x",
  "grace_days": 15,
  "offline_max_days": 30
}
```

> Canonicalisation **obligatoire** avant signature ET vérification :
> `json.dumps(payload, sort_keys=True, separators=(',',':'))` → bytes UTF-8.
> Tout écart de sérialisation invalide la signature.

### 3.2 Enveloppe stockée / transmise

```json
{
  "payload": { ... l'objet ci-dessus ... },
  "signature": "base64( RSA-PSS-SHA256( canonical(payload) ) )",
  "alg": "RSASSA-PSS-SHA256",
  "key_id": "aivanov-license-2026"
}
```

`key_id` permet une **rotation de clé** future (le client embarque plusieurs pubkeys
indexées par `key_id`). MVP : une seule clé.

---

## 4. Flows

### 4.1 Activation d'un poste
```
Client se connecte sur app.aivanov.fr → /licences → « Activer un poste »
  → serveur génère jeton d'activation usage unique (lié licence_id)
Client lance l'EXE → saisit le jeton → license.py.activate(token)
  → POST /activate { token, machine_id, os_info }
  → serveur : vérifie quota (COUNT active < postes_max), crée poste, journalise
  → serveur signe la licence (clé privée) et renvoie l'enveloppe
  → license.py chiffre l'enveloppe dans license.dat (DPAPI), state=ACTIVE
Refus si quota atteint → 409 refuse_quota → message GUI « postes max atteints ».
```

### 4.2 Expiration + grace period
```
Au lancement, get_status() (offline) :
  now <= expires_at            → ACTIVE
  J0..J15 après expires_at     → GRACE : anonymisation OK + bannière jaune « J-X »
  > J15                        → EXPIRED : anonymisation bloquée, CTA « renouveler »
Renouvellement : Dom renew côté serveur → au prochain /check, licence re-signée
  avec nouveau expires_at → state repasse ACTIVE automatiquement.
```

### 4.3 Offline 30 jours
```
Poste sans réseau :
  age_check <= 30 j  → fonctionne 100 % offline (vérif locale signature+dates)
  age_check > 30 j   → tente /check ; si échec → OFFLINE_STALE (bloquant)
                       message « Connexion requise pour valider la licence ».
```

### 4.4 Révocation
```
Dom (ou client) clique « révoquer » → postes.status=revoked (audit logged).
Effet : rien d'immédiat sur le poste (offline).
Au prochain /check du poste (≤ 30 j) → serveur renvoie revoked
  → license.py.deactivate() supprime license.dat → state=REVOKED (bloquant).
```

---

## 5. Plan de branches et livrables

> **Tout démarre APRÈS le GO bêta (D-16).** Branches créées depuis la branche de
> livraison figée, conformément au plan maître §6.

### Phase 1.1 — client `license.py` (~12h) — EN PREMIER (le plus isolé)
- **Branche** : `feature/v11-5-license-client`
- **Livrables** :
  - `license.py` (module neuf, API §2.2)
  - `config/license_pubkey.pem` (clé publique de test d'abord, prod ensuite)
  - 1 ligne ajoutée au `.spec` (`("config/license_pubkey.pem", "config")`) — ajout
    isolé, ne touche aucune entrée existante
  - flag de build `BETA` (dans `build_info.py`) pour `is_beta_build()`
  - `tests/unit/test_license.py` (§6)
- **Isolation** : `license.py` n'importe ni le moteur ni la GUI → **zéro conflit**.
  Mergeable indépendamment (plan maître §6.2).

### Phase 1.2 — plateforme `platform/` (~50h) — APRÈS, en parallèle de A/B
- **Branche** : `feature/v11-5-platform` (ou repo `platform/` dédié)
- **Livrables** : arborescence §1.1, migrations Alembic, docker-compose,
  Caddyfile (`app.aivanov.fr`), workflow GitHub Actions, `tests/` serveur.
- **Isolation** : dossier `platform/` entièrement neuf → **zéro fichier applicatif
  partagé** avec moteur/GUI/admin.

### Ce qui est isolé (résumé anti-collision pour Agent D)
| Zone Agent C | Conflit possible ? |
|---|---|
| `license.py` (neuf) | Non |
| `platform/` (neuf) | Non |
| `config/license_pubkey.pem` (neuf) | Non |
| `.spec` (+1 entrée datas) | Quasi nul (ajout en fin de liste) |
| `build_info.py` (+1 flag BETA) | Faible (1 constante) — à coordonner avec D |
| Point d'appel GUI | **Contrat §7** — A réserve l'emplacement, C fournit l'API |

---

## 6. Tests attendus

### Client `license.py` (`tests/unit/test_license.py`) — pas de mock réseau pour la crypto
| Test | Attendu |
|---|---|
| Signature valide | licence signée avec la clé privée de test → `ACTIVE` |
| Signature **falsifiée** (1 octet modifié dans payload OU signature) | `INVALID`, `can_anonymize=False` |
| `machine_id` divergent (licence d'un autre poste) | `INVALID` |
| Expiration : now < expires | `ACTIVE` |
| Grace : expires < now ≤ +15 j | `GRACE`, `can_anonymize=True`, `days_remaining` négatif |
| Au-delà grace : now > +15 j | `EXPIRED`, `can_anonymize=False` |
| Offline ≤ 30 j (pas de réseau) | reste `ACTIVE`/`GRACE` sans appel réseau |
| Offline > 30 j + check échoue | `OFFLINE_STALE`, bloquant |
| Révocation (check renvoie revoked) | `REVOKED`, `license.dat` supprimé |
| Recul d'horloge (now < last_check) | pas de prolongation frauduleuse → `OFFLINE_STALE` |
| Cache corrompu | `INVALID` sans crash |
| Mode bêta (`is_beta_build()`) | `UNLICENSED` + `can_anonymize=True`, zéro réseau |

> Fixtures : on génère une **paire RSA de test** dans la fixture (jamais la clé prod),
> on signe des payloads à la volée → tests déterministes, hermétiques, sans serveur.

### Serveur `platform/tests/`
| Test | Attendu |
|---|---|
| Activation poste | crée `postes`, renvoie licence signée vérifiable par la pubkey |
| Quota 1 licence = 1 poste | 2ᵉ activation sur `postes_max=1` → `409 refuse_quota` |
| Réactivation même machine_id | idempotent (pas de doublon) |
| `/check` poste révoqué | renvoie `revoked` |
| Renew | `expires_at` prolongé → licence re-signée |
| Auth | endpoints admin refusés aux non-superusers |
| Audit | chaque événement écrit une ligne `activations` |

---

## 7. Zone de contact avec Agent A (GUI v6)

L'Agent A réserve un emplacement UI (bannière d'état licence). **C fournit l'API**,
A ne fait que l'afficher. Contrat figé :

```python
from license import get_status, LicenseState

st = get_status()           # jamais bloquant, pas de réseau sauf si >30 j
banner_text  = st.message_fr        # ex. « Licence active — expire le 10/06/2027 »
banner_level = {                    # pour la couleur de bannière
    LicenseState.ACTIVE:        "ok",       # vert/neutre
    LicenseState.GRACE:         "warning",  # jaune « renouveler J-X »
    LicenseState.EXPIRED:       "error",    # rouge bloquant
    LicenseState.OFFLINE_STALE: "warning",  # « connexion requise »
    LicenseState.REVOKED:       "error",
    LicenseState.UNLICENSED:    "info",     # bêta : info discrète ou rien
    LicenseState.INVALID:       "error",
}[st.state]
allow_run = st.can_anonymize        # la GUI grise « Anonymiser » si False
```

- La GUI **n'appelle jamais** `/activate` ou `/check` directement : tout passe par
  `license.py` (`activate(token)`, `check_now()`).
- L'écran d'activation (saisie du jeton) appelle `license.activate(token)` et affiche
  le `LicenseStatus` retourné.
- En bêta, `get_status()` renvoie `UNLICENSED` + `can_anonymize=True` → A peut masquer
  totalement la bannière (rien à afficher).

---

## 8. Risques & points à valider par Dom

| Point | Reco |
|---|---|
| Stabilité `machine_id` (carte réseau changée → ré-activation) | Hasher des composants stables (MachineGuid + UUID carte mère), pas le disque. Acceptable : support gère les rares dérives. |
| DPAPI `LOCAL_MACHINE` vs `CURRENT_USER` | `LOCAL_MACHINE` = tous les comptes du poste partagent la licence (cohérent « 1 poste »). À confirmer côté hôpital (sessions partagées). |
| `pywin32` (DPAPI) pas encore listé côté EXE | Ajout dépendance Windows uniquement, en Phase 1.1. Hors périmètre `numpy<2.0`. |
| Rotation de clé future | `key_id` prévu dans l'enveloppe (§3.2) → non bloquant. |
| Bêta sans licence | `is_beta_build()` court-circuite tout (D-14 Phase 0 respecté). |

---

## Résumé exécutif

Conception complète de la brique licence D-14, **respectant le cadre validé** sans le
réinventer. Côté serveur (`platform/`, Phase 1.2) : FastAPI + PostgreSQL (5 tables
clients/licences/postes/activations + users fastapi-users), endpoints client
(`/activate`, `/check`, `/download`) + pages HTMX (« Mes licences », activation,
révocation) + back-office Dom. Côté client (`license.py`, Phase 1.1) : module **neuf et
isolé** (zéro import moteur/GUI), API publique figée (`get_status`, `activate`,
`check_now`, `deactivate`), vérif **RSA-PSS 2048/SHA256 offline**, `machine_id` hashé,
cache `license.dat` **DPAPI**, machine à états grace 15 j / offline 30 j / révocation au
check. Format licence = JSON canonique signé + enveloppe base64 avec `key_id`. `cryptography`
**déjà installée** (aucune dépendance lourde nouvelle). Plan de branches : 1.1 client (mergeable
seul, le plus isolé) puis 1.2 plateforme. Tests crypto hermétiques (paire RSA de test) +
tests serveur (quota, révocation, renew, audit). Contrat GUI fourni à l'Agent A
(bannière via `message_fr` + `can_anonymize`). **Aucun code de prod écrit, aucun déploiement.**
