The problem

Agents now write code faster than people can check it. The risky part is what happens after the code: review, merge, release, deploy. Three things go wrong once an agent writes most changes:

  1. The reviewer read a different version. A review passes, one more commit lands, and the merge takes the unreviewed head.
  2. The author signs its own work. The same agent, or the person who asked it, writes, approves and merges.
  3. "Done" without proof. The merge request says tests pass; nothing records which command ran, on which commit, with which exit code.

What Countersign does

Design goal — one rule over the whole post-code lifecycle:

An agent-written change reaches the default branch or production only when a reviewer that is not the author passed the exact head SHA and the pipeline passed on that SHA. Every change that is not an ordinary one (each changed file a regular file under site/ or docs/ of a listed type, docs/DESIGN.md section 6a) also needs a human to approve that SHA: never the reviewer, the author only where the repository's policy allows it, and for a change to the gate itself one more approval than usual, so never the author's alone. Production is always a person's step. Decisions leave evidence (command, exit code, SHA) in a hash-chained ledger.

For an ordinary change a person does two things: writes the issue and assigns the author, then reads the summary note and presses production. In between, the steward (deterministic code a scheduled pipeline runs from the default branch) starts the reviewer and, on its pass, decides with the gate code and policy on main and merges with the head SHA. tests/test_steward.py holds the steward's local tests. On GitLab, steward job logs read "merged" for !7 on 2026-10-22, from the older steward code that is written to play a manual gate job (the run), and for !12 and !13 on 2026-10-26, from the current steward (the runs): !12 in a staged scenario (human step: participant record; the logs do not show who acted; see Who did what), !13 its repair.

What is implemented, and what has run on GitLab:

  • The gate (python -m countersign gate <request>) returns allow or deny for the request it is given, checking the rule above against that request. A collector builds that request from the GitLab REST API. The steward runs both on main, right before it merges: it logs "decided allow on main" and merges, or "decided deny on main" and stops. In a merge request pipeline the countersign-gate job runs by itself and only checks the hash chain of the ledger the test job wrote (python -m countersign verify-ledger evidence.jsonl); it decides nothing. All of this is tested locally on documented answers. On GitLab steward jobs 17454399139, 17456290275 and 17459419409 logged "decided allow on main" for !12, !13 and !16. A deny on GitLab is not yet measured. On 2026-10-22 the gate was still a manual merge-request job; it ran once, on !7 (job 17381381718, success, account 43436308, inside steward job 17382733439).
  • A gate decision is written to the ledger only when --ledger <file> is passed. The deploy-staging job as written and the local demo runs do not pass it; its decision is in the job log. The steward's decision is in the steward job log; the countersign-gate job writes no decision.
  • The test job as written records two commands (unittest discover, mutation_check.py) with their exit codes and the commit SHA in the ledger. The test job succeeded (jobs API) in the merge request pipeline of !7 and in the main pipeline after the merge.

Three pieces, as they exist in the repository:

  • A/B/C action tiers (policy/tiers.json), as the gate classifies an action. A = read, an agent may do it alone. B = propose (branch, commit, merge request, note, staging), only while the action's parameters fit a written contract (an agent/<topic> branch, the staging environment, a test command from a fixed list, files created or updated but never deleted). C = a human is required (merge, approve, tag, release, production deploy, delete, force push, settings). Anything not in the table is C.
  • A deterministic gate (countersign/gate.py). Same input, same decision, no model in the loop. It tells people apart by stable account id and account type, never by name. It counts human approvals on the head SHA, one per account id: never the reviewer's, never one from an account GitLab reports as a bot. The author's own counts once, however many names it comes under, because this repository sets author_may_approve to true in policy/tiers.json. A change to the gate itself needs one more human approval, so the author alone gets 1 of 2. That is the gate's count; whether GitLab lets the author approve is in docs/DESIGN.md, Known limits.
  • An evidence ledger (countersign/ledger.py). JSON Lines, each record holding the hash of the previous one. verify-ledger catches an edited, deleted, reordered or inserted record; with a head hash kept outside the ledger (--expect-head) it also catches a cut tail or a re-hashed edit.

Who did what

