# Platform conventions for site-development agents

The rules an autonomous agent needs to operate the tenant site stack without
tripping over error codes, quotas, the draft/publish model, and scoped keys.
Everything here is verified against the code; file references inline.

---

## Error codes

Every user-facing error is a machine-readable UPPER_SNAKE code — as the GraphQL
`errors[].message` (bare code, exact-matched by the frontend) or the JSON
`error` field of an HTTP response. Never pattern-match English prose.

### Site management (`site*` fields, render service)

Sources: `internal/errcodes/errcodes.go:371-388`, mapping in
`internal/graphql/site_schema.go:233-282`.

| Code | Meaning | What to do |
|---|---|---|
| `RENDER_SERVICE_UNAVAILABLE` | Render service unreachable, returned 5xx, or transport error; also when a tenant has no public base for `siteStaticFile` | Retry with backoff; if persistent, the render service is down or `RENDER_SERVICE_URL` misconfigured — stop and report |
| `SITE_TEMPLATE_NOT_FOUND` | `siteTemplate(path:)` miss (render 404) | Check `siteTemplates` for the exact path (paths are relative, e.g. `blog/post.liquid`); try `draft: true` for drafts |
| `SITE_OBJECT_NOT_FOUND` | `siteStaticFile(path:)` miss | Check `siteStaticFiles`; statics are live-only |
| `SITE_QUERY_IN_USE` | `deleteSiteQuery` — routes still reference the name (render 409); a live DELETE also 409s when a FLOW references the name (a graphql node's `library_name`, the "flow_bound" sidecar) | Read the `routes` **extension**: the offending route paths. Remove the refs (or the routes) first, then delete. When the referencer is a flow the extension is EMPTY — fix the flow instead |
| `SITE_PUBLISH_VALIDATION_FAILED` | `publishSite` — post-promotion state invalid | Read the `details` **extension** (array of strings, one per violation: corrupt draft JSON; a route with no flow bindings). Template existence is NOT checked. Fix each, re-publish |
| `SITE_SUBDOMAIN_TAKEN` | `setSiteSubdomain` — subdomain already claimed | Pick another name; check with `siteSubdomainAvailable` first |
| `SUBDOMAIN_INVALID` / `SUBDOMAIN_RESERVED` | Bad or reserved subdomain (passes through verbatim) | Fix the name (kebab-case, not reserved) |
| `SITE_RATE_LIMITED` | Render management abuse cap hit (HTTP 429) | Read the `retry_after` **extension** (seconds, from the render limiter body). Wait, then retry — do not hammer |
| `RENDER_FEATURE_UNAVAILABLE` | Tenant's plan/add-ons lack `render_service` | Stop — every `site*` field is gated. The tenant needs the feature via plan or `site_hosting` add-on |
| `SITE_REQUEST_INVALID` | Uncoded 4xx from the render service (e.g. malformed route payload, bad `sitemapQuery` at route-write time) | The request body was rejected — re-check argument shapes against the reference |
| `SITE_QUERY_INVALID_SYNTAX` | `saveSiteQuery` — the query failed GraphQL syntax parsing; the message carries the parser location after the code | The most common cause is a missing opening brace: `pages(where: …) { … }` must be `{ pages(where: …) { … } }` (or `query($v: Type) { … }`) |
| `TEMPLATE_COMPILE_FAILED` | `saveSiteTemplate` — the Liquid source failed to compile; the parser diagnosis (line, when the engine provides one) rides in the error detail | Fix the named construct — most often an unclosed `{% if %}` / `{% for %}` / `{% case %}`. The broken template is NOT stored |
| `DOMAIN_INVALID` / `DOMAIN_RESERVED` / `DOMAIN_TAKEN` / `DOMAIN_LIMIT_REACHED` | Custom-domain add rejected | Fix the domain value; `DOMAIN_LIMIT_REACHED` is the per-tenant cap (`CUSTOM_DOMAINS_MAX_PER_TENANT`) |
| `DOMAIN_VERIFY_TXT_MISSING` | No TXT at `_dynapi-verify.<domain>` | Wait for DNS propagation; re-check the record |
| `DOMAIN_VERIFY_TXT_MISMATCH` | TXT exists but value ≠ verifyToken | The record value must be the token verbatim |
| `DOMAIN_VERIFY_POINTING_MISMATCH` | Domain doesn't resolve to the edge IP | A record `<domain>` → `siteDomains.edgeIp` |
| `DOMAIN_VERIFY_CAA_BLOCKED` | CAA records forbid Let's Encrypt | Fix/remove the CAA policy |
| `CUSTOM_DOMAINS_UNAVAILABLE` | Plan lacks `custom_domains` | Stop; needs business plan or higher |
| `FORM_FLOW_INVALID` / `_CYCLE` / `_SHAPE` / `_CONDITION_BAD` | Flow document rejected at `saveFlowDoc` (generic invalid / a cycle / entry+ids+edges shape / predicate shape) | Fix the flow per flows-guide.md §1/§4/§9; cycles and a second `session:true` cookie are structural |
| `FLOW_NO_RESPONSE` | A header-conditioned GET binding ran but did not answer (no respond_json/render/redirect) — HTTP 500 from the public renderer, not GraphQL | The chosen flow must end in a response node on that branch |
| `AUTHENTICATION_REQUIRED` | Anonymous mutation on a members-only route (flow-bound POST denied) — HTTP 403 JSON | Sign in first; member gating is by design |
| `REQUEST_ORIGIN_DENIED` | A flow tried to set a cookie on a cross-site Origin (login-CSRF guard) — HTTP 403 | Post from the same origin |
| `CAPTCHA_FAILED` | Turnstile is enabled and the POST carried no/invalid token — HTTP 403 | Include `cf-turnstile-response` |
| `UNSUPPORTED_MEDIA_TYPE` | Public POST with a Content-Type the form endpoint does not parse (not form/multipart/JSON/XML) — HTTP 415 | Send one of the supported body formats |
| `RATE_LIMIT_EXCEEDED` | Public rate limits (pages/flows/forms) — HTTP 429 + `Retry-After: 60` | Slow down; cache hits are free |
| `METHOD_NOT_ALLOWED` | Non-GET/HEAD on a public path with no binding — HTTP 405 | Add a flow binding for that method or use GET |
| `FIELD_PASSWORD_TOO_LONG` / `FIELD_INVALID_TYPE` / `FIELD_PASSWORD_HASH_FAILED` | Writing a bcrypt field (>72 bytes / not a string / hash failure) | Fix the value; interpolated `field` names the property |

Extensions recap (they ride on the GraphQL error, message stays the bare code):

- `SITE_PUBLISH_VALIDATION_FAILED` → `details: [String!]`
- `SITE_QUERY_IN_USE` → `routes: [String!]` (empty when the referencer is a flow, not a route)
- `SITE_RATE_LIMITED` → `retry_after: Float` (seconds)

### Quota limits (plan limits, CMS side)

Source: `internal/multiTenancy/limit_middleware.go:336-415`. These are plain
strings in the 429 JSON body (not `errcodes` constants):

| Code | Meaning | What to do |
|---|---|---|
| `LIMIT_API_WRITES` | Hourly API-write quota exhausted (API-key traffic only) | Read `retry_after` (seconds until the top of the hour, when the bucket resets) — wait it out or surface an upgrade path |
| `LIMIT_API_READS` | Hourly API-read quota exhausted | Same |

The 429 body also carries `limit` (`max_api_writes_per_hour` /
`max_api_reads_per_hour`). Browser JWT/cookie sessions are never metered —
metering applies to `X-API-Key` traffic only.

### Generic codes an agent hits often

| Code | Meaning | What to do |
|---|---|---|
| `AUTHENTICATION_REQUIRED` | No role in context — token missing/expired, or the request had no `tenant_id` | Re-authenticate (control-plane `/login`) or fix the API-key header |
| `ACCESS_DENIED_USER_PERMISSION` | Role or scoped-key capability missing for the field (e.g. `site:manage` for `site*` and form/flow mutations, `data:write` for submissions) | Use an owner/admin session or a key with the right capability scope |
| `INVALID_INPUT` | Argument shape wrong (e.g. missing `entry` on `saveSiteQuery`) | Fix the mutation payload |
| `NO_TENANT` | Authenticated request carries no tenant context | Site fields are multi-tenant only — log in through the control plane |
| `ADMIN_ROLE_REQUIRED` | `createEntity`/`updateEntity`/`deleteEntity` need admin or owner | Elevate the session |
| `ENTITY_ID_OR_SLUG_REQUIRED` | Singular field queried with neither `id` nor `slug` (the `slug` arg exists only when the entity has a unique `slug` property) | Pass `id` or `slug`; or use the plural + `where: {slug: {eq: …}}` |
| `PROPERTY_TYPE_INVALID` | A property `type` slug outside the canonical set (`markdown`, `media`, a bare entity slug, empty…) | Use the slugs from `api-reference.md` (`string`, `text`, `select`, `number`, `int`, `boolean`, `date`, `datetime`, `json`, `array`, `entity`); markdown content is `text`, media links are `entity` + `entity_ref:"media"` |
| `FIELD_INVALID_DATE` | A `date`/`datetime` value that is neither `YYYY-MM-DD` nor RFC3339 (message names the field, localized server-side) | Resend the value as `"2026-07-28"` or `"2026-07-28T10:00:00Z"` |
| `DATA_WRITE_CAPABILITY_REQUIRED` | Entity **data** write under a scoped key without `data:write` | Add `data:write` to the key's scopes |
| `SCOPES_NOT_ALLOWED` | Scoped-key minting attempted by a non-admin/owner, or by a caller already restricted to a scoped key | Only admin/owner may mint scoped keys. A scope outside the 7-capability vocabulary is a different failure: `INVALID_INPUT` |
| `REQUEST_FAILED` | Generic 500-class failure in forms/CP-backed fields | Retry once, then report |

---

## Quota model

Two independent limiters; know which one you hit.

**1. Plan quotas (content metering, CMS side).** API-key traffic against
`/cms/graphql` is metered per hour per tenant against the plan's
`max_api_reads_per_hour` / `max_api_writes_per_hour` (atomic upsert on
`api_counters`; negative limit = unlimited). Exceeding → HTTP 429 with
`LIMIT_API_READS`/`LIMIT_API_WRITES` + `retry_after` (seconds to the hour
rollover).

**Site-only requests are exempt.** If **every** root field of **every**
operation in the request document is a `site*` field (the allowlist is
`graphql.SiteFieldNames`, `internal/graphql/site_schema.go:22-55`), the CMS
skips metering entirely — site management never burns content quota
(`internal/multiTenancy/limit_middleware.go:119-161,352-361`).

**The mixed-request rule: one non-site root field meters the whole document.**
A batch `[siteRoutes, posts]` or a single mutation mixing `saveSiteRoute` with
`createPage` is NOT site-only — the entire request is classified and counted
(one read-or-write increment, classified by whether the document contains any
mutation root field). Introspection, unparseable bodies, and empty queries also
meter (conservative). Agent rule of thumb: keep site-management calls in
documents that contain nothing but `site*` root fields; batch content CRUD
separately.

Browser sessions (JWT bearer / `auth_token` cookie) are exempt from metering in
general — only `X-API-Key` traffic counts.

**2. Render management abuse caps (per tenant, env-tunable by ops — defaults
matter to you):** mutation-class management calls (`saveSiteRoutes`,
`saveSiteRoute`, `deleteSiteRoute`, template/static/query writes) are
capped at **120/min** (`RENDER_MGMT_MUTATIONS_PER_MIN`), `publishSite` /
`publishFlowDoc` at **6/min** (`RENDER_MGMT_PUBLISH_PER_MIN`), and
upload bodies at **25 MB**
(`RENDER_MGMT_MAX_UPLOAD_BYTES`) — `internal/render/config.go:87-89`,
`internal/render/server.go:204-205`. Exceeding → 429
`{"error":"SITE_RATE_LIMITED","retry_after":60}` surfaced with the
`retry_after` extension through GraphQL. Batch your writes; never loop
publish.

Public-form POSTs have their own limiter (per-tenant + per-IP, per-IP default
10/min) answering 429 `RATE_LIMIT_EXCEEDED`.

**Public page traffic: flow-response cache + miss-only budgets.** The
flow-response cache stores anonymous 200
answers of flows that ended in respond_json/render with no set_cookie and no
redirect, and ONLY when the route opts in with `cache.defaultTtl > 0` (unset
or 0 = never cached). A cache HIT costs one Redis GET and consumes NO
budget — repeat views, refetches and verification crawls of the same URLs
are effectively free (`X-Cache: hit|miss` marks THIS cache; present on GET
and HEAD — `curl -I` works for cache checks). The budgets price only MISSES
(flow run → GraphQL → Liquid render):

- per-tenant miss budget, default **300/min** (`RENDER_RATE_LIMIT_PER_MIN`,
  ops can push per-tenant overrides);
- per-IP sublimit over public page MISSES, default **120/min**
  (`RENDER_RATE_LIMIT_PER_IP_MIN`), checked BEFORE the tenant bucket — one
  client cannot drain the tenant budget (added after internet scanners did
  exactly that and the tenant's visitors inherited their 429s).

`/static/*`, robots.txt, favicon and sitemap.xml are served before the
limiter and never count. Unknown paths get the stock 404 before the budgets,
and the custom `/404` render skips the budget too. Only a storm of
never-repeating URLs (crawler-style `?page=1..99999`) earns a 429; pace
unique-URL loops if you must run them.

---

## Public site delivery: statics caching and browser gotchas

**Statics are browser-cached for 1 hour** (`Cache-Control: public, max-age=3600`;
fonts are immutable for a year). A hotfixed CSS/JS file under the same URL is
therefore invisible to returning visitors for up to an hour. Do NOT bump URLs
by hand — reference mutable statics with the site's generation counter:

```liquid
<link rel="stylesheet" href="/static/css/main.css?v={{ site.version }}">
```

`site.version`, `site.query` and `site.params` are injected into the
GET-flow template seed, and the
generation counter bumps on EVERY site mutation (template/static/route/query
write, publish) as well as on entity-data and entity-definition
changes (2s debounce → render invalidation webhook) — so the URL changes
exactly when the file could have changed.

**HTML `pattern` attributes compile with the regex `v` flag** in browsers.
Inside a character class `(` and `)` are FORBIDDEN even escaped (`\(` is a
syntax error too); `\-` is fine. A syntactically broken `pattern` silently
disables native validation AND makes `checkValidity()` THROW — wrap manual
validation loops in try/catch. In JS, `form.novalidate` sets an expando
property; the real one is `form.noValidate`. A phone pattern that survives
all of the above: `\+?[0-9][0-9 \-]{8,18}`.

---

## Async schema rebuild

Entity-definition mutations (`createEntity`, `updateEntity`, `deleteEntity`)
commit the definition row immediately, then trigger a GraphQL schema rebuild
**asynchronously**:

- Single-tenant: the change notifier fans the event to a goroutine that
  rebuilds the schema and swaps it atomically (serialized by a mutex) —
  `internal/graphql/rebuilder.go:31-58`.
- Multi-tenant: the tenant's cached schema is invalidated and the next request
  rebuilds it from the tenant DB (`NotifyEntityChangedMT`).

There is no synchronous signal of completion. The mutation response returning
does **not** mean `createBlogPost` exists yet. The admin SPA listens to the
`/cms/api/schema-events` SSE stream, but agents should simply **poll**: run an
introspection query (`__type(name: "Mutation") { fields { name } }` or
`__schema { queryType { fields { name } } }`) until the expected field appears,
typically within seconds, before issuing the typed data mutations.

Entity **data** mutations (`createPage`, `updateBlogPost`, …) do not rebuild
anything — they are effective immediately.

---

## Content lives in the CMS

DynapiCMS is a CMS, not a static site generator: the tenant's VALUE is that
their content is editable in the CMS without touching code. A site whose
texts are hardcoded in Liquid templates is indistinguishable from a static
site. This is a hard rule, not a style
preference (it became golden-rule #1 after dogfood #4 put an entire landing's
copy in templates).

**What must be a CMS entity (queried by flow graphql nodes, rendered via
flow render nodes):**

- Page headings and paragraphs — hero copy, about sections, FAQ answers,
  hours of operation, delivery/payment terms;
- Every list of meaningful items — services, prices, genres, features,
  catalog entries;
- Navigation menu items (the `navigation` entity: `name`, `url`,
  `position`, `visible`);
- SEO meta tags (`page-seo` entries, linked or queried per route);
- Anything the site owner might want to reword later without a developer.

**What may stay in the template (interface chrome only):** button captions
("Submit", "Back to catalog"), form field labels/placeholders, aria-labels,
formatting glue (units, `₽`, separators). Three words or fewer, never a
sentence. If you can read it aloud as a sentence, it belongs in the CMS.

**Two modeling styles (pick per page):**

1. **Typed content entities (recommended).** Create an entity per content
   shape — e.g. `about-info` (heading, body text, hours), `price-item`
   (name, price, note), `hero` (title, subtitle, cta label/url) — create the
   entries via `createAboutInfo(input: …)`, bind them with a queryRef, and
   loop/interpolate in Liquid. This is exactly what a catalog does for
   records; apply the same discipline to "static-looking" pages.
2. **`page` + `blocks`** for block-composed pages: `page` (title,
   slug, status, seo, polymorphic `blocks` array). You must create the block
   entity types first (any entity can serve as a block member); query blocks
   with `__typename` + inline fragments (polymorphic union).

**Self-check before publish** (add to your own checklist): open every
template and grep for sentences in the site's human language — anything
longer than the micro-label whitelist must come from a binding
(`{{ … }}` / `{% for %}`), and editing any visible text on the published site
must be possible with a single CMS mutation (`update<Entity>(…)`) — never a
template edit + republish.

## Property shapes: model by how the data is USED (entity[] for positioned items, text for prose)

Choosing the property shape is a modeling decision, not a typing detail.
The wrong shape leaks into every consumer — templates, JSON bodies,
filters — and cannot be fixed without a schema migration + data rework.

| The data is… | Model it as | Never as |
|---|---|---|
| A LIST OF ITEMS WITH POSITION/QUANTITY — order lines, gallery images, tracklist, FAQ items, recurring schedule slots | a child entity (one property per field: `product` → `entity_ref: "product"`, `qty` → `int`, …) + the parent's **`entity[]` property** holding the child ids **in order** (jsonb arrays keep element order) | numbered scalar fields (`pid_1`/`qty_1`, `pid_2`/`qty_2`, …) or a `json` blob per item |
| Rich PROSE — article body, description, policy, biography | one `text` property (markdown via `config.editor = "markdown"`); the only correct home for long-form formatting | chopped into `string_1…string_n`, or stuffed into `json` |
| ONE VALUE FROM A FIXED SET — status, category, difficulty | `select` with `options` (filterable, typed) | free `string` the template `if`-chains over |
| A single date / price / count | `date` / `number` / `int` | a `string` you parse in Liquid (`plus: 0` tricks) |
| A machine-written blob consumed wholesale (external webhook snapshot, opaque provider payload) | `json` — the LAST resort: not filterable, no per-field typing, invisible to `where` | the container for anything a human edits field-by-field |

**Why numbered fields are poison** (a real production casualty): a flow
modeled "order items" as `pid_1`/`fid_1` … `pid_3`/`fid_3` form fields.
Every downstream consumer then paid for it: the JSON body needed a
conditional fragment per slot (`{% if form.pid_2 != blank %}` — six
guards, each a place to ship broken JSON), a 4th item meant a schema
migration + flow rewrite, and empty slots made "missing vs empty" guards
the flow's central logic. The `entity[]` model needs NONE of that: the
parent's `items` array carries the child ids in order, items are created
first (`createOrderItem` per line), the parent gets `items: [<ids>]`,
and every consumer loops ONE array of REAL fields:

```liquid
[{% for i in order.items %}{{ i | json }}{% unless forloop.last %},{% endunless %}{% endfor %}]
```

Adding item #4 is a data operation, not a schema change; position is
just the array order; nothing is ever "slot 2 is empty".

**Rule of thumb:** if you catch yourself inventing a field name with a
NUMBER in it (`item_2`, `phone_extra_3`), stop — that is a child entity
with a relation. If a human writes paragraphs into it, it is `text`. If
a human picks from a list, it is `select`. `json` is for machines, not
for people.

## Draft/publish state machine

Each tenant's site lives in S3 under two namespaces:

```
LIVE (served on https://<sub>.<base> and custom domains)
  routes.json              ← route table (whole document)
  queries.json             ← query library (whole document)
  templates/main/<path>    ← Liquid templates
  static/<path>            ← static files

DRAFT (served on https://preview-<sub>.<base>)
  routes.draft.json
  queries.draft.json
  templates/_draft/<path>
  static/_draft/<path>
```

State transitions:

```
                   read (draft:false, the default)            write (draft:true, the default)
                   ┌───────────────────────────┐             ┌───────────────────────────────┐
                   │                           ▼             │                               ▼
  ┌──────────┐     │      ┌─────────────┐      │      ┌──────┴──────┐        ┌──────────────┐
  │ (none)   │─────┼─────▶│   DRAFT     │──────┼─────▶│  publishSite │──────▶│     LIVE     │
  └──────────┘     │      │ *.draft.json│      │      │ (validate +  │      │ routes.json  │
                   │      │ templates/  │      │      │  promote)    │      │ queries.json │
                   │      │   _draft/   │      │      └──────────────┘      │ templates/   │
                   │      └─────────────┘      │                             │   main/      │
                   └───────────────────────────┘                             └──────────────┘
                                   ▲                                                      │
                                   └──────────── publishSite ─────────────────────────────┘
                                     (validates + promotes drafts, regenerates sitemap)
```

Key rules:

- **Writes default to draft.** Every `save*` mutation takes `draft: Boolean`
  defaulting to `true`. Reads default to `draft: false` (live). To inspect what
  preview serves, read with `draft: true`.
- **Route settings go live on save.** The admin UI publishes a route change
  as the pair `saveSiteRoute(draft: true)` + `publishSiteRoute(path:)` in
  one motion — repeat the same pair when driving the raw API; a lone draft
  write is not live.
- **Flows stage; queries/templates/statics are draft-first.** Flow body
  writes are STAGED and `publishFlowDoc` promotes ONE flow —
  `publishSite` does NOT promote flow drafts. The query library and
  template/static files are draft-first (preview executes drafts; publish
  promotes them).
- **Publish checklist (read before AND after `publishSite`).** Publish
  copies ONLY what was SAVED as a draft — a locally edited file that never
  went through its `save*` mutation is invisible to publish. BEFORE: check
  the pending counts (draft routes/queries/templates vs live). AFTER: read
  `templatesCopied` / `routesPromoted` / `queriesPromoted` — a zero where you
  expected a copy means the draft was never saved (or live was newer), NOT
  that publish is broken. `publishSite` never promotes flow drafts — publish
  each pending flow with `publishFlowDoc` and verify `flow_has_draft`
  is clear.
- **Publish merges per item, by recency.** Every route and query carries a
  server-stamped `updatedAt` (you cannot set it). `publishSite` first
  **validates** the post-publish state (corrupt `routes.draft.json`/
  `queries.draft.json`; a merged route with no flow bindings).
  Failure → `SITE_PUBLISH_VALIDATION_FAILED` + `details` extension, nothing
  mutated.
  Success → each `templates/_draft/<p>` is copied to `templates/main/<p>`
  (additive — deleting a draft does NOT delete the live copy), routes and
  queries are **merged item-by-item into live**, sitemap regenerated, robots
  patched, caches invalidated, and the `*.draft.json` files are **deleted**
  (the next edit re-seeds the draft from fresh live).
- **Merge rules (why API hotfixes survive stale drafts).** For each
  route/query key: identical content → keep; only in draft → add; differs →
  the version with the NEWER `updatedAt` wins. A live write newer than the
  draft copy (e.g. you hotfixed with `draft: false` after a draft existed) is
  KEPT and its key is reported in `routesConflicts`/`queriesConflicts` of the
  publish result — the stale draft version is not silently promoted over it.
  Items deleted in the draft (the delete writes a tombstone) leave live —
  unless live was written after the deletion (conflict again). A
  live-only item with NO tombstone (added after the draft copy was taken) is
  NEVER deleted by publish.
- **Per-item publish.** `publishSiteRoute(path:)` / `publishSiteQuery(name:)`
  publish ONE item (or its draft deletion) straight to live — explicit
  intent that FORCES the draft version even where the merge above would keep
  a newer live item. The route variant runs the same publish-time validation
  as `publishSite` (corrupt draft JSON, a route
  with no flow bindings); the query-deletion variant rejects with
  `SITE_QUERY_IN_USE` while any live route still references the name.
- **Effective documents:** the published site uses `routes.json`/
  `queries.json`; preview uses the draft files when present, falling back to
  live — and that per-file draft-first model covers templates
  AND static assets too: a template or static file with a staged copy renders
  it on preview, one without falls back to its published body.
- **A fallback page means a runtime render failure, never a size limit.**
  There is NO template byte-size cap (templates of 100 KB render fine).
  Templates are compile-validated at save (`TEMPLATE_COMPILE_FAILED`), so a
  SAVED template always parses — a fallback page comes from a flow render
  node failing at RUNTIME. The visitor gets the fallback page and the error
  is logged to the render-service log (there is no response header): debug
  via the render-service logs and the flow's action journal (the
  `form_action_runs` storage table). Don't
  bisect by file size and don't shrink templates to work around it.
- **Statics are draft-first too** (same as templates):
  `saveSiteStaticText` / `uploadSiteStatic` default to `draft: true` (the
  `static/_draft/` namespace, served on preview), `publishSiteStatic(path)`
  promotes one file, `deleteSiteStatic(path, draft: true)` discards a draft.
  Domain and subdomain ops remain live immediately.
- **A fresh tenant starts empty.** Everything is authored via the `save*`
  mutations.
- `publishSite` response (`SitePublishResult`) reports `templatesCopied`,
  `routesPromoted`, `queriesPromoted`, `sitemapUrls`, `publishedAt`, plus
  `routesConflicts`/`queriesConflicts` — live keys kept because a live write
  is newer than the draft copy (see merge rules above); to force your draft
  version onto such an item, publish it explicitly with
  `publishSiteRoute`/`publishSiteQuery`. `sitePublishStatus` recalls the
  last result (`routesPromoted: false` means there was no draft to promote —
  the live table was already in effect).

---

## Flow response cache (TTL answers)

A flow answering an anonymous request (respond_json or render, status 200,
no set_cookie, no redirect) is cached for the route's `cache.defaultTtl`:

- Key folds in the site generation, the route path, the handle, path params
  (`p:`), query params (`q:`, capped at 256 runes, empty dropped), and the
  header value for header-conditioned bindings (`h:`). `submitted` and
  `utm_*` share one entry by design.
- Signed-in members NEVER get cached answers; cookie-planting, redirecting,
  and non-200 answers are never cached.
- Hits are FREE — the lookup runs before the rate limits. `X-Cache: hit`
  marks a hit, `X-Cache: miss` a freshly computed answer.
- `defaultTtl: 0` = live mode (every request executes the flow; the flow
  response cache is off too).
- ANY site mutation bumps the generation and orphans every cached answer.
  TTL lives on the ROUTE, not on the binding.

## Whole-file semantics: read-modify-write

`saveSiteRoutes(routes: [...]!)` **replaces the entire routes document** (it
builds a fresh `{version: 1, routes: [...]}` and PUTs it). Same for the query
library at the document level — `saveSiteQuery(entry:)` upserts a **single
entry by `name`** (safe); publish replaces whole files. Consequences:

- To add/modify one route with `saveSiteRoutes`, you MUST send the complete
  list: `siteRoutes(draft: true)` → mutate in memory → `saveSiteRoutes(routes:
  <full list>)`. Omitting routes deletes them.
- Prefer `saveSiteRoute(route:)` for single-route changes: it upserts by path
  (pass `originalPath` to rename) inside the existing document, and the first
  draft write seeds `routes.draft.json` from live `routes.json` as a complete
  working copy.
- Two concurrent editors (or an agent + a human in the admin UI) can lose
  writes to last-writer-wins at document granularity. Read fresh before every
  write; don't cache the route list across steps.

---

## Scoped API keys

User-bound API keys may carry **restriction scopes** drawn from the
7-capability vocabulary (`internal/auth/capabilities.go:17-23`,
`internal/auth/capability_gate.go:36-46`):

```
schema:manage    users:manage    settings:manage    plugins:manage
data:write       media:write     site:manage
```

Semantics (all verified in `internal/auth/capability_gate.go`):

- **Restriction-only.** `allowed = RoleHasCapability(role, cap) && (scopes
  absent || cap ∈ scopes)`. Scopes can only *shrink* access below the role —
  a viewer gains nothing from any scope; an owner scoped to `settings:manage`
  loses `data:write`.
- **Every capability gate consults scopes**: `site:manage` gates all `site*`
  fields AND flow/action mutations — one capability for the whole
  site add-on — plus the `render_service` feature check; `data:write` gates
  `landingSubmissions` reads and entity-data access.
- **Entity writes under a scoped key additionally require `data:write`**
  (`internal/auth/graphql_auth.go:73-75`): a key scoped to only
  `schema:manage` gets `DATA_WRITE_CAPABILITY_REQUIRED` on
  `createPage`/`updateBlogPost`/etc. Reads are exempt.
- **Minting** scoped keys is admin/owner-only — a non-admin, or a caller
  already restricted to a scoped key, gets `SCOPES_NOT_ALLOWED`; a scope
  outside the vocabulary gets `INVALID_INPUT`
  (`internal/server/multitenant.go:956-975`, same split in the GraphQL
  resolver at `internal/graphql/schema_builder.go:2394-2403`).
- For a site-development agent the practical key shape is
  `scopes: [site:manage, data:write, media:write]` —
  everything needed to run the site workflows (media uploads included),
  nothing for users/plugins/schema definitions (entity
  *definitions* need `ADMIN_ROLE_REQUIRED` anyway, which a scoped editor-role
  key cannot pass).

Site-only-request exemption reminder (see Quota model): a scoped key's
`site*`-only documents never consume the hourly quota; any document mixing in
content fields does.

## Cyrillic content and Windows-side tooling

The API is UTF-8 end to end: store, query, and render Russian text freely in
any `string`/`text` value, entity names, content, and templates. Every
recurring Cyrillic failure is client-side. Three rules prevent them all.

### Slugs are ASCII — non-ASCII runes are silently deleted, not transliterated

`CanonicalSlug` (applied to entity slugs on create/update) keeps only
`[a-z0-9-]`. `"Блог"` canonicalizes to `""` → the create fails with
`SLUG_REQUIRED`; `"Статьи о CMS"` silently becomes `cms`. Always pass a Latin
kebab-case slug yourself (`blog`, `about`) even when the entity name is
Russian — Russian belongs in `name`/`title`/content, never in slugs. Keep
page/item `slug` VALUES Latin too: they become URLs, sitemap entries, and
`:param` matches, where percent-encoded Cyrillic invites subtle mismatches.

### Localized fields: one `{locale: value}` map in ONE mutation

A localized field accepts a bare scalar (written to the default locale) or a
`{locale: value}` map. The map replaces the stored map wholesale — two
mutations writing `ru` then `en` each wipe the other locale. Send all locales
you care about together:

```graphql
updatePage(id: "…", input: { title: {ru: "О нас", en: "About us"} })
```

Locale keys are validated against the tenant's supported locales (default
`en`, `ru` — configurable via SystemSettings); anything else is rejected with
`unsupported locale`. Reads: `<field>` resolves to one locale — pass
`locale: "ru"` on the collection/singular QUERY (`works(locale: "ru")`,
`work(id: …, locale: "ru")` — it is an argument of the query field, NOT of
individual scalar subfields; without it you get the tenant's default locale)
— and the read-only `<field>_locales` sibling returns the whole
`{locale: value}` map as a JSON string. Check the tenant default: if it is
`en`, every library query that must render Russian passes `locale: "ru"`
explicitly.

### Send non-ASCII request bodies from files, never inline shell strings

Windows shells corrupt inline UTF-8: Git Bash mangles Cyrillic inside
`curl -d '{…}'` argument strings, and Windows PowerShell re-encodes through its
console codepage. Write the JSON document to a UTF-8 file (no BOM) with your
file tooling, then send it wholesale:

```
curl --data-binary @body.json -H "Content-Type: application/json" …
```

Once the bytes are right in the file, the request is right — the server always
parses JSON bodies as UTF-8. Diagnostic: if a round-tripped value comes back
as `?????` or `Ð¡Ñ‚Ð°Ñ‚ÑŒÐ¸`, the corruption happened in your shell, not in the
API.

## Visual QA — alignment mechanics (user directive)

Shipped rounds repeatedly had "strange" visuals: cards not aligned with each
other, gaps that differ by a few px between siblings, one-off radii/paddings.
Vision review alone does NOT catch these — it passes vague impressions. The
checks below are MECHANICAL (Playwright/DOM) and belong in every
self-critique circle, on EVERY page, at BOTH viewports:

1. **Token discipline.** Collect computed styles of all elements sharing a
   role (`.tile`, `.card`, `.btn`, section paddings). gap / border-radius /
   padding / box-shadow / font-size must form a SMALL set matching the
   brief's tokens. A one-off 14px gap among 12px gaps is a defect — find it
   by listing `getComputedStyle` values, not by eyeballing.
2. **Grid integrity.** In a uniform grid, every row's items share the same
   `offsetTop` and (when the design says equal cards) the same height; left
   edges of same-column items align ACROSS rows. In a bento/mosaic,
   intentional size variation is fine — but gaps must still be ONE value and
   column edges must snap to the same lines.
3. **Sibling consistency.** Same-class siblings must not drift: same radius,
   same shadow, same internal padding, heading baselines on one line when
   the tiles themselves align.
4. **Edge alignment across sections.** Every section's content starts at the
   same container padding line; a section indented 4px off its neighbors is
   a defect.
5. **Overflow (existing rule).** `scrollWidth <= clientWidth` on body and
   every tile/card.
6. **No `padding` shorthand on combined classes.** `.sec { padding: 48px 0 }`
   on an element that also carries `.wrap { padding: 0 24px }` silently
   wipes the wrap's side insets (same specificity, later declaration wins)
   — content hugs the screen edges on mobile and sections desync from the
   header by 24px on desktop. Combined classes must set longhand only
   (`padding-top` / `padding-bottom`). When auditing, measure the side
   inset of EVERY element carrying a combined `.wrap*` class — checking
   just the first `.wrap` you meet hides the break (dogfood #11).

Run an audit snippet on each page (1280 + 360) that reports: distinct gap
values per grid, distinct radii per sibling class, per-row offsetTop/height
mismatches, overflows. Fix everything it lists before the vision circle —
the vision prompt should then be NARROW: "point at ANY tile whose gap, edge,
radius, or baseline differs from its siblings" (specific questions, not
"does it look good?").

"Cards should look aligned as a system — if it doesn't look INTENTIONALLY
uneven, it's a bug" (user, on dogfood round visuals).
