# Architecture technique — Rubis Sur l'Ongle

> Version : 0.1 · Dernière maj : 2026-05-05
> Décisions de référence : ADR-014 (stack), ADR-015 (repo), ADR-016 (PG), ADR-017 (auth), ADR-018 (storage). Voir `/docs/decisions.md`.

Ce document est la source de vérité technique. Quand le code et ce fichier divergent, on tranche en discussion et on met à jour ici.

---

## 1. Vue d'ensemble

```
                     ┌────────────────────────────┐
                     │      Internet (HTTPS)       │
                     └─────────────┬──────────────┘
                                   │
                     ┌─────────────▼──────────────┐
                     │  Traefik (Proxmox gateway)  │
                     │  Host(rubis.pro)            │
                     │  Host(app.rubis.pro)        │
                     └─────────────┬──────────────┘
                                   │
        ┌──────────────────────────┼─────────────────────────┐
        │                          │                         │
┌───────▼─────────┐       ┌────────▼─────────┐    ┌──────────▼────────┐
│ Pod: rubis-     │       │ Pod: rubis-web   │    │  Pod: rubis-api   │
│ landing (Astro) │       │ (nginx + Vite)   │    │  (AdonisJS Node)  │
│ rubis.pro/*     │       │ app.rubis.pro/*  │    │  /api/v1/* +      │
│ (SSG + SSR)     │       │ (proxy /api → api)│    │  jobs BullMQ      │
└───────┬─────────┘       └────────┬─────────┘    └──────────┬────────┘
        │ SSR fetch JSON            │                         │
        └─────────────►──────────────────────►────────────────┘
                                    │
            ┌───────────────────────┼───────────────────────┐
            │                       │                       │
   ┌────────▼─────────┐    ┌────────▼────────┐    ┌─────────▼────────┐
   │ LXC: PostgreSQL  │    │  LXC: MinIO     │    │  Provider OCR    │
   │ (existant)       │    │  (S3-compat)    │    │  (à benchmarker) │
   └──────────────────┘    └─────────────────┘    └──────────────────┘
```

**Composants** :

| Composant | Rôle | Hosting | Status |
|---|---|---|---|
| `apps/landing` | Landing + blog publics (Astro 6 SSR) | Pod Node K3s (`rubis-landing:4321`) | ✅ Déployé |
| `apps/web` | SPA React (SaaS) | nginx pod K3s (build statique) | À écrire |
| `apps/api` | API REST AdonisJS — logique métier, jobs, email | Pod Node K3s (`rubis-api:3333`) | À écrire |
| `packages/shared` | Types TS, schemas Zod, constantes communes | workspace local | À écrire |
| `packages/ui` | Design system partagé (tokens Tailwind + composants TSX) | workspace local | ✅ |
| PostgreSQL | Base de données métier | LXC Proxmox existant | ✅ En place |
| MinIO | Stockage PDF + pièces jointes (S3-compat) | LXC Proxmox existant | ✅ En place |
| Provider OCR | Extraction texte des factures | Externe (HTTPS) | ADR-020 à venir |
| Provider Email | Envoi outbound (relances + check-in) | Externe (HTTPS) | ADR-021 à venir |

---

## 2. Repo layout (monorepo)

```
rubis/
├── apps/
│   ├── api/                    # AdonisJS v7 backend
│   │   ├── app/                # Controllers, models, services
│   │   ├── config/             # Auth, database, mail, queue
│   │   ├── database/
│   │   │   ├── migrations/
│   │   │   └── seeders/
│   │   ├── start/              # Routes, kernel
│   │   ├── tests/
│   │   ├── ace.js              # CLI Adonis
│   │   ├── package.json
│   │   └── tsconfig.json
│   ├── web/                    # SPA React Vite (app.rubis.pro)
│   │   ├── src/
│   │   │   ├── routes/         # TanStack Router (file-based)
│   │   │   ├── components/     # composants app-spécifiques
│   │   │   ├── lib/            # api client, query keys, utils
│   │   │   └── main.tsx
│   │   ├── public/
│   │   ├── vite.config.ts
│   │   └── package.json
│   └── landing/                # Astro SSR (rubis.pro + /blog)
│       ├── src/
│       │   ├── pages/          # routes file-based (.astro + .ts endpoints)
│       │   ├── layouts/        # Layout.astro, LegalLayout.astro
│       │   ├── components/     # SiteHeader/Footer + sections + blog
│       │   ├── lib/api.ts      # client API typé
│       │   └── styles/app.css
│       ├── public/             # favicons, manifest
│       ├── astro.config.mjs
│       └── package.json
├── packages/
│   ├── shared/                 # Types TS + schemas Zod (api ↔ web)
│   │   └── src/{types,schemas,constants}
│   └── ui/                     # Design system partagé (apps/web + apps/landing)
│       └── src/{components,lib,styles}
├── docs/
│   └── tech/architecture.md    # (ce fichier)
├── k3s/
│   ├── namespace.yml
│   └── app/{api,web,landing,redis}.yml
├── Dockerfile.api              # AdonisJS Node SSR
├── Dockerfile.web              # nginx + bundle Vite
├── Dockerfile.landing          # Astro Node SSR (standalone)
├── pnpm-workspace.yaml
└── CLAUDE.md
```

