Files
home-network-k3s-impl/decisions
jakman85andClaude Opus 5 616a4974f3
gate / gate (pull_request) Failing after 1m46s
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-02 00:02:20 -05:00
..

decisions/ — the handoff log, one file per entry

This directory is the decision log. ../DECISIONS-LOG.md is the frozen archive of everything written before 2026-09-29 and is never edited again (see "The archive is frozen" below).

Why this exists

DECISIONS-LOG.md was append-only in a single file, and every agent appended to its tail. Two branches that both append hit the same last hunk, so git cannot auto-merge them: the second one to be reviewed conflicts, and so does the third, and the fourth. Measured on 2026-09-29: four of the five open PRs were unmergeable, and DECISIONS-LOG.md was the only conflicting file in all four:

#22 fix/crafty-oauth2-proxy-signin      CONFLICT (content): DECISIONS-LOG.md
#20 feat/conformance-job-template       CONFLICT (content): DECISIONS-LOG.md
#19 feat/conformance-trigger-kubeconfig CONFLICT (content): DECISIONS-LOG.md
#18 feat/conformance-runner-image       CONFLICT (content): DECISIONS-LOG.md
#17 feat/ci-is-the-approver             clean  (it touched no log entry)

Nothing was wrong with any of those changes. The log's storage format serialised work that was otherwise parallel, and it got worse with every concurrent branch: N branches produce N-1 rebases, each of which invalidates any gate run that preceded it.

The fix is the obvious one for concurrent appenders: stop sharing a file. Each handoff is its own file. Two agents writing handoffs touch disjoint paths, so there is nothing for git to merge and nothing to conflict. This scales to any number of parallel branches with zero coordination — which is the point, since the agents are deliberately isolated from each other in separate worktrees and cannot coordinate.

There is deliberately no index file and no counter. An index would be a shared mutable file — exactly the thing that just failed — and a sequence number would have two agents racing for 0043. The directory listing is the index.

Writing an entry

One file per handoff, named:

decisions/YYYY-MM-DD-<slug>.md

<slug> is lowercase [a-z0-9-]. Use your branch name's last segment — it is already unique per unit of work, which is precisely the property the filename needs. A branch fix/crafty-oauth2-proxy-signin handed off on 2026-09-29 writes:

decisions/2026-09-29-crafty-oauth2-proxy-signin.md

The file is YAML frontmatter followed by three required sections:

---
date: 2026-09-29
branch: fix/crafty-oauth2-proxy-signin
title: The Keycloak gate works — Crafty's own login was the wall
---

## What changed

Prose. What you actually did, and why.

## What I verified

The measurements, with their numbers. Quote the field you read, not the prose you
believed. Name the gate result and its exit code.

## What I did NOT verify

Everything else. An honest "UNVERIFIED" is worth more than an optimistic "green" —
this repo has been burned by the difference more than once.

All three headings are required and enforced (ci/decisions-log.sh). They are CLAUDE.md §7's contract; making them a checked structure is what stops an entry degrading into "done ✅".

Reading the log

The whole log, oldest first:

ls decisions/[0-9]*.md | sort

The current PM handoff — the newest entry. Ties within a day are broken by what landed on main last, which is the honest answer, so ask git rather than the filename:

git log --diff-filter=A --format='%H' --name-only -- 'decisions/[0-9]*.md' \
  | grep '^decisions/' | head -1

For a quick skim of who said what:

grep -H '^title:' decisions/[0-9]*.md | sort

The archive is frozen

DECISIONS-LOG.md is read-only history. Its SHA-256 is pinned in decisions/ARCHIVE.sha256 and ci/decisions-log.sh fails the merge gate if the file changes. That is not ceremony: without it, the next agent to read runbooks/ or an old CLAUDE.md habit appends to the tail again and re-creates the conflict for everybody else, silently, one PR at a time.

If the archive genuinely must change (a leaked credential to redact, say), that is a deliberate, reviewed act: change it, run sha256sum DECISIONS-LOG.md > decisions/ARCHIVE.sha256, and say in your handoff entry why. Re-pinning is visible in the diff. Appending is not.