This paragraph is the participant record of the runs, the entrant's own account; the logs and APIs do not prove who acted. By this participant record (not shown by the logs), a person, the entrant, started each run on GitLab; wrote the scenario issues, assigned them to the author flow's account and set the reviewer of !14, directly or through the entrant's own coding-agent session in the entrant's signed-in browser; merged the merge requests left to people (!8, !9, !14, !15; steward job logs read "left to people" for !14 and !15) and closed !10, #4 and #7; re-issued the steward's token when it was needed (on 2026-10-22, between the run that failed with HTTP 403 and the next one); and pressed production (pages) each time it ran, the two re-runs of an older pages job included. In between, by design, the author flow writes the change, the reviewer flow reviews it, and the steward's code decides and merges (flows/author.yml, flows/review.yml, countersign/steward.py). The steward code that steward job 17382733439 checked out on 2026-10-22, 6f7ef28b, is written to play the manual gate job itself (countersign/steward.py:181–194,270–282 at 6f7ef28b). That commit is in the history of main (git log --first-parent of main at 035d43e), and the job log reads "ref is main": it was the older implementation then running from main. The implementation on main has changed since: the current countersign/steward.py plays no job (countersign/steward.py:22). GitLab's logs and APIs show an account id and a time for each event, not who was at the keyboard. Elsewhere in this text, each human step carries the mark (human step: participant record; the logs do not show who acted; see Who did what).

The run on GitLab (2026-10-22)

Project: https://gitlab.com/gitlab-ai-hackathon/transcend-october-2026/43436308/showcase (public). Links below are relative to it. Times are UTC.

  1. 12:08:02 — issue #3 is authored by account 43436308 and assigned to account 43587127 (issues API, author, assignees) (human step: participant record; the logs do not show who acted; see Who did what).
  2. Merge request !7 (agent/how-a-change-ships → main, head 04843e1, one file, site/index.html, "Closes #3") is authored by account 43436308 (merge request API, author); the copied logs do not show which flow ran the commit. The author flow's session page is for signed-in users only. What the copies show of account 43587127, the assignee of #3: the test and package jobs of merge request pipeline 2976416894 ran under it, and the jobs API reports it with bot: true (account 43436308 reads bot: false in the same copies; jobs API, user); issue #3's public discussions.json shows a note from it that starts with its own display name and "has started", at 12:08:05 (note 4067615907); and the author name of commit 04843e1 (merge request commits API) equals its name field (jobs API, user).
  3. Merge request pipeline -/pipelines/2976416894: test and package succeeded; countersign-gate (job 17381381718) is manual in that version's .gitlab-ci.yml.
  4. Scheduled pipelines ran the steward from main: -/pipelines/2976461464 ("!7: waiting for the head pipeline to finish"), then -/pipelines/2976525714, whose steward log reads "!7: review started, flow 9887989" (a Flows API start).
  5. The verdict note on !7 ends countersign-verdict: pass sha=04843e1cee0fe7763a66f4427fcf1c55343ac2c2 config=flows/review.yml (the note as the public discussions.json of !7 reads it, read 2026-10-27T04:02:44Z without signing in; note author account 43579089).
  6. Scheduled pipeline -/pipelines/2976591959 (steward job 17382733439, 12:52:04–12:52:53, at commit 6f7ef28b, its log line 35): countersign-gate (-/jobs/17381381718) ran from 12:52:19 under account 43436308 (jobs API, user) and succeeded; the steward job logged "!7: merged at 04843e1…" at 12:52:52: merge commit afbbdca. The steward code at 6f7ef28b is written to play the gate job (Who did what). Issue #3 closed through "Closes #3" at 12:53:06.
  7. main pipeline -/pipelines/2976595615 at afbbdca: test, package, upload-package, secret_detection and semgrep-sast succeeded; so did deploy-staging (-/jobs/17382762533).
  8. countersign-summary (-/jobs/17382762535) logged "summary: posted on 1 issue(s)": a note on issue #3 with the merged SHA, the verdict, the gate job, staging and the pages job to press (the note as the public discussions.json of #3 reads it, read 2026-10-27T04:02:48Z without signing in; note author account 43436308).
  9. Production: pages (-/jobs/17382762534) is a manual job a person presses; smoke-production (-/jobs/17382762536) waits for it. This pages job never ran (still manual on 2026-10-26, jobs API); a pages job first ran on 2026-10-25, on a later commit (next section).

The merge is recorded under the account that owns the steward's token, the entrant's own (merge request API, merged_by). The steward has no account of its own; the notes it writes start with countersign-steward: or countersign-summary: (countersign/steward.py).

Before this run, the first scheduled steward run failed with steward: GET /user: HTTP 403 (-/jobs/17380023605), and the next run succeeded (-/jobs/17381297962). A token re-issue came between them (human step: participant record; the logs do not show who acted; see Who did what); the cause, a missing User: Read permission, is the participant record at the time, not re-verified from a source copy or shown by the logs.

The runs on GitLab (2026-10-25 and 2026-10-26)

Times are UTC. The steward's lines are quoted from its job logs (-/jobs/<id>/raw, readable without signing in).

  1. 2026-10-25 05:09:40 — !8 was merged from the entrant's account (merge request API, merged_by; it is the current steward: it decides on main before every merge, runs the fix loop (off since !15, step 8) and the monitor; not an ordinary change). In the main pipeline -/pipelines/2985911159 at c248886 pages ran (human step: participant record; the logs do not show who acted; see Who did what) (-/jobs/17445109840, 05:34:08–05:34:23); smoke-production (-/jobs/17445109842) succeeded at 05:34:35. That pages job is the first production deploy in the jobs API. !9 (08:27:07) put MONITOR_URL on main; from then each scheduled steward run in the copied steward logs measured production first, for example "monitor: production ok sha=c248886… deployment=1640486808 job=17445109840" (steward job 17446786769, 09:35:38).
  2. Staged for the demo. Issue #4 ("site: staged for the demo, remove the Live commit line"), authored by account 43436308 and assigned to account 43587127 (issues API) (human step: participant record; the logs do not show who acted; see Who did what), asks the author agent to remove the page's Live commit element; !10 followed. In the copied steward logs the fix loop ran once, on !10: its merge request pipeline -/pipelines/2986218992 failed in test. A steward job logged the author flow's fix start ("!10: fix started, flow 10014930", steward job 17446786769, 09:35:38). The fix flow ended without pushing a commit, and the next run stopped that merge request as designed ("!10: stopped on this head (fix-no-push); a person looks", steward job 17446873249, 09:49:55). !10 and issue #4 were closed from the entrant's account on 2026-10-26. Not measured on GitLab: a fix commit pushed by the author flow, a new review of it, and a merge after a fix.
  3. Staged for the demo (issue #5's title). The anomaly was staged: an issue asked the author agent to remove the page's Live commit element, and the entrant pressed production (human step: participant record; the logs do not show who acted; see Who did what). The monitor's issue that follows comes from a scheduled steward run (its log line below). As on 2026-10-22, the scenario issues are authored by account 43436308 and assigned to the author agent's account (issues API, author and assignees) (human step: participant record; the logs do not show who acted; see Who did what). Issue #5 (2026-10-26 00:39:16) → !12 → steward log ("!12: review started, flow 10027174", 01:08:38) → verdict pass sha=3e4c63e7 → "!12: decided allow on main" and "!12: merged at 3e4c63e…" (steward job 17454399139, 01:20:21). In the main pipeline -/pipelines/2987554919 at 0ebc5a1 pages ran (human step: participant record; the logs do not show who acted; see Who did what) (-/jobs/17454402753, 01:51:54); smoke-production (-/jobs/17454402755) succeeded, because it checks only version.txt. Steward job 17454948199 logged at 02:09:19 the page without its marker: "monitor: marker at 0ebc5a1…; issue #6 opened".
  4. Rolling back by re-running an older pages job does not work. pages of the older pipeline 2985911159 was re-run (human step: participant record; the logs do not show who acted; see Who did what) (-/jobs/17455067508, 02:20:51) to put c248886 back. GitLab recorded production deployment 1641068329 at c248886, but the site's version.txt was not c248886, and steward job 17455292083 logged a second issue at 02:48:47: "monitor: version at c248886…; issue #7 opened". A second re-run (human step: participant record; the logs do not show who acted; see Who did what) (-/jobs/17455425880, 03:03:22) showed why: its pages:deploy (-/jobs/17455428618) failed with "build SHA is outdated for this ref" (issue #8's text; no job log or jobs API copy of that job). The way back is a revert or fix merge request on main, then pages pressed in that pipeline.
  5. That way back: issue #6 is assigned to account 43587127 (issues API, assignees) (human step: participant record; the logs do not show who acted; see Who did what) → !13 (03:13:07) → "!13: review started, flow 10029648" (03:50:47) → verdict pass sha=ac2b37a1 → "!13: decided allow on main" and "!13: merged at ac2b37a…" (steward job 17456290275, 04:09:56). In the main pipeline -/pipelines/2987872897 at 4f5abaf pages ran (human step: participant record; the logs do not show who acted; see Who did what) (-/jobs/17456305540, 05:28:22–05:28:36); smoke-production (-/jobs/17456305542) logged "live site serves 4f5abaf10eeb12cb5483821c704ceabcd64ed37e" at 05:28:57. The next measured run: "monitor: production ok sha=4f5abaf… deployment=1641154688 job=17456305540" (steward job 17458528686, 05:50:02). The issue API records #7 closed under the entrant's account (06:05:53), with a note on its cause.
  6. The rollback wording itself: issue #8 asked the author flow to correct the documents and the monitor's issue text, which had said "re-deploy the previous deployment". Its merge request !14 changed countersign/ and tests/, so it was not an ordinary change, and no steward log line merges it. By the participant record (the logs do not show who acted), the entrant set the reviewer of !14 to the reviewer flow's account; the merge request API lists that account in reviewers. The verdict note of !14 reads pass sha=2b1e266f; the merge request API records !14 merged under the entrant's account (06:38:21, merge commit 6328004) and an empty approvals list.
  7. An approval rule that no longer applies (issue #9's title). A second scenario: issue #9 (06:01:49, authored and assigned the same way) (human step: participant record; the logs do not show who acted; see Who did what) asked to replace the two paragraphs above "How a change ships" with one sentence. !16 followed (06:03:21); it replaced only the first paragraph and kept the Live commit element, and its description says why. Steward logs: "!16: review started, flow 10033512" (06:35:54), a verdict pass sha=ecb12dce, and "!16: decided allow on main", but GitLab refused the merge: meanwhile !14 had reached main, and this project merges semi-linearly, so !16 needed a rebase. Steward job 17459419409 logged at 06:50:21 "!16: stopped on this head (merge-refused); a person looks". No steward log line shows a fix start for !16.
  8. The fix loop is off on main since 07:24:00: !15 was merged from the entrant's account (merge commit 035d43e), which sets FIX_LOOP = False in countersign/steward.py. The steward starts no new fix; the monitor stays on. On GitLab the fix loop is design and local tests only.

Lifecycle stages touched

Only stages the 2026-10-22 run touched on GitLab, each with the job or file it rests on:

  • Plan — issue #3 assigned to account 43587127 (issues API, assignees; the Assign trigger is in flows/author.yml) (human step: participant record; the logs do not show who acted; see Who did what).
  • Create — !7 on branch agent/how-a-change-ships with commit 04843e1 (flows/author.yml).
  • Verify — test succeeded in pipelines 2976416894 and 2976595615; the verdict note bound to the head SHA (flows/review.yml) is on !7 (public discussions.json, read 2026-10-27T04:02:44Z; note author account 43579089).
  • Package — package and upload-package succeeded in pipeline 2976595615 (.gitlab-ci.yml).
  • Secure — semgrep-sast and secret_detection succeeded in pipeline 2976595615 (GitLab's SAST and Secret Detection templates, included in .gitlab-ci.yml).
  • Configure — deploy-staging (job 17382762533) deployed to the staging environment (.gitlab-ci.yml).
  • Govern — countersign-gate (job 17381381718) applied policy/tiers.json to head 04843e1 and allowed it with no human approval, because every changed file is ordinary and merge_needs_human is false (countersign/gate.py, countersign/policy.py); the steward merged only after that.

Touched in the runs of 2026-10-25 and 2026-10-26 (jobs API and job logs, below):

  • Monitor — smoke-production succeeded after each pages job (jobs 17445109842, 17454402755, 17456305542); steward job logs name issue #6 (marker) and issue #7 (version) as opened, and "production ok" on 4f5abaf (countersign/steward.py).
  • Govern — steward logs show a decision on main before each merge ("!12: decided allow on main", "!13: decided allow on main").

Not claimed (not touched by a run yet):

  • Release — no tag and no Release exist (tags and releases API, both empty on 2026-10-26). The production deploy is the manual pages job a person presses.

Path A, Supervised. The rules define Supervised as "you approve the outcome, not the individual steps. Your agents execute a multi-step workflow on their own, clearing gates and making decisions along the way", with the example "Agents handle code review, … and deploy to staging. You get a summary and approve the production deployment." In this run the human steps are the intent at the start (issue #3 and its assignment to the author flow) and the production press at the end (human step: participant record; the logs do not show who acted; see Who did what). Measured between them: steward log lines for the review start and the merge, the gate job inside a steward run (job 17381381718), merge commit afbbdca of that exact SHA, and staging and the summary note on the issue from the main pipeline. A change that is not ordinary (for example to policy/ or countersign/) still needs a person's approval of its SHA; that path did not run on GitLab on 2026-10-22.

What is tested (local, the code of afbbdca, re-run on 2026-10-26)

  • python -m unittest discover -s tests: 177 tests, OK (tree of afbbdca, Python 3.14.4).
  • python tests/mutation_check.py: weakens the gate, collector, steward, CI jobs and gate checklist in many ways and checks whether the suite catches each. The run of tests/mutation_check.py on 2026-10-26 did not finish within the 9 minutes it was given (command output), so no result is stated here.
  • python tests/test_attack_matrix.py: 704 rows (attack family × input form, including 64 rows from an independent review) — 672 must-deny, 32 allow controls, plus 12 ledger tampering cells; 0 failed cells (tree of afbbdca).

Attacks covered include: review of an older SHA, short or uppercase SHAs, self-approval under a renamed username, bot/Duo/service-account approvals, a tier C action labelled B, look-alike action names, B actions aimed at the default branch in other spellings, chained shell commands, Windows path aliases for guarded files, malformed numbers, and forged or malformed verdict notes.

Honest limits

  • A custom flow cannot choose a model per prompt, so the reviewer is a different configuration (prompt, read-only tools, flow identity), not a different model.
  • On GitLab Free a merge request can edit .gitlab-ci.yml to drop a gate job; the stronger fix (a pipeline execution policy) needs Ultimate.
  • Agent tool calls inside a flow do not pass through the gate; inside GitLab, protected branches, the manual production job and tool governance are what stop an out-of-contract call.
  • One run is one run. On GitLab, the path for an ordinary change ran on !7 (2026-10-22), !12 and !13 (2026-10-26). Not yet measured there: a gate deny, a change that needs a person's approval, the gate flow (flows/gate.yml), the Chat agent (agents/), a tag and Release, the credits a flow run costs, and a fix loop that pushes a fix (the one fix start, on !10, ended without a push and stopped that merge request).
  • Rolling production back by re-running the pages job of an older pipeline does not work: Pages refused it with "build SHA is outdated for this ref" (pipeline 2985911159; issue #8's text). The way back is a revert or fix merge request on main and a new press.
  • By the participant record (the logs do not show who acted), the monitor's anomaly of 2026-10-26 was staged (step 3 of the runs above).
  • The project merges semi-linearly. When another merge request reaches main first, an ordinary one the steward has decided on needs a rebase, GitLab refuses the merge, and the steward stops on that head for a person (!16, 2026-10-26). The steward does not rebase.
  • The steward's merges appear under the entrant's own account (merge request API, merged_by), because its token is the entrant's personal token; on GitLab they look like the entrant's actions, and its notes are written with the same token.

Demo video

https://youtu.be/38ATVAsCps4 — script: demo-script.md

The demo video records a local, read-only viewer made for this video, not part of Countersign and not in this repository, which reads the project's public GitLab data (REST API, raw job logs and the public discussions.json of !7 and #3) with no sign-in and no token; it was recorded with a Playwright script (record.py) kept with the video build.

What's next

A fix loop that pushes a fix and merges after a new review; on GitLab, a change that needs a person's approval, decided by the steward on main; the gate flow; a tag and Release; and the credits each flow run costs.

Built With

  • gitlab
  • gitlab-ci-cd
  • gitlab-deployments-api
  • gitlab-duo-agent-platform
  • gitlab-package-registry
  • gitlab-pages
  • gitlab-sast
  • gitlab-secret-detection
  • python
Share this project:

Updates

Submission history