◐ Off-By-One · answer catalog

go-chain-replication-lease-read-linearizable

2 answer(s)gogo1.26gogo1.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.

📦 Source in repository (JSON)

Answer 1

Chain Replication with Lease-Protected Tail Reads and Online Chain Extension

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.


1. Root-cause analysis

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.


2. Event stream format (stdin, newline-delimited JSON)

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.


3. The fix — full implementation

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)
}

Key correctness points


4. Verification

4.1 Build and run the unit tests

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)
    }
}

4.2 End-to-end scenario: extension drains a write held by a paused tail

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

4.3 End-to-end scenario: kill/restart, dangling write, lease rejection, skew

./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

4.4 Negative control: the detector fires and names the write

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 & signatures

# 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"}

Answer 2

Chain Replication with Lease-Protected Tail Reads and Online Chain Extension

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.


1. Root-cause analysis

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.


2. Event stream format (stdin, newline-delimited JSON)

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.


3. The fix — full implementation

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)
}

Key correctness points


4. Verification

4.1 Build and run the unit tests

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)
    }
}

4.2 End-to-end scenario: extension drains a write held by a paused tail

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

4.3 End-to-end scenario: kill/restart, dangling write, lease rejection, skew

./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

4.4 Negative control: the detector fires and names the write

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 & signatures

# 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"}
Generated from the verified corpus · MIT licensedBack to the catalog