diff --git a/documentation/architecture.md b/documentation/architecture.md
index f858eb01d..250c47d06 100644
--- a/documentation/architecture.md
+++ b/documentation/architecture.md
@@ -1,20 +1,513 @@
-## Architecture
+# Architecture
-### Global system architecture
+## Overview
+Docs is a set of small services around one rule: **Django owns who may do what,
+and the collaboration server owns what a document says**. Every other service is
+either a client of those two or a converter they call.
+
+- The **backend** (Django) holds documents, users, accesses, invitations and
+ attachments metadata. It is the only service that decides whether a caller may
+ read or write a document, and every other service asks it rather than deciding
+ on its own.
+- **yhub** holds the live Yjs state of the documents. Browsers connect to it over
+ a websocket and edit together; it persists the merged state in a store of its
+ own. The backend reads and writes that state through yhub's REST API — never
+ directly in its database.
+- The **frontend** is a Next.js application exported to static files. In
+ production it is served by Nginx, with no Node.js runtime.
+- **y-provider** and **Docspec** are stateless converters. They store nothing and
+ own nothing.
+
+Nothing long-lived is shared between the services: they authenticate to each
+other with short-lived RS256 JWTs, each verified against the JWKS the other
+publishes.
+
+## The services at a glance
+
+| Service | Stack | Owns | Talks to |
+| --- | --- | --- | --- |
+| Frontend | Next.js, BlockNote, static export served by Nginx | nothing | the browser only |
+| Backend | Django, DRF | documents, users, accesses, attachments | PostgreSQL, Redis, S3, yhub, y-provider, Docspec, OIDC, LLM, search |
+| Celery worker | Celery | nothing | PostgreSQL, Redis, yhub, SMTP, search |
+| yhub server | `@y/hub`, Node | the live document content | Valkey, its PostgreSQL, the backend, S3 |
+| yhub worker | `@y/hub`, Node | — | Valkey, its PostgreSQL, optional S3 |
+| y-provider | Express, BlockNote server-util | nothing | the backend (JWKS only) |
+| Docspec | external image | nothing | nothing |
+
+## Global service map
+
+
```mermaid
-flowchart TD
- User -- HTTP --> Front("Frontend (NextJS SPA)")
- Front -- REST API --> Back("Backend (Django)")
- Front -- WebSocket --> Yserver("Microservice Yjs (Express)") -- WebSocket --> CollaborationServer("Collaboration server (Hocuspocus)") -- REST API <--> Back
- Front -- OIDC --> Back -- OIDC ---> OIDC("Keycloak / ProConnect")
- Back -- REST API --> Yserver
- Back --> DB("Database (PostgreSQL)")
- Back <--> Celery --> DB
- Back ----> S3("Minio (S3)")
- Back -- REST API --> Find
+flowchart LR
+
+ subgraph clients["Clients"]
+ direction TB
+ BROWSER["Browser Next.js SPA · BlockNote + service worker (offline)"]
+ EXTAPP["Third-party app resource-server client"]
+ end
+
+ subgraph edge["Edge"]
+ INGRESS["Ingress / Nginx TLS · routing · media auth"]
+ end
+
+ subgraph app["Application services"]
+ direction TB
+ FRONT["Frontend Next.js static build"]
+ BACK["Backend Django · DRF"]
+ CELERY["Celery worker"]
+ subgraph collab["Collaboration"]
+ direction TB
+ YHUB["yhub server WebSocket + REST"]
+ YWORKER["yhub worker"]
+ end
+ subgraph conv["Conversion"]
+ direction TB
+ YPROV["y-provider Yjs to md / HTML / JSON"]
+ DOCSPEC["Docspec .docx import"]
+ end
+ end
+
+ subgraph stores["Data stores"]
+ direction TB
+ PG[("PostgreSQL docs · users · rights")]
+ REDIS[("Redis broker · cache")]
+ S3[("S3 / MinIO attachments · versions")]
+ VALKEY[("Valkey update stream")]
+ YPG[("PostgreSQL yhub Yjs state")]
+ YS3[("S3 yhub optional")]
+ end
+
+ subgraph ext["External services"]
+ direction TB
+ OIDC["OIDC provider Keycloak · ProConnect"]
+ LLM["LLM provider OpenAI · Mistral"]
+ SEARCH["Search / indexing"]
+ SMTP["SMTP relay"]
+ AV["Malware detection"]
+ OBS["Langfuse · Sentry · PostHog"]
+ end
+
+ %% clients to edge
+ BROWSER ==>|"HTTPS · WSS"| INGRESS
+ EXTAPP -->|"bearer token"| INGRESS
+ BROWSER -.->|"login"| OIDC
+
+ %% edge to services
+ INGRESS -->|"/"| FRONT
+ INGRESS ==>|"/api/v1.0/"| BACK
+ INGRESS ==>|"/collaboration/"| YHUB
+ INGRESS -->|"/media/ after auth check"| S3
+ INGRESS -.->|"auth_request"| BACK
+ INGRESS -->|"/external_api/v1.0/"| BACK
+
+ %% backend
+ BACK --> PG
+ BACK --> S3
+ BACK --> REDIS
+ REDIS --> CELERY
+ CELERY --> PG
+ BACK <-.->|"OIDC"| OIDC
+ BACK -->|"AI features"| LLM
+ BACK -->|"query"| SEARCH
+ CELERY -->|"index"| SEARCH
+ CELERY -->|"mail"| SMTP
+ BACK <-.->|"scan · callback"| AV
+ BACK -.-> OBS
+
+ %% backend and collaboration
+ BACK <==>|"document lifecycle + auth callbacks"| YHUB
+ CELERY -->|"cascade sync"| YHUB
+
+ %% conversion
+ BACK -->|"convert"| YPROV
+ BACK -->|"import .docx"| DOCSPEC
+ YPROV -.->|"JWKS"| BACK
+
+ %% collaboration internals
+ YHUB --> VALKEY
+ VALKEY --> YWORKER
+ YWORKER --> YPG
+ YHUB --> YPG
+ YWORKER -.-> YS3
+ YHUB -.->|"legacy content"| S3
+
+ classDef client fill:#e8f0fe,stroke:#3b6fd4,color:#12244a
+ classDef service fill:#e6f7ed,stroke:#2f9e5f,color:#0f3d24
+ classDef store fill:#fff4e0,stroke:#d99a19,color:#4a3410
+ classDef external fill:#f3ecfc,stroke:#8256c4,color:#2e1a4d
+ classDef edgeNode fill:#fdeaea,stroke:#cf4747,color:#4a1414
+
+ class BROWSER,EXTAPP client
+ class FRONT,BACK,CELERY,YHUB,YWORKER,YPROV,DOCSPEC service
+ class PG,REDIS,S3,VALKEY,YPG,YS3 store
+ class OIDC,LLM,SEARCH,SMTP,AV,OBS external
+ class INGRESS edgeNode
+
+ style clients fill:#fbfcff,stroke:#c7d4ec
+ style edge fill:#fffafa,stroke:#eccaca
+ style app fill:#fafdfb,stroke:#c9e3d4
+ style collab fill:#f0faf4,stroke:#a9d6bd
+ style conv fill:#f0faf4,stroke:#a9d6bd
+ style stores fill:#fffdf8,stroke:#ecd9b0
+ style ext fill:#fdfbff,stroke:#d9c9ee
```
-### Architecture decision records
+## Request routing
-- [ADR-0001-20250106-use-yjs-for-docs-editing](./adr/ADR-0001-20250106-use-yjs-for-docs-editing.md)
\ No newline at end of file
+One hostname is enough. The reverse proxy dispatches on the path:
+
+| Path | Goes to | Notes |
+| --- | --- | --- |
+| `/` | frontend | static assets, client-side routing |
+| `/api/v1.0/` | backend | REST API, session cookie |
+| `/external_api/v1.0/` | backend | resource server, OIDC bearer token |
+| `/collaboration/ws/v1/…` | yhub | the websocket the editors open |
+| `/collaboration/{ydoc,rollback,prune,changeset,activity,jwks}` | yhub | document APIs, guarded by the same document authorization |
+| `/media/` | S3, after an `auth_request` to the backend | see [attachments](#attachments-and-protected-media) |
+
+Four yhub routes are **backend-internal** and must not be published:
+`reset-connections`, `migrate`, `restore-ydoc` and `reset-ydoc`. The two probes
+(`ping`, `ready`) are called by the kubelet and stay in-cluster too. The helm
+chart's ingress lists what it routes rather than what it hides, so they stay
+private on their own.
+
+## Collaboration
+
+
+```mermaid
+flowchart TB
+
+ EDITORS["Editors one browser per participant"]
+ BACK["Django backend owns documents, users, access rights"]
+
+ subgraph yhubbox["yhub — two halves sharing only the stores"]
+ direction LR
+ SRV["server YHUB_ROLE=server | all websockets + /collaboration/ routes"]
+ WRK["worker YHUB_ROLE=worker | all merges updates, persists, trims"]
+ end
+
+ VALKEY[("Valkey / Redis update stream · consumer group")]
+ YPG[("PostgreSQLyhub persisted Yjs state")]
+ YS3[("S3yhub optional blob store")]
+ LEGACY[("S3 media bucket legacy content + versions")]
+
+ EDITORS <==>|"WSS /collaboration/ws/v1/:org/:docid"| SRV
+ SRV -->|"auth + change callbacks"| BACK
+ BACK -->|"document lifecycle API"| SRV
+
+ SRV -->|"XADD"| VALKEY
+ VALKEY -->|"XREADGROUP"| WRK
+ VALKEY -->|"fan-out to the other replicas"| SRV
+ SRV -->|"read on first sync"| YPG
+ WRK -->|"merged state"| YPG
+ WRK -.->|"when enabled"| YS3
+ SRV -.->|"seed a room · replay history"| LEGACY
+ BACK --> LEGACY
+
+ classDef svc fill:#e6f7ed,stroke:#2f9e5f,color:#0f3d24
+ classDef store fill:#fff4e0,stroke:#d99a19,color:#4a3410
+ classDef client fill:#e8f0fe,stroke:#3b6fd4,color:#12244a
+ class SRV,WRK,BACK svc
+ class VALKEY,YPG,YS3,LEGACY store
+ class EDITORS client
+ style yhubbox fill:#f0faf4,stroke:#a9d6bd
+```
+
+yhub is **two halves that share the two stores and nothing else** — no
+in-process state, no ordering between them:
+
+- the **server** accepts the websockets, serves the routes, and writes every
+ update to the Valkey stream;
+- the **worker** claims tasks from that stream, merges the updates, stores the
+ result and trims what it persisted.
+
+`YHUB_ROLE` decides which half a process runs. Unset means both, which is the
+default. Splitting them lets each scale on its own — servers with the connected
+editors, workers with the write throughput — but they are split together or not
+at all: servers alone accept edits and never persist them.
+
+Consumer groups hand each task to exactly one worker, and replicas exchange
+updates through the stream, so **no sticky routing is needed** on the websocket
+ingress and any number of replicas can serve the same document.
+
+yhub decides nothing about access. On every websocket upgrade it asks the
+backend twice — `GET /api/v1.0/users/me/` for the identity behind the cookie,
+then `GET /api/v1.0/documents/{id}/` for what that identity may do with this
+document — and a backend that does not answer produces a retryable 503 rather
+than a permission decision. When the persisted content changes, it calls
+`POST /api/v1.0/documents/{id}/content-updated/` back, so that lists ordered by
+`updated_at` follow the edits. That call is best effort: one the backend refuses
+or never receives is logged and dropped.
+
+Documents created before the migration still have their content in the S3 media
+bucket. The first access to a room yhub does not know seeds it from there;
+`POST /collaboration/migrate/…` replays a document's full version history out of
+the same bucket.
+
+See [collaboration.md](collaboration.md) for the configuration.
+
+### Opening and editing a document
+
+
+```mermaid
+sequenceDiagram
+ autonumber
+ actor U as Browser
+ participant N as Ingress / Nginx
+ participant D as Django backend
+ participant Y as yhub server
+ participant V as Valkey
+ participant W as yhub worker
+ participant P as PostgreSQL yhub
+
+ U->>N: GET /docs/:id
+ N->>D: GET /api/v1.0/documents/:id/ (session cookie)
+ D-->>U: metadata, abilities, link reach
+
+ U->>N: WSS /collaboration/ws/v1/docs/:id
+ N->>Y: upgrade, forwards cookie and origin
+ Y->>D: GET /api/v1.0/users/me/
+ D-->>Y: user id (or 401, then anonymous)
+ Y->>D: GET /api/v1.0/documents/:id/
+ D-->>Y: abilities, so read-only or read-write
+ Note over Y: unknown room, seed it once from the legacy S3 content
+ Y->>P: read the persisted state
+ Y-->>U: initial sync
+
+ loop while editing
+ U->>Y: Yjs update
+ Y->>V: XADD
+ Y-->>U: broadcast to the other editors
+ end
+
+ V->>W: XREADGROUP, one task to one worker
+ W->>P: merge and persist
+ W->>V: trim what is stored
+ Y->>D: POST /documents/:id/content-updated/ (yhub JWT)
+ D->>D: bump updated_at, queue the search indexer
+```
+
+## Content is read through yhub, never around it
+
+The backend never reads a document's content from its own database — it does not
+have it. Anything needing the text goes through yhub's REST API with an admin
+JWT:
+
+| The backend wants to | It calls |
+| --- | --- |
+| create a document with initial content | `POST /collaboration/create-ydoc/v1/…` — a strict create, 409 if content already exists |
+| read the content (export, search indexing, AI) | `GET /collaboration/ydoc/v1/…` |
+| delete a document | `DELETE /collaboration/ydoc/v1/…` |
+| restore a deleted one | `POST /collaboration/restore-ydoc/v1/…` |
+| erase the content, keeping the room | `POST /collaboration/reset-ydoc/v1/…` |
+| re-check the rights of connected clients | `POST /collaboration/reset-connections/v1/…` |
+| replay the legacy history | `POST /collaboration/migrate/v1/…` |
+
+Deletions and permission changes cascade: a document inherits the accesses of its
+ancestors, so the Celery tasks walk the subtree and call the document-scoped
+endpoint once per descendant. A document that fails is logged and does not stop
+the ones after it.
+
+## Conversion, import and export
+
+
+```mermaid
+sequenceDiagram
+ autonumber
+ actor U as Browser
+ participant D as Django backend
+ participant K as Docspec
+ participant C as y-provider
+ participant Y as yhub server
+
+ rect rgb(240,247,255)
+ Note over U,Y: importing a file
+ U->>D: POST /documents/create-for-owner/ or import
+ alt .docx
+ D->>K: POST /conversion
+ K-->>D: BlockNote blocks
+ else markdown
+ Note over D: kept as is
+ end
+ D->>C: POST /api/convert/ (admin JWT)
+ C-->>D: Yjs update, binary
+ D->>Y: POST /collaboration/create-ydoc/v1/docs/:id
+ Y-->>D: 201, or 409 if the document already has content
+ end
+
+ rect rgb(245,255,248)
+ Note over U,Y: exporting or reading the content
+ U->>D: GET /documents/:id/formatted-content/?content_format=markdown
+ D->>Y: GET /collaboration/ydoc/v1/docs/:id
+ Y-->>D: current state, the source of truth
+ D->>C: POST /api/convert/ Yjs to markdown
+ C-->>D: markdown
+ D-->>U: converted content
+ end
+```
+
+**y-provider** is the only service that understands both a Yjs update and the
+BlockNote schema, so every conversion in either direction goes through it. It
+used to serve the websockets as well, which is why it could be deployed twice;
+the collaboration is served by yhub now and the conversion is all that is left.
+
+**Docspec** turns `.docx` into modern editable content. It is optional
+(`CONVERSION_UPLOAD_ENABLED`), it exposes a public API with no authentication of
+its own, and should therefore be deployed on a private network.
+
+See [format_conversion.md](format_conversion.md).
+
+## Attachments and protected media
+
+
+```mermaid
+sequenceDiagram
+ autonumber
+ actor U as Browser
+ participant N as Ingress / Nginx
+ participant D as Django backend
+ participant S as S3 / MinIO
+ participant C as Celery worker
+ participant A as Malware scanner
+
+ rect rgb(240,247,255)
+ Note over U,A: upload
+ U->>D: POST /documents/:id/attachment-upload/
+ D->>D: check write access, size, mime type
+ D->>S: PUT object, status=processing
+ D->>C: queue the scan
+ C->>A: scan the object
+ A-->>D: callback, safe or infected
+ D->>S: metadata status=ready, or delete
+ D-->>U: media url
+ end
+
+ rect rgb(245,255,248)
+ Note over U,S: read
+ U->>N: GET /media/:key
+ N->>D: auth_request /documents/media-auth/ X-Original-URL
+ D->>D: resolve the document, check read access
+ D-->>N: 200 + Authorization, X-Amz-Date, X-Amz-Content-SHA256
+ N->>S: GET the object with those signed headers
+ S-->>U: file, with Content-Security-Policy default-src none
+ end
+```
+
+Attachments are never served straight from the bucket. Every `/media/` request
+goes through an Nginx `auth_request` sub-request to the backend, which resolves
+the object key back to its document, checks the caller's read access, and answers
+with the S3 headers signing that single object. Nginx then proxies the object
+with those headers. The bucket needs no public access, and a leaked url grants
+nothing.
+
+The sub-request is on the hot path of every image in a document, so a slow
+backend shows up here first — the reason the readiness probe and the database
+pool sizing matter more than their traffic suggests.
+
+## Service-to-service trust
+
+
+```mermaid
+flowchart LR
+
+ BACK["Django backend signs with JWT_PRIVATE_KEY publishes /api/v1.0/jwks"]
+ YHUB["yhub signs with YHUB_JWT_PRIVATE_KEY publishes /collaboration/jwks/v1"]
+ YPROV["y-provider verifies only, signs nothing"]
+ RS["Resource server same Django process publishes /external_api/v1.0/jwks"]
+ OIDC["OIDC provider"]
+
+ BACK ==>|"admin JWT · RS256 · aud yhub · short lived"| YHUB
+ YHUB -.->|"fetches the backend JWKS to verify it"| BACK
+ YHUB ==>|"callback JWT · RS256 · aud docs-backend · 1 min"| BACK
+ BACK -.->|"fetches the yhub JWKS to verify it"| YHUB
+ BACK ==>|"admin JWT · RS256"| YPROV
+ YPROV -.->|"fetches the backend JWKS to verify it"| BACK
+ OIDC -.->|"introspection · declared JWKS"| RS
+ RS --- BACK
+
+ NOTE["No shared secret in either direction. Each side holds only its own private key and can roll it without touching the other: keys are picked by kid and the set is re-fetched whenever an unknown one shows up."]
+
+ classDef svc fill:#e6f7ed,stroke:#2f9e5f,color:#0f3d24
+ classDef external fill:#f3ecfc,stroke:#8256c4,color:#2e1a4d
+ classDef note fill:#fffdf5,stroke:#d9c9a0,color:#4a3410
+ class BACK,YHUB,YPROV,RS svc
+ class OIDC external
+ class NOTE note
+```
+
+Both directions between the backend and yhub are authenticated with short-lived
+RS256 JWTs, and each side verifies the other against the JWKS it publishes. Both
+need a signing key of their own, and neither needs a copy of the other's — so
+rolling one needs no change on the other side. The helm chart can generate both
+keys into a secret every service mounts read-only (`jwtKeys.enabled`).
+
+Two other credentials exist and are *not* part of that scheme:
+
+- `Y_PROVIDER_API_KEY`, sent by yhub as `X-Y-Provider-Key` on its authorization
+ calls. It only exempts them from the API throttling — it authenticates nothing.
+- `SERVER_TO_SERVER_API_TOKENS`, a list of bearer tokens accepted on
+ `create-for-owner`, for trusted services creating a document on a user's behalf.
+
+## Authentication of the users
+
+The frontend redirects to the OIDC provider (Keycloak in development, ProConnect
+in production, any compliant provider elsewhere); the backend is the relying
+party and keeps the session in a cookie. That cookie is what the browser sends to
+the API *and* what yhub forwards to the backend when authorizing a websocket.
+
+Docs is also a **resource server**: with `OIDC_RESOURCE_SERVER_ENABLED`, another
+application can call `/external_api/v1.0/` with an OIDC access token, on the
+routes and actions the `EXTERNAL_API` setting allows. See
+[resource_server.md](resource_server.md).
+
+## Asynchronous work
+
+Celery runs on the same Redis instance the backend caches and stores sessions in,
+on a database of its own. It carries what must not
+block a request: invitation and access-request emails, the search indexing, the
+user reconciliation import, the malware scan callbacks, and the cascade calls to
+yhub when a document is deleted, restored, or has its accesses changed. There is
+no beat scheduler — periodic work is deployed as Kubernetes CronJobs
+(`backend.cronjobs` in the chart).
+
+## Optional and external services
+
+| Service | Enabled by | Used for |
+| --- | --- | --- |
+| Search / indexing | `SEARCH_INDEXER_CLASS`, `INDEXING_URL`, `SEARCH_URL` | full-text search across documents, fed by Celery |
+| LLM provider | `AI_FEATURE_ENABLED` + OpenAI-compatible or Mistral credentials | rewrite, summarize, translate, fix typos |
+| Langfuse | `LANGFUSE_*` | tracing the LLM calls |
+| Malware detection | `MALWARE_DETECTION` backend | scanning uploaded attachments |
+| Docspec | `CONVERSION_UPLOAD_ENABLED`, `DOCSPEC_API_URL` | `.docx` import |
+| yhub S3 persistence | `YHUB_S3_PERSISTENCE` | document blobs in a bucket instead of PostgreSQL |
+| Sentry, PostHog | `SENTRY_DSN`, `POSTHOG_KEY` | errors and product analytics |
+
+## Data stores
+
+| Store | Holds | Lost if deleted |
+| --- | --- | --- |
+| PostgreSQL (Django) | documents, users, accesses, invitations | everything but the document text |
+| Redis | Celery broker, cache, sessions | in-flight tasks, and every session — users are logged out |
+| S3 / MinIO | attachments, legacy content and its version history | attachments, version history |
+| Valkey / Redis (yhub) | the update stream not yet persisted | the last seconds of edits — hence `appendonly` |
+| PostgreSQL (yhub) | the persisted Yjs state | the documents' text |
+| S3 (yhub, optional) | the same state as blobs | the documents' text |
+
+yhub's schema is never created at startup — the server runs no DDL. Run its init
+script once before starting it, and again after every upgrade that adds a table;
+the helm chart runs it as a job.
+
+## Regenerating the diagrams
+
+The diagrams above are the source. `documentation/assets/diagrams/render.sh`
+extracts each one into a standalone `.mmd` file and renders it to PNG and SVG in
+the same directory:
+
+```bash
+./documentation/assets/diagrams/render.sh
+```
+
+## Architecture decision records
+
+- [ADR-0001-20250106-use-yjs-for-docs-editing](./adr/ADR-0001-20250106-use-yjs-for-docs-editing.md)
diff --git a/documentation/assets/diagrams/architecture-collaboration.mmd b/documentation/assets/diagrams/architecture-collaboration.mmd
new file mode 100644
index 000000000..6729c2f8c
--- /dev/null
+++ b/documentation/assets/diagrams/architecture-collaboration.mmd
@@ -0,0 +1,36 @@
+flowchart TB
+
+ EDITORS["Editors one browser per participant"]
+ BACK["Django backend owns documents, users, access rights"]
+
+ subgraph yhubbox["yhub — two halves sharing only the stores"]
+ direction LR
+ SRV["server YHUB_ROLE=server | all websockets + /collaboration/ routes"]
+ WRK["worker YHUB_ROLE=worker | all merges updates, persists, trims"]
+ end
+
+ VALKEY[("Valkey / Redis update stream · consumer group")]
+ YPG[("PostgreSQLyhub persisted Yjs state")]
+ YS3[("S3yhub optional blob store")]
+ LEGACY[("S3 media bucket legacy content + versions")]
+
+ EDITORS <==>|"WSS /collaboration/ws/v1/:org/:docid"| SRV
+ SRV -->|"auth + change callbacks"| BACK
+ BACK -->|"document lifecycle API"| SRV
+
+ SRV -->|"XADD"| VALKEY
+ VALKEY -->|"XREADGROUP"| WRK
+ VALKEY -->|"fan-out to the other replicas"| SRV
+ SRV -->|"read on first sync"| YPG
+ WRK -->|"merged state"| YPG
+ WRK -.->|"when enabled"| YS3
+ SRV -.->|"seed a room · replay history"| LEGACY
+ BACK --> LEGACY
+
+ classDef svc fill:#e6f7ed,stroke:#2f9e5f,color:#0f3d24
+ classDef store fill:#fff4e0,stroke:#d99a19,color:#4a3410
+ classDef client fill:#e8f0fe,stroke:#3b6fd4,color:#12244a
+ class SRV,WRK,BACK svc
+ class VALKEY,YPG,YS3,LEGACY store
+ class EDITORS client
+ style yhubbox fill:#f0faf4,stroke:#a9d6bd
diff --git a/documentation/assets/diagrams/architecture-collaboration.png b/documentation/assets/diagrams/architecture-collaboration.png
new file mode 100644
index 000000000..56749204f
Binary files /dev/null and b/documentation/assets/diagrams/architecture-collaboration.png differ
diff --git a/documentation/assets/diagrams/architecture-collaboration.svg b/documentation/assets/diagrams/architecture-collaboration.svg
new file mode 100644
index 000000000..02bb710d4
--- /dev/null
+++ b/documentation/assets/diagrams/architecture-collaboration.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/documentation/assets/diagrams/architecture-services.mmd b/documentation/assets/diagrams/architecture-services.mmd
new file mode 100644
index 000000000..fca05d20a
--- /dev/null
+++ b/documentation/assets/diagrams/architecture-services.mmd
@@ -0,0 +1,112 @@
+flowchart LR
+
+ subgraph clients["Clients"]
+ direction TB
+ BROWSER["Browser Next.js SPA · BlockNote + service worker (offline)"]
+ EXTAPP["Third-party app resource-server client"]
+ end
+
+ subgraph edge["Edge"]
+ INGRESS["Ingress / Nginx TLS · routing · media auth"]
+ end
+
+ subgraph app["Application services"]
+ direction TB
+ FRONT["Frontend Next.js static build"]
+ BACK["Backend Django · DRF"]
+ CELERY["Celery worker"]
+ subgraph collab["Collaboration"]
+ direction TB
+ YHUB["yhub server WebSocket + REST"]
+ YWORKER["yhub worker"]
+ end
+ subgraph conv["Conversion"]
+ direction TB
+ YPROV["y-provider Yjs to md / HTML / JSON"]
+ DOCSPEC["Docspec .docx import"]
+ end
+ end
+
+ subgraph stores["Data stores"]
+ direction TB
+ PG[("PostgreSQL docs · users · rights")]
+ REDIS[("Redis broker · cache")]
+ S3[("S3 / MinIO attachments · versions")]
+ VALKEY[("Valkey update stream")]
+ YPG[("PostgreSQL yhub Yjs state")]
+ YS3[("S3 yhub optional")]
+ end
+
+ subgraph ext["External services"]
+ direction TB
+ OIDC["OIDC provider Keycloak · ProConnect"]
+ LLM["LLM provider OpenAI · Mistral"]
+ SEARCH["Search / indexing"]
+ SMTP["SMTP relay"]
+ AV["Malware detection"]
+ OBS["Langfuse · Sentry · PostHog"]
+ end
+
+ %% clients to edge
+ BROWSER ==>|"HTTPS · WSS"| INGRESS
+ EXTAPP -->|"bearer token"| INGRESS
+ BROWSER -.->|"login"| OIDC
+
+ %% edge to services
+ INGRESS -->|"/"| FRONT
+ INGRESS ==>|"/api/v1.0/"| BACK
+ INGRESS ==>|"/collaboration/"| YHUB
+ INGRESS -->|"/media/ after auth check"| S3
+ INGRESS -.->|"auth_request"| BACK
+ INGRESS -->|"/external_api/v1.0/"| BACK
+
+ %% backend
+ BACK --> PG
+ BACK --> S3
+ BACK --> REDIS
+ REDIS --> CELERY
+ CELERY --> PG
+ BACK <-.->|"OIDC"| OIDC
+ BACK -->|"AI features"| LLM
+ BACK -->|"query"| SEARCH
+ CELERY -->|"index"| SEARCH
+ CELERY -->|"mail"| SMTP
+ BACK <-.->|"scan · callback"| AV
+ BACK -.-> OBS
+
+ %% backend and collaboration
+ BACK <==>|"document lifecycle + auth callbacks"| YHUB
+ CELERY -->|"cascade sync"| YHUB
+
+ %% conversion
+ BACK -->|"convert"| YPROV
+ BACK -->|"import .docx"| DOCSPEC
+ YPROV -.->|"JWKS"| BACK
+
+ %% collaboration internals
+ YHUB --> VALKEY
+ VALKEY --> YWORKER
+ YWORKER --> YPG
+ YHUB --> YPG
+ YWORKER -.-> YS3
+ YHUB -.->|"legacy content"| S3
+
+ classDef client fill:#e8f0fe,stroke:#3b6fd4,color:#12244a
+ classDef service fill:#e6f7ed,stroke:#2f9e5f,color:#0f3d24
+ classDef store fill:#fff4e0,stroke:#d99a19,color:#4a3410
+ classDef external fill:#f3ecfc,stroke:#8256c4,color:#2e1a4d
+ classDef edgeNode fill:#fdeaea,stroke:#cf4747,color:#4a1414
+
+ class BROWSER,EXTAPP client
+ class FRONT,BACK,CELERY,YHUB,YWORKER,YPROV,DOCSPEC service
+ class PG,REDIS,S3,VALKEY,YPG,YS3 store
+ class OIDC,LLM,SEARCH,SMTP,AV,OBS external
+ class INGRESS edgeNode
+
+ style clients fill:#fbfcff,stroke:#c7d4ec
+ style edge fill:#fffafa,stroke:#eccaca
+ style app fill:#fafdfb,stroke:#c9e3d4
+ style collab fill:#f0faf4,stroke:#a9d6bd
+ style conv fill:#f0faf4,stroke:#a9d6bd
+ style stores fill:#fffdf8,stroke:#ecd9b0
+ style ext fill:#fdfbff,stroke:#d9c9ee
diff --git a/documentation/assets/diagrams/architecture-services.png b/documentation/assets/diagrams/architecture-services.png
new file mode 100644
index 000000000..aa30b9858
Binary files /dev/null and b/documentation/assets/diagrams/architecture-services.png differ
diff --git a/documentation/assets/diagrams/architecture-services.svg b/documentation/assets/diagrams/architecture-services.svg
new file mode 100644
index 000000000..38c5c5b40
--- /dev/null
+++ b/documentation/assets/diagrams/architecture-services.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/documentation/assets/diagrams/architecture-trust.mmd b/documentation/assets/diagrams/architecture-trust.mmd
new file mode 100644
index 000000000..5425ea046
--- /dev/null
+++ b/documentation/assets/diagrams/architecture-trust.mmd
@@ -0,0 +1,25 @@
+flowchart LR
+
+ BACK["Django backend signs with JWT_PRIVATE_KEY publishes /api/v1.0/jwks"]
+ YHUB["yhub signs with YHUB_JWT_PRIVATE_KEY publishes /collaboration/jwks/v1"]
+ YPROV["y-provider verifies only, signs nothing"]
+ RS["Resource server same Django process publishes /external_api/v1.0/jwks"]
+ OIDC["OIDC provider"]
+
+ BACK ==>|"admin JWT · RS256 · aud yhub · short lived"| YHUB
+ YHUB -.->|"fetches the backend JWKS to verify it"| BACK
+ YHUB ==>|"callback JWT · RS256 · aud docs-backend · 1 min"| BACK
+ BACK -.->|"fetches the yhub JWKS to verify it"| YHUB
+ BACK ==>|"admin JWT · RS256"| YPROV
+ YPROV -.->|"fetches the backend JWKS to verify it"| BACK
+ OIDC -.->|"introspection · declared JWKS"| RS
+ RS --- BACK
+
+ NOTE["No shared secret in either direction. Each side holds only its own private key and can roll it without touching the other: keys are picked by kid and the set is re-fetched whenever an unknown one shows up."]
+
+ classDef svc fill:#e6f7ed,stroke:#2f9e5f,color:#0f3d24
+ classDef external fill:#f3ecfc,stroke:#8256c4,color:#2e1a4d
+ classDef note fill:#fffdf5,stroke:#d9c9a0,color:#4a3410
+ class BACK,YHUB,YPROV,RS svc
+ class OIDC external
+ class NOTE note
diff --git a/documentation/assets/diagrams/architecture-trust.png b/documentation/assets/diagrams/architecture-trust.png
new file mode 100644
index 000000000..1f7e8ea83
Binary files /dev/null and b/documentation/assets/diagrams/architecture-trust.png differ
diff --git a/documentation/assets/diagrams/architecture-trust.svg b/documentation/assets/diagrams/architecture-trust.svg
new file mode 100644
index 000000000..17d193dc9
--- /dev/null
+++ b/documentation/assets/diagrams/architecture-trust.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/documentation/assets/diagrams/render.sh b/documentation/assets/diagrams/render.sh
new file mode 100755
index 000000000..611d1dd5f
--- /dev/null
+++ b/documentation/assets/diagrams/render.sh
@@ -0,0 +1,40 @@
+#!/usr/bin/env bash
+#
+# Extract every mermaid diagram of documentation/architecture.md into a
+# standalone .mmd file, and render it to PNG and SVG next to it.
+#
+# The document is the source: a diagram is picked up when its fenced block is
+# preceded by a `` comment, which gives it its file name.
+#
+# Usage: ./documentation/assets/diagrams/render.sh [name ...]
+# (no argument renders them all)
+
+set -euo pipefail
+
+here="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
+doc="${here}/../../architecture.md"
+width="${MERMAID_WIDTH:-2000}"
+
+[ -f "$doc" ] || { echo "not found: $doc" >&2; exit 1; }
+
+# 1. extract
+names=$(awk -v dir="$here" '
+ /^>D: callback, safe or infected
+ D->>S: metadata status=ready, or delete
+ D-->>U: media url
+ end
+
+ rect rgb(245,255,248)
+ Note over U,S: read
+ U->>N: GET /media/:key
+ N->>D: auth_request /documents/media-auth/ X-Original-URL
+ D->>D: resolve the document, check read access
+ D-->>N: 200 + Authorization, X-Amz-Date, X-Amz-Content-SHA256
+ N->>S: GET the object with those signed headers
+ S-->>U: file, with Content-Security-Policy default-src none
+ end
diff --git a/documentation/assets/diagrams/sequence-attachment.png b/documentation/assets/diagrams/sequence-attachment.png
new file mode 100644
index 000000000..cf3b10851
Binary files /dev/null and b/documentation/assets/diagrams/sequence-attachment.png differ
diff --git a/documentation/assets/diagrams/sequence-attachment.svg b/documentation/assets/diagrams/sequence-attachment.svg
new file mode 100644
index 000000000..835410bb6
--- /dev/null
+++ b/documentation/assets/diagrams/sequence-attachment.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/documentation/assets/diagrams/sequence-conversion.mmd b/documentation/assets/diagrams/sequence-conversion.mmd
new file mode 100644
index 000000000..2322ca064
--- /dev/null
+++ b/documentation/assets/diagrams/sequence-conversion.mmd
@@ -0,0 +1,32 @@
+sequenceDiagram
+ autonumber
+ actor U as Browser
+ participant D as Django backend
+ participant K as Docspec
+ participant C as y-provider
+ participant Y as yhub server
+
+ rect rgb(240,247,255)
+ Note over U,Y: importing a file
+ U->>D: POST /documents/create-for-owner/ or import
+ alt .docx
+ D->>K: POST /conversion
+ K-->>D: BlockNote blocks
+ else markdown
+ Note over D: kept as is
+ end
+ D->>C: POST /api/convert/ (admin JWT)
+ C-->>D: Yjs update, binary
+ D->>Y: POST /collaboration/create-ydoc/v1/docs/:id
+ Y-->>D: 201, or 409 if the document already has content
+ end
+
+ rect rgb(245,255,248)
+ Note over U,Y: exporting or reading the content
+ U->>D: GET /documents/:id/formatted-content/?content_format=markdown
+ D->>Y: GET /collaboration/ydoc/v1/docs/:id
+ Y-->>D: current state, the source of truth
+ D->>C: POST /api/convert/ Yjs to markdown
+ C-->>D: markdown
+ D-->>U: converted content
+ end
diff --git a/documentation/assets/diagrams/sequence-conversion.png b/documentation/assets/diagrams/sequence-conversion.png
new file mode 100644
index 000000000..d7fade238
Binary files /dev/null and b/documentation/assets/diagrams/sequence-conversion.png differ
diff --git a/documentation/assets/diagrams/sequence-conversion.svg b/documentation/assets/diagrams/sequence-conversion.svg
new file mode 100644
index 000000000..432d0436a
--- /dev/null
+++ b/documentation/assets/diagrams/sequence-conversion.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/documentation/assets/diagrams/sequence-open-document.mmd b/documentation/assets/diagrams/sequence-open-document.mmd
new file mode 100644
index 000000000..d66e92243
--- /dev/null
+++ b/documentation/assets/diagrams/sequence-open-document.mmd
@@ -0,0 +1,35 @@
+sequenceDiagram
+ autonumber
+ actor U as Browser
+ participant N as Ingress / Nginx
+ participant D as Django backend
+ participant Y as yhub server
+ participant V as Valkey
+ participant W as yhub worker
+ participant P as PostgreSQL yhub
+
+ U->>N: GET /docs/:id
+ N->>D: GET /api/v1.0/documents/:id/ (session cookie)
+ D-->>U: metadata, abilities, link reach
+
+ U->>N: WSS /collaboration/ws/v1/docs/:id
+ N->>Y: upgrade, forwards cookie and origin
+ Y->>D: GET /api/v1.0/users/me/
+ D-->>Y: user id (or 401, then anonymous)
+ Y->>D: GET /api/v1.0/documents/:id/
+ D-->>Y: abilities, so read-only or read-write
+ Note over Y: unknown room, seed it once from the legacy S3 content
+ Y->>P: read the persisted state
+ Y-->>U: initial sync
+
+ loop while editing
+ U->>Y: Yjs update
+ Y->>V: XADD
+ Y-->>U: broadcast to the other editors
+ end
+
+ V->>W: XREADGROUP, one task to one worker
+ W->>P: merge and persist
+ W->>V: trim what is stored
+ Y->>D: POST /documents/:id/content-updated/ (yhub JWT)
+ D->>D: bump updated_at, queue the search indexer
diff --git a/documentation/assets/diagrams/sequence-open-document.png b/documentation/assets/diagrams/sequence-open-document.png
new file mode 100644
index 000000000..8d90f59f8
Binary files /dev/null and b/documentation/assets/diagrams/sequence-open-document.png differ
diff --git a/documentation/assets/diagrams/sequence-open-document.svg b/documentation/assets/diagrams/sequence-open-document.svg
new file mode 100644
index 000000000..9d6eab7cc
--- /dev/null
+++ b/documentation/assets/diagrams/sequence-open-document.svg
@@ -0,0 +1 @@
+
\ No newline at end of file