CLAUDE.md · diff

git:20260904.3e3e448 to git:20260904.ca36d2b

12 added, 1 removed. Audit A to A.

# CLAUDE.md — Contributor Guide
This file is read by Claude Code (and other AI coding tools) at session start. Keep it accurate — stale guidance misleads every downstream dispatch.
## Product and goal
What Fountain is, in one paragraph, is the top of [README.md](README.md).
The standing goal since the May-2026 launch is **100 weekly active users by
month 6** (November 2026), and the one gate that hangs on it: org/team
features stay out of scope until that traction shows. Onboarding is the open
product decision on the way there (#1039). These two lines used to live in
`OPERATING_MODEL.md` and `ROADMAP.md`, the bus files for the captain-picard
orchestrator that built the first cut; that fleet stopped running against this
repo after launch and both files were deleted, so this is now the only place
the goal is written down.
## Quick start
```bash
mise install # pin Erlang/OTP 28 + Elixir 1.19.2
mix deps.get
mix setup # dev DB: create + migrate
MIX_ENV=test mix ecto.create && MIX_ENV=test mix ecto.migrate
mix test # full suite (~1,850 tests)
mix precommit # the core CI checks, locally
```
See [SETUP.md](SETUP.md) for full workstation bootstrap (Postgres, OAuth keys, etc.).
## Repo layout
```
fountain/ umbrella root
apps/
fountain/ core business logic (Elixir OTP app)
lib/fountain/ contexts: Accounts, Agents, Environments, Vaults,
| Conversations, Crypto, Audit
lib/fountain_web/ Phoenix: controllers, LiveView, plugs, router
test/fountain/ context unit tests (async: true, DataCase)
test/fountain_web/ controller/LiveView integration tests
test/support/ DataCase, ConnCase, factory.ex
fountain_buzz/ the two first-party extensions (ADR 0043, tracker
fountain_support/ #1503 and #1528). Each is an AGPL OTP app that
depends on :fountain, is named in
`config :fountain, :extensions` and is reached only
through the `Fountain.Extension` callbacks. Buzz owns
FountainBuzz.*, buzz_identities and its migrations,
/api/buzz + /api/mcp/buzz, and the harness tree;
Support owns FountainSupport.*, support_reports,
/api/support and the report forwarder.
`apps/fountain` depends on neither in any direction
and names no module of either
(extension_guard_test.exs); the release's
`applications:` decides inclusion. Their suites run
from their own directories, so
`scripts/test-libraries.sh` runs them in CI. A new
extension needs a row in extension_guard_test.exs
and a boundary test of its own; adding a tenth
`Fountain.Extension` callback needs an ADR.
Buzz also owns its supply chain (#1509):
buzz-acp.version, buzz-acp.source and the
buzz-acp-publish workflow's inputs live in its app,
and FountainBuzz.Assets both finds the binaries and
refuses one whose version does not match the pin.
`BUNDLE_EXTENSIONS=false` builds the core distribution —
no Buzz application in the release (mix.exs) and no
binaries in the image (Dockerfile), one switch for
both.
Buzz also owns a Go module, apps/fountain_buzz/cli
(#1508): the buzz-backend-fountain remote-agents
provider Buzz Desktop discovers by name on PATH,
Apache-2.0 like cli/ and released for the same four
platforms as the fountain binary. A module of its
own so Go's internal/ rule stops it reaching
cli/internal/...; it uses the public cli/api +
cli/credentials instead. `fountain buzz agents ...`
deliberately STAYS in cli/ (ADR 0043 decision 6) —
removing a subcommand from a released binary is a
user-visible break with nothing bought. Run its
tests from its own directory; `go test ./...` in
cli/ does not reach a second module.
(managoat_*/) the component libraries extracted from the server
(ADR 0037, tracker #1334): Apache-2.0, Managoat.*
namespace, no reference back into Fountain. Each
began as an in_umbrella app here and all nine have
graduated (#1345, then #1368): on hex, pinned
"~> 0.1.0" in apps/fountain/mix.exs, source in the
repository managoat/managoat_<name>, no directory
here. A future one starts as an app again;
CONTRIBUTING.md has both recipes.
managoat_substitution Managoat.Substitution, the ${VAR} engine
managoat_mcp_auth Managoat.McpAuth, RFC 9728/8414/7591 MCP
authorization discovery with the SSRF guard
managoat_oauth Managoat.OAuth, the OAuth 2.0 code+PKCE and
device-grant state machine as a `use` macro
over Managoat.OAuth.Host; Fountain.OAuth is the
instance, Fountain.OAuth.Host mints the API key
managoat_acp Managoat.ACP, the client-side ACP session
(Peer), Protocol, Permissions, Blocks, Usage,
Tracer and the ScriptedAgent behind a writer
callback; the provisioning half is
managoat_runtimes (below); LegacyBlocks and
Conversations.Blocks stay here
managoat_sandbox Managoat.Sandbox, the sandbox behaviour, the
Sprites/E2B/Daytona adapters, Retry, the Fake
and the conformance case; the "which providers
are enabled here" policy
(Fountain.SandboxProviders) stays here.
apps/managoat_runner takes it from hex too.
managoat_docs Managoat.Docs, the compile-time embedded manual
(a `use` macro Fountain.Docs is one instance
of), Managoat.Docs.Markdown (both render paths)
and Managoat.Docs.GuardrailCase; docs/, nav.yml,
Fountain.Help, the prose gates and the /docs
controller stay here
managoat_broker Managoat.Broker, the native egress credential
proxy (CONNECT + absolute-form forward proxy,
the derived CA, the header injector) behind
Managoat.Broker.Store; fountain implements the
store over broker_sessions, runs the listener
in-process (BROKER_LISTEN_PORT) and writes the
egress log to broker_requests. It is the only
backend since the 2026-09-03 flip (ADR 0019)
managoat_runner Managoat.Runner, the self-hosted runner wire
protocol (Connection), the sandbox adapter over
it, the sandbox-name shape and the FakeDaemon,
behind Managoat.Runner.Host; Fountain.Runners.Host
implements it over Horde, and the runners
table, placement and presence stay here
managoat_runtimes Managoat.Runtimes, how claude/codex/gemini/
opencode get into a sandbox speaking ACP: the
behaviour and for_runtime/1 (the agent is a
plain map, not %Agent{}), the pinned ACP
adapter table and install, Layout,
Instructions, Quirks, the provider/model_id
parser (Model), Skills and the FakeRuntime.
The model suggestion catalog
(Fountain.Agents.ModelCatalog), the bundled
skill content (Fountain.SandboxSkills), the
permission-ask timeout read
(Conversations.Lifecycle.ask_timeout_ms/0),
LegacyBlocks and InferenceCredentials stay here
ee/ credits, Stripe and the credit emails (welcome,
lib/fountain/ credits-low/exhausted, rent-due), compiled into the
lib/fountain_web/ same :fountain app via elixirc_paths. Licence: ee/ is
test/ Elastic 2.0, the server is AGPL-3.0 (0010, 0027).
Account email + Mailer are core (#475/#476).
config/
config.exs shared config
dev.exs dev overrides
test.exs test overrides (pool_size: 20, test-mode flags)
prod.exs prod overrides
.github/workflows/ci.yml CI pipeline
decisions/ ADRs (Architecture Decision Records)
docs/ the public manual, served at /docs (nav.yml is the nav)
```
## Architecture: the four primitives
| Primitive | Purpose |
|---|---|
| **Environment** | Baseline set of encrypted env vars + runtime config (packages, repos, scripts) attached to an agent. A conversation may name a different one at launch (`environment_id`; scoped by `agent.allowed_environment_ids`) — the agent's is the default, not the only choice |
| **Vault** | Free-floating bag of env-var overrides. Vault values **win on key collision** when merged with an environment at sprite spawn time |
| **Agent** | A named, re-runnable agent config — model, runtime, skills, MCP servers, optional environment |
| **Conversation** | A single run of an agent inside a Sprites sandbox. Has turns, log events, and a status lifecycle |
## Credits are the product (ADR 0031)
There are no plans, no tiers and no subscription. `CREDITS_ENABLED` means
"credits on"; off, nothing is priced, granted, gated or shown. Six rules
that are easy to get wrong:
- **The gate is the balance.** `Billing.check_spend/1` is `Credits.gate/1`:
`:ok` with billing off, for a comped account (`users.comped`, the one
operator lever), or a positive `credit_balance_cents`; else
`{:error, :insufficient_credits}` (402 with `upgrade_url`). Every door that
spends calls `check_spend/1`; the reservation lock in
`Quotas.with_sandbox_reservation/3` checks it too. In-flight turns finish
and may go negative (ADR 0030 decision 6). There is no `check_active/1`.
- **The opening credit lands at verification.** `Accounts.verify_email/2`
posts `Credits.grant_opening/2` — `CREDIT_OPENING_CENTS` ($5) expiring
`CREDIT_OPENING_DAYS` (14) later, idempotent per account. In tests
`insert_verified_user/1` therefore holds $5 and may spend; drain it with a
`burn_turn` debit to test refusal. `insert_active_user/1` is the same
function, kept for readability.
- **Concurrency is funded by the balance, under a fleet ceiling.**
`Quotas.sandbox_limit/1` = `sandbox_limit_override` if set, else
`clamp(balance ÷ SANDBOX_RESERVE_CENTS, SANDBOX_CAP_FLOOR, SANDBOX_CAP_CEILING)`
(defaults $2 / 2 / 20; comped and billing-off get the ceiling).
`SANDBOX_FLEET_CEILING` bounds live sandboxes across every tenant under a
global advisory lock taken before the per-user one; `:fleet_full` is 503,
not 402. Anything that displays a cap shows `Quotas.sandbox_limit_for/1`.
- **Teammate contacts are rented from the balance, not a Stripe item.**
`Credits.Rent` takes `CREDIT_NUMBER_CENTS + CREDIT_INBOX_CENTS` a month up
front at provisioning and on each anniversary, with a seven-day grace before
release (ADR 0030 decision 4). `TEAM_CONTACT_CEILING` is an abuse ceiling,
not an allowance. Free numbers: comp the account, or grant credit.
- **A turn hour is not a sandbox hour.** Turns burn credit against
`SandboxUsage`'s `turn_seconds` (summed per turn, clipped to the period) on
platform-paid providers only — an idle sandbox and a self-hosted runner
spend none of it. `busy_seconds` is the *union* of the same intervals and is
what a provider bill relates to; several conversations share one sandbox
(ADR 0023), so the two differ and must not be swapped.
- **The ledger.** `Fountain.Credits` keeps a cents ledger (`credit_ledger`,
cached on `users.credit_balance_cents`, idempotent per row, never summed on
a gate; every credit row is a lot with `remaining_cents`, and a debit
consumes lots in order: the lot it names, earliest expiry, then purchased).
`CreditPricer` burns closed turns at `CREDIT_TURN_HOUR_CENTS` (default 25)
and comms messages when priced, seven days back;
`CreditExpirer` (and the pricer's tick) expires unspent grants; `Credits.Purchases` sells packs
through one-time Checkout and claws back on `charge.refunded` /
`charge.dispute.created`. Stripe holds no subscription and no price.
Usage is reported over the calendar month (`Billing.current_month_range/0`).
**decisions/0031-credits-are-the-product.md** and
**decisions/0030-prepaid-credits.md** own the rest.
## Tenant isolation contract
Every user-facing query is scoped by `user_id`. The pattern is consistent:
```elixir
# CORRECT — tenant-scoped
Agents.get_agent(id, user_id)
Agents.list_agents(user_id, filters)
# WRONG — unsafe, admin-only, prefixed accordingly
Agents._unsafe_get_agent(id)
Conversations._unsafe_get_sandbox(id)
```
Functions prefixed `_unsafe_` bypass tenant scoping. **Never call one as the
first fetch in a user-facing request** — ownership must already be
established on that request before an `_unsafe_` call is legitimate.
The prefix goes on *every* unscoped function, including ones whose callers
happen to check ownership first — the reader of a call site should not have to
go and find out. The legitimate callers are:
- an admin surface behind `require_admin`;
- a system-level sweep like the rehydrator or `SandboxReaper`;
- a GenServer that has already established ownership;
- **a user-facing call site directly after a tenant-scoped parent fetch** —
the dominant pattern since #383:
```elixir
# CORRECT — ownership established by the scoped get_vault above
vault = Vaults.get_vault(vault_id, user.id)
secrets = Vaults._unsafe_list_secrets(vault)
```
The scoped fetch and the `_unsafe_` child call must be adjacent and in the
same function, with a comment saying which fetch established ownership.
## Envelope encryption
Secrets (environment and vault) are encrypted at rest using a per-tenant DEK (Data Encryption Key) derived from a platform master key:
```elixir
{:ok, dek} = Fountain.Crypto.load_tenant_key(user_id)
# Persist
Environments.upsert_secret(env, %{"key" => "TOKEN", "value" => "plaintext"}, dek)
# Read back
%{"TOKEN" => "plaintext"} = Environments.decrypted_env(env, dek)
```
- `dek` is a binary; `upsert_secret` accepts **string-keyed** maps (`%{"key" => ...}`).
- `decrypted_env/2` returns a plain `%{"KEY" => "value"}` map (not a tagged tuple).
- The master key lives in `MASTER_SECRETS_KEY` env var. See `.env.example`.
## Substitution engine
`Managoat.Substitution.apply(value, vars)` substitutes `${VAR}` references.
It is the hex package `managoat_substitution`, the first library extracted
under ADR 0037 and the first to graduate to its own repository
(managoat/managoat_substitution, #1345); `apps/fountain/mix.exs` pins it at
`~> 0.1.0`. There is no `Fountain.Substitution` any more.
- `${VAR}` → value from `vars` map
- `$$` → literal `$`
- Returns `{:ok, result}` or `{:error, {:missing_vars, sorted_list}}`
- Recursively walks maps and lists; collects **all** missing vars, not just the first
## The web UI is a console
Fountain's own browser UI is an **operator console**: the dashboard, agents,
environments, vaults, audit, API keys, account and admin. It is deliberately
not an interactive application.
Watching an agent work and messaging a teammate are separate single-page apps
on `/api`, on their own origins with their own OAuth clients:
| App | Repo | Replaced |
|---|---|---|
| Conversations | [jhgaylor/fountain-conversations](https://github.com/jhgaylor/fountain-conversations) | `/conversations`, `/conversations/new`, `/conversations/:id`, `/conversations/:id/logs` |
| Team | [jhgaylor/fountain-team](https://github.com/jhgaylor/fountain-team) | `/team`, `/team/:agent_id` |
`Fountain.Apps` is the only place that knows where they live
(`CONVERSATIONS_APP_URL` / `TEAM_APP_URL`, defaulting to the hosted builds;
`""` means this deployment has none). Anything that links a human to a
transcript — the console, an email, a forwarded support report, `/api/catalog`
— reads it from there. The old paths redirect (`FountainWeb.MovedController`)
rather than 404, and `/onboarding*` goes to the dashboard, whose checklist
replaced the wizard.
**Building a conversation-facing feature? It goes in the app, not here.** The
server's job is to serve it: `?blocks=true` on `/events` and the streams means
no client re-parses a runtime dialect.
## LiveView auth hooks
`FountainWeb.Live.Hooks` provides these `on_mount` guards:
| Hook | Unauthenticated | Authenticated but ineligible |
|---|---|---|
| `require_authenticated_user` | `redirect` to `/auth/login` | unverified: `redirect` to `/auth/verify-pending` |
| `require_pending_verification` | `redirect` to `/auth/login` | already verified: `redirect` to the dashboard |
| `require_admin` | `redirect` to `/auth/login` | `push_navigate` to `/dashboard` |
The distinction matters in tests: plain `redirect` yields `{:redirect, _}` (the
login redirect), while `push_navigate` yields `{:live_redirect, _}` (the
non-admin case in `require_admin`).
There is no router-level billing gate. The gate that protects spend is
`Billing.check_spend/1` **in the context** (ADR 0031), so every door gets it
— see `ee/test/fountain/credits_enforcement_test.exs`.
## Rate limiter
`FountainWeb.Plugs.RateLimit` — ETS-backed, keyed by IP in prod. In tests:
```elixir
# config/test.exs
config :fountain, :rate_limit_test_isolation, true
```
This switches the key to `{bucket, self()}` so async ExUnit tests don't share counters. Leave this enabled in test config.
## Ueberauth test mode
The `UeberAuthController` skips `plug Ueberauth` when `ueberauth_test_mode: true` (set in `config/test.exs`). This prevents the OAuth plug from overwriting manually-set `conn.assigns` in tests. Don't remove this flag.
## Audit logging
**Mutations audit in the context, not the caller.** A function that changes
tenant-owned state records its own event, so the UI, the API, background
workers and any future surface are covered by construction rather than by each
caller remembering. This is the rule the #540 campaign established after
finding seven contexts with no audit calls at all and coverage that depended on
which door a request came through.
```elixir
# CORRECT — the context records; the caller supplies only attribution
def create_agent(attrs, opts \\ []) do
%Agent{} |> Agent.changeset(attrs) |> Repo.insert() |> audited("agent.created", opts)
end
# at the call site
Agents.create_agent(attrs, Audited.attribution(conn)) # or (socket)
Agents.create_agent(attrs, actor: "system:my_worker") # background caller
```
`FountainWeb.Audited.attribution/2` returns the `:actor` / `:request_ip` pair
for a `%Plug.Conn{}` or a LiveView socket. Pass an override where the
connection cannot reveal the actor — at registration and at
`POST /api/auth/token` there is no session yet, so derivation would report
`"system"` for a plainly-human action.
Three rules that are easy to get wrong:
- **Never audit inside a transaction.** `record/1` is best-effort *by rescuing*,
and that does not survive a transaction — a failed insert aborts the enclosing
one, so a lost audit row would take the mutation with it. Record outside.
- **Never record values.** Update events name the fields that changed
(`Audit.changed_fields/1`); secret, credential and prompt events record keys,
sizes and providers. The trail is not a second copy of tenant data.
- **Only record what happened.** A rejected changeset or a no-op sync records
nothing; a trail that logs attempts as changes is worse than no trail.
`apps/fountain/test/fountain/audit_guardrail_test.exs` enforces this: add a
context mutation and the guardrail fails until it audits, or until you add it
to the documented exclusion list with a reason.
The actor vocabulary is closed: `self` (the context default), `ui`, `api`,
`sprite`, `admin`, `admin:<operator_id>` (account deletion only) and
`system:<worker>`. A bare `"system"` — what `attribution/2` derives when a
request has no principal — is a defect, not a value: the unauthenticated
routes (login, registration, `POST /api/auth/token`, email verification) pass
an explicit actor. **decisions/0013-audit-trail.md** owns all of this, plus
the deliberate exclusions, the two-rows-per-API-mutation choice and the
deletion semantics; read it before adding an actor string or moving a
recording out of a context.
`Fountain.Audit.record!/1` is deliberately test-only — see its docstring. Never
wrap `record/1` in a way that makes it blocking for the user.
## CI pipeline
`.github/workflows/ci.yml` runs on every push to `main` and all PRs:
1. `mix deps.unlock --unused` (fails if `mix.lock` has unused entries)
2. `mix format --check-formatted`
3. `mix compile --warnings-as-errors`
4. `mix credo --strict`
5. `mix hex.audit` (non-blocking — visible in the log, doesn't fail the build)
6. `mix sobelow --config` (security scan)
7. `MIX_ENV=dev mix dialyzer`
8. Go CLI checks: `go test ./...` and `go vet ./...` in `cli/`
9. `mix ecto.create && mix ecto.migrate`
10. The test suite, as six partitions in six parallel jobs (`scripts/test-partition.sh`), plus a `coverage` job that merges their exports with `scripts/coverage-gate.exs` and enforces the 85% threshold
11. Release boot check — builds a prod release, probes `/health` and `/health/ready`, runs a release task beside the live server
12. `mix openapi.spec.json` + `jq empty` (OpenAPI spec validates)
On `main` the run usually short-circuits: the `already-tested` job compares
the pushed commit's tree with the head of the merged PR and, when they are
identical and that head has a successful `pull_request` CI run, every other
job is skipped and `build.yml` starts straight away (the run still concludes
`success`, which is its gate). A rebased squash-merge is that case; anything
else — a tree that differs, no PR, no green run — gets the full run.
### Coverage
Coverage uses Elixir's built-in cover, not ExCoveralls — ExCoveralls cannot
merge results across machines, and since #620 the suite runs as six
partitions on six of them (three until #894). Settings live in `coverage.exs` at the repo root
(read by both mix.exs files): `summary: [threshold: 85]` and `:ignore_modules`,
which replaced `coveralls.json`'s `minimum_coverage` and `skip_files`.
`:ignore_modules` matches **module names**, not source paths — a bare atom for
one module, a regex against `inspect(module)` for what used to be a
directory entry. Locally, `mix test --cover` reports and gates in one step.
CI enforces the threshold with `scripts/coverage-gate.exs`, not
`mix test.coverage`: ~90% of that task's time is rendering an HTML report the
job never opens. The script reads the same `coverage.exs` and was verified to
produce the identical total (85.46% on the same six exports). Run
`mix test.coverage` locally when you want the per-module table or the HTML —
and re-verify the two agree when bumping Elixir, since the script depends on
`:cover` semantics the pin currently freezes.
Run `mix precommit` locally before pushing — it covers the core gate (compile
with warnings-as-errors, unused deps, format, `credo --strict`, sobelow,
dialyzer, a prod release **assemble**, tests). CI additionally runs hex.audit,
the Go CLI checks, the release **boot** check, and OpenAPI validation.
The release assemble is the only step that builds `MIX_ENV=prod`, and it is
there because everything else is blind to a prod-only dependency graph: the
OpenTelemetry family is `only: :prod`, so `chatterbox` exists in no dev or test
build, and the hackney 4 bump that collided with it left every other gate green
on a tree whose release would not assemble (#1472, #1477). It costs ~9s warm;
the first run in a fresh checkout pays a full prod compile (~2 min) and then
caches it under `_build/prod`. It needs no secrets — assembling copies
`config/runtime.exs` in as a config provider rather than evaluating it, which
is why CI keeps the booting half where `SECRET_KEY_BASE` and a database are
required.
## Test patterns
`mix test` at the umbrella root runs core, `ee/test` **and** every sibling app
(`apps/fountain_buzz`, `apps/fountain_support`). To run a single ee file by
path, run from `apps/fountain` (`mix test ../../ee/test/...`) or pass an
absolute path — root-relative `ee/...` paths don't resolve.
`apps/fountain` deliberately depends on no sibling app, so neither
`:fountain_buzz` nor `:fountain_support` is on the code path there: running from
`apps/fountain` (which is what CI's partition script does) runs core only, and
`config/runtime.exs` installs each extension **only where it loads**. Run an
extension's suite from its own directory, or from the root.
### Database tests
```elixir
use Fountain.DataCase, async: true # SQL Sandbox, isolated per-test
```
Pool size is `20` to handle concurrent async modules. Don't lower it.
### Factory helpers
All helpers live in `test/support/factory.ex` and are imported by `DataCase`:
```elixir
user = insert_verified_user()
agent = insert_agent(user_id: user.id)
env = insert_env(user_id: user.id)
vault = insert_vault(user_id: user.id)
# Factories accept keyword lists or atom/string-keyed maps
# and always persist through the real changeset pipeline
```
`*_attrs/1` helpers return **string-keyed** maps (e.g. `%{"name" => "..."}`) — match with `attrs["name"]`, not `attrs.name`.
### Non-DB tests
```elixir
use ExUnit.Case, async: true # e.g. Substitution, pure logic
use ExUnitProperties # StreamData property tests (installed)
```
### Mocking
Mimic is available. Prefer integration tests through real changesets over heavy mocking.
## Things NOT to do
- **Don't reach for `_unsafe_*` without established ownership.** These skip tenant scoping; the legitimate caller shapes (including the scoped-parent-fetch-then-`_unsafe_`-child pattern) are in the tenant isolation section above.
- **Don't lower the test pool size below 20.** Pool exhaustion causes flaky timeouts.
- **Don't remove `:ueberauth_test_mode` or `:rate_limit_test_isolation` from `config/test.exs`.** They're correctness guards, not performance flags.
- **Don't start fire-and-forget work with `Task.async`.** It *links* to the
caller and nothing awaits it, so a transient failure in a write nobody wants
the result of takes the request process down — and under the SQL Sandbox that
process is the test, which is how a stamp on `last_used_at` failed a
scheduling test that had already passed (#1040). Use
`Task.Supervisor.start_child(Fountain.TaskSupervisor, fun)`: unlinked,
supervised, and a crash is logged. `DataCase` waits for the tasks a test
started before stopping the sandbox owner, so the write lands rather than
having its connection pulled away. `Task.async` is right where the caller
keeps the ref and handles the reply (`Analytics.Sink`).
- **Don't push directly to `main`.** All changes go through PRs; the CI gate must pass.
- **Don't add `async: false` to tests unless the test genuinely requires it** (e.g. global ETS state). The SQL Sandbox handles DB isolation.
## Adding a new context
1. Create `apps/fountain/lib/fountain/<context>.ex` with tenant-scoped functions.
2. Add a test at `apps/fountain/test/fountain/<context>_test.exs` with `use Fountain.DataCase, async: true`.
3. Use `_unsafe_` prefix for any admin/internal functions that bypass tenant scoping.
4. If the context handles secrets, use `Fountain.Crypto.load_tenant_key/1` + the pattern above.
## Environment variables
See `.env.example` for the full list. Key ones for local dev:
| Var | Purpose |
|---|---|
| `DATABASE_URL` | Postgres connection (defaults to `localhost:5432/fountain_dev`) |
| `MASTER_SECRETS_KEY` | Platform master key for envelope encryption |
| `SPRITES_TOKEN` | Token for the Sprites sandbox platform |
| `GITHUB_OAUTH_CLIENT_ID/SECRET` | GitHub OAuth app |
| `STRIPE_SECRET_KEY` / `STRIPE_WEBHOOK_SECRET` | Credit-pack Checkout and the three credit webhooks (`CREDITS_ENABLED=true` only) |
| `RESEND_API_KEY` | Transactional email |
## Docs (`docs/`)
`docs/` is the public manual, published at `/docs` and **nowhere else** — the
markdown is embedded at compile time by `Fountain.Docs`, which is one `use`
line over the `managoat_docs` library (ADR 0037): the macro embeds the pages,
`Managoat.Docs.Markdown` renders them (the sanitising pipeline `/help` and
agent output share; it returns a binary, callers `Phoenix.HTML.raw/1` it),
and `docs_test.exs` gets its structural checks from
`Managoat.Docs.GuardrailCase`. It used to be built as
a MkDocs Material site and deployed to GitHub Pages as well; that copy was
retired in #1008, so there is no `mkdocs.yml`, no `docs.yml` workflow and no
`mkdocs build` step to keep green. A page reaches a reader through `/docs` or
not at all.
The one thing left on GitHub Pages is a **tombstone** (#1011): one redirect per
old URL into `/docs`, built by `scripts/build-pages-tombstone.py` from the nav
and published by hand from `.github/workflows/pages-tombstone.yml`. It is
`workflow_dispatch` only and reads no page content. **Do not turn it into a
docs publishing path**, and do not treat a docs edit as needing a re-run —
the redirects only go stale if a page's *slug* changes.
Guardrails that trip people who only edit markdown:
+ - **An extension may own pages** (ADR 0043, #1510). They live in
+ `apps/<app>/docs/` with that app's own `nav.yml`, embedded by its own
+ `use Managoat.Docs` module and merged into the sidebar by `Fountain.Manual`,
+ which is what every renderer asks instead of `Fountain.Docs`. Sections merge
+ by title, so a moved page keeps its place. A **core page must not link to
+ one** — `Fountain.DocsTest` walks the core manual alone and a core-only
+ distribution would have a dead link; the extension's own suite runs the link
+ checks over the merged manual.
- **The nav lives only in `docs/nav.yml`.** `Fountain.Docs` parses that file's
`nav:` block at compile time, so adding, renaming or moving a page is a
one-file change. It used to be mirrored in `@nav` in
`apps/fountain/lib/fountain/docs.ex` with a drift test, which is exactly how
docs-only PRs kept going red in *partition 3* of CI, looking unrelated to
the docs. Keep to the two line shapes the parser reads — ` - Title: x.md`,
and a ` - Section:` header with six-space-indented children — because a
line it cannot read raises at compile time rather than being skipped.
**Sections are one level deep.** The in-app sidebar renders exactly a section
and its pages, so a sub-section (or a page indented past its siblings) raises
too. Flatten it into a sibling section — `Catalog` is what that looks like,
with the three hub pages sitting among their own entries — or make it
headings on a hub page.
- **A page not in the nav is published nowhere.** `docs_test.exs` walks
`docs/**/*.md` both ways: every page the nav names exists, and every page on
disk is named. There is no allowlist. If a markdown file should not be read
at `/docs`, it belongs somewhere else — `decisions/` and `standards/` are
deliberately unpublished. MkDocs used to build an unlisted page anyway, so
four planning pages accumulated under `docs/superpowers/` before this check
existed (since deleted).
- **Anything `Fountain.Docs` reads at compile time must be `COPY`d into the
Docker build stage.** The release image contains no `docs/`, only strings
baked out of it, so a file outside the `COPY` list does not degrade to a
broken link: `mix release` dies, no image is built, CI stays green and the
deploy silently never happens (#884). `docs_test.exs` asserts the module's
`external_resources/0` (every `@external_resource` the `use` declared)
against the Dockerfile, so adding a new compile-time read fails there
first. A file outside `docs/` goes on the `extra_resources:` line of
`Fountain.Docs`, which is how `CHANGELOG.md` is declared.
- **Links and anchors are checked by the suite, not by a site build.** Every
internal `/docs` link must resolve to a page, and every `#anchor` to a
heading on that page. `mkdocs build --strict` used to carry the link half of
this on `main` only; `docs_test.exs` carries both, on every PR, and it is the
stricter of the two — MkDocs never checked anchors. Run
`mix test apps/fountain/test/fountain/docs_test.exs`. The checks themselves
are `Managoat.Docs.Checks`, functions returning failure messages, and
`GuardrailCase` is what turns them into the tests in that file; the
library runs the same template against a fixture manual, so a change to a
check is tested there before it runs against `docs/`.
- **`python3 scripts/docs-style.py`** enforces the style sheet
(`standards/voice-and-style.md`): no em dashes, no colon-introduced
lists, no "simply"/"obviously"/"coming soon". It skips the backlog in
`scripts/docs-style-allow.txt`, so a file **not** on that list is checked and
every new page is covered by default. Cleaning a page means deleting its
line; the list only shrinks (#911).
- - **`vale lint docs` enforces ASD-STE100 Simplified Technical English.** Every
+ - **`vale lint docs $(ls -d apps/*/docs)` enforces ASD-STE100 Simplified
+ Technical English.** An extension's slice of the manual (ADR 0043) is held to
+ the same gate; vale selects from its path arguments, so those directories are
+ named explicitly, while `docs-style.py` and `destink.mjs` discover them. Every
published page is written in it. The standard is
`standards/simplified-technical-english.md`; config is
`.vale-ste.yml`; the backlog is `.valeignore` and is **empty**. The linter is
[`stuffbucket/vale`](https://github.com/stuffbucket/vale) — MIT, pure Go.
Install it with `brew install stuffbucket/tap/vale`. CI uses the pinned
`v0.15.0` release binary, checksum-verified, **not** `go install`: the jobs
pin Go from `cli/go.mod` with `GOTOOLCHAIN=local` and cannot fetch the newer
toolchain the module wants. The gate is six rules: sentence length (20
procedural / 25 descriptive), contractions, the passive voice, phrasal verbs,
one instruction per sentence, and the -ing form. `STE.Vocabulary` advises and
does not gate, because its wordset was built for aircraft maintenance. Read
the standard before you fight the linter — it lists the three traps (joined
table cells, a code span opening a sentence, `anything` matching the -ing
rule) and where a suppression comment is legitimate.
- **`node scripts/destink/destink.mjs` looks for AI-writing tells.** The third
prose gate. The engine is the published
[`sentences`](https://github.com/lex00/sentences) package (MIT), pinned by
the version range in `scripts/destink/package.json` — bump it and run
`npm install --prefix scripts/destink` to pull in an upstream change.
`scripts/destink/allow.txt` is the backlog and is **empty**, like the other
two. Two things to know before fighting it:
- **It lints prose, not markdown.** The package's `lint/markdown-prose`
export blanks code fences, tables, inline code, link targets, HTML blocks
and admonition directives, replacing each character with a space so every
offset still indexes the real file. Run whole over raw markdown the linter
reported 2,721 findings, of which ~1,750 were markdown mistaken for
writing: `--` in a CLI flag read as an em dash, table rows read as
sentence fragments, and `guides` and `operate` read as words because they
sat in a URL. If a finding points at something that is not prose, the fix
belongs upstream in `lint/markdown-prose`, not in the page.
- **The rule set is opt-in, and each entry carries its count.** `ENABLED` and
`DISABLED` in `scripts/destink/destink.mjs` list all 46 rules with the
number each produced over `docs/` and, for the disabled ones, why. Opt-in
is the safety property: a rule added upstream between versions arrives
off. The gate refuses to run if an id named there is missing from the
package's registry, which is what a rename upstream looks like from here.
- **`docs/cli.md` is diffed against the CLI.** `cli/internal/cmd/docs_test.go`
fails if a command exists that the page does not mention, or the page
mentions one that does not exist. Add a CLI command → add it to `docs/cli.md`
in a fenced `bash` block.
The dialect the renderer understands is small (snippet includes, admonitions,
relative `.md` links) and is inherited from the MkDocs site these pages used to
be; check a page at `/docs` if it uses anything fancier. Nothing else renders
them, so `/docs` is the answer, not a second opinion.
## Decisions
Architecturally significant choices live in `decisions/NNNN-<title>.md`. When a decision is contentious or needs to constrain future work, write an ADR. Use `decisions/0001-template.md` as the template.
`decisions/` is an [OKF](https://github.com/GoogleCloudPlatform/knowledge-catalog/blob/main/okf/SPEC.md) bundle. Start at `decisions/index.md`, which lists every ADR with its status, whether it has been verified against the code, and when its status block goes stale. Each ADR's frontmatter carries `adr_status`, `verified` and `stale_after`; the template explains the fields. CI runs `okf validate decisions` and regenerates the index (`scripts/decisions-index.sh`), so a new ADR needs frontmatter and an index refresh in the same PR; run both locally before pushing. `okf backlinks decisions <id>` lists the ADRs that depend on one before you amend it. The `okf` CLI is a single Go binary from [okfcli/okf](https://github.com/okfcli/okf): `brew install okfcli/okf/okf`, or `go install github.com/okfcli/okf/cmd/okf@latest`, or the pinned linux tarball the workflow downloads (`.github/workflows/decisions.yml`). Every command prints JSON; `okf schema` describes them all.
**ADRs and docstrings must not describe unbuilt behavior as existing.** The 2026-07 audit found three mechanisms asserted as implemented (a quota check, metering call sites, a gate backstop) that did not exist — anyone reading the ADRs concluded the system was metered, capped and gated when it was none of those. If a document describes behavior that is not yet in code, say so explicitly (`**Status:** Proposed`, "not yet built"), and remove the caveat in the PR that builds it.