DECISIONS-LOG.md was append-only in ONE file and every agent appended to its
tail, so any two concurrent branches collided on the same last hunk. Measured
against the live Gitea today: four of the five open PRs were mergeable=false,
and `git merge-tree` named DECISIONS-LOG.md as the ONLY conflicting path in
all four.
#22 fix/crafty-oauth2-proxy-signin CONFLICT: DECISIONS-LOG.md
#20 feat/conformance-job-template CONFLICT: DECISIONS-LOG.md
#19 feat/conformance-trigger-kubeconfig CONFLICT: DECISIONS-LOG.md
#18 feat/conformance-runner-image CONFLICT: DECISIONS-LOG.md
#17 feat/ci-is-the-approver clean — it wrote no handoff
Nothing was wrong with any of those changes. The log's storage format
serialised work that was otherwise parallel, and the cost grew with every
concurrent branch (N branches -> N-1 rebases, each invalidating the gate run
before it). The agents work in isolated worktrees precisely so they do not
block each other; this one file undid that.
So: stop sharing a file. Handoffs are now one-per-file under decisions/,
named YYYY-MM-DD-<slug>.md. Disjoint paths have nothing to merge. There is
deliberately no index file (a shared mutable file is the thing that just
failed) and no counter (two agents would race for the same number) — the
directory listing is the index.
DECISIONS-LOG.md is frozen as the pre-2026-09-29 archive, its SHA-256 pinned
in decisions/ARCHIVE.sha256, and a new gate stage rejects any change to it.
That is a guard and not a paragraph because the old habit is written into the
runbooks and into every agent that has read the log, and getting it wrong does
not fail: it produces a clean-looking commit that re-creates the conflict on
OTHER branches, later, misattributed.
ci/decisions-log.sh new stage: archive pinned; every entry named with
a real date, carrying date/branch/title frontmatter
that agrees with its filename, and all three of
CLAUDE.md §7's sections with a real body under each.
Exit 0 or 1 only — no skip path by construction, so
no fail-open surface on a guard that closes one.
ci/gate.sh runs it; fail and (impossible) skip both BLOCK.
No REQUIRE_DECISIONS_LOG knob: a guard with an off
switch is not a guard.
ci/tests/decisions-log.test.sh 22 assertions, incl. the one that matters —
an append to the archive is rejected, and the
message names the file to write instead.
ci/tests/gate-decision.test.sh +3 assertions for the new wiring, AND a
pre-existing bug: the stub list never included
gitea-settings, so run_stage saw exit 127 and
recorded a FAILED stage in every case. 15 of 47
assertions failed on main for that reason alone —
the suite was measuring its own missing stub.
Now 50/50.
CLAUDE.md §7 rewritten; §2b widened to the new path.
ci/Makefile, ci/README.md, .gitea/workflows/README.md — stage named where
the other stages are named.
Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
8.4 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. A human approves and clicks merge.
- Do not
kubectl apply/edit/patcha durable change. Flux reconcilesmain; anything applied by hand is drift that the next reconcile reverts or, worse, adopts.
Many runbooks under
runbooks/still saygit push origin mainIS 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 |
| Approval required before merge | ✅ required_approvals: 1 |
All four 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.
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:
dev.shrefuses to start:infra/ansible/.cache/prod.kubeconfigis untracked and exists only in the main checkout. Do not copy it (it is a credential). UseDEV_KUBECONFIG=/dev/null— the gate contacts no cluster, so L2/L3 scaffold-skip normally.- Every
gitcall inside the container fails: a worktree's.gitis a pointer to a gitdir outside the mounted root.dev.shtakes no extra-mount flag, so inject one via theENGINEhook — a shim that interceptsrunand re-execspodman run -v /path/to/main/.git:/path/to/main/.git:ro "$@". A traceback intracked_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 -da file into a terminal, a log, or a transcript. - Never print a Secret's
.data/.stringDatawhen 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.yamlor live undersecrets/. The root.sops.yamlis 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.