**Outils monorepo** :
- **pnpm workspaces** — léger, rapide, gestion native des liens symboliques entre packages
- **TypeScript project references** — résout les imports cross-package sans build préalable
- **Turborepo** *(optionnel, à voir au volume)* — cache + parallélisation des scripts

**Commandes racine** typiques :
```bash
pnpm install                  # installe tout
pnpm -F api dev              # dev API
pnpm -F web dev              # dev SPA
pnpm -F api migration:run    # migrations DB
pnpm -F api test             # tests API
pnpm build                   # build api + web pour prod
```

---

## 3. apps/api — AdonisJS v7

### Stack interne
- **AdonisJS v7** + **Lucid ORM** (PG)
- **`@adonisjs/auth`** — access tokens Bearer (stateless)
- **`@adonisjs/bouncer`** — autorisations par policy (admin/lecture/édition pour V2 multi-users)
- **`@adonisjs/mail`** — emails outbound (provider à choisir ADR-021)
- **`@adonisjs/queue`** ou **BullMQ** — jobs différés (relances programmées, OCR, check-ins)
- **`@adonisjs/limiter`** — rate limiting sur les routes publiques (login, signup)
- **Vine** (validateur natif Adonis 7) ou **Zod** côté API pour validation des payloads

### Conventions de routes
Toutes les routes API sous `/api/v1/`. Versioning explicite — V2 vivra côté `/api/v2/` sans casser V1.

```
POST   /api/v1/auth/register
POST   /api/v1/auth/login
POST   /api/v1/auth/logout
POST   /api/v1/auth/refresh

GET    /api/v1/me
PATCH  /api/v1/me
GET    /api/v1/organizations/:id

GET    /api/v1/invoices
POST   /api/v1/invoices                 # create manual
POST   /api/v1/invoices/upload          # OCR pipeline
GET    /api/v1/invoices/:id
PATCH  /api/v1/invoices/:id
DELETE /api/v1/invoices/:id
POST   /api/v1/invoices/:id/relance     # relance manuelle
POST   /api/v1/invoices/:id/mark-paid

GET    /api/v1/plans
POST   /api/v1/plans
PATCH  /api/v1/plans/:id
DELETE /api/v1/plans/:id

GET    /api/v1/clients
POST   /api/v1/clients
PATCH  /api/v1/clients/:id

GET    /api/v1/dashboard/kpis
GET    /api/v1/dashboard/activity
```

### Conventions de réponse
- JSON systématique
- Format succès : `{ data: ..., meta?: { ... } }`
- Format erreur : `{ errors: [{ code, message, field? }] }`
- Codes HTTP standards (200, 201, 204, 400, 401, 403, 404, 422, 500)
- Pagination cursor-based pour les listes (préférable à offset pour les flux modifiés en temps réel)

### Endpoints blog publics

L'API expose les articles du blog en **JSON** (consommés par `apps/landing` en SSR). L'API ne sert plus de HTML — le rendu HTML déménagé côté Astro.

| Route | Description |
|---|---|
| `GET /api/v1/posts` | Liste des articles publiés (summary, sans contentHtml) |
| `GET /api/v1/posts/:slug` | Article complet (contentHtml inclus) + 3 articles liés |

Cf. `apps/api/app/controllers/blog_controller.ts` + `apps/api/app/transformers/post_transformer.ts`.

