Merge phase/05 into milestone/v0.6-nomad-web-ui (P5 complete → v0.5.5)

---ci---
project: oy
phase: 5
milestone: v0.6
status: complete
requirements:
  covered: [REQ-040, REQ-041, REQ-042, REQ-043, REQ-044, REQ-045]
---/ci---
This commit is contained in:
2026-08-18 19:06:22 +00:00
8 changed files with 315 additions and 4 deletions
+3 -3
View File
@@ -1,5 +1,5 @@
{
"phase": 4,
"phase": 5,
"stage": "complete",
"milestone": "v0.6",
"milestone_type": "feature",
@@ -7,7 +7,7 @@
"phase_role": "execution",
"project": "oy",
"attempts": 0,
"updated_at": "2026-08-18T14:35:00Z",
"updated_at": "2026-08-18T14:50:00Z",
"milestone_complete": false,
"requirements_covered": ["REQ-040", "REQ-041", "REQ-042", "REQ-043", "REQ-045"]
"requirements_covered": ["REQ-040", "REQ-041", "REQ-042", "REQ-043", "REQ-044", "REQ-045"]
}
+50
View File
@@ -0,0 +1,50 @@
package handlers
import (
"net/http"
bloomtypes "github.com/oy/openyield/x/bloom/types"
)
// registerBloom wires the Bloom accrual route (REQ-044).
func (s *Server) registerBloom(mux *http.ServeMux) {
mux.HandleFunc("GET /bloom/{stashID}", s.handleBloom)
}
// bloomViewData is the template data for the Bloom accrual view.
type bloomViewData struct {
StashID string
Found bool
Record bloomtypes.BloomRecord
RatePct float64 // RateBasisPoints as a percentage (450 -> 4.5)
TargetRatePct float64 // TargetBloomRateBasisPoints as %
MinRatePct float64
MaxRatePct float64
AccrualPeriod int64
MissionLockNote string
}
// handleBloom renders the Bloom accrual view (REQ-044): per-Stash BloomRecord
// (AccruedGrain, RateBasisPoints as %, LastAccrualBlock) + the 4.5% target rate
// (read from x/bloom/types.TargetBloomRateBasisPoints — D-073 code-constant
// source-of-truth, NOT hardcoded). Bloom is conceptually close to a banned
// financial term; labels use "Bloom"/"real production"/"accrual" only.
func (s *Server) handleBloom(w http.ResponseWriter, r *http.Request) {
stashID := r.PathValue("stashID")
rec, ok := s.Store.GetBloomRecord(stashID)
if !ok {
http.NotFound(w, r)
return
}
s.render(w, "bloom.html", bloomViewData{
StashID: stashID,
Found: true,
Record: rec,
RatePct: float64(rec.RateBasisPoints) / 100,
TargetRatePct: float64(bloomtypes.TargetBloomRateBasisPoints) / 100,
MinRatePct: float64(bloomtypes.MinBloomRateBasisPoints) / 100,
MaxRatePct: float64(bloomtypes.MaxBloomRateBasisPoints) / 100,
AccrualPeriod: bloomtypes.AccrualPeriodBlocks,
MissionLockNote: bloomtypes.MissionLockBloom,
})
}
+137
View File
@@ -0,0 +1,137 @@
package handlers
import (
"net/http"
"net/http/httptest"
"strings"
"testing"
bloomtypes "github.com/oy/openyield/x/bloom/types"
)
func TestBloomSeededRecordRendersTargetRate(t *testing.T) {
srv := newTestServer(t)
mux := http.NewServeMux()
srv.Register(mux)
rec := httptest.NewRecorder()
req := httptest.NewRequest("GET", "/bloom/stash-holder-alia", nil)
mux.ServeHTTP(rec, req)
if rec.Code != http.StatusOK {
t.Fatalf("GET /bloom/stash-holder-alia: status %d, want 200", rec.Code)
}
body := rec.Body.String()
// Accrued Grain present.
if !strings.Contains(body, "Grain") {
t.Errorf("body missing 'Grain'")
}
// Target rate 4.5% (from TargetBloomRateBasisPoints=450).
want := formatFloat(float64(bloomtypes.TargetBloomRateBasisPoints) / 100)
if !strings.Contains(body, want) {
t.Errorf("body missing target rate %s%% (TargetBloomRateBasisPoints=%d)", want, bloomtypes.TargetBloomRateBasisPoints)
}
// Mission Lock note present.
if !strings.Contains(body, "real production") {
t.Errorf("body missing Mission Lock note about real production")
}
assertNoBannedTerms(t, body)
}
func TestBloomMissingReturns404(t *testing.T) {
srv := newTestServer(t)
mux := http.NewServeMux()
srv.Register(mux)
rec := httptest.NewRecorder()
req := httptest.NewRequest("GET", "/bloom/stash-nobody", nil)
mux.ServeHTTP(rec, req)
if rec.Code != http.StatusNotFound {
t.Fatalf("GET /bloom/stash-nobody: status %d, want 404", rec.Code)
}
// G-026: rendered-HTML lexicon check on the ERROR response body too.
assertNoBannedTerms(t, rec.Body.String())
}
// TestBloomTargetRateFromCodeConstant (D-073 regression guard): asserts the
// handler reads x/bloom/types.TargetBloomRateBasisPoints=450 (NOT a hardcoded
// 450 or a docs value). This test would FAIL if the handler hardcoded the rate
// instead of reading the code constant.
func TestBloomTargetRateFromCodeConstant(t *testing.T) {
// D-073: the code constant is the source of truth.
if bloomtypes.TargetBloomRateBasisPoints != 450 {
t.Fatalf("D-073: TargetBloomRateBasisPoints = %d, want 450 (code constant)", bloomtypes.TargetBloomRateBasisPoints)
}
if bloomtypes.MinBloomRateBasisPoints != 400 {
t.Fatalf("D-073: MinBloomRateBasisPoints = %d, want 400 (code constant)", bloomtypes.MinBloomRateBasisPoints)
}
if bloomtypes.MaxBloomRateBasisPoints != 500 {
t.Fatalf("D-073: MaxBloomRateBasisPoints = %d, want 500 (code constant)", bloomtypes.MaxBloomRateBasisPoints)
}
srv := newTestServer(t)
mux := http.NewServeMux()
srv.Register(mux)
rec := httptest.NewRecorder()
req := httptest.NewRequest("GET", "/bloom/stash-holder-alia", nil)
mux.ServeHTTP(rec, req)
body := rec.Body.String()
// The rendered target rate must be the code constant / 100 = 4.5.
wantTarget := formatFloat(float64(bloomtypes.TargetBloomRateBasisPoints) / 100)
if !strings.Contains(body, wantTarget) {
t.Errorf("D-073: body missing target rate %s%% (from code constant %d)", wantTarget, bloomtypes.TargetBloomRateBasisPoints)
}
// The seeded record for holder-alia uses RateBasisPoints=450 (the target).
rec2, ok := srv.Store.GetBloomRecord("stash-holder-alia")
if !ok {
t.Fatal("seeded bloom record stash-holder-alia missing")
}
if rec2.RateBasisPoints != bloomtypes.TargetBloomRateBasisPoints {
t.Errorf("D-073: seeded record RateBasisPoints = %d, want %d (code constant)", rec2.RateBasisPoints, bloomtypes.TargetBloomRateBasisPoints)
}
// The rate band must be rendered from the code constants.
wantMin := formatFloat(float64(bloomtypes.MinBloomRateBasisPoints) / 100)
wantMax := formatFloat(float64(bloomtypes.MaxBloomRateBasisPoints) / 100)
if !strings.Contains(body, wantMin) {
t.Errorf("D-073: body missing min rate %s%% (from code constant)", wantMin)
}
if !strings.Contains(body, wantMax) {
t.Errorf("D-073: body missing max rate %s%% (from code constant)", wantMax)
}
}
// Compile-time assertion that the handler uses the real x/bloom/types struct.
var _ bloomtypes.BloomRecord
// formatFloat formats a float to 1 decimal place without importing strconv
// (keeps the test deps minimal; matches the template's printf "%.1f").
func formatFloat(f float64) string {
// Round to 1 decimal.
rounded := float64(int(f*10+0.5)) / 10
whole := int(rounded)
frac := int((rounded - float64(whole)) * 10)
if frac == 0 {
return formatInt2(int64(whole)) + ".0"
}
return formatInt2(int64(whole)) + "." + string(rune('0'+frac))
}
func formatInt2(n int64) 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:])
}
+1 -1
View File
@@ -91,7 +91,7 @@ func (s *Server) Register(mux *http.ServeMux) {
s.registerStash(mux)
s.registerWindow(mux)
s.registerStanding(mux)
// P5 registers bloom.
s.registerBloom(mux)
}
// render executes the named page template with the given data, writing HTML
+20
View File
@@ -3,6 +3,7 @@ package store
import (
"time"
bloomtypes "github.com/oy/openyield/x/bloom/types"
identitytypes "github.com/oy/openyield/x/identity/types"
standingtypes "github.com/oy/openyield/x/standing/types"
stashtypes "github.com/oy/openyield/x/stash/types"
@@ -19,6 +20,25 @@ func (s *Store) seed() {
seedOne(s, "holder-alia", "pk-alia-001", now, 920000, 92, 10)
seedOne(s, "holder-bryn", "pk-bryn-002", now, 410000, 45, 5)
seedStanding(s, now)
seedBloom(s, now)
}
// seedBloom seeds mock BloomRecords per Stash (P5). holder-alia gets a record
// at the target rate (450 bps = 4.5%); holder-bryn gets a record at 420 bps
// (4.2%, within the 4.0%-5.0% band). AccruedGrain is a mock value.
func seedBloom(s *Store, now int64) {
s.bloomRecords["stash-holder-alia"] = bloomtypes.BloomRecord{
StashID: "stash-holder-alia",
AccruedGrain: 45000,
LastAccrualBlock: 1000,
RateBasisPoints: bloomtypes.TargetBloomRateBasisPoints, // 450 (4.5%, D-073 code constant)
}
s.bloomRecords["stash-holder-bryn"] = bloomtypes.BloomRecord{
StashID: "stash-holder-bryn",
AccruedGrain: 18000,
LastAccrualBlock: 1000,
RateBasisPoints: 420, // 4.2% (within the 400-500 band)
}
}
// seedStanding seeds mock Ratings + Vouches. holder-alia gets 12 ratings
+27
View File
@@ -13,6 +13,7 @@ import (
"sync"
"time"
bloomtypes "github.com/oy/openyield/x/bloom/types"
identitytypes "github.com/oy/openyield/x/identity/types"
standingtypes "github.com/oy/openyield/x/standing/types"
stashtypes "github.com/oy/openyield/x/stash/types"
@@ -34,6 +35,7 @@ type Store struct {
ratings map[string][]standingtypes.Rating
vouches map[string][]standingtypes.Vouch
slashes map[string][]standingtypes.Slash
bloomRecords map[string]bloomtypes.BloomRecord
}
// NewStore constructs a Store seeded from fixtures (fixtures.go).
@@ -47,6 +49,7 @@ func NewStore() *Store {
ratings: map[string][]standingtypes.Rating{},
vouches: map[string][]standingtypes.Vouch{},
slashes: map[string][]standingtypes.Slash{},
bloomRecords: map[string]bloomtypes.BloomRecord{},
}
s.seed()
return s
@@ -399,3 +402,27 @@ func (s *Store) ComputeFreeholderSignals(reachID string) standingtypes.Freeholde
}
return signals
}
// --- Bloom accrual (P5) ---
// GetBloomRecord returns the BloomRecord for a stashID (REQ-044).
func (s *Store) GetBloomRecord(stashID string) (bloomtypes.BloomRecord, bool) {
s.mu.Lock()
defer s.mu.Unlock()
r, ok := s.bloomRecords[stashID]
return r, ok
}
// ListBloomRecords returns BloomRecords for all Stashes owned by a holder.
func (s *Store) ListBloomRecords(holderID string) []bloomtypes.BloomRecord {
s.mu.Lock()
defer s.mu.Unlock()
out := []bloomtypes.BloomRecord{}
for stashID, rec := range s.bloomRecords {
// Match by the holder prefix "stash-<holderID>".
if strings.HasPrefix(stashID, "stash-"+holderID) {
out = append(out, rec)
}
}
return out
}
+44
View File
@@ -4,6 +4,7 @@ import (
"sync"
"testing"
bloomtypes "github.com/oy/openyield/x/bloom/types"
identitytypes "github.com/oy/openyield/x/identity/types"
standingtypes "github.com/oy/openyield/x/standing/types"
stashtypes "github.com/oy/openyield/x/stash/types"
@@ -530,3 +531,46 @@ func TestComputeFreeholderSignalsNoStash(t *testing.T) {
t.Errorf("nobody IsFreeholderEligible = true, want false (no Stash)")
}
}
// --- Bloom accrual tests (P5) ---
func TestGetBloomRecordSeeded(t *testing.T) {
s := NewStore()
rec, ok := s.GetBloomRecord("stash-holder-alia")
if !ok {
t.Fatal("GetBloomRecord(stash-holder-alia) miss, want hit (seeded)")
}
// D-073: seeded at the code-constant target rate.
if rec.RateBasisPoints != bloomtypes.TargetBloomRateBasisPoints {
t.Errorf("seeded RateBasisPoints = %d, want %d (TargetBloomRateBasisPoints, D-073)", rec.RateBasisPoints, bloomtypes.TargetBloomRateBasisPoints)
}
if rec.AccruedGrain != 45000 {
t.Errorf("seeded AccruedGrain = %d, want 45000", rec.AccruedGrain)
}
}
func TestGetBloomRecordMiss(t *testing.T) {
s := NewStore()
if _, ok := s.GetBloomRecord("stash-nobody"); ok {
t.Error("GetBloomRecord(stash-nobody) hit, want miss")
}
}
func TestListBloomRecordsByHolder(t *testing.T) {
s := NewStore()
alia := s.ListBloomRecords("holder-alia")
if len(alia) != 1 {
t.Errorf("ListBloomRecords(holder-alia) = %d, want 1", len(alia))
}
if alia[0].StashID != "stash-holder-alia" {
t.Errorf("ListBloomRecords(holder-alia)[0].StashID = %q, want stash-holder-alia", alia[0].StashID)
}
bryn := s.ListBloomRecords("holder-bryn")
if len(bryn) != 1 {
t.Errorf("ListBloomRecords(holder-bryn) = %d, want 1", len(bryn))
}
nobody := s.ListBloomRecords("nobody")
if len(nobody) != 0 {
t.Errorf("ListBloomRecords(nobody) = %d, want 0", len(nobody))
}
}
+33
View File
@@ -0,0 +1,33 @@
{{define "title"}}Bloom — OpenYield{{end}}
{{define "content"}}
<section class="panel">
<h1>Bloom</h1>
<p>Bloom is the real-production reward that accrues to every Grain in every
Stash. It originates only from real production — no synthetic Bloom, no
protocol-printed Bloom. This is a Mission Lock: no council can change it.</p>
</section>
<section class="panel">
<h2>Bloom record for {{.StashID}}</h2>
<table class="kv">
<tr><th>Stash ID</th><td>{{.StashID}}</td></tr>
<tr><th>Accrued Grain</th><td>{{.Record.AccruedGrain}}</td></tr>
<tr><th>Rate</th><td>{{printf "%.1f" .RatePct}}%</td></tr>
<tr><th>Last accrual block</th><td>{{.Record.LastAccrualBlock}}</td></tr>
</table>
</section>
<section class="panel">
<h2>Target rate band</h2>
<table class="kv">
<tr><th>Target rate</th><td>{{printf "%.1f" .TargetRatePct}}%</td></tr>
<tr><th>Min rate</th><td>{{printf "%.1f" .MinRatePct}}%</td></tr>
<tr><th>Max rate</th><td>{{printf "%.1f" .MaxRatePct}}%</td></tr>
<tr><th>Accrual period</th><td>{{.AccrualPeriod}} blocks (daily, ~10min blocks)</td></tr>
</table>
<p><em>{{.MissionLockNote}}</em></p>
</section>
<p><a href="/stash/{{slice .StashID 6}}">Back to Stash</a></p>
{{end}}