diff --git a/plugins/USER_PROVISIONING.md b/plugins/USER_PROVISIONING.md
new file mode 100644
index 00000000..f91d781e
--- /dev/null
+++ b/plugins/USER_PROVISIONING.md
@@ -0,0 +1,240 @@
+# Provisioning a user in a plugin's upstream service
+
+> **Handing this to a fresh agent?** Everything below was verified against a live instance on
+> 2026-08-19 — every status code, every error body. Trust it over the upstream's own docs, which
+> lag. Re-verify with the probe sequence at the end if the upstream has been upgraded since.
+
+Officer creates the account; the upstream service owns it. This is the contract for doing that
+safely, written from the **`photos` plugin** (Immich) as the first worked example. The shape
+generalises: authenticate as an admin, check-then-create, verify the result, hand over a one-time
+password.
+
+**This directory holds documentation, not plugins.** The plugin itself lives in
+[`plugins/photos`](https://gitea.officer.dev/plugins/photos).
+
+---
+
+## The four rules
+
+1. **Never hardcode the admin credential.** Read it from the environment or the platform's
+ credential store, and never log it. Docs and repos get shared; credentials must not travel
+ with them.
+2. **Check before you create.** None of these APIs upsert. A second call with the same email is
+ an error, not a no-op — so provisioning must be re-runnable by listing first.
+3. **Force a password change.** Generate a strong random password, mark it as
+ must-change, and treat it as a one-time handover value. It stops being a secret the moment
+ the user logs in.
+4. **Verify by logging in.** A `201` means the row was written, not that the account works. Do a
+ real login before reporting success, so failures surface at provisioning time rather than when
+ the person first tries to use it.
+
+---
+
+## Worked example — Immich (`photos` plugin)
+
+Verified against **Immich 3.1.0**.
+
+| | |
+|---|---|
+| API root | `/api` — e.g. `http://127.0.0.1:2283/api` |
+| Auth | header `x-api-key: ` |
+| Admin routes | `/api/admin/*` — reject non-admin keys with `401` |
+
+Immich stores API keys **SHA-256 hashed**, so a lost key can only be replaced, never recovered.
+Mint one in the UI → Account Settings → API Keys.
+
+### 1. Check (idempotency guard)
+
+```http
+GET /api/admin/users
+x-api-key:
+```
+→ `200`, JSON array. If the target email is present, stop — already provisioned.
+
+### 2. Create
+
+```http
+POST /api/admin/users
+x-api-key:
+Content-Type: application/json
+
+{
+ "email": "person@example.com",
+ "password": "",
+ "name": "Full Name",
+ "shouldChangePassword": true
+}
+```
+
+**Required:** `email`, `password`, `name`.
+
+| Optional field | Type | Notes |
+|---|---|---|
+| `shouldChangePassword` | bool | `true` ⇒ forced reset at first login. Always send it. |
+| `quotaSizeInBytes` | int | e.g. `1073741824` = 1 GiB. Omit for unlimited. |
+| `storageLabel` | string | Folder name used by the storage template. |
+| `notify` | bool | Emails the new user. Requires SMTP configured upstream. |
+
+→ **`201 Created`**, body carries the new `id`:
+
+```json
+{ "id": "70ee4cd2-…", "email": "person@example.com", "name": "Full Name",
+ "isAdmin": false, "shouldChangePassword": true, "quotaSizeInBytes": null }
+```
+
+`isAdmin` cannot be set at creation — promote afterwards via the admin update endpoint.
+Names are full Unicode (`Bruno Conceição`); send UTF-8, no escaping.
+
+### 3. Verify
+
+```http
+POST /api/auth/login
+Content-Type: application/json
+
+{ "email": "person@example.com", "password": "" }
+```
+→ `200` with `{"name":…,"userEmail":…,"isAdmin":false,"shouldChangePassword":true}`.
+No API key — this is the ordinary user login path, which is exactly why it proves the account works.
+
+### Errors (observed, not guessed)
+
+| Condition | Status | Body |
+|---|---|---|
+| Email already exists | `400` | `{"message":"Email is not available"}` |
+| Missing `password` / `name` | `400` | `{"message":"Validation failed","errors":[{"path":["password"],…}]}` |
+| Bad or missing API key | `401` | — |
+
+### Deprovisioning
+
+```http
+DELETE /api/admin/users/{id}
+Content-Type: application/json
+
+{ "force": true }
+```
+→ `200` with `{"status":"removing","deletedAt":"…"}`. Soft-delete; `force` skips the grace period.
+
+---
+
+## Worked example — Jellyfin (`jellyfin` plugin)
+
+Same four rules, **different shape**. Read this section as: the route surface and auth model are
+**verified** against Jellyfin **10.11.11**; the request bodies are from working knowledge and
+marked as such — confirm them with the probe recipe below before relying on them in code.
+
+### How it differs from Immich — read this first
+
+| | Immich | Jellyfin |
+|---|---|---|
+| Auth header | `x-api-key: ` | `Authorization: MediaBrowser Token=""` |
+| Create + configure | one call | **two or three** — create, then policy, sometimes password |
+| Force password change | `shouldChangePassword: true` | **no equivalent** — see below |
+| Scope of an account | one server | **per instance** — see below |
+
+Two consequences that bite:
+
+1. **There is no "must change password" flag.** Rule 3 cannot be enforced by the server.
+ **Decision for this deployment (2026-08-20): set a generated password at creation and tell the
+ user to change it.** Do *not* use the blank-password alternative — Jellyfin permits it, but the
+ account is then open to anyone who can reach the port until the user sets one.
+ So for Jellyfin, rule 3 becomes: still generate a strong unique password, still treat it as a
+ handover value, but the change is advisory rather than forced — which means **never reuse a
+ generated password across users or instances**, since it may stay valid indefinitely.
+2. **Users are per-instance, not global.** This deployment runs **four** independent Jellyfin
+ servers, each with its own user database and its own API keys:
+
+ | Instance | Port | Server name |
+ |---|---|---|
+ | `jellyfin` | 9301 | Jelly |
+ | `jellyfin_music` | 9302 | Albums |
+ | `jellyfin_dj_sets` | 9303 | DJ_Sets |
+ | `jellyfin_diogo` | 9399 | JellyDiogo |
+
+ Provisioning "a Jellyfin user" therefore means looping over whichever instances that person
+ should reach, with a **separate API key per instance**. There is no cross-server account.
+
+### Getting an API key
+
+Unlike Immich, Jellyfin has **no admin-token bootstrap from nothing** — creating a key requires
+an already-authenticated session. Two routes:
+
+- **UI:** Dashboard → API Keys → `+`. Simplest, and the one to use for first setup.
+- **API:** `POST /Auth/Keys?App=` (verified: route exists, `401` unauthenticated), using a
+ token obtained from `POST /Users/AuthenticateByName` with an admin username/password
+ (verified: route exists; returns `415` if you omit the JSON content type).
+
+`GET /Auth/Keys` lists them (verified: exists, `401` unauthenticated). Keys are stored in
+`config/data/jellyfin.db`, table `ApiKeys`. **This deployment currently has zero API keys** — one
+must be created before any of the flow below will work.
+
+### The flow
+
+```http
+GET /Users
+Authorization: MediaBrowser Token=""
+```
+→ verified: exists, `401` without auth. Use it as the **check** step (rule 2) — match on `Name`.
+
+```http
+POST /Users/New
+Authorization: MediaBrowser Token=""
+Content-Type: application/json
+
+{ "Name": "Full Name", "Password": "" }
+```
+→ verified: route exists (`401` unauthenticated). Body shape from working knowledge. Returns the
+created user object including its `Id` (a GUID), which the next calls need.
+
+Then, as needed:
+
+| Purpose | Endpoint | Notes |
+|---|---|---|
+| Library access, admin rights, parental rating | `POST /Users/{id}/Policy` | Full policy object; **send the whole thing**, it replaces rather than merges. This is where `IsAdministrator` and `EnabledFolders` live. |
+| Set/replace password | `POST /Users/{id}/Password` | Needed if you created the user without one. |
+| Delete | `DELETE /Users/{id}` | Hard delete — no soft-delete/grace period, unlike Immich. |
+
+**Verify (rule 4)** with a real login rather than trusting the create:
+
+```http
+POST /Users/AuthenticateByName
+Content-Type: application/json
+
+{ "Username": "Full Name", "Pw": "" }
+```
+→ returns an `AccessToken` and the `User` object on success. Note the field is `Pw`, not
+`Password`, and Jellyfin authenticates by **username**, not email — Jellyfin has no concept of an
+email login.
+
+### A public endpoint worth knowing
+
+`GET /Users/Public` needs **no authentication at all** (verified: `200`). It lists users flagged
+as visible on the login screen. On this deployment it returns `[]` on all four instances, so
+nothing is currently exposed — but if you provision users, be aware that leaving them
+login-screen-visible publishes their names to anyone who can reach the port.
+
+### Probe recipe (Jellyfin)
+
+Same spirit as the Immich one, but you need a key first, and there is no soft-delete — so probe on
+a **non-production instance** or be prepared to hard-delete:
+
+1. `GET /Users` with the key → `200` and an array. Wrong key → `401`.
+2. `POST /Users/New` with a throwaway name → expect the created object; keep the `Id`.
+3. Repeat the identical call → observe the duplicate-name behaviour and **record the exact status
+ and body here**, since it is the guard rule 2 depends on.
+4. `POST /Users/AuthenticateByName` with the throwaway credentials → expect an `AccessToken`.
+5. `DELETE /Users/{id}` → then `GET /Users` to confirm it is gone.
+
+---
+
+## Re-verifying after an upstream upgrade
+
+Cheap and non-destructive — create a probe user on an unroutable domain, exercise it, delete it:
+
+1. `POST /api/admin/users` with `probe-delete-me@example.invalid` → expect `201`, keep the `id`.
+2. Repeat the identical call → expect the duplicate-email `400`.
+3. `POST` with `password`/`name` omitted → expect the validation `400`.
+4. `POST` with a bogus key → expect `401`.
+5. `DELETE /api/admin/users/{id}` with `{"force":true}` → expect `200`, then `GET` to confirm it's gone.
+
+Use `.invalid` (RFC 2606 — guaranteed never to resolve) so a stray notification email can never
+reach a real person.