**Pipeline de contenu** :
1. **Manuel V1** : `node ace seed:blog` insère/upserte les 3 articles fondateurs.
2. **Admin (PR3)** : page React `/admin/blog` sur `app.rubis.pro` (auth-gated) — édition markdown + preview + bouton publier. Le markdown est rendu en HTML via `app/services/blog_renderer.ts` (marked v15, GFM, IDs auto) au save, cache dans `posts.content_html`.
3. **Cron IA hebdo (PR4)** : `apps/api/app/jobs/weekly_blog_post.ts` génère un draft Sonnet 4.6 chaque lundi 06:00 Europe/Paris ; review humaine obligatoire avant publish.

---

## 4. apps/web — React + Vite

### Stack interne
- **React 19** + **Vite 6**
- **TanStack Router** — routing file-based (à privilégier), search params type-safe pour les filtres facture
- **TanStack Query** — cache + invalidation + optimistic updates pour le state serveur
- **TailwindCSS** *(à confirmer)* — utility-first, cohérent avec les couleurs de marque
- **Lucide React** pour les icônes
- **Bricolage Grotesque + Inter** via Google Fonts (cohérent landing)

### Auth côté SPA (cf. ADR-017)
- **Access token** stocké en mémoire (variable de module / state Query) — pas localStorage pour éviter XSS
- **Refresh token** en cookie httpOnly + SameSite=Strict
- Au boot du SPA : appel `/auth/refresh` pour obtenir un nouvel access token (silent reauth)
- Si refresh échoue → redirect `/login`

### Organisation des routes
File-based via TanStack Router :
```
src/routes/
├── __root.tsx            # Layout global + AuthGate
├── login.tsx
├── signup.tsx
├── _app/                  # Routes protégées
│   ├── _app.tsx           # Layout app (sidebar, header, brand)
│   ├── index.tsx          # Dashboard
│   ├── factures.tsx       # Liste factures
│   ├── factures.$id.tsx   # Détail facture
│   ├── plans.tsx          # Bibliothèque plans
│   ├── plans.$id.tsx      # Éditeur plan
│   ├── clients.tsx
│   └── parametres.tsx
└── onboarding/
    ├── compte.tsx
    ├── entreprise.tsx
    └── signature.tsx
```

---

## 4bis. apps/landing — Astro 6 (rubis.pro)

Landing publique + blog, en Astro avec rendu hybride statique/SSR. Image Docker `rubis-landing` (Node 22 standalone), service ClusterIP `rubis-landing:4321`.

### Stack interne
- **Astro 6** + adapter `@astrojs/node` (mode standalone)
- **React 19** via `@astrojs/react` pour les composants interactifs / sections complexes
- **Tailwind v4** via `@tailwindcss/vite`
- **`@rubis/ui`** — tokens + composants partagés avec `apps/web`

### Stratégie de rendu

`output: "server"` dans `astro.config.mjs` → SSR par défaut, override par page :

| Page | Mode | Raison |
|---|---|---|
| `/` (landing) | SSG (`prerender = true`) | HTML figé, LCP/CLS optimaux, copy change rarement |
| `/mentions-legales`, `/confidentialite`, `/cgv` | SSG | Pages légales, mises à jour planifiées |
| `/blog` | SSR | Doit refléter immédiatement les publications admin |
| `/blog/:slug` | SSR | Idem + 404 dynamique |
| `/blog/rss.xml`, `/sitemap.xml`, `/robots.txt` | SSR (endpoints `.ts`) | Liste à jour |

**Cache HTTP** : pages SSR avec `Cache-Control: public, max-age=300, stale-while-revalidate=86400`. Absorbe les pics, et un publish admin propage en ≤5 min sans purge active.

### Structure
```
apps/landing/
├── astro.config.mjs        # output: server, adapter Node, tailwindcss plugin
├── src/
│   ├── pages/
│   │   ├── index.astro              # / (SSG)
│   │   ├── mentions-legales.astro   # SSG
│   │   ├── confidentialite.astro    # SSG
│   │   ├── cgv.astro                # SSG
│   │   ├── sitemap.xml.ts           # SSR endpoint
│   │   ├── robots.txt.ts            # SSR endpoint
│   │   └── blog/
│   │       ├── index.astro          # /blog (SSR)
│   │       ├── [slug].astro         # /blog/:slug (SSR)
│   │       └── rss.xml.ts           # SSR endpoint
│   ├── layouts/
│   │   ├── Layout.astro             # SEO complet, Header + Footer
│   │   └── LegalLayout.astro        # wrapper prose pour pages légales
│   ├── components/
│   │   ├── SiteHeader.tsx           # nav publique
│   │   ├── SiteFooter.tsx
│   │   ├── sections/                # 10 sections de la landing
│   │   └── blog/PostCard.tsx
│   ├── lib/api.ts                   # client API typé (fetch /api/v1/posts)
│   ├── styles/app.css               # @import @rubis/ui/styles + @source
│   └── public/                      # favicons, manifest
```

