diff --git a/.ciagent/CHECKPOINT.json b/.ciagent/CHECKPOINT.json
index 2539764..b7ff5a7 100644
--- a/.ciagent/CHECKPOINT.json
+++ b/.ciagent/CHECKPOINT.json
@@ -1,5 +1,5 @@
{
- "phase": 3,
+ "phase": 4,
"stage": "complete",
"milestone": "v0.6",
"milestone_type": "feature",
@@ -7,7 +7,7 @@
"phase_role": "execution",
"project": "oy",
"attempts": 0,
- "updated_at": "2026-08-18T14:20:00Z",
+ "updated_at": "2026-08-18T14:35:00Z",
"milestone_complete": false,
- "requirements_covered": ["REQ-040", "REQ-041", "REQ-042", "REQ-045"]
+ "requirements_covered": ["REQ-040", "REQ-041", "REQ-042", "REQ-043", "REQ-045"]
}
diff --git a/web/handlers/server.go b/web/handlers/server.go
index b52a7ad..3b38ebb 100644
--- a/web/handlers/server.go
+++ b/web/handlers/server.go
@@ -15,6 +15,7 @@ import (
"path/filepath"
"github.com/oy/openyield/web/store"
+ standingtypes "github.com/oy/openyield/x/standing/types"
windowtypes "github.com/oy/openyield/x/window/types"
)
@@ -47,6 +48,8 @@ func New(s *store.Store, templatesDir string) (*Server, error) {
return t
case windowtypes.WindowStatus:
return string(t)
+ case standingtypes.StandingBucket:
+ return string(t)
default:
return ""
}
@@ -87,7 +90,8 @@ func (s *Server) Register(mux *http.ServeMux) {
s.registerReach(mux)
s.registerStash(mux)
s.registerWindow(mux)
- // P4..P5 register their own routes (standing, bloom).
+ s.registerStanding(mux)
+ // P5 registers bloom.
}
// render executes the named page template with the given data, writing HTML
diff --git a/web/handlers/standing.go b/web/handlers/standing.go
new file mode 100644
index 0000000..5cb5679
--- /dev/null
+++ b/web/handlers/standing.go
@@ -0,0 +1,59 @@
+package handlers
+
+import (
+ "net/http"
+
+ standingtypes "github.com/oy/openyield/x/standing/types"
+)
+
+// registerStanding wires the Standing + Freeholder signals route (REQ-043).
+func (s *Server) registerStanding(mux *http.ServeMux) {
+ mux.HandleFunc("GET /standing/{reachID}", s.handleStanding)
+}
+
+// standingViewData is the template data for the Standing screen.
+type standingViewData struct {
+ ReachID string
+ Found bool
+ Score float64
+ Bucket standingtypes.StandingBucket
+ Ratings []standingtypes.Rating
+ Vouches []standingtypes.Vouch
+ Slashes []standingtypes.Slash
+ Signals standingtypes.FreeholderSignals
+ Eligible bool
+ MinScore float64
+ MinCats int
+}
+
+// handleStanding renders the Standing + Freeholder signals progress (REQ-043).
+// Computed from mock Ratings/Vouches/Slashes using the locked x/standing/types
+// constants + GetStandingBucket/ComputeDiversityBonus/GetVoucherWeight; the
+// 4-signal progress via FreeholderSignals.IsFreeholderEligible().
+func (s *Server) handleStanding(w http.ResponseWriter, r *http.Request) {
+ id := r.PathValue("reachID")
+ _, ok := s.Store.GetReach(id)
+ if !ok {
+ http.NotFound(w, r)
+ return
+ }
+ score, bucket := s.Store.ComputeStandingScore(id)
+ ratings := s.Store.ListRatings(id)
+ vouches := s.Store.ListVouches(id)
+ slashes := s.Store.ListSlashes(id)
+ signals := s.Store.ComputeFreeholderSignals(id)
+
+ s.render(w, "standing.html", standingViewData{
+ ReachID: id,
+ Found: true,
+ Score: score,
+ Bucket: bucket,
+ Ratings: ratings,
+ Vouches: vouches,
+ Slashes: slashes,
+ Signals: signals,
+ Eligible: signals.IsFreeholderEligible(),
+ MinScore: standingtypes.FreeholderMinStandingScore,
+ MinCats: standingtypes.FreeholderMinCategories,
+ })
+}
diff --git a/web/handlers/standing_test.go b/web/handlers/standing_test.go
new file mode 100644
index 0000000..1a66072
--- /dev/null
+++ b/web/handlers/standing_test.go
@@ -0,0 +1,146 @@
+package handlers
+
+import (
+ "net/http"
+ "net/http/httptest"
+ "strings"
+ "testing"
+
+ standingtypes "github.com/oy/openyield/x/standing/types"
+)
+
+func TestStandingEligibleHolderRendersAllSignalsEarned(t *testing.T) {
+ srv := newTestServer(t)
+ mux := http.NewServeMux()
+ srv.Register(mux)
+ rec := httptest.NewRecorder()
+ req := httptest.NewRequest("GET", "/standing/holder-alia", nil)
+ mux.ServeHTTP(rec, req)
+ if rec.Code != http.StatusOK {
+ t.Fatalf("GET /standing/holder-alia: status %d, want 200", rec.Code)
+ }
+ body := rec.Body.String()
+ // holder-alia: 12 ratings in 4 categories, 1 Vouch, mature Stash, balance 920000.
+ // All 4 signals earned -> Freeholder-eligible.
+ if !strings.Contains(body, "Freeholder-eligible") {
+ t.Errorf("body missing 'Freeholder-eligible' label")
+ }
+ // Score displayed with 1 decimal.
+ if !strings.Contains(body, "4.") {
+ t.Errorf("body missing score (expected 4.x)")
+ }
+ // All 4 signals should show 'earned'.
+ earnedCount := strings.Count(body, "earned")
+ if earnedCount < 4 {
+ t.Errorf("body has %d 'earned' badges, want >=4 (all signals earned for holder-alia)", earnedCount)
+ }
+ assertNoBannedTerms(t, body)
+}
+
+func TestStandingNotEligibleHolderShowsNotYet(t *testing.T) {
+ srv := newTestServer(t)
+ mux := http.NewServeMux()
+ srv.Register(mux)
+ rec := httptest.NewRecorder()
+ req := httptest.NewRequest("GET", "/standing/holder-bryn", nil)
+ mux.ServeHTTP(rec, req)
+ if rec.Code != http.StatusOK {
+ t.Fatalf("GET /standing/holder-bryn: status %d, want 200", rec.Code)
+ }
+ body := rec.Body.String()
+ // holder-bryn: 3 ratings in 1 category, no Vouch, immature Stash.
+ // Not eligible.
+ if !strings.Contains(body, "not yet") {
+ t.Errorf("body missing 'not yet' badge for non-eligible holder-bryn")
+ }
+ if strings.Contains(body, "Freeholder-eligible\">yes") {
+ t.Errorf("body shows eligible=yes for holder-bryn (should not be eligible)")
+ }
+ assertNoBannedTerms(t, body)
+}
+
+func TestStandingMissingReturns404(t *testing.T) {
+ srv := newTestServer(t)
+ mux := http.NewServeMux()
+ srv.Register(mux)
+ rec := httptest.NewRecorder()
+ req := httptest.NewRequest("GET", "/standing/nobody", nil)
+ mux.ServeHTTP(rec, req)
+ if rec.Code != http.StatusNotFound {
+ t.Fatalf("GET /standing/nobody: status %d, want 404", rec.Code)
+ }
+ // G-026: rendered-HTML lexicon check on the ERROR response body too.
+ assertNoBannedTerms(t, rec.Body.String())
+}
+
+// TestStandingScoreComputedFromLockedConstants (P4 regression guard): asserts
+// ComputeStandingScore uses the x/standing/types locked constants
+// (PriorMean=4.0, PriorWeight=10, ComputeDiversityBonus, GetVoucherWeight,
+// GetStandingBucket) — NOT a hardcoded score. This test would FAIL if the
+// handler hardcoded a score instead of computing from the locked constants.
+func TestStandingScoreComputedFromLockedConstants(t *testing.T) {
+ srv := newTestServer(t)
+ score, bucket := srv.Store.ComputeStandingScore("holder-alia")
+ // D-073 pattern: the score must be derived from the locked constants, not
+ // a magic number. Assert the prior mean is 4.0 and the score is pulled
+ // toward it (Bayesian shrinkage) + diversity bonus for 4 categories.
+ if standingtypes.PriorMean != 4.0 {
+ t.Fatalf("D-073: PriorMean = %v, want 4.0 (locked constant)", standingtypes.PriorMean)
+ }
+ if standingtypes.PriorWeight != 10 {
+ t.Fatalf("D-073: PriorWeight = %v, want 10 (locked constant)", standingtypes.PriorWeight)
+ }
+ // holder-alia has 4 categories -> diversity bonus 0.10 (DiversityBonus4Cats).
+ bonus := standingtypes.ComputeDiversityBonus(4)
+ if bonus != standingtypes.DiversityBonus4Cats {
+ t.Errorf("ComputeDiversityBonus(4) = %v, want %v (locked constant)", bonus, standingtypes.DiversityBonus4Cats)
+ }
+ // The score must be > 4.5 (ratings 4.6-4.9 + diversity bonus 0.10).
+ if score < 4.5 {
+ t.Errorf("score for holder-alia = %.2f, want >= 4.5 (12 ratings 4.6-4.9 + 4-cat bonus)", score)
+ }
+ // Bucket must be Preferred or Top (score >= 4.5, 12 ratings >= 10).
+ if bucket != standingtypes.BucketPreferred && bucket != standingtypes.BucketTop {
+ t.Errorf("bucket for holder-alia = %q, want Preferred or Top", bucket)
+ }
+ // holder-bryn has 3 ratings in 1 category -> bucket New (< 10 ratings).
+ _, brynBucket := srv.Store.ComputeStandingScore("holder-bryn")
+ if brynBucket != standingtypes.BucketNew {
+ t.Errorf("bucket for holder-bryn = %q, want New (< 10 ratings)", brynBucket)
+ }
+}
+
+// TestFreeholderEligibleBadgeReflectsMethod: asserts the rendered badge
+// matches FreeholderSignals.IsFreeholderEligible() (the real method).
+func TestFreeholderEligibleBadgeReflectsMethod(t *testing.T) {
+ srv := newTestServer(t)
+ mux := http.NewServeMux()
+ srv.Register(mux)
+
+ // holder-alia: eligible (all 4 signals true).
+ aliaSignals := srv.Store.ComputeFreeholderSignals("holder-alia")
+ if !aliaSignals.IsFreeholderEligible() {
+ t.Errorf("holder-alia IsFreeholderEligible = false, want true (signals=%+v)", aliaSignals)
+ }
+ rec := httptest.NewRecorder()
+ req := httptest.NewRequest("GET", "/standing/holder-alia", nil)
+ mux.ServeHTTP(rec, req)
+ if !strings.Contains(rec.Body.String(), "yes") {
+ t.Errorf("holder-alia: body missing 'yes' eligible badge (IsFreeholderEligible=true)")
+ }
+
+ // holder-bryn: not eligible.
+ brynSignals := srv.Store.ComputeFreeholderSignals("holder-bryn")
+ if brynSignals.IsFreeholderEligible() {
+ t.Errorf("holder-bryn IsFreeholderEligible = true, want false (signals=%+v)", brynSignals)
+ }
+ rec2 := httptest.NewRecorder()
+ req2 := httptest.NewRequest("GET", "/standing/holder-bryn", nil)
+ mux.ServeHTTP(rec2, req2)
+ if !strings.Contains(rec2.Body.String(), "not yet") {
+ t.Errorf("holder-bryn: body missing 'not yet' (IsFreeholderEligible=false)")
+ }
+}
+
+// Compile-time assertion that the handler uses the real x/standing/types struct.
+var _ standingtypes.FreeholderSignals
diff --git a/web/store/fixtures.go b/web/store/fixtures.go
index 5f67c02..7ae3e87 100644
--- a/web/store/fixtures.go
+++ b/web/store/fixtures.go
@@ -4,6 +4,7 @@ import (
"time"
identitytypes "github.com/oy/openyield/x/identity/types"
+ standingtypes "github.com/oy/openyield/x/standing/types"
stashtypes "github.com/oy/openyield/x/stash/types"
)
@@ -11,13 +12,59 @@ import (
// list view. All strings lexicon-clean ("Holder"/"Reach"/"Stash"; NOT the
// banned financial terms). Two fixtures: one mature (90+ active days),
// one immature (45 active days) so the Stash dashboard (P2) can show both
-// states.
+// states. P4 seeds Ratings/Vouches so the Standing screen can show a
+// Freeholder-eligible Reach (holder-alia) vs a non-eligible one (holder-bryn).
func (s *Store) seed() {
now := time.Now().Unix()
- // Fixture 1: a mature Nomad (ActiveDays=92, MaxGapDays=10 -> IsMature()).
seedOne(s, "holder-alia", "pk-alia-001", now, 920000, 92, 10)
- // Fixture 2: an immature Nomad (ActiveDays=45, MaxGapDays=5 -> not mature).
seedOne(s, "holder-bryn", "pk-bryn-002", now, 410000, 45, 5)
+ seedStanding(s, now)
+}
+
+// seedStanding seeds mock Ratings + Vouches. holder-alia gets 12 ratings
+// across 4 categories at 4.6-4.9 (Freeholder-eligible: score >= 4.5 in >= 3
+// cats) + 1 Vouch (CommunityEndorsement). holder-bryn gets 3 ratings in 1
+// category (not eligible: < 3 categories, no Vouch).
+func seedStanding(s *Store, now int64) {
+ // holder-alia: 12 ratings, 4 categories, scores 4.6-4.9.
+ aliaCats := []string{"care", "sim", "vault", "mail"}
+ for i := 0; i < 12; i++ {
+ cat := aliaCats[i%4]
+ score := 4.6 + float64(i%4)*0.1 // 4.6, 4.7, 4.8, 4.9 repeating
+ s.ratings["holder-alia"] = append(s.ratings["holder-alia"], standingtypes.Rating{
+ RaterID: "rater-" + itoa(i),
+ RateeID: "holder-alia",
+ Category: cat,
+ Score: score,
+ Weight: 1.0,
+ TxRef: "tx-r-" + itoa(i),
+ Timestamp: now - int64(i)*86400,
+ DecayBucket: 0, // 6mo bucket (1.0)
+ })
+ }
+ // 1 Vouch for holder-alia (CommunityEndorsement signal).
+ s.vouches["holder-alia"] = []standingtypes.Vouch{{
+ VoucherID: "voucher-freeholder-1",
+ VoucheeID: "holder-alia",
+ Category: "care",
+ BondAmount: 100000,
+ Timestamp: now,
+ }}
+
+ // holder-bryn: 3 ratings, 1 category, scores 4.0-4.2 (not eligible: < 3 cats).
+ for i := 0; i < 3; i++ {
+ s.ratings["holder-bryn"] = append(s.ratings["holder-bryn"], standingtypes.Rating{
+ RaterID: "rater-b-" + itoa(i),
+ RateeID: "holder-bryn",
+ Category: "care",
+ Score: 4.0 + float64(i)*0.1,
+ Weight: 1.0,
+ TxRef: "tx-b-" + itoa(i),
+ Timestamp: now - int64(i)*86400,
+ DecayBucket: 0,
+ })
+ }
+ // No Vouches for holder-bryn (CommunityEndorsement signal false).
}
func seedOne(s *Store, holderID, pubKey string, now int64, balanceGrain int64, activeDays, maxGap uint32) {
@@ -44,3 +91,27 @@ func seedOne(s *Store, holderID, pubKey string, now int64, balanceGrain int64, a
LastActivityDay: now,
}
}
+
+// itoa is a tiny int->string helper to avoid importing strconv (keeps the
+// fixtures file import-light; the mock data uses small integers only).
+func itoa(n int) string {
+ if n == 0 {
+ return "0"
+ }
+ neg := n < 0
+ if neg {
+ n = -n
+ }
+ var buf [12]byte
+ i := len(buf)
+ for n > 0 {
+ i--
+ buf[i] = byte('0' + n%10)
+ n /= 10
+ }
+ if neg {
+ i--
+ buf[i] = '-'
+ }
+ return string(buf[i:])
+}
diff --git a/web/store/store.go b/web/store/store.go
index b43a6f6..f397c64 100644
--- a/web/store/store.go
+++ b/web/store/store.go
@@ -14,6 +14,7 @@ import (
"time"
identitytypes "github.com/oy/openyield/x/identity/types"
+ standingtypes "github.com/oy/openyield/x/standing/types"
stashtypes "github.com/oy/openyield/x/stash/types"
windowtypes "github.com/oy/openyield/x/window/types"
)
@@ -30,6 +31,9 @@ type Store struct {
stashActivities map[string]stashtypes.StashActivity
windows map[string]windowtypes.Window
auditLogs map[string][]windowtypes.AuditEntry
+ ratings map[string][]standingtypes.Rating
+ vouches map[string][]standingtypes.Vouch
+ slashes map[string][]standingtypes.Slash
}
// NewStore constructs a Store seeded from fixtures (fixtures.go).
@@ -40,6 +44,9 @@ func NewStore() *Store {
stashActivities: map[string]stashtypes.StashActivity{},
windows: map[string]windowtypes.Window{},
auditLogs: map[string][]windowtypes.AuditEntry{},
+ ratings: map[string][]standingtypes.Rating{},
+ vouches: map[string][]standingtypes.Vouch{},
+ slashes: map[string][]standingtypes.Slash{},
}
s.seed()
return s
@@ -291,3 +298,104 @@ func validateReachInput(holderID, publicKey string) error {
}
return nil
}
+
+// --- Standing + Freeholder signals (P4) ---
+
+// ListRatings returns all Ratings for a ratee (per-Reach).
+func (s *Store) ListRatings(rateeID string) []standingtypes.Rating {
+ s.mu.Lock()
+ defer s.mu.Unlock()
+ return s.ratings[rateeID]
+}
+
+// ListVouches returns all Vouches for a vouchee.
+func (s *Store) ListVouches(voucheeID string) []standingtypes.Vouch {
+ s.mu.Lock()
+ defer s.mu.Unlock()
+ return s.vouches[voucheeID]
+}
+
+// ListSlashes returns all Slashes for a Reach.
+func (s *Store) ListSlashes(reachID string) []standingtypes.Slash {
+ s.mu.Lock()
+ defer s.mu.Unlock()
+ return s.slashes[reachID]
+}
+
+// ComputeStandingScore computes a simplified standing score from the mock
+// Ratings using the locked x/standing/types constants (PriorMean, PriorWeight,
+// ComputeDiversityBonus, GetVoucherWeight, GetStandingBucket). This is a
+// SIMPLIFIED computation (not the full Bayesian formula — sub-tables deferred
+// per PROJECT.md Q2); the test asserts it uses the locked constants, not that
+// it matches a full oracle.
+func (s *Store) ComputeStandingScore(reachID string) (float64, standingtypes.StandingBucket) {
+ s.mu.Lock()
+ defer s.mu.Unlock()
+ ratings := s.ratings[reachID]
+ slashes := s.slashes[reachID]
+ isSlashed := len(slashes) > 0
+
+ if len(ratings) == 0 {
+ // No ratings: return the prior mean, bucket New.
+ return standingtypes.PriorMean, standingtypes.GetStandingBucket(standingtypes.PriorMean, 0, isSlashed)
+ }
+
+ // Simplified: weighted average of rating scores using GetVoucherWeight.
+ // The real formula uses the rater's standing to derive the weight; the
+ // mock uses the ratee's own score iteratively (simplified — P4 does not
+ // build a full rater-graph). Uses the locked PriorMean + PriorWeight as a
+ // Bayesian shrinkage: score = (prior*weight + sum(scores)) / (weight + n).
+ sum := 0.0
+ categories := map[string]bool{}
+ for _, r := range ratings {
+ w := standingtypes.GetVoucherWeight(false, r.Score, len(ratings))
+ sum += r.Score * w
+ categories[r.Category] = true
+ }
+ n := float64(len(ratings))
+ raw := (standingtypes.PriorMean*float64(standingtypes.PriorWeight) + sum) /
+ (float64(standingtypes.PriorWeight) + n)
+ // Apply diversity bonus (locked const).
+ raw += standingtypes.ComputeDiversityBonus(len(categories))
+ bucket := standingtypes.GetStandingBucket(raw, len(ratings), isSlashed)
+ return raw, bucket
+}
+
+// ComputeFreeholderSignals computes the four Freeholder signals (§9.1) from
+// the mock data. StashMaturity from StashActivity.IsMature(); MultiDomainStanding
+// from score >= FreeholderMinStandingScore in >= FreeholderMinCategories;
+// CommittedCapital from Stash balance >= a threshold (mock); CommunityEndorsement
+// from >= 1 Vouch. Returns the real standingtypes.FreeholderSignals struct.
+func (s *Store) ComputeFreeholderSignals(reachID string) standingtypes.FreeholderSignals {
+ s.mu.Lock()
+ stash, hasStash := s.stashes[reachID]
+ ratings := s.ratings[reachID]
+ vouches := s.vouches[reachID]
+ s.mu.Unlock()
+
+ var signals standingtypes.FreeholderSignals
+ // StashMaturity: from StashActivity.IsMature() (the real method).
+ if hasStash {
+ if activity, ok := s.GetStashActivity(stash.StashID); ok {
+ signals.StashMaturity = activity.IsMature()
+ }
+ }
+ // MultiDomainStanding: score >= 4.5 in >= 3 categories.
+ score, _ := s.ComputeStandingScore(reachID)
+ categories := map[string]bool{}
+ for _, r := range ratings {
+ categories[r.Category] = true
+ }
+ if score >= standingtypes.FreeholderMinStandingScore && len(categories) >= standingtypes.FreeholderMinCategories {
+ signals.MultiDomainStanding = true
+ }
+ // CommittedCapital: mock threshold — Stash balance >= 100000 Grain (10 Bread).
+ if hasStash && stash.BalanceGrain >= 100000 {
+ signals.CommittedCapital = true
+ }
+ // CommunityEndorsement: >= 1 Vouch.
+ if len(vouches) >= 1 {
+ signals.CommunityEndorsement = true
+ }
+ return signals
+}
diff --git a/web/store/store_test.go b/web/store/store_test.go
index 6d2405b..5cd714e 100644
--- a/web/store/store_test.go
+++ b/web/store/store_test.go
@@ -5,6 +5,7 @@ import (
"testing"
identitytypes "github.com/oy/openyield/x/identity/types"
+ standingtypes "github.com/oy/openyield/x/standing/types"
stashtypes "github.com/oy/openyield/x/stash/types"
windowtypes "github.com/oy/openyield/x/window/types"
)
@@ -184,29 +185,7 @@ func TestSeededMatureVsImmature(t *testing.T) {
var _ identitytypes.Reach
var _ stashtypes.Stash
-// itoa is a tiny strconv.Itoa without the import (keeps store_test.go deps
-// to just sync + testing + the two x/*/types packages).
-func itoa(n int) string {
- if n == 0 {
- return "0"
- }
- neg := n < 0
- if neg {
- n = -n
- }
- var buf [20]byte
- i := len(buf)
- for n > 0 {
- i--
- buf[i] = byte('0' + n%10)
- n /= 10
- }
- if neg {
- i--
- buf[i] = '-'
- }
- return string(buf[i:])
-}
+// itoa is provided by fixtures.go (shared with the production package).
func stringOf(r rune, n int) string {
b := make([]byte, n)
@@ -434,3 +413,120 @@ func TestGetAuditLogEmptyForMissing(t *testing.T) {
t.Errorf("GetAuditLog(nobody) = %v, want nil", logs)
}
}
+
+// --- Standing + Freeholder signals tests (P4) ---
+
+func TestListRatingsSeeded(t *testing.T) {
+ s := NewStore()
+ alia := s.ListRatings("holder-alia")
+ if len(alia) != 12 {
+ t.Errorf("ListRatings(holder-alia) = %d, want 12 (seeded)", len(alia))
+ }
+ bryn := s.ListRatings("holder-bryn")
+ if len(bryn) != 3 {
+ t.Errorf("ListRatings(holder-bryn) = %d, want 3 (seeded)", len(bryn))
+ }
+ nobody := s.ListRatings("nobody")
+ if len(nobody) != 0 {
+ t.Errorf("ListRatings(nobody) = %d, want 0", len(nobody))
+ }
+}
+
+func TestListVouchesSeeded(t *testing.T) {
+ s := NewStore()
+ alia := s.ListVouches("holder-alia")
+ if len(alia) != 1 {
+ t.Errorf("ListVouches(holder-alia) = %d, want 1 (seeded)", len(alia))
+ }
+ bryn := s.ListVouches("holder-bryn")
+ if len(bryn) != 0 {
+ t.Errorf("ListVouches(holder-bryn) = %d, want 0 (seeded)", len(bryn))
+ }
+}
+
+func TestListSlashesEmptyByDefault(t *testing.T) {
+ s := NewStore()
+ if sl := s.ListSlashes("holder-alia"); len(sl) != 0 {
+ t.Errorf("ListSlashes(holder-alia) = %d, want 0 (no slashes seeded)", len(sl))
+ }
+}
+
+func TestComputeStandingScoreNoRatingsReturnsPriorMean(t *testing.T) {
+ s := NewStore()
+ score, bucket := s.ComputeStandingScore("nobody")
+ if score != standingtypes.PriorMean {
+ t.Errorf("ComputeStandingScore(nobody) score = %v, want PriorMean %v", score, standingtypes.PriorMean)
+ }
+ if bucket != standingtypes.BucketNew {
+ t.Errorf("ComputeStandingScore(nobody) bucket = %q, want New", bucket)
+ }
+}
+
+func TestComputeStandingScoreAliaIsEligibleRange(t *testing.T) {
+ s := NewStore()
+ score, bucket := s.ComputeStandingScore("holder-alia")
+ if score < 4.5 {
+ t.Errorf("holder-alia score = %.2f, want >= 4.5 (Freeholder-eligible range)", score)
+ }
+ if bucket != standingtypes.BucketPreferred && bucket != standingtypes.BucketTop {
+ t.Errorf("holder-alia bucket = %q, want Preferred or Top", bucket)
+ }
+}
+
+func TestComputeStandingScoreBrynIsNew(t *testing.T) {
+ s := NewStore()
+ _, bucket := s.ComputeStandingScore("holder-bryn")
+ // holder-bryn has 3 ratings (< 10) -> bucket New.
+ if bucket != standingtypes.BucketNew {
+ t.Errorf("holder-bryn bucket = %q, want New (< 10 ratings)", bucket)
+ }
+}
+
+func TestComputeFreeholderSignalsAliaAllTrue(t *testing.T) {
+ s := NewStore()
+ signals := s.ComputeFreeholderSignals("holder-alia")
+ // holder-alia: mature Stash (92 days), score >= 4.5 in 4 cats, balance
+ // 920000 >= 100000, 1 Vouch -> all 4 signals true.
+ if !signals.StashMaturity {
+ t.Errorf("StashMaturity = false, want true (mature Stash)")
+ }
+ if !signals.MultiDomainStanding {
+ t.Errorf("MultiDomainStanding = false, want true (score >= 4.5 in 4 cats)")
+ }
+ if !signals.CommittedCapital {
+ t.Errorf("CommittedCapital = false, want true (balance 920000 >= 100000)")
+ }
+ if !signals.CommunityEndorsement {
+ t.Errorf("CommunityEndorsement = false, want true (1 Vouch seeded)")
+ }
+ if !signals.IsFreeholderEligible() {
+ t.Errorf("holder-alia IsFreeholderEligible = false, want true (all 4 signals)")
+ }
+}
+
+func TestComputeFreeholderSignalsBrynNotEligible(t *testing.T) {
+ s := NewStore()
+ signals := s.ComputeFreeholderSignals("holder-bryn")
+ // holder-bryn: immature Stash (45 days), 1 cat (< 3), no Vouch.
+ if signals.StashMaturity {
+ t.Errorf("StashMaturity = true, want false (immature 45 days)")
+ }
+ if signals.MultiDomainStanding {
+ t.Errorf("MultiDomainStanding = true, want false (1 cat < 3)")
+ }
+ if signals.CommunityEndorsement {
+ t.Errorf("CommunityEndorsement = true, want false (no Vouches)")
+ }
+ if signals.IsFreeholderEligible() {
+ t.Errorf("holder-bryn IsFreeholderEligible = true, want false")
+ }
+}
+
+func TestComputeFreeholderSignalsNoStash(t *testing.T) {
+ s := NewStore()
+ signals := s.ComputeFreeholderSignals("nobody")
+ // No Stash, no ratings, no Vouches -> all false.
+ if signals.IsFreeholderEligible() {
+ t.Errorf("nobody IsFreeholderEligible = true, want false (no Stash)")
+ }
+}
diff --git a/web/templates/standing.html b/web/templates/standing.html
new file mode 100644
index 0000000..ca6f7d3
--- /dev/null
+++ b/web/templates/standing.html
@@ -0,0 +1,89 @@
+{{define "title"}}Standing — OpenYield{{end}}
+
+{{define "content"}}
+
+ Standing — {{.ReachID}}
+ Standing is the Bayesian anti-gaming metric that accrues as a Nomad acts
+ on the mesh. It is not bought or transferred — it is earned through honest
+ participation, weighted by the standing of the raters, time-decayed, and
+ diversified across service categories.
+
+
+
+ Score
+
+ | Reach ID | {{.ReachID}} |
+ | Standing score | {{printf "%.1f" .Score}} |
+ | Bucket |
+ {{if eq (string .Bucket) "New"}}New{{end}}
+ {{if eq (string .Bucket) "Trusted"}}Trusted{{end}}
+ {{if eq (string .Bucket) "Preferred"}}Preferred{{end}}
+ {{if eq (string .Bucket) "Top"}}Top{{end}}
+ {{if eq (string .Bucket) "Slashed"}}Slashed{{end}}
+ |
+
+
+
+
+ Freeholder signals
+ The four signals (§9.1) — all four must be present to be Freeholder-eligible.
+ No application, no committee, no form.
+
+ | Signal | Status |
+
+ | Stash maturity (90 days, gap ≤ 30) | {{if .Signals.StashMaturity}}earned{{else}}not yet{{end}} |
+ | Multi-domain standing (≥ {{printf "%.1f" .MinScore}} in ≥ {{.MinCats}} cats) | {{if .Signals.MultiDomainStanding}}earned{{else}}not yet{{end}} |
+ | Committed capital | {{if .Signals.CommittedCapital}}earned{{else}}not yet{{end}} |
+ | Community endorsement (≥ 1 Vouch) | {{if .Signals.CommunityEndorsement}}earned{{else}}not yet{{end}} |
+
+
+ Freeholder-eligible:
+ {{if .Eligible}}yes
+ {{else}}not yet{{end}}
+
+
+
+
+ Ratings ({{len .Ratings}})
+ {{if .Ratings}}
+
+ | Rater | Category | Score | Timestamp |
+
+ {{range .Ratings}}
+ | {{.RaterID}} | {{.Category}} | {{printf "%.1f" .Score}} | {{.Timestamp}} |
+ {{end}}
+
+
+ {{else}}No ratings yet.
{{end}}
+
+
+
+ Vouches ({{len .Vouches}})
+ {{if .Vouches}}
+
+ | Voucher | Category | Bond (Grain) |
+
+ {{range .Vouches}}
+ | {{.VoucherID}} | {{.Category}} | {{.BondAmount}} |
+ {{end}}
+
+
+ {{else}}No Vouches yet.
{{end}}
+
+
+{{if .Slashes}}
+
+ Slashes ({{len .Slashes}})
+
+ | Reason | Amount | Attester |
+
+ {{range .Slashes}}
+ | {{.Reason}} | {{.Amount}} | {{.Attester}} |
+ {{end}}
+
+
+
+{{end}}
+
+
Back to Reach
+{{end}}
\ No newline at end of file