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:
- The reviewer read a different version. A review passes, one more commit lands, and the merge takes the unreviewed head.
- The author signs its own work. The same agent, or the person who asked it, writes, approves and merges.
- "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/ordocs/of a listed type,docs/DESIGN.mdsection 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 onmain, right before it merges: it logs "decided allow on main" and merges, or "decided deny on main" and stops. In a merge request pipeline thecountersign-gatejob runs by itself and only checks the hash chain of the ledger thetestjob 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. Thedeploy-stagingjob 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; thecountersign-gatejob writes no decision. - The
testjob as written records two commands (unittest discover,mutation_check.py) with their exit codes and the commit SHA in the ledger. Thetestjob succeeded (jobs API) in the merge request pipeline of !7 and in themainpipeline 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 (anagent/<topic>branch, thestagingenvironment, 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 setsauthor_may_approvetotrueinpolicy/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 indocs/DESIGN.md, Known limits. - An evidence ledger (
countersign/ledger.py). JSON Lines, each record holding the hash of the previous one.verify-ledgercatches 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.
- 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). - Merge request !7
(
agent/how-a-change-ships→main, head04843e1, 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: thetestandpackagejobs of merge request pipeline 2976416894 ran under it, and the jobs API reports it withbot: true(account 43436308 readsbot: falsein 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 commit04843e1(merge request commits API) equals its name field (jobs API,user). - Merge request pipeline
-/pipelines/2976416894:testandpackagesucceeded;countersign-gate(job 17381381718) is manual in that version's.gitlab-ci.yml. - 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). - 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). - Scheduled pipeline
-/pipelines/2976591959(steward job 17382733439, 12:52:04–12:52:53, at commit6f7ef28b, 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 commitafbbdca. The steward code at6f7ef28bis written to play the gate job (Who did what). Issue #3 closed through "Closes #3" at 12:53:06. mainpipeline-/pipelines/2976595615atafbbdca:test,package,upload-package,secret_detectionandsemgrep-sastsucceeded; so diddeploy-staging(-/jobs/17382762533).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 thepagesjob 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).- Production:
pages(-/jobs/17382762534) is a manual job a person presses;smoke-production(-/jobs/17382762536) waits for it. Thispagesjob never ran (stillmanualon 2026-10-26, jobs API); apagesjob 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).
- 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 onmainbefore every merge, runs the fix loop (off since !15, step 8) and the monitor; not an ordinary change). In themainpipeline-/pipelines/2985911159atc248886pagesran (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. Thatpagesjob is the first production deploy in the jobs API. !9 (08:27:07) putMONITOR_URLonmain; 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). - 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/2986218992failed intest. 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. - 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,
authorandassignees) (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) → verdictpass sha=3e4c63e7→ "!12: decided allow on main" and "!12: merged at 3e4c63e…" (steward job 17454399139, 01:20:21). In themainpipeline-/pipelines/2987554919at0ebc5a1pagesran (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 onlyversion.txt. Steward job 17454948199 logged at 02:09:19 the page without its marker: "monitor: marker at 0ebc5a1…; issue #6 opened". - Rolling back by re-running an older
pagesjob does not work.pagesof 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 putc248886back. GitLab recorded production deployment 1641068329 atc248886, but the site'sversion.txtwas notc248886, 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: itspages: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 onmain, thenpagespressed in that pipeline. - 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) → verdictpass sha=ac2b37a1→ "!13: decided allow on main" and "!13: merged at ac2b37a…" (steward job 17456290275, 04:09:56). In themainpipeline-/pipelines/2987872897at4f5abafpagesran (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. - 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/andtests/, 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 inreviewers. The verdict note of !14 readspass sha=2b1e266f; the merge request API records !14 merged under the entrant's account (06:38:21, merge commit6328004) and an empty approvals list. - 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 reachedmain, 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. - The fix loop is off on
mainsince 07:24:00: !15 was merged from the entrant's account (merge commit035d43e), which setsFIX_LOOP = Falseincountersign/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 inflows/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-shipswith commit04843e1(flows/author.yml). - Verify —
testsucceeded 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 —
packageandupload-packagesucceeded in pipeline 2976595615 (.gitlab-ci.yml). - Secure —
semgrep-sastandsecret_detectionsucceeded in pipeline 2976595615 (GitLab's SAST and Secret Detection templates, included in.gitlab-ci.yml). - Configure —
deploy-staging(job 17382762533) deployed to thestagingenvironment (.gitlab-ci.yml). - Govern —
countersign-gate(job 17381381718) appliedpolicy/tiers.jsonto head04843e1and allowed it with no human approval, because every changed file is ordinary andmerge_needs_humanisfalse(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-productionsucceeded after eachpagesjob (jobs 17445109842, 17454402755, 17456305542); steward job logs name issue #6 (marker) and issue #7 (version) as opened, and "production ok" on4f5abaf(countersign/steward.py). - Govern — steward logs show a decision on
mainbefore 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
pagesjob 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 ofafbbdca, 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 oftests/mutation_check.pyon 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 ofafbbdca).
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.ymlto 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
pagesjob 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 onmainand 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
mainfirst, 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