### Connexion à l'API

En SSR, Astro fetch directement le service Adonis via le DNS K3s (interne, pas de TLS) — cf. `API_URL` dans le ConfigMap `rubis-landing-config` :
```
API_URL=http://rubis-api.rubis.svc.cluster.local:3333
```

En dev local : `API_URL=http://localhost:3333` dans `.env.development`.

---

## 5. packages/shared — types et schémas partagés

Le but : un client API typé fortement, sans duplication de définitions.

```ts
// packages/shared/src/types/invoice.ts
export type InvoiceStatus = 'pending' | 'awaiting_user_confirmation' | 'paid' | 'in_relance' | 'litigation' | 'cancelled'

export type Invoice = {
  id: string
  numero: string
  clientId: string
  amountTtc: number
  dueDate: string  // ISO
  status: InvoiceStatus
  planId: string | null
  // ...
}
```

```ts
// packages/shared/src/schemas/invoice.ts
import { z } from 'zod'

export const createInvoiceSchema = z.object({
  numero: z.string().min(1),
  clientId: z.string().uuid(),
  amountTtc: z.number().positive(),
  dueDate: z.string().datetime(),
  // ...
})

export type CreateInvoiceInput = z.infer<typeof createInvoiceSchema>
```

**Avantage** : Adonis valide avec ce schéma, le SPA valide avec le même, le type est inféré une seule fois.

---

## 5bis. packages/ui — design system partagé

Source unique de vérité pour la marque visuelle, consommée par `apps/web` (et bientôt `apps/landing`).

```
packages/ui/
├── src/
│   ├── styles/
│   │   ├── tokens.css         # @theme Tailwind v4 : palette, fonts, radius, shadows
│   │   └── base.css           # @layer base + @utility (eyebrow, shadow-rubis…)
│   ├── components/
│   │   ├── Brand.tsx          # Lockup ◆ + "Rubis sur l'ongle"
│   │   ├── Gem.tsx            # SVG gem facetté direction A
│   │   ├── Button.tsx         # Variants : primary/secondary/ghost/link/danger × sm/md/lg
│   │   ├── Card.tsx           # Variants : default/flat/hero × paddings
│   │   ├── Chip.tsx           # Pastille sélectionnable (filtres, choix tonalité)
│   │   ├── Eyebrow.tsx        # Petit label majuscule rubis avec ◆ préfixe
│   │   └── EmptyState.tsx     # Message centré + icon + cta
│   ├── lib/cn.ts              # Helper Tailwind merge (clsx + tailwind-merge)
│   └── index.ts               # Barrel : exports nommés uniquement
├── package.json               # peerDeps : react, react-dom, lucide-react
└── tsconfig.json              # extends ../../tsconfig.base.json
```

**Conventions** :
- **Pas de build step** : on exporte directement les `.ts/.tsx` (cohérent avec `packages/shared`). Vite/tsc résout les imports en mode source.
- **Tokens en CSS** : Tailwind v4 lit `@theme {}` et génère automatiquement les classes (`bg-rubis`, `font-display`, `rounded-card`…). Pour ajouter une couleur, c'est dans `tokens.css`, nulle part ailleurs.
- **Consommation** :
  ```css
  /* dans le CSS racine de l'app */
  @import "@rubis/ui/styles/tokens.css";
  @import "@rubis/ui/styles/base.css";
  @source "../../../packages/ui/src/**/*.{ts,tsx}";  /* scan des composants */
  ```
  ```ts
  import { Button, Card, Brand } from "@rubis/ui";
  ```
