From 68298d81b3c12e3d1b439967019375d334029474 Mon Sep 17 00:00:00 2001
From: cloudinit-bot
Date: Tue, 18 Aug 2026 11:54:06 +0000
Subject: [PATCH 01/26] =?UTF-8?q?docs(init):=20validate=20specification=20?=
=?UTF-8?q?=E2=80=94=20v0.6=20Nomad=20Web=20UI?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
---ci---
project: oy
phase: 0
milestone: v0.6
status: specify
---/ci---
---
.ciagent/CHECKPOINT.json | 17 ++++++--------
.ciagent/config.json | 4 ++--
.ciagent/oy/PROJECT.md | 48 ++++++++++++++++++++++++++++++++++++++++
3 files changed, 57 insertions(+), 12 deletions(-)
diff --git a/.ciagent/CHECKPOINT.json b/.ciagent/CHECKPOINT.json
index b04b690..63a6d56 100644
--- a/.ciagent/CHECKPOINT.json
+++ b/.ciagent/CHECKPOINT.json
@@ -1,15 +1,12 @@
{
- "phase": 8,
- "stage": "complete",
- "milestone": "v0.5",
+ "phase": 0,
+ "stage": "specify",
+ "milestone": "v0.6",
"milestone_type": "feature",
- "tag_base": "v0.4.x",
- "phase_role": "final",
+ "tag_base": "v0.5.x",
+ "phase_role": "pre_execution",
"project": "oy",
"attempts": 0,
- "updated_at": "2026-08-18T02:00:00Z",
- "milestone_complete": true,
- "milestone_release_tag": "v0.4.8",
- "release_id": 763,
- "requirements_covered": ["REQ-033", "REQ-034", "REQ-035", "REQ-036", "REQ-037", "REQ-038", "REQ-039"]
+ "updated_at": "2026-08-18T11:30:00Z",
+ "milestone_complete": false
}
\ No newline at end of file
diff --git a/.ciagent/config.json b/.ciagent/config.json
index 4c406e6..52f310c 100644
--- a/.ciagent/config.json
+++ b/.ciagent/config.json
@@ -6,9 +6,9 @@
}
],
"active_project": "oy",
- "milestone": "v0.5",
+ "milestone": "v0.6",
"milestone_type": "feature",
- "tag_base": "v0.4.x",
+ "tag_base": "v0.5.x",
"autonomy": {
"level": "full",
"escalation_hooks": ["deploy", "delete_data", "merge_to_main"],
diff --git a/.ciagent/oy/PROJECT.md b/.ciagent/oy/PROJECT.md
index f8f67e9..2494fa0 100644
--- a/.ciagent/oy/PROJECT.md
+++ b/.ciagent/oy/PROJECT.md
@@ -61,6 +61,54 @@ OpenYield (OY) is a durable, anti-greed, jurisdiction-light financial layer —
- D-009: Rebased history to fix v1.0 → v0.1 in ---ci--- blocks
## Milestone
+v0.6 — Nomad Web UI (in progress; feature type; tags run on the v0.5.x patch line)
+
+### v0.6 Scope (Nomad Web UI MVP — generated test data, no real chain)
+
+v0.6 is the project's first UI milestone. It delivers a working prototype Web
+UI where a person can sign up to be a Nomad (create a Reach + open a Stash)
+and exercise basic functionality around the (Reach, Stash) components, plus
+Window authorization, Standing progress, and Bloom accrual views. All data is
+generated as test fixtures — there is no real blockchain interaction, no live
+chain launch, no real bearer transports (D-020 continues to govern network
+deployment). The UI is a greenfield Go `html/template` + HTMX layer served by a
+Go mock HTTP server that instantiates the real `x/*/types` structs (Reach,
+Stash, Window, FreeholderSignals, BloomRecord) populated from in-memory
+fixtures. No keeper, no Cosmos runtime, no `app.go` (none exists in the repo).
+
+This milestone is the prerequisite for real-world MVP testing: it makes the
+Nomad path visible and exercisable in a browser. Wiring the UI to a real `oyd`
+daemon (once one exists) is deferred to v0.7+ (no `app.go`, `cmd/`, or `main.go`
+exists in the repo today).
+
+- **REQ-040** Nomad Reach signup Web UI — Go HTTP mock server (`web/`) + "Create a Reach" form + Reach list/detail; grounds the UI in `x/identity/types.Reach`. "Sign up" maps to "Create a Reach" (the word "account" is banned per REQ-012).
+- **REQ-041** Stash dashboard Web UI — balance in Grain + Bread-scale conversion (using `x/bread/types.BreadScaleAll()`) + 90-day maturity progress bar (`x/stash/types.StashActivity.IsMature`).
+- **REQ-042** Window authorization Web UI — form to open a Window (scope + duration + rate-limit), lifecycle view (Open→Active→Revoked/Expired via `x/window/types.Window.Activate/Revoke/Expire`), audit log.
+- **REQ-043** Standing + Freeholder signals progress Web UI — computed from mock `Rating`/`Vouch`/`Slash` records using the locked constants + `GetStandingBucket`/`ComputeDiversityBonus`/`GetVoucherWeight`; 4-signal progress (`FreeholderSignals.IsFreeholderEligible`).
+- **REQ-044** Bloom accrual Web UI — per-Stash `BloomRecord` view (`AccruedGrain`, `RateBasisPoints`), computed from mock data; shows the 4.5% target rate.
+- **REQ-045** Extend REQ-012 lexicon firewall to scan `web/templates/**` + `web/static/**` (new `lexicon_meta_web_test.go`). Firewall-first: lands in P1 before content.
+
+### Milestone Type
+Feature (all execution phases are `feat` except REQ-045 which is `test`). Phase 0 → `v0.5.0`; execution phases `v0.5.1..v0.5.5`; final phase patch `v0.5.6` IS the v0.6 milestone release. No separate minor tag. The final-phase audit enforces the feature purity gate (no breaking schema changes; G-003 production firewall intact; G-006 go.mod unchanged unless a runtime dep is GRILL-approved — HTMX is a vendored static asset, not a Go dep).
+
+### Out of Scope (v0.6)
+- Real blockchain interaction / mainnet / IBC / real bearer transports (D-020 continues)
+- A real `oyd` daemon / `app.go` / `cmd/oyd` (no chain runtime exists; deferred to v0.7+)
+- Real Anchors onboarding / Hub API B2B / real custody (simtest/mock only)
+- Authentication / sessions / real key management (mock; a Reach is created by form submission, stored in-memory)
+- Persistence (mock store is in-memory; resets on restart)
+- i18n / multi-language UI
+- Real Standing oracle / real Bloom accrual engine (computed from fixtures using locked constants)
+- The 5 P1+ mainnet-readiness items deferred from v0.5 (governance spam deposit, CLOB front-running, real IBC simtest, CLOB perf, emitMatchEventHook testability) — those are v0.7+ mainnet-readiness, not UI work
+
+### Prior Milestones
+- v0.1 — OpenYield Foundation Init (COMPLETE; pre-MVP foundation skeleton; released as v0.0.9)
+- v0.2 — The Mesh (COMPLETE; skeleton + tests; released as v0.1.5)
+- v0.3 — Bearers & Documentation (COMPLETE; feature; released as v0.2.6)
+- v0.4 — Refinement (COMPLETE; NFR; released as v0.3.4)
+- v0.5 — Bearers Runtime (COMPLETE; feature; released as v0.4.8)
+
+## Prior Milestone
v0.5 — Bearers Runtime (complete; feature type; tags ran on the v0.4.x patch line)
### v0.5 Scope (Live-runtime promotions of the v0.3 Bearers skeletons)
From 4876c49aa0cb22c558479165bbbd3aff089565fd Mon Sep 17 00:00:00 2001
From: cloudinit-bot
Date: Tue, 18 Aug 2026 11:54:59 +0000
Subject: [PATCH 02/26] =?UTF-8?q?docs(P00):=20clarify=20=E2=80=94=20v0.6?=
=?UTF-8?q?=20decisions=20D-066..D-073?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
User-validated stack: Go html/template + HTMX, Go mock API server,
new web/ dir, all 5 screens. Lexicon firewall extended to web/.
Bread-scale source of truth = x/bread/types code constants.
---ci---
project: oy
phase: 0
milestone: v0.6
status: clarify
---/ci---
---
.ciagent/CHECKPOINT.json | 4 ++--
.ciagent/oy/PROJECT.md | 17 ++++++++++++++++-
2 files changed, 18 insertions(+), 3 deletions(-)
diff --git a/.ciagent/CHECKPOINT.json b/.ciagent/CHECKPOINT.json
index 63a6d56..c73cb5f 100644
--- a/.ciagent/CHECKPOINT.json
+++ b/.ciagent/CHECKPOINT.json
@@ -1,12 +1,12 @@
{
"phase": 0,
- "stage": "specify",
+ "stage": "clarify",
"milestone": "v0.6",
"milestone_type": "feature",
"tag_base": "v0.5.x",
"phase_role": "pre_execution",
"project": "oy",
"attempts": 0,
- "updated_at": "2026-08-18T11:30:00Z",
+ "updated_at": "2026-08-18T11:35:00Z",
"milestone_complete": false
}
\ No newline at end of file
diff --git a/.ciagent/oy/PROJECT.md b/.ciagent/oy/PROJECT.md
index 2494fa0..b040c9b 100644
--- a/.ciagent/oy/PROJECT.md
+++ b/.ciagent/oy/PROJECT.md
@@ -291,4 +291,19 @@ Auto-decided defaults logged per clarify workflow Step 4 (full autonomy → acce
| D-058 | **Hub custody model = key-share abstraction (MPC-via-interface, not a concrete HSM/MPC vendor).** `x/hub` custody handlers expose a `CustodyKeyring` interface with `Sign`/`Derive` methods; v0.5 ships an in-memory test-only implementation. Real MPC/HSM backing is deferred (operational, Year 3+). This keeps v0.5 dep-neutral w.r.t. custody vendors while landing the handler surface. | Custody key management is operational, not protocol-level. An interface + test impl lets runtime handlers be exercised in simtest without committing to a vendor. GRILL reviews the interface boundary. | 0.78 | [commit to a specific MPC vendor (premature); hand-roll shamir (out of scope)] |
| D-059 | **IBC packet scope = the 5 L2 chains already in the v0.2 skeleton** (Polygon, Base, Arbitrum, Optimism, Solana per REQ-009/`x/satellite`). v0.5 `x/bridge` handlers implement IBC packet recv/ack for these 5 chains' `BridgeStatus` transitions. No new L2 chains in v0.5. Solana IBC uses the wormhole-style bridge adapter (already stubbed in `x/bridge` per D-021). | The 5 L2 chains are the locked-const set (REQ-009). Adding new chains is a Year-4 concern. Solana IBC was a v0.1 deferred item (D-021) now promoted. | 0.82 | [add 3+ new L2 chains (Year 4); defer Solana IBC again (stalls)] |
| D-060 | **Council governance shape (AUDIT §193 P1-1)**: add `Proposal` and `VoteOption` enum types to `x/council/types` (currently absent per AUDIT). `ProposalKind` enum = {Stand, Guild, Mesh, MissionLockAmendment-Rejected} (Mission Lock non-amendable → the enum value exists but the handler rejects it; documents the non-amendability in code). `VoteOption` enum = {Yes, No, Abstain, Veto} (Veto = Watcher-only, quorum rule). SignalKind stays at 4 sources (P1-2 defensible per AUDIT; expansion deferred to v0.6+ governance vote). Mission Lock const firewall (G-003) intact. | AUDIT P1-1 flagged the absence as a divergence. Adding the enums is `feat:` (deferred from v0.4 by D-001). P1-2 (SignalKind 4→5) is a locked-const change rejected by the audit rationale, so it stays at 4. | 0.82 | [add SignalKind 5th source (locked-const change, rejected); defer Proposal/VoteOption again (stalls)] |
-| D-061 | **No IDEATE stage in v0.5** (no `--ideate` flag this run). The feature scope was pre-seeded from PROJECT.md v0.4 out-of-scope + AUDIT §193 P1-1 + D-050 and ratified at CLARIFY. The D-001 refinement-only filter does NOT apply (v0.5 is a feature milestone, not NFR). | run.md §IDEATE is conditional on `--ideate`. This invocation has no `--ideate`. | 1.00 | [run IDEATE anyway] |
\ No newline at end of file
+| D-061 | **No IDEATE stage in v0.5** (no `--ideate` flag this run). The feature scope was pre-seeded from PROJECT.md v0.4 out-of-scope + AUDIT §193 P1-1 + D-050 and ratified at CLARIFY. The D-001 refinement-only filter does NOT apply (v0.5 is a feature milestone, not NFR). | run.md §IDEATE is conditional on `--ideate`. This invocation has no `--ideate`. | 1.00 | [run IDEATE anyway] |
+
+## Clarification Decisions (Phase 0 v0.6 — CLARIFY, autonomy=full)
+
+Auto-decided defaults logged per clarify workflow Step 4 (full autonomy → accept defaults, log decisions). `--ideate` flag IS present this run; v0.6 is the project's first UI milestone. The D-001 refinement-only filter does NOT apply (v0.6 is a feature milestone). User-validated stack choices recorded via interactive questions: Go `html/template` + HTMX (frontend stack), Go mock API server (mock data layer), all 5 screens (Reach signup, Stash dashboard, Window authorization, Standing + Freeholder signals, Bloom accrual), new top-level `web/` dir (UI code location).
+
+| ID | Decision | Rationale | Confidence | Alternatives |
+|----|----------|-----------|------------|--------------|
+| D-066 | **Frontend stack = Go `html/template` + HTMX.** HTMX is a single vendored JS file served as a static asset — no node toolchain, no `package.json`, no build step. Go `html/template` is stdlib. **G-006 (zero-dep) is preserved** — this is the decisive factor vs. a React/Svelte SPA. Sufficient for Reach/Stash/Window/Standing/Bloom screens (progressive enhancement over server-rendered HTML). Weakest for rich dashboards, but adequate for an MVP prototype. | User-validated. Project has a strong zero-dep ethos (G-006); v0.5 took a controlled G-006 exception for cosmos-sdk only after GRILL ratification. A node toolchain + `package.json` would be a far larger exception for a UI MVP that can be served by stdlib + one vendored JS file. | 0.88 | [React/Vite SPA (adds node toolchain, breaks Go-only convention); SvelteKit (same tradeoff); pure static HTML/CSS/vanilla JS (most fragile)] |
+| D-067 | **Mock data layer = Go HTTP server in `web/` instantiating real `x/*/types` structs.** New top-level `web/` dir holds `main.go`, HTTP handlers, an in-memory mock store seeded from fixtures, and `static/` + `templates/`. The mock store imports `x/identity/types`, `x/stash/types`, `x/window/types`, `x/standing/types`, `x/bread/types`, `x/bloom/types` and populates them with test values. **No keeper, no Cosmos runtime, no `app.go`** (none exists in the repo). This grounds the UI in the actual locked data shapes (Reach, Stash, Window, FreeholderSignals, BloomRecord) — the UI does not exercise a chain but does exercise the real Go type definitions. | User-validated. The four modules the Nomad UI primarily surfaces (identity, stash, window, standing) are types-only skeletons with no keeper/MsgServer. A Go mock server reuses the type definitions as the source of truth, so the UI is grounded in the locked constants (GrainsPerBread=10000, MaturityThresholdDays=90, standing formula) rather than mirroring them in TS/JSON. | 0.85 | [frontend-only TS/JSON fixtures (UI would not exercise any Go code path); Go mock API + in-memory keepers (more code, premature)] |
+| D-068 | **UI code location = new top-level `web/` dir.** Clean separation from `x/` protocol modules; does not touch the `go.mod` module path; does not pretend to be a Cosmos module. `web/` contains `main.go` (or `cmd/oyd-ui/main.go`), handlers, mock store, fixtures, `static/`, `templates/`. | User-validated. The Mesh Experience component is ROADMAP Phase 2, not a v0.6 deliverable; coupling the UI to Cosmos module conventions (a new `x/meshexperience`) is premature. A top-level `web/` dir matches the project's existing convention of non-`x/` top-level dirs (`docs/`, `lexicon/`, `lexicon_meta_docs/`). | 0.90 | [new `x/meshexperience` Cosmos module (couples UI to Cosmos conventions prematurely); `cmd/oyd-ui` + `web/` split (more files, clearer build)] |
+| D-069 | **Lexicon firewall extension to `web/`.** REQ-012 currently scans `x/` + `docs/`. A new sibling meta-test `lexicon_meta_web_test.go` (package `lexicon_meta_web`) scans `web/templates/**/*.html` + `web/static/**/*.js` for the 10 banned terms, using the same `lexicon.FindBannedTerm` + word-boundary regex. Self-exclusion + fragment pattern preserved. **"Sign up" maps to "Create a Reach"** — the word "account" is banned (REQ-012). Firewall-first: lands in P1 before content (P2..P5) so UI strings are lexicon-clean by construction. | REQ-012 is `All` phases and UI strings are user-facing; the firewall must cover the UI to be durable. Extending the existing meta-test pattern (not modifying it) preserves v0.2/v0.3 coverage. Firewall-first (D-044 pattern) ensures UI content is lexicon-clean by construction, not by retrofit. | 0.88 | [skip (REQ-012 is All phases); single combined meta-test scanning x/ + docs/ + web/ (loses isolation)] |
+| D-070 | **G-003 firewall scope: `web/` importing `x/*/types` is app-layer consumption, not a cross-`x/` production import.** G-003 (production import firewall) prohibits production struct imports across `x//types` packages. `web/` is not an `x/` module — it is the application layer that consumes protocol types, exactly as a future `cmd/oyd` would. The G-003 firewall stays intact: no `x/` module gains a production import of another `x/` module's types via `web/`. GRILL reviews this boundary. | G-003's intent is to prevent cross-module coupling inside the protocol layer. The application layer consuming types is the intended consumption direction. `web/` importing `x/identity/types` is no different from `cmd/oyd` importing it (when one exists). | 0.85 | [treat `web/` as an `x/` module (wrong — it is not protocol); forbid `web/` from importing `x/*/types` (would force TS/JSON fixtures, losing the grounding in locked constants)] |
+| D-071 | **"Sign up" = create a Reach + open a Stash atomically.** The Nomad entry path per `docs/nomads/reach.md` is "a Nomad starts with a Reach and a Stash". The signup form creates both atomically: a `Reach` with `IsNomad=true` + a `Stash` with `HolderID` matching the Reach's `HolderID` and `BalanceGrain` seeded to a test value (e.g., 500,000 Grain = 50 Bread). No KYC, no custodian (REQ-001 self-service principle). The UI labels this "Create a Reach" (lexicon-clean; "account" is banned). | The docs define the Nomad starting state as Reach + Stash. Creating only a Reach would leave the Nomad unable to view a Stash dashboard (P2) — the atomic creation matches the docs and makes the happy path contiguous. | 0.82 | [create Reach only, defer Stash creation to a separate flow (fractures the happy path); create Reach + Stash + Window all at signup (over-scope for an MVP)] |
+| D-072 | **Phase ordering** (provisional, planner finalizes): P1 Web foundation + Reach signup + lexicon firewall extension (REQ-040 + REQ-045 — same `web/` territory, vertical slice, firewall-first) → P2 Stash dashboard (REQ-041, depends on Reach existing) → P3 Window authorization (REQ-042, depends on Stash existing) → P4 Standing + Freeholder signals (REQ-043, depends on Reach existing) → P5 Bloom accrual (REQ-044, depends on Stash existing) → P6 final review + audit + milestone ship. Each phase independently shippable; P1 lands the foundation + firewall first (lexicon-clean by construction). | P1 bundles the web foundation + Reach signup + the firewall extension (same `web/` territory, vertical slice). P2..P5 each add one screen, ordered by the Nomad happy path (Reach → Stash → Window → Standing → Bloom). Vertical slices, each phase shippable. | 0.82 | [different wave ordering; bundle Stash + Window in one phase] |
+| D-073 | **Bread-scale source of truth = `x/bread/types` code constants, NOT `docs/shared/bread-scale.md`.** The code constants (`GrainsPerBread=10000`, `BreadScaleAll()` table) are the locked, tested values; the docs table is aspirational/outdated (states 1,000× ratios that do not match the code). The UI uses the code constants for all Bread-scale conversions. A doc-fix for `docs/shared/bread-scale.md` is flagged as a P1+ follow-up (not a v0.6 deliverable — docs were a v0.3 deliverable; this is a doc-drift fix, not a UI feature). | The code constants are tested (`x/bread/types/types_test.go` asserts them); the docs are not. Using the code as the source of truth keeps the UI consistent with the protocol layer. | 0.90 | [use the docs table (wrong — not tested, disagrees with code); fix the docs in v0.6 (out of scope — doc-drift fix, not a UI feature)] |
\ No newline at end of file
From 6597cbebd8bd73dbeba411ff680177028517619e Mon Sep 17 00:00:00 2001
From: cloudinit-bot
Date: Tue, 18 Aug 2026 11:57:23 +0000
Subject: [PATCH 03/26] =?UTF-8?q?docs(P00):=20research=20=E2=80=94=20v0.6?=
=?UTF-8?q?=20Nomad=20Web=20UI=20MVP=20findings?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
---ci---
phase: 0
milestone: v0.6
status: research
decisions:
- id: D-074
decision: HTMX 2.0.10 vendored as web/static/htmx.min.js (single JS file, no build step, no go get — G-006 preserved)
rationale: htmx.org docs confirm dependency-free single-file install; 2.0.10 is current stable (v4 in beta, Summer 26 target)
confidence: 0.95
alternatives: [htmx 1.x (IE support, unnecessary), pin a newer beta (instability risk)]
- id: D-075
decision: lexicon_meta_web_test.go scans web/**/*.{html,js,go} as a new sibling firewall (package lexicon_meta_web, subdir lexicon_meta_web/)
rationale: web/ is a new top-level dir NOT under x/ — the existing lexicon_meta_test.go (x/**/*.go) does not cover it; mirror the lexicon_meta_docs/ subdir pattern with G-013 walk-coverage + G-009 self-test + G-014 shared SyntheticBannedStrings()
confidence: 0.85
alternatives: [extend lexicon_meta_test.go to also walk web/ (mixes x/ and web/ concerns), separate .go and .html/.js tests (more files)]
- id: D-076
decision: Go 1.22 net/http.ServeMux is the sole router for web/ (method+path patterns, r.PathValue); gorilla/mux NOT used by web/ despite being a transitive cosmos-sdk dep
rationale: go.mod:3 confirms go 1.22; enhanced ServeMux covers GET/POST + path params for all 5 screens; G-006 zero-dep preserved (no third-party router)
confidence: 0.95
alternatives: [gorilla/mux (breaks G-006 for web/, unnecessary), chi/router (new dep)]
- id: D-077
decision: frontend-engineer activated for v0.6 with territory web/** (templates, static, handlers, store, main.go, lexicon_meta_web_test.go); backend-engineer co-owns the mock store x/*/types integration
rationale: first UI milestone — frontend-engineer was deactivated since v0.3 (no UI work); Go html/template + HTMX stack (no node/React) aligns with frameworks; constraints bind G-006 (vendored HTMX), G-003 (app-layer type import), REQ-012 (lexicon), D-073 (bread-scale code constants)
confidence: 0.90
alternatives: [keep frontend-engineer deactivated and have backend-engineer own templates (wrong skill fit), activate docs-writer instead (no docs-content work in v0.6)]
---ci---
v0.6 §1: Go html/template + HTMX architecture — server layout (web/main.go,
handlers/, store/, templates/, static/), base template pattern, HTMX 2.0.10
vendoring (single JS file, no build step, G-006 preserved), progressive
enhancement via HX-Request header (fragment vs full-page dispatch), html/template
contextual auto-escaping (XSS prevention).
v0.6 §2: Mock server data model — exact struct shapes verified from source for
all 6 modules (identity Reach, stash Stash+StashActivity+IsMature, window
Window+Scope+RateLimit+Activate/Revoke/Expire, standing Rating/Vouch/Slash/
FreeholderSignals+helpers, bread GrainsPerBread=10000+BreadScaleAll 11 tiers,
bloom BloomRecord+TargetBloomRateBasisPoints=450). Import paths use module
github.com/oy/openyield. Bread-scale code constants are the source of truth
(D-073) — docs/shared/bread-scale.md is outdated (claims 1000x ratios; code
uses 100x).
v0.6 §3: Lexicon firewall extension — pattern to mirror from
lexicon_meta_test.go (x/**/*.go) and lexicon_meta_docs/ (docs/**/*.md); new
lexicon_meta_web/ subdir + package lexicon_meta_web scanning
web/**/*.{html,js,go}; 10 banned terms verified from lexicon.go:30-41 (bank,
deposit, interest, yield, currency, dollar, euro, account, savings, depositor);
G-013 walk-coverage + G-009 self-test + G-014 shared helper.
v0.6 §4: HTTP routing — Go 1.22 net/http.ServeMux method+path patterns
confirmed (go.mod:3); r.PathValue() for path params; gorilla/mux (go.mod:75
transitive) NOT used by web/ per G-006.
v0.6 §5: PERSONAS.md update — frontend-engineer activation (YAML frontmatter
format documented); territory web/**, frameworks Go 1.22+html/template+HTMX+
ServeMux (NO node/React), constraints G-006/G-003/REQ-012/D-073; backend-engineer
co-owns mock store type integration; security/cosmos/mesh/data-engineers
deactivate for v0.6 (UI-only, no runtime).
v0.6 §6: ARCHITECTURE.md update — proposed section outline appended after
line 514 (v0.5 section end); 7 subsections mirroring v0.5 density.
---
.ciagent/oy/RESEARCH.md | 531 +++++++++++++++++++++++++++++++++++++++-
1 file changed, 530 insertions(+), 1 deletion(-)
diff --git a/.ciagent/oy/RESEARCH.md b/.ciagent/oy/RESEARCH.md
index c519736..996e08e 100644
--- a/.ciagent/oy/RESEARCH.md
+++ b/.ciagent/oy/RESEARCH.md
@@ -2194,4 +2194,533 @@ flow per the researcher role — NOT flagged `[ASSUMED]`):
rejection is proposed. Confidence 0.80.
4. **A-574** (Watcher Veto quorum value): default 6 proposed (matches
REQ-004 6-of-9); the exact param value is a planner decision.
- Confidence 0.75.
\ No newline at end of file
+ Confidence 0.75.
+
+---
+
+## v0.6 Research (Nomad Web UI MVP)
+
+> v0.6 is the project's first UI milestone (D-066..D-073, CLARIFY). Stack is
+> Go `html/template` + HTMX (vendored single JS file, no node toolchain —
+> G-006 zero-dep preserved). Mock data layer = Go HTTP server in new
+> top-level `web/` dir instantiating real `x/*/types` structs from in-memory
+> fixtures (no keeper, no Cosmos runtime, no `app.go`). 5 screens: Reach
+> signup, Stash dashboard, Window authorization, Standing + Freeholder
+> signals, Bloom accrual. This research grounds the UI in the live codebase
+> (read-only — no files modified).
+
+### v0.6 §1. Go `html/template` + HTMX Architecture
+
+**Server structure** — A Go `html/template` server for the mock UI follows
+the standard stdlib-only layout (matches D-068's `web/` dir decision):
+
+```
+web/
+ main.go # entrypoint: registers routes, serves static + templates
+ handlers/ # one file per screen (reach.go, stash.go, window.go, ...)
+ store/ # in-memory mock store (imports x/*/types, seeded from fixtures)
+ templates/
+ base.html # layout: ...` (served by `http.FileServer` or
+`http.ServeFile` from `web/static/`).
+
+**HTMX vendoring (G-006-compliant)** — HTMX is a dependency-free, browser-
+oriented JS library: a single `htmx.min.js` file loaded via `
+
+
+
+ OpenYield
+ Home
+ Reach
+ Stash
+ Window
+ Standing
+ Bloom
+
+
+ {{block "content" .}}{{end}}
+
+
+
+
+{{end}}
\ No newline at end of file
diff --git a/web/templates/home.html b/web/templates/home.html
new file mode 100644
index 0000000..16f27a2
--- /dev/null
+++ b/web/templates/home.html
@@ -0,0 +1,26 @@
+{{template "base.html" .}}
+
+{{define "title"}}OpenYield — real production on the mesh{{end}}
+
+{{define "content"}}
+
+
OpenYield
+
+ OpenYield is a mesh-native system for real production. A Holder creates a
+ Reach to enter the mesh, holds a Stash of Grain, and authorizes Window
+ access to partners. Standing accrues through honest participation, and
+ Bloom rewards sustained contribution. No middleman holds your Stash.
+
+
+
+
+
The five screens
+
+
Reach — create a Reach and view the mesh of Holders.
+{{end}}
\ No newline at end of file
diff --git a/web/templates/reach_list.html b/web/templates/reach_list.html
new file mode 100644
index 0000000..27e866e
--- /dev/null
+++ b/web/templates/reach_list.html
@@ -0,0 +1,35 @@
+{{define "title"}}Reach — OpenYield{{end}}
+
+{{define "content"}}
+
+
Reach
+
A Reach is the mesh-native identity a Holder uses to act on the mesh
+ without a custodian, a gatekeeper, or a legacy financial position. A Nomad
+ is a Holder who has a Reach and a Stash and is on the way to earning the
+ four Freeholder signals.
+ {{end}}
+
+{{end}}
\ No newline at end of file
diff --git a/web/templates/reach_new.html b/web/templates/reach_new.html
new file mode 100644
index 0000000..7a98642
--- /dev/null
+++ b/web/templates/reach_new.html
@@ -0,0 +1,22 @@
+{{define "title"}}Create a Reach — OpenYield{{end}}
+
+{{define "content"}}
+
+
Create a Reach
+
A Reach is an identity, not a custodial position. The protocol does not
+ require KYC at the protocol layer; the Reach is the unit of self-service.
+ Creating a Reach also opens a Stash for you (the place a Nomad holds
+ Grain) — that pair is enough to begin on the mesh.
+
+{{end}}
\ No newline at end of file
From 27b565c9650ffc7011c0964c8d9220c62876c1e3 Mon Sep 17 00:00:00 2001
From: cloudinit-bot
Date: Tue, 18 Aug 2026 18:52:39 +0000
Subject: [PATCH 15/26] docs(P1): README web UI quickstart + lexicon firewall
v0.6 section
---ci---
project: oy
phase: 1
milestone: v0.6
status: execute
---/ci---
---
README.md | 49 +++++++++++++++++++++++++++++++++++++++----------
1 file changed, 39 insertions(+), 10 deletions(-)
diff --git a/README.md b/README.md
index a2fadd0..287e226 100644
--- a/README.md
+++ b/README.md
@@ -25,10 +25,14 @@ Loaf → Batch → Cake → Bakery → Granary → Mill → Harvest → Earth.**
## Status
-**v0.3 (Bearers & Documentation) — in progress.** The codebase is a skeleton +
-tests layer (Go types + keeper stubs + invariant tests, zero external Go deps)
-matching the v0.1/v0.2 pre-MVP pattern. See `.ciagent/oy/ROADMAP.md` for the
-phase plan and `.ciagent/oy/PROJECT.md` for governance.
+**v0.6 (Nomad Web UI) — in progress.** v0.5 shipped the Bearers Runtime
+(simtest-grade keeper handlers for 8 x/ modules). v0.6 adds the project's
+first UI: a Go `html/template` + HTMX prototype Web UI in `web/` where a
+visitor can sign up to be a Nomad (create a Reach + open a Stash) and
+exercise basic functionality around Reach, Stash, Window, Standing, and
+Bloom. All data is generated test fixtures — no real chain. See
+`.ciagent/oy/ROADMAP.md` for the phase plan and `.ciagent/oy/PROJECT.md`
+for governance.
## Build & test
@@ -40,6 +44,29 @@ go build ./...
go test ./...
```
+## Web UI
+
+The Nomad Web UI (v0.6) is a Go `html/template` server with HTMX progressive
+enhancement, served by a mock HTTP server in `web/` that instantiates the
+real `x/*/types` structs from in-memory fixtures. No node, no build step,
+no real chain. To run it:
+
+```sh
+go run ./web
+# opens on http://localhost:8080 (PORT env var overridable)
+```
+
+Five screens, all reachable from the home nav:
+
+- `/reach` — create a Reach (sign up to be a Nomad) + Reach list/detail
+- `/stash/{holderID}` — Stash dashboard (Grain balance + Bread scale + 90-day maturity)
+- `/window` — Window authorization (open/lifecycle/audit log)
+- `/standing/{reachID}` — Standing + Freeholder signals progress
+- `/bloom/{stashID}` — Bloom accrual view
+
+HTMX is a single vendored JS file (`web/static/htmx.min.js`), NOT a Go
+dependency — `go.mod` stays unchanged (G-006).
+
## Docs
The docs site is [MkDocs Material](https://squidfunk.github.io/mkdocs-material/)
@@ -58,17 +85,19 @@ deferred to v0.4 (D-046); v0.3 ships the source.
## Lexicon firewall
OpenYield bans 10 financial terms as standalone words (REQ-012) across all Go
-source (`x/**/*.go`) and all docs (`README.md` + `docs/**/*.md`). The banned
-terms are the words you would expect a legacy financial institution to use;
-this README and the docs describe them only by their **safe replacements**, so
-the firewall itself never trips. The firewall is enforced in code by two
-sibling Go tests:
+source (`x/**/*.go`), all docs (`README.md` + `docs/**/*.md`), and all web UI
+files (`web/**/*.{html,js,go}`). The banned terms are the words you would
+expect a legacy financial institution to use; this README and the docs describe
+them only by their **safe replacements**, so the firewall itself never trips.
+The firewall is enforced in code by three sibling Go tests:
- `lexicon_meta_test.go` (v0.2) — scans `x/**/*.go`.
- `lexicon_meta_docs/lexicon_meta_docs_test.go` (v0.3) — scans `README.md` +
`docs/**/*.md`.
+- `lexicon_meta_web/lexicon_meta_web_test.go` (v0.6) — scans
+ `web/templates/**` + `web/static/**` + `web/**/*.go`.
-Both use `lexicon.FindBannedTerm` (word-boundary, case-insensitive), so
+All three use `lexicon.FindBannedTerm` (word-boundary, case-insensitive), so
"OpenYield" is safe (word-boundary does not match the banned term inside an
identifier) but the standalone banned term is not — docs say **"real
production"** / **"real return"**, and a Holder's identity is **Holder** /
From eba12998b76951ddf75fb552c90eb23f089b8652 Mon Sep 17 00:00:00 2001
From: cloudinit-bot
Date: Tue, 18 Aug 2026 18:52:53 +0000
Subject: [PATCH 16/26] =?UTF-8?q?checkpoint(p1):=20v0.6=20phase=201=20comp?=
=?UTF-8?q?lete=20=E2=86=92=20v0.5.1?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
---ci---
project: oy
phase: 1
milestone: v0.6
status: complete
requirements:
covered: [REQ-040, REQ-045]
partial: []
---/ci---
---
.ciagent/CHECKPOINT.json | 9 ++++-----
1 file changed, 4 insertions(+), 5 deletions(-)
diff --git a/.ciagent/CHECKPOINT.json b/.ciagent/CHECKPOINT.json
index 85620cc..0e5acaa 100644
--- a/.ciagent/CHECKPOINT.json
+++ b/.ciagent/CHECKPOINT.json
@@ -1,14 +1,13 @@
{
- "phase": 0,
+ "phase": 1,
"stage": "complete",
"milestone": "v0.6",
"milestone_type": "feature",
"tag_base": "v0.5.x",
- "phase_role": "pre_execution",
+ "phase_role": "execution",
"project": "oy",
"attempts": 0,
- "updated_at": "2026-08-18T12:12:00Z",
+ "updated_at": "2026-08-18T13:55:00Z",
"milestone_complete": false,
- "milestone_release_tag": "v0.5.0",
- "release_id": 770
+ "requirements_covered": ["REQ-040", "REQ-045"]
}
\ No newline at end of file
From 973e25a7c950f37c7ec1920c8b9ba13c241fd392 Mon Sep 17 00:00:00 2001
From: cloudinit-bot
Date: Tue, 18 Aug 2026 18:55:03 +0000
Subject: [PATCH 17/26] =?UTF-8?q?feat(P2):=20Stash=20dashboard=20=E2=80=94?=
=?UTF-8?q?=20Bread=20scale=20+=20maturity=20progress=20(REQ-041)?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
web/handlers/stash.go: GET /stash/{holderID} renders balance in Grain +
Bread-scale conversion (x/bread/types.BreadScaleAll() + GrainsPerBread=10000,
D-073 code constants NOT docs) + 90-day maturity progress bar (ActiveDays/90
capped at 100%) + IsMature badge. stash_test.go: D-073 regression guard
(GrainsPerBread=10000, Crumb=100 Grain; would fail if docs 1000x values used),
mature vs immature fixture, 404 + G-026 error lexicon check. Coverage 83.5%
cumulative. Template FuncMap divGrain for the scale table.
---ci---
project: oy
phase: 2
milestone: v0.6
status: execute
---/ci---
---
web/handlers/server.go | 13 +++-
web/handlers/stash.go | 68 ++++++++++++++++
web/handlers/stash_test.go | 156 +++++++++++++++++++++++++++++++++++++
web/templates/stash.html | 56 +++++++++++++
4 files changed, 291 insertions(+), 2 deletions(-)
create mode 100644 web/handlers/stash.go
create mode 100644 web/handlers/stash_test.go
create mode 100644 web/templates/stash.html
diff --git a/web/handlers/server.go b/web/handlers/server.go
index b9eafec..deb6c39 100644
--- a/web/handlers/server.go
+++ b/web/handlers/server.go
@@ -33,8 +33,16 @@ type Server struct {
// New constructs a Server with the given store + per-page templates loaded
// from templatesDir (the absolute or relative path to web/templates/).
func New(s *store.Store, templatesDir string) (*Server, error) {
+ funcs := template.FuncMap{
+ "divGrain": func(grain, unit int64) int64 {
+ if unit == 0 {
+ return 0
+ }
+ return grain / unit
+ },
+ }
basePath := filepath.Join(templatesDir, "base.html")
- base, err := template.ParseFiles(basePath)
+ base, err := template.New("base.html").Funcs(funcs).ParseFiles(basePath)
if err != nil {
return nil, fmt.Errorf("parse base: %w", err)
}
@@ -66,7 +74,8 @@ func New(s *store.Store, templatesDir string) (*Server, error) {
// patterns). Called by web/server.go after constructing the Server.
func (s *Server) Register(mux *http.ServeMux) {
s.registerReach(mux)
- // P2..P5 register their own routes (stash, window, standing, bloom).
+ s.registerStash(mux)
+ // P3..P5 register their own routes (window, standing, bloom).
}
// render executes the named page template with the given data, writing HTML
diff --git a/web/handlers/stash.go b/web/handlers/stash.go
new file mode 100644
index 0000000..f9fff61
--- /dev/null
+++ b/web/handlers/stash.go
@@ -0,0 +1,68 @@
+package handlers
+
+import (
+ "net/http"
+
+ breadtypes "github.com/oy/openyield/x/bread/types"
+ stashtypes "github.com/oy/openyield/x/stash/types"
+)
+
+// registerStash wires the Stash dashboard route (REQ-041) into the mux.
+func (s *Server) registerStash(mux *http.ServeMux) {
+ mux.HandleFunc("GET /stash/{holderID}", s.handleStashDashboard)
+}
+
+// stashViewData is the template data for the Stash dashboard. It carries the
+// real x/*/types structs plus the Bread-scale conversion (computed from the
+// x/bread/types code constants per D-073) and the maturity progress.
+type stashViewData struct {
+ Stash stashtypes.Stash
+ Activity stashtypes.StashActivity
+ Found bool
+ BreadScale []breadtypes.BreadScale
+ BalanceBread int64
+ MaturityPct int
+ Mature bool
+ ThresholdDays uint32
+ MaxGapDays uint32
+}
+
+// handleStashDashboard renders the Stash dashboard (REQ-041): balance in Grain
+// + Bread-scale conversion (using x/bread/types.BreadScaleAll() + GrainsPerBread
+// per D-073 — code constants, NOT docs) + 90-day maturity progress bar
+// (StashActivity.IsMature, MaturityThresholdDays=90).
+func (s *Server) handleStashDashboard(w http.ResponseWriter, r *http.Request) {
+ holderID := r.PathValue("holderID")
+ stash, ok := s.Store.GetStash(holderID)
+ if !ok {
+ http.NotFound(w, r)
+ return
+ }
+ activity, _ := s.Store.GetStashActivity(stash.StashID)
+
+ // D-073: Bread-scale conversion from x/bread/types code constants.
+ scale := breadtypes.BreadScaleAll()
+ balanceBread := stash.BalanceGrain / breadtypes.GrainsPerBread
+
+ // Maturity progress: ActiveDays / MaturityThresholdDays, capped at 100%.
+ threshold := uint32(stashtypes.MaturityThresholdDays)
+ pct := int(float64(activity.ActiveDays) / float64(threshold) * 100)
+ if pct > 100 {
+ pct = 100
+ }
+ if pct < 0 {
+ pct = 0
+ }
+
+ s.render(w, "stash.html", stashViewData{
+ Stash: stash,
+ Activity: activity,
+ Found: true,
+ BreadScale: scale,
+ BalanceBread: balanceBread,
+ MaturityPct: pct,
+ Mature: activity.IsMature(),
+ ThresholdDays: threshold,
+ MaxGapDays: stashtypes.MaxGapForMaturity,
+ })
+}
diff --git a/web/handlers/stash_test.go b/web/handlers/stash_test.go
new file mode 100644
index 0000000..fc8d895
--- /dev/null
+++ b/web/handlers/stash_test.go
@@ -0,0 +1,156 @@
+package handlers
+
+import (
+ "net/http"
+ "net/http/httptest"
+ "strings"
+ "testing"
+
+ breadtypes "github.com/oy/openyield/x/bread/types"
+ stashtypes "github.com/oy/openyield/x/stash/types"
+)
+
+func TestStashDashboardSeededMatureHolder(t *testing.T) {
+ srv := newTestServer(t)
+ mux := http.NewServeMux()
+ srv.Register(mux)
+ rec := httptest.NewRecorder()
+ req := httptest.NewRequest("GET", "/stash/holder-alia", nil)
+ mux.ServeHTTP(rec, req)
+ if rec.Code != http.StatusOK {
+ t.Fatalf("GET /stash/holder-alia: status %d, want 200", rec.Code)
+ }
+ body := rec.Body.String()
+ // Balance in Grain present.
+ if !strings.Contains(body, "Grain") {
+ t.Errorf("body missing 'Grain' balance")
+ }
+ // Bread-scale conversion table present (all 11 denominations from BreadScaleAll).
+ for _, ds := range breadtypes.BreadScaleAll() {
+ if !strings.Contains(body, ds.Name) {
+ t.Errorf("body missing Bread-scale denomination %q", ds.Name)
+ }
+ }
+ // Mature holder (ActiveDays=92, MaxGap=10): progress ~100%, Mature badge.
+ if !strings.Contains(body, "Mature") {
+ t.Errorf("body missing 'Mature' badge for mature holder-alia")
+ }
+ assertNoBannedTerms(t, body)
+}
+
+func TestStashDashboardImmatureHolder(t *testing.T) {
+ srv := newTestServer(t)
+ mux := http.NewServeMux()
+ srv.Register(mux)
+ rec := httptest.NewRecorder()
+ req := httptest.NewRequest("GET", "/stash/holder-bryn", nil)
+ mux.ServeHTTP(rec, req)
+ if rec.Code != http.StatusOK {
+ t.Fatalf("GET /stash/holder-bryn: status %d, want 200", rec.Code)
+ }
+ body := rec.Body.String()
+ // Immature holder (ActiveDays=45, MaxGap=5): Not mature badge.
+ if !strings.Contains(body, "Not mature") {
+ t.Errorf("body missing 'Not mature' badge for immature holder-bryn")
+ }
+ // Progress bar at 50% (45/90).
+ if !strings.Contains(body, "50%") {
+ t.Errorf("body missing 50%% progress for holder-bryn (45/90 days)")
+ }
+ assertNoBannedTerms(t, body)
+}
+
+func TestStashDashboardMissingReturns404(t *testing.T) {
+ srv := newTestServer(t)
+ mux := http.NewServeMux()
+ srv.Register(mux)
+ rec := httptest.NewRecorder()
+ req := httptest.NewRequest("GET", "/stash/nobody", nil)
+ mux.ServeHTTP(rec, req)
+ if rec.Code != http.StatusNotFound {
+ t.Fatalf("GET /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())
+}
+
+// TestStashBreadScaleConversionCorrectness (D-073 regression guard): asserts
+// the Stash dashboard uses x/bread/types code constants (GrainsPerBread=10000,
+// BreadScaleAll() with Grain=1, Crumb=100, Bread=10000...), NOT the outdated
+// docs/shared/bread-scale.md (which claims 1,000x ratios). This test would FAIL
+// if the handler hardcoded the docs values instead of using the code constants.
+func TestStashBreadScaleConversionCorrectness(t *testing.T) {
+ srv := newTestServer(t)
+ mux := http.NewServeMux()
+ srv.Register(mux)
+ rec := httptest.NewRecorder()
+ req := httptest.NewRequest("GET", "/stash/holder-alia", nil)
+ mux.ServeHTTP(rec, req)
+ body := rec.Body.String()
+
+ // D-073: the code constants are the source of truth.
+ // GrainsPerBread must be 10000 (code), NOT 1000 (docs claim 1 Crumb=1000 Grain).
+ if breadtypes.GrainsPerBread != 10000 {
+ t.Fatalf("D-073: x/bread/types.GrainsPerBread = %d, want 10000 (code constant)", breadtypes.GrainsPerBread)
+ }
+
+ // The handler computes BalanceBread = BalanceGrain / GrainsPerBread.
+ // holder-alia seed: BalanceGrain = 920000 -> 92 Bread.
+ stash, ok := srv.Store.GetStash("holder-alia")
+ if !ok {
+ t.Fatal("seeded holder-alia stash missing")
+ }
+ wantBread := stash.BalanceGrain / breadtypes.GrainsPerBread
+ wantBreadStr := []byte(formatInt(wantBread))
+ if !strings.Contains(body, string(wantBreadStr)) {
+ t.Errorf("D-073: body missing expected Bread conversion %d (from %d Grain / %d GrainsPerBread)",
+ wantBread, stash.BalanceGrain, breadtypes.GrainsPerBread)
+ }
+
+ // The Bread-scale table must include the code-constant Grain values.
+ scale := breadtypes.BreadScaleAll()
+ for _, ds := range scale {
+ if !strings.Contains(body, formatInt(ds.GrainValue)) {
+ t.Errorf("D-073: body missing Bread-scale GrainValue %d for %s", ds.GrainValue, ds.Name)
+ }
+ }
+
+ // Regression guard: if someone used the outdated docs value (1 Crumb = 1000
+ // Grain) instead of the code constant (1 Crumb = 100 Grain), the Crumb row
+ // would show 1000 — assert it shows 100 (the code value).
+ crumbs := scale[1] // index 1 = Crumb
+ if crumbs.Name != "Crumb" || crumbs.GrainValue != 100 {
+ t.Fatalf("D-073: BreadScaleAll()[1] = {%s, %d}, want {Crumb, 100}", crumbs.Name, crumbs.GrainValue)
+ }
+ if !strings.Contains(body, "100") {
+ t.Errorf("D-073: body missing code-constant Crumb=100 Grain (would show 1000 if docs values were used)")
+ }
+}
+
+// Compile-time assertions that the handler uses the real x/*/types structs
+// (D-067: the UI grounds in the real Go type definitions).
+var _ stashtypes.Stash
+var _ stashtypes.StashActivity
+
+// formatInt is a tiny strconv.Itoa without the import (keeps test deps minimal).
+func formatInt(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:])
+}
diff --git a/web/templates/stash.html b/web/templates/stash.html
new file mode 100644
index 0000000..300ab51
--- /dev/null
+++ b/web/templates/stash.html
@@ -0,0 +1,56 @@
+{{define "title"}}Stash — OpenYield{{end}}
+
+{{define "content"}}
+
+
Stash
+
A Stash is a Holder's personal storage — the place a Nomad holds Grain.
+ It is a storage layer, not a custodial position: the Holder owns it,
+ controls it, and can delegate a scoped, time-limited Window to a partner
+ without giving up custody.
+{{end}}
\ No newline at end of file
diff --git a/web/templates/window_list.html b/web/templates/window_list.html
new file mode 100644
index 0000000..32a15a6
--- /dev/null
+++ b/web/templates/window_list.html
@@ -0,0 +1,33 @@
+{{define "title"}}Window — OpenYield{{end}}
+
+{{define "content"}}
+
+
Window
+
A Window is a Holder-authorized, scope-bounded, time-limited, revocable
+ delegation of access (REQ-015). The Holder opens a Window so a partner or
+ service can read a Stash or process a Pass-Act — without giving up custody.
+ The Window is revocable, rate-limited, and audited.
No Windows yet for {{.Grantor}}. Open a Window to begin.
+ {{end}}
+
+{{end}}
\ No newline at end of file
diff --git a/web/templates/window_new.html b/web/templates/window_new.html
new file mode 100644
index 0000000..ea7bb70
--- /dev/null
+++ b/web/templates/window_new.html
@@ -0,0 +1,36 @@
+{{define "title"}}Open a Window — OpenYield{{end}}
+
+{{define "content"}}
+
+
Open a Window
+
A Window delegates scoped access to a partner or service without giving
+ up custody. The Holder sets the scope, the duration, and a rate-limit; the
+ Window is revocable at any time.
+
+{{end}}
\ No newline at end of file
From 5008f85da108f273a7cf208584c7c859ccdcf7ad Mon Sep 17 00:00:00 2001
From: cloudinit-bot
Date: Tue, 18 Aug 2026 18:58:32 +0000
Subject: [PATCH 20/26] =?UTF-8?q?checkpoint(p3):=20v0.6=20phase=203=20comp?=
=?UTF-8?q?lete=20=E2=86=92=20v0.5.3?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
---ci---
project: oy
phase: 3
milestone: v0.6
status: complete
requirements:
covered: [REQ-040, REQ-041, REQ-042, REQ-045]
---/ci---
---
.ciagent/CHECKPOINT.json | 6 +++---
1 file changed, 3 insertions(+), 3 deletions(-)
diff --git a/.ciagent/CHECKPOINT.json b/.ciagent/CHECKPOINT.json
index 686b8f2..2539764 100644
--- a/.ciagent/CHECKPOINT.json
+++ b/.ciagent/CHECKPOINT.json
@@ -1,5 +1,5 @@
{
- "phase": 2,
+ "phase": 3,
"stage": "complete",
"milestone": "v0.6",
"milestone_type": "feature",
@@ -7,7 +7,7 @@
"phase_role": "execution",
"project": "oy",
"attempts": 0,
- "updated_at": "2026-08-18T14:05:00Z",
+ "updated_at": "2026-08-18T14:20:00Z",
"milestone_complete": false,
- "requirements_covered": ["REQ-040", "REQ-041", "REQ-045"]
+ "requirements_covered": ["REQ-040", "REQ-041", "REQ-042", "REQ-045"]
}
From 451ea0841402a00919495a94ab928cece08d2192 Mon Sep 17 00:00:00 2001
From: cloudinit-bot
Date: Tue, 18 Aug 2026 19:02:44 +0000
Subject: [PATCH 21/26] feat(P4): Standing + Freeholder signals progress
(REQ-043)
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
web/handlers/standing.go: GET /standing/{reachID} renders standing score
+ bucket + 4-signal progress + Freeholder-eligible badge. Store extensions:
ListRatings/ListVouches/ListSlashes/ComputeStandingScore (simplified, from
locked x/standing/types constants PriorMean/PriorWeight/ComputeDiversityBonus/
GetVoucherWeight/GetStandingBucket — NOT hardcoded)/ComputeFreeholderSignals
(real FreeholderSignals struct + IsFreeholderEligible). Seed: holder-alia
(12 ratings, 4 cats, 1 Vouch, mature Stash -> eligible) vs holder-bryn
(3 ratings, 1 cat, no Vouch, immature -> not eligible). standing_test.go:
computed-from-locked-constants regression guard (PriorMean=4.0, PriorWeight=
10, DiversityBonus4Cats=0.10), Freeholder-eligible badge reflects
IsFreeholderEligible() (the real method), G-026 error lexicon check.
Coverage: store 98.0%, handlers 88.7%.
---ci---
project: oy
phase: 4
milestone: v0.6
status: execute
---/ci---
---
web/handlers/server.go | 6 +-
web/handlers/standing.go | 59 ++++++++++++++
web/handlers/standing_test.go | 146 ++++++++++++++++++++++++++++++++++
web/store/fixtures.go | 77 +++++++++++++++++-
web/store/store.go | 108 +++++++++++++++++++++++++
web/store/store_test.go | 142 +++++++++++++++++++++++++++------
web/templates/standing.html | 89 +++++++++++++++++++++
7 files changed, 600 insertions(+), 27 deletions(-)
create mode 100644 web/handlers/standing.go
create mode 100644 web/handlers/standing_test.go
create mode 100644 web/templates/standing.html
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.
+{{end}}
\ No newline at end of file
From 89ec9a76dbdffb50cd755951d6c503652dbd9023 Mon Sep 17 00:00:00 2001
From: cloudinit-bot
Date: Tue, 18 Aug 2026 19:02:52 +0000
Subject: [PATCH 22/26] =?UTF-8?q?checkpoint(p4):=20v0.6=20phase=204=20comp?=
=?UTF-8?q?lete=20=E2=86=92=20v0.5.4?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
---ci---
project: oy
phase: 4
milestone: v0.6
status: complete
requirements:
covered: [REQ-040, REQ-041, REQ-042, REQ-043, REQ-045]
---/ci---
---
.ciagent/CHECKPOINT.json | 6 +++---
1 file changed, 3 insertions(+), 3 deletions(-)
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"]
}
From 56db37a46346d20567e3c005436ff96ee1a92d38 Mon Sep 17 00:00:00 2001
From: cloudinit-bot
Date: Tue, 18 Aug 2026 19:06:16 +0000
Subject: [PATCH 23/26] =?UTF-8?q?feat(P5):=20Bloom=20accrual=20view=20?=
=?UTF-8?q?=E2=80=94=20per-Stash=20BloomRecord=20(REQ-044)?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
web/handlers/bloom.go: GET /bloom/{stashID} renders 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) + the 4.0%-5.0% band
(Min/MaxBloomRateBasisPoints) + Mission Lock note. Store extensions:
GetBloomRecord/ListBloomRecords. Seed: holder-alia at target rate (450
bps), holder-bryn at 420 bps (within band). bloom_test.go: D-073
target-rate source-of-truth regression guard (asserts TargetBloomRate-
BasisPoints=450, Min=400, Max=500 from code constants; would fail if
hardcoded), G-026 error lexicon check, highest-risk screen for banned-
term drift (uses "Bloom"/"real production"/"accrual" labels only).
Coverage: store 98.1%, handlers 89.2%.
---ci---
project: oy
phase: 5
milestone: v0.6
status: execute
---/ci---
---
web/handlers/bloom.go | 50 ++++++++++++++
web/handlers/bloom_test.go | 137 +++++++++++++++++++++++++++++++++++++
web/handlers/server.go | 2 +-
web/store/fixtures.go | 20 ++++++
web/store/store.go | 27 ++++++++
web/store/store_test.go | 44 ++++++++++++
web/templates/bloom.html | 33 +++++++++
7 files changed, 312 insertions(+), 1 deletion(-)
create mode 100644 web/handlers/bloom.go
create mode 100644 web/handlers/bloom_test.go
create mode 100644 web/templates/bloom.html
diff --git a/web/handlers/bloom.go b/web/handlers/bloom.go
new file mode 100644
index 0000000..76a2585
--- /dev/null
+++ b/web/handlers/bloom.go
@@ -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,
+ })
+}
diff --git a/web/handlers/bloom_test.go b/web/handlers/bloom_test.go
new file mode 100644
index 0000000..0260ecb
--- /dev/null
+++ b/web/handlers/bloom_test.go
@@ -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:])
+}
diff --git a/web/handlers/server.go b/web/handlers/server.go
index 3b38ebb..a1f1394 100644
--- a/web/handlers/server.go
+++ b/web/handlers/server.go
@@ -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
diff --git a/web/store/fixtures.go b/web/store/fixtures.go
index 7ae3e87..5eda588 100644
--- a/web/store/fixtures.go
+++ b/web/store/fixtures.go
@@ -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
diff --git a/web/store/store.go b/web/store/store.go
index f397c64..45bbd6a 100644
--- a/web/store/store.go
+++ b/web/store/store.go
@@ -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-".
+ if strings.HasPrefix(stashID, "stash-"+holderID) {
+ out = append(out, rec)
+ }
+ }
+ return out
+}
diff --git a/web/store/store_test.go b/web/store/store_test.go
index 5cd714e..0b61e39 100644
--- a/web/store/store_test.go
+++ b/web/store/store_test.go
@@ -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))
+ }
+}
diff --git a/web/templates/bloom.html b/web/templates/bloom.html
new file mode 100644
index 0000000..561a256
--- /dev/null
+++ b/web/templates/bloom.html
@@ -0,0 +1,33 @@
+{{define "title"}}Bloom — OpenYield{{end}}
+
+{{define "content"}}
+
+
Bloom
+
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.