---
title: "ADR-0001: JSON Schema is the contract source; TypeScript and Rust are generated"
description: "Cloudhal ships in TypeScript first and moves the core to Rust later. Both must agree on Project/Site/Page/Section/ContentEntry/Snapshot/SkillManifest without hand-maintained duplicates."
url: https://cloudhal.dev/docs/adr/0001-contract-pipeline
source: https://github.com/cloudhal-dev/cloudhal/blob/main/docs/adr/0001-contract-pipeline.md
---
# ADR-0001: JSON Schema is the contract source; TypeScript and Rust are generated

- Status: Accepted
- Date: 2026-10-07
- Related: RFC-0001
- Sources: https://github.com/bcherny/json-schema-to-typescript · https://github.com/oxidecomputer/typify (issues #579, #828) · https://ajv.js.org/json-schema.html · https://docs.rs/jsonschema · https://zod.dev/json-schema · https://github.com/glideapps/quicktype

## Context
Cloudhal ships in TypeScript first and moves the core to Rust later. Both must agree on Project/Site/Page/Section/ContentEntry/Snapshot/SkillManifest without hand-maintained duplicates. Discriminated unions (Section kinds) and versioning (`schemaVersion`) must survive generation in both languages.

## Decision
Hand-written **JSON Schema 2020-12 (draft-07-compatible subset)** in `packages/core-contracts/schemas/` is the single source of truth. A bundler inlines shared `$defs` into self-contained per-root files (`schemas/.bundled/`). TypeScript is generated with **json-schema-to-typescript 16.0.0**, Rust with **cargo-typify 0.8.0**; both outputs are committed and CI regenerates and `git diff --exit-code`s them. Golden fixtures under `fixtures/{valid,invalid}` are validated by **ajv 8.20 (Ajv2020)** and the **jsonschema 0.58** crate, and valid fixtures must round-trip through the generated Rust types.

Authoring rules: unions are `oneOf` with inline variants and `kind: {type: string, enum: [one]}`; no `default` (serde would drop default-equal values and break round-trips); no `$dynamicRef`/`unevaluated*`/`dependentSchemas`; cross-file refs only to `common.schema.json#/$defs/*` or another root's top level; `additionalProperties: false` except on declared maps; every root carries `schemaVersion`.

## Alternatives considered
| Alternative | Pros | Cons | Why not |
|---|---|---|---|
| TypeScript-first (zod 4 / TypeBox) → JSON Schema → Rust | Runtime validation DSL for free | Rust becomes downstream of whatever shape `toJSONSchema` emits (`anyOf` vs `oneOf`), typify needs specific shapes | Second source of truth; weak Rust fidelity |
| Rust-first (schemars) → JSON Schema → TS | Best Rust types | Rust ships months after TS | Timeline makes it impossible |
| quicktype for both | One tool | Flattens discriminated unions into one struct in both targets (verified) | Loses the core modelling feature |

## Consequences
- Positive: one place to change; drift is mechanical to detect; both validators consume identical fixtures.
- Negative: generated code is committed (larger diffs); typify's subset constrains schema features; generator versions must be pinned exactly because output changes between minors.
- Risks: typify 0.x churn (mitigated by pin + drift job); schema evolution needs a migration story (RFC-0001 §versioning).


---
title: "ADR-0002: Thin SQL/blob driver interface, batch-only, two backends"
description: "The self-host promise is real only if the same code runs on Cloudflare (D1/R2/KV) and on a laptop (SQLite + filesystem)."
url: https://cloudhal.dev/docs/adr/0002-storage-interface
source: https://github.com/cloudhal-dev/cloudhal/blob/main/docs/adr/0002-storage-interface.md
---
# ADR-0002: Thin SQL/blob driver interface, batch-only, two backends

- Status: Accepted
- Date: 2026-10-07
- Related: RFC-0001
- Sources: https://developers.cloudflare.com/d1/platform/limits/ · https://developers.cloudflare.com/d1/worker-api/d1-database/ · https://orm.drizzle.team/docs/sqlite/batch-api · https://github.com/drizzle-team/drizzle-orm/issues/2463 · https://nodejs.org/docs/latest-v24.x/api/sqlite.html · https://developers.cloudflare.com/durable-objects/api/sqlite-storage-api/

## Context
The self-host promise is real only if the same code runs on Cloudflare (D1/R2/KV) and on a laptop (SQLite + filesystem). D1 has no interactive transactions, only atomic `batch()` (≤1000 statements, 100 bind params, 100 KB per statement). Node's built-in `node:sqlite` is still release-candidate.

## Decision
`packages/storage` exposes a ~50-line `SqlDriver` (`get`, `all`, `run`, atomic `batch`) and a `BlobStore` (`get/put/delete/list`). Adapters: `d1` and `better-sqlite3` now; `durable-sqlite` and `bun-sqlite` later; filesystem and R2 for blobs. **Interactive transactions are forbidden project-wide**; every multi-statement write is a `batch()`. Drizzle ORM provides schema + `drizzle-kit generate` SQL migrations shared by both backends; application code calls only `batch()`, never `transaction()`. Optimistic locking is a single `UPDATE … SET rev = rev + 1 WHERE id = ? AND rev = ?` with `changes === 1` as success. The same test suite runs against both adapters in CI.

## Alternatives considered
| Alternative | Pros | Cons | Why not |
|---|---|---|---|
| Kysely + kysely-d1 | Type-safe SQL builder | `kysely-d1` community-maintained, last release Apr 2025 | Maintenance risk |
| Raw SQL only | Zero deps | Hand-written migrations diverge per backend | Drizzle's migration generation is the value |
| SQLite Durable Object as the only hosted store | Per-site isolation, sync SQL | No dashboard/CLI tooling, single-location | Keep as option behind the same interface |

## Consequences
- Positive: adapters are tiny; D1's constraints become everyone's constraints early, so nothing "works locally but not on Cloudflare".
- Negative: no long transactions means careful batch design for multi-node mutations (a snapshot write is one batch).
- Risks: D1 100-param limit on bulk upserts (chunk ≤100 binds); stale reads with D1 replication (use first-primary sessions for CAS reads).

## Addendum (2026-10-07, Sprint 2)
Drizzle is deferred: with three tables, hand-written SQL migrations (`packages/storage/migrations/*.ts`, applied as one atomic batch each) are smaller and bundle for Workers without a build step. Revisit Drizzle when the schema grows or typed query building becomes a maintenance problem. Everything else in this ADR stands and is implemented.


---
title: "ADR-0003: Studio is a Vite React SPA on Kumo; Astro only renders sites"
description: "The initial plan proposed Astro + React islands for Studio with Kumo UI. Verified today: React context does not cross Astro islands (Kumo's portal/toast/command-palette providers cannot be shared), children from .astro…"
url: https://cloudhal.dev/docs/adr/0003-studio-architecture
source: https://github.com/cloudhal-dev/cloudhal/blob/main/docs/adr/0003-studio-architecture.md
---
# ADR-0003: Studio is a Vite React SPA on Kumo; Astro only renders sites

- Status: Accepted (supersedes the initial "Astro + React islands" assumption)
- Date: 2026-10-07
- Sources: https://github.com/cloudflare/kumo · https://kumo-ui.com/installation · https://kumo-ui.com/api/component-registry · https://docs.astro.build/en/guides/integrations-guide/react/ · https://ui.shadcn.com/docs/changelog