- **`peerDeps`** : `react`, `react-dom`, `lucide-react`. Le consommateur fournit ces deps (évite la duplication de bundle).
- **Ce qui RESTE app-spécifique** (`apps/web/src/components/`) : layouts (`AppLayout`, `AppSidebar`), formulaires liés à TanStack Form, composants couplés à des hooks d'auth/router.

---

## 6. Flux de données critiques

### 6.1 Upload + OCR + création facture

```
SPA (drag & drop PDF)
   │
   │ POST /api/v1/invoices/upload (multipart)
   ▼
api: stocke le PDF dans MinIO (bucket rubis-invoices)
   │
   │ retourne { uploadId, status: 'processing' }
   ▼
api: enqueue job ProcessOcr(uploadId)
   │
   ▼
worker: récupère le PDF depuis MinIO
   │
   │ appel HTTP vers OCR provider
   ▼
worker: parse les champs extraits, crée l'Invoice en DB (status: pending)
   │
   ▼
SPA: poll ou WebSocket → reçoit l'Invoice prête à valider
```

**Points d'attention** : le job OCR doit être idempotent (même uploadId rejoué = pas de duplicate). Le SPA peut afficher un spinner pendant les 3-10 secondes d'OCR.

### 6.2 Programmation des relances

```
SPA: utilisateur clique "Valider" sur l'Invoice
   │
   │ PATCH /api/v1/invoices/:id (status: scheduled, planId: …)
   ▼
api: créé N RelanceTasks (une par étape du plan)
       chaque RelanceTask a un sendAt (calculé d'après dueDate + offset étape)
   │
   ▼
queue: tâches en attente
   │
   │ ┌─ avant chaque relance, créer aussi un CheckinTask (T-2j) ─┐
   ▼ ▼                                                          ▼
worker @ sendAt: vérifie l'état de l'Invoice (toujours pending ?)
   │
   │ si invoice.status === 'pending' → envoie l'email
   │ sinon → no-op (l'invoice a été marquée payée entre-temps)
```

### 6.3 Check-in email à l'utilisateur

```
worker @ checkinTask.sendAt:
   │
   │ génère un token signé (avec invoice.id + reply_action: 'paid' | 'not_paid')
   ▼
api: envoie un email à l'utilisateur (pas au client) avec 2 boutons
   │
   │ chaque bouton = lien GET /api/v1/checkin/:token (action embeddée)
   ▼
utilisateur clique "Oui, j'ai été payé"
   │
   ▼
api: GET /checkin/:token → vérifie token, marque invoice.status = 'paid'
                          → annule les RelanceTasks futures de cette invoice
                          → redirect SPA avec confirmation
```

**Sécurité** : le token doit être signé (HMAC ou JWT court) et avoir une durée limitée (24h après émission). Pas d'auth Bearer requise pour ce endpoint car c'est un click depuis email.

### 6.4 Authentification Bearer

```
SPA: POST /api/v1/auth/login { email, password }
   │
   │ api valide credentials, crée AccessToken (TTL 30 min) + RefreshToken (TTL 30j httpOnly cookie)
   ▼
SPA reçoit { accessToken, user } — accessToken stocké en mémoire
   │
   │ chaque requête API : Authorization: Bearer <accessToken>
   ▼
30 min plus tard : 401 sur appel API
   │
   ▼
SPA: POST /api/v1/auth/refresh (cookie httpOnly envoyé auto)
   │
   │ api valide refresh, émet nouvel accessToken
   ▼
SPA retry l'appel original avec nouveau token
```

---

## 7. Topologie de déploiement

### Réseau Proxmox

| Resource | Type | Rôle |
|---|---|---|
| Cluster K3s | Pool VMs Proxmox | Orchestration des pods app |
| LXC `postgres` | LXC dédié | PostgreSQL — accessible aux pods K3s via réseau interne |
| LXC `minio` | LXC dédié | MinIO — accessible aux pods K3s via réseau interne |
| Traefik | Reverse proxy | TLS termination + routing par hostname |

### Pods K3s

```yaml
# Namespace: rubis
- Deployment: rubis-api          # AdonisJS Node, port 3333
- Deployment: rubis-web          # nginx, sert le bundle Vite, port 80
- Deployment: rubis-landing      # déjà existant
- Service: rubis-api-svc         # ClusterIP
- Service: rubis-web-svc         # ClusterIP
- Service: postgres-external     # ExternalName → IP du LXC postgres
- Service: minio-external        # ExternalName → IP du LXC minio
- Secret: rubis-config           # DB credentials, MinIO credentials, OCR API key, mail API key
- IngressRoute (Traefik) :
    api.rubis.pro  → rubis-api-svc:3333
    app.rubis.pro  → rubis-web-svc:80
    rubis.pro      → rubis-landing-svc:80
```

