Contributing¶
Contributions are welcome. The fastest way to get one merged is to arrive with the gates green.
This repository is public and everything in it is world-readable - comments, fixtures, branch names, commit messages. Read the first section of AGENTS.md before you write anything down. It is also the root of trust for how this codebase is built, and it routes to the rest.
Feedback and reporting¶
Questions, feature ideas and bug reports go to the issue tracker. Please do not open a public issue for a suspected vulnerability - report it privately through SECURITY.md instead.
Development¶
Nix with flakes, plus devenv and direnv. Everything a gate uses comes from there at the pinned
version, so you cannot get a different compiler than CI.
curl -L https://nixos.org/nix/install | sh -s -- --daemon
printf 'experimental-features = nix-command flakes\n' >> ~/.config/nix/nix.conf
nix profile install nixpkgs#devenv nixpkgs#direnv
echo 'eval "$(direnv hook bash)"' >> ~/.bashrc # or the zsh / fish equivalent
direnv allow # once per clone: consents to .envrc loading devenv.nix
just setup # installs the git hooks and the pixi env, warms xtask
just doctor # what is present, what is missing, what would fail
direnv allow is a trust boundary, not a formality, and hooking direnv into your shell is the
step people skip. just with no argument lists every task.
just setup is what installs the git hooks, and it is not optional. It installs all three
stages (pre-commit, pre-push, commit-msg) through prek, installs the pixi environment the
hook runner lives in, warms xtask so your first commit is not a cold compile, and ends with
doctor. It is idempotent, so re-run it whenever an environment file changes.
Run it before your first commit, because an uninstalled hook does not complain - it silently never
fires. This repo spent a whole session believing hooks ran that had never run: core.hooksPath
pointed at a .githooks/ directory that had been deleted, so git looked somewhere that did not
exist while prek install wrote to .git/hooks where git was not looking. just setup now unsets
a core.hooksPath pointing at a missing directory for exactly that reason. If you are unsure
whether yours are live, just setup again and read what it prints.
Working in parallel worktrees¶
Stacked branches mean several worktrees at once, so the development service tier is built to run one independent instance per worktree rather than one shared set you have to take turns on.
just worktree feat/thing # an isolated worktree for a stacked change
just worktrees # every worktree and its scope
just ports # this worktree's compose project and service ports
just dev-up # this worktree's services, provisioned and health-gated
just dev-endpoints # where they are listening - the readable table
just dev-endpoint clickhouse # one host:port on stdout, for shell substitution
just dev-down # remove this worktree's services, network and volumes. Nothing else
just dev-down-dry # what that would remove, and what it would spare
No host port is written anywhere, and that is the load-bearing part. compose.services.yaml
names only container ports, so the host port is ephemeral and chosen by docker and the OS;
dev-up reads back what they chose and writes it into a discovery file the harness reads, which is
why just dev-endpoint is the only way to learn a port and there is no constant to hardcode. A hash
into a port range cannot promise disjoint blocks, and "check whether the port is free, then bind" is
a race whose window belongs to whatever else is on the host.
Isolation comes from the compose project name, derived from the worktree's canonical path, so containers, network and named volumes are all per-worktree and two worktrees running the same file share nothing. Nobody has to remember to change a value. Every service declares a healthcheck, because provisioning gates on health rather than sleeping.
The tier provisions ClickHouse today, plus Keycloak behind an identity profile that is off by
default (just dev-up-identity). Postgres is not here: just test provisions it from nix instead.
A missing docker skips locally and fails in CI, both from one flag. Adding a service is a
registration - a row in sutura_dev::scope::SERVICES and a block in compose.services.yaml - and
the file itself records which services are deferred and why. Orchestration lives in xtask and the
worktree and port commands in sutura-dev, deliberately: docker orchestration inside a release
artifact would be test scaffolding shipped to users, and neither of those crates is packaged.
Two other routes, if that one is closed to you. The dev container is the same shell in Docker -
docker compose -f compose.dev.yaml run --rm dev - and is the answer on Windows without WSL2. Bare
rustup compiles and tests (rust-toolchain.toml is the pin and rustup honours it), but just,
the hooks, the secret scan and the supply-chain gate all come from Nix and pixi, so on that route
you find out on the pull request instead of before it. On a network with no direct egress, read
Building without direct internet egress
first: nothing that fetches is hardcoded, and every location is read from the environment.
The dev shell's bare cargo is the nightly toolchain, the same one every gate and CI use now
- the stable/nightly split is gone, so a bare cargo clippy reports the lints CI sees. Run
just lint anyway, because it also adds -D warnings. The trap and its siblings are in
the gates skill.
Using AI-generated code¶
This codebase is written largely with coding agents, and that is not something to be coy about.
AGENTS.md is their contract; the tree under .agents/skills/ is the reference material they load
on demand. If you work with an agent here, point it at AGENTS.md first and let it route.
Used thoughtfully these tools are a real multiplier. What they do not do is transfer ownership:
- You own the diff. A generated patch is a draft until you can explain why each part of it is there. If you cannot answer a review question about a line, it is not ready.
- A claim needs a mechanism. The failure mode here is not bad code, it is confident prose - a comment or a pull request describing a control that does not exist, or one stronger than the code delivers. An overstated claim is treated as a defect in its own right, because it spends trust a reviewer needed elsewhere.
- A test has to be shown to test something. See below. Generated tests are especially prone to passing against the base behaviour too.
- Do not add prose where a check belongs. A rule with no mechanism is a wish, and this repository would rather have a gate than a paragraph.
- Keep guidance discoverable rather than duplicated. If you add to
.agents/skills/, add what a command cannot answer: a reason, a limit, a trap that cost somebody a session. Not a list of crates or tasks thatlsandjust --listalready print.
Attribution. Where an agent co-authored a commit, say so with a trailer - the convention here is
Co-Authored-By: <model> <noreply@anthropic.com> - and note in the pull request what you verified
yourself rather than what was generated for you.
Commit messages¶
The commit-msg hook runs cargo xtask commit-msg, which judges the subject line and nothing else:
| Types | feat, fix, refactor, chore, test, docs, perf, ci, build, style, revert |
| Length | at most 72 characters, where git log --oneline starts truncating |
| Breaking | ! after the type or scope |
| Shape | a space after the colon, no trailing full stop |
| Exempt | subjects git writes itself: Merge, Revert, fixup!, squash!, amend! |
feat(semantic): compile a bounded date predicate
fix(catalog-local): reject a dimension absent from the pinned bundle
refactor!: rename the Warehouse port's execute method
The body is not checked. Use it for why. The subject also decides the next version: feat a
minor, fix and the rest a patch, ! or a BREAKING CHANGE footer a major.
Testing¶
just test # the gate's own invocation - never hand-write the cargo line
just check-changed # does what I touched compile
just causality # red-before-green proof
just ship-check # the finishing sequence; run this before saying done
just validate # THE gate: the site build, then the nix checks, which build their own tree copy
A new or changed test must be red against the base behaviour and green with your change. A test
that passes both ways proves nothing and is worse than no test, because it looks like coverage.
just causality checks that mechanically. Where the change is not separable - impl and test in one
file, a rename with no behavioural difference, or every added test #[ignore]d so no run here
reaches one - the gate says so and asks for evidence instead: the command you ran, the failure
before the fix, the pass after. That goes in the pull request. Do not skip it silently.
Exit 3 means the gate measured nothing, and it is neither a pass nor a violation: the base tree did not build, the base run named no failure, or every test in scope was one the base tree already had - a MOVED test, which the gate reads out of the base tree because a diff cannot tell a move from an addition. A harness move and a changed public signature that a test file kept at HEAD calls both land there too, so it is not a defect in your change - but nothing about causality was proven either. Substitute a mutation run, or scope the gate per commit, and say which in the pull request. Read the verdict line, never a step's colour.
The gate measures the MERGE BASE, it says which commit that was, and on a stack it derives it.
It resolves git merge-base <ref> HEAD itself, so a base branch that has moved on cannot put other
people's commits into the diff - and on a stacked branch it takes the fork point from the parent
branch st recorded instead, because the trunk's merge base there is the fork point of the whole
stack. The printed line names the commit, the parent branch and the commit it replaced; a stale or
retargeted parent falls back to the ref you named rather than moving the base off your history.
SHIP_CHECK_BASE_REF, or a commit as the recipe's argument, still overrides both.
One parent is refused rather than used, and it is one you can easily have: a branch that already CONTAINS yours - because you merged yours into it to check the merge, or because the metadata points at the branch above. Its fork point with your HEAD is your HEAD, and a base equal to HEAD would make the diff your uncommitted working tree alone: exit 0 over every file you changed. The line says so, and the gate measures the ref you named instead.
A changed page, recipe or nix file IS an implementation to the gate and gets reverted like any
other; a manifest or a lockfile is not - it is held at HEAD and named as not reverted: on
whichever arm you land on, because reverting one changes what cargo RESOLVES rather than what the
tests measure. So a change whose only implementation is a manifest reads as tests changed but no
implementation did, at exit 0, with the manifest named beside it: that is the gate saying it could
not see your change, not that there was nothing to see. One manifest change does fail, though:
declaring a feature name the base did not, where a #[cfg(feature = ..)] mod ..; this diff does not
otherwise touch gates a module that holds tests. That compiles a whole module of pre-existing tests
with no added .rs line, and neither run can measure them - so state the evidence instead.
Ports get fakes, not mocked HTTP. That is what lets the whole tool surface, refusals included, be tested without a warehouse, and a test asserting on source text proves nothing.
just validate is the only thing that counts as verified, because its nix checks build a
git-derived copy of the tree - so git add -N a new file immediately, or it compiles locally and
does not exist in the sandbox. Gate stages, what each one covers and where a green run means less
than it looks are all in
the gates skill.
Pull requests¶
main is the sole trunk. Pull requests are squashed, leaving one conventional commit for
git-cliff. A branch behind main is rebased, never merged: a merge commit from main into a
topic branch comes back as a rebase request in review.
That history requirement is not authorization to rewrite a published branch. Obtain explicit
authorization before rebasing or restacking any published branch and force-pushing its replacement,
even if you alone own it (AGENTS.md). Without it, ask; do not merge main as a workaround.
We prefer stacked pull requests, and we recommend stax
(st) for them. A chain of dependent changes ships as one reviewable pull request per link, not
one branch that grows until nobody can review it. stax is in the dev shell at a pinned version
(nix/stax.nix), and stax.toml at the repo root overlays your global config with this repo's
forge, so a machine set up for a different one cannot submit stacks there.
st create feat/thing # branch off the current one, entering the stack
st modify # stage and amend into the branch tip
st ss # push the stack, opening or updating a PR per branch
st ls # what the stack looks like now
st refresh # sync trunk, restack, submit the updates
Two rules, and both exist because the mistakes are the expensive kind:
- Create stacked branches and their PRs with
st, nevergit checkout -borgh pr create. stax only knows about branches and pull requests it made itself. One made with plain git is not merely invisible to it - it draws the stack wrong and then offers a destructive restack for that wrong picture, andst stack submitwill plan a duplicate PR for a branch that already has one. Alwaysst ss --dry-runbefore submitting a stack you did not create withst. - Never hand-
git rebasea branch that belongs to a stack. Restacking rewrites history by design; published branches need the explicit authorization above.
Run just ship-check per branch rather than once for the stack, since each pull request is
reviewed alone and so has to be green alone, and land bottom-up. Each link can sit in its own
worktree with its own service instances - see
Working in parallel worktrees. A single independent change is fine
as one ordinary pull request; the preference is about dependent work, not ceremony. The mechanics,
including recovery when branches were made the wrong way, are in
the stacked-branches skill.
.github/pull_request_template.md is the form, and it is short because a reviewer's first five
seconds decide how they spend the rest. It asks for a review snapshot, what changed as behaviour
rather than as a list of files, the files in reading order, the exact validation commands and
whether they passed (including what you did not run - "should work" is not a result), the
causality evidence, and which mechanism would have failed if the change were wrong. "Nothing
mechanical" is an acceptable answer there and a useful one: it tells the reviewer the judgement is
theirs.
One reviewable idea per branch. If describing it needs an "and", split it.
Releasing¶
An approved manual dispatch of version-bump on main is the only release entry point. It
derives the version from the commit subjects above, writes CHANGELOG.md in the release commit, and
pushes that commit and its v* tag through the narrow release App; the tag is what starts
release.yml and docs.yml, and the release workflow has no manual trigger of its own. Nothing is
tagged if no commit since the last tag would move the version, so a dispatch over chores publishes
nothing.
If a tagged release fails, its commit and tag stay for diagnosis - no GitHub Release, no image manifest, no moving tag. Fix the source and approve a new dispatch. Never rewrite a release tag. Verifying a release is what a consumer of the artifacts reads.
Conventions that will fail your pull request¶
- LF line endings, no trailing whitespace, one final newline.
just fmtfixes what is mechanically fixable. - No em dashes. Plain hyphens, in prose and in comments. A gate rather than a request, because the convention was stated from the start and one shipped anyway.
- No first-party
unsafe.forbidin the workspace lint table, so a crate cannot re-allow it locally. #[expect(.., reason = "..")], never#[allow], so a suppression cannot outlive its cause.- No file over 1000 lines, and no exemption under
crates/orxtask/- the only way past it is to split the file. - No dependency declared and unused. Declaring one to satisfy a document is what that gate exists to stop.
--all-featureson every lint and test entry point. Several crates declare features now, so an entry point missing the flag lints and tests nothing behind them. The scopedcargo check -p sutura-domain --no-default-featuresis the fast inner loop, never the gate.- The domain crate acquires no framework dependency, checked over the whole transitive tree, so a framework reached through an innocuous crate fails too.
Building the docs¶
just docs # render to site/
just docs-serve # live reload
just docs-deploy # one version to gh-pages
The site is mkdocs-material versioned by mike, built with
--strict, and every command goes through pixi's isolated docs environment. nav in mkdocs.yml
is explicit rather than derived, and cargo xtask check-docs fails on a page in no nav entry, a nav
entry with no file, or an asset that stopped resolving. A page that is deliberately not part of the
site - the implementation plans are the case - is named in exclude_docs instead, and the same gate
fails an exclusion naming no page, a page both navigated to and excluded, a pattern it cannot
resolve to one file, and a published page that LINKS an excluded one. That last is not --strict's
job: mkdocs logs such a link at INFO and exits 0, measured. This file is one of those published
pages: docs/contributing.md pulls it in with pymdownx.snippets, so a relative link written
here resolves against that page's URL rather than the repository root, and the gate reads this file
to judge it. just validate renders the site as its first step, so a page that cannot render fails
before the nix closure rather than after a merge; just docs is the same build, reached on its own.
If the docs environment cannot be materialised at all - a cold package cache with no network - that
step says SKIPPED in one line and the recipe fails at the end instead of losing every other check.
Publishing the docs covers the
versioning and the one repository setting it needs.