Files
home-network-k3s-impl/CLAUDE.md
T
jakman85andClaude Opus 5 7b9f733c97
gate / gate (pull_request) Failing after 1m44s
gate / conformance (pull_request) Has been skipped
fix(ci): let an administrator merge their own green PR
`required_approvals: 1` on `main` was a lock-out, not a protection. Gitea
refuses to let a PR's author approve their own PR, and `jakman85` is the only
collaborator — so no account in existence could approve a PR the operator
opened, including the PR that would fix that. Measured 2026-10-01 on PR #35:
gate green in 1m41s, reviews `[]`, `required_approvals: 1`, unmergeable.

It was never the documented design either: `ci/approval-policy.sh`'s header and
`ci/gate.sh:194` both already describe the model as "required_approvals drops
to 0 and the GATE decides", and `require-gate.sh`'s own closing note named this
failure and prescribed this remedy. 2026-09-30's entry flagged the 1-vs-0
disagreement as unresolved; this settles it at 0.

Layer 1 — branch protection. required_approvals 1 -> 0 in the declaration, plus
infra/gitea/allow-admin-self-merge.sh to apply it live (target read FROM the
declaration, so the script cannot drift from what the gate checks).
require-gate.sh moves to 0 too; left at 1 it would re-impose the lock-out on its
next run. The narrow choice is deliberate: the other way to unblock an owner is
block_admin_merge_override: false, which would re-open what
block-admin-override.sh was written to close (PRs #5-#9, zero approvals). This
drops the approval COUNT only — block_admin_merge_override and
enable_status_check stay true and are asserted after the PATCH, so a red gate
stays unmergeable by everyone, administrators included.

Layer 2 — ci/approval-policy.sh. Sensitive paths still need a non-author
approval the sole admin cannot get, so an administrator may waive it with an
`/approve-override <reason>` comment: directive must start a line, reason
mandatory, permission re-checked LIVE, invalidated by a new commit, and the
overrider + reason printed in the gate log and job summary. Candidate selection
is the pure, offline ci/approval-override.py (23 unit assertions, all passing);
authorisation stays in the fail-closed stage. Every new failure mode blocks.

The cost, stated not buried: for an administrator, "a change to the gate cannot
be gated by the gate" is now advisory rather than structural. Unavoidable with
one human; what is bought back is an attributed, commit-scoped record instead of
a silent bypass. And the uncomfortable part, accepted as an operator decision:
authorisation is by live permission alone, and the AGENT account `gitea-admin`
also holds `owner` — so an agent can clear its own PR this way. Documented in
approval-policy.json, the stage header, and CLAUDE.md (which tells agents not
to). Narrowing needs no code change: add `override_admins` and the stage already
requires membership.

Adjacent fix, in scope because `make -C ci test` is the only runner for the
suite added here: ci/tests/gate-decision.test.sh hand-maintained a stub list
that "must name EVERY stage gate.sh runs" and omitted `approval-policy` (added
to gate.sh by PR #17), so run_stage hit exit 127 and 15 of 50 assertions failed.
Verified pre-existing on pristine origin/main @ 0c7e8dd. The list is now derived
from gate.sh's own run_stage lines, so the omission cannot recur; the suite goes
15-failed -> 50 passing and `make -C ci test` goes FAILED -> all suites passed.

Toolbox gate: rc 3 UNDETERMINED — every stage green except `gitea-settings`
(no SETTINGS_API_TOKEN locally) and `conformance` (no cluster). NOT a green run.

The live rule is UNCHANGED and still reads 1 — mutating it is an operator
action. Until allow-admin-self-merge.sh is run, ci/gitea-settings.sh will
correctly report DRIFT and this PR will be red.

Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
2026-10-01 23:54:51 -05:00

12 KiB

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. The newest entry in decisions/ 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. See the approval model below — and either way, the merge is someone else's action, never yours.

Who approves

The CI/CD pipeline is the approver. A green gate is sufficient for an ordinary change; the branch requires no human approval for one. That is a deliberate transfer of authority, on the operator's stated principle: if the pipeline is not good enough to be trusted for this, it is not good enough to do its job and needs improving — so a weak gate is a bug to fix, not a reason to add a human.

One exception, enforced by ci/approval-policy.sh: a change to the pipeline itself, the admission policies, the Flux wiring, the SOPS recipients, the RBAC, or this file needs an approving review from someone who is not its author. The reason is narrow and structural — a change to the gate cannot be gated by the gate. A PR that weakens a check would be approved by the weakened check. Everything else merges on green.

The path list is data, with a reason per entry: infra/gitea/approval-policy.json. It is itself a sensitive path, so the policy cannot be widened or narrowed without a human.

That stage is fail-closed — if it cannot evaluate, it blocks. Unlike gitea-settings, where a missing credential is a lock-out, here a skip would be a bypass.

The administrator override (2026-10-01). The rule above was unsatisfiable for the one human who runs this repo: Gitea refuses to let an author approve their own PR, and jakman85 is the only collaborator — so a sensitive-path PR the operator opened could never be merged, including the PR that would fix that. An administrator may therefore waive the review by commenting on the PR:

/approve-override <reason>

The directive must start a line, the reason is mandatory, the commenter's admin/owner permission is re-checked live when the gate runs, and the comment must be newer than the head commit — push again and you must override again. The overrider, the timestamp and the reason are printed in the gate log and the job summary. For an administrator, "a change to the gate cannot be gated by the gate" is therefore advisory, not structural; what is kept is an attributed record of each waiver. Note that the agent account gitea-admin also holds owner here, so an agent can clear this on its own PR — do not: posting that directive to unblock your own agent work is exactly the self-approval the stage exists to prevent. Get the operator to post it, or move the change out of the sensitive paths.

required_approvals on main is 0, not 1 — the gate is the approver. That drops the approval count only: block_admin_merge_override and enable_status_check both remain true, so a red gate is unmergeable by everyone, administrators included.

  • 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 (as of 2026-09-29: the rule is now MACHINE-ENFORCED)

Verified against the live Gitea on 2026-09-29:

Expected Actual
main protected, direct push rejected ✅ protected: true, user_can_push: false — verified by a real rejected push, not by the API's word
PR blocked until the gate passes ✅ enable_status_check: true, context gate / gate (pull_request)
Gate runs on every PR ✅ runner incluster-dind online; ~1m35s
Gate cannot be overridden, by anyone ✅ block_admin_merge_override: true
Human approval required before merge ⬜ No — required_approvals: 0 since 2026-10-01. The gate is the approver; sensitive paths are gated by ci/approval-policy.sh instead. See §1

The first three were FALSE as recently as 2026-09-29, while this repo's own log asserted they were true. They are true now because each was measured, and the push rejection was provoked rather than assumed. Re-measure before relying on any of them — see §2b.

The fourth row deliberately changed on 2026-10-01. required_approvals: 1 was a lock-out, not a protection: with a single collaborator and Gitea refusing author self-approval, nothing could satisfy it (measured on PR #35 — green gate, zero reviews, unmergeable). It also contradicted the model already written into ci/approval-policy.sh and ci/gate.sh. Rationale and the live-change script: infra/gitea/allow-admin-self-merge.sh.

Still not covered: vars.CLUSTER_AVAILABLE is unset, so L2/L3 conformance does not run in CI. A green PR therefore proves the L0/L1 offline subset, not runtime behaviour.

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 the handoff log (decisions/, and the frozen DECISIONS-LOG.md archive). 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 — write a handoff entry, in its OWN file

Add one new file:

decisions/YYYY-MM-DD-<slug>.md          <slug> = your branch's last segment

with frontmatter (date, branch, title) and exactly these three sections:

## What changed
## What I verified
## What I 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. All three headings are enforced by ci/decisions-log.sh, because the third is the one that gets dropped. Template and full rationale: decisions/README.md.

Do NOT append to DECISIONS-LOG.md. It is a frozen archive (pre-2026-09-29), its SHA-256 is pinned in decisions/ARCHIVE.sha256, and the merge gate rejects any change to it. Appending to its tail is what every agent used to do, and because every branch appended to the same last hunk, no two concurrent branches could auto-merge. Measured on 2026-09-29: four of the five open PRs were mergeable: false, with DECISIONS-LOG.md the only conflicting path in all four — none of those changes had anything to do with each other. Separate files have nothing to merge, which is the entire fix. Note that the damage from getting this wrong lands on other people's branches, later, which is why it is a gate stage and not a request.

Many older handoff entries and runbooks say "append to DECISIONS-LOG.md". That wording is stale, in the same way §1's git push origin main wording is stale.