### Pipeline CI Gitea

```
git push gitea main
     ↓
.gitea/workflows/build.yml
     ↓
build & push images :
  - git.arthurbarre.fr/ordinarthur/rubis-api:<sha>
  - git.arthurbarre.fr/ordinarthur/rubis-web:<sha>
     ↓
kubectl rollout (api + web)
     ↓
healthchecks readinessProbe → service public
```

---

## 8. Conventions de code

| Domaine | Convention |
|---|---|
| Branches | `feat/<short-desc>`, `fix/<short-desc>`, `chore/<…>` |
| Commits | [Conventional Commits](https://www.conventionalcommits.org/) (`feat:`, `fix:`, `chore:`, `docs:`, `refactor:`) |
| TypeScript | `strict: true`, pas de `any` (sauf justifié + commenté), `noUncheckedIndexedAccess: true` |
| Linting | ESLint + Prettier, config partagée via `tsconfig.base.json` + `.eslintrc.cjs` racine |
| Tests | Japa (Adonis) pour API, Vitest pour shared/, Playwright pour E2E utilisateur (V2) |
| Migrations | Versionnées, jamais éditées rétroactivement (cf. principe ADR du log de décisions) |
| Secrets | Jamais en clair dans le repo. `.env.local` git-ignoré, secrets K3s pour la prod |

---

## 9. Sécurité & RGPD

- **Hébergement français** (Proxmox France) — conforme RGPD pour la cible TPE-PME
- **Chiffrement at-rest** : disque Proxmox chiffré (LUKS) — à confirmer côté infra
- **Chiffrement in-transit** : TLS partout (Traefik), connexions PG et MinIO en SSL interne
- **Rate limiting** : `@adonisjs/limiter` sur `/auth/*` (5 req/min par IP), routes OCR (10/h par utilisateur)
- **CORS** : whitelist stricte (`app.rubis.pro` uniquement) — refus des origines tierces
- **CSRF** : non-applicable car auth via Bearer header (pas cookie session). Les endpoints email check-in utilisent des tokens signés à TTL court.
- **Backups** :
  - PG : dump quotidien dans MinIO (`rubis-backups/pg/<date>.dump`)
  - MinIO : snapshot Proxmox du LXC quotidien
  - Retention : 30 jours mini (à confirmer)
- **Suppression** : RGPD Article 17 — endpoint `DELETE /api/v1/me` qui purge data utilisateur + factures + pièces jointes

---

## 10. Décisions encore en attente

À trancher avant fin V1, par ordre de priorité :

| # | Sujet | Échéance suggérée |
|---|---|---|
| 019 | **Domain model** (entités, relations, index) | Avant la 1ère migration |
| 020 | **Provider OCR** (Mindee, Document AI, Textract, Tesseract) | Avant l'implémentation du job ProcessOcr |
| 021 | **Provider email** (Resend, Postmark, SendGrid, AWS SES) | Avant l'implémentation des relances |
| 022 | **Pricing exact** (Free 5 factures ? Pro 19 €/mois ?) | Avant le payment flow |
| 023 | **Endpoint waitlist** (Resend / Formspree / API Adonis) | Au push de la landing en prod |

---

## 11. Évolutions V2+ anticipées

- **Multi-utilisateurs** : tables `organizations` et `memberships` à prévoir dès la V1 (même si UI mono-user)
- **SMS** : provider Twilio/OVH abstrait derrière un service `MessageDispatcher` qui route email/sms selon plan + cadence
- **Intégration banking** : webhook entrant sur `/api/v1/banking/payment-confirmed` qui marque les invoices payées automatiquement (le check-in email V1 devient fallback)
- **Intégrations comptables** (Pennylane/Sage) : modèle d'événement abstrait `invoice.created` exportable en webhook sortant
- **API publique** : sous `/api/v1/public/*` avec abilities/scopes par token (lecture seule, écriture limitée)

---

*Maintenu par Arthur + Claude. Ce document est versionné — les changements significatifs passent par un ADR dans `/docs/decisions.md`.*