## Context
The initial plan proposed Astro + React islands for Studio with Kumo UI. Verified today: React context does not cross Astro islands (Kumo's portal/toast/command-palette providers cannot be shared), children from `.astro` into React arrive as strings, and an interactive admin degenerates into one `client:only` island per page. Kumo is MIT, `@cloudflare/kumo` 2.14 on `@base-ui/react` 1.8, Tailwind 4, Phosphor; it ships a machine-readable component registry and CLI.

## Decision
`apps/studio` is a **Vite 8 + React 19 single-page app** (TanStack Router + Query, Tailwind 4 via `@tailwindcss/vite`), served as static assets from a Worker next to the API. **Kumo** is the component layer, consumed only through `packages/ui` (`@cloudhal/ui`): Kumo components from npm, Kumo blocks copied in and owned, Cloudhal blocks (DiffViewer, SnapshotTimeline, PolicyBadge, AgentRunPanel, ApprovalDialog) built on top. Direct `@base-ui/react` imports are lint errors. **Astro 7** is used only for `astro-renderer` (visitor sites) and `apps/site` (docs/marketing).

## Alternatives considered
| Alternative | Pros | Cons | Why not |
|---|---|---|---|
| Astro + React islands | One framework for everything | Isolated React roots, string children, no shared providers | Breaks Kumo's compound components |
| TanStack Start (SSR) | Server functions, streaming | Heavier, moving fast, SSR of an authenticated admin adds little | Revisit only if SSR becomes a requirement |
| shadcn/ui (Base UI style) instead of Kumo | Code-owned, huge ecosystem | No agent-facing registry/CLI; more assembly | Kept as the fallback behind the `@cloudhal/ui` barrel |

## Consequences
- Positive: one React root, providers everywhere, Kumo's registry feeds the `studio-ui` skill.
- Negative: Kumo 2.x deprecates things weekly; the barrel is mandatory to contain churn.
- Risks: Kumo stalls → swap to shadcn behind the barrel; Base UI package rename already happened once (`@base-ui-components/react` is frozen; use `@base-ui/react`).


---
title: "ADR-0004: Stateless MCP server (spec 2026-07-28) with layered approval"
description: "MCP spec 2026-07-28 removed sessions and initialize; every request is self-describing, elicitation became multi-round-trip (inputrequired + retry with inputResponses), SSE is deprecated, Dynamic Client Registration is…"
url: https://cloudhal.dev/docs/adr/0004-mcp-server
source: https://github.com/cloudhal-dev/cloudhal/blob/main/docs/adr/0004-mcp-server.md
---
# ADR-0004: Stateless MCP server (spec 2026-07-28) with layered approval

- Status: Accepted
- Date: 2026-10-07
- Sources: https://modelcontextprotocol.io/specification/2026-07-28/changelog · https://modelcontextprotocol.io/specification/2026-07-28/basic/authorization · https://modelcontextprotocol.io/specification/2026-07-28/client/elicitation · https://developers.cloudflare.com/changelog/post/2026-07-27-agents-sdk-v0.20.0-mcp-sdk-v2/ · https://developers.cloudflare.com/agents/model-context-protocol/apis/handler-api/

## Context
MCP spec 2026-07-28 removed sessions and `initialize`; every request is self-describing, elicitation became multi-round-trip (`input_required` + retry with `inputResponses`), SSE is deprecated, Dynamic Client Registration is deprecated in favour of Client ID Metadata Documents. Cloudflare deprecated `McpAgent` in favour of `createMcpHandler` (one server per request).

## Decision
`packages/mcp-server` uses **`@modelcontextprotocol/server` 2.x**, served on Workers via **`createMcpHandler`** (`legacy: "reject"`), wrapped by **`@cloudflare/workers-oauth-provider`** (OAuth 2.1, CIMD with DCR fallback). Scopes: `site:read`, `site:write`, `site:publish`, `site:rollback`. All cross-call state is carried by **server-minted handles** (`snap_…`, `draft_…`, `pub_…`) stored in D1/KV and re-authorized against the caller on every call — ids in the contracts follow the same prefixed form. Every tool declares `outputSchema`, returns `structuredContent`, sets honest annotations (`destructiveHint: true` only for publish/rollback/delete) and `tools/list` is deterministic. Approval is layered: policy engine → `publish` returns `input_required` with a diff summary → `accept` completes; hosts without elicitation use a `confirm_token` on a second call. Decisions are audit-logged.

## Alternatives considered
| Alternative | Pros | Cons | Why not |
|---|---|---|---|
| `McpAgent` (Durable Object per session) | Familiar, stateful | Deprecated and feature-frozen | Dead end |
| Legacy SDK 1.x with SSE | Wider old-client support | Deprecated transport, 12-month removal window | Support legacy only via `isLegacyRequest` routing if demanded |
| Approval only in the host (Claude Code permission prompts) | Zero server work | Not every host gates; policy must hold without the host | Defence in depth required |

## Consequences
- Positive: scales horizontally, no session affinity, works from any MCP client.
- Negative: more explicit handle plumbing; two approval paths to test.
- Risks: host elicitation support is uneven in 2026; the `confirm_token` fallback must be first-class, not an afterthought.


---
title: "ADR-0005: Skills follow the Agent Skills spec; Cloudhal data lives in metadata"
description: "The Agent Skills specification (agentskills.io) defines exactly six frontmatter fields (name, description, license, compatibility, metadata, allowed-tools); metadata is a string-to-string map."
url: https://cloudhal.dev/docs/adr/0005-skills-format
source: https://github.com/cloudhal-dev/cloudhal/blob/main/docs/adr/0005-skills-format.md
---
# ADR-0005: Skills follow the Agent Skills spec; Cloudhal data lives in metadata

- Status: Accepted (replaces an earlier nested `cloudhal:` frontmatter design)
- Date: 2026-10-07
- Related: `packages/core-contracts/schemas/skill-manifest.schema.json`
- Sources: https://agentskills.io/specification · https://code.claude.com/docs/en/skills · https://www.cursor.com/docs/context/skills · https://modelcontextprotocol.io/extensions/skills/overview · https://agents.md/

## Context
The Agent Skills specification (agentskills.io) defines exactly six frontmatter fields (`name`, `description`, `license`, `compatibility`, `metadata`, `allowed-tools`); `metadata` is a string-to-string map. Claude Code ignores unknown fields, but claude.ai / the Skills API reject them. Codex, Cursor and Claude Code load skills from different directories.

## Decision
Every `SKILL.md` (product skills in `skills/`, repo skills in `.agents/skills/`) uses only the six spec fields; `SkillManifest` in core-contracts enforces this (`additionalProperties: false`). Cloudhal-specific data goes under `metadata` as flat, prefixed string keys: `cloudhal.schemaVersion`, `cloudhal.requiresCapabilities` (comma-separated), `cloudhal.scopes`, `cloudhal.approval`. Repo skills live in `.agents/skills/` with `.claude/skills` symlinked to it. Product skills will additionally be exposed over MCP via the `io.modelcontextprotocol/skills` extension with digest-verified manifests.

## Alternatives considered
| Alternative | Pros | Cons | Why not |
|---|---|---|---|
| Nested `cloudhal:` block | Readable | Hard error on hosted skill upload; not spec-valid | Portability |
| Sidecar `cloudhal.yaml` next to SKILL.md | Rich structure | Second file to keep in sync; invisible to spec tooling | Metadata strings are enough for now |

## Consequences
- Positive: skills upload anywhere; one schema validates them; Skills-over-MCP preserves metadata verbatim.
- Negative: lists are encoded as comma-separated strings; the agent runtime parses them.
- Risks: if the spec later adds a vendor-extension mechanism, migrate keys (schemaVersion makes this mechanical).


---
title: "ADR-0006: Node-first toolchain and runtime; Bun supported, not primary"
description: "Cloudhal must run on Cloudflare (workerd) and self-hosted. Wrangler, miniflare and the Vite plugin are officially Node-only and have open Bun hangs; Bun 1.4 is a weeks-old Rust rewrite that mis-reports its Node ABI."
url: https://cloudhal.dev/docs/adr/0006-toolchain-and-runtime
source: https://github.com/cloudhal-dev/cloudhal/blob/main/docs/adr/0006-toolchain-and-runtime.md
---
# ADR-0006: Node-first toolchain and runtime; Bun supported, not primary

- Status: Accepted
- Date: 2026-10-07
- Sources: https://raw.githubusercontent.com/nodejs/Release/main/schedule.json · https://pnpm.io/catalogs · https://turborepo.dev/docs/guides/tools/rust · https://biomejs.dev/internals/language-support/ · https://devblogs.microsoft.com/typescript/announcing-typescript-7-0/ · https://developers.cloudflare.com/workers/wrangler/install-and-update/ · https://bun.com/blog/bun-v1.4 · https://docs.astro.build/en/recipes/bun/

## Context
Cloudhal must run on Cloudflare (workerd) and self-hosted. Wrangler, miniflare and the Vite plugin are officially Node-only and have open Bun hangs; Bun 1.4 is a weeks-old Rust rewrite that mis-reports its Node ABI. Bun's genuine advantages are single-binary cross-compilation and a 42 MB alpine image for self-host.

## Decision
- Runtime: **Node 24 LTS** floor (`engines.node >= 24`), Node 26 in the CI matrix; switch primary to 26 after it enters LTS (2026-10-28).
- Package manager: **pnpm 12** with `catalog:` and `catalogMode: strict`; build scripts allow-listed via `allowBuilds`.
- Tasks: **Turborepo 2.11** (enable `experimentalCargoWorkspaces` when more crates exist). Lint/format: **Biome 2.5** only. Tests: **Vitest 5**. Types: **TypeScript 7.0** (`module: nodenext`, `verbatimModuleSyntax`, `erasableSyntaxOnly`, `rewriteRelativeImportExtensions`). Releases: **Changesets 3**.
- Rust: stable toolchain, **edition 2024, resolver 3**, `[workspace.package]` inheritance, clippy `-D warnings`.
- **Bun** is a supported secondary runtime for `cloudhal serve` and pure packages: a `bun-sqlite` storage adapter and a Bun CI job; never for `cloudhal dev` (must spawn wrangler/workerd). No Bun-only APIs in shared code.

## Alternatives considered
| Alternative | Pros | Cons | Why not |
|---|---|---|---|
| Bun-first | Fast installs, `--compile`, small images | Cloudflare toolchain unsupported, fresh rewrite, ABI mismatch, no comparable CMS does it | Risk concentrated exactly where Cloudhal is most Cloudflare-dependent |
| Nx / moon instead of Turborepo | Richer polyglot features | Heavier; moon's ecosystem is small | Revisit if the Rust side grows large |
| ESLint + Prettier | Familiar | Two tools, slower, TS 7 ecosystem still catching up | Biome covers TS/TSX/JSON/CSS in one binary |

## Consequences
- Positive: everything first-class; CI mirrors Cloudflare's own repos.
- Negative: single-binary self-host story is weaker on Node (SEA experimental) until Bun stabilizes.
- Risks: `.astro` type-checking waits for TS 7.1 (`astro check` as a separate CI step).


---
title: "ADR-0007: Apache-2.0 open core, DCO, Contributor Covenant 3.0"
description: "An open-source launch needs the license, contribution terms and conduct policy settled before the first external PR; changing them later breaks trust."
url: https://cloudhal.dev/docs/adr/0007-license-and-community
source: https://github.com/cloudhal-dev/cloudhal/blob/main/docs/adr/0007-license-and-community.md
---
# ADR-0007: Apache-2.0 open core, DCO, Contributor Covenant 3.0

- Status: Accepted
- Date: 2026-10-07
- Sources: https://www.apache.org/licenses/LICENSE-2.0 · https://www.contributor-covenant.org/version/3/0/code_of_conduct/ · https://developercertificate.org/ · https://github.com/apps/dco · https://spring.io/trademarks · https://docs.github.com/en/code-security/security-advisories/working-with-repository-security-advisories/configuring-private-vulnerability-reporting-for-a-repository

## Context
An open-source launch needs the license, contribution terms and conduct policy settled before the first external PR; changing them later breaks trust. The project's moat is Cloud operations and the registry, not the code.

## Decision
- **License:** Apache-2.0 for the whole monorepo (patent grant eases enterprise adoption). Trademark protected separately via `TRADEMARK.md`.
- **Open-core boundary** declared in `README.md` and `GOVERNANCE.md`: open = core, graph, policy, CLI, Studio, renderer, Cloudflare adapter, SDK, starters, official skills/capabilities; closed = billing, multi-tenant control plane, managed registry. Relicensing requires an RFC and is expected never to happen.
- **Contribution terms:** DCO (`git commit -s`), enforced by the GitHub DCO app; no CLA.
- **Conduct:** Contributor Covenant **3.0** with a project-specific reporting section.
- **Security:** `SECURITY.md` + GitHub private vulnerability reporting, with response-time commitments.
- **Governance:** BDFL (Halil) + area maintainers; RFC process for significant changes; ADRs for architecture.
- **Names:** GitHub org `cloudhal-dev` (`cloudhal` is a 2020 user account), npm scope `@cloudhal`, crates `cloudhal-*`, domains `cloudhal.com` (owned), `cloudhal.app` and `cloudhal.dev` (to register).

## Alternatives considered
| Alternative | Pros | Cons | Why not |
|---|---|---|---|
| AGPL-3.0 | Deters hosted competitors | Slows adoption, scares enterprises, hurts the capability ecosystem | Moat is elsewhere |
| MIT | Simplest | No patent grant | Agent/AI space makes the patent clause worth it |
| CLA | Enables future relicensing | Friction; signals relicensing intent | DCO is enough and more trusted |

## Consequences
- Positive: predictable terms; low-friction contributions; enterprise-safe.
- Negative: Apache-2.0 cannot stop a hosted fork; brand + operations must carry the business.
- Risks: trademark registration must actually be filed; until then `TRADEMARK.md` is policy, not law.


---
title: "ADR-0008: Precompiled schema validators; D1 tested through Miniflare"
description: "Agent mutations must be validated against the contracts at the write boundary, in Node and on Cloudflare Workers. Ajv compiles schemas with new Function, which Workers forbid."
url: https://cloudhal.dev/docs/adr/0008-runtime-validation-and-d1-testing
source: https://github.com/cloudhal-dev/cloudhal/blob/main/docs/adr/0008-runtime-validation-and-d1-testing.md
---
# ADR-0008: Precompiled schema validators; D1 tested through Miniflare

- Status: Accepted (Miniflare version rationale corrected by ADR-0012)
- Date: 2026-10-07
- Related: ADR-0001, ADR-0002
- Sources: https://ajv.js.org/standalone.html · https://developers.cloudflare.com/workers/runtime-apis/web-standards/#javascript-standards (no `eval`/`new Function`) · https://github.com/cloudflare/workers-sdk/tree/main/packages/miniflare (programmatic `getD1Database`)

## Context
Agent mutations must be validated against the contracts at the write boundary, in Node and on Cloudflare Workers. Ajv compiles schemas with `new Function`, which Workers forbid. Separately, the storage layer must be proven against real D1 semantics (no interactive transactions, `batch()` atomicity, bind limits) without a Cloudflare account.

## Decision
- `@cloudhal/core-contracts` generates **standalone validator modules** with ajv's `standaloneCode` at build time (`pnpm gen`), committed under `src/generated/validators.js` (+ `.d.ts`), and exports the bundled schemas as constants. Runtime code never instantiates Ajv.
- Storage and core-ts tests run **the same suite** against `better-sqlite3` (in-memory) and against a **Miniflare-provided D1 database** (workerd in-process), so CI exercises D1 behaviour offline.

## Alternatives considered
| Alternative | Pros | Cons | Why not |
|---|---|---|---|
| Ajv at runtime | Simple | Fails on Workers (`new Function`) | Hard blocker |
| zod/TypeBox runtime validators | Nice DX | Second source of truth (ADR-0001) | Rejected before |
| `@cloudflare/vitest-pool-workers` for D1 tests | Runs tests inside workerd | Heavier, pool-specific config, slower feedback | Miniflare's D1 handle is enough for storage tests; revisit for Worker-side integration tests |
| Mock D1 in tests | Fast | Does not prove D1 limits/atomicity | The whole point is real semantics |

## Consequences
- Positive: validation works everywhere with zero runtime deps; D1 constraints are enforced by tests from Sprint 2 on.
- Negative: one more generated artefact to drift-check; Miniflare is an alpha line (5.x) tied to wrangler's version.
- Risks: Miniflare API churn → pin to the version wrangler ships.


---
title: "ADR-0009: Renderer reads live nodes in dev and a snapshot in builds; starters own routes"
description: "The renderer must serve two situations with one code path: an author previewing drafts locally, and a production build that must be reproducible from an approved snapshot."
url: https://cloudhal.dev/docs/adr/0009-renderer-sources-and-local-dev
source: https://github.com/cloudhal-dev/cloudhal/blob/main/docs/adr/0009-renderer-sources-and-local-dev.md
---
# ADR-0009: Renderer reads live nodes in dev and a snapshot in builds; starters own routes

- Status: Accepted
- Date: 2026-10-08
- Related: ADR-0002, RFC-0001
- Sources: https://docs.astro.build/en/reference/integrations-reference/ · https://docs.astro.build/en/reference/programmatic-reference/ · https://github.com/withastro/astro/pull/16776 (dev route cache) · https://docs.astro.build/en/reference/publish-to-npm/

## Context
The renderer must serve two situations with one code path: an author previewing drafts locally, and a production build that must be reproducible from an approved snapshot. Astro 7 keeps `getStaticPaths()` results cached across dev requests (since 6.3.6), and it skips its dev middleware under Vitest.

## Decision
- `GraphSource` abstraction with `LiveSource` (current nodes, drafts included, read per request) and `SnapshotSource` (node bodies from the blob store of one snapshot, published nodes only). `cloudhal dev` uses live; `cloudhal build` resolves published → latest → fresh snapshot and sets `CLOUDHAL_SOURCE=snapshot:<id>`.
- The `cloudhal()` integration only provides configuration (`virtual:cloudhal/config`), SSR externals, and a data-directory watcher that clears Astro's dev route cache (`astro:content-changed` + full reload). **Routes and layouts belong to the starter/theme**, so themes stay explicit and forkable.
- Section rendering is a dispatcher over `section.kind` with a component map; themes override per kind.
- The CLI runs Astro programmatically (`dev()`, `build()` with `root`), and ships as TypeScript executed by Node 24 type stripping until the first published release adds a build step.

## Alternatives considered
| Alternative | Pros | Cons | Why not |
|---|---|---|---|
| Astro Content Layer loaders | Official data path, `refreshContent` | Loader runs at startup; live edits need refresh plumbing; snapshot vs live becomes two loaders | Direct `GraphSource` is simpler and already covers both |
| On-demand rendering (`prerender = false`) in dev | No route cache issue | Requires an adapter even for a static site | Static output is the product promise |
| Integration-injected routes | Zero files in starters | Themes cannot change page structure without escape hatches | Starters as the theme boundary |

## Consequences
- Positive: one renderer, two provenance modes; production HTML is reproducible from a snapshot id.
- Negative: the dev watcher relies on an internal Astro HMR event name; pinned Astro minor, covered by the e2e test.
- Risks: rich text is rendered without sanitisation until the Sprint 5 policy work; only trusted authors until then.


---
title: "ADR-0010: Better Auth for human identity, tenants and (later) MCP authorization"
description: "Cloudhal needs user authentication for Studio, a tenant/role model, non-interactive credentials for agents, and — per MCP 2026-07-28 — an OAuth 2.1 authorization server whose tokens the MCP server verifies."
url: https://cloudhal.dev/docs/adr/0010-auth-better-auth
source: https://github.com/cloudhal-dev/cloudhal/blob/main/docs/adr/0010-auth-better-auth.md
---
# ADR-0010: Better Auth for human identity, tenants and (later) MCP authorization

- Status: Accepted
- Date: 2026-10-08
- Related: ADR-0002, ADR-0004, ADR-0003
- Sources: https://better-auth.com/docs/installation · https://www.better-auth.com/docs/plugins/organization · https://www.better-auth.com/docs/plugins/admin · https://www.better-auth.com/docs/plugins/mcp · https://www.better-auth.com/docs/plugins/cimd · https://www.better-auth.com/docs/plugins/oauth-provider · https://better-auth.com/blog/1-7 · https://better-auth.com/blog/better-auth-joins-vercel · https://github.com/better-auth/better-auth/security/advisories

## Context
Cloudhal needs user authentication for Studio, a tenant/role model, non-interactive credentials for agents, and — per MCP 2026-07-28 — an OAuth 2.1 authorization server whose tokens the MCP server verifies. All of it must run on Cloudflare (D1) and self-hosted (Node + SQLite). Hand-rolling an OAuth AS is the riskiest code a small team can write.

## Decision
Adopt **Better Auth 1.7.x** (MIT; Vercel-owned since 2026-07 with an open-source, framework-agnostic commitment):
- Studio identity: core email+password (+ magic link / GitHub / Google when configured), `admin` plugin for roles; `organization` plugin as the tenant and membership model; `@better-auth/api-key` with organization-owned keys for non-interactive agents.
- MCP authorization (Sprint 5): `@better-auth/mcp` + `@better-auth/cimd` + `jwt()` — RFC 9728 resource metadata, PKCE, RFC 8707 `resource` → `aud`, JWKS verification in the MCP server; DCR disabled; DPoP optional.
- Database: the Kysely adapter over the same better-sqlite3/D1 handle as the storage layer; Better Auth's DDL is **checked into `packages/storage/migrations`** so one migration pipeline governs everything.
- Cloudhal keeps one actor model: `Actor { kind: "human", id: user_<betterAuthUserId> }`; the policy engine decides, Better Auth only authenticates and issues tokens.

## Alternatives considered
| Alternative | Pros | Cons | Why not |
|---|---|---|---|
| `@cloudflare/workers-oauth-provider` | Excellent MCP AS | Workers+KV only; no user authentication | Cannot serve self-host |
| Auth.js | Familiar | Maintenance mode, no AS/orgs/API keys | Dead end |
| Clerk / WorkOS | Hosted, polished | Not self-hostable | Violates the product promise |
| Hand-rolled (arctic + own sessions + own AS) | Full control | Largest security surface, slowest | Risk |

## Consequences
- Positive: one library covers Studio login, tenants, API keys and the MCP authorization server on both runtimes.
- Negative: 1.5→1.7 churn (adapter packages split, OIDC plugin removed, tables renamed) and a wide advisory history → pin to 1.7.x, subscribe to GHSAs, keep telemetry off.
- Risks: scrypt cost on Workers free plan (override hashing or document paid plan); Vercel governance (mitigated by MIT + data in our DB).


---
title: "ADR-0011: Agent layer — policy engine, audit, skills and the MCP server"
description: "Sprint 5 makes Cloudhal usable by agents: an MCP client must be able to create content, see a preview, and ask to publish — with publishing stopping at a human."
url: https://cloudhal.dev/docs/adr/0011-agent-layer
source: https://github.com/cloudhal-dev/cloudhal/blob/main/docs/adr/0011-agent-layer.md
---
# ADR-0011: Agent layer — policy engine, audit, skills and the MCP server

- Status: Accepted
- Date: 2026-10-08
- Builds on: ADR-0004 (stateless MCP server, layered approval), ADR-0005 (skills format), ADR-0010 (Better Auth)
- Sources: `@modelcontextprotocol/server` 2.3.1 / `@modelcontextprotocol/client` 2.3.1 (verified against the published type definitions) · https://modelcontextprotocol.io/specification/2026-07-28/basic/patterns/mrtr · https://modelcontextprotocol.io/extensions/skills/overview · `@better-auth/api-key` 1.7.7

## Context
Sprint 5 makes Cloudhal usable *by agents*: an MCP client must be able to create content, see a preview, and ask to publish — with publishing stopping at a human. Three things were undecided: how policy, audit and skills relate to the MCP SDK; how approval works for clients with and without elicitation; and how agents authenticate over HTTP.

## Decision
1. **`packages/agent-runtime` is SDK-free.** It holds the `PolicyEngine` (actor scopes × tool scopes × annotations × declared skill → `allow | deny | approval-required`), the `SkillLoader` (SKILL.md → `SkillManifest` contract; Cloudhal data in flat `cloudhal.*` metadata keys) and the `AuditLog` (table `audit`, migration `0003_agent`). It never imports MCP.
2. **Tools are defined once, independently of the SDK** (`packages/mcp-server/src/tools.ts`): JSON Schema in/out built from the contract schemas, honest annotations, scopes, handler over `SiteGraphStore`. `runTool` is the only execution path: policy → audit → handler. Every mutation auto-snapshots (`reason: "agentMutation"`, label = tool name). The 18 tools are listed deterministically.
3. **The MCP server is a thin wrapper** (`createCloudhalMcpServer`): one instance per principal, `fromJsonSchema` for schemas (no zod in our code), `structuredContent` on every result, skills exposed as `skill://<name>/SKILL.md` (+ `references/`) resources, and a declared skill via `_meta["cloudhal.skill"]` narrowing the call to that skill's scopes.
4. **Approval = multi-round-trip elicitation, with a signed token as fallback.** `approval-required` returns `input_required` carrying an elicitation (the prompt shows the diff summary) and an HMAC-signed `requestState` bound to the tool, the argument hash and the principal. The client retries with the answer; only `accept` + `confirm: true` runs the handler; `decline`/`cancel` is audited as a denial. Clients that cannot elicit (2025-era over stateless HTTP, hosts without elicitation) receive a `confirmToken` to pass on a second identical call — same HMAC state, same binding, 10-minute TTL; can be disabled with `confirmTokenFallback: false`.
5. **Transports:** `cloudhal mcp` (stdio, local developer, default scopes `*`, `--scopes` to narrow) and `/mcp` inside the Hono API (`createMcpHandler`, stateless per request, 2025-era served statelessly). Authentication stays outside the SDK: the API resolves a `Principal` (session cookie → human with all scopes; `x-api-key`, or `Authorization: Bearer <key>` outside `/api/admin/*` → `agent` actor with the key's `cloudhal` permissions as scopes) and passes it via `authInfo.extra`.
6. **Agent credentials are Better Auth API keys** (`@better-auth/api-key`, table in migration `0003_agent`). Keys are minted server-side through `POST /api/agent-keys {name, scopes}` by a signed-in human (permissions are a server-only property in the plugin), listed and revoked under the same route.
7. **Skills extension (SEP-2640) is not implemented yet.** The TypeScript SDK has no `skills/list`/`skills/get` support in 2.3.1 (PR #2818 open); resources cover discovery for now. Revisit when the SDK ships it.

## Alternatives considered
| Alternative | Pros | Cons | Why not |
|---|---|---|---|
| Register tools directly with the SDK (zod schemas) | Less code | Policy/audit/tests tied to SDK types; zod in our tree despite ADR-0001 | Contract schemas already exist; `fromJsonSchema` reuses them |
| Approval only via `confirmToken` | Works everywhere | The agent sees the token; relies on the host to involve a human | Elicitation is the first-class path; token is the documented fallback (ADR-0004) |
| OAuth 2.1 (`@better-auth/mcp` + CIMD) now | Spec-aligned remote auth | Much larger surface; not needed for local/CI agents | Sprint 6; API keys cover agents today |
| Durable Object / sessionful server | Server-initiated elicitation works for legacy clients | Deprecated pattern; session affinity | Stateless per ADR-0004 |

## Consequences
- Positive: one tool table drives policy, MCP, docs and tests; the exit criterion is proven by a real client ↔ server test (in-memory, HTTP and stdio); audit answers "who did what, under which skill, with which snapshot".
- Negative: two approval paths to keep in sync; legacy clients over HTTP never get interactive approval.
- Risks: SDK v2 is young (2.3.x) — pinned exact; `requestState` HMAC key must be shared across instances in multi-process deployments (configure `mcp.stateKey` from a secret).


---
title: "ADR-0012: Cloudflare deployment — two Workers, pointer-flip publishing, Workers Cache"
description: "Sprint 6 puts a Cloudhal site on Cloudflare. Publishing and rollback must be instant and must not need a build server: agents publish through MCP, and an approved publish should be live on the next request."
url: https://cloudhal.dev/docs/adr/0012-cloudflare-deployment
source: https://github.com/cloudhal-dev/cloudhal/blob/main/docs/adr/0012-cloudflare-deployment.md
---
# ADR-0012: Cloudflare deployment — two Workers, pointer-flip publishing, Workers Cache

- Status: Accepted
- Date: 2026-10-08
- Related: ADR-0002 (storage), ADR-0004 / ADR-0011 (MCP, agent layer), ADR-0009 (renderer sources), ADR-0010 (auth). Corrects the Miniflare rationale in ADR-0008.
- Sources: https://docs.astro.build/en/guides/integrations-guide/cloudflare/ · https://developers.cloudflare.com/workers/cache/ (+ `/cache-keys/`, `/purge/`) · https://developers.cloudflare.com/workers/runtime-apis/cache/ · https://developers.cloudflare.com/workers/static-assets/routing/single-page-application/ · https://developers.cloudflare.com/workers/wrangler/configuration/ · https://developers.cloudflare.com/workers/testing/test-harness/ · https://developers.cloudflare.com/d1/best-practices/read-replication/ · https://www.better-auth.com/docs/installation · versions verified with `npm view` on 2026-10-08 (astro 7.3, @astrojs/cloudflare 14.3.4, wrangler 4.148.0, miniflare 4.20260730.0)

## Context
Sprint 6 puts a Cloudhal site on Cloudflare. Publishing and rollback must be instant and must not need a build server: agents publish through MCP, and an approved publish should be live on the next request. Drafts and arbitrary snapshots must be previewable on the real deployment without leaking into the cache. Everything has to be testable without a Cloudflare account.

## Decision
1. **Two Workers over one D1 database and one R2 bucket.**
   - *Site Worker* — the project's Astro build with `@astrojs/cloudflare` (`CLOUDHAL_TARGET=cloudflare`). Every project route renders on demand (`astro:route:setup` sets `prerender = false`). `@cloudhal/astro-renderer/runtime` has a `workerd` export condition that reads D1/R2 bindings through `SiteResolver`: the site's `publishedSnapshotId` decides what renders, so **publish and rollback are pointer flips** — no rebuild, no redeploy. Snapshot sources are immutable and cached per isolate; the pointer is read on every cache miss.
   - *Admin Worker* (`@cloudhal/cloudflare/admin`) — Studio SPA as static assets plus the Hono API, Better Auth (D1 binding, D1-backed rate limiting, `cf-connecting-ip`), `/mcp` and the push/pull endpoints, on one origin. `run_worker_first: ["/api/*", "/mcp", "/mcp/*", "/.well-known/*"]` because SPA navigation requests otherwise never reach the Worker.
2. **Workers Cache, not `caches.default`.** Published pages carry `Cloudflare-CDN-Cache-Control: public, max-age=31536000`, `Cache-Control: public, max-age=0, must-revalidate` and `Cache-Tag: cloudhal-site-<siteId>`. A hit never runs the Worker. Publish/rollback purge the tag: purges are scoped to the Worker that cached, so the admin Worker calls the site Worker's authenticated `POST /__cloudhal/purge` over the `SITE` service binding (`SiteGraphStore.onPublished`). `caches.default` was rejected: no effect on workers.dev, per-colo only.
3. **Previews never touch the cache.** The cache key ignores the hostname and cookies, so previews are signed URL tokens (`?cloudhal-preview=`, HMAC, site-bound, expiring) answered with `no-store`; the entry rewrites internal links with HTMLRewriter to keep the token while navigating. Invalid tokens fall back to the published page, uncached.
4. **The deployment is the source of truth after the first deploy.** `cloudhal deploy` provisions D1/R2 (ids recorded in the wrangler configs), generates secrets once (`.cloudhal/deploy/secrets.json`, 0600), deploys site then admin with `wrangler deploy --secrets-file`, and pushes the local site only if the deployment does not have it. `cloudhal push` refuses to overwrite diverged content (agents edit production); `cloudhal pull` brings it home. Transfers are JSON site bundles verified by content hash, through `/api/admin/sites/:id/bundle` behind a deployment token — no Cloudflare API token handling in Cloudhal; wrangler uses the user's own login.
5. **Migrations run from the admin Worker** (our `_migrations` ledger, once per isolate), not `wrangler d1 migrations apply` — one ledger only.
6. **Testing:** both Workers run on workerd through wrangler's `createTestHarness` with local D1/R2 (`packages/cli/test/cloudflare*.test.ts`). `@cloudflare/vitest-pool-workers` is not used (needs Vitest 4). **Correction to ADR-0008:** Miniflare 5 alpha *does* expose `getD1Database`/`getR2Bucket`; its constructor options changed shape. We pin the last stable **Miniflare 4.20260730.0**, which needs no code change.

## Alternatives considered
| Alternative | Pros | Cons | Why not |
|---|---|---|---|
| Static build per snapshot uploaded to R2, tiny host Worker | No SSR in production | Publish needs a build (Astro cannot build inside a Worker), so agent publishes over MCP would need CI | Breaks "approved publish is live on the next request" |
| Published pointer in KV | Cheap reads | Eventually consistent (≥60 s); a stale read after a purge re-caches the old snapshot | D1 read-after-write is required |
| Preview on a separate hostname of the same Worker | Pretty URLs | Cache key excludes the host → previews could be served from / poison the production cache | Unsafe |
| Single Worker for site and admin | One deploy | Auth, MCP and Studio code on every page view; cache rules mixed | Separation of concerns and blast radius |
| `cloudhal deploy` uploading data with `wrangler r2 object put` / D1 REST | No admin endpoint | One process per object; D1 REST params are strings only; needs extra token scopes | Admin API with bindings is simpler and atomic |

## Consequences
- Positive: agent → approval → live in one request; rollback is instant; previews are safe; the whole loop is tested on workerd in CI.
- Negative: on-demand SSR on cache misses (bounded by the edge cache); two Workers to deploy; the generated admin entry needs `@cloudhal/cloudflare/workerd-prelude` first — esbuild does not initialise lazily-wrapped zod before `@modelcontextprotocol/core` evaluates (remove when fixed upstream).
- Risks: Workers Cache purge rate limits are the Free-tier limits — purges are per publish, not per edit (and at most three tag requests per publish, see the addendum). Enabling D1 read replication later requires `withSession("first-primary")` for the pointer read. Real deployment was not exercised from CI (no account); the dry run bundles both Workers with the real wrangler.

## Addendum (2026-10-08): custom domains
`cloudhal domain set <host> [--studio <host>]` stores hostnames in `cloudhal.json` (`domains.site`, `domains.studio`). `cloudhal deploy` then adds `routes: [{ pattern, custom_domain: true }]` to the site and admin Worker configs. Wrangler creates the DNS record and certificate; the zone must be in the same account.

After the deploy, the site domain becomes the site's primary hostname, set through `PUT /api/admin/sites/:id/domains` with the deploy token and mirrored locally. The workers.dev hostname stays as a secondary domain.

Removing the domain from `cloudhal.json` restores workers.dev as primary on the next deploy. Deploy state remembers the last custom domain for this. Custom Domains that already exist in the account are not deleted by wrangler; the CLI says so.

Studio needs nothing extra, because Better Auth's base URL is the request origin. Not exercised against a real zone in CI (no account): the deploy test checks the generated routes and the domain switch against an in-process admin API.


## Addendum (2026-10-08): purging what changed
Publish and rollback no longer purge the whole cache. `onPublished` receives the snapshot that was live and the new one; `SiteGraphStore.planCachePurge` turns their diff into Cloudhal cache tags (`cloudhal-node-`, `-term-`, `-list-`, or the site tag for structure — RFC-0002, follow-up notes), and the admin Worker sends them to the site Worker in requests of at most 100 tags, Cloudflare's limit per tag purge. Workers Cache always uses the Free-tier purge rate limit (5 requests per minute, bursts of 25), so one publish spends at most three requests and otherwise sends one "purge everything", which is also the fallback for a first publish, an unreadable or too large diff, and a failed tag purge. Sources: https://developers.cloudflare.com/workers/cache/purge/ · https://developers.cloudflare.com/cache/how-to/purge-cache/#hostname-tag-prefix-url-and-purge-everything-limits (checked 2026-10-08).


---
title: "ADR-0013: Publishing to npm — sources in the monorepo, dist in the tarball"
description: "Every package exports its TypeScript sources so the monorepo needs no build step (Node 24 strips types, Vite/wrangler/Vitest read TS)."
url: https://cloudhal.dev/docs/adr/0013-npm-packaging
source: https://github.com/cloudhal-dev/cloudhal/blob/main/docs/adr/0013-npm-packaging.md
---
# ADR-0013: Publishing to npm — sources in the monorepo, dist in the tarball

- Status: Accepted
- Date: 2026-10-08
- Related: ADR-0001 (contracts), ADR-0006 (toolchain), ADR-0012 (deployment)
- Sources: https://nodejs.org/api/module.html#modulestriptypescripttypescode-options · https://nodejs.org/api/typescript.html (type stripping is not applied under node_modules) · https://pnpm.io/package_json#publishconfig · https://docs.npmjs.com/cli/v11/configuring-npm/package-json#files (.gitignore is never packed)

## Context
Every package exports its TypeScript sources so the monorepo needs no build step (Node 24 strips types, Vite/wrangler/Vitest read TS). Published as-is, the packages would not run: Node refuses to strip types inside `node_modules`, `core-contracts` imported a directory it did not ship, and `cloudhal init` outside the monorepo produced a project with `catalog:` dependencies npm cannot install. The roadmap's public-alpha promise is `npm create cloudhal` in five minutes.

## Decision
1. **Two export maps.** `exports` keeps pointing at `src/*.ts` for development; `publishConfig.exports` (pnpm swaps it in on pack/publish) points at `dist/*.js` with `types` → `dist/*.d.ts`, conditions (e.g. `workerd`) preserved. `scripts/build-packages.ts --write-publish-config` derives it; `--check` fails CI when it drifts. Tarballs ship `dist` (+ `schemas` for contracts) only.
2. **Build = strip + rewrite, declarations from one tsc pass.** JS per file via Node's `module.stripTypeScriptTypes` (sources are erasable-only), relative `.ts` specifiers rewritten to `.js` with es-module-lexer; `.d.ts` from a single `tsc --emitDeclarationOnly` over all packages (`tsconfig.build.json`), each package keeping its own files. `.astro`/JSON/generated assets are copied with the same rewrite. No bundler; output mirrors the source tree.
3. **The CLI carries its assets.** `dist/assets/{studio,starters,skills}`: the built Studio (no source maps), the starters with `workspace:`/`catalog:` replaced by real versions, and the official skills. `src/assets.ts` resolves them in the published layout first, then in the monorepo. `.gitignore` ships as `_gitignore` and `init` renames it.
4. **`create-cloudhal`** (`npm create cloudhal@latest`) wraps `cloudhal init`; starters get npm scripts (`dev`, `build`, `studio`, `deploy`) and their own `@cloudhal/cli`.
5. **`@cloudhal/ui` stays private**: it is bundled into the Studio build and not consumed on its own yet.
6. **One version for everything published** (amended 2026-10-20, after dogfooding cloudhal.com): the published packages are a changesets `fixed` group, and a starter names each `@cloudhal/*` package at that exact version, as `pnpm publish` writes `workspace:*`. Astro, a peer of the renderer and the CLI, is pinned to the version the monorepo installed, so a project gets one copy. `cloudhal init` run from a source checkout, outside the workspace, links the packages to that checkout (`link:`, installed with pnpm) instead of naming a version that may not be on npm. New projects get `pnpm-workspace.yaml` `allowBuilds`, bun `trustedDependencies` and yarn `nodeLinker: node-modules`, and next steps in the package manager that ran the command.
7. **Proof:** `scripts/smoke-pack.ts` packs everything, then with plain npm in an empty directory runs create → install → snapshot/build → `cloudhal dev` → `deploy --dry-run` (CI: weekly, manual, and on packaging changes).

## Alternatives considered
| Alternative | Pros | Cons | Why not |
|---|---|---|---|
| Per-package `tsc` emit | Standard | Cross-package source imports make tsc emit (or reject) other packages' files | Needs project references / path rewiring for every package |
| tsdown / rolldown bundling | Small output, d.ts bundling | New toolchain; its d.ts plugin uses the TypeScript JS API, which TS 7 does not have | Risk without benefit for libraries this size |
| Ship `.ts` and let consumers compile | No build | Node will not run it from node_modules; consumer type-checking of our sources | Does not work |
| `development` export condition | One map | Node/wrangler do not set it; bins and Workers would need flags | Fragile |

## Consequences
- Positive: the monorepo stays build-free; npm users get plain JS + types; the full user path is tested.
- Negative: `stripTypeScriptTypes` is still experimental in Node 24 (a warning during builds); two export maps to keep in sync (checked in CI).
- Risks: a source file using non-erasable syntax would fail the build — `erasableSyntaxOnly` in tsconfig already forbids it.


---
title: "ADR-0014: Approvals happen in Studio, once, for the state the person saw"
description: "ADR-0004/0011 approved gated calls (publish, rollback, delete) through MCP elicitation, with a signed confirmToken for clients that cannot elicit. The review confirmed two flaws."
url: https://cloudhal.dev/docs/adr/0014-out-of-band-approvals
source: https://github.com/cloudhal-dev/cloudhal/blob/main/docs/adr/0014-out-of-band-approvals.md
---
# ADR-0014: Approvals happen in Studio, once, for the state the person saw

- Status: Accepted (supersedes the approval mechanics of ADR-0004 and ADR-0011)
- Date: 2026-10-08
- Related: ADR-0004, ADR-0011, ADR-0012
- Source: Sprint 7 pre-launch security review (findings H1, H2)

## Context
ADR-0004/0011 approved gated calls (publish, rollback, delete) through MCP elicitation, with a signed `confirmToken` for clients that cannot elicit. The review confirmed two flaws. **H1:** whoever answers the elicitation or holds the token is the agent's own client — the token was returned to the agent in the tool result, and an auto-accepting client passes elicitation — so a remote agent could approve itself. **H2:** the approval was bound only to (tool, arguments, actor) and was reusable for ten minutes, so after one approved publish an agent could edit content and publish again with the same approval.

## Decision
1. **Agents with their own credentials (API keys over HTTP) never answer for a person.** A gated call creates a `pending` row in `approvals` (migration 0006) and returns "approval pending" with an `approvalId`. A signed-in person approves or denies it in Studio (`POST /api/sites/:id/approvals/:aid/approve|deny`, people only, audited). The agent repeats the same call with `approvalId`.
2. **Single use, bound to the exact call and the content.** Redeeming is one atomic `UPDATE … WHERE status='approved' AND tool AND args_hash AND actor_id AND state_hash AND not expired`. `state_hash` is a digest of every node's id, rev and hash at request time (`SiteGraphStore.stateHash`): any change after the person looked voids the approval. Requests expire after 24 h.
3. **Elicitation remains for people and local use** (a session-authenticated person's MCP client; `cloudhal mcp` over stdio on the developer's machine), now also bound to `state_hash`.
4. **`confirmToken` is off by default**; it is an explicit local opt-in only.
5. Agents are refused on the REST API (security review C1), so approval endpoints are reachable by people only.

## Alternatives considered
| Alternative | Pros | Cons | Why not |
|---|---|---|---|
| Keep elicitation for agents | No Studio step | The agent's client answers it | Not a human control |
| Signed single-use tokens without storage | Stateless | Single use needs storage anyway; the token still reaches the agent | Same flaw |
| Approve by e-mail/Slack link | Reach people anywhere | New channels, identity mapping | Later, on top of the same table |

## Consequences
- Positive: "publish stops for a person" holds even against a compromised or careless agent client; every decision is audited with who decided.
- Negative: a remote agent must wait (and poll) for the decision; Studio needs an Approvals screen.
- Follow-up: notifications for pending approvals; per-site approver roles.

## Addendum (2026-10-09): one waiting request per call
An agent that repeats a call needing approval (same actor, tool, arguments and site state) gets the request that is already pending instead of a new one (`ApprovalStore.request`). Retries and lost answers no longer leave copies in Approvals for a person to deny. A call with other arguments, or after the site changed, still files its own request; a request voided by a site change ("the site changed after the approval was requested") is never reused.


---
title: "ADR-0015: Agent modes — how independently an agent works"
description: "Owners chose raw scopes when creating an agent key. Scopes say what an agent may touch, not how independently it works — the question an owner actually has, and the one IDE agents answer with permission modes (plan,…"
url: https://cloudhal.dev/docs/adr/0015-agent-modes
source: https://github.com/cloudhal-dev/cloudhal/blob/main/docs/adr/0015-agent-modes.md
---
# ADR-0015: Agent modes — how independently an agent works

- Status: Accepted
- Date: 2026-10-08
- Related: ADR-0011 (policy engine), ADR-0014 (approvals)

## Context
Owners chose raw scopes when creating an agent key. Scopes say *what* an agent may touch, not *how independently* it works — the question an owner actually has, and the one IDE agents answer with permission modes (plan, ask, accept edits, auto, bypass).

## Decision
Every agent principal has a **mode**, stored with its API key (metadata) and chosen by its owner; a mode never grants scopes, it narrows them and decides where a person approves:

| Mode | Content changes | Publish | Rollback / delete |
|---|---|---|---|
| `plan` | denied (read + preview only) | denied | denied |
| `manual` | each waits for a person | waits | waits |
| `edits` (default) | free, snapshotted | waits | waits |
| `auto` | free | **low-risk publishes go live without a person**; others wait | waits |
| `bypass` | free | free | free — **local stdio only** |

- **Low risk** (`checkAutoPublish`): only articles added or edited, nothing removed, no pages or site settings changed, at most 20 changes — measured against the live version (`SiteGraphStore.pendingChanges`, which ignores the publish pointer itself). Such publishes are audited as `auto mode: published without approval (…)`.
- **`bypass` never applies to API keys**: creating or switching a key to it is refused, and a remote principal claiming it is treated as `edits` (`effectiveMode`). `cloudhal mcp --mode bypass` is the only way to get it.
- Keys without a stored mode read as `edits` (the previous behaviour). Mode changes take effect on the agent's next call and are audited (`agent-key.mode`, `old → new`). Every audit row records the mode the call ran under (migration 0007).
- The MCP server tells the agent its mode in the server instructions so it can plan accordingly.

## Alternatives considered
| Alternative | Pros | Cons | Why not |
|---|---|---|---|
| Scopes only | Already there | Owners reason about autonomy, not scope lists | The UX question stays unanswered |
| Mode replaces scopes | One concept | Loses fine-grained limits (e.g. publish but never rollback) | Keep scopes as the hard limit, mode as policy |
| `auto` = publish everything | Simple | One bad call goes live | Low-risk rule + audit + one-click rollback instead |
| Remote `bypass` with expiry | Power users | A leaked key publishes at will | Local only for now |

## Consequences
- Positive: owners pick a behaviour they understand; agents learn their limits up front; the audit says which mode allowed what.
- Negative: `manual` makes agents slow (every write waits); `auto` publishes without a person by design.
- Follow-ups: time-boxed elevation ("auto for 1 hour"), proposals (plan mode leaving a draft change set for review), per-site modes.

## Addendum (2026-10-08): policy detail as data
Clients should not parse English reasons. Migration 0008 adds structured fields:

- **Approvals** record `mode` (the mode the agent worked in), `cause` (`tool` | `destructive` | `skill` | `manual-mode`: why a person must decide) and, for an `auto`-mode publish that the low-risk rule held back, `autoCheck`.
- **Audit rows** record `cause` on `approval-required` rows and `autoCheck` on any publish the `auto` rule looked at. `autoCheck = { rule: "low-risk-publish", lowRisk, code, changes, limit }`, where `code` is `low-risk` | `removes-content` | `nothing` | `structure` | `too-many`. An `allow` row with `autoCheck.lowRisk` is a publish that went live without a person. The English `reason` stays for agents and older readers.
- **`GET /api/instance`** reports `agentModes { modes, default, scopes, autoPublishLimit }`, so Studio applies the server's rules instead of a copy. Studio falls back to its own copy (and to reason text for older rows) when a server omits them.


---
title: "ADR-0016: Time-boxed elevation — a more autonomous mode, for a while"
description: "Owners pick a mode per key (ADR-0015). Often they want more autonomy only for a while: \"let the newsroom agent publish small updates on its own during launch day\", \"let it edit freely for the next hour while I watch\"."
url: https://cloudhal.dev/docs/adr/0016-time-boxed-elevation
source: https://github.com/cloudhal-dev/cloudhal/blob/main/docs/adr/0016-time-boxed-elevation.md
---
# ADR-0016: Time-boxed elevation — a more autonomous mode, for a while

- Status: Accepted
- Date: 2026-10-08
- Related: ADR-0015 (agent modes), ADR-0014 (approvals)

## Context
Owners pick a mode per key (ADR-0015). Often they want more autonomy only for a while: "let the newsroom agent publish small updates on its own during launch day", "let it edit freely for the next hour while I watch". Raising the key's mode and remembering to lower it again is error-prone; a forgotten `auto` key keeps publishing without a person.

## Decision
An owner can **elevate** an API key to a more autonomous mode for a bounded time. It ends by itself.

- **Stored with the key** (API key metadata, next to `mode`): `elevation = { mode, until, grantedBy, grantedAt, reason? }`.
- **Computed on every call.** `resolvePrincipal` checks the clock each time. While `now < until`, the agent's mode is the elevated one and the principal carries `elevation: { from, until }`. Afterwards the key's own mode applies again. Nothing has to run at expiry, and there is no window where a stale mode survives.
- **Bounds.** The elevated mode must be a remote mode (never `bypass`) more autonomous than the key's own mode. The duration is 5 minutes to 24 hours (`ELEVATION_MIN_MINUTES`, `ELEVATION_MAX_MINUTES`). A new elevation replaces the one in force. Changing the key's own mode keeps an elevation only while it is still higher.
- **API (people only):**
  - `POST /api/agent-keys/:id/elevation {mode, minutes, reason?}` grants an elevation.
  - `DELETE /api/agent-keys/:id/elevation` ends it early.
  - Summaries carry `mode` (the key's own), `elevation` (only while in force) and `effectiveMode`.
  - `/api/instance` reports the bounds under `agentModes.elevation`.
- **Audit.**
  - Granting is audited as `agent-key.elevate`, with reason `name: edits → auto until … (60 min): reason`.
  - Ending early is audited as `agent-key.elevation-end`.
  - Every agent call made while elevated records `elevation: { from, until }` (migration 0009).
  - Natural expiry is not an event. The next call simply runs in the key's own mode, and its audit row shows it.
- **The agent is told.** The MCP server instructions add that the mode is temporary, when it ends, and which mode follows.

## Alternatives considered
| Alternative | Pros | Cons | Why not |
|---|---|---|---|
| Owner changes the mode and changes it back | No new concept | People forget; a lingering `auto` key publishes unattended | The failure mode we want to remove |
| Scheduled job reverts the mode | Mode field stays the only truth | Needs a scheduler on every runtime (Node, Workers); stale until it runs | Computing on each call is simpler and exact |
| Separate short-lived elevated API key | Natural expiry via key `expiresAt` | Agent must be reconfigured with a new secret; two keys to manage | Owners want the same agent, just freer |
| Elevation to `bypass` for remote keys | Power users | A leaked key publishes at will, even if briefly | `bypass` stays local only (ADR-0015) |
| Elevation downwards (temporary restriction) | Incident response | Different intent; a permanent mode change already does it | Keep "elevation" meaning more autonomy |

## Consequences
- Positive: autonomy can be granted for exactly as long as it is needed, and it is revoked without anyone remembering to. The audit shows who granted what and which calls used it.
- Negative: the effective mode now depends on the clock. Tests inject `now` (`AuthOptions.now`). Clock skew between isolates is negligible at minute granularity.
- Follow-ups: email/webhook delivery of notifications (needs a mail/webhook capability).

## Addendum (2026-10-08): agents ask for an elevation
An agent that needs more autonomy for a task asks for it instead of failing or nagging in chat:

- **`request_elevation {siteId, mode, minutes, reason}`** (MCP). It files an approval with tool `request_elevation`, input `{mode, minutes, reason, keyId}` and a prompt quoting the reason, on that site's Approvals.
  - Only API-key agents on a server with approvals can use it. Local stdio is told to restart with `--mode`, and people are refused.
  - The same rules as a person's grant apply: more autonomous than the key's own mode, not already in force, never `bypass`, 5 minutes to 24 hours.
  - One open request per key: asking again returns the pending one.
  - It changes neither the site nor the key, so it is annotated read-only and also works in `plan` mode (a planner may ask to become `edits`).
- **`get_mode`** (MCP) returns:
  - the current and own mode;
  - the elevation in force;
  - which modes could still be requested;
  - the latest request's status (`pending`, `denied`, `expired`, or `consumed` = granted).
- **Granting is a person's act.** `POST /api/sites/:id/approvals/:id/approve` with an optional `{minutes}` grants the elevation with the approver's own rights. The approver may shorten the request but not extend it.
  - The key must still exist, belong to the approver, and still be able to take the elevation; otherwise the answer is 409 and the request stays pending.
  - The request is then marked `consumed`, since the agent has nothing to redeem.
  - The grant is audited as `agent-key.elevate` on the site, naming the request.
- **The agent learns the outcome** from `get_mode` or from its next call's instructions. Nothing it receives can grant the elevation.
- **Answers are data, not wording.** Readers never parse the English text:
  - The request's `input` carries the key's own mode (`ownMode`).
  - A granted request records what was granted, `grant: {from, mode, minutes, until}` (migration 0010), which may be shorter than asked. `get_mode` reports it as `latestRequest.granted`.
  - The grant's audit row carries `input: {requestId, keyId, minutes}`.
  - A 409 says which case applies: `conflict` when the request is no longer pending, and `elevation_conflict` when it is still pending but the key cannot take it.
  - Ending an elevation early is audited with its `elevation`.

## Addendum (2026-10-08): told before it ends
- **`GET /api/notifications`** (people only) is derived from the current state on every request. Nothing is stored and nothing runs on a schedule. It lists:
  - the owner's elevations ending within `ELEVATION_NOTICE_MINUTES` (10), as `elevation-ending` with `minutesLeft`;
  - agents' pending elevation requests for the owner's keys, as `elevation-request`.

  Items have stable ids (`elevation-ending:<key>:<until>`, `elevation-request:<approval>`), so a client shows each one once. Extending is simply a new elevation that replaces the one in force. The notice window is reported as `agentModes.elevation.noticeMinutes` in `/api/instance`.
- **The agent** sees `elevation.minutesLeft` and `elevation.endingSoon` in `get_mode`.
- Delivery beyond Studio (email, webhooks, push) is left for later. Studio polls and can raise browser notifications while it is open.


---
title: "ADR-0017: Taxonomy and redirects are graph nodes; content references terms by id"
description: "Taxonomy was one field: ContentEntry.tags, up to 50 free slugs. Tags had no name, description or archive; there were no categories, no hierarchy and no primary term."
url: https://cloudhal.dev/docs/adr/0017-taxonomy-and-redirects
source: https://github.com/cloudhal-dev/cloudhal/blob/main/docs/adr/0017-taxonomy-and-redirects.md
---
# ADR-0017: Taxonomy and redirects are graph nodes; content references terms by id

- Status: Accepted
- Date: 2026-10-08
- Related: RFC-0002 (the design; this ADR records the decision), RFC-0001, ADR-0001, ADR-0002, ADR-0014, ADR-0015
- Sources: Ghost source (TryGhost/Ghost `main` ad3d9ce: `posts_tags.sort_order`, primary-tag logic, internal tags), WordPress `register_taxonomy` and term tables, Contentful Taxonomy (SKOS concepts, locale-independent assignment), W3C SKOS Reference, Google Search Central (faceted navigation 2024-12, pagination, canonicalization, soft 404) — full list in RFC-0002 and the research behind it

## Context
Taxonomy was one field: `ContentEntry.tags`, up to 50 free slugs. Tags had no name, description or archive; there were no categories, no hierarchy and no primary term. Agents could not see the vocabulary, so near-duplicate tags were inevitable. A rename meant N entry writes and N snapshots. Changing a published slug broke its URL silently. Ghost (flat tags, primary = first public tag, no merge or redirects) and WordPress (shared terms that 4.2 had to split, no term redirects in core) show the failure modes to avoid.

## Decision
Adopt RFC-0002 as accepted, with the recommended answers to its open questions:

- **Nodes.** `Taxonomy` (`tax_`), `Term` (`term_`) and `Redirect` (`redir_`) are site-scoped graph nodes with `rev`, CAS and snapshots. The store uses an explicit node-kind registry instead of duck typing, and no document may be valid under two node schemas.
- **Assignment.** Entries reference terms by id, independent of locale: `terms: {[taxonomyKey]: {ids, primary?}}`. The primary term is explicit, never derived from order.
- **Default taxonomies.** `category` is hierarchical and required for review/published entries on new sites; only people create categories (`allowNewTerms: "none"`). `tag` is flat; agents propose tags (`"propose"`), and a proposed tag has no public URL until a person approves it.
- **Terms.** Names, synonyms (`altNames`), description and SEO are per locale. There is one slug per term (localized slugs come with locale routing). An optional `position` orders siblings.
- **Governance operations.** Rename, merge, retire, move and review are one `batch()` and one snapshot each. They are approval-gated over MCP and leave 301 redirects. Operations touching more than 400 entries are refused in v1.
- **Redirects.** They are created automatically when a published entry's slug or a published page's path changes, and when terms are renamed, merged or retired. Chains are collapsed and loops refused; agents cannot create external redirects. Retired terms without a redirect answer **410**. URL-changing nodes always count as "structure" in `auto` mode.
- **Archives.** Pagination is self-canonical. Archives below `minEntriesToIndex` are `noindex, follow` and left out of sitemaps; empty and proposed terms are 404. Category archives include sub-categories. There is one filter grammar for the API, MCP, `ContentList` and feeds.
- **Compatibility.** Every change is additive (`schemaVersion` stays 1). `tags → terms` is migrated with a revertible command. On existing sites `category.required` starts false, and no "Uncategorized" term is created.

## Alternatives considered
| Alternative | Pros | Cons | Why not |
|---|---|---|---|
| Ghost-style flat tags + "first tag is primary" | Simple, proven | Primary depends on order and visibility; no hierarchy, merge or synonyms | Publishers expect categories; agents need a governed vocabulary |
| WordPress-style shared terms across taxonomies | Fewer rows | Shared terms had to be split in WP 4.2 | One (term, taxonomy) pair, never shared |
| Terms embedded in the Site node | One node to read | Every term edit rewrites the site; no per-term rev, CAS or snapshot diff | Conflicts and noisy diffs |
| SKOS polyhierarchy now | Most expressive | Harder UI, invariants and archives | `parentId` now; the shape allows `broader[]` later |

## Consequences
- **Positive:**
  - A rename changes one node.
  - Agents see and reuse the vocabulary.
  - URL changes leave redirects.
  - Archives are SEO-safe by default.
  - Approvers see taxonomy deltas explicitly.
- **Negative:**
  - Three new node kinds across store, sync, renderer, MCP and Studio.
  - Cross-node invariants need batch-guarded SQL (an `op_guards` assertion row, migration 0011).
  - `tags` lingers, deprecated, for one release.
- **Risks:**
  - Guard statements must stay within D1's bound-parameter limits (entries are capped at 60 term ids).
  - Very large merges are refused until a staged job exists.


---
title: "ADR-0018: Staff roles are scope sets; agent keys never exceed their creator; invites are one-time links"
description: "Every signed-in person had scopes, and sign-up closed after the owner, so a team could not be added safely. The scopes model already treats people and agents alike, and agent keys, modes, elevations and approvals…"
url: https://cloudhal.dev/docs/adr/0018-roles-and-invites
source: https://github.com/cloudhal-dev/cloudhal/blob/main/docs/adr/0018-roles-and-invites.md
---
# ADR-0018: Staff roles are scope sets; agent keys never exceed their creator; invites are one-time links

- Status: Accepted
- Date: 2026-10-08
- Related: RFC-0005 (the design; this ADR records the decision), ADR-0004, ADR-0010, ADR-0011, ADR-0014, ADR-0015, ADR-0016, ADR-0017
- Sources: Better Auth 1.7.7 organization and admin plugins (as installed, and https://www.better-auth.com/docs/plugins/organization, https://www.better-auth.com/docs/plugins/admin); Ghost staff roles (https://ghost.org/help/managing-your-team/, https://docs.ghost.org/staff)

## Context
Every signed-in person had `*` scopes, and sign-up closed after the owner, so a team could not be added safely. The scopes model already treats people and agents alike, and agent keys, modes, elevations and approvals (ADR-0014–0016) need to respect who created a key and who may decide a request. REST also accepted an entry's `author` from the request body.

## Decision
Adopt RFC-0005 as accepted, including its recommended answers:
- **Roles are named scope sets.** Owner, Administrator, Editor, Author and Contributor map onto the policy engine's scopes. "Own" variants exist for Authors and Contributors: `content:draft:own`, `content:write:own` and `site:publish:own`. Some scopes are people-only and never grantable to a key: `site:settings`, `members:*`, `agents:manage` and `audit:read`.
- **Agent keys never exceed their creator.** At creation a key's scopes must fit the creator's role. On every call the key's effective scopes are cut down to the creator's current role, and a key whose creator is no longer a member is refused. Demotion lowers keys, modes and elevations at once; removal disables keys and denies their pending requests. Only a key's creator grants it elevations.
- **Approvals follow roles.** Only someone who could make the same call now may decide a request; nobody approves their own person-filed request. A person may approve their own agent's request by default.
- **Author publishes.** Publishing moves one pointer per site, so an Author's publish goes live only when every public change is the Author's own. Otherwise it becomes an approval request (cause `role`) for an Editor.
- **Membership storage.** Membership lives in Better Auth's organization plugin, one organization per deployment now and one per tenant in Cloud. Its raw HTTP endpoints are blocked, so every membership change goes through Cloudhal's audited routes. There is exactly one Owner. Ownership moves by transfer with password re-entry; `cloudhal owner transfer` with the deployment token recovers a locked-out deployment.
- **Invites are one-time links.** A link is `chinv_` plus 256 random bits, stored as a SHA-256 hash and carried in the URL fragment. It is bound to one role and one email and is revocable. It expires after 7 days by default (range 1 h–14 d; Administrator invites at most 72 h). Sign-up stays closed, and accepting an invite creates the account server-side. A pluggable mail sender can deliver links later.
- **REST is default-deny.** Every route declares its requirement in a route table, and a test fails for any route that does not. `author` is server-controlled: only Editors and above may set or change ownership fields. `open` sign-ups become Contributors.

## Alternatives considered
| Alternative | Pros | Cons | Why not |
|---|---|---|---|
| Keep "every person is `*`" until Cloud | No work now | No safe team, no Contributor flow, approvals by anyone | P0 publisher gap; agents amplify a too-broad human role |
| A Cloudhal-only roles table | Full control | Duplicates Better Auth membership; Cloud migration later | Better Auth org tables exist and map to tenants |
| Per-post publish for Authors | Familiar from Ghost | Breaks the single-pointer snapshot model and atomic rollback | Change-set rule keeps the model and still lets Authors publish their own work |
| Email-only invites | Familiar | No mail infrastructure yet | Links now, a pluggable sender later |

## Consequences
- **Positive:**
  - Teams can be added safely.
  - Agents inherit their creator's limits, and demotions take effect on the next call.
  - Approvals cannot be decided by someone who could not make the change.
  - Every membership change is audited.
- **Negative:**
  - Every REST route needs a declared requirement.
  - "Own" checks add reads on writes and publishes.
  - The single-pointer model makes Author publishes conditional.
- **Risks:**
  - Invite-link leakage: links are bound to an email, single use, expiring and revocable.
  - A misconfigured route table: the default-deny test guards against it.
  - Effective scopes recomputed on every call: they are cached per request only.


---
title: "ADR-0019: Menus are Menu nodes holding a tree of id-referenced items"
description: "The header was derived from top-level pages. There was no editor, no footer menu, and no tree that a docs sidebar could reuse. Links typed as URLs break when slugs change."
url: https://cloudhal.dev/docs/adr/0019-navigation-menus
source: https://github.com/cloudhal-dev/cloudhal/blob/main/docs/adr/0019-navigation-menus.md
---
# ADR-0019: Menus are Menu nodes holding a tree of id-referenced items

- Status: Accepted
- Date: 2026-10-08
- Related: RFC-0003 (the design), RFC-0001 (open question on menus), ADR-0017 (terms, redirects), ADR-0018 (roles: menus are Editor and above)
- Sources: Ghost primary/secondary navigation (https://ghost.org/help/changing-the-navigation/); docs-sidebar needs from the docs research (Starlight, Docusaurus, Nimbus)

## Context
The header was derived from top-level pages. There was no editor, no footer menu, and no tree that a docs sidebar could reuse. Links typed as URLs break when slugs change.

## Decision
Adopt RFC-0003 with its recommended answers:
- **Menu nodes.** Each named menu (`primary`, `footer`, later `sidebar`, or custom) is a **Menu node** (`menu_`) with `rev`, CAS and snapshots. It holds an ordered tree of items as nested arrays, so cycles are impossible.
- **Items.** Item kinds are `page`, `contentEntry` and `term` (all by id), plus `collection`, `url` (https only) and `group`. Labels are per locale and fall back to the target's title.
- **Missing targets.** Items whose target is gone, unpublished, retired or empty are hidden with their subtree on the site and flagged in Studio. The menu itself is never rewritten.
- **Depth.** Default `maxDepth` is primary 3, footer 2 and sidebar 6, with a hard cap of 6. core-ts enforces it, because a recursive schema cannot count levels.
- **Agent writes.** `update_menu`, `set_menu` and `delete_menu` need `site:write` (Editor and above) and a person's approval in every mode. A menu change makes a publish "structure" in `auto` mode, and approval prompts show the delta.
- **Rendering.** The renderer resolves links at render time, picks the active item, and derives breadcrumbs and prev/next from the same tree. Sites without a menu keep today's derived header exactly.

## Alternatives considered
| Alternative | Pros | Cons | Why not |
|---|---|---|---|
| A `navigation` field on Site | One node | Every menu edit rewrites the site; noisy diffs, CAS conflicts with settings | Separate nodes, like terms |
| Links as URLs | Simple | Break on slug changes and redirects | Ids survive renames |
| Parent pointers per item node | Fine-grained | Cycles possible; many tiny nodes | Nested arrays in one node |

## Consequences
- **Positive.** Menus survive renames, docs sidebars reuse the same primitive, and menu changes are reviewable and reversible.
- **Negative.** Another node kind across the store, sync, renderer, MCP and Studio. Kumo has no tree component, so Studio needs a `MenuTree` block.
- **Risk.** Hidden items can go unnoticed. Studio flags them, and the publish prompt lists menu effects.


---
title: "ADR-0020: Assets are content-addressed nodes with SHA-256; uploads are SSRF-safe; scopes asset:upload / asset:manage"
description: "Images were URLs typed by hand. There was no upload, no library, and no way to serve responsive images. Scholarly and data sites (the third vertical) need checksums and external storage for large files."
url: https://cloudhal.dev/docs/adr/0020-assets-and-media
source: https://github.com/cloudhal-dev/cloudhal/blob/main/docs/adr/0020-assets-and-media.md
---
# ADR-0020: Assets are content-addressed nodes with SHA-256; uploads are SSRF-safe; scopes asset:upload / asset:manage

- Status: Accepted
- Date: 2026-10-08
- Related: RFC-0004 (the design), RFC-0001 (open question on media), ADR-0002 (BlobStore), ADR-0012 (R2, Workers Cache), ADR-0018 (roles and the asset scopes)
- Sources: Cloudflare R2 and Image Resizing (`/cdn-cgi/image`) docs, OWASP SSRF prevention, research `.internal/research/12b` (scholarly: checksums and external storage; private)

## Context
Images were URLs typed by hand. There was no upload, no library, and no way to serve responsive images. Scholarly and data sites (the third vertical) need checksums and external storage for large files.

## Decision
Adopt RFC-0004 with its recommended answers:
- **Asset nodes.** An **Asset node** (`asset_`) carries kind, sniffed media type, bytes, **SHA-256**, storage (blob or external), image metadata, per-locale title/alt/caption, credit, license, status, uploader and source.
- **Content addressing.** Bytes are content-addressed and deduplicated per site, under a derived key (`media/<siteId>/<sha256>`) that is never stored in the document.
- **References.** Content refers to assets by id: `ImageRef.assetId` and markdown `asset:<id>`. `src` is kept for back-compat.
- **Retention.** Snapshots record the blobs they use. GC uses leases and never removes bytes that a snapshot needs. A takedown answers 410/451.
- **Uploads.** People upload through a signed ticket and a streamed PUT. Agents use `upload_asset` (≤ 4 MB) and `fetch_asset_url` (≤ 25 MB). Fetching has full SSRF protection: https only, no IP literals or internal names, resolved-address checks and pinning, re-checked redirects, size and time caps, type agreement.
- **Sanitisation.** EXIF and GPS are stripped by default (people may opt out). SVG is validated and rejected rather than sanitised. Malware scanning is out of scope.
- **Serving.** Assets are served from `/media/<sha16>/<slug>.<ext>` with immutable caching, Range support and `.sha256` files. Responsive images go through `/cdn-cgi/image`, with an offline fallback; transformations are opt-in until verified on a real account.
- **Scopes (per ADR-0018).** `asset:upload` covers Contributor and above, for their own uploads. `asset:manage` covers Editor and above.
- **`auto` mode.** In `auto` mode, a new raster image that only rides along with its article goes out with that publish; every other asset change waits for a person.

## Alternatives considered
| Alternative | Pros | Cons | Why not |
|---|---|---|---|
| Keep URLs, no asset nodes | No work | No library, no checksums, no responsive images, broken hotlinks | P0 gap |
| Multipart uploads through the Worker | Familiar | `formData()` buffers whole files in Worker memory | Streamed PUT with a signed ticket |
| Sanitise SVG | Accept more files | Sanitisers have a long bypass history | Validate-and-reject; serve sandboxed |
| Path-based blob keys | Readable | Duplicates, rename churn, path traversal surface | Content addressing |

## Consequences
- **Positive.** Uploads, a library, deduplication, safe agent fetching, checksums for data sites, and immutable caching.
- **Negative.** It needs GC with leases, image-metadata stripping without re-encoding, and an SSRF-safe fetcher on Workers and Node.
- **Risks.** SSRF bypasses (covered by tests over resolution and redirects), quota abuse (caps per call and per site), and takedown obligations (410/451 path).


---
title: "ADR-0021: Site analytics — first-party cookie-less script, Workers Analytics Engine via bindings, D1 rollups, analytics:read"
description: "Editors and agents cannot see what readers read. Most page views on a deployed site are served from Workers Cache, where the site Worker does not run, so counting in the Worker sees only cache misses."
url: https://cloudhal.dev/docs/adr/0021-site-analytics
source: https://github.com/cloudhal-dev/cloudhal/blob/main/docs/adr/0021-site-analytics.md
---
# ADR-0021: Site analytics — first-party cookie-less script, Workers Analytics Engine via bindings, D1 rollups, analytics:read

- Status: Accepted
- Date: 2026-10-09
- Related: RFC-0006 (the design), ADR-0012 (Cloudflare deployment; "no Cloudflare API token handling"; Workers Cache), ADR-0011 / ADR-0015 (MCP, policy, modes), ADR-0018 (roles and scopes), ADR-0002 (batch-only storage)
- Sources: Cloudflare Workers Analytics Engine (limits, pricing, sampling), Analytics SQL API and its Workers binding, Workers Cache, D1 limits (all seen 2026-10-09, listed in RFC-0006); a spike on the maintainer's live account (2026-10-09); research `.internal/research/13-analitik.md` (private)

## Context
Editors and agents cannot see what readers read. Most page views on a deployed site are served from Workers Cache, where the site Worker does not run, so counting in the Worker sees only cache misses. Many deployments run on Workers Free with only a `*.workers.dev` hostname, and Cloudhal keeps no Cloudflare API token (ADR-0012). D1 Free's daily write budget is shared with the CMS and must not hold raw events.

## Decision
Adopt RFC-0006 with the owner's decisions of 2026-10-09:
- **Collection.** A first-party, cookie-less script of about 1 KB, served as a hashed static asset from the site's own origin, posts page views (and sampled Core Web Vitals) to `POST /__cloudhal/e` on the site Worker. CSP stays `'self'`. This ends the principle "sites ship no client scripts of their own": a site ships exactly this one script, and it can be switched off per site. The Worker records redirects, 404, 410 and 5xx itself. The browser never sends a node id; the server resolves the path against the published site.
- **Privacy.** Visits (referrer-based) by default, no personal data. Unique visitors are opt-in: a hash of IP and user agent with a random daily salt that is deleted daily. GPC/DNT: counted, never hashed. Only normalised `utm_source`, `utm_medium` and `utm_campaign` survive from query strings; no IP, user agent, full referrer or city is stored. The contracts `AnalyticsEvent` and `AnalyticsBeacon` enforce this.
- **Storage.** Layer L0 on Cloudflare: raw events in Workers Analytics Engine (one dataset per deployment, `index1 = siteId`), read by the admin Worker through the **Analytics SQL binding without an API token**; an hourly cron rolls finished days into D1 (`analytics_daily`), kept indefinitely by default. Layer L3 locally and on Node: the same model on SQLite behind one Worker-safe interface in `@cloudhal/analytics`. Per-site settings live in D1, not in the site graph (a content rollback must not change privacy settings).
- **Cost guard.** Queries are bounded by time and site, cached for 60 s and kept under a daily budget (default 8,000); Studio warns Free accounts approaching 100,000 requests/day.
- **Access.** New scope `analytics:read` for Author and above and in the default scopes of new agent keys; settings need Administrator. REST routes are declared in `ROUTE_SCOPES`; MCP tools `get_analytics`, `top_content`, `content_stats`, `broken_links` are read-only.
- **Deferred to v1.1 (own ADR):** an optional read-only token for Cloudflare Web Analytics and zone analytics.

## Alternatives considered
| Alternative | Pros | Cons | Why not |
|---|---|---|---|
| Count in the site Worker only | No client script | Misses every cached page view | Numbers would be wrong by an order of magnitude |
| Uncached front ("gateway") Worker | Exact counts, no script | Every view costs two requests; halves Free capacity | Too expensive on Free |
| Cloudflare Web Analytics only | Zero code, vitals | No content ids, no uniques, manual snippet on workers.dev, needs a token to read, ad-blocked | Kept as v1.1 add-on |
| Raw events in D1 | One store | Shares and exhausts D1 Free's daily writes, taking the CMS down | Rollups only |
| Third-party (Plausible, Umami) | Mature | Another service, not agent-native, data leaves the deployment | Against the self-contained deployment |

## Consequences
- **Positive.** Accurate, cookie-less analytics on every plan including 100% free workers.dev deployments, without API tokens; per-article stats that survive slug changes; agents can see what performs.
- **Negative.** Each page view costs one extra Worker request (Free sites serve roughly 20–35% less traffic); a client script on every published page; WAE keeps raw data for 3 months only (rollups cover the long term).
- **Risks.** Beacon spam and inflation (same-origin checks, size caps, bot filtering, per-isolate limits, published-path resolution); the binding's availability and quota accounting on Workers Free are assumptions until the rollout's step 0 closes them (fallback: data is recorded and reading waits for the v1.1 token ADR).


---
title: "ADR-0022: Reader comments — guest comments held for moderation, D1 outside the graph, first-party proof of work, agents label and hide while people approve"
description: "Sites have no way for readers to respond. Comments are the first free-form visitor text Cloudhal stores and the first that reaches agents, so they bring spam, privacy and prompt-injection risk."
url: https://cloudhal.dev/docs/adr/0022-reader-comments
source: https://github.com/cloudhal-dev/cloudhal/blob/main/docs/adr/0022-reader-comments.md
---
# ADR-0022: Reader comments — guest comments held for moderation, D1 outside the graph, first-party proof of work, agents label and hide while people approve

- Status: Accepted
- Date: 2026-10-12
- Related: RFC-0007 (the design), ADR-0012 (Workers Cache, cache tags, purge through the site Worker), ADR-0014 (out-of-band approvals), ADR-0015 (agent modes), ADR-0018 (roles and scopes), ADR-0021 (site analytics: the pattern for visitor-facing, privacy-sensitive features)
- Sources: those listed in RFC-0007 (Cloudflare Workers Cache, purge limits, Turnstile, Rate Limiting, Workers AI, D1 limits and jurisdictions; EmDash, Ghost, WordPress, Substack, Isso, Remark42, ALTCHA; GDPR, DSA, KVKK, Law 5651); research `.internal/research/14-yorumlar.md` (private); a live check on 2026-10-12 that Workers Cache serves `cf-cache-status: HIT` on the deployed workers.dev site

## Context
Sites have no way for readers to respond. Comments are the first free-form visitor text Cloudhal stores and the first that reaches agents, so they bring spam, privacy and prompt-injection risk. Pages are rendered from immutable snapshots and served from the edge cache; D1's Free daily write budget is shared with the CMS; the CSP is `'self'`; agents act through MCP under the policy engine.

## Decision
Adopt RFC-0007 with the owner's decisions of 2026-10-12 and its reviewed defaults:
- **Identity (MVP).** Guest comments: a name and an optional private email; every guest comment is `pending` until a person approves it. Verified readers (email code or magic link through a second Better Auth instance with host-only cookies, Better Auth ≥ 1.7.7) follow in v1.1 as the base for members.
- **Storage.** D1 tables in a new `@cloudhal/comments` package (migration `0016_comments`), keyed by entry id, **outside the snapshot graph**: snapshots are immutable (erasure), and a content rollback must not touch readers' comments. Settings per site, collection and entry live in D1. Comments are off by default; threads close 90 days after first publication.
- **Rendering.** Approved comments are server-rendered into the cached article page and tagged `cloudhal-comments-<nodeId>`; moderation purges that tag through the existing purge path, batched inside the shared purge budget (hides and takedowns first). A plain-HTML form posts to `/__cloudhal/comments`; a small first-party module under `/_cloudhal/` enhances it on open threads only. The script rule becomes "only first-party Cloudhal scripts under `/_cloudhal/`".
- **Anti-spam before any D1 write:** enabled and open thread, same origin, size, schema, honeypot with a fake "pending" answer, first-party **proof of work** (signed stateless challenges, single use; CSP stays `'self'`), rate limits, content rules, a per-site daily cap, bans. Turnstile is opt-in per site and widens the CSP only on comment pages. Workers AI labels are opt-in and advisory.
- **Privacy.** No IP stored, only a hash with a monthly salt; emails encrypted and visible to Editor+ only (person-only `commenters:data`); pending and spam comments deleted after 30 days; erasure anonymises by default and may delete the text, audited; "Report comment" and statement-of-reasons records (DSA Art. 16/17).
- **Agents.** Scopes `comments:read` (default for new keys), `comments:moderate(:own)`, `comments:reply(:own)`. Agents may label and hide (reversible); approving comments, publishing replies and reopening threads wait for a person in every mode; no hard delete or bans over MCP. Comment text reaches agents only as labelled untrusted data with output caps; no tool acts on comment text. Comment approvals bind to the exact comments and their state, not the site-graph hash.

## Alternatives considered
| Alternative | Pros | Cons | Why not |
|---|---|---|---|
| Third-party embed (Disqus, Hyvor) | No work | Tracking, data leaves the deployment, third-party script and iframe | Privacy and CSP |
| GitHub-backed (Giscus, Utterances) | No storage | Readers need GitHub; third-party iframe | Excludes most readers |
| Comments in the snapshot graph | One store, versioned | Erasure impossible in immutable snapshots; rollbacks would rewrite readers' words; publish churn | Wrong lifecycle |
| Client-side rendering only | Simple purge story | No SEO, no JS-less reading, an extra request per view | SSR with tag purge works on our cache |
| Turnstile by default | Strong bot signal | Third-party script/iframe, CSP widening, privacy addendum | Opt-in instead |

## Consequences
- **Positive.** Native, private, cache-friendly comments on every plan; moderation that agents can help with safely; DSA/GDPR/KVKK tooling built in.
- **Negative.** A moderation queue people must work; more D1 writes (~12–15 rows per comment over its life); purge budget shared with publishing; a second first-party script.
- **Risks.** Spam beyond proof of work (rate limits, caps, opt-in Turnstile/AI); prompt injection through comment text (untrusted rendering, caps, no comment-driven tools); purge budget exhaustion (queue, back-off; step-0 verification); the step-0 live checks on a Workers Free account are still open.


---
title: "ADR-0023: Schedules — content releases pinned to exact versions, render-time time gates, one every-minute tick, deterministic jobs plus MCP agent tasks"
description: "Editors need to publish at a set time, keep content under embargo, take campaigns down on a date and run recurring checks; agents need a safe way to propose these and to do recurring work unattended."
url: https://cloudhal.dev/docs/adr/0023-schedules
source: https://github.com/cloudhal-dev/cloudhal/blob/main/docs/adr/0023-schedules.md
---
# ADR-0023: Schedules — content releases pinned to exact versions, render-time time gates, one every-minute tick, deterministic jobs plus MCP agent tasks

- Status: Accepted
- Date: 2026-10-12
- Related: RFC-0008 (the design), ADR-0002 (batch-only storage), ADR-0011 (agent layer, policy engine), ADR-0012 (two Workers, pointer-flip publishing, Workers Cache and purge through the site Worker), ADR-0014 (approvals bound to state), ADR-0015 (agent modes), ADR-0016 (time-boxed elevation), ADR-0018 (roles and scopes), ADR-0021 and ADR-0022 (the hourly jobs this replaces, the shared purge budget, `cm1:` bindings)
- Sources: those listed in RFC-0008 (Cloudflare Cron Triggers, `scheduled()`, Durable Object alarms, Workers/D1/DO/Queues/Workflows limits and pricing, Workers Cache; RFC 5545/7529, `temporal-polyfill`, IANA tzdb, CLDR week data; WordPress, Ghost, Contentful, Sanity, Strapi, Storyblok, Payload, Drupal Scheduler; Google Calendar, Linear, GitHub Projects, Claude Code routines, GitHub Agentic Workflows, Zapier, n8n, Make; the lethal trifecta, CaMeL, OWASP LLM06; WCAG 2.2 SC 2.5.7/2.5.8, WAI-ARIA APG); the maintainer's private research notes (2026-10-12)

## Context
Editors need to publish at a set time, keep content under embargo, take campaigns down on a date and run recurring checks; agents need a safe way to propose these and to do recurring work unattended. Cloudhal publishes by composing a whole-site snapshot and flipping a pointer (ADR-0012), so a snapshot prepared now and flipped later would revert everything published in between and its approval would be voided by any edit. The admin Worker already has one hourly cron slot (`17 * * * *`) used by the analytics rollup and the comments job, with `CLOUDHAL_*_CRON=off` request-time fallbacks where no slot is free. Workers Free allows 5 cron triggers per account, 10 ms CPU and 50 D1 queries per invocation; D1 has no interactive transactions; Workers and Node 24 have no native `Temporal` at the pinned versions.

## Decision
Adopt RFC-0008 with the owner's four decisions of 2026-10-12 and its defaults D1–D15 as drafted:
- **Releases are content-level change sets.** A release pins node versions (`id@rev:hash`) together with the live base it was made against and is applied **on top of whatever is live** at its time T, in one guarded D1 batch. A single scheduled publish is a one-item release. If a pinned node or its base changed, the release is **held as a conflict** with a diff. Approval binds to an `rl1:` hash of exactly those versions and the time; a person who may approve keeps it when moving the release, an agent's move or any item change voids it; an unapproved release **expires** at its time. Scheduled nodes are **locked** and kept at their live version by ordinary publishes. Rights are re-checked when the release runs. Releases run one at a time per site, in time order.
- **Embargo and expiry are time gates** (`availability.from/until` on entries and pages), checked by one helper on every public path at render time; the edge-cache lifetime is capped at the next gate, embargoed content is absent from lists, feeds, sitemap, search output and Open Graph until `from`, its assets inherit the gate, and previews still show it with a notice. Gates hold to the minute with no scheduler and no purge. Writers refuse `availability` until the site Worker confirms it enforces gates.
- **Missed runs.** A release runs late at most once and only within **one hour** of its time; later it becomes `missed`, with a notification and "Publish now". Recurring jobs run only the latest missed occurrence.
- **One tick.** The existing cron slot becomes `* * * * *`; the analytics and comments jobs move behind D1 watermarks (`system_jobs`) in the same handler. Where Cloudflare refuses a cron slot, a single `SchedulerClock` Durable Object alarm ticks instead (`CLOUDHAL_SCHEDULER = cron | alarm`, replacing `CLOUDHAL_*_CRON=off`). Node runs the same `runDue` on a ≤ 60 s timer with catch-up on start. Deterministic run ids, leases and guarded batches give at most one effect per occurrence; a heartbeat raises "scheduler stalled" in every mode, and admin requests act as a backstop.
- **Time model.** Wall-clock time plus an IANA zone (`Site.timeZone`, absent = UTC), the instant recomputed and never early; a DST gap moves forward, an overlap runs once at the first; an RRULE subset with presets; `temporal-polyfill` with an injected clock, never `Temporal.Now`.
- **Automations v1** are built-in **deterministic jobs** (broken-link sweep, stale-content review, feed and sitemap checks, weekly digest, cleanup) plus **scheduled agent tasks** that external agents perform over MCP with a per-automation key that works only inside an open run window, narrowed to its owner's role and capped by a mode cap that is never `bypass`. Budgets per run and a per-site daily write cap; three consecutive failures pause; kill switches per automation, per site and per deployment. Once a run reads untrusted text it may only read, label and hide, and automations declared to read untrusted text cannot hold write scopes. **No language model runs inside Cloudhal.**
- **Policy.** New scopes `schedules:read` (default for new keys), `schedules:write(:own)`, `releases:publish(:own)` (implied by `site:publish`) and person-only `automations:manage`; a new `publish` call class; proposals from `manual` mode up; `auto`-mode releases only within the key's elevation window or 24 h; lifting an embargo is Editor+ with a reason, a person in every mode; creating and resuming automations is people-only in Studio.
- **Contracts.** `Site.timeZone`, `availability` on `ContentEntry` and `Page`, and three roots `Release`, `Automation`, `ScheduleRun`; storage in migration `0017_schedules`; a Worker-safe `@cloudhal/schedules` package.

## Alternatives considered
| Alternative | Pros | Cons | Why not |
|---|---|---|---|
| Whole-site snapshot prepared now, flipped at T | Trivial executor | Reverts intervening publishes; approval voided by any edit | Wrong for pointer-flip publishing |
| Publish the latest version at T (Strapi) | No pinning | Publishes edits nobody approved | Pinned versions |
| Releases only (no gates) | One mechanism | Embargo depends on the scheduler; purge at T | Gates hold without a scheduler |
| Cloudflare Workflows per schedule | Durable sleep, retries | No Node equivalent; still needs D1 lists; Free step limits | v2 candidate behind the same `Scheduler` |
| Queues with delays | Managed delivery | 24 h maximum delay, no withdrawal | Not a scheduler |
| WP-Cron style (run on requests) | No infrastructure | Cached views never reach the Worker | Kept only as the backstop |
| LLM inside Cloudhal | Turnkey automations | Secrets, cost and the full prompt-injection burden on us | Non-goal |

## Consequences
- **Positive.** Exact, reviewable scheduled publishing that survives unrelated edits; embargoes that cannot leak through a late scheduler; one ticker on every plan and on Node; agents can propose and run recurring work without unattended publish rights.
- **Negative.** A minute tick costs ~1,440 requests a day (1.4 % of Free); ordinary publishes and edits must respect holds and locks; every public render path must call the gate helper; a code downgrade is unsafe while releases or gates exist (the deploy refuses without `--force-downgrade`).
- **Risks.** Cron delivery delay and DO alarm precision, Workers Cache honouring short lifetimes, release composition within Free's 10 ms CPU and 50 queries, and cron/alarm behaviour after Free's daily request limit are verified live in rollout step 0 (RFC-0008 L1–L9), with the fallbacks the RFC names; a forgotten theme route could leak an embargo (route sweep test, feed self-check).


---
title: "ADR-0024: Snapshot bundles — the site Worker loads a published snapshot from a few shard objects"
description: "The site Worker renders a published snapshot. Its node bodies live in R2 as nodes/<hash>.json, and SnapshotSource read them one get each on the first render of a snapshot in an isolate: after every cold start and after…"
url: https://cloudhal.dev/docs/adr/0024-snapshot-bundles
source: https://github.com/cloudhal-dev/cloudhal/blob/main/docs/adr/0024-snapshot-bundles.md
---
# ADR-0024: Snapshot bundles — the site Worker loads a published snapshot from a few shard objects

- Status: Accepted
- Date: 2026-10-13
- Related: RFC-0010 (the design), ADR-0002 (storage interface), ADR-0012 (pointer-flip publishing, snapshot sources cached per isolate), RFC-0004 (blob keys), RFC-0008 §6.1 (its subrequest note, corrected)
- Sources: Workers limits, "Subrequests" and "Simultaneous open connections" — https://developers.cloudflare.com/workers/platform/limits/ · R2 object lifecycles — https://developers.cloudflare.com/r2/buckets/object-lifecycles/ (both checked 2026-10-13 in `cloudflare/cloudflare-docs` at `0cd4753`)

## Context
The site Worker renders a published snapshot. Its node bodies live in R2 as `nodes/<hash>.json`, and `SnapshotSource` read them one `get` each on the first render of a snapshot in an isolate: after every cold start and after every publish. A Worker invocation has at most 6 connections waiting for response headers, so a 1,000-node site needed about 167 sequential rounds before its first byte; and R2/D1 calls are subrequests to internal services, capped at 1,000 per invocation on Workers Free (the configured limit, default 10,000, on Paid), so a site near 1,000 nodes could not render on Free at all. RFC-0008 §6.1 had assumed 50, which is the Free limit for external `fetch` only.

## Decision
Adopt RFC-0010 with its defaults, accepted by the owner on 2026-10-13 (PR #23):
- **Layout.** A published snapshot also gets `bundles/<siteId>/<snapshotId>/<i>.json`: shard *i* holds the bodies of `snapshot.nodes[i·256 … i·256+255]` in the snapshot's order, with their hashes and the snapshot id. No manifest and no contract change: the shard count follows from the number of refs. `nodes/<hash>.json` stays the source of truth.
- **Writers.** `createSnapshot` with reason `publish` writes the shards from the bodies it already holds. The site Worker writes them read-through, in `waitUntil`, when it renders the **published** snapshot without a complete bundle (release runs, rollback, push, expiry). Previews and history snapshots never get a bundle.
- **Reader.** `SnapshotSource` reads all shards (at most 6 in flight) and uses them only if every shard exists and its `v`, `snapshotId` and hashes match the snapshot's refs; otherwise it loads node by node, as before.
- **Retention.** `cloudhal deploy` adds an R2 lifecycle rule expiring `bundles/` after 30 days; a missing bundle costs one slow load, never correctness.
- **Not in v1:** shard compression; pre-warming bundles from the admin Worker after release runs or rollbacks.

## Alternatives considered
| Alternative | Pros | Cons | Why not |
|---|---|---|---|
| Per-isolate cache of bodies by hash | Small change | Cold isolates still read every node | Helps warm isolates only |
| Bodies in a D1 table, one query | One subrequest, dedupe | Every node version of every site in D1 (500 MB per database on Free); rows read billed per cold load | Wrong home for history |
| KV or Cache API per node | Simple | Still one subrequest per node | Same round count |
| One unsharded bundle | Simplest | One large parse in a 128 MB isolate | Shards bound peaks and retries |
| Raise `limits.subrequests` | One line | Paid only; latency unchanged | Treats the ceiling, not the cause |

## Consequences
- **Positive:** a cold or just-published site loads in `ceil(N/256)` reads, one round up to 1,536 nodes; sites no longer hit the Free subrequest ceiling by size; no migration and no contract change.
- **Negative:** R2 storage grows by the bodies of what was published in the last 30 days; one more object family to reason about.
- **Risks:** a writer bug producing a wrong shard is caught by the hash check and falls back; a missing lifecycle rule (wrangler or permissions) only means bundles live until deleted.


---
title: "ADR-0025: Email delivery — one MailSender on the Cloudflare Email Service binding"
description: "MailSender has existed since RFC-0005 without an implementation, so invites were copied by hand and only an Administrator could reset a password; an Owner who forgot theirs had nobody to ask."
url: https://cloudhal.dev/docs/adr/0025-email-delivery
source: https://github.com/cloudhal-dev/cloudhal/blob/main/docs/adr/0025-email-delivery.md
---
# ADR-0025: Email delivery — one MailSender on the Cloudflare Email Service binding

- Status: Accepted
- Date: 2026-10-13
- Related: RFC-0011 (the design), RFC-0005 §7.2 and open question 6 (emailed invites, reset links), RFC-0007 D1 (mail adapters), ADR-0012 (two Workers)
- Sources: Email Service Workers API, send bindings, pricing, limits and allowed headers — https://developers.cloudflare.com/email-service/ (checked 2026-10-13 in `cloudflare/cloudflare-docs` at `0cd4753`)

## Context
`MailSender` has existed since RFC-0005 without an implementation, so invites were copied by hand and only an Administrator could reset a password; an Owner who forgot theirs had nobody to ask. Email Service sends from a Worker through a `send_email` binding: no API key, no second vendor, no external `fetch`. Sending to any recipient needs Workers Paid (3,000 mails a month included); on Free only verified destination addresses of the account can receive.

## Decision
Adopt RFC-0011 with its defaults, accepted by the owner on 2026-10-13 (PR #24):
- **Switch.** An optional `mail: {from, fromName?}` in `cloudhal.json`. `cloudhal deploy` gives the admin Worker a `send_email` binding locked to that one address (`allowed_sender_addresses`) and the `CLOUDHAL_MAIL_FROM(_NAME)` vars. No key: no binding, link delivery only.
- **Sender.** `cloudflareEmailSender` in `@cloudhal/cloudflare`: plain text, `Auto-Submitted: auto-generated`, CR/LF stripped from subjects, bodies never logged, failures as `MailError {code, retryable}`. `cloudhal studio` prints mail to the terminal instead of sending.
- **Invites.** A failed send cancels the invite and answers 502 `delivery_failed`; the attempt is audited as `member.invite-failed`.
- **Password reset by email.** `POST /api/password-reset/request {email}` always answers 202; the lookup and the send run after the response. Limits: 10 per minute per IP, 3 mails per address per hour. It issues the existing audited `chrst_` link (1 hour, single use, voids older links) with the member as `createdBy`; completing a self-issued link marks the email verified. Every completed reset sends a "your password was changed" notice when a sender exists. `/api/instance` reports `passwordReset`, and Studio shows "Forgot password?" only when email is offered.
- **Not in v1:** HTML mail, a `cloudhal mail test` command, staff and reader notifications (a follow-up RFC: they need preferences and unsubscribe links).

## Alternatives considered
| Alternative | Pros | Cons | Why not |
|---|---|---|---|
| Resend with an API key | Works on Free | A secret, a second vendor, an external `fetch` | Possible later adapter |
| Email Service REST API | Usable from the CLI | An account token stored as a secret | The binding needs no secret and is lockable to one sender |
| Better Auth `sendResetPassword` | Built in | A second reset system beside the audited `chrst_` links | One mechanism, one audit trail |

## Consequences
- **Positive:** invites by email work end to end; no deployment depends on an Administrator to recover a password; no secrets added.
- **Negative:** account recovery now also runs through the member's mailbox; arbitrary recipients need Workers Paid.
- **Risks:** an unonboarded sending domain fails at the first send. Invites surface the service's code; reset requests fail silently by design (no enumeration), so the owner sees it only in Worker logs.


---
title: "ADR-0026: HTML bodies are sanitized at render time"
description: "Content bodies and rich-text sections can be markdown or html. Markdown has always been rendered as data. HTML was published exactly as stored, on the assumption that only humans write it."
url: https://cloudhal.dev/docs/adr/0026-html-body-sanitizer
source: https://github.com/cloudhal-dev/cloudhal/blob/main/docs/adr/0026-html-body-sanitizer.md
---
# ADR-0026: HTML bodies are sanitized at render time

- Status: Accepted
- Date: 2026-10-13
- Related: RFC-0012 (the design), RFC-0001 (content bodies), RFC-0005 (roles), ADR-0011 (agent keys over REST), the 2026-10-13 security audit (PR #22)
- Sources: WHATWG HTML parsing — https://html.spec.whatwg.org/multipage/parsing.html · parse5 — https://github.com/inikulin/parse5 · OWASP XSS prevention — https://cheatsheetseries.owasp.org/cheatsheets/Cross_Site_Scripting_Prevention_Cheat_Sheet.html

## Context
Content bodies and rich-text sections can be `markdown` or `html`. Markdown has always been rendered as data. HTML was published exactly as stored, on the assumption that only humans write it. That assumption was wrong: agent keys over REST, `cloudhal push` and the least trusted roles can all store HTML. `SITE_CSP` blocks scripts on the Cloudflare site, but not `<meta http-equiv="refresh">`, `<base>` or UI redress. Static builds have a CSP only where the host reads `_headers`, and feeds have none.

## Decision
Adopt RFC-0012 with its defaults, accepted by the owner on 2026-10-13:
- **Where.** `renderBody` sanitizes every `html` body, so pages, previews, feeds and static builds all go through it. Stored bodies are never modified.
- **How.**
  - Parse as an HTML fragment with parse5, the way a browser parses `innerHTML`.
  - Keep a fixed allowlist of elements and attributes. Unknown elements are unwrapped (their children are kept). A drop set (`script`, `style`, `iframe`, forms, `svg`, `math`, `meta`, `base`, …) is removed with its content. Comments are removed.
  - Serialize with our own escaping, so nothing from the input reaches the output unescaped except allowlisted names.
- **URLs.** The rules markdown already applies:
  - `safeHref` for links;
  - `safeSrc` for images, so raster `data:` images are kept and SVG `data:` URLs are not;
  - `http`, `https` or relative only for other media.
- **Defaults of the open questions:** no inline `style`; no embeds; no `srcset` in v1. A warning in the approval diff when content is dropped is left for later.

## Alternatives considered
| Alternative | Why not |
|---|---|
| Rely on CSP | Misses meta refresh, `base` and UI redress, and is absent where `_headers` is ignored and in feeds |
| Restrict `html` to Editors and above | Treats who, not what; imports and pushes still bypass it |
| Sanitize on write | Rewrites stored content; old content stays unsafe without a migration |
| `sanitize-html`, `ultrahtml` | Parsers that are not the browser's: parse differences are the classic bypass |
| DOMPurify | Needs a DOM; not Worker-safe |

## Consequences
- **Positive:** HTML bodies are safe on every host, whoever wrote them, independent of CSP. A wider allowlist later applies to old content without a migration.
- **Negative:** parse5 adds to the site Worker bundle. Content that relied on inline styles, embeds, forms or inline SVG renders without them.
- **Risks:** an allowlist bug could drop legitimate markup (tests pin the list) or keep something dangerous (tests pin the known vectors).


---
title: "ADR-0027: cloudhal.json settings for observability and placement"
description: "Both Workers logged every request, with no way to sample short of editing generated files that the next deploy overwrites. Traces could not be turned on. The admin Worker makes several D1 round trips per request."
url: https://cloudhal.dev/docs/adr/0027-observability-and-placement
source: https://github.com/cloudhal-dev/cloudhal/blob/main/docs/adr/0027-observability-and-placement.md
---
# ADR-0027: cloudhal.json settings for observability and placement

- Status: Accepted
- Date: 2026-10-13
- Related: RFC-0013 (the design), ADR-0012 (two Workers, generated configs), PR #25 (the implementation)
- Sources: Workers Logs — https://developers.cloudflare.com/workers/observability/logs/workers-logs/ · Workers Traces — https://developers.cloudflare.com/workers/observability/traces/ · Placement — https://developers.cloudflare.com/workers/configuration/placement/ · `observability.redact_query_string` in wrangler 4.148's config schema (checked 2026-10-13)

## Context
Both Workers logged every request, with no way to sample short of editing generated files that the next deploy overwrites. Traces could not be turned on. The admin Worker makes several D1 round trips per request. Signed preview tokens travel in query strings, and those appeared in logs.

## Decision
Adopt RFC-0013 with its defaults, accepted by the owner on 2026-10-13:
- **`observability`.** `{ logs: 0–1, traces: true | 0–1 }` sets both Workers' log `head_sampling_rate` and opt-in Workers Traces. Defaults: every log, no traces.
- **`placement: "smart"`.** Smart Placement for the admin Worker only. Default off, as a measured trial.
- **Redaction.** Query strings are always redacted from both Workers' logs and traces. No setting turns that off.
- **Parsing** is lenient, like the other `cloudhal.json` blocks. Generated configs are rewritten on every deploy, so removing a key removes its effect.

## Consequences
- **Positive:** owners control log volume and cost, can turn on tracing when diagnosing, and can try Smart Placement without editing generated files. Preview tokens no longer reach logs.
- **Negative:** two more public keys to support.
- **Risks:** whether the API accepts `redact_query_string` is confirmed only by the first real deploy (the wrangler dry run passes). Smart Placement's gain is unmeasured until an owner tries it.


---
title: "ADR-0028: Body blocks and body operations"
description: "People. Bodies were edited in a monospace textarea. Studio now has a rich editor that keeps markdown canonical (phase 1)."
url: https://cloudhal.dev/docs/adr/0028-body-blocks-and-operations
source: https://github.com/cloudhal-dev/cloudhal/blob/main/docs/adr/0028-body-blocks-and-operations.md
---
# ADR-0028: Body blocks and body operations

- Status: Accepted
- Date: 2026-10-14
- Related: RFC-0014 (the design), ADR-0015 (agent modes; the proposals follow-up), ADR-0026 (HTML bodies), PRs #27–#30 (phases 1 and 2a)
- Sources: `marked` lexer tokens and `raw` — https://marked.js.org/using_pro#lexer · CommonMark 0.31.2 — https://spec.commonmark.org/0.31.2/ · GFM — https://github.github.com/gfm/ · Tiptap Markdown (MIT, beta) — https://tiptap.dev/docs/editor/markdown · MCP sampling — https://modelcontextprotocol.io/specification/2025-06-18/client/sampling (checked 2026-10-13/14)

## Context
- **People.** Bodies were edited in a monospace textarea. Studio now has a rich editor that keeps markdown canonical (phase 1).
- **Agents** can only replace a whole body (`update_entry.body`). A one-word fix sends the full body and races every concurrent edit on `rev`.
- **Approvals and history.** Approvals name only "body". Nothing records who wrote which part of a body. The planned theme and UI generator needs addressable blocks inside bodies.

## Decision
Adopt RFC-0014. The owner accepted it on 2026-10-14 with the defaults below.
- **Markdown stays canonical**, under the "Cloudhal Markdown" profile v1:
  - CommonMark + GFM + `asset:`, plus directives for typed blocks (RFC-0015);
  - raw HTML stays escaped; no MDX, footnotes, math or inline directives in v1.
- **Block index.**
  - `Body` and `RichTextSection` gain optional `profile` (`1`) and `blocks: BodyBlock[]` (`{id, hash, by?, via?, at?}`). Neither is required, and `schemaVersion` stays 1.
  - **Only the store writes `blocks`.** On every write it splits the markdown with `marked`'s lexer (RFC-0014 §2) and hashes each block's trimmed source with SHA-256.
  - It carries ids over from the previous index: unchanged blocks are matched by longest common subsequence of hashes, edited blocks by position between those matches.
  - `by`/`via`/`at` change only when a block's hash changes.
  - Whatever `blocks` a client sends is replaced. HTML bodies get no index.
- **Agent operations.** `get_body` and `edit_body` (`replace`, `replaceText`, `insert`, `move`, `delete`; `insertBlock` and `setProps` once RFC-0015 lands) work as follows:
  - one CAS write per call, at most 50 ops;
  - a stale `rev` rebases when every targeted block's hash is unchanged;
  - every write is auto-snapshotted like any other agent write.

  `diff_node` returns `bodyDiff` between two versions. Approval requests carry structured `changes`.
- **Proposals.** `mode: "propose"` stores the operations in a proposals table. A person accepts or rejects them op by op. Accepting applies them as the person, with `via: "proposal"` and the agent kept as `by`. Ops whose blocks changed in the meantime are marked stale. Proposing needs `content:propose` (with `content:read`), which every write scope implies and `plan` keys get by default.
- **Defaults for the open questions:**
  1. **In-editor AI:** no model call until the owner chooses a provider. Phase 5 waits for that choice.
  2. **Modes:** `plan` may propose. `edits` keeps applying body changes directly.
  3. **HTML bodies:** stay source-only.
  4. **Profile extras** (footnotes, math, inline directives) come later.
  5. **Registry:** one registry for sections and body blocks, with a `component` section kind (RFC-0015).
  6. **Field names:** `Body.value` and `RichTextSection.body` keep their names.

## Alternatives considered
| Alternative | Pros | Cons | Why not |
|---|---|---|---|
| Store Tiptap/ProseMirror JSON | Lossless in the editor | Ties the contract to one editor's schema; agents and the Rust core must speak it | Worse for agents and for Rust |
| Store mdast | Generic tree | Outside the schema subset (hard rule 2); verbose | Too generic; heavy migration |
| Own block schema replacing markdown | Finest diffs | Rewrites every body; a new format for agents and people | Migration cost with no gain over an index |
| Paid Tiptap Tracked Changes / AI Toolkit | Ready-made | Paid, alpha/beta, a vendor service | MIT/Apache-only dependencies |
| Yjs collaboration now | Simultaneous typing | A Durable Object transport and a new failure mode | Block ops plus CAS cover people and agents on different blocks; Yjs later, as a transport only |

## Consequences
- **Positive:**
  - Agents can change one paragraph without racing a whole body.
  - Approvals, snapshots and releases show what the text says.
  - Every block records who last wrote it.
  - The generator gets addressable blocks, while bodies stay plain markdown that people, `cloudhal push` and other tools can read and write.
- **Negative:**
  - Every content write splits and hashes its body. The cost is linear in body size and bounded by the 1 MB cap; a body over 5,000 blocks keeps no index.
  - The node JSON grows by about 100 bytes per block.
- **Risks:**
  - The Rust core needs its own splitter. The shared fixtures in `fixtures/*/body-markdown` keep the two in step.
  - `@tiptap/markdown` is beta. It is pinned, and the editor's round-trip guard covers what it gets wrong.


---
title: "ADR-0029: In-editor AI on Workers AI, model chosen per site"
description: "What editors lack. Since RFC-0014 they have a real editor, block ids and proposals, but no help with the text itself."
url: https://cloudhal.dev/docs/adr/0029-ai-commands-and-model-choice
source: https://github.com/cloudhal-dev/cloudhal/blob/main/docs/adr/0029-ai-commands-and-model-choice.md
---
# ADR-0029: In-editor AI on Workers AI, model chosen per site

- Status: Accepted
- Date: 2026-10-14
- Related: RFC-0016 (the design), RFC-0014 open question 1 and phase 5, ADR-0028 (body blocks), ADR-0025 (the binding-plus-Node pattern), PR #34
- Sources:
  - `@cloudflare/workers-types@5.20261014.1`: `Ai.run`, `Ai.models`, `Ai.gateway`, `GatewayOptions`.
  - `workers-ai-provider@4.0.0` (MIT): the REST URL, and both stream chunk shapes.
  - Workers AI pricing: https://developers.cloudflare.com/workers-ai/platform/pricing/
  - Model list API: https://developers.cloudflare.com/api/resources/ai/subresources/models/methods/list
  - AI Gateway BYOK: https://developers.cloudflare.com/ai-gateway/configuration/bring-your-own-keys/
  - Unified billing: https://developers.cloudflare.com/changelog/post/2026-08-07-workers-ai-unified-billing/
  - All checked 2026-10-14.

## Context
- **What editors lack.** Since RFC-0014 they have a real editor, block ids and proposals, but no help with the text itself.
- **What was blocking.** RFC-0014 left the model call open (open question 1), so phase 5 waited.
- **What the owner wants.**
  - Cloudflare Workers AI, so there is no secret: it uses the binding and bills to the same account.
  - A choice of model, because quality, price and languages differ and models are retired every few months.
  - AI Gateway as an option.

## Decision
Adopt RFC-0016. The owner accepted it on 2026-10-14 with the defaults below.
- **Transport.**
  - On Workers: the `AI` binding, which `cloudhal.json` `ai: {}` makes deploy bind to the admin Worker.
  - On Node: the Workers AI REST API, with `CLOUDFLARE_ACCOUNT_ID` and `CLOUDHAL_AI_TOKEN` from the environment. Neither is ever stored.
  - Without either, AI is unavailable and Studio says how to connect it.
- **Client.**
  - Our own small, dependency-free client in `@cloudhal/api`. It parses both stream shapes and drops reasoning output.
  - The Vercel AI SDK plus `workers-ai-provider` stays the path for tool calls or structured output.
- **Model choice per site** (`ai_settings`):
  - A Workers AI id, or `provider/model`. The second runs only through an AI Gateway the owner configures, with the gateway's stored keys (BYOK) or unified billing. Cloudhal stores no provider keys.
  - The picker lists the recommended models first, then the account's live text-generation catalogue, cached for an hour.
  - A model that leaves the catalogue is flagged, never silently replaced.
- **Commands.**
  - Improve, shorten, expand, fix, tone, translate and custom (≤ 500 characters).
  - They run on the editor's selected markdown (≤ 16,000 characters) and are streamed as `text/plain`, at most 2,048 output tokens.
  - A fixed system prompt treats the selection as data.
  - The person sees a diff and chooses Replace (into the editor; they save as usual) or Discard. AI output is not filed as a proposal; proposals remain the agents' channel.
- **Policy.**
  - `ai:invoke` is person-only, never on an API key, and every role holds it.
  - Settings need `site:settings`.
  - AI is off until the owner turns it on.
  - A daily cap of commands per site, counted in `ai_usage` with one atomic upsert.
  - One running command per person, aborted after 2 minutes.
  - Every command is audited as `ai_command` with counts, never the text.
- **Defaults for the open questions:**
  1. **Default model:** `@cf/zai-org/glm-4.7-flash`. Recommended alongside: `@cf/openai/gpt-oss-120b` and `@cf/meta/llama-3.3-70b-instruct-fp8-fast`. Turkish quality is to be checked on real articles.
  2. **Daily cap:** 100 commands per site (0–10,000).
  3. **Every role** may use AI; a per-person cap can come later.
  4. **Third-party models** only through a gateway.
  5. **The seven commands**, applied in the editor, not as proposals.

## Alternatives considered
| Alternative | Pros | Cons | Why not |
|---|---|---|---|
| Vercel AI SDK + `workers-ai-provider` now | Tested streaming, tools, structured output | Bundle weight and `@ai-sdk/*` peers in the admin Worker; experimental gateway delegation; fast-moving majors | v1 needs text streaming only |
| Owner's provider key stored in Cloudhal | Any provider, no gateway | Secret storage and rotation (hard rule 9) | Gateway BYOK reaches the same providers without Cloudhal holding a key |
| AI output as proposals | One review path for people and agents | The person asking must save, then accept their own request; no live text | The person asking is already the reviewer |
| Hand-off to a connected agent | No model in Cloudhal | No streaming; needs an agent online | Kept for agents (`edit_body` propose) |

## Consequences
- **Positive:**
  - Editors get help in place, at a price the owner sets and sees.
  - No secret on Cloudflare deployments, and no provider key anywhere in Cloudhal.
  - Models can change without a release.
- **Negative:**
  - Workers AI models are retired often. The owner must re-pick when warned.
  - Per-person busy state is per isolate on Workers, so it is best effort; the daily cap is the hard limit.
- **Risks:**
  - Turkish quality varies by model.
  - Prompt injection through content can at worst produce text the person then sees in a diff before Replace.

## Addendum (2026-10-17): the default model is gpt-oss-120b
The owner moved the default to `@cf/openai/gpt-oss-120b` after the build evals of RFC-0020/0027 (2026-10-17): on the same briefs gpt-oss-120b finished every turn with a design score of about 3 in ~70 s, while GLM-4.7-flash finished two of three with 1.5 in ~410 s. GLM stays in the recommended list as the inexpensive choice (`budget`), Llama 3.3 as the general one. Sites that already chose a model keep it; the live newsroom was already on gpt-oss-120b. Evals are run by Cloudhal's maintainers on the deployment (`cloudhal ai build-eval --remote`), not delegated to a model.


---
title: "ADR-0030: One block registry for page sections and bodies"
description: "Page sections are a closed set of four kinds, and markdown bodies have no typed parts. Editors want callouts, buttons, videos and FAQs, and agents can only describe them in prose."
url: https://cloudhal.dev/docs/adr/0030-block-registry
source: https://github.com/cloudhal-dev/cloudhal/blob/main/docs/adr/0030-block-registry.md
---
# ADR-0030: One block registry for page sections and bodies

- Status: Accepted
- Date: 2026-10-14
- Related: RFC-0015 (the design), RFC-0014 open question 5 and phase 6, ADR-0028 (markdown canonical, block index), ADR-0029 (AI commands), ADR-0008 (precompiled validators), ADR-0026 (sanitizer), PR #35
- Sources: the generic directives proposal — https://talk.commonmark.org/t/generic-directives-plugins-syntax/444 · `micromark-extension-directive` (MIT) — https://github.com/micromark/micromark-extension-directive · `marked` extensions — https://marked.js.org/using_pro#extensions · Tiptap node views (MIT) — https://tiptap.dev/docs/editor/extensions/custom-extensions/node-views

## Context
Page sections are a closed set of four kinds, and markdown bodies have no typed parts. Editors want callouts, buttons, videos and FAQs, and agents can only describe them in prose. The theme and UI generator (phase 7) needs a vocabulary it can produce, check and render. RFC-0014 reserved block directives in profile v1 and asked for one registry with a `component` section kind.

## Decision
Adopt RFC-0015. The owner accepted it on 2026-10-14 with the drafted defaults, and changed one: type names are global.
- **Block types are data:**
  - A name, a localized title, where the type may appear (`section` / `body`), a props schema, a container or leaf form, and an example.
  - Each type has one Astro component, used both for sections and for bodies.
- **Storage:**
  - Pages: a new `ComponentSection` (`{ id, kind: "component", type, props }`). `props` is a declared map shaped like `ContentEntry.fields`.
  - Bodies: block directives (`:::name{attrs}` … `:::`, leaf `::name{attrs}`), with scalar props only.
  - Markdown stays canonical (ADR-0028).
- **Props validation:**
  - A JSON Schema subset, checked by a small dependency-free interpreter with no `eval`. The same interpreter drives Studio's generated forms.
  - Contracts keep their precompiled ajv validators.
- **Built-ins:** `callout`, `button`, `embed` (YouTube or Vimeo, click to load; CSP `frame-src` only when used) and `faq` (sections only). The four existing section kinds stay as they are.
- **Names are global and English-rooted:**
  - One namespace for built-in and theme types alike (`pricing-table`, `testimonial`), never theme-prefixed. Content therefore moves between themes that share a name, and agents and the generator learn one vocabulary.
  - A built-in name belongs to its schema; themes may restyle it.
  - Duplicate names in one deployment are a deploy error.
- **Theme types:**
  - Declared in a pure-data `src/blocks.ts`, with components in `theme.ts` `blocks`.
  - `cloudhal deploy` validates and bundles them into the admin Worker.
  - Unknown or no-longer-fitting content is kept and flagged, never dropped or rewritten, and not rendered publicly.
- **Agents:** `list_block_types`; `edit_body` `insertBlock` and `setProps`; `component` sections in the page tools. The store answers bad props with 422 `invalid_props`.
- **Phases:** 6a (core, sections, Studio forms), 6b (body directives), 6c (MCP), 6d (theme types).

## Alternatives considered
| Alternative | Why not |
|---|---|
| MDX components in bodies | Executes code; unsafe for agent-written content; not Worker-friendly |
| Custom HTML elements in bodies | Raw HTML is escaped in profile v1; the sanitizer allowlist would grow per type |
| A typed JSON body tree | Reverses ADR-0028 |
| Full JSON Schema with ajv compiled at deploy | Generated code per theme; no form generation |
| A contract variant per block type | Every theme block would need an RFC |
| Theme-prefixed type names (`acme-pricing`) | Ties content to one theme; agents and the generator would learn a vocabulary per theme |

## Consequences
- **Positive:**
  - Sections and bodies can hold typed, validated, agent-editable blocks without a contract change per type.
  - Themes extend the vocabulary.
  - The generator gets a target.
- **Negative:**
  - A second validator (the subset interpreter) to maintain next to ajv.
  - The body renderer splits markdown at directives.
- **Risks:**
  - Two themes may give the same global name different props. The store validates against the deployed schema, and content that no longer fits is kept and flagged.
  - Bodies already containing `:::` lines are reported once before rendering changes.


---
title: "ADR-0031: A generator that writes data — theme settings, draft plans, Kumo catalog panels"
description: "Starting a site and giving it a look is still manual, and agents have no typed handle on design. Changing the look means editing CSS, which is code."
url: https://cloudhal.dev/docs/adr/0031-theme-and-ui-generator
source: https://github.com/cloudhal-dev/cloudhal/blob/main/docs/adr/0031-theme-and-ui-generator.md
---
# ADR-0031: A generator that writes data — theme settings, draft plans, Kumo catalog panels

- Status: Accepted
- Date: 2026-10-14
- Related: RFC-0017 (the design), RFC-0014 phase 7, ADR-0030 (block registry), ADR-0029 (AI commands), RFC-0001 (`Site.theme`), RFC-0005 (scopes), PR #40
- Sources: `@cloudflare/kumo@2.14.0` `./catalog` (`createKumoCatalog`, `validateTree`, `generatePrompt`, `UITree`, `resolveProps`, `evaluateVisibility`) and `./ai/schemas`, read in the installed package · WCAG 2.2 success criteria 1.4.3 and 1.4.11 — https://www.w3.org/TR/WCAG22/

## Context
Starting a site and giving it a look is still manual, and agents have no typed handle on design. Changing the look means editing CSS, which is code. `Site.theme.config` exists in the contract, but no theme declares or reads it. Studio panels are hand-written React. The block registry (ADR-0030) gave a vocabulary the generator can write and check, and Kumo's catalog validates AI-generated UI trees against its own components.

## Decision
Adopt RFC-0017. The owner accepted it on 2026-10-14 with the defaults of every open question.
- **The generator writes data only:** design settings, pages made of registry blocks, menus and panel trees. It writes no code, CSS, HTML or scripts. Everything it writes is an ordinary change: a draft that is snapshotted and previewable, and that a person publishes.
- **Design settings** live in `Site.theme.config`, so there is no contract change.
  - A theme declares them in a pure-data `src/settings.ts` (`themeSettings`) and checks them with the block props subset and interpreter.
  - `x-contrast` rules hold the theme's color pairs to WCAG AA.
  - The theme renders the settings as CSS custom properties. Every value is typed: `#rrggbb` colors, enums and bounded numbers.
  - Fonts are named stacks: system fonts or fonts bundled with the theme (open question 2). Fonts from the media library and generated font URLs are not loaded.
  - Studio's Appearance form is `BlockPropsForm` over the theme's schema.
- **Plans** (`GeneratorPlan`: settings, pages, menus, notes):
  - `apply_plan` checks everything first, then writes drafts. With `mode: "propose"` it files the plan for a person.
  - Studio "Generate…" uses the site's RFC-0016 model and budget. A plan that fails validation is never applied partly.
  - `cloudhal init --prompt` runs only when the AI environment variables are set. Otherwise the person generates in Studio after init (open question 3).
  - Plans use no generated images: they use the media library or leave image slots empty (open question 6).
- **Proposals** move to one table with a `kind` (`body`, `plan`, `panel`). Body proposals are migrated in place (open question 4).
- **Kumo catalog panels:**
  - A panel is a `UITree` stored per site, validated on write and again before render.
  - On the server, validation runs with Zod in jitless mode, because Workers forbid `new Function`. The browser validates again (open question 1).
  - Components come from an allowlist and render through `@cloudhal/ui`.
  - Data comes from named, read-only sources, each behind its own scope.
  - Actions are MCP tool names, run as the person who clicks through the policy engine; anything not read-only needs `confirm`.
  - An agent's panel stays pending until an Administrator activates it.
  - The first slot is `overview` only (open question 5).
- **Theme code stays code:** it is written in the repository and shipped by `cloudhal deploy`. A `theme-block` skill scaffolds new block types.
- **Phases:** 7a settings, 7b plans, 7c panels, 7d skill.

## Alternatives considered
| Alternative | Why not |
|---|---|
| Generating Astro components or CSS at runtime | Code from a prompt on the live site; bypasses review and the CSP |
| A new `Theme` node in the contract | `ThemeRef.config` already exists and is snapshotted with `Site` |
| Free-form CSS variables | CSS injection and unreadable color pairs, and no form can be built from them |
| Panels written as React by agents | Every panel would need code review, and the bundle would grow; catalog trees are validated data |
| Applying plans to the live site | No review and no rollback before readers see the result |
| Generated images in plans | Cost, rights and moderation questions; deferred |

## Consequences
- **Positive:**
  - Agents and people can restyle a site, start it from a brief and build dashboards, all inside the existing review, publish and rollback flow.
  - Themes expose design knobs without new contracts.
- **Negative:**
  - The props interpreter gains a cross-field contrast rule.
  - Studio gains a tree renderer and its allowlist, both to maintain.
  - The proposals table is migrated.
- **Risks:**
  - Kumo's catalog API is new. Pin it, and recheck the allowlist on every upgrade, as with the Kumo patch.
  - Generated plans can be bland or off-brief. That is acceptable, because they are drafts a person edits or rejects.


---
title: "ADR-0032: Studio messages are an ICU MessageFormat 1 subset, compiled to data at build time"
description: "Studio's catalogs knew only {name} substitution. English's two plural forms were coded by hand (46 …One key pairs, 26 count ternaries), and grammar was handled by passing several forms of a word."
url: https://cloudhal.dev/docs/adr/0032-icu-messages
source: https://github.com/cloudhal-dev/cloudhal/blob/main/docs/adr/0032-icu-messages.md
---
# ADR-0032: Studio messages are an ICU MessageFormat 1 subset, compiled to data at build time

- Status: Accepted
- Date: 2026-10-18
- Related: RFC-0032, DESIGN.md §1, an internal review (2026-10)
- Sources:
  - CLDR plural rules: https://www.unicode.org/cldr/charts/latest/supplemental/language_plural_rules.html
  - ICU MessageFormat: https://unicode-org.github.io/icu/userguide/format_parse/messages/
  - MessageFormat 2 (stable since CLDR 47): https://messageformat.unicode.org/, https://cldr.unicode.org/downloads/cldr-47
  - `Intl.MessageFormat` (TC39 Stage 1): https://github.com/tc39/proposal-intl-messageformat
  - `Intl.PluralRules`: https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Intl/PluralRules

## Context
Studio's catalogs knew only `{name}` substitution. English's two plural forms were coded by hand (46 `…One` key pairs, 26 count ternaries), and grammar was handled by passing several forms of a word. Languages with more plural categories (ru, uk, pl: four; ar: six) could not be added correctly.

## Decision
1. **Syntax.** A catalog value is a string in an ICU MessageFormat 1 subset:
   - `{name}`;
   - `{n, number[, integer|percent|compact]}`;
   - `{n, plural, =k {…} <cldr category> {…} other {…}}`, where both `#` and `{n}` inside a branch are allowed;
   - `{n, selectordinal, …}`;
   - `{k, select, … other {…}}`.

   `other` is required. There are no date or time arguments and no `offset`. Quoting follows ICU's "double optional" apostrophe mode.
2. **Compiled to data at build time.** The private, dependency-free package `@cloudhal/messages` holds:
   - a build-only compiler (`./compile`);
   - a Worker-safe runtime formatter (`./format`).

   A Vite plugin compiles catalog modules in dev, build and Vitest. Messages with only `{name}` arguments stay strings; others become JSON data. No parser ships, nothing is evaluated, and `Intl.PluralRules`/`Intl.NumberFormat` are cached per locale and options.
3. **Types.** The parameter types per key are generated from the English catalogs into a committed file (`pnpm gen`, checked by `pnpm drift`), and `t()` checks the arguments against them. Keys stay in code, and lazy namespaces and the locale registry are unchanged.
4. **Checks.** Every catalog compiles. Arguments in translations are a kind-compatible subset of English's (with declared forms and drops). Every CLDR plural category of the locale is present.
5. **MessageFormat 2 later.** The compiled data model mirrors MF2's selectors and variants, so moving to MF2 syntax or `Intl.MessageFormat` changes the parser and the catalogs (by codemod), not call sites.

## Alternatives considered
| Alternative | Pros | Cons | Why not |
|---|---|---|---|
| Keep `…One/…Other` pairs | No code | Wrong for ru/uk/pl/ar; doubles keys | Blocks languages |
| FormatJS / Lingui / i18next / Paraglide | Mature | Each replaces our typed `t()`, loading or ids | We need the syntax, not a framework |
| Parse ICU at runtime | No plugin | A parser in the first load; errors at render | Build-time is cheaper and fails earlier |
| MF2 syntax now | Future standard | Stage 1 in TC39; the JS implementation is a prerelease; translation platforms don't document MF2 | The data model keeps the path open |

## Consequences
- Positive:
  - plurals are correct for every CLDR language;
  - `t()` argument errors are caught at compile time;
  - no parser in any bundle;
  - translators work in the industry-standard format.
- Negative:
  - a build plugin and a generated file to keep in step;
  - non-English catalogs are checked by tests, not by types.
- Risks:
  - a Vite or Vitest path that skips the plugin would hand raw ICU to the runtime. The runtime treats unknown shapes as plain strings, and a test asserts the compiled shape.


---
title: "ADR-0033: Right-to-left Studio — logical properties, dir from the registry, a Kumo patch until upstream"
description: "No right-to-left language (Arabic, Hebrew, Persian, Urdu) can be added to Studio yet. DESIGN.md §1 lists RTL as a prerequisite. Measured on main at 7b1ec23 with node scripts/i18n-audit.ts:"
url: https://cloudhal.dev/docs/adr/0033-rtl-layout
source: https://github.com/cloudhal-dev/cloudhal/blob/main/docs/adr/0033-rtl-layout.md
---
# ADR-0033: Right-to-left Studio — logical properties, dir from the registry, a Kumo patch until upstream

- Status: Accepted (2026-10-18, by Halil, as recommended). The open questions below stay open for the owner: the first RTL language and its numerals.
- Date: 2026-10-18
- Related: an internal review (2026-10) (Phase 3), DESIGN.md §1 ("Languages", "Adding a language"), §2v "Kumo locale", ADR-0032, `docs/design/kumo-upstream-notes.md`
- Sources:
  - Tailwind CSS 4 logical utilities (`ms-*`, `me-*`, `ps-*`, `pe-*`, `start-*`, `end-*`, `text-start`, `border-s`, `rounded-s`) and the `rtl:`/`ltr:` variants: https://tailwindcss.com/docs/margin, https://tailwindcss.com/docs/hover-focus-and-other-states#rtl-support
  - Base UI `DirectionProvider` (keyboard arrows and placement follow `dir`): https://base-ui.com/react/utils/direction-provider
  - CSS logical properties: https://developer.mozilla.org/en-US/docs/Web/CSS/CSS_logical_properties_and_values
  - Phosphor's `mirrored` prop (`@phosphor-icons/react` `IconProps`, `dist/lib/types.d.ts` in 2.1.10)
  - Material guidance on which icons mirror: https://m3.material.io/foundations/layout/understanding-layout/bidirectionality-rtl

## Context
No right-to-left language (Arabic, Hebrew, Persian, Urdu) can be added to Studio yet. DESIGN.md §1 lists RTL as a prerequisite. Measured on `main` at `7b1ec23` with `node scripts/i18n-audit.ts`:

| Where | Physical utilities (`ml`/`mr`/`pl`/`pr`/`left`/`right`/`text-left`/`text-right`/`border-l`/…) | Logical utilities |
|---|---:|---:|
| `apps/studio/src` class names | 34 (`text-right` 25, `pl` 4, `text-left` 3, `ml` 2) | 0 |
| `packages/ui/src` blocks | 39 (`ml` 9, `text-right` 7, `pl` 6, `text-left` 6, …) | 3 |
| Kumo 2.14 `dist` (compiled classes) | 239 | 12, plus 3 `rtl:` variants |
| Icons that point (`Caret*`/`Arrow*Left/Right`) | 77 uses | — |

What already works:
- the locale registry has `dir` and sets `<html dir>` together with `lang` (`apps/studio/src/i18n/locales.ts`, PR #152);
- untrusted reader text uses `dir="auto"` (`blocks/CommentText.tsx`);
- Base UI, under Kumo, supports direction through `DirectionProvider`, and Kumo re-exports it as `@cloudflare/kumo/primitives/direction-provider`.

What is missing:
- **Kumo styles.** Its own styles are physical, so the sidebar, dialogs' close buttons, input addons, table alignment, popovers' offsets and the toolbar would not mirror.
- **Icons.** Nothing mirrors directional icons.

## Options

### A. Where Kumo's physical styles get fixed
| Option | Pros | Cons |
|---|---|---|
| **A1. Upstream first, with a `pnpm patch` until it lands** (as `@cloudflare__kumo@2.14.0.patch` does today) | No fork. The fix benefits Cloudflare's own RTL plans. The patch is mechanical (`ml-`→`ms-`, `left-`→`start-`, …) and one file. | A patch over compiled `dist` must be redone on every Kumo upgrade until upstream ships, and the class list in `dist` is long (239). |
| A2. Fork Kumo | Full control | Owning a design system that Cloudflare evolves weekly. Breaks "Kumo via `@cloudhal/ui`" and the `kumo-design` skill. |
| A3. A wrapper layer: CSS overrides under `[dir=rtl]` in `@cloudhal/ui/styles.css` that flip margins, paddings and positions of Kumo's classes | No change to Kumo's files | Hundreds of overrides that silently drift from Kumo's classes, and specificity fights. It fixes classes, not components (popover placement is still physical). |
| A4. Wait for Kumo to support RTL | No work | Unknown timeline; RTL languages stay blocked |

### B. `dir` handling
- `<html dir>` comes from the registry, which is done.
- `DirectionProvider` wraps the app at the root (`main.tsx`), with `direction={LOCALE_REGISTRY[locale].dir}`, so Base UI's arrows and placement follow it.
- `dir="auto"` stays on user content (comments, titles shown verbatim, editor bodies), because a Turkish Studio can show Arabic comments.

### C. Icon mirroring
| Option | Pros | Cons |
|---|---|---|
| **C1. A `DirectionalIcon` block** (or a `directional` prop on Kumo-wrapped buttons) that sets Phosphor's `mirrored` when `dir` is `rtl`, used for icons that *mean* a direction: back, forward, next, previous, breadcrumbs' carets, "open in new", indent | Explicit: only meaning-carrying icons flip, never clocks, media play or checkmarks (Material's guidance) | 77 call sites to review once |
| C2. CSS `rtl:-scale-x-100` on every `Caret*`/`Arrow*` | One rule | Flips icons that must not flip (a chart's trend arrow, "sort ascending") |

### D. Studio's and the blocks' own 73 physical classes
A lint rule (design lint, like `i18n-format`) forbids physical utilities in `apps/studio/src` and `packages/ui/src` from now on, and a codemod converts the 73 occurrences. Directional exceptions (a chart axis, a slider that means "left") carry `i18n-ignore`-style reasons.

## Decision (recommended)
1. **A1:**
   - convert Kumo's physical classes with a `pnpm patch`, generated by a script so each Kumo upgrade can re-run it;
   - file the upstream issue the same day (`kumo-upstream-notes.md` §7);
   - drop the patch when Kumo ships logical properties.
2. **B:** `DirectionProvider` at the root, driven by the registry. Keep `dir="auto"` on user content.
3. **C1:** a `DirectionalIcon` block in `@cloudhal/ui` and a reviewed list of directional icons.
4. **D:** convert Studio's and the blocks' 34 + 39 physical utilities with a codemod, and add a design-lint rule against new ones now. The rule costs nothing even before any RTL language exists.
5. **Verification:** `pnpm design:shots` with an RTL pseudo-locale, a mirrored variant of Phase 5's pseudo-locale with `dir="rtl"` and an Arabic font stack, at 1440 and 390 px, before the first real RTL language.
6. **Order:** D and B first (cheap, no new language needed), then A1 and C1 when an RTL language is scheduled.

## Consequences
- Positive:
  - RTL languages become possible;
  - logical properties also make the code clearer ("start" means the reading start);
  - the lint keeps Studio honest without waiting.
- Negative:
  - a second Kumo patch to maintain until upstream;
  - a one-time review of 77 icon uses.
- Risks:
  - Kumo's compiled class names change between versions and the patch script drifts. Mitigation: a test that fails when the patch no longer applies, as for the current patch.
  - Charts (ECharts) need their own RTL settings (axis and legend placement), out of this ADR's scope; noted for the analytics screens.

## Decisions (2026-10-18)
The owner delegated the open questions; decided in the session that coordinates the i18n work:

- **First right-to-left language: Arabic (`ar`), with Western digits.** The registry entry is keyed `ar`, and Studio formats with the tag **`ar-u-nu-latn`**. Studio shows ids, versions, revisions and code next to numbers, and Western digits keep them readable together. Arabic-Indic digits (`nu-arab`) may become a person's preference later.
- **Order:**
  - A1: the generated Kumo patch, with a test that fails when it no longer applies, and the upstream issue text ready for the owner to file;
  - C1: `DirectionalIcon`, converting only icons that mean a direction;
  - then the Arabic catalogs, verified with `SHOTS_LOCALE=ar pnpm design:shots` at 1440 and 390 px.
- **ECharts:** its right-to-left settings (axis side, legend order) are a follow-up if the Arabic step does not cover them.

## Open questions
None left from this ADR; the questions below were answered above.
- *(answered)* Does Cloudflare plan RTL in Kumo? The upstream issue (`kumo-upstream-notes.md` §7) asks; the patch does not wait for the answer.
- *(answered)* Which RTL language first, which numerals? Arabic, Western digits (`ar-u-nu-latn`).


---
title: "ADR-0034: Translation workflow — catalogs stay in the repo, ICU JSON exchange, Weblate self-hosted (or Tolgee), AI drafts reviewed by people"
description: "Studio's catalogs are TypeScript modules: apps/studio/src/i18n/<locale>.ts and lazy/<ns>.<locale>.ts. - English is the source and the type."
url: https://cloudhal.dev/docs/adr/0034-translation-platform
source: https://github.com/cloudhal-dev/cloudhal/blob/main/docs/adr/0034-translation-platform.md
---
# ADR-0034: Translation workflow — catalogs stay in the repo, ICU JSON exchange, Weblate self-hosted (or Tolgee), AI drafts reviewed by people

- Status: Accepted (2026-10-18, by Halil, as recommended). The open questions below stay open for the owner: platform hosting, reviewers, visitor-facing strings.
- Date: 2026-10-18
- Related: an internal review (2026-10) (Phase 4), ADR-0032 (ICU messages, compiled), DESIGN.md §1 ("Languages", "Adding a language"), the locale registry's `reviewed` flag
- Sources:
  - Weblate: file formats (JSON, nested JSON, i18next JSON, gettext PO, XLIFF 1.2/2.0) https://docs.weblate.org/en/latest/formats.html; the ICU MessageFormat quality check (`icu-message-format`, syntax and placeholder mismatches) https://docs.weblate.org/en/latest/user/checks.html; licence GNU GPL v3+ (https://pypi.org/project/weblate/); hosted service with a programme for libre projects https://weblate.org/en/hosting/
  - Tolgee: ICU MessageFormat in the platform and SDKs https://docs.tolgee.io/platform/translation_process/icu_message_format; core open source (Apache-2.0) with an enterprise layer, self-hosting by Docker https://github.com/tolgee/tolgee-platform; in-context editing and machine translation https://docs.tolgee.io/
  - Crowdin: ICU message syntax (plural, select, number, date) with highlighting and preview https://support.crowdin.com/icu-message-syntax/; supported formats https://support.crowdin.com/supported-formats/; open-source programme https://crowdin.com/page/open-source-project-setup-request
  - Lokalise: ICU plural detection on upload (vendor guide) https://lokalise.com/blog/complete-guide-to-icu-message-format/; supported formats https://docs.lokalise.com/en/collections/2909229-supported-file-formats
  - XLIFF 2.1: https://docs.oasis-open.org/xliff/xliff-core/v2.1/xliff-core-v2.1.html

## Context
Studio's catalogs are TypeScript modules: `apps/studio/src/i18n/<locale>.ts` and `lazy/<ns>.<locale>.ts`.
- English is the source and the type. The other languages are typed against it, and since ADR-0032 they are ICU MessageFormat 1.
- Checks guard keys, argument kinds, plural categories, declared forms and drops, unused keys, and English sentences left untranslated (`test/i18n*.test.ts`).
- German was added as an AI draft (`reviewed: false`). Turkish is maintained by the owner.

More languages need four things:
- people outside the repo editing translations;
- a review state per string;
- machine or AI drafts that a person approves;
- an import back into the repo that the checks validate.

Pricing changes often, so it is linked, not quoted. Check the pages when deciding.

## Options

### Exchange format
| Format | Fit |
|---|---|
| **Nested JSON with ICU MF1 values**, one file per namespace and locale, plus a sidecar of descriptions | What all four platforms import. Keeps ICU intact (ADR-0032). Nesting maps 1:1 to our keys. |
| XLIFF 2 | For agencies and CAT tools. A second export, if a vendor asks |
| gettext PO | Source-text oriented; ICU plurals would have to be flattened to `msgstr[n]`. No |

### Platform
| Platform | Model and licence | ICU | Review states / MT | Hosting and cost | Fit |
|---|---|---|---|---|---|
| **Weblate** | Libre (GPL v3+); self-host or the hosted service | ICU quality check (syntax, placeholders); JSON / nested / i18next JSON | Needs-editing, translated, approved (reviewers); machine translation add-ons, including LLM back ends | Self-host on any container host; hosted plans, with a programme for libre projects | Git-native: pushes translations back as commits or PRs. Strongest fit for an Apache-2.0 open-source project |
| **Tolgee** | Open-source core (Apache-2.0) and an enterprise layer; self-host (Docker) or cloud | ICU native | States and MT/AI translation; **in-context editing** (Alt+click in the running app) | Self-host free for a small team (vendor); cloud plans | Best translator UX (in context). The SDK model is built around its own runtime, but plain file import/export works without the SDK |
| Crowdin | SaaS | ICU syntax with highlighting and preview | Proofreading, approvals, MT and AI pre-translation | Paid plans; open-source programme | Polished SaaS with GitHub integration; not self-hostable |
| Lokalise | SaaS | ICU plurals (detected on upload) | Review statuses; MT and AI | Paid plans (free tier limited) | Strong product teams' tool; no self-hosting, no open-source programme found |

### AI-first drafts
A Claude Code skill, `.agents/skills/translate` (six spec fields, AGENTS.md rule 8). It reads:
- the export JSON;
- descriptions;
- a glossary (product terms kept as they are: Cloudhal, Snapshot, Release, Studio);
- DESIGN.md's tone rules;
- per language: formality (`du`/`sen`/…) and the CLDR plural categories.

It writes ICU strings for every category the language needs, then runs `pnpm i18n:check`. Its drafts are uploaded as "needs review" (Weblate: needs editing; Tolgee/Crowdin: unreviewed). This follows the agent-native direction, and German's pilot already followed the pattern.

## Decision (recommended)
1. **The repo stays the source of truth.**
   - `en.ts` is edited by developers; it is the type.
   - Translations flow through `pnpm i18n:export` / `pnpm i18n:import`, which write nested ICU JSON plus descriptions and regenerate `<locale>.ts` / `lazy/<ns>.<locale>.ts`.
   - Every import runs the existing checks and opens a PR. Generated catalog files are committed, as contract types are (AGENTS.md rule 1).
2. **Weblate, self-hosted** (or its hosted libre programme): libre, Git-native, ICU-checked, reviewers built in. **Tolgee is the alternative** if in-context editing matters more than Git-native flow. A short trial of both on German (`reviewed: false` today) decides, with a German reviewer.
3. **Review states map to the registry.** A language becomes `reviewed: true` when all its strings are approved in the platform; the import computes this.
4. **AI drafts:** the `translate` skill produces first drafts and uploads them as needing review. People approve. The skill never marks a string approved.
5. **XLIFF 2** only on demand, from the same export.

## Consequences
- Positive:
  - translators work in a proper tool;
  - reviews are visible;
  - the repo's checks still guard every import;
  - no vendor lock-in, because the exchange format is portable.
- Negative:
  - export and import scripts to maintain;
  - hosting Weblate or Tolgee, a container and a database, if self-hosted.
- Risks:
  - two-way edits (a developer changes `de.ts` while a translator changes the platform). Mitigation: catalogs other than English are generated, and only English is edited by hand. This is a lint rule once the import exists.
  - MF2: none of the four platforms documents MessageFormat 2 import/export yet (RFC-0032, open question 1). The ICU MF1 exchange stays until they do.

## Decisions (2026-10-18)
The owner delegated the open questions; decided in the session that coordinates the i18n work:

- **Platform: Hosted Weblate, Libre plan.** No self-hosting.
  - The plan is for public projects, and is set up from https://hosted.weblate.org/hosting/.
  - Its limit is 160,000 hosted strings, counted as source strings × (languages + 1). Studio has about 4,550 keys, so en plus tr, de and ar is about 18,200.
  - **The owner creates the account and applies; agents never create accounts.** The steps are in `docs/guides/translations-weblate.md`.
- **Reviewers:** German and Arabic stay `reviewed: false` until community reviewers approve them in Weblate. Turkish stays reviewed (the owner).
- **Until then:** an AI second-pass consistency review (glossary, register, terms) may correct the drafts through the `translate` skill and `pnpm i18n:import`. It never sets `reviewed: true`.
- **Visitor-facing strings** (themes, notices) stay out of scope for now. The catalogs remain Studio-only.

## Open questions
None left from this ADR; the questions below were answered above.
- *(answered)* Hosting: Hosted Weblate, Libre plan.
- *(answered)* Reviewers: community reviewers in Weblate for German and Arabic; the owner for Turkish.
- *(answered)* Visitor-facing strings: not now.


---
title: "ADR-0035: Published content in several languages — one node per locale in translation groups, locale in the URL, gated agent translation, Site = publication"
description: "Pages, entries, terms, menus and assets already carry a locale or per-locale labels, but only the default locale is served and nothing links translations."
url: https://cloudhal.dev/docs/adr/0035-multilingual-publishing
source: https://github.com/cloudhal-dev/cloudhal/blob/main/docs/adr/0035-multilingual-publishing.md
---
# ADR-0035: Published content in several languages — one node per locale in translation groups, locale in the URL, gated agent translation, Site = publication

- Status: Accepted (2026-10-18, by Halil)
- Date: 2026-10-18
- Related: RFC-0033; the research report (internal); RFC-0001/0002/0003/0004/0008/0018 (their deferred locale work); ADR-0033/0034 (Studio's own UI, not this)
- Sources:
  - Google: [localized versions](https://developers.google.com/search/docs/specialty/international/localized-versions), [multi-regional sites](https://developers.google.com/search/docs/specialty/international/managing-multi-regional-sites), [spam policies](https://developers.google.com/search/docs/essentials/spam-policies)
  - [EU AI Act Art. 50](https://artificialintelligenceact.eu/article/50/)
  - [Crossref relationships](https://www.crossref.org/documentation/schema-library/markup-guide-metadata-segments/relationships/)
  - [Arc XP localization strategies](https://docs.arcxp.com/en/products/general/localization-strategies-to-build-your-sites.html)
  - [Sanity localization](https://www.sanity.io/docs/localization)
  - [MDN translated content](https://developer.mozilla.org/en-US/docs/MDN/Community/Translated_content)

## Context
Pages, entries, terms, menus and assets already carry a locale or per-locale labels, but only the default locale is served and nothing links translations. Three accepted RFCs deferred their locale work to a model that did not exist. The research (19 platforms, 4 newsrooms, docs and scholarly tools, Google, W3C and the AI Act) points to one shape. RFC-0033 proposes it.

## Decision
1. **One language stays the zero-cost default.** Several languages are a setting.
2. **Hybrid model.**
   - Pages and entries: one node per locale, linked by `translation.group`. Each translation records its source `{id, locale, rev, hash}`, its `method` (`original`, `human`, `machine`, `machine-reviewed`), `provenance` and `review`.
   - Taxonomy, term, menu, asset and site chrome: per-locale field maps, which already exist.
   - Terms gain a slug per locale.
3. **Status and staleness.** Each translation has its own status. Staleness is computed (source hash ≠ recorded hash), never stored.
4. **Review gate.** A machine translation in a collection listed in `Site.localization.review.required` cannot be published without a person's review. Only humans set `review`.
5. **URLs.**
   - The locale goes in the path: `prefix-except-default` by default, `prefix-all` as an option, or a domain per locale.
   - Never cookie- or `Accept-Language`-only.
   - `hreflang` is reciprocal, self-referencing and includes `x-default`; every page is self-canonical.
   - `lang`, `dir` and `Content-Language` are set; sitemaps, feeds, search and `llms.txt` are per locale.
   - Fallback is off by default. When on, it shows a notice and is `noindex`.
6. **Landing pages.** Translations keep the original's section ids. With `layout: "linked"` (the default), structural changes propagate as drafts and text written by people is never overwritten. With `independent`, nothing propagates. Theme words come from the theme's per-locale strings, never from pages.
7. **Agents draft; people decide.**
   - Glossary, deterministic checks and a quality score (starting with an LLM MQM-style judge, behind an interface) gate every draft.
   - A new `content:translate` scope; optional `locales[]` limits on keys.
   - News and scholarly starters require review.
   - Unreviewed machine text is labelled to readers (`label: "unreviewed"` by default; scholarly starters use `always`).
8. **Site = publication, Project = container.** Translation groups may cross sites in one project (language editions); `hreflang` then spans hosts.
9. **Deferred to later RFCs.** The glossary is per site; a project glossary may come later. Per-locale rollback, scholarly (DOI per language version, JATS, `citation_*`), docs versions × locales, and hostname dispatch with per-site themes come later.

## Alternatives considered
- Single-language only.
- Field-level localisation everywhere.
- Document per locale everywhere.
- A `Publication` entity inside Site.
- Edge proxy translation.
- Auto-publishing machine output.
- Cookie or `Accept-Language` locales.
- A theme per locale.
- A text overlay inside one page.

The reasons each was rejected are in the table in RFC-0033.

## Consequences
- **Positive.**
  - Additive and compatible: existing sites are unchanged and existing URLs keep working.
  - Per-node CAS gives independent locking and review.
  - News editions, mirrors and journal portals all fit one model.
  - The AI Act disclosure and Google's guidance are met by default.
- **Negative.**
  - Structural sync for linked layouts is new machinery (a JSON Patch between revisions).
  - CJK search needs a second FTS5 table.
  - The redirect key gains a locale.
- **Risks.**
  - Quality scores are noisy per segment; they route drafts and never replace review.
  - Cross-site groups must stay inside one project (the store enforces this).


---
title: "ADR-0036: Multi-site installations — one Studio, one D1/R2, many isolated sites served by hostname"
description: "Every Cloudhal site is a whole installation today: a site Worker, an admin Worker (Studio), a D1 and an R2 for one CLOUDHALSITEID. A second site means a second Studio, login, team and deploy."
url: https://cloudhal.dev/docs/adr/0036-multi-site-installations
source: https://github.com/cloudhal-dev/cloudhal/blob/main/docs/adr/0036-multi-site-installations.md
---
# ADR-0036: Multi-site installations — one Studio, one D1/R2, many isolated sites served by hostname

- Status: Accepted (2026-10-19, by Halil)
- Date: 2026-10-19
- Related:
  - RFC-0034 and the Studio multilingual UX report (internal)
  - RFC-0033 / ADR-0035 (Site = publication, cross-site translation groups)
  - RFC-0018 (one theme per renderer)
  - RFC-0005 (per-site roles)
- Sources:
  - [Workers limits](https://developers.cloudflare.com/workers/platform/limits/)
  - [D1 limits](https://developers.cloudflare.com/d1/platform/limits/)
  - [Workers custom domains](https://developers.cloudflare.com/workers/configuration/routing/custom-domains/)
  - [Workers Cache purge](https://developers.cloudflare.com/workers/cache/purge/)
  - [Storyblok multi-space](https://www.storyblok.com/docs/manuals/multi-space-orchestration)

## Context
Every Cloudhal site is a whole installation today: a site Worker, an admin Worker (Studio), a D1 and an R2 for one `CLOUDHAL_SITE_ID`. A second site means a second Studio, login, team and deploy. On Workers Free that stops at 10 sites (D1 count) and on Paid at 250 (Worker count). The data model is already keyed by `site_id` and Studio already lists sites, but nothing adds a second site to an installation. Self-hosted users want independent sites (`en.example.com`, `tr.example.com`, client sites, journals) run by one team from one login.

## Decision
1. **Installation = management boundary; site = isolation boundary.**
   - An installation is one Studio, one D1 and one R2 in one Cloudflare account.
   - Within it, each site has its own content, settings, members, keys, analytics and comments.
   - Roles are per site.
   - Nothing crosses sites except opt-in translation-group links.
2. **Sites are created inside an installation** from Studio, the CLI or MCP (`site:create`). They start empty, or copy structure from another site as a language edition. Creating a site never copies readers' data, members or credentials, and never publishes.
3. **Every site can be exported whole and deleted whole on its own** (`site:export`, owner-only `site:delete`).
4. **Serving by hostname.**
   - A `site_hosts` D1 table, written only by the API and unique on `host`, maps hostnames (including per-locale domains) to sites.
   - A site Worker with `sites: [...]` serves only that allow-list. Any other host gets a 404, never another site.
   - The table is cached in isolate memory for 60 s and refreshed on purge.
   - Purges are tag-only in multi-site mode.
5. **One theme per site Worker, several site Workers per installation.** A site Worker attaches to an existing installation's D1/R2 and registers in `site_workers`, so sites with different themes still share one Studio.
6. **Single-site installations are unchanged.** `siteId` keeps today's exact config, and the new paths are inactive without `sites`.
7. **Out of scope:**
   - sites in other Cloudflare accounts under one Studio (remote installations);
   - per-site D1 databases;
   - hosted-Cloud custom hostnames;
   - project-level roles.

## Addendum (2026-10-19)
Roles are deployment-wide today (RFC-0005). Per-site roles (decision 1) arrive in phase A2, before Studio can create sites:
- a `site_members` table;
- owners and administrators stay installation-wide;
- other roles with site rows are limited to those sites.

## Alternatives considered
- One installation per site (status quo).
- One D1 per site under one Studio.
- A host map in Workers KV.
- A host map baked into the build.
- Several themes in one site Worker.
- Workers for Platforms.

The reasons each was rejected are in RFC-0034's table.

## Consequences
- **Positive.**
  - One login and team for any number of sites.
  - Site count is no longer bounded by Workers or D1 counts.
  - `en`/`tr` subdomains, client sites and journal portals all fit.
  - Sites stay portable through export and import.
- **Negative.**
  - All sites share one database's size (500 MB on Free, 10 GB on Paid) and the account's request quota.
  - Deleting or exporting a site walks every site-keyed table, so new tables must register for both.
- **Risks.**
  - A missing `site_id` check in a new route would leak across sites. The shared API suite gains a two-site isolation test that every route family must pass.
  - Host conflicts in old data are reported and skipped by the migration.


---
title: "ADR-0037: Domain strategy: cloudhal.com and cloudhal.dev"
description: "Cloudhal owns exactly two domains, cloudhal.com and cloudhal.dev, and will not buy a third. The repo already uses https://cloudhal.dev/schemas/v1/ as JSON Schema $id, https://cloudhal.dev/docs, the bot User-Agent page…"
url: https://cloudhal.dev/docs/adr/0037-domain-strategy
source: https://github.com/cloudhal-dev/cloudhal/blob/main/docs/adr/0037-domain-strategy.md
---
# ADR-0037: Domain strategy: cloudhal.com and cloudhal.dev

- Status: Accepted
- Date: 2026-10-20 (decided by Halil)
- Amendment (2026-10-20): editorial, product names removed pending trademark check. No rule or host changed; the control-plane host keeps a placeholder until its name is decided.
- Related: ADR-0007, ADR-0010, ADR-0012, ADR-0020
- Sources: [Cloudflare: create a self-managed OAuth client](https://developers.cloudflare.com/fundamentals/oauth/create-an-oauth-client/) · [HSTS preload list (all `.dev` names are preloaded)](https://hstspreload.org) · [EmDash playground](https://github.com/emdash-cms/emdash/tree/main/demos/playground)

## Context
Cloudhal owns exactly two domains, `cloudhal.com` and `cloudhal.dev`, and will not buy a third. The repo already uses `https://cloudhal.dev/schemas/v1/*` as JSON Schema `$id`, `https://cloudhal.dev/docs`, the bot User-Agent page `https://cloudhal.dev/fetch`, and `security@`, `legal@` and `conduct@cloudhal.com` for mail. As of 2026-10-20 neither domain had DNS records; cloudhal.dev went live on 2026-10-21 from `apps/docs`.

Planned: a future hosted control plane that installs and manages Cloudhal in the customer's own Cloudflare account, and a public playground modelled on EmDash's (a per-visitor SQLite Durable Object, one-hour TTL, no login) in a separate repository, `cloudhal-dev/try`. The question is which domain carries what, and where customer sites live.

## Decision
**Host map**

| Host | Purpose |
|---|---|
| `cloudhal.com` | Marketing, pricing, blog |
| `try.cloudhal.com` | Public playground and the Deploy-to-Cloudflare / OAuth flow. Its own Worker, `__Host-` cookies, no `Domain=` |
| `<control-plane>.cloudhal.com` | Future control-plane panel: a control-plane host under `cloudhal.com` (name to be decided) |
| `api.cloudhal.com` | The control plane's API and OAuth redirect host |
| `status.cloudhal.com` | Status page |
| `*@cloudhal.com` | All mail: `security@`, `legal@`, `conduct@`, `abuse@`, `support@` |
| `cloudhal.dev` | Open-source home and install |
| `cloudhal.dev/docs` | The single copy of the docs; `cloudhal.com/docs` redirects here |
| `cloudhal.dev/schemas/v1/*` | Permanent JSON Schema `$id` URLs. Never moved or deleted; a new version adds `/v2` |
| `cloudhal.dev/fetch` | Bot User-Agent page |
| `registry.cloudhal.dev` | Index of plugins, capabilities, themes and skills. Read-only metadata; packages stay on npm. Publisher sign-in through GitHub, host-only cookies |

**Customer sites never run on Cloudhal's domains.** The hosted control plane deploys into the customer's own Cloudflare account, and sites use the customer's domains or their own `*.workers.dev`. No Public Suffix List entry and no third domain is needed.

**Rules**
1. One trust boundary per domain: login, payment and control-plane data live on `.com`; public, unauthenticated developer content lives on `.dev`.
2. No customer-controlled content on either domain. The one exception is the playground: ephemeral, no uploads, no publishing, isolated on `try.`.
3. No `Domain=` cookies on either apex; cookies are host-only (`__Host-` where possible).
4. Schema `$id` URLs are permanent.

## Alternatives considered
| Alternative | Pros | Cons | Why not |
|---|---|---|---|
| A third domain for customer sites | Clean cookie and reputation isolation | A third domain to buy and run; the user keeps juggling domains | Customer sites are not on our domains at all, so there is nothing to isolate |
| Customer sites under `*.cloudhal.dev`, with a PSL entry | Instant URLs, no DNS work for customers | Abuse and phishing land on the developer-trust domain; PSL listing is slow and outside our control; we would own the content | Puts untrusted content on the domain that serves schemas and docs |
| Hosting customer sites on Cloudhal's Cloudflare account | Simplest onboarding | We pay for traffic, carry abuse handling and data responsibility, and become a single point of failure for every site | Cost, abuse and data responsibility; contradicts the self-hosted model |

## Consequences
- Positive: two domains suffice; schema URLs and docs are stable and public; the control plane can be locked down independently; customers keep their data and domains.
- Negative: sites on `*.workers.dev` carry Cloudflare's hostname until the customer adds their own; two zones to keep in order.
- Risks: a schema URL going dark breaks validators that fetch `$id`, so the `/schemas/v1/` content must be served from the repo on every release. `.dev` is HSTS-preloaded, so everything there must serve valid HTTPS.

## Operator checklist (not done by this change; done in Cloudflare)
- [ ] DNS for both zones.
- [x] Serve `packages/core-contracts/schemas` at `https://cloudhal.dev/schemas/v1/` with `Content-Type: application/schema+json`.
- [ ] SPF, DKIM and DMARC for `cloudhal.com`.
- [ ] `cloudhal.dev` sends no mail: `v=spf1 -all`, DMARC `p=reject`, null MX.


---
title: "ADR-0038: Public playground: one SQLite Durable Object per visitor, store and renderer inside it, deny-list guard with a route walk"
description: "Trying Cloudhal needs Node, wrangler and a Cloudflare account. RFC-0036 proposes try.cloudhal.com (ADR-0037): every visitor gets a private, disposable Cloudhal (Studio, API and rendered site) with no account."
url: https://cloudhal.dev/docs/adr/0038-public-playground
source: https://github.com/cloudhal-dev/cloudhal/blob/main/docs/adr/0038-public-playground.md
---
# ADR-0038: Public playground: one SQLite Durable Object per visitor, store and renderer inside it, deny-list guard with a route walk

- Status: Accepted
- Date: 2026-10-20 (decided by Halil)
- Amendment (2026-10-20, phase 3): session cookies carry an HMAC of the ULID (`PLAYGROUND_SECRET`); an unsigned ID let clients mint sessions past the creation limits. See RFC-0036 "Implementation notes".
- Related: RFC-0036, ADR-0002, ADR-0008, ADR-0012, ADR-0018, ADR-0024, ADR-0036, ADR-0037
- Sources: [Durable Objects SQLite storage API](https://developers.cloudflare.com/durable-objects/api/sqlite-storage-api/) · [Durable Objects limits](https://developers.cloudflare.com/durable-objects/platform/limits/) · [Durable Objects alarms](https://developers.cloudflare.com/durable-objects/api/alarms/) · [Workers Rate Limiting binding](https://developers.cloudflare.com/workers/runtime-apis/bindings/rate-limit/) · [Workers limits (script size, startup)](https://developers.cloudflare.com/workers/platform/limits/) · [EmDash playground](https://github.com/emdash-cms/emdash/tree/main/demos/playground)

## Context
Trying Cloudhal needs Node, wrangler and a Cloudflare account. RFC-0036 proposes `try.cloudhal.com` (ADR-0037): every visitor gets a private, disposable Cloudhal (Studio, API and rendered site) with no account. An internal spike on workerd measured the two placements of the store and the bundle size: the first open is 24–41 ms of server work, a fresh database is about 815 KB, 20 concurrent sessions all succeeded, and one Worker holding the API and the site renderer is at most 1.6 MiB gzip. With the store in the Worker and SQL over DO RPC, a publish took 26 sequential RPCs (7 ms locally against 2 ms with the store in the DO, an estimated 25–130 ms more in production). Snapshots and publishes write blobs (`nodes/<hash>.json`, bundle shards), so a read-only blob store breaks every save.

## Decision
1. **One SQLite-backed Durable Object per session**, addressed by `idFromName(<ULID from a __Host- cookie>)`. No shared database and no server-side session table; the cookie carries an HMAC of the ULID so that sessions are only created through the rate-limited endpoint (amendment).
2. **Store, API and renderer run inside the session's DO.** The Worker serves static files, checks the cookie, applies the guard and the rate limits, and forwards with `stub.fetch`. One hop per request, however chatty the call.
3. **`@cloudhal/storage/do-sqlite`**: `DoSqliteDriver` over `ctx.storage.sql`, `batch` atomic through `transactionSync` and never exposed as a transaction to callers (hard rule 4 holds), D1 limits enforced as on every adapter (ADR-0002). Blobs: `SqlBlobStore` (blobs in the same SQL database, capped), `LayeredBlobStore` (read-only base under a writable upper), `FetcherBlobStore` (static assets). All pass the existing conformance suites on Miniflare; core-ts's behavioural suite gets a DO backend.
4. **Playground mode is defence in depth**: the pure `isBlockedInPlayground(method, path)` deny list runs in the Worker and again as API middleware; `createApi({ playground })` leaves auth, MCP, mail, AI and analytics unconfigured; the fixed `PLAYGROUND_PRINCIPAL` holds Editor scopes plus `site:settings` and nothing installation-wide, so the default-deny scope check sits under both. A test walks every route `createApi` registers and fails on any route that is neither blocked nor on the allow list.
5. **Lifetime**: an alarm deletes everything 60 minutes after the last request, never later than 2 hours after creation; a DO older than 2 hours refuses requests even if the alarm did not run. Reset deletes at once.
6. **Session pages are private**: rendered under `/s/<session>/`, `cache-control: private, no-store`, `x-robots-tag: noindex`, never a `cache-tag`. ADR-0037 rule 2's "no publishing" is read as "no public publishing": a publish is visible only to its own session.
7. **Agents are off in the MVP**; abuse limits, a daily session cap and a `PLAYGROUND_ENABLED` kill switch bound cost (RFC-0036 §7, §9).
8. **Split**: the general pieces (driver, blob stores, playground mode, `@cloudhal/cloudflare/playground`, `withRequestStore`, Studio's session bar) live in the monorepo, versioned and tested in `pnpm check`. The deployable site (wrangler config, landing page, demo pack, domain, secrets) lives in `cloudhal-dev/try` and only composes published packages.

## Alternatives considered
| Alternative | Pros | Cons | Why not |
|---|---|---|---|
| Store in the Worker, SQL over DO RPC | DO only runs SQL; render CPU off the DO | One network hop per query (26 for a publish) | Spike: 1.5–3.5× slower locally; worse with real RTT |
| One shared D1, a site per visitor (ADR-0036) | No new driver | Shared blast radius, cleanup across 30+ tables, noisy neighbours, D1 size cap | Isolation is the point |
| A D1 database per visitor | Real D1 | Creation through the API takes seconds; account database limits | Too slow and too limited for anonymous traffic |
| SQLite WASM in the browser | No server cost | Hides the platform; large download; no Worker rendering | Would not show Cloudhal on Cloudflare |
| Read-only blob store | Simplest | Snapshots and publishes write blobs | Breaks every save |
| Unsigned session cookie | No secret to manage | Clients could mint session IDs and skip the creation limits and daily cap | Found in phase 3; the cookie is signed |
| Allow list only, no route walk | Smaller | A forgotten route stays reachable or silently breaks | The walk makes every new route a decision |

## Consequences
- **Positive.** Cloudhal runs with no D1 at all (also on Workers Free); isolation is per visitor by construction; first open is tens of milliseconds; idle sessions cost nothing; every new API route is classified for the playground before it ships.
- **Negative.** A third SQL backend to keep in the conformance suites; Studio carries a `features.playground` branch; the DO runs rendering on its single thread.
- **Risks.** Worker startup CPU with a 1.6 MiB bundle (measured on a real deploy in phase 3; lazy-import the renderer if tight); production DO latency from far regions (measured before launch); abuse of compute (rate limits, caps, kill switch).


---
title: "ADR-0039: cloudhal.dev: Starlight over the repo's docs, a link contract checked in CI, docs for agents"
description: "ADR-0037 makes cloudhal.dev the developer domain and the only copy of the docs. Packages, cloudhal.com, the schemas' $ids and the fetcher's User-Agent already link there."
url: https://cloudhal.dev/docs/adr/0039-cloudhal-dev-docs-site
source: https://github.com/cloudhal-dev/cloudhal/blob/main/docs/adr/0039-cloudhal-dev-docs-site.md
---
# ADR-0039: cloudhal.dev: Starlight over the repo's docs, a link contract checked in CI, docs for agents

- Status: Accepted
- Date: 2026-10-21 (decided by Halil)
- Amendment (2026-10-21, phase 3): the docs stay out of the site MCP server; `cloudhal.dev/mcp` is the only docs MCP. See rule 4.
- Related: RFC-0037, ADR-0037, ADR-0011, RFC-0004
- Sources: [Starlight](https://starlight.astro.build/) · [starlight-llms-txt](https://github.com/delucis/starlight-llms-txt) · [llms.txt](https://llmstxt.org/) · [Cloudflare docs for agents](https://developers.cloudflare.com/docs-for-agents/) · [Workers static assets: redirects](https://developers.cloudflare.com/workers/static-assets/redirects/) and [headers](https://developers.cloudflare.com/workers/static-assets/headers/)

## Context
ADR-0037 makes `cloudhal.dev` the developer domain and the only copy of the docs. Packages, cloudhal.com, the schemas' `$id`s and the fetcher's User-Agent already link there. A stopgap (`apps/docs`, #33) serves the schemas, `/fetch`, `/llms.txt` and redirects `/docs` to GitHub. RFC-0037 proposes the real site.

## Decision
1. **Starlight in `apps/docs`**, reading the monorepo's `docs/` through a content-collection loader. Docs change in the same PR as code. The same `cloudhal-dev` Worker serves it with the schemas and `/fetch`.
2. **Generated reference**: CLI, MCP tools, `cloudhal.json`, schemas and changelog are built from source, never written by hand.
3. **Link contract**: a published URL never 404s (moves get a 301 from `apps/docs/redirects.ts`); `/schemas/v1/*` never moves; user-facing links point at cloudhal.dev, not GitHub paths; errors link to `/e/<code>` pages from one error-code registry in `packages/core-contracts`; CI checks all of it.
4. **Docs for agents**: Markdown per page (`<path>.md` and `<path>/index.md`), `Accept: text/markdown` with `x-markdown-tokens`, `llms.txt` and `llms-full.txt` per site and per section, and a standalone, read-only, unauthenticated MCP at `cloudhal.dev/mcp` (`search_docs`, `read_doc`), rate limited per IP. The site MCP server (`cloudhal mcp`, remote `/mcp`) gets no docs tools (amendment): it is for agents working on sites (content, design, generated UI, comments, schedules), and its how-to knowledge lives in skills, tool descriptions and server instructions. Docs tools there would add tools that narrow-scoped keys never need, a second knowledge source that can contradict the first, and an outbound dependency on cloudhal.dev. Developer agents read the docs from cloudhal.dev; the site server's instructions only name `llms.txt`.
5. **English only, latest release only** while 0.x; versions under `/docs/v<major>/` from 1.0; languages later under `/docs/<lang>/`.
6. **All ADRs and RFCs are published**, each page showing its status.
7. **Delivered in four phases** (RFC-0037 §7): the site; generated reference, error pages, CI checks and previews; agent endpoints; new content and link migration.

## Alternatives considered
| Alternative | Pros | Cons | Why not |
|---|---|---|---|
| A Cloudhal site (dogfood) | Shows the product | Docs leave the repo and drift; needs a Markdown-from-git import | cloudhal.com already dogfoods |
| Mintlify / GitBook | Fast, polished | Vendor lock-in, cost, not on our Worker | Contradicts open source and self-hosting |
| Docusaurus | Versioning built in | A second front-end stack | Starlight is Astro, like the renderer |
| GitHub only | No work | No search, navigation or Markdown endpoints | The stopgap, not the site |
| Docs tools in the site MCP (Supabase, Sanity) | One server to connect | Irrelevant tools for narrow keys, two knowledge sources, an outbound call from every deployment | Their servers are developer tools; Cloudhal's is content-operational and policy-governed (amendment) |

## Consequences
- **Positive.** One docs source; reference that cannot drift; stable URLs; agents get Markdown, indexes and search.
- **Negative.** A docs build in CI on docs and reference changes; moving a docs file needs a redirect entry; a small Worker script for content negotiation and the MCP endpoint.
- **Risks.** Starlight and Astro majors must move together with the renderer's Astro (one catalog entry); an unauthenticated MCP endpoint needs rate limiting.


---
title: "Architecture Decision Records"
description: "One file per decision, numbered, immutable once accepted (write a new ADR to supersede). Template:"
url: https://cloudhal.dev/docs/adr
source: https://github.com/cloudhal-dev/cloudhal/blob/main/docs/adr/README.md
---
# Architecture Decision Records

One file per decision, numbered, immutable once accepted (write a new ADR to supersede). Template:

```markdown
# ADR-NNNN: Title
- Status: Proposed | Accepted | Superseded by ADR-NNNN
- Date: YYYY-MM-DD
- Related: RFC-…, ADR-…
- Sources: primary links the decision was verified against
## Context
## Decision
## Alternatives considered
| Alternative | Pros | Cons | Why not |
## Consequences (positive / negative / risks)
```

| # | Title |
|---|---|
| [0001](https://cloudhal.dev/docs/adr/0001-contract-pipeline) | JSON Schema is the contract source; TS and Rust are generated |
| [0002](https://cloudhal.dev/docs/adr/0002-storage-interface) | Thin SQL/blob driver interface, batch-only, two backends |
| [0003](https://cloudhal.dev/docs/adr/0003-studio-architecture) | Studio is a Vite React SPA on Kumo; Astro only renders sites |
| [0004](https://cloudhal.dev/docs/adr/0004-mcp-server) | Stateless MCP server (spec 2026-07-28), layered approval |
| [0005](https://cloudhal.dev/docs/adr/0005-skills-format) | Skills follow the Agent Skills spec; Cloudhal data in metadata |
| [0006](https://cloudhal.dev/docs/adr/0006-toolchain-and-runtime) | Node-first toolchain and runtime; Bun supported, not primary |
| [0007](https://cloudhal.dev/docs/adr/0007-license-and-community) | Apache-2.0 open core, DCO, Contributor Covenant 3.0 |
| [0008](https://cloudhal.dev/docs/adr/0008-runtime-validation-and-d1-testing) | Precompiled schema validators; D1 tested through Miniflare |
| [0009](https://cloudhal.dev/docs/adr/0009-renderer-sources-and-local-dev) | Renderer: live in dev, snapshot in builds; starters own routes |
| [0010](https://cloudhal.dev/docs/adr/0010-auth-better-auth) | Better Auth for identity, tenants and MCP authorization |
| [0011](https://cloudhal.dev/docs/adr/0011-agent-layer) | Agent layer: SDK-free policy/audit/skills, MCP wrapper, MRTR approval + token fallback, API keys |
| [0012](https://cloudhal.dev/docs/adr/0012-cloudflare-deployment) | Cloudflare: site + admin Workers, pointer-flip publishing, Workers Cache, signed previews |
| [0013](https://cloudhal.dev/docs/adr/0013-npm-packaging) | npm packaging: sources in the repo, dist in tarballs; CLI ships Studio/starters/skills; create-cloudhal |
| [0014](https://cloudhal.dev/docs/adr/0014-out-of-band-approvals) | Approvals in Studio: single use, bound to the call and the content state; no agent self-approval |
| [0015](https://cloudhal.dev/docs/adr/0015-agent-modes) | Agent modes: plan, manual, edits, auto (low-risk publishes), bypass (local only) |
| [0016](https://cloudhal.dev/docs/adr/0016-time-boxed-elevation) | Time-boxed elevation: a more autonomous mode until a set time, computed per call, audited |
| [0017](https://cloudhal.dev/docs/adr/0017-taxonomy-and-redirects) | Taxonomy and redirects are graph nodes; terms referenced by id; governed rename/merge with automatic 301s (RFC-0002) |
| [0018](https://cloudhal.dev/docs/adr/0018-roles-and-invites) | Staff roles are scope sets; agent keys never exceed their creator; approvals follow roles; invites are one-time links (RFC-0005) |
| [0019](https://cloudhal.dev/docs/adr/0019-navigation-menus) | Menus are Menu nodes with a tree of id-referenced items; agent edits always approved (RFC-0003) |
| [0020](https://cloudhal.dev/docs/adr/0020-assets-and-media) | Assets are content-addressed nodes with SHA-256; SSRF-safe agent fetch; asset:upload / asset:manage (RFC-0004) |
| [0021](https://cloudhal.dev/docs/adr/0021-site-analytics) | Site analytics: first-party cookie-less script, Workers Analytics Engine via bindings (no token), D1 rollups, `analytics:read` (RFC-0006) |
| [0022](https://cloudhal.dev/docs/adr/0022-reader-comments) | Reader comments: guest comments held for moderation, D1 outside the graph, first-party proof of work, agents label and hide while people approve (RFC-0007) |
| [0023](https://cloudhal.dev/docs/adr/0023-schedules) | Schedules: content releases pinned to exact versions, render-time time gates, one every-minute tick (cron or DO alarm), deterministic jobs plus MCP agent tasks (RFC-0008) |
| [0024](https://cloudhal.dev/docs/adr/0024-snapshot-bundles) | Snapshot bundles: the site Worker loads a published snapshot from 256-node shard objects, written at publish and read-through; per-node blobs stay the source of truth (RFC-0010) |
| [0025](https://cloudhal.dev/docs/adr/0025-email-delivery) | Email delivery: one `MailSender` on the Cloudflare Email Service binding, locked to one sender; emailed invites and self-service password reset (RFC-0011) |
| [0026](https://cloudhal.dev/docs/adr/0026-html-body-sanitizer) | HTML bodies are sanitized at render time: parse5 (spec-compliant, Worker-safe), a fixed allowlist, our own serializer; stored content unchanged (RFC-0012) |
| [0027](https://cloudhal.dev/docs/adr/0027-observability-and-placement) | `cloudhal.json` `observability` (log sampling, opt-in traces) and `placement: "smart"` for the admin Worker; query strings always redacted from logs and traces (RFC-0013) |
| [0028](https://cloudhal.dev/docs/adr/0028-body-blocks-and-operations) | Bodies stay markdown under profile v1 with a store-maintained block index (`profile`, `blocks`); agents edit by block with `get_body`/`edit_body` (apply, rebase, propose); diffs and proposals by block (RFC-0014) |
| [0029](https://cloudhal.dev/docs/adr/0029-ai-commands-and-model-choice) | In-editor AI on Cloudflare Workers AI (binding on Workers, REST from Node, optional AI Gateway); the model is chosen per site; `ai:invoke` person-only, daily cap, audited without text (RFC-0016) |
| [0030](https://cloudhal.dev/docs/adr/0030-block-registry) | One block registry for page sections (`component` kind) and bodies (markdown directives); props in a JSON Schema subset checked by an `eval`-free interpreter that also drives Studio forms; global English type names (RFC-0015) |
| [0031](https://cloudhal.dev/docs/adr/0031-theme-and-ui-generator) | A generator that writes data only: theme settings in `Site.theme.config` (declared by the theme, props subset, contrast rules), plans applied as drafts or proposed, Studio panels as validated Kumo catalog trees with allowlisted components, scoped read-only data and actions through the policy engine (RFC-0017) |
| [0032](https://cloudhal.dev/docs/adr/0032-icu-messages) | Studio messages are an ICU MessageFormat 1 subset (plural, select, selectordinal, number), compiled to data at build time by `@cloudhal/messages` (no parser shipped, no `eval`); parameter types generated from English (RFC-0032) |
| [0033](https://cloudhal.dev/docs/adr/0033-rtl-layout) | Right-to-left Studio — logical properties (codemod + design-lint rule), `DirectionProvider` and `<html dir>` from the locale registry, a `DirectionalIcon` block, a generated Kumo patch until upstream ships logical properties |
| [0034](https://cloudhal.dev/docs/adr/0034-translation-platform) | Translation workflow — the repo stays the source of truth, nested ICU JSON export/import with the checks on every import, Weblate self-hosted (Tolgee as the alternative), AI drafts by an agent skill uploaded as needing review |
| [0035](https://cloudhal.dev/docs/adr/0035-multilingual-publishing) | Published content in several languages: one node per locale in translation groups (source rev/hash, method, provenance, review), structure keeps per-locale maps, locale in the URL with generated `hreflang`, linked landing-page layouts, agents draft behind glossary/checks/score while people review and unreviewed machine text is labelled, Site = publication (RFC-0033) |
| [0036](https://cloudhal.dev/docs/adr/0036-multi-site-installations) | Multi-site installations: one Studio, D1 and R2 manage many isolated sites (per-site roles, export and delete), created empty or as editions, served by hostname through `site_hosts` with per-Worker allow-lists, one theme per site Worker and several site Workers per installation (RFC-0034) |
| [0037](https://cloudhal.dev/docs/adr/0037-domain-strategy) | Domain strategy: `cloudhal.com` is marketing, a future hosted control plane and the playground; `cloudhal.dev` is docs, schemas, registry and the open-source home; customer sites never run on either (they deploy into the customer's own Cloudflare account), so no third domain or PSL entry |
| [0038](https://cloudhal.dev/docs/adr/0038-public-playground) | Public playground: one SQLite Durable Object per visitor holding store, API and renderer; `@cloudhal/storage/do-sqlite` and SQL/layered/fetcher blob stores; a deny-list guard in the Worker and the API with a route-walk test and a fixed Editor principal; 60-minute TTL with a 2-hour cap; private, uncached session pages (RFC-0036) |
| [0039](https://cloudhal.dev/docs/adr/0039-cloudhal-dev-docs-site) | cloudhal.dev: Starlight in `apps/docs` over the repo's `docs/`, generated reference, a link contract checked in CI (301s, permanent `/schemas/v1`, `/e/<code>` pages from one error-code registry), docs for agents (`.md` per page, `Accept: text/markdown`, `llms.txt`, a standalone read-only docs MCP at `cloudhal.dev/mcp`, none in `cloudhal mcp` (amended)); all ADRs and RFCs published (RFC-0037) |
