// Package store is the in-memory mock data layer for the OpenYield web UI. // // It instantiates the real x/*/types structs (Reach, Stash, StashActivity) // from in-memory fixtures and provides create/get/list methods. This is the // app-layer consumption of protocol types (D-070), NOT a cross-x/ production // import — web/ is NOT an x/ module. No keeper, no Cosmos runtime, no app.go // (G-003 boundary enforced by import_test.go / G-025). package store import ( "fmt" "strings" "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" windowtypes "github.com/oy/openyield/x/window/types" ) // seedBalanceGrain is the test balance seeded to a new Stash at signup (D-071 // example: 500000 Grain = 50 Bread per GrainsPerBread=10000). const seedBalanceGrain int64 = 500000 // Store is the in-memory mock store. All methods are goroutine-safe (mu). type Store struct { mu sync.Mutex reaches map[string]identitytypes.Reach stashes map[string]stashtypes.Stash 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 bloomRecords map[string]bloomtypes.BloomRecord } // NewStore constructs a Store seeded from fixtures (fixtures.go). func NewStore() *Store { s := &Store{ reaches: map[string]identitytypes.Reach{}, stashes: map[string]stashtypes.Stash{}, 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{}, bloomRecords: map[string]bloomtypes.BloomRecord{}, } s.seed() return s } // CreateReach atomically creates a Reach (IsNomad=true) + a Stash (D-071). // G-027: HolderID and PublicKey are validated (non-empty, <=128 bytes, no // path separators, no template syntax) before any map write. Returns the // created Reach + Stash. func (s *Store) CreateReach(holderID, publicKey string) (identitytypes.Reach, stashtypes.Stash, error) { if err := validateReachInput(holderID, publicKey); err != nil { return identitytypes.Reach{}, stashtypes.Stash{}, err } s.mu.Lock() defer s.mu.Unlock() if _, dup := s.reaches[holderID]; dup { return identitytypes.Reach{}, stashtypes.Stash{}, fmt.Errorf("holder %q already has a Reach", holderID) } now := time.Now().Unix() reachID := "reach-" + holderID stashID := "stash-" + holderID reach := identitytypes.Reach{ ReachID: reachID, HolderID: holderID, CreatedAt: now, PublicKey: publicKey, IsNomad: true, } stash := stashtypes.Stash{ HolderID: holderID, StashID: stashID, CreatedAt: now, LastActive: now, BalanceGrain: seedBalanceGrain, } activity := stashtypes.StashActivity{ StashID: stashID, ActiveDays: 1, MaxGapDays: 1, LastActivityDay: now, } s.reaches[holderID] = reach s.stashes[holderID] = stash s.stashActivities[stashID] = activity return reach, stash, nil } // ListReaches returns all seeded + created Reaches. func (s *Store) ListReaches() []identitytypes.Reach { s.mu.Lock() defer s.mu.Unlock() out := make([]identitytypes.Reach, 0, len(s.reaches)) for _, r := range s.reaches { out = append(out, r) } return out } // GetReach returns the Reach for a holderID (by HolderID, the stable key). func (s *Store) GetReach(holderID string) (identitytypes.Reach, bool) { s.mu.Lock() defer s.mu.Unlock() r, ok := s.reaches[holderID] return r, ok } // GetStash returns the Stash for a holderID. func (s *Store) GetStash(holderID string) (stashtypes.Stash, bool) { s.mu.Lock() defer s.mu.Unlock() st, ok := s.stashes[holderID] return st, ok } // GetStashActivity returns the StashActivity for a stashID. func (s *Store) GetStashActivity(stashID string) (stashtypes.StashActivity, bool) { s.mu.Lock() defer s.mu.Unlock() a, ok := s.stashActivities[stashID] return a, ok } // OpenWindow creates a new Window in the Open status (REQ-042) with an initial // AuditEntry. Returns the created Window. The Window is keyed by a generated // windowID derived from the grantor + a counter (mock; not cryptographic). func (s *Store) OpenWindow(grantorHolder, grantee string, scope windowtypes.Scope, start, end int64, rateLimit windowtypes.RateLimit) (windowtypes.Window, error) { if grantorHolder == "" { return windowtypes.Window{}, fmt.Errorf("grantor holder is required") } if grantee == "" { return windowtypes.Window{}, fmt.Errorf("grantee is required") } s.mu.Lock() defer s.mu.Unlock() windowID := fmt.Sprintf("window-%s-%d", grantorHolder, len(s.windows)+1) now := time.Now().Unix() w := windowtypes.Window{ WindowID: windowID, GrantorHolder: grantorHolder, Grantee: grantee, Scope: scope, Start: start, End: end, RateLimit: rateLimit, Status: windowtypes.StatusOpen, } s.windows[windowID] = w entry := windowtypes.AuditEntry{ EntryID: windowID + "-audit-1", Timestamp: now, Action: "open", Result: "created", GranterRef: grantorHolder, } s.auditLogs[windowID] = []windowtypes.AuditEntry{entry} w.AuditLogRefs = []string{entry.EntryID} s.windows[windowID] = w return w, nil } // ActivateWindow transitions a Window from Open to Active by calling the real // x/window/types.Window.Activate() method (not a reimplementation). Appends an // AuditEntry. Returns an error if the Window is not in the Open status. func (s *Store) ActivateWindow(windowID string) error { s.mu.Lock() defer s.mu.Unlock() w, ok := s.windows[windowID] if !ok { return fmt.Errorf("window %q not found", windowID) } if err := w.Activate(); err != nil { return err } s.windows[windowID] = w s.appendAuditLocked(windowID, "activate", "active", w.GrantorHolder) return nil } // RevokeWindow transitions a Window to Revoked by calling the real // x/window/types.Window.Revoke() method. Idempotent on already-revoked; // no-op on Expired (terminal state wins — v0.2 type contract). Appends an // AuditEntry only if the status actually changed. func (s *Store) RevokeWindow(windowID string) error { s.mu.Lock() defer s.mu.Unlock() w, ok := s.windows[windowID] if !ok { return fmt.Errorf("window %q not found", windowID) } prevStatus := w.Status if err := w.Revoke(); err != nil { return err } s.windows[windowID] = w if w.Status != prevStatus { s.appendAuditLocked(windowID, "revoke", "revoked", w.GrantorHolder) } return nil } // ExpireWindow transitions a Window to Expired by calling the real // x/window/types.Window.Expire() method. Appends an AuditEntry. func (s *Store) ExpireWindow(windowID string) error { s.mu.Lock() defer s.mu.Unlock() w, ok := s.windows[windowID] if !ok { return fmt.Errorf("window %q not found", windowID) } prevStatus := w.Status w.Expire() s.windows[windowID] = w if w.Status != prevStatus { s.appendAuditLocked(windowID, "expire", "expired", w.GrantorHolder) } return nil } // ListWindows returns all Windows for a grantor holder. func (s *Store) ListWindows(grantorHolder string) []windowtypes.Window { s.mu.Lock() defer s.mu.Unlock() out := []windowtypes.Window{} for _, w := range s.windows { if w.GrantorHolder == grantorHolder { out = append(out, w) } } return out } // GetWindow returns the Window for a windowID. func (s *Store) GetWindow(windowID string) (windowtypes.Window, bool) { s.mu.Lock() defer s.mu.Unlock() w, ok := s.windows[windowID] return w, ok } // GetAuditLog returns the audit-log entries for a windowID. func (s *Store) GetAuditLog(windowID string) []windowtypes.AuditEntry { s.mu.Lock() defer s.mu.Unlock() return s.auditLogs[windowID] } // appendAuditLocked appends an AuditEntry to the window's audit log. Caller // MUST hold s.mu. func (s *Store) appendAuditLocked(windowID, action, result, granterRef string) { logs := s.auditLogs[windowID] now := time.Now().Unix() entry := windowtypes.AuditEntry{ EntryID: fmt.Sprintf("%s-audit-%d", windowID, len(logs)+1), Timestamp: now, Action: action, Result: result, GranterRef: granterRef, } s.auditLogs[windowID] = append(logs, entry) w := s.windows[windowID] w.AuditLogRefs = append(w.AuditLogRefs, entry.EntryID) s.windows[windowID] = w } // validateReachInput enforces G-027: HolderID and PublicKey must be non-empty, // <=128 bytes, and contain no path separators or template syntax. This is a // prototype-robustness gate (the mock store uses holderID as a map key). func validateReachInput(holderID, publicKey string) error { if holderID == "" { return fmt.Errorf("holder id is required") } if len(holderID) > 128 { return fmt.Errorf("holder id too long (max 128)") } if strings.ContainsAny(holderID, "/\\") { return fmt.Errorf("holder id must not contain path separators") } if strings.Contains(holderID, "{{") { return fmt.Errorf("holder id must not contain template syntax") } if publicKey == "" { return fmt.Errorf("public key is required") } if len(publicKey) > 128 { return fmt.Errorf("public key too long (max 128)") } if strings.ContainsAny(publicKey, "/\\") { return fmt.Errorf("public key must not contain path separators") } 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 } // --- 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-". if strings.HasPrefix(stashID, "stash-"+holderID) { out = append(out, rec) } } return out }