Add inert resource budgets with consent-bound reservations and evidence holds
11 files changed,
+2772
−0
internal/console/resourcebudget_preference_test.go+108 −0internal/resourcebudget/amount.go+154 −0internal/resourcebudget/amount_test.go+22 −0internal/resourcebudget/basis.go+198 −0internal/resourcebudget/core.go+305 −0internal/resourcebudget/core_test.go+726 −0internal/resourcebudget/recovery_test.go+254 −0internal/resourcebudget/reservation.go+271 −0internal/resourcebudget/schema.go+40 −0internal/resourcebudget/settlement.go+375 −0internal/resourcebudget/verify.go+319 −0
CI passed — run details
diff --git a/internal/console/resourcebudget_preference_test.go b/internal/console/resourcebudget_preference_test.go| new file mode 100644 |
| index 000000000..88edc123f |
| --- /dev/null |
| +++ b/internal/console/resourcebudget_preference_test.go |
| @@ -0,0 +1,108 @@ |
| +package console |
| + |
| +import ( |
| + "database/sql" |
| + rb "github.com/carlosframework/platform/internal/resourcebudget" |
| + "path/filepath" |
| + "testing" |
| + "time" |
| +) |
| + |
| +func TestResourceBudgetPreferenceOrdering(t *testing.T) { |
| + for _, order := range []string{"reserve-first", "off-first", "resave-before-claim"} { |
| + t.Run(order, func(t *testing.T) { |
| + path := filepath.Join(t.TempDir(), "console.db") |
| + s := newOverageStore(t, path) |
| + now := time.Date(2026, 10, 3, 12, 0, 0, 0, time.UTC) |
| + seedOverageAccount(t, s, 42, overageOwner, now) |
| + // Production remains NORMAL. Both test handles pin their own FULL connection. |
| + if _, e := s.db.Exec(`PRAGMA synchronous=FULL`); e != nil { |
| + t.Fatal(e) |
| + } |
| + other := newOverageStore(t, path) |
| + if _, e := other.db.Exec(`PRAGMA synchronous=FULL`); e != nil { |
| + t.Fatal(e) |
| + } |
| + if _, e := s.changeOveragePreference(overageChange(42, overageOwner, 0, true, 1), now); e != nil { |
| + t.Fatal(e) |
| + } |
| + ev := overageEvidenceForTest() |
| + identity := rb.Identity{Terms: ev.TermsVersion, Book: ev.PriceBook, Currency: ev.Currency} |
| + loader := rb.GateFunc(func(tx *sql.Tx, a, m, subject string) (rb.Gate, error) { |
| + p, e := loadOveragePreferenceTx(tx, 42) |
| + return rb.Gate{Epoch: 1, Eligible: a == "42" && subject == "app", On: p.RequestedOn, Consent: p.Revision, Approved: true, Offer: ev.OfferDigest, StoredOffer: p.Evidence.OfferDigest, Identity: identity, Supported: map[rb.Resource]bool{rb.Awake: true}, Basis: map[rb.Resource]rb.BasisFact{rb.Awake: {Revision: 1, Digest: "synthetic", Identity: identity}}}, e |
| + }) |
| + req := func(id string) rb.Request { |
| + return rb.Request{ID: id, Account: "42", Month: "2026-10", Subject: "app", Epoch: 1} |
| + } |
| + tx, e := s.db.Begin() |
| + if e != nil { |
| + t.Fatal(e) |
| + } |
| + if e = rb.Migrate(tx); e != nil { |
| + t.Fatal(e) |
| + } |
| + if _, e = rb.InstallBasis(tx, loader, rb.Basis{Request: req("basis"), Revision: 1, Identity: identity, Digest: "synthetic", Lines: []rb.Line{{Resource: rb.Awake, Amount: "0"}}}, now); e != nil { |
| + t.Fatal(e) |
| + } |
| + if _, e = rb.InstallPaidLimit(tx, loader, rb.PaidLimit{Request: req("limit"), Resource: rb.Awake, Consent: 1, Offer: ev.OfferDigest, Identity: identity, Ceiling: "10", From: now, Until: now.Add(time.Hour)}, now); e != nil { |
| + t.Fatal(e) |
| + } |
| + if e = tx.Commit(); e != nil { |
| + t.Fatal(e) |
| + } |
| + r := rb.ReserveRequest{Request: req("reserve"), Reservation: "res", Lines: []rb.Line{{Resource: rb.Awake, Amount: "1"}}, BasisRevision: 1, Consent: 1, AllowPaid: true, From: now, Until: now.Add(time.Minute), ClaimBy: now.Add(time.Second)} |
| + if order == "off-first" { |
| + if _, e = s.changeOveragePreference(overageChange(42, overageOwner, 1, false, 2), now); e != nil { |
| + t.Fatal(e) |
| + } |
| + tx, _ = other.db.Begin() |
| + defer tx.Rollback() |
| + if _, e = rb.Reserve(tx, loader, r, now); e == nil { |
| + t.Fatal("OFF admitted paid reserve") |
| + } |
| + return |
| + } |
| + tx, _ = s.db.Begin() |
| + if _, e = rb.Reserve(tx, loader, r, now); e != nil { |
| + t.Fatal(e) |
| + } |
| + if order == "reserve-first" { |
| + started := make(chan struct{}) |
| + done := make(chan error, 1) |
| + go func() { |
| + close(started) |
| + _, err := other.changeOveragePreference(overageChange(42, overageOwner, 1, false, 2), now) |
| + done <- err |
| + }() |
| + <-started |
| + if e = tx.Commit(); e != nil { |
| + t.Fatal(e) |
| + } |
| + select { |
| + case e = <-done: |
| + if e != nil { |
| + t.Fatal(e) |
| + } |
| + case <-time.After(6 * time.Second): |
| + t.Fatal("OFF did not complete") |
| + } |
| + } else { |
| + if e = tx.Commit(); e != nil { |
| + t.Fatal(e) |
| + } |
| + if _, e = s.changeOveragePreference(overageChange(42, overageOwner, 1, true, 2), now); e != nil { |
| + t.Fatal(e) |
| + } |
| + } |
| + tx, _ = s.db.Begin() |
| + defer tx.Rollback() |
| + if _, e = rb.Claim(tx, loader, rb.ClaimRequest{Request: req("claim"), Reservation: "res", Producer: "producer", Attempt: "attempt", ProducerEpoch: 1}, now); e == nil { |
| + t.Fatal("stale consent claim") |
| + } |
| + if e = rb.Verify(tx); e != nil { |
| + t.Fatal(e) |
| + } |
| + }) |
| + } |
| +} |
diff --git a/internal/resourcebudget/amount.go b/internal/resourcebudget/amount.go| new file mode 100644 |
| index 000000000..fa3cc2dce |
| --- /dev/null |
| +++ b/internal/resourcebudget/amount.go |
| @@ -0,0 +1,154 @@ |
| +package resourcebudget |
| + |
| +import ( |
| + "errors" |
| + "math/big" |
| +) |
| + |
| +var ErrInvalid = errors.New("resourcebudget: invalid") |
| +var ErrDenied = errors.New("resourcebudget: denied") |
| +var ErrConflict = errors.New("resourcebudget: conflict") |
| +var ErrCorrupt = errors.New("resourcebudget: corrupt") |
| + |
| +type Resource string |
| + |
| +const ( |
| + Awake Resource = "awake_ns" |
| + Transfer Resource = "transfer_bytes" |
| + Writes Resource = "replication_writes" |
| +) |
| + |
| +func (r Resource) valid() bool { return r == Awake || r == Transfer || r == Writes } |
| + |
| +type Amount string |
| + |
| +const Zero Amount = "0" |
| + |
| +var maximum = new(big.Int).Sub(new(big.Int).Lsh(big.NewInt(1), 127), big.NewInt(1)) |
| + |
| +func number(a Amount) (*big.Int, error) { |
| + if len(a) == 0 || len(a) > 39 || (len(a) > 1 && a[0] == '0') { |
| + return nil, ErrInvalid |
| + } |
| + for _, c := range a { |
| + if c < '0' || c > '9' { |
| + return nil, ErrInvalid |
| + } |
| + } |
| + n, ok := new(big.Int).SetString(string(a), 10) |
| + if !ok || n.Cmp(maximum) > 0 { |
| + return nil, ErrInvalid |
| + } |
| + return n, nil |
| +} |
| +func ParseAmount(s string) (Amount, error) { a := Amount(s); _, e := number(a); return a, e } |
| +func add(a, b Amount) (Amount, error) { |
| + x, e := number(a) |
| + if e != nil { |
| + return "", e |
| + } |
| + y, e := number(b) |
| + if e != nil { |
| + return "", e |
| + } |
| + x.Add(x, y) |
| + if x.Cmp(maximum) > 0 { |
| + return "", ErrInvalid |
| + } |
| + return Amount(x.String()), nil |
| +} |
| +func sub(a, b Amount) (Amount, error) { |
| + x, e := number(a) |
| + if e != nil { |
| + return "", e |
| + } |
| + y, e := number(b) |
| + if e != nil { |
| + return "", e |
| + } |
| + x.Sub(x, y) |
| + if x.Sign() < 0 { |
| + return "", ErrCorrupt |
| + } |
| + return Amount(x.String()), nil |
| +} |
| +func cmp(a, b Amount) int { x, _ := number(a); y, _ := number(b); return x.Cmp(y) } |
| +func min(a, b Amount) Amount { |
| + if cmp(a, b) < 0 { |
| + return a |
| + } |
| + return b |
| +} |
| + |
| +func validAmounts(values ...Amount) bool { |
| + for _, v := range values { |
| + if _, e := number(v); e != nil { |
| + return false |
| + } |
| + } |
| + return true |
| +} |
| +func balanced(cap, available, settled, outstanding Amount) bool { |
| + sum, e := add(available, settled) |
| + if e != nil { |
| + return false |
| + } |
| + sum, e = add(sum, outstanding) |
| + return e == nil && sum == cap |
| +} |
| +func validateStored(table, id string, v any) error { |
| + switch p := v.(type) { |
| + case *Pool: |
| + if !p.Resource.valid() || p.Account == "" || p.Revision <= 0 || p.Digest == "" || poolKey(p.Account, p.Month, p.Resource) != id || !validAmounts(p.Capacity, p.Available, p.Settled, p.Outstanding, p.Excess) || !balanced(p.Capacity, p.Available, p.Settled, p.Outstanding) { |
| + return ErrCorrupt |
| + } |
| + case *PaidLimit: |
| + if validRequest(p.Request) != nil || !p.Resource.valid() || p.Consent <= 0 || p.Offer == "" || limitKey(p.Account, p.Month, p.Resource, p.Consent, p.Subject) != id || !window(p.Month, p.From, p.Until) || !validAmounts(p.Ceiling, p.Unused, p.Settled, p.Outstanding) || !balanced(p.Ceiling, p.Unused, p.Settled, p.Outstanding) { |
| + return ErrCorrupt |
| + } |
| + case *Reservation: |
| + if validRequest(p.Request) != nil || p.Reservation != id || validateLines(p.Lines) != nil || len(p.Allocations) != len(p.Lines) || !window(p.Month, p.From, p.Until) { |
| + return ErrCorrupt |
| + } |
| + switch p.State { |
| + case "reserved", "closed-unused": |
| + if p.Attempt != "" || p.Producer != "" || p.ProducerEpoch != 0 { |
| + return ErrCorrupt |
| + } |
| + case "claimed", "uncertain", "closed": |
| + if p.Attempt == "" || p.Producer == "" || p.ProducerEpoch <= 0 { |
| + return ErrCorrupt |
| + } |
| + default: |
| + return ErrCorrupt |
| + } |
| + for i, a := range p.Allocations { |
| + if a.Resource != p.Lines[i].Resource || a.Maximum != p.Lines[i].Amount || !validAmounts(a.Maximum, a.Included, a.Paid, a.SettledIncluded, a.SettledPaid, a.Excess, a.Total) { |
| + return ErrCorrupt |
| + } |
| + sum, e := add(a.Included, a.Paid) |
| + if e != nil || sum != a.Maximum { |
| + return ErrCorrupt |
| + } |
| + b := min(a.Total, a.Maximum) |
| + inc := min(b, a.Included) |
| + paid, _ := sub(b, inc) |
| + excess, _ := sub(a.Total, b) |
| + if inc != a.SettledIncluded || paid != a.SettledPaid || excess != a.Excess { |
| + return ErrCorrupt |
| + } |
| + if a.Paid != Zero && a.Limit != limitKey(p.Account, p.Month, a.Resource, p.Consent, p.Subject) { |
| + return ErrCorrupt |
| + } |
| + } |
| + case *Evidence: |
| + if validEvidence(*p) != nil || p.ID != id { |
| + return ErrCorrupt |
| + } |
| + case *FenceRequest: |
| + if validRequest(p.Request) != nil || p.Producer == "" || p.ProducerEpoch <= 0 || key(p.Account, p.Producer) != id { |
| + return ErrCorrupt |
| + } |
| + } |
| + return nil |
| +} |
diff --git a/internal/resourcebudget/amount_test.go b/internal/resourcebudget/amount_test.go| new file mode 100644 |
| index 000000000..5f909610c |
| --- /dev/null |
| +++ b/internal/resourcebudget/amount_test.go |
| @@ -0,0 +1,22 @@ |
| +package resourcebudget |
| + |
| +import "testing" |
| + |
| +func TestAmounts(t *testing.T) { |
| + for _, s := range []string{"", "00", "-1", "+1", "1.0", "170141183460469231731687303715884105728"} { |
| + if _, e := ParseAmount(s); e == nil { |
| + t.Fatal(s) |
| + } |
| + } |
| + for _, s := range []string{"0", "360000000000000000000", "170141183460469231731687303715884105727"} { |
| + if _, e := ParseAmount(s); e != nil { |
| + t.Fatal(s, e) |
| + } |
| + } |
| + if _, e := add(Amount(maximum.String()), "1"); e == nil { |
| + t.Fatal("overflow") |
| + } |
| + if validateLines([]Line{{Resource("storage_byte_ns"), "1"}}) == nil || validateLines([]Line{{Awake, "1"}, {Awake, "2"}}) == nil { |
| + t.Fatal("resource validation") |
| + } |
| +} |
diff --git a/internal/resourcebudget/basis.go b/internal/resourcebudget/basis.go| new file mode 100644 |
| index 000000000..a5b33bd1e |
| --- /dev/null |
| +++ b/internal/resourcebudget/basis.go |
| @@ -0,0 +1,198 @@ |
| +package resourcebudget |
| + |
| +import ( |
| + "database/sql" |
| + "time" |
| +) |
| + |
| +type Line struct { |
| + Resource Resource |
| + Amount Amount |
| +} |
| +type Basis struct { |
| + Request |
| + Revision int64 |
| + Identity Identity |
| + Digest string |
| + Lines []Line |
| +} |
| +type Pool struct { |
| + Account, Month string |
| + Resource Resource |
| + Capacity, Available, Settled, Outstanding, Excess Amount |
| + Revision int64 |
| + Identity Identity |
| + Digest string |
| +} |
| +type PaidLimit struct { |
| + Request |
| + Resource Resource |
| + Consent int64 |
| + Offer string |
| + Identity Identity |
| + Ceiling, Unused, Settled, Outstanding Amount |
| + From, Until time.Time |
| +} |
| + |
| +func poolKey(a, m string, r Resource) string { return key(a, m, r) } |
| +func limitKey(a, m string, r Resource, c int64, s string) string { return key(a, m, r, c, s) } |
| +func validateLines(ls []Line) error { |
| + if len(ls) == 0 || len(ls) > 3 { |
| + return ErrInvalid |
| + } |
| + var previous Resource |
| + for _, l := range ls { |
| + if !l.Resource.valid() || l.Resource <= previous { |
| + return ErrInvalid |
| + } |
| + if _, e := number(l.Amount); e != nil { |
| + return e |
| + } |
| + previous = l.Resource |
| + } |
| + return nil |
| +} |
| +func InstallBasis(tx *sql.Tx, l GateLoader, b Basis, now time.Time) (Outcome, error) { |
| + return basis(tx, l, b, now, false) |
| +} |
| +func IncreaseBasis(tx *sql.Tx, l GateLoader, b Basis, now time.Time) (Outcome, error) { |
| + return basis(tx, l, b, now, true) |
| +} |
| +func basis(tx *sql.Tx, l GateLoader, b Basis, now time.Time, increase bool) (Outcome, error) { |
| + if validateLines(b.Lines) != nil || b.Revision <= 0 || b.Digest == "" { |
| + return Outcome{}, ErrInvalid |
| + } |
| + kind := "InstallBasis" |
| + if increase { |
| + kind = "IncreaseBasis" |
| + } |
| + o, re, e := begin(tx, l, b.Request, kind, b, now) |
| + if e != nil { |
| + return Outcome{}, e |
| + } |
| + if re != nil { |
| + return *re, nil |
| + } |
| + pools := make([]Pool, len(b.Lines)) |
| + conflict := false |
| + rows, e := tx.Query(`SELECT body FROM rb_pools`) |
| + if e != nil { |
| + return Outcome{}, e |
| + } |
| + count := 0 |
| + affected := map[Resource]bool{} |
| + for rows.Next() { |
| + var raw string |
| + if e = rows.Scan(&raw); e != nil { |
| + rows.Close() |
| + return Outcome{}, e |
| + } |
| + var p Pool |
| + if e = decode(raw, &p); e != nil { |
| + rows.Close() |
| + return Outcome{}, e |
| + } |
| + if p.Account == b.Account && p.Month == b.Month { |
| + count++ |
| + affected[p.Resource] = true |
| + } |
| + } |
| + e = rows.Err() |
| + rows.Close() |
| + if e != nil { |
| + return Outcome{}, e |
| + } |
| + if (increase && count != len(b.Lines)) || (!increase && count != 0) { |
| + conflict = true |
| + } |
| + for i, line := range b.Lines { |
| + var p Pool |
| + e = get(tx, "pools", poolKey(b.Account, b.Month, line.Resource), &p) |
| + if e == nil { |
| + if !increase || p.Identity != b.Identity || b.Revision <= p.Revision || cmp(line.Amount, p.Capacity) < 0 { |
| + conflict = true |
| + } |
| + } else if !missing(e) { |
| + return Outcome{}, e |
| + } else if increase { |
| + conflict = true |
| + } else { |
| + p = Pool{Account: b.Account, Month: b.Month, Resource: line.Resource, Capacity: Zero, Available: Zero, Settled: Zero, Outstanding: Zero, Excess: Zero} |
| + } |
| + pools[i] = p |
| + } |
| + if conflict { |
| + for _, line := range b.Lines { |
| + affected[line.Resource] = true |
| + } |
| + for resource := range affected { |
| + if e = o.hold(b.Month, resource, "basis_conflict"); e != nil { |
| + return Outcome{}, e |
| + } |
| + } |
| + if e = o.put("basis", key("conflict", b.ID), b, true); e != nil { |
| + return Outcome{}, e |
| + } |
| + return o.finish("held:basis_conflict") |
| + } |
| + for i, p := range pools { |
| + delta, e := sub(b.Lines[i].Amount, p.Capacity) |
| + if e != nil { |
| + return Outcome{}, e |
| + } |
| + p.Available, e = add(p.Available, delta) |
| + if e != nil { |
| + return Outcome{}, e |
| + } |
| + p.Capacity = b.Lines[i].Amount |
| + p.Revision = b.Revision |
| + p.Identity = b.Identity |
| + p.Digest = b.Digest |
| + if e = o.put("pools", poolKey(b.Account, b.Month, p.Resource), p, false); e != nil { |
| + return Outcome{}, e |
| + } |
| + } |
| + if e = o.put("basis", key("installed", b.ID), b, true); e != nil { |
| + return Outcome{}, e |
| + } |
| + return o.finish("installed") |
| +} |
| +func InstallPaidLimit(tx *sql.Tx, l GateLoader, p PaidLimit, now time.Time) (Outcome, error) { |
| + if !p.Resource.valid() || p.Consent <= 0 || p.Offer == "" || !window(p.Month, p.From, p.Until) || p.Unused != "" || p.Settled != "" || p.Outstanding != "" { |
| + return Outcome{}, ErrInvalid |
| + } |
| + if _, e := number(p.Ceiling); e != nil { |
| + return Outcome{}, e |
| + } |
| + o, re, e := begin(tx, l, p.Request, "InstallPaidLimit", p, now) |
| + if e != nil { |
| + return Outcome{}, e |
| + } |
| + if re != nil { |
| + return *re, nil |
| + } |
| + id := limitKey(p.Account, p.Month, p.Resource, p.Consent, p.Subject) |
| + var old PaidLimit |
| + e = get(tx, "limits", id, &old) |
| + if e == nil { |
| + return o.anomaly(p.Resource, "limit_conflict") |
| + } |
| + if !missing(e) { |
| + return Outcome{}, e |
| + } |
| + if !paidGate(o.gate, p.Consent, p.Offer, p.Identity) { |
| + return Outcome{}, ErrDenied |
| + } |
| + p.Unused, p.Settled, p.Outstanding = p.Ceiling, Zero, Zero |
| + if e = o.put("limits", id, p, false); e != nil { |
| + return Outcome{}, e |
| + } |
| + return o.finish("installed") |
| +} |
| +func paidGate(g Gate, c int64, offer string, i Identity) bool { |
| + return g.Eligible && g.On && g.Approved && g.Consent == c && g.Offer != "" && g.Offer == offer && g.StoredOffer == offer && g.Identity == i |
| +} |
| +func window(m string, a, b time.Time) bool { |
| + s, e, err := monthBounds(m) |
| + return err == nil && !a.Before(s) && !b.After(e) && a.Before(b) |
| +} |
diff --git a/internal/resourcebudget/core.go b/internal/resourcebudget/core.go| new file mode 100644 |
| index 000000000..75852b137 |
| --- /dev/null |
| +++ b/internal/resourcebudget/core.go |
| @@ -0,0 +1,305 @@ |
| +// Package resourcebudget provides inert, caller-transaction resource accounting. |
| +// Every returned outcome is provisional until the caller commits. No outcome is |
| +// executable permission; production consumers and a durable-commit wrapper are absent. |
| +// Any error requires the caller to roll back the transaction, including earlier |
| +// successful operations in that transaction. Refused IDs have no receipt and may |
| +// succeed on a later attempt. A future wrapper must enforce rollback on error. |
| +package resourcebudget |
| + |
| +import ( |
| + "crypto/sha256" |
| + "database/sql" |
| + "encoding/hex" |
| + "encoding/json" |
| + "errors" |
| + "fmt" |
| + "reflect" |
| + "strconv" |
| + "strings" |
| + "time" |
| + "unicode/utf8" |
| +) |
| + |
| +type Identity struct { |
| + Terms, Book, Currency string |
| + House bool |
| +} |
| +type BasisFact struct { |
| + Revision int64 |
| + Digest string |
| + Identity Identity |
| +} |
| +type Gate struct { |
| + Epoch int64 |
| + Eligible, On, Approved bool |
| + Consent int64 |
| + Offer, StoredOffer string |
| + Identity Identity |
| + Supported map[Resource]bool |
| + Basis map[Resource]BasisFact |
| +} |
| +type GateLoader interface { |
| + Load(*sql.Tx, string, string, string) (Gate, error) |
| +} |
| +type GateFunc func(*sql.Tx, string, string, string) (Gate, error) |
| + |
| +func (f GateFunc) Load(t *sql.Tx, a, m, s string) (Gate, error) { return f(t, a, m, s) } |
| + |
| +type Request struct { |
| + ID, Account, Month, Subject string |
| + Epoch int64 |
| +} |
| +type Receipt struct { |
| + ID, Kind, Account, Subject, Outcome string |
| + Epoch int64 |
| +} |
| +type Outcome struct { |
| + Receipt Receipt |
| + Replayed bool |
| +} |
| +type ClaimResult interface{ claimResult() } |
| +type NewClaimProvisional struct{ Receipt Receipt } |
| + |
| +func (NewClaimProvisional) claimResult() {} |
| + |
| +type ReplayedClaim struct{ Receipt Receipt } |
| + |
| +func (ReplayedClaim) claimResult() {} |
| + |
| +type operation struct { |
| + tx *sql.Tx |
| + r Request |
| + kind, digest string |
| + gate Gate |
| + payload any |
| +} |
| + |
| +func key(parts ...any) string { |
| + var out strings.Builder |
| + for _, part := range parts { |
| + var s string |
| + switch v := part.(type) { |
| + case string: |
| + s = v |
| + case Resource: |
| + s = string(v) |
| + case int64: |
| + s = strconv.FormatInt(v, 10) |
| + case int: |
| + s = strconv.Itoa(v) |
| + default: |
| + panic(ErrInvalid) |
| + } |
| + out.WriteString(strconv.Itoa(len(s))) |
| + out.WriteByte(':') |
| + out.WriteString(s) |
| + } |
| + return out.String() |
| +} |
| +func encode(v any) ([]byte, error) { |
| + var check func(reflect.Value) bool |
| + check = func(v reflect.Value) bool { |
| + if !v.IsValid() { |
| + return true |
| + } |
| + switch v.Kind() { |
| + case reflect.String: |
| + return utf8.ValidString(v.String()) |
| + case reflect.Pointer, reflect.Interface: |
| + if !v.IsNil() { |
| + return check(v.Elem()) |
| + } |
| + case reflect.Struct: |
| + for i := 0; i < v.NumField(); i++ { |
| + if v.Type().Field(i).PkgPath == "" && !check(v.Field(i)) { |
| + return false |
| + } |
| + } |
| + case reflect.Slice, reflect.Array: |
| + for i := 0; i < v.Len(); i++ { |
| + if !check(v.Index(i)) { |
| + return false |
| + } |
| + } |
| + case reflect.Map: |
| + for _, k := range v.MapKeys() { |
| + if !check(k) || !check(v.MapIndex(k)) { |
| + return false |
| + } |
| + } |
| + } |
| + return true |
| + } |
| + if !check(reflect.ValueOf(v)) { |
| + return nil, ErrInvalid |
| + } |
| + return json.Marshal(v) |
| +} |
| +func digest(v any) (string, error) { |
| + b, e := encode(v) |
| + if e != nil { |
| + return "", e |
| + } |
| + h := sha256.Sum256(b) |
| + return hex.EncodeToString(h[:]), nil |
| +} |
| +func monthBounds(m string) (time.Time, time.Time, error) { |
| + s, e := time.Parse("2006-01", m) |
| + if e != nil || s.Format("2006-01") != m { |
| + return time.Time{}, time.Time{}, ErrInvalid |
| + } |
| + return s, s.AddDate(0, 1, 0), nil |
| +} |
| +func validRequest(r Request) error { |
| + if r.ID == "" || r.Account == "" || r.Subject == "" || r.Epoch <= 0 { |
| + return ErrInvalid |
| + } |
| + _, _, e := monthBounds(r.Month) |
| + return e |
| +} |
| +func begin(tx *sql.Tx, l GateLoader, r Request, kind string, payload any, now time.Time) (*operation, *Outcome, error) { |
| + if tx == nil || l == nil || validRequest(r) != nil || now.IsZero() { |
| + return nil, nil, ErrInvalid |
| + } |
| + requestDigest, err := digest(struct { |
| + Kind string |
| + Payload any |
| + }{kind, payload}) |
| + if err != nil { |
| + return nil, nil, err |
| + } |
| + if !time.Unix(0, now.UnixNano()).Equal(now) { |
| + return nil, nil, ErrInvalid |
| + } |
| + var sync int |
| + if e := tx.QueryRow(`PRAGMA synchronous`).Scan(&sync); e != nil { |
| + return nil, nil, e |
| + } |
| + if sync < 2 { |
| + return nil, nil, ErrDenied |
| + } |
| + if _, e := tx.Exec(`INSERT INTO rb_authority VALUES(?,1,?,0) ON CONFLICT(account) DO UPDATE SET serial=serial`, r.Account, r.Epoch); e != nil { |
| + return nil, nil, e |
| + } |
| + op := &operation{tx: tx, r: r, kind: kind, payload: payload, digest: requestDigest} |
| + var d, b string |
| + e := tx.QueryRow(`SELECT digest,body FROM rb_receipts WHERE id=?`, r.ID).Scan(&d, &b) |
| + if e == nil { |
| + if d != op.digest { |
| + return nil, nil, ErrConflict |
| + } |
| + var receipt Receipt |
| + if json.Unmarshal([]byte(b), &receipt) != nil { |
| + return nil, nil, ErrCorrupt |
| + } |
| + return nil, &Outcome{receipt, true}, nil |
| + } |
| + if !errors.Is(e, sql.ErrNoRows) { |
| + return nil, nil, e |
| + } |
| + var epoch, latest int64 |
| + if e = tx.QueryRow(`SELECT epoch,latest FROM rb_authority WHERE account=?`, r.Account).Scan(&epoch, &latest); e != nil { |
| + return nil, nil, e |
| + } |
| + if epoch != r.Epoch || now.UnixNano() < latest { |
| + return nil, nil, ErrDenied |
| + } |
| + op.gate, e = l.Load(tx, r.Account, r.Month, r.Subject) |
| + if e != nil { |
| + return nil, nil, e |
| + } |
| + if _, e = encode(op.gate); e != nil { |
| + return nil, nil, e |
| + } |
| + if op.gate.Epoch != r.Epoch { |
| + return nil, nil, ErrDenied |
| + } |
| + if _, e = tx.Exec(`UPDATE rb_authority SET latest=? WHERE account=?`, now.UnixNano(), r.Account); e != nil { |
| + return nil, nil, e |
| + } |
| + return op, nil, nil |
| +} |
| +func (o *operation) finish(out string) (Outcome, error) { |
| + r := Receipt{o.r.ID, o.kind, o.r.Account, o.r.Subject, out, o.r.Epoch} |
| + b, e := encode(r) |
| + if e != nil { |
| + return Outcome{}, e |
| + } |
| + _, e = o.tx.Exec(`INSERT INTO rb_receipts VALUES(?,?,?)`, o.r.ID, o.digest, string(b)) |
| + return Outcome{Receipt: r}, e |
| +} |
| +func get(tx *sql.Tx, table, id string, v any) error { |
| + var b string |
| + if e := tx.QueryRow(`SELECT body FROM rb_`+table+` WHERE id=?`, id).Scan(&b); e != nil { |
| + return e |
| + } |
| + if json.Unmarshal([]byte(b), v) != nil { |
| + return ErrCorrupt |
| + } |
| + if _, e := encode(v); e != nil { |
| + return ErrCorrupt |
| + } |
| + return validateStored(table, id, v) |
| +} |
| +func (o *operation) put(table, id string, v any, immutable bool) error { |
| + b, e := encode(v) |
| + if e != nil { |
| + return e |
| + } |
| + q := `INSERT INTO rb_` + table + ` VALUES(?,?)` |
| + if !immutable { |
| + q += ` ON CONFLICT(id) DO UPDATE SET body=excluded.body` |
| + } |
| + if _, e = o.tx.Exec(q, id, string(b)); e != nil { |
| + return e |
| + } |
| + _, e = o.tx.Exec(`INSERT INTO rb_events(kind,id,body,epoch,operation_id) VALUES(?,?,?,?,?)`, table, id, string(b), o.r.Epoch, o.r.ID) |
| + return e |
| +} |
| +func (o *operation) hold(month string, r Resource, reason string) error { |
| + id := key(o.r.ID, month, r, reason) |
| + _, e := o.tx.Exec(`INSERT INTO rb_holds VALUES(?,?,?,?,?,?)`, id, o.r.Account, month, r, reason, o.r.Epoch) |
| + return e |
| +} |
| +func (o *operation) held(r Resource) (bool, error) { |
| + var n int |
| + e := o.tx.QueryRow(`SELECT count(*) FROM rb_holds WHERE account=? AND month=? AND resource=?`, o.r.Account, o.r.Month, r).Scan(&n) |
| + return n > 0, e |
| +} |
| +func (o *operation) admit(r Resource) error { |
| + h, e := o.held(r) |
| + if e != nil { |
| + return e |
| + } |
| + if h || !o.gate.Eligible || !o.gate.Supported[r] { |
| + return ErrDenied |
| + } |
| + return nil |
| +} |
| +func (o *operation) anomaly(r Resource, reason string, months ...string) (Outcome, error) { |
| + if len(months) == 0 { |
| + months = []string{o.r.Month} |
| + } |
| + seen := map[string]bool{} |
| + for _, m := range months { |
| + if !seen[m] { |
| + seen[m] = true |
| + if e := o.hold(m, r, reason); e != nil { |
| + return Outcome{}, e |
| + } |
| + } |
| + } |
| + if e := o.put("basis", key("anomaly", o.r.ID), struct { |
| + Reason string |
| + Resource Resource |
| + Months []string |
| + Payload any |
| + }{reason, r, months, o.payload}, true); e != nil { |
| + return Outcome{}, e |
| + } |
| + return o.finish("held:" + reason) |
| +} |
| +func missing(e error) bool { return errors.Is(e, sql.ErrNoRows) } |
| +func problem(format string, args ...any) error { |
| + return fmt.Errorf("%w: "+format, append([]any{ErrCorrupt}, args...)...) |
| +} |
diff --git a/internal/resourcebudget/core_test.go b/internal/resourcebudget/core_test.go| new file mode 100644 |
| index 000000000..2587c5114 |
| --- /dev/null |
| +++ b/internal/resourcebudget/core_test.go |
| @@ -0,0 +1,726 @@ |
| +package resourcebudget |
| + |
| +import ( |
| + "database/sql" |
| + "errors" |
| + "fmt" |
| + _ "modernc.org/sqlite" |
| + "path/filepath" |
| + "strings" |
| + "testing" |
| + "time" |
| +) |
| + |
| +type fixture struct { |
| + t *testing.T |
| + db *sql.DB |
| + dsn string |
| + gate Gate |
| + now time.Time |
| + n int |
| +} |
| + |
| +func fresh(t *testing.T) *fixture { |
| + t.Helper() |
| + dir := t.TempDir() |
| + dsn := filepath.Join(dir, "budget.db") + "?_pragma=synchronous(2)&_pragma=busy_timeout(5000)&_pragma=journal_mode(WAL)" |
| + db, e := sql.Open("sqlite", dsn) |
| + if e != nil { |
| + t.Fatal(e) |
| + } |
| + db.SetMaxOpenConns(4) |
| + t.Cleanup(func() { db.Close() }) |
| + f := &fixture{t: t, db: db, dsn: dsn, now: time.Date(2026, 10, 3, 12, 0, 0, 0, time.UTC), gate: Gate{Epoch: 1, Eligible: true, On: true, Approved: true, Consent: 1, Offer: "approved", StoredOffer: "approved", Identity: Identity{Terms: "synthetic", Book: "approved-test", Currency: "eur"}, Supported: map[Resource]bool{Awake: true, Transfer: true, Writes: true}}} |
| + f.gate.Basis = map[Resource]BasisFact{Awake: {Revision: 1, Digest: "basis", Identity: f.gate.Identity}} |
| + tx, e := db.Begin() |
| + if e != nil { |
| + t.Fatal(e) |
| + } |
| + if e = Migrate(tx); e != nil { |
| + t.Fatal(e) |
| + } |
| + if e = tx.Commit(); e != nil { |
| + t.Fatal(e) |
| + } |
| + return f |
| +} |
| +func (f *fixture) Load(*sql.Tx, string, string, string) (Gate, error) { return f.gate, nil } |
| +func (f *fixture) req() Request { |
| + f.n++ |
| + return Request{ID: fmt.Sprint("op", f.n), Account: "account", Month: "2026-10", Subject: "app", Epoch: 1} |
| +} |
| +func (f *fixture) call(commit bool, fn func(*sql.Tx) (Outcome, error)) (Outcome, error) { |
| + f.t.Helper() |
| + tx, e := f.db.Begin() |
| + if e != nil { |
| + f.t.Fatal(e) |
| + } |
| + defer tx.Rollback() |
| + o, e := fn(tx) |
| + if e != nil { |
| + return o, e |
| + } |
| + if e = Verify(tx); e != nil { |
| + f.t.Fatal("verify", e) |
| + } |
| + if commit { |
| + e = tx.Commit() |
| + } |
| + return o, e |
| +} |
| +func (f *fixture) ok(fn func(*sql.Tx) (Outcome, error)) Outcome { |
| + f.t.Helper() |
| + o, e := f.call(true, fn) |
| + if e != nil { |
| + f.t.Fatal(e) |
| + } |
| + return o |
| +} |
| +func (f *fixture) basis(amount Amount) Basis { |
| + b := Basis{Request: f.req(), Revision: 1, Identity: f.gate.Identity, Digest: "basis", Lines: []Line{{Awake, amount}}} |
| + f.ok(func(tx *sql.Tx) (Outcome, error) { return InstallBasis(tx, f, b, f.now) }) |
| + return b |
| +} |
| +func (f *fixture) limit() PaidLimit { |
| + p := PaidLimit{Request: f.req(), Resource: Awake, Consent: 1, Offer: "approved", Identity: f.gate.Identity, Ceiling: "20", From: f.now.Add(-time.Hour), Until: f.now.Add(time.Hour)} |
| + f.ok(func(tx *sql.Tx) (Outcome, error) { return InstallPaidLimit(tx, f, p, f.now) }) |
| + return p |
| +} |
| +func (f *fixture) reserve(amount Amount) ReserveRequest { |
| + r := ReserveRequest{Request: f.req(), Reservation: "res", Lines: []Line{{Awake, amount}}, BasisRevision: 1, Consent: 1, AllowPaid: true, From: f.now, Until: f.now.Add(time.Minute), ClaimBy: f.now.Add(time.Second)} |
| + f.ok(func(tx *sql.Tx) (Outcome, error) { return Reserve(tx, f, r, f.now) }) |
| + return r |
| +} |
| +func (f *fixture) claim(r ReserveRequest) ClaimRequest { |
| + q := ClaimRequest{Request: f.req(), Reservation: r.Reservation, Producer: "producer", Attempt: "attempt", ProducerEpoch: 1} |
| + f.ok(func(tx *sql.Tx) (Outcome, error) { |
| + v, e := Claim(tx, f, q, f.now) |
| + if e != nil { |
| + return Outcome{}, e |
| + } |
| + n, ok := v.(NewClaimProvisional) |
| + if !ok { |
| + f.t.Fatal("not new") |
| + } |
| + return Outcome{Receipt: n.Receipt}, nil |
| + }) |
| + return q |
| +} |
| + |
| +type checks struct{ fail bool } |
| + |
| +func (c checks) Measurement(Evidence) error { |
| + if c.fail { |
| + return ErrDenied |
| + } |
| + return nil |
| +} |
| +func (c checks) Terminal(Terminal) error { |
| + if c.fail { |
| + return ErrDenied |
| + } |
| + return nil |
| +} |
| +func (f *fixture) evidence(total Amount) Evidence { |
| + return Evidence{ID: "meter", Account: "account", Reservation: "res", Subject: "app", Attempt: "attempt", Producer: "producer", ProducerEpoch: 1, Resource: Awake, Total: total, From: f.now, Until: f.now.Add(time.Second)} |
| +} |
| +func (f *fixture) finish(e Evidence) FinishRequest { |
| + return FinishRequest{Request: f.req(), Reservation: "res", Evidence: []Evidence{e}, Terminal: Terminal{ID: "terminal", Account: e.Account, Reservation: e.Reservation, Subject: e.Subject, Attempt: e.Attempt, Producer: e.Producer, ProducerEpoch: e.ProducerEpoch}} |
| +} |
| +func (f *fixture) pool() Pool { |
| + f.t.Helper() |
| + tx, e := f.db.Begin() |
| + if e != nil { |
| + f.t.Fatal(e) |
| + } |
| + defer tx.Rollback() |
| + var p Pool |
| + if e = get(tx, "pools", poolKey("account", "2026-10", Awake), &p); e != nil { |
| + f.t.Fatal(e) |
| + } |
| + return p |
| +} |
| +func (f *fixture) held() int { |
| + var n int |
| + if e := f.db.QueryRow(`SELECT count(*) FROM rb_holds`).Scan(&n); e != nil { |
| + f.t.Fatal(e) |
| + } |
| + return n |
| +} |
| + |
| +func TestReserveClaimFinishAndReplay(t *testing.T) { |
| + f := fresh(t) |
| + f.basis("10") |
| + f.limit() |
| + r := f.reserve("15") |
| + q := f.claim(r) |
| + tx, _ := f.db.Begin() |
| + v, e := Claim(tx, f, q, f.now.Add(time.Hour)) |
| + if e != nil { |
| + t.Fatal(e) |
| + } |
| + if _, ok := v.(ReplayedClaim); !ok { |
| + t.Fatalf("%T", v) |
| + } |
| + tx.Rollback() |
| + q.ID = "different" |
| + tx, _ = f.db.Begin() |
| + if _, e = Claim(tx, f, q, f.now); e == nil { |
| + t.Fatal("distinct claim") |
| + } |
| + tx.Rollback() |
| + evi := f.evidence("12") |
| + fin := f.finish(evi) |
| + f.ok(func(tx *sql.Tx) (Outcome, error) { return Finish(tx, f, checks{}, fin, f.now) }) |
| + p := f.pool() |
| + if p.Settled != "10" || p.Outstanding != Zero { |
| + t.Fatal(p) |
| + } |
| + var raw string |
| + f.db.QueryRow(`SELECT body FROM rb_limits`).Scan(&raw) |
| + var limit PaidLimit |
| + decode(raw, &limit) |
| + if limit.Settled != "2" || limit.Outstanding != Zero || limit.Unused != "18" { |
| + t.Fatal(limit) |
| + } |
| + out := f.ok(func(tx *sql.Tx) (Outcome, error) { return Finish(tx, f, checks{}, fin, f.now.Add(time.Hour)) }) |
| + if !out.Replayed { |
| + t.Fatal("not replayed") |
| + } |
| +} |
| +func TestProvisionalRollbackAndRejectedID(t *testing.T) { |
| + f := fresh(t) |
| + b := Basis{Request: f.req(), Revision: 1, Identity: f.gate.Identity, Digest: "b", Lines: []Line{{Awake, "10"}}} |
| + f.gate.Basis[Awake] = BasisFact{1, "b", f.gate.Identity} |
| + if _, e := f.call(false, func(tx *sql.Tx) (Outcome, error) { return InstallBasis(tx, f, b, f.now) }); e != nil { |
| + t.Fatal(e) |
| + } |
| + var n int |
| + f.db.QueryRow(`SELECT count(*) FROM rb_receipts`).Scan(&n) |
| + if n != 0 { |
| + t.Fatal(n) |
| + } |
| + f.ok(func(tx *sql.Tx) (Outcome, error) { return InstallBasis(tx, f, b, f.now) }) |
| + r := ReserveRequest{Request: f.req(), Reservation: "res", Lines: []Line{{Awake, "5"}}, BasisRevision: 1, From: f.now, Until: f.now.Add(time.Minute), ClaimBy: f.now.Add(time.Second)} |
| + f.gate.Eligible = false |
| + if _, e := f.call(true, func(tx *sql.Tx) (Outcome, error) { return Reserve(tx, f, r, f.now) }); e == nil { |
| + t.Fatal("denied") |
| + } |
| + f.gate.Eligible = true |
| + f.ok(func(tx *sql.Tx) (Outcome, error) { return Reserve(tx, f, r, f.now) }) |
| + q := ClaimRequest{Request: f.req(), Reservation: "res", Producer: "p", Attempt: "a", ProducerEpoch: 1} |
| + tx, _ := f.db.Begin() |
| + if _, e := Claim(tx, f, q, f.now); e != nil { |
| + t.Fatal(e) |
| + } |
| + tx.Rollback() |
| + tx, _ = f.db.Begin() |
| + var rr Reservation |
| + get(tx, "reservations", "res", &rr) |
| + tx.Rollback() |
| + if rr.State != "reserved" { |
| + t.Fatal(rr.State) |
| + } |
| +} |
| +func TestBasisAndLimitConflicts(t *testing.T) { |
| + for _, kind := range []string{"same", "shrink", "identity", "revision", "limit"} { |
| + for _, commit := range []bool{false, true} { |
| + t.Run(fmt.Sprint(kind, commit), func(t *testing.T) { |
| + f := fresh(t) |
| + b := f.basis("10") |
| + p := f.limit() |
| + b.Request = f.req() |
| + b.Revision = 2 |
| + switch kind { |
| + case "same": |
| + b.Revision = 1 |
| + case "shrink": |
| + b.Lines[0].Amount = "9" |
| + case "identity": |
| + b.Identity.House = true |
| + case "revision": |
| + b.Revision = 1 |
| + case "limit": |
| + p.Request = f.req() |
| + } |
| + out, e := f.call(commit, func(tx *sql.Tx) (Outcome, error) { |
| + if kind == "limit" { |
| + return InstallPaidLimit(tx, f, p, f.now) |
| + } |
| + if kind == "same" { |
| + return InstallBasis(tx, f, b, f.now) |
| + } |
| + return IncreaseBasis(tx, f, b, f.now) |
| + }) |
| + if e != nil || !strings.HasPrefix(out.Receipt.Outcome, "held:") { |
| + t.Fatal(out, e) |
| + } |
| + if (f.held() > 0) != commit || f.pool().Capacity != "10" { |
| + t.Fatal("conflict persistence") |
| + } |
| + }) |
| + } |
| + } |
| +} |
| +func TestPaidGateAndWindow(t *testing.T) { |
| + for _, kind := range []string{"revision", "off", "offer", "draft", "subject", "window", "capability"} { |
| + t.Run(kind, func(t *testing.T) { |
| + f := fresh(t) |
| + f.basis("0") |
| + f.limit() |
| + r := ReserveRequest{Request: f.req(), Reservation: "res", Lines: []Line{{Awake, "5"}}, BasisRevision: 1, Consent: 1, AllowPaid: true, From: f.now, Until: f.now.Add(time.Minute), ClaimBy: f.now.Add(time.Second)} |
| + switch kind { |
| + case "revision": |
| + f.gate.Consent++ |
| + case "off": |
| + f.gate.On = false |
| + case "offer": |
| + f.gate.StoredOffer = "other" |
| + case "draft": |
| + f.gate.Approved = false |
| + case "subject": |
| + r.Subject = "other" |
| + case "window": |
| + r.Until = f.now.Add(2 * time.Hour) |
| + case "capability": |
| + f.gate.Supported[Awake] = false |
| + } |
| + if _, e := f.call(true, func(tx *sql.Tx) (Outcome, error) { return Reserve(tx, f, r, f.now) }); e == nil { |
| + t.Fatal("accepted") |
| + } |
| + }) |
| + } |
| +} |
| +func TestClaimConsentResaveAndExpiredCancellation(t *testing.T) { |
| + f := fresh(t) |
| + f.basis("0") |
| + f.limit() |
| + r := f.reserve("5") |
| + f.gate.Consent++ |
| + q := ClaimRequest{Request: f.req(), Reservation: r.Reservation, Producer: "p", Attempt: "a", ProducerEpoch: 1} |
| + tx, _ := f.db.Begin() |
| + if _, e := Claim(tx, f, q, f.now); e == nil { |
| + t.Fatal("old consent") |
| + } |
| + tx.Rollback() |
| + f.gate.On = false |
| + request := ReservationRequest{f.req(), r.Reservation} |
| + f.ok(func(tx *sql.Tx) (Outcome, error) { return CancelUnclaimed(tx, f, request, f.now.Add(time.Hour)) }) |
| +} |
| +func TestHoldsPermitCloseout(t *testing.T) { |
| + f := fresh(t) |
| + b := f.basis("10") |
| + r := f.reserve("8") |
| + f.claim(r) |
| + b.Request = f.req() |
| + f.ok(func(tx *sql.Tx) (Outcome, error) { return InstallBasis(tx, f, b, f.now) }) |
| + f.gate.On = false |
| + f.gate.Eligible = false |
| + req := ReservationRequest{f.req(), r.Reservation} |
| + f.ok(func(tx *sql.Tx) (Outcome, error) { return MarkUncertain(tx, f, req, f.now.Add(time.Hour)) }) |
| + if f.pool().Outstanding != "8" { |
| + t.Fatal("expiry refund") |
| + } |
| + e := f.evidence("3") |
| + ob := ObserveRequest{f.req(), e} |
| + f.ok(func(tx *sql.Tx) (Outcome, error) { return Observe(tx, f, checks{}, ob, f.now.Add(time.Hour)) }) |
| + fin := f.finish(e) |
| + f.ok(func(tx *sql.Tx) (Outcome, error) { return Finish(tx, f, checks{}, fin, f.now.Add(time.Hour)) }) |
| + if f.pool().Available != "7" { |
| + t.Fatal(f.pool()) |
| + } |
| + fr := FenceRequest{f.req(), "producer", 1} |
| + f.ok(func(tx *sql.Tx) (Outcome, error) { return FenceProducer(tx, f, fr, f.now.Add(time.Hour)) }) |
| +} |
| +func TestEvidenceAnomaliesCommitRollback(t *testing.T) { |
| + for _, kind := range []string{"regression", "conflict", "window", "crossmonth", "overshoot", "finish"} { |
| + for _, commit := range []bool{false, true} { |
| + t.Run(fmt.Sprint(kind, commit), func(t *testing.T) { |
| + f := fresh(t) |
| + f.basis("10") |
| + r := f.reserve("8") |
| + f.claim(r) |
| + first := f.evidence("3") |
| + ob := ObserveRequest{f.req(), first} |
| + f.ok(func(tx *sql.Tx) (Outcome, error) { return Observe(tx, f, checks{}, ob, f.now) }) |
| + if kind == "finish" { |
| + fin := f.finish(first) |
| + f.ok(func(tx *sql.Tx) (Outcome, error) { return Finish(tx, f, checks{}, fin, f.now) }) |
| + } |
| + ev := first |
| + ev.ID = "next" |
| + switch kind { |
| + case "regression": |
| + ev.Total = "2" |
| + case "conflict": |
| + ev.ID = first.ID |
| + ev.Total = "4" |
| + case "window": |
| + ev.Until = f.now.Add(time.Hour) |
| + case "crossmonth": |
| + ev.Until = time.Date(2026, 11, 2, 0, 0, 0, 0, time.UTC) |
| + case "overshoot": |
| + ev.Total = "12" |
| + case "finish": |
| + ev.Total = "4" |
| + } |
| + ob = ObserveRequest{f.req(), ev} |
| + out, e := f.call(commit, func(tx *sql.Tx) (Outcome, error) { |
| + if kind == "finish" { |
| + fin := FinishRequest{Request: ob.Request, Reservation: "res", Evidence: []Evidence{ev}, Terminal: Terminal{ID: "end", Account: ev.Account, Reservation: ev.Reservation, Subject: ev.Subject, Attempt: ev.Attempt, Producer: ev.Producer, ProducerEpoch: ev.ProducerEpoch}} |
| + return Finish(tx, f, checks{}, fin, f.now) |
| + } |
| + return Observe(tx, f, checks{}, ob, f.now) |
| + }) |
| + if e != nil || !strings.HasPrefix(out.Receipt.Outcome, "held:") { |
| + t.Fatal(out, e) |
| + } |
| + if (f.held() > 0) != commit { |
| + t.Fatal("hold persistence") |
| + } |
| + var receipts int |
| + if err := f.db.QueryRow(`SELECT count(*) FROM rb_receipts WHERE id=?`, ob.ID).Scan(&receipts); err != nil { |
| + t.Fatal(err) |
| + } |
| + wantReceipts := 0 |
| + if commit { |
| + wantReceipts = 1 |
| + } |
| + if receipts != wantReceipts { |
| + t.Fatal("anomaly receipt persistence", receipts) |
| + } |
| + |
| + p := f.pool() |
| + if kind == "overshoot" && commit { |
| + if p.Excess != "4" || p.Settled != "8" { |
| + t.Fatal(p) |
| + } |
| + } else if p.Settled != "3" { |
| + t.Fatal(p) |
| + } |
| + if kind == "crossmonth" && commit { |
| + b := Basis{Request: f.req(), Revision: 1, Identity: f.gate.Identity, Digest: "nov", Lines: []Line{{Awake, "10"}}} |
| + b.Month = "2026-11" |
| + f.ok(func(tx *sql.Tx) (Outcome, error) { return InstallBasis(tx, f, b, f.now) }) |
| + var n int |
| + f.db.QueryRow(`SELECT count(*) FROM rb_holds WHERE month='2026-11'`).Scan(&n) |
| + if n != 1 { |
| + t.Fatal(n) |
| + } |
| + } |
| + }) |
| + } |
| + } |
| +} |
| +func TestEvidenceRefusalsRollback(t *testing.T) { |
| + for _, kind := range []string{"malformed", "checker", "unknown", "account", "reservation", "subject", "clock", "database"} { |
| + t.Run(kind, func(t *testing.T) { |
| + f := fresh(t) |
| + f.basis("10") |
| + r := f.reserve("8") |
| + f.claim(r) |
| + ev := f.evidence("3") |
| + c := checks{} |
| + now := f.now |
| + switch kind { |
| + case "malformed": |
| + ev.Total = "-1" |
| + case "checker": |
| + c.fail = true |
| + case "unknown": |
| + ev.Resource = "unknown" |
| + case "account": |
| + ev.Account = "other" |
| + case "reservation": |
| + ev.Reservation = "other" |
| + case "subject": |
| + ev.Subject = "other" |
| + case "clock": |
| + now = now.Add(-time.Second) |
| + } |
| + ob := ObserveRequest{f.req(), ev} |
| + tx, _ := f.db.Begin() |
| + if kind == "database" { |
| + tx.Rollback() |
| + } |
| + _, e := Observe(tx, f, c, ob, now) |
| + if e == nil { |
| + t.Fatal("accepted") |
| + } |
| + tx.Rollback() |
| + var n int |
| + f.db.QueryRow(`SELECT count(*) FROM rb_receipts WHERE id=?`, ob.ID).Scan(&n) |
| + if n != 0 || f.held() != 0 || f.pool().Settled != Zero { |
| + t.Fatal("refusal changed accounting") |
| + } |
| + }) |
| + } |
| +} |
| +func TestReceiptConflict(t *testing.T) { |
| + f := fresh(t) |
| + b := f.basis("10") |
| + b.Account = "other" |
| + if _, e := f.call(true, func(tx *sql.Tx) (Outcome, error) { return InstallBasis(tx, f, b, f.now) }); !errors.Is(e, ErrConflict) { |
| + t.Fatal(e) |
| + } |
| +} |
| + |
| +func TestReplayRejectsInvalidUTF8AndTime(t *testing.T) { |
| + f := fresh(t) |
| + b := Basis{Request: f.req(), Revision: 1, Identity: f.gate.Identity, Digest: "\ufffd", Lines: []Line{{Awake, "10"}}} |
| + f.ok(func(tx *sql.Tx) (Outcome, error) { return InstallBasis(tx, f, b, f.now) }) |
| + for _, bad := range []string{string([]byte{0xff}), string([]byte{0xfe})} { |
| + badBasis := b |
| + badBasis.Digest = bad |
| + if _, e := f.call(true, func(tx *sql.Tx) (Outcome, error) { return InstallBasis(tx, f, badBasis, f.now) }); e == nil { |
| + t.Fatal("invalid UTF-8 replay alias") |
| + } |
| + } |
| + p := PaidLimit{Request: f.req(), Resource: Awake, Consent: 1, Offer: "approved", Identity: f.gate.Identity, Ceiling: "1", From: f.now, Until: time.Date(10000, 1, 1, 0, 0, 0, 0, time.UTC)} |
| + if _, e := f.call(true, func(tx *sql.Tx) (Outcome, error) { return InstallPaidLimit(tx, f, p, f.now) }); e == nil { |
| + t.Fatal("invalid time") |
| + } |
| + for _, v := range []any{Request{ID: string([]byte{0xff})}, Evidence{Subject: string([]byte{0xff})}, Identity{Terms: string([]byte{0xff})}, time.Date(10000, 1, 1, 0, 0, 0, 0, time.UTC)} { |
| + if _, e := digest(v); e == nil { |
| + t.Fatal("invalid digest input") |
| + } |
| + } |
| +} |
| + |
| +func TestClaimSubjectAndGlobalAttempt(t *testing.T) { |
| + f := fresh(t) |
| + f.basis("10") |
| + r := f.reserve("1") |
| + q := ClaimRequest{Request: f.req(), Reservation: r.Reservation, Producer: "producer", Attempt: "attempt", ProducerEpoch: 1} |
| + q.Subject = "wrong" |
| + tx, _ := f.db.Begin() |
| + if _, e := Claim(tx, f, q, f.now); e == nil { |
| + t.Fatal("wrong subject") |
| + } |
| + tx.Rollback() |
| + f.claim(r) |
| + r.Request = f.req() |
| + r.Reservation = "second" |
| + f.ok(func(tx *sql.Tx) (Outcome, error) { return Reserve(tx, f, r, f.now) }) |
| + q.Request = f.req() |
| + q.Reservation = "second" |
| + tx, _ = f.db.Begin() |
| + if _, e := Claim(tx, f, q, f.now); e == nil { |
| + t.Fatal("duplicate attempt") |
| + } |
| + tx.Rollback() |
| +} |
| + |
| +func TestAbsentPoolHoldActuallyBarsLaterReserve(t *testing.T) { |
| + f := fresh(t) |
| + f.basis("10") |
| + r := f.reserve("1") |
| + f.claim(r) |
| + ev := f.evidence("0") |
| + ev.Until = time.Date(2026, 11, 2, 0, 0, 0, 0, time.UTC) |
| + ob := ObserveRequest{f.req(), ev} |
| + f.ok(func(tx *sql.Tx) (Outcome, error) { return Observe(tx, f, checks{}, ob, f.now) }) |
| + b := Basis{Request: f.req(), Revision: 1, Identity: f.gate.Identity, Digest: "nov", Lines: []Line{{Awake, "10"}}} |
| + b.Month = "2026-11" |
| + f.ok(func(tx *sql.Tx) (Outcome, error) { return InstallBasis(tx, f, b, f.now) }) |
| + r.Request = f.req() |
| + r.Month = "2026-11" |
| + r.Reservation = "nov" |
| + r.From = time.Date(2026, 11, 1, 0, 0, 0, 0, time.UTC) |
| + r.Until = r.From.Add(time.Hour) |
| + r.ClaimBy = r.Until |
| + if _, e := f.call(true, func(tx *sql.Tx) (Outcome, error) { return Reserve(tx, f, r, r.From) }); e == nil { |
| + t.Fatal("absent-pool hold ignored") |
| + } |
| +} |
| + |
| +func TestMultiLineRollbackAndProofRefusal(t *testing.T) { |
| + f := fresh(t) |
| + b := Basis{Request: f.req(), Revision: 1, Identity: f.gate.Identity, Digest: "two", Lines: []Line{{Awake, "10"}, {Transfer, "0"}}} |
| + f.gate.Basis = map[Resource]BasisFact{Awake: {1, "two", f.gate.Identity}, Transfer: {1, "two", f.gate.Identity}} |
| + f.ok(func(tx *sql.Tx) (Outcome, error) { return InstallBasis(tx, f, b, f.now) }) |
| + r := ReserveRequest{Request: f.req(), Reservation: "res", Lines: []Line{{Awake, "2"}, {Transfer, "1"}}, BasisRevision: 1, From: f.now, Until: f.now.Add(time.Minute), ClaimBy: f.now.Add(time.Second)} |
| + if _, e := f.call(true, func(tx *sql.Tx) (Outcome, error) { return Reserve(tx, f, r, f.now) }); e == nil { |
| + t.Fatal("partial reservation") |
| + } |
| + if f.pool().Available != "10" { |
| + t.Fatal(f.pool()) |
| + } |
| + r.Lines = r.Lines[:1] |
| + f.ok(func(tx *sql.Tx) (Outcome, error) { return Reserve(tx, f, r, f.now) }) |
| + f.claim(r) |
| + fin := f.finish(f.evidence("1")) |
| + if _, e := f.call(true, func(tx *sql.Tx) (Outcome, error) { return Finish(tx, f, checks{fail: true}, fin, f.now) }); e == nil { |
| + t.Fatal("unproven finish") |
| + } |
| + if f.pool().Outstanding != "2" { |
| + t.Fatal("released without proof") |
| + } |
| +} |
| + |
| +func TestFinishRejectsDuplicateEvidenceBeforeWrites(t *testing.T) { |
| + f := fresh(t) |
| + f.gate.Basis = map[Resource]BasisFact{Awake: {1, "two", f.gate.Identity}, Transfer: {1, "two", f.gate.Identity}} |
| + b := Basis{Request: f.req(), Revision: 1, Identity: f.gate.Identity, Digest: "two", Lines: []Line{{Awake, "10"}, {Transfer, "10"}}} |
| + f.ok(func(tx *sql.Tx) (Outcome, error) { return InstallBasis(tx, f, b, f.now) }) |
| + r := ReserveRequest{Request: f.req(), Reservation: "res", Lines: []Line{{Awake, "8"}, {Transfer, "8"}}, BasisRevision: 1, From: f.now, Until: f.now.Add(time.Minute), ClaimBy: f.now.Add(time.Second)} |
| + f.ok(func(tx *sql.Tx) (Outcome, error) { return Reserve(tx, f, r, f.now) }) |
| + f.claim(r) |
| + awake := f.evidence("3") |
| + transfer := awake |
| + transfer.Resource = Transfer |
| + transfer.Total = "5" |
| + fin := f.finish(awake) |
| + fin.Evidence = append(fin.Evidence, transfer) |
| + tx, _ := f.db.Begin() |
| + defer tx.Rollback() |
| + var beforeEvents int |
| + tx.QueryRow(`SELECT count(*) FROM rb_events`).Scan(&beforeEvents) |
| + if _, e := Finish(tx, f, checks{}, fin, f.now); !errors.Is(e, ErrInvalid) { |
| + t.Fatalf("duplicate ID: %v", e) |
| + } |
| + var events, evidence, receipts int |
| + tx.QueryRow(`SELECT count(*) FROM rb_events`).Scan(&events) |
| + tx.QueryRow(`SELECT count(*) FROM rb_evidence`).Scan(&evidence) |
| + tx.QueryRow(`SELECT count(*) FROM rb_receipts WHERE id=?`, fin.ID).Scan(&receipts) |
| + if events != beforeEvents || evidence != 0 || receipts != 0 { |
| + t.Fatal(events, beforeEvents, evidence, receipts) |
| + } |
| + for _, resource := range []Resource{Awake, Transfer} { |
| + var p Pool |
| + if e := get(tx, "pools", poolKey("account", "2026-10", resource), &p); e != nil { |
| + t.Fatal(e) |
| + } |
| + if p.Outstanding != "8" || p.Settled != Zero { |
| + t.Fatal(p) |
| + } |
| + } |
| +} |
| + |
| +func TestSettleDefensivelyRejectsConflictingEvidence(t *testing.T) { |
| + f := fresh(t) |
| + f.basis("10") |
| + r := f.reserve("8") |
| + f.claim(r) |
| + ev := f.evidence("3") |
| + ob := ObserveRequest{f.req(), ev} |
| + f.ok(func(tx *sql.Tx) (Outcome, error) { return Observe(tx, f, checks{}, ob, f.now) }) |
| + tx, _ := f.db.Begin() |
| + defer tx.Rollback() |
| + var rr Reservation |
| + if e := get(tx, "reservations", "res", &rr); e != nil { |
| + t.Fatal(e) |
| + } |
| + o := &operation{tx: tx, r: f.req()} |
| + ev.Total = "4" |
| + if _, e := o.settle(&rr, ev); !errors.Is(e, ErrCorrupt) { |
| + t.Fatal(e) |
| + } |
| + var p Pool |
| + get(tx, "pools", poolKey("account", "2026-10", Awake), &p) |
| + if p.Settled != "3" { |
| + t.Fatal(p) |
| + } |
| +} |
| + |
| +func TestEvidenceAffectedMonthsUseUTC(t *testing.T) { |
| + for _, commit := range []bool{false, true} { |
| + t.Run(fmt.Sprint(commit), func(t *testing.T) { |
| + f := fresh(t) |
| + f.basis("10") |
| + r := f.reserve("8") |
| + f.claim(r) |
| + ev := f.evidence("3") |
| + ev.From = time.Date(2026, 10, 1, 0, 30, 0, 0, time.FixedZone("x", 3600)) |
| + ev.Until = time.Date(2026, 10, 1, 0, 10, 0, 0, time.UTC) |
| + ob := ObserveRequest{f.req(), ev} |
| + out, e := f.call(commit, func(tx *sql.Tx) (Outcome, error) { return Observe(tx, f, checks{}, ob, f.now) }) |
| + if e != nil || out.Receipt.Outcome != "held:evidence_window" { |
| + t.Fatal(out, e) |
| + } |
| + for _, month := range []string{"2026-09", "2026-10"} { |
| + var n int |
| + f.db.QueryRow(`SELECT count(*) FROM rb_holds WHERE month=?`, month).Scan(&n) |
| + want := 0 |
| + if commit { |
| + want = 1 |
| + } |
| + if n != want { |
| + t.Fatal(month, n, want) |
| + } |
| + } |
| + var n int |
| + f.db.QueryRow(`SELECT count(*) FROM rb_receipts WHERE id=?`, ob.ID).Scan(&n) |
| + want := 0 |
| + if commit { |
| + want = 1 |
| + } |
| + if n != want || f.pool().Settled != Zero { |
| + t.Fatal(n, f.pool()) |
| + } |
| + }) |
| + } |
| +} |
| + |
| +func TestFreshBasisFactsRequiredForReserveAndClaim(t *testing.T) { |
| + for _, op := range []string{"Reserve", "Claim"} { |
| + for _, kind := range []string{"missing", "revision", "digest", "identity"} { |
| + t.Run(op+kind, func(t *testing.T) { |
| + f := fresh(t) |
| + f.basis("10") |
| + r := ReserveRequest{Request: f.req(), Reservation: "res", Lines: []Line{{Awake, "1"}}, BasisRevision: 1, From: f.now, Until: f.now.Add(time.Minute), ClaimBy: f.now.Add(time.Second)} |
| + if op == "Claim" { |
| + f.ok(func(tx *sql.Tx) (Outcome, error) { return Reserve(tx, f, r, f.now) }) |
| + } |
| + fact := f.gate.Basis[Awake] |
| + switch kind { |
| + case "missing": |
| + delete(f.gate.Basis, Awake) |
| + case "revision": |
| + fact.Revision++ |
| + f.gate.Basis[Awake] = fact |
| + case "digest": |
| + fact.Digest = "new-authoritative-digest" |
| + f.gate.Basis[Awake] = fact |
| + case "identity": |
| + fact.Identity.Terms = "new-authoritative-terms" |
| + f.gate.Basis[Awake] = fact |
| + } |
| + tx, _ := f.db.Begin() |
| + defer tx.Rollback() |
| + var e error |
| + if op == "Reserve" { |
| + _, e = Reserve(tx, f, r, f.now) |
| + } else { |
| + _, e = Claim(tx, f, ClaimRequest{Request: f.req(), Reservation: "res", Producer: "p", Attempt: "a", ProducerEpoch: 1}, f.now) |
| + } |
| + if !errors.Is(e, ErrDenied) { |
| + t.Fatal(e) |
| + } |
| + }) |
| + } |
| + } |
| +} |
| + |
| +func TestClaimReturnsNilOnReceiptFailure(t *testing.T) { |
| + f := fresh(t) |
| + f.basis("10") |
| + r := f.reserve("1") |
| + q := ClaimRequest{Request: f.req(), Reservation: r.Reservation, Producer: "p", Attempt: "a", ProducerEpoch: 1} |
| + if _, e := f.db.Exec(`CREATE TRIGGER fail_claim_receipt BEFORE INSERT ON rb_receipts WHEN json_extract(NEW.body,'$.Kind')='Claim' BEGIN SELECT RAISE(ABORT,'injected'); END`); e != nil { |
| + t.Fatal(e) |
| + } |
| + tx, _ := f.db.Begin() |
| + v, e := Claim(tx, f, q, f.now) |
| + tx.Rollback() |
| + if e == nil || v != nil { |
| + t.Fatal(v, e) |
| + } |
| + tx, _ = f.db.Begin() |
| + defer tx.Rollback() |
| + var rr Reservation |
| + get(tx, "reservations", "res", &rr) |
| + if rr.State != "reserved" { |
| + t.Fatal(rr.State) |
| + } |
| +} |
diff --git a/internal/resourcebudget/recovery_test.go b/internal/resourcebudget/recovery_test.go| new file mode 100644 |
| index 000000000..0f0bc7b23 |
| --- /dev/null |
| +++ b/internal/resourcebudget/recovery_test.go |
| @@ -0,0 +1,254 @@ |
| +package resourcebudget |
| + |
| +import ( |
| + "database/sql" |
| + "errors" |
| + "fmt" |
| + "strings" |
| + "testing" |
| + "time" |
| +) |
| + |
| +func TestDurabilityAndReopen(t *testing.T) { |
| + for _, level := range []int{0, 1, 2, 3} { |
| + t.Run(fmt.Sprint(level), func(t *testing.T) { |
| + f := fresh(t) |
| + db, e := sql.Open("sqlite", strings.Split(f.dsn, "?")[0]+fmt.Sprintf("?_pragma=synchronous(%d)&_pragma=busy_timeout(5000)", level)) |
| + if e != nil { |
| + t.Fatal(e) |
| + } |
| + defer db.Close() |
| + db.SetMaxOpenConns(1) |
| + tx, _ := db.Begin() |
| + b := Basis{Request: f.req(), Revision: 1, Identity: f.gate.Identity, Digest: "b", Lines: []Line{{Awake, "10"}}} |
| + _, e = InstallBasis(tx, f, b, f.now) |
| + if (e == nil) != (level >= 2) { |
| + t.Fatal(e) |
| + } |
| + if e == nil { |
| + if e = tx.Commit(); e != nil { |
| + t.Fatal(e) |
| + } |
| + } else { |
| + tx.Rollback() |
| + } |
| + tx, _ = f.db.Begin() |
| + defer tx.Rollback() |
| + if e = Verify(tx); e != nil { |
| + t.Fatal(e) |
| + } |
| + }) |
| + } |
| +} |
| +func TestSeparateHandlesContestAndReplay(t *testing.T) { |
| + f := fresh(t) |
| + f.basis("1") |
| + db, e := sql.Open("sqlite", f.dsn) |
| + if e != nil { |
| + t.Fatal(e) |
| + } |
| + defer db.Close() |
| + db.SetMaxOpenConns(1) |
| + if e = db.Ping(); e != nil { |
| + t.Fatal(e) |
| + } |
| + r := ReserveRequest{Request: f.req(), Reservation: "one", Lines: []Line{{Awake, "1"}}, BasisRevision: 1, From: f.now, Until: f.now.Add(time.Minute), ClaimBy: f.now.Add(time.Second)} |
| + tx, _ := f.db.Begin() |
| + if _, e = Reserve(tx, f, r, f.now); e != nil { |
| + t.Fatal(e) |
| + } |
| + second := r |
| + second.Request = f.req() |
| + second.Reservation = "two" |
| + second.Subject = "other-app" |
| + started := make(chan struct{}) |
| + done := make(chan error, 1) |
| + go func() { |
| + other, err := db.Begin() |
| + if err != nil { |
| + done <- err |
| + return |
| + } |
| + defer other.Rollback() |
| + close(started) |
| + _, err = Reserve(other, f, second, f.now) |
| + if err == nil { |
| + err = other.Commit() |
| + } |
| + done <- err |
| + }() |
| + <-started |
| + if e = tx.Commit(); e != nil { |
| + t.Fatal(e) |
| + } |
| + select { |
| + case e = <-done: |
| + if !errors.Is(e, ErrDenied) { |
| + t.Fatal("expected capacity denial", e) |
| + } |
| + case <-time.After(6 * time.Second): |
| + t.Fatal("worker blocked") |
| + } |
| + tx, _ = db.Begin() |
| + o, e := Reserve(tx, f, r, f.now.Add(time.Hour)) |
| + if e != nil || !o.Replayed { |
| + t.Fatal(o, e) |
| + } |
| + tx.Rollback() |
| + tx, _ = db.Begin() |
| + defer tx.Rollback() |
| + if e = Verify(tx); e != nil { |
| + t.Fatal(e) |
| + } |
| +} |
| +func TestImmutableHistoryAndVerify(t *testing.T) { |
| + f := fresh(t) |
| + f.basis("10") |
| + r := f.reserve("8") |
| + f.claim(r) |
| + ob := ObserveRequest{f.req(), f.evidence("2")} |
| + f.ok(func(tx *sql.Tx) (Outcome, error) { return Observe(tx, f, checks{}, ob, f.now) }) |
| + for _, table := range []string{"receipts", "events", "basis", "evidence"} { |
| + for _, q := range []string{`DELETE FROM rb_` + table, `UPDATE rb_` + table + ` SET ` + map[string]string{"receipts": "body=body", "events": "body=body", "basis": "body=body", "evidence": "body=body"}[table], `INSERT OR REPLACE INTO rb_` + table + ` SELECT * FROM rb_` + table + ` LIMIT 1`} { |
| + if _, e := f.db.Exec(q); e == nil { |
| + t.Fatalf("immutable %s", q) |
| + } |
| + } |
| + } |
| + if _, e := f.db.Exec(`UPDATE rb_pools SET body='{}'`); e != nil { |
| + t.Fatal(e) |
| + } |
| + tx, _ := f.db.Begin() |
| + defer tx.Rollback() |
| + if e := Verify(tx); e == nil { |
| + t.Fatal("corruption unnoticed") |
| + } |
| +} |
| +func TestMonotoneIncreaseAndHoldAdmission(t *testing.T) { |
| + f := fresh(t) |
| + b := f.basis("10") |
| + b.Request = f.req() |
| + b.Revision = 2 |
| + b.Lines[0].Amount = "12" |
| + f.gate.Basis[Awake] = BasisFact{2, "basis", f.gate.Identity} |
| + f.ok(func(tx *sql.Tx) (Outcome, error) { return IncreaseBasis(tx, f, b, f.now) }) |
| + if f.pool().Available != "12" { |
| + t.Fatal(f.pool()) |
| + } |
| + r := ReserveRequest{Request: f.req(), Reservation: "res", Lines: []Line{{Awake, "1"}}, BasisRevision: 2, From: f.now, Until: f.now.Add(time.Minute), ClaimBy: f.now.Add(time.Second)} |
| + f.ok(func(tx *sql.Tx) (Outcome, error) { return Reserve(tx, f, r, f.now) }) |
| + b.Request = f.req() |
| + f.ok(func(tx *sql.Tx) (Outcome, error) { return IncreaseBasis(tx, f, b, f.now) }) |
| + q := ClaimRequest{Request: f.req(), Reservation: "res", Producer: "p", Attempt: "a", ProducerEpoch: 1} |
| + tx, _ := f.db.Begin() |
| + if _, e := Claim(tx, f, q, f.now); e == nil { |
| + t.Fatal("claim under hold") |
| + } |
| + tx.Rollback() |
| + r.Request = f.req() |
| + r.Reservation = "another" |
| + if _, e := f.call(true, func(tx *sql.Tx) (Outcome, error) { return Reserve(tx, f, r, f.now) }); e == nil { |
| + t.Fatal("reserve under hold") |
| + } |
| + cancel := ReservationRequest{f.req(), "res"} |
| + f.ok(func(tx *sql.Tx) (Outcome, error) { return CancelUnclaimed(tx, f, cancel, f.now) }) |
| +} |
| + |
| +func TestVerifyRejectsHistoryDespiteMatchingCurrentRows(t *testing.T) { |
| + f := fresh(t) |
| + f.basis("10") |
| + r := f.reserve("2") |
| + q := f.claim(r) |
| + // Simulate a defective writer, not an attacker: matching row/event payloads |
| + // cannot turn a Claim transition into a cancellation transition. |
| + if _, e := f.db.Exec(`DROP TRIGGER rb_receipts_UPDATE`); e != nil { |
| + t.Fatal(e) |
| + } |
| + if _, e := f.db.Exec(`UPDATE rb_receipts SET body=json_set(body,'$.Kind','CancelUnclaimed') WHERE id=?`, q.ID); e != nil { |
| + t.Fatal(e) |
| + } |
| + tx, _ := f.db.Begin() |
| + defer tx.Rollback() |
| + if e := Verify(tx); e == nil { |
| + t.Fatal("invalid history accepted") |
| + } |
| +} |
| + |
| +func TestPersistedMalformedAmountsRefuseWithoutPanic(t *testing.T) { |
| + for _, bad := range []string{"-1", "01", "not-a-number", "170141183460469231731687303715884105728"} { |
| + t.Run(bad, func(t *testing.T) { |
| + f := fresh(t) |
| + f.basis("10") |
| + if _, e := f.db.Exec(`UPDATE rb_pools SET body=json_set(body,'$.Available',?)`, bad); e != nil { |
| + t.Fatal(e) |
| + } |
| + r := ReserveRequest{Request: f.req(), Reservation: "res", Lines: []Line{{Awake, "1"}}, BasisRevision: 1, From: f.now, Until: f.now.Add(time.Minute), ClaimBy: f.now.Add(time.Second)} |
| + tx, _ := f.db.Begin() |
| + _, e := Reserve(tx, f, r, f.now) |
| + tx.Rollback() |
| + if !errors.Is(e, ErrCorrupt) { |
| + t.Fatal(e) |
| + } |
| + var n int |
| + f.db.QueryRow(`SELECT count(*) FROM rb_receipts WHERE id=?`, r.ID).Scan(&n) |
| + if n != 0 { |
| + t.Fatal("corrupt state wrote receipt") |
| + } |
| + }) |
| + } |
| + |
| +} |
| + |
| +func TestVerifyValidatesEveryHistoricalSnapshot(t *testing.T) { |
| + for _, table := range []string{"pools", "limits", "reservations"} { |
| + t.Run(table, func(t *testing.T) { |
| + f := fresh(t) |
| + f.basis("10") |
| + f.limit() |
| + f.reserve("1") |
| + // A defective writer can emit two snapshots in one operation. The first |
| + // must be validated even though the operation's final snapshot is valid. |
| + var id, body string |
| + if e := f.db.QueryRow(`SELECT id,body FROM rb_`+table+` LIMIT 1`).Scan(&id, &body); e != nil { |
| + t.Fatal(e) |
| + } |
| + tx, _ := f.db.Begin() |
| + defer tx.Rollback() |
| + if table == "pools" { |
| + var next string |
| + if e := tx.QueryRow(`SELECT json_set(?,'$.Month','2026-11')`, body).Scan(&next); e != nil { |
| + t.Fatal(e) |
| + } |
| + body = next |
| + id = poolKey("account", "2026-11", Awake) |
| + } |
| + var bad string |
| + field := map[string]string{"pools": "$.Capacity", "limits": "$.Unused", "reservations": "$.Allocations[0].Total"}[table] |
| + if e := tx.QueryRow(`SELECT json_set(?,?,'bad')`, body, field).Scan(&bad); e != nil { |
| + t.Fatal(e) |
| + } |
| + receipt := Receipt{ID: "bad-history", Kind: "InstallBasis", Account: "account", Subject: "app", Epoch: 1} |
| + if table == "limits" { |
| + receipt.Kind = "InstallPaidLimit" |
| + } |
| + if table == "reservations" { |
| + receipt.Kind = "Reserve" |
| + } |
| + raw, e := encode(receipt) |
| + if e != nil { |
| + t.Fatal(e) |
| + } |
| + if _, e = tx.Exec(`INSERT INTO rb_receipts VALUES(?,?,?)`, receipt.ID, "synthetic", string(raw)); e != nil { |
| + t.Fatal(e) |
| + } |
| + for _, b := range []string{bad, body} { |
| + if _, e = tx.Exec(`INSERT INTO rb_events(kind,id,body,epoch,operation_id) VALUES(?,?,?,1,?)`, table, id, b, receipt.ID); e != nil { |
| + t.Fatal(e) |
| + } |
| + } |
| + if e = Verify(tx); !errors.Is(e, ErrCorrupt) { |
| + t.Fatal(e) |
| + } |
| + }) |
| + } |
| +} |
diff --git a/internal/resourcebudget/reservation.go b/internal/resourcebudget/reservation.go| new file mode 100644 |
| index 000000000..f0666934b |
| --- /dev/null |
| +++ b/internal/resourcebudget/reservation.go |
| @@ -0,0 +1,271 @@ |
| +package resourcebudget |
| + |
| +import ( |
| + "database/sql" |
| + "encoding/json" |
| + "time" |
| +) |
| + |
| +func decode(s string, v any) error { |
| + if json.Unmarshal([]byte(s), v) != nil { |
| + return ErrCorrupt |
| + } |
| + return nil |
| +} |
| + |
| +type ReserveRequest struct { |
| + Request |
| + Reservation string |
| + Lines []Line |
| + BasisRevision int64 |
| + Consent int64 |
| + AllowPaid bool |
| + From, Until, ClaimBy time.Time |
| +} |
| +type Allocation struct { |
| + Resource Resource |
| + Maximum, Included, Paid, SettledIncluded, SettledPaid, Excess, Total Amount |
| + Limit string |
| +} |
| +type Reservation struct { |
| + ReserveRequest |
| + State, Producer, Attempt string |
| + ProducerEpoch int64 |
| + Allocations []Allocation |
| + TerminalProof *Terminal |
| +} |
| +type ClaimRequest struct { |
| + Request |
| + Reservation, Producer, Attempt string |
| + ProducerEpoch int64 |
| +} |
| +type ReservationRequest struct { |
| + Request |
| + Reservation string |
| +} |
| +type FenceRequest struct { |
| + Request |
| + Producer string |
| + ProducerEpoch int64 |
| +} |
| + |
| +func Reserve(tx *sql.Tx, l GateLoader, r ReserveRequest, now time.Time) (Outcome, error) { |
| + if validateLines(r.Lines) != nil || r.Reservation == "" || r.BasisRevision <= 0 || !window(r.Month, r.From, r.Until) || r.ClaimBy.IsZero() || r.ClaimBy.After(r.Until) { |
| + return Outcome{}, ErrInvalid |
| + } |
| + for _, line := range r.Lines { |
| + if line.Amount == Zero { |
| + return Outcome{}, ErrInvalid |
| + } |
| + } |
| + o, re, e := begin(tx, l, r.Request, "Reserve", r, now) |
| + if e != nil { |
| + return Outcome{}, e |
| + } |
| + if re != nil { |
| + return *re, nil |
| + } |
| + if !now.Before(r.ClaimBy) { |
| + return Outcome{}, ErrDenied |
| + } |
| + var old Reservation |
| + e = get(tx, "reservations", r.Reservation, &old) |
| + if e == nil { |
| + return Outcome{}, ErrConflict |
| + } |
| + if !missing(e) { |
| + return Outcome{}, e |
| + } |
| + rr := Reservation{ReserveRequest: r, State: "reserved"} |
| + for _, line := range r.Lines { |
| + if e = o.admit(line.Resource); e != nil { |
| + return Outcome{}, e |
| + } |
| + var p Pool |
| + if e = get(tx, "pools", poolKey(r.Account, r.Month, line.Resource), &p); e != nil { |
| + return Outcome{}, e |
| + } |
| + if p.Revision != r.BasisRevision || !currentBasis(o.gate, p) { |
| + return Outcome{}, ErrDenied |
| + } |
| + inc := min(line.Amount, p.Available) |
| + paid, _ := sub(line.Amount, inc) |
| + a := Allocation{Resource: line.Resource, Maximum: line.Amount, Included: inc, Paid: paid, SettledIncluded: Zero, SettledPaid: Zero, Excess: Zero, Total: Zero} |
| + if paid != Zero { |
| + if !r.AllowPaid || r.Consent <= 0 { |
| + return Outcome{}, ErrDenied |
| + } |
| + a.Limit = limitKey(r.Account, r.Month, line.Resource, r.Consent, r.Subject) |
| + var q PaidLimit |
| + if e = get(tx, "limits", a.Limit, &q); e != nil { |
| + return Outcome{}, e |
| + } |
| + if !paidGate(o.gate, q.Consent, q.Offer, q.Identity) || r.From.Before(q.From) || r.Until.After(q.Until) || cmp(q.Unused, paid) < 0 { |
| + return Outcome{}, ErrDenied |
| + } |
| + q.Unused, _ = sub(q.Unused, paid) |
| + q.Outstanding, e = add(q.Outstanding, paid) |
| + if e != nil { |
| + return Outcome{}, e |
| + } |
| + if e = o.put("limits", a.Limit, q, false); e != nil { |
| + return Outcome{}, e |
| + } |
| + } |
| + p.Available, _ = sub(p.Available, inc) |
| + p.Outstanding, e = add(p.Outstanding, inc) |
| + if e != nil { |
| + return Outcome{}, e |
| + } |
| + if e = o.put("pools", poolKey(r.Account, r.Month, line.Resource), p, false); e != nil { |
| + return Outcome{}, e |
| + } |
| + rr.Allocations = append(rr.Allocations, a) |
| + } |
| + if e = o.put("reservations", r.Reservation, rr, false); e != nil { |
| + return Outcome{}, e |
| + } |
| + return o.finish("reserved") |
| +} |
| +func bound(o *operation, id string) (Reservation, error) { |
| + var r Reservation |
| + e := get(o.tx, "reservations", id, &r) |
| + if e != nil { |
| + return r, e |
| + } |
| + if r.Account != o.r.Account || r.Month != o.r.Month || r.Subject != o.r.Subject || r.Epoch != o.r.Epoch { |
| + return r, ErrDenied |
| + } |
| + return r, nil |
| +} |
| +func Claim(tx *sql.Tx, l GateLoader, r ClaimRequest, now time.Time) (ClaimResult, error) { |
| + if r.Reservation == "" || r.Producer == "" || r.Attempt == "" || r.ProducerEpoch <= 0 { |
| + return nil, ErrInvalid |
| + } |
| + o, re, e := begin(tx, l, r.Request, "Claim", r, now) |
| + if e != nil { |
| + return nil, e |
| + } |
| + if re != nil { |
| + return ReplayedClaim{re.Receipt}, nil |
| + } |
| + rr, e := bound(o, r.Reservation) |
| + if e != nil { |
| + return nil, e |
| + } |
| + if rr.State != "reserved" || !now.Before(rr.ClaimBy) { |
| + return nil, ErrDenied |
| + } |
| + var fence FenceRequest |
| + e = get(tx, "fences", key(r.Account, r.Producer), &fence) |
| + if e != nil && !missing(e) { |
| + return nil, e |
| + } |
| + if e == nil && r.ProducerEpoch <= fence.ProducerEpoch { |
| + return nil, ErrDenied |
| + } |
| + for _, a := range rr.Allocations { |
| + if e = o.admit(a.Resource); e != nil { |
| + return nil, e |
| + } |
| + var p Pool |
| + if e = get(tx, "pools", poolKey(r.Account, r.Month, a.Resource), &p); e != nil { |
| + return nil, e |
| + } |
| + if p.Revision != rr.BasisRevision || !currentBasis(o.gate, p) { |
| + return nil, ErrDenied |
| + } |
| + if a.Paid != Zero { |
| + var q PaidLimit |
| + if e = get(tx, "limits", a.Limit, &q); e != nil { |
| + return nil, e |
| + } |
| + if !paidGate(o.gate, q.Consent, q.Offer, q.Identity) { |
| + return nil, ErrDenied |
| + } |
| + } |
| + } |
| + rr.State, rr.Producer, rr.Attempt, rr.ProducerEpoch = "claimed", r.Producer, r.Attempt, r.ProducerEpoch |
| + if e = o.put("reservations", r.Reservation, rr, false); e != nil { |
| + return nil, e |
| + } |
| + out, e := o.finish("claimed") |
| + if e != nil { |
| + return nil, e |
| + } |
| + return NewClaimProvisional{out.Receipt}, nil |
| +} |
| +func CancelUnclaimed(tx *sql.Tx, l GateLoader, r ReservationRequest, now time.Time) (Outcome, error) { |
| + o, re, e := begin(tx, l, r.Request, "CancelUnclaimed", r, now) |
| + if e != nil { |
| + return Outcome{}, e |
| + } |
| + if re != nil { |
| + return *re, nil |
| + } |
| + rr, e := bound(o, r.Reservation) |
| + if e != nil { |
| + return Outcome{}, e |
| + } |
| + if rr.State != "reserved" { |
| + return Outcome{}, ErrDenied |
| + } |
| + if e = release(o, &rr); e != nil { |
| + return Outcome{}, e |
| + } |
| + rr.State = "closed-unused" |
| + if e = o.put("reservations", r.Reservation, rr, false); e != nil { |
| + return Outcome{}, e |
| + } |
| + return o.finish("closed-unused") |
| +} |
| +func MarkUncertain(tx *sql.Tx, l GateLoader, r ReservationRequest, now time.Time) (Outcome, error) { |
| + o, re, e := begin(tx, l, r.Request, "MarkUncertain", r, now) |
| + if e != nil { |
| + return Outcome{}, e |
| + } |
| + if re != nil { |
| + return *re, nil |
| + } |
| + rr, e := bound(o, r.Reservation) |
| + if e != nil { |
| + return Outcome{}, e |
| + } |
| + if rr.State != "claimed" && rr.State != "uncertain" { |
| + return Outcome{}, ErrDenied |
| + } |
| + rr.State = "uncertain" |
| + if e = o.put("reservations", r.Reservation, rr, false); e != nil { |
| + return Outcome{}, e |
| + } |
| + return o.finish("uncertain") |
| +} |
| +func FenceProducer(tx *sql.Tx, l GateLoader, r FenceRequest, now time.Time) (Outcome, error) { |
| + if r.Producer == "" || r.ProducerEpoch <= 0 { |
| + return Outcome{}, ErrInvalid |
| + } |
| + o, re, e := begin(tx, l, r.Request, "FenceProducer", r, now) |
| + if e != nil { |
| + return Outcome{}, e |
| + } |
| + if re != nil { |
| + return *re, nil |
| + } |
| + var old FenceRequest |
| + e = get(tx, "fences", key(r.Account, r.Producer), &old) |
| + if e != nil && !missing(e) { |
| + return Outcome{}, e |
| + } |
| + if e == nil && r.ProducerEpoch <= old.ProducerEpoch { |
| + return Outcome{}, ErrDenied |
| + } |
| + if e = o.put("fences", key(r.Account, r.Producer), r, false); e != nil { |
| + return Outcome{}, e |
| + } |
| + return o.finish("fenced") |
| +} |
| + |
| +func currentBasis(g Gate, p Pool) bool { |
| + b, ok := g.Basis[p.Resource] |
| + return ok && b.Revision == p.Revision && b.Digest == p.Digest && b.Identity == p.Identity |
| +} |
diff --git a/internal/resourcebudget/schema.go b/internal/resourcebudget/schema.go| new file mode 100644 |
| index 000000000..ef3bc9ecb |
| --- /dev/null |
| +++ b/internal/resourcebudget/schema.go |
| @@ -0,0 +1,40 @@ |
| +package resourcebudget |
| + |
| +import "database/sql" |
| + |
| +// Migrate is not registered with console startup. Callers own its transaction. |
| +func Migrate(tx *sql.Tx) error { |
| + for _, s := range []string{ |
| + `CREATE TABLE IF NOT EXISTS rb_authority(account TEXT PRIMARY KEY, serial INTEGER NOT NULL, epoch INTEGER NOT NULL, latest INTEGER NOT NULL)`, |
| + `CREATE TABLE IF NOT EXISTS rb_receipts(id TEXT PRIMARY KEY, digest TEXT NOT NULL, body TEXT NOT NULL)`, |
| + `CREATE TABLE IF NOT EXISTS rb_events(seq INTEGER PRIMARY KEY, kind TEXT NOT NULL, id TEXT NOT NULL, body TEXT NOT NULL, epoch INTEGER NOT NULL, operation_id TEXT NOT NULL)`, |
| + `CREATE TABLE IF NOT EXISTS rb_holds(id TEXT PRIMARY KEY, account TEXT NOT NULL, month TEXT NOT NULL, resource TEXT NOT NULL, reason TEXT NOT NULL, epoch INTEGER NOT NULL)`, |
| + } { |
| + if _, e := tx.Exec(s); e != nil { |
| + return e |
| + } |
| + } |
| + for _, table := range []string{"pools", "limits", "reservations", "fences", "evidence", "basis"} { |
| + if _, e := tx.Exec(`CREATE TABLE IF NOT EXISTS rb_` + table + `(id TEXT PRIMARY KEY, body TEXT NOT NULL)`); e != nil { |
| + return e |
| + } |
| + } |
| + if _, e := tx.Exec(`CREATE UNIQUE INDEX IF NOT EXISTS rb_attempt_unique ON rb_reservations(json_extract(body,'$.Account'),json_extract(body,'$.Producer'),json_extract(body,'$.ProducerEpoch'),json_extract(body,'$.Attempt')) WHERE json_extract(body,'$.Attempt')<>''`); e != nil { |
| + return e |
| + } |
| + for _, table := range []string{"receipts", "events", "holds", "evidence", "basis"} { |
| + key := "id" |
| + if table == "events" { |
| + key = "seq" |
| + } |
| + for _, op := range []string{"UPDATE", "DELETE"} { |
| + if _, e := tx.Exec(`CREATE TRIGGER IF NOT EXISTS rb_` + table + `_` + op + ` BEFORE ` + op + ` ON rb_` + table + ` BEGIN SELECT RAISE(ABORT,'resourcebudget: immutable'); END`); e != nil { |
| + return e |
| + } |
| + } |
| + if _, e := tx.Exec(`CREATE TRIGGER IF NOT EXISTS rb_` + table + `_replace BEFORE INSERT ON rb_` + table + ` WHEN EXISTS(SELECT 1 FROM rb_` + table + ` WHERE ` + key + `=NEW.` + key + `) BEGIN SELECT RAISE(ABORT,'resourcebudget: immutable'); END`); e != nil { |
| + return e |
| + } |
| + } |
| + return nil |
| +} |
diff --git a/internal/resourcebudget/settlement.go b/internal/resourcebudget/settlement.go| new file mode 100644 |
| index 000000000..d4ea87956 |
| --- /dev/null |
| +++ b/internal/resourcebudget/settlement.go |
| @@ -0,0 +1,375 @@ |
| +package resourcebudget |
| + |
| +import ( |
| + "database/sql" |
| + "time" |
| +) |
| + |
| +type Evidence struct { |
| + ID, Account, Reservation, Subject, Attempt, Producer string |
| + ProducerEpoch int64 |
| + Resource Resource |
| + Total Amount |
| + From, Until time.Time |
| +} |
| +type Terminal struct { |
| + ID, Account, Reservation, Subject, Attempt, Producer string |
| + ProducerEpoch int64 |
| +} |
| +type Checker interface { |
| + Measurement(Evidence) error |
| + Terminal(Terminal) error |
| +} |
| +type ObserveRequest struct { |
| + Request |
| + Evidence Evidence |
| +} |
| +type FinishRequest struct { |
| + Request |
| + Reservation string |
| + Evidence []Evidence |
| + Terminal Terminal |
| +} |
| + |
| +func validEvidence(e Evidence) error { |
| + if e.ID == "" || e.Account == "" || e.Reservation == "" || e.Subject == "" || e.Attempt == "" || e.Producer == "" || e.ProducerEpoch <= 0 || !e.Resource.valid() || !e.From.Before(e.Until) { |
| + return ErrInvalid |
| + } |
| + _, err := number(e.Total) |
| + return err |
| +} |
| +func evidenceBound(r Reservation, e Evidence) bool { |
| + return e.Account == r.Account && e.Reservation == r.Reservation && e.Subject == r.Subject && e.Attempt == r.Attempt && e.Producer == r.Producer && e.ProducerEpoch == r.ProducerEpoch |
| +} |
| +func affected(r Reservation, e Evidence) []string { |
| + m := []string{r.Month} |
| + from := e.From.UTC() |
| + a := time.Date(from.Year(), from.Month(), 1, 0, 0, 0, 0, time.UTC) |
| + end := e.Until.Add(-time.Nanosecond) |
| + for !a.After(end) { |
| + m = append(m, a.Format("2006-01")) |
| + a = a.AddDate(0, 1, 0) |
| + } |
| + return m |
| +} |
| +func (o *operation) checkEvidence(r Reservation, e Evidence) (string, error) { |
| + if !evidenceBound(r, e) { |
| + return "", ErrDenied |
| + } |
| + found := false |
| + for _, a := range r.Allocations { |
| + if a.Resource == e.Resource { |
| + found = true |
| + } |
| + } |
| + if !found { |
| + return "", ErrDenied |
| + } |
| + var old Evidence |
| + err := get(o.tx, "evidence", e.ID, &old) |
| + oldDigest, de := digest(old) |
| + if de != nil { |
| + return "", de |
| + } |
| + newDigest, de := digest(e) |
| + if de != nil { |
| + return "", de |
| + } |
| + if err == nil && oldDigest != newDigest { |
| + return "evidence_conflict", nil |
| + } |
| + if err != nil && !missing(err) { |
| + return "", err |
| + } |
| + if e.From.Before(r.From) || e.Until.After(r.Until) || !window(r.Month, e.From, e.Until) { |
| + return "evidence_window", nil |
| + } |
| + for _, a := range r.Allocations { |
| + if a.Resource == e.Resource && cmp(e.Total, a.Total) < 0 { |
| + return "regression", nil |
| + } |
| + } |
| + return "", nil |
| +} |
| +func Observe(tx *sql.Tx, l GateLoader, c Checker, r ObserveRequest, now time.Time) (Outcome, error) { |
| + if c == nil || validEvidence(r.Evidence) != nil { |
| + return Outcome{}, ErrInvalid |
| + } |
| + o, re, e := begin(tx, l, r.Request, "Observe", r, now) |
| + if e != nil { |
| + return Outcome{}, e |
| + } |
| + if re != nil { |
| + return *re, nil |
| + } |
| + if e := c.Measurement(r.Evidence); e != nil { |
| + return Outcome{}, e |
| + } |
| + rr, e := bound(o, r.Evidence.Reservation) |
| + if e != nil { |
| + return Outcome{}, e |
| + } |
| + if !evidenceBound(rr, r.Evidence) { |
| + return Outcome{}, ErrDenied |
| + } |
| + reason, e := o.checkEvidence(rr, r.Evidence) |
| + if e != nil { |
| + return Outcome{}, e |
| + } |
| + if reason != "" { |
| + return o.anomaly(r.Evidence.Resource, reason, affected(rr, r.Evidence)...) |
| + } |
| + if rr.State == "closed" { |
| + for _, a := range rr.Allocations { |
| + if a.Resource == r.Evidence.Resource && a.Total != r.Evidence.Total { |
| + return o.anomaly(a.Resource, "finish_contradiction") |
| + } |
| + } |
| + return o.finish("closed") |
| + } |
| + if rr.State != "claimed" && rr.State != "uncertain" { |
| + return Outcome{}, ErrDenied |
| + } |
| + over, e := o.settle(&rr, r.Evidence) |
| + if e != nil { |
| + return Outcome{}, e |
| + } |
| + if e = o.put("reservations", rr.Reservation, rr, false); e != nil { |
| + return Outcome{}, e |
| + } |
| + if over { |
| + return o.anomaly(r.Evidence.Resource, "overshoot") |
| + } |
| + return o.finish("observed") |
| +} |
| +func (o *operation) settle(r *Reservation, e Evidence) (bool, error) { |
| + var prior Evidence |
| + err := get(o.tx, "evidence", e.ID, &prior) |
| + if missing(err) { |
| + if err = o.put("evidence", e.ID, e, true); err != nil { |
| + return false, err |
| + } |
| + } else if err != nil { |
| + return false, err |
| + } else { |
| + oldDigest, err := digest(prior) |
| + if err != nil { |
| + return false, err |
| + } |
| + newDigest, err := digest(e) |
| + if err != nil { |
| + return false, err |
| + } |
| + if oldDigest != newDigest { |
| + return false, ErrCorrupt |
| + } |
| + } |
| + for i, a := range r.Allocations { |
| + if a.Resource != e.Resource { |
| + continue |
| + } |
| + bounded := min(e.Total, a.Maximum) |
| + inc := min(bounded, a.Included) |
| + paid, _ := sub(bounded, inc) |
| + excess, _ := sub(e.Total, bounded) |
| + di, _ := sub(inc, a.SettledIncluded) |
| + dp, _ := sub(paid, a.SettledPaid) |
| + dx, _ := sub(excess, a.Excess) |
| + var p Pool |
| + if err = get(o.tx, "pools", poolKey(r.Account, r.Month, a.Resource), &p); err != nil { |
| + return false, err |
| + } |
| + p.Outstanding, err = sub(p.Outstanding, di) |
| + if err != nil { |
| + return false, err |
| + } |
| + p.Settled, err = add(p.Settled, di) |
| + if err != nil { |
| + return false, err |
| + } |
| + p.Excess, err = add(p.Excess, dx) |
| + if err != nil { |
| + return false, err |
| + } |
| + if err = o.put("pools", poolKey(r.Account, r.Month, a.Resource), p, false); err != nil { |
| + return false, err |
| + } |
| + if a.Limit != "" { |
| + var q PaidLimit |
| + if err = get(o.tx, "limits", a.Limit, &q); err != nil { |
| + return false, err |
| + } |
| + q.Outstanding, err = sub(q.Outstanding, dp) |
| + if err != nil { |
| + return false, err |
| + } |
| + q.Settled, err = add(q.Settled, dp) |
| + if err != nil { |
| + return false, err |
| + } |
| + if err = o.put("limits", a.Limit, q, false); err != nil { |
| + return false, err |
| + } |
| + } |
| + a.SettledIncluded, a.SettledPaid, a.Total, a.Excess = inc, paid, e.Total, excess |
| + r.Allocations[i] = a |
| + return excess != Zero, nil |
| + } |
| + return false, ErrDenied |
| +} |
| +func Finish(tx *sql.Tx, l GateLoader, c Checker, r FinishRequest, now time.Time) (Outcome, error) { |
| + if c == nil || r.Reservation == "" || len(r.Evidence) == 0 || len(r.Evidence) > 3 || r.Terminal.ID == "" { |
| + return Outcome{}, ErrInvalid |
| + } |
| + var last Resource |
| + seen := map[string]bool{} |
| + for _, e := range r.Evidence { |
| + if seen[e.ID] { |
| + return Outcome{}, ErrInvalid |
| + } |
| + seen[e.ID] = true |
| + if validEvidence(e) != nil || e.Resource <= last { |
| + return Outcome{}, ErrInvalid |
| + } |
| + last = e.Resource |
| + } |
| + o, re, err := begin(tx, l, r.Request, "Finish", r, now) |
| + if err != nil { |
| + return Outcome{}, err |
| + } |
| + if re != nil { |
| + return *re, nil |
| + } |
| + if e := c.Terminal(r.Terminal); e != nil { |
| + return Outcome{}, e |
| + } |
| + for _, v := range r.Evidence { |
| + if e := c.Measurement(v); e != nil { |
| + return Outcome{}, e |
| + } |
| + } |
| + rr, err := bound(o, r.Reservation) |
| + if err != nil { |
| + return Outcome{}, err |
| + } |
| + t := r.Terminal |
| + if t.Account != rr.Account || t.Reservation != rr.Reservation || t.Subject != rr.Subject || t.Attempt != rr.Attempt || t.Producer != rr.Producer || t.ProducerEpoch != rr.ProducerEpoch { |
| + return Outcome{}, ErrDenied |
| + } |
| + if len(r.Evidence) != len(rr.Allocations) { |
| + return Outcome{}, ErrInvalid |
| + } |
| + reason := "" |
| + for i, e := range r.Evidence { |
| + if e.Resource != rr.Allocations[i].Resource { |
| + return Outcome{}, ErrInvalid |
| + } |
| + why, err := o.checkEvidence(rr, e) |
| + if err != nil { |
| + return Outcome{}, err |
| + } |
| + if why != "" { |
| + reason = why |
| + } |
| + if rr.State == "closed" && e.Total != rr.Allocations[i].Total { |
| + reason = "finish_contradiction" |
| + } |
| + } |
| + if reason != "" { |
| + seen := map[string]bool{} |
| + for _, e := range r.Evidence { |
| + for _, m := range affected(rr, e) { |
| + k := key(m, e.Resource) |
| + if !seen[k] { |
| + seen[k] = true |
| + if err = o.hold(m, e.Resource, reason); err != nil { |
| + return Outcome{}, err |
| + } |
| + } |
| + } |
| + } |
| + if err = o.put("basis", key("anomaly", r.ID), r, true); err != nil { |
| + return Outcome{}, err |
| + } |
| + return o.finish("held:" + reason) |
| + } |
| + if rr.State == "closed" { |
| + return o.finish("closed") |
| + } |
| + if rr.State != "claimed" && rr.State != "uncertain" { |
| + return Outcome{}, ErrDenied |
| + } |
| + over := false |
| + for _, e := range r.Evidence { |
| + v, err := o.settle(&rr, e) |
| + if err != nil { |
| + return Outcome{}, err |
| + } |
| + if v { |
| + over = true |
| + if err = o.hold(rr.Month, e.Resource, "overshoot"); err != nil { |
| + return Outcome{}, err |
| + } |
| + } |
| + } |
| + if err = release(o, &rr); err != nil { |
| + return Outcome{}, err |
| + } |
| + rr.State = "closed" |
| + rr.TerminalProof = &r.Terminal |
| + if err = o.put("reservations", rr.Reservation, rr, false); err != nil { |
| + return Outcome{}, err |
| + } |
| + if over { |
| + if err = o.put("basis", key("anomaly", r.ID), r, true); err != nil { |
| + return Outcome{}, err |
| + } |
| + return o.finish("held:overshoot") |
| + } |
| + return o.finish("closed") |
| +} |
| +func release(o *operation, r *Reservation) error { |
| + for _, a := range r.Allocations { |
| + di, err := sub(a.Included, a.SettledIncluded) |
| + if err != nil { |
| + return err |
| + } |
| + dp, err := sub(a.Paid, a.SettledPaid) |
| + if err != nil { |
| + return err |
| + } |
| + var p Pool |
| + if err = get(o.tx, "pools", poolKey(r.Account, r.Month, a.Resource), &p); err != nil { |
| + return err |
| + } |
| + p.Outstanding, err = sub(p.Outstanding, di) |
| + if err != nil { |
| + return err |
| + } |
| + p.Available, err = add(p.Available, di) |
| + if err != nil { |
| + return err |
| + } |
| + if err = o.put("pools", poolKey(r.Account, r.Month, a.Resource), p, false); err != nil { |
| + return err |
| + } |
| + if a.Limit != "" { |
| + var q PaidLimit |
| + if err = get(o.tx, "limits", a.Limit, &q); err != nil { |
| + return err |
| + } |
| + q.Outstanding, err = sub(q.Outstanding, dp) |
| + if err != nil { |
| + return err |
| + } |
| + q.Unused, err = add(q.Unused, dp) |
| + if err != nil { |
| + return err |
| + } |
| + if err = o.put("limits", a.Limit, q, false); err != nil { |
| + return err |
| + } |
| + } |
| + } |
| + return nil |
| +} |
diff --git a/internal/resourcebudget/verify.go b/internal/resourcebudget/verify.go| new file mode 100644 |
| index 000000000..1a9160ef4 |
| --- /dev/null |
| +++ b/internal/resourcebudget/verify.go |
| @@ -0,0 +1,319 @@ |
| +package resourcebudget |
| + |
| +import ( |
| + "database/sql" |
| + "reflect" |
| +) |
| + |
| +// Verify replays event groups, checks each operation boundary and compares live rows. |
| +func Verify(tx *sql.Tx) error { |
| + receipts := map[string]Receipt{} |
| + rows, e := tx.Query(`SELECT id,body FROM rb_receipts`) |
| + if e != nil { |
| + return e |
| + } |
| + for rows.Next() { |
| + var id, b string |
| + if e = rows.Scan(&id, &b); e != nil { |
| + rows.Close() |
| + return e |
| + } |
| + var r Receipt |
| + if decode(b, &r) != nil || r.ID != id { |
| + rows.Close() |
| + return ErrCorrupt |
| + } |
| + receipts[id] = r |
| + } |
| + e = rows.Err() |
| + rows.Close() |
| + if e != nil { |
| + return e |
| + } |
| + latest := map[string]string{} |
| + pools := map[string]Pool{} |
| + limits := map[string]PaidLimit{} |
| + rs := map[string]Reservation{} |
| + closedOps := map[string]bool{} |
| + previous := "" |
| + check := func() error { |
| + var list []Reservation |
| + for _, r := range rs { |
| + list = append(list, r) |
| + } |
| + return balances(pools, limits, list) |
| + } |
| + rows, e = tx.Query(`SELECT kind,id,body,epoch,operation_id FROM rb_events ORDER BY seq`) |
| + if e != nil { |
| + return e |
| + } |
| + for rows.Next() { |
| + var table, id, b, op string |
| + var epoch int64 |
| + if e = rows.Scan(&table, &id, &b, &epoch, &op); e != nil { |
| + rows.Close() |
| + return e |
| + } |
| + receipt, ok := receipts[op] |
| + if !ok || receipt.Epoch != epoch { |
| + rows.Close() |
| + return ErrCorrupt |
| + } |
| + if op != previous { |
| + if closedOps[op] { |
| + rows.Close() |
| + return ErrCorrupt |
| + } |
| + if e = check(); e != nil { |
| + rows.Close() |
| + return e |
| + } |
| + if previous != "" { |
| + closedOps[previous] = true |
| + } |
| + previous = op |
| + } |
| + switch table { |
| + case "pools": |
| + var p Pool |
| + if decode(b, &p) != nil || validateStored(table, id, &p) != nil || p.Account != receipt.Account || poolKey(p.Account, p.Month, p.Resource) != id { |
| + rows.Close() |
| + return ErrCorrupt |
| + } |
| + if old, ok := pools[id]; ok { |
| + if old.Identity != p.Identity || old.Account != p.Account || old.Month != p.Month || old.Resource != p.Resource { |
| + rows.Close() |
| + return ErrCorrupt |
| + } |
| + if _, err := number(p.Capacity); err != nil { |
| + rows.Close() |
| + return err |
| + } |
| + if cmp(p.Capacity, old.Capacity) < 0 || p.Revision < old.Revision { |
| + rows.Close() |
| + return ErrCorrupt |
| + } |
| + if (p.Capacity != old.Capacity || p.Revision != old.Revision || p.Digest != old.Digest) && receipt.Kind != "IncreaseBasis" { |
| + rows.Close() |
| + return ErrCorrupt |
| + } |
| + } else if receipt.Kind != "InstallBasis" { |
| + rows.Close() |
| + return ErrCorrupt |
| + } |
| + pools[id] = p |
| + case "limits": |
| + var p PaidLimit |
| + if decode(b, &p) != nil || validateStored(table, id, &p) != nil || p.Account != receipt.Account || limitKey(p.Account, p.Month, p.Resource, p.Consent, p.Subject) != id { |
| + rows.Close() |
| + return ErrCorrupt |
| + } |
| + if old, ok := limits[id]; ok { |
| + old.Unused, old.Settled, old.Outstanding = p.Unused, p.Settled, p.Outstanding |
| + if !reflect.DeepEqual(old, p) { |
| + rows.Close() |
| + return ErrCorrupt |
| + } |
| + } else if receipt.Kind != "InstallPaidLimit" { |
| + rows.Close() |
| + return ErrCorrupt |
| + } |
| + limits[id] = p |
| + case "reservations": |
| + var r Reservation |
| + if decode(b, &r) != nil || validateStored(table, id, &r) != nil || r.Account != receipt.Account || r.Subject != receipt.Subject || r.Reservation != id { |
| + rows.Close() |
| + return ErrCorrupt |
| + } |
| + if old, ok := rs[id]; ok { |
| + if !reflect.DeepEqual(old.ReserveRequest, r.ReserveRequest) || len(old.Allocations) != len(r.Allocations) { |
| + rows.Close() |
| + return ErrCorrupt |
| + } |
| + for i, a := range r.Allocations { |
| + o := old.Allocations[i] |
| + if _, e := number(a.Total); e != nil { |
| + rows.Close() |
| + return e |
| + } |
| + if cmp(a.Total, o.Total) < 0 { |
| + rows.Close() |
| + return ErrCorrupt |
| + } |
| + |
| + a.SettledIncluded, a.SettledPaid, a.Excess, a.Total = o.SettledIncluded, o.SettledPaid, o.Excess, o.Total |
| + if a != o { |
| + rows.Close() |
| + return ErrCorrupt |
| + } |
| + } |
| + valid := false |
| + switch receipt.Kind { |
| + case "Claim": |
| + valid = old.State == "reserved" && r.State == "claimed" |
| + case "CancelUnclaimed": |
| + valid = old.State == "reserved" && r.State == "closed-unused" |
| + case "MarkUncertain": |
| + valid = (old.State == "claimed" || old.State == "uncertain") && r.State == "uncertain" |
| + case "Observe": |
| + valid = (old.State == "claimed" || old.State == "uncertain") && r.State == old.State |
| + case "Finish": |
| + valid = (old.State == "claimed" || old.State == "uncertain") && r.State == "closed" |
| + } |
| + if !valid { |
| + rows.Close() |
| + return ErrCorrupt |
| + } |
| + if receipt.Kind != "Claim" && (r.Producer != old.Producer || r.Attempt != old.Attempt || r.ProducerEpoch != old.ProducerEpoch) { |
| + rows.Close() |
| + return ErrCorrupt |
| + } |
| + } else if receipt.Kind != "Reserve" || r.State != "reserved" { |
| + rows.Close() |
| + return ErrCorrupt |
| + } |
| + rs[id] = r |
| + case "basis", "evidence", "fences": |
| + default: |
| + rows.Close() |
| + return ErrCorrupt |
| + } |
| + latest[key(table, id)] = b |
| + } |
| + e = rows.Err() |
| + rows.Close() |
| + if e != nil { |
| + return e |
| + } |
| + if e = check(); e != nil { |
| + return e |
| + } |
| + for _, table := range []string{"pools", "limits", "reservations", "fences", "evidence", "basis"} { |
| + rows, e = tx.Query(`SELECT id,body FROM rb_` + table) |
| + if e != nil { |
| + return e |
| + } |
| + for rows.Next() { |
| + var id, b string |
| + if e = rows.Scan(&id, &b); e != nil { |
| + rows.Close() |
| + return e |
| + } |
| + k := key(table, id) |
| + if latest[k] != b { |
| + rows.Close() |
| + return ErrCorrupt |
| + } |
| + delete(latest, k) |
| + } |
| + e = rows.Err() |
| + rows.Close() |
| + if e != nil { |
| + return e |
| + } |
| + } |
| + if len(latest) != 0 { |
| + return ErrCorrupt |
| + } |
| + return nil |
| +} |
| +func balances(pools map[string]Pool, limits map[string]PaidLimit, reservations []Reservation) error { |
| + type sums struct{ s, o, x Amount } |
| + ps, ls := map[string]sums{}, map[string]sums{} |
| + accum := func(m map[string]sums, k string, s, o, x Amount) error { |
| + v, ok := m[k] |
| + if !ok { |
| + v = sums{Zero, Zero, Zero} |
| + } |
| + var err error |
| + v.s, err = add(v.s, s) |
| + if err != nil { |
| + return err |
| + } |
| + v.o, err = add(v.o, o) |
| + if err != nil { |
| + return err |
| + } |
| + v.x, err = add(v.x, x) |
| + if err != nil { |
| + return err |
| + } |
| + m[k] = v |
| + return nil |
| + } |
| + for _, r := range reservations { |
| + for _, a := range r.Allocations { |
| + for _, n := range []Amount{a.Maximum, a.Included, a.Paid, a.SettledIncluded, a.SettledPaid, a.Excess, a.Total} { |
| + if _, e := number(n); e != nil { |
| + return ErrCorrupt |
| + } |
| + } |
| + bounded := min(a.Total, a.Maximum) |
| + included := min(bounded, a.Included) |
| + paid, _ := sub(bounded, included) |
| + excess, _ := sub(a.Total, bounded) |
| + if a.SettledIncluded != included || a.SettledPaid != paid || a.Excess != excess { |
| + return ErrCorrupt |
| + } |
| + sum, e := add(a.Included, a.Paid) |
| + if e != nil || sum != a.Maximum { |
| + return ErrCorrupt |
| + } |
| + di, e := sub(a.Included, a.SettledIncluded) |
| + if e != nil { |
| + return e |
| + } |
| + dp, e := sub(a.Paid, a.SettledPaid) |
| + if e != nil { |
| + return e |
| + } |
| + if r.State == "closed" || r.State == "closed-unused" { |
| + di, dp = Zero, Zero |
| + } |
| + pk := poolKey(r.Account, r.Month, a.Resource) |
| + if _, ok := pools[pk]; !ok { |
| + return ErrCorrupt |
| + } |
| + if e := accum(ps, pk, a.SettledIncluded, di, a.Excess); e != nil { |
| + return e |
| + } |
| + if a.Limit != "" { |
| + q, ok := limits[a.Limit] |
| + if !ok || q.Account != r.Account || q.Month != r.Month || q.Subject != r.Subject || q.Resource != a.Resource || q.Consent != r.Consent { |
| + return ErrCorrupt |
| + } |
| + if e := accum(ls, a.Limit, a.SettledPaid, dp, Zero); e != nil { |
| + return e |
| + } |
| + } |
| + } |
| + } |
| + compare := func(cap, avail, settled, outstanding, excess Amount, s sums) error { |
| + if s.s == "" { |
| + s = sums{Zero, Zero, Zero} |
| + } |
| + v, e := add(avail, settled) |
| + if e != nil { |
| + return e |
| + } |
| + v, e = add(v, outstanding) |
| + if e != nil { |
| + return e |
| + } |
| + if v != cap || settled != s.s || outstanding != s.o || excess != s.x { |
| + return ErrCorrupt |
| + } |
| + return nil |
| + } |
| + for k, p := range pools { |
| + if e := compare(p.Capacity, p.Available, p.Settled, p.Outstanding, p.Excess, ps[k]); e != nil { |
| + return e |
| + } |
| + } |
| + for k, p := range limits { |
| + if e := compare(p.Ceiling, p.Unused, p.Settled, p.Outstanding, Zero, ls[k]); e != nil { |
| + return e |
| + } |
| + } |
| + return nil |
| +} |