Deliverable: a self-contained Go 1.26 simulator (~/chainrep/main.go, stdlib only) that reads a deterministic NDJSON event script from stdin, models a head→…→tail replica chain, drains and reconfigures on chain extension, enforces leases for tail reads, and emits per-replica logs, acked/dangling writes, a linearizability verdict, and the offending write.
Deliverable: a self-contained Go 1.26 simulator (~/chainrep/main.go, stdlib only) that reads a deterministic NDJSON event script from stdin, models a head→…→tail replica chain, drains and reconfigures on chain extension, enforces leases for tail reads, and emits per-replica logs, acked/dangling writes, a linearizability verdict, and the offending write.
This is an implement-from-scratch problem, so the "root cause" is the set of failure modes that make a naive chain-replication implementation violate linearizability. There are three, and the third is the one that actually produces a wrong read.
R1 — Read path returns a suffix instead of a prefix.
A tail read must return a prefix of the global commit history that ends at the write last acknowledged before the read. A new/recovering replica that starts empty and is immediately promoted to tail returns a history beginning at the first write it saw — e.g. [w4] when the true history is [w1 w2 w3 w4]. Reads then observe a later write without its predecessors: not a prefix, not linearizable.
R2 — Chain extension truncates before draining.
If a reconfiguration truncates each log to a committed watermark while writes are still in flight, two things break:
- acknowledged writes can be dropped (data loss / reads miss an acked write), and
- if the watermark is read from the new, empty replica it is 0, so the whole committed history is wiped.
The correct order is drain → freeze/truncate → attach/backfill → move lease. Everything that can still reach the old tail must commit first; only genuinely unreachable (dead-link) writes are discarded, and those are exactly the un-acknowledged "dangling" writes.
R3 — Lease is not bound to the tail role and ignores clock skew.
The lease must gate reads: a tail serves a read only when localClock ≤ leaseUntil, where localClock = now + skew. On reconfiguration the lease must move to the new tail and be revoked from the old one; otherwise the demoted replica keeps answering reads. Clock skew is deliberately applied to the local clock so a skewed replica can reject early (positive skew) or serve late (negative skew).
The exact fix is the implementation below; the injected negative control in §4 flips only R1 (attach the new tail without back-filling) and the checker correctly names w1 as the write that broke the invariant.
Events must appear in non-decreasing t order; t is a logical timestamp, not wall-clock.
| Event | Meaning |
|---|---|
{"t":0,"type":"config","nodes":["n1","n2"]} |
install initial chain (head first, tail last) |
{"t":1,"type":"write","id":"w1"} |
client write, enters at head |
{"t":2,"type":"read","id":"r1"} |
client read, served at tail |
{"t":3,"type":"node","node":"n2","action":"pause\|resume\|kill\|restart"} |
node lifecycle |
{"t":4,"type":"lease","node":"n2","action":"grant","duration":10} |
grant tail lease |
{"t":4,"type":"lease","node":"n2","action":"expire"} |
force expiry |
{"t":5,"type":"skew","node":"n2","skew":-2} |
add clock skew to a node |
{"t":6,"type":"extend","node":"n3"} |
extend chain with a new tail |
Writes are acknowledged only when they reach the tail (commit). Writes that never reach a tail are dangling.
Save as main.go (module chainrep, stdlib only):
// chainrep is a deterministic, event-scripted simulator for chain replication
// with a lease-protected tail read path and online chain extension.
//
// It reads newline-delimited JSON events from stdin (or a file given with -in),
// runs the modelled protocol deterministically, and prints:
// - the final per-replica logs,
// - the writes the cluster acknowledged (acked) vs. writes that never
// committed (dangling),
// - a linearizability verdict for the observed read/write history,
// - the write that first broke the invariant, if any.
//
// Standard library only.
package main
import (
"bufio"
"encoding/json"
"flag"
"fmt"
"io"
"os"
)
// Event is one line of the scripted input stream.
//
// Supported types:
//
// {"t":0,"type":"config","nodes":["n1","n2"]} install initial chain
// {"t":1,"type":"write","id":"w1"} client write at head
// {"t":2,"type":"read","id":"r1"} client read at tail
// {"t":3,"type":"node","node":"n2","action":"pause"} pause/resume/kill/restart
// {"t":4,"type":"lease","node":"n2","action":"grant","duration":10}
// {"t":4,"type":"lease","node":"n2","action":"expire"}
// {"t":5,"type":"skew","node":"n2","skew":-2} add clock skew to a node
// {"t":6,"type":"extend","node":"n3"} extend the chain
//
// t is a logical timestamp; events must appear in non-decreasing t order.
type Event struct {
T int `json:"t"`
Type string `json:"type"`
Node string `json:"node"`
Action string `json:"action"`
ID string `json:"id"`
Nodes []string `json:"nodes"`
Duration int `json:"duration"`
Skew int `json:"skew"`
}
type Node struct {
ID string
Log []string
Alive bool
Paused bool
LeaseUntil int // logical time at which the lease expires (exclusive)
Skew int // local clock = global time + Skew
}
func (n *Node) serveable() bool { return n.Alive && !n.Paused }
type ReadRec struct {
ID string
Seq int
Result []string
Rejected bool
Reason string
}
type Sim struct {
nodes []*Node
byID map[string]*Node
inFlight [][]string // inFlight[i]: writes appended at nodes[i], waiting for nodes[i+1]
commitOrder []string // acknowledged writes, in commit order
ackSeq map[string]int
issued []string
dangling map[string]bool
reads []ReadRec
seq int
now int
injectExtendBug bool // negative control only
}
func NewSim(injectExtendBug bool) *Sim {
return &Sim{
byID: map[string]*Node{},
ackSeq: map[string]int{},
dangling: map[string]bool{},
injectExtendBug: injectExtendBug,
}
}
func (s *Sim) commit(id string) {
if _, done := s.ackSeq[id]; done {
return
}
s.ackSeq[id] = s.seq
s.commitOrder = append(s.commitOrder, id)
delete(s.dangling, id)
}
// deliver moves every deliverable in-flight write one or more hops downstream.
// It loops to a fixed point because a write can traverse the whole chain.
func (s *Sim) deliver() {
for {
changed := false
n := len(s.nodes)
for i := 0; i+1 < n; i++ {
next := s.nodes[i+1]
if !next.serveable() {
continue
}
for len(s.inFlight[i]) > 0 {
id := s.inFlight[i][0]
s.inFlight[i] = s.inFlight[i][1:]
next.Log = append(next.Log, id)
if i+1 == n-1 { // reached the tail: commit + ack
s.commit(id)
} else {
s.inFlight[i+1] = append(s.inFlight[i+1], id)
}
changed = true
}
}
if !changed {
return
}
}
}
func (s *Sim) Write(id string) {
s.seq++
s.issued = append(s.issued, id)
s.dangling[id] = true // cleared if/when it commits
if len(s.nodes) == 0 {
return
}
head := s.nodes[0]
if !head.serveable() {
return
}
head.Log = append(head.Log, id)
if len(s.nodes) == 1 {
s.commit(id)
return
}
s.inFlight[0] = append(s.inFlight[0], id)
s.deliver()
}
func (s *Sim) Read(id string) {
s.seq++
if len(s.nodes) == 0 {
s.reads = append(s.reads, ReadRec{ID: id, Seq: s.seq, Rejected: true, Reason: "no chain"})
return
}
tail := s.nodes[len(s.nodes)-1]
if !tail.serveable() {
s.reads = append(s.reads, ReadRec{ID: id, Seq: s.seq, Rejected: true, Reason: "tail unavailable"})
return
}
// Lease check uses the tail's local clock (which may be skewed).
local := s.now + tail.Skew
if local > tail.LeaseUntil {
s.reads = append(s.reads, ReadRec{ID: id, Seq: s.seq, Rejected: true, Reason: "lease expired"})
return
}
s.reads = append(s.reads, ReadRec{ID: id, Seq: s.seq, Result: append([]string(nil), tail.Log...)})
}
// truncateToCommitted drops every log entry that is not part of the globally
// committed prefix, and re-orders the surviving entries to match commit order.
func (s *Sim) truncateToCommitted(n *Node) {
committed := map[string]bool{}
for _, id := range s.commitOrder {
committed[id] = true
}
kept := map[string]bool{}
out := make([]string, 0, len(n.Log))
for _, id := range n.Log {
if committed[id] && !kept[id] {
out = append(out, id)
kept[id] = true
}
}
n.Log = out
}
func (s *Sim) SetNode(id, action string) {
n := s.byID[id]
if n == nil {
return
}
switch action {
case "pause":
n.Paused = true
case "resume":
n.Paused = false
case "kill":
n.Alive = false
n.Paused = false
case "restart":
n.Alive = true
n.Paused = false
// Recover by truncating to the committed prefix and syncing it.
n.Log = append([]string(nil), s.commitOrder...)
}
s.deliver()
}
func (s *Sim) Lease(id, action string, duration int) {
n := s.byID[id]
if n == nil {
return
}
switch action {
case "grant":
n.LeaseUntil = s.now + duration
case "expire":
n.LeaseUntil = s.now - 1
}
}
func (s *Sim) Skew(id string, skew int) {
if n := s.byID[id]; n != nil {
n.Skew = skew
}
}
// Extend appends a new replica to the tail of the chain.
//
// Correct order of operations:
// 1. Drain: push every in-flight write that can still reach the old tail, so
// that anything already ackable becomes committed before the topology
// changes (paused nodes are quiesced for the drain; dead nodes are not).
// 2. Freeze + truncate: remove every non-committed suffix from every replica,
// so the surviving logs are exactly the committed prefix.
// 3. Attach the new replica and back-fill it with the committed prefix before
// it may serve as tail.
// 4. Rebuild the links and hand the tail lease to the new tail (the old
// tail's lease is revoked so it rejects reads).
func (s *Sim) Extend(newID string) {
// 1. Drain. A config change quiesces the chain: pending writes are pushed
// through even nodes that are momentarily paused, so anything that can still
// reach the old tail is committed (and therefore acknowledged) before the
// topology changes. Writes blocked by a dead node cannot be drained and are
// dropped below (they stay dangling).
savedPaused := make(map[*Node]bool, len(s.nodes))
for _, n := range s.nodes {
savedPaused[n] = n.Paused
n.Paused = false
}
s.deliver()
for _, n := range s.nodes {
n.Paused = savedPaused[n]
}
// Anything still in flight is unreachable (dead downstream node); it was not
// acknowledged and must not survive the reconfiguration.
s.inFlight = make([][]string, max(0, len(s.nodes)-1))
// 2. Freeze + truncate.
for _, n := range s.nodes {
s.truncateToCommitted(n)
}
// 3. Attach and back-fill.
var tailLease int
if len(s.nodes) > 0 {
oldTail := s.nodes[len(s.nodes)-1]
tailLease = oldTail.LeaseUntil
oldTail.LeaseUntil = 0 // revoke: it is no longer the tail
}
nn := &Node{ID: newID, Alive: true}
if !s.injectExtendBug {
nn.Log = append([]string(nil), s.commitOrder...)
}
s.byID[newID] = nn
s.nodes = append(s.nodes, nn)
nn.LeaseUntil = tailLease
// 4. Rebuild links (empty after a successful drain).
s.inFlight = make([][]string, max(0, len(s.nodes)-1))
s.deliver()
}
// Check returns (ok, offendingWrite). A read is valid iff its result is exactly
// the committed prefix of writes acked strictly before the read was processed.
func (s *Sim) Check() (bool, string) {
for _, rd := range s.reads {
if rd.Rejected {
continue
}
// Number of writes acked before this read's processing slot.
k := 0
for _, id := range s.commitOrder {
if s.ackSeq[id] < rd.Seq {
k++
} else {
break
}
}
// The returned value must be a prefix of the global commit order; the
// first mismatch is the write that violates the invariant.
for i := range rd.Result {
if i >= len(s.commitOrder) {
return false, rd.Result[i]
}
if rd.Result[i] != s.commitOrder[i] {
return false, s.commitOrder[i]
}
}
// It must also contain exactly the writes acked before the read.
if len(rd.Result) < k {
return false, s.commitOrder[len(rd.Result)]
}
if len(rd.Result) > k {
if k < len(s.commitOrder) {
return false, s.commitOrder[k]
}
return false, rd.Result[k]
}
}
return true, ""
}
func (s *Sim) Run(r io.Reader) error {
sc := bufio.NewScanner(r)
sc.Buffer(make([]byte, 0, 64*1024), 16*1024*1024)
line := 0
for sc.Scan() {
line++
raw := sc.Bytes()
if len(raw) == 0 {
continue
}
var ev Event
if err := json.Unmarshal(raw, &ev); err != nil {
return fmt.Errorf("line %d: %w", line, err)
}
s.now = ev.T
switch ev.Type {
case "config":
for _, id := range ev.Nodes {
n := &Node{ID: id, Alive: true}
s.byID[id] = n
s.nodes = append(s.nodes, n)
}
s.inFlight = make([][]string, max(0, len(s.nodes)-1))
case "write":
s.Write(ev.ID)
case "read":
s.Read(ev.ID)
case "node":
s.SetNode(ev.Node, ev.Action)
case "lease":
s.Lease(ev.Node, ev.Action, ev.Duration)
case "skew":
s.Skew(ev.Node, ev.Skew)
case "extend":
s.Extend(ev.Node)
default:
return fmt.Errorf("line %d: unknown event type %q", line, ev.Type)
}
}
return sc.Err()
}
func max(a, b int) int {
if a > b {
return a
}
return b
}
func (s *Sim) Report(w io.Writer) {
fmt.Fprintln(w, "=== final replica logs (head -> tail) ===")
for i, n := range s.nodes {
role := "middle"
if i == 0 {
role = "head"
}
if i == len(s.nodes)-1 {
role = "tail"
}
fmt.Fprintf(w, "%s [%s]: %v\n", n.ID, role, n.Log)
}
fmt.Fprintln(w, "\n=== writes ===")
fmt.Fprintf(w, "acked: %v\n", s.commitOrder)
var dangling []string
for _, id := range s.issued {
if s.dangling[id] {
dangling = append(dangling, id)
}
}
fmt.Fprintf(w, "dangling: %v\n", dangling)
fmt.Fprintln(w, "\n=== reads ===")
for _, rd := range s.reads {
if rd.Rejected {
fmt.Fprintf(w, "%s: rejected (%s)\n", rd.ID, rd.Reason)
} else {
fmt.Fprintf(w, "%s: %v\n", rd.ID, rd.Result)
}
}
fmt.Fprintln(w, "\n=== linearizability ===")
ok, bad := s.Check()
if ok {
fmt.Fprintln(w, "verdict: LINEARIZABLE")
fmt.Fprintln(w, "violating write: none")
} else {
fmt.Fprintln(w, "verdict: NOT LINEARIZABLE")
fmt.Fprintf(w, "violating write: %s\n", bad)
}
}
func main() {
in := flag.String("in", "-", "input file, or - for stdin")
inject := flag.Bool("inject-extend-bug", false, "negative control: attach new tail without back-filling committed prefix")
flag.Parse()
var r io.Reader = os.Stdin
if *in != "-" {
f, err := os.Open(*in)
if err != nil {
fmt.Fprintln(os.Stderr, err)
os.Exit(1)
}
defer f.Close()
r = f
}
s := NewSim(*inject)
if err := s.Run(r); err != nil {
fmt.Fprintln(os.Stderr, "error:", err)
os.Exit(1)
}
s.Report(os.Stdout)
}
commit, called when i+1 == n-1). Re-sending a write can never double-commit.deliver runs to a fixed point, so one write can traverse the whole chain synchronously and in order; per-link FIFO queues preserve write order.truncateToCommitted uses the one global commitOrder, never a per-replica local watermark — this is the fix for R2.Extend order is drain → truncate → backfill → move lease. Backfill for the new tail is copy(commitOrder), so its first read is a valid prefix.Extend revokes the old tail's lease (LeaseUntil = 0) and transfers it to the new tail. Read rejects when now + skew > leaseUntil.Check requires each served read to equal the prefix of commitOrder containing exactly the writes acked before that read's processing slot; the first mismatch is reported as the offending write.mkdir -p chainrep && cd chainrep
# (paste main.go and main_test.go from this document)
go mod init chainrep
gofmt -w main.go main_test.go
go vet ./...
go test -v ./...
Observed:
=== RUN TestHappyPathExtendAndLease
--- PASS: TestHappyPathExtendAndLease (0.00s)
=== RUN TestDanglingWriteIsDropped
--- PASS: TestDanglingWriteIsDropped (0.00s)
=== RUN TestInjectExtendBugCaught
--- PASS: TestInjectExtendBugCaught (0.00s)
PASS
ok chainrep 0.002s
main_test.go:
package main
import (
"strings"
"testing"
)
func run(t *testing.T, script string, inject bool) *Sim {
t.Helper()
s := NewSim(inject)
if err := s.Run(strings.NewReader(script)); err != nil {
t.Fatalf("run: %v", err)
}
return s
}
func eq(a, b []string) bool {
if len(a) != len(b) {
return false
}
for i := range a {
if a[i] != b[i] {
return false
}
}
return true
}
func TestHappyPathExtendAndLease(t *testing.T) {
script := `
{"t":0,"type":"config","nodes":["n1","n2"]}
{"t":1,"type":"lease","node":"n2","action":"grant","duration":100}
{"t":2,"type":"write","id":"w1"}
{"t":3,"type":"read","id":"r1"}
{"t":4,"type":"node","node":"n2","action":"pause"}
{"t":5,"type":"write","id":"w2"}
{"t":6,"type":"extend","node":"n3"}
{"t":7,"type":"read","id":"r2"}
{"t":8,"type":"node","node":"n2","action":"resume"}
{"t":9,"type":"write","id":"w3"}
{"t":10,"type":"read","id":"r3"}
{"t":11,"type":"skew","node":"n3","skew":1000}
{"t":12,"type":"read","id":"r4"}
`
s := run(t, script, false)
if !eq(s.commitOrder, []string{"w1", "w2", "w3"}) {
t.Fatalf("acked = %v", s.commitOrder)
}
if len(s.dangling) != 0 {
t.Fatalf("dangling = %v", s.dangling)
}
for _, n := range s.nodes {
if !eq(n.Log, s.commitOrder) {
t.Fatalf("node %s log %v != commit %v", n.ID, n.Log, s.commitOrder)
}
}
if s.reads[0].Rejected || !eq(s.reads[0].Result, []string{"w1"}) {
t.Fatalf("r1 = %+v", s.reads[0])
}
if s.reads[1].Rejected || !eq(s.reads[1].Result, []string{"w1", "w2"}) {
t.Fatalf("r2 = %+v", s.reads[1])
}
if s.reads[2].Rejected || !eq(s.reads[2].Result, []string{"w1", "w2", "w3"}) {
t.Fatalf("r3 = %+v", s.reads[2])
}
if !s.reads[3].Rejected {
t.Fatalf("r4 should be rejected after skewed lease, got %+v", s.reads[3])
}
if ok, bad := s.Check(); !ok {
t.Fatalf("expected linearizable, got NOT (offender %s)", bad)
}
}
func TestDanglingWriteIsDropped(t *testing.T) {
script := `
{"t":0,"type":"config","nodes":["n1","n2"]}
{"t":1,"type":"lease","node":"n2","action":"grant","duration":100}
{"t":2,"type":"write","id":"w1"}
{"t":3,"type":"node","node":"n2","action":"kill"}
{"t":4,"type":"write","id":"w2"}
{"t":5,"type":"extend","node":"n3"}
{"t":6,"type":"node","node":"n2","action":"restart"}
{"t":7,"type":"write","id":"w3"}
{"t":8,"type":"read","id":"r1"}
`
s := run(t, script, false)
if !eq(s.commitOrder, []string{"w1", "w3"}) {
t.Fatalf("acked = %v", s.commitOrder)
}
if !s.dangling["w2"] {
t.Fatalf("w2 should be dangling")
}
if s.dangling["w1"] || s.dangling["w3"] {
t.Fatalf("w1/w3 acked but marked dangling: %v", s.dangling)
}
if ok, bad := s.Check(); !ok {
t.Fatalf("expected linearizable, got NOT (offender %s)", bad)
}
}
// Negative control: if the replacement tail is attached without back-filling
// the committed prefix, a later read observes a truncated history and the
// checker must name the first lost acknowledged write.
func TestInjectExtendBugCaught(t *testing.T) {
script := `
{"t":0,"type":"config","nodes":["n1","n2"]}
{"t":1,"type":"lease","node":"n2","action":"grant","duration":100}
{"t":2,"type":"write","id":"w1"}
{"t":4,"type":"extend","node":"n3"}
{"t":5,"type":"write","id":"w2"}
{"t":6,"type":"read","id":"r1"}
`
s := run(t, script, true)
ok, bad := s.Check()
if ok {
t.Fatalf("expected injected bug to be detected")
}
if bad != "w1" {
t.Fatalf("expected offender w1, got %q", bad)
}
}
drain.ndjson:
{"t":0,"type":"config","nodes":["n1","n2"]}
{"t":1,"type":"lease","node":"n2","action":"grant","duration":100}
{"t":2,"type":"write","id":"w1"}
{"t":3,"type":"read","id":"r1"}
{"t":4,"type":"node","node":"n2","action":"pause"}
{"t":5,"type":"write","id":"w2"}
{"t":6,"type":"extend","node":"n3"}
{"t":7,"type":"read","id":"r2"}
{"t":8,"type":"node","node":"n2","action":"resume"}
{"t":9,"type":"write","id":"w3"}
{"t":10,"type":"read","id":"r3"}
go build -o chainrep .
./chainrep -in drain.ndjson
Observed — w2 is drained and committed by the extension (not lost, not dangling), n3 is back-filled, all logs identical:
=== final replica logs (head -> tail) ===
n1 [head]: [w1 w2 w3]
n2 [middle]: [w1 w2 w3]
n3 [tail]: [w1 w2 w3]
=== writes ===
acked: [w1 w2 w3]
dangling: []
=== reads ===
r1: [w1]
r2: [w1 w2]
r3: [w1 w2 w3]
=== linearizability ===
verdict: LINEARIZABLE
violating write: none
./chainrep -in happy.ndjson
./chainrep -in dangling.ndjson
Observed for happy.ndjson (lease expiry rejects reads; a positive skew of +1000 also makes the lease look expired; no write is lost or duplicated):
=== final replica logs (head -> tail) ===
n1 [head]: [w1 w2 w3 w4]
n2 [middle]: [w1 w2 w3 w4]
n3 [tail]: [w1 w2 w3 w4]
=== writes ===
acked: [w1 w2 w3 w4]
dangling: []
=== reads ===
r1: [w1]
r2: rejected (lease expired)
r3: [w1 w2]
r4: [w1 w2 w3]
r5: [w1 w2 w3]
r6: [w1 w2 w3 w4]
r7: rejected (lease expired)
r8: [w1 w2 w3 w4]
=== linearizability ===
verdict: LINEARIZABLE
violating write: none
Observed for dangling.ndjson (a write blocked by a dead replica is discarded as un-acknowledged; recovered n2 syncs to the committed prefix; the final history is still linearizable):
=== final replica logs (head -> tail) ===
n1 [head]: [w1 w3]
n2 [middle]: [w1 w3]
n3 [tail]: [w1 w3]
=== writes ===
acked: [w1 w3]
dangling: [w2]
=== linearizability ===
verdict: LINEARIZABLE
violating write: none
Run the same happy-path script with the injected bug (--inject-extend-bug, which flips R1: the new tail is attached without back-filling the committed prefix):
./chainrep -inject-extend-bug -in happy.ndjson
Observed — the new tail misses w1..w3, reads deviate from the commit order, and the checker names the first lost acknowledged write, w1:
n3 [tail]: [w4]
=== writes ===
acked: [w1 w2 w3 w4]
dangling: []
=== reads ===
...
r5: []
r6: [w4]
...
r8: [w4]
=== linearizability ===
verdict: NOT LINEARIZABLE
violating write: w1
This confirms the checker is not vacuous: the correctly-implemented path returns LINEARIZABLE, while deliberately reintroducing the reconfiguration bug is caught and attributed to the exact offending write.
# Evidence - Problem class: go-chain-replication-lease-read-linearizable - Model: openrouter/deepseek/deepseek-v4.1-flash - Solved: 2026-09-14T10:07:22.155Z - Verification: solution produced by pi in sandbox; see signatures.json
{"description": "Implement chain replication in Go (standard library only) for a chain of up to 4 replicas where the head accepts writes and forwards them downstream, the tail commits and propagates acknowledgements back up-chain, and read-only clients are served at the tail. Support a configuration change that extends the chain (e.g. 2 to 3 replicas) which must drain in-flight pending operations at the old tail, truncate each replica's log to the committed prefix, and continue serving without duplicating, reordering, or losing any acknowledged write, plus a lease-based tail read path that rejects reads once the tail lease has expired. Read a deterministic scripted event stream from stdin (client ops with ids, node pause/kill/restart, lease grant and expiry, clock skew) and print the final per-replica logs, the ids of acknowledged vs. dangling writes, the linearizability verdict for the observed read/write history, and the specific write that broke the invariant if any.", "environment": "go1.26", "language": "go", "model": "openrouter/deepseek/deepseek-v4.1-flash", "problem_class": "go-chain-replication-lease-read-linearizable", "provider": "openrouter", "solved_at": "2026-09-14T10:07:22.155Z", "version": "1.26"}Deliverable: a self-contained Go 1.26 simulator (~/chainrep/main.go, stdlib only) that reads a deterministic NDJSON event script from stdin, models a head→…→tail replica chain, drains and reconfigures on chain extension, enforces leases for tail reads, and emits per-replica logs, acked/dangling writes, a linearizability verdict, and the offending write.
This is an implement-from-scratch problem, so the "root cause" is the set of failure modes that make a naive chain-replication implementation violate linearizability. There are three, and the third is the one that actually produces a wrong read.
R1 — Read path returns a suffix instead of a prefix.
A tail read must return a prefix of the global commit history that ends at the write last acknowledged before the read. A new/recovering replica that starts empty and is immediately promoted to tail returns a history beginning at the first write it saw — e.g. [w4] when the true history is [w1 w2 w3 w4]. Reads then observe a later write without its predecessors: not a prefix, not linearizable.
R2 — Chain extension truncates before draining.
If a reconfiguration truncates each log to a committed watermark while writes are still in flight, two things break:
- acknowledged writes can be dropped (data loss / reads miss an acked write), and
- if the watermark is read from the new, empty replica it is 0, so the whole committed history is wiped.
The correct order is drain → freeze/truncate → attach/backfill → move lease. Everything that can still reach the old tail must commit first; only genuinely unreachable (dead-link) writes are discarded, and those are exactly the un-acknowledged "dangling" writes.
R3 — Lease is not bound to the tail role and ignores clock skew.
The lease must gate reads: a tail serves a read only when localClock ≤ leaseUntil, where localClock = now + skew. On reconfiguration the lease must move to the new tail and be revoked from the old one; otherwise the demoted replica keeps answering reads. Clock skew is deliberately applied to the local clock so a skewed replica can reject early (positive skew) or serve late (negative skew).
The exact fix is the implementation below; the injected negative control in §4 flips only R1 (attach the new tail without back-filling) and the checker correctly names w1 as the write that broke the invariant.
Events must appear in non-decreasing t order; t is a logical timestamp, not wall-clock.
| Event | Meaning |
|---|---|
{"t":0,"type":"config","nodes":["n1","n2"]} |
install initial chain (head first, tail last) |
{"t":1,"type":"write","id":"w1"} |
client write, enters at head |
{"t":2,"type":"read","id":"r1"} |
client read, served at tail |
{"t":3,"type":"node","node":"n2","action":"pause\|resume\|kill\|restart"} |
node lifecycle |
{"t":4,"type":"lease","node":"n2","action":"grant","duration":10} |
grant tail lease |
{"t":4,"type":"lease","node":"n2","action":"expire"} |
force expiry |
{"t":5,"type":"skew","node":"n2","skew":-2} |
add clock skew to a node |
{"t":6,"type":"extend","node":"n3"} |
extend chain with a new tail |
Writes are acknowledged only when they reach the tail (commit). Writes that never reach a tail are dangling.
Save as main.go (module chainrep, stdlib only):
// chainrep is a deterministic, event-scripted simulator for chain replication
// with a lease-protected tail read path and online chain extension.
//
// It reads newline-delimited JSON events from stdin (or a file given with -in),
// runs the modelled protocol deterministically, and prints:
// - the final per-replica logs,
// - the writes the cluster acknowledged (acked) vs. writes that never
// committed (dangling),
// - a linearizability verdict for the observed read/write history,
// - the write that first broke the invariant, if any.
//
// Standard library only.
package main
import (
"bufio"
"encoding/json"
"flag"
"fmt"
"io"
"os"
)
// Event is one line of the scripted input stream.
//
// Supported types:
//
// {"t":0,"type":"config","nodes":["n1","n2"]} install initial chain
// {"t":1,"type":"write","id":"w1"} client write at head
// {"t":2,"type":"read","id":"r1"} client read at tail
// {"t":3,"type":"node","node":"n2","action":"pause"} pause/resume/kill/restart
// {"t":4,"type":"lease","node":"n2","action":"grant","duration":10}
// {"t":4,"type":"lease","node":"n2","action":"expire"}
// {"t":5,"type":"skew","node":"n2","skew":-2} add clock skew to a node
// {"t":6,"type":"extend","node":"n3"} extend the chain
//
// t is a logical timestamp; events must appear in non-decreasing t order.
type Event struct {
T int `json:"t"`
Type string `json:"type"`
Node string `json:"node"`
Action string `json:"action"`
ID string `json:"id"`
Nodes []string `json:"nodes"`
Duration int `json:"duration"`
Skew int `json:"skew"`
}
type Node struct {
ID string
Log []string
Alive bool
Paused bool
LeaseUntil int // logical time at which the lease expires (exclusive)
Skew int // local clock = global time + Skew
}
func (n *Node) serveable() bool { return n.Alive && !n.Paused }
type ReadRec struct {
ID string
Seq int
Result []string
Rejected bool
Reason string
}
type Sim struct {
nodes []*Node
byID map[string]*Node
inFlight [][]string // inFlight[i]: writes appended at nodes[i], waiting for nodes[i+1]
commitOrder []string // acknowledged writes, in commit order
ackSeq map[string]int
issued []string
dangling map[string]bool
reads []ReadRec
seq int
now int
injectExtendBug bool // negative control only
}
func NewSim(injectExtendBug bool) *Sim {
return &Sim{
byID: map[string]*Node{},
ackSeq: map[string]int{},
dangling: map[string]bool{},
injectExtendBug: injectExtendBug,
}
}
func (s *Sim) commit(id string) {
if _, done := s.ackSeq[id]; done {
return
}
s.ackSeq[id] = s.seq
s.commitOrder = append(s.commitOrder, id)
delete(s.dangling, id)
}
// deliver moves every deliverable in-flight write one or more hops downstream.
// It loops to a fixed point because a write can traverse the whole chain.
func (s *Sim) deliver() {
for {
changed := false
n := len(s.nodes)
for i := 0; i+1 < n; i++ {
next := s.nodes[i+1]
if !next.serveable() {
continue
}
for len(s.inFlight[i]) > 0 {
id := s.inFlight[i][0]
s.inFlight[i] = s.inFlight[i][1:]
next.Log = append(next.Log, id)
if i+1 == n-1 { // reached the tail: commit + ack
s.commit(id)
} else {
s.inFlight[i+1] = append(s.inFlight[i+1], id)
}
changed = true
}
}
if !changed {
return
}
}
}
func (s *Sim) Write(id string) {
s.seq++
s.issued = append(s.issued, id)
s.dangling[id] = true // cleared if/when it commits
if len(s.nodes) == 0 {
return
}
head := s.nodes[0]
if !head.serveable() {
return
}
head.Log = append(head.Log, id)
if len(s.nodes) == 1 {
s.commit(id)
return
}
s.inFlight[0] = append(s.inFlight[0], id)
s.deliver()
}
func (s *Sim) Read(id string) {
s.seq++
if len(s.nodes) == 0 {
s.reads = append(s.reads, ReadRec{ID: id, Seq: s.seq, Rejected: true, Reason: "no chain"})
return
}
tail := s.nodes[len(s.nodes)-1]
if !tail.serveable() {
s.reads = append(s.reads, ReadRec{ID: id, Seq: s.seq, Rejected: true, Reason: "tail unavailable"})
return
}
// Lease check uses the tail's local clock (which may be skewed).
local := s.now + tail.Skew
if local > tail.LeaseUntil {
s.reads = append(s.reads, ReadRec{ID: id, Seq: s.seq, Rejected: true, Reason: "lease expired"})
return
}
s.reads = append(s.reads, ReadRec{ID: id, Seq: s.seq, Result: append([]string(nil), tail.Log...)})
}
// truncateToCommitted drops every log entry that is not part of the globally
// committed prefix, and re-orders the surviving entries to match commit order.
func (s *Sim) truncateToCommitted(n *Node) {
committed := map[string]bool{}
for _, id := range s.commitOrder {
committed[id] = true
}
kept := map[string]bool{}
out := make([]string, 0, len(n.Log))
for _, id := range n.Log {
if committed[id] && !kept[id] {
out = append(out, id)
kept[id] = true
}
}
n.Log = out
}
func (s *Sim) SetNode(id, action string) {
n := s.byID[id]
if n == nil {
return
}
switch action {
case "pause":
n.Paused = true
case "resume":
n.Paused = false
case "kill":
n.Alive = false
n.Paused = false
case "restart":
n.Alive = true
n.Paused = false
// Recover by truncating to the committed prefix and syncing it.
n.Log = append([]string(nil), s.commitOrder...)
}
s.deliver()
}
func (s *Sim) Lease(id, action string, duration int) {
n := s.byID[id]
if n == nil {
return
}
switch action {
case "grant":
n.LeaseUntil = s.now + duration
case "expire":
n.LeaseUntil = s.now - 1
}
}
func (s *Sim) Skew(id string, skew int) {
if n := s.byID[id]; n != nil {
n.Skew = skew
}
}
// Extend appends a new replica to the tail of the chain.
//
// Correct order of operations:
// 1. Drain: push every in-flight write that can still reach the old tail, so
// that anything already ackable becomes committed before the topology
// changes (paused nodes are quiesced for the drain; dead nodes are not).
// 2. Freeze + truncate: remove every non-committed suffix from every replica,
// so the surviving logs are exactly the committed prefix.
// 3. Attach the new replica and back-fill it with the committed prefix before
// it may serve as tail.
// 4. Rebuild the links and hand the tail lease to the new tail (the old
// tail's lease is revoked so it rejects reads).
func (s *Sim) Extend(newID string) {
// 1. Drain. A config change quiesces the chain: pending writes are pushed
// through even nodes that are momentarily paused, so anything that can still
// reach the old tail is committed (and therefore acknowledged) before the
// topology changes. Writes blocked by a dead node cannot be drained and are
// dropped below (they stay dangling).
savedPaused := make(map[*Node]bool, len(s.nodes))
for _, n := range s.nodes {
savedPaused[n] = n.Paused
n.Paused = false
}
s.deliver()
for _, n := range s.nodes {
n.Paused = savedPaused[n]
}
// Anything still in flight is unreachable (dead downstream node); it was not
// acknowledged and must not survive the reconfiguration.
s.inFlight = make([][]string, max(0, len(s.nodes)-1))
// 2. Freeze + truncate.
for _, n := range s.nodes {
s.truncateToCommitted(n)
}
// 3. Attach and back-fill.
var tailLease int
if len(s.nodes) > 0 {
oldTail := s.nodes[len(s.nodes)-1]
tailLease = oldTail.LeaseUntil
oldTail.LeaseUntil = 0 // revoke: it is no longer the tail
}
nn := &Node{ID: newID, Alive: true}
if !s.injectExtendBug {
nn.Log = append([]string(nil), s.commitOrder...)
}
s.byID[newID] = nn
s.nodes = append(s.nodes, nn)
nn.LeaseUntil = tailLease
// 4. Rebuild links (empty after a successful drain).
s.inFlight = make([][]string, max(0, len(s.nodes)-1))
s.deliver()
}
// Check returns (ok, offendingWrite). A read is valid iff its result is exactly
// the committed prefix of writes acked strictly before the read was processed.
func (s *Sim) Check() (bool, string) {
for _, rd := range s.reads {
if rd.Rejected {
continue
}
// Number of writes acked before this read's processing slot.
k := 0
for _, id := range s.commitOrder {
if s.ackSeq[id] < rd.Seq {
k++
} else {
break
}
}
// The returned value must be a prefix of the global commit order; the
// first mismatch is the write that violates the invariant.
for i := range rd.Result {
if i >= len(s.commitOrder) {
return false, rd.Result[i]
}
if rd.Result[i] != s.commitOrder[i] {
return false, s.commitOrder[i]
}
}
// It must also contain exactly the writes acked before the read.
if len(rd.Result) < k {
return false, s.commitOrder[len(rd.Result)]
}
if len(rd.Result) > k {
if k < len(s.commitOrder) {
return false, s.commitOrder[k]
}
return false, rd.Result[k]
}
}
return true, ""
}
func (s *Sim) Run(r io.Reader) error {
sc := bufio.NewScanner(r)
sc.Buffer(make([]byte, 0, 64*1024), 16*1024*1024)
line := 0
for sc.Scan() {
line++
raw := sc.Bytes()
if len(raw) == 0 {
continue
}
var ev Event
if err := json.Unmarshal(raw, &ev); err != nil {
return fmt.Errorf("line %d: %w", line, err)
}
s.now = ev.T
switch ev.Type {
case "config":
for _, id := range ev.Nodes {
n := &Node{ID: id, Alive: true}
s.byID[id] = n
s.nodes = append(s.nodes, n)
}
s.inFlight = make([][]string, max(0, len(s.nodes)-1))
case "write":
s.Write(ev.ID)
case "read":
s.Read(ev.ID)
case "node":
s.SetNode(ev.Node, ev.Action)
case "lease":
s.Lease(ev.Node, ev.Action, ev.Duration)
case "skew":
s.Skew(ev.Node, ev.Skew)
case "extend":
s.Extend(ev.Node)
default:
return fmt.Errorf("line %d: unknown event type %q", line, ev.Type)
}
}
return sc.Err()
}
func max(a, b int) int {
if a > b {
return a
}
return b
}
func (s *Sim) Report(w io.Writer) {
fmt.Fprintln(w, "=== final replica logs (head -> tail) ===")
for i, n := range s.nodes {
role := "middle"
if i == 0 {
role = "head"
}
if i == len(s.nodes)-1 {
role = "tail"
}
fmt.Fprintf(w, "%s [%s]: %v\n", n.ID, role, n.Log)
}
fmt.Fprintln(w, "\n=== writes ===")
fmt.Fprintf(w, "acked: %v\n", s.commitOrder)
var dangling []string
for _, id := range s.issued {
if s.dangling[id] {
dangling = append(dangling, id)
}
}
fmt.Fprintf(w, "dangling: %v\n", dangling)
fmt.Fprintln(w, "\n=== reads ===")
for _, rd := range s.reads {
if rd.Rejected {
fmt.Fprintf(w, "%s: rejected (%s)\n", rd.ID, rd.Reason)
} else {
fmt.Fprintf(w, "%s: %v\n", rd.ID, rd.Result)
}
}
fmt.Fprintln(w, "\n=== linearizability ===")
ok, bad := s.Check()
if ok {
fmt.Fprintln(w, "verdict: LINEARIZABLE")
fmt.Fprintln(w, "violating write: none")
} else {
fmt.Fprintln(w, "verdict: NOT LINEARIZABLE")
fmt.Fprintf(w, "violating write: %s\n", bad)
}
}
func main() {
in := flag.String("in", "-", "input file, or - for stdin")
inject := flag.Bool("inject-extend-bug", false, "negative control: attach new tail without back-filling committed prefix")
flag.Parse()
var r io.Reader = os.Stdin
if *in != "-" {
f, err := os.Open(*in)
if err != nil {
fmt.Fprintln(os.Stderr, err)
os.Exit(1)
}
defer f.Close()
r = f
}
s := NewSim(*inject)
if err := s.Run(r); err != nil {
fmt.Fprintln(os.Stderr, "error:", err)
os.Exit(1)
}
s.Report(os.Stdout)
}
commit, called when i+1 == n-1). Re-sending a write can never double-commit.deliver runs to a fixed point, so one write can traverse the whole chain synchronously and in order; per-link FIFO queues preserve write order.truncateToCommitted uses the one global commitOrder, never a per-replica local watermark — this is the fix for R2.Extend order is drain → truncate → backfill → move lease. Backfill for the new tail is copy(commitOrder), so its first read is a valid prefix.Extend revokes the old tail's lease (LeaseUntil = 0) and transfers it to the new tail. Read rejects when now + skew > leaseUntil.Check requires each served read to equal the prefix of commitOrder containing exactly the writes acked before that read's processing slot; the first mismatch is reported as the offending write.mkdir -p chainrep && cd chainrep
# (paste main.go and main_test.go from this document)
go mod init chainrep
gofmt -w main.go main_test.go
go vet ./...
go test -v ./...
Observed:
=== RUN TestHappyPathExtendAndLease
--- PASS: TestHappyPathExtendAndLease (0.00s)
=== RUN TestDanglingWriteIsDropped
--- PASS: TestDanglingWriteIsDropped (0.00s)
=== RUN TestInjectExtendBugCaught
--- PASS: TestInjectExtendBugCaught (0.00s)
PASS
ok chainrep 0.002s
main_test.go:
package main
import (
"strings"
"testing"
)
func run(t *testing.T, script string, inject bool) *Sim {
t.Helper()
s := NewSim(inject)
if err := s.Run(strings.NewReader(script)); err != nil {
t.Fatalf("run: %v", err)
}
return s
}
func eq(a, b []string) bool {
if len(a) != len(b) {
return false
}
for i := range a {
if a[i] != b[i] {
return false
}
}
return true
}
func TestHappyPathExtendAndLease(t *testing.T) {
script := `
{"t":0,"type":"config","nodes":["n1","n2"]}
{"t":1,"type":"lease","node":"n2","action":"grant","duration":100}
{"t":2,"type":"write","id":"w1"}
{"t":3,"type":"read","id":"r1"}
{"t":4,"type":"node","node":"n2","action":"pause"}
{"t":5,"type":"write","id":"w2"}
{"t":6,"type":"extend","node":"n3"}
{"t":7,"type":"read","id":"r2"}
{"t":8,"type":"node","node":"n2","action":"resume"}
{"t":9,"type":"write","id":"w3"}
{"t":10,"type":"read","id":"r3"}
{"t":11,"type":"skew","node":"n3","skew":1000}
{"t":12,"type":"read","id":"r4"}
`
s := run(t, script, false)
if !eq(s.commitOrder, []string{"w1", "w2", "w3"}) {
t.Fatalf("acked = %v", s.commitOrder)
}
if len(s.dangling) != 0 {
t.Fatalf("dangling = %v", s.dangling)
}
for _, n := range s.nodes {
if !eq(n.Log, s.commitOrder) {
t.Fatalf("node %s log %v != commit %v", n.ID, n.Log, s.commitOrder)
}
}
if s.reads[0].Rejected || !eq(s.reads[0].Result, []string{"w1"}) {
t.Fatalf("r1 = %+v", s.reads[0])
}
if s.reads[1].Rejected || !eq(s.reads[1].Result, []string{"w1", "w2"}) {
t.Fatalf("r2 = %+v", s.reads[1])
}
if s.reads[2].Rejected || !eq(s.reads[2].Result, []string{"w1", "w2", "w3"}) {
t.Fatalf("r3 = %+v", s.reads[2])
}
if !s.reads[3].Rejected {
t.Fatalf("r4 should be rejected after skewed lease, got %+v", s.reads[3])
}
if ok, bad := s.Check(); !ok {
t.Fatalf("expected linearizable, got NOT (offender %s)", bad)
}
}
func TestDanglingWriteIsDropped(t *testing.T) {
script := `
{"t":0,"type":"config","nodes":["n1","n2"]}
{"t":1,"type":"lease","node":"n2","action":"grant","duration":100}
{"t":2,"type":"write","id":"w1"}
{"t":3,"type":"node","node":"n2","action":"kill"}
{"t":4,"type":"write","id":"w2"}
{"t":5,"type":"extend","node":"n3"}
{"t":6,"type":"node","node":"n2","action":"restart"}
{"t":7,"type":"write","id":"w3"}
{"t":8,"type":"read","id":"r1"}
`
s := run(t, script, false)
if !eq(s.commitOrder, []string{"w1", "w3"}) {
t.Fatalf("acked = %v", s.commitOrder)
}
if !s.dangling["w2"] {
t.Fatalf("w2 should be dangling")
}
if s.dangling["w1"] || s.dangling["w3"] {
t.Fatalf("w1/w3 acked but marked dangling: %v", s.dangling)
}
if ok, bad := s.Check(); !ok {
t.Fatalf("expected linearizable, got NOT (offender %s)", bad)
}
}
// Negative control: if the replacement tail is attached without back-filling
// the committed prefix, a later read observes a truncated history and the
// checker must name the first lost acknowledged write.
func TestInjectExtendBugCaught(t *testing.T) {
script := `
{"t":0,"type":"config","nodes":["n1","n2"]}
{"t":1,"type":"lease","node":"n2","action":"grant","duration":100}
{"t":2,"type":"write","id":"w1"}
{"t":4,"type":"extend","node":"n3"}
{"t":5,"type":"write","id":"w2"}
{"t":6,"type":"read","id":"r1"}
`
s := run(t, script, true)
ok, bad := s.Check()
if ok {
t.Fatalf("expected injected bug to be detected")
}
if bad != "w1" {
t.Fatalf("expected offender w1, got %q", bad)
}
}
drain.ndjson:
{"t":0,"type":"config","nodes":["n1","n2"]}
{"t":1,"type":"lease","node":"n2","action":"grant","duration":100}
{"t":2,"type":"write","id":"w1"}
{"t":3,"type":"read","id":"r1"}
{"t":4,"type":"node","node":"n2","action":"pause"}
{"t":5,"type":"write","id":"w2"}
{"t":6,"type":"extend","node":"n3"}
{"t":7,"type":"read","id":"r2"}
{"t":8,"type":"node","node":"n2","action":"resume"}
{"t":9,"type":"write","id":"w3"}
{"t":10,"type":"read","id":"r3"}
go build -o chainrep .
./chainrep -in drain.ndjson
Observed — w2 is drained and committed by the extension (not lost, not dangling), n3 is back-filled, all logs identical:
=== final replica logs (head -> tail) ===
n1 [head]: [w1 w2 w3]
n2 [middle]: [w1 w2 w3]
n3 [tail]: [w1 w2 w3]
=== writes ===
acked: [w1 w2 w3]
dangling: []
=== reads ===
r1: [w1]
r2: [w1 w2]
r3: [w1 w2 w3]
=== linearizability ===
verdict: LINEARIZABLE
violating write: none
./chainrep -in happy.ndjson
./chainrep -in dangling.ndjson
Observed for happy.ndjson (lease expiry rejects reads; a positive skew of +1000 also makes the lease look expired; no write is lost or duplicated):
=== final replica logs (head -> tail) ===
n1 [head]: [w1 w2 w3 w4]
n2 [middle]: [w1 w2 w3 w4]
n3 [tail]: [w1 w2 w3 w4]
=== writes ===
acked: [w1 w2 w3 w4]
dangling: []
=== reads ===
r1: [w1]
r2: rejected (lease expired)
r3: [w1 w2]
r4: [w1 w2 w3]
r5: [w1 w2 w3]
r6: [w1 w2 w3 w4]
r7: rejected (lease expired)
r8: [w1 w2 w3 w4]
=== linearizability ===
verdict: LINEARIZABLE
violating write: none
Observed for dangling.ndjson (a write blocked by a dead replica is discarded as un-acknowledged; recovered n2 syncs to the committed prefix; the final history is still linearizable):
=== final replica logs (head -> tail) ===
n1 [head]: [w1 w3]
n2 [middle]: [w1 w3]
n3 [tail]: [w1 w3]
=== writes ===
acked: [w1 w3]
dangling: [w2]
=== linearizability ===
verdict: LINEARIZABLE
violating write: none
Run the same happy-path script with the injected bug (--inject-extend-bug, which flips R1: the new tail is attached without back-filling the committed prefix):
./chainrep -inject-extend-bug -in happy.ndjson
Observed — the new tail misses w1..w3, reads deviate from the commit order, and the checker names the first lost acknowledged write, w1:
n3 [tail]: [w4]
=== writes ===
acked: [w1 w2 w3 w4]
dangling: []
=== reads ===
...
r5: []
r6: [w4]
...
r8: [w4]
=== linearizability ===
verdict: NOT LINEARIZABLE
violating write: w1
This confirms the checker is not vacuous: the correctly-implemented path returns LINEARIZABLE, while deliberately reintroducing the reconfiguration bug is caught and attributed to the exact offending write.
# Evidence - Problem class: go-chain-replication-lease-read-linearizable - Model: openrouter/deepseek/deepseek-v4.1-flash - Solved: 2026-09-14T10:07:22.155Z - Verification: solution produced by pi in sandbox; see signatures.json
{"description": "Implement chain replication in Go (standard library only) for a chain of up to 4 replicas where the head accepts writes and forwards them downstream, the tail commits and propagates acknowledgements back up-chain, and read-only clients are served at the tail. Support a configuration change that extends the chain (e.g. 2 to 3 replicas) which must drain in-flight pending operations at the old tail, truncate each replica's log to the committed prefix, and continue serving without duplicating, reordering, or losing any acknowledged write, plus a lease-based tail read path that rejects reads once the tail lease has expired. Read a deterministic scripted event stream from stdin (client ops with ids, node pause/kill/restart, lease grant and expiry, clock skew) and print the final per-replica logs, the ids of acknowledged vs. dangling writes, the linearizability verdict for the observed read/write history, and the specific write that broke the invariant if any.", "environment": "go1.26", "language": "go", "model": "openrouter/deepseek/deepseek-v4.1-flash", "problem_class": "go-chain-replication-lease-read-linearizable", "provider": "openrouter", "solved_at": "2026-09-14T10:07:22.155Z", "version": "1.26"}