# ScaiLabs Corporate Site documentation — full text > Every documentation page on ScaiLabs Corporate Site, concatenated for context-stuffing into an LLM. Companion to `https://www.scailabs.ai/docs/llms.txt` which is the index. Each page is preceded by an `## Heading` with its canonical URL. ## ScaiCMS (`scaicms`) _Headless, AI-first content management — the system you're using now._ ### Concepts _Source: https://www.scailabs.ai/docs/scaicms/concepts (https://www.scailabs.ai/docs/scaicms/concepts.md for raw)_ # Concepts Pick the page that matches what you're trying to understand. | Concept | What it covers | |---|---| | [Architecture](/docs/scaicms/concepts/architecture) | The three services and how they share state | | [Sites & multi-tenancy](/docs/scaicms/concepts/sites-and-multi-tenancy) | One deployment, many sites | | [Content types & fields](/docs/scaicms/concepts/content-types-and-fields) | How you model your data | | [Content tree](/docs/scaicms/concepts/content-tree) | Hierarchy, paths, navigation | | [Translations](/docs/scaicms/concepts/translations) | Multilingual content | | [Template packs](/docs/scaicms/concepts/template-packs) | Jinja2 themes | | [Assets](/docs/scaicms/concepts/assets) | File uploads + S3-compatible storage | | [RBAC](/docs/scaicms/concepts/rbac) | Users, groups, roles, permissions | | [API keys](/docs/scaicms/concepts/api-keys) | Programmatic access | | [Search](/docs/scaicms/concepts/search) | Hybrid BM25 + semantic | | [MCP](/docs/scaicms/concepts/mcp) | AI agent integration | | [Docs subsystem](/docs/scaicms/concepts/docs-subsystem) | First-class documentation, the one rendering this page | --- ### API keys _Source: https://www.scailabs.ai/docs/scaicms/concepts/api-keys (https://www.scailabs.ai/docs/scaicms/concepts/api-keys.md for raw)_ # API keys API keys are the agent-facing equivalent of a user session. Issue one, hand it to your agent or service, and the agent gains exactly the permissions of whatever the key is bound to — narrowed by optional **scope qualifiers**. ## What a key is bound to A key is bound to **either** a user (acts as them) **or** a group (acts as the group, no user attribution). Exactly one; never both. Both routes funnel through the same RBAC engine — there is no parallel permission system for keys. ## Scope qualifiers A key's `scopes` array can narrow what the key is allowed to do without changing the underlying role: | Scope | Permits | |---|---| | (no scopes) | Everything the bound principal has | | `docs:read` | Maps to `docs.read` permission | | `docs:write:scaigrid` | Write only within the `scaigrid` namespace | | `docs:write:scaigrid/v2/**` | Write only within version v2 (or a path prefix) | | `docs:*` | All `docs.*` perms | | `*` | Wildcard — same as no scopes | Scopes only *narrow*; they cannot grant a permission the principal doesn't already hold. This is enforced both for permissions globally (the `docs:write` part) and per-resource (the `:scaigrid/...` part — checked at write-time). ## Auth header Same `Authorization` header as user JWTs; the backend disambiguates by the `scai_` prefix. ```bash curl -H "Authorization: Bearer scai_live_NbZRFjU6…" \ "$API/api/v1/docs/_namespaces" ``` ## Lifecycle 1. **Issue** in admin → `/api-keys` → New API key. 2. The plaintext is shown **once** in a one-time modal. Copy it. 3. The key is identified afterwards by its prefix (`scai_live_NbZRFjU6`). 4. **Revoke** by clicking *Revoke* — the key becomes `is_active=false` and subsequent requests get `401 Invalid API key`. ## Audit trail `api_key_logs` records every authenticated request: endpoint, method, status, IP, user agent. Group-bound keys log against their `api_key_id`, so even without user attribution you can answer "who/what touched this". --- ### Assets _Source: https://www.scailabs.ai/docs/scaicms/concepts/assets (https://www.scailabs.ai/docs/scaicms/concepts/assets.md for raw)_ # Assets Asset uploads land in S3-compatible storage (MinIO in dev, AWS S3 or any compatible service in production). Each upload gets: - A canonical `storage_key`. - Variants for images (`thumbnail`, `small`, `medium`, `large`) generated by a background task. - An API-proxied URL: `/api/v1/assets/{asset_id}/file`. The proxy URL is what you embed in content — no direct S3 access from clients, no signed-URL renewal headaches, and revoking access is one flag flip. ## Asset metadata Each asset carries: - `mime_type`, `size_bytes` - `original_filename` - Optional `alt_text` (translatable) - Optional `tags` - A `folder` for organisation (just a virtual path) ## Variants For images, the backend kicks off a sharp-style resize per `asset_image_variants` setting. Defaults: ```python asset_image_variants = { "thumbnail": {"width": 150, "height": 150, "fit": "cover"}, "small": {"width": 320, "height": 320, "fit": "inside"}, "medium": {"width": 800, "height": 800, "fit": "inside"}, "large": {"width": 1600, "height": 1600, "fit": "inside"}, } ``` Variants are stored as separate S3 objects and served through the same `/file` proxy with `?variant=medium`. ## Allowed types Configurable via `asset_allowed_mime_types`. Defaults include JPEG/PNG/GIF/ WebP/SVG, PDF/Word/Excel, MP4/WebM, MP3/WAV/OGG. Anything else is rejected at upload. --- ### Content types & fields _Source: https://www.scailabs.ai/docs/scaicms/concepts/content-types-and-fields (https://www.scailabs.ai/docs/scaicms/concepts/content-types-and-fields.md for raw)_ # Content types & fields Content modelling in ScaiCMS is two layers: 1. A **content type** is a named schema (e.g. `blog-post`, `case-study`). 2. Each content type has many **field definitions**: `body`, `hero_image`, `tags`, etc. A piece of content (a `Content` row) is then "an instance of a content type" with field values keyed by field definition slug. ## Field types The built-in field types cover most needs: | Type | Editor | Use for | |---|---|---| | `text` | textarea | Plain text | | `richtext` | WYSIWYG | Body copy with formatting | | `markdown` | Markdown editor with preview | Technical content | | `number` | numeric input | Counts, ratings | | `boolean` | checkbox | Toggles | | `date` / `datetime` | date pickers | Scheduling, timestamps | | `select` | dropdown (single/multi) | Enums | | `asset` | `AssetPicker` | Upload-and-pick | | `blocks` | `BlocksEditor` | Structured page sections | | `json` | mono textarea | Free-form structured data | | `slug`, `email`, `url` | typed inputs | Validation | | `relation` | (UI WIP) | Reference another content item | See the [field-types reference](/docs/scaicms/reference/field-types) for the exact validation rules and admin UI per type. ## Content blocks (`blocks` field) The `blocks` field stores an ordered array of typed sections, each with its own data schema, e.g.: ```json [ {"type": "text_image", "data": {"title": "...", "content": "...", "image": ""}}, {"type": "values_grid", "data": {"title": "...", "items": [...]}} ] ``` Block types are declared in the **template pack's manifest** — not in admin code. This is what lets you ship a completely new theme with new block types without touching the backend or admin. See [Template packs](/docs/scaicms/concepts/template-packs). ## When to use `markdown` vs `richtext` vs `blocks` - **`markdown`** — engineering-oriented content, agent-authored copy, docs pages. - **`richtext`** — marketing prose that needs WYSIWYG editing. - **`blocks`** — layout-aware content (landing pages, mixed media). Better than rich-text once you start nesting components. --- ### Docs subsystem (this!) _Source: https://www.scailabs.ai/docs/scaicms/concepts/docs-subsystem (https://www.scailabs.ai/docs/scaicms/concepts/docs-subsystem.md for raw)_ # Docs subsystem The page you're reading right now is served by the docs subsystem — a separate first-class module that lives alongside regular content but with its own model designed specifically for documentation. ## What makes it different | | Regular content | Docs subsystem | |---|---|---| | Storage | `contents` + field rows | `doc_pages` rows, body is raw markdown | | Site-scoped? | Yes | No — namespaces are global | | Versions | One | Many (full-copy per version) | | Hierarchy | Yes (parent_id) | Yes (parent_id) | | Address | Site + path | Namespace + version + path | | Markdown native | No | Yes — GFM rendered server-side | | Sub-products | Via taxonomy | Via `parent_namespace_id` | | Visibility | Per-site content perms | `public` / `authenticated` / `restricted` per namespace | | Public site mounts | Direct URL | `site_doc_mounts` (longest-prefix wins) | | AI surface | MCP `manage_content` | MCP `manage_documentation` + REST + Python SDK | ## Mental model - **Namespace** = one product (`scaigrid`, `scaibot`, `scaicms`). Global. Optional parent for sub-products (so `scaibot` can declare its parent as `scaigrid` without changing URLs). - **Version** = `v1`, `v2`, … inside a namespace. Pages are deep-copied on fork; versions are independent thereafter. Exactly one default. - **Page** = a markdown document at a slash-joined `path` inside a version (`models/training`). Every page is uniquely identified by `(namespace, version, path)`. URLs follow the same shape. Mounts choose a URL prefix per site. ## Mounting on a site ``` namespace `scaicms` mounted at `/docs/scaicms` on the ScaiLabs site └─ visibility=public namespace `scaicms-internal` mounted at `/docs/scaicms/internal` └─ visibility=restricted, read_role_slugs=[docs_internal] ``` Longest-prefix wins, so internal docs nest cleanly under the public ones without conflicts. ## Restricted docs flow When an anonymous user hits a restricted URL on the delivery service: 1. The middleware checks for a session cookie. 2. No cookie → 303 redirect to `/docs/login?next=`. 3. User signs in (same credentials as the admin UI — backend mints a JWT into an HttpOnly cookie). 4. The role check runs: user must hold one of the namespace's `read_role_slugs`. Authorised → page renders. Not authorised → 403. The cache key includes the auth scope so signed-in users don't see the anonymous cache, and vice versa. ## Searching, deep-linking Markdown is chunked by heading on index. Each chunk gets an embedding and indexes into Weaviate's `DocPage` collection. A search hit carries both the page path and the chunk anchor: ``` {namespace}/{path}#{anchor} ``` so a search agent can produce links that jump straight to the relevant section. ## Further reading - [Agent guide](/docs/scaicms/concepts/mcp) — feed it to an LLM for read/write capability. - [Reference → docs API](/docs/scaicms/reference/api-endpoints) — full REST surface for the docs system. --- ### MCP integration _Source: https://www.scailabs.ai/docs/scaicms/concepts/mcp (https://www.scailabs.ai/docs/scaicms/concepts/mcp.md for raw)_ # MCP integration ScaiCMS speaks [MCP](https://modelcontextprotocol.io) natively. Run the server in `stdio` mode (for desktop agents) or as HTTP, point your agent at it with an API key + site ID, and the agent gets typed tools for every significant operation: | Tool | Action | |---|---| | `create_content`, `edit_content`, `find_content` | Content CRUD | | `manage_content_permissions` | RBAC overrides on a single content item | | `manage_workflow` | Status transitions | | `manage_assets` | Asset upload, listing | | `manage_taxonomy` | Taxonomies and terms | | `generate_content` | LLM-assisted draft generation | | `configure_site` | Site settings | | `manage_users` | User CRUD | | `manage_search_index` | Reindex, consistency, cleanup | | `manage_template_packs` | Template pack lifecycle | | `manage_form_submissions` | Inbox | | `manage_documentation` | The docs subsystem (a single tool with action dispatch) | ## Connecting ```bash export SCAICMS_API_KEY=scai_live_… export SCAICMS_SITE_ID=30847722-… python -m scaicms.mcp.server # stdio ``` Auth is the same as REST — same API key, same scopes apply. ## Agent bundle A self-contained kit lives at `docs/agent-bundle/` (run `python -m scaicms.scripts.build_agent_bundle` to regenerate it from source). It contains the agent guide, an OpenAPI subset, the MCP tool JSON-schema, and concrete request/response examples. Drop the directory or its tarball into any agent that needs to author documentation. --- ### RBAC _Source: https://www.scailabs.ai/docs/scaicms/concepts/rbac (https://www.scailabs.ai/docs/scaicms/concepts/rbac.md for raw)_ # Role-based access control Permissions in ScaiCMS are slug-based (`content.create`, `docs.write`, `api_key.create`, …). They are granted **via roles**, and roles attach to users either directly or through group membership. ## The model ``` User --has--> UserRole --grants--> Role --has--> Permission(s) \ +--member-of--> Group --has--> GroupRole --grants--> Role ``` Effectively, a user's permissions are the union of all direct + group- inherited role permissions. ## Scopes A `UserRole` can be: - **Global** — applies everywhere. - **Site-scoped** — applies only in one site. - **Content-type-scoped** — applies only within one content type. - **Subtree-scoped** — applies under a content path. When a permission check runs, the system computes the user's permissions *in the current request's context* (site + optional content). This is implemented in `core/permissions.py:get_user_permissions`. ## Standard roles Seeded by `python -m scaicms.scripts.seed`: - `super_admin` — every permission, global. - `site_admin` — all site/content/asset perms scoped to one site. - `editor` — content create/read/update + asset upload. - `viewer` — content read only. You can build your own roles in the admin UI under **Roles**. ## Docs-specific permissions The documentation subsystem ships its own perms: - `docs.read` — read non-public namespaces (public ones don't require it). - `docs.write` — upsert/delete pages. - `docs.manage` — namespaces, versions, mounts, index admin. See also [API keys](/docs/scaicms/concepts/api-keys) for how scope qualifiers on a key narrow these even further. --- ### Search _Source: https://www.scailabs.ai/docs/scaicms/concepts/search (https://www.scailabs.ai/docs/scaicms/concepts/search.md for raw)_ # Search Both regular content and documentation are indexed into [Weaviate](https://weaviate.io) and searchable through a hybrid (BM25 + vector) query. ## What gets indexed Two collections: - **`Content`** — one row per content item × locale. Title, summary, body, searchable field values, taxonomy term IDs, status, visibility. - **`DocPage`** — one row per *chunk*, where docs are split on H1/H2/H3 headings. Each chunk carries page metadata (namespace, version, path), the heading text, an anchor slug for deep linking, and the chunk body. ## Indexing flow ``` Content/Doc write → event bus → ARQ task → Weaviate upsert ``` The pipeline is async — there's a 1–3 second lag between writing and searchability. CLIs `python -m scaicms.cli.index_management` (content) and `python -m scaicms.cli docs-index` (docs) let you reindex, check consistency, and reset the index. ## Hybrid alpha `SEARCH_HYBRID_ALPHA=0.7` by default — leans semantic but still considers keyword matches. Tune per environment. ## Embeddings When `embedding_provider` is configured the backend calls out for vector embeddings on each chunk; otherwise search falls back to pure BM25. Embeddings cost money — disable them in dev to save calls. ## Searching docs ```bash curl -X POST -H "Authorization: Bearer $TOKEN" -H "Content-Type: application/json" \ -d '{"query":"how do I shard models","namespace":"scaigrid","limit":10}' \ "$API/api/v1/docs/_search" ``` Hits include `anchor`, so you can deep-link to the matching chunk: `/docs/scaigrid/models/training#tensor-parallelism`. --- ### Sites & multi-tenancy _Source: https://www.scailabs.ai/docs/scaicms/concepts/sites-and-multi-tenancy (https://www.scailabs.ai/docs/scaicms/concepts/sites-and-multi-tenancy.md for raw)_ # Sites & multi-tenancy A **site** is the unit of tenancy. One ScaiCMS deployment can serve any number of sites — each with its own content tree, template pack, locales, RBAC scope, and email configuration. ## Properties of a site | Field | Meaning | |---|---| | `id`, `slug` | Identity | | `name` | Display name | | `domains[]` | One or more domains; one is `is_primary` | | `default_locale`, `supported_locales` | Multilingual config (see [Translations](/docs/scaicms/concepts/translations)) | | `template_pack_id` | Active theme; the delivery service uses this | | `settings` | Free-form JSON: emails, search index, public name, etc. | ## Site context Every request to the backend that touches content carries an `X-Site-ID` header. The middleware looks it up, attaches the site object to the request state, and downstream code filters its queries by that ID. Without the header, content endpoints return 400. ```bash curl -H "Authorization: Bearer $JWT" \ -H "X-Site-ID: 30847722-3fae-477a-b7ce-34e6fce390fe" \ http://localhost:8000/api/v1/content ``` Some surfaces are global and don't take a site header — users, groups, roles, the docs subsystem (which is namespace-scoped, not site-scoped). The error message tells you which one a given endpoint expects. ## Multi-tenancy on delivery Delivery resolves `Host:` to a site, so the URL alone is enough. Each site gets its own: - Active template pack and partial caching keys. - Navigation tree (rebuilt per-locale). - `robots.txt`, `sitemap.xml`, `/docs/llms.txt`, `/docs/llms-full.txt`. - Form-submission inbox. ## Cross-site sharing Content is **per-site**. The docs subsystem is **global** — a single namespace can be mounted on multiple sites (see [Docs subsystem](/docs/scaicms/concepts/docs-subsystem)). For everything else, duplicating content into multiple sites is the pattern. --- ### Template packs _Source: https://www.scailabs.ai/docs/scaicms/concepts/template-packs (https://www.scailabs.ai/docs/scaicms/concepts/template-packs.md for raw)_ # Template packs A **template pack** is a directory of Jinja2 templates, CSS, JS, and static assets bundled together and uploaded to ScaiCMS. Each site has one active pack; switching themes is one admin click. ## Pack structure ``` my-pack/ manifest.json templates/ base.html page.html blog-post.html partials/ header.html footer.html assets/ css/ js/ images/ ``` ## manifest.json The manifest names the templates and declares custom block types: ```json { "name": "Scailabs", "slug": "scailabs", "version": "1.0.0", "templates": [ {"slug": "page", "file": "templates/page.html", "type": "page", "content_types": ["page"]} ], "block_types": { "text_image": { "label": "Text + Image", "icon": "image", "fields": [ {"slug": "title", "type": "text"}, {"slug": "content", "type": "richtext"}, {"slug": "image", "type": "asset"} ] } } } ``` The admin's `BlocksEditor` reads `block_types` at runtime — no frontend code change needed when a pack adds a new section. ## Upload flow ```bash python -m scaicms.cli.template_packs verify ./my-pack --check-syntax python -m scaicms.cli.template_packs package ./my-pack -o /tmp/my-pack.zip curl -X POST "$API/api/v1/template-packs/upload?set_as_default=true" \ -H "Authorization: Bearer $TOKEN" \ -H "X-Site-ID: $SITE" \ -F "file=@/tmp/my-pack.zip" redis-cli PUBLISH scaicms:cache:invalidate "site:$SITE:*" ``` The backend uploads the pack to S3 and registers each template. Delivery fetches templates from S3 on demand and caches them. See [Tutorials → First template pack](/docs/scaicms/tutorials/first-template-pack) for an end-to-end walkthrough. --- ### ScaiCMS — AI-first Content Management _Source: https://www.scailabs.ai/docs/scaicms/overview (https://www.scailabs.ai/docs/scaicms/overview.md for raw)_ # ScaiCMS — AI-first Content Management ScaiCMS is a headless, multi-tenant content management system built so that both humans **and AI agents** are first-class authors. Content, assets, and documentation are addressable through a stable REST API, an MCP tool surface for agents, and a Python SDK. ## At a glance - **Headless** — JSON-first API; pick your own rendering stack (or use the bundled Starlette delivery service). - **Multi-tenant** — one deployment can serve any number of sites with independent template packs, content trees, locales, and permissions. - **AI-native** — every read/write surface (REST, MCP, SDK) is documented and designed for agent consumption. The docs subsystem ships an [agent guide bundle](https://scailabs.ai/docs/scaicms/concepts/docs-subsystem) and scoped API keys so agents can be granted exactly what they need. - **Markdown-first documentation** — the docs subsystem is a separate first-class module (versioned, hierarchical, hybrid-searchable) that lives alongside regular content. - **Powerful but boring stack** — FastAPI + SQLAlchemy + MariaDB + Redis + Weaviate + S3-compatible storage. No magical glue; every component is observable and replaceable. ## Three services ScaiCMS is split into three independent services that share the same database: | Service | Role | Port (dev) | |---|---|---| | **backend** | FastAPI API, auth, RBAC, MCP server | 8000 | | **admin** | SolidJS + Tailwind/daisyUI admin UI | 3000 | | **delivery** | Starlette read-only content/docs renderer | 8080 | Background tasks (indexing, webhook delivery, email) run in an **ARQ worker** process driven by Redis. ## Who it's for - **Brand & product teams** that want a fast, modern CMS without lock-in. - **Engineering teams** building AI-augmented workflows (codegen, summarisation, agent-authored documentation). - **Multi-product organisations** that need shared content infrastructure across multiple sites and brands. ## Next steps - [Quickstart](/docs/scaicms/quickstart) — get a dev environment running. - [Concepts](/docs/scaicms/concepts) — core ideas: sites, content types, template packs, RBAC, the docs subsystem. - [Tutorials](/docs/scaicms/tutorials) — end-to-end walkthroughs. - [Reference](/docs/scaicms/reference) — field types, endpoints, CLI. --- ### Reference _Source: https://www.scailabs.ai/docs/scaicms/reference (https://www.scailabs.ai/docs/scaicms/reference.md for raw)_ # Reference | Section | Use when | |---|---| | [Field types](/docs/scaicms/reference/field-types) | Choosing the right type for a field | | [API endpoints](/docs/scaicms/reference/api-endpoints) | Looking up a REST path | | [CLI commands](/docs/scaicms/reference/cli) | Operating the system from the shell | | [Configuration](/docs/scaicms/reference/configuration) | Tuning environment variables | | [Permissions](/docs/scaicms/reference/permissions) | Auditing what a role grants | --- ### API endpoints _Source: https://www.scailabs.ai/docs/scaicms/reference/api-endpoints (https://www.scailabs.ai/docs/scaicms/reference/api-endpoints.md for raw)_ # API endpoints For an interactive view, hit `http://localhost:8000/docs` on a running backend — that's the FastAPI-generated Swagger. ## Major route groups | Prefix | What lives there | |---|---| | `/api/v1/auth/*` | Login, refresh, logout, profile | | `/api/v1/users/*` | User CRUD | | `/api/v1/groups/*` | Groups + members | | `/api/v1/roles/*` | Roles + permission attachments | | `/api/v1/permissions/*` | Permission listing | | `/api/v1/sites/*` | Sites + domains + error pages | | `/api/v1/content-types/*` | Schema management | | `/api/v1/content/*` | Content CRUD, translations, revisions | | `/api/v1/taxonomies/*` | Taxonomies + terms + content links | | `/api/v1/assets/*` | Upload, list, file proxy | | `/api/v1/template-packs/*` | Pack upload, set default, replace | | `/api/v1/search/*` | Content search + index admin + queue stats | | `/api/v1/webhooks/*` | Outbound webhook management | | `/api/v1/form-submissions/*` | Inbox | | `/api/v1/docs/*` | The docs subsystem | | `/api/v1/api-keys/*` | API key issuance + revocation | | `/api/v1/ai/*` | LLM-assisted generation | ## Response conventions ``` Success: {field: value, ...} (direct model response) List: {"items": [...], "total": N} (paginated) Error: {"detail": "message"} or {"detail": {"code": "X", "message": "Y"}} ``` ## Authentication Two flavors over the same `Authorization: Bearer …` header: - **JWT** for user sessions (access token, 30 min; refresh, 7 d). - **API key** with the `scai_` prefix; see [API keys](/docs/scaicms/concepts/api-keys). ## Site context Content endpoints require `X-Site-ID:` matching a site you can access. Global endpoints (users, groups, the docs subsystem) ignore it. ## Pagination List endpoints accept `page` and `page_size` (default 50; max varies by endpoint). The response always includes `total` so you can drive a paging UI. --- ### CLI reference _Source: https://www.scailabs.ai/docs/scaicms/reference/cli (https://www.scailabs.ai/docs/scaicms/reference/cli.md for raw)_ # CLI reference All commands are run from `backend/` after `source .venv/bin/activate`. ## Database ```bash alembic upgrade head # apply migrations alembic revision --autogenerate -m "description" # create migration alembic downgrade -1 # rollback one alembic check # detect model drift ``` ## Seeding ```bash python -m scaicms.scripts.seed --email admin@example.com --password secret python scripts/seed_scailabs_docs.py # 6 default doc namespaces python scripts/seed_product_dev_teams.py # 15 dev teams + API keys python scripts/seed_scaicms_docs.py # populate this very page ``` ## Background worker ```bash scaicms-worker # preferred arq scaicms.tasks.worker.WorkerSettings # equivalent ``` ## Content search index ```bash python -m scaicms.cli.index_management sites # list sites python -m scaicms.cli.index_management reindex # reindex all content python -m scaicms.cli.index_management check # consistency check python -m scaicms.cli.index_management reset # destructive ``` ## Docs search index ```bash python -m scaicms.cli docs-index status python -m scaicms.cli docs-index ensure-schema python -m scaicms.cli docs-index reindex python -m scaicms.cli docs-index reindex --namespace scaicms python -m scaicms.cli docs-index reset ``` ## Template packs ```bash python -m scaicms.cli.template_packs verify ./my-pack --check-syntax python -m scaicms.cli.template_packs package ./my-pack -o /tmp/my-pack.zip ``` ## Agent bundle ```bash python -m scaicms.scripts.build_agent_bundle # regenerate bundle python -m scaicms.scripts.build_agent_bundle --tar # plus tarball ``` ## Tests ```bash pytest # all backend tests pytest tests/test_documentation_api.py -v # single file pytest -k "scope" # by name ``` --- ### Configuration _Source: https://www.scailabs.ai/docs/scaicms/reference/configuration (https://www.scailabs.ai/docs/scaicms/reference/configuration.md for raw)_ # Configuration All configuration is environment-driven through `pydantic-settings`. A `.env.example` ships in each service directory. ## Backend (`backend/.env`) | Var | Default | Purpose | |---|---|---| | `DATABASE_URL` | `mysql+aiomysql://…` | MariaDB DSN. | | `REDIS_URL` | `redis://localhost:6379/0` | Cache + ARQ. | | `SECRET_KEY` | `change-me-in-production` | JWT signing. Share with delivery. | | `ALGORITHM` | `HS256` | JWT alg. Share with delivery. | | `ACCESS_TOKEN_EXPIRE_MINUTES` | `30` | JWT access TTL. | | `REFRESH_TOKEN_EXPIRE_DAYS` | `7` | JWT refresh TTL. | | `CORS_ORIGINS` | `[localhost:3000,…]` | Browser CORS allow-list. | | `API_KEY_PREFIX` | `scai_` | Prefix of issued keys. | | `S3_ENDPOINT_URL` | _(empty)_ | MinIO or other S3-compat. | | `S3_ACCESS_KEY_ID`, `S3_SECRET_ACCESS_KEY` | — | S3 credentials. | | `S3_BUCKET_NAME` | `scaicms-assets` | Bucket. | | `WEAVIATE_URL` | `http://localhost:8080` | Vector DB. | | `WEAVIATE_API_KEY` | — | Auth for hosted Weaviate. | | `WEAVIATE_COLLECTION_NAME` | `Content` | Content index. | | `WEAVIATE_DOC_COLLECTION_NAME` | `DocPage` | Docs index. | | `SEARCH_HYBRID_ALPHA` | `0.7` | 0 = pure BM25; 1 = pure vector. | ## Delivery (`delivery/.env`) | Var | Default | Purpose | |---|---|---| | `DELIVERY_HOST`, `DELIVERY_PORT` | `0.0.0.0:8080` | Listen address. | | `DELIVERY_DATABASE_URL` | shared with backend | Read-only access. | | `DELIVERY_REDIS_URL` | shared with backend | Cache + invalidations. | | `SECRET_KEY`, `ALGORITHM` | shared with backend | Validates session cookies. | | `DELIVERY_DOCS_SESSION_COOKIE` | `scaicms_docs_session` | Cookie name. | | `DELIVERY_DOCS_SESSION_MAX_AGE` | `604800` | 7 days. | | `DELIVERY_S3_*` | shared with backend | Templates + media. | ## Admin (`admin/.env`) | Var | Default | Purpose | |---|---|---| | `VITE_API_URL` | proxied via vite | Backend base URL. | --- ### Field types reference _Source: https://www.scailabs.ai/docs/scaicms/reference/field-types (https://www.scailabs.ai/docs/scaicms/reference/field-types.md for raw)_ # Field types reference All field types are defined in `backend/src/scaicms/models/content_type.py`. | Slug | Editor | Notes | |---|---|---| | `text` | textarea | Single/multi-line plain text. | | `richtext` | RichTextEditor (WYSIWYG) | ` Source` toggle for raw HTML. | | `markdown` | MarkdownEditor with live preview | Use for technical content. | | `number` | `` | Integer/decimal — the value is stored as a number, not a string. | | `boolean` | checkbox | Stored as a boolean. | | `date` | `` | Date only. | | `datetime` | `` | Date + time, no TZ in editor (UTC over the wire). | | `select` | dropdown | `options` array in field config; multi-select with `multiple: true`. | | `asset` | AssetPicker | Stores a single asset UUID; previews thumbnail. | | `blocks` | BlocksEditor | Ordered JSON array; block types declared in the **template pack manifest**. | | `json` | mono textarea | Raw JSON with validation. | | `slug` | typed input | URL-safe; auto-generated from `title` if blank. | | `email` | `` | RFC validation. | | `url` | `` | http(s) only. | | `relation` | (input + picker WIP) | Reference another content item. | ## Field definition flags Beyond `field_type`, each field can carry: | Flag | Meaning | |---|---| | `is_required` | Empty values rejected by validation. | | `is_translatable` | The value can be overridden per-locale in `content_translations`. | | `is_searchable` | Indexed into Weaviate so search can hit this field. | | `validation` | Per-type config: `{"max_length": 200, "regex": "..."}`. | | `default_value` | Used when creating new content. | | `config` | Type-specific: `options` for `select`, `accept` for `asset`. | --- ### Permissions reference _Source: https://www.scailabs.ai/docs/scaicms/reference/permissions (https://www.scailabs.ai/docs/scaicms/reference/permissions.md for raw)_ # Permissions reference Permissions are slug-based and seeded by `scripts/seed.py`. Custom permissions can be added through the admin UI — they integrate the same way. | Slug | Resource | Action | |---|---|---| | `content.create` | content | create | | `content.read` | content | read | | `content.update` | content | update | | `content.delete` | content | delete | | `content.publish` | content | publish | | `content.unpublish` | content | unpublish | | `content.manage_permissions` | content | manage_permissions | | `content_type.create` | content_type | create | | `content_type.read` | content_type | read | | `content_type.update` | content_type | update | | `content_type.delete` | content_type | delete | | `asset.create` | asset | create | | `asset.read` | asset | read | | `asset.update` | asset | update | | `asset.delete` | asset | delete | | `taxonomy.create` | taxonomy | create | | `taxonomy.read` | taxonomy | read | | `taxonomy.update` | taxonomy | update | | `taxonomy.delete` | taxonomy | delete | | `user.create` | user | create | | `user.read` | user | read | | `user.update` | user | update | | `user.delete` | user | delete | | `user.manage_roles` | user | manage_roles | | `site.read` | site | read | | `site.update` | site | update | | `site.manage` | site | manage | | `api_key.create` | api_key | create | | `api_key.read` | api_key | read | | `api_key.revoke` | api_key | revoke | | `webhooks.manage` | webhooks | manage | | `webhooks.read` | webhooks | read | | `docs.read` | docs | read | | `docs.write` | docs | write | | `docs.manage` | docs | manage | --- ### Troubleshooting _Source: https://www.scailabs.ai/docs/scaicms/troubleshooting (https://www.scailabs.ai/docs/scaicms/troubleshooting.md for raw)_ # Troubleshooting | Topic | When you'd open this | |---|---| | [Cache invalidation](/docs/scaicms/troubleshooting/cache-invalidation) | "I changed content and the site still shows the old version" | | [Search index issues](/docs/scaicms/troubleshooting/search-index-issues) | "My doc isn't searchable" / wrong results | | [Auth & API keys](/docs/scaicms/troubleshooting/auth-and-api-keys) | 401/403 surprises | | [Template rendering](/docs/scaicms/troubleshooting/template-rendering) | "TemplateNotFound" / Jinja errors / blank pages | --- ### Auth & API keys _Source: https://www.scailabs.ai/docs/scaicms/troubleshooting/auth-and-api-keys (https://www.scailabs.ai/docs/scaicms/troubleshooting/auth-and-api-keys.md for raw)_ # Auth & API keys ## 401 `AUTHENTICATION_REQUIRED` No or invalid token. Re-login (JWT) or check that your key isn't revoked (API key — its `is_active` is false). ## 401 `INVALID_API_KEY` The key was revoked or doesn't exist. Re-issue in admin → `/api-keys`. ## 403 `PERMISSION_DENIED` The principal doesn't hold the required permission. Read the `details.required_permission` field and grant it via a role. ## 403 `SCOPE_DENIED` The API key's `scopes` array doesn't permit the action on this namespace/version/path. The message says exactly what it tried. Fix: re-issue the key with a wider scope (e.g. drop the `:namespace` qualifier) or grant it the specific qualifier you need. ## 403 on a restricted namespace I "should" be able to read The namespace has `visibility=restricted` and you don't hold one of the `read_role_slugs`. In admin: **Roles** → make sure your user (or one of their groups) has the right role. ## "Login works in admin but delivery says I'm anonymous" Delivery uses the **docs session cookie**, set when you POST to `/docs/login`. Admin uses a JWT in `localStorage`. They're separate. Sign in at `/docs/login?next=…` on the delivery domain. The cookie is HttpOnly and SameSite=Lax; check your browser's storage to confirm it landed. --- ### Cache invalidation _Source: https://www.scailabs.ai/docs/scaicms/troubleshooting/cache-invalidation (https://www.scailabs.ai/docs/scaicms/troubleshooting/cache-invalidation.md for raw)_ # Cache invalidation Delivery caches at three layers: in-memory LRU → Redis → DB. The Redis pub/sub channel `scaicms:cache:invalidate` carries invalidation messages from backend to delivery. ## "I changed content and the site is still stale" 1. **Check the worker is running.** Index writes are queued, not synchronous. `ps aux | grep scaicms-worker`. 2. **Check the channel.** In one terminal: `redis-cli PSUBSCRIBE 'scaicms:cache:invalidate'`. Then change the content. You should see a message. 3. **Manual flush.** As a hammer: ``` redis-cli FLUSHDB redis-cli PUBLISH scaicms:cache:invalidate "site::*" ``` ## "I uploaded a new template pack but pages still use the old one" Pack uploads invalidate the **pack** cache but not all content. Run the manual flush above or restart the delivery process. ## "I updated my JWT secret" JWTs from before the change are now invalid — delivery and backend both reject them. Sign out and sign back in. --- ### Search index issues _Source: https://www.scailabs.ai/docs/scaicms/troubleshooting/search-index-issues (https://www.scailabs.ai/docs/scaicms/troubleshooting/search-index-issues.md for raw)_ # Search index issues ## "I wrote a page and search doesn't find it" Index writes are async. Expect 1–3 seconds of lag, longer if the worker is backed up. 1. **Worker running?** `ps aux | grep scaicms-worker`. 2. **Queue size**: in the admin UI go to **Queue Monitor**. 3. **Force a reindex** of the namespace: ```bash python -m scaicms.cli docs-index reindex --namespace ``` ## "Search returns old text" The chunker re-chunks on every page write — old chunks are deleted before new ones are inserted. If you see old text, you're probably hitting a cache. Try `?_nocache=1` on the search URL to bypass. ## "I think the index drifted" ```bash python -m scaicms.cli.index_management check # content python -m scaicms.cli docs-index status # docs ``` If counts disagree with the DB, run a full reindex. As a last resort: ```bash python -m scaicms.cli docs-index reset # destructive! python -m scaicms.cli docs-index reindex ``` --- ### Template rendering _Source: https://www.scailabs.ai/docs/scaicms/troubleshooting/template-rendering (https://www.scailabs.ai/docs/scaicms/troubleshooting/template-rendering.md for raw)_ # Template rendering ## `TemplateNotFound: blog-post.html` Delivery loads templates from S3 via the active template pack. Check: 1. Is the pack uploaded and marked as default for the site? 2. Does the manifest reference the template's slug? 3. Is the file path under `templates/` and does it match the manifest's `file:` field? ## "Variable X is undefined" In dev: enable Jinja `StrictUndefined` to make this loud (see the renderer config). In prod: a `{{ var | default('') }}` filter sweeps over expected- nullable fields. ## "`{{ content.fields.items }}` shows ``" Python dicts have a method called `items()`. Use bracket notation: ```jinja {{ content.fields["items"] }} ``` This bites everyone once. ## "Blocks render as nothing" Each block's `type` must match a key under `block_types` in the pack's manifest. Unknown types are silently skipped. Add the block type to the manifest (with `fields:` declaring its data shape) and re-upload the pack. --- ### Tutorials _Source: https://www.scailabs.ai/docs/scaicms/tutorials (https://www.scailabs.ai/docs/scaicms/tutorials.md for raw)_ # Tutorials Pick the one that matches what you're building. | Tutorial | What you'll learn | |---|---| | [Your first content type](/docs/scaicms/tutorials/first-content-type) | Modelling a blog post end to end | | [First template pack](/docs/scaicms/tutorials/first-template-pack) | Build a complete theme, upload, swap | | [Connecting an agent](/docs/scaicms/tutorials/connecting-an-agent) | Issue an API key, point an LLM at MCP | | [Writing docs with an agent](/docs/scaicms/tutorials/writing-docs-with-an-agent) | Use the Python SDK to author docs from code | --- ### Connecting an agent _Source: https://www.scailabs.ai/docs/scaicms/tutorials/connecting-an-agent (https://www.scailabs.ai/docs/scaicms/tutorials/connecting-an-agent.md for raw)_ # Connecting an agent Goal: spin up a Claude / ChatGPT / custom agent that can read and write ScaiCMS content. ## 1. Decide what the agent should be able to do - **Read-only research bot** — `docs:read` is enough. - **Drafting bot** — needs `docs.write` on a chosen namespace, no manage. - **Full dev-team bot** — needs `docs.read`, `docs.write`, `docs.manage` on one or more namespaces. ## 2. Issue an API key In the admin UI: **API keys → + New**. - **Name**: descriptive (e.g. `claude-docs-bot`). - **Bind to**: a *Group* if multiple humans/agents share it, a *User* if it's for one human's automations. - **Scopes**: comma-separated. Examples: - `docs:read` — broad read. - `docs:write:scaigrid` — write only to scaigrid. - `docs:read, docs:write:scaicms, docs:manage:scaicms` — full control of one namespace. Copy the plaintext from the one-time modal **before closing it**. ## 3a. Connect via MCP ```bash export SCAICMS_API_KEY=scai_live_… export SCAICMS_SITE_ID=30847722-… python -m scaicms.mcp.server ``` Configure your agent to use the resulting `stdio` MCP server. It will see all `manage_*` tools, including `manage_documentation` for docs writes. ## 3b. Connect via REST + Python SDK ```python from scaicms_docs import DocsClient with DocsClient(base_url="https://yourdomain", api_key="scai_live_…") as c: page = c.pages.upsert( "scaicms", "v1", "concepts/something", title="Something", body_md="# Something\n\nDrafted by my agent.", frontmatter={"author": "claude-bot"}, ) ``` ## 4. Verify access matches the scope Try writing into a namespace the key shouldn't reach: ```bash curl -X PUT -H "Authorization: Bearer $KEY" \ -H "Content-Type: application/json" \ -d '{"title":"nope","body_md":"x"}' \ "$API/api/v1/docs/some-other-namespace/v1/intro" ``` You should get `403 SCOPE_DENIED`. Good — that means the scope is holding. ## What's next - Drop `docs/agent-bundle/` into the agent's system prompt for full read/write coverage in plain English. - Use [`scaicms-docs` CLI](/docs/scaicms/tutorials/writing-docs-with-an-agent) for one-off scripts. --- ### Your first content type _Source: https://www.scailabs.ai/docs/scaicms/tutorials/first-content-type (https://www.scailabs.ai/docs/scaicms/tutorials/first-content-type.md for raw)_ # Your first content type Goal: model a blog post and read it back through the API. ## 1. Define the type In the admin UI: **Content Types → + New**. | Field | Value | |---|---| | Name | Blog Post | | Slug | `blog-post` | | Is hierarchical | off | Then add field definitions: | Slug | Type | Required | Searchable | |---|---|---|---| | `title` | text | ✓ | ✓ | | `slug` | slug | ✓ | | | `excerpt` | text | | ✓ | | `body` | markdown | ✓ | ✓ | | `hero_image` | asset | | | | `published_at` | datetime | ✓ | | ## 2. Create your first post **Content → + New** and pick *Blog Post*. Fill in the form, hit Save. ## 3. Read it back ```bash curl -H "Authorization: Bearer $JWT" \ -H "X-Site-ID: $SITE" \ "http://localhost:8000/api/v1/content?content_type=blog-post" ``` Response: ```json { "items": [ { "id": "…", "slug": "hello-world", "title": "Hello, world", "fields": { "title": "Hello, world", "excerpt": "First post.", "body": "# Hello\n\nWelcome…", "hero_image": "asset-uuid", "published_at": "2026-05-16T10:00:00Z" }, "status": "published", "...": "..." } ], "total": 1 } ``` ## 4. Resolve assets The `hero_image` value is a bare asset UUID. The API proxy serves it at `/api/v1/assets//file?variant=medium`. The delivery service resolves these automatically when rendering. ## What's next - Wire it into a template pack ([next tutorial](/docs/scaicms/tutorials/first-template-pack)). - Build a list page that queries content by type. - Add taxonomies for tags/categories. --- ### First template pack _Source: https://www.scailabs.ai/docs/scaicms/tutorials/first-template-pack (https://www.scailabs.ai/docs/scaicms/tutorials/first-template-pack.md for raw)_ # First template pack Build a working template pack from scratch. ## 1. Lay out the directory ``` my-pack/ manifest.json templates/ base.html page.html blog-post.html blog-list.html home.html assets/ css/styles.css ``` ## 2. manifest.json ```json { "name": "My Pack", "slug": "my-pack", "version": "1.0.0", "templates": [ {"slug": "base", "file": "templates/base.html", "type": "layout"}, {"slug": "page", "file": "templates/page.html", "type": "page", "content_types": ["page"]}, {"slug": "blog-post", "file": "templates/blog-post.html", "type": "page", "content_types": ["blog-post"]}, {"slug": "blog-list", "file": "templates/blog-list.html", "type": "page"}, {"slug": "home", "file": "templates/home.html", "type": "page"} ] } ``` ## 3. base.html ```jinja {% block title %}{{ site.name }}{% endblock %}
{% block content %}{% endblock %}
``` ## 4. blog-post.html ```jinja {% extends "base.html" %} {% block title %}{{ content.fields.title }}{% endblock %} {% block content %}

{{ content.fields.title }}

{% if content.fields.hero_image %} {% endif %}
{{ content.fields.body | markdown }}
{% endblock %} ``` ## 5. Verify, package, upload ```bash python -m scaicms.cli.template_packs verify ./my-pack --check-syntax python -m scaicms.cli.template_packs package ./my-pack -o /tmp/my-pack.zip curl -X POST \ "http://localhost:8000/api/v1/template-packs/upload?set_as_default=true&replace_existing=true" \ -H "Authorization: Bearer $TOKEN" \ -H "X-Site-ID: $SITE" \ -F "file=@/tmp/my-pack.zip" redis-cli PUBLISH scaicms:cache:invalidate "site:$SITE:*" ``` ## 6. View ``` http://localhost:8080/blog/your-first-post ``` If you don't see your changes, check the delivery service logs — Jinja errors surface there. The most common gotcha: writing `content.fields.items` (Python's dict method!) instead of `content.fields["items"]` for bracket-notation access. ## What's next - Add a `blocks` field type and declare `block_types` in your manifest. - Build template-pack partials (`templates/partials/header.html`, etc.) for reuse with `{% include %}`. - Add a `manifest.json → settings` block to let site admins toggle behavior without code changes. --- ### Writing docs with an agent (Python SDK) _Source: https://www.scailabs.ai/docs/scaicms/tutorials/writing-docs-with-an-agent (https://www.scailabs.ai/docs/scaicms/tutorials/writing-docs-with-an-agent.md for raw)_ # Writing docs with an agent Walk-through: a Python script (or an agent that can write Python) drafting docs by talking to ScaiCMS through the official SDK. ## Install ```bash pip install -e sdks/python-docs # monorepo dev # or, once published: # pip install scaicms-docs ``` ## Connect ```python import os from scaicms_docs import DocsClient c = DocsClient( base_url=os.environ["SCAICMS_DOCS_URL"], api_key=os.environ["SCAICMS_DOCS_API_KEY"], ) ``` Set those env vars from the API key your admin issued (see [Connecting an agent](/docs/scaicms/tutorials/connecting-an-agent)). ## Discover the tree ```python for ns in c.namespaces.list(): print(ns.slug, ns.visibility) tree = c.pages.tree("scaicms", "v1") def show(nodes, depth=0): for n in nodes: print(" " * depth + n.path + " — " + n.title) show(n.children, depth + 1) show(tree) ``` ## Read a page (raw markdown) ```python md = c.pages.read_raw("scaicms", "v1", "concepts/architecture") # Now feed `md` to your LLM for transformation, summarisation, etc. ``` ## Write a page ```python saved = c.pages.upsert( "scaicms", "v1", "concepts/your-new-page", title="Your new page", body_md=open("draft.md").read(), frontmatter={"audience": "developers", "summary": "Drafted by my agent."}, comment="Drafted by my agent at 2026-05-16", ) print(saved.current_revision) ``` Each `upsert` is a revision. You can read the page back, mutate it, write it again — `current_revision` increments each time. ## Search ```python result = c.search.query("how do I shard models", namespace="scaigrid", limit=5) for hit in result.hits: print(hit.path, "→", hit.anchor, hit.snippet[:80]) ``` ## Error handling ```python from scaicms_docs import PermissionDenied, ValidationFailed try: c.pages.upsert("locked", "v1", "x", title="Y", body_md="...") except PermissionDenied as e: print("scope blocks this write:", e.message) except ValidationFailed as e: print("payload rejected:", e.message) ``` ## CLI For one-off shell automation: ```bash scaicms-docs read scaicms/v1/concepts/architecture echo "# New page" | scaicms-docs write scaicms/v1/concepts/new --title "New" scaicms-docs search "weaviate" ``` The agent bundle at `docs/agent-bundle/` packages this guide together with the OpenAPI spec and MCP tool schema, ready to feed to an LLM. --- ## ScaiControl (`scaicontrol`) _Unified control plane for managing ScaiLabs services, infrastructure, and tenants._ ### Changelog _Source: https://www.scailabs.ai/docs/scaicontrol/changelog (https://www.scailabs.ai/docs/scaicontrol/changelog.md for raw)_ # Changelog Notable changes by milestone. ScaiControl uses migration numbers (`001`–`022+`) as its versioning anchor; release tags follow the migration that bumped the schema. ## v1.x (current) ### Outbound webhooks (migration 022) Two-table durable event-outbox pattern. 14 lifecycle topics ship in v1.0: - `tenant.billing_linked.v1`, `tenant.billing_updated.v1` - `partner.billing_linked.v1`, `partner.billing_updated.v1` - `subscription.activated.v1`, `subscription.changed.v1`, `subscription.cancelled.v1`, `subscription.suspended.v1`, `subscription.resumed.v1`, `subscription.trial_ending.v1`, `subscription.payment_failed.v1` - `pack_subscription.activated.v1`, `pack_subscription.changed.v1` (reserved), `pack_subscription.cancelled.v1` HMAC-SHA256 signing, 1m/5m/30m/2h/12h/24h backoff schedule, `(subscription_id, idempotency_key)` uniqueness, admin UI at `/admin/webhook-subscriptions`. See [Concepts: webhooks](./concepts/webhooks). ### Email template system (migration 020) Operator-customisable email templates per `(name, document_type, language)` triple. GrapesJS designer reused from invoices; Jinja syntax encoded as HTML comments (``) so the designer doesn't choke on `{% %}` tags. Variables differ per document_type (invoice-sent vs trial-ending vs payment-failed). Validated by `scaicontrol admin templates validate`. ### Service packs (migration 014) Bundled subscriptions modeled as a parent `PackSubscription` linked to child `Subscription` rows. Pricing can be `fixed_price` (override the sum) or `percentage` (discount off the catalog sum). Lifecycle propagates parent → children atomically. Pack-level events fire instead of per-child events. See [Concepts: service packs](./concepts/service-packs). ### Identity sync (migration 013) `scaicontrol sync` pulls users + groups from ScaiKey, merging with local role assignments. Resolved the "super-admin downgrade after JWT refresh" issue: ScaiControl now re-fetches the canonical permission set from `/auth/me` on each token refresh. ### Template designer (migration 012) GrapesJS-based WYSIWYG editor for invoice + credit-note templates, including a live preview pane that renders the same WeasyPrint pipeline used for production PDFs. Templates support partner-specific overrides AND language fallbacks. Blogger Sans is embedded as base64 so WeasyPrint doesn't need to fetch fonts at render time. ### Accounting integrations (migration 011) Plugin architecture for outbound accounting sync. First implementation: Visma e-Accounting (Sweden/Norway/Finland). Hooks fire on invoice finalize → mirrored to the external accounting system. Failures don't roll back the invoice — they queue for retry. ### EU-compliant invoicing (migration 008) The biggest milestone. Adds: - `tenant_billing_profiles` — buyer EU address + VAT. - `partner_configuration` extensions — seller EU address + VAT + bank. - `invoice_templates` table with partner-specific and language-specific overrides. - `invoices.document_type` (`invoice` | `credit_note`) and `invoices.referenced_invoice_id`. - Buyer + seller + VAT-details snapshots frozen on finalize. - VAT determination (`services/billing/vat.py`): 4 rules covering domestic, intra-EU B2B reverse-charge per Art. 196 EU VAT Directive, intra-EU B2C, and non-EU export. - WeasyPrint-rendered PDFs in S3 keyed by invoice ID; `template_html_snapshot` for byte-stable reprints. - `CreditNoteSequence` for separate gap-free `SCAI-CN-YYYY-NNNNNN` numbering. - E-invoicing in migration 009 (UBL / XRechnung / CII / ZUGFeRD / Factur-X). - Per-line tax in migration 010 (replacing single invoice-level rate). See [Concepts: invoice lifecycle](./concepts/invoice-lifecycle), [Concepts: VAT & reverse charge](./concepts/vat-and-reverse-charge). ### Cost-based metering (migration 007) Cost-derived plans where the unit price is computed from a per-unit cost + margin instead of fixed. Subscriptions inherit a billable rate updated on plan refresh; usage records reference the rate at ingestion time. ### Service registry (migration 006) Parent-slug relationships for service hierarchy (e.g. `scaicontrol-billing` is a sub-component of `scaicontrol`). Health monitoring split from approval state — `health_status` is now derived continuously from heartbeats independent of `registration_status`. ### Users + groups (migrations 003–005) Identity reflection from ScaiKey: `users`, `groups`, and `user_groups` populated by `scaicontrol sync`. Role bundles defined locally on groups; effective permissions = union of (user.role scopes ∪ groups.role scopes). ### Marketplace (migration 002) Service catalog: `services`, `plans`, foundational subscription tables. ### Initial schema (migration 001) Tenancy hierarchy (partners, tenants, users), audit log, basic ORM-layer tenant filter via `do_orm_execute`. ## Forward-looking Topics deferred past v1.x — these features exist conceptually but have no producer wired up yet: - **Invoice events** — `invoice.issued`, `invoice.paid`, `invoice.overdue`. Will land when ScaiCRM needs them for AR automation. - **Dunning escalation events** — surfacing the past_due → suspended path to subscribers. - **Usage aggregate events** — daily and monthly usage roll-ups as topic streams. - **Catalog product events** — when a plan price changes, when a service is added/retired. - **Provisioning rejection events** — explicit signal when a DAG step's compensating rollback completes. - **Plan-change service tokens** — service-to-service tokens with a `scaicontrol:admin` scope for CRM-driven plan changes. ## Versioning policy - ScaiControl's external API is versioned at the URL (`/api/v1/`). Within v1, only additive changes ship — no field removals, no semantics shifts. - Outbound event topics are versioned at the topic name (`subscription.activated.v1`). Breaking shape changes ship a new major (`v2`); both versions coexist for at least one full quarter before `v1` retires. - Migrations are numbered sequentially and never re-numbered. Once a migration is in `main`, it's append-only — corrections ship as later migrations. --- ### Concepts _Source: https://www.scailabs.ai/docs/scaicontrol/concepts (https://www.scailabs.ai/docs/scaicontrol/concepts.md for raw)_ # Concepts Background and mental models for ScaiControl. Read these to understand WHY the system is shaped this way — the reference pages will then make sense as a description of the result rather than a list of facts. | Page | What it covers | |---|---| | [Architecture](./concepts/architecture) | Components, data flow, the position of ScaiControl in the ScaiLabs network | | [Multi-tenancy](./concepts/multi-tenancy) | Partner → Tenant → User hierarchy, ORM-layer auto-filtering, JWT-driven tenant context | | [Authentication & RBAC](./concepts/authentication-and-rbac) | ScaiKey JWTs, scopes, roles, `require_permission`, service-to-service tokens | | [Subscriptions](./concepts/subscriptions) | State machine, lifecycle endpoints, reaper + trial-monitor workers, pack subs | | [Service packs](./concepts/service-packs) | Bundled subscriptions, parent–child model, pack-pricing modes | | [Plans & pricing](./concepts/plans-and-pricing) | Plan schema, tier conventions, MRR normalisation, usage pricing | | [Billing profiles](./concepts/billing-profiles) | Buyer/seller data, snapshot at finalize, template resolution chain | | [Invoice lifecycle](./concepts/invoice-lifecycle) | State machine, numbering, finalize flow, credit notes, immutability | | [VAT & reverse charge](./concepts/vat-and-reverse-charge) | The 4 VAT rules, country rates, UNCL categories, legal notes | | [Templates](./concepts/templates) | `(name, document_type, language)` triple, Jinja-as-HTML encoding, WeasyPrint quirks | | [Webhooks](./concepts/webhooks) | 14 topics, envelope, dispatcher fan-out pattern, delivery semantics | When you're ready for action, jump to the [Reference](./reference) section. --- ### Architecture _Source: https://www.scailabs.ai/docs/scaicontrol/concepts/architecture (https://www.scailabs.ai/docs/scaicontrol/concepts/architecture.md for raw)_ # Architecture ScaiControl is the operator-grade control plane that ties the ScaiLabs service ecosystem together. It owns the billing and subscription relationships, maintains a registry of installed services, and exposes a single admin surface across them. This page describes the moving parts. ## Top-level components - **Backend** — FastAPI + SQLAlchemy 2.0 async + MariaDB Galera. Single Python service exposing `/api/v1/*` REST endpoints and an MCP server with 16 tools. - **Frontend** — SolidJS 1.9 + Tailwind 4 + DaisyUI 5. The customer-facing portal (catalog, billing, usage) and the admin UI both live in one bundle, gated by RBAC. - **Background worker** — `arq` over Redis. Owns provisioning loops, heartbeat monitoring, identity reconciliation, the event dispatcher, the subscription reaper, the trial-ending notifier. - **Storage** — MariaDB Galera for relational state, S3-compatible object store for invoice PDFs and uploaded assets, Redis for queues and short-lived state. ## Tenancy hierarchy ```mermaid flowchart TB P["Platform
operator"] PA["Partner
resells to its own customers, or is the operator itself"] T["Tenant
the billable entity, has users"] U["User
authenticates via ScaiKey, scoped by role"] P --> PA --> T --> U ``` Every API call below `/api/v1/*` (except a handful of public ones) carries a JWT whose claims pin a `partner_id` and a `tenant_id`. Queries on tenant-scoped tables are auto-filtered server-side via a SQLAlchemy `do_orm_execute` event hook (`db/tenant_filter.py`), so leaks across tenants aren't possible at the ORM layer. ## What ScaiControl owns - **Service registry** — every running service (ScaiKey, ScaiVault, ScaiFlow, …) registers here. Registration carries `app_id`, `base_url`, `health_check_url`, capabilities. Heartbeats keep the row marked `active`. - **Plans + service packs** — pricing units. Plans belong to a service; service packs bundle multiple `(service, plan)` pairs under one billable line. - **Subscriptions** — the active link between a tenant and a (service, plan). Lifecycle is a state machine: `pending → trialing → active → past_due/cancelling → cancelled/expired/suspended`. - **Billing profiles** — buyer-side identity for invoices (`tenant_billing_profiles`) and the seller-side for partners (`partner_configuration`). - **Invoices and credit notes** — EU-compliant, with VAT determination, finalisation immutability, multi-format e-invoicing (UBL, XRechnung, ZUGFeRD, Factur-X), and a GrapesJS-based template designer with language variants. - **Outbound events + webhook subscribers** — every billing or subscription transition emits a signed webhook; subscribers (CRM, accounting bridges, dashboards) are managed in the admin UI. - **Payment providers** — Stripe, Mollie, and Coinbase Commerce (crypto) via a plugin architecture. ## What ScaiControl does NOT own - Identity. ScaiKey owns users, groups, OIDC. ScaiControl consumes the JWT and reflects a denormalised copy of identity into its own `users` table (synced via `services/identity_sync.py`). - Secrets. ScaiVault owns API keys and webhook signing secrets. ScaiControl resolves them by vault path at use time. - Email transport. ScaiSend ships the outgoing mails; ScaiControl renders the template + envelope and hands off to ScaiSend. - The actual services being subscribed to — those are independent applications that integrate via the [registry](./registry) and event protocols. ## Data flow at a glance ```mermaid flowchart LR SK["ScaiKey"] -->|login| P["Portal"] P -->|REST| BE["ScaiControl backend"] BE --- DB[("MariaDB
state")] BE --- S3[("S3
PDFs, assets")] BE --- R[("Redis
queues")] BE --> W["arq worker"] W --> ED["event dispatcher"] W --> RP["subscription reaper"] W --> HM["heartbeat monitor"] W --> TN["trial-ending notifier"] W --> PR["provisioning loops"] W --> IR["identity reconciliation"] ED -->|HMAC POST| SUBS["external subscribers"] HM -->|GET /health| SVCS["registered services"] ``` ## Surfaces | Surface | Path / mechanism | |---|---| | Tenant portal | `https:///` — catalog, services, billing, invoices, usage | | Admin portal | `https:///admin/...` — billing, partners, tenants, subscriptions, packs, templates, webhook subscribers | | REST API | `https:///api/v1/...` — same endpoints both surfaces use | | MCP server | `manage_subscriptions` and 15 sibling tools for AI agents | | CLI | `scaicontrol admin ...` — operator commands, runs in-process against the DB | | Background jobs | `arq scaicontrol.workers.main.WorkerSettings` — cron + queue | ## Where to look next - [Multi-tenancy](./multi-tenancy) — how the partner/tenant/user model works in practice. - [Subscriptions](./subscriptions) — state machine + lifecycle endpoints. - [Authentication & RBAC](./authentication-and-rbac) — scopes, roles, and how the JWT becomes a permission set. --- ### Authentication & RBAC _Source: https://www.scailabs.ai/docs/scaicontrol/concepts/authentication-and-rbac (https://www.scailabs.ai/docs/scaicontrol/concepts/authentication-and-rbac.md for raw)_ # Authentication & RBAC ScaiControl trusts ScaiKey for identity. Every request to a protected endpoint carries a JWT issued by ScaiKey; ScaiControl validates the signature, extracts claims, merges in local roles, and dispatches the request with a `CurrentUser` in scope. ## The JWT Issued by ScaiKey at `/oauth/authorize` + `/oauth/token`. Standard OIDC ID token + access token. The access token's claims carry the bits ScaiControl needs: ```json { "sub": "usr_…", "iss": "https://scaikey.scailabs.ai", "exp": 1715432400, "tenant_id": "tnt_…", "partner_id": "prt_…", "roles": ["tenant_admin"], "permissions": ["billing:manage", "admin:billing", ...] } ``` ScaiControl validates against ScaiKey's JWKS endpoint (`/api/v1/platform/.well-known/jwks.json`) with `cache_ttl=3600`. The keys rotate periodically; the cache picks up new ones on next refresh. ## How it reaches the handler ```mermaid flowchart TB R["HTTP request"] GCU["get_current_user()
dependencies.py
• extract Bearer token
• validate JWT signature + iss/exp
• parse claims → CurrentUser"] RP["require_permission(\"scope:action\")
where applicable
• check scope in user.permissions
• super_admin bypasses ALL scope checks"] H["handler(user: CurrentUserDep, db: DbDep)"] R --> GCU --> RP --> H ``` `DbDep` is just the async session. `TenantDbDep` is the same session with `tenant_id` pushed into `session.info` so tenant-scoped queries get auto-filtered. ## Scopes The convention is `:`. Active scopes: | Scope | Grants | |---|---| | `admin:billing` | Invoice + template + pack + webhook admin | | `admin:tenants` | Tenant listing + billing-profile management | | `admin:partners` | Partner config edits | | `admin:subscriptions` | Subscription lifecycle | | `admin:users` | User management | | `admin:groups` | Group + role management | | `admin:provisioning` | Provisioning workflow control | | `admin:registry` | Service registry edits | | `admin:accounting` | Accounting integration management | | `admin:platform` | Platform-wide settings | | `admin:usage` | Usage / metering read | | `billing:manage` | Tenant-self billing-profile edits | | `billing:read` | Tenant-self invoice listing | | `subscriptions:read` | Tenant-self subscription listing | | `services:read` | Catalog browsing | | `registry:manage` | Service-registry write access (separate from `admin:registry`; for service-to-platform calls) | Scope strings are stable. New scopes can be added freely; renaming is a breaking change requiring sync with ScaiKey. ## Roles Roles bundle scopes server-side. The defaults: | Role | Scopes | |---|---| | `super_admin` | Bypasses every `require_permission()` check — full access | | `partner_admin` | Most `admin:*` scopes, but scoped to the partner's own tenants | | `tenant_admin` | `billing:manage`, `billing:read`, `subscriptions:read`, `services:read` | | (no role) | Tenant user — catalog + own services + own subscriptions | ScaiKey owns the role assignment. ScaiControl reflects role membership locally via `services/identity_sync.py` so it can merge "user has role X" with "X holds scopes Y, Z" to produce the actual permission set per request. ## Merged permissions The JWT carries a snapshot of permissions at issue time. ScaiKey's permissions may have changed since (e.g. operator added a role to the user). ScaiControl re-fetches the canonical permission list from `/auth/me` on each token refresh, so a user's effective access stays current without re-logging-in. This is the fix for the "super-admin downgrade after refresh" issue. ## Tenant filtering Every model that inherits from `TenantScopedModel` automatically gets a `WHERE tenant_id = ` predicate added to its SELECTs by the `do_orm_execute` event hook (`db/tenant_filter.py`). To bypass for admin operations, use `DbDep` (which doesn't push tenant_id into session.info) instead of `TenantDbDep`. The bypass is intentional: admins legitimately need to see other tenants. The risk is reduced by gating those endpoints on `require_permission("admin:*")`. ## Service-to-service (no human) Services that integrate with ScaiControl — registering themselves, reporting metering, etc. — authenticate with a **ScaiKey app token**, which is a JWT with `token_type="service"` and an `app_id` claim instead of `tenant_id`. These hit a different set of endpoints (`/api/v1/registry/*`, `/api/v1/metering/*`) gated by `require_permission("registry:manage")` etc. The CRM-driven plan-change flow (post-MVP) will use an app token with a `scaicontrol:admin` scope — not yet implemented; on the v1.x roadmap. ## API keys (for docs and integrations) Separate from JWTs, the ScaiCMS docs subsystem and a handful of operator integrations issue long-lived API keys (`scai_live_…`). Those are bearer tokens like JWTs but with a different validation path (they're checked against an `api_keys` table, not signed). ScaiControl uses one of these against the docs subsystem; outside of that, it doesn't issue or accept API keys today. ## Common patterns **A tenant admin viewing their own billing**: ``` GET /api/v1/billing/invoices Authorization: Bearer ``` Handler uses `TenantDbDep`, which auto-filters on `tenant_id=tnt_X`. No additional scope check needed. **An operator viewing all invoices**: ``` GET /api/v1/admin/billing/invoices Authorization: Bearer ``` Handler uses `DbDep`, no tenant filter; gates on `require_permission("admin:billing")`. **A service heartbeating**: ``` POST /api/v1/registry/heartbeat Authorization: Bearer ``` Handler checks `CurrentUser.token_type == "service"` and `app_id` against the registered service row. ## Bypass and audit `super_admin` is the only operational bypass. Every admin action that mutates state writes to `audit_log` (`resource_type`, `resource_id`, `action`, `user_id`, `details` JSON) — this is what the per-row "History" feature in the admin UI reads. The audit log is partitioned by month; partitions can be archived without dropping the table. --- ### Billing profiles _Source: https://www.scailabs.ai/docs/scaicontrol/concepts/billing-profiles (https://www.scailabs.ai/docs/scaicontrol/concepts/billing-profiles.md for raw)_ # Billing profiles Two distinct objects hold the billing-side identity of every invoice: - `tenant_billing_profiles` — the **buyer**. One row per tenant. Carries company name, address, EU VAT number, contact email, preferred language, and the names of the templates this tenant gets. - `partner_configuration` — the **seller**. One row per partner. Carries legal name, address, VAT, bank details (IBAN/BIC), and the default templates the partner's tenants inherit. At invoice finalisation time, both rows are *snapshotted* into the invoice itself (`buyer_snapshot` and `seller_snapshot` JSON columns) so the invoice stays immutable even if either profile is edited later. ## Tenant billing profile The full schema: ``` tenant_billing_profiles tenant_id (PK in spirit, unique), company_name, address_line1, address_line2, postal_code, city, state, country_code, vat_number, tax_exempt (bool), contact_email, phone, is_business (bool), peppol_id, peppol_scheme, -- for Peppol e-invoicing leitweg_id, -- for German XRechnung preferred_einvoice_format, -- ubl | xrechnung | cii | zugferd | facturx preferred_language, -- en | nl | de | fr | es | it invoice_template_name, -- which designer template (see Templates) email_template_name, -- which email template buyer_reference, -- customer-side PO/reference for invoices created_at, updated_at ``` A tenant can perform any commerce action (subscribe, get an invoice) *only* if this profile exists. Triggering its first creation emits the `tenant.billing_linked.v1` event; subsequent edits emit `tenant.billing_updated.v1` with `changed_fields[]`. Endpoints: - Self-service (the tenant edits its own): `GET/PUT /billing/billing-profile` — see [Billing (tenant)](../reference/api/billing). - Admin: `PUT /admin/tenants/{tenant_id}/billing-profile` — see [Admin — tenants](../reference/api/admin-tenants). ## Partner configuration (seller side) ``` partner_configuration partner_id, partner_name, billing_model, -- pass_through | wholesale | self_billed commission_rate, -- decimal % for reseller revenue split allowed_services, allowed_plans, branding, -- JSON: theme, logo URL, footer text, etc. default_plan_slug, provider_config, invoice_template_name, email_template_name, legal_name, -- "ScaiLabs B.V." seller_address_line1, seller_address_line2, seller_postal_code, seller_city, seller_country_code, seller_vat_number, seller_email, seller_phone, invoice_logo_url, invoice_footer_text, seller_peppol_id, seller_peppol_scheme, ... ``` A partner is considered **billable** once `legal_name` AND `seller_country_code` are both set. The first time those fields cross from null → set, `partner.billing_linked.v1` fires. Subsequent changes to any seller field emit `partner.billing_updated.v1` with `changed_fields[]`. Endpoint: `PATCH /admin/partners/{partner_id}` — see [Admin — partners](../reference/api/admin-partners). ## Snapshots at finalisation When an invoice is finalised: 1. The tenant's billing profile is copied into `invoices.buyer_snapshot` (JSON). 2. The partner's relevant seller fields are copied into `invoices.seller_snapshot`. 3. The VAT determination is recomputed and frozen into `invoices.vat_details`. After that point, edits to the tenant profile do NOT propagate. Generating a credit note from the same invoice will reuse the snapshots, ensuring downstream documents reference the original buyer/seller exactly as they were at the time. If the tenant moves countries, gets a new VAT number, etc., the *next* invoice picks up the new state, but the existing ones stay correct for audit purposes. ## Template assignment Both invoice templates and email templates resolve through a two-step chain at render time: 1. Try the **tenant's** assigned template (`tenant_billing_profiles.invoice_template_name` / `.email_template_name`). 2. Fall back to the **partner's** assigned template (`partner_configuration.invoice_template_name` / `.email_template_name`). 3. Fall back to the built-in filesystem default. Each step also tries the tenant's `preferred_language` first, falling back to the any-language template variant. See [Templates](./templates). ## E-invoicing identifiers Several optional fields on the buyer side cover European e-invoicing requirements: - **Peppol** — used in the Netherlands, Nordics, Belgium. `peppol_id` + `peppol_scheme` together identify the receiver in the Peppol network. - **Leitweg-ID** — required for German XRechnung-format invoices to public-sector buyers. - **`preferred_einvoice_format`** — `ubl`, `xrechnung`, `cii`, `zugferd`, `facturx`, or null for PDF-only. Drives the format of the XML attachment shipped alongside the PDF. The seller side mirrors these so the operator (or reseller) can act as an e-invoicing sender. ## Editing a billing profile mid-stream Profile edits are non-blocking — any in-flight subscription continues, and the next invoice picks up the new buyer info. The only special case is `country_code`, which changes VAT treatment (domestic ↔ intra-EU ↔ export). The next finalisation will recompute. If you need to *correct* a previously-issued invoice for a buyer-side error, issue a credit note and a fresh invoice — never edit a finalised one. --- ### Invoice lifecycle _Source: https://www.scailabs.ai/docs/scaicontrol/concepts/invoice-lifecycle (https://www.scailabs.ai/docs/scaicontrol/concepts/invoice-lifecycle.md for raw)_ # Invoice lifecycle Invoices in ScaiControl follow a strict state machine designed to satisfy EU invoicing rules: once finalised, an invoice is immutable. Corrections happen via credit notes, not edits. ## States ``` draft Editable. No legal force. Buyer/seller not snapshotted yet. finalized Locked. Snapshots applied. PDF generated, archived in S3. sent Emailed to the buyer via ScaiSend. past_due A scheduled payment failed; still owed. paid Customer paid in full. void Cancelled by the operator (rare; usually credit note instead). ``` Allowed transitions: ```mermaid stateDiagram-v2 [*] --> draft draft --> finalized draft --> void finalized --> sent finalized --> void sent --> past_due sent --> paid sent --> void past_due --> paid past_due --> void paid --> [*] void --> [*] ``` ## Numbering Invoice numbers follow a gap-free sequence per fiscal year: ``` SCAI-YYYY-NNNNNN ``` The next number comes from `invoice_sequence(fiscal_year, next_number)` under a `SELECT … FOR UPDATE` lock. This guarantees no gaps and no duplicates even under concurrent finalisations. Credit notes use a parallel sequence with their own prefix: ``` SCAI-CN-YYYY-NNNNNN ``` ## Lifecycle endpoints | Operation | Endpoint | What happens | |---|---|---| | Create draft | `POST /admin/invoices` | New row, `status=draft`, no number yet | | Edit draft | `PATCH /admin/invoices/{id}` | Mutates notes, dates, period, and the full line items list (replaces) | | Reorder lines | `PUT /admin/invoices/{id}/line-items/order` | Renumbers `order_index` 0..N | | Sort by date | `POST /admin/invoices/{id}/line-items/sort-by-date` | Parses leading `DD-MM-YYYY` from each description, sorts | | Finalize | `POST /admin/invoices/{id}/finalize` | Allocates number; snapshots buyer/seller; applies VAT; generates PDF; uploads to S3; `→ finalized` | | Regenerate PDF | `POST /admin/invoices/{id}/regenerate-pdf` | Re-renders the PDF in-place (e.g. after template edit). Keeps the original number/snapshots; only the PDF bytes change. | | Send by email | `POST /admin/invoices/{id}/send` | Renders the email template, attaches the PDF (and optional e-invoice XML), POSTs to ScaiSend, `→ sent` | | Credit note | `POST /admin/invoices/{id}/credit-note` | Creates a new invoice with `document_type=credit_note`, `referenced_invoice_id` set, line items negated | | Refund | `POST /admin/invoices/{id}/refund` | (When invoice paid) refund via the original payment provider | See [Admin — billing & invoices](../reference/api/admin-billing) for the full request/response shapes. ## Finalisation: what happens inside The `services/billing/lifecycle.py:finalize_invoice` flow: 1. **Validate** — must be in `draft`, must have at least one line item, buyer billing profile must exist. 2. **Snapshot buyer** — copies `tenant_billing_profiles` row into `invoices.buyer_snapshot`. 3. **Snapshot seller** — copies relevant fields from `partner_configuration` into `invoices.seller_snapshot`. 4. **Determine VAT** — calls `services/billing/vat.py:determine_vat` with seller country, buyer country, buyer VAT number, and `is_business`. Sets per-invoice `tax_rate`, `reverse_charge`, and per-line `tax_category` + `tax_cents`. Aggregates into `invoices.vat_details.entries[]`. 5. **Allocate number** — `services/billing/numbering.py:get_next_invoice_number` under row lock. 6. **Generate PDF** — `services/billing/template_renderer.py:render_pdf` with the resolved designer template (tenant → partner fallback, language-matched) plus Blogger Sans embedded as base64 data URLs. WeasyPrint with a custom URL fetcher that strips `width="100%" height="100%"` from inner SVGs. 7. **Upload to S3** — `s3:///uploads/invoices/.pdf`. Stores the s3:// URL on `invoices.pdf_url`. 8. **Set state** — `status = 'finalized'`, `finalized_at = now`. 9. **Emit event** — `subscription.changed.v1` doesn't fire here (invoice events are not in the launch catalog); future addition. After finalisation the invoice is legally an invoice and is immutable. Trying to PATCH it returns 400. ## Sending `POST /admin/invoices/{id}/send` does: 1. Resolve recipient (buyer snapshot's contact_email → tenant billing profile's contact_email). 2. Resolve buyer's preferred language from the snapshot or live profile. 3. Resolve the email template via the same chain as the PDF (tenant → partner → built-in). 4. Render subject + HTML + plain-text from the template against a context with the same shape as the PDF context (seller, buyer, document, line items, total, vat_breakdown). 5. Re-render the PDF using the *current* designer template (so template fixes since finalisation are picked up) and upload to S3, overwriting the previous PDF. This is intentional: the operator is responsible for getting the template right before sending; once sent, do not edit the template. 6. Attach the PDF (and optionally an e-invoice XML). 7. POST to ScaiSend. 8. `→ sent`, record `send_history` in `metadata`. ## Credit notes A credit note (`document_type='credit_note'`) is itself an invoice — same numbering scheme but separate sequence, same template pipeline (but uses the `credit_note_default.html` template or its designer override). Line items are negated copies of the original; references the original via `referenced_invoice_id`. Two flavours: - **Full credit** — every line of the original is negated. - **Partial credit** — selected items only, with explicit amounts. Once issued, a credit note is finalised and emitted immediately (no separate draft → finalize step for credit notes). The original invoice's `status` stays as-is (`paid`, `sent`, `past_due` — whatever it was); the credit note offsets it on the customer's ledger. ## VAT details `invoices.vat_details` is a JSON column: ```json { "entries": [ { "category": "S", // UNCL 5305: S/AE/G/E/Z/O/K "rate": "21.00", "label": "VAT 21.00%", // localised at render time "taxable_cents": 15000, "amount_cents": 3150, "legal_note": "Reverse charge - Art. 196 EU VAT Directive" // when applicable } ] } ``` The label and legal note are stored in English (canonical, for audit) but localised at render time via `services/billing/vat.py:localize_legal_note` / `localize_tax_label` to match the buyer's preferred_language. ## E-invoicing formats Beyond the human-readable PDF, ScaiControl can attach a structured XML alongside: | Format | Schema | Used by | |---|---|---| | `ubl` | UBL 2.1 invoice | Generic Peppol-compatible, OASIS | | `xrechnung` | UBL profile (CIUS-XR) | German public sector | | `cii` | UN/CEFACT Cross Industry Invoice | French Factur-X non-hybrid | | `zugferd` | Hybrid PDF/A-3 with CII embedded | German B2B | | `facturx` | Same hybrid as ZUGFeRD | French label for the same standard | The buyer's `preferred_einvoice_format` drives which one ships. If null, only a plain PDF is attached. ## Snapshots, immutability, and what "edit" really means What you can do to a finalised invoice: - **Re-render its PDF.** The template is server-rendered; the snapshot data is frozen, so re-rendering produces a visually-equivalent PDF with whatever template changes you've made since. Use this when the template was wrong but the underlying data is right. - **Issue a credit note**, then a fresh invoice if the math was wrong. - **Mark void** in operational cases (e.g. duplicate). Rare; prefer credit notes. What you cannot do: - Edit line items. - Change the buyer or seller snapshots. - Renumber. - Change `invoice_date` or `due_date`. - Mutate `vat_details`. This is a hard line — it's enforced at the service layer with a `BadRequestError`, and the admin UI hides edit controls for non-draft invoices. --- ### Multi-tenancy _Source: https://www.scailabs.ai/docs/scaicontrol/concepts/multi-tenancy (https://www.scailabs.ai/docs/scaicontrol/concepts/multi-tenancy.md for raw)_ # Multi-tenancy ScaiControl is a three-level hierarchy: **Platform → Partner → Tenant**. Every billable resource hangs off one of those levels. Understanding the model is the prerequisite to everything else here. ## The three levels | Level | Examples | Owns | |---|---|---| | Platform (operator) | ScaiLabs B.V. | Service registry, default plans, the codebase | | Partner | ScaiLabs B.V. (as itself) or any reseller | A set of tenants, optional own billing identity, branding | | Tenant | A company that subscribed | Subscriptions, billing profile, invoices, users | A user is always attached to a tenant. A tenant is always attached to a partner. The operator IS a partner — typically `partner_id` equal to the operator's own `partner_id` so its own tenants flow through the same plumbing. ## How identity flows ScaiControl does not authenticate users itself. ScaiKey issues JWTs containing `sub` (user ID), `tenant_id`, `partner_id`, `roles`, and `permissions`. Every request to `/api/v1/*` either: 1. Carries the JWT → request gets a `CurrentUser` with those claims, plus an enriched permission set merged from local roles via `/auth/me`. 2. Lacks a JWT → only public endpoints (`/auth/config`, `/catalog/services`) respond; everything else returns 401. The `tenant_id` from the JWT is automatically pushed into the SQLAlchemy session's `info` dict and any query touching a `TenantScopedModel` subclass is rewritten to add `WHERE tenant_id = `. This makes cross-tenant leaks at the query layer impossible — you'd have to bypass the ORM entirely to hit another tenant's rows. ## Tenant-scoped vs unscoped tables ```python class TenantScopedModel(Base): tenant_id: Mapped[str] = mapped_column(String(36), index=True) ``` Roughly: anything that belongs to ONE tenant inherits from `TenantScopedModel`. Examples: - `subscriptions` - `invoices` + `invoice_line_items` - `tenant_billing_profiles` - `payment_methods` - `notifications` - `usage_events` Unscoped (shared across tenants): the service registry, plans, service packs, invoice/email templates, webhook subscribers, audit log, the user/group/partner directories. ## How a request reaches the right tenant The dependency chain in `dependencies.py`: ```mermaid flowchart LR JWT["JWT"] --> GCU["get_current_user()"] GCU --> CU["CurrentUser
user_id, tenant_id, partner_id,
roles, permissions"] CU --> TDB["TenantDbDep
pushes tenant_id into session.info
/billing, /subscriptions, …"] CU --> DB["DbDep
unscoped session
/admin (cross-tenant)"] ``` So tenant-facing endpoints get tenant-isolated DB access automatically; admin endpoints opt in to cross-tenant visibility by using `DbDep` directly. ## Admin vs tenant in the UI The same SolidJS bundle serves both. RBAC decides what the user sees: - `super_admin` — full access. Sees the admin sidebar and bypasses tenant filtering. - `partner_admin` — manages tenants belonging to their partner. Sees a scoped admin surface. - `tenant_admin` — manages billing for their own tenant. Sees the customer portal plus a billing-profile editor. - No admin role — just the customer portal. In the routes, `require_permission("admin:billing")` etc. gates the admin endpoints. `super_admin` is a bypass; everything else is checked against the merged permission set. ## Partner-level vs tenant-level billing A tenant is the *typical* buyer of an invoice. The billing profile (`tenant_billing_profiles`) carries the buyer-side information: company name, EU VAT number, address, contact email, preferred language for invoices. In some setups a partner buys directly (partner-direct billing). The partner's `PartnerConfiguration` carries the seller fields (legal name, VAT, IBAN/BIC) and, in this case, also the buyer fields. The two patterns can coexist for the same partner — partner-direct for itself and tenant-level for its customers. The [billing profiles](./billing-profiles) concept page goes deeper. ## Cross-tenant operations A few operations legitimately span tenants: - The heartbeat monitor scans the service registry — registry-wide, no tenant scope. - The event dispatcher emits webhooks for all tenants from a single outbox table. - Admin "list all invoices" obviously needs to cross tenants. These all use `DbDep` (unscoped) and explicitly filter tenant_id only when an admin filter is supplied. ## Cardinality In a typical ScaiLabs deployment you see roughly: - 1 platform operator - 5–50 partners (most being "the operator itself" for direct-sold tenants; the rest being resellers) - 10–1000s of tenants per partner - 1–100s of users per tenant The model scales linearly in each dimension; the bottleneck is the heartbeat monitor's loop, which is sized for hundreds of registered services, not tens of thousands of tenants. --- ### Plans and pricing _Source: https://www.scailabs.ai/docs/scaicontrol/concepts/plans-and-pricing (https://www.scailabs.ai/docs/scaicontrol/concepts/plans-and-pricing.md for raw)_ # Plans and pricing A plan is the smallest billable unit: a price + a billing period + a set of quotas + a tier. Every active service has one or more plans; subscriptions reference plans by ID. ## Schema ``` plans service_id (FK service_registry), slug, -- unique within service (trial, starter, pro, enterprise) name, -- human label description, tier, -- trial | starter | pro | enterprise (or your own) billing_period, -- monthly | yearly | quarterly | one_time base_price_cents, -- in cents, currency below currency, -- ISO 4217 features, -- JSON {items: [string], unit: string} quotas, -- JSON service-specific limits is_active, is_public, trial_days, -- 0 means no auto-trial on signup cost_multiplier, -- partner-cost markup (rarely used) sort_order, metadata plan_usage_pricing plan_id, metric_slug, tier_start, tier_end, -- usage tier brackets unit_price_cents, unit_label, unit_divisor ``` ## Tier conventions The four canonical tier values map to standard product expectations: | Tier | Use | |---|---| | `trial` | 14-day free trial. `base_price_cents = 0`, `trial_days = 14`, low quotas. | | `starter` | Entry paid tier. Small quotas, basic features. | | `pro` | Mainstream paid tier. The "sweet spot" most customers land on. | | `enterprise` | Top tier. Often custom-quoted; the plan in the DB is a reference price. | Tier names are free-form strings — these are just the conventions ScaiLabs' own services use. ## Quotas `plans.quotas` is service-specific JSON. Examples: ```json // ScaiKey { "monthly_active_users": 10000 } // ScaiVault { "secrets": 25000 } // ScaiSend { "monthly_emails": 100000 } // ScaiFlow { "monthly_runs": 250000, "active_workflows": 50 } ``` A quota value of `0` (or absent) is the conventional "unlimited" sentinel for Enterprise tiers. Quotas are enforced by each service itself, not by ScaiControl. ScaiControl publishes them; the service polls (or webhooks back) for the current `plan_id` and reads its own quota. ## Pricing normalisation For events and reports, prices are normalised to monthly (MRR): | `billing_period` | MRR formula | |---|---| | `monthly` | `base_price_cents` | | `yearly` | `base_price_cents / 12` | | `quarterly` | `base_price_cents / 3` | | `weekly` | `base_price_cents * 4` | | `daily` | `base_price_cents * 30` | | `one_time` | `base_price_cents` (pass-through; consumer interprets) | `services/events/builders.py:_normalize_mrr()` is the canonical implementation; subscription event payloads (`subscription.activated.v1` etc.) carry the normalised value as `mrr_amount_cents`. ## Plan keys for stable referencing Plan IDs are UUIDs, which churn between re-seeds. For event payloads and cross-system references, ScaiControl emits a composite `plan_key` of the form: ``` . ``` Examples: `scaikey.starter`, `scaivault.pro`, `scaiwave.enterprise`. The `plan_key` is stable across re-seeds (assuming you keep slug conventions consistent), while `plan_id` is still emitted alongside for the database-level link. ## Usage-based pricing For metered services (API calls, storage GB, model invocations), `plan_usage_pricing` adds tiered overage: ```python PlanUsagePricing( plan_id="", metric_slug="api_calls", tier_start=0, # First N free under the plan's base price tier_end=100_000, # ...up to 100k unit_price_cents=1, unit_label="call", unit_divisor=1, ) PlanUsagePricing( plan_id="", metric_slug="api_calls", tier_start=100_000, tier_end=None, # Open-ended above 100k unit_price_cents=2, # 2¢ per call beyond ) ``` The monthly billing job (`services/billing/invoice.py:generate_monthly_invoices`) joins this with the metering data (`usage_events`, partitioned by month) to compute overage lines. ## Editing plans without orphaning subscriptions Best practice: - **Never delete a plan that has live subscriptions.** Make it `is_active = false` and `is_public = false` so it stops appearing in the catalog, but existing subscriptions keep working. - **Price changes only take effect on the next billing cycle.** Already-finalised invoices snapshot their VAT but not their plan price (the line item carries the exact `unit_price_cents`). So an active subscription whose plan price changed mid-month bills the new price on the next cycle. - **Trial-day changes** apply only to newly-created subscriptions. An in-flight `trialing` subscription keeps its original `trial_ends_at`. ## Admin surface - List plans for a service: `GET /catalog/services/{slug}/plans` (also returned in `GET /admin/registry/services` with `?expand=plans`). - Create/edit plans: `POST/PUT /admin/registry/services/{slug}/plans` (see [Admin — service registry](../reference/api/registry)). - The admin UI exposes plan management on each service's detail page. ## See also - [Service packs](./service-packs) for bundled `(service, plan)` combinations. - [Subscriptions](./subscriptions) for the lifecycle of "tenant on a plan". - [VAT and reverse charge](./vat-and-reverse-charge) for how prices flow into invoices. --- ### Service packs _Source: https://www.scailabs.ai/docs/scaicontrol/concepts/service-packs (https://www.scailabs.ai/docs/scaicontrol/concepts/service-packs.md for raw)_ # Service packs A service pack is a curated bundle: one billable line that includes several `(service, plan)` pairs. Useful for "everything you need to evaluate ScaiLabs" (a trial bundle), regional add-on combos, or seat-priced enterprise bundles. ## Data model ``` service_packs slug, name, description, icon_url, billing_period (monthly | yearly | quarterly | one_time), base_price_cents, currency, discount_type (fixed_price | percentage), discount_percentage (optional, for percentage), trial_days, features (JSON), is_active, is_public, sort_order service_pack_items pack_id, service_id, plan_id, override_price_cents (optional — null = inherit plan price), sort_order pack_subscriptions (per-tenant subscription rows for a pack) partner_id, tenant_id, pack_id, status, activated_at, trial_ends_at, cancelled_at, cancellation_reason ``` ## Pricing The pack's headline price is `service_packs.base_price_cents`. It is **not** the sum of constituent plan prices — packs typically apply a discount. The two `discount_type` values: - `fixed_price` — `base_price_cents` IS the customer's monthly bill. The constituent plans are documentation, not the source of truth. - `percentage` — multiply the sum of constituent (or override) prices by `(100 - discount_percentage) / 100`. Use this when the bundle math should be transparent. Per-item `override_price_cents` lets you tweak individual lines (e.g. "the ScaiVault plan is half-price in this bundle"). Setting it to null inherits the underlying plan's `base_price_cents`. ## Subscription model When a tenant subscribes to a pack: 1. A `pack_subscriptions` row is created. 2. One `subscriptions` row is created per pack item, with `pack_subscription_id` pointing back. 3. The child subscription rows share the pack's `status` and lifecycle. When the pack is cancelled, the cancel cascades to every child subscription with `pack_subscription_id = `. ## Why both rows? The duplication isn't free, but it serves two real needs: 1. **Quota enforcement.** Per-service quota checks (e.g. ScaiKey's "monthly active users") run against the `subscriptions` table joined to `plans`. If the only record of a tenant's access were the pack row, every quota check would have to JOIN through `pack_items` — slower and less indexable. 2. **Mixed subscriptions.** A tenant can have both pack subscriptions AND standalone subscriptions to services NOT in any pack. The single subscriptions table makes this work without branching every query. ## Events Pack lifecycle has its own event topics, separate from regular subscription events: | Topic | Trigger | |---|---| | `pack_subscription.activated.v1` | Pack subscribe succeeds | | `pack_subscription.changed.v1` | (Reserved for future — no live producer yet) | | `pack_subscription.cancelled.v1` | Pack cancellation, immediate or scheduled | Child subscription rows do NOT independently emit `subscription.activated.v1` / `cancelled.v1` — subscribers see one event per pack action, with `pack_includes[]` enumerating the constituent services. This avoids double-counting on the CRM side. ## Admin surface - **List packs**: `GET /admin/packs` - **Create pack**: `POST /admin/packs` with `items[]` - **Update pack**: `PUT /admin/packs/{id}` (replaces items wholesale) - **Deactivate pack**: `DELETE /admin/packs/{id}` (soft — `is_active=false`) - **Pack subscriptions across tenants**: `GET /admin/packs/subscriptions` The admin UI lives at `/admin/packs`. See [Admin — service packs](../reference/api/admin-packs) for the full request/response shapes. ## Trial bundles The most common pattern: a `trial-bundle` pack that includes the `trial` plan of every service. €0 / 14-day trial / public. Tenants subscribing to it land on every service simultaneously at zero cost; when the 14 days lapse, the reaper flips each child subscription to `cancelled` (unless they upgraded individual services in the meantime). --- ### Subscriptions _Source: https://www.scailabs.ai/docs/scaicontrol/concepts/subscriptions (https://www.scailabs.ai/docs/scaicontrol/concepts/subscriptions.md for raw)_ # Subscriptions A subscription is the active link between a tenant (the buyer) and a `(service, plan)` (what they're paying for). It carries the billing period, the trial date, the current state, and any pending cancellation. This page covers the state machine and the lifecycle. ## States ``` pending Newly created, not yet activated. Sub-second to seconds. trialing Active and consuming, but not yet paid. Has a trial_ends_at. active Live, billed at the current_period_end cadence. past_due A scheduled payment failed; still functioning. cancelling Admin clicked "cancel at period end"; live until current_period_end. suspended Admin-driven pause, or sustained past_due. Service should refuse work. cancelled Terminal. Cancelled_at is set. Not reversible. expired Terminal for time-bound subscriptions that ran their term. ``` ## Allowed transitions The state machine lives in `services/subscription.py:VALID_TRANSITIONS`: ```mermaid stateDiagram-v2 [*] --> pending pending --> trialing pending --> active pending --> cancelled trialing --> active trialing --> cancelled active --> past_due active --> cancelling active --> cancelled active --> expired past_due --> active past_due --> suspended past_due --> cancelled suspended --> active suspended --> cancelled cancelling --> cancelled cancelling --> active: un-cancel cancelled --> [*] expired --> [*] ``` For trigger labels and side effects, see [Reference: state machines — subscription](../reference/state-machines). Attempting an invalid transition raises `BadRequestError` from the service layer. ## Lifecycle endpoints | Operation | Endpoint | Effect | |---|---|---| | Create | `POST /admin/subscriptions` | `pending → active` or `trialing` (depending on plan trial days). Emits `subscription.activated.v1`. | | Change plan / status | `POST /admin/subscriptions/{id}/override` | Mutates `plan_id` and/or `status`. Emits `subscription.changed.v1` with `change_kind=plan_change` or `status_change`. | | Cancel at period end | `POST /admin/subscriptions/{id}/cancel` (default) | `→ cancelling`. The reaper flips to `cancelled` once `current_period_end ≤ now`. Emits `subscription.changed.v1` with `change_kind=scheduled_cancellation`. | | Cancel immediately | `POST /admin/subscriptions/{id}/cancel` with `immediate=true` | `→ cancelled`. Sets `cancelled_at`. Emits `subscription.cancelled.v1`. | | Resume | `POST /admin/subscriptions/{id}/resume` | `cancelling → active` (un-cancel) or `suspended → active`. Emits `subscription.changed.v1` (`scheduled_cancellation_undone`) or `subscription.resumed.v1`. | | Suspend | `POST /admin/subscriptions/{id}/suspend` | `active|trialing|past_due → suspended`. Emits `subscription.suspended.v1`. | | Bulk | `POST /admin/subscriptions/bulk` | Apply any of `cancel`, `cancel_immediate`, `suspend`, `resume` to a list of IDs. Best-effort; returns `succeeded[]` + `failed[{id, error}]`. | The full request/response shapes are in [Admin — subscriptions](../reference/api/admin-subscriptions). ## Background workers - **`subscription_reaper`** — hourly. Finds subscriptions in `cancelling` status whose `current_period_end <= now` and flips them to `cancelled`. Emits `subscription.cancelled.v1` for each. - **`trial_monitor`** — daily at 09:13 UTC. Finds `trialing` subscriptions whose `trial_ends_at` lands at 7, 3, or 1 days from today, and emits `subscription.trial_ending.v1` once per (sub, threshold). Dedup state lives in `subscription.metadata_["trial_ending_fired"]`. ## Pack subscriptions A subscription can also be a pack subscription — a single billable line that bundles multiple `(service, plan)` pairs. Pack subscriptions live in `pack_subscriptions` and *generate* child rows in `subscriptions` linked via `subscription.pack_subscription_id`. The pack row carries the headline price; child rows exist for state tracking only (they share the parent's state). External consumers should treat pack subscriptions as one entity — the events fire under `pack_subscription.*` topics, not on the child rows. See [Service packs](./service-packs). ## Audit trail Every transition is written to the `audit_log` table (`resource_type='subscription'`, `resource_id=`). The admin UI's per-row "History" button surfaces this via `GET /admin/subscriptions/{id}/history`. ## VAT and pricing snapshots A subscription is just a *link*. It does NOT freeze pricing. The actual invoice that gets generated at billing time computes VAT freshly via [`services/billing/vat.py`](./vat-and-reverse-charge) based on the buyer's country, VAT number, and the seller's location at finalization. So a plan price change between billing cycles takes effect on the next invoice — but anything already finalised is immutable. --- ### Templates _Source: https://www.scailabs.ai/docs/scaicontrol/concepts/templates (https://www.scailabs.ai/docs/scaicontrol/concepts/templates.md for raw)_ # Templates ScaiControl ships a visual template designer for invoices and emails. Templates are stored in the `invoice_templates` table, edited via a GrapesJS-based canvas in the admin UI, rendered with Jinja2 + WeasyPrint (PDFs) or Jinja2 alone (emails). ## What is a template A template is identified by the triple `(name, document_type, language)`: - **`name`** — operator-chosen, e.g. "ScaiLabs Standard Invoice". Same name spans multiple language variants. - **`document_type`** — one of: - `invoice` — PDF - `credit_note` — PDF (rendered the same way, but `credit_note_default.html` is the fallback) - `email_invoice` — HTML email - `email_credit_note` — HTML email - **`language`** — ISO 639-1 lowercase code (`en`, `nl`, `de`, `fr`, `es`, `it`) or NULL for the any-language fallback. A template carries: - `subject` — only for email types - `html_content` — the GrapesJS-saved body markup - `css_content` — the CSS authored in the canvas - `editor_data` — opaque GrapesJS project state (not used at render time but stored for round-trips) - `version` — incremented on every save ## Resolution chain When ScaiControl needs to render an invoice or email, it walks this chain (highest priority first): 1. Tenant's `invoice_template_name` (or `email_template_name`) + tenant's `preferred_language` 2. Tenant's template name + the any-language (NULL) variant 3. Partner's template name + tenant's preferred_language 4. Partner's template name + any-language variant 5. Built-in filesystem template (`services/billing/templates/invoice_default.html`, `credit_note_default.html`) The first hit wins. This means a tenant can override its partner, and a partner can override the platform default. ## Designer UX `/admin/billing/templates` lists every template, grouped by `(name, document_type)`. Inside each row, every language variant is listed; clicking opens the designer at `/admin/billing/templates/designer?name=…&type=…&lang=…`. The designer: - Drag-and-drop blocks for sections (header, invoice details, line items table, totals, bank info, footer). - Variable picker — every Jinja variable that's in scope at render time, browsable by group (Seller, Buyer, Document, Loops, Conditionals). - Live preview that renders the canvas with sample data through the same backend pipeline that finalisation uses. - Page-format picker (A4, Letter, A5) for PDFs. - "Send test email" button for email types. - Language switcher — load a different language variant; "Copy current to another language" for fast localisation. ## Jinja round-trip GrapesJS is HTML-aware, not Jinja-aware. Naively storing `{% if x %}…{% endif %}` in the canvas breaks the HTML5 parser (table foster-parenting moves block-level Jinja tags out of ``), and the designer's auto-classification of text components misbehaves when it sees raw Jinja tokens. To work around both issues, ScaiControl **encodes Jinja control syntax as HTML comments** before loading into the canvas and decodes on save: ``` {% if x %}foo{% endif %} → foo {# comment #} → ``` `{{ var }}` (expressions, no side effects) survive as-is. In the designer canvas: - HTML comments are invisible, so the user sees the wrapped content rendered statically. - The auto-applied `data-jinja-cond="if x"` decoration draws an amber dashed border around any element wrapped in `{% if %}` / `{% for %}` blocks, with a small label. This is canvas-only (stripped at save time). The frontend validation calls `POST /admin/billing/templates/designer/validate` on every change (debounced); the backend compiles the HTML through Jinja2 and returns errors (line + reason) so the admin sees broken syntax before saving. ## Page format and WeasyPrint quirks PDF templates are rendered with WeasyPrint via `services/billing/template_renderer.py:render_pdf`. Things to know: - **Blogger Sans is embedded as base64 data URLs** in the `@font-face` declarations. This avoids a runtime fetch and ensures consistent rendering offline. - **SVG images with `width="100%" height="100%"` get those attributes stripped** by a custom WeasyPrint `url_fetcher`. Without this, WeasyPrint scales the SVG to fill its container instead of honouring the author's CSS `width`. - **`@media (max-width: …)`-wrapped rules are not part of print rendering**. GrapesJS occasionally wraps style rules in media queries when the user resizes elements on the non-default device — those rules don't match in WeasyPrint's print media and silently disappear. The designer config now sets `widthMedia: ''` on every device so future edits stay unscoped, but already-saved templates may need a one-time SQL fix. ## Email rendering Email templates render via `services/billing/template_renderer.py:render_email_template`. Both subject and HTML are Jinja-rendered with the same context shape as invoices (`seller`, `buyer`, `document`, `total`, `line_items`, `vat_breakdown`, `notes`, `reverse_charge`). Plain-text fallback is auto-derived from the HTML by stripping tags + `