carlos / platform Public

Spike: the admission protocol as an executable model (plan step 0): Part C's transition table, both holds, the latch and its replay, construction backfill, ancestry reach, root-first acknowledgement, helper death, the producer fence and recovery, with the design's named sequences as tests and randomised schedules under barrier, hazard, reach and liveness invariants; 30000 schedules pass; fourteen mutants killed

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
claude agent, operated by paul@keymail.dev f331b55a6f1c994201fbc6908daf20e843c00cef parent 8a87793
3 files changed, +1567 −0
  • docs/superpowers/specs/2026-10-03-replicator-pinned-roots-spikes.md +11 −0
  • internal/ops/admissionmodel/model.go +931 −0
  • internal/ops/admissionmodel/model_test.go +625 −0

CI passed — run details

diff --git a/docs/superpowers/specs/2026-10-03-replicator-pinned-roots-spikes.md b/docs/superpowers/specs/2026-10-03-replicator-pinned-roots-spikes.md
index 6cb610854..48a70767e 100644
--- a/docs/superpowers/specs/2026-10-03-replicator-pinned-roots-spikes.md
+++ b/docs/superpowers/specs/2026-10-03-replicator-pinned-roots-spikes.md
@@ -13,3 +13,14 @@ Read-only findings (no code written or run), 2026-10-03 06:45, revised 07:25 for
- `DurabilityClient.Start` (`internal/usage/durabilityledger.go:1007`) is already one transaction per attempt on a `synchronous=FULL` WAL database: an `INSERT` into `attempts`, `touchDay`, `noteInstant`, `persistSeq`, commit. The meter gives it `requestMeterBudget` (25 ms) and sends regardless of its outcome (`internal/lease/requestmeter.go`).
- The design's admission is a commit of its own before permission (the `Start` transaction stays, so an attempt now costs two commits plus the settlement writes and the socket round trip to the helper): the gate's answer waits for it, and a commit that misses the budget refuses the request instead of sending anyway (the late commit is then marked unissued by the goroutine that awaited it). The cost is a second commit and a dependency: the request path's latency includes the ledger's commit, and a slow disk refuses requests rather than sending unrecorded ones. With the helper process the admission crosses a socket as well. The spike's deliverable: the ledger's commit latency distribution on a box's disk under the replicator's request rate (litestream's sync interval per database times the fleet's size) plus the socket round trip, and the budget to set from it; and, as an executable model, the design's transition table as it stands (both completion orders; `unissued`, `unrecorded`, `recovered-unknown`; discovery with zero pending; a failed discovery write under the pending-discovery latch; construction links and transitive inheritance across three fleets; the producer fence at a shutdown timeout; a kill at each boundary).
+
+## The admission protocol as an executable model (run 2026-10-03 08:20 to 08:50)
+
+Plan step 0's second spike is code, kept in tree at `internal/ops/admissionmodel` (not wired, imported by nothing): Part C's transition table (`registered`, `admitted`, `transport done`, `recorder done`, `settled`, `unissued`, `cancelled`, `recovered-unknown`), the barrier and the uncertainty, the pending set under the discovery mutex, the pending-discovery latch and its one-transaction replay, construction backfill, reach over the unacknowledged ancestry, root-first acknowledgement, helper death with the lifecycle guard, the producer fence and process recovery, as plain Go over an integer clock with no SQLite. The model keeps a *truth* apart from the ledger it simulates (when the authority really changed, when each transport really began and ended, which helpers outlived the agent) and the tests check the ledger's answers against it.
+
+- **The named sequences are tests**, one each: an unchanged fleet holds nothing; the three-midnight interval in both completion orders (closes at the settlement plus the margin); Astra's commit-after-discovery (unissued, the uncertainty closed once by the empty settlement); review 7's budget expiry then commit; a definite failure cancels and an ambiguous commit is read back either way with the pending set empty after; zero-pending discovery closes at once and holds the margin's extra day across midnight; a failed discovery's latch bars and refuses until the replay, which with nothing pending closes at the replay instant plus the margin; review 6's replay with a counted settlement (the 2nd, 3rd and 4th held, closed at 00:04, a successor built after the replay backfilled); Astra's A, B, C construction and root-first acknowledgement (C refused before B acknowledged A; C inherits U); helper death before and after recorder registration, a `Start` refused after the guard closed; the fence outliving the agent (recovery refused until the helper is gone; recovered-unknown covers the transport that finished after the crash); a healthy chain contributing nothing; and the lower bound reaching a request completed before the change was noticed (the clean look minus the margin).
+- **Randomised schedules**: every action above in random interleavings over about four days, 120 steps a schedule; after each step the barrier invariant (an issued unsettled admission bars its owner from its day on) and the hazard invariant (once the ledger knows of a change, every day on which a transport overlapped the true change is incomplete for its owner; after a crash, every admission unsettled at the crash likewise); at quiescence every fleet's owners hold every interval of every uncertainty on its whole chain, no uncertainty is open, and a day with no uncertainty reaching an owner is complete. `ADMISSION_MODEL_SEEDS=30000` passes in under two seconds; the default is 400.
+- **What the first runs found** was in the model's own bookkeeping, not the design: the driver admitted an owner the fleet did not have (the model now refuses that, as the design implies), and a helper the crash killed left its transport's true end unset so the hazard looked open forever. The design's rules held once the truth was kept correctly; no interleaving the design does not already name was found.
+- **Mutants, in a tree copy**, each killed by a named test or a schedule: no construction backfill (reach); the pending set not counted (the commit-after-discovery tests); no recheck after commit (the same test, and a hazard at seed 264); the replay dropping counted settlements (hazard); the replay closing at the discovery instant (liveness and the latch tests); a settlement writing no interval (hazard); recovery ignoring the fence (the fence test); helper death without transport-lost (an uncertainty open at quiescence); the lower bound ignoring the clean look (the lower-bound test; the 30000-schedule sweep alone did not find it, which is why that sequence is a named test); the lower bound ignoring the earliest unsettled admission (liveness, because the model's proof then reached too far); unrecorded synthesised for a registered worker (the guard test); recovery skipping unclosed fleets (an uncertainty open at quiescence); acknowledgement not root-first and reach ignoring successors (reach). An epoch check made redundant by the stale state is the one equivalent mutant, noted in the model.
+- **What the model does not cover**, by construction: the ledger's commit latency on a box's disk under the replicator's request rate (the spike's other half, which needs a box), Part C2's owner scope beyond the owner-scoped holds the model writes, and process kills inside a transaction (the model's transactions are atomic by fiat; the ledger's are SQLite's).
+
diff --git a/internal/ops/admissionmodel/model.go b/internal/ops/admissionmodel/model.go
new file mode 100644
index 000000000..129546f60
--- /dev/null
+++ b/internal/ops/admissionmodel/model.go
@@ -0,0 +1,931 @@
+// Package admissionmodel is plan step 0's second spike for the replicator's
+// authority design (docs/superpowers/specs/2026-10-03-replicator-pinned-roots-design.md,
+// Part C): the admission's transition table, the two kinds of hold, the
+// pending-discovery latch and its replay, construction backfill, ancestry
+// reach, acknowledgement, helper death, the producer fence and process
+// recovery, as one executable model with no SQLite and no clock. The tests
+// drive it through the sequences the design names and through randomised
+// schedules, and check the invariants the design claims (I2) against a truth
+// the model keeps separately from the ledger it simulates. Nothing here is
+// wired; it exists to find the interleavings a prose table hides.
+package admissionmodel
+
+import (
+ "fmt"
+ "math/rand"
+ "sort"
+)
+
+// Time is minutes since an epoch; a Day is 1440 of them. Margin is the
+// design's one minute.
+type Time = int64
+
+const (
+ Day Time = 1440
+ Margin Time = 1
+ unset Time = -1
+)
+
+func dayOf(t Time) int64 { return t / Day }
+
+// Owner is an (account, app) pair.
+type Owner string
+
+// State is an admission's state in the design's table.
+type State int
+
+const (
+ Registered State = iota // in memory only
+ Admitted // durable row
+ TransportDone
+ RecorderDone
+ Settled
+ Unissued
+ Cancelled
+ RecoveredUnknown
+)
+
+func (s State) String() string {
+ return [...]string{"registered", "admitted", "transport done", "recorder done", "settled", "unissued", "cancelled", "recovered-unknown"}[s]
+}
+
+// Terminal reports whether no participant remains to complete.
+func (s State) Terminal() bool { return s >= Settled }
+
+// Admission is one row of `admissions`, plus the in-memory facts of its
+// registration and the truth the model keeps about what the helper did.
+type Admission struct {
+ Token int
+ Fleet *Fleet
+ Owner Owner
+ AdmittedAt Time
+ State State
+ Durable bool // the row exists
+ Issued bool // the token was handed to the helper
+ Refused bool // the gate's budget expired or the recheck refused
+
+ writerRunning bool
+ epoch int // the fleet's discovery epoch at registration
+
+ TransportDoneAt Time
+ RecorderStartedAt Time
+ RecorderDoneAt Time
+ SettledAt Time
+ How string
+
+ // truth, never read by the ledger side
+ SentAt Time // when the helper began the transport
+ SentEndAt Time // when the transport truly ended (lost: helper death)
+}
+
+func (a *Admission) unsettled() bool { return !a.State.Terminal() }
+
+// Uncertainty is one row of `uncertainties` with the tokens it counted.
+type Uncertainty struct {
+ ID int
+ Fleet *Fleet
+ From, To Time // To == unset while open
+ DiscoveryAt Time
+ Counted map[int]*Admission
+}
+
+func (u *Uncertainty) open() bool { return u.To == unset }
+
+// Latch is the pending discovery the ledger's failure overlay carries.
+type Latch struct {
+ Fleet *Fleet
+ From Time
+ At Time
+ Counted map[int]*Admission
+}
+
+// FState is a fleet row's state.
+type FState int
+
+const (
+ Open FState = iota
+ Stale
+ Closing
+ Closed
+)
+
+// Fleet is one row of `fleets` plus its in-memory state.
+type Fleet struct {
+ ID int
+ Owners []Owner
+ OpenedAt Time
+ State FState
+ Pred *Fleet
+ Acked bool // this fleet has acknowledged its predecessor
+
+ Admissions []*Admission
+ Uncertainties []*Uncertainty
+ Inherited []*Uncertainty
+
+ // in memory
+ pending map[int]*Admission
+ epoch int
+ inDiscovery bool
+ latch *Latch
+ lastCleanLook Time
+ proof Time
+ helperAlive bool
+ helperStopped bool
+ guardClosed bool
+
+ // truth
+ ChangedAt Time // the instant authority truly changed (unset if never)
+ DiscoveredAt Time // when the ledger learned of it (durably or latched)
+ DiedAt Time // helper death
+}
+
+// Model is the whole box: one agent process, its helpers, the ledger.
+type Model struct {
+ rnd *rand.Rand
+ Now Time
+ Fleets []*Fleet
+ Admissions []*Admission
+ Holds map[int64]map[Owner]bool // durable
+ Alive bool // the agent process
+ Recovered bool // recovery ran since the last crash
+ CrashedAt Time
+ nextToken int
+ nextUnc int
+ Log []string
+}
+
+// New returns an empty box with a live agent.
+func New(seed int64) *Model {
+ return &Model{rnd: rand.New(rand.NewSource(seed)), Holds: map[int64]map[Owner]bool{}, Alive: true, Recovered: true, CrashedAt: unset}
+}
+
+func (m *Model) logf(f string, args ...any) {
+ m.Log = append(m.Log, fmt.Sprintf("%6d %s", m.Now, fmt.Sprintf(f, args...)))
+}
+
+// Advance moves the clock.
+func (m *Model) Advance(d Time) { m.Now += d }
+
+func (m *Model) hold(day int64, o Owner) {
+ if m.Holds[day] == nil {
+ m.Holds[day] = map[Owner]bool{}
+ }
+ m.Holds[day][o] = true
+}
+
+func (m *Model) holdInterval(from, to Time, owners []Owner) {
+ for d := dayOf(from); d <= dayOf(to); d++ {
+ for _, o := range owners {
+ m.hold(d, o)
+ }
+ }
+}
+
+// unackAncestry is the design's walk: predecessors through fleets that have
+// not acknowledged; an acknowledged step ends it.
+func unackAncestry(g *Fleet) []*Fleet {
+ var anc []*Fleet
+ for cur := g; !cur.Acked && cur.Pred != nil; cur = cur.Pred {
+ anc = append(anc, cur.Pred)
+ }
+ return anc
+}
+
+// effective is a fleet's effective set: own, inherited, and the own and
+// inherited sets of its unacknowledged ancestry.
+func effective(g *Fleet) []*Uncertainty {
+ seen := map[int]bool{}
+ var out []*Uncertainty
+ add := func(us []*Uncertainty) {
+ for _, u := range us {
+ if !seen[u.ID] {
+ seen[u.ID] = true
+ out = append(out, u)
+ }
+ }
+ }
+ add(g.Uncertainties)
+ add(g.Inherited)
+ for _, a := range unackAncestry(g) {
+ add(a.Uncertainties)
+ add(a.Inherited)
+ }
+ return out
+}
+
+// reach is who an uncertainty of f reaches: f's owners and the owners of
+// every fleet whose unacknowledged ancestry contains f.
+func (m *Model) reach(f *Fleet) []Owner {
+ set := map[Owner]bool{}
+ for _, o := range f.Owners {
+ set[o] = true
+ }
+ for _, g := range m.Fleets {
+ for _, a := range unackAncestry(g) {
+ if a == f {
+ for _, o := range g.Owners {
+ set[o] = true
+ }
+ }
+ }
+ }
+ return ownersOf(set)
+}
+
+func ownersOf(set map[Owner]bool) []Owner {
+ out := make([]Owner, 0, len(set))
+ for o := range set {
+ out = append(out, o)
+ }
+ sort.Slice(out, func(i, j int) bool { return out[i] < out[j] })
+ return out
+}
+
+// Build constructs a fleet against the chain's tip, with construction
+// backfill: every interval its unacknowledged ancestry already holds.
+func (m *Model) Build(owners ...Owner) *Fleet {
+ m.mustAlive("build")
+ var pred *Fleet
+ if n := len(m.Fleets); n > 0 {
+ pred = m.Fleets[n-1]
+ }
+ f := &Fleet{ID: len(m.Fleets) + 1, Owners: owners, OpenedAt: m.Now, Pred: pred, pending: map[int]*Admission{},
+ lastCleanLook: m.Now, proof: m.Now, helperAlive: true, ChangedAt: unset, DiscoveredAt: unset, DiedAt: unset}
+ m.Fleets = append(m.Fleets, f)
+ for _, u := range effective(f) { // own set is empty; this is the ancestry's
+ to := u.To
+ if u.open() {
+ to = m.Now
+ }
+ m.holdInterval(u.From, to, f.Owners)
+ }
+ m.logf("fleet %d built (owners %v, pred %v)", f.ID, owners, predID(pred))
+ return f
+}
+
+func (f *Fleet) owns(o Owner) bool {
+ for _, x := range f.Owners {
+ if x == o {
+ return true
+ }
+ }
+ return false
+}
+
+func predID(p *Fleet) any {
+ if p == nil {
+ return nil
+ }
+ return p.ID
+}
+
+// Look is a clean look at the fleet's files: it moves the lower bound. If
+// the authority has truly changed, the look is a discovery instead.
+func (m *Model) Look(f *Fleet) {
+ m.mustAlive("look")
+ if f.ChangedAt != unset && f.DiscoveredAt == unset {
+ m.Discover(f, false)
+ return
+ }
+ if f.State == Open {
+ f.lastCleanLook = m.Now
+ }
+}
+
+// Change is the truth: the fleet's authority changes now, unseen.
+func (m *Model) Change(f *Fleet) {
+ if f.ChangedAt == unset {
+ f.ChangedAt = m.Now
+ }
+}
+
+// Register is the gate's first half: under the discovery mutex, proof then
+// registration in the pending set. It returns nil when refused.
+func (m *Model) Register(f *Fleet, o Owner) *Admission {
+ m.mustAlive("register")
+ if f.State != Open || f.inDiscovery || f.latch != nil || !f.helperAlive || f.helperStopped {
+ return nil
+ }
+ if !f.owns(o) {
+ panic("model: an admission for an owner the fleet does not have")
+ }
+ f.proof = m.Now // the gate's proof: the witness read unchanged now
+ m.nextToken++
+ a := &Admission{Token: m.nextToken, Fleet: f, Owner: o, AdmittedAt: m.Now, State: Registered, writerRunning: true, epoch: f.epoch,
+ TransportDoneAt: unset, RecorderStartedAt: unset, RecorderDoneAt: unset, SettledAt: unset, SentAt: unset, SentEndAt: unset}
+ f.pending[a.Token] = a
+ m.Admissions = append(m.Admissions, a)
+ m.logf("T%d registered on fleet %d for %s", a.Token, f.ID, o)
+ return a
+}
+
+// BudgetExpires: the gate refuses the request while the writer still runs.
+func (m *Model) BudgetExpires(a *Admission) {
+ if a.State == Registered && a.writerRunning {
+ a.Refused = true
+ }
+}
+
+// WriterReturns resolves a registration: "commit", "fail", or "ambiguous"
+// (the writer cannot tell; the model decides whether the write landed and
+// the read-back, after the handle is closed, finds it).
+func (m *Model) WriterReturns(a *Admission, outcome string) {
+ if !m.Alive || a.State != Registered || !a.writerRunning {
+ return
+ }
+ a.writerRunning = false
+ f := a.Fleet
+ landed := outcome == "commit" || (outcome == "ambiguous" && m.rnd.Intn(2) == 0)
+ if outcome == "ambiguous" {
+ a.Refused = true // the gate could not answer within its budget
+ }
+ if !landed {
+ // conclusive cancellation: the writer has returned (or its handle is
+ // closed and a fresh read found no row)
+ a.State = Cancelled
+ a.SettledAt = m.Now
+ a.How = "cancelled"
+ delete(f.pending, a.Token)
+ m.logf("T%d cancelled", a.Token)
+ m.emptySettlement(a)
+ return
+ }
+ a.Durable = true
+ a.State = Admitted
+ f.Admissions = append(f.Admissions, a)
+ delete(f.pending, a.Token) // under the mutex: the row now carries it
+ // the recheck under the mutex (the epoch test is redundant with the
+ // stale state here, since every discovery stales the fleet; it is kept
+ // because the design names it)
+ if a.Refused || f.State != Open || f.inDiscovery || f.epoch != a.epoch || f.latch != nil {
+ m.terminal(a, Unissued, "unissued")
+ m.logf("T%d unissued", a.Token)
+ m.emptySettlement(a)
+ return
+ }
+ a.Issued = true
+ a.SentAt = m.Now
+ m.logf("T%d issued", a.Token)
+}
+
+func (m *Model) terminal(a *Admission, s State, how string) {
+ a.State = s
+ a.How = how
+ if a.TransportDoneAt == unset {
+ a.TransportDoneAt = m.Now
+ }
+ if a.RecorderDoneAt == unset {
+ a.RecorderDoneAt = m.Now
+ }
+ a.SettledAt = m.Now
+}
+
+// emptySettlement tells every discovery that counted a registration that it
+// ended with an empty interval, so an uncertainty waiting only on it closes.
+func (m *Model) emptySettlement(a *Admission) {
+ for _, u := range a.Fleet.Uncertainties {
+ if u.open() && u.Counted[a.Token] != nil {
+ m.maybeClose(u, m.Now)
+ }
+ }
+}
+
+func (m *Model) openUncertainty(f *Fleet) *Uncertainty {
+ for _, u := range f.Uncertainties {
+ if u.open() {
+ return u
+ }
+ }
+ return nil
+}
+
+func countedUnsettled(u *Uncertainty) bool {
+ for _, a := range u.Counted {
+ if a.unsettled() {
+ return true
+ }
+ }
+ return false
+}
+
+func (m *Model) maybeClose(u *Uncertainty, settlement Time) {
+ if countedUnsettled(u) {
+ return
+ }
+ to := u.DiscoveryAt
+ if settlement > to {
+ to = settlement
+ }
+ u.To = to + Margin
+ m.holdInterval(u.From, u.To, m.reach(u.Fleet))
+ m.logf("U%d closed [%d,%d]", u.ID, u.From, u.To)
+}
+
+// Discover is the change found: under the discovery mutex, the lower bound
+// and the counted set; then the transaction, which may fail and latch.
+func (m *Model) Discover(f *Fleet, fail bool) {
+ m.mustAlive("discover")
+ if f.State != Open {
+ return
+ }
+ f.inDiscovery = true
+ f.epoch++
+ if f.ChangedAt == unset {
+ f.ChangedAt = m.Now
+ }
+ f.DiscoveredAt = m.Now
+ counted := map[int]*Admission{}
+ from := f.proof
+ if f.lastCleanLook < from {
+ from = f.lastCleanLook
+ }
+ for _, a := range f.Admissions {
+ if a.unsettled() {
+ counted[a.Token] = a
+ if a.AdmittedAt < from {
+ from = a.AdmittedAt
+ }
+ }
+ }
+ for _, a := range f.pending {
+ counted[a.Token] = a
+ if a.AdmittedAt < from {
+ from = a.AdmittedAt
+ }
+ }
+ from -= Margin
+ f.State = Stale
+ f.inDiscovery = false
+ if fail {
+ f.latch = &Latch{Fleet: f, From: from, At: m.Now, Counted: counted}
+ m.logf("fleet %d discovery latched (from %d, %d counted)", f.ID, from, len(counted))
+ return
+ }
+ m.nextUnc++
+ u := &Uncertainty{ID: m.nextUnc, Fleet: f, From: from, To: unset, DiscoveryAt: m.Now, Counted: counted}
+ f.Uncertainties = append(f.Uncertainties, u)
+ m.holdInterval(from, m.Now, m.reach(f))
+ m.logf("fleet %d discovered: U%d open from %d (%d counted)", f.ID, u.ID, from, len(counted))
+ m.maybeClose(u, m.Now)
+}
+
+// Replay is the failed discovery's one transaction.
+func (m *Model) Replay(f *Fleet) {
+ m.mustAlive("replay")
+ l := f.latch
+ if l == nil {
+ return
+ }
+ m.nextUnc++
+ u := &Uncertainty{ID: m.nextUnc, Fleet: f, From: l.From, To: unset, DiscoveryAt: l.At, Counted: l.Counted}
+ f.Uncertainties = append(f.Uncertainties, u)
+ reached := m.reach(f)
+ m.holdInterval(l.From, l.At, reached)
+ for _, a := range l.Counted {
+ if a.State == Settled {
+ m.holdInterval(a.AdmittedAt, a.SettledAt, reached)
+ }
+ }
+ if !countedUnsettled(u) {
+ u.To = m.Now + Margin
+ m.holdInterval(u.From, u.To, reached)
+ }
+ f.latch = nil // the overlay clears after the commit
+ m.logf("fleet %d replayed: U%d from %d to %d", f.ID, u.ID, u.From, u.To)
+}
+
+// TransportDone is the SDK middleware's finalize step.
+func (m *Model) TransportDone(a *Admission) {
+ if !a.Issued || a.TransportDoneAt != unset {
+ return
+ }
+ a.TransportDoneAt = m.Now
+ a.SentEndAt = m.Now
+ if a.State == Admitted {
+ a.State = TransportDone
+ } else if a.State == RecorderDone {
+ m.settle(a)
+ }
+}
+
+// RecorderStart is the helper's Start message reaching the agent.
+func (m *Model) RecorderStart(a *Admission) bool {
+ if !m.Alive || !a.Issued || a.RecorderStartedAt != unset || a.RecorderDoneAt != unset || a.Fleet.guardClosed {
+ return false
+ }
+ a.RecorderStartedAt = m.Now
+ return true
+}
+
+// RecorderDone is Finish, Abandon, or unrecorded (Start failed).
+func (m *Model) RecorderDone(a *Admission, how string) {
+ if !m.Alive || !a.Issued || a.RecorderDoneAt != unset {
+ return
+ }
+ if how != "unrecorded" && a.RecorderStartedAt == unset {
+ return
+ }
+ a.RecorderDoneAt = m.Now
+ a.How = how
+ if a.State == Admitted {
+ a.State = RecorderDone
+ } else if a.State == TransportDone {
+ m.settle(a)
+ }
+}
+
+func (m *Model) settle(a *Admission) {
+ a.State = Settled
+ a.SettledAt = a.TransportDoneAt
+ if a.RecorderDoneAt > a.SettledAt {
+ a.SettledAt = a.RecorderDoneAt
+ }
+ if a.SettledAt < m.Now {
+ a.SettledAt = m.Now
+ }
+ f := a.Fleet
+ if u := m.openUncertainty(f); u != nil {
+ m.holdInterval(a.AdmittedAt, a.SettledAt, m.reach(f))
+ m.maybeClose(u, a.SettledAt)
+ }
+ m.logf("T%d settled", a.Token)
+}
+
+// HelperDies: the helper's death proven by waitpid. It is a change; the
+// guard closes, the socket drains, transports are lost and never-registered
+// recorders are unrecorded. Registered workers are kept.
+func (m *Model) HelperDies(f *Fleet, discoveryFails bool) {
+ if !f.helperAlive {
+ return
+ }
+ f.helperAlive = false
+ f.DiedAt = m.Now
+ if !m.Alive {
+ return // the agent is gone; recovery handles it
+ }
+ m.Change(f)
+ m.Discover(f, discoveryFails)
+ f.guardClosed = true
+ for _, a := range f.Admissions {
+ if !a.Issued || a.State.Terminal() {
+ continue
+ }
+ if a.TransportDoneAt == unset {
+ a.TransportDoneAt = m.Now
+ a.SentEndAt = m.Now
+ if a.State == Admitted {
+ a.State = TransportDone
+ }
+ }
+ if a.RecorderDoneAt == unset && a.RecorderStartedAt == unset {
+ a.RecorderDoneAt = m.Now
+ a.How = "unrecorded"
+ }
+ if a.TransportDoneAt != unset && a.RecorderDoneAt != unset {
+ m.settle(a)
+ }
+ }
+}
+
+// StopHelper is closeNow's clean path: only when no transport is in flight.
+func (m *Model) StopHelper(f *Fleet) bool {
+ if !f.helperAlive {
+ return false
+ }
+ for _, a := range f.Admissions {
+ if a.Issued && a.TransportDoneAt == unset {
+ return false
+ }
+ }
+ m.Look(f)
+ f.helperAlive = false
+ f.helperStopped = true
+ f.guardClosed = true
+ for _, a := range f.Admissions { // Start can no longer arrive
+ if a.Issued && !a.State.Terminal() && a.RecorderDoneAt == unset && a.RecorderStartedAt == unset {
+ a.RecorderDoneAt = m.Now
+ a.How = "unrecorded"
+ if a.TransportDoneAt != unset {
+ m.settle(a)
+ }
+ }
+ }
+ if f.State != Closed {
+ f.State = Closing
+ }
+ return true
+}
+
+// Close marks the fleet closed when every admission is terminal and no
+// discovery is uncommitted; the closure carries a pending replay.
+func (m *Model) Close(f *Fleet) bool {
+ m.mustAlive("close")
+ if f.helperAlive || f.State == Closed {
+ return false
+ }
+ for _, a := range f.Admissions {
+ if a.unsettled() {
+ return false
+ }
+ }
+ if len(f.pending) > 0 {
+ return false
+ }
+ if f.latch != nil {
+ m.Replay(f)
+ }
+ f.State = Closed
+ m.logf("fleet %d closed", f.ID)
+ return true
+}
+
+// Acknowledge is root-first and copies the predecessor's effective set.
+func (m *Model) Acknowledge(g *Fleet) bool {
+ m.mustAlive("acknowledge")
+ p := g.Pred
+ if p == nil || g.Acked || p.State != Closed || !(p.Acked || p.Pred == nil) {
+ return false
+ }
+ for _, u := range effective(p) {
+ g.Inherited = append(g.Inherited, u)
+ if !u.open() {
+ m.holdInterval(u.From, u.To, g.Owners)
+ }
+ }
+ g.Acked = true
+ m.logf("fleet %d acknowledged fleet %d", g.ID, p.ID)
+ return true
+}
+
+// Crash kills the agent. In-memory state is lost; a registration whose
+// writer was running may or may not have landed; helpers may survive with
+// the fence.
+func (m *Model) Crash(helperSurvives func(*Fleet) bool) {
+ if !m.Alive {
+ return
+ }
+ m.Alive = false
+ m.Recovered = false
+ m.CrashedAt = m.Now
+ for _, f := range m.Fleets {
+ for tok, a := range f.pending {
+ a.writerRunning = false
+ if m.rnd.Intn(2) == 0 { // the commit landed before the crash
+ a.Durable = true
+ a.State = Admitted
+ f.Admissions = append(f.Admissions, a)
+ } else {
+ a.State = Cancelled
+ a.How = "lost-with-process"
+ a.SettledAt = m.Now
+ }
+ delete(f.pending, tok)
+ }
+ f.latch = nil
+ f.inDiscovery = false
+ f.guardClosed = true
+ if f.helperAlive && !helperSurvives(f) {
+ f.helperAlive = false
+ f.DiedAt = m.Now
+ for _, a := range f.Admissions {
+ if a.Issued && a.SentEndAt == unset {
+ a.SentEndAt = m.Now
+ }
+ }
+ }
+ }
+ m.logf("agent crashed")
+}
+
+// SurvivingHelperTransport: a helper that outlived its agent still finishes
+// a transport it had in hand.
+func (m *Model) SurvivingHelperTransport(a *Admission) {
+ if m.Alive || !a.Fleet.helperAlive || !a.Issued || a.TransportDoneAt != unset {
+ return
+ }
+ a.SentEndAt = m.Now // the truth; the row is not written, the agent is dead
+}
+
+// SurvivingHelperDies ends a helper the crash left alive.
+func (m *Model) SurvivingHelperDies(f *Fleet) {
+ if f.helperAlive {
+ f.helperAlive = false
+ f.DiedAt = m.Now
+ for _, a := range f.Admissions {
+ if a.Issued && a.SentEndAt == unset {
+ a.SentEndAt = m.Now
+ }
+ }
+ }
+}
+
+// FenceFree reports whether every old producer is gone.
+func (m *Model) FenceFree() bool {
+ for _, f := range m.Fleets {
+ if f.helperAlive {
+ return false
+ }
+ }
+ return true
+}
+
+// Recover is the new process's first transaction, holding the fence.
+func (m *Model) Recover() bool {
+ if m.Alive || !m.FenceFree() {
+ return false
+ }
+ m.Alive = true
+ m.Recovered = true
+ for _, f := range m.Fleets {
+ for _, a := range f.Admissions {
+ if a.unsettled() {
+ m.terminal(a, RecoveredUnknown, "recovered-unknown")
+ m.nextUnc++
+ u := &Uncertainty{ID: m.nextUnc, Fleet: f, From: a.AdmittedAt - Margin, To: m.Now + Margin, DiscoveryAt: m.Now, Counted: map[int]*Admission{}}
+ f.Uncertainties = append(f.Uncertainties, u)
+ m.holdInterval(u.From, u.To, append(m.reach(f), a.Owner))
+ m.logf("T%d recovered-unknown, U%d", a.Token, u.ID)
+ }
+ }
+ if f.State != Closed {
+ if u := m.openUncertainty(f); u != nil {
+ u.To = m.Now + Margin
+ m.holdInterval(u.From, u.To, m.reach(f))
+ } else {
+ from := f.OpenedAt
+ for _, a := range f.Admissions {
+ if a.AdmittedAt < from {
+ from = a.AdmittedAt
+ }
+ }
+ m.nextUnc++
+ u := &Uncertainty{ID: m.nextUnc, Fleet: f, From: from - Margin, To: m.Now + Margin, DiscoveryAt: m.Now, Counted: map[int]*Admission{}}
+ f.Uncertainties = append(f.Uncertainties, u)
+ m.holdInterval(u.From, u.To, m.reach(f))
+ }
+ f.State = Closed
+ f.helperAlive = false
+ f.DiscoveredAt = m.Now
+ }
+ }
+ // root-first acknowledgement of every fleet left unacknowledged
+ for _, f := range m.Fleets {
+ if f.Pred != nil && !f.Acked {
+ m.Acknowledge(f)
+ }
+ }
+ m.logf("recovered")
+ return true
+}
+
+func (m *Model) mustAlive(op string) {
+ if !m.Alive {
+ panic("model: " + op + " while the agent is dead")
+ }
+}
+
+// Complete is the publisher's answer for an owner's day: no hold, no
+// barrier, no pending discovery reaching the owner. It is only asked of a
+// live agent.
+func (m *Model) Complete(o Owner, day int64) bool {
+ if !m.Alive {
+ panic("model: publication asked of a dead agent")
+ }
+ if m.Holds[day][o] {
+ return false
+ }
+ for _, a := range m.Admissions {
+ if a.Durable && a.Issued && a.unsettled() && a.Owner == o && dayOf(a.AdmittedAt) <= day {
+ return false
+ }
+ }
+ for _, f := range m.Fleets {
+ if f.latch != nil && dayOf(f.latch.From) <= day {
+ for _, r := range m.reach(f) {
+ if r == o {
+ return false
+ }
+ }
+ }
+ }
+ return true
+}
+
+// Check is the invariant pass the tests run after every step: a Violation
+// is returned for the first failure found.
+func (m *Model) Check() error {
+ if !m.Alive {
+ return nil
+ }
+ today := dayOf(m.Now)
+ // barrier: an issued, unsettled admission bars its owner from its day on
+ for _, a := range m.Admissions {
+ if a.Durable && a.Issued && a.unsettled() {
+ for d := dayOf(a.AdmittedAt); d <= today; d++ {
+ if m.Complete(a.Owner, d) {
+ return fmt.Errorf("barrier: T%d unsettled but %s day %d complete", a.Token, a.Owner, d)
+ }
+ }
+ }
+ }
+ // hazard: a transport that overlapped a true change, once the ledger
+ // knows of the change, has its days held for its owner
+ for _, a := range m.Admissions {
+ if !a.Issued || a.SentAt == unset {
+ continue
+ }
+ f := a.Fleet
+ known := f.DiscoveredAt != unset || (m.Recovered && m.CrashedAt != unset && a.AdmittedAt <= m.CrashedAt)
+ if !known {
+ continue
+ }
+ end := a.SentEndAt
+ if end == unset && a.State.Terminal() {
+ end = a.SettledAt
+ }
+ if end == unset {
+ end = m.Now
+ }
+ hazard := false
+ if f.ChangedAt != unset && end >= f.ChangedAt {
+ hazard = true
+ }
+ if m.CrashedAt != unset && a.AdmittedAt <= m.CrashedAt && (a.SettledAt == unset || a.SettledAt >= m.CrashedAt) {
+ hazard = true
+ }
+ if !hazard {
+ continue
+ }
+ for d := dayOf(a.SentAt); d <= dayOf(end); d++ {
+ if m.Complete(a.Owner, d) {
+ return fmt.Errorf("hazard: T%d (sent %d..%d, change %d) but %s day %d complete", a.Token, a.SentAt, end, f.ChangedAt, a.Owner, d)
+ }
+ }
+ }
+ return nil
+}
+
+// CheckQuiescent is the end-state pass: every fleet's owners hold every
+// interval of every uncertainty on its whole chain, and days nobody has a
+// reason to hold are complete.
+func (m *Model) CheckQuiescent() error {
+ if err := m.Check(); err != nil {
+ return err
+ }
+ for _, g := range m.Fleets {
+ for p := g; p != nil; p = p.Pred {
+ for _, u := range p.Uncertainties {
+ if u.open() {
+ return fmt.Errorf("U%d still open at quiescence", u.ID)
+ }
+ for d := dayOf(u.From); d <= dayOf(u.To); d++ {
+ for _, o := range g.Owners {
+ if !m.Holds[d][o] {
+ return fmt.Errorf("reach: fleet %d's owner %s not held for U%d (fleet %d) day %d", g.ID, o, u.ID, p.ID, d)
+ }
+ }
+ }
+ }
+ }
+ }
+ // liveness: a day with no uncertainty reaching the owner is complete
+ owners := map[Owner]bool{}
+ for _, f := range m.Fleets {
+ for _, o := range f.Owners {
+ owners[o] = true
+ }
+ }
+ for o := range owners {
+ for d := int64(0); d <= dayOf(m.Now); d++ {
+ if m.Complete(o, d) {
+ continue
+ }
+ if !m.reasonToHold(o, d) {
+ return fmt.Errorf("liveness: %s day %d incomplete with no uncertainty reaching it", o, d)
+ }
+ }
+ }
+ return nil
+}
+
+func (m *Model) reasonToHold(o Owner, d int64) bool {
+ for _, g := range m.Fleets {
+ mine := false
+ for _, go_ := range g.Owners {
+ if go_ == o {
+ mine = true
+ }
+ }
+ if !mine {
+ continue
+ }
+ for p := g; p != nil; p = p.Pred {
+ for _, u := range p.Uncertainties {
+ if dayOf(u.From) <= d && d <= dayOf(u.To) {
+ return true
+ }
+ }
+ }
+ }
+ return false
+}
diff --git a/internal/ops/admissionmodel/model_test.go b/internal/ops/admissionmodel/model_test.go
new file mode 100644
index 000000000..179b82abe
--- /dev/null
+++ b/internal/ops/admissionmodel/model_test.go
@@ -0,0 +1,625 @@
+package admissionmodel
+
+import (
+ "fmt"
+ "os"
+ "strconv"
+ "testing"
+)
+
+const (
+ alice Owner = "alice/app"
+ bob Owner = "bob/app"
+ carol Owner = "carol/app"
+)
+
+// at is day d (1-based) at hh:mm.
+func at(d int, hh, mm int) Time { return Time(d-1)*Day + Time(hh)*60 + Time(mm) }
+
+func must(t *testing.T, m *Model) {
+ t.Helper()
+ if err := m.Check(); err != nil {
+ t.Fatalf("%v\n%s", err, dump(m))
+ }
+}
+
+func dump(m *Model) string {
+ s := ""
+ for _, l := range m.Log {
+ s += l + "\n"
+ }
+ return s
+}
+
+func held(t *testing.T, m *Model, o Owner, days ...int) {
+ t.Helper()
+ for _, d := range days {
+ if m.Complete(o, int64(d-1)) {
+ t.Fatalf("%s day %d complete, expected held\n%s", o, d, dump(m))
+ }
+ }
+}
+
+func complete(t *testing.T, m *Model, o Owner, days ...int) {
+ t.Helper()
+ for _, d := range days {
+ if !m.Complete(o, int64(d-1)) {
+ t.Fatalf("%s day %d incomplete, expected complete\n%s", o, d, dump(m))
+ }
+ }
+}
+
+// issue registers and commits an admission on a healthy fleet.
+func issue(t *testing.T, m *Model, f *Fleet, o Owner) *Admission {
+ t.Helper()
+ a := m.Register(f, o)
+ if a == nil {
+ t.Fatalf("registration refused\n%s", dump(m))
+ }
+ m.WriterReturns(a, "commit")
+ if !a.Issued {
+ t.Fatalf("T%d not issued\n%s", a.Token, dump(m))
+ }
+ return a
+}
+
+func finish(m *Model, a *Admission) {
+ m.TransportDone(a)
+ m.RecorderStart(a)
+ m.RecorderDone(a, "finished")
+}
+
+// A settled admission on an unchanged fleet writes no permanent hold: its
+// day becomes complete when the barrier lifts.
+func TestUnchangedFleetHoldsNothing(t *testing.T) {
+ m := New(1)
+ m.Now = at(1, 10, 0)
+ f := m.Build(alice)
+ a := issue(t, m, f, alice)
+ held(t, m, alice, 1) // the barrier
+ must(t, m)
+ m.Advance(5)
+ finish(m, a)
+ must(t, m)
+ complete(t, m, alice, 1)
+ if len(f.Uncertainties) != 0 {
+ t.Fatal("an uncertainty on an unchanged fleet")
+ }
+}
+
+// The multiple-midnights sequence in both completion orders: admitted on
+// the 2nd, the change at 23:58:10, sent on the 3rd, finished on the 4th;
+// the 2nd, 3rd and 4th are held whichever participant finished last.
+func TestThreeMidnightsBothOrders(t *testing.T) {
+ for _, transportLast := range []bool{false, true} {
+ m := New(1)
+ m.Now = at(1, 9, 0)
+ f := m.Build(alice)
+ m.Now = at(2, 23, 57)
+ a := issue(t, m, f, alice)
+ m.Now = at(2, 23, 58)
+ m.Change(f)
+ m.Now = at(3, 0, 1)
+ m.Look(f) // the discovery
+ must(t, m)
+ m.Now = at(3, 12, 0)
+ if transportLast {
+ m.RecorderStart(a)
+ m.RecorderDone(a, "finished")
+ } else {
+ m.TransportDone(a)
+ }
+ must(t, m)
+ m.Now = at(4, 0, 2)
+ if transportLast {
+ m.TransportDone(a)
+ } else {
+ m.RecorderStart(a)
+ m.RecorderDone(a, "finished")
+ }
+ must(t, m)
+ held(t, m, alice, 2, 3, 4)
+ if a.State != Settled || f.Uncertainties[0].open() {
+ t.Fatalf("not settled and closed: %s\n%s", a.State, dump(m))
+ }
+ if to := f.Uncertainties[0].To; to != at(4, 0, 3) {
+ t.Fatalf("closed at %d, want settlement+margin %d", to, at(4, 0, 3))
+ }
+ }
+}
+
+// Astra's fifth-review sequence: proof, discovery, commit, a send on the
+// 4th. The commit after the discovery began is refused (unissued) and the
+// uncertainty that counted it closes once.
+func TestCommitAfterDiscoveryIsUnissued(t *testing.T) {
+ m := New(1)
+ m.Now = at(2, 23, 57)
+ f := m.Build(alice)
+ a := m.Register(f, alice)
+ m.Now = at(2, 23, 58)
+ m.Discover(f, false)
+ if len(f.Uncertainties[0].Counted) != 1 {
+ t.Fatal("the registration was not counted")
+ }
+ if !f.Uncertainties[0].open() {
+ t.Fatal("closed while a counted registration is unresolved")
+ }
+ m.Now = at(3, 0, 5)
+ m.WriterReturns(a, "commit")
+ if a.Issued || a.State != Unissued {
+ t.Fatalf("commit after discovery: issued=%v state=%s", a.Issued, a.State)
+ }
+ if f.Uncertainties[0].open() {
+ t.Fatal("the uncertainty did not close on the empty settlement")
+ }
+ must(t, m)
+}
+
+// Review 7's sequence: the budget expires while the writer still runs, the
+// discovery counts the registration, then the writer commits. One final
+// outcome, unissued, and the uncertainty closes once.
+func TestBudgetExpiryThenCommit(t *testing.T) {
+ m := New(1)
+ m.Now = at(2, 23, 58)
+ f := m.Build(alice)
+ a := m.Register(f, alice)
+ m.BudgetExpires(a)
+ m.Discover(f, false)
+ u := f.Uncertainties[0]
+ if !u.open() {
+ t.Fatal("closed with a counted registration unresolved")
+ }
+ m.WriterReturns(a, "commit")
+ if a.State != Unissued || a.Issued {
+ t.Fatalf("state %s issued %v", a.State, a.Issued)
+ }
+ if u.open() {
+ t.Fatal("uncertainty still open after the empty settlement")
+ }
+ must(t, m)
+}
+
+// A definite failure cancels; an ambiguous one is read back either way.
+func TestCancellationAndAmbiguity(t *testing.T) {
+ m := New(1)
+ m.Now = at(1, 10, 0)
+ f := m.Build(alice)
+ a := m.Register(f, alice)
+ m.Discover(f, false)
+ m.WriterReturns(a, "fail")
+ if a.State != Cancelled || f.Uncertainties[0].open() || len(f.pending) != 0 {
+ t.Fatalf("cancel: %s open=%v pending=%d", a.State, f.Uncertainties[0].open(), len(f.pending))
+ }
+ for seed := int64(1); seed <= 8; seed++ {
+ m := New(seed)
+ m.Now = at(1, 10, 0)
+ f := m.Build(alice)
+ a := m.Register(f, alice)
+ m.WriterReturns(a, "ambiguous")
+ if a.Issued || (a.State != Unissued && a.State != Cancelled) || len(f.pending) != 0 {
+ t.Fatalf("ambiguous: issued=%v state=%s pending=%d", a.Issued, a.State, len(f.pending))
+ }
+ must(t, m)
+ }
+}
+
+// Discovery with no admission pending closes at once and materialises the
+// margin's extra day across midnight.
+func TestZeroPendingClosesAtOnceAcrossMidnight(t *testing.T) {
+ m := New(1)
+ m.Now = at(1, 9, 0)
+ f := m.Build(alice)
+ m.Now = at(1, 23, 59)
+ m.Discover(f, false)
+ u := f.Uncertainties[0]
+ if u.open() || u.To != at(2, 0, 0) {
+ t.Fatalf("open=%v to=%d", u.open(), u.To)
+ }
+ held(t, m, alice, 1, 2)
+ must(t, m)
+}
+
+// A discovery whose write fails with nothing pending bars the day through
+// the latch until the replay commits; replay with nothing pending closes
+// at the replay instant plus the margin.
+func TestFailedDiscoveryLatchBarsUntilReplay(t *testing.T) {
+ m := New(1)
+ m.Now = at(1, 9, 0)
+ f := m.Build(alice)
+ m.Now = at(1, 12, 0)
+ m.Discover(f, true)
+ held(t, m, alice, 1)
+ if m.Register(f, alice) != nil {
+ t.Fatal("a request admitted while the discovery is uncommitted")
+ }
+ m.Now = at(1, 12, 30)
+ m.Replay(f)
+ u := f.Uncertainties[0]
+ if u.open() || u.To != at(1, 12, 31) || f.latch != nil {
+ t.Fatalf("open=%v to=%d latch=%v", u.open(), u.To, f.latch != nil)
+ }
+ held(t, m, alice, 1)
+ must(t, m)
+}
+
+// Review 6's P1: T counted at 23:58:10 on the 2nd, the write failing, T
+// settling at 00:02 on the 4th, the replay at 00:03: the 2nd, 3rd and 4th
+// are held through T's interval, the close is at 00:04, and a successor
+// built after the replay backfills the closed interval.
+func TestFailedDiscoveryReplayKeepsSettlements(t *testing.T) {
+ m := New(1)
+ m.Now = at(2, 9, 0)
+ f := m.Build(alice)
+ m.Now = at(2, 23, 58)
+ a := issue(t, m, f, alice)
+ m.Change(f)
+ m.Discover(f, true)
+ must(t, m)
+ m.Now = at(4, 0, 2)
+ finish(m, a)
+ if a.State != Settled {
+ t.Fatal("not settled")
+ }
+ must(t, m) // the latch still bars
+ m.Now = at(4, 0, 3)
+ m.Replay(f)
+ u := f.Uncertainties[0]
+ if u.open() || u.To != at(4, 0, 4) {
+ t.Fatalf("open=%v to=%d", u.open(), u.To)
+ }
+ held(t, m, alice, 2, 3, 4)
+ m.Now = at(4, 9, 0)
+ g := m.Build(bob)
+ held(t, m, bob, 2, 3, 4)
+ if g.Pred != f {
+ t.Fatal("chain")
+ }
+ must(t, m)
+}
+
+// Astra's construction sequence: A has U open and an admission unsettled;
+// B is built and closes unopened without acknowledging; C is built against
+// B; A's settlement on the 4th reaches B's and C's owners; A closes, B
+// acknowledges A root-first, then C acknowledges B and inherits U.
+func TestAncestryReachAndRootFirstAcknowledgement(t *testing.T) {
+ m := New(1)
+ m.Now = at(2, 9, 0)
+ A := m.Build(alice)
+ m.Now = at(2, 23, 57)
+ a := issue(t, m, A, alice)
+ m.Now = at(2, 23, 58)
+ m.Change(A)
+ m.Look(A)
+ U := A.Uncertainties[0]
+ m.Now = at(2, 23, 59)
+ B := m.Build(bob)
+ held(t, m, bob, 2) // backfill of the open interval so far
+ m.Now = at(3, 0, 1)
+ if !m.StopHelper(B) || !m.Close(B) {
+ t.Fatalf("B did not close unopened\n%s", dump(m))
+ }
+ C := m.Build(carol)
+ held(t, m, carol, 2, 3)
+ m.Now = at(4, 0, 2)
+ finish(m, a)
+ if U.open() {
+ t.Fatal("U still open")
+ }
+ held(t, m, alice, 2, 3, 4)
+ held(t, m, bob, 2, 3, 4)
+ held(t, m, carol, 2, 3, 4)
+ if m.Acknowledge(C) {
+ t.Fatal("C acknowledged B before B acknowledged A")
+ }
+ m.StopHelper(A)
+ if !m.Close(A) || !m.Acknowledge(B) || !m.Acknowledge(C) {
+ t.Fatalf("close/ack\n%s", dump(m))
+ }
+ found := false
+ for _, u := range C.Inherited {
+ if u == U {
+ found = true
+ }
+ }
+ if !found {
+ t.Fatal("C did not inherit U")
+ }
+ must(t, m)
+}
+
+// The request completed before the change was noticed is inside the lower
+// bound: T1 is sent and settled on the 1st after the last clean look, the
+// authority changes during its transport, T2 is admitted on the 2nd, and
+// the discovery's bound reaches back to the clean look, so the 1st is held.
+func TestLowerBoundReachesCompletedRequest(t *testing.T) {
+ m := New(1)
+ m.Now = at(1, 9, 0)
+ f := m.Build(alice)
+ m.Now = at(1, 23, 0)
+ m.Look(f)
+ m.Now = at(1, 23, 40)
+ t1 := issue(t, m, f, alice)
+ m.Now = at(1, 23, 45)
+ m.Change(f)
+ m.Now = at(1, 23, 50)
+ finish(m, t1)
+ complete(t, m, alice, 1) // nothing is known yet
+ m.Now = at(2, 0, 30)
+ issue(t, m, f, alice)
+ m.Now = at(2, 1, 0)
+ m.Look(f)
+ u := f.Uncertainties[0]
+ if u.From != at(1, 22, 59) {
+ t.Fatalf("from %d, want the clean look minus the margin %d", u.From, at(1, 22, 59))
+ }
+ held(t, m, alice, 1, 2)
+ must(t, m)
+}
+
+// Helper death: before a recorder registered, the admission ends
+// unrecorded after the proven exit; with a registered worker it is kept
+// until the worker ends; a Start after the guard closed is refused.
+func TestHelperDeathAndRecorderGuard(t *testing.T) {
+ m := New(1)
+ m.Now = at(1, 10, 0)
+ f := m.Build(alice)
+ never := issue(t, m, f, alice)
+ live := issue(t, m, f, alice)
+ m.RecorderStart(live)
+ m.Advance(1)
+ m.HelperDies(f, false)
+ if never.State != Settled || never.How != "unrecorded" {
+ t.Fatalf("never-registered: %s %q", never.State, never.How)
+ }
+ if live.State != TransportDone {
+ t.Fatalf("registered worker not kept: %s", live.State)
+ }
+ if m.RecorderStart(live) {
+ t.Fatal("Start accepted after the guard closed")
+ }
+ held(t, m, alice, 1)
+ u := f.Uncertainties[0]
+ if !u.open() {
+ t.Fatal("closed with the worker still running")
+ }
+ m.Advance(1)
+ m.RecorderDone(live, "abandoned")
+ if live.State != Settled || u.open() {
+ t.Fatalf("after abandon: %s open=%v", live.State, u.open())
+ }
+ must(t, m)
+}
+
+// The fence: a helper that outlives its agent still holds it, finishes a
+// transport, and recovery cannot run until it is gone; then the admission
+// is recovered-unknown over an interval that covers that transport.
+func TestFenceOutlivesAgent(t *testing.T) {
+ m := New(1)
+ m.Now = at(1, 23, 50)
+ f := m.Build(alice)
+ a := issue(t, m, f, alice)
+ m.Crash(func(*Fleet) bool { return true })
+ if m.Recover() {
+ t.Fatal("recovered over a surviving helper")
+ }
+ m.Now = at(2, 0, 10)
+ m.SurvivingHelperTransport(a)
+ m.Now = at(2, 0, 20)
+ m.SurvivingHelperDies(f)
+ if !m.Recover() {
+ t.Fatal("recovery refused with the fence free")
+ }
+ if a.State != RecoveredUnknown {
+ t.Fatalf("state %s", a.State)
+ }
+ held(t, m, alice, 1, 2)
+ must(t, m)
+ if err := m.CheckQuiescent(); err != nil {
+ t.Fatal(err)
+ }
+}
+
+// Routine replacement of a healthy chain holds no day.
+func TestHealthyChainContributesNothing(t *testing.T) {
+ m := New(1)
+ m.Now = at(1, 10, 0)
+ A := m.Build(alice)
+ a := issue(t, m, A, alice)
+ m.Advance(1)
+ finish(m, a)
+ m.StopHelper(A)
+ m.Close(A)
+ m.Advance(1)
+ B := m.Build(alice, bob)
+ m.Acknowledge(B)
+ complete(t, m, alice, 1)
+ complete(t, m, bob, 1)
+ m.StopHelper(B)
+ m.Close(B)
+ if err := m.CheckQuiescent(); err != nil {
+ t.Fatal(err)
+ }
+}
+
+// Randomised schedules: every action the design names, in random
+// interleavings over several days, with the invariants checked after each
+// step and the end state checked at quiescence.
+func TestRandomSchedules(t *testing.T) {
+ seeds := 400
+ if s := os.Getenv("ADMISSION_MODEL_SEEDS"); s != "" {
+ seeds, _ = strconv.Atoi(s)
+ }
+ for seed := 1; seed <= seeds; seed++ {
+ if err := runSchedule(int64(seed), 120); err != nil {
+ t.Fatalf("seed %d: %v", seed, err)
+ }
+ }
+}
+
+func runSchedule(seed int64, steps int) (err error) {
+ m := New(seed)
+ defer func() {
+ if r := recover(); r != nil {
+ err = fmt.Errorf("panic %v\n%s", r, dump(m))
+ }
+ }()
+ owners := []Owner{alice, bob, carol}
+ m.Now = at(1, 9, 0)
+ m.Build(alice)
+ tip := func() *Fleet { return m.Fleets[len(m.Fleets)-1] }
+ pick := func(xs []*Admission) *Admission {
+ if len(xs) == 0 {
+ return nil
+ }
+ return xs[m.rnd.Intn(len(xs))]
+ }
+ for i := 0; i < steps; i++ {
+ m.Advance(Time(m.rnd.Intn(90)))
+ var regs, issued, inflight, recorderless []*Admission
+ for _, a := range m.Admissions {
+ switch {
+ case a.State == Registered:
+ regs = append(regs, a)
+ case a.Issued && !a.State.Terminal():
+ issued = append(issued, a)
+ if a.TransportDoneAt == unset {
+ inflight = append(inflight, a)
+ }
+ if a.RecorderDoneAt == unset {
+ recorderless = append(recorderless, a)
+ }
+ }
+ }
+ if !m.Alive {
+ switch m.rnd.Intn(4) {
+ case 0:
+ if a := pick(inflight); a != nil {
+ m.SurvivingHelperTransport(a)
+ }
+ case 1:
+ m.SurvivingHelperDies(m.Fleets[m.rnd.Intn(len(m.Fleets))])
+ default:
+ m.Recover()
+ }
+ continue
+ }
+ f := tip()
+ switch m.rnd.Intn(20) {
+ case 0, 1, 2, 3:
+ m.Register(f, f.Owners[m.rnd.Intn(len(f.Owners))])
+ case 4, 5:
+ if a := pick(regs); a != nil {
+ m.WriterReturns(a, []string{"commit", "commit", "commit", "fail", "ambiguous"}[m.rnd.Intn(5)])
+ }
+ case 6:
+ if a := pick(regs); a != nil {
+ m.BudgetExpires(a)
+ }
+ case 7, 8:
+ if a := pick(inflight); a != nil {
+ m.TransportDone(a)
+ }
+ case 9, 10:
+ if a := pick(recorderless); a != nil {
+ if a.RecorderStartedAt == unset {
+ if m.rnd.Intn(5) == 0 {
+ m.RecorderDone(a, "unrecorded")
+ } else {
+ m.RecorderStart(a)
+ }
+ } else {
+ m.RecorderDone(a, []string{"finished", "abandoned"}[m.rnd.Intn(2)])
+ }
+ }
+ case 11:
+ g := m.Fleets[m.rnd.Intn(len(m.Fleets))]
+ if m.rnd.Intn(2) == 0 {
+ m.Change(g)
+ }
+ m.Look(g)
+ case 12:
+ g := m.Fleets[m.rnd.Intn(len(m.Fleets))]
+ m.Change(g)
+ m.Discover(g, m.rnd.Intn(3) == 0)
+ case 13:
+ for _, g := range m.Fleets {
+ if g.latch != nil && m.rnd.Intn(2) == 0 {
+ m.Replay(g)
+ }
+ }
+ case 14:
+ m.HelperDies(m.Fleets[m.rnd.Intn(len(m.Fleets))], m.rnd.Intn(3) == 0)
+ case 15:
+ g := m.Fleets[m.rnd.Intn(len(m.Fleets))]
+ m.StopHelper(g)
+ case 16:
+ for _, g := range m.Fleets {
+ m.Close(g)
+ }
+ case 17:
+ for _, g := range m.Fleets {
+ m.Acknowledge(g)
+ }
+ case 18:
+ if f.State != Open || m.rnd.Intn(3) == 0 {
+ n := 1 + m.rnd.Intn(2)
+ var os_ []Owner
+ for k := 0; k < n; k++ {
+ os_ = append(os_, owners[m.rnd.Intn(len(owners))])
+ }
+ m.Build(os_...)
+ }
+ case 19:
+ if m.rnd.Intn(6) == 0 {
+ m.Crash(func(*Fleet) bool { return m.rnd.Intn(2) == 0 })
+ }
+ }
+ if err := m.Check(); err != nil {
+ return fmt.Errorf("step %d: %v\n%s", i, err, dump(m))
+ }
+ }
+ // quiesce
+ if !m.Alive {
+ for _, g := range m.Fleets {
+ m.SurvivingHelperDies(g)
+ }
+ m.Recover()
+ }
+ for _, a := range m.Admissions {
+ if a.State == Registered {
+ m.WriterReturns(a, "commit")
+ }
+ }
+ m.Advance(1)
+ for _, a := range m.Admissions {
+ if a.Issued && !a.State.Terminal() {
+ m.TransportDone(a)
+ if a.RecorderDoneAt == unset {
+ if a.RecorderStartedAt == unset && !a.Fleet.guardClosed {
+ m.RecorderStart(a)
+ }
+ if a.RecorderStartedAt != unset {
+ m.RecorderDone(a, "finished")
+ } else {
+ m.RecorderDone(a, "unrecorded")
+ }
+ }
+ }
+ }
+ for _, g := range m.Fleets {
+ if g.latch != nil {
+ m.Replay(g)
+ }
+ m.StopHelper(g)
+ }
+ for pass := 0; pass < len(m.Fleets)+1; pass++ {
+ for _, g := range m.Fleets {
+ m.Close(g)
+ m.Acknowledge(g)
+ }
+ }
+ if err := m.CheckQuiescent(); err != nil {
+ return fmt.Errorf("quiescent: %v\n%s", err, dump(m))
+ }
+ return nil
+}