Compare commits
5 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| d09c6132b1 | |||
| fa4ee47bde | |||
| a780884379 | |||
| cb394cb516 | |||
| 23de3c544b |
@@ -1,11 +1,14 @@
|
|||||||
{
|
{
|
||||||
"phase": 0,
|
"phase": 1,
|
||||||
"stage": "mvp_ux_check",
|
"stage": "complete",
|
||||||
"milestone": "v0.4",
|
"milestone": "v0.3",
|
||||||
"milestone_type": "nfr",
|
"milestone_type": "feature",
|
||||||
"tag_base": "v0.3.x",
|
"tag_base": "v0.2.x",
|
||||||
"phase_role": "pre_execution",
|
"phase_role": "execution",
|
||||||
"project": "oy",
|
"project": "oy",
|
||||||
"attempts": 0,
|
"attempts": 0,
|
||||||
"updated_at": "2026-08-17T22:50:00Z"
|
"updated_at": "2026-08-18T00:00:00Z",
|
||||||
|
"milestone_complete": false,
|
||||||
|
"phase_release_tag": "v0.2.1",
|
||||||
|
"release_id": 734
|
||||||
}
|
}
|
||||||
@@ -6,9 +6,9 @@
|
|||||||
}
|
}
|
||||||
],
|
],
|
||||||
"active_project": "oy",
|
"active_project": "oy",
|
||||||
"milestone": "v0.4",
|
"milestone": "v0.3",
|
||||||
"milestone_type": "nfr",
|
"milestone_type": "feature",
|
||||||
"tag_base": "v0.3.x",
|
"tag_base": "v0.2.x",
|
||||||
"autonomy": {
|
"autonomy": {
|
||||||
"level": "full",
|
"level": "full",
|
||||||
"escalation_hooks": ["deploy", "delete_data", "merge_to_main"],
|
"escalation_hooks": ["deploy", "delete_data", "merge_to_main"],
|
||||||
|
|||||||
@@ -183,48 +183,4 @@ module's production `.go` files).
|
|||||||
> this file) that discuss the banned terms by name for governance reasons — they
|
> 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
|
> 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
|
> scan. This mirrors how `lexicon_meta_test.go` excludes itself: the firewall's
|
||||||
> own code is allowed to name the terms it bans.
|
> 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.
|
|
||||||
+1
-109
@@ -299,112 +299,4 @@ Escalations: 0
|
|||||||
Overall verdict: PASS (after critical fixes)
|
Overall verdict: PASS (after critical fixes)
|
||||||
Confidence: 0.90
|
Confidence: 0.90
|
||||||
AUDIT.md written: /root/oy/.ciagent/oy/AUDIT.md ✓
|
AUDIT.md written: /root/oy/.ciagent/oy/AUDIT.md ✓
|
||||||
```
|
```
|
||||||
---
|
|
||||||
|
|
||||||
# Audit: OpenYield (oy) — v0.3 (Bearers & Documentation) Final Phase (P6)
|
|
||||||
|
|
||||||
> **Auditor**: CIAgent doc verifier (final-audit mode, full autonomy)
|
|
||||||
> **Date**: 2026-08-17
|
|
||||||
> **Scope**: v0.3 milestone state on `oy/milestone/v0.3-bearers-docs` (HEAD = `oy/phase/06-final-review-ship`)
|
|
||||||
> **Milestone**: v0.3 — Bearers & Documentation (feature type; tag_base `v0.2.x`)
|
|
||||||
> **Mode**: multi-project (slug `oy`)
|
|
||||||
> **Autonomy**: full
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## v0.3 Final Audit (P6)
|
|
||||||
|
|
||||||
### Reconstruction Test — **PASS**
|
|
||||||
|
|
||||||
**Git log matches `.ciagent/` files.** `git log v0.1.5..HEAD --oneline` returns 13 commits across P0-P5 (6 phase-ship `docs(P##):` commits, 5 `checkpoint(P##):` advance commits, 1 v0.2 milestone marker inherited via v0.1.5). The 6 `docs(P##): complete ...` commits each carry a `---ci---` block with `status: complete` and the correct phase integer.
|
|
||||||
|
|
||||||
**Per-phase `---ci---` block verification:**
|
|
||||||
|
|
||||||
| Phase | Commit | Tag | `status` | `requirements.covered` | `requirements.partial` | Verdict |
|
|
||||||
|---|---|---|---|---|---|---|
|
|
||||||
| P0 | 23de3c5 | v0.2.0 | complete | [] (pre-execution) | [] | PASS |
|
|
||||||
| P1 | a780884 | v0.2.1 | complete | [REQ-028] | [REQ-027] | PASS |
|
|
||||||
| P2 | d09c613 | v0.2.2 | complete | [] (nomads docs) | [REQ-027] | PASS |
|
|
||||||
| P3 | 2ef3f2e | v0.2.3 | complete | [REQ-027] | [] | PASS |
|
|
||||||
| P4 | ab43bef | v0.2.4 | complete | [REQ-010, REQ-022, REQ-023] | [] | PASS |
|
|
||||||
| P5 | c1aa274 | v0.2.5 | complete | [REQ-024, REQ-025, REQ-026] | [] | PASS |
|
|
||||||
|
|
||||||
- Phase `---ci---` blocks: 6 (one per phase P0-P5). Each phase's final block shows `status: complete`. ✓
|
|
||||||
- Tag count: 6 (v0.2.0..v0.2.5). Each tag exists and points at the matching `docs(P##): complete ...` commit. ✓
|
|
||||||
- REQ coverage vs. expected (P0: none; P1: REQ-028; P2: partial REQ-027; P3: REQ-027; P4: REQ-010/022/023; P5: REQ-024/025/026): **exact match on all 6 phases**. ✓
|
|
||||||
- IDEATE traceability (REQUIREMENTS.md §"IDEATE Traceability"): 8 IDEATE-NN → REQ-ID mappings present (IDEATE-01→REQ-027 ... IDEATE-08→REQ-026). ✓
|
|
||||||
- CHECKPOINT.json matches state: `phase: 5`, `stage: complete`, `milestone: v0.3`, `tag_base: v0.2.x`, `milestone_complete: false`, `phase_release_tag: v0.2.5` — consistent with "P5 complete, advancing to P6 final review/audit/ship". ✓
|
|
||||||
|
|
||||||
**Reconstruction test verdict: PASS** (6/6 phase blocks well-formed; 6/6 tags present; 6/6 REQ-coverage sets match; CHECKPOINT current).
|
|
||||||
|
|
||||||
### File Discipline — **PASS** (after fix)
|
|
||||||
|
|
||||||
`.ciagent/oy/` contains: PROJECT.md, ROADMAP.md, REQUIREMENTS.md, ARCHITECTURE.md, RESEARCH.md, PERSONAS.md, PLANS.md, GRILL.md, REVIEW.md, AUDIT.md. Plus historical P1_SHIP_VERIFICATION.md..P4_SHIP_VERIFICATION.md (v0.2 audit artifacts; not orphan — referenced by v0.2 AUDIT.md).
|
|
||||||
|
|
||||||
`.ciagent/` (root, multi-project) contains: CHECKPOINT.json, config.json, oy/ (slug subdir). ✓ config.json valid (`projects[]` length 1, `active_project: oy`, `milestone: v0.3`, `tag_base: v0.2.x`, `autonomy.level: full`). ✓
|
|
||||||
|
|
||||||
**Stale-content fixes applied during this audit:**
|
|
||||||
- REQUIREMENTS.md v0.3 table: all 8 REQs were marked `Pending` despite P1-P5 shipping them. Updated REQ-010/022/023/024/025/026 → `Skeleton`, REQ-027/028 → `Complete` to match the `---ci---` coverage blocks.
|
|
||||||
- ROADMAP.md v0.3 milestone: header read `ACTIVE` with no per-phase completion markers; P0-P5 shipped. Added `[x]` markers for P0-P5 and `[ ]` for P6-in-progress, plus a status line.
|
|
||||||
|
|
||||||
No orphan files detected. REVIEW.md exists (v0.2 content; review agent may append v0.3 section concurrently — tracked as pending, non-blocking).
|
|
||||||
|
|
||||||
**File discipline verdict: PASS** (after REQUIREMENTS + ROADMAP freshness fixes).
|
|
||||||
|
|
||||||
### Branch Hygiene — **PASS**
|
|
||||||
|
|
||||||
- `git branch -a` lists: `main`, `oy/milestone/v0.3-bearers-docs`, `oy/phase/06-final-review-ship` (current), `remotes/origin/main`, `remotes/origin/oy/milestone/v0.3-bearers-docs`.
|
|
||||||
- No leftover execution phase branches (`oy/phase/01-05`): grep for `phase/0[1-5]` returned zero. ✓ Phase branches deleted after merge.
|
|
||||||
- Milestone branch `oy/milestone/v0.3-bearers-docs` exists and is at the P5-checkpoint commit (62ff0d7), matching the final-phase branch HEAD. ✓
|
|
||||||
- Final-phase branch `oy/phase/06-final-review-ship` exists and tracks milestone HEAD. ✓
|
|
||||||
|
|
||||||
**Branch hygiene verdict: PASS.**
|
|
||||||
|
|
||||||
### Commit Discipline — **PASS**
|
|
||||||
|
|
||||||
- 6 phase-ship commits follow `docs(P##): complete ...` convention (P00..P05). ✓
|
|
||||||
- 5 checkpoint commits follow `checkpoint(P##): ...` convention. ✓
|
|
||||||
- All 6 `---ci---` blocks well-formed (opening `---ci---`, closing `---/ci---`, YAML keys `project: oy`, `phase: N`, `milestone: v0.3`, `status: complete`, `tag_base: v0.2.x`, `phase_role`, `requirements.covered`, `requirements.partial`). ✓
|
|
||||||
- Multi-project `project: oy` field present in every `---ci---` block. ✓
|
|
||||||
- No malformed blocks, no missing closing tags, no orphan phase markers.
|
|
||||||
|
|
||||||
**Commit discipline verdict: PASS.**
|
|
||||||
|
|
||||||
### Build/Test Sanity — **PASS**
|
|
||||||
|
|
||||||
- `go build ./...` → GREEN (exit 0). ✓
|
|
||||||
- `go test ./...` → 26 packages GREEN, 4 packages `[no test files]` (identity/processing/rootpool/vault — pre-existing v0.1 layout), zero FAIL. ✓
|
|
||||||
- New v0.3 packages present and green: x/exit, x/bridge, x/hub, x/services (plus x/bearers, x/partner, x/bond extended; lexicon_meta_docs at root). ✓
|
|
||||||
|
|
||||||
### Fixes Applied
|
|
||||||
|
|
||||||
| Fix | File | Change | Severity |
|
|
||||||
|---|---|---|---|
|
|
||||||
| 1 | `.ciagent/oy/REQUIREMENTS.md` | v0.3 REQ table statuses: 8 REQs Pending → 6 Skeleton + 2 Complete (matches `---ci---` coverage) | critical (stale docs) |
|
|
||||||
| 2 | `.ciagent/oy/ROADMAP.md` | v0.3 milestone: added P0-P5 `[x]` completion markers + P6 `[ ]` + status line | critical (stale docs) |
|
|
||||||
|
|
||||||
Both fixes are committed under `fix(P06-audit):` per the final-audit protocol (see commit below).
|
|
||||||
|
|
||||||
### Overall Audit Verdict — **PASS**
|
|
||||||
|
|
||||||
```
|
|
||||||
Per-check verdicts (v0.3 final):
|
|
||||||
1. Reconstruction test — PASS (6 phase blocks; 6 tags v0.2.0..v0.2.5; REQ coverage exact on all 6 phases; CHECKPOINT current)
|
|
||||||
2. File discipline — PASS (after fix; 10 .ciagent/oy/ files + CHECKPOINT.json + config.json; no orphans)
|
|
||||||
3. Branch hygiene — PASS (no phase/01-05 branches; milestone + final-phase present)
|
|
||||||
4. Commit discipline — PASS (6 docs(P##) + 5 checkpoint(P##); all ---ci--- well-formed; project: oy present)
|
|
||||||
5. Build/test sanity — PASS (build GREEN; 26 pkgs GREEN; new v0.3 packages green)
|
|
||||||
|
|
||||||
Critical issues: 2 found → 2 fixed → 0 remaining
|
|
||||||
- Critical-1: REQUIREMENTS.md v0.3 statuses stale (Pending vs Skeleton/Complete) → FIXED
|
|
||||||
- Critical-2: ROADMAP.md v0.3 P0-P5 completion markers missing → FIXED
|
|
||||||
|
|
||||||
Non-critical: 1 (REVIEW.md v0.3 section pending concurrent review agent — non-blocking)
|
|
||||||
Escalations: 0
|
|
||||||
Overall verdict: PASS (after critical fixes)
|
|
||||||
Confidence: 0.92
|
|
||||||
AUDIT.md appended: /root/oy/.ciagent/oy/AUDIT.md ✓ (v0.3 section appended; v0.2 content preserved)
|
|
||||||
```
|
|
||||||
|
|
||||||
AUDIT.md
|
|
||||||
@@ -316,73 +316,3 @@ Binding decisions: 4 (G-011..G-014)
|
|||||||
Escalations: 0
|
Escalations: 0
|
||||||
Overall: SHIP Phase 0 with binding changes (confidence 0.80)
|
Overall: SHIP Phase 0 with binding changes (confidence 0.80)
|
||||||
```
|
```
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Grill Review: OpenYield (oy) — v0.4 (Refinement — NFR) Phase 0
|
|
||||||
|
|
||||||
> **Reviewer**: CIAgent adversarial grill (red-team, full autonomy)
|
|
||||||
> **Date**: 2026-08-17
|
|
||||||
> **Target**: Phase 0 artifacts (PROJECT.md v0.4 section, REQUIREMENTS.md v0.4 table, ARCHITECTURE.md v0.4 section, PERSONAS.md v0.4, PLANS.md v0.4 plan, config.json) + v0.3 codebase baseline
|
|
||||||
> **Milestone**: v0.4 — Refinement (NFR)
|
|
||||||
> **Autonomy**: full (decision_confidence_threshold = 0.60)
|
|
||||||
> **Mode**: multi-project (slug `oy`)
|
|
||||||
|
|
||||||
### Methodology
|
|
||||||
|
|
||||||
Each of nine axes was scored against concrete evidence. The v0.4 scope is small (4 REQs, 12 tasks, 5 phases including P0+P4) so the grill is correspondingly focused. The central question: **is v0.4 a legitimate NFR milestone or is it a scope-creep / busywork milestone that should be a single patch or deferred?**
|
|
||||||
|
|
||||||
### Evidence baseline (verified against the actual repo)
|
|
||||||
|
|
||||||
- v0.3 milestone COMPLETE: checkpoint `milestone_complete: true`, release `v0.2.6` (id 739), HEAD on `main`. Verified.
|
|
||||||
- v0.3 forward-references for v0.4 scope: REVIEW.md P2 (A-304 cross-const), AUDIT.md §193 (P1-1/P1-2 council divergences, P2 bearers no-op), GRILL.md G-014 (lexicon helper). Verified — all four REQ-029..REQ-032 map to a real v0.3 forward-reference.
|
|
||||||
- The two synthetic self-test tables are byte-identical duplicates: `lexicon_meta_test.go:93-104` and `lexicon_meta_docs/lexicon_meta_docs_test.go:157-168`. Verified by diff — G-014 drift risk is REAL, not theoretical.
|
|
||||||
- `x/hub/types/types.go:51,56` defines `LendingCouponCapBps = uint32(800)` and `LendingCouponFloorBps = uint32(0)`; `x/bond/types/types.go:21,26` defines `CouponCapBps = 800` and `CouponFloorBps = 0`. No automated cross-check exists (only a cross-doc comment). Verified — A-304 drift risk is REAL.
|
|
||||||
- `SignalKindCount = 4` is already a locked-const test (`x/council/types/types_test.go:102`). The AUDIT P1-2 rationale (Freeholder is eligibility, Guild is council tier, Capital is committed-capital) is in AUDIT.md but NOT in ARCHITECTURE.md. Verified — the documentation gap is REAL.
|
|
||||||
- No `.github/workflows/` and no `.gitea/workflows/` directory exists. Verified — REQ-032 is net-new CI, not a modification.
|
|
||||||
|
|
||||||
### Forcing Questions and Verdicts
|
|
||||||
|
|
||||||
**1. Is v0.4 a legitimate milestone, or should these fixes be a single v0.3.x patch?**
|
|
||||||
The four fixes are independent (lexicon helper, cross-const test, lifecycle docs, CI). Bundling them into a milestone with per-phase ship (P1..P3 each ship a patch) gives each fix its own release tag and audit trail. The alternative (one v0.3.1 patch with all four) loses the per-fix release boundary. D-052 phase ordering keeps each phase independently shippable. **Verdict: legitimate milestone** — the per-phase ship cadence (D-052) is the value, not the milestone label. Confidence 0.82.
|
|
||||||
|
|
||||||
**2. Does REQ-031 violate the D-001 refinement-only filter by documenting a divergence that mentions `Proposal`/`VoteOption` (which are `feat:`-class types)?**
|
|
||||||
No. DOCUMENTING a deferred `feat:` in ARCHITECTURE.md is `docs`, not `feat:`. REQ-031 adds NO enum types and changes NO locked consts. The regression-guard test asserts the CURRENT shape (4-signal) is intentional — it does not ADD a type. The D-001 filter rejects the `feat:` (adding Proposal/VoteOption) but accepts the `docs` (documenting why they are absent). **Verdict: passes D-001**. Confidence 0.85.
|
|
||||||
|
|
||||||
**3. Does REQ-030's test-only import of `x/bond/types` into `x/hub/types` violate G-003?**
|
|
||||||
No. G-003 forbids PRODUCTION cross-module struct imports. The import is in `x/hub/types/cross_const_test.go` (a `_test.go` file). The v0.2 GRILL G-003 explicitly documented the test-import exemption (and `x/bearers/types/types_test.go:7` already imports `x/processing/types` as a test-only precedent). The plan's P1-02-01 verification includes a grep confirming no PRODUCTION `.go` file in `x/hub/types/` imports `x/bond/types`. **Verdict: G-003 intact**. Confidence 0.88.
|
|
||||||
|
|
||||||
**4. Does REQ-032 (CI workflow with mkdocs) violate G-006 (zero Go deps)?**
|
|
||||||
No. The CI workflow installs mkdocs + mkdocs-material in a SEPARATE CI job (`docs-build`), not in `go.mod`. `go.mod` is not modified by the workflow (Python deps are isolated to the CI runner). The plan's P3-99-01 verification confirms `go.mod` diff is empty. **Verdict: G-006 intact**. Confidence 0.90.
|
|
||||||
|
|
||||||
**5. Is the NFR purity gate enforceable?**
|
|
||||||
Yes. The gate is `git log --grep "^feat:" <milestone-range>` returns zero. The v0.4 plan explicitly rejects `feat:`-class work (D-050 rejects Proposal/VoteOption; D-051 rejects Pages publish as a feature). The P4 audit (P4-02-01) runs the gate. The only risk: a commit MESSAGE accidentally using `feat:` prefix — the gate checks the prefix, not the content. **Verdict: enforceable**. Confidence 0.85.
|
|
||||||
|
|
||||||
### Binding Decisions (G-015..G-016)
|
|
||||||
|
|
||||||
| ID | Binding Decision | Rationale | Confidence | Source |
|
|
||||||
|----|------------------|-----------|------------|--------|
|
|
||||||
| **G-015** | The P1-02-01 cross-const test MUST also assert the absolute mission-locked values (`LendingCouponCapBps == 800`, `LendingCouponFloorBps == 0`, `CouponCapBps == 800`, `CouponFloorBps == 0`) in addition to the cross-package equality. The cross-package equality test alone would pass if BOTH consts drifted to the same wrong value (e.g., both 900). The absolute-value assertion catches a paired drift. | The plan's P1-02-01 already includes `TestConstsAreMissionLocked800And0` (good). This binding makes it MANDATORY: the test must assert BOTH the cross-equality AND the absolute 800/0 values. Without the absolute assertion, a paired drift (both consts change to the same wrong value) is undetected. | 0.85 | PLANS.md P1-02-01 |
|
|
||||||
| **G-016** | The P3-01-01 CI workflow MUST run `go test ./...` BEFORE `mkdocs build` is allowed to proceed IF the jobs are serial, OR the `go-test` job must be a REQUIRED check (not advisory) if the jobs are parallel. The lexicon firewall (`go test ./...`) is the higher-priority check; a docs build that passes while the lexicon firewall fails is a false-green deploy. The plan's P3-01-01 runs the jobs in parallel (no dependency) — acceptable ONLY if both are required-status checks. If Gitea Actions does not support required-status on artifact-upload jobs, the jobs MUST be serial (`go-test` then `docs-build` depends-on `go-test`). | A docs build that succeeds while the lexicon firewall fails would publish (or artifact) a docs site from a repo that has a lexicon violation — a false-green. The firewall must gate the docs build. | 0.78 | PLANS.md P3-01-01 |
|
|
||||||
|
|
||||||
### Nine-Axis Scorecard (v0.4 Phase 0)
|
|
||||||
|
|
||||||
```
|
|
||||||
1. Scope — PASS (0.85) scoped to 4 v0.3 forward-refs; no scope creep
|
|
||||||
2. Feasibility — PASS (0.88) all 4 fixes are mechanical; no research risk
|
|
||||||
3. Specification — PASS (0.82) REQs clear; D-047..D-053 unambiguous
|
|
||||||
4. Decomposition — PASS (0.80) P1..P3 independent vertical slices; P4 review/ship
|
|
||||||
5. Risk — PASS (0.85) NFR scope (no behavioral change); low risk
|
|
||||||
6. Dependency — PASS (0.82) no cross-phase hard blockers; G-003 test-exempt documented
|
|
||||||
7. Testing — PASS (0.85) each fix has a verification task; NFR purity gate in P4
|
|
||||||
8. Maintainability — CONDITIONAL (0.78) → fixed by G-015 (absolute-value assertion)
|
|
||||||
9. Adversarial — PASS (0.82) D-001 filter enforced; no feat: creep
|
|
||||||
|
|
||||||
Binding decisions: 2 (G-015, G-016)
|
|
||||||
Escalations: 0
|
|
||||||
Overall: SHIP Phase 0 with binding changes (confidence 0.84)
|
|
||||||
```
|
|
||||||
|
|
||||||
### v0.4 Grill Verdict
|
|
||||||
|
|
||||||
**SHIP Phase 0** with G-015 (absolute-value const assertion in P1-02-01) and G-016 (firewall-gates-docs-build in P3-01-01) applied. The v0.4 NFR milestone is a legitimate, well-scoped refinement cycle that closes three real v0.3 forward-references (G-014, A-304, AUDIT §193) and lands the deferred docs CI (D-046). The D-001 refinement-only filter is enforced throughout; the NFR purity gate in P4 is enforceable. No escalations.
|
|
||||||
|
|||||||
+92
-51
@@ -3,80 +3,121 @@ active_personas:
|
|||||||
- id: backend-engineer
|
- id: backend-engineer
|
||||||
active: true
|
active: true
|
||||||
phase_specific: false
|
phase_specific: false
|
||||||
reason: Owns the v0.4 NFR code work: REQ-029 (lexicon shared helper in `lexicon/lexicon.go` + refactor of both meta-tests to consume it), REQ-030 (new `x/hub/types/cross_const_test.go` test-only import of `x/bond/types`), and REQ-031's regression-guard test (council SignalKind intent-assertion test). The v0.3 frontend/docs-writer personas are deactivated because v0.4 has no docs-content authoring; the only docs-adjacent work is the CI workflow file (REQ-032, owned by lead-developer as infra/config). v0.4 is pure Go test/refactor work, which is backend-engineer's core territory.
|
reason: Owns ALL Bearers skeleton Go modules in v0.3 (x/exit, x/bridge, x/bearers ext, x/partner ext, x/hub, x/services, x/bond ext). The v0.2 cosmos-engineer/security-engineer split is collapsed back into backend-engineer for v0.3 because the Cosmos-convention-alignment load is lower (no new IBC/governance/capability modules — x/bridge reuses the v0.2 satellite ICS-20 shape, x/hub is a fresh B2B scaffold). v0.3 is bespoke-type skeleton + tests work, which is backend-engineer's core territory.
|
||||||
frameworks: [Go 1.22 stdlib (zero-dep), Go testing, lexicon firewall]
|
frameworks: [Go 1.22 stdlib, Cosmos-style types (zero-dep)]
|
||||||
territory: ["lexicon/**", "lexicon_meta_test.go", "lexicon_meta_docs/**", "x/hub/types/**", "x/bond/types/**", "x/council/types/**"]
|
territory: ["x/exit/**", "x/bridge/**", "x/hub/**", "x/services/**", "x/bearers/**", "x/partner/**", "x/bond/**", "x/**/types/**", "x/**/keeper/**"]
|
||||||
constraints: ["zero external deps (G-006 — go.mod stays zero-require)", "D-001 refinement-only filter: refactor/test/quality only, NO feat: (no new enum types, no new production types, no behavioral change)", "G-003 by-ID-string rule preserved in PRODUCTION imports; test-only cross-package imports are EXEMPT (G-003 test exemption — REQ-030 relies on this)", "lexicon firewall stays green on both x/ and docs/ after refactor", "locked-const invariants stay green (SignalKindCount==4 unchanged; LendingCouponCapBps==800, LendingCouponFloorBps==0 unchanged)", "≥80% coverage on any modified package (do not reduce existing coverage)"]
|
constraints: ["zero external deps (G-006 — go.mod read-only)", "D-020 skeleton+tests pattern (D-035 continues)", "≥80% coverage on new/extended packages", "per-package lexicon assertion (REQ-012) in every new/extended test file", "by-ID-string inter-module refs (G-003 — no struct imports across x/<module>/types)", "locked-const invariants (HubService count, ServiceKind count, BridgeStatus count, ExitStatus count, Anchor credential fields)", "no live chain / no real IBC / no real bearer transports / no live B2B runtime"]
|
||||||
|
|
||||||
- id: lead-developer
|
- id: lead-developer
|
||||||
active: true
|
active: true
|
||||||
phase_specific: false
|
phase_specific: false
|
||||||
reason: Coordinates v0.4 phase decomposition (P1 lexicon+const hardening → P2 lifecycle divergence docs+guard → P3 docs CI → P4 review/ship), territory enforcement (warn mode per config.json), the final-phase NFR purity gate audit (zero `feat:` commits), and the milestone ship. Owns REQ-031's ARCHITECTURE.md documentation deliverable (the divergence-decision writeup) and REQ-032's CI workflow file (`.gitea/workflows/docs-build.yml`) as infra/config territory. Also owns the v0.4 ROADMAP.md / REQUIREMENTS.md status updates at milestone completion.
|
reason: Coordinates the v0.3 phase decomposition (P1 firewall+docs foundation → P2 nomads → P3 freeholders → P4 Bearers I → P5 Bearers II → P6 review/ship), territory enforcement (warn mode per config.json), and final review. Owns the cross-component dependency finding (x/exit→x/bridge in P4; x/partner-Anchor→x/hub across P4→P5) that constrains phase ordering.
|
||||||
frameworks: [cross-cutting, Gitea Actions, Markdown, YAML]
|
frameworks: [cross-cutting]
|
||||||
territory: [".ciagent/**", ".gitea/workflows/**", ".ciagent/oy/ARCHITECTURE.md", ".ciagent/oy/ROADMAP.md", ".ciagent/oy/REQUIREMENTS.md"]
|
territory: [".ciagent/**", "**"]
|
||||||
constraints: ["D-052 phase ordering (P1 firewall-first; each phase independently shippable)", "milestone versioning (v0.4 NFR / tag_base v0.3.x)", "NFR purity gate: zero feat: commits in the milestone (final-phase audit)", "persona territory warn-mode enforcement", "zero Go deps invariant (G-006) preserved; CI workflow may use build-only Python deps (mkdocs) in a separate job", "D-001 filter: no feat: scope creep — the CI workflow is chore (build+artifact), NOT a publishing feature"]
|
constraints: ["D-044 phase ordering (firewall-first; P4 before P5 for Anchor→hub dep)", "milestone versioning (v0.3 / tag_base v0.2.x)", "lexicon gate on merge (REQ-012 extends to docs/)", "persona territory warn-mode enforcement", "zero Go deps invariant (G-006); docs build-deps are allowed (D-042)"]
|
||||||
|
|
||||||
|
- id: frontend-engineer
|
||||||
|
active: true
|
||||||
|
phase_specific: true
|
||||||
|
reason: v0.3 introduces the docs site (REQ-027) — the first non-skeleton, non-Go deliverable since v0.1's Mesh Experience. frontend-engineer owns the docs territory (docs/**, mkdocs.yml, README.md) and the docs firewall test (lexicon_meta_docs_test.go). Phase-specific: ACTIVE only for P1-P3 (docs phases); removed after P3 once the docs site is complete and the Bearers skeleton phases (P4/P5) are pure Go.
|
||||||
|
frameworks: [MkDocs Material, Markdown]
|
||||||
|
territory: ["docs/**", "mkdocs.yml", "README.md", "lexicon_meta_docs_test.go"]
|
||||||
|
constraints: ["lexicon-clean by construction (REQ-012 extended to docs via D-043 — 10 banned terms must not appear in docs/*.md or README.md; 'yield' banned as standalone word, 'OpenYield' safe via word-boundary regex)", "audience-organized nav (nomads/freeholders/shared/reference per D-042)", "~20-25 pages total per D-045", "no publishing CI in v0.3 (D-046 — mkdocs.yml buildable locally only)", "mkdocs.yml is build-only Python dep; go.mod stays zero-dep (G-006)"]
|
||||||
|
removed_after: P3
|
||||||
|
|
||||||
|
- id: docs-writer
|
||||||
|
active: true
|
||||||
|
phase_specific: true
|
||||||
|
reason: Custom persona for the docs content authoring load (REQ-027, D-045 ~20-25 pages across 4 audiences). Folded as a SEPARATE persona rather than into frontend-engineer because the skills differ: frontend-engineer owns the docs TOOLCHAIN (mkdocs.yml config, theme, nav structure, firewall test wiring) while docs-writer owns the CONTENT (the actual Markdown pages: nomads Reach/Stash/bearers pages, freeholders Standing/Bonds pages, shared Principles/Bread-Scale pages, reference architecture-index). Splitting keeps the toolchain-vs-content boundary explicit so a toolchain change does not entangle content review. Phase-specific: ACTIVE only for P1-P3; removed after P3.
|
||||||
|
frameworks: [Markdown, MkDocs Material (content authoring only)]
|
||||||
|
territory: ["docs/nomads/**/*.md", "docs/freeholders/**/*.md", "docs/shared/**/*.md", "docs/reference/**/*.md"]
|
||||||
|
constraints: ["lexicon-clean by construction (same REQ-012 extension — 'real production'/'real return' not 'real yield'; 'Holder'/'Reach' not 'account'; 'Stash'/'Vault'/'Root-Pool' not 'bank'/'deposit'/'savings')", "audience-organized (each page belongs to exactly one of nomads/freeholders/shared/reference)", "page-count budget per D-045", "no banned-term literals in page source (the docs firewall scans .md files directly, unlike .go which uses fragment assembly)"]
|
||||||
|
removed_after: P3
|
||||||
|
|
||||||
deactivated:
|
deactivated:
|
||||||
- id: frontend-engineer
|
|
||||||
reason: INACTIVE for v0.4. The v0.3 docs site (docs/**, mkdocs.yml) is COMPLETE; v0.4 does not author or restructure docs content. The only docs-adjacent work is the CI workflow that BUILDS the existing site (REQ-032), which is infra/config territory owned by lead-developer, not frontend toolchain. Reactivate in v0.5+ if docs content is restructured or i18n is added.
|
|
||||||
- id: docs-writer
|
|
||||||
reason: INACTIVE for v0.4. v0.3's docs-writer owned page content authoring; v0.4 has zero new docs pages. The only documentation work is the ARCHITECTURE.md divergence-decision section (REQ-031), which is lead-developer's architecture territory, not audience-content authoring. Reactivate if a future milestone adds docs pages.
|
|
||||||
- id: data-engineer
|
- id: data-engineer
|
||||||
reason: INACTIVE for v0.4 (carried from v0.3). The project has zero external deps and no database; REQ-031 does not change genesis schemas (it documents divergence, no schema change). Reactivate if a future milestone adds a real store/migration.
|
reason: INACTIVE for v0.3. The project has zero external deps and no database; the v0.2 data-engineer owned genesis.go schema helpers, which are a thin layer in v0.3's new modules (x/exit, x/bridge, x/hub, x/services each get a small GenesisState + ValidateGenesis following the v0.2 A-212 pattern). That work is owned by backend-engineer in v0.3 (the genesis schema is part of the skeleton type authoring, not a separate schema-design discipline). Reactivate if a future milestone adds a real store/migration.
|
||||||
- id: cosmos-engineer
|
- id: cosmos-engineer
|
||||||
reason: INACTIVE for v0.4. v0.4 has no new Cosmos-convention-alignment work (no new modules, no IBC, no governance runtime). Reactivate in v0.5+ if live-runtime promotion of the v0.3 Bearers skeletons lands.
|
reason: The v0.2 custom persona is NOT reactivated for v0.3. v0.3's new modules do not map onto new Cosmos SDK modules the way v0.2's did (x/gov, x/group, x/authz, x/capability, x/ibc-transfer). x/bridge reuses the v0.2 satellite ICS-20 shape (already aligned); x/hub/x/services/x/exit are bespoke B2B/service scaffolds with no direct Cosmos analog. The Cosmos-convention-alignment load drops below the threshold that justified a separate persona. backend-engineer absorbs the work.
|
||||||
- id: security-engineer
|
- id: security-engineer
|
||||||
reason: INACTIVE for v0.4. v0.4 introduces no new Mission-Lock-class invariant; REQ-029/030/031 are refactor/test/docs, not security invariants. The existing locked-consts stay unchanged. Reactivate if a future milestone adds a new mission-locked const or a new clamp.
|
reason: The v0.2 custom persona is NOT reactivated for v0.3. v0.3's invariant density is lower than v0.2's (no Mission Lock, no new fee/bond clamp — the 8%/0% consts are reused unchanged from v0.2; the new locked-consts are enum counts: HubService=3, ServiceKind=4, BridgeStatus, ExitStatus). The locked-const + invariant tests are absorbed by backend-engineer's per-package test authoring. The docs firewall (lexicon_meta_docs_test.go) is frontend-engineer's territory. Reactivate in v0.4 if a new Mission-Lock-class invariant lands.
|
||||||
- id: ci-security-auditor
|
- id: ci-security-auditor
|
||||||
reason: Default deactivated; activate in P4 (final review/ship) for the v0.4 milestone audit and NFR purity gate enforcement.
|
reason: Default deactivated; activate in P6 (review/ship) for the v0.3 milestone audit.
|
||||||
- id: mesh-engineer
|
- id: mesh-engineer
|
||||||
reason: Still not needed in v0.4 (no bearer hardware runtime; OY-SAT/OY-QR remain type stubs). Activate in v0.5+ for real bearer runtime.
|
reason: Still not needed in v0.3 (OY-SAT/OY-QR are type stubs only; no hardware/RF runtime). Activate in v0.4+ for real bearer runtime.
|
||||||
|
|
||||||
custom_personas: []
|
custom_personas:
|
||||||
|
- id: docs-writer
|
||||||
|
rationale: v0.3's docs deliverable (~20-25 pages across 4 audiences per D-045) is a substantial content-authoring load distinct from the docs toolchain work. A dedicated docs-writer keeps the content-vs-toolchain boundary explicit: frontend-engineer owns mkdocs.yml/nav/theme/firewall-wiring; docs-writer owns the page content. This split means a toolchain PR (e.g., adding a markdown extension) does not entangle a content review (e.g., a nomads Reach-page rewrite), and vice versa. Distinct from frontend-engineer because content authoring (prose, audience voice, lexicon-safe phrasing) is a different skill from toolchain config (YAML, theme, nav, Go test wiring). Removed after P3 when the docs site is complete.
|
||||||
---
|
---
|
||||||
|
|
||||||
# Personas: OpenYield (oy) — v0.4 (Refinement — NFR)
|
# Personas: OpenYield (oy) — v0.3 (Bearers & Documentation)
|
||||||
|
|
||||||
> This file supersedes the v0.3 PERSONAS.md for the v0.4 milestone. v0.4 is a
|
> This file supersedes the v0.2 PERSONAS.md for the v0.3 milestone. The v0.2
|
||||||
> refinement-only NFR milestone (D-047): zero `feat:` phases. The active
|
> custom personas (cosmos-engineer, security-engineer) are NOT reactivated for
|
||||||
> roster is **backend-engineer + lead-developer** only. The v0.3
|
> v0.3 — see Deactivated below for rationale. The default four personas are
|
||||||
> phase-specific personas (frontend-engineer, docs-writer) are deactivated
|
> backend-engineer, data-engineer, frontend-engineer, lead-developer; v0.3
|
||||||
> because v0.4 does not author docs content or restructure the docs toolchain;
|
> activates backend-engineer + lead-developer + frontend-engineer (phase-
|
||||||
> the only docs-adjacent work is a CI workflow file (REQ-032) owned by
|
> specific) and adds one custom persona (docs-writer, phase-specific).
|
||||||
> lead-developer as infra/config.
|
|
||||||
|
|
||||||
## Active Roster
|
## Active Roster
|
||||||
|
|
||||||
| Persona | Active | Phase-specific | Territory |
|
### backend-engineer
|
||||||
|---------|--------|-----------------|-----------|
|
- **Domain**: All Bearers skeleton Go modules in v0.3 — `x/exit`, `x/bridge`, `x/hub`, `x/services` (new); `x/bearers`, `x/partner`, `x/bond` (extended). Owns the D-020 skeleton+tests pattern (D-035 continues): Go types + keeper stubs + invariant tests, no live chain. Absorbs the v0.2 cosmos-engineer/security-engineer split because v0.3's Cosmos-convention and invariant density are lower.
|
||||||
| backend-engineer | yes | no (all phases) | `lexicon/**`, `lexicon_meta*`, `x/hub/types`, `x/bond/types`, `x/council/types` |
|
- **Frameworks**: Go 1.22 stdlib, Cosmos-style types (zero-dep).
|
||||||
| lead-developer | yes | no (all phases) | `.ciagent/**`, `.gitea/workflows/**` |
|
- **Territory**: `x/exit/**`, `x/bridge/**`, `x/hub/**`, `x/services/**`, `x/bearers/**`, `x/partner/**`, `x/bond/**`, `x/**/types/**`, `x/**/keeper/**`. (`go.mod` is read-only per G-006.)
|
||||||
|
- **Constraints**: zero external deps (G-006), D-020 skeleton+tests (D-035), ≥80% coverage on new/extended packages, per-package lexicon assertion (REQ-012), by-ID-string inter-module refs (G-003), locked-const invariants (HubService/ServiceKind/BridgeStatus/ExitStatus counts + Anchor credential fields), no live chain/IBC/bearer/B2B runtime.
|
||||||
|
|
||||||
## Phase-Persona Matrix
|
### lead-developer
|
||||||
|
- **Domain**: v0.3 phase decomposition (P1 firewall+docs foundation → P2 nomads → P3 freeholders → P4 Bearers I → P5 Bearers II → P6 review/ship), territory enforcement (warn mode), final review. Owns the cross-component dependency finding that constrains phase ordering: `x/exit`→`x/bridge` (same phase P4); `x/partner`-Anchor→`x/hub` (P4 before P5).
|
||||||
|
- **Frameworks**: cross-cutting.
|
||||||
|
- **Territory**: `.ciagent/**`, `**`.
|
||||||
|
- **Constraints**: D-044 phase ordering (firewall-first; P4→P5 for Anchor→hub dep), milestone versioning (v0.3 / tag_base v0.2.x), lexicon gate on merge (REQ-012 extends to docs/), persona territory warn-mode, zero Go deps (G-006; docs build-deps allowed per D-042).
|
||||||
|
|
||||||
| Phase | Personas | Work |
|
### frontend-engineer (phase-specific: P1-P3 only)
|
||||||
|-------|----------|------|
|
- **Domain**: v0.3 docs TOOLCHAIN — `mkdocs.yml` (site_name, nav, theme: material, markdown_extensions), the audience-based nav structure (nomads/freeholders/shared/reference per D-042), and the docs firewall test wiring (`lexicon_meta_docs_test.go` mirroring `lexicon_meta_test.go` with `lexicon.FindBannedTerm` + word-boundary regex + self-test table + self-exclusion, scanning `README.md` + `docs/**/*.md`). Owns the firewall landing in P1 BEFORE content (D-044 firewall-first). Removed after P3.
|
||||||
| P0 (pre-execution) | lead-developer | spec/clarify/research/plan/grill/mvp-ux + ship |
|
- **Frameworks**: MkDocs Material, Markdown, Go testing (for the firewall test).
|
||||||
| P1 (lexicon hardening) | backend-engineer | REQ-029 shared helper + REQ-030 cross-const test |
|
- **Territory**: `docs/**` (toolchain), `mkdocs.yml`, `README.md`, `lexicon_meta_docs_test.go`.
|
||||||
| P2 (lifecycle divergence) | backend-engineer (regression-guard test) + lead-developer (ARCHITECTURE.md docs) | REQ-031 |
|
- **Constraints**: lexicon-clean by construction (REQ-012 extended via D-043; 10 banned terms absent from docs; "yield" banned standalone, "OpenYield" safe), audience-organized nav, ~20-25 pages total (D-045), no publishing CI in v0.3 (D-046), mkdocs.yml build-only Python dep (go.mod stays zero-dep per G-006).
|
||||||
| P3 (docs build CI) | lead-developer | REQ-032 `.gitea/workflows/docs-build.yml` |
|
- **Removed after**: P3.
|
||||||
| P4 (final review/ship) | lead-developer + ci-security-auditor (audit) | review + NFR purity gate + milestone ship |
|
|
||||||
|
|
||||||
## D-001 Refinement-Only Filter (governs all v0.4 work)
|
### docs-writer (custom, phase-specific: P1-P3 only)
|
||||||
|
- **Domain**: v0.3 docs CONTENT — the actual Markdown pages across the four audiences (nomads: Reach/Stash/bearers/Maps-Pay/Pacts/standing-basics; freeholders: 4-signals/Bayesian-Standing/Stands-Guilds/Councils-Voice/Bonds/Partner-spectrum; shared: Six-Principles/Bread-Scale/Storage-pools/Watchers-Mirror/Lexicon-glossary/Vision-overview; reference: architecture-index/component-map). Split from frontend-engineer so content review and toolchain review do not entangle. Removed after P3.
|
||||||
|
- **Frameworks**: Markdown, MkDocs Material (content authoring only).
|
||||||
|
- **Territory**: `docs/nomads/**/*.md`, `docs/freeholders/**/*.md`, `docs/shared/**/*.md`, `docs/reference/**/*.md`.
|
||||||
|
- **Constraints**: lexicon-clean by construction (same REQ-012 extension; "real production"/"real return" not "real yield"; "Holder"/"Reach" not "account"; "Stash"/"Vault"/"Root-Pool" not "bank"/"deposit"/"savings"), audience-organized (each page in exactly one audience dir), page-count budget per D-045, no banned-term literals in page source (docs firewall scans .md directly, unlike .go fragment assembly).
|
||||||
|
- **Removed after**: P3.
|
||||||
|
|
||||||
Every v0.4 change must pass the D-001 filter:
|
## Deactivated
|
||||||
- **Accept**: refactor, test, docs, chore, perf, fix, quality, coverage, architecture (drift fix only), improvement (of existing).
|
|
||||||
- **Reject**: any `add_requirement` + `feat:`-class signal (new capability, new enum type, new production type, new CLI, new distribution channel, new backend).
|
|
||||||
- **Enforcement**: lead-developer reviews each phase's commit set; the final-phase audit runs the NFR purity gate (`git log --grep "^feat:" --all-match` on the milestone range must return zero).
|
|
||||||
|
|
||||||
## Constraints Carried Forward
|
- **data-engineer** — INACTIVE for v0.3. Zero deps + no database; the v0.2 genesis.go schema work is a thin layer absorbed by backend-engineer in v0.3's new modules. Reactivate if a future milestone adds a real store/migration.
|
||||||
|
- **cosmos-engineer** (v0.2 custom) — NOT reactivated. v0.3's new modules do not map onto new Cosmos SDK modules (x/bridge reuses v0.2 satellite shape; x/hub/x/services/x/exit are bespoke). Cosmos-convention load drops below the threshold for a separate persona; backend-engineer absorbs.
|
||||||
|
- **security-engineer** (v0.2 custom) — NOT reactivated. v0.3's invariant density is lower (no new Mission-Lock/fee-clamp; 8%/0% consts reused unchanged; new locked-consts are enum counts). Locked-const + invariant tests absorbed by backend-engineer's per-package test authoring; docs firewall is frontend-engineer's. Reactivate in v0.4 if a new Mission-Lock-class invariant lands.
|
||||||
|
- **ci-security-auditor** — Default deactivated; activate in P6 (review/ship) for the milestone audit.
|
||||||
|
- **mesh-engineer** — Still not needed (OY-SAT/OY-QR are type stubs only). Activate in v0.4+ for real bearer runtime.
|
||||||
|
|
||||||
- **G-003** by-ID-string rule: preserved in PRODUCTION imports. Test-only cross-package imports are EXEMPT (REQ-030 relies on this exemption — `x/hub/types/cross_const_test.go` imports `x/bond/types` in a `_test.go` file only).
|
## Custom Personas
|
||||||
- **G-006** zero Go deps: `go.mod` stays zero-require. The CI workflow (REQ-032) may use build-only Python deps (mkdocs + mkdocs-material) in a separate CI job; this does not touch `go.mod`.
|
|
||||||
- **G-014** lexicon shared helper: REQ-029 closes the G-014 drift risk by adding `lexicon.SyntheticBannedStrings()` as the single source for the synthetic self-test table consumed by BOTH meta-tests.
|
|
||||||
- **Locked consts unchanged**: `SignalKindCount==4`, `LendingCouponCapBps==800`, `LendingCouponFloorBps==0`, `CouponCapBps==800`, `CouponFloorBps==0` — v0.4 does NOT change any locked const. REQ-030 asserts they stay in lockstep; REQ-031 asserts the 4-signal shape is intentional.
|
|
||||||
|
|
||||||
## Removal Notes
|
- **docs-writer** — v0.3's docs deliverable (~20-25 pages, D-045) is a substantial content-authoring load distinct from the docs toolchain. A dedicated docs-writer keeps the content-vs-toolchain boundary explicit: frontend-engineer owns mkdocs.yml/nav/theme/firewall-wiring; docs-writer owns page content. This split means a toolchain PR does not entangle a content review and vice versa. Distinct from frontend-engineer because prose/audience-voice/lexicon-safe-phrasing is a different skill from YAML/theme/nav/Go-test wiring. Removed after P3 when the docs site is complete.
|
||||||
|
|
||||||
- frontend-engineer and docs-writer were `removed_after: P3` in v0.3. They are formally deactivated here for v0.4 (not just phase-removed) because v0.4 has no docs-content phase at all.
|
## Framework Alignment
|
||||||
- No phase-specific personas are created for v0.4. The roster is stable across all phases.
|
- **Go 1.22** — backend-engineer targets Go 1.22 (`go.mod`); zero external deps (G-006).
|
||||||
|
- **MkDocs Material** — frontend-engineer + docs-writer target MkDocs Material (D-042); build-only Python dep, NOT a Go dependency. No publishing CI in v0.3 (D-046).
|
||||||
|
|
||||||
|
## Territory Alignment
|
||||||
|
- backend-engineer owns all `x/*` Bearers-skeleton modules (new: exit/bridge/hub/services; extended: bearers/partner/bond) + shared `types/`+`keeper/` authoring.
|
||||||
|
- frontend-engineer owns the docs toolchain (`docs/**` config, `mkdocs.yml`, `README.md`, `lexicon_meta_docs_test.go`).
|
||||||
|
- docs-writer owns docs content (`docs/<audience>/**/*.md`).
|
||||||
|
- lead-developer owns `.ciagent/**` + `**` for cross-cutting coordination.
|
||||||
|
- `go.mod` is read-only in v0.3 (G-006) — no persona may modify it; docs build-deps are allowed (D-042) but live outside `go.mod`.
|
||||||
|
|
||||||
|
## Constraint Alignment
|
||||||
|
- **Lexicon (REQ-012)** — every active persona carries it; backend-engineer asserts per test file (x/*); frontend-engineer asserts via the docs firewall (docs/* + README.md). The firewall extension is a sibling test, NOT a modification of the v0.2 meta-test (D-043).
|
||||||
|
- **Skeleton + tests (D-020/D-035)** — backend-engineer enforces.
|
||||||
|
- **≥80% coverage** — backend-engineer owns the gate for x/* packages.
|
||||||
|
- **Phase ordering (D-044)** — lead-developer enforces; firewall-first (P1) before content (P2/P3); P4 (exit/bridge/bearers/partner) before P5 (hub/services/bond) for the Anchor→hub dependency.
|
||||||
|
- **Locked-const invariants** — backend-engineer owns; HubService=3, ServiceKind=4, BridgeStatus count, ExitStatus count, Anchor credential fields, 8%/0% bond consts (reused).
|
||||||
|
|
||||||
|
## Phase-Specific Personas
|
||||||
|
- **frontend-engineer** — phase-specific to v0.3 P1-P3 (docs phases). Removed after P3; the Bearers skeleton phases (P4/P5) are pure Go (backend-engineer). Reassess at v0.4 if new docs work is queued.
|
||||||
|
- **docs-writer** — phase-specific to v0.3 P1-P3 (docs content). Removed after P3 with frontend-engineer.
|
||||||
+1
-255
@@ -789,258 +789,4 @@ The v0.3 Phase 0 clarify/ideate/research stages produced 13 clarification decisi
|
|||||||
| A-311 | OY-SAT surveillance-resistant LOCKED true; OY-QR one-shot | P4-03-01 |
|
| A-311 | OY-SAT surveillance-resistant LOCKED true; OY-QR one-shot | P4-03-01 |
|
||||||
| A-312 | HubServiceCount=3 | P5-01-01 |
|
| A-312 | HubServiceCount=3 | P5-01-01 |
|
||||||
| A-313 | OrderSideCount=2, OrderStatusCount=3 | P5-03-01 |
|
| A-313 | OrderSideCount=2, OrderStatusCount=3 | P5-03-01 |
|
||||||
| A-315 | v0.2 cosmos-engineer/security-engineer NOT reactivated | Persona assignments (backend-engineer owns all P4/P5) |
|
| A-315 | v0.2 cosmos-engineer/security-engineer NOT reactivated | Persona assignments (backend-engineer owns all P4/P5) |
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Milestone v0.4 — Refinement (NFR) — Phase Plan
|
|
||||||
|
|
||||||
### Milestone Summary
|
|
||||||
|
|
||||||
- **Milestone**: v0.4 — Refinement (NFR)
|
|
||||||
- **Type**: NFR (zero `feat:` phases by construction; D-047). Final-phase audit enforces the NFR purity gate (`git log --grep "^feat:"` on the milestone range returns zero).
|
|
||||||
- **Tag base**: `v0.3.x` patch line. P0 ships as `v0.3.0`; execution phases `v0.3.1..v0.3.3`; final phase P4 patch `v0.3.4` IS the milestone release. No separate minor tag (D-008).
|
|
||||||
- **Phases**: 4 execution + 1 final = 5. Phase 0 (this PLAN) is pre-execution.
|
|
||||||
- **Depth**: refinement-only — refactor + test + docs + chore. No new production types, no new enum types, no behavioral changes. All work passes the D-001 refinement-only filter.
|
|
||||||
- **Coverage target**: ≥80% on any modified package; do NOT reduce existing coverage (v0.3 floor is 93.3%).
|
|
||||||
- **New modules**: ZERO. Touched files: `lexicon/lexicon.go` (add helper), `lexicon_meta_test.go` + `lexicon_meta_docs/lexicon_meta_docs_test.go` (refactor to consume helper), `x/hub/types/cross_const_test.go` (NEW test file), `x/council/types/types_test.go` (add intent test), `.ciagent/oy/ARCHITECTURE.md` (docs), `.gitea/workflows/docs-build.yml` (NEW CI).
|
|
||||||
- **Phase ordering** (D-052): P1 lexicon+const hardening → P2 lifecycle divergence docs+guard → P3 docs build CI → P4 final review/audit/ship. P1 is firewall-first (highest-severity regression risk); each phase independently shippable (vertical slices).
|
|
||||||
- **Personas**: backend-engineer (P1, P2 code), lead-developer (P2 docs, P3 CI, P4 review/ship). v0.3 frontend-engineer + docs-writer deactivated.
|
|
||||||
|
|
||||||
### Cross-Phase Dependency Map
|
|
||||||
|
|
||||||
```
|
|
||||||
P1 (lexicon helper, cross-const test) ──► P4 (review)
|
|
||||||
P2 (lifecycle docs + regression guard) ─► P4 (review)
|
|
||||||
P3 (docs build CI) ─► P4 (review)
|
|
||||||
```
|
|
||||||
|
|
||||||
No hard cross-phase blockers (all three execution phases are independent vertical slices). P4 depends on P1+P2+P3 completion only.
|
|
||||||
|
|
||||||
### D-001 Refinement-Only Filter (governs every v0.4 task)
|
|
||||||
|
|
||||||
- **Accept**: refactor, test, docs, chore, quality, architecture (drift fix only).
|
|
||||||
- **Reject**: `add_requirement` + `feat:`-class signal (new enum type, new production type, new capability, new CLI, new distribution channel).
|
|
||||||
- **Pre-seeded NFR REQs** (REQ-029..REQ-032) are exempt — already in REQUIREMENTS.md before PLAN.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Phase P1 — Lexicon + Const Hardening
|
|
||||||
|
|
||||||
- **Slug**: `lexicon-const-hardening`
|
|
||||||
- **Branch**: `oy/phase/01-lexicon-const-hardening`
|
|
||||||
- **REQs covered**: REQ-029 (lexicon shared helper), REQ-030 (cross-const test)
|
|
||||||
- **Tag**: `v0.3.1`
|
|
||||||
- **Goal**: Close the G-014 lexicon-firewall drift risk (shared synthetic-string helper) and the A-304 hub↔bond const drift risk (cross-package equality test). Both are refactor+test; no behavioral change.
|
|
||||||
|
|
||||||
### Wave 1 — Shared helper + cross-const test (parallel; no internal deps)
|
|
||||||
|
|
||||||
| Task ID | REQ | Persona | Files | Deliverable | Must-have verification | Blocked-by |
|
|
||||||
|---|---|---|---|---|---|---|
|
|
||||||
| P1-01-01 | REQ-029 | backend-engineer | `lexicon/lexicon.go` | Add `SyntheticBannedStrings() []string` returning the 10 synthetic strings currently duplicated in both meta-tests. Each string embeds exactly one banned term (from `BannedTerms()`) in a plausible sentence context. Source uses `BannedTerms()` so the lexicon package's own source stays lexicon-clean (fragment-assembled). Add a doc comment cross-referencing G-014 and both consuming meta-tests. | `go build ./lexicon/...` succeeds; `SyntheticBannedStrings()` returns exactly `len(BannedTerms())` strings; each returned string triggers `FindBannedTerm` (self-verifiable in a new test); lexicon package source stays lexicon-clean (the existing `lexicon_meta_test.go` x/ scan does not scan `lexicon/` itself, but the helper must not introduce a banned-term literal). | — |
|
|
||||||
| P1-01-02 | REQ-029 | backend-engineer | `lexicon_meta_test.go`, `lexicon_meta_docs/lexicon_meta_docs_test.go` | Refactor `TestLexiconMetaSelfTestTable` and `TestLexiconMetaDocsSelfTestTable` to consume `lexicon.SyntheticBannedStrings()` instead of building their own `synthetic` slice. Remove the duplicated 10-string table from both. Keep the per-string `FindBannedTerm` assertion loop (detection coverage unchanged). Keep the `len(terms) == 10` assertion from `BannedTerms()`. Add a `len(synthetic) == len(terms)` assertion derived from the helper. | `go test ./lexicon_meta... ./lexicon_meta_docs/...` passes; both tests still fail if detection breaks; the duplicated table is gone (grep for the old literal sentence fragments returns zero matches in meta-test files); coverage on both meta-tests does not decrease. | P1-01-01 |
|
|
||||||
| P1-02-01 | REQ-030 | backend-engineer | `x/hub/types/cross_const_test.go` (NEW) | New test file, package `types` (same package as `x/hub/types`). Test-only import `github.com/oy/openyield/x/bond/types` (G-003 test-exempt). Tests: (1) `TestLendingCouponCapMatchesBondCap` asserts `hub.LendingCouponCapBps == bond.CouponCapBps`; (2) `TestLendingCouponFloorMatchesBondFloor` asserts `hub.LendingCouponFloorBps == bond.CouponFloorBps`; (3) `TestConstsAreMissionLocked800And0` asserts both caps == 800 and both floors == 0 (regression guard for the mission-locked values). Doc comment cross-references A-304 and the G-003 test-exemption. | `go test ./x/hub/types/...` passes; all three tests green at current values (800/0); test fails closed if either const drifts; NO production file in `x/hub/types/` imports `x/bond/types` (G-003 production firewall intact — verified by a grep of non-test `.go` files). | — |
|
|
||||||
|
|
||||||
### Wave 2 — Verification (blocked-by Wave 1)
|
|
||||||
|
|
||||||
| Task ID | REQ | Persona | Files | Deliverable | Must-have verification | Blocked-by |
|
|
||||||
|---|---|---|---|---|---|---|
|
|
||||||
| P1-99-01 | REQ-029, REQ-030 | lead-developer | — (verification only) | Run `go test ./...` (all packages, both lexicon firewalls + cross-const test + all v0.3 baseline tests); run `grep -rn "open a .* here\|make a .* now\|compounding .* rate" lexicon_meta_test.go lexicon_meta_docs/` and confirm zero matches (old duplicated table fully removed); confirm `go.mod` unchanged (zero deps, G-006). | `go test ./...` exits 0; grep returns zero; `go.mod` diff is empty. | P1-01-02, P1-02-01 |
|
|
||||||
|
|
||||||
### P1 Must-Haves (verification gate before SHIP)
|
|
||||||
|
|
||||||
- [ ] `lexicon.SyntheticBannedStrings()` exists and returns 10 strings.
|
|
||||||
- [ ] Both meta-tests consume the helper (no duplicated table).
|
|
||||||
- [ ] `x/hub/types/cross_const_test.go` exists and passes.
|
|
||||||
- [ ] `go test ./...` green (all v0.3 baseline + v0.4 new tests).
|
|
||||||
- [ ] `go.mod` unchanged.
|
|
||||||
- [ ] No `feat:` commits in P1 (D-001 filter).
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Phase P2 — Lifecycle Divergence Documentation + Regression Guard
|
|
||||||
|
|
||||||
- **Slug**: `lifecycle-divergence-docs`
|
|
||||||
- **Branch**: `oy/phase/02-lifecycle-divergence-docs`
|
|
||||||
- **REQs covered**: REQ-031
|
|
||||||
- **Tag**: `v0.3.2`
|
|
||||||
- **Goal**: Document the AUDIT §193 council lifecycle type divergences (P1-1 Proposal/VoteOption absent; P1-2 SignalKind 4-vs-5) in ARCHITECTURE.md and add an intent-assertion test guarding the current `SignalKindCount==4` shape. No enum additions, no locked-const changes (D-050).
|
|
||||||
|
|
||||||
### Wave 1 — Docs + regression guard (parallel; no internal deps)
|
|
||||||
|
|
||||||
| Task ID | REQ | Persona | Files | Deliverable | Must-have verification | Blocked-by |
|
|
||||||
|---|---|---|---|---|---|---|
|
|
||||||
| P2-01-01 | REQ-031 | lead-developer | `.ciagent/oy/ARCHITECTURE.md` | Add a "Council Voice/Council Interface — Lifecycle Type Divergence Decisions (v0.4)" subsection under the v0.4 Architecture section. Document: (a) P1-1 `Proposal`/`ProposalStatus`/`VoteOption` absent — deferred to v0.5+ governance runtime (feat:-class, rejected by D-001); (b) P1-2 `SignalKind` 4 sources (Stash/Standing/Vouch/Capital) vs spec `VoiceSource` 5 sources — the AUDIT rationale (Freeholder is eligibility, Guild is council tier, Capital is committed-capital per vision §9.1) and the decision to KEEP the 4-source shape; (c) the Bearers `ValidateGenesis` no-op is correct per spec (P2, no action). Cross-reference AUDIT §193 P1-1/P1-2/P2. | The ARCHITECTURE.md section exists and names both P1 divergences + the P2 no-action; a reader can find why Proposal/VoteOption are absent and why SignalKind is 4-not-5 without re-reading AUDIT. | — |
|
|
||||||
| P2-02-01 | REQ-031 | backend-engineer | `x/council/types/types_test.go` | Add `TestSignalKindShapeIntentional` — an intent-assertion test that documents and guards the 4-source `SignalKind` shape. Asserts: `SignalKindCount == 4`; `AllSignalKinds()` returns exactly [Stash, Standing, Vouch, Capital]; a doc-comment block above the test explaining the AUDIT §193 P1-2 rationale (why Freeholder and Guild are NOT signal kinds, why Capital IS) so a future agent does not "fix" the divergence by silently changing the locked const. This is a regression GUARD, not a shape change — the existing `TestSignalKindCountLockedConst` already locks the count; this test adds the INTENT documentation. | `go test ./x/council/types/...` passes; the new test fails if `SignalKindCount` changes from 4; the test's doc comment explains the rationale. | — |
|
|
||||||
|
|
||||||
### Wave 2 — Verification (blocked-by Wave 1)
|
|
||||||
|
|
||||||
| Task ID | REQ | Persona | Files | Deliverable | Must-have verification | Blocked-by |
|
|
||||||
|---|---|---|---|---|---|---|
|
|
||||||
| P2-99-01 | REQ-031 | lead-developer | — (verification only) | Run `go test ./...`; confirm ARCHITECTURE.md subsection present; confirm no production `.go` file was modified in P2 (only a `_test.go` file + a `.ciagent/` doc). | `go test ./...` exits 0; `git diff --name-only <p2-base> HEAD -- 'x/**/*.go'` returns only `x/council/types/types_test.go` (no production files). | P2-01-01, P2-02-01 |
|
|
||||||
|
|
||||||
### P2 Must-Haves (verification gate before SHIP)
|
|
||||||
|
|
||||||
- [ ] ARCHITECTURE.md has the Council lifecycle divergence subsection.
|
|
||||||
- [ ] `TestSignalKindShapeIntentional` exists and passes.
|
|
||||||
- [ ] `SignalKindCount` unchanged (still 4).
|
|
||||||
- [ ] No production `.go` files modified in P2 (test + docs only).
|
|
||||||
- [ ] No `feat:` commits in P2 (D-001 filter).
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Phase P3 — Docs Build CI
|
|
||||||
|
|
||||||
- **Slug**: `docs-build-ci`
|
|
||||||
- **Branch**: `oy/phase/03-docs-build-ci`
|
|
||||||
- **REQs covered**: REQ-032
|
|
||||||
- **Tag**: `v0.3.3`
|
|
||||||
- **Goal**: Ship a Gitea Actions workflow that runs the lexicon firewall (`go test ./...`) + builds the docs site (`mkdocs build`) on every push, uploading `site/` as a CI artifact. Full Gitea Pages publishing deferred (no hosting target configured, D-051). The workflow file is `chore` (CI config), not `feat:`.
|
|
||||||
|
|
||||||
### Wave 1 — CI workflow (single task; no internal deps)
|
|
||||||
|
|
||||||
| Task ID | REQ | Persona | Files | Deliverable | Must-have verification | Blocked-by |
|
|
||||||
|---|---|---|---|---|---|---|
|
|
||||||
| P3-01-01 | REQ-032 | lead-developer | `.gitea/workflows/docs-build.yml` (NEW) | Gitea Actions workflow (YAML). Triggers: on push (all branches). Jobs: (1) `go-test` — setup Go 1.22, `go test ./...` (runs lexicon firewall + all x/* tests); (2) `docs-build` — setup Python, `pip install mkdocs mkdocs-material`, `mkdocs build` (produces `site/`), upload `site/` as an artifact. **G-016 binding**: the `docs-build` job MUST depend on `go-test` (serial: `needs: go-test`), so a lexicon-firewall failure blocks the docs build (no false-green docs build from a repo with a lexicon violation). `go.mod` is NOT modified (Python deps are isolated to the `docs-build` job). Doc comment in the YAML references D-046 (forward-reference), D-051 (no Pages publish), and G-016 (firewall-gates-docs-build). | YAML parses; `go test ./...` command matches the local green baseline; `mkdocs build` command matches the README's local build invocation; `docs-build` job has `needs: go-test`; no `go.mod` change. | — |
|
|
||||||
|
|
||||||
### Wave 2 — Verification (blocked-by Wave 1)
|
|
||||||
|
|
||||||
| Task ID | REQ | Persona | Files | Deliverable | Must-have verification | Blocked-by |
|
|
||||||
|---|---|---|---|---|---|---|
|
|
||||||
| P3-99-01 | REQ-032 | lead-developer | — (verification only) | Validate the workflow YAML parses; confirm `go test ./...` still green locally (the workflow does not change Go source); confirm `mkdocs build` succeeds locally (mkdocs installed); confirm `go.mod` unchanged. | YAML parse OK; `go test ./...` exits 0; `mkdocs build` produces `site/`; `go.mod` diff empty. | P3-01-01 |
|
|
||||||
|
|
||||||
### P3 Must-Haves (verification gate before SHIP)
|
|
||||||
|
|
||||||
- [ ] `.gitea/workflows/docs-build.yml` exists and parses.
|
|
||||||
- [ ] Workflow runs `go test ./...` and `mkdocs build`.
|
|
||||||
- [ ] `go.mod` unchanged.
|
|
||||||
- [ ] No `feat:` commits in P3 (D-001 filter; the workflow is `chore`).
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Phase P4 — Final Review + Audit + Milestone Ship
|
|
||||||
|
|
||||||
- **Slug**: `final-review-ship`
|
|
||||||
- **Branch**: `oy/phase/04-final-review-ship`
|
|
||||||
- **REQs covered**: all v0.4 REQs (REQ-029..REQ-032) — final coverage accounting
|
|
||||||
- **Tag**: `v0.3.4` (IS the v0.4 milestone release; D-008)
|
|
||||||
- **Goal**: Multi-persona review across P1..P3, audit (reconstruction test + NFR purity gate), milestone ship (merge to main, tag v0.3.4, release, delete all milestone branches).
|
|
||||||
|
|
||||||
### Wave 1 — Review + Audit (parallel; no internal deps)
|
|
||||||
|
|
||||||
| Task ID | REQ | Persona | Files | Deliverable | Must-have verification | Blocked-by |
|
|
||||||
|---|---|---|---|---|---|---|
|
|
||||||
| P4-01-01 | — | lead-developer (review) | `.ciagent/oy/REVIEW.md` (NEW for v0.4) | Multi-persona code review across P1..P3. Adversarial probes: (1) does the shared helper actually dedupe (grep for old table); (2) does the cross-const test fail closed on drift; (3) does the regression guard lock the 4-signal shape; (4) does the CI workflow YAML parse. Auto-apply P0 fixes; flag P1+ for post-hoc. | REVIEW.md written; P0 issues (if any) fixed in P4; P1+ flagged. | P1, P2, P3 |
|
|
||||||
| P4-02-01 | — | ci-security-auditor (audit) | `.ciagent/oy/AUDIT.md` (v0.4 section) | Audit: (1) reconstruction test (git log ↔ `.ciagent/` files for v0.4); (2) file/branch/commit discipline; (3) **NFR purity gate** — `git log --grep "^feat:" $(git rev-list --tags=v0.3.0..v0.3.4)` returns zero (or the equivalent milestone-range grep); (4) coverage did not decrease on any modified package. | AUDIT.md v0.4 section written; NFR purity gate GREEN (zero feat: commits); reconstruction test passes. | P1, P2, P3 |
|
|
||||||
|
|
||||||
### Wave 2 — Ship (blocked-by Wave 1)
|
|
||||||
|
|
||||||
| Task ID | REQ | Persona | Files | Deliverable | Must-have verification | Blocked-by |
|
|
||||||
|---|---|---|---|---|---|---|
|
|
||||||
| P4-03-01 | REQ-029..REQ-032 | lead-developer (ship) | `.ciagent/oy/REQUIREMENTS.md`, `.ciagent/oy/ROADMAP.md`, `.ciagent/CHECKPOINT.json` | Milestone ship via `ciagent ship 4` (or ShipWorkflow fallback). Merge `oy/phase/04` → `oy/milestone/v0.4-refinement` → `main`. Tag `v0.3.4`. Create release (best-effort via `resolveSecret(GITEA_TOKEN)`). Delete all milestone branches (local + remote). Update REQUIREMENTS.md (v0.4 REQs → Complete) + ROADMAP.md (v0.4 → COMPLETE). Commit `docs(milestone): complete v0.4`. Clear CHECKPOINT. | Tag `v0.3.4` created; milestone branch merged to main; release created (or local-only fallback); all `oy/phase/*` and `oy/milestone/v0.4-refinement` branches deleted; REQUIREMENTS.md + ROADMAP.md updated; checkpoint cleared. | P4-01-01, P4-02-01 |
|
|
||||||
|
|
||||||
### P4 Must-Haves (verification gate before milestone complete)
|
|
||||||
|
|
||||||
- [ ] REVIEW.md v0.4 section written; P0 fixes applied.
|
|
||||||
- [ ] AUDIT.md v0.4 section written; reconstruction test passes.
|
|
||||||
- [ ] **NFR purity gate GREEN**: zero `feat:` commits in the v0.4 milestone range.
|
|
||||||
- [ ] Tag `v0.3.4` created (= milestone release).
|
|
||||||
- [ ] Milestone branch merged to `main`.
|
|
||||||
- [ ] All milestone branches deleted (local + remote).
|
|
||||||
- [ ] REQUIREMENTS.md + ROADMAP.md mark v0.4 COMPLETE.
|
|
||||||
- [ ] CHECKPOINT.json cleared (milestone complete).
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Coverage Targets (D-033) — v0.4
|
|
||||||
|
|
||||||
v0.4 does not lower the v0.3 coverage floor (93.3% on the lowest package). The modified packages must not decrease:
|
|
||||||
- `lexicon` (if coverage applies — it's a helper package): maintain or improve.
|
|
||||||
- `lexicon_meta` / `lexicon_meta_docs`: maintain 100% (test-only packages).
|
|
||||||
- `x/hub/types`: the new `cross_const_test.go` ADDS coverage; do not decrease.
|
|
||||||
- `x/council/types`: the new intent test ADDS coverage; do not decrease.
|
|
||||||
|
|
||||||
No new packages are created, so no new 80% floor is set.
|
|
||||||
|
|
||||||
## Task Count Summary — v0.4
|
|
||||||
|
|
||||||
| Phase | Tasks | Personas | Tag |
|
|
||||||
|-------|-------|----------|-----|
|
|
||||||
| P0 | (pre-execution, this plan) | lead-developer | v0.3.0 |
|
|
||||||
| P1 | 4 (3 code + 1 verify) | backend-engineer, lead-developer | v0.3.1 |
|
|
||||||
| P2 | 3 (2 deliver + 1 verify) | backend-engineer, lead-developer | v0.3.2 |
|
|
||||||
| P3 | 2 (1 deliver + 1 verify) | lead-developer | v0.3.3 |
|
|
||||||
| P4 | 3 (review + audit + ship) | lead-developer, ci-security-auditor | v0.3.4 |
|
|
||||||
| **Total** | **12 tasks** | | |
|
|
||||||
|
|
||||||
## Per-Phase REQ Coverage — v0.4
|
|
||||||
|
|
||||||
| Phase | REQs covered | Status after phase |
|
|
||||||
|-------|--------------|--------------------|
|
|
||||||
| P1 | REQ-029, REQ-030 | Complete (shipped as v0.3.1) |
|
|
||||||
| P2 | REQ-031 | Complete (shipped as v0.3.2) |
|
|
||||||
| P3 | REQ-032 | Complete (shipped as v0.3.3) |
|
|
||||||
| P4 | (all v0.4 REQs final accounting) | Complete (shipped as v0.3.4) |
|
|
||||||
|
|
||||||
## Cross-Phase Blockers (hard) — v0.4
|
|
||||||
|
|
||||||
None. P1, P2, P3 are independent vertical slices. P4 depends on P1+P2+P3 completion only (soft ordering for shippable slices).
|
|
||||||
|
|
||||||
## v0.4 Decisions Applied (D-047..D-053)
|
|
||||||
|
|
||||||
| Decision | Application |
|
|
||||||
|----------|-------------|
|
|
||||||
| D-047 | v0.4 NFR type, tags v0.3.x, zero feat: phases | Milestone summary |
|
|
||||||
| D-048 | REQ-029 lexicon shared helper | P1-01-01, P1-01-02 |
|
|
||||||
| D-049 | REQ-030 cross-const test (G-003 test-exempt import) | P1-02-01 |
|
|
||||||
| D-050 | REQ-031 = document only, no feat: enum additions | P2-01-01, P2-02-01 |
|
|
||||||
| D-051 | REQ-032 docs CI = .gitea/workflows build+artifact, no Pages | P3-01-01 |
|
|
||||||
| D-052 | Phase ordering P1→P2→P3→P4 | Phase ordering |
|
|
||||||
| D-053 | No IDEATE in v0.4 (no --ideate flag) | (no IDEATE stage run) |
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## MVP/UX Check (REQ-MVP-UX-001) — v0.4 NFR Milestone
|
|
||||||
|
|
||||||
> Auto-generated at full autonomy per run.md §MVP/UX CHECK. The three required
|
|
||||||
> sections below name at least one user-facing surface, a happy path written
|
|
||||||
> BEFORE execute, and explicit UX acceptance criteria. v0.4 is an NFR milestone,
|
|
||||||
> so "user-facing surface" is developer-facing (the lexicon firewall, the
|
|
||||||
> cross-const test, the CI workflow) and the docs site build (end-user-facing
|
|
||||||
> via the published docs artifact).
|
|
||||||
|
|
||||||
### User-Facing Surface
|
|
||||||
|
|
||||||
1. **Lexicon firewall (developer-facing)**: `go test ./lexicon_meta... ./lexicon_meta_docs/...` — the green test output is the surface a developer sees on every `go test ./...` run. After REQ-029, both meta-tests consume `lexicon.SyntheticBannedStrings()`; a future banned-term addition updates both firewalls from one place.
|
|
||||||
2. **Cross-const drift test (developer-facing)**: `go test ./x/hub/types/...` — `TestConstsAreMissionLocked800And0` is the surface a developer sees if the hub↔bond mission-locked consts ever drift.
|
|
||||||
3. **Docs build CI (end-user-facing via artifact)**: `.gitea/workflows/docs-build.yml` produces a `site/` artifact on every push. A maintainer downloads the artifact to preview the docs site without running `mkdocs build` locally.
|
|
||||||
4. **ARCHITECTURE.md divergence section (developer-facing)**: a contributor reading `.ciagent/oy/ARCHITECTURE.md` finds the "Council Voice/Council Interface — Lifecycle Type Divergence Decisions (v0.4)" subsection explaining why `SignalKind` is 4-not-5 and why `Proposal`/`VoteOption` are absent.
|
|
||||||
|
|
||||||
### Happy Path
|
|
||||||
|
|
||||||
**Scenario: a contributor adds an 11th banned term to the lexicon firewall.**
|
|
||||||
|
|
||||||
1. The contributor edits `lexicon/lexicon.go` to add a new fragment pair to `fragments` (e.g., a new banned term).
|
|
||||||
2. `lexicon.BannedTerms()` now returns 11 strings.
|
|
||||||
3. `lexicon.SyntheticBannedStrings()` (REQ-029) is the single source — the contributor does NOT need to update two meta-test tables (the v0.3 drift risk).
|
|
||||||
4. `go test ./lexicon_meta... ./lexicon_meta_docs/...` — both meta-tests consume the helper; the `len(terms) == 10` assertion in BOTH meta-tests now fails (expecting 11), alerting the contributor to update the count assertion in both files.
|
|
||||||
5. The contributor updates the `len(terms) == 11` assertion in both meta-tests (the G-014 minimum — both already derive count from `BannedTerms()`).
|
|
||||||
6. Both meta-tests pass; the new banned term is now enforced in BOTH the `x/**/*.go` firewall AND the `docs/**/*.md` firewall from one source change.
|
|
||||||
7. The contributor pushes; `.gitea/workflows/docs-build.yml` (REQ-032) runs `go-test` (green) → `docs-build` (green, G-016 gating) → `site/` artifact uploaded.
|
|
||||||
|
|
||||||
**Scenario: a contributor accidentally changes `x/bond.CouponCapBps` from 800 to 900 without updating `x/hub.LendingCouponCapBps`.**
|
|
||||||
|
|
||||||
1. `go test ./x/hub/types/...` — `TestLendingCouponCapMatchesBondCap` (REQ-030) fails: `hub.LendingCouponCapBps (800) != bond.CouponCapBps (900)`.
|
|
||||||
2. `TestConstsAreMissionLocked800And0` (G-015) ALSO fails: `bond.CouponCapBps (900) != 800`.
|
|
||||||
3. The contributor sees two failures pointing at the same root cause; the mission-locked 8% cap is defended by the cross-const test. The v0.3 A-304 drift risk is closed.
|
|
||||||
|
|
||||||
### UX Acceptance Criteria
|
|
||||||
|
|
||||||
The v0.4 deliverable MUST meet these explicit criteria (verified in P4 audit):
|
|
||||||
|
|
||||||
1. **REQ-029**: `lexicon.SyntheticBannedStrings()` exists; both `lexicon_meta_test.go` and `lexicon_meta_docs/lexicon_meta_docs_test.go` consume it; the duplicated 10-string table is gone (grep for the old literal sentence fragments returns zero matches in meta-test files).
|
|
||||||
2. **REQ-030**: `x/hub/types/cross_const_test.go` exists; `TestLendingCouponCapMatchesBondCap`, `TestLendingCouponFloorMatchesBondFloor`, and `TestConstsAreMissionLocked800And0` all pass (G-015); no production `.go` file in `x/hub/types/` imports `x/bond/types` (G-003 production firewall intact).
|
|
||||||
3. **REQ-031**: ARCHITECTURE.md has the Council lifecycle divergence subsection; `TestSignalKindShapeIntentional` in `x/council/types/types_test.go` passes and documents the 4-signal rationale; `SignalKindCount` unchanged (still 4); no production `.go` files modified in P2.
|
|
||||||
4. **REQ-032**: `.gitea/workflows/docs-build.yml` parses; runs `go test ./...` then `mkdocs build` (G-016: `docs-build` needs `go-test`); `go.mod` unchanged.
|
|
||||||
5. **NFR purity gate**: zero `feat:` commits in the v0.4 milestone range (P4 audit enforces).
|
|
||||||
6. **No regression**: `go test ./...` green; v0.3 coverage floor (93.3%) not reduced on any modified package.
|
|
||||||
+2
-45
@@ -61,36 +61,7 @@ OpenYield (OY) is a durable, anti-greed, jurisdiction-light financial layer —
|
|||||||
- D-009: Rebased history to fix v1.0 → v0.1 in ---ci--- blocks
|
- D-009: Rebased history to fix v1.0 → v0.1 in ---ci--- blocks
|
||||||
|
|
||||||
## Milestone
|
## Milestone
|
||||||
v0.4 — Refinement (active milestone; NFR type; tags run on the v0.3.x patch line)
|
v0.3 — Bearers & Documentation (active milestone; feature type; tags run on the v0.2.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)
|
|
||||||
|
|
||||||
## Prior Milestone
|
|
||||||
v0.3 — Bearers & Documentation (complete; feature type; tags ran on the v0.2.x patch line)
|
|
||||||
|
|
||||||
### v0.3 Scope (Bearers skeleton + Docs site — ROADMAP Phase 3 partial, plus a docs deliverable)
|
### v0.3 Scope (Bearers skeleton + Docs site — ROADMAP Phase 3 partial, plus a docs deliverable)
|
||||||
|
|
||||||
@@ -174,18 +145,4 @@ Auto-decided defaults logged per clarify workflow Step 4 (full autonomy → acce
|
|||||||
|
|
||||||
### Ideation outcome (Phase 0 — IDEATE stage, autonomy=full)
|
### 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).
|
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] |
|
|
||||||
@@ -30,37 +30,19 @@
|
|||||||
|
|
||||||
| ID | Requirement | Vision § | Priority | Status | Phase |
|
| 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-010 | Exit layer (Layer 3) — DEX swaps, bridges, off-mesh services | §7 | Medium | Pending | v0.3/P4 |
|
||||||
| REQ-022 | Bearers expansion: OY-SAT + OY-QR bearer transports | §14 | Medium | Skeleton | v0.3/P4 |
|
| REQ-022 | Bearers expansion: OY-SAT + OY-QR bearer transports | §14 | Medium | Pending | v0.3/P4 |
|
||||||
| REQ-023 | Anchors — first institutional Partner tier | §13 | Medium | Skeleton | v0.3/P4 |
|
| REQ-023 | Anchors — first institutional Partner tier | §13 | Medium | Pending | v0.3/P4 |
|
||||||
| REQ-024 | Hub API — B2B backbone: custody, lending primitive, compliance | §13 | Medium | Skeleton | v0.3/P5 |
|
| REQ-024 | Hub API — B2B backbone: custody, lending primitive, compliance | §13 | Medium | Pending | v0.3/P5 |
|
||||||
| REQ-025 | Services — Care / SIM / Vault / Mail | §13 | Medium | Skeleton | v0.3/P5 |
|
| REQ-025 | Services — Care / SIM / Vault / Mail | §13 | Medium | Pending | v0.3/P5 |
|
||||||
| REQ-026 | Bond market depth — Growth Bonds + secondary market | §17 | Medium | Skeleton | v0.3/P5 |
|
| REQ-026 | Bond market depth — Growth Bonds + secondary market | §17 | Medium | Pending | v0.3/P5 |
|
||||||
| REQ-027 | README.md + docs site in docs/ for nomads and freeholders | (vision §8) | High | Complete | v0.3/P1-P3 |
|
| REQ-027 | README.md + docs site in docs/ for nomads and freeholders | (vision §8) | High | Pending | v0.3/P1-P3 |
|
||||||
| REQ-028 | Extend REQ-012 lexicon firewall to scan docs/ + README.md | §3 | High | Complete | v0.3/P1 |
|
| REQ-028 | Extend REQ-012 lexicon firewall to scan docs/ + README.md | §3 | High | Pending | v0.3/P1 |
|
||||||
|
|
||||||
> REQ-022 through REQ-028 are NEW in v0.3 (ratified during Phase 0 IDEATE as
|
> 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
|
> IDEATE-01..IDEATE-07, then assigned final REQ-IDs). REQ-010 is promoted from
|
||||||
> v0.1 Skeleton to a fuller v0.3 skeleton.
|
> 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 | Pending | 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 | Pending | 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 | Pending | 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 | Pending | 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).
|
|
||||||
|
|
||||||
## IDEATE Traceability (Phase 0 — IDEATE stage, autonomy=full)
|
## IDEATE Traceability (Phase 0 — IDEATE stage, autonomy=full)
|
||||||
|
|
||||||
The IDEATE stage ran the three ideation tiers (mechanical, backend-enriched,
|
The IDEATE stage ran the three ideation tiers (mechanical, backend-enriched,
|
||||||
|
|||||||
+1
-110
@@ -255,113 +255,4 @@ P0 fixes auto-applied: 0
|
|||||||
P1+ flags: 2 (x/council/types — Proposal/VoteOption lifecycle absent; VoiceSource→SignalKind 4-not-5)
|
P1+ flags: 2 (x/council/types — Proposal/VoteOption lifecycle absent; VoiceSource→SignalKind 4-not-5)
|
||||||
P2 nits: 1 (bearers ValidateGenesis no-op — correct per spec)
|
P2 nits: 1 (bearers ValidateGenesis no-op — correct per spec)
|
||||||
Overall: APPROVE WITH P1+ FLAGS (confidence 0.88) — milestone ship not blocked
|
Overall: APPROVE WITH P1+ FLAGS (confidence 0.88) — milestone ship not blocked
|
||||||
```
|
```
|
||||||
---
|
|
||||||
|
|
||||||
## v0.3 Final Review (P6)
|
|
||||||
|
|
||||||
**Reviewer:** Multi-persona final review (correctness, testing, security, performance, maintainability, adversarial)
|
|
||||||
**Scope:** `v0.1.5..HEAD` — all v0.3 milestone phases (P0 pre-exec + P1 docs foundation + P2 nomads docs + P3 freeholders docs + P4 Bearers I + P5 Bearers II)
|
|
||||||
**Branch:** `oy/milestone/v0.3-bearers-docs` (reviewed on `oy/phase/06-final-review-ship`)
|
|
||||||
**Date:** 2026-08-17
|
|
||||||
**Diff stat:** 56 files changed, 6891 insertions(+), 169 deletions(-)
|
|
||||||
|
|
||||||
### Verification commands (all PASS)
|
|
||||||
|
|
||||||
```
|
|
||||||
go build ./... — PASS (zero errors)
|
|
||||||
go test ./... — PASS (all packages green)
|
|
||||||
go test -cover ./x/{bridge,exit,bearers,partner,hub,services,bond}/types/ — PASS (coverage below)
|
|
||||||
go test -run TestLexiconMeta ./lexicon_meta_docs/ . — PASS (docs firewall green)
|
|
||||||
go test -run TestLexiconMeta ./ — PASS (x/ firewall green, repo-root package)
|
|
||||||
```
|
|
||||||
|
|
||||||
### Coverage on new/extended packages
|
|
||||||
|
|
||||||
| Package | Coverage | Threshold (80%) | Verdict |
|
|
||||||
|---|---|---|---|
|
|
||||||
| x/bridge/types | 100.0% | ✓ | PASS |
|
|
||||||
| x/exit/types | 100.0% | ✓ | PASS |
|
|
||||||
| x/bearers/types | 100.0% | ✓ | PASS |
|
|
||||||
| x/partner/types | 100.0% | ✓ | PASS |
|
|
||||||
| x/hub/types | 93.3% | ✓ | PASS |
|
|
||||||
| x/services/types | 100.0% | ✓ | PASS |
|
|
||||||
| x/bond/types | 95.1% | ✓ | PASS |
|
|
||||||
|
|
||||||
All packages exceed the ≥80% coverage requirement. The two sub-100% packages (hub 93.3%, bond 95.1%) have gaps only in defensive genesis error-branches (e.g., `validateComplianceServices` 87.5%, `Clamp`/`ClampLendingCoupon` 80% — the upper-bound and floor branches each exercised by ≥1 test but not every permutation). No must-have is uncovered; G-012 boundary cases (`currentBps==cap`, `currentBps>cap`, underflow guard) are all explicitly tested (`TestClampGrowthCurrentAtCapReturnsZero`, `TestClampGrowthCurrentAboveCapReturnsZero`, `TestClampGrowthInvariantPostGrowthLeCap`).
|
|
||||||
|
|
||||||
### Per-axis verdicts
|
|
||||||
|
|
||||||
#### 1. Correctness (backend-engineer) — PASS (0.92)
|
|
||||||
|
|
||||||
Locked-const invariants all enforced and tested:
|
|
||||||
- `BridgeStatusCount = 4` (x/bridge/types/types.go:18) — `AllBridgeStatuses()` returns 4 in REQ-015 order. ✓
|
|
||||||
- `ExitStatusCount = 5` (x/exit/types/types.go:18) — `AllExitStatuses()` returns 5 in vision §7 order. ✓
|
|
||||||
- `HubServiceCount = 3` (x/hub/types/types.go:42) — `AllHubServices()` returns 3 (Custody/LendingPrimitive/Compliance). ✓
|
|
||||||
- `ServiceKindCount = 4` (x/services/types/types.go:37) — locked count asserted. ✓
|
|
||||||
- `OrderSideCount = 2` (x/bond/types/types.go:171) — Buy/Sell. ✓
|
|
||||||
- `OrderStatusCount = 3` (x/bond/types/types.go:174) — Open/Filled/Cancelled. ✓
|
|
||||||
- `PartnerTierCount = 4` (x/partner/types/types.go:18) — regression intact. ✓
|
|
||||||
- `BondStatusCount = 5` (x/bond/types/types.go:31) — regression intact. ✓
|
|
||||||
- `CouponCapBps = 800` / `CouponFloorBps = 0` (x/bond/types/types.go:21,26) — D-028 LOCKED, regression firewall in types_test.go asserts both values. ✓
|
|
||||||
|
|
||||||
**G-012 (ClampGrowth underflow guard) — CORRECT.** The guard at x/bond/types/types.go:239 (`if currentBps >= CouponCapBps { return 0 }`) runs BEFORE the `CouponCapBps - currentBps` subtraction (line 243), so the uint32 underflow path is unreachable. Five boundary/invariant tests cover: currentBps==0 (full room), currentBps==cap (return 0), currentBps>cap (return 0, NOT wrapped huge), growth>room (clamp to room), growth<room (unchanged), and a meta-assert `current + ClampGrowth(current, growth) <= max(current, cap)` across a fuzz table.
|
|
||||||
|
|
||||||
The hub `LendingCouponCapBps`/`LendingCouponFloorBps` LOCAL consts (A-304) mirror x/bond's LOCKED values (800/0) without importing x/bond (G-003 preserved). Genesis-side clamp enforcement present in both `validateLendingPrimitives` (hub) and `ValidateBonds`/`ValidateGrowthBonds` (bond).
|
|
||||||
|
|
||||||
#### 2. Testing (backend-engineer) — PASS (0.90)
|
|
||||||
|
|
||||||
All new packages ≥93.3% (above 80% threshold). Per-package lexicon assertions present in every new package's types_test.go (TestLexiconNoBannedTermsIn<Pkg>Package + TestLexiconNoBannedTermsIn<Pkg>TestFile) — confirmed in x/bridge, x/exit, x/bearers, x/partner, x/hub, x/services, x/bond. G-012 boundary cases (currentBps==cap, currentBps>cap) explicitly tested. Locked-const regression tests present (Test<Const>LockedConst pattern) for every locked const enumerated above.
|
|
||||||
|
|
||||||
#### 3. Security (security) — PASS (0.93)
|
|
||||||
|
|
||||||
Both lexicon firewalls green:
|
|
||||||
- x/ firewall (`lexicon_meta_test.go`, package `lexicon_meta`): `go test -run TestLexiconMeta ./` PASS.
|
|
||||||
- docs/ firewall (`lexicon_meta_docs/lexicon_meta_docs_test.go`, package `lexicon_meta_docs`): `go test -run TestLexiconMeta ./lexicon_meta_docs/` PASS. Scans README.md + docs/**/*.md.
|
|
||||||
|
|
||||||
Adversarial verification: confirmed `lexicon.FindBannedTerm` catches all 10 banned terms (bank, deposit, interest, yield, currency, dollar, euro, account, savings, depositor) via direct injection test. The docs firewall self-test table (G-009 for docs), walk-coverage test (G-013), and self-test drift assertion (G-014) all present and passing — the firewall provably CATCHES banned-term regressions rather than silently scanning nothing.
|
|
||||||
|
|
||||||
**G-003 (by-ID-string, no struct imports between x/*) — INTACT.** `grep -rn "oy/openyield/x/"` across all new package non-test .go files returns ZERO struct imports. The only cross-package import in a test file is `x/bearers/types/types_test.go` importing `x/processing/types` (a test-only import for a stub reference; G-003 governs production struct imports, not test imports). All cross-module references in production types use ID-string fields (issuer-stand-id, reach-id, holder-reach-id, custody-provider-id, anchor-id, bond-id, operator-partner-id, etc.) with explicit G-003 doc-comments.
|
|
||||||
|
|
||||||
#### 4. Performance (backend-engineer) — PASS (0.95)
|
|
||||||
|
|
||||||
`go.mod` UNCHANGED since v0.1.5 (`git diff v0.1.5..HEAD -- go.mod go.sum` is empty) — zero external deps preserved (G-006). The mkdocs build deps (mkdocs + mkdocs-material) are Python-only and documented as non-Go (mkdocs.yml header comment). Skeleton stubs use O(1) maps for registry lookups (x/partner Keeper); no N+1 patterns in the stub code. The genesis validators iterate slices once (O(n) per set) with map-backed uniqueness checks — appropriate for skeleton scale.
|
|
||||||
|
|
||||||
#### 5. Maintainability (lead-developer) — PASS (0.91)
|
|
||||||
|
|
||||||
**Pattern consistency:** All new modules follow the v0.1/v0.2 file structure (types.go + types_test.go, genesis.go where genesis validation exists). The Params/GenesisState/DefaultGenesisState/ValidateGenesis pattern is uniform across x/bridge, x/exit, x/bearers, x/partner, x/hub, x/services, x/bond. G-008 split (data-engineer's genesis.go schema helpers composed by ValidateGenesis in types.go) is present in x/bond and x/hub. The new modules use the same ModuleName/StoreKey/RouterKey/QuerierRoute const block and the same JSON/YAML struct-tag convention as v0.1/v0.2 modules.
|
|
||||||
|
|
||||||
**Docs cross-reference (G-011):** `mkdocs.yml` nav lists ALL 26 pages (1 Home + 8 Nomads + 8 Freeholders + 7 Shared + 2 Reference = 26), matching the 26 .md files under docs/. `docs/reference/components.md` cross-references the new modules (10 mentions of x/* packages). All docs pages are lexicon-clean (firewall green).
|
|
||||||
|
|
||||||
**.ciagent/oy/* updates:** PROJECT, ROADMAP, REQUIREMENTS, ARCHITECTURE, RESEARCH, PERSONAS, PLANS, GRILL all updated to reflect v0.3 scope (Bearers & Documentation, REQ-024..REQ-028, D-037..D-046, A-304..A-313, G-011..G-014).
|
|
||||||
|
|
||||||
#### 6. Adversarial (adversarial) — PASS (0.88)
|
|
||||||
|
|
||||||
Adversarial probes attempted and their outcomes:
|
|
||||||
1. **Banned term slipped into docs** — the firewall self-test table (`TestLexiconMetaDocsSelfTestTable`, G-009 for docs) injects synthetic banned-term strings and asserts FindBannedTerm detects each; the walk-coverage test (`TestLexiconMetaDocsWalkCoverage`, G-013) injects a real .md fixture under docs/.lexicon_fixture/ and asserts the walk FINDS it. Catches the "silently scans nothing and reports green" failure mode. ✓
|
|
||||||
2. **Locked-const regression** — every locked const has a `Test<Const>LockedConst` regression test asserting the exact value AND the All<Enum>() entry count/names. A regression (e.g., BridgeStatusCount→5) fails the test. ✓
|
|
||||||
3. **Struct import breaks G-003** — no production .go file in the new packages imports another x/* package; verified by grep. ✓
|
|
||||||
4. **ClampGrowth underflow** — the guard returns 0 BEFORE the subtraction; the underflow path is unreachable; tested with currentBps>cap (e.g., 801) asserting return 0 (NOT 4294967295). ✓
|
|
||||||
5. **Hub A-304 drift from x/bond D-028** — the LOCAL consts are documented as cross-referenced (comment "also 800") and a regression test asserts LendingCouponCapBps==800. A future x/bond cap change without a matching hub change is flagged by the cross-doc comment (not a test — appropriate since they are LOCAL to hub). Note P2 below.
|
|
||||||
|
|
||||||
### P0 fixes auto-applied
|
|
||||||
|
|
||||||
**0.** No P0 (critical) issues found. The milestone ships clean.
|
|
||||||
|
|
||||||
### P1+ flags (post-hoc review — do NOT block ship)
|
|
||||||
|
|
||||||
**1.** [P2 nit, maintainability] x/hub `LendingCouponCapBps`/`LendingCouponFloorBps` (A-304) are LOCAL consts cross-documented to x/bond's D-028 consts (both 800/0) but there is no automated cross-check that they stay in lockstep. If a future mission-locked change to x/bond.CouponCapBps does not update the hub LOCAL const, the two packages silently drift. The cross-doc comment in types.go:46-50 flags this for human review, but a shared-const test (e.g., asserting `LendingCouponCapBps == x/bond.CouponCapBps` — though that would require a test-only import, acceptable per G-003 test exemption) would be more robust. Recommend post-hoc: add a cross-package const-equality test OR document the manual-sync requirement in ARCHITECTURE.md. Not a ship blocker — both are currently 800/0.
|
|
||||||
|
|
||||||
**2.** [P2 nit, testing] x/hub coverage 93.3% and x/bond coverage 95.1% leave defensive error-branches in `ClampLendingCoupon` (80%), `Clamp` (80%), `validateComplianceServices` (87.5%), `ValidateGrowthBonds` (85.7%) partially exercised. All must-have paths are tested; the uncovered lines are error-return branches for malformed genesis inputs. Recommend post-hoc: add 2-3 negative-case genesis tests per package to close the gaps to 100%. Not a ship blocker (both above the 80% threshold).
|
|
||||||
|
|
||||||
**3.** [P2 nit, docs] `docs/reference/architecture.md` has 0 cross-references to x/* packages (vs `docs/reference/components.md` which has 10). The architecture page is conceptual; the components page is the cross-ref hub. Acceptable as-is, but post-hoc adding 1-2 module cross-refs to architecture.md would improve discoverability. Not a ship blocker.
|
|
||||||
|
|
||||||
### Overall verdict
|
|
||||||
|
|
||||||
**SHIP.**
|
|
||||||
|
|
||||||
All verification commands pass. All locked-const invariants enforced and tested. Both lexicon firewalls green (x/ and docs/). G-003 (by-ID-string, no struct imports) intact across all new packages. G-012 (ClampGrowth underflow guard) correctly implemented with explicit boundary tests. Zero external deps (go.mod unchanged). Coverage ≥93.3% on all new/extended packages (above 80% threshold). mkdocs.yml nav complete (26/26 pages, G-011). No P0 issues. Three P2 nits flagged for post-hoc review (none blocking).
|
|
||||||
|
|
||||||
**P0 fixes auto-applied: 0**
|
|
||||||
**P1+ findings: 0 P1, 3 P2 (all nits, post-hoc, non-blocking)**
|
|
||||||
**Confidence in overall verdict: 0.91**
|
|
||||||
+1
-10
@@ -23,18 +23,9 @@
|
|||||||
- [x] P5: Final Review + Ship → v0.1.5 (milestone release)
|
- [x] P5: Final Review + Ship → v0.1.5 (milestone release)
|
||||||
- Status: COMPLETE (skeleton + tests layer; released as v0.1.5)
|
- Status: COMPLETE (skeleton + tests layer; released as v0.1.5)
|
||||||
|
|
||||||
## Milestone v0.3 — Bearers & Documentation (COMPLETE; feature type; tags v0.2.x)
|
## Milestone v0.3 — Bearers & Documentation (ACTIVE; feature type; tags v0.2.x)
|
||||||
Target: Bearers skeleton (ROADMAP Phase 3 subset) + docs site for nomads and freeholders.
|
Target: Bearers skeleton (ROADMAP Phase 3 subset) + docs site for nomads and freeholders.
|
||||||
|
|
||||||
- [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
|
|
||||||
|
|
||||||
> v0.3 bundles two work-streams under one feature milestone: (A) Bearers
|
> 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
|
> 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.
|
> organized by audience, with the REQ-012 lexicon firewall extended to docs.
|
||||||
|
|||||||
@@ -1,47 +0,0 @@
|
|||||||
# 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.
|
|
||||||
@@ -1,46 +0,0 @@
|
|||||||
# 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.
|
|
||||||
@@ -1,48 +0,0 @@
|
|||||||
# 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.
|
|
||||||
@@ -1,39 +0,0 @@
|
|||||||
# 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.
|
|
||||||
@@ -1,42 +0,0 @@
|
|||||||
# 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.
|
|
||||||
@@ -1,45 +0,0 @@
|
|||||||
# 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.
|
|
||||||
@@ -1,49 +0,0 @@
|
|||||||
# 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.
|
|
||||||
@@ -1,47 +0,0 @@
|
|||||||
# 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.
|
|
||||||
@@ -1,51 +0,0 @@
|
|||||||
# 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.
|
|
||||||
@@ -1,62 +0,0 @@
|
|||||||
# 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.
|
|
||||||
@@ -93,73 +93,6 @@ type BeaconFrame struct {
|
|||||||
TTL int64 `json:"ttl" yaml:"ttl"`
|
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).
|
|
||||||
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"`
|
|
||||||
}
|
|
||||||
|
|
||||||
// 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{}
|
type Params struct{}
|
||||||
|
|
||||||
func DefaultParams() Params { return Params{} }
|
func DefaultParams() Params { return Params{} }
|
||||||
|
|||||||
@@ -261,214 +261,6 @@ 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
|
// packageDir resolves a Go import path to its filesystem directory by
|
||||||
// walking up from this test file (v0.2 skeleton has zero external deps).
|
// walking up from this test file (v0.2 skeleton has zero external deps).
|
||||||
func packageDir(t *testing.T, importPath string) string {
|
func packageDir(t *testing.T, importPath string) string {
|
||||||
|
|||||||
@@ -52,93 +52,3 @@ func knownBondStatus(s BondStatus) bool {
|
|||||||
}
|
}
|
||||||
return false
|
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
|
|
||||||
}
|
|
||||||
|
|||||||
+11
-167
@@ -113,33 +113,26 @@ type Params struct{}
|
|||||||
|
|
||||||
func DefaultParams() Params { return Params{} }
|
func DefaultParams() Params { return Params{} }
|
||||||
|
|
||||||
// GenesisState defines the bond module genesis state (REQ-021, REQ-026).
|
// GenesisState defines the bond module genesis state (REQ-021). Bonds is the
|
||||||
// Bonds is the top-level set of issued bonds (v0.2). GrowthBonds (v0.3) and
|
// top-level set of issued bonds. ValidateGenesis enforces bond-id uniqueness
|
||||||
// Orders (v0.3) extend the genesis with growth bonds and secondary-market
|
// and the coupon clamp at genesis load (the data-engineer's genesis.go holds
|
||||||
// orders. ValidateGenesis enforces bond-id / growth-bond-id / order-id
|
// the schema helpers per G-008).
|
||||||
// uniqueness and the coupon clamp at genesis load (the data-engineer's
|
|
||||||
// genesis.go holds the schema helpers per G-008).
|
|
||||||
type GenesisState struct {
|
type GenesisState struct {
|
||||||
Params Params `json:"params" yaml:"params"`
|
Params Params `json:"params" yaml:"params"`
|
||||||
Bonds []Bond `json:"bonds" yaml:"bonds"`
|
Bonds []Bond `json:"bonds" yaml:"bonds"`
|
||||||
GrowthBonds []GrowthBond `json:"growth_bonds" yaml:"growth_bonds"`
|
|
||||||
Orders []SecondaryOrder `json:"orders" yaml:"orders"`
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func DefaultGenesisState() *GenesisState {
|
func DefaultGenesisState() *GenesisState {
|
||||||
return &GenesisState{
|
return &GenesisState{
|
||||||
Params: DefaultParams(),
|
Params: DefaultParams(),
|
||||||
Bonds: []Bond{},
|
Bonds: []Bond{},
|
||||||
GrowthBonds: []GrowthBond{},
|
|
||||||
Orders: []SecondaryOrder{},
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// ValidateGenesis performs ID-uniqueness checks (A-212 upgrade from v0.1
|
// ValidateGenesis performs ID-uniqueness checks (A-212 upgrade from v0.1
|
||||||
// no-op): rejects duplicate bond-ids / growth-bond-ids / order-ids, and runs
|
// no-op): rejects duplicate bond-ids, and runs the coupon clamp at genesis
|
||||||
// the coupon clamp at genesis load (each genesis bond's coupon-bps must be
|
// load (each genesis bond's coupon-bps must be within [floor, cap]). Delegates
|
||||||
// within [floor, cap]). Delegates to the data-engineer's genesis.go helpers
|
// to the data-engineer's genesis.go helpers (G-008).
|
||||||
// (G-008).
|
|
||||||
func ValidateGenesis(bz json.RawMessage) error {
|
func ValidateGenesis(bz json.RawMessage) error {
|
||||||
var gs GenesisState
|
var gs GenesisState
|
||||||
if err := json.Unmarshal(bz, &gs); err != nil {
|
if err := json.Unmarshal(bz, &gs); err != nil {
|
||||||
@@ -148,154 +141,5 @@ func ValidateGenesis(bz json.RawMessage) error {
|
|||||||
if err := ValidateBonds(gs.Bonds); err != nil {
|
if err := ValidateBonds(gs.Bonds); err != nil {
|
||||||
return fmt.Errorf("bond: %w", err)
|
return fmt.Errorf("bond: %w", err)
|
||||||
}
|
}
|
||||||
if err := ValidateGrowthBonds(gs.GrowthBonds); err != nil {
|
|
||||||
return fmt.Errorf("bond: %w", err)
|
|
||||||
}
|
|
||||||
if err := ValidateOrders(gs.Orders); err != nil {
|
|
||||||
return fmt.Errorf("bond: %w", err)
|
|
||||||
}
|
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// --- v0.3 extension: GrowthBond + secondary market (REQ-026, D-041, G-012) -------
|
|
||||||
//
|
|
||||||
// The v0.3 bond extension adds GrowthBond (a bond whose coupon grows with
|
|
||||||
// protocol health, vision §17) and secondary-market order types. The 8%/0%
|
|
||||||
// consts (D-028) are UNCHANGED — the regression firewall in types_test.go
|
|
||||||
// asserts CouponCapBps==800 and CouponFloorBps==0 are still the v0.2 values.
|
|
||||||
// Full secondary-market matching is deferred to v0.4.
|
|
||||||
|
|
||||||
// OrderSideCount is the locked count of OrderSide enum values (vision §17
|
|
||||||
// secondary market, A-313). A regression firewall: adding/removing/renaming
|
|
||||||
// an order side breaks this const's test.
|
|
||||||
const OrderSideCount = 2
|
|
||||||
|
|
||||||
// OrderStatusCount is the locked count of OrderStatus enum values (A-313).
|
|
||||||
const OrderStatusCount = 3
|
|
||||||
|
|
||||||
// OrderSide enumerates the two sides of a secondary-market order (vision §17,
|
|
||||||
// REQ-026, A-313): Buy (a bid for a bond), Sell (an ask for a bond).
|
|
||||||
type OrderSide string
|
|
||||||
|
|
||||||
const (
|
|
||||||
OrderBuy OrderSide = "Buy" // bid
|
|
||||||
OrderSell OrderSide = "Sell" // ask
|
|
||||||
)
|
|
||||||
|
|
||||||
// AllOrderSides returns both OrderSide values in vision-§17 order. Locked-
|
|
||||||
// const test asserts exactly 2 entries with these names (A-313).
|
|
||||||
func AllOrderSides() []OrderSide {
|
|
||||||
return []OrderSide{
|
|
||||||
OrderBuy,
|
|
||||||
OrderSell,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// OrderStatus enumerates the three lifecycle states of a secondary-market
|
|
||||||
// order (vision §17, REQ-026, A-313): Open (resting on the book), Filled
|
|
||||||
// (matched and settled), Cancelled (removed by the holder or expired). The
|
|
||||||
// matching engine is v0.4; v0.3 types the order shape only.
|
|
||||||
type OrderStatus string
|
|
||||||
|
|
||||||
const (
|
|
||||||
OrderOpen OrderStatus = "Open" // resting on the book
|
|
||||||
OrderFilled OrderStatus = "Filled" // matched and settled
|
|
||||||
OrderCancelled OrderStatus = "Cancelled" // removed by the holder or expired
|
|
||||||
)
|
|
||||||
|
|
||||||
// AllOrderStatuses returns all three OrderStatus values in A-313 order.
|
|
||||||
// Locked-const test asserts exactly 3 entries with these names.
|
|
||||||
func AllOrderStatuses() []OrderStatus {
|
|
||||||
return []OrderStatus{
|
|
||||||
OrderOpen,
|
|
||||||
OrderFilled,
|
|
||||||
OrderCancelled,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// ClampGrowth returns the additional bps a GrowthBond's coupon can grow so
|
|
||||||
// that the post-growth coupon (currentBps + additional) never exceeds
|
|
||||||
// CouponCapBps (D-028, A-306, G-012). The "post-growth coupon <= cap"
|
|
||||||
// invariant holds UNCONDITIONALLY.
|
|
||||||
//
|
|
||||||
// G-012 BINDING: ClampGrowth MUST guard currentBps > CouponCapBps BEFORE
|
|
||||||
// computing cap - current. The naive `min(cap - current, growth)` underflows
|
|
||||||
// uint32 when current > cap (cap - current wraps to a huge value, then min
|
|
||||||
// picks growthBps — the invariant is violated). This implementation guards
|
|
||||||
// explicitly:
|
|
||||||
// - If currentBps >= CouponCapBps: return 0 (no room to grow; the cap is
|
|
||||||
// already reached or exceeded — the post-growth coupon cannot grow
|
|
||||||
// without breaching the cap).
|
|
||||||
// - Otherwise: return min(CouponCapBps - currentBps, growthBps) (the room-
|
|
||||||
// to-cap, clamped by the requested growth).
|
|
||||||
//
|
|
||||||
// The two G-012-mandated test cases are: currentBps == CouponCapBps (return 0,
|
|
||||||
// the at-cap boundary) and currentBps > CouponCapBps (return 0, the guard
|
|
||||||
// against uint32 underflow — NOT a wrapped huge value).
|
|
||||||
func ClampGrowth(currentBps, growthBps uint32) uint32 {
|
|
||||||
// G-012 guard: at-or-above cap means no room to grow. This MUST be checked
|
|
||||||
// before the cap - current subtraction to avoid uint32 underflow when
|
|
||||||
// currentBps > cap.
|
|
||||||
if currentBps >= CouponCapBps {
|
|
||||||
return 0
|
|
||||||
}
|
|
||||||
// currentBps < cap is guaranteed here; cap - current does not underflow.
|
|
||||||
room := CouponCapBps - currentBps
|
|
||||||
if growthBps < room {
|
|
||||||
return growthBps
|
|
||||||
}
|
|
||||||
return room
|
|
||||||
}
|
|
||||||
|
|
||||||
// GrowthBond is a bond whose coupon grows with protocol health (vision §17,
|
|
||||||
// REQ-026, D-041, A-306). It embeds the v0.2 Bond (anonymous field) so it
|
|
||||||
// carries all Bond fields (bond-id, issuer-stand-id, principal-grain,
|
|
||||||
// coupon-bps, term-days, issued-at, maturity, status) PLUS a GrowthRateBps
|
|
||||||
// field (the per-period growth rate of the coupon, in bps). The growth rate
|
|
||||||
// is clamped at issuance so that the post-growth coupon never exceeds
|
|
||||||
// CouponCapBps (800 bps) — see IssueGrowth, which clamps couponBps via Clamp
|
|
||||||
// and growthRateBps via ClampGrowth (with currentBps=couponBps).
|
|
||||||
//
|
|
||||||
// The 8%/0% consts (D-028) apply to GrowthBonds too: the growth coupon is
|
|
||||||
// clamped to [0, 800] bps at any point. GrowthBond is in the same package as
|
|
||||||
// Bond (no G-003 concern for the Clamp/ClampGrowth reuse).
|
|
||||||
type GrowthBond struct {
|
|
||||||
Bond // anonymous embed — carries all v0.2 Bond fields
|
|
||||||
GrowthRateBps uint32 `json:"growth_rate_bps" yaml:"growth_rate_bps"`
|
|
||||||
}
|
|
||||||
|
|
||||||
// IssueGrowth is the GrowthBond issuance stub (REQ-026, D-041). It constructs a
|
|
||||||
// GrowthBond with the coupon clamped to [CouponFloorBps, CouponCapBps] via
|
|
||||||
// Clamp, and the growth-rate clamped so that coupon + growth never exceeds
|
|
||||||
// CouponCapBps via ClampGrowth (with currentBps=couponBps). The returned
|
|
||||||
// GrowthBond has status BondIssued (inherited from Issue's Bond construction).
|
|
||||||
// The stub does not persist or enforce referential integrity of issuer-stand-
|
|
||||||
// id (a v0.4 keeper concern); it only enforces the coupon + growth clamp
|
|
||||||
// invariants at construction time.
|
|
||||||
func IssueGrowth(bondID, issuerStandID string, principalGrain int64, couponBps, growthRateBps uint32, termDays uint32, issuedAt, maturity int64) GrowthBond {
|
|
||||||
clampedCoupon := Clamp(couponBps)
|
|
||||||
clampedGrowth := ClampGrowth(clampedCoupon, growthRateBps)
|
|
||||||
return GrowthBond{
|
|
||||||
Bond: Issue(bondID, issuerStandID, principalGrain, clampedCoupon, termDays, issuedAt, maturity),
|
|
||||||
GrowthRateBps: clampedGrowth,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// SecondaryOrder is a secondary-market order on an issued bond (vision §17,
|
|
||||||
// REQ-026, D-041, A-313). order-id is the unique identifier. bond-id references
|
|
||||||
// a Bond (by-ID-string ref to a Bond — same package, so this is an in-package
|
|
||||||
// ID-string ref, not a cross-module G-003 concern). side picks OrderSide
|
|
||||||
// (Buy/Sell). price-grain is the order price in Grain (fraction of principal,
|
|
||||||
// expressed in Grain for fixed-point precision). holder-reach-id references
|
|
||||||
// an x/identity Reach by ID-string (G-003 — use "holder-reach-id" not the
|
|
||||||
// banned Holder-identity term). status is the OrderStatus. created-at is the
|
|
||||||
// unix timestamp.
|
|
||||||
type SecondaryOrder struct {
|
|
||||||
OrderID string `json:"order_id" yaml:"order_id"`
|
|
||||||
BondID string `json:"bond_id" yaml:"bond_id"`
|
|
||||||
Side OrderSide `json:"side" yaml:"side"`
|
|
||||||
PriceGrain int64 `json:"price_grain" yaml:"price_grain"`
|
|
||||||
HolderReachID string `json:"holder_reach_id" yaml:"holder_reach_id"`
|
|
||||||
Status OrderStatus `json:"status" yaml:"status"`
|
|
||||||
CreatedAt int64 `json:"created_at" yaml:"created_at"`
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -416,539 +416,6 @@ func TestLexiconNoBannedTermsInBondTestFile(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// --- v0.3 extension: ClampGrowth (G-012 BINDING) ---------------------------------
|
|
||||||
// ClampGrowth is the G-012 binding decision: it MUST guard currentBps >
|
|
||||||
// CouponCapBps before computing cap - current, otherwise the uint32
|
|
||||||
// subtraction underflows (cap - current wraps to a huge value, then min picks
|
|
||||||
// growthBps — the post-growth coupon invariant is violated). These tests are
|
|
||||||
// written FIRST (TDD) to confirm the guard works before the function existed;
|
|
||||||
// they are the highest-severity v0.3 bond firewall.
|
|
||||||
//
|
|
||||||
// The five G-012-mandated test cases:
|
|
||||||
// 1. currentBps == 0 (full growth room)
|
|
||||||
// 2. currentBps == CouponCapBps (no room, return 0 — the at-cap boundary)
|
|
||||||
// 3. currentBps > CouponCapBps (the underflow GUARD — return 0, NOT a wrapped
|
|
||||||
// huge value)
|
|
||||||
// 4. growthBps larger than room (clamp to room)
|
|
||||||
// 5. growthBps smaller than room (return growthBps)
|
|
||||||
|
|
||||||
// TestClampGrowthCurrentZeroFullRoom asserts case 1: currentBps == 0 leaves
|
|
||||||
// the full room to the cap; the growth is clamped to min(cap, growth).
|
|
||||||
func TestClampGrowthCurrentZeroFullRoom(t *testing.T) {
|
|
||||||
// growth < cap (room) -> return growth
|
|
||||||
if got := btypes.ClampGrowth(0, 500); got != 500 {
|
|
||||||
t.Errorf("ClampGrowth(0, 500) = %d, expected 500 (full room, growth < cap)", got)
|
|
||||||
}
|
|
||||||
// growth == cap (room) -> return cap (room)
|
|
||||||
if got := btypes.ClampGrowth(0, btypes.CouponCapBps); got != btypes.CouponCapBps {
|
|
||||||
t.Errorf("ClampGrowth(0, cap) = %d, expected cap %d (full room, growth == cap)", got, btypes.CouponCapBps)
|
|
||||||
}
|
|
||||||
// growth > cap (room) -> return cap (room)
|
|
||||||
if got := btypes.ClampGrowth(0, 1000); got != btypes.CouponCapBps {
|
|
||||||
t.Errorf("ClampGrowth(0, 1000) = %d, expected cap %d (full room, growth > cap clamps to cap)", got, btypes.CouponCapBps)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// TestClampGrowthCurrentAtCapReturnsZero asserts case 2: currentBps ==
|
|
||||||
// CouponCapBps (the at-cap boundary). There is no room to grow; return 0.
|
|
||||||
// This is the G-012-mandated at-cap test.
|
|
||||||
func TestClampGrowthCurrentAtCapReturnsZero(t *testing.T) {
|
|
||||||
got := btypes.ClampGrowth(btypes.CouponCapBps, 100)
|
|
||||||
if got != 0 {
|
|
||||||
t.Errorf("ClampGrowth(cap, 100) = %d, expected 0 (at-cap boundary — no room to grow, G-012)", got)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// TestClampGrowthCurrentAboveCapReturnsZero asserts case 3: currentBps >
|
|
||||||
// CouponCapBps (the uint32 underflow GUARD). The naive min(cap-current,
|
|
||||||
// growth) would underflow uint32 (cap-current wraps to a huge value, then min
|
|
||||||
// picks growth — invariant violated). ClampGrowth MUST return 0, NOT a
|
|
||||||
// wrapped huge value. This is the G-012-mandated above-cap test.
|
|
||||||
func TestClampGrowthCurrentAboveCapReturnsZero(t *testing.T) {
|
|
||||||
cases := []struct {
|
|
||||||
current uint32
|
|
||||||
growth uint32
|
|
||||||
}{
|
|
||||||
{uint32(btypes.CouponCapBps) + 1, 100},
|
|
||||||
{uint32(btypes.CouponCapBps) + 100, 500},
|
|
||||||
{uint32(btypes.CouponCapBps) + 1000, 50},
|
|
||||||
{5000, 100},
|
|
||||||
{100_000, 1},
|
|
||||||
}
|
|
||||||
for _, c := range cases {
|
|
||||||
got := btypes.ClampGrowth(c.current, c.growth)
|
|
||||||
if got != 0 {
|
|
||||||
t.Errorf("ClampGrowth(%d, %d) = %d, expected 0 (above-cap GUARD — uint32 underflow must NOT happen, G-012)",
|
|
||||||
c.current, c.growth, got)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// TestClampGrowthGrowthLargerThanRoomClampsToRoom asserts case 4: growthBps
|
|
||||||
// larger than the room-to-cap is clamped to the room.
|
|
||||||
func TestClampGrowthGrowthLargerThanRoomClampsToRoom(t *testing.T) {
|
|
||||||
// current=500, cap=800, room=300. growth=400 > room -> return 300.
|
|
||||||
got := btypes.ClampGrowth(500, 400)
|
|
||||||
if got != 300 {
|
|
||||||
t.Errorf("ClampGrowth(500, 400) = %d, expected 300 (growth larger than room clamps to room)", got)
|
|
||||||
}
|
|
||||||
// current=799, cap=800, room=1. growth=50 > room -> return 1.
|
|
||||||
got = btypes.ClampGrowth(799, 50)
|
|
||||||
if got != 1 {
|
|
||||||
t.Errorf("ClampGrowth(799, 50) = %d, expected 1 (room=1, growth clamps to room)", got)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// TestClampGrowthGrowthSmallerThanRoomReturnsGrowth asserts case 5: growthBps
|
|
||||||
// smaller than the room-to-cap is returned unchanged.
|
|
||||||
func TestClampGrowthGrowthSmallerThanRoomReturnsGrowth(t *testing.T) {
|
|
||||||
// current=500, cap=800, room=300. growth=200 < room -> return 200.
|
|
||||||
got := btypes.ClampGrowth(500, 200)
|
|
||||||
if got != 200 {
|
|
||||||
t.Errorf("ClampGrowth(500, 200) = %d, expected 200 (growth < room, unchanged)", got)
|
|
||||||
}
|
|
||||||
// current=0, cap=800, room=800. growth=100 < room -> return 100.
|
|
||||||
got = btypes.ClampGrowth(0, 100)
|
|
||||||
if got != 100 {
|
|
||||||
t.Errorf("ClampGrowth(0, 100) = %d, expected 100 (growth < room, unchanged)", got)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// TestClampGrowthInvariantPostGrowthLeCap is the meta-assert: ClampGrowth
|
|
||||||
// never ADDS growth that would push the post-growth coupon past the cap. The
|
|
||||||
// invariant is: current + ClampGrowth(current, growth) <= max(current, cap).
|
|
||||||
// When current <= cap, this means post-growth <= cap (no growth past the
|
|
||||||
// cap). When current > cap (the G-012 misuse/guard case), ClampGrowth returns
|
|
||||||
// 0 (no additional growth), so post == current (the already-broken state is
|
|
||||||
// not made worse; the guard prevents the uint32 underflow from adding a
|
|
||||||
// wrapped-huge value as growth).
|
|
||||||
func TestClampGrowthInvariantPostGrowthLeCap(t *testing.T) {
|
|
||||||
cases := []struct {
|
|
||||||
current uint32
|
|
||||||
growth uint32
|
|
||||||
}{
|
|
||||||
{0, 0},
|
|
||||||
{0, 800},
|
|
||||||
{0, 1000},
|
|
||||||
{400, 400},
|
|
||||||
{400, 500},
|
|
||||||
{799, 1},
|
|
||||||
{799, 100},
|
|
||||||
{800, 100}, // at-cap
|
|
||||||
{801, 100}, // above-cap (guard)
|
|
||||||
{5000, 1000}, // way above-cap (guard)
|
|
||||||
}
|
|
||||||
for _, c := range cases {
|
|
||||||
got := btypes.ClampGrowth(c.current, c.growth)
|
|
||||||
post := c.current + got
|
|
||||||
// The bound: post <= max(current, cap). When current <= cap, this is
|
|
||||||
// post <= cap (no growth past the cap). When current > cap, this is
|
|
||||||
// post <= current (no additional growth — the guard returned 0).
|
|
||||||
upper := c.current
|
|
||||||
if uint32(btypes.CouponCapBps) > upper {
|
|
||||||
upper = btypes.CouponCapBps
|
|
||||||
}
|
|
||||||
if post > upper {
|
|
||||||
t.Errorf("ClampGrowth(%d, %d) = %d; post-growth coupon %d > %d (G-012 invariant violated)",
|
|
||||||
c.current, c.growth, got, post, upper)
|
|
||||||
}
|
|
||||||
// Stronger assert for the in-bounds case: when current <= cap, post
|
|
||||||
// must be <= cap exactly (no growth past the cap).
|
|
||||||
if c.current <= btypes.CouponCapBps && post > btypes.CouponCapBps {
|
|
||||||
t.Errorf("ClampGrowth(%d, %d) = %d; post-growth coupon %d > cap %d (in-bounds invariant violated)",
|
|
||||||
c.current, c.growth, got, post, btypes.CouponCapBps)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// --- D-028 regression: 8%/0% consts unchanged (v0.3 must not change v0.2) -------
|
|
||||||
// These tests are re-declared here in the v0.3 block to make the regression
|
|
||||||
// firewall explicit in the extension context. The v0.2 tests above
|
|
||||||
// (TestCouponCapBpsLockedConst / TestCouponFloorBpsLockedConst) are the
|
|
||||||
// primary firewall; this block re-asserts in the v0.3 extension context.
|
|
||||||
|
|
||||||
// TestD028RegressionCouponCapUnchanged asserts CouponCapBps is still 800
|
|
||||||
// after the v0.3 GrowthBond extension (D-028 regression firewall).
|
|
||||||
func TestD028RegressionCouponCapUnchanged(t *testing.T) {
|
|
||||||
if btypes.CouponCapBps != 800 {
|
|
||||||
t.Errorf("D-028 regression: CouponCapBps = %d, expected 800 (v0.3 must not change v0.2 const)", btypes.CouponCapBps)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// TestD028RegressionCouponFloorUnchanged asserts CouponFloorBps is still 0.
|
|
||||||
func TestD028RegressionCouponFloorUnchanged(t *testing.T) {
|
|
||||||
if btypes.CouponFloorBps != 0 {
|
|
||||||
t.Errorf("D-028 regression: CouponFloorBps = %d, expected 0 (v0.3 must not change v0.2 const)", btypes.CouponFloorBps)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// TestD028RegressionBondStatusCountUnchanged asserts BondStatusCount is still
|
|
||||||
// 5 (the v0.2 enum is unchanged by the v0.3 extension).
|
|
||||||
func TestD028RegressionBondStatusCountUnchanged(t *testing.T) {
|
|
||||||
if btypes.BondStatusCount != 5 {
|
|
||||||
t.Errorf("D-028 regression: BondStatusCount = %d, expected 5 (v0.2 enum unchanged)", btypes.BondStatusCount)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// --- OrderSide enum coverage (2) ----------------------------------------------
|
|
||||||
|
|
||||||
// TestOrderSideCountLockedConst asserts OrderSideCount == 2 and AllOrderSides()
|
|
||||||
// returns exactly 2 (A-313). A regression firewall.
|
|
||||||
func TestOrderSideCountLockedConst(t *testing.T) {
|
|
||||||
if btypes.OrderSideCount != 2 {
|
|
||||||
t.Errorf("OrderSideCount = %d, expected 2 (A-313 LOCKED)", btypes.OrderSideCount)
|
|
||||||
}
|
|
||||||
all := btypes.AllOrderSides()
|
|
||||||
if len(all) != 2 {
|
|
||||||
t.Errorf("AllOrderSides() len = %d, expected 2", len(all))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// TestAllOrderSidesNames asserts the 2 A-313 names in order with no extras, no
|
|
||||||
// dups, no renames.
|
|
||||||
func TestAllOrderSidesNames(t *testing.T) {
|
|
||||||
want := []string{"Buy", "Sell"}
|
|
||||||
all := btypes.AllOrderSides()
|
|
||||||
if len(all) != len(want) {
|
|
||||||
t.Fatalf("len = %d, want %d", len(all), len(want))
|
|
||||||
}
|
|
||||||
seen := map[string]bool{}
|
|
||||||
for i, s := range all {
|
|
||||||
if string(s) != want[i] {
|
|
||||||
t.Errorf("AllOrderSides()[%d] = %q, want %q", i, s, want[i])
|
|
||||||
}
|
|
||||||
if seen[string(s)] {
|
|
||||||
t.Errorf("duplicate OrderSide %q", s)
|
|
||||||
}
|
|
||||||
seen[string(s)] = true
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// TestOrderSideValues asserts each named const matches its AllOrderSides entry.
|
|
||||||
func TestOrderSideValues(t *testing.T) {
|
|
||||||
if btypes.OrderBuy != "Buy" {
|
|
||||||
t.Errorf("OrderBuy = %q", btypes.OrderBuy)
|
|
||||||
}
|
|
||||||
if btypes.OrderSell != "Sell" {
|
|
||||||
t.Errorf("OrderSell = %q", btypes.OrderSell)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// --- OrderStatus enum coverage (3) -------------------------------------------
|
|
||||||
|
|
||||||
// TestOrderStatusCountLockedConst asserts OrderStatusCount == 3 and
|
|
||||||
// AllOrderStatuses() returns exactly 3 (A-313). A regression firewall.
|
|
||||||
func TestOrderStatusCountLockedConst(t *testing.T) {
|
|
||||||
if btypes.OrderStatusCount != 3 {
|
|
||||||
t.Errorf("OrderStatusCount = %d, expected 3 (A-313 LOCKED)", btypes.OrderStatusCount)
|
|
||||||
}
|
|
||||||
all := btypes.AllOrderStatuses()
|
|
||||||
if len(all) != 3 {
|
|
||||||
t.Errorf("AllOrderStatuses() len = %d, expected 3", len(all))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// TestAllOrderStatusesNames asserts the 3 A-313 names in order with no extras,
|
|
||||||
// no dups, no renames.
|
|
||||||
func TestAllOrderStatusesNames(t *testing.T) {
|
|
||||||
want := []string{"Open", "Filled", "Cancelled"}
|
|
||||||
all := btypes.AllOrderStatuses()
|
|
||||||
if len(all) != len(want) {
|
|
||||||
t.Fatalf("len = %d, want %d", len(all), len(want))
|
|
||||||
}
|
|
||||||
seen := map[string]bool{}
|
|
||||||
for i, s := range all {
|
|
||||||
if string(s) != want[i] {
|
|
||||||
t.Errorf("AllOrderStatuses()[%d] = %q, want %q", i, s, want[i])
|
|
||||||
}
|
|
||||||
if seen[string(s)] {
|
|
||||||
t.Errorf("duplicate OrderStatus %q", s)
|
|
||||||
}
|
|
||||||
seen[string(s)] = true
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// TestOrderStatusValues asserts each named const matches its AllOrderStatuses
|
|
||||||
// entry.
|
|
||||||
func TestOrderStatusValues(t *testing.T) {
|
|
||||||
if btypes.OrderOpen != "Open" {
|
|
||||||
t.Errorf("OrderOpen = %q", btypes.OrderOpen)
|
|
||||||
}
|
|
||||||
if btypes.OrderFilled != "Filled" {
|
|
||||||
t.Errorf("OrderFilled = %q", btypes.OrderFilled)
|
|
||||||
}
|
|
||||||
if btypes.OrderCancelled != "Cancelled" {
|
|
||||||
t.Errorf("OrderCancelled = %q", btypes.OrderCancelled)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// --- GrowthBond + IssueGrowth --------------------------------------------------
|
|
||||||
|
|
||||||
// TestGrowthBondStructFields asserts GrowthBond embeds Bond and adds
|
|
||||||
// GrowthRateBps.
|
|
||||||
func TestGrowthBondStructFields(t *testing.T) {
|
|
||||||
gb := btypes.GrowthBond{
|
|
||||||
Bond: btypes.Bond{BondID: "gb-1", IssuerStandID: "stand-1", PrincipalGrain: 1_000_000, CouponBps: 500, TermDays: 365, IssuedAt: 1000, Maturity: 1365, Status: btypes.BondIssued},
|
|
||||||
GrowthRateBps: 200,
|
|
||||||
}
|
|
||||||
if gb.BondID != "gb-1" || gb.IssuerStandID != "stand-1" || gb.PrincipalGrain != 1_000_000 ||
|
|
||||||
gb.CouponBps != 500 || gb.TermDays != 365 || gb.IssuedAt != 1000 || gb.Maturity != 1365 ||
|
|
||||||
gb.Status != btypes.BondIssued || gb.GrowthRateBps != 200 {
|
|
||||||
t.Error("GrowthBond fields not set correctly")
|
|
||||||
}
|
|
||||||
// The embedded Bond is accessible via the anonymous field.
|
|
||||||
if gb.Bond.BondID != "gb-1" {
|
|
||||||
t.Errorf("embedded Bond.BondID = %q", gb.Bond.BondID)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// TestIssueGrowthConstruction asserts IssueGrowth clamps the coupon via Clamp
|
|
||||||
// and the growth-rate via ClampGrowth, and returns status BondIssued.
|
|
||||||
func TestIssueGrowthConstruction(t *testing.T) {
|
|
||||||
// In-range coupon and growth: both unchanged.
|
|
||||||
gb := btypes.IssueGrowth("gb-2", "stand-1", 1_000_000, 500, 200, 365, 1000, 1365)
|
|
||||||
if gb.BondID != "gb-2" {
|
|
||||||
t.Errorf("BondID = %q", gb.BondID)
|
|
||||||
}
|
|
||||||
if gb.CouponBps != 500 {
|
|
||||||
t.Errorf("CouponBps = %d, expected 500 (in-range, unchanged)", gb.CouponBps)
|
|
||||||
}
|
|
||||||
if gb.GrowthRateBps != 200 {
|
|
||||||
t.Errorf("GrowthRateBps = %d, expected 200 (in-range, growth < room)", gb.GrowthRateBps)
|
|
||||||
}
|
|
||||||
if gb.Status != btypes.BondIssued {
|
|
||||||
t.Errorf("Status = %q, expected BondIssued", gb.Status)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// TestIssueGrowthClampsAboveCapCoupon asserts IssueGrowth clamps an above-cap
|
|
||||||
// coupon down to the cap (via Clamp), and the growth-rate is then clamped
|
|
||||||
// against the clamped coupon (currentBps=cap -> growth returns 0, G-012).
|
|
||||||
func TestIssueGrowthClampsAboveCapCoupon(t *testing.T) {
|
|
||||||
gb := btypes.IssueGrowth("gb-3", "stand-1", 1_000_000, 1200, 100, 365, 1000, 1365)
|
|
||||||
if gb.CouponBps != btypes.CouponCapBps {
|
|
||||||
t.Errorf("CouponBps = %d, expected cap %d (IssueGrowth must clamp above-cap coupon)", gb.CouponBps, btypes.CouponCapBps)
|
|
||||||
}
|
|
||||||
// coupon clamped to cap -> ClampGrowth(cap, 100) == 0 (no room, G-012).
|
|
||||||
if gb.GrowthRateBps != 0 {
|
|
||||||
t.Errorf("GrowthRateBps = %d, expected 0 (coupon at cap -> no room, G-012)", gb.GrowthRateBps)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// TestIssueGrowthClampsGrowthToRoom asserts IssueGrowth clamps a growth-rate
|
|
||||||
// that would push the coupon above the cap down to the room-to-cap.
|
|
||||||
func TestIssueGrowthClampsGrowthToRoom(t *testing.T) {
|
|
||||||
// coupon=500, cap=800, room=300. growth=400 -> clamped to 300.
|
|
||||||
gb := btypes.IssueGrowth("gb-4", "stand-1", 1_000_000, 500, 400, 365, 1000, 1365)
|
|
||||||
if gb.CouponBps != 500 {
|
|
||||||
t.Errorf("CouponBps = %d, expected 500", gb.CouponBps)
|
|
||||||
}
|
|
||||||
if gb.GrowthRateBps != 300 {
|
|
||||||
t.Errorf("GrowthRateBps = %d, expected 300 (growth clamped to room, G-012)", gb.GrowthRateBps)
|
|
||||||
}
|
|
||||||
// post-growth coupon: 500 + 300 = 800 == cap (invariant holds).
|
|
||||||
if gb.CouponBps+gb.GrowthRateBps > btypes.CouponCapBps {
|
|
||||||
t.Errorf("post-growth coupon %d > cap %d (G-012 invariant)", gb.CouponBps+gb.GrowthRateBps, btypes.CouponCapBps)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// --- SecondaryOrder struct ----------------------------------------------------
|
|
||||||
|
|
||||||
// TestSecondaryOrderStructFields asserts SecondaryOrder carries order-id,
|
|
||||||
// bond-id (by-ID-string ref to a Bond — in-package), side, price-grain,
|
|
||||||
// holder-reach-id (by-ID-string ref to x/identity — G-003), status, created-at.
|
|
||||||
func TestSecondaryOrderStructFields(t *testing.T) {
|
|
||||||
o := btypes.SecondaryOrder{
|
|
||||||
OrderID: "order-1",
|
|
||||||
BondID: "bond-1",
|
|
||||||
Side: btypes.OrderBuy,
|
|
||||||
PriceGrain: 950_000,
|
|
||||||
HolderReachID: "reach-holder-1",
|
|
||||||
Status: btypes.OrderOpen,
|
|
||||||
CreatedAt: 5000,
|
|
||||||
}
|
|
||||||
if o.OrderID != "order-1" || o.BondID != "bond-1" || o.Side != btypes.OrderBuy ||
|
|
||||||
o.PriceGrain != 950_000 || o.HolderReachID != "reach-holder-1" ||
|
|
||||||
o.Status != btypes.OrderOpen || o.CreatedAt != 5000 {
|
|
||||||
t.Error("SecondaryOrder fields not set correctly")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// TestSecondaryOrderBondIDIsString asserts bond-id is string-typed (in-package
|
|
||||||
// by-ID-string ref to a Bond — same package, not a G-003 cross-module import).
|
|
||||||
func TestSecondaryOrderBondIDIsString(t *testing.T) {
|
|
||||||
o := btypes.SecondaryOrder{BondID: "bond-xyz"}
|
|
||||||
if o.BondID != "bond-xyz" {
|
|
||||||
t.Errorf("BondID = %q", o.BondID)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// TestSecondaryOrderHolderReachIDIsString asserts holder-reach-id is
|
|
||||||
// string-typed (G-003 by-ID-string ref to x/identity Reach — no struct import).
|
|
||||||
func TestSecondaryOrderHolderReachIDIsString(t *testing.T) {
|
|
||||||
o := btypes.SecondaryOrder{HolderReachID: "reach-abc"}
|
|
||||||
if o.HolderReachID != "reach-abc" {
|
|
||||||
t.Errorf("HolderReachID = %q", o.HolderReachID)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// --- Genesis v0.3 extension: GrowthBonds + Orders -----------------------------
|
|
||||||
|
|
||||||
// TestDefaultGenesisStateV3Empty asserts DefaultGenesisState returns non-nil
|
|
||||||
// empty slices for the v0.3 GrowthBonds and Orders sets.
|
|
||||||
func TestDefaultGenesisStateV3Empty(t *testing.T) {
|
|
||||||
gs := btypes.DefaultGenesisState()
|
|
||||||
if gs.GrowthBonds == nil || len(gs.GrowthBonds) != 0 {
|
|
||||||
t.Errorf("Default GrowthBonds should be non-nil empty slice; got len=%d nil=%v", len(gs.GrowthBonds), gs.GrowthBonds == nil)
|
|
||||||
}
|
|
||||||
if gs.Orders == nil || len(gs.Orders) != 0 {
|
|
||||||
t.Errorf("Default Orders should be non-nil empty slice; got len=%d nil=%v", len(gs.Orders), gs.Orders == nil)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// TestValidateGenesisRejectsDupGrowthBondIDs asserts A-212: duplicate
|
|
||||||
// growth-bond-ids are rejected.
|
|
||||||
func TestValidateGenesisRejectsDupGrowthBondIDs(t *testing.T) {
|
|
||||||
gs := btypes.GenesisState{
|
|
||||||
GrowthBonds: []btypes.GrowthBond{
|
|
||||||
{Bond: btypes.Bond{BondID: "gb1", IssuerStandID: "s1", CouponBps: 500, Status: btypes.BondIssued}, GrowthRateBps: 100},
|
|
||||||
{Bond: btypes.Bond{BondID: "gb1", IssuerStandID: "s2", CouponBps: 200, Status: btypes.BondActive}, GrowthRateBps: 50}, // dup
|
|
||||||
},
|
|
||||||
}
|
|
||||||
bz, _ := json.Marshal(gs)
|
|
||||||
if err := btypes.ValidateGenesis(bz); err == nil {
|
|
||||||
t.Error("ValidateGenesis should reject duplicate growth-bond-ids")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// TestValidateGenesisRejectsGrowthBondCouponAboveCap asserts a genesis
|
|
||||||
// GrowthBond with coupon-bps above the cap is rejected (D-028 at genesis).
|
|
||||||
func TestValidateGenesisRejectsGrowthBondCouponAboveCap(t *testing.T) {
|
|
||||||
gs := btypes.GenesisState{
|
|
||||||
GrowthBonds: []btypes.GrowthBond{
|
|
||||||
{Bond: btypes.Bond{BondID: "gb1", IssuerStandID: "s1", CouponBps: 900, Status: btypes.BondIssued}, GrowthRateBps: 0},
|
|
||||||
},
|
|
||||||
}
|
|
||||||
bz, _ := json.Marshal(gs)
|
|
||||||
if err := btypes.ValidateGenesis(bz); err == nil {
|
|
||||||
t.Error("ValidateGenesis should reject growth-bond coupon above cap (D-028)")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// TestValidateGenesisRejectsGrowthBondGrowthAboveRoom asserts a genesis
|
|
||||||
// GrowthBond whose growth-rate would push the coupon above the cap is
|
|
||||||
// rejected (G-012 / A-306 at genesis).
|
|
||||||
func TestValidateGenesisRejectsGrowthBondGrowthAboveRoom(t *testing.T) {
|
|
||||||
gs := btypes.GenesisState{
|
|
||||||
GrowthBonds: []btypes.GrowthBond{
|
|
||||||
// coupon=500, cap=800, room=300. growth=400 -> would push to 900 > cap.
|
|
||||||
{Bond: btypes.Bond{BondID: "gb1", IssuerStandID: "s1", CouponBps: 500, Status: btypes.BondIssued}, GrowthRateBps: 400},
|
|
||||||
},
|
|
||||||
}
|
|
||||||
bz, _ := json.Marshal(gs)
|
|
||||||
if err := btypes.ValidateGenesis(bz); err == nil {
|
|
||||||
t.Error("ValidateGenesis should reject growth-bond growth-rate above room (G-012/A-306)")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// TestValidateGenesisRejectsDupOrderIDs asserts A-212: duplicate order-ids are
|
|
||||||
// rejected.
|
|
||||||
func TestValidateGenesisRejectsDupOrderIDs(t *testing.T) {
|
|
||||||
gs := btypes.GenesisState{
|
|
||||||
Orders: []btypes.SecondaryOrder{
|
|
||||||
{OrderID: "o1", BondID: "b1", Side: btypes.OrderBuy, Status: btypes.OrderOpen},
|
|
||||||
{OrderID: "o1", BondID: "b2", Side: btypes.OrderSell, Status: btypes.OrderOpen}, // dup
|
|
||||||
},
|
|
||||||
}
|
|
||||||
bz, _ := json.Marshal(gs)
|
|
||||||
if err := btypes.ValidateGenesis(bz); err == nil {
|
|
||||||
t.Error("ValidateGenesis should reject duplicate order-ids")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// TestValidateGenesisRejectsEmptyOrderBondID asserts an order with an empty
|
|
||||||
// bond-id is rejected.
|
|
||||||
func TestValidateGenesisRejectsEmptyOrderBondID(t *testing.T) {
|
|
||||||
gs := btypes.GenesisState{
|
|
||||||
Orders: []btypes.SecondaryOrder{{OrderID: "o1", BondID: "", Side: btypes.OrderBuy, Status: btypes.OrderOpen}},
|
|
||||||
}
|
|
||||||
bz, _ := json.Marshal(gs)
|
|
||||||
if err := btypes.ValidateGenesis(bz); err == nil {
|
|
||||||
t.Error("ValidateGenesis should reject empty order bond-id")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// TestValidateGenesisRejectsUnknownOrderSide asserts an unknown OrderSide is
|
|
||||||
// rejected.
|
|
||||||
func TestValidateGenesisRejectsUnknownOrderSide(t *testing.T) {
|
|
||||||
gs := btypes.GenesisState{
|
|
||||||
Orders: []btypes.SecondaryOrder{{OrderID: "o1", BondID: "b1", Side: btypes.OrderSide("Bogus"), Status: btypes.OrderOpen}},
|
|
||||||
}
|
|
||||||
bz, _ := json.Marshal(gs)
|
|
||||||
if err := btypes.ValidateGenesis(bz); err == nil {
|
|
||||||
t.Error("ValidateGenesis should reject unknown order side")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// TestValidateGenesisRejectsUnknownOrderStatus asserts an unknown OrderStatus
|
|
||||||
// is rejected.
|
|
||||||
func TestValidateGenesisRejectsUnknownOrderStatus(t *testing.T) {
|
|
||||||
gs := btypes.GenesisState{
|
|
||||||
Orders: []btypes.SecondaryOrder{{OrderID: "o1", BondID: "b1", Side: btypes.OrderBuy, Status: btypes.OrderStatus("Bogus")}},
|
|
||||||
}
|
|
||||||
bz, _ := json.Marshal(gs)
|
|
||||||
if err := btypes.ValidateGenesis(bz); err == nil {
|
|
||||||
t.Error("ValidateGenesis should reject unknown order status")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// TestValidateGenesisAcceptsCleanV3 asserts a clean v0.3 genesis (bonds +
|
|
||||||
// growth bonds + orders) validates.
|
|
||||||
func TestValidateGenesisAcceptsCleanV3(t *testing.T) {
|
|
||||||
gs := btypes.GenesisState{
|
|
||||||
Bonds: []btypes.Bond{
|
|
||||||
{BondID: "b1", IssuerStandID: "s1", CouponBps: 100, Status: btypes.BondIssued},
|
|
||||||
},
|
|
||||||
GrowthBonds: []btypes.GrowthBond{
|
|
||||||
{Bond: btypes.Bond{BondID: "gb1", IssuerStandID: "s1", CouponBps: 500, Status: btypes.BondIssued}, GrowthRateBps: 200},
|
|
||||||
{Bond: btypes.Bond{BondID: "gb2", IssuerStandID: "s1", CouponBps: 800, Status: btypes.BondActive}, GrowthRateBps: 0},
|
|
||||||
},
|
|
||||||
Orders: []btypes.SecondaryOrder{
|
|
||||||
{OrderID: "o1", BondID: "b1", Side: btypes.OrderBuy, PriceGrain: 950_000, HolderReachID: "r1", Status: btypes.OrderOpen, CreatedAt: 1000},
|
|
||||||
{OrderID: "o2", BondID: "gb1", Side: btypes.OrderSell, PriceGrain: 1_050_000, HolderReachID: "r2", Status: btypes.OrderFilled, CreatedAt: 2000},
|
|
||||||
},
|
|
||||||
}
|
|
||||||
bz, _ := json.Marshal(gs)
|
|
||||||
if err := btypes.ValidateGenesis(bz); err != nil {
|
|
||||||
t.Errorf("ValidateGenesis should accept clean v0.3 genesis, got: %v", err)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// TestValidateGrowthBondsAcceptsClean asserts the data-engineer's
|
|
||||||
// ValidateGrowthBonds helper accepts a clean set.
|
|
||||||
func TestValidateGrowthBondsAcceptsClean(t *testing.T) {
|
|
||||||
gbs := []btypes.GrowthBond{
|
|
||||||
{Bond: btypes.Bond{BondID: "gb1", CouponBps: 0, Status: btypes.BondIssued}, GrowthRateBps: 800},
|
|
||||||
{Bond: btypes.Bond{BondID: "gb2", CouponBps: 500, Status: btypes.BondActive}, GrowthRateBps: 300},
|
|
||||||
{Bond: btypes.Bond{BondID: "gb3", CouponBps: 800, Status: btypes.BondMatured}, GrowthRateBps: 0},
|
|
||||||
}
|
|
||||||
if err := btypes.ValidateGrowthBonds(gbs); err != nil {
|
|
||||||
t.Errorf("ValidateGrowthBonds should accept clean set; got: %v", err)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// TestValidateOrdersAcceptsClean asserts ValidateOrders accepts a clean set.
|
|
||||||
func TestValidateOrdersAcceptsClean(t *testing.T) {
|
|
||||||
orders := []btypes.SecondaryOrder{
|
|
||||||
{OrderID: "o1", BondID: "b1", Side: btypes.OrderBuy, Status: btypes.OrderOpen},
|
|
||||||
{OrderID: "o2", BondID: "b1", Side: btypes.OrderSell, Status: btypes.OrderFilled},
|
|
||||||
{OrderID: "o3", BondID: "b2", Side: btypes.OrderBuy, Status: btypes.OrderCancelled},
|
|
||||||
}
|
|
||||||
if err := btypes.ValidateOrders(orders); err != nil {
|
|
||||||
t.Errorf("ValidateOrders should accept clean set; got: %v", err)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// packageDir resolves a Go import path to its filesystem directory by
|
// packageDir resolves a Go import path to its filesystem directory by
|
||||||
// walking up from this test file (v0.2 skeleton has zero external deps).
|
// walking up from this test file (v0.2 skeleton has zero external deps).
|
||||||
func packageDir(t *testing.T, importPath string) string {
|
func packageDir(t *testing.T, importPath string) string {
|
||||||
|
|||||||
@@ -1,48 +0,0 @@
|
|||||||
package types
|
|
||||||
|
|
||||||
import "fmt"
|
|
||||||
|
|
||||||
// genesis.go holds the data-engineer's genesis schema helpers for the
|
|
||||||
// bridge module (G-008 split). ValidateGenesis in types.go composes these
|
|
||||||
// helpers; the security-engineer's test assertions live in types_test.go.
|
|
||||||
//
|
|
||||||
// The Bridge genesis schema has one top-level set: Routes (the bridge
|
|
||||||
// routes). The invariants enforced at genesis load are (1) bridge-id
|
|
||||||
// uniqueness, (2) bridge-id non-empty, and (3) status is a known
|
|
||||||
// BridgeStatus. The route's l2-chain and watcher-quorum-id are by-ID-string
|
|
||||||
// refs (G-003) and are NOT referentially checked at genesis (the referenced
|
|
||||||
// x/satellite and x/watcher state is in separate modules; cross-module
|
|
||||||
// referential integrity is a v0.4 keeper concern, not a v0.3 skeleton
|
|
||||||
// concern per A-304).
|
|
||||||
|
|
||||||
// ValidateRoutes asserts bridge-ids are present and unique, and that each
|
|
||||||
// route's status is a known BridgeStatus. ValidateRoutes is the
|
|
||||||
// data-engineer's schema validator, composed by ValidateGenesis in
|
|
||||||
// types.go.
|
|
||||||
func ValidateRoutes(routes []BridgeRoute) error {
|
|
||||||
seen := make(map[string]bool, len(routes))
|
|
||||||
for i, r := range routes {
|
|
||||||
if r.BridgeID == "" {
|
|
||||||
return fmt.Errorf("bridge [%d]: empty bridge-id", i)
|
|
||||||
}
|
|
||||||
if seen[r.BridgeID] {
|
|
||||||
return fmt.Errorf("bridge: duplicate bridge-id %q", r.BridgeID)
|
|
||||||
}
|
|
||||||
seen[r.BridgeID] = true
|
|
||||||
if !knownBridgeStatus(r.Status) {
|
|
||||||
return fmt.Errorf("bridge %q: unknown bridge status %q", r.BridgeID, r.Status)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// knownBridgeStatus reports whether s is one of the four BridgeStatus
|
|
||||||
// values.
|
|
||||||
func knownBridgeStatus(s BridgeStatus) bool {
|
|
||||||
for _, ss := range AllBridgeStatuses() {
|
|
||||||
if s == ss {
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
@@ -1,104 +0,0 @@
|
|||||||
package types
|
|
||||||
|
|
||||||
import (
|
|
||||||
"encoding/json"
|
|
||||||
"fmt"
|
|
||||||
)
|
|
||||||
|
|
||||||
const (
|
|
||||||
ModuleName = "bridge"
|
|
||||||
StoreKey = ModuleName
|
|
||||||
RouterKey = ModuleName
|
|
||||||
QuerierRoute = ModuleName
|
|
||||||
|
|
||||||
// BridgeStatusCount is the locked count of BridgeStatus enum values
|
|
||||||
// (vision §7, REQ-010, D-036). Four route-level lifecycle states:
|
|
||||||
// Pending, Attested, Active, Closed. A regression firewall:
|
|
||||||
// adding/removing/renaming a status breaks this const's test.
|
|
||||||
BridgeStatusCount = 4
|
|
||||||
)
|
|
||||||
|
|
||||||
// BridgeStatus enumerates the route-level lifecycle of an L2↔L1 bridge
|
|
||||||
// (vision §7, REQ-010, D-036). The four-state lifecycle sits above the
|
|
||||||
// ICS-20 channel handshake (x/satellite ChannelStatus): a bridge route is
|
|
||||||
// Pending until Watcher attestation confirms it (Attested), then it
|
|
||||||
// becomes Active for transfers, and is Closed when the route is retired.
|
|
||||||
// The Attested state references a Watcher quorum by ID-string (the
|
|
||||||
// attestation is a by-ID-string field, not a struct import — G-003).
|
|
||||||
type BridgeStatus string
|
|
||||||
|
|
||||||
const (
|
|
||||||
BridgePending BridgeStatus = "Pending" // route declared, awaiting attestation
|
|
||||||
BridgeAttested BridgeStatus = "Attested" // Watcher quorum confirmed the route
|
|
||||||
BridgeActive BridgeStatus = "Active" // route open for transfers
|
|
||||||
BridgeClosed BridgeStatus = "Closed" // route retired
|
|
||||||
)
|
|
||||||
|
|
||||||
// AllBridgeStatuses returns all four BridgeStatus values in vision §7
|
|
||||||
// route-lifecycle order. Locked-const test asserts exactly 4 entries.
|
|
||||||
func AllBridgeStatuses() []BridgeStatus {
|
|
||||||
return []BridgeStatus{
|
|
||||||
BridgePending,
|
|
||||||
BridgeAttested,
|
|
||||||
BridgeActive,
|
|
||||||
BridgeClosed,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// BridgeRoute is a single L2↔L1 bridge route (REQ-010, D-036). The route
|
|
||||||
// is the higher-level abstraction over the v0.2 satellite IBC transfer
|
|
||||||
// channel: it carries the route-level status lifecycle and the Watcher
|
|
||||||
// attestation ref, while the underlying channel handshake lives in
|
|
||||||
// x/satellite. All cross-module references are by-ID-string per G-003:
|
|
||||||
//
|
|
||||||
// - bridge-id is this route's unique identifier.
|
|
||||||
// - l2-chain references an x/satellite L2Chain by ID-string (the L2
|
|
||||||
// satellite chain this route bridges to/from). No struct import of
|
|
||||||
// x/satellite (G-003).
|
|
||||||
// - watcher-quorum-id references an x/watcher quorum by ID-string; it is
|
|
||||||
// set when status transitions to Attested (the Watcher 6-of-9 quorum
|
|
||||||
// attests the route per vision §7). No struct import of x/watcher.
|
|
||||||
//
|
|
||||||
// status is the route-level lifecycle (BridgeStatus), distinct from the
|
|
||||||
// channel-level handshake (x/satellite ChannelStatus).
|
|
||||||
type BridgeRoute struct {
|
|
||||||
BridgeID string `json:"bridge_id" yaml:"bridge_id"`
|
|
||||||
L2Chain string `json:"l2_chain" yaml:"l2_chain"`
|
|
||||||
WatcherQuorumID string `json:"watcher_quorum_id" yaml:"watcher_quorum_id"`
|
|
||||||
Status BridgeStatus `json:"status" yaml:"status"`
|
|
||||||
}
|
|
||||||
|
|
||||||
// Params for the bridge module (skeleton — no tunables in v0.3).
|
|
||||||
type Params struct{}
|
|
||||||
|
|
||||||
func DefaultParams() Params { return Params{} }
|
|
||||||
|
|
||||||
// GenesisState defines the bridge module genesis state (REQ-010). Routes
|
|
||||||
// is the set of bridge routes. ValidateGenesis enforces bridge-id
|
|
||||||
// uniqueness and status validity. The data-engineer's genesis.go holds
|
|
||||||
// the schema helpers (G-008 split).
|
|
||||||
type GenesisState struct {
|
|
||||||
Params Params `json:"params" yaml:"params"`
|
|
||||||
Routes []BridgeRoute `json:"routes" yaml:"routes"`
|
|
||||||
}
|
|
||||||
|
|
||||||
func DefaultGenesisState() *GenesisState {
|
|
||||||
return &GenesisState{
|
|
||||||
Params: DefaultParams(),
|
|
||||||
Routes: []BridgeRoute{},
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// ValidateGenesis performs ID-uniqueness checks (A-212 upgrade from v0.1
|
|
||||||
// no-op): rejects duplicate bridge-ids and unknown statuses. Delegates to
|
|
||||||
// the data-engineer's genesis.go helpers (G-008).
|
|
||||||
func ValidateGenesis(bz json.RawMessage) error {
|
|
||||||
var gs GenesisState
|
|
||||||
if err := json.Unmarshal(bz, &gs); err != nil {
|
|
||||||
return fmt.Errorf("bridge: invalid genesis: %w", err)
|
|
||||||
}
|
|
||||||
if err := ValidateRoutes(gs.Routes); err != nil {
|
|
||||||
return fmt.Errorf("bridge: %w", err)
|
|
||||||
}
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
@@ -1,278 +0,0 @@
|
|||||||
package types_test
|
|
||||||
|
|
||||||
import (
|
|
||||||
"encoding/json"
|
|
||||||
"os"
|
|
||||||
"path/filepath"
|
|
||||||
"runtime"
|
|
||||||
"strings"
|
|
||||||
"testing"
|
|
||||||
|
|
||||||
"github.com/oy/openyield/lexicon"
|
|
||||||
btypes "github.com/oy/openyield/x/bridge/types"
|
|
||||||
)
|
|
||||||
|
|
||||||
// --- BridgeStatus enum (exactly 4) ---------------------------------------------
|
|
||||||
|
|
||||||
// TestBridgeStatusCountLockedConst asserts BridgeStatusCount == 4 and
|
|
||||||
// AllBridgeStatuses() returns exactly 4 (vision §7, REQ-010, D-036). A
|
|
||||||
// regression firewall: adding/removing/renaming a status breaks this test.
|
|
||||||
func TestBridgeStatusCountLockedConst(t *testing.T) {
|
|
||||||
if btypes.BridgeStatusCount != 4 {
|
|
||||||
t.Errorf("BridgeStatusCount = %d, expected 4 (vision §7 LOCKED)", btypes.BridgeStatusCount)
|
|
||||||
}
|
|
||||||
all := btypes.AllBridgeStatuses()
|
|
||||||
if len(all) != 4 {
|
|
||||||
t.Errorf("AllBridgeStatuses() len = %d, expected 4", len(all))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// TestAllBridgeStatusesNames asserts the 4 vision §7 route-lifecycle names
|
|
||||||
// in order with no extras, no dups, no renames (Pending, Attested, Active,
|
|
||||||
// Closed).
|
|
||||||
func TestAllBridgeStatusesNames(t *testing.T) {
|
|
||||||
want := []string{"Pending", "Attested", "Active", "Closed"}
|
|
||||||
all := btypes.AllBridgeStatuses()
|
|
||||||
if len(all) != len(want) {
|
|
||||||
t.Fatalf("len = %d, want %d", len(all), len(want))
|
|
||||||
}
|
|
||||||
seen := map[string]bool{}
|
|
||||||
for i, s := range all {
|
|
||||||
if string(s) != want[i] {
|
|
||||||
t.Errorf("AllBridgeStatuses()[%d] = %q, want %q", i, s, want[i])
|
|
||||||
}
|
|
||||||
if seen[string(s)] {
|
|
||||||
t.Errorf("duplicate BridgeStatus %q", s)
|
|
||||||
}
|
|
||||||
seen[string(s)] = true
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// TestBridgeStatusValues asserts each named const matches its AllBridgeStatuses
|
|
||||||
// entry.
|
|
||||||
func TestBridgeStatusValues(t *testing.T) {
|
|
||||||
if btypes.BridgePending != "Pending" {
|
|
||||||
t.Errorf("BridgePending = %q", btypes.BridgePending)
|
|
||||||
}
|
|
||||||
if btypes.BridgeAttested != "Attested" {
|
|
||||||
t.Errorf("BridgeAttested = %q", btypes.BridgeAttested)
|
|
||||||
}
|
|
||||||
if btypes.BridgeActive != "Active" {
|
|
||||||
t.Errorf("BridgeActive = %q", btypes.BridgeActive)
|
|
||||||
}
|
|
||||||
if btypes.BridgeClosed != "Closed" {
|
|
||||||
t.Errorf("BridgeClosed = %q", btypes.BridgeClosed)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// --- BridgeRoute struct (by-ID-string refs — G-003) -----------------------------
|
|
||||||
|
|
||||||
// TestBridgeRouteStructFields asserts BridgeRoute carries all required
|
|
||||||
// fields including the by-ID-string refs to x/satellite (l2-chain) and
|
|
||||||
// x/watcher (watcher-quorum-id) per G-003. No struct imports of either
|
|
||||||
// referenced module (the G-003 import-invariant test enforces this).
|
|
||||||
func TestBridgeRouteStructFields(t *testing.T) {
|
|
||||||
r := btypes.BridgeRoute{
|
|
||||||
BridgeID: "bridge-1",
|
|
||||||
L2Chain: "Polygon", // by-ID-string ref to x/satellite L2Chain (G-003)
|
|
||||||
WatcherQuorumID: "quorum-1",
|
|
||||||
Status: btypes.BridgeActive,
|
|
||||||
}
|
|
||||||
if r.BridgeID != "bridge-1" {
|
|
||||||
t.Errorf("BridgeID = %q", r.BridgeID)
|
|
||||||
}
|
|
||||||
if r.L2Chain != "Polygon" {
|
|
||||||
t.Errorf("L2Chain = %q", r.L2Chain)
|
|
||||||
}
|
|
||||||
if r.WatcherQuorumID != "quorum-1" {
|
|
||||||
t.Errorf("WatcherQuorumID = %q", r.WatcherQuorumID)
|
|
||||||
}
|
|
||||||
if r.Status != btypes.BridgeActive {
|
|
||||||
t.Errorf("Status = %q", r.Status)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// TestBridgeRouteL2ChainIsString asserts the L2Chain field is an opaque
|
|
||||||
// string (by-ID-string ref — G-003), NOT a typed enum import from
|
|
||||||
// x/satellite. This locks the by-ID-string invariant at the type level.
|
|
||||||
func TestBridgeRouteL2ChainIsString(t *testing.T) {
|
|
||||||
r := btypes.BridgeRoute{L2Chain: "Polygon"}
|
|
||||||
// The field must be assignable from a plain string (no satellite.L2Chain
|
|
||||||
// type needed).
|
|
||||||
r.L2Chain = "Base"
|
|
||||||
if r.L2Chain != "Base" {
|
|
||||||
t.Errorf("L2Chain = %q, want %q (must be plain string)", r.L2Chain, "Base")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// TestBridgeRouteWatcherQuorumIDIsString asserts the WatcherQuorumID field
|
|
||||||
// is an opaque string (by-ID-string ref to x/watcher — G-003).
|
|
||||||
func TestBridgeRouteWatcherQuorumIDIsString(t *testing.T) {
|
|
||||||
r := btypes.BridgeRoute{WatcherQuorumID: "quorum-9"}
|
|
||||||
if r.WatcherQuorumID != "quorum-9" {
|
|
||||||
t.Errorf("WatcherQuorumID = %q", r.WatcherQuorumID)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// --- Genesis tests (A-212) ------------------------------------------------------
|
|
||||||
|
|
||||||
// TestDefaultGenesisStateEmpty asserts DefaultGenesisState returns a non-nil
|
|
||||||
// empty slice for Routes.
|
|
||||||
func TestDefaultGenesisStateEmpty(t *testing.T) {
|
|
||||||
gs := btypes.DefaultGenesisState()
|
|
||||||
if gs == nil {
|
|
||||||
t.Fatal("DefaultGenesisState returned nil")
|
|
||||||
}
|
|
||||||
if gs.Routes == nil || len(gs.Routes) != 0 {
|
|
||||||
t.Errorf("Default Routes should be non-nil empty slice; got len=%d nil=%v", len(gs.Routes), gs.Routes == nil)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// TestValidateGenesisRejectsDupBridgeIDs asserts A-212: duplicate bridge-ids
|
|
||||||
// are rejected.
|
|
||||||
func TestValidateGenesisRejectsDupBridgeIDs(t *testing.T) {
|
|
||||||
gs := btypes.GenesisState{
|
|
||||||
Routes: []btypes.BridgeRoute{
|
|
||||||
{BridgeID: "b1", L2Chain: "Polygon", Status: btypes.BridgePending},
|
|
||||||
{BridgeID: "b1", L2Chain: "Base", Status: btypes.BridgeActive}, // dup
|
|
||||||
},
|
|
||||||
}
|
|
||||||
bz, _ := json.Marshal(gs)
|
|
||||||
if err := btypes.ValidateGenesis(bz); err == nil {
|
|
||||||
t.Error("ValidateGenesis should reject duplicate bridge-ids")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// TestValidateGenesisRejectsEmptyBridgeID asserts empty bridge-id is rejected.
|
|
||||||
func TestValidateGenesisRejectsEmptyBridgeID(t *testing.T) {
|
|
||||||
gs := btypes.GenesisState{
|
|
||||||
Routes: []btypes.BridgeRoute{{BridgeID: "", L2Chain: "Polygon", Status: btypes.BridgePending}},
|
|
||||||
}
|
|
||||||
bz, _ := json.Marshal(gs)
|
|
||||||
if err := btypes.ValidateGenesis(bz); err == nil {
|
|
||||||
t.Error("ValidateGenesis should reject empty bridge-id")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// TestValidateGenesisRejectsUnknownStatus asserts an unknown BridgeStatus
|
|
||||||
// is rejected.
|
|
||||||
func TestValidateGenesisRejectsUnknownStatus(t *testing.T) {
|
|
||||||
gs := btypes.GenesisState{
|
|
||||||
Routes: []btypes.BridgeRoute{{BridgeID: "b1", L2Chain: "Polygon", Status: btypes.BridgeStatus("Bogus")}},
|
|
||||||
}
|
|
||||||
bz, _ := json.Marshal(gs)
|
|
||||||
if err := btypes.ValidateGenesis(bz); err == nil {
|
|
||||||
t.Error("ValidateGenesis should reject unknown bridge status")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// TestValidateGenesisRejectsBadJSON asserts malformed JSON is rejected.
|
|
||||||
func TestValidateGenesisRejectsBadJSON(t *testing.T) {
|
|
||||||
if err := btypes.ValidateGenesis(json.RawMessage(`{not json`)); err == nil {
|
|
||||||
t.Error("ValidateGenesis should reject malformed JSON")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// TestValidateGenesisAcceptsClean asserts a clean genesis validates.
|
|
||||||
func TestValidateGenesisAcceptsClean(t *testing.T) {
|
|
||||||
gs := btypes.GenesisState{
|
|
||||||
Routes: []btypes.BridgeRoute{
|
|
||||||
{BridgeID: "b1", L2Chain: "Polygon", WatcherQuorumID: "q1", Status: btypes.BridgeActive},
|
|
||||||
{BridgeID: "b2", L2Chain: "Base", Status: btypes.BridgePending},
|
|
||||||
},
|
|
||||||
}
|
|
||||||
bz, _ := json.Marshal(gs)
|
|
||||||
if err := btypes.ValidateGenesis(bz); err != nil {
|
|
||||||
t.Errorf("ValidateGenesis should accept clean genesis, got: %v", err)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// --- Module consts -------------------------------------------------------------
|
|
||||||
|
|
||||||
// TestModuleConsts asserts the four Cosmos-convention module consts.
|
|
||||||
func TestModuleConsts(t *testing.T) {
|
|
||||||
if btypes.ModuleName != "bridge" {
|
|
||||||
t.Errorf("ModuleName = %q", btypes.ModuleName)
|
|
||||||
}
|
|
||||||
if btypes.StoreKey != "bridge" {
|
|
||||||
t.Errorf("StoreKey = %q", btypes.StoreKey)
|
|
||||||
}
|
|
||||||
if btypes.RouterKey != "bridge" {
|
|
||||||
t.Errorf("RouterKey = %q", btypes.RouterKey)
|
|
||||||
}
|
|
||||||
if btypes.QuerierRoute != "bridge" {
|
|
||||||
t.Errorf("QuerierRoute = %q", btypes.QuerierRoute)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// TestDefaultParams asserts DefaultParams returns a zero-value Params.
|
|
||||||
func TestDefaultParams(t *testing.T) {
|
|
||||||
_ = btypes.DefaultParams() // no panics
|
|
||||||
}
|
|
||||||
|
|
||||||
// --- Lexicon assertion (REQ-012) -------------------------------------------------
|
|
||||||
//
|
|
||||||
// The bridge module must avoid the banned financial holder terms (the
|
|
||||||
// lexicon firewall's banned list). Use "Holder"/"Reach" instead. The lexicon
|
|
||||||
// helpers are used here — no banned literals are inlined.
|
|
||||||
|
|
||||||
// TestLexiconNoBannedTermsInBridgePackage scans every non-test .go file in
|
|
||||||
// the bridge/types package directory for the banned terms (case-insensitive).
|
|
||||||
// Production files only — the test file references banned terms via the
|
|
||||||
// lexicon package helpers (standard lexicon-test bootstrapping pattern).
|
|
||||||
func TestLexiconNoBannedTermsInBridgePackage(t *testing.T) {
|
|
||||||
pkgDir := packageDir(t, "github.com/oy/openyield/x/bridge/types")
|
|
||||||
files, err := filepath.Glob(filepath.Join(pkgDir, "*.go"))
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("glob: %v", err)
|
|
||||||
}
|
|
||||||
prodFiles := []string{}
|
|
||||||
for _, f := range files {
|
|
||||||
if strings.HasSuffix(f, "_test.go") {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
prodFiles = append(prodFiles, f)
|
|
||||||
}
|
|
||||||
if len(prodFiles) == 0 {
|
|
||||||
t.Fatal("no production .go files found in bridge/types")
|
|
||||||
}
|
|
||||||
for _, f := range prodFiles {
|
|
||||||
bz, err := os.ReadFile(f)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("read %s: %v", f, err)
|
|
||||||
}
|
|
||||||
if found, ok := lexicon.FindBannedTerm(string(bz)); ok {
|
|
||||||
t.Errorf("%s: banned term %q (REQ-012 lexicon firewall — use Holder/Reach, not banned financial terms)", filepath.Base(f), found)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// TestLexiconNoBannedTermsInBridgeTestFile asserts this test file itself
|
|
||||||
// does not contain any banned term as a literal.
|
|
||||||
func TestLexiconNoBannedTermsInBridgeTestFile(t *testing.T) {
|
|
||||||
_, thisFile, _, ok := runtime.Caller(0)
|
|
||||||
if !ok {
|
|
||||||
t.Fatal("runtime.Caller failed")
|
|
||||||
}
|
|
||||||
bz, err := os.ReadFile(thisFile)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("read self: %v", err)
|
|
||||||
}
|
|
||||||
if found, ok := lexicon.FindBannedTerm(string(bz)); ok {
|
|
||||||
t.Fatalf("bridge test file contains banned term %q — use lexicon helpers, not literals", found)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// packageDir resolves a Go import path to its filesystem directory by
|
|
||||||
// walking up from this test file (v0.3 skeleton has zero external deps).
|
|
||||||
func packageDir(t *testing.T, importPath string) string {
|
|
||||||
t.Helper()
|
|
||||||
_, file, _, ok := runtime.Caller(0)
|
|
||||||
if !ok {
|
|
||||||
t.Fatal("runtime.Caller failed")
|
|
||||||
}
|
|
||||||
// file = .../oy/x/bridge/types/types_test.go -> repoRoot = .../oy (4 dirs up)
|
|
||||||
repoRoot := filepath.Dir(filepath.Dir(filepath.Dir(filepath.Dir(file))))
|
|
||||||
rel := strings.TrimPrefix(importPath, "github.com/oy/openyield/")
|
|
||||||
return filepath.Join(repoRoot, rel)
|
|
||||||
}
|
|
||||||
@@ -1,66 +0,0 @@
|
|||||||
package types
|
|
||||||
|
|
||||||
import "fmt"
|
|
||||||
|
|
||||||
// genesis.go holds the data-engineer's genesis schema helpers for the
|
|
||||||
// exit module (G-008 split). ValidateGenesis in types.go composes these
|
|
||||||
// helpers; the security-engineer's test assertions live in types_test.go.
|
|
||||||
//
|
|
||||||
// The Exit genesis schema has two top-level sets: Routes (exit routes) and
|
|
||||||
// Swaps (DEX swaps). The invariants enforced at genesis load are (1)
|
|
||||||
// route-id uniqueness, (2) swap-id uniqueness, and (3) status validity.
|
|
||||||
// The route's bridge-route-id is a by-ID-string ref (G-003) and is NOT
|
|
||||||
// referentially checked at genesis (the referenced x/bridge state is in a
|
|
||||||
// separate module; cross-module referential integrity is a v0.4 keeper
|
|
||||||
// concern, not a v0.3 skeleton concern per A-308).
|
|
||||||
|
|
||||||
// ValidateRoutes asserts route-ids are present and unique, and that each
|
|
||||||
// route's status is a known ExitStatus. ValidateRoutes is the
|
|
||||||
// data-engineer's schema validator, composed by ValidateGenesis in
|
|
||||||
// types.go.
|
|
||||||
func ValidateRoutes(routes []ExitRoute) error {
|
|
||||||
seen := make(map[string]bool, len(routes))
|
|
||||||
for i, r := range routes {
|
|
||||||
if r.RouteID == "" {
|
|
||||||
return fmt.Errorf("exit [%d]: empty route-id", i)
|
|
||||||
}
|
|
||||||
if seen[r.RouteID] {
|
|
||||||
return fmt.Errorf("exit: duplicate route-id %q", r.RouteID)
|
|
||||||
}
|
|
||||||
seen[r.RouteID] = true
|
|
||||||
if !knownExitStatus(r.Status) {
|
|
||||||
return fmt.Errorf("exit %q: unknown exit status %q", r.RouteID, r.Status)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// ValidateSwaps asserts swap-ids are present and unique, and that each
|
|
||||||
// swap's status is a known ExitStatus. The venue is an opaque string
|
|
||||||
// (A-308) and is not validated against a locked enum.
|
|
||||||
func ValidateSwaps(swaps []DEXSwap) error {
|
|
||||||
seen := make(map[string]bool, len(swaps))
|
|
||||||
for i, s := range swaps {
|
|
||||||
if s.SwapID == "" {
|
|
||||||
return fmt.Errorf("exit [%d]: empty swap-id", i)
|
|
||||||
}
|
|
||||||
if seen[s.SwapID] {
|
|
||||||
return fmt.Errorf("exit: duplicate swap-id %q", s.SwapID)
|
|
||||||
}
|
|
||||||
seen[s.SwapID] = true
|
|
||||||
if !knownExitStatus(s.Status) {
|
|
||||||
return fmt.Errorf("exit swap %q: unknown exit status %q", s.SwapID, s.Status)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// knownExitStatus reports whether s is one of the five ExitStatus values.
|
|
||||||
func knownExitStatus(s ExitStatus) bool {
|
|
||||||
for _, ss := range AllExitStatuses() {
|
|
||||||
if s == ss {
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
@@ -1,122 +0,0 @@
|
|||||||
package types
|
|
||||||
|
|
||||||
import (
|
|
||||||
"encoding/json"
|
|
||||||
"fmt"
|
|
||||||
)
|
|
||||||
|
|
||||||
const (
|
|
||||||
ModuleName = "exit"
|
|
||||||
StoreKey = ModuleName
|
|
||||||
RouterKey = ModuleName
|
|
||||||
QuerierRoute = ModuleName
|
|
||||||
|
|
||||||
// ExitStatusCount is the locked count of ExitStatus enum values
|
|
||||||
// (vision §7, REQ-010, D-036). Five exit lifecycle states: Proposed,
|
|
||||||
// InProgress, Settled, Failed, Refunded. A regression firewall:
|
|
||||||
// adding/removing/renaming a status breaks this const's test.
|
|
||||||
ExitStatusCount = 5
|
|
||||||
)
|
|
||||||
|
|
||||||
// ExitStatus enumerates the lifecycle of a Layer-3 exit (vision §7,
|
|
||||||
// REQ-010, D-036). The five-state lifecycle covers both successful exits
|
|
||||||
// (Proposed → InProgress → Settled) and the failure/recovery paths
|
|
||||||
// (Failed → Refunded). Refunded is the terminal recovery state when an
|
|
||||||
// exit fails and the holder is made whole.
|
|
||||||
type ExitStatus string
|
|
||||||
|
|
||||||
const (
|
|
||||||
ExitProposed ExitStatus = "Proposed" // exit declared, not yet executing
|
|
||||||
ExitInProgress ExitStatus = "InProgress" // exit executing (swap/bridge hop)
|
|
||||||
ExitSettled ExitStatus = "Settled" // exit completed, holder paid out
|
|
||||||
ExitFailed ExitStatus = "Failed" // exit failed (slippage/timeout)
|
|
||||||
ExitRefunded ExitStatus = "Refunded" // failed exit refunded to holder
|
|
||||||
)
|
|
||||||
|
|
||||||
// AllExitStatuses returns all five ExitStatus values in vision §7 lifecycle
|
|
||||||
// order. Locked-const test asserts exactly 5 entries.
|
|
||||||
func AllExitStatuses() []ExitStatus {
|
|
||||||
return []ExitStatus{
|
|
||||||
ExitProposed,
|
|
||||||
ExitInProgress,
|
|
||||||
ExitSettled,
|
|
||||||
ExitFailed,
|
|
||||||
ExitRefunded,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// ExitRoute is a Holder-initiated exit route (REQ-010, D-036, A-308). The
|
|
||||||
// route describes a holder's intent to exit the mesh via a DEX swap and
|
|
||||||
// (optionally) a cross-chain bridge hop. All cross-module references are
|
|
||||||
// by-ID-string per G-003:
|
|
||||||
//
|
|
||||||
// - route-id is this route's unique identifier.
|
|
||||||
// - bridge-route-id references an x/bridge BridgeRoute by ID-string
|
|
||||||
// (A-308, G-003). It is optional (empty for same-chain exits) and
|
|
||||||
// present for cross-chain exits. No struct import of x/bridge.
|
|
||||||
// - status is the exit lifecycle (ExitStatus).
|
|
||||||
//
|
|
||||||
// The bridge-route-id is the P4 intra-phase dependency edge (x/bridge is
|
|
||||||
// authored first within P4; x/exit references it by ID-string only).
|
|
||||||
type ExitRoute struct {
|
|
||||||
RouteID string `json:"route_id" yaml:"route_id"`
|
|
||||||
BridgeRouteID string `json:"bridge_route_id" yaml:"bridge_route_id"`
|
|
||||||
Status ExitStatus `json:"status" yaml:"status"`
|
|
||||||
}
|
|
||||||
|
|
||||||
// DEXSwap is a single DEX swap executed as part of an exit route (REQ-010,
|
|
||||||
// D-036, A-308). The venue is an OPAQUE string (e.g. "uniswap-v3", "oy-dex")
|
|
||||||
// — NOT a locked enum. A-308: venues are operational, not protocol-locked;
|
|
||||||
// locking an enum now risks churn (uniswap-v3/v4, oy-dex, etc. change over
|
|
||||||
// time). The skeleton keeps the venue as a free-form string so the type
|
|
||||||
// shape is stable across venue additions. status reuses ExitStatus (a swap
|
|
||||||
// shares the exit lifecycle: Proposed → InProgress → Settled/Failed).
|
|
||||||
//
|
|
||||||
// - swap-id is this swap's unique identifier.
|
|
||||||
// - venue is the opaque DEX venue string (A-308 — not a locked enum).
|
|
||||||
// - status is the swap lifecycle (ExitStatus).
|
|
||||||
type DEXSwap struct {
|
|
||||||
SwapID string `json:"swap_id" yaml:"swap_id"`
|
|
||||||
Venue string `json:"venue" yaml:"venue"`
|
|
||||||
Status ExitStatus `json:"status" yaml:"status"`
|
|
||||||
}
|
|
||||||
|
|
||||||
// Params for the exit module (skeleton — no tunables in v0.3).
|
|
||||||
type Params struct{}
|
|
||||||
|
|
||||||
func DefaultParams() Params { return Params{} }
|
|
||||||
|
|
||||||
// GenesisState defines the exit module genesis state (REQ-010). Routes is
|
|
||||||
// the set of exit routes; Swaps is the set of DEX swaps. ValidateGenesis
|
|
||||||
// enforces route-id and swap-id uniqueness. The data-engineer's genesis.go
|
|
||||||
// holds the schema helpers (G-008 split).
|
|
||||||
type GenesisState struct {
|
|
||||||
Params Params `json:"params" yaml:"params"`
|
|
||||||
Routes []ExitRoute `json:"routes" yaml:"routes"`
|
|
||||||
Swaps []DEXSwap `json:"swaps" yaml:"swaps"`
|
|
||||||
}
|
|
||||||
|
|
||||||
func DefaultGenesisState() *GenesisState {
|
|
||||||
return &GenesisState{
|
|
||||||
Params: DefaultParams(),
|
|
||||||
Routes: []ExitRoute{},
|
|
||||||
Swaps: []DEXSwap{},
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// ValidateGenesis performs ID-uniqueness checks (A-212 upgrade from v0.1
|
|
||||||
// no-op): rejects duplicate route-ids and swap-ids. Delegates to the
|
|
||||||
// data-engineer's genesis.go helpers (G-008).
|
|
||||||
func ValidateGenesis(bz json.RawMessage) error {
|
|
||||||
var gs GenesisState
|
|
||||||
if err := json.Unmarshal(bz, &gs); err != nil {
|
|
||||||
return fmt.Errorf("exit: invalid genesis: %w", err)
|
|
||||||
}
|
|
||||||
if err := ValidateRoutes(gs.Routes); err != nil {
|
|
||||||
return fmt.Errorf("exit: %w", err)
|
|
||||||
}
|
|
||||||
if err := ValidateSwaps(gs.Swaps); err != nil {
|
|
||||||
return fmt.Errorf("exit: %w", err)
|
|
||||||
}
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
@@ -1,390 +0,0 @@
|
|||||||
package types_test
|
|
||||||
|
|
||||||
import (
|
|
||||||
"encoding/json"
|
|
||||||
"os"
|
|
||||||
"path/filepath"
|
|
||||||
"runtime"
|
|
||||||
"strings"
|
|
||||||
"testing"
|
|
||||||
|
|
||||||
"github.com/oy/openyield/lexicon"
|
|
||||||
etypes "github.com/oy/openyield/x/exit/types"
|
|
||||||
)
|
|
||||||
|
|
||||||
// --- ExitStatus enum (exactly 5) -----------------------------------------------
|
|
||||||
|
|
||||||
// TestExitStatusCountLockedConst asserts ExitStatusCount == 5 and
|
|
||||||
// AllExitStatuses() returns exactly 5 (vision §7, REQ-010, D-036). A
|
|
||||||
// regression firewall: adding/removing/renaming a status breaks this test.
|
|
||||||
func TestExitStatusCountLockedConst(t *testing.T) {
|
|
||||||
if etypes.ExitStatusCount != 5 {
|
|
||||||
t.Errorf("ExitStatusCount = %d, expected 5 (vision §7 LOCKED)", etypes.ExitStatusCount)
|
|
||||||
}
|
|
||||||
all := etypes.AllExitStatuses()
|
|
||||||
if len(all) != 5 {
|
|
||||||
t.Errorf("AllExitStatuses() len = %d, expected 5", len(all))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// TestAllExitStatusesNames asserts the 5 vision §7 exit-lifecycle names in
|
|
||||||
// order with no extras, no dups, no renames (Proposed, InProgress, Settled,
|
|
||||||
// Failed, Refunded).
|
|
||||||
func TestAllExitStatusesNames(t *testing.T) {
|
|
||||||
want := []string{"Proposed", "InProgress", "Settled", "Failed", "Refunded"}
|
|
||||||
all := etypes.AllExitStatuses()
|
|
||||||
if len(all) != len(want) {
|
|
||||||
t.Fatalf("len = %d, want %d", len(all), len(want))
|
|
||||||
}
|
|
||||||
seen := map[string]bool{}
|
|
||||||
for i, s := range all {
|
|
||||||
if string(s) != want[i] {
|
|
||||||
t.Errorf("AllExitStatuses()[%d] = %q, want %q", i, s, want[i])
|
|
||||||
}
|
|
||||||
if seen[string(s)] {
|
|
||||||
t.Errorf("duplicate ExitStatus %q", s)
|
|
||||||
}
|
|
||||||
seen[string(s)] = true
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// TestExitStatusValues asserts each named const matches its AllExitStatuses
|
|
||||||
// entry.
|
|
||||||
func TestExitStatusValues(t *testing.T) {
|
|
||||||
if etypes.ExitProposed != "Proposed" {
|
|
||||||
t.Errorf("ExitProposed = %q", etypes.ExitProposed)
|
|
||||||
}
|
|
||||||
if etypes.ExitInProgress != "InProgress" {
|
|
||||||
t.Errorf("ExitInProgress = %q", etypes.ExitInProgress)
|
|
||||||
}
|
|
||||||
if etypes.ExitSettled != "Settled" {
|
|
||||||
t.Errorf("ExitSettled = %q", etypes.ExitSettled)
|
|
||||||
}
|
|
||||||
if etypes.ExitFailed != "Failed" {
|
|
||||||
t.Errorf("ExitFailed = %q", etypes.ExitFailed)
|
|
||||||
}
|
|
||||||
if etypes.ExitRefunded != "Refunded" {
|
|
||||||
t.Errorf("ExitRefunded = %q", etypes.ExitRefunded)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// --- ExitRoute struct (bridge-route-id by-ID-string — G-003/A-308) ----------------
|
|
||||||
|
|
||||||
// TestExitRouteStructFields asserts ExitRoute carries all required fields
|
|
||||||
// including the by-ID-string ref to x/bridge BridgeRoute (bridge-route-id)
|
|
||||||
// per A-308/G-003. No struct import of x/bridge (the G-003 import-invariant
|
|
||||||
// test enforces this).
|
|
||||||
func TestExitRouteStructFields(t *testing.T) {
|
|
||||||
r := etypes.ExitRoute{
|
|
||||||
RouteID: "route-1",
|
|
||||||
BridgeRouteID: "bridge-1", // by-ID-string ref to x/bridge (A-308/G-003)
|
|
||||||
Status: etypes.ExitProposed,
|
|
||||||
}
|
|
||||||
if r.RouteID != "route-1" {
|
|
||||||
t.Errorf("RouteID = %q", r.RouteID)
|
|
||||||
}
|
|
||||||
if r.BridgeRouteID != "bridge-1" {
|
|
||||||
t.Errorf("BridgeRouteID = %q", r.BridgeRouteID)
|
|
||||||
}
|
|
||||||
if r.Status != etypes.ExitProposed {
|
|
||||||
t.Errorf("Status = %q", r.Status)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// TestExitRouteBridgeRouteIDIsString asserts the BridgeRouteID field is an
|
|
||||||
// opaque string (by-ID-string ref — G-003), NOT a typed x/bridge.BridgeRoute
|
|
||||||
// import. This locks the by-ID-string invariant at the type level.
|
|
||||||
func TestExitRouteBridgeRouteIDIsString(t *testing.T) {
|
|
||||||
r := etypes.ExitRoute{BridgeRouteID: "bridge-9"}
|
|
||||||
// The field must be assignable from a plain string (no bridge.BridgeRoute
|
|
||||||
// type needed).
|
|
||||||
r.BridgeRouteID = "bridge-2"
|
|
||||||
if r.BridgeRouteID != "bridge-2" {
|
|
||||||
t.Errorf("BridgeRouteID = %q, want %q (must be plain string)", r.BridgeRouteID, "bridge-2")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// TestExitRouteBridgeRouteIDOptional asserts an empty bridge-route-id is
|
|
||||||
// valid (same-chain exits have no bridge hop).
|
|
||||||
func TestExitRouteBridgeRouteIDOptional(t *testing.T) {
|
|
||||||
r := etypes.ExitRoute{
|
|
||||||
RouteID: "same-chain-exit",
|
|
||||||
BridgeRouteID: "", // empty = same-chain exit (no bridge hop)
|
|
||||||
Status: etypes.ExitSettled,
|
|
||||||
}
|
|
||||||
if r.BridgeRouteID != "" {
|
|
||||||
t.Errorf("BridgeRouteID should be empty for same-chain exit; got %q", r.BridgeRouteID)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// --- DEXSwap struct (opaque venue — A-308) --------------------------------------
|
|
||||||
|
|
||||||
// TestDEXSwapStructFields asserts DEXSwap carries all required fields
|
|
||||||
// including the opaque venue string (A-308) and an ExitStatus.
|
|
||||||
func TestDEXSwapStructFields(t *testing.T) {
|
|
||||||
s := etypes.DEXSwap{
|
|
||||||
SwapID: "swap-1",
|
|
||||||
Venue: "uniswap-v3",
|
|
||||||
Status: etypes.ExitSettled,
|
|
||||||
}
|
|
||||||
if s.SwapID != "swap-1" {
|
|
||||||
t.Errorf("SwapID = %q", s.SwapID)
|
|
||||||
}
|
|
||||||
if s.Venue != "uniswap-v3" {
|
|
||||||
t.Errorf("Venue = %q", s.Venue)
|
|
||||||
}
|
|
||||||
if s.Status != etypes.ExitSettled {
|
|
||||||
t.Errorf("Status = %q", s.Status)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// TestDEXSwapVenueIsOpaqueString asserts the DEXSwap venue is an opaque
|
|
||||||
// string, NOT a locked enum (A-308 — venues are operational, locking now
|
|
||||||
// risks churn). The field must accept any free-form string.
|
|
||||||
func TestDEXSwapVenueIsOpaqueString(t *testing.T) {
|
|
||||||
// A-308: venue is an opaque string, not a locked enum. Various venue
|
|
||||||
// strings must be assignable without any enum type.
|
|
||||||
venues := []string{"uniswap-v3", "oy-dex", "1inch", "paraswap", "0x-api", "custom-venue-xyz"}
|
|
||||||
for _, v := range venues {
|
|
||||||
s := etypes.DEXSwap{SwapID: "s", Venue: v}
|
|
||||||
if s.Venue != v {
|
|
||||||
t.Errorf("Venue = %q, want %q (A-308: venue must be opaque string)", s.Venue, v)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// TestDEXSwapVenueTypeIsString asserts the Venue field's Go type is the
|
|
||||||
// built-in string (not a typed enum). This locks A-308 at the type level:
|
|
||||||
// the field is a plain string, so any venue string is assignable without
|
|
||||||
// conversion.
|
|
||||||
func TestDEXSwapVenueTypeIsString(t *testing.T) {
|
|
||||||
s := etypes.DEXSwap{}
|
|
||||||
// Assigning a plain string literal must compile and work — no enum
|
|
||||||
// conversion needed. If venue were a typed enum, assigning a plain
|
|
||||||
// string would require a type conversion (e.g. etypes.Venue("x")).
|
|
||||||
s.Venue = "any-string-works"
|
|
||||||
var want string = "any-string-works"
|
|
||||||
if s.Venue != want {
|
|
||||||
t.Errorf("Venue type is not plain string (A-308): got %q want %q", s.Venue, want)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// TestDEXSwapStatusReusesExitStatus asserts the DEXSwap status field reuses
|
|
||||||
// the ExitStatus enum (a swap shares the exit lifecycle).
|
|
||||||
func TestDEXSwapStatusReusesExitStatus(t *testing.T) {
|
|
||||||
statuses := etypes.AllExitStatuses()
|
|
||||||
for _, st := range statuses {
|
|
||||||
s := etypes.DEXSwap{SwapID: "s", Venue: "v", Status: st}
|
|
||||||
if s.Status != st {
|
|
||||||
t.Errorf("DEXSwap.Status = %q, want %q (must reuse ExitStatus)", s.Status, st)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// --- Genesis tests (A-212) ------------------------------------------------------
|
|
||||||
|
|
||||||
// TestDefaultGenesisStateEmpty asserts DefaultGenesisState returns non-nil
|
|
||||||
// empty slices for Routes and Swaps.
|
|
||||||
func TestDefaultGenesisStateEmpty(t *testing.T) {
|
|
||||||
gs := etypes.DefaultGenesisState()
|
|
||||||
if gs == nil {
|
|
||||||
t.Fatal("DefaultGenesisState returned nil")
|
|
||||||
}
|
|
||||||
if gs.Routes == nil || len(gs.Routes) != 0 {
|
|
||||||
t.Errorf("Default Routes should be non-nil empty slice; got len=%d nil=%v", len(gs.Routes), gs.Routes == nil)
|
|
||||||
}
|
|
||||||
if gs.Swaps == nil || len(gs.Swaps) != 0 {
|
|
||||||
t.Errorf("Default Swaps should be non-nil empty slice; got len=%d nil=%v", len(gs.Swaps), gs.Swaps == nil)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// TestValidateGenesisRejectsDupRouteIDs asserts A-212: duplicate route-ids
|
|
||||||
// are rejected.
|
|
||||||
func TestValidateGenesisRejectsDupRouteIDs(t *testing.T) {
|
|
||||||
gs := etypes.GenesisState{
|
|
||||||
Routes: []etypes.ExitRoute{
|
|
||||||
{RouteID: "r1", Status: etypes.ExitProposed},
|
|
||||||
{RouteID: "r1", Status: etypes.ExitSettled}, // dup
|
|
||||||
},
|
|
||||||
}
|
|
||||||
bz, _ := json.Marshal(gs)
|
|
||||||
if err := etypes.ValidateGenesis(bz); err == nil {
|
|
||||||
t.Error("ValidateGenesis should reject duplicate route-ids")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// TestValidateGenesisRejectsEmptyRouteID asserts empty route-id is rejected.
|
|
||||||
func TestValidateGenesisRejectsEmptyRouteID(t *testing.T) {
|
|
||||||
gs := etypes.GenesisState{
|
|
||||||
Routes: []etypes.ExitRoute{{RouteID: "", Status: etypes.ExitProposed}},
|
|
||||||
}
|
|
||||||
bz, _ := json.Marshal(gs)
|
|
||||||
if err := etypes.ValidateGenesis(bz); err == nil {
|
|
||||||
t.Error("ValidateGenesis should reject empty route-id")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// TestValidateGenesisRejectsUnknownRouteStatus asserts an unknown ExitStatus
|
|
||||||
// on a route is rejected.
|
|
||||||
func TestValidateGenesisRejectsUnknownRouteStatus(t *testing.T) {
|
|
||||||
gs := etypes.GenesisState{
|
|
||||||
Routes: []etypes.ExitRoute{{RouteID: "r1", Status: etypes.ExitStatus("Bogus")}},
|
|
||||||
}
|
|
||||||
bz, _ := json.Marshal(gs)
|
|
||||||
if err := etypes.ValidateGenesis(bz); err == nil {
|
|
||||||
t.Error("ValidateGenesis should reject unknown exit status on route")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// TestValidateGenesisRejectsDupSwapIDs asserts A-212: duplicate swap-ids
|
|
||||||
// are rejected.
|
|
||||||
func TestValidateGenesisRejectsDupSwapIDs(t *testing.T) {
|
|
||||||
gs := etypes.GenesisState{
|
|
||||||
Swaps: []etypes.DEXSwap{
|
|
||||||
{SwapID: "s1", Venue: "uniswap-v3", Status: etypes.ExitSettled},
|
|
||||||
{SwapID: "s1", Venue: "oy-dex", Status: etypes.ExitProposed}, // dup
|
|
||||||
},
|
|
||||||
}
|
|
||||||
bz, _ := json.Marshal(gs)
|
|
||||||
if err := etypes.ValidateGenesis(bz); err == nil {
|
|
||||||
t.Error("ValidateGenesis should reject duplicate swap-ids")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// TestValidateGenesisRejectsEmptySwapID asserts empty swap-id is rejected.
|
|
||||||
func TestValidateGenesisRejectsEmptySwapID(t *testing.T) {
|
|
||||||
gs := etypes.GenesisState{
|
|
||||||
Swaps: []etypes.DEXSwap{{SwapID: "", Venue: "oy-dex", Status: etypes.ExitProposed}},
|
|
||||||
}
|
|
||||||
bz, _ := json.Marshal(gs)
|
|
||||||
if err := etypes.ValidateGenesis(bz); err == nil {
|
|
||||||
t.Error("ValidateGenesis should reject empty swap-id")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// TestValidateGenesisRejectsUnknownSwapStatus asserts an unknown ExitStatus
|
|
||||||
// on a swap is rejected.
|
|
||||||
func TestValidateGenesisRejectsUnknownSwapStatus(t *testing.T) {
|
|
||||||
gs := etypes.GenesisState{
|
|
||||||
Swaps: []etypes.DEXSwap{{SwapID: "s1", Venue: "oy-dex", Status: etypes.ExitStatus("Bogus")}},
|
|
||||||
}
|
|
||||||
bz, _ := json.Marshal(gs)
|
|
||||||
if err := etypes.ValidateGenesis(bz); err == nil {
|
|
||||||
t.Error("ValidateGenesis should reject unknown exit status on swap")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// TestValidateGenesisRejectsBadJSON asserts malformed JSON is rejected.
|
|
||||||
func TestValidateGenesisRejectsBadJSON(t *testing.T) {
|
|
||||||
if err := etypes.ValidateGenesis(json.RawMessage(`{not json`)); err == nil {
|
|
||||||
t.Error("ValidateGenesis should reject malformed JSON")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// TestValidateGenesisAcceptsClean asserts a clean genesis validates.
|
|
||||||
func TestValidateGenesisAcceptsClean(t *testing.T) {
|
|
||||||
gs := etypes.GenesisState{
|
|
||||||
Routes: []etypes.ExitRoute{
|
|
||||||
{RouteID: "r1", BridgeRouteID: "bridge-1", Status: etypes.ExitInProgress},
|
|
||||||
{RouteID: "r2", BridgeRouteID: "", Status: etypes.ExitSettled}, // same-chain exit
|
|
||||||
},
|
|
||||||
Swaps: []etypes.DEXSwap{
|
|
||||||
{SwapID: "s1", Venue: "uniswap-v3", Status: etypes.ExitSettled},
|
|
||||||
{SwapID: "s2", Venue: "oy-dex", Status: etypes.ExitProposed},
|
|
||||||
},
|
|
||||||
}
|
|
||||||
bz, _ := json.Marshal(gs)
|
|
||||||
if err := etypes.ValidateGenesis(bz); err != nil {
|
|
||||||
t.Errorf("ValidateGenesis should accept clean genesis, got: %v", err)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// --- Module consts -------------------------------------------------------------
|
|
||||||
|
|
||||||
// TestModuleConsts asserts the four Cosmos-convention module consts.
|
|
||||||
func TestModuleConsts(t *testing.T) {
|
|
||||||
if etypes.ModuleName != "exit" {
|
|
||||||
t.Errorf("ModuleName = %q", etypes.ModuleName)
|
|
||||||
}
|
|
||||||
if etypes.StoreKey != "exit" {
|
|
||||||
t.Errorf("StoreKey = %q", etypes.StoreKey)
|
|
||||||
}
|
|
||||||
if etypes.RouterKey != "exit" {
|
|
||||||
t.Errorf("RouterKey = %q", etypes.RouterKey)
|
|
||||||
}
|
|
||||||
if etypes.QuerierRoute != "exit" {
|
|
||||||
t.Errorf("QuerierRoute = %q", etypes.QuerierRoute)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// TestDefaultParams asserts DefaultParams returns a zero-value Params.
|
|
||||||
func TestDefaultParams(t *testing.T) {
|
|
||||||
_ = etypes.DefaultParams() // no panics
|
|
||||||
}
|
|
||||||
|
|
||||||
// --- Lexicon assertion (REQ-012) -------------------------------------------------
|
|
||||||
//
|
|
||||||
// The exit module must avoid the banned financial holder terms (the
|
|
||||||
// lexicon firewall's banned list). Use "Holder"/"Reach" instead. The lexicon
|
|
||||||
// helpers are used here — no banned literals are inlined.
|
|
||||||
|
|
||||||
// TestLexiconNoBannedTermsInExitPackage scans every non-test .go file in
|
|
||||||
// the exit/types package directory for the banned terms (case-insensitive).
|
|
||||||
// Production files only — the test file references banned terms via the
|
|
||||||
// lexicon package helpers (standard lexicon-test bootstrapping pattern).
|
|
||||||
func TestLexiconNoBannedTermsInExitPackage(t *testing.T) {
|
|
||||||
pkgDir := packageDir(t, "github.com/oy/openyield/x/exit/types")
|
|
||||||
files, err := filepath.Glob(filepath.Join(pkgDir, "*.go"))
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("glob: %v", err)
|
|
||||||
}
|
|
||||||
prodFiles := []string{}
|
|
||||||
for _, f := range files {
|
|
||||||
if strings.HasSuffix(f, "_test.go") {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
prodFiles = append(prodFiles, f)
|
|
||||||
}
|
|
||||||
if len(prodFiles) == 0 {
|
|
||||||
t.Fatal("no production .go files found in exit/types")
|
|
||||||
}
|
|
||||||
for _, f := range prodFiles {
|
|
||||||
bz, err := os.ReadFile(f)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("read %s: %v", f, err)
|
|
||||||
}
|
|
||||||
if found, ok := lexicon.FindBannedTerm(string(bz)); ok {
|
|
||||||
t.Errorf("%s: banned term %q (REQ-012 lexicon firewall — use Holder/Reach, not banned financial terms)", filepath.Base(f), found)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// TestLexiconNoBannedTermsInExitTestFile asserts this test file itself
|
|
||||||
// does not contain any banned term as a literal.
|
|
||||||
func TestLexiconNoBannedTermsInExitTestFile(t *testing.T) {
|
|
||||||
_, thisFile, _, ok := runtime.Caller(0)
|
|
||||||
if !ok {
|
|
||||||
t.Fatal("runtime.Caller failed")
|
|
||||||
}
|
|
||||||
bz, err := os.ReadFile(thisFile)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("read self: %v", err)
|
|
||||||
}
|
|
||||||
if found, ok := lexicon.FindBannedTerm(string(bz)); ok {
|
|
||||||
t.Fatalf("exit test file contains banned term %q — use lexicon helpers, not literals", found)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// packageDir resolves a Go import path to its filesystem directory by
|
|
||||||
// walking up from this test file (v0.3 skeleton has zero external deps).
|
|
||||||
func packageDir(t *testing.T, importPath string) string {
|
|
||||||
t.Helper()
|
|
||||||
_, file, _, ok := runtime.Caller(0)
|
|
||||||
if !ok {
|
|
||||||
t.Fatal("runtime.Caller failed")
|
|
||||||
}
|
|
||||||
// file = .../oy/x/exit/types/types_test.go -> repoRoot = .../oy (4 dirs up)
|
|
||||||
repoRoot := filepath.Dir(filepath.Dir(filepath.Dir(filepath.Dir(file))))
|
|
||||||
rel := strings.TrimPrefix(importPath, "github.com/oy/openyield/")
|
|
||||||
return filepath.Join(repoRoot, rel)
|
|
||||||
}
|
|
||||||
@@ -1,245 +0,0 @@
|
|||||||
// Package types defines the Hub API module types (vision §13, REQ-024, D-039).
|
|
||||||
//
|
|
||||||
// The Hub is the B2B backbone: a registry of Hub-brokered services an Anchor
|
|
||||||
// partner operates. v0.3 ships the skeleton (enum + per-service struct stubs
|
|
||||||
// + genesis); the live B2B runtime is deferred to v0.4 (D-039).
|
|
||||||
//
|
|
||||||
// Lexicon note (REQ-012, A-210): the Hub is HIGH lexicon-risk because the
|
|
||||||
// lending primitive is a natural fit for the banned financial terms. The
|
|
||||||
// coupon vocabulary is used EXCLUSIVELY here — "lending"/"coupon"/"custody"/
|
|
||||||
// "compliance"/"jurisdiction" are the safe vision-§13 phrasings; the banned
|
|
||||||
// synonyms for these concepts NEVER appear in this package. "lending" is NOT
|
|
||||||
// a banned term (the banned list has the compounding term and the storage
|
|
||||||
// terms, not "lending" or "loan"); "coupon" is the bond vocabulary (vision
|
|
||||||
// §17). The per-package lexicon assertion in types_test.go is the gate.
|
|
||||||
//
|
|
||||||
// Cross-module references are by-ID-string per G-003 (no struct imports):
|
|
||||||
// - operator-partner-id references an x/partner Anchor Partner by ID-string
|
|
||||||
// (A-304, G-003). The Anchor extension lands in P4; x/hub in P5. The
|
|
||||||
// reference is a string, validated by the keeper against the partner
|
|
||||||
// registry at runtime, not by the type system.
|
|
||||||
// - LendingCouponCapBps is a LOCAL const cross-documented to D-028 /
|
|
||||||
// x/bond CouponCapBps (A-304). x/hub does NOT import x/bond; the cap is
|
|
||||||
// redefined locally so the lending-primitive coupon clamp is enforced
|
|
||||||
// without a cross-module struct import (mirrors how x/guild cross-docs
|
|
||||||
// x/feecovenant WaiverHandPassGuild).
|
|
||||||
package types
|
|
||||||
|
|
||||||
import (
|
|
||||||
"encoding/json"
|
|
||||||
"fmt"
|
|
||||||
)
|
|
||||||
|
|
||||||
const (
|
|
||||||
ModuleName = "hub"
|
|
||||||
StoreKey = ModuleName
|
|
||||||
RouterKey = ModuleName
|
|
||||||
QuerierRoute = ModuleName
|
|
||||||
|
|
||||||
// HubServiceCount is the locked count of HubService enum values (vision
|
|
||||||
// §13, REQ-024, A-312). A regression firewall: adding/removing/renaming a
|
|
||||||
// Hub service breaks this const's test.
|
|
||||||
HubServiceCount = 3
|
|
||||||
|
|
||||||
// LendingCouponCapBps is the LOCAL upper bound on a lending-primitive
|
|
||||||
// coupon in basis points (A-304). It is cross-documented to D-028 and
|
|
||||||
// x/bond.CouponCapBps (also 800, the mission-locked 8pct bond coupon cap).
|
|
||||||
// This const is LOCAL to x/hub to avoid importing x/bond (G-003 — no
|
|
||||||
// cross-module struct imports). The two consts MUST stay in sync; a
|
|
||||||
// change to x/bond.CouponCapBps requires a matching change here. The
|
|
||||||
// ClampLendingCoupon helper uses this local const, NOT x/bond.Clamp.
|
|
||||||
LendingCouponCapBps = uint32(800) // 8pct (cross-doc D-028 / x/bond CouponCapBps — A-304)
|
|
||||||
|
|
||||||
// LendingCouponFloorBps is the LOCAL lower bound on a lending-primitive
|
|
||||||
// coupon (A-304, cross-doc to D-028 / x/bond.CouponFloorBps = 0). Local
|
|
||||||
// const for the same G-003 reason as LendingCouponCapBps.
|
|
||||||
LendingCouponFloorBps = uint32(0) // 0pct (cross-doc D-028 / x/bond CouponFloorBps — A-304)
|
|
||||||
)
|
|
||||||
|
|
||||||
// HubService enumerates the three Hub-brokered B2B service categories (vision
|
|
||||||
// §13, REQ-024, A-312): Custody (asset safekeeping), LendingPrimitive (the
|
|
||||||
// protocol-level lending primitive, NOT a live market), Compliance (on-chain
|
|
||||||
// compliance attestations). The full B2B suite is deferred to v0.4 (D-039).
|
|
||||||
type HubService string
|
|
||||||
|
|
||||||
const (
|
|
||||||
ServiceCustody HubService = "Custody" // asset safekeeping
|
|
||||||
ServiceLendingPrimitive HubService = "LendingPrimitive" // protocol-level lending primitive
|
|
||||||
ServiceCompliance HubService = "Compliance" // on-chain compliance attestations
|
|
||||||
)
|
|
||||||
|
|
||||||
// AllHubServices returns all three HubService values in vision §13 order.
|
|
||||||
// Locked-const test asserts exactly 3 entries with these names (REQ-024).
|
|
||||||
func AllHubServices() []HubService {
|
|
||||||
return []HubService{
|
|
||||||
ServiceCustody,
|
|
||||||
ServiceLendingPrimitive,
|
|
||||||
ServiceCompliance,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// CustodyService is the per-service struct stub for a Hub custody service
|
|
||||||
// (vision §13, REQ-024). custody-id is the service identifier. operator-
|
|
||||||
// partner-id references an x/partner Anchor Partner by ID-string (A-304,
|
|
||||||
// G-003 — no struct import of x/partner). asset-ref is an opaque reference to
|
|
||||||
// the custodied asset (the asset identifier is opaque so the Hub does not
|
|
||||||
// import any asset-denom module).
|
|
||||||
type CustodyService struct {
|
|
||||||
CustodyID string `json:"custody_id" yaml:"custody_id"`
|
|
||||||
OperatorPartnerID string `json:"operator_partner_id" yaml:"operator_partner_id"`
|
|
||||||
AssetRef string `json:"asset_ref" yaml:"asset_ref"`
|
|
||||||
}
|
|
||||||
|
|
||||||
// LendingPrimitive is the per-service struct stub for a Hub lending-primitive
|
|
||||||
// service (vision §13, REQ-024). loan-id is the primitive identifier.
|
|
||||||
// principal-grain is the principal in Grain (the OY internal unit, cross-ref
|
|
||||||
// x/bread by name only — no struct import). coupon-bps is the coupon rate in
|
|
||||||
// basis points, clamped to [LendingCouponFloorBps, LendingCouponCapBps] by
|
|
||||||
// ClampLendingCoupon at construction (NewLendingPrimitive). term-days is the
|
|
||||||
// primitive term length. The coupon vocabulary is used EXCLUSIVELY here
|
|
||||||
// (A-210); the banned compounding term and storage terms NEVER appear.
|
|
||||||
type LendingPrimitive struct {
|
|
||||||
LoanID string `json:"loan_id" yaml:"loan_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"`
|
|
||||||
}
|
|
||||||
|
|
||||||
// ComplianceService is the per-service struct stub for a Hub compliance
|
|
||||||
// service (vision §13, REQ-024). compliance-id is the service identifier.
|
|
||||||
// jurisdiction is an opaque jurisdiction tag (e.g. "EU-MiCA"). attestation-
|
|
||||||
// uri is an opaque URI to the compliance attestation (kept opaque in the
|
|
||||||
// skeleton, like the v0.2 Pier CredentialRef).
|
|
||||||
type ComplianceService struct {
|
|
||||||
ComplianceID string `json:"compliance_id" yaml:"compliance_id"`
|
|
||||||
Jurisdiction string `json:"jurisdiction" yaml:"jurisdiction"`
|
|
||||||
AttestationURI string `json:"attestation_uri" yaml:"attestation_uri"`
|
|
||||||
}
|
|
||||||
|
|
||||||
// ClampLendingCoupon ensures a lending-primitive coupon is within the LOCKED
|
|
||||||
// LOCAL bounds (A-304: never above the local cap, never below the local floor).
|
|
||||||
// This mirrors x/bond.Clamp's shape (min(cap, max(floor, coupon))) but uses the
|
|
||||||
// LOCAL LendingCouponCapBps / LendingCouponFloorBps consts — it does NOT import
|
|
||||||
// x/bond.Clamp (G-003). The clamp is automatic and authoritative; the live
|
|
||||||
// keeper enforces it at construction and at genesis load.
|
|
||||||
func ClampLendingCoupon(couponBps uint32) uint32 {
|
|
||||||
if couponBps > LendingCouponCapBps {
|
|
||||||
return LendingCouponCapBps
|
|
||||||
}
|
|
||||||
if couponBps < LendingCouponFloorBps {
|
|
||||||
return LendingCouponFloorBps
|
|
||||||
}
|
|
||||||
return couponBps
|
|
||||||
}
|
|
||||||
|
|
||||||
// NewLendingPrimitive constructs a LendingPrimitive with the coupon clamped to
|
|
||||||
// the LOCAL [floor, cap] bounds via ClampLendingCoupon (A-304). The stub does
|
|
||||||
// not persist or enforce referential integrity of operator-partner-id; it only
|
|
||||||
// enforces the coupon clamp invariant at construction time.
|
|
||||||
func NewLendingPrimitive(loanID string, principalGrain int64, couponBps uint32, termDays uint32) LendingPrimitive {
|
|
||||||
return LendingPrimitive{
|
|
||||||
LoanID: loanID,
|
|
||||||
PrincipalGrain: principalGrain,
|
|
||||||
CouponBps: ClampLendingCoupon(couponBps),
|
|
||||||
TermDays: termDays,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Params for the hub module (skeleton — no tunables in v0.3; the lending
|
|
||||||
// coupon cap/floor are LOCKED LOCAL consts, not Params fields).
|
|
||||||
type Params struct{}
|
|
||||||
|
|
||||||
// DefaultParams returns the zero-value Params (skeleton — no tunables).
|
|
||||||
func DefaultParams() Params { return Params{} }
|
|
||||||
|
|
||||||
// GenesisState defines the hub module genesis state (REQ-024). The three
|
|
||||||
// slices hold the per-service stubs. ValidateGenesis enforces per-set ID
|
|
||||||
// uniqueness (A-212) and the lending-primitive coupon clamp at genesis load
|
|
||||||
// (each LendingPrimitive's coupon-bps must be within the LOCAL bounds).
|
|
||||||
type GenesisState struct {
|
|
||||||
Params Params `json:"params" yaml:"params"`
|
|
||||||
CustodyServices []CustodyService `json:"custody_services" yaml:"custody_services"`
|
|
||||||
LendingPrimitives []LendingPrimitive `json:"lending_primitives" yaml:"lending_primitives"`
|
|
||||||
ComplianceServices []ComplianceService `json:"compliance_services" yaml:"compliance_services"`
|
|
||||||
}
|
|
||||||
|
|
||||||
// DefaultGenesisState returns an empty genesis state with non-nil slices.
|
|
||||||
func DefaultGenesisState() *GenesisState {
|
|
||||||
return &GenesisState{
|
|
||||||
Params: DefaultParams(),
|
|
||||||
CustodyServices: []CustodyService{},
|
|
||||||
LendingPrimitives: []LendingPrimitive{},
|
|
||||||
ComplianceServices: []ComplianceService{},
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// ValidateGenesis performs ID-uniqueness checks (A-212 upgrade from v0.1
|
|
||||||
// no-op) and the lending-primitive coupon clamp at genesis load (A-304):
|
|
||||||
// rejects duplicate custody-ids, loan-ids, compliance-ids, and any
|
|
||||||
// LendingPrimitive whose coupon-bps is outside the LOCAL [floor, cap] bounds.
|
|
||||||
func ValidateGenesis(bz json.RawMessage) error {
|
|
||||||
var gs GenesisState
|
|
||||||
if err := json.Unmarshal(bz, &gs); err != nil {
|
|
||||||
return fmt.Errorf("hub: invalid genesis: %w", err)
|
|
||||||
}
|
|
||||||
if err := validateCustodyServices(gs.CustodyServices); err != nil {
|
|
||||||
return fmt.Errorf("hub: %w", err)
|
|
||||||
}
|
|
||||||
if err := validateLendingPrimitives(gs.LendingPrimitives); err != nil {
|
|
||||||
return fmt.Errorf("hub: %w", err)
|
|
||||||
}
|
|
||||||
if err := validateComplianceServices(gs.ComplianceServices); err != nil {
|
|
||||||
return fmt.Errorf("hub: %w", err)
|
|
||||||
}
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// validateCustodyServices enforces custody-id presence and uniqueness.
|
|
||||||
func validateCustodyServices(svcs []CustodyService) error {
|
|
||||||
seen := make(map[string]bool, len(svcs))
|
|
||||||
for i, c := range svcs {
|
|
||||||
if c.CustodyID == "" {
|
|
||||||
return fmt.Errorf("custody service [%d]: empty custody-id", i)
|
|
||||||
}
|
|
||||||
if seen[c.CustodyID] {
|
|
||||||
return fmt.Errorf("custody service: duplicate custody-id %q", c.CustodyID)
|
|
||||||
}
|
|
||||||
seen[c.CustodyID] = true
|
|
||||||
}
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// validateLendingPrimitives enforces loan-id presence/uniqueness and the
|
|
||||||
// LOCAL coupon clamp at genesis load (A-304).
|
|
||||||
func validateLendingPrimitives(svcs []LendingPrimitive) error {
|
|
||||||
seen := make(map[string]bool, len(svcs))
|
|
||||||
for i, l := range svcs {
|
|
||||||
if l.LoanID == "" {
|
|
||||||
return fmt.Errorf("lending primitive [%d]: empty loan-id", i)
|
|
||||||
}
|
|
||||||
if seen[l.LoanID] {
|
|
||||||
return fmt.Errorf("lending primitive: duplicate loan-id %q", l.LoanID)
|
|
||||||
}
|
|
||||||
seen[l.LoanID] = true
|
|
||||||
if l.CouponBps < LendingCouponFloorBps || l.CouponBps > LendingCouponCapBps {
|
|
||||||
return fmt.Errorf("lending primitive %q: coupon-bps %d outside [%d, %d] (A-304 clamp at genesis load)",
|
|
||||||
l.LoanID, l.CouponBps, LendingCouponFloorBps, LendingCouponCapBps)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// validateComplianceServices enforces compliance-id presence and uniqueness.
|
|
||||||
func validateComplianceServices(svcs []ComplianceService) error {
|
|
||||||
seen := make(map[string]bool, len(svcs))
|
|
||||||
for i, c := range svcs {
|
|
||||||
if c.ComplianceID == "" {
|
|
||||||
return fmt.Errorf("compliance service [%d]: empty compliance-id", i)
|
|
||||||
}
|
|
||||||
if seen[c.ComplianceID] {
|
|
||||||
return fmt.Errorf("compliance service: duplicate compliance-id %q", c.ComplianceID)
|
|
||||||
}
|
|
||||||
seen[c.ComplianceID] = true
|
|
||||||
}
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
@@ -1,424 +0,0 @@
|
|||||||
package types_test
|
|
||||||
|
|
||||||
import (
|
|
||||||
"encoding/json"
|
|
||||||
"os"
|
|
||||||
"path/filepath"
|
|
||||||
"runtime"
|
|
||||||
"strings"
|
|
||||||
"testing"
|
|
||||||
|
|
||||||
"github.com/oy/openyield/lexicon"
|
|
||||||
htypes "github.com/oy/openyield/x/hub/types"
|
|
||||||
)
|
|
||||||
|
|
||||||
// --- HubService enum coverage (3) ----------------------------------------------
|
|
||||||
|
|
||||||
// TestHubServiceCountLockedConst asserts HubServiceCount == 3 and
|
|
||||||
// AllHubServices() returns exactly 3 (REQ-024, A-312). A regression firewall.
|
|
||||||
func TestHubServiceCountLockedConst(t *testing.T) {
|
|
||||||
if htypes.HubServiceCount != 3 {
|
|
||||||
t.Errorf("HubServiceCount = %d, expected 3 (REQ-024 LOCKED)", htypes.HubServiceCount)
|
|
||||||
}
|
|
||||||
all := htypes.AllHubServices()
|
|
||||||
if len(all) != 3 {
|
|
||||||
t.Errorf("AllHubServices() len = %d, expected 3", len(all))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// TestAllHubServicesNames asserts the 3 REQ-024 names in order with no
|
|
||||||
// extras, no dups, no renames.
|
|
||||||
func TestAllHubServicesNames(t *testing.T) {
|
|
||||||
want := []string{"Custody", "LendingPrimitive", "Compliance"}
|
|
||||||
all := htypes.AllHubServices()
|
|
||||||
if len(all) != len(want) {
|
|
||||||
t.Fatalf("len = %d, want %d", len(all), len(want))
|
|
||||||
}
|
|
||||||
seen := map[string]bool{}
|
|
||||||
for i, s := range all {
|
|
||||||
if string(s) != want[i] {
|
|
||||||
t.Errorf("AllHubServices()[%d] = %q, want %q", i, s, want[i])
|
|
||||||
}
|
|
||||||
if seen[string(s)] {
|
|
||||||
t.Errorf("duplicate HubService %q", s)
|
|
||||||
}
|
|
||||||
seen[string(s)] = true
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// TestHubServiceValues asserts each named const matches its AllHubServices
|
|
||||||
// entry.
|
|
||||||
func TestHubServiceValues(t *testing.T) {
|
|
||||||
if htypes.ServiceCustody != "Custody" {
|
|
||||||
t.Errorf("ServiceCustody = %q", htypes.ServiceCustody)
|
|
||||||
}
|
|
||||||
if htypes.ServiceLendingPrimitive != "LendingPrimitive" {
|
|
||||||
t.Errorf("ServiceLendingPrimitive = %q", htypes.ServiceLendingPrimitive)
|
|
||||||
}
|
|
||||||
if htypes.ServiceCompliance != "Compliance" {
|
|
||||||
t.Errorf("ServiceCompliance = %q", htypes.ServiceCompliance)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// --- LendingCouponCapBps LOCAL const (A-304) -----------------------------------
|
|
||||||
|
|
||||||
// TestLendingCouponCapBpsLockedConst asserts the LOCAL LendingCouponCapBps ==
|
|
||||||
// 800 (A-304 cross-doc to D-028 / x/bond.CouponCapBps). The const is LOCAL to
|
|
||||||
// x/hub to avoid importing x/bond (G-003); the test asserts the value matches
|
|
||||||
// the bond cap so the two consts stay in sync.
|
|
||||||
func TestLendingCouponCapBpsLockedConst(t *testing.T) {
|
|
||||||
if htypes.LendingCouponCapBps != 800 {
|
|
||||||
t.Errorf("LendingCouponCapBps = %d, expected 800 (A-304 cross-doc D-028)", htypes.LendingCouponCapBps)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// TestLendingCouponFloorBpsLockedConst asserts the LOCAL
|
|
||||||
// LendingCouponFloorBps == 0 (A-304 cross-doc to D-028 / x/bond.CouponFloorBps).
|
|
||||||
func TestLendingCouponFloorBpsLockedConst(t *testing.T) {
|
|
||||||
if htypes.LendingCouponFloorBps != 0 {
|
|
||||||
t.Errorf("LendingCouponFloorBps = %d, expected 0 (A-304 cross-doc D-028)", htypes.LendingCouponFloorBps)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// --- ClampLendingCoupon invariants (A-304) -------------------------------------
|
|
||||||
// The ClampLendingCoupon invariant is the hub module's firewall (A-304): a
|
|
||||||
// lending-primitive coupon can never exceed the local cap (8pct) and can
|
|
||||||
// never fall below the local floor (0pct). These tests are the regression
|
|
||||||
// firewall — a change to LendingCouponCapBps or LendingCouponFloorBps breaks
|
|
||||||
// them.
|
|
||||||
|
|
||||||
// TestClampLendingCouponBelowFloorReturnsFloor asserts a coupon below the
|
|
||||||
// floor is clamped up to the floor. The floor is 0 and uint32 cannot be
|
|
||||||
// negative, so the below-floor case is type-prevented; the test asserts the
|
|
||||||
// floor boundary passes through.
|
|
||||||
func TestClampLendingCouponBelowFloorReturnsFloor(t *testing.T) {
|
|
||||||
got := htypes.ClampLendingCoupon(htypes.LendingCouponFloorBps)
|
|
||||||
if got != htypes.LendingCouponFloorBps {
|
|
||||||
t.Errorf("ClampLendingCoupon(floor) = %d, expected floor %d", got, htypes.LendingCouponFloorBps)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// TestClampLendingCouponAboveCapReturnsCap asserts a coupon above the cap is
|
|
||||||
// clamped down to the cap.
|
|
||||||
func TestClampLendingCouponAboveCapReturnsCap(t *testing.T) {
|
|
||||||
cases := []uint32{
|
|
||||||
uint32(htypes.LendingCouponCapBps) + 1,
|
|
||||||
uint32(htypes.LendingCouponCapBps) + 100,
|
|
||||||
uint32(htypes.LendingCouponCapBps) + 1000,
|
|
||||||
900,
|
|
||||||
1000,
|
|
||||||
5000,
|
|
||||||
}
|
|
||||||
for _, c := range cases {
|
|
||||||
got := htypes.ClampLendingCoupon(c)
|
|
||||||
if got != htypes.LendingCouponCapBps {
|
|
||||||
t.Errorf("ClampLendingCoupon(%d) = %d, expected cap %d (above-cap must clamp to cap)", c, got, htypes.LendingCouponCapBps)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// TestClampLendingCouponInRangeUnchanged asserts a coupon within [floor, cap]
|
|
||||||
// is unchanged.
|
|
||||||
func TestClampLendingCouponInRangeUnchanged(t *testing.T) {
|
|
||||||
cases := []uint32{
|
|
||||||
0,
|
|
||||||
1,
|
|
||||||
100,
|
|
||||||
400,
|
|
||||||
500,
|
|
||||||
799,
|
|
||||||
uint32(htypes.LendingCouponCapBps),
|
|
||||||
}
|
|
||||||
for _, c := range cases {
|
|
||||||
got := htypes.ClampLendingCoupon(c)
|
|
||||||
if got != c {
|
|
||||||
t.Errorf("ClampLendingCoupon(%d) = %d, expected %d (in-range must be unchanged)", c, got, c)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// TestClampLendingCouponShape asserts the min(cap, max(floor, coupon)) shape
|
|
||||||
// at the boundaries.
|
|
||||||
func TestClampLendingCouponShape(t *testing.T) {
|
|
||||||
if htypes.ClampLendingCoupon(0) != 0 {
|
|
||||||
t.Error("ClampLendingCoupon(0) should be 0 (floor boundary)")
|
|
||||||
}
|
|
||||||
if htypes.ClampLendingCoupon(800) != 800 {
|
|
||||||
t.Error("ClampLendingCoupon(800) should be 800 (cap boundary)")
|
|
||||||
}
|
|
||||||
if htypes.ClampLendingCoupon(801) != 800 {
|
|
||||||
t.Error("ClampLendingCoupon(801) should be 800 (above-cap clamps to cap)")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// --- Per-service struct stubs --------------------------------------------------
|
|
||||||
|
|
||||||
// TestCustodyServiceStructFields asserts CustodyService carries custody-id,
|
|
||||||
// operator-partner-id (by-ID-string ref to x/partner Anchor — G-003), asset-ref.
|
|
||||||
func TestCustodyServiceStructFields(t *testing.T) {
|
|
||||||
c := htypes.CustodyService{
|
|
||||||
CustodyID: "cust-1",
|
|
||||||
OperatorPartnerID: "anchor-partner-1",
|
|
||||||
AssetRef: "bread-grain",
|
|
||||||
}
|
|
||||||
if c.CustodyID != "cust-1" || c.OperatorPartnerID != "anchor-partner-1" || c.AssetRef != "bread-grain" {
|
|
||||||
t.Error("CustodyService fields not set correctly")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// TestCustodyServiceOperatorPartnerIDIsString asserts operator-partner-id is
|
|
||||||
// string-typed (G-003 by-ID-string ref to x/partner Anchor; no struct import).
|
|
||||||
func TestCustodyServiceOperatorPartnerIDIsString(t *testing.T) {
|
|
||||||
c := htypes.CustodyService{OperatorPartnerID: "anchor-1"}
|
|
||||||
if c.OperatorPartnerID != "anchor-1" {
|
|
||||||
t.Errorf("OperatorPartnerID = %q", c.OperatorPartnerID)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// TestLendingPrimitiveStructFields asserts LendingPrimitive carries loan-id,
|
|
||||||
// principal-grain, coupon-bps, term-days.
|
|
||||||
func TestLendingPrimitiveStructFields(t *testing.T) {
|
|
||||||
l := htypes.LendingPrimitive{
|
|
||||||
LoanID: "loan-1",
|
|
||||||
PrincipalGrain: 1_000_000,
|
|
||||||
CouponBps: 500,
|
|
||||||
TermDays: 365,
|
|
||||||
}
|
|
||||||
if l.LoanID != "loan-1" || l.PrincipalGrain != 1_000_000 || l.CouponBps != 500 || l.TermDays != 365 {
|
|
||||||
t.Error("LendingPrimitive fields not set correctly")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// TestNewLendingPrimitiveClampsCoupon asserts NewLendingPrimitive clamps an
|
|
||||||
// above-cap coupon down to the cap and leaves an in-range coupon unchanged.
|
|
||||||
func TestNewLendingPrimitiveClampsCoupon(t *testing.T) {
|
|
||||||
l := htypes.NewLendingPrimitive("loan-2", 500_000, 1200, 180)
|
|
||||||
if l.CouponBps != htypes.LendingCouponCapBps {
|
|
||||||
t.Errorf("CouponBps = %d, expected cap %d (NewLendingPrimitive must clamp above-cap coupon)", l.CouponBps, htypes.LendingCouponCapBps)
|
|
||||||
}
|
|
||||||
l2 := htypes.NewLendingPrimitive("loan-3", 500_000, 300, 180)
|
|
||||||
if l2.CouponBps != 300 {
|
|
||||||
t.Errorf("CouponBps = %d, expected 300 (in-range, unchanged)", l2.CouponBps)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// TestComplianceServiceStructFields asserts ComplianceService carries
|
|
||||||
// compliance-id, jurisdiction, attestation-uri.
|
|
||||||
func TestComplianceServiceStructFields(t *testing.T) {
|
|
||||||
c := htypes.ComplianceService{
|
|
||||||
ComplianceID: "comp-1",
|
|
||||||
Jurisdiction: "EU-MiCA",
|
|
||||||
AttestationURI: "ipfs://attestation/abc",
|
|
||||||
}
|
|
||||||
if c.ComplianceID != "comp-1" || c.Jurisdiction != "EU-MiCA" || c.AttestationURI != "ipfs://attestation/abc" {
|
|
||||||
t.Error("ComplianceService fields not set correctly")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// --- Module consts + Params ----------------------------------------------------
|
|
||||||
|
|
||||||
// TestModuleConsts asserts the four Cosmos-convention module consts.
|
|
||||||
func TestModuleConsts(t *testing.T) {
|
|
||||||
if htypes.ModuleName != "hub" {
|
|
||||||
t.Errorf("ModuleName = %q", htypes.ModuleName)
|
|
||||||
}
|
|
||||||
if htypes.StoreKey != "hub" {
|
|
||||||
t.Errorf("StoreKey = %q", htypes.StoreKey)
|
|
||||||
}
|
|
||||||
if htypes.RouterKey != "hub" {
|
|
||||||
t.Errorf("RouterKey = %q", htypes.RouterKey)
|
|
||||||
}
|
|
||||||
if htypes.QuerierRoute != "hub" {
|
|
||||||
t.Errorf("QuerierRoute = %q", htypes.QuerierRoute)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// TestDefaultParams asserts DefaultParams returns a zero-value Params.
|
|
||||||
func TestDefaultParams(t *testing.T) {
|
|
||||||
_ = htypes.DefaultParams() // no panics
|
|
||||||
}
|
|
||||||
|
|
||||||
// --- Genesis -------------------------------------------------------------------
|
|
||||||
|
|
||||||
// TestDefaultGenesisStateEmpty asserts DefaultGenesisState returns non-nil
|
|
||||||
// empty slices for all three service sets.
|
|
||||||
func TestDefaultGenesisStateEmpty(t *testing.T) {
|
|
||||||
gs := htypes.DefaultGenesisState()
|
|
||||||
if gs == nil {
|
|
||||||
t.Fatal("DefaultGenesisState returned nil")
|
|
||||||
}
|
|
||||||
if gs.CustodyServices == nil || len(gs.CustodyServices) != 0 {
|
|
||||||
t.Errorf("Default CustodyServices should be non-nil empty slice; got len=%d nil=%v", len(gs.CustodyServices), gs.CustodyServices == nil)
|
|
||||||
}
|
|
||||||
if gs.LendingPrimitives == nil || len(gs.LendingPrimitives) != 0 {
|
|
||||||
t.Errorf("Default LendingPrimitives should be non-nil empty slice; got len=%d nil=%v", len(gs.LendingPrimitives), gs.LendingPrimitives == nil)
|
|
||||||
}
|
|
||||||
if gs.ComplianceServices == nil || len(gs.ComplianceServices) != 0 {
|
|
||||||
t.Errorf("Default ComplianceServices should be non-nil empty slice; got len=%d nil=%v", len(gs.ComplianceServices), gs.ComplianceServices == nil)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// TestValidateGenesisRejectsDupCustodyIDs asserts A-212: duplicate custody-ids
|
|
||||||
// are rejected.
|
|
||||||
func TestValidateGenesisRejectsDupCustodyIDs(t *testing.T) {
|
|
||||||
gs := htypes.GenesisState{
|
|
||||||
CustodyServices: []htypes.CustodyService{
|
|
||||||
{CustodyID: "c1", OperatorPartnerID: "a1"},
|
|
||||||
{CustodyID: "c1", OperatorPartnerID: "a2"}, // dup
|
|
||||||
},
|
|
||||||
}
|
|
||||||
bz, _ := json.Marshal(gs)
|
|
||||||
if err := htypes.ValidateGenesis(bz); err == nil {
|
|
||||||
t.Error("ValidateGenesis should reject duplicate custody-ids")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// TestValidateGenesisRejectsEmptyCustodyID asserts empty custody-id is rejected.
|
|
||||||
func TestValidateGenesisRejectsEmptyCustodyID(t *testing.T) {
|
|
||||||
gs := htypes.GenesisState{
|
|
||||||
CustodyServices: []htypes.CustodyService{{CustodyID: "", OperatorPartnerID: "a1"}},
|
|
||||||
}
|
|
||||||
bz, _ := json.Marshal(gs)
|
|
||||||
if err := htypes.ValidateGenesis(bz); err == nil {
|
|
||||||
t.Error("ValidateGenesis should reject empty custody-id")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// TestValidateGenesisRejectsDupLoanIDs asserts duplicate loan-ids are rejected.
|
|
||||||
func TestValidateGenesisRejectsDupLoanIDs(t *testing.T) {
|
|
||||||
gs := htypes.GenesisState{
|
|
||||||
LendingPrimitives: []htypes.LendingPrimitive{
|
|
||||||
{LoanID: "l1", CouponBps: 100},
|
|
||||||
{LoanID: "l1", CouponBps: 200}, // dup
|
|
||||||
},
|
|
||||||
}
|
|
||||||
bz, _ := json.Marshal(gs)
|
|
||||||
if err := htypes.ValidateGenesis(bz); err == nil {
|
|
||||||
t.Error("ValidateGenesis should reject duplicate loan-ids")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// TestValidateGenesisRejectsCouponAboveCap asserts the genesis-side clamp: a
|
|
||||||
// LendingPrimitive with coupon-bps above the local cap is rejected (A-304).
|
|
||||||
func TestValidateGenesisRejectsCouponAboveCap(t *testing.T) {
|
|
||||||
gs := htypes.GenesisState{
|
|
||||||
LendingPrimitives: []htypes.LendingPrimitive{
|
|
||||||
{LoanID: "l1", CouponBps: uint32(htypes.LendingCouponCapBps) + 1},
|
|
||||||
},
|
|
||||||
}
|
|
||||||
bz, _ := json.Marshal(gs)
|
|
||||||
if err := htypes.ValidateGenesis(bz); err == nil {
|
|
||||||
t.Error("ValidateGenesis should reject lending-primitive coupon-bps above local cap (A-304)")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// TestValidateGenesisRejectsDupComplianceIDs asserts duplicate compliance-ids
|
|
||||||
// are rejected.
|
|
||||||
func TestValidateGenesisRejectsDupComplianceIDs(t *testing.T) {
|
|
||||||
gs := htypes.GenesisState{
|
|
||||||
ComplianceServices: []htypes.ComplianceService{
|
|
||||||
{ComplianceID: "comp-1"},
|
|
||||||
{ComplianceID: "comp-1"}, // dup
|
|
||||||
},
|
|
||||||
}
|
|
||||||
bz, _ := json.Marshal(gs)
|
|
||||||
if err := htypes.ValidateGenesis(bz); err == nil {
|
|
||||||
t.Error("ValidateGenesis should reject duplicate compliance-ids")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// TestValidateGenesisRejectsBadJSON asserts malformed JSON is rejected.
|
|
||||||
func TestValidateGenesisRejectsBadJSON(t *testing.T) {
|
|
||||||
if err := htypes.ValidateGenesis(json.RawMessage(`{not json`)); err == nil {
|
|
||||||
t.Error("ValidateGenesis should reject malformed JSON")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// TestValidateGenesisAcceptsClean asserts a clean genesis validates.
|
|
||||||
func TestValidateGenesisAcceptsClean(t *testing.T) {
|
|
||||||
gs := htypes.GenesisState{
|
|
||||||
CustodyServices: []htypes.CustodyService{
|
|
||||||
{CustodyID: "c1", OperatorPartnerID: "a1", AssetRef: "bread"},
|
|
||||||
},
|
|
||||||
LendingPrimitives: []htypes.LendingPrimitive{
|
|
||||||
{LoanID: "l1", PrincipalGrain: 1_000_000, CouponBps: 500, TermDays: 365},
|
|
||||||
{LoanID: "l2", PrincipalGrain: 500_000, CouponBps: 800, TermDays: 180},
|
|
||||||
},
|
|
||||||
ComplianceServices: []htypes.ComplianceService{
|
|
||||||
{ComplianceID: "comp-1", Jurisdiction: "EU-MiCA", AttestationURI: "ipfs://x"},
|
|
||||||
},
|
|
||||||
}
|
|
||||||
bz, _ := json.Marshal(gs)
|
|
||||||
if err := htypes.ValidateGenesis(bz); err != nil {
|
|
||||||
t.Errorf("ValidateGenesis should accept clean genesis, got: %v", err)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// --- Lexicon assertion (REQ-012) -------------------------------------------------
|
|
||||||
// The hub module is HIGH lexicon-risk (lending primitive): the banned terms
|
|
||||||
// that are natural fit-words for a lending primitive (the compounding term,
|
|
||||||
// the storage terms, the tradable-unit terms) must NEVER appear. The coupon
|
|
||||||
// + lending vocabulary is used EXCLUSIVELY. The lexicon helpers are used
|
|
||||||
// here — no banned literals are inlined in this test file.
|
|
||||||
|
|
||||||
// TestLexiconNoBannedTermsInHubPackage scans every non-test .go file in the
|
|
||||||
// hub/types package directory for the banned terms (case-insensitive).
|
|
||||||
// Production files only — the test file references banned terms via the
|
|
||||||
// lexicon package helpers (standard lexicon-test bootstrapping pattern).
|
|
||||||
func TestLexiconNoBannedTermsInHubPackage(t *testing.T) {
|
|
||||||
pkgDir := packageDir(t, "github.com/oy/openyield/x/hub/types")
|
|
||||||
files, err := filepath.Glob(filepath.Join(pkgDir, "*.go"))
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("glob: %v", err)
|
|
||||||
}
|
|
||||||
prodFiles := []string{}
|
|
||||||
for _, f := range files {
|
|
||||||
if strings.HasSuffix(f, "_test.go") {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
prodFiles = append(prodFiles, f)
|
|
||||||
}
|
|
||||||
if len(prodFiles) == 0 {
|
|
||||||
t.Fatal("no production .go files found in hub/types")
|
|
||||||
}
|
|
||||||
for _, f := range prodFiles {
|
|
||||||
bz, err := os.ReadFile(f)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("read %s: %v", f, err)
|
|
||||||
}
|
|
||||||
if found, ok := lexicon.FindBannedTerm(string(bz)); ok {
|
|
||||||
t.Errorf("%s: banned term %q (REQ-012 lexicon firewall — coupon+lending vocabulary only)", filepath.Base(f), found)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// TestLexiconNoBannedTermsInHubTestFile asserts this test file itself does not
|
|
||||||
// contain any banned term as a literal (the firewall scans test files too;
|
|
||||||
// the lexicon helpers must be used rather than inlining banned terms).
|
|
||||||
func TestLexiconNoBannedTermsInHubTestFile(t *testing.T) {
|
|
||||||
_, thisFile, _, ok := runtime.Caller(0)
|
|
||||||
if !ok {
|
|
||||||
t.Fatal("runtime.Caller failed")
|
|
||||||
}
|
|
||||||
bz, err := os.ReadFile(thisFile)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("read self: %v", err)
|
|
||||||
}
|
|
||||||
if found, ok := lexicon.FindBannedTerm(string(bz)); ok {
|
|
||||||
t.Fatalf("hub test file contains banned term %q — use lexicon helpers, not literals", found)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// packageDir resolves a Go import path to its filesystem directory by walking
|
|
||||||
// up from this test file (v0.3 skeleton has zero external deps).
|
|
||||||
func packageDir(t *testing.T, importPath string) string {
|
|
||||||
t.Helper()
|
|
||||||
_, file, _, ok := runtime.Caller(0)
|
|
||||||
if !ok {
|
|
||||||
t.Fatal("runtime.Caller failed")
|
|
||||||
}
|
|
||||||
// file = .../oy/x/hub/types/types_test.go -> repoRoot = .../oy (4 dirs up)
|
|
||||||
repoRoot := filepath.Dir(filepath.Dir(filepath.Dir(filepath.Dir(file))))
|
|
||||||
rel := strings.TrimPrefix(importPath, "github.com/oy/openyield/")
|
|
||||||
return filepath.Join(repoRoot, rel)
|
|
||||||
}
|
|
||||||
@@ -155,53 +155,6 @@ func (k *Keeper) ListByTier(tier PartnerTier) []Partner {
|
|||||||
return out
|
return out
|
||||||
}
|
}
|
||||||
|
|
||||||
// AnchorCredential is the institutional onboarding metadata for an Anchor
|
|
||||||
// tier Partner (REQ-023, D-038, A-305). The Anchor tier (the 4th of the
|
|
||||||
// 4-tier Partner Spectrum, REQ-018) gets institution-specific credential
|
|
||||||
// fields in v0.3. v0.2 defined the 4-tier enum + Partner struct +
|
|
||||||
// CredentialRef; v0.3 adds this AnchorCredential struct carrying the
|
|
||||||
// institutional onboarding metadata. No live institutional onboarding in
|
|
||||||
// v0.3 (the skeleton defines the type shape only).
|
|
||||||
//
|
|
||||||
// All cross-module references are by-ID-string per G-003:
|
|
||||||
//
|
|
||||||
// - anchor-id references a Partner with Tier=Anchor by ID-string
|
|
||||||
// (G-003). No struct import; the reference is validated against the
|
|
||||||
// Partner registry by the keeper, not the type system.
|
|
||||||
// - custody-provider-id references an x/hub custody service by ID-string
|
|
||||||
// (A-304/G-003). The hub is NOT live until P5/v0.4, so this field is
|
|
||||||
// EMPTY in the v0.3 skeleton (NewAnchorCredential sets it to "").
|
|
||||||
// The field exists so the shape is stable when the hub comes online.
|
|
||||||
// No struct import of x/hub.
|
|
||||||
// - credential-uri is an opaque URI to the institutional credential
|
|
||||||
// (regulatory jurisdiction, attestation refs, etc.) — like the v0.2
|
|
||||||
// Pier CredentialRef, kept opaque in the skeleton.
|
|
||||||
// - attestation-count is the number of Watcher/auditor attestations on
|
|
||||||
// the credential (starts at 0 in the skeleton).
|
|
||||||
type AnchorCredential struct {
|
|
||||||
AnchorID string `json:"anchor_id" yaml:"anchor_id"`
|
|
||||||
CustodyProviderID string `json:"custody_provider_id" yaml:"custody_provider_id"`
|
|
||||||
CredentialURI string `json:"credential_uri" yaml:"credential_uri"`
|
|
||||||
AttestationCount uint32 `json:"attestation_count" yaml:"attestation_count"`
|
|
||||||
}
|
|
||||||
|
|
||||||
// NewAnchorCredential constructs an AnchorCredential for an Anchor-tier
|
|
||||||
// Partner (D-038, A-305). The custody-provider-id is set to "" (empty)
|
|
||||||
// because the x/hub custody service is NOT live until P5/v0.4 (A-304:
|
|
||||||
// the field is typed-but-empty in the v0.3 skeleton; the hub is live in
|
|
||||||
// P5, so the field exists but is not validated against hub yet). The
|
|
||||||
// attestation-count is set to 0 (no attestations in the skeleton). The
|
|
||||||
// caller supplies the anchor-id (the Anchor Partner's ID) and the opaque
|
|
||||||
// credential-uri.
|
|
||||||
func NewAnchorCredential(anchorID, credentialURI string) AnchorCredential {
|
|
||||||
return AnchorCredential{
|
|
||||||
AnchorID: anchorID,
|
|
||||||
CustodyProviderID: "", // empty — hub not live until P5/v0.4 (A-304)
|
|
||||||
CredentialURI: credentialURI,
|
|
||||||
AttestationCount: 0, // no attestations in the skeleton
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Params for the partner module (skeleton — no tunables in v0.2).
|
// Params for the partner module (skeleton — no tunables in v0.2).
|
||||||
type Params struct{}
|
type Params struct{}
|
||||||
|
|
||||||
|
|||||||
@@ -411,151 +411,6 @@ func TestLexiconNoBannedTermsInPartnerTestFile(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// --- v0.3 Partner extension (P4-04, D-038, A-305) — AnchorCredential -------------
|
|
||||||
//
|
|
||||||
// The following tests extend the v0.2 partner tests with the v0.3
|
|
||||||
// AnchorCredential struct (D-038). The existing v0.1/v0.2 tests above
|
|
||||||
// MUST remain green — no regression. The PartnerTier enum (4 tiers) is
|
|
||||||
// locked since v0.2; v0.3 adds the AnchorCredential STRUCT only (no new
|
|
||||||
// tier — A-305).
|
|
||||||
|
|
||||||
// TestAnchorCredentialStructFields asserts the AnchorCredential struct
|
|
||||||
// carries all required fields (anchor-id, custody-provider-id,
|
|
||||||
// credential-uri, attestation-count) per D-038/A-305.
|
|
||||||
func TestAnchorCredentialStructFields(t *testing.T) {
|
|
||||||
c := types.AnchorCredential{
|
|
||||||
AnchorID: "anchor-1",
|
|
||||||
CustodyProviderID: "hub-custody-1",
|
|
||||||
CredentialURI: "oy:cred:anchor-1/jurisdiction/EU-MiCA",
|
|
||||||
AttestationCount: 3,
|
|
||||||
}
|
|
||||||
if c.AnchorID != "anchor-1" {
|
|
||||||
t.Errorf("AnchorID = %q", c.AnchorID)
|
|
||||||
}
|
|
||||||
if c.CustodyProviderID != "hub-custody-1" {
|
|
||||||
t.Errorf("CustodyProviderID = %q", c.CustodyProviderID)
|
|
||||||
}
|
|
||||||
if c.CredentialURI != "oy:cred:anchor-1/jurisdiction/EU-MiCA" {
|
|
||||||
t.Errorf("CredentialURI = %q", c.CredentialURI)
|
|
||||||
}
|
|
||||||
if c.AttestationCount != 3 {
|
|
||||||
t.Errorf("AttestationCount = %d, want 3", c.AttestationCount)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// TestAnchorCredentialAnchorIDIsString asserts the AnchorID field is an
|
|
||||||
// opaque string (by-ID-string ref to a Partner with Tier=Anchor — G-003),
|
|
||||||
// NOT a typed Partner import. This locks the by-ID-string invariant at
|
|
||||||
// the type level.
|
|
||||||
func TestAnchorCredentialAnchorIDIsString(t *testing.T) {
|
|
||||||
c := types.AnchorCredential{AnchorID: "partner-9"}
|
|
||||||
c.AnchorID = "partner-2"
|
|
||||||
if c.AnchorID != "partner-2" {
|
|
||||||
t.Errorf("AnchorID = %q, want %q (must be plain string — G-003)", c.AnchorID, "partner-2")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// TestAnchorCredentialCustodyProviderIDIsString asserts the
|
|
||||||
// CustodyProviderID field is an opaque string (by-ID-string ref to an
|
|
||||||
// x/hub custody service — A-304/G-003), NOT a typed x/hub import.
|
|
||||||
func TestAnchorCredentialCustodyProviderIDIsString(t *testing.T) {
|
|
||||||
c := types.AnchorCredential{CustodyProviderID: "hub-custody-9"}
|
|
||||||
c.CustodyProviderID = "hub-custody-2"
|
|
||||||
if c.CustodyProviderID != "hub-custody-2" {
|
|
||||||
t.Errorf("CustodyProviderID = %q, want %q (must be plain string — A-304/G-003)", c.CustodyProviderID, "hub-custody-2")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// TestNewAnchorCredentialConstruction asserts NewAnchorCredential sets
|
|
||||||
// the anchor-id and credential-uri from the constructor args, AND sets
|
|
||||||
// custody-provider-id to "" (empty — hub not live until P5/v0.4 per
|
|
||||||
// A-304), AND attestation-count to 0 (no attestations in the skeleton).
|
|
||||||
func TestNewAnchorCredentialConstruction(t *testing.T) {
|
|
||||||
c := types.NewAnchorCredential("anchor-1", "oy:cred:anchor-1/EU-MiCA")
|
|
||||||
if c.AnchorID != "anchor-1" {
|
|
||||||
t.Errorf("AnchorID = %q, want %q", c.AnchorID, "anchor-1")
|
|
||||||
}
|
|
||||||
if c.CredentialURI != "oy:cred:anchor-1/EU-MiCA" {
|
|
||||||
t.Errorf("CredentialURI = %q, want %q", c.CredentialURI, "oy:cred:anchor-1/EU-MiCA")
|
|
||||||
}
|
|
||||||
// custody-provider-id must be EMPTY in the skeleton (A-304: hub not
|
|
||||||
// live until P5/v0.4).
|
|
||||||
if c.CustodyProviderID != "" {
|
|
||||||
t.Errorf("CustodyProviderID = %q, want empty (A-304: hub not live until P5)", c.CustodyProviderID)
|
|
||||||
}
|
|
||||||
// attestation-count must be 0 in the skeleton.
|
|
||||||
if c.AttestationCount != 0 {
|
|
||||||
t.Errorf("AttestationCount = %d, want 0 (skeleton)", c.AttestationCount)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// TestNewAnchorCredentialCustodyProviderIDEmptyInvariant asserts the
|
|
||||||
// A-304 invariant: NewAnchorCredential ALWAYS sets custody-provider-id to
|
|
||||||
// "" regardless of inputs (the hub is not live until P5/v0.4; the field
|
|
||||||
// is typed-but-empty in the v0.3 skeleton). This is the dependency edge
|
|
||||||
// that forces P4 before P5 (D-044): x/partner Anchor lands in P4, x/hub
|
|
||||||
// in P5.
|
|
||||||
func TestNewAnchorCredentialCustodyProviderIDEmptyInvariant(t *testing.T) {
|
|
||||||
cases := []struct {
|
|
||||||
anchorID string
|
|
||||||
credURI string
|
|
||||||
}{
|
|
||||||
{"anchor-1", "oy:cred:a/EU-MiCA"},
|
|
||||||
{"anchor-2", "oy:cred:a/US-SOC2"},
|
|
||||||
{"", ""},
|
|
||||||
{"anchor-3", ""},
|
|
||||||
}
|
|
||||||
for _, c := range cases {
|
|
||||||
got := types.NewAnchorCredential(c.anchorID, c.credURI)
|
|
||||||
if got.CustodyProviderID != "" {
|
|
||||||
t.Errorf("NewAnchorCredential(%q,%q): CustodyProviderID = %q, want empty (A-304 LOCKED)", c.anchorID, c.credURI, got.CustodyProviderID)
|
|
||||||
}
|
|
||||||
if got.AttestationCount != 0 {
|
|
||||||
t.Errorf("NewAnchorCredential(%q,%q): AttestationCount = %d, want 0 (skeleton)", c.anchorID, c.credURI, got.AttestationCount)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// TestNewAnchorCredentialAttestationCountZero asserts the constructor sets
|
|
||||||
// attestation-count to 0 (no attestations in the skeleton; attestations
|
|
||||||
// are a v0.4 keeper concern).
|
|
||||||
func TestNewAnchorCredentialAttestationCountZero(t *testing.T) {
|
|
||||||
c := types.NewAnchorCredential("anchor-1", "oy:cred:anchor-1/x")
|
|
||||||
if c.AttestationCount != 0 {
|
|
||||||
t.Errorf("AttestationCount = %d, want 0 (skeleton — attestations are v0.4)", c.AttestationCount)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// TestAnchorCredentialZeroValue asserts the zero-value AnchorCredential
|
|
||||||
// has empty strings and a 0 attestation-count.
|
|
||||||
func TestAnchorCredentialZeroValue(t *testing.T) {
|
|
||||||
var c types.AnchorCredential
|
|
||||||
if c.AnchorID != "" || c.CustodyProviderID != "" || c.CredentialURI != "" {
|
|
||||||
t.Error("zero-value AnchorCredential should have empty string fields")
|
|
||||||
}
|
|
||||||
if c.AttestationCount != 0 {
|
|
||||||
t.Errorf("zero-value AttestationCount = %d, want 0", c.AttestationCount)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// TestPartnerTierCountStillFour is the v0.3 REGRESSION test (A-305): the
|
|
||||||
// PartnerTier enum is LOCKED at 4 tiers since v0.2; v0.3 adds the
|
|
||||||
// AnchorCredential STRUCT, NOT a new tier. This test asserts the count
|
|
||||||
// is still 4 (no new tier added by the v0.3 extension).
|
|
||||||
func TestPartnerTierCountStillFour(t *testing.T) {
|
|
||||||
if types.PartnerTierCount != 4 {
|
|
||||||
t.Errorf("PartnerTierCount = %d, expected 4 (A-305: v0.3 adds AnchorCredential struct, not a tier)", types.PartnerTierCount)
|
|
||||||
}
|
|
||||||
all := types.AllPartnerTiers()
|
|
||||||
if len(all) != 4 {
|
|
||||||
t.Errorf("AllPartnerTiers() len = %d, expected 4 (A-305 regression)", len(all))
|
|
||||||
}
|
|
||||||
// Anchor must still be the 4th tier (no new tier added before/after it).
|
|
||||||
if all[3] != types.TierAnchor {
|
|
||||||
t.Errorf("AllPartnerTiers()[3] = %q, want %q (Anchor must remain 4th tier)", all[3], types.TierAnchor)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// packageDir resolves a Go import path to its filesystem directory by
|
// packageDir resolves a Go import path to its filesystem directory by
|
||||||
// walking up from this test file (v0.2 skeleton has zero external deps).
|
// walking up from this test file (v0.2 skeleton has zero external deps).
|
||||||
func packageDir(t *testing.T, importPath string) string {
|
func packageDir(t *testing.T, importPath string) string {
|
||||||
|
|||||||
@@ -1,225 +0,0 @@
|
|||||||
// Package types defines the Services module types (vision §13, REQ-025,
|
|
||||||
// D-040, A-307).
|
|
||||||
//
|
|
||||||
// OY-protocol services beyond the financial layer: Care (community care),
|
|
||||||
// SIM (connectivity), Vault (storage service), Mail (messaging). v0.3 ships
|
|
||||||
// the skeleton (enum + per-service struct stubs + genesis); no live services.
|
|
||||||
//
|
|
||||||
// Lexicon note (REQ-012): "Mail"/"SIM"/"Care"/"Vault" are not banned terms.
|
|
||||||
// Avoid the banned Holder-identity term (use "operator-reach-id" not the
|
|
||||||
// banned term). The per-package lexicon assertion in types_test.go is the gate.
|
|
||||||
//
|
|
||||||
// Cross-module references are by-ID-string per G-003 (no struct imports):
|
|
||||||
// - operator-reach-id references an x/identity Reach by ID-string (G-003).
|
|
||||||
// - window-id references an x/window Window by ID-string (A-307, G-003).
|
|
||||||
// A service-grant opens a Window on the holder's behalf (the Window
|
|
||||||
// Lifecycle interface hook, typed in v0.3, invoked at runtime in v0.4).
|
|
||||||
// - mailbox-id (MailService) and storage-quota-grain (VaultService) are
|
|
||||||
// opaque / in-package values; VaultService references x/vault by name only
|
|
||||||
// (the ServiceKind "Vault" is a service kind, NOT a struct import of
|
|
||||||
// x/vault — the naming collision is concept-level, not package-level).
|
|
||||||
package types
|
|
||||||
|
|
||||||
import (
|
|
||||||
"encoding/json"
|
|
||||||
"fmt"
|
|
||||||
)
|
|
||||||
|
|
||||||
const (
|
|
||||||
ModuleName = "services"
|
|
||||||
StoreKey = ModuleName
|
|
||||||
RouterKey = ModuleName
|
|
||||||
QuerierRoute = ModuleName
|
|
||||||
|
|
||||||
// ServiceKindCount is the locked count of ServiceKind enum values (vision
|
|
||||||
// §13, REQ-025, A-307). A regression firewall: adding/removing/renaming a
|
|
||||||
// service kind breaks this const's test.
|
|
||||||
ServiceKindCount = 4
|
|
||||||
)
|
|
||||||
|
|
||||||
// ServiceKind enumerates the four OY-protocol service kinds (vision §13,
|
|
||||||
// REQ-025, A-307): Care (community care), SIM (subscriber identity module /
|
|
||||||
// connectivity), Vault (storage service), Mail (messaging). The full services
|
|
||||||
// suite (the real-return token, Travel, +11 more) is Phase 4, out of v0.3
|
|
||||||
// scope (D-040). The real-return token's name in vision §13 uses a banned
|
|
||||||
// standalone term; this comment uses the lexicon-safe "real-return" phrasing.
|
|
||||||
type ServiceKind string
|
|
||||||
|
|
||||||
const (
|
|
||||||
KindCare ServiceKind = "Care" // community care
|
|
||||||
KindSIM ServiceKind = "SIM" // connectivity
|
|
||||||
KindVault ServiceKind = "Vault" // storage service
|
|
||||||
KindMail ServiceKind = "Mail" // messaging
|
|
||||||
)
|
|
||||||
|
|
||||||
// AllServiceKinds returns all four ServiceKind values in vision §13 order.
|
|
||||||
// Locked-const test asserts exactly 4 entries with these names (REQ-025).
|
|
||||||
func AllServiceKinds() []ServiceKind {
|
|
||||||
return []ServiceKind{
|
|
||||||
KindCare,
|
|
||||||
KindSIM,
|
|
||||||
KindVault,
|
|
||||||
KindMail,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// ServiceStatus enumerates the lifecycle states of a service (REQ-025). This
|
|
||||||
// is a LOCAL redefinition of the 4-state shape (mirrors the v0.2 PartnerStatus
|
|
||||||
// shape); no struct import of x/partner (G-003).
|
|
||||||
type ServiceStatus string
|
|
||||||
|
|
||||||
const (
|
|
||||||
ServicePending ServiceStatus = "Pending" // registered, not yet active
|
|
||||||
ServiceActive ServiceStatus = "Active" // live
|
|
||||||
ServiceSuspended ServiceStatus = "Suspended" // temporarily halted
|
|
||||||
ServiceRevoked ServiceStatus = "Revoked" // permanently revoked
|
|
||||||
)
|
|
||||||
|
|
||||||
// ServiceStatusCount is the locked count of ServiceStatus enum values.
|
|
||||||
const ServiceStatusCount = 4
|
|
||||||
|
|
||||||
// ServiceInfo is the registry record for a service (REQ-025, A-307).
|
|
||||||
// service-id is the unique identifier. kind picks the ServiceKind.
|
|
||||||
// operator-reach-id references an x/identity Reach by ID-string (G-003 — use
|
|
||||||
// "operator-reach-id" not the banned Holder-identity term). name is a human-
|
|
||||||
// readable label. status is the lifecycle state. window-id references an
|
|
||||||
// x/window Window by ID-string (A-307, G-003 — a service-grant opens a Window
|
|
||||||
// on the holder's behalf; the Window Lifecycle interface hook, typed in v0.3,
|
|
||||||
// invoked at runtime in v0.4). The window-id field is the by-ID-string ref
|
|
||||||
// that ties a service-grant to a Window scope.
|
|
||||||
type ServiceInfo struct {
|
|
||||||
ServiceID string `json:"service_id" yaml:"service_id"`
|
|
||||||
Kind ServiceKind `json:"kind" yaml:"kind"`
|
|
||||||
OperatorReachID string `json:"operator_reach_id" yaml:"operator_reach_id"`
|
|
||||||
Name string `json:"name" yaml:"name"`
|
|
||||||
Status ServiceStatus `json:"status" yaml:"status"`
|
|
||||||
WindowID string `json:"window_id" yaml:"window_id"`
|
|
||||||
}
|
|
||||||
|
|
||||||
// CareService is the per-service struct stub for a Care service (vision §13,
|
|
||||||
// REQ-025). care-id is the service identifier. care-kind is an opaque string
|
|
||||||
// (the kind of community care, e.g. "mutual-aid" — opaque so the enum is not
|
|
||||||
// locked in v0.3; care kinds are operational, not protocol-locked).
|
|
||||||
type CareService struct {
|
|
||||||
CareID string `json:"care_id" yaml:"care_id"`
|
|
||||||
CareKind string `json:"care_kind" yaml:"care_kind"`
|
|
||||||
}
|
|
||||||
|
|
||||||
// SIMService is the per-service struct stub for a SIM (connectivity) service
|
|
||||||
// (vision §13, REQ-025). sim-id is the service identifier. carrier is an
|
|
||||||
// opaque string (the connectivity carrier — opaque so the enum is not locked
|
|
||||||
// in v0.3 per A-308 venue pattern; carriers are operational).
|
|
||||||
type SIMService struct {
|
|
||||||
SIMID string `json:"sim_id" yaml:"sim_id"`
|
|
||||||
Carrier string `json:"carrier" yaml:"carrier"`
|
|
||||||
}
|
|
||||||
|
|
||||||
// VaultService is the per-service struct stub for a Vault (storage) service
|
|
||||||
// (vision §13, REQ-025). vault-id is the service identifier. holder-reach-id
|
|
||||||
// references an x/identity Reach by ID-string (G-003 — use "holder-reach-id"
|
|
||||||
// not the banned Holder-identity term). storage-quota-grain is the storage
|
|
||||||
// quota in Grain (the OY internal unit, by name only — no x/bread import).
|
|
||||||
// "Vault" here is a service kind, NOT a struct import of x/vault (the naming
|
|
||||||
// collision is concept-level; VaultService references x/vault by ID-string at
|
|
||||||
// runtime, not by Go import).
|
|
||||||
type VaultService struct {
|
|
||||||
VaultID string `json:"vault_id" yaml:"vault_id"`
|
|
||||||
HolderReachID string `json:"holder_reach_id" yaml:"holder_reach_id"`
|
|
||||||
StorageQuotaGrain int64 `json:"storage_quota_grain" yaml:"storage_quota_grain"`
|
|
||||||
}
|
|
||||||
|
|
||||||
// MailService is the per-service struct stub for a Mail (messaging) service
|
|
||||||
// (vision §13, REQ-025). mail-id is the service identifier. holder-reach-id
|
|
||||||
// references an x/identity Reach by ID-string (G-003). mailbox-id is the
|
|
||||||
// opaque mailbox identifier.
|
|
||||||
type MailService struct {
|
|
||||||
MailID string `json:"mail_id" yaml:"mail_id"`
|
|
||||||
HolderReachID string `json:"holder_reach_id" yaml:"holder_reach_id"`
|
|
||||||
MailboxID string `json:"mailbox_id" yaml:"mailbox_id"`
|
|
||||||
}
|
|
||||||
|
|
||||||
// Params for the services module (skeleton — no tunables in v0.3).
|
|
||||||
type Params struct{}
|
|
||||||
|
|
||||||
// DefaultParams returns the zero-value Params (skeleton — no tunables).
|
|
||||||
func DefaultParams() Params { return Params{} }
|
|
||||||
|
|
||||||
// GenesisState defines the services module genesis state (REQ-025). The
|
|
||||||
// ServiceInfos slice holds the registry records. The per-service stub slices
|
|
||||||
// hold the service-specific metadata. ValidateGenesis enforces service-id
|
|
||||||
// uniqueness across the registry (A-212).
|
|
||||||
type GenesisState struct {
|
|
||||||
Params Params `json:"params" yaml:"params"`
|
|
||||||
ServiceInfos []ServiceInfo `json:"service_infos" yaml:"service_infos"`
|
|
||||||
CareServices []CareService `json:"care_services" yaml:"care_services"`
|
|
||||||
SIMServices []SIMService `json:"sim_services" yaml:"sim_services"`
|
|
||||||
VaultServices []VaultService `json:"vault_services" yaml:"vault_services"`
|
|
||||||
MailServices []MailService `json:"mail_services" yaml:"mail_services"`
|
|
||||||
}
|
|
||||||
|
|
||||||
// DefaultGenesisState returns an empty genesis state with non-nil slices.
|
|
||||||
func DefaultGenesisState() *GenesisState {
|
|
||||||
return &GenesisState{
|
|
||||||
Params: DefaultParams(),
|
|
||||||
ServiceInfos: []ServiceInfo{},
|
|
||||||
CareServices: []CareService{},
|
|
||||||
SIMServices: []SIMService{},
|
|
||||||
VaultServices: []VaultService{},
|
|
||||||
MailServices: []MailService{},
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// ValidateGenesis performs ID-uniqueness checks (A-212 upgrade from v0.1
|
|
||||||
// no-op): rejects duplicate or empty service-ids in the registry, and unknown
|
|
||||||
// ServiceKind / ServiceStatus values.
|
|
||||||
func ValidateGenesis(bz json.RawMessage) error {
|
|
||||||
var gs GenesisState
|
|
||||||
if err := json.Unmarshal(bz, &gs); err != nil {
|
|
||||||
return fmt.Errorf("services: invalid genesis: %w", err)
|
|
||||||
}
|
|
||||||
if err := validateServiceInfos(gs.ServiceInfos); err != nil {
|
|
||||||
return fmt.Errorf("services: %w", err)
|
|
||||||
}
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// validateServiceInfos enforces service-id presence and uniqueness, and that
|
|
||||||
// each Kind/Status is a known enum value.
|
|
||||||
func validateServiceInfos(infos []ServiceInfo) error {
|
|
||||||
seen := make(map[string]bool, len(infos))
|
|
||||||
for i, s := range infos {
|
|
||||||
if s.ServiceID == "" {
|
|
||||||
return fmt.Errorf("service info [%d]: empty service-id", i)
|
|
||||||
}
|
|
||||||
if seen[s.ServiceID] {
|
|
||||||
return fmt.Errorf("service info: duplicate service-id %q", s.ServiceID)
|
|
||||||
}
|
|
||||||
seen[s.ServiceID] = true
|
|
||||||
if !knownServiceKind(s.Kind) {
|
|
||||||
return fmt.Errorf("service %q: unknown service kind %q", s.ServiceID, s.Kind)
|
|
||||||
}
|
|
||||||
if !knownServiceStatus(s.Status) {
|
|
||||||
return fmt.Errorf("service %q: unknown service status %q", s.ServiceID, s.Status)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// knownServiceKind reports whether k is one of the four ServiceKind values.
|
|
||||||
func knownServiceKind(k ServiceKind) bool {
|
|
||||||
for _, kk := range AllServiceKinds() {
|
|
||||||
if k == kk {
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
|
|
||||||
// knownServiceStatus reports whether s is one of the four ServiceStatus values.
|
|
||||||
func knownServiceStatus(s ServiceStatus) bool {
|
|
||||||
switch s {
|
|
||||||
case ServicePending, ServiceActive, ServiceSuspended, ServiceRevoked:
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
@@ -1,369 +0,0 @@
|
|||||||
package types_test
|
|
||||||
|
|
||||||
import (
|
|
||||||
"encoding/json"
|
|
||||||
"os"
|
|
||||||
"path/filepath"
|
|
||||||
"runtime"
|
|
||||||
"strings"
|
|
||||||
"testing"
|
|
||||||
|
|
||||||
"github.com/oy/openyield/lexicon"
|
|
||||||
stypes "github.com/oy/openyield/x/services/types"
|
|
||||||
)
|
|
||||||
|
|
||||||
// --- ServiceKind enum coverage (4) --------------------------------------------
|
|
||||||
|
|
||||||
// TestServiceKindCountLockedConst asserts ServiceKindCount == 4 and
|
|
||||||
// AllServiceKinds() returns exactly 4 (REQ-025, A-307). A regression firewall.
|
|
||||||
func TestServiceKindCountLockedConst(t *testing.T) {
|
|
||||||
if stypes.ServiceKindCount != 4 {
|
|
||||||
t.Errorf("ServiceKindCount = %d, expected 4 (REQ-025 LOCKED)", stypes.ServiceKindCount)
|
|
||||||
}
|
|
||||||
all := stypes.AllServiceKinds()
|
|
||||||
if len(all) != 4 {
|
|
||||||
t.Errorf("AllServiceKinds() len = %d, expected 4", len(all))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// TestAllServiceKindsNames asserts the 4 REQ-025 names in order with no extras,
|
|
||||||
// no dups, no renames.
|
|
||||||
func TestAllServiceKindsNames(t *testing.T) {
|
|
||||||
want := []string{"Care", "SIM", "Vault", "Mail"}
|
|
||||||
all := stypes.AllServiceKinds()
|
|
||||||
if len(all) != len(want) {
|
|
||||||
t.Fatalf("len = %d, want %d", len(all), len(want))
|
|
||||||
}
|
|
||||||
seen := map[string]bool{}
|
|
||||||
for i, k := range all {
|
|
||||||
if string(k) != want[i] {
|
|
||||||
t.Errorf("AllServiceKinds()[%d] = %q, want %q", i, k, want[i])
|
|
||||||
}
|
|
||||||
if seen[string(k)] {
|
|
||||||
t.Errorf("duplicate ServiceKind %q", k)
|
|
||||||
}
|
|
||||||
seen[string(k)] = true
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// TestServiceKindValues asserts each named const matches its AllServiceKinds
|
|
||||||
// entry.
|
|
||||||
func TestServiceKindValues(t *testing.T) {
|
|
||||||
if stypes.KindCare != "Care" {
|
|
||||||
t.Errorf("KindCare = %q", stypes.KindCare)
|
|
||||||
}
|
|
||||||
if stypes.KindSIM != "SIM" {
|
|
||||||
t.Errorf("KindSIM = %q", stypes.KindSIM)
|
|
||||||
}
|
|
||||||
if stypes.KindVault != "Vault" {
|
|
||||||
t.Errorf("KindVault = %q", stypes.KindVault)
|
|
||||||
}
|
|
||||||
if stypes.KindMail != "Mail" {
|
|
||||||
t.Errorf("KindMail = %q", stypes.KindMail)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// --- ServiceStatus enum coverage (4) ------------------------------------------
|
|
||||||
|
|
||||||
// TestServiceStatusCountLockedConst asserts ServiceStatusCount == 4.
|
|
||||||
func TestServiceStatusCountLockedConst(t *testing.T) {
|
|
||||||
if stypes.ServiceStatusCount != 4 {
|
|
||||||
t.Errorf("ServiceStatusCount = %d, expected 4", stypes.ServiceStatusCount)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// TestServiceStatusValues asserts the four ServiceStatus named consts.
|
|
||||||
func TestServiceStatusValues(t *testing.T) {
|
|
||||||
if stypes.ServicePending != "Pending" {
|
|
||||||
t.Errorf("ServicePending = %q", stypes.ServicePending)
|
|
||||||
}
|
|
||||||
if stypes.ServiceActive != "Active" {
|
|
||||||
t.Errorf("ServiceActive = %q", stypes.ServiceActive)
|
|
||||||
}
|
|
||||||
if stypes.ServiceSuspended != "Suspended" {
|
|
||||||
t.Errorf("ServiceSuspended = %q", stypes.ServiceSuspended)
|
|
||||||
}
|
|
||||||
if stypes.ServiceRevoked != "Revoked" {
|
|
||||||
t.Errorf("ServiceRevoked = %q", stypes.ServiceRevoked)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// --- ServiceInfo struct + by-ID-string refs (G-003, A-307) --------------------
|
|
||||||
|
|
||||||
// TestServiceInfoStructFields asserts ServiceInfo carries service-id, kind,
|
|
||||||
// operator-reach-id, name, status, window-id.
|
|
||||||
func TestServiceInfoStructFields(t *testing.T) {
|
|
||||||
s := stypes.ServiceInfo{
|
|
||||||
ServiceID: "svc-1",
|
|
||||||
Kind: stypes.KindCare,
|
|
||||||
OperatorReachID: "reach-holder-1",
|
|
||||||
Name: "Care Service",
|
|
||||||
Status: stypes.ServiceActive,
|
|
||||||
WindowID: "window-1",
|
|
||||||
}
|
|
||||||
if s.ServiceID != "svc-1" || s.Kind != stypes.KindCare || s.OperatorReachID != "reach-holder-1" ||
|
|
||||||
s.Name != "Care Service" || s.Status != stypes.ServiceActive || s.WindowID != "window-1" {
|
|
||||||
t.Error("ServiceInfo fields not set correctly")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// TestServiceInfoWindowIDIsString asserts window-id is string-typed (A-307
|
|
||||||
// by-ID-string ref to x/window — G-003, no struct import). This is the
|
|
||||||
// window-id by-ID-string ref presence test mandated by the P5 task spec.
|
|
||||||
func TestServiceInfoWindowIDIsString(t *testing.T) {
|
|
||||||
s := stypes.ServiceInfo{WindowID: "window-abc"}
|
|
||||||
if s.WindowID != "window-abc" {
|
|
||||||
t.Errorf("WindowID = %q", s.WindowID)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// TestServiceInfoOperatorReachIDIsString asserts operator-reach-id is
|
|
||||||
// string-typed (G-003 by-ID-string ref to x/identity Reach — no struct import).
|
|
||||||
func TestServiceInfoOperatorReachIDIsString(t *testing.T) {
|
|
||||||
s := stypes.ServiceInfo{OperatorReachID: "reach-xyz"}
|
|
||||||
if s.OperatorReachID != "reach-xyz" {
|
|
||||||
t.Errorf("OperatorReachID = %q", s.OperatorReachID)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// --- Per-service struct stubs --------------------------------------------------
|
|
||||||
|
|
||||||
// TestCareServiceStructFields asserts CareService carries care-id, care-kind.
|
|
||||||
func TestCareServiceStructFields(t *testing.T) {
|
|
||||||
c := stypes.CareService{CareID: "care-1", CareKind: "mutual-aid"}
|
|
||||||
if c.CareID != "care-1" || c.CareKind != "mutual-aid" {
|
|
||||||
t.Error("CareService fields not set correctly")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// TestSIMServiceStructFields asserts SIMService carries sim-id, carrier.
|
|
||||||
func TestSIMServiceStructFields(t *testing.T) {
|
|
||||||
s := stypes.SIMService{SIMID: "sim-1", Carrier: "oy-mobile"}
|
|
||||||
if s.SIMID != "sim-1" || s.Carrier != "oy-mobile" {
|
|
||||||
t.Error("SIMService fields not set correctly")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// TestVaultServiceStructFields asserts VaultService carries vault-id,
|
|
||||||
// holder-reach-id (by-ID-string ref to x/identity — G-003), storage-quota-grain.
|
|
||||||
func TestVaultServiceStructFields(t *testing.T) {
|
|
||||||
v := stypes.VaultService{
|
|
||||||
VaultID: "vault-1",
|
|
||||||
HolderReachID: "reach-holder-1",
|
|
||||||
StorageQuotaGrain: 1_000_000,
|
|
||||||
}
|
|
||||||
if v.VaultID != "vault-1" || v.HolderReachID != "reach-holder-1" || v.StorageQuotaGrain != 1_000_000 {
|
|
||||||
t.Error("VaultService fields not set correctly")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// TestVaultServiceHolderReachIDIsString asserts holder-reach-id is string-typed
|
|
||||||
// (G-003 by-ID-string ref to x/identity Reach — no struct import).
|
|
||||||
func TestVaultServiceHolderReachIDIsString(t *testing.T) {
|
|
||||||
v := stypes.VaultService{HolderReachID: "reach-abc"}
|
|
||||||
if v.HolderReachID != "reach-abc" {
|
|
||||||
t.Errorf("HolderReachID = %q", v.HolderReachID)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// TestMailServiceStructFields asserts MailService carries mail-id,
|
|
||||||
// holder-reach-id (by-ID-string ref to x/identity — G-003), mailbox-id.
|
|
||||||
func TestMailServiceStructFields(t *testing.T) {
|
|
||||||
m := stypes.MailService{
|
|
||||||
MailID: "mail-1",
|
|
||||||
HolderReachID: "reach-holder-1",
|
|
||||||
MailboxID: "mbox-1",
|
|
||||||
}
|
|
||||||
if m.MailID != "mail-1" || m.HolderReachID != "reach-holder-1" || m.MailboxID != "mbox-1" {
|
|
||||||
t.Error("MailService fields not set correctly")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// --- Module consts + Params ----------------------------------------------------
|
|
||||||
|
|
||||||
// TestModuleConsts asserts the four Cosmos-convention module consts.
|
|
||||||
func TestModuleConsts(t *testing.T) {
|
|
||||||
if stypes.ModuleName != "services" {
|
|
||||||
t.Errorf("ModuleName = %q", stypes.ModuleName)
|
|
||||||
}
|
|
||||||
if stypes.StoreKey != "services" {
|
|
||||||
t.Errorf("StoreKey = %q", stypes.StoreKey)
|
|
||||||
}
|
|
||||||
if stypes.RouterKey != "services" {
|
|
||||||
t.Errorf("RouterKey = %q", stypes.RouterKey)
|
|
||||||
}
|
|
||||||
if stypes.QuerierRoute != "services" {
|
|
||||||
t.Errorf("QuerierRoute = %q", stypes.QuerierRoute)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// TestDefaultParams asserts DefaultParams returns a zero-value Params.
|
|
||||||
func TestDefaultParams(t *testing.T) {
|
|
||||||
_ = stypes.DefaultParams() // no panics
|
|
||||||
}
|
|
||||||
|
|
||||||
// --- Genesis -------------------------------------------------------------------
|
|
||||||
|
|
||||||
// TestDefaultGenesisStateEmpty asserts DefaultGenesisState returns non-nil
|
|
||||||
// empty slices for all five sets.
|
|
||||||
func TestDefaultGenesisStateEmpty(t *testing.T) {
|
|
||||||
gs := stypes.DefaultGenesisState()
|
|
||||||
if gs == nil {
|
|
||||||
t.Fatal("DefaultGenesisState returned nil")
|
|
||||||
}
|
|
||||||
if gs.ServiceInfos == nil || len(gs.ServiceInfos) != 0 {
|
|
||||||
t.Errorf("Default ServiceInfos should be non-nil empty slice; got len=%d nil=%v", len(gs.ServiceInfos), gs.ServiceInfos == nil)
|
|
||||||
}
|
|
||||||
if gs.CareServices == nil || len(gs.CareServices) != 0 {
|
|
||||||
t.Errorf("Default CareServices should be non-nil empty slice; got len=%d nil=%v", len(gs.CareServices), gs.CareServices == nil)
|
|
||||||
}
|
|
||||||
if gs.SIMServices == nil || len(gs.SIMServices) != 0 {
|
|
||||||
t.Errorf("Default SIMServices should be non-nil empty slice; got len=%d nil=%v", len(gs.SIMServices), gs.SIMServices == nil)
|
|
||||||
}
|
|
||||||
if gs.VaultServices == nil || len(gs.VaultServices) != 0 {
|
|
||||||
t.Errorf("Default VaultServices should be non-nil empty slice; got len=%d nil=%v", len(gs.VaultServices), gs.VaultServices == nil)
|
|
||||||
}
|
|
||||||
if gs.MailServices == nil || len(gs.MailServices) != 0 {
|
|
||||||
t.Errorf("Default MailServices should be non-nil empty slice; got len=%d nil=%v", len(gs.MailServices), gs.MailServices == nil)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// TestValidateGenesisRejectsDupServiceIDs asserts A-212: duplicate service-ids
|
|
||||||
// are rejected.
|
|
||||||
func TestValidateGenesisRejectsDupServiceIDs(t *testing.T) {
|
|
||||||
gs := stypes.GenesisState{
|
|
||||||
ServiceInfos: []stypes.ServiceInfo{
|
|
||||||
{ServiceID: "s1", Kind: stypes.KindCare, Status: stypes.ServiceActive},
|
|
||||||
{ServiceID: "s1", Kind: stypes.KindSIM, Status: stypes.ServiceActive}, // dup
|
|
||||||
},
|
|
||||||
}
|
|
||||||
bz, _ := json.Marshal(gs)
|
|
||||||
if err := stypes.ValidateGenesis(bz); err == nil {
|
|
||||||
t.Error("ValidateGenesis should reject duplicate service-ids")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// TestValidateGenesisRejectsEmptyServiceID asserts empty service-id is rejected.
|
|
||||||
func TestValidateGenesisRejectsEmptyServiceID(t *testing.T) {
|
|
||||||
gs := stypes.GenesisState{
|
|
||||||
ServiceInfos: []stypes.ServiceInfo{{ServiceID: "", Kind: stypes.KindCare, Status: stypes.ServiceActive}},
|
|
||||||
}
|
|
||||||
bz, _ := json.Marshal(gs)
|
|
||||||
if err := stypes.ValidateGenesis(bz); err == nil {
|
|
||||||
t.Error("ValidateGenesis should reject empty service-id")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// TestValidateGenesisRejectsUnknownServiceKind asserts an unknown ServiceKind
|
|
||||||
// is rejected.
|
|
||||||
func TestValidateGenesisRejectsUnknownServiceKind(t *testing.T) {
|
|
||||||
gs := stypes.GenesisState{
|
|
||||||
ServiceInfos: []stypes.ServiceInfo{{ServiceID: "s1", Kind: stypes.ServiceKind("Bogus"), Status: stypes.ServiceActive}},
|
|
||||||
}
|
|
||||||
bz, _ := json.Marshal(gs)
|
|
||||||
if err := stypes.ValidateGenesis(bz); err == nil {
|
|
||||||
t.Error("ValidateGenesis should reject unknown service kind")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// TestValidateGenesisRejectsUnknownServiceStatus asserts an unknown
|
|
||||||
// ServiceStatus is rejected.
|
|
||||||
func TestValidateGenesisRejectsUnknownServiceStatus(t *testing.T) {
|
|
||||||
gs := stypes.GenesisState{
|
|
||||||
ServiceInfos: []stypes.ServiceInfo{{ServiceID: "s1", Kind: stypes.KindCare, Status: stypes.ServiceStatus("Bogus")}},
|
|
||||||
}
|
|
||||||
bz, _ := json.Marshal(gs)
|
|
||||||
if err := stypes.ValidateGenesis(bz); err == nil {
|
|
||||||
t.Error("ValidateGenesis should reject unknown service status")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// TestValidateGenesisRejectsBadJSON asserts malformed JSON is rejected.
|
|
||||||
func TestValidateGenesisRejectsBadJSON(t *testing.T) {
|
|
||||||
if err := stypes.ValidateGenesis(json.RawMessage(`{not json`)); err == nil {
|
|
||||||
t.Error("ValidateGenesis should reject malformed JSON")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// TestValidateGenesisAcceptsClean asserts a clean genesis validates.
|
|
||||||
func TestValidateGenesisAcceptsClean(t *testing.T) {
|
|
||||||
gs := stypes.GenesisState{
|
|
||||||
ServiceInfos: []stypes.ServiceInfo{
|
|
||||||
{ServiceID: "s1", Kind: stypes.KindCare, OperatorReachID: "r1", Name: "Care", Status: stypes.ServiceActive, WindowID: "w1"},
|
|
||||||
{ServiceID: "s2", Kind: stypes.KindMail, OperatorReachID: "r2", Name: "Mail", Status: stypes.ServicePending, WindowID: "w2"},
|
|
||||||
},
|
|
||||||
}
|
|
||||||
bz, _ := json.Marshal(gs)
|
|
||||||
if err := stypes.ValidateGenesis(bz); err != nil {
|
|
||||||
t.Errorf("ValidateGenesis should accept clean genesis, got: %v", err)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// --- Lexicon assertion (REQ-012) -------------------------------------------------
|
|
||||||
// The services module must avoid the banned Holder-identity term (use
|
|
||||||
// "operator-reach-id"/"holder-reach-id" not the banned term). "Mail"/"SIM"/
|
|
||||||
// "Care"/"Vault" are not banned. The lexicon helpers are used here — no
|
|
||||||
// banned literals are inlined in this test file.
|
|
||||||
|
|
||||||
// TestLexiconNoBannedTermsInServicesPackage scans every non-test .go file in
|
|
||||||
// the services/types package directory for the banned terms
|
|
||||||
// (case-insensitive). Production files only — the test file references banned
|
|
||||||
// terms via the lexicon package helpers (standard lexicon-test bootstrapping
|
|
||||||
// pattern).
|
|
||||||
func TestLexiconNoBannedTermsInServicesPackage(t *testing.T) {
|
|
||||||
pkgDir := packageDir(t, "github.com/oy/openyield/x/services/types")
|
|
||||||
files, err := filepath.Glob(filepath.Join(pkgDir, "*.go"))
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("glob: %v", err)
|
|
||||||
}
|
|
||||||
prodFiles := []string{}
|
|
||||||
for _, f := range files {
|
|
||||||
if strings.HasSuffix(f, "_test.go") {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
prodFiles = append(prodFiles, f)
|
|
||||||
}
|
|
||||||
if len(prodFiles) == 0 {
|
|
||||||
t.Fatal("no production .go files found in services/types")
|
|
||||||
}
|
|
||||||
for _, f := range prodFiles {
|
|
||||||
bz, err := os.ReadFile(f)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("read %s: %v", f, err)
|
|
||||||
}
|
|
||||||
if found, ok := lexicon.FindBannedTerm(string(bz)); ok {
|
|
||||||
t.Errorf("%s: banned term %q (REQ-012 lexicon firewall — use operator-reach-id not the banned Holder-identity term)", filepath.Base(f), found)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// TestLexiconNoBannedTermsInServicesTestFile asserts this test file itself does
|
|
||||||
// not contain any banned term as a literal (the firewall scans test files too;
|
|
||||||
// the lexicon helpers must be used rather than inlining banned terms).
|
|
||||||
func TestLexiconNoBannedTermsInServicesTestFile(t *testing.T) {
|
|
||||||
_, thisFile, _, ok := runtime.Caller(0)
|
|
||||||
if !ok {
|
|
||||||
t.Fatal("runtime.Caller failed")
|
|
||||||
}
|
|
||||||
bz, err := os.ReadFile(thisFile)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("read self: %v", err)
|
|
||||||
}
|
|
||||||
if found, ok := lexicon.FindBannedTerm(string(bz)); ok {
|
|
||||||
t.Fatalf("services test file contains banned term %q — use lexicon helpers, not literals", found)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// packageDir resolves a Go import path to its filesystem directory by walking
|
|
||||||
// up from this test file (v0.3 skeleton has zero external deps).
|
|
||||||
func packageDir(t *testing.T, importPath string) string {
|
|
||||||
t.Helper()
|
|
||||||
_, file, _, ok := runtime.Caller(0)
|
|
||||||
if !ok {
|
|
||||||
t.Fatal("runtime.Caller failed")
|
|
||||||
}
|
|
||||||
// file = .../oy/x/services/types/types_test.go -> repoRoot = .../oy (4 dirs up)
|
|
||||||
repoRoot := filepath.Dir(filepath.Dir(filepath.Dir(filepath.Dir(file))))
|
|
||||||
rel := strings.TrimPrefix(importPath, "github.com/oy/openyield/")
|
|
||||||
return filepath.Join(repoRoot, rel)
|
|
||||||
}
|
|
||||||
Reference in New Issue
Block a user