Skip to content

Study: pre-push verification harness for autonomous code evolution #152

Closed

Description

@bgauduch

Intake phase 2 (study & framing), per ADR-0014. This body is the qualified spec and states present state (refreshed 2026-08-18; GitHub keeps the edit history). Staged go 1–2: PR 1a is merged (#160), PR 2 is merged (#167) — its decision reversed in review, see P1 — PRs 3–5 stay behind a second gate; PR 4 got its go 2026-09-19 (ordered first) and is delivered, PRs 3 and 5 remain gated.
2026-09-20 (review binding): four oracle/CI candidates joined P2's remaining list; P3.3's comment-permanence claim is narrowed in place.
2026-09-21: the key-expiry candidate is broadened to every key under security/ and is delivering; one new candidate joined P2.

Problem statement

The repository has a mature governance layer (AGENTS.md, docs/conventions.md, the ADRs, the #106 status SSOT, the ADR-0014 intake pipeline) and a post-publication outcome oracle (verify_release, #149). This study specs the layer between the two: a pre-push verification harness that lets an agent, or a human, prove a change is good before CI is asked, plus the deterministic capabilities that produce such changes.

Its first two passes are live (ADR-0016 #160, ADR-0020 #167). What remains: an enforced floor under the ADR-0012 autonomy (PR 3), deterministic structured writes (PR 4), and the oracle's later passes.

Current state

Layer State Evidence
Governance Complete as prose, unenforced AGENTS.md, conventions B/C/D1-D6/A/L1-L5, ADR-0001 to 0020
Context loading Fixed, criterion unstated #146: CLAUDE.md @-imports, SessionStart hook on stdout, ADR-0009 amended. Which documents to import, and why, is studied in #170
CI gates Good adr-check, commitlint, lint-dockerfile, build-test, release-please, validate
Outcome oracle Fixed #149: verify_release asserts every expected tag is live, opens an issue on gap
Pre-push oracle Live #160 (ADR-0016): scripts/validate.sh --fast + --full, the single local entry point
PR gate coverage Live #167 (ADR-0020): full matrix × published architectures, built and structure-tested, arm64 emulated and proven to execute
Capabilities Partial bump-version.sh + refresh-apt-pins.sh delivered (ADR-0021, PR 4); no skills, subagents or commands yet
Guardrails Partial, and demonstrably insufficient .claude/settings.json has a permissions allowlist; every NEVER in AGENTS.md is unenforced prose — and conventions L1 was violated twice (see P3)
Session lifecycle Partial the qualification gate (ADR-0014) and the delivery loop (ADR-0012) exist; nothing binds a session to plan before its first edit
Feedback loop Partial capture works (L4 → #106); graduation has no trigger, no owner, no target

The three problems

P1. The PR gate — cost question closed, coverage question answered the other way

This section is rewritten (2026-08-18). Its original framing was overtaken by its own dependencies, and the conclusion it proposed was rejected in review.

The study opened P1 as a cost problem: build-test ran, per version combination, an amd64 build plus a full multi-arch build. Raw cost fell by an order of magnitude from outside this study's scope: 16 × (1 + 4) ≈ 80 platform builds → 3 × (1 + 2) = 9, via ADR-0015 (#158) and ADR-0019 (#164). #150 (GHA cache limits) was a symptom of the old size.

By the time PR 2 was authored, the cost that justified trimming was therefore already gone. The proposal — a representative subset plus one multi-arch canary — was implemented, measured, and declined:

  • measured 14.2 → 8.6 runner-minutes at an unchanged wall clock, because the canary is the critical path and the multi-arch builds it replaced already ran in parallel;
  • runner minutes are free on a public repository, so the saving was cache churn only;
  • it was paid for in arm64 coverage — the wrong currency, because of what the measurement surfaced next.

The finding that redirected the decision: nothing in CI had ever executed an arm64 image. build-test ran container-structure-test on amd64 only; push-edge and release-please build multi-arch and test nothing. A green build proves the image assembles, not that it works — which is how #161 shipped a non-executable aws for years behind green multi-arch builds, found by a human running --full on Apple Silicon rather than by any gate.

ADR-0020 records the opposite of the original proposal and is merged in #167: the gate builds and structure-tests every supported combination on every published platform, one job per pair, arm64 under emulation. The emulated assertions are proven to execute (aws-cli/2.36.6 … exe/aarch64.debian.13). Cost rose (~14 → ~20 runner-minutes, comparable wall clock); the blind spot closed. Bounded: a second AWS CLI line takes the gate to twelve jobs, the trigger to revisit deliberately.

P2. The pre-push oracle is live but partial

Scope is bounded by the ADR-0016 principle: the oracle checks only invariants no purpose-built tool covers, and calls tools rather than re-implementing them.

Live (#160): supported_versions.json ↔ the files in security/**, both directions, extended to both architectures by ADR-0017; ADR index ↔ the files in docs/adr/.

Remaining candidates:

  • version policy: semver, sorted, one patch per minor, within the ADR-0015 EOL window
  • workflow paths: filters against files that actually exist
  • the L5 dash rule
  • HashiCorp GPG key expiry — delivering in ci(validate): fail on an expired key in security/ #211, and broadened in the process: the candidate named one key, the check covers every key under security/, because awscliv2.asc turned out to have been expired since 2023-09-17 as well. Root cause recorded there and in ADR-0016 (amended): gpg --verify exits 0 for a good signature made by an expired key, so neither lapse was visible to anything. Blocked on the AWS key itself, which no host reachable from a session serves (see fix(security): refresh the expired vendor signing keys #210)
  • (added 2026-09-21, found while writing the above) a failing check truncates --fast: every check function ends on [ "$ok" = 1 ] && pass …, which returns 1 under set -euo pipefail and aborts run_fast, so the first failure swallows every later check and the validate: FAILED line never prints. Exit codes are unaffected, so no gate is wrong — the output is just far less useful than it looks, and an agent reading it sees one finding where there may be four. Fix is one line per check; it touches them all, hence a pass of its own
  • one publisher per declared tag — ADR-0018 defines the matrix, the check asserts no second writer appears
  • the published architectures agreeing across workflows — the shapes differ: push-edge.yml and release-please.yml carry the literal linux/amd64,linux/arm64, build-test.yml carries ["amd64","arm64"] and composes linux/<arch> per job (ADR-0020)
  • documentation coherence: resolvable @-imports, resolvable relative links, every docs/ file claimed exactly once by the documentation map, no rule defined outside docs/conventions.md. The oracle owns the checking; Study: documentation single-source-of-truth map and agent context loading #170 owns the map they check, so this pass lands after it
  • --simulate-publish, learnings graduation, and seam 2 from the CI-duplication review
  • (added 2026-09-20, review binding) shellcheck on scripts/**, local-if-present in --fast (the hadolint pattern) — two real bug classes hit on 2026-09-19
  • (added 2026-09-20) one container-structure-test path: the PR gate calls the oracle's CST step instead of the third-party plexsystems action — two version sources today, and the only third-party action on the gate
  • (added 2026-09-20) one hadolint path: fold lint-dockerfile.yml into validate.yml (action-bundled hadolint vs the oracle's pinned 2.12.0)
  • (added 2026-09-20) a pre-agreed split criterion for validate.sh (e.g. ~600 lines → scripts/lib/), decided before Monorepo consolidation — merge terraform-aws-cli + terraform-azure-cli (draft plan) #131 needs scripts/ product-agnostic

Delivered by PR 2 (#167): seam 1 — rendering the container-structure-test config moved into validate.sh --render-tests, called by the workflow.

Deliberately not an oracle check: Dockerfile apt pins against the test-template assertions — container-structure-test owns detection, and PR 4's atomic writes remove the drift class at the source.

P3. Autonomy without an enforced floor

Three defects now, the third with evidence:

  1. Every session rule (never merge a PR, never delete a branch or tag, never force push or amend, never bypass hooks, never push master) exists only as prose in AGENTS.md and docs/conventions.md. Nothing blocks the action.
  2. agentRoles in .claude/settings.json is inert. It is not a Claude Code settings key and nothing reads it. ADR-0006 states that swapping a model is a one line config edit; physically, that mechanism does not exist. The real vehicle is .claude/agents/*.md with model: in frontmatter.
  3. Conventions L1 (English only for repo-facing content) has been violated twice by agent sessions, both times while the exchange with the maintainer was in French: a full comment on ci: lint commits and PR title with commitlint (Phase 1) #121 (2026-06-18) and a comment on ci(build-test): test every combination on every published platform #167 (2026-08-18). A repository-wide scan on 2026-08-18 found the tracked files and the entire commit history clean; the French is confined to GitHub artefacts — those two comments, plus the pre-conventions pull-request titles of 2026-04-22 (ci: conventional commits et semantic release (epic #101) #107–ci: amélioration des workflows - concurrency, pr triggers, cache (epic #103) #114). The rule existed both times and stopped nothing, which is this problem in one sentence. Two aggravating facts: no CI gate reads pull-request comment bodies, and a session cannot edit or delete a comment (no such tool in the MCP surface), so a slip is permanent and can only be superseded. (Narrowed 2026-09-20: the MCP surface now carries update_issue_comment — a session can edit its own comments; others' comments, deletion, and the enforcement gap stand.) Maintainer decision 2026-08-18: the historical artefacts are left as they are — closed, never merged, and written before the conventions existed. The work is forward-looking only.

Target architecture

Four verification tiers, one script, docked into ADR-0014 phase 5 (realisation). The tier numbering is this study's shorthand and is defined here; ADR-0016 deliberately does not use it.

ADR-0014 intake:  1 dedup -> 2 go/nogo -> 3 prioritise -> 4 plan -> 5 REALISATION
                                                                        |
                    +---------------------------------------------------+
                    v
   T0  scripts/validate.sh --fast    no Docker, <10s    human, agent loop, every PR
   T1  scripts/validate.sh --full    Docker, 1 combo    before push
   T2  CI build-test                 full matrix x published arches, built and tested
   T3  CI release-please             publication + verify_release (authoritative)
                    |
   +----------------+--------------+
   |  capabilities (scripts/)      |   guardrails             feedback
   |  bump-version, refresh-pins   |   PreToolUse deny-list   L4/L5 graduation
   |  skills are thin wrappers     |   real .claude/agents/   simulate-then-fix
   +-------------------------------+

Core invariant: one oracle, three callers — and one entry point. scripts/validate.sh is the same script whether run by the maintainer, by an agent mid-loop, or by the validate.yml job.

Verification tiers

Tier Status Content Gain
T0 live (#160) scripts/validate.sh --fast: the oracle-owned P2 checks, built up in passes; hadolint called when locally available Agent self-verifies in seconds instead of pushing and waiting on CI
T1 live (#160) scripts/validate.sh --full: the fast checks, then pinned containerized hadolint, single-platform build, container-structure-test One entry point locally and in CI
T2 live (#167) every combination × every published architecture, one job per pair: single-platform build then container-structure-test, arm64 emulated; seam 1 (--render-tests) rode along arm64 executed before merge for the first time — the #161 defect class is now detectable
T3 unchanged publication + verify_release Remains the authoritative gate

Acceptance criteria

The harness is working, not merely merged, when:

  • T0 discipline: validate.sh --fast runs in under ~10s without Docker and is always green on master.
  • Drift classes retired: zero recurrences in the following quarter — versions↔security/** and ADR-index via the oracle; pins↔template via its owners.
  • Gate honesty (after PR 2, met): a green build-test means every published combination was built and executed on every published architecture.
  • Guardrails proven (after PR 3): every NEVER in AGENTS.md, and conventions L1, have a deny rule with a test demonstrating the block fires, on both the Bash and MCP paths.

Automation ladder, and the arbitration of #20

Evolution Rung Owner Note
GitHub Actions, Debian base digest 0, bot Renovate done
Release, changelog, tags, publish verification 0, bot release-please plus verify_release done
Detect a new Terraform or AWS CLI release 0, bot Renovate customManager on supported_versions.json (#20) delivering in #209 — detect-only, reported on the Dependency Dashboard rather than as a pull request, because a bumped JSON without its security/ material cannot build (ADR-0002, amended)
Materialise that bump 1, script scripts/bump-version.sh: fetch SHA256SUMS and .sig per architecture, GPG verify, drop the superseded patch of the same minor and retire EOL minors per ADR-0015, sync the test template Delivered (ADR-0021, PR 4)
APT pin refresh 1, script scripts/refresh-apt-pins.sh, writing the Dockerfile and the test template atomically Delivered (ADR-0021, PR 4). Live cost of not having it: #168 (2026-08-18), and the 2026-09-19 curl pin (#184)
Dockerfile or CI structural change 2, agent plus ADR orchestrator drafts the ADR, executor implements, the local script then CI verify ADR-0014 phases 2 to 5
CVE response 2, agent Trivy (Phase 5) plus a reviewer subagent not started

#20 is therefore not an alternative to a bump script, it is its trigger.

Guardrails

Each binding NEVER becomes a PreToolUse deny rule in a single .claude/hooks/deny-destructive.sh:

Rule Denied pattern
Never merge a pull request gh pr merge, gh api -X PUT .*merge
Never delete a branch, tag or ref git branch -[dD], git push .*--delete, git push .*:refs/, git tag -d
B3, never rewrite shared history --force, --force-with-lease, git commit --amend, git rebase
C3, never bypass hooks --no-verify, --no-gpg-sign
B2, never push master git push origin master, any git push while HEAD is master

Bash patterns alone miss the real path. Agents act through the GitHub MCP tools as much as through Bash: a gh pr merge pattern never fires when the call is mcp__github__merge_pull_request. The deny layer needs two matchers: the Bash patterns above, plus PreToolUse rules on the MCP tool names themselves — at minimum mcp__github__merge_pull_request, mcp__github__enable_pr_auto_merge, and any branch/ref deletion or force-update API call.

A third matcher, on content, not on verbs — maintainer decision 2026-08-18, from P3.3. The same hook gains a predicate over the body of outbound GitHub text (mcp__github__add_issue_comment, issue_write, create_pull_request, update_pull_request, add_reply_to_pull_request_comment, pull_request_review_write) and over git commit messages: French accented letters, or French stop-words on word boundaries, denies the call with "rewrite in English before sending". Two signals required, to keep accented proper nouns from tripping it. This is the only mechanism that can enforce L1 — no CI gate reads comment bodies, and prose has now failed twice.

Escape hatch for every rule: overrides live only in settings.local.json (git-ignored, human-owned), so a misfiring rule can never lock the maintainer out. Limit to state in the ADR: hooks bind Claude Code sessions only (the ADR-0009 adapter layer); another tool's adapter must carry its own equivalent, and the agent-agnostic obligation stays in AGENTS.md.

Separately: remove the inert agentRoles block and create real .claude/agents/{orchestrator,executor,reviewer}.md carrying model: in frontmatter — an amendment to ADR-0006 rather than a new decision.

Session lifecycle: plan before edit

Phase Discipline State
Qualification dedup → study → human go/nogo, once per unit of work exists, ADR-0014
Session planning an explored, stated plan before the first edit; no human gate — the work is already qualified missing
Delivery implement → verify locally → push → drive CI to green exists, ADR-0012

Proposed session rule (AGENTS.md, tier-1 per the awareness-tiers doctrine in #106): non-trivial work begins with an explored, stated plan before the first edit; iterating an open PR to green needs no new plan while the fix stays within the PR's declared scope. The escape hatch is unchanged: an ambiguous, architecturally significant or out-of-scope fix exits the delivery loop and returns to planning.

Rejected option: a global defaultMode: "plan" in .claude/settings.json. Plan mode is a per-lifecycle-phase discipline, not a per-session default — a static default would stall every ADR-0012 CI-fix session in plan mode.

Ships with PR 3, carried by an amendment to ADR-0012.

Feedback loop (closing it)

Capture stays manual and cheap (L4: session learnings land in the #106 body). The missing half is graduation: nothing triggers it, nothing owns it, no target is defined. Four additions close the loop:

  1. Graduation rule: a learning graduates when it recurs or is binary; preferred target order oracle check > conventions / AGENTS.md rule > ADR amendment > skill. The highest reachable target wins — a check never needs to be remembered.
  2. Owner — a PR 1 definition of done: ripe 📌 Tracking & resume — modernization + agent framework #106 learnings ship as part of the oracle. The three writing learnings share one root and are candidates for a single docs/conventions.md L-rule.
  3. Cadence — mechanical nudge only: the Phase 7 monthly health-check workflow flags 📌 Tracking & resume — modernization + agent framework #106 learnings older than about a month as "graduate or drop". The human decides what each becomes.
  4. Capture rule — bind the input: a surprising failure ships with a learnings entry in the 📌 Tracking & resume — modernization + agent framework #106 body, in the same gesture as the fix.

Candidate rule surfaced by PR 2 (2026-08-18): a queued item restates why it is still worth doing before it is delivered, not only what it does. P1 is the case study.

Counter-example that sizes the loop (2026-08-18): the L1 violation graduated straight past prose — the learning existed, in this very repository, and the second occurrence happened anyway. When a learning is binary and machine-checkable, prose is not a target; it goes to the highest rung or it does not count as graduated.

Proposed sequencing

Order PR Payload Relates ADR
1 feat(ci): single validation oracle — pass 1a merged (#160) validate.sh --fast + --full as the single entry point, validate.yml, scripts/ in the adr-check structural regex. Later passes: the P2 remaining candidates #104 items delivered ADR-0016 (shipped)
2 ci(build-test): test every combination on every published platform — merged (#167) Full matrix × published architectures, built and structure-tested, arm64 emulated; matrix = supported_versions.json + one arch key crossed by strategy.matrix; seam 1. Cross-references realigned in ADR-0015, ADR-0019, roadmap #150, #103, #158, #164, #161 ADR-0020 (shipped)
3 feat(agent): enforce session boundaries — second gate, still gated deny hook with three matchers: Bash patterns, MCP tool names, and the L1 content predicate (see Guardrails); .claude/agents/*, drop agentRoles; plan-before-edit and failure-capture session rules in AGENTS.md; amend conventions B1 to exempt tool-managed session branches P3 (all three defects), session lifecycle amend ADR-0006 + amend ADR-0012
4 feat(scripts): version and pin automation — go 2026-09-19, delivered bump-version.sh, refresh-apt-pins.sh; scripts write the Dockerfile and the test template atomically. docs/binaries-verifications.md re-scoped as the manual fallback #20, #104 (remainder), #168, the 2026-09-19 curl pin ADR-0021 (shipped with the PR)
5 feat(agent): skills and commands — second gate /preflight, /plan, bump-version, propose-adr as thin wrappers over PRs 1 and 4 roadmap Phase 6 no
6 Phase 3, 4 and 5 residue docker/login-action, permissions:, concurrency: on push-edge, GHCR, Trivy, SBOM, cosign #100, #99, #103 per item

Order matters. A skill written before the oracle exists re-implements logic in prose, which is exactly what conventions L2 forbids.

Decisions requiring an ADR

Decision Trigger Status
A single verification oracle shared by human, agent and CI PR 1a ADR-0016, shipped
PR gate coverage — builds and tests the full matrix on every published platform PR 2 ADR-0020, shipped (#167)
Amend ADR-0006 on the role to model vehicle PR 3 second gate
Amend ADR-0012 on autonomy scope versus the planning obligation PR 3 second gate
Deterministic scripts own every structured write, agents orchestrate PR 4 ADR-0021, shipped

Open findings

Finding Confidence Next step
push-edge.yml pipes a secret into docker login and declares neither permissions: nor concurrency:; release-please.yml pipes the same secret High In scope of #100/#60/#103, listed as PR 6 residue (the concurrency: half landed via #179; permissions: on push-edge rides the #183 delivery)
Publication tests nothing: push-edge and release-please build multi-arch and push without a single assertion Re-homed Qualified, decided and delivering in #183 (ADR-0022)
Documentation ownership and agent context loading Medium Homed in #170 (qualified 2026-08-18); this study keeps only the coherence checks, in P2
--full cannot complete in a hosted agent session (egress proxy terminates TLS, build containers do not trust its CA) High Recorded in docs/agent-framework.md; candidate for an oracle pass that detects the interception
A vendor key cannot be refreshed from a hosted session: the HashiCorp key is served at a reachable .well-known URL, the AWS CLI key only inline in the install guide, and docs.aws.amazon.com, keys.openpgp.org and the Wayback host are all blocked by the egress proxy; keyserver.ubuntu.com answers but serves a copy that is itself stale High Same class as the row above — an environment limit, not a code one. Recorded in #210; docs/agent-framework.md is the home if it recurs
#106 is a large ledger; the SSOT audit remainder is to slim the done ledger Resolved #178/#181 delivered the rule and the restructure
Monorepo (#131): scripts/ must be product-agnostic from day one Design constraint Note on #131
docs/binaries-verifications.md is legacy-quality, superseded by PR 4's bump-version.sh Resolved Re-scoped as manual fallback in the PR 4 delivery

Tracking and upkeep

Next step

  1. Second gate decision on PRs 3 and 5 — PR 4 is delivered; PR 3 carries the L1 enforcement, which has a live defect behind it (P3.3).
  2. Oracle later passes (the P2 remaining candidates + seam 2); the documentation-coherence pass waits on Study: documentation single-source-of-truth map and agent context loading #170.

Activity

  1. bgauduch commented on Aug 1, 2026

    @bgauduch
    OwnerAuthor

    CI-duplication review (2026-07-31) — three residual seams, scoped as later passes. Maintainer question: now that validate.sh exists, do lint-dockerfile.yml and build-test.yml duplicate it?

    Audited the workflows. The verdicts and the config are already single-homed — both callers run the same tools (hadolint, buildx, container-structure-test) against the same files (hadolint.yaml, tests/container-structure-tests.yml.template, supported_versions.json, Dockerfile). What is written twice is only the orchestration, and deliberately so: CI keeps matrix fan-out, type=gha cache, QEMU and hadolint-action's PR annotations — reimplementing those in bash would rebuild a CI engine in shell. Boundary now recorded in ADR-0016 (#160): the core is tools + config; orchestration is a thin per-environment adapter, the ADR-0009 pattern applied to verification.

    Three genuine seams remain, all cheap:

    # Seam Fix Home
    1 The envsubst line rendering the CST config from the template is duplicated in build-test.yml and in validate.sh — that is shared logic, not orchestration validate.sh --render-tests, called by the workflow PR 1 later pass, or ride PR 2 (it edits build-test.yml anyway)
    2 Tool versions pinned on both sides: hadolint 2.12.0 (script) vs hadolint-action@v3.1.0 (opaque embedded version); CST v1.16.0 (script) vs plexsystems/container-structure-test-action@v0.3.0 T0 check asserting script pins and action versions stay coherent PR 1 later pass
    3 Renovate bumps the actions but not the images pinned inside the script customManager regex for the script's pinned images #20

    None blocks PR 1a (#160) or PR 2; logged here so the seams are tracked rather than remembered.


    Generated by Claude Code

  2. bgauduch commented on Sep 20, 2026

    @bgauduch
    OwnerAuthor

    Folded into the body (P2 remaining candidates + P3.3 narrowed, dated 2026-09-20).


    Generated by Claude Code

  3. added 2 commits that reference this issue on Sep 24, 2026
    4c6b186
    d6d96c8
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    enhancementNew feature or request

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions