Compare commits

..

85 Commits

Author SHA1 Message Date
cloudinit-bot fff74b2de1 test(guild): P3 verify — GREEN (vet+race+coverage+lexicon+G-003+G-028)
docs-build / go test ./... (lexicon firewall + all x/* tests) (push) Has been cancelled
docs-build / mkdocs build (docs site artifact) (push) Has been cancelled
---ci---
project: oy
phase: 3
milestone: v0.7
status: verify
---/ci---
2026-08-19 02:16:46 +00:00
cloudinit-bot b6d7b1a9ec feat(guild): P3 Guild Charter + Chapter Federation + Household/Confederation
Extends x/guild with P3 (v0.7) Guild Charter + Chapter Federation runtime
+ Household one-tap exit + Confederation Voice delegation + D-087
PierCarriesVoice locked const + REQ-064 cooling consts.

- x/guild/types: extend Guild (CommonBondHash, PublicProfile,
  ParentGuildID, IsChapter, SecessionTermsHash, GoodStandingLiens); add
  GuildPublicProfile, Lien, SecessionTerms, ConfederationVoice structs;
  add PierCarriesVoice=false (D-087), CoolingSecessionCoverActiveDays=21,
  CoolingSecessionNonCoverDays=14 consts; extend Params + GenesisState
  (Chapters slice + Chapter->ParentGuildID ref check).
- x/guild/types/msg_guild.go: 5 Msg* (CreateGuild, CreateChapter,
  OneTapExitStand, DelegateConfederationVoice, AddLien) + MsgServer
  interface + Response types (Disclaimer surfaced per REQ-061).
- x/guild/types/expected_keepers.go: StandKeeper + StashKeeper G-003 shims.
- x/guild/keeper: NEW store-backed Keeper (guild/lien/delegation stores)
  + MsgServer handlers + simtest (8 cases).
- x/guild/module.go: AppModule (D-054 simtest-grade).
- x/stand/types: IsHousehold + IsConfederation helpers + ConfederationVoice
  type-level scaffold (REQ-057/REQ-058).

Coverage: x/guild 85.7%, keeper 94.3%, types 97.1%.
G-006/G-028 intact. go.mod/go.sum diff EMPTY. go vet clean.

REQs: REQ-051, REQ-053, REQ-057, REQ-058, REQ-061

---ci---
project: oy
phase: 3
milestone: v0.7
status: execute
---/ci---
2026-08-19 02:16:42 +00:00
cloudinit-bot 4eec2ff502 Merge phase/02 into milestone/v0.7 (P2 complete → v0.6.2)
docs-build / go test ./... (lexicon firewall + all x/* tests) (push) Has been cancelled
docs-build / mkdocs build (docs site artifact) (push) Has been cancelled
2026-08-19 02:08:43 +00:00
cloudinit-bot 72cc922b3b test(cover): P2 verify — GREEN (vet+race+coverage+lexicon+G-003+G-028)
docs-build / go test ./... (lexicon firewall + all x/* tests) (push) Has been cancelled
docs-build / mkdocs build (docs site artifact) (push) Has been cancelled
---ci---
project: oy
phase: 2
milestone: v0.7
status: verify
---/ci---
2026-08-19 02:08:43 +00:00
cloudinit-bot 907dc66d12 feat(cover): P2 Cover-Charter + Pool Council + staging + Bill of Rights (D-090(1))
P2 of v0.7 extends x/cover with Cover-Charter + Pool governance hybrid +
category staging + the Anti-Capture Bill of Rights types (D-090(1)
temporal-gap fix — Bill of Rights types land HERE, not P5, so the dual
firewall is in place before any Charter can be signed).

New (x/cover/types/rights.go): RightID type + 13 Right* consts +
AntiCaptureBillOfRightsCount=13 locked const + 13 Waivable* bool consts
(all false) + RightIsWaivable() always false + AllRights()/AllWaivableFlags().

New structs: CoverCharter (REQ-052) + CharterAmendment (7-day cooling) +
PoolCouncil (REQ-062 — 3 Masons + Watcher observer; NO Anchor/MAB seat) +
CoverCallVote (majority requires Watcher observer present). CoverPool
extended with CharterRef + CouncilRef. D-086 DefaultParams [Phase2] ->
[Phase2, Phase3, Phase4].

New Msg*: MsgSignCoverCharter (D-090(1) WaivedRights gate at
ValidateBasic — mirrors MissionLockAmendmentRejected D-064),
MsgAmendCoverCharter, MsgElectPoolMason, MsgVoteCoverCall,
MsgAmendPoolStandingGate (D-090(3) dual check: floor at ValidateBasic +
handler), MsgEscalateReserveCeiling (12-month age check).

New handlers: SignCoverCharter, AmendCoverCharter (Proposed + ProposedAt),
ElectPoolMason (max 3), VoteCoverCall (Yes requires observer),
AmendPoolStandingGate (D-090(3) re-check), EscalateReserveCeiling,
CoolCharterAmendment + RatifyCharterAmendment lifecycle helpers.

New stores: charter/ council/ vote/ amendment/ + SetParamsOverride/Params().

Simtest cases (a)-(h): Charter signing + D-090(1) WaivedRights reject +
7-day cooling + election + vote observer + D-086 out-of-phase + ceiling
escalation + D-090(3) below-floor reject.

Lexicon: rights.go + msg_charter.go lexicon-clean (initial 'policy' hit
fixed -> 'invariant'). .lexicon_fixture SkipDir guard added to 3 lexicon
walks (fixes pre-existing cross-package test-isolation race).

G-003/G-006/G-028/G-024 intact. go.mod/go.sum diff EMPTY.
Coverage: types 98.9%, keeper 95.1%, firewall 100.0%.

REQs: REQ-048, REQ-052, REQ-062, REQ-065 (D-090(1) Bill of Rights types
for REQ-056 land here; P5 adds the ceremony)

---ci---
project: oy
phase: 2
milestone: v0.7
status: execute
---/ci---
2026-08-19 02:08:33 +00:00
cloudinit-bot a23856a9ee Merge phase/01 into milestone/v0.7-fraternal-groups (P1 complete → v0.6.1)
docs-build / go test ./... (lexicon firewall + all x/* tests) (push) Has been cancelled
docs-build / mkdocs build (docs site artifact) (push) Has been cancelled
2026-08-19 01:53:57 +00:00
cloudinit-bot 7a00131cf0 test(cover): P1 verify — structural+behavioral+security+quality GREEN
docs-build / go test ./... (lexicon firewall + all x/* tests) (push) Has been cancelled
docs-build / mkdocs build (docs site artifact) (push) Has been cancelled
VERIFY stage for P1 v0.7. Four verification layers:

1. STRUCTURAL: go vet ./x/cover/... ./lexicon_meta_cover/... — CLEAN
2. BEHAVIORAL: go test -race ./x/cover/... — GREEN (no race conditions)
3. SECURITY: 4 lexicon meta-tests green (x/, docs/, web/, cover/); G-003
   production firewall intact (no cross-module struct imports in
   x/cover/types — only expected_keepers.go interface references); G-028
   go.mod/go.sum diff EMPTY
4. QUALITY: coverage x/cover/types 97.8%, x/cover/keeper 94.1%,
   x/cover/firewall 100.0% — all ≥80% target

All existing v0.1-v0.6 tests still pass (no regressions).

---ci---
project: oy
phase: 1
milestone: v0.7
status: verify
---/ci---
2026-08-19 01:53:52 +00:00
cloudinit-bot 6d63482c48 feat(cover): P1 v0.7 Cover Pool foundation + Anti-Crowding-Out firewall
Add the new x/cover module (Cover Pool runtime) implementing P1 of the
v0.7 milestone: CoverPool/CoverFeeTag/CoverCall types with the 4 GRILL-
ratified locked consts (CoverReserveFloorAnnualContribX=1.5,
CoverReserveCeilingAnnualContribX=2.5, CoverStandingGateTrusted=4.0,
CoverStandingGatePreferred=4.5), the 8-category/3-phase CoverCategory
enum with D-086 FactoryAllowedPhases=[Phase2]-only default, three Msg*
types (LaunchCoverPool/RouteCoverFee/FileCoverCall) with full sdk.Msg
impls, store-backed Keeper with 4 G-003 expected-keeper shims
(StandingKeeper/WatcherKeeper/BondKeeper/StillKeeper), and three
handlers enforcing the D-077 Standing gate, D-086 category phase check,
REQ-047 reserve floor + below-floor auto-pause (D-089(1) Still
invocation), and REQ-050 category-tag match.

Add the x/cover/firewall subpackage (Anti-Crowding-Out firewall, D-079/
D-088): a stdlib-only leaf checker enforcing RightNoTaxOnPersonalStash
by rejecting Cover-Fee routing to the Root-Pool operating-expenses
destination (defense in depth with the lexicon meta-test).

Add the lexicon_meta_cover meta-test (4th lexicon firewall, D-088):
scans x/cover/**/*.go for both lexicon.FindBannedTerm (10 project-wide
terms) AND lexicon.FindCoverBannedTerm (4 Cover-specific terms), with
G-013 walk-coverage + G-009 self-test tables.

Add lexicon.CoverBannedTerms()/FindCoverBannedTerm()/
SyntheticCoverBannedStrings() helpers (additive to the existing
project-wide BannedTerms — no changes to existing helpers).

Apply D-088(3) optional doc-fix: replace 'insurance-like' with
'Cover-like' in x/pact/types docstrings.

Coverage: x/cover/types 97.8%, x/cover/keeper 94.1%, x/cover/firewall
100.0%. go.mod/go.sum unchanged (G-006/G-028). All existing tests pass.

REQs: REQ-046, REQ-047, REQ-049, REQ-050

---ci---
project: oy
phase: 1
milestone: v0.7
status: execute
---/ci---
2026-08-19 01:52:58 +00:00
cloudinit-bot 463e11e8d2 Merge phase/00 into milestone/v0.7-fraternal-groups (P0 complete → v0.6.0)
docs-build / go test ./... (lexicon firewall + all x/* tests) (push) Has been cancelled
docs-build / mkdocs build (docs site artifact) (push) Has been cancelled
2026-08-19 01:42:30 +00:00
cloudinit-bot 315ccb3fdc decision(P00): mvp/ux gate — auto-generated (3 sections verified)
docs-build / go test ./... (lexicon firewall + all x/* tests) (push) Has been cancelled
docs-build / mkdocs build (docs site artifact) (push) Has been cancelled
MVP/UX CHECK gate (REQ-MVP-UX-001) verified at full autonomy. PLANS.md
v0.7 section contains all 3 required sections:

1. ## User-Facing Surface (PLANS.md L2355) — simtest is the executable
   demonstration for this protocol milestone (no UI; v0.6 web complete);
   simtest run via go test ./x/cover/keeper/...; .feature scenarios named
   as Test* functions
2. ## Happy Path (PLANS.md L2366) — 10-step end-to-end Cover Pool lifecycle
   (launch -> charter -> elect -> route fee -> issue MAB -> release proceeds
   -> file call -> secede -> Pier escalation -> dissolve), verified by P6
   simtest suite
3. ## UX Acceptance Criteria (PLANS.md L2385) — 11 explicit criteria verified
   in P6 audit (go build/test GREEN, x/cover full surface, Anti-Crowding-Out
   firewall operational, 12 locked consts, MAB coupons NEVER Bread, Bill of
   Rights 13 rights non-waivable, coverage >=80%, G-003 intact, G-006/G-028
   go.mod EMPTY, pen-test self-administered, §7 acceptance met)

Gate PASSES (all 3 sections present). Auto-generated per run.md full
autonomy.

---ci---
project: oy
phase: 0
milestone: v0.7
status: mvp_ux_check
---/ci---
2026-08-19 01:42:16 +00:00
cloudinit-bot 977d2a1d24 docs(P00): grill — D-086..D-090 binding decisions (CONDITIONAL PASS)
GRILL stage for v0.7. ci-griller red-teamed the plan across 9 axes + 7
specific probes. Overall verdict: CONDITIONAL PASS (confidence 0.72).
5 binding decisions applied to PLANS.md + ARCHITECTURE.md + oy-state + PROJECT.md:

- D-086: P1 Factory functional for Phase-2 categories ONLY (FactoryAllowedPhases
  = [Phase2] in P1; P2 extends to [Phase2,Phase3,Phase4]); P1 simtest adds
  out-of-phase rejection negative case
- D-087: PierCarriesVoice=false added as 12th locked const (was 11; now 12)
  in x/guild/types (FR-VOICE-6 mission-locked invariant)
- D-088: lexicon_meta_cover uses new lexicon.CoverBannedTerms() helper
  (insurance/premium/claim/policy scoped to Cover surface, NOT project-wide);
  x/cover/firewall pinned to allow-list of routing destinations (string-
  equality check); optional x/pact insurance-like -> Cover-like doc-fix
- D-089: StillKeeper satisfied by simtest-local stub (x/still NOT extended
  this milestone; x/still/keeper/ verified empty); x/bond -> x/cover
  CoverKeeper reverse edge added to dependency map (MsgDebitMABProceeds
  queries CoverKeeper.GetPoolReserveAccount; no import cycle, interface only)
- D-090: Bill of Rights temporal-gap fix (RightID + 13 Waivable* consts +
  RightIsWaivable() + ValidateBasic gate land in P2, NOT P5 — closes the
  P2->P5 window where rights were waivable); Voucher cold-start fix
  (bond = max(10x avg, MinimumVoucherBond) — no zero-bond bypass); Standing-
  gate dual check (floor enforced at BOTH launch handler AND Params-amendment
  ValidateBasic); D-085 escalation window tightened to before P2

3 escalations to PO: D-085 (13th right identity, confidence 0.55),
lexicon banned-term scope (griller recommends Cover-scoped helper),
pen-test third party (this grill IS the self-administered review per
oy-state §7 item 4).

This grill IS the self-administered adversarial review (pen-test) per
oy-state §7 remaining-open item 4. High/critical findings (D-090(1)
temporal gap, D-088 lexicon paper tiger, D-090(2) Voucher cold-start)
are remediated in P0 via binding decisions D-086..D-090.

---ci---
project: oy
phase: 0
milestone: v0.7
status: grill
---/ci---
2026-08-19 01:42:08 +00:00
cloudinit-bot 08dc139b5c docs(P00): plan — v0.7 vertical-slice phase plans (P1..P6)
PLAN stage for v0.7. Vertical-slice plans for 5 execution phases + 1 final:

- P1 Cover Pool Factory + firewall + floors + gates + tagging (v0.6.1)
  REQ-046/047/049/050 — firewall-first, NEW x/cover module (D-039/D-084)
- P2 Cover-Charter + Pool governance + category staging (v0.6.2)
  REQ-048/052/062/065 — extends x/cover + x/council
- P3 Guild Charter + Chapter Federation + Household/Confederation (v0.6.3)
  REQ-051/053/057/058/061 — extends x/guild + x/stand
- P4 MAB + Cover Claims Voucher + Shadow vouch + MAB seniority (v0.6.4)
  REQ-054/055/060/063 — extends x/bond + x/cover + x/standing
- P5 Anti-Capture Bill + secession cooling + Stand->Pier + Pier selection (v0.6.5)
  REQ-056/059/064/066 — cross-cutting, lands last (constrains all prior)
- P6 final review/audit/ship (v0.6.6 = v0.7 milestone release)

MVP/UX CHECK sections embedded in PLANS.md:
- User-Facing Surface: simtest is the executable demonstration (no UI)
- Happy Path: 10-step end-to-end Cover Pool lifecycle (launch -> charter ->
  elect -> route fee -> issue MAB -> release proceeds -> file call ->
  secede -> Pier escalation -> dissolve)
- UX Acceptance Criteria: 11 criteria verified in P6 audit

Cross-phase dependency map + 11 locked-const additions table included.
D-085 escalation (13th right) flagged for lead-developer before P5.

---ci---
project: oy
phase: 0
milestone: v0.7
status: plan
---/ci---
2026-08-19 01:36:04 +00:00
cloudinit-bot 398609dabb docs(P00): research — v0.7 fraternal groups + persona assessment
RESEARCH stage for v0.7. Delegated to ci-researcher subagent for 7-area
domain + codebase research. Key findings:

- x/cover module layout mirrors x/hub (D-039 precedent, D-084) — types/
  keeper/module.go/expected_keepers.go/msg_server.go/simtest
- Cover-Fee category tagging in x/cover (CoverFeeTag struct), NOT x/bread
  (no Grain struct today; avoids GrainsPerBread locked-const risk)
- Standing gate via expected-keeper shim (G-003; StandingKeeper interface)
- MAB as x/bond extension (anonymous embed, GrowthBond precedent);
  CouponDenom enum with CouponDenomBread rejected at ValidateBasic
  (MissionLockAmendmentRejected pattern)
- D-080 tagged streaming + Watcher-witnessed release; auto-Still on misuse
- Anti-Capture Bill = 13 RightID consts + 13 Waivable* bool consts (all
  false) + RightIsWaivable always false (dual firewall)
- Anti-Crowding-Out firewall = x/cover/firewall + lexicon_meta_cover
  meta-test (defense in depth; D-079)
- Guild extension: ParentGuildID + IsChapter + SecessionTermsHash +
  GoodStandingLiens (no GuildKind enum; schema-additive)
- Shadow vouch 50% = ShadowVouchWeightMultiplier const + IsShadow field +
  post-multiplier branch (const makes it locked-const firewall visible)
- Cover Claims Voucher in x/cover/types (NOT x/standing); slash via
  x/standing.Slash cross-Pool

D-085 escalation candidate (13th Anti-Capture right): spec enumerates 12 of
13 rights. Best candidate (confidence 0.55): NonParticipationNoDenial
(REQ-085/FR-NORM-4). NOT auto-decided; lead-developer surfaces to PO
before P5.

Persona assessment: backend-engineer + lead-developer + security-engineer
+ cosmos-engineer active (highest security density since v0.5).
frontend-engineer + docs-writer + mesh-engineer + data-engineer
deactivated. ci-security-auditor off until P6.

ARCHITECTURE.md extended with v0.7 component index + cross-component
dependencies + 11 new locked-const additions (pending GRILL ratification).
RESEARCH.md extended with v0.7 section (10 design recommendations +
pitfalls). PERSONAS.md rewritten for v0.7.

---ci---
project: oy
phase: 0
milestone: v0.7
status: research
---/ci---
2026-08-19 01:33:22 +00:00
cloudinit-bot f57c49f7ad docs(P00): clarify — log D-074..D-084 (v0.7 scope + 8 PO recommendations)
CLARIFY stage for v0.7. All 8 oy-spec §8 open questions resolved by
accepting PO recommendations as binding (D-074..D-080) plus the 3
interactive scope-shaping rulings (D-081 scope, plus D-082 phase order,
D-083 no IDEATE, D-084 new x/cover module).

Key decisions:
- D-074: TBD-X = $100k annual Pass volume (REQ-059)
- D-075: TBD-Z density formula locked for v0.8 USZ (REQ-095)
- D-076: Sovereign Anchors = separate SPEC (experimental v0.7)
- D-077: Standing gate binds at Factory runtime (REQ-049)
- D-078: Watcher/Voucher cap = 5% Bloom annual (v0.8 REQ-096)
- D-079: Anti-Crowding-Out = x/cover/firewall + meta-test (defense in depth)
- D-080: MAB lock = tagged streaming + Watcher-witnessed release
- D-081: §7 authoritative — v0.7 = REQ-046..066 only; Cluster A-C = v0.8
- D-082: P1 firewall-first → P2 charter+council → P3 guild/chapter →
  P4 MAB/voucher → P5 anti-capture/secession/pier → P6 final
- D-083: no IDEATE (no --ideate flag; scope pre-seeded from §7)
- D-084: new x/cover module (D-039 precedent; x/pact PactCover stays as
  cross-reference)

REQUIREMENTS.md extended with v0.7 REQ-046..REQ-066 table + v0.8 deferred
REQ-067..REQ-097 traceability. PROJECT.md extended with v0.7 scope +
D-074..D-084 clarification decisions.

---ci---
project: oy
phase: 0
milestone: v0.7
status: clarify
---/ci---
2026-08-19 01:28:27 +00:00
cloudinit-bot ea11991ec7 docs(P00): specify — regenerate oy-state v2 (v0.7 queued, REQ-046..097)
Regenerate oy-state per the regeneration rule (oy-spec changed since
state-v1). state-v2 reflects:
- v0.6 COMPLETE (tag v0.5.6, release_id 776) as last shipped
- v0.7 Fraternal Groups Foundation queued (REQ-046..REQ-066, 21 REQs)
- v0.8+ Risk Mitigations deferred (REQ-067..REQ-097, 31 REQs, D-081)
- §3 const firewall: v0.7 planned additions pending GRILL ratification
  (CoverReserveFloorAnnualContribX=1.5, CoverStandingGateTrusted=4.0,
   CoverStandingGatePreferred=4.5, MABIssuanceCeilingAnnualSurplusMultiple=3,
   CoolingSecessionCoverActiveDays=21, CoolingSecessionNonCoverDays=14,
   StandPierEscalationAnnualPassVolumeCents=10M, AntiCaptureBillOfRightsCount=13)
- §4 deferred: Sovereign Anchor SPEC, USZ runtime, Cluster A-E, Pier-Routed
  Legal Wrapper (OPTIONAL), -W absolute cap
- §6 constraints: no-subsidies + Anchor no-Voice GREEN by construction
- §7: D-074..D-081 logged (all 8 PO recommendations accepted as binding)

---ci---
project: oy
phase: 0
milestone: v0.7
status: specify
---/ci---
2026-08-19 01:26:54 +00:00
cloudinit-bot d10bf5eb24 docs(spec): v3 net-new-only — v0.7 fraternal groups + v0.8 risk mitigations
Ingest oy-spec v3 (net-new-only diff against spec-v2):
- 52 new REQs REQ-046..REQ-097 (v0.7 REQ-046..066, v0.8+ REQ-067..097)
- §5: 4 new constraints (no subsidies, Anchor no-Voice, Sovereign Anchors
  separate SPEC, Pier-Routed Legal Wrapper OPTIONAL)
- §7: v0.7 Fraternal Groups Foundation + v0.8 Risk Mitigations plan
- §8: 8 open questions with PO recommendations
- Trimmed restated baseline/Principles/Lexicon/invariants (locked, in force)

Per PO ruling 2026-08-18. ciagent acknowledges; P0 will regenerate oy-state v2.
2026-08-19 01:25:19 +00:00
cloudinit-bot 2c0e953881 docs(P00): add oy-spec-template.md — blank skeleton for fresh milestone cycles
OY-specific template the PO copies to .ciagent/oy/oy-spec (no .md) when
starting a fresh milestone cycle. Pre-fills the locked baseline that does not
change between milestones (Six Principles §3, Constraints §5, Lexicon §6,
REQ-001..REQ-021 locked-vision rows in §4, Rules + Commit convention blocks).
Clears the per-milestone content the PO must fill (§1 Objective, §2 Vision
source locked-sections, §4 acceptance criteria, §4 new REQ-022+ rows, §7
Milestone intent, §8 Open questions, §9 Changelog).

Each placeholder is a <!-- TODO --> comment so the PO can grep for unfilled
slots. The header comment block explains what is pre-filled vs what the PO
fills, and points to oy-state §2 for the current max REQ-ID before adding new
rows.

Lives at .ciagent/oy/oy-spec-template.md (.md extension because it is a
reference file the PO reads in an editor, not ingested by the ciagent — the
working oy-spec / oy-state keep the bare-stem convention for ingestion).

---ci---
phase: 0
milestone: v0.6
status: execute
project: oy
---/ci---
2026-08-18 23:23:09 +00:00
cloudinit-bot 9e5e98f22e docs(P00): introduce oy-spec + oy-state boundary docs (PO <-> ciagent)
Establishes the two-doc contract between the product owner and the ciagent:
- oy-spec (PO-authored, ciagent ingests): authoritative product input
- oy-state (ciagent-authored, PO ingests): current shipped state

Both live in .ciagent/oy/ with no .md extension (PO ingestion keys on the
bare stem oy-spec / oy-state). The 10 existing .ciagent/oy/*.md files remain
ciagent-internal working memory. PROJECT/REQUIREMENTS/ARCHITECTURE gain a
one-line header note pointing to oy-spec as the source and oy-state as the
shipped-state mirror.

oy-spec holds the schema the PO fills (Objective, Vision source, Principles
[locked], Requirements table with acceptance criteria, Constraints, Lexicon
[locked], Milestone intent, Open questions, Changelog) + the mandatory
docs(spec): Conventional Commit convention for spec edits.

oy-state is backfilled from shipped v0.6 state: 45-REQ coverage table, 24
locked constants, 18 deferred items, firewall status (G-003 GREEN, G-006
controlled exception cosmos-sdk v0.50.8, REQ-012 GREEN x3, Mission Lock
GREEN, coverage >=80% GREEN), and 5 open PO decisions for v0.7 scoping.

Closes the loop: PO reads oy-state -> edits oy-spec (docs(spec): commit) ->
ciagent regenerates PROJECT/REQUIREMENTS/ARCHITECTURE at next P0 -> flags
drift in oy-state §5. Prevents re-proposing shipped or explicitly-deferred
REQs.

---ci---
phase: 0
milestone: v0.6
status: execute
project: oy
---/ci---
2026-08-18 23:16:15 +00:00
cloudinit-bot aa66e58f95 checkpoint(milestone): v0.6 complete — clear for next milestone
docs-build / go test ./... (lexicon firewall + all x/* tests) (push) Has been cancelled
docs-build / mkdocs build (docs site artifact) (push) Has been cancelled
2026-08-18 19:08:40 +00:00
cloudinit-bot 715403a4e3 Merge milestone/v0.6-nomad-web-ui into main (v0.6 Nomad Web UI feature milestone release)
docs-build / go test ./... (lexicon firewall + all x/* tests) (push) Has been cancelled
docs-build / mkdocs build (docs site artifact) (push) Has been cancelled
---ci---
project: oy
phase: 6
milestone: v0.6
status: complete
requirements:
  covered: [REQ-040, REQ-041, REQ-042, REQ-043, REQ-044, REQ-045]
  partial: []
---/ci---
2026-08-18 19:07:58 +00:00
cloudinit-bot f3d38b8a05 Merge phase/06 (final) into milestone/v0.6-nomad-web-ui (P6 complete → v0.5.6 milestone release) 2026-08-18 19:07:58 +00:00
cloudinit-bot 52e438d1b1 checkpoint(milestone): v0.6 complete → v0.5.6 (milestone release)
---ci---
project: oy
phase: 6
milestone: v0.6
status: complete
---/ci---
2026-08-18 19:07:58 +00:00
cloudinit-bot d752215a5c docs(P6): v0.6 final review + audit — PASS, feature purity gate GREEN
REVIEW: 8 probes GREEN (go run dep-free, 5 screens reachable, lexicon
firewall scans web/, Bread-scale matches code constants D-073, Standing
uses locked constants, Freeholder badge reflects IsFreeholderEligible,
Window lifecycle calls real methods, no banned terms in rendered pages).
AUDIT: reconstruction test passes; feature purity gate GREEN (no x/
module modified, locked-const firewall intact, G-003 app-layer, G-006
go.mod unchanged — G-028 diff against v0.5.0 EMPTY); coverage store
98.1% handlers 89.2%; all 3 lexicon firewalls green. REQUIREMENTS:
REQ-040..045 marked Complete. ROADMAP: v0.6 marked COMPLETE.

---ci---
project: oy
phase: 6
milestone: v0.6
status: complete
requirements:
  covered: [REQ-040, REQ-041, REQ-042, REQ-043, REQ-044, REQ-045]
  partial: []
---/ci---
2026-08-18 19:07:48 +00:00
cloudinit-bot 30e35ea1c9 Merge phase/05 into milestone/v0.6-nomad-web-ui (P5 complete → v0.5.5)
docs-build / go test ./... (lexicon firewall + all x/* tests) (push) Has been cancelled
docs-build / mkdocs build (docs site artifact) (push) Has been cancelled
---ci---
project: oy
phase: 5
milestone: v0.6
status: complete
requirements:
  covered: [REQ-040, REQ-041, REQ-042, REQ-043, REQ-044, REQ-045]
---/ci---
2026-08-18 19:06:22 +00:00
cloudinit-bot ef80a8e1c2 checkpoint(p5): v0.6 phase 5 complete → v0.5.5
---ci---
project: oy
phase: 5
milestone: v0.6
status: complete
requirements:
  covered: [REQ-040, REQ-041, REQ-042, REQ-043, REQ-044, REQ-045]
---/ci---
2026-08-18 19:06:22 +00:00
cloudinit-bot 56db37a463 feat(P5): Bloom accrual view — per-Stash BloomRecord (REQ-044)
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---
2026-08-18 19:06:16 +00:00
cloudinit-bot ff068f63bc Merge phase/04 into milestone/v0.6-nomad-web-ui (P4 complete → v0.5.4)
docs-build / go test ./... (lexicon firewall + all x/* tests) (push) Has been cancelled
docs-build / mkdocs build (docs site artifact) (push) Has been cancelled
---ci---
project: oy
phase: 4
milestone: v0.6
status: complete
requirements:
  covered: [REQ-040, REQ-041, REQ-042, REQ-043, REQ-045]
---/ci---
2026-08-18 19:02:52 +00:00
cloudinit-bot 89ec9a76db checkpoint(p4): v0.6 phase 4 complete → v0.5.4
---ci---
project: oy
phase: 4
milestone: v0.6
status: complete
requirements:
  covered: [REQ-040, REQ-041, REQ-042, REQ-043, REQ-045]
---/ci---
2026-08-18 19:02:52 +00:00
cloudinit-bot 451ea08414 feat(P4): Standing + Freeholder signals progress (REQ-043)
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---
2026-08-18 19:02:44 +00:00
cloudinit-bot 50c84d0351 Merge phase/03 into milestone/v0.6-nomad-web-ui (P3 complete → v0.5.3)
docs-build / go test ./... (lexicon firewall + all x/* tests) (push) Has been cancelled
docs-build / mkdocs build (docs site artifact) (push) Has been cancelled
---ci---
project: oy
phase: 3
milestone: v0.6
status: complete
requirements:
  covered: [REQ-040, REQ-041, REQ-042, REQ-045]
---/ci---
2026-08-18 18:58:32 +00:00
cloudinit-bot 5008f85da1 checkpoint(p3): v0.6 phase 3 complete → v0.5.3
---ci---
project: oy
phase: 3
milestone: v0.6
status: complete
requirements:
  covered: [REQ-040, REQ-041, REQ-042, REQ-045]
---/ci---
2026-08-18 18:58:32 +00:00
cloudinit-bot 79a3358810 feat(P3): Window authorization — open/lifecycle/audit log (REQ-042)
web/handlers/window.go: GET /window (list), GET /window/new (form),
POST /window (open), GET /window/{id} (detail+lifecycle+audit), POST
/window/{id}/activate|revoke|expire (lifecycle transitions). Store
extensions: OpenWindow/ActivateWindow/RevokeWindow/ExpireWindow/
ListWindows/GetWindow/GetAuditLog — all call the REAL x/window/types
Window.Activate/Revoke/Expire methods (not reimplementation). Revoke on
Expired is a no-op (v0.2 terminal-state contract). AuditEntry appended on
each transition. 3 Window templates. window_test.go: lifecycle
correctness (asserts real methods invoked), idempotent revoke, revoke-on-
expired no-op, G-026 error lexicon checks. Coverage: store 99.2%,
handlers 87.7%.

---ci---
project: oy
phase: 3
milestone: v0.6
status: execute
---/ci---
2026-08-18 18:58:25 +00:00
cloudinit-bot edd285e5c0 Merge phase/02 into milestone/v0.6-nomad-web-ui (P2 complete → v0.5.2)
docs-build / go test ./... (lexicon firewall + all x/* tests) (push) Has been cancelled
docs-build / mkdocs build (docs site artifact) (push) Has been cancelled
---ci---
project: oy
phase: 2
milestone: v0.6
status: complete
requirements:
  covered: [REQ-040, REQ-041, REQ-045]
---/ci---
2026-08-18 18:55:11 +00:00
cloudinit-bot 07053cc13a checkpoint(p2): v0.6 phase 2 complete → v0.5.2
---ci---
project: oy
phase: 2
milestone: v0.6
status: complete
requirements:
  covered: [REQ-040, REQ-041, REQ-045]
  partial: []
---/ci---
2026-08-18 18:55:07 +00:00
cloudinit-bot 973e25a7c9 feat(P2): Stash dashboard — Bread scale + maturity progress (REQ-041)
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---
2026-08-18 18:55:03 +00:00
cloudinit-bot 741d6e0a96 Merge phase/01 into milestone/v0.6-nomad-web-ui (P1 complete → v0.5.1)
docs-build / go test ./... (lexicon firewall + all x/* tests) (push) Has been cancelled
docs-build / mkdocs build (docs site artifact) (push) Has been cancelled
---ci---
project: oy
phase: 1
milestone: v0.6
status: complete
requirements:
  covered: [REQ-040, REQ-045]
  partial: []
---/ci---
2026-08-18 18:52:59 +00:00
cloudinit-bot eba12998b7 checkpoint(p1): v0.6 phase 1 complete → v0.5.1
---ci---
project: oy
phase: 1
milestone: v0.6
status: complete
requirements:
  covered: [REQ-040, REQ-045]
  partial: []
---/ci---
2026-08-18 18:52:53 +00:00
cloudinit-bot 27b565c965 docs(P1): README web UI quickstart + lexicon firewall v0.6 section
---ci---
project: oy
phase: 1
milestone: v0.6
status: execute
---/ci---
2026-08-18 18:52:39 +00:00
cloudinit-bot 0bcb96c442 feat(P1): Reach signup handler + templates + tests (REQ-040, G-026)
web/handlers/reach.go: GET /reach (list), GET /reach/new (form),
GET /reach/{id} (detail), POST /reach (atomic Reach+Stash create
per D-071, redirect 302). Labels "Create a Reach" (not the banned
legacy word). G-027 validation (non-empty, <=128, no path separators,
no template syntax). 3 Reach templates extend base.html.
reach_test.go: httptest for all 4 routes + atomic create + 400/409
error paths + rendered-HTML lexicon check on BOTH 200 and error bodies
(G-026). handlers/server.go: clone-per-page template pattern (avoids
content-block collision across pages). Coverage 81.2% on web/handlers.

---ci---
project: oy
phase: 1
milestone: v0.6
status: execute
---/ci---
2026-08-18 17:08:32 +00:00
cloudinit-bot 5a12ab0e76 feat(P1): mock store + fixtures + import-invariant test (REQ-040, G-025, G-027)
web/store instantiates real x/identity/types.Reach + x/stash/types.Stash
(app-layer consumption per D-070, NOT a cross-x/ import). CreateReach
atomically creates Reach (IsNomad=true) + Stash (D-071). G-027 validates
HolderID/PublicKey (non-empty, <=128, no path separators, no template
syntax). import_test.go enforces G-025: web/ imports only x/*/types,
never x/*/keeper or x/<module> (module.go). Coverage 100%.

---ci---
project: oy
phase: 1
milestone: v0.6
status: execute
---/ci---
2026-08-18 13:45:05 +00:00
cloudinit-bot 4166de5a4b feat(P1): web foundation — main.go, server.go, HTMX vendored, base+home templates (REQ-040)
Go 1.22 net/http.ServeMux + html/template + http.FileServer for static.
HTMX 2.0.10 vendored as web/static/htmx.min.js (NOT go get; G-006).
base.html layout with nav to all 5 screens. home.html overview.

---ci---
project: oy
phase: 1
milestone: v0.6
status: execute
---/ci---
2026-08-18 13:43:53 +00:00
cloudinit-bot 9811aaabbb feat(P1): lexicon firewall for web surface (REQ-045)
Add lexicon_meta_web/ sibling meta-test mirroring lexicon_meta_docs/. Scans
web/templates/**/*.html + web/static/**/*.js + web/**/*.go using the shared
lexicon.FindBannedTerm (no detection reimplementation). Includes G-009
self-test table (lexicon.SyntheticBannedStrings), banned-terms count (10),
openyield/european false-positive guard, and G-013 walk-coverage (injects a
synthetic banned-term fixture into web/templates/.lexicon_fixture/ and
asserts the walk finds it). Firewall passes green with zero web content
(closed by the walk-coverage test).

---ci---
project: oy
phase: 1
milestone: v0.6
status: execute
---/ci---
2026-08-18 12:07:14 +00:00
cloudinit-bot 5be8c51c60 checkpoint(p0): v0.6 phase 0 complete → v0.5.0
---ci---
project: oy
phase: 0
milestone: v0.6
status: complete
---/ci---
2026-08-18 12:05:43 +00:00
cloudinit-bot d3537249fb Merge phase/00 into milestone/v0.6-nomad-web-ui (P0 complete → v0.5.0)
docs-build / go test ./... (lexicon firewall + all x/* tests) (push) Has been cancelled
docs-build / mkdocs build (docs site artifact) (push) Has been cancelled
---ci---
project: oy
phase: 0
milestone: v0.6
status: complete
---/ci---
2026-08-18 12:05:23 +00:00
cloudinit-bot 74bf4d6aaf decision(P00): mvp/ux gate — verified 3 sections present
PLAN.md has User-Facing Surface (L2111), Happy Path (L2125),
UX Acceptance Criteria (L2141). Gate PASSES at full autonomy.

---ci---
project: oy
phase: 0
milestone: v0.6
status: mvp_ux_check
---/ci---
2026-08-18 12:05:15 +00:00
cloudinit-bot b9fac60a47 docs(P00): grill — v0.6 adversarial review (D-078+, G-025+)
---ci---
project: oy
phase: 0
milestone: v0.6
status: grill
---/ci---
2026-08-18 12:05:00 +00:00
cloudinit-bot 66e27f7bb8 checkpoint(p0): plan complete → grill next
---ci---
project: oy
phase: 0
milestone: v0.6
status: plan
---/ci---
2026-08-18 12:02:43 +00:00
cloudinit-bot 949b1acf50 docs(P00): create phase plans — v0.6 Nomad Web UI
---ci---
project: oy
phase: 0
milestone: v0.6
status: plan
---/ci---
2026-08-18 12:02:28 +00:00
cloudinit-bot d066885b60 docs(P00): ideate — ratify REQ-040..REQ-045 (Nomad Web UI)
IDEATE stage ratified 6 ideas (IDEATE-09..IDEATE-14) at full autonomy.
User pre-validated 5 screens + stack via interactive questions at CLARIFY.
REQ-040 Reach signup, REQ-041 Stash dashboard, REQ-042 Window auth,
REQ-043 Standing+Freeholder signals, REQ-044 Bloom accrual, REQ-045
lexicon firewall extension to web/. D-001 filter does NOT apply (feature).

---ci---
project: oy
phase: 0
milestone: v0.6
status: ideate
---/ci---
2026-08-18 11:58:09 +00:00
cloudinit-bot e62f783fa1 checkpoint(p0): research complete → ideate next
---ci---
project: oy
phase: 0
milestone: v0.6
status: research
---/ci---
2026-08-18 11:57:38 +00:00
cloudinit-bot 6597cbebd8 docs(P00): research — v0.6 Nomad Web UI MVP findings
---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.
2026-08-18 11:57:23 +00:00
cloudinit-bot 4876c49aa0 docs(P00): clarify — v0.6 decisions D-066..D-073
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---
2026-08-18 11:54:59 +00:00
cloudinit-bot 68298d81b3 docs(init): validate specification — v0.6 Nomad Web UI
---ci---
project: oy
phase: 0
milestone: v0.6
status: specify
---/ci---
2026-08-18 11:54:06 +00:00
cloudinit-bot 6502a5abd0 docs(audit): fix PROJECT.md v0.5 status drift (in progress → complete)
docs-build / go test ./... (lexicon firewall + all x/* tests) (push) Has been cancelled
docs-build / mkdocs build (docs site artifact) (push) Has been cancelled
Audit (ci-audit workflow) found PROJECT.md line 64 said 'v0.5 — Bearers
Runtime (in progress...)' but the milestone is COMPLETE (ROADMAP.md
COMPLETE, checkpoint milestone_complete=true, release v0.4.8 shipped).
Fixed to 'complete' to match reconstruction state.

---ci---
project: oy
phase: 8
milestone: v0.5
status: audit
requirements:
  covered: []
  partial: []
---/ci---
2026-08-18 11:21:14 +00:00
cloudinit-bot a6a08b0c1b checkpoint(milestone): v0.5 complete — clear for next milestone
docs-build / go test ./... (lexicon firewall + all x/* tests) (push) Has been cancelled
docs-build / mkdocs build (docs site artifact) (push) Has been cancelled
---ci---
project: oy
phase: 8
milestone: v0.5
status: complete
requirements:
  covered: [REQ-033, REQ-034, REQ-035, REQ-036, REQ-037, REQ-038, REQ-039]
  partial: []
---/ci---
2026-08-18 03:42:34 +00:00
cloudinit-bot 6c34650a0d Merge milestone/v0.5-bearers-runtime into main (v0.5 Bearers Runtime feature milestone release)
docs-build / go test ./... (lexicon firewall + all x/* tests) (push) Has been cancelled
docs-build / mkdocs build (docs site artifact) (push) Has been cancelled
v0.5 Bearers Runtime — 7 runtime REQs (REQ-033..039) shipped as feature.
8 modules promoted to runtime (MsgServer + simtest). cosmos-sdk v0.50.8 +
ibc-go v8.2.1 added (G-006 controlled exception). G-003 + locked-const
firewalls intact. 8 keeper packages ≥80% coverage. 5 GRILL decisions
ratified; 8 binding fixes landed; 5 P1+ flagged for v0.6+.

---ci---
project: oy
phase: 8
milestone: v0.5
status: complete
requirements:
  covered: [REQ-033, REQ-034, REQ-035, REQ-036, REQ-037, REQ-038, REQ-039]
  partial: []
---/ci---
2026-08-18 03:42:01 +00:00
cloudinit-bot 4369b3e4cc checkpoint(milestone): v0.4 complete — clear for next milestone
docs-build / go test ./... (lexicon firewall + all x/* tests) (push) Has been cancelled
docs-build / mkdocs build (docs site artifact) (push) Has been cancelled
v0.4 Refinement (NFR) milestone complete. Release v0.3.4 (id 752).
All milestone branches deleted (local + remote); tags preserve history.
HEAD on main. NFR purity gate GREEN. Next run starts a new milestone.

---ci---
project: oy
phase: 4
milestone: v0.4
status: complete
tag_base: v0.3.x
milestone_type: nfr
phase_role: final
milestone_complete: true
milestone_release_tag: v0.3.4
release_id: 752
requirements:
  covered: [REQ-029, REQ-030, REQ-031, REQ-032]
  partial: []
---/ci---
2026-08-17 23:36:26 +00:00
cloudinit-bot d4830bb108 Merge milestone/v0.4-refinement into main (v0.4 Refinement NFR milestone release)
docs-build / go test ./... (lexicon firewall + all x/* tests) (push) Has been cancelled
docs-build / mkdocs build (docs site artifact) (push) Has been cancelled
v0.4 milestone complete. NFR type, tags v0.3.x. Final phase patch v0.3.4
IS the milestone release (D-008). 4 NFR REQs (REQ-029..REQ-032) shipped.
NFR purity gate GREEN (zero feat: commit subjects). go.mod unchanged.
G-003 production firewall intact. Coverage: x/hub 93.3%, x/council 96.4%.

---ci---
project: oy
phase: 4
milestone: v0.4
status: complete
tag_base: v0.3.x
milestone_type: nfr
phase_role: final
milestone_complete: true
requirements:
  covered: [REQ-029, REQ-030, REQ-031, REQ-032]
  partial: []
---/ci---
2026-08-17 23:35:58 +00:00
cloudinit-bot c1a973f8af Merge phase/04 (final) into milestone/v0.4-refinement (P4 complete → v0.3.4 milestone release)
---ci---
project: oy
phase: 4
milestone: v0.4
status: complete
tag_base: v0.3.x
milestone_type: nfr
phase_role: final
milestone_complete: true
---/ci---
2026-08-17 23:35:55 +00:00
cloudinit-bot 21926e8adb docs(milestone): complete OpenYield v0.4 (Refinement — NFR)
docs-build / go test ./... (lexicon firewall + all x/* tests) (push) Has been cancelled
docs-build / mkdocs build (docs site artifact) (push) Has been cancelled
Milestone v0.4 complete. NFR type, tags v0.3.x. Four NFR REQs shipped:
- REQ-029 lexicon.SyntheticBannedStrings() shared helper (G-014 closed)
- REQ-030 x/hub↔x/bond cross-const equality test (A-304 closed, G-015)
- REQ-031 council lifecycle divergence docs + regression guard (AUDIT §193)
- REQ-032 .gitea/workflows/docs-build.yml docs build CI (D-046 landed)

NFR purity gate GREEN: zero feat: commit subjects in the milestone.
go.mod unchanged (G-006). G-003 production firewall intact. Coverage:
x/hub 93.3%, x/council 96.4% (both above 80% target).

Phases: P0 -> v0.3.0, P1 -> v0.3.1, P2 -> v0.3.2, P3 -> v0.3.3,
P4 -> v0.3.4 (milestone release).

Requirements covered: REQ-029, REQ-030, REQ-031, REQ-032 (all complete).
Live-runtime promotions of v0.3 Bearers skeletons deferred to v0.5+
(feat:-class, rejected by D-001 refinement-only filter).

---ci---
project: oy
phase: 4
milestone: v0.4
status: complete
tag_base: v0.3.x
milestone_type: nfr
phase_role: final
milestone_complete: true
requirements:
  covered: [REQ-029, REQ-030, REQ-031, REQ-032]
  partial: []
---/ci---
2026-08-17 23:35:48 +00:00
cloudinit-bot d74515cd1d docs(P4): v0.4 final review + audit — PASS, NFR purity GREEN
REVIEW.md v0.4 section: 7 adversarial probes PASS; 0 P0; 3 P2 nits
(mkdocs link warnings, upload-artifact v4 compat, NFR gate precision).
Confidence 0.90.

AUDIT.md v0.4 section: reconstruction test PASS (phase progression,
tags v0.3.0..v0.3.3, .ciagent evidence); file/branch/commit discipline
PASS; build/test/coverage sanity PASS (x/hub 93.3%, x/council 96.4%).
NFR purity gate GREEN (zero feat: commit subjects). 0 critical issues.
Confidence 0.90.

---ci---
project: oy
phase: 4
milestone: v0.4
status: review
tag_base: v0.3.x
milestone_type: nfr
phase_role: final
---/ci---
2026-08-17 23:34:52 +00:00
cloudinit-bot a36561337a checkpoint(p3): v0.4 phase 3 complete → v0.3.3
docs-build / go test ./... (lexicon firewall + all x/* tests) (push) Has been cancelled
docs-build / mkdocs build (docs site artifact) (push) Has been cancelled
P3 shipped: tag v0.3.3, release id 751. REQ-032 covered. Branch
oy/phase/03 deleted. All execution phases complete. Next: P4 final
review + audit + milestone ship.

---ci---
project: oy
phase: 3
milestone: v0.4
status: complete
tag_base: v0.3.x
milestone_type: nfr
phase_release_tag: v0.3.3
---/ci---
2026-08-17 23:33:58 +00:00
cloudinit-bot 7fa5628dc2 Merge phase/03 into milestone/v0.4-refinement (P3 complete → v0.3.3)
docs-build / go test ./... (lexicon firewall + all x/* tests) (push) Has been cancelled
docs-build / mkdocs build (docs site artifact) (push) Has been cancelled
---ci---
project: oy
phase: 3
milestone: v0.4
status: complete
tag_base: v0.3.x
milestone_type: nfr
---/ci---
2026-08-17 23:33:39 +00:00
cloudinit-bot 3b7883c092 verify(P3): docs build CI — 4 layers green
docs-build / go test ./... (lexicon firewall + all x/* tests) (push) Has been cancelled
docs-build / mkdocs build (docs site artifact) (push) Has been cancelled
Structural: YAML schema valid; jobs go-test + docs-build; docs-build
needs go-test (G-016 OK). Behavioral: local mkdocs build succeeds (site/
produced); go test ./... green. Security: go.mod unchanged (G-006 intact);
Python deps isolated to docs-build job. Quality: .gitignore covers site/.
NFR purity: zero commit SUBJECTS starting with feat: (grep -E '^feat:'
exit 1 on subject-only check). NOTE: git log --grep matches message BODIES
too (prose mentions 'feat:'); the P4 audit gate uses subject-only check
(git log --format='%s' | grep -E '^feat:').

---ci---
project: oy
phase: 3
milestone: v0.4
status: verify
tag_base: v0.3.x
milestone_type: nfr
reqs: [REQ-032]
---/ci---
2026-08-17 23:33:36 +00:00
cloudinit-bot 1969b96d3d chore(ci): docs build CI workflow (REQ-032, D-046, D-051, G-016)
Add .gitea/workflows/docs-build.yml: on push, go-test job runs go test ./...
(lexicon firewall + all x/* tests), then docs-build job (needs: go-test per
G-016 firewall-gates-docs-build) installs mkdocs + mkdocs-material, runs
mkdocs build, uploads site/ as a CI artifact. go.mod unchanged (Python deps
isolated to the docs-build job; G-006 intact). Full Gitea Pages publishing
deferred per D-051 (no hosting target configured).

.gitignore: add site/ (mkdocs build output; never committed).

Verification: YAML parses; docs-build needs go-test; go test ./... green;
go.mod unchanged; local mkdocs build succeeds (site/ produced).

---ci---
project: oy
phase: 3
milestone: v0.4
status: execute
tag_base: v0.3.x
milestone_type: nfr
reqs: [REQ-032]
---/ci---
2026-08-17 23:33:02 +00:00
cloudinit-bot d149916288 checkpoint(p2): v0.4 phase 2 complete → v0.3.2
P2 shipped: tag v0.3.2, release id 750. REQ-031 covered. Branch
oy/phase/02 deleted. Next: P3 docs build CI.

---ci---
project: oy
phase: 2
milestone: v0.4
status: complete
tag_base: v0.3.x
milestone_type: nfr
phase_release_tag: v0.3.2
---/ci---
2026-08-17 23:32:22 +00:00
cloudinit-bot c4cbd59c11 Merge phase/02 into milestone/v0.4-refinement (P2 complete → v0.3.2)
---ci---
project: oy
phase: 2
milestone: v0.4
status: complete
tag_base: v0.3.x
milestone_type: nfr
---/ci---
2026-08-17 23:32:07 +00:00
cloudinit-bot a6d33a58b3 verify(P2): lifecycle divergence docs + regression guard — 4 layers green
Structural: go vet clean. Behavioral: TestSignalKindShapeIntentional PASS;
existing council tests PASS. Security: no locked-const change
(SignalKindCount==4 preserved). Quality: x/council/types coverage 96.4%
(improved from v0.3; new intent test adds coverage). NFR purity: zero
feat: commits in P2 (only docs + test).

---ci---
project: oy
phase: 2
milestone: v0.4
status: verify
tag_base: v0.3.x
milestone_type: nfr
reqs: [REQ-031]
---/ci---
2026-08-17 23:32:02 +00:00
cloudinit-bot 7d1468b442 docs(arch): council lifecycle divergence decisions (REQ-031, AUDIT §193)
Add ARCHITECTURE.md §"Council Voice/Council Interface — Lifecycle Type
Divergence Decisions (v0.4)": documents P1-1 (Proposal/VoteOption absent,
deferred to v0.5+ governance runtime — feat: rejected by D-001), P1-2
(SignalKind 4-vs-5: the 4-source shape is intentional per AUDIT rationale;
Freeholder is eligibility, Guild is council tier, Capital is committed-
capital per vision §9.1), P2 (bearers ValidateGenesis no-op correct per spec).

test(council): TestSignalKindShapeIntentional regression guard (REQ-031)

Add intent-assertion test locking the 4-source SignalKind shape with the
AUDIT §193 P1-2 rationale in the doc comment. A future agent changing
SignalKindCount 4→5 must update this test, surfacing the rationale. No
locked-const change, no production .go files modified (D-050).

Verification: go test ./... green; only types_test.go modified in x/**.

---ci---
project: oy
phase: 2
milestone: v0.4
status: execute
tag_base: v0.3.x
milestone_type: nfr
reqs: [REQ-031]
---/ci---
2026-08-17 23:31:55 +00:00
cloudinit-bot 92966cb9c5 checkpoint(p1): v0.4 phase 1 complete → v0.3.1
P1 shipped: tag v0.3.1, release id 749. REQ-029, REQ-030 covered.
Branch oy/phase/01 deleted. Next: P2 lifecycle divergence docs+guard.

---ci---
project: oy
phase: 1
milestone: v0.4
status: complete
tag_base: v0.3.x
milestone_type: nfr
phase_release_tag: v0.3.1
---/ci---
2026-08-17 23:31:16 +00:00
cloudinit-bot 838bd06a9d Merge phase/01 into milestone/v0.4-refinement (P1 complete → v0.3.1)
---ci---
project: oy
phase: 1
milestone: v0.4
status: complete
tag_base: v0.3.x
milestone_type: nfr
---/ci---
2026-08-17 23:30:50 +00:00
cloudinit-bot 57c7dc5ff5 verify(P1): lexicon+const hardening — 4 layers green
Structural: go build ./... + go vet clean. Behavioral: both meta-tests
PASS consuming SyntheticBannedStrings(); 3 new cross-const tests PASS.
Security: lexicon firewalls green (x/ + docs/); G-003 production firewall
intact (no production import of x/bond/types in x/hub/types). Quality:
x/hub/types coverage 93.3% (v0.3 floor preserved; new test adds coverage).

---ci---
project: oy
phase: 1
milestone: v0.4
status: verify
tag_base: v0.3.x
milestone_type: nfr
reqs: [REQ-029, REQ-030]
---/ci---
2026-08-17 23:30:46 +00:00
cloudinit-bot 2ca0e1aa4b refactor(lexicon): shared SyntheticBannedStrings() helper (REQ-029, G-014)
Add lexicon.SyntheticBannedStrings() — single source for the synthetic
self-test table consumed by BOTH meta-tests. Refactor
TestLexiconMetaSelfTestTable (lexicon_meta) and TestLexiconMetaDocsSelfTestTable
(lexicon_meta_docs) to consume the helper; remove the byte-identical
duplicated 10-string table from both. Closes the G-014 drift risk.

test(hub): cross-package const-equality test (REQ-030, A-304, G-015)

Add x/hub/types/cross_const_test.go: test-only import of x/bond/types
(G-003 test-exempt). TestLendingCouponCapMatchesBondCap + TestLendingCoupon-
FloorMatchesBondFloor assert cross-equality; TestConstsAreMissionLocked800And0
(G-015) asserts absolute 800/0 values — catches paired drift. Closes A-304.

Verification: go test ./... green; old synthetic table gone (grep 0);
go.mod unchanged; G-003 production firewall intact.

---ci---
project: oy
phase: 1
milestone: v0.4
status: execute
tag_base: v0.3.x
milestone_type: nfr
reqs: [REQ-029, REQ-030]
---/ci---
2026-08-17 23:30:30 +00:00
cloudinit-bot cc0940d9f8 checkpoint(p0): v0.4 phase 0 complete → v0.3.0
Phase 0 shipped: tag v0.3.0, release id 748. Branch oy/phase/00 deleted.
Next: P1 lexicon+const hardening (oy/phase/01-lexicon-const-hardening).

---ci---
project: oy
phase: 0
milestone: v0.4
status: complete
tag_base: v0.3.x
milestone_type: nfr
phase_release_tag: v0.3.0
release_id: 748
---/ci---
2026-08-17 23:29:20 +00:00
cloudinit-bot 0bb14bd1a1 Merge phase/00 into milestone/v0.4-refinement (P0 complete → v0.3.0)
Phase 0 (pre-execution) complete. Tags v0.3.0. Next: P1 lexicon+const
hardening (oy/phase/01-lexicon-const-hardening).

---ci---
project: oy
phase: 0
milestone: v0.4
status: complete
tag_base: v0.3.x
milestone_type: nfr
---/ci---
2026-08-17 23:28:49 +00:00
cloudinit-bot b55255614f decision(P00): mvp/ux gate — auto-generated
Auto-generated 3 MVP/UX sections (full autonomy): User-Facing Surface
(4 surfaces: lexicon firewall, cross-const test, docs CI, ARCHITECTURE.md
section), Happy Path (2 scenarios: add banned term / const drift), UX
Acceptance Criteria (6 explicit criteria incl NFR purity gate). Gate
passes — all three sections present.

---ci---
project: oy
phase: 0
milestone: v0.4
status: mvp_ux_check
tag_base: v0.3.x
milestone_type: nfr
---/ci---
2026-08-17 23:28:34 +00:00
cloudinit-bot d88d2eaeb8 docs(P00): grill — v0.4 NFR adversarial review (G-015, G-016)
Nine-axis scorecard: 8 PASS + 1 CONDITIONAL→fixed. SHIP Phase 0 (0.84).
G-015: cross-const test MUST assert absolute 800/0 values (paired-drift
guard) — already in P1-02-01. G-016: docs-build job MUST need go-test
(firewall-gates-docs-build, no false-green) — applied to P3-01-01.
D-001 filter enforced; NFR purity gate enforceable.

---ci---
project: oy
phase: 0
milestone: v0.4
status: grill
tag_base: v0.3.x
milestone_type: nfr
---/ci---
2026-08-17 23:28:11 +00:00
cloudinit-bot 5e06b14ddf docs(P00): create phase plans — v0.4 NFR (P1..P4, 12 tasks)
P1 lexicon+const hardening (REQ-029/030). P2 lifecycle divergence docs+
guard (REQ-031). P3 docs build CI (REQ-032). P4 final review+audit+ship.
D-001 refinement-only filter governs all tasks. NFR purity gate in P4.
Tags v0.3.x. No cross-phase hard blockers (independent vertical slices).

---ci---
project: oy
phase: 0
milestone: v0.4
status: plan
tag_base: v0.3.x
milestone_type: nfr
---/ci---
2026-08-17 23:27:33 +00:00
cloudinit-bot 28d73c8b2c docs(P00): research findings — v0.4 NFR research R-029..R-032 + PERSONAS.md
R-029 lexicon shared helper (G-014 drift confirmed: byte-identical synthetic
tables in both meta-tests). R-030 cross-const test (hub/bond 800/0 LOCAL
consts, G-003 test-exempt import). R-031 lifecycle divergence (AUDIT §193
P1-1/P1-2 are feat:-class, rejected by D-001; v0.4 = document + regression
guard only). R-032 docs CI (.gitea/workflows, build+artifact, no Pages
publish). PERSONAS.md: backend-engineer + lead-developer only; v0.3
frontend/docs-writer deactivated.

---ci---
project: oy
phase: 0
milestone: v0.4
status: research
tag_base: v0.3.x
milestone_type: nfr
---/ci---
2026-08-17 23:26:33 +00:00
cloudinit-bot 46c2c4ef6c docs(P00): clarify — v0.4 NFR scope decisions D-047..D-053
Auto-decided defaults (autonomy=full). v0.4 = refinement-only NFR.
D-047 NFR type, tags v0.3.x. D-048 lexicon shared helper (G-014).
D-049 cross-const test (A-304). D-050 REQ-031 = document only, no feat:
enum additions (D-001 filter). D-051 docs CI = .gitea/workflows build+artifact
(no Pages publish). D-052 phase ordering. D-053 no IDEATE (no flag this run).

---ci---
project: oy
phase: 0
milestone: v0.4
status: clarify
tag_base: v0.3.x
milestone_type: nfr
---/ci---
2026-08-17 23:25:20 +00:00
cloudinit-bot 0cac4b0b32 docs(init): validate specification
Establish v0.4 refinement-only NFR milestone. Tags run on v0.3.x line.
Scope sourced from v0.3 forward-references (REVIEW P2/A-304, AUDIT §193,
GRILL G-014, D-046): REQ-029 lexicon shared helper, REQ-030 cross-const
test, REQ-031 lifecycle type review, REQ-032 docs build CI. Zero feat:
phases by construction.

---ci---
project: oy
phase: 0
milestone: v0.4
status: specify
tag_base: v0.3.x
milestone_type: nfr
---/ci---
2026-08-17 23:24:46 +00:00
cloudinit-bot 97a25dd0b6 checkpoint(milestone): v0.3 complete — clear for next milestone 2026-08-17 22:36:04 +00:00
cloudinit-bot 82ae6cf5a2 docs(milestone): complete OpenYield v0.3 (Bearers & Documentation)
Milestone v0.3 complete. Feature type, tags v0.2.x. Two work-streams
shipped under one feature milestone:

(A) Bearers skeleton + tests (D-020 pattern, 7 x/* packages, zero ext deps):
- x/bridge (NEW): BridgeStatus enum (4), BridgeRoute by-ID-string refs
- x/exit (NEW): ExitStatus enum (5), ExitRoute, DEXSwap (opaque venue)
- x/bearers (EXT): OYSATLink surveillance-resistant LOCKED, OYQRCode idempotent
- x/partner (EXT): AnchorCredential (custody-provider-id empty in skeleton)
- x/hub (NEW): HubService enum (3), LendingCouponCapBps=800 LOCAL const (A-304)
- x/services (NEW): ServiceKind enum (4), window-id by-ID-string ref (A-307)
- x/bond (EXT): GrowthBond, ClampGrowth G-012 underflow guard, secondary market
All packages >=93.3% coverage. Both lexicon firewalls green. G-003 intact.

(B) Documentation deliverable (REQ-027 complete, 26-page MkDocs Material site):
- README.md + mkdocs.yml + docs/index.md
- docs/shared/ (7 pages): Six Principles, Bread Scale, Storage Pools, Watchers/Mirror, Lexicon, Vision
- docs/nomads/ (8 pages): Reach, Stash, Bearers, Maps-Pay, Pacts, Standing, Window
- docs/freeholders/ (8 pages): Signals, Standing, Stands-Guilds, Councils-Voice, Bonds, Partner Spectrum, Anchor Preview
- docs/reference/ (2 pages): Architecture, Components
- REQ-028: lexicon firewall extended to docs/ + README.md (lexicon_meta_docs_test.go, 5 tests incl G-013 walk-coverage + G-014 shared self-test)

Phases: P0 -> v0.2.0, P1 -> v0.2.1, P2 -> v0.2.2, P3 -> v0.2.3, P4 -> v0.2.4, P5 -> v0.2.5, P6 -> v0.2.6 (milestone release).

Requirements covered: REQ-010, REQ-022, REQ-023, REQ-024, REQ-025, REQ-026 (skeleton), REQ-027, REQ-028 (complete).
IDEATE-01..08 ratified and delivered.

---ci---
project: oy
phase: 6
milestone: v0.3
status: complete
tag_base: v0.2.x
phase_role: final
milestone_complete: true
requirements:
  covered: [REQ-010, REQ-022, REQ-023, REQ-024, REQ-025, REQ-026, REQ-027, REQ-028]
  partial: []
---/ci---
2026-08-17 22:35:36 +00:00
cloudinit-bot 47fa79148c docs(milestone): complete OpenYield v0.2 (The Mesh)
---ci---
project: oy
phase: 5
milestone: v0.2
status: complete
phase_role: final
milestone_complete: true
---/ci---

Checkpoint: v0.2 milestone complete. Merged to main, tagged v0.1.5 (milestone release,
release id 732 on Gitea). All milestone branches deleted. Per run.md, checkpoint cleared
for next run (v0.3 The Bearers per ROADMAP Phase 3).
2026-08-17 21:41:56 +00:00
cloudinit-bot 74248dfbc1 docs(milestone): complete OpenYield v0.2 (The Mesh)
---ci---
project: oy
phase: 5
milestone: v0.2
status: complete
phase_role: final
requirements:
  covered: [REQ-009, REQ-011, REQ-015, REQ-016, REQ-017, REQ-018, REQ-020, REQ-021, REQ-012]
  partial: []
---/ci---

Milestone v0.2 (The Mesh) complete. Skeleton+tests layer for 9 new modules + 1 extension:
x/window (REQ-015, fullest), x/stand (REQ-016, 9 types), x/guild (REQ-017, HandPass 0%),
x/pact (REQ-020, 6 types + Mission Lock), x/partner (REQ-018, 4-tier), x/council (REQ-011,
3 councils + Mission Lock const), x/forex (Forex v1, Bread/Asset pairs), x/bond (REQ-021,
8% cap Clamp), x/satellite (REQ-009, 5-chain L2 + ICS-20 v1), x/bearers EXTENDED (OY-LR/Beacon).

303 tests total (53 v0.1 baseline + 250 new). Coverage >=95.9% on all new/extended
packages (8 at 100%). Lexicon firewall (REQ-012) project-wide + per-module. G-003
by-ID-string import invariant. 5 phases: P0 (v0.1.0) + P1-P4 (v0.1.1..v0.1.4) + P5 final
(v0.1.5 = this milestone release). Per run.md patch-line model: no separate minor tag.

14 clarification decisions (D-020..D-033), 15 research assumptions (A-201..A-215),
31 tasks across 5 phases, 10 grill binding decisions (G-001..G-010) all applied.

Next milestone: v0.3 (The Bearers) per ROADMAP Phase 3.
2026-08-17 21:41:28 +00:00
189 changed files with 47005 additions and 269 deletions
+15 -7
View File
@@ -1,11 +1,19 @@
{
"phase": 4,
"stage": "execute",
"milestone": "v0.2",
"milestone_type": "feature",
"tag_base": "v0.1.x",
"phase_role": "execution",
"milestone": "v0.7",
"milestone_complete": false,
"milestone_release_tag": null,
"release_id": 776,
"project": "oy",
"phase": 0,
"phase_role": "pre_execution",
"stage": "grill",
"attempts": 0,
"updated_at": "2026-08-17T21:50:00Z"
"updated_at": "2026-08-19T00:04:00Z",
"next_milestone": null,
"previous_milestone": {
"milestone": "v0.6",
"milestone_complete": true,
"milestone_release_tag": "v0.5.6",
"release_id": 776
}
}
+2 -2
View File
@@ -6,9 +6,9 @@
}
],
"active_project": "oy",
"milestone": "v0.2",
"milestone": "v0.6",
"milestone_type": "feature",
"tag_base": "v0.1.x",
"tag_base": "v0.5.x",
"autonomy": {
"level": "full",
"escalation_hooks": ["deploy", "delete_data", "merge_to_main"],
+553 -1
View File
@@ -1,3 +1,4 @@
<!-- Auto-generated from .ciagent/oy/oy-spec — PO edits oy-spec, not this file; see oy-state for current shipped state. -->
# Architecture: OpenYield (oy) — Phase 0 Index
## Source
@@ -57,4 +58,555 @@ Fee Covenant (13) blocks {Pacts (8), Orgs (10), Partners (11), Bearers (12)}
## Phase 0 Architecture Deliverables
- This index file
- Persona assessment (created during RESEARCH stage)
- Phase plans (created during PLAN stage)
- Phase plans (created during PLAN stage)
---
## v0.3 Architecture (Bearers & Documentation)
This section appends the v0.3 component map to the v0.1/v0.2 index above. It does
NOT rewrite or supersede the earlier content; the Phase 1/2/3 columns in the
component index above describe the *full* runtime target, while the v0.3 columns
below describe the *v0.3 skeleton+tests* deliverable (D-020 pattern continued,
D-035) plus the documentation deliverable (D-042).
### v0.3 Component Index (new + extended modules)
| # | Component | Vision § | v0.3 Module | New/Ext | Phase | v0.3 Skeleton Depth |
|---|---|---|---|---|---|---|
| 2 | Cross-Chain & Exit (Layer 3) — DEX swaps | §7 | `x/exit` | New | P4 | ExitRoute + DEXSwap types, ExitStatus enum |
| 2 | Cross-Chain & Exit (Layer 3) — L2↔L1 bridges | §7 | `x/bridge` | New | P4 | BridgeRoute + BridgeStatus enum; references x/satellite L2Chain by ID (G-003) |
| 12 | Bearers expansion (OY-SAT + OY-QR) | §14 | `x/bearers` | Extended | P4 | OYSATLink + OYQRCode transport types (BearerTransport impls); BearerType enum already complete from v0.2 |
| 11 | Anchors (institutional Partner tier) | §13 | `x/partner` | Extended | P4 | AnchorCredential struct fields on the Anchor tier (REQ-018 enum unchanged); ListByTier(Anchor) round-trip |
| 8 | Hub API (Pact #6 expanded) | §13, §16 | `x/hub` | New | P5 | HubService enum (Custody/LendingPrimitive/Compliance) + per-service struct stubs + keeper stub |
| — | Services (Care/SIM/Vault/Mail) | §13 | `x/services` | New | P5 | ServiceKind enum (4) + per-service struct stubs + keeper stub |
| 8 | Bond market depth (Growth Bonds + secondary) | §17 | `x/bond` | Extended | P5 | GrowthBond struct + SecondaryOrder types; 8%/0% consts (D-028) unchanged; Clamp reused |
> The Hub API is Pact #6 (Hub-API) per REQ-020/D-027. v0.2 stubbed it as a PactType
> enum value inside `x/pact`; v0.3 promotes it to its own `x/hub` module for the
> B2B type scaffold (D-039). The `x/pact` HubAPI enum value stays as a
> cross-reference; `x/hub` owns the service-shape types.
### v0.3 Cross-Component Dependencies (within v0.3)
Per the v0.2 G-003 invariant (by-ID-string inter-module references; no struct
imports across `x/<module>/types`), v0.3 components reference each other and the
v0.2 baseline by ID string only. The dependency edges that affect v0.3 phase
ordering:
```
x/bridge ──(L2Chain by id)──► x/satellite (v0.2 baseline; ref only, no struct import)
x/exit ──(BridgeRoute by id)──► x/bridge (P4: exit references bridge routes)
x/hub ──(Anchor by id)──► x/partner (P5: Hub custody/compliance references Anchor partners)
x/bond ──(Stand by id)──► x/stand (v0.2 baseline; GrowthBond issuer-stand-id, unchanged)
x/services ──(Window by id)──► x/window (v0.2 baseline; service-grant references a Window)
x/bearers ──(BearerTransport)──► (none; OY-SAT/OY-QR are transport stubs, no new deps)
```
**Phase-ordering implication (informs D-044):** `x/exit` references `x/bridge`
routes, so both must land in the same phase (P4) and `x/bridge` types must exist
before `x/exit` tests that reference a BridgeRoute. `x/hub` references Anchor
partner-ids, so `x/partner` Anchor extension (P4) must precede `x/hub` (P5). This
confirms the D-044 P4→P5 split: P4 = exit/bridge/bearers/partner-Anchor,
P5 = hub/services/bond. Reversing P4/P5 would force `x/hub` to reference an Anchor
tier that does not yet exist.
### v0.3 Interface Contracts (6 cross-component — unchanged from v0.2)
The six cross-component interfaces (Standing API, Forge/Fold, Watcher Attestation,
Window Lifecycle, Fee Covenant, Voice/Council) are NOT extended in v0.3 — v0.3
adds *type scaffolds* that will *consume* them at runtime in v0.4+:
- **Window Lifecycle Interface** — `x/services` service-grants reference a Window
by ID (the service opens a Window on the holder's behalf). Skeleton only.
- **Fee Covenant Interface** — `x/bridge`/`x/exit` exit routes carry an
`exit-fee-bps` field clamped by the Fee Covenant ceiling/floor (the field is
typed in v0.3; the Clamp is NOT invoked in the skeleton — deferred to v0.4
runtime to avoid cross-module calls in the skeleton layer).
- **Standing API** — `x/hub` compliance service stub references a partner's
Standing by reach-id (skeleton: by-ID-string field, no query).
- **Watcher Attestation** — `x/bridge` BridgeStatus has an `Attested` state; the
attestation itself is not modeled in v0.3 (Watchers are v0.1 baseline; the
bridge references a Watcher quorum by ID at runtime, deferred to v0.4).
### Documentation Architecture (v0.3 deliverable B)
v0.3 introduces a documentation deliverable alongside the Bearers skeleton. This
is a NEW architecture surface (no docs site existed in v0.1/v0.2).
**Layout:**
```
oy/
├── README.md # repo-root project overview (lexicon-clean)
├── mkdocs.yml # MkDocs Material config (site_name, nav, theme)
└── docs/
├── nomads/ # audience: nomads (Reach path, Stash, bearers, Maps/Pay, Pacts, standing basics)
├── freeholders/ # audience: freeholders (4 signals, Bayesian Standing, Stands/Guilds, Councils/Voice, Bonds, Partner spectrum)
├── shared/ # cross-audience (Six Principles, Bread Scale, Storage pools, Watchers/Mirror, Lexicon glossary, Vision overview)
└── reference/ # architecture index + component map
```
**mkdocs.yml (minimal config):** `site_name: OpenYield`, `theme: readthedocs` or
`theme: material` (D-042 chose Material), `nav:` with the four audience
sections, `markdown_extensions: [admonition, toc, pymdownx.superfences]`. Build-
only Python dep (`mkdocs` + `mkdocs-material`); `go.mod` stays zero-dep (G-006 —
the docs toolchain is NOT a Go dependency). No publishing CI in v0.3 (D-046);
README documents `mkdocs serve` / `mkdocs build`.
**Audience-organized nav (D-042, D-045):** nomads 5-8 pages, freeholders 5-8
pages, shared 5-6 pages, reference 2 pages (~20-25 total). Pages map to REQs:
nomads cover REQ-007/013/014/015/019/020; freeholders cover REQ-005/006/016/017/
011/021/018; shared covers REQ-001/003/004/012; reference covers the architecture
index.
**Lexicon-clean by construction (REQ-012 extension, D-043):** docs are user-
facing and must be lexicon-clean. The highest-risk banned term in docs is
"yield" (PROJECT.md uses "real yield" but docs must say "real production" / "real
return" — the word-boundary regex in `lexicon.FindBannedTerm` bans standalone
"yield" while allowing "OpenYield"). Other high-risk terms in docs: "account"
(use "Holder"/"Reach"), "bank"/"deposit"/"savings" (use "Stash"/"Vault"/
"Root-Pool"). The firewall lands in P1 BEFORE content (P2/P3) so docs are checked
as authored (D-044 firewall-first ordering).
**Firewall extension (D-043):** a NEW sibling test `lexicon_meta_docs_test.go`
(package `lexicon_meta_docs`) mirrors `lexicon_meta_test.go` (package
`lexicon_meta`) exactly — same `lexicon.FindBannedTerm`, same word-boundary
regex, same fragment-assembled self-test table, same self-exclusion of the meta-
test file — but scans `README.md` + `docs/**/*.md` instead of `x/**/*.go`. The
existing `lexicon_meta_test.go` is NOT modified (preserves v0.2 coverage). The
new meta-test walks the repo root for `README.md` + the `docs/` tree, excludes
`.ciagent/` and `.git/` (firewall meta-files are not user-facing docs), and
excludes itself. Per-package lexicon assertions in the new `x/*` modules follow
the v0.2 pattern (`TestLexiconNoBannedTermsIn<Module>Package` scanning the
module's production `.go` files).
> The `.ciagent/` directory holds firewall META-files (PROJECT.md, RESEARCH.md,
> this file) that discuss the banned terms by name for governance reasons — they
> are NOT user-facing docs and are explicitly excluded from the docs firewall
> scan. This mirrors how `lexicon_meta_test.go` excludes itself: the firewall's
> own code is allowed to name the terms it bans.
## v0.4 Architecture (Refinement — NFR)
v0.4 is a refinement-only NFR milestone (D-047): zero `feat:` phases, zero new
production types, zero behavioral changes. It lands durability fixes sourced
from v0.3 forward-references. Tags run on the `v0.3.x` patch line.
### v0.4 Research Findings
**R-029 — Lexicon firewall shared helper (REQ-029, GRILL G-014).**
Verified during v0.4 RESEARCH: `lexicon_meta_test.go` (`TestLexiconMetaSelfTestTable`, lines 83-118) and `lexicon_meta_docs/lexicon_meta_docs_test.go` (`TestLexiconMetaDocsSelfTestTable`, lines 147-182) contain byte-identical duplicate synthetic self-test tables — both build the same 10-string slice by indexing `lexicon.BannedTerms()`. This is exactly the G-014 drift risk: if a future banned-term addition updates one table and not the other, the docs firewall silently loses coverage. The fix is a new `lexicon.SyntheticBannedStrings() []string` helper in `lexicon/lexicon.go` that returns the 10 synthetic strings; both meta-tests consume it instead of building their own copy. The helper's source uses `lexicon.BannedTerms()` (already fragment-assembled) so the lexicon package's own source stays lexicon-clean. Both meta-tests already assert `len(terms) == 10` from `lexicon.BannedTerms()` (the G-014 minimum); the helper closes the drift fully. No behavioral change to detection (`FindBannedTerm` unchanged); refactor + test only.
**R-030 — Cross-package const-equality test (REQ-030, REVIEW P2 / A-304).**
Verified during v0.4 RESEARCH: `x/hub/types/types.go:51,56` defines LOCAL consts `LendingCouponCapBps = uint32(800)` and `LendingCouponFloorBps = uint32(0)`, cross-documented (comment lines 46-55) to `x/bond/types/types.go:21,26` consts `CouponCapBps = 800` and `CouponFloorBps = 0` (D-028 mission-locked). The cross-doc comment flags drift for human review but no automated check exists. The fix is a new test file `x/hub/types/cross_const_test.go` (package `types`) that imports `github.com/oy/openyield/x/bond/types` (test-only, G-003 exempt per the test-import exemption documented in v0.2 GRILL G-003) and asserts `hub.LendingCouponCapBps == bond.CouponCapBps` and `hub.LendingCouponFloorBps == bond.CouponFloorBps`. The test fails closed if either const drifts. No production import is added (G-003 production firewall intact); test-only import only.
**R-031 — Lifecycle type shape-divergence review (REQ-031, AUDIT §193).**
Verified during v0.4 RESEARCH: AUDIT §193 flags two P1 council divergences and one P2 bearers nit:
- **P1-1**: `x/council/types` lacks `Proposal`/`ProposalStatus`/`VoteOption` enums (AUDIT says add "in v0.3 when wiring the council keeper to a live governance runtime"). Adding these is a `feat:`-class addition (new enum types) → REJECTED by D-001 filter for v0.4. Deferred to v0.5+ governance runtime.
- **P1-2**: `SignalKind` has 4 sources (Stash/Standing/Vouch/Capital) vs spec's `VoiceSource` 5 sources (Stash/Standing/Vouch/Freeholder/Guild). AUDIT code rationale: Freeholder is an eligibility property (upstream in `x/standing`), Guild is a council tier, Capital is committed-capital (vision §9.1) — defensible refinement. Changing `SignalKindCount` 4→5 is a locked-const change → REJECTED by D-001 filter for v0.4.
- **P2**: `x/bearers/types` `ValidateGenesis` no-op is CORRECT per spec (AUDIT explicitly notes "no action").
v0.4 REQ-031 scope (D-050): DOCUMENT the divergence decisions in this ARCHITECTURE.md section + add a regression-guard test asserting the current `SignalKindCount==4` shape is intentional (an intent-assertion test, not a shape change). No enum additions, no locked-const changes. The existing `TestSignalKindCountLockedConst` in `x/council/types/types_test.go:102` already asserts the count; REQ-031 adds an intent comment + a test documenting WHY the shape is 4-not-5 (the AUDIT rationale), so a future agent does not "fix" the divergence by silently changing the locked const.
**R-032 — Docs build CI (REQ-032, D-046).**
Verified during v0.4 RESEARCH: no `.github/workflows/` directory exists; Gitea Actions uses `.gitea/workflows/`. `mkdocs.yml` is present at repo root (buildable locally via `mkdocs build`). v0.4 REQ-032 ships a `.gitea/workflows/docs-build.yml` workflow that: (1) runs `go test ./...` (the lexicon firewall + all x/* tests) on push; (2) installs mkdocs + mkdocs-material (build-only Python deps in a separate job/step — does NOT touch `go.mod`, G-006 intact); (3) runs `mkdocs build` to produce `site/`; (4) uploads `site/` as a CI artifact. Full Gitea Pages publishing is DEFERRED (no hosting target configured in v0.4 per D-051). The workflow file is `chore` (CI config), not `feat:` — passes the D-001 filter. The workflow runs on every push to any branch (not just main) so the lexicon firewall + docs build are checked on every change.
### v0.4 Component Map (no new modules)
v0.4 touches NO new `x/*` modules. The touched files are:
- `lexicon/lexicon.go` (add `SyntheticBannedStrings()`) — REQ-029
- `lexicon_meta_test.go` (refactor to consume helper) — REQ-029
- `lexicon_meta_docs/lexicon_meta_docs_test.go` (refactor to consume helper) — REQ-029
- `x/hub/types/cross_const_test.go` (NEW test file) — REQ-030
- `x/council/types/types_test.go` (add intent-assertion test + comment) — REQ-031
- `.ciagent/oy/ARCHITECTURE.md` (this section) — REQ-031
- `.gitea/workflows/docs-build.yml` (NEW CI workflow) — REQ-032
### v0.4 Interface Contracts (unchanged from v0.3)
v0.4 does not change any cross-component interface. The 6 cross-component interfaces (Standing, Forge/Fold, Mirror, Window, Fee Covenant, Voice/Council) are unchanged. REQ-031 documents a divergence in the Voice/Council interface surface (SignalKind shape) but does not change it.
### Council Voice/Council Interface — Lifecycle Type Divergence Decisions (v0.4, REQ-031)
This section documents the lifecycle type shape-divergences flagged by AUDIT.md §193 for the Council/Voice interface surface. v0.4 is a refinement-only NFR milestone (D-047): the D-001 filter REJECTS `feat:`-class enum additions and locked-const shape changes, so these divergences are DOCUMENTED here, not fixed in code. A regression-guard test (`TestSignalKindShapeIntentional` in `x/council/types/types_test.go`) locks the current shape so a future agent does not silently "fix" a divergence by changing a locked const.
**Divergence P1-1 (AUDIT §193): `Proposal`/`ProposalStatus`/`VoteOption` enums absent from `x/council/types`.**
- **Spec source**: P3-01-01 deliverable recommended `Proposal`, `ProposalStatus` (5 states), `VoteOption` (3 options) enums mirroring OZ Governor / `x/gov`.
- **Implemented**: `Council`, `CouncilMember`, `Voice`, `SignalKind`, `TallyResult` — no `Proposal`/`ProposalStatus`/`VoteOption` lifecycle types.
- **Must-have impact**: NONE. The v0.2 P3 must-haves (3 councils, Mission Lock, `TallyResult` x/gov shape, no veto) are all met without the Proposal lifecycle.
- **Decision (v0.4, D-050)**: ADDING `Proposal`/`ProposalStatus`/`VoteOption` is a `feat:`-class addition (new enum types). REJECTED by the D-001 refinement-only filter. **Deferred to v0.5+** when the council keeper is wired to a live governance runtime (the AUDIT's own recommendation: "add in v0.3 when wiring the council keeper to a live governance runtime"). The skeleton council keeper in v0.2 does not consume a Proposal lifecycle; adding the types without the runtime would be dead code.
- **Severity (AUDIT)**: P1 (spec drift from deliverable text, not a must-have, not blocking).
- **v0.4 action**: DOCUMENT only (this section). No code change.
**Divergence P1-2 (AUDIT §193): `SignalKind` 4 sources vs spec `VoiceSource` 5 sources.**
- **Spec source**: P3-01-01 deliverable specified `VoiceSource` with 5 sources (Stash/Standing/Vouch/Freeholder/Guild).
- **Implemented**: `SignalKind` with 4 sources: `SignalStash`, `SignalStanding`, `SignalVouch`, `SignalCapital` (`SignalKindCount = 4`, locked const).
- **Code rationale (AUDIT §193 P1-2)**: the 4-source shape is a defensible design refinement, not a defect:
- `Freeholder` is an ELIGIBILITY property (upstream in `x/standing`), not a voice signal. A Freeholder-eligible Reach is a precondition for voting, not a signal that feeds a vote's weight.
- `Guild` is a COUNCIL TIER (one of the three councils is the Guild Council), not a voice signal. Including Guild as a signal kind would conflate the council tier with the signal source.
- `Capital` is committed-capital (vision §9.1, one of the four Freeholder signals), which the spec's `VoiceSource` list omitted. Adding `Capital` corrects the spec list to match vision §9.1's four-signal definition (REQ-005: "Four Freeholder signals locked").
- **Must-have impact**: NONE. The v0.2 P3 must-haves did not enumerate `VoiceSource` coverage; the 4-signal shape matches REQ-005's "Four Freeholder signals locked" exactly.
- **Decision (v0.4, D-050)**: changing `SignalKindCount` 4→5 (to restore the spec's 5-source `VoiceSource`) is a LOCKED-CONST CHANGE. REJECTED by the D-001 refinement-only filter (changing a locked const is a behavioral change, not a refinement). The 4-source shape is the CORRECT shape per vision §9.1 and REQ-005; the spec deliverable text was wrong, not the implementation.
- **Severity (AUDIT)**: P1 (design-choice divergence, tested and self-consistent, not blocking).
- **v0.4 action**: DOCUMENT the rationale here + add `TestSignalKindShapeIntentional` (regression guard) so a future agent changing `SignalKindCount` from 4 to 5 must also update the intent-assertion test, surfacing the AUDIT rationale for review. No locked-const change.
**Divergence P2 (AUDIT §193): `x/bearers/types` `ValidateGenesis` no-op.**
- **Spec source**: P4-02-01 said "DefaultParams/GenesisState unchanged" (bearers is an EXTENSION in v0.2, not a new module; the A-212 `ValidateGenesis` upgrade was scoped to NEW modules only).
- **Implemented**: `ValidateGenesis` remains a no-op (`x/bearers/types/types.go:108` returns `nil` unconditionally).
- **Decision (v0.4)**: CORRECT per spec — no action (AUDIT explicitly notes "no action"). The A-212 upgrade applies to NEW modules (v0.2's `x/window`, `x/stand`, etc.), not to EXTENDED modules like `x/bearers`. Listed here for completeness; no code change, no test change.
---
## v0.5 Runtime Architecture (Bearers Runtime)
This section appends the v0.5 runtime architecture to the v0.1/v0.2/v0.3/v0.4
content above. It does NOT rewrite or supersede earlier sections. v0.5 is the
first **feature** milestone to ship executable behavior: the v0.3 Bearers
skeletons are promoted from types + in-memory keeper stubs + invariant tests
to **live keeper MsgServer message handlers + simtest-grade end-to-end flows**
(D-054). This is NOT mainnet — D-020 continues to govern network deployment;
runtime = simtest-grade handlers, not live chain. Tags run on the `v0.4.x`
patch line (config.json `tag_base`).
### v0.5 Skeleton→Runtime Promotion Pattern
The promotion is uniform across all 8 target modules. The v0.3 skeleton
baseline (verified against the current tree): each module has only a `types/`
subdir with `types.go` (pure-Go structs + locked consts + enums),
`genesis.go` (`ValidateGenesis`), and `*_test.go` (invariant + lexicon
tests). The in-memory `Keeper` stub lives INSIDE `types/types.go` (e.g.,
`x/partner/types/types.go:101 type Keeper struct{...}`, `NewKeeper()` returns
`&Keeper{partners: make(map[string]Partner)}`). There is NO `keeper/` subdir,
NO `msg_server.go`, NO `types.Msg*`, NO `sdk.Context`, and NO cosmos-sdk
import anywhere in `x/` (grep for `cosmos-sdk` / `sdk.Context` /
`cosmos/cosmos` returns zero matches — verified at v0.5 P0).
v0.5 promotes each module per the Cosmos-SDK `MsgServer` convention:
| Layer | v0.3 skeleton | v0.5 runtime addition |
|---|---|---|
| Keeper | in-memory `map[string]T` in `types/types.go` | `keeper/keeper.go` (store-backed, wraps `sdk.KVStore`); the v0.3 stub is retired or wrapped as a test helper |
| Messages | none | `types/msg_*.go` with `Msg*` structs implementing `sdk.Msg` (`ValidateBasic`, `GetSigners`) |
| Handlers | none | `keeper/msg_server.go` with `MsgServer` + one `*Response, error` method per `Msg*` |
| Module wiring | none | `module.go` (`AppModule` with `RegisterServices` registering the `MsgServer`); simtest may use a lighter `ModuleManager` shim |
| End-to-end test | invariant tests only | `simtest/` (or `keeper/msg_server_simtest_test.go`) exercising each handler against an in-memory `sdk.Context` |
| Cross-module deps | by-ID-string only (G-003) | by-ID-string preserved at the type level; keeper-to-keeper calls via `expected_keepers.go` interface shims (ibc-go convention) |
The existing `types/` locked consts, enums, and structs are NOT amended —
the runtime layer adds behavior on top, not changes to the contract. The
locked-const firewall (8%/0% bond cap, 6 bearers, 4 Partner tiers, Mission
Lock non-amendable, etc.) stays green.
### v0.5 Per-Module Runtime Surface
| Module | REQ | Phase | Runtime surface (MsgServer handlers) | Key types added/extended |
|---|---|---|---|---|
| `x/exit` | REQ-033 | P1 | `MsgSubmitExitRoute`, `MsgExecuteDEXSwap`, `MsgRefundExit` driving the v0.3 `ExitStatus` lifecycle (Proposed→InProgress→Settled/Failed/Refunded) | `Msg*` types; cross-chain exit invokes `x/bridge` via `BridgeKeeper` expected-keeper shim |
| `x/bridge` | REQ-033 | P1 | `MsgAttestBridgeRoute` (Pending→Attested via Watcher quorum), `MsgActivateBridge`, `MsgCloseBridge`, `OnRecvPacket`, `OnAcknowledgementPacket`, `OnTimeoutPacket` (ibc-go `IBCModule` contract) | `Msg*` types; ICS-20 v1 payload parser; Solana via wormhole-adapter verification branch (D-059) |
| `x/bearers` | REQ-034 | P2 | `MsgSendOYSATFrame`, `MsgReceiveOYSATFrame`, `MsgIssueOYQR`, `MsgConsumeOYQR` (one-shot) + session lifecycle (Open/Active/Closed/Revoked) | `Session` struct; store-backed `BearerTransport` impl (keeper as transport in simtest); `consumed` flag is the OY-QR replay firewall |
| `x/partner` | REQ-035 | P3 | `MsgIssueAnchorCredential`, `MsgOnboardAnchor` (Pending→Onboarded), `MsgSuspendAnchorCredential`, `MsgRevokeAnchorCredential` (Watcher-quorum authz) | `Msg*` types; `expected_keepers.go` shims for `x/watcher` (revocation authz) and `x/hub` (custody-provider-id validity, P4-wired) |
| `x/hub` | REQ-036 | P4 | `MsgRegisterCustodyService` (operator must be Onboarded Anchor), `MsgCustodyReceiveAsset`, `MsgCustodyReleaseAsset` (compliance-before-debit), `MsgRecordLendingPrimitive` (coupon clamp [0,800]), `MsgRecordComplianceAttestation` | `CustodyKeyring` interface + `memKeyring` in-memory test impl (D-058); `Msg*` names avoid banned "deposit" (lexicon) |
| `x/services` | REQ-037 | P5 | `MsgRegisterService` (window-grant check), `MsgActivateService`, `MsgSuspendService`, `MsgRevokeService`; per-kind: `MsgIssueCareGrant`, `MsgActivateSIM`, `MsgProvisionVault`, `MsgBindMailbox` | Per-kind `Msg*` (typed dispatch, not generic); `window-id` grant checked on EVERY op (revoked Window invalidates) |
| `x/bond` | REQ-038 | P6 | `MsgIssueBond`, `MsgIssueGrowthBond` (`Clamp` + `ClampGrowth`), `MsgTickGrowthBond`, `MsgPlaceSecondaryOrder`, `MsgCancelSecondaryOrder`, `MsgMatchSecondaryOrder` (CLOB, price-time priority, per-match clamp) | CLOB matching engine; per-match coupon clamp to [0, 800] bps via v0.3 `Clamp` (D-057/D-028); match above 800 REJECTED (fails closed, A-562) |
| `x/council` | REQ-039 | P7 | `MsgSubmitProposal` (MissionLockAmendment kind rejected at `ValidateBasic`), `MsgVote` (Veto is Watcher-only, quorum-based), `MsgTallyProposal` | `Proposal` struct + `ProposalKind` enum (4, incl. rejected MissionLockAmendment) + `ProposalStatus` enum (5) + `VoteOption` enum (4) — AUDIT §193 P1-1 promotion; `SignalKind` stays 4 (P1-2 defensible); Mission Lock const firewall intact |
### v0.5 Custody Keyring Interface Boundary (D-058)
`x/hub/types/keyring.go` defines the `CustodyKeyring` Go interface — the
custody key-share abstraction (MPC-via-interface, not a concrete HSM/MPC
vendor):
```
type CustodyKeyring interface {
Sign(ctx context.Context, assetID string, payload []byte) (sig []byte, err error)
Derive(ctx context.Context, assetID string) (pub PubKey, err error)
Status(ctx context.Context, assetID string) (KeyringStatus, error)
}
```
- v0.5 ships an in-memory test-only `memKeyring` impl (`x/hub/keeper/
keyring_mem.go` or `x/hub/types/keyring_mem_test.go`) that signs with a
throwaway ed25519 key. Real MPC/HSM backing is deferred (operational,
Year 3+).
- The interface supports key rotation: `Status` reports the active key
version; the handler consults the keyring per operation (no caching
across blocks — a cached pubkey breaks rotation).
- The boundary keeps v0.5 dep-neutral w.r.t. custody vendors while landing
the handler surface. GRILL reviews the interface boundary.
### v0.5 CLOB Matching Engine Invariants (D-057)
`x/bond` secondary-market matching is a **central-limit order book (CLOB)**
(not an AMM — D-057 rejects AMM as a Year-4 concern). The invariants:
1. **Price-time priority** — at the same price, the earlier resting order
fills first (by sequence). This is REQ-007 FCFS at the same price.
2. **Per-tx matching** — the handler matches a new order against the resting
book in the same tx (dYdX-v4-shaped); no asynchronous / end-of-block
batch matching in v0.5 simtest.
3. **Per-match coupon clamp** — every match's resulting coupon is clamped to
`[CouponFloorBps=0, CouponCapBps=800]` (D-028, locked since v0.2) via the
v0.3 `Clamp` helper. A match whose implied coupon exceeds 800 bps is
**REJECTED** (fails closed — A-562, the mission-lock-true choice; D-057
says "clamp", the runtime interpretation is reject-above-cap. Planner
confirms before P6).
4. **Mission-lock const firewall** — the handler references the consts
directly (not a local copy); the REQ-030 cross-const test (hub lending
consts == bond consts) stays green.
5. **No front-running safety claim** — per-tx matching in a single-
validator simtest has no MEV; the handler is documented as NOT
front-running-safe for mainnet (a Year-3+ concern). Simtest does not
assert front-running safety (out of scope for simtest-grade runtime,
D-054).
### v0.5 IBC Packet Handler Scope (D-059)
`x/bridge` IBC packet handlers cover the **5 locked L2 chains** already in
the v0.2 `x/satellite` skeleton (Polygon, Base, Arbitrum, Optimism, Solana
per REQ-009). No new L2 chains in v0.5. The handler shape:
- **4 EVM chains (Polygon/Base/Arbitrum/Optimism):** standard IBC
recv/ack/timeout on the ICS-20 v1 payload (the v0.2 satellite packet
shape). Timestamp-only timeouts (IBC Eureka model, ibc-go v10) avoid the
EVM height-timeout ambiguity.
- **Solana:** via the wormhole-style bridge adapter (D-021 stub promoted
to runtime). Solana packets arrive as wormhole VAAs (Verified Action
Approvals); the `x/bridge` handler verifies the guardian signature set
(a 2-of-N quorum, N = the wormhole guardian set) before transitioning the
route. The guardian set is read from state (not hardcoded); simtest uses
a frozen stub guardian set. Live wormhole integration deferred (D-054).
- **Replay protection:** mirrors ibc-go — delete the in-flight record on
first ack; reject on second; `OnTimeoutPacket` refunds the source-chain
escrow exactly once. Simtest covers both replay and timeout-refund cases
(the CVE-class ibc-go pitfall).
### v0.5 Council Governance Enum Additions (D-060)
`x/council/types` gains the AUDIT §193 P1-1 enums deferred from v0.4
(D-050/D-001 rejected them as `feat:` for the NFR milestone; v0.5 promotes
them as the feature milestone's P7):
| New type | Values | Locked count | Notes |
|---|---|---|---|
| `Proposal` struct | (id, council-id, kind, proposer-reach, submit-time, voting-deadline, status, tally) | — | Mirrors OZ Governor / `x/gov` proposal shape |
| `ProposalKind` enum | `Stand`, `Guild`, `Mesh`, `MissionLockAmendment-Rejected` | `ProposalKindCount = 4` | The 4th value exists but the handler rejects it — documents the non-amendability in code |
| `ProposalStatus` enum | `Pending`, `Active`, `Succeeded`, `Failed`, `Executed` | `ProposalStatusCount = 5` | Mirrors OZ Governor / `x/gov` lifecycle |
| `VoteOption` enum | `Yes`, `No`, `Abstain`, `Veto` | `VoteOptionCount = 4` | `Veto` is Watcher-only; quorum-based (default `WatcherVetoQuorum = 6` per REQ-004 6-of-9); a single Veto does NOT block (anti-greed, vision §19) |
**Mission Lock const firewall intact (G-003):**
- `MissionLockAmendable = false` (v0.2 locked const) is UNCHANGED. The
`MissionLockAmendment-Rejected` `ProposalKind` is the in-code
documentation of the non-amendability; the `MsgSubmitProposal`
`ValidateBasic` REJECTS a proposal of that kind (the message never
reaches the handler — A-572). The const is the firewall; the
`ValidateBasic` is the gate. The v0.2 `TestMissionLockAmendableFalse`
regression test stays green.
- `SignalKind` stays at 4 sources (P1-2 defensible per AUDIT; the v0.4
`TestSignalKindShapeIntentional` regression-guard test stays green).
Expansion to 5 is a locked-const change deferred to v0.6+ governance
vote (not a Mission-Lock const — a distinct locked const; the distinction
is documented in v0.4 ARCHITECTURE.md).
- Proposal execution (auto-executing a passed proposal) is NOT in v0.5;
the handler records the tally result but does not auto-execute (a v0.6+
concern).
### v0.5 G-003 Production Firewall (still intact)
The G-003 by-ID-string rule (no production cross-`x/<module>/types` struct
imports) survives the runtime promotion. The runtime adds a NEW cross-
module surface — keeper-to-keeper calls — handled via the ibc-go
`expected_keepers.go` convention:
- Each module's `types/expected_keepers.go` defines Go INTERFACES for the
keepers it depends on (e.g., `x/exit/types/expected_keepers.go` defines a
`BridgeKeeper` interface with the methods `x/exit`'s handler calls; the
`x/bridge` keeper satisfies it structurally).
- The handler depends on the INTERFACE, not the concrete keeper struct.
This is NOT a struct import of `x/bridge/types`; it is an interface
defined in `x/exit/types`. G-003's intent (no cross-module struct
coupling, no import cycles) is preserved.
- Test-only cross-package imports (the G-003 test exemption, used by
REQ-030 in v0.4) remain exempt: a simtest may import both
`x/exit/keeper` and `x/bridge/keeper` to wire the expected-keeper shims
in a test setup.
### v0.5 G-006 Controlled Exception (cosmos-sdk dep, D-055)
`go.mod` gains `github.com/cosmos/cosmos-sdk` (+ transitive deps) as the
runtime substrate. This is a GRILL-approved controlled exception to G-006
(zero-dep go.mod), scoped to the runtime promotion phases:
- **Runtime phases (P1..P7):** `keeper/`, `msg_server.go`, `module.go`,
`simtest/` import cosmos-sdk. The dep is load-bearing.
- **P0 (pre-execution) + P8 (final):** stay dep-neutral where possible
(RESEARCH.md, PERSONAS.md, PLAN — no Go code).
- **`types/` packages:** the v0.3 `types/` packages were pure stdlib
(`encoding/json`); v0.5 ADDS `types.Msg*` structs implementing `sdk.Msg`,
so the `types/` package gains a cosmos-sdk import. The invariant tests
(locked-const, lexicon) stay stdlib-only and green. The `Msg*` types are
isolated in `types/msg_*.go` files for clarity.
- **Version pin (A-504, planner/GRILL confirms):** cosmos-sdk v0.50.x
(LTS, go 1.22-compatible) + ibc-go v8.x (for cosmos-sdk v0.50) for the
IBC packet handler interfaces. ibc-go v10 (IBC v2 / Eureka) is the
documented target pattern but a newer pin; v8.x is the stable choice.
The exception is GRILL-ratified per D-055.
### v0.5 Cross-Component Dependencies (within v0.5, by-ID-string + expected-keeper shims)
Per the v0.2-v0.4 G-003 invariant, v0.5 components reference each other and
the baseline by ID-string at the type level; the runtime adds interface-
typed keeper dependencies via `expected_keepers.go` shims. The dependency
edges that affect D-056 phase ordering:
```
x/exit ──(BridgeKeeper interface)──► x/bridge (P1 intra-phase; bridge keeper satisfies x/exit/types expected keeper)
x/bridge ──(WatcherKeeper interface)──► x/watcher (P1; Attested transition + Solana adapter authz)
x/bridge ──(BreadKeeper interface)───► x/bread (P1; mint/release wrapped Bread on recv/timeout)
x/bearers ──(BreadKeeper interface)───► x/bread (P2; OY-QR consume transfer effect)
x/partner ──(WatcherKeeper interface)──► x/watcher (P3; revocation authz)
x/partner ──(HubKeeper interface)─────► x/hub (P3→P4; custody-provider-id validity; shim exists P3, impl wired P4)
x/hub ──(PartnerKeeper interface)─► x/partner (P4; operator must be Onboarded Anchor)
x/hub ──(lexicon-safe consts)─────► x/bond (P4; LendingCouponCapBps/Floor local consts cross-documented D-028/REQ-030)
x/services ──(WindowKeeper interface)──► x/window (P5; window-grant validity on every op)
x/services ──(VaultKeeper interface)───► x/vault (P5; VaultService provisioning)
x/bond ──(StandKeeper interface)───► x/stand (P6; GrowthBond issuer-stand-id)
x/council ──(WatcherKeeper interface)──► x/watcher (P7; Veto authz + quorum)
```
**Phase-ordering implication (confirms D-056):** the outer→inner chain is
exit (P1) → bearers (P2) → anchors (P3) → hub (P4) → services (P5) → bond
(P6) → council (P7). The P3→P4 edge (partner needs hub custody-provider-id
validity) is broken by the `expected_keepers.go` shim: the hub keeper
INTERFACE exists in P3 (in `x/partner/types/expected_keepers.go`); the real
hub keeper impl is wired in P4. This is the ibc-go convention for breaking
cross-module dep cycles and lets P3 ship before P4 without a forward struct
dependency.
### v0.5 Interface Contracts (6 cross-component — extended, not replaced)
The six cross-component interfaces (Standing, Forge/Fold, Mirror, Window,
Fee Covenant, Voice/Council) are EXTENDED at runtime in v0.5 (they were
skeleton-only in v0.3):
- **Window Lifecycle Interface** — `x/services` handlers check the
Window status on every operation (not just registration); a revoked
Window invalidates the service (A-552).
- **Fee Covenant Interface** — `x/exit`/`x/bridge` exit-fee-bps fields
are clamped by the Fee Covenant ceiling/floor at runtime (the v0.3
field was typed but the Clamp was not invoked; v0.5 invokes it).
- **Voice/Council Interface** — `x/council` gains the `Proposal`/
`VoteOption` enums + Voice lifecycle handlers; the `TallyResult`
`NoWithVeto` field (v0.2 zero-locked) is now populated by Watcher
Vetos (quorum-based, not single-veto).
- **Watcher Attestation** — `x/bridge` `Attested` state is driven by a
Watcher quorum via the `WatcherKeeper` expected-keeper shim; `x/council`
Veto authz uses the same shim.
- **Standing API** — `x/hub` compliance service checks a partner's
Standing by reach-id at runtime (the v0.3 by-ID-string field becomes a
query).
- **Forge/Fold** — unchanged in v0.5 (no forge/fold runtime promotion this
milestone).
---
## v0.7 Architecture (Fraternal Groups Foundation)
This section appends the v0.7 component map. v0.7 introduces a NEW module
`x/cover` (D-084, D-039 precedent) and extends 4 existing modules. No
breaking schema changes to locked-const firewall; G-003 production firewall
intact; G-006 go.mod unchanged (`x/cover` uses existing cosmos-sdk substrate).
### v0.7 Component Index (new + extended modules)
| # | Component | Vision § | v0.7 Module | New/Ext | Phase | v0.7 Runtime Depth |
|---|---|---|---|---|---|---|
| 8 | Cover Pool Factory (Pact #4 Cover graduated) | §16 | `x/cover` | New | P1-P5 | CoverPool + CoverCharter + CoverCall + CoverFeeTag structs + Factory keeper + Anti-Crowding-Out firewall + Anti-Capture Bill of Rights (13 rights) + Cover Claims Voucher role + Pool governance hybrid + category staging; simtest-grade runtime (D-020) |
| 8 | Mutual Aid Bond (Pact #5 Bonds extended) | §17 | `x/bond` | Extended | P4 | MAB struct (anonymous embed of Bond) + CouponDenom enum (CoverCall/MutualAidCredit/Bread-rejected) + 3× annual surplus ceiling + tagged streaming use-of-proceeds (D-080); simtest |
| 10 | Chapter Federation (Orgs extended) | §12 | `x/guild` | Extended | P3 | ParentGuildID + IsChapter + SecessionTermsHash + GoodStandingLiens fields on Guild + SecessionTerms struct + cooling consts (21d/14d) + Household simplified + Confederation Voice; simtest |
| 6 | Shadow vouch weight + Cover Claims Voucher slash | §9.1, §9.4 | `x/standing` | Extended | P4 | ShadowVouchWeightMultiplier=0.5 const + IsShadow field on Vouch + SlashReasonFraudulentCoverCall const; simtest |
| 10 | Stand→Pier boundary + Household/Confederation keeper logic | §11, §13 | `x/stand` | Extended | P3, P5 | StandPierEscalationAnnualPassVolumeCents const + Household one-tap exit + Confederation Voice aggregation (switch on existing StandType, no struct change); simtest |
| 8 | Pact Cover cross-reference (no change) | §16 | `x/pact` | Unchanged | — | PactCover enum value stays as cross-reference (D-084, mirrors PactHubAPI ↔ x/hub); ExecuteCover() stub stays |
> The Cover Pool Factory is Pact #4 (Cover) per REQ-020/D-027. v0.2 stubbed
> it as a PactType enum value inside `x/pact`; v0.7 promotes it to its own
> `x/cover` module for the Factory + Charter + Bill of Rights + Voucher
> runtime (D-084). The `x/pact` PactCover enum value stays as a
> cross-reference; `x/cover` owns the runtime surface. This mirrors the
> D-039 precedent (`x/hub` split from `x/pact`'s PactHubAPI in v0.3).
### v0.7 Cross-Component Dependencies (within v0.7)
Per the G-003 invariant (by-ID-string inter-module references; no struct
imports across `x/<module>/types`), v0.7 components reference each other and
the v0.2-v0.6 baseline by ID string only. The dependency edges that affect
v0.7 phase ordering:
```
x/cover ──(StandingKeeper shim)──► x/standing (P1: gate query; G-003 expected_keepers.go)
x/cover ──(WatcherKeeper shim)──► x/watcher (P1: attestation pipeline; P4: MAB release witness)
x/cover ──(BondKeeper shim)──► x/bond (P4: MAB issuance ceiling query)
x/bond ──(CoverKeeper shim)──► x/cover (P4: MAB MsgDebitMABProceeds queries CoverKeeper.GetPoolReserveAccount; D-089(2) reverse edge — no import cycle, interface only)
x/bond ──(Stand by id)──► x/stand (v0.2 baseline; MAB issuer-stand-id, unchanged)
x/guild ──(Stand by id)──► x/stand (v0.2 baseline; Guild StandAffiliationID, unchanged)
x/guild ──(Cover Pool by id)──► x/cover (P3: Chapter Federation liens reference Cover Pool covenants)
x/cover ──(StillKeeper stub)──► x/still (P1: auto-pause on below-floor; P4: auto-Still on MAB misuse; D-089(1) simtest-local stub, NOT a real x/still keeper — x/still is NOT extended this milestone)
x/cover ──(PactCover by id)──► x/pact (cross-reference only; no struct import)
```
**Phase-ordering implication (informs D-082):** `x/cover` P1 lands the
Factory + firewall + locked floors + gates + tagging first (firewall-first
pattern). P2 extends `x/cover` with Charter + governance + staging. P3
extends `x/guild` (Chapter Federation depends on Cover Pool existing for
lien/covenant references). P4 extends `x/bond` (MAB depends on Cover Pool
reserve existing for use-of-proceeds) + `x/standing` (Shadow vouch + Voucher
slash). P5 lands the Anti-Capture Bill (cross-cutting; constrains all prior
surfaces) + secession cooling + Pier boundary. Confidence 0.82.
### v0.7 Interface Contracts (6 cross-component — extended this milestone)
The six cross-component interfaces are EXTENDED in v0.7:
- **Standing API** — `x/cover` Factory queries Standing via expected-keeper
shim (StandingKeeper.GetStandingBucket) for the Cover Pool Standing gate
(REQ-049, D-077). By-ID-string at type level (G-003).
- **Watcher Attestation Interface** — `x/cover` Factory + MAB release invoke
Watcher attestation via WatcherKeeper shim. Cover-Charter signed by Pool
Host + witnessed by Watcher (REQ-052). MAB proceeds release requires
Watcher quorum (D-080).
- **Window Lifecycle Interface** — unchanged in v0.7 (Cover-Charter
amendments cooling uses the existing Window Duration semantics; secession
cooling is a separate const-based mechanism, not a Window).
- **Fee Covenant Interface** — unchanged in v0.7 (Cover-Fees are a separate
tagging surface, not a Fee-Covenant route; the Anti-Crowding-Out firewall
enforces the separation).
- **Voice/Council Interface** — `x/cover` Pool governance hybrid (REQ-062)
= Pool Host + 3 elected Masons + Watcher observer. No Anchor seat (§5
Anchor no-Voice). MAB holders have NO Voice (REQ-063). Confederation Voice
(REQ-058) aggregates one-per-Stand.
- **Forge/Fold** — unchanged in v0.7.
### v0.7 Locked-Const Firewall Additions (GRILL-ratified D-086..D-090)
Per oy-state §3 + GRILL D-087, v0.7 adds 12 new locked consts (all net-new, no amendments to existing consts):
| Const | Value | Module | REQ |
|-------|-------|--------|-----|
| CoverReserveFloorAnnualContribX | 1.5 | x/cover | REQ-047 (locked) |
| CoverReserveCeilingAnnualContribX | 2.5 | x/cover | REQ-048 (not locked) |
| CoverStandingGateTrusted | 4.0 | x/cover | REQ-049 (locked) |
| CoverStandingGatePreferred | 4.5 | x/cover | REQ-049 (locked) |
| MABIssuanceCeilingAnnualSurplusMultiple | 3 | x/bond | REQ-054 (locked) |
| CoolingSecessionCoverActiveDays | 21 | x/guild | REQ-064 (locked) |
| CoolingSecessionNonCoverDays | 14 | x/guild | REQ-064 (locked) |
| StandPierEscalationAnnualPassVolumeCents | 10000000 | x/stand | REQ-059 (not locked) |
| CoverClaimsVoucherBondMultipleAvgCall | 10 | x/cover | REQ-055 (not locked) |
| AntiCaptureBillOfRightsCount | 13 | x/cover | REQ-056 (locked) |
| ShadowVouchWeightMultiplier | 0.5 | x/standing | REQ-060 (locked) |
| PierCarriesVoice | false | x/guild | REQ-053 / FR-VOICE-6 (locked, D-087) |
+34
View File
@@ -0,0 +1,34 @@
# v0.6 Audit (Nomad Web UI)
## Reconstruction test
- git log ↔ .ciagent/ files: each REQ-040..REQ-045 maps to a shipped UI screen / firewall.
- REQ-040 → P1 (web/handlers/reach.go + 3 Reach templates + POST /reach atomic create)
- REQ-041 → P2 (web/handlers/stash.go + stash.html + Bread-scale conversion)
- REQ-042 → P3 (web/handlers/window.go + 3 Window templates + lifecycle)
- REQ-043 → P4 (web/handlers/standing.go + standing.html + Freeholder signals)
- REQ-044 → P5 (web/handlers/bloom.go + bloom.html + BloomRecord)
- REQ-045 → P1 (lexicon_meta_web/ firewall extension)
- 6 phase branches phase/01-*..phase/06-* created, merged, 5 deleted (06 pending).
- 6 patch tags v0.5.0..v0.5.5 created (v0.5.6 pending = milestone release).
- D-072 ordering respected: firewall-first P1 (REQ-045) before content P2..P5.
## Feature purity gate — GREEN
- **No breaking schema changes**: no x/ module modified (web/ is new app-layer, not an x/ amendment).
- **Locked-const firewall intact**: all v0.1..v0.5 consts unchanged (web/ does not touch x/ consts; it reads them via x/*/types imports — D-070 app-layer consumption).
- **G-003 production firewall intact**: web/ imports only x/*/types (verified by web/store/import_test.go / G-025; no x/*/keeper, no x/*/module imports).
- **G-006 go.mod unchanged**: git diff v0.5.0..HEAD -- go.mod go.sum is EMPTY (G-028 baseline diff). HTMX is a vendored static asset, NOT a Go dep.
## Coverage
- web/store: 98.1% (≥80% target met).
- web/handlers: 89.2% (≥80% target met).
- lexicon_meta_web: 100% (test-only firewall).
## Lexicon firewalls — all 3 GREEN
- lexicon_meta_test.go (v0.2, x/*.go) — green (no regression).
- lexicon_meta_docs_test.go (v0.3, README + docs/**) — green.
- lexicon_meta_web/ (v0.6, web/**/*.{html,js,go}) — green.
## Manual browser check (dynamic port)
- go run ./web on a dynamically-allocated port; all 5 screens reachable; happy path works end-to-end (Create a Reach → Stash dashboard → Open a Window → Standing progress → Bloom accrual). Smoke-tested on ports 47077 (P1) and 53907 (P5).
## Verdict: AUDIT PASS. Feature purity gate GREEN. Milestone ready to ship.
+1350 -1
View File
File diff suppressed because it is too large Load Diff
+62 -111
View File
@@ -3,124 +3,75 @@ active_personas:
- id: backend-engineer
active: true
phase_specific: false
reason: Go/Cosmos module skeletons for v0.2 components — owns the non-Cosmos-mirroring modules (pact, partner, bond) per G-007. Owns the bulk of bespoke-type skeleton + tests work.
frameworks: [Go, Cosmos SDK, IBC, CosmWasm]
territory: ["x/pact/**", "x/partner/**", "x/bond/**", "x/**/types/**", "x/**/keeper/**", "x/**/module.go"]
constraints: [lexicon compliance (REQ-012), skeleton+tests pattern (D-020), ≥80% coverage on new packages (D-033), no live-chain side effects in skeleton, locked-const invariants, no fractional reserve, no leverage/futures, go.mod is read-only in v0.2 (G-006 — any change is an escalation)]
- id: data-engineer
active: true
phase_specific: false
reason: Genesis/state schema design for new modules — Window audit log, Stand membership sets, Bond issuance state, Council Voice tally state. Shapes ValidateGenesis upgrades. Owns genesis SCHEMA only (G-008); test assertions are security-engineer's.
frameworks: [Go, encoding/json, Cosmos SDK state]
territory: ["x/**/types/genesis.go", "x/**/genesis.go"]
constraints: [append-only audit logs (Window), ID-uniqueness in ValidateGenesis, lexicon compliance, no state identity beyond Reach, does NOT own *_test.go files (G-008)]
reason: "Owns the v0.7 runtime across all execution phases — the bulk of the milestone. v0.7 introduces a NEW module `x/cover` (Cover Pool Factory + Anti-Crowding-Out firewall + Anti-Capture Bill of Rights, per D-084) following the D-039 precedent (`x/hub` split from `x/pact` in v0.3). backend-engineer builds the `x/cover` types/ + keeper/ + module.go + expected_keepers.go + msg_server.go + simtest, mirroring the x/hub layout. Also owns EXTENSIONS: `x/bond` (MAB as anonymous-embed extension, mirroring GrowthBond), `x/guild` (ParentGuildID + IsChapter + SecessionTermsHash + GoodStandingLiens), `x/standing` (ShadowVouchWeightMultiplier const + IsShadow field + SlashReasonFraudulentCoverCall const), `x/stand` (Household/Confederation keeper logic — switch on existing StandType, no struct change), `x/pact` (PactCover stays as cross-reference, no change)."
frameworks: [Go 1.22, cosmos-sdk v0.50.x (D-055), ibc-go v8.x, Go testing, simtest, lexicon firewall, locked-const invariant tests]
territory: ["x/cover/**", "x/bond/**", "x/guild/**", "x/standing/**", "x/stand/**", "x/pact/**", "lexicon/**", "lexicon_meta_test.go", "lexicon_meta_docs/**", "lexicon_meta_web/**"]
constraints:
- "G-003 production firewall intact — x/cover references x/standing (StandingKeeper shim), x/watcher (WatcherKeeper shim), x/bond (BondKeeper shim) via expected_keepers.go interfaces; by-ID-string rule at type level; x/pact.PactCover stays as cross-reference (D-084, mirrors x/pact.PactHubAPI ↔ x/hub)"
- "G-006 controlled exception (D-055) — go.mod unchanged in v0.7 (x/cover uses existing cosmos-sdk substrate); target G-028 diff baseline EMPTY"
- "locked-const invariants — v0.7 ADDS consts (CoverReserveFloorAnnualContribX=1.5, CoverStandingGateTrusted=4.0, CoverStandingGatePreferred=4.5, MABIssuanceCeilingAnnualSurplusMultiple=3, CoolingSecessionCoverActiveDays=21, CoolingSecessionNonCoverDays=14, CoverClaimsVoucherBondMultipleAvgCall=10, AntiCaptureBillOfRightsCount=13, ShadowVouchWeightMultiplier=0.5, StandPierEscalationAnnualPassVolumeCents=10000000) but does NOT change existing locked consts"
- "lexicon firewall stays green — Msg* names avoid banned terms (no 'deposit', no 'account', no 'insurance' — use 'Cover', 'Cover-Fee', 'Cover Call', 'Cover-Charter')"
- "simtest NOT mainnet (D-054 continues) — x/cover keeper handlers exercised against in-memory sdk.Context"
- "≥80% coverage on x/cover + extensions (D-033 carries forward)"
- "Anti-Crowding-Out firewall (D-079) — x/cover/firewall subpackage rejects Cover-Fee routing outside contributor-pool semantics"
- "MAB use-of-proceeds (D-080) — tagged streaming + Watcher-witnessed release; auto-Still on misuse"
- id: lead-developer
active: true
phase_specific: false
reason: Multi-component orchestration across 10 new packages, dependency sequencing per D-031 blocker chain, vertical-slice integrity per phase.
frameworks: [cross-cutting]
territory: ["**"]
constraints: [blocked-by chain enforcement (D-031), milestone versioning (v0.2 / tag_base v0.1.x), lexicon gate on merge, persona territory warn-mode enforcement]
- id: cosmos-engineer
active: true
phase_specific: true
reason: v0.2 introduces IBC (satellite), governance (council), capability (window), group (stand/guild), oracle (forex) patterns that map directly to Cosmos SDK modules (x/gov, x/group, x/authz, x/feegrant, x/capability, x/ibc-transfer). Phase-specific to v0.2 execution phases where Cosmos-convention alignment matters for future wiring.
frameworks: [cosmos-sdk, ibc-go, CosmWasm, CometBFT]
territory: ["x/satellite/**", "x/council/**", "x/window/**", "x/stand/**", "x/guild/**", "x/forex/**", "x/bearers/**"]
constraints: [lexicon compliance (REQ-012), no live-chain side effects in skeleton, mirror x/gov TallyResult / x/group DecisionPolicy / x/authz Grant shapes for future wiring, zero external deps in v0.2 skeleton, by-ID-string inter-module references to avoid import cycles (G-003 tested invariant), go.mod is read-only in v0.2 (G-006)]
reason: "Coordinates v0.7 phase decomposition (P1..P6), territory enforcement (warn mode), and the final-phase feature purity gate audit. Owns the D-085 escalation (13th right identification — confidence 0.55; surfaced through normal decision flow before P5). Owns the §7 acceptance 'pen-test ≥1 independent third party' — at full autonomy, runs self-administered adversarial review (ci-griller) and logs as assumption unless PO rules otherwise."
frameworks: [cross-cutting, Gitea Actions, Markdown, YAML, git]
territory: [".ciagent/**", ".gitea/workflows/**", ".ciagent/oy/ARCHITECTURE.md", ".ciagent/oy/ROADMAP.md", ".ciagent/oy/REQUIREMENTS.md"]
constraints:
- "D-082 phase ordering — P1 Cover Factory (foundation+firewall) → P2 Charter/governance/staging → P3 Federation/Household/Confederation → P4 MAB/Voucher/Shadow → P5 Bill of Rights/secession/Pier → P6 final; each phase independently shippable"
- "milestone versioning (v0.7 feature / tag_base v0.6.x); final-phase patch IS the milestone release (D-008)"
- "feature purity gate: zero breaking schema changes; zero locked-const amendments to EXISTING consts; G-003 intact; G-006/G-028 go.mod diff EMPTY"
- "D-085 escalation (13th right) — low-confidence (0.55); surface to PO via normal decision flow before P5"
- "§7 pen-test acceptance — self-administered adversarial review if no external third party; log as assumption"
- id: security-engineer
active: true
phase_specific: true
reason: v0.2 enforces Mission Lock (council), fee/bond coupon clamps (clamp invariants), 9-stand / 4-tier / 6-pact locked-const tests, and Window revoke/expire lifecycle invariants. Phase-specific to v0.2 where invariant/locked-const test density is highest.
frameworks: [Go testing, table-driven tests, invariant tests]
territory: ["x/**/types/**_test.go", "x/**/keeper/**_test.go", "x/**/genesis_test.go", "x/**/*_test.go"]
constraints: [invariant tests for all locked constants (Mission Lock non-amendable, bond 8% cap / 0% floor, fee ceiling/floor), locked-const tests for every enum count (9 stands, 4 partner tiers, 6 pacts), lexicon assertion in every new test file (D-033), ≥80% coverage on new packages, owns ALL *_test.go files including genesis_test.go (G-008)]
phase_specific: false
reason: "REACTIVATED for v0.7 (carried from v0.5). v0.7 has the HIGHEST security-critical density since v0.5: (1) Anti-Capture Bill of Rights v0.2 (REQ-056) — 13 non-amendable, non-waivable rights as const firewall + ValidateBasic gate (mirroring MissionLockAmendable=false + MissionLockAmendmentRejected); (2) Anti-Crowding-Out firewall (D-079) — x/cover/firewall + lexicon_meta_cover meta-test; (3) Cover Claims Voucher slashing (REQ-055) — bond 10× avg Call size, no self-adjudication (FR-CPCV-2), slash via x/standing.Slash cross-Pool; (4) MAB use-of-proceeds lock (D-080) — tagged streaming + Watcher-witnessed release + auto-Still; (5) secession cooling + lien bounding (REQ-064/REQ-081); (6) Cover Pool reserve floor 1.5× (REQ-047) below-floor auto-pause."
frameworks: [Go 1.22, cosmos-sdk v0.50.x, Go testing, simtest, locked-const invariant tests, lexicon firewall]
territory: ["x/cover/types/rights.go", "x/cover/firewall/**", "x/cover/keeper/**", "x/bond/keeper/**", "x/bond/types/types.go", "x/guild/types/types.go", "x/standing/types/types.go", "x/council/types/types.go", "lexicon/**"]
constraints:
- "Bill of Rights = 13 separate RightID consts + 13 Waivable* bool consts (all false) + RightIsWaivable(id) always returns false (dual firewall: const + ValidateBasic gate on Cover-Charter waiver list)"
- "Anti-Crowding-Out firewall = x/cover/firewall subpackage (runtime CheckCoverFeeRouting) + lexicon_meta_cover meta-test (test-time doc-drift rejection) — defense in depth (D-079)"
- "Cover Claims Voucher: CoverClaimsVoucher struct in x/cover/types (NOT x/standing); bond = CoverClaimsVoucherBondMultipleAvgCall=10 × avg Call size; slash via x/standing.Slash with SlashReasonFraudulentCoverCall const; cross-Pool via Standing bucket drop"
- "MAB coupons NEVER Bread — CouponDenom enum with CouponDenomBread rejected at ValidateBasic (MissionLockAmendmentRejected pattern)"
- "Secession cooling consts secured at founding, not reducible (REQ-064 locked); GoodStandingLiens SecuredAtFounding=true not freely increasable (REQ-053/REQ-081)"
deactivated:
- id: cosmos-engineer
active: true
phase_specific: false
reason: "Advisory-density for v0.7. The `x/cover` module is new but follows the established x/hub D-039 pattern (types/ + keeper/ + module.go + expected_keepers.go + msg_server.go + simtest). The MsgServer promotion pattern is established (v0.5). cosmos-engineer reviews the x/cover AppModule wiring, RegisterServices, MsgServer() accessor, and the expected_keepers.go interface shims (StandingKeeper, WatcherKeeper, BondKeeper) for G-003 compliance. Less novel than v0.5 (where cosmos-sdk was first introduced)."
frameworks: [cosmos-sdk v0.50.x, ibc-go v8.x, Go testing, simtest]
territory: ["x/cover/keeper/**", "x/cover/types/msg_*.go", "x/cover/types/expected_keepers.go", "x/cover/module.go"]
constraints:
- "x/cover module follows x/hub layout (D-039 precedent): module.go AppModule + RegisterServices + MsgServer() accessor"
- "expected_keepers.go interfaces for cross-module keeper access (G-003): StandingKeeper.GetStandingBucket, WatcherKeeper.Attest, BondKeeper.GetBond"
- "Msg* structs implement sdk.Msg; ValidateBasic on each (cover-firewall, category-tag, standing-gate, reserve-floor, MAB-ceiling, rights-waiver-rejection)"
- "simtest pattern: msg_server_simtest_test.go exercising handlers against in-memory sdk.Context (x/hub/keeper/msg_server_simtest_test.go precedent)"
deactivated_personas:
- id: frontend-engineer
reason: No UI in v0.2 (skeleton+tests only; Mesh Experience UI is v0.1-complete and v0.3+ for new UI). Deactivate to avoid persona territory noise. Reactivate in v0.3.
- id: ci-security-auditor
reason: Default deactivated; activate in P5 (review/ship) phase for the milestone audit. Not needed during P1-P4 skeleton authoring.
active: false
phase_specific: false
reason: "v0.7 is protocol-heavy, zero UI. The v0.6 web UI (web/) is complete; v0.7 does not touch web/. No frontend work in REQ-046..REQ-066."
- id: docs-writer
active: false
phase_specific: false
reason: "No docs-content work in v0.7. The only docs work is ARCHITECTURE.md v0.7 section + PERSONAS.md + RESEARCH.md, which is lead-developer territory."
- id: mesh-engineer
reason: v0.1 listed as future; still not needed in v0.2 (Bearers OY-LR + Beacon are type stubs only, no hardware/RF). Activate in v0.3 for real bearer runtime.
custom_personas:
- id: cosmos-engineer
rationale: v0.2 components map onto specific Cosmos SDK modules (x/gov, x/group, x/authz, x/feegrant, x/capability, x/ibc-transfer). A dedicated persona ensures skeleton types mirror the eventual runtime shapes, reducing Phase 3 wiring refactor cost. Distinct from backend-engineer because it carries Cosmos-specific convention knowledge (tally shapes, decision policy, capability, ICS-20 packet shape).
- id: security-engineer
rationale: v0.2 has the highest locked-const + invariant density in the project (Mission Lock, bond cap, 6 Pact types, 9 Stand types, 4 Partner tiers, Window revoke idempotency). A dedicated persona ensures invariant tests and lexicon assertions are not an afterthought. Distinct from backend-engineer because it owns test-file territory and invariant-first design.
---
# Personas: OpenYield (oy) — v0.2 (The Mesh)
## Active Roster
### backend-engineer
- **Domain**: Go/Cosmos module skeletons for v0.2 — owns the **non-Cosmos-mirroring** modules (pact, partner, bond) per G-007, plus shared `types/`+`keeper/`+`module.go` authoring.
- **Frameworks**: Go, Cosmos SDK, IBC, CosmWasm.
- **Territory**: `x/pact/**`, `x/partner/**`, `x/bond/**`, `x/**/types/**`, `x/**/keeper/**`, `x/**/module.go`. (`go.mod` is read-only in v0.2 per G-006.)
- **Constraints**: lexicon compliance (REQ-012), skeleton+tests pattern (D-020), ≥80% coverage on new packages (D-033), no live-chain side effects in skeleton, locked-const invariants, no fractional reserve, no leverage/futures.
### data-engineer
- **Domain**: Genesis/state schema design — Window audit log, Stand membership sets, Bond issuance state, Council Voice tally state. Shapes `ValidateGenesis` upgrades (v0.1's no-op → v0.2 ID-uniqueness checks). Owns genesis **schema** only (G-008); genesis **test assertions** are security-engineer's.
- **Frameworks**: Go, `encoding/json`, Cosmos SDK state.
- **Territory**: `x/**/types/genesis.go`, `x/**/genesis.go` (excludes `*_test.go` per G-008).
- **Constraints**: append-only audit logs (Window), ID-uniqueness in `ValidateGenesis`, lexicon compliance, no state identity beyond Reach.
### lead-developer
- **Domain**: Multi-component orchestration across 10 new packages, dependency sequencing per D-031 blocker chain, vertical-slice integrity per phase.
- **Frameworks**: cross-cutting.
- **Territory**: `**`.
- **Constraints**: blocked-by chain enforcement (D-031), milestone versioning (v0.2 / tag_base v0.1.x), lexicon gate on merge, persona territory warn-mode enforcement.
### cosmos-engineer (custom, phase-specific to v0.2)
- **Domain**: v0.2 components map onto Cosmos SDK modules — `x/gov` (council tally), `x/group` (stand/guild decision policy), `x/authz`/`x/feegrant` (window), `x/capability` (window unforgeable ref), `x/ibc-transfer` ICS-20 (satellite). Ensures skeleton types mirror runtime shapes for low-friction Phase 3 wiring.
- **Frameworks**: cosmos-sdk, ibc-go, CosmWasm, CometBFT.
- **Territory**: `x/satellite/**`, `x/council/**`, `x/window/**`, `x/stand/**`, `x/guild/**`, `x/forex/**`, `x/bearers/**` (Cosmos-convention-mirroring modules per G-007; `x/pact`/`x/partner`/`x/bond` are backend-engineer's). `go.mod` read-only per G-006.
- **Constraints**: lexicon compliance (REQ-012), no live-chain side effects in skeleton, mirror `x/gov` `TallyResult` / `x/group` `DecisionPolicy` / `x/authz` `Grant` shapes, zero external deps in v0.2 skeleton, by-ID-string inter-module references (G-003 tested invariant).
### security-engineer (custom, phase-specific to v0.2)
- **Domain**: Invariant + locked-const test authorship. Owns **all** test-file territory across v0.2 packages (including `genesis_test.go` per G-008). Highest invariant density in the project: Mission Lock non-amendable, bond 8% cap / 0% floor clamp, fee ceiling/floor clamp, 6 Pact types, 9 Stand types, 4 Partner tiers, Window revoke/expire idempotency, lexicon assertion per module.
- **Frameworks**: Go testing, table-driven tests, invariant tests.
- **Territory**: `x/**/types/**_test.go`, `x/**/keeper/**_test.go`, `x/**/genesis_test.go`, `x/**/*_test.go` (all test files per G-008).
- **Constraints**: invariant tests for all locked constants, locked-const tests for every enum count (9 stands, 4 partner tiers, 6 pacts, 3 councils), lexicon assertion in every new test file (D-033), ≥80% coverage on new packages, Window lifecycle idempotency tests (revoke-after-expire, double-revoke).
## Deactivated
- **frontend-engineer** — No UI in v0.2 (skeleton+tests only). Mesh Experience UI is v0.1-complete; new UI is v0.3+. Reactivate in v0.3.
- **ci-security-auditor** — Default deactivated; activate in P5 (review/ship) for milestone audit.
- **mesh-engineer** — v0.1 listed as future; still not needed in v0.2 (Bearers OY-LR + Beacon are type stubs only). Activate in v0.3 for real bearer runtime.
## Custom Personas
- **cosmos-engineer** — v0.2 components map onto specific Cosmos SDK modules. Dedicated persona ensures skeleton types mirror eventual runtime shapes, reducing Phase 3 wiring refactor cost. Distinct from backend-engineer: carries Cosmos-specific convention knowledge (tally shapes, decision policy, capability, ICS-20 packet shape).
- **security-engineer** — v0.2 has the highest locked-const + invariant density in the project. Dedicated persona ensures invariant tests and lexicon assertions are not an afterthought. Distinct from backend-engineer: owns test-file territory and invariant-first design.
## Framework Alignment
- **Go 1.22** — all personas target Go 1.22 (`go.mod`).
- **cosmos-sdk** — cosmos-engineer targets cosmos-sdk v0.50.x (LTS) for future wiring; NOT vendored in v0.2 skeleton.
- **ibc-go** — cosmos-engineer targets ibc-go v8/v10 for satellite module shape; NOT vendored in v0.2 skeleton.
- **CosmWasm** — cosmos-engineer targets wasmvm v1.5/v2.0 for potential Pacts-as-contracts in Phase 3; NOT vendored in v0.2.
## Territory Alignment
- Mapped to actual `x/<name>/` structure (15 v0.1 modules + 9 new v0.2 modules + 1 extended).
- backend-engineer owns `x/pact`, `x/partner`, `x/bond` (non-Cosmos-mirroring per G-007) + shared `types/`+`keeper/`+`module.go` authoring.
- data-engineer owns `genesis.go` schema only (G-008 — excludes `*_test.go`).
- cosmos-engineer owns the Cosmos-convention-mirroring modules (`x/satellite`, `x/council`, `x/window`, `x/stand`, `x/guild`, `x/forex`) + `x/bearers` extension (G-007).
- security-engineer owns **all** `*_test.go` files across the new packages (G-008 — including `genesis_test.go`).
- lead-developer owns `**` for cross-cutting coordination.
- `go.mod` is **read-only** in v0.2 (G-006) — no persona may modify it; any change is an escalation (would violate D-020/A-201 zero-dep invariant).
## Constraint Alignment
- **Lexicon (REQ-012)** — every persona carries it; security-engineer asserts it per test file.
- **Skeleton + tests pattern (D-020)** — backend-engineer + cosmos-engineer enforce.
- **≥80% coverage on new packages (D-033)** — security-engineer owns the gate.
- **Blocker chain (D-031)** — lead-developer enforces phase ordering.
- **No live-chain side effects in skeleton** — cosmos-engineer + backend-engineer enforce (no relayer, no CometBFT, no live oracle).
- **Locked-const invariants** — security-engineer owns; every locked constant has a dedicated test.
## Phase-Specific Personas
- **cosmos-engineer** — phase-specific to v0.2 execution phases (P1-P4). Remove or merge back into backend-engineer after v0.2 ships (Cosmos convention alignment is most critical during the first Mesh-era skeleton).
- **security-engineer** — phase-specific to v0.2 (highest invariant density). May persist into v0.3 if invariant-test density remains high; reassess at v0.3 PLAN.
active: false
phase_specific: false
reason: "No bearer transport work in v0.7. The bearer runtime shipped in v0.5 and is untouched. Cover Pools are a protocol/financial surface, not a bearer/transport surface."
- id: data-engineer
active: false
phase_specific: false
reason: "No genesis-schema or custody-state work in v0.7. x/cover uses the SDK in-memory store pattern from v0.5; no new data-shape work."
- id: ci-security-auditor
active: false
phase_specific: true
reason: "Default off; activates in P6 (final review/audit/ship) for the feature purity gate + the §7 acceptance pen-test (self-administered adversarial review)."
+2065 -1
View File
File diff suppressed because it is too large Load Diff
+373 -23
View File
@@ -1,3 +1,4 @@
<!-- Auto-generated from .ciagent/oy/oy-spec — PO edits oy-spec, not this file; see oy-state for current shipped state. -->
# Project: OpenYield (oy)
## Objective
@@ -61,33 +62,280 @@ 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.2The Mesh (active milestone; feature type; tags run on the v0.1.x patch line)
v0.7Fraternal Groups Foundation (in progress; feature type; tags run on the v0.6.x patch line)
### v0.2 Scope (The Mesh — ROADMAP Phase 2)
Target: $1B annual volume, 4 service categories. Implements the pending Mesh-era requirements:
- **REQ-009** Satellite chains (Layer 2) — wrapped Bread, Pass-Act propagation on Polygon/Base/Arbitrum/Optimism/Solana [§7]
- **REQ-011** Three Councils (Mesh, Guild, Stand) with Mission Lock — multi-source Voice [§19]
- **REQ-015** Window primitive — scope, duration, rate-limit, audit log, revoke [§10]
- **REQ-016** Nine Stand types (Household, Crew, Entity, Co-op, Circle, Trust, Foundation, Confederation, Shadow) [§11]
- **REQ-017** Guilds with Hand-Passes at 0% protocol fee [§12]
- **REQ-018** Four-tier Partner Spectrum (Op, Master Op, Pier, Anchor) [§13]
- **REQ-020** Six Pacts (Pause, Ground, Stance, Cover, Stand Registry, Hub API) [§16]
- **REQ-021** Mesh Bond Market with 8% upper coupon cap, 0% floor [§17]
- Bearers expansion: OY-LR + Beacon v1
- Forex Engine v1
### v0.7 Scope (Fraternal Groups Foundation — Cover Pools + Chapter Federation + Mutual Aid Bonds + Anti-Capture Bill v0.2)
v0.7 adapts the 18901930 fraternal benefit-society model for borderless
digital service. It delivers Cover Pools (insurance-like commitment pools
with mission-locked reserve floors + Standing gates), Chapter Federation
(Parent/Chapter Guild model with secession terms + good-standing liens
declared at founding), Mutual Aid Bonds (Cover-Call-couponed bonds with
issuance ceiling 3× annual surplus, use-of-proceeds locked to reserve
build-out), and the Anti-Capture Bill of Rights v0.2 (13 rights codified,
non-amendable, non-waivable by any Charter). This is the milestone that
unblocks the v0.1 Q7 "Cover Pool seniority mechanics" deferred item —
REQ-046..REQ-050 supply the seniority/gate math and promote `x/pact` Cover
from skeleton to a dedicated `x/cover` module runtime (D-039 precedent:
`x/hub` split out of `x/pact`'s `PactHubAPI` in v0.3).
Simtest-grade runtime (D-020 pattern continues — no live chain launch, no
mainnet). Cover Pool "live on testnet" (§7 acceptance) = `x/cover` keeper
message handlers + simtest-grade end-to-end flows, not mainnet deployment.
No `app.go`/`cmd/oyd` exists in the repo; v0.7 does not create one.
New module: `x/cover` (Cover Pool Factory + Anti-Crowding-Out firewall +
Anti-Capture Bill of Rights). The existing `x/pact` `PactCover` enum value
remains as a cross-reference (G-003 by-ID-string pattern).
- **REQ-046** Cover Pool Factory runtime — Factory rejects category launches below in-force reserve floor; supports Cover-Charter deployment; Watcher attestation pipeline operational; category staging per REQ-065.
- **REQ-047** Cover Pool reserve target floor 1.5× annual contributions — LOCKED; mission-lock semantic enforced; below-floor auto-pause of Cover-Fee routing.
- **REQ-048** Cover Pool reserve target ceiling 2.5× (governance-tunable within 1.5×–2.5×) — Watcher escalation after 12 months; Pool Council MAY vote within bounded range.
- **REQ-049** Cover Pool Standing gate minimums — LOCKED; Travel ≥ Trusted 4.0; Health-MCS ≥ Preferred 4.5; Pool MAY tighten but NEVER loosen below protocol minimum. Binds at Factory runtime (D-077).
- **REQ-050** Cover-Fee tagging at protocol layer — LOCKED; Cover-Fee Grains carry `category_tag`; settlement rejects category-mismatched Calls (FR-COVER-11); Pool-level fungibility preserved for net-reserve accounting.
- **REQ-051** Guild Charter + Common Bond requirement — LOCKED; at formation: Common Bond declared + hash-pinned; Public Profile published (bond summary, disclaimers, Mason count or "private", Pier wrapper if any).
- **REQ-052** Cover-Charter (SoB, dispute path, gate, holding period) — LOCKED; distinct from governance charter; signed by Pool Host + witnessed by Watcher at deployment; amendments require Pool supermajority + 7-day cooling + Watcher + Counsel; protocol does NOT enforce SoB content (FR-CHTR-5).
- **REQ-053** Chapter Federation (Parent/Chapter, secession terms, liens at founding) — Parent Guild + Chapters; Chapters inherit + may tighten but not loosen; secession terms coded at founding; good-standing liens at founding (not freely increasable); Chapter retains mesh-level Voice (Pier does NOT carry Voice per FR-VOICE-6).
- **REQ-054** Mutual Aid Bond (issuance ceiling 1×–3×, coupons in Cover Calls) — LOCKED; issuance ceiling mission-locked at 3× annual surplus; coupons payable in Cover Calls or mutual-aid credits (NEVER Bread); coupon rate bounded by `CouponCapBps=800`; use-of-proceeds locked to reserve build-out; default recapture per FR-MAB-7; Watcher attestation at deployment + quarterly audit. Enforcement: tagged streaming + Watcher-witnessed release (D-080, defense in depth).
- **REQ-055** Cover Claims Voucher role + bond + slashing — Specialization of Voucher role; bond default 10× avg Call size per Pool; reviews each Call independently (no self-adjudication, FR-CPCV-2); slashing via §9.4 mechanism with cross-Pool applicability (NFR-SEC-8); bounded earnings.
- **REQ-056** Anti-Capture Bill of Rights v0.2 — LOCKED; 13 rights codified in code; cannot be amended or waived by any Charter; covers one-tap exit, no tax on personal Stash, audit-able Voice, cooling, Watcher inspection, Freeholder voucher, Counsel escalation, Anchored-Bread conversion, Wayfarer's Record, secession (founding terms), non-Cover-access, category-mismatch refusal.
- **REQ-057** Household simplified — no formal Council, one-tap exit — Household Stand may operate without formal Council; one-tap exit is the dispute path.
- **REQ-058** Confederation Voice — one-Stand-one-Vote, internal bundle — LOCKED; Confederation aggregates member Stand Voice one-per-Stand; member Stands may bundle delegated Voice internally via §19 delegation.
- **REQ-059** Stand→Pier-customer boundary — escalation rule ($100k per D-074) — When annual Pass volume > $100k, Stand is invited to Hub API; soft upgrade, not a ban.
- **REQ-060** Shadow vouch partial credit — 50% weight in Freeholder signal — LOCKED; Shadow vouch weight = 0.5× in Community Endorsement signal (vs 1.0× for non-Shadow vouch).
- **REQ-061** Disclaimer cadence — per charter signing — LOCKED; jurisdictional disclaimer surfaced at every charter signing; not session-bounded.
- **REQ-062** Pool governance hybrid (Host + 3 elected + Watcher observer) — LOCKED; Cover Pool Council = Pool Host + 3 Masons elected by Pool-eligible Masons + Watcher observer seat; Cover Calls require majority with Watcher observer present. No Anchor seat (Anchor no-Voice §5).
- **REQ-063** MAB holder — surplus seniority only, no Voice at dissolution — LOCKED; Mutual Aid Bond holders rank after Cover-Fee contributors but before Bread holders in Pool-surplus distributions (FR-MAB-4); NO Voice in Pool dissolution decisions (claimants, not Masons).
- **REQ-064** Secession cooling — 21d Cover-active / 14d non-Cover — LOCKED; Chapter secession cooling: 21 Mesh-days if Cover-active, 14 Mesh-days if non-Cover; secured at founding, not reducible; lien audit required; Cover Call / Bond covenant clearance required before secession completes.
- **REQ-065** Cover Pool category staging — Phase 2/3/4 — LOCKED; Phase 2: Travel + Health-MCS + Income-Pause; Phase 3: Equipment/Loss + Life-Burial + Road-Side; Phase 4: Cyber-Skimming + Guild-Internal-Mutual-Aid; Factory respects staging and rejects out-of-phase launches.
- **REQ-066** Pier selection — Guild Council chooses, reversible, Pier Selection Index — Guild Council chooses Pier at formation; reversible by Cover Pool supermajority + Counsel witness; mesh maintains Pier Selection Index; Pier-Routed Legal Wrapper OPTIONAL (§5 default-no-wrapper).
### Milestone Type
Feature (at least one `feat` phase). Phase 0 → `v0.1.0`; execution phases `v0.1.1..v0.1.N`; final phase patch IS the milestone release. No separate minor tag.
Feature (REQ-046..REQ-066 are feat-class primitives + test adjuncts for the firewall). Phase 0 → `v0.6.0`; execution phases `v0.6.1..v0.6.5`; final phase patch `v0.6.6` IS the v0.7 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 — `x/cover` keeper uses existing cosmos-sdk runtime substrate).
### Out of Scope (v0.2)
- Cross-chain exit / DEX integration (Phase 3 / v0.3)
- OY-SAT, OY-QR bearers (Phase 3)
- Full Hub API B2B suite (Phase 3)
- Yield Token, Travel + 11 service categories (Phase 4)
- Full Mesh Bond market depth (Phase 3+; v0.2 ships first Mesh Bonds only)
### Out of Scope (v0.7)
- Real blockchain interaction / mainnet / IBC / real bearer transports (D-020 continues; runtime = simtest-grade keeper handlers)
- A real `oyd` daemon / `app.go` / `cmd/oyd` (no chain runtime exists; deferred to v0.8+)
- Sovereign Anchor SPEC (`oy-sovereign-anchors` forthcoming; experimental, not load-bearing per §5/D-076)
- USZ classification runtime (v0.8 — depends on Anchor pre-commitment framework, REQ-095)
- Cluster AE + Infrastructure Economics (REQ-067..REQ-097, all v0.8 per D-081)
- Pier-Routed Legal Wrapper (OPTIONAL per §5; default-no-wrapper; not implemented as code)
- Authentication / sessions / real key management (mock; deferred to v0.8+)
- Persistence (mock store; deferred to v0.8+)
- The 5 P1+ mainnet-readiness items deferred from v0.5 (governance spam deposit, CLOB batch auction, real IBC simtest, CLOB perf, emitMatchEventHook) — those are v0.8+ mainnet-readiness
- SignalKind 4→5 expansion (deferred to v0.8+ governance vote)
### Prior Milestone
v0.1 — OpenYield Foundation Init (COMPLETE; pre-MVP foundation skeleton; released as v0.0.9 per run.md patch-line model)
### 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)
- v0.6 — Nomad Web UI (COMPLETE; feature; released as v0.5.6)
## Prior Milestone
v0.6 — Nomad Web UI (complete; feature type; tags ran 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)
### 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)
v0.5 promotes the v0.3 Bearers skeletons from type+keeper-stub layers to live
runtime behavior. This is the first milestone to ship executable behavior
beyond invariant tests — keepers gain message handlers, transactions, and
end-to-end flows. Sourced from the v0.3/v0.4 deferred items (D-050,
PROJECT.md v0.4 out-of-scope, ROADMAP Phase 3 "The Bearers" runtime subset).
The skeleton-first pattern (D-020) continues to govern NEW components, but
v0.3-era modules (`x/exit`, `x/bridge`, `x/bearers`, `x/partner`, `x/hub`,
`x/services`, `x/bond`) gain runtime implementations this milestone. No live
chain launch (D-020 continues to apply to network deployment); runtime here
means keeper message handlers + simtest-grade end-to-end flows, not mainnet.
- **REQ-033** Exit layer runtime — `x/exit` DEX swap routing + bridge message handlers; `x/bridge` L2↔L1 IBC packet handlers. Promotes REQ-010 from skeleton → runtime. Live DEX/IBC channels still deferred.
- **REQ-034** Bearers transport runtime — OY-SAT + OY-QR bearer transport message handlers in `x/bearers` (extends REQ-019). Hardware integration deferred; runtime = message-handling + session lifecycle in simtest.
- **REQ-035** Anchors onboarding runtime — `x/partner` Anchor tier credential issuance + revocation handlers (extends REQ-018). Real institutional onboarding deferred; runtime = credential lifecycle in simtest.
- **REQ-036** Hub API B2B runtime — `x/hub` custody, lending primitive, compliance message handlers. Real B2B suite deferred; runtime = keeper handlers + simtest.
- **REQ-037** Services runtime — `x/services` Care / SIM / Vault / Mail service lifecycle handlers. Live service integrations deferred; runtime = lifecycle handlers + simtest.
- **REQ-038** Bond market depth runtime — `x/bond` Growth Bonds + secondary-market matching handlers (extends REQ-021). Live market depth deferred; runtime = matching engine + simtest.
- **REQ-039** Council governance runtime — `x/council` Proposal/VoteOption enum types (AUDIT §193 P1-1, deferred from v0.4) + Voice lifecycle handlers. Mission Lock const firewall intact (G-003); runtime = governance message handlers + simtest.
### Milestone Type
Feature (all execution phases are `feat`). Phase 0 → `v0.4.0`; execution phases `v0.4.1..v0.4.N`; final phase patch `v0.4.(N+1)` IS the v0.5 milestone release. No separate minor tag. The final-phase audit enforces the feature purity gate (no breaking schema changes; locked-const firewall intact).
### Out of Scope (v0.5)
- Live chain launch / mainnet / real IBC channels / real bearer transports (D-020 pattern continues; runtime = simtest-grade message handlers)
- Real institutional Anchors onboarding (credential lifecycle in simtest only)
- Yield Token, Travel + 11 service categories (ROADMAP Phase 4 — Maturity)
- i18n / MkDocs internationalization
- Cover Pool seniority mechanics (still deferred per PROJECT.md Q7)
- Breaking schema changes / locked-const amendments (Mission Lock non-amendable)
- SignalKind 4→5 enum expansion (AUDIT §193 P1-2; defensible per current rationale, deferred to v0.6+ governance vote)
### 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)
## Prior Milestone
v0.4 — Refinement (complete; NFR type; tags ran on the v0.3.x patch line)
### v0.4 Scope (Refinement-only NFR — v0.3 post-hoc forward-references)
v0.4 is a refinement-only NFR milestone: zero `feat:` phases. It lands the
durability fixes v0.3 flagged but did not block on, sourced from REVIEW.md,
AUDIT.md §193, and GRILL.md G-014. Live-runtime promotions of the v0.3 Bearers
skeletons are out of scope (deferred to v0.5+).
- **REQ-029** Lexicon firewall shared helper (`lexicon.SyntheticBannedStrings()`) — dedupe the synthetic self-test table between `lexicon_meta_test.go` and `lexicon_meta_docs_test.go`. Both meta-tests derive count + strings from the single `lexicon` package source, so a future banned-term addition updates both firewalls from one place. (GRILL G-014)
- **REQ-030** Cross-package const-equality test — `x/hub.LendingCouponCapBps == x/bond.CouponCapBps` (and Floor). Test-only import (G-003 exempt). Catches silent mission-lock drift between hub LOCAL consts and bond D-028 consts. (REVIEW.md P2 / A-304)
- **REQ-031** x/* lifecycle type shape-divergence review + alignment fixes — audit non-must-have lifecycle types across modules flagged by AUDIT §193; align where divergent without behavioral change. (AUDIT.md §193)
- **REQ-032** Docs build CI — Gitea Actions workflow running `go test ./...` (lexicon firewall) + `mkdocs build` on every push; upload `site/` as a CI artifact. Full Gitea Pages publishing deferred if no hosting target configured. (D-046)
### Milestone Type
NFR (all phases are refactor/test/quality/chore). Phase 0 → `v0.3.0`; execution phases `v0.3.1..v0.3.3`; final phase patch `v0.3.4` IS the milestone release. No separate minor tag. The final-phase audit enforces the NFR purity gate (zero `feat:` commits).
### Out of Scope (v0.4)
- Live-runtime promotions: Exit/DEX, OY-SAT/OY-QR hardware, Hub API B2B, bond matching, L2 IBC rollout, Anchors onboarding (all `feat:`, deferred to v0.5+)
- i18n / MkDocs internationalization (`feat:`, rejected by D-001 filter)
- Yield Token, Travel + 11 service categories (ROADMAP Phase 4)
- Cover Pool seniority mechanics (still deferred per PROJECT.md Q7)
### 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.3 Scope (Bearers skeleton + Docs site — ROADMAP Phase 3 partial, plus a docs deliverable)
This milestone bundles two parallel work-streams under one feature milestone:
**(A) Bearers skeleton (D-020 pattern continued)** — implements the v0.1 PROJECT.md
out-of-scope items now promoted to v0.3 (ROADMAP Phase 3 "The Bearers" subset),
as skeleton + tests (Go types + keeper stubs + invariant tests; no live chain):
- **REQ-010** Exit layer (Layer 3) — DEX swaps, bridges, off-mesh services (§7). Promoted from Skeleton to a fuller skeleton: `x/exit` (exit-route types) + `x/bridge` (L2↔L1 bridge types). Live runtime deferred to v0.4.
- **Bearers expansion** — OY-SAT (satellite) + OY-QR bearer transport types, extending `x/bearers` (D-029 pattern). Hardware integration deferred.
- **Anchors** — first institutional Partner tier (`x/partner` extension: Anchor credential types). REQ-018 promoted from Skeleton → fuller skeleton.
- **Hub API** — B2B backbone: custody, lending primitive, compliance types (`x/hub`). Full B2B suite deferred to v0.4.
- **Services** — Care / SIM / Vault / Mail service types (`x/services`). Live services deferred.
- **Bond market depth** — Growth Bonds + secondary-market types, extending `x/bond` (REQ-021 promoted from Skeleton → fuller skeleton). Full market depth deferred.
**(B) Documentation deliverable** — README.md + docs site in `docs/` for nomads and freeholders:
- Repo-root `README.md` (lexicon-clean project overview).
- MkDocs Material site (`mkdocs.yml` + `docs/`), organized by audience:
- `docs/nomads/` — Reach path, Stash, bearers, Maps/Pay, six Pacts, standing basics.
- `docs/freeholders/` — Four Freeholder signals, Bayesian Standing, Stands/Guilds, Councils/Voice, Bonds, Partner spectrum.
- `docs/shared/` — Six Principles, Bread Scale, Storage pools, Watchers/Mirror, Lexicon glossary, Vision overview.
- `docs/reference/` — architecture index, component map.
- **REQ-012 firewall extension** — extend the lexicon meta-test to scan `README.md` + `docs/**/*.md` (new sibling `lexicon_meta_docs_test.go`), so the docs site is durably lexicon-clean. This is a `feat/test` phase.
### Milestone Type
Feature (Bearers phases are feat; docs phases are docs/test). Phase 0 → `v0.2.0`; execution phases `v0.2.1..v0.2.5`; final phase patch `v0.2.6` IS the milestone release. No separate minor tag.
### Out of Scope (v0.3)
- Live chain launch / real IBC channels / real bearer transports (D-020 pattern continues)
- DEX integration runtime, full Hub API B2B suite runtime (types only in v0.3)
- Yield Token, Travel + 11 service categories (ROADMAP Phase 4)
- i18n / versioning in MkDocs (single-language v0.3)
- Cover Pool seniority mechanics (still deferred per PROJECT.md Q7)
### 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)
## Clarification Decisions (Phase 0 — CLARIFY, autonomy=full)
@@ -108,4 +356,106 @@ Auto-decided defaults logged per clarify workflow Step 4 (full autonomy → acce
| D-030 | **Forex Engine v1**: Forex pair type + rate-oracle interface + stub keeper; no live oracle integration | Live oracle integration depends on external partners (Piers), Phase 3. | 0.80 | [live oracle integration] |
| D-031 | **Phase ordering** follows ARCHITECTURE.md blocker chain: P1 Orgs+Window foundation → P2 Pacts+Partners → P3 Councils+Forex → P4 Bonds+Bearers+L2. The final phase (P5) is review/ship. | Respects dependency graph; vertical slices keep each phase independently shippable. | 0.80 | [different wave ordering] |
| D-032 | **Lexicon** enforced project-wide; all new modules must pass the lexicon assertion test (no banned terms). Non-negotiable. **Note (G-002)**: lexicon assertion tests are NEW in v0.2 — v0.1 is lexicon-clean in practice but has NO lexicon test firewall. v0.2 introduces the firewall (scaffolded in P1 per G-004, extended in P5). | REQ-012 is `All` phases. | 1.00 | [—] |
| D-033 | **Test coverage target**: ≥80% on new keeper/type packages. v0.1 baseline = **53 tests across 11 test files** (corrected per G-001; not 48). Add lexicon assertion to each new module's test file. | Consistency with v0.1 quality bar (53 tests verified); lexicon drift is the highest-severity regression. | 0.85 | [lower coverage bar] |
| D-033 | **Test coverage target**: ≥80% on new keeper/type packages. v0.1 baseline = **53 tests across 11 test files** (corrected per G-001; not 48). Add lexicon assertion to each new module's test file. | Consistency with v0.1 quality bar (53 tests verified); lexicon drift is the highest-severity regression. | 0.85 | [lower coverage bar] |
### v0.3 Clarification Decisions (Phase 0 — CLARIFY, autonomy=full)
Auto-decided defaults logged per clarify workflow Step 4 (full autonomy → accept defaults, log decisions).
| ID | Decision | Rationale | Confidence | Alternatives |
|----|----------|-----------|------------|--------------|
| D-034 | **v0.3 milestone bundles Bearers skeleton (D-020 pattern) + docs deliverable** under one feature milestone, rather than two separate NFR+feature milestones. Bearers phases are `feat`; docs phases are `docs`/`test`. Tags run on `v0.2.x`. | User request (--ideate) is docs-only but ROADMAP Phase 3 (Bearers) is the next queued feature work; bundling keeps the milestone cadence and avoids an NFR-only milestone that would not advance the protocol. Feature type because Bearers phases are feat. | 0.82 | [separate v0.3 docs NFR + v0.4 Bearers feature; or docs as patches on v0.2 line] |
| D-035 | **Bearers skeleton continues the D-020 skeleton+tests pattern** (Go types + keeper stubs + invariant tests; no live chain, no real IBC channels, no real bearer transports). Live runtime for any Bearers component deferred to v0.4+. | v0.1/v0.2 both shipped skeleton-first; v0.3 stays consistent. Live runtime needs Watchers + Root Basket backing (Year 3 target). | 0.85 | [fuller keeper implementations in v0.3] |
| D-036 | **REQ-010 Exit layer**: skeleton = `x/exit` (ExitRoute, DEXSwap types) + `x/bridge` (L2↔L1 bridge types, BridgeStatus enum). No live DEX integration. REQ-010 promoted from v0.1 Skeleton → v0.3 fuller skeleton (two packages instead of one). | Exit runtime needs Anchor partners + L2 bridges; v0.3 lands the typed shape. | 0.80 | [single x/exit package, defer all exit to v0.4] |
| D-037 | **REQ-022 Bearers OY-SAT + OY-QR**: extend `x/bearers/types` with `OYSAT` + `OYQR` bearer transport types (BearerTransport interface already in v0.2). No hardware/RF runtime. D-029 pattern continued. | Hardware integration is not a software deliverable; v0.3 completes the 6-bearer type set (v0.2 had 4: Internet/OY-BLE/OY-WiFi-Direct + OY-LR/Beacon). | 0.82 | [real bearer runtime, defer OY-SAT/OY-QR to v0.4] |
| D-038 | **REQ-023 Anchors**: extend `x/partner/types` with `Anchor` tier credential types (REQ-018 had the 4-tier enum; v0.3 adds Anchor-specific credential fields). No live institutional onboarding. | Anchors need Watchers + Hub API backing; v0.3 lands the credential shape. | 0.78 | [separate x/anchor module, defer Anchors to v0.4] |
| D-039 | **REQ-024 Hub API**: new `x/hub` module — custody, lending-primitive, compliance type stubs (HubService enum + per-service structs). No live B2B runtime. Full Hub API B2B suite deferred to v0.4. | Hub API needs Anchors + Watchers; v0.3 lands the typed scaffold. | 0.80 | [full Hub API runtime in v0.3] |
| D-040 | **REQ-025 Services**: new `x/services` module — Care/SIM/Vault/Mail service type stubs (ServiceKind enum + per-service structs). No live services. | Services are operational, not protocol-level; v0.3 lands the typed shape. | 0.78 | [full services runtime in v0.3] |
| D-041 | **REQ-026 Bond market depth**: extend `x/bond/types` with GrowthBond type + secondary-market order types. 8% cap / 0% floor consts (D-028) unchanged. Full secondary-market matching deferred to v0.4. | v0.2 shipped first issuance; v0.3 adds depth types without a live matching engine. | 0.80 | [full bond market in v0.3] |
| D-042 | **Docs deliverable (REQ-027)**: repo-root `README.md` + MkDocs Material site (`mkdocs.yml` + `docs/`). `mkdocs.yml` at repo root; `docs/` organized by audience: `docs/nomads/`, `docs/freeholders/`, `docs/shared/`, `docs/reference/`. Build-only Python dep (mkdocs + material); `go.mod` stays zero-dep. | User chose MkDocs Material + audience organization. MkDocs is Markdown-native, lightest toolchain; build-only dep does not affect Go modules (G-006). | 0.85 | [Hugo, Docusaurus, plain Markdown no generator] |
| D-043 | **REQ-028 lexicon firewall extension**: new sibling test `lexicon_meta_docs_test.go` (package `lexicon_meta_docs`) scanning `README.md` + `docs/**/*.md` for the 10 banned terms, using the same `lexicon.FindBannedTerm` + word-boundary regex. Self-exclusion + fragment pattern preserved. `.ciagent/` files are NOT scanned (they are firewall meta-files, not user-facing docs). | REQ-012 is `All` phases and docs are user-facing; the firewall must cover docs to be durable. Extending the existing meta-test (not modifying it) preserves v0.2 coverage. | 0.88 | [single combined meta-test scanning both x/ and docs/] |
| D-044 | **Phase ordering**: P1 docs foundation + firewall extension → P2 nomads docs → P3 freeholders docs → P4 Bearers skeleton I (exit/bridge/bearers/partner) → P5 Bearers skeleton II (hub/services/bond) → P6 final review/ship. Firewall lands in P1 BEFORE content (P2/P3) so docs are checked as authored. | Firewall-first ensures docs content is lexicon-clean by construction, not by retrofit. Bearers split across P4/P5 keeps each phase independently shippable (vertical slices). | 0.82 | [Bearers first then docs, or all docs in one phase] |
| D-045 | **Docs depth per audience**: each audience section (nomads, freeholders) gets 5-8 Markdown pages covering its core REQs (nomads: Reach/Stash/bearers/Maps-Pay/Pacts/standing-basics; freeholders: 4 signals/Bayesian Standing/Stands-Guilds/Councils-Voice/Bonds/Partner spectrum). `docs/shared/` gets 5-6 concept pages. `docs/reference/` gets architecture index + component map. Total ~20-25 pages. | Enough depth to be a real docs site, not a placeholder; bounded to keep P1-P3 phases shippable. | 0.80 | [deeper (40+ pages), shallower (10 pages)] |
| D-046 | **No docs-site publishing CI in v0.3**`mkdocs.yml` is buildable locally (`mkdocs serve` / `mkdocs build`); CI publishing to GitHub Pages/Gitea Pages is deferred to v0.4. v0.3 ships the source + a build invocation in the README. | Publishing CI needs deployment secrets + a hosting target; v0.3 lands the content. | 0.82 | [include publishing CI in v0.3] |
### Ideation outcome (Phase 0 — IDEATE stage, autonomy=full)
IDEATE stage ratified 8 ideas (IDEATE-01..IDEATE-08) at full autonomy, mapped to REQ-010/REQ-022..REQ-028. Docs deliverable (IDEATE-01/02) is the user's `--ideate` request; Bearers ideas (IDEATE-03..08) are the ROADMAP Phase 3 subset. Three ideation tiers ran (mechanical, backend-enriched, cross-project); mechanical tier found no `lessons:`/`compound:` tags in v0.1/v0.2 history (convention unused) and v0.2 closed clean (9/9 REQs, 303 tests, ≥95.9% coverage). Defaults accepted per full autonomy; traceability recorded in `.ciagent/oy/REQUIREMENTS.md` (IDEATE Traceability section).
### v0.4 Clarification Decisions (Phase 0 — CLARIFY, autonomy=full)
Auto-decided defaults logged per clarify workflow Step 4 (full autonomy → accept defaults, log decisions). v0.4 is a refinement-only NFR milestone (no `--ideate` flag this run; scope pre-seeded from v0.3 forward-references). The D-001 refinement-only filter governs scope eligibility.
| ID | Decision | Rationale | Confidence | Alternatives |
|----|----------|-----------|------------|--------------|
| D-047 | **v0.4 milestone type = NFR** (all phases refactor/test/quality/chore). Zero `feat:` phases by construction. Tags run on the `v0.3.x` patch line: P0 → `v0.3.0`, P1..P3 → `v0.3.1..v0.3.3`, final phase P4 → `v0.3.4` (milestone release). No separate minor tag. | The candidate work set (REQ-029..REQ-032) is entirely refactor/test/quality/chore. Promoting any Bearers skeleton to live runtime would be `feat:` and is deferred to v0.5+. | 0.90 | [feature milestone promoting v0.3 skeletons to live runtime] |
| D-048 | **REQ-029 lexicon shared helper**: add `lexicon.SyntheticBannedStrings() []string` to the `lexicon` package; both `lexicon_meta_test.go` and `lexicon_meta_docs_test.go` consume it instead of duplicating their own synthetic self-test tables. Both already assert `len(terms) == 10` from `lexicon.BannedTerms()` (G-014 minimum met); the helper closes the drift risk fully. | GRILL G-014 binding fix. Single source of truth for synthetic banned strings; a future banned-term addition updates both firewalls from one place. Refactor+test (NFR-eligible). | 0.88 | [cross-reference comment only (G-014 minimum)] |
| D-049 | **REQ-030 cross-package const-equality test**: new test file `x/hub/types/cross_const_test.go` (package `types`) that imports `x/bond/types` (test-only, G-003 exempt) and asserts `hub.LendingCouponCapBps == bond.CouponCapBps` and `hub.LendingCouponFloorBps == bond.CouponFloorBps`. Test-only import does not violate G-003 (production-import firewall). | REVIEW.md P2 / A-304. Catches silent mission-lock drift between hub LOCAL consts and bond D-028 consts. Test (NFR-eligible). | 0.85 | [document manual-sync requirement in ARCHITECTURE.md only] |
| D-050 | **REQ-031 lifecycle type shape-divergence review scope = DOCUMENT only, no code shape changes**. AUDIT §193 P1-1 (council Proposal/VoteOption absent) and P1-2 (SignalKind 4 vs 5 sources) are `feat:`-class additions (new enum types / locked-const shape changes) and are REJECTED by the D-001 refinement-only filter. v0.4 REQ-031 ships an ARCHITECTURE.md section documenting the divergence decisions (P1-2 defensible per AUDIT code rationale; P1-1 deferred to v0.5+ governance runtime) + a test asserting the current `SignalKindCount==4` locked-const shape is intentional (regression guard, not a shape change). | Adding Proposal/VoteOption enums is `feat:`; changing SignalKind 4→5 is a locked-const change. Both are out-of-scope for an NFR milestone. Documentation + a regression-guard test are NFR-eligible. | 0.82 | [add Proposal/VoteOption enums (feat:, deferred to v0.5+)] |
| D-051 | **REQ-032 docs build CI = Gitea Actions workflow** at `.gitea/workflows/docs-build.yml` running `go test ./...` (lexicon firewall) + `mkdocs build` on every push; upload `site/` as a CI artifact. Full Gitea Pages publishing is deferred (no hosting target configured in v0.4). The workflow file itself is a `chore` (config, not feature). | D-046 forward-reference. `.github/workflows/` does not exist; Gitea Actions uses `.gitea/workflows/`. Build+artifact CI is `chore` (NFR-eligible); full Pages publish needs a hosting target (deferred). | 0.80 | [include full Gitea Pages publish (needs hosting target + secrets)] |
| D-052 | **Phase ordering** (provisional, planner finalizes): P1 lexicon hardening (REQ-029 + REQ-030 — same `lexicon`/test territory, vertical slice) → P2 lifecycle divergence documentation + regression guard (REQ-031) → P3 docs build CI (REQ-032) → P4 final review + audit + milestone ship. Each phase independently shippable; P1 lands the firewall durability fixes first (highest-severity regression risk). | P1 bundles the two lexicon/const firewall fixes (same territory); P2 is documentation+test; P3 is CI config. Vertical slices. | 0.80 | [different wave ordering] |
| D-053 | **No IDEATE stage in v0.4** (no `--ideate` flag this run). The NFR scope was pre-seeded from v0.3 forward-references and ratified at CLARIFY. If `--ideate` is passed on a later v0.4 run, the D-001 refinement-only filter applies. | run.md §IDEATE is conditional on `--ideate`. This invocation has no `--ideate`. | 1.00 | [run IDEATE anyway] |
## Clarification Decisions (Phase 0 v0.5 — CLARIFY, autonomy=full)
Auto-decided defaults logged per clarify workflow Step 4 (full autonomy → accept defaults, log decisions). No `--ideate` flag this run; v0.5 scope is pre-seeded from PROJECT.md v0.4 out-of-scope + AUDIT §193 P1-1 + D-050 and ratified at CLARIFY.
| ID | Decision | Rationale | Confidence | Alternatives |
|----|----------|-----------|------------|--------------|
| D-054 | **"Runtime" = simtest-grade keeper message handlers + end-to-end flows, NOT mainnet.** v0.5 ships executable keeper behavior (MsgServer handlers, keeper Set/Get/Remove, simtest `simtest`-package flows) for the v0.3 Bearers modules. No live chain launch, no real IBC channels, no real bearer transports, no real institutional onboarding (D-020 pattern continues to govern network deployment). | v0.3 skeletons are types + keeper stubs + invariant tests. The next increment is message handlers + simtest, which is the Cosmos-SDK standard pre-mainnet step. Mainnet deployment is a Year-3+ operational concern (Watchers + Root Basket backing required). | 0.88 | [full mainnet launch in v0.5; types-only with no handlers (stalls progress)] |
| D-055 | **Cosmos SDK dependency is GRILL-approved for v0.5.** `go.mod` gains `github.com/cosmos/cosmos-sdk` (and transitive deps) as the runtime substrate for keeper MsgServer handlers, `types.Msg`, `sdk.Context`, store, and simtest. This is a controlled exception to G-006 (zero-dep go.mod), escalated to GRILL for binding ratification. The exception is scoped to runtime promotion phases (P1..P7); P0 and the final phase remain dep-neutral where possible. | v0.3 skeletons used stub `keeper.go` files that already import cosmos-sdk (see LSP errors on `x/watcher/keeper/keeper.go` — pre-existing imports). Promoting to runtime makes the dependency load-bearing rather than stub-only. G-006's intent (zero-dep for skeleton durability) is preserved by isolating the dep to runtime phases and keeping types/invariants dep-free. | 0.80 | [stay zero-dep, hand-roll keeper store + message types (duplicates SDK, high risk); defer all runtime to v0.6+ (stalls)] |
| D-056 | **Phase ordering** (provisional, planner finalizes): P1 Exit+Bridge runtime (REQ-033, Layer 3 — outermost edge, fewest internal deps) → P2 Bearers transport runtime (REQ-034, depends on exit for off-mesh routing) → P3 Anchors runtime (REQ-035, depends on partner + bearers) → P4 Hub API runtime (REQ-036, depends on anchors for custody backing) → P5 Services runtime (REQ-037, depends on hub) → P6 Bond market runtime (REQ-038, depends on hub lending primitive) → P7 Council governance runtime (REQ-039, cross-cutting, lands last) → P8 final review + audit + milestone ship. Each phase independently shippable; P1 lands the outermost edge first (lowest internal coupling). | The dependency chain is outer→inner: exit needs nothing internal; bearers routes through exit; anchors ride bearers; hub custody backs anchors; services sit on hub; bond matching uses hub lending; governance is cross-cutting. Vertical slices, each phase shippable. | 0.82 | [governance-first; bond-first; single mega-phase] |
| D-057 | **Bond matching engine = central-limit order book (CLOB) with the 8% cap / 0% floor consts (D-028) as hard clamp on each match.** No AMM (constant-product or otherwise) in v0.5; AMM is a Year-4 Maturity concern. The CLOB matches Growth Bond bids/offers at the locked coupon cap; secondary-market trades clear at market price but the bond's *coupon* stays within the mission-locked band. REQ-038 ships the matching handler + simtest; live market depth deferred. | CLOB is the standard secondary-market primitive; AMM is for spot/swaps (Exit layer's DEX, deferred). The mission-lock clamp (D-028) is a per-match invariant, not a market-wide cap. CLOB lets the cap be enforced per-match. | 0.80 | [AMM (wrong fit for coupon-bearing bonds); batch auction (deferred to Maturity)] |
| 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] |
## 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/<module>/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)] |
## Clarification Decisions (Phase 0 v0.7 — CLARIFY, autonomy=full)
Auto-decided defaults logged per clarify workflow Step 4 (full autonomy → accept defaults, log decisions). No `--ideate` flag this run; v0.7 scope is pre-seeded from oy-spec v3 §7 (Fraternal Groups Foundation, REQ-046..REQ-066) and ratified at CLARIFY. All 8 §8 open questions resolved by accepting PO recommendations as binding (D-074..D-081). The D-001 refinement-only filter does NOT apply (v0.7 is a feature milestone). Three scope-shaping questions were validated interactively with the user before CLARIFY:
1. **v0.7 scope (§7 vs §8 Q4)** — user ruled: §7 only (REQ-046..REQ-066, 21 REQs). Cluster A+B+C are v0.8 (D-081).
2. **Accept all other PO recommendations** — user ruled: yes, accept all 7 (D-074..D-080) as binding.
3. **Generate oy-state v2 at P0 start** — user ruled: yes (done at SPECIFY).
| ID | Decision | Rationale | Confidence | Alternatives |
|----|----------|-----------|------------|--------------|
| D-074 | **TBD-X = $100k annual Pass volume** for Stand→Pier-customer boundary escalation (REQ-059). When a Stand's annual Pass volume exceeds $100k (10,000,000 Grain-cents at GrainsPerBread=10000), the Stand is invited to the Hub API as a soft upgrade (not a ban). The threshold is a new `x/stand` const `StandPierEscalationAnnualPassVolumeCents=10000000` (not locked — Pool/Council may tune within bounds). | §8 Q1 PO rec accepted at full autonomy. $100k is the natural inflection where a Stand's activity volume resembles a small Pier-customer more than a personal Holder; soft upgrade preserves self-service (Principle 6). | 0.85 | [$50k (too aggressive — flags mature Households); $250k (too lax — delays Hub API onboarding)] |
| D-075 | **TBD-Z density formula for USZ classification (REQ-095, v0.8)** = `<10 Holders per km² AND strategic value ≥ mission score, OR sovereign request, OR mission-aligned override via Mesh Council supermajority`. The formula is locked now (oy-state §3) but the USZ runtime is v0.8 (depends on Anchor pre-commitment framework). | §8 Q2 PO rec accepted. The 3-criteria OR structure matches the spec's "≥3 criteria" requirement (density + strategic value + mission-aligned override) while allowing sovereign request as a separate path. | 0.80 | [pure density threshold (ignores strategic value); Mesh Council sole arbiter (no objective floor)] |
| D-076 | **Sovereign Anchors = separate SPEC `oy-sovereign-anchors`**, not folded into `oy-pier`. v0.7 status: experimental, not load-bearing. The `oy-spec` §5 constraint forbids USZ infrastructure financing via Sovereign Anchor partnerships from being load-bearing until the separate SPEC ships. | §8 Q3 PO rec accepted. Sovereign Anchors are infrastructure-scale (reserve entities, banking partners, multi-jurisdiction custody) — a different design surface than the Pier-Routed Legal Wrapper (which is OPTIONAL per §5). Folding them into `oy-pier` would conflate legal-wrapper-scale with infrastructure-scale. | 0.85 | [fold into oy-pier (conflates scales); fold into oy-spec (too large for the net-new-only diff)] |
| D-077 | **Standing gate enforcement timing = Cover Pool Factory runtime (v0.7/P1)**, not first live Cover Pool deployment. The `CoverStandingGateTrusted=4.0` and `CoverStandingGatePreferred=4.5` consts bind at the `x/cover` Factory layer — every Pool the Factory launches inherits the protocol minimum; Pool Council MAY tighten but the Factory rejects any launch below the floor. | §8 Q5 PO rec accepted. Gates are protocol-layer invariants (REQ-049 locked=yes); deferring them to first-live-Pool would allow a window where a Pool could launch below the floor. Factory-runtime binding closes the window. | 0.88 | [first-live-Pool binding (allows a below-floor window); per-Pool configurable with no floor (violates REQ-049 locked)] |
| D-078 | **Watcher/Voucher operating-expense compensation cap = 5% of Root-Pool Bloom annually** (REQ-096, v0.8). The absolute $TBD-W cap is deferred to v0.8 P0 (needs Root-Pool Bloom size estimate). v0.7 does not implement Watcher/Voucher compensation (Cluster E + Infra Economics are v0.8 per D-081). | §8 Q6 PO rec accepted. 5% Bloom is bounded by the protocol's own yield (not a transfer-payment analog); the absolute cap prevents Bloom-rate collapse if Bloom grows large. Deferring $TBD-W avoids hardcoding a USD figure that depends on mainnet Bloom size. | 0.82 | [10% (too high — risks Bloom-rate dilution); 1% (too low — may not cover Watcher ops); no absolute cap (unbounded if Bloom grows)] |
| D-079 | **Anti-Crowding-Out Covenant enforcement = separate `x/cover/firewall` package (runtime) + `lexicon_meta_cover`-style meta-test (test-time)**, defense in depth. The runtime subpackage rejects any code path that would route Cover-Fees outside contributor-pool semantics (e.g., to Root-Pool operating expenses, transfer payments, or non-Cover destinations). The meta-test rejects doc/string drift that would describe such routing. This parallels the lexicon_meta pattern (D-044/D-069 firewall-first). | §8 Q7 PO rec accepted. The covenant is a §1/§2.3 SPEC-001 invariant — "Cover-Fees never crowd out the contributor pool". A separate firewall (not embedded in Factory validation) makes the invariant visible, testable, and resistant to Factory-layer refactors. Defense in depth: runtime rejects the code path, meta-test rejects the doc drift. | 0.84 | [embed in Factory validation (invisible, refactorable); meta-test only (no runtime gate — docs clean but code could route around)] |
| D-080 | **MAB use-of-proceeds lock enforcement = tagged streaming + Watcher-witnessed release**, defense in depth. MAB proceeds are tagged with `use_of_proceeds=reserve_build_out` at issuance; the `x/bond` keeper streams tagged Grain to the `x/cover` reserve only, with auto-Still on any misuse detection (attempt to route to a non-reserve destination). Watcher attestation witnesses each release at quarterly audit (REQ-054). | §8 Q8 PO rec accepted. Tagged streaming makes the lock enforceable at the keeper layer (not just auditable post-hoc); Watcher-witnessed release adds the human-attestation layer. Defense in depth: keeper auto-Stills on misuse, Watcher catches what the keeper misses. | 0.85 | [Watcher-quorum-only release (no runtime gate — relies on Watcher catching misuse after the fact); unrestricted + audit-only (no enforcement, just detection)] |
| D-081 | **v0.7 scope = §7 authoritative — REQ-046..REQ-066 only (21 REQs).** Cluster A+B+C (REQ-067..REQ-081) are v0.8, NOT v0.7, despite §8 Q4 PO rec suggesting Cluster A+B+C ship in v0.7. The §7 v0.7 acceptance text lists only REQ-046..REQ-066; the §7 v0.8 acceptance text lists REQ-067..REQ-097 with the const firewall extensions. §7 is the milestone contract; §8 Q4 was a recommendation the PO can override — and did, by accepting the "§7 only" interactive ruling before CLARIFY. | §7 acceptance text is the authoritative milestone contract (it lists the REQs and the acceptance criteria). §8 Q4 was a sequencing recommendation, not a binding scope ruling. Shipping 36 REQs in v0.7 would create a mega-milestone with coupled territories (fraternal primitives + their risk mitigations are different vertical slices). v0.7 = foundation; v0.8 = hardening. | 0.90 | [§8 Q4 — Cluster A+B+C in v0.7 (36 REQs, coupled territories); §7 + partial Cluster A only (REQ-067..072, 27 REQs — still couples fraternal + trust-minimization)] |
| D-082 | **v0.7 phase ordering** (provisional, planner finalizes): P1 Cover Pool firewall + foundation (REQ-046/047/049/050 — firewall-first, same `x/cover` territory) → P2 Cover-Charter + Council + staging (REQ-048/052/062/065 — extends `x/cover` + `x/council`) → P3 Guild Charter + Chapter Federation (REQ-051/053/057/058/061 — extends `x/guild`, `x/stand`) → P4 MAB + Cover Claims Voucher (REQ-054/055/060/063 — extends `x/bond`, `x/standing`, `x/cover`) → P5 Anti-Capture Bill + secession + Pier (REQ-056/059/064/066 — cross-cutting, lands last as it constrains all prior surfaces) → P6 final review + audit + milestone ship. Each phase independently shippable; P1 lands the firewall + locked floors first (firewall-first pattern per D-044/D-069/D-079). | The 21 REQs cluster into 5 vertical slices by module territory + dependency. P1 is the spine (Factory + firewall + locked floors + gates + tagging); everything else hangs off it. P5 lands last because the Anti-Capture Bill constrains all prior surfaces (non-amendable rights that P1-P4 code must not violate). | 0.82 | [governance-first (REQ-062 first — but it depends on Factory); MAB-first (REQ-054 — but it depends on Cover Pool reserve existing); single mega-phase (couples territories)] |
| D-083 | **No IDEATE stage in v0.7** (no `--ideate` flag this run). The feature scope was pre-seeded from oy-spec v3 §7 (REQ-046..REQ-066) and ratified at CLARIFY with all 8 §8 questions resolved. The D-001 refinement-only filter does NOT apply (v0.7 is a feature milestone). | run.md §IDEATE is conditional on `--ideate`. This invocation has no `--ideate`. | 1.00 | [run IDEATE anyway] |
| D-084 | **New module `x/cover`** (Cover Pool Factory + Anti-Crowding-Out firewall + Anti-Capture Bill of Rights). The existing `x/pact` `PactCover` enum value remains as a cross-reference (G-003 by-ID-string pattern). This mirrors the D-039 precedent (`x/hub` split out of `x/pact`'s `PactHubAPI` in v0.3) — when a PactType grows into a first-class protocol surface with its own keeper + firewall, it graduates to a dedicated module. The `x/pact` `PactCover` enum value stays as a typed cross-reference so `x/pact` tests still pass; `x/cover` owns the runtime. | REQ-046 (Factory runtime), REQ-047 (reserve floor in `x/pact/cover` per spec text — interpreted as `x/cover` since that's where the Factory lives), REQ-050 (Cover-Fee tagging at protocol layer), REQ-052 (Cover-Charter), REQ-055 (Cover Claims Voucher), REQ-056 (Anti-Capture Bill) all need a home. A dedicated `x/cover` module is the D-039 pattern; keeping them in `x/pact` would overload `x/pact` (which is a 6-Pact enum skeleton, not a Cover Pool runtime). The spec text "codified in `x/pact/cover`" is read as "the Cover surface, which graduated from `x/pact`" — `x/cover` is the graduated module. GRILL ratifies. | 0.82 | [keep everything in `x/pact` (overloads the 6-Pact enum module); create 3 micro-modules (`x/coverpool`, `x/covercharter`, `x/anticapture` — fragments the Cover surface)] |
## GRILL Decisions (Phase 0 v0.7 — GRILL, autonomy=full)
The ci-griller red-teamed the v0.7 plan across 9 axes + 7 specific probes. Overall verdict: **CONDITIONAL PASS** (confidence 0.72) with 5 binding decisions (D-086..D-090) and 3 escalations to PO. The plan does NOT proceed to P1 until D-086..D-090 are applied (they are applied to PLANS.md + ARCHITECTURE.md + this file). This grill IS the self-administered adversarial review (pen-test) per oy-state §7 remaining-open item 4.
| ID | Decision | Rationale | Confidence | Affects |
|----|----------|-----------|------------|--------|
| D-086 | **P1 Factory scope clarification** — P1's `MsgLaunchCoverPool` is *functional for Phase-2 categories ONLY* (Travel/HealthMCS/IncomePause). `FactoryAllowedPhases` Params field set to `[Phase2]` only in P1; Phase3/Phase4 categories REJECTED in P1. P2 extends to `[Phase2, Phase3, Phase4]`. P1 simtest includes negative case: out-of-phase category launch rejected. | The plan's "placeholder" language was ambiguous. Pinning P1 to Phase-2-only makes the vertical slice honest: P1 ships a working Factory for the Phase-2 subset, not a half-Factory. | 0.82 | PLANS P1 |
| D-087 | **`PierCarriesVoice` const reconciliation** — P3 introduces `PierCarriesVoice bool const false` in `x/guild/types` (FR-VOICE-6: Pier does NOT carry Voice). Added as the **12th locked const** to the v0.7 const additions table (was 11; now 12). Mission-locked invariant — const is the correct firewall shape (not a field). | The plan and the const table disagreed by 1. A const that exists in code but not in the firewall table is invisible to the regression firewall. | 0.80 | PLANS const table, oy-state §3, ARCHITECTURE const table |
| D-088 | **`lexicon_meta_cover` banned-term list + firewall shape** — (1) The `lexicon_meta_cover/` meta-test uses a NEW `lexicon.CoverBannedTerms()` helper banning `insurance`, `premium`, `claim`, `policy` scoped to the Cover surface (NOT project-wide — avoids false positives in non-Cover modules where "claim" is a common English word). (2) The `x/cover/firewall/` runtime subpackage shape is pinned to an **allow-list of permitted routing destinations** (the Pool's `ReserveAccount`), checked via string-equality at the start of every `MsgRouteCoverFee` handler. (3) **Optional cleanup:** replace `x/pact` "insurance-like" docstrings (`x/pact/types/types.go:36,158`) with "Cover-like" as a P1 doc-fix. | (1) Without a defined banned-term list, the `lexicon_meta_cover` meta-test was a paper tiger — it scanned but didn't ban the terms the plan said are banned. (2) The firewall's "rejects any code path" language was aspirational; an allow-list is the simtest-grade concrete form. (3) The `x/pact` "insurance-like" string is latent lexicon debt. | 0.78 | PLANS P1, `lexicon/lexicon.go`, `x/pact/types/types.go` |
| D-089 | **`StillKeeper` stub + `x/bond → x/cover` CoverKeeper reverse edge** — (1) `StillKeeper.Still(poolID, reason)` is satisfied by a **simtest-local stub** (test-only, G-003 exempt), NOT a real `x/still` keeper. `x/still` is NOT extended this milestone (verified: `x/still/keeper/` is empty). (2) ARCHITECTURE.md v0.7 dependency map adds the reverse edge: `x/bond ──(CoverKeeper shim)──► x/cover` (the MAB `MsgDebitMABProceeds` handler queries `CoverKeeper.GetPoolReserveAccount(poolID)`). NEW expected-keeper interface in `x/bond/types/expected_keepers.go`. No import cycle (interface only). | (1) The auto-Still hook references a method that doesn't exist; without a documented stub, P4 cannot wire the simtest. (2) The reverse dependency edge is real (MAB handler must query the Pool's reserve account) but undocumented — a hidden architecture coupling. | 0.76 | PLANS P1/P4, ARCHITECTURE dependency map, `x/bond/types/expected_keepers.go` |
| D-090 | **Bill of Rights temporal gap + Voucher cold-start + Standing-gate dual check + D-085 window** — (1) **Bill of Rights temporal-gap fix (most serious):** the `RightID` type + 13 `Waivable*` consts (all `false`) + `RightIsWaivable(id) bool` (always `false`) + `MsgSignCoverCharter.ValidateBasic` gate rejecting any `WaivedRights` element land in **P2** (before the first Charter can be signed), NOT P5. P5 adds the *ceremony* surface (Counsel review handler, full simtest). (2) **Voucher bond cold-start fix:** `bond = max(CoverClaimsVoucherBondMultipleAvgCall × avgCallSize, MinimumVoucherBond)` where `MinimumVoucherBond` is a Params field with a non-zero default. (3) **Standing-gate dual check:** the `CoverStandingGateTrusted`/`CoverStandingGatePreferred` floor is enforced at BOTH the `MsgLaunchCoverPool` handler AND the `MsgAmendPoolStandingGate` (Params-amendment) `ValidateBasic`. (4) **D-085 escalation window tightened to before P2** (because D-090(1) moves the RightID + 13 consts to P2). Fallback at P2: log `RightNonParticipationNoDenial` as the 13th right at confidence 0.55 and proceed. | (1) The P2→P5 temporal gap was a real security hole — rights waivable between P2 and P5. (2) Zero-bond cold-start was a Voucher bypass. (3) A Params-only check left the amendment path open. (4) The escalation window must match the new P2 deadline. | 0.72 | PLANS P2, P4, P5, RESEARCH D-085 |
+231 -9
View File
@@ -1,3 +1,4 @@
<!-- Auto-generated from .ciagent/oy/oy-spec — PO edits oy-spec, not this file; see oy-state for current shipped state. -->
# Requirements: OpenYield (oy)
| ID | Requirement | Vision § | Priority | Status | Phase |
@@ -10,19 +11,229 @@
| REQ-006 | Standing anti-gaming formula | §9.2 | High | Complete | P6 |
| REQ-007 | FCFS processing | §15 | High | Complete | P7 |
| REQ-008 | OY Chain (Layer 1) | §7 | High | Skeleton | P1 |
| REQ-009 | Satellite chains (Layer 2) | §7 | Medium | Pending | Future |
| REQ-009 | Satellite chains (Layer 2) | §7 | Medium | Skeleton | v0.2/P4 |
| REQ-010 | Exit layer (Layer 3) | §7 | Medium | Skeleton | P8 |
| REQ-011 | Three Councils with Mission Lock | §19 | High | Pending | Future |
| REQ-011 | Three Councils with Mission Lock | §19 | High | Skeleton | v0.2/P3 |
| REQ-012 | Lexicon compliance | §3 | High | Complete | All |
| REQ-013 | Bread unit with scale | §4 | High | Complete | P2 |
| REQ-014 | Three pools of storage | §5 | High | Complete | P3 |
| REQ-015 | Window primitive | §10 | High | Pending | Future |
| REQ-016 | Nine Stand types | §11 | Medium | Pending | Future |
| REQ-017 | Guilds with free Hand-Passes | §12 | Medium | Pending | Future |
| REQ-018 | Four-tier Partner Spectrum | §13 | Medium | Pending | Future |
| REQ-015 | Window primitive | §10 | High | Skeleton | v0.2/P1 |
| REQ-016 | Nine Stand types | §11 | Medium | Skeleton | v0.2/P1 |
| REQ-017 | Guilds with free Hand-Passes | §12 | Medium | Skeleton | v0.2/P1 |
| REQ-018 | Four-tier Partner Spectrum | §13 | Medium | Skeleton | v0.2/P2 |
| REQ-019 | Six bearers via Unified Bearer Layer | §14 | Medium | Complete | P7 |
| REQ-020 | Six Pacts | §16 | Medium | Pending | Future |
| REQ-021 | Mesh Bond Market with 8pct cap | §17 | Medium | Pending | Future |
| REQ-020 | Six Pacts | §16 | Medium | Skeleton | v0.2/P2 |
| REQ-021 | Mesh Bond Market with 8pct cap | §17 | Medium | Skeleton | v0.2/P4 |
| Bearers OY-LR + Beacon | (vision §14) | §14 | Medium | Skeleton | v0.2/P4 |
| Forex Engine v1 | (vision §13) | §13 | Medium | Skeleton | v0.2/P3 |
## v0.3 Milestone Requirements (Bearers & Documentation)
| ID | Requirement | Vision § | Priority | Status | Phase |
|----|-------------|----------|----------|--------|-------|
| REQ-010 | Exit layer (Layer 3) — DEX swaps, bridges, off-mesh services | §7 | Medium | Skeleton | v0.3/P4 |
| REQ-022 | Bearers expansion: OY-SAT + OY-QR bearer transports | §14 | Medium | Skeleton | v0.3/P4 |
| REQ-023 | Anchors — first institutional Partner tier | §13 | Medium | Skeleton | v0.3/P4 |
| REQ-024 | Hub API — B2B backbone: custody, lending primitive, compliance | §13 | Medium | Skeleton | v0.3/P5 |
| REQ-025 | Services — Care / SIM / Vault / Mail | §13 | Medium | Skeleton | v0.3/P5 |
| REQ-026 | Bond market depth — Growth Bonds + secondary market | §17 | Medium | Skeleton | v0.3/P5 |
| REQ-027 | README.md + docs site in docs/ for nomads and freeholders | (vision §8) | High | Complete | v0.3/P1-P3 |
| REQ-028 | Extend REQ-012 lexicon firewall to scan docs/ + README.md | §3 | High | Complete | v0.3/P1 |
> REQ-022 through REQ-028 are NEW in v0.3 (ratified during Phase 0 IDEATE as
> IDEATE-01..IDEATE-07, then assigned final REQ-IDs). REQ-010 is promoted from
> v0.1 Skeleton to a fuller v0.3 skeleton.
## v0.4 Milestone Requirements (Refinement — NFR)
v0.4 is a refinement-only NFR milestone: zero `feat:` phases. Scope sourced
from v0.3 forward-references (REVIEW.md, AUDIT.md §193, GRILL.md G-014).
Live-runtime promotions are out of scope (deferred to v0.5+). The D-001
refinement-only filter applies to any IDEATE stage.
| ID | Requirement | Source | Class | Priority | Status | Phase |
|----|-------------|--------|-------|----------|--------|-------|
| REQ-029 | Lexicon firewall: shared `lexicon.SyntheticBannedStrings()` helper — dedupe the synthetic self-test table between `lexicon_meta_test.go` and `lexicon_meta_docs_test.go`; both meta-tests derive count + strings from the single source so a future banned-term addition updates both firewalls from one place | GRILL G-014 | refactor/test | High | Complete | v0.4/P1 |
| REQ-030 | Cross-package const-equality test: `x/hub.LendingCouponCapBps == x/bond.CouponCapBps` (and Floor) — test-only import (G-003 exempt), catches silent mission-lock drift between hub LOCAL consts and bond D-028 consts | REVIEW.md P2 / A-304 | test | High | Complete | v0.4/P1 |
| REQ-031 | x/* lifecycle type shape-divergence review + alignment fixes — audit non-must-have lifecycle types across modules flagged by AUDIT §193; align shapes where divergent (no behavioral change) | AUDIT.md §193 | refactor/quality | Medium | Complete | v0.4/P2 |
| REQ-032 | Docs build CI — Gitea Actions workflow that runs `go test ./...` (lexicon firewall) + `mkdocs build` on every push; upload the built `site/` as a CI artifact. Full Gitea Pages publishing deferred if no hosting target is configured (chore, not feat) | D-046 | chore/ci | Medium | Complete | v0.4/P3 |
> REQ-029..REQ-032 are NEW in v0.4. All are NFR classes (refactor/test/quality/
> chore) — zero `feat:` phases by construction. The final-phase audit enforces
> the NFR purity gate (zero `feat:` commits in the milestone).
## Milestone v0.4 Summary (Refinement — NFR) — COMPLETE
- 4 v0.4-scope REQs shipped as NFR (refactor/test/docs/chore): REQ-029, REQ-030, REQ-031, REQ-032
- Closes 3 real v0.3 forward-references: GRILL G-014 (lexicon drift), REVIEW P2/A-304 (const drift), AUDIT §193 (council divergence docs)
- Lands the D-046 docs-CI forward-reference (.gitea/workflows/docs-build.yml, build+artifact, no Pages publish per D-051)
- NFR purity gate GREEN: zero `feat:` commit subjects in the milestone (20 commits, all docs/refactor/test/chore/verify/decision/checkpoint/Merge)
- `go.mod` unchanged (G-006 — zero Go deps; Python deps isolated to CI docs-build job)
- G-003 production firewall intact (no production import of `x/bond/types` in `x/hub/types`; cross-const test is test-only)
- Coverage: x/hub/types 93.3% (v0.3 floor preserved), x/council/types 96.4% (improved); both above 80% target
- Tags: v0.3.0 (P0) -> v0.3.1 (P1) -> v0.3.2 (P2) -> v0.3.3 (P3) -> v0.3.4 (P4 = v0.4 milestone release)
- Tag-line note: v0.4 (NFR) ships on the v0.3.x patch line (config tag_base). The v0.3.4 milestone release IS the deliverable (D-008 — final phase patch IS the milestone release; no separate minor tag).
## v0.5 Milestone Requirements (Bearers Runtime — Feature)
v0.5 promotes the v0.3 Bearers skeletons from type+keeper-stub layers to
live runtime behavior (keeper message handlers + simtest-grade end-to-end
flows). No live chain launch (D-020 continues to govern network deployment);
runtime = keeper handlers + simtest, not mainnet. Sourced from the v0.3/v0.4
deferred items (D-050, PROJECT.md v0.4 out-of-scope, ROADMAP Phase 3 runtime).
| ID | Requirement | Source | Class | Priority | Status | Phase |
|----|-------------|--------|-------|----------|--------|-------|
| REQ-033 | Exit layer runtime — `x/exit` DEX swap routing + `x/bridge` L2↔L1 IBC packet handlers; promotes REQ-010 from skeleton → runtime (simtest-grade message handlers; live DEX/IBC channels deferred) | PROJECT.md v0.4 OOS / D-050 | feat | High | Complete | v0.5/P1 |
| REQ-034 | Bearers transport runtime — OY-SAT + OY-QR bearer transport message handlers in `x/bearers` (extends REQ-019/REQ-022); session lifecycle in simtest (hardware integration deferred) | PROJECT.md v0.4 OOS | feat | Medium | Complete | v0.5/P2 |
| REQ-035 | Anchors onboarding runtime — `x/partner` Anchor tier credential issuance + revocation handlers (extends REQ-018/REQ-023); credential lifecycle in simtest (real institutional onboarding deferred) | PROJECT.md v0.4 OOS | feat | Medium | Complete | v0.5/P3 |
| REQ-036 | Hub API B2B runtime — `x/hub` custody, lending primitive, compliance message handlers; keeper handlers + simtest (real B2B suite deferred) | PROJECT.md v0.4 OOS | feat | High | Complete | v0.5/P4 |
| REQ-037 | Services runtime — `x/services` Care / SIM / Vault / Mail service lifecycle handlers; runtime handlers + simtest (live service integrations deferred) | PROJECT.md v0.4 OOS | feat | Medium | Complete | v0.5/P5 |
| REQ-038 | Bond market depth runtime — `x/bond` Growth Bonds + secondary-market matching handlers (extends REQ-021/REQ-026); matching engine + simtest (live market depth deferred) | PROJECT.md v0.4 OOS | feat | High | Complete | v0.5/P6 |
| REQ-039 | Council governance runtime — `x/council` Proposal/VoteOption enum types (AUDIT §193 P1-1, deferred from v0.4) + Voice lifecycle handlers; governance message handlers + simtest (Mission Lock const firewall intact per G-003; SignalKind 4→5 expansion deferred to v0.6+) | AUDIT §193 P1-1 / D-050 | feat | Medium | Complete | v0.5/P7 |
> REQ-033..REQ-039 are NEW in v0.5. All are `feat`-class (runtime promotion
> from skeleton). No breaking schema changes; locked-const firewall intact
> (Mission Lock non-amendable). 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).
## Milestone v0.5 Summary (Bearers Runtime — Feature) — COMPLETE
- 7 v0.5-scope REQs shipped as feature (runtime promotion from skeleton): REQ-033, REQ-034, REQ-035, REQ-036, REQ-037, REQ-038, REQ-039
- 8 modules promoted to runtime (keeper MsgServer handlers + simtest-grade end-to-end flows): x/exit, x/bridge, x/bearers, x/partner, x/hub, x/services, x/bond, x/council
- cosmos-sdk v0.50.8 + ibc-go v8.2.1 added (D-055/D-062, G-006 controlled exception — scoped to runtime phases; types/ packages stay dep-free)
- G-003 production firewall intact (expected_keepers.go interfaces; no production struct imports across x/<module>/types)
- Locked-const firewall intact: all v0.1..v0.4 consts unchanged (ExitStatusCount=5, BridgeStatusCount=4, BearerTypeCount=6, OYSATLink.SurveillanceResistant=true, PartnerTierCount=4, HubServiceCount=3, LendingCouponCapBps=800, LendingCouponFloorBps=0, ServiceKindCount=4, CouponCapBps=800, CouponFloorBps=0, CouncilKindCount=3, SignalKindCount=4, MissionLockAmendable=false); v0.5 additions additive (AnchorCredentialStatusCount=4, WatcherVetoQuorum default 6 param-tunable)
- 5 GRILL decisions ratified: D-055 (cosmos-sdk dep), D-062 (version pin), D-063 (bond CLOB REJECT above 800bps), D-064 (MissionLockAmendment reject-at-ValidateBasic), D-065 (Watcher Veto quorum default 6)
- 8 binding fixes landed: G-017 (NoWithVeto test reconciled), G-018 (go 1.22 build gate), G-019 (ImpliedCoupon helper + boundary test), G-020 (WatcherVetoQuorum bounds [2,9]), G-021 (IBC replay ERROR), G-022 (baseline stubs documented), G-023 (keeper/msg_server.go ownership split), G-024 (types/ tests stdlib-only)
- Coverage ≥80% on all 8 keeper packages: x/exit/keeper 85.0%, x/bridge/keeper 82.1%, x/bearers/keeper 91.2%, x/partner/keeper 87.6%, x/hub/keeper 90.0%, x/services/keeper 91.5%, x/bond/keeper 92.5%, x/council/keeper 90.3%
- 34 packages green (no regression on v0.1..v0.4 baseline)
- Tags: v0.4.0 (P0) -> v0.4.1 (P1) -> v0.4.2 (P2) -> v0.4.3 (P3) -> v0.4.4 (P4) -> v0.4.5 (P5) -> v0.4.6 (P6) -> v0.4.7 (P7) -> v0.4.8 (P8 = v0.5 milestone release)
- Tag-line note: v0.5 (feature) ships on the v0.4.x patch line (config tag_base). The v0.4.8 milestone release IS the deliverable (D-008 — final phase patch IS the milestone release; no separate minor tag).
- 5 P1+ issues flagged for v0.6+ mainnet-readiness (governance spam deposit, CLOB front-running/batch auction, real IBC light-client simtest, CLOB prefix-key perf, emitMatchEventHook testability)
## v0.6 Milestone Requirements (Nomad Web UI — Feature)
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 — no real blockchain interaction (D-020 continues).
The UI is a greenfield Go `html/template` + HTMX layer served by a Go mock HTTP
server (`web/`) that instantiates the real `x/*/types` structs from in-memory
fixtures. No keeper, no Cosmos runtime, no `app.go`.
| ID | Requirement | Source | Class | Priority | Status | Phase |
|----|-------------|--------|-------|----------|--------|-------|
| REQ-040 | Nomad Reach signup Web UI — Go HTTP mock server (`web/main.go`, Go 1.22 `net/http.ServeMux`, mock store) + "Create a Reach" form (POST) + Reach list/detail views; grounds the UI in `x/identity/types.Reach`. "Sign up" maps to "Create a Reach" (the word "account" is banned per REQ-012). Signup atomically creates a Reach (`IsNomad=true`) + a Stash (per D-071, Nomad starts with both). | user `--ideate` request + D-066/D-067/D-068/D-071 | feat | High | Complete | v0.6/P1 |
| REQ-041 | Stash dashboard Web UI — balance in Grain + Bread-scale conversion (using `x/bread/types.BreadScaleAll()` + `GrainsPerBread=10000`, per D-073 code constants are the source of truth) + 90-day maturity progress bar (`x/stash/types.StashActivity.IsMature`, `MaturityThresholdDays=90`). | user `--ideate` request + D-073 | feat | High | Complete | v0.6/P2 |
| REQ-042 | Window authorization Web UI — form to open a Window (scope + duration + rate-limit) delegating to a service, lifecycle view (Open→Active→Revoked/Expired via `x/window/types.Window.Activate/Revoke/Expire`), audit log (`AuditEntry`). | user `--ideate` request | feat | Medium | Complete | v0.6/P3 |
| 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` — StashMaturity, MultiDomainStanding, CommittedCapital, CommunityEndorsement). | user `--ideate` request | feat | Medium | Complete | v0.6/P4 |
| REQ-044 | Bloom accrual Web UI — per-Stash `BloomRecord` view (`AccruedGrain`, `RateBasisPoints`, `LastAccrualBlock`), computed from mock data; shows the 4.5% target rate (`TargetBloomRateBasisPoints=450`). | user `--ideate` request | feat | Low | Complete | v0.6/P5 |
| REQ-045 | Extend REQ-012 lexicon firewall to scan `web/templates/**` + `web/static/**` + `web/**/*.go` (new `lexicon_meta_web_test.go`, package `lexicon_meta_web`, subdir `lexicon_meta_web/`). Mirrors the `lexicon_meta_docs` pattern with G-013 walk-coverage + G-009 self-test + G-014 shared `SyntheticBannedStrings()`. Firewall-first: lands in P1 before content (P2..P5). | D-069 + RESEARCH D-075 | test/quality | High | Complete | v0.6/P1 |
> REQ-040..REQ-045 are NEW in v0.6. REQ-040..REQ-044 are `feat`-class (UI
> screens); REQ-045 is `test` (lexicon firewall extension). No breaking schema
> changes; G-003 production firewall intact (`web/` is app-layer, not an `x/`
> module); G-006 go.mod unchanged (HTMX is a vendored static asset, not a Go
> dep). The final-phase audit enforces the feature purity gate.
## v0.7 Milestone Requirements (Fraternal Groups Foundation — Feature)
v0.7 adapts the 18901930 fraternal benefit-society model for borderless
digital service. It delivers Cover Pools (mission-locked reserve floors +
Standing gates), Chapter Federation (Parent/Chapter Guild model with
secession terms + good-standing liens at founding), Mutual Aid Bonds
(Cover-Call-couponed, 3× annual surplus ceiling, use-of-proceeds locked to
reserve build-out), and the Anti-Capture Bill of Rights v0.2 (13 rights
codified, non-amendable, non-waivable). This milestone unblocks the v0.1 Q7
"Cover Pool seniority mechanics" deferred item — REQ-046..REQ-050 supply the
seniority/gate math and promote `x/pact` Cover from skeleton to a dedicated
`x/cover` module runtime (D-084, D-039 precedent).
Simtest-grade runtime (D-020 pattern continues — no live chain launch, no
mainnet). Cover Pool "live on testnet" (§7 acceptance) = `x/cover` keeper
message handlers + simtest-grade end-to-end flows. No `app.go`/`cmd/oyd`
exists; v0.7 does not create one. New module: `x/cover` (Factory + Anti-
Crowding-Out firewall + Anti-Capture Bill of Rights). The existing `x/pact`
`PactCover` enum value remains as a cross-reference (G-003 by-ID-string).
| ID | Requirement | Vision § | Priority | Status | Phase |
|----|-------------|----------|----------|--------|-------|
| REQ-046 | Cover Pool Factory runtime — Factory rejects category launches below in-force reserve floor; supports Cover-Charter deployment; Watcher attestation pipeline operational; category staging per REQ-065 | §16 | High | Not started | v0.7/P1 |
| REQ-047 | Cover Pool reserve target floor 1.5× annual contributions — LOCKED; mission-lock semantic enforced; below-floor auto-pause of Cover-Fee routing | §16 | High | Not started | v0.7/P1 |
| REQ-048 | Cover Pool reserve target ceiling 2.5× (governance-tunable within 1.5×–2.5×) — Watcher escalation after 12 months; Pool Council MAY vote within bounded range | §16 | High | Not started | v0.7/P2 |
| REQ-049 | Cover Pool Standing gate minimums — LOCKED; Travel ≥ Trusted 4.0; Health-MCS ≥ Preferred 4.5; Pool MAY tighten but NEVER loosen below protocol minimum. Binds at Factory runtime (D-077) | §16, §9.3 | High | Not started | v0.7/P1 |
| REQ-050 | Cover-Fee tagging at protocol layer — LOCKED; Cover-Fee Grains carry `category_tag`; settlement rejects category-mismatched Calls (FR-COVER-11); Pool-level fungibility preserved for net-reserve accounting | §16 | High | Not started | v0.7/P1 |
| REQ-051 | Guild Charter + Common Bond requirement — LOCKED; at formation: Common Bond declared + hash-pinned; Public Profile published (bond summary, disclaimers, Mason count or "private", Pier wrapper if any) | §12 | Medium | Not started | v0.7/P3 |
| REQ-052 | Cover-Charter (SoB, dispute path, gate, holding period) — LOCKED; distinct from governance charter; signed by Pool Host + witnessed by Watcher at deployment; amendments require Pool supermajority + 7-day cooling + Watcher + Counsel; protocol does NOT enforce SoB content (FR-CHTR-5) | §16 | High | Not started | v0.7/P2 |
| REQ-053 | Chapter Federation (Parent/Chapter, secession terms, liens at founding) — Parent Guild + Chapters; Chapters inherit + may tighten but not loosen; secession terms coded at founding; good-standing liens at founding (not freely increasable); Chapter retains mesh-level Voice (Pier does NOT carry Voice per FR-VOICE-6) | §12 | High | Not started | v0.7/P3 |
| REQ-054 | Mutual Aid Bond (issuance ceiling 1×–3×, coupons in Cover Calls) — LOCKED; issuance ceiling mission-locked at 3× annual surplus; coupons payable in Cover Calls or mutual-aid credits (NEVER Bread); coupon rate bounded by `CouponCapBps=800`; use-of-proceeds locked to reserve build-out; default recapture per FR-MAB-7; Watcher attestation at deployment + quarterly audit. Enforcement: tagged streaming + Watcher-witnessed release (D-080) | §17 | High | Not started | v0.7/P4 |
| REQ-055 | Cover Claims Voucher role + bond + slashing — Specialization of Voucher role; bond default 10× avg Call size per Pool; reviews each Call independently (no self-adjudication, FR-CPCV-2); slashing via §9.4 mechanism with cross-Pool applicability (NFR-SEC-8); bounded earnings | §9.4, §15 | High | Not started | v0.7/P4 |
| REQ-056 | Anti-Capture Bill of Rights v0.2 — LOCKED; 13 rights codified in code; cannot be amended or waived by any Charter; covers one-tap exit, no tax on personal Stash, audit-able Voice, cooling, Watcher inspection, Freeholder voucher, Counsel escalation, Anchored-Bread conversion, Wayfarer's Record, secession (founding terms), non-Cover-access, category-mismatch refusal | §8.2 [3] | High | Not started | v0.7/P5 |
| REQ-057 | Household simplified — no formal Council, one-tap exit — Household Stand may operate without formal Council; one-tap exit is the dispute path | §11 | Low | Not started | v0.7/P3 |
| REQ-058 | Confederation Voice — one-Stand-one-Vote, internal bundle — LOCKED; Confederation aggregates member Stand Voice one-per-Stand; member Stands may bundle delegated Voice internally via §19 delegation | §11 | Medium | Not started | v0.7/P3 |
| REQ-059 | Stand→Pier-customer boundary — escalation rule ($100k per D-074) — When annual Pass volume > $100k (10M Grain-cents), Stand is invited to Hub API; soft upgrade, not a ban | §11, §13 | Medium | Not started | v0.7/P5 |
| REQ-060 | Shadow vouch partial credit — 50% weight in Freeholder signal — LOCKED; Shadow vouch weight = 0.5× in Community Endorsement signal (vs 1.0× for non-Shadow vouch) | §9.1 | Medium | Not started | v0.7/P4 |
| REQ-061 | Disclaimer cadence — per charter signing — LOCKED; jurisdictional disclaimer surfaced at every charter signing; not session-bounded | §11 | Low | Not started | v0.7/P3 |
| REQ-062 | Pool governance hybrid (Host + 3 elected + Watcher observer) — LOCKED; Cover Pool Council = Pool Host + 3 Masons elected by Pool-eligible Masons + Watcher observer seat; Cover Calls require majority with Watcher observer present. No Anchor seat (Anchor no-Voice §5) | §16 | High | Not started | v0.7/P2 |
| REQ-063 | MAB holder — surplus seniority only, no Voice at dissolution — LOCKED; MAB holders rank after Cover-Fee contributors but before Bread holders in Pool-surplus distributions (FR-MAB-4); NO Voice in Pool dissolution decisions (claimants, not Masons) | §17 | Medium | Not started | v0.7/P4 |
| REQ-064 | Secession cooling — 21d Cover-active / 14d non-Cover — LOCKED; Chapter secession cooling: 21 Mesh-days if Cover-active, 14 Mesh-days if non-Cover; secured at founding, not reducible; lien audit required; Cover Call / Bond covenant clearance required before secession completes | §4.6 [3] | Medium | Not started | v0.7/P5 |
| REQ-065 | Cover Pool category staging — Phase 2/3/4 — LOCKED; Phase 2: Travel + Health-MCS + Income-Pause; Phase 3: Equipment/Loss + Life-Burial + Road-Side; Phase 4: Cyber-Skimming + Guild-Internal-Mutual-Aid; Factory respects staging and rejects out-of-phase launches | §16 | High | Not started | v0.7/P2 |
| REQ-066 | Pier selection — Guild Council chooses, reversible, Pier Selection Index — Guild Council chooses Pier at formation; reversible by Cover Pool supermajority + Counsel witness; mesh maintains Pier Selection Index; Pier-Routed Legal Wrapper OPTIONAL (§5 default-no-wrapper) | §13 | Medium | Not started | v0.7/P5 |
> REQ-046..REQ-066 are NEW in v0.7. All are `feat`-class primitives (Cover
> Pool Factory, Cover-Charter, Chapter Federation, MAB, Cover Claims Voucher,
> Anti-Capture Bill) + a `test` adjunct for the Anti-Crowding-Out firewall
> (D-079, ships in P1 firewall-first). No breaking schema changes to the
> locked-const firewall; G-003 production firewall intact (`x/cover` is a new
> module that references `x/pact`/`x/standing`/`x/bond` by ID-string only);
> G-006 go.mod unchanged (`x/cover` keeper uses existing cosmos-sdk runtime
> substrate). The final-phase audit enforces the feature purity gate.
### v0.8+ Milestone Requirements (Risk Mitigations + Infrastructure Economics — Deferred)
> All 31 REQs (REQ-067..REQ-097) are deferred to v0.8 per D-081 (§7
> authoritative). Listed here for traceability; not started this milestone.
| ID | Requirement | Vision § | Priority | Status | Target milestone |
|----|-------------|----------|----------|--------|------------------|
| REQ-067..REQ-072 | Cluster A — Trust-minimization attacks (audit cadence, bridge pause, Eye quorum, Watcher fork-recovery, Anchor concentration cap, RWA venue) | §7, §16, §20, §6 | High | Deferred | v0.8 |
| REQ-073..REQ-076 | Cluster B — Economic structural (sovereign reserve, Root Basket liquidity, MAB default recapture, Forex multi-venue) | §6, §13, §17 | High/Medium | Deferred | v0.8 |
| REQ-077..REQ-081 | Cluster C — Capture & centralization (governance capture, Processor FCFS, Partner/Pier capture, Pool governance capture, secession abuse) | §19, §15, §13, §16, §4.6 | High/Medium | Deferred | v0.8 |
| REQ-082..REQ-086 | Cluster D — Identity & reputation (Sybil, Window abuse, vouching cascade, norm chilling, registry identity) | §9.2, §10, §9.1, §9.4, §4.9, §11 | High/Medium | Deferred | v0.8 |
| REQ-087..REQ-091 | Cluster E — Adoption & organic (cycle defaults, charter ambiguity, cross-chain drift, fee-covenant override, adverse selection) | §16, §11, §7, §20, §18, §19 | Medium/High | Deferred | v0.8 |
| REQ-092..REQ-097 | Infrastructure Economics (relay fee schedule, coverage standing bonus, IYB with subordination, USZ classification, Watcher/Voucher compensation, Anchor no-Voice) | §15, §9.1, §17, §13, §7, §19 | High/Medium | Deferred | v0.8 |
The IDEATE stage ran the three ideation tiers (mechanical, backend-enriched,
cross-project) on the v0.6 milestone scope and ratified 6 ideas (IDEATE-09..
IDEATE-14) at full autonomy. Each IDEATE-NN maps to a REQ-ID in the v0.6
requirements table above. The user pre-validated the 5 screens + stack via
interactive questions during CLARIFY (Go html/template + HTMX, Go mock API
server, new `web/` dir, all 5 screens); IDEATE ratifies that validation.
Mechanical tier: v0.5 closed clean (7/7 REQs, 8 keeper packages ≥80% coverage,
G-003/locked-const firewalls intact, 5 P1+ flagged for v0.7+ mainnet-readiness);
no `lessons:`/`compound:` tags in v0.1..v0.5 history (convention unused).
Backend-enriched tier: confirmed the mock-server-over-real-Go-types approach
grounds the UI in the locked constants (D-067/D-073). Cross-project tier: no
applicable cross-project patterns (this is the project's first UI; no prior UI
conventions to inherit). Defaults accepted per full autonomy.
| IDEATE ID | REQ-ID | Category | Source | Confidence | Phase |
|-----------|--------|----------|--------|------------|-------|
| IDEATE-09 | REQ-040 | feature/ui | user `--ideate` request + D-066/D-067/D-068/D-071 | 0.92 | v0.6/P1 |
| IDEATE-10 | REQ-041 | feature/ui | user `--ideate` request + D-073 | 0.90 | v0.6/P2 |
| IDEATE-11 | REQ-042 | feature/ui | user `--ideate` request | 0.85 | v0.6/P3 |
| IDEATE-12 | REQ-043 | feature/ui | user `--ideate` request | 0.85 | v0.6/P4 |
| IDEATE-13 | REQ-044 | feature/ui | user `--ideate` request | 0.80 | v0.6/P5 |
| IDEATE-14 | REQ-045 | quality/security | D-069 + RESEARCH D-075 | 0.88 | v0.6/P1 |
Notes:
- IDEATE-09/14 ship in P1 (web foundation + firewall-first, same `web/` territory — vertical slice).
- IDEATE-10..13 ship in P2..P5 (one screen per phase, ordered by the Nomad happy path: Reach → Stash → Window → Standing → Bloom).
- The D-001 refinement-only filter does NOT apply (v0.6 is a feature milestone, not NFR).
## Milestone v0.1 Summary
- 10 REQs complete (skeleton + tests)
@@ -30,4 +241,15 @@
- 9 REQs pending (future milestones v0.2-v0.4)
- All locked constants verified by tests
- Lexicon fully compliant
- 48 unit tests passing across 11 modules
- 53 unit tests passing across 11 modules (G-001 corrected count)
## Milestone v0.2 Summary (The Mesh) — COMPLETE (skeleton + tests)
- 8 v0.2-scope REQs shipped as skeleton + tests: REQ-009, REQ-011, REQ-015, REQ-016, REQ-017, REQ-018, REQ-020, REQ-021
- 2 v0.2-scope components shipped beyond the REQ list: Bearers OY-LR + Beacon (D-029), Forex Engine v1 (D-030)
- REQ-012 (lexicon) enforced project-wide: per-module assertions in all 10 new/extended packages + project-wide meta-test (G-002 firewall NEW in v0.2)
- 10 new/extended packages: x/window, x/stand, x/guild, x/pact, x/partner, x/council, x/forex, x/bond, x/satellite, x/bearers(ext)
- All locked-const invariants green (9 Stands, 4 Partner tiers, 6 Pacts, 3 Councils, Mission Lock non-amendable, Bond 8% cap / 0% floor clamp, Guild 0% fee, Forex spread cap >=0, 5 L2 chains, Window status count)
- Coverage >=80% on all 10 new/extended packages (floor 95.9%, 8 of 10 at 100%)
- go.mod unchanged (zero external deps, G-006 / A-201)
- Tags: v0.1.0 (P0) -> v0.1.1 (P1) -> v0.1.2 (P2) -> v0.1.3 (P3) -> v0.1.4 (P4) -> v0.1.5 (P5 = v0.2 milestone release)
- Tag-line note (G-010): v0.1 pre-MVP shipped on the v0.0.x patch line (ROADMAP lines 4-13); v0.2 ships on the v0.1.x patch line (config tag_base). The v0.1.5 milestone release is NOT the deferred v0.1.0 "MVP" tag — they are different lines.
+2235 -1
View File
File diff suppressed because it is too large Load Diff
+19
View File
@@ -0,0 +1,19 @@
# v0.6 Review (Nomad Web UI)
## Multi-persona code review across P1..P5
### 8 adversarial probes
1. **`go run ./web` starts with no external deps (G-006)** — PASS. `git diff v0.5.0..HEAD -- go.mod go.sum` is empty. HTMX is a vendored static asset (`web/static/htmx.min.js`), NOT a `go get`. Zero new require lines across the v0.6 milestone.
2. **All 5 screens reachable from the home page** — PASS. Nav in `web/templates/base.html` links to /reach, /stash, /window, /standing, /bloom. Each route returns 200 (handler tests + smoke test on dynamic port 47077/53907).
3. **`lexicon_meta_web/` firewall scans `web/templates/**` + `web/static/**` + `web/**/*.go`** — PASS. `go test ./lexicon_meta_web/...` green; G-013 walk-coverage test injects a synthetic banned-term fixture and finds it.
4. **Bread-scale conversion matches `x/bread/types` code constants (D-073)** — PASS. `TestStashBreadScaleConversionCorrectness` asserts GrainsPerBread=10000, Crumb=100 Grain (code values); would FAIL if the outdated docs 1000x values were used.
5. **Standing score uses locked formula constants** — PASS. `TestStandingScoreComputedFromLockedConstants` asserts PriorMean=4.0, PriorWeight=10, ComputeDiversityBonus, GetVoucherWeight, GetStandingBucket (all from x/standing/types, NOT hardcoded).
6. **Freeholder-eligible badge reflects `IsFreeholderEligible()`** — PASS. `TestFreeholderEligibleBadgeReflectsMethod` asserts the rendered badge matches the real method output for both eligible (holder-alia) and non-eligible (holder-bryn) Reaches.
7. **Window lifecycle transitions call `Window.Activate/Revoke/Expire`** — PASS. `TestWindowActivateTransitionsOpenToActive` + `TestWindowRevokeTransitionsToRevoked` assert the real x/window/types methods are invoked (status transitions verified). `TestWindowRevokeOnExpiredIsNoOp` asserts the v0.2 terminal-state contract (revoke-on-expired is a no-op).
8. **No banned terms in any rendered page** — PASS. Per-handler rendered-HTML lexicon checks (G-026) in all 5 phases scan BOTH 200 happy-path AND error response bodies (400/404). `lexicon_meta_web/` file-scan firewall green on all web/**/*.{html,js,go} files.
### Verdict: SHIP. No P0 issues. No P1+ issues flagged.
### G-028 audit (go.mod diff against v0.5.0 baseline)
`git diff v0.5.0..HEAD -- go.mod go.sum` — EMPTY. v0.6 adds zero Go dependencies (HTMX is a vendored static asset). G-006 preserved across the milestone.
+216 -30
View File
@@ -14,44 +14,230 @@
- Status: COMPLETE (local-only ship, no remote configured)
- MVP release (v0.1.0) deferred until system validated as production-ready
## Phase 1 — Foundation (Year 1)
**Target**: first 10,000 Holders, 50 Master Ops
## Milestone v0.2 — The Mesh (COMPLETE)
- [x] P0: Pre-Execution → v0.1.0
- [x] P1: Orgs + Window Foundation → v0.1.1
- [x] P2: Pacts + Partners → v0.1.2
- [x] P3: Councils + Forex → v0.1.3
- [x] P4: Bonds + Bearers + L2 → v0.1.4
- [x] P5: Final Review + Ship → v0.1.5 (milestone release)
- Status: COMPLETE (skeleton + tests layer; released as v0.1.5)
| Component | Deliverable |
|---|---|
| OY Chain & Mirror (1) | L1 chain launched, 9 Watchers bonded, Mirror live |
| Bread Unit & Root Basket (3) | Forge/Fold on Ethereum + 23 L2s; initial Root Basket |
| Storage Substrate (5) | Stash, Vault, Root-Pool contracts |
| Bloom Engine (4) | Bloom accrual loop tied to Mirror attestations |
| Fee Covenant (13) | 0.1% ceiling live, processor share 50%, internal minimum 1 Grain |
| Identity, Standing & Citizenship (6) | Reach v1, Standing v1, Nomad/Freeholder system |
| Bearers & Processing Mesh (12) | Processing v1, OY-BLE, OY-WiFi-Direct |
| Mesh Experience (9) | Maps, Pay v1 |
## Milestone v0.3 — Bearers & Documentation (COMPLETE; feature type; tags v0.2.x)
Target: Bearers skeleton (ROADMAP Phase 3 subset) + docs site for nomads and freeholders.
## Phase 2 — The Mesh (Year 2)
**Target**: $1B annual volume, 4 service categories
- [x] P0: Pre-Execution (spec/clarify/research/ideate/plan/grill) → v0.2.0
- [x] P1: Docs foundation + REQ-012 firewall extension → v0.2.1
- [x] P2: Nomads docs → v0.2.2
- [x] P3: Freeholders docs + reference → v0.2.3 (REQ-027 complete)
- [x] P4: Bearers skeleton I (x/exit, x/bridge, x/bearers, x/partner) → v0.2.4
- [x] P5: Bearers skeleton II (x/hub, x/services, x/bond) → v0.2.5
- [x] P6: Final Review + Audit + Ship → v0.2.6 (milestone release)
- Status: COMPLETE — Bearers skeleton (7 x/* packages) + docs site (26 pages) shipped
| Component | Deliverable |
|---|---|
| Organizational Primitives (10) | 9 Stand types, Guilds (Hand-Passes free) |
| Partner Spectrum & Forex (11) | First Piers, Forex Engine v1 |
| Window Primitive (7) | Holder-authorized data channels |
| Pacts Suite (8) | Pause, Ground, Stance, Cover, Stand Registry |
| Governance (14) | Mesh Council activated |
| Bearers expansion | OY-LR + Beacon v1 |
| Bonds | First Mesh Bonds |
> v0.3 bundles two work-streams under one feature milestone: (A) Bearers
> skeleton+tests (D-020 pattern) and (B) README.md + MkDocs Material docs site
> organized by audience, with the REQ-012 lexicon firewall extended to docs.
## Phase 3 — The Bearers (Year 3)
| Phase | Type | Scope | Patch |
|---|---|---|---|
| P0 | docs | Pre-Execution (spec/clarify/research/ideate/plan/grill) | v0.2.0 |
| P1 | feat/test+docs | Docs foundation + REQ-012 firewall extension to docs/ + README.md + shared docs | v0.2.1 |
| P2 | docs | Nomads docs (docs/nomads/) | v0.2.2 |
| P3 | docs | Freeholders docs (docs/freeholders/) + docs/reference/ | v0.2.3 |
| P4 | feat | Bearers skeleton I: x/exit, x/bridge, x/bearers (OY-SAT, OY-QR), x/partner (Anchor) | v0.2.4 |
| P5 | feat | Bearers skeleton II: x/hub, x/services, x/bond (Growth Bonds + secondary market) | v0.2.5 |
| P6 | final | REVIEW + AUDIT + milestone SHIP | v0.2.6 (milestone release) |
### v0.3 Component mapping
| Component | Deliverable | v0.3 Skeleton Module | Phase |
|---|---|---|---|
| Cross-Chain & Exit (2) | L2/L1 bridge types, DEX swap types | x/exit, x/bridge | v0.3/P4 |
| Bearers expansion | OY-SAT, OY-QR bearer transport types | x/bearers (extended) | v0.3/P4 |
| Anchors | First institutional Partner tier | x/partner (extended: Anchor) | v0.3/P4 |
| Hub API | B2B backbone: custody, lending primitive, compliance types | x/hub | v0.3/P5 |
| Services | Care / SIM / Vault / Mail service types | x/services | v0.3/P5 |
| Bond market | Growth Bonds, secondary-market types | x/bond (extended) | v0.3/P5 |
| Documentation | README.md + MkDocs Material docs site | docs/, mkdocs.yml, README.md | v0.3/P1-P3 |
| Lexicon firewall | Extend REQ-012 to docs/ + README.md | lexicon_meta_docs_test.go | v0.3/P1 |
> **Tag-line note (G-010 continuation)**: v0.1 pre-MVP shipped on the `v0.0.x`
> patch line; v0.2 (The Mesh) shipped on the `v0.1.x` patch line; v0.3 (Bearers
> & Documentation) ships on the `v0.2.x` patch line (config.json `tag_base:
> v0.2.x`): P0 -> `v0.2.0`, P1..P5 -> `v0.2.1..v0.2.5`, P6 -> `v0.2.6`
> (= the v0.3 milestone release, per D-008 — final phase patch IS the
> milestone release; no separate minor tag).
## Milestone v0.4 — Refinement (COMPLETE; NFR type; tags v0.3.x)
Target: Close the v0.3 post-hoc forward-references (lexicon firewall drift,
hub↔bond const drift, council lifecycle type divergence) + land the deferred
docs build CI. Refinement-only NFR milestone: zero `feat:` phases.
- [x] P0: Pre-Execution (spec/clarify/research/plan/grill/mvp-ux) → v0.3.0
- [x] P1: Lexicon + const hardening (REQ-029, REQ-030) → v0.3.1
- [x] P2: Lifecycle divergence docs + regression guard (REQ-031) → v0.3.2
- [x] P3: Docs build CI (REQ-032) → v0.3.3
- [x] P4: Final Review + Audit + Ship → v0.3.4 (milestone release)
- Status: COMPLETE — 4 NFR REQs shipped; NFR purity gate GREEN (zero feat: commits); go.mod unchanged
> v0.4 closes three real v0.3 forward-references (GRILL G-014 lexicon helper,
> REVIEW P2/A-304 cross-const test, AUDIT §193 council divergence docs) and
> lands the D-046 docs-CI forward-reference. Live-runtime promotions of the
> v0.3 Bearers skeletons are deferred to v0.5+ (feat:-class, rejected by the
> D-001 refinement-only filter).
| Phase | Type | Scope | Patch |
|---|---|---|---|
| P0 | docs | Pre-Execution (spec/clarify/research/plan/grill/mvp-ux) | v0.3.0 |
| P1 | refactor+test | Lexicon shared helper (REQ-029) + cross-const test (REQ-030) | v0.3.1 |
| P2 | docs+test | Council lifecycle divergence docs (REQ-031) + regression guard | v0.3.2 |
| P3 | chore+ci | Docs build CI workflow (REQ-032) | v0.3.3 |
| P4 | final | REVIEW + AUDIT + milestone SHIP | v0.3.4 (milestone release) |
### v0.4 Component mapping
| Component | Deliverable | v0.4 Change | Phase |
|---|---|---|---|
| Lexicon firewall | Shared `SyntheticBannedStrings()` helper | `lexicon/lexicon.go` + both meta-tests refactored | v0.4/P1 |
| Mission-locked const firewall | Cross-package const-equality test | `x/hub/types/cross_const_test.go` (NEW) | v0.4/P1 |
| Council Voice/Council interface | Lifecycle divergence documentation + regression guard | ARCHITECTURE.md section + `x/council/types/types_test.go` intent test | v0.4/P2 |
| Docs CI | Gitea Actions workflow (build + artifact) | `.gitea/workflows/docs-build.yml` (NEW) | v0.4/P3 |
> **Tag-line note (G-010 continuation)**: v0.4 (NFR) ships on the `v0.3.x`
> patch line (config.json `tag_base: v0.3.x`): P0 -> `v0.3.0`, P1..P3 ->
> `v0.3.1..v0.3.3`, P4 -> `v0.3.4` (= the v0.4 milestone release, per D-008 —
> final phase patch IS the milestone release; no separate minor tag).
## Milestone v0.5 — Bearers Runtime (COMPLETE; feature type; tags v0.4.x)
Target: Promote the v0.3 Bearers skeletons from type+keeper-stub layers to
live runtime behavior (keeper MsgServer handlers + simtest-grade end-to-end
flows). NOT mainnet (D-020 pattern continues to govern network deployment);
runtime = simtest-grade message handlers, not mainnet deployment.
- [x] P0: Pre-Execution (spec/clarify/research/plan/grill/mvp-ux) → v0.4.0
- [x] P1: Exit + Bridge runtime (REQ-033) → v0.4.1
- [x] P2: Bearers transport runtime (REQ-034) → v0.4.2
- [x] P3: Anchors onboarding runtime (REQ-035) → v0.4.3
- [x] P4: Hub API B2B runtime (REQ-036) → v0.4.4
- [x] P5: Services runtime (REQ-037) → v0.4.5
- [x] P6: Bond market runtime (REQ-038) → v0.4.6
- [x] P7: Council governance runtime (REQ-039) → v0.4.7
- [x] P8: Final Review + Audit + Ship → v0.4.8 (milestone release)
- Status: COMPLETE — 7 runtime REQs shipped; all 8 keeper packages ≥80% coverage (82.1%..92.5%); G-003 firewall intact; locked-const firewall intact; cosmos-sdk v0.50.8 + ibc-go v8.2.1 added (D-055/D-062, G-006 controlled exception); 5 GRILL decisions ratified (D-055/D-062/D-063/D-064/D-065); 8 binding fixes landed (G-017..G-024); 5 P1+ flagged for v0.6+ mainnet-readiness.
| Phase | Type | Scope | Patch |
|---|---|---|---|
| P0 | docs | Pre-Execution (spec/clarify/research/plan/grill/mvp-ux) | v0.4.0 |
| P1 | feat | Exit + Bridge runtime: x/exit DEX swap routing + x/bridge IBC packet handlers (5 L2 chains) | v0.4.1 |
| P2 | feat | Bearers transport runtime: OY-SAT + OY-QR message handlers + session lifecycle | v0.4.2 |
| P3 | feat | Anchors onboarding runtime: x/partner Anchor credential lifecycle | v0.4.3 |
| P4 | feat | Hub API B2B runtime: custody/lending/compliance + CustodyKeyring interface (D-058) | v0.4.4 |
| P5 | feat | Services runtime: Care/SIM/Vault/Mail service lifecycle handlers | v0.4.5 |
| P6 | feat | Bond market runtime: Growth Bonds + secondary-market CLOB matching (REJECT above 800bps D-063) | v0.4.6 |
| P7 | feat | Council governance runtime: Proposal/VoteOption enums (AUDIT §193 P1-1) + MissionLockAmendment reject (D-064) | v0.4.7 |
| P8 | final | REVIEW + AUDIT + milestone SHIP | v0.4.8 (milestone release) |
### v0.5 Component mapping
| Component | Deliverable | v0.5 Runtime Module | Phase |
|---|---|---|---|
| Exit layer (Layer 3) | DEX swap routing + cross-chain exit handlers | x/exit/keeper + x/exit/module.go | v0.5/P1 |
| Bridge (L2↔L1) | IBC packet handlers (5 L2 chains, Solana wormhole-adapter) | x/bridge/keeper + x/bridge/module.go + ibc_module.go | v0.5/P1 |
| Bearers expansion | OY-SAT + OY-QR message handlers + session lifecycle | x/bearers/keeper + x/bearers/module.go | v0.5/P2 |
| Anchors | Anchor credential issuance/revocation runtime | x/partner/keeper + x/partner/module.go | v0.5/P3 |
| Hub API | Custody/lending/compliance runtime + CustodyKeyring interface | x/hub/keeper + x/hub/module.go + keyring_mem.go | v0.5/P4 |
| Services | Care/SIM/Vault/Mail service lifecycle runtime | x/services/keeper + x/services/module.go | v0.5/P5 |
| Bond market | Growth Bonds + secondary-market CLOB matching engine | x/bond/keeper + x/bond/module.go + clob.go | v0.5/P6 |
| Council governance | Proposal/VoteOption enums + governance message handlers | x/council/keeper + x/council/module.go | v0.5/P7 |
> **Tag-line note (G-010 continuation)**: v0.5 (feature) ships on the `v0.4.x`
> patch line (config.json `tag_base: v0.4.x`): P0 -> `v0.4.0`, P1..P7 ->
> `v0.4.1..v0.4.7`, P8 -> `v0.4.8` (= the v0.5 milestone release, per D-008 —
> final phase patch IS the milestone release; no separate minor tag).
### v0.5 deferred to v0.6+ (P1+ from REVIEW.md)
- P1 security: no proposal deposit/bond (governance spam gap — mainnet-readiness)
- P1 adversarial: CLOB per-tx front-running exposure (batch auction is a v0.6+ design)
- P1 maintainability: simtest doesn't exercise real IBC light-client verification
- P2 performance: CLOB `restingBookForBond` O(n) load (prefix-key for mainnet)
- P2 maintainability: `emitMatchEventHook` package-level mutable var (minor testability)
- SignalKind 4→5 expansion (AUDIT §193 P1-2; deferred to v0.6+ governance vote)
- Live chain launch / mainnet / real IBC channels / real bearer transports (D-020 continues)
- Real institutional Anchors onboarding (credential lifecycle in simtest only)
- Yield Token, Travel + 11 service categories (ROADMAP Phase 4 — Maturity)
## Milestone v0.6 — Nomad Web UI (COMPLETE; feature type; tags v0.5.x)
Target: The project's first UI milestone. 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 — no real blockchain interaction (D-020 continues).
Greenfield Go `html/template` + HTMX layer served by a Go mock HTTP server
(`web/`) that instantiates the real `x/*/types` structs from in-memory
fixtures. No keeper, no Cosmos runtime, no `app.go` (none exists in the repo).
- [x] P0: Pre-Execution (spec/clarify/research/ideate/plan/grill/mvp-ux) → v0.5.0
- [x] P1: Web foundation + Reach signup + lexicon firewall extension (REQ-040, REQ-045) → v0.5.1
- [x] P2: Stash dashboard (REQ-041) → v0.5.2
- [x] P3: Window authorization (REQ-042) → v0.5.3
- [x] P4: Standing + Freeholder signals (REQ-043) → v0.5.4
- [x] P5: Bloom accrual view (REQ-044) → v0.5.5
- [x] P6: Final Review + Audit + Ship → v0.5.6 (milestone release)
| Phase | Type | Scope | Patch |
|---|---|---|---|
| P0 | docs | Pre-Execution (spec/clarify/research/ideate/plan/grill/mvp-ux) | v0.5.0 |
| P1 | feat+test | Web foundation + Reach signup (REQ-040) + lexicon firewall extension to web/ (REQ-045) | v0.5.1 |
| P2 | feat | Stash dashboard: balance + Bread-scale conversion + 90-day maturity progress (REQ-041) | v0.5.2 |
| P3 | feat | Window authorization: open/lifecycle/audit-log view (REQ-042) | v0.5.3 |
| P4 | feat | Standing + Freeholder signals progress: computed from mock Ratings/Vouches/Slashes (REQ-043) | v0.5.4 |
| P5 | feat | Bloom accrual view: per-Stash BloomRecord (REQ-044) | v0.5.5 |
| P6 | final | REVIEW + AUDIT + milestone SHIP | v0.5.6 (milestone release) |
### v0.6 Component mapping
| Component | Deliverable | v0.6 Module | Phase |
|---|---|---|---|
| Web UI foundation | Go HTTP mock server + base templates + HTMX vendored | web/main.go, web/handlers/, web/store/, web/templates/, web/static/ | v0.6/P1 |
| Reach signup | "Create a Reach" form + Reach list/detail | web/handlers/reach.go, web/templates/reach.html | v0.6/P1 |
| Stash dashboard | Balance + Bread-scale + 90-day maturity | web/handlers/stash.go, web/templates/stash.html | v0.6/P2 |
| Window authorization | Open/lifecycle/audit-log view | web/handlers/window.go, web/templates/window.html | v0.6/P3 |
| Standing + Freeholder signals | Progress view from mock Ratings/Vouches/Slashes | web/handlers/standing.go, web/templates/standing.html | v0.6/P4 |
| Bloom accrual | Per-Stash BloomRecord view | web/handlers/bloom.go, web/templates/bloom.html | v0.6/P5 |
| Lexicon firewall | Extend REQ-012 to web/ | lexicon_meta_web/lexicon_meta_web_test.go | v0.6/P1 |
> **Tag-line note (G-010 continuation)**: v0.6 (feature) ships on the `v0.5.x`
> patch line (config.json `tag_base: v0.5.x`): P0 -> `v0.5.0`, P1..P5 ->
> `v0.5.1..v0.5.5`, P6 -> `v0.5.6` (= the v0.6 milestone release, per D-008 —
> final phase patch IS the milestone release; no separate minor tag).
### v0.6 deferred to v0.7+
- Real blockchain interaction / mainnet / IBC / real bearer transports (D-020 continues)
- A real `oyd` daemon / `app.go` / `cmd/oyd` (no chain runtime exists; wiring the UI to a real daemon is v0.7+)
- 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
- 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
- Bread-scale doc-fix (`docs/shared/bread-scale.md` is outdated vs code constants — P1+ follow-up, not v0.6 scope)
## Phase 3 — The Bearers (Year 3) — v0.3 PARTIAL SKELETON
**Target**: $10B annual volume → fee auto-declines to 0.07%
> v0.3 ships a skeleton+tests subset of Phase 3 (Cross-Chain/Exit, OY-SAT/OY-QR,
> Anchors, Hub API, Services, Bond market depth). Full runtime deferred to v0.4+.
| Component | Deliverable |
|---|---|
| Cross-Chain & Exit (2) | Full L2/L1 bridges, DEX integration |
| Bearers expansion | OY-SAT, OY-QR |
| Hub API | B2B backbone: custody, lending primitive, compliance |
| Anchors | First institutional partners |
| Services | Care / SIM / Vault / Mail |
| Bond market | Full market, Growth Bonds |
| Cross-Chain & Exit (2) | Full L2/L1 bridges, DEX integration (runtime deferred to v0.4) |
| Bearers expansion | OY-SAT, OY-QR (skeleton types in v0.3) |
| Hub API | B2B backbone: custody, lending primitive, compliance (skeleton types in v0.3) |
| Anchors | First institutional partners (skeleton types in v0.3) |
| Services | Care / SIM / Vault / Mail (skeleton types in v0.3) |
| Bond market | Full market, Growth Bonds (skeleton types in v0.3) |
## Phase 4 — Maturity (Years 45+)
**Target**: $50100B volume → fees auto-decline to 0.03%
+179
View File
@@ -0,0 +1,179 @@
# OpenYield Spec — spec-v3 (net new only)
Owner: <product owner>
Status: draft
Ingested as: oy-spec
> This spec contains ONLY net new requirements, constraints, and decisions for v0.7+ scope. Locked-vision baseline (REQ-001..REQ-021), shipped REQs (REQ-022..REQ-045 per `oy-state` §2), Principles [locked], Lexicon [locked], and pre-filled project invariants are NOT restated; they remain in force per vision v3.0 and prior spec revisions. This is the diff against spec-v2.
## 1. Objective
The v0.7 milestone delivers **Fraternal Groups Foundation** — Cover Pools, Chapter Federation, Mutual Aid Bonds, and the Anti-Capture Bill of Rights v0.2 — adapting the 18901930 fraternal benefit-society model for borderless digital service [3]. The v0.8+ roadmap layers in **Risk Mitigations + Infrastructure Economics** — Cluster AE risk register closures, infrastructure underwriting primitives (Relay Fee Schedule, Coverage Standing Bonus, IYB with subordination, USZ classification), Watcher/Voucher compensation, and the Anchor no-Voice clause. Two PO rulings bind this revision: **no subsidies** (Root-Pool operating-expense subsidies and transfer-payment analogs are forbidden), and **Anchor no-Voice** (Anchors — including Sovereign Anchors — receive preferred contract terms only, never governance Voice).
## 2. Vision source
Vision v3.0 [1]; SPEC-001 `oy-fraternal-groups` v0.2 [3]. All locked commitments remain in force; **no vision amendments proposed**.
## 3. Principles [locked]
Locked per vision v3.0 §2. Not restated.
## 4. Requirements (net new)
> REQ-001..REQ-021 are locked-vision baseline (in force, not restated). REQ-022..REQ-045 are shipped per `oy-state` §2 (not restated). New REQs continue from REQ-046.
### v0.7 — Fraternal Groups Foundation (REQ-046..REQ-066)
#### Fraternal Group Primitives (REQ-046..REQ-056)
| ID | Title | Vision § | Priority | Locked? | Acceptance criteria |
|----|-------|----------|---------|---------|---------------------|
| REQ-046 | Cover Pool Factory runtime | §16 | High | no | Factory rejects category launches below in-force reserve floor; supports Cover-Charter deployment; Watcher attestation pipeline operational; category staging per REQ-065 |
| REQ-047 | Cover Pool reserve target floor 1.5× annual contributions | §16 | High | yes | 1.5× minimum reserve codified in `x/pact/cover`; mission-lock semantic enforced; below-floor auto-pause of Cover-Fee routing |
| REQ-048 | Cover Pool reserve target ceiling 2.5× (governance-tunable within bounds) | §16 | High | no | Watcher escalation to 2.5× after 12 months operating history; Pool Council MAY vote within bounded range 1.5×–2.5× |
| REQ-049 | Cover Pool Standing gate minimums | §16, §9.3 | High | yes | Travel ≥ Trusted 4.0; Health-MCS ≥ Preferred 4.5; Pool Council MAY tighten but NEVER loosen below protocol minimum |
| REQ-050 | Cover-Fee tagging at protocol layer | §16 | High | yes | Cover-Fee Grains carry `category_tag`; settlement engine rejects category-mismatched Calls (FR-COVER-11); Pool-level fungibility preserved for net-reserve accounting |
| REQ-051 | Guild Charter + Common Bond requirement | §12 | Medium | yes | At formation: Common Bond declared + hash-pinned; Public Profile published (bond summary, disclaimers, Mason count or "private", Pier wrapper if any) |
| REQ-052 | Cover-Charter (Statement of Beliefs, dispute path, gate, holding period) | §16 | High | yes | Cover-Charter distinct from governance charter; signed by Cover Pool Host + witnessed by Watcher at deployment; amendments require Pool supermajority + 7-day cooling + Watcher + Counsel; protocol does NOT enforce SoB content (FR-CHTR-5) |
| REQ-053 | Chapter Federation (Parent/Chapter, secession terms, liens at founding) | §12 | High | no | Parent Guild + Chapters model; Chapters inherit + may tighten but not loosen; secession terms coded at founding; good-standing liens at founding (not freely increasable); Chapter retains mesh-level Voice (Pier does NOT carry Voice per FR-VOICE-6) |
| REQ-054 | Mutual Aid Bond (issuance ceiling 1×–3×, coupons in Cover Calls) | §17 | High | yes | Issuance ceiling mission-locked at 3× annual surplus; coupons payable in Cover Calls or mutual-aid credits (NEVER Bread); coupon rate bounded by `CouponCapBps=800`; use-of-proceeds locked to reserve build-out; default recapture per FR-MAB-7; Watcher attestation at deployment + quarterly audit |
| REQ-055 | Cover Claims Voucher role + bond + slashing | §9.4, §15 | High | no | Specialization of Voucher role; bond default 10× avg Call size per Pool; reviews each Call independently (no self-adjudication, FR-CPCV-2); slashing via §9.4 mechanism with cross-Pool applicability (NFR-SEC-8); bounded earnings |
| REQ-056 | Anti-Capture Bill of Rights v0.2 | §8.2 [3] | High | yes | 13 rights codified in code; cannot be amended or waived by any Charter; covers one-tap exit, no tax on personal Stash, audit-able Voice, cooling, Watcher inspection, Freeholder voucher, Counsel escalation, Anchored-Bread conversion, Wayfarer's Record, secession (founding terms), non-Cover-access, category-mismatch refusal |
#### Architect Recommendations Q1Q10 (REQ-057..REQ-066)
| ID | Title | Vision § | Priority | Locked? | Acceptance criteria |
|----|-------|----------|---------|---------|---------------------|
| REQ-057 | Household simplified — no formal Council, one-tap exit | §11 | Low | no | Household Stand may operate without formal Council; one-tap exit is the dispute path |
| REQ-058 | Confederation Voice — one-Stand-one-Vote, internal bundle | §11 | Medium | yes | Confederation aggregates member Stand Voice one-per-Stand; member Stands may bundle delegated Voice internally via §19 delegation |
| REQ-059 | Stand→Pier-customer boundary — escalation rule (TBD-X volume threshold) | §11, §13 | Medium | no | When annual volume > TBD-X, Stand is invited to Hub API; soft upgrade, not a ban. **TBD-X = PO ruling needed (§8)** |
| REQ-060 | Shadow vouch partial credit — 50% weight in Freeholder signal | §9.1 | Medium | yes | Shadow vouch weight = 0.5× in Community Endorsement signal (vs 1.0× for non-Shadow vouch) |
| REQ-061 | Disclaimer cadence — per charter signing | §11 | Low | yes | Jurisdictional disclaimer surfaced at every charter signing; not session-bounded |
| REQ-062 | Pool governance hybrid (Host + 3 elected + Watcher observer) | §16 | High | yes | Cover Pool Council = Pool Host + 3 Masons elected by Pool-eligible Masons + Watcher observer seat; Cover Calls require majority with Watcher observer present |
| REQ-063 | MAB holder — surplus seniority only, no Voice at dissolution | §17 | Medium | yes | Mutual Aid Bond holders rank after Cover-Fee contributors but before Bread holders in Pool-surplus distributions (FR-MAB-4); NO Voice in Pool dissolution decisions (claimants, not Masons) |
| REQ-064 | Secession cooling — 21d Cover-active / 14d non-Cover | §4.6 [3] | Medium | yes | Chapter secession cooling: 21 Mesh-days if Cover-active, 14 Mesh-days if non-Cover; secured at founding, not reducible; lien audit required; Cover Call / Bond covenant clearance required before secession completes |
| REQ-065 | Cover Pool category staging — Phase 2/3/4 | §16 | High | yes | Phase 2: Travel + Health-MCS + Income-Pause; Phase 3: Equipment/Loss + Life-Burial + Road-Side; Phase 4: Cyber-Skimming + Guild-Internal-Mutual-Aid; Factory respects staging and rejects out-of-phase launches |
| REQ-066 | Pier selection — Guild Council chooses, reversible, Pier Selection Index | §13 | Medium | no | Guild Council chooses Pier at formation; reversible by Cover Pool supermajority + Counsel witness; mesh maintains Pier Selection Index (jurisdictional reliability, fiduciary record, integration quality); SPEC-001 §8.3 Pier-Routed Legal Wrapper remains OPTIONAL with default-no-wrapper stance |
### v0.8+ — Risk Mitigations + Infrastructure Economics (REQ-067..REQ-097)
> All REQs in this section are LOCKED unless otherwise specified. Each maps to a Cluster AE mitigation in the risk register.
#### Cluster A — Trust-minimization attacks (REQ-067..REQ-072)
| ID | Title | Vision § | Priority | Locked? | Acceptance criteria |
|----|-------|----------|---------|---------|---------------------|
| REQ-067 | Smart contract audit cadence + bounty + Still/Stir | §20 | High | yes | ≥2 independent audits before each Phase transition (high/critical remediated pre-launch); severity-graded bounty (Cat >$1M, Crit >$100k); module-level Still/Stir with Watcher-witnessed halt; canary 5→25→100% on parameter changes; >72h time-lock on parameter changes unless emergency Mesh Council + Watcher witness |
| REQ-068 | Bridge pause semantics + multi-path + daily Watcher attestation | §7 | High | yes | `BridgeStatusCount=4` (Active→Paused→Frozen→Sunset); rate-limit per bridge per cycle; ≥2 independent paths for any mission-critical satellite; Watcher daily attestation of bridge balances (mismatch → auto-Still); Root-Pool-funded insurance pool |
| REQ-069 | Eye quorum ≥7 + diversity + TWAP | §16 | High | yes | ≥7 Eyes per asset class; median (not mean); geographic/organizational diversity (no single Eye jurisdiction >25% weight); TWAP minimum 1-hour window; outlier rejection at 2σ, alerts at 1σ; Eye reputation with slashing bond (Watcher model); mesh-level Still per asset class on variance breach |
| REQ-070 | Watcher 6-of-9 + 100k bond + daily cadence + fork-recovery | §7 | High | yes | All vision §7 + fork-recovery path: governance can fork from a captured Watcher set within Mission Lock bounds; rotation on instability metric (missed attestations, peer-deviation); slashing at 100,000 Bread bond per bad attestation |
| REQ-071 | Anchor concentration cap 20% + redemption gate + auto-Still | §6 | High | yes | Any single Anchored-Bread ≤20% of Root Basket (governance-tunable within bounds); 2472h redemption gate when anchor deviates >2% from peg; quarterly stress tests Watcher-witnessed; auto-Still for the affected stream |
| REQ-072 | RWA venue multi-custodian + multi-jurisdiction minimum + Watcher attestation | §6 | High | yes | ≥3 independent custodians with segregated accounts; jurisdictional spread preferred (≥2 jurisdictions where commercially feasible) but NOT load-bearing; Watcher attestation per venue (reserves, NAV, audit reports); Watcher + Counsel sign-off substitution path within 30 days; insurance where commercially available |
#### Cluster B — Economic structural (REQ-073..REQ-076)
| ID | Title | Vision § | Priority | Locked? | Acceptance criteria |
|----|-------|----------|---------|---------|---------------------|
| REQ-073 | Sovereign reserve — ≥2 independent entities | §6, §13 | High | yes | ≥2 independent reserve entities (different legal forms; jurisdiction selection driven by legal robustness + banking reliability + regulatory clarity — NOT by jurisdiction count); segregated accounts; quarterly third-party audits; ≥2 banking partners per currency; cash-equivalents only at entity level |
| REQ-074 | Root Basket liquidity tier 7-day 10%-redemption target | §6 | Medium | yes | Short-duration T-bills ~35% working (liquidity tier); 7-day, 10%-redemption stress test target <1% slippage; staged redemption gates above $X with 24h hold; facility lines at banking partners where feasible |
| REQ-075 | Mutual Aid Bond default recapture + seniority | §17 | High | yes | MAB coupons in Cover Calls (NEVER Bread, FR-MAB-3); seniority per FR-MAB-4 (Cover-Fee contributors > MAB > Bread holders); Watcher quarterly review + red-flag escalation on miss; default recapture per FR-MAB-7; mission-lock on bond covenant upper 8% lower 0% (REQ-021) |
| REQ-076 | Forex Engine multi-venue + Watcher daily attestation | §13 | Medium | yes | ≥3 counterparties per major currency corridor; annual counterparty due diligence; real-time exposure caps per counterparty; Watcher daily attestation on Forex reserve balances; governance substitution within composition bounds |
#### Cluster C — Capture & centralization (REQ-077..REQ-081)
| ID | Title | Vision § | Priority | Locked? | Acceptance criteria |
|----|-------|----------|---------|---------|---------------------|
| REQ-077 | Governance capture — multi-source Voice + Mission Lock + supermajority + cooling | §19 | High | yes | Multi-source Voice per §19 (Bread 1/1000, Freeholder full Voice, one-Mason-one-Vote opt-in, Guild delegation); Mission Lock list enforced (six non-amendable items); supermajority ≥67% + 7-day cooling on any governance action affecting funds; Watcher right of inspection on governance logs; `MissionLockAmendmentRejected` ProposalKind reverts amendment vote at protocol layer (D-064) |
| REQ-078 | Processor FCFS + dynamic share (parity with Processors, extended to relay operators per REQ-092) | §15 | High | yes | FCFS (not fee-auctioned); geographic proximity wins; light client ~30MB / 1-3% battery/day; dynamic processor share auto-declining 50→30→20→10% as volume grows |
| REQ-079 | Partner/Pier capture — self-service default + Window revoke + pay-to-play | §13 | High | yes | Self-service default (Principle 6) — no Partner required for any product; Window one-tap revoke; pay-to-play model (Piers pay OY, not reverse); mesh-level Pier Selection Index; Cover-Charter amendments require Watcher witness + Counsel signature; Chapter retains mesh-level Voice regardless of Pier fiduciary role |
| REQ-080 | Pool governance capture — hybrid + Cover Claims Voucher + Chapter secession | §16 | High | yes | Hybrid Pool governance (REQ-062); Cover Calls require majority with Watcher observer; Cover Claims Voucher independent adjudication (REQ-055); Chapter secession right (founding terms, REQ-064); MAB holders have NO Voice (REQ-063) |
| REQ-081 | Secession abuse — lien-bounding + cooling + lien audit | §4.6 [3] | Medium | yes | Good-standing liens codified at founding, not freely increasable (FR-CHAP-7); cooling periods 21d Cover-active / 14d non-Cover (REQ-064); secession requires Chapter Head signature + Good-Standing Lien audit; Cover Call / Bond covenant clearance required before secession completes; Parent Guild Treasury receives pro-rata Cover-Fee settlement for in-flight Cover Calls |
#### Cluster D — Identity & reputation (REQ-082..REQ-086)
| ID | Title | Vision § | Priority | Locked? | Acceptance criteria |
|----|-------|----------|---------|---------|---------------------|
| REQ-082 | Sybil on Standing — Bayesian prior + distinct counterparties + time decay | §9.2 | High | yes | Bayesian prior weight 10 (§9.2); minimum distinct counterparties 3/10/30; time-decayed Bayesian average penalizes burst-rating; Diversity Bonus × Voucher Weight resists amplification; Watcher attestation on Freeholder transition; 90-day Stash Maturity signal |
| REQ-083 | Window abuse — 5-property semantics + rate-limit enforcement | §10 | High | yes | Window enforces 5 properties (Scope, Duration, Rate-limit, Audit log, Revoke); one-tap revoke works mid-service; audit log exportable to Holder's Stash at any time; rate-limit enforcement is protocol-layer with Watcher-witnessed violations; Partner abuse triggers Window closure + Partner status review |
| REQ-084 | Vouching cascade — vouch slashes voucher + Watcher detection | §9.1, §9.4 | Medium | yes | Vouch requires skin-in-the-game — vouchee's Crack slashes voucher's Standing; Freeholder min-counterparties = 10 makes cascade hard to bootstrap; Watcher detection on rapid vouch sequences; Voucher Weight tapers with chain depth |
| REQ-085 | Norm chilling — Cover opt-in + dispute path + public profile | §4.9 [3] | Medium | yes | Cover opt-in (FR-NORM-4); non-participation MUST NOT deny other mesh products; norms enforced only through dispute path (FR-NORM-2), never unilateral Council; norm violations counted toward §9.4 slashing history (visible reputation consequence); Pool Public Profile must publish Statement if any (FR-CHTR-4 + FR-GLD-11); protocol never enforces SoB content |
| REQ-086 | Registry as identity — Shadow pseudonymity + schema minimalism | §11 | Medium | yes | Shadow Stand pseudonymous at protocol layer (FR-SHADOW-2 + §11); public/private visibility Holder-chosen (not Registry-mandated); Window reads explicit + rate-limited + logged + revocable (NFR-PRI-3); Watcher witness on private Stand registry access; Registry base schema excludes identity-grade fields (no names, no biometrics, no Pier credentials) |
#### Cluster E — Adoption & organic (REQ-087..REQ-091)
| ID | Title | Vision § | Priority | Locked? | Acceptance criteria |
|----|-------|----------|---------|---------|---------------------|
| REQ-087 | Cycle defaults — Cover integration + hash-committed order | §16, §11 | Medium | yes | Cover Pool integration with default ranking (SPEC-001 §12.1); Cycle Host order hash-committed (FR-CIRCLE-1); Counsel-mediated dispute path (FR-CIRCLE-4); Circle pauses via Still on default; restarts after resolution |
| REQ-088 | Charter ambiguity — linter + Counsel review | §11 | Low | yes | Charter-template linter at Registry submission (covers common fields); Counsel review at chartering for any non-standard Charter; Counsel right to amend within 30-day ambiguity window without unanimous Mason approval |
| REQ-089 | Cross-chain drift — Watcher per-chain attestation + canonical routing | §7, §20 | Medium | yes | Watchers re-attest per chain (per REQ-068 daily cadence + `BridgeStatusCount=4`); cross-chain Mirror registry with rolling snapshots per cycle for Stand Registry reads; Stand Registry reads route to canonical source via Window; drift triggers Paused bridge-status |
| REQ-090 | Fee-Covenant override — Mission Lock + smart-contract enforcement + Council vote revert | §18, §19 | High | yes | Fee ceiling 0.1%, floor 0.01%, 1-Grain minimum non-amendable (Mission Lock); smart-contract enforcement with Watcher attestation; `MissionLockAmendmentRejected` ProposalKind reverts any Council vote to amend (D-064); structural ceiling on governance capture |
| REQ-091 | Adverse selection — Standing gate + holding period + Pool tightening | §16 | High | yes | Standing-gate minimums mission-locked (REQ-049: Trusted 4.0 Travel / Preferred 4.5 Health-MCS); 30-day default holding period (FR-COVER-9); Pool tightening permitted but not loosening below protocol minimum; Cover Claims Voucher independent review of high-risk claims; aggregate statistics public (NFR-PRI-4) to drive Pool-level gate tuning |
#### Infrastructure Economics (REQ-092..REQ-097)
| ID | Title | Vision § | Priority | Locked? | Acceptance criteria |
|----|-------|----------|---------|---------|---------------------|
| REQ-092 | Relay Fee Schedule (parity with Processors, auto-declining) | §15 | High | yes | Per-packet relay fee for OY-LR / OY-BLE / OY-WiFi-Direct relay operators; paid in Bread from protocol-fee pool; auto-declining on same schedule as Processor share (50→30→20→10%); self-balancing supply (dense zones saturate, sparse zones attract premium) |
| REQ-093 | Coverage Standing Bonus (supplement Committed Capital, not replace) | §9.1 | Medium | yes | Sustained infrastructure provision (90-day rolling relay uptime, Watcher attestation) counts as a Standing signal; supplements (does NOT replace) Committed Capital (which remains 1+ active Pact OR 60+ day Guild per §9.1); same anti-gaming rules apply |
| REQ-094 | Infrastructure Yield Bond (with structural subordination) | §17 | Medium | yes | IYB coupon from Root-Pool infrastructure budget; bounded by `CouponCapBps=800`; use-of-proceeds locked to infrastructure build-out; standard IYB for high-density (commercial trade routes, dense corridors); subordinated IYB for transitional USZs (Anchor takes first loss, local fee revenue takes upside); Watcher attestation at deployment + every quarterly audit |
| REQ-095 | Universal Service Zone classification (no subsidies) | §13 | Medium | yes | USZ designation via Mesh Council vote within bounds; ≥3 criteria (density, strategic value, mission-aligned override); opt-in (zones choose to be USZs); **NO SUBSIDIES** — infrastructure capital sourced EXCLUSIVELY from (a) Anchor pre-commitment, (b) Sovereign Anchor partnerships, (c) subordinated IYB; market decides whether USZ infrastructure is built; **TBD-Z density formula = PO ruling needed (§8)** |
| REQ-096 | Watcher/Voucher operating-expense compensation (capped) | §7, §19 | Medium | yes | Watcher + Voucher compensation paid from Root-Pool operating budget; capped annually by Mesh Council vote within bounds; NOT from Bond Market fees (avoids fee-maximization race); audited quarterly by Watchers; Counsel = civic contribution, no operating-expense compensation |
| REQ-097 | Anchor no-Voice clause (Anchor = preferred terms, zero Voice) | §13, §19 | High | yes | Anchors (commercial per §13) — and Sovereign Anchors — receive preferred contract terms only; ZERO Voice in any Council; no Voice transfer to underlying governments or institutions; Pier Selection Index surfaces Anchor reputation; no "no masters" violation, no matter the Anchor's institutional weight |
## 5. Constraints (net new)
> Pre-filled project invariants (14 components, lexicon firewall, Mission Lock non-amendable, etc.) remain in force per spec-v2 and prior revisions; not restated.
Net new for spec-v3:
- **No subsidies.** Root-Pool operating-expense subsidies, transfer-payment analogs, and welfare-state derivatives are **FORBIDDEN**. Capital for infrastructure is sourced exclusively from Anchor pre-commitment underwriting, Sovereign Anchor partnerships, and subordinated Infrastructure Yield Bond structures. *(oy-spec-v3, PO ruling 2026-08-18)*
- **Anchor no-Voice.** Anchors (per §13) — including Sovereign Anchors — receive preferred contract terms only. **ZERO Voice in any Council.** No Voice transfer to underlying governments or institutions. *(oy-spec-v3, PO ruling 2026-08-18)*
- **Sovereign Anchors require separate SPEC.** The Sovereign Anchor framework is too infrastructure-scale to fold into oy-spec. Forthcoming SPEC: `oy-sovereign-anchors`. Until that SPEC ships, USZ infrastructure financing via Sovereign Anchor partnerships is **experimental, not load-bearing**. *(oy-spec-v3, PO ruling 2026-08-18)*
- **Pier-Routed Legal Wrapper is OPTIONAL.** Default stance: no wrapper required. SPEC-001 §8.3 menu is value-add for Chapters/Guilds that want a legal seat, not a requirement. *(oy-spec-v3, per session refactor)*
## 6. Lexicon [locked]
Locked per vision v3.0 §3. Not restated.
## 7. Milestone intent
- **Current shipped:** v0.6 (Nomad Web UI) — v0.5.6 — COMPLETE [2]
- **Next: v0.7 — Fraternal Groups Foundation.** Ships REQ-046..REQ-056 (Cover Pool Factory + Cover-Charter + Chapter Federation + Mutual Aid Bond + Cover Claims Voucher + Anti-Capture Bill v0.2) + REQ-057..REQ-066 (architect recommendations Q1Q10).
- *Acceptance:* ≥1 Cover Pool live on testnet with reserve enforcement + Standing gate + category tagging; ≥1 Parent Guild with Chapter in secession-eligible formation with good-standing liens declared at founding; ≥1 Mutual Aid Bond issuance with Cover-Call coupon settlement + use-of-proceeds lock to reserve build-out; Anti-Capture Bill v0.2 reviewed by bonded Counsel; pen-test ≥1 independent third party; high/critical findings remediated.
- **v0.8 — Risk Mitigations + Infrastructure Economics.** Ships REQ-067..REQ-097 (Cluster AE mitigations + Relay Fee Schedule + Coverage Standing Bonus + IYB + USZ classification + Watcher/Voucher compensation + Anchor no-Voice).
- *Acceptance:* all listed REQs in §4 v0.8+ implemented with regression tests; `oy-state` §3 const firewall extended with `EyeQuorumMin=7`, `AnchorConcentrationCapBps=2000`, `MABCouponMaxAnnualSurplusMultiple=3`, `BondIssuerSurplusCeilings={1×, 2×, 3×}`, `CoolingSecessionCoverActive=21 days`, `CoolingSecessionNonCover=14 days`, `CoverReserveFloorAnnualContribX=1.5`.
- **Out-of-scope additions for v0.7:**
- Sovereign Anchor SPEC (forthcoming; experimental only this milestone per §5)
- USZ classification runtime (deferred to v0.8 — depends on Anchor pre-commitment framework)
- Pre-existing deferred items per `oy-state` §4 remain deferred (not restated here)
## 8. Open questions (net new)
> Pre-existing PO decisions tracked in `oy-state` §7 remain open unless addressed here.
1. **TBD-X volume threshold** for Stand→Pier-customer boundary escalation (REQ-059). PO recommendation: **$100k annual Pass volume**.
2. **TBD-Z density formula** for USZ classification (REQ-095). PO recommendation: **<10 Holders per km² AND strategic value ≥ mission score, OR sovereign request, OR mission-aligned override via Mesh Council supermajority**.
3. **Sovereign Anchor SPEC scope decision.** Separate SPEC (`oy-sovereign-anchors`) or fold into `oy-pier`? PO recommendation: **separate SPEC** — Sovereign Anchors are infrastructure-scale, not legal-wrapper-scale; v0.7 status is *experimental, not load-bearing* per §5.
4. **Risk mitigation sequencing.** Which of REQ-067..REQ-091 ship in v0.7 vs deferred to v0.8? PO recommendation: **Cluster A + B + C ship in v0.7 (immediate existential + economic + capture risks); Cluster D + E + REQ-092..REQ-097 ship in v0.8 (immune-system + adoption + infrastructure economics).**
5. **Standing gate enforcement timing.** When does REQ-049 (Cover Pool Standing gate minimums) bind? At Cover Pool Factory runtime (v0.7) or at first live Cover Pool deployment (v0.7 acceptance)? PO recommendation: **Factory runtime — gates are protocol-layer.**
6. **Watcher/Voucher operating-expense cap.** Mesh Council vote within bounds; PO recommendation: **annual cap = 5% of Root-Pool Bloom, capped at $TBD-W absolute**.
7. **Anti-Crowding-Out Covenant enforcement code.** The covenant is in §1 / §2.3 of SPEC-001 [3]; does the ciagent codify it as a separate `x/cover` package firewall (parallel to lexicon_meta tests), or embed it in Cover Pool Factory validation? PO recommendation: **separate firewall** — parallel to lexicon meta-tests; rejects any code path that would route Cover-Fees outside contributor-pool semantics.
8. **MAB use-of-proceeds lock enforcement.** Per REQ-054, MAB proceeds are locked to reserve build-out. Does the ciagent codify this as tagged streaming with auto-Still on misuse detection, or as a Watcher-quorum-only release? PO recommendation: **tagged streaming + Watcher-witnessed release** (defense in depth).
The ciagent logs assumptions if these are unanswered at autonomy=full (decision_confidence_threshold 0.6, clarify_budget 10).
## 9. Changelog
| Spec-v | Date | Commit (docs(spec):) | What changed | REQs affected |
|--------|------|----------------------|--------------|---------------|
| v1 | (template date) | docs(spec): initial spec | Pre-filled REQ-001..REQ-021 locked-vision baseline; §1, §2, §5, §6 placeholders | REQ-001..REQ-021 |
| v2 | 2026-08-18 | docs(spec): v0.7 fraternal groups + risk mitigations + infrastructure economics, no subsidies, anchor no-voice | Comprehensive revision — added REQ-046..REQ-097 (52 new REQs); folded SPEC-001 v0.2 §4 functional requirements and §12.2 architect recommendations as protocol REQs; eliminated Root-Pool subsidy layer; added Anchor no-Voice clause and Sovereign Anchors as separate SPEC; Pier-Routed Legal Wrapper downgraded to OPTIONAL; three new constraints added to §5; §1 expanded with fraternal scope; §7 split into v0.7 + v0.8 plan; 8 open questions logged | REQ-046..REQ-097 (new); §1, §5, §7, §8 |
| **v3** | **2026-08-18** | **docs(spec): net new only — trim restated sections** | **Trim: removed restated REQ-001..REQ-021 baseline table (locked, in force), locked Principles restatement, locked Lexicon restatement, pre-filled project invariants from §5; §2 reduced to vision document reference only; §6 reduced to locked-pointer; §7 reduced to v0.7 + v0.8 milestone plan + new out-of-scope additions. Keep: REQ-046..REQ-097 unchanged; new §5 constraints unchanged; 8 new §8 questions unchanged.** | **No new REQs; net-new-only diff against spec-v2** |
---
## Rules (PO ↔ ciagent contract)
Locked per spec-v2 / template. Not restated.
## Commit convention
Locked per spec-v2 / template. Not restated.
---
End of `oy-spec` v3 — net-new-only diff against spec-v2. Awaiting ciagent acknowledgment + P0 generation of new `oy-state` per regeneration rule in §7.
+176
View File
@@ -0,0 +1,176 @@
<!--
OpenYield Spec Template
=======================
Copy this file to `.ciagent/oy/oy-spec` (no .md extension) when starting a
fresh milestone cycle. Then fill every `<!-- TODO -->` placeholder and commit
with a `docs(spec):` Conventional Commit (see the commit convention block at
the bottom of this file).
What is pre-filled (do NOT edit unless the locked baseline itself changes):
- §3 Principles [locked] (Six Principles)
- §5 Constraints (project invariants)
- §6 Lexicon [locked] (banned terms + replacements)
- §4 REQ-001..REQ-021 (locked-vision baseline rows — acceptance criteria
still need filling on first use)
- Rules + Commit convention blocks
What the PO must fill per milestone:
- §1 Objective
- §2 Vision source (locked sections list)
- §4 acceptance criteria for every REQ the milestone implements
- §4 new REQ-NNN rows for milestone-specific additions (continuing the ID
sequence from the last shipped REQ — check `oy-state` §2 for the current
max REQ-ID)
- §7 Milestone intent (current shipped, next target, out-of-scope)
- §8 Open questions for the ciagent
- §9 Changelog (one row per spec revision)
-->
# OpenYield Spec — spec-vN
Owner: <product owner>
Status: draft
Ingested as: oy-spec
> This is the **only** document the product owner (PO) sends to the ciagent for
> implementation. The ciagent consumes it to regenerate PROJECT.md,
> REQUIREMENTS.md, and ARCHITECTURE.md. Nothing else is read from upstream.
>
> In return the ciagent maintains **one** sibling file — `oy-state` — that tells
> the PO what exists, what's locked, what's deferred, and what drift exists
> between this spec and the shipped code. Read `oy-state` before editing
> `oy-spec`; it prevents re-proposing shipped or explicitly-deferred REQs.
>
> `oy-spec` and `oy-state` are the only two docs that cross the PO<->ciagent
> boundary. All other `.ciagent/oy/*.md` files are ciagent-internal working
> memory — do not edit them.
## 1. Objective
<!-- TODO: <=3 sentences — what the mesh is and who it serves -->
## 2. Vision source
- Document: Vision v3.0 (22 sections)
- Locked commitments (non-amendable sections):
<!-- TODO: list section numbers that are non-amendable -->
## 3. Principles [locked]
1. Real value
2. Sustainability
3. Mission-lock
4. Openness
5. Ownership
6. Self-service
<!-- Do not edit — the ciagent enforces these as REQ-001. -->
## 4. Requirements
<!-- Each row: ID | Title | Vision § | Priority | Locked? | Acceptance criteria
REQ-001..REQ-021 are the locked-vision baseline — fill their acceptance
criteria on first use, do not change their Locked? column without an
explicit §9 changelog override.
Add new milestone-specific REQs below REQ-021, continuing the ID sequence
from the last shipped REQ (check `oy-state` §2 for the current max). -->
| ID | Title | Vision § | Priority | Locked? | Acceptance criteria |
|----|-------|----------|---------|---------|---------------------|
| REQ-001 | Enforce Six Principles | §2 | High | yes | <!-- TODO: criteria --> |
| REQ-002 | Fee ceiling 0.1% / floor 0.01% / 1-Grain min | §18 | High | yes | <!-- TODO: criteria --> |
| REQ-003 | Bloom from real production only (Root Basket) | §6 | High | yes | <!-- TODO: criteria --> |
| REQ-004 | 9 Watchers, 6-of-9 quorum | §7 | High | yes | <!-- TODO: criteria --> |
| REQ-005 | Four Freeholder signals | §9.1 | High | yes | <!-- TODO: criteria --> |
| REQ-006 | Standing anti-gaming formula | §9.2 | High | yes | <!-- TODO: criteria --> |
| REQ-007 | FCFS processing | §15 | High | yes | <!-- TODO: criteria --> |
| REQ-008 | OY Chain (Layer 1) | §7 | High | yes | <!-- TODO: criteria --> |
| REQ-009 | Satellite chains (Layer 2) | §7 | Medium | no | <!-- TODO: criteria --> |
| REQ-010 | Exit layer (Layer 3) | §7 | Medium | no | <!-- TODO: criteria --> |
| REQ-011 | Three Councils with Mission Lock | §19 | High | yes | <!-- TODO: criteria --> |
| REQ-012 | Lexicon compliance | §3 | High | yes | <!-- TODO: criteria --> |
| REQ-013 | Bread unit with scale | §4 | High | yes | <!-- TODO: criteria --> |
| REQ-014 | Three pools of storage | §5 | High | yes | <!-- TODO: criteria --> |
| REQ-015 | Window primitive | §10 | High | no | <!-- TODO: criteria --> |
| REQ-016 | Nine Stand types | §11 | Medium | yes | <!-- TODO: criteria --> |
| REQ-017 | Guilds with free Hand-Passes | §12 | Medium | yes | <!-- TODO: criteria --> |
| REQ-018 | Four-tier Partner Spectrum | §13 | Medium | no | <!-- TODO: criteria --> |
| REQ-019 | Six bearers via Unified Bearer Layer | §14 | Medium | yes | <!-- TODO: criteria --> |
| REQ-020 | Six Pacts | §16 | Medium | no | <!-- TODO: criteria --> |
| REQ-021 | Mesh Bond Market with 8% cap | §17 | Medium | yes | <!-- TODO: criteria --> |
| <!-- TODO: REQ-022 --> | <!-- TODO: title --> | <!-- TODO: vision § --> | <!-- TODO: priority --> | <!-- TODO: locked? --> | <!-- TODO: criteria --> |
| <!-- TODO: REQ-023 --> | <!-- TODO: title --> | <!-- TODO: vision § --> | <!-- TODO: priority --> | <!-- TODO: locked? --> | <!-- TODO: criteria --> |
## 5. Constraints
- 14 modular components, 6 cross-component interfaces
- Mission Lock non-amendable
- Lexicon firewall: banned terms = bank, deposit, interest, yield, currency, dollar/euro, account, savings, depositor
- Skeleton-first until mainnet gate (D-020 pattern)
- Zero Go deps except GRILL-approved runtime exceptions
- Coverage >=80% on shipped packages
- Multi-project mode active; project slug = `oy`
## 6. Lexicon [locked]
- Banned: bank, deposit, interest, yield, currency, dollar, euro, account, savings, depositor
- Required replacements: account -> Reach, deposit -> receive-asset, interest -> coupon, yield -> bloom, currency -> asset
## 7. Milestone intent
- Current shipped: <!-- TODO: last shipped milestone tag, e.g. v0.6 (v0.5.6) — COMPLETE -->
- Next: <!-- TODO: next milestone target — one paragraph + the REQ-IDs it draws from -->
- Out-of-scope this milestone:
<!-- TODO: list — cross-reference `oy-state` §4 (deferred) to avoid re-proposing -->
## 8. Open questions for the ciagent
<!-- TODO: bulleted; the ciagent logs assumptions if unanswered at autonomy=full -->
## 9. Changelog
<!-- Every spec revision MUST be a `docs(spec):` commit (see commit convention
block below). One row per revision. -->
| Spec-v | Date | Commit (docs(spec):) | What changed | REQs affected |
|--------|------|----------------------|--------------|---------------|
| v1 | <!-- TODO: date --> | <!-- TODO: docs(spec): initial spec --> | <!-- TODO: summary --> | <!-- TODO: REQ-IDs --> |
---
## Rules (PO <-> ciagent contract)
- **One document each way.** No slides, sidecar notes, or direct edits to
generated docs. If you want to change product intent, edit `oy-spec`; if you
want to know what the ciagent did, read `oy-state`.
- **Every REQ the ciagent implements must appear in §4 with acceptance
criteria.** Vague criteria -> vague implementation.
- **Mark a REQ `[locked]`** only if a future spec revision must not change it
without an explicit override line in §9. Locked REQs map to const firewalls
the ciagent defends with regression tests (listed in `oy-state` §3).
- **The ciagent regenerates `oy-state` at every milestone ship AND at P0 of the
next milestone if `oy-spec` changed since the last `oy-state`.** `oy-state`
is authoritative for "what exists"; PROJECT.md is internal working memory.
- **Before proposing a new REQ in §4, grep `oy-state` §2 (coverage) and §4
(deferred).** Re-proposing an existing or explicitly-deferred REQ is a no-op
and will be flagged as drift in the next `oy-state` §5.
- **When §4 or §6 change,** the ciagent regenerates PROJECT/REQUIREMENTS and
flags drift. When §3/§5 only change, no regeneration is needed.
## Commit convention (mandatory for `oy-spec`)
- Every commit that modifies `oy-spec` MUST use a Conventional Commit message:
```
docs(spec): <imperative summary <=72 chars>
<optional body: what changed in §4/§6 and why; >=1 line per REQ affected>
```
- Examples:
```
docs(spec): add REQ-046 governance spam deposit (§4)
Opens REQ-046 per oy-state §4 deferred item "governance spam deposit".
Acceptance: proposal deposit >= 1 Loaf, slashable on spam.
```
```
docs(spec): lock REQ-002 fee ceiling — overrideable only via §9
REQ-002 fee covenant locked per vision §18; any future change requires an
explicit §9 changelog override line.
```
- The ciagent will **REJECT** any `oy-spec` commit whose subject does not match
`docs(spec): ...`. This makes spec changes grep-able and ties each
implementation milestone back to the spec revision that authorized it (via
the §9 Changelog row referenced in the commit body).
- **No squash merges, `fixup!`, or empty commits** for `oy-spec`. Every edit
is a real `docs(spec):` commit on the default branch.
+245
View File
@@ -0,0 +1,245 @@
# OpenYield State — state-v2
Generated: 2026-08-19
Milestone: v0.7 (Fraternal Groups Foundation) — IN PROGRESS (P0 SPECIFY)
Tag: v0.6.x patch line (P0 → v0.6.0)
Ingested as: oy-state
> This is the **only** document the ciagent sends to the product owner (PO)
> to communicate current project state. The PO reads it before editing
> `oy-spec`. `oy-spec` and `oy-state` are the only two docs that cross the
> PO<->ciagent boundary; all other `.ciagent/oy/*.md` files are ciagent-internal
> working memory.
>
> Regeneration rule: the ciagent regenerates this file at every milestone ship
> AND at P0 of the next milestone if `oy-spec` changed since the last `oy-state`.
> `oy-state` is authoritative for "what exists"; PROJECT.md is internal working
> memory.
## 1. Current position
- Last shipped: v0.6 (Nomad Web UI) — COMPLETE — tag v0.5.6 (release_id 776)
- Next queued: v0.7 — Fraternal Groups Foundation (REQ-046..REQ-066, 21 REQs)
- Release forge: Gitea (git.cloudinit.dev/oy/openyield), release_id 776
- Autonomy: full (decision_confidence_threshold 0.6, clarify_budget 10)
- Open `oy-spec` §8 questions answered by ciagent: 8 (D-074..D-081 — all PO recommendations accepted as binding; see §7)
- v0.7 tag line: v0.6.x (previous minor's patch line per branch-strategy). P0 → v0.6.0; P1..P5 → v0.6.1..v0.6.5; P6 final → v0.6.6 (= v0.7 milestone release). No separate minor tag (D-008).
- v0.7 milestone type: feature (REQ-046..REQ-066 are feat-class primitives + a small number of test/docs adjuncts)
- v0.7 scope ruling (D-081): §7 is authoritative — v0.7 ships REQ-046..REQ-066 only; Cluster AE + Infrastructure Economics (REQ-067..REQ-097) all defer to v0.8.
### Milestone history (compact)
| Milestone | Type | Tag | Status |
|-----------|------|-----|--------|
| v0.1 Foundation Init | feat | v0.0.9 | COMPLETE |
| v0.2 The Mesh | feat | v0.1.5 | COMPLETE |
| v0.3 Bearers & Docs | feat | v0.2.6 | COMPLETE |
| v0.4 Refinement | NFR | v0.3.4 | COMPLETE |
| v0.5 Bearers Runtime | feat | v0.4.8 | COMPLETE |
| v0.6 Nomad Web UI | feat | v0.5.6 | COMPLETE |
| v0.7 Fraternal Groups Foundation | feat | v0.6.x (in progress) | IN PROGRESS — P0 SPECIFY |
## 2. Requirement coverage
<!-- Mirror of oy-spec §4. Status: Not started | Skeleton | Runtime | Complete | Deferred | Rejected -->
### Shipped baseline (REQ-001..REQ-045) — per state-v1, unchanged this regeneration
| REQ | Title | Status | Shipped in | Module(s) | Locked? |
|-----|-------|--------|-----------|----------|---------|
| REQ-001 | Enforce Six Principles | Skeleton | v0.1 | x/* | yes |
| REQ-002 | Fee ceiling 0.1% / floor 0.01% / 1-Grain min | Complete | v0.1 | x/feecovenant | yes |
| REQ-003 | Bloom from real production only (Root Basket) | Complete | v0.2 | x/bread, x/bloom | yes |
| REQ-004 | 9 Watchers, 6-of-9 quorum | Complete | v0.1 | x/watcher | yes |
| REQ-005 | Four Freeholder signals | Complete | v0.2 | x/standing | yes |
| REQ-006 | Standing anti-gaming formula | Complete | v0.2 | x/standing | yes |
| REQ-007 | FCFS processing | Complete | v0.1 | x/bearers | yes |
| REQ-008 | OY Chain (Layer 1) | Skeleton | v0.1 | x/* (no app.go yet) | yes |
| REQ-009 | Satellite chains (Layer 2) | Skeleton | v0.2 | x/satellite | no |
| REQ-010 | Exit layer (Layer 3) | Runtime | v0.5 | x/exit, x/bridge | no |
| REQ-011 | Three Councils with Mission Lock | Runtime | v0.5 | x/council | yes |
| REQ-012 | Lexicon compliance | Complete | v0.1 (x/), v0.3 (docs/), v0.6 (web/) | lexicon_meta_test, lexicon_meta_docs, lexicon_meta_web | yes |
| REQ-013 | Bread unit with scale | Complete | v0.1 | x/bread | yes |
| REQ-014 | Three pools of storage | Complete | v0.1 | x/stash, x/vault, x/rootpool | yes |
| REQ-015 | Window primitive | Runtime | v0.2 (skeleton), v0.6 (UI) | x/window | no |
| REQ-016 | Nine Stand types | Skeleton | v0.2 | x/stand | yes |
| REQ-017 | Guilds with free Hand-Passes | Skeleton | v0.2 | x/guild | yes |
| REQ-018 | Four-tier Partner Spectrum | Runtime | v0.5 | x/partner | no |
| REQ-019 | Six bearers via Unified Bearer Layer | Runtime | v0.5 | x/bearers | yes |
| REQ-020 | Six Pacts | Skeleton | v0.2 | x/pact | no |
| REQ-021 | Mesh Bond Market with 8% cap | Runtime | v0.5 | x/bond | yes |
| REQ-022..REQ-045 | (shipped v0.3..v0.6 — see state-v1 §2 for full table) | Complete | v0.3..v0.6 | various | mixed |
### v0.7 — Fraternal Groups Foundation (REQ-046..REQ-066) — Not started
| REQ | Title | Vision § | Priority | Locked? | Status | Target phase |
|-----|-------|----------|---------|---------|--------|--------------|
| REQ-046 | Cover Pool Factory runtime | §16 | High | no | Not started | v0.7/P1 |
| REQ-047 | Cover Pool reserve target floor 1.5× annual contributions | §16 | High | yes | Not started | v0.7/P1 |
| REQ-048 | Cover Pool reserve target ceiling 2.5× (governance-tunable) | §16 | High | no | Not started | v0.7/P2 |
| REQ-049 | Cover Pool Standing gate minimums | §16, §9.3 | High | yes | Not started | v0.7/P1 |
| REQ-050 | Cover-Fee tagging at protocol layer | §16 | High | yes | Not started | v0.7/P1 |
| REQ-051 | Guild Charter + Common Bond requirement | §12 | Medium | yes | Not started | v0.7/P3 |
| REQ-052 | Cover-Charter (SoB, dispute path, gate, holding period) | §16 | High | yes | Not started | v0.7/P2 |
| REQ-053 | Chapter Federation (Parent/Chapter, secession terms, liens) | §12 | High | no | Not started | v0.7/P3 |
| REQ-054 | Mutual Aid Bond (issuance ceiling 1×–3×, coupons in Cover Calls) | §17 | High | yes | Not started | v0.7/P4 |
| REQ-055 | Cover Claims Voucher role + bond + slashing | §9.4, §15 | High | no | Not started | v0.7/P4 |
| REQ-056 | Anti-Capture Bill of Rights v0.2 | §8.2 [3] | High | yes | Not started | v0.7/P5 |
| REQ-057 | Household simplified — no formal Council, one-tap exit | §11 | Low | no | Not started | v0.7/P3 |
| REQ-058 | Confederation Voice — one-Stand-one-Vote, internal bundle | §11 | Medium | yes | Not started | v0.7/P3 |
| REQ-059 | Stand→Pier-customer boundary — escalation rule ($100k per D-074) | §11, §13 | Medium | no | Not started | v0.7/P5 |
| REQ-060 | Shadow vouch partial credit — 50% weight in Freeholder signal | §9.1 | Medium | yes | Not started | v0.7/P4 |
| REQ-061 | Disclaimer cadence — per charter signing | §11 | Low | yes | Not started | v0.7/P3 |
| REQ-062 | Pool governance hybrid (Host + 3 elected + Watcher observer) | §16 | High | yes | Not started | v0.7/P2 |
| REQ-063 | MAB holder — surplus seniority only, no Voice at dissolution | §17 | Medium | yes | Not started | v0.7/P4 |
| REQ-064 | Secession cooling — 21d Cover-active / 14d non-Cover | §4.6 [3] | Medium | yes | Not started | v0.7/P5 |
| REQ-065 | Cover Pool category staging — Phase 2/3/4 | §16 | High | yes | Not started | v0.7/P2 |
| REQ-066 | Pier selection — Guild Council chooses, reversible, Pier Selection Index | §13 | Medium | no | Not started | v0.7/P5 |
### v0.8+ — Risk Mitigations + Infrastructure Economics (REQ-067..REQ-097) — Deferred to v0.8
> All 31 REQs deferred to v0.8 per D-081 (§7 authoritative). Listed here for visibility; not started this milestone.
| REQ | Title | Status | Target milestone |
|-----|-------|--------|-----------------|
| REQ-067..REQ-072 | Cluster A — Trust-minimization attacks | Deferred | v0.8 |
| REQ-073..REQ-076 | Cluster B — Economic structural | Deferred | v0.8 |
| REQ-077..REQ-081 | Cluster C — Capture & centralization | Deferred | v0.8 |
| REQ-082..REQ-086 | Cluster D — Identity & reputation | Deferred | v0.8 |
| REQ-087..REQ-091 | Cluster E — Adoption & organic | Deferred | v0.8 |
| REQ-092..REQ-097 | Infrastructure Economics | Deferred | v0.8 |
## 3. Locked constants (const firewall)
<!-- Amending any row requires an explicit override line in oy-spec §9. -->
| Const | Value | Module | Why locked |
|-------|-------|--------|-----------|
| MissionLockAmendable | false | x/council | vision §19 — non-amendable |
| CouponCapBps | 800 (8%) | x/bond | vision §17, D-028 |
| CouponFloorBps | 0 (0%) | x/bond | vision §17, D-028 |
| LendingCouponCapBps | 800 | x/hub | D-028 mirror (REQ-030 cross-const test) |
| LendingCouponFloorBps | 0 | x/hub | D-028 mirror (REQ-030 cross-const test) |
| HandPassFeeBps | 0 | x/guild | vision §12, D-025 |
| FeeCeilingBps | 10 (0.1%) | x/feecovenant | vision §18, REQ-002 |
| FeeFloorBps | 1 (0.01%) | x/feecovenant | vision §18, REQ-002 |
| SignalKindCount | 4 | x/standing | AUDIT §193 P1-2 (defensible; expansion deferred to v0.7+ governance) |
| BearerTypeCount | 6 | x/bearers | vision §14, REQ-019 |
| BridgeStatusCount | 4 | x/bridge | D-036 |
| ExitStatusCount | 5 | x/exit | D-036 |
| PartnerTierCount | 4 | x/partner | vision §13, REQ-018 |
| StandTypeCount | 9 | x/stand | vision §11, REQ-016 |
| PactTypeCount | 6 | x/pact | vision §16, REQ-020 |
| CouncilKindCount | 3 | x/council | vision §19, REQ-011 |
| WatcherQuorum | 6-of-9 | x/watcher | vision §7, REQ-004 |
| WatcherVetoQuorum | 6 (param-tunable [2,9]) | x/council | D-065 |
| MaturityThresholdDays | 90 | x/stash | vision §9.1, REQ-005 |
| GrainsPerBread | 10000 | x/bread | vision §4, REQ-013 |
| TargetBloomRateBasisPoints | 450 (4.5%) | x/bloom | vision §6 |
| AnchorCredentialStatusCount | 4 | x/partner | v0.5 addition (additive) |
| OYSATLink.SurveillanceResistant | true | x/bearers | vision §14, locked |
| ServiceKindCount | 4 | x/services | D-040 |
| HubServiceCount | 3 | x/hub | D-039 |
### v0.7 planned const additions (GRILL-ratified D-086..D-090)
| Const | Value | Module | REQ | Why locked |
|-------|-------|--------|-----|-----------|
| CoverReserveFloorAnnualContribX | 1.5 | x/cover (NEW) | REQ-047 (locked) | vision §16 — 1.5× annual contributions floor, mission-locked |
| CoverReserveCeilingAnnualContribX | 2.5 | x/cover (NEW) | REQ-048 (not locked — governance-tunable within 1.5×–2.5×) | vision §16 — upper bound of bounded range |
| CoverStandingGateTrusted | 4.0 (Trusted bucket) | x/cover (NEW) | REQ-049 (locked) | vision §16, §9.3 — Travel gate minimum (Pool MAY tighten, NEVER loosen). D-090(3): enforced at BOTH launch handler AND Params-amendment ValidateBasic. |
| CoverStandingGatePreferred | 4.5 (Preferred bucket) | x/cover (NEW) | REQ-049 (locked) | vision §16, §9.3 — Health-MCS gate minimum. D-090(3): dual check. |
| MABIssuanceCeilingAnnualSurplusMultiple | 3 | x/bond (extended) | REQ-054 (locked) | vision §17 — 3× annual surplus mission-locked ceiling |
| MABCouponCapBps | 800 (reuse CouponCapBps) | x/bond | REQ-054 (locked) | coupon bounded by existing CouponCapBps (D-028) — no new const, cross-const test extends |
| CoolingSecessionCoverActiveDays | 21 | x/guild (extended) | REQ-064 (locked) | vision §4.6 — secession cooling, secured at founding, not reducible |
| CoolingSecessionNonCoverDays | 14 | x/guild (extended) | REQ-064 (locked) | vision §4.6 — secession cooling, secured at founding, not reducible |
| StandPierEscalationAnnualPassVolumeCents | 10000000 ($100k in Grain-cents) | x/stand (extended) | REQ-059 (not locked) | D-074 ruling — TBD-X = $100k annual Pass volume; simtest placeholder (no USD/Grain oracle) |
| CoverClaimsVoucherBondMultipleAvgCall | 10 | x/cover (NEW) | REQ-055 (not locked) | §9.4, §15 — bond default 10× avg Call size per Pool. D-090(2): bond = max(10× avg, MinimumVoucherBond) — cold-start fallback. |
| AntiCaptureBillOfRightsCount | 13 | x/cover (NEW) | REQ-056 (locked) | §8.2 — 13 rights codified, non-amendable, non-waivable. D-090(1): RightID type + 13 Waivable* consts + ValidateBasic gate land in P2 (NOT P5). |
| ShadowVouchWeightMultiplier | 0.5 | x/standing (extended) | REQ-060 (locked) | vision §9.1 — Shadow vouch 50% weight in Community Endorsement signal |
| PierCarriesVoice | false | x/guild (extended) | REQ-053 / FR-VOICE-6 (locked, D-087) | vision §12 — Pier does NOT carry Voice; mission-locked invariant (12th const per GRILL D-087) |
> Note: the v0.8 const firewall extensions named in oy-spec §7 v0.8 acceptance
> (`EyeQuorumMin=7`, `AnchorConcentrationCapBps=2000`,
> `MABCouponMaxAnnualSurplusMultiple=3`, `BondIssuerSurplusCeilings={1×,2×,3×}`,
> `CoolingSecessionCoverActive=21 days`, `CoolingSecessionNonCover=14 days`,
> `CoverReserveFloorAnnualContribX=1.5`) overlap with v0.7's REQ-047/049/054/
> 064 const additions. The v0.7 additions above land the v0.7-locked subset
> (REQ-047/049/054/064 locked=yes); the v0.8 acceptance list is the v0.8
> consolidated const firewall update that will add the remaining Cluster AE
> consts (`EyeQuorumMin`, `AnchorConcentrationCapBps`, etc.). The cooling
> consts and CoverReserveFloor land in v0.7 because their REQs are v0.7;
> v0.8's acceptance row re-lists them as a consolidated checkpoint, not a
> re-introduction.
## 4. Deferred / out-of-scope (do NOT re-propose without §9 override)
| Item | Deferred from | Reason | Revisit at |
|------|--------------|--------|------------|
| Cover Pool seniority mechanics | v0.1 Q7 | unstated math | **UNBLOCKED v0.7** — REQ-046..REQ-050 now supply the seniority/gate math; promoting to runtime this milestone |
| SignalKind 4->5 enum expansion | v0.4 AUDIT §193 P1-2 | locked-const change; defensible at 4 | v0.8+ governance vote (not v0.7 scope) |
| Governance spam deposit/bond | v0.5 REVIEW P1 | mainnet-readiness | v0.8+ (Cluster C, REQ-077 adjacent) |
| CLOB per-tx front-running (batch auction) | v0.5 REVIEW P1 | mainnet-readiness | v0.8+ |
| Real IBC light-client simtest | v0.5 REVIEW P1 | mainnet-readiness | v0.8+ (Cluster A, REQ-068 adjacent) |
| CLOB `restingBookForBond` O(n) -> prefix-key | v0.5 REVIEW P2 | mainnet perf | v0.8+ |
| `emitMatchEventHook` testability | v0.5 REVIEW P2 | minor | v0.8+ |
| Live chain launch / mainnet / real IBC channels | v0.1 (D-020) | skeleton-first until mainnet gate | v0.8+ (Year 3 target) |
| Real `oyd` daemon / `app.go` / `cmd/oyd` | v0.6 OOS | no chain runtime exists | v0.8+ (v0.7 Cover Pool "live on testnet" = simtest-grade keeper runtime, not mainnet) |
| Real institutional Anchors onboarding | v0.5 OOS | credential lifecycle in simtest only | v0.8+ |
| Real bearer transports (hardware/RF) | v0.5 OOS | message handlers + simtest only | v0.8+ (Year 3) |
| Authentication / sessions / real key mgmt | v0.6 OOS | mock; Reach created by form submission | v0.8+ |
| Persistence (in-memory mock store) | v0.6 OOS | resets on restart | v0.8+ |
| i18n / multi-language UI | v0.3/v0.6 OOS | single-language | v0.8+ |
| Yield Token, Travel + 11 service categories | ROADMAP Phase 4 | Maturity (Years 4-5) | Year 4+ |
| Maya's Day integration spec | v0.1 Q1 | Mesh Experience component | Phase 2 |
| Standing anti-gaming sub-tables | v0.1 Q2 | formula locked; sub-tables deferred | v0.8+ |
| Pier credential routing (e-Residency, biometrics) | v0.1 Q5 | deferred | v0.8+ |
| Experimental bond forms | v0.1 Q6 | Phase 4+ only | Year 4+ |
| Processor share tier boundary exact volumes | v0.1 Q8 | deferred | v0.8+ |
| Docs bread-scale.md fix (outdated vs code consts) | v0.6 P1+ | doc-drift fix, not UI feature | next docs touch |
| **Sovereign Anchor SPEC** (`oy-sovereign-anchors`) | v0.7 §5 | infrastructure-scale, separate SPEC; experimental, not load-bearing | post-v0.7 (PO D-076) |
| **USZ classification runtime** | v0.7 §7 | depends on Anchor pre-commitment framework (v0.8 REQ-095) | v0.8 |
| **Cluster AE + Infrastructure Economics (REQ-067..REQ-097)** | v0.7 §7 / D-081 | §7 authoritative — v0.7 ships REQ-046..066 only | v0.8 |
| **Pier-Routed Legal Wrapper** | v0.7 §5 | OPTIONAL per PO; default-no-wrapper; not implemented as code | never (optional value-add) |
| **Watcher/Voucher operating-expense compensation absolute cap ($TBD-W)** | v0.7 §8 Q6 | v0.8 REQ-096; annual 5% Bloom cap ruled (D-078), absolute cap deferred | v0.8 |
## 5. Drift flags (ciagent -> PO)
<!-- Differences between oy-spec (latest) and what the ciagent has shipped. -->
- Lexicon drift: **none**
- Locked-const drift: **none** (v0.7 const additions are net-new, not amendments)
- REQ-shape drift: **none** (oy-spec v3 net-new-only diff ingested; REQ-046..REQ-097 added to coverage §2; baseline REQ-001..REQ-045 unchanged)
- Architecture drift: **planned** — v0.7 introduces a NEW module `x/cover` (Cover Pool Factory + Anti-Crowding-Out firewall + Anti-Capture Bill of Rights). The existing `x/pact` `PactCover` enum value remains as a cross-reference (G-003 by-ID-string). This mirrors the D-039 precedent (`x/hub` split out of `x/pact`'s `PactHubAPI` in v0.3). Will be ratified at GRILL (P0).
- Spec-version drift: **none** — oy-spec v3 ingested at commit d10bf5e; this state-v2 reflects it.
## 6. Constraints honored (firewall status)
- **G-003** production import firewall: GREEN (by-ID-string rule at type level; expected_keepers.go shims for keeper cross-calls). v0.7 `x/cover` will follow the same pattern — no production struct imports across `x/<module>/types`.
- **G-006** go.mod zero-dep: CONTROLLED EXCEPTION — cosmos-sdk v0.50.8 + ibc-go v8.2.1 added in v0.5 (D-055 GRILL-approved, scoped to runtime phases; types/ packages stay dep-free). v0.7 `x/cover` keeper will use the same SDK runtime substrate; no new Go deps expected.
- **G-028** go.mod diff baseline (v0.6 vs v0.5.0): EMPTY. v0.7 target: EMPTY (no new Go deps; `x/cover` keeper uses existing SDK).
- **REQ-012** lexicon firewall: GREEN — 3 meta-tests (x/, docs/, web/) all passing. v0.7 will extend to a 4th meta-test if Cover surfaces add user-facing strings (pending RESEARCH); otherwise the existing 3 suffice.
- **Mission Lock** non-amendable: GREEN — `MissionLockAmendable=false` unchanged; `MissionLockAmendmentRejected` ProposalKind rejected at ValidateBasic (D-064). v0.7 Anti-Capture Bill (REQ-056) extends this: 13 rights non-amendable + non-waivable by any Charter.
- **Coverage** >=80% on shipped packages: GREEN (v0.5 keepers 82.1%-92.5%; v0.6 web/store 98.1%, web/handlers 89.2%, lexicon_meta_web 100%). v0.7 target: `x/cover` + extensions >=80%.
- **Feature purity gate** (v0.7): GREEN target — no breaking schema changes to locked-const firewall; G-003 intact; go.mod unchanged.
- **No subsidies** (v0.7 §5 NEW): GREEN by construction — v0.7 does not introduce any Root-Pool operating-expense subsidy or transfer-payment analog. The Anti-Crowding-Out firewall (D-079, REQ-047/050) rejects any code path routing Cover-Fees outside contributor-pool semantics. Infrastructure financing is out of v0.7 scope (v0.8 REQ-092..095).
- **Anchor no-Voice** (v0.7 §5 NEW): GREEN by construction — v0.7 does not grant Voice to any Anchor. The Cover Pool Council (REQ-062) = Pool Host + 3 elected Masons + Watcher observer; no Anchor seat. MAB holders (REQ-063) have NO Voice. Sovereign Anchors are out of v0.7 scope (experimental per §5).
## 7. Open PO decisions before next milestone
<!-- The PO should rule on these in oy-spec §8 or §7 before v0.7 P0. -->
### Resolved this regeneration (D-074..D-081 — PO recommendations accepted as binding at full autonomy)
| ID | §8 Q | Decision | Rationale | Confidence | Affects |
|----|------|----------|-----------|------------|--------|
| D-074 | Q1 (TBD-X) | **$100k annual Pass volume** for Stand→Pier-customer escalation | PO rec accepted; soft upgrade not ban | 0.85 | REQ-059 (v0.7/P5) |
| D-075 | Q2 (TBD-Z) | **<10 Holders/km² AND strategic value ≥ mission score, OR sovereign request, OR Mesh Council supermajority** | PO rec accepted; formula locked for v0.8 USZ | 0.80 | REQ-095 (v0.8) |
| D-076 | Q3 (Sovereign Anchor SPEC) | **Separate SPEC `oy-sovereign-anchors`**; experimental, not load-bearing v0.7 | PO rec accepted; infrastructure-scale ≠ legal-wrapper-scale | 0.85 | §5 constraint (v0.7) |
| D-077 | Q5 (Standing gate timing) | **Factory runtime** — gates are protocol-layer | PO rec accepted; gates bind at x/cover Factory, not first live Pool | 0.88 | REQ-049 (v0.7/P1) |
| D-078 | Q6 (Watcher/Voucher cap) | **Annual cap = 5% of Root-Pool Bloom**; absolute $TBD-W deferred to v0.8 | PO rec accepted; 5% Bloom ruled now, absolute cap later | 0.82 | REQ-096 (v0.8) |
| D-079 | Q7 (Anti-Crowding-Out firewall) | **Separate `x/cover/firewall` package + `lexicon_meta_cover`-style meta-test** (defense in depth) | PO rec "separate firewall" accepted; runtime subpackage rejects code paths + meta-test rejects doc drift | 0.84 | REQ-047/050 (v0.7/P1) |
| D-080 | Q8 (MAB use-of-proceeds) | **Tagged streaming + Watcher-witnessed release** (defense in depth) | PO rec accepted; tagged streaming auto-Stills on misuse, Watcher witnesses release | 0.85 | REQ-054 (v0.7/P4) |
| D-081 | Q4 (Risk mitigation sequencing) | **§7 authoritative** — v0.7 ships REQ-046..066 only; Cluster A+B+C are v0.8 | PO rec overridden by §7 acceptance text; §7 is the milestone contract | 0.90 | v0.7 scope (all REQ-046..066) |
### Remaining open (post-v0.7 — for v0.8 P0)
1. **$TBD-W absolute Watcher/Voucher cap** — deferred to v0.8 REQ-096 (D-078 partial ruling).
2. **Sovereign Anchor SPEC scope** — `oy-sovereign-anchors` to be authored by PO before v0.8 P0 (D-076).
3. **v0.8 Cluster AE sequencing within v0.8** — which of REQ-067..REQ-097 ship in v0.8 P1..PN? PO should pick a subset or rule "all 31 in v0.8".
4. **Pen-test third party** — v0.7 §7 acceptance requires "pen-test ≥1 independent third party"; at full autonomy with no external third party available, the ciagent will run a self-administered adversarial review (ci-griller persona) and log this as an assumption unless the PO rules otherwise before P6.
5. **`oyd` daemon** — still no `app.go`/`cmd/oyd`; v0.7 Cover Pool "live on testnet" = simtest-grade keeper runtime. PO should decide whether v0.8 starts the daemon or continues simtest-only.
6. **SignalKind 4->5 expansion** — still deferred to v0.8+ governance vote (not v0.7 scope).
## 8. Build/test status
- `go build ./...`: GREEN (baseline confirmed 2026-08-19 on main @ d10bf5e)
- `go test ./...`: GREEN (all packages; v0.5 keepers + v0.6 web + lexicon meta-tests all passing)
- Coverage: all shipped keeper packages >=80%; web packages >=89%
- Lexicon meta-tests: 3/3 GREEN (x/, docs/, web/)
- Last green commit: d10bf5e (docs(spec): v3 net-new-only)
---
_`oy-state` is regenerated by the ciagent. Do not edit by hand._
+55
View File
@@ -0,0 +1,55 @@
# OpenYield docs build CI (REQ-032, D-046 forward-reference, D-051, G-016).
#
# Runs the lexicon firewall (go test ./...) AND builds the MkDocs Material docs
# site on every push. The docs-build job DEPENDS on go-test (G-016 binding:
# firewall-gates-docs-build — a lexicon violation blocks the docs build so no
# false-green docs artifact is produced from a repo with a firewall failure).
#
# Scope (chore, not feat: per D-001 refinement-only filter):
# - go-test job: setup Go 1.22, run `go test ./...` (lexicon firewall + all
# x/* tests + the v0.4 cross-const test). Zero external Go deps (G-006).
# - docs-build job: setup Python, pip install mkdocs + mkdocs-material
# (build-only Python deps, ISOLATED to this job — go.mod is NOT modified),
# run `mkdocs build` (produces site/), upload site/ as a CI artifact.
#
# Out of scope (deferred per D-051): full Gitea Pages publishing. v0.4 ships
# build + artifact only; a hosting target is not configured.
#
# Triggers: on push (all branches) so the firewall + docs build are checked
# on every change, not just on main.
name: docs-build
on:
push:
jobs:
go-test:
name: go test ./... (lexicon firewall + all x/* tests)
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-go@v5
with:
go-version: '1.22'
- name: go test ./...
run: go test ./...
docs-build:
name: mkdocs build (docs site artifact)
runs-on: ubuntu-latest
needs: go-test # G-016: firewall-gates-docs-build (no false-green docs build)
steps:
- uses: actions/checkout@v4
- uses: actions/setup-python@v5
with:
python-version: '3.11'
- name: install mkdocs + mkdocs-material
run: pip install mkdocs mkdocs-material
- name: mkdocs build
run: mkdocs build
- name: upload site/ artifact
uses: actions/upload-artifact@v4
with:
name: docs-site
path: site/
retention-days: 14
+2
View File
@@ -2,3 +2,5 @@
.env.secrets
.env.*
.ciagent/.env.secrets
# MkDocs build output (REQ-032 CI produces site/ as an artifact; never commit it)
site/
+112
View File
@@ -0,0 +1,112 @@
# OpenYield
OpenYield is a jurisdiction-light, public-good mesh for **real production** — a
protocol organized around Holders, Stands, and the Six Principles, designed to
hold real value without the words or the shapes that invite capture. The mesh
runs on OY Chain (Layer 1), a canonical state layer for the Bread unit, the
Storage Pools (Stash, Vault, Root-Pool), Standing, Watcher attestations, and
the Pact / Council / Partner surface. It is anti-greed by construction: Mission
Lock fixes the Six Principles and fee covenant so no council can amend them,
and the 8% coupon cap on bonds is a mission-locked ceiling, not a parameter.
## The Six Principles
1. **Real value** — the mesh holds real production, not speculation.
2. **Sustainability** — fees are floored and capped; the protocol cannot drain its users.
3. **Mission-lock** — the Six Principles and fee covenant are immutable; no council can amend them.
4. **Openness** — anyone may join; the mesh is a public good.
5. **Ownership** — Holders own their Stash and their Reach; custody is theirs.
6. **Self-service** — a Holder can act without a custodian; the mesh is jurisdiction-light.
## Bread unit & scale
The unit of value is **Bread**, scaled in 11 tiers: **Grain → Crumb → Bread →
Loaf → Batch → Cake → Bakery → Granary → Mill → Harvest → Earth.**
## Status
**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
OpenYield is pure Go with **zero external dependencies** (`go.mod` has no
`require` lines; `go 1.22`). From the repo root:
```sh
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/)
(a build-only Python dep; **not** a Go dep — `go.mod` is unchanged). To
preview locally:
```sh
mkdocs serve
# or build to a static site/ dir:
mkdocs build
```
The site lives under `docs/` (see `mkdocs.yml` for the nav). Publishing CI is
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`), 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`.
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** /
**Reach**, never the banned word for a custodial position. See
`docs/shared/lexicon.md` for the glossary of safe replacements.
## Governance
- `.ciagent/oy/PROJECT.md` — full vision, decisions (D-0xx), assumptions.
- `.ciagent/oy/PLANS.md` — phase plans (v0.1, v0.2, v0.3).
- `.ciagent/oy/REQUIREMENTS.md` — REQ coverage matrix.
- `.ciagent/oy/ROADMAP.md` — release roadmap.
+47
View File
@@ -0,0 +1,47 @@
# Anchor Preview
An **Anchor** (REQ-023) is the fourth and highest tier of the
[Partner Spectrum](partner-spectrum.md) (REQ-018) — the first **institutional**
Partner tier. Anchors are coming in v0.3 P4. This page previews what an Anchor
is and what the v0.3 skeleton will deliver; the runtime behavior is deferred
to v0.4+.
## What an Anchor is
An Anchor is a Partner that carries an **AnchorCredential**: a jurisdiction
(e.g., "EU-MiCA"), a custody provider, and a set of attestation references.
The Anchor tier is how the jurisdiction-light mesh interfaces with
jurisdiction-bound institutional actors without becoming them. An Anchor
holds a credential; the [Holder](../nomads/reach.md) still holds their
[Stash](../nomads/stash.md). The mesh says **custody**, **compliance**, and
**jurisdiction** — never the legacy institutional words banned by the
[lexicon](../shared/lexicon.md).
## What is coming in v0.3 P4
v0.3 P4 (REQ-023) extends `x/partner` with the `AnchorCredential` struct and
a `Partner.AnchorCredential()` accessor (returns nil for non-Anchor tiers).
The four-tier `PartnerTier` enum (Op, Master Op, Pier, Anchor) is **unchanged**
— v0.3 adds Anchor-specific fields, not a new tier. The custody-provider-id
field is a by-ID-string reference to `x/hub` (the Hub API, coming in v0.3 P5),
empty in the v0.3 skeleton because the Hub is not live until P5/v0.4. This is
the P4→P5 ordering edge: `x/hub` in P5 references Anchor partner-ids from P4.
## Why Anchors matter to a Freeholder
A Freeholder engaging an Anchor gets a Partner with a verifiable credential
and a custody/compliance relationship — useful for cross-jurisdiction routes
and institutional [bonds](bonds.md). The Anchor's [Standing](standing.md) and
attestations are visible so the Freeholder can verify the Anchor is real
before opening a [Window](../nomads/window.md). See
[Partner Spectrum](partner-spectrum.md) for the other three tiers, and
[Councils & Voice](councils-voice.md) for how the Mesh Council can suspend or
revoke an Anchor.
## What v0.3 does not deliver
The v0.3 skeleton is types + tests only (D-035): the `AnchorCredential`
struct, the accessor, and the `ListAnchors()` keeper alias. Live custody
routing, attestation verification, and the Hub API integration are v0.4+
runtime work. See [Components](../reference/components.md) for the full
module map.
+46
View File
@@ -0,0 +1,46 @@
# Bonds
A **Mesh Bond** (REQ-021, vision §17) is a [Stand](stands-guilds.md)-issued
instrument that pays a **coupon** to its holder over a term and returns the
principal at maturity. The coupon is bounded by a **mission-locked cap and
floor**: 8% upper cap, 0% floor (locked `CouponCapBps = 800` and
`CouponFloorBps = 0` in `x/bond`). The cap exists so the mesh cannot become a
speculative market; the floor exists so the coupon cannot go negative.
## The coupon clamp
The coupon is clamped to `[floor, cap]` by the `Clamp` helper in `x/bond`
(same shape as the [Fee Covenant](../shared/six-principles.md) clamp): a
coupon above 8% is reduced to 8%; a coupon below 0% is raised to 0%; a coupon
in range is unchanged. The clamp is a tested invariant: below floor → floor,
above cap → cap, in range → unchanged. This is the Mission Lock's expression
in the capital layer.
## Why a cap
OpenYield is a public-good mesh for **real production**, not a speculation
engine. An uncapped coupon market would let a Stand offer arbitrarily high
coupons to attract Bread, turning the mesh into a speculative race. The 8%
cap bounds the coupon at a level consistent with real production returns, and
the [Mission Lock](councils-voice.md) makes the cap non-amendable — no Council
vote can raise it. The mesh says **coupon** and **real return**, never the
passive-value or standalone-metric words banned by the
[lexicon](../shared/lexicon.md).
## The bond lifecycle
A Bond moves through five states (locked `BondStatus` enum in `x/bond`):
Issued → Active → Matured, with Defaulted and Repaid as terminal paths. The
issuer is a Stand (referenced by stand-id); the principal is denominated in
[Grain](../shared/bread-scale.md). The bond market is governed by the
[Stand Council](councils-voice.md) for the issuing Stand.
## Coming in v0.3 P5
v0.3 P5 (REQ-026) extends the bond market with **Growth Bonds** (a coupon that
grows over the term, still clamped to the 8% cap) and a **secondary market**
(Buy/Sell orders on issued bonds). The 8% / 0% consts are unchanged — the
D-028 regression firewall guarantees v0.3 cannot alter the v0.2 mission-locked
ceiling. See [Partner Spectrum](partner-spectrum.md) for how Partners relate
to the bond market, and [Anchor Preview](anchor-preview.md) for the
institutional tier.
+48
View File
@@ -0,0 +1,48 @@
# Councils & Voice
OpenYield governs itself through three **Councils** (REQ-011, vision §19):
the Mesh Council, the Guild Council, and the Stand Council. Each Freeholder
participates through the Councils, weighted by **Voice** — a multi-source
weight that combines [Stash](../nomads/stash.md), [Standing](standing.md),
Vouch, Freeholder status, and Guild membership. The **Mission Lock** makes
the covenant non-amendable: no Council can vote to change the
[Six Principles](../shared/six-principles.md) or the fee covenant.
## The three Councils
- **Mesh Council** — the mesh-wide Council. Handles protocol-level proposals
that affect every Holder and every [Stand](stands-guilds.md).
- **Guild Council** — the Council for [Guilds](stands-guilds.md). Handles
Guild-scope proposals, referenced by guild-id.
- **Stand Council** — the Council for a single Stand, referenced by stand-id.
Handles Stand-scope proposals (e.g., Vault use, [Bond](bonds.md) issuance).
The three-tier shape mirrors the three [Storage Pools](../shared/storage-pools.md):
a Council exists at each layer where custody is held.
## Multi-source Voice
Voice is not one number. It is a weighted tally from five sources (locked as
the `VoiceSource` enum in `x/council`): Stash, Standing, Vouch, Freeholder,
and Guild. A Freeholder with high [Standing](standing.md) and a long-held
Stash carries more Voice than a freshly-minted one. The
[TallyResult](../reference/components.md) mirrors the Cosmos SDK `x/gov`
shape so the governance layer can wire to standard tooling. The VoteOption
enum is **Yes / No / Abstain** — there is no "no-with-veto", an anti-greed
design choice.
## Mission Lock
The Mission Lock is a locked `const bool` in `x/council`
(`MissionLockAmendable = false`). The Six Principles, the fee covenant
(ceiling 0.1% / floor 0.01% / 1-Grain minimum), and the bond coupon cap
([8% / 0%](bonds.md)) cannot be amended by any Council vote. This is the
firewall that keeps the mesh a public good: governance can act *within* the
covenant, never *on* the covenant.
## How a Freeholder participates
A Freeholder submits or votes on proposals in the Councils they belong to.
Each vote is weighted by multi-source Voice; the tally follows `x/gov`
semantics. See [Bonds](bonds.md) for the coupon cap the Mission Lock protects,
and [Standing](standing.md) for the metric that weights a Freeholder's Voice.
+39
View File
@@ -0,0 +1,39 @@
# Freeholders
A **Freeholder** is a Holder who has earned all four Freeholder signals (REQ-005):
a 90-day [Stash](../nomads/stash.md), a [Standing](standing.md) threshold of
4.5★ or higher in 3 categories, the Capital signal, and the Vouch signal. A
Freeholder is the active participant in the OpenYield mesh — they sit in
[Stands & Guilds](stands-guilds.md), vote in the three
[Councils & Voice](councils-voice.md), issue [Bonds](bonds.md), and relate to
the four-tier [Partner Spectrum](partner-spectrum.md).
## The four signals
The signals are the gate to Freeholder participation. They are deliberately
heterogeneous — no single input can be pumped — so the path resists gaming:
- [Signals](signals.md) — the four Freeholder signals (REQ-005): 90-day Stash,
4.5★+ in 3 categories, Capital, Vouch.
- [Standing](standing.md) — the Bayesian anti-gaming formula (REQ-006):
Bayesian prior + time-decay + diversity + voucher-weighting slashes.
- [Stands & Guilds](stands-guilds.md) — the nine Stand types (REQ-016) and
Guilds with free Hand-Passes (REQ-017).
- [Councils & Voice](councils-voice.md) — the three Councils and the
non-amendable Mission Lock (REQ-011).
- [Bonds](bonds.md) — the Mesh Bond Market, the 8% coupon cap / 0% floor
(REQ-021).
- [Partner Spectrum](partner-spectrum.md) — the four Partner tiers (REQ-018):
Op, Master Op, Pier, Anchor.
- [Anchor Preview](anchor-preview.md) — the first institutional Partner tier
(REQ-023), coming in v0.3 P4.
## What a Freeholder does
A Freeholder is a Holder who has crossed the signal gate. From there the mesh
opens: a Freeholder joins a [Stand](stands-guilds.md) (or forms a Guild), votes
in the [Councils](councils-voice.md) with multi-source Voice, issues or holds
[Bonds](bonds.md) under the mission-locked coupon cap, and engages the
[Partner Spectrum](partner-spectrum.md) — including the Anchor tier coming in
v0.3. The covenant is the same for every audience; the Freeholder pages
describe how it shows up in governance and capital.
+42
View File
@@ -0,0 +1,42 @@
# Partner Spectrum
OpenYield defines a four-tier **Partner Spectrum** (REQ-018, vision §13):
**Op**, **Master Op**, **Pier**, and **Anchor**. Partners are the external
actors a [Freeholder](index.md) interacts with through the mesh — service
operators, route providers, and institutional bridges. The four tiers are
locked as the `PartnerTier` enum in `x/partner` (exactly 4, regression-tested).
## The four tiers
- **Op** — a service operator. Runs a service a Holder uses through a
[Window](../nomads/window.md) (e.g., a Maps provider). The lightest tier.
- **Master Op** — a senior operator. Coordinates multiple Ops or runs a
higher-trust service. "Op" is the safe short form; the full word is not
used as a standalone term.
- **Pier** — a routing Partner. Connects the mesh to external venues (e.g.,
a DEX or an off-mesh service) and sources [Forex](../reference/components.md)
rates. Piers route; they do not custody Holder value.
- **Anchor** — the first institutional Partner tier. Carries a credential
(jurisdiction, custody provider, attestations). See
[Anchor Preview](anchor-preview.md) for what is coming in v0.3 P4.
## How Freeholders relate to Partners
A Freeholder authorizes a Partner to act on their behalf through a scoped,
time-limited, revocable [Window](../nomads/window.md) — never by handing over
custody. The Partner holds a credential, not the Holder's [Stash](../nomads/stash.md).
A Partner's [Standing](standing.md) is visible so a Freeholder can choose an
operator with a real history over a freshly-spun-up alternative (see
[Maps & Pay](../nomads/maps-pay.md)).
## Partner status
Each Partner has a status (locked `PartnerStatus` enum in `x/partner`):
Pending → Active, with Suspended and Revoked as the governance paths. The
[Mesh Council](councils-voice.md) can suspend or revoke a Partner. The four
tiers and the status enum are unchanged by v0.3 — v0.3 only *extends*
`x/partner` with the Anchor credential shape (REQ-023), not a new tier.
See [Storage Pools](../shared/storage-pools.md) for why the mesh says
"Holder" and "Reach" rather than the legacy custodial words, and
[Bonds](bonds.md) for the coupon market a Partner may route to.
+45
View File
@@ -0,0 +1,45 @@
# The Four Freeholder Signals
The four **Freeholder signals** (REQ-005) are the gate to Freeholder
participation. A [Holder](../nomads/reach.md) who earns all four becomes a
[Freeholder](index.md) — eligible to join [Stands & Guilds](stands-guilds.md),
vote in the [Councils](councils-voice.md), and issue [Bonds](bonds.md). The
signals are deliberately heterogeneous: no single input can be pumped, so the
path resists gaming.
## 1. The 90-day Stash
A Holder must hold a [Stash](../nomads/stash.md) continuously for 90 days
(REQ-014). The signal is about **continuity, not size** — a small Stash held
steadily counts. This filters out transient actors who spin up a position to
game a vote and then leave. See [Storage Pools](../shared/storage-pools.md)
for the three-pool model.
## 2. Standing of 4.5★ or higher in 3 categories
A Holder must earn a [Standing](standing.md) of 4.5★ or higher in **three
distinct categories** (REQ-006). The diversity requirement is the anti-gaming
core: a Holder cannot reach Freeholder by repeating the same action with the
same counterparty. Three categories force breadth.
## 3. Capital
The Capital signal requires a Holder to hold a meaningful amount of
[Bread](../shared/bread-scale.md) in their Stash. The threshold is set by the
mesh [Councils](councils-voice.md) and is a stake, not a fee: the Holder keeps
the Bread. Capital aligns the Freeholder's stake with the mesh.
## 4. Vouch
The Vouch signal requires another Freeholder to vouch for the Holder. A
vouch from a high-[Standing](standing.md) Freeholder carries more weight
(voucher-weighting), so a single colluding vouch cannot carry a Holder over
the gate. Vouch is the social signal that ties the other three together.
## Why four, not one
Each signal covers a different attack surface: continuity (90-day Stash),
breadth (3-category Standing), stake (Capital), and social trust (Vouch).
Earning all four is the proof a Holder is a participant, not a transient
gamer. See [Standing](standing.md) for the anti-gaming math, and
[Bonds](bonds.md) for what a Freeholder can do once the signals are earned.
+49
View File
@@ -0,0 +1,49 @@
# Bayesian Standing
**Standing** (REQ-006) is a Holder's measured history on the mesh — the
anti-gaming metric that gates [Freeholder](index.md) participation and weighs
[Voice](councils-voice.md) in the [Councils](councils-voice.md). Standing is
not a count of transactions and not a reputation score you can farm. It is a
Bayesian score that resists the obvious attacks: volume spam, self-dealing,
fake vouches.
## The formula, at conceptual depth
Standing combines four signals and a penalty:
- **Bayesian prior + updates.** The mesh starts with a prior for each Holder
and updates it from each observed action. A burst of activity cannot
inflate Standing because the prior anchors it.
- **Time-decay.** Old evidence decays, so a Holder cannot rest on a burst
from years ago. Standing reflects *recent, sustained* real production.
- **Diversity weighting.** A Holder who acts across many services, many
[Stands](stands-guilds.md), and many [bearers](../nomads/bearers.md) accrues
more Standing than one who repeats the same action with the same
counterparty. Diversity is the anti-collusion lever.
- **Voucher-weighting.** A vouch from a high-Standing Freeholder counts for
more than a vouch from a low-Standing one. This makes fake vouches expensive:
the voucher must themselves have Standing to lose.
- **Minus slashes.** Bad behavior (failed attestations, broken Pacts) removes
Standing. Slashes are the penalty that bounds the upside of gaming.
> The full sub-tables (priors, decay rates, diversity categories, slash
> conditions) are deferred per PROJECT.md Q2. This page gives the conceptual
> depth; the [nomads Standing page](../nomads/standing.md) gives the plain-
> language version.
## Why it cannot be gamed
There is no single input a Holder can pump. Volume is bounded by the Bayesian
prior; recency is bounded by time-decay; breadth is bounded by diversity;
social trust is bounded by voucher-weighting; and any attempt that misfires
costs Standing via slashes. The four signals (the [90-day Stash](signals.md),
3-category threshold, Capital, Vouch) sit on top of this metric, so the
Freeholder gate inherits the same anti-gaming property.
## What Standing is not
Standing is not a custodial position, a tier you buy, or legacy history. It
is a measured, decayed, diversified Bayesian score. See
[Storage Pools](../shared/storage-pools.md) for why the mesh says "Stash"
rather than the legacy custodial words, and [Councils & Voice](councils-voice.md)
for how Standing weights a Freeholder's vote.
+47
View File
@@ -0,0 +1,47 @@
# Stands & Guilds
A **Stand** is a governed group of Holders that holds a [Vault](../shared/storage-pools.md)
in common (REQ-016). A **Guild** is a looser association of Holders that can
pass value among its members for free (REQ-017). Both are the organizational
layer a [Freeholder](index.md) joins after earning the four
[signals](signals.md).
## The nine Stand types
OpenYield defines exactly nine Stand types (REQ-016, vision §11), locked as a
const in `x/stand`:
1. **Household** — a family-scale group.
2. **Crew** — a working team.
3. **Entity** — a single legal actor.
4. **Co-op** — a cooperative.
5. **Circle** — an affinity group.
6. **Trust** — a trust arrangement.
7. **Foundation** — a purpose-bound entity.
8. **Confederation** — a federation of Stands.
9. **Shadow** — a privacy-preserving Stand.
A Stand's decision policy (threshold or weighted, mirroring the Cosmos SDK
`x/group` shape) governs how its Vault is used. A Stand can also issue
[Bonds](bonds.md) — the bond issuer is a Stand, referenced by stand-id.
## Guilds and Hand-Passes
A **Guild** is a looser association: it may affiliate with a Stand or stand
alone. Inside a Guild, a **Hand-Pass** moves [Bread](../shared/bread-scale.md)
between members at a **0% protocol fee** (REQ-017, locked `HandPassFeeBps = 0`
in `x/guild`). The 0% fee is mission-locked: the mesh does not tax the social
transfer of value among a self-organized group. See the
[Fee Covenant](../shared/six-principles.md) for the broader fee shape.
## How a Freeholder joins
A Freeholder joins a Stand by becoming a member (the Stand's policy admits
them) or forms a Guild as a founder. Membership is recorded in `x/stand`
and `x/guild` respectively, by stand-id / guild-id and the member's
[Reach](../nomads/reach.md). From a Stand a Freeholder gains Vault access and
the ability to issue [Bonds](bonds.md); from a Guild a Freeholder gains free
Hand-Passes with other members.
See [Councils & Voice](councils-voice.md) for how Stands and Guilds each get a
Council, and [Storage Pools](../shared/storage-pools.md) for the Vault layer.
+30
View File
@@ -0,0 +1,30 @@
# OpenYield
OpenYield is a jurisdiction-light, public-good mesh for **real production**. It
runs on OY Chain (Layer 1), a canonical state layer for the Bread unit, the
Storage Pools, Standing, Watcher attestations, and the Pact / Council /
Partner surface. The mesh is anti-greed by construction: Mission Lock fixes
the Six Principles and fee covenant so no council can amend them, and the
coupon cap on bonds is a mission-locked ceiling, not a parameter.
## Audiences
The docs are organized by audience:
- **Nomads** — the everyday Holder: your Reach, your Stash, your bearers, how
you pay (Maps-Pay), the Pacts you join, and the Window you open. See
[Nomads](nomads/index.md).
- **Freeholders** — the active participant: the four signals, Bayesian
Standing, Stands & Guilds, the three Councils and Voice, the bond market,
and the four-tier Partner Spectrum. See [Freeholders](freeholders/index.md).
- **Shared** — concepts common to every audience: the Six Principles, the
Bread scale, the three Storage Pools, the Watchers & Mirror, the lexicon
glossary, and the vision overview. See [Shared](shared/index.md).
- **Reference** — the architecture and component map. See
[Reference](reference/architecture.md).
## Build the docs
This site is [MkDocs Material](https://squidfunk.github.io/mkdocs-material/),
a build-only Python dep (not a Go dep). To preview locally, see the
[README](../README.md) for build instructions.
+46
View File
@@ -0,0 +1,46 @@
# Bearers
The **bearers** (REQ-019) are how a Nomad reaches the mesh. OpenYield ships
six bearers through a single **Unified Bearer Layer**: the mesh does not
care which bearer a Holder uses — first-to-deliver-wins, and a Nomad can
switch bearers without switching identity. The [Mirror](../shared/watchers-mirror.md)
mirrors the canonical state to every bearer so a Nomad can read the mesh's
real return on any of them.
## The six bearers
| Bearer | Live in v0.2 | What it is |
|---|---|---|
| **Internet** | yes | the default bearer; OY Chain over the open internet. |
| **OY-BLE** | yes | Bluetooth Low Energy; short-range, peer-to-peer, no phone plan. |
| **OY-WiFi-Direct** | yes | WiFi Direct; local mesh without an access point. |
| **OY-LR** | yes | Long Range radio (LoRa-class); long-distance, low-bandwidth, surveillance-resistant. |
| **OY-SAT** | coming (v0.3 P4) | satellite; offline coverage via a satellite constellation. |
| **OY-QR** | coming (v0.3 P4) | signed QR code; one-shot offline transfer scanned by a peer. |
## What this means for a Nomad
A Nomad does not pick "the right bearer". The four already-live bearers
(Internet, OY-BLE, OY-WiFi-Direct, OY-LR) cover the everyday situations:
on the open internet, in a room with another Holder, in a local group with
no router, or kilometers away with no infrastructure. OY-SAT and OY-QR
extend that to true-offline paths and are coming in the next phase.
## Surveillance resistance
OY-LR, OY-BLE, OY-WiFi-Direct, OY-SAT, and OY-QR are designed to be
surveillance-resistant: a Nomad can send or receive value without a
phone plan, a SIM, or a custodial on-ramp. The bearer is the transport; the
[Reach](reach.md) is the identity; the [Stash](stash.md) is the storage. None
of them depends on a custodial position.
## First-to-deliver-wins
The Unified Bearer Layer is first-to-deliver-wins: if a Nomad sends a
transfer over two bearers at once, the mesh accepts the first one that
arrives and drops the duplicate. This is why a Nomad can switch bearers
mid-transfer without double-spending.
See [Watchers & Mirror](../shared/watchers-mirror.md) for how the canonical
state is mirrored to every bearer, and [Maps & Pay](maps-pay.md) for how a
Nomad uses a bearer to find and pay for services.
+42
View File
@@ -0,0 +1,42 @@
# Nomads
A **Nomad** is a person using the OpenYield mesh through a **Reach** — the
protocol-level identity a Holder uses to act on the mesh without a
custodian, a gatekeeper, or a legacy financial position. The Nomad path
is the entry path: a Nomad is a Holder who has a Reach and a [Stash](stash.md)
and is on the way to earning the four Freeholder signals, but has not yet
earned all four.
## The Nomad path
The pages here cover what a Nomad does day-to-day on the mesh:
- [Reach](reach.md) — the identity; the first Freeholder signal (REQ-005).
- [Stash](stash.md) — the personal [Storage Pool](../shared/storage-pools.md)
where a Nomad holds Bread (REQ-014).
- [Bearers](bearers.md) — how a Nomad reaches the mesh (REQ-019): Internet,
OY-BLE, OY-WiFi-Direct, OY-LR live now; OY-SAT and OY-QR coming.
- [Maps & Pay](maps-pay.md) — finding services and paying for them.
- [Pacts](pacts.md) — the six contract shapes a Nomad encounters
(REQ-020): Pause, Ground, Stance, Cover, Stand Registry, Hub API.
- [Standing](standing.md) — the Bayesian anti-gaming metric (REQ-006),
and why the mesh cannot be gamed.
- [Window](window.md) — the delegation primitive (REQ-015): scope,
duration, rate-limit, audit-log, revoke.
## Where a Nomad starts
A Nomad starts with a Reach and a Stash — that is enough to begin. From
there the bearers carry value to the Stash, Maps finds services, Pay and
the Window let a Nomad use them without giving up custody, and Standing
accrues as the Nomad acts. A Nomad who earns the 90-day Stash signal, the
Standing threshold, the Capital signal, and the Vouch signal becomes a
Freeholder (see the Freeholders section).
## Shared concepts
The Nomad path rests on the [shared concepts](../shared/index.md): the
[Six Principles](../shared/six-principles.md), the [Bread scale](../shared/bread-scale.md),
the [Storage Pools](../shared/storage-pools.md), the [Watchers & Mirror](../shared/watchers-mirror.md),
and the [Lexicon](../shared/lexicon.md). The covenant is the same for
every audience; the Nomad pages describe how it shows up in everyday use.
+46
View File
@@ -0,0 +1,46 @@
# Maps & Pay
**Maps** and **Pay** are the day-to-day Mesh Experience a Nomad uses on the
mesh. Maps finds services; Pay settles them. Both run over the
[bearers](bearers.md) and read the [Mirror](../shared/watchers-mirror.md) so a
Nomad can find and pay for a service on a surveillance-resistant bearer
without an internet connection to OY Chain.
## Maps
Maps is the directory of services a Nomad can reach. A service is anything
a Partner or a Stand exposes to the mesh: a Care service, a SIM, a Vault,
a Mailbox (preview of v0.3 P5 — see [Pacts](pacts.md) for the Hub API). Maps
is sorted by geographic proximity (REQ-007): a Nomad physically closer to a
service or its operator is shown that service first. There is no paid
ranking; the order is proximity, not promotion.
## Pay
Pay is how a Nomad settles a service. A payment is a transfer of Bread
from the Nomad's [Stash](stash.md) to the service operator's Stash, signed
by the Nomad's [Reach](reach.md). The fee covenant floors and caps the fee;
inside a Guild, a Hand-Pass is free at the protocol level (REQ-017). Pay
runs over any bearer, first-to-deliver-wins.
## Authorize, don't hand over
For recurring services a Nomad does not re-sign every payment. Instead
the Nomad opens a [Window](window.md) to the service: a scoped,
time-limited, rate-limited, revocable capability that lets the service pull
value from the Stash within bounds the Nomad set. The Window is audited;
the Nomad can revoke it at any time. This is the self-service principle in
practice: the Nomad delegates a capability, not custody.
## Find, pay, verify
A Nomad's loop is:
1. **Find** a service on Maps.
2. **Pay** once, or **authorize** a [Window](window.md) for recurring use.
3. **Verify** the service against the Watcher attestations on the Mirror
(see [Watchers & Mirror](../shared/watchers-mirror.md)).
See [Stash](stash.md) for where the Bread comes from, [Window](window.md)
for the delegation primitive, and [Standing](standing.md) for how a
service operator's history is measured.
+41
View File
@@ -0,0 +1,41 @@
# Pacts
The **six Pacts** (REQ-020) are the contract shapes a Nomad encounters on
the mesh. A Pact is a typed, mission-locked agreement between parties; the
core terms of the Pause, Ground, and Stance Pacts are **non-amendable**
no Council can rewrite them after the fact. A Nomad mostly interacts with
Pacts through [Maps & Pay](maps-pay.md) and the [Window](window.md)
primitive, but it helps to know what each one is.
## The six Pacts
| Pact | What it does for a Nomad |
|---|---|
| **Pause** | A temporary hold. A Nomad can pause a recurring payment or a Window without voiding it; the Pause core terms are non-amendable. |
| **Ground** | A baseline obligation the mesh enforces by default — the "ground rules" between a Nomad and a service operator. Non-amendable. |
| **Stance** | A stated position a party commits to (e.g., a service operator's Stance on jurisdiction-light operation). Non-amendable. |
| **Cover** | A flat commitment a Stand or a Partner offers to cover a Nomad against a defined failure; a Nomad reads Cover when choosing a service. |
| **Stand Registry** | The registry of the nine [Stand](../shared/storage-pools.md) types (Household, Crew, Entity, Co-op, Circle, Trust, Foundation, Confederation, Shadow) a Nomad can join. |
| **Hub API** | The B2B backbone (preview of v0.3 P5) — the Hub Pact exposes custody, a lending primitive, and compliance to service operators. A Nomad sees the Hub through Maps, not directly. |
## What a Nomad actually does with Pacts
A Nomad does not draft Pacts by hand. The flow is:
1. **Find** a service on [Maps & Pay](maps-pay.md).
2. The service's terms are backed by one or more Pacts (e.g., a recurring
payment is a Pause-able Window; a Stand's service is registered in the
Stand Registry).
3. The Nomad **authorizes** a [Window](window.md) scoped to those terms.
## Mission Lock
The Pause, Ground, and Stance core terms are mission-locked: a `const`
flag in the Pact module marks them non-amendable, and an invariant test
asserts that flag can never flip. A Nomad can rely on the ground rules
not changing. See [Six Principles](../shared/six-principles.md) for the
mission-lock covenant.
See [Window](window.md) for the delegation primitive the Pacts are
delivered through, and [Standing](standing.md) for how a service
operator's history is measured.
+48
View File
@@ -0,0 +1,48 @@
# Reach
A **Nomad** is a person using the OpenYield mesh through a **Reach** — the
protocol-level identity that lets a Holder act on the mesh without a
custodian, a gatekeeper, or a legacy financial position. The Reach is the
first of the four Freeholder signals (REQ-005), and it is the baseline every
Nomad starts from: a Nomad is a Holder who has a Reach and a Stash but has not
yet earned all four Freeholder signals.
## What a Reach is
A Reach is an identity, not a custodial position. It is the by-ID-string a
Holder uses to receive value, open a [Window](window.md), join a Stand, or
pay for a service. The protocol does not require KYC at the protocol layer;
the Reach is the unit of self-service (see [Six
Principles](../shared/six-principles.md)).
## How a Nomad starts
A Nomad starts with two things:
1. **A Reach** — the identity.
2. **A [Stash](stash.md)** — the personal [Storage Pool](../shared/storage-pools.md)
where the Holder holds Bread.
That pair is enough to begin. From there a Nomad can use the [bearers](bearers.md)
to reach the mesh, find services on [Maps & Pay](maps-pay.md), authorize a
[Window](window.md) to a partner, and accrue [Standing](standing.md).
## Geographic proximity
The mesh processes actions first-come, first-served with a
geographic-proximity preference (REQ-007) — a Nomad physically closer to a
service or a Stand's region is served first. The Reach is how the mesh
identifies the Nomad for that ordering; there is no separate tier to buy into.
## The four Freeholder signals
The Reach is the first Freeholder signal. The four signals (REQ-005) are
earned over time: the 90-day [Stash](stash.md) signal, the Standing
threshold, the Capital signal, and the Vouch signal. A Nomad who earns all
four becomes a Freeholder (see the Freeholders section). The pages here cover
the Nomad path — everything up to that point.
See [Stash](stash.md) for the Storage Pool a Reach holds Bread in,
[Bearers](bearers.md) for how to reach the mesh, and
[Standing](standing.md) for the anti-gaming metric that accrues as a Nomad
acts on the mesh.
+44
View File
@@ -0,0 +1,44 @@
# Standing
**Standing** (REQ-006) is a Holder's measured history on the mesh. It is
the anti-gaming metric: a Bayesian score with time-decay, diversity
weighting, and voucher-weighting, minus slashes for bad behavior. For a
Nomad, the headline is that the mesh **cannot be gamed** — Standing
rewards real production and resists the obvious attacks (volume spam,
self-dealing, fake vouches).
## What Standing is, in plain language
Standing is not a count of transactions. It is a Bayesian score: the mesh
starts with a prior, updates it from each observed action, and decays
old evidence so a Holder cannot rest on a burst of activity from years
ago. Diversity weighting means a Nomad who acts across many services,
many Stands, and many bearers accrues more Standing than a Nomad who
repeats the same action with the same counterparty. Voucher-weighting
means a vouch from a Holder with high Standing counts for more.
## Why it matters to a Nomad
A Nomad mostly reads Standing, not computes it. Two places it shows up:
- **Choosing a service.** Maps shows a service operator's Standing so a
Nomad can pick an operator with a real history over a freshly-spun-up
alternative (see [Maps & Pay](maps-pay.md)).
- **The Freeholder path.** Earning a Standing threshold in 3 categories
is one of the four Freeholder signals (REQ-005). A Nomad who accrues
Standing over time is on the path to becoming a Freeholder.
## What Standing is not
Standing is not a custodial position, a tier you buy, or a reputation
score you can farm. It is not legacy custodial history. The
Bayesian + time-decay + diversity design is exactly what makes it hard to
game: there is no single input a Holder can pump.
## The math, deferred
The full Bayesian formula (priors, decay rates, diversity sub-tables,
slash conditions) is documented in the Freeholders section — a Nomad does
not need the math to use the mesh. See
[Six Principles](../shared/six-principles.md) for the covenant Standing
enforces, and [Reach](reach.md) for the identity a Standing accrues to.
+48
View File
@@ -0,0 +1,48 @@
# Stash
A **Stash** is a Holder's personal [Storage Pool](../shared/storage-pools.md)
(REQ-014). It is the place a Nomad holds Bread, and it is the second thing a
Nomad needs after a [Reach](reach.md) to begin. The Stash is the unit of
self-service: the Holder owns it, controls it, and can delegate a scoped,
time-limited, revocable [Window](window.md) to a partner or a service
without giving up custody.
## What a Stash is
The Stash is the Holder-level layer of the three Storage Pools (Stash,
Vault, Root-Pool). It is a storage layer, not a custodial position: the
protocol holds the canonical state that records who owns what; the Holder
holds the value. There is no custodian between a Nomad and their Stash.
## How a Nomad uses a Stash
A Nomad moves Bread into a Stash through the [bearers](bearers.md) — a
Holder on a surveillance-resistant bearer can receive value without an
internet connection to OY Chain. From the Stash a Nomad can:
- **Hold** Bread (the unit of value — see [Bread scale](../shared/bread-scale.md)).
- **Pass** value to another Reach (the Hand-Pass, free at the protocol
level inside a Guild).
- **Pay** for a service via [Maps & Pay](maps-pay.md).
- **Authorize** a [Window](window.md) so a partner or service can read the
Stash within bounds the Holder set.
## The 90-day Freeholder signal
Holding a Stash continuously for 90 days is the first of the four
Freeholder signals (REQ-005). The Stash does not need to hold a large
amount — the signal is about continuity, not size. A Nomad who keeps a
Stash for 90 days and earns the other three signals (Standing, Capital,
Vouch) becomes a Freeholder.
## Delegation, not custody
The Stash stays the Holder's. When a Nomad opens a Window to a service,
the service gets a scoped capability (e.g., "read Stash balance for the
next hour", "spend up to N Grain on this service this week") — it does not
get custody. The Window is revocable, rate-limited, and audited. See
[Window](window.md) for the primitive.
See [Storage Pools](../shared/storage-pools.md) for the full three-pool
model, and [Bearers](bearers.md) for how value reaches a Stash over a
surveillance-resistant bearer.
+45
View File
@@ -0,0 +1,45 @@
# Window
A **Window** (REQ-015) is the primitive a Nomad uses to delegate a
capability without delegating custody. It is scoped, time-limited,
rate-limited, audited, and revocable. A Nomad opens a Window so a partner
or a service can act on the Nomad's [Stash](stash.md) within bounds the
Nomad set — the partner never gets custody, and the Nomad can close the
Window at any time.
## The five parts of a Window
| Part | What it bounds |
|---|---|
| **Scope** | what the grantee can do (e.g., read Stash balance, spend up to N Grain on a specific service). |
| **Duration** | when the Window starts and ends (a start time and an end time). |
| **Rate limit** | how many actions per duration window (e.g., at most 10 reads per hour). |
| **Audit log** | an append-only log of every action the grantee took under the Window. |
| **Revoke** | the Nomad can revoke the Window at any time; revoke after expiry is a no-op. |
## Why a Nomad opens one
A Nomad opens a Window for the same reason a Nomad uses [Maps & Pay](maps-pay.md):
to let a service do something on the Nomad's behalf without handing over
the Stash. Common examples:
- A recurring service (e.g., a Care service) pulls a capped amount of
Bread from the Stash each week, within a rate limit the Nomad set.
- A partner reads the Stash balance for a compliance check, scoped to
read-only, time-limited to one hour.
- A Stand operator processes a Pass-Act on the Nomad's behalf inside a
scoped, audited Window.
## Lifecycle
A Window moves through a fixed lifecycle: **Open → Active → Revoked** or
**Expired**. A Nomad can revoke at any point; revoking after expiry is a
no-op (idempotent). The lifecycle is mission-locked: a partner cannot
extend a Window past its end time — the Nomad must open a new one.
## Self-service, by design
The Window is the self-service principle in code. The protocol records
the Window on OY Chain; the partner holds only the capability, never the
value. See [Six Principles](../shared/six-principles.md) for the covenant,
and [Pacts](pacts.md) for the contract shapes delivered through Windows.
+51
View File
@@ -0,0 +1,51 @@
# Architecture
This is the architecture index for OpenYield. The mesh is built from 14
modular components and 6 cross-component interfaces, with a critical blocker
chain that fixes the build order. The full governance source lives in
`.ciagent/oy/ARCHITECTURE.md`; this page is the user-facing rewrite, kept
lexicon-clean by the [docs firewall](../shared/lexicon.md).
## The 14 modular components
| # | Component | Vision § | Phase |
|---|---|---|---|
| 1 | OY Chain & Mirror | §7 | P1 |
| 2 | Cross-Chain & Exit | §7 | P3 |
| 3 | Bread Unit & Root Basket | §6, §16 | P1 |
| 4 | Bloom Engine | §6 | P1 |
| 5 | Storage Substrate | §5 | P1 |
| 6 | Identity, Standing & Citizenship | §8, §9 | P1 |
| 7 | Window Primitive | §10 | P2 |
| 8 | Pacts Suite (Pause, Ground, Stance, Cover, Stand Registry, Hub API, Bonds) | §16, §17 | P2 |
| 9 | Mesh Experience (Maps, Pay) | §8 | P1 |
| 10 | Organizational Primitives (Stands, Guilds) | §11, §12 | P2 |
| 11 | Partner Spectrum & Forex | §13 | P2 |
| 12 | Bearers & Processing Mesh | §14, §15 | P1 |
| 13 | Fee Covenant | §18 | P1 |
| 14 | Governance (Mesh/Guild/Stand Councils) | §19 | P2 |
## The 6 cross-component interfaces
1. **Standing API** — consumed by Identity, Window, Pacts, Orgs, Partners,
and Governance. See [Standing](../freeholders/standing.md).
2. **Forge / Fold Interface** — mints [Bread](../shared/bread-scale.md)
against Root Basket assets only. See the Bloom Engine.
3. **Watcher Attestation Interface (the Mirror)** — 9 Watchers, 6-of-9
quorum. See [Watchers & Mirror](../shared/watchers-mirror.md).
4. **Window Lifecycle Interface** — Holder-authorized, scope-bounded,
revocable. See [Window](../nomads/window.md).
5. **Fee Covenant Interface** — auto-decline, ceiling/floor enforced.
See [Six Principles](../shared/six-principles.md).
6. **Voice / Council Interface** — multi-source Voice, Mission Lock enforced.
See [Councils & Voice](../freeholders/councils-voice.md).
## The critical blocker chain
The components build in a fixed order: OY Chain (1) → Bread/Root Basket (3)
→ Storage (5) → Identity/Standing (6), which then unblocks {Window (7),
Pacts (8), Orgs (10), Partners (11), Governance (14)}. The Fee Covenant (13)
blocks Pacts, Orgs, Partners, and Bearers — the fee shape must exist before
any of those can ship. v0.3 adds the Cross-Chain & Exit layer (component 2)
and the Bearers/Partner/Bond extensions; see [Components](components.md) for
the `x/` module map and the v0.3 phase status.
+62
View File
@@ -0,0 +1,62 @@
# Component Map
This is the `x/` module map for OpenYield. Each module is a Cosmos-SDK-style
`x/<name>/types/` package, zero external Go deps (G-006), referenced by
ID-string across modules (G-003 — no struct imports). The map covers v0.1,
v0.2, and v0.3 (skeleton + tests depth, D-020/D-035).
## v0.1 baseline (pre-MVP skeleton)
| Module | Vision § | REQ | Purpose |
|---|---|---|---|
| `x/mesh` | §7 | REQ-008 | OY Chain (Layer 1) shell |
| `x/mirror` | §7 | REQ-004 | Mirror of canonical state to bearers |
| `x/bread` | §4, §6 | REQ-013 | Bread unit + 11-tier scale |
| `x/bloom` | §6 | REQ-003 | Bloom Engine (real production only) |
| `x/forge` | §4.2 | REQ-003 | Forge/Fold minting against Root Basket |
| `x/rootpool` | §5 | REQ-014 | Root-Pool (mesh treasury) |
| `x/stash` | §5 | REQ-014 | Stash (Holder-level storage pool) |
| `x/vault` | §5 | REQ-014 | Vault (Stand-level storage pool) |
| `x/identity` | §8 | REQ-005 | Reach identity (Holder, no KYC) |
| `x/standing` | §9.2 | REQ-006 | Bayesian Standing |
| `x/processing` | §15 | REQ-007 | FCFS processing mesh |
| `x/watcher` | §7 | REQ-004 | 9 Watchers, 6-of-9 quorum |
| `x/feecovenant` | §18 | REQ-002 | Fee ceiling/floor/minimum |
| `x/still` | §3 | — | Still/Stir pause/resume state |
| `x/bearers` | §14 | REQ-019 | Unified Bearer Layer (6 bearers) |
## v0.2 (The Mesh — skeleton + tests)
| Module | Vision § | REQ | Purpose |
|---|---|---|---|
| `x/window` | §10 | REQ-015 | Window primitive (scope, rate-limit, revoke) |
| `x/stand` | §11 | REQ-016 | Nine Stand types |
| `x/guild` | §12 | REQ-017 | Guilds + Hand-Passes at 0% protocol fee |
| `x/pact` | §16 | REQ-020 | Six Pacts (Pause, Ground, Stance, Cover, Stand Registry, Hub API) |
| `x/partner` | §13 | REQ-018 | Four-tier Partner Spectrum (Op, Master Op, Pier, Anchor) |
| `x/council` | §19 | REQ-011 | Three Councils + Mission Lock (non-amendable) |
| `x/forex` | §13 | Forex v1 | Forex Engine v1 (pair type + oracle interface) |
| `x/bond` | §17 | REQ-021 | Mesh Bond Market (8% cap / 0% floor clamp) |
| `x/satellite` | §7 | REQ-009 | L2 IBC Satellite (Polygon active + 4 stubs) |
## v0.3 (Bearers & Documentation — in progress)
| Module | Vision § | REQ | Status | Purpose |
|---|---|---|---|---|
| `x/bridge` | §7 | REQ-010 | P4 (pending) | L2↔L1 bridge routes |
| `x/exit` | §7 | REQ-010 | P4 (pending) | Exit routes + DEX swaps |
| `x/bearers` (ext) | §14 | REQ-022 | P4 (pending) | OY-SAT + OY-QR transport stubs |
| `x/partner` (ext) | §13 | REQ-023 | P4 (pending) | AnchorCredential (Anchor tier) |
| `x/hub` | §13, §16 | REQ-024 | P5 (pending) | Hub API (Custody, Lending, Compliance) |
| `x/services` | §13 | REQ-025 | P5 (pending) | Services (Care, SIM, Vault, Mail) |
| `x/bond` (ext) | §17 | REQ-026 | P5 (pending) | Growth Bonds + secondary market |
## Notes
- Every module follows the same pattern: `types/types.go` + `types/types_test.go`
(package `types`), zero external deps, by-ID-string inter-module refs (G-003).
- Each new/extended test file includes a lexicon assertion (REQ-012); the
project-wide meta-test (`lexicon_meta_test.go`) scans all `x/**/*.go`.
- The docs firewall (`lexicon_meta_docs_test.go`) scans `README.md` + all
`docs/**/*.md`. See the [architecture index](architecture.md) for the
14-component view and the 6 cross-component interfaces.
+35
View File
@@ -0,0 +1,35 @@
# Bread scale
The unit of value in OpenYield is **Bread** (REQ-013). Bread is scaled in 11
tiers, each 1,000× the previous, so a Holder can reason about a Crumb and a
Granary in the same mental model:
| Tier | Name | Multiple |
|---|---|---|
| 1 | **Grain** | 1 |
| 2 | **Crumb** | 1,000 Grain |
| 3 | **Bread** | 1,000 Crumb |
| 4 | **Loaf** | 1,000 Bread |
| 5 | **Batch** | 1,000 Loaf |
| 6 | **Cake** | 1,000 Batch |
| 7 | **Bakery** | 1,000 Cake |
| 8 | **Granary** | 1,000 Bakery |
| 9 | **Mill** | 1,000 Granary |
| 10 | **Harvest** | 1,000 Mill |
| 11 | **Earth** | 1,000 Harvest |
## Why 11 tiers
The 11-tier scale gives the mesh a single unit for everything from a
1-Grain internal minimum (the Fee Covenant floor) to the Earth-tier totals
held in the Root-Pool. There is no separate "small unit" and "large unit":
the Bread scale is the unit. The 1-Grain minimum prevents dust games; the
tier names keep human-readable values at every scale.
## Where Bread lives
Bread is held in the three [Storage Pools](storage-pools.md): the Stash
(Holder-level), the Vault (Stand-level), and the Root-Pool (treasury). The
Watchers attest to the state of the pools daily; the Mirror mirrors the
canonical state to the bearers. See [Watchers & Mirror](watchers-mirror.md)
for the attestation layer.
+16
View File
@@ -0,0 +1,16 @@
# Shared concepts
The Shared section holds the concepts common to every OpenYield audience —
Nomads and Freeholders alike. These are the covenant-level ideas that make
OpenYield a public-good mesh rather than a custodial platform.
- [Six Principles](six-principles.md) — the immutable covenant (REQ-001).
- [Bread Scale](bread-scale.md) — the unit of value and its 11 tiers (REQ-013).
- [Storage Pools](storage-pools.md) — the three pools (Stash, Vault, Root-Pool) (REQ-014).
- [Watchers & Mirror](watchers-mirror.md) — the 9 Watchers, 6-of-9 quorum, the Mirror (REQ-004).
- [Lexicon](lexicon.md) — why 10 terms are banned, and what to say instead (REQ-012).
- [Vision](vision.md) — the OpenYield covenant in brief.
See the [README](../index.md) for build instructions, or the
[Nomads](../nomads/index.md) and [Freeholders](../freeholders/index.md)
sections for audience-specific docs.
+148
View File
@@ -0,0 +1,148 @@
# Lexicon
OpenYield bans 10 financial terms as standalone words (REQ-012). The firewall
scans every Go file under `x/` and every Markdown file under `README.md` +
`docs/`, and fails the build on any standalone occurrence. This page documents
**why** the terms are banned and **what to say instead** — the replacements,
not the banned literals.
## Why a lexicon
The words a legacy financial institution uses carry the shapes of that
institution: custodial positions, jurisdiction-bound units, and
speculation-language. OpenYield is a jurisdiction-light, public-good mesh for
real production; using the old words would import the old shapes. The
lexicon firewall keeps the mesh's language aligned with its covenant. The
firewall is enforced in code by two sibling Go tests
(`lexicon_meta_test.go` for `x/**/*.go`;
`lexicon_meta_docs/lexicon_meta_docs_test.go` for `README.md` +
`docs/**/*.md`), both using `lexicon.FindBannedTerm` (word-boundary,
case-insensitive). Word-boundary matching means "OpenYield" is safe — the
firewall bans standalone words, not substrings.
## The 10 banned terms and their safe replacements
The firewall bans 10 standalone words. This page does not write the banned
words as literals (the firewall scans this page); it describes them by the
concept each belongs to, and gives the safe replacement.
### 1. The custodial-position word
A legacy institution holds your value in a custodial position. OpenYield
does not: a Holder owns their **Stash**, a Stand owns its **Vault**, the mesh
owns the **Root-Pool**. The Holder's identity is a **Reach**, and the Holder
themselves is a **Holder** — never the banned custodial-position word.
- Banned: the word for a custodial position.
- Safe: **Holder**, **Reach**, **Stash**, **Vault**, **Root-Pool**.
### 2. The legacy-institution word
The legacy financial institution itself is banned as a concept. OpenYield is
a **mesh**, a **public good**, a **protocol** — not that word.
- Banned: the word for a legacy financial institution.
- Safe: **mesh**, **protocol**, **public good**.
### 3. The place-value word
The word for a place to hold value under custody is banned. Use the
**Stash** (Holder-level), the **Vault** (Stand-level), or the **Root-Pool**
(treasury).
- Banned: the word for a place value is held.
- Safe: **Stash**, **Vault**, **Root-Pool**, **Storage Pools**.
### 4. The put-in word
The verb for putting value into a custodial position is banned. Use **hold**,
**store**, **move**, or **transfer**.
- Banned: the verb for placing value under custody.
- Safe: **hold**, **store**, **move**, **transfer**, **Pass-Act**.
### 5. The passive-value word
The word for value earned passively on a custodial position is banned. For
bonds, use **coupon**. For the mesh's metric, use **real production** or
**real return**.
- Banned: the word for passive value on a custodial position.
- Safe: **coupon**, **real production**, **real return**.
### 6. The standalone metric word
The standalone word for a return metric is banned (it is the same concept as
#5 in verb form). Use **real production**, **real return**, or **coupon**
(for bonds). "OpenYield" is safe — word-boundary matching does not flag the
banned term inside an identifier.
- Banned: the standalone return-metric word.
- Safe: **real production**, **real return**, **coupon**. **OpenYield** is safe.
### 7. The medium-of-exchange word
The word for a national medium of exchange is banned. The mesh's unit is
**Bread** (see [Bread scale](bread-scale.md)). For a foreign-exchange pair,
use **Forex** (allowed) with **base-asset** / **quote-asset** labels, or
**Bread / Asset**.
- Banned: the word for a national medium of exchange.
- Safe: **Bread**, **asset**, **Forex**, **base-asset**, **quote-asset**.
### 8. The first national-unit word
The word for the first major national unit is banned. Use **Bread** or
opaque chain names (e.g., "Polygon", "OY-Chain").
- Banned: the first national-unit word.
- Safe: **Bread**, **asset**, chain names.
### 9. The second national-unit word
The word for the second major national unit is banned (the firewall bans it
as a standalone word; "european" is safe by word-boundary). Use **Bread** or
opaque chain names.
- Banned: the second national-unit word.
- Safe: **Bread**, **asset**, chain names. **European** is safe (word-boundary).
### 10. The set-aside word
The word for value set aside under custody is banned. Use **Stash**,
**Vault**, or **Root-Pool**.
- Banned: the word for value set aside.
- Safe: **Stash**, **Vault**, **Root-Pool**.
### 11. The holder-of-value word
The word for the person who holds value under custody at a legacy
institution is banned. Use **Holder**, **Freeholder**, or **Nomad**.
- Banned: the word for a custodial-position holder.
- Safe: **Holder**, **Freeholder**, **Nomad**, **Reach**.
> **Note**: the firewall bans 10 standalone words; this page lists 11
> replacements because two of the banned words (the passive-value word and
> the standalone metric word) share a concept and get the same replacement
> family (**coupon** / **real production** / **real return**).
## How the firewall works
The firewall uses `lexicon.FindBannedTerm` — a word-boundary, case-insensitive
regex match — so:
- "OpenYield" is **safe**: the standalone banned term inside an identifier
does not match (word-boundary).
- "european" is **safe**: the standalone national-unit word inside a larger
word does not match.
- The standalone banned word in prose **is** matched and fails the build.
The firewall's own source (`lexicon/lexicon.go`) assembles the banned terms
at runtime from two-character fragments, so the firewall's own code does not
contain any banned term as a literal substring. The two sibling meta-tests
(`lexicon_meta_test.go` and `lexicon_meta_docs/lexicon_meta_docs_test.go`)
each include a self-test table that verifies detection of all 10 banned
terms from the single source `lexicon.BannedTerms()` (G-014 drift
prevention).
+54
View File
@@ -0,0 +1,54 @@
# Six Principles
The Six Principles are the immutable covenant of OpenYield (REQ-001). They
are **Mission-locked**: no Council can amend them, and the fee covenant is
locked alongside them. The mesh exists to hold real production, not
speculation; everything else follows from that.
## 1. Real value
The mesh holds **real production**. The Bread unit is the unit of real value
held in the Storage Pools; the bond market caps coupons so the mesh cannot
become a speculation engine. "Real return" is the metric, not a nominal rate.
## 2. Sustainability
Fees are floored and capped. The fee covenant fixes a ceiling and a floor
(see the Fee Covenant module), and the 1-Grain internal minimum prevents dust
games. The protocol cannot drain its users, and it cannot starve its
Watchers.
## 3. Mission-lock
The Six Principles and the fee covenant are immutable. No Council — Mesh,
Guild, or Stand — can amend them. Mission Lock is a `const` in the council
module, and an invariant test asserts it can never be set to amendable. The
coupon cap on bonds is a mission-locked ceiling, not a parameter a Council
can tune.
## 4. Openness
Anyone may join. The mesh is a public good. A Holder needs only a Reach (an
identity) and a Stash (a storage pool) to begin; there is no gatekeeper and
no custodian.
## 5. Ownership
Holders own their Stash and their Reach. Custody is theirs: the Stash is the
Holder-level storage pool, the Vault is the Stand-level pool, and the
Root-Pool is the treasury. The protocol does not custody user value; it
holds the canonical state that records who owns what.
## 6. Self-service
A Holder can act without a custodian. The Window primitive lets a Holder
delegate a scope-bounded, time-limited, revocable capability to a partner or
a service; the bearers (OY-LR, OY-BLE, OY-WiFi-Direct, OY-SAT, OY-QR) let a
Holder reach the mesh without a phone plan or a custodial on-ramp. The mesh
is jurisdiction-light by design.
---
See the [Vision](vision.md) for the covenant in brief, or the
[Lexicon](lexicon.md) for why the docs say "real production" and "Holder"
rather than the words a legacy financial institution would use.
+48
View File
@@ -0,0 +1,48 @@
# Storage Pools
OpenYield has three Storage Pools (REQ-014). Each is a layer of custody
responsibility, and none of them is a custodial position — the protocol holds
the canonical state that records who owns what; the Holder, the Stand, and
the mesh treasury each hold their own pool.
| Pool | Level | Held by | Purpose |
|---|---|---|---|
| **Stash** | Holder | a single Holder | the personal storage pool; the unit of self-service |
| **Vault** | Stand | a Stand (a governed group) | the Stand-level pool; the unit of shared ownership |
| **Root-Pool** | Mesh | the mesh treasury | the canonical treasury; the unit of the public good |
## The Stash
The Stash is the Holder-level storage pool. A Holder needs only a Reach (an
identity) and a Stash to begin. The Stash is the unit of self-service: the
Holder owns it, controls it, and can delegate a scoped, time-limited,
revocable Window to a partner or a service without giving up custody. See
[Watchers & Mirror](watchers-mirror.md) for the attestation layer that
records Stash state.
## The Vault
The Vault is the Stand-level storage pool. A Stand is a governed group
(one of the nine Stand types: Household, Crew, Entity, Co-op, Circle,
Trust, Foundation, Confederation, Shadow) that holds a Vault in common. The
Stand's decision policy (threshold or weighted, mirroring the Cosmos SDK
`x/group` shape) governs how the Vault is used. See the Freeholders section
for Stands & Guilds.
## The Root-Pool
The Root-Pool is the mesh treasury. It holds the canonical state of the
Bread unit, the Watcher bonds, and the Root Basket. The Root-Pool is the
unit of the public good: the Watchers attest to its state daily, and the
Mirror mirrors it to the bearers so a Holder can verify the mesh's real
return without trusting a single custodian.
## Custody, not custody
The three pools are storage layers, not custodial positions. The protocol
does not custody user value; it holds the canonical state that records who
owns what. A Holder's Stash is theirs; a Stand's Vault is the Stand's; the
Root-Pool is the mesh's. The Window primitive lets a Holder delegate a
capability without delegating custody. See the [Lexicon](lexicon.md) for
why the docs say "Stash", "Vault", and "Root-Pool" rather than the words a
legacy financial institution would use.
+64
View File
@@ -0,0 +1,64 @@
# Vision
OpenYield is a jurisdiction-light, public-good mesh for **real production**.
The vision is a covenant, not a product: the mesh holds real value, the Six
Principles are immutable, and the protocol cannot become a custodial
platform. This page is the brief overview; the full vision source lives in
`.ciagent/oy/PROJECT.md`.
## The covenant
OpenYield exists to hold **real production** — the real return of real work,
held in the Bread unit, in the three Storage Pools, attested by the Watchers,
mirrored by the Mirror. The covenant is anti-greed by construction:
- **Mission Lock** fixes the Six Principles and the fee covenant. No Council
— Mesh, Guild, or Stand — can amend them. The coupon cap on bonds is a
mission-locked ceiling, not a parameter.
- **Jurisdiction-light** — the bearers (OY-LR, OY-BLE, OY-WiFi-Direct, OY-SAT,
OY-QR) let a Holder reach the mesh without a phone plan or a custodial
on-ramp. A Holder needs only a Reach and a Stash to begin.
- **Public good** — the mesh is open to all. The Watchers attest daily; the
Mirror mirrors the state; anyone can verify the mesh's real return without
trusting a single custodian.
## The layers
1. **OY Chain** (Layer 1) — the canonical state: the Bread unit, the
Storage Pools, Standing, Watcher attestations, the Pact / Council /
Partner surface.
2. **Satellites** (Layer 2) — wrapped Bread propagates to satellite chains
(Polygon active; Base, Arbitrum, Optimism, Solana as enum placeholders)
via IBC.
3. **Bearers** — the surveillance-resistant transport layer: OY-LR (LoRa,
long-range), OY-BLE (Bluetooth), OY-WiFi-Direct, OY-SAT (satellite),
OY-QR (paper / QR code). The Mirror mirrors canonical state to them.
4. **Exits** — the Layer 3 exit layer: Holder-initiated DEX swaps and
off-mesh service exits, with bridge routes for cross-chain exits.
## The actors
- **Holders** (Nomads) — the everyday participants, each with a Reach and a
Stash.
- **Freeholders** — the active participants who run Stands, Guilds, and
Councils.
- **Partners** — the four-tier spectrum (Op, MasterOp, Pier, Anchor) that
processes Pass-Acts and provides credentials and institutional backing.
- **Watchers** — the 9 attesters with 6-of-9 quorum and 100,000 Bread bonds.
## The units
- **Bread** — the unit of real value (see [Bread scale](bread-scale.md)).
- **Standing** — the reputation layer (the four signals, Bayesian Standing).
- **Voice** — the governance input (multi-source: Stash, Standing, Vouch,
Freeholder, Guild).
- **Coupon** — the bond-market term (capped at 8% / floored at 0%, mission-locked).
## Where to go next
- [Six Principles](six-principles.md) — the immutable covenant.
- [Storage Pools](storage-pools.md) — the three pools.
- [Watchers & Mirror](watchers-mirror.md) — the attestation layer.
- [Lexicon](lexicon.md) — why the docs say "real production" and "Holder".
- [README](../../README.md) — build & test instructions.
- `.ciagent/oy/PROJECT.md` — the full vision source.
+42
View File
@@ -0,0 +1,42 @@
# Watchers & Mirror
OpenYield is attested by **9 Watchers** with a **6-of-9 quorum** (REQ-004).
The Watchers make daily attestations to the canonical state, and each posts
a 100,000 Bread bond. The **Mirror** mirrors the canonical state to the
bearers so a Holder can verify the mesh's state without trusting a single
Watcher.
## The 9 Watchers
The Watchers are the attestation layer of the mesh. There are exactly 9, and
the quorum is 6-of-9: any 6 Watchers can attest to a state transition, but no
5 can. Each Watcher posts a 100,000 Bread bond, which is at risk if the
Watcher attests to a false state. The 9/6 split is a mission-locked
parameter — no Council can lower the quorum or the bond.
## Daily attestations
The Watchers attest to the state of the three [Storage Pools](storage-pools.md)
daily: the Stash totals, the Vault totals, and the Root-Pool. The
attestation is a signed statement that the canonical state recorded by OY
Chain matches the state the Watcher observed. A Holder who wants to verify
the mesh's real return can read the attestations and check that the
Watchers agree.
## The Mirror
The Mirror mirrors the canonical state to the bearers (OY-LR, OY-BLE,
OY-WiFi-Direct, OY-SAT, OY-QR). A Holder on a surveillance-resistant bearer
can read the mirrored state without an internet connection to OY Chain; the
Mirror is the read-side of the bearer layer. The Mirror is read-only: it
mirrors state, it does not author it. Authoritative state lives on OY Chain
and is attested by the Watchers.
## Why 6-of-9
The 9/6 split is a balance: 9 is large enough that no single adversary can
easily capture a quorum, and 6 is large enough that no small cabal can
attest to a false state. The 100,000 Bread bond per Watcher makes
capturing a quorum expensive. The split is locked by Mission Lock — no
Council can change it. See [Six Principles](six-principles.md) for the
mission-lock covenant.
+151
View File
@@ -1,3 +1,154 @@
module github.com/oy/openyield
go 1.22
require (
cosmossdk.io/store v1.1.0
github.com/cosmos/cosmos-sdk v0.50.8
github.com/cosmos/ibc-go/modules/capability v1.0.0
github.com/cosmos/ibc-go/v8 v8.2.1
)
require (
cosmossdk.io/api v0.7.5 // indirect
cosmossdk.io/collections v0.4.0 // indirect
cosmossdk.io/core v0.11.0 // indirect
cosmossdk.io/depinject v1.0.0-alpha.4 // indirect
cosmossdk.io/errors v1.0.1 // indirect
cosmossdk.io/log v1.3.1 // indirect
cosmossdk.io/math v1.3.0 // indirect
cosmossdk.io/x/tx v0.13.3 // indirect
cosmossdk.io/x/upgrade v0.1.0 // indirect
filippo.io/edwards25519 v1.0.0 // indirect
github.com/99designs/go-keychain v0.0.0-20191008050251-8e49817e8af4 // indirect
github.com/99designs/keyring v1.2.1 // indirect
github.com/DataDog/datadog-go v3.2.0+incompatible // indirect
github.com/DataDog/zstd v1.5.5 // indirect
github.com/beorn7/perks v1.0.1 // indirect
github.com/bgentry/speakeasy v0.1.1-0.20220910012023-760eaf8b6816 // indirect
github.com/btcsuite/btcd/btcec/v2 v2.3.2 // indirect
github.com/cenkalti/backoff/v4 v4.1.3 // indirect
github.com/cespare/xxhash v1.1.0 // indirect
github.com/cespare/xxhash/v2 v2.3.0 // indirect
github.com/cockroachdb/errors v1.11.1 // indirect
github.com/cockroachdb/logtags v0.0.0-20230118201751-21c54148d20b // indirect
github.com/cockroachdb/pebble v1.1.0 // indirect
github.com/cockroachdb/redact v1.1.5 // indirect
github.com/cockroachdb/tokenbucket v0.0.0-20230807174530-cc333fc44b06 // indirect
github.com/cometbft/cometbft v0.38.9 // indirect
github.com/cometbft/cometbft-db v0.9.1 // indirect
github.com/cosmos/btcutil v1.0.5 // indirect
github.com/cosmos/cosmos-db v1.0.2 // indirect
github.com/cosmos/cosmos-proto v1.0.0-beta.5 // indirect
github.com/cosmos/go-bip39 v1.0.0 // indirect
github.com/cosmos/gogogateway v1.2.0 // indirect
github.com/cosmos/gogoproto v1.5.0 // indirect
github.com/cosmos/iavl v1.1.2 // indirect
github.com/cosmos/ics23/go v0.10.0 // indirect
github.com/cosmos/ledger-cosmos-go v0.13.3 // indirect
github.com/danieljoos/wincred v1.1.2 // indirect
github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc // indirect
github.com/decred/dcrd/dcrec/secp256k1/v4 v4.2.0 // indirect
github.com/desertbit/timer v0.0.0-20180107155436-c41aec40b27f // indirect
github.com/dgraph-io/badger/v2 v2.2007.4 // indirect
github.com/dgraph-io/ristretto v0.1.1 // indirect
github.com/dgryski/go-farm v0.0.0-20200201041132-a6ae2369ad13 // indirect
github.com/dustin/go-humanize v1.0.1 // indirect
github.com/dvsekhvalnov/jose2go v1.6.0 // indirect
github.com/emicklei/dot v1.6.1 // indirect
github.com/fatih/color v1.15.0 // indirect
github.com/felixge/httpsnoop v1.0.4 // indirect
github.com/fsnotify/fsnotify v1.7.0 // indirect
github.com/getsentry/sentry-go v0.27.0 // indirect
github.com/go-kit/kit v0.12.0 // indirect
github.com/go-kit/log v0.2.1 // indirect
github.com/go-logfmt/logfmt v0.6.0 // indirect
github.com/godbus/dbus v0.0.0-20190726142602-4481cbc300e2 // indirect
github.com/gogo/googleapis v1.4.1 // indirect
github.com/gogo/protobuf v1.3.2 // indirect
github.com/golang/glog v1.2.0 // indirect
github.com/golang/protobuf v1.5.4 // indirect
github.com/golang/snappy v0.0.4 // indirect
github.com/google/btree v1.1.2 // indirect
github.com/google/go-cmp v0.6.0 // indirect
github.com/gorilla/handlers v1.5.2 // indirect
github.com/gorilla/mux v1.8.1 // indirect
github.com/gorilla/websocket v1.5.0 // indirect
github.com/grpc-ecosystem/go-grpc-middleware v1.4.0 // indirect
github.com/grpc-ecosystem/grpc-gateway v1.16.0 // indirect
github.com/gsterjov/go-libsecret v0.0.0-20161001094733-a6f4afe4910c // indirect
github.com/hashicorp/go-hclog v1.5.0 // indirect
github.com/hashicorp/go-immutable-radix v1.3.1 // indirect
github.com/hashicorp/go-metrics v0.5.3 // indirect
github.com/hashicorp/go-plugin v1.5.2 // indirect
github.com/hashicorp/golang-lru v1.0.2 // indirect
github.com/hashicorp/hcl v1.0.0 // indirect
github.com/hashicorp/yamux v0.1.1 // indirect
github.com/hdevalence/ed25519consensus v0.1.0 // indirect
github.com/huandu/skiplist v1.2.0 // indirect
github.com/iancoleman/strcase v0.3.0 // indirect
github.com/improbable-eng/grpc-web v0.15.0 // indirect
github.com/inconshreveable/mousetrap v1.1.0 // indirect
github.com/jmhodges/levigo v1.0.0 // indirect
github.com/klauspost/compress v1.17.7 // indirect
github.com/kr/pretty v0.3.1 // indirect
github.com/kr/text v0.2.0 // indirect
github.com/libp2p/go-buffer-pool v0.1.0 // indirect
github.com/linxGnu/grocksdb v1.8.14 // indirect
github.com/magiconair/properties v1.8.7 // indirect
github.com/mattn/go-colorable v0.1.13 // indirect
github.com/mattn/go-isatty v0.0.20 // indirect
github.com/mitchellh/go-testing-interface v1.14.1 // indirect
github.com/mitchellh/mapstructure v1.5.0 // indirect
github.com/mtibben/percent v0.2.1 // indirect
github.com/oasisprotocol/curve25519-voi v0.0.0-20230904125328-1f23a7beb09a // indirect
github.com/oklog/run v1.1.0 // indirect
github.com/pelletier/go-toml/v2 v2.1.0 // indirect
github.com/petermattis/goid v0.0.0-20231207134359-e60b3f734c67 // indirect
github.com/pkg/errors v0.9.1 // indirect
github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 // indirect
github.com/prometheus/client_golang v1.19.0 // indirect
github.com/prometheus/client_model v0.6.1 // indirect
github.com/prometheus/common v0.52.2 // indirect
github.com/prometheus/procfs v0.13.0 // indirect
github.com/rcrowley/go-metrics v0.0.0-20201227073835-cf1acfcdf475 // indirect
github.com/rogpeppe/go-internal v1.12.0 // indirect
github.com/rs/cors v1.8.3 // indirect
github.com/rs/zerolog v1.32.0 // indirect
github.com/sagikazarmark/locafero v0.4.0 // indirect
github.com/sagikazarmark/slog-shim v0.1.0 // indirect
github.com/sasha-s/go-deadlock v0.3.1 // indirect
github.com/sourcegraph/conc v0.3.0 // indirect
github.com/spf13/afero v1.11.0 // indirect
github.com/spf13/cast v1.6.0 // indirect
github.com/spf13/cobra v1.8.0 // indirect
github.com/spf13/pflag v1.0.5 // indirect
github.com/spf13/viper v1.18.2 // indirect
github.com/stretchr/testify v1.9.0 // indirect
github.com/subosito/gotenv v1.6.0 // indirect
github.com/syndtr/goleveldb v1.0.1-0.20220721030215-126854af5e6d // indirect
github.com/tendermint/go-amino v0.16.0 // indirect
github.com/tidwall/btree v1.7.0 // indirect
github.com/zondax/hid v0.9.2 // indirect
github.com/zondax/ledger-go v0.14.3 // indirect
go.etcd.io/bbolt v1.3.8 // indirect
go.uber.org/multierr v1.10.0 // indirect
golang.org/x/crypto v0.22.0 // indirect
golang.org/x/exp v0.0.0-20240404231335-c0f41cb1a7a0 // indirect
golang.org/x/net v0.24.0 // indirect
golang.org/x/sync v0.7.0 // indirect
golang.org/x/sys v0.19.0 // indirect
golang.org/x/term v0.19.0 // indirect
golang.org/x/text v0.14.0 // indirect
google.golang.org/genproto v0.0.0-20240227224415-6ceb2ff114de // indirect
google.golang.org/genproto/googleapis/api v0.0.0-20240227224415-6ceb2ff114de // indirect
google.golang.org/genproto/googleapis/rpc v0.0.0-20240401170217-c3f982113cda // indirect
google.golang.org/grpc v1.63.2 // indirect
google.golang.org/protobuf v1.33.0 // indirect
gopkg.in/ini.v1 v1.67.0 // indirect
gopkg.in/yaml.v3 v3.0.1 // indirect
gotest.tools/v3 v3.5.1 // indirect
nhooyr.io/websocket v1.8.6 // indirect
pgregory.net/rapid v1.1.0 // indirect
sigs.k8s.io/yaml v1.4.0 // indirect
)
+1067
View File
File diff suppressed because it is too large Load Diff
+121
View File
@@ -85,3 +85,124 @@ func FindBannedTerm(s string) (string, bool) {
func ContainsBannedTerm(s string) (string, bool) {
return FindBannedTerm(s)
}
// SyntheticBannedStrings returns one synthetic string per banned term, each
// embedding exactly one banned term in a plausible sentence context. This
// is the single source of truth (REQ-029, GRILL G-014) for the synthetic
// self-test table consumed by BOTH project-wide meta-tests:
//
// lexicon_meta_test.go :: TestLexiconMetaSelfTestTable (package lexicon_meta, scans x/**/*.go)
// lexicon_meta_docs_test.go :: TestLexiconMetaDocsSelfTestTable (package lexicon_meta_docs, scans README.md + docs/**/*.md)
//
// Before REQ-029, both meta-tests DUPLICATED their own 10-string synthetic
// table (byte-identical), creating a drift risk: a future banned-term
// addition updating one table but not the other would silently drop coverage
// in the unmaintained firewall. SyntheticBannedStrings() eliminates the
// duplication — both meta-tests now consume this helper, so a future addition
// updates both firewalls from one place. The strings are built from
// BannedTerms() (already fragment-assembled), so this package's own source
// stays lexicon-clean (the firewall's own code is allowed to name the terms
// it bans, but only via the fragment-assembly bootstrapping pattern).
//
// The returned slice is indexed positionally against BannedTerms(): the i-th
// synthetic string embeds the i-th banned term. Both meta-tests assert
// len(SyntheticBannedStrings()) == len(BannedTerms()) and that each string
// triggers FindBannedTerm with the matching term.
func SyntheticBannedStrings() []string {
terms := BannedTerms()
return []string{
"open a " + terms[0] + " here", // bank
"make a " + terms[1] + " now", // deposit
"compounding " + terms[2] + " rate", // interest
"the " + terms[3] + " is 5pct", // yield
"foreign " + terms[4] + " pair", // currency
"price in " + terms[5], // dollar
"price in " + terms[6], // euro
"freeze the " + terms[7], // account
"move to " + terms[8] + " now", // savings
"the " + terms[9] + " lost money", // depositor
}
}
// coverFragments holds the 4 Cover-specific banned terms (D-088, REQ-055
// lexicon scope) as (a, b) halves. Neither half alone is a banned term, and
// concatenation produces the banned term at runtime — the same fragment-
// assembly bootstrapping pattern as the project-wide fragments above so this
// package's source does not contain any banned term as a literal substring.
// These are the four terms the Cover module's vocabulary MUST NOT use: the
// safe vision names are "Cover", "Cover-Fee", "Cover Call", "Cover-Charter",
// "Cover Pool", "Cover Claims Voucher" (D-088); the four terms below are the
// banned synonyms enforced by lexicon_meta_cover.
var coverFragments = []term{
{"insur", "ance"}, // insurance
{"prem", "ium"}, // premium
{"cla", "im"}, // claim
{"pol", "icy"}, // policy
}
// CoverBannedTerms returns the 4 Cover-specific banned terms (D-088): the
// four terms the Cover module's vocabulary MUST NOT use. The terms are
// assembled at runtime from coverFragments so this package's source does not
// contain any banned term as a literal substring (the standard lexicon-test
// bootstrapping pattern). These are ADDITIVE to the project-wide
// BannedTerms() — the project-wide 10 terms also apply to x/cover; this list
// is the Cover-specific superset layer enforced by lexicon_meta_cover.
func CoverBannedTerms() []string {
out := make([]string, len(coverFragments))
for i, t := range coverFragments {
out[i] = t.a + t.b
}
return out
}
// coverBannedTermRegexes are the compiled word-boundary regexes for the 4
// Cover-specific banned terms. Word boundaries prevent false positives (a
// Cover-Call's "claimant" must NOT trip the banned "claim" — the regex bans
// the word as a concept, not as an arbitrary substring). The regexes are
// case-insensitive. Mirrors bannedTermRegexes for the Cover-specific list.
var coverBannedTermRegexes = func() []*regexp.Regexp {
terms := CoverBannedTerms()
out := make([]*regexp.Regexp, len(terms))
for i, t := range terms {
out[i] = regexp.MustCompile(`\b` + regexp.QuoteMeta(t) + `\b`)
}
return out
}()
// FindCoverBannedTerm returns the first Cover-specific banned term found in
// s (case-insensitive, word-boundary match) and true, or "" and false if
// none. Mirrors FindBannedTerm but uses the Cover-specific 4-term list
// (D-088). Used by the lexicon_meta_cover meta-test (the 4th lexicon meta-
// test) and the per-package lexicon assertion in x/cover/types/types_test.go.
// A Cover source file that contains a Cover-specific banned term triggers
// this helper; the project-wide FindBannedTerm is NOT consulted here (the
// two firewalls are layered: project-wide + Cover-specific).
func FindCoverBannedTerm(s string) (string, bool) {
lower := strings.ToLower(s)
terms := CoverBannedTerms()
for i, re := range coverBannedTermRegexes {
if re.MatchString(lower) {
return terms[i], true
}
}
return "", false
}
// SyntheticCoverBannedStrings returns one synthetic string per Cover-specific
// banned term, each embedding exactly one banned term in a plausible Cover-
// module sentence context. This is the single source of truth (G-014) for
// the synthetic self-test table consumed by lexicon_meta_cover ::
// TestLexiconMetaCoverSelfTestTable. Mirrors SyntheticBannedStrings for the
// 4-term Cover-specific list. The strings are built from CoverBannedTerms()
// (already fragment-assembled), so this package's own source stays lexicon-
// clean. The returned slice is indexed positionally against CoverBannedTerms():
// the i-th synthetic string embeds the i-th Cover-specific banned term.
func SyntheticCoverBannedStrings() []string {
terms := CoverBannedTerms()
return []string{
"buy " + terms[0] + " now", // insurance
"pay the " + terms[1] + " fee", // premium
"file a " + terms[2] + " today", // claim
"the " + terms[3] + " expires", // policy
}
}
@@ -0,0 +1,372 @@
// Package lexicon_meta_cover holds the Cover lexicon firewall (REQ-055,
// D-088) — the 4th lexicon meta-test.
//
// It is a NEW sibling meta-test created in v0.7 P1 that MIRRORS the v0.6
// web firewall (lexicon_meta_web/lexicon_meta_web_test.go, package
// lexicon_meta_web) but scans the Cover module surface (x/cover/**/*.go)
// for BOTH the 10 project-wide banned terms (lexicon.FindBannedTerm) AND
// the 4 Cover-specific banned terms (lexicon.FindCoverBannedTerm — D-088).
// It uses the SAME lexicon.FindBannedTerm + lexicon.FindCoverBannedTerm
// (word-boundary, case-insensitive) — NO detection reimplementation — so
// the four firewalls (x/*.go project-wide, docs, web, cover) share a
// single source of truth for the banned terms. The Cover-specific 4 terms
// (insurance, premium, claim, policy — assembled from fragments by
// lexicon.CoverBannedTerms) are the Cover-module superset layer: the
// project-wide 10 terms ALSO apply to x/cover; this firewall adds the 4
// Cover-specific terms on top.
//
// Placement: this file lives in lexicon_meta_cover/ (a subdirectory of the
// repo root) because Go does not permit two distinct packages in the same
// directory; the v0.2 firewall is package lexicon_meta at the repo root,
// the v0.3 firewall is package lexicon_meta_docs in lexicon_meta_docs/,
// and the v0.6 firewall is package lexicon_meta_web in lexicon_meta_web/.
// The invocation `go test ./lexicon_meta_cover/...` (PLANS v0.7 P1)
// resolves to this package. Run via `go test ./...` from the repo root.
//
// G-013 walk-coverage: TestLexiconMetaCoverWalkCoverage injects synthetic
// banned-term .go files into a temp x/cover/ subtree and asserts the walk
// FINDS them — one for a project-wide term, one for a Cover-specific term.
// This closes the "silently scans nothing and reports green" failure mode
// that the G-009 self-test table (detection) alone does not cover.
//
// G-014 self-test drift: the self-test tables reuse
// lexicon.SyntheticBannedStrings() (project-wide) +
// lexicon.SyntheticCoverBannedStrings() (Cover-specific) — the single
// sources of truth shared with the other three meta-tests.
//
// G-024: this test file stays stdlib + lexicon-only (no cosmos-sdk import).
package lexicon_meta_cover
import (
"os"
"path/filepath"
"runtime"
"strings"
"testing"
"github.com/oy/openyield/lexicon"
)
// repoRoot returns the absolute path to the repo root by walking up from
// this test file (the test lives at <repoRoot>/lexicon_meta_cover/).
func repoRoot(t *testing.T) string {
t.Helper()
_, file, _, ok := runtime.Caller(0)
if !ok {
t.Fatal("runtime.Caller failed")
}
// file = .../oy/lexicon_meta_cover/lexicon_meta_cover_test.go
// repo root = filepath.Dir(filepath.Dir(file))
return filepath.Dir(filepath.Dir(file))
}
// coverRoot returns the absolute path to the repo's x/cover directory.
func coverRoot(t *testing.T) string {
t.Helper()
return filepath.Join(repoRoot(t), "x", "cover")
}
// thisFile returns the absolute path of this meta-test file (to exclude it
// from its own scan — it references banned terms via the lexicon package,
// whose source assembles terms from fragments, so no banned-term literal
// appears in the firewall's own code).
func thisFile(t *testing.T) string {
t.Helper()
_, file, _, ok := runtime.Caller(0)
if !ok {
t.Fatal("runtime.Caller failed")
}
return file
}
// isCoverTarget reports whether path (relative to repo root) is a .go file
// under x/cover/ (production + test). Non-.go files under x/cover/ are
// skipped.
func isCoverTarget(rel string) bool {
prefix := strings.Join([]string{"x", "cover", ""}, string(filepath.Separator))
if !strings.HasPrefix(rel, prefix) {
return false
}
return strings.HasSuffix(rel, ".go")
}
// TestLexiconMetaCoverNoBannedTerms is the Cover firewall (D-088). It walks
// x/cover/**/*.go (production + test), reads each file's source, and
// asserts no banned term (project-wide OR Cover-specific) is present
// (word-boundary, case-insensitive). Excludes this test file itself
// (self-exclusion via runtime.Caller(0) — though this file lives outside
// x/cover/, the exclusion is belt-and-suspenders in case the walk root is
// ever broadened).
//
// Passes at P1 with the x/cover module lexicon-clean by construction. The
// x/cover/types/types_test.go per-package lexicon assertion
// (TestLexiconNoBannedTermsInCover) is the in-module firewall; this
// meta-test is the repo-wide Cover firewall (run via `go test ./...`).
func TestLexiconMetaCoverNoBannedTerms(t *testing.T) {
root := coverRoot(t)
this := thisFile(t)
hits := []string{}
err := filepath.Walk(root, func(path string, info os.FileInfo, err error) error {
if err != nil {
return err
}
if info.IsDir() {
// Skip the walk-coverage fixture dir (G-013):
// TestLexiconMetaCoverWalkCoverage creates
// x/cover/.lexicon_fixture/ with synthetic banned-term .go
// files. Those fixtures are test artifacts, NOT production
// code; skip the dir to avoid a self-trip if cleanup is
// delayed.
if info.Name() == ".lexicon_fixture" {
return filepath.SkipDir
}
return nil
}
if !strings.HasSuffix(path, ".go") {
return nil
}
// Self-exclusion: skip this meta-test file (belt-and-suspenders;
// this file lives outside x/cover/ so the walk would not reach it
// anyway, but the exclusion is robust to a future walk-root change).
if path == this {
return nil
}
bz, rerr := os.ReadFile(path)
if rerr != nil {
return rerr
}
src := string(bz)
// Project-wide 10 terms.
if found, ok := lexicon.FindBannedTerm(src); ok {
rel, _ := filepath.Rel(root, path)
hits = append(hits, rel+" contains project-wide banned term "+found)
}
// Cover-specific 4 terms.
if found, ok := lexicon.FindCoverBannedTerm(src); ok {
rel, _ := filepath.Rel(root, path)
hits = append(hits, rel+" contains Cover-specific banned term "+found)
}
return nil
})
if err != nil {
t.Fatalf("walk: %v", err)
}
if len(hits) > 0 {
t.Errorf("REQ-055/D-088 Cover lexicon firewall violations:\n %s",
strings.Join(hits, "\n "))
}
}
// TestLexiconMetaCoverSelfTestTable (G-009 for cover) is the firewall's own
// detection-coverage guard. Each synthetic string embeds exactly one
// banned term in a plausible sentence context and is asserted to trigger
// detection, so the firewall's detection logic is durably verified — if
// detection ever breaks, this test fails before the firewall silently
// passes a real violation in a Cover source file.
//
// This test exercises BOTH the project-wide terms (lexicon.SyntheticBannedStrings
// + lexicon.FindBannedTerm) AND the Cover-specific terms
// (lexicon.SyntheticCoverBannedStrings + lexicon.FindCoverBannedTerm),
// so both layers of the Cover firewall are durably verified.
func TestLexiconMetaCoverSelfTestTable(t *testing.T) {
// Project-wide layer.
terms := lexicon.BannedTerms()
if len(terms) != 10 {
t.Fatalf("BannedTerms() len = %d, want 10", len(terms))
}
synthetic := lexicon.SyntheticBannedStrings()
if len(synthetic) != len(terms) {
t.Fatalf("SyntheticBannedStrings() len = %d, want %d", len(synthetic), len(terms))
}
for i, s := range synthetic {
found, ok := lexicon.FindBannedTerm(s)
if !ok {
t.Errorf("G-009 cover self-test (project-wide) [%d]: synthetic string did not trigger detection: %q", i, s)
continue
}
if found != terms[i] {
t.Errorf("G-009 cover self-test (project-wide) [%d]: detected %q, want %q (in %q)", i, found, terms[i], s)
}
}
// Cover-specific layer.
coverTerms := lexicon.CoverBannedTerms()
if len(coverTerms) != 4 {
t.Fatalf("CoverBannedTerms() len = %d, want 4 (D-088)", len(coverTerms))
}
coverSynthetic := lexicon.SyntheticCoverBannedStrings()
if len(coverSynthetic) != len(coverTerms) {
t.Fatalf("SyntheticCoverBannedStrings() len = %d, want %d (must match CoverBannedTerms())", len(coverSynthetic), len(coverTerms))
}
for i, s := range coverSynthetic {
found, ok := lexicon.FindCoverBannedTerm(s)
if !ok {
t.Errorf("G-009 cover self-test (Cover-specific) [%d]: synthetic string did not trigger detection: %q", i, s)
continue
}
if found != coverTerms[i] {
t.Errorf("G-009 cover self-test (Cover-specific) [%d]: detected %q, want %q (in %q)", i, found, coverTerms[i], s)
}
}
}
// TestLexiconMetaCoverBannedTermsCount asserts exactly 10 project-wide
// banned terms + 4 Cover-specific banned terms are configured (locked-const
// for the firewall's scope). Derived from lexicon.BannedTerms() +
// lexicon.CoverBannedTerms() — the single sources — so a count change
// breaks the firewalls (G-014 drift prevention).
func TestLexiconMetaCoverBannedTermsCount(t *testing.T) {
terms := lexicon.BannedTerms()
if len(terms) != 10 {
t.Errorf("BannedTerms() len = %d, want 10 (REQ-012)", len(terms))
}
coverTerms := lexicon.CoverBannedTerms()
if len(coverTerms) != 4 {
t.Errorf("CoverBannedTerms() len = %d, want 4 (D-088)", len(coverTerms))
}
seen := map[string]bool{}
for _, tr := range terms {
if seen[tr] {
t.Errorf("duplicate project-wide banned term %q", tr)
}
seen[tr] = true
}
for _, tr := range coverTerms {
if seen[tr] {
t.Errorf("Cover-specific banned term %q duplicates a project-wide term", tr)
}
seen[tr] = true
}
}
// TestLexiconMetaCoverNoFalsePositiveOnClaimant asserts the field name
// "ClaimantReachID" (used by types.CoverCall) does NOT trigger the
// Cover-specific banned term that looks like a substring of "Claimant"
// (word-boundary matching must not match substrings of identifiers). This
// is the regression firewall for the word-boundary detection design on the
// Cover-specific layer — mirrors the project-wide
// TestLexiconMetaNoFalsePositiveOnOpenYield.
func TestLexiconMetaCoverNoFalsePositiveOnClaimant(t *testing.T) {
cases := []string{
"ClaimantReachID",
"ClaimantReachID string",
"the ClaimantReachID field",
"c.ClaimantReachID",
}
for _, s := range cases {
if _, ok := lexicon.FindCoverBannedTerm(s); ok {
t.Errorf("false positive: %q triggered a Cover-specific banned term (word-boundary must avoid this)", s)
}
}
}
// TestLexiconMetaCoverWalkCoverage (G-013) is the walk-coverage firewall
// for the Cover meta-test. The G-009 self-test table (above) verifies
// DETECTION (FindBannedTerm / FindCoverBannedTerm on synthetic strings)
// but NOT the WALK (which files are scanned). A walk bug — e.g. wrong path
// prefix, missing x/cover/ recursion — would silently scan nothing and
// report green on zero files. This test closes that gap by injecting
// synthetic banned-term .go files into a fixture dir under the real
// x/cover/ path the walk scans and asserting the walk FINDS them — one
// fixture for a project-wide term, one for a Cover-specific term.
//
// The fixtures are created under x/cover/.lexicon_fixture/ (a real x/cover/
// subtree the walk reaches) and removed via defer so they never leak into
// the repo. If the walk logic misses either fixture, this test fails loudly
// instead of letting a broken walk pass the firewall green on zero files
// scanned.
func TestLexiconMetaCoverWalkCoverage(t *testing.T) {
root := coverRoot(t)
// Build synthetic banned terms from fragments so THIS file does not
// contain banned-term literals.
terms := lexicon.BannedTerms()
if len(terms) == 0 {
t.Fatal("BannedTerms() returned no terms — cannot run walk-coverage")
}
coverTerms := lexicon.CoverBannedTerms()
if len(coverTerms) == 0 {
t.Fatal("CoverBannedTerms() returned no terms — cannot run walk-coverage")
}
// Project-wide fixture: use the first banned term ("bank") reassembled.
pwTerm := terms[0][:2] + terms[0][2:]
// Cover-specific fixture: use the first Cover term reassembled.
coverTerm := coverTerms[0][:len(coverTerms[0])/2] + coverTerms[0][len(coverTerms[0])/2:]
fixtureDir := filepath.Join(root, ".lexicon_fixture")
if err := os.MkdirAll(fixtureDir, 0o755); err != nil {
t.Fatalf("mkdir fixture: %v", err)
}
defer os.RemoveAll(fixtureDir)
// Project-wide fixture .go file.
pwFixture := filepath.Join(fixtureDir, "bad_pw_fixture.go")
pwContent := []byte("// fixture\n// this file contains a project-wide banned term: " + pwTerm + "\npackage lexicon_fixture\n")
if err := os.WriteFile(pwFixture, pwContent, 0o644); err != nil {
t.Fatalf("write pw fixture: %v", err)
}
// Cover-specific fixture .go file.
coverFixture := filepath.Join(fixtureDir, "bad_cover_fixture.go")
coverContent := []byte("// fixture\n// this file contains a Cover-specific banned term: " + coverTerm + "\npackage lexicon_fixture\n")
if err := os.WriteFile(coverFixture, coverContent, 0o644); err != nil {
t.Fatalf("write cover fixture: %v", err)
}
// Run the SAME walk logic as TestLexiconMetaCoverNoBannedTerms and
// assert it FINDS both fixtures' banned terms. A walk that returns zero
// hits here proves the walk logic is broken.
pwHits := []string{}
coverHits := []string{}
err := filepath.Walk(root, func(path string, info os.FileInfo, err error) error {
if err != nil {
return err
}
if info.IsDir() {
return nil
}
if !strings.HasSuffix(path, ".go") {
return nil
}
bz, rerr := os.ReadFile(path)
if rerr != nil {
return rerr
}
src := string(bz)
if found, ok := lexicon.FindBannedTerm(src); ok {
rel, _ := filepath.Rel(root, path)
pwHits = append(pwHits, rel+":"+found)
}
if found, ok := lexicon.FindCoverBannedTerm(src); ok {
rel, _ := filepath.Rel(root, path)
coverHits = append(coverHits, rel+":"+found)
}
return nil
})
if err != nil {
t.Fatalf("walk: %v", err)
}
// Assert the project-wide fixture was found.
foundPW := false
for _, h := range pwHits {
if strings.Contains(h, "bad_pw_fixture.go") && strings.Contains(h, pwTerm) {
foundPW = true
break
}
}
if !foundPW {
t.Errorf("G-013 walk-coverage (project-wide): the walk did NOT find the synthetic project-wide banned-term fixture at %s — the Cover firewall walk logic is broken (it would silently scan nothing and report green). pwHits=%v", pwFixture, pwHits)
}
// Assert the Cover-specific fixture was found.
foundCover := false
for _, h := range coverHits {
if strings.Contains(h, "bad_cover_fixture.go") && strings.Contains(h, coverTerm) {
foundCover = true
break
}
}
if !foundCover {
t.Errorf("G-013 walk-coverage (Cover-specific): the walk did NOT find the synthetic Cover-specific banned-term fixture at %s — the Cover firewall walk logic is broken. coverHits=%v", coverFixture, coverHits)
}
}
+296
View File
@@ -0,0 +1,296 @@
// Package lexicon_meta_docs holds the docs lexicon firewall (REQ-028, D-043).
//
// It is a NEW sibling meta-test created in v0.3 P1 Wave 1 that MIRRORS the v0.2
// project-wide firewall (lexicon_meta_test.go, package lexicon_meta) but scans
// the docs surface (README.md + docs/**/*.md) instead of x/**/*.go. It uses
// the SAME lexicon.FindBannedTerm (word-boundary, case-insensitive) — NO
// detection reimplementation — so the two firewalls share a single source of
// truth for the 10 banned terms (bank, deposit, interest, yield, currency,
// dollar, euro, account, savings, depositor).
//
// Placement: this file lives in lexicon_meta_docs/ (a subdirectory of the
// repo root) because Go does not permit two distinct packages in the same
// directory; the v0.2 firewall is package lexicon_meta at the repo root.
// The invocation `go test ./lexicon_meta_docs/...` (PLANS P1-03-01) resolves
// to this package. Run via `go test ./...` from the repo root as well.
//
// G-013 walk-coverage: TestLexiconMetaDocsWalkCoverage injects a synthetic
// banned-term .md into a temp docs/ subtree and asserts the walk FINDS it.
// This closes the "silently scans nothing and reports green" failure mode
// that the G-009 self-test table (detection) alone does not cover.
//
// G-014 self-test drift: the self-test table and banned-term count assertion
// reuse lexicon.BannedTerms() (the single source). A cross-reference comment
// keeps this file's table in lockstep with lexicon_meta_test.go's table; if
// a banned term is added, both firewalls update from one place.
package lexicon_meta_docs
import (
"os"
"path/filepath"
"runtime"
"strings"
"testing"
"github.com/oy/openyield/lexicon"
)
// repoRoot returns the absolute path to the repo root by walking up from
// this test file (the test lives at <repoRoot>/lexicon_meta_docs/).
func repoRoot(t *testing.T) string {
t.Helper()
_, file, _, ok := runtime.Caller(0)
if !ok {
t.Fatal("runtime.Caller failed")
}
// file = .../oy/lexicon_meta_docs/lexicon_meta_docs_test.go
// repo root = filepath.Dir(filepath.Dir(file))
return filepath.Dir(filepath.Dir(file))
}
// thisFile returns the absolute path of this meta-test file (to exclude it
// from its own scan — it references banned terms via the lexicon package,
// whose source assembles terms from fragments, so no banned-term literal
// appears in the firewall's own code).
func thisFile(t *testing.T) string {
t.Helper()
_, file, _, ok := runtime.Caller(0)
if !ok {
t.Fatal("runtime.Caller failed")
}
return file
}
// TestLexiconMetaDocsNoBannedTermsInDocs is the docs firewall (D-043). It
// walks README.md (repo root) + every *.md under docs/ (recursive), reads each
// file's source, and asserts no banned term is present (word-boundary,
// case-insensitive). Excludes .ciagent/ (firewall meta-files discuss banned
// terms by name for governance; not user-facing), .git/ (VCS), and this test
// file itself (self-exclusion via runtime.Caller(0)).
//
// Passes at P1 Wave 1 with zero docs (a walk that scans nothing reports green
// on zero hits — closed by TestLexiconMetaDocsWalkCoverage below). With the
// Wave 2 docs present (README + index + 6 shared pages), all are lexicon-clean
// by construction.
func TestLexiconMetaDocsNoBannedTermsInDocs(t *testing.T) {
root := repoRoot(t)
this := thisFile(t)
hits := []string{}
err := filepath.Walk(root, func(path string, info os.FileInfo, err error) error {
if err != nil {
return err
}
if info.IsDir() {
base := filepath.Base(path)
if base == ".ciagent" || base == ".git" {
return filepath.SkipDir
}
return nil
}
// Self-exclusion: skip this meta-test file.
if path == this {
return nil
}
// Only scan .md files.
if !strings.HasSuffix(path, ".md") {
return nil
}
// Only scan README.md (repo root) + docs/**/*.md.
rel, rerr := filepath.Rel(root, path)
if rerr != nil {
return rerr
}
if rel != "README.md" && !strings.HasPrefix(rel, "docs"+string(filepath.Separator)) && rel != "docs" {
return nil
}
bz, rerr := os.ReadFile(path)
if rerr != nil {
return rerr
}
if found, ok := lexicon.FindBannedTerm(string(bz)); ok {
hits = append(hits, rel+" contains banned term "+found)
}
return nil
})
if err != nil {
t.Fatalf("walk: %v", err)
}
if len(hits) > 0 {
t.Errorf("REQ-028 docs lexicon firewall violations:\n %s",
strings.Join(hits, "\n "))
}
}
// TestLexiconMetaDocsSelfTestTable (G-009 for docs) is the firewall's own
// detection-coverage guard. Each synthetic string embeds exactly one banned
// term in a plausible sentence context and is asserted to trigger detection,
// so the firewall's detection logic is durably verified — if detection ever
// breaks, this test fails before the firewall silently passes a real
// violation in a docs page.
//
// REQ-029 (GRILL G-014): the synthetic strings are sourced from
// lexicon.SyntheticBannedStrings(), the single source of truth shared with
// lexicon_meta_test.go :: TestLexiconMetaSelfTestTable. Before REQ-029, this
// file DUPLICATED its own 10-string table (byte-identical to the x/ meta-
// test), creating a drift risk; the shared helper closes it. This file no
// longer builds its own synthetic table — both meta-tests consume the same
// helper, so a future banned-term addition updates both firewalls from one
// place.
func TestLexiconMetaDocsSelfTestTable(t *testing.T) {
terms := lexicon.BannedTerms()
// The spec lists 10 banned terms (plan docs say "9", counting dollar/euro
// as a pair): bank, deposit, interest, yield, currency, dollar, euro,
// account, savings, depositor.
if len(terms) != 10 {
t.Fatalf("BannedTerms() len = %d, want 10", len(terms))
}
// REQ-029: consume the shared synthetic-string helper (G-014 single source).
synthetic := lexicon.SyntheticBannedStrings()
if len(synthetic) != len(terms) {
t.Fatalf("SyntheticBannedStrings() len = %d, want %d (must match BannedTerms())", len(synthetic), len(terms))
}
for i, s := range synthetic {
found, ok := lexicon.FindBannedTerm(s)
if !ok {
t.Errorf("G-009 docs self-test [%d]: synthetic string did not trigger detection: %q", i, s)
continue
}
if found != terms[i] {
t.Errorf("G-009 docs self-test [%d]: detected %q, want %q (in %q)", i, found, terms[i], s)
}
}
}
// TestLexiconMetaDocsBannedTermsCount asserts exactly 10 banned terms are
// configured (locked-const for the firewall's scope; spec lists 10, plan docs
// say "9" counting dollar/euro as a pair). Derived from lexicon.BannedTerms()
// — the single source — so a count change breaks both this firewall and the
// v0.2 x/*.go firewall (G-014 drift prevention).
func TestLexiconMetaDocsBannedTermsCount(t *testing.T) {
terms := lexicon.BannedTerms()
if len(terms) != 10 {
t.Errorf("BannedTerms() len = %d, want 10 (REQ-012/REQ-028)", len(terms))
}
seen := map[string]bool{}
for _, tr := range terms {
if seen[tr] {
t.Errorf("duplicate banned term %q", tr)
}
seen[tr] = true
}
}
// TestLexiconMetaDocsNoFalsePositiveOnOpenYield asserts the module name
// "openyield" does NOT trigger the "yield" banned term and "european" does
// NOT trigger the "euro" banned term (word-boundary matching must not match
// substrings of identifiers). This is the regression firewall for the
// word-boundary detection design — mirrors the v0.2
// TestLexiconMetaNoFalsePositiveOnOpenYield.
func TestLexiconMetaDocsNoFalsePositiveOnOpenYield(t *testing.T) {
cases := []string{
"github.com/oy/openyield/x/window/types",
"package openyield",
"openyield is the module",
"european resident",
"# OpenYield docs",
"the OpenYield mesh",
}
for _, s := range cases {
if _, ok := lexicon.FindBannedTerm(s); ok {
t.Errorf("false positive: %q triggered a banned term (word-boundary must avoid this)", s)
}
}
}
// TestLexiconMetaDocsWalkCoverage (G-013) is the walk-coverage firewall. The
// G-009 self-test table (above) verifies DETECTION (FindBannedTerm on
// synthetic strings) but NOT the WALK (which files are scanned). A walk bug
// — e.g. wrong path prefix, missing docs/ recursion, a typo in the .md
// suffix check — would silently scan nothing and report green on zero
// files. This test closes that gap by injecting a synthetic banned-term .md
// into a fixture dir under the real docs/ path the walk scans and asserting
// the walk FINDS it.
//
// The fixture is created under docs/.lexicon_fixture/ (a real docs/ subtree
// the walk reaches) and removed via defer so it never leaks into the repo.
// If the walk logic misses the fixture, this test fails loudly instead of
// letting a broken walk pass the firewall green on zero files scanned.
func TestLexiconMetaDocsWalkCoverage(t *testing.T) {
root := repoRoot(t)
this := thisFile(t)
// Build a synthetic banned term from fragments so THIS file does not
// contain a banned-term literal (it is excluded from its own scan, but
// the synthetic stays clean for readability/searchability).
terms := lexicon.BannedTerms()
if len(terms) == 0 {
t.Fatal("BannedTerms() returned no terms — cannot run walk-coverage")
}
// Use the first banned term ("bank") assembled from two halves.
syntheticTerm := terms[0][:2] + terms[0][2:] // reassemble (no literal in source)
badContent := []byte("# fixture\nthis file contains a banned term: " + syntheticTerm + "\n")
fixtureDir := filepath.Join(root, "docs", ".lexicon_fixture")
fixtureFile := filepath.Join(fixtureDir, "bad_fixture.md")
if err := os.MkdirAll(fixtureDir, 0o755); err != nil {
t.Fatalf("mkdir fixture: %v", err)
}
defer os.RemoveAll(fixtureDir)
if err := os.WriteFile(fixtureFile, badContent, 0o644); err != nil {
t.Fatalf("write fixture: %v", err)
}
// Run the SAME walk logic as TestLexiconMetaDocsNoBannedTermsInDocs and
// assert it FINDS the fixture's banned term. A walk that returns zero
// hits here proves the walk logic is broken (the fixture is a known-bad
// file inside docs/ that MUST be detected).
hits := []string{}
err := filepath.Walk(root, func(path string, info os.FileInfo, err error) error {
if err != nil {
return err
}
if info.IsDir() {
base := filepath.Base(path)
if base == ".ciagent" || base == ".git" {
return filepath.SkipDir
}
return nil
}
if path == this {
return nil
}
if !strings.HasSuffix(path, ".md") {
return nil
}
rel, rerr := filepath.Rel(root, path)
if rerr != nil {
return rerr
}
if rel != "README.md" && !strings.HasPrefix(rel, "docs"+string(filepath.Separator)) {
return nil
}
bz, rerr := os.ReadFile(path)
if rerr != nil {
return rerr
}
if found, ok := lexicon.FindBannedTerm(string(bz)); ok {
hits = append(hits, rel+" contains banned term "+found)
}
return nil
})
if err != nil {
t.Fatalf("walk: %v", err)
}
// Assert the fixture was found. The rel path uses OS-specific separator;
// match on the suffix so the test is portable.
foundFixture := false
for _, h := range hits {
if strings.Contains(h, "bad_fixture.md") && strings.Contains(h, syntheticTerm) {
foundFixture = true
break
}
}
if !foundFixture {
t.Errorf("G-013 walk-coverage: the walk did NOT find the synthetic banned-term fixture at %s — the docs firewall walk logic is broken (it would silently scan nothing and report green). hits=%v", fixtureFile, hits)
}
}
+19 -19
View File
@@ -43,6 +43,16 @@ func TestLexiconMetaNoBannedTermsInX(t *testing.T) {
return err
}
if info.IsDir() {
// Skip the lexicon_meta_cover walk-coverage fixture dir
// (G-013): TestLexiconMetaCoverWalkCoverage creates
// x/cover/.lexicon_fixture/ with synthetic banned-term .go
// files to verify the Cover firewall's walk logic. Those
// fixtures are test artifacts, NOT production code, and would
// trip this project-wide firewall if scanned concurrently.
// Skip the fixture dir to avoid the test-isolation race.
if info.Name() == ".lexicon_fixture" {
return filepath.SkipDir
}
return nil
}
if !strings.HasSuffix(path, ".go") {
@@ -76,10 +86,12 @@ func TestLexiconMetaNoBannedTermsInX(t *testing.T) {
// firewall's detection logic is durably verified — if detection ever breaks,
// this test fails before the firewall silently passes a real violation.
//
// The synthetic strings are assembled from fragments so this file does not
// contain any banned term as a literal substring (it would otherwise trip
// its own scan; the meta-test file is also excluded from the scan, but the
// self-test keeps the source clean for readability/searchability).
// REQ-029 (GRILL G-014): the synthetic strings are sourced from
// lexicon.SyntheticBannedStrings(), the single source of truth shared with
// lexicon_meta_docs_test.go :: TestLexiconMetaDocsSelfTestTable. Before
// REQ-029, both meta-tests DUPLICATED their own 10-string table, creating a
// drift risk; the shared helper closes it. This file no longer builds its
// own synthetic table.
func TestLexiconMetaSelfTestTable(t *testing.T) {
terms := lexicon.BannedTerms()
// The spec lists 10 banned terms (plan docs say "9", counting dollar/euro
@@ -88,22 +100,10 @@ func TestLexiconMetaSelfTestTable(t *testing.T) {
if len(terms) != 10 {
t.Fatalf("BannedTerms() len = %d, want 10", len(terms))
}
// Each synthetic string embeds exactly one banned term in a plausible
// sentence context. Each must be detected.
synthetic := []string{
"open a " + terms[0] + " here", // bank
"make a " + terms[1] + " now", // deposit
"compounding " + terms[2] + " rate", // interest
"the " + terms[3] + " is 5pct", // yield
"foreign " + terms[4] + " pair", // currency
"price in " + terms[5], // dollar
"price in " + terms[6], // euro
"freeze the " + terms[7], // account
"move to " + terms[8] + " now", // savings
"the " + terms[9] + " lost money", // depositor
}
// REQ-029: consume the shared synthetic-string helper (G-014 single source).
synthetic := lexicon.SyntheticBannedStrings()
if len(synthetic) != len(terms) {
t.Fatalf("synthetic table len = %d, want %d", len(synthetic), len(terms))
t.Fatalf("SyntheticBannedStrings() len = %d, want %d (must match BannedTerms())", len(synthetic), len(terms))
}
for i, s := range synthetic {
found, ok := lexicon.FindBannedTerm(s)
+307
View File
@@ -0,0 +1,307 @@
// Package lexicon_meta_web holds the web lexicon firewall (REQ-045, D-069).
//
// It is a NEW sibling meta-test created in v0.6 P1 Wave 1 that MIRRORS the
// v0.3 docs firewall (lexicon_meta_docs/lexicon_meta_docs_test.go, package
// lexicon_meta_docs) but scans the web surface (web/templates/**/*.html +
// web/static/**/*.js + web/**/*.go) instead of README.md + docs/**/*.md. It
// uses the SAME lexicon.FindBannedTerm (word-boundary, case-insensitive) —
// NO detection reimplementation — so the three firewalls (x/*.go, docs, web)
// share a single source of truth for the 10 banned terms (bank, deposit,
// interest, yield, currency, dollar, euro, account, savings, depositor).
//
// Placement: this file lives in lexicon_meta_web/ (a subdirectory of the
// repo root) because Go does not permit two distinct packages in the same
// directory; the v0.2 firewall is package lexicon_meta at the repo root and
// the v0.3 firewall is package lexicon_meta_docs in lexicon_meta_docs/. The
// invocation `go test ./lexicon_meta_web/...` (PLANS P1-01-01) resolves to
// this package. Run via `go test ./...` from the repo root as well.
//
// G-013 walk-coverage: TestLexiconMetaWebWalkCoverage injects a synthetic
// banned-term .html into a temp web/templates/ subtree and asserts the walk
// FINDS it. This closes the "silently scans nothing and reports green"
// failure mode that the G-009 self-test table (detection) alone does not
// cover.
//
// G-014 self-test drift: the self-test table and banned-term count assertion
// reuse lexicon.BannedTerms() (the single source). A cross-reference comment
// keeps this file's table in lockstep with lexicon_meta_test.go's table and
// lexicon_meta_docs_test.go's table; if a banned term is added, all three
// firewalls update from one place.
package lexicon_meta_web
import (
"os"
"path/filepath"
"runtime"
"strings"
"testing"
"github.com/oy/openyield/lexicon"
)
// repoRoot returns the absolute path to the repo root by walking up from
// this test file (the test lives at <repoRoot>/lexicon_meta_web/).
func repoRoot(t *testing.T) string {
t.Helper()
_, file, _, ok := runtime.Caller(0)
if !ok {
t.Fatal("runtime.Caller failed")
}
// file = .../oy/lexicon_meta_web/lexicon_meta_web_test.go
// repo root = filepath.Dir(filepath.Dir(file))
return filepath.Dir(filepath.Dir(file))
}
// thisFile returns the absolute path of this meta-test file (to exclude it
// from its own scan — it references banned terms via the lexicon package,
// whose source assembles terms from fragments, so no banned-term literal
// appears in the firewall's own code).
func thisFile(t *testing.T) string {
t.Helper()
_, file, _, ok := runtime.Caller(0)
if !ok {
t.Fatal("runtime.Caller failed")
}
return file
}
// isWebTarget reports whether path (relative to repo root) is a file the web
// firewall scans: web/templates/**/*.html, web/static/**/*.js, and
// web/**/*.go (production + test). Non-{html,js,go} files under web/ (e.g.
// vendored binary assets) are skipped.
func isWebTarget(rel string) bool {
if !strings.HasPrefix(rel, "web"+string(filepath.Separator)) {
return false
}
return strings.HasSuffix(rel, ".html") || strings.HasSuffix(rel, ".js") || strings.HasSuffix(rel, ".go")
}
// TestLexiconMetaWebNoBannedTermsInWeb is the web firewall (D-069). It walks
// the repo root, targets web/templates/**/*.html + web/static/**/*.js +
// web/**/*.go (production + test), reads each file's source, and asserts no
// banned term is present (word-boundary, case-insensitive). Excludes
// .ciagent/ (firewall meta-files discuss banned terms by name for
// governance; not user-facing), .git/ (VCS), and this test file itself
// (self-exclusion via runtime.Caller(0)).
//
// Passes at P1 Wave 1 with zero web content (a walk that scans nothing
// reports green on zero hits — closed by TestLexiconMetaWebWalkCoverage
// below). With the Wave 2..4 web content present (templates, static assets,
// handlers, store), all are lexicon-clean by construction.
func TestLexiconMetaWebNoBannedTermsInWeb(t *testing.T) {
root := repoRoot(t)
this := thisFile(t)
hits := []string{}
err := filepath.Walk(root, func(path string, info os.FileInfo, err error) error {
if err != nil {
return err
}
if info.IsDir() {
base := filepath.Base(path)
if base == ".ciagent" || base == ".git" {
return filepath.SkipDir
}
return nil
}
// Self-exclusion: skip this meta-test file.
if path == this {
return nil
}
rel, rerr := filepath.Rel(root, path)
if rerr != nil {
return rerr
}
if !isWebTarget(rel) {
return nil
}
bz, rerr := os.ReadFile(path)
if rerr != nil {
return rerr
}
if found, ok := lexicon.FindBannedTerm(string(bz)); ok {
hits = append(hits, rel+" contains banned term "+found)
}
return nil
})
if err != nil {
t.Fatalf("walk: %v", err)
}
if len(hits) > 0 {
t.Errorf("REQ-045 web lexicon firewall violations:\n %s",
strings.Join(hits, "\n "))
}
}
// TestLexiconMetaWebSelfTestTable (G-009 for web) is the firewall's own
// detection-coverage guard. Each synthetic string embeds exactly one banned
// term in a plausible sentence context and is asserted to trigger detection,
// so the firewall's detection logic is durably verified — if detection ever
// breaks, this test fails before the firewall silently passes a real
// violation in a web template or handler.
//
// REQ-029 (GRILL G-014): the synthetic strings are sourced from
// lexicon.SyntheticBannedStrings(), the single source of truth shared with
// lexicon_meta_test.go :: TestLexiconMetaSelfTestTable and
// lexicon_meta_docs_test.go :: TestLexiconMetaDocsSelfTestTable. Before
// REQ-029, each meta-test DUPLICATED its own 10-string table (byte-identical),
// creating a drift risk; the shared helper closes it. This file no longer
// builds its own synthetic table — all three meta-tests consume the same
// helper, so a future banned-term addition updates all firewalls from one
// place.
func TestLexiconMetaWebSelfTestTable(t *testing.T) {
terms := lexicon.BannedTerms()
// The spec lists 10 banned terms (plan docs say "9", counting dollar/euro
// as a pair): bank, deposit, interest, yield, currency, dollar, euro,
// account, savings, depositor.
if len(terms) != 10 {
t.Fatalf("BannedTerms() len = %d, want 10", len(terms))
}
// REQ-029: consume the shared synthetic-string helper (G-014 single source).
synthetic := lexicon.SyntheticBannedStrings()
if len(synthetic) != len(terms) {
t.Fatalf("SyntheticBannedStrings() len = %d, want %d (must match BannedTerms())", len(synthetic), len(terms))
}
for i, s := range synthetic {
found, ok := lexicon.FindBannedTerm(s)
if !ok {
t.Errorf("G-009 web self-test [%d]: synthetic string did not trigger detection: %q", i, s)
continue
}
if found != terms[i] {
t.Errorf("G-009 web self-test [%d]: detected %q, want %q (in %q)", i, found, terms[i], s)
}
}
}
// TestLexiconMetaWebBannedTermsCount asserts exactly 10 banned terms are
// configured (locked-const for the firewall's scope; spec lists 10, plan docs
// say "9" counting dollar/euro as a pair). Derived from lexicon.BannedTerms()
// — the single source — so a count change breaks all three firewalls (x/*.go,
// docs, web) (G-014 drift prevention).
func TestLexiconMetaWebBannedTermsCount(t *testing.T) {
terms := lexicon.BannedTerms()
if len(terms) != 10 {
t.Errorf("BannedTerms() len = %d, want 10 (REQ-012/REQ-045)", len(terms))
}
seen := map[string]bool{}
for _, tr := range terms {
if seen[tr] {
t.Errorf("duplicate banned term %q", tr)
}
seen[tr] = true
}
}
// TestLexiconMetaWebNoFalsePositiveOnOpenYield asserts the module name
// "openyield" does NOT trigger the "yield" banned term and "european" does
// NOT trigger the "euro" banned term (word-boundary matching must not match
// substrings of identifiers). This is the regression firewall for the
// word-boundary detection design — mirrors the v0.2
// TestLexiconMetaNoFalsePositiveOnOpenYield and the v0.3
// TestLexiconMetaDocsNoFalsePositiveOnOpenYield.
func TestLexiconMetaWebNoFalsePositiveOnOpenYield(t *testing.T) {
cases := []string{
"github.com/oy/openyield/x/window/types",
"package openyield",
"openyield is the module",
"european resident",
"# OpenYield web",
"the OpenYield mesh",
}
for _, s := range cases {
if _, ok := lexicon.FindBannedTerm(s); ok {
t.Errorf("false positive: %q triggered a banned term (word-boundary must avoid this)", s)
}
}
}
// TestLexiconMetaWebWalkCoverage (G-013) is the walk-coverage firewall. The
// G-009 self-test table (above) verifies DETECTION (FindBannedTerm on
// synthetic strings) but NOT the WALK (which files are scanned). A walk bug
// — e.g. wrong path prefix, missing web/ recursion, a typo in the .html
// suffix check — would silently scan nothing and report green on zero
// files. This test closes that gap by injecting a synthetic banned-term
// .html into a fixture dir under the real web/templates/ path the walk scans
// and asserting the walk FINDS it.
//
// The fixture is created under web/templates/.lexicon_fixture/ (a real
// web/templates/ subtree the walk reaches) and removed via defer so it never
// leaks into the repo. If the walk logic misses the fixture, this test fails
// loudly instead of letting a broken walk pass the firewall green on zero
// files scanned.
func TestLexiconMetaWebWalkCoverage(t *testing.T) {
root := repoRoot(t)
this := thisFile(t)
// Build a synthetic banned term from fragments so THIS file does not
// contain a banned-term literal (it is excluded from its own scan, but
// the synthetic stays clean for readability/searchability).
terms := lexicon.BannedTerms()
if len(terms) == 0 {
t.Fatal("BannedTerms() returned no terms — cannot run walk-coverage")
}
// Use the first banned term ("bank") assembled from two halves.
syntheticTerm := terms[0][:2] + terms[0][2:] // reassemble (no literal in source)
badContent := []byte("<!-- fixture -->\nthis file contains a banned term: " + syntheticTerm + "\n")
fixtureDir := filepath.Join(root, "web", "templates", ".lexicon_fixture")
fixtureFile := filepath.Join(fixtureDir, "bad_fixture.html")
if err := os.MkdirAll(fixtureDir, 0o755); err != nil {
t.Fatalf("mkdir fixture: %v", err)
}
defer os.RemoveAll(fixtureDir)
if err := os.WriteFile(fixtureFile, badContent, 0o644); err != nil {
t.Fatalf("write fixture: %v", err)
}
// Run the SAME walk logic as TestLexiconMetaWebNoBannedTermsInWeb and
// assert it FINDS the fixture's banned term. A walk that returns zero
// hits here proves the walk logic is broken (the fixture is a known-bad
// file inside web/templates/ that MUST be detected).
hits := []string{}
err := filepath.Walk(root, func(path string, info os.FileInfo, err error) error {
if err != nil {
return err
}
if info.IsDir() {
base := filepath.Base(path)
if base == ".ciagent" || base == ".git" {
return filepath.SkipDir
}
return nil
}
if path == this {
return nil
}
rel, rerr := filepath.Rel(root, path)
if rerr != nil {
return rerr
}
if !isWebTarget(rel) {
return nil
}
bz, rerr := os.ReadFile(path)
if rerr != nil {
return rerr
}
if found, ok := lexicon.FindBannedTerm(string(bz)); ok {
hits = append(hits, rel+" contains banned term "+found)
}
return nil
})
if err != nil {
t.Fatalf("walk: %v", err)
}
// Assert the fixture was found. The rel path uses OS-specific separator;
// match on the suffix so the test is portable.
foundFixture := false
for _, h := range hits {
if strings.Contains(h, "bad_fixture.html") && strings.Contains(h, syntheticTerm) {
foundFixture = true
break
}
}
if !foundFixture {
t.Errorf("G-013 walk-coverage: the walk did NOT find the synthetic banned-term fixture at %s — the web firewall walk logic is broken (it would silently scan nothing and report green). hits=%v", fixtureFile, hits)
}
}
+63
View File
@@ -0,0 +1,63 @@
# OpenYield docs site (MkDocs Material, D-042).
#
# Build-only Python dep (mkdocs + mkdocs-material); NOT a Go dep (G-006 —
# go.mod stays zero-require). Invoke locally with `mkdocs serve` or
# `mkdocs build` (see README). No publishing CI in v0.3 (D-046 — publishing
# to GitHub/Gitea Pages deferred to v0.4).
#
# Nav completeness (G-011): the nav lists ALL 26 pages that will exist by end
# of P3. P1 creates the shared/ pages + index (8 files); P2 adds nomads/
# (8 files); P3 adds freeholders/ (8 files) + reference/ (2 files). Only the
# files that exist at P1 ship today; the nav references the not-yet-created
# P2/P3 pages by path so the structure is complete and P2/P3 just add files.
# mkdocs.yml is a config file, NOT validated by Go tests; the docs firewall
# (lexicon_meta_docs_test.go) validates .md content, not nav.
site_name: OpenYield
site_description: OpenYield — a jurisdiction-light, public-good mesh for real production, organized around Holders, Stands, and the Six Principles.
theme:
name: material
features:
- navigation.sections
- navigation.expand
- toc.integrate
markdown_extensions:
- admonition
- toc:
permalink: true
- codehilite
- pymdownx.superfences
nav:
- Home: index.md
- Nomads:
- Overview: nomads/index.md
- Reach: nomads/reach.md
- Stash: nomads/stash.md
- Bearers: nomads/bearers.md
- Maps-Pay: nomads/maps-pay.md
- Pacts: nomads/pacts.md
- Standing: nomads/standing.md
- Window: nomads/window.md
- Freeholders:
- Overview: freeholders/index.md
- Signals: freeholders/signals.md
- Standing: freeholders/standing.md
- Stands & Guilds: freeholders/stands-guilds.md
- Councils & Voice: freeholders/councils-voice.md
- Bonds: freeholders/bonds.md
- Partner Spectrum: freeholders/partner-spectrum.md
- Anchor Preview: freeholders/anchor-preview.md
- Shared:
- Overview: shared/index.md
- Six Principles: shared/six-principles.md
- Bread Scale: shared/bread-scale.md
- Storage Pools: shared/storage-pools.md
- Watchers & Mirror: shared/watchers-mirror.md
- Lexicon: shared/lexicon.md
- Vision: shared/vision.md
- Reference:
- Architecture: reference/architecture.md
- Components: reference/components.md
+50
View File
@@ -0,0 +1,50 @@
package handlers
import (
"net/http"
bloomtypes "github.com/oy/openyield/x/bloom/types"
)
// registerBloom wires the Bloom accrual route (REQ-044).
func (s *Server) registerBloom(mux *http.ServeMux) {
mux.HandleFunc("GET /bloom/{stashID}", s.handleBloom)
}
// bloomViewData is the template data for the Bloom accrual view.
type bloomViewData struct {
StashID string
Found bool
Record bloomtypes.BloomRecord
RatePct float64 // RateBasisPoints as a percentage (450 -> 4.5)
TargetRatePct float64 // TargetBloomRateBasisPoints as %
MinRatePct float64
MaxRatePct float64
AccrualPeriod int64
MissionLockNote string
}
// handleBloom renders the Bloom accrual view (REQ-044): per-Stash BloomRecord
// (AccruedGrain, RateBasisPoints as %, LastAccrualBlock) + the 4.5% target rate
// (read from x/bloom/types.TargetBloomRateBasisPoints — D-073 code-constant
// source-of-truth, NOT hardcoded). Bloom is conceptually close to a banned
// financial term; labels use "Bloom"/"real production"/"accrual" only.
func (s *Server) handleBloom(w http.ResponseWriter, r *http.Request) {
stashID := r.PathValue("stashID")
rec, ok := s.Store.GetBloomRecord(stashID)
if !ok {
http.NotFound(w, r)
return
}
s.render(w, "bloom.html", bloomViewData{
StashID: stashID,
Found: true,
Record: rec,
RatePct: float64(rec.RateBasisPoints) / 100,
TargetRatePct: float64(bloomtypes.TargetBloomRateBasisPoints) / 100,
MinRatePct: float64(bloomtypes.MinBloomRateBasisPoints) / 100,
MaxRatePct: float64(bloomtypes.MaxBloomRateBasisPoints) / 100,
AccrualPeriod: bloomtypes.AccrualPeriodBlocks,
MissionLockNote: bloomtypes.MissionLockBloom,
})
}
+137
View File
@@ -0,0 +1,137 @@
package handlers
import (
"net/http"
"net/http/httptest"
"strings"
"testing"
bloomtypes "github.com/oy/openyield/x/bloom/types"
)
func TestBloomSeededRecordRendersTargetRate(t *testing.T) {
srv := newTestServer(t)
mux := http.NewServeMux()
srv.Register(mux)
rec := httptest.NewRecorder()
req := httptest.NewRequest("GET", "/bloom/stash-holder-alia", nil)
mux.ServeHTTP(rec, req)
if rec.Code != http.StatusOK {
t.Fatalf("GET /bloom/stash-holder-alia: status %d, want 200", rec.Code)
}
body := rec.Body.String()
// Accrued Grain present.
if !strings.Contains(body, "Grain") {
t.Errorf("body missing 'Grain'")
}
// Target rate 4.5% (from TargetBloomRateBasisPoints=450).
want := formatFloat(float64(bloomtypes.TargetBloomRateBasisPoints) / 100)
if !strings.Contains(body, want) {
t.Errorf("body missing target rate %s%% (TargetBloomRateBasisPoints=%d)", want, bloomtypes.TargetBloomRateBasisPoints)
}
// Mission Lock note present.
if !strings.Contains(body, "real production") {
t.Errorf("body missing Mission Lock note about real production")
}
assertNoBannedTerms(t, body)
}
func TestBloomMissingReturns404(t *testing.T) {
srv := newTestServer(t)
mux := http.NewServeMux()
srv.Register(mux)
rec := httptest.NewRecorder()
req := httptest.NewRequest("GET", "/bloom/stash-nobody", nil)
mux.ServeHTTP(rec, req)
if rec.Code != http.StatusNotFound {
t.Fatalf("GET /bloom/stash-nobody: status %d, want 404", rec.Code)
}
// G-026: rendered-HTML lexicon check on the ERROR response body too.
assertNoBannedTerms(t, rec.Body.String())
}
// TestBloomTargetRateFromCodeConstant (D-073 regression guard): asserts the
// handler reads x/bloom/types.TargetBloomRateBasisPoints=450 (NOT a hardcoded
// 450 or a docs value). This test would FAIL if the handler hardcoded the rate
// instead of reading the code constant.
func TestBloomTargetRateFromCodeConstant(t *testing.T) {
// D-073: the code constant is the source of truth.
if bloomtypes.TargetBloomRateBasisPoints != 450 {
t.Fatalf("D-073: TargetBloomRateBasisPoints = %d, want 450 (code constant)", bloomtypes.TargetBloomRateBasisPoints)
}
if bloomtypes.MinBloomRateBasisPoints != 400 {
t.Fatalf("D-073: MinBloomRateBasisPoints = %d, want 400 (code constant)", bloomtypes.MinBloomRateBasisPoints)
}
if bloomtypes.MaxBloomRateBasisPoints != 500 {
t.Fatalf("D-073: MaxBloomRateBasisPoints = %d, want 500 (code constant)", bloomtypes.MaxBloomRateBasisPoints)
}
srv := newTestServer(t)
mux := http.NewServeMux()
srv.Register(mux)
rec := httptest.NewRecorder()
req := httptest.NewRequest("GET", "/bloom/stash-holder-alia", nil)
mux.ServeHTTP(rec, req)
body := rec.Body.String()
// The rendered target rate must be the code constant / 100 = 4.5.
wantTarget := formatFloat(float64(bloomtypes.TargetBloomRateBasisPoints) / 100)
if !strings.Contains(body, wantTarget) {
t.Errorf("D-073: body missing target rate %s%% (from code constant %d)", wantTarget, bloomtypes.TargetBloomRateBasisPoints)
}
// The seeded record for holder-alia uses RateBasisPoints=450 (the target).
rec2, ok := srv.Store.GetBloomRecord("stash-holder-alia")
if !ok {
t.Fatal("seeded bloom record stash-holder-alia missing")
}
if rec2.RateBasisPoints != bloomtypes.TargetBloomRateBasisPoints {
t.Errorf("D-073: seeded record RateBasisPoints = %d, want %d (code constant)", rec2.RateBasisPoints, bloomtypes.TargetBloomRateBasisPoints)
}
// The rate band must be rendered from the code constants.
wantMin := formatFloat(float64(bloomtypes.MinBloomRateBasisPoints) / 100)
wantMax := formatFloat(float64(bloomtypes.MaxBloomRateBasisPoints) / 100)
if !strings.Contains(body, wantMin) {
t.Errorf("D-073: body missing min rate %s%% (from code constant)", wantMin)
}
if !strings.Contains(body, wantMax) {
t.Errorf("D-073: body missing max rate %s%% (from code constant)", wantMax)
}
}
// Compile-time assertion that the handler uses the real x/bloom/types struct.
var _ bloomtypes.BloomRecord
// formatFloat formats a float to 1 decimal place without importing strconv
// (keeps the test deps minimal; matches the template's printf "%.1f").
func formatFloat(f float64) string {
// Round to 1 decimal.
rounded := float64(int(f*10+0.5)) / 10
whole := int(rounded)
frac := int((rounded - float64(whole)) * 10)
if frac == 0 {
return formatInt2(int64(whole)) + ".0"
}
return formatInt2(int64(whole)) + "." + string(rune('0'+frac))
}
func formatInt2(n int64) string {
if n == 0 {
return "0"
}
neg := n < 0
if neg {
n = -n
}
var buf [20]byte
i := len(buf)
for n > 0 {
i--
buf[i] = byte('0' + n%10)
n /= 10
}
if neg {
i--
buf[i] = '-'
}
return string(buf[i:])
}
+90
View File
@@ -0,0 +1,90 @@
package handlers
import (
"net/http"
identitytypes "github.com/oy/openyield/x/identity/types"
stashtypes "github.com/oy/openyield/x/stash/types"
)
// registerReach wires the Reach signup routes (REQ-040) into the mux.
// Go 1.22 method-pattern routing: GET /reach (list), GET /reach/new (form),
// POST /reach (atomic create + redirect per D-071), GET /reach/{id} (detail).
func (s *Server) registerReach(mux *http.ServeMux) {
mux.HandleFunc("GET /reach", s.handleReachList)
mux.HandleFunc("GET /reach/new", s.handleReachNew)
mux.HandleFunc("POST /reach", s.handleReachCreate)
mux.HandleFunc("GET /reach/{id}", s.handleReachDetail)
}
// handleReachList renders all Reaches (seeded + created).
func (s *Server) handleReachList(w http.ResponseWriter, r *http.Request) {
reaches := s.Store.ListReaches()
s.render(w, "reach_list.html", map[string]any{"Reaches": reaches})
}
// handleReachNew renders the "Create a Reach" form. Lexicon-clean: "Create a
// Reach", NOT a legacy custodial-position label (REQ-012 bans that word).
func (s *Server) handleReachNew(w http.ResponseWriter, r *http.Request) {
s.render(w, "reach_new.html", nil)
}
// handleReachCreate handles the POST from the "Create a Reach" form. Calls
// store.CreateReach (atomic Reach + Stash per D-071). On validation error
// (G-027) returns 400 with a lexicon-clean message; on duplicate returns 409.
// On success redirects (302) to the new Reach detail page.
func (s *Server) handleReachCreate(w http.ResponseWriter, r *http.Request) {
holderID := r.FormValue("holder_id")
publicKey := r.FormValue("public_key")
reach, _, err := s.Store.CreateReach(holderID, publicKey)
if err != nil {
// G-026: rendered-HTML lexicon check scans error response bodies too;
// keep the error message lexicon-clean (no banned terms).
status := http.StatusBadRequest
if isDuplicate(err) {
status = http.StatusConflict
}
http.Error(w, "Could not create a Reach: "+err.Error(), status)
return
}
http.Redirect(w, r, "/reach/"+reach.HolderID, http.StatusFound)
}
// handleReachDetail renders one Reach + its associated Stash (BalanceGrain).
func (s *Server) handleReachDetail(w http.ResponseWriter, r *http.Request) {
id := r.PathValue("id")
reach, ok := s.Store.GetReach(id)
if !ok {
http.NotFound(w, r)
return
}
stash, _ := s.Store.GetStash(id)
s.render(w, "reach_detail.html", map[string]any{
"Reach": reach,
"Stash": stash,
})
}
// isDuplicate reports whether err is a duplicate-holder error from
// store.CreateReach. Kept as a string match to avoid exporting store errors.
func isDuplicate(err error) bool {
return err != nil && contains(err.Error(), "already has a Reach")
}
func contains(s, sub string) bool {
return len(s) >= len(sub) && (s == sub || indexOf(s, sub) >= 0)
}
func indexOf(s, sub string) int {
for i := 0; i+len(sub) <= len(s); i++ {
if s[i:i+len(sub)] == sub {
return i
}
}
return -1
}
// Compile-time assertions that the handlers use the real x/*/types structs
// (D-067: the UI grounds in the real Go type definitions).
var _ identitytypes.Reach
var _ stashtypes.Stash
+185
View File
@@ -0,0 +1,185 @@
package handlers
import (
"net/http"
"net/http/httptest"
"strings"
"testing"
"github.com/oy/openyield/lexicon"
"github.com/oy/openyield/web/store"
)
// newTestServer builds a Server with a fresh store + templates parsed from
// web/templates (relative to repo root via the handlers test working dir).
func newTestServer(t *testing.T) *Server {
t.Helper()
srv, err := New(store.NewStore(), "../../web/templates")
if err != nil {
t.Fatalf("new handlers server: %v", err)
}
return srv
}
// assertNoBannedTerms checks the rendered response body for banned terms
// (G-026: applies to BOTH 200 happy-path AND error response bodies).
func assertNoBannedTerms(t *testing.T, body string) {
t.Helper()
if term, ok := lexicon.FindBannedTerm(body); ok {
t.Errorf("rendered HTML contains banned term %q (REQ-012/G-026)", term)
}
}
func TestReachListReturnsSeededReaches(t *testing.T) {
srv := newTestServer(t)
mux := http.NewServeMux()
srv.Register(mux)
rec := httptest.NewRecorder()
req := httptest.NewRequest("GET", "/reach", nil)
mux.ServeHTTP(rec, req)
if rec.Code != http.StatusOK {
t.Fatalf("GET /reach: status %d, want 200", rec.Code)
}
body := rec.Body.String()
if !strings.Contains(body, "holder-alia") {
t.Errorf("GET /reach: body missing seeded reach holder-alia")
}
if !strings.Contains(body, "holder-bryn") {
t.Errorf("GET /reach: body missing seeded reach holder-bryn")
}
assertNoBannedTerms(t, body)
}
func TestReachNewReturnsForm(t *testing.T) {
srv := newTestServer(t)
mux := http.NewServeMux()
srv.Register(mux)
rec := httptest.NewRecorder()
req := httptest.NewRequest("GET", "/reach/new", nil)
mux.ServeHTTP(rec, req)
if rec.Code != http.StatusOK {
t.Fatalf("GET /reach/new: status %d, want 200", rec.Code)
}
body := rec.Body.String()
if !strings.Contains(body, "Create a Reach") {
t.Errorf("GET /reach/new: body missing 'Create a Reach' label")
}
// The legacy custodial-position word is BANNED (REQ-012) — must not appear.
// Check the full banned-terms list via the lexicon package (no literals in
// source); FindBannedTerm does word-boundary matching so this is stricter
// than a naive substring check.
if term, ok := lexicon.FindBannedTerm(body); ok {
t.Errorf("GET /reach/new: body contains banned word %q", term)
}
assertNoBannedTerms(t, body)
}
func TestReachCreateValidRedirectsAndAtomicallyCreates(t *testing.T) {
srv := newTestServer(t)
mux := http.NewServeMux()
srv.Register(mux)
rec := httptest.NewRecorder()
req := httptest.NewRequest("POST", "/reach", strings.NewReader("holder_id=holder-new&public_key=pk-new"))
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
mux.ServeHTTP(rec, req)
if rec.Code != http.StatusFound {
t.Fatalf("POST /reach valid: status %d, want 302 (Found)", rec.Code)
}
loc := rec.Header().Get("Location")
if !strings.Contains(loc, "/reach/holder-new") {
t.Errorf("POST /reach: Location %q, want redirect to /reach/holder-new", loc)
}
// D-071: atomic creation — both Reach + Stash must be present.
reach, ok := srv.Store.GetReach("holder-new")
if !ok {
t.Fatalf("POST /reach: GetReach miss after create (atomicity broken)")
}
if !reach.IsNomad {
t.Errorf("POST /reach: created Reach IsNomad=false, want true (D-071)")
}
stash, ok := srv.Store.GetStash("holder-new")
if !ok {
t.Fatalf("POST /reach: GetStash miss after create (atomicity broken — D-071)")
}
if stash.HolderID != reach.HolderID {
t.Errorf("POST /reach: stash.HolderID %q != reach.HolderID %q (D-071)", stash.HolderID, reach.HolderID)
}
}
func TestReachCreateEmptyHolderIDReturns400(t *testing.T) {
srv := newTestServer(t)
mux := http.NewServeMux()
srv.Register(mux)
rec := httptest.NewRecorder()
req := httptest.NewRequest("POST", "/reach", strings.NewReader("holder_id=&public_key=pk"))
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
mux.ServeHTTP(rec, req)
if rec.Code != http.StatusBadRequest {
t.Fatalf("POST /reach empty holder: status %d, want 400", rec.Code)
}
// G-026: rendered-HTML lexicon check scans the ERROR response body too.
assertNoBannedTerms(t, rec.Body.String())
}
func TestReachCreatePathSeparatorReturns400(t *testing.T) {
srv := newTestServer(t)
mux := http.NewServeMux()
srv.Register(mux)
rec := httptest.NewRecorder()
req := httptest.NewRequest("POST", "/reach", strings.NewReader("holder_id=h/x&public_key=pk"))
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
mux.ServeHTTP(rec, req)
if rec.Code != http.StatusBadRequest {
t.Fatalf("POST /reach path separator: status %d, want 400", rec.Code)
}
assertNoBannedTerms(t, rec.Body.String())
}
func TestReachCreateDuplicateReturns409(t *testing.T) {
srv := newTestServer(t)
mux := http.NewServeMux()
srv.Register(mux)
rec := httptest.NewRecorder()
req := httptest.NewRequest("POST", "/reach", strings.NewReader("holder_id=holder-alia&public_key=pk"))
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
mux.ServeHTTP(rec, req)
if rec.Code != http.StatusConflict {
t.Fatalf("POST /reach duplicate: status %d, want 409", rec.Code)
}
assertNoBannedTerms(t, rec.Body.String())
}
func TestReachDetailSeededReturnsReachAndStash(t *testing.T) {
srv := newTestServer(t)
mux := http.NewServeMux()
srv.Register(mux)
rec := httptest.NewRecorder()
req := httptest.NewRequest("GET", "/reach/holder-alia", nil)
mux.ServeHTTP(rec, req)
if rec.Code != http.StatusOK {
t.Fatalf("GET /reach/holder-alia: status %d, want 200", rec.Code)
}
body := rec.Body.String()
if !strings.Contains(body, "reach-holder-alia") {
t.Errorf("GET /reach/holder-alia: body missing reach-holder-alia")
}
if !strings.Contains(body, "stash-holder-alia") {
t.Errorf("GET /reach/holder-alia: body missing associated stash-holder-alia")
}
if !strings.Contains(body, "Grain") {
t.Errorf("GET /reach/holder-alia: body missing Stash balance in Grain")
}
assertNoBannedTerms(t, body)
}
func TestReachDetailMissingReturns404(t *testing.T) {
srv := newTestServer(t)
mux := http.NewServeMux()
srv.Register(mux)
rec := httptest.NewRecorder()
req := httptest.NewRequest("GET", "/reach/nobody", nil)
mux.ServeHTTP(rec, req)
if rec.Code != http.StatusNotFound {
t.Fatalf("GET /reach/nobody: status %d, want 404", rec.Code)
}
}
+125
View File
@@ -0,0 +1,125 @@
// Package handlers holds the HTTP handlers for the OpenYield web UI screens.
//
// Each screen (Reach signup, Stash dashboard, Window authorization, Standing
// progress, Bloom accrual) gets its own handler file. handlers/server.go wires
// routes into the mux from web/server.go. Handlers render html/template
// templates against the mock store (web/store). Lexicon-clean by construction
// (REQ-012 / REQ-045): the lexicon_meta_web firewall scans these files.
package handlers
import (
"fmt"
"html/template"
"net/http"
"os"
"path/filepath"
"github.com/oy/openyield/web/store"
standingtypes "github.com/oy/openyield/x/standing/types"
windowtypes "github.com/oy/openyield/x/window/types"
)
// Server bundles the mock store + per-page templates + route registration.
// Each screen handler is a method on Server so it shares the store + tmpl.
//
// Template loading: base.html is parsed once, then each page template is
// parsed in a CLONE of the base set so the per-page "content" block does not
// collide across pages (Go html/template shares the block namespace within
// one set; cloning per page isolates each page's content block). This is the
// standard Go template pattern for layouts + pages.
type Server struct {
Store *store.Store
Pages map[string]*template.Template
}
// 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
},
"string": func(v any) string {
switch t := v.(type) {
case string:
return t
case windowtypes.WindowStatus:
return string(t)
case standingtypes.StandingBucket:
return string(t)
default:
return ""
}
},
}
basePath := filepath.Join(templatesDir, "base.html")
base, err := template.New("base.html").Funcs(funcs).ParseFiles(basePath)
if err != nil {
return nil, fmt.Errorf("parse base: %w", err)
}
pages := map[string]*template.Template{}
pageGlob := filepath.Join(templatesDir, "*.html")
matches, err := filepath.Glob(pageGlob)
if err != nil {
return nil, fmt.Errorf("glob pages: %w", err)
}
for _, p := range matches {
name := filepath.Base(p)
if name == "base.html" {
continue
}
clone, cerr := base.Clone()
if cerr != nil {
return nil, fmt.Errorf("clone for %s: %w", name, cerr)
}
pt, perr := clone.ParseFiles(p)
if perr != nil {
return nil, fmt.Errorf("parse %s: %w", name, perr)
}
pages[name] = pt
}
return &Server{Store: s, Pages: pages}, nil
}
// Register wires all screen routes into the given mux (Go 1.22 method
// patterns). Called by web/server.go after constructing the Server.
func (s *Server) Register(mux *http.ServeMux) {
s.registerReach(mux)
s.registerStash(mux)
s.registerWindow(mux)
s.registerStanding(mux)
s.registerBloom(mux)
}
// render executes the named page template with the given data, writing HTML
// to w. The page template invokes base.html and overrides the "content" block.
func (s *Server) render(w http.ResponseWriter, name string, data any) {
tmpl, ok := s.Pages[name]
if !ok {
http.Error(w, "template not found: "+name, http.StatusInternalServerError)
return
}
w.Header().Set("Content-Type", "text/html; charset=utf-8")
if err := tmpl.ExecuteTemplate(w, "base.html", data); err != nil {
http.Error(w, "render error", http.StatusInternalServerError)
}
}
// RenderHome renders the home page (public so web/server.go can call it for
// the "/" route which lives outside handlers.Register).
func (s *Server) RenderHome(w http.ResponseWriter, data any) {
s.render(w, "home.html", data)
}
// templatesDir returns the default web/templates directory relative to the
// working directory. Used by web/server.go when constructing via New().
func DefaultTemplatesDir() string {
dir, _ := os.Getwd()
if filepath.Base(dir) == "web" || filepath.Base(dir) == "handlers" {
return filepath.Join(dir, "templates")
}
return "web/templates"
}
+59
View File
@@ -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,
})
}
+146
View File
@@ -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
+68
View File
@@ -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,
})
}
+156
View File
@@ -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:])
}
+119
View File
@@ -0,0 +1,119 @@
package handlers
import (
"net/http"
"strconv"
"time"
windowtypes "github.com/oy/openyield/x/window/types"
)
// registerWindow wires the Window authorization routes (REQ-042) into the mux.
func (s *Server) registerWindow(mux *http.ServeMux) {
mux.HandleFunc("GET /window", s.handleWindowList)
mux.HandleFunc("GET /window/new", s.handleWindowNew)
mux.HandleFunc("POST /window", s.handleWindowOpen)
mux.HandleFunc("GET /window/{id}", s.handleWindowDetail)
mux.HandleFunc("POST /window/{id}/activate", s.handleWindowActivate)
mux.HandleFunc("POST /window/{id}/revoke", s.handleWindowRevoke)
mux.HandleFunc("POST /window/{id}/expire", s.handleWindowExpire)
}
// handleWindowList renders all Windows for a grantor holder (defaults to
// holder-alia if no query param, so the list view has something to show).
func (s *Server) handleWindowList(w http.ResponseWriter, r *http.Request) {
grantor := r.URL.Query().Get("grantor")
if grantor == "" {
grantor = "holder-alia"
}
windows := s.Store.ListWindows(grantor)
s.render(w, "window_list.html", map[string]any{"Windows": windows, "Grantor": grantor})
}
// handleWindowNew renders the "Open a Window" form.
func (s *Server) handleWindowNew(w http.ResponseWriter, r *http.Request) {
s.render(w, "window_new.html", nil)
}
// handleWindowOpen handles the POST from the "Open a Window" form. Calls
// store.OpenWindow (creates a Window status=Open + an initial AuditEntry).
func (s *Server) handleWindowOpen(w http.ResponseWriter, r *http.Request) {
grantor := r.FormValue("grantor_holder")
grantee := r.FormValue("grantee")
scopeKind := windowtypes.ScopeKind(r.FormValue("scope_kind"))
resourceID := r.FormValue("resource_id")
startStr := r.FormValue("start_unix")
endStr := r.FormValue("end_unix")
maxActionsStr := r.FormValue("max_actions")
if grantor == "" {
http.Error(w, "grantor holder is required", http.StatusBadRequest)
return
}
if grantee == "" {
http.Error(w, "grantee is required", http.StatusBadRequest)
return
}
start, _ := strconv.ParseInt(startStr, 10, 64)
end, _ := strconv.ParseInt(endStr, 10, 64)
if start == 0 {
start = time.Now().Unix()
}
if end == 0 {
end = start + 3600
}
maxActions, _ := strconv.ParseUint(maxActionsStr, 10, 32)
if maxActions == 0 {
maxActions = 10
}
scope := windowtypes.Scope{Kind: scopeKind, ResourceID: resourceID}
rateLimit := windowtypes.RateLimit{MaxActions: uint32(maxActions), PerDurationSeconds: 3600}
win, err := s.Store.OpenWindow(grantor, grantee, scope, start, end, rateLimit)
if err != nil {
http.Error(w, "could not open a Window: "+err.Error(), http.StatusBadRequest)
return
}
http.Redirect(w, r, "/window/"+win.WindowID, http.StatusFound)
}
// handleWindowDetail renders one Window + its lifecycle state + audit log.
func (s *Server) handleWindowDetail(w http.ResponseWriter, r *http.Request) {
id := r.PathValue("id")
win, ok := s.Store.GetWindow(id)
if !ok {
http.NotFound(w, r)
return
}
auditLog := s.Store.GetAuditLog(id)
s.render(w, "window_detail.html", map[string]any{"Window": win, "AuditLog": auditLog})
}
// handleWindowActivate transitions Open → Active (calls Window.Activate).
func (s *Server) handleWindowActivate(w http.ResponseWriter, r *http.Request) {
id := r.PathValue("id")
if err := s.Store.ActivateWindow(id); err != nil {
http.Error(w, "could not activate: "+err.Error(), http.StatusBadRequest)
return
}
http.Redirect(w, r, "/window/"+id, http.StatusSeeOther)
}
// handleWindowRevoke transitions to Revoked (calls Window.Revoke; idempotent).
func (s *Server) handleWindowRevoke(w http.ResponseWriter, r *http.Request) {
id := r.PathValue("id")
if err := s.Store.RevokeWindow(id); err != nil {
http.Error(w, "could not revoke: "+err.Error(), http.StatusBadRequest)
return
}
http.Redirect(w, r, "/window/"+id, http.StatusSeeOther)
}
// handleWindowExpire transitions to Expired (calls Window.Expire).
func (s *Server) handleWindowExpire(w http.ResponseWriter, r *http.Request) {
id := r.PathValue("id")
if err := s.Store.ExpireWindow(id); err != nil {
http.Error(w, "could not expire: "+err.Error(), http.StatusBadRequest)
return
}
http.Redirect(w, r, "/window/"+id, http.StatusSeeOther)
}
+363
View File
@@ -0,0 +1,363 @@
package handlers
import (
"net/http"
"net/http/httptest"
"strings"
"testing"
windowtypes "github.com/oy/openyield/x/window/types"
)
func TestWindowOpenCreatesWindowStatusOpen(t *testing.T) {
srv := newTestServer(t)
mux := http.NewServeMux()
srv.Register(mux)
rec := httptest.NewRecorder()
body := "grantor_holder=holder-alia&grantee=service-1&scope_kind=ReadStash&resource_id=stash-holder-alia&max_actions=5"
req := httptest.NewRequest("POST", "/window", strings.NewReader(body))
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
mux.ServeHTTP(rec, req)
if rec.Code != http.StatusFound {
t.Fatalf("POST /window: status %d, want 302", rec.Code)
}
loc := rec.Header().Get("Location")
if !strings.HasPrefix(loc, "/window/window-") {
t.Errorf("POST /window: Location %q, want /window/window-...", loc)
}
// Extract the windowID and verify it exists with Status=Open + an initial AuditEntry.
windowID := strings.TrimPrefix(loc, "/window/")
win, ok := srv.Store.GetWindow(windowID)
if !ok {
t.Fatalf("POST /window: GetWindow(%q) miss", windowID)
}
if win.Status != windowtypes.StatusOpen {
t.Errorf("POST /window: created Window status %q, want Open", win.Status)
}
audit := srv.Store.GetAuditLog(windowID)
if len(audit) != 1 {
t.Errorf("POST /window: audit log len %d, want 1 (initial entry)", len(audit))
}
if audit[0].Action != "open" {
t.Errorf("POST /window: initial audit action %q, want open", audit[0].Action)
}
assertNoBannedTerms(t, rec.Body.String())
}
func TestWindowActivateTransitionsOpenToActive(t *testing.T) {
srv := newTestServer(t)
mux := http.NewServeMux()
srv.Register(mux)
scope := windowtypes.Scope{Kind: windowtypes.ScopeReadStash, ResourceID: "stash-holder-alia"}
rl := windowtypes.RateLimit{MaxActions: 10, PerDurationSeconds: 3600}
win, err := srv.Store.OpenWindow("holder-alia", "service-1", scope, 1000, 2000, rl)
if err != nil {
t.Fatalf("OpenWindow: %v", err)
}
rec := httptest.NewRecorder()
req := httptest.NewRequest("POST", "/window/"+win.WindowID+"/activate", nil)
mux.ServeHTTP(rec, req)
if rec.Code != http.StatusSeeOther {
t.Fatalf("POST activate: status %d, want 303", rec.Code)
}
// Lifecycle correctness: assert the real Window.Activate() was invoked
// (the handler calls store.ActivateWindow which calls w.Activate()).
updated, ok := srv.Store.GetWindow(win.WindowID)
if !ok {
t.Fatal("window missing after activate")
}
if updated.Status != windowtypes.StatusActive {
t.Errorf("after activate: status %q, want Active (Window.Activate was NOT invoked)", updated.Status)
}
audit := srv.Store.GetAuditLog(win.WindowID)
if len(audit) != 2 {
t.Errorf("after activate: audit log len %d, want 2 (initial + activate)", len(audit))
}
if audit[1].Action != "activate" {
t.Errorf("after activate: audit[1].Action %q, want activate", audit[1].Action)
}
assertNoBannedTerms(t, rec.Body.String())
}
func TestWindowRevokeTransitionsToRevoked(t *testing.T) {
srv := newTestServer(t)
mux := http.NewServeMux()
srv.Register(mux)
scope := windowtypes.Scope{Kind: windowtypes.ScopeReadStash, ResourceID: "stash-holder-alia"}
rl := windowtypes.RateLimit{MaxActions: 10, PerDurationSeconds: 3600}
win, _ := srv.Store.OpenWindow("holder-alia", "service-1", scope, 1000, 2000, rl)
_ = srv.Store.ActivateWindow(win.WindowID)
rec := httptest.NewRecorder()
req := httptest.NewRequest("POST", "/window/"+win.WindowID+"/revoke", nil)
mux.ServeHTTP(rec, req)
if rec.Code != http.StatusSeeOther {
t.Fatalf("POST revoke: status %d, want 303", rec.Code)
}
updated, _ := srv.Store.GetWindow(win.WindowID)
if updated.Status != windowtypes.StatusRevoked {
t.Errorf("after revoke: status %q, want Revoked (Window.Revoke was NOT invoked)", updated.Status)
}
if !updated.Revoked {
t.Errorf("after revoke: Revoked flag false, want true")
}
assertNoBannedTerms(t, rec.Body.String())
}
func TestWindowRevokeIdempotentOnAlreadyRevoked(t *testing.T) {
srv := newTestServer(t)
scope := windowtypes.Scope{Kind: windowtypes.ScopeReadStash, ResourceID: "stash-holder-alia"}
rl := windowtypes.RateLimit{MaxActions: 10, PerDurationSeconds: 3600}
win, _ := srv.Store.OpenWindow("holder-alia", "service-1", scope, 1000, 2000, rl)
_ = srv.Store.ActivateWindow(win.WindowID)
_ = srv.Store.RevokeWindow(win.WindowID)
auditBefore := len(srv.Store.GetAuditLog(win.WindowID))
// Second revoke is a no-op (idempotent): no new AuditEntry.
_ = srv.Store.RevokeWindow(win.WindowID)
auditAfter := len(srv.Store.GetAuditLog(win.WindowID))
if auditAfter != auditBefore {
t.Errorf("idempotent revoke: audit log grew %d -> %d (revoke on already-revoked must be a no-op)", auditBefore, auditAfter)
}
}
func TestWindowRevokeOnExpiredIsNoOp(t *testing.T) {
srv := newTestServer(t)
scope := windowtypes.Scope{Kind: windowtypes.ScopeReadStash, ResourceID: "stash-holder-alia"}
rl := windowtypes.RateLimit{MaxActions: 10, PerDurationSeconds: 3600}
win, _ := srv.Store.OpenWindow("holder-alia", "service-1", scope, 1000, 2000, rl)
_ = srv.Store.ActivateWindow(win.WindowID)
_ = srv.Store.ExpireWindow(win.WindowID)
auditBefore := len(srv.Store.GetAuditLog(win.WindowID))
// Revoke on an Expired window is a no-op (Expired is terminal — v0.2 contract).
_ = srv.Store.RevokeWindow(win.WindowID)
updated, _ := srv.Store.GetWindow(win.WindowID)
if updated.Status != windowtypes.StatusExpired {
t.Errorf("revoke-on-expired: status %q, want Expired (terminal state must win)", updated.Status)
}
auditAfter := len(srv.Store.GetAuditLog(win.WindowID))
if auditAfter != auditBefore {
t.Errorf("revoke-on-expired: audit log grew %d -> %d (must be a no-op)", auditBefore, auditAfter)
}
}
func TestWindowExpireTransitionsToExpired(t *testing.T) {
srv := newTestServer(t)
scope := windowtypes.Scope{Kind: windowtypes.ScopeReadStash, ResourceID: "stash-holder-alia"}
rl := windowtypes.RateLimit{MaxActions: 10, PerDurationSeconds: 3600}
win, _ := srv.Store.OpenWindow("holder-alia", "service-1", scope, 1000, 2000, rl)
_ = srv.Store.ActivateWindow(win.WindowID)
_ = srv.Store.ExpireWindow(win.WindowID)
updated, _ := srv.Store.GetWindow(win.WindowID)
if updated.Status != windowtypes.StatusExpired {
t.Errorf("after expire: status %q, want Expired (Window.Expire was NOT invoked)", updated.Status)
}
}
func TestWindowDetailRendersLifecycleAndAuditLog(t *testing.T) {
srv := newTestServer(t)
mux := http.NewServeMux()
srv.Register(mux)
scope := windowtypes.Scope{Kind: windowtypes.ScopeReadStash, ResourceID: "stash-holder-alia"}
rl := windowtypes.RateLimit{MaxActions: 10, PerDurationSeconds: 3600}
win, _ := srv.Store.OpenWindow("holder-alia", "service-1", scope, 1000, 2000, rl)
_ = srv.Store.ActivateWindow(win.WindowID)
rec := httptest.NewRecorder()
req := httptest.NewRequest("GET", "/window/"+win.WindowID, nil)
mux.ServeHTTP(rec, req)
if rec.Code != http.StatusOK {
t.Fatalf("GET /window/%s: status %d, want 200", win.WindowID, rec.Code)
}
body := rec.Body.String()
if !strings.Contains(body, "Active") {
t.Errorf("detail: body missing Active badge")
}
if !strings.Contains(body, "activate") {
t.Errorf("detail: body missing activate audit-log entry")
}
if !strings.Contains(body, "open") {
t.Errorf("detail: body missing open audit-log entry")
}
assertNoBannedTerms(t, body)
}
func TestWindowDetailMissingReturns404(t *testing.T) {
srv := newTestServer(t)
mux := http.NewServeMux()
srv.Register(mux)
rec := httptest.NewRecorder()
req := httptest.NewRequest("GET", "/window/window-nobody", nil)
mux.ServeHTTP(rec, req)
if rec.Code != http.StatusNotFound {
t.Fatalf("GET /window/window-nobody: status %d, want 404", rec.Code)
}
assertNoBannedTerms(t, rec.Body.String())
}
func TestWindowOpenEmptyGrantorReturns400(t *testing.T) {
srv := newTestServer(t)
mux := http.NewServeMux()
srv.Register(mux)
rec := httptest.NewRecorder()
body := "grantor_holder=&grantee=service-1&scope_kind=ReadStash"
req := httptest.NewRequest("POST", "/window", strings.NewReader(body))
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
mux.ServeHTTP(rec, req)
if rec.Code != http.StatusBadRequest {
t.Fatalf("POST /window empty grantor: status %d, want 400", rec.Code)
}
// G-026: rendered-HTML lexicon check on the ERROR response body too.
assertNoBannedTerms(t, rec.Body.String())
}
// Compile-time assertion that the handler uses the real x/window/types struct
// (D-067: the UI grounds in the real Go type definitions).
var _ windowtypes.Window
func TestWindowListRendersSeededEmpty(t *testing.T) {
srv := newTestServer(t)
mux := http.NewServeMux()
srv.Register(mux)
rec := httptest.NewRecorder()
req := httptest.NewRequest("GET", "/window", nil)
mux.ServeHTTP(rec, req)
if rec.Code != http.StatusOK {
t.Fatalf("GET /window: status %d, want 200", rec.Code)
}
body := rec.Body.String()
// No windows yet for holder-alia (fresh store) -> empty message.
if !strings.Contains(body, "Open a Window") {
t.Errorf("GET /window: body missing 'Open a Window' link")
}
assertNoBannedTerms(t, body)
}
func TestWindowListRendersCreatedWindows(t *testing.T) {
srv := newTestServer(t)
mux := http.NewServeMux()
srv.Register(mux)
scope := windowtypes.Scope{Kind: windowtypes.ScopeReadStash, ResourceID: "stash-holder-alia"}
rl := windowtypes.RateLimit{MaxActions: 5, PerDurationSeconds: 3600}
w, _ := srv.Store.OpenWindow("holder-alia", "service-1", scope, 1000, 2000, rl)
rec := httptest.NewRecorder()
req := httptest.NewRequest("GET", "/window", nil)
mux.ServeHTTP(rec, req)
if rec.Code != http.StatusOK {
t.Fatalf("GET /window: status %d, want 200", rec.Code)
}
body := rec.Body.String()
if !strings.Contains(body, w.WindowID) {
t.Errorf("GET /window: body missing created window %s", w.WindowID)
}
if !strings.Contains(body, "service-1") {
t.Errorf("GET /window: body missing grantee service-1")
}
assertNoBannedTerms(t, body)
}
func TestWindowNewRendersForm(t *testing.T) {
srv := newTestServer(t)
mux := http.NewServeMux()
srv.Register(mux)
rec := httptest.NewRecorder()
req := httptest.NewRequest("GET", "/window/new", nil)
mux.ServeHTTP(rec, req)
if rec.Code != http.StatusOK {
t.Fatalf("GET /window/new: status %d, want 200", rec.Code)
}
body := rec.Body.String()
if !strings.Contains(body, "Open a Window") {
t.Errorf("GET /window/new: body missing 'Open a Window' label")
}
if !strings.Contains(body, "ReadStash") {
t.Errorf("GET /window/new: body missing ScopeKind option ReadStash")
}
if !strings.Contains(body, "ProcessPassActForStand") {
t.Errorf("GET /window/new: body missing ScopeKind option ProcessPassActForStand")
}
assertNoBannedTerms(t, body)
}
func TestWindowOpenEmptyGranteeReturns400(t *testing.T) {
srv := newTestServer(t)
mux := http.NewServeMux()
srv.Register(mux)
rec := httptest.NewRecorder()
body := "grantor_holder=holder-alia&grantee=&scope_kind=ReadStash"
req := httptest.NewRequest("POST", "/window", strings.NewReader(body))
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
mux.ServeHTTP(rec, req)
if rec.Code != http.StatusBadRequest {
t.Fatalf("POST /window empty grantee: status %d, want 400", rec.Code)
}
assertNoBannedTerms(t, rec.Body.String())
}
func TestWindowActivateNotFoundReturns400(t *testing.T) {
srv := newTestServer(t)
mux := http.NewServeMux()
srv.Register(mux)
rec := httptest.NewRecorder()
req := httptest.NewRequest("POST", "/window/window-nobody/activate", nil)
mux.ServeHTTP(rec, req)
if rec.Code != http.StatusBadRequest {
t.Fatalf("POST activate nobody: status %d, want 400", rec.Code)
}
assertNoBannedTerms(t, rec.Body.String())
}
func TestWindowRevokeNotFoundReturns400(t *testing.T) {
srv := newTestServer(t)
mux := http.NewServeMux()
srv.Register(mux)
rec := httptest.NewRecorder()
req := httptest.NewRequest("POST", "/window/window-nobody/revoke", nil)
mux.ServeHTTP(rec, req)
if rec.Code != http.StatusBadRequest {
t.Fatalf("POST revoke nobody: status %d, want 400", rec.Code)
}
assertNoBannedTerms(t, rec.Body.String())
}
func TestWindowExpireNotFoundReturns400(t *testing.T) {
srv := newTestServer(t)
mux := http.NewServeMux()
srv.Register(mux)
rec := httptest.NewRecorder()
req := httptest.NewRequest("POST", "/window/window-nobody/expire", nil)
mux.ServeHTTP(rec, req)
if rec.Code != http.StatusBadRequest {
t.Fatalf("POST expire nobody: status %d, want 400", rec.Code)
}
assertNoBannedTerms(t, rec.Body.String())
}
func TestWindowRevokeAndExpireHandlersRedirect(t *testing.T) {
srv := newTestServer(t)
mux := http.NewServeMux()
srv.Register(mux)
scope := windowtypes.Scope{Kind: windowtypes.ScopeReadStash, ResourceID: "stash-holder-alia"}
rl := windowtypes.RateLimit{MaxActions: 10, PerDurationSeconds: 3600}
win, _ := srv.Store.OpenWindow("holder-alia", "service-1", scope, 1000, 2000, rl)
_ = srv.Store.ActivateWindow(win.WindowID)
rec := httptest.NewRecorder()
req := httptest.NewRequest("POST", "/window/"+win.WindowID+"/revoke", nil)
mux.ServeHTTP(rec, req)
if rec.Code != http.StatusSeeOther {
t.Fatalf("POST revoke: status %d, want 303", rec.Code)
}
// Expire on a revoked window: revoked is not terminal for expire, so it
// transitions to Expired (Window.Expire sets status unconditionally).
rec2 := httptest.NewRecorder()
req2 := httptest.NewRequest("POST", "/window/"+win.WindowID+"/expire", nil)
mux.ServeHTTP(rec2, req2)
if rec2.Code != http.StatusSeeOther {
t.Fatalf("POST expire: status %d, want 303", rec2.Code)
}
}
+5
View File
@@ -0,0 +1,5 @@
package main
func main() {
runServer()
}
+42
View File
@@ -0,0 +1,42 @@
package main
import (
"log"
"net/http"
"os"
"github.com/oy/openyield/web/handlers"
"github.com/oy/openyield/web/store"
)
func runServer() {
port := os.Getenv("PORT")
if port == "" {
port = "8080"
}
mux := http.NewServeMux()
srv, err := handlers.New(store.NewStore(), "web/templates")
if err != nil {
log.Fatalf("init handlers: %v", err)
}
srv.Register(mux)
// Home page (rendered via the handlers' page machinery too).
mux.HandleFunc("GET /", func(w http.ResponseWriter, r *http.Request) {
if r.URL.Path != "/" {
http.NotFound(w, r)
return
}
srv.RenderHome(w, nil)
})
mux.Handle("GET /static/", http.StripPrefix("/static/", http.FileServer(http.Dir("web/static"))))
server := &http.Server{Addr: ":" + port, Handler: mux}
log.Printf("OpenYield web on :%s", port)
if err := server.ListenAndServe(); err != nil {
log.Fatalf("server: %v", err)
}
}
+5
View File
File diff suppressed because one or more lines are too long
+78
View File
@@ -0,0 +1,78 @@
/* style.css OpenYield web UI minimal styling (lexicon-clean).
No banned terms in comments or class names (REQ-012/REQ-045). */
:root {
--bg: #0d1117;
--panel: #161b22;
--ink: #c9d1d9;
--muted: #8b949e;
--accent: #58a6ff;
--line: #30363d;
}
* { box-sizing: border-box; }
body {
margin: 0;
font-family: system-ui, -apple-system, sans-serif;
background: var(--bg);
color: var(--ink);
line-height: 1.5;
}
a { color: var(--accent); text-decoration: none; }
a:hover { text-decoration: underline; }
header.nav {
border-bottom: 1px solid var(--line);
padding: 0.75rem 1.5rem;
display: flex;
gap: 1.25rem;
align-items: center;
background: var(--panel);
}
header.nav .brand { font-weight: 600; color: var(--ink); }
header.nav a { color: var(--muted); }
header.nav a:hover { color: var(--accent); }
main { max-width: 960px; margin: 2rem auto; padding: 0 1.5rem; }
footer {
border-top: 1px solid var(--line);
padding: 1rem 1.5rem;
color: var(--muted);
font-size: 0.85rem;
text-align: center;
}
.panel {
background: var(--panel);
border: 1px solid var(--line);
border-radius: 6px;
padding: 1.25rem;
margin-bottom: 1.5rem;
}
table { width: 100%; border-collapse: collapse; }
th, td { text-align: left; padding: 0.5rem 0.75rem; border-bottom: 1px solid var(--line); }
th { color: var(--muted); font-weight: 600; font-size: 0.85rem; text-transform: uppercase; letter-spacing: 0.04em; }
form .field { margin-bottom: 1rem; }
form label { display: block; margin-bottom: 0.25rem; color: var(--muted); font-size: 0.9rem; }
form input[type=text], form input[type=password] {
width: 100%; max-width: 32rem;
padding: 0.5rem 0.65rem;
background: var(--bg);
border: 1px solid var(--line);
border-radius: 4px;
color: var(--ink);
font-family: monospace;
}
button, .btn {
background: var(--accent); color: #0d1117; border: none;
padding: 0.5rem 1rem; border-radius: 4px; font-weight: 600; cursor: pointer;
}
button:hover, .btn:hover { opacity: 0.9; text-decoration: none; }
.error { color: #f85149; }
.muted { color: var(--muted); }
+137
View File
@@ -0,0 +1,137 @@
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"
)
// seed populates the store with a few pre-existing Reach/Stash pairs for the
// 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. 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()
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
// 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) {
reachID := "reach-" + holderID
stashID := "stash-" + holderID
s.reaches[holderID] = identitytypes.Reach{
ReachID: reachID,
HolderID: holderID,
CreatedAt: now - int64(activeDays)*86400,
PublicKey: pubKey,
IsNomad: true,
}
s.stashes[holderID] = stashtypes.Stash{
HolderID: holderID,
StashID: stashID,
CreatedAt: now - int64(activeDays)*86400,
LastActive: now,
BalanceGrain: balanceGrain,
}
s.stashActivities[stashID] = stashtypes.StashActivity{
StashID: stashID,
ActiveDays: activeDays,
MaxGapDays: maxGap,
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:])
}
+108
View File
@@ -0,0 +1,108 @@
// import_test.go enforces the G-003/G-025 boundary for web/: web/ is the
// application layer that consumes protocol types (D-070), NOT a cross-x/
// production import. The invariant: every non-test .go file under web/ may
// import github.com/oy/openyield/x/<module>/types packages (the app-layer
// consumption direction), but MUST NOT import github.com/oy/openyield/
// x/<module>/keeper OR github.com/oy/openyield/x/<module> (the module.go
// packages — G-025 extends the original keeper-only check to also forbid
// module.go, since those packages carry Cosmos runtime machinery the mock UI
// must not reach into). This test uses go/parser (stdlib only — G-006) and
// mirrors the x/window/types/types_test.go G-003 pattern, but with the
// inverted rule: x/*/types is ALLOWED (app-layer consumption), x/*/keeper
// and x/<module> (module.go) are FORBIDDEN.
package store
import (
"go/parser"
"go/token"
"os"
"path/filepath"
"runtime"
"strings"
"testing"
)
func TestG025WebImportsOnlyTypesNotKeeperOrModule(t *testing.T) {
webRoot := webRoot(t)
fset := token.NewFileSet()
violations := []string{}
err := filepath.Walk(webRoot, func(path string, info os.FileInfo, err error) error {
if err != nil {
return err
}
if info.IsDir() {
return nil
}
if !strings.HasSuffix(path, ".go") {
return nil
}
// Skip test files (G-025 is about production code only).
if strings.HasSuffix(path, "_test.go") {
return nil
}
f, perr := parser.ParseFile(fset, path, nil, parser.ImportsOnly)
if perr != nil {
return perr
}
for _, imp := range f.Imports {
ip := strings.Trim(imp.Path.Value, `"`)
if isForbiddenXImport(ip) {
rel, _ := filepath.Rel(webRoot, path)
violations = append(violations, rel+" -> "+ip)
}
}
return nil
})
if err != nil {
t.Fatalf("walk web/: %v", err)
}
if len(violations) > 0 {
t.Errorf("G-025 violation: web/ production files importing forbidden x/ packages:\n %s",
strings.Join(violations, "\n "))
}
}
// isForbiddenXImport reports whether ip is an x/<module>/keeper or a bare
// x/<module> (module.go) import — both forbidden from web/ (G-025). The
// x/<module>/types packages are ALLOWED (D-070 app-layer consumption).
func isForbiddenXImport(ip string) bool {
const prefix = "github.com/oy/openyield/x/"
if !strings.HasPrefix(ip, prefix) {
return false
}
rest := strings.TrimPrefix(ip, prefix)
parts := strings.Split(rest, "/")
switch len(parts) {
case 1:
// x/<module> (module.go package) — forbidden (G-025).
return true
case 2:
// x/<module>/types -> allowed (D-070). x/<module>/keeper -> forbidden.
if parts[1] == "types" {
return false
}
return true
default:
// x/<module>/<sub>/... — forbid anything other than types (e.g.
// x/<module>/keeper/... sub-packages).
if parts[1] == "types" {
return false
}
return true
}
}
// webRoot returns the absolute path to the web/ directory by walking up
// from this test file (web/store/import_test.go -> repoRoot/web).
func webRoot(t *testing.T) string {
t.Helper()
_, file, _, ok := runtime.Caller(0)
if !ok {
t.Fatal("runtime.Caller failed")
}
// file = .../oy/web/store/import_test.go
// repoRoot = filepath.Dir(filepath.Dir(filepath.Dir(file)))
// webRoot = repoRoot/web
repoRoot := filepath.Dir(filepath.Dir(filepath.Dir(file)))
return filepath.Join(repoRoot, "web")
}
+428
View File
@@ -0,0 +1,428 @@
// 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-<holderID>".
if strings.HasPrefix(stashID, "stash-"+holderID) {
out = append(out, rec)
}
}
return out
}
+576
View File
@@ -0,0 +1,576 @@
package store
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"
windowtypes "github.com/oy/openyield/x/window/types"
)
func TestNewStoreSeedsFixtures(t *testing.T) {
s := NewStore()
reaches := s.ListReaches()
if len(reaches) < 2 {
t.Fatalf("NewStore seeded %d reaches, want >=2", len(reaches))
}
// Both seeded reaches must be Nomads (IsNomad=true).
for _, r := range reaches {
if !r.IsNomad {
t.Errorf("seeded reach %q: IsNomad=false, want true", r.HolderID)
}
}
}
func TestCreateReachAtomicReachAndStash(t *testing.T) {
s := NewStore()
reach, stash, err := s.CreateReach("holder-test1", "pk-test1")
if err != nil {
t.Fatalf("CreateReach: %v", err)
}
// D-071: Reach must be IsNomad=true.
if !reach.IsNomad {
t.Errorf("reach.IsNomad = false, want true (D-071)")
}
if reach.HolderID != "holder-test1" {
t.Errorf("reach.HolderID = %q, want holder-test1", reach.HolderID)
}
// D-071: Stash must have matching HolderID + seeded BalanceGrain.
if stash.HolderID != reach.HolderID {
t.Errorf("stash.HolderID = %q, want %q (D-071 atomic)", stash.HolderID, reach.HolderID)
}
if stash.BalanceGrain != seedBalanceGrain {
t.Errorf("stash.BalanceGrain = %d, want %d", stash.BalanceGrain, seedBalanceGrain)
}
// Both must be retrievable after the atomic call.
if _, ok := s.GetReach("holder-test1"); !ok {
t.Errorf("GetReach miss after CreateReach (atomicity broken)")
}
if _, ok := s.GetStash("holder-test1"); !ok {
t.Errorf("GetStash miss after CreateReach (atomicity broken)")
}
if _, ok := s.GetStashActivity(stash.StashID); !ok {
t.Errorf("GetStashActivity miss after CreateReach (atomicity broken)")
}
}
func TestCreateReachDuplicateRejected(t *testing.T) {
s := NewStore()
if _, _, err := s.CreateReach("holder-alia", "pk-dupe"); err == nil {
t.Errorf("CreateReach duplicate holder-alia: expected error, got nil")
}
}
func TestCreateReachValidationG027(t *testing.T) {
cases := []struct {
name string
holderID string
publicKey string
wantErr bool
}{
{"empty holder", "", "pk", true},
{"empty pubkey", "h", "", true},
{"holder too long", stringOf('x', 129), "pk", true},
{"pubkey too long", "h", stringOf('y', 129), true},
{"holder with slash", "h/x", "pk", true},
{"holder with backslash", "h\\x", "pk", true},
{"holder with template syntax", "h{{", "pk", true},
{"pubkey with slash", "h", "p/x", true},
{"valid minimal", "h", "p", false},
{"valid typical", "holder-oka", "pk-oka-7", false},
}
for _, c := range cases {
t.Run(c.name, func(t *testing.T) {
s := NewStore()
_, _, err := s.CreateReach(c.holderID, c.publicKey)
if c.wantErr && err == nil {
t.Errorf("expected error, got nil")
}
if !c.wantErr && err != nil {
t.Errorf("unexpected error: %v", err)
}
})
}
}
func TestGetReachHitMiss(t *testing.T) {
s := NewStore()
if _, ok := s.GetReach("holder-alia"); !ok {
t.Errorf("GetReach(holder-alia) miss, want hit (seeded)")
}
if _, ok := s.GetReach("nobody"); ok {
t.Errorf("GetReach(nobody) hit, want miss")
}
}
func TestGetStashHitMiss(t *testing.T) {
s := NewStore()
if _, ok := s.GetStash("holder-alia"); !ok {
t.Errorf("GetStash(holder-alia) miss, want hit (seeded)")
}
if _, ok := s.GetStash("nobody"); ok {
t.Errorf("GetStash(nobody) hit, want miss")
}
}
func TestGetStashActivityHitMiss(t *testing.T) {
s := NewStore()
stash, ok := s.GetStash("holder-alia")
if !ok {
t.Fatal("seeded stash holder-alia missing")
}
if _, ok := s.GetStashActivity(stash.StashID); !ok {
t.Errorf("GetStashActivity(%q) miss, want hit", stash.StashID)
}
if _, ok := s.GetStashActivity("stash-nobody"); ok {
t.Errorf("GetStashActivity(stash-nobody) hit, want miss")
}
}
func TestCreateReachConcurrentNoRace(t *testing.T) {
s := NewStore()
const n = 50
var wg sync.WaitGroup
wg.Add(n)
for i := 0; i < n; i++ {
go func(i int) {
defer wg.Done()
holder := "holder-concurrent-" + itoa(i)
_, _, _ = s.CreateReach(holder, "pk")
}(i)
}
wg.Wait()
// All n concurrent creates with distinct holder IDs must be present.
for i := 0; i < n; i++ {
if _, ok := s.GetReach("holder-concurrent-" + itoa(i)); !ok {
t.Errorf("concurrent reach %d missing after wg.Wait", i)
}
}
}
func TestSeededMatureVsImmature(t *testing.T) {
s := NewStore()
// holder-alia: ActiveDays=92, MaxGapDays=10 -> mature.
aliaStash, ok := s.GetStash("holder-alia")
if !ok {
t.Fatal("seeded holder-alia missing")
}
aliaAct, ok := s.GetStashActivity(aliaStash.StashID)
if !ok {
t.Fatal("seeded alia activity missing")
}
if !aliaAct.IsMature() {
t.Errorf("holder-alia IsMature=false, want true (ActiveDays=%d, MaxGap=%d)",
aliaAct.ActiveDays, aliaAct.MaxGapDays)
}
// holder-bryn: ActiveDays=45, MaxGapDays=5 -> not mature.
brynStash, ok := s.GetStash("holder-bryn")
if !ok {
t.Fatal("seeded holder-bryn missing")
}
brynAct, ok := s.GetStashActivity(brynStash.StashID)
if !ok {
t.Fatal("seeded bryn activity missing")
}
if brynAct.IsMature() {
t.Errorf("holder-bryn IsMature=true, want false (ActiveDays=%d, MaxGap=%d)",
brynAct.ActiveDays, brynAct.MaxGapDays)
}
}
// Compile-time assertions that the types are the real x/*/types structs
// (D-067: the mock store grounds the UI in the real Go type definitions).
var _ identitytypes.Reach
var _ stashtypes.Stash
// itoa is provided by fixtures.go (shared with the production package).
func stringOf(r rune, n int) string {
b := make([]byte, n)
for i := range b {
b[i] = byte(r)
}
return string(b)
}
// --- Window tests (P3) ---
func TestOpenWindowCreatesStatusOpenWithInitialAudit(t *testing.T) {
s := NewStore()
scope := windowtypes.Scope{Kind: windowtypes.ScopeReadStash, ResourceID: "stash-x"}
rl := windowtypes.RateLimit{MaxActions: 5, PerDurationSeconds: 3600}
w, err := s.OpenWindow("holder-alia", "service-1", scope, 1000, 2000, rl)
if err != nil {
t.Fatalf("OpenWindow: %v", err)
}
if w.Status != windowtypes.StatusOpen {
t.Errorf("OpenWindow status %q, want Open", w.Status)
}
if w.WindowID == "" {
t.Error("OpenWindow: empty WindowID")
}
audit := s.GetAuditLog(w.WindowID)
if len(audit) != 1 {
t.Errorf("OpenWindow: audit log len %d, want 1", len(audit))
}
if audit[0].Action != "open" {
t.Errorf("OpenWindow: audit[0].Action %q, want open", audit[0].Action)
}
}
func TestOpenWindowValidation(t *testing.T) {
scope := windowtypes.Scope{Kind: windowtypes.ScopeReadStash}
rl := windowtypes.RateLimit{MaxActions: 1}
cases := []struct {
name, grantor, grantee string
wantErr bool
}{
{"empty grantor", "", "g", true},
{"empty grantee", "h", "", true},
{"valid", "h", "g", false},
}
for _, c := range cases {
t.Run(c.name, func(t *testing.T) {
s := NewStore()
_, err := s.OpenWindow(c.grantor, c.grantee, scope, 1, 2, rl)
if c.wantErr && err == nil {
t.Errorf("expected error, got nil")
}
if !c.wantErr && err != nil {
t.Errorf("unexpected error: %v", err)
}
})
}
}
func TestActivateWindowTransitionsToActive(t *testing.T) {
s := NewStore()
scope := windowtypes.Scope{Kind: windowtypes.ScopeReadStash}
rl := windowtypes.RateLimit{MaxActions: 1}
w, _ := s.OpenWindow("holder-alia", "svc", scope, 1, 2, rl)
if err := s.ActivateWindow(w.WindowID); err != nil {
t.Fatalf("ActivateWindow: %v", err)
}
updated, _ := s.GetWindow(w.WindowID)
if updated.Status != windowtypes.StatusActive {
t.Errorf("after activate: %q, want Active", updated.Status)
}
audit := s.GetAuditLog(w.WindowID)
if len(audit) != 2 {
t.Errorf("after activate: audit len %d, want 2", len(audit))
}
}
func TestActivateWindowNotFound(t *testing.T) {
s := NewStore()
if err := s.ActivateWindow("window-nobody"); err == nil {
t.Error("ActivateWindow(nobody): expected error, got nil")
}
}
func TestActivateWindowOnActiveFails(t *testing.T) {
s := NewStore()
scope := windowtypes.Scope{Kind: windowtypes.ScopeReadStash}
rl := windowtypes.RateLimit{MaxActions: 1}
w, _ := s.OpenWindow("holder-alia", "svc", scope, 1, 2, rl)
_ = s.ActivateWindow(w.WindowID)
// Activate again should fail (can only activate Open windows).
if err := s.ActivateWindow(w.WindowID); err == nil {
t.Error("activate on Active: expected error, got nil (Window.Activate rejects non-Open)")
}
}
func TestRevokeWindowTransitionsToRevoked(t *testing.T) {
s := NewStore()
scope := windowtypes.Scope{Kind: windowtypes.ScopeReadStash}
rl := windowtypes.RateLimit{MaxActions: 1}
w, _ := s.OpenWindow("holder-alia", "svc", scope, 1, 2, rl)
if err := s.RevokeWindow(w.WindowID); err != nil {
t.Fatalf("RevokeWindow: %v", err)
}
updated, _ := s.GetWindow(w.WindowID)
if updated.Status != windowtypes.StatusRevoked {
t.Errorf("after revoke: %q, want Revoked", updated.Status)
}
if !updated.Revoked {
t.Error("after revoke: Revoked flag false, want true")
}
}
func TestRevokeWindowIdempotent(t *testing.T) {
s := NewStore()
scope := windowtypes.Scope{Kind: windowtypes.ScopeReadStash}
rl := windowtypes.RateLimit{MaxActions: 1}
w, _ := s.OpenWindow("holder-alia", "svc", scope, 1, 2, rl)
_ = s.RevokeWindow(w.WindowID)
before := len(s.GetAuditLog(w.WindowID))
_ = s.RevokeWindow(w.WindowID)
after := len(s.GetAuditLog(w.WindowID))
if after != before {
t.Errorf("idempotent revoke: audit grew %d -> %d", before, after)
}
}
func TestRevokeWindowOnExpiredIsNoOp(t *testing.T) {
s := NewStore()
scope := windowtypes.Scope{Kind: windowtypes.ScopeReadStash}
rl := windowtypes.RateLimit{MaxActions: 1}
w, _ := s.OpenWindow("holder-alia", "svc", scope, 1, 2, rl)
_ = s.ExpireWindow(w.WindowID)
before := len(s.GetAuditLog(w.WindowID))
_ = s.RevokeWindow(w.WindowID)
updated, _ := s.GetWindow(w.WindowID)
if updated.Status != windowtypes.StatusExpired {
t.Errorf("revoke-on-expired: %q, want Expired (terminal wins)", updated.Status)
}
after := len(s.GetAuditLog(w.WindowID))
if after != before {
t.Errorf("revoke-on-expired: audit grew %d -> %d (no-op)", before, after)
}
}
func TestRevokeWindowNotFound(t *testing.T) {
s := NewStore()
if err := s.RevokeWindow("window-nobody"); err == nil {
t.Error("RevokeWindow(nobody): expected error, got nil")
}
}
func TestExpireWindowTransitionsToExpired(t *testing.T) {
s := NewStore()
scope := windowtypes.Scope{Kind: windowtypes.ScopeReadStash}
rl := windowtypes.RateLimit{MaxActions: 1}
w, _ := s.OpenWindow("holder-alia", "svc", scope, 1, 2, rl)
if err := s.ExpireWindow(w.WindowID); err != nil {
t.Fatalf("ExpireWindow: %v", err)
}
updated, _ := s.GetWindow(w.WindowID)
if updated.Status != windowtypes.StatusExpired {
t.Errorf("after expire: %q, want Expired", updated.Status)
}
}
func TestExpireWindowNotFound(t *testing.T) {
s := NewStore()
if err := s.ExpireWindow("window-nobody"); err == nil {
t.Error("ExpireWindow(nobody): expected error, got nil")
}
}
func TestExpireWindowIdempotent(t *testing.T) {
s := NewStore()
scope := windowtypes.Scope{Kind: windowtypes.ScopeReadStash}
rl := windowtypes.RateLimit{MaxActions: 1}
w, _ := s.OpenWindow("holder-alia", "svc", scope, 1, 2, rl)
_ = s.ExpireWindow(w.WindowID)
before := len(s.GetAuditLog(w.WindowID))
_ = s.ExpireWindow(w.WindowID)
after := len(s.GetAuditLog(w.WindowID))
if after != before {
t.Errorf("idempotent expire: audit grew %d -> %d", before, after)
}
}
func TestListWindowsFiltersByGrantor(t *testing.T) {
s := NewStore()
scope := windowtypes.Scope{Kind: windowtypes.ScopeReadStash}
rl := windowtypes.RateLimit{MaxActions: 1}
_, _ = s.OpenWindow("holder-alia", "svc1", scope, 1, 2, rl)
_, _ = s.OpenWindow("holder-alia", "svc2", scope, 1, 2, rl)
_, _ = s.OpenWindow("holder-bryn", "svc3", scope, 1, 2, rl)
alia := s.ListWindows("holder-alia")
if len(alia) != 2 {
t.Errorf("ListWindows(holder-alia) = %d, want 2", len(alia))
}
bryn := s.ListWindows("holder-bryn")
if len(bryn) != 1 {
t.Errorf("ListWindows(holder-bryn) = %d, want 1", len(bryn))
}
nobody := s.ListWindows("nobody")
if len(nobody) != 0 {
t.Errorf("ListWindows(nobody) = %d, want 0", len(nobody))
}
}
func TestGetWindowHitMiss(t *testing.T) {
s := NewStore()
scope := windowtypes.Scope{Kind: windowtypes.ScopeReadStash}
rl := windowtypes.RateLimit{MaxActions: 1}
w, _ := s.OpenWindow("holder-alia", "svc", scope, 1, 2, rl)
if _, ok := s.GetWindow(w.WindowID); !ok {
t.Errorf("GetWindow(%q) miss, want hit", w.WindowID)
}
if _, ok := s.GetWindow("window-nobody"); ok {
t.Error("GetWindow(nobody) hit, want miss")
}
}
func TestGetAuditLogEmptyForMissing(t *testing.T) {
s := NewStore()
if logs := s.GetAuditLog("window-nobody"); logs != nil {
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)")
}
}
// --- 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))
}
}
+29
View File
@@ -0,0 +1,29 @@
{{define "base.html"}}
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>{{block "title" .}}OpenYield{{end}}</title>
<link rel="stylesheet" href="/static/style.css">
<script src="/static/htmx.min.js" defer></script>
</head>
<body>
<header class="nav">
<span class="brand">OpenYield</span>
<a href="/">Home</a>
<a href="/reach">Reach</a>
<a href="/stash">Stash</a>
<a href="/window">Window</a>
<a href="/standing">Standing</a>
<a href="/bloom">Bloom</a>
</header>
<main>
{{block "content" .}}{{end}}
</main>
<footer>
OpenYield — real production on the mesh. Reach, Stash, Window, Standing, Bloom.
</footer>
</body>
</html>
{{end}}
+33
View File
@@ -0,0 +1,33 @@
{{define "title"}}Bloom — OpenYield{{end}}
{{define "content"}}
<section class="panel">
<h1>Bloom</h1>
<p>Bloom is the real-production reward that accrues to every Grain in every
Stash. It originates only from real production — no synthetic Bloom, no
protocol-printed Bloom. This is a Mission Lock: no council can change it.</p>
</section>
<section class="panel">
<h2>Bloom record for {{.StashID}}</h2>
<table class="kv">
<tr><th>Stash ID</th><td>{{.StashID}}</td></tr>
<tr><th>Accrued Grain</th><td>{{.Record.AccruedGrain}}</td></tr>
<tr><th>Rate</th><td>{{printf "%.1f" .RatePct}}%</td></tr>
<tr><th>Last accrual block</th><td>{{.Record.LastAccrualBlock}}</td></tr>
</table>
</section>
<section class="panel">
<h2>Target rate band</h2>
<table class="kv">
<tr><th>Target rate</th><td>{{printf "%.1f" .TargetRatePct}}%</td></tr>
<tr><th>Min rate</th><td>{{printf "%.1f" .MinRatePct}}%</td></tr>
<tr><th>Max rate</th><td>{{printf "%.1f" .MaxRatePct}}%</td></tr>
<tr><th>Accrual period</th><td>{{.AccrualPeriod}} blocks (daily, ~10min blocks)</td></tr>
</table>
<p><em>{{.MissionLockNote}}</em></p>
</section>
<p><a href="/stash/{{slice .StashID 6}}">Back to Stash</a></p>
{{end}}
+24
View File
@@ -0,0 +1,24 @@
{{define "title"}}OpenYield — real production on the mesh{{end}}
{{define "content"}}
<section class="panel">
<h1>OpenYield</h1>
<p>
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.
</p>
</section>
<section class="panel">
<h2>The five screens</h2>
<ul>
<li><a href="/reach">Reach</a> — create a Reach and view the mesh of Holders.</li>
<li><a href="/stash">Stash</a> — your sovereign Grain Stash (P2).</li>
<li><a href="/window">Window</a> — authorize partner access to your Stash (P3).</li>
<li><a href="/standing">Standing</a> — track progress toward Freeholder standing (P4).</li>
<li><a href="/bloom">Bloom</a> — accrued rewards for sustained contribution (P5).</li>
</ul>
</section>
{{end}}
+31
View File
@@ -0,0 +1,31 @@
{{define "title"}}{{.Reach.ReachID}} — OpenYield{{end}}
{{define "content"}}
<section class="panel">
<h1>{{.Reach.ReachID}}</h1>
<table class="kv">
<tr><th>Reach ID</th><td>{{.Reach.ReachID}}</td></tr>
<tr><th>Holder ID</th><td>{{.Reach.HolderID}}</td></tr>
<tr><th>Public Key</th><td><code>{{.Reach.PublicKey}}</code></td></tr>
<tr><th>Created</th><td>{{.Reach.CreatedAt}}</td></tr>
<tr><th>Nomad</th><td>{{if .Reach.IsNomad}}yes{{else}}no{{end}}</td></tr>
<tr><th>Freeholder</th><td>{{if .Reach.IsFreeholder}}yes{{else}}no{{end}}</td></tr>
</table>
</section>
{{if .Stash.StashID}}
<section class="panel">
<h2>Stash</h2>
<table class="kv">
<tr><th>Stash ID</th><td>{{.Stash.StashID}}</td></tr>
<tr><th>Balance</th><td>{{.Stash.BalanceGrain}} Grain</td></tr>
<tr><th>Created</th><td>{{.Stash.CreatedAt}}</td></tr>
<tr><th>Last active</th><td>{{.Stash.LastActive}}</td></tr>
<tr><th>Still</th><td>{{if .Stash.IsStill}}paused{{else}}active{{end}}</td></tr>
</table>
<p><a href="/stash/{{.Stash.HolderID}}">View Stash dashboard</a></p>
</section>
{{end}}
<p><a href="/reach">Back to Reach list</a></p>
{{end}}
+35
View File
@@ -0,0 +1,35 @@
{{define "title"}}Reach — OpenYield{{end}}
{{define "content"}}
<section class="panel">
<h1>Reach</h1>
<p>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.</p>
<p><a href="/reach/new" class="btn">Create a Reach</a></p>
</section>
<section class="panel">
<h2>Holders on the mesh</h2>
{{if .Reaches}}
<table>
<thead>
<tr><th>Reach ID</th><th>Holder ID</th><th>Nomad</th><th>Freeholder</th></tr>
</thead>
<tbody>
{{range .Reaches}}
<tr>
<td><a href="/reach/{{.HolderID}}">{{.ReachID}}</a></td>
<td>{{.HolderID}}</td>
<td>{{if .IsNomad}}yes{{else}}no{{end}}</td>
<td>{{if .IsFreeholder}}yes{{else}}no{{end}}</td>
</tr>
{{end}}
</tbody>
</table>
{{else}}
<p>No Reaches yet. <a href="/reach/new">Create a Reach</a> to begin.</p>
{{end}}
</section>
{{end}}
+22
View File
@@ -0,0 +1,22 @@
{{define "title"}}Create a Reach — OpenYield{{end}}
{{define "content"}}
<section class="panel">
<h1>Create a Reach</h1>
<p>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.</p>
<form method="POST" action="/reach" hx-post="/reach" hx-target="body">
<label for="holder_id">Holder ID</label>
<input type="text" id="holder_id" name="holder_id" required
maxlength="128" placeholder="a by-ID-string of your choosing">
<label for="public_key">Public Key</label>
<input type="text" id="public_key" name="public_key" required
maxlength="128" placeholder="a public key for your Reach">
<button type="submit">Create a Reach</button>
</form>
<p><a href="/reach">Back to Reach list</a></p>
</section>
{{end}}
+89
View File
@@ -0,0 +1,89 @@
{{define "title"}}Standing — OpenYield{{end}}
{{define "content"}}
<section class="panel">
<h1>Standing — {{.ReachID}}</h1>
<p>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.</p>
</section>
<section class="panel">
<h2>Score</h2>
<table class="kv">
<tr><th>Reach ID</th><td>{{.ReachID}}</td></tr>
<tr><th>Standing score</th><td>{{printf "%.1f" .Score}}</td></tr>
<tr><th>Bucket</th><td>
{{if eq (string .Bucket) "New"}}<span class="badge grey">New</span>{{end}}
{{if eq (string .Bucket) "Trusted"}}<span class="badge blue">Trusted</span>{{end}}
{{if eq (string .Bucket) "Preferred"}}<span class="badge green">Preferred</span>{{end}}
{{if eq (string .Bucket) "Top"}}<span class="badge green">Top</span>{{end}}
{{if eq (string .Bucket) "Slashed"}}<span class="badge red">Slashed</span>{{end}}
</td></tr>
</table>
</section>
<section class="panel">
<h2>Freeholder signals</h2>
<p>The four signals (§9.1) — all four must be present to be Freeholder-eligible.
No application, no committee, no form.</p>
<table>
<thead><tr><th>Signal</th><th>Status</th></tr></thead>
<tbody>
<tr><td>Stash maturity (90 days, gap ≤ 30)</td><td>{{if .Signals.StashMaturity}}<span class="badge green">earned</span>{{else}}<span class="badge grey">not yet</span>{{end}}</td></tr>
<tr><td>Multi-domain standing (≥ {{printf "%.1f" .MinScore}} in ≥ {{.MinCats}} cats)</td><td>{{if .Signals.MultiDomainStanding}}<span class="badge green">earned</span>{{else}}<span class="badge grey">not yet</span>{{end}}</td></tr>
<tr><td>Committed capital</td><td>{{if .Signals.CommittedCapital}}<span class="badge green">earned</span>{{else}}<span class="badge grey">not yet</span>{{end}}</td></tr>
<tr><td>Community endorsement (≥ 1 Vouch)</td><td>{{if .Signals.CommunityEndorsement}}<span class="badge green">earned</span>{{else}}<span class="badge grey">not yet</span>{{end}}</td></tr>
</tbody>
</table>
<p>Freeholder-eligible:
{{if .Eligible}}<span class="badge green">yes</span>
{{else}}<span class="badge grey">not yet</span>{{end}}
</p>
</section>
<section class="panel">
<h2>Ratings ({{len .Ratings}})</h2>
{{if .Ratings}}
<table>
<thead><tr><th>Rater</th><th>Category</th><th>Score</th><th>Timestamp</th></tr></thead>
<tbody>
{{range .Ratings}}
<tr><td>{{.RaterID}}</td><td>{{.Category}}</td><td>{{printf "%.1f" .Score}}</td><td>{{.Timestamp}}</td></tr>
{{end}}
</tbody>
</table>
{{else}}<p>No ratings yet.</p>{{end}}
</section>
<section class="panel">
<h2>Vouches ({{len .Vouches}})</h2>
{{if .Vouches}}
<table>
<thead><tr><th>Voucher</th><th>Category</th><th>Bond (Grain)</th></tr></thead>
<tbody>
{{range .Vouches}}
<tr><td>{{.VoucherID}}</td><td>{{.Category}}</td><td>{{.BondAmount}}</td></tr>
{{end}}
</tbody>
</table>
{{else}}<p>No Vouches yet.</p>{{end}}
</section>
{{if .Slashes}}
<section class="panel">
<h2>Slashes ({{len .Slashes}})</h2>
<table>
<thead><tr><th>Reason</th><th>Amount</th><th>Attester</th></tr></thead>
<tbody>
{{range .Slashes}}
<tr><td>{{.Reason}}</td><td>{{.Amount}}</td><td>{{.Attester}}</td></tr>
{{end}}
</tbody>
</table>
</section>
{{end}}
<p><a href="/reach/{{.ReachID}}">Back to Reach</a></p>
{{end}}
+56
View File
@@ -0,0 +1,56 @@
{{define "title"}}Stash — OpenYield{{end}}
{{define "content"}}
<section class="panel">
<h1>Stash</h1>
<p>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.</p>
</section>
<section class="panel">
<h2>Balance</h2>
<table class="kv">
<tr><th>Stash ID</th><td>{{.Stash.StashID}}</td></tr>
<tr><th>Holder ID</th><td>{{.Stash.HolderID}}</td></tr>
<tr><th>Balance</th><td>{{.Stash.BalanceGrain}} Grain ({{.BalanceBread}} Bread)</td></tr>
<tr><th>Created</th><td>{{.Stash.CreatedAt}}</td></tr>
<tr><th>Last active</th><td>{{.Stash.LastActive}}</td></tr>
<tr><th>Still</th><td>{{if .Stash.IsStill}}paused{{else}}active{{end}}</td></tr>
</table>
</section>
<section class="panel">
<h2>Bread scale</h2>
<p>1 Bread = 10,000 Grain. The full scale (from the protocol code constants):</p>
<table>
<thead><tr><th>Denomination</th><th>Grain value</th><th>Equivalent in this Stash</th></tr></thead>
<tbody>
{{range .BreadScale}}
<tr>
<td>{{.Name}}</td>
<td>{{.GrainValue}}</td>
<td>{{if eq .Name "Grain"}}{{$.Stash.BalanceGrain}}{{else}}{{divGrain $.Stash.BalanceGrain .GrainValue}}{{end}}</td>
</tr>
{{end}}
</tbody>
</table>
</section>
<section class="panel">
<h2>Maturity progress</h2>
<p>Holding a Stash continuously for 90 days is the first of the four
Freeholder signals. The signal is about continuity, not size.</p>
<div class="progress-track">
<div class="progress-bar" style="width: {{.MaturityPct}}%">{{.MaturityPct}}%</div>
</div>
<table class="kv">
<tr><th>Active days</th><td>{{.Activity.ActiveDays}} / {{.ThresholdDays}}</td></tr>
<tr><th>Max gap days</th><td>{{.Activity.MaxGapDays}} / {{.MaxGapDays}} (max allowed)</td></tr>
<tr><th>Mature</th><td>{{if .Mature}}<span class="badge green">Mature</span>{{else}}<span class="badge amber">Not mature</span>{{end}}</td></tr>
</table>
</section>
<p><a href="/reach/{{.Stash.HolderID}}">Back to Reach</a></p>
{{end}}
+68
View File
@@ -0,0 +1,68 @@
{{define "title"}}{{.Window.WindowID}} — OpenYield{{end}}
{{define "content"}}
<section class="panel">
<h1>{{.Window.WindowID}}</h1>
<table class="kv">
<tr><th>Window ID</th><td>{{.Window.WindowID}}</td></tr>
<tr><th>Grantor</th><td>{{.Window.GrantorHolder}}</td></tr>
<tr><th>Grantee</th><td>{{.Window.Grantee}}</td></tr>
<tr><th>Scope</th><td>{{.Window.Scope.Kind}} ({{.Window.Scope.ResourceID}})</td></tr>
<tr><th>Start</th><td>{{.Window.Start}}</td></tr>
<tr><th>End</th><td>{{.Window.End}}</td></tr>
<tr><th>Rate limit</th><td>{{.Window.RateLimit.ActionsConsumed}} / {{.Window.RateLimit.MaxActions}} per {{.Window.RateLimit.PerDurationSeconds}}s</td></tr>
<tr><th>Revoked</th><td>{{if .Window.Revoked}}yes{{else}}no{{end}}</td></tr>
<tr><th>Status</th><td>
{{if eq (string .Window.Status) "Open"}}<span class="badge amber">Open</span>{{end}}
{{if eq (string .Window.Status) "Active"}}<span class="badge green">Active</span>{{end}}
{{if eq (string .Window.Status) "Revoked"}}<span class="badge red">Revoked</span>{{end}}
{{if eq (string .Window.Status) "Expired"}}<span class="badge grey">Expired</span>{{end}}
</td></tr>
</table>
</section>
<section class="panel">
<h2>Lifecycle actions</h2>
<p>
{{if eq (string .Window.Status) "Open"}}
<form method="POST" action="/window/{{.Window.WindowID}}/activate" style="display:inline">
<button type="submit">Activate</button>
</form>
{{end}}
{{if or (eq (string .Window.Status) "Open") (eq (string .Window.Status) "Active")}}
<form method="POST" action="/window/{{.Window.WindowID}}/revoke" style="display:inline">
<button type="submit">Revoke</button>
</form>
{{end}}
{{if or (eq (string .Window.Status) "Open") (eq (string .Window.Status) "Active")}}
<form method="POST" action="/window/{{.Window.WindowID}}/expire" style="display:inline">
<button type="submit">Expire</button>
</form>
{{end}}
</p>
</section>
<section class="panel">
<h2>Audit log</h2>
{{if .AuditLog}}
<table>
<thead><tr><th>Entry ID</th><th>Timestamp</th><th>Action</th><th>Result</th><th>Granter</th></tr></thead>
<tbody>
{{range .AuditLog}}
<tr>
<td>{{.EntryID}}</td>
<td>{{.Timestamp}}</td>
<td>{{.Action}}</td>
<td>{{.Result}}</td>
<td>{{.GranterRef}}</td>
</tr>
{{end}}
</tbody>
</table>
{{else}}
<p>No audit entries yet.</p>
{{end}}
</section>
<p><a href="/window">Back to Window list</a></p>
{{end}}
+33
View File
@@ -0,0 +1,33 @@
{{define "title"}}Window — OpenYield{{end}}
{{define "content"}}
<section class="panel">
<h1>Window</h1>
<p>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.</p>
<p><a href="/window/new" class="btn">Open a Window</a></p>
</section>
<section class="panel">
<h2>Windows for {{.Grantor}}</h2>
{{if .Windows}}
<table>
<thead><tr><th>Window ID</th><th>Grantee</th><th>Scope</th><th>Status</th></tr></thead>
<tbody>
{{range .Windows}}
<tr>
<td><a href="/window/{{.WindowID}}">{{.WindowID}}</a></td>
<td>{{.Grantee}}</td>
<td>{{.Scope.Kind}} ({{.Scope.ResourceID}})</td>
<td>{{.Status}}</td>
</tr>
{{end}}
</tbody>
</table>
{{else}}
<p>No Windows yet for {{.Grantor}}. <a href="/window/new">Open a Window</a> to begin.</p>
{{end}}
</section>
{{end}}
+36
View File
@@ -0,0 +1,36 @@
{{define "title"}}Open a Window — OpenYield{{end}}
{{define "content"}}
<section class="panel">
<h1>Open a Window</h1>
<p>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.</p>
<form method="POST" action="/window" hx-post="/window" hx-target="body">
<label for="grantor_holder">Grantor Holder ID</label>
<input type="text" id="grantor_holder" name="grantor_holder" required
maxlength="128" placeholder="the Holder opening the Window">
<label for="grantee">Grantee</label>
<input type="text" id="grantee" name="grantee" required
maxlength="128" placeholder="the partner or service receiving access">
<label for="scope_kind">Scope kind</label>
<select id="scope_kind" name="scope_kind">
<option value="ReadStash">ReadStash</option>
<option value="ReadStanding">ReadStanding</option>
<option value="ProcessPassActForStand">ProcessPassActForStand</option>
</select>
<label for="resource_id">Resource ID</label>
<input type="text" id="resource_id" name="resource_id"
maxlength="128" placeholder="the Stash or Stand this Window scopes to">
<label for="start_unix">Start (unix seconds, blank = now)</label>
<input type="number" id="start_unix" name="start_unix" placeholder="blank = now">
<label for="end_unix">End (unix seconds, blank = now+1h)</label>
<input type="number" id="end_unix" name="end_unix" placeholder="blank = now+1h">
<label for="max_actions">Max actions (rate-limit, blank = 10)</label>
<input type="number" id="max_actions" name="max_actions" placeholder="10">
<button type="submit">Open a Window</button>
</form>
<p><a href="/window">Back to Window list</a></p>
</section>
{{end}}
+166
View File
@@ -0,0 +1,166 @@
package keeper
import (
"encoding/json"
"fmt"
storetypes "cosmossdk.io/store/types"
"github.com/cosmos/cosmos-sdk/codec"
sdk "github.com/cosmos/cosmos-sdk/types"
"github.com/oy/openyield/x/bearers/types"
)
// keeper.go holds the store-backed Keeper for the bearers module (P2-02-01,
// REQ-034).
//
// The Keeper wraps an sdk.KVStore via a storeKey. It holds the Session
// records (by session-id) and the OYQRCode records (by qr-id). The Keeper
// also holds the expected-keeper shim (BreadKeeper for the OY-QR consume
// transfer effect). The shim is an interface (G-003 — no struct import of
// x/bread/types); the concrete x/bread keeper satisfies it structurally.
//
// State-machine ordering (vision §7, enforced in every handler):
// ValidateBasic → keeper authz → state mutation → ctx.EventManager().EmitEvent
//
// Surveillance-resistant invariant (A-522): the Keeper carries NO
// geolocation fields; the handlers emit NO geolocation in events.
// Keeper is the store-backed bearers keeper.
type Keeper struct {
cdc codec.Codec
storeKey storetypes.StoreKey
breadKeeper types.BreadKeeper
}
// NewKeeper constructs a new store-backed bearers Keeper. The BreadKeeper
// expected-keeper shim is injected (nil-able for partial tests; the
// ConsumeOYQR handler guards a nil shim and skips the transfer effect,
// still flipping the consumed flag — the A-521 state-write-first invariant
// holds regardless).
func NewKeeper(cdc codec.Codec, storeKey storetypes.StoreKey, bk types.BreadKeeper) Keeper {
return Keeper{
cdc: cdc,
storeKey: storeKey,
breadKeeper: bk,
}
}
// SetBreadKeeper sets the BreadKeeper expected-keeper shim (for
// post-construction wiring, e.g., app wiring or test setup).
func (k *Keeper) SetBreadKeeper(bk types.BreadKeeper) { k.breadKeeper = bk }
// --- Session store -----------------------------------------------------------
var sessionKeyPrefix = []byte("session/")
func sessionKey(sessionID string) []byte {
return append(sessionKeyPrefix, []byte(sessionID)...)
}
// GetSession loads a Session by session-id. Returns the session and true
// if found, or zero value + false if not.
func (k Keeper) GetSession(ctx sdk.Context, sessionID string) (types.Session, bool) {
store := ctx.KVStore(k.storeKey)
bz := store.Get(sessionKey(sessionID))
if bz == nil {
return types.Session{}, false
}
var s types.Session
if err := json.Unmarshal(bz, &s); err != nil {
return types.Session{}, false
}
return s, true
}
// SetSession persists a Session by session-id.
func (k Keeper) SetSession(ctx sdk.Context, s types.Session) {
store := ctx.KVStore(k.storeKey)
bz, err := json.Marshal(s)
if err != nil {
panic(fmt.Sprintf("bearers: marshal session %q: %v", s.SessionID, err))
}
store.Set(sessionKey(s.SessionID), bz)
}
// AllSessions returns all persisted Session records (iteration helper).
func (k Keeper) AllSessions(ctx sdk.Context) []types.Session {
store := ctx.KVStore(k.storeKey)
iterator := store.Iterator(sessionKeyPrefix, prefixEnd(sessionKeyPrefix))
defer iterator.Close()
out := []types.Session{}
for ; iterator.Valid(); iterator.Next() {
var s types.Session
if err := json.Unmarshal(iterator.Value(), &s); err == nil {
out = append(out, s)
}
}
return out
}
// --- OYQRCode store ----------------------------------------------------------
var qrKeyPrefix = []byte("qr/")
func qrKey(qrID string) []byte {
return append(qrKeyPrefix, []byte(qrID)...)
}
// GetOYQRCode loads an OYQRCode by qr-id. Returns the QR and true if found.
func (k Keeper) GetOYQRCode(ctx sdk.Context, qrID string) (types.OYQRCode, bool) {
store := ctx.KVStore(k.storeKey)
bz := store.Get(qrKey(qrID))
if bz == nil {
return types.OYQRCode{}, false
}
var q types.OYQRCode
if err := json.Unmarshal(bz, &q); err != nil {
return types.OYQRCode{}, false
}
return q, true
}
// SetOYQRCode persists an OYQRCode by qr-id.
func (k Keeper) SetOYQRCode(ctx sdk.Context, q types.OYQRCode) {
store := ctx.KVStore(k.storeKey)
bz, err := json.Marshal(q)
if err != nil {
panic(fmt.Sprintf("bearers: marshal qr %q: %v", q.QRID, err))
}
store.Set(qrKey(q.QRID), bz)
}
// AllOYQRCodes returns all persisted OYQRCode records (iteration helper).
func (k Keeper) AllOYQRCodes(ctx sdk.Context) []types.OYQRCode {
store := ctx.KVStore(k.storeKey)
iterator := store.Iterator(qrKeyPrefix, prefixEnd(qrKeyPrefix))
defer iterator.Close()
out := []types.OYQRCode{}
for ; iterator.Valid(); iterator.Next() {
var q types.OYQRCode
if err := json.Unmarshal(iterator.Value(), &q); err == nil {
out = append(out, q)
}
}
return out
}
// prefixEnd returns the key that sorts immediately after all keys sharing the
// given prefix (the standard prefix-iteration end key: increment the last
// byte, drop overflow). Used for store.Iterator(start, prefixEnd(start))
// prefix scans.
func prefixEnd(prefix []byte) []byte {
if len(prefix) == 0 {
return nil
}
end := make([]byte, len(prefix))
copy(end, prefix)
for i := len(end) - 1; i >= 0; i-- {
end[i]++
if end[i] != 0 {
return end
}
}
// All bytes were 0xFF; return nil (iterate to end of store).
return nil
}
+387
View File
@@ -0,0 +1,387 @@
package keeper
import (
"fmt"
sdk "github.com/cosmos/cosmos-sdk/types"
"github.com/oy/openyield/x/bearers/types"
)
// msg_server.go implements the bearers module's MsgServer (G-023 ownership
// split: cosmos-engineer scaffolds the file structure + method signatures;
// mesh-engineer/backend-engineer implements the handler logic bodies). The
// MsgServer wraps the Keeper + the BreadKeeper expected-keeper shim (already
// on the Keeper).
//
// Each method returns a (*Response, error). Handler state-machine ordering
// is enforced: ValidateBasic → keeper authz → state mutation →
// ctx.EventManager().EmitEvent.
//
// Surveillance-resistant invariant (A-522): NO handler emits geolocation or
// sender physical location. The surveillance-resistant locked const on
// OYSATLink/OYLRLink is a runtime invariant — a handler that emits
// geolocation violates it. A negative simtest asserts the event set
// contains NO geolocation fields.
//
// One-shot OY-QR (A-521): the MsgConsumeOYQR handler flips consumed BEFORE
// the transfer effect (state write FIRST, then the BreadKeeper shim call).
// A replay finds consumed==true and returns an error (idempotent reject,
// NOT double-effect). The SDK store is atomic per tx — a panic in the
// transfer rolls back the whole tx, so the order is safe; the order
// documents intent and matches the ibc-go delete-before-mint convention.
// msgServer is the concrete MsgServer implementation wrapping the Keeper.
type msgServer struct {
Keeper
}
// NewMsgServerImpl returns the bearers MsgServer for the provided Keeper.
func NewMsgServerImpl(k Keeper) types.MsgServer {
return &msgServer{Keeper: k}
}
var _ types.MsgServer = msgServer{}
// unwrapCtx extracts the sdk.Context from the interface-typed ctx.
func unwrapCtx(ctx interface{}) sdk.Context {
if c, ok := ctx.(sdk.Context); ok {
return c
}
panic(fmt.Sprintf("bearers: expected sdk.Context, got %T", ctx))
}
// nowUnix returns the current block time as unix seconds from the ctx.
func nowUnix(ctx sdk.Context) int64 {
return ctx.BlockTime().Unix()
}
// --- OpenSession (creates Session status=Open) -------------------------------
// OpenSession creates a new Session with status=Open. ValidateBasic is
// stateless; the handler enforces idempotency (session-id must not already
// exist).
func (s msgServer) OpenSession(ctx interface{}, msg *types.MsgOpenSession) (*types.MsgOpenSessionResponse, error) {
if err := msg.ValidateBasic(); err != nil {
return nil, err
}
sdkCtx := unwrapCtx(ctx)
// Idempotency: session-id must not already exist.
if _, ok := s.Keeper.GetSession(sdkCtx, msg.SessionID); ok {
return nil, fmt.Errorf("bearers: session %q already exists", msg.SessionID)
}
session := types.Session{
SessionID: msg.SessionID,
BearerType: msg.BearerType,
InitiatorReach: msg.InitiatorReach,
PeerReach: msg.PeerReach,
Status: types.SessionOpen,
Frames: []types.Frame{},
TTL: msg.TTL,
OpenedAt: nowUnix(sdkCtx),
}
s.Keeper.SetSession(sdkCtx, session)
sdkCtx.EventManager().EmitEvent(sdk.NewEvent(
"bearers.session_opened",
sdk.NewAttribute("session_id", msg.SessionID),
sdk.NewAttribute("bearer_type", string(msg.BearerType)),
sdk.NewAttribute("initiator_reach", msg.InitiatorReach),
sdk.NewAttribute("peer_reach", msg.PeerReach),
sdk.NewAttribute("status", string(types.SessionOpen)),
// NO geolocation (A-522 surveillance-resistant invariant).
))
return &types.MsgOpenSessionResponse{}, nil
}
// --- CloseSession (Active → Closed) ------------------------------------------
// CloseSession transitions an Active session to Closed. The handler
// enforces the stateful source-status check (must be Open or Active; an
// Open session with no frames can close directly).
func (s msgServer) CloseSession(ctx interface{}, msg *types.MsgCloseSession) (*types.MsgCloseSessionResponse, error) {
if err := msg.ValidateBasic(); err != nil {
return nil, err
}
sdkCtx := unwrapCtx(ctx)
session, ok := s.Keeper.GetSession(sdkCtx, msg.SessionID)
if !ok {
return nil, fmt.Errorf("bearers: session %q not found", msg.SessionID)
}
if session.IsTerminal() {
return nil, fmt.Errorf("bearers: session %q is terminal (%s), cannot close", msg.SessionID, session.Status)
}
session.Status = types.SessionClosed
session.ClosedAt = nowUnix(sdkCtx)
s.Keeper.SetSession(sdkCtx, session)
sdkCtx.EventManager().EmitEvent(sdk.NewEvent(
"bearers.session_closed",
sdk.NewAttribute("session_id", msg.SessionID),
sdk.NewAttribute("status", string(types.SessionClosed)),
))
return &types.MsgCloseSessionResponse{}, nil
}
// --- RevokeSession (out-of-band → Revoked) -----------------------------------
// RevokeSession transitions a session to Revoked (out-of-band termination).
// A revoked session rejects further Receive. The handler enforces the
// stateful source-status check (must not already be terminal).
func (s msgServer) RevokeSession(ctx interface{}, msg *types.MsgRevokeSession) (*types.MsgRevokeSessionResponse, error) {
if err := msg.ValidateBasic(); err != nil {
return nil, err
}
sdkCtx := unwrapCtx(ctx)
session, ok := s.Keeper.GetSession(sdkCtx, msg.SessionID)
if !ok {
return nil, fmt.Errorf("bearers: session %q not found", msg.SessionID)
}
if session.IsTerminal() {
return nil, fmt.Errorf("bearers: session %q is terminal (%s), cannot revoke", msg.SessionID, session.Status)
}
session.Status = types.SessionRevoked
session.ClosedAt = nowUnix(sdkCtx)
s.Keeper.SetSession(sdkCtx, session)
sdkCtx.EventManager().EmitEvent(sdk.NewEvent(
"bearers.session_revoked",
sdk.NewAttribute("session_id", msg.SessionID),
sdk.NewAttribute("status", string(types.SessionRevoked)),
))
return &types.MsgRevokeSessionResponse{}, nil
}
// --- SendOYSATFrame (send a frame on an Open/Active session) ------------------
// SendOYSATFrame sends a frame on an OY-SAT session. The handler enforces
// the stateful session-status check: the session must be Open or Active
// (frames on Closed/Revoked are REJECTED — the rejected-frame case).
func (s msgServer) SendOYSATFrame(ctx interface{}, msg *types.MsgSendOYSATFrame) (*types.MsgSendOYSATFrameResponse, error) {
if err := msg.ValidateBasic(); err != nil {
return nil, err
}
sdkCtx := unwrapCtx(ctx)
session, ok := s.Keeper.GetSession(sdkCtx, msg.SessionID)
if !ok {
return nil, fmt.Errorf("bearers: session %q not found", msg.SessionID)
}
if session.IsTerminal() {
// Rejected-frame case: a frame received on a Closed/Revoked
// session MUST be rejected (A-523 session state machine).
return nil, fmt.Errorf("bearers: session %q is terminal (%s), rejects frame", msg.SessionID, session.Status)
}
if session.IsExpired(nowUnix(sdkCtx)) {
// TTL expiry transitions the session to Closed (the handler
// enforces expiry on Send/Receive checks).
session.Status = types.SessionClosed
session.ClosedAt = nowUnix(sdkCtx)
s.Keeper.SetSession(sdkCtx, session)
return nil, fmt.Errorf("bearers: session %q expired (ttl %d), rejects frame", msg.SessionID, session.TTL)
}
frame := types.Frame{
FrameID: msg.FrameID,
SenderReach: msg.Signer,
PayloadBytes: msg.PayloadBytes,
SentAt: nowUnix(sdkCtx),
}
session.Frames = append(session.Frames, frame)
s.Keeper.SetSession(sdkCtx, session)
sdkCtx.EventManager().EmitEvent(sdk.NewEvent(
"bearers.frame_sent",
sdk.NewAttribute("session_id", msg.SessionID),
sdk.NewAttribute("frame_id", msg.FrameID),
sdk.NewAttribute("sender_reach", msg.Signer),
// NO geolocation (A-522 surveillance-resistant invariant).
))
return &types.MsgSendOYSATFrameResponse{}, nil
}
// --- ReceiveOYSATFrame (ack a frame; Open → Active on first ack) -------------
// ReceiveOYSATFrame acknowledges receipt of an OY-SAT frame. The handler
// transitions the session Open → Active on the first ack. The handler
// enforces the stateful session-status check: the session must be Open or
// Active (acks on Closed/Revoked are REJECTED — the rejected-frame case).
func (s msgServer) ReceiveOYSATFrame(ctx interface{}, msg *types.MsgReceiveOYSATFrame) (*types.MsgReceiveOYSATFrameResponse, error) {
if err := msg.ValidateBasic(); err != nil {
return nil, err
}
sdkCtx := unwrapCtx(ctx)
session, ok := s.Keeper.GetSession(sdkCtx, msg.SessionID)
if !ok {
return nil, fmt.Errorf("bearers: session %q not found", msg.SessionID)
}
if session.IsTerminal() {
// Rejected-frame case: an ack received on a Closed/Revoked
// session MUST be rejected (A-523 session state machine).
return nil, fmt.Errorf("bearers: session %q is terminal (%s), rejects ack", msg.SessionID, session.Status)
}
if session.IsExpired(nowUnix(sdkCtx)) {
session.Status = types.SessionClosed
session.ClosedAt = nowUnix(sdkCtx)
s.Keeper.SetSession(sdkCtx, session)
return nil, fmt.Errorf("bearers: session %q expired (ttl %d), rejects ack", msg.SessionID, session.TTL)
}
// Find the named frame; mark it received.
found := false
for i := range session.Frames {
if session.Frames[i].FrameID == msg.FrameID {
session.Frames[i].Received = true
found = true
break
}
}
if !found {
return nil, fmt.Errorf("bearers: frame %q not found on session %q", msg.FrameID, msg.SessionID)
}
// Open → Active on the first ack.
if session.Status == types.SessionOpen {
session.Status = types.SessionActive
}
s.Keeper.SetSession(sdkCtx, session)
sdkCtx.EventManager().EmitEvent(sdk.NewEvent(
"bearers.frame_received",
sdk.NewAttribute("session_id", msg.SessionID),
sdk.NewAttribute("frame_id", msg.FrameID),
sdk.NewAttribute("status", string(session.Status)),
// NO geolocation (A-522 surveillance-resistant invariant).
))
return &types.MsgReceiveOYSATFrameResponse{}, nil
}
// --- IssueOYQR (issue a one-shot OY-QR, consumed=false) ----------------------
// IssueOYQR issues a one-shot OY-QR (consumed=false). The handler enforces
// idempotency (qr-id must not already exist).
func (s msgServer) IssueOYQR(ctx interface{}, msg *types.MsgIssueOYQR) (*types.MsgIssueOYQRResponse, error) {
if err := msg.ValidateBasic(); err != nil {
return nil, err
}
sdkCtx := unwrapCtx(ctx)
// Idempotency: qr-id must not already exist.
if _, ok := s.Keeper.GetOYQRCode(sdkCtx, msg.QRID); ok {
return nil, fmt.Errorf("bearers: qr %q already exists", msg.QRID)
}
qr := types.OYQRCode{
QRID: msg.QRID,
PayloadBytes: msg.PayloadBytes,
Consumed: false,
IssuerReachID: msg.IssuerReachID,
AmountGrain: msg.AmountGrain,
ExpiresAt: msg.ExpiresAt,
}
s.Keeper.SetOYQRCode(sdkCtx, qr)
sdkCtx.EventManager().EmitEvent(sdk.NewEvent(
"bearers.qr_issued",
sdk.NewAttribute("qr_id", msg.QRID),
sdk.NewAttribute("issuer_reach", msg.IssuerReachID),
sdk.NewAttribute("amount_grain", fmt.Sprintf("%d", msg.AmountGrain)),
sdk.NewAttribute("consumed", "false"),
// NO geolocation (A-522 surveillance-resistant invariant).
))
return &types.MsgIssueOYQRResponse{}, nil
}
// --- ConsumeOYQR (one-shot; A-521 consumed-flip-before-effect) ---------------
// ConsumeOYQR is the canonical one-shot handler (A-521). The ordering is:
// 1. load QR
// 2. assert !consumed (replay firewall — a replay finds consumed==true
// and returns an error; idempotent reject, NOT double-effect)
// 3. assert expires-at > now (the QR is still valid)
// 4. FLIP consumed=true (state write FIRST — A-521)
// 5. emit transfer effect via BreadKeeper shim (the SDK store is atomic
// per tx — a panic in the transfer rolls back the whole tx, so the
// order is safe; the order documents intent and matches the ibc-go
// delete-before-mint convention)
// 6. emit event
// 7. return
//
// A nil BreadKeeper shim is permitted (the handler still flips consumed —
// the A-521 state-write-first invariant holds regardless; the transfer
// effect is skipped, which is the simtest behavior when the shim is not
// wired). This keeps the one-shot replay firewall intact even without the
// x/bread keeper wired.
func (s msgServer) ConsumeOYQR(ctx interface{}, msg *types.MsgConsumeOYQR) (*types.MsgConsumeOYQRResponse, error) {
if err := msg.ValidateBasic(); err != nil {
return nil, err
}
sdkCtx := unwrapCtx(ctx)
// 1. Load QR.
qr, ok := s.Keeper.GetOYQRCode(sdkCtx, msg.QRID)
if !ok {
return nil, fmt.Errorf("bearers: qr %q not found", msg.QRID)
}
// 2. Replay firewall: a consumed QR rejects further consumes
// (idempotent reject, NOT double-effect — A-521).
if qr.Consumed {
return nil, fmt.Errorf("bearers: qr %q already consumed (one-shot — A-521)", msg.QRID)
}
// 3. Expiry check: the QR must still be valid (expires-at > now).
now := nowUnix(sdkCtx)
if qr.ExpiresAt <= now {
// Flip consumed to prevent a late replay (the QR is expired,
// but we mark it consumed to lock the one-shot semantics; the
// consume itself fails).
qr.Consumed = true
s.Keeper.SetOYQRCode(sdkCtx, qr)
return nil, fmt.Errorf("bearers: qr %q expired (expires-at %d <= now %d)", msg.QRID, qr.ExpiresAt, now)
}
// 4. FLIP consumed=true (state write FIRST — A-521). This is the
// replay firewall: any subsequent consume finds consumed==true
// and returns the error above (idempotent reject).
qr.Consumed = true
s.Keeper.SetOYQRCode(sdkCtx, qr)
// 5. Emit transfer effect via BreadKeeper shim. A nil shim is
// permitted (the consumed flip already happened — the A-521
// invariant holds; the transfer is skipped in the unwired case).
var transferErr error
if s.Keeper.breadKeeper != nil {
transferErr = s.Keeper.breadKeeper.TransferGrain(qr.IssuerReachID, msg.ConsumerReachID, qr.AmountGrain)
}
if transferErr != nil {
// The transfer failed AFTER the consumed flip. The SDK store
// is atomic per tx — returning the error rolls back the
// consumed flip too (the QR is restored to consumed=false).
// This is the correct behavior: a failed transfer does NOT
// burn the one-shot QR. The order (flip first, transfer
// second) documents intent and matches the ibc-go
// delete-before-mint convention; the atomicity guarantee
// makes the order safe.
return nil, fmt.Errorf("bearers: qr %q transfer effect failed: %w", msg.QRID, transferErr)
}
// 6. Emit event.
sdkCtx.EventManager().EmitEvent(sdk.NewEvent(
"bearers.qr_consumed",
sdk.NewAttribute("qr_id", msg.QRID),
sdk.NewAttribute("issuer_reach", qr.IssuerReachID),
sdk.NewAttribute("consumer_reach", msg.ConsumerReachID),
sdk.NewAttribute("amount_grain", fmt.Sprintf("%d", qr.AmountGrain)),
sdk.NewAttribute("consumed", "true"),
// NO geolocation (A-522 surveillance-resistant invariant).
))
return &types.MsgConsumeOYQRResponse{}, nil
}
File diff suppressed because it is too large Load Diff
+130
View File
@@ -0,0 +1,130 @@
package keeper
import (
"fmt"
sdk "github.com/cosmos/cosmos-sdk/types"
"github.com/oy/openyield/x/bearers/types"
)
// transport.go holds the store-backed BearerTransport impl (P2-02-01,
// REQ-034, A-522). The v0.2 BearerTransport Go interface (Send, Receive,
// Status) gains a store-backed runtime impl: the keeper IS the transport
// for simtest purposes — no hardware/RF Go libraries (D-054).
//
// The transport wraps the keeper's session store. Send appends a frame to
// the session's Frames slice. Receive marks the frame received (and
// transitions the session Open → Active on first ack). Status reports
// whether the session is Open or Active (i.e., still carrying traffic).
//
// Surveillance-resistant invariant (A-522): the transport carries NO
// geolocation / sender physical location fields. The surveillance-resistant
// locked const on OYSATLink/OYLRLink is a runtime invariant — the transport
// MUST NOT emit geolocation in events. A negative simtest asserts the event
// set contains NO geolocation fields.
// StoreTransport is the store-backed BearerTransport impl. It wraps a
// Keeper + the sdk.Context (bound at construction so the BearerTransport
// interface methods can stay parameterless per the v0.2 interface contract).
// The transport operates on a single session-id (a transport instance is
// scoped to one session — the bearer is a per-session handle in the simtest
// runtime).
type StoreTransport struct {
keeper Keeper
ctx sdk.Context
sessionID string
}
// NewStoreTransport constructs a store-backed BearerTransport scoped to the
// named session. The session must already exist (Open or Active). The
// transport reads/writes the session's Frames slice via the keeper store.
func NewStoreTransport(k Keeper, ctx sdk.Context, sessionID string) *StoreTransport {
return &StoreTransport{keeper: k, ctx: ctx, sessionID: sessionID}
}
// Compile-time assertion: StoreTransport satisfies the v0.2 BearerTransport
// interface (D-029, REQ-034). The interface contract is Send/Receive/Status
// (parameterless except Send takes a payload).
var _ types.BearerTransport = (*StoreTransport)(nil)
// Send dispatches a payload via the bearer. The store-backed impl appends
// the payload as a new Frame on the session's Frames slice. Returns an
// error if the session is not found or is terminal (Closed/Revoked) — a
// terminal session rejects further Send calls.
func (t *StoreTransport) Send(payload []byte) error {
s, ok := t.keeper.GetSession(t.ctx, t.sessionID)
if !ok {
return fmt.Errorf("bearers: session %q not found", t.sessionID)
}
if s.IsTerminal() {
return fmt.Errorf("bearers: session %q is terminal (%s), rejects Send", t.sessionID, s.Status)
}
frame := types.Frame{
FrameID: fmt.Sprintf("%s-frame-%d", t.sessionID, len(s.Frames)+1),
SenderReach: s.InitiatorReach,
PayloadBytes: payload,
SentAt: t.ctx.BlockTime().Unix(),
}
s.Frames = append(s.Frames, frame)
t.keeper.SetSession(t.ctx, s)
t.ctx.EventManager().EmitEvent(sdk.NewEvent(
"bearers.frame_sent",
sdk.NewAttribute("session_id", t.sessionID),
sdk.NewAttribute("frame_id", frame.FrameID),
sdk.NewAttribute("sender_reach", frame.SenderReach),
// NO geolocation (A-522 surveillance-resistant invariant).
))
return nil
}
// Receive accepts an inbound payload from the bearer. The store-backed impl
// marks the first unreceived frame as Received and transitions the session
// Open → Active on the first ack. Returns the payload and an error if the
// bearer has no inbound (unreceived) payload or the session is terminal.
func (t *StoreTransport) Receive() ([]byte, error) {
s, ok := t.keeper.GetSession(t.ctx, t.sessionID)
if !ok {
return nil, fmt.Errorf("bearers: session %q not found", t.sessionID)
}
if s.IsTerminal() {
return nil, fmt.Errorf("bearers: session %q is terminal (%s), rejects Receive", t.sessionID, s.Status)
}
// Find the first unreceived frame.
var received *types.Frame
for i := range s.Frames {
if !s.Frames[i].Received {
s.Frames[i].Received = true
received = &s.Frames[i]
break
}
}
if received == nil {
return nil, fmt.Errorf("bearers: no inbound frame on session %q", t.sessionID)
}
// Open → Active on the first ack.
if s.Status == types.SessionOpen {
s.Status = types.SessionActive
}
t.keeper.SetSession(t.ctx, s)
t.ctx.EventManager().EmitEvent(sdk.NewEvent(
"bearers.frame_received",
sdk.NewAttribute("session_id", t.sessionID),
sdk.NewAttribute("frame_id", received.FrameID),
sdk.NewAttribute("status", string(s.Status)),
// NO geolocation (A-522 surveillance-resistant invariant).
))
return received.PayloadBytes, nil
}
// Status reports the bearer's current reachability (true = reachable). The
// store-backed impl reports true iff the session exists and is Open or
// Active (still carrying traffic). A terminal or missing session is
// unreachable.
func (t *StoreTransport) Status() bool {
s, ok := t.keeper.GetSession(t.ctx, t.sessionID)
if !ok {
return false
}
return s.Status == types.SessionOpen || s.Status == types.SessionActive
}
+79
View File
@@ -0,0 +1,79 @@
package bearers
import (
"encoding/json"
storetypes "cosmossdk.io/store/types"
"github.com/cosmos/cosmos-sdk/codec"
sdk "github.com/cosmos/cosmos-sdk/types"
"github.com/cosmos/cosmos-sdk/types/module"
"github.com/oy/openyield/x/bearers/keeper"
"github.com/oy/openyield/x/bearers/types"
)
// module.go holds the bearers module's AppModule + RegisterServices
// (P2-02-01, REQ-034).
//
// The AppModule wraps the Keeper and registers the MsgServer via
// RegisterServices. This is the simtest-grade AppModule (D-054): the
// RegisterServices wires the hand-rolled MsgServer (no protobuf codegen per
// the skeleton's zero-codegen style). The MsgServer is constructed directly
// and exposed via the module for test wiring.
// ConsensusVersion is the bearers module's consensus version (AppModule).
const ConsensusVersion = 1
// AppModule is the bearers application module (simtest-grade — D-054).
type AppModule struct {
keeper keeper.Keeper
}
// NewAppModule constructs a new bearers AppModule. The BreadKeeper
// expected-keeper shim is injected (nil-able for partial tests).
func NewAppModule(cdc codec.Codec, storeKey storetypes.StoreKey, bk types.BreadKeeper) AppModule {
k := keeper.NewKeeper(cdc, storeKey, bk)
return AppModule{keeper: k}
}
// RegisterServices registers the bearers MsgServer. Simtest-grade wiring:
// the MsgServer is constructed from the keeper and exposed via the module's
// MsgServer method (tests use NewMsgServerImpl directly).
func (am AppModule) RegisterServices(cfg module.Configurator) {
_ = cfg
}
// MsgServer returns the bearers MsgServer for this module's keeper.
func (am AppModule) MsgServer() types.MsgServer {
return keeper.NewMsgServerImpl(am.keeper)
}
// Name returns the module name.
func (AppModule) Name() string { return types.ModuleName }
// ConsensusVersion implements AppModule.ConsensusVersion.
func (AppModule) ConsensusVersion() uint64 { return ConsensusVersion }
// InitGenesis performs genesis initialization for the bearers module.
func (am AppModule) InitGenesis(ctx sdk.Context, cdc codec.JSONCodec, data json.RawMessage) {
var gs types.GenesisState
cdc.MustUnmarshalJSON(data, &gs)
for _, s := range gs.Sessions {
am.keeper.SetSession(ctx, s)
}
for _, q := range gs.QRs {
am.keeper.SetOYQRCode(ctx, q)
}
}
// ExportGenesis returns the exported genesis state as raw bytes.
func (am AppModule) ExportGenesis(ctx sdk.Context, cdc codec.JSONCodec) json.RawMessage {
sessions := am.keeper.AllSessions(ctx)
qrs := am.keeper.AllOYQRCodes(ctx)
gs := types.GenesisState{Sessions: sessions, QRs: qrs}
return cdc.MustMarshalJSON(&gs)
}
// Compile-time assertions: AppModule implements the module interface stubs.
var _ module.HasName = AppModule{}
var _ module.HasConsensusVersion = AppModule{}
+36
View File
@@ -0,0 +1,36 @@
package types
// expected_keepers.go holds the Go INTERFACE for the cross-module keeper
// x/bearers depends on (G-003 firewall — ibc-go expected-keepers convention).
//
// x/bearers's MsgConsumeOYQR handler drives a one-shot grain transfer via
// the x/bread keeper (by-ID-string on the reach-ids — the issuer-reach-id
// and consumer-reach-id). The dependency is expressed as an INTERFACE
// defined HERE (in x/bearers/types), NOT as a struct import of
// x/bread/types. The x/bread keeper satisfies this interface structurally;
// the handler depends on the interface, preserving G-003's intent (no
// cross-module struct coupling, no import cycles).
//
// Test-only cross-package imports (the G-003 test exemption) remain exempt:
// a simtest may import both x/bearers/keeper and x/bread/keeper to wire the
// BreadKeeper shim in a test setup.
// BreadKeeper is the expected-keeper interface for x/bread (G-003). The
// bearers MsgConsumeOYQR handler calls it for the OY-QR one-shot transfer
// effect: TransferGrain moves grain from the issuer-reach to the
// consumer-reach (by-ID-string — the lexicon-clean holder identifier, NOT
// a banned financial-holder lexicon; use Holder/Reach).
//
// The reach-ids are by-ID-string at the type level (G-003) and stay
// by-ID-string at the runtime level (this interface takes strings, not a
// x/bread struct). No struct import of x/bread/types.
type BreadKeeper interface {
// TransferGrain moves grain from the from-reach to the to-reach (by
// reach-id string). Returns an error if the transfer fails (e.g.,
// insufficient grain, unknown reach-id). The bearers handler flips
// the OY-QR consumed flag FIRST (state write — A-521), THEN invokes
// this transfer effect; a panic in the transfer rolls back the whole
// tx (SDK store is atomic per tx — the order documents intent and
// matches the ibc-go delete-before-mint convention).
TransferGrain(fromReach, toReach string, amount int64) error
}
+473
View File
@@ -0,0 +1,473 @@
package types
import (
"fmt"
sdk "github.com/cosmos/cosmos-sdk/types"
)
// msg_bearer.go holds the bearers module's Msg* types implementing sdk.Msg
// (G-006 controlled exception: types/ gains the cosmos-sdk import for
// sdk.Msg — D-055; the invariant/lexicon tests in *_test.go stay stdlib-only
// per G-024, isolated from this msg_*.go file). Each Msg carries a
// ValidateBasic (stateless) and GetSigners.
//
// The seven bearer Msg types drive the OY-SAT frame transport + OY-QR
// one-shot consume + session lifecycle (REQ-034):
// - MsgSendOYSATFrame: send a frame on an OY-SAT session.
// - MsgReceiveOYSATFrame: acknowledge receipt of a frame (transitions the
// session Open → Active on first ack).
// - MsgIssueOYQR: issue a one-shot OY-QR (consumed=false).
// - MsgConsumeOYQR: consume a one-shot OY-QR — flips consumed BEFORE the
// transfer effect (A-521); replay finds consumed==true and errors.
// - MsgOpenSession: open a new session (status=Open).
// - MsgCloseSession: close a session (Active → Closed).
// - MsgRevokeSession: revoke a session (out-of-band → Revoked).
//
// All cross-module refs are by-ID-string (G-003): session-id is this
// session's ID; qr-id is this QR's ID; reach-ids are by-ID-string user
// identifiers. GetSigners returns the signer reach-ids encoded as
// sdk.AccAddress bytes. The reach-id is the lexicon-clean holder
// identifier (G-003 — NOT a banned financial-holder lexicon; use
// Holder/Reach).
//
// Surveillance-resistant invariant (A-522): NO Msg carries geolocation or
// sender physical location fields. The handler MUST NOT emit geolocation
// in events. A negative simtest asserts the event set contains NO
// geolocation fields.
// --- MsgSendOYSATFrame --------------------------------------------------------
// MsgSendOYSATFrame sends a frame on an OY-SAT session. ValidateBasic is
// stateless: non-empty session-id, non-empty frame payload, non-empty
// signer. The handler enforces the stateful session-status check (the
// session must be Open or Active — frames on Closed/Revoked are rejected).
type MsgSendOYSATFrame struct {
SessionID string `json:"session_id" yaml:"session_id"`
FrameID string `json:"frame_id" yaml:"frame_id"`
PayloadBytes []byte `json:"payload_bytes" yaml:"payload_bytes"`
Signer string `json:"signer" yaml:"signer"`
}
// Reset implements proto.Message (sdk.Msg = proto.Message).
func (m *MsgSendOYSATFrame) Reset() { *m = MsgSendOYSATFrame{} }
// String implements proto.Message.
func (m *MsgSendOYSATFrame) String() string {
return fmt.Sprintf("MsgSendOYSATFrame{SessionID:%s FrameID:%s Signer:%s}",
m.SessionID, m.FrameID, m.Signer)
}
// ProtoMessage implements proto.Message.
func (*MsgSendOYSATFrame) ProtoMessage() {}
// ValidateBasic is the stateless validation: non-empty session-id, non-empty
// frame payload, non-empty signer.
func (m *MsgSendOYSATFrame) ValidateBasic() error {
if m.SessionID == "" {
return fmt.Errorf("bearers: empty session-id")
}
if len(m.PayloadBytes) == 0 {
return fmt.Errorf("bearers: empty frame payload")
}
if m.Signer == "" {
return fmt.Errorf("bearers: empty signer")
}
return nil
}
// GetSigners returns the signer's reach-id as sdk.AccAddress bytes.
func (m *MsgSendOYSATFrame) GetSigners() []sdk.AccAddress {
return []sdk.AccAddress{[]byte(m.Signer)}
}
// --- MsgReceiveOYSATFrame -----------------------------------------------------
// MsgReceiveOYSATFrame acknowledges receipt of an OY-SAT frame. The handler
// transitions the session Open → Active on the first ack. ValidateBasic is
// stateless: non-empty session-id, non-empty frame-id, non-empty signer.
type MsgReceiveOYSATFrame struct {
SessionID string `json:"session_id" yaml:"session_id"`
FrameID string `json:"frame_id" yaml:"frame_id"`
Signer string `json:"signer" yaml:"signer"`
}
// Reset implements proto.Message.
func (m *MsgReceiveOYSATFrame) Reset() { *m = MsgReceiveOYSATFrame{} }
// String implements proto.Message.
func (m *MsgReceiveOYSATFrame) String() string {
return fmt.Sprintf("MsgReceiveOYSATFrame{SessionID:%s FrameID:%s Signer:%s}",
m.SessionID, m.FrameID, m.Signer)
}
// ProtoMessage implements proto.Message.
func (*MsgReceiveOYSATFrame) ProtoMessage() {}
// ValidateBasic is the stateless validation: non-empty session-id, non-empty
// frame-id, non-empty signer.
func (m *MsgReceiveOYSATFrame) ValidateBasic() error {
if m.SessionID == "" {
return fmt.Errorf("bearers: empty session-id")
}
if m.FrameID == "" {
return fmt.Errorf("bearers: empty frame-id")
}
if m.Signer == "" {
return fmt.Errorf("bearers: empty signer")
}
return nil
}
// GetSigners returns the signer's reach-id as sdk.AccAddress bytes.
func (m *MsgReceiveOYSATFrame) GetSigners() []sdk.AccAddress {
return []sdk.AccAddress{[]byte(m.Signer)}
}
// --- MsgIssueOYQR -------------------------------------------------------------
// MsgIssueOYQR issues a one-shot OY-QR (consumed=false). ValidateBasic is
// stateless: non-empty qr-id, non-empty issuer-reach-id, non-empty payload,
// expires-at > 0 (the handler asserts expires-at > now at consume time, not
// issue time — but a zero/negative expires-at is rejected as malformed).
type MsgIssueOYQR struct {
QRID string `json:"qr_id" yaml:"qr_id"`
IssuerReachID string `json:"issuer_reach_id" yaml:"issuer_reach_id"`
PayloadBytes []byte `json:"payload_bytes" yaml:"payload_bytes"`
AmountGrain int64 `json:"amount_grain" yaml:"amount_grain"`
ExpiresAt int64 `json:"expires_at" yaml:"expires_at"`
Signer string `json:"signer" yaml:"signer"`
}
// Reset implements proto.Message.
func (m *MsgIssueOYQR) Reset() { *m = MsgIssueOYQR{} }
// String implements proto.Message.
func (m *MsgIssueOYQR) String() string {
return fmt.Sprintf("MsgIssueOYQR{QRID:%s IssuerReachID:%s AmountGrain:%d ExpiresAt:%d Signer:%s}",
m.QRID, m.IssuerReachID, m.AmountGrain, m.ExpiresAt, m.Signer)
}
// ProtoMessage implements proto.Message.
func (*MsgIssueOYQR) ProtoMessage() {}
// ValidateBasic is the stateless validation: non-empty qr-id, non-empty
// issuer-reach-id, non-empty payload, amount > 0, expires-at > 0, non-empty
// signer. The handler asserts expires-at > now at consume time (the
// stateful check); a zero/negative expires-at is rejected as malformed here.
func (m *MsgIssueOYQR) ValidateBasic() error {
if m.QRID == "" {
return fmt.Errorf("bearers: empty qr-id")
}
if m.IssuerReachID == "" {
return fmt.Errorf("bearers: empty issuer-reach-id")
}
if len(m.PayloadBytes) == 0 {
return fmt.Errorf("bearers: empty qr payload")
}
if m.AmountGrain <= 0 {
return fmt.Errorf("bearers: amount-grain must be > 0")
}
if m.ExpiresAt <= 0 {
return fmt.Errorf("bearers: expires-at must be > 0")
}
if m.Signer == "" {
return fmt.Errorf("bearers: empty signer")
}
return nil
}
// GetSigners returns the signer's reach-id as sdk.AccAddress bytes.
func (m *MsgIssueOYQR) GetSigners() []sdk.AccAddress {
return []sdk.AccAddress{[]byte(m.Signer)}
}
// --- MsgConsumeOYQR ----------------------------------------------------------
// MsgConsumeOYQR consumes a one-shot OY-QR. The handler is the canonical
// one-shot handler (A-521): load QR → assert !consumed → assert expires-at
// > now → FLIP consumed=true (state write FIRST) → emit transfer effect
// via BreadKeeper shim → emit event → return. A replay finds consumed==true
// and returns an error (idempotent reject, NOT double-effect).
//
// ValidateBasic is stateless: non-empty qr-id, non-empty consumer-reach-id,
// non-empty signer.
type MsgConsumeOYQR struct {
QRID string `json:"qr_id" yaml:"qr_id"`
ConsumerReachID string `json:"consumer_reach_id" yaml:"consumer_reach_id"`
Signer string `json:"signer" yaml:"signer"`
}
// Reset implements proto.Message.
func (m *MsgConsumeOYQR) Reset() { *m = MsgConsumeOYQR{} }
// String implements proto.Message.
func (m *MsgConsumeOYQR) String() string {
return fmt.Sprintf("MsgConsumeOYQR{QRID:%s ConsumerReachID:%s Signer:%s}",
m.QRID, m.ConsumerReachID, m.Signer)
}
// ProtoMessage implements proto.Message.
func (*MsgConsumeOYQR) ProtoMessage() {}
// ValidateBasic is the stateless validation: non-empty qr-id, non-empty
// consumer-reach-id, non-empty signer.
func (m *MsgConsumeOYQR) ValidateBasic() error {
if m.QRID == "" {
return fmt.Errorf("bearers: empty qr-id")
}
if m.ConsumerReachID == "" {
return fmt.Errorf("bearers: empty consumer-reach-id")
}
if m.Signer == "" {
return fmt.Errorf("bearers: empty signer")
}
return nil
}
// GetSigners returns the signer's reach-id as sdk.AccAddress bytes.
func (m *MsgConsumeOYQR) GetSigners() []sdk.AccAddress {
return []sdk.AccAddress{[]byte(m.Signer)}
}
// --- MsgOpenSession ----------------------------------------------------------
// MsgOpenSession opens a new bearer session (status=Open). ValidateBasic is
// stateless: non-empty session-id, valid bearer-type, non-empty
// initiator-reach, non-empty peer-reach, non-empty signer.
type MsgOpenSession struct {
SessionID string `json:"session_id" yaml:"session_id"`
BearerType BearerType `json:"bearer_type" yaml:"bearer_type"`
InitiatorReach string `json:"initiator_reach" yaml:"initiator_reach"`
PeerReach string `json:"peer_reach" yaml:"peer_reach"`
TTL int64 `json:"ttl" yaml:"ttl"`
Signer string `json:"signer" yaml:"signer"`
}
// Reset implements proto.Message.
func (m *MsgOpenSession) Reset() { *m = MsgOpenSession{} }
// String implements proto.Message.
func (m *MsgOpenSession) String() string {
return fmt.Sprintf("MsgOpenSession{SessionID:%s BearerType:%s InitiatorReach:%s PeerReach:%s TTL:%d Signer:%s}",
m.SessionID, m.BearerType, m.InitiatorReach, m.PeerReach, m.TTL, m.Signer)
}
// ProtoMessage implements proto.Message.
func (*MsgOpenSession) ProtoMessage() {}
// ValidateBasic is the stateless validation: non-empty session-id, known
// bearer-type, non-empty initiator-reach, non-empty peer-reach, non-empty
// signer. ttl may be 0 (never expires).
func (m *MsgOpenSession) ValidateBasic() error {
if m.SessionID == "" {
return fmt.Errorf("bearers: empty session-id")
}
if !knownBearerType(m.BearerType) {
return fmt.Errorf("bearers: unknown bearer-type %q", m.BearerType)
}
if m.InitiatorReach == "" {
return fmt.Errorf("bearers: empty initiator-reach")
}
if m.PeerReach == "" {
return fmt.Errorf("bearers: empty peer-reach")
}
if m.Signer == "" {
return fmt.Errorf("bearers: empty signer")
}
return nil
}
// GetSigners returns the signer's reach-id as sdk.AccAddress bytes.
func (m *MsgOpenSession) GetSigners() []sdk.AccAddress {
return []sdk.AccAddress{[]byte(m.Signer)}
}
// --- MsgCloseSession ---------------------------------------------------------
// MsgCloseSession closes a session (Active → Closed). ValidateBasic is
// stateless: non-empty session-id, non-empty signer.
type MsgCloseSession struct {
SessionID string `json:"session_id" yaml:"session_id"`
Signer string `json:"signer" yaml:"signer"`
}
// Reset implements proto.Message.
func (m *MsgCloseSession) Reset() { *m = MsgCloseSession{} }
// String implements proto.Message.
func (m *MsgCloseSession) String() string {
return fmt.Sprintf("MsgCloseSession{SessionID:%s Signer:%s}", m.SessionID, m.Signer)
}
// ProtoMessage implements proto.Message.
func (*MsgCloseSession) ProtoMessage() {}
// ValidateBasic is the stateless validation: non-empty session-id and signer.
func (m *MsgCloseSession) ValidateBasic() error {
if m.SessionID == "" {
return fmt.Errorf("bearers: empty session-id")
}
if m.Signer == "" {
return fmt.Errorf("bearers: empty signer")
}
return nil
}
// GetSigners returns the signer's reach-id as sdk.AccAddress bytes.
func (m *MsgCloseSession) GetSigners() []sdk.AccAddress {
return []sdk.AccAddress{[]byte(m.Signer)}
}
// --- MsgRevokeSession --------------------------------------------------------
// MsgRevokeSession revokes a session (out-of-band → Revoked). A revoked
// session rejects further Receive. ValidateBasic is stateless: non-empty
// session-id, non-empty signer.
type MsgRevokeSession struct {
SessionID string `json:"session_id" yaml:"session_id"`
Signer string `json:"signer" yaml:"signer"`
}
// Reset implements proto.Message.
func (m *MsgRevokeSession) Reset() { *m = MsgRevokeSession{} }
// String implements proto.Message.
func (m *MsgRevokeSession) String() string {
return fmt.Sprintf("MsgRevokeSession{SessionID:%s Signer:%s}", m.SessionID, m.Signer)
}
// ProtoMessage implements proto.Message.
func (*MsgRevokeSession) ProtoMessage() {}
// ValidateBasic is the stateless validation: non-empty session-id and signer.
func (m *MsgRevokeSession) ValidateBasic() error {
if m.SessionID == "" {
return fmt.Errorf("bearers: empty session-id")
}
if m.Signer == "" {
return fmt.Errorf("bearers: empty signer")
}
return nil
}
// GetSigners returns the signer's reach-id as sdk.AccAddress bytes.
func (m *MsgRevokeSession) GetSigners() []sdk.AccAddress {
return []sdk.AccAddress{[]byte(m.Signer)}
}
// --- MsgServer interface + Response types -----------------------------------
// MsgServer is the bearers module's message server interface (one method per
// Msg*). The keeper's msg_server.go implements this; module.go's
// RegisterServices wires the implementation. This is the hand-rolled
// equivalent of the protobuf-generated MsgServer interface (no codegen per
// the skeleton's zero-codegen style).
type MsgServer interface {
SendOYSATFrame(ctx interface{}, msg *MsgSendOYSATFrame) (*MsgSendOYSATFrameResponse, error)
ReceiveOYSATFrame(ctx interface{}, msg *MsgReceiveOYSATFrame) (*MsgReceiveOYSATFrameResponse, error)
IssueOYQR(ctx interface{}, msg *MsgIssueOYQR) (*MsgIssueOYQRResponse, error)
ConsumeOYQR(ctx interface{}, msg *MsgConsumeOYQR) (*MsgConsumeOYQRResponse, error)
OpenSession(ctx interface{}, msg *MsgOpenSession) (*MsgOpenSessionResponse, error)
CloseSession(ctx interface{}, msg *MsgCloseSession) (*MsgCloseSessionResponse, error)
RevokeSession(ctx interface{}, msg *MsgRevokeSession) (*MsgRevokeSessionResponse, error)
}
// Response types (hand-rolled equivalents of the protobuf-generated response
// wrappers; empty bodies — the response is the state mutation + event).
// MsgSendOYSATFrameResponse is the response to MsgSendOYSATFrame.
type MsgSendOYSATFrameResponse struct{}
// Reset implements proto.Message.
func (m *MsgSendOYSATFrameResponse) Reset() { *m = MsgSendOYSATFrameResponse{} }
// String implements proto.Message.
func (m *MsgSendOYSATFrameResponse) String() string { return "MsgSendOYSATFrameResponse{}" }
// ProtoMessage implements proto.Message.
func (*MsgSendOYSATFrameResponse) ProtoMessage() {}
// MsgReceiveOYSATFrameResponse is the response to MsgReceiveOYSATFrame.
type MsgReceiveOYSATFrameResponse struct{}
// Reset implements proto.Message.
func (m *MsgReceiveOYSATFrameResponse) Reset() { *m = MsgReceiveOYSATFrameResponse{} }
// String implements proto.Message.
func (m *MsgReceiveOYSATFrameResponse) String() string { return "MsgReceiveOYSATFrameResponse{}" }
// ProtoMessage implements proto.Message.
func (*MsgReceiveOYSATFrameResponse) ProtoMessage() {}
// MsgIssueOYQRResponse is the response to MsgIssueOYQR.
type MsgIssueOYQRResponse struct{}
// Reset implements proto.Message.
func (m *MsgIssueOYQRResponse) Reset() { *m = MsgIssueOYQRResponse{} }
// String implements proto.Message.
func (m *MsgIssueOYQRResponse) String() string { return "MsgIssueOYQRResponse{}" }
// ProtoMessage implements proto.Message.
func (*MsgIssueOYQRResponse) ProtoMessage() {}
// MsgConsumeOYQRResponse is the response to MsgConsumeOYQR.
type MsgConsumeOYQRResponse struct{}
// Reset implements proto.Message.
func (m *MsgConsumeOYQRResponse) Reset() { *m = MsgConsumeOYQRResponse{} }
// String implements proto.Message.
func (m *MsgConsumeOYQRResponse) String() string { return "MsgConsumeOYQRResponse{}" }
// ProtoMessage implements proto.Message.
func (*MsgConsumeOYQRResponse) ProtoMessage() {}
// MsgOpenSessionResponse is the response to MsgOpenSession.
type MsgOpenSessionResponse struct{}
// Reset implements proto.Message.
func (m *MsgOpenSessionResponse) Reset() { *m = MsgOpenSessionResponse{} }
// String implements proto.Message.
func (m *MsgOpenSessionResponse) String() string { return "MsgOpenSessionResponse{}" }
// ProtoMessage implements proto.Message.
func (*MsgOpenSessionResponse) ProtoMessage() {}
// MsgCloseSessionResponse is the response to MsgCloseSession.
type MsgCloseSessionResponse struct{}
// Reset implements proto.Message.
func (m *MsgCloseSessionResponse) Reset() { *m = MsgCloseSessionResponse{} }
// String implements proto.Message.
func (m *MsgCloseSessionResponse) String() string { return "MsgCloseSessionResponse{}" }
// ProtoMessage implements proto.Message.
func (*MsgCloseSessionResponse) ProtoMessage() {}
// MsgRevokeSessionResponse is the response to MsgRevokeSession.
type MsgRevokeSessionResponse struct{}
// Reset implements proto.Message.
func (m *MsgRevokeSessionResponse) Reset() { *m = MsgRevokeSessionResponse{} }
// String implements proto.Message.
func (m *MsgRevokeSessionResponse) String() string { return "MsgRevokeSessionResponse{}" }
// ProtoMessage implements proto.Message.
func (*MsgRevokeSessionResponse) ProtoMessage() {}
// --- Helpers ----------------------------------------------------------------
// knownBearerType reports whether bt is one of the six BearerType values.
func knownBearerType(bt BearerType) bool {
for _, b := range AllBearers() {
if b.Type == bt {
return true
}
}
return false
}
+121
View File
@@ -0,0 +1,121 @@
package types
// session.go holds the bearers runtime Session struct + lifecycle enum
// (P2-01-01, REQ-034). The Session is the runtime state object for a bearer
// transport conversation: a sequence of frames bound by a session-id, with
// Open/Active/Closed/Revoked lifecycle (mirrors the v0.2 Window primitive's
// lifecycle per A-523).
//
// All cross-module references are by-ID-string (G-003): initiator-reach and
// peer-reach are reach-id strings (the lexicon-clean holder identifier — NOT
// a banned financial-holder lexicon; use Holder/Reach). bearer-type is a
// BearerType enum value defined in types.go (same package — no cross-module
// import).
//
// Surveillance-resistant invariant (vision §14, A-522): the Session carries
// NO geolocation / sender physical location fields. The surveillance-
// resistant locked const on OYSATLink/OYLRLink is a runtime invariant —
// the handler MUST NOT emit geolocation in events. A negative simtest
// asserts the event set contains NO geolocation fields.
// SessionStatus is the session lifecycle (A-523 — mirrors Window's
// Open/Active/Closed/Revoked shape for consistency with the v0.2 Window
// primitive).
type SessionStatus string
const (
// SessionOpen is the initial state: a session has been declared but no
// frame has been acknowledged yet.
SessionOpen SessionStatus = "Open"
// SessionActive is the state after the first frame is acknowledged
// (received). The session is carrying traffic.
SessionActive SessionStatus = "Active"
// SessionClosed is the terminal success state: the last frame was
// delivered or the ttl expired.
SessionClosed SessionStatus = "Closed"
// SessionRevoked is the out-of-band termination state: a RevokeSession
// handler flipped the status. A revoked session rejects further Receive.
SessionRevoked SessionStatus = "Revoked"
)
// AllSessionStatuses returns all four SessionStatus values in lifecycle
// order. Locked-const test asserts exactly 4 entries.
func AllSessionStatuses() []SessionStatus {
return []SessionStatus{
SessionOpen,
SessionActive,
SessionClosed,
SessionRevoked,
}
}
// SessionStatusCount is the locked count of SessionStatus enum values.
// A regression firewall: adding/removing/renaming a status breaks this
// const's test.
const SessionStatusCount = 4
// Frame is a single bearer transport frame within a Session (REQ-034). A
// frame is a unit of payload sent via the bearer transport (OY-SAT satellite
// frame, OY-QR paper QR, etc.). The frame carries the payload-bytes and the
// sender-reach-id (the lexicon-clean holder identifier — NOT a geolocation
// or physical location; surveillance-resistant invariant A-522).
type Frame struct {
FrameID string `json:"frame_id" yaml:"frame_id"`
SenderReach string `json:"sender_reach" yaml:"sender_reach"`
PayloadBytes []byte `json:"payload_bytes" yaml:"payload_bytes"`
SentAt int64 `json:"sent_at" yaml:"sent_at"`
Received bool `json:"received" yaml:"received"`
}
// Session is the runtime state object for a bearer transport conversation
// (REQ-034, A-523). A session is a sequence of frames bound by a session-id,
// with Open/Active/Closed/Revoked lifecycle (mirrors the v0.2 Window
// primitive's lifecycle). The session is stored under the bearers keeper
// (by session-id).
//
// - session-id is this session's unique identifier.
// - bearer-type is the BearerType enum value (BearerOYSAT, BearerOYQR,
// etc.) — same package, no cross-module import.
// - initiator-reach is the reach-id of the session initiator (the holder
// who opened the session). Reach-id is the lexicon-clean identifier
// (G-003 — NOT a banned financial-holder lexicon).
// - peer-reach is the reach-id of the session peer (the other endpoint).
// - status is the SessionStatus lifecycle (Open/Active/Closed/Revoked).
// - frames is the ordered list of Frames in the session.
// - ttl is the time-to-live in seconds (a session with ttl=0 never
// expires; ttl > 0 expires at opened-at + ttl).
// - opened-at is the block time the session was opened (unix seconds).
// - closed-at is the block time the session was closed/revoked (0 while
// Open/Active).
//
// Surveillance-resistant invariant (A-522): the Session carries NO
// geolocation / sender physical location fields. The surveillance-resistant
// locked const on OYSATLink/OYLRLink is a runtime invariant — the handler
// MUST NOT emit geolocation in events.
type Session struct {
SessionID string `json:"session_id" yaml:"session_id"`
BearerType BearerType `json:"bearer_type" yaml:"bearer_type"`
InitiatorReach string `json:"initiator_reach" yaml:"initiator_reach"`
PeerReach string `json:"peer_reach" yaml:"peer_reach"`
Status SessionStatus `json:"status" yaml:"status"`
Frames []Frame `json:"frames" yaml:"frames"`
TTL int64 `json:"ttl" yaml:"ttl"`
OpenedAt int64 `json:"opened_at" yaml:"opened_at"`
ClosedAt int64 `json:"closed_at" yaml:"closed_at"`
}
// IsTerminal reports whether the session status is terminal (Closed or
// Revoked). A terminal session rejects further Receive calls.
func (s Session) IsTerminal() bool {
return s.Status == SessionClosed || s.Status == SessionRevoked
}
// IsExpired reports whether the session has expired at the given block time
// (unix seconds). A session with TTL=0 never expires. Expiry transitions the
// session to Closed (the handler enforces this on Receive/Status checks).
func (s Session) IsExpired(now int64) bool {
if s.TTL == 0 {
return false
}
return now >= s.OpenedAt+s.TTL
}
+146 -4
View File
@@ -1,6 +1,9 @@
package types
import "encoding/json"
import (
"encoding/json"
"fmt"
)
const (
ModuleName = "bearers"
@@ -93,16 +96,155 @@ type BeaconFrame struct {
TTL int64 `json:"ttl" yaml:"ttl"`
}
// OYSATLink is the OY-SAT (satellite bearer) transport link stub (D-037,
// vision §14). OY-SAT is global, surveillance-resistant (vision §14: the
// bearer is designed to resist surveillance, matching OY-LR). The struct
// mirrors the v0.2 OYLRLink shape (gateway-id, range, frequency, surveillance-
// resistant flag). It is a transport-shape stub (a typed data struct, not a
// BearerTransport interface impl — matching the v0.2 OYLRLink/BeaconFrame
// approach per D-029).
//
// - satellite-id is the satellite gateway/constellation identifier.
// - surveillance-resistant is LOCKED true for OY-SAT (A-311: OY-SAT is
// designed to resist surveillance, matching OY-LR from v0.2). The
// NewOYSATLink constructor enforces this invariant; the field is
// exported for JSON marshalling but the LOCKED-true invariant is
// asserted by the constructor and the regression test.
// - range-meters is the link range (0 for global satellite coverage).
type OYSATLink struct {
SatelliteID string `json:"satellite_id" yaml:"satellite_id"`
SurveillanceResistant bool `json:"surveillance_resistant" yaml:"surveillance_resistant"`
RangeMeters int32 `json:"range_meters" yaml:"range_meters"`
}
// OYSATSurveillanceResistant is the LOCKED invariant for OY-SAT (A-311):
// OY-SAT is surveillance-resistant by design (vision §14). The const is
// the authoritative value; the NewOYSATLink constructor sets the struct
// field from this const so the invariant is enforced at construction time.
// A regression test asserts this const is true.
const OYSATSurveillanceResistant = true
// NewOYSATLink constructs an OYSATLink with the surveillance-resistant
// flag LOCKED true (A-311). The caller cannot clear the flag via the
// constructor; the invariant is enforced at construction time. range-meters
// defaults to 0 (global satellite coverage) if not specified.
func NewOYSATLink(satelliteID string, rangeMeters int32) OYSATLink {
return OYSATLink{
SatelliteID: satelliteID,
SurveillanceResistant: OYSATSurveillanceResistant, // LOCKED true (A-311)
RangeMeters: rangeMeters,
}
}
// OYQRCode is the OY-QR (paper/QR-code bearer) transport stub (D-037,
// vision §14). OY-QR is 0-range (vision §14: the bearer list has OY-QR at
// "0 range"); a QR encodes a signed transfer that the recipient scans and
// submits. The struct mirrors the v0.2 BeaconFrame shape (a payload + a
// lifecycle flag), but for QR the flag is a one-shot consumed flag (A-311)
// instead of a ttl. It is a transport-shape stub (a typed data struct, not a
// BearerTransport interface impl — matching D-029).
//
// - qr-id is the QR code identifier.
// - payload-bytes is the signed transfer payload encoded in the QR.
// - consumed is the one-shot flag (A-311): a QR is single-use; once
// scanned/submitted, MarkConsumed flips it to true. Double-consume is
// idempotent (a no-op, not an error).
// - issuer-reach-id is the reach-id of the QR issuer (the holder who
// issued the QR; the MsgConsumeOYQR handler transfers grain FROM this
// reach-id to the consumer-reach-id via the BreadKeeper shim). Reach-id
// is the lexicon-clean holder identifier (G-003 — NOT a banned financial
// lexicon). Added in v0.5 P2 to support the MsgConsumeOYQR transfer
// effect (REQ-034, A-521).
// - amount-grain is the grain amount encoded in the QR (the transfer
// value the recipient receives on consume). Added in v0.5 P2.
// - expires-at is the unix-second expiry timestamp (the QR is valid until
// this time; the MsgConsumeOYQR handler asserts expires-at > now before
// flipping consumed). Added in v0.5 P2.
type OYQRCode struct {
QRID string `json:"qr_id" yaml:"qr_id"`
PayloadBytes []byte `json:"payload_bytes" yaml:"payload_bytes"`
Consumed bool `json:"consumed" yaml:"consumed"`
IssuerReachID string `json:"issuer_reach_id" yaml:"issuer_reach_id"`
AmountGrain int64 `json:"amount_grain" yaml:"amount_grain"`
ExpiresAt int64 `json:"expires_at" yaml:"expires_at"`
}
// MarkConsumed marks the QR as consumed (one-shot, A-311). Idempotent:
// calling MarkConsumed on an already-consumed QR is a no-op (no error, no
// state change beyond setting consumed=true which is already true). This
// locks the one-shot semantics: a QR cannot be unconsumed.
func (q *OYQRCode) MarkConsumed() {
q.Consumed = true
}
type Params struct{}
func DefaultParams() Params { return Params{} }
// GenesisState defines the bearers module genesis state. v0.1 had only
// Params; v0.5 P2 (REQ-034) adds Sessions + QRs so the runtime keeper can
// load/export its state via AppModule.InitGenesis/ExportGenesis. The
// Sessions and QRs slices are validated for ID-uniqueness (A-212 pattern).
type GenesisState struct {
Params Params `json:"params" yaml:"params"`
Params Params `json:"params" yaml:"params"`
Sessions []Session `json:"sessions" yaml:"sessions"`
QRs []OYQRCode `json:"qrs" yaml:"qrs"`
}
func DefaultGenesisState() *GenesisState {
return &GenesisState{Params: DefaultParams()}
return &GenesisState{
Params: DefaultParams(),
Sessions: []Session{},
QRs: []OYQRCode{},
}
}
func ValidateGenesis(bz json.RawMessage) error { return nil }
// Reset implements proto.Message (codec.JSONCodec.MustMarshalJSON /
// MustUnmarshalJSON require proto.Message; the GenesisState is the JSON
// genesis payload and gains the gogoproto proto.Message methods here so the
// AppModule's InitGenesis/ExportGenesis compile without protobuf codegen).
func (m *GenesisState) Reset() { *m = GenesisState{} }
// String implements proto.Message.
func (m *GenesisState) String() string {
return fmt.Sprintf("GenesisState{Sessions:%d QRs:%d}", len(m.Sessions), len(m.QRs))
}
// ProtoMessage implements proto.Message.
func (*GenesisState) ProtoMessage() {}
// ValidateGenesis performs ID-uniqueness checks (A-212 upgrade from v0.1
// no-op): rejects duplicate session-ids and duplicate qr-ids. A nil/empty
// input is accepted (equivalent to the default empty genesis — preserves
// the v0.1 no-op behavior for the TestValidateGenesisUnchanged regression
// test).
func ValidateGenesis(bz json.RawMessage) error {
if len(bz) == 0 {
return nil
}
var gs GenesisState
if err := json.Unmarshal(bz, &gs); err != nil {
return fmt.Errorf("bearers: invalid genesis: %w", err)
}
seenSessions := make(map[string]bool, len(gs.Sessions))
for i, s := range gs.Sessions {
if s.SessionID == "" {
return fmt.Errorf("bearers: session [%d]: empty session-id", i)
}
if seenSessions[s.SessionID] {
return fmt.Errorf("bearers: duplicate session-id %q", s.SessionID)
}
seenSessions[s.SessionID] = true
}
seenQRs := make(map[string]bool, len(gs.QRs))
for i, q := range gs.QRs {
if q.QRID == "" {
return fmt.Errorf("bearers: qr [%d]: empty qr-id", i)
}
if seenQRs[q.QRID] {
return fmt.Errorf("bearers: duplicate qr-id %q", q.QRID)
}
seenQRs[q.QRID] = true
}
return nil
}
+208
View File
@@ -261,6 +261,214 @@ func TestLexiconNoBannedTermsInBearersTestFile(t *testing.T) {
}
}
// --- v0.3 Bearers extension (P4-03, D-037, A-311) — OYSATLink + OYQRCode -------
//
// The following tests extend the v0.2 bearers tests with the v0.3 OY-SAT
// and OY-QR transport stubs (D-037). The existing v0.1/v0.2 tests above
// MUST remain green — no regression. The BearerType enum (6 bearers,
// including BearerOYSAT + BearerOYQR) is locked since v0.1; v0.3 adds the
// transport STRUCTS only (no enum change).
// TestOYSATLinkStructFields asserts the OYSATLink struct carries all
// required fields (satellite-id, surveillance-resistant, range-meters).
func TestOYSATLinkStructFields(t *testing.T) {
link := btypes.OYSATLink{
SatelliteID: "sat-1",
SurveillanceResistant: true,
RangeMeters: 0, // 0 for global satellite coverage
}
if link.SatelliteID != "sat-1" {
t.Errorf("SatelliteID = %q", link.SatelliteID)
}
if !link.SurveillanceResistant {
t.Error("SurveillanceResistant must be true for OY-SAT (vision §14)")
}
if link.RangeMeters != 0 {
t.Errorf("RangeMeters = %d, want 0 (global)", link.RangeMeters)
}
}
// TestOYSATLinkSurveillanceResistantLockedTrue asserts the OY-SAT
// surveillance-resistant invariant is LOCKED true (A-311: OY-SAT is
// surveillance-resistant by design, matching OY-LR). The
// NewOYSATLink constructor sets the field from the locked const; this
// test asserts the constructor always produces a link with
// surveillance-resistant == true regardless of inputs.
func TestOYSATLinkSurveillanceResistantLockedTrue(t *testing.T) {
// The LOCKED const must be true (A-311).
if !btypes.OYSATSurveillanceResistant {
t.Fatal("OYSATSurveillanceResistant const must be true (A-311 LOCKED)")
}
// The constructor must set surveillance-resistant true regardless of
// the other inputs.
cases := []struct {
satID string
rng int32
}{
{"sat-1", 0},
{"sat-2", 5000},
{"", 0},
{"global-constellation", 0},
}
for _, c := range cases {
link := btypes.NewOYSATLink(c.satID, c.rng)
if !link.SurveillanceResistant {
t.Errorf("NewOYSATLink(%q,%d): SurveillanceResistant = false, want true (A-311 LOCKED)", c.satID, c.rng)
}
if link.SurveillanceResistant != btypes.OYSATSurveillanceResistant {
t.Errorf("NewOYSATLink(%q,%d): field != locked const (A-311)", c.satID, c.rng)
}
}
}
// TestOYSATLinkConstructorSetsFields asserts NewOYSATLink sets the
// satellite-id and range-meters fields from the constructor args.
func TestOYSATLinkConstructorSetsFields(t *testing.T) {
link := btypes.NewOYSATLink("iridium-1", 0)
if link.SatelliteID != "iridium-1" {
t.Errorf("SatelliteID = %q, want %q", link.SatelliteID, "iridium-1")
}
if link.RangeMeters != 0 {
t.Errorf("RangeMeters = %d, want 0", link.RangeMeters)
}
link2 := btypes.NewOYSATLink("starlink-2", 5000)
if link2.SatelliteID != "starlink-2" {
t.Errorf("SatelliteID = %q, want %q", link2.SatelliteID, "starlink-2")
}
if link2.RangeMeters != 5000 {
t.Errorf("RangeMeters = %d, want 5000", link2.RangeMeters)
}
}
// TestOYSATStillInAllBearers is the v0.3 REGRESSION test: OY-SAT must
// still be in AllBearers() (the 6-bearer count is unchanged by the v0.3
// extension — the BearerType enum is locked since v0.1).
func TestOYSATStillInAllBearers(t *testing.T) {
bearers := btypes.AllBearers()
if len(bearers) != 6 {
t.Errorf("AllBearers() len = %d, expected 6 (no regression — D-037)", len(bearers))
}
found := false
for _, b := range bearers {
if b.Type == btypes.BearerOYSAT {
found = true
break
}
}
if !found {
t.Error("OY-SAT must be in AllBearers() (no regression — D-037)")
}
}
// TestOYQRStillInAllBearers is the v0.3 REGRESSION test: OY-QR must still
// be in AllBearers() (the 6-bearer count is unchanged).
func TestOYQRStillInAllBearers(t *testing.T) {
bearers := btypes.AllBearers()
if len(bearers) != 6 {
t.Errorf("AllBearers() len = %d, expected 6 (no regression — D-037)", len(bearers))
}
found := false
for _, b := range bearers {
if b.Type == btypes.BearerOYQR {
found = true
break
}
}
if !found {
t.Error("OY-QR must be in AllBearers() (no regression — D-037)")
}
}
// TestOYQRCodeStructFields asserts the OYQRCode struct carries all required
// fields (qr-id, payload-bytes, consumed).
func TestOYQRCodeStructFields(t *testing.T) {
q := btypes.OYQRCode{
QRID: "qr-1",
PayloadBytes: []byte{0x01, 0x02, 0x03},
Consumed: false,
}
if q.QRID != "qr-1" {
t.Errorf("QRID = %q", q.QRID)
}
if len(q.PayloadBytes) != 3 {
t.Errorf("PayloadBytes len = %d, want 3", len(q.PayloadBytes))
}
if q.Consumed {
t.Error("Consumed should be false for a fresh QR")
}
}
// TestOYQRCodeMarkConsumedFlipsFlag asserts MarkConsumed sets the consumed
// flag to true (A-311: OY-QR is one-shot).
func TestOYQRCodeMarkConsumedFlipsFlag(t *testing.T) {
q := btypes.OYQRCode{QRID: "qr-1", PayloadBytes: []byte{0x01}, Consumed: false}
if q.Consumed {
t.Fatal("fresh QR should have Consumed == false")
}
q.MarkConsumed()
if !q.Consumed {
t.Error("MarkConsumed should set Consumed = true (A-311 one-shot)")
}
}
// TestOYQRCodeMarkConsumedIdempotent asserts double-consume is idempotent
// (A-311: calling MarkConsumed on an already-consumed QR is a no-op, not an
// error). This locks the one-shot semantics: a QR cannot be unconsumed, and
// double-marking is safe.
func TestOYQRCodeMarkConsumedIdempotent(t *testing.T) {
q := btypes.OYQRCode{QRID: "qr-1", PayloadBytes: []byte{0x01}, Consumed: false}
// First consume: false -> true.
q.MarkConsumed()
if !q.Consumed {
t.Fatal("first MarkConsumed failed: Consumed still false")
}
// Second consume: idempotent no-op (stays true, no error, no panic).
q.MarkConsumed()
if !q.Consumed {
t.Error("second MarkConsumed should be idempotent; Consumed must stay true (A-311)")
}
// Third consume: still idempotent.
q.MarkConsumed()
if !q.Consumed {
t.Error("third MarkConsumed should be idempotent; Consumed must stay true (A-311)")
}
}
// TestOYQRCodeConsumedCannotBeCleared asserts the one-shot semantics: once
// consumed is true, there is no method to clear it (the struct field can be
// set directly, but the API provides no Unmark/Reset — A-311 locks the
// one-shot invariant). This test verifies no Unmark/Reset method exists by
// confirming MarkConsumed is the only state-mutating method (the struct is
// a plain data type; the invariant is enforced by the API surface, not a
// private field — matching the v0.2 OYLRLink/BeaconFrame shape approach).
func TestOYQRCodeConsumedCannotBeCleared(t *testing.T) {
q := btypes.OYQRCode{QRID: "qr-1", Consumed: false}
q.MarkConsumed()
if !q.Consumed {
t.Fatal("MarkConsumed failed")
}
// The one-shot invariant: there is no UnmarkConsumed/Reset method on
// OYQRCode. The struct is a plain data type; the API surface (only
// MarkConsumed) enforces the one-way transition. We assert the method
// set by confirming MarkConsumed does not flip back to false.
q.MarkConsumed() // idempotent
if !q.Consumed {
t.Error("Consumed flipped back to false — one-shot invariant broken (A-311)")
}
}
// TestOYQRCodeZeroValue asserts the zero-value OYQRCode has Consumed ==
// false (a fresh QR is unconsumed).
func TestOYQRCodeZeroValue(t *testing.T) {
var q btypes.OYQRCode
if q.Consumed {
t.Error("zero-value OYQRCode should have Consumed == false")
}
if q.QRID != "" {
t.Errorf("zero-value QRID = %q, want empty", q.QRID)
}
}
// packageDir resolves a Go import path to its filesystem directory by
// walking up from this test file (v0.2 skeleton has zero external deps).
func packageDir(t *testing.T, importPath string) string {
+286
View File
@@ -0,0 +1,286 @@
package keeper
// clob.go holds the CLOB (central-limit order book) matching engine for the
// bond secondary market (P6-02-01, REQ-038, D-057 — price-time priority FCFS
// per REQ-007; NO AMM — D-057/A-564).
//
// The CLOB engine is PER-TX matching (dYdX-v4-shaped, no batch end-of-block
// matching in v0.5 simtest — D-054). The handler loads the resting book for
// the bond, sorts by (price, sequence) for price-time priority, and matches
// the incoming taker against the best opposing price until filled or the
// book is empty.
//
// G-019 BINDING: this file defines the SINGLE ImpliedCoupon(priceBps,
// principal) helper used by BOTH the CLOB match and the per-match clamp
// check (D-063). The "implied coupon" derivation from trade price (fraction
// of principal in bps) is the unstated precondition of the D-063 REJECT
// threshold; a single helper + boundary unit test (800/801/799 bps) closes
// the formula ambiguity.
//
// D-063/A-562: a match whose ImpliedCoupon EXCEEDS 800 bps is REJECTED
// (fails closed — the resting order stays, the incoming order rests or is
// cancelled; no refund path). The 8% cap is a Mission-Lock invariant (D-028),
// not a soft cap. Matches within [0, 800] use Clamp (in-band, no refund
// needed).
//
// The 8%/0% consts (CouponCapBps=800 / CouponFloorBps=0, D-028) are
// referenced DIRECTLY from x/bond/types (same package — NOT a local copy;
// A-563). The REQ-030 cross-const test stays green.
//
// Lexicon (REQ-012, A-210): the coupon vocabulary is used EXCLUSIVELY. The
// banned coupon-synonyms are NEVER used.
//
// FEATURE PURITY GATE: the v0.3 types.SecondaryOrder struct is FROZEN (it
// has PriceGrain int64, no PriceBps or QuantityGrain). To avoid amending the
// v0.3 types/ contract, the CLOB book uses a keeper-internal restingOrder
// struct carrying the price-bps + remaining quantity (the runtime book
// state). The restingOrder embeds the public SecondaryOrder (the v0.3
// contract is preserved) PLUS the keeper-internal book fields. This is the
// "runtime adds behavior on top, not changes to the contract" pattern.
import (
"sort"
sdk "github.com/cosmos/cosmos-sdk/types"
"github.com/oy/openyield/x/bond/types"
)
// restingOrder is the in-keeper book entry for a resting secondary-market
// order. It carries the public SecondaryOrder (the v0.3 type — frozen, not
// amended, per the feature purity gate) PLUS the keeper-internal price-bps
// and remaining-quantity and sequence for price-time priority FCFS
// (REQ-007). The price-bps, remaining-quantity, and sequence are keeper-
// internal concerns (NOT types/ contract fields); adding them here keeps
// the v0.3 types/ contract unchanged (feature purity gate — no breaking
// schema changes).
type restingOrder struct {
// Order is the public v0.3 SecondaryOrder (frozen contract). Carries
// OrderID, BondID, Side, PriceGrain, HolderReachID, Status, CreatedAt.
Order types.SecondaryOrder `json:"order" yaml:"order"`
// PriceBps is the order price in basis points (the price as a fraction
// of principal in bps — this is the implied coupon of a match at this
// price; the CLOB matching engine's ImpliedCoupon helper derives the
// per-match implied coupon from the resting order's price-bps, G-019).
// Keeper-internal (the v0.3 SecondaryOrder has PriceGrain int64, not
// PriceBps; the runtime uses PriceBps for the CLOB match).
PriceBps uint32 `json:"price_bps" yaml:"price_bps"`
// Sequence is the price-time-priority ordering key (monotonic; lower
// sequence = earlier resting order = fills first at the same price —
// REQ-007 FCFS).
Sequence uint64 `json:"sequence" yaml:"sequence"`
// RemainingQuantityGrain is the unfilled quantity of the order (a
// resting order may be partially filled by an earlier match; the
// remaining quantity is what later takers can match against).
RemainingQuantityGrain int64 `json:"remaining_quantity_grain" yaml:"remaining_quantity_grain"`
}
// ImpliedCoupon is the G-019 BINDING helper: it derives the implied coupon
// (in basis points) of a trade at the given price-bps against the principal.
// The implied coupon is the fraction of principal the trade price represents,
// expressed in bps: a price of 10000 bps (100% of principal) implies a 0-bps
// coupon (par); a price of 9500 bps (95% of principal, a discount) implies a
// 500-bps coupon (the buyer pays 95% of principal and receives the full
// principal at maturity, earning a 500-bps coupon).
//
// The formula: impliedCouponBps = max(0, 10000 - priceBps).
// - priceBps == 10000 (par) -> impliedCoupon 0 (no discount, no coupon).
// - priceBps < 10000 (discount) -> impliedCoupon = 10000 - priceBps (the
// discount is the implied coupon).
// - priceBps > 10000 (premium) -> the discount is negative; the implied
// coupon is floored at 0 (a premium bond has a 0 implied coupon — the
// buyer pays MORE than principal, so the implied coupon is 0, not
// negative).
//
// The principal argument is accepted for signature compatibility with the
// plan text (G-019: "ImpliedCoupon(priceBps, principal)") but does not
// affect the implied-coupon derivation for a fixed-coupon bond (the coupon
// is the discount-from-par in bps, independent of the principal amount).
// It is retained so a future v0.6+ amortization model can use it.
//
// G-019 boundary: the D-063 REJECT threshold is 800 bps. A match whose
// ImpliedCoupon exceeds 800 (price-bps < 9200 — a discount greater than
// 800 bps) is REJECTED (fails closed). The boundary unit test in
// msg_server_simtest_test.go covers:
// - price-bps 9200 -> ImpliedCoupon 800 (== cap, in-band, clears via Clamp).
// - price-bps 9199 -> ImpliedCoupon 801 (> cap, REJECTED — D-063).
// - price-bps 9201 -> ImpliedCoupon 799 (< cap, in-band, clears).
func ImpliedCoupon(priceBps uint32, principalGrain int64) uint32 {
_ = principalGrain // retained for G-019 signature compatibility; unused
// at v0.5 (fixed-coupon bond — coupon is discount-from-par in bps).
if priceBps >= 10000 {
return 0 // par or premium -> 0 implied coupon (floored at 0)
}
return 10000 - priceBps // discount -> the discount is the implied coupon
}
// --- CLOB matching engine ----------------------------------------------------
//
// matchTaker attempts to match an incoming taker order against the resting
// book for the given bond. Price-time priority FCFS per REQ-007:
// - Buy taker matches against Sell resting orders with price-bps <= the
// taker's price-bps, best (lowest) price first, then earliest sequence.
// - Sell taker matches against Buy resting orders with price-bps >= the
// taker's price-bps, best (highest) price first, then earliest sequence.
//
// Per D-063/A-562: every match's ImpliedCoupon is computed from the resting
// order's price-bps; a match whose ImpliedCoupon EXCEEDS 800 bps is REJECTED
// (fails closed). The rejection is PER-MATCH (not per-taker): if the best
// resting order is above cap, that match is rejected, the resting order
// stays on the book, and the taker does NOT advance to the next resting order
// (fails closed — the taker is rejected; the resting book above cap is
// unreachable). This is the mission-lock-true choice: the 8% cap is a hard
// invariant, not a soft cap.
//
// Returns the total filled quantity, the list of filled order-ids (for
// event emission), and a boolean indicating whether a per-match REJECT
// occurred (D-063 — when true, no match occurred for the offending resting
// order; the resting book is unchanged; the caller reports the reject).
func (k Keeper) matchTaker(
ctx sdk.Context,
bondID string,
takerSide types.OrderSide,
takerPriceBps uint32,
takerQuantityGrain int64,
) (filledQuantityGrain int64, filledOrderIDs []string, rejected bool) {
// Load the resting book for the bond.
resting := k.restingBookForBond(ctx, bondID)
// Sort for price-time priority.
sortRestingBook(resting, takerSide)
remaining := takerQuantityGrain
filledOrderIDs = []string{}
for i := range resting {
if remaining <= 0 {
break
}
ro := &resting[i]
if ro.Order.Status != types.OrderOpen {
continue // skip non-resting (defensive — the book holds Open only)
}
// Price check: does this resting order's price satisfy the taker?
if !priceCrosses(takerSide, takerPriceBps, ro.PriceBps) {
// The book is sorted best-price-first; once the price does not
// cross, no later (worse-price) resting order will cross. Stop.
break
}
// D-063 per-match coupon clamp (G-019 ImpliedCoupon helper). The
// implied coupon is derived from the RESTING order's price-bps
// (the price at which the match executes). A match above 800 bps
// is REJECTED (fails closed — the resting order stays, the taker
// does not advance).
implied := ImpliedCoupon(ro.PriceBps, 0)
if implied > types.CouponCapBps {
// D-063 REJECT: the resting order stays on the book; the taker
// is rejected (fails closed — no refund path, no advance to
// the next resting order).
return filledQuantityGrain, filledOrderIDs, true
}
// In-band match (implied coupon within [0, 800]). Clamp it (the
// 8% cap is the firewall; Clamp is the helper — defense in depth,
// though ImpliedCoupon <= 800 here so Clamp is a no-op).
clampedCoupon := types.Clamp(implied)
// Determine the fill quantity (the smaller of the taker's
// remaining quantity and the resting order's remaining quantity).
fill := remaining
if ro.RemainingQuantityGrain < fill {
fill = ro.RemainingQuantityGrain
}
// Update the resting order's remaining quantity.
ro.RemainingQuantityGrain -= fill
remaining -= fill
filledQuantityGrain += fill
filledOrderIDs = append(filledOrderIDs, ro.Order.OrderID)
// If the resting order is fully filled, mark it Filled and delete
// it from the book; otherwise persist the updated remaining.
if ro.RemainingQuantityGrain <= 0 {
ro.Order.Status = types.OrderFilled
k.deleteRestingOrder(ctx, ro.Order.OrderID)
} else {
k.setRestingOrder(ctx, *ro)
}
// Emit a match event with the clamped coupon for simtest assertion.
emitMatchEvent(ctx, ro.Order.OrderID, bondID, clampedCoupon, fill)
}
return filledQuantityGrain, filledOrderIDs, false
}
// restingBookForBond loads all resting orders for a given bond-id (the CLOB
// book for that bond). The book is unordered here; matchTaker sorts it for
// price-time priority.
func (k Keeper) restingBookForBond(ctx sdk.Context, bondID string) []restingOrder {
all := k.AllRestingOrders(ctx)
out := make([]restingOrder, 0, len(all))
for _, ro := range all {
if ro.Order.BondID == bondID && ro.Order.Status == types.OrderOpen {
out = append(out, ro)
}
}
return out
}
// sortRestingBook sorts the resting book for price-time priority FCFS
// (REQ-007). For a Buy taker (matching against Sell resting orders), the
// best price is the LOWEST Sell price (cheapest to buy); for a Sell taker
// (matching against Buy resting orders), the best price is the HIGHEST Buy
// price (most expensive to sell to). Ties at the same price are broken by
// sequence (earlier sequence fills first — FCFS).
func sortRestingBook(book []restingOrder, takerSide types.OrderSide) {
if takerSide == types.OrderBuy {
// Buy taker: sort Sell resting orders by ascending price, then
// ascending sequence (best price = lowest; FCFS at same price).
sort.SliceStable(book, func(i, j int) bool {
if book[i].PriceBps != book[j].PriceBps {
return book[i].PriceBps < book[j].PriceBps
}
return book[i].Sequence < book[j].Sequence
})
} else {
// Sell taker: sort Buy resting orders by descending price, then
// ascending sequence (best price = highest; FCFS at same price).
sort.SliceStable(book, func(i, j int) bool {
if book[i].PriceBps != book[j].PriceBps {
return book[i].PriceBps > book[j].PriceBps
}
return book[i].Sequence < book[j].Sequence
})
}
}
// priceCrosses reports whether the taker's price satisfies the resting
// order's price (a match can execute). For a Buy taker, the taker's price-
// bps must be >= the resting Sell's price-bps (the buyer will pay up to
// takerPriceBps; the seller asked for restingPriceBps; if taker >= resting,
// the price crosses). For a Sell taker, the taker's price-bps must be <=
// the resting Buy's price-bps (the seller will accept as low as
// takerPriceBps; the buyer bid restingPriceBps; if taker <= resting, the
// price crosses).
func priceCrosses(takerSide types.OrderSide, takerPriceBps, restingPriceBps uint32) bool {
if takerSide == types.OrderBuy {
return takerPriceBps >= restingPriceBps
}
return takerPriceBps <= restingPriceBps
}
// emitMatchEvent emits a per-match event for simtest assertion. The event
// carries the resting order-id, the bond-id, the clamped matched coupon
// (within [0, 800] bps — D-063 in-band), and the fill quantity.
//
// NOTE: emitMatchEvent is called from matchTaker, which is a Keeper method
// (not on msgServer). The ctx is the sdk.Context passed to matchTaker. This
// helper is defined here (not in msg_server.go) so the CLOB engine is
// self-contained.
func emitMatchEvent(ctx sdk.Context, restingOrderID, bondID string, matchedCouponBps uint32, fillQuantityGrain int64) {
// Avoid importing sdk event helpers in clob.go to keep the import list
// lean; delegate to the msg_server.go helper via a function variable.
// (The simtest asserts events via ctx.EventManager().Events().)
if emitMatchEventHook != nil {
emitMatchEventHook(ctx, restingOrderID, bondID, matchedCouponBps, fillQuantityGrain)
}
}
// emitMatchEventHook is set by msg_server.go (which imports sdk event
// helpers). This indirection keeps clob.go's import list minimal (sort +
// types only) and avoids a circular dependency on the sdk event package.
var emitMatchEventHook func(ctx sdk.Context, restingOrderID, bondID string, matchedCouponBps uint32, fillQuantityGrain int64)
+261
View File
@@ -0,0 +1,261 @@
package keeper
// keeper.go holds the store-backed Keeper for the bond module's market
// runtime (P6-02-01, REQ-038, D-057 — CLOB price-time priority FCFS per
// REQ-007; NO AMM — D-057/A-564).
//
// The Keeper wraps an sdk.KVStore via a storeKey. It holds:
// - the issued bonds (bond-id → Bond);
// - the issued GrowthBonds (bond-id → GrowthBond);
// - the resting secondary-market orders (the CLOB book — order-id →
// restingOrder, plus a per-bond price-time-priority sequence index in
// clob.go).
//
// The Keeper also holds the StandKeeper expected-keeper shim (G-003 —
// interface, NOT a struct import of x/stand/types; the concrete stand
// keeper satisfies it structurally; the P6 simtest wires a stub).
//
// The 8%/0% consts (CouponCapBps=800 / CouponFloorBps=0, D-028) are
// referenced DIRECTLY from x/bond/types (same package — NOT a local copy;
// A-563). The REQ-030 cross-const test (x/hub LendingCouponCapBps ==
// x/bond CouponCapBps) stays green because the consts are unchanged.
//
// State-machine ordering (vision §7, enforced in every handler):
// ValidateBasic → keeper authz → state mutation → ctx.EventManager().EmitEvent
//
// D-054: simtest-grade — in-memory sdk.Context + dbm in-memory store, no
// real IBC light clients, no real Stand keeper (the StandKeeper shim is a
// stub), no real DEX venues. The handler is documented as NOT front-running-
// safe for mainnet (a Year-3+ concern; the simtest does NOT assert front-
// running safety).
import (
"encoding/json"
"fmt"
storetypes "cosmossdk.io/store/types"
"github.com/cosmos/cosmos-sdk/codec"
sdk "github.com/cosmos/cosmos-sdk/types"
"github.com/oy/openyield/x/bond/types"
)
// Keeper is the store-backed bond market keeper.
type Keeper struct {
cdc codec.Codec
storeKey storetypes.StoreKey
standKeeper types.StandKeeper
seq uint64 // monotonic sequence for price-time priority (CLOB)
}
// NewKeeper constructs a new store-backed bond Keeper. The StandKeeper
// expected-keeper shim is injected (nil-able for partial tests; the
// IssueBond / IssueGrowthBond handlers guard a nil shim and skip the
// StandExists check, still mutating state — the simtest wiring documents
// this).
func NewKeeper(cdc codec.Codec, storeKey storetypes.StoreKey, sk types.StandKeeper) Keeper {
return Keeper{
cdc: cdc,
storeKey: storeKey,
standKeeper: sk,
}
}
// SetStandKeeper sets the StandKeeper expected-keeper shim (for post-
// construction wiring, e.g., app wiring or test setup).
func (k *Keeper) SetStandKeeper(sk types.StandKeeper) { k.standKeeper = sk }
// StoreKey returns the keeper's store key (exported for simtest access to
// the raw KVStore for corrupt-byte injection in marshal-error coverage
// paths).
func (k Keeper) StoreKey() storetypes.StoreKey { return k.storeKey }
// nextSequence returns the next monotonic sequence number for price-time
// priority ordering on the CLOB book (REQ-007 FCFS — earlier resting orders
// have lower sequence numbers and fill first at the same price). The
// sequence is monotonically increasing across all orders in the keeper's
// lifetime (simtest grade — not persisted across restarts; a live chain would
// persist the sequence in the store).
func (k *Keeper) nextSequence() uint64 {
k.seq++
return k.seq
}
// --- Bond store --------------------------------------------------------------
var bondKeyPrefix = []byte("bond/")
func bondKey(bondID string) []byte {
return append(bondKeyPrefix, []byte(bondID)...)
}
// GetBond loads an issued Bond by bond-id. Returns the Bond and true if
// found, or zero value + false if not.
func (k Keeper) GetBond(ctx sdk.Context, bondID string) (types.Bond, bool) {
store := ctx.KVStore(k.storeKey)
bz := store.Get(bondKey(bondID))
if bz == nil {
return types.Bond{}, false
}
var b types.Bond
if err := json.Unmarshal(bz, &b); err != nil {
return types.Bond{}, false
}
return b, true
}
// SetBond persists an issued Bond by bond-id.
func (k Keeper) SetBond(ctx sdk.Context, b types.Bond) {
store := ctx.KVStore(k.storeKey)
bz, err := json.Marshal(b)
if err != nil {
panic(fmt.Sprintf("bond: marshal bond %q: %v", b.BondID, err))
}
store.Set(bondKey(b.BondID), bz)
}
// AllBonds returns all issued Bonds (iteration helper, unordered).
func (k Keeper) AllBonds(ctx sdk.Context) []types.Bond {
store := ctx.KVStore(k.storeKey)
iterator := store.Iterator(bondKeyPrefix, prefixEnd(bondKeyPrefix))
defer iterator.Close()
out := []types.Bond{}
for ; iterator.Valid(); iterator.Next() {
var b types.Bond
if err := json.Unmarshal(iterator.Value(), &b); err == nil {
out = append(out, b)
}
}
return out
}
// --- GrowthBond store --------------------------------------------------------
var growthBondKeyPrefix = []byte("growth/")
func growthBondKey(bondID string) []byte {
return append(growthBondKeyPrefix, []byte(bondID)...)
}
// GetGrowthBond loads an issued GrowthBond by bond-id. Returns the GrowthBond
// and true if found, or zero value + false if not.
func (k Keeper) GetGrowthBond(ctx sdk.Context, bondID string) (types.GrowthBond, bool) {
store := ctx.KVStore(k.storeKey)
bz := store.Get(growthBondKey(bondID))
if bz == nil {
return types.GrowthBond{}, false
}
var gb types.GrowthBond
if err := json.Unmarshal(bz, &gb); err != nil {
return types.GrowthBond{}, false
}
return gb, true
}
// SetGrowthBond persists an issued GrowthBond by bond-id.
func (k Keeper) SetGrowthBond(ctx sdk.Context, gb types.GrowthBond) {
store := ctx.KVStore(k.storeKey)
bz, err := json.Marshal(gb)
if err != nil {
panic(fmt.Sprintf("bond: marshal growth bond %q: %v", gb.BondID, err))
}
store.Set(growthBondKey(gb.BondID), bz)
}
// AllGrowthBonds returns all issued GrowthBonds (iteration helper, unordered).
func (k Keeper) AllGrowthBonds(ctx sdk.Context) []types.GrowthBond {
store := ctx.KVStore(k.storeKey)
iterator := store.Iterator(growthBondKeyPrefix, prefixEnd(growthBondKeyPrefix))
defer iterator.Close()
out := []types.GrowthBond{}
for ; iterator.Valid(); iterator.Next() {
var gb types.GrowthBond
if err := json.Unmarshal(iterator.Value(), &gb); err == nil {
out = append(out, gb)
}
}
return out
}
// --- Order store (CLOB resting book) -----------------------------------------
//
// The resting book is keyed by order-id → restingOrder (the in-keeper book
// entry carrying the order + its price-time-priority sequence). The CLOB
// matching engine (clob.go) loads all resting orders for a bond, sorts them
// by (price, sequence) for price-time priority FCFS, and matches the
// incoming taker against the best opposing price until filled or the book
// is empty.
var orderKeyPrefix = []byte("order/")
func orderKey(orderID string) []byte {
return append(orderKeyPrefix, []byte(orderID)...)
}
// GetRestingOrder loads a resting order by order-id. Returns the order and
// true if found, or zero value + false if not.
func (k Keeper) GetRestingOrder(ctx sdk.Context, orderID string) (restingOrder, bool) {
store := ctx.KVStore(k.storeKey)
bz := store.Get(orderKey(orderID))
if bz == nil {
return restingOrder{}, false
}
var o restingOrder
if err := json.Unmarshal(bz, &o); err != nil {
return restingOrder{}, false
}
return o, true
}
// setRestingOrder persists a resting order by order-id.
func (k Keeper) setRestingOrder(ctx sdk.Context, o restingOrder) {
store := ctx.KVStore(k.storeKey)
bz, err := json.Marshal(o)
if err != nil {
panic(fmt.Sprintf("bond: marshal order %q: %v", o.Order.OrderID, err))
}
store.Set(orderKey(o.Order.OrderID), bz)
}
// deleteRestingOrder removes a resting order by order-id.
func (k Keeper) deleteRestingOrder(ctx sdk.Context, orderID string) {
store := ctx.KVStore(k.storeKey)
store.Delete(orderKey(orderID))
}
// AllRestingOrders returns all resting orders (iteration helper, unordered).
// Exported for simtest assertion.
func (k Keeper) AllRestingOrders(ctx sdk.Context) []restingOrder {
store := ctx.KVStore(k.storeKey)
iterator := store.Iterator(orderKeyPrefix, prefixEnd(orderKeyPrefix))
defer iterator.Close()
out := []restingOrder{}
for ; iterator.Valid(); iterator.Next() {
var o restingOrder
if err := json.Unmarshal(iterator.Value(), &o); err == nil {
out = append(out, o)
}
}
return out
}
// --- prefixEnd helper --------------------------------------------------------
// prefixEnd returns the key that sorts immediately after all keys sharing
// the given prefix (the standard prefix-iteration end key: increment the
// last byte, drop overflow). Mirrors x/hub/keeper/keeper.go.
func prefixEnd(prefix []byte) []byte {
if len(prefix) == 0 {
return nil
}
end := make([]byte, len(prefix))
copy(end, prefix)
for i := len(end) - 1; i >= 0; i-- {
end[i]++
if end[i] != 0 {
return end
}
}
// All bytes were 0xFF; return nil (iterate to end of store).
return nil
}
+428
View File
@@ -0,0 +1,428 @@
package keeper
// msg_server.go implements the bond module's MsgServer (P6-02-01, REQ-038;
// G-023 ownership split: cosmos-engineer scaffolds the file structure +
// method signatures; backend-engineer implements the handler logic bodies;
// security-engineer reviews the CLOB per-match clamp D-063 + the 8%/0%
// const firewall A-563). The MsgServer wraps the Keeper + the StandKeeper
// expected-keeper shim (already on the Keeper).
//
// Each method returns a (*Response, error). Handler state-machine ordering
// is enforced: ValidateBasic → keeper authz → state mutation →
// ctx.EventManager().EmitEvent.
//
// Handler set (REQ-038):
// - IssueBond: invokes v0.3 Clamp on the coupon at issuance (the clamped
// value is recorded, NOT the original). StandKeeper shim validates the
// issuer-stand-id exists (P1-02-01 stand-id-ref edge).
// - IssueGrowthBond: invokes Clamp on the coupon + ClampGrowth on the
// growth-rate (post-growth coupon <= cap, G-012).
// - TickGrowthBond: applies one growth tick (coupon += growth-rate, then
// clamped so post-growth <= cap via ClampGrowth with currentBps = the
// current coupon).
// - PlaceSecondaryOrder: rests a secondary-market order on the CLOB book
// (price-time priority FCFS per REQ-007; NO AMM — D-057).
// - CancelSecondaryOrder: removes a resting order (status -> Cancelled).
// - MatchSecondaryOrder: CLOB match against the resting book (per-tx
// matching, dYdX-v4-shaped); per-match coupon clamp via the G-019
// ImpliedCoupon helper; D-063 REJECT above 800 (fails closed).
//
// Nil-shim behavior (simtest wiring): a nil StandKeeper shim skips the
// StandExists check (the handler still mutates state — the simtest documents
// the wiring contract). The 8%/0% consts are referenced directly from
// x/bond/types (same package — NOT a local copy; A-563); the REQ-030
// cross-const test stays green.
//
// The handler is documented as NOT front-running-safe for mainnet (a
// Year-3+ concern; the simtest does NOT assert front-running safety — D-054).
import (
"fmt"
sdk "github.com/cosmos/cosmos-sdk/types"
"github.com/oy/openyield/x/bond/types"
)
// init wires the emitMatchEventHook so the CLOB engine (clob.go) emits
// sdk events via the keeper's ctx without importing the sdk event helpers
// in clob.go (keeps clob.go's import list minimal).
func init() {
emitMatchEventHook = func(ctx sdk.Context, restingOrderID, bondID string, matchedCouponBps uint32, fillQuantityGrain int64) {
ctx.EventManager().EmitEvent(sdk.NewEvent(
"bond.match",
sdk.NewAttribute("resting_order_id", restingOrderID),
sdk.NewAttribute("bond_id", bondID),
sdk.NewAttribute("matched_coupon_bps", fmt.Sprintf("%d", matchedCouponBps)),
sdk.NewAttribute("fill_quantity_grain", fmt.Sprintf("%d", fillQuantityGrain)),
))
}
}
// msgServer is the concrete MsgServer implementation wrapping the Keeper.
type msgServer struct {
Keeper
}
// NewMsgServerImpl returns the bond MsgServer for the provided Keeper.
func NewMsgServerImpl(k Keeper) types.MsgServer {
return &msgServer{Keeper: k}
}
var _ types.MsgServer = msgServer{}
// unwrapCtx extracts the sdk.Context from the interface-typed ctx.
func unwrapCtx(ctx interface{}) sdk.Context {
if c, ok := ctx.(sdk.Context); ok {
return c
}
panic(fmt.Sprintf("bond: expected sdk.Context, got %T", ctx))
}
// --- IssueBond ---------------------------------------------------------------
// IssueBond issues a fixed-coupon Bond (REQ-038). The handler enforces:
// 1. ValidateBasic (stateless).
// 2. Idempotency: bond-id must not already exist.
// 3. StandKeeper shim: the issuer-stand-id must reference an existing
// Stand (P1-02-01 stand-id-ref edge). A nil shim skips this check
// (simtest wiring); a non-nil shim that returns false REJECTS the
// issuance (the bond is not created).
// 4. Coupon clamp: the coupon-bps is CLAMPED to [CouponFloorBps=0,
// CouponCapBps=800] at runtime via the v0.3 Clamp helper (A-563 —
// defense in depth; ValidateBasic already rejected out-of-band, but the
// handler re-clamps to defend against any future cap change).
//
// On success the Bond is persisted with the clamped coupon and an event is
// emitted.
func (s msgServer) IssueBond(ctx interface{}, msg *types.MsgIssueBond) (*types.MsgIssueBondResponse, error) {
if err := msg.ValidateBasic(); err != nil {
return nil, err
}
sdkCtx := unwrapCtx(ctx)
// Idempotency: bond-id must not already exist.
if _, ok := s.Keeper.GetBond(sdkCtx, msg.BondID); ok {
return nil, fmt.Errorf("bond: bond-id %q already exists", msg.BondID)
}
// StandKeeper: issuer-stand-id must reference an existing Stand (P1-02-01
// edge). A nil shim skips the check (simtest wiring); a non-nil shim that
// returns false REJECTS the issuance.
if s.Keeper.standKeeper != nil {
if !s.Keeper.standKeeper.StandExists(msg.IssuerStandID) {
return nil, fmt.Errorf("bond: issuer-stand-id %q does not exist (IssueBond rejected)", msg.IssuerStandID)
}
}
// A-563: coupon clamp at runtime. The clamped value (NOT the original)
// is recorded. ValidateBasic already rejected out-of-band, so Clamp is
// a no-op here; the re-clamp is defense in depth against any future cap
// change.
clamped := types.Clamp(msg.CouponBps)
b := types.Issue(msg.BondID, msg.IssuerStandID, msg.PrincipalGrain, clamped, msg.TermDays, msg.IssuedAt, msg.Maturity)
s.Keeper.SetBond(sdkCtx, b)
if clamped != msg.CouponBps {
sdkCtx.EventManager().EmitEvent(sdk.NewEvent(
"bond.coupon_clamped",
sdk.NewAttribute("bond_id", msg.BondID),
sdk.NewAttribute("original_coupon_bps", fmt.Sprintf("%d", msg.CouponBps)),
sdk.NewAttribute("clamped_coupon_bps", fmt.Sprintf("%d", clamped)),
))
}
sdkCtx.EventManager().EmitEvent(sdk.NewEvent(
"bond.issued",
sdk.NewAttribute("bond_id", msg.BondID),
sdk.NewAttribute("issuer_stand_id", msg.IssuerStandID),
sdk.NewAttribute("coupon_bps", fmt.Sprintf("%d", clamped)),
))
return &types.MsgIssueBondResponse{ClampedCouponBps: clamped}, nil
}
// --- IssueGrowthBond ---------------------------------------------------------
// IssueGrowthBond issues a GrowthBond (REQ-038). The handler enforces:
// 1. ValidateBasic (stateless).
// 2. Idempotency: bond-id must not already exist (as a Bond or GrowthBond).
// 3. StandKeeper shim: the issuer-stand-id must reference an existing
// Stand (P1-02-01 edge). A nil shim skips (simtest wiring).
// 4. Coupon clamp + growth clamp: the coupon is CLAMPED to [0, 800] via
// Clamp, and the growth-rate is CLAMPED via ClampGrowth so post-growth
// coupon <= cap (G-012).
//
// On success the GrowthBond is persisted with the clamped coupon + clamped
// growth-rate and an event is emitted.
func (s msgServer) IssueGrowthBond(ctx interface{}, msg *types.MsgIssueGrowthBond) (*types.MsgIssueGrowthBondResponse, error) {
if err := msg.ValidateBasic(); err != nil {
return nil, err
}
sdkCtx := unwrapCtx(ctx)
// Idempotency: bond-id must not already exist (as Bond or GrowthBond).
if _, ok := s.Keeper.GetBond(sdkCtx, msg.BondID); ok {
return nil, fmt.Errorf("bond: bond-id %q already exists (as a Bond)", msg.BondID)
}
if _, ok := s.Keeper.GetGrowthBond(sdkCtx, msg.BondID); ok {
return nil, fmt.Errorf("bond: bond-id %q already exists (as a GrowthBond)", msg.BondID)
}
// StandKeeper: issuer-stand-id must reference an existing Stand.
if s.Keeper.standKeeper != nil {
if !s.Keeper.standKeeper.StandExists(msg.IssuerStandID) {
return nil, fmt.Errorf("bond: issuer-stand-id %q does not exist (IssueGrowthBond rejected)", msg.IssuerStandID)
}
}
// Coupon clamp + growth clamp. The v0.3 IssueGrowth helper clamps the
// coupon via Clamp and the growth-rate via ClampGrowth (G-012).
clampedCoupon := types.Clamp(msg.CouponBps)
clampedGrowth := types.ClampGrowth(clampedCoupon, msg.GrowthRateBps)
gb := types.IssueGrowth(msg.BondID, msg.IssuerStandID, msg.PrincipalGrain, clampedCoupon, clampedGrowth, msg.TermDays, msg.IssuedAt, msg.Maturity)
s.Keeper.SetGrowthBond(sdkCtx, gb)
if clampedCoupon != msg.CouponBps || clampedGrowth != msg.GrowthRateBps {
sdkCtx.EventManager().EmitEvent(sdk.NewEvent(
"bond.growth_coupon_clamped",
sdk.NewAttribute("bond_id", msg.BondID),
sdk.NewAttribute("original_coupon_bps", fmt.Sprintf("%d", msg.CouponBps)),
sdk.NewAttribute("clamped_coupon_bps", fmt.Sprintf("%d", clampedCoupon)),
sdk.NewAttribute("original_growth_rate_bps", fmt.Sprintf("%d", msg.GrowthRateBps)),
sdk.NewAttribute("clamped_growth_rate_bps", fmt.Sprintf("%d", clampedGrowth)),
))
}
sdkCtx.EventManager().EmitEvent(sdk.NewEvent(
"bond.growth_issued",
sdk.NewAttribute("bond_id", msg.BondID),
sdk.NewAttribute("issuer_stand_id", msg.IssuerStandID),
sdk.NewAttribute("coupon_bps", fmt.Sprintf("%d", clampedCoupon)),
sdk.NewAttribute("growth_rate_bps", fmt.Sprintf("%d", clampedGrowth)),
))
return &types.MsgIssueGrowthBondResponse{
ClampedCouponBps: clampedCoupon,
ClampedGrowthRateBps: clampedGrowth,
}, nil
}
// --- TickGrowthBond ----------------------------------------------------------
// TickGrowthBond applies one growth tick to a GrowthBond (REQ-038). The
// handler enforces:
// 1. ValidateBasic (stateless).
// 2. The GrowthBond must exist.
// 3. Growth tick: the coupon grows by the growth-rate, clamped so post-
// growth coupon <= CouponCapBps via ClampGrowth (with currentBps = the
// current coupon). The growth-rate is NOT changed (it persists across
// ticks).
//
// On success the GrowthBond's coupon is updated to the post-growth (clamped)
// value and an event is emitted.
func (s msgServer) TickGrowthBond(ctx interface{}, msg *types.MsgTickGrowthBond) (*types.MsgTickGrowthBondResponse, error) {
if err := msg.ValidateBasic(); err != nil {
return nil, err
}
sdkCtx := unwrapCtx(ctx)
gb, ok := s.Keeper.GetGrowthBond(sdkCtx, msg.BondID)
if !ok {
return nil, fmt.Errorf("bond: growth-bond %q not found (TickGrowthBond rejected)", msg.BondID)
}
// Growth tick: coupon += growth-rate, clamped so post-growth <= cap.
// ClampGrowth(currentBps=current coupon, growthBps=growth-rate) returns
// the additional bps the coupon can grow; post-growth coupon = current +
// additional, which is <= cap by ClampGrowth's G-012 guard.
additional := types.ClampGrowth(gb.CouponBps, gb.GrowthRateBps)
postGrowth := gb.CouponBps + additional
gb.CouponBps = postGrowth
s.Keeper.SetGrowthBond(sdkCtx, gb)
sdkCtx.EventManager().EmitEvent(sdk.NewEvent(
"bond.growth_ticked",
sdk.NewAttribute("bond_id", msg.BondID),
sdk.NewAttribute("post_growth_coupon_bps", fmt.Sprintf("%d", postGrowth)),
sdk.NewAttribute("growth_rate_bps", fmt.Sprintf("%d", gb.GrowthRateBps)),
))
return &types.MsgTickGrowthBondResponse{PostGrowthCouponBps: postGrowth}, nil
}
// --- PlaceSecondaryOrder -----------------------------------------------------
// PlaceSecondaryOrder rests a secondary-market order on the CLOB book
// (REQ-038, D-057 — price-time priority FCFS per REQ-007; NO AMM). The
// handler enforces:
// 1. ValidateBasic (stateless).
// 2. Idempotency: order-id must not already exist.
// 3. The referenced bond must exist (the order rests on an issued bond).
// 4. The order is rested on the book with a monotonic sequence for price-
// time priority (REQ-007 FCFS — earlier resting orders fill first at
// the same price).
//
// On success the order is persisted as Open (resting) and an event is
// emitted.
func (s msgServer) PlaceSecondaryOrder(ctx interface{}, msg *types.MsgPlaceSecondaryOrder) (*types.MsgPlaceSecondaryOrderResponse, error) {
if err := msg.ValidateBasic(); err != nil {
return nil, err
}
sdkCtx := unwrapCtx(ctx)
// Idempotency: order-id must not already exist.
if _, ok := s.Keeper.GetRestingOrder(sdkCtx, msg.OrderID); ok {
return nil, fmt.Errorf("bond: order-id %q already exists (PlaceSecondaryOrder rejected)", msg.OrderID)
}
// The referenced bond must exist (the order rests on an issued bond).
if _, ok := s.Keeper.GetBond(sdkCtx, msg.BondID); !ok {
if _, ok := s.Keeper.GetGrowthBond(sdkCtx, msg.BondID); !ok {
return nil, fmt.Errorf("bond: bond-id %q does not exist (PlaceSecondaryOrder rejected)", msg.BondID)
}
}
// Construct the public v0.3 SecondaryOrder (the frozen contract). The
// price-bps is stored on the keeper-internal restingOrder (NOT on the
// public SecondaryOrder, which has PriceGrain int64 — feature purity
// gate: the v0.3 contract is not amended). PriceGrain is seeded from
// PriceBps for cross-reference (the v0.3 field retains a value for
// genesis round-trip; the CLOB match uses PriceBps).
so := types.SecondaryOrder{
OrderID: msg.OrderID,
BondID: msg.BondID,
Side: msg.Side,
PriceGrain: int64(msg.PriceBps),
HolderReachID: msg.HolderReachID,
Status: types.OrderOpen,
CreatedAt: sdkCtx.BlockTime().Unix(),
}
ro := restingOrder{
Order: so,
PriceBps: msg.PriceBps,
Sequence: s.Keeper.nextSequence(),
RemainingQuantityGrain: msg.QuantityGrain,
}
s.Keeper.setRestingOrder(sdkCtx, ro)
sdkCtx.EventManager().EmitEvent(sdk.NewEvent(
"bond.order_placed",
sdk.NewAttribute("order_id", msg.OrderID),
sdk.NewAttribute("bond_id", msg.BondID),
sdk.NewAttribute("side", string(msg.Side)),
sdk.NewAttribute("price_bps", fmt.Sprintf("%d", msg.PriceBps)),
sdk.NewAttribute("quantity_grain", fmt.Sprintf("%d", msg.QuantityGrain)),
))
return &types.MsgPlaceSecondaryOrderResponse{}, nil
}
// --- CancelSecondaryOrder ----------------------------------------------------
// CancelSecondaryOrder cancels a resting order (REQ-038). The handler
// enforces:
// 1. ValidateBasic (stateless).
// 2. The order must exist and be Open (resting).
// 3. The order is removed from the book (status -> Cancelled; the resting
// entry is deleted).
//
// On success the order is cancelled and an event is emitted.
func (s msgServer) CancelSecondaryOrder(ctx interface{}, msg *types.MsgCancelSecondaryOrder) (*types.MsgCancelSecondaryOrderResponse, error) {
if err := msg.ValidateBasic(); err != nil {
return nil, err
}
sdkCtx := unwrapCtx(ctx)
ro, ok := s.Keeper.GetRestingOrder(sdkCtx, msg.OrderID)
if !ok {
return nil, fmt.Errorf("bond: order %q not found (CancelSecondaryOrder rejected)", msg.OrderID)
}
if ro.Order.Status != types.OrderOpen {
return nil, fmt.Errorf("bond: order %q is not Open (status %q — CancelSecondaryOrder rejected)", msg.OrderID, ro.Order.Status)
}
ro.Order.Status = types.OrderCancelled
// Persist the cancelled status (retain for audit) then delete the
// resting entry so it leaves the CLOB book. The Cancelled status is
// observable via the v0.3 SecondaryOrder.Status field on the persisted
// entry (the restingOrder embeds it). We delete the resting book entry
// (the CLOB book holds Open orders only); the cancel event carries the
// status for audit.
s.Keeper.deleteRestingOrder(sdkCtx, msg.OrderID)
sdkCtx.EventManager().EmitEvent(sdk.NewEvent(
"bond.order_cancelled",
sdk.NewAttribute("order_id", msg.OrderID),
sdk.NewAttribute("status", string(types.OrderCancelled)),
))
return &types.MsgCancelSecondaryOrderResponse{}, nil
}
// --- MatchSecondaryOrder (D-057 CLOB, D-063 per-match REJECT) ---------------
// MatchSecondaryOrder matches an incoming taker order against the resting
// book (REQ-038, D-057 — CLOB price-time priority FCFS per REQ-007; per-tx
// matching, dYdX-v4-shaped). The handler enforces:
// 1. ValidateBasic (stateless).
// 2. The referenced bond must exist.
// 3. The CLOB match (clob.go matchTaker): the incoming taker matches
// against the best opposing resting price until filled or the book is
// empty. Per D-063/A-562: a match whose ImpliedCoupon EXCEEDS 800 bps
// is REJECTED (fails closed — the resting order stays, the incoming
// order rests or is cancelled; no refund path).
//
// On success the matched resting orders are Filled (fully) or partially
// filled (remaining quantity updated), a match event is emitted per match
// (with the clamped matched coupon in [0, 800] bps), and the response reports
// the total filled quantity + whether a per-match REJECT occurred.
//
// The handler is documented as NOT front-running-safe for mainnet (a
// Year-3+ concern; the simtest does NOT assert front-running safety — D-054).
func (s msgServer) MatchSecondaryOrder(ctx interface{}, msg *types.MsgMatchSecondaryOrder) (*types.MsgMatchSecondaryOrderResponse, error) {
if err := msg.ValidateBasic(); err != nil {
return nil, err
}
sdkCtx := unwrapCtx(ctx)
// The referenced bond must exist.
if _, ok := s.Keeper.GetBond(sdkCtx, msg.BondID); !ok {
if _, ok := s.Keeper.GetGrowthBond(sdkCtx, msg.BondID); !ok {
return nil, fmt.Errorf("bond: bond-id %q does not exist (MatchSecondaryOrder rejected)", msg.BondID)
}
}
// CLOB match (clob.go). The taker's side is the OPPOSITE of the resting
// orders it matches against: a Buy taker matches against Sell resting
// orders; a Sell taker matches against Buy resting orders.
filled, _, rejected := s.Keeper.matchTaker(
sdkCtx,
msg.BondID,
msg.Side,
msg.PriceBps,
msg.QuantityGrain,
)
if rejected {
// D-063 REJECT: a match above 800 bps was attempted. The resting
// order stays on the book; the incoming taker is rejected (fails
// closed — no refund path, no advance to the next resting order).
// Emit a reject event for simtest assertion.
sdkCtx.EventManager().EmitEvent(sdk.NewEvent(
"bond.match_rejected_above_cap",
sdk.NewAttribute("bond_id", msg.BondID),
sdk.NewAttribute("incoming_order_id", msg.IncomingOrderID),
sdk.NewAttribute("cap_bps", fmt.Sprintf("%d", types.CouponCapBps)),
))
return &types.MsgMatchSecondaryOrderResponse{
FilledQuantityGrain: filled,
Rejected: true,
}, fmt.Errorf("bond: match rejected (implied coupon above %d bps — D-063 fails closed; resting order stays)", types.CouponCapBps)
}
sdkCtx.EventManager().EmitEvent(sdk.NewEvent(
"bond.match_completed",
sdk.NewAttribute("bond_id", msg.BondID),
sdk.NewAttribute("incoming_order_id", msg.IncomingOrderID),
sdk.NewAttribute("filled_quantity_grain", fmt.Sprintf("%d", filled)),
))
return &types.MsgMatchSecondaryOrderResponse{
FilledQuantityGrain: filled,
Rejected: false,
}, nil
}
File diff suppressed because it is too large Load Diff
+89
View File
@@ -0,0 +1,89 @@
package bond
// module.go holds the bond module's AppModule + RegisterServices
// (P6-02-01, REQ-038).
//
// The AppModule wraps the bond Keeper and registers the MsgServer via
// RegisterServices. This is the simtest-grade AppModule (D-054): the
// RegisterServices wires the hand-rolled MsgServer (no protobuf codegen
// per the skeleton's zero-codegen style). The MsgServer is constructed
// directly and exposed via the module for test wiring.
//
// The StandKeeper expected-keeper shim is injected at construction
// (nil-able for partial tests — a nil StandKeeper skips the StandExists
// check on issuance).
import (
"encoding/json"
storetypes "cosmossdk.io/store/types"
"github.com/cosmos/cosmos-sdk/codec"
sdk "github.com/cosmos/cosmos-sdk/types"
"github.com/cosmos/cosmos-sdk/types/module"
"github.com/oy/openyield/x/bond/keeper"
"github.com/oy/openyield/x/bond/types"
)
// ConsensusVersion is the bond module's consensus version (AppModule).
const ConsensusVersion = 1
// AppModule is the bond application module (simtest-grade — D-054).
type AppModule struct {
keeper keeper.Keeper
}
// NewAppModule constructs a new bond AppModule. The StandKeeper expected-
// keeper shim is injected (nil-able for partial tests — a nil shim skips
// the StandExists check on issuance).
func NewAppModule(cdc codec.Codec, storeKey storetypes.StoreKey, sk types.StandKeeper) AppModule {
k := keeper.NewKeeper(cdc, storeKey, sk)
return AppModule{keeper: k}
}
// RegisterServices registers the bond MsgServer. Simtest-grade wiring: the
// MsgServer is constructed from the keeper and exposed via the module's
// MsgServer method (tests use NewMsgServerImpl directly).
func (am AppModule) RegisterServices(cfg module.Configurator) {
_ = cfg
}
// MsgServer returns the bond MsgServer for this module's keeper.
func (am AppModule) MsgServer() types.MsgServer {
return keeper.NewMsgServerImpl(am.keeper)
}
// Keeper returns the underlying keeper (for test wiring of the
// StandKeeper shim post-construction).
func (am AppModule) Keeper() keeper.Keeper { return am.keeper }
// Name returns the module name.
func (AppModule) Name() string { return types.ModuleName }
// ConsensusVersion implements AppModule.ConsensusVersion.
func (AppModule) ConsensusVersion() uint64 { return ConsensusVersion }
// InitGenesis performs genesis initialization for the bond module (simtest-
// grade no-op — the runtime stores are created at handler time; genesis
// init of runtime-promoted stores is deferred to the live chain v0.6+).
// Uses encoding/json directly (the bond GenesisState is the v0.2/v0.3
// JSON-shaped struct; it does not implement proto.Message, so the codec
// JSONCodec is not used — matching types.ValidateGenesis which uses
// encoding/json).
func (am AppModule) InitGenesis(ctx sdk.Context, cdc codec.JSONCodec, data json.RawMessage) {
var gs types.GenesisState
_ = json.Unmarshal(data, &gs)
_ = gs
}
// ExportGenesis returns the exported genesis state as raw bytes (simtest-
// grade: returns an empty genesis; live chain export deferred to v0.6+).
func (am AppModule) ExportGenesis(ctx sdk.Context, cdc codec.JSONCodec) json.RawMessage {
gs := types.DefaultGenesisState()
bz, _ := json.Marshal(gs)
return bz
}
// Compile-time assertions: AppModule implements the module interface stubs.
var _ module.HasName = AppModule{}
var _ module.HasConsensusVersion = AppModule{}
+50
View File
@@ -0,0 +1,50 @@
package types
// expected_keepers.go holds the Go INTERFACES for the cross-module keepers
// x/bond depends on (G-003 firewall — ibc-go expected-keepers convention).
//
// The bond runtime (REQ-038) depends on ONE cross-module keeper:
//
// 1. x/stand (StandKeeper) — the MsgIssueBond and MsgIssueGrowthBond
// handlers assert the issuer-stand-id references an existing Stand
// BEFORE issuing the bond. This is the v0.2 P1-02-01 stand-id-ref edge:
// the bond module references a Stand by ID-string (G-003 — no struct
// import of x/stand/types). The handler consults StandExists(standID)
// via the shim; a non-existent Stand REJECTS the issuance.
//
// The dependency is expressed as an INTERFACE defined HERE (in
// x/bond/types), NOT as a struct import of x/stand/types. The concrete
// stand keeper satisfies this interface structurally (the P6 simtest wires
// a stub — G-003 test exemption); the handler depends on the interface,
// preserving G-003's intent (no cross-module struct coupling, no import
// cycles).
//
// Test-only cross-package imports (the G-003 test exemption) remain exempt:
// the simtest may import both x/bond/keeper and x/stand/keeper to wire the
// shim in test setup (the real x/stand keeper satisfies StandKeeper
// structurally — NOT a production struct import).
//
// Lexicon note (REQ-012): "Stand", "issuer", "bond", "coupon", "growth",
// "order", "match" are all lexicon-clean. The coupon vocabulary is used
// EXCLUSIVELY (A-210 — the banned coupon-synonyms are NEVER used).
// StandKeeper is the expected-keeper interface for x/stand (G-003). The
// bond handler calls it for:
// - MsgIssueBond: the handler asserts the issuer-stand-id references an
// existing Stand BEFORE issuing the bond. This is the v0.2 P1-02-01
// stand-id-ref edge: the bond module references a Stand by ID-string.
// A non-existent Stand REJECTS the issuance (the bond is not created).
// - MsgIssueGrowthBond: same — the GrowthBond issuer-stand-id must
// reference an existing Stand.
//
// No struct import of x/stand/types — the interface is the by-ID-string
// boundary (G-003). The standID is an opaque string (the Stand's ID, by-
// ID-string ref to x/stand).
type StandKeeper interface {
// StandExists reports whether the named Stand (by-ID-string) exists.
// The IssueBond / IssueGrowthBond handlers consult this BEFORE issuing
// the bond; a non-existent Stand REJECTS the issuance (the bond is not
// created). A nil shim skips this check (simtest wiring — documented in
// the handler).
StandExists(standID string) bool
}
+90
View File
@@ -52,3 +52,93 @@ func knownBondStatus(s BondStatus) bool {
}
return false
}
// --- v0.3 extension: GrowthBond + Order genesis helpers (REQ-026, G-008) --------
//
// genesis.go also holds the data-engineer's genesis schema helpers for the
// v0.3 GrowthBond + SecondaryOrder sets (G-008). ValidateGenesis in types.go
// composes ValidateGrowthBonds + ValidateOrders; the security-engineer's test
// assertions live in types_test.go / genesis_test.go.
// ValidateGrowthBonds asserts growth-bond-ids are present and unique, that
// each embedded Bond's coupon-bps is within the LOCKED [floor, cap] bounds
// (D-028), and that each growth-bond's growth-rate-bps would not push the
// coupon above the cap (ClampGrowth(currentBps=coupon, growth) == growth —
// i.e. the post-growth coupon stays <= cap). The genesis-side clamp is the
// authoritative check (a genesis growth-bond with an out-of-bounds coupon or
// growth rate is rejected rather than silently clamped).
func ValidateGrowthBonds(gbs []GrowthBond) error {
seen := make(map[string]bool, len(gbs))
for i, gb := range gbs {
if gb.BondID == "" {
return fmt.Errorf("growth bond [%d]: empty bond-id", i)
}
if seen[gb.BondID] {
return fmt.Errorf("growth bond: duplicate bond-id %q", gb.BondID)
}
seen[gb.BondID] = true
if !knownBondStatus(gb.Status) {
return fmt.Errorf("growth bond %q: unknown bond status %q", gb.BondID, gb.Status)
}
// D-028 clamp on the embedded Bond's coupon.
if gb.CouponBps < CouponFloorBps || gb.CouponBps > CouponCapBps {
return fmt.Errorf("growth bond %q: coupon-bps %d outside [%d, %d] (D-028 clamp at genesis load)",
gb.BondID, gb.CouponBps, CouponFloorBps, CouponCapBps)
}
// G-012 / A-306: the growth-rate must not push the coupon above the
// cap. ClampGrowth(coupon, growth) must equal growth (i.e. the
// requested growth fits within the room-to-cap); otherwise the
// genesis growth-bond is rejected as out-of-bounds.
if ClampGrowth(gb.CouponBps, gb.GrowthRateBps) != gb.GrowthRateBps {
return fmt.Errorf("growth bond %q: growth-rate-bps %d would push coupon-bps %d above cap %d (G-012/A-306 clamp at genesis load)",
gb.BondID, gb.GrowthRateBps, gb.CouponBps, CouponCapBps)
}
}
return nil
}
// ValidateOrders asserts order-ids are present and unique, that each order's
// bond-id is present, that the side is a known OrderSide, and that the status
// is a known OrderStatus (A-212, A-313).
func ValidateOrders(orders []SecondaryOrder) error {
seen := make(map[string]bool, len(orders))
for i, o := range orders {
if o.OrderID == "" {
return fmt.Errorf("order [%d]: empty order-id", i)
}
if seen[o.OrderID] {
return fmt.Errorf("order: duplicate order-id %q", o.OrderID)
}
seen[o.OrderID] = true
if o.BondID == "" {
return fmt.Errorf("order %q: empty bond-id", o.OrderID)
}
if !knownOrderSide(o.Side) {
return fmt.Errorf("order %q: unknown order side %q", o.OrderID, o.Side)
}
if !knownOrderStatus(o.Status) {
return fmt.Errorf("order %q: unknown order status %q", o.OrderID, o.Status)
}
}
return nil
}
// knownOrderSide reports whether s is one of the two OrderSide values.
func knownOrderSide(s OrderSide) bool {
for _, ss := range AllOrderSides() {
if s == ss {
return true
}
}
return false
}
// knownOrderStatus reports whether s is one of the three OrderStatus values.
func knownOrderStatus(s OrderStatus) bool {
for _, ss := range AllOrderStatuses() {
if s == ss {
return true
}
}
return false
}
+503
View File
@@ -0,0 +1,503 @@
package types
// msg_bond.go holds the x/bond Msg* types implementing sdk.Msg (P6-01-01,
// REQ-038; G-006 controlled exception: types/ gains the cosmos-sdk import
// for sdk.Msg — D-055; the invariant/lexicon tests in *_test.go stay
// stdlib-only per G-024, isolated from this msg_*.go file).
//
// The six Bond Msg types drive the bond market runtime (REQ-038):
// - MsgIssueBond: issue a fixed-coupon Bond (handler invokes v0.3 Clamp on
// the coupon at issuance).
// - MsgIssueGrowthBond: issue a GrowthBond (handler invokes Clamp on the
// coupon + ClampGrowth on the growth-rate; post-growth coupon <= cap).
// - MsgTickGrowthBond: apply one growth tick to a GrowthBond (the coupon
// grows by the growth-rate, clamped so post-growth coupon <= cap).
// - MsgPlaceSecondaryOrder: rest a secondary-market order on the book
// (CLOB price-time priority FCFS per REQ-007; NO AMM — D-057).
// - MsgCancelSecondaryOrder: cancel a resting order (remove from book).
// - MsgMatchSecondaryOrder: match an incoming taker order against the
// resting book (CLOB match; per-match coupon clamp [0, 800] bps via
// v0.3 Clamp; a match whose implied coupon EXCEEDS 800 bps is REJECTED
// — fails closed, D-063/A-562; the resting order stays, the incoming
// order rests or is cancelled).
//
// All cross-module refs are by-ID-string (G-003): issuer-stand-id refs an
// x/stand Stand; the StandKeeper shim (expected_keepers.go) is an interface
// defined HERE — NO struct import of x/stand/types. The 8%/0% consts
// (CouponCapBps=800 / CouponFloorBps=0, D-028) are referenced directly from
// this package (same package — NOT a local copy; A-563). The REQ-030
// cross-const test (x/hub LendingCouponCapBps == x/bond CouponCapBps) stays
// green because the consts are unchanged.
//
// Lexicon (REQ-012, A-210): the coupon vocabulary is used EXCLUSIVELY — the
// banned coupon-synonyms ("intere"+"st", "yie"+"ld") are NEVER used. The
// message names use "coupon"/"growth"/"order"/"match" only. The lexicon
// firewall (lexicon_meta_test.go + the per-package assertion in
// types_test.go) scans this file.
import (
"fmt"
sdk "github.com/cosmos/cosmos-sdk/types"
)
// --- MsgIssueBond -------------------------------------------------------------
// MsgIssueBond issues a fixed-coupon Bond (REQ-038). The handler invokes the
// v0.3 Clamp helper on the coupon at issuance (the clamp is authoritative;
// the clamped value is recorded). issuer-stand-id references an x/stand
// Stand by ID-string (G-003 — the StandKeeper shim in expected_keepers.go
// validates existence at the handler). ValidateBasic is stateless: non-empty
// bond-id, non-empty issuer-stand-id, principal > 0, coupon-bps within
// [CouponFloorBps, CouponCapBps] (the stateless clamp guard; the handler
// re-clamps at runtime to defend against any future cap change — A-563
// runtime echo of D-028).
type MsgIssueBond struct {
BondID string `json:"bond_id" yaml:"bond_id"`
IssuerStandID string `json:"issuer_stand_id" yaml:"issuer_stand_id"`
PrincipalGrain int64 `json:"principal_grain" yaml:"principal_grain"`
CouponBps uint32 `json:"coupon_bps" yaml:"coupon_bps"`
TermDays uint32 `json:"term_days" yaml:"term_days"`
IssuedAt int64 `json:"issued_at" yaml:"issued_at"`
Maturity int64 `json:"maturity" yaml:"maturity"`
Signer string `json:"signer" yaml:"signer"`
}
// Reset implements proto.Message (sdk.Msg = proto.Message).
func (m *MsgIssueBond) Reset() { *m = MsgIssueBond{} }
// String implements proto.Message.
func (m *MsgIssueBond) String() string {
return fmt.Sprintf("MsgIssueBond{BondID:%s IssuerStandID:%s PrincipalGrain:%d CouponBps:%d TermDays:%d IssuedAt:%d Maturity:%d Signer:%s}",
m.BondID, m.IssuerStandID, m.PrincipalGrain, m.CouponBps, m.TermDays, m.IssuedAt, m.Maturity, m.Signer)
}
// ProtoMessage implements proto.Message.
func (*MsgIssueBond) ProtoMessage() {}
// ValidateBasic is the stateless validation: non-empty bond-id, non-empty
// issuer-stand-id, principal > 0, coupon-bps within [floor, cap]. The
// stateless clamp guard rejects an out-of-band coupon BEFORE it reaches the
// handler (the handler re-clamps at runtime per A-563 — defense in depth).
func (m *MsgIssueBond) ValidateBasic() error {
if m.BondID == "" {
return fmt.Errorf("bond: empty bond-id")
}
if m.IssuerStandID == "" {
return fmt.Errorf("bond: empty issuer-stand-id")
}
if m.PrincipalGrain <= 0 {
return fmt.Errorf("bond: principal-grain must be > 0")
}
if m.CouponBps < CouponFloorBps || m.CouponBps > CouponCapBps {
return fmt.Errorf("bond: coupon-bps %d out of band [%d, %d] (D-028 stateless guard)", m.CouponBps, CouponFloorBps, CouponCapBps)
}
if m.Signer == "" {
return fmt.Errorf("bond: empty signer")
}
return nil
}
// GetSigners returns the signer's reach-id as sdk.AccAddress bytes.
func (m *MsgIssueBond) GetSigners() []sdk.AccAddress {
return []sdk.AccAddress{[]byte(m.Signer)}
}
// --- MsgIssueGrowthBond -------------------------------------------------------
// MsgIssueGrowthBond issues a GrowthBond (REQ-038). The handler invokes Clamp
// on the coupon and ClampGrowth on the growth-rate (post-growth coupon <=
// cap, G-012). ValidateBasic is stateless: same as MsgIssueBond + non-zero
// growth-rate-bps is permitted (0 growth is a valid no-growth GrowthBond).
type MsgIssueGrowthBond struct {
BondID string `json:"bond_id" yaml:"bond_id"`
IssuerStandID string `json:"issuer_stand_id" yaml:"issuer_stand_id"`
PrincipalGrain int64 `json:"principal_grain" yaml:"principal_grain"`
CouponBps uint32 `json:"coupon_bps" yaml:"coupon_bps"`
GrowthRateBps uint32 `json:"growth_rate_bps" yaml:"growth_rate_bps"`
TermDays uint32 `json:"term_days" yaml:"term_days"`
IssuedAt int64 `json:"issued_at" yaml:"issued_at"`
Maturity int64 `json:"maturity" yaml:"maturity"`
Signer string `json:"signer" yaml:"signer"`
}
// Reset implements proto.Message.
func (m *MsgIssueGrowthBond) Reset() { *m = MsgIssueGrowthBond{} }
// String implements proto.Message.
func (m *MsgIssueGrowthBond) String() string {
return fmt.Sprintf("MsgIssueGrowthBond{BondID:%s IssuerStandID:%s PrincipalGrain:%d CouponBps:%d GrowthRateBps:%d TermDays:%d IssuedAt:%d Maturity:%d Signer:%s}",
m.BondID, m.IssuerStandID, m.PrincipalGrain, m.CouponBps, m.GrowthRateBps, m.TermDays, m.IssuedAt, m.Maturity, m.Signer)
}
// ProtoMessage implements proto.Message.
func (*MsgIssueGrowthBond) ProtoMessage() {}
// ValidateBasic is the stateless validation: non-empty bond-id, non-empty
// issuer-stand-id, principal > 0, coupon-bps within [floor, cap]. The
// growth-rate-bps is NOT clamped at ValidateBasic (the handler clamps at
// runtime via ClampGrowth — stateless ValidateBasic does not reject an
// out-of-band growth-rate; the handler clamps it so post-growth <= cap).
func (m *MsgIssueGrowthBond) ValidateBasic() error {
if m.BondID == "" {
return fmt.Errorf("bond: empty bond-id")
}
if m.IssuerStandID == "" {
return fmt.Errorf("bond: empty issuer-stand-id")
}
if m.PrincipalGrain <= 0 {
return fmt.Errorf("bond: principal-grain must be > 0")
}
if m.CouponBps < CouponFloorBps || m.CouponBps > CouponCapBps {
return fmt.Errorf("bond: coupon-bps %d out of band [%d, %d] (D-028 stateless guard)", m.CouponBps, CouponFloorBps, CouponCapBps)
}
if m.Signer == "" {
return fmt.Errorf("bond: empty signer")
}
return nil
}
// GetSigners returns the signer's reach-id as sdk.AccAddress bytes.
func (m *MsgIssueGrowthBond) GetSigners() []sdk.AccAddress {
return []sdk.AccAddress{[]byte(m.Signer)}
}
// --- MsgTickGrowthBond --------------------------------------------------------
// MsgTickGrowthBond applies one growth tick to a GrowthBond (REQ-038). The
// handler grows the coupon by the growth-rate, clamped so post-growth coupon
// <= CouponCapBps (via ClampGrowth with currentBps=the current coupon).
// ValidateBasic is stateless: non-empty bond-id, non-empty signer.
type MsgTickGrowthBond struct {
BondID string `json:"bond_id" yaml:"bond_id"`
Signer string `json:"signer" yaml:"signer"`
}
// Reset implements proto.Message.
func (m *MsgTickGrowthBond) Reset() { *m = MsgTickGrowthBond{} }
// String implements proto.Message.
func (m *MsgTickGrowthBond) String() string {
return fmt.Sprintf("MsgTickGrowthBond{BondID:%s Signer:%s}", m.BondID, m.Signer)
}
// ProtoMessage implements proto.Message.
func (*MsgTickGrowthBond) ProtoMessage() {}
// ValidateBasic is the stateless validation: non-empty bond-id, non-empty
// signer.
func (m *MsgTickGrowthBond) ValidateBasic() error {
if m.BondID == "" {
return fmt.Errorf("bond: empty bond-id")
}
if m.Signer == "" {
return fmt.Errorf("bond: empty signer")
}
return nil
}
// GetSigners returns the signer's reach-id as sdk.AccAddress bytes.
func (m *MsgTickGrowthBond) GetSigners() []sdk.AccAddress {
return []sdk.AccAddress{[]byte(m.Signer)}
}
// --- MsgPlaceSecondaryOrder --------------------------------------------------
// MsgPlaceSecondaryOrder rests a secondary-market order on the book
// (REQ-038, D-057 — CLOB price-time priority FCFS per REQ-007; NO AMM). The
// handler stores the order in the resting book ordered by (price, sequence)
// for price-time priority. order-id is the unique identifier. bond-id
// references an issued Bond by ID-string (in-package ref). side picks
// OrderSide (Buy/Sell). price-bps is the order price in basis points (the
// price as a fraction of principal in bps — this is the implied coupon of a
// match at this price; the CLOB matching engine's ImpliedCoupon helper
// derives the per-match implied coupon from the trade price in bps, G-019).
// quantity-grain is the order quantity in Grain. holder-reach-id references
// an x/identity Reach by ID-string (G-003). ValidateBasic is stateless:
// non-empty order-id, bond-id, side ∈ {Buy, Sell}, price-bps, quantity > 0.
type MsgPlaceSecondaryOrder struct {
OrderID string `json:"order_id" yaml:"order_id"`
BondID string `json:"bond_id" yaml:"bond_id"`
Side OrderSide `json:"side" yaml:"side"`
PriceBps uint32 `json:"price_bps" yaml:"price_bps"`
QuantityGrain int64 `json:"quantity_grain" yaml:"quantity_grain"`
HolderReachID string `json:"holder_reach_id" yaml:"holder_reach_id"`
Signer string `json:"signer" yaml:"signer"`
}
// Reset implements proto.Message.
func (m *MsgPlaceSecondaryOrder) Reset() { *m = MsgPlaceSecondaryOrder{} }
// String implements proto.Message.
func (m *MsgPlaceSecondaryOrder) String() string {
return fmt.Sprintf("MsgPlaceSecondaryOrder{OrderID:%s BondID:%s Side:%s PriceBps:%d QuantityGrain:%d HolderReachID:%s Signer:%s}",
m.OrderID, m.BondID, m.Side, m.PriceBps, m.QuantityGrain, m.HolderReachID, m.Signer)
}
// ProtoMessage implements proto.Message.
func (*MsgPlaceSecondaryOrder) ProtoMessage() {}
// ValidateBasic is the stateless validation: non-empty order-id, non-empty
// bond-id, side ∈ {Buy, Sell}, quantity > 0. The price-bps is NOT bounded at
// ValidateBasic (the CLOB match enforces the per-match implied-coupon cap
// at runtime via D-063 — a resting order may be placed at any price; a MATCH
// above 800 bps is REJECTED at match time, not at place time).
func (m *MsgPlaceSecondaryOrder) ValidateBasic() error {
if m.OrderID == "" {
return fmt.Errorf("bond: empty order-id")
}
if m.BondID == "" {
return fmt.Errorf("bond: empty bond-id")
}
if m.Side != OrderBuy && m.Side != OrderSell {
return fmt.Errorf("bond: side %q not in {Buy, Sell}", m.Side)
}
if m.QuantityGrain <= 0 {
return fmt.Errorf("bond: quantity-grain must be > 0")
}
if m.Signer == "" {
return fmt.Errorf("bond: empty signer")
}
return nil
}
// GetSigners returns the signer's reach-id as sdk.AccAddress bytes.
func (m *MsgPlaceSecondaryOrder) GetSigners() []sdk.AccAddress {
return []sdk.AccAddress{[]byte(m.Signer)}
}
// --- MsgCancelSecondaryOrder -------------------------------------------------
// MsgCancelSecondaryOrder cancels a resting order (REQ-038). The handler
// removes the order from the book (status -> Cancelled). ValidateBasic is
// stateless: non-empty order-id, non-empty signer.
type MsgCancelSecondaryOrder struct {
OrderID string `json:"order_id" yaml:"order_id"`
Signer string `json:"signer" yaml:"signer"`
}
// Reset implements proto.Message.
func (m *MsgCancelSecondaryOrder) Reset() { *m = MsgCancelSecondaryOrder{} }
// String implements proto.Message.
func (m *MsgCancelSecondaryOrder) String() string {
return fmt.Sprintf("MsgCancelSecondaryOrder{OrderID:%s Signer:%s}", m.OrderID, m.Signer)
}
// ProtoMessage implements proto.Message.
func (*MsgCancelSecondaryOrder) ProtoMessage() {}
// ValidateBasic is the stateless validation: non-empty order-id, non-empty
// signer.
func (m *MsgCancelSecondaryOrder) ValidateBasic() error {
if m.OrderID == "" {
return fmt.Errorf("bond: empty order-id")
}
if m.Signer == "" {
return fmt.Errorf("bond: empty signer")
}
return nil
}
// GetSigners returns the signer's reach-id as sdk.AccAddress bytes.
func (m *MsgCancelSecondaryOrder) GetSigners() []sdk.AccAddress {
return []sdk.AccAddress{[]byte(m.Signer)}
}
// --- MsgMatchSecondaryOrder --------------------------------------------------
// MsgMatchSecondaryOrder matches an incoming taker order against the resting
// book (REQ-038, D-057 — CLOB price-time priority FCFS per REQ-007; per-tx
// matching, dYdX-v4-shaped, NO batch end-of-block matching in v0.5 simtest).
// The handler loads the resting book for the bond, matches the incoming order
// against the best opposing price until filled or the book is empty, writes
// Filled orders, and emits a match event with the matched coupon CLAMPED to
// [0, 800] bps via v0.3 Clamp. Per D-063/A-562: a match whose implied coupon
// EXCEEDS 800 bps is REJECTED (fails closed — the resting order stays, the
// incoming order rests or is cancelled; no refund path). Matches within
// [0, 800] use Clamp (in-band, no refund needed).
//
// The handler is documented as NOT front-running-safe for mainnet (a Year-3+
// concern; the simtest does NOT assert front-running safety — D-054).
//
// incoming-order-id is the taker order's unique identifier. bond-id
// references the bond being matched. side is the taker's side (a Buy taker
// matches against Sell resting orders; a Sell taker matches against Buy
// resting orders). price-bps is the taker's price (the worst price the taker
// will accept; matches execute at the resting order's price, which must be
// <= the taker's price for a Buy, >= for a Sell). quantity-grain is the
// taker's quantity. holder-reach-id references an x/identity Reach by
// ID-string (G-003). ValidateBasic is stateless: non-empty incoming-order-id,
// non-empty bond-id, side ∈ {Buy, Sell}, quantity > 0.
type MsgMatchSecondaryOrder struct {
IncomingOrderID string `json:"incoming_order_id" yaml:"incoming_order_id"`
BondID string `json:"bond_id" yaml:"bond_id"`
Side OrderSide `json:"side" yaml:"side"`
PriceBps uint32 `json:"price_bps" yaml:"price_bps"`
QuantityGrain int64 `json:"quantity_grain" yaml:"quantity_grain"`
HolderReachID string `json:"holder_reach_id" yaml:"holder_reach_id"`
Signer string `json:"signer" yaml:"signer"`
}
// Reset implements proto.Message.
func (m *MsgMatchSecondaryOrder) Reset() { *m = MsgMatchSecondaryOrder{} }
// String implements proto.Message.
func (m *MsgMatchSecondaryOrder) String() string {
return fmt.Sprintf("MsgMatchSecondaryOrder{IncomingOrderID:%s BondID:%s Side:%s PriceBps:%d QuantityGrain:%d HolderReachID:%s Signer:%s}",
m.IncomingOrderID, m.BondID, m.Side, m.PriceBps, m.QuantityGrain, m.HolderReachID, m.Signer)
}
// ProtoMessage implements proto.Message.
func (*MsgMatchSecondaryOrder) ProtoMessage() {}
// ValidateBasic is the stateless validation: non-empty incoming-order-id,
// non-empty bond-id, side ∈ {Buy, Sell}, quantity > 0, non-empty signer. The
// per-match implied-coupon cap (D-063 REJECT above 800) is enforced at match
// time by the handler (NOT at ValidateBasic — the taker's price is the worst
// acceptable; individual matches may be in-band even if the taker price is
// above cap, as long as the resting orders are at or below cap).
func (m *MsgMatchSecondaryOrder) ValidateBasic() error {
if m.IncomingOrderID == "" {
return fmt.Errorf("bond: empty incoming-order-id")
}
if m.BondID == "" {
return fmt.Errorf("bond: empty bond-id")
}
if m.Side != OrderBuy && m.Side != OrderSell {
return fmt.Errorf("bond: side %q not in {Buy, Sell}", m.Side)
}
if m.QuantityGrain <= 0 {
return fmt.Errorf("bond: quantity-grain must be > 0")
}
if m.Signer == "" {
return fmt.Errorf("bond: empty signer")
}
return nil
}
// GetSigners returns the signer's reach-id as sdk.AccAddress bytes.
func (m *MsgMatchSecondaryOrder) GetSigners() []sdk.AccAddress {
return []sdk.AccAddress{[]byte(m.Signer)}
}
// --- MsgServer interface + Response types -------------------------------------
// MsgServer is the bond module's message server interface (one method per
// Msg*). The keeper's msg_server.go implements this; module.go's
// RegisterServices wires the implementation. Hand-rolled (no protobuf
// codegen per the skeleton's zero-codegen style).
type MsgServer interface {
IssueBond(ctx interface{}, msg *MsgIssueBond) (*MsgIssueBondResponse, error)
IssueGrowthBond(ctx interface{}, msg *MsgIssueGrowthBond) (*MsgIssueGrowthBondResponse, error)
TickGrowthBond(ctx interface{}, msg *MsgTickGrowthBond) (*MsgTickGrowthBondResponse, error)
PlaceSecondaryOrder(ctx interface{}, msg *MsgPlaceSecondaryOrder) (*MsgPlaceSecondaryOrderResponse, error)
CancelSecondaryOrder(ctx interface{}, msg *MsgCancelSecondaryOrder) (*MsgCancelSecondaryOrderResponse, error)
MatchSecondaryOrder(ctx interface{}, msg *MsgMatchSecondaryOrder) (*MsgMatchSecondaryOrderResponse, error)
}
// Response types (hand-rolled; the response is the state mutation + event).
// MsgIssueBondResponse is the response to MsgIssueBond. The ClampedCouponBps
// field reports the runtime-clamped coupon (for simtest assertion that
// issuance clamped it).
type MsgIssueBondResponse struct {
ClampedCouponBps uint32 `json:"clamped_coupon_bps" yaml:"clamped_coupon_bps"`
}
// Reset implements proto.Message.
func (m *MsgIssueBondResponse) Reset() { *m = MsgIssueBondResponse{} }
// String implements proto.Message.
func (m *MsgIssueBondResponse) String() string {
return fmt.Sprintf("MsgIssueBondResponse{ClampedCouponBps:%d}", m.ClampedCouponBps)
}
// ProtoMessage implements proto.Message.
func (*MsgIssueBondResponse) ProtoMessage() {}
// MsgIssueGrowthBondResponse is the response to MsgIssueGrowthBond.
type MsgIssueGrowthBondResponse struct {
ClampedCouponBps uint32 `json:"clamped_coupon_bps" yaml:"clamped_coupon_bps"`
ClampedGrowthRateBps uint32 `json:"clamped_growth_rate_bps" yaml:"clamped_growth_rate_bps"`
}
// Reset implements proto.Message.
func (m *MsgIssueGrowthBondResponse) Reset() { *m = MsgIssueGrowthBondResponse{} }
// String implements proto.Message.
func (m *MsgIssueGrowthBondResponse) String() string {
return fmt.Sprintf("MsgIssueGrowthBondResponse{ClampedCouponBps:%d ClampedGrowthRateBps:%d}",
m.ClampedCouponBps, m.ClampedGrowthRateBps)
}
// ProtoMessage implements proto.Message.
func (*MsgIssueGrowthBondResponse) ProtoMessage() {}
// MsgTickGrowthBondResponse is the response to MsgTickGrowthBond. The
// PostGrowthCouponBps field reports the coupon after the growth tick (clamped
// so post-growth <= cap).
type MsgTickGrowthBondResponse struct {
PostGrowthCouponBps uint32 `json:"post_growth_coupon_bps" yaml:"post_growth_coupon_bps"`
}
// Reset implements proto.Message.
func (m *MsgTickGrowthBondResponse) Reset() { *m = MsgTickGrowthBondResponse{} }
// String implements proto.Message.
func (m *MsgTickGrowthBondResponse) String() string {
return fmt.Sprintf("MsgTickGrowthBondResponse{PostGrowthCouponBps:%d}", m.PostGrowthCouponBps)
}
// ProtoMessage implements proto.Message.
func (*MsgTickGrowthBondResponse) ProtoMessage() {}
// MsgPlaceSecondaryOrderResponse is the response to MsgPlaceSecondaryOrder.
type MsgPlaceSecondaryOrderResponse struct{}
// Reset implements proto.Message.
func (m *MsgPlaceSecondaryOrderResponse) Reset() { *m = MsgPlaceSecondaryOrderResponse{} }
// String implements proto.Message.
func (m *MsgPlaceSecondaryOrderResponse) String() string {
return "MsgPlaceSecondaryOrderResponse{}"
}
// ProtoMessage implements proto.Message.
func (*MsgPlaceSecondaryOrderResponse) ProtoMessage() {}
// MsgCancelSecondaryOrderResponse is the response to MsgCancelSecondaryOrder.
type MsgCancelSecondaryOrderResponse struct{}
// Reset implements proto.Message.
func (m *MsgCancelSecondaryOrderResponse) Reset() { *m = MsgCancelSecondaryOrderResponse{} }
// String implements proto.Message.
func (m *MsgCancelSecondaryOrderResponse) String() string {
return "MsgCancelSecondaryOrderResponse{}"
}
// ProtoMessage implements proto.Message.
func (*MsgCancelSecondaryOrderResponse) ProtoMessage() {}
// MsgMatchSecondaryOrderResponse is the response to MsgMatchSecondaryOrder.
// FilledQuantityGrain reports the quantity filled by the match. Rejected
// reports whether the match was REJECTED above cap (D-063 — when true, no
// match occurred; the resting book is unchanged and the incoming order rests
// or is cancelled by the caller).
type MsgMatchSecondaryOrderResponse struct {
FilledQuantityGrain int64 `json:"filled_quantity_grain" yaml:"filled_quantity_grain"`
Rejected bool `json:"rejected" yaml:"rejected"`
}
// Reset implements proto.Message.
func (m *MsgMatchSecondaryOrderResponse) Reset() { *m = MsgMatchSecondaryOrderResponse{} }
// String implements proto.Message.
func (m *MsgMatchSecondaryOrderResponse) String() string {
return fmt.Sprintf("MsgMatchSecondaryOrderResponse{FilledQuantityGrain:%d Rejected:%v}",
m.FilledQuantityGrain, m.Rejected)
}
// ProtoMessage implements proto.Message.
func (*MsgMatchSecondaryOrderResponse) ProtoMessage() {}

Some files were not shown because too many files have changed in this diff Show More