AI agents · Formal verification · Developer tools
Invariant
You decide what must be true. It proves the code.
A code factory that turns GitHub issues into merged pull requests, with code proved against statements a person ratified on the issue.
The factory’s first real issue, replayed from what GitHub recorded, in UTC on September 25, 2026. Open any step to read what was said.
- Invariant reads the issue and asks 42s
- Joseph decides 3m 28s
- Invariant proposes and checks with TLC 1m 15s
- Joseph reviews and ratifies 6m 50s
- Invariant writes and proves the code 2m 5s
- CI’s gate, then the merge 11m 56s
+0sJoseph opened issue #1Add a bounded buffer for log shipping
“Add a bounded buffer for log shipping”, opened by Joseph as @gitdek.
+42sInvariant asked two questions instead of guessing42 seconds after the issue opened
◉ Invariant
Before I write down what must be true for this issue, I need you to decide:
F1. When a producer writes a line and the buffer is already at capacity, what should happen?
F2. When the shipper fails to send a line, what should happen to that line?
+4m 10sJoseph decidedF1 A and F2 A
Joseph
/invariant choose F1 A /invariant choose F2 A
+5m 25sInvariant proposed 13 statements6 invariants, 3 witnesses and 3 known bugs, already checked by TLC
◉ Invariant
Here's what I propose must always be true for bounded log shipping buffer. Once you ratify it, these statements are pinned by hash, and I can't change them.
The spec
SpecThe system starts in Init, and every step is a Next step.
Invariants: what must never go wrong
TypeOKThe buffer, the shipped lines and the write history are lists of log lines, each producer has written a bounded number of lines, and the shipper is either retrying or not.BoundedCapacityThe buffer never holds more lines than its capacity.NoLineLostEvery line a producer has written is either still in the buffer or has been shipped.ShippedOnceNo line is shipped more than once.ShippedInOrderThe shipped lines are exactly the oldest written lines, in the order they were written.BufferInOrderThe buffer holds exactly the written lines not yet shipped, oldest first.
Witnesses: outcomes that must be reachable
BufferFullThe buffer can fill up to its capacity.RetryPendingA send can fail and leave its line in the buffer to be retried.AllShippedEvery producer can finish writing and every line can be shipped.
Known bugs: mistakes the invariants must catch
WriteOverCapacityA producer adds a line to a buffer that is already full. It must breakBoundedCapacity.DropOnFailureWhen a send fails, the line is thrown away instead of retried. It must breakNoLineLost.ResendDeliveredA send that actually went through is treated as failed and retried, so the line is shipped twice. It must breakShippedOnce.
Already checked against a draft model, within
Capacity = 2,MaxLines = 2,Producers = {p1, p2}: TLC explored 87 states and found no violation and no deadlock, all 3 witnesses were reached, and the known bugsWriteOverCapacity,DropOnFailure,ResendDeliveredwere caught.The statements in TLA+
vars == <<buf, sent, log, written, retrying>> Lines == Producers \X (1..MaxLines) Range(s) == {s[i] : i \in DOMAIN s} TypeOK == /\ buf \in Seq(Lines) /\ sent \in Seq(Lines) /\ log \in Seq(Lines) /\ written \in [Producers -> 0..MaxLines] /\ retrying \in BOOLEAN BoundedCapacity == Len(buf) <= Capacity NoLineLost == \A i \in 1..Len(log) : log[i] \in Range(sent) \cup Range(buf) ShippedOnce == \A i, j \in 1..Len(sent) : i # j => sent[i] # sent[j] ShippedInOrder == /\ Len(sent) <= Len(log) /\ sent = SubSeq(log, 1, Len(sent)) BufferInOrder == /\ Len(sent) <= Len(log) /\ buf = SubSeq(log, Len(sent) + 1, Len(log)) BufferFull == Len(buf) = Capacity RetryPending == retrying /\ buf # <<>> AllShipped == /\ \A p \in Producers : written[p] = MaxLines /\ buf = <<>> /\ Len(sent) = Len(log) /\ Len(log) > 0 WriteOverCapacity == \E p \in Producers : /\ written[p] < MaxLines /\ Len(buf) = Capacity /\ LET line == <<p, written[p] + 1>> IN /\ buf' = Append(buf, line) /\ log' = Append(log, line) /\ written' = [written EXCEPT ![p] = @ + 1] /\ UNCHANGED <<sent, retrying>> DropOnFailure == /\ buf # <<>> /\ buf' = Tail(buf) /\ retrying' = FALSE /\ UNCHANGED <<sent, log, written>> ResendDelivered == /\ buf # <<>> /\ sent' = sent \o <<Head(buf), Head(buf)>> /\ buf' = Tail(buf) /\ retrying' = FALSE /\ UNCHANGED <<log, written>> Spec == Init /\ [][Next]_vars+12m 15sJoseph ratifiedproposal e5dca8da9f15
Joseph
/invariant ratify e5dca8da9f15
◉ Invariant
Ratified by @gitdek. I pinned 13 statements in
examples/03-log-bufferon branchinvariant/issue-1-log-buffer(proposalsha256:e5dca8da9f15). Writing the code now.From here the 13 statements are pinned by hash. The factory can’t change them, and CI checks that a person with write access ratified exactly this proposal.
+14m 20sInvariant opened #2: provedGobra: 5 of 5 functions · the code reaches all 87 states
◉ Invariant
The code is written, and it passed the gate here: it's proved. I opened #2, and I'll merge it once CI's
invariant/gatepasses on it.An AI coding agent wrote the code in 8 turns, confined to its workspace, and the gate passed on run 1 of 1.
+26m 16sInvariant merged #2 once CI’s gate passedas 3ce3ba3
◉ Invariant
CI's
invariant/gatepassed on #2, and I merged it as 3ce3ba3a5ca07acd2bb422902c945fb43a80de59.The pull request closed issue #1 at 18:25:02.
People decide, the factory proves
AI coding agents don’t ask clarifying questions. They pick an interpretation and ship it confidently. A proof alone doesn’t fix that: if the agent also wrote the property, the proof only certifies the agent’s guess.
Invariant splits the work. People decide what must be true, on the issue, and the factory proves that the code satisfies it.
- Formalize. An agent reads the issue and drafts formal statements in TLA+: invariants, what must never go wrong; witnesses, outcomes that must be reachable; and known bugs, plausible mistakes the invariants must catch.
- Ask. When the issue allows materially different behaviors, the factory doesn’t pick. It posts the question on the issue and waits.
- Ratify. The factory checks its draft with the TLC model checker before anyone sees it. A person with write access ratifies it with a comment, and the statements are pinned by hash.
- Synthesize. An agent writes the code against the ratified statements, confined to its workspace. The gate checks it, and the agent gets up to three repairs.
- Gate and merge. CI re-runs every check. The factory merges its own pull request only when the gate passes on that exact commit.
A planted bug, caught
A model can pass its checks because nothing ever happens in it. So every proposal also carries witnesses, outcomes that must be reachable, and known bugs: plausible mistakes planted in the model, each of which TLC must catch. That’s how you know the invariants have teeth.
These are the real counterexamples TLC found. Step through the early-commit bug in two-phase commit: 6 states, from every resource manager working to a violation of TCConsistent.
Planted bug EarlyCommit The coordinator commits once any resource manager has prepared, instead of waiting for all of them.
Step 1 of 6
All three resource managers are working, and the coordinator hasn’t decided.
- Coordinator
tmState - init
- Resource managers
rmState - r1 workingr2 workingr3 working
- Messages
msgs - none
- Votes recorded
tmPrepared - none
msgs = {} rmState = (r1 :> "working" @@ r2 :> "working" @@ r3 :> "working") tmPrepared = {} tmState = "init"- Coordinator
Step 2 of 6 ·
RMPreparer1 prepares, and its Prepared message goes out.
- Coordinator
tmState - init
- Resource managers
rmStatechanged - r1 preparedr2 workingr3 working
- Messages
msgschanged - Prepared r1
- Votes recorded
tmPrepared - none
msgs = {[type |-> "Prepared", rm |-> r1]} rmState = (r1 :> "prepared" @@ r2 :> "working" @@ r3 :> "working") tmPrepared = {} tmState = "init"- Coordinator
Step 3 of 6 ·
TMRcvPreparedThe coordinator records r1’s vote.
- Coordinator
tmState - init
- Resource managers
rmState - r1 preparedr2 workingr3 working
- Messages
msgs - Prepared r1
- Votes recorded
tmPreparedchanged - r1
msgs = {[type |-> "Prepared", rm |-> r1]} rmState = (r1 :> "prepared" @@ r2 :> "working" @@ r3 :> "working") tmPrepared = {r1} tmState = "init"- Coordinator
Step 4 of 6 ·
RMChooseToAbortr2 aborts on its own.
- Coordinator
tmState - init
- Resource managers
rmStatechanged - r1 preparedr2 abortedr3 working
- Messages
msgs - Prepared r1
- Votes recorded
tmPrepared - r1
msgs = {[type |-> "Prepared", rm |-> r1]} rmState = (r1 :> "prepared" @@ r2 :> "aborted" @@ r3 :> "working") tmPrepared = {r1} tmState = "init"- Coordinator
Step 5 of 6 ·
EarlyCommitThe buggy coordinator commits on r1’s vote alone.
- Coordinator
tmStatechanged - done
- Resource managers
rmState - r1 preparedr2 abortedr3 working
- Messages
msgschanged - CommitPrepared r1
- Votes recorded
tmPrepared - r1
msgs = {[type |-> "Commit"], [type |-> "Prepared", rm |-> r1]} rmState = (r1 :> "prepared" @@ r2 :> "aborted" @@ r3 :> "working") tmPrepared = {r1} tmState = "done"- Coordinator
Step 6 of 6 ·
RMRcvCommitMsgr1 receives the Commit message and commits.
- Coordinator
tmState - done
- Resource managers
rmStatechanged - r1 committedr2 abortedr3 working
- Messages
msgs - CommitPrepared r1
- Votes recorded
tmPrepared - r1
msgs = {[type |-> "Commit"], [type |-> "Prepared", rm |-> r1]} rmState = (r1 :> "committed" @@ r2 :> "aborted" @@ r3 :> "working") tmPrepared = {r1} tmState = "done"TCConsistentviolated: r1 has committed while r2 has aborted. The invariant says: No resource manager commits while another aborts.- Coordinator
Planted bug WriteOverCapacity A producer adds a line to a buffer that is already full.
Step 1 of 4
The buffer is empty.
- Buffer
buf - emptycapacity 2
- Written
log - none
- Shipped
sent - none
- Lines per producer
written - p1 0p2 0
- Retrying
retrying - no
buf = <<>> log = <<>> retrying = FALSE sent = <<>> written = (p1 :> 0 @@ p2 :> 0)
- Buffer
Step 2 of 4 ·
Writep1 writes its first line.
- Buffer
bufchanged - p1·1capacity 2
- Written
logchanged - p1·1
- Shipped
sent - none
- Lines per producer
writtenchanged - p1 1p2 0
- Retrying
retrying - no
buf = <<<<p1, 1>>>> log = <<<<p1, 1>>>> retrying = FALSE sent = <<>> written = (p1 :> 1 @@ p2 :> 0)
- Buffer
Step 3 of 4 ·
Writep1 writes its second line, and the buffer is full.
- Buffer
bufchanged - p1·1p1·2capacity 2
- Written
logchanged - p1·1p1·2
- Shipped
sent - none
- Lines per producer
writtenchanged - p1 2p2 0
- Retrying
retrying - no
buf = <<<<p1, 1>>, <<p1, 2>>>> log = <<<<p1, 1>>, <<p1, 2>>>> retrying = FALSE sent = <<>> written = (p1 :> 2 @@ p2 :> 0)
- Buffer
Step 4 of 4 ·
WriteOverCapacityThe buggy producer p2 writes anyway.
- Buffer
bufchanged - p1·1p1·2p2·1capacity 2
- Written
logchanged - p1·1p1·2p2·1
- Shipped
sent - none
- Lines per producer
writtenchanged - p1 2p2 1
- Retrying
retrying - no
buf = <<<<p1, 1>>, <<p1, 2>>, <<p2, 1>>>> log = <<<<p1, 1>>, <<p1, 2>>, <<p2, 1>>>> retrying = FALSE sent = <<>> written = (p1 :> 2 @@ p2 :> 1)
BoundedCapacityviolated: the buffer holds 3 lines, and its capacity is 2. The invariant says: The buffer never holds more lines than its capacity.- Buffer
Planted bug DropOnFailure When a send fails, the line is thrown away instead of retried.
Step 1 of 3
The buffer is empty.
- Buffer
buf - emptycapacity 2
- Written
log - none
- Shipped
sent - none
- Lines per producer
written - p1 0p2 0
- Retrying
retrying - no
buf = <<>> log = <<>> retrying = FALSE sent = <<>> written = (p1 :> 0 @@ p2 :> 0)
- Buffer
Step 2 of 3 ·
Writep1 writes a line.
- Buffer
bufchanged - p1·1capacity 2
- Written
logchanged - p1·1
- Shipped
sent - none
- Lines per producer
writtenchanged - p1 1p2 0
- Retrying
retrying - no
buf = <<<<p1, 1>>>> log = <<<<p1, 1>>>> retrying = FALSE sent = <<>> written = (p1 :> 1 @@ p2 :> 0)
- Buffer
Step 3 of 3 ·
DropOnFailureA send fails, and the buggy shipper throws the line away.
- Buffer
bufchanged - emptycapacity 2
- Written
log - p1·1
- Shipped
sent - none
- Lines per producer
written - p1 1p2 0
- Retrying
retrying - no
buf = <<>> log = <<<<p1, 1>>>> retrying = FALSE sent = <<>> written = (p1 :> 1 @@ p2 :> 0)
NoLineLostviolated: line p1·1 was written, but it is neither in the buffer nor shipped. The invariant says: Every line a producer has written is either still in the buffer or has been shipped.- Buffer
Planted bug ResendDelivered A send that actually went through is treated as failed and retried, so the line is shipped twice.
Step 1 of 3
The buffer is empty.
- Buffer
buf - emptycapacity 2
- Written
log - none
- Shipped
sent - none
- Lines per producer
written - p1 0p2 0
- Retrying
retrying - no
buf = <<>> log = <<>> retrying = FALSE sent = <<>> written = (p1 :> 0 @@ p2 :> 0)
- Buffer
Step 2 of 3 ·
Writep1 writes a line.
- Buffer
bufchanged - p1·1capacity 2
- Written
logchanged - p1·1
- Shipped
sent - none
- Lines per producer
writtenchanged - p1 1p2 0
- Retrying
retrying - no
buf = <<<<p1, 1>>>> log = <<<<p1, 1>>>> retrying = FALSE sent = <<>> written = (p1 :> 1 @@ p2 :> 0)
- Buffer
Step 3 of 3 ·
ResendDeliveredThe send goes through, but the buggy shipper treats it as failed and sends it again.
- Buffer
bufchanged - emptycapacity 2
- Written
log - p1·1
- Shipped
sentchanged - p1·1p1·1
- Lines per producer
written - p1 1p2 0
- Retrying
retrying - no
buf = <<>> log = <<<<p1, 1>>>> retrying = FALSE sent = <<<<p1, 1>>, <<p1, 1>>>> written = (p1 :> 1 @@ p2 :> 0)
ShippedOnceviolated: line p1·1 was shipped twice. The invariant says: No line is shipped more than once.- Buffer
One contract per action
Each Go function carries a Gobra contract that restates exactly one TLA+ action. The action’s enabling condition becomes the contract’s precondition, and its effect, including everything it leaves unchanged, becomes the postconditions.
In two-phase commit, Gobra verified all 10 functions against their contracts, with integer overflow checked, and the Go code reaches exactly the model’s 288 states. Choose a clause to see its match.
TwoPhase.tla, lines 72 to 76RMPrepare(r) == /\ rmState[r] = "working" /\ rmState' = [rmState EXCEPT ![r] = "prepared"] /\ msgs' = msgs \cup {[type |-> "Prepared", rm |-> r]} /\ UNCHANGED <<tmState, tmPrepared>>twophase.go, lines 96 to 109// RMPrepare mirrors RMPrepare(r): a working resource manager prepares and// tells the transaction manager.// @ requires 0 <= r && r < N// @ requires s.RM[r] == Working// @ ensures t.RM[r] == Prepared && t.PreparedMsg[r]// @ ensures forall i int :: 0 <= i && i < N && i != r ==> t.RM[i] == s.RM[i] && t.PreparedMsg[i] == s.PreparedMsg[i]// @ ensures t.TM == s.TM && t.TMPrepared == s.TMPrepared// @ ensures t.CommitMsg == s.CommitMsg && t.AbortMsg == s.AbortMsgfunc RMPrepare(s State, r int) (t State) { t = s t.RM[r] = Prepared t.PreparedMsg[r] = true return t}Choose a clause, in either language, to see its match.
The action takes one resource manager, r. The contract requires r to be one of the resource managers.
The action can only happen while r is working. The TLA+ guard is the contract’s requires.
r becomes prepared and its Prepared message goes out. The effect is the first ensures.
UNCHANGED keeps the coordinator’s state and its recorded votes. The frame lines in the contract also spell out what EXCEPT and the set union leave alone: the other resource managers and messages.
go run ./cmd/invariant verify -out out/02-twophase-commit examples/02-twophase-commit
Pinned statements ✓ 6 of 6 match · recorded in D-0027
Design · TLC ✓ no violations, no deadlock · 288 distinct states (1,146 generated), depth 11
Reachability ✓ 2 of 2 witnesses reached · AllCommitted in 10 steps, AllAborted in 3 steps
Known bugs ✓ 1 of 1 caught · early-commit: TCConsistent violated after 5 steps
Agreement ✓ code reaches the model's states · 288 states, depth 11
Code · Gobra ✓ proved: 10 of 10 functions verified · 10 with contracts, overflow checked, not verified: Successors
Build ✓ go vet, go test · sandboxed, no network
Checked within RM = {r1, r2, r3}. Within these bounds TLC’s search is exhaustive. Nothing is claimed outside them.
✓ Pass. Every check passed. The code is proved. Fingerprint sha256:2a0189e69e1f
The receipt on the pull request
The factory opened #2 once the code passed the gate on Joseph’s machine, and merged it only after CI’s invariant/gate passed on that exact commit.
In CI, the gate also checks that a factory pull request touches only its own project, and confirms on GitHub that a person with write access ratified exactly the proposal in the lock.
Merged Add a bounded buffer for log shipping #2
invariant/gate · proved · 87 states, exhaustive within bounds · merged by the factory
◉ Invariant receipt · bounded log shipping buffer
✓ Pass. Every check passed. The code is proved.
| Check | Result | Evidence |
|---|---|---|
| Pinned statements | ✓ 13 of 13 match | ratified by @gitdek on #1 at 18:10:59 |
| Design · TLC | ✓ no violations, no deadlock | 87 distinct states (159 generated), depth 9 |
| Reachability | ✓ 3 of 3 witnesses reached | BufferFull in 2 steps, RetryPending in 2 steps, AllShipped in 8 steps |
| Known bugs | ✓ 3 of 3 caught | write-over-capacity: BoundedCapacity violated after 3 steps; drop-on-failure: NoLineLost violated after 2 steps; resend-delivered: ShippedOnce violated after 2 steps |
| Agreement | ✓ code reaches the model’s states | 87 states, depth 9 |
| Code · Gobra | ✓ proved: 5 of 5 functions verified | 5 with contracts, overflow checked, not verified: Successors |
| Build | ✓ go vet, go test | sandboxed, no network · go test found no test files |
Checked within Capacity = 2, MaxLines = 2, Producers = {p1, p2}. Within these bounds TLC’s search is exhaustive. Nothing is claimed outside them.
Fingerprint sha256:dfaf31c67a3f78c8909ce205f89823204aa20237a2b2b69553cba0817064d31f
The same fingerprint came from the run that opened #2, at 18:13:02, and from a later re-run at 18:46:09.
What it doesn’t do yet
Invariant checks safety, not liveness: that nothing bad happens, not that something good eventually does. Model checking is exhaustive only within the bounds each receipt states, and nothing is claimed outside them.
The log buffer’s model assumes the shipper knows whether a send succeeded. The factory builds new projects only, and writes Go only. Existing TypeScript and Python code can be tested against a model instead of proved, and a receipt always says which kind of evidence it carries.
Current status
Invariant runs on Joseph’s machine: invariant watch polls GitHub through the gh command-line tool and hands the writing to AI coding agents. Invariant itself is written in Go.
The repository is private for now, so issue #1 and pull request #2 are named here without links.