# Contributing to Managoat Use [CLAUDE.md](CLAUDE.md) for the repo map and core rules. For a change: 1. Run the relevant [local checks](#before-you-push). 2. Open a [PR](#pull-requests) whose title says what changed for a reader of the release notes, and merge it once CI is green. Server implementation details are in [Server conventions](contributing/server.md). Read the API, migration and library sections below when the change touches them. ## Ideas and accepted work **A discussion records something we might pursue. An issue records a next action we have accepted.** Start unaccepted feature ideas and deferred product directions in [Discussions](https://github.com/managoat/managoat/discussions): - **Ideas** is for problems, opportunities, and exploratory directions. - **Proposals** is for a concrete approach awaiting a decision, including a designed feature we have chosen to defer. Record the problem, supporting evidence, current decision, and what would make us reconsider it. A proposal also records the approach, alternatives, and the smallest useful next step. A discussion is not a delivery commitment. Issues hold accepted fixes, scoped changes, bounded investigations and decisions, and delivery work blocked by a named dependency. Keep reporting bugs as issues; triage establishes their scope and disposition. An accepted issue needs a concrete deliverable, completion criteria, and known dependencies. A date or assignee is not required for accepted backlog work. Use the [Managoat roadmap](https://github.com/orgs/managoat/projects/1) to sequence it. When a maintainer accepts a next step from a discussion, create an issue for that step and link the two records in both directions. Record architectural outcomes in `decisions/` using the existing ADR process. Keep the discussion's broader possibilities separate from the issue's accepted scope. When moving an existing idea out of Issues, use GitHub's **Convert to discussion** action to preserve its history. Read the latest decisions first: a parked label or an exploratory title alone does not establish that work is unaccepted. If an issue mixes an idea with a concrete bug or approved task, preserve that action in its own linked issue before converting the broader record. ## Licensing Managoat is licensed under the [Apache License 2.0](LICENSE), and so is what you contribute: section 5 of the licence covers it. There is no contributor agreement and nothing to sign. You keep the copyright in your work. ## Before you push Run focused tests while iterating. Before pushing, choose the checks for the parts you changed; mixed changes take the union of the relevant rows. | Change | Local checks | |---|---| | Server Elixir code, tests, dependencies, or runtime/build configuration | `mix precommit`, plus the tests covering the changed behavior | | Go CLI | In `cli/`: `go test -mod=readonly -count=1 ./...` and `go vet -mod=readonly ./...`; format changed Go files with `gofmt` | | SDK implementation or packaging | That SDK's checks in [CI maintenance](scripts/ci/README.md#sdk-jobs); API changes also follow [Changing the API](#changing-the-api) | | CI decision logic or workflow wiring | `python3 -m unittest discover -s scripts/ci -p 'test_*.py'`; use `actionlint` for changed workflows and run the changed script's own checks | | Shell tooling | `shellcheck` and `bash -n` on changed Bash scripts, plus their focused regression tests | | The managoat.com pages (`site/`) | In `site/`: `mix format --check-formatted`, `mix compile --warnings-as-errors`, `mix test` and `mix site.build` ([site/README.md](site/README.md)). A change to `docs/` runs the site's link test too | | Other tooling or integrations | The affected component's tests and lint commands from its README or CI job | | Documentation | The focused commands in [contributing/docs.md](contributing/docs.md) | Run `python3 scripts/conflict-markers.py` and `git diff --check` for every change. A Go-only, SDK-only or tooling-only change does not need the server's local Elixir gate. If a workflow or script changes how the server compiles, boots or runs tests, also run the relevant server stages or tests. CI remains the complete integration check for its selected plan. ### The server's local gate ```bash mix precommit ``` That runs `scripts/precommit.sh`: CI's Elixir static job, the sobelow scan and a prod release assemble, each stage its own process, in this order. The test suite is the last stage and is opt-in, `mix precommit --full` or `mix precommit test`: CI runs the suite on full server plans, so a full local run is useful when you want broader feedback before pushing, such as a change to `test/support`, a factory, a migration or `config/`. | Stage | Runs | |---|---| | `toolchain` | the shell's Elixir and OTP match `.tool-versions` (activate mise, or `mise exec -- mix precommit`) | | `conflict-markers` | `python3 scripts/conflict-markers.py` | | `compile` | `MIX_ENV=test mix compile --warnings-as-errors` | | `deps` | `mix deps.unlock --check-unused` | | `format` | `mix format --check-formatted` | | `credo` | `mix credo --strict` | | `dialyzer` | `MIX_ENV=dev mix dialyzer` | | `sobelow` | `scripts/sobelow.sh` | | `release` | `MIX_ENV=prod mix deps.get && mix release fountain_server --overwrite` | | `test` | `mix test` from the umbrella root. Only with `--full` or by name | **The exit status is the verdict.** The run stops at the first failing stage, names it, and exits with that stage's status; the last line is always `precommit: PASSED` or `precommit: FAILED at `. The one thing the script cannot see past is a pipe: `mix precommit | tee log` reports `tee`'s status unless the shell has `pipefail` on. `mix precommit --list` prints the stages, and `mix precommit credo test` runs only those, in the canonical order, after a fix; naming a stage always runs it, `--full` or not. The release stage is the only one that builds `MIX_ENV=prod`. It is there because everything else is blind to a prod-only dependency graph: the OpenTelemetry family is `only: :prod`, 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; a fresh checkout pays one full prod compile first. It assembles only; CI boots the release, because booting needs `SECRET_KEY_BASE` and a database. CI additionally runs `hex.audit`, the Go CLI module, the release boot check, OpenAPI validation, the SDK jobs and the docs gates; [`scripts/ci/README.md`](scripts/ci/README.md) lists every job. If you touched `docs/`, read [`contributing/docs.md`](contributing/docs.md): the manual is built and tested in `site/` (`cd site && mix test`), not by the server's suite. ### If a test went red and then green Keep the failed run's assertion, file/line and run URL. Compare the commits, workflow conditions, runner environment and external dependencies before calling it a flake; a later green run alone does not establish the cause. Record an unexplained failure with what is known. For a confirmed flake, search existing issues first: ```bash gh issue list --label type:flake --state all ``` Use the **Flaky test** issue template or file with `type:flake`, `area:testing` and the affected area. Include the failing assertion, frequency with a denominator and run URLs, and the suspected race if known. Check the file's recent history so an already-fixed failure does not become a new issue. Fix a flake caused by your change in that PR; file unrelated flakes separately. ## Repeating a live load test The [conversation load-test runner](scripts/load-test/README.md) runs tiny prompts across Sprites/E2B and Claude/Codex, records launch failures as well as latency, and can preserve idle conversations for later cold-resume checks. Use its before/after procedure when verifying provisioning, resume or rollout fixes. Its local tests use a fake API and run in the existing CI tooling suite. ## Finding dead code ```bash scripts/dead-code.sh # both reports scripts/dead-code.sh elixir # public functions no compiled module calls scripts/dead-code.sh go # unreachable functions in the Go CLI module ``` The Elixir report uses `mix_unused`; the Go report uses `deadcode`. The monthly workflow keeps advisory artifacts. Findings do not block merges; an analyzer failure marks the report incomplete. Treat findings as candidates. The Elixir tracer misses dynamic calls (including MFA callbacks), test-only seams, some protocol/callback implementations and macro-generated functions. Search the function's bare name across `apps/` and its tests before deleting it. A test-only caller may be a useful observation seam or evidence of a missing production caller; resolve which before removing the function and its tests. ## Component libraries The `managoat_*` libraries are hex packages pinned in `apps/fountain/mix.exs`, each in its own repository. A fix that crosses into one follows [Updating existing libraries](contributing/component-libraries.md#updating-existing-libraries). No new library is planned (ADR 0037). ## Changing the API The server's OpenAPI document is the wire contract, and the TypeScript SDK and the `managoat` CLI live in this repository against it. A schema change that reaches the server but not its clients is the failure mode this section exists to prevent, so the checks are arranged to fail in the PR that makes the change rather than in somebody's application months later. If you touched `apps/fountain/lib/fountain_web/schemas.ex`, a controller's `operation/2`, or the router, regenerate the SDK's wire types: ```bash mise exec -- python3 scripts/sdk-contract/generate.py git diff --stat sdk/typescript/src/generated ``` The script exports the server's OpenAPI document to `dist/openapi.json` (ignored) and runs `openapi-typescript` over it into `sdk/typescript/src/generated/openapi.ts`, which is committed. CI does the same and fails on any diff, so commit the regenerated file with the server change. An empty diff means the wire did not move; a non-empty one is the wire change, and `npm --prefix sdk/typescript run typecheck` shows what handwritten SDK code it breaks. The CLI has no generated types: check it by hand when a field it reads moves. ### Shape-only conversation changes Use the API-shaped launch path for new conversation fields: TypeScript `runRequest` and `managoat conv create --file request.json` (or `--file -` for stdin). Pass raw IDs and wire names. Local execution options stay outside the request. The existing convenience helpers remain supported and retain their explicit argument lists; add an ergonomic argument only when it merits a convenience API. An optional field with no new client behavior needs the server input/JSON-view change, its focused server test, and regeneration. TypeScript receives generated declarations; CLI JSON input forwards it. A new behavior still needs independent expectations in the relevant tests. ### The schema has to match its own controller The generated types and the SDK's wire scenarios are both derived from or written against the schema, so neither notices when the document is wrong about what the action actually renders. Three defects of that shape landed in a day (#1417, #1418, #1427), so the suite now checks it directly and you will meet it without doing anything: - **Every response any controller test renders** is validated against the schema its operation declares. The check is attached in `test_helper.exs` and costs nothing per test; 146 operations are covered by tests that already exist. A failure names the operation, the mismatch and the body. - **`apps/fountain/test/fountain_web/schema_guardrail_test.exs`** adds what a rendered response cannot show: that no `required` list names a property its schema lacks, that no response is declared as an object with no properties, and — for the operations on its short list — that the action renders *every* property the schema declares, which an optional field never sent would otherwise hide. If you hit it, the schema in `apps/fountain/lib/fountain_web/schemas.ex` is usually the thing that is wrong, not the action. When it genuinely cannot be fixed in that PR, add the `{operation, status}` pair to `FountainWeb.SchemaGuardAllowlist` with a reason and an issue, and raise `@ceiling` in the guardrail test in the same diff — that number moving is the signal to a reviewer. The list may shrink freely; deleting a line is how a fix finishes. ### Changing behaviour rather than shape The generated types cover request and response *shape*. What a client does with that shape — SSE framing, reconnect and cursor resume, which error class a status becomes, terminal run states, the permission flow, pagination — is the TypeScript SDK's wire scenarios in `sdk/typescript/test/scenarios/`, JSON files of scripted bytes in and observations out. Change one of those behaviours and the scenario is where you start, before the client changes: ```bash $EDITOR sdk/typescript/test/scenarios/.json cd sdk/typescript && node --test test/scenarios.test.ts ``` They also run in `npm test`. `sdk/typescript/test/scenarios/README.md` is the reference for the scenario format and its vocabularies. Do not bump an SDK's version because the contract moved. Merging a version bump publishes that SDK, so a version moves when its own public surface changes. Label a PR `release:skip-sdk` where the distinction needs saying out loud. ## SDK ownership and releases TypeScript (`@managoat/sdk`, [sdk/typescript](sdk/typescript/README.md)) is the one SDK. [SDK maintenance](contributing/sdk-maintenance.md) owns version bumps, release tags and credentials. ## Pull requests Every change goes through a PR. Managoat has one maintainer, so there is no required review and no merge queue (ADR 0078): once the PR's CI is green, the maintainer squash-merges it as an admin. ```bash gh pr checks gh pr merge --squash --admin --delete-branch ``` Do not push directly to `main`; the [Release bump](#cutting-a-release) workflow is the one thing that does. A PR green against an older `main` can still break the newer one; `main`'s own CI runs the full suite on every merge, and a red `main` is fixed forward before anything else merges. A stack merges from the PR based on `main`; deleting its branch retargets the next PR to `main`. ### Release notes There is no changelog file to edit. The server's `CHANGELOG.md` was removed on 2026-10-15 (its last copy is at [v0.29.0](https://github.com/managoat/managoat/blob/v0.29.0/CHANGELOG.md)); each release's notes are on [GitHub Releases](https://github.com/managoat/managoat/releases), generated from the titles of the pull requests it contains. So a PR title is a release note: say what changed for someone running Managoat, not how. When a change needs an operator to act on upgrade (a migration that must follow a rollout, a removed setting, a breaking API change), say so in the PR description. Once the release is published, edit it (on its page, or with `gh release edit vX.Y.Z --notes-file`) to put an **Upgrade notes** section above the generated list. If your change is architecturally significant, or constrains future work, write an ADR using [`decisions/0001-template.md`](decisions/0001-template.md) and refresh the index (`scripts/decisions-index.sh`) in the same PR. ## CI maintenance [CI maintenance](scripts/ci/README.md) owns the CI jobs, coverage, timing refresh and toolchain troubleshooting. ## Cutting a release Run the **Release bump** workflow on `main` (Actions tab, or `gh workflow run release-bump.yml -f bump=patch`; `minor` or `major` for a larger step). It refuses unless CI passed on `main`'s tip and the live adapter suite passes on it ([the live adapter gate](contributing/component-libraries.md#the-live-adapter-gate)), then bumps the version in both `mix.exs` files and the five self-host image pins in one commit, and pushes that commit to `main` with its `vX.Y.Z` tag. The tag runs `release.yml`: the CLI binaries, the versioned image, a boot of the documented quick start, the Homebrew tap and the GitHub Release with its generated notes. Add any **Upgrade notes** to that release by hand ([Release notes](#release-notes)). A release is also the maintainer's deploy. Merges to `main` do not reach prod; when `release.yml` succeeds, `publish-manifests.yml` moves the manifest artifact's `latest` to the release and Flux rolls it out ([ADR 0080](decisions/0080-prod-deploys-releases.md)). Check prod after a release, not after a merge. Hotfixes, manifest-only changes that cannot wait, and rollbacks are in [scripts/ci/README.md](scripts/ci/README.md#deploys).