# CLAUDE.md — how work lands in this repo Read this before doing anything durable. It is the delivery contract for AI coding sessions and agents. `DECISIONS-LOG.md`'s last entry is the current PM handoff and takes precedence on *what* to work on; this file governs *how* it lands. ## 1. Never push to `main`. Ever. Every durable change reaches `main` exactly one way: branch -> push the branch -> open a PR -> CI gate green -> human approval -> merge There is no other path. Specifically: - **Do not `git push origin main`.** - **Do not `git push origin HEAD:main`.** Same thing wearing a hat. - **Do not merge your own PR.** A human approves and clicks merge. - **Do not `kubectl apply` / `edit` / `patch` a durable change.** Flux reconciles `main`; anything applied by hand is drift that the next reconcile reverts or, worse, adopts. > **Many runbooks under `runbooks/` still say `git push origin main` IS the deploy.** > That wording is **stale** — it predates the PR flow. It describes what Flux does > (main is what the cluster follows), not what you are permitted to do. Honour this > file, not that line. Fixing those runbooks is tracked work. ## 2. Enforcement is currently WEAKER than this rule. Follow the rule anyway. Verified against the live Gitea on 2026-09-29: | Expected | Actual | |---|---| | `main` protected, direct push rejected | **No branch protection.** `protected: false`, `user_can_push: true` | | PR blocked until the gate passes | **`enable_status_check: false`**, `status_check_contexts: []` | | Gate runs on every PR | **Fixed 2026-09-29.** Runner `incluster-dind` is online; `gate / gate (pull_request)` runs and passes in ~1m30s–2m | | Approval required before merge | **`required_approvals: 0`** | So nothing mechanically stops you from pushing to `main` right now. **That is a gap being closed, not permission.** `DECISIONS-LOG.md` (2026-08-31) claimed the gate was a "required status check" with a runner online; neither was true, which is how this went unnoticed for a month. The runner half is now genuinely fixed and observed green. The protection half is not — see `infra/gitea/`. **Consequence for you:** CI now runs, but it does not yet *block* anything. Always run the toolbox gate yourself and quote its numbers. Never describe a PR as "green" on the strength of a check you have not seen report — and note that a **skipped** Gitea job reports as `success`, so a green tick is not by itself evidence that anything ran. ## 2b. Never infer platform state from this repository **This repo is not evidence about the live platform.** Neither is `DECISIONS-LOG.md`. Both describe what someone believed at the time of writing, and both have been confidently wrong for a month at a stretch. On 2026-09-29 the log stated the merge gate was a required status check with a runner online. The API said `branch_protections: []` and **zero** runners. Everything downstream of that belief — "the gate enforces this", "your push would be rejected", "CI covers it" — was wrong, and nothing in the tree could have revealed it, because the tree was the source of the error. **So, before asserting that any gate, protection, runner, secret, or workload is in a given state: query the thing itself.** | Claim about... | Check | |---|---| | branch protection / required checks | `GET /api/v1/repos/{owner}/{repo}/branches/main` | | a runner exists | `GET /api/v1/admin/actions/runners` | | CI actually ran | the commit's `/statuses` — and read the **description**; a *skipped* Gitea job reports `success` with "Has been skipped" | | a workload / Secret / env | `kubectl --context k3s-prod ...` (read-only is fine for agents) | | Flux applied your commit | compare `.status.lastAppliedRevision` against the GitRepository's artifact revision | Quote the measured field, not the prose. If you cannot measure it, say so explicitly — "unverified" is a legitimate report, and a far better one than a confident guess that matches the docs. **A green tick is not proof anything ran.** Read what actually executed. ## 3. Gate before you push ./infra/devcontainer/dev.sh run bash -c './ci/gate.sh' Exit codes are a contract — do not flatten them: | rc | Meaning | |---|---| | 0 | PASS | | 1 | BLOCKED — something failed | | 3 | UNDETERMINED — a stage did not run. **Never report this as green.** | `spec-immutability` reaches rc 3 whenever the sibling spec repo is not checked out beside this one; that single skip is expected and tolerated. Any *other* skip is a real gap in your run. **Never gate on the host.** `promtool` and `kustomize` are not on the host `$PATH`, so checks SKIP and you get a false rc 3. Only the pinned toolbox result counts. ### Running the gate from a git worktree Two unrelated failures, both with known workarounds — put both in any agent brief: 1. **`dev.sh` refuses to start:** `infra/ansible/.cache/prod.kubeconfig` is untracked and exists only in the main checkout. Do **not** copy it (it is a credential). Use `DEV_KUBECONFIG=/dev/null` — the gate contacts no cluster, so L2/L3 scaffold-skip normally. 2. **Every `git` call inside the container fails:** a worktree's `.git` is a pointer to a gitdir outside the mounted root. `dev.sh` takes no extra-mount flag, so inject one via the `ENGINE` hook — a shim that intercepts `run` and re-execs `podman run -v /path/to/main/.git:/path/to/main/.git:ro "$@"`. A traceback in `tracked_files()` is this bug, not a broken tree. ## 4. Branch naming Use `feat/`, `fix/`, `docs/`, `chore/`. Note that `.gitea/workflows/gate.yml`'s **push** trigger lists `feature/**` — not `feat/**` — so branch pushes get no fast-feedback run. The `pull_request` trigger fires regardless of branch name, so the PR gate is unaffected. ## 5. Cluster access `kubectl` does not work on the operator workstation; the toolchain lives only in the pinned container. Always name the context explicitly: ./infra/devcontainer/dev.sh run bash -c 'kubectl --context k3s-prod get nodes' `dev.sh` merges both clusters and picks a current-context **per invocation**; that default has already changed once. Never rely on it. Do not run two `dev.sh` invocations concurrently. **Mutating `kubectl` and `flux reconcile` are operator actions, not agent actions.** ## 6. Secrets - **Never `sops -d` a file into a terminal, a log, or a transcript.** - **Never print a Secret's `.data`/`.stringData`** when checking whether it exists — list key names or object names only. A password has been leaked into a transcript this way before. - Encrypted files are `*.sops.yaml` or live under `secrets/`. The root `.sops.yaml` is the single creation-rules file; do not add a second one in a subtree. - A short-lived credential (e.g. a plex.tv claim token, ~minutes) cannot survive the PR flow. Do not design around committing one — read the expiry from the issuing page and treat a burnt token as free to retry. ## 7. Before you finish Append a handoff entry to `DECISIONS-LOG.md` describing what you changed, what you verified, and what you did **not** verify. State gaps plainly — an honest "UNVERIFIED" is worth more than an optimistic "green", and this repo has already been burned by the difference.