Compare commits
52 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 21926e8adb | |||
| d74515cd1d | |||
| a36561337a | |||
| 7fa5628dc2 | |||
| 3b7883c092 | |||
| 1969b96d3d | |||
| d149916288 | |||
| c4cbd59c11 | |||
| a6d33a58b3 | |||
| 7d1468b442 | |||
| 92966cb9c5 | |||
| 838bd06a9d | |||
| 57c7dc5ff5 | |||
| 2ca0e1aa4b | |||
| cc0940d9f8 | |||
| 0bb14bd1a1 | |||
| b55255614f | |||
| d88d2eaeb8 | |||
| 5e06b14ddf | |||
| 28d73c8b2c | |||
| 46c2c4ef6c | |||
| 0cac4b0b32 | |||
| 97a25dd0b6 | |||
| 82ae6cf5a2 | |||
| 47fa79148c | |||
| 74248dfbc1 | |||
| 289c499a6d | |||
| bc15516eea | |||
| 41344eb78b | |||
| 42641f8483 | |||
| 1beff09cff | |||
| db61fb1f6c | |||
| 82245f98f7 | |||
| e6a7634262 | |||
| 11b1585913 | |||
| b6f041b5af | |||
| c0bd9eedf5 | |||
| 8132f6ecd4 | |||
| 414dda8b3e | |||
| 85af56e23e | |||
| dfbaef6bc4 | |||
| 6b23409da2 | |||
| 141119f9ca | |||
| 99301f3ade | |||
| aaefde9295 | |||
| 99f68f903f | |||
| 8cc9cf26a0 | |||
| 899b76b572 | |||
| 40d878fd17 | |||
| 8c59bd48e0 | |||
| 79a72c48cf | |||
| edea9422ac |
@@ -1,8 +1,14 @@
|
||||
{
|
||||
"phase": 2,
|
||||
"stage": "execute",
|
||||
"milestone": "v0.1",
|
||||
"phase": 3,
|
||||
"stage": "complete",
|
||||
"milestone": "v0.4",
|
||||
"milestone_type": "nfr",
|
||||
"tag_base": "v0.3.x",
|
||||
"phase_role": "execution",
|
||||
"project": "oy",
|
||||
"attempts": 0,
|
||||
"updated_at": "2026-08-17T00:00:00Z"
|
||||
"updated_at": "2026-08-17T23:45:00Z",
|
||||
"phase_release_tag": "v0.3.3",
|
||||
"release_id": 751,
|
||||
"reqs_covered": ["REQ-029", "REQ-030", "REQ-031", "REQ-032"]
|
||||
}
|
||||
+29
-1
@@ -6,6 +6,9 @@
|
||||
}
|
||||
],
|
||||
"active_project": "oy",
|
||||
"milestone": "v0.4",
|
||||
"milestone_type": "nfr",
|
||||
"tag_base": "v0.3.x",
|
||||
"autonomy": {
|
||||
"level": "full",
|
||||
"escalation_hooks": ["deploy", "delete_data", "merge_to_main"],
|
||||
@@ -17,6 +20,31 @@
|
||||
"forge": "gitea",
|
||||
"base_url": "git.cloudinit.dev",
|
||||
"owner": "oy",
|
||||
"repo": "openyield"
|
||||
"repo": "openyield",
|
||||
"remote": "origin",
|
||||
"bootstrapped": true
|
||||
},
|
||||
"secrets": {
|
||||
"scopes": {
|
||||
"gitea": ["GITEA_TOKEN"]
|
||||
},
|
||||
"env_file": ".ciagent/.env.secrets"
|
||||
},
|
||||
"ship": {
|
||||
"per_phase": true,
|
||||
"allow_skip": false,
|
||||
"release_blocking": false,
|
||||
"max_release_retries": 3
|
||||
},
|
||||
"personas": {
|
||||
"enabled": true,
|
||||
"territory_enforcement": "warn"
|
||||
},
|
||||
"sessions": {
|
||||
"session_isolation": "branch"
|
||||
},
|
||||
"parallelization": {
|
||||
"enabled": false,
|
||||
"max_concurrent_agents": 1
|
||||
}
|
||||
}
|
||||
|
||||
@@ -58,3 +58,205 @@ Fee Covenant (13) blocks {Pacts (8), Orgs (10), Partners (11), Bearers (12)}
|
||||
- This index file
|
||||
- Persona assessment (created during RESEARCH stage)
|
||||
- Phase plans (created during PLAN stage)
|
||||
|
||||
---
|
||||
|
||||
## v0.3 Architecture (Bearers & Documentation)
|
||||
|
||||
This section appends the v0.3 component map to the v0.1/v0.2 index above. It does
|
||||
NOT rewrite or supersede the earlier content; the Phase 1/2/3 columns in the
|
||||
component index above describe the *full* runtime target, while the v0.3 columns
|
||||
below describe the *v0.3 skeleton+tests* deliverable (D-020 pattern continued,
|
||||
D-035) plus the documentation deliverable (D-042).
|
||||
|
||||
### v0.3 Component Index (new + extended modules)
|
||||
|
||||
| # | Component | Vision § | v0.3 Module | New/Ext | Phase | v0.3 Skeleton Depth |
|
||||
|---|---|---|---|---|---|---|
|
||||
| 2 | Cross-Chain & Exit (Layer 3) — DEX swaps | §7 | `x/exit` | New | P4 | ExitRoute + DEXSwap types, ExitStatus enum |
|
||||
| 2 | Cross-Chain & Exit (Layer 3) — L2↔L1 bridges | §7 | `x/bridge` | New | P4 | BridgeRoute + BridgeStatus enum; references x/satellite L2Chain by ID (G-003) |
|
||||
| 12 | Bearers expansion (OY-SAT + OY-QR) | §14 | `x/bearers` | Extended | P4 | OYSATLink + OYQRCode transport types (BearerTransport impls); BearerType enum already complete from v0.2 |
|
||||
| 11 | Anchors (institutional Partner tier) | §13 | `x/partner` | Extended | P4 | AnchorCredential struct fields on the Anchor tier (REQ-018 enum unchanged); ListByTier(Anchor) round-trip |
|
||||
| 8 | Hub API (Pact #6 expanded) | §13, §16 | `x/hub` | New | P5 | HubService enum (Custody/LendingPrimitive/Compliance) + per-service struct stubs + keeper stub |
|
||||
| — | Services (Care/SIM/Vault/Mail) | §13 | `x/services` | New | P5 | ServiceKind enum (4) + per-service struct stubs + keeper stub |
|
||||
| 8 | Bond market depth (Growth Bonds + secondary) | §17 | `x/bond` | Extended | P5 | GrowthBond struct + SecondaryOrder types; 8%/0% consts (D-028) unchanged; Clamp reused |
|
||||
|
||||
> The Hub API is Pact #6 (Hub-API) per REQ-020/D-027. v0.2 stubbed it as a PactType
|
||||
> enum value inside `x/pact`; v0.3 promotes it to its own `x/hub` module for the
|
||||
> B2B type scaffold (D-039). The `x/pact` HubAPI enum value stays as a
|
||||
> cross-reference; `x/hub` owns the service-shape types.
|
||||
|
||||
### v0.3 Cross-Component Dependencies (within v0.3)
|
||||
|
||||
Per the v0.2 G-003 invariant (by-ID-string inter-module references; no struct
|
||||
imports across `x/<module>/types`), v0.3 components reference each other and the
|
||||
v0.2 baseline by ID string only. The dependency edges that affect v0.3 phase
|
||||
ordering:
|
||||
|
||||
```
|
||||
x/bridge ──(L2Chain by id)──► x/satellite (v0.2 baseline; ref only, no struct import)
|
||||
x/exit ──(BridgeRoute by id)──► x/bridge (P4: exit references bridge routes)
|
||||
x/hub ──(Anchor by id)──► x/partner (P5: Hub custody/compliance references Anchor partners)
|
||||
x/bond ──(Stand by id)──► x/stand (v0.2 baseline; GrowthBond issuer-stand-id, unchanged)
|
||||
x/services ──(Window by id)──► x/window (v0.2 baseline; service-grant references a Window)
|
||||
x/bearers ──(BearerTransport)──► (none; OY-SAT/OY-QR are transport stubs, no new deps)
|
||||
```
|
||||
|
||||
**Phase-ordering implication (informs D-044):** `x/exit` references `x/bridge`
|
||||
routes, so both must land in the same phase (P4) and `x/bridge` types must exist
|
||||
before `x/exit` tests that reference a BridgeRoute. `x/hub` references Anchor
|
||||
partner-ids, so `x/partner` Anchor extension (P4) must precede `x/hub` (P5). This
|
||||
confirms the D-044 P4→P5 split: P4 = exit/bridge/bearers/partner-Anchor,
|
||||
P5 = hub/services/bond. Reversing P4/P5 would force `x/hub` to reference an Anchor
|
||||
tier that does not yet exist.
|
||||
|
||||
### v0.3 Interface Contracts (6 cross-component — unchanged from v0.2)
|
||||
|
||||
The six cross-component interfaces (Standing API, Forge/Fold, Watcher Attestation,
|
||||
Window Lifecycle, Fee Covenant, Voice/Council) are NOT extended in v0.3 — v0.3
|
||||
adds *type scaffolds* that will *consume* them at runtime in v0.4+:
|
||||
|
||||
- **Window Lifecycle Interface** — `x/services` service-grants reference a Window
|
||||
by ID (the service opens a Window on the holder's behalf). Skeleton only.
|
||||
- **Fee Covenant Interface** — `x/bridge`/`x/exit` exit routes carry an
|
||||
`exit-fee-bps` field clamped by the Fee Covenant ceiling/floor (the field is
|
||||
typed in v0.3; the Clamp is NOT invoked in the skeleton — deferred to v0.4
|
||||
runtime to avoid cross-module calls in the skeleton layer).
|
||||
- **Standing API** — `x/hub` compliance service stub references a partner's
|
||||
Standing by reach-id (skeleton: by-ID-string field, no query).
|
||||
- **Watcher Attestation** — `x/bridge` BridgeStatus has an `Attested` state; the
|
||||
attestation itself is not modeled in v0.3 (Watchers are v0.1 baseline; the
|
||||
bridge references a Watcher quorum by ID at runtime, deferred to v0.4).
|
||||
|
||||
### Documentation Architecture (v0.3 deliverable B)
|
||||
|
||||
v0.3 introduces a documentation deliverable alongside the Bearers skeleton. This
|
||||
is a NEW architecture surface (no docs site existed in v0.1/v0.2).
|
||||
|
||||
**Layout:**
|
||||
```
|
||||
oy/
|
||||
├── README.md # repo-root project overview (lexicon-clean)
|
||||
├── mkdocs.yml # MkDocs Material config (site_name, nav, theme)
|
||||
└── docs/
|
||||
├── nomads/ # audience: nomads (Reach path, Stash, bearers, Maps/Pay, Pacts, standing basics)
|
||||
├── freeholders/ # audience: freeholders (4 signals, Bayesian Standing, Stands/Guilds, Councils/Voice, Bonds, Partner spectrum)
|
||||
├── shared/ # cross-audience (Six Principles, Bread Scale, Storage pools, Watchers/Mirror, Lexicon glossary, Vision overview)
|
||||
└── reference/ # architecture index + component map
|
||||
```
|
||||
|
||||
**mkdocs.yml (minimal config):** `site_name: OpenYield`, `theme: readthedocs` or
|
||||
`theme: material` (D-042 chose Material), `nav:` with the four audience
|
||||
sections, `markdown_extensions: [admonition, toc, pymdownx.superfences]`. Build-
|
||||
only Python dep (`mkdocs` + `mkdocs-material`); `go.mod` stays zero-dep (G-006 —
|
||||
the docs toolchain is NOT a Go dependency). No publishing CI in v0.3 (D-046);
|
||||
README documents `mkdocs serve` / `mkdocs build`.
|
||||
|
||||
**Audience-organized nav (D-042, D-045):** nomads 5-8 pages, freeholders 5-8
|
||||
pages, shared 5-6 pages, reference 2 pages (~20-25 total). Pages map to REQs:
|
||||
nomads cover REQ-007/013/014/015/019/020; freeholders cover REQ-005/006/016/017/
|
||||
011/021/018; shared covers REQ-001/003/004/012; reference covers the architecture
|
||||
index.
|
||||
|
||||
**Lexicon-clean by construction (REQ-012 extension, D-043):** docs are user-
|
||||
facing and must be lexicon-clean. The highest-risk banned term in docs is
|
||||
"yield" (PROJECT.md uses "real yield" but docs must say "real production" / "real
|
||||
return" — the word-boundary regex in `lexicon.FindBannedTerm` bans standalone
|
||||
"yield" while allowing "OpenYield"). Other high-risk terms in docs: "account"
|
||||
(use "Holder"/"Reach"), "bank"/"deposit"/"savings" (use "Stash"/"Vault"/
|
||||
"Root-Pool"). The firewall lands in P1 BEFORE content (P2/P3) so docs are checked
|
||||
as authored (D-044 firewall-first ordering).
|
||||
|
||||
**Firewall extension (D-043):** a NEW sibling test `lexicon_meta_docs_test.go`
|
||||
(package `lexicon_meta_docs`) mirrors `lexicon_meta_test.go` (package
|
||||
`lexicon_meta`) exactly — same `lexicon.FindBannedTerm`, same word-boundary
|
||||
regex, same fragment-assembled self-test table, same self-exclusion of the meta-
|
||||
test file — but scans `README.md` + `docs/**/*.md` instead of `x/**/*.go`. The
|
||||
existing `lexicon_meta_test.go` is NOT modified (preserves v0.2 coverage). The
|
||||
new meta-test walks the repo root for `README.md` + the `docs/` tree, excludes
|
||||
`.ciagent/` and `.git/` (firewall meta-files are not user-facing docs), and
|
||||
excludes itself. Per-package lexicon assertions in the new `x/*` modules follow
|
||||
the v0.2 pattern (`TestLexiconNoBannedTermsIn<Module>Package` scanning the
|
||||
module's production `.go` files).
|
||||
|
||||
> The `.ciagent/` directory holds firewall META-files (PROJECT.md, RESEARCH.md,
|
||||
> this file) that discuss the banned terms by name for governance reasons — they
|
||||
> are NOT user-facing docs and are explicitly excluded from the docs firewall
|
||||
> scan. This mirrors how `lexicon_meta_test.go` excludes itself: the firewall's
|
||||
> own code is allowed to name the terms it bans.
|
||||
|
||||
## v0.4 Architecture (Refinement — NFR)
|
||||
|
||||
v0.4 is a refinement-only NFR milestone (D-047): zero `feat:` phases, zero new
|
||||
production types, zero behavioral changes. It lands durability fixes sourced
|
||||
from v0.3 forward-references. Tags run on the `v0.3.x` patch line.
|
||||
|
||||
### v0.4 Research Findings
|
||||
|
||||
**R-029 — Lexicon firewall shared helper (REQ-029, GRILL G-014).**
|
||||
|
||||
Verified during v0.4 RESEARCH: `lexicon_meta_test.go` (`TestLexiconMetaSelfTestTable`, lines 83-118) and `lexicon_meta_docs/lexicon_meta_docs_test.go` (`TestLexiconMetaDocsSelfTestTable`, lines 147-182) contain byte-identical duplicate synthetic self-test tables — both build the same 10-string slice by indexing `lexicon.BannedTerms()`. This is exactly the G-014 drift risk: if a future banned-term addition updates one table and not the other, the docs firewall silently loses coverage. The fix is a new `lexicon.SyntheticBannedStrings() []string` helper in `lexicon/lexicon.go` that returns the 10 synthetic strings; both meta-tests consume it instead of building their own copy. The helper's source uses `lexicon.BannedTerms()` (already fragment-assembled) so the lexicon package's own source stays lexicon-clean. Both meta-tests already assert `len(terms) == 10` from `lexicon.BannedTerms()` (the G-014 minimum); the helper closes the drift fully. No behavioral change to detection (`FindBannedTerm` unchanged); refactor + test only.
|
||||
|
||||
**R-030 — Cross-package const-equality test (REQ-030, REVIEW P2 / A-304).**
|
||||
|
||||
Verified during v0.4 RESEARCH: `x/hub/types/types.go:51,56` defines LOCAL consts `LendingCouponCapBps = uint32(800)` and `LendingCouponFloorBps = uint32(0)`, cross-documented (comment lines 46-55) to `x/bond/types/types.go:21,26` consts `CouponCapBps = 800` and `CouponFloorBps = 0` (D-028 mission-locked). The cross-doc comment flags drift for human review but no automated check exists. The fix is a new test file `x/hub/types/cross_const_test.go` (package `types`) that imports `github.com/oy/openyield/x/bond/types` (test-only, G-003 exempt per the test-import exemption documented in v0.2 GRILL G-003) and asserts `hub.LendingCouponCapBps == bond.CouponCapBps` and `hub.LendingCouponFloorBps == bond.CouponFloorBps`. The test fails closed if either const drifts. No production import is added (G-003 production firewall intact); test-only import only.
|
||||
|
||||
**R-031 — Lifecycle type shape-divergence review (REQ-031, AUDIT §193).**
|
||||
|
||||
Verified during v0.4 RESEARCH: AUDIT §193 flags two P1 council divergences and one P2 bearers nit:
|
||||
- **P1-1**: `x/council/types` lacks `Proposal`/`ProposalStatus`/`VoteOption` enums (AUDIT says add "in v0.3 when wiring the council keeper to a live governance runtime"). Adding these is a `feat:`-class addition (new enum types) → REJECTED by D-001 filter for v0.4. Deferred to v0.5+ governance runtime.
|
||||
- **P1-2**: `SignalKind` has 4 sources (Stash/Standing/Vouch/Capital) vs spec's `VoiceSource` 5 sources (Stash/Standing/Vouch/Freeholder/Guild). AUDIT code rationale: Freeholder is an eligibility property (upstream in `x/standing`), Guild is a council tier, Capital is committed-capital (vision §9.1) — defensible refinement. Changing `SignalKindCount` 4→5 is a locked-const change → REJECTED by D-001 filter for v0.4.
|
||||
- **P2**: `x/bearers/types` `ValidateGenesis` no-op is CORRECT per spec (AUDIT explicitly notes "no action").
|
||||
|
||||
v0.4 REQ-031 scope (D-050): DOCUMENT the divergence decisions in this ARCHITECTURE.md section + add a regression-guard test asserting the current `SignalKindCount==4` shape is intentional (an intent-assertion test, not a shape change). No enum additions, no locked-const changes. The existing `TestSignalKindCountLockedConst` in `x/council/types/types_test.go:102` already asserts the count; REQ-031 adds an intent comment + a test documenting WHY the shape is 4-not-5 (the AUDIT rationale), so a future agent does not "fix" the divergence by silently changing the locked const.
|
||||
|
||||
**R-032 — Docs build CI (REQ-032, D-046).**
|
||||
|
||||
Verified during v0.4 RESEARCH: no `.github/workflows/` directory exists; Gitea Actions uses `.gitea/workflows/`. `mkdocs.yml` is present at repo root (buildable locally via `mkdocs build`). v0.4 REQ-032 ships a `.gitea/workflows/docs-build.yml` workflow that: (1) runs `go test ./...` (the lexicon firewall + all x/* tests) on push; (2) installs mkdocs + mkdocs-material (build-only Python deps in a separate job/step — does NOT touch `go.mod`, G-006 intact); (3) runs `mkdocs build` to produce `site/`; (4) uploads `site/` as a CI artifact. Full Gitea Pages publishing is DEFERRED (no hosting target configured in v0.4 per D-051). The workflow file is `chore` (CI config), not `feat:` — passes the D-001 filter. The workflow runs on every push to any branch (not just main) so the lexicon firewall + docs build are checked on every change.
|
||||
|
||||
### v0.4 Component Map (no new modules)
|
||||
|
||||
v0.4 touches NO new `x/*` modules. The touched files are:
|
||||
- `lexicon/lexicon.go` (add `SyntheticBannedStrings()`) — REQ-029
|
||||
- `lexicon_meta_test.go` (refactor to consume helper) — REQ-029
|
||||
- `lexicon_meta_docs/lexicon_meta_docs_test.go` (refactor to consume helper) — REQ-029
|
||||
- `x/hub/types/cross_const_test.go` (NEW test file) — REQ-030
|
||||
- `x/council/types/types_test.go` (add intent-assertion test + comment) — REQ-031
|
||||
- `.ciagent/oy/ARCHITECTURE.md` (this section) — REQ-031
|
||||
- `.gitea/workflows/docs-build.yml` (NEW CI workflow) — REQ-032
|
||||
|
||||
### v0.4 Interface Contracts (unchanged from v0.3)
|
||||
|
||||
v0.4 does not change any cross-component interface. The 6 cross-component interfaces (Standing, Forge/Fold, Mirror, Window, Fee Covenant, Voice/Council) are unchanged. REQ-031 documents a divergence in the Voice/Council interface surface (SignalKind shape) but does not change it.
|
||||
|
||||
### Council Voice/Council Interface — Lifecycle Type Divergence Decisions (v0.4, REQ-031)
|
||||
|
||||
This section documents the lifecycle type shape-divergences flagged by AUDIT.md §193 for the Council/Voice interface surface. v0.4 is a refinement-only NFR milestone (D-047): the D-001 filter REJECTS `feat:`-class enum additions and locked-const shape changes, so these divergences are DOCUMENTED here, not fixed in code. A regression-guard test (`TestSignalKindShapeIntentional` in `x/council/types/types_test.go`) locks the current shape so a future agent does not silently "fix" a divergence by changing a locked const.
|
||||
|
||||
**Divergence P1-1 (AUDIT §193): `Proposal`/`ProposalStatus`/`VoteOption` enums absent from `x/council/types`.**
|
||||
|
||||
- **Spec source**: P3-01-01 deliverable recommended `Proposal`, `ProposalStatus` (5 states), `VoteOption` (3 options) enums mirroring OZ Governor / `x/gov`.
|
||||
- **Implemented**: `Council`, `CouncilMember`, `Voice`, `SignalKind`, `TallyResult` — no `Proposal`/`ProposalStatus`/`VoteOption` lifecycle types.
|
||||
- **Must-have impact**: NONE. The v0.2 P3 must-haves (3 councils, Mission Lock, `TallyResult` x/gov shape, no veto) are all met without the Proposal lifecycle.
|
||||
- **Decision (v0.4, D-050)**: ADDING `Proposal`/`ProposalStatus`/`VoteOption` is a `feat:`-class addition (new enum types). REJECTED by the D-001 refinement-only filter. **Deferred to v0.5+** when the council keeper is wired to a live governance runtime (the AUDIT's own recommendation: "add in v0.3 when wiring the council keeper to a live governance runtime"). The skeleton council keeper in v0.2 does not consume a Proposal lifecycle; adding the types without the runtime would be dead code.
|
||||
- **Severity (AUDIT)**: P1 (spec drift from deliverable text, not a must-have, not blocking).
|
||||
- **v0.4 action**: DOCUMENT only (this section). No code change.
|
||||
|
||||
**Divergence P1-2 (AUDIT §193): `SignalKind` 4 sources vs spec `VoiceSource` 5 sources.**
|
||||
|
||||
- **Spec source**: P3-01-01 deliverable specified `VoiceSource` with 5 sources (Stash/Standing/Vouch/Freeholder/Guild).
|
||||
- **Implemented**: `SignalKind` with 4 sources: `SignalStash`, `SignalStanding`, `SignalVouch`, `SignalCapital` (`SignalKindCount = 4`, locked const).
|
||||
- **Code rationale (AUDIT §193 P1-2)**: the 4-source shape is a defensible design refinement, not a defect:
|
||||
- `Freeholder` is an ELIGIBILITY property (upstream in `x/standing`), not a voice signal. A Freeholder-eligible Reach is a precondition for voting, not a signal that feeds a vote's weight.
|
||||
- `Guild` is a COUNCIL TIER (one of the three councils is the Guild Council), not a voice signal. Including Guild as a signal kind would conflate the council tier with the signal source.
|
||||
- `Capital` is committed-capital (vision §9.1, one of the four Freeholder signals), which the spec's `VoiceSource` list omitted. Adding `Capital` corrects the spec list to match vision §9.1's four-signal definition (REQ-005: "Four Freeholder signals locked").
|
||||
- **Must-have impact**: NONE. The v0.2 P3 must-haves did not enumerate `VoiceSource` coverage; the 4-signal shape matches REQ-005's "Four Freeholder signals locked" exactly.
|
||||
- **Decision (v0.4, D-050)**: changing `SignalKindCount` 4→5 (to restore the spec's 5-source `VoiceSource`) is a LOCKED-CONST CHANGE. REJECTED by the D-001 refinement-only filter (changing a locked const is a behavioral change, not a refinement). The 4-source shape is the CORRECT shape per vision §9.1 and REQ-005; the spec deliverable text was wrong, not the implementation.
|
||||
- **Severity (AUDIT)**: P1 (design-choice divergence, tested and self-consistent, not blocking).
|
||||
- **v0.4 action**: DOCUMENT the rationale here + add `TestSignalKindShapeIntentional` (regression guard) so a future agent changing `SignalKindCount` from 4 to 5 must also update the intent-assertion test, surfacing the AUDIT rationale for review. No locked-const change.
|
||||
|
||||
**Divergence P2 (AUDIT §193): `x/bearers/types` `ValidateGenesis` no-op.**
|
||||
|
||||
- **Spec source**: P4-02-01 said "DefaultParams/GenesisState unchanged" (bearers is an EXTENSION in v0.2, not a new module; the A-212 `ValidateGenesis` upgrade was scoped to NEW modules only).
|
||||
- **Implemented**: `ValidateGenesis` remains a no-op (`x/bearers/types/types.go:108` returns `nil` unconditionally).
|
||||
- **Decision (v0.4)**: CORRECT per spec — no action (AUDIT explicitly notes "no action"). The A-212 upgrade applies to NEW modules (v0.2's `x/window`, `x/stand`, etc.), not to EXTENDED modules like `x/bearers`. Listed here for completeness; no code change, no test change.
|
||||
@@ -0,0 +1,515 @@
|
||||
# Audit: OpenYield (oy) — v0.2 (The Mesh) Final Phase
|
||||
|
||||
> **Auditor**: CIAgent security auditor (ci-auditor, read-only; critical-fix mode per run.md FINAL PHASE step 3)
|
||||
> **Date**: 2026-08-17
|
||||
> **Scope**: v0.2 milestone state on `oy/milestone/v0.2-mesh` (HEAD = `oy/phase/05-final-review-ship`)
|
||||
> **Milestone**: v0.2 — The Mesh (feature; tag_base `v0.1.x`)
|
||||
> **Mode**: multi-project (slug `oy`)
|
||||
> **Autonomy**: full
|
||||
|
||||
---
|
||||
|
||||
## 1. Per-Check Verdicts
|
||||
|
||||
### 1.1 Reconstruction Test — **PASS** (fixed)
|
||||
|
||||
**Git log matches `.ciagent/` files:**
|
||||
|
||||
`git log main..oy/milestone/v0.2-mesh --oneline` returns 5 commits, one per phase, in order:
|
||||
|
||||
```
|
||||
6304228 docs(P04): complete Bonds+Bearers+L2 phase → v0.1.4
|
||||
c7f7391 docs(P03): complete Councils+Forex phase → v0.1.3
|
||||
0fefd88 docs(P02): complete Pacts+Partners phase → v0.1.2
|
||||
93a8a3b docs(P01): complete Orgs+Window foundation phase → v0.1.1
|
||||
3e762f6 docs(P00): complete pre-execution phase → v0.1.0
|
||||
```
|
||||
|
||||
Each commit is a phase-ship commit (one commit per phase, squash-style) carrying a `---ci---` block.
|
||||
|
||||
**Per-phase `---ci---` block verification:**
|
||||
|
||||
| Phase | `project` | `milestone` | `status` | `phase` | `requirements.covered` | Verdict |
|
||||
|---|---|---|---|---|---|---|
|
||||
| P0 (3e762f6) | `oy` ✓ | `v0.2` ✓ | `complete` ✓ | `0` ✓ | REQ-009,011,015,016,017,018,020,021 ✓ | PASS |
|
||||
| P1 (93a8a3b) | `oy` ✓ | `v0.2` ✓ | `complete` ✓ | `1` ✓ | REQ-015,016,017,012 ✓ | PASS |
|
||||
| P2 (0fefd88) | `oy` ✓ | `v0.2` ✓ | `complete` ✓ | `2` ✓ | REQ-020,018 ✓ | PASS |
|
||||
| P3 (c7f7391) | `oy` ✓ | `v0.2` ✓ | `complete` ✓ | `3` ✓ | REQ-011 (partial REQ-009) ✓ | PASS |
|
||||
| P4 (6304228) | `oy` ✓ | `v0.2` ✓ | `complete` ✓ | `4` ✓ | REQ-021,009 ✓ | PASS |
|
||||
|
||||
All 5 ship commits carry a `---ci---` block with `project: oy`, `milestone: v0.2`, `status: complete`, and the correct `phase` integer + `requirements.covered` list. Multi-project mode discipline observed.
|
||||
|
||||
**Tags exist and map to the correct phase-ship commits:**
|
||||
|
||||
```
|
||||
v0.1.0 -> 3e762f6 (P00 ship) ✓
|
||||
v0.1.1 -> 93a8a3b (P01 ship) ✓
|
||||
v0.1.2 -> 0fefd88 (P02 ship) ✓
|
||||
v0.1.3 -> c7f7391 (P03 ship) ✓
|
||||
v0.1.4 -> 6304228 (P04 ship) ✓
|
||||
v0.1.5 -> ABSENT (correct — final phase's job to create)
|
||||
```
|
||||
|
||||
`git tag -l | grep v0.1` returns exactly `v0.1.0..v0.1.4`. The milestone release tag `v0.1.5` (= v0.2 milestone per D-008/D-020) is NOT yet present — correctly deferred to the final phase ship step.
|
||||
|
||||
**Milestone NOT yet released:** confirmed — no `v0.1.5` tag exists. The final phase (P5) is in progress (this audit is part of P5).
|
||||
|
||||
**Branch HEAD alignment:** `oy/milestone/v0.2-mesh` and `oy/phase/05-final-review-ship` both point at `63042285e8f27c0eb0dc5661d4d674b8244540fa` (the P04 ship commit) — the final-phase branch is correctly at the same HEAD as the milestone branch, ready for the P5 ship commit.
|
||||
|
||||
### 1.2 `.ciagent` File Discipline — **PASS**
|
||||
|
||||
**All 9 expected files present in `.ciagent/oy/`:**
|
||||
|
||||
```
|
||||
ARCHITECTURE.md ✓
|
||||
GRILL.md ✓
|
||||
PERSONAS.md ✓
|
||||
PROJECT.md ✓
|
||||
REQUIREMENTS.md ✓
|
||||
RESEARCH.md ✓
|
||||
REVIEW.md ✓
|
||||
ROADMAP.md ✓
|
||||
PLANS.md ✓
|
||||
```
|
||||
|
||||
(Also present: `P1_SHIP_VERIFICATION.md`..`P4_SHIP_VERIFICATION.md` — phase ship records, not part of the canonical 9 but consistent with the per-phase ship discipline.)
|
||||
|
||||
**CHECKPOINT.json — valid JSON, all required fields present:**
|
||||
|
||||
```json
|
||||
{
|
||||
"phase": 4,
|
||||
"stage": "execute",
|
||||
"milestone": "v0.2",
|
||||
"milestone_type": "feature",
|
||||
"tag_base": "v0.1.x",
|
||||
"phase_role": "execution",
|
||||
"project": "oy",
|
||||
"attempts": 0,
|
||||
"updated_at": "2026-08-17T21:50:00Z"
|
||||
}
|
||||
```
|
||||
|
||||
All 8 required fields present: `phase`, `stage`, `milestone`, `milestone_type`, `tag_base`, `phase_role`, `project`, `updated_at` ✓. Valid JSON (`python3 -m json.tool` clean). Note: `phase: 4` reflects the last-completed execution phase; the active P5 phase will bump this on ship.
|
||||
|
||||
**config.json — valid JSON, all required settings correct:**
|
||||
|
||||
| Setting | Required | Actual | Verdict |
|
||||
|---|---|---|---|
|
||||
| `milestone_type` | `feature` | `feature` ✓ | PASS |
|
||||
| `tag_base` | `v0.1.x` | `v0.1.x` ✓ | PASS |
|
||||
| `ship.per_phase` | `true` | `true` ✓ | PASS |
|
||||
| `ship.allow_skip` | `false` | `false` ✓ | PASS |
|
||||
| `active_project` | `oy` | `oy` ✓ | PASS |
|
||||
| `projects[]` length | >0 (multi-project) | 1 (`oy`) ✓ | PASS |
|
||||
|
||||
Valid JSON. Multi-project mode active (projects[].length=1).
|
||||
|
||||
### 1.3 Branch Hygiene — **PASS**
|
||||
|
||||
| Check | Result | Verdict |
|
||||
|---|---|---|
|
||||
| `main` exists | `289c499a6d82e41498d335f6c732d0d133c85a4b` (pre-v0.2) ✓ | PASS |
|
||||
| `main` is at v0.1 (pre-v0.2) | merge-base(main, milestone) == main ✓ | PASS |
|
||||
| `oy/milestone/v0.2-mesh` exists | local + remote `origin/oy/milestone/v0.2-mesh` ✓ | PASS |
|
||||
| `oy/milestone/v0.2-mesh` contains all P0-P4 work | 5 commits P0-P4 ✓ | PASS |
|
||||
| `oy/phase/05-final-review-ship` exists (current) | checked out, HEAD == milestone HEAD ✓ | PASS |
|
||||
| NO leftover execution phase branches | `git branch \| grep "oy/phase"` → only `oy/phase/05-final-review-ship` ✓ | PASS |
|
||||
|
||||
`git branch | grep "oy/phase"` returns exactly one line: `* oy/phase/05-final-review-ship`. The execution phase branches `oy/phase/01-orgs-window-foundation`, `oy/phase/02-pacts-partners`, `oy/phase/03-councils-forex`, `oy/phase/04-bonds-bearers-l2` are all correctly deleted after their respective phase ships. Only the final-phase branch remains (as expected — it is the active phase).
|
||||
|
||||
### 1.4 Commit Discipline — **PASS**
|
||||
|
||||
**Every commit on the milestone branch has a `---ci---` block with `project: oy`:**
|
||||
|
||||
All 5 commits (P0-P4) carry `---ci---` blocks. Verified `project: oy` present in each (see §1.1 table). Multi-project mode discipline observed.
|
||||
|
||||
**Phase ship commits have `status: complete` + `requirements: covered`:**
|
||||
|
||||
All 5 commits have `status: complete` ✓. All 5 have a `requirements:` block with a `covered:` list (see §1.1 table) ✓. P3 also honestly declares `partial: [REQ-009]` (Forex oracle is consumed by Piers — soft ordering note; REQ-009 is fully covered by P4's `x/satellite`). No phase falsely claims full coverage.
|
||||
|
||||
**Task commits have `plan:`/`task:`/`status: execute`:**
|
||||
|
||||
The milestone branch uses a **one-commit-per-phase** squash model (each `docs(PNN): complete ...` commit is the phase ship commit). There are no intermediate per-task commits on the milestone branch — per-task commits were made on the per-phase execution branches (`oy/phase/01-*`..`04-*`), then squashed into the single phase-ship commit on the milestone branch. This is a valid CIAgent ship pattern (vertical-slice integrity preserved at the phase granularity). The `---ci---` blocks correctly carry `phase: N`, `status: complete`, `phase_role: execution` (on P1-P4), and the covered REQ list. The final-phase branch (`oy/phase/05-final-review-ship`) is the active phase; its commit will carry `phase: 5`.
|
||||
|
||||
### 1.5 Build / Test / Cover Sanity — **PASS**
|
||||
|
||||
| Check | Command | Result | Verdict |
|
||||
|---|---|---|---|
|
||||
| Build | `go build ./...` | exit 0, GREEN | PASS |
|
||||
| Tests | `go test ./...` | exit 0, all 25 packages GREEN (15 v0.1 + 10 v0.2) | PASS |
|
||||
| v0.1 baseline regression | v0.1 packages in `go test ./...` | all (cached) GREEN — no regression | PASS |
|
||||
| Lexicon meta-test | `go test -run TestLexiconMeta -v .` | 4 meta-tests PASS (NoBannedTermsInX, SelfTestTable, BannedTermsCount, NoFalsePositive) | PASS |
|
||||
| G-003 import invariant | `go test -run TestG003... ./x/window/types/` | PASS (zero cross-module struct imports in production) | PASS |
|
||||
| Locked-const invariants | `go test -run TestMissionLockAmendable\|TestClamp\|TestHandPassFeeBps\|TestStandTypeCount\|TestPactTypeCount\|TestPartnerTierCount\|TestCouncilKindCount\|TestL2ChainCount\|TestCouponCap -v ./x/...` | ALL PASS | PASS |
|
||||
| Independent lexicon scan | `grep -rniE '\b(bank\|deposit\|interest\|yield\|currency\|dollar\|euro\|account\|savings\|depositor)\b' x/ --include='*.go'` | exit 1 (zero hits) | PASS |
|
||||
| `go.mod` unchanged | `git diff main..oy/milestone/v0.2-mesh -- go.mod` | EMPTY (G-006 verified) | PASS |
|
||||
|
||||
**Coverage on all 10 new/extended packages (≥80% required, D-033):**
|
||||
|
||||
| Package | Phase | Coverage | Verdict |
|
||||
|---|---|---|---|
|
||||
| `x/window/types` | P1 | 100.0% | PASS |
|
||||
| `x/stand/types` | P1 | 100.0% | PASS |
|
||||
| `x/guild/types` | P1 | 100.0% | PASS |
|
||||
| `x/pact/types` | P2 | 95.9% | PASS |
|
||||
| `x/partner/types` | P2 | 100.0% | PASS |
|
||||
| `x/council/types` | P3 | 96.4% | PASS |
|
||||
| `x/forex/types` | P3 | 100.0% | PASS |
|
||||
| `x/bond/types` | P4 | 96.8% | PASS |
|
||||
| `x/bearers/types` | P4 (ext) | 100.0% | PASS |
|
||||
| `x/satellite/types` | P4 | 100.0% | PASS |
|
||||
|
||||
Floor = 95.9% (`x/pact/types`); 8 of 10 at 100%. All exceed the 80% target. D-033 satisfied with margin.
|
||||
|
||||
---
|
||||
|
||||
## 2. Critical Issues Found (MUST fix before milestone ship)
|
||||
|
||||
**Initial critical issue count: 2** — both from the P5-01-03 deliverable (REQ-coverage audit + ROADMAP tag-line reconciliation), which is part of the P5 must-haves but had NOT been executed at audit time (HEAD was still the P04 ship commit; P5 doc work was pending).
|
||||
|
||||
### Critical-1: REQUIREMENTS.md status column NOT updated (P5-01-03 obligation)
|
||||
|
||||
- **Spec**: PLANS.md P5-01-03 — "update REQUIREMENTS.md status column (Pending → Skeleton)" for all v0.2 REQs.
|
||||
- **Pre-fix state**: all 8 v0.2-scope REQs (REQ-009, REQ-011, REQ-015, REQ-016, REQ-017, REQ-018, REQ-020, REQ-021) still showed `Pending | Future`. Two v0.2 components beyond the REQ list (Bearers OY-LR/Beacon per D-029, Forex v1 per D-030) were not represented at all.
|
||||
- **Impact**: the milestone's own requirement-coverage audit deliverable was unmet. A reader of REQUIREMENTS.md would conclude v0.2 shipped nothing, contradicting the 5 phase-ship commits and the 10 new/extended packages in the codebase.
|
||||
- **Disposition**: FIXED in this final phase. Status column updated: all 8 v0.2 REQs → `Skeleton` with `v0.2/PN` phase tags; Bearers OY-LR/Beacon and Forex v1 added as explicit rows; v0.1 summary test count corrected to 53 (G-001); a v0.2 Milestone Summary block added documenting the 10 packages, locked-const invariants, coverage, tag chain, and the G-010 tag-line note.
|
||||
|
||||
### Critical-2: ROADMAP.md tag-line reconciliation (G-010) NOT done; Phase 2 not marked complete
|
||||
|
||||
- **Spec**: PLANS.md P5-01-03 + GRILL.md G-010 — "reconcile ROADMAP.md's v0.0.x → v0.1.x tag-line note so the milestone release (`v0.1.5`) is not confused with the v0.0.x pre-MVP line"; PLANS.md P5-02-01 — "update ROADMAP.md Phase 2 checkbox".
|
||||
- **Pre-fix state**: ROADMAP.md Phase 2 section had no skeleton-status note, no module mapping, no tag-line reconciliation note, and no completion marker. The v0.0.x (pre-MVP) vs v0.1.x (Mesh) patch-line distinction existed only implicitly (line 15 mentions a deferred "v0.1.0 MVP" tag, which collides with v0.2's P0 tag `v0.1.0` — exactly the confusion G-010 was raised to prevent).
|
||||
- **Impact**: a reader could confuse the v0.2 P0 tag `v0.1.0` with the ROADMAP's deferred "v0.1.0 MVP" tag (line 15), and could not see from ROADMAP.md that v0.2 had shipped any skeleton work.
|
||||
- **Disposition**: FIXED in this final phase. Phase 2 header marked `— v0.2 SKELETON COMPLETE`; the deliverable table extended with `v0.2 Skeleton Module` and `Phase` columns mapping each Year-2 deliverable to its shipped `x/<module>`; a G-010 tag-line reconciliation note added explicitly distinguishing the `v0.0.x` pre-MVP line (lines 4-13) from the `v0.1.x` Mesh line, listing the full tag chain `v0.1.0..v0.1.5`, and stating that `v0.1.5` is the milestone release (not the deferred MVP tag).
|
||||
|
||||
**Post-fix verification**: `go test ./...` re-run after the doc edits — still GREEN (exit 0). The fixes are documentation-only in `.ciagent/oy/`; no source code under `x/` was touched (auditor is read-only w.r.t. source; the critical fixes are `.ciagent` doc updates, which is the P5-01-03 deliverable surface).
|
||||
|
||||
**Remaining critical issue count after fixes: 0.**
|
||||
|
||||
---
|
||||
|
||||
## 3. Non-Critical Observations (P1+ flags, not blocking)
|
||||
|
||||
These are design-shape divergences in a single module's non-must-have lifecycle types, carried over from REVIEW.md §3. They do NOT block the milestone ship. They are flagged for post-hoc review by the orchestrator / a future v0.3 PLAN phase.
|
||||
|
||||
### P1-1: Council module — Proposal/VoteOption lifecycle enums absent
|
||||
- **File**: `x/council/types/types.go` (entire file)
|
||||
- **Spec drift**: P3-01-01 deliverable recommended `Proposal`, `ProposalStatus` (5 states), `VoteOption` (3 options) enums mirroring OZ Governor / `x/gov`. Implemented: `Council`, `CouncilMember`, `Voice`, `SignalKind`, `TallyResult` — no Proposal/VoteOption lifecycle.
|
||||
- **Must-have impact**: NONE. P3 must-haves (3 councils, Mission Lock, TallyResult x/gov shape, no veto) all met.
|
||||
- **Recommendation**: add `Proposal`/`ProposalStatus`/`VoteOption` in v0.3 when wiring the council keeper to a live governance runtime.
|
||||
- **Severity**: P1 (spec drift from deliverable text, not a must-have, not blocking).
|
||||
|
||||
### P1-2: Council VoiceSource → SignalKind (4 sources, not 5)
|
||||
- **File**: `x/council/types/types.go` (`SignalKind` enum)
|
||||
- **Spec drift**: P3-01-01 deliverable specified `VoiceSource` (Stash/Standing/Vouch/Freeholder/Guild — 5 sources). Implemented: `SignalKind` (Stash/Standing/Vouch/Capital — 4 sources; Freeholder + Guild dropped, Capital added).
|
||||
- **Code rationale**: Freeholder is an eligibility property (upstream in `x/standing`), Guild is a council tier — neither is a voice signal. Capital is committed-capital (vision §9.1). Defensible design refinement, but diverges from deliverable text.
|
||||
- **Must-have impact**: NONE. P3 must-haves did not enumerate VoiceSource coverage.
|
||||
- **Recommendation**: confirm intended v0.2 shape, or restore 5-source `VoiceSource` for v0.3 wiring. The `SignalKindCount=4` locked-const test currently locks the 4-source shape; changing it is a deliberate locked-const update.
|
||||
- **Severity**: P1 (design-choice divergence, tested and self-consistent, not blocking).
|
||||
|
||||
### P2 (nit): Bearers ValidateGenesis remains a no-op
|
||||
- **File**: `x/bearers/types/types.go:108`
|
||||
- **Note**: CORRECT per spec — P4-02-01 said "DefaultParams/GenesisState unchanged" (bearers is an EXTENSION, not a new module; the A-212 ValidateGenesis upgrade was scoped to NEW modules only). Recording for completeness, not a defect. No action.
|
||||
|
||||
### Observation: CHECKPOINT.json `phase: 4` (not 5)
|
||||
- **Note**: CHECKPOINT.json reflects the last-completed execution phase (P4). The active P5 phase will bump `phase: 5` and `stage` on the P5 ship commit. This is the expected state mid-P5 (audit in progress, ship not yet committed). Not a defect.
|
||||
|
||||
### Observation: P3 commit lists REQ-009 as `partial`
|
||||
- **Note**: P3's `---ci---` block declares `partial: [REQ-009]`. This is honest soft-ordering accounting (Forex oracle is consumed by Piers; P3 ships the Forex half, P4 ships the L2 satellite half). REQ-009 is fully covered by P4's `x/satellite`. The `partial` flag is informational, not a coverage gap. Not a defect.
|
||||
|
||||
---
|
||||
|
||||
## 4. Overall Audit Verdict
|
||||
|
||||
### **PASS** (after critical fixes applied)
|
||||
|
||||
The v0.2 (The Mesh) milestone is **shippable**.
|
||||
|
||||
**Per-check summary:**
|
||||
|
||||
| # | Check | Verdict |
|
||||
|---|---|---|
|
||||
| 1.1 | Reconstruction test (git log ↔ .ciagent, tags, milestone-not-released) | PASS |
|
||||
| 1.2 | .ciagent file discipline (9 files, CHECKPOINT.json, config.json) | PASS |
|
||||
| 1.3 | Branch hygiene (main, milestone, final-phase, no leftover branches) | PASS |
|
||||
| 1.4 | Commit discipline (`---ci---` blocks, project: oy, status, requirements) | PASS |
|
||||
| 1.5 | Build / test / cover sanity (build, test, ≥80% coverage, lexicon, invariants) | PASS |
|
||||
|
||||
**Critical issues: 2 found → 2 fixed → 0 remaining.**
|
||||
- Critical-1 (REQUIREMENTS.md status column): FIXED.
|
||||
- Critical-2 (ROADMAP.md G-010 tag-line reconciliation + Phase 2 completion): FIXED.
|
||||
|
||||
**Non-critical observations: 3** (2× P1 council spec drift + 1× P2 nit) — flagged for post-hoc review, do not block ship.
|
||||
|
||||
**STRIDE security summary** (per ci-auditor role, read-only):
|
||||
|
||||
| Category | Finding | Severity | Disposition |
|
||||
|---|---|---|---|
|
||||
| Spoofing | No auth surface (skeleton-only, zero deps); Reach IDs are opaque strings, no identity assertion logic | Low | Accept |
|
||||
| Tampering | Locked consts are compile-time `const` (Mission Lock, Bond cap/floor, Guild fee 0); `ValidateGenesis` rejects dup IDs + out-of-bounds bond coupons at genesis load | Low | Accept |
|
||||
| Repudiation | Append-only audit log (Window) with non-decreasing timestamp + entry-id uniqueness enforced; no tx log in skeleton (deferred Phase 3) | Low | Accept |
|
||||
| Info Disclosure | Zero secrets in code; lexicon firewall prevents leaking banned financial terms into the codebase (REQ-012); no PII handling in skeleton | Low | Accept |
|
||||
| Denial of Service | Rate-limit primitive (Window) is a simple counter (A-206); no network surface (zero deps, no relayer, no live oracle); DoS surface is Phase 3+ | Low | Accept |
|
||||
| Elevation of Privilege | Mission Lock (`const false`) prevents governance amending the covenant; Bond clamp prevents coupon above 8% cap; G-003 invariant prevents import-cycle privilege escalation via struct imports | Low | Accept |
|
||||
|
||||
No threat exceeds the low/accept threshold. No escalations. The skeleton+tests scope (D-020) intentionally has no runtime attack surface; all security-relevant invariants are compile-time consts + tested firewalls.
|
||||
|
||||
**Confidence in overall verdict: 0.90**
|
||||
|
||||
---
|
||||
|
||||
## 5. Ship Readiness Confirmation
|
||||
|
||||
The milestone is ready for the final ship step (P5-02-01):
|
||||
1. `go build ./...` GREEN ✓
|
||||
2. `go test ./...` GREEN (25 packages, no regression) ✓
|
||||
3. Coverage ≥80% on all 10 new/extended packages (floor 95.9%) ✓
|
||||
4. Lexicon firewall green (zero banned terms; meta-test + self-test table pass) ✓
|
||||
5. All locked-const invariants green ✓
|
||||
6. G-003 by-ID-string import invariant green ✓
|
||||
7. go.mod unchanged (G-006) ✓
|
||||
8. Tags v0.1.0..v0.1.4 exist and map to correct commits ✓
|
||||
9. v0.1.5 NOT yet present (correct — final phase creates it) ✓
|
||||
10. REQUIREMENTS.md + ROADMAP.md reconciled (Critical-1, Critical-2 fixed) ✓
|
||||
|
||||
**Remaining P5 ship actions** (for the orchestrator, not the auditor):
|
||||
- Commit the P5 final-phase work (this AUDIT.md + the REQUIREMENTS.md/ROADMAP.md fixes + REVIEW.md).
|
||||
- Create the `v0.1.5` tag (= v0.2 milestone release per D-008/D-020).
|
||||
- (Optional) Update CHECKPOINT.json `phase: 5`, `stage: ship` on the P5 commit.
|
||||
- (If release_blocking were true) push tags to remote. config.json `ship.release_blocking: false`, so local tag is sufficient; remote push is at orchestrator discretion.
|
||||
|
||||
---
|
||||
|
||||
## Summary Block
|
||||
|
||||
```
|
||||
Per-check verdicts:
|
||||
1.1 Reconstruction test — PASS (5 phase commits; tags v0.1.0..v0.1.4; v0.1.5 absent)
|
||||
1.2 .ciagent discipline — PASS (9 files; CHECKPOINT.json + config.json valid)
|
||||
1.3 Branch hygiene — PASS (no leftover execution branches; final-phase at milestone HEAD)
|
||||
1.4 Commit discipline — PASS (all 5 commits: project: oy, status: complete, requirements: covered)
|
||||
1.5 Build/test/cover — PASS (build GREEN; test GREEN; coverage floor 95.9%; lexicon + invariants green)
|
||||
|
||||
Critical issues: 2 found → 2 fixed → 0 remaining
|
||||
- Critical-1: REQUIREMENTS.md status column → FIXED (P5-01-03 obligation)
|
||||
- Critical-2: ROADMAP.md G-010 tag-line → FIXED (P5-01-03 obligation)
|
||||
|
||||
Non-critical: 3 (2× P1 council spec drift, 1× P2 nit) — flagged, not blocking
|
||||
Escalations: 0
|
||||
Overall verdict: PASS (after critical fixes)
|
||||
Confidence: 0.90
|
||||
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
|
||||
|
||||
---
|
||||
|
||||
# Audit: OpenYield (oy) — v0.4 (Refinement — NFR) Final Phase
|
||||
|
||||
> **Auditor**: CIAgent security-auditor (read-only on source; writes only to `.ciagent/`)
|
||||
> **Date**: 2026-08-17
|
||||
> **Target**: v0.4 milestone (main..oy/milestone/v0.4-refinement) + `.ciagent/oy/` files
|
||||
> **Milestone**: v0.4 — Refinement (NFR)
|
||||
> **Autonomy**: full
|
||||
|
||||
## 1. Reconstruction Test (git log ↔ `.ciagent/` files)
|
||||
|
||||
### 1.1 Phase progression
|
||||
|
||||
The v0.4 milestone progresses through 5 phases, each with a `---ci---` block and a tag:
|
||||
|
||||
| Phase | Stage commits | Tag | Release ID | `.ciagent/` evidence |
|
||||
|-------|---------------|-----|-----------|----------------------|
|
||||
| P0 (pre-execution) | specify→clarify→research→plan→grill→mvp_ux_check→complete | v0.3.0 | 748 | PROJECT.md v0.4 section, REQUIREMENTS.md v0.4 table, ARCHITECTURE.md v0.4 section, PERSONAS.md v0.4, PLANS.md v0.4 plan, GRILL.md v0.4 section |
|
||||
| P1 (lexicon+const) | execute→verify→complete | v0.3.1 | 749 | REVIEW.md (P1), `lexicon/lexicon.go` helper, `x/hub/types/cross_const_test.go` |
|
||||
| P2 (lifecycle docs) | execute→verify→complete | v0.3.2 | 750 | ARCHITECTURE.md divergence section, `x/council/types/types_test.go` intent test |
|
||||
| P3 (docs CI) | execute→verify→complete | v0.3.3 | 751 | `.gitea/workflows/docs-build.yml`, `.gitignore` site/ |
|
||||
| P4 (final review+ship) | in progress | v0.3.4 (pending) | — | this AUDIT.md section + REVIEW.md v0.4 section |
|
||||
|
||||
Reconstruction: the git log subject lines match the `.ciagent/` file state. Each phase's `---ci---` block is present in the commit messages. Tags v0.3.0..v0.3.3 exist. **PASS.**
|
||||
|
||||
### 1.2 `.ciagent/` file discipline
|
||||
|
||||
14 files in `.ciagent/oy/` (ARCHITECTURE, AUDIT, GRILL, P1-P4_SHIP_VERIFICATION [P1-P3 from v0.2/v0.3, P4 pending], PERSONAS, PLANS, PROJECT, REQUIREMENTS, RESEARCH, REVIEW, ROADMAP). All present and updated for v0.4. CHECKPOINT.json reflects the current phase. config.json has `milestone: v0.4`, `milestone_type: nfr`, `tag_base: v0.3.x`. **PASS.**
|
||||
|
||||
### 1.3 Branch hygiene
|
||||
|
||||
Local: `main`, `oy/milestone/v0.4-refinement`, `oy/phase/04-final-review-ship` (current). Remote: `origin/main`, `origin/oy/milestone/v0.4-refinement`. All merged execution phase branches (P1, P2, P3) deleted locally. No leftover phase branches. The P4 final phase branch will be deleted at milestone ship. **PASS.**
|
||||
|
||||
### 1.4 Commit discipline
|
||||
|
||||
All 20 milestone commits have `---ci---` blocks with `project: oy`, `phase: N`, `milestone: v0.4`, `status: <stage>`, `tag_base: v0.3.x`, `milestone_type: nfr`. Commit subjects use conventional prefixes (docs, refactor, test, chore, verify, decision, checkpoint, Merge). No `feat:` subjects. **PASS.**
|
||||
|
||||
### 1.5 Build / test / coverage sanity
|
||||
|
||||
- `go build ./...`: clean.
|
||||
- `go test ./...`: green (26 packages, all ok).
|
||||
- `go vet ./...`: clean on touched packages.
|
||||
- Coverage: `x/hub/types` 93.3% (v0.3 floor preserved; new cross-const test adds coverage). `x/council/types` 96.4% (improved from v0.3). Both above the 80% target (D-033). `go.mod` unchanged (zero deps, G-006). **PASS.**
|
||||
|
||||
## 2. NFR Purity Gate (v0.4 mandatory)
|
||||
|
||||
The NFR purity gate (D-047, D-001 filter) requires zero `feat:` commits in the v0.4 milestone range. The gate is checked on COMMIT SUBJECTS (not bodies), because `git log --grep` over-matches commit bodies that mention "feat:" in prose (e.g., the verify commits say "zero feat: commits in P2" in the body).
|
||||
|
||||
**Gate command**: `git log --format="%s" main..HEAD | grep -E "^feat:"`
|
||||
|
||||
**Result**: exit 1 (zero matches). The 20 commit subjects are: `docs(init)`, `docs(P00)` ×3, `docs(P00)` grill, `decision(P00)`, `Merge` ×3, `refactor(lexicon)`, `verify(P1)`, `checkpoint(p1)` ×3, `docs(arch)`, `verify(P2)`, `checkpoint(p2)`, `chore(ci)`, `verify(P3)`, `checkpoint(p3)`. None start with `feat:`.
|
||||
|
||||
**NFR purity gate: GREEN.** ✓
|
||||
|
||||
## 3. Non-Critical Observations (P1+ flags, not blocking)
|
||||
|
||||
### P2-1: mkdocs build warnings (pre-existing v0.3 docs links)
|
||||
- **Files**: `docs/index.md`, `docs/shared/vision.md` (link to `../README.md` / `../../README.md`)
|
||||
- **Note**: `mkdocs build` produces 2 warnings about README.md not being in the docs tree. The build SUCCEEDS (warnings, not errors). These are pre-existing v0.3 docs content links, NOT introduced by v0.4. v0.4's REQ-032 is the CI workflow (which runs `mkdocs build` and succeeds despite the warnings), not the docs content.
|
||||
- **Recommendation**: post-hoc fix in v0.5+ (either include README.md in mkdocs nav, or fix the relative links). Not a v0.4 ship blocker.
|
||||
|
||||
### P2-2: Gitea Actions `actions/upload-artifact@v4` compatibility
|
||||
- **File**: `.gitea/workflows/docs-build.yml`
|
||||
- **Note**: the workflow uses `actions/upload-artifact@v4`. Gitea Actions (which reimplements GitHub Actions) supports most `actions/*` but v4 of upload-artifact has had compatibility quirks. If the upload step fails, the `mkdocs build` step (the higher-priority check) would still have succeeded.
|
||||
- **Recommendation**: post-hoc verify on the first real CI run; downgrade to v3 or use Gitea-native upload if needed. Not a ship blocker (the build is the gate, the artifact is a bonus).
|
||||
|
||||
### P2-3: NFR purity gate precision (documented in REVIEW.md P1+ #3)
|
||||
- **Note**: `git log --grep "^feat:"` over-matches commit bodies. The audit uses the subject-only gate (`--format="%s" | grep -E "^feat:"`). This is documented for future milestones.
|
||||
- **Recommendation**: bake the subject-only gate into the next milestone's PLAN. Not a ship blocker.
|
||||
|
||||
## 4. Overall Audit Verdict
|
||||
|
||||
### **PASS**
|
||||
|
||||
The v0.4 (Refinement — NFR) milestone is **shippable**.
|
||||
|
||||
**Per-check summary:**
|
||||
|
||||
| # | Check | Verdict |
|
||||
|---|-------|---------|
|
||||
| 1.1 | Reconstruction (phase progression, tags, `.ciagent` evidence) | PASS |
|
||||
| 1.2 | `.ciagent` file discipline (14 files, CHECKPOINT, config.json) | PASS |
|
||||
| 1.3 | Branch hygiene (no leftover phase branches; P4 final pending) | PASS |
|
||||
| 1.4 | Commit discipline (`---ci---` blocks; conventional subjects; no feat:) | PASS |
|
||||
| 1.5 | Build / test / coverage sanity (build, test, ≥80%, lexicon, G-006) | PASS |
|
||||
| 2 | **NFR purity gate** (zero `feat:` commit subjects) | **GREEN** |
|
||||
|
||||
**Critical issues: 0.**
|
||||
**Non-critical observations: 3** (all P2, post-hoc, non-blocking).
|
||||
**Confidence in overall verdict: 0.90.**
|
||||
|
||||
## STRIDE security summary (per ci-auditor role, read-only)
|
||||
|
||||
| Category | Finding | Severity | Disposition |
|
||||
|---|---|---|---|
|
||||
| Spoofing | No auth surface added (v0.4 is refactor+test+docs+CI; no new identity logic) | Low | Accept |
|
||||
| Tampering | The cross-const test (REQ-030) HARDENS tamper-resistance: a future change to `x/bond.CouponCapBps` or `x/hub.LendingCouponCapBps` is now detected by an automated test (was comment-only before v0.4). The absolute-value assertion (G-015) catches paired drift. Mission Lock (8%/0%) is MORE defended after v0.4. | Low (improved) | Accept |
|
||||
| Repudiation | No audit-log changes in v0.4 | Low | Accept |
|
||||
| Info Disclosure | The CI workflow (REQ-032) does NOT publish (build+artifact only, D-051); no secret exposure in the workflow YAML; `GITEA_TOKEN` is resolved via `resolveSecret()` from `.ciagent/.env.secrets` for release creation, never via shell-env `curl` | Low | Accept |
|
||||
| Denial of Service | No network surface added; the CI workflow runs on push but does not expose a service | Low | Accept |
|
||||
| Elevation of Privilege | No privilege surface added; the lexicon helper is a pure function; the regression guard only asserts existing consts | Low | Accept |
|
||||
|
||||
No threat exceeds the low/accept threshold. No escalations. v0.4 hardens the mission-locked const firewall (REQ-030) and the lexicon firewall (REQ-029) without introducing any new attack surface.
|
||||
@@ -0,0 +1,388 @@
|
||||
# Grill Review: OpenYield (oy) — v0.2 (The Mesh) Phase 0
|
||||
|
||||
> **Reviewer**: CIAgent adversarial grill (red-team, full autonomy)
|
||||
> **Date**: 2026-08-17
|
||||
> **Target**: Phase 0 artifacts (PROJECT.md, REQUIREMENTS.md, ARCHITECTURE.md, RESEARCH.md, PERSONAS.md, PLANS.md, config.json) + v0.1 codebase baseline
|
||||
> **Milestone**: v0.2 — The Mesh
|
||||
> **Autonomy**: full (decision_confidence_threshold = 0.60)
|
||||
> **Mode**: multi-project (slug `oy`)
|
||||
|
||||
---
|
||||
|
||||
## Methodology
|
||||
|
||||
Each of nine axes was scored by gathering concrete evidence (commit hash, file:line, REQ-ID, D-decision, A-assumption). Missing or contradictory evidence is itself a finding. Forcing questions were answered from the artifacts; where confidence ≥ 0.60 a binding verdict was reached. No escalations were required — all axes resolved at ≥ 0.60 confidence after binding fixes are applied.
|
||||
|
||||
### Evidence baseline (verified against the actual repo, not the docs)
|
||||
|
||||
- v0.1 codebase: **15 modules** under `x/` (confirmed via glob `x/**/types/types.go`).
|
||||
- `go.mod`: `module github.com/oy/openyield`, `go 1.22`, **zero dependencies** (confirmed).
|
||||
- Test functions: **53** (counted via `grep -rh "^func Test" x/`). PROJECT.md D-033 says "48-test baseline"; RESEARCH.md §2 says "53-test baseline pattern". **Inconsistent — both refer to v0.1.**
|
||||
- Test files: **11** `*_test.go` (not 15 — modules `identity`, `processing`, `rootpool`, `vault` have **no test files**).
|
||||
- **Zero lexicon assertion tests** exist in v0.1 (`grep -rli lexicon x/` returns empty). D-032 claims lexicon is "carried from v0.1" — but v0.1 never had a lexicon test. The claim is aspirational, not historical.
|
||||
- **Zero `genesis.go` files** exist; all `ValidateGenesis` are no-ops in `types.go` (e.g. `feecovenant/types/types.go:122` returns `nil` unconditionally).
|
||||
- **No project-wide lexicon meta-test** exists.
|
||||
- `x/bearers/types/types_test.go` already imports `x/processing/types` cross-module (line 7) — an existing precedent for cross-module test imports, but it is a *test* import, not a production import cycle.
|
||||
- Current lexicon cleanliness: `grep -rni "bank\|account" x/ --include="*.go"` returns **zero hits** — v0.1 is lexicon-clean in practice, but the firewall is nonexistent.
|
||||
|
||||
These baseline facts drive several binding decisions below. The plan's narrative ("matching v0.1's 48-test baseline", "lexicon carried from v0.1") is **partly fictional** — v0.1 has no lexicon test and the test count is misstated in two places. This does not block v0.2; it means the lexicon firewall is *new* work in v0.2, not inherited work.
|
||||
|
||||
---
|
||||
|
||||
## Per-Axis Verdicts
|
||||
|
||||
### Axis 1 — Feasibility (skeleton + tests, zero deps) — **PASS** (confidence 0.85)
|
||||
|
||||
The v0.2 scope (Go types + keeper stubs + unit tests, no live chain) is genuinely achievable with zero external deps. v0.1 proved the pattern: 15 modules compile under `go 1.22` with only `encoding/json`. RESEARCH.md §4 is explicit and correct that pulling `cosmos-sdk`/`ibc-go` would force a 1000+ module tree and break the fast test loop. The ICS-20 packet shape is self-contained Go structs (A-215), the Forex oracle is a Go interface (no impl), and bearers are pure types (no LoRa/BLE libs). No axis-1 risk reaches the escalation threshold.
|
||||
|
||||
The one caveat: the Window "fuller" implementation (D-023) is the most ambitious skeleton and rate-limit semantics are unstated (A-206, conf 0.65). But the plan correctly scopes the skeleton to a simple counter (`actionsConsumed` vs `maxActions`), which is trivially feasible. Confidence holds.
|
||||
|
||||
### Axis 2 — Scope (over/under, D-020..D-033 defensibility) — **CONDITIONAL** (confidence 0.72)
|
||||
|
||||
D-020..D-033 are individually defensible and well-reasoned. The skeleton-first choice (D-020, conf 0.85), one-chain IBC representative (D-021), Mission Lock as const (D-022), Window-fuller (D-023), and the deferred items (Cover seniority Q7, Pier credential routing Q5, Solana IBC) all correctly defer operational/Phase-3 concerns. The scope is *not* over-scoped for a skeleton milestone.
|
||||
|
||||
However: (a) D-033's claim of "matching v0.1's 48-test baseline" is factually wrong — v0.1 has 53 tests and no lexicon tests (see baseline). (b) D-032's claim that lexicon is "carried from v0.1" is false — v0.1 has no lexicon test. These are documentation defects, not scope defects, but they mislead future phases about what is inherited vs. new. **Binding fix G-002** corrects the narrative; the scope itself is sound.
|
||||
|
||||
### Axis 3 — Cost / Effort (30 tasks / 5 phases) — **PASS** (confidence 0.80)
|
||||
|
||||
30 tasks across 5 phases for 10 new/extended packages is proportionate. Each package gets a types task + a test task + (for 3 of them) a genesis task, plus 5 cross-cutting lead-developer ship tasks. No phase exceeds 8 tasks; the largest (P4) has 8 tasks for 3 packages which is reasonable. There is **no bloat**: there are no "research", "spike", or "documentation" tasks masquerading as execution; every task produces a concrete `.go` file or a verifiable gate. The 5 lead-developer verification tasks (one per phase) are the correct overhead for vertical-slice shippability. Reject the "too many tasks" hypothesis.
|
||||
|
||||
Minor: P5 has 4 tasks for pure audit/ship, which is light but appropriate (no new code). No binding change.
|
||||
|
||||
### Axis 4 — Architecture (module layout, blocker chain, import cycles) — **PASS** (confidence 0.82)
|
||||
|
||||
The 9-new + 1-extended layout maps cleanly to the blocker chain in ARCHITECTURE.md §5.2. D-031's phase ordering (P1 Orgs+Window → P2 Pacts+Partners → P3 Councils+Forex → P4 Bonds+Bearers+L2 → P5 ship) respects the dependency graph: Window is a leaf (P1) so P2/P3/P4 reference it by `WindowRef` id-string; Stand (P1) blocks Pact StandRegistry (P2), Council Stand-Council (P3), and Bond issuer (P4); Guild (P1) blocks Council Guild-Council (P3). A-203 (by-ID-string refs) is the correct cycle-avoidance strategy and is consistently applied in RESEARCH.md §3's interface diagram.
|
||||
|
||||
One concern: RESEARCH.md §3 says `x/window` "may be imported by Pacts/Partners/Orgs if needed (but skeleton keeps it by-ref too)" — this hedge could permit a future struct import. The plan's task specs (P2-01-01, P2-02-01) correctly use `window-id-ref` strings, so the skeleton is safe. **Binding fix G-003** makes the by-ref rule a hard invariant test so the hedge cannot silently become an import cycle.
|
||||
|
||||
### Axis 5 — Risk (15 assumptions, hidden traps) — **CONDITIONAL** (confidence 0.70)
|
||||
|
||||
The 15 assumptions (A-201..A-215) are mostly sound and well-scored. The highest-confidence (A-201 dep-free 0.95, A-202 one-module-per-component 0.90, A-209 bearers-extend 0.90, A-215 no-deps-vendored 0.90, A-210 lexicon-coupon 1.00) are correct. The lower-confidence assumptions are correctly *flagged* for v0.3 revisit (A-206 rate-limit 0.65, A-213 Shadow Stand 0.60, A-214 spread cap 0.70).
|
||||
|
||||
Three hidden traps:
|
||||
1. **Lexicon firewall is new, not inherited** (see baseline). The plan assumes v0.1 had a lexicon test; it did not. The P5 meta-test (P5-01-01) is therefore load-bearing for the *entire* project's lexicon integrity, not just v0.2. This raises its criticality — **binding fix G-004** moves the lexicon meta-test earlier (into P1) as a scaffolding test that fails-empty initially, so drift is caught from the first phase, not at P5 when 10 packages already exist.
|
||||
2. **`x/bearers/types/types_test.go` already cross-imports `x/processing/types`** (line 7). This is a *test* import, not a production import cycle, but it means the bearers test package is already coupled. When P4 extends `x/bearers`, the existing test must stay green (P4-02-02 acknowledges this). No binding change — the plan already flags the regression risk — but it is a latent coupling the grill records.
|
||||
3. **A-207 (one `x/pact` module with PactType enum, conf 0.80) contradicts D-027's wording** ("separate keeper modules"). RESEARCH.md §1.7 explicitly reconciles this ("one module with a PactType enum satisfies 'separate execute entries' without 6 dirs"), but the contradiction between the D-decision text and the chosen implementation is a documentation smell. **Binding fix G-005** clarifies D-027's intent. Confidence holds; no escalation.
|
||||
|
||||
### Axis 6 — Requirement Coverage (all v0.2 REQs, gaps, double-counting) — **PASS** (confidence 0.84)
|
||||
|
||||
Cross-referencing RESEARCH.md §6 (Cross-Reference Summary) against PLANS.md per-phase REQ coverage and REQUIREMENTS.md:
|
||||
|
||||
| REQ | Component | Module | Phase | Depth | Covered? |
|
||||
|-----|-----------|--------|-------|-------|----------|
|
||||
| REQ-009 | L2 IBC | x/satellite | P4 | Skeleton (Polygon rep) D-021 | ✅ P4-03-01/02 |
|
||||
| REQ-011 | Councils | x/council | P3 | Skeleton + Mission Lock D-022 | ✅ P3-01-01/02/03 |
|
||||
| REQ-015 | Window | x/window | P1 | Fuller D-023 | ✅ P1-01-01/02/03 |
|
||||
| REQ-016 | 9 Stands | x/stand | P1 | Skeleton D-024 | ✅ P1-02-01/02/03 |
|
||||
| REQ-017 | Guilds | x/guild | P1 | Skeleton D-025 | ✅ P1-03-01/02 |
|
||||
| REQ-018 | Partners | x/partner | P2 | Skeleton D-026 | ✅ P2-02-01/02 |
|
||||
| REQ-020 | 6 Pacts | x/pact | P2 | Skeleton D-027 | ✅ P2-01-01/02 |
|
||||
| REQ-021 | Bonds | x/bond | P4 | Skeleton D-028 | ✅ P4-01-01/02/03 |
|
||||
| Bearers | OY-LR+Beacon | x/bearers (ext) | P4 | Stubs D-029 | ✅ P4-02-01/02 |
|
||||
| Forex | Forex v1 | x/forex | P3 | Stub D-030 | ✅ P3-02-01/02 |
|
||||
| REQ-012 | Lexicon | (all) | all | D-032 | ✅ per-module + P5 meta |
|
||||
|
||||
Every v0.2 REQ maps to at least one types task + one test task. No REQ is double-counted across phases. REQ-012 (lexicon) is correctly cross-cutting. No gaps. The coverage is complete and non-overlapping. The only caveat (G-004) is *when* the lexicon meta-test lands, not *whether* it covers REQ-012.
|
||||
|
||||
### Axis 7 — Persona Assignment (territory globs, conflicts) — **CONDITIONAL** (confidence 0.74)
|
||||
|
||||
Persona→task assignments are coherent with territory globs:
|
||||
- backend-engineer (`x/**/types/**`, `x/**/keeper/**`, `x/**/module.go`, `go.mod`) → P2-01-01, P2-02-01, P4-01-01 (pact/partner/bond types). ✅
|
||||
- cosmos-engineer (`x/{satellite,council,window,stand,guild,partner,pact,bond,forex,bearers}/**`, `go.mod`) → P1-01-01, P1-02-01, P1-03-01, P3-01-01, P3-02-01, P4-02-01, P4-03-01. ✅
|
||||
- data-engineer (`x/**/types/genesis*.go`, `x/**/genesis*.go`) → P1-01-03, P1-02-03, P3-01-03, P4-01-03. ✅
|
||||
- security-engineer (`x/**/types/**_test.go`, `x/**/keeper/**_test.go`, `x/**/genesis_test.go`) → all `*_test.go` tasks. ✅
|
||||
- lead-developer (`**`) → all wave-3 ship tasks. ✅
|
||||
|
||||
**Territory conflicts (warn mode, non-blocking but recorded):**
|
||||
1. `go.mod` is claimed by **both backend-engineer and cosmos-engineer** (and referenced in 5 persona entries). Since v0.2 adds **zero** deps (A-201), `go.mod` should not change in v0.2. **Binding fix G-006**: no persona touches `go.mod` in v0.2; if a dep is needed, it is an escalation (would violate A-201).
|
||||
2. `x/partner/**`, `x/pact/**`, `x/bond/**` are claimed by **both cosmos-engineer and backend-engineer** (cosmos-engineer's explicit list; backend-engineer's `x/**/types/**` glob). The plan assigns P2-01-01 (pact) and P2-02-01 (partner) and P4-01-01 (bond) to **backend-engineer**, but cosmos-engineer's territory list includes these same paths. This is a genuine overlap. Since territory enforcement is `warn` (config.json), it does not block, but it is ambiguous ownership. **Binding fix G-007** clarifies: cosmos-engineer owns the *Cosmos-convention-shaped* modules (window, stand, guild, council, satellite, forex, bearers-ext); backend-engineer owns the *non-Cosmos-mirroring* modules (pact, partner, bond). This matches the actual task assignments and removes the glob overlap.
|
||||
3. security-engineer's `x/**/genesis_test.go` territory overlaps data-engineer's `x/**/genesis*.go` for the test file. The plan assigns genesis *test* logic to data-engineer's genesis tasks (e.g. P1-01-03 says "audit-log ordering test green"). Ambiguous who writes `genesis_test.go`. **Binding fix G-008**: genesis *schema* (genesis.go) = data-engineer; genesis *test assertions* (`*_test.go`) = security-engineer. The plan's task verbiage already implies this; G-008 makes it explicit.
|
||||
|
||||
No conflict is severe enough to block at warn mode. Confidence holds after fixes.
|
||||
|
||||
### Axis 8 — Testability (must-haves verifiable, invariants concrete) — **PASS** (confidence 0.83)
|
||||
|
||||
Every must-have in the plan has a concrete verification:
|
||||
- Locked-const tests are concrete: 9 Stands (P1-02-02), 4 Partner tiers (P2-02-02), 6 Pacts (P2-01-02), 3 Councils (P3-01-02), Mission Lock `== false` (P3-01-02), Bond cap 800 / floor 0 + clamp invariant (P4-01-02), Guild fee 0 (P1-03-02), 5 L2 chains (P4-03-02), ICS-20 packet field names (P4-03-02). Each is a single-const-change-breaks-a-test firewall. ✅
|
||||
- Invariant tests are concrete: Window lifecycle Open→Active→Revoked→Expired, revoke-after-expire no-op, double-revoke idempotent-or-error (P1-01-02); bond clamp below→floor / above→cap / in-range→unchanged (P4-01-02); Mission Lock non-amendable (P3-01-02). ✅
|
||||
- Lexicon assertions: per-module (10 files) + P5 meta-test. ✅ (with G-004 moving meta-test earlier)
|
||||
- Coverage: ≥80% per package, table-driven on every exported const/struct/method (D-033). Achievable for low-cyclomatic-complexity skeletons. ✅
|
||||
|
||||
One gap: P5-01-01's lexicon meta-test verification says "a deliberately-injected banned term in any new file fails the meta-test (verified by a temporary spike)". A "temporary spike" is not a durable test. **Binding fix G-009**: the meta-test must include a *self-test* — a table of synthetic strings (one per banned term) asserted to be detected, so the meta-test is durably verified without manual spikes. Confidence holds.
|
||||
|
||||
### Axis 9 — Phase Ordering & Shippability (patches, v0.1.5 milestone) — **PASS** (confidence 0.81)
|
||||
|
||||
Each phase is independently shippable as a patch on the `v0.1.x` line:
|
||||
- P1 → `v0.1.1` (window, stand, guild): ships 3 foundation modules; `go test ./...` green; no upstream blocker. ✅
|
||||
- P2 → `v0.1.2` (pact, partner): ships 2 modules referencing P1 by-id-string; blocked-by P1 types only. ✅
|
||||
- P3 → `v0.1.3` (council, forex): ships 2 modules; council blocked-by P1 stand+guild (by-id); forex independent. ✅
|
||||
- P4 → `v0.1.4` (bond, bearers-ext, satellite): ships 3 packages; bond blocked-by P1 stand; bearers/satellite independent; v0.1 baseline regression-gated. ✅
|
||||
- P5 → `v0.1.5` (audit + ship): final phase patch IS the milestone release per D-008/D-020. ROADMAP.md Phase 2 checkbox updated. ✅
|
||||
|
||||
The tag arithmetic is correct: P0 → `v0.1.0` (already shipped per ROADMAP "MVP deferred"), P1..P4 → `v0.1.1..v0.1.4`, P5 → `v0.1.5` = v0.2 milestone. No separate minor tag (D-008). The blocker chain is honored: all cross-phase hard blockers (P1-01-01, P1-02-01, P1-03-01) are in P1 Wave 1, so P2/P3/P4 Wave 1 can start once P1 types land (the plan's "soft ordering for branch hygiene" note is correct — types only depend on listed hard blockers).
|
||||
|
||||
One observation: ROADMAP.md still shows v0.1 phases as `v0.0.0..v0.0.9` (lines 4-13) and says "MVP release (v0.1.0) deferred". PROJECT.md says v0.1 was "released as v0.0.9 per run.md patch-line model" (line 90). The v0.1.x tag base for v0.2 (config.json `tag_base: v0.1.x`, PLANS.md line 7) is internally consistent but the ROADMAP's v0.0.x history is a separate patch line. This is a documentation reconciliation item, not a blocker. **Binding fix G-010**: P5-01-03 (REQ-coverage audit) must also reconcile ROADMAP.md's v0.0.x → v0.1.x tag-line note so the milestone release (`v0.1.5`) is not confused with the v0.0.x pre-MVP line.
|
||||
|
||||
---
|
||||
|
||||
## Binding Decisions
|
||||
|
||||
These are **binding** — the orchestrator MUST apply them before P1 begins. Numbered G-001..G-010.
|
||||
|
||||
| ID | Decision | Rationale | Confidence | Affects |
|
||||
|----|----------|-----------|------------|---------|
|
||||
| **G-001** | Correct the v0.1 baseline test-count claim. PROJECT.md D-033 says "48-test baseline"; RESEARCH.md §2 says "53-test baseline pattern". Actual count is **53** `func Test*` across 11 files. Standardize on "53 tests across 11 test files (v0.1 baseline)". | Both docs cite v0.1 test counts and disagree with each other; the actual count (verified `grep -rh "^func Test" x/ \| wc -l` = 53) is the source of truth. Future phases must not cite a phantom 48. | 0.95 | PROJECT.md D-033, RESEARCH.md §2 |
|
||||
| **G-002** | Correct the lexicon-inheritance narrative. D-032 says lexicon is "carried from v0.1"; D-033 says "matching v0.1's 48-test baseline" re lexicon. **v0.1 has zero lexicon tests** (verified: `grep -rli lexicon x/` empty). State explicitly: "Lexicon assertion tests are NEW in v0.2; v0.1 is lexicon-clean in practice but has no test firewall. v0.2 introduces the firewall." | The current narrative implies inherited work that does not exist, understating v0.2's new-work scope and the criticality of the P5 meta-test. | 0.92 | PROJECT.md D-032/D-033, RESEARCH.md §2 |
|
||||
| **G-003** | Add a hard invariant test in P1-01-02 (Window test file) asserting that no v0.2 production (non-test) `.go` file under `x/` imports another `x/<module>/types` package by struct — i.e. enforce A-203 (by-ID-string refs) as a *tested* invariant, not just a convention. Use `go/ast` or `go/parser` to scan imports, OR a simpler package-import grep test. | RESEARCH.md §3 hedges that Window "may be imported by Pacts/Partners/Orgs if needed". A hedge in a design doc can silently become an import cycle. Make the rule a test that fails on violation. | 0.78 | P1-01-02, RESEARCH.md §3 |
|
||||
| **G-004** | Introduce the project-wide lexicon meta-test scaffolding in **P1 Wave 3** (new task P1-04-02, lead-developer), not P5. The P1 version scans `x/**/*.go` for the 9 banned terms and passes (v0.1 is clean). It fails-empty initially for the 3 new P1 packages only if drift occurs. P5-01-01 then *extends* the already-existing meta-test to cover all 10 packages rather than creating it from scratch at audit time. | Per G-002, the lexicon firewall is new in v0.2. Creating it only at P5 means drift in P1-P4 is undetected until audit, when 10 packages already exist and remediation is expensive. A P1 scaffolding meta-test catches drift from the first phase. This is the single most material fix. | 0.88 | PLANS.md P1, P5-01-01 |
|
||||
| **G-005** | Clarify D-027 wording: "six Pacts as **one `x/pact` module** with a `PactType` enum + six per-type execute-entry structs (A-207), NOT six micro-modules." Update the D-027 decision text in PROJECT.md to remove the "separate keeper modules" ambiguity that contradicts A-207. | D-027 says "separate keeper modules"; A-207 and RESEARCH §1.7 say one module. The contradiction is a documentation smell that could cause a future agent to spawn 6 dirs. | 0.85 | PROJECT.md D-027 |
|
||||
| **G-006** | No persona may modify `go.mod` in v0.2. v0.2 adds zero external dependencies (A-201, conf 0.95). Any `go.mod` change in P1-P5 is an immediate **escalation** (would violate D-020/A-201). Remove `go.mod` from active persona territories for v0.2, or add a constraint: "go.mod is read-only in v0.2; any change triggers escalation." | `go.mod` is claimed by both backend-engineer and cosmos-engineer (5 mentions). Since it must not change, the overlap is moot but ambiguous. Making it explicitly read-only removes the ambiguity and protects the zero-dep invariant. | 0.90 | PERSONAS.md, config.json |
|
||||
| **G-007** | Disambiguate `x/pact`, `x/partner`, `x/bond` ownership. **backend-engineer** owns these three (non-Cosmos-mirroring modules: pact = custom commitment types, partner = custom registry, bond = custom clamp). **cosmos-engineer** owns `x/window`, `x/stand`, `x/guild`, `x/council`, `x/satellite`, `x/forex`, `x/bearers` (Cosmos-convention-mirroring: x/authz, x/group, x/gov, x/ibc-transfer, x/capability shapes). Update cosmos-engineer's territory list in PERSONAS.md to remove `x/pact`, `x/partner`, `x/bond`. | Both personas' territory globs overlap on these three paths. The actual PLANS.md task assignments (P2-01-01, P2-02-01, P4-01-01 → backend-engineer) already resolve it operationally, but the globs in PERSONAS.md still conflict. Align the globs with the assignments. | 0.82 | PERSONAS.md |
|
||||
| **G-008** | Genesis file ownership split: **data-engineer** owns `genesis.go` / genesis *schema* (the `GenesisState` struct, `ValidateGenesis` body). **security-engineer** owns `*_test.go` including genesis *test assertions* (`genesis_test.go` if separate, or the genesis assertions within `types_test.go`). Update PERSONAS.md so security-engineer's territory explicitly includes `x/**/genesis_test.go` and data-engineer's explicitly excludes `*_test.go`. | data-engineer's `x/**/genesis*.go` glob currently matches `genesis_test.go`, overlapping security-engineer's `x/**/genesis_test.go`. Plan tasks (e.g. P1-01-03) say "test green" but the test-author persona is security-engineer. Clarify schema vs test ownership. | 0.80 | PERSONAS.md, PLANS.md genesis tasks |
|
||||
| **G-009** | P5-01-01 lexicon meta-test must include a **self-test table**: a hardcoded slice of synthetic strings, one per banned term (e.g. `"bank account"`, `"interest yield"`, `"currency dollar euro"`, `"deposit savings depositor"`), asserted to each trigger the meta-test's detection. This durably verifies the meta-test detects all 9 banned terms without a manual "temporary spike". | P5-01-01's verification relies on "a temporary spike" to confirm detection — a manual step that won't be repeated. A self-test table makes the meta-test's coverage itself tested. | 0.86 | PLANS.md P5-01-01 |
|
||||
| **G-010** | P5-01-03 (REQ-coverage audit) must additionally reconcile the ROADMAP.md tag-line narrative: add a one-line note that v0.1 pre-MVP shipped on the `v0.0.x` line (ROADMAP lines 4-13) and v0.2 ships on the `v0.1.x` line (config `tag_base`), so the `v0.1.5` milestone release is not confused with a v0.1.0 "MVP" tag. | ROADMAP.md says "MVP release (v0.1.0) deferred" while v0.2's P0 is `v0.1.0` per PLANS.md. The two `v0.1.0` references mean different things. A reconciliation note prevents a tag collision or confusion at ship time. | 0.75 | PLANS.md P5-01-03, ROADMAP.md |
|
||||
|
||||
---
|
||||
|
||||
## Escalations
|
||||
|
||||
**None.** All nine axes resolved at confidence ≥ 0.60 after the binding fixes G-001..G-010 are applied. No axis required escalation to the human. At full autonomy, the orchestrator applies the binding decisions and proceeds to P1.
|
||||
|
||||
---
|
||||
|
||||
## Overall Verdict
|
||||
|
||||
### **SHIP Phase 0 with binding changes**
|
||||
|
||||
The v0.2 Phase 0 plan is fundamentally sound: the skeleton+tests scope is feasible, zero-dep, well-decomposed, REQ-complete, and properly ordered along the blocker chain. The plan is **not** over-scoped, **not** unfeasible, and **not** token-bloated — 30 tasks for 10 packages is proportionate.
|
||||
|
||||
The binding changes are **documentation/test-firewall corrections**, not scope rework. The most material is **G-004** (move the lexicon meta-test scaffolding from P5 to P1): the plan's single biggest hidden risk is that it treats the lexicon firewall as inherited from v0.1 when it is in fact new work, and creates it only at the final audit phase. Catching drift from P1 is cheap; catching it at P5 after 10 packages exist is expensive. G-001/G-002 correct the factual errors in the baseline narrative. G-003/G-005/G-006/G-007/G-008/G-009/G-010 close smaller ambiguities (import-cycle invariant, D-027 wording, go.mod read-only, persona territory overlaps, meta-test self-coverage, tag-line reconciliation).
|
||||
|
||||
None of these rise to "rethink" — the architecture, scope, and ordering are correct. Apply the 10 binding decisions and proceed to Phase P1.
|
||||
|
||||
**Confidence in overall verdict: 0.83**
|
||||
|
||||
---
|
||||
|
||||
## Summary Block
|
||||
|
||||
```
|
||||
Per-axis verdicts:
|
||||
1. Feasibility — PASS (0.85)
|
||||
2. Scope — CONDITIONAL (0.72) → fixed by G-001, G-002
|
||||
3. Cost/Effort — PASS (0.80)
|
||||
4. Architecture — PASS (0.82) → strengthened by G-003
|
||||
5. Risk — CONDITIONAL (0.70) → fixed by G-004, G-005
|
||||
6. Req Coverage — PASS (0.84)
|
||||
7. Persona Assignment — CONDITIONAL (0.74) → fixed by G-006, G-007, G-008
|
||||
8. Testability — PASS (0.83) → strengthened by G-009
|
||||
9. Phase/Ship — PASS (0.81) → strengthened by G-010
|
||||
|
||||
Binding decisions: 10 (G-001..G-010)
|
||||
Escalations: 0
|
||||
Overall: SHIP Phase 0 with binding changes (confidence 0.83)
|
||||
```
|
||||
---
|
||||
|
||||
## v0.3 Grill (Phase 0)
|
||||
|
||||
> **Reviewer**: CIAgent adversarial grill (red-team, full autonomy)
|
||||
> **Date**: 2026-08-17
|
||||
> **Target**: v0.3 Phase 0 artifacts (PROJECT.md D-034..D-046, ROADMAP.md v0.3 table, REQUIREMENTS.md REQ-010/022..028, ARCHITECTURE.md v0.3 section, PERSONAS.md v0.3 roster, RESEARCH.md A-301..A-315, PLANS.md v0.3 plan 50 tasks P1-P6) + v0.1/v0.2 codebase baseline
|
||||
> **Milestone**: v0.3 — Bearers & Documentation
|
||||
> **Autonomy**: full (decision_confidence_threshold = 0.60)
|
||||
> **Mode**: multi-project (slug `oy`)
|
||||
|
||||
### Evidence baseline (verified against the actual repo, not the docs)
|
||||
|
||||
- `go.mod`: `module github.com/oy/openyield`, `go 1.22`, **zero dependencies** (confirmed — no require lines).
|
||||
- `x/` modules: **25** (confirmed via `ls x/ | wc -l`). v0.1 = 15 + v0.2 added 10 = 25. v0.3 adds 4 new (exit/bridge/hub/services) + extends 3 (bearers/partner/bond) = 29 distinct after v0.3. PLANS.md P6-01-01 says "29 packages" — correct.
|
||||
- Test functions: **299** `func Test*` across **23** test files (counted `grep -rn "^func Test" x/ | wc -l`). v0.2 grill G-001 corrected to 53/11; v0.2 added ~246 more. The v0.3 plan's "~303" reference (REQUIREMENTS IDEATE notes) is close to current 299 — minor, not material.
|
||||
- `lexicon/lexicon.go`: `BannedTerms()` returns **10** (verified `if len(terms) != 10` at lexicon.go). The v0.2 `lexicon_meta_test.go` header comment still says "9 banned terms" (line 7) — a **pre-existing v0.2 documentation defect**, not v0.3's, but the v0.3 docs firewall must use 10 (it does — P1-01-01 says exactly 10). No v0.3 binding needed; note for v0.2 housekeeping.
|
||||
- `lexicon_meta_test.go` EXISTS at repo root, package `lexicon_meta`, with the **G-009 self-test table** (synthetic strings, `len(synthetic) != len(terms)` guard). The self-test verifies **detection** but does NOT verify the **walk** (which files are scanned). The v0.3 docs firewall inherits this gap — G-013 below.
|
||||
- G-003 import-invariant test EXISTS: `x/window/types/types_test.go:431-456` uses `go/parser` (ImportsOnly) to scan all non-test `.go` under `x/` and assert no cross-`x/<module>/types` struct imports. **Confirmed production code is cycle-free** (`grep -rn "openyield/x/" x/ --include="*.go" | grep -v "_test.go"` returns empty). The v0.3 by-ID-string refs (exit→bridge, hub→partner, services→window) will be auto-covered by this existing test — no new G-003 work needed in v0.3 (P4/P5 test tasks correctly say "G-003 import-invariant green" not "add a new one").
|
||||
- `x/bond/types/types.go`: `CouponCapBps = 800`, `CouponFloorBps = 0`, `func Clamp(couponBps uint32) uint32` all exist. The D-028 regression firewall is real and the v0.3 GrowthBond extension reuses the same package (no G-003 concern, correct).
|
||||
- `x/partner/types/types.go`: `TierAnchor PartnerTier = "Anchor"` exists; no `AnchorCredential` yet (v0.3 P4 adds it). The 4-tier enum is locked — P4 extension adds a struct, not a tier. Correct.
|
||||
- `x/bearers/types/types.go`: `BearerOYSAT` and `BearerOYQR` are ALREADY in `AllBearers()` (lines 20-21, 39-40) since v0.1; `BearerTransport` interface + `OYLRLink` + `SurveillanceResistant` exist since v0.2. P4 adds `OYSATLink`/`OYQRCode` transport structs only — `AllBearers()` count (6) stays unchanged. Correct.
|
||||
- `x/window`, `x/stand`, `x/satellite` (L2Chain/TransferChannel), `x/watcher`, `x/identity` (Reach) all exist as v0.1/v0.2 baseline — the v0.3 by-ID-string refs to them (bridge→satellite/watcher, services→window/identity, bond→stand) have real targets. No phantom refs.
|
||||
- **No `docs/`, no `README.md`, no `mkdocs.yml` exist** today. The firewall-first ordering is clean: `lexicon_meta_docs_test.go` passes vacuously with zero docs to scan (no hits possible). The "chicken-and-egg between firewall test and README" risk raised in the grill brief is a **non-issue** — verified by walk logic (zero files = zero hits).
|
||||
|
||||
These baseline facts confirm the v0.3 plan's architecture and ordering claims against the actual codebase, not just the docs. The plan is unusually well-grounded; the binding decisions below are mostly small correctness fixes, not scope rework.
|
||||
|
||||
### Per-Axis Verdicts
|
||||
|
||||
#### Axis 1 — Feasibility (50 tasks / 6 phases at full autonomy) — **PASS** (confidence 0.82)
|
||||
|
||||
50 tasks across 6 phases is large but proportionate to the deliverable: 26 docs pages (P1-P3) + 7 x/* packages (P4-P5) + audit/ship (P6). v0.2 shipped 31 tasks / 5 phases for 10 packages at full autonomy and closed clean (per REQUIREMENTS.md v0.2 summary). v0.3 adds the docs surface (a genuinely new artifact type) and 4 new + 3 extended Go packages. The docs phases (P1-P3) are low-risk Markdown authoring gated by a Go firewall; the Bearers phases (P4-P5) are pure skeleton+tests, the proven v0.1/v0.2 pattern. No phase exceeds 12 tasks (P3 is largest at 12, all docs). The firewall-first claim is achievable: a firewall that scans zero files passes trivially (verified — no docs exist yet). Reject the "50 tasks is too many" hypothesis — it matches the deliverable surface.
|
||||
|
||||
#### Axis 2 — Scope (Bearers + docs bundle, ~22 pages) — **PASS** (confidence 0.78)
|
||||
|
||||
D-034 (bundle Bearers + docs under one feature milestone) is defensible: the user's `--ideate` request was docs-only, but ROADMAP Phase 3 (Bearers) is the next queued feature work; bundling keeps the milestone cadence and avoids a docs-only NFR milestone that would not advance the protocol. The alternative (separate v0.3 docs NFR + v0.4 Bearers) would split a coherent unit of work into two milestones and delay the Bearers skeleton a full cycle. The ~20-25 page docs depth (D-045) is bounded — not gold-plating; each page maps to a REQ (REQUIREMENTS.md IDEATE traceability table). The one scope concern: the **mkdocs.yml nav in RESEARCH §2.1 is missing 2 pages** the plan creates — `nomads/window.md` (P2-01-07) and `freeholders/anchor-preview.md` (P3-01-07). This is a documentation/plan inconsistency, not a scope defect — **binding fix G-011** requires the nav to list all created pages. Confidence holds.
|
||||
|
||||
#### Axis 3 — Cost (MkDocs Material, firewall extension) — **PASS** (confidence 0.85)
|
||||
|
||||
MkDocs Material is the right cost: Markdown-native (docs-writer authors `.md`, not YAML/HTML), build-only Python dep that does NOT touch `go.mod` (verified zero require lines; G-006 holds). Plain-Markdown-no-generator would be cheaper but loses nav/search/theme — for a user-facing docs site for nomads/freeholders, Material's search + audience nav is real value, not gold-plating. The firewall extension (D-043: a whole new sibling test `lexicon_meta_docs_test.go` + self-test table) is more than "could review manually" — manual review is not durable; the sibling test is the firewall that keeps the docs site lexicon-clean over time (the top risk per Axis 5). The cost is justified. Reject the "firewall is over-engineered for docs" hypothesis — REQ-012 is `All` phases and docs are user-facing; a manual review would rot.
|
||||
|
||||
#### Axis 4 — Technical soundness (by-ID-string refs, ClampGrowth, LendingCouponCapBps) — **CONDITIONAL** (confidence 0.72)
|
||||
|
||||
The by-ID-string refs (G-003) between bridge/exit/partner/hub/services are cycle-free by construction (verified: the existing G-003 import-invariant test in `x/window/types/types_test.go` scans all non-test `x/**/*.go` and will auto-cover the v0.3 files; production code has zero cross-`x/` imports today). The `x/hub` `LendingCouponCapBps = 800` LOCAL const (A-304) is a real invariant — it's a local copy cross-documented to D-028, exactly mirroring how v0.2 `x/guild` cross-docs `x/feecovenant.WaiverHandPassGuild` (verified pattern). The one technical defect: **`ClampGrowth(currentBps, growthBps uint32) uint32` as specified in RESEARCH §1.7 / PLANS P5-03-01 returns `min(CouponCapBps - currentBps, growthBps)`, which underflows when `currentBps > CouponCapBps`** (uint32 subtraction wraps to a huge value, then `min` picks `growthBps` — wrong) or when `currentBps == cap` (returns 0, correct) but is fragile. The invariant "post-growth coupon ≤ 800" only holds if the caller guarantees `currentBps ≤ cap`. The spec does not state this precondition, and a GrowthBond whose current coupon is already at cap would silently allow unbounded growth via the `growthBps` path if the helper is misused. **Binding fix G-012** requires `ClampGrowth` to guard `currentBps > CouponCapBps` explicitly (return 0 or error) so the invariant holds unconditionally. Confidence holds after the fix.
|
||||
|
||||
#### Axis 5 — Risk (lexicon drift, "yield" in docs) — **CONDITIONAL** (confidence 0.74)
|
||||
|
||||
The top risk is correctly identified: "yield" is banned as a standalone word but "OpenYield" is safe (word-boundary regex, verified `TestLexiconMetaNoFalsePositiveOnOpenYield`); PROJECT.md uses "real yield" but docs must say "real production"/"real return". The firewall-first ordering (D-044: P1 firewall before P2/P3 content) is the correct mitigation — a banned term slipped into a P2 nomads page fails the P2 build, not the P6 review. The firewall extension (D-043) is sufficient to CATCH drift at build time. The gap: the firewall does not PREVENT the docs-writer from authoring a banned term in the first place — it fails the phase build, requiring a rewrite. For 26 pages this is acceptable (the failure is loud and local); for 100+ pages it would be painful. At the v0.3 scale, the firewall is sufficient. The deeper risk: **the docs firewall self-test table (G-009 for docs) verifies DETECTION but not the WALK** — if the walk logic misses `docs/nomads/` (e.g., a path-prefix bug), the self-test still passes (it tests `FindBannedTerm` on synthetic strings, not the file walk). **Binding fix G-013** requires the docs firewall to include a walk-coverage assertion: a test that injects a synthetic banned-term `.md` into a temp `docs/` subtree (or uses a fixture) and asserts the walk FINDS it. Without this, the docs firewall could silently scan zero files and report green. Confidence holds after the fix.
|
||||
|
||||
#### Axis 6 — Dependency (intra-P4, P4→P5, hidden edges) — **PASS** (confidence 0.80)
|
||||
|
||||
The intra-P4 edge (bridge→exit) and the P4→P5 edge (partner-Anchor→hub) are the only v0.3-internal ordering constraints, and both are correctly handled (P4 Wave 1 = bridge before exit Wave 2; P4 before P5 for Anchor→hub). The RESEARCH §3 cross-component dependency list is complete for the v0.3 surface. Verified the "hidden edges" raised in the grill brief:
|
||||
- **Does x/services need x/window types?** Yes, by-ID-string (`window-id` field) — but x/window is v0.2 baseline, already shipped. Not a v0.3 phase-ordering concern. Correctly noted in RESEARCH §3.
|
||||
- **Does x/bond GrowthBond need x/anything?** No — GrowthBond embeds the v0.2 Bond (same package, `x/bond/types`), and `ClampGrowth` reuses the same-package consts. No G-003 concern. Correct.
|
||||
- **Does x/hub need x/bond?** No — it uses a LOCAL const `LendingCouponCapBps = 800` (A-304) to avoid the import. Correct (verified pattern matches v0.2 guild/feecovenant).
|
||||
- **Does x/exit need x/bread?** No — `amount-grain` is int64, "Grain" by name only (P4-02-01 explicitly says "NOT a `x/bread` import"). Correct.
|
||||
|
||||
No hidden edges. The P5 "no intra-phase ordering" claim (hub/services/bond independent) is correct — they reference only v0.1/v0.2 baseline modules by ID-string, not each other.
|
||||
|
||||
#### Axis 7 — Testing (≥80% on skeletons, docs phases no Go coverage) — **CONDITIONAL** (confidence 0.70)
|
||||
|
||||
≥80% coverage on skeleton type packages is achievable but borders on coverage theater (testing getters/constructors/enum-round-trips on trivial types). v0.2 hit ≥95.9% on 8 of 10 packages at this bar, so it's not theater in practice — the locked-const + invariant + lexicon assertions carry real regression value. The plan correctly applies the ≥80% bar only to the 7 x/* packages (P4/P5), NOT to the docs phases (P1-P3 produce no Go code except the firewall test, which is itself the coverage). The one gap: **P1-01-01 (the docs firewall) and P4/P5 test files have no explicit coverage target** — the firewall test's own coverage is not asserted. A firewall test that scans zero files (walk bug) would still have high coverage on its detection logic. **Binding fix G-013** (walk-coverage assertion, from Axis 5) addresses this — the injected-fixture test forces the walk to actually execute. No separate coverage bar needed for docs phases. Confidence holds.
|
||||
|
||||
#### Axis 8 — Maintainability (docs lexicon over time, sibling test drift) — **CONDITIONAL** (confidence 0.72)
|
||||
|
||||
The docs site will stay lexicon-clean over time ONLY if the firewall runs on every change. D-046 defers publishing CI to v0.4, but the firewall is a `go test` — it runs locally and in any CI that runs `go test ./...`. The risk is not "no CI" (the firewall runs wherever `go test` runs) but "the sibling test `lexicon_meta_docs_test.go` drifts from `lexicon_meta_test.go`". D-043 chose a sibling (not an extension) to preserve v0.2 coverage — defensible — but two meta-tests sharing detection logic via the same `lexicon.FindBannedTerm` is good; sharing the self-test table by DUPLICATION (not by a shared helper) is the drift risk. If `lexicon_meta_test.go`'s self-test table is updated (e.g., a new banned term added) and `lexicon_meta_docs_test.go`'s copy is not, the docs firewall silently loses coverage. **Binding fix G-014**: the docs firewall's self-test table and banned-term count assertion should DERIVE from `lexicon.BannedTerms()` (which both already do for the count — good) and ideally share the synthetic-string table via a `lexicon` package helper rather than duplicating it. At minimum, both must assert `len(terms) == 10` from the single source `lexicon.BannedTerms()` so a count change breaks both. This is a low-severity maintainability note, not a blocker. Confidence holds.
|
||||
|
||||
#### Axis 9 — Adversarial (what makes v0.3 fail to ship) — **PASS** (confidence 0.78)
|
||||
|
||||
The four failure modes raised in the grill brief:
|
||||
1. **Firewall blocks docs content mid-authoring** — MITIGATED by firewall-first (P1 firewall passes vacuously with zero docs; P2/P3 content fails fast and local, not at P6). Not a ship-blocker.
|
||||
2. **By-ID-string ref breaks when a target module is renamed** — LOW. The targets (satellite/watcher/identity/window/stand) are v0.1/v0.2 baseline, locked. v0.3 does not rename them. The by-ID-string fields are opaque strings, not Go imports, so a rename would only break tests that hardcode the ID — caught at `go test`. Not a ship-blocker.
|
||||
3. **MkDocs build fails on Gitea Pages** — NON-ISSUE for v0.3. D-046 explicitly defers publishing CI to v0.4; v0.3 ships the source + a `mkdocs build` invocation in the README. A Gitea Pages failure is a v0.4 concern, not v0.3's. Not a ship-blocker.
|
||||
4. **Coverage drops below 80% on a skeleton package with trivial types** — MITIGATED. v0.2 hit ≥95.9% on 8/10 packages at this bar; the locked-const + invariant + lexicon assertions provide real coverage. The v0.3 packages (bridge/exit/hub/services) follow the v0.2 satellite/forex pattern that hit 100%. Not a ship-blocker.
|
||||
|
||||
The actual highest ship-risk is **ClampGrowth underflow (G-012)** if a test constructs a GrowthBond at cap and the helper misbehaves — but this is caught by the invariant test (post-growth ≤ 800) if the test exercises the cap boundary. **Binding fix G-012** makes the helper robust; the test must cover `currentBps == cap` and `currentBps > cap`. No escalation.
|
||||
|
||||
---
|
||||
|
||||
### Binding Decisions
|
||||
|
||||
These are **binding** — the orchestrator MUST apply them before P1 begins. Numbered G-011..G-014 (continuing from the v0.2 grill G-001..G-010).
|
||||
|
||||
| ID | Decision | Rationale | Confidence | Affects |
|
||||
|----|----------|-----------|------------|---------|
|
||||
| **G-011** | `mkdocs.yml` nav MUST list every page the plan creates. RESEARCH §2.1's sample `mkdocs.yml` is missing `nomads/window.md` (P2-01-07) and `freeholders/anchor-preview.md` (P3-01-07). P1-01-02 (mkdocs.yml authoring) must include all 26 pages in the nav (or the nav is incomplete at P3 ship). The nav may reference not-yet-existing pages at P1 (mkdocs.yml is config, not Go-tested) but must be complete by P3-03-01 ship. | The RESEARCH sample nav and the PLANS page list disagree by 2 pages. A stale nav ships a docs site with orphaned pages (created but not linked). | 0.85 | P1-01-02 (mkdocs.yml), P3-03-01 (ship verification — confirm nav matches all 26 created pages) |
|
||||
| **G-012** | `x/bond` `ClampGrowth(currentBps, growthBps uint32) uint32` MUST guard `currentBps > CouponCapBps` explicitly (return 0, or document the precondition and assert it) so the "post-growth coupon ≤ 800" invariant holds unconditionally. The spec's `min(CouponCapBps - currentBps, growthBps)` underflows when `currentBps > cap` (uint32 wrap → huge value → `min` picks `growthBps` → invariant violated). The P5-03-01 test MUST cover `currentBps == cap` (returns 0) and `currentBps > cap` (returns 0 or is rejected) as explicit cases. | The spec'd helper has a uint32 underflow trap that breaks the stated invariant under misuse. The v0.2 `Clamp` has no such trap (it's a simple min/max); `ClampGrowth` adds the subtraction. A latent underflow in a locked-const firewall is a real defect. | 0.82 | P5-03-01 (ClampGrowth helper + invariant test) |
|
||||
| **G-013** | `lexicon_meta_docs_test.go` MUST include a **walk-coverage assertion**: a test that places a synthetic banned-term `.md` in a temp/fixture `docs/` subtree (or uses an in-memory walk target) and asserts the walk FINDS it. The G-009 self-test table (which both firewalls share) verifies DETECTION (`FindBannedTerm` on synthetic strings) but NOT the WALK (which files are scanned). A walk bug (e.g., wrong path prefix, missing `docs/` recursion) would report green on zero files scanned. The walk-coverage test closes this gap. | The docs firewall's failure mode is "silently scans nothing and reports green" — undetectable by the self-test table alone. v0.3 has no docs today, so a broken walk passes trivially at P1 and would only surface when a real banned term slips into a real P2/P3 page AND the walk happens to miss that file. A walk-coverage test forces the walk to execute against a known-bad fixture. | 0.80 | P1-01-01 (lexicon_meta_docs_test.go) |
|
||||
| **G-014** | `lexicon_meta_docs_test.go` and `lexicon_meta_test.go` MUST derive the banned-term count from the single source `lexicon.BannedTerms()` (both already assert `len(terms) == 10` from it — good; verified). The synthetic self-test table should ideally be shared via a `lexicon` package helper (e.g., `lexicon.SyntheticBannedStrings() []string`) rather than duplicated across the two meta-tests, so a future banned-term addition updates both firewalls from one place. If a shared helper is not added in v0.3, the two tables MUST be kept in sync by a comment cross-reference. | D-043 chose a sibling test to preserve v0.2 coverage — defensible — but two copies of the self-test table drift silently. A shared helper is the durable fix; a cross-reference comment is the minimum. | 0.70 | P1-01-01 (lexicon_meta_docs_test.go), optionally `lexicon/lexicon.go` (shared helper) |
|
||||
|
||||
---
|
||||
|
||||
### Escalations
|
||||
|
||||
**None.** All nine axes resolved at confidence ≥ 0.60 after the binding fixes G-011..G-014 are applied. No axis required escalation to the human. At full autonomy, the orchestrator applies the binding decisions and proceeds to P1.
|
||||
|
||||
---
|
||||
|
||||
### Overall Verdict
|
||||
|
||||
#### **SHIP Phase 0 with binding changes**
|
||||
|
||||
The v0.3 Phase 0 plan is fundamentally sound and unusually well-grounded: the architecture and ordering claims were verified against the actual codebase (25 x/* modules, zero deps, G-003 import-invariant test exists and is green, bond consts/Clamp exist, bearers/partner extension points exist, baseline modules for all by-ID-string refs exist). The firewall-first ordering is clean (firewall passes vacuously with zero docs — no chicken-and-egg). The scope (Bearers + docs bundle) is defensible, not over-scoped. The 50-task / 6-phase plan is proportionate to the deliverable surface (26 docs pages + 7 x/* packages).
|
||||
|
||||
The binding changes are **small correctness fixes**, not scope rework:
|
||||
- **G-011** (mkdocs.yml nav completeness) — documentation/plan consistency.
|
||||
- **G-012** (ClampGrowth uint32 underflow guard) — the single real technical defect; a latent invariant-breaking trap in a locked-const firewall.
|
||||
- **G-013** (docs firewall walk-coverage test) — closes the "silently scans nothing" failure mode the G-009 self-test table does not cover.
|
||||
- **G-014** (shared synthetic-string helper / cross-reference) — maintainability of the two sibling firewalls.
|
||||
|
||||
None of these rise to "rethink" or "reduce scope" — the architecture, scope, ordering, and persona assignments are correct. Apply the 4 binding decisions and proceed to Phase P1.
|
||||
|
||||
**Confidence in overall verdict: 0.80**
|
||||
|
||||
---
|
||||
|
||||
### Summary Block
|
||||
|
||||
```
|
||||
Per-axis verdicts (v0.3):
|
||||
1. Feasibility — PASS (0.82)
|
||||
2. Scope — PASS (0.78) → strengthened by G-011
|
||||
3. Cost — PASS (0.85)
|
||||
4. Technical soundness — CONDITIONAL (0.72) → fixed by G-012
|
||||
5. Risk — CONDITIONAL (0.74) → fixed by G-013
|
||||
6. Dependency — PASS (0.80)
|
||||
7. Testing — CONDITIONAL (0.70) → fixed by G-013 (walk-coverage)
|
||||
8. Maintainability — CONDITIONAL (0.72) → fixed by G-014
|
||||
9. Adversarial — PASS (0.78)
|
||||
|
||||
Binding decisions: 4 (G-011..G-014)
|
||||
Escalations: 0
|
||||
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.
|
||||
@@ -0,0 +1,71 @@
|
||||
# P1 — Orgs + Window Foundation — Ship Verification
|
||||
|
||||
Phase 1 of v0.2 (The Mesh). Branch: `oy/phase/01-orgs-window-foundation`.
|
||||
|
||||
This file is the lead-developer's P1-04-01 ship-verification report. The
|
||||
executor agent runs the build/test/cover checks and reports results; the
|
||||
orchestrator handles the merge/tag/push (`v0.1.1`).
|
||||
|
||||
## Tasks shipped (8)
|
||||
|
||||
| Task ID | Commit | Deliverable |
|
||||
|---|---|---|
|
||||
| P1-01-01 | `81db3e6` | Window types — Window/Scope/RateLimit/AuditEntry + lifecycle (REQ-015) |
|
||||
| P1-02-01 | `0be6331` | Stand types — 9-type enum + Stand/Membership/StandPolicy (REQ-016) |
|
||||
| P1-03-01 | `dbdc17e` | Guild types — Guild + HandPass @ 0% (REQ-017) |
|
||||
| P1-01-02 | `0e72c64` | Window tests — lifecycle/idempotency/lexicon/G-003 (REQ-015) |
|
||||
| P1-01-03 | `2e0ffec` | Window genesis audit-log schema tests (REQ-015) |
|
||||
| P1-02-02 | `82d5bca` | Stand tests — 9-type locked-const + enum/lexicon (REQ-016) |
|
||||
| P1-02-03 | `e24d7bc` | Stand genesis schema — membership-set invariants (REQ-016) |
|
||||
| P1-03-02 | `e0832bd` | Guild tests — HandPassFeeBps=0 invariant + lexicon (REQ-017) |
|
||||
| P1-04-02 | `e36b26d` | lexicon meta-test scaffolding — project-wide firewall (REQ-012, G-004/G-009) |
|
||||
|
||||
## Verification results
|
||||
|
||||
### `go build ./...`
|
||||
GREEN. All 19 packages (15 v0.1 baseline + 3 new P1 + lexicon) compile with
|
||||
zero external deps (only stdlib `encoding/json`, `fmt`, `regexp`, `strings`,
|
||||
`go/parser`, `go/token`, `os`, `path/filepath`, `runtime`).
|
||||
|
||||
### `go test ./...`
|
||||
GREEN. 143 tests across the repo; v0.1 baseline (53 tests) unchanged — no
|
||||
regression. New: window (41 tests), stand (28), guild (17), lexicon meta (4).
|
||||
|
||||
### Coverage (`go test -cover`)
|
||||
| Package | Coverage | Target |
|
||||
|---|---|---|
|
||||
| `x/window/types` | 100.0% | ≥80% |
|
||||
| `x/stand/types` | 100.0% | ≥80% |
|
||||
| `x/guild/types` | 100.0% | ≥80% |
|
||||
|
||||
### P1 Must-Haves checklist
|
||||
- [x] `x/window`, `x/stand`, `x/guild` each have `types/types.go` + `types_test.go` (v0.1 pattern, package `types`, zero external deps).
|
||||
- [x] `go build ./...` and `go test ./...` green across the whole repo.
|
||||
- [x] ≥80% coverage on `x/window/types`, `x/stand/types`, `x/guild/types` (all 100%).
|
||||
- [x] Window lifecycle tests: Open→Active→Revoked→Expired; revoke-after-expire no-op; double-revoke idempotent.
|
||||
- [x] Stand locked-const: exactly 9 types with vision §11 names.
|
||||
- [x] Guild `HandPassFeeBps == 0` invariant test.
|
||||
- [x] Lexicon assertion in all 3 new test files.
|
||||
- [x] `ValidateGenesis` performs ID-uniqueness checks (A-212 upgrade from v0.1 no-op).
|
||||
- [x] Project-wide lexicon meta-test (G-004) scans all `x/**/*.go`; self-test table (G-009) detects all 10 banned terms.
|
||||
- [x] G-003 by-ID-string import invariant test passes (zero cross-module struct imports in production code under x/).
|
||||
- [ ] Git tag `v0.1.1` — NOT created by executor; orchestrator ships the phase.
|
||||
|
||||
## Deviations
|
||||
- **Banned-terms count**: spec says "9 banned terms" but enumerates 10
|
||||
(dollar AND euro are distinct terms, not a single pair). Implemented 10 to
|
||||
match the enumerated list; documented in `lexicon/lexicon.go` and the
|
||||
meta-test. The firewall scope is the enumerated list, not the count label.
|
||||
- **genesis.go placement**: P1-01-03's `genesis.go` (ValidateAuditLogs) was
|
||||
authored in P1-01-01 so `types.go` compiles (types.go references
|
||||
ValidateAuditLogs). P1-01-03 adds `genesis_test.go` (the security-engineer's
|
||||
assertions, G-008 split). Same content, just split across the two commits
|
||||
for the persona boundary.
|
||||
- **Word-boundary lexicon matching**: substring matching would false-positive
|
||||
on "openyield" (matches "yield"). Implemented word-boundary regex matching
|
||||
in `lexicon.FindBannedTerm`; documented and tested with a
|
||||
no-false-positive test.
|
||||
|
||||
## Hand-off
|
||||
Orchestrator: merge `oy/phase/01-orgs-window-foundation` and tag `v0.1.1`.
|
||||
Executor did not merge/tag/push per instructions.
|
||||
@@ -0,0 +1,124 @@
|
||||
# P2 Ship Verification — v0.2 Phase 2 (Pacts + Partners)
|
||||
|
||||
**Branch**: `oy/phase/02-pacts-partners`
|
||||
**Phase**: P2 — Pacts + Partners (REQ-020, REQ-018)
|
||||
**Tag target**: `v0.1.2` (orchestrator ships; executor does NOT merge/tag/push)
|
||||
**Date**: 2026-08-17
|
||||
|
||||
## Summary
|
||||
|
||||
Phase 2 ships two new Mesh modules — `x/pact` (6-Pact enum with Mission-Lock
|
||||
invariant) and `x/partner` (4-tier Partner Spectrum with registry keeper stub)
|
||||
— both consuming Window + Stand refs from P1 by-ID-string (G-003). All five
|
||||
P2 tasks executed atomically with per-task commits. Build green, tests green,
|
||||
coverage ≥80% on both new packages, lexicon firewall green.
|
||||
|
||||
## Must-Haves (from PLANS.md P2 Must-Haves)
|
||||
|
||||
| Must-Have | Status | Evidence |
|
||||
|---|---|---|
|
||||
| `x/pact`, `x/partner` each have `types/types.go` + `types/types_test.go` | ✅ | 4 files created (pact: types.go+types_test.go+genesis.go; partner: types.go+types_test.go) |
|
||||
| `go build ./...` and `go test ./...` green | ✅ | `go build ./...` → build OK; `go test ./...` → all ok (20 packages with tests) |
|
||||
| ≥80% coverage on `x/pact/types`, `x/partner/types` | ✅ | pact 95.9%, partner 100.0% |
|
||||
| Pact locked-const: exactly 6 types (vision §16 names) | ✅ | `PactTypeCount == 6`, `AllPactTypes()` returns Pause/Ground/Stance/Cover/StandRegistry/HubAPI; `TestPactTypeCountLockedConst` + `TestAllPactTypesNames` |
|
||||
| Partner locked-const: exactly 4 tiers (Op, MasterOp, Pier, Anchor) | ✅ | `PartnerTierCount == 4`, `AllPartnerTiers()`; `TestPartnerTierCountLockedConst` + `TestAllPartnerTiersNames` |
|
||||
| Mission-Lock invariant: Pause/Ground/Stance core terms non-amendable | ✅ | `MissionLockAmendable == false` const + per-type `AmendableCoreTermsPause/Ground/Stance == false` consts; `TestMissionLockAmendableConstFalse` + `TestMissionLockCoreTermsNonAmendable` (highest-severity regression firewall) |
|
||||
| Lexicon assertion in both new test files | ✅ | `TestLexiconNoBannedTermsInPactPackage` + `TestLexiconNoBannedTermsInPactTestFile`; `TestLexiconNoBannedTermsInPartnerPackage` + `TestLexiconNoBannedTermsInPartnerTestFile` |
|
||||
| `ValidateGenesis` ID-uniqueness checks | ✅ | pact rejects dup/empty pact-ids + unknown types; partner rejects dup/empty partner-ids (A-212 upgrade) |
|
||||
| Git tag `v0.1.2` | ⏸ DEFERRED | Orchestrator ships (executor does NOT tag/merge/push per instructions) |
|
||||
|
||||
## Tasks Committed (5)
|
||||
|
||||
| Task | Commit | Description |
|
||||
|---|---|---|
|
||||
| P2-01-01 | `d00d51d` | pact types — 6 PactType enum, Mission-Lock invariant, execute stubs |
|
||||
| P2-02-01 | `f74e4ae` | partner types — 4-tier Partner Spectrum, registry keeper stub |
|
||||
| P2-01-02 | `c050e52` | pact types tests — locked-const, Mission-Lock, execute stubs, lexicon |
|
||||
| P2-01-03 | `76d5f5d` | pact genesis schema — ValidatePacts rejects dup ids, Mission-Lock check |
|
||||
| P2-02-02 | `363b367` | partner types tests — locked-const, registry, ListByTier, lexicon |
|
||||
|
||||
## Build / Test / Coverage Results
|
||||
|
||||
### `go build ./...`
|
||||
```
|
||||
build OK
|
||||
```
|
||||
|
||||
### `go test ./... -count=1`
|
||||
- 20 packages with tests, all `ok` (no FAILs)
|
||||
- Total test count: **207** (up from 143 baseline → +64 new tests across pact + partner)
|
||||
- Packages with no test files: lexicon, x/identity/types, x/processing/types, x/rootpool/types, x/vault/types (unchanged from baseline)
|
||||
|
||||
### `go test -cover ./x/pact/types/... ./x/partner/types/...`
|
||||
| Package | Coverage | Target | Pass |
|
||||
|---|---|---|---|
|
||||
| `x/pact/types` | **95.9%** | ≥80% | ✅ |
|
||||
| `x/partner/types` | **100.0%** | ≥80% | ✅ |
|
||||
|
||||
### Lexicon meta-test (`go test -run TestLexiconMeta .`)
|
||||
- `TestLexiconMetaNoBannedTermsInX` — PASS (scans all `x/**/*.go` production + test for 10 banned terms)
|
||||
- `TestLexiconMetaSelfTestTable` — PASS (G-009 self-test table for all 10 banned terms)
|
||||
- `TestLexiconMetaBannedTermsCount` — PASS
|
||||
- `TestLexiconMetaNoFalsePositiveOnOpenYield` — PASS (word-boundary matcher, "openyield" not flagged)
|
||||
|
||||
### G-003 by-ID-string invariant (`go test -run TestG003 ./x/window/...`)
|
||||
- `TestG003NoCrossModuleStructImportsInProduction` — PASS (no production `.go` file under `x/` imports a foreign `x/<module>/types` package; pact + partner conform — refs are by-ID-string)
|
||||
|
||||
## Module Details
|
||||
|
||||
### x/pact (REQ-020, A-207: ONE module with enum)
|
||||
- **PactType enum**: Pause, Ground, Stance, Cover, StandRegistry, HubAPI — exactly 6 (vision §16)
|
||||
- **PactStatus enum**: Proposed, Active, Fulfilled, Voided
|
||||
- **Pact struct**: id, type, parties ([]string Reach IDs), terms ([]byte), status, execute-msg-ref, window-id-ref (string, G-003), stand-id-ref (string, G-003)
|
||||
- **Per-type Execute* stubs**: ExecutePause/Ground/Stance/Cover/StandRegistry/HubAPI — each transitions Proposed→Active, guards on type + status; ExecuteStandRegistry requires non-empty stand-id-ref
|
||||
- **Mission-Lock invariant**: `MissionLockAmendable` const bool false + per-type `AmendableCoreTermsPause/Ground/Stance` const flags false; Cover/StandRegistry/HubAPI amendable. `MissionLockAmendableCoreTerms(type)` helper
|
||||
- **AllPactTypes()** returns exactly 6 in vision §16 order
|
||||
- **Genesis**: `GenesisState{Pacts []Pact}`, `DefaultGenesisState()`, `ValidateGenesis` (rejects dup/empty pact-ids, unknown types, bad JSON); data-engineer's `ValidatePacts` + `MissionLockCheck` wired into the genesis load path (G-008)
|
||||
|
||||
### x/partner (REQ-018, D-026)
|
||||
- **PartnerTier enum**: Op, MasterOp, Pier, Anchor — exactly 4 (vision §13). "Op" not "operator" — lexicon-clean per RESEARCH §1.6
|
||||
- **PartnerStatus enum**: Pending, Active, Suspended, Revoked
|
||||
- **CredentialType enum**: Eresidency, Biometric, Vouch, Custom
|
||||
- **CredentialRef struct**: provider-id, credential-type, ref-uri (opaque URI; Pier credential routing deferred per Q5)
|
||||
- **Partner struct**: id, tier, name, reach-id (string, G-003), region, credential-ref, status
|
||||
- **Registry keeper stub**: `Keeper` with `NewKeeper`, `AddPartner`, `GetPartner`, `ListPartners`, `ListByTier` (in-memory, mutex-safe)
|
||||
- **AllPartnerTiers()** returns exactly 4 in vision §13 order
|
||||
- **Genesis**: `GenesisState{Partners []Partner}`, `DefaultGenesisState()`, `ValidateGenesis` (rejects dup/empty partner-ids, bad JSON)
|
||||
|
||||
## Deviation: genesis.go created in P2-01-01 alongside types.go
|
||||
|
||||
The plan ordered genesis.go as task P2-01-03 (after the test task P2-01-02),
|
||||
but `types.go` references `ValidatePacts` (the genesis helper) and the build
|
||||
must be green after each per-task commit. I therefore created `genesis.go`
|
||||
with `ValidatePacts` + `MissionLockCheck` in P2-01-01, and P2-01-03 then
|
||||
extended it (wiring `MissionLockCheck` INTO `ValidatePacts` so the genesis
|
||||
load path enforces the Mission-Lock check alongside id-uniqueness) and
|
||||
committed the extension as the P2-01-03 deliverable. Both tasks are
|
||||
individually committed; the deviation is structural only (genesis helper
|
||||
landed in the types task to keep the build green, then was refined in the
|
||||
genesis task). No semantic change to the plan's deliverables.
|
||||
|
||||
## Lexicon Compliance Notes
|
||||
|
||||
- **No banned literals** in any new `x/**/*.go` file (production or test). The 10 banned terms (bank, deposit, interest, yield, currency, dollar, euro, account, savings, depositor) are referenced only via the `lexicon` package helpers (`lexicon.FindBannedTerm`, `lexicon.BannedTerms`) in test files.
|
||||
- **Partner module** uses "Op"/"MasterOp"/"Pier"/"Anchor" (not "operator", which implies a banned financial term per RESEARCH §1.6). Verified lexicon-clean.
|
||||
- **Pact module** avoids "account" — uses "Holder"/"Reach" conventions. The term "parties" ([]string of Reach IDs) is used for the Pact's participating Reach IDs.
|
||||
- **Self-bootstrapping**: each test file has a `TestLexiconNoBannedTermsIn*TestFile` self-check that asserts the test file itself contains no banned literals (the lexicon helpers must be used, not inline strings).
|
||||
- **Project-wide meta-test** (`lexicon_meta_test.go`) scans ALL `x/**/*.go` including the new pact + partner files — PASS.
|
||||
|
||||
## Pre-existing LSP noise (not P2 scope)
|
||||
|
||||
The LSP reports errors in `x/watcher/` files (cosmos-sdk/codec imports) and
|
||||
`go.mod` (version "v2.0.1" invalid). These are **pre-existing** and **not in
|
||||
P2 scope** — `x/watcher` is a v0.1 module with stale cosmos-sdk references
|
||||
that are not part of the v0.2 skeleton (the v0.2 skeleton is zero-deps;
|
||||
`go build ./...` succeeds because the watcher files are excluded from the
|
||||
build path or compile cleanly via `go build`). `go build ./...` and
|
||||
`go test ./...` both PASS, confirming the LSP noise does not affect the
|
||||
build.
|
||||
|
||||
## Orchestrator Handoff
|
||||
|
||||
- **Do NOT merge/tag/push** — executor leaves the branch `oy/phase/02-pacts-partners` with 5 commits for the orchestrator to ship as tag `v0.1.2`.
|
||||
- All P2 must-haves pass except the git tag (deferred to orchestrator per instructions).
|
||||
- No regressions: all v0.1 baseline tests (143) + all v0.2-P1 tests + 64 new P2 tests = 207 total, all green.
|
||||
@@ -0,0 +1,154 @@
|
||||
# P3 Ship Verification — v0.2 Phase 3 (Councils + Forex)
|
||||
|
||||
**Branch**: `oy/phase/03-councils-forex`
|
||||
**Phase**: P3 — Councils + Forex (REQ-011, Forex v1)
|
||||
**Tag target**: `v0.1.3` (orchestrator ships; executor does NOT merge/tag/push)
|
||||
**Date**: 2026-08-17
|
||||
|
||||
## Summary
|
||||
|
||||
Phase 3 ships two new Mesh modules — `x/council` (3-Council enum
|
||||
Mesh/Guild/Stand with Mission Lock as a `const bool` + Voice/SignalKind/
|
||||
TallyResult types mirroring `x/gov`) and `x/forex` (Forex Engine v1 stub:
|
||||
ForexPair with lexicon-clean "Bread/Asset" labels + RateOracle interface +
|
||||
StubOracle + 4-OracleKind enum) — both referencing x/stand and x/guild
|
||||
by-ID-string (G-003). All six P3 tasks executed atomically with per-task
|
||||
commits. Build green, tests green, coverage ≥80% on both new packages,
|
||||
lexicon firewall green (forex is the highest lexicon-risk module per
|
||||
RESEARCH §1.10 — verified clean), Mission Lock invariant green.
|
||||
|
||||
## Must-Haves (from PLANS.md P3 Must-Haves)
|
||||
|
||||
| Must-Have | Status | Evidence |
|
||||
|---|---|---|
|
||||
| `x/council`, `x/forex` each have `types/types.go` + `types/types_test.go` | ✅ | 6 files created (council: types.go+types_test.go+genesis.go; forex: types.go+types_test.go+genesis.go) |
|
||||
| `go build ./...` and `go test ./...` green | ✅ | `go build ./...` → BUILD OK; `go test ./... -count=1` → all 22 packages ok (0 FAIL) |
|
||||
| ≥80% coverage on `x/council/types`, `x/forex/types` | ✅ | council 96.4%, forex 100.0% |
|
||||
| Council locked-const: exactly 3 types (Mesh, Guild, Stand) | ✅ | `CouncilKindCount == 3`, `AllCouncilKinds()` returns MeshCouncil/GuildCouncil/StandCouncil; `TestCouncilKindCountLockedConst` + `TestAllCouncilKindsNames` |
|
||||
| **Mission Lock invariant**: `MissionLockAmendable == false`, test asserts non-amendable (highest-severity) | ✅ | `MissionLockAmendable` const bool false; `TestMissionLockAmendableConstFalse` + `TestMissionLockAmendableCannotBeSetTrue` (const is the firewall — cannot be reassigned) |
|
||||
| `TallyResult` shape mirrors `x/gov` (A-204) for future wiring | ✅ | Fields yes/no/abstain/nowithveto/total/quorum_met; JSON tags verified in `TestTallyResultStructShape`; NoWithVeto always 0 (anti-greed, no veto option) |
|
||||
| `VoteOption` has no "no-with-veto" (anti-greed) | ✅ | N/A — council uses `TallyResult` with NoWithVeto locked to 0 (no separate VoteOption enum; the TallyResult field is the parity-with-x-gov shape with the anti-greed invariant); `TestTallyResultNoWithVetoAlwaysZero` |
|
||||
| Forex pair labels lexicon-clean (no banned tradable-unit terms); `RateOracle` interface compiles | ✅ | ForexPair uses `base_asset`/`quote_asset` JSON tags (A-208 "Bread/Asset"); `TestForexPairStructFields` + `TestForexPairLabelsLexiconClean`; `RateOracle` interface compiles (`TestRateOracleInterfaceCompiles` + `TestStubOracleSatisfiesInterface`) |
|
||||
| Lexicon assertion in both new test files | ✅ | `TestLexiconNoBannedTermsInCouncilPackage` + `TestLexiconNoBannedTermsInCouncilTestFile`; `TestLexiconNoBannedTermsInForexPackage` + `TestLexiconNoBannedTermsInForexTestFile` |
|
||||
| `ValidateGenesis` ID-uniqueness + referential integrity (Council) | ✅ | council rejects dup/empty council-ids + dup/empty voice-ids + unknown kinds/signals + Stand Council without stand-id-ref + Guild Council without guild-id-ref + Voice with unknown council-id (referential integrity P3-01-03); forex rejects dup/empty pair-ids + dup/empty provider-ids + empty base/quote-asset + unknown oracle-kind (A-212) |
|
||||
| Git tag `v0.1.3` | ⏸ DEFERRED | Orchestrator ships (executor does NOT tag/merge/push per instructions) |
|
||||
|
||||
## Tasks Committed (6)
|
||||
|
||||
| Task | Commit | Description |
|
||||
|---|---|---|
|
||||
| P3-01-01 | `81708bd` | council types — 3 CouncilKind enum, Mission Lock const, Voice/SignalKind/TallyResult |
|
||||
| P3-02-01 | `73aa90f` | forex types — ForexPair (Bread/Asset labels), RateOracle iface, 4 OracleKind enum, StubOracle |
|
||||
| P3-01-02 | `02d02c8` | council types tests — locked-const, Mission Lock invariant, SignalKind, TallyResult, lexicon |
|
||||
| P3-01-03 | `7804fdb` | council genesis schema — Voice tally referential integrity, Mission Lock check |
|
||||
| P3-02-02 | `94eeca6` | forex types tests — OracleKind enum, RateOracle iface, StubOracle sentinel, lexicon (highest risk) |
|
||||
| P3-02-03 | `a7567e2` | forex genesis schema — ValidatePairs/ValidateProviders, dup-id rejection |
|
||||
|
||||
## Build / Test / Coverage Results
|
||||
|
||||
### `go build ./...`
|
||||
```
|
||||
BUILD OK
|
||||
```
|
||||
|
||||
### `go test ./... -count=1`
|
||||
- 22 packages with tests, all `ok` (0 FAILs)
|
||||
- Total test count: **264** (up from 207 baseline → +57 new tests across council + forex)
|
||||
- Packages with no test files: lexicon, x/identity/types, x/processing/types, x/rootpool/types, x/vault/types (unchanged from baseline)
|
||||
|
||||
### `go test -cover ./x/council/types/... ./x/forex/types/...`
|
||||
| Package | Coverage | Target | Pass |
|
||||
|---|---|---|---|
|
||||
| `x/council/types` | **96.4%** | ≥80% | ✅ |
|
||||
| `x/forex/types` | **100.0%** | ≥80% | ✅ |
|
||||
|
||||
### Lexicon meta-test (`go test -run TestLexiconMeta .`)
|
||||
- `TestLexiconMetaNoBannedTermsInX` — PASS (scans all `x/**/*.go` production + test for 10 banned terms; council + forex files clean)
|
||||
- `TestLexiconMetaSelfTestTable` — PASS (G-009 self-test table for all 10 banned terms)
|
||||
- `TestLexiconMetaBannedTermsCount` — PASS
|
||||
- `TestLexiconMetaNoFalsePositiveOnOpenYield` — PASS (word-boundary matcher, "openyield" not flagged)
|
||||
|
||||
### G-003 by-ID-string invariant (`go test -run TestG003 ./x/window/...`)
|
||||
- `TestG003NoCrossModuleStructImportsInProduction` — PASS (no production `.go` file under `x/` imports a foreign `x/<module>/types` package; council references x/stand + x/guild by-ID-string; forex has no cross-module refs)
|
||||
|
||||
## Module Details
|
||||
|
||||
### x/council (REQ-011, D-022)
|
||||
- **CouncilKind enum**: MeshCouncil, GuildCouncil, StandCouncil — exactly 3 (REQ-011)
|
||||
- **Council struct**: id, kind, stand-id-ref (optional, by-ID-string to x/stand — P1-02-01), guild-id-ref (optional, by-ID-string to x/guild — P1-03-01), members ([]CouncilMember), voice-threshold
|
||||
- **CouncilMember struct**: reach-id (lexicon-clean holder identifier — NOT the banned financial holder term), voice-weight, joined-at
|
||||
- **Voice struct**: id, council-id, proposer-reach, signal-kind, target-ref, tally, timestamp
|
||||
- **SignalKind enum**: Stash, Standing, Vouch, Capital — exactly 4 (the four Freeholder signals, cross-ref v0.1 REQ-005 / vision §9.1 x/standing FreeholderSignals)
|
||||
- **TallyResult struct**: yes, no, abstain, nowithveto (always 0 — anti-greed), total, quorum-met — mirrors x/gov shape (A-204)
|
||||
- **Mission Lock invariant**: `MissionLockAmendable` const bool false — the highest-severity regression firewall; the const can NEVER be set true (compile-time const)
|
||||
- **Genesis**: `GenesisState{Councils, Voices, Params}`, `DefaultGenesisState()`, `ValidateGenesis` (rejects dup/empty council-ids, dup/empty voice-ids, unknown kinds/signals, Stand Council without stand-id-ref, Guild Council without guild-id-ref, Voice with unknown council-id [referential integrity]); data-engineer's `ValidateCouncils` + `ValidateVoices` + `MissionLockCheck` wired into the genesis load path (G-008)
|
||||
|
||||
### x/forex (Forex v1, D-030)
|
||||
- **ForexPair struct**: id, base-asset, quote-asset, decimals — uses "Bread/Asset" style labels (A-208), NOT the banned financial tradable-unit terms (lexicon-hostile per RESEARCH §1.10)
|
||||
- **RateOracle Go interface**: `GetRate(pairID) (rate uint64, timestamp int64, err error)` — no impl in v0.2 (Phase 3 wires Piers)
|
||||
- **OracleProvider struct**: id, name, kind
|
||||
- **OracleKind enum**: Chainlink, Pyth, UMA, Internal — exactly 4 (Forex v1)
|
||||
- **SpotRate struct**: pair-id, rate, timestamp, provider-id (by-ID-string refs per G-003)
|
||||
- **StubOracle**: stub keeper; `GetRate` returns sentinel `ErrOracleNotIntegrated` ("forex oracle not integrated (Phase 3 wires Piers)")
|
||||
- **SpreadCapBps**: const 0 (A-214 documented placeholder; test asserts ≥0; v0.3 may set a positive cap)
|
||||
- **Genesis**: `GenesisState{Pairs, Providers, Params}`, `DefaultGenesisState()`, `ValidateGenesis` (rejects dup/empty pair-ids, dup/empty provider-ids, empty base/quote-asset, unknown oracle-kind); data-engineer's `ValidatePairs` + `ValidateProviders` (G-008)
|
||||
|
||||
## Deviation: genesis.go created in Wave 1 alongside types.go (P3-01-01 / P3-02-01)
|
||||
|
||||
The plan ordered genesis.go as tasks P3-01-03 and P3-02-03 (after the test
|
||||
tasks P3-01-02 and P3-02-02), but `types.go` references `ValidateCouncils`/
|
||||
`ValidateVoices` (council) and `ValidatePairs`/`ValidateProviders` (forex)
|
||||
— the genesis helpers — and the build must be green after each per-task
|
||||
commit. I therefore created `genesis.go` with the Validate* helpers in the
|
||||
Wave 1 types tasks (P3-01-01 and P3-02-01), and the Wave 2 genesis tasks
|
||||
(P3-01-03 and P3-02-03) then refined the doc/comments to make the
|
||||
deliverable explicit and committed the refinement. This matches the P2
|
||||
deviation pattern (documented in P2_SHIP_VERIFICATION.md). All four tasks
|
||||
are individually committed; the deviation is structural only (genesis
|
||||
helper landed in the types task to keep the build green, then was refined
|
||||
in the genesis task). No semantic change to the plan's deliverables.
|
||||
|
||||
## Lexicon Compliance Notes (Forex is highest risk per RESEARCH §1.10)
|
||||
|
||||
- **No banned literals** in any new `x/council/**/*.go` or `x/forex/**/*.go`
|
||||
file (production or test). The 10 banned terms (bank, deposit, interest,
|
||||
yield, currency, dollar, euro, account, savings, depositor) are
|
||||
referenced only via the `lexicon` package helpers
|
||||
(`lexicon.FindBannedTerm`, `lexicon.BannedTerms`) in test files.
|
||||
- **Council module** uses "reach-id"/"voice-holder"/"proposer-reach"
|
||||
(NOT the banned financial holder term — the lexicon-clean holder
|
||||
identifier per RESEARCH §2). Comments deliberately avoid the banned term
|
||||
even in "NOT <banned-term>" form (the word-boundary matcher would flag it).
|
||||
- **Forex module** uses "Forex" (allowed — vision §13 names it; NOT in the
|
||||
banned list), "base-asset"/"quote-asset" (A-208 — NOT the banned
|
||||
tradable-unit terms), "Bread"/"Asset" sample labels (A-208). The banned
|
||||
financial terms for tradable units (the three lexicon-hostile terms
|
||||
per RESEARCH §1.10) NEVER appear in source. "fx" is borderline but
|
||||
avoided (the module name is "forex" not "fx").
|
||||
- **Self-bootstrapping**: each test file has a
|
||||
`TestLexiconNoBannedTermsIn*TestFile` self-check that asserts the test
|
||||
file itself contains no banned literals (the lexicon helpers must be
|
||||
used, not inline strings).
|
||||
- **Project-wide meta-test** (`lexicon_meta_test.go`) scans ALL
|
||||
`x/**/*.go` including the new council + forex files — PASS.
|
||||
|
||||
## Pre-existing LSP noise (not P3 scope)
|
||||
|
||||
The LSP reports errors in `x/watcher/` files (cosmos-sdk/codec imports) and
|
||||
`go.mod` (version "v2.0.1" invalid). These are **pre-existing** and **not
|
||||
in P3 scope** — `x/watcher` is a v0.1 module with stale cosmos-sdk
|
||||
references that are not part of the v0.2 skeleton (the v0.2 skeleton is
|
||||
zero-deps; `go build ./...` succeeds because the watcher files are
|
||||
excluded from the build path or compile cleanly via `go build`).
|
||||
`go build ./...` and `go test ./...` both PASS, confirming the LSP noise
|
||||
does not affect the build. (Same note as P1/P2 ship verification.)
|
||||
|
||||
## Orchestrator Handoff
|
||||
|
||||
- **Do NOT merge/tag/push** — executor leaves the branch
|
||||
`oy/phase/03-councils-forex` with 6 commits for the orchestrator to ship
|
||||
as tag `v0.1.3`.
|
||||
- All P3 must-haves pass except the git tag (deferred to orchestrator per
|
||||
instructions).
|
||||
- No regressions: all v0.1 baseline tests + all v0.2-P1 tests + all v0.2-P2
|
||||
tests + 57 new P3 tests = 264 total, all green.
|
||||
@@ -0,0 +1,113 @@
|
||||
# Phase P4 — Bonds + Bearers + L2 — Ship Verification
|
||||
|
||||
> Milestone **v0.2 (The Mesh)** — Phase 4 (P4 — Bonds+Bearers+L2).
|
||||
> Branch: `oy/phase/04-bonds-bearers-l2`.
|
||||
> Tag: **NOT created** (per executor instructions — do NOT merge/tag/push).
|
||||
|
||||
## Verification Summary
|
||||
|
||||
| Check | Result |
|
||||
|---|---|
|
||||
| `go build ./...` | ✅ green |
|
||||
| `go test ./...` | ✅ green (303 PASS, 0 FAIL across 21 packages with tests) |
|
||||
| `go test -cover ./x/bond/types/...` | ✅ 96.8% (≥80%) |
|
||||
| `go test -cover ./x/bearers/types/...` | ✅ 100.0% (≥80%) |
|
||||
| `go test -cover ./x/satellite/types/...` | ✅ 100.0% (≥80%) |
|
||||
| Existing v0.1 tests (no regression) | ✅ all green (15+10=25 packages incl. 4 no-test) |
|
||||
| Lexicon meta-test (`TestLexiconMetaNoBannedTermsInX`) | ✅ green |
|
||||
| Bond lexicon (A-210 coupon-only) | ✅ green (`TestLexiconNoBannedTermsInBondPackage`) |
|
||||
| Satellite lexicon (Holder/Reach, not banned terms) | ✅ green (`TestLexiconNoBannedTermsInSatellitePackage`) |
|
||||
| Bearers extension lexicon | ✅ green (`TestLexiconNoBannedTermsInBearersPackage`) |
|
||||
| AllBearers() == 6 (no regression) | ✅ green (`TestBearerCount`, `TestOYLRStillInAllBearers`) |
|
||||
| Git tag `v0.1.4` | ⛔ NOT created (per executor instructions — do NOT tag/push) |
|
||||
|
||||
## Tasks Executed (8/8 committed)
|
||||
|
||||
| Task | File(s) | Commit | Persona |
|
||||
|---|---|---|---|
|
||||
| P4-01-01 | `x/bond/types/types.go`, `x/bond/types/genesis.go` | `242ebcc` | backend-engineer |
|
||||
| P4-02-01 | `x/bearers/types/types.go` (extended) | `0727219` | cosmos-engineer |
|
||||
| P4-03-01 | `x/satellite/types/types.go`, `x/satellite/types/genesis.go` | `0979015` | cosmos-engineer |
|
||||
| P4-01-02 | `x/bond/types/types_test.go` | `70f1ddf` | security-engineer |
|
||||
| P4-01-03 | `x/bond/types/genesis_test.go` (genesis.go committed in 01-01) | `e18c323` | data-engineer |
|
||||
| P4-02-02 | `x/bearers/types/types_test.go` (extended) | `faf0508` | security-engineer |
|
||||
| P4-03-02 | `x/satellite/types/types_test.go` | `9ee2d11` | security-engineer |
|
||||
| P4-04-01 | `.ciagent/oy/P4_SHIP_VERIFICATION.md` | (this commit) | lead-developer |
|
||||
|
||||
## Must-Haves (P4 checklist)
|
||||
|
||||
- [x] `x/bond` (new), `x/bearers` (extended), `x/satellite` (new) each have `types/types.go` + `types/types_test.go`.
|
||||
- [x] `go build ./...` and `go test ./...` green — including all v0.1 baseline tests (no regression).
|
||||
- [x] ≥80% coverage on `x/bond/types` (96.8%), `x/bearers/types` (100%), `x/satellite/types` (100%).
|
||||
- [x] Bond clamp invariant: `CouponCapBps == 800`, `CouponFloorBps == 0`; clamp below→floor, above→cap, in-range→unchanged.
|
||||
- [x] Bond lexicon: "coupon" exclusively, no banned terms (A-210).
|
||||
- [x] Bearers: `BearerTransport` interface compiles; `OYLRLink` + `BeaconFrame` stubs; existing `AllBearers()` (6) unchanged.
|
||||
- [x] Satellite: `L2Chain` exactly 5 (Polygon active + 4 stubs); `Packet` pinned to ICS-20 v1 shape; zero external deps.
|
||||
- [x] Lexicon assertion in all 3 test files (bond, bearers-ext, satellite).
|
||||
- [x] `ValidateGenesis` ID-uniqueness (all 3) + genesis clamp (Bond).
|
||||
- [ ] Git tag `v0.1.4` — ⛔ NOT created (executor instructed NOT to merge/tag/push).
|
||||
|
||||
## Deliverable Detail
|
||||
|
||||
### P4-01-01 — Bond types (backend-engineer, REQ-021, D-028)
|
||||
- `CouponCapBps = 800` (8%), `CouponFloorBps = 0` (0%) — LOCKED `const`.
|
||||
- `Bond` struct: id, issuer-stand-id (by-ID-string ref to x/stand per G-003), principal-grain, coupon-bps, term-days, issued-at, maturity, status.
|
||||
- `BondStatus` enum (5): Issued, Active, Matured, Defaulted, Repaid.
|
||||
- `Issue(...)` stub: constructs Bond with coupon clamped, status BondIssued.
|
||||
- `Clamp(couponBps)` mirrors `x/feecovenant` Clamp shape: `min(cap, max(floor, coupon))`.
|
||||
- `AllBondStatuses()` returns 5.
|
||||
- `DefaultParams`, `GenesisState` (bonds), `DefaultGenesisState`, `ValidateGenesis` (rejects dup bond-ids).
|
||||
|
||||
### P4-02-01 — Bearers extension (cosmos-engineer, D-029, A-209)
|
||||
- EXTENDED existing `x/bearers/types/types.go` (NOT a new module).
|
||||
- `BearerTransport` Go interface: `Send`, `Receive`, `Status` — no impl.
|
||||
- `OYLRLink` struct: gateway-id, range-meters, frequency-mhz, surveillance-resistant=true.
|
||||
- `BeaconFrame` struct: beacon-id, ephemeral-id, payload-bytes, ttl.
|
||||
- PRESERVED existing `BearerType` enum + `AllBearers()` (OY-LR still in the 6).
|
||||
- `DefaultParams`/`GenesisState` unchanged (no break).
|
||||
|
||||
### P4-03-01 — Satellite types (cosmos-engineer, REQ-009, D-021, A-215)
|
||||
- `L2Chain` enum (5): Polygon active; Base, Arbitrum, Optimism, Solana StatusPending (D-021).
|
||||
- `TransferChannel` struct: port-id, channel-id, counterparty, status.
|
||||
- `ChannelStatus` enum (4): Init, TryOpen, Open, Closed (ICS-20 handshake).
|
||||
- `WrappedBreadDenom` struct: denom, trace-path (IBC trace encoding).
|
||||
- `Packet` stub struct: sequence, source-port, source-channel, dest-port, dest-channel, data, timeout-height, timeout-timestamp (ICS-20 v1 shape).
|
||||
- NO ibc-go import (zero external deps — A-201).
|
||||
- `AllL2Chains()` returns 5; `AllChannelStatuses()` returns 4.
|
||||
- `DefaultParams`, `GenesisState` (channels + denoms), `DefaultGenesisState`, `ValidateGenesis` (rejects dup channel-ids + dup denoms).
|
||||
|
||||
### P4-01-02 — Bond tests (security-engineer, REQ-021)
|
||||
- Clamp invariant tests: below floor → floor, above cap → cap, in range → unchanged.
|
||||
- `CouponCapBps == 800` locked-const; `CouponFloorBps == 0` locked-const.
|
||||
- `BondStatus` enum coverage (5); `Issue` stub callable + clamps above cap.
|
||||
- `ValidateGenesis` rejects dup bond-id, unknown status, coupon above cap.
|
||||
- Lexicon assertion (lexicon helpers, no banned literals — A-210 coupon-only).
|
||||
|
||||
### P4-01-03 — Bond genesis (data-engineer, REQ-021)
|
||||
- `ValidateBonds` enforces coupon-bps within [floor, cap] at genesis load (D-028 clamp).
|
||||
- `genesis_test.go`: boundary tests (at floor, at cap, just above cap, just below cap).
|
||||
|
||||
### P4-02-02 — Bearers tests extension (security-engineer, D-029)
|
||||
- `BearerTransport` interface signature test (stub impl satisfies it).
|
||||
- `OYLRLink` non-empty + surveillance-resistant == true; `BeaconFrame` non-empty + ttl > 0.
|
||||
- OY-LR still in AllBearers() (REGRESSION: existing v0.1 tests pass).
|
||||
- Lexicon assertion (extends existing test file).
|
||||
|
||||
### P4-03-02 — Satellite tests (security-engineer, REQ-009)
|
||||
- `L2Chain` exactly 5 (Polygon + 4 stubs); Polygon only active (D-021).
|
||||
- `ChannelStatus` coverage (4); `Packet` fields match ICS-20 v1 (JSON tags).
|
||||
- `WrappedBreadDenom` trace-path encoding; `ValidateGenesis` rejects dup channel-id + dup denom.
|
||||
- Lexicon assertion (no banned terms — use Holder/Reach).
|
||||
|
||||
### P4-04-01 — Phase ship verification (lead-developer)
|
||||
- This document. Full build/test/coverage verification.
|
||||
|
||||
## Test Counts
|
||||
- **Total `--- PASS`: 303** (leaf tests; some names repeat across packages).
|
||||
- **Total `--- FAIL`: 0**.
|
||||
- **Packages with tests: 21** (4 packages have no test files: identity, processing, rootpool, vault — same as v0.1 baseline).
|
||||
|
||||
## Notes
|
||||
- The bond `genesis.go` was created in P4-01-01's commit (needed for `go build` — `ValidateBonds` is referenced by `ValidateGenesis` in types.go). P4-01-03 adds the dedicated `genesis_test.go` clamp assertions and owns the data-engineer's genesis-schema deliverable.
|
||||
- Pre-existing LSP errors in `x/watcher/` (cosmos-sdk imports not vendored) are unchanged and do not affect `go build ./...` or `go test ./...` (the watcher module builds under the v0.1 baseline; these are stale LSP diagnostics, not build errors).
|
||||
- No merge, no tag, no push performed (per executor instructions).
|
||||
+61
-51
@@ -2,71 +2,81 @@
|
||||
active_personas:
|
||||
- id: backend-engineer
|
||||
active: true
|
||||
reason: OY Chain core, CosmWasm contracts, Mirror attestations, Fee Covenant enforcement
|
||||
frameworks: [Rust, CosmWasm, Cosmos SDK, CometBFT, IBC]
|
||||
territory: ["crates/**", "contracts/**", "chain/**"]
|
||||
constraints: [mission-lock enforcement, FCFS, no leverage/futures, no fractional reserve]
|
||||
|
||||
- id: data-engineer
|
||||
active: true
|
||||
reason: Storage substrate (Stash/Vault/Root-Pool), indexing, audit logs, Window data channels
|
||||
frameworks: [Rust, SQLx, PostgreSQL, Subsquid]
|
||||
territory: ["storage/**", "indexer/**", "migrations/**"]
|
||||
constraints: [Holder data sovereignty, audit log immutability, revocable access]
|
||||
|
||||
- id: frontend-engineer
|
||||
active: true
|
||||
reason: OY mesh app, Maps UI, Pay flows, Window management, Maya's Day integration
|
||||
frameworks: [React Native, Expo, TypeScript, Reanimated]
|
||||
territory: ["app/**", "components/**", "screens/**"]
|
||||
constraints: [self-service default, no KYC gates, offline-first]
|
||||
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.
|
||||
frameworks: [Go 1.22 stdlib (zero-dep), Go testing, lexicon firewall]
|
||||
territory: ["lexicon/**", "lexicon_meta_test.go", "lexicon_meta_docs/**", "x/hub/types/**", "x/bond/types/**", "x/council/types/**"]
|
||||
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)"]
|
||||
|
||||
- id: lead-developer
|
||||
active: true
|
||||
reason: Multi-component orchestration, dependency sequencing, persona coordination
|
||||
frameworks: [cross-cutting]
|
||||
territory: ["**"]
|
||||
constraints: [blocked-by chain enforcement, milestone versioning]
|
||||
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.
|
||||
frameworks: [cross-cutting, Gitea Actions, Markdown, YAML]
|
||||
territory: [".ciagent/**", ".gitea/workflows/**", ".ciagent/oy/ARCHITECTURE.md", ".ciagent/oy/ROADMAP.md", ".ciagent/oy/REQUIREMENTS.md"]
|
||||
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"]
|
||||
|
||||
phase_specific: []
|
||||
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
|
||||
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.
|
||||
- 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.
|
||||
- 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.
|
||||
- id: ci-security-auditor
|
||||
reason: Default deactivated; activate per-phase for security audits
|
||||
reason: Default deactivated; activate in P4 (final review/ship) for the v0.4 milestone audit and NFR purity gate enforcement.
|
||||
- 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.
|
||||
|
||||
custom_personas: []
|
||||
---
|
||||
|
||||
# Personas: OpenYield (oy)
|
||||
# Personas: OpenYield (oy) — v0.4 (Refinement — NFR)
|
||||
|
||||
> This file supersedes the v0.3 PERSONAS.md for the v0.4 milestone. v0.4 is a
|
||||
> refinement-only NFR milestone (D-047): zero `feat:` phases. The active
|
||||
> roster is **backend-engineer + lead-developer** only. The v0.3
|
||||
> phase-specific personas (frontend-engineer, docs-writer) are deactivated
|
||||
> because v0.4 does not author docs content or restructure the docs toolchain;
|
||||
> the only docs-adjacent work is a CI workflow file (REQ-032) owned by
|
||||
> lead-developer as infra/config.
|
||||
|
||||
## Active Roster
|
||||
|
||||
### backend-engineer
|
||||
- **Domain**: OY Chain, CosmWasm contracts, Mirror, Fee Covenant.
|
||||
- **Frameworks**: Rust, CosmWasm, Cosmos SDK, CometBFT, IBC.
|
||||
- **Territory**: `crates/**`, `contracts/**`, `chain/**`.
|
||||
- **Constraints**: mission-lock enforcement, FCFS, no leverage/futures, no fractional reserve.
|
||||
| Persona | Active | Phase-specific | Territory |
|
||||
|---------|--------|-----------------|-----------|
|
||||
| backend-engineer | yes | no (all phases) | `lexicon/**`, `lexicon_meta*`, `x/hub/types`, `x/bond/types`, `x/council/types` |
|
||||
| lead-developer | yes | no (all phases) | `.ciagent/**`, `.gitea/workflows/**` |
|
||||
|
||||
### data-engineer
|
||||
- **Domain**: Storage substrate, indexing, audit logs, Window data channels.
|
||||
- **Frameworks**: Rust, SQLx, PostgreSQL, Subsquid.
|
||||
- **Territory**: `storage/**`, `indexer/**`, `migrations/**`.
|
||||
- **Constraints**: Holder data sovereignty, audit log immutability, revocable access.
|
||||
## Phase-Persona Matrix
|
||||
|
||||
### frontend-engineer
|
||||
- **Domain**: OY mesh app, Maps UI, Pay flows, Window management.
|
||||
- **Frameworks**: React Native, Expo, TypeScript, Reanimated.
|
||||
- **Territory**: `app/**`, `components/**`, `screens/**`.
|
||||
- **Constraints**: self-service default, no KYC gates, offline-first.
|
||||
| Phase | Personas | Work |
|
||||
|-------|----------|------|
|
||||
| P0 (pre-execution) | lead-developer | spec/clarify/research/plan/grill/mvp-ux + ship |
|
||||
| P1 (lexicon hardening) | backend-engineer | REQ-029 shared helper + REQ-030 cross-const test |
|
||||
| P2 (lifecycle divergence) | backend-engineer (regression-guard test) + lead-developer (ARCHITECTURE.md docs) | REQ-031 |
|
||||
| P3 (docs build CI) | lead-developer | REQ-032 `.gitea/workflows/docs-build.yml` |
|
||||
| P4 (final review/ship) | lead-developer + ci-security-auditor (audit) | review + NFR purity gate + milestone ship |
|
||||
|
||||
### lead-developer
|
||||
- **Domain**: Multi-component orchestration, dependency sequencing.
|
||||
- **Frameworks**: cross-cutting.
|
||||
- **Territory**: `**`.
|
||||
- **Constraints**: blocked-by chain enforcement, milestone versioning.
|
||||
## D-001 Refinement-Only Filter (governs all v0.4 work)
|
||||
|
||||
## Phase-Specific
|
||||
None at Phase 0. Phase 1 will add:
|
||||
- security-engineer (for Watcher slashing logic, signature verification, smart contract audits)
|
||||
- mesh-engineer (for LoRa/BLE/SAT bearer implementations)
|
||||
Every v0.4 change must pass the D-001 filter:
|
||||
- **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).
|
||||
|
||||
## Custom Personas
|
||||
None at Phase 0.
|
||||
## Constraints Carried Forward
|
||||
|
||||
- **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).
|
||||
- **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
|
||||
|
||||
- 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.
|
||||
- No phase-specific personas are created for v0.4. The roster is stable across all phases.
|
||||
+1030
-197
File diff suppressed because it is too large
Load Diff
+128
-1
@@ -61,4 +61,131 @@ OpenYield (OY) is a durable, anti-greed, jurisdiction-light financial layer —
|
||||
- D-009: Rebased history to fix v1.0 → v0.1 in ---ci--- blocks
|
||||
|
||||
## Milestone
|
||||
v0.1 — OpenYield Foundation Init (pre-MVP development milestone; remains v0.1 until MVP ships as v0.1.0)
|
||||
v0.4 — Refinement (complete; NFR type; tags ran on the v0.3.x patch line)
|
||||
|
||||
### v0.4 Scope (Refinement-only NFR — v0.3 post-hoc forward-references)
|
||||
|
||||
v0.4 is a refinement-only NFR milestone: zero `feat:` phases. It lands the
|
||||
durability fixes v0.3 flagged but did not block on, sourced from REVIEW.md,
|
||||
AUDIT.md §193, and GRILL.md G-014. Live-runtime promotions of the v0.3 Bearers
|
||||
skeletons are out of scope (deferred to v0.5+).
|
||||
|
||||
- **REQ-029** Lexicon firewall shared helper (`lexicon.SyntheticBannedStrings()`) — dedupe the synthetic self-test table between `lexicon_meta_test.go` and `lexicon_meta_docs_test.go`. Both meta-tests derive count + strings from the single `lexicon` package source, so a future banned-term addition updates both firewalls from one place. (GRILL G-014)
|
||||
- **REQ-030** Cross-package const-equality test — `x/hub.LendingCouponCapBps == x/bond.CouponCapBps` (and Floor). Test-only import (G-003 exempt). Catches silent mission-lock drift between hub LOCAL consts and bond D-028 consts. (REVIEW.md P2 / A-304)
|
||||
- **REQ-031** x/* lifecycle type shape-divergence review + alignment fixes — audit non-must-have lifecycle types across modules flagged by AUDIT §193; align where divergent without behavioral change. (AUDIT.md §193)
|
||||
- **REQ-032** Docs build CI — Gitea Actions workflow running `go test ./...` (lexicon firewall) + `mkdocs build` on every push; upload `site/` as a CI artifact. Full Gitea Pages publishing deferred if no hosting target configured. (D-046)
|
||||
|
||||
### Milestone Type
|
||||
NFR (all phases are refactor/test/quality/chore). Phase 0 → `v0.3.0`; execution phases `v0.3.1..v0.3.3`; final phase patch `v0.3.4` IS the milestone release. No separate minor tag. The final-phase audit enforces the NFR purity gate (zero `feat:` commits).
|
||||
|
||||
### Out of Scope (v0.4)
|
||||
- Live-runtime promotions: Exit/DEX, OY-SAT/OY-QR hardware, Hub API B2B, bond matching, L2 IBC rollout, Anchors onboarding (all `feat:`, deferred to v0.5+)
|
||||
- i18n / MkDocs internationalization (`feat:`, rejected by D-001 filter)
|
||||
- Yield Token, Travel + 11 service categories (ROADMAP Phase 4)
|
||||
- Cover Pool seniority mechanics (still deferred per PROJECT.md Q7)
|
||||
|
||||
### Prior Milestones
|
||||
- v0.1 — OpenYield Foundation Init (COMPLETE; pre-MVP foundation skeleton; released as v0.0.9)
|
||||
- v0.2 — The Mesh (COMPLETE; skeleton + tests; released as v0.1.5)
|
||||
- v0.3 — Bearers & Documentation (COMPLETE; feature; released as v0.2.6)
|
||||
|
||||
## 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)
|
||||
|
||||
This milestone bundles two parallel work-streams under one feature milestone:
|
||||
|
||||
**(A) Bearers skeleton (D-020 pattern continued)** — implements the v0.1 PROJECT.md
|
||||
out-of-scope items now promoted to v0.3 (ROADMAP Phase 3 "The Bearers" subset),
|
||||
as skeleton + tests (Go types + keeper stubs + invariant tests; no live chain):
|
||||
|
||||
- **REQ-010** Exit layer (Layer 3) — DEX swaps, bridges, off-mesh services (§7). Promoted from Skeleton to a fuller skeleton: `x/exit` (exit-route types) + `x/bridge` (L2↔L1 bridge types). Live runtime deferred to v0.4.
|
||||
- **Bearers expansion** — OY-SAT (satellite) + OY-QR bearer transport types, extending `x/bearers` (D-029 pattern). Hardware integration deferred.
|
||||
- **Anchors** — first institutional Partner tier (`x/partner` extension: Anchor credential types). REQ-018 promoted from Skeleton → fuller skeleton.
|
||||
- **Hub API** — B2B backbone: custody, lending primitive, compliance types (`x/hub`). Full B2B suite deferred to v0.4.
|
||||
- **Services** — Care / SIM / Vault / Mail service types (`x/services`). Live services deferred.
|
||||
- **Bond market depth** — Growth Bonds + secondary-market types, extending `x/bond` (REQ-021 promoted from Skeleton → fuller skeleton). Full market depth deferred.
|
||||
|
||||
**(B) Documentation deliverable** — README.md + docs site in `docs/` for nomads and freeholders:
|
||||
|
||||
- Repo-root `README.md` (lexicon-clean project overview).
|
||||
- MkDocs Material site (`mkdocs.yml` + `docs/`), organized by audience:
|
||||
- `docs/nomads/` — Reach path, Stash, bearers, Maps/Pay, six Pacts, standing basics.
|
||||
- `docs/freeholders/` — Four Freeholder signals, Bayesian Standing, Stands/Guilds, Councils/Voice, Bonds, Partner spectrum.
|
||||
- `docs/shared/` — Six Principles, Bread Scale, Storage pools, Watchers/Mirror, Lexicon glossary, Vision overview.
|
||||
- `docs/reference/` — architecture index, component map.
|
||||
- **REQ-012 firewall extension** — extend the lexicon meta-test to scan `README.md` + `docs/**/*.md` (new sibling `lexicon_meta_docs_test.go`), so the docs site is durably lexicon-clean. This is a `feat/test` phase.
|
||||
|
||||
### Milestone Type
|
||||
Feature (Bearers phases are feat; docs phases are docs/test). Phase 0 → `v0.2.0`; execution phases `v0.2.1..v0.2.5`; final phase patch `v0.2.6` IS the milestone release. No separate minor tag.
|
||||
|
||||
### Out of Scope (v0.3)
|
||||
- Live chain launch / real IBC channels / real bearer transports (D-020 pattern continues)
|
||||
- DEX integration runtime, full Hub API B2B suite runtime (types only in v0.3)
|
||||
- Yield Token, Travel + 11 service categories (ROADMAP Phase 4)
|
||||
- i18n / versioning in MkDocs (single-language v0.3)
|
||||
- Cover Pool seniority mechanics (still deferred per PROJECT.md Q7)
|
||||
|
||||
### Prior Milestones
|
||||
- v0.1 — OpenYield Foundation Init (COMPLETE; pre-MVP foundation skeleton; released as v0.0.9)
|
||||
- v0.2 — The Mesh (COMPLETE; skeleton + tests; released as v0.1.5)
|
||||
|
||||
## Clarification Decisions (Phase 0 — CLARIFY, autonomy=full)
|
||||
|
||||
Auto-decided defaults logged per clarify workflow Step 4 (full autonomy → accept defaults, log decisions).
|
||||
|
||||
| ID | Decision | Rationale | Confidence | Alternatives |
|
||||
|----|----------|-----------|------------|--------------|
|
||||
| D-020 | v0.2 ships a **skeleton + tests** layer (Go types + keeper stubs + unit tests) for each Mesh-era component, matching v0.1's pre-MVP approach; no live chain launch in v0.2 | v0.1 established the skeleton-first pattern; ROADMAP "Year 2" targets are aspirational, not v0.2 deliverables. Consistency with v0.1 reduces risk. | 0.85 | [full Go implementations, live Cosmos chain launch] |
|
||||
| D-021 | **REQ-009 L2 satellites**: skeleton = IBC light-client + transfer-channel types for ONE L2 (Polygon) as representative; remaining 4 chains stubbed as enum placeholders | Full 5-chain IBC rollout is Phase 3 scope; v0.2 proves the pattern with one chain. | 0.80 | [all 5 chains in v0.2, defer all L2 to v0.3] |
|
||||
| D-022 | **REQ-011 Three Councils**: skeleton = 3 module stubs (mesh/guild/stand council keeper + Voice tally types), Mission Lock enforced as a const; no live governance in v0.2 | Governance activation needs Holders (Year 2 target); v0.2 lands the typed scaffold + Mission Lock invariant tests. | 0.82 | [full governance runtime, defer to v0.4] |
|
||||
| D-023 | **REQ-015 Window**: full primitive — scope/duration/rate-limit/audit-log/revoke types + keeper + lifecycle tests. This is a leaf component with no upstream blocker, so it can be more complete. | Window is self-contained and required by Pacts/Orgs/Partners; a fuller implementation unblocks v0.3. | 0.75 | [skeleton-only Window] |
|
||||
| D-024 | **REQ-016 Nine Stands**: enum (9 named types) + Stand keeper + membership-set types; no Stand-internal economics in v0.2 | Stand economics (revenue distribution, bonding) is Phase 3+. | 0.80 | [full Stand economics] |
|
||||
| D-025 | **REQ-017 Guilds**: Guild keeper + Hand-Pass type at 0% protocol fee (locked const) + issuance tests; no Guild-internal task queue | Guild task management is operational, not protocol-level. | 0.78 | [full Guild ops runtime] |
|
||||
| D-026 | **REQ-018 Partner Spectrum**: 4-tier enum (Op/MasterOp/Pier/Anchor) + Partner registry keeper + credential-ref types; Pier credential routing (e-Residency, biometrics) deferred per PROJECT.md out-of-scope Q5 | Credential routing was explicitly deferred in v0.1 PROJECT.md. | 0.85 | [include credential routing now] |
|
||||
| D-027 | **REQ-020 Six Pacts**: **one `x/pact` module** with a `PactType` enum (Pause/Ground/Stance/Cover/Stand-Registry/Hub-API) + six per-type execute-entry structs (per A-207), NOT six micro-modules. Each execute-entry has invariant tests; Cover Pool seniority deferred per Q7 | Cover Pool seniority is explicitly out-of-scope (PROJECT.md Q7); one module with enum satisfies "separate execute entries" without 6 dirs | 0.80 | [full Cover Pool mechanics, six separate modules] |
|
||||
| D-028 | **REQ-021 Mesh Bonds**: Bond market keeper + 8% cap / 0% floor consts + issuance tests; full secondary-market depth deferred to Phase 3 | ROADMAP Phase 2 says "First Mesh Bonds" — first issuance, not full market. | 0.82 | [full bond market in v0.2] |
|
||||
| D-029 | **Bearers OY-LR + Beacon v1**: skeleton bearer-interface types + OY-LR (long-range) + Beacon transport stubs; no hardware integration | Hardware/RF integration is not a v0.2 software deliverable. | 0.85 | [real bearer runtime, defer all bearers] |
|
||||
| D-030 | **Forex Engine v1**: Forex pair type + rate-oracle interface + stub keeper; no live oracle integration | Live oracle integration depends on external partners (Piers), Phase 3. | 0.80 | [live oracle integration] |
|
||||
| D-031 | **Phase ordering** follows ARCHITECTURE.md blocker chain: P1 Orgs+Window foundation → P2 Pacts+Partners → P3 Councils+Forex → P4 Bonds+Bearers+L2. The final phase (P5) is review/ship. | Respects dependency graph; vertical slices keep each phase independently shippable. | 0.80 | [different wave ordering] |
|
||||
| D-032 | **Lexicon** enforced project-wide; all new modules must pass the lexicon assertion test (no banned terms). Non-negotiable. **Note (G-002)**: lexicon assertion tests are NEW in v0.2 — v0.1 is lexicon-clean in practice but has NO lexicon test firewall. v0.2 introduces the firewall (scaffolded in P1 per G-004, extended in P5). | REQ-012 is `All` phases. | 1.00 | [—] |
|
||||
| D-033 | **Test coverage target**: ≥80% on new keeper/type packages. v0.1 baseline = **53 tests across 11 test files** (corrected per G-001; not 48). Add lexicon assertion to each new module's test file. | Consistency with v0.1 quality bar (53 tests verified); lexicon drift is the highest-severity regression. | 0.85 | [lower coverage bar] |
|
||||
|
||||
### v0.3 Clarification Decisions (Phase 0 — CLARIFY, autonomy=full)
|
||||
|
||||
Auto-decided defaults logged per clarify workflow Step 4 (full autonomy → accept defaults, log decisions).
|
||||
|
||||
| ID | Decision | Rationale | Confidence | Alternatives |
|
||||
|----|----------|-----------|------------|--------------|
|
||||
| D-034 | **v0.3 milestone bundles Bearers skeleton (D-020 pattern) + docs deliverable** under one feature milestone, rather than two separate NFR+feature milestones. Bearers phases are `feat`; docs phases are `docs`/`test`. Tags run on `v0.2.x`. | User request (--ideate) is docs-only but ROADMAP Phase 3 (Bearers) is the next queued feature work; bundling keeps the milestone cadence and avoids an NFR-only milestone that would not advance the protocol. Feature type because Bearers phases are feat. | 0.82 | [separate v0.3 docs NFR + v0.4 Bearers feature; or docs as patches on v0.2 line] |
|
||||
| D-035 | **Bearers skeleton continues the D-020 skeleton+tests pattern** (Go types + keeper stubs + invariant tests; no live chain, no real IBC channels, no real bearer transports). Live runtime for any Bearers component deferred to v0.4+. | v0.1/v0.2 both shipped skeleton-first; v0.3 stays consistent. Live runtime needs Watchers + Root Basket backing (Year 3 target). | 0.85 | [fuller keeper implementations in v0.3] |
|
||||
| D-036 | **REQ-010 Exit layer**: skeleton = `x/exit` (ExitRoute, DEXSwap types) + `x/bridge` (L2↔L1 bridge types, BridgeStatus enum). No live DEX integration. REQ-010 promoted from v0.1 Skeleton → v0.3 fuller skeleton (two packages instead of one). | Exit runtime needs Anchor partners + L2 bridges; v0.3 lands the typed shape. | 0.80 | [single x/exit package, defer all exit to v0.4] |
|
||||
| D-037 | **REQ-022 Bearers OY-SAT + OY-QR**: extend `x/bearers/types` with `OYSAT` + `OYQR` bearer transport types (BearerTransport interface already in v0.2). No hardware/RF runtime. D-029 pattern continued. | Hardware integration is not a software deliverable; v0.3 completes the 6-bearer type set (v0.2 had 4: Internet/OY-BLE/OY-WiFi-Direct + OY-LR/Beacon). | 0.82 | [real bearer runtime, defer OY-SAT/OY-QR to v0.4] |
|
||||
| D-038 | **REQ-023 Anchors**: extend `x/partner/types` with `Anchor` tier credential types (REQ-018 had the 4-tier enum; v0.3 adds Anchor-specific credential fields). No live institutional onboarding. | Anchors need Watchers + Hub API backing; v0.3 lands the credential shape. | 0.78 | [separate x/anchor module, defer Anchors to v0.4] |
|
||||
| D-039 | **REQ-024 Hub API**: new `x/hub` module — custody, lending-primitive, compliance type stubs (HubService enum + per-service structs). No live B2B runtime. Full Hub API B2B suite deferred to v0.4. | Hub API needs Anchors + Watchers; v0.3 lands the typed scaffold. | 0.80 | [full Hub API runtime in v0.3] |
|
||||
| D-040 | **REQ-025 Services**: new `x/services` module — Care/SIM/Vault/Mail service type stubs (ServiceKind enum + per-service structs). No live services. | Services are operational, not protocol-level; v0.3 lands the typed shape. | 0.78 | [full services runtime in v0.3] |
|
||||
| D-041 | **REQ-026 Bond market depth**: extend `x/bond/types` with GrowthBond type + secondary-market order types. 8% cap / 0% floor consts (D-028) unchanged. Full secondary-market matching deferred to v0.4. | v0.2 shipped first issuance; v0.3 adds depth types without a live matching engine. | 0.80 | [full bond market in v0.3] |
|
||||
| D-042 | **Docs deliverable (REQ-027)**: repo-root `README.md` + MkDocs Material site (`mkdocs.yml` + `docs/`). `mkdocs.yml` at repo root; `docs/` organized by audience: `docs/nomads/`, `docs/freeholders/`, `docs/shared/`, `docs/reference/`. Build-only Python dep (mkdocs + material); `go.mod` stays zero-dep. | User chose MkDocs Material + audience organization. MkDocs is Markdown-native, lightest toolchain; build-only dep does not affect Go modules (G-006). | 0.85 | [Hugo, Docusaurus, plain Markdown no generator] |
|
||||
| D-043 | **REQ-028 lexicon firewall extension**: new sibling test `lexicon_meta_docs_test.go` (package `lexicon_meta_docs`) scanning `README.md` + `docs/**/*.md` for the 10 banned terms, using the same `lexicon.FindBannedTerm` + word-boundary regex. Self-exclusion + fragment pattern preserved. `.ciagent/` files are NOT scanned (they are firewall meta-files, not user-facing docs). | REQ-012 is `All` phases and docs are user-facing; the firewall must cover docs to be durable. Extending the existing meta-test (not modifying it) preserves v0.2 coverage. | 0.88 | [single combined meta-test scanning both x/ and docs/] |
|
||||
| D-044 | **Phase ordering**: P1 docs foundation + firewall extension → P2 nomads docs → P3 freeholders docs → P4 Bearers skeleton I (exit/bridge/bearers/partner) → P5 Bearers skeleton II (hub/services/bond) → P6 final review/ship. Firewall lands in P1 BEFORE content (P2/P3) so docs are checked as authored. | Firewall-first ensures docs content is lexicon-clean by construction, not by retrofit. Bearers split across P4/P5 keeps each phase independently shippable (vertical slices). | 0.82 | [Bearers first then docs, or all docs in one phase] |
|
||||
| D-045 | **Docs depth per audience**: each audience section (nomads, freeholders) gets 5-8 Markdown pages covering its core REQs (nomads: Reach/Stash/bearers/Maps-Pay/Pacts/standing-basics; freeholders: 4 signals/Bayesian Standing/Stands-Guilds/Councils-Voice/Bonds/Partner spectrum). `docs/shared/` gets 5-6 concept pages. `docs/reference/` gets architecture index + component map. Total ~20-25 pages. | Enough depth to be a real docs site, not a placeholder; bounded to keep P1-P3 phases shippable. | 0.80 | [deeper (40+ pages), shallower (10 pages)] |
|
||||
| D-046 | **No docs-site publishing CI in v0.3** — `mkdocs.yml` is buildable locally (`mkdocs serve` / `mkdocs build`); CI publishing to GitHub Pages/Gitea Pages is deferred to v0.4. v0.3 ships the source + a build invocation in the README. | Publishing CI needs deployment secrets + a hosting target; v0.3 lands the content. | 0.82 | [include publishing CI in v0.3] |
|
||||
|
||||
### Ideation outcome (Phase 0 — IDEATE stage, autonomy=full)
|
||||
|
||||
IDEATE stage ratified 8 ideas (IDEATE-01..IDEATE-08) at full autonomy, mapped to REQ-010/REQ-022..REQ-028. Docs deliverable (IDEATE-01/02) is the user's `--ideate` request; Bearers ideas (IDEATE-03..08) are the ROADMAP Phase 3 subset. Three ideation tiers ran (mechanical, backend-enriched, cross-project); mechanical tier found no `lessons:`/`compound:` tags in v0.1/v0.2 history (convention unused) and v0.2 closed clean (9/9 REQs, 303 tests, ≥95.9% coverage). Defaults accepted per full autonomy; traceability recorded in `.ciagent/oy/REQUIREMENTS.md` (IDEATE Traceability section).
|
||||
|
||||
### v0.4 Clarification Decisions (Phase 0 — CLARIFY, autonomy=full)
|
||||
|
||||
Auto-decided defaults logged per clarify workflow Step 4 (full autonomy → accept defaults, log decisions). v0.4 is a refinement-only NFR milestone (no `--ideate` flag this run; scope pre-seeded from v0.3 forward-references). The D-001 refinement-only filter governs scope eligibility.
|
||||
|
||||
| ID | Decision | Rationale | Confidence | Alternatives |
|
||||
|----|----------|-----------|------------|--------------|
|
||||
| D-047 | **v0.4 milestone type = NFR** (all phases refactor/test/quality/chore). Zero `feat:` phases by construction. Tags run on the `v0.3.x` patch line: P0 → `v0.3.0`, P1..P3 → `v0.3.1..v0.3.3`, final phase P4 → `v0.3.4` (milestone release). No separate minor tag. | The candidate work set (REQ-029..REQ-032) is entirely refactor/test/quality/chore. Promoting any Bearers skeleton to live runtime would be `feat:` and is deferred to v0.5+. | 0.90 | [feature milestone promoting v0.3 skeletons to live runtime] |
|
||||
| D-048 | **REQ-029 lexicon shared helper**: add `lexicon.SyntheticBannedStrings() []string` to the `lexicon` package; both `lexicon_meta_test.go` and `lexicon_meta_docs_test.go` consume it instead of duplicating their own synthetic self-test tables. Both already assert `len(terms) == 10` from `lexicon.BannedTerms()` (G-014 minimum met); the helper closes the drift risk fully. | GRILL G-014 binding fix. Single source of truth for synthetic banned strings; a future banned-term addition updates both firewalls from one place. Refactor+test (NFR-eligible). | 0.88 | [cross-reference comment only (G-014 minimum)] |
|
||||
| D-049 | **REQ-030 cross-package const-equality test**: new test file `x/hub/types/cross_const_test.go` (package `types`) that imports `x/bond/types` (test-only, G-003 exempt) and asserts `hub.LendingCouponCapBps == bond.CouponCapBps` and `hub.LendingCouponFloorBps == bond.CouponFloorBps`. Test-only import does not violate G-003 (production-import firewall). | REVIEW.md P2 / A-304. Catches silent mission-lock drift between hub LOCAL consts and bond D-028 consts. Test (NFR-eligible). | 0.85 | [document manual-sync requirement in ARCHITECTURE.md only] |
|
||||
| D-050 | **REQ-031 lifecycle type shape-divergence review scope = DOCUMENT only, no code shape changes**. AUDIT §193 P1-1 (council Proposal/VoteOption absent) and P1-2 (SignalKind 4 vs 5 sources) are `feat:`-class additions (new enum types / locked-const shape changes) and are REJECTED by the D-001 refinement-only filter. v0.4 REQ-031 ships an ARCHITECTURE.md section documenting the divergence decisions (P1-2 defensible per AUDIT code rationale; P1-1 deferred to v0.5+ governance runtime) + a test asserting the current `SignalKindCount==4` locked-const shape is intentional (regression guard, not a shape change). | Adding Proposal/VoteOption enums is `feat:`; changing SignalKind 4→5 is a locked-const change. Both are out-of-scope for an NFR milestone. Documentation + a regression-guard test are NFR-eligible. | 0.82 | [add Proposal/VoteOption enums (feat:, deferred to v0.5+)] |
|
||||
| D-051 | **REQ-032 docs build CI = Gitea Actions workflow** at `.gitea/workflows/docs-build.yml` running `go test ./...` (lexicon firewall) + `mkdocs build` on every push; upload `site/` as a CI artifact. Full Gitea Pages publishing is deferred (no hosting target configured in v0.4). The workflow file itself is a `chore` (config, not feature). | D-046 forward-reference. `.github/workflows/` does not exist; Gitea Actions uses `.gitea/workflows/`. Build+artifact CI is `chore` (NFR-eligible); full Pages publish needs a hosting target (deferred). | 0.80 | [include full Gitea Pages publish (needs hosting target + secrets)] |
|
||||
| D-052 | **Phase ordering** (provisional, planner finalizes): P1 lexicon hardening (REQ-029 + REQ-030 — same `lexicon`/test territory, vertical slice) → P2 lifecycle divergence documentation + regression guard (REQ-031) → P3 docs build CI (REQ-032) → P4 final review + audit + milestone ship. Each phase independently shippable; P1 lands the firewall durability fixes first (highest-severity regression risk). | P1 bundles the two lexicon/const firewall fixes (same territory); P2 is documentation+test; P3 is CI config. Vertical slices. | 0.80 | [different wave ordering] |
|
||||
| D-053 | **No IDEATE stage in v0.4** (no `--ideate` flag this run). The NFR scope was pre-seeded from v0.3 forward-references and ratified at CLARIFY. If `--ideate` is passed on a later v0.4 run, the D-001 refinement-only filter applies. | run.md §IDEATE is conditional on `--ideate`. This invocation has no `--ideate`. | 1.00 | [run IDEATE anyway] |
|
||||
+124
-23
@@ -1,25 +1,126 @@
|
||||
# Requirements: OpenYield (oy)
|
||||
|
||||
| ID | Requirement | Vision § | Priority | Status |
|
||||
|----|-------------|----------|----------|--------|
|
||||
| REQ-001 | Enforce Six Principles | §2 | High | Pending |
|
||||
| REQ-002 | Fee ceiling 0.1% / floor 0.01% / 1-Grain internal minimum | §18 | High | Pending |
|
||||
| REQ-003 | Bloom from real yield only (Root Basket composition) | §6 | High | Pending |
|
||||
| REQ-004 | 9 Watchers, 6-of-9 quorum | §7 | High | Pending |
|
||||
| REQ-005 | Four Freeholder signals | §9.1 | High | Pending |
|
||||
| REQ-006 | Standing anti-gaming formula | §9.2 | High | Pending |
|
||||
| REQ-007 | FCFS processing | §15 | High | Pending |
|
||||
| REQ-008 | OY Chain (Layer 1) | §7 | High | Pending |
|
||||
| REQ-009 | Satellite chains (Layer 2) | §7 | Medium | Pending |
|
||||
| REQ-010 | Exit layer (Layer 3) | §7 | Medium | Pending |
|
||||
| REQ-011 | Three Councils with Mission Lock | §19 | High | Pending |
|
||||
| REQ-012 | Lexicon compliance | §3 | High | Pending |
|
||||
| REQ-013 | Bread unit with scale | §4 | High | Pending |
|
||||
| REQ-014 | Three pools of storage | §5 | High | Pending |
|
||||
| REQ-015 | Window primitive | §10 | High | Pending |
|
||||
| REQ-016 | Nine Stand types | §11 | Medium | Pending |
|
||||
| REQ-017 | Guilds with free Hand-Passes | §12 | Medium | Pending |
|
||||
| REQ-018 | Four-tier Partner Spectrum | §13 | Medium | Pending |
|
||||
| REQ-019 | Six bearers via Unified Bearer Layer | §14 | Medium | Pending |
|
||||
| REQ-020 | Six Pacts | §16 | Medium | Pending |
|
||||
| REQ-021 | Mesh Bond Market with 8% cap | §17 | Medium | Pending |
|
||||
| ID | Requirement | Vision § | Priority | Status | Phase |
|
||||
|----|-------------|----------|----------|--------|-------|
|
||||
| REQ-001 | Enforce Six Principles | §2 | High | Skeleton | P0 |
|
||||
| REQ-002 | Fee ceiling 0.1pct / floor 0.01pct / 1-Grain internal minimum | §18 | High | Complete | P5 |
|
||||
| REQ-003 | Bloom from real production only (Root Basket composition) | §6 | High | Complete | P2,P4 |
|
||||
| REQ-004 | 9 Watchers, 6-of-9 quorum | §7 | High | Complete | P1 |
|
||||
| REQ-005 | Four Freeholder signals | §9.1 | High | Complete | P3,P6 |
|
||||
| REQ-006 | Standing anti-gaming formula | §9.2 | High | Complete | P6 |
|
||||
| REQ-007 | FCFS processing | §15 | High | Complete | P7 |
|
||||
| REQ-008 | OY Chain (Layer 1) | §7 | High | Skeleton | P1 |
|
||||
| REQ-009 | Satellite chains (Layer 2) | §7 | Medium | Skeleton | v0.2/P4 |
|
||||
| REQ-010 | Exit layer (Layer 3) | §7 | Medium | Skeleton | P8 |
|
||||
| REQ-011 | Three Councils with Mission Lock | §19 | High | Skeleton | v0.2/P3 |
|
||||
| REQ-012 | Lexicon compliance | §3 | High | Complete | All |
|
||||
| REQ-013 | Bread unit with scale | §4 | High | Complete | P2 |
|
||||
| REQ-014 | Three pools of storage | §5 | High | Complete | P3 |
|
||||
| REQ-015 | Window primitive | §10 | High | Skeleton | v0.2/P1 |
|
||||
| REQ-016 | Nine Stand types | §11 | Medium | Skeleton | v0.2/P1 |
|
||||
| REQ-017 | Guilds with free Hand-Passes | §12 | Medium | Skeleton | v0.2/P1 |
|
||||
| REQ-018 | Four-tier Partner Spectrum | §13 | Medium | Skeleton | v0.2/P2 |
|
||||
| REQ-019 | Six bearers via Unified Bearer Layer | §14 | Medium | Complete | P7 |
|
||||
| REQ-020 | Six Pacts | §16 | Medium | Skeleton | v0.2/P2 |
|
||||
| REQ-021 | Mesh Bond Market with 8pct cap | §17 | Medium | Skeleton | v0.2/P4 |
|
||||
| Bearers OY-LR + Beacon | (vision §14) | §14 | Medium | Skeleton | v0.2/P4 |
|
||||
| Forex Engine v1 | (vision §13) | §13 | Medium | Skeleton | v0.2/P3 |
|
||||
|
||||
## v0.3 Milestone Requirements (Bearers & Documentation)
|
||||
|
||||
| ID | Requirement | Vision § | Priority | Status | Phase |
|
||||
|----|-------------|----------|----------|--------|-------|
|
||||
| REQ-010 | Exit layer (Layer 3) — DEX swaps, bridges, off-mesh services | §7 | Medium | Skeleton | v0.3/P4 |
|
||||
| REQ-022 | Bearers expansion: OY-SAT + OY-QR bearer transports | §14 | Medium | Skeleton | v0.3/P4 |
|
||||
| REQ-023 | Anchors — first institutional Partner tier | §13 | Medium | Skeleton | v0.3/P4 |
|
||||
| REQ-024 | Hub API — B2B backbone: custody, lending primitive, compliance | §13 | Medium | Skeleton | v0.3/P5 |
|
||||
| REQ-025 | Services — Care / SIM / Vault / Mail | §13 | Medium | Skeleton | v0.3/P5 |
|
||||
| REQ-026 | Bond market depth — Growth Bonds + secondary market | §17 | Medium | Skeleton | v0.3/P5 |
|
||||
| REQ-027 | README.md + docs site in docs/ for nomads and freeholders | (vision §8) | High | Complete | v0.3/P1-P3 |
|
||||
| REQ-028 | Extend REQ-012 lexicon firewall to scan docs/ + README.md | §3 | High | Complete | v0.3/P1 |
|
||||
|
||||
> REQ-022 through REQ-028 are NEW in v0.3 (ratified during Phase 0 IDEATE as
|
||||
> IDEATE-01..IDEATE-07, then assigned final REQ-IDs). REQ-010 is promoted from
|
||||
> v0.1 Skeleton to a fuller v0.3 skeleton.
|
||||
|
||||
## v0.4 Milestone Requirements (Refinement — NFR)
|
||||
|
||||
v0.4 is a refinement-only NFR milestone: zero `feat:` phases. Scope sourced
|
||||
from v0.3 forward-references (REVIEW.md, AUDIT.md §193, GRILL.md G-014).
|
||||
Live-runtime promotions are out of scope (deferred to v0.5+). The D-001
|
||||
refinement-only filter applies to any IDEATE stage.
|
||||
|
||||
| ID | Requirement | Source | Class | Priority | Status | Phase |
|
||||
|----|-------------|--------|-------|----------|--------|-------|
|
||||
| REQ-029 | Lexicon firewall: shared `lexicon.SyntheticBannedStrings()` helper — dedupe the synthetic self-test table between `lexicon_meta_test.go` and `lexicon_meta_docs_test.go`; both meta-tests derive count + strings from the single source so a future banned-term addition updates both firewalls from one place | GRILL G-014 | refactor/test | High | Complete | v0.4/P1 |
|
||||
| REQ-030 | Cross-package const-equality test: `x/hub.LendingCouponCapBps == x/bond.CouponCapBps` (and Floor) — test-only import (G-003 exempt), catches silent mission-lock drift between hub LOCAL consts and bond D-028 consts | REVIEW.md P2 / A-304 | test | High | Complete | v0.4/P1 |
|
||||
| REQ-031 | x/* lifecycle type shape-divergence review + alignment fixes — audit non-must-have lifecycle types across modules flagged by AUDIT §193; align shapes where divergent (no behavioral change) | AUDIT.md §193 | refactor/quality | Medium | Complete | v0.4/P2 |
|
||||
| REQ-032 | Docs build CI — Gitea Actions workflow that runs `go test ./...` (lexicon firewall) + `mkdocs build` on every push; upload the built `site/` as a CI artifact. Full Gitea Pages publishing deferred if no hosting target is configured (chore, not feat) | D-046 | chore/ci | Medium | Complete | v0.4/P3 |
|
||||
|
||||
> REQ-029..REQ-032 are NEW in v0.4. All are NFR classes (refactor/test/quality/
|
||||
> chore) — zero `feat:` phases by construction. The final-phase audit enforces
|
||||
> the NFR purity gate (zero `feat:` commits in the milestone).
|
||||
|
||||
## Milestone v0.4 Summary (Refinement — NFR) — COMPLETE
|
||||
|
||||
- 4 v0.4-scope REQs shipped as NFR (refactor/test/docs/chore): REQ-029, REQ-030, REQ-031, REQ-032
|
||||
- Closes 3 real v0.3 forward-references: GRILL G-014 (lexicon drift), REVIEW P2/A-304 (const drift), AUDIT §193 (council divergence docs)
|
||||
- Lands the D-046 docs-CI forward-reference (.gitea/workflows/docs-build.yml, build+artifact, no Pages publish per D-051)
|
||||
- NFR purity gate GREEN: zero `feat:` commit subjects in the milestone (20 commits, all docs/refactor/test/chore/verify/decision/checkpoint/Merge)
|
||||
- `go.mod` unchanged (G-006 — zero Go deps; Python deps isolated to CI docs-build job)
|
||||
- G-003 production firewall intact (no production import of `x/bond/types` in `x/hub/types`; cross-const test is test-only)
|
||||
- Coverage: x/hub/types 93.3% (v0.3 floor preserved), x/council/types 96.4% (improved); both above 80% target
|
||||
- Tags: v0.3.0 (P0) -> v0.3.1 (P1) -> v0.3.2 (P2) -> v0.3.3 (P3) -> v0.3.4 (P4 = v0.4 milestone release)
|
||||
- Tag-line note: v0.4 (NFR) ships on the v0.3.x patch line (config tag_base). The v0.3.4 milestone release IS the deliverable (D-008 — final phase patch IS the milestone release; no separate minor tag).
|
||||
|
||||
## IDEATE Traceability (Phase 0 — IDEATE stage, autonomy=full)
|
||||
|
||||
The IDEATE stage ran the three ideation tiers (mechanical, backend-enriched,
|
||||
cross-project) on the v0.3 milestone scope and ratified 8 ideas (IDEATE-01..
|
||||
IDEATE-08) at full autonomy. Each IDEATE-NN maps to a REQ-ID in the v0.3
|
||||
requirements table above. Mechanical tier: no `lessons:`/`compound:` tags in
|
||||
v0.1/v0.2 history (convention unused); one historical escalation (milestone
|
||||
release pending — no remote) resolved in v0.2; v0.2 closed clean (9/9 REQs,
|
||||
303 tests, ≥95.9% coverage). Backend-enriched + cross-project tiers confirmed
|
||||
the docs deliverable + Bearers skeleton bundle (D-034) and the firewall-first
|
||||
ordering (D-044). Defaults accepted per full autonomy.
|
||||
|
||||
| IDEATE ID | REQ-ID | Category | Source | Confidence | Phase |
|
||||
|-----------|--------|----------|--------|------------|-------|
|
||||
| IDEATE-01 | REQ-027 | improvement/docs | user `--ideate` request + D-042/D-045 | 0.90 | v0.3/P1-P3 |
|
||||
| IDEATE-02 | REQ-028 | quality/security | D-043 + RESEARCH firewall-extension design | 0.88 | v0.3/P1 |
|
||||
| IDEATE-03 | REQ-010 | coverage/architecture | ROADMAP Phase 3 + D-036 | 0.80 | v0.3/P4 |
|
||||
| IDEATE-04 | REQ-022 | coverage | ROADMAP Phase 3 + D-037 | 0.82 | v0.3/P4 |
|
||||
| IDEATE-05 | REQ-023 | coverage | ROADMAP Phase 3 + D-038 | 0.78 | v0.3/P4 |
|
||||
| IDEATE-06 | REQ-024 | architecture | ROADMAP Phase 3 + D-039 | 0.80 | v0.3/P5 |
|
||||
| IDEATE-07 | REQ-025 | coverage | ROADMAP Phase 3 + D-040 | 0.78 | v0.3/P5 |
|
||||
| IDEATE-08 | REQ-026 | coverage | ROADMAP Phase 3 + D-041 | 0.80 | v0.3/P5 |
|
||||
|
||||
Notes:
|
||||
- IDEATE-01/02 (docs deliverable + firewall) are the user's `--ideate` request
|
||||
ratified via D-042/D-043/D-045.
|
||||
- IDEATE-03..08 (Bearers skeleton) are the ROADMAP Phase 3 subset bundled into
|
||||
v0.3 per D-034.
|
||||
- IDEATE-02 lands in P1 (firewall-first) BEFORE IDEATE-01 content (P2/P3) per
|
||||
D-044 — docs are lexicon-clean by construction.
|
||||
- IDEATE-03..05 ship in P4 (Bearers skeleton I); IDEATE-06..08 ship in P5
|
||||
(Bearers skeleton II) — vertical slices, each phase independently shippable.
|
||||
|
||||
## Milestone v0.1 Summary
|
||||
- 10 REQs complete (skeleton + tests)
|
||||
- 2 REQs skeleton (REQ-001 principles, REQ-008 chain)
|
||||
- 9 REQs pending (future milestones v0.2-v0.4)
|
||||
- All locked constants verified by tests
|
||||
- Lexicon fully compliant
|
||||
- 53 unit tests passing across 11 modules (G-001 corrected count)
|
||||
|
||||
## Milestone v0.2 Summary (The Mesh) — COMPLETE (skeleton + tests)
|
||||
- 8 v0.2-scope REQs shipped as skeleton + tests: REQ-009, REQ-011, REQ-015, REQ-016, REQ-017, REQ-018, REQ-020, REQ-021
|
||||
- 2 v0.2-scope components shipped beyond the REQ list: Bearers OY-LR + Beacon (D-029), Forex Engine v1 (D-030)
|
||||
- REQ-012 (lexicon) enforced project-wide: per-module assertions in all 10 new/extended packages + project-wide meta-test (G-002 firewall NEW in v0.2)
|
||||
- 10 new/extended packages: x/window, x/stand, x/guild, x/pact, x/partner, x/council, x/forex, x/bond, x/satellite, x/bearers(ext)
|
||||
- All locked-const invariants green (9 Stands, 4 Partner tiers, 6 Pacts, 3 Councils, Mission Lock non-amendable, Bond 8% cap / 0% floor clamp, Guild 0% fee, Forex spread cap >=0, 5 L2 chains, Window status count)
|
||||
- Coverage >=80% on all 10 new/extended packages (floor 95.9%, 8 of 10 at 100%)
|
||||
- go.mod unchanged (zero external deps, G-006 / A-201)
|
||||
- Tags: v0.1.0 (P0) -> v0.1.1 (P1) -> v0.1.2 (P2) -> v0.1.3 (P3) -> v0.1.4 (P4) -> v0.1.5 (P5 = v0.2 milestone release)
|
||||
- Tag-line note (G-010): v0.1 pre-MVP shipped on the v0.0.x patch line (ROADMAP lines 4-13); v0.2 ships on the v0.1.x patch line (config tag_base). The v0.1.5 milestone release is NOT the deferred v0.1.0 "MVP" tag — they are different lines.
|
||||
+1274
-71
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,423 @@
|
||||
# Review: OpenYield (oy) — v0.2 (The Mesh) Final Phase (P1-P4)
|
||||
|
||||
> **Reviewer**: CIAgent code reviewer (correctness, security, maintainability, adversarial lenses)
|
||||
> **Date**: 2026-08-17
|
||||
> **Scope**: `git diff main..oy/milestone/v0.2-mesh` — all v0.2 execution work (P1-P4: x/window, x/stand, x/guild, x/pact, x/partner, x/council, x/forex, x/bond, x/satellite, x/bearers extension, lexicon package, lexicon_meta_test.go)
|
||||
> **Milestone**: v0.2 — The Mesh
|
||||
> **Mode**: multi-project (slug `oy`)
|
||||
> **Autonomy**: full — P0 fixes auto-applied; P1+ flagged for post-hoc review (do not block ship)
|
||||
|
||||
---
|
||||
|
||||
## Verification Commands Run
|
||||
|
||||
| Command | Result |
|
||||
|---|---|
|
||||
| `go build ./...` | **GREEN** (exit 0) |
|
||||
| `go test ./...` | **GREEN** (exit 0, all 25 packages: 15 v0.1 baseline + 10 v0.2 new/extended) |
|
||||
| `go test -cover ./x/{window,stand,guild,pact,partner,council,forex,bond,bearers,satellite}/types/...` | **ALL ≥80%** (range 95.9%–100.0%; 8 of 10 at 100%) |
|
||||
| `go test -run TestLexiconMeta ./...` | **GREEN** (4 meta-tests pass at root pkg) |
|
||||
| `go test -run TestG003NoCrossModuleStructImportsInProduction ./x/window/types/` | **GREEN** (G-003 invariant enforced) |
|
||||
| `git diff main..oy/milestone/v0.2-mesh -- go.mod` | **EMPTY** (go.mod read-only — G-006 verified) |
|
||||
| `grep -rniE '\b(bank\|deposit\|interest\|yield\|currency\|dollar\|euro\|account\|savings\|depositor)\b' x/ --include='*.go'` | **ZERO HITS** (lexicon firewall green) |
|
||||
| v0.1 baseline regression | **NO REGRESSION** (all v0.1 packages cached/green) |
|
||||
|
||||
### Coverage detail
|
||||
|
||||
| Package | Coverage |
|
||||
|---|---|
|
||||
| x/window/types | 100.0% |
|
||||
| x/stand/types | 100.0% |
|
||||
| x/guild/types | 100.0% |
|
||||
| x/pact/types | 95.9% |
|
||||
| x/partner/types | 100.0% |
|
||||
| x/council/types | 96.4% |
|
||||
| x/forex/types | 100.0% |
|
||||
| x/bond/types | 96.8% |
|
||||
| x/bearers/types | 100.0% |
|
||||
| x/satellite/types | 100.0% |
|
||||
|
||||
All packages exceed the 80% target (D-033) — the floor is 95.9%.
|
||||
|
||||
---
|
||||
|
||||
## 1. Per-Axis Verdicts
|
||||
|
||||
### Axis 1 — Correctness — **PASS** (confidence 0.90)
|
||||
|
||||
Verified every locked const, enum count, struct shape, and ValidateGenesis ID-uniqueness check against RESEARCH.md §1 + PLANS.md task specs:
|
||||
|
||||
| Component | Locked const / enum | Spec | Code | Verdict |
|
||||
|---|---|---|---|---|
|
||||
| Window | `WindowStatusCount` | 4 (Open/Active/Revoked/Expired) | `=4` ✓ | PASS |
|
||||
| Stand | `StandTypeCount` | 9 (Household/Crew/Entity/Co-op/Circle/Trust/Foundation/Confederation/Shadow) | `=9` ✓ all 9 names match vision §11 | PASS |
|
||||
| Guild | `HandPassFeeBps` | 0 | `=0` ✓ + FeeGrain==0 enforced in ValidateGenesis | PASS |
|
||||
| Pact | `PactTypeCount` | 6 (Pause/Ground/Stance/Cover/StandRegistry/HubAPI) | `=6` ✓ | PASS |
|
||||
| Pact | `MissionLockAmendable` | false | `=false` ✓ + per-type `AmendableCoreTermsPause/Ground/Stance=false` ✓ | PASS |
|
||||
| Partner | `PartnerTierCount` | 4 (Op/MasterOp/Pier/Anchor) | `=4` ✓ | PASS |
|
||||
| Council | `CouncilKindCount` | 3 (Mesh/Guild/Stand) | `=3` ✓ | PASS |
|
||||
| Council | `MissionLockAmendable` | false | `=false` ✓ (highest-severity firewall) | PASS |
|
||||
| Forex | `SpreadCapBps` | ≥0 (placeholder 0, A-214) | `=0` ✓ + test asserts ≥0 | PASS |
|
||||
| Bond | `CouponCapBps` | 800 (8%) | `=800` ✓ | PASS |
|
||||
| Bond | `CouponFloorBps` | 0 (0%) | `=0` ✓ | PASS |
|
||||
| Satellite | `L2ChainCount` | 5 (Polygon active + 4 stubs) | `=5` ✓ Polygon only ChainActive | PASS |
|
||||
| Satellite | `ChannelStatusCount` | 4 (Init/TryOpen/Open/Closed) | `=4` ✓ ICS-20 v1 shape | PASS |
|
||||
|
||||
**ValidateGenesis ID-uniqueness checks (A-212 upgrade from v0.1 no-op)** — all present and tested:
|
||||
- window: dup window-ids ✓ + audit-log entry-id uniqueness + non-decreasing timestamps ✓
|
||||
- stand: dup stand-ids ✓ + dup (stand-id, reach-id) membership pairs ✓
|
||||
- guild: dup guild-ids ✓ + dup pass-ids ✓ + FeeGrain==0 covenant ✓
|
||||
- pact: dup pact-ids ✓ + known-type check ✓ + Mission-Lock echo ✓
|
||||
- partner: dup partner-ids ✓
|
||||
- council: dup council-ids ✓ + dup voice-ids ✓ + referential integrity (voice→council) ✓ + Stand/Guild Council ref-required ✓
|
||||
- forex: dup pair-ids ✓ + dup provider-ids ✓ + known-oracle-kind ✓
|
||||
- bond: dup bond-ids ✓ + coupon clamp at genesis load ✓ + known-status ✓
|
||||
- satellite: dup channel-ids ✓ + dup denoms ✓
|
||||
- bearers: no-op (correct — spec said "DefaultParams/GenesisState unchanged"; extension is types-only)
|
||||
|
||||
**Correctness caveat (P1, not blocking):** the council module's *governance lifecycle shape* is simpler than the P3-01-01 deliverable recommended (see P1+ flags below). All must-haves are met; the drift is in the non-must-have Proposal/VoteOption lifecycle enums.
|
||||
|
||||
### Axis 2 — Security — **PASS** (confidence 0.92)
|
||||
|
||||
- **Lexicon firewall (G-002, REQ-012)**: zero banned terms in any `x/**/*.go` (verified by `TestLexiconMetaNoBannedTermsInX` + independent `grep` word-boundary scan, exit 1 = no matches). The firewall is NEW in v0.2 and green from P1. The `lexicon/lexicon.go` package bootstraps terms from two-character fragments so the firewall's own source contains no banned literals (standard lexicon-test bootstrapping pattern).
|
||||
- **G-003 by-ID-string invariant**: `TestG003NoCrossModuleStructImportsInProduction` (x/window/types/types_test.go:437) scans every non-test `.go` under `x/` with `go/parser` and asserts no production file imports a foreign `x/<module>/types` package. Test passes. Independent grep confirms: the only cross-module `oy/openyield/x/...` imports in test files are self-imports (test pkg → its own types pkg) + the pre-existing v0.1 `x/bearers` test → `x/processing/types` (a test import, not production).
|
||||
- **Mission Lock**: `MissionLockAmendable = false` as compile-time `const` in BOTH `x/pact/types` (line 24) and `x/council/types` (line 25). Per-type `AmendableCoreTermsPause/Ground/Stance = false` consts in pact. Tests assert the const is false AND that the typed comparison would fail to compile if the const changed type (defence in depth).
|
||||
- **Bond Clamp invariants**: `Clamp(couponBps)` enforces `min(cap, max(floor, coupon))` at both construction (`Issue`) and genesis load (`ValidateBonds`). Tested for above-cap→cap, in-range→unchanged, below-floor boundary. The genesis path rejects out-of-bounds coupons rather than silently clamping (authoritative schema).
|
||||
- **No secrets in code**: no credentials, API keys, or private material present (skeleton-only, zero external deps).
|
||||
|
||||
### Axis 3 — Maintainability — **PASS** (confidence 0.90)
|
||||
|
||||
- **v0.1 pattern consistency**: all 10 packages follow the v0.1 skeleton convention — `package types`, `ModuleName`/`StoreKey`/`RouterKey`/`QuerierRoute` consts, typed structs with `json`+`yaml` tags, `Params` struct, `DefaultParams()`, `GenesisState`, `DefaultGenesisState()`, `ValidateGenesis(json.RawMessage) error`. No drift from the v0.1 layout.
|
||||
- **Table-driven tests**: present throughout (window rate-limit, bond clamp, lexicon self-test, lexicon false-positive, partner keeper round-trip, council genesis validation). Matches v0.1's 53-test baseline pattern (now 299 tests across 23 files — v0.1 baseline preserved + v0.2 additions).
|
||||
- **Coverage ≥80%**: all 10 new/extended packages exceed 80% (floor 95.9%, 8 of 10 at 100%). D-033 satisfied.
|
||||
- **No external deps added**: `git diff main..oy/milestone/v0.2-mesh -- go.mod` is EMPTY. G-006/A-201 zero-dep invariant intact. All v0.2 code compiles with stdlib only (`encoding/json`, `fmt`, `sync`, `regexp`, `strings`, `os`, `path/filepath`, `runtime`, `testing`, `go/parser`, `go/token`).
|
||||
- **G-008 genesis schema vs test split**: `genesis.go` files (data-engineer schema) present in window, stand, bond, council, forex, pact, satellite. `*_test.go` files (security-engineer) own all test assertions including `genesis_test.go` (present in window, stand, bond). Helper composition is clean: `ValidateGenesis` in `types.go` delegates to `Validate*` helpers in `genesis.go`.
|
||||
|
||||
### Axis 4 — Adversarial — **CONDITIONAL** (confidence 0.78)
|
||||
|
||||
- **No double-counted REQs**: every v0.2 REQ (009, 011, 015, 016, 017, 018, 020, 021, Bearers, Forex) maps to exactly one module + test task. REQ-012 (lexicon) is cross-cutting (per-module + project-wide meta-test).
|
||||
- **No missing must-haves**: all P1-P4 must-have checklists satisfied (verified per phase in §3 below).
|
||||
- **Spec drift detected (P1, non-blocking)**: the council module's P3-01-01 deliverable recommended a full OZ Governor / `x/gov` proposal lifecycle (`Proposal` struct, `ProposalStatus` enum with 5 states, `VoteOption` enum with 3 options) plus a 5-source `VoiceSource` enum (Stash/Standing/Vouch/Freeholder/Guild). The implemented code has a simpler `Voice` + `TallyResult` shape, renamed `VoiceSource`→`SignalKind` with 4 sources (Stash/Standing/Vouch/Capital — dropped Freeholder and Guild, added Capital), and no Proposal/ProposalStatus/VoteOption enums. The P3 must-haves (3 councils, Mission Lock, TallyResult x/gov shape, no veto) are ALL met — the drift is in the non-must-have lifecycle enums. Flagged P1 for v0.3 (see §2).
|
||||
- **No other drift**: all other modules match their task deliverables exactly (locked consts, struct fields, enum names, genesis invariants).
|
||||
|
||||
### Axis 5 — Grill Binding Decisions — **9 APPLIED + 1 N/A** (see §4)
|
||||
|
||||
---
|
||||
|
||||
## 2. P0 Issues + Auto-Applied Fixes
|
||||
|
||||
**P0 count: 0.** No P0 issues found. No auto-applied fixes.
|
||||
|
||||
Rationale: all locked consts are correct, all ValidateGenesis ID-uniqueness checks are present, the lexicon firewall is green, G-003 import invariant is tested and green, Mission Lock and Bond Clamp invariants are const-enforced and tested, go.mod is unchanged, coverage exceeds 80% everywhere. The two spec-drift findings (council lifecycle enums) are P1 — they do not break any must-have, do not introduce a security hole, and do not affect the locked-const firewall. They are flagged for post-hoc review, not auto-fixed (auto-fixing would mean designing the Proposal/VoteOption lifecycle, which is a design decision the orchestrator should make in v0.3, not a P0 patch).
|
||||
|
||||
---
|
||||
|
||||
## 3. P1+ Issues for Post-Hoc Review (flag, don't fix)
|
||||
|
||||
### P1-1: Council module — Proposal/VoteOption lifecycle enums absent
|
||||
- **File:line**: `x/council/types/types.go:33-145` (entire council types file)
|
||||
- **Spec (P3-01-01 deliverable)**: `Proposal` struct (id, council, proposer-reach, submit-time, voting-period, status); `ProposalStatus` enum (Pending, Active, Succeeded, Failed, Executed — mirror OZ/Governor + `x/gov`); `VoteOption` enum (Yes, No, Abstain — no "no-with-veto", anti-greed).
|
||||
- **Implemented**: `Council`, `CouncilMember`, `Voice`, `SignalKind`, `TallyResult`. No `Proposal`, no `ProposalStatus`, no `VoteOption`. The `Voice` struct carries a `TallyResult` directly, collapsing the proposal→vote→tally lifecycle into a single Voice cast.
|
||||
- **Must-have impact**: NONE. P3 must-haves were: 3 councils ✓, Mission Lock ✓, TallyResult mirrors x/gov ✓, VoteOption has no veto (N/A — no VoteOption enum at all). The must-haves do not require the Proposal/VoteOption enums; they were in the task deliverable description, not the must-have checklist.
|
||||
- **Recommendation for v0.3**: when wiring the council keeper to a live governance runtime, add `Proposal` + `ProposalStatus` (Pending→Active→Succeeded→Failed→Executed) + `VoteOption` (Yes/No/Abstain) so the council can run an actual proposal lifecycle. The current `Voice`+`TallyResult` shape is sufficient for the skeleton's tally-structure goal but insufficient for live governance.
|
||||
- **Severity**: P1 (spec drift from deliverable, not a must-have, not blocking).
|
||||
|
||||
### P1-2: Council VoiceSource→SignalKind (4 sources, not 5)
|
||||
- **File:line**: `x/council/types/types.go:102-129` (`SignalKind` enum + `AllSignalKinds()`)
|
||||
- **Spec (P3-01-01 deliverable)**: `VoiceSource` enum (Stash, Standing, Vouch, Freeholder, Guild) — 5 multi-source weighting inputs.
|
||||
- **Implemented**: `SignalKind` enum (Stash, Standing, Vouch, Capital) — 4 sources. "Freeholder" and "Guild" dropped; "Capital" added.
|
||||
- **Code rationale (types.go:104-114)**: the comment explains Capital as "committed-capital signal (vision §9.1 committed_capital)" and argues Freeholder is an eligibility property (upstream in `x/standing`), not a voice signal, and Guild is a council tier, not a voice source. This is a defensible design refinement — but it diverges from the P3-01-01 deliverable text.
|
||||
- **Must-have impact**: NONE. P3 must-haves did not enumerate VoiceSource coverage; only "Mission Lock invariant" and "TallyResult x/gov shape" were must-haves.
|
||||
- **Recommendation for post-hoc review**: confirm with the lead-developer/cosmos-engineer that the 4-source `SignalKind` (Stash/Standing/Vouch/Capital) is the intended v0.2 shape, or whether the 5-source `VoiceSource` (adding Freeholder + Guild) should be restored for v0.3 wiring. The `SignalKindCount=4` locked-const test (types_test.go:102) currently locks the 4-source shape; changing it in v0.3 is a deliberate locked-const update.
|
||||
- **Severity**: P1 (design-choice divergence from deliverable, tested and self-consistent, not blocking).
|
||||
|
||||
### P2 (nit): Bearers ValidateGenesis remains a no-op
|
||||
- **File:line**: `x/bearers/types/types.go:108` (`func ValidateGenesis(bz json.RawMessage) error { return nil }`)
|
||||
- **Note**: this is CORRECT per spec — P4-02-01 said "DefaultParams/GenesisState unchanged" (bearers is an EXTENSION, not a new module; v0.1's bearers ValidateGenesis was a no-op and the extension adds types, not genesis state). The A-212 upgrade was scoped to NEW modules. Recording as a P2 nit for completeness, not a defect. No action needed.
|
||||
|
||||
---
|
||||
|
||||
## 4. Grill Binding Decisions Verification (G-001..G-010)
|
||||
|
||||
| ID | Decision | Status | Evidence |
|
||||
|---|---|---|---|
|
||||
| **G-001** | Correct v0.1 baseline test count: 53 tests / 11 files (not 48) | **APPLIED** | PROJECT.md D-033 line 111: "53 tests across 11 test files (corrected per G-001; not 48)"; RESEARCH.md line 20: "53 tests across 11 test files (not 48)"; RESEARCH.md line 575: "53 tests, 11 files, zero deps". No "48" reference remains as a v0.1 baseline claim. |
|
||||
| **G-002** | Lexicon assertion tests are NEW in v0.2 (v0.1 has zero); firewall is new work, not inherited | **APPLIED** | RESEARCH.md lines 16-20: "v0.1 is lexicon-clean in practice but has **zero** lexicon test files... The lexicon assertion tests are NEW in v0.2"; PROJECT.md D-032 line 110: "lexicon assertion tests are NEW in v0.2 — v0.1 is lexicon-clean in practice but has NO lexicon test firewall". Code: `lexicon/lexicon.go` + `lexicon_meta_test.go` are new in v0.2; zero lexicon test files exist on `main`. |
|
||||
| **G-003** | By-ID-string inter-module refs (A-203) enforced as a TESTED invariant in P1-01-02 | **APPLIED** | `x/window/types/types_test.go:437` `TestG003NoCrossModuleStructImportsInProduction` scans every non-test `.go` under `x/` with `go/parser` (ImportsOnly) and asserts no production file imports a foreign `x/<module>/types` package. Test passes (verified: `go test -run TestG003... -v` → PASS). Independent grep confirms zero cross-module struct imports in production code. |
|
||||
| **G-004** | Lexicon meta-test scaffolding moved from P5 to P1 Wave 3 (new task P1-04-02); P5-01-01 EXTENDS it | **APPLIED** | `lexicon_meta_test.go` exists at repo root with `TestLexiconMetaNoBannedTermsInX`, `TestLexiconMetaSelfTestTable`, `TestLexiconMetaBannedTermsCount`, `TestLexiconMetaNoFalsePositiveOnOpenYield`. Package doc (line 1-15) states "the durable firewall created in v0.2 P1 Wave 3; P5-01-01 EXTENDS it rather than recreating it." All 4 meta-tests pass. |
|
||||
| **G-005** | One `x/pact` module with `PactType` enum + 6 per-type execute-entry structs (A-207), NOT six micro-modules | **APPLIED** | PROJECT.md D-027 line 105: "**one `x/pact` module** with a `PactType` enum... NOT six micro-modules". Code: single `x/pact/types/types.go` with `PactType` enum (6 values) + 6 `Execute*` methods on `*Pact` (`ExecutePause`, `ExecuteGround`, `ExecuteStance`, `ExecuteCover`, `ExecuteStandRegistry`, `ExecuteHubAPI`). No `x/pactpause`, `x/pactground`, etc. dirs exist. |
|
||||
| **G-006** | `go.mod` is read-only in v0.2 (zero deps, A-201); any change is an escalation | **APPLIED** | `git diff main..oy/milestone/v0.2-mesh -- go.mod` is **EMPTY**. PERSONAS.md lines 9, 33, 65, 83, 114 all state "go.mod is read-only in v0.2 (G-006)". No persona may modify it. |
|
||||
| **G-007** | `x/pact`/`x/partner`/`x/bond`=backend-engineer; `x/window`/`x/stand`/`x/guild`/`x/council`/`x/satellite`/`x/forex`/`x/bearers`=cosmos-engineer | **APPLIED** | PERSONAS.md line 65 (backend territory): "`x/pact/**`, `x/partner/**`, `x/bond/**`"; line 83 (cosmos territory): "`x/satellite/**`, `x/council/**`, `x/window/**`, `x/stand/**`, `x/guild/**`, `x/forex/**`, `x/bearers/**` (Cosmos-convention-mirroring modules per G-007; `x/pact`/`x/partner`/`x/bond` are backend-engineer's)". Lines 109-111 reiterate the split. No overlap remains. |
|
||||
| **G-008** | Genesis schema (`genesis.go`)=data-engineer; genesis test assertions (`*_test.go` incl `genesis_test.go`)=security-engineer | **APPLIED** | PERSONAS.md line 14 (data-engineer): "Owns genesis SCHEMA only (G-008); test assertions are security-engineer's"; line 17: "does NOT own *_test.go files (G-008)"; line 41 (security-engineer): "owns ALL *_test.go files including genesis_test.go (G-008)"; line 71 (data-engineer territory): "`x/**/types/genesis.go`, `x/**/genesis.go` (excludes `*_test.go` per G-008)"; line 89 (security-engineer territory): "all test files per G-008". Code: `genesis.go` files present in 7 modules; `genesis_test.go` present in window/stand/bond; all `*_test.go` use `package types_test` (external test package, security-engineer convention). |
|
||||
| **G-009** | Self-test table in lexicon meta-test (synthetic string per banned term) | **APPLIED** | `lexicon_meta_test.go:83` `TestLexiconMetaSelfTestTable` — builds a synthetic string per banned term (10 terms: bank, deposit, interest, yield, currency, dollar, euro, account, savings, depositor) and asserts each triggers detection. Test passes. Also `TestLexiconMetaBannedTermsCount` asserts exactly 10 terms configured. |
|
||||
| **G-010** | P5-01-03 reconciles ROADMAP.md tag-line narrative (v0.0.x vs v0.1.x) | **N/A** (P5 task, out of P1-P4 review scope) | G-010 is explicitly a P5-01-03 task (ROADMAP tag-line reconciliation). P1-P4 execution phases do not touch ROADMAP.md. The PLANS.md P5-01-03 task description (line 249) still carries the G-010 obligation. Correctly deferred to P5. |
|
||||
|
||||
**Grill decisions applied: 9 APPLIED + 1 N/A (G-010 is P5, out of scope) = 9 of 9 applicable.**
|
||||
|
||||
---
|
||||
|
||||
## 5. Per-Phase Must-Have Audit
|
||||
|
||||
### P1 (Orgs + Window Foundation) — ALL MET ✓
|
||||
- [x] `x/window`, `x/stand`, `x/guild` each have `types/types.go` + `types/types_test.go` (v0.1 pattern, package `types`, zero external deps).
|
||||
- [x] `go build ./...` and `go test ./...` green across the whole repo.
|
||||
- [x] ≥80% coverage on `x/window/types` (100%), `x/stand/types` (100%), `x/guild/types` (100%).
|
||||
- [x] Window lifecycle tests: Open→Active→Revoked→Expired (`TestWindowLifecycleOpenActiveRevokedExpired`); revoke-after-expire no-op (`TestRevokeAfterExpireIsNoOp`); double-revoke idempotent (`TestDoubleRevokeIdempotent`).
|
||||
- [x] Stand locked-const: exactly 9 types with vision §11 names (`TestStandTypeCountLockedConst`, `TestAllStandTypesNames`).
|
||||
- [x] Guild `HandPassFeeBps == 0` invariant test (`TestHandPassFeeBpsLockedConst`).
|
||||
- [x] Lexicon assertion in all 3 new test files.
|
||||
- [x] `ValidateGenesis` performs ID-uniqueness checks (A-212).
|
||||
- [x] G-003 import-invariant test (`TestG003NoCrossModuleStructImportsInProduction`).
|
||||
- [x] Lexicon meta-test scaffolding in P1 Wave 3 (G-004) with self-test table (G-009).
|
||||
- (Tag `v0.1.1` is a ship-time action, not a code must-have — tracked in P1-04-01.)
|
||||
|
||||
### P2 (Pacts + Partners) — ALL MET ✓
|
||||
- [x] `x/pact`, `x/partner` each have `types/types.go` + `types/types_test.go`.
|
||||
- [x] `go build ./...` and `go test ./...` green.
|
||||
- [x] ≥80% coverage on `x/pact/types` (95.9%), `x/partner/types` (100%).
|
||||
- [x] Pact locked-const: exactly 6 types (vision §16 names) (`TestPactTypeCountLockedConst`).
|
||||
- [x] Partner locked-const: exactly 4 tiers (Op, MasterOp, Pier, Anchor) (`TestPartnerTierCountLockedConst`).
|
||||
- [x] Mission-Lock invariant: Pause/Ground/Stance `AmendableCoreTerms == false` (`TestMissionLockAmendableConstFalse` + per-type flags).
|
||||
- [x] Lexicon assertion in both new test files.
|
||||
- [x] `ValidateGenesis` ID-uniqueness checks (pact: dup pact-id; partner: dup partner-id).
|
||||
|
||||
### P3 (Councils + Forex) — ALL MET ✓ (with P1 spec-drift flags on council lifecycle)
|
||||
- [x] `x/council`, `x/forex` each have `types/types.go` + `types/types_test.go`.
|
||||
- [x] `go build ./...` and `go test ./...` green.
|
||||
- [x] ≥80% coverage on `x/council/types` (96.4%), `x/forex/types` (100%).
|
||||
- [x] Council locked-const: exactly 3 kinds (Mesh, Guild, Stand) (`TestCouncilKindCountLockedConst`).
|
||||
- [x] **Mission Lock invariant**: `MissionLockAmendable == false` + cannot-be-set-true test (`TestMissionLockAmendableConstFalse`, `TestMissionLockAmendableCannotBeSetTrue`).
|
||||
- [x] `TallyResult` shape mirrors `x/gov` (yes/no/abstain/nowithveto/total/quorum_met) (`TestTallyResultStructShape`).
|
||||
- [x] `VoteOption` has no "no-with-veto" — N/A (no VoteOption enum; `TallyResult.NoWithVeto` is always 0, `TestTallyResultNoWithVetoAlwaysZero`).
|
||||
- [x] Forex pair labels lexicon-clean (base-asset/quote-asset, "Bread"/"Asset" sample) (`TestForexPairStructFields`); `RateOracle` interface compiles (`TestRateOracleInterfaceCompiles`).
|
||||
- [x] Lexicon assertion in both new test files.
|
||||
- [x] `ValidateGenesis` ID-uniqueness (council: dup council-id + dup voice-id) + referential integrity (voice→council) (`TestValidateGenesisRejectsVoiceWithUnknownCouncil`).
|
||||
- [P1 flag] Council `Proposal`/`ProposalStatus`/`VoteOption` enums absent (see §3 P1-1).
|
||||
- [P1 flag] Council `VoiceSource`→`SignalKind` (4 not 5) (see §3 P1-2).
|
||||
|
||||
### P4 (Bonds + Bearers + L2) — ALL MET ✓
|
||||
- [x] `x/bond` (new), `x/bearers` (extended), `x/satellite` (new) each have `types/types.go` + `types/types_test.go`.
|
||||
- [x] `go build ./...` and `go test ./...` green — including all v0.1 baseline tests (no regression across 25 packages).
|
||||
- [x] ≥80% coverage on `x/bond/types` (96.8%), `x/bearers/types` (100%), `x/satellite/types` (100%).
|
||||
- [x] Bond clamp invariant: `CouponCapBps == 800`, `CouponFloorBps == 0`; clamp below→floor, above→cap, in-range→unchanged (`TestClampBelowFloorReturnsFloor`, `TestClampAboveCapReturnsCap`, `TestClampInRangeUnchanged`, `TestClampMatchesFeeCovenantShape`).
|
||||
- [x] Bond lexicon: "coupon" exclusively, no "interest"/"yield" (A-210) — verified by meta-test + per-module lexicon test.
|
||||
- [x] Bearers: `BearerTransport` interface compiles (`TestBearerTransportInterfaceSignature`); `OYLRLink` + `BeaconFrame` stubs; existing `AllBearers()` (6) unchanged (`TestOYLRStillInAllBearers` — regression green).
|
||||
- [x] Satellite: `L2Chain` exactly 5 (Polygon active + 4 stubs) (`TestL2ChainCountLockedConst`, `TestPolygonOnlyActiveRep`); `Packet` pinned to ICS-20 v1 shape; zero external deps.
|
||||
- [x] Lexicon assertion in all 3 test files (bond, bearers, satellite).
|
||||
- [x] `ValidateGenesis` ID-uniqueness (bond: dup bond-id; satellite: dup channel-id + dup denom) + genesis clamp (Bond: coupon within [floor, cap]).
|
||||
|
||||
---
|
||||
|
||||
## 6. Overall Verdict
|
||||
|
||||
### **APPROVE WITH P1+ FLAGS**
|
||||
|
||||
The v0.2 (The Mesh) milestone P1-P4 execution work is **shippable**.
|
||||
|
||||
**Rationale:**
|
||||
- All P1-P4 must-have checklists are met (verified per phase in §5).
|
||||
- All 13 locked consts/enums are correct (Window 4, Stand 9, Guild 0, Pact 6, Partner 4, Council 3, MissionLock false in pact+council, Bond 800/0, Forex ≥0, Satellite 5+4).
|
||||
- All ValidateGenesis ID-uniqueness checks present (A-212 upgrade applied to all 9 new modules; bearers extension correctly exempt).
|
||||
- `go build ./...` and `go test ./...` green across all 25 packages (15 v0.1 + 10 v0.2) — no regression.
|
||||
- Coverage ≥80% on all 10 new/extended packages (floor 95.9%, 8 of 10 at 100%).
|
||||
- Lexicon firewall green (zero banned terms in any `x/**/*.go`); G-002 firewall is new and operational.
|
||||
- G-003 by-ID-string invariant tested and green (zero cross-module struct imports in production).
|
||||
- go.mod unchanged (G-006 verified — `git diff` empty).
|
||||
- 9 of 9 applicable grill binding decisions applied (G-010 is P5, N/A for this scope).
|
||||
- Mission Lock and Bond Clamp invariants are compile-time consts + tested firewalls.
|
||||
|
||||
**P1+ flags (2) for post-hoc review — do NOT block the milestone ship:**
|
||||
1. Council `Proposal`/`ProposalStatus`/`VoteOption` lifecycle enums absent (P3-01-01 deliverable drift; must-haves met; recommend adding for v0.3 live governance wiring).
|
||||
2. Council `VoiceSource`→`SignalKind` (4 sources Stash/Standing/Vouch/Capital, not 5 with Freeholder/Guild) (P3-01-01 deliverable drift; defensible design choice; locked-const test currently locks the 4-source shape; confirm intended for v0.3).
|
||||
|
||||
These are design-shape divergences in a single module's non-must-have lifecycle types. They do not affect the Mission Lock firewall, the locked consts, the lexicon firewall, the by-ID-string invariant, coverage, or any must-have. The orchestrator should review them post-ship and decide whether v0.3 restores the full Proposal/VoteOption lifecycle and the 5-source VoiceSource.
|
||||
|
||||
**P0 fixes auto-applied: 0**
|
||||
**P1+ flags: 2** (both in x/council/types)
|
||||
**P2 nits: 1** (bearers ValidateGenesis no-op — correct per spec, no action)
|
||||
**Grill decisions applied: 9 APPLIED + 1 N/A (G-010 is P5) = 9 of 9 applicable**
|
||||
|
||||
**Confidence in overall verdict: 0.88**
|
||||
|
||||
---
|
||||
|
||||
## Summary Block
|
||||
|
||||
```
|
||||
Per-axis verdicts:
|
||||
1. Correctness — PASS (0.90) [all locked consts correct; council lifecycle drift is P1]
|
||||
2. Security — PASS (0.92) [lexicon green; G-003 tested; Mission Lock + Bond Clamp const-enforced]
|
||||
3. Maintainability — PASS (0.90) [v0.1 pattern; coverage ≥95.9%; go.mod unchanged; G-008 split clean]
|
||||
4. Adversarial — CONDITIONAL (0.78) [council Proposal/VoteOption + VoiceSource→SignalKind drift; no must-have missing]
|
||||
5. Grill Decisions — 9 APPLIED + 1 N/A (G-010 P5)
|
||||
|
||||
P0 fixes auto-applied: 0
|
||||
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)
|
||||
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**
|
||||
|
||||
---
|
||||
|
||||
# Review: OpenYield (oy) — v0.4 (Refinement — NFR) Final Phase
|
||||
|
||||
> **Reviewer**: CIAgent multi-persona code review (lead-developer + backend-engineer lenses)
|
||||
> **Date**: 2026-08-17
|
||||
> **Target**: All v0.4 milestone commits (main..oy/milestone/v0.4-refinement) across P1..P3
|
||||
> **Milestone**: v0.4 — Refinement (NFR)
|
||||
> **Autonomy**: full
|
||||
|
||||
## Scope Reviewed
|
||||
|
||||
The v0.4 milestone ships 4 REQs across 3 execution phases (P1..P3) + phase 0 (pre-execution). 20 commits, 15 files changed (+764 / -157). The review covers the execution-phase deliverables:
|
||||
|
||||
- **P1** (v0.3.1): REQ-029 lexicon shared helper, REQ-030 cross-const test
|
||||
- **P2** (v0.3.2): REQ-031 lifecycle divergence docs + regression guard
|
||||
- **P3** (v0.3.3): REQ-032 docs build CI
|
||||
|
||||
## Adversarial Probes
|
||||
|
||||
1. **Does the shared helper actually dedupe?** Probe: `grep -rn 'open a.*here\|make a.*now\|compounding.*rate' lexicon_meta_test.go lexicon_meta_docs/` — returns ZERO matches (the old duplicated table is gone; both meta-tests now call `lexicon.SyntheticBannedStrings()`). Verified at `lexicon_meta_test.go:93` and `lexicon_meta_docs/lexicon_meta_docs_test.go:155` (both consume the helper). ✓
|
||||
|
||||
2. **Does the cross-const test fail closed on drift?** Probe: the test imports `bondtypes "github.com/oy/openyield/x/bond/types"` in `x/hub/types/cross_const_test.go:28` and asserts `LendingCouponCapBps != bondtypes.CouponCapBps` would fail the test. The absolute-value test `TestConstsAreMissionLocked800And0` (G-015) catches paired drift (both consts → 900). Both paths verified by reading the test. ✓
|
||||
|
||||
3. **Does the regression guard lock the 4-signal shape?** Probe: `TestSignalKindShapeIntentional` at `x/council/types/types_test.go` asserts `SignalKindCount == 4` with a `t.Fatalf` (not `t.Errorf`) and checks `AllSignalKinds()` returns `[SignalStash, SignalStanding, SignalVouch, SignalCapital]` in order. The doc comment includes the AUDIT §193 P1-2 rationale. Changing `SignalKindCount` to 5 fails this test AND `TestSignalKindCountLockedConst`. ✓
|
||||
|
||||
4. **Does the CI workflow YAML parse and enforce G-016?** Probe: `python3 -c "import yaml; doc=yaml.safe_load(open('.gitea/workflows/docs-build.yml')); assert doc['jobs']['docs-build']['needs'] == 'go-test'"` — passes. The `needs: go-test` line is present at `.gitea/workflows/docs-build.yml` in the `docs-build` job. ✓
|
||||
|
||||
5. **Is `go.mod` really unchanged across the whole milestone?** Probe: `git diff main..HEAD -- go.mod` — empty. The CI workflow's Python deps are in a separate job; the lexicon helper adds no Go deps; the cross-const test adds no Go deps (test-only import of an internal package). G-006 intact. ✓
|
||||
|
||||
6. **NFR purity gate — zero `feat:` commit SUBJECTS?** Probe: `git log --format="%s" main..HEAD | grep -E "^feat:"` — exit 1 (no matches). All 20 subjects are `docs(`, `refactor(`, `test(`, `chore(`, `verify(`, `decision(`, `checkpoint(`, or `Merge`. NOTE: `git log --grep "^feat:"` matches commit BODIES too (the v0.4 verify commits mention "feat:" in prose like "zero feat: commits in P2"); the correct gate uses subject-only check via `--format="%s"`. This is a documented refinement for the audit. ✓
|
||||
|
||||
7. **Does G-003 hold — no production cross-module struct imports added?** Probe: `grep -rn "x/bond/types" x/hub/types/ --include="*.go" | grep -v "_test.go"` — zero matches. The only import of `x/bond/types` in `x/hub/types/` is in `cross_const_test.go` (a `_test.go` file, G-003 test-exempt). Production firewall intact. ✓
|
||||
|
||||
## 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] The `mkdocs build` local run produced 2 warnings about README.md links (`docs/index.md` links to `../README.md`, `docs/shared/vision.md` links to `../../README.md`). These are pre-existing v0.3 docs links (not introduced by v0.4); the warnings are non-blocking (mkdocs builds successfully). Recommend post-hoc: either configure `mkdocs.yml` to include README.md in the nav, or fix the relative links. Not a v0.4 ship blocker (the docs site builds; v0.4's REQ-032 is the CI workflow, not the docs content).
|
||||
|
||||
**2.** [P2 nit, CI] The `docs-build.yml` workflow uses `actions/upload-artifact@v4` with `retention-days: 14`. Gitea Actions may have a different artifact retention default; the explicit `retention-days: 14` is defensive. If Gitea Actions does not support v4 of the upload-artifact action, the workflow would fail at the upload step (the `mkdocs build` itself would have succeeded). Recommend post-hoc: verify Gitea Actions supports `actions/upload-artifact@v4`; if not, downgrade to v3 or use the Gitea-native artifact upload. Not a ship blocker (the build itself is the higher-priority check; the artifact upload is a bonus).
|
||||
|
||||
**3.** [P2 nit, audit-gate precision] The NFR purity gate (`git log --grep "^feat:"`) over-matches commit bodies. The correct gate is subject-only (`git log --format="%s" | grep -E "^feat:"`). The P4 audit uses the subject-only check. Recommend post-hoc: document the subject-only gate in the next milestone's PLAN so future audits do not over-count. Not a ship blocker (the subject-only gate is green).
|
||||
|
||||
## Overall verdict
|
||||
|
||||
**SHIP.**
|
||||
|
||||
All four REQs (REQ-029..REQ-032) delivered. The three real v0.3 forward-references (G-014 lexicon drift, A-304 const drift, AUDIT §193 council divergence) are closed; the D-046 docs-CI forward-reference is landed. `go test ./...` green across all 26 packages. `go.mod` unchanged (G-006). G-003 production firewall intact. NFR purity gate GREEN (zero `feat:` commit subjects). 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.90**
|
||||
+115
-38
@@ -1,52 +1,129 @@
|
||||
# Roadmap: OpenYield (oy)
|
||||
|
||||
## Phase 0 — Pre-Execution (Current)
|
||||
- [x] Initialize CIAgent project (init workflow)
|
||||
- [ ] SPECIFY — validate specification
|
||||
- [ ] CLARIFY — resolve ambiguities (autonomy: full → auto-decide defaults)
|
||||
- [ ] RESEARCH — domain research + persona assessment
|
||||
- [ ] PLAN — create phase plans
|
||||
- [ ] GRILL — adversarial review
|
||||
- [ ] Ship phase 0
|
||||
## Milestone v0.1 — Pre-MVP Foundation (COMPLETE)
|
||||
- [x] P0: Pre-Execution (spec/research/plan/grill) → v0.0.0
|
||||
- [x] P1: OY Chain & Mirror → v0.0.1
|
||||
- [x] P2: Bread & Root Basket → v0.0.2
|
||||
- [x] P3: Storage Substrate → v0.0.3
|
||||
- [x] P4: Bloom Engine → v0.0.4
|
||||
- [x] P5: Fee Covenant → v0.0.5
|
||||
- [x] P6: Identity/Standing → v0.0.6
|
||||
- [x] P7: Bearers/Processing → v0.0.7
|
||||
- [x] P8: Mesh Experience → v0.0.8
|
||||
- [x] P9: Final Review → v0.0.9
|
||||
- Status: COMPLETE (local-only ship, no remote configured)
|
||||
- MVP release (v0.1.0) deferred until system validated as production-ready
|
||||
|
||||
## Phase 1 — Foundation (Year 1)
|
||||
**Target**: first 10,000 Holders, 50 Master Ops
|
||||
## Milestone v0.2 — The Mesh (COMPLETE)
|
||||
- [x] P0: Pre-Execution → v0.1.0
|
||||
- [x] P1: Orgs + Window Foundation → v0.1.1
|
||||
- [x] P2: Pacts + Partners → v0.1.2
|
||||
- [x] P3: Councils + Forex → v0.1.3
|
||||
- [x] P4: Bonds + Bearers + L2 → v0.1.4
|
||||
- [x] P5: Final Review + Ship → v0.1.5 (milestone release)
|
||||
- Status: COMPLETE (skeleton + tests layer; released as v0.1.5)
|
||||
|
||||
| Component | Deliverable |
|
||||
|---|---|
|
||||
| OY Chain & Mirror (1) | L1 chain launched, 9 Watchers bonded, Mirror live |
|
||||
| Bread Unit & Root Basket (3) | Forge/Fold on Ethereum + 2–3 L2s; initial Root Basket |
|
||||
| Storage Substrate (5) | Stash, Vault, Root-Pool contracts |
|
||||
| Bloom Engine (4) | Bloom accrual loop tied to Mirror attestations |
|
||||
| Fee Covenant (13) | 0.1% ceiling live, processor share 50%, internal minimum 1 Grain |
|
||||
| Identity, Standing & Citizenship (6) | Reach v1, Standing v1, Nomad/Freeholder system |
|
||||
| Bearers & Processing Mesh (12) | Processing v1, OY-BLE, OY-WiFi-Direct |
|
||||
| Mesh Experience (9) | Maps, Pay v1 |
|
||||
## Milestone v0.3 — Bearers & Documentation (COMPLETE; feature type; tags v0.2.x)
|
||||
Target: Bearers skeleton (ROADMAP Phase 3 subset) + docs site for nomads and freeholders.
|
||||
|
||||
## Phase 2 — The Mesh (Year 2)
|
||||
**Target**: $1B annual volume, 4 service categories
|
||||
- [x] P0: Pre-Execution (spec/clarify/research/ideate/plan/grill) → v0.2.0
|
||||
- [x] P1: Docs foundation + REQ-012 firewall extension → v0.2.1
|
||||
- [x] P2: Nomads docs → v0.2.2
|
||||
- [x] P3: Freeholders docs + reference → v0.2.3 (REQ-027 complete)
|
||||
- [x] P4: Bearers skeleton I (x/exit, x/bridge, x/bearers, x/partner) → v0.2.4
|
||||
- [x] P5: Bearers skeleton II (x/hub, x/services, x/bond) → v0.2.5
|
||||
- [x] P6: Final Review + Audit + Ship → v0.2.6 (milestone release)
|
||||
- Status: COMPLETE — Bearers skeleton (7 x/* packages) + docs site (26 pages) shipped
|
||||
|
||||
| Component | Deliverable |
|
||||
|---|---|
|
||||
| Organizational Primitives (10) | 9 Stand types, Guilds (Hand-Passes free) |
|
||||
| Partner Spectrum & Forex (11) | First Piers, Forex Engine v1 |
|
||||
| Window Primitive (7) | Holder-authorized data channels |
|
||||
| Pacts Suite (8) | Pause, Ground, Stance, Cover, Stand Registry |
|
||||
| Governance (14) | Mesh Council activated |
|
||||
| Bearers expansion | OY-LR + Beacon v1 |
|
||||
| Bonds | First Mesh Bonds |
|
||||
> v0.3 bundles two work-streams under one feature milestone: (A) Bearers
|
||||
> skeleton+tests (D-020 pattern) and (B) README.md + MkDocs Material docs site
|
||||
> organized by audience, with the REQ-012 lexicon firewall extended to docs.
|
||||
|
||||
## Phase 3 — The Bearers (Year 3)
|
||||
| Phase | Type | Scope | Patch |
|
||||
|---|---|---|---|
|
||||
| P0 | docs | Pre-Execution (spec/clarify/research/ideate/plan/grill) | v0.2.0 |
|
||||
| P1 | feat/test+docs | Docs foundation + REQ-012 firewall extension to docs/ + README.md + shared docs | v0.2.1 |
|
||||
| P2 | docs | Nomads docs (docs/nomads/) | v0.2.2 |
|
||||
| P3 | docs | Freeholders docs (docs/freeholders/) + docs/reference/ | v0.2.3 |
|
||||
| P4 | feat | Bearers skeleton I: x/exit, x/bridge, x/bearers (OY-SAT, OY-QR), x/partner (Anchor) | v0.2.4 |
|
||||
| P5 | feat | Bearers skeleton II: x/hub, x/services, x/bond (Growth Bonds + secondary market) | v0.2.5 |
|
||||
| P6 | final | REVIEW + AUDIT + milestone SHIP | v0.2.6 (milestone release) |
|
||||
|
||||
### v0.3 Component mapping
|
||||
|
||||
| Component | Deliverable | v0.3 Skeleton Module | Phase |
|
||||
|---|---|---|---|
|
||||
| Cross-Chain & Exit (2) | L2/L1 bridge types, DEX swap types | x/exit, x/bridge | v0.3/P4 |
|
||||
| Bearers expansion | OY-SAT, OY-QR bearer transport types | x/bearers (extended) | v0.3/P4 |
|
||||
| Anchors | First institutional Partner tier | x/partner (extended: Anchor) | v0.3/P4 |
|
||||
| Hub API | B2B backbone: custody, lending primitive, compliance types | x/hub | v0.3/P5 |
|
||||
| Services | Care / SIM / Vault / Mail service types | x/services | v0.3/P5 |
|
||||
| Bond market | Growth Bonds, secondary-market types | x/bond (extended) | v0.3/P5 |
|
||||
| Documentation | README.md + MkDocs Material docs site | docs/, mkdocs.yml, README.md | v0.3/P1-P3 |
|
||||
| Lexicon firewall | Extend REQ-012 to docs/ + README.md | lexicon_meta_docs_test.go | v0.3/P1 |
|
||||
|
||||
> **Tag-line note (G-010 continuation)**: v0.1 pre-MVP shipped on the `v0.0.x`
|
||||
> patch line; v0.2 (The Mesh) shipped on the `v0.1.x` patch line; v0.3 (Bearers
|
||||
> & Documentation) ships on the `v0.2.x` patch line (config.json `tag_base:
|
||||
> v0.2.x`): P0 -> `v0.2.0`, P1..P5 -> `v0.2.1..v0.2.5`, P6 -> `v0.2.6`
|
||||
> (= the v0.3 milestone release, per D-008 — final phase patch IS the
|
||||
> milestone release; no separate minor tag).
|
||||
|
||||
## Milestone v0.4 — Refinement (COMPLETE; NFR type; tags v0.3.x)
|
||||
|
||||
Target: Close the v0.3 post-hoc forward-references (lexicon firewall drift,
|
||||
hub↔bond const drift, council lifecycle type divergence) + land the deferred
|
||||
docs build CI. Refinement-only NFR milestone: zero `feat:` phases.
|
||||
|
||||
- [x] P0: Pre-Execution (spec/clarify/research/plan/grill/mvp-ux) → v0.3.0
|
||||
- [x] P1: Lexicon + const hardening (REQ-029, REQ-030) → v0.3.1
|
||||
- [x] P2: Lifecycle divergence docs + regression guard (REQ-031) → v0.3.2
|
||||
- [x] P3: Docs build CI (REQ-032) → v0.3.3
|
||||
- [x] P4: Final Review + Audit + Ship → v0.3.4 (milestone release)
|
||||
- Status: COMPLETE — 4 NFR REQs shipped; NFR purity gate GREEN (zero feat: commits); go.mod unchanged
|
||||
|
||||
> v0.4 closes three real v0.3 forward-references (GRILL G-014 lexicon helper,
|
||||
> REVIEW P2/A-304 cross-const test, AUDIT §193 council divergence docs) and
|
||||
> lands the D-046 docs-CI forward-reference. Live-runtime promotions of the
|
||||
> v0.3 Bearers skeletons are deferred to v0.5+ (feat:-class, rejected by the
|
||||
> D-001 refinement-only filter).
|
||||
|
||||
| Phase | Type | Scope | Patch |
|
||||
|---|---|---|---|
|
||||
| P0 | docs | Pre-Execution (spec/clarify/research/plan/grill/mvp-ux) | v0.3.0 |
|
||||
| P1 | refactor+test | Lexicon shared helper (REQ-029) + cross-const test (REQ-030) | v0.3.1 |
|
||||
| P2 | docs+test | Council lifecycle divergence docs (REQ-031) + regression guard | v0.3.2 |
|
||||
| P3 | chore+ci | Docs build CI workflow (REQ-032) | v0.3.3 |
|
||||
| P4 | final | REVIEW + AUDIT + milestone SHIP | v0.3.4 (milestone release) |
|
||||
|
||||
### v0.4 Component mapping
|
||||
|
||||
| Component | Deliverable | v0.4 Change | Phase |
|
||||
|---|---|---|---|
|
||||
| Lexicon firewall | Shared `SyntheticBannedStrings()` helper | `lexicon/lexicon.go` + both meta-tests refactored | v0.4/P1 |
|
||||
| Mission-locked const firewall | Cross-package const-equality test | `x/hub/types/cross_const_test.go` (NEW) | v0.4/P1 |
|
||||
| Council Voice/Council interface | Lifecycle divergence documentation + regression guard | ARCHITECTURE.md section + `x/council/types/types_test.go` intent test | v0.4/P2 |
|
||||
| Docs CI | Gitea Actions workflow (build + artifact) | `.gitea/workflows/docs-build.yml` (NEW) | v0.4/P3 |
|
||||
|
||||
> **Tag-line note (G-010 continuation)**: v0.4 (NFR) ships on the `v0.3.x`
|
||||
> patch line (config.json `tag_base: v0.3.x`): P0 -> `v0.3.0`, P1..P3 ->
|
||||
> `v0.3.1..v0.3.3`, P4 -> `v0.3.4` (= the v0.4 milestone release, per D-008 —
|
||||
> final phase patch IS the milestone release; no separate minor tag).
|
||||
|
||||
## Phase 3 — The Bearers (Year 3) — v0.3 PARTIAL SKELETON
|
||||
**Target**: $10B annual volume → fee auto-declines to 0.07%
|
||||
|
||||
> v0.3 ships a skeleton+tests subset of Phase 3 (Cross-Chain/Exit, OY-SAT/OY-QR,
|
||||
> Anchors, Hub API, Services, Bond market depth). Full runtime deferred to v0.4+.
|
||||
|
||||
| Component | Deliverable |
|
||||
|---|---|
|
||||
| Cross-Chain & Exit (2) | Full L2/L1 bridges, DEX integration |
|
||||
| Bearers expansion | OY-SAT, OY-QR |
|
||||
| Hub API | B2B backbone: custody, lending primitive, compliance |
|
||||
| Anchors | First institutional partners |
|
||||
| Services | Care / SIM / Vault / Mail |
|
||||
| Bond market | Full market, Growth Bonds |
|
||||
| Cross-Chain & Exit (2) | Full L2/L1 bridges, DEX integration (runtime deferred to v0.4) |
|
||||
| Bearers expansion | OY-SAT, OY-QR (skeleton types in v0.3) |
|
||||
| Hub API | B2B backbone: custody, lending primitive, compliance (skeleton types in v0.3) |
|
||||
| Anchors | First institutional partners (skeleton types in v0.3) |
|
||||
| Services | Care / SIM / Vault / Mail (skeleton types in v0.3) |
|
||||
| Bond market | Full market, Growth Bonds (skeleton types in v0.3) |
|
||||
|
||||
## Phase 4 — Maturity (Years 4–5+)
|
||||
**Target**: $50–100B volume → fees auto-decline to 0.03%
|
||||
|
||||
@@ -0,0 +1,55 @@
|
||||
# OpenYield docs build CI (REQ-032, D-046 forward-reference, D-051, G-016).
|
||||
#
|
||||
# Runs the lexicon firewall (go test ./...) AND builds the MkDocs Material docs
|
||||
# site on every push. The docs-build job DEPENDS on go-test (G-016 binding:
|
||||
# firewall-gates-docs-build — a lexicon violation blocks the docs build so no
|
||||
# false-green docs artifact is produced from a repo with a firewall failure).
|
||||
#
|
||||
# Scope (chore, not feat: per D-001 refinement-only filter):
|
||||
# - go-test job: setup Go 1.22, run `go test ./...` (lexicon firewall + all
|
||||
# x/* tests + the v0.4 cross-const test). Zero external Go deps (G-006).
|
||||
# - docs-build job: setup Python, pip install mkdocs + mkdocs-material
|
||||
# (build-only Python deps, ISOLATED to this job — go.mod is NOT modified),
|
||||
# run `mkdocs build` (produces site/), upload site/ as a CI artifact.
|
||||
#
|
||||
# Out of scope (deferred per D-051): full Gitea Pages publishing. v0.4 ships
|
||||
# build + artifact only; a hosting target is not configured.
|
||||
#
|
||||
# Triggers: on push (all branches) so the firewall + docs build are checked
|
||||
# on every change, not just on main.
|
||||
|
||||
name: docs-build
|
||||
on:
|
||||
push:
|
||||
|
||||
jobs:
|
||||
go-test:
|
||||
name: go test ./... (lexicon firewall + all x/* tests)
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/setup-go@v5
|
||||
with:
|
||||
go-version: '1.22'
|
||||
- name: go test ./...
|
||||
run: go test ./...
|
||||
|
||||
docs-build:
|
||||
name: mkdocs build (docs site artifact)
|
||||
runs-on: ubuntu-latest
|
||||
needs: go-test # G-016: firewall-gates-docs-build (no false-green docs build)
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/setup-python@v5
|
||||
with:
|
||||
python-version: '3.11'
|
||||
- name: install mkdocs + mkdocs-material
|
||||
run: pip install mkdocs mkdocs-material
|
||||
- name: mkdocs build
|
||||
run: mkdocs build
|
||||
- name: upload site/ artifact
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: docs-site
|
||||
path: site/
|
||||
retention-days: 14
|
||||
@@ -2,3 +2,5 @@
|
||||
.env.secrets
|
||||
.env.*
|
||||
.ciagent/.env.secrets
|
||||
# MkDocs build output (REQ-032 CI produces site/ as an artifact; never commit it)
|
||||
site/
|
||||
|
||||
@@ -0,0 +1,83 @@
|
||||
# OpenYield
|
||||
|
||||
OpenYield is a jurisdiction-light, public-good mesh for **real production** — a
|
||||
protocol organized around Holders, Stands, and the Six Principles, designed to
|
||||
hold real value without the words or the shapes that invite capture. The mesh
|
||||
runs on OY Chain (Layer 1), a canonical state layer for the Bread unit, the
|
||||
Storage Pools (Stash, Vault, Root-Pool), Standing, Watcher attestations, and
|
||||
the Pact / Council / Partner surface. It is anti-greed by construction: Mission
|
||||
Lock fixes the Six Principles and fee covenant so no council can amend them,
|
||||
and the 8% coupon cap on bonds is a mission-locked ceiling, not a parameter.
|
||||
|
||||
## The Six Principles
|
||||
|
||||
1. **Real value** — the mesh holds real production, not speculation.
|
||||
2. **Sustainability** — fees are floored and capped; the protocol cannot drain its users.
|
||||
3. **Mission-lock** — the Six Principles and fee covenant are immutable; no council can amend them.
|
||||
4. **Openness** — anyone may join; the mesh is a public good.
|
||||
5. **Ownership** — Holders own their Stash and their Reach; custody is theirs.
|
||||
6. **Self-service** — a Holder can act without a custodian; the mesh is jurisdiction-light.
|
||||
|
||||
## Bread unit & scale
|
||||
|
||||
The unit of value is **Bread**, scaled in 11 tiers: **Grain → Crumb → Bread →
|
||||
Loaf → Batch → Cake → Bakery → Granary → Mill → Harvest → Earth.**
|
||||
|
||||
## Status
|
||||
|
||||
**v0.3 (Bearers & Documentation) — in progress.** The codebase is a skeleton +
|
||||
tests layer (Go types + keeper stubs + invariant tests, zero external Go deps)
|
||||
matching the v0.1/v0.2 pre-MVP pattern. See `.ciagent/oy/ROADMAP.md` for the
|
||||
phase plan and `.ciagent/oy/PROJECT.md` for governance.
|
||||
|
||||
## Build & test
|
||||
|
||||
OpenYield is pure Go with **zero external dependencies** (`go.mod` has no
|
||||
`require` lines; `go 1.22`). From the repo root:
|
||||
|
||||
```sh
|
||||
go build ./...
|
||||
go test ./...
|
||||
```
|
||||
|
||||
## Docs
|
||||
|
||||
The docs site is [MkDocs Material](https://squidfunk.github.io/mkdocs-material/)
|
||||
(a build-only Python dep; **not** a Go dep — `go.mod` is unchanged). To
|
||||
preview locally:
|
||||
|
||||
```sh
|
||||
mkdocs serve
|
||||
# or build to a static site/ dir:
|
||||
mkdocs build
|
||||
```
|
||||
|
||||
The site lives under `docs/` (see `mkdocs.yml` for the nav). Publishing CI is
|
||||
deferred to v0.4 (D-046); v0.3 ships the source.
|
||||
|
||||
## Lexicon firewall
|
||||
|
||||
OpenYield bans 10 financial terms as standalone words (REQ-012) across all Go
|
||||
source (`x/**/*.go`) and all docs (`README.md` + `docs/**/*.md`). The banned
|
||||
terms are the words you would expect a legacy financial institution to use;
|
||||
this README and the docs describe them only by their **safe replacements**, so
|
||||
the firewall itself never trips. The firewall is enforced in code by two
|
||||
sibling Go tests:
|
||||
|
||||
- `lexicon_meta_test.go` (v0.2) — scans `x/**/*.go`.
|
||||
- `lexicon_meta_docs/lexicon_meta_docs_test.go` (v0.3) — scans `README.md` +
|
||||
`docs/**/*.md`.
|
||||
|
||||
Both use `lexicon.FindBannedTerm` (word-boundary, case-insensitive), so
|
||||
"OpenYield" is safe (word-boundary does not match the banned term inside an
|
||||
identifier) but the standalone banned term is not — docs say **"real
|
||||
production"** / **"real return"**, and a Holder's identity is **Holder** /
|
||||
**Reach**, never the banned word for a custodial position. See
|
||||
`docs/shared/lexicon.md` for the glossary of safe replacements.
|
||||
|
||||
## Governance
|
||||
|
||||
- `.ciagent/oy/PROJECT.md` — full vision, decisions (D-0xx), assumptions.
|
||||
- `.ciagent/oy/PLANS.md` — phase plans (v0.1, v0.2, v0.3).
|
||||
- `.ciagent/oy/REQUIREMENTS.md` — REQ coverage matrix.
|
||||
- `.ciagent/oy/ROADMAP.md` — release roadmap.
|
||||
@@ -0,0 +1,47 @@
|
||||
# Anchor Preview
|
||||
|
||||
An **Anchor** (REQ-023) is the fourth and highest tier of the
|
||||
[Partner Spectrum](partner-spectrum.md) (REQ-018) — the first **institutional**
|
||||
Partner tier. Anchors are coming in v0.3 P4. This page previews what an Anchor
|
||||
is and what the v0.3 skeleton will deliver; the runtime behavior is deferred
|
||||
to v0.4+.
|
||||
|
||||
## What an Anchor is
|
||||
|
||||
An Anchor is a Partner that carries an **AnchorCredential**: a jurisdiction
|
||||
(e.g., "EU-MiCA"), a custody provider, and a set of attestation references.
|
||||
The Anchor tier is how the jurisdiction-light mesh interfaces with
|
||||
jurisdiction-bound institutional actors without becoming them. An Anchor
|
||||
holds a credential; the [Holder](../nomads/reach.md) still holds their
|
||||
[Stash](../nomads/stash.md). The mesh says **custody**, **compliance**, and
|
||||
**jurisdiction** — never the legacy institutional words banned by the
|
||||
[lexicon](../shared/lexicon.md).
|
||||
|
||||
## What is coming in v0.3 P4
|
||||
|
||||
v0.3 P4 (REQ-023) extends `x/partner` with the `AnchorCredential` struct and
|
||||
a `Partner.AnchorCredential()` accessor (returns nil for non-Anchor tiers).
|
||||
The four-tier `PartnerTier` enum (Op, Master Op, Pier, Anchor) is **unchanged**
|
||||
— v0.3 adds Anchor-specific fields, not a new tier. The custody-provider-id
|
||||
field is a by-ID-string reference to `x/hub` (the Hub API, coming in v0.3 P5),
|
||||
empty in the v0.3 skeleton because the Hub is not live until P5/v0.4. This is
|
||||
the P4→P5 ordering edge: `x/hub` in P5 references Anchor partner-ids from P4.
|
||||
|
||||
## Why Anchors matter to a Freeholder
|
||||
|
||||
A Freeholder engaging an Anchor gets a Partner with a verifiable credential
|
||||
and a custody/compliance relationship — useful for cross-jurisdiction routes
|
||||
and institutional [bonds](bonds.md). The Anchor's [Standing](standing.md) and
|
||||
attestations are visible so the Freeholder can verify the Anchor is real
|
||||
before opening a [Window](../nomads/window.md). See
|
||||
[Partner Spectrum](partner-spectrum.md) for the other three tiers, and
|
||||
[Councils & Voice](councils-voice.md) for how the Mesh Council can suspend or
|
||||
revoke an Anchor.
|
||||
|
||||
## What v0.3 does not deliver
|
||||
|
||||
The v0.3 skeleton is types + tests only (D-035): the `AnchorCredential`
|
||||
struct, the accessor, and the `ListAnchors()` keeper alias. Live custody
|
||||
routing, attestation verification, and the Hub API integration are v0.4+
|
||||
runtime work. See [Components](../reference/components.md) for the full
|
||||
module map.
|
||||
@@ -0,0 +1,46 @@
|
||||
# Bonds
|
||||
|
||||
A **Mesh Bond** (REQ-021, vision §17) is a [Stand](stands-guilds.md)-issued
|
||||
instrument that pays a **coupon** to its holder over a term and returns the
|
||||
principal at maturity. The coupon is bounded by a **mission-locked cap and
|
||||
floor**: 8% upper cap, 0% floor (locked `CouponCapBps = 800` and
|
||||
`CouponFloorBps = 0` in `x/bond`). The cap exists so the mesh cannot become a
|
||||
speculative market; the floor exists so the coupon cannot go negative.
|
||||
|
||||
## The coupon clamp
|
||||
|
||||
The coupon is clamped to `[floor, cap]` by the `Clamp` helper in `x/bond`
|
||||
(same shape as the [Fee Covenant](../shared/six-principles.md) clamp): a
|
||||
coupon above 8% is reduced to 8%; a coupon below 0% is raised to 0%; a coupon
|
||||
in range is unchanged. The clamp is a tested invariant: below floor → floor,
|
||||
above cap → cap, in range → unchanged. This is the Mission Lock's expression
|
||||
in the capital layer.
|
||||
|
||||
## Why a cap
|
||||
|
||||
OpenYield is a public-good mesh for **real production**, not a speculation
|
||||
engine. An uncapped coupon market would let a Stand offer arbitrarily high
|
||||
coupons to attract Bread, turning the mesh into a speculative race. The 8%
|
||||
cap bounds the coupon at a level consistent with real production returns, and
|
||||
the [Mission Lock](councils-voice.md) makes the cap non-amendable — no Council
|
||||
vote can raise it. The mesh says **coupon** and **real return**, never the
|
||||
passive-value or standalone-metric words banned by the
|
||||
[lexicon](../shared/lexicon.md).
|
||||
|
||||
## The bond lifecycle
|
||||
|
||||
A Bond moves through five states (locked `BondStatus` enum in `x/bond`):
|
||||
Issued → Active → Matured, with Defaulted and Repaid as terminal paths. The
|
||||
issuer is a Stand (referenced by stand-id); the principal is denominated in
|
||||
[Grain](../shared/bread-scale.md). The bond market is governed by the
|
||||
[Stand Council](councils-voice.md) for the issuing Stand.
|
||||
|
||||
## Coming in v0.3 P5
|
||||
|
||||
v0.3 P5 (REQ-026) extends the bond market with **Growth Bonds** (a coupon that
|
||||
grows over the term, still clamped to the 8% cap) and a **secondary market**
|
||||
(Buy/Sell orders on issued bonds). The 8% / 0% consts are unchanged — the
|
||||
D-028 regression firewall guarantees v0.3 cannot alter the v0.2 mission-locked
|
||||
ceiling. See [Partner Spectrum](partner-spectrum.md) for how Partners relate
|
||||
to the bond market, and [Anchor Preview](anchor-preview.md) for the
|
||||
institutional tier.
|
||||
@@ -0,0 +1,48 @@
|
||||
# Councils & Voice
|
||||
|
||||
OpenYield governs itself through three **Councils** (REQ-011, vision §19):
|
||||
the Mesh Council, the Guild Council, and the Stand Council. Each Freeholder
|
||||
participates through the Councils, weighted by **Voice** — a multi-source
|
||||
weight that combines [Stash](../nomads/stash.md), [Standing](standing.md),
|
||||
Vouch, Freeholder status, and Guild membership. The **Mission Lock** makes
|
||||
the covenant non-amendable: no Council can vote to change the
|
||||
[Six Principles](../shared/six-principles.md) or the fee covenant.
|
||||
|
||||
## The three Councils
|
||||
|
||||
- **Mesh Council** — the mesh-wide Council. Handles protocol-level proposals
|
||||
that affect every Holder and every [Stand](stands-guilds.md).
|
||||
- **Guild Council** — the Council for [Guilds](stands-guilds.md). Handles
|
||||
Guild-scope proposals, referenced by guild-id.
|
||||
- **Stand Council** — the Council for a single Stand, referenced by stand-id.
|
||||
Handles Stand-scope proposals (e.g., Vault use, [Bond](bonds.md) issuance).
|
||||
|
||||
The three-tier shape mirrors the three [Storage Pools](../shared/storage-pools.md):
|
||||
a Council exists at each layer where custody is held.
|
||||
|
||||
## Multi-source Voice
|
||||
|
||||
Voice is not one number. It is a weighted tally from five sources (locked as
|
||||
the `VoiceSource` enum in `x/council`): Stash, Standing, Vouch, Freeholder,
|
||||
and Guild. A Freeholder with high [Standing](standing.md) and a long-held
|
||||
Stash carries more Voice than a freshly-minted one. The
|
||||
[TallyResult](../reference/components.md) mirrors the Cosmos SDK `x/gov`
|
||||
shape so the governance layer can wire to standard tooling. The VoteOption
|
||||
enum is **Yes / No / Abstain** — there is no "no-with-veto", an anti-greed
|
||||
design choice.
|
||||
|
||||
## Mission Lock
|
||||
|
||||
The Mission Lock is a locked `const bool` in `x/council`
|
||||
(`MissionLockAmendable = false`). The Six Principles, the fee covenant
|
||||
(ceiling 0.1% / floor 0.01% / 1-Grain minimum), and the bond coupon cap
|
||||
([8% / 0%](bonds.md)) cannot be amended by any Council vote. This is the
|
||||
firewall that keeps the mesh a public good: governance can act *within* the
|
||||
covenant, never *on* the covenant.
|
||||
|
||||
## How a Freeholder participates
|
||||
|
||||
A Freeholder submits or votes on proposals in the Councils they belong to.
|
||||
Each vote is weighted by multi-source Voice; the tally follows `x/gov`
|
||||
semantics. See [Bonds](bonds.md) for the coupon cap the Mission Lock protects,
|
||||
and [Standing](standing.md) for the metric that weights a Freeholder's Voice.
|
||||
@@ -0,0 +1,39 @@
|
||||
# Freeholders
|
||||
|
||||
A **Freeholder** is a Holder who has earned all four Freeholder signals (REQ-005):
|
||||
a 90-day [Stash](../nomads/stash.md), a [Standing](standing.md) threshold of
|
||||
4.5★ or higher in 3 categories, the Capital signal, and the Vouch signal. A
|
||||
Freeholder is the active participant in the OpenYield mesh — they sit in
|
||||
[Stands & Guilds](stands-guilds.md), vote in the three
|
||||
[Councils & Voice](councils-voice.md), issue [Bonds](bonds.md), and relate to
|
||||
the four-tier [Partner Spectrum](partner-spectrum.md).
|
||||
|
||||
## The four signals
|
||||
|
||||
The signals are the gate to Freeholder participation. They are deliberately
|
||||
heterogeneous — no single input can be pumped — so the path resists gaming:
|
||||
|
||||
- [Signals](signals.md) — the four Freeholder signals (REQ-005): 90-day Stash,
|
||||
4.5★+ in 3 categories, Capital, Vouch.
|
||||
- [Standing](standing.md) — the Bayesian anti-gaming formula (REQ-006):
|
||||
Bayesian prior + time-decay + diversity + voucher-weighting − slashes.
|
||||
- [Stands & Guilds](stands-guilds.md) — the nine Stand types (REQ-016) and
|
||||
Guilds with free Hand-Passes (REQ-017).
|
||||
- [Councils & Voice](councils-voice.md) — the three Councils and the
|
||||
non-amendable Mission Lock (REQ-011).
|
||||
- [Bonds](bonds.md) — the Mesh Bond Market, the 8% coupon cap / 0% floor
|
||||
(REQ-021).
|
||||
- [Partner Spectrum](partner-spectrum.md) — the four Partner tiers (REQ-018):
|
||||
Op, Master Op, Pier, Anchor.
|
||||
- [Anchor Preview](anchor-preview.md) — the first institutional Partner tier
|
||||
(REQ-023), coming in v0.3 P4.
|
||||
|
||||
## What a Freeholder does
|
||||
|
||||
A Freeholder is a Holder who has crossed the signal gate. From there the mesh
|
||||
opens: a Freeholder joins a [Stand](stands-guilds.md) (or forms a Guild), votes
|
||||
in the [Councils](councils-voice.md) with multi-source Voice, issues or holds
|
||||
[Bonds](bonds.md) under the mission-locked coupon cap, and engages the
|
||||
[Partner Spectrum](partner-spectrum.md) — including the Anchor tier coming in
|
||||
v0.3. The covenant is the same for every audience; the Freeholder pages
|
||||
describe how it shows up in governance and capital.
|
||||
@@ -0,0 +1,42 @@
|
||||
# Partner Spectrum
|
||||
|
||||
OpenYield defines a four-tier **Partner Spectrum** (REQ-018, vision §13):
|
||||
**Op**, **Master Op**, **Pier**, and **Anchor**. Partners are the external
|
||||
actors a [Freeholder](index.md) interacts with through the mesh — service
|
||||
operators, route providers, and institutional bridges. The four tiers are
|
||||
locked as the `PartnerTier` enum in `x/partner` (exactly 4, regression-tested).
|
||||
|
||||
## The four tiers
|
||||
|
||||
- **Op** — a service operator. Runs a service a Holder uses through a
|
||||
[Window](../nomads/window.md) (e.g., a Maps provider). The lightest tier.
|
||||
- **Master Op** — a senior operator. Coordinates multiple Ops or runs a
|
||||
higher-trust service. "Op" is the safe short form; the full word is not
|
||||
used as a standalone term.
|
||||
- **Pier** — a routing Partner. Connects the mesh to external venues (e.g.,
|
||||
a DEX or an off-mesh service) and sources [Forex](../reference/components.md)
|
||||
rates. Piers route; they do not custody Holder value.
|
||||
- **Anchor** — the first institutional Partner tier. Carries a credential
|
||||
(jurisdiction, custody provider, attestations). See
|
||||
[Anchor Preview](anchor-preview.md) for what is coming in v0.3 P4.
|
||||
|
||||
## How Freeholders relate to Partners
|
||||
|
||||
A Freeholder authorizes a Partner to act on their behalf through a scoped,
|
||||
time-limited, revocable [Window](../nomads/window.md) — never by handing over
|
||||
custody. The Partner holds a credential, not the Holder's [Stash](../nomads/stash.md).
|
||||
A Partner's [Standing](standing.md) is visible so a Freeholder can choose an
|
||||
operator with a real history over a freshly-spun-up alternative (see
|
||||
[Maps & Pay](../nomads/maps-pay.md)).
|
||||
|
||||
## Partner status
|
||||
|
||||
Each Partner has a status (locked `PartnerStatus` enum in `x/partner`):
|
||||
Pending → Active, with Suspended and Revoked as the governance paths. The
|
||||
[Mesh Council](councils-voice.md) can suspend or revoke a Partner. The four
|
||||
tiers and the status enum are unchanged by v0.3 — v0.3 only *extends*
|
||||
`x/partner` with the Anchor credential shape (REQ-023), not a new tier.
|
||||
|
||||
See [Storage Pools](../shared/storage-pools.md) for why the mesh says
|
||||
"Holder" and "Reach" rather than the legacy custodial words, and
|
||||
[Bonds](bonds.md) for the coupon market a Partner may route to.
|
||||
@@ -0,0 +1,45 @@
|
||||
# The Four Freeholder Signals
|
||||
|
||||
The four **Freeholder signals** (REQ-005) are the gate to Freeholder
|
||||
participation. A [Holder](../nomads/reach.md) who earns all four becomes a
|
||||
[Freeholder](index.md) — eligible to join [Stands & Guilds](stands-guilds.md),
|
||||
vote in the [Councils](councils-voice.md), and issue [Bonds](bonds.md). The
|
||||
signals are deliberately heterogeneous: no single input can be pumped, so the
|
||||
path resists gaming.
|
||||
|
||||
## 1. The 90-day Stash
|
||||
|
||||
A Holder must hold a [Stash](../nomads/stash.md) continuously for 90 days
|
||||
(REQ-014). The signal is about **continuity, not size** — a small Stash held
|
||||
steadily counts. This filters out transient actors who spin up a position to
|
||||
game a vote and then leave. See [Storage Pools](../shared/storage-pools.md)
|
||||
for the three-pool model.
|
||||
|
||||
## 2. Standing of 4.5★ or higher in 3 categories
|
||||
|
||||
A Holder must earn a [Standing](standing.md) of 4.5★ or higher in **three
|
||||
distinct categories** (REQ-006). The diversity requirement is the anti-gaming
|
||||
core: a Holder cannot reach Freeholder by repeating the same action with the
|
||||
same counterparty. Three categories force breadth.
|
||||
|
||||
## 3. Capital
|
||||
|
||||
The Capital signal requires a Holder to hold a meaningful amount of
|
||||
[Bread](../shared/bread-scale.md) in their Stash. The threshold is set by the
|
||||
mesh [Councils](councils-voice.md) and is a stake, not a fee: the Holder keeps
|
||||
the Bread. Capital aligns the Freeholder's stake with the mesh.
|
||||
|
||||
## 4. Vouch
|
||||
|
||||
The Vouch signal requires another Freeholder to vouch for the Holder. A
|
||||
vouch from a high-[Standing](standing.md) Freeholder carries more weight
|
||||
(voucher-weighting), so a single colluding vouch cannot carry a Holder over
|
||||
the gate. Vouch is the social signal that ties the other three together.
|
||||
|
||||
## Why four, not one
|
||||
|
||||
Each signal covers a different attack surface: continuity (90-day Stash),
|
||||
breadth (3-category Standing), stake (Capital), and social trust (Vouch).
|
||||
Earning all four is the proof a Holder is a participant, not a transient
|
||||
gamer. See [Standing](standing.md) for the anti-gaming math, and
|
||||
[Bonds](bonds.md) for what a Freeholder can do once the signals are earned.
|
||||
@@ -0,0 +1,49 @@
|
||||
# Bayesian Standing
|
||||
|
||||
**Standing** (REQ-006) is a Holder's measured history on the mesh — the
|
||||
anti-gaming metric that gates [Freeholder](index.md) participation and weighs
|
||||
[Voice](councils-voice.md) in the [Councils](councils-voice.md). Standing is
|
||||
not a count of transactions and not a reputation score you can farm. It is a
|
||||
Bayesian score that resists the obvious attacks: volume spam, self-dealing,
|
||||
fake vouches.
|
||||
|
||||
## The formula, at conceptual depth
|
||||
|
||||
Standing combines four signals and a penalty:
|
||||
|
||||
- **Bayesian prior + updates.** The mesh starts with a prior for each Holder
|
||||
and updates it from each observed action. A burst of activity cannot
|
||||
inflate Standing because the prior anchors it.
|
||||
- **Time-decay.** Old evidence decays, so a Holder cannot rest on a burst
|
||||
from years ago. Standing reflects *recent, sustained* real production.
|
||||
- **Diversity weighting.** A Holder who acts across many services, many
|
||||
[Stands](stands-guilds.md), and many [bearers](../nomads/bearers.md) accrues
|
||||
more Standing than one who repeats the same action with the same
|
||||
counterparty. Diversity is the anti-collusion lever.
|
||||
- **Voucher-weighting.** A vouch from a high-Standing Freeholder counts for
|
||||
more than a vouch from a low-Standing one. This makes fake vouches expensive:
|
||||
the voucher must themselves have Standing to lose.
|
||||
- **Minus slashes.** Bad behavior (failed attestations, broken Pacts) removes
|
||||
Standing. Slashes are the penalty that bounds the upside of gaming.
|
||||
|
||||
> The full sub-tables (priors, decay rates, diversity categories, slash
|
||||
> conditions) are deferred per PROJECT.md Q2. This page gives the conceptual
|
||||
> depth; the [nomads Standing page](../nomads/standing.md) gives the plain-
|
||||
> language version.
|
||||
|
||||
## Why it cannot be gamed
|
||||
|
||||
There is no single input a Holder can pump. Volume is bounded by the Bayesian
|
||||
prior; recency is bounded by time-decay; breadth is bounded by diversity;
|
||||
social trust is bounded by voucher-weighting; and any attempt that misfires
|
||||
costs Standing via slashes. The four signals (the [90-day Stash](signals.md),
|
||||
3-category threshold, Capital, Vouch) sit on top of this metric, so the
|
||||
Freeholder gate inherits the same anti-gaming property.
|
||||
|
||||
## What Standing is not
|
||||
|
||||
Standing is not a custodial position, a tier you buy, or legacy history. It
|
||||
is a measured, decayed, diversified Bayesian score. See
|
||||
[Storage Pools](../shared/storage-pools.md) for why the mesh says "Stash"
|
||||
rather than the legacy custodial words, and [Councils & Voice](councils-voice.md)
|
||||
for how Standing weights a Freeholder's vote.
|
||||
@@ -0,0 +1,47 @@
|
||||
# Stands & Guilds
|
||||
|
||||
A **Stand** is a governed group of Holders that holds a [Vault](../shared/storage-pools.md)
|
||||
in common (REQ-016). A **Guild** is a looser association of Holders that can
|
||||
pass value among its members for free (REQ-017). Both are the organizational
|
||||
layer a [Freeholder](index.md) joins after earning the four
|
||||
[signals](signals.md).
|
||||
|
||||
## The nine Stand types
|
||||
|
||||
OpenYield defines exactly nine Stand types (REQ-016, vision §11), locked as a
|
||||
const in `x/stand`:
|
||||
|
||||
1. **Household** — a family-scale group.
|
||||
2. **Crew** — a working team.
|
||||
3. **Entity** — a single legal actor.
|
||||
4. **Co-op** — a cooperative.
|
||||
5. **Circle** — an affinity group.
|
||||
6. **Trust** — a trust arrangement.
|
||||
7. **Foundation** — a purpose-bound entity.
|
||||
8. **Confederation** — a federation of Stands.
|
||||
9. **Shadow** — a privacy-preserving Stand.
|
||||
|
||||
A Stand's decision policy (threshold or weighted, mirroring the Cosmos SDK
|
||||
`x/group` shape) governs how its Vault is used. A Stand can also issue
|
||||
[Bonds](bonds.md) — the bond issuer is a Stand, referenced by stand-id.
|
||||
|
||||
## Guilds and Hand-Passes
|
||||
|
||||
A **Guild** is a looser association: it may affiliate with a Stand or stand
|
||||
alone. Inside a Guild, a **Hand-Pass** moves [Bread](../shared/bread-scale.md)
|
||||
between members at a **0% protocol fee** (REQ-017, locked `HandPassFeeBps = 0`
|
||||
in `x/guild`). The 0% fee is mission-locked: the mesh does not tax the social
|
||||
transfer of value among a self-organized group. See the
|
||||
[Fee Covenant](../shared/six-principles.md) for the broader fee shape.
|
||||
|
||||
## How a Freeholder joins
|
||||
|
||||
A Freeholder joins a Stand by becoming a member (the Stand's policy admits
|
||||
them) or forms a Guild as a founder. Membership is recorded in `x/stand`
|
||||
and `x/guild` respectively, by stand-id / guild-id and the member's
|
||||
[Reach](../nomads/reach.md). From a Stand a Freeholder gains Vault access and
|
||||
the ability to issue [Bonds](bonds.md); from a Guild a Freeholder gains free
|
||||
Hand-Passes with other members.
|
||||
|
||||
See [Councils & Voice](councils-voice.md) for how Stands and Guilds each get a
|
||||
Council, and [Storage Pools](../shared/storage-pools.md) for the Vault layer.
|
||||
@@ -0,0 +1,30 @@
|
||||
# OpenYield
|
||||
|
||||
OpenYield is a jurisdiction-light, public-good mesh for **real production**. It
|
||||
runs on OY Chain (Layer 1), a canonical state layer for the Bread unit, the
|
||||
Storage Pools, Standing, Watcher attestations, and the Pact / Council /
|
||||
Partner surface. The mesh is anti-greed by construction: Mission Lock fixes
|
||||
the Six Principles and fee covenant so no council can amend them, and the
|
||||
coupon cap on bonds is a mission-locked ceiling, not a parameter.
|
||||
|
||||
## Audiences
|
||||
|
||||
The docs are organized by audience:
|
||||
|
||||
- **Nomads** — the everyday Holder: your Reach, your Stash, your bearers, how
|
||||
you pay (Maps-Pay), the Pacts you join, and the Window you open. See
|
||||
[Nomads](nomads/index.md).
|
||||
- **Freeholders** — the active participant: the four signals, Bayesian
|
||||
Standing, Stands & Guilds, the three Councils and Voice, the bond market,
|
||||
and the four-tier Partner Spectrum. See [Freeholders](freeholders/index.md).
|
||||
- **Shared** — concepts common to every audience: the Six Principles, the
|
||||
Bread scale, the three Storage Pools, the Watchers & Mirror, the lexicon
|
||||
glossary, and the vision overview. See [Shared](shared/index.md).
|
||||
- **Reference** — the architecture and component map. See
|
||||
[Reference](reference/architecture.md).
|
||||
|
||||
## Build the docs
|
||||
|
||||
This site is [MkDocs Material](https://squidfunk.github.io/mkdocs-material/),
|
||||
a build-only Python dep (not a Go dep). To preview locally, see the
|
||||
[README](../README.md) for build instructions.
|
||||
@@ -0,0 +1,46 @@
|
||||
# Bearers
|
||||
|
||||
The **bearers** (REQ-019) are how a Nomad reaches the mesh. OpenYield ships
|
||||
six bearers through a single **Unified Bearer Layer**: the mesh does not
|
||||
care which bearer a Holder uses — first-to-deliver-wins, and a Nomad can
|
||||
switch bearers without switching identity. The [Mirror](../shared/watchers-mirror.md)
|
||||
mirrors the canonical state to every bearer so a Nomad can read the mesh's
|
||||
real return on any of them.
|
||||
|
||||
## The six bearers
|
||||
|
||||
| Bearer | Live in v0.2 | What it is |
|
||||
|---|---|---|
|
||||
| **Internet** | yes | the default bearer; OY Chain over the open internet. |
|
||||
| **OY-BLE** | yes | Bluetooth Low Energy; short-range, peer-to-peer, no phone plan. |
|
||||
| **OY-WiFi-Direct** | yes | WiFi Direct; local mesh without an access point. |
|
||||
| **OY-LR** | yes | Long Range radio (LoRa-class); long-distance, low-bandwidth, surveillance-resistant. |
|
||||
| **OY-SAT** | coming (v0.3 P4) | satellite; offline coverage via a satellite constellation. |
|
||||
| **OY-QR** | coming (v0.3 P4) | signed QR code; one-shot offline transfer scanned by a peer. |
|
||||
|
||||
## What this means for a Nomad
|
||||
|
||||
A Nomad does not pick "the right bearer". The four already-live bearers
|
||||
(Internet, OY-BLE, OY-WiFi-Direct, OY-LR) cover the everyday situations:
|
||||
on the open internet, in a room with another Holder, in a local group with
|
||||
no router, or kilometers away with no infrastructure. OY-SAT and OY-QR
|
||||
extend that to true-offline paths and are coming in the next phase.
|
||||
|
||||
## Surveillance resistance
|
||||
|
||||
OY-LR, OY-BLE, OY-WiFi-Direct, OY-SAT, and OY-QR are designed to be
|
||||
surveillance-resistant: a Nomad can send or receive value without a
|
||||
phone plan, a SIM, or a custodial on-ramp. The bearer is the transport; the
|
||||
[Reach](reach.md) is the identity; the [Stash](stash.md) is the storage. None
|
||||
of them depends on a custodial position.
|
||||
|
||||
## First-to-deliver-wins
|
||||
|
||||
The Unified Bearer Layer is first-to-deliver-wins: if a Nomad sends a
|
||||
transfer over two bearers at once, the mesh accepts the first one that
|
||||
arrives and drops the duplicate. This is why a Nomad can switch bearers
|
||||
mid-transfer without double-spending.
|
||||
|
||||
See [Watchers & Mirror](../shared/watchers-mirror.md) for how the canonical
|
||||
state is mirrored to every bearer, and [Maps & Pay](maps-pay.md) for how a
|
||||
Nomad uses a bearer to find and pay for services.
|
||||
@@ -0,0 +1,42 @@
|
||||
# Nomads
|
||||
|
||||
A **Nomad** is a person using the OpenYield mesh through a **Reach** — the
|
||||
protocol-level identity a Holder uses to act on the mesh without a
|
||||
custodian, a gatekeeper, or a legacy financial position. The Nomad path
|
||||
is the entry path: a Nomad is a Holder who has a Reach and a [Stash](stash.md)
|
||||
and is on the way to earning the four Freeholder signals, but has not yet
|
||||
earned all four.
|
||||
|
||||
## The Nomad path
|
||||
|
||||
The pages here cover what a Nomad does day-to-day on the mesh:
|
||||
|
||||
- [Reach](reach.md) — the identity; the first Freeholder signal (REQ-005).
|
||||
- [Stash](stash.md) — the personal [Storage Pool](../shared/storage-pools.md)
|
||||
where a Nomad holds Bread (REQ-014).
|
||||
- [Bearers](bearers.md) — how a Nomad reaches the mesh (REQ-019): Internet,
|
||||
OY-BLE, OY-WiFi-Direct, OY-LR live now; OY-SAT and OY-QR coming.
|
||||
- [Maps & Pay](maps-pay.md) — finding services and paying for them.
|
||||
- [Pacts](pacts.md) — the six contract shapes a Nomad encounters
|
||||
(REQ-020): Pause, Ground, Stance, Cover, Stand Registry, Hub API.
|
||||
- [Standing](standing.md) — the Bayesian anti-gaming metric (REQ-006),
|
||||
and why the mesh cannot be gamed.
|
||||
- [Window](window.md) — the delegation primitive (REQ-015): scope,
|
||||
duration, rate-limit, audit-log, revoke.
|
||||
|
||||
## Where a Nomad starts
|
||||
|
||||
A Nomad starts with a Reach and a Stash — that is enough to begin. From
|
||||
there the bearers carry value to the Stash, Maps finds services, Pay and
|
||||
the Window let a Nomad use them without giving up custody, and Standing
|
||||
accrues as the Nomad acts. A Nomad who earns the 90-day Stash signal, the
|
||||
Standing threshold, the Capital signal, and the Vouch signal becomes a
|
||||
Freeholder (see the Freeholders section).
|
||||
|
||||
## Shared concepts
|
||||
|
||||
The Nomad path rests on the [shared concepts](../shared/index.md): the
|
||||
[Six Principles](../shared/six-principles.md), the [Bread scale](../shared/bread-scale.md),
|
||||
the [Storage Pools](../shared/storage-pools.md), the [Watchers & Mirror](../shared/watchers-mirror.md),
|
||||
and the [Lexicon](../shared/lexicon.md). The covenant is the same for
|
||||
every audience; the Nomad pages describe how it shows up in everyday use.
|
||||
@@ -0,0 +1,46 @@
|
||||
# Maps & Pay
|
||||
|
||||
**Maps** and **Pay** are the day-to-day Mesh Experience a Nomad uses on the
|
||||
mesh. Maps finds services; Pay settles them. Both run over the
|
||||
[bearers](bearers.md) and read the [Mirror](../shared/watchers-mirror.md) so a
|
||||
Nomad can find and pay for a service on a surveillance-resistant bearer
|
||||
without an internet connection to OY Chain.
|
||||
|
||||
## Maps
|
||||
|
||||
Maps is the directory of services a Nomad can reach. A service is anything
|
||||
a Partner or a Stand exposes to the mesh: a Care service, a SIM, a Vault,
|
||||
a Mailbox (preview of v0.3 P5 — see [Pacts](pacts.md) for the Hub API). Maps
|
||||
is sorted by geographic proximity (REQ-007): a Nomad physically closer to a
|
||||
service or its operator is shown that service first. There is no paid
|
||||
ranking; the order is proximity, not promotion.
|
||||
|
||||
## Pay
|
||||
|
||||
Pay is how a Nomad settles a service. A payment is a transfer of Bread
|
||||
from the Nomad's [Stash](stash.md) to the service operator's Stash, signed
|
||||
by the Nomad's [Reach](reach.md). The fee covenant floors and caps the fee;
|
||||
inside a Guild, a Hand-Pass is free at the protocol level (REQ-017). Pay
|
||||
runs over any bearer, first-to-deliver-wins.
|
||||
|
||||
## Authorize, don't hand over
|
||||
|
||||
For recurring services a Nomad does not re-sign every payment. Instead
|
||||
the Nomad opens a [Window](window.md) to the service: a scoped,
|
||||
time-limited, rate-limited, revocable capability that lets the service pull
|
||||
value from the Stash within bounds the Nomad set. The Window is audited;
|
||||
the Nomad can revoke it at any time. This is the self-service principle in
|
||||
practice: the Nomad delegates a capability, not custody.
|
||||
|
||||
## Find, pay, verify
|
||||
|
||||
A Nomad's loop is:
|
||||
|
||||
1. **Find** a service on Maps.
|
||||
2. **Pay** once, or **authorize** a [Window](window.md) for recurring use.
|
||||
3. **Verify** the service against the Watcher attestations on the Mirror
|
||||
(see [Watchers & Mirror](../shared/watchers-mirror.md)).
|
||||
|
||||
See [Stash](stash.md) for where the Bread comes from, [Window](window.md)
|
||||
for the delegation primitive, and [Standing](standing.md) for how a
|
||||
service operator's history is measured.
|
||||
@@ -0,0 +1,41 @@
|
||||
# Pacts
|
||||
|
||||
The **six Pacts** (REQ-020) are the contract shapes a Nomad encounters on
|
||||
the mesh. A Pact is a typed, mission-locked agreement between parties; the
|
||||
core terms of the Pause, Ground, and Stance Pacts are **non-amendable** —
|
||||
no Council can rewrite them after the fact. A Nomad mostly interacts with
|
||||
Pacts through [Maps & Pay](maps-pay.md) and the [Window](window.md)
|
||||
primitive, but it helps to know what each one is.
|
||||
|
||||
## The six Pacts
|
||||
|
||||
| Pact | What it does for a Nomad |
|
||||
|---|---|
|
||||
| **Pause** | A temporary hold. A Nomad can pause a recurring payment or a Window without voiding it; the Pause core terms are non-amendable. |
|
||||
| **Ground** | A baseline obligation the mesh enforces by default — the "ground rules" between a Nomad and a service operator. Non-amendable. |
|
||||
| **Stance** | A stated position a party commits to (e.g., a service operator's Stance on jurisdiction-light operation). Non-amendable. |
|
||||
| **Cover** | A flat commitment a Stand or a Partner offers to cover a Nomad against a defined failure; a Nomad reads Cover when choosing a service. |
|
||||
| **Stand Registry** | The registry of the nine [Stand](../shared/storage-pools.md) types (Household, Crew, Entity, Co-op, Circle, Trust, Foundation, Confederation, Shadow) a Nomad can join. |
|
||||
| **Hub API** | The B2B backbone (preview of v0.3 P5) — the Hub Pact exposes custody, a lending primitive, and compliance to service operators. A Nomad sees the Hub through Maps, not directly. |
|
||||
|
||||
## What a Nomad actually does with Pacts
|
||||
|
||||
A Nomad does not draft Pacts by hand. The flow is:
|
||||
|
||||
1. **Find** a service on [Maps & Pay](maps-pay.md).
|
||||
2. The service's terms are backed by one or more Pacts (e.g., a recurring
|
||||
payment is a Pause-able Window; a Stand's service is registered in the
|
||||
Stand Registry).
|
||||
3. The Nomad **authorizes** a [Window](window.md) scoped to those terms.
|
||||
|
||||
## Mission Lock
|
||||
|
||||
The Pause, Ground, and Stance core terms are mission-locked: a `const`
|
||||
flag in the Pact module marks them non-amendable, and an invariant test
|
||||
asserts that flag can never flip. A Nomad can rely on the ground rules
|
||||
not changing. See [Six Principles](../shared/six-principles.md) for the
|
||||
mission-lock covenant.
|
||||
|
||||
See [Window](window.md) for the delegation primitive the Pacts are
|
||||
delivered through, and [Standing](standing.md) for how a service
|
||||
operator's history is measured.
|
||||
@@ -0,0 +1,48 @@
|
||||
# Reach
|
||||
|
||||
A **Nomad** is a person using the OpenYield mesh through a **Reach** — the
|
||||
protocol-level identity that lets a Holder act on the mesh without a
|
||||
custodian, a gatekeeper, or a legacy financial position. The Reach is the
|
||||
first of the four Freeholder signals (REQ-005), and it is the baseline every
|
||||
Nomad starts from: a Nomad is a Holder who has a Reach and a Stash but has not
|
||||
yet earned all four Freeholder signals.
|
||||
|
||||
## What a Reach is
|
||||
|
||||
A Reach is an identity, not a custodial position. It is the by-ID-string a
|
||||
Holder uses to receive value, open a [Window](window.md), join a Stand, or
|
||||
pay for a service. The protocol does not require KYC at the protocol layer;
|
||||
the Reach is the unit of self-service (see [Six
|
||||
Principles](../shared/six-principles.md)).
|
||||
|
||||
## How a Nomad starts
|
||||
|
||||
A Nomad starts with two things:
|
||||
|
||||
1. **A Reach** — the identity.
|
||||
2. **A [Stash](stash.md)** — the personal [Storage Pool](../shared/storage-pools.md)
|
||||
where the Holder holds Bread.
|
||||
|
||||
That pair is enough to begin. From there a Nomad can use the [bearers](bearers.md)
|
||||
to reach the mesh, find services on [Maps & Pay](maps-pay.md), authorize a
|
||||
[Window](window.md) to a partner, and accrue [Standing](standing.md).
|
||||
|
||||
## Geographic proximity
|
||||
|
||||
The mesh processes actions first-come, first-served with a
|
||||
geographic-proximity preference (REQ-007) — a Nomad physically closer to a
|
||||
service or a Stand's region is served first. The Reach is how the mesh
|
||||
identifies the Nomad for that ordering; there is no separate tier to buy into.
|
||||
|
||||
## The four Freeholder signals
|
||||
|
||||
The Reach is the first Freeholder signal. The four signals (REQ-005) are
|
||||
earned over time: the 90-day [Stash](stash.md) signal, the Standing
|
||||
threshold, the Capital signal, and the Vouch signal. A Nomad who earns all
|
||||
four becomes a Freeholder (see the Freeholders section). The pages here cover
|
||||
the Nomad path — everything up to that point.
|
||||
|
||||
See [Stash](stash.md) for the Storage Pool a Reach holds Bread in,
|
||||
[Bearers](bearers.md) for how to reach the mesh, and
|
||||
[Standing](standing.md) for the anti-gaming metric that accrues as a Nomad
|
||||
acts on the mesh.
|
||||
@@ -0,0 +1,44 @@
|
||||
# Standing
|
||||
|
||||
**Standing** (REQ-006) is a Holder's measured history on the mesh. It is
|
||||
the anti-gaming metric: a Bayesian score with time-decay, diversity
|
||||
weighting, and voucher-weighting, minus slashes for bad behavior. For a
|
||||
Nomad, the headline is that the mesh **cannot be gamed** — Standing
|
||||
rewards real production and resists the obvious attacks (volume spam,
|
||||
self-dealing, fake vouches).
|
||||
|
||||
## What Standing is, in plain language
|
||||
|
||||
Standing is not a count of transactions. It is a Bayesian score: the mesh
|
||||
starts with a prior, updates it from each observed action, and decays
|
||||
old evidence so a Holder cannot rest on a burst of activity from years
|
||||
ago. Diversity weighting means a Nomad who acts across many services,
|
||||
many Stands, and many bearers accrues more Standing than a Nomad who
|
||||
repeats the same action with the same counterparty. Voucher-weighting
|
||||
means a vouch from a Holder with high Standing counts for more.
|
||||
|
||||
## Why it matters to a Nomad
|
||||
|
||||
A Nomad mostly reads Standing, not computes it. Two places it shows up:
|
||||
|
||||
- **Choosing a service.** Maps shows a service operator's Standing so a
|
||||
Nomad can pick an operator with a real history over a freshly-spun-up
|
||||
alternative (see [Maps & Pay](maps-pay.md)).
|
||||
- **The Freeholder path.** Earning a Standing threshold in 3 categories
|
||||
is one of the four Freeholder signals (REQ-005). A Nomad who accrues
|
||||
Standing over time is on the path to becoming a Freeholder.
|
||||
|
||||
## What Standing is not
|
||||
|
||||
Standing is not a custodial position, a tier you buy, or a reputation
|
||||
score you can farm. It is not legacy custodial history. The
|
||||
Bayesian + time-decay + diversity design is exactly what makes it hard to
|
||||
game: there is no single input a Holder can pump.
|
||||
|
||||
## The math, deferred
|
||||
|
||||
The full Bayesian formula (priors, decay rates, diversity sub-tables,
|
||||
slash conditions) is documented in the Freeholders section — a Nomad does
|
||||
not need the math to use the mesh. See
|
||||
[Six Principles](../shared/six-principles.md) for the covenant Standing
|
||||
enforces, and [Reach](reach.md) for the identity a Standing accrues to.
|
||||
@@ -0,0 +1,48 @@
|
||||
# Stash
|
||||
|
||||
A **Stash** is a Holder's personal [Storage Pool](../shared/storage-pools.md)
|
||||
(REQ-014). It is the place a Nomad holds Bread, and it is the second thing a
|
||||
Nomad needs after a [Reach](reach.md) to begin. The Stash is the unit of
|
||||
self-service: the Holder owns it, controls it, and can delegate a scoped,
|
||||
time-limited, revocable [Window](window.md) to a partner or a service
|
||||
without giving up custody.
|
||||
|
||||
## What a Stash is
|
||||
|
||||
The Stash is the Holder-level layer of the three Storage Pools (Stash,
|
||||
Vault, Root-Pool). It is a storage layer, not a custodial position: the
|
||||
protocol holds the canonical state that records who owns what; the Holder
|
||||
holds the value. There is no custodian between a Nomad and their Stash.
|
||||
|
||||
## How a Nomad uses a Stash
|
||||
|
||||
A Nomad moves Bread into a Stash through the [bearers](bearers.md) — a
|
||||
Holder on a surveillance-resistant bearer can receive value without an
|
||||
internet connection to OY Chain. From the Stash a Nomad can:
|
||||
|
||||
- **Hold** Bread (the unit of value — see [Bread scale](../shared/bread-scale.md)).
|
||||
- **Pass** value to another Reach (the Hand-Pass, free at the protocol
|
||||
level inside a Guild).
|
||||
- **Pay** for a service via [Maps & Pay](maps-pay.md).
|
||||
- **Authorize** a [Window](window.md) so a partner or service can read the
|
||||
Stash within bounds the Holder set.
|
||||
|
||||
## The 90-day Freeholder signal
|
||||
|
||||
Holding a Stash continuously for 90 days is the first of the four
|
||||
Freeholder signals (REQ-005). The Stash does not need to hold a large
|
||||
amount — the signal is about continuity, not size. A Nomad who keeps a
|
||||
Stash for 90 days and earns the other three signals (Standing, Capital,
|
||||
Vouch) becomes a Freeholder.
|
||||
|
||||
## Delegation, not custody
|
||||
|
||||
The Stash stays the Holder's. When a Nomad opens a Window to a service,
|
||||
the service gets a scoped capability (e.g., "read Stash balance for the
|
||||
next hour", "spend up to N Grain on this service this week") — it does not
|
||||
get custody. The Window is revocable, rate-limited, and audited. See
|
||||
[Window](window.md) for the primitive.
|
||||
|
||||
See [Storage Pools](../shared/storage-pools.md) for the full three-pool
|
||||
model, and [Bearers](bearers.md) for how value reaches a Stash over a
|
||||
surveillance-resistant bearer.
|
||||
@@ -0,0 +1,45 @@
|
||||
# Window
|
||||
|
||||
A **Window** (REQ-015) is the primitive a Nomad uses to delegate a
|
||||
capability without delegating custody. It is scoped, time-limited,
|
||||
rate-limited, audited, and revocable. A Nomad opens a Window so a partner
|
||||
or a service can act on the Nomad's [Stash](stash.md) within bounds the
|
||||
Nomad set — the partner never gets custody, and the Nomad can close the
|
||||
Window at any time.
|
||||
|
||||
## The five parts of a Window
|
||||
|
||||
| Part | What it bounds |
|
||||
|---|---|
|
||||
| **Scope** | what the grantee can do (e.g., read Stash balance, spend up to N Grain on a specific service). |
|
||||
| **Duration** | when the Window starts and ends (a start time and an end time). |
|
||||
| **Rate limit** | how many actions per duration window (e.g., at most 10 reads per hour). |
|
||||
| **Audit log** | an append-only log of every action the grantee took under the Window. |
|
||||
| **Revoke** | the Nomad can revoke the Window at any time; revoke after expiry is a no-op. |
|
||||
|
||||
## Why a Nomad opens one
|
||||
|
||||
A Nomad opens a Window for the same reason a Nomad uses [Maps & Pay](maps-pay.md):
|
||||
to let a service do something on the Nomad's behalf without handing over
|
||||
the Stash. Common examples:
|
||||
|
||||
- A recurring service (e.g., a Care service) pulls a capped amount of
|
||||
Bread from the Stash each week, within a rate limit the Nomad set.
|
||||
- A partner reads the Stash balance for a compliance check, scoped to
|
||||
read-only, time-limited to one hour.
|
||||
- A Stand operator processes a Pass-Act on the Nomad's behalf inside a
|
||||
scoped, audited Window.
|
||||
|
||||
## Lifecycle
|
||||
|
||||
A Window moves through a fixed lifecycle: **Open → Active → Revoked** or
|
||||
**Expired**. A Nomad can revoke at any point; revoking after expiry is a
|
||||
no-op (idempotent). The lifecycle is mission-locked: a partner cannot
|
||||
extend a Window past its end time — the Nomad must open a new one.
|
||||
|
||||
## Self-service, by design
|
||||
|
||||
The Window is the self-service principle in code. The protocol records
|
||||
the Window on OY Chain; the partner holds only the capability, never the
|
||||
value. See [Six Principles](../shared/six-principles.md) for the covenant,
|
||||
and [Pacts](pacts.md) for the contract shapes delivered through Windows.
|
||||
@@ -0,0 +1,51 @@
|
||||
# Architecture
|
||||
|
||||
This is the architecture index for OpenYield. The mesh is built from 14
|
||||
modular components and 6 cross-component interfaces, with a critical blocker
|
||||
chain that fixes the build order. The full governance source lives in
|
||||
`.ciagent/oy/ARCHITECTURE.md`; this page is the user-facing rewrite, kept
|
||||
lexicon-clean by the [docs firewall](../shared/lexicon.md).
|
||||
|
||||
## The 14 modular components
|
||||
|
||||
| # | Component | Vision § | Phase |
|
||||
|---|---|---|---|
|
||||
| 1 | OY Chain & Mirror | §7 | P1 |
|
||||
| 2 | Cross-Chain & Exit | §7 | P3 |
|
||||
| 3 | Bread Unit & Root Basket | §6, §16 | P1 |
|
||||
| 4 | Bloom Engine | §6 | P1 |
|
||||
| 5 | Storage Substrate | §5 | P1 |
|
||||
| 6 | Identity, Standing & Citizenship | §8, §9 | P1 |
|
||||
| 7 | Window Primitive | §10 | P2 |
|
||||
| 8 | Pacts Suite (Pause, Ground, Stance, Cover, Stand Registry, Hub API, Bonds) | §16, §17 | P2 |
|
||||
| 9 | Mesh Experience (Maps, Pay) | §8 | P1 |
|
||||
| 10 | Organizational Primitives (Stands, Guilds) | §11, §12 | P2 |
|
||||
| 11 | Partner Spectrum & Forex | §13 | P2 |
|
||||
| 12 | Bearers & Processing Mesh | §14, §15 | P1 |
|
||||
| 13 | Fee Covenant | §18 | P1 |
|
||||
| 14 | Governance (Mesh/Guild/Stand Councils) | §19 | P2 |
|
||||
|
||||
## The 6 cross-component interfaces
|
||||
|
||||
1. **Standing API** — consumed by Identity, Window, Pacts, Orgs, Partners,
|
||||
and Governance. See [Standing](../freeholders/standing.md).
|
||||
2. **Forge / Fold Interface** — mints [Bread](../shared/bread-scale.md)
|
||||
against Root Basket assets only. See the Bloom Engine.
|
||||
3. **Watcher Attestation Interface (the Mirror)** — 9 Watchers, 6-of-9
|
||||
quorum. See [Watchers & Mirror](../shared/watchers-mirror.md).
|
||||
4. **Window Lifecycle Interface** — Holder-authorized, scope-bounded,
|
||||
revocable. See [Window](../nomads/window.md).
|
||||
5. **Fee Covenant Interface** — auto-decline, ceiling/floor enforced.
|
||||
See [Six Principles](../shared/six-principles.md).
|
||||
6. **Voice / Council Interface** — multi-source Voice, Mission Lock enforced.
|
||||
See [Councils & Voice](../freeholders/councils-voice.md).
|
||||
|
||||
## The critical blocker chain
|
||||
|
||||
The components build in a fixed order: OY Chain (1) → Bread/Root Basket (3)
|
||||
→ Storage (5) → Identity/Standing (6), which then unblocks {Window (7),
|
||||
Pacts (8), Orgs (10), Partners (11), Governance (14)}. The Fee Covenant (13)
|
||||
blocks Pacts, Orgs, Partners, and Bearers — the fee shape must exist before
|
||||
any of those can ship. v0.3 adds the Cross-Chain & Exit layer (component 2)
|
||||
and the Bearers/Partner/Bond extensions; see [Components](components.md) for
|
||||
the `x/` module map and the v0.3 phase status.
|
||||
@@ -0,0 +1,62 @@
|
||||
# Component Map
|
||||
|
||||
This is the `x/` module map for OpenYield. Each module is a Cosmos-SDK-style
|
||||
`x/<name>/types/` package, zero external Go deps (G-006), referenced by
|
||||
ID-string across modules (G-003 — no struct imports). The map covers v0.1,
|
||||
v0.2, and v0.3 (skeleton + tests depth, D-020/D-035).
|
||||
|
||||
## v0.1 baseline (pre-MVP skeleton)
|
||||
|
||||
| Module | Vision § | REQ | Purpose |
|
||||
|---|---|---|---|
|
||||
| `x/mesh` | §7 | REQ-008 | OY Chain (Layer 1) shell |
|
||||
| `x/mirror` | §7 | REQ-004 | Mirror of canonical state to bearers |
|
||||
| `x/bread` | §4, §6 | REQ-013 | Bread unit + 11-tier scale |
|
||||
| `x/bloom` | §6 | REQ-003 | Bloom Engine (real production only) |
|
||||
| `x/forge` | §4.2 | REQ-003 | Forge/Fold minting against Root Basket |
|
||||
| `x/rootpool` | §5 | REQ-014 | Root-Pool (mesh treasury) |
|
||||
| `x/stash` | §5 | REQ-014 | Stash (Holder-level storage pool) |
|
||||
| `x/vault` | §5 | REQ-014 | Vault (Stand-level storage pool) |
|
||||
| `x/identity` | §8 | REQ-005 | Reach identity (Holder, no KYC) |
|
||||
| `x/standing` | §9.2 | REQ-006 | Bayesian Standing |
|
||||
| `x/processing` | §15 | REQ-007 | FCFS processing mesh |
|
||||
| `x/watcher` | §7 | REQ-004 | 9 Watchers, 6-of-9 quorum |
|
||||
| `x/feecovenant` | §18 | REQ-002 | Fee ceiling/floor/minimum |
|
||||
| `x/still` | §3 | — | Still/Stir pause/resume state |
|
||||
| `x/bearers` | §14 | REQ-019 | Unified Bearer Layer (6 bearers) |
|
||||
|
||||
## v0.2 (The Mesh — skeleton + tests)
|
||||
|
||||
| Module | Vision § | REQ | Purpose |
|
||||
|---|---|---|---|
|
||||
| `x/window` | §10 | REQ-015 | Window primitive (scope, rate-limit, revoke) |
|
||||
| `x/stand` | §11 | REQ-016 | Nine Stand types |
|
||||
| `x/guild` | §12 | REQ-017 | Guilds + Hand-Passes at 0% protocol fee |
|
||||
| `x/pact` | §16 | REQ-020 | Six Pacts (Pause, Ground, Stance, Cover, Stand Registry, Hub API) |
|
||||
| `x/partner` | §13 | REQ-018 | Four-tier Partner Spectrum (Op, Master Op, Pier, Anchor) |
|
||||
| `x/council` | §19 | REQ-011 | Three Councils + Mission Lock (non-amendable) |
|
||||
| `x/forex` | §13 | Forex v1 | Forex Engine v1 (pair type + oracle interface) |
|
||||
| `x/bond` | §17 | REQ-021 | Mesh Bond Market (8% cap / 0% floor clamp) |
|
||||
| `x/satellite` | §7 | REQ-009 | L2 IBC Satellite (Polygon active + 4 stubs) |
|
||||
|
||||
## v0.3 (Bearers & Documentation — in progress)
|
||||
|
||||
| Module | Vision § | REQ | Status | Purpose |
|
||||
|---|---|---|---|---|
|
||||
| `x/bridge` | §7 | REQ-010 | P4 (pending) | L2↔L1 bridge routes |
|
||||
| `x/exit` | §7 | REQ-010 | P4 (pending) | Exit routes + DEX swaps |
|
||||
| `x/bearers` (ext) | §14 | REQ-022 | P4 (pending) | OY-SAT + OY-QR transport stubs |
|
||||
| `x/partner` (ext) | §13 | REQ-023 | P4 (pending) | AnchorCredential (Anchor tier) |
|
||||
| `x/hub` | §13, §16 | REQ-024 | P5 (pending) | Hub API (Custody, Lending, Compliance) |
|
||||
| `x/services` | §13 | REQ-025 | P5 (pending) | Services (Care, SIM, Vault, Mail) |
|
||||
| `x/bond` (ext) | §17 | REQ-026 | P5 (pending) | Growth Bonds + secondary market |
|
||||
|
||||
## Notes
|
||||
|
||||
- Every module follows the same pattern: `types/types.go` + `types/types_test.go`
|
||||
(package `types`), zero external deps, by-ID-string inter-module refs (G-003).
|
||||
- Each new/extended test file includes a lexicon assertion (REQ-012); the
|
||||
project-wide meta-test (`lexicon_meta_test.go`) scans all `x/**/*.go`.
|
||||
- The docs firewall (`lexicon_meta_docs_test.go`) scans `README.md` + all
|
||||
`docs/**/*.md`. See the [architecture index](architecture.md) for the
|
||||
14-component view and the 6 cross-component interfaces.
|
||||
@@ -0,0 +1,35 @@
|
||||
# Bread scale
|
||||
|
||||
The unit of value in OpenYield is **Bread** (REQ-013). Bread is scaled in 11
|
||||
tiers, each 1,000× the previous, so a Holder can reason about a Crumb and a
|
||||
Granary in the same mental model:
|
||||
|
||||
| Tier | Name | Multiple |
|
||||
|---|---|---|
|
||||
| 1 | **Grain** | 1 |
|
||||
| 2 | **Crumb** | 1,000 Grain |
|
||||
| 3 | **Bread** | 1,000 Crumb |
|
||||
| 4 | **Loaf** | 1,000 Bread |
|
||||
| 5 | **Batch** | 1,000 Loaf |
|
||||
| 6 | **Cake** | 1,000 Batch |
|
||||
| 7 | **Bakery** | 1,000 Cake |
|
||||
| 8 | **Granary** | 1,000 Bakery |
|
||||
| 9 | **Mill** | 1,000 Granary |
|
||||
| 10 | **Harvest** | 1,000 Mill |
|
||||
| 11 | **Earth** | 1,000 Harvest |
|
||||
|
||||
## Why 11 tiers
|
||||
|
||||
The 11-tier scale gives the mesh a single unit for everything from a
|
||||
1-Grain internal minimum (the Fee Covenant floor) to the Earth-tier totals
|
||||
held in the Root-Pool. There is no separate "small unit" and "large unit":
|
||||
the Bread scale is the unit. The 1-Grain minimum prevents dust games; the
|
||||
tier names keep human-readable values at every scale.
|
||||
|
||||
## Where Bread lives
|
||||
|
||||
Bread is held in the three [Storage Pools](storage-pools.md): the Stash
|
||||
(Holder-level), the Vault (Stand-level), and the Root-Pool (treasury). The
|
||||
Watchers attest to the state of the pools daily; the Mirror mirrors the
|
||||
canonical state to the bearers. See [Watchers & Mirror](watchers-mirror.md)
|
||||
for the attestation layer.
|
||||
@@ -0,0 +1,16 @@
|
||||
# Shared concepts
|
||||
|
||||
The Shared section holds the concepts common to every OpenYield audience —
|
||||
Nomads and Freeholders alike. These are the covenant-level ideas that make
|
||||
OpenYield a public-good mesh rather than a custodial platform.
|
||||
|
||||
- [Six Principles](six-principles.md) — the immutable covenant (REQ-001).
|
||||
- [Bread Scale](bread-scale.md) — the unit of value and its 11 tiers (REQ-013).
|
||||
- [Storage Pools](storage-pools.md) — the three pools (Stash, Vault, Root-Pool) (REQ-014).
|
||||
- [Watchers & Mirror](watchers-mirror.md) — the 9 Watchers, 6-of-9 quorum, the Mirror (REQ-004).
|
||||
- [Lexicon](lexicon.md) — why 10 terms are banned, and what to say instead (REQ-012).
|
||||
- [Vision](vision.md) — the OpenYield covenant in brief.
|
||||
|
||||
See the [README](../index.md) for build instructions, or the
|
||||
[Nomads](../nomads/index.md) and [Freeholders](../freeholders/index.md)
|
||||
sections for audience-specific docs.
|
||||
@@ -0,0 +1,148 @@
|
||||
# Lexicon
|
||||
|
||||
OpenYield bans 10 financial terms as standalone words (REQ-012). The firewall
|
||||
scans every Go file under `x/` and every Markdown file under `README.md` +
|
||||
`docs/`, and fails the build on any standalone occurrence. This page documents
|
||||
**why** the terms are banned and **what to say instead** — the replacements,
|
||||
not the banned literals.
|
||||
|
||||
## Why a lexicon
|
||||
|
||||
The words a legacy financial institution uses carry the shapes of that
|
||||
institution: custodial positions, jurisdiction-bound units, and
|
||||
speculation-language. OpenYield is a jurisdiction-light, public-good mesh for
|
||||
real production; using the old words would import the old shapes. The
|
||||
lexicon firewall keeps the mesh's language aligned with its covenant. The
|
||||
firewall is enforced in code by two sibling Go tests
|
||||
(`lexicon_meta_test.go` for `x/**/*.go`;
|
||||
`lexicon_meta_docs/lexicon_meta_docs_test.go` for `README.md` +
|
||||
`docs/**/*.md`), both using `lexicon.FindBannedTerm` (word-boundary,
|
||||
case-insensitive). Word-boundary matching means "OpenYield" is safe — the
|
||||
firewall bans standalone words, not substrings.
|
||||
|
||||
## The 10 banned terms and their safe replacements
|
||||
|
||||
The firewall bans 10 standalone words. This page does not write the banned
|
||||
words as literals (the firewall scans this page); it describes them by the
|
||||
concept each belongs to, and gives the safe replacement.
|
||||
|
||||
### 1. The custodial-position word
|
||||
|
||||
A legacy institution holds your value in a custodial position. OpenYield
|
||||
does not: a Holder owns their **Stash**, a Stand owns its **Vault**, the mesh
|
||||
owns the **Root-Pool**. The Holder's identity is a **Reach**, and the Holder
|
||||
themselves is a **Holder** — never the banned custodial-position word.
|
||||
|
||||
- Banned: the word for a custodial position.
|
||||
- Safe: **Holder**, **Reach**, **Stash**, **Vault**, **Root-Pool**.
|
||||
|
||||
### 2. The legacy-institution word
|
||||
|
||||
The legacy financial institution itself is banned as a concept. OpenYield is
|
||||
a **mesh**, a **public good**, a **protocol** — not that word.
|
||||
|
||||
- Banned: the word for a legacy financial institution.
|
||||
- Safe: **mesh**, **protocol**, **public good**.
|
||||
|
||||
### 3. The place-value word
|
||||
|
||||
The word for a place to hold value under custody is banned. Use the
|
||||
**Stash** (Holder-level), the **Vault** (Stand-level), or the **Root-Pool**
|
||||
(treasury).
|
||||
|
||||
- Banned: the word for a place value is held.
|
||||
- Safe: **Stash**, **Vault**, **Root-Pool**, **Storage Pools**.
|
||||
|
||||
### 4. The put-in word
|
||||
|
||||
The verb for putting value into a custodial position is banned. Use **hold**,
|
||||
**store**, **move**, or **transfer**.
|
||||
|
||||
- Banned: the verb for placing value under custody.
|
||||
- Safe: **hold**, **store**, **move**, **transfer**, **Pass-Act**.
|
||||
|
||||
### 5. The passive-value word
|
||||
|
||||
The word for value earned passively on a custodial position is banned. For
|
||||
bonds, use **coupon**. For the mesh's metric, use **real production** or
|
||||
**real return**.
|
||||
|
||||
- Banned: the word for passive value on a custodial position.
|
||||
- Safe: **coupon**, **real production**, **real return**.
|
||||
|
||||
### 6. The standalone metric word
|
||||
|
||||
The standalone word for a return metric is banned (it is the same concept as
|
||||
#5 in verb form). Use **real production**, **real return**, or **coupon**
|
||||
(for bonds). "OpenYield" is safe — word-boundary matching does not flag the
|
||||
banned term inside an identifier.
|
||||
|
||||
- Banned: the standalone return-metric word.
|
||||
- Safe: **real production**, **real return**, **coupon**. **OpenYield** is safe.
|
||||
|
||||
### 7. The medium-of-exchange word
|
||||
|
||||
The word for a national medium of exchange is banned. The mesh's unit is
|
||||
**Bread** (see [Bread scale](bread-scale.md)). For a foreign-exchange pair,
|
||||
use **Forex** (allowed) with **base-asset** / **quote-asset** labels, or
|
||||
**Bread / Asset**.
|
||||
|
||||
- Banned: the word for a national medium of exchange.
|
||||
- Safe: **Bread**, **asset**, **Forex**, **base-asset**, **quote-asset**.
|
||||
|
||||
### 8. The first national-unit word
|
||||
|
||||
The word for the first major national unit is banned. Use **Bread** or
|
||||
opaque chain names (e.g., "Polygon", "OY-Chain").
|
||||
|
||||
- Banned: the first national-unit word.
|
||||
- Safe: **Bread**, **asset**, chain names.
|
||||
|
||||
### 9. The second national-unit word
|
||||
|
||||
The word for the second major national unit is banned (the firewall bans it
|
||||
as a standalone word; "european" is safe by word-boundary). Use **Bread** or
|
||||
opaque chain names.
|
||||
|
||||
- Banned: the second national-unit word.
|
||||
- Safe: **Bread**, **asset**, chain names. **European** is safe (word-boundary).
|
||||
|
||||
### 10. The set-aside word
|
||||
|
||||
The word for value set aside under custody is banned. Use **Stash**,
|
||||
**Vault**, or **Root-Pool**.
|
||||
|
||||
- Banned: the word for value set aside.
|
||||
- Safe: **Stash**, **Vault**, **Root-Pool**.
|
||||
|
||||
### 11. The holder-of-value word
|
||||
|
||||
The word for the person who holds value under custody at a legacy
|
||||
institution is banned. Use **Holder**, **Freeholder**, or **Nomad**.
|
||||
|
||||
- Banned: the word for a custodial-position holder.
|
||||
- Safe: **Holder**, **Freeholder**, **Nomad**, **Reach**.
|
||||
|
||||
> **Note**: the firewall bans 10 standalone words; this page lists 11
|
||||
> replacements because two of the banned words (the passive-value word and
|
||||
> the standalone metric word) share a concept and get the same replacement
|
||||
> family (**coupon** / **real production** / **real return**).
|
||||
|
||||
## How the firewall works
|
||||
|
||||
The firewall uses `lexicon.FindBannedTerm` — a word-boundary, case-insensitive
|
||||
regex match — so:
|
||||
|
||||
- "OpenYield" is **safe**: the standalone banned term inside an identifier
|
||||
does not match (word-boundary).
|
||||
- "european" is **safe**: the standalone national-unit word inside a larger
|
||||
word does not match.
|
||||
- The standalone banned word in prose **is** matched and fails the build.
|
||||
|
||||
The firewall's own source (`lexicon/lexicon.go`) assembles the banned terms
|
||||
at runtime from two-character fragments, so the firewall's own code does not
|
||||
contain any banned term as a literal substring. The two sibling meta-tests
|
||||
(`lexicon_meta_test.go` and `lexicon_meta_docs/lexicon_meta_docs_test.go`)
|
||||
each include a self-test table that verifies detection of all 10 banned
|
||||
terms from the single source `lexicon.BannedTerms()` (G-014 drift
|
||||
prevention).
|
||||
@@ -0,0 +1,54 @@
|
||||
# Six Principles
|
||||
|
||||
The Six Principles are the immutable covenant of OpenYield (REQ-001). They
|
||||
are **Mission-locked**: no Council can amend them, and the fee covenant is
|
||||
locked alongside them. The mesh exists to hold real production, not
|
||||
speculation; everything else follows from that.
|
||||
|
||||
## 1. Real value
|
||||
|
||||
The mesh holds **real production**. The Bread unit is the unit of real value
|
||||
held in the Storage Pools; the bond market caps coupons so the mesh cannot
|
||||
become a speculation engine. "Real return" is the metric, not a nominal rate.
|
||||
|
||||
## 2. Sustainability
|
||||
|
||||
Fees are floored and capped. The fee covenant fixes a ceiling and a floor
|
||||
(see the Fee Covenant module), and the 1-Grain internal minimum prevents dust
|
||||
games. The protocol cannot drain its users, and it cannot starve its
|
||||
Watchers.
|
||||
|
||||
## 3. Mission-lock
|
||||
|
||||
The Six Principles and the fee covenant are immutable. No Council — Mesh,
|
||||
Guild, or Stand — can amend them. Mission Lock is a `const` in the council
|
||||
module, and an invariant test asserts it can never be set to amendable. The
|
||||
coupon cap on bonds is a mission-locked ceiling, not a parameter a Council
|
||||
can tune.
|
||||
|
||||
## 4. Openness
|
||||
|
||||
Anyone may join. The mesh is a public good. A Holder needs only a Reach (an
|
||||
identity) and a Stash (a storage pool) to begin; there is no gatekeeper and
|
||||
no custodian.
|
||||
|
||||
## 5. Ownership
|
||||
|
||||
Holders own their Stash and their Reach. Custody is theirs: the Stash is the
|
||||
Holder-level storage pool, the Vault is the Stand-level pool, and the
|
||||
Root-Pool is the treasury. The protocol does not custody user value; it
|
||||
holds the canonical state that records who owns what.
|
||||
|
||||
## 6. Self-service
|
||||
|
||||
A Holder can act without a custodian. The Window primitive lets a Holder
|
||||
delegate a scope-bounded, time-limited, revocable capability to a partner or
|
||||
a service; the bearers (OY-LR, OY-BLE, OY-WiFi-Direct, OY-SAT, OY-QR) let a
|
||||
Holder reach the mesh without a phone plan or a custodial on-ramp. The mesh
|
||||
is jurisdiction-light by design.
|
||||
|
||||
---
|
||||
|
||||
See the [Vision](vision.md) for the covenant in brief, or the
|
||||
[Lexicon](lexicon.md) for why the docs say "real production" and "Holder"
|
||||
rather than the words a legacy financial institution would use.
|
||||
@@ -0,0 +1,48 @@
|
||||
# Storage Pools
|
||||
|
||||
OpenYield has three Storage Pools (REQ-014). Each is a layer of custody
|
||||
responsibility, and none of them is a custodial position — the protocol holds
|
||||
the canonical state that records who owns what; the Holder, the Stand, and
|
||||
the mesh treasury each hold their own pool.
|
||||
|
||||
| Pool | Level | Held by | Purpose |
|
||||
|---|---|---|---|
|
||||
| **Stash** | Holder | a single Holder | the personal storage pool; the unit of self-service |
|
||||
| **Vault** | Stand | a Stand (a governed group) | the Stand-level pool; the unit of shared ownership |
|
||||
| **Root-Pool** | Mesh | the mesh treasury | the canonical treasury; the unit of the public good |
|
||||
|
||||
## The Stash
|
||||
|
||||
The Stash is the Holder-level storage pool. A Holder needs only a Reach (an
|
||||
identity) and a Stash to begin. The Stash is the unit of self-service: the
|
||||
Holder owns it, controls it, and can delegate a scoped, time-limited,
|
||||
revocable Window to a partner or a service without giving up custody. See
|
||||
[Watchers & Mirror](watchers-mirror.md) for the attestation layer that
|
||||
records Stash state.
|
||||
|
||||
## The Vault
|
||||
|
||||
The Vault is the Stand-level storage pool. A Stand is a governed group
|
||||
(one of the nine Stand types: Household, Crew, Entity, Co-op, Circle,
|
||||
Trust, Foundation, Confederation, Shadow) that holds a Vault in common. The
|
||||
Stand's decision policy (threshold or weighted, mirroring the Cosmos SDK
|
||||
`x/group` shape) governs how the Vault is used. See the Freeholders section
|
||||
for Stands & Guilds.
|
||||
|
||||
## The Root-Pool
|
||||
|
||||
The Root-Pool is the mesh treasury. It holds the canonical state of the
|
||||
Bread unit, the Watcher bonds, and the Root Basket. The Root-Pool is the
|
||||
unit of the public good: the Watchers attest to its state daily, and the
|
||||
Mirror mirrors it to the bearers so a Holder can verify the mesh's real
|
||||
return without trusting a single custodian.
|
||||
|
||||
## Custody, not custody
|
||||
|
||||
The three pools are storage layers, not custodial positions. The protocol
|
||||
does not custody user value; it holds the canonical state that records who
|
||||
owns what. A Holder's Stash is theirs; a Stand's Vault is the Stand's; the
|
||||
Root-Pool is the mesh's. The Window primitive lets a Holder delegate a
|
||||
capability without delegating custody. See the [Lexicon](lexicon.md) for
|
||||
why the docs say "Stash", "Vault", and "Root-Pool" rather than the words a
|
||||
legacy financial institution would use.
|
||||
@@ -0,0 +1,64 @@
|
||||
# Vision
|
||||
|
||||
OpenYield is a jurisdiction-light, public-good mesh for **real production**.
|
||||
The vision is a covenant, not a product: the mesh holds real value, the Six
|
||||
Principles are immutable, and the protocol cannot become a custodial
|
||||
platform. This page is the brief overview; the full vision source lives in
|
||||
`.ciagent/oy/PROJECT.md`.
|
||||
|
||||
## The covenant
|
||||
|
||||
OpenYield exists to hold **real production** — the real return of real work,
|
||||
held in the Bread unit, in the three Storage Pools, attested by the Watchers,
|
||||
mirrored by the Mirror. The covenant is anti-greed by construction:
|
||||
|
||||
- **Mission Lock** fixes the Six Principles and the fee covenant. No Council
|
||||
— Mesh, Guild, or Stand — can amend them. The coupon cap on bonds is a
|
||||
mission-locked ceiling, not a parameter.
|
||||
- **Jurisdiction-light** — the bearers (OY-LR, OY-BLE, OY-WiFi-Direct, OY-SAT,
|
||||
OY-QR) let a Holder reach the mesh without a phone plan or a custodial
|
||||
on-ramp. A Holder needs only a Reach and a Stash to begin.
|
||||
- **Public good** — the mesh is open to all. The Watchers attest daily; the
|
||||
Mirror mirrors the state; anyone can verify the mesh's real return without
|
||||
trusting a single custodian.
|
||||
|
||||
## The layers
|
||||
|
||||
1. **OY Chain** (Layer 1) — the canonical state: the Bread unit, the
|
||||
Storage Pools, Standing, Watcher attestations, the Pact / Council /
|
||||
Partner surface.
|
||||
2. **Satellites** (Layer 2) — wrapped Bread propagates to satellite chains
|
||||
(Polygon active; Base, Arbitrum, Optimism, Solana as enum placeholders)
|
||||
via IBC.
|
||||
3. **Bearers** — the surveillance-resistant transport layer: OY-LR (LoRa,
|
||||
long-range), OY-BLE (Bluetooth), OY-WiFi-Direct, OY-SAT (satellite),
|
||||
OY-QR (paper / QR code). The Mirror mirrors canonical state to them.
|
||||
4. **Exits** — the Layer 3 exit layer: Holder-initiated DEX swaps and
|
||||
off-mesh service exits, with bridge routes for cross-chain exits.
|
||||
|
||||
## The actors
|
||||
|
||||
- **Holders** (Nomads) — the everyday participants, each with a Reach and a
|
||||
Stash.
|
||||
- **Freeholders** — the active participants who run Stands, Guilds, and
|
||||
Councils.
|
||||
- **Partners** — the four-tier spectrum (Op, MasterOp, Pier, Anchor) that
|
||||
processes Pass-Acts and provides credentials and institutional backing.
|
||||
- **Watchers** — the 9 attesters with 6-of-9 quorum and 100,000 Bread bonds.
|
||||
|
||||
## The units
|
||||
|
||||
- **Bread** — the unit of real value (see [Bread scale](bread-scale.md)).
|
||||
- **Standing** — the reputation layer (the four signals, Bayesian Standing).
|
||||
- **Voice** — the governance input (multi-source: Stash, Standing, Vouch,
|
||||
Freeholder, Guild).
|
||||
- **Coupon** — the bond-market term (capped at 8% / floored at 0%, mission-locked).
|
||||
|
||||
## Where to go next
|
||||
|
||||
- [Six Principles](six-principles.md) — the immutable covenant.
|
||||
- [Storage Pools](storage-pools.md) — the three pools.
|
||||
- [Watchers & Mirror](watchers-mirror.md) — the attestation layer.
|
||||
- [Lexicon](lexicon.md) — why the docs say "real production" and "Holder".
|
||||
- [README](../../README.md) — build & test instructions.
|
||||
- `.ciagent/oy/PROJECT.md` — the full vision source.
|
||||
@@ -0,0 +1,42 @@
|
||||
# Watchers & Mirror
|
||||
|
||||
OpenYield is attested by **9 Watchers** with a **6-of-9 quorum** (REQ-004).
|
||||
The Watchers make daily attestations to the canonical state, and each posts
|
||||
a 100,000 Bread bond. The **Mirror** mirrors the canonical state to the
|
||||
bearers so a Holder can verify the mesh's state without trusting a single
|
||||
Watcher.
|
||||
|
||||
## The 9 Watchers
|
||||
|
||||
The Watchers are the attestation layer of the mesh. There are exactly 9, and
|
||||
the quorum is 6-of-9: any 6 Watchers can attest to a state transition, but no
|
||||
5 can. Each Watcher posts a 100,000 Bread bond, which is at risk if the
|
||||
Watcher attests to a false state. The 9/6 split is a mission-locked
|
||||
parameter — no Council can lower the quorum or the bond.
|
||||
|
||||
## Daily attestations
|
||||
|
||||
The Watchers attest to the state of the three [Storage Pools](storage-pools.md)
|
||||
daily: the Stash totals, the Vault totals, and the Root-Pool. The
|
||||
attestation is a signed statement that the canonical state recorded by OY
|
||||
Chain matches the state the Watcher observed. A Holder who wants to verify
|
||||
the mesh's real return can read the attestations and check that the
|
||||
Watchers agree.
|
||||
|
||||
## The Mirror
|
||||
|
||||
The Mirror mirrors the canonical state to the bearers (OY-LR, OY-BLE,
|
||||
OY-WiFi-Direct, OY-SAT, OY-QR). A Holder on a surveillance-resistant bearer
|
||||
can read the mirrored state without an internet connection to OY Chain; the
|
||||
Mirror is the read-side of the bearer layer. The Mirror is read-only: it
|
||||
mirrors state, it does not author it. Authoritative state lives on OY Chain
|
||||
and is attested by the Watchers.
|
||||
|
||||
## Why 6-of-9
|
||||
|
||||
The 9/6 split is a balance: 9 is large enough that no single adversary can
|
||||
easily capture a quorum, and 6 is large enough that no small cabal can
|
||||
attest to a false state. The 100,000 Bread bond per Watcher makes
|
||||
capturing a quorum expensive. The split is locked by Mission Lock — no
|
||||
Council can change it. See [Six Principles](six-principles.md) for the
|
||||
mission-lock covenant.
|
||||
@@ -0,0 +1,125 @@
|
||||
// Package lexicon holds the project-wide lexicon firewall (REQ-012).
|
||||
//
|
||||
// The 9 banned financial terms must never appear in any production or test
|
||||
// .go file under x/. This package exposes the banned-terms list and detection
|
||||
// helpers; the terms themselves are assembled at runtime from two-character
|
||||
// fragments so that the SOURCE of this package does not contain any banned
|
||||
// term as a literal substring. This is the standard lexicon-test bootstrapping
|
||||
// pattern: the firewall's own code must not trip the firewall.
|
||||
//
|
||||
// The lexicon firewall is NEW in v0.2 (G-002): v0.1 is lexicon-clean in
|
||||
// practice but has zero lexicon tests. The project-wide meta-test in
|
||||
// P1-04-02 (lexicon_meta_test.go) is the durable firewall; per-package
|
||||
// lexicon assertions in each new module's types_test.go scan the module's
|
||||
// production files.
|
||||
package lexicon
|
||||
|
||||
import (
|
||||
"regexp"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// term is a banned term assembled from two halves so the source file does
|
||||
// not contain the literal banned word.
|
||||
type term struct {
|
||||
a, b string
|
||||
}
|
||||
|
||||
// fragments holds the 9 banned terms as (a, b) halves. Neither half alone
|
||||
// is a banned term, and concatenation produces the banned term at runtime.
|
||||
var fragments = []term{
|
||||
{"ba", "nk"}, // bank
|
||||
{"depo", "sit"}, // deposit
|
||||
{"intere", "st"}, // interest
|
||||
{"yie", "ld"}, // yield
|
||||
{"curre", "ncy"}, // currency
|
||||
{"dol", "lar"}, // dollar
|
||||
{"eu", "ro"}, // euro
|
||||
{"acco", "unt"}, // account
|
||||
{"savin", "gs"}, // savings
|
||||
{"deposito", "r"}, // depositor
|
||||
}
|
||||
|
||||
// BannedTerms returns the banned financial terms (REQ-012). The spec lists
|
||||
// 10 terms (often described as "9" in plan docs, counting dollar/euro as a
|
||||
// pair): bank, deposit, interest, yield, currency, dollar, euro, account,
|
||||
// savings, depositor. The terms are assembled at runtime from fragments so
|
||||
// this package's source does not contain any banned term as a literal
|
||||
// substring.
|
||||
func BannedTerms() []string {
|
||||
out := make([]string, len(fragments))
|
||||
for i, t := range fragments {
|
||||
out[i] = t.a + t.b
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// bannedTermRegexes are the compiled word-boundary regexes for the 9 banned
|
||||
// terms. Word boundaries prevent false positives like "openyield" matching
|
||||
// "yield" or "european" matching "euro" — the firewall bans the words as
|
||||
// concepts, not as arbitrary substrings. The regexes are case-insensitive.
|
||||
var bannedTermRegexes = func() []*regexp.Regexp {
|
||||
terms := BannedTerms()
|
||||
out := make([]*regexp.Regexp, len(terms))
|
||||
for i, t := range terms {
|
||||
out[i] = regexp.MustCompile(`\b` + regexp.QuoteMeta(t) + `\b`)
|
||||
}
|
||||
return out
|
||||
}()
|
||||
|
||||
// FindBannedTerm returns the first banned term found in s (case-insensitive,
|
||||
// word-boundary match) and true, or "" and false if none. Used by the
|
||||
// project-wide meta-test (P1-04-02) and the per-package lexicon assertions.
|
||||
func FindBannedTerm(s string) (string, bool) {
|
||||
lower := strings.ToLower(s)
|
||||
terms := BannedTerms()
|
||||
for i, re := range bannedTermRegexes {
|
||||
if re.MatchString(lower) {
|
||||
return terms[i], true
|
||||
}
|
||||
}
|
||||
return "", false
|
||||
}
|
||||
|
||||
// ContainsBannedTerm is an alias for FindBannedTerm kept for compatibility.
|
||||
func ContainsBannedTerm(s string) (string, bool) {
|
||||
return FindBannedTerm(s)
|
||||
}
|
||||
|
||||
// SyntheticBannedStrings returns one synthetic string per banned term, each
|
||||
// embedding exactly one banned term in a plausible sentence context. This
|
||||
// is the single source of truth (REQ-029, GRILL G-014) for the synthetic
|
||||
// self-test table consumed by BOTH project-wide meta-tests:
|
||||
//
|
||||
// lexicon_meta_test.go :: TestLexiconMetaSelfTestTable (package lexicon_meta, scans x/**/*.go)
|
||||
// lexicon_meta_docs_test.go :: TestLexiconMetaDocsSelfTestTable (package lexicon_meta_docs, scans README.md + docs/**/*.md)
|
||||
//
|
||||
// Before REQ-029, both meta-tests DUPLICATED their own 10-string synthetic
|
||||
// table (byte-identical), creating a drift risk: a future banned-term
|
||||
// addition updating one table but not the other would silently drop coverage
|
||||
// in the unmaintained firewall. SyntheticBannedStrings() eliminates the
|
||||
// duplication — both meta-tests now consume this helper, so a future addition
|
||||
// updates both firewalls from one place. The strings are built from
|
||||
// BannedTerms() (already fragment-assembled), so this package's own source
|
||||
// stays lexicon-clean (the firewall's own code is allowed to name the terms
|
||||
// it bans, but only via the fragment-assembly bootstrapping pattern).
|
||||
//
|
||||
// The returned slice is indexed positionally against BannedTerms(): the i-th
|
||||
// synthetic string embeds the i-th banned term. Both meta-tests assert
|
||||
// len(SyntheticBannedStrings()) == len(BannedTerms()) and that each string
|
||||
// triggers FindBannedTerm with the matching term.
|
||||
func SyntheticBannedStrings() []string {
|
||||
terms := BannedTerms()
|
||||
return []string{
|
||||
"open a " + terms[0] + " here", // bank
|
||||
"make a " + terms[1] + " now", // deposit
|
||||
"compounding " + terms[2] + " rate", // interest
|
||||
"the " + terms[3] + " is 5pct", // yield
|
||||
"foreign " + terms[4] + " pair", // currency
|
||||
"price in " + terms[5], // dollar
|
||||
"price in " + terms[6], // euro
|
||||
"freeze the " + terms[7], // account
|
||||
"move to " + terms[8] + " now", // savings
|
||||
"the " + terms[9] + " lost money", // depositor
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,296 @@
|
||||
// Package lexicon_meta_docs holds the docs lexicon firewall (REQ-028, D-043).
|
||||
//
|
||||
// It is a NEW sibling meta-test created in v0.3 P1 Wave 1 that MIRRORS the v0.2
|
||||
// project-wide firewall (lexicon_meta_test.go, package lexicon_meta) but scans
|
||||
// the docs surface (README.md + docs/**/*.md) instead of x/**/*.go. It uses
|
||||
// the SAME lexicon.FindBannedTerm (word-boundary, case-insensitive) — NO
|
||||
// detection reimplementation — so the two firewalls share a single source of
|
||||
// truth for the 10 banned terms (bank, deposit, interest, yield, currency,
|
||||
// dollar, euro, account, savings, depositor).
|
||||
//
|
||||
// Placement: this file lives in lexicon_meta_docs/ (a subdirectory of the
|
||||
// repo root) because Go does not permit two distinct packages in the same
|
||||
// directory; the v0.2 firewall is package lexicon_meta at the repo root.
|
||||
// The invocation `go test ./lexicon_meta_docs/...` (PLANS P1-03-01) resolves
|
||||
// to this package. Run via `go test ./...` from the repo root as well.
|
||||
//
|
||||
// G-013 walk-coverage: TestLexiconMetaDocsWalkCoverage injects a synthetic
|
||||
// banned-term .md into a temp docs/ subtree and asserts the walk FINDS it.
|
||||
// This closes the "silently scans nothing and reports green" failure mode
|
||||
// that the G-009 self-test table (detection) alone does not cover.
|
||||
//
|
||||
// G-014 self-test drift: the self-test table and banned-term count assertion
|
||||
// reuse lexicon.BannedTerms() (the single source). A cross-reference comment
|
||||
// keeps this file's table in lockstep with lexicon_meta_test.go's table; if
|
||||
// a banned term is added, both firewalls update from one place.
|
||||
package lexicon_meta_docs
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"runtime"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/oy/openyield/lexicon"
|
||||
)
|
||||
|
||||
// repoRoot returns the absolute path to the repo root by walking up from
|
||||
// this test file (the test lives at <repoRoot>/lexicon_meta_docs/).
|
||||
func repoRoot(t *testing.T) string {
|
||||
t.Helper()
|
||||
_, file, _, ok := runtime.Caller(0)
|
||||
if !ok {
|
||||
t.Fatal("runtime.Caller failed")
|
||||
}
|
||||
// file = .../oy/lexicon_meta_docs/lexicon_meta_docs_test.go
|
||||
// repo root = filepath.Dir(filepath.Dir(file))
|
||||
return filepath.Dir(filepath.Dir(file))
|
||||
}
|
||||
|
||||
// thisFile returns the absolute path of this meta-test file (to exclude it
|
||||
// from its own scan — it references banned terms via the lexicon package,
|
||||
// whose source assembles terms from fragments, so no banned-term literal
|
||||
// appears in the firewall's own code).
|
||||
func thisFile(t *testing.T) string {
|
||||
t.Helper()
|
||||
_, file, _, ok := runtime.Caller(0)
|
||||
if !ok {
|
||||
t.Fatal("runtime.Caller failed")
|
||||
}
|
||||
return file
|
||||
}
|
||||
|
||||
// TestLexiconMetaDocsNoBannedTermsInDocs is the docs firewall (D-043). It
|
||||
// walks README.md (repo root) + every *.md under docs/ (recursive), reads each
|
||||
// file's source, and asserts no banned term is present (word-boundary,
|
||||
// case-insensitive). Excludes .ciagent/ (firewall meta-files discuss banned
|
||||
// terms by name for governance; not user-facing), .git/ (VCS), and this test
|
||||
// file itself (self-exclusion via runtime.Caller(0)).
|
||||
//
|
||||
// Passes at P1 Wave 1 with zero docs (a walk that scans nothing reports green
|
||||
// on zero hits — closed by TestLexiconMetaDocsWalkCoverage below). With the
|
||||
// Wave 2 docs present (README + index + 6 shared pages), all are lexicon-clean
|
||||
// by construction.
|
||||
func TestLexiconMetaDocsNoBannedTermsInDocs(t *testing.T) {
|
||||
root := repoRoot(t)
|
||||
this := thisFile(t)
|
||||
hits := []string{}
|
||||
err := filepath.Walk(root, func(path string, info os.FileInfo, err error) error {
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if info.IsDir() {
|
||||
base := filepath.Base(path)
|
||||
if base == ".ciagent" || base == ".git" {
|
||||
return filepath.SkipDir
|
||||
}
|
||||
return nil
|
||||
}
|
||||
// Self-exclusion: skip this meta-test file.
|
||||
if path == this {
|
||||
return nil
|
||||
}
|
||||
// Only scan .md files.
|
||||
if !strings.HasSuffix(path, ".md") {
|
||||
return nil
|
||||
}
|
||||
// Only scan README.md (repo root) + docs/**/*.md.
|
||||
rel, rerr := filepath.Rel(root, path)
|
||||
if rerr != nil {
|
||||
return rerr
|
||||
}
|
||||
if rel != "README.md" && !strings.HasPrefix(rel, "docs"+string(filepath.Separator)) && rel != "docs" {
|
||||
return nil
|
||||
}
|
||||
bz, rerr := os.ReadFile(path)
|
||||
if rerr != nil {
|
||||
return rerr
|
||||
}
|
||||
if found, ok := lexicon.FindBannedTerm(string(bz)); ok {
|
||||
hits = append(hits, rel+" contains banned term "+found)
|
||||
}
|
||||
return nil
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("walk: %v", err)
|
||||
}
|
||||
if len(hits) > 0 {
|
||||
t.Errorf("REQ-028 docs lexicon firewall violations:\n %s",
|
||||
strings.Join(hits, "\n "))
|
||||
}
|
||||
}
|
||||
|
||||
// TestLexiconMetaDocsSelfTestTable (G-009 for docs) is the firewall's own
|
||||
// detection-coverage guard. Each synthetic string embeds exactly one banned
|
||||
// term in a plausible sentence context and is asserted to trigger detection,
|
||||
// so the firewall's detection logic is durably verified — if detection ever
|
||||
// breaks, this test fails before the firewall silently passes a real
|
||||
// violation in a docs page.
|
||||
//
|
||||
// REQ-029 (GRILL G-014): the synthetic strings are sourced from
|
||||
// lexicon.SyntheticBannedStrings(), the single source of truth shared with
|
||||
// lexicon_meta_test.go :: TestLexiconMetaSelfTestTable. Before REQ-029, this
|
||||
// file DUPLICATED its own 10-string table (byte-identical to the x/ meta-
|
||||
// test), creating a drift risk; the shared helper closes it. This file no
|
||||
// longer builds its own synthetic table — both meta-tests consume the same
|
||||
// helper, so a future banned-term addition updates both firewalls from one
|
||||
// place.
|
||||
func TestLexiconMetaDocsSelfTestTable(t *testing.T) {
|
||||
terms := lexicon.BannedTerms()
|
||||
// The spec lists 10 banned terms (plan docs say "9", counting dollar/euro
|
||||
// as a pair): bank, deposit, interest, yield, currency, dollar, euro,
|
||||
// account, savings, depositor.
|
||||
if len(terms) != 10 {
|
||||
t.Fatalf("BannedTerms() len = %d, want 10", len(terms))
|
||||
}
|
||||
// REQ-029: consume the shared synthetic-string helper (G-014 single source).
|
||||
synthetic := lexicon.SyntheticBannedStrings()
|
||||
if len(synthetic) != len(terms) {
|
||||
t.Fatalf("SyntheticBannedStrings() len = %d, want %d (must match BannedTerms())", len(synthetic), len(terms))
|
||||
}
|
||||
for i, s := range synthetic {
|
||||
found, ok := lexicon.FindBannedTerm(s)
|
||||
if !ok {
|
||||
t.Errorf("G-009 docs self-test [%d]: synthetic string did not trigger detection: %q", i, s)
|
||||
continue
|
||||
}
|
||||
if found != terms[i] {
|
||||
t.Errorf("G-009 docs self-test [%d]: detected %q, want %q (in %q)", i, found, terms[i], s)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestLexiconMetaDocsBannedTermsCount asserts exactly 10 banned terms are
|
||||
// configured (locked-const for the firewall's scope; spec lists 10, plan docs
|
||||
// say "9" counting dollar/euro as a pair). Derived from lexicon.BannedTerms()
|
||||
// — the single source — so a count change breaks both this firewall and the
|
||||
// v0.2 x/*.go firewall (G-014 drift prevention).
|
||||
func TestLexiconMetaDocsBannedTermsCount(t *testing.T) {
|
||||
terms := lexicon.BannedTerms()
|
||||
if len(terms) != 10 {
|
||||
t.Errorf("BannedTerms() len = %d, want 10 (REQ-012/REQ-028)", len(terms))
|
||||
}
|
||||
seen := map[string]bool{}
|
||||
for _, tr := range terms {
|
||||
if seen[tr] {
|
||||
t.Errorf("duplicate banned term %q", tr)
|
||||
}
|
||||
seen[tr] = true
|
||||
}
|
||||
}
|
||||
|
||||
// TestLexiconMetaDocsNoFalsePositiveOnOpenYield asserts the module name
|
||||
// "openyield" does NOT trigger the "yield" banned term and "european" does
|
||||
// NOT trigger the "euro" banned term (word-boundary matching must not match
|
||||
// substrings of identifiers). This is the regression firewall for the
|
||||
// word-boundary detection design — mirrors the v0.2
|
||||
// TestLexiconMetaNoFalsePositiveOnOpenYield.
|
||||
func TestLexiconMetaDocsNoFalsePositiveOnOpenYield(t *testing.T) {
|
||||
cases := []string{
|
||||
"github.com/oy/openyield/x/window/types",
|
||||
"package openyield",
|
||||
"openyield is the module",
|
||||
"european resident",
|
||||
"# OpenYield docs",
|
||||
"the OpenYield mesh",
|
||||
}
|
||||
for _, s := range cases {
|
||||
if _, ok := lexicon.FindBannedTerm(s); ok {
|
||||
t.Errorf("false positive: %q triggered a banned term (word-boundary must avoid this)", s)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestLexiconMetaDocsWalkCoverage (G-013) is the walk-coverage firewall. The
|
||||
// G-009 self-test table (above) verifies DETECTION (FindBannedTerm on
|
||||
// synthetic strings) but NOT the WALK (which files are scanned). A walk bug
|
||||
// — e.g. wrong path prefix, missing docs/ recursion, a typo in the .md
|
||||
// suffix check — would silently scan nothing and report green on zero
|
||||
// files. This test closes that gap by injecting a synthetic banned-term .md
|
||||
// into a fixture dir under the real docs/ path the walk scans and asserting
|
||||
// the walk FINDS it.
|
||||
//
|
||||
// The fixture is created under docs/.lexicon_fixture/ (a real docs/ subtree
|
||||
// the walk reaches) and removed via defer so it never leaks into the repo.
|
||||
// If the walk logic misses the fixture, this test fails loudly instead of
|
||||
// letting a broken walk pass the firewall green on zero files scanned.
|
||||
func TestLexiconMetaDocsWalkCoverage(t *testing.T) {
|
||||
root := repoRoot(t)
|
||||
this := thisFile(t)
|
||||
|
||||
// Build a synthetic banned term from fragments so THIS file does not
|
||||
// contain a banned-term literal (it is excluded from its own scan, but
|
||||
// the synthetic stays clean for readability/searchability).
|
||||
terms := lexicon.BannedTerms()
|
||||
if len(terms) == 0 {
|
||||
t.Fatal("BannedTerms() returned no terms — cannot run walk-coverage")
|
||||
}
|
||||
// Use the first banned term ("bank") assembled from two halves.
|
||||
syntheticTerm := terms[0][:2] + terms[0][2:] // reassemble (no literal in source)
|
||||
badContent := []byte("# fixture\nthis file contains a banned term: " + syntheticTerm + "\n")
|
||||
|
||||
fixtureDir := filepath.Join(root, "docs", ".lexicon_fixture")
|
||||
fixtureFile := filepath.Join(fixtureDir, "bad_fixture.md")
|
||||
if err := os.MkdirAll(fixtureDir, 0o755); err != nil {
|
||||
t.Fatalf("mkdir fixture: %v", err)
|
||||
}
|
||||
defer os.RemoveAll(fixtureDir)
|
||||
if err := os.WriteFile(fixtureFile, badContent, 0o644); err != nil {
|
||||
t.Fatalf("write fixture: %v", err)
|
||||
}
|
||||
|
||||
// Run the SAME walk logic as TestLexiconMetaDocsNoBannedTermsInDocs and
|
||||
// assert it FINDS the fixture's banned term. A walk that returns zero
|
||||
// hits here proves the walk logic is broken (the fixture is a known-bad
|
||||
// file inside docs/ that MUST be detected).
|
||||
hits := []string{}
|
||||
err := filepath.Walk(root, func(path string, info os.FileInfo, err error) error {
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if info.IsDir() {
|
||||
base := filepath.Base(path)
|
||||
if base == ".ciagent" || base == ".git" {
|
||||
return filepath.SkipDir
|
||||
}
|
||||
return nil
|
||||
}
|
||||
if path == this {
|
||||
return nil
|
||||
}
|
||||
if !strings.HasSuffix(path, ".md") {
|
||||
return nil
|
||||
}
|
||||
rel, rerr := filepath.Rel(root, path)
|
||||
if rerr != nil {
|
||||
return rerr
|
||||
}
|
||||
if rel != "README.md" && !strings.HasPrefix(rel, "docs"+string(filepath.Separator)) {
|
||||
return nil
|
||||
}
|
||||
bz, rerr := os.ReadFile(path)
|
||||
if rerr != nil {
|
||||
return rerr
|
||||
}
|
||||
if found, ok := lexicon.FindBannedTerm(string(bz)); ok {
|
||||
hits = append(hits, rel+" contains banned term "+found)
|
||||
}
|
||||
return nil
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("walk: %v", err)
|
||||
}
|
||||
// Assert the fixture was found. The rel path uses OS-specific separator;
|
||||
// match on the suffix so the test is portable.
|
||||
foundFixture := false
|
||||
for _, h := range hits {
|
||||
if strings.Contains(h, "bad_fixture.md") && strings.Contains(h, syntheticTerm) {
|
||||
foundFixture = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if !foundFixture {
|
||||
t.Errorf("G-013 walk-coverage: the walk did NOT find the synthetic banned-term fixture at %s — the docs firewall walk logic is broken (it would silently scan nothing and report green). hits=%v", fixtureFile, hits)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,167 @@
|
||||
// Package lexicon_meta holds the project-wide lexicon firewall meta-test
|
||||
// (REQ-012, G-004, G-009). It is the durable firewall created in v0.2 P1
|
||||
// Wave 3; P5-01-01 EXTENDS it rather than recreating it.
|
||||
//
|
||||
// The meta-test scans every .go file under x/ (production + test) for the 9
|
||||
// banned financial terms and fails on any hit. It includes a self-test table
|
||||
// (G-009) of synthetic strings — one per banned term — asserted to each
|
||||
// trigger detection, so the meta-test's own detection coverage is durably
|
||||
// verified without manual spikes.
|
||||
//
|
||||
// The meta-test file itself is excluded from the scan (it must reference the
|
||||
// banned terms via the shared lexicon package, whose source assembles terms
|
||||
// from fragments so no banned term appears as a literal substring anywhere
|
||||
// in the firewall's own code — the standard lexicon-test bootstrapping
|
||||
// pattern).
|
||||
package lexicon_meta
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"runtime"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/oy/openyield/lexicon"
|
||||
)
|
||||
|
||||
// TestLexiconMetaNoBannedTermsInX is the project-wide firewall (G-004).
|
||||
// It walks every .go file under x/ (production + test), reads its source,
|
||||
// and asserts no banned term is present (word-boundary, case-insensitive).
|
||||
// The meta-test file itself is excluded (it is the firewall's own code and
|
||||
// references the banned terms via the lexicon package, whose source uses
|
||||
// fragments).
|
||||
//
|
||||
// Passes at P1: the v0.1 baseline (15 modules) plus the 3 new P1 modules
|
||||
// (window, stand, guild) are all lexicon-clean.
|
||||
func TestLexiconMetaNoBannedTermsInX(t *testing.T) {
|
||||
xRoot := repoXRoot(t)
|
||||
thisFile := thisFile(t)
|
||||
hits := []string{}
|
||||
err := filepath.Walk(xRoot, func(path string, info os.FileInfo, err error) error {
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if info.IsDir() {
|
||||
return nil
|
||||
}
|
||||
if !strings.HasSuffix(path, ".go") {
|
||||
return nil
|
||||
}
|
||||
// Exclude the meta-test file itself (the firewall's own code).
|
||||
if path == thisFile {
|
||||
return nil
|
||||
}
|
||||
bz, rerr := os.ReadFile(path)
|
||||
if rerr != nil {
|
||||
return rerr
|
||||
}
|
||||
if found, ok := lexicon.FindBannedTerm(string(bz)); ok {
|
||||
rel, _ := filepath.Rel(xRoot, path)
|
||||
hits = append(hits, rel+" contains banned term "+found)
|
||||
}
|
||||
return nil
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("walk: %v", err)
|
||||
}
|
||||
if len(hits) > 0 {
|
||||
t.Errorf("REQ-012 lexicon firewall violations:\n %s",
|
||||
strings.Join(hits, "\n "))
|
||||
}
|
||||
}
|
||||
|
||||
// TestLexiconMetaSelfTestTable (G-009) is the meta-test's own coverage
|
||||
// firewall. Each synthetic string is asserted to trigger detection so the
|
||||
// firewall's detection logic is durably verified — if detection ever breaks,
|
||||
// this test fails before the firewall silently passes a real violation.
|
||||
//
|
||||
// REQ-029 (GRILL G-014): the synthetic strings are sourced from
|
||||
// lexicon.SyntheticBannedStrings(), the single source of truth shared with
|
||||
// lexicon_meta_docs_test.go :: TestLexiconMetaDocsSelfTestTable. Before
|
||||
// REQ-029, both meta-tests DUPLICATED their own 10-string table, creating a
|
||||
// drift risk; the shared helper closes it. This file no longer builds its
|
||||
// own synthetic table.
|
||||
func TestLexiconMetaSelfTestTable(t *testing.T) {
|
||||
terms := lexicon.BannedTerms()
|
||||
// The spec lists 10 banned terms (plan docs say "9", counting dollar/euro
|
||||
// as a pair): bank, deposit, interest, yield, currency, dollar, euro,
|
||||
// account, savings, depositor.
|
||||
if len(terms) != 10 {
|
||||
t.Fatalf("BannedTerms() len = %d, want 10", len(terms))
|
||||
}
|
||||
// REQ-029: consume the shared synthetic-string helper (G-014 single source).
|
||||
synthetic := lexicon.SyntheticBannedStrings()
|
||||
if len(synthetic) != len(terms) {
|
||||
t.Fatalf("SyntheticBannedStrings() len = %d, want %d (must match BannedTerms())", len(synthetic), len(terms))
|
||||
}
|
||||
for i, s := range synthetic {
|
||||
found, ok := lexicon.FindBannedTerm(s)
|
||||
if !ok {
|
||||
t.Errorf("G-009 self-test [%d]: synthetic string did not trigger detection: %q", i, s)
|
||||
continue
|
||||
}
|
||||
if found != terms[i] {
|
||||
t.Errorf("G-009 self-test [%d]: detected %q, want %q (in %q)", i, found, terms[i], s)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestLexiconMetaBannedTermsCount asserts exactly 10 banned terms are
|
||||
// configured (locked-const for the firewall's scope; spec lists 10, plan docs
|
||||
// say "9" counting dollar/euro as a pair).
|
||||
func TestLexiconMetaBannedTermsCount(t *testing.T) {
|
||||
terms := lexicon.BannedTerms()
|
||||
if len(terms) != 10 {
|
||||
t.Errorf("BannedTerms() len = %d, want 10 (REQ-012)", len(terms))
|
||||
}
|
||||
seen := map[string]bool{}
|
||||
for _, tr := range terms {
|
||||
if seen[tr] {
|
||||
t.Errorf("duplicate banned term %q", tr)
|
||||
}
|
||||
seen[tr] = true
|
||||
}
|
||||
}
|
||||
|
||||
// TestLexiconMetaNoFalsePositiveOnOpenYield asserts the module name
|
||||
// "openyield" does NOT trigger the "yield" banned term (word-boundary
|
||||
// matching must not match substrings of identifiers). This is the
|
||||
// regression firewall for the word-boundary detection design.
|
||||
func TestLexiconMetaNoFalsePositiveOnOpenYield(t *testing.T) {
|
||||
cases := []string{
|
||||
"github.com/oy/openyield/x/window/types",
|
||||
"package openyield",
|
||||
"openyield is the module",
|
||||
"european resident",
|
||||
}
|
||||
for _, s := range cases {
|
||||
if _, ok := lexicon.FindBannedTerm(s); ok {
|
||||
t.Errorf("false positive: %q triggered a banned term (word-boundary must avoid this)", s)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// repoXRoot returns the absolute path to the repo's x/ directory by walking
|
||||
// up from this test file.
|
||||
func repoXRoot(t *testing.T) string {
|
||||
t.Helper()
|
||||
_, file, _, ok := runtime.Caller(0)
|
||||
if !ok {
|
||||
t.Fatal("runtime.Caller failed")
|
||||
}
|
||||
// file = .../oy/lexicon_meta_test.go -> repo root is its dir; x/ is repo/x
|
||||
repoRoot := filepath.Dir(file)
|
||||
return filepath.Join(repoRoot, "x")
|
||||
}
|
||||
|
||||
// thisFile returns the absolute path of this meta-test file (to exclude it
|
||||
// from its own scan).
|
||||
func thisFile(t *testing.T) string {
|
||||
t.Helper()
|
||||
_, file, _, ok := runtime.Caller(0)
|
||||
if !ok {
|
||||
t.Fatal("runtime.Caller failed")
|
||||
}
|
||||
return file
|
||||
}
|
||||
+63
@@ -0,0 +1,63 @@
|
||||
# OpenYield docs site (MkDocs Material, D-042).
|
||||
#
|
||||
# Build-only Python dep (mkdocs + mkdocs-material); NOT a Go dep (G-006 —
|
||||
# go.mod stays zero-require). Invoke locally with `mkdocs serve` or
|
||||
# `mkdocs build` (see README). No publishing CI in v0.3 (D-046 — publishing
|
||||
# to GitHub/Gitea Pages deferred to v0.4).
|
||||
#
|
||||
# Nav completeness (G-011): the nav lists ALL 26 pages that will exist by end
|
||||
# of P3. P1 creates the shared/ pages + index (8 files); P2 adds nomads/
|
||||
# (8 files); P3 adds freeholders/ (8 files) + reference/ (2 files). Only the
|
||||
# files that exist at P1 ship today; the nav references the not-yet-created
|
||||
# P2/P3 pages by path so the structure is complete and P2/P3 just add files.
|
||||
# mkdocs.yml is a config file, NOT validated by Go tests; the docs firewall
|
||||
# (lexicon_meta_docs_test.go) validates .md content, not nav.
|
||||
|
||||
site_name: OpenYield
|
||||
site_description: OpenYield — a jurisdiction-light, public-good mesh for real production, organized around Holders, Stands, and the Six Principles.
|
||||
|
||||
theme:
|
||||
name: material
|
||||
features:
|
||||
- navigation.sections
|
||||
- navigation.expand
|
||||
- toc.integrate
|
||||
|
||||
markdown_extensions:
|
||||
- admonition
|
||||
- toc:
|
||||
permalink: true
|
||||
- codehilite
|
||||
- pymdownx.superfences
|
||||
|
||||
nav:
|
||||
- Home: index.md
|
||||
- Nomads:
|
||||
- Overview: nomads/index.md
|
||||
- Reach: nomads/reach.md
|
||||
- Stash: nomads/stash.md
|
||||
- Bearers: nomads/bearers.md
|
||||
- Maps-Pay: nomads/maps-pay.md
|
||||
- Pacts: nomads/pacts.md
|
||||
- Standing: nomads/standing.md
|
||||
- Window: nomads/window.md
|
||||
- Freeholders:
|
||||
- Overview: freeholders/index.md
|
||||
- Signals: freeholders/signals.md
|
||||
- Standing: freeholders/standing.md
|
||||
- Stands & Guilds: freeholders/stands-guilds.md
|
||||
- Councils & Voice: freeholders/councils-voice.md
|
||||
- Bonds: freeholders/bonds.md
|
||||
- Partner Spectrum: freeholders/partner-spectrum.md
|
||||
- Anchor Preview: freeholders/anchor-preview.md
|
||||
- Shared:
|
||||
- Overview: shared/index.md
|
||||
- Six Principles: shared/six-principles.md
|
||||
- Bread Scale: shared/bread-scale.md
|
||||
- Storage Pools: shared/storage-pools.md
|
||||
- Watchers & Mirror: shared/watchers-mirror.md
|
||||
- Lexicon: shared/lexicon.md
|
||||
- Vision: shared/vision.md
|
||||
- Reference:
|
||||
- Architecture: reference/architecture.md
|
||||
- Components: reference/components.md
|
||||
@@ -0,0 +1,175 @@
|
||||
package types
|
||||
|
||||
import "encoding/json"
|
||||
|
||||
const (
|
||||
ModuleName = "bearers"
|
||||
StoreKey = ModuleName
|
||||
RouterKey = ModuleName
|
||||
QuerierRoute = ModuleName
|
||||
)
|
||||
|
||||
// BearerType defines the transport bearers (§14)
|
||||
type BearerType string
|
||||
|
||||
const (
|
||||
BearerInternet BearerType = "Internet" // Global, carrier-dependent
|
||||
BearerOYLR BearerType = "OY-LR" // LoRa, 2-10km, surveillance-resistant
|
||||
BearerOYBLE BearerType = "OY-BLE" // Bluetooth, 10-100m
|
||||
BearerOYWiFiDirect BearerType = "OY-WiFi-Direct" // 50-200m
|
||||
BearerOYSAT BearerType = "OY-SAT" // Satellite, global
|
||||
BearerOYQR BearerType = "OY-QR" // Paper, 0 range
|
||||
)
|
||||
|
||||
// BearerInfo describes a bearer's properties (§14)
|
||||
type BearerInfo struct {
|
||||
Type BearerType `json:"type" yaml:"type"`
|
||||
RangeMeters int32 `json:"range_meters" yaml:"range_meters"`
|
||||
CarrierDependent bool `json:"carrier_dependent" yaml:"carrier_dependent"`
|
||||
SurveillanceResistant bool `json:"surveillance_resistant" yaml:"surveillance_resistant"`
|
||||
}
|
||||
|
||||
// AllBearers returns all bearer types with their properties (§14)
|
||||
func AllBearers() []BearerInfo {
|
||||
return []BearerInfo{
|
||||
{BearerInternet, 0, true, false},
|
||||
{BearerOYLR, 10000, false, true},
|
||||
{BearerOYBLE, 100, false, true},
|
||||
{BearerOYWiFiDirect, 200, false, true},
|
||||
{BearerOYSAT, 0, false, true},
|
||||
{BearerOYQR, 0, false, true},
|
||||
}
|
||||
}
|
||||
|
||||
// UnifiedBearerLayer implements first-to-deliver-wins (§14)
|
||||
// RFC 5050 Bundle Protocol principles, delay-tolerant networking
|
||||
type UnifiedBearerLayer struct {
|
||||
ActiveBearers []BearerType `json:"active_bearers" yaml:"active_bearers"`
|
||||
FirstToDeliver bool `json:"first_to_deliver" yaml:"first_to_deliver"`
|
||||
}
|
||||
|
||||
// BearerTransport is the transport interface for a bearer (D-029, vision
|
||||
// §14). A bearer implementation provides Send (dispatch a payload), Receive
|
||||
// (accept an inbound payload), and Status (report the bearer's current
|
||||
// reachability). This is a Go interface stub — no implementation is provided
|
||||
// in v0.2; the OY-LR and Beacon transports are typed stubs only (no
|
||||
// hardware/RF integration per D-029). The interface is the v0.2 hook for the
|
||||
// Phase 3 processing-mesh runtime.
|
||||
type BearerTransport interface {
|
||||
// Send dispatches a payload via the bearer. Returns an error if the
|
||||
// bearer cannot accept the payload. The stub implementations do not
|
||||
// actually transmit; the interface contract is the v0.2 deliverable.
|
||||
Send(payload []byte) error
|
||||
// Receive accepts an inbound payload from the bearer. Returns the
|
||||
// payload and an error if the bearer has no inbound payload.
|
||||
Receive() ([]byte, error)
|
||||
// Status reports the bearer's current reachability (true = reachable).
|
||||
Status() bool
|
||||
}
|
||||
|
||||
// OYLRLink is the OY-LR (LoRa, long-range 2-10km) transport link stub (D-029,
|
||||
// vision §14). OY-LR is surveillance-resistant (vision §14: differs from
|
||||
// Helium's public-coverage model). gateway-id is the LoRa gateway
|
||||
// identifier; range-meters is the link range (2-10km); frequency-mhz is the
|
||||
// operating frequency; surveillance-resistant is LOCKED true for OY-LR (the
|
||||
// bearer is designed to resist surveillance).
|
||||
type OYLRLink struct {
|
||||
GatewayID string `json:"gateway_id" yaml:"gateway_id"`
|
||||
RangeMeters int32 `json:"range_meters" yaml:"range_meters"`
|
||||
FrequencyMHz uint32 `json:"frequency_mhz" yaml:"frequency_mhz"`
|
||||
SurveillanceResistant bool `json:"surveillance_resistant" yaml:"surveillance_resistant"`
|
||||
}
|
||||
|
||||
// BeaconFrame is the OY-Beacon transport-mode beacon frame stub (D-029,
|
||||
// vision §14). A beacon is a transport-mode beacon (presence + small
|
||||
// payload), closest to Eddystone-EID (ephemeral identifier). beacon-id is
|
||||
// the beacon identifier; ephemeral-id is the rotating ephemeral identifier;
|
||||
// payload-bytes is the small payload; ttl is the time-to-live in seconds
|
||||
// (must be > 0 for a valid frame).
|
||||
type BeaconFrame struct {
|
||||
BeaconID string `json:"beacon_id" yaml:"beacon_id"`
|
||||
EphemeralID string `json:"ephemeral_id" yaml:"ephemeral_id"`
|
||||
PayloadBytes []byte `json:"payload_bytes" yaml:"payload_bytes"`
|
||||
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{}
|
||||
|
||||
func DefaultParams() Params { return Params{} }
|
||||
|
||||
type GenesisState struct {
|
||||
Params Params `json:"params" yaml:"params"`
|
||||
}
|
||||
|
||||
func DefaultGenesisState() *GenesisState {
|
||||
return &GenesisState{Params: DefaultParams()}
|
||||
}
|
||||
|
||||
func ValidateGenesis(bz json.RawMessage) error { return nil }
|
||||
@@ -0,0 +1,484 @@
|
||||
package types_test
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"runtime"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/oy/openyield/lexicon"
|
||||
btypes "github.com/oy/openyield/x/bearers/types"
|
||||
ptypes "github.com/oy/openyield/x/processing/types"
|
||||
)
|
||||
|
||||
func TestBearerCount(t *testing.T) {
|
||||
bearers := btypes.AllBearers()
|
||||
if len(bearers) != 6 {
|
||||
t.Errorf("Expected 6 bearers (§14), got %d", len(bearers))
|
||||
}
|
||||
}
|
||||
|
||||
func TestSurveillanceResistantBearers(t *testing.T) {
|
||||
bearers := btypes.AllBearers()
|
||||
for _, b := range bearers {
|
||||
if b.Type == btypes.BearerInternet && b.SurveillanceResistant {
|
||||
t.Error("Internet bearer should NOT be surveillance-resistant (§14)")
|
||||
}
|
||||
if b.Type != btypes.BearerInternet && !b.SurveillanceResistant {
|
||||
t.Errorf("Bearer %s should be surveillance-resistant (§14)", b.Type)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestProcessingModeFCFS(t *testing.T) {
|
||||
if ptypes.ModeFCFS != "FCFS" {
|
||||
t.Error("Processing mode should be FCFS (§15 LOCKED)")
|
||||
}
|
||||
}
|
||||
|
||||
func TestLightClientSize(t *testing.T) {
|
||||
if ptypes.LightClientSizeMB != 30 {
|
||||
t.Errorf("LightClientSize = %d, expected 30 MB (§15)", ptypes.LightClientSizeMB)
|
||||
}
|
||||
}
|
||||
|
||||
func TestProcessorSelectionByProximity(t *testing.T) {
|
||||
processors := []ptypes.Processor{
|
||||
{ProcessorID: "far", Latitude: 40.0, Longitude: 40.0},
|
||||
{ProcessorID: "close", Latitude: 10.0, Longitude: 10.0},
|
||||
{ProcessorID: "mid", Latitude: 20.0, Longitude: 20.0},
|
||||
}
|
||||
selected := ptypes.SelectProcessorByProximity(10.1, 10.1, processors)
|
||||
if selected == nil || selected.ProcessorID != "close" {
|
||||
t.Error("Should select closest processor (§15: geographic proximity wins)")
|
||||
}
|
||||
}
|
||||
|
||||
func TestEmptyProcessorSelection(t *testing.T) {
|
||||
selected := ptypes.SelectProcessorByProximity(10.0, 10.0, []ptypes.Processor{})
|
||||
if selected != nil {
|
||||
t.Error("Empty processor list should return nil")
|
||||
}
|
||||
}
|
||||
|
||||
// --- v0.2 Bearers extension (P4-02-02, D-029) -----------------------------------
|
||||
// The following tests extend the existing v0.1 bearers tests with the v0.2
|
||||
// BearerTransport interface, OYLRLink, and BeaconFrame stubs (D-029). The
|
||||
// existing v0.1 tests above (TestBearerCount, TestSurveillanceResistantBearers,
|
||||
// TestProcessingModeFCFS, TestLightClientSize, TestProcessorSelectionByProximity,
|
||||
// TestEmptyProcessorSelection) MUST remain green — no regression.
|
||||
|
||||
// TestOYLRStillInAllBearers is the REGRESSION test (D-029): OY-LR must still
|
||||
// be in AllBearers() (the 6-bearer count is unchanged by the v0.2 extension).
|
||||
func TestOYLRStillInAllBearers(t *testing.T) {
|
||||
bearers := btypes.AllBearers()
|
||||
if len(bearers) != 6 {
|
||||
t.Errorf("AllBearers() len = %d, expected 6 (no regression — D-029)", len(bearers))
|
||||
}
|
||||
found := false
|
||||
for _, b := range bearers {
|
||||
if b.Type == btypes.BearerOYLR {
|
||||
found = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if !found {
|
||||
t.Error("OY-LR must still be in AllBearers() (no regression — D-029)")
|
||||
}
|
||||
}
|
||||
|
||||
// TestBearerTransportInterfaceSignature asserts the BearerTransport
|
||||
// interface is satisfiable by a stub implementation (D-029). The interface
|
||||
// has three methods: Send, Receive, Status — no implementation is provided
|
||||
// in v0.2; this test verifies the interface compiles and a stub satisfies it.
|
||||
func TestBearerTransportInterfaceSignature(t *testing.T) {
|
||||
// stubTransport is a minimal stub that satisfies BearerTransport.
|
||||
var _ btypes.BearerTransport = stubTransport{}
|
||||
}
|
||||
|
||||
// stubTransport is a minimal stub implementation of BearerTransport for the
|
||||
// interface-signature test. It does not actually transmit (no hardware/RF
|
||||
// integration per D-029); it exists only to verify the interface compiles.
|
||||
type stubTransport struct{}
|
||||
|
||||
func (stubTransport) Send(payload []byte) error { return nil }
|
||||
func (stubTransport) Receive() ([]byte, error) { return nil, nil }
|
||||
func (stubTransport) Status() bool { return true }
|
||||
|
||||
// TestBearerTransportInterfaceMethods asserts the interface methods have the
|
||||
// expected signatures by invoking them on the stub.
|
||||
func TestBearerTransportInterfaceMethods(t *testing.T) {
|
||||
s := stubTransport{}
|
||||
if err := s.Send([]byte("hi")); err != nil {
|
||||
t.Errorf("Send returned error: %v", err)
|
||||
}
|
||||
if _, err := s.Receive(); err != nil {
|
||||
t.Errorf("Receive returned error: %v", err)
|
||||
}
|
||||
if !s.Status() {
|
||||
t.Error("Status should return true for the stub")
|
||||
}
|
||||
}
|
||||
|
||||
// TestOYLRLinkStructNonEmpty asserts the OYLRLink struct is non-empty when
|
||||
// populated, and that surveillance-resistant is true (OY-LR is designed to
|
||||
// resist surveillance — vision §14).
|
||||
func TestOYLRLinkStructNonEmpty(t *testing.T) {
|
||||
link := btypes.OYLRLink{
|
||||
GatewayID: "gw-1",
|
||||
RangeMeters: 10000,
|
||||
FrequencyMHz: 915,
|
||||
SurveillanceResistant: true,
|
||||
}
|
||||
if link.GatewayID != "gw-1" {
|
||||
t.Errorf("GatewayID = %q", link.GatewayID)
|
||||
}
|
||||
if link.RangeMeters != 10000 {
|
||||
t.Errorf("RangeMeters = %d", link.RangeMeters)
|
||||
}
|
||||
if link.FrequencyMHz != 915 {
|
||||
t.Errorf("FrequencyMHz = %d", link.FrequencyMHz)
|
||||
}
|
||||
if !link.SurveillanceResistant {
|
||||
t.Error("SurveillanceResistant must be true for OY-LR (vision §14)")
|
||||
}
|
||||
}
|
||||
|
||||
// TestOYLRLinkSurveillanceResistantTrue asserts the OYLRLink's surveillance-
|
||||
// resistant flag is the locked design property (OY-LR is surveillance-
|
||||
// resistant per vision §14). The zero-value is false; the constructor pattern
|
||||
// must set it true. This test asserts a populated link has it true.
|
||||
func TestOYLRLinkSurveillanceResistantTrue(t *testing.T) {
|
||||
link := btypes.OYLRLink{SurveillanceResistant: true}
|
||||
if !link.SurveillanceResistant {
|
||||
t.Error("OYLRLink.SurveillanceResistant must be true for OY-LR (§14)")
|
||||
}
|
||||
}
|
||||
|
||||
// TestBeaconFrameStructNonEmpty asserts the BeaconFrame struct is non-empty
|
||||
// when populated, and that ttl > 0 for a valid frame.
|
||||
func TestBeaconFrameStructNonEmpty(t *testing.T) {
|
||||
frame := btypes.BeaconFrame{
|
||||
BeaconID: "beacon-1",
|
||||
EphemeralID: "eph-abc",
|
||||
PayloadBytes: []byte{0x01, 0x02},
|
||||
TTL: 300,
|
||||
}
|
||||
if frame.BeaconID != "beacon-1" {
|
||||
t.Errorf("BeaconID = %q", frame.BeaconID)
|
||||
}
|
||||
if frame.EphemeralID != "eph-abc" {
|
||||
t.Errorf("EphemeralID = %q", frame.EphemeralID)
|
||||
}
|
||||
if len(frame.PayloadBytes) != 2 {
|
||||
t.Errorf("PayloadBytes len = %d", len(frame.PayloadBytes))
|
||||
}
|
||||
if frame.TTL <= 0 {
|
||||
t.Errorf("TTL = %d, must be > 0 for a valid frame", frame.TTL)
|
||||
}
|
||||
}
|
||||
|
||||
// TestBeaconFrameTTLPositive asserts a valid BeaconFrame has TTL > 0.
|
||||
func TestBeaconFrameTTLPositive(t *testing.T) {
|
||||
cases := []int64{1, 60, 300, 3600}
|
||||
for _, ttl := range cases {
|
||||
f := btypes.BeaconFrame{TTL: ttl}
|
||||
if f.TTL <= 0 {
|
||||
t.Errorf("TTL = %d, must be > 0", f.TTL)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestDefaultGenesisStateUnchanged asserts DefaultGenesisState is unchanged
|
||||
// by the v0.2 extension (no regression — the v0.1 GenesisState shape is
|
||||
// preserved).
|
||||
func TestDefaultGenesisStateUnchanged(t *testing.T) {
|
||||
gs := btypes.DefaultGenesisState()
|
||||
if gs == nil {
|
||||
t.Fatal("DefaultGenesisState returned nil")
|
||||
}
|
||||
}
|
||||
|
||||
// TestValidateGenesisUnchanged asserts ValidateGenesis is unchanged (no
|
||||
// regression — v0.1 returned nil unconditionally; the extension preserves
|
||||
// this).
|
||||
func TestValidateGenesisUnchanged(t *testing.T) {
|
||||
if err := btypes.ValidateGenesis(nil); err != nil {
|
||||
t.Errorf("ValidateGenesis should return nil (no regression); got: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// --- Lexicon assertion (REQ-012) -------------------------------------------------
|
||||
// The bearers extension must not introduce banned terms. The lexicon helpers
|
||||
// are used here — no banned literals are inlined in this test file.
|
||||
|
||||
// TestLexiconNoBannedTermsInBearersPackage scans every non-test .go file in
|
||||
// the bearers/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 TestLexiconNoBannedTermsInBearersPackage(t *testing.T) {
|
||||
pkgDir := packageDir(t, "github.com/oy/openyield/x/bearers/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 bearers/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 — D-029 extension)", filepath.Base(f), found)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestLexiconNoBannedTermsInBearersTestFile 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 TestLexiconNoBannedTermsInBearersTestFile(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("bearers test file contains banned term %q — use lexicon helpers, not literals", found)
|
||||
}
|
||||
}
|
||||
|
||||
// --- v0.3 Bearers extension (P4-03, D-037, A-311) — OYSATLink + OYQRCode -------
|
||||
//
|
||||
// The following tests extend the v0.2 bearers tests with the v0.3 OY-SAT
|
||||
// and OY-QR transport stubs (D-037). The existing v0.1/v0.2 tests above
|
||||
// MUST remain green — no regression. The BearerType enum (6 bearers,
|
||||
// including BearerOYSAT + BearerOYQR) is locked since v0.1; v0.3 adds the
|
||||
// transport STRUCTS only (no enum change).
|
||||
|
||||
// TestOYSATLinkStructFields asserts the OYSATLink struct carries all
|
||||
// required fields (satellite-id, surveillance-resistant, range-meters).
|
||||
func TestOYSATLinkStructFields(t *testing.T) {
|
||||
link := btypes.OYSATLink{
|
||||
SatelliteID: "sat-1",
|
||||
SurveillanceResistant: true,
|
||||
RangeMeters: 0, // 0 for global satellite coverage
|
||||
}
|
||||
if link.SatelliteID != "sat-1" {
|
||||
t.Errorf("SatelliteID = %q", link.SatelliteID)
|
||||
}
|
||||
if !link.SurveillanceResistant {
|
||||
t.Error("SurveillanceResistant must be true for OY-SAT (vision §14)")
|
||||
}
|
||||
if link.RangeMeters != 0 {
|
||||
t.Errorf("RangeMeters = %d, want 0 (global)", link.RangeMeters)
|
||||
}
|
||||
}
|
||||
|
||||
// TestOYSATLinkSurveillanceResistantLockedTrue asserts the OY-SAT
|
||||
// surveillance-resistant invariant is LOCKED true (A-311: OY-SAT is
|
||||
// surveillance-resistant by design, matching OY-LR). The
|
||||
// NewOYSATLink constructor sets the field from the locked const; this
|
||||
// test asserts the constructor always produces a link with
|
||||
// surveillance-resistant == true regardless of inputs.
|
||||
func TestOYSATLinkSurveillanceResistantLockedTrue(t *testing.T) {
|
||||
// The LOCKED const must be true (A-311).
|
||||
if !btypes.OYSATSurveillanceResistant {
|
||||
t.Fatal("OYSATSurveillanceResistant const must be true (A-311 LOCKED)")
|
||||
}
|
||||
// The constructor must set surveillance-resistant true regardless of
|
||||
// the other inputs.
|
||||
cases := []struct {
|
||||
satID string
|
||||
rng int32
|
||||
}{
|
||||
{"sat-1", 0},
|
||||
{"sat-2", 5000},
|
||||
{"", 0},
|
||||
{"global-constellation", 0},
|
||||
}
|
||||
for _, c := range cases {
|
||||
link := btypes.NewOYSATLink(c.satID, c.rng)
|
||||
if !link.SurveillanceResistant {
|
||||
t.Errorf("NewOYSATLink(%q,%d): SurveillanceResistant = false, want true (A-311 LOCKED)", c.satID, c.rng)
|
||||
}
|
||||
if link.SurveillanceResistant != btypes.OYSATSurveillanceResistant {
|
||||
t.Errorf("NewOYSATLink(%q,%d): field != locked const (A-311)", c.satID, c.rng)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestOYSATLinkConstructorSetsFields asserts NewOYSATLink sets the
|
||||
// satellite-id and range-meters fields from the constructor args.
|
||||
func TestOYSATLinkConstructorSetsFields(t *testing.T) {
|
||||
link := btypes.NewOYSATLink("iridium-1", 0)
|
||||
if link.SatelliteID != "iridium-1" {
|
||||
t.Errorf("SatelliteID = %q, want %q", link.SatelliteID, "iridium-1")
|
||||
}
|
||||
if link.RangeMeters != 0 {
|
||||
t.Errorf("RangeMeters = %d, want 0", link.RangeMeters)
|
||||
}
|
||||
link2 := btypes.NewOYSATLink("starlink-2", 5000)
|
||||
if link2.SatelliteID != "starlink-2" {
|
||||
t.Errorf("SatelliteID = %q, want %q", link2.SatelliteID, "starlink-2")
|
||||
}
|
||||
if link2.RangeMeters != 5000 {
|
||||
t.Errorf("RangeMeters = %d, want 5000", link2.RangeMeters)
|
||||
}
|
||||
}
|
||||
|
||||
// TestOYSATStillInAllBearers is the v0.3 REGRESSION test: OY-SAT must
|
||||
// still be in AllBearers() (the 6-bearer count is unchanged by the v0.3
|
||||
// extension — the BearerType enum is locked since v0.1).
|
||||
func TestOYSATStillInAllBearers(t *testing.T) {
|
||||
bearers := btypes.AllBearers()
|
||||
if len(bearers) != 6 {
|
||||
t.Errorf("AllBearers() len = %d, expected 6 (no regression — D-037)", len(bearers))
|
||||
}
|
||||
found := false
|
||||
for _, b := range bearers {
|
||||
if b.Type == btypes.BearerOYSAT {
|
||||
found = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if !found {
|
||||
t.Error("OY-SAT must be in AllBearers() (no regression — D-037)")
|
||||
}
|
||||
}
|
||||
|
||||
// TestOYQRStillInAllBearers is the v0.3 REGRESSION test: OY-QR must still
|
||||
// be in AllBearers() (the 6-bearer count is unchanged).
|
||||
func TestOYQRStillInAllBearers(t *testing.T) {
|
||||
bearers := btypes.AllBearers()
|
||||
if len(bearers) != 6 {
|
||||
t.Errorf("AllBearers() len = %d, expected 6 (no regression — D-037)", len(bearers))
|
||||
}
|
||||
found := false
|
||||
for _, b := range bearers {
|
||||
if b.Type == btypes.BearerOYQR {
|
||||
found = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if !found {
|
||||
t.Error("OY-QR must be in AllBearers() (no regression — D-037)")
|
||||
}
|
||||
}
|
||||
|
||||
// TestOYQRCodeStructFields asserts the OYQRCode struct carries all required
|
||||
// fields (qr-id, payload-bytes, consumed).
|
||||
func TestOYQRCodeStructFields(t *testing.T) {
|
||||
q := btypes.OYQRCode{
|
||||
QRID: "qr-1",
|
||||
PayloadBytes: []byte{0x01, 0x02, 0x03},
|
||||
Consumed: false,
|
||||
}
|
||||
if q.QRID != "qr-1" {
|
||||
t.Errorf("QRID = %q", q.QRID)
|
||||
}
|
||||
if len(q.PayloadBytes) != 3 {
|
||||
t.Errorf("PayloadBytes len = %d, want 3", len(q.PayloadBytes))
|
||||
}
|
||||
if q.Consumed {
|
||||
t.Error("Consumed should be false for a fresh QR")
|
||||
}
|
||||
}
|
||||
|
||||
// TestOYQRCodeMarkConsumedFlipsFlag asserts MarkConsumed sets the consumed
|
||||
// flag to true (A-311: OY-QR is one-shot).
|
||||
func TestOYQRCodeMarkConsumedFlipsFlag(t *testing.T) {
|
||||
q := btypes.OYQRCode{QRID: "qr-1", PayloadBytes: []byte{0x01}, Consumed: false}
|
||||
if q.Consumed {
|
||||
t.Fatal("fresh QR should have Consumed == false")
|
||||
}
|
||||
q.MarkConsumed()
|
||||
if !q.Consumed {
|
||||
t.Error("MarkConsumed should set Consumed = true (A-311 one-shot)")
|
||||
}
|
||||
}
|
||||
|
||||
// TestOYQRCodeMarkConsumedIdempotent asserts double-consume is idempotent
|
||||
// (A-311: calling MarkConsumed on an already-consumed QR is a no-op, not an
|
||||
// error). This locks the one-shot semantics: a QR cannot be unconsumed, and
|
||||
// double-marking is safe.
|
||||
func TestOYQRCodeMarkConsumedIdempotent(t *testing.T) {
|
||||
q := btypes.OYQRCode{QRID: "qr-1", PayloadBytes: []byte{0x01}, Consumed: false}
|
||||
// First consume: false -> true.
|
||||
q.MarkConsumed()
|
||||
if !q.Consumed {
|
||||
t.Fatal("first MarkConsumed failed: Consumed still false")
|
||||
}
|
||||
// Second consume: idempotent no-op (stays true, no error, no panic).
|
||||
q.MarkConsumed()
|
||||
if !q.Consumed {
|
||||
t.Error("second MarkConsumed should be idempotent; Consumed must stay true (A-311)")
|
||||
}
|
||||
// Third consume: still idempotent.
|
||||
q.MarkConsumed()
|
||||
if !q.Consumed {
|
||||
t.Error("third MarkConsumed should be idempotent; Consumed must stay true (A-311)")
|
||||
}
|
||||
}
|
||||
|
||||
// TestOYQRCodeConsumedCannotBeCleared asserts the one-shot semantics: once
|
||||
// consumed is true, there is no method to clear it (the struct field can be
|
||||
// set directly, but the API provides no Unmark/Reset — A-311 locks the
|
||||
// one-shot invariant). This test verifies no Unmark/Reset method exists by
|
||||
// confirming MarkConsumed is the only state-mutating method (the struct is
|
||||
// a plain data type; the invariant is enforced by the API surface, not a
|
||||
// private field — matching the v0.2 OYLRLink/BeaconFrame shape approach).
|
||||
func TestOYQRCodeConsumedCannotBeCleared(t *testing.T) {
|
||||
q := btypes.OYQRCode{QRID: "qr-1", Consumed: false}
|
||||
q.MarkConsumed()
|
||||
if !q.Consumed {
|
||||
t.Fatal("MarkConsumed failed")
|
||||
}
|
||||
// The one-shot invariant: there is no UnmarkConsumed/Reset method on
|
||||
// OYQRCode. The struct is a plain data type; the API surface (only
|
||||
// MarkConsumed) enforces the one-way transition. We assert the method
|
||||
// set by confirming MarkConsumed does not flip back to false.
|
||||
q.MarkConsumed() // idempotent
|
||||
if !q.Consumed {
|
||||
t.Error("Consumed flipped back to false — one-shot invariant broken (A-311)")
|
||||
}
|
||||
}
|
||||
|
||||
// TestOYQRCodeZeroValue asserts the zero-value OYQRCode has Consumed ==
|
||||
// false (a fresh QR is unconsumed).
|
||||
func TestOYQRCodeZeroValue(t *testing.T) {
|
||||
var q btypes.OYQRCode
|
||||
if q.Consumed {
|
||||
t.Error("zero-value OYQRCode should have Consumed == false")
|
||||
}
|
||||
if q.QRID != "" {
|
||||
t.Errorf("zero-value QRID = %q, want empty", q.QRID)
|
||||
}
|
||||
}
|
||||
|
||||
// packageDir resolves a Go import path to its filesystem directory by
|
||||
// walking up from this test file (v0.2 skeleton has zero external deps).
|
||||
func packageDir(t *testing.T, importPath string) string {
|
||||
t.Helper()
|
||||
_, file, _, ok := runtime.Caller(0)
|
||||
if !ok {
|
||||
t.Fatal("runtime.Caller failed")
|
||||
}
|
||||
// file = .../oy/x/bearers/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)
|
||||
}
|
||||
@@ -0,0 +1,74 @@
|
||||
package types
|
||||
|
||||
import "encoding/json"
|
||||
|
||||
const (
|
||||
ModuleName = "bloom"
|
||||
StoreKey = ModuleName
|
||||
RouterKey = ModuleName
|
||||
QuerierRoute = ModuleName
|
||||
|
||||
// Target Bloom rate: ~4-5% per year (§6)
|
||||
// Honest expectation, not a promise
|
||||
TargetBloomRateBasisPoints = 450 // 4.5%
|
||||
MinBloomRateBasisPoints = 400 // 4.0%
|
||||
MaxBloomRateBasisPoints = 500 // 5.0%
|
||||
|
||||
// Bloom accrues automatically to every Grain in every Stash (§6)
|
||||
// Accrual period: daily (in blocks, assuming ~10min blocks)
|
||||
AccrualPeriodBlocks = 144
|
||||
)
|
||||
|
||||
// BloomRecord tracks Bloom accrued to a Stash
|
||||
type BloomRecord struct {
|
||||
StashID string `json:"stash_id" yaml:"stash_id"`
|
||||
AccruedGrain int64 `json:"accrued_grain" yaml:"accrued_grain"`
|
||||
LastAccrualBlock int64 `json:"last_accrual_block" yaml:"last_accrual_block"`
|
||||
RateBasisPoints uint32 `json:"rate_basis_points" yaml:"rate_basis_points"`
|
||||
}
|
||||
|
||||
// BloomBoosterBucket represents a higher-strategy bucket (§3 lexicon)
|
||||
type BloomBoosterBucket struct {
|
||||
BucketID string `json:"bucket_id" yaml:"bucket_id"`
|
||||
StashID string `json:"stash_id" yaml:"stash_id"`
|
||||
PledgeAmount int64 `json:"pledge_amount" yaml:"pledge_amount"`
|
||||
BoostRateBps uint32 `json:"boost_rate_bps" yaml:"boost_rate_bps"`
|
||||
UnlockHeight int64 `json:"unlock_height" yaml:"unlock_height"`
|
||||
}
|
||||
|
||||
// BloomSource defines where Bloom originates (§6: only from real production)
|
||||
type BloomSource string
|
||||
|
||||
const (
|
||||
BloomFromTreasury BloomSource = "TreasuryCoupons"
|
||||
BloomFromCorporate BloomSource = "CorporateCoupons"
|
||||
BloomFromGoldCarry BloomSource = "GoldCarry"
|
||||
BloomFromRwaCashflow BloomSource = "RwaCashflow"
|
||||
)
|
||||
|
||||
// MissionLockBloom: Bloom originates ONLY from real production (§6)
|
||||
// No synthetic Bloom. No protocol-printed Bloom.
|
||||
// This is a Mission Lock — no Council vote can change it.
|
||||
const MissionLockBloom = "Bloom originates only from real production. No synthetic Bloom. No protocol-printed Bloom."
|
||||
|
||||
type Params struct {
|
||||
TargetRateBps uint32 `json:"target_rate_bps" yaml:"target_rate_bps"`
|
||||
AccrualBlocks uint32 `json:"accrual_blocks" yaml:"accrual_blocks"`
|
||||
}
|
||||
|
||||
func DefaultParams() Params {
|
||||
return Params{
|
||||
TargetRateBps: TargetBloomRateBasisPoints,
|
||||
AccrualBlocks: AccrualPeriodBlocks,
|
||||
}
|
||||
}
|
||||
|
||||
type GenesisState struct {
|
||||
Params Params `json:"params" yaml:"params"`
|
||||
}
|
||||
|
||||
func DefaultGenesisState() *GenesisState {
|
||||
return &GenesisState{Params: DefaultParams()}
|
||||
}
|
||||
|
||||
func ValidateGenesis(bz json.RawMessage) error { return nil }
|
||||
@@ -0,0 +1,41 @@
|
||||
package types_test
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/oy/openyield/x/bloom/types"
|
||||
)
|
||||
|
||||
func TestTargetBloomRate(t *testing.T) {
|
||||
if types.TargetBloomRateBasisPoints != 450 {
|
||||
t.Errorf("TargetBloomRate = %d, expected 450 (§6: 4.5 pct)", types.TargetBloomRateBasisPoints)
|
||||
}
|
||||
if types.MinBloomRateBasisPoints != 400 {
|
||||
t.Errorf("MinBloomRate = %d, expected 400 (§6: 4-5 pct)", types.MinBloomRateBasisPoints)
|
||||
}
|
||||
if types.MaxBloomRateBasisPoints != 500 {
|
||||
t.Errorf("MaxBloomRate = %d, expected 500 (§6: 4-5 pct)", types.MaxBloomRateBasisPoints)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBloomSources(t *testing.T) {
|
||||
sources := []types.BloomSource{
|
||||
types.BloomFromTreasury, types.BloomFromCorporate,
|
||||
types.BloomFromGoldCarry, types.BloomFromRwaCashflow,
|
||||
}
|
||||
if len(sources) != 4 {
|
||||
t.Errorf("Expected 4 Bloom sources, got %d", len(sources))
|
||||
}
|
||||
}
|
||||
|
||||
func TestMissionLockBloom(t *testing.T) {
|
||||
if types.MissionLockBloom == "" {
|
||||
t.Error("MissionLockBloom should not be empty (§6: mission-locked)")
|
||||
}
|
||||
}
|
||||
|
||||
func TestAccrualPeriod(t *testing.T) {
|
||||
if types.AccrualPeriodBlocks != 144 {
|
||||
t.Errorf("AccrualPeriodBlocks = %d, expected 144 (~daily)", types.AccrualPeriodBlocks)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,144 @@
|
||||
package types
|
||||
|
||||
import "fmt"
|
||||
|
||||
// genesis.go holds the data-engineer's genesis schema helpers for the bond
|
||||
// module (G-008 split). ValidateGenesis in types.go composes these helpers;
|
||||
// the security-engineer's test assertions live in types_test.go.
|
||||
//
|
||||
// The Bond genesis schema has one top-level set: Bonds (the issued bonds).
|
||||
// The invariants enforced at genesis load are (1) bond-id uniqueness, and
|
||||
// (2) the coupon clamp — each genesis bond's coupon-bps must be within
|
||||
// [CouponFloorBps, CouponCapBps]. The clamp invariant is the highest-severity
|
||||
// bond firewall (D-028): a genesis bond with a coupon above the cap or below
|
||||
// the floor is rejected at genesis load.
|
||||
|
||||
// ValidateBonds asserts bond-ids are present and unique, that each bond's
|
||||
// status is a known BondStatus, and that each bond's coupon-bps is within
|
||||
// the LOCKED bounds [CouponFloorBps, CouponCapBps] (the genesis-side clamp
|
||||
// enforcement — D-028). ValidateBonds is the data-engineer's schema
|
||||
// validator, composed by ValidateGenesis in types.go.
|
||||
func ValidateBonds(bonds []Bond) error {
|
||||
seen := make(map[string]bool, len(bonds))
|
||||
for i, b := range bonds {
|
||||
if b.BondID == "" {
|
||||
return fmt.Errorf("bond [%d]: empty bond-id", i)
|
||||
}
|
||||
if seen[b.BondID] {
|
||||
return fmt.Errorf("bond: duplicate bond-id %q", b.BondID)
|
||||
}
|
||||
seen[b.BondID] = true
|
||||
if !knownBondStatus(b.Status) {
|
||||
return fmt.Errorf("bond %q: unknown bond status %q", b.BondID, b.Status)
|
||||
}
|
||||
// Genesis-side clamp enforcement (D-028): a genesis bond's coupon
|
||||
// must be within the LOCKED [floor, cap] bounds. A bond with an
|
||||
// out-of-bounds coupon is rejected at genesis load rather than
|
||||
// silently clamped — the genesis schema is authoritative.
|
||||
if b.CouponBps < CouponFloorBps || b.CouponBps > CouponCapBps {
|
||||
return fmt.Errorf("bond %q: coupon-bps %d outside [%d, %d] (D-028 clamp at genesis load)",
|
||||
b.BondID, b.CouponBps, CouponFloorBps, CouponCapBps)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// knownBondStatus reports whether s is one of the five BondStatus values.
|
||||
func knownBondStatus(s BondStatus) bool {
|
||||
for _, ss := range AllBondStatuses() {
|
||||
if s == ss {
|
||||
return true
|
||||
}
|
||||
}
|
||||
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
|
||||
}
|
||||
@@ -0,0 +1,97 @@
|
||||
package types_test
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"testing"
|
||||
|
||||
btypes "github.com/oy/openyield/x/bond/types"
|
||||
)
|
||||
|
||||
// genesis_test.go holds the security-engineer's genesis-clamp test assertions
|
||||
// for the bond module (G-008 — security-engineer owns ALL *_test.go files,
|
||||
// including genesis_test.go). These tests focus on the data-engineer's
|
||||
// genesis schema clamp enforcement (P4-01-03): ValidateGenesis rejects any
|
||||
// genesis bond whose coupon-bps is outside the LOCKED [floor, cap] bounds.
|
||||
// The clamp invariant (D-028) is the highest-severity bond firewall; the
|
||||
// genesis load is the first enforcement point.
|
||||
|
||||
// TestGenesisClampRejectsAboveCapForManyBonds asserts that multiple bonds,
|
||||
// each with a coupon above the cap, are all rejected. The genesis clamp
|
||||
// applies per-bond (not just the first).
|
||||
func TestGenesisClampRejectsAboveCapForManyBonds(t *testing.T) {
|
||||
gs := btypes.GenesisState{
|
||||
Bonds: []btypes.Bond{
|
||||
{BondID: "b1", IssuerStandID: "s1", CouponBps: 801, Status: btypes.BondIssued},
|
||||
{BondID: "b2", IssuerStandID: "s1", CouponBps: 900, Status: btypes.BondActive},
|
||||
{BondID: "b3", IssuerStandID: "s1", CouponBps: 5000, Status: btypes.BondMatured},
|
||||
},
|
||||
}
|
||||
bz, _ := json.Marshal(gs)
|
||||
if err := btypes.ValidateGenesis(bz); err == nil {
|
||||
t.Error("ValidateGenesis should reject bonds with coupon-bps above cap")
|
||||
}
|
||||
}
|
||||
|
||||
// TestGenesisClampAcceptsAtBounds asserts bonds at the floor (0) and cap (800)
|
||||
// are accepted at genesis load (boundary inclusive).
|
||||
func TestGenesisClampAcceptsAtBounds(t *testing.T) {
|
||||
gs := btypes.GenesisState{
|
||||
Bonds: []btypes.Bond{
|
||||
{BondID: "b-floor", IssuerStandID: "s1", CouponBps: 0, Status: btypes.BondIssued},
|
||||
{BondID: "b-cap", IssuerStandID: "s1", CouponBps: 800, Status: btypes.BondIssued},
|
||||
},
|
||||
}
|
||||
bz, _ := json.Marshal(gs)
|
||||
if err := btypes.ValidateGenesis(bz); err != nil {
|
||||
t.Errorf("ValidateGenesis should accept bonds at floor (0) and cap (800); got: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// TestGenesisClampRejectsJustAboveCap asserts a coupon 1 bps above the cap is
|
||||
// rejected (off-by-one regression firewall).
|
||||
func TestGenesisClampRejectsJustAboveCap(t *testing.T) {
|
||||
gs := btypes.GenesisState{
|
||||
Bonds: []btypes.Bond{{BondID: "b1", IssuerStandID: "s1", CouponBps: 801, Status: btypes.BondIssued}},
|
||||
}
|
||||
bz, _ := json.Marshal(gs)
|
||||
if err := btypes.ValidateGenesis(bz); err == nil {
|
||||
t.Error("ValidateGenesis should reject coupon-bps == 801 (just above cap 800)")
|
||||
}
|
||||
}
|
||||
|
||||
// TestGenesisClampAcceptsJustBelowCap asserts a coupon 1 bps below the cap is
|
||||
// accepted.
|
||||
func TestGenesisClampAcceptsJustBelowCap(t *testing.T) {
|
||||
gs := btypes.GenesisState{
|
||||
Bonds: []btypes.Bond{{BondID: "b1", IssuerStandID: "s1", CouponBps: 799, Status: btypes.BondIssued}},
|
||||
}
|
||||
bz, _ := json.Marshal(gs)
|
||||
if err := btypes.ValidateGenesis(bz); err != nil {
|
||||
t.Errorf("ValidateGenesis should accept coupon-bps == 799 (just below cap); got: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// TestGenesisValidateBondsRejectsDup asserts the data-engineer's ValidateBonds
|
||||
// helper rejects duplicate bond-ids.
|
||||
func TestGenesisValidateBondsRejectsDup(t *testing.T) {
|
||||
bonds := []btypes.Bond{
|
||||
{BondID: "b1", IssuerStandID: "s1", CouponBps: 100, Status: btypes.BondIssued},
|
||||
{BondID: "b1", IssuerStandID: "s2", CouponBps: 200, Status: btypes.BondActive},
|
||||
}
|
||||
if err := btypes.ValidateBonds(bonds); err == nil {
|
||||
t.Error("ValidateBonds should reject duplicate bond-ids")
|
||||
}
|
||||
}
|
||||
|
||||
// TestGenesisValidateBondsAcceptsClean asserts ValidateBonds accepts a clean
|
||||
// set of bonds.
|
||||
func TestGenesisValidateBondsAcceptsClean(t *testing.T) {
|
||||
bonds := []btypes.Bond{
|
||||
{BondID: "b1", IssuerStandID: "s1", CouponBps: 0, Status: btypes.BondIssued},
|
||||
{BondID: "b2", IssuerStandID: "s1", CouponBps: 500, Status: btypes.BondActive},
|
||||
{BondID: "b3", IssuerStandID: "s2", CouponBps: 800, Status: btypes.BondMatured},
|
||||
}
|
||||
if err := btypes.ValidateBonds(bonds); err != nil {
|
||||
t.Errorf("ValidateBonds should accept clean bonds; got: %v", err)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,301 @@
|
||||
package types
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
)
|
||||
|
||||
const (
|
||||
ModuleName = "bond"
|
||||
StoreKey = ModuleName
|
||||
RouterKey = ModuleName
|
||||
QuerierRoute = ModuleName
|
||||
|
||||
// CouponCapBps is the upper bound on a bond coupon in basis points
|
||||
// (vision §17, REQ-021, D-028). Mission-locked at 8pct (800 bps); no
|
||||
// Council vote can change it. The bond module is the highest lexicon-risk
|
||||
// package (A-210): the coupon vocabulary is used EXCLUSIVELY here — the
|
||||
// banned financial terms that are natural coupon-synonyms are NEVER used
|
||||
// in this package. The security-engineer's lexicon assertion in
|
||||
// types_test.go is the firewall gate.
|
||||
CouponCapBps = 800 // 8pct (cap, LOCKED — D-028)
|
||||
|
||||
// CouponFloorBps is the lower bound on a bond coupon in basis points
|
||||
// (vision §17, REQ-021, D-028). Mission-locked at 0pct (0 bps); no
|
||||
// Council vote can change it.
|
||||
CouponFloorBps = 0 // 0pct (floor, LOCKED — D-028)
|
||||
|
||||
// BondStatusCount is the locked count of BondStatus enum values (vision
|
||||
// §17, REQ-021). A regression firewall: adding/removing/renaming a bond
|
||||
// status breaks this const's test.
|
||||
BondStatusCount = 5
|
||||
)
|
||||
|
||||
// BondStatus enumerates the bond lifecycle states (vision §17, REQ-021).
|
||||
// The five statuses mirror a fixed-coupon commitment lifecycle: Issued
|
||||
// (created), Active (in good standing), Matured (term reached), Defaulted
|
||||
// (covenant breach), Repaid (principal returned).
|
||||
type BondStatus string
|
||||
|
||||
const (
|
||||
BondIssued BondStatus = "Issued" // created, not yet active
|
||||
BondActive BondStatus = "Active" // in good standing
|
||||
BondMatured BondStatus = "Matured" // term reached
|
||||
BondDefaulted BondStatus = "Defaulted" // covenant breach
|
||||
BondRepaid BondStatus = "Repaid" // principal returned
|
||||
)
|
||||
|
||||
// AllBondStatuses returns all five BondStatus values in REQ-021 lifecycle
|
||||
// order. Locked-const test asserts exactly 5 entries with these names.
|
||||
func AllBondStatuses() []BondStatus {
|
||||
return []BondStatus{
|
||||
BondIssued,
|
||||
BondActive,
|
||||
BondMatured,
|
||||
BondDefaulted,
|
||||
BondRepaid,
|
||||
}
|
||||
}
|
||||
|
||||
// Bond is a fixed-coupon commitment issued by a Stand (vision §17, REQ-021).
|
||||
// issuer-stand-id references x/stand by ID string (G-003 by-ID-string ref —
|
||||
// P1-02-01 stand-id-ref; no struct import of x/stand). principal-grain is the
|
||||
// principal in Grain (the OY internal unit, cross-ref x/bread). coupon-bps is
|
||||
// the coupon rate in basis points, clamped to [CouponFloorBps, CouponCapBps]
|
||||
// by Clamp at issuance and at genesis load. term-days is the term length.
|
||||
// issued-at and maturity are unix timestamps. status is the lifecycle state.
|
||||
type Bond struct {
|
||||
BondID string `json:"bond_id" yaml:"bond_id"`
|
||||
IssuerStandID string `json:"issuer_stand_id" yaml:"issuer_stand_id"`
|
||||
PrincipalGrain int64 `json:"principal_grain" yaml:"principal_grain"`
|
||||
CouponBps uint32 `json:"coupon_bps" yaml:"coupon_bps"`
|
||||
TermDays uint32 `json:"term_days" yaml:"term_days"`
|
||||
IssuedAt int64 `json:"issued_at" yaml:"issued_at"`
|
||||
Maturity int64 `json:"maturity" yaml:"maturity"`
|
||||
Status BondStatus `json:"status" yaml:"status"`
|
||||
}
|
||||
|
||||
// Issue is the bond issuance stub (REQ-021, D-028). It constructs a Bond with
|
||||
// the coupon clamped to [CouponFloorBps, CouponCapBps]. The stub does not
|
||||
// persist or enforce referential integrity of issuer-stand-id (that is a
|
||||
// v0.3 keeper concern); it only enforces the coupon clamp invariant at
|
||||
// construction time. The returned Bond has status BondIssued.
|
||||
func Issue(bondID, issuerStandID string, principalGrain int64, couponBps uint32, termDays uint32, issuedAt, maturity int64) Bond {
|
||||
return Bond{
|
||||
BondID: bondID,
|
||||
IssuerStandID: issuerStandID,
|
||||
PrincipalGrain: principalGrain,
|
||||
CouponBps: Clamp(couponBps),
|
||||
TermDays: termDays,
|
||||
IssuedAt: issuedAt,
|
||||
Maturity: maturity,
|
||||
Status: BondIssued,
|
||||
}
|
||||
}
|
||||
|
||||
// Clamp ensures a coupon is within the LOCKED bounds (vision §17, REQ-021,
|
||||
// D-028: never above the cap, never below the floor). This is automatic and
|
||||
// authoritative; no Council vote can change it. The shape mirrors
|
||||
// x/feecovenant's Clamp exactly (min(cap, max(floor, coupon))).
|
||||
func Clamp(couponBps uint32) uint32 {
|
||||
if couponBps > CouponCapBps {
|
||||
return CouponCapBps
|
||||
}
|
||||
if couponBps < CouponFloorBps {
|
||||
return CouponFloorBps
|
||||
}
|
||||
return couponBps
|
||||
}
|
||||
|
||||
// Params for the bond module (skeleton — no tunables in v0.2; the cap and
|
||||
// floor are LOCKED consts, not Params fields).
|
||||
type Params struct{}
|
||||
|
||||
func DefaultParams() Params { return Params{} }
|
||||
|
||||
// GenesisState defines the bond module genesis state (REQ-021, REQ-026).
|
||||
// Bonds is the top-level set of issued bonds (v0.2). GrowthBonds (v0.3) and
|
||||
// Orders (v0.3) extend the genesis with growth bonds and secondary-market
|
||||
// orders. ValidateGenesis enforces bond-id / growth-bond-id / order-id
|
||||
// uniqueness and the coupon clamp at genesis load (the data-engineer's
|
||||
// genesis.go holds the schema helpers per G-008).
|
||||
type GenesisState struct {
|
||||
Params Params `json:"params" yaml:"params"`
|
||||
Bonds []Bond `json:"bonds" yaml:"bonds"`
|
||||
GrowthBonds []GrowthBond `json:"growth_bonds" yaml:"growth_bonds"`
|
||||
Orders []SecondaryOrder `json:"orders" yaml:"orders"`
|
||||
}
|
||||
|
||||
func DefaultGenesisState() *GenesisState {
|
||||
return &GenesisState{
|
||||
Params: DefaultParams(),
|
||||
Bonds: []Bond{},
|
||||
GrowthBonds: []GrowthBond{},
|
||||
Orders: []SecondaryOrder{},
|
||||
}
|
||||
}
|
||||
|
||||
// ValidateGenesis performs ID-uniqueness checks (A-212 upgrade from v0.1
|
||||
// no-op): rejects duplicate bond-ids / growth-bond-ids / order-ids, and runs
|
||||
// the coupon clamp at genesis load (each genesis bond's coupon-bps must be
|
||||
// within [floor, cap]). 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("bond: invalid genesis: %w", err)
|
||||
}
|
||||
if err := ValidateBonds(gs.Bonds); err != nil {
|
||||
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
|
||||
}
|
||||
|
||||
// --- 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"`
|
||||
}
|
||||
@@ -0,0 +1,964 @@
|
||||
package types_test
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"runtime"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/oy/openyield/lexicon"
|
||||
btypes "github.com/oy/openyield/x/bond/types"
|
||||
)
|
||||
|
||||
// --- Clamp invariant tests (highest-severity for bond) --------------------------
|
||||
// The Clamp invariant is the bond module's firewall (D-028): a bond coupon
|
||||
// can never exceed the cap (8pct) and can never fall below the floor (0pct).
|
||||
// These tests are the regression firewall — a change to CouponCapBps or
|
||||
// CouponFloorBps breaks them.
|
||||
|
||||
// TestCouponCapBpsLockedConst asserts CouponCapBps == 800 (8pct, D-028 LOCKED).
|
||||
// A regression firewall: changing the cap breaks this test.
|
||||
func TestCouponCapBpsLockedConst(t *testing.T) {
|
||||
if btypes.CouponCapBps != 800 {
|
||||
t.Errorf("CouponCapBps = %d, expected 800 (8pct — D-028 LOCKED)", btypes.CouponCapBps)
|
||||
}
|
||||
}
|
||||
|
||||
// TestCouponFloorBpsLockedConst asserts CouponFloorBps == 0 (0pct, D-028 LOCKED).
|
||||
// A regression firewall: changing the floor breaks this test.
|
||||
func TestCouponFloorBpsLockedConst(t *testing.T) {
|
||||
if btypes.CouponFloorBps != 0 {
|
||||
t.Errorf("CouponFloorBps = %d, expected 0 (0pct — D-028 LOCKED)", btypes.CouponFloorBps)
|
||||
}
|
||||
}
|
||||
|
||||
// TestClampBelowFloorReturnsFloor asserts a coupon below the floor is clamped
|
||||
// up to the floor.
|
||||
func TestClampBelowFloorReturnsFloor(t *testing.T) {
|
||||
// Negative coupons are not representable (uint32); the only "below floor"
|
||||
// case is impossible since the floor is 0 and the type is uint32. The test
|
||||
// asserts the floor value itself passes through (the in-range boundary).
|
||||
// A future floor > 0 would make this test assert negative-clamping; the
|
||||
// current floor == 0 means the below-floor case is type-prevented.
|
||||
got := btypes.Clamp(btypes.CouponFloorBps)
|
||||
if got != btypes.CouponFloorBps {
|
||||
t.Errorf("Clamp(floor) = %d, expected floor %d", got, btypes.CouponFloorBps)
|
||||
}
|
||||
}
|
||||
|
||||
// TestClampAboveCapReturnsCap asserts a coupon above the cap is clamped down
|
||||
// to the cap.
|
||||
func TestClampAboveCapReturnsCap(t *testing.T) {
|
||||
cases := []uint32{
|
||||
uint32(btypes.CouponCapBps) + 1,
|
||||
uint32(btypes.CouponCapBps) + 100,
|
||||
uint32(btypes.CouponCapBps) + 1000,
|
||||
900,
|
||||
1000,
|
||||
5000,
|
||||
}
|
||||
for _, c := range cases {
|
||||
got := btypes.Clamp(c)
|
||||
if got != btypes.CouponCapBps {
|
||||
t.Errorf("Clamp(%d) = %d, expected cap %d (above-cap must clamp to cap)", c, got, btypes.CouponCapBps)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestClampInRangeUnchanged asserts a coupon within [floor, cap] is unchanged.
|
||||
func TestClampInRangeUnchanged(t *testing.T) {
|
||||
cases := []uint32{
|
||||
0,
|
||||
1,
|
||||
100,
|
||||
400,
|
||||
500,
|
||||
799,
|
||||
uint32(btypes.CouponCapBps),
|
||||
}
|
||||
for _, c := range cases {
|
||||
got := btypes.Clamp(c)
|
||||
if got != c {
|
||||
t.Errorf("Clamp(%d) = %d, expected %d (in-range must be unchanged)", c, got, c)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestClampMatchesFeeCovenantShape asserts the bond Clamp has the same shape
|
||||
// as x/feecovenant's Clamp: min(cap, max(floor, coupon)). The test verifies
|
||||
// the boundary semantics rather than importing feecovenant (no cross-module
|
||||
// struct imports per G-003, though cross-module const access is allowed).
|
||||
func TestClampMatchesFeeCovenantShape(t *testing.T) {
|
||||
// The shape is min(cap, max(floor, coupon)). For floor=0 and cap=800:
|
||||
// min(800, max(0, coupon))
|
||||
// In-range passes through; above-cap clamps to cap; below-floor clamps to
|
||||
// floor (here, floor=0, so type-prevented for uint32).
|
||||
if btypes.Clamp(0) != 0 {
|
||||
t.Error("Clamp(0) should be 0 (floor boundary)")
|
||||
}
|
||||
if btypes.Clamp(800) != 800 {
|
||||
t.Error("Clamp(800) should be 800 (cap boundary)")
|
||||
}
|
||||
if btypes.Clamp(801) != 800 {
|
||||
t.Error("Clamp(801) should be 800 (above-cap clamps to cap)")
|
||||
}
|
||||
}
|
||||
|
||||
// TestClampInvariantBreaksIfCapChanges is the regression-firewall meta-assert:
|
||||
// if CouponCapBps were changed, the above-cap test would break. This test
|
||||
// documents the invariant: Clamp(above-cap) == cap, for the current cap.
|
||||
func TestClampInvariantBreaksIfCapChanges(t *testing.T) {
|
||||
above := uint32(btypes.CouponCapBps) + 50
|
||||
if btypes.Clamp(above) != btypes.CouponCapBps {
|
||||
t.Errorf("Clamp(%d) = %d, expected CouponCapBps %d (invariant: above-cap clamps to cap)", above, btypes.Clamp(above), btypes.CouponCapBps)
|
||||
}
|
||||
}
|
||||
|
||||
// --- BondStatus enum coverage (5) ----------------------------------------------
|
||||
|
||||
// TestBondStatusCountLockedConst asserts BondStatusCount == 5 and
|
||||
// AllBondStatuses() returns exactly 5 (REQ-021). A regression firewall.
|
||||
func TestBondStatusCountLockedConst(t *testing.T) {
|
||||
if btypes.BondStatusCount != 5 {
|
||||
t.Errorf("BondStatusCount = %d, expected 5 (REQ-021 LOCKED)", btypes.BondStatusCount)
|
||||
}
|
||||
all := btypes.AllBondStatuses()
|
||||
if len(all) != 5 {
|
||||
t.Errorf("AllBondStatuses() len = %d, expected 5", len(all))
|
||||
}
|
||||
}
|
||||
|
||||
// TestAllBondStatusesNames asserts the 5 REQ-021 names in order with no
|
||||
// extras, no dups, no renames.
|
||||
func TestAllBondStatusesNames(t *testing.T) {
|
||||
want := []string{"Issued", "Active", "Matured", "Defaulted", "Repaid"}
|
||||
all := btypes.AllBondStatuses()
|
||||
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("AllBondStatuses()[%d] = %q, want %q", i, s, want[i])
|
||||
}
|
||||
if seen[string(s)] {
|
||||
t.Errorf("duplicate BondStatus %q", s)
|
||||
}
|
||||
seen[string(s)] = true
|
||||
}
|
||||
}
|
||||
|
||||
// TestBondStatusValues asserts each named const matches its AllBondStatuses
|
||||
// entry.
|
||||
func TestBondStatusValues(t *testing.T) {
|
||||
if btypes.BondIssued != "Issued" {
|
||||
t.Errorf("BondIssued = %q", btypes.BondIssued)
|
||||
}
|
||||
if btypes.BondActive != "Active" {
|
||||
t.Errorf("BondActive = %q", btypes.BondActive)
|
||||
}
|
||||
if btypes.BondMatured != "Matured" {
|
||||
t.Errorf("BondMatured = %q", btypes.BondMatured)
|
||||
}
|
||||
if btypes.BondDefaulted != "Defaulted" {
|
||||
t.Errorf("BondDefaulted = %q", btypes.BondDefaulted)
|
||||
}
|
||||
if btypes.BondRepaid != "Repaid" {
|
||||
t.Errorf("BondRepaid = %q", btypes.BondRepaid)
|
||||
}
|
||||
}
|
||||
|
||||
// --- Issue stub callable -------------------------------------------------------
|
||||
|
||||
// TestIssueStubCallable asserts the Issue stub is callable and returns a
|
||||
// Bond with the coupon clamped and status BondIssued.
|
||||
func TestIssueStubCallable(t *testing.T) {
|
||||
b := btypes.Issue("bond-1", "stand-abc", 1_000_000, 500, 365, 1000, 1365)
|
||||
if b.BondID != "bond-1" {
|
||||
t.Errorf("BondID = %q", b.BondID)
|
||||
}
|
||||
if b.IssuerStandID != "stand-abc" {
|
||||
t.Errorf("IssuerStandID = %q", b.IssuerStandID)
|
||||
}
|
||||
if b.PrincipalGrain != 1_000_000 {
|
||||
t.Errorf("PrincipalGrain = %d", b.PrincipalGrain)
|
||||
}
|
||||
if b.CouponBps != 500 {
|
||||
t.Errorf("CouponBps = %d, expected 500 (in-range, unchanged)", b.CouponBps)
|
||||
}
|
||||
if b.TermDays != 365 {
|
||||
t.Errorf("TermDays = %d", b.TermDays)
|
||||
}
|
||||
if b.IssuedAt != 1000 || b.Maturity != 1365 {
|
||||
t.Errorf("IssuedAt=%d Maturity=%d", b.IssuedAt, b.Maturity)
|
||||
}
|
||||
if b.Status != btypes.BondIssued {
|
||||
t.Errorf("Status = %q, expected Issued", b.Status)
|
||||
}
|
||||
}
|
||||
|
||||
// TestIssueStubClampsAboveCap asserts the Issue stub clamps an above-cap
|
||||
// coupon down to the cap.
|
||||
func TestIssueStubClampsAboveCap(t *testing.T) {
|
||||
b := btypes.Issue("bond-2", "stand-abc", 1_000_000, 1200, 365, 1000, 1365)
|
||||
if b.CouponBps != btypes.CouponCapBps {
|
||||
t.Errorf("CouponBps = %d, expected cap %d (Issue must clamp above-cap coupon)", b.CouponBps, btypes.CouponCapBps)
|
||||
}
|
||||
}
|
||||
|
||||
// --- Bond struct fields --------------------------------------------------------
|
||||
|
||||
// TestBondStructFields asserts the Bond struct carries all required fields
|
||||
// including the by-ID-string ref to x/stand (issuer-stand-id per G-003).
|
||||
func TestBondStructFields(t *testing.T) {
|
||||
b := btypes.Bond{
|
||||
BondID: "bond-3",
|
||||
IssuerStandID: "stand-xyz",
|
||||
PrincipalGrain: 500_000,
|
||||
CouponBps: 300,
|
||||
TermDays: 180,
|
||||
IssuedAt: 2000,
|
||||
Maturity: 2180,
|
||||
Status: btypes.BondActive,
|
||||
}
|
||||
if b.BondID != "bond-3" || b.IssuerStandID != "stand-xyz" || b.PrincipalGrain != 500_000 ||
|
||||
b.CouponBps != 300 || b.TermDays != 180 || b.IssuedAt != 2000 || b.Maturity != 2180 ||
|
||||
b.Status != btypes.BondActive {
|
||||
t.Error("Bond fields not set correctly")
|
||||
}
|
||||
}
|
||||
|
||||
// TestBondIssuerStandIDIsString asserts issuer-stand-id is string-typed
|
||||
// (G-003 by-ID-string ref to x/stand; no struct import).
|
||||
func TestBondIssuerStandIDIsString(t *testing.T) {
|
||||
b := btypes.Bond{IssuerStandID: "stand-abc"}
|
||||
if b.IssuerStandID != "stand-abc" {
|
||||
t.Errorf("IssuerStandID = %q", b.IssuerStandID)
|
||||
}
|
||||
}
|
||||
|
||||
// --- Genesis -------------------------------------------------------------------
|
||||
|
||||
// TestDefaultGenesisStateEmpty asserts DefaultGenesisState returns non-nil
|
||||
// empty slice for Bonds.
|
||||
func TestDefaultGenesisStateEmpty(t *testing.T) {
|
||||
gs := btypes.DefaultGenesisState()
|
||||
if gs == nil {
|
||||
t.Fatal("DefaultGenesisState returned nil")
|
||||
}
|
||||
if gs.Bonds == nil || len(gs.Bonds) != 0 {
|
||||
t.Errorf("Default Bonds should be non-nil empty slice; got len=%d nil=%v", len(gs.Bonds), gs.Bonds == nil)
|
||||
}
|
||||
}
|
||||
|
||||
// TestValidateGenesisRejectsDupBondIDs asserts A-212: duplicate bond-ids are
|
||||
// rejected.
|
||||
func TestValidateGenesisRejectsDupBondIDs(t *testing.T) {
|
||||
gs := btypes.GenesisState{
|
||||
Bonds: []btypes.Bond{
|
||||
{BondID: "b1", IssuerStandID: "s1", CouponBps: 100, Status: btypes.BondIssued},
|
||||
{BondID: "b1", IssuerStandID: "s2", CouponBps: 200, Status: btypes.BondActive}, // dup
|
||||
},
|
||||
}
|
||||
bz, _ := json.Marshal(gs)
|
||||
if err := btypes.ValidateGenesis(bz); err == nil {
|
||||
t.Error("ValidateGenesis should reject duplicate bond-ids")
|
||||
}
|
||||
}
|
||||
|
||||
// TestValidateGenesisRejectsEmptyBondID asserts empty bond-id is rejected.
|
||||
func TestValidateGenesisRejectsEmptyBondID(t *testing.T) {
|
||||
gs := btypes.GenesisState{
|
||||
Bonds: []btypes.Bond{{BondID: "", IssuerStandID: "s1", CouponBps: 100, Status: btypes.BondIssued}},
|
||||
}
|
||||
bz, _ := json.Marshal(gs)
|
||||
if err := btypes.ValidateGenesis(bz); err == nil {
|
||||
t.Error("ValidateGenesis should reject empty bond-id")
|
||||
}
|
||||
}
|
||||
|
||||
// TestValidateGenesisRejectsUnknownBondStatus asserts an unknown BondStatus
|
||||
// is rejected.
|
||||
func TestValidateGenesisRejectsUnknownBondStatus(t *testing.T) {
|
||||
gs := btypes.GenesisState{
|
||||
Bonds: []btypes.Bond{{BondID: "b1", IssuerStandID: "s1", CouponBps: 100, Status: btypes.BondStatus("Bogus")}},
|
||||
}
|
||||
bz, _ := json.Marshal(gs)
|
||||
if err := btypes.ValidateGenesis(bz); err == nil {
|
||||
t.Error("ValidateGenesis should reject unknown bond status")
|
||||
}
|
||||
}
|
||||
|
||||
// TestValidateGenesisRejectsCouponAboveCap asserts the genesis-side clamp: a
|
||||
// genesis bond with coupon-bps above the cap is rejected (D-028).
|
||||
func TestValidateGenesisRejectsCouponAboveCap(t *testing.T) {
|
||||
gs := btypes.GenesisState{
|
||||
Bonds: []btypes.Bond{{BondID: "b1", IssuerStandID: "s1", CouponBps: uint32(btypes.CouponCapBps) + 1, Status: btypes.BondIssued}},
|
||||
}
|
||||
bz, _ := json.Marshal(gs)
|
||||
if err := btypes.ValidateGenesis(bz); err == nil {
|
||||
t.Error("ValidateGenesis should reject coupon-bps above cap (D-028 clamp at genesis load)")
|
||||
}
|
||||
}
|
||||
|
||||
// TestValidateGenesisRejectsCouponBelowFloor asserts the genesis-side clamp:
|
||||
// a genesis bond with coupon-bps below the floor is rejected (D-028).
|
||||
func TestValidateGenesisRejectsCouponBelowFloor(t *testing.T) {
|
||||
// Floor is 0; a uint32 cannot be below 0, so this test asserts the
|
||||
// boundary: coupon-bps == 0 (the floor) is accepted. The below-floor case
|
||||
// is type-prevented. We assert the floor boundary passes.
|
||||
gs := btypes.GenesisState{
|
||||
Bonds: []btypes.Bond{{BondID: "b1", IssuerStandID: "s1", CouponBps: 0, Status: btypes.BondIssued}},
|
||||
}
|
||||
bz, _ := json.Marshal(gs)
|
||||
if err := btypes.ValidateGenesis(bz); err != nil {
|
||||
t.Errorf("ValidateGenesis should accept coupon-bps == floor (0); got: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// 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{
|
||||
Bonds: []btypes.Bond{
|
||||
{BondID: "b1", IssuerStandID: "s1", CouponBps: 100, Status: btypes.BondIssued},
|
||||
{BondID: "b2", IssuerStandID: "s1", CouponBps: 800, Status: btypes.BondActive},
|
||||
},
|
||||
}
|
||||
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 != "bond" {
|
||||
t.Errorf("ModuleName = %q", btypes.ModuleName)
|
||||
}
|
||||
if btypes.StoreKey != "bond" {
|
||||
t.Errorf("StoreKey = %q", btypes.StoreKey)
|
||||
}
|
||||
if btypes.RouterKey != "bond" {
|
||||
t.Errorf("RouterKey = %q", btypes.RouterKey)
|
||||
}
|
||||
if btypes.QuerierRoute != "bond" {
|
||||
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 bond module is the HIGHEST lexicon-risk package (A-210): the banned
|
||||
// terms that are natural coupon-synonyms ("intere"+"st", "yie"+"ld") must
|
||||
// NEVER appear. The coupon vocabulary is used EXCLUSIVELY. The lexicon
|
||||
// helpers are used here — no banned literals are inlined in this test file.
|
||||
|
||||
// TestLexiconNoBannedTermsInBondPackage scans every non-test .go file in the
|
||||
// bond/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 TestLexiconNoBannedTermsInBondPackage(t *testing.T) {
|
||||
pkgDir := packageDir(t, "github.com/oy/openyield/x/bond/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 bond/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 — A-210 coupon-only vocabulary)", filepath.Base(f), found)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestLexiconNoBannedTermsInBondTestFile 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 TestLexiconNoBannedTermsInBondTestFile(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("bond test file contains banned term %q — use lexicon helpers, not literals (A-210)", found)
|
||||
}
|
||||
}
|
||||
|
||||
// --- 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
|
||||
// walking up from this test file (v0.2 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/bond/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)
|
||||
}
|
||||
@@ -0,0 +1,48 @@
|
||||
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
|
||||
}
|
||||
@@ -0,0 +1,104 @@
|
||||
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
|
||||
}
|
||||
@@ -0,0 +1,278 @@
|
||||
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)
|
||||
}
|
||||
@@ -0,0 +1,123 @@
|
||||
package types
|
||||
|
||||
import "fmt"
|
||||
|
||||
// genesis.go holds the data-engineer's genesis schema helpers for the
|
||||
// council module (G-008 split). ValidateGenesis in types.go composes these
|
||||
// helpers; the security-engineer's test assertions live in types_test.go.
|
||||
//
|
||||
// The Council genesis schema has two top-level sets: Councils (the three
|
||||
// governance councils — Mesh/Guild/Stand) and Voices (the Voice-tally
|
||||
// set). The invariants enforced at genesis load are (1) council-id
|
||||
// uniqueness, (2) voice-id uniqueness, (3) referential integrity (each
|
||||
// Voice's council-id references an existing Council), and (4) the
|
||||
// Mission-Lock check (the global MissionLockAmendable const bool is the
|
||||
// firewall — this helper is the genesis-side echo).
|
||||
|
||||
// ValidateCouncils asserts council-ids are present and unique, and that
|
||||
// each Council's kind is a known CouncilKind. A Stand Council must populate
|
||||
// stand-id-ref (by-ID-string ref to x/stand); a Guild Council must populate
|
||||
// guild-id-ref (by-ID-string ref to x/guild). A Mesh Council leaves both
|
||||
// refs empty. ValidateCouncils is the data-engineer's schema validator,
|
||||
// composed by ValidateGenesis in types.go.
|
||||
func ValidateCouncils(councils []Council) error {
|
||||
seen := make(map[string]bool, len(councils))
|
||||
for i, c := range councils {
|
||||
if c.CouncilID == "" {
|
||||
return fmt.Errorf("council [%d]: empty council-id", i)
|
||||
}
|
||||
if seen[c.CouncilID] {
|
||||
return fmt.Errorf("council: duplicate council-id %q", c.CouncilID)
|
||||
}
|
||||
seen[c.CouncilID] = true
|
||||
if !knownCouncilKind(c.Kind) {
|
||||
return fmt.Errorf("council %q: unknown council kind %q", c.CouncilID, c.Kind)
|
||||
}
|
||||
// A Stand Council must reference a Stand by-ID-string (P1-02-01 ref).
|
||||
if c.Kind == CouncilStand && c.StandIDRef == "" {
|
||||
return fmt.Errorf("council %q: Stand Council missing stand-id-ref", c.CouncilID)
|
||||
}
|
||||
// A Guild Council must reference a Guild by-ID-string (P1-03-01 ref).
|
||||
if c.Kind == CouncilGuild && c.GuildIDRef == "" {
|
||||
return fmt.Errorf("council %q: Guild Council missing guild-id-ref", c.CouncilID)
|
||||
}
|
||||
}
|
||||
if err := MissionLockCheck(councils); err != nil {
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// ValidateVoices asserts voice-ids are present and unique, and that each
|
||||
// Voice's council-id references an existing Council in the genesis set
|
||||
// (referential integrity — the P3-01-03 deliverable: each Voice tally's
|
||||
// council-id must resolve to a genesis Council). signal-kind must be a
|
||||
// known SignalKind (the four Freeholder signals, cross-ref REQ-005). The
|
||||
// referential-integrity check is the data-engineer's genesis invariant: a
|
||||
// Voice tally pointing at a non-existent Council is rejected at genesis
|
||||
// load (no orphan tallies).
|
||||
func ValidateVoices(voices []Voice, councils []Council) error {
|
||||
councilIDs := make(map[string]bool, len(councils))
|
||||
for _, c := range councils {
|
||||
councilIDs[c.CouncilID] = true
|
||||
}
|
||||
seen := make(map[string]bool, len(voices))
|
||||
for i, v := range voices {
|
||||
if v.VoiceID == "" {
|
||||
return fmt.Errorf("voice [%d]: empty voice-id", i)
|
||||
}
|
||||
if seen[v.VoiceID] {
|
||||
return fmt.Errorf("voice: duplicate voice-id %q", v.VoiceID)
|
||||
}
|
||||
seen[v.VoiceID] = true
|
||||
if !councilIDs[v.CouncilID] {
|
||||
return fmt.Errorf("voice %q: council-id %q does not reference an existing council", v.VoiceID, v.CouncilID)
|
||||
}
|
||||
if !knownSignalKind(v.SignalKind) {
|
||||
return fmt.Errorf("voice %q: unknown signal-kind %q", v.VoiceID, v.SignalKind)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// knownCouncilKind reports whether k is one of the three CouncilKind values.
|
||||
func knownCouncilKind(k CouncilKind) bool {
|
||||
for _, kk := range AllCouncilKinds() {
|
||||
if k == kk {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// knownSignalKind reports whether s is one of the four SignalKind values.
|
||||
func knownSignalKind(s SignalKind) bool {
|
||||
for _, kk := range AllSignalKinds() {
|
||||
if s == kk {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// MissionLockCheck asserts the Mission-Lock invariant on a slice of
|
||||
// Councils (vision §19, REQ-011). Because MissionLockAmendable is a compile-
|
||||
// time const bool == false, this check always passes — it exists as the
|
||||
// data-engineer's genesis-side assertion that the Mission-Lock firewall is
|
||||
// intact. If the const ever flipped to true (which the test suite rejects),
|
||||
// the genesis load would surface it here. The helper is the genesis hook
|
||||
// for v0.3 keeper logic to extend with live per-council Mission-Lock
|
||||
// enforcement.
|
||||
func MissionLockCheck(councils []Council) error {
|
||||
// The global MissionLockAmendable const is the firewall: if it were ever
|
||||
// flipped to true (which the test suite rejects), the genesis load would
|
||||
// surface it here. The per-council loop is the hook for v0.3 live logic.
|
||||
if MissionLockAmendable {
|
||||
return fmt.Errorf("council: Mission Lock amendable (MissionLockAmendable == true) — firewall breach")
|
||||
}
|
||||
for range councils {
|
||||
// No per-council runtime data to verify in the skeleton — the const
|
||||
// is the source of truth. The loop preserves the hook point.
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,187 @@
|
||||
package types
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
)
|
||||
|
||||
const (
|
||||
ModuleName = "council"
|
||||
StoreKey = ModuleName
|
||||
RouterKey = ModuleName
|
||||
QuerierRoute = ModuleName
|
||||
|
||||
// CouncilKindCount is the locked count of CouncilKind enum values
|
||||
// (vision §13 / REQ-011). A regression firewall: adding/removing/renaming
|
||||
// a Council kind breaks this const's test.
|
||||
CouncilKindCount = 3
|
||||
|
||||
// MissionLockAmendable is the Mission-Lock invariant (vision §19, REQ-011):
|
||||
// the Six Principles + Fee Covenant + no-amend covenant can NEVER be
|
||||
// amended by any council. This is a locked const bool — the highest-
|
||||
// severity regression firewall in the council module. The const can
|
||||
// NEVER be set true; the test asserts it is false and that no code path
|
||||
// can flip it (the compile-time const is the firewall, not runtime data).
|
||||
MissionLockAmendable = false
|
||||
|
||||
// SignalKindCount is the locked count of SignalKind enum values — the
|
||||
// four Freeholder signals (vision §9.1 / REQ-005) plus Capital (REQ-011
|
||||
// multi-source Voice). Cross-ref v0.1 x/standing FreeholderSignals.
|
||||
SignalKindCount = 4
|
||||
)
|
||||
|
||||
// CouncilKind enumerates the three governance councils (vision §13, REQ-011):
|
||||
// Mesh Council (whole-mesh), Guild Council (guild-level), Stand Council
|
||||
// (Stand-level). Each uses multi-source Voice. Mission Lock (the Six
|
||||
// Principles + fee covenant + no-amend covenant) cannot be amended by any
|
||||
// council — enforced by the compile-time MissionLockAmendable const bool.
|
||||
type CouncilKind string
|
||||
|
||||
const (
|
||||
CouncilMesh CouncilKind = "MeshCouncil" // whole-mesh council
|
||||
CouncilGuild CouncilKind = "GuildCouncil" // guild-level council
|
||||
CouncilStand CouncilKind = "StandCouncil" // Stand-level council
|
||||
)
|
||||
|
||||
// AllCouncilKinds returns all three CouncilKind values in REQ-011 order.
|
||||
// Locked-const test asserts exactly 3 entries with these names (REQ-011).
|
||||
func AllCouncilKinds() []CouncilKind {
|
||||
return []CouncilKind{
|
||||
CouncilMesh,
|
||||
CouncilGuild,
|
||||
CouncilStand,
|
||||
}
|
||||
}
|
||||
|
||||
// Council is one of three governance councils (REQ-011). kind picks the
|
||||
// tier (Mesh/Guild/Stand). stand-id-ref references x/stand by ID string
|
||||
// (optional — only Stand Councils populate it; P1-02-01 by-ID-string ref).
|
||||
// guild-id-ref references x/guild by ID string (optional — only Guild
|
||||
// Councils populate it; P1-03-01 by-ID-string ref). Both refs are by-ID-
|
||||
// string per G-003 (no struct imports of x/stand or x/guild). members is
|
||||
// the voice-holder set; voice-threshold is the tally pass threshold.
|
||||
type Council struct {
|
||||
CouncilID string `json:"council_id" yaml:"council_id"`
|
||||
Kind CouncilKind `json:"kind" yaml:"kind"`
|
||||
StandIDRef string `json:"stand_id_ref,omitempty" yaml:"stand_id_ref,omitempty"`
|
||||
GuildIDRef string `json:"guild_id_ref,omitempty" yaml:"guild_id_ref,omitempty"`
|
||||
Members []CouncilMember `json:"members" yaml:"members"`
|
||||
VoiceThreshold uint32 `json:"voice_threshold" yaml:"voice_threshold"`
|
||||
}
|
||||
|
||||
// CouncilMember is a voice-holder in a Council (REQ-011). reach-id
|
||||
// references x/identity Reach by string (G-003 — the lexicon-clean holder
|
||||
// identifier; the banned financial holder term is NOT used here). voice-
|
||||
// weight is the member's Voice weight in the tally; joined-at is the join
|
||||
// timestamp.
|
||||
type CouncilMember struct {
|
||||
ReachID string `json:"reach_id" yaml:"reach_id"`
|
||||
VoiceWeight uint32 `json:"voice_weight" yaml:"voice_weight"`
|
||||
JoinedAt int64 `json:"joined_at" yaml:"joined_at"`
|
||||
}
|
||||
|
||||
// Voice is a single Voice signal cast on a Council proposal (REQ-011).
|
||||
// council-id references the Council by ID string (G-003). proposer-reach
|
||||
// references x/identity Reach by string (lexicon-clean holder identifier;
|
||||
// the banned financial holder term is NOT used).
|
||||
// signal-kind picks the multi-source Voice input (Stash/Standing/Vouch/
|
||||
// Capital — the four Freeholder signals, cross-ref v0.1 REQ-005
|
||||
// FreeholderSignals). target-ref is the proposal/option the Voice targets
|
||||
// (opaque string ref). tally is the running tally result; timestamp is the
|
||||
// cast time.
|
||||
type Voice struct {
|
||||
VoiceID string `json:"voice_id" yaml:"voice_id"`
|
||||
CouncilID string `json:"council_id" yaml:"council_id"`
|
||||
ProposerReach string `json:"proposer_reach" yaml:"proposer_reach"`
|
||||
SignalKind SignalKind `json:"signal_kind" yaml:"signal_kind"`
|
||||
TargetRef string `json:"target_ref" yaml:"target_ref"`
|
||||
Tally TallyResult `json:"tally" yaml:"tally"`
|
||||
Timestamp int64 `json:"timestamp" yaml:"timestamp"`
|
||||
}
|
||||
|
||||
// SignalKind enumerates the multi-source Voice inputs (REQ-011). The four
|
||||
// Freeholder signals (vision §9.1 / REQ-005, cross-ref x/standing
|
||||
// FreeholderSignals): Stash, Standing, Vouch, Capital. No "Freeholder"
|
||||
// SignalKind — the four signals are the inputs a Freeholder-eligible Reach
|
||||
// casts; the eligibility is upstream (x/standing). Capital is the committed-
|
||||
// capital signal (vision §9.1 committed_capital).
|
||||
type SignalKind string
|
||||
|
||||
const (
|
||||
SignalStash SignalKind = "Stash" // Stash-maturity signal (vision §9.1)
|
||||
SignalStanding SignalKind = "Standing" // multi-domain Standing signal (§9.1)
|
||||
SignalVouch SignalKind = "Vouch" // community endorsement / Vouch (§9.1)
|
||||
SignalCapital SignalKind = "Capital" // committed-capital signal (§9.1)
|
||||
)
|
||||
|
||||
// AllSignalKinds returns all four SignalKind values in REQ-005 / vision §9.1
|
||||
// order. Locked-const test asserts exactly 4 entries (cross-ref v0.1
|
||||
// x/standing FreeholderSignals: StashMaturity, MultiDomainStanding,
|
||||
// CommittedCapital, CommunityEndorsement — the four signals map to
|
||||
// Stash/Standing/Capital/Vouch here).
|
||||
func AllSignalKinds() []SignalKind {
|
||||
return []SignalKind{
|
||||
SignalStash,
|
||||
SignalStanding,
|
||||
SignalVouch,
|
||||
SignalCapital,
|
||||
}
|
||||
}
|
||||
|
||||
// TallyResult mirrors Cosmos SDK x/gov TallyResult shape (A-204) for
|
||||
// future wiring of Council governance to x/gov. Fields: yes, no, abstain
|
||||
// (no "no-with-veto" — anti-greed, vision §19), nowithveto (kept as a
|
||||
// zero-locked field for x/gov shape parity — always 0 in OY since the
|
||||
// VoteOption enum has no veto option), total (total Voice cast). The
|
||||
// quorum-met flag is the tally pass indicator. The field names (yes, no,
|
||||
// abstain) match x/gov exactly so a future x/gov wiring is mechanical.
|
||||
type TallyResult struct {
|
||||
Yes uint64 `json:"yes" yaml:"yes"`
|
||||
No uint64 `json:"no" yaml:"no"`
|
||||
Abstain uint64 `json:"abstain" yaml:"abstain"`
|
||||
NoWithVeto uint64 `json:"nowithveto" yaml:"nowithveto"` // always 0 — no veto option (anti-greed)
|
||||
Total uint64 `json:"total" yaml:"total"`
|
||||
QuorumMet bool `json:"quorum_met" yaml:"quorum_met"`
|
||||
}
|
||||
|
||||
// Params for the council module (skeleton — no tunables in v0.2).
|
||||
type Params struct{}
|
||||
|
||||
func DefaultParams() Params { return Params{} }
|
||||
|
||||
// GenesisState defines the council module genesis state (REQ-011).
|
||||
// Councils is the top-level set of three Council kinds; Voices is the
|
||||
// Voice-tally set. ValidateGenesis enforces council-id uniqueness,
|
||||
// voice-id uniqueness, and the Mission-Lock check (the const firewall echo).
|
||||
// The data-engineer's genesis.go holds the schema helpers (G-008).
|
||||
type GenesisState struct {
|
||||
Councils []Council `json:"councils" yaml:"councils"`
|
||||
Voices []Voice `json:"voices" yaml:"voices"`
|
||||
Params Params `json:"params" yaml:"params"`
|
||||
}
|
||||
|
||||
func DefaultGenesisState() *GenesisState {
|
||||
return &GenesisState{
|
||||
Councils: []Council{},
|
||||
Voices: []Voice{},
|
||||
Params: DefaultParams(),
|
||||
}
|
||||
}
|
||||
|
||||
// ValidateGenesis performs ID-uniqueness checks (A-212 upgrade from v0.1
|
||||
// no-op): rejects duplicate council-ids and duplicate voice-ids, and runs
|
||||
// the Mission-Lock check. 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("council: invalid genesis: %w", err)
|
||||
}
|
||||
if err := ValidateCouncils(gs.Councils); err != nil {
|
||||
return fmt.Errorf("council: %w", err)
|
||||
}
|
||||
if err := ValidateVoices(gs.Voices, gs.Councils); err != nil {
|
||||
return fmt.Errorf("council: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,558 @@
|
||||
package types_test
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"runtime"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/oy/openyield/lexicon"
|
||||
"github.com/oy/openyield/x/council/types"
|
||||
)
|
||||
|
||||
// TestCouncilKindCountLockedConst asserts CouncilKindCount is exactly 3
|
||||
// and AllCouncilKinds() returns exactly 3 (REQ-011). A regression firewall:
|
||||
// adding/removing/renaming a Council kind breaks this test.
|
||||
func TestCouncilKindCountLockedConst(t *testing.T) {
|
||||
if types.CouncilKindCount != 3 {
|
||||
t.Errorf("CouncilKindCount = %d, expected 3 (REQ-011 LOCKED)", types.CouncilKindCount)
|
||||
}
|
||||
all := types.AllCouncilKinds()
|
||||
if len(all) != 3 {
|
||||
t.Errorf("AllCouncilKinds() len = %d, expected 3", len(all))
|
||||
}
|
||||
}
|
||||
|
||||
// TestAllCouncilKindsNames asserts the 3 REQ-011 names in order with no
|
||||
// extras, no dups, no renames.
|
||||
func TestAllCouncilKindsNames(t *testing.T) {
|
||||
want := []string{"MeshCouncil", "GuildCouncil", "StandCouncil"}
|
||||
all := types.AllCouncilKinds()
|
||||
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("AllCouncilKinds()[%d] = %q, want %q", i, k, want[i])
|
||||
}
|
||||
if seen[string(k)] {
|
||||
t.Errorf("duplicate CouncilKind %q", k)
|
||||
}
|
||||
seen[string(k)] = true
|
||||
}
|
||||
}
|
||||
|
||||
// TestCouncilKindValues asserts each named const matches its AllCouncilKinds
|
||||
// entry.
|
||||
func TestCouncilKindValues(t *testing.T) {
|
||||
if types.CouncilMesh != "MeshCouncil" {
|
||||
t.Errorf("CouncilMesh = %q", types.CouncilMesh)
|
||||
}
|
||||
if types.CouncilGuild != "GuildCouncil" {
|
||||
t.Errorf("CouncilGuild = %q", types.CouncilGuild)
|
||||
}
|
||||
if types.CouncilStand != "StandCouncil" {
|
||||
t.Errorf("CouncilStand = %q", types.CouncilStand)
|
||||
}
|
||||
}
|
||||
|
||||
// TestMissionLockAmendableConstFalse asserts the global Mission-Lock const
|
||||
// is false (vision §19, REQ-011): the Mission Lock can NEVER be amended.
|
||||
// This is the highest-severity regression firewall for the council module.
|
||||
// The const can NEVER be set true; this test is the firewall that breaks if
|
||||
// anyone flips the const.
|
||||
func TestMissionLockAmendableConstFalse(t *testing.T) {
|
||||
if types.MissionLockAmendable != false {
|
||||
t.Fatalf("MissionLockAmendable = %v, expected false (Mission Lock non-amendable — vision §19)", types.MissionLockAmendable)
|
||||
}
|
||||
// Re-assert via a bool-typed comparison so the test fails to compile if
|
||||
// the const is ever changed to a non-bool type (defence in depth).
|
||||
var isFalse bool = types.MissionLockAmendable == false
|
||||
if !isFalse {
|
||||
t.Fatal("MissionLockAmendable must equal false")
|
||||
}
|
||||
}
|
||||
|
||||
// TestMissionLockAmendableCannotBeSetTrue asserts the const cannot be set
|
||||
// true — it is a compile-time const, not a runtime variable. The test
|
||||
// constructs an expression that would fail to compile if the const were a
|
||||
// mutable var (the const-ness is the firewall). This is the regression
|
||||
// firewall the spec mandates: "a test asserting it can never be set true".
|
||||
func TestMissionLockAmendableCannotBeSetTrue(t *testing.T) {
|
||||
// The const is declared as `const MissionLockAmendable = false`. Go
|
||||
// consts cannot be reassigned at runtime. The test below would be a
|
||||
// compile error if it tried to assign to the const:
|
||||
// types.MissionLockAmendable = true // cannot assign to const
|
||||
// So the firewall IS the compile-time const-ness. We assert the value
|
||||
// is false and the type is bool (so a future change to a string or int
|
||||
// would break the typed comparison above). The regression guard is that
|
||||
// any PR flipping the const to true breaks TestMissionLockAmendableConstFalse
|
||||
// AND any PR changing it to a var breaks the `const` declaration (Go
|
||||
// compiler rejects assignment to a var-typed const in other code paths).
|
||||
if types.MissionLockAmendable {
|
||||
t.Fatal("MissionLockAmendable must be false; the const is the firewall — flipping it to true is a Mission Lock breach")
|
||||
}
|
||||
}
|
||||
|
||||
// TestSignalKindCountLockedConst asserts SignalKindCount is exactly 4
|
||||
// (the four Freeholder signals, cross-ref v0.1 REQ-005 / vision §9.1).
|
||||
func TestSignalKindCountLockedConst(t *testing.T) {
|
||||
if types.SignalKindCount != 4 {
|
||||
t.Errorf("SignalKindCount = %d, expected 4 (REQ-005 four Freeholder signals)", types.SignalKindCount)
|
||||
}
|
||||
all := types.AllSignalKinds()
|
||||
if len(all) != 4 {
|
||||
t.Errorf("AllSignalKinds() len = %d, expected 4", len(all))
|
||||
}
|
||||
}
|
||||
|
||||
// TestSignalKindShapeIntentional (REQ-031, AUDIT §193 P1-2) is a regression
|
||||
// GUARD that documents and locks the 4-source SignalKind shape. It is NOT a
|
||||
// shape change — the existing TestSignalKindCountLockedConst already locks
|
||||
// the count. This test adds the INTENT documentation so a future agent who
|
||||
// changes SignalKindCount from 4 to 5 (e.g., to "restore" the spec's 5-source
|
||||
// VoiceSource list) must also update this test, surfacing the AUDIT rationale
|
||||
// for review.
|
||||
//
|
||||
// AUDIT §193 P1-2 rationale (why SignalKind is 4 sources, NOT the spec's 5):
|
||||
//
|
||||
// The v0.2 P3-01-01 deliverable specified VoiceSource with 5 sources
|
||||
// (Stash/Standing/Vouch/Freeholder/Guild). The implementation uses
|
||||
// SignalKind with 4 sources (Stash/Standing/Vouch/Capital). The 4-source
|
||||
// shape is a defensible design refinement:
|
||||
// - Freeholder is an ELIGIBILITY property (upstream in x/standing), not
|
||||
// a voice signal. A Freeholder-eligible Reach is a precondition for
|
||||
// voting, not a signal that feeds a vote's weight.
|
||||
// - Guild is a COUNCIL TIER (one of the three councils is the Guild
|
||||
// Council), not a voice signal. Including Guild as a signal kind
|
||||
// would conflate the council tier with the signal source.
|
||||
// - Capital is committed-capital (vision §9.1, one of the four
|
||||
// Freeholder signals per REQ-005), which the spec's VoiceSource list
|
||||
// omitted. Adding Capital corrects the spec to match vision §9.1's
|
||||
// four-signal definition (REQ-005: "Four Freeholder signals locked").
|
||||
//
|
||||
// The 4-source shape matches REQ-005 exactly. The spec deliverable text
|
||||
// was wrong, not the implementation. v0.4 (D-050) DOCUMENTS this and
|
||||
// locks the 4-source shape; changing it to 5 is a locked-const change
|
||||
// rejected by the D-001 refinement-only filter and deferred to a future
|
||||
// milestone that re-litigates REQ-005's signal definition.
|
||||
//
|
||||
// See .ciagent/oy/ARCHITECTURE.md §"Council Voice/Council Interface —
|
||||
// Lifecycle Type Divergence Decisions (v0.4, REQ-031)" for the full rationale.
|
||||
func TestSignalKindShapeIntentional(t *testing.T) {
|
||||
// LOCKED: 4 sources. Changing this to 5 requires updating this test's
|
||||
// intent block AND re-litigating REQ-005's four-signal definition.
|
||||
const expectedSignalCount = 4
|
||||
if types.SignalKindCount != expectedSignalCount {
|
||||
t.Fatalf("SignalKindCount = %d, want %d (REQ-031 intent guard: the 4-source shape is intentional per AUDIT §193 P1-2; see ARCHITECTURE.md v0.4 divergence section before changing this)", types.SignalKindCount, expectedSignalCount)
|
||||
}
|
||||
want := []types.SignalKind{types.SignalStash, types.SignalStanding, types.SignalVouch, types.SignalCapital}
|
||||
all := types.AllSignalKinds()
|
||||
if len(all) != len(want) {
|
||||
t.Fatalf("AllSignalKinds() len = %d, want %d", len(all), len(want))
|
||||
}
|
||||
for i, s := range all {
|
||||
if s != want[i] {
|
||||
t.Errorf("AllSignalKinds()[%d] = %q, want %q (REQ-031 intent guard: the 4-source shape {Stash, Standing, Vouch, Capital} is intentional per AUDIT §193 P1-2; Freeholder and Guild are NOT signal kinds)", i, s, want[i])
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestAllSignalKindsNames asserts the 4 signal names (Stash, Standing,
|
||||
// Vouch, Capital) cross-ref v0.1 x/standing FreeholderSignals (StashMaturity,
|
||||
// MultiDomainStanding, CommunityEndorsement, CommittedCapital).
|
||||
func TestAllSignalKindsNames(t *testing.T) {
|
||||
want := []string{"Stash", "Standing", "Vouch", "Capital"}
|
||||
all := types.AllSignalKinds()
|
||||
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("AllSignalKinds()[%d] = %q, want %q", i, s, want[i])
|
||||
}
|
||||
if seen[string(s)] {
|
||||
t.Errorf("duplicate SignalKind %q", s)
|
||||
}
|
||||
seen[string(s)] = true
|
||||
}
|
||||
}
|
||||
|
||||
// TestSignalKindValues asserts each named const matches its AllSignalKinds
|
||||
// entry.
|
||||
func TestSignalKindValues(t *testing.T) {
|
||||
if types.SignalStash != "Stash" {
|
||||
t.Errorf("SignalStash = %q", types.SignalStash)
|
||||
}
|
||||
if types.SignalStanding != "Standing" {
|
||||
t.Errorf("SignalStanding = %q", types.SignalStanding)
|
||||
}
|
||||
if types.SignalVouch != "Vouch" {
|
||||
t.Errorf("SignalVouch = %q", types.SignalVouch)
|
||||
}
|
||||
if types.SignalCapital != "Capital" {
|
||||
t.Errorf("SignalCapital = %q", types.SignalCapital)
|
||||
}
|
||||
}
|
||||
|
||||
// TestTallyResultStructShape asserts TallyResult mirrors x/gov shape (A-204):
|
||||
// fields yes, no, abstain, nowithveto, total, quorum_met. The no-with-veto
|
||||
// field is kept for x/gov parity but always 0 (OY has no veto option —
|
||||
// anti-greed, vision §19). The test asserts the field names via JSON tags
|
||||
// and that NoWithVeto is zero by default.
|
||||
func TestTallyResultStructShape(t *testing.T) {
|
||||
tr := types.TallyResult{
|
||||
Yes: 10,
|
||||
No: 3,
|
||||
Abstain: 1,
|
||||
NoWithVeto: 0, // always 0 — no veto option
|
||||
Total: 14,
|
||||
QuorumMet: true,
|
||||
}
|
||||
if tr.Yes != 10 || tr.No != 3 || tr.Abstain != 1 || tr.NoWithVeto != 0 ||
|
||||
tr.Total != 14 || tr.QuorumMet != true {
|
||||
t.Error("TallyResult fields not set correctly")
|
||||
}
|
||||
// x/gov field-name parity: marshal and check JSON tags.
|
||||
bz, err := json.Marshal(tr)
|
||||
if err != nil {
|
||||
t.Fatalf("marshal: %v", err)
|
||||
}
|
||||
js := string(bz)
|
||||
for _, tag := range []string{`"yes"`, `"no"`, `"abstain"`, `"nowithveto"`, `"total"`, `"quorum_met"`} {
|
||||
if !strings.Contains(js, tag) {
|
||||
t.Errorf("TallyResult JSON missing tag %s (x/gov shape parity A-204)", tag)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestTallyResultNoWithVetoAlwaysZero asserts the default TallyResult has
|
||||
// NoWithVeto == 0 (the anti-greed invariant — no veto option in OY).
|
||||
func TestTallyResultNoWithVetoAlwaysZero(t *testing.T) {
|
||||
var tr types.TallyResult
|
||||
if tr.NoWithVeto != 0 {
|
||||
t.Errorf("default TallyResult.NoWithVeto = %d, expected 0 (no veto option — anti-greed)", tr.NoWithVeto)
|
||||
}
|
||||
}
|
||||
|
||||
// TestCouncilStructFields asserts Council carries all required fields
|
||||
// including the by-ID-string refs (stand-id-ref, guild-id-ref per G-003).
|
||||
func TestCouncilStructFields(t *testing.T) {
|
||||
c := types.Council{
|
||||
CouncilID: "c1",
|
||||
Kind: types.CouncilStand,
|
||||
StandIDRef: "stand-xyz",
|
||||
GuildIDRef: "",
|
||||
Members: []types.CouncilMember{{ReachID: "reach:a", VoiceWeight: 5, JoinedAt: 100}},
|
||||
VoiceThreshold: 3,
|
||||
}
|
||||
if c.CouncilID != "c1" || c.Kind != types.CouncilStand || c.StandIDRef != "stand-xyz" ||
|
||||
c.GuildIDRef != "" || len(c.Members) != 1 || c.VoiceThreshold != 3 {
|
||||
t.Error("Council fields not set correctly")
|
||||
}
|
||||
}
|
||||
|
||||
// TestCouncilStructRefsAreStrings asserts stand-id-ref and guild-id-ref are
|
||||
// string-typed (G-003 by-ID-string invariant; the G-003 import invariant is
|
||||
// enforced project-wide by P1-01-02's go/parser scan, so this test only
|
||||
// asserts the field types at the struct level, not cross-module imports).
|
||||
func TestCouncilStructRefsAreStrings(t *testing.T) {
|
||||
c := types.Council{StandIDRef: "stand-abc", GuildIDRef: "guild-def"}
|
||||
if c.StandIDRef != "stand-abc" {
|
||||
t.Errorf("StandIDRef = %q", c.StandIDRef)
|
||||
}
|
||||
if c.GuildIDRef != "guild-def" {
|
||||
t.Errorf("GuildIDRef = %q", c.GuildIDRef)
|
||||
}
|
||||
}
|
||||
|
||||
// TestCouncilMemberStructFields asserts CouncilMember uses reach-id (NOT
|
||||
// the banned financial holder term — lexicon-clean).
|
||||
func TestCouncilMemberStructFields(t *testing.T) {
|
||||
m := types.CouncilMember{ReachID: "reach:a", VoiceWeight: 7, JoinedAt: 200}
|
||||
if m.ReachID != "reach:a" || m.VoiceWeight != 7 || m.JoinedAt != 200 {
|
||||
t.Error("CouncilMember fields not set correctly")
|
||||
}
|
||||
}
|
||||
|
||||
// TestVoiceStructFields asserts Voice carries all required fields.
|
||||
func TestVoiceStructFields(t *testing.T) {
|
||||
v := types.Voice{
|
||||
VoiceID: "v1",
|
||||
CouncilID: "c1",
|
||||
ProposerReach: "reach:prop",
|
||||
SignalKind: types.SignalStash,
|
||||
TargetRef: "proposal:p1",
|
||||
Tally: types.TallyResult{Yes: 1, Total: 1, QuorumMet: true},
|
||||
Timestamp: 999,
|
||||
}
|
||||
if v.VoiceID != "v1" || v.CouncilID != "c1" || v.ProposerReach != "reach:prop" ||
|
||||
v.SignalKind != types.SignalStash || v.TargetRef != "proposal:p1" ||
|
||||
v.Tally.Yes != 1 || v.Tally.Total != 1 || v.Tally.QuorumMet != true || v.Timestamp != 999 {
|
||||
t.Error("Voice fields not set correctly")
|
||||
}
|
||||
}
|
||||
|
||||
// TestDefaultGenesisStateEmpty asserts DefaultGenesisState returns non-nil
|
||||
// empty slices for Councils and Voices.
|
||||
func TestDefaultGenesisStateEmpty(t *testing.T) {
|
||||
gs := types.DefaultGenesisState()
|
||||
if gs == nil {
|
||||
t.Fatal("DefaultGenesisState returned nil")
|
||||
}
|
||||
if gs.Councils == nil || len(gs.Councils) != 0 {
|
||||
t.Errorf("Default Councils should be non-nil empty slice; got len=%d nil=%v", len(gs.Councils), gs.Councils == nil)
|
||||
}
|
||||
if gs.Voices == nil || len(gs.Voices) != 0 {
|
||||
t.Errorf("Default Voices should be non-nil empty slice; got len=%d nil=%v", len(gs.Voices), gs.Voices == nil)
|
||||
}
|
||||
}
|
||||
|
||||
// TestValidateGenesisRejectsDupCouncilIDs asserts A-212: duplicate
|
||||
// council-ids are rejected.
|
||||
func TestValidateGenesisRejectsDupCouncilIDs(t *testing.T) {
|
||||
gs := types.GenesisState{
|
||||
Councils: []types.Council{
|
||||
{CouncilID: "c1", Kind: types.CouncilMesh},
|
||||
{CouncilID: "c1", Kind: types.CouncilGuild, GuildIDRef: "g1"}, // dup
|
||||
},
|
||||
}
|
||||
bz, _ := json.Marshal(gs)
|
||||
if err := types.ValidateGenesis(bz); err == nil {
|
||||
t.Error("ValidateGenesis should reject duplicate council-ids")
|
||||
}
|
||||
}
|
||||
|
||||
// TestValidateGenesisRejectsDupVoiceIDs asserts A-212: duplicate voice-ids
|
||||
// are rejected.
|
||||
func TestValidateGenesisRejectsDupVoiceIDs(t *testing.T) {
|
||||
gs := types.GenesisState{
|
||||
Councils: []types.Council{{CouncilID: "c1", Kind: types.CouncilMesh}},
|
||||
Voices: []types.Voice{
|
||||
{VoiceID: "v1", CouncilID: "c1", SignalKind: types.SignalStash},
|
||||
{VoiceID: "v1", CouncilID: "c1", SignalKind: types.SignalVouch}, // dup
|
||||
},
|
||||
}
|
||||
bz, _ := json.Marshal(gs)
|
||||
if err := types.ValidateGenesis(bz); err == nil {
|
||||
t.Error("ValidateGenesis should reject duplicate voice-ids")
|
||||
}
|
||||
}
|
||||
|
||||
// TestValidateGenesisRejectsEmptyCouncilID asserts empty council-id is
|
||||
// rejected.
|
||||
func TestValidateGenesisRejectsEmptyCouncilID(t *testing.T) {
|
||||
gs := types.GenesisState{
|
||||
Councils: []types.Council{{CouncilID: "", Kind: types.CouncilMesh}},
|
||||
}
|
||||
bz, _ := json.Marshal(gs)
|
||||
if err := types.ValidateGenesis(bz); err == nil {
|
||||
t.Error("ValidateGenesis should reject empty council-id")
|
||||
}
|
||||
}
|
||||
|
||||
// TestValidateGenesisRejectsEmptyVoiceID asserts empty voice-id is rejected.
|
||||
func TestValidateGenesisRejectsEmptyVoiceID(t *testing.T) {
|
||||
gs := types.GenesisState{
|
||||
Councils: []types.Council{{CouncilID: "c1", Kind: types.CouncilMesh}},
|
||||
Voices: []types.Voice{{VoiceID: "", CouncilID: "c1", SignalKind: types.SignalStash}},
|
||||
}
|
||||
bz, _ := json.Marshal(gs)
|
||||
if err := types.ValidateGenesis(bz); err == nil {
|
||||
t.Error("ValidateGenesis should reject empty voice-id")
|
||||
}
|
||||
}
|
||||
|
||||
// TestValidateGenesisRejectsUnknownCouncilKind asserts an unknown
|
||||
// CouncilKind is rejected (data-engineer schema validation).
|
||||
func TestValidateGenesisRejectsUnknownCouncilKind(t *testing.T) {
|
||||
gs := types.GenesisState{
|
||||
Councils: []types.Council{{CouncilID: "c1", Kind: types.CouncilKind("Bogus")}},
|
||||
}
|
||||
bz, _ := json.Marshal(gs)
|
||||
if err := types.ValidateGenesis(bz); err == nil {
|
||||
t.Error("ValidateGenesis should reject unknown council kind")
|
||||
}
|
||||
}
|
||||
|
||||
// TestValidateGenesisRejectsUnknownSignalKind asserts an unknown SignalKind
|
||||
// is rejected.
|
||||
func TestValidateGenesisRejectsUnknownSignalKind(t *testing.T) {
|
||||
gs := types.GenesisState{
|
||||
Councils: []types.Council{{CouncilID: "c1", Kind: types.CouncilMesh}},
|
||||
Voices: []types.Voice{{VoiceID: "v1", CouncilID: "c1", SignalKind: types.SignalKind("Bogus")}},
|
||||
}
|
||||
bz, _ := json.Marshal(gs)
|
||||
if err := types.ValidateGenesis(bz); err == nil {
|
||||
t.Error("ValidateGenesis should reject unknown signal-kind")
|
||||
}
|
||||
}
|
||||
|
||||
// TestValidateGenesisRejectsBadJSON asserts malformed JSON is rejected.
|
||||
func TestValidateGenesisRejectsBadJSON(t *testing.T) {
|
||||
if err := types.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 := types.GenesisState{
|
||||
Councils: []types.Council{
|
||||
{CouncilID: "cm", Kind: types.CouncilMesh},
|
||||
{CouncilID: "cg", Kind: types.CouncilGuild, GuildIDRef: "g1"},
|
||||
{CouncilID: "cs", Kind: types.CouncilStand, StandIDRef: "s1"},
|
||||
},
|
||||
Voices: []types.Voice{
|
||||
{VoiceID: "v1", CouncilID: "cm", SignalKind: types.SignalStash},
|
||||
{VoiceID: "v2", CouncilID: "cs", SignalKind: types.SignalCapital},
|
||||
},
|
||||
}
|
||||
bz, _ := json.Marshal(gs)
|
||||
if err := types.ValidateGenesis(bz); err != nil {
|
||||
t.Errorf("ValidateGenesis should accept clean genesis, got: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// TestValidateGenesisRejectsStandCouncilWithoutStandIDRef asserts a Stand
|
||||
// Council without stand-id-ref is rejected (by-ID-string ref to x/stand).
|
||||
func TestValidateGenesisRejectsStandCouncilWithoutStandIDRef(t *testing.T) {
|
||||
gs := types.GenesisState{
|
||||
Councils: []types.Council{{CouncilID: "cs", Kind: types.CouncilStand, StandIDRef: ""}},
|
||||
}
|
||||
bz, _ := json.Marshal(gs)
|
||||
if err := types.ValidateGenesis(bz); err == nil {
|
||||
t.Error("ValidateGenesis should reject Stand Council without stand-id-ref")
|
||||
}
|
||||
}
|
||||
|
||||
// TestValidateGenesisRejectsGuildCouncilWithoutGuildIDRef asserts a Guild
|
||||
// Council without guild-id-ref is rejected (by-ID-string ref to x/guild).
|
||||
func TestValidateGenesisRejectsGuildCouncilWithoutGuildIDRef(t *testing.T) {
|
||||
gs := types.GenesisState{
|
||||
Councils: []types.Council{{CouncilID: "cg", Kind: types.CouncilGuild, GuildIDRef: ""}},
|
||||
}
|
||||
bz, _ := json.Marshal(gs)
|
||||
if err := types.ValidateGenesis(bz); err == nil {
|
||||
t.Error("ValidateGenesis should reject Guild Council without guild-id-ref")
|
||||
}
|
||||
}
|
||||
|
||||
// TestValidateGenesisRejectsVoiceWithUnknownCouncil asserts referential
|
||||
// integrity: a Voice whose council-id does not reference an existing
|
||||
// Council is rejected (P3-01-03 deliverable).
|
||||
func TestValidateGenesisRejectsVoiceWithUnknownCouncil(t *testing.T) {
|
||||
gs := types.GenesisState{
|
||||
Councils: []types.Council{{CouncilID: "c1", Kind: types.CouncilMesh}},
|
||||
Voices: []types.Voice{{VoiceID: "v1", CouncilID: "no-such-council", SignalKind: types.SignalStash}},
|
||||
}
|
||||
bz, _ := json.Marshal(gs)
|
||||
if err := types.ValidateGenesis(bz); err == nil {
|
||||
t.Error("ValidateGenesis should reject Voice with unknown council-id (referential integrity)")
|
||||
}
|
||||
}
|
||||
|
||||
// TestMissionLockCheckIsNoOp asserts the genesis-side MissionLockCheck helper
|
||||
// is a no-op (the const is the true firewall). It must return nil for any
|
||||
// slice of Councils.
|
||||
func TestMissionLockCheckIsNoOp(t *testing.T) {
|
||||
councils := []types.Council{
|
||||
{CouncilID: "c1", Kind: types.CouncilMesh},
|
||||
{CouncilID: "c2", Kind: types.CouncilGuild, GuildIDRef: "g1"},
|
||||
{CouncilID: "c3", Kind: types.CouncilStand, StandIDRef: "s1"},
|
||||
}
|
||||
if err := types.MissionLockCheck(councils); err != nil {
|
||||
t.Errorf("MissionLockCheck should be a no-op (const is the firewall), got: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// TestModuleConsts asserts the four Cosmos-convention module consts.
|
||||
func TestModuleConsts(t *testing.T) {
|
||||
if types.ModuleName != "council" {
|
||||
t.Errorf("ModuleName = %q", types.ModuleName)
|
||||
}
|
||||
if types.StoreKey != "council" {
|
||||
t.Errorf("StoreKey = %q", types.StoreKey)
|
||||
}
|
||||
if types.RouterKey != "council" {
|
||||
t.Errorf("RouterKey = %q", types.RouterKey)
|
||||
}
|
||||
if types.QuerierRoute != "council" {
|
||||
t.Errorf("QuerierRoute = %q", types.QuerierRoute)
|
||||
}
|
||||
}
|
||||
|
||||
// TestDefaultParams asserts DefaultParams returns a zero-value Params.
|
||||
func TestDefaultParams(t *testing.T) {
|
||||
_ = types.DefaultParams() // no panics
|
||||
}
|
||||
|
||||
// --- Lexicon assertion (REQ-012) -------------------------------------------------
|
||||
|
||||
// TestLexiconNoBannedTermsInCouncilPackage scans every non-test .go file in
|
||||
// the council/types package directory for the 9 banned terms
|
||||
// (case-insensitive). Production files only — the test file references
|
||||
// banned terms via the lexicon package helpers (standard lexicon-test
|
||||
// bootstrapping pattern; no banned literals are inlined in this test file).
|
||||
func TestLexiconNoBannedTermsInCouncilPackage(t *testing.T) {
|
||||
pkgDir := packageDir(t, "github.com/oy/openyield/x/council/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 council/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)", filepath.Base(f), found)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestLexiconNoBannedTermsInCouncilTestFile 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). This is the self-bootstrapping check.
|
||||
func TestLexiconNoBannedTermsInCouncilTestFile(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("council 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.2 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/council/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)
|
||||
}
|
||||
@@ -0,0 +1,66 @@
|
||||
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
|
||||
}
|
||||
@@ -0,0 +1,122 @@
|
||||
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
|
||||
}
|
||||
@@ -0,0 +1,390 @@
|
||||
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)
|
||||
}
|
||||
@@ -0,0 +1,122 @@
|
||||
package types
|
||||
|
||||
import "encoding/json"
|
||||
|
||||
const (
|
||||
ModuleName = "feecovenant"
|
||||
StoreKey = ModuleName
|
||||
RouterKey = ModuleName
|
||||
QuerierRoute = ModuleName
|
||||
|
||||
// Fee Covenant — LOCKED (§18)
|
||||
// Mission-lock: fee can never exceed 0.1pct or fall below 0.01pct.
|
||||
// Both bounds enforced by smart contract. No future Council vote can change them.
|
||||
// Auto-decline only — fees only go down as the mesh grows, never up.
|
||||
|
||||
FeeCeilingBps = 10 // 0.1pct (ceiling, LOCKED)
|
||||
FeeFloorBps = 1 // 0.01pct (floor, LOCKED)
|
||||
InternalMinGrain = 1 // 1 Grain internal minimum (LOCKED, never changes)
|
||||
|
||||
// Volume tier thresholds (§18)
|
||||
VolumeTier2Bps = 7 // 0.07pct at $10B
|
||||
VolumeTier3Bps = 5 // 0.05pct at $50B
|
||||
VolumeTier4Bps = 3 // 0.03pct at $200B
|
||||
VolumeTier5Bps = 2 // 0.02pct at $500B
|
||||
VolumeFloorBps = 1 // 0.01pct at $1T (floor)
|
||||
|
||||
// Processor share (§18: auto-declining 50pct -> 30pct -> 20pct -> 10pct)
|
||||
ProcessorShareGenesis = 50 // 50pct at launch
|
||||
ProcessorShareTier2 = 30 // 30pct at $10B
|
||||
ProcessorShareTier3 = 20 // 20pct at $50B
|
||||
ProcessorShareTier4 = 10 // 10pct at $200B+
|
||||
)
|
||||
|
||||
// VolumeTier represents the current fee schedule tier (§18)
|
||||
type VolumeTier string
|
||||
|
||||
const (
|
||||
TierGenesis VolumeTier = "Genesis" // 0.1pct (ceiling)
|
||||
TierStage2 VolumeTier = "Stage2" // 0.07pct at $10B
|
||||
TierStage3 VolumeTier = "Stage3" // 0.05pct at $50B
|
||||
TierStage4 VolumeTier = "Stage4" // 0.03pct at $200B
|
||||
TierStage5 VolumeTier = "Stage5" // 0.02pct at $500B
|
||||
TierFloor VolumeTier = "Floor" // 0.01pct at $1T (floor)
|
||||
)
|
||||
|
||||
// WaiverReason defines "always free" pass types (§18)
|
||||
// 0pct protocol fee; only 1-Grain internal minimum
|
||||
type WaiverReason string
|
||||
|
||||
const (
|
||||
WaiverHandPassGuild WaiverReason = "HandPassGuild"
|
||||
WaiverCrewInternalVault WaiverReason = "CrewInternalVault"
|
||||
WaiverHouseholdInternal WaiverReason = "HouseholdInternal"
|
||||
WaiverCoverCall WaiverReason = "CoverCall"
|
||||
WaiverTrustDistribution WaiverReason = "TrustDistribution"
|
||||
WaiverFoundationGrant WaiverReason = "FoundationGrant"
|
||||
WaiverCircleActiveCycle WaiverReason = "CircleActiveCycle"
|
||||
)
|
||||
|
||||
// FeeSchedule is the current fee schedule (§18)
|
||||
type FeeSchedule struct {
|
||||
CeilingBps uint32 `json:"ceiling_bps" yaml:"ceiling_bps"`
|
||||
FloorBps uint32 `json:"floor_bps" yaml:"floor_bps"`
|
||||
CurrentBps uint32 `json:"current_bps" yaml:"current_bps"`
|
||||
VolumeTier VolumeTier `json:"volume_tier" yaml:"volume_tier"`
|
||||
InternalMinGrain int64 `json:"internal_min_grain" yaml:"internal_min_grain"`
|
||||
}
|
||||
|
||||
// DefaultFeeSchedule returns the launch fee schedule (§18)
|
||||
func DefaultFeeSchedule() FeeSchedule {
|
||||
return FeeSchedule{
|
||||
CeilingBps: FeeCeilingBps,
|
||||
FloorBps: FeeFloorBps,
|
||||
CurrentBps: FeeCeilingBps, // starts at ceiling
|
||||
VolumeTier: TierGenesis,
|
||||
InternalMinGrain: InternalMinGrain,
|
||||
}
|
||||
}
|
||||
|
||||
// Clamp ensures fee is within LOCKED bounds (§18: never > ceiling, never < floor)
|
||||
// This is automatic and authoritative. No Council vote can change it.
|
||||
func Clamp(feeBps uint32) uint32 {
|
||||
if feeBps > FeeCeilingBps {
|
||||
return FeeCeilingBps
|
||||
}
|
||||
if feeBps < FeeFloorBps {
|
||||
return FeeFloorBps
|
||||
}
|
||||
return feeBps
|
||||
}
|
||||
|
||||
// IsWaived checks if a pass type is "always free" (§18)
|
||||
func IsWaived(reason WaiverReason) bool {
|
||||
switch reason {
|
||||
case WaiverHandPassGuild, WaiverCrewInternalVault, WaiverHouseholdInternal,
|
||||
WaiverCoverCall, WaiverTrustDistribution, WaiverFoundationGrant,
|
||||
WaiverCircleActiveCycle:
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// MissionLockFeeCovenant — enforced by code, not by promise (§18)
|
||||
const MissionLockFeeCovenant = "Standard Pass-Act fee: never above 0.1pct, never below 0.01pct. Auto-decline on schedule, never auto-increase. Internal minimum: 1 Grain per waived Pass-Act. Never changes. This covenant is enforced by code, not by promise."
|
||||
|
||||
type Params struct {
|
||||
Schedule FeeSchedule `json:"schedule" yaml:"schedule"`
|
||||
}
|
||||
|
||||
func DefaultParams() Params {
|
||||
return Params{Schedule: DefaultFeeSchedule()}
|
||||
}
|
||||
|
||||
type GenesisState struct {
|
||||
Params Params `json:"params" yaml:"params"`
|
||||
}
|
||||
|
||||
func DefaultGenesisState() *GenesisState {
|
||||
return &GenesisState{Params: DefaultParams()}
|
||||
}
|
||||
|
||||
func ValidateGenesis(bz json.RawMessage) error { return nil }
|
||||
@@ -0,0 +1,79 @@
|
||||
package types_test
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/oy/openyield/x/feecovenant/types"
|
||||
)
|
||||
|
||||
func TestFeeCeiling(t *testing.T) {
|
||||
if types.FeeCeilingBps != 10 {
|
||||
t.Errorf("FeeCeilingBps = %d, expected 10 (§18: 0.1pct ceiling LOCKED)", types.FeeCeilingBps)
|
||||
}
|
||||
}
|
||||
|
||||
func TestFeeFloor(t *testing.T) {
|
||||
if types.FeeFloorBps != 1 {
|
||||
t.Errorf("FeeFloorBps = %d, expected 1 (§18: 0.01pct floor LOCKED)", types.FeeFloorBps)
|
||||
}
|
||||
}
|
||||
|
||||
func TestInternalMinGrain(t *testing.T) {
|
||||
if types.InternalMinGrain != 1 {
|
||||
t.Errorf("InternalMinGrain = %d, expected 1 (§18: 1 Grain LOCKED)", types.InternalMinGrain)
|
||||
}
|
||||
}
|
||||
|
||||
func TestClamp(t *testing.T) {
|
||||
if types.Clamp(15) != 10 {
|
||||
t.Error("Clamp(15) should return 10 (ceiling)")
|
||||
}
|
||||
if types.Clamp(0) != 1 {
|
||||
t.Error("Clamp(0) should return 1 (floor)")
|
||||
}
|
||||
if types.Clamp(5) != 5 {
|
||||
t.Error("Clamp(5) should return 5 (within bounds)")
|
||||
}
|
||||
}
|
||||
|
||||
func TestVolumeTiers(t *testing.T) {
|
||||
if types.VolumeTier2Bps != 7 {
|
||||
t.Error("VolumeTier2 should be 7 bps (§18)")
|
||||
}
|
||||
if types.VolumeFloorBps != 1 {
|
||||
t.Error("VolumeFloor should be 1 bps (§18)")
|
||||
}
|
||||
}
|
||||
|
||||
func TestProcessorShares(t *testing.T) {
|
||||
if types.ProcessorShareGenesis != 50 {
|
||||
t.Error("ProcessorShareGenesis should be 50pct (§18)")
|
||||
}
|
||||
if types.ProcessorShareTier4 != 10 {
|
||||
t.Error("ProcessorShareTier4 should be 10pct (§18)")
|
||||
}
|
||||
}
|
||||
|
||||
func TestWaivers(t *testing.T) {
|
||||
waivers := []types.WaiverReason{
|
||||
types.WaiverHandPassGuild, types.WaiverCrewInternalVault,
|
||||
types.WaiverHouseholdInternal, types.WaiverCoverCall,
|
||||
types.WaiverTrustDistribution, types.WaiverFoundationGrant,
|
||||
types.WaiverCircleActiveCycle,
|
||||
}
|
||||
for _, w := range waivers {
|
||||
if !types.IsWaived(w) {
|
||||
t.Errorf("Waiver %s should be free (§18)", w)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestDefaultFeeSchedule(t *testing.T) {
|
||||
schedule := types.DefaultFeeSchedule()
|
||||
if schedule.CurrentBps != 10 {
|
||||
t.Error("Default CurrentBps should be 10 (Genesis ceiling)")
|
||||
}
|
||||
if schedule.VolumeTier != types.TierGenesis {
|
||||
t.Error("Default VolumeTier should be Genesis")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,71 @@
|
||||
package types
|
||||
|
||||
import "fmt"
|
||||
|
||||
// genesis.go holds the data-engineer's genesis schema helpers for the
|
||||
// forex module (G-008 split). ValidateGenesis in types.go composes these
|
||||
// helpers; the security-engineer's test assertions live in types_test.go.
|
||||
//
|
||||
// The Forex genesis schema has two top-level sets: Pairs (the tradable
|
||||
// ForexPairs) and Providers (the oracle-provider registry). The
|
||||
// invariants enforced at genesis load are (1) pair-id uniqueness and
|
||||
// (2) provider-id uniqueness (A-212 upgrade from v0.1's no-op). The
|
||||
// lexicon firewall is the highest-severity constraint for this module
|
||||
// (RESEARCH §1.10): the data-engineer's schema uses "base-asset"/"quote-
|
||||
// asset" field names (A-208 "Bread/Asset" labels) and never the banned
|
||||
// financial terms for tradable units.
|
||||
|
||||
// ValidatePairs asserts pair-ids are present and unique, and that the
|
||||
// base-asset / quote-asset labels are non-empty (the lexicon-clean "Bread/
|
||||
// Asset" labels per A-208 — the schema trusts the labels are lexicon-clean
|
||||
// because the production code never inlines a banned term; the project-wide
|
||||
// meta-test in lexicon_meta_test.go is the durable firewall). This is the
|
||||
// P3-02-03 data-engineer schema validator composed by ValidateGenesis.
|
||||
func ValidatePairs(pairs []ForexPair) error {
|
||||
seen := make(map[string]bool, len(pairs))
|
||||
for i, p := range pairs {
|
||||
if p.PairID == "" {
|
||||
return fmt.Errorf("forex pair [%d]: empty pair-id", i)
|
||||
}
|
||||
if seen[p.PairID] {
|
||||
return fmt.Errorf("forex: duplicate pair-id %q", p.PairID)
|
||||
}
|
||||
seen[p.PairID] = true
|
||||
if p.BaseAsset == "" {
|
||||
return fmt.Errorf("forex pair %q: empty base-asset", p.PairID)
|
||||
}
|
||||
if p.QuoteAsset == "" {
|
||||
return fmt.Errorf("forex pair %q: empty quote-asset", p.PairID)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// ValidateProviders asserts provider-ids are present and unique, and that
|
||||
// each provider's kind is a known OracleKind.
|
||||
func ValidateProviders(providers []OracleProvider) error {
|
||||
seen := make(map[string]bool, len(providers))
|
||||
for i, p := range providers {
|
||||
if p.ProviderID == "" {
|
||||
return fmt.Errorf("forex provider [%d]: empty provider-id", i)
|
||||
}
|
||||
if seen[p.ProviderID] {
|
||||
return fmt.Errorf("forex: duplicate provider-id %q", p.ProviderID)
|
||||
}
|
||||
seen[p.ProviderID] = true
|
||||
if !knownOracleKind(p.Kind) {
|
||||
return fmt.Errorf("forex provider %q: unknown oracle kind %q", p.ProviderID, p.Kind)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// knownOracleKind reports whether k is one of the four OracleKind values.
|
||||
func knownOracleKind(k OracleKind) bool {
|
||||
for _, kk := range AllOracleKinds() {
|
||||
if k == kk {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
@@ -0,0 +1,155 @@
|
||||
package types
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
)
|
||||
|
||||
const (
|
||||
ModuleName = "forex"
|
||||
StoreKey = ModuleName
|
||||
RouterKey = ModuleName
|
||||
QuerierRoute = ModuleName
|
||||
|
||||
// SpreadCapBps is the LOCKED spread cap for Forex rates (vision §18
|
||||
// risk #18, A-214). The exact value is deferred to a v0.3 decision; the
|
||||
// skeleton sets a documented placeholder of 0 (≥0 invariant). The test
|
||||
// asserts SpreadCapBps >= 0. A v0.3+ governance decision may set a
|
||||
// positive cap; the placeholder is the locked skeleton value.
|
||||
SpreadCapBps = 0
|
||||
|
||||
// OracleKindCount is the locked count of OracleKind enum values
|
||||
// (vision §13 / Forex v1). A regression firewall: adding/removing/
|
||||
// renaming an Oracle kind breaks this const's test.
|
||||
OracleKindCount = 4
|
||||
|
||||
// ErrOracleNotIntegrated is the sentinel error returned by the stub
|
||||
// keeper GetRate when no live oracle is wired (skeleton — Phase 3
|
||||
// wires Piers as the oracle consumer). The sentinel is the "not-
|
||||
// integrated" marker the spec mandates.
|
||||
ErrOracleNotIntegrated = "forex oracle not integrated (Phase 3 wires Piers)"
|
||||
)
|
||||
|
||||
// ForexPair is a tradable pair in the Forex Engine v1 (vision §13, Forex v1).
|
||||
// base-asset / quote-asset use "Bread/Asset" style labels (A-208) — NOT the
|
||||
// banned financial terms for tradable units (which are lexicon-hostile per
|
||||
// RESEARCH §1.10). "Forex" itself is allowed (vision §13 names it). The
|
||||
// pair is a (base, quote) tuple of asset labels plus a decimals precision.
|
||||
// The labels are opaque strings (e.g. "Bread"/"Asset") so downstream modules
|
||||
// reference pairs by ID without importing banned terms.
|
||||
type ForexPair struct {
|
||||
PairID string `json:"pair_id" yaml:"pair_id"`
|
||||
BaseAsset string `json:"base_asset" yaml:"base_asset"`
|
||||
QuoteAsset string `json:"quote_asset" yaml:"quote_asset"`
|
||||
Decimals uint32 `json:"decimals" yaml:"decimals"`
|
||||
}
|
||||
|
||||
// RateOracle is the Go interface a Forex rate oracle must satisfy (Forex v1).
|
||||
// GetRate returns the current rate for a pair-id (as a fixed-point uint64),
|
||||
// the timestamp of the rate (block/unix time), and an error if the oracle
|
||||
// is unavailable or the pair-id is unknown. The interface has no impl in
|
||||
// v0.2 (skeleton — Phase 3 wires Piers as the oracle consumer per the
|
||||
// soft-ordering note in PLANS.md cross-phase map).
|
||||
type RateOracle interface {
|
||||
GetRate(pairID string) (rate uint64, timestamp int64, err error)
|
||||
}
|
||||
|
||||
// OracleKind enumerates the supported oracle providers (Forex v1).
|
||||
// Chainlink (aggregated off-chain reports), Pyth (low-latency pull-based),
|
||||
// UMA (optimistic oracle with dispute window), Internal (a protocol-internal
|
||||
// rate source — e.g. a DEX TWAP). The skeleton defines the enum only; no
|
||||
// live integration.
|
||||
type OracleKind string
|
||||
|
||||
const (
|
||||
OracleChainlink OracleKind = "Chainlink"
|
||||
OraclePyth OracleKind = "Pyth"
|
||||
OracleUMA OracleKind = "UMA"
|
||||
OracleInternal OracleKind = "Internal"
|
||||
)
|
||||
|
||||
// AllOracleKinds returns all four OracleKind values in Forex v1 order.
|
||||
// Locked-const test asserts exactly 4 entries with these names.
|
||||
func AllOracleKinds() []OracleKind {
|
||||
return []OracleKind{
|
||||
OracleChainlink,
|
||||
OraclePyth,
|
||||
OracleUMA,
|
||||
OracleInternal,
|
||||
}
|
||||
}
|
||||
|
||||
// OracleProvider is a registered oracle provider in the Forex Engine
|
||||
// (Forex v1). id is the provider's unique identifier; name is a human-
|
||||
// readable label; kind picks the OracleKind (Chainlink/Pyth/UMA/Internal).
|
||||
type OracleProvider struct {
|
||||
ProviderID string `json:"provider_id" yaml:"provider_id"`
|
||||
Name string `json:"name" yaml:"name"`
|
||||
Kind OracleKind `json:"kind" yaml:"kind"`
|
||||
}
|
||||
|
||||
// SpotRate is a single spot-rate observation for a ForexPair (Forex v1).
|
||||
// pair-id references the ForexPair by ID string (G-003); rate is the fixed-
|
||||
// point uint64 rate; timestamp is the observation time; provider-id
|
||||
// references the OracleProvider by ID string (G-003).
|
||||
type SpotRate struct {
|
||||
PairID string `json:"pair_id" yaml:"pair_id"`
|
||||
Rate uint64 `json:"rate" yaml:"rate"`
|
||||
Timestamp int64 `json:"timestamp" yaml:"timestamp"`
|
||||
ProviderID string `json:"provider_id" yaml:"provider_id"`
|
||||
}
|
||||
|
||||
// StubOracle is the stub keeper for the Forex Engine (Forex v1). GetRate
|
||||
// returns the sentinel ErrOracleNotIntegrated for any pair-id (the skeleton
|
||||
// is not wired to a live oracle — Phase 3 wires Piers). The stub satisfies
|
||||
// the RateOracle interface so the interface compiles and a stub impl is
|
||||
// callable from tests.
|
||||
type StubOracle struct{}
|
||||
|
||||
// GetRate returns the sentinel "not-integrated" rate for any pair-id.
|
||||
// The skeleton never returns a live rate; Phase 3 wires the real keeper.
|
||||
func (StubOracle) GetRate(pairID string) (uint64, int64, error) {
|
||||
_ = pairID
|
||||
return 0, 0, fmt.Errorf("%s", ErrOracleNotIntegrated)
|
||||
}
|
||||
|
||||
// Params for the forex module (skeleton — no tunables in v0.2; SpreadCapBps
|
||||
// is the locked const, not a tunable param).
|
||||
type Params struct{}
|
||||
|
||||
func DefaultParams() Params { return Params{} }
|
||||
|
||||
// GenesisState defines the forex module genesis state (Forex v1).
|
||||
// Pairs is the top-level set of ForexPairs; Providers is the oracle-provider
|
||||
// registry. ValidateGenesis enforces pair-id uniqueness and provider-id
|
||||
// uniqueness. The data-engineer's genesis.go holds the schema helpers (G-008).
|
||||
type GenesisState struct {
|
||||
Pairs []ForexPair `json:"pairs" yaml:"pairs"`
|
||||
Providers []OracleProvider `json:"providers" yaml:"providers"`
|
||||
Params Params `json:"params" yaml:"params"`
|
||||
}
|
||||
|
||||
func DefaultGenesisState() *GenesisState {
|
||||
return &GenesisState{
|
||||
Pairs: []ForexPair{},
|
||||
Providers: []OracleProvider{},
|
||||
Params: DefaultParams(),
|
||||
}
|
||||
}
|
||||
|
||||
// ValidateGenesis performs ID-uniqueness checks (A-212 upgrade from v0.1
|
||||
// no-op): rejects duplicate pair-ids and duplicate provider-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("forex: invalid genesis: %w", err)
|
||||
}
|
||||
if err := ValidatePairs(gs.Pairs); err != nil {
|
||||
return fmt.Errorf("forex: %w", err)
|
||||
}
|
||||
if err := ValidateProviders(gs.Providers); err != nil {
|
||||
return fmt.Errorf("forex: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,421 @@
|
||||
package types_test
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"runtime"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/oy/openyield/lexicon"
|
||||
"github.com/oy/openyield/x/forex/types"
|
||||
)
|
||||
|
||||
// TestOracleKindCountLockedConst asserts OracleKindCount is exactly 4 and
|
||||
// AllOracleKinds() returns exactly 4 (Forex v1). A regression firewall:
|
||||
// adding/removing/renaming an Oracle kind breaks this test.
|
||||
func TestOracleKindCountLockedConst(t *testing.T) {
|
||||
if types.OracleKindCount != 4 {
|
||||
t.Errorf("OracleKindCount = %d, expected 4 (Forex v1 LOCKED)", types.OracleKindCount)
|
||||
}
|
||||
all := types.AllOracleKinds()
|
||||
if len(all) != 4 {
|
||||
t.Errorf("AllOracleKinds() len = %d, expected 4", len(all))
|
||||
}
|
||||
}
|
||||
|
||||
// TestAllOracleKindsNames asserts the 4 oracle-kind names in order with no
|
||||
// extras, no dups, no renames.
|
||||
func TestAllOracleKindsNames(t *testing.T) {
|
||||
want := []string{"Chainlink", "Pyth", "UMA", "Internal"}
|
||||
all := types.AllOracleKinds()
|
||||
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("AllOracleKinds()[%d] = %q, want %q", i, k, want[i])
|
||||
}
|
||||
if seen[string(k)] {
|
||||
t.Errorf("duplicate OracleKind %q", k)
|
||||
}
|
||||
seen[string(k)] = true
|
||||
}
|
||||
}
|
||||
|
||||
// TestOracleKindValues asserts each named const matches its AllOracleKinds
|
||||
// entry.
|
||||
func TestOracleKindValues(t *testing.T) {
|
||||
if types.OracleChainlink != "Chainlink" {
|
||||
t.Errorf("OracleChainlink = %q", types.OracleChainlink)
|
||||
}
|
||||
if types.OraclePyth != "Pyth" {
|
||||
t.Errorf("OraclePyth = %q", types.OraclePyth)
|
||||
}
|
||||
if types.OracleUMA != "UMA" {
|
||||
t.Errorf("OracleUMA = %q", types.OracleUMA)
|
||||
}
|
||||
if types.OracleInternal != "Internal" {
|
||||
t.Errorf("OracleInternal = %q", types.OracleInternal)
|
||||
}
|
||||
}
|
||||
|
||||
// TestSpreadCapBpsNonNegative asserts SpreadCapBps >= 0 (A-214: the exact
|
||||
// value is deferred to v0.3; the skeleton uses a documented placeholder of
|
||||
// 0; the test asserts the invariant is non-negative).
|
||||
func TestSpreadCapBpsNonNegative(t *testing.T) {
|
||||
if types.SpreadCapBps < 0 {
|
||||
t.Errorf("SpreadCapBps = %d, expected >= 0 (A-214)", types.SpreadCapBps)
|
||||
}
|
||||
// The skeleton placeholder is exactly 0 (documented TBD per A-214).
|
||||
if types.SpreadCapBps != 0 {
|
||||
t.Logf("SpreadCapBps = %d (skeleton placeholder is 0; v0.3 may set a positive cap)", types.SpreadCapBps)
|
||||
}
|
||||
}
|
||||
|
||||
// TestForexPairStructFields asserts ForexPair uses base-asset / quote-asset
|
||||
// field names (A-208 "Bread/Asset" labels) — NOT the banned financial terms
|
||||
// for tradable units (lexicon-hostile per RESEARCH §1.10). The test asserts
|
||||
// the field names via JSON tags and constructs a sample pair with lexicon-
|
||||
// clean labels.
|
||||
func TestForexPairStructFields(t *testing.T) {
|
||||
p := types.ForexPair{
|
||||
PairID: "pair-1",
|
||||
BaseAsset: "Bread",
|
||||
QuoteAsset: "Asset",
|
||||
Decimals: 8,
|
||||
}
|
||||
if p.PairID != "pair-1" || p.BaseAsset != "Bread" || p.QuoteAsset != "Asset" || p.Decimals != 8 {
|
||||
t.Error("ForexPair fields not set correctly")
|
||||
}
|
||||
// Assert the JSON tags are "base_asset"/"quote_asset" (NOT the banned
|
||||
// tradable-unit terms). This is the lexicon shape invariant.
|
||||
bz, err := json.Marshal(p)
|
||||
if err != nil {
|
||||
t.Fatalf("marshal: %v", err)
|
||||
}
|
||||
js := string(bz)
|
||||
if !strings.Contains(js, `"base_asset"`) {
|
||||
t.Error("ForexPair JSON missing base_asset tag (A-208)")
|
||||
}
|
||||
if !strings.Contains(js, `"quote_asset"`) {
|
||||
t.Error("ForexPair JSON missing quote_asset tag (A-208)")
|
||||
}
|
||||
}
|
||||
|
||||
// TestForexPairLabelsLexiconClean asserts the sample pair labels ("Bread"/
|
||||
// "Asset") are lexicon-clean — the highest-severity check for the forex
|
||||
// module (RESEARCH §1.10). The test scans the literal labels used in this
|
||||
// test file AND the production types.go for any banned term.
|
||||
func TestForexPairLabelsLexiconClean(t *testing.T) {
|
||||
// Sample labels per A-208.
|
||||
labels := []string{"Bread", "Asset", "base_asset", "quote_asset", "BaseAsset", "QuoteAsset"}
|
||||
for _, l := range labels {
|
||||
if found, ok := lexicon.FindBannedTerm(l); ok {
|
||||
t.Errorf("label %q contains banned term %q (A-208 lexicon-clean labels)", l, found)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestRateOracleInterfaceCompiles asserts the RateOracle interface signature
|
||||
// compiles and a stub impl satisfies it. This is the interface-shape
|
||||
// regression firewall: GetRate(pairID) (rate uint64, timestamp int64, err error).
|
||||
func TestRateOracleInterfaceCompiles(t *testing.T) {
|
||||
var oracle types.RateOracle = types.StubOracle{}
|
||||
if oracle == nil {
|
||||
t.Fatal("StubOracle should be non-nil")
|
||||
}
|
||||
// The interface method must be callable.
|
||||
_, _, err := oracle.GetRate("pair-1")
|
||||
if err == nil {
|
||||
t.Error("StubOracle.GetRate should return the not-integrated sentinel error")
|
||||
}
|
||||
}
|
||||
|
||||
// TestStubOracleGetRateSentinel asserts the stub keeper GetRate returns the
|
||||
// sentinel "not-integrated" error for any pair-id (Forex v1 stub; Phase 3
|
||||
// wires Piers as the oracle consumer).
|
||||
func TestStubOracleGetRateSentinel(t *testing.T) {
|
||||
stub := types.StubOracle{}
|
||||
rate, ts, err := stub.GetRate("any-pair-id")
|
||||
if err == nil {
|
||||
t.Fatal("StubOracle.GetRate should error (not integrated)")
|
||||
}
|
||||
if !strings.Contains(err.Error(), "not integrated") {
|
||||
t.Errorf("StubOracle.GetRate error = %q, want sentinel containing 'not integrated'", err.Error())
|
||||
}
|
||||
if rate != 0 {
|
||||
t.Errorf("StubOracle.GetRate rate = %d, expected 0 (sentinel)", rate)
|
||||
}
|
||||
if ts != 0 {
|
||||
t.Errorf("StubOracle.GetRate timestamp = %d, expected 0 (sentinel)", ts)
|
||||
}
|
||||
}
|
||||
|
||||
// TestStubOracleSatisfiesInterface asserts StubOracle satisfies the
|
||||
// RateOracle interface at compile time (var _ types.RateOracle = StubOracle{}
|
||||
// would be a compile error if the interface drifted).
|
||||
func TestStubOracleSatisfiesInterface(t *testing.T) {
|
||||
var _ types.RateOracle = types.StubOracle{}
|
||||
}
|
||||
|
||||
// TestOracleProviderStructFields asserts OracleProvider carries id, name,
|
||||
// kind.
|
||||
func TestOracleProviderStructFields(t *testing.T) {
|
||||
p := types.OracleProvider{
|
||||
ProviderID: "op-1",
|
||||
Name: "Chainlink FX",
|
||||
Kind: types.OracleChainlink,
|
||||
}
|
||||
if p.ProviderID != "op-1" || p.Name != "Chainlink FX" || p.Kind != types.OracleChainlink {
|
||||
t.Error("OracleProvider fields not set correctly")
|
||||
}
|
||||
}
|
||||
|
||||
// TestSpotRateStructFields asserts SpotRate carries pair-id, rate, timestamp,
|
||||
// provider-id (by-ID-string ref per G-003).
|
||||
func TestSpotRateStructFields(t *testing.T) {
|
||||
sr := types.SpotRate{
|
||||
PairID: "pair-1",
|
||||
Rate: 100000000,
|
||||
Timestamp: 1700000000,
|
||||
ProviderID: "op-1",
|
||||
}
|
||||
if sr.PairID != "pair-1" || sr.Rate != 100000000 || sr.Timestamp != 1700000000 || sr.ProviderID != "op-1" {
|
||||
t.Error("SpotRate fields not set correctly")
|
||||
}
|
||||
}
|
||||
|
||||
// TestDefaultGenesisStateEmpty asserts DefaultGenesisState returns non-nil
|
||||
// empty slices for Pairs and Providers.
|
||||
func TestDefaultGenesisStateEmpty(t *testing.T) {
|
||||
gs := types.DefaultGenesisState()
|
||||
if gs == nil {
|
||||
t.Fatal("DefaultGenesisState returned nil")
|
||||
}
|
||||
if gs.Pairs == nil || len(gs.Pairs) != 0 {
|
||||
t.Errorf("Default Pairs should be non-nil empty slice; got len=%d nil=%v", len(gs.Pairs), gs.Pairs == nil)
|
||||
}
|
||||
if gs.Providers == nil || len(gs.Providers) != 0 {
|
||||
t.Errorf("Default Providers should be non-nil empty slice; got len=%d nil=%v", len(gs.Providers), gs.Providers == nil)
|
||||
}
|
||||
}
|
||||
|
||||
// TestValidateGenesisRejectsDupPairIDs asserts A-212: duplicate pair-ids
|
||||
// are rejected.
|
||||
func TestValidateGenesisRejectsDupPairIDs(t *testing.T) {
|
||||
gs := types.GenesisState{
|
||||
Pairs: []types.ForexPair{
|
||||
{PairID: "p1", BaseAsset: "Bread", QuoteAsset: "Asset"},
|
||||
{PairID: "p1", BaseAsset: "Bread", QuoteAsset: "Asset"}, // dup
|
||||
},
|
||||
}
|
||||
bz, _ := json.Marshal(gs)
|
||||
if err := types.ValidateGenesis(bz); err == nil {
|
||||
t.Error("ValidateGenesis should reject duplicate pair-ids")
|
||||
}
|
||||
}
|
||||
|
||||
// TestValidateGenesisRejectsDupProviderIDs asserts A-212: duplicate
|
||||
// provider-ids are rejected.
|
||||
func TestValidateGenesisRejectsDupProviderIDs(t *testing.T) {
|
||||
gs := types.GenesisState{
|
||||
Providers: []types.OracleProvider{
|
||||
{ProviderID: "op1", Name: "A", Kind: types.OracleChainlink},
|
||||
{ProviderID: "op1", Name: "B", Kind: types.OraclePyth}, // dup
|
||||
},
|
||||
}
|
||||
bz, _ := json.Marshal(gs)
|
||||
if err := types.ValidateGenesis(bz); err == nil {
|
||||
t.Error("ValidateGenesis should reject duplicate provider-ids")
|
||||
}
|
||||
}
|
||||
|
||||
// TestValidateGenesisRejectsEmptyPairID asserts empty pair-id is rejected.
|
||||
func TestValidateGenesisRejectsEmptyPairID(t *testing.T) {
|
||||
gs := types.GenesisState{
|
||||
Pairs: []types.ForexPair{{PairID: "", BaseAsset: "Bread", QuoteAsset: "Asset"}},
|
||||
}
|
||||
bz, _ := json.Marshal(gs)
|
||||
if err := types.ValidateGenesis(bz); err == nil {
|
||||
t.Error("ValidateGenesis should reject empty pair-id")
|
||||
}
|
||||
}
|
||||
|
||||
// TestValidateGenesisRejectsEmptyProviderID asserts empty provider-id is
|
||||
// rejected.
|
||||
func TestValidateGenesisRejectsEmptyProviderID(t *testing.T) {
|
||||
gs := types.GenesisState{
|
||||
Providers: []types.OracleProvider{{ProviderID: "", Name: "A", Kind: types.OracleChainlink}},
|
||||
}
|
||||
bz, _ := json.Marshal(gs)
|
||||
if err := types.ValidateGenesis(bz); err == nil {
|
||||
t.Error("ValidateGenesis should reject empty provider-id")
|
||||
}
|
||||
}
|
||||
|
||||
// TestValidateGenesisRejectsEmptyBaseAsset asserts empty base-asset is
|
||||
// rejected (the lexicon-clean label must be present).
|
||||
func TestValidateGenesisRejectsEmptyBaseAsset(t *testing.T) {
|
||||
gs := types.GenesisState{
|
||||
Pairs: []types.ForexPair{{PairID: "p1", BaseAsset: "", QuoteAsset: "Asset"}},
|
||||
}
|
||||
bz, _ := json.Marshal(gs)
|
||||
if err := types.ValidateGenesis(bz); err == nil {
|
||||
t.Error("ValidateGenesis should reject empty base-asset")
|
||||
}
|
||||
}
|
||||
|
||||
// TestValidateGenesisRejectsEmptyQuoteAsset asserts empty quote-asset is
|
||||
// rejected.
|
||||
func TestValidateGenesisRejectsEmptyQuoteAsset(t *testing.T) {
|
||||
gs := types.GenesisState{
|
||||
Pairs: []types.ForexPair{{PairID: "p1", BaseAsset: "Bread", QuoteAsset: ""}},
|
||||
}
|
||||
bz, _ := json.Marshal(gs)
|
||||
if err := types.ValidateGenesis(bz); err == nil {
|
||||
t.Error("ValidateGenesis should reject empty quote-asset")
|
||||
}
|
||||
}
|
||||
|
||||
// TestValidateGenesisRejectsUnknownOracleKind asserts an unknown OracleKind
|
||||
// is rejected.
|
||||
func TestValidateGenesisRejectsUnknownOracleKind(t *testing.T) {
|
||||
gs := types.GenesisState{
|
||||
Providers: []types.OracleProvider{{ProviderID: "op1", Name: "A", Kind: types.OracleKind("Bogus")}},
|
||||
}
|
||||
bz, _ := json.Marshal(gs)
|
||||
if err := types.ValidateGenesis(bz); err == nil {
|
||||
t.Error("ValidateGenesis should reject unknown oracle kind")
|
||||
}
|
||||
}
|
||||
|
||||
// TestValidateGenesisRejectsBadJSON asserts malformed JSON is rejected.
|
||||
func TestValidateGenesisRejectsBadJSON(t *testing.T) {
|
||||
if err := types.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 := types.GenesisState{
|
||||
Pairs: []types.ForexPair{
|
||||
{PairID: "p1", BaseAsset: "Bread", QuoteAsset: "Asset", Decimals: 8},
|
||||
{PairID: "p2", BaseAsset: "Bread", QuoteAsset: "Other", Decimals: 6},
|
||||
},
|
||||
Providers: []types.OracleProvider{
|
||||
{ProviderID: "op1", Name: "Chainlink FX", Kind: types.OracleChainlink},
|
||||
{ProviderID: "op2", Name: "Pyth FX", Kind: types.OraclePyth},
|
||||
},
|
||||
}
|
||||
bz, _ := json.Marshal(gs)
|
||||
if err := types.ValidateGenesis(bz); err != nil {
|
||||
t.Errorf("ValidateGenesis should accept clean genesis, got: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// TestModuleConsts asserts the four Cosmos-convention module consts.
|
||||
func TestModuleConsts(t *testing.T) {
|
||||
if types.ModuleName != "forex" {
|
||||
t.Errorf("ModuleName = %q", types.ModuleName)
|
||||
}
|
||||
if types.StoreKey != "forex" {
|
||||
t.Errorf("StoreKey = %q", types.StoreKey)
|
||||
}
|
||||
if types.RouterKey != "forex" {
|
||||
t.Errorf("RouterKey = %q", types.RouterKey)
|
||||
}
|
||||
if types.QuerierRoute != "forex" {
|
||||
t.Errorf("QuerierRoute = %q", types.QuerierRoute)
|
||||
}
|
||||
}
|
||||
|
||||
// TestDefaultParams asserts DefaultParams returns a zero-value Params.
|
||||
func TestDefaultParams(t *testing.T) {
|
||||
_ = types.DefaultParams() // no panics
|
||||
}
|
||||
|
||||
// TestErrOracleNotIntegratedSentinel asserts the sentinel error string is
|
||||
// non-empty and mentions "not integrated".
|
||||
func TestErrOracleNotIntegratedSentinel(t *testing.T) {
|
||||
if types.ErrOracleNotIntegrated == "" {
|
||||
t.Error("ErrOracleNotIntegrated sentinel is empty")
|
||||
}
|
||||
if !strings.Contains(types.ErrOracleNotIntegrated, "not integrated") {
|
||||
t.Errorf("ErrOracleNotIntegrated = %q, want substring 'not integrated'", types.ErrOracleNotIntegrated)
|
||||
}
|
||||
}
|
||||
|
||||
// --- Lexicon assertion (REQ-012) -------------------------------------------------
|
||||
//
|
||||
// The forex module is the HIGHEST lexicon-risk module per RESEARCH §1.10
|
||||
// (the banned financial terms for tradable units are "natural" fit-words
|
||||
// for Forex). The lexicon assertion scans production files AND the test
|
||||
// file itself; sample pair-label data ("Bread"/"Asset") is asserted clean.
|
||||
|
||||
// TestLexiconNoBannedTermsInForexPackage scans every non-test .go file in
|
||||
// the forex/types package directory for the 9 banned terms
|
||||
// (case-insensitive). Production files only — the test file references
|
||||
// banned terms via the lexicon package helpers (standard lexicon-test
|
||||
// bootstrapping pattern; no banned literals are inlined in this test file).
|
||||
func TestLexiconNoBannedTermsInForexPackage(t *testing.T) {
|
||||
pkgDir := packageDir(t, "github.com/oy/openyield/x/forex/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 forex/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 — forex is highest risk)", filepath.Base(f), found)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestLexiconNoBannedTermsInForexTestFile 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).
|
||||
// This is the self-bootstrapping check.
|
||||
func TestLexiconNoBannedTermsInForexTestFile(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("forex 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.2 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/forex/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)
|
||||
}
|
||||
@@ -0,0 +1,117 @@
|
||||
package types
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
)
|
||||
|
||||
const (
|
||||
ModuleName = "guild"
|
||||
StoreKey = ModuleName
|
||||
RouterKey = ModuleName
|
||||
QuerierRoute = ModuleName
|
||||
|
||||
// HandPassFeeBps is the LOCKED protocol fee for a Hand-Pass: 0 bps (REQ-017).
|
||||
// A Guild Hand-Pass is always free at the protocol layer. This is a covenant,
|
||||
// not a tunable parameter — cross-referenced to feecovenant.WaiverHandPassGuild
|
||||
// (v0.1 already encodes HandPassGuild as a 0-fee waiver reason). v0.2's Guild
|
||||
// module references that waiver, doesn't redefine the fee.
|
||||
HandPassFeeBps = 0
|
||||
)
|
||||
|
||||
// Guild is a task-oriented collective (vision §16, REQ-017). A Guild may
|
||||
// optionally affiliate with a Stand (stand-affiliation-id references x/stand
|
||||
// by ID string — G-003 by-ID-string invariant). founder-reach references
|
||||
// x/identity Reach by string.
|
||||
type Guild struct {
|
||||
GuildID string `json:"guild_id" yaml:"guild_id"`
|
||||
Name string `json:"name" yaml:"name"`
|
||||
FounderReach string `json:"founder_reach" yaml:"founder_reach"`
|
||||
CreatedAt int64 `json:"created_at" yaml:"created_at"`
|
||||
StandAffiliationID string `json:"stand_affiliation_id,omitempty" yaml:"stand_affiliation_id,omitempty"`
|
||||
}
|
||||
|
||||
// HandPass is a free (0% protocol fee) Pass-Act issued by a Guild (REQ-017).
|
||||
// FeeGrain is always 0 (HandPassFeeBps == 0 is the locked const covenant).
|
||||
// issuer-reach / recipient-reach reference x/identity Reach by string (G-003).
|
||||
type HandPass struct {
|
||||
PassID string `json:"pass_id" yaml:"pass_id"`
|
||||
GuildID string `json:"guild_id" yaml:"guild_id"`
|
||||
IssuerReach string `json:"issuer_reach" yaml:"issuer_reach"`
|
||||
RecipientReach string `json:"recipient_reach" yaml:"recipient_reach"`
|
||||
AmountGrain int64 `json:"amount_grain" yaml:"amount_grain"`
|
||||
Timestamp int64 `json:"timestamp" yaml:"timestamp"`
|
||||
FeeGrain int64 `json:"fee_grain" yaml:"fee_grain"` // always 0 (HandPassFeeBps == 0)
|
||||
}
|
||||
|
||||
// IssueHandPass is a stub for issuing a Hand-Pass (REQ-017). The skeleton
|
||||
// constructs a HandPass with FeeGrain = 0 (the locked covenant). Issuer
|
||||
// type-level checks (issuer must be a guild member) are NOT enforced in
|
||||
// the skeleton — flagged for v0.3 keeper logic.
|
||||
func IssueHandPass(passID, guildID, issuerReach, recipientReach string, amountGrain int64, timestamp int64) HandPass {
|
||||
return HandPass{
|
||||
PassID: passID,
|
||||
GuildID: guildID,
|
||||
IssuerReach: issuerReach,
|
||||
RecipientReach: recipientReach,
|
||||
AmountGrain: amountGrain,
|
||||
Timestamp: timestamp,
|
||||
FeeGrain: 0, // HandPassFeeBps == 0 (locked covenant)
|
||||
}
|
||||
}
|
||||
|
||||
// Params for the guild module (skeleton — no tunables in v0.2).
|
||||
type Params struct{}
|
||||
|
||||
func DefaultParams() Params { return Params{} }
|
||||
|
||||
// GenesisState defines the guild module genesis state (REQ-017).
|
||||
// Guilds + HandPasses are the two top-level sets; ValidateGenesis enforces
|
||||
// guild-id uniqueness and pass-id uniqueness.
|
||||
type GenesisState struct {
|
||||
Params Params `json:"params" yaml:"params"`
|
||||
Guilds []Guild `json:"guilds" yaml:"guilds"`
|
||||
HandPasses []HandPass `json:"hand_passes" yaml:"hand_passes"`
|
||||
}
|
||||
|
||||
func DefaultGenesisState() *GenesisState {
|
||||
return &GenesisState{
|
||||
Params: DefaultParams(),
|
||||
Guilds: []Guild{},
|
||||
HandPasses: []HandPass{},
|
||||
}
|
||||
}
|
||||
|
||||
// ValidateGenesis performs ID-uniqueness checks (A-212 upgrade from v0.1
|
||||
// no-op): rejects duplicate guild-ids and duplicate pass-ids. Also enforces
|
||||
// the 0-fee covenant on genesis HandPasses (FeeGrain must be 0).
|
||||
func ValidateGenesis(bz json.RawMessage) error {
|
||||
var gs GenesisState
|
||||
if err := json.Unmarshal(bz, &gs); err != nil {
|
||||
return fmt.Errorf("guild: invalid genesis: %w", err)
|
||||
}
|
||||
seenGuild := make(map[string]bool, len(gs.Guilds))
|
||||
for _, g := range gs.Guilds {
|
||||
if g.GuildID == "" {
|
||||
return fmt.Errorf("guild: empty guild-id")
|
||||
}
|
||||
if seenGuild[g.GuildID] {
|
||||
return fmt.Errorf("guild: duplicate guild-id %q", g.GuildID)
|
||||
}
|
||||
seenGuild[g.GuildID] = true
|
||||
}
|
||||
seenPass := make(map[string]bool, len(gs.HandPasses))
|
||||
for _, p := range gs.HandPasses {
|
||||
if p.PassID == "" {
|
||||
return fmt.Errorf("guild: empty pass-id")
|
||||
}
|
||||
if seenPass[p.PassID] {
|
||||
return fmt.Errorf("guild: duplicate pass-id %q", p.PassID)
|
||||
}
|
||||
seenPass[p.PassID] = true
|
||||
if p.FeeGrain != 0 {
|
||||
return fmt.Errorf("guild: HandPass %q has non-zero FeeGrain (HandPassFeeBps == 0 covenant)", p.PassID)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,264 @@
|
||||
package types_test
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"runtime"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/oy/openyield/lexicon"
|
||||
"github.com/oy/openyield/x/guild/types"
|
||||
)
|
||||
|
||||
// TestHandPassFeeBpsLockedConst asserts the LOCKED 0-fee covenant (REQ-017).
|
||||
// A Guild Hand-Pass is always free at the protocol layer. This is a
|
||||
// regression firewall: changing HandPassFeeBps breaks this test.
|
||||
func TestHandPassFeeBpsLockedConst(t *testing.T) {
|
||||
if types.HandPassFeeBps != 0 {
|
||||
t.Errorf("HandPassFeeBps = %d, expected 0 (REQ-017 LOCKED 0pct covenant)", types.HandPassFeeBps)
|
||||
}
|
||||
}
|
||||
|
||||
// TestIssueHandPassFeeAlwaysZero asserts IssueHandPass constructs a HandPass
|
||||
// with FeeGrain = 0 (the locked covenant), regardless of the amount.
|
||||
func TestIssueHandPassFeeAlwaysZero(t *testing.T) {
|
||||
hp := types.IssueHandPass("p1", "g1", "reach:issuer", "reach:recipient", 10000, 1234)
|
||||
if hp.FeeGrain != 0 {
|
||||
t.Errorf("IssueHandPass FeeGrain = %d, expected 0 (HandPassFeeBps == 0)", hp.FeeGrain)
|
||||
}
|
||||
// Even a large amount has zero fee (0% covenant).
|
||||
hp2 := types.IssueHandPass("p2", "g1", "reach:i", "reach:r", 1_000_000_000, 1234)
|
||||
if hp2.FeeGrain != 0 {
|
||||
t.Errorf("IssueHandPass FeeGrain (large amount) = %d, expected 0", hp2.FeeGrain)
|
||||
}
|
||||
}
|
||||
|
||||
// TestIssueHandPassFields asserts IssueHandPass populates all fields.
|
||||
func TestIssueHandPassFields(t *testing.T) {
|
||||
hp := types.IssueHandPass("p1", "g1", "reach:issuer", "reach:recipient", 5000, 1234)
|
||||
if hp.PassID != "p1" || hp.GuildID != "g1" || hp.IssuerReach != "reach:issuer" ||
|
||||
hp.RecipientReach != "reach:recipient" || hp.AmountGrain != 5000 ||
|
||||
hp.Timestamp != 1234 || hp.FeeGrain != 0 {
|
||||
t.Error("IssueHandPass fields not set correctly")
|
||||
}
|
||||
}
|
||||
|
||||
// TestHandPassStructFields asserts HandPass carries all required fields.
|
||||
func TestHandPassStructFields(t *testing.T) {
|
||||
hp := types.HandPass{
|
||||
PassID: "p1",
|
||||
GuildID: "g1",
|
||||
IssuerReach: "reach:i",
|
||||
RecipientReach: "reach:r",
|
||||
AmountGrain: 100,
|
||||
Timestamp: 200,
|
||||
FeeGrain: 0,
|
||||
}
|
||||
if hp.PassID != "p1" || hp.GuildID != "g1" || hp.AmountGrain != 100 ||
|
||||
hp.FeeGrain != 0 {
|
||||
t.Error("HandPass fields not set correctly")
|
||||
}
|
||||
}
|
||||
|
||||
// TestGuildWithStandAffiliation asserts a Guild can affiliate with a Stand
|
||||
// (stand-affiliation-id set).
|
||||
func TestGuildWithStandAffiliation(t *testing.T) {
|
||||
g := types.Guild{
|
||||
GuildID: "g1",
|
||||
Name: "Task Guild",
|
||||
FounderReach: "reach:founder",
|
||||
CreatedAt: 100,
|
||||
StandAffiliationID: "s1",
|
||||
}
|
||||
if g.StandAffiliationID != "s1" {
|
||||
t.Errorf("StandAffiliationID = %q, want %q", g.StandAffiliationID, "s1")
|
||||
}
|
||||
}
|
||||
|
||||
// TestGuildStandalone asserts a Guild can be standalone (no Stand affiliation).
|
||||
func TestGuildStandalone(t *testing.T) {
|
||||
g := types.Guild{
|
||||
GuildID: "g2",
|
||||
Name: "Loose Collective",
|
||||
FounderReach: "reach:founder",
|
||||
CreatedAt: 100,
|
||||
}
|
||||
if g.StandAffiliationID != "" {
|
||||
t.Errorf("Standalone Guild StandAffiliationID = %q, want empty", g.StandAffiliationID)
|
||||
}
|
||||
}
|
||||
|
||||
// TestDefaultGenesisStateEmpty asserts DefaultGenesisState returns non-nil
|
||||
// empty slices for Guilds and HandPasses.
|
||||
func TestDefaultGenesisStateEmpty(t *testing.T) {
|
||||
gs := types.DefaultGenesisState()
|
||||
if gs == nil {
|
||||
t.Fatal("DefaultGenesisState returned nil")
|
||||
}
|
||||
if gs.Guilds == nil || len(gs.Guilds) != 0 {
|
||||
t.Errorf("Default Guilds should be non-nil empty slice")
|
||||
}
|
||||
if gs.HandPasses == nil || len(gs.HandPasses) != 0 {
|
||||
t.Errorf("Default HandPasses should be non-nil empty slice")
|
||||
}
|
||||
}
|
||||
|
||||
// TestValidateGenesisRejectsDupGuildIDs asserts A-212: duplicate guild-ids
|
||||
// are rejected.
|
||||
func TestValidateGenesisRejectsDupGuildIDs(t *testing.T) {
|
||||
gs := types.GenesisState{
|
||||
Guilds: []types.Guild{
|
||||
{GuildID: "g1"},
|
||||
{GuildID: "g1"}, // dup
|
||||
},
|
||||
}
|
||||
bz, _ := json.Marshal(gs)
|
||||
if err := types.ValidateGenesis(bz); err == nil {
|
||||
t.Error("ValidateGenesis should reject duplicate guild-ids")
|
||||
}
|
||||
}
|
||||
|
||||
// TestValidateGenesisRejectsDupPassIDs asserts A-212: duplicate pass-ids
|
||||
// are rejected.
|
||||
func TestValidateGenesisRejectsDupPassIDs(t *testing.T) {
|
||||
gs := types.GenesisState{
|
||||
HandPasses: []types.HandPass{
|
||||
{PassID: "p1"},
|
||||
{PassID: "p1"}, // dup
|
||||
},
|
||||
}
|
||||
bz, _ := json.Marshal(gs)
|
||||
if err := types.ValidateGenesis(bz); err == nil {
|
||||
t.Error("ValidateGenesis should reject duplicate pass-ids")
|
||||
}
|
||||
}
|
||||
|
||||
// TestValidateGenesisRejectsNonZeroFeeGrain asserts the 0-fee covenant is
|
||||
// enforced at genesis: any HandPass with non-zero FeeGrain is rejected.
|
||||
func TestValidateGenesisRejectsNonZeroFeeGrain(t *testing.T) {
|
||||
gs := types.GenesisState{
|
||||
HandPasses: []types.HandPass{
|
||||
{PassID: "p1", FeeGrain: 1}, // violates 0-fee covenant
|
||||
},
|
||||
}
|
||||
bz, _ := json.Marshal(gs)
|
||||
if err := types.ValidateGenesis(bz); err == nil {
|
||||
t.Error("ValidateGenesis should reject non-zero FeeGrain (0pct covenant)")
|
||||
}
|
||||
}
|
||||
|
||||
// TestValidateGenesisRejectsEmptyGuildID asserts empty guild-id is rejected.
|
||||
func TestValidateGenesisRejectsEmptyGuildID(t *testing.T) {
|
||||
gs := types.GenesisState{
|
||||
Guilds: []types.Guild{{GuildID: ""}},
|
||||
}
|
||||
bz, _ := json.Marshal(gs)
|
||||
if err := types.ValidateGenesis(bz); err == nil {
|
||||
t.Error("ValidateGenesis should reject empty guild-id")
|
||||
}
|
||||
}
|
||||
|
||||
// TestValidateGenesisRejectsEmptyPassID asserts empty pass-id is rejected.
|
||||
func TestValidateGenesisRejectsEmptyPassID(t *testing.T) {
|
||||
gs := types.GenesisState{
|
||||
HandPasses: []types.HandPass{{PassID: ""}},
|
||||
}
|
||||
bz, _ := json.Marshal(gs)
|
||||
if err := types.ValidateGenesis(bz); err == nil {
|
||||
t.Error("ValidateGenesis should reject empty pass-id")
|
||||
}
|
||||
}
|
||||
|
||||
// TestValidateGenesisRejectsBadJSON asserts malformed JSON is rejected.
|
||||
func TestValidateGenesisRejectsBadJSON(t *testing.T) {
|
||||
if err := types.ValidateGenesis(json.RawMessage(`{bad`)); err == nil {
|
||||
t.Error("ValidateGenesis should reject malformed JSON")
|
||||
}
|
||||
}
|
||||
|
||||
// TestValidateGenesisAcceptsClean asserts a clean genesis validates,
|
||||
// including a Guild with Stand affiliation and a standalone Guild.
|
||||
func TestValidateGenesisAcceptsClean(t *testing.T) {
|
||||
gs := types.GenesisState{
|
||||
Guilds: []types.Guild{
|
||||
{GuildID: "g1", StandAffiliationID: "s1"},
|
||||
{GuildID: "g2"}, // standalone
|
||||
},
|
||||
HandPasses: []types.HandPass{
|
||||
{PassID: "p1", GuildID: "g1", FeeGrain: 0},
|
||||
{PassID: "p2", GuildID: "g2", FeeGrain: 0},
|
||||
},
|
||||
}
|
||||
bz, _ := json.Marshal(gs)
|
||||
if err := types.ValidateGenesis(bz); err != nil {
|
||||
t.Errorf("ValidateGenesis should accept clean genesis, got: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// TestModuleConsts asserts the four Cosmos-convention module consts.
|
||||
func TestModuleConsts(t *testing.T) {
|
||||
if types.ModuleName != "guild" {
|
||||
t.Errorf("ModuleName = %q", types.ModuleName)
|
||||
}
|
||||
if types.StoreKey != "guild" {
|
||||
t.Errorf("StoreKey = %q", types.StoreKey)
|
||||
}
|
||||
if types.RouterKey != "guild" {
|
||||
t.Errorf("RouterKey = %q", types.RouterKey)
|
||||
}
|
||||
if types.QuerierRoute != "guild" {
|
||||
t.Errorf("QuerierRoute = %q", types.QuerierRoute)
|
||||
}
|
||||
}
|
||||
|
||||
// TestDefaultParams asserts DefaultParams returns a zero-value Params.
|
||||
func TestDefaultParams(t *testing.T) {
|
||||
_ = types.DefaultParams() // no panics
|
||||
}
|
||||
|
||||
// --- Lexicon assertion (REQ-012) -------------------------------------------------
|
||||
|
||||
// TestLexiconNoBannedTermsInGuildPackage scans every non-test .go file in
|
||||
// the guild/types package directory for the 9 banned terms (case-insensitive).
|
||||
// Production files only — the test file contains the banned terms as the list
|
||||
// of things to forbid (standard lexicon-test bootstrapping pattern).
|
||||
func TestLexiconNoBannedTermsInGuildPackage(t *testing.T) {
|
||||
pkgDir := packageDir(t, "github.com/oy/openyield/x/guild/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 guild/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)", filepath.Base(f), found)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// packageDir resolves a Go import path to its filesystem directory.
|
||||
func packageDir(t *testing.T, importPath string) string {
|
||||
t.Helper()
|
||||
_, file, _, ok := runtime.Caller(0)
|
||||
if !ok {
|
||||
t.Fatal("runtime.Caller failed")
|
||||
}
|
||||
repoRoot := filepath.Dir(filepath.Dir(filepath.Dir(filepath.Dir(file))))
|
||||
rel := strings.TrimPrefix(importPath, "github.com/oy/openyield/")
|
||||
return filepath.Join(repoRoot, rel)
|
||||
}
|
||||
@@ -0,0 +1,70 @@
|
||||
package types
|
||||
|
||||
// cross_const_test.go (REQ-030, REVIEW.md P2 / A-304, GRILL G-015) is a
|
||||
// cross-package const-equality test that catches silent drift between the
|
||||
// x/hub LOCAL consts (LendingCouponCapBps / LendingCouponFloorBps) and the
|
||||
// x/bond mission-locked consts (CouponCapBps / CouponFloorBps, D-028).
|
||||
//
|
||||
// Before REQ-030, the two const pairs were cross-documented only by a comment
|
||||
// (x/hub/types/types.go:46-55) — no automated check existed. A future
|
||||
// mission-locked change to x/bond.CouponCapBps without a matching x/hub change
|
||||
// would silently drift. This test fails closed on either kind of drift:
|
||||
//
|
||||
// - single-sided drift: hub stays 800, bond changes to 900 → the equality
|
||||
// test fails.
|
||||
// - paired drift: BOTH change to the same wrong value (e.g., both 900) → the
|
||||
// equality test passes BUT the absolute-value test (G-015) fails, because
|
||||
// the mission-locked value is 800, not 900.
|
||||
//
|
||||
// G-003 (no production cross-module struct imports): this is a TEST-ONLY
|
||||
// import of x/bond/types in a _test.go file. G-003's test-import exemption
|
||||
// (documented in v0.2 GRILL G-003 and already exercised by
|
||||
// x/bearers/types/types_test.go:7 importing x/processing/types) permits
|
||||
// cross-package test imports. NO production .go file in x/hub/types/ imports
|
||||
// x/bond/types (the P1-99-01 verification greps non-test .go files to confirm).
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
bondtypes "github.com/oy/openyield/x/bond/types"
|
||||
)
|
||||
|
||||
// TestLendingCouponCapMatchesBondCap asserts the x/hub LOCAL
|
||||
// LendingCouponCapBps equals the x/bond mission-locked CouponCapBps (A-304).
|
||||
// Fails on single-sided drift (one changes, the other does not).
|
||||
func TestLendingCouponCapMatchesBondCap(t *testing.T) {
|
||||
if LendingCouponCapBps != bondtypes.CouponCapBps {
|
||||
t.Errorf("A-304 drift: x/hub LendingCouponCapBps = %d, x/bond CouponCapBps = %d (must match)", LendingCouponCapBps, bondtypes.CouponCapBps)
|
||||
}
|
||||
}
|
||||
|
||||
// TestLendingCouponFloorMatchesBondFloor asserts the x/hub LOCAL
|
||||
// LendingCouponFloorBps equals the x/bond mission-locked CouponFloorBps
|
||||
// (A-304). Fails on single-sided drift.
|
||||
func TestLendingCouponFloorMatchesBondFloor(t *testing.T) {
|
||||
if LendingCouponFloorBps != bondtypes.CouponFloorBps {
|
||||
t.Errorf("A-304 drift: x/hub LendingCouponFloorBps = %d, x/bond CouponFloorBps = %d (must match)", LendingCouponFloorBps, bondtypes.CouponFloorBps)
|
||||
}
|
||||
}
|
||||
|
||||
// TestConstsAreMissionLocked800And0 (GRILL G-015) asserts the ABSOLUTE
|
||||
// mission-locked values: both caps are 800 (8pct, D-028) and both floors are 0
|
||||
// (0pct, D-028). This catches PAIRED drift — if both consts change to the same
|
||||
// wrong value (e.g., both 900), the equality tests above pass but this test
|
||||
// fails, because the mission-locked value is 800, not 900. The 8pct cap /
|
||||
// 0pct floor is the anti-greed covenant (vision §17, §18); defending the
|
||||
// absolute value is the highest-priority regression guard in v0.4.
|
||||
func TestConstsAreMissionLocked800And0(t *testing.T) {
|
||||
if LendingCouponCapBps != 800 {
|
||||
t.Errorf("G-015: x/hub LendingCouponCapBps = %d, want 800 (mission-locked 8pct, D-028)", LendingCouponCapBps)
|
||||
}
|
||||
if bondtypes.CouponCapBps != 800 {
|
||||
t.Errorf("G-015: x/bond CouponCapBps = %d, want 800 (mission-locked 8pct, D-028)", bondtypes.CouponCapBps)
|
||||
}
|
||||
if LendingCouponFloorBps != 0 {
|
||||
t.Errorf("G-015: x/hub LendingCouponFloorBps = %d, want 0 (mission-locked 0pct, D-028)", LendingCouponFloorBps)
|
||||
}
|
||||
if bondtypes.CouponFloorBps != 0 {
|
||||
t.Errorf("G-015: x/bond CouponFloorBps = %d, want 0 (mission-locked 0pct, D-028)", bondtypes.CouponFloorBps)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,245 @@
|
||||
// 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
|
||||
}
|
||||
@@ -0,0 +1,424 @@
|
||||
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)
|
||||
}
|
||||
@@ -0,0 +1,66 @@
|
||||
package types
|
||||
|
||||
import "encoding/json"
|
||||
|
||||
const (
|
||||
ModuleName = "identity"
|
||||
StoreKey = ModuleName
|
||||
RouterKey = ModuleName
|
||||
QuerierRoute = ModuleName
|
||||
)
|
||||
|
||||
// Reach is the mesh-native identity layer (§8, §9.6 Layer 1)
|
||||
// Cryptographic, virtual, no state identity
|
||||
type Reach struct {
|
||||
ReachID string `json:"reach_id" yaml:"reach_id"`
|
||||
HolderID string `json:"holder_id" yaml:"holder_id"`
|
||||
CreatedAt int64 `json:"created_at" yaml:"created_at"`
|
||||
PublicKey string `json:"public_key" yaml:"public_key"`
|
||||
IsNomad bool `json:"is_nomad" yaml:"is_nomad"`
|
||||
IsFreeholder bool `json:"is_freeholder" yaml:"is_freeholder"`
|
||||
}
|
||||
|
||||
// CitizenshipTier (§9)
|
||||
type CitizenshipTier string
|
||||
|
||||
const (
|
||||
TierNomad CitizenshipTier = "Nomad" // any Holder (lifestyle tier)
|
||||
TierFreeholder CitizenshipTier = "Freeholder" // earned full recognition (sovereign tier)
|
||||
)
|
||||
|
||||
// TwoLayerPassport (§9.6)
|
||||
// Layer 1: Reach (mesh-native, virtual, cryptographic)
|
||||
// Layer 2: Pier-routed real-world credentials (e-Residency, biometric IDs)
|
||||
// OY does not issue passports; OY routes to Piers that do.
|
||||
type TwoLayerPassport struct {
|
||||
ReachID string `json:"reach_id" yaml:"reach_id"`
|
||||
Layer1Reach bool `json:"layer1_reach" yaml:"layer1_reach"`
|
||||
Layer2PierRouted bool `json:"layer2_pier_routed" yaml:"layer2_pier_routed"`
|
||||
PierCredentialRef string `json:"pier_credential_ref" yaml:"pier_credential_ref"`
|
||||
}
|
||||
|
||||
// WayfarersRecord is an optional Reach-signed attestation (§9.5)
|
||||
// Credential of belonging, not state identity
|
||||
type WayfarersRecord struct {
|
||||
ReachID string `json:"reach_id" yaml:"reach_id"`
|
||||
FreeholderStatus bool `json:"freeholder_status" yaml:"freeholder_status"`
|
||||
StandingScore float64 `json:"standing_score" yaml:"standing_score"`
|
||||
GuildAffiliations []string `json:"guild_affiliations" yaml:"guild_affiliations"`
|
||||
BloomHistory string `json:"bloom_history" yaml:"bloom_history"`
|
||||
IssuedAt int64 `json:"issued_at" yaml:"issued_at"`
|
||||
}
|
||||
|
||||
type Params struct{}
|
||||
|
||||
func DefaultParams() Params { return Params{} }
|
||||
|
||||
type GenesisState struct {
|
||||
Params Params `json:"params" yaml:"params"`
|
||||
Reaches []Reach `json:"reaches" yaml:"reaches"`
|
||||
}
|
||||
|
||||
func DefaultGenesisState() *GenesisState {
|
||||
return &GenesisState{Params: DefaultParams(), Reaches: []Reach{}}
|
||||
}
|
||||
|
||||
func ValidateGenesis(bz json.RawMessage) error { return nil }
|
||||
@@ -0,0 +1,79 @@
|
||||
package types
|
||||
|
||||
import "encoding/json"
|
||||
|
||||
const (
|
||||
ModuleName = "mesh"
|
||||
StoreKey = ModuleName
|
||||
RouterKey = ModuleName
|
||||
QuerierRoute = ModuleName
|
||||
)
|
||||
|
||||
// Four Faces of the mesh experience (§8)
|
||||
// Reach (who you are), Standing (who you can trust),
|
||||
// Maps (where you are), Pay (how value flows)
|
||||
type MeshFace string
|
||||
|
||||
const (
|
||||
FaceReach MeshFace = "Reach"
|
||||
FaceStanding MeshFace = "Standing"
|
||||
FaceMaps MeshFace = "Maps"
|
||||
FacePay MeshFace = "Pay"
|
||||
)
|
||||
|
||||
// AllFaces returns the four mesh faces (§8)
|
||||
func AllFaces() []MeshFace {
|
||||
return []MeshFace{FaceReach, FaceStanding, FaceMaps, FacePay}
|
||||
}
|
||||
|
||||
// MapEntry is a discovery entry (§8: Maps — where you are, what's around)
|
||||
type MapEntry struct {
|
||||
EntryID string `json:"entry_id" yaml:"entry_id"`
|
||||
Name string `json:"name" yaml:"name"`
|
||||
Latitude float64 `json:"latitude" yaml:"latitude"`
|
||||
Longitude float64 `json:"longitude" yaml:"longitude"`
|
||||
Category string `json:"category" yaml:"category"` // Op, Pier, service
|
||||
HolderID string `json:"holder_id" yaml:"holder_id"`
|
||||
IsOp bool `json:"is_op" yaml:"is_op"`
|
||||
IsPier bool `json:"is_pier" yaml:"is_pier"`
|
||||
}
|
||||
|
||||
// PayAct represents a Pass-Act (value flow, §8: Pay)
|
||||
type PayAct struct {
|
||||
PassID string `json:"pass_id" yaml:"pass_id"`
|
||||
FromStashID string `json:"from_stash_id" yaml:"from_stash_id"`
|
||||
ToStashID string `json:"to_stash_id" yaml:"to_stash_id"`
|
||||
AmountGrain int64 `json:"amount_grain" yaml:"amount_grain"`
|
||||
FeeGrain int64 `json:"fee_grain" yaml:"fee_grain"`
|
||||
IsLongPass bool `json:"is_long_pass" yaml:"is_long_pass"` // remittance
|
||||
Timestamp int64 `json:"timestamp" yaml:"timestamp"`
|
||||
}
|
||||
|
||||
// ExitLayerType defines Layer 3 exit methods (§7)
|
||||
type ExitLayerType string
|
||||
|
||||
const (
|
||||
ExitDEX ExitLayerType = "DEX"
|
||||
ExitBridge ExitLayerType = "Bridge"
|
||||
ExitOffMesh ExitLayerType = "OffMesh"
|
||||
)
|
||||
|
||||
// MayasDayPlaceholder — full narrative deferred to component doc (§8, Q1)
|
||||
// The mesh is one environment with four invisible primitives
|
||||
// activating simultaneously. A Holder doesn't open apps — they live in the mesh.
|
||||
const MayasDayPlaceholder = "Maya's Day narrative deferred to Mesh Experience component doc (Architecture Q1)"
|
||||
|
||||
type Params struct{}
|
||||
|
||||
func DefaultParams() Params { return Params{} }
|
||||
|
||||
type GenesisState struct {
|
||||
Params Params `json:"params" yaml:"params"`
|
||||
MapEntries []MapEntry `json:"map_entries" yaml:"map_entries"`
|
||||
}
|
||||
|
||||
func DefaultGenesisState() *GenesisState {
|
||||
return &GenesisState{Params: DefaultParams(), MapEntries: []MapEntry{}}
|
||||
}
|
||||
|
||||
func ValidateGenesis(bz json.RawMessage) error { return nil }
|
||||
@@ -0,0 +1,39 @@
|
||||
package types_test
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/oy/openyield/x/mesh/types"
|
||||
)
|
||||
|
||||
func TestFourFaces(t *testing.T) {
|
||||
faces := types.AllFaces()
|
||||
if len(faces) != 4 {
|
||||
t.Errorf("Expected 4 mesh faces (§8), got %d", len(faces))
|
||||
}
|
||||
}
|
||||
|
||||
func TestFaceNames(t *testing.T) {
|
||||
expected := map[types.MeshFace]bool{
|
||||
types.FaceReach: true, types.FaceStanding: true,
|
||||
types.FaceMaps: true, types.FacePay: true,
|
||||
}
|
||||
for _, f := range types.AllFaces() {
|
||||
if !expected[f] {
|
||||
t.Errorf("Unexpected face: %s", f)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestExitLayerTypes(t *testing.T) {
|
||||
exits := []types.ExitLayerType{types.ExitDEX, types.ExitBridge, types.ExitOffMesh}
|
||||
if len(exits) != 3 {
|
||||
t.Errorf("Expected 3 exit layer types (§7), got %d", len(exits))
|
||||
}
|
||||
}
|
||||
|
||||
func TestMayasDayPlaceholder(t *testing.T) {
|
||||
if types.MayasDayPlaceholder == "" {
|
||||
t.Error("Maya's Day placeholder should note deferral (§8, Q1)")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,80 @@
|
||||
package types
|
||||
|
||||
import "fmt"
|
||||
|
||||
// genesis.go holds the data-engineer's genesis schema helpers for the pact
|
||||
// module (G-008 split). ValidateGenesis in types.go composes these helpers;
|
||||
// the security-engineer's test assertions live in types_test.go.
|
||||
//
|
||||
// The Pact genesis schema is a single top-level set: Pacts. The two
|
||||
// invariants enforced at genesis load are (1) pact-id uniqueness (A-212) and
|
||||
// (2) the Mission-Lock check (the per-type AmendableCoreTerms flags for
|
||||
// Pause/Ground/Stance must be false — the global MissionLockAmendable const
|
||||
// bool is the firewall). The Mission-Lock is enforced by compile-time consts;
|
||||
// the genesis-side MissionLockCheck is the data-engineer's hook that asserts
|
||||
// the const firewall is intact whenever genesis Pacts are loaded (so a
|
||||
// future change to the consts would surface here too).
|
||||
|
||||
// ValidatePacts asserts pact-ids are present and unique, and that each
|
||||
// Pact's type is a known PactType. It also runs the Mission-Lock check
|
||||
// (MissionLockCheck) so the genesis load path enforces both invariants.
|
||||
// ValidatePacts is the data-engineer's schema validator, composed by
|
||||
// ValidateGenesis in types.go.
|
||||
func ValidatePacts(pacts []Pact) error {
|
||||
seen := make(map[string]bool, len(pacts))
|
||||
for i, p := range pacts {
|
||||
if p.PactID == "" {
|
||||
return fmt.Errorf("pact [%d]: empty pact-id", i)
|
||||
}
|
||||
if seen[p.PactID] {
|
||||
return fmt.Errorf("pact: duplicate pact-id %q", p.PactID)
|
||||
}
|
||||
seen[p.PactID] = true
|
||||
if !knownPactType(p.Type) {
|
||||
return fmt.Errorf("pact %q: unknown pact type %q", p.PactID, p.Type)
|
||||
}
|
||||
}
|
||||
if err := MissionLockCheck(pacts); err != nil {
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// knownPactType reports whether t is one of the six vision §16 PactType values.
|
||||
func knownPactType(t PactType) bool {
|
||||
for _, kt := range AllPactTypes() {
|
||||
if t == kt {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// MissionLockCheck asserts the Mission-Lock invariant on a slice of Pacts:
|
||||
// every Pause/Ground/Stance Pact must have its AmendableCoreTerms flag false.
|
||||
// Because the flags are compile-time consts (AmendableCoreTermsPause/Ground/
|
||||
// Stance == false) and the global MissionLockAmendable const is false, this
|
||||
// check always passes — it exists as the data-engineer's genesis-side
|
||||
// assertion that the Mission-Lock firewall is intact. If the consts ever
|
||||
// changed to true, this check would still pass (the consts are the firewall,
|
||||
// not runtime data); the test in types_test.go is the true regression guard.
|
||||
// The helper is the genesis hook for v0.3 keeper logic to extend with live
|
||||
// per-pact Mission-Lock enforcement.
|
||||
func MissionLockCheck(pacts []Pact) error {
|
||||
// The global MissionLockAmendable const is the firewall: if it were ever
|
||||
// flipped to true (which the test suite rejects), the genesis load would
|
||||
// surface it here. The per-pact loop echoes the invariant for each
|
||||
// Mission-Locked Pact type so a future per-pact check has a hook point.
|
||||
if MissionLockAmendable {
|
||||
return fmt.Errorf("pact: Mission Lock amendable (MissionLockAmendable == true) — firewall breach")
|
||||
}
|
||||
for _, p := range pacts {
|
||||
if !MissionLockAmendableCoreTerms(p.Type) {
|
||||
// Non-amendable core terms: the const flags already guarantee this;
|
||||
// the genesis check is the echo. No per-pact runtime data to verify
|
||||
// in the skeleton — the const is the source of truth.
|
||||
continue
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,235 @@
|
||||
package types
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
)
|
||||
|
||||
const (
|
||||
ModuleName = "pact"
|
||||
StoreKey = ModuleName
|
||||
RouterKey = ModuleName
|
||||
QuerierRoute = ModuleName
|
||||
|
||||
// PactTypeCount is the locked count of PactType enum values (vision §16).
|
||||
// A regression firewall: adding/removing/renaming a Pact type breaks this
|
||||
// const's test (REQ-020, A-207: ONE module with enum, not six micro-modules).
|
||||
PactTypeCount = 6
|
||||
|
||||
// MissionLockAmendable is the Mission-Lock invariant: the core terms of
|
||||
// Pause/Ground/Stance Pacts are non-amendable (vision §19). This is a
|
||||
// locked const bool: it can NEVER be set true. The regression test asserts
|
||||
// it is false and that the per-type AmendableCoreTerms flags for
|
||||
// Pause/Ground/Stance are all false.
|
||||
MissionLockAmendable = false
|
||||
)
|
||||
|
||||
// PactType enumerates the six commitment types (vision §16, REQ-020).
|
||||
// A-207: all six live in ONE x/pact module with a PactType enum + per-type
|
||||
// execute-entry stubs (NOT six micro-modules).
|
||||
type PactType string
|
||||
|
||||
const (
|
||||
PactPause PactType = "Pause" // circuit-breaker commitment (wraps x/still)
|
||||
PactGround PactType = "Ground" // earth-anchored collateral lock commitment
|
||||
PactStance PactType = "Stance" // public-position / attestation commitment
|
||||
PactCover PactType = "Cover" // insurance-like commitment (Cover Pool)
|
||||
PactStandRegistry PactType = "StandRegistry" // registers a Stand into the canonical registry
|
||||
PactHubAPI PactType = "HubAPI" // B2B backbone commitment
|
||||
)
|
||||
|
||||
// AllPactTypes returns all six PactType values in vision §16 order.
|
||||
// Locked-const test asserts exactly 6 entries with these names (REQ-020).
|
||||
func AllPactTypes() []PactType {
|
||||
return []PactType{
|
||||
PactPause,
|
||||
PactGround,
|
||||
PactStance,
|
||||
PactCover,
|
||||
PactStandRegistry,
|
||||
PactHubAPI,
|
||||
}
|
||||
}
|
||||
|
||||
// PactStatus enumerates the lifecycle states of a Pact (REQ-020).
|
||||
type PactStatus string
|
||||
|
||||
const (
|
||||
StatusProposed PactStatus = "Proposed" // pact created, not yet active
|
||||
StatusActive PactStatus = "Active" // pact is live and binding
|
||||
StatusFulfilled PactStatus = "Fulfilled" // pact completed successfully
|
||||
StatusVoided PactStatus = "Voided" // pact voided (cancelled / breached)
|
||||
)
|
||||
|
||||
// PactStatusCount is the locked count of PactStatus enum values.
|
||||
const PactStatusCount = 4
|
||||
|
||||
// Pact is a commitment of one of six types (vision §16, REQ-020). Each Pact
|
||||
// has a type, parties (Reach IDs by-ID-string per G-003), opaque terms-bytes,
|
||||
// a status, and per-type execute-message ref. window-id-ref references
|
||||
// x/window by ID string (G-003 by-ID-string invariant; P1-01-01 convention).
|
||||
// stand-id-ref references x/stand by ID string (P1-02-01 convention); only
|
||||
// StandRegistry Pacts populate it for non-empty, others leave it "".
|
||||
type Pact struct {
|
||||
PactID string `json:"pact_id" yaml:"pact_id"`
|
||||
Type PactType `json:"type" yaml:"type"`
|
||||
Parties []string `json:"parties" yaml:"parties"`
|
||||
Terms []byte `json:"terms" yaml:"terms"`
|
||||
Status PactStatus `json:"status" yaml:"status"`
|
||||
ExecuteMsgRef string `json:"execute_msg_ref" yaml:"execute_msg_ref"`
|
||||
WindowIDRef string `json:"window_id_ref" yaml:"window_id_ref"`
|
||||
StandIDRef string `json:"stand_id_ref" yaml:"stand_id_ref"`
|
||||
}
|
||||
|
||||
// MissionLockCoreTerms flags which Pact types have non-amendable core terms
|
||||
// under the Mission Lock (vision §19). Pause/Ground/Stance core terms are
|
||||
// non-amendable; the const flags below are the per-type invariant. The
|
||||
// module-level MissionLockAmendable const bool is the global firewall.
|
||||
const (
|
||||
// AmendableCoreTermsPause is false: Pause Pact core terms are
|
||||
// non-amendable under the Mission Lock.
|
||||
AmendableCoreTermsPause = false
|
||||
// AmendableCoreTermsGround is false: Ground Pact core terms are
|
||||
// non-amendable under the Mission Lock.
|
||||
AmendableCoreTermsGround = false
|
||||
// AmendableCoreTermsStance is false: Stance Pact core terms are
|
||||
// non-amendable under the Mission Lock.
|
||||
AmendableCoreTermsStance = false
|
||||
)
|
||||
|
||||
// MissionLockAmendableCoreTerms returns the per-type AmendableCoreTerms flag
|
||||
// for a PactType. Pause/Ground/Stance return false (non-amendable); Cover,
|
||||
// StandRegistry, HubAPI return true (amendable per the skeleton — these are
|
||||
// not Mission-Locked). The Mission-Lock invariant test asserts the three
|
||||
// core types return false.
|
||||
func MissionLockAmendableCoreTerms(t PactType) bool {
|
||||
switch t {
|
||||
case PactPause:
|
||||
return AmendableCoreTermsPause
|
||||
case PactGround:
|
||||
return AmendableCoreTermsGround
|
||||
case PactStance:
|
||||
return AmendableCoreTermsStance
|
||||
default:
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
// ExecutePause is the execute-entry stub for a Pause Pact (circuit-breaker).
|
||||
// The skeleton returns the proposed status transition; v0.3 wires the live
|
||||
// keeper that wraps x/still.
|
||||
func (p *Pact) ExecutePause() error {
|
||||
if p.Type != PactPause {
|
||||
return fmt.Errorf("ExecutePause: pact %q is type %q, not Pause", p.PactID, p.Type)
|
||||
}
|
||||
if p.Status != StatusProposed {
|
||||
return fmt.Errorf("ExecutePause: pact %q status %q, not Proposed", p.PactID, p.Status)
|
||||
}
|
||||
p.Status = StatusActive
|
||||
return nil
|
||||
}
|
||||
|
||||
// ExecuteGround is the execute-entry stub for a Ground Pact
|
||||
// (earth-anchored collateral lock).
|
||||
func (p *Pact) ExecuteGround() error {
|
||||
if p.Type != PactGround {
|
||||
return fmt.Errorf("ExecuteGround: pact %q is type %q, not Ground", p.PactID, p.Type)
|
||||
}
|
||||
if p.Status != StatusProposed {
|
||||
return fmt.Errorf("ExecuteGround: pact %q status %q, not Proposed", p.PactID, p.Status)
|
||||
}
|
||||
p.Status = StatusActive
|
||||
return nil
|
||||
}
|
||||
|
||||
// ExecuteStance is the execute-entry stub for a Stance Pact
|
||||
// (public-position / attestation).
|
||||
func (p *Pact) ExecuteStance() error {
|
||||
if p.Type != PactStance {
|
||||
return fmt.Errorf("ExecuteStance: pact %q is type %q, not Stance", p.PactID, p.Type)
|
||||
}
|
||||
if p.Status != StatusProposed {
|
||||
return fmt.Errorf("ExecuteStance: pact %q status %q, not Proposed", p.PactID, p.Status)
|
||||
}
|
||||
p.Status = StatusActive
|
||||
return nil
|
||||
}
|
||||
|
||||
// ExecuteCover is the execute-entry stub for a Cover Pact (insurance-like).
|
||||
// Cover Pool seniority is deferred per Q7 — the skeleton is a flat
|
||||
// commitment type with no seniority fields.
|
||||
func (p *Pact) ExecuteCover() error {
|
||||
if p.Type != PactCover {
|
||||
return fmt.Errorf("ExecuteCover: pact %q is type %q, not Cover", p.PactID, p.Type)
|
||||
}
|
||||
if p.Status != StatusProposed {
|
||||
return fmt.Errorf("ExecuteCover: pact %q status %q, not Proposed", p.PactID, p.Status)
|
||||
}
|
||||
p.Status = StatusActive
|
||||
return nil
|
||||
}
|
||||
|
||||
// ExecuteStandRegistry is the execute-entry stub for a StandRegistry Pact.
|
||||
// stand-id-ref references x/stand by ID string (G-003); the skeleton activates
|
||||
// the pact without a live keeper call.
|
||||
func (p *Pact) ExecuteStandRegistry() error {
|
||||
if p.Type != PactStandRegistry {
|
||||
return fmt.Errorf("ExecuteStandRegistry: pact %q is type %q, not StandRegistry", p.PactID, p.Type)
|
||||
}
|
||||
if p.Status != StatusProposed {
|
||||
return fmt.Errorf("ExecuteStandRegistry: pact %q status %q, not Proposed", p.PactID, p.Status)
|
||||
}
|
||||
if p.StandIDRef == "" {
|
||||
return fmt.Errorf("ExecuteStandRegistry: pact %q missing stand-id-ref", p.PactID)
|
||||
}
|
||||
p.Status = StatusActive
|
||||
return nil
|
||||
}
|
||||
|
||||
// ExecuteHubAPI is the execute-entry stub for a HubAPI Pact (B2B backbone).
|
||||
// The full Hub API suite is deferred to Phase 3; v0.2 = stub type only.
|
||||
func (p *Pact) ExecuteHubAPI() error {
|
||||
if p.Type != PactHubAPI {
|
||||
return fmt.Errorf("ExecuteHubAPI: pact %q is type %q, not HubAPI", p.PactID, p.Type)
|
||||
}
|
||||
if p.Status != StatusProposed {
|
||||
return fmt.Errorf("ExecuteHubAPI: pact %q status %q, not Proposed", p.PactID, p.Status)
|
||||
}
|
||||
p.Status = StatusActive
|
||||
return nil
|
||||
}
|
||||
|
||||
// Params for the pact module (skeleton — no tunables in v0.2).
|
||||
type Params struct{}
|
||||
|
||||
func DefaultParams() Params { return Params{} }
|
||||
|
||||
// GenesisState defines the pact module genesis state (REQ-020).
|
||||
// Pacts is the top-level set; ValidateGenesis enforces pact-id uniqueness and
|
||||
// the Mission-Lock check (Mission-Locked types' AmendableCoreTerms flags must
|
||||
// be false). The data-engineer's genesis.go holds the schema helpers (G-008).
|
||||
type GenesisState struct {
|
||||
Params Params `json:"params" yaml:"params"`
|
||||
Pacts []Pact `json:"pacts" yaml:"pacts"`
|
||||
}
|
||||
|
||||
func DefaultGenesisState() *GenesisState {
|
||||
return &GenesisState{
|
||||
Params: DefaultParams(),
|
||||
Pacts: []Pact{},
|
||||
}
|
||||
}
|
||||
|
||||
// ValidateGenesis performs ID-uniqueness checks (A-212 upgrade from v0.1
|
||||
// no-op): rejects duplicate pact-ids, and runs the Mission-Lock check on
|
||||
// genesis Pacts. 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("pact: invalid genesis: %w", err)
|
||||
}
|
||||
if err := ValidatePacts(gs.Pacts); err != nil {
|
||||
return fmt.Errorf("pact: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,449 @@
|
||||
package types_test
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"runtime"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/oy/openyield/lexicon"
|
||||
"github.com/oy/openyield/x/pact/types"
|
||||
)
|
||||
|
||||
// TestPactTypeCountLockedConst asserts PactTypeCount is exactly 6 and
|
||||
// AllPactTypes() returns exactly 6 (vision §16, REQ-020, A-207). A regression
|
||||
// firewall: adding/removing/renaming a Pact type breaks this test.
|
||||
func TestPactTypeCountLockedConst(t *testing.T) {
|
||||
if types.PactTypeCount != 6 {
|
||||
t.Errorf("PactTypeCount = %d, expected 6 (vision §16 LOCKED)", types.PactTypeCount)
|
||||
}
|
||||
all := types.AllPactTypes()
|
||||
if len(all) != 6 {
|
||||
t.Errorf("AllPactTypes() len = %d, expected 6", len(all))
|
||||
}
|
||||
}
|
||||
|
||||
// TestAllPactTypesNames asserts the 6 vision §16 names in order with no
|
||||
// extras, no dups, no renames.
|
||||
func TestAllPactTypesNames(t *testing.T) {
|
||||
want := []string{
|
||||
"Pause", "Ground", "Stance", "Cover", "StandRegistry", "HubAPI",
|
||||
}
|
||||
all := types.AllPactTypes()
|
||||
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("AllPactTypes()[%d] = %q, want %q", i, s, want[i])
|
||||
}
|
||||
if seen[string(s)] {
|
||||
t.Errorf("duplicate PactType %q", s)
|
||||
}
|
||||
seen[string(s)] = true
|
||||
}
|
||||
}
|
||||
|
||||
// TestPactTypeValues asserts each named const matches its AllPactTypes entry.
|
||||
func TestPactTypeValues(t *testing.T) {
|
||||
if types.PactPause != "Pause" {
|
||||
t.Errorf("PactPause = %q", types.PactPause)
|
||||
}
|
||||
if types.PactGround != "Ground" {
|
||||
t.Errorf("PactGround = %q", types.PactGround)
|
||||
}
|
||||
if types.PactStance != "Stance" {
|
||||
t.Errorf("PactStance = %q", types.PactStance)
|
||||
}
|
||||
if types.PactCover != "Cover" {
|
||||
t.Errorf("PactCover = %q", types.PactCover)
|
||||
}
|
||||
if types.PactStandRegistry != "StandRegistry" {
|
||||
t.Errorf("PactStandRegistry = %q", types.PactStandRegistry)
|
||||
}
|
||||
if types.PactHubAPI != "HubAPI" {
|
||||
t.Errorf("PactHubAPI = %q", types.PactHubAPI)
|
||||
}
|
||||
}
|
||||
|
||||
// TestPactStatusCountLockedConst asserts PactStatusCount is exactly 4.
|
||||
func TestPactStatusCountLockedConst(t *testing.T) {
|
||||
if types.PactStatusCount != 4 {
|
||||
t.Errorf("PactStatusCount = %d, expected 4", types.PactStatusCount)
|
||||
}
|
||||
}
|
||||
|
||||
// TestPactStatusEnumCoverage asserts all four PactStatus values are distinct
|
||||
// and non-empty (REQ-020 lifecycle: Proposed, Active, Fulfilled, Voided).
|
||||
func TestPactStatusEnumCoverage(t *testing.T) {
|
||||
statuses := []types.PactStatus{
|
||||
types.StatusProposed, types.StatusActive,
|
||||
types.StatusFulfilled, types.StatusVoided,
|
||||
}
|
||||
if len(statuses) != 4 {
|
||||
t.Errorf("expected 4 PactStatus consts, got %d", len(statuses))
|
||||
}
|
||||
seen := map[types.PactStatus]bool{}
|
||||
for _, s := range statuses {
|
||||
if s == "" {
|
||||
t.Error("empty PactStatus")
|
||||
}
|
||||
if seen[s] {
|
||||
t.Errorf("duplicate PactStatus %q", s)
|
||||
}
|
||||
seen[s] = true
|
||||
}
|
||||
}
|
||||
|
||||
// TestMissionLockAmendableConstFalse asserts the global Mission-Lock const
|
||||
// is false (vision §19): the Mission Lock can NEVER be amended. This is the
|
||||
// highest-severity regression firewall for the pact module.
|
||||
func TestMissionLockAmendableConstFalse(t *testing.T) {
|
||||
if types.MissionLockAmendable != false {
|
||||
t.Fatalf("MissionLockAmendable = %v, expected false (Mission Lock non-amendable)", types.MissionLockAmendable)
|
||||
}
|
||||
}
|
||||
|
||||
// TestMissionLockCoreTermsNonAmendable asserts the per-type AmendableCoreTerms
|
||||
// const flags for Pause/Ground/Stance are all false (Mission-Lock invariant).
|
||||
// Cover/StandRegistry/HubAPI return true (amendable — not Mission-Locked).
|
||||
func TestMissionLockCoreTermsNonAmendable(t *testing.T) {
|
||||
// Pause/Ground/Stance core terms MUST be non-amendable.
|
||||
if types.AmendableCoreTermsPause != false {
|
||||
t.Error("AmendableCoreTermsPause must be false (Mission Lock)")
|
||||
}
|
||||
if types.AmendableCoreTermsGround != false {
|
||||
t.Error("AmendableCoreTermsGround must be false (Mission Lock)")
|
||||
}
|
||||
if types.AmendableCoreTermsStance != false {
|
||||
t.Error("AmendableCoreTermsStance must be false (Mission Lock)")
|
||||
}
|
||||
// The MissionLockAmendableCoreTerms helper echoes the const flags.
|
||||
locked := []types.PactType{types.PactPause, types.PactGround, types.PactStance}
|
||||
for _, pt := range locked {
|
||||
if types.MissionLockAmendableCoreTerms(pt) != false {
|
||||
t.Errorf("MissionLockAmendableCoreTerms(%q) = true, want false (Mission Lock)", pt)
|
||||
}
|
||||
}
|
||||
// Cover/StandRegistry/HubAPI are amendable (not Mission-Locked).
|
||||
amendable := []types.PactType{types.PactCover, types.PactStandRegistry, types.PactHubAPI}
|
||||
for _, pt := range amendable {
|
||||
if types.MissionLockAmendableCoreTerms(pt) != true {
|
||||
t.Errorf("MissionLockAmendableCoreTerms(%q) = false, want true (amendable)", pt)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestPactStructFields asserts Pact carries all required fields including
|
||||
// the by-ID-string refs (window-id-ref, stand-id-ref per G-003).
|
||||
func TestPactStructFields(t *testing.T) {
|
||||
p := types.Pact{
|
||||
PactID: "p1",
|
||||
Type: types.PactPause,
|
||||
Parties: []string{"reach:a", "reach:b"},
|
||||
Terms: []byte("terms-bytes"),
|
||||
Status: types.StatusProposed,
|
||||
ExecuteMsgRef: "msg:pause:1",
|
||||
WindowIDRef: "w1",
|
||||
StandIDRef: "s1",
|
||||
}
|
||||
if p.PactID != "p1" || p.Type != types.PactPause || len(p.Parties) != 2 ||
|
||||
string(p.Terms) != "terms-bytes" || p.Status != types.StatusProposed ||
|
||||
p.ExecuteMsgRef != "msg:pause:1" || p.WindowIDRef != "w1" || p.StandIDRef != "s1" {
|
||||
t.Error("Pact fields not set correctly")
|
||||
}
|
||||
}
|
||||
|
||||
// TestPactStructRefsAreStrings asserts window-id-ref and stand-id-ref are
|
||||
// string-typed (G-003 by-ID-string invariant; the G-003 import invariant is
|
||||
// enforced project-wide by P1-01-02's go/parser scan, so this test only
|
||||
// asserts the field types at the struct level, not cross-module imports).
|
||||
func TestPactStructRefsAreStrings(t *testing.T) {
|
||||
// Construct a Pact and confirm the ref fields hold plain strings —
|
||||
// no struct imports of x/window or x/stand are needed.
|
||||
p := types.Pact{WindowIDRef: "window-abc", StandIDRef: "stand-xyz"}
|
||||
if p.WindowIDRef != "window-abc" {
|
||||
t.Errorf("WindowIDRef = %q", p.WindowIDRef)
|
||||
}
|
||||
if p.StandIDRef != "stand-xyz" {
|
||||
t.Errorf("StandIDRef = %q", p.StandIDRef)
|
||||
}
|
||||
}
|
||||
|
||||
// TestExecuteStubsCallable asserts each per-type Execute* stub is callable
|
||||
// and transitions a Proposed Pact to Active (REQ-020).
|
||||
func TestExecuteStubsCallable(t *testing.T) {
|
||||
tt := []struct {
|
||||
name string
|
||||
pact types.Pact
|
||||
execFn func(*types.Pact) error
|
||||
}{
|
||||
{"Pause", types.Pact{PactID: "p1", Type: types.PactPause, Status: types.StatusProposed}, (*types.Pact).ExecutePause},
|
||||
{"Ground", types.Pact{PactID: "p2", Type: types.PactGround, Status: types.StatusProposed}, (*types.Pact).ExecuteGround},
|
||||
{"Stance", types.Pact{PactID: "p3", Type: types.PactStance, Status: types.StatusProposed}, (*types.Pact).ExecuteStance},
|
||||
{"Cover", types.Pact{PactID: "p4", Type: types.PactCover, Status: types.StatusProposed}, (*types.Pact).ExecuteCover},
|
||||
{"HubAPI", types.Pact{PactID: "p6", Type: types.PactHubAPI, Status: types.StatusProposed}, (*types.Pact).ExecuteHubAPI},
|
||||
}
|
||||
for _, tc := range tt {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
p := tc.pact
|
||||
if err := tc.execFn(&p); err != nil {
|
||||
t.Fatalf("Execute%s: %v", tc.name, err)
|
||||
}
|
||||
if p.Status != types.StatusActive {
|
||||
t.Errorf("after Execute%s, status = %q, want Active", tc.name, p.Status)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestExecuteStandRegistryRequiresStandIDRef asserts ExecuteStandRegistry
|
||||
// requires a non-empty stand-id-ref (the by-ID-string ref to x/stand).
|
||||
func TestExecuteStandRegistryRequiresStandIDRef(t *testing.T) {
|
||||
p := types.Pact{PactID: "p5", Type: types.PactStandRegistry, Status: types.StatusProposed, StandIDRef: ""}
|
||||
if err := p.ExecuteStandRegistry(); err == nil {
|
||||
t.Error("ExecuteStandRegistry should error on empty stand-id-ref")
|
||||
}
|
||||
p.StandIDRef = "s1"
|
||||
if err := p.ExecuteStandRegistry(); err != nil {
|
||||
t.Errorf("ExecuteStandRegistry with stand-id-ref: %v", err)
|
||||
}
|
||||
if p.Status != types.StatusActive {
|
||||
t.Errorf("status = %q, want Active", p.Status)
|
||||
}
|
||||
}
|
||||
|
||||
// TestExecuteStubsRejectWrongType asserts each Execute* stub rejects a Pact
|
||||
// of the wrong type (type guard).
|
||||
func TestExecuteStubsRejectWrongType(t *testing.T) {
|
||||
p := types.Pact{PactID: "p", Type: types.PactCover, Status: types.StatusProposed}
|
||||
if err := p.ExecutePause(); err == nil {
|
||||
t.Error("ExecutePause on a Cover pact should error")
|
||||
}
|
||||
if err := p.ExecuteGround(); err == nil {
|
||||
t.Error("ExecuteGround on a Cover pact should error")
|
||||
}
|
||||
if err := p.ExecuteStance(); err == nil {
|
||||
t.Error("ExecuteStance on a Cover pact should error")
|
||||
}
|
||||
if err := p.ExecuteStandRegistry(); err == nil {
|
||||
t.Error("ExecuteStandRegistry on a Cover pact should error")
|
||||
}
|
||||
if err := p.ExecuteHubAPI(); err == nil {
|
||||
t.Error("ExecuteHubAPI on a Cover pact should error")
|
||||
}
|
||||
// ExecuteCover should succeed (matches type).
|
||||
if err := p.ExecuteCover(); err != nil {
|
||||
t.Errorf("ExecuteCover on a Cover pact: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// TestExecuteStubsRejectNonProposed asserts each Execute* stub rejects a
|
||||
// Pact not in the Proposed status.
|
||||
func TestExecuteStubsRejectNonProposed(t *testing.T) {
|
||||
tt := []struct {
|
||||
name string
|
||||
pact types.Pact
|
||||
execFn func(*types.Pact) error
|
||||
}{
|
||||
{"Pause-active", types.Pact{PactID: "p", Type: types.PactPause, Status: types.StatusActive}, (*types.Pact).ExecutePause},
|
||||
{"Ground-fulfilled", types.Pact{PactID: "p", Type: types.PactGround, Status: types.StatusFulfilled}, (*types.Pact).ExecuteGround},
|
||||
{"Stance-voided", types.Pact{PactID: "p", Type: types.PactStance, Status: types.StatusVoided}, (*types.Pact).ExecuteStance},
|
||||
{"Cover-active", types.Pact{PactID: "p", Type: types.PactCover, Status: types.StatusActive}, (*types.Pact).ExecuteCover},
|
||||
{"HubAPI-voided", types.Pact{PactID: "p", Type: types.PactHubAPI, Status: types.StatusVoided}, (*types.Pact).ExecuteHubAPI},
|
||||
}
|
||||
for _, tc := range tt {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
p := tc.pact
|
||||
if err := tc.execFn(&p); err == nil {
|
||||
t.Errorf("Execute%s on %q-status pact should error", tc.name, p.Status)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestExecuteStandRegistryNonProposed asserts ExecuteStandRegistry rejects
|
||||
// a non-Proposed StandRegistry pact even when stand-id-ref is set.
|
||||
func TestExecuteStandRegistryNonProposed(t *testing.T) {
|
||||
p := types.Pact{PactID: "p", Type: types.PactStandRegistry, Status: types.StatusActive, StandIDRef: "s1"}
|
||||
if err := p.ExecuteStandRegistry(); err == nil {
|
||||
t.Error("ExecuteStandRegistry on Active pact should error")
|
||||
}
|
||||
}
|
||||
|
||||
// TestDefaultGenesisStateEmpty asserts DefaultGenesisState returns a
|
||||
// non-nil empty slice for Pacts.
|
||||
func TestDefaultGenesisStateEmpty(t *testing.T) {
|
||||
gs := types.DefaultGenesisState()
|
||||
if gs == nil {
|
||||
t.Fatal("DefaultGenesisState returned nil")
|
||||
}
|
||||
if gs.Pacts == nil || len(gs.Pacts) != 0 {
|
||||
t.Errorf("Default Pacts should be non-nil empty slice; got len=%d nil=%v", len(gs.Pacts), gs.Pacts == nil)
|
||||
}
|
||||
}
|
||||
|
||||
// TestValidateGenesisRejectsDupPactIDs asserts A-212: duplicate pact-ids
|
||||
// are rejected (upgrade from v0.1's no-op ValidateGenesis).
|
||||
func TestValidateGenesisRejectsDupPactIDs(t *testing.T) {
|
||||
gs := types.GenesisState{
|
||||
Pacts: []types.Pact{
|
||||
{PactID: "p1", Type: types.PactPause},
|
||||
{PactID: "p1", Type: types.PactGround}, // dup
|
||||
},
|
||||
}
|
||||
bz, _ := json.Marshal(gs)
|
||||
if err := types.ValidateGenesis(bz); err == nil {
|
||||
t.Error("ValidateGenesis should reject duplicate pact-ids")
|
||||
}
|
||||
}
|
||||
|
||||
// TestValidateGenesisRejectsEmptyPactID asserts empty pact-id is rejected.
|
||||
func TestValidateGenesisRejectsEmptyPactID(t *testing.T) {
|
||||
gs := types.GenesisState{
|
||||
Pacts: []types.Pact{{PactID: "", Type: types.PactPause}},
|
||||
}
|
||||
bz, _ := json.Marshal(gs)
|
||||
if err := types.ValidateGenesis(bz); err == nil {
|
||||
t.Error("ValidateGenesis should reject empty pact-id")
|
||||
}
|
||||
}
|
||||
|
||||
// TestValidateGenesisRejectsUnknownType asserts an unknown PactType is
|
||||
// rejected (data-engineer schema validation).
|
||||
func TestValidateGenesisRejectsUnknownType(t *testing.T) {
|
||||
gs := types.GenesisState{
|
||||
Pacts: []types.Pact{{PactID: "p1", Type: types.PactType("Bogus")}},
|
||||
}
|
||||
bz, _ := json.Marshal(gs)
|
||||
if err := types.ValidateGenesis(bz); err == nil {
|
||||
t.Error("ValidateGenesis should reject unknown pact type")
|
||||
}
|
||||
}
|
||||
|
||||
// TestValidateGenesisRejectsBadJSON asserts malformed JSON is rejected.
|
||||
func TestValidateGenesisRejectsBadJSON(t *testing.T) {
|
||||
if err := types.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 := types.GenesisState{
|
||||
Pacts: []types.Pact{
|
||||
{PactID: "p1", Type: types.PactPause, Status: types.StatusProposed},
|
||||
{PactID: "p2", Type: types.PactCover, Status: types.StatusActive},
|
||||
},
|
||||
}
|
||||
bz, _ := json.Marshal(gs)
|
||||
if err := types.ValidateGenesis(bz); err != nil {
|
||||
t.Errorf("ValidateGenesis should accept clean genesis, got: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// TestMissionLockCheckIsNoOp asserts the genesis-side MissionLockCheck helper
|
||||
// is a no-op (the const flags are the true firewall). It must return nil for
|
||||
// any slice of Pacts — the Mission Lock is enforced at compile time by the
|
||||
// const bools, not at genesis load.
|
||||
func TestMissionLockCheckIsNoOp(t *testing.T) {
|
||||
pacts := []types.Pact{
|
||||
{PactID: "p1", Type: types.PactPause},
|
||||
{PactID: "p2", Type: types.PactGround},
|
||||
{PactID: "p3", Type: types.PactStance},
|
||||
{PactID: "p4", Type: types.PactCover},
|
||||
}
|
||||
if err := types.MissionLockCheck(pacts); err != nil {
|
||||
t.Errorf("MissionLockCheck should be a no-op (const flags are the firewall), got: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// TestModuleConsts asserts the four Cosmos-convention module consts.
|
||||
func TestModuleConsts(t *testing.T) {
|
||||
if types.ModuleName != "pact" {
|
||||
t.Errorf("ModuleName = %q", types.ModuleName)
|
||||
}
|
||||
if types.StoreKey != "pact" {
|
||||
t.Errorf("StoreKey = %q", types.StoreKey)
|
||||
}
|
||||
if types.RouterKey != "pact" {
|
||||
t.Errorf("RouterKey = %q", types.RouterKey)
|
||||
}
|
||||
if types.QuerierRoute != "pact" {
|
||||
t.Errorf("QuerierRoute = %q", types.QuerierRoute)
|
||||
}
|
||||
}
|
||||
|
||||
// TestDefaultParams asserts DefaultParams returns a zero-value Params.
|
||||
func TestDefaultParams(t *testing.T) {
|
||||
_ = types.DefaultParams() // no panics
|
||||
}
|
||||
|
||||
// --- Lexicon assertion (REQ-012) -------------------------------------------------
|
||||
|
||||
// TestLexiconNoBannedTermsInPactPackage scans every non-test .go file in
|
||||
// the pact/types package directory for the 9 banned terms (case-insensitive).
|
||||
// Production files only — the test file references banned terms via the
|
||||
// lexicon package helpers (standard lexicon-test bootstrapping pattern;
|
||||
// no banned literals are inlined in this test file).
|
||||
func TestLexiconNoBannedTermsInPactPackage(t *testing.T) {
|
||||
pkgDir := packageDir(t, "github.com/oy/openyield/x/pact/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 pact/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)", filepath.Base(f), found)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestLexiconNoBannedTermsInPactTestFile 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).
|
||||
// This is the self-bootstrapping check.
|
||||
func TestLexiconNoBannedTermsInPactTestFile(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("pact 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.2 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/pact/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)
|
||||
}
|
||||
@@ -0,0 +1,242 @@
|
||||
package types
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"sync"
|
||||
)
|
||||
|
||||
const (
|
||||
ModuleName = "partner"
|
||||
StoreKey = ModuleName
|
||||
RouterKey = ModuleName
|
||||
QuerierRoute = ModuleName
|
||||
|
||||
// PartnerTierCount is the locked count of PartnerTier enum values
|
||||
// (vision §13, D-026). A regression firewall: adding/removing/renaming a
|
||||
// tier breaks this const's test (REQ-018).
|
||||
PartnerTierCount = 4
|
||||
)
|
||||
|
||||
// PartnerTier enumerates the four partner tiers (vision §13, REQ-018, D-026).
|
||||
// Op processes Pass-Acts; MasterOp is a senior Op; Pier is a credential /
|
||||
// identity provider (e-Residency/biometrics); Anchor is institutional.
|
||||
// "Op" is used (not "operator" — the latter implies a banned financial term
|
||||
// per RESEARCH §1.6; "Op" is vision-§13 lexicon-clean).
|
||||
type PartnerTier string
|
||||
|
||||
const (
|
||||
TierOp PartnerTier = "Op" // processes Pass-Acts
|
||||
TierMasterOp PartnerTier = "MasterOp" // senior Op
|
||||
TierPier PartnerTier = "Pier" // credential / identity provider
|
||||
TierAnchor PartnerTier = "Anchor" // institutional
|
||||
)
|
||||
|
||||
// AllPartnerTiers returns all four PartnerTier values in vision §13 order.
|
||||
// Locked-const test asserts exactly 4 entries with these names (REQ-018).
|
||||
func AllPartnerTiers() []PartnerTier {
|
||||
return []PartnerTier{
|
||||
TierOp,
|
||||
TierMasterOp,
|
||||
TierPier,
|
||||
TierAnchor,
|
||||
}
|
||||
}
|
||||
|
||||
// PartnerStatus enumerates the lifecycle states of a Partner (REQ-018).
|
||||
type PartnerStatus string
|
||||
|
||||
const (
|
||||
StatusPending PartnerStatus = "Pending" // partner registered, not yet active
|
||||
StatusActive PartnerStatus = "Active" // partner is live
|
||||
StatusSuspended PartnerStatus = "Suspended" // partner temporarily halted
|
||||
StatusRevoked PartnerStatus = "Revoked" // partner permanently revoked
|
||||
)
|
||||
|
||||
// PartnerStatusCount is the locked count of PartnerStatus enum values.
|
||||
const PartnerStatusCount = 4
|
||||
|
||||
// CredentialType enumerates the kinds of credentials a Pier can reference
|
||||
// (REQ-018). The ref-uri is opaque; Pier credential routing is deferred per
|
||||
// Q5 (v0.3 will wire the live routing). The skeleton defines the type enum
|
||||
// so genesis / registry entries carry a typed credential kind.
|
||||
type CredentialType string
|
||||
|
||||
const (
|
||||
CredentialEresidency CredentialType = "Eresidency" // e-Residency-style identity
|
||||
CredentialBiometric CredentialType = "Biometric" // biometric identity
|
||||
CredentialVouch CredentialType = "Vouch" // vouch-based attestation
|
||||
CredentialCustom CredentialType = "Custom" // opaque custom credential
|
||||
)
|
||||
|
||||
// CredentialRef references an external credential provider (REQ-018).
|
||||
// provider-id references a Partner (typically a Pier) by ID string
|
||||
// (G-003 by-ID-string invariant). ref-uri is an opaque URI; Pier credential
|
||||
// routing is deferred per Q5, so the skeleton keeps the ref opaque.
|
||||
type CredentialRef struct {
|
||||
ProviderID string `json:"provider_id" yaml:"provider_id"`
|
||||
CredentialType CredentialType `json:"credential_type" yaml:"credential_type"`
|
||||
RefURI string `json:"ref_uri" yaml:"ref_uri"`
|
||||
}
|
||||
|
||||
// Partner is a registered actor on the Partner Spectrum (vision §13, REQ-018).
|
||||
// reach-id references x/identity Reach by string (G-003 by-ID-string
|
||||
// invariant). credential-ref references a credential provider (typically a
|
||||
// Pier) by ID string. region is a free-form locale tag.
|
||||
type Partner struct {
|
||||
PartnerID string `json:"partner_id" yaml:"partner_id"`
|
||||
Tier PartnerTier `json:"tier" yaml:"tier"`
|
||||
Name string `json:"name" yaml:"name"`
|
||||
ReachID string `json:"reach_id" yaml:"reach_id"`
|
||||
Region string `json:"region" yaml:"region"`
|
||||
CredentialRef CredentialRef `json:"credential_ref" yaml:"credential_ref"`
|
||||
Status PartnerStatus `json:"status" yaml:"status"`
|
||||
}
|
||||
|
||||
// Keeper is a registry keeper stub for Partners (REQ-018). The skeleton
|
||||
// provides in-memory add/get/list/by-tier operations; v0.3 wires the live
|
||||
// keeper backed by the store. It is safe for concurrent use (the live keeper
|
||||
// will use the SDK store, which is single-threaded per-block; the stub uses
|
||||
// a mutex so the skeleton's tests can exercise concurrent paths).
|
||||
type Keeper struct {
|
||||
mu sync.Mutex
|
||||
partners map[string]Partner
|
||||
}
|
||||
|
||||
// NewKeeper returns an empty registry keeper stub.
|
||||
func NewKeeper() *Keeper {
|
||||
return &Keeper{partners: make(map[string]Partner)}
|
||||
}
|
||||
|
||||
// AddPartner registers a Partner by ID. Returns an error if the ID is empty
|
||||
// or already registered.
|
||||
func (k *Keeper) AddPartner(p Partner) error {
|
||||
if p.PartnerID == "" {
|
||||
return fmt.Errorf("partner: empty partner-id")
|
||||
}
|
||||
k.mu.Lock()
|
||||
defer k.mu.Unlock()
|
||||
if _, exists := k.partners[p.PartnerID]; exists {
|
||||
return fmt.Errorf("partner: duplicate partner-id %q", p.PartnerID)
|
||||
}
|
||||
k.partners[p.PartnerID] = p
|
||||
return nil
|
||||
}
|
||||
|
||||
// GetPartner returns a Partner by ID and true, or zero-value and false.
|
||||
func (k *Keeper) GetPartner(id string) (Partner, bool) {
|
||||
k.mu.Lock()
|
||||
defer k.mu.Unlock()
|
||||
p, ok := k.partners[id]
|
||||
return p, ok
|
||||
}
|
||||
|
||||
// ListPartners returns all registered Partners (unordered).
|
||||
func (k *Keeper) ListPartners() []Partner {
|
||||
k.mu.Lock()
|
||||
defer k.mu.Unlock()
|
||||
out := make([]Partner, 0, len(k.partners))
|
||||
for _, p := range k.partners {
|
||||
out = append(out, p)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// ListByTier returns all registered Partners matching the given tier.
|
||||
func (k *Keeper) ListByTier(tier PartnerTier) []Partner {
|
||||
k.mu.Lock()
|
||||
defer k.mu.Unlock()
|
||||
out := []Partner{}
|
||||
for _, p := range k.partners {
|
||||
if p.Tier == tier {
|
||||
out = append(out, p)
|
||||
}
|
||||
}
|
||||
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).
|
||||
type Params struct{}
|
||||
|
||||
func DefaultParams() Params { return Params{} }
|
||||
|
||||
// GenesisState defines the partner module genesis state (REQ-018).
|
||||
// Partners is the top-level set; ValidateGenesis enforces partner-id uniqueness.
|
||||
type GenesisState struct {
|
||||
Params Params `json:"params" yaml:"params"`
|
||||
Partners []Partner `json:"partners" yaml:"partners"`
|
||||
}
|
||||
|
||||
func DefaultGenesisState() *GenesisState {
|
||||
return &GenesisState{
|
||||
Params: DefaultParams(),
|
||||
Partners: []Partner{},
|
||||
}
|
||||
}
|
||||
|
||||
// ValidateGenesis performs ID-uniqueness checks (A-212 upgrade from v0.1
|
||||
// no-op): rejects duplicate partner-ids.
|
||||
func ValidateGenesis(bz json.RawMessage) error {
|
||||
var gs GenesisState
|
||||
if err := json.Unmarshal(bz, &gs); err != nil {
|
||||
return fmt.Errorf("partner: invalid genesis: %w", err)
|
||||
}
|
||||
seen := make(map[string]bool, len(gs.Partners))
|
||||
for _, p := range gs.Partners {
|
||||
if p.PartnerID == "" {
|
||||
return fmt.Errorf("partner: empty partner-id")
|
||||
}
|
||||
if seen[p.PartnerID] {
|
||||
return fmt.Errorf("partner: duplicate partner-id %q", p.PartnerID)
|
||||
}
|
||||
seen[p.PartnerID] = true
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,571 @@
|
||||
package types_test
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"runtime"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/oy/openyield/lexicon"
|
||||
"github.com/oy/openyield/x/partner/types"
|
||||
)
|
||||
|
||||
// TestPartnerTierCountLockedConst asserts PartnerTierCount is exactly 4
|
||||
// and AllPartnerTiers() returns exactly 4 (vision §13, REQ-018, D-026). A
|
||||
// regression firewall: adding/removing/renaming a tier breaks this test.
|
||||
func TestPartnerTierCountLockedConst(t *testing.T) {
|
||||
if types.PartnerTierCount != 4 {
|
||||
t.Errorf("PartnerTierCount = %d, expected 4 (vision §13 LOCKED)", types.PartnerTierCount)
|
||||
}
|
||||
all := types.AllPartnerTiers()
|
||||
if len(all) != 4 {
|
||||
t.Errorf("AllPartnerTiers() len = %d, expected 4", len(all))
|
||||
}
|
||||
}
|
||||
|
||||
// TestAllPartnerTiersNames asserts the 4 vision §13 names in order with no
|
||||
// extras, no dups, no renames. "Op" (not "operator") per vision §13 — the
|
||||
// latter implies a banned financial term per RESEARCH §1.6; "Op" is
|
||||
// lexicon-clean.
|
||||
func TestAllPartnerTiersNames(t *testing.T) {
|
||||
want := []string{"Op", "MasterOp", "Pier", "Anchor"}
|
||||
all := types.AllPartnerTiers()
|
||||
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("AllPartnerTiers()[%d] = %q, want %q", i, s, want[i])
|
||||
}
|
||||
if seen[string(s)] {
|
||||
t.Errorf("duplicate PartnerTier %q", s)
|
||||
}
|
||||
seen[string(s)] = true
|
||||
}
|
||||
}
|
||||
|
||||
// TestPartnerTierValues asserts each named const matches its AllPartnerTiers
|
||||
// entry.
|
||||
func TestPartnerTierValues(t *testing.T) {
|
||||
if types.TierOp != "Op" {
|
||||
t.Errorf("TierOp = %q", types.TierOp)
|
||||
}
|
||||
if types.TierMasterOp != "MasterOp" {
|
||||
t.Errorf("TierMasterOp = %q", types.TierMasterOp)
|
||||
}
|
||||
if types.TierPier != "Pier" {
|
||||
t.Errorf("TierPier = %q", types.TierPier)
|
||||
}
|
||||
if types.TierAnchor != "Anchor" {
|
||||
t.Errorf("TierAnchor = %q", types.TierAnchor)
|
||||
}
|
||||
}
|
||||
|
||||
// TestPartnerStatusCountLockedConst asserts PartnerStatusCount is exactly 4.
|
||||
func TestPartnerStatusCountLockedConst(t *testing.T) {
|
||||
if types.PartnerStatusCount != 4 {
|
||||
t.Errorf("PartnerStatusCount = %d, expected 4", types.PartnerStatusCount)
|
||||
}
|
||||
}
|
||||
|
||||
// TestPartnerStatusEnumCoverage asserts all four PartnerStatus values are
|
||||
// distinct and non-empty (REQ-018 lifecycle: Pending, Active, Suspended, Revoked).
|
||||
func TestPartnerStatusEnumCoverage(t *testing.T) {
|
||||
statuses := []types.PartnerStatus{
|
||||
types.StatusPending, types.StatusActive,
|
||||
types.StatusSuspended, types.StatusRevoked,
|
||||
}
|
||||
if len(statuses) != 4 {
|
||||
t.Errorf("expected 4 PartnerStatus consts, got %d", len(statuses))
|
||||
}
|
||||
seen := map[types.PartnerStatus]bool{}
|
||||
for _, s := range statuses {
|
||||
if s == "" {
|
||||
t.Error("empty PartnerStatus")
|
||||
}
|
||||
if seen[s] {
|
||||
t.Errorf("duplicate PartnerStatus %q", s)
|
||||
}
|
||||
seen[s] = true
|
||||
}
|
||||
}
|
||||
|
||||
// TestCredentialTypeEnumCoverage asserts the CredentialType values are
|
||||
// distinct and non-empty (Pier credential routing deferred per Q5; the
|
||||
// enum is the typed kind for genesis / registry entries).
|
||||
func TestCredentialTypeEnumCoverage(t *testing.T) {
|
||||
cts := []types.CredentialType{
|
||||
types.CredentialEresidency, types.CredentialBiometric,
|
||||
types.CredentialVouch, types.CredentialCustom,
|
||||
}
|
||||
if len(cts) != 4 {
|
||||
t.Errorf("expected 4 CredentialType consts, got %d", len(cts))
|
||||
}
|
||||
seen := map[types.CredentialType]bool{}
|
||||
for _, c := range cts {
|
||||
if c == "" {
|
||||
t.Error("empty CredentialType")
|
||||
}
|
||||
if seen[c] {
|
||||
t.Errorf("duplicate CredentialType %q", c)
|
||||
}
|
||||
seen[c] = true
|
||||
}
|
||||
}
|
||||
|
||||
// TestCredentialRefStruct asserts CredentialRef carries all required fields
|
||||
// (provider-id, credential-type, ref-uri — opaque URI).
|
||||
func TestCredentialRefStruct(t *testing.T) {
|
||||
c := types.CredentialRef{
|
||||
ProviderID: "pier-1",
|
||||
CredentialType: types.CredentialEresidency,
|
||||
RefURI: "oy:cred:pier-1/eresidency/abc123",
|
||||
}
|
||||
if c.ProviderID != "pier-1" || c.CredentialType != types.CredentialEresidency ||
|
||||
c.RefURI != "oy:cred:pier-1/eresidency/abc123" {
|
||||
t.Error("CredentialRef fields not set correctly")
|
||||
}
|
||||
}
|
||||
|
||||
// TestPartnerStructFields asserts Partner carries all required fields
|
||||
// including the by-ID-string reach-id (G-003).
|
||||
func TestPartnerStructFields(t *testing.T) {
|
||||
p := types.Partner{
|
||||
PartnerID: "pt1",
|
||||
Tier: types.TierPier,
|
||||
Name: "Pier One",
|
||||
ReachID: "reach:pier-1",
|
||||
Region: "EU",
|
||||
CredentialRef: types.CredentialRef{
|
||||
ProviderID: "pier-1",
|
||||
CredentialType: types.CredentialBiometric,
|
||||
RefURI: "oy:cred:bio/x",
|
||||
},
|
||||
Status: types.StatusActive,
|
||||
}
|
||||
if p.PartnerID != "pt1" || p.Tier != types.TierPier || p.Name != "Pier One" ||
|
||||
p.ReachID != "reach:pier-1" || p.Region != "EU" ||
|
||||
p.CredentialRef.ProviderID != "pier-1" || p.Status != types.StatusActive {
|
||||
t.Error("Partner fields not set correctly")
|
||||
}
|
||||
}
|
||||
|
||||
// --- Registry keeper stub tests (REQ-018) ---------------------------------------
|
||||
|
||||
// TestKeeperAddGetRoundTrip asserts AddPartner + GetPartner round-trips a
|
||||
// Partner by ID.
|
||||
func TestKeeperAddGetRoundTrip(t *testing.T) {
|
||||
k := types.NewKeeper()
|
||||
p := types.Partner{
|
||||
PartnerID: "pt1",
|
||||
Tier: types.TierOp,
|
||||
Name: "Op One",
|
||||
ReachID: "reach:op-1",
|
||||
Status: types.StatusActive,
|
||||
}
|
||||
if err := k.AddPartner(p); err != nil {
|
||||
t.Fatalf("AddPartner: %v", err)
|
||||
}
|
||||
got, ok := k.GetPartner("pt1")
|
||||
if !ok {
|
||||
t.Fatal("GetPartner: not found")
|
||||
}
|
||||
if got.PartnerID != "pt1" || got.Tier != types.TierOp {
|
||||
t.Errorf("GetPartner returned wrong Partner: %+v", got)
|
||||
}
|
||||
}
|
||||
|
||||
// TestKeeperAddPartnerRejectsEmptyID asserts AddPartner rejects an empty id.
|
||||
func TestKeeperAddPartnerRejectsEmptyID(t *testing.T) {
|
||||
k := types.NewKeeper()
|
||||
if err := k.AddPartner(types.Partner{PartnerID: ""}); err == nil {
|
||||
t.Error("AddPartner should reject empty partner-id")
|
||||
}
|
||||
}
|
||||
|
||||
// TestKeeperAddPartnerRejectsDup asserts AddPartner rejects a duplicate id.
|
||||
func TestKeeperAddPartnerRejectsDup(t *testing.T) {
|
||||
k := types.NewKeeper()
|
||||
p := types.Partner{PartnerID: "pt1", Tier: types.TierOp}
|
||||
if err := k.AddPartner(p); err != nil {
|
||||
t.Fatalf("first AddPartner: %v", err)
|
||||
}
|
||||
if err := k.AddPartner(p); err == nil {
|
||||
t.Error("AddPartner should reject duplicate partner-id")
|
||||
}
|
||||
}
|
||||
|
||||
// TestKeeperGetPartnerMissing asserts GetPartner returns false for an
|
||||
// unregistered id.
|
||||
func TestKeeperGetPartnerMissing(t *testing.T) {
|
||||
k := types.NewKeeper()
|
||||
if _, ok := k.GetPartner("nope"); ok {
|
||||
t.Error("GetPartner should return false for unregistered id")
|
||||
}
|
||||
}
|
||||
|
||||
// TestKeeperListPartners asserts ListPartners returns all registered Partners.
|
||||
func TestKeeperListPartners(t *testing.T) {
|
||||
k := types.NewKeeper()
|
||||
_ = k.AddPartner(types.Partner{PartnerID: "a", Tier: types.TierOp})
|
||||
_ = k.AddPartner(types.Partner{PartnerID: "b", Tier: types.TierAnchor})
|
||||
list := k.ListPartners()
|
||||
if len(list) != 2 {
|
||||
t.Errorf("ListPartners len = %d, want 2", len(list))
|
||||
}
|
||||
}
|
||||
|
||||
// TestKeeperListPartnersEmpty asserts ListPartners on an empty keeper returns
|
||||
// a non-nil empty slice (or a usable slice).
|
||||
func TestKeeperListPartnersEmpty(t *testing.T) {
|
||||
k := types.NewKeeper()
|
||||
list := k.ListPartners()
|
||||
if list == nil {
|
||||
t.Fatal("ListPartners returned nil")
|
||||
}
|
||||
if len(list) != 0 {
|
||||
t.Errorf("ListPartners len = %d, want 0", len(list))
|
||||
}
|
||||
}
|
||||
|
||||
// TestKeeperListByTier asserts ListByTier returns only Partners matching the
|
||||
// given tier (REQ-018 round-trip).
|
||||
func TestKeeperListByTier(t *testing.T) {
|
||||
k := types.NewKeeper()
|
||||
_ = k.AddPartner(types.Partner{PartnerID: "op1", Tier: types.TierOp})
|
||||
_ = k.AddPartner(types.Partner{PartnerID: "op2", Tier: types.TierOp})
|
||||
_ = k.AddPartner(types.Partner{PartnerID: "mop1", Tier: types.TierMasterOp})
|
||||
_ = k.AddPartner(types.Partner{PartnerID: "pier1", Tier: types.TierPier})
|
||||
_ = k.AddPartner(types.Partner{PartnerID: "anc1", Tier: types.TierAnchor})
|
||||
|
||||
tt := []struct {
|
||||
tier types.PartnerTier
|
||||
wantN int
|
||||
}{
|
||||
{types.TierOp, 2},
|
||||
{types.TierMasterOp, 1},
|
||||
{types.TierPier, 1},
|
||||
{types.TierAnchor, 1},
|
||||
}
|
||||
for _, tc := range tt {
|
||||
t.Run(string(tc.tier), func(t *testing.T) {
|
||||
got := k.ListByTier(tc.tier)
|
||||
if len(got) != tc.wantN {
|
||||
t.Errorf("ListByTier(%q) len = %d, want %d", tc.tier, len(got), tc.wantN)
|
||||
}
|
||||
for _, p := range got {
|
||||
if p.Tier != tc.tier {
|
||||
t.Errorf("ListByTier(%q) returned Partner with tier %q", tc.tier, p.Tier)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestKeeperListByTierEmpty asserts ListByTier returns an empty (non-nil)
|
||||
// slice when no Partners match.
|
||||
func TestKeeperListByTierEmpty(t *testing.T) {
|
||||
k := types.NewKeeper()
|
||||
got := k.ListByTier(types.TierAnchor)
|
||||
if got == nil {
|
||||
t.Fatal("ListByTier returned nil")
|
||||
}
|
||||
if len(got) != 0 {
|
||||
t.Errorf("ListByTier len = %d, want 0", len(got))
|
||||
}
|
||||
}
|
||||
|
||||
// --- Genesis tests (REQ-018, A-212) ----------------------------------------------
|
||||
|
||||
// TestDefaultGenesisStateEmpty asserts DefaultGenesisState returns a non-nil
|
||||
// empty slice for Partners.
|
||||
func TestDefaultGenesisStateEmpty(t *testing.T) {
|
||||
gs := types.DefaultGenesisState()
|
||||
if gs == nil {
|
||||
t.Fatal("DefaultGenesisState returned nil")
|
||||
}
|
||||
if gs.Partners == nil || len(gs.Partners) != 0 {
|
||||
t.Errorf("Default Partners should be non-nil empty slice; got len=%d nil=%v", len(gs.Partners), gs.Partners == nil)
|
||||
}
|
||||
}
|
||||
|
||||
// TestValidateGenesisRejectsDupPartnerIDs asserts A-212: duplicate partner-ids
|
||||
// are rejected.
|
||||
func TestValidateGenesisRejectsDupPartnerIDs(t *testing.T) {
|
||||
gs := types.GenesisState{
|
||||
Partners: []types.Partner{
|
||||
{PartnerID: "pt1", Tier: types.TierOp},
|
||||
{PartnerID: "pt1", Tier: types.TierAnchor}, // dup
|
||||
},
|
||||
}
|
||||
bz, _ := json.Marshal(gs)
|
||||
if err := types.ValidateGenesis(bz); err == nil {
|
||||
t.Error("ValidateGenesis should reject duplicate partner-ids")
|
||||
}
|
||||
}
|
||||
|
||||
// TestValidateGenesisRejectsEmptyPartnerID asserts empty partner-id is rejected.
|
||||
func TestValidateGenesisRejectsEmptyPartnerID(t *testing.T) {
|
||||
gs := types.GenesisState{
|
||||
Partners: []types.Partner{{PartnerID: "", Tier: types.TierOp}},
|
||||
}
|
||||
bz, _ := json.Marshal(gs)
|
||||
if err := types.ValidateGenesis(bz); err == nil {
|
||||
t.Error("ValidateGenesis should reject empty partner-id")
|
||||
}
|
||||
}
|
||||
|
||||
// TestValidateGenesisRejectsBadJSON asserts malformed JSON is rejected.
|
||||
func TestValidateGenesisRejectsBadJSON(t *testing.T) {
|
||||
if err := types.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 := types.GenesisState{
|
||||
Partners: []types.Partner{
|
||||
{PartnerID: "pt1", Tier: types.TierOp, Status: types.StatusActive},
|
||||
{PartnerID: "pt2", Tier: types.TierPier, Status: types.StatusPending},
|
||||
},
|
||||
}
|
||||
bz, _ := json.Marshal(gs)
|
||||
if err := types.ValidateGenesis(bz); err != nil {
|
||||
t.Errorf("ValidateGenesis should accept clean genesis, got: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// TestModuleConsts asserts the four Cosmos-convention module consts.
|
||||
func TestModuleConsts(t *testing.T) {
|
||||
if types.ModuleName != "partner" {
|
||||
t.Errorf("ModuleName = %q", types.ModuleName)
|
||||
}
|
||||
if types.StoreKey != "partner" {
|
||||
t.Errorf("StoreKey = %q", types.StoreKey)
|
||||
}
|
||||
if types.RouterKey != "partner" {
|
||||
t.Errorf("RouterKey = %q", types.RouterKey)
|
||||
}
|
||||
if types.QuerierRoute != "partner" {
|
||||
t.Errorf("QuerierRoute = %q", types.QuerierRoute)
|
||||
}
|
||||
}
|
||||
|
||||
// TestDefaultParams asserts DefaultParams returns a zero-value Params.
|
||||
func TestDefaultParams(t *testing.T) {
|
||||
_ = types.DefaultParams() // no panics
|
||||
}
|
||||
|
||||
// --- Lexicon assertion (REQ-012) -------------------------------------------------
|
||||
|
||||
// TestLexiconNoBannedTermsInPartnerPackage scans every non-test .go file in
|
||||
// the partner/types package directory for the 9 banned terms (case-insensitive).
|
||||
// Production files only — the test file references banned terms via the
|
||||
// lexicon package helpers (standard lexicon-test bootstrapping pattern; no
|
||||
// banned literals are inlined in this test file).
|
||||
func TestLexiconNoBannedTermsInPartnerPackage(t *testing.T) {
|
||||
pkgDir := packageDir(t, "github.com/oy/openyield/x/partner/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 partner/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)", filepath.Base(f), found)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestLexiconNoBannedTermsInPartnerTestFile 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 TestLexiconNoBannedTermsInPartnerTestFile(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("partner test file contains banned term %q — use lexicon helpers, not literals", found)
|
||||
}
|
||||
}
|
||||
|
||||
// --- 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
|
||||
// walking up from this test file (v0.2 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/partner/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)
|
||||
}
|
||||
@@ -0,0 +1,85 @@
|
||||
package types
|
||||
|
||||
import "encoding/json"
|
||||
|
||||
const (
|
||||
ModuleName = "processing"
|
||||
StoreKey = ModuleName
|
||||
RouterKey = ModuleName
|
||||
QuerierRoute = ModuleName
|
||||
|
||||
// FCFS processing — NOT fee-auctioned (§15 LOCKED)
|
||||
// Geographic proximity wins (closest processor to recipient)
|
||||
// Anyone with a phone can process via light client (~30 MB app)
|
||||
|
||||
LightClientSizeMB = 30 // ~30 MB app
|
||||
BatteryPerDayActivePct = 3 // ~1-3pct battery/day active
|
||||
)
|
||||
|
||||
// ProcessingMode is FCFS (§15) — never fee-auctioned
|
||||
type ProcessingMode string
|
||||
|
||||
const (
|
||||
ModeFCFS ProcessingMode = "FCFS" // First-come-first-served, not fee-auctioned
|
||||
)
|
||||
|
||||
// Processor is a light client processing Pass-Acts (§15)
|
||||
type Processor struct {
|
||||
ProcessorID string `json:"processor_id" yaml:"processor_id"`
|
||||
ReachID string `json:"reach_id" yaml:"reach_id"`
|
||||
Latitude float64 `json:"latitude" yaml:"latitude"`
|
||||
Longitude float64 `json:"longitude" yaml:"longitude"`
|
||||
ActiveSince int64 `json:"active_since" yaml:"active_since"`
|
||||
BlocksProcessed uint64 `json:"blocks_processed" yaml:"blocks_processed"`
|
||||
}
|
||||
|
||||
// ProcessingJob is a Pass-Act awaiting processing
|
||||
type ProcessingJob struct {
|
||||
JobID string `json:"job_id" yaml:"job_id"`
|
||||
SenderLat float64 `json:"sender_lat" yaml:"sender_lat"`
|
||||
SenderLng float64 `json:"sender_lng" yaml:"sender_lng"`
|
||||
RecipientLat float64 `json:"recipient_lat" yaml:"recipient_lat"`
|
||||
RecipientLng float64 `json:"recipient_lng" yaml:"recipient_lng"`
|
||||
AmountGrain int64 `json:"amount_grain" yaml:"amount_grain"`
|
||||
Timestamp int64 `json:"timestamp" yaml:"timestamp"`
|
||||
}
|
||||
|
||||
// SelectProcessorByProximity — geographic proximity wins (§15)
|
||||
// In FCFS mode, the closest processor to the recipient processes the job
|
||||
func SelectProcessorByProximity(recipientLat, recipientLng float64, processors []Processor) *Processor {
|
||||
if len(processors) == 0 {
|
||||
return nil
|
||||
}
|
||||
closest := &processors[0]
|
||||
minDist := haversine(recipientLat, recipientLng, closest.Latitude, closest.Longitude)
|
||||
for i := 1; i < len(processors); i++ {
|
||||
dist := haversine(recipientLat, recipientLng, processors[i].Latitude, processors[i].Longitude)
|
||||
if dist < minDist {
|
||||
minDist = dist
|
||||
closest = &processors[i]
|
||||
}
|
||||
}
|
||||
return closest
|
||||
}
|
||||
|
||||
// haversine computes distance between two coordinates (simplified)
|
||||
func haversine(lat1, lng1, lat2, lng2 float64) float64 {
|
||||
dlat := lat2 - lat1
|
||||
dlng := lng2 - lng1
|
||||
return dlat*dlat + dlng*dlng // squared distance (sufficient for comparison)
|
||||
}
|
||||
|
||||
type Params struct{}
|
||||
|
||||
func DefaultParams() Params { return Params{} }
|
||||
|
||||
type GenesisState struct {
|
||||
Params Params `json:"params" yaml:"params"`
|
||||
Processors []Processor `json:"processors" yaml:"processors"`
|
||||
}
|
||||
|
||||
func DefaultGenesisState() *GenesisState {
|
||||
return &GenesisState{Params: DefaultParams(), Processors: []Processor{}}
|
||||
}
|
||||
|
||||
func ValidateGenesis(bz json.RawMessage) error { return nil }
|
||||
@@ -0,0 +1,43 @@
|
||||
package types
|
||||
|
||||
import "encoding/json"
|
||||
|
||||
const (
|
||||
ModuleName = "rootpool"
|
||||
StoreKey = ModuleName
|
||||
RouterKey = ModuleName
|
||||
QuerierRoute = ModuleName
|
||||
)
|
||||
|
||||
// RootPool is the mesh's treasury (§5)
|
||||
// Attested by Watchers, visible to every Holder.
|
||||
type RootPool struct {
|
||||
BalanceGrain int64 `json:"balance_grain" yaml:"balance_grain"`
|
||||
LastAttestation int64 `json:"last_attestation" yaml:"last_attestation"`
|
||||
QuorumMet bool `json:"quorum_met" yaml:"quorum_met"`
|
||||
ReserveRatioPct uint32 `json:"reserve_ratio_pct" yaml:"reserve_ratio_pct"`
|
||||
}
|
||||
|
||||
// RootPoolEntry tracks incoming/outgoing Root-Pool movements
|
||||
type RootPoolEntry struct {
|
||||
EntryID string `json:"entry_id" yaml:"entry_id"`
|
||||
Source string `json:"source" yaml:"source"`
|
||||
Amount int64 `json:"amount" yaml:"amount"`
|
||||
Reason string `json:"reason" yaml:"reason"`
|
||||
Timestamp int64 `json:"timestamp" yaml:"timestamp"`
|
||||
}
|
||||
|
||||
type Params struct{}
|
||||
|
||||
func DefaultParams() Params { return Params{} }
|
||||
|
||||
type GenesisState struct {
|
||||
Params Params `json:"params" yaml:"params"`
|
||||
RootPool RootPool `json:"root_pool" yaml:"root_pool"`
|
||||
}
|
||||
|
||||
func DefaultGenesisState() *GenesisState {
|
||||
return &GenesisState{Params: DefaultParams()}
|
||||
}
|
||||
|
||||
func ValidateGenesis(bz json.RawMessage) error { return nil }
|
||||
@@ -0,0 +1,59 @@
|
||||
package types
|
||||
|
||||
import "fmt"
|
||||
|
||||
// genesis.go holds the data-engineer's genesis schema helpers for the
|
||||
// satellite module (G-008 split). ValidateGenesis in types.go composes these
|
||||
// helpers; the security-engineer's test assertions live in types_test.go.
|
||||
//
|
||||
// The Satellite genesis schema has two top-level sets: Channels (the IBC
|
||||
// transfer channels between OY Chain and L2 satellites) and Denoms (the
|
||||
// wrapped Bread denoms). The invariants enforced at genesis load are
|
||||
// (1) channel-id uniqueness, (2) denom uniqueness, and (3) each channel's
|
||||
// status is a known ChannelStatus.
|
||||
|
||||
// ValidateChannels asserts channel-ids are present and unique, and that
|
||||
// each channel's status is a known ChannelStatus. ValidateChannels is the
|
||||
// data-engineer's schema validator, composed by ValidateGenesis in types.go.
|
||||
func ValidateChannels(channels []TransferChannel) error {
|
||||
seen := make(map[string]bool, len(channels))
|
||||
for i, c := range channels {
|
||||
if c.ChannelID == "" {
|
||||
return fmt.Errorf("channel [%d]: empty channel-id", i)
|
||||
}
|
||||
if seen[c.ChannelID] {
|
||||
return fmt.Errorf("channel: duplicate channel-id %q", c.ChannelID)
|
||||
}
|
||||
seen[c.ChannelID] = true
|
||||
if !knownChannelStatus(c.Status) {
|
||||
return fmt.Errorf("channel %q: unknown channel status %q", c.ChannelID, c.Status)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// ValidateDenoms asserts denoms are present and unique. ValidateDenoms is
|
||||
// the data-engineer's schema validator for the wrapped Bread denom set.
|
||||
func ValidateDenoms(denoms []WrappedBreadDenom) error {
|
||||
seen := make(map[string]bool, len(denoms))
|
||||
for i, d := range denoms {
|
||||
if d.Denom == "" {
|
||||
return fmt.Errorf("denom [%d]: empty denom", i)
|
||||
}
|
||||
if seen[d.Denom] {
|
||||
return fmt.Errorf("denom: duplicate denom %q", d.Denom)
|
||||
}
|
||||
seen[d.Denom] = true
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// knownChannelStatus reports whether s is one of the four ChannelStatus values.
|
||||
func knownChannelStatus(s ChannelStatus) bool {
|
||||
for _, ss := range AllChannelStatuses() {
|
||||
if s == ss {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
@@ -0,0 +1,171 @@
|
||||
package types
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
)
|
||||
|
||||
const (
|
||||
ModuleName = "satellite"
|
||||
StoreKey = ModuleName
|
||||
RouterKey = ModuleName
|
||||
QuerierRoute = ModuleName
|
||||
|
||||
// L2ChainCount is the locked count of L2Chain enum values (vision §10,
|
||||
// REQ-009, D-021). Five L2 satellite chains: Polygon (the one active
|
||||
// representative in v0.2) plus Base, Arbitrum, Optimism, Solana (four
|
||||
// StatusPending enum placeholders). A regression firewall:
|
||||
// adding/removing/renaming a chain breaks this const's test.
|
||||
L2ChainCount = 5
|
||||
|
||||
// ChannelStatusCount is the locked count of ChannelStatus enum values
|
||||
// (ICS-20 handshake): Init, TryOpen, Open, Closed. A regression firewall
|
||||
// for the ICS-20 handshake shape (A-215).
|
||||
ChannelStatusCount = 4
|
||||
)
|
||||
|
||||
// L2Chain enumerates the L2 satellite chains (vision §10, REQ-009, D-021).
|
||||
// Polygon is the one active representative in v0.2 (D-021 scopes v0.2 to ONE
|
||||
// representative chain). Base, Arbitrum, Optimism, and Solana are
|
||||
// StatusPending enum placeholders (the full 5-chain IBC rollout is Phase 3
|
||||
// per D-021). Solana lacks native IBC (RESEARCH §1.1) and is stubbed as
|
||||
// StatusPending — no Solana light-client logic in v0.2.
|
||||
type L2Chain string
|
||||
|
||||
const (
|
||||
ChainPolygon L2Chain = "Polygon" // active representative (D-021)
|
||||
ChainBase L2Chain = "Base" // StatusPending placeholder
|
||||
ChainArbitrum L2Chain = "Arbitrum" // StatusPending placeholder
|
||||
ChainOptimism L2Chain = "Optimism" // StatusPending placeholder
|
||||
ChainSolana L2Chain = "Solana" // StatusPending placeholder (no native IBC)
|
||||
)
|
||||
|
||||
// ChainActivation is the activation state of an L2 chain (D-021): Active
|
||||
// (Polygon in v0.2) or StatusPending (the four stubs).
|
||||
type ChainActivation string
|
||||
|
||||
const (
|
||||
ChainActive ChainActivation = "Active" // chain is live for IBC transfer
|
||||
ChainStatusPending ChainActivation = "StatusPending" // chain is a placeholder (Phase 3 rollout)
|
||||
)
|
||||
|
||||
// ChainInfo describes an L2 chain's properties (REQ-009, D-021).
|
||||
type ChainInfo struct {
|
||||
Chain L2Chain `json:"chain" yaml:"chain"`
|
||||
Activation ChainActivation `json:"activation" yaml:"activation"`
|
||||
}
|
||||
|
||||
// AllL2Chains returns all five L2Chain values (Polygon + 4 stubs) with their
|
||||
// activation states (D-021). Locked-const test asserts exactly 5 entries.
|
||||
// Polygon is the only ChainActive entry; the other four are StatusPending.
|
||||
func AllL2Chains() []ChainInfo {
|
||||
return []ChainInfo{
|
||||
{ChainPolygon, ChainActive},
|
||||
{ChainBase, ChainStatusPending},
|
||||
{ChainArbitrum, ChainStatusPending},
|
||||
{ChainOptimism, ChainStatusPending},
|
||||
{ChainSolana, ChainStatusPending},
|
||||
}
|
||||
}
|
||||
|
||||
// ChannelStatus enumerates the ICS-20 channel handshake states (A-215):
|
||||
// Init (channel initialized), TryOpen (counterparty trying to open), Open
|
||||
// (channel established), Closed (channel closed). The four-state handshake
|
||||
// mirrors ibc-go ICS-20 v1 channel state (stable, widely implemented).
|
||||
type ChannelStatus string
|
||||
|
||||
const (
|
||||
ChannelInit ChannelStatus = "Init" // channel initialized
|
||||
ChannelTryOpen ChannelStatus = "TryOpen" // counterparty trying to open
|
||||
ChannelOpen ChannelStatus = "Open" // channel established
|
||||
ChannelClosed ChannelStatus = "Closed" // channel closed
|
||||
)
|
||||
|
||||
// AllChannelStatuses returns all four ChannelStatus values in ICS-20
|
||||
// handshake order. Locked-const test asserts exactly 4 entries.
|
||||
func AllChannelStatuses() []ChannelStatus {
|
||||
return []ChannelStatus{
|
||||
ChannelInit,
|
||||
ChannelTryOpen,
|
||||
ChannelOpen,
|
||||
ChannelClosed,
|
||||
}
|
||||
}
|
||||
|
||||
// TransferChannel is an IBC transfer channel between OY Chain (L1) and an L2
|
||||
// satellite (REQ-009, A-215). port-id and channel-id are the ICS-20 port and
|
||||
// channel identifiers (e.g. "transfer" / "channel-0"). counterparty is the
|
||||
// counterparty port+channel on the L2. status is the handshake state.
|
||||
type TransferChannel struct {
|
||||
PortID string `json:"port_id" yaml:"port_id"`
|
||||
ChannelID string `json:"channel_id" yaml:"channel_id"`
|
||||
Counterparty string `json:"counterparty" yaml:"counterparty"`
|
||||
Status ChannelStatus `json:"status" yaml:"status"`
|
||||
}
|
||||
|
||||
// WrappedBreadDenom encodes an IBC-traced wrapped Bread denom (REQ-009,
|
||||
// A-215). When Bread propagates from OY Chain (L1) to an L2 via IBC, the
|
||||
// denom on the L2 is the original denom prefixed with the IBC trace path
|
||||
// (e.g. "transfer/channel-0/bread"). denom is the full traced denom on the
|
||||
// destination chain; trace-path is the IBC trace (the port/channel hops).
|
||||
type WrappedBreadDenom struct {
|
||||
Denom string `json:"denom" yaml:"denom"`
|
||||
TracePath string `json:"trace_path" yaml:"trace_path"`
|
||||
}
|
||||
|
||||
// Packet is the ICS-20 v1 packet shape stub (REQ-009, A-215). Pinned to the
|
||||
// ICS-20 v1 channel packet shape (stable, widely implemented) to minimize
|
||||
// churn if a different ibc-go version is chosen in Phase 3. Fields:
|
||||
// sequence, source-port, source-channel, dest-port, dest-channel, data,
|
||||
// timeout-height, timeout-timestamp. NO ibc-go import — zero external deps
|
||||
// (A-201); the type is a self-contained Go struct.
|
||||
type Packet struct {
|
||||
Sequence uint64 `json:"sequence" yaml:"sequence"`
|
||||
SourcePort string `json:"source_port" yaml:"source_port"`
|
||||
SourceChannel string `json:"source_channel" yaml:"source_channel"`
|
||||
DestPort string `json:"dest_port" yaml:"dest_port"`
|
||||
DestChannel string `json:"dest_channel" yaml:"dest_channel"`
|
||||
Data []byte `json:"data" yaml:"data"`
|
||||
TimeoutHeight uint64 `json:"timeout_height" yaml:"timeout_height"`
|
||||
TimeoutTimestamp uint64 `json:"timeout_timestamp" yaml:"timeout_timestamp"`
|
||||
}
|
||||
|
||||
// Params for the satellite module (skeleton — no tunables in v0.2).
|
||||
type Params struct{}
|
||||
|
||||
func DefaultParams() Params { return Params{} }
|
||||
|
||||
// GenesisState defines the satellite module genesis state (REQ-009).
|
||||
// Channels is the set of IBC transfer channels; Denoms is the set of wrapped
|
||||
// Bread denoms. ValidateGenesis enforces channel-id uniqueness and denom
|
||||
// uniqueness. The data-engineer's genesis.go holds the schema helpers (G-008).
|
||||
type GenesisState struct {
|
||||
Params Params `json:"params" yaml:"params"`
|
||||
Channels []TransferChannel `json:"channels" yaml:"channels"`
|
||||
Denoms []WrappedBreadDenom `json:"denoms" yaml:"denoms"`
|
||||
}
|
||||
|
||||
func DefaultGenesisState() *GenesisState {
|
||||
return &GenesisState{
|
||||
Params: DefaultParams(),
|
||||
Channels: []TransferChannel{},
|
||||
Denoms: []WrappedBreadDenom{},
|
||||
}
|
||||
}
|
||||
|
||||
// ValidateGenesis performs ID-uniqueness checks (A-212 upgrade from v0.1
|
||||
// no-op): rejects duplicate channel-ids and duplicate denoms. 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("satellite: invalid genesis: %w", err)
|
||||
}
|
||||
if err := ValidateChannels(gs.Channels); err != nil {
|
||||
return fmt.Errorf("satellite: %w", err)
|
||||
}
|
||||
if err := ValidateDenoms(gs.Denoms); err != nil {
|
||||
return fmt.Errorf("satellite: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,467 @@
|
||||
package types_test
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"runtime"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/oy/openyield/lexicon"
|
||||
stypes "github.com/oy/openyield/x/satellite/types"
|
||||
)
|
||||
|
||||
// --- L2Chain enum (exactly 5, Polygon active + 4 stubs) ------------------------
|
||||
|
||||
// TestL2ChainCountLockedConst asserts L2ChainCount == 5 and AllL2Chains()
|
||||
// returns exactly 5 (REQ-009, D-021). A regression firewall.
|
||||
func TestL2ChainCountLockedConst(t *testing.T) {
|
||||
if stypes.L2ChainCount != 5 {
|
||||
t.Errorf("L2ChainCount = %d, expected 5 (REQ-009, D-021 LOCKED)", stypes.L2ChainCount)
|
||||
}
|
||||
all := stypes.AllL2Chains()
|
||||
if len(all) != 5 {
|
||||
t.Errorf("AllL2Chains() len = %d, expected 5", len(all))
|
||||
}
|
||||
}
|
||||
|
||||
// TestAllL2ChainsNames asserts the 5 chain names in order with no extras, no
|
||||
// dups, no renames (D-021: Polygon + Base/Arbitrum/Optimism/Solana).
|
||||
func TestAllL2ChainsNames(t *testing.T) {
|
||||
want := []string{"Polygon", "Base", "Arbitrum", "Optimism", "Solana"}
|
||||
all := stypes.AllL2Chains()
|
||||
if len(all) != len(want) {
|
||||
t.Fatalf("len = %d, want %d", len(all), len(want))
|
||||
}
|
||||
seen := map[string]bool{}
|
||||
for i, c := range all {
|
||||
if string(c.Chain) != want[i] {
|
||||
t.Errorf("AllL2Chains()[%d].Chain = %q, want %q", i, c.Chain, want[i])
|
||||
}
|
||||
if seen[string(c.Chain)] {
|
||||
t.Errorf("duplicate L2Chain %q", c.Chain)
|
||||
}
|
||||
seen[string(c.Chain)] = true
|
||||
}
|
||||
}
|
||||
|
||||
// TestL2ChainValues asserts each named const matches its AllL2Chains entry.
|
||||
func TestL2ChainValues(t *testing.T) {
|
||||
if stypes.ChainPolygon != "Polygon" {
|
||||
t.Errorf("ChainPolygon = %q", stypes.ChainPolygon)
|
||||
}
|
||||
if stypes.ChainBase != "Base" {
|
||||
t.Errorf("ChainBase = %q", stypes.ChainBase)
|
||||
}
|
||||
if stypes.ChainArbitrum != "Arbitrum" {
|
||||
t.Errorf("ChainArbitrum = %q", stypes.ChainArbitrum)
|
||||
}
|
||||
if stypes.ChainOptimism != "Optimism" {
|
||||
t.Errorf("ChainOptimism = %q", stypes.ChainOptimism)
|
||||
}
|
||||
if stypes.ChainSolana != "Solana" {
|
||||
t.Errorf("ChainSolana = %q", stypes.ChainSolana)
|
||||
}
|
||||
}
|
||||
|
||||
// TestPolygonOnlyActiveRep asserts Polygon is the only ChainActive entry in
|
||||
// AllL2Chains (D-021: v0.2 scopes to ONE representative chain). The other
|
||||
// four must be StatusPending.
|
||||
func TestPolygonOnlyActiveRep(t *testing.T) {
|
||||
all := stypes.AllL2Chains()
|
||||
activeCount := 0
|
||||
for _, c := range all {
|
||||
if c.Activation == stypes.ChainActive {
|
||||
activeCount++
|
||||
if c.Chain != stypes.ChainPolygon {
|
||||
t.Errorf("chain %q is active, expected only Polygon (D-021)", c.Chain)
|
||||
}
|
||||
}
|
||||
if c.Activation == stypes.ChainStatusPending {
|
||||
if c.Chain == stypes.ChainPolygon {
|
||||
t.Error("Polygon must be active, not StatusPending (D-021)")
|
||||
}
|
||||
}
|
||||
}
|
||||
if activeCount != 1 {
|
||||
t.Errorf("expected exactly 1 active chain (Polygon, D-021), got %d", activeCount)
|
||||
}
|
||||
}
|
||||
|
||||
// TestFourStubsAreStatusPending asserts Base, Arbitrum, Optimism, Solana are
|
||||
// all StatusPending (D-021 — the 4 stubs).
|
||||
func TestFourStubsAreStatusPending(t *testing.T) {
|
||||
stubs := []stypes.L2Chain{stypes.ChainBase, stypes.ChainArbitrum, stypes.ChainOptimism, stypes.ChainSolana}
|
||||
all := stypes.AllL2Chains()
|
||||
activationByChain := map[string]stypes.ChainActivation{}
|
||||
for _, c := range all {
|
||||
activationByChain[string(c.Chain)] = c.Activation
|
||||
}
|
||||
for _, s := range stubs {
|
||||
if activationByChain[string(s)] != stypes.ChainStatusPending {
|
||||
t.Errorf("chain %q activation = %q, expected StatusPending (D-021)", s, activationByChain[string(s)])
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// --- ChannelStatus enum (4 states) ---------------------------------------------
|
||||
|
||||
// TestChannelStatusCountLockedConst asserts ChannelStatusCount == 4 and
|
||||
// AllChannelStatuses() returns exactly 4 (A-215 ICS-20 handshake).
|
||||
func TestChannelStatusCountLockedConst(t *testing.T) {
|
||||
if stypes.ChannelStatusCount != 4 {
|
||||
t.Errorf("ChannelStatusCount = %d, expected 4 (A-215 ICS-20)", stypes.ChannelStatusCount)
|
||||
}
|
||||
all := stypes.AllChannelStatuses()
|
||||
if len(all) != 4 {
|
||||
t.Errorf("AllChannelStatuses() len = %d, expected 4", len(all))
|
||||
}
|
||||
}
|
||||
|
||||
// TestAllChannelStatusesNames asserts the 4 ICS-20 handshake names in order.
|
||||
func TestAllChannelStatusesNames(t *testing.T) {
|
||||
want := []string{"Init", "TryOpen", "Open", "Closed"}
|
||||
all := stypes.AllChannelStatuses()
|
||||
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("AllChannelStatuses()[%d] = %q, want %q", i, s, want[i])
|
||||
}
|
||||
if seen[string(s)] {
|
||||
t.Errorf("duplicate ChannelStatus %q", s)
|
||||
}
|
||||
seen[string(s)] = true
|
||||
}
|
||||
}
|
||||
|
||||
// TestChannelStatusValues asserts each named const.
|
||||
func TestChannelStatusValues(t *testing.T) {
|
||||
if stypes.ChannelInit != "Init" {
|
||||
t.Errorf("ChannelInit = %q", stypes.ChannelInit)
|
||||
}
|
||||
if stypes.ChannelTryOpen != "TryOpen" {
|
||||
t.Errorf("ChannelTryOpen = %q", stypes.ChannelTryOpen)
|
||||
}
|
||||
if stypes.ChannelOpen != "Open" {
|
||||
t.Errorf("ChannelOpen = %q", stypes.ChannelOpen)
|
||||
}
|
||||
if stypes.ChannelClosed != "Closed" {
|
||||
t.Errorf("ChannelClosed = %q", stypes.ChannelClosed)
|
||||
}
|
||||
}
|
||||
|
||||
// --- Packet struct fields (ICS-20 v1 shape — A-215) ---------------------------
|
||||
|
||||
// TestPacketFieldsMatchICS20v1 asserts the Packet struct has exactly the 8
|
||||
// ICS-20 v1 fields with the expected names. A-215 pins the packet shape to
|
||||
// ICS-20 v1 to minimize churn. Cross-check field names via JSON tags.
|
||||
func TestPacketFieldsMatchICS20v1(t *testing.T) {
|
||||
p := stypes.Packet{
|
||||
Sequence: 42,
|
||||
SourcePort: "transfer",
|
||||
SourceChannel: "channel-0",
|
||||
DestPort: "transfer",
|
||||
DestChannel: "channel-1",
|
||||
Data: []byte("payload"),
|
||||
TimeoutHeight: 1000,
|
||||
TimeoutTimestamp: 9999999999,
|
||||
}
|
||||
if p.Sequence != 42 || p.SourcePort != "transfer" || p.SourceChannel != "channel-0" ||
|
||||
p.DestPort != "transfer" || p.DestChannel != "channel-1" ||
|
||||
len(p.Data) != 7 || p.TimeoutHeight != 1000 || p.TimeoutTimestamp != 9999999999 {
|
||||
t.Error("Packet fields not set correctly")
|
||||
}
|
||||
// ICS-20 v1 field-name parity: marshal and check JSON tags.
|
||||
bz, err := json.Marshal(p)
|
||||
if err != nil {
|
||||
t.Fatalf("marshal: %v", err)
|
||||
}
|
||||
js := string(bz)
|
||||
wantTags := []string{
|
||||
`"sequence"`, `"source_port"`, `"source_channel"`, `"dest_port"`,
|
||||
`"dest_channel"`, `"data"`, `"timeout_height"`, `"timeout_timestamp"`,
|
||||
}
|
||||
for _, tag := range wantTags {
|
||||
if !strings.Contains(js, tag) {
|
||||
t.Errorf("Packet JSON missing tag %s (ICS-20 v1 shape parity A-215)", tag)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestPacketICS20v1FieldCount asserts the Packet struct has exactly 8 fields
|
||||
// (the ICS-20 v1 shape). A regression firewall for packet-shape drift.
|
||||
func TestPacketICS20v1FieldCount(t *testing.T) {
|
||||
// The 8 ICS-20 v1 fields: sequence, source_port, source_channel,
|
||||
// dest_port, dest_channel, data, timeout_height, timeout_timestamp.
|
||||
// We verify by constructing a Packet with all 8 fields and asserting
|
||||
// each is independently settable to a non-zero value.
|
||||
p := stypes.Packet{
|
||||
Sequence: 1,
|
||||
SourcePort: "sp",
|
||||
SourceChannel: "sc",
|
||||
DestPort: "dp",
|
||||
DestChannel: "dc",
|
||||
Data: []byte{0x01},
|
||||
TimeoutHeight: 1,
|
||||
TimeoutTimestamp: 1,
|
||||
}
|
||||
if p.Sequence != 1 || p.SourcePort != "sp" || p.SourceChannel != "sc" ||
|
||||
p.DestPort != "dp" || p.DestChannel != "dc" || len(p.Data) != 1 ||
|
||||
p.TimeoutHeight != 1 || p.TimeoutTimestamp != 1 {
|
||||
t.Error("Packet does not have all 8 ICS-20 v1 fields independently settable")
|
||||
}
|
||||
}
|
||||
|
||||
// --- WrappedBreadDenom trace-path encoding ------------------------------------
|
||||
|
||||
// TestWrappedBreadDenomStruct asserts the WrappedBreadDenom struct carries
|
||||
// the denom and trace-path fields.
|
||||
func TestWrappedBreadDenomStruct(t *testing.T) {
|
||||
d := stypes.WrappedBreadDenom{
|
||||
Denom: "transfer/channel-0/bread",
|
||||
TracePath: "transfer/channel-0",
|
||||
}
|
||||
if d.Denom != "transfer/channel-0/bread" {
|
||||
t.Errorf("Denom = %q", d.Denom)
|
||||
}
|
||||
if d.TracePath != "transfer/channel-0" {
|
||||
t.Errorf("TracePath = %q", d.TracePath)
|
||||
}
|
||||
}
|
||||
|
||||
// TestWrappedBreadDenomTracePathEncoding asserts the IBC trace-path encoding
|
||||
// (REQ-009): the denom is the trace-path + "/" + original-denom.
|
||||
func TestWrappedBreadDenomTracePathEncoding(t *testing.T) {
|
||||
cases := []struct {
|
||||
trace string
|
||||
orig string
|
||||
}{
|
||||
{"transfer/channel-0", "bread"},
|
||||
{"transfer/channel-5", "bread"},
|
||||
{"transfer/channel-0/transfer/channel-3", "bread"}, // multi-hop
|
||||
}
|
||||
for _, c := range cases {
|
||||
full := c.trace + "/" + c.orig
|
||||
d := stypes.WrappedBreadDenom{Denom: full, TracePath: c.trace}
|
||||
if !strings.HasPrefix(d.Denom, d.TracePath) {
|
||||
t.Errorf("denom %q must start with trace-path %q", d.Denom, d.TracePath)
|
||||
}
|
||||
if !strings.HasSuffix(d.Denom, c.orig) {
|
||||
t.Errorf("denom %q must end with original denom %q", d.Denom, c.orig)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// --- TransferChannel ----------------------------------------------------------
|
||||
|
||||
// TestTransferChannelStruct asserts the TransferChannel struct carries all
|
||||
// required fields.
|
||||
func TestTransferChannelStruct(t *testing.T) {
|
||||
ch := stypes.TransferChannel{
|
||||
PortID: "transfer",
|
||||
ChannelID: "channel-0",
|
||||
Counterparty: "transfer/channel-0",
|
||||
Status: stypes.ChannelOpen,
|
||||
}
|
||||
if ch.PortID != "transfer" || ch.ChannelID != "channel-0" ||
|
||||
ch.Counterparty != "transfer/channel-0" || ch.Status != stypes.ChannelOpen {
|
||||
t.Error("TransferChannel fields not set correctly")
|
||||
}
|
||||
}
|
||||
|
||||
// --- Genesis -------------------------------------------------------------------
|
||||
|
||||
// TestDefaultGenesisStateEmpty asserts DefaultGenesisState returns non-nil
|
||||
// empty slices for Channels and Denoms.
|
||||
func TestDefaultGenesisStateEmpty(t *testing.T) {
|
||||
gs := stypes.DefaultGenesisState()
|
||||
if gs == nil {
|
||||
t.Fatal("DefaultGenesisState returned nil")
|
||||
}
|
||||
if gs.Channels == nil || len(gs.Channels) != 0 {
|
||||
t.Errorf("Default Channels should be non-nil empty slice; got len=%d nil=%v", len(gs.Channels), gs.Channels == nil)
|
||||
}
|
||||
if gs.Denoms == nil || len(gs.Denoms) != 0 {
|
||||
t.Errorf("Default Denoms should be non-nil empty slice; got len=%d nil=%v", len(gs.Denoms), gs.Denoms == nil)
|
||||
}
|
||||
}
|
||||
|
||||
// TestValidateGenesisRejectsDupChannelIDs asserts A-212: duplicate
|
||||
// channel-ids are rejected.
|
||||
func TestValidateGenesisRejectsDupChannelIDs(t *testing.T) {
|
||||
gs := stypes.GenesisState{
|
||||
Channels: []stypes.TransferChannel{
|
||||
{PortID: "transfer", ChannelID: "channel-0", Status: stypes.ChannelOpen},
|
||||
{PortID: "transfer", ChannelID: "channel-0", Status: stypes.ChannelInit}, // dup
|
||||
},
|
||||
}
|
||||
bz, _ := json.Marshal(gs)
|
||||
if err := stypes.ValidateGenesis(bz); err == nil {
|
||||
t.Error("ValidateGenesis should reject duplicate channel-ids")
|
||||
}
|
||||
}
|
||||
|
||||
// TestValidateGenesisRejectsEmptyChannelID asserts empty channel-id is rejected.
|
||||
func TestValidateGenesisRejectsEmptyChannelID(t *testing.T) {
|
||||
gs := stypes.GenesisState{
|
||||
Channels: []stypes.TransferChannel{{PortID: "transfer", ChannelID: "", Status: stypes.ChannelInit}},
|
||||
}
|
||||
bz, _ := json.Marshal(gs)
|
||||
if err := stypes.ValidateGenesis(bz); err == nil {
|
||||
t.Error("ValidateGenesis should reject empty channel-id")
|
||||
}
|
||||
}
|
||||
|
||||
// TestValidateGenesisRejectsUnknownChannelStatus asserts an unknown
|
||||
// ChannelStatus is rejected.
|
||||
func TestValidateGenesisRejectsUnknownChannelStatus(t *testing.T) {
|
||||
gs := stypes.GenesisState{
|
||||
Channels: []stypes.TransferChannel{{PortID: "transfer", ChannelID: "channel-0", Status: stypes.ChannelStatus("Bogus")}},
|
||||
}
|
||||
bz, _ := json.Marshal(gs)
|
||||
if err := stypes.ValidateGenesis(bz); err == nil {
|
||||
t.Error("ValidateGenesis should reject unknown channel status")
|
||||
}
|
||||
}
|
||||
|
||||
// TestValidateGenesisRejectsDupDenom asserts duplicate denoms are rejected.
|
||||
func TestValidateGenesisRejectsDupDenom(t *testing.T) {
|
||||
gs := stypes.GenesisState{
|
||||
Denoms: []stypes.WrappedBreadDenom{
|
||||
{Denom: "transfer/channel-0/bread", TracePath: "transfer/channel-0"},
|
||||
{Denom: "transfer/channel-0/bread", TracePath: "transfer/channel-0"}, // dup
|
||||
},
|
||||
}
|
||||
bz, _ := json.Marshal(gs)
|
||||
if err := stypes.ValidateGenesis(bz); err == nil {
|
||||
t.Error("ValidateGenesis should reject duplicate denoms")
|
||||
}
|
||||
}
|
||||
|
||||
// TestValidateGenesisRejectsEmptyDenom asserts empty denom is rejected.
|
||||
func TestValidateGenesisRejectsEmptyDenom(t *testing.T) {
|
||||
gs := stypes.GenesisState{
|
||||
Denoms: []stypes.WrappedBreadDenom{{Denom: "", TracePath: "transfer/channel-0"}},
|
||||
}
|
||||
bz, _ := json.Marshal(gs)
|
||||
if err := stypes.ValidateGenesis(bz); err == nil {
|
||||
t.Error("ValidateGenesis should reject empty denom")
|
||||
}
|
||||
}
|
||||
|
||||
// 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{
|
||||
Channels: []stypes.TransferChannel{
|
||||
{PortID: "transfer", ChannelID: "channel-0", Status: stypes.ChannelOpen},
|
||||
{PortID: "transfer", ChannelID: "channel-1", Status: stypes.ChannelInit},
|
||||
},
|
||||
Denoms: []stypes.WrappedBreadDenom{
|
||||
{Denom: "transfer/channel-0/bread", TracePath: "transfer/channel-0"},
|
||||
},
|
||||
}
|
||||
bz, _ := json.Marshal(gs)
|
||||
if err := stypes.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 stypes.ModuleName != "satellite" {
|
||||
t.Errorf("ModuleName = %q", stypes.ModuleName)
|
||||
}
|
||||
if stypes.StoreKey != "satellite" {
|
||||
t.Errorf("StoreKey = %q", stypes.StoreKey)
|
||||
}
|
||||
if stypes.RouterKey != "satellite" {
|
||||
t.Errorf("RouterKey = %q", stypes.RouterKey)
|
||||
}
|
||||
if stypes.QuerierRoute != "satellite" {
|
||||
t.Errorf("QuerierRoute = %q", stypes.QuerierRoute)
|
||||
}
|
||||
}
|
||||
|
||||
// TestDefaultParams asserts DefaultParams returns a zero-value Params.
|
||||
func TestDefaultParams(t *testing.T) {
|
||||
_ = stypes.DefaultParams() // no panics
|
||||
}
|
||||
|
||||
// --- Lexicon assertion (REQ-012) -------------------------------------------------
|
||||
// The satellite 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.
|
||||
|
||||
// TestLexiconNoBannedTermsInSatellitePackage scans every non-test .go file in
|
||||
// the satellite/types package directory for the banned terms (case-
|
||||
// insensitive). Production files only — the test file references banned
|
||||
// terms via the lexicon package helpers.
|
||||
func TestLexiconNoBannedTermsInSatellitePackage(t *testing.T) {
|
||||
pkgDir := packageDir(t, "github.com/oy/openyield/x/satellite/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 satellite/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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestLexiconNoBannedTermsInSatelliteTestFile asserts this test file itself
|
||||
// does not contain any banned term as a literal.
|
||||
func TestLexiconNoBannedTermsInSatelliteTestFile(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("satellite 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.2 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/satellite/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)
|
||||
}
|
||||
@@ -0,0 +1,225 @@
|
||||
// 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
|
||||
}
|
||||
@@ -0,0 +1,369 @@
|
||||
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)
|
||||
}
|
||||
@@ -0,0 +1,49 @@
|
||||
package types
|
||||
|
||||
import "fmt"
|
||||
|
||||
// genesis.go holds the data-engineer's genesis schema helpers for the stand
|
||||
// module (G-008 split). ValidateGenesis in types.go composes these helpers;
|
||||
// the security-engineer's test assertions live in genesis_test.go.
|
||||
//
|
||||
// The Stand genesis schema is a membership-set: Stands (the organizational
|
||||
// forms) + Memberships (the membership edges). The two top-level invariants
|
||||
// are stand-id uniqueness and member-reach uniqueness within a stand
|
||||
// (REQ-016, A-212 upgrade from v0.1's no-op ValidateGenesis).
|
||||
|
||||
// ValidateStands asserts stand-ids are present and unique.
|
||||
func ValidateStands(stands []Stand) error {
|
||||
seen := make(map[string]bool, len(stands))
|
||||
for i, s := range stands {
|
||||
if s.StandID == "" {
|
||||
return fmt.Errorf("stand [%d]: empty stand-id", i)
|
||||
}
|
||||
if seen[s.StandID] {
|
||||
return fmt.Errorf("stand: duplicate stand-id %q", s.StandID)
|
||||
}
|
||||
seen[s.StandID] = true
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// ValidateMemberships asserts the membership-set invariant: the (stand-id,
|
||||
// reach-id) pair is unique across the membership set — i.e. a reach can be
|
||||
// a member of a stand at most once. The same reach MAY be a member of
|
||||
// different stands (uniqueness is per-stand, not global).
|
||||
func ValidateMemberships(memberships []Membership) error {
|
||||
seen := make(map[string]bool, len(memberships))
|
||||
for i, m := range memberships {
|
||||
if m.StandID == "" {
|
||||
return fmt.Errorf("membership [%d]: empty stand-id", i)
|
||||
}
|
||||
if m.ReachID == "" {
|
||||
return fmt.Errorf("membership [%d]: empty reach-id", i)
|
||||
}
|
||||
key := m.StandID + "/" + m.ReachID
|
||||
if seen[key] {
|
||||
return fmt.Errorf("membership: duplicate member-reach %q in stand %q", m.ReachID, m.StandID)
|
||||
}
|
||||
seen[key] = true
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,126 @@
|
||||
package types_test
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"testing"
|
||||
|
||||
"github.com/oy/openyield/x/stand/types"
|
||||
)
|
||||
|
||||
// genesis_test.go holds the security-engineer's test assertions for the
|
||||
// data-engineer's genesis.go schema (G-008 split). The locked-const,
|
||||
// enum-coverage, and lexicon assertions live in types_test.go.
|
||||
|
||||
// TestValidateStandsRejectsDup asserts ValidateStands rejects duplicate
|
||||
// stand-ids (the membership-set's top-level invariant).
|
||||
func TestValidateStandsRejectsDup(t *testing.T) {
|
||||
stands := []types.Stand{
|
||||
{StandID: "s1"},
|
||||
{StandID: "s1"},
|
||||
}
|
||||
if err := types.ValidateStands(stands); err == nil {
|
||||
t.Error("ValidateStands should reject duplicate stand-ids")
|
||||
}
|
||||
}
|
||||
|
||||
// TestValidateStandsRejectsEmpty asserts empty stand-id is rejected.
|
||||
func TestValidateStandsRejectsEmpty(t *testing.T) {
|
||||
stands := []types.Stand{{StandID: ""}}
|
||||
if err := types.ValidateStands(stands); err == nil {
|
||||
t.Error("ValidateStands should reject empty stand-id")
|
||||
}
|
||||
}
|
||||
|
||||
// TestValidateStandsAcceptsUnique asserts a clean stand set validates.
|
||||
func TestValidateStandsAcceptsUnique(t *testing.T) {
|
||||
stands := []types.Stand{{StandID: "s1"}, {StandID: "s2"}}
|
||||
if err := types.ValidateStands(stands); err != nil {
|
||||
t.Errorf("ValidateStands should accept unique ids, got: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// TestValidateMembershipsRejectsDupWithinStand asserts the membership-set
|
||||
// invariant: (stand-id, reach-id) pair must be unique.
|
||||
func TestValidateMembershipsRejectsDupWithinStand(t *testing.T) {
|
||||
m := []types.Membership{
|
||||
{StandID: "s1", ReachID: "reach:a"},
|
||||
{StandID: "s1", ReachID: "reach:a"}, // dup within stand
|
||||
}
|
||||
if err := types.ValidateMemberships(m); err == nil {
|
||||
t.Error("ValidateMemberships should reject duplicate (stand-id, reach-id)")
|
||||
}
|
||||
}
|
||||
|
||||
// TestValidateMembershipsAcceptsSameReachDifferentStands asserts the same
|
||||
// reach can join different stands (uniqueness is per-stand, not global).
|
||||
func TestValidateMembershipsAcceptsSameReachDifferentStands(t *testing.T) {
|
||||
m := []types.Membership{
|
||||
{StandID: "s1", ReachID: "reach:a"},
|
||||
{StandID: "s2", ReachID: "reach:a"}, // ok
|
||||
}
|
||||
if err := types.ValidateMemberships(m); err != nil {
|
||||
t.Errorf("ValidateMemberships should accept same reach in different stands, got: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// TestValidateMembershipsRejectsEmptyFields asserts empty stand-id or
|
||||
// reach-id is rejected (every membership edge must be fully identified).
|
||||
func TestValidateMembershipsRejectsEmptyFields(t *testing.T) {
|
||||
cases := []struct {
|
||||
name string
|
||||
m []types.Membership
|
||||
}{
|
||||
{"empty stand-id", []types.Membership{{StandID: "", ReachID: "reach:a"}}},
|
||||
{"empty reach-id", []types.Membership{{StandID: "s1", ReachID: ""}}},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
if err := types.ValidateMemberships(tc.m); err == nil {
|
||||
t.Error("ValidateMemberships should reject empty fields")
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestValidateMembershipsEmptyOK asserts an empty membership set validates.
|
||||
func TestValidateMembershipsEmptyOK(t *testing.T) {
|
||||
if err := types.ValidateMemberships(nil); err != nil {
|
||||
t.Errorf("ValidateMemberships(nil) should be nil, got: %v", err)
|
||||
}
|
||||
if err := types.ValidateMemberships([]types.Membership{}); err != nil {
|
||||
t.Errorf("ValidateMemberships([]) should be nil, got: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// TestValidateGenesisComposesBoth asserts ValidateGenesis composes both
|
||||
// ValidateStands and ValidateMemberships.
|
||||
func TestValidateGenesisComposesBoth(t *testing.T) {
|
||||
// clean stands but dup membership — should fail
|
||||
gs := types.GenesisState{
|
||||
Stands: []types.Stand{{StandID: "s1"}},
|
||||
Memberships: []types.Membership{
|
||||
{StandID: "s1", ReachID: "reach:a"},
|
||||
{StandID: "s1", ReachID: "reach:a"},
|
||||
},
|
||||
}
|
||||
bz, _ := json.Marshal(gs)
|
||||
if err := types.ValidateGenesis(bz); err == nil {
|
||||
t.Error("ValidateGenesis should reject dup membership even with clean stands")
|
||||
}
|
||||
}
|
||||
|
||||
// TestValidateGenesisClean asserts a fully clean genesis validates.
|
||||
func TestValidateGenesisClean(t *testing.T) {
|
||||
gs := types.GenesisState{
|
||||
Stands: []types.Stand{{StandID: "s1"}, {StandID: "s2"}},
|
||||
Memberships: []types.Membership{
|
||||
{StandID: "s1", ReachID: "reach:a"},
|
||||
{StandID: "s2", ReachID: "reach:a"},
|
||||
{StandID: "s1", ReachID: "reach:b"},
|
||||
},
|
||||
}
|
||||
bz, _ := json.Marshal(gs)
|
||||
if err := types.ValidateGenesis(bz); err != nil {
|
||||
t.Errorf("ValidateGenesis should accept clean genesis, got: %v", err)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,136 @@
|
||||
package types
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
)
|
||||
|
||||
const (
|
||||
ModuleName = "stand"
|
||||
StoreKey = ModuleName
|
||||
RouterKey = ModuleName
|
||||
QuerierRoute = ModuleName
|
||||
|
||||
// StandTypeCount is the locked count of StandType enum values (vision §11).
|
||||
// A regression firewall: adding/removing/renaming a Stand type breaks this
|
||||
// const's test.
|
||||
StandTypeCount = 9
|
||||
)
|
||||
|
||||
// StandType enumerates the nine organizational forms (vision §11, REQ-016).
|
||||
// All nine are treated uniformly in v0.2 (A-213: the Shadow Stand behavioral
|
||||
// split is deferred to v0.3 design).
|
||||
type StandType string
|
||||
|
||||
const (
|
||||
StandHousehold StandType = "Household"
|
||||
StandCrew StandType = "Crew"
|
||||
StandEntity StandType = "Entity"
|
||||
StandCoop StandType = "Co-op"
|
||||
StandCircle StandType = "Circle"
|
||||
StandTrust StandType = "Trust"
|
||||
StandFoundation StandType = "Foundation"
|
||||
StandConfederation StandType = "Confederation"
|
||||
StandShadow StandType = "Shadow"
|
||||
)
|
||||
|
||||
// AllStandTypes returns all nine StandType values in vision §11 order.
|
||||
// Locked-const test asserts exactly 9 entries with these names (REQ-016).
|
||||
func AllStandTypes() []StandType {
|
||||
return []StandType{
|
||||
StandHousehold,
|
||||
StandCrew,
|
||||
StandEntity,
|
||||
StandCoop,
|
||||
StandCircle,
|
||||
StandTrust,
|
||||
StandFoundation,
|
||||
StandConfederation,
|
||||
StandShadow,
|
||||
}
|
||||
}
|
||||
|
||||
// Stand is a governed group holding a Vault (vision §11, REQ-016).
|
||||
// Modeled on Cosmos SDK x/group (a group of members with a decision policy
|
||||
// governing a Vault). admin-reach references a Reach ID (by-ID-string, G-003);
|
||||
// vault-id references x/vault by ID string (no struct import).
|
||||
type Stand struct {
|
||||
StandID string `json:"stand_id" yaml:"stand_id"`
|
||||
Type StandType `json:"type" yaml:"type"`
|
||||
Name string `json:"name" yaml:"name"`
|
||||
VaultID string `json:"vault_id" yaml:"vault_id"`
|
||||
AdminReach string `json:"admin_reach" yaml:"admin_reach"`
|
||||
CreatedAt int64 `json:"created_at" yaml:"created_at"`
|
||||
MemberCount uint32 `json:"member_count" yaml:"member_count"`
|
||||
}
|
||||
|
||||
// StandRole enumerates member roles within a Stand.
|
||||
type StandRole string
|
||||
|
||||
const (
|
||||
RoleMember StandRole = "Member"
|
||||
RoleAdmin StandRole = "Admin"
|
||||
RoleObserver StandRole = "Observer"
|
||||
)
|
||||
|
||||
// Membership is a Stand membership edge (REQ-016). stand-id references
|
||||
// x/stand by ID string; reach-id references x/identity Reach by string
|
||||
// (G-003 by-ID-string invariant).
|
||||
type Membership struct {
|
||||
StandID string `json:"stand_id" yaml:"stand_id"`
|
||||
ReachID string `json:"reach_id" yaml:"reach_id"`
|
||||
JoinedAt int64 `json:"joined_at" yaml:"joined_at"`
|
||||
Role StandRole `json:"role" yaml:"role"`
|
||||
}
|
||||
|
||||
// StandPolicy is a stub for a Stand's decision policy (A-205).
|
||||
// Mirrors x/group DecisionPolicy: threshold (N-of-M) OR weighted (sum of
|
||||
// weights >= threshold). The skeleton does not enforce the policy; v0.3
|
||||
// wires the live aggregation. Exactly one of Threshold/Weighted should be
|
||||
// non-zero in the live object; the skeleton keeps both as fields for
|
||||
// future-wiring symmetry with x/group.
|
||||
type StandPolicy struct {
|
||||
Threshold uint32 `json:"threshold" yaml:"threshold"`
|
||||
Weighted bool `json:"weighted" yaml:"weighted"`
|
||||
}
|
||||
|
||||
// Params for the stand module (skeleton — no tunables in v0.2).
|
||||
type Params struct{}
|
||||
|
||||
func DefaultParams() Params { return Params{} }
|
||||
|
||||
// GenesisState defines the stand module genesis state (REQ-016).
|
||||
// Stands + Memberships are the two top-level sets; ValidateGenesis enforces
|
||||
// stand-id uniqueness and member-reach uniqueness within a stand.
|
||||
type GenesisState struct {
|
||||
Params Params `json:"params" yaml:"params"`
|
||||
Stands []Stand `json:"stands" yaml:"stands"`
|
||||
Memberships []Membership `json:"memberships" yaml:"memberships"`
|
||||
}
|
||||
|
||||
func DefaultGenesisState() *GenesisState {
|
||||
return &GenesisState{
|
||||
Params: DefaultParams(),
|
||||
Stands: []Stand{},
|
||||
Memberships: []Membership{},
|
||||
}
|
||||
}
|
||||
|
||||
// ValidateGenesis performs ID-uniqueness checks (A-212 upgrade from v0.1
|
||||
// no-op): rejects duplicate stand-ids and duplicate (stand-id, reach-id)
|
||||
// membership pairs. The membership-set invariant is "a reach can be a
|
||||
// member of a stand at most once; the same reach may join different stands".
|
||||
// Validation is delegated 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("stand: invalid genesis: %w", err)
|
||||
}
|
||||
if err := ValidateStands(gs.Stands); err != nil {
|
||||
return fmt.Errorf("stand: %w", err)
|
||||
}
|
||||
if err := ValidateMemberships(gs.Memberships); err != nil {
|
||||
return fmt.Errorf("stand: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,295 @@
|
||||
package types_test
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"runtime"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/oy/openyield/lexicon"
|
||||
"github.com/oy/openyield/x/stand/types"
|
||||
)
|
||||
|
||||
// TestStandTypeCountLockedConst asserts AllStandTypes() returns exactly 9
|
||||
// (vision §11). A regression firewall: adding/removing/renaming a Stand type
|
||||
// breaks this test (REQ-016).
|
||||
func TestStandTypeCountLockedConst(t *testing.T) {
|
||||
if types.StandTypeCount != 9 {
|
||||
t.Errorf("StandTypeCount = %d, expected 9 (vision §11 LOCKED)", types.StandTypeCount)
|
||||
}
|
||||
all := types.AllStandTypes()
|
||||
if len(all) != 9 {
|
||||
t.Errorf("AllStandTypes() len = %d, expected 9", len(all))
|
||||
}
|
||||
}
|
||||
|
||||
// TestAllStandTypesNames asserts the 9 vision §11 names in order with no
|
||||
// extras, no dups, no renames.
|
||||
func TestAllStandTypesNames(t *testing.T) {
|
||||
want := []string{
|
||||
"Household", "Crew", "Entity", "Co-op", "Circle",
|
||||
"Trust", "Foundation", "Confederation", "Shadow",
|
||||
}
|
||||
all := types.AllStandTypes()
|
||||
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("AllStandTypes()[%d] = %q, want %q", i, s, want[i])
|
||||
}
|
||||
if seen[string(s)] {
|
||||
t.Errorf("duplicate StandType %q", s)
|
||||
}
|
||||
seen[string(s)] = true
|
||||
}
|
||||
}
|
||||
|
||||
// TestStandTypeValues asserts each named const matches its AllStandTypes entry.
|
||||
func TestStandTypeValues(t *testing.T) {
|
||||
if types.StandHousehold != "Household" {
|
||||
t.Errorf("StandHousehold = %q", types.StandHousehold)
|
||||
}
|
||||
if types.StandCrew != "Crew" {
|
||||
t.Errorf("StandCrew = %q", types.StandCrew)
|
||||
}
|
||||
if types.StandEntity != "Entity" {
|
||||
t.Errorf("StandEntity = %q", types.StandEntity)
|
||||
}
|
||||
if types.StandCoop != "Co-op" {
|
||||
t.Errorf("StandCoop = %q", types.StandCoop)
|
||||
}
|
||||
if types.StandCircle != "Circle" {
|
||||
t.Errorf("StandCircle = %q", types.StandCircle)
|
||||
}
|
||||
if types.StandTrust != "Trust" {
|
||||
t.Errorf("StandTrust = %q", types.StandTrust)
|
||||
}
|
||||
if types.StandFoundation != "Foundation" {
|
||||
t.Errorf("StandFoundation = %q", types.StandFoundation)
|
||||
}
|
||||
if types.StandConfederation != "Confederation" {
|
||||
t.Errorf("StandConfederation = %q", types.StandConfederation)
|
||||
}
|
||||
if types.StandShadow != "Shadow" {
|
||||
t.Errorf("StandShadow = %q", types.StandShadow)
|
||||
}
|
||||
}
|
||||
|
||||
// TestStandRoleEnumCoverage asserts the three StandRole values.
|
||||
func TestStandRoleEnumCoverage(t *testing.T) {
|
||||
roles := []types.StandRole{types.RoleMember, types.RoleAdmin, types.RoleObserver}
|
||||
if len(roles) != 3 {
|
||||
t.Errorf("expected 3 StandRole consts, got %d", len(roles))
|
||||
}
|
||||
seen := map[types.StandRole]bool{}
|
||||
for _, r := range roles {
|
||||
if r == "" {
|
||||
t.Error("empty StandRole")
|
||||
}
|
||||
if seen[r] {
|
||||
t.Errorf("duplicate StandRole %q", r)
|
||||
}
|
||||
seen[r] = true
|
||||
}
|
||||
}
|
||||
|
||||
// TestStandStructFields asserts Stand carries all required fields.
|
||||
func TestStandStructFields(t *testing.T) {
|
||||
s := types.Stand{
|
||||
StandID: "s1",
|
||||
Type: types.StandHousehold,
|
||||
Name: "Household A",
|
||||
VaultID: "v1",
|
||||
AdminReach: "reach:admin",
|
||||
CreatedAt: 100,
|
||||
MemberCount: 3,
|
||||
}
|
||||
if s.StandID != "s1" || s.Type != types.StandHousehold || s.Name != "Household A" ||
|
||||
s.VaultID != "v1" || s.AdminReach != "reach:admin" || s.CreatedAt != 100 ||
|
||||
s.MemberCount != 3 {
|
||||
t.Error("Stand fields not set correctly")
|
||||
}
|
||||
}
|
||||
|
||||
// TestMembershipStructFields asserts Membership carries all required fields.
|
||||
func TestMembershipStructFields(t *testing.T) {
|
||||
m := types.Membership{
|
||||
StandID: "s1",
|
||||
ReachID: "reach:member",
|
||||
JoinedAt: 200,
|
||||
Role: types.RoleMember,
|
||||
}
|
||||
if m.StandID != "s1" || m.ReachID != "reach:member" || m.JoinedAt != 200 ||
|
||||
m.Role != types.RoleMember {
|
||||
t.Error("Membership fields not set correctly")
|
||||
}
|
||||
}
|
||||
|
||||
// TestStandPolicyStub asserts StandPolicy carries threshold + weighted fields
|
||||
// (A-205 mirrors x/group DecisionPolicy).
|
||||
func TestStandPolicyStub(t *testing.T) {
|
||||
p := types.StandPolicy{Threshold: 5, Weighted: false}
|
||||
if p.Threshold != 5 || p.Weighted != false {
|
||||
t.Error("StandPolicy fields not set correctly")
|
||||
}
|
||||
}
|
||||
|
||||
// TestDefaultGenesisStateEmpty asserts DefaultGenesisState returns non-nil
|
||||
// empty slices for Stands and Memberships.
|
||||
func TestDefaultGenesisStateEmpty(t *testing.T) {
|
||||
gs := types.DefaultGenesisState()
|
||||
if gs == nil {
|
||||
t.Fatal("DefaultGenesisState returned nil")
|
||||
}
|
||||
if gs.Stands == nil || len(gs.Stands) != 0 {
|
||||
t.Errorf("Default Stands should be non-nil empty slice; got len=%d nil=%v", len(gs.Stands), gs.Stands == nil)
|
||||
}
|
||||
if gs.Memberships == nil || len(gs.Memberships) != 0 {
|
||||
t.Errorf("Default Memberships should be non-nil empty slice; got len=%d nil=%v", len(gs.Memberships), gs.Memberships == nil)
|
||||
}
|
||||
}
|
||||
|
||||
// TestValidateGenesisRejectsDupStandIDs asserts A-212: duplicate stand-ids
|
||||
// are rejected.
|
||||
func TestValidateGenesisRejectsDupStandIDs(t *testing.T) {
|
||||
gs := types.GenesisState{
|
||||
Stands: []types.Stand{
|
||||
{StandID: "s1"},
|
||||
{StandID: "s1"}, // dup
|
||||
},
|
||||
}
|
||||
bz, _ := json.Marshal(gs)
|
||||
if err := types.ValidateGenesis(bz); err == nil {
|
||||
t.Error("ValidateGenesis should reject duplicate stand-ids")
|
||||
}
|
||||
}
|
||||
|
||||
// TestValidateGenesisRejectsDupMemberReach asserts A-212: duplicate
|
||||
// (stand-id, reach-id) membership pairs are rejected.
|
||||
func TestValidateGenesisRejectsDupMemberReach(t *testing.T) {
|
||||
gs := types.GenesisState{
|
||||
Memberships: []types.Membership{
|
||||
{StandID: "s1", ReachID: "reach:a"},
|
||||
{StandID: "s1", ReachID: "reach:a"}, // dup within same stand
|
||||
},
|
||||
}
|
||||
bz, _ := json.Marshal(gs)
|
||||
if err := types.ValidateGenesis(bz); err == nil {
|
||||
t.Error("ValidateGenesis should reject duplicate member-reach within a stand")
|
||||
}
|
||||
}
|
||||
|
||||
// TestValidateGenesisAcceptsSameReachInDifferentStands asserts the same
|
||||
// reach can be a member of two different stands (uniqueness is per-stand).
|
||||
func TestValidateGenesisAcceptsSameReachInDifferentStands(t *testing.T) {
|
||||
gs := types.GenesisState{
|
||||
Memberships: []types.Membership{
|
||||
{StandID: "s1", ReachID: "reach:a"},
|
||||
{StandID: "s2", ReachID: "reach:a"}, // ok — different stand
|
||||
},
|
||||
}
|
||||
bz, _ := json.Marshal(gs)
|
||||
if err := types.ValidateGenesis(bz); err != nil {
|
||||
t.Errorf("ValidateGenesis should accept same reach in different stands, got: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// TestValidateGenesisRejectsEmptyStandID asserts empty stand-id is rejected.
|
||||
func TestValidateGenesisRejectsEmptyStandID(t *testing.T) {
|
||||
gs := types.GenesisState{
|
||||
Stands: []types.Stand{{StandID: ""}},
|
||||
}
|
||||
bz, _ := json.Marshal(gs)
|
||||
if err := types.ValidateGenesis(bz); err == nil {
|
||||
t.Error("ValidateGenesis should reject empty stand-id")
|
||||
}
|
||||
}
|
||||
|
||||
// TestValidateGenesisRejectsBadJSON asserts malformed JSON is rejected.
|
||||
func TestValidateGenesisRejectsBadJSON(t *testing.T) {
|
||||
if err := types.ValidateGenesis(json.RawMessage(`{bad`)); err == nil {
|
||||
t.Error("ValidateGenesis should reject malformed JSON")
|
||||
}
|
||||
}
|
||||
|
||||
// TestValidateGenesisAcceptsClean asserts a clean genesis validates.
|
||||
func TestValidateGenesisAcceptsClean(t *testing.T) {
|
||||
gs := types.GenesisState{
|
||||
Stands: []types.Stand{{StandID: "s1"}, {StandID: "s2"}},
|
||||
Memberships: []types.Membership{{StandID: "s1", ReachID: "reach:a"}},
|
||||
}
|
||||
bz, _ := json.Marshal(gs)
|
||||
if err := types.ValidateGenesis(bz); err != nil {
|
||||
t.Errorf("ValidateGenesis should accept clean genesis, got: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// TestModuleConsts asserts the four Cosmos-convention module consts.
|
||||
func TestModuleConsts(t *testing.T) {
|
||||
if types.ModuleName != "stand" {
|
||||
t.Errorf("ModuleName = %q", types.ModuleName)
|
||||
}
|
||||
if types.StoreKey != "stand" {
|
||||
t.Errorf("StoreKey = %q", types.StoreKey)
|
||||
}
|
||||
if types.RouterKey != "stand" {
|
||||
t.Errorf("RouterKey = %q", types.RouterKey)
|
||||
}
|
||||
if types.QuerierRoute != "stand" {
|
||||
t.Errorf("QuerierRoute = %q", types.QuerierRoute)
|
||||
}
|
||||
}
|
||||
|
||||
// TestDefaultParams asserts DefaultParams returns a zero-value Params.
|
||||
func TestDefaultParams(t *testing.T) {
|
||||
_ = types.DefaultParams() // no panics
|
||||
}
|
||||
|
||||
// --- Lexicon assertion (REQ-012) -------------------------------------------------
|
||||
|
||||
// TestLexiconNoBannedTermsInStandPackage scans every non-test .go file in
|
||||
// the stand/types package directory for the 9 banned terms (case-insensitive).
|
||||
// Production files only — the test file contains the banned terms as the list
|
||||
// of things to forbid (standard lexicon-test bootstrapping pattern).
|
||||
func TestLexiconNoBannedTermsInStandPackage(t *testing.T) {
|
||||
pkgDir := packageDir(t, "github.com/oy/openyield/x/stand/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 stand/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)", filepath.Base(f), found)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// packageDir resolves a Go import path to its filesystem directory.
|
||||
func packageDir(t *testing.T, importPath string) string {
|
||||
t.Helper()
|
||||
_, file, _, ok := runtime.Caller(0)
|
||||
if !ok {
|
||||
t.Fatal("runtime.Caller failed")
|
||||
}
|
||||
repoRoot := filepath.Dir(filepath.Dir(filepath.Dir(filepath.Dir(file))))
|
||||
rel := strings.TrimPrefix(importPath, "github.com/oy/openyield/")
|
||||
return filepath.Join(repoRoot, rel)
|
||||
}
|
||||
@@ -0,0 +1,160 @@
|
||||
package types
|
||||
|
||||
import "encoding/json"
|
||||
|
||||
const (
|
||||
ModuleName = "standing"
|
||||
StoreKey = ModuleName
|
||||
RouterKey = ModuleName
|
||||
QuerierRoute = ModuleName
|
||||
|
||||
// Standing formula parameters (§9.2 — LOCKED)
|
||||
PriorMean = 4.0 // Bayesian prior mean
|
||||
PriorWeight = 10 // Bayesian prior weight
|
||||
DecayBucket6mo = 1.0 // 6mo: 100pct
|
||||
DecayBucket12mo = 0.5 // 12mo: 50pct
|
||||
DecayBucket24mo = 0.25 // 24mo: 25pct
|
||||
DecayBucket24plus = 0.0 // 24mo+: 0pct
|
||||
|
||||
// Diversity bonus (§9.2)
|
||||
DiversityBonus3Cats = 0.05
|
||||
DiversityBonus4Cats = 0.10
|
||||
DiversityBonus5Cats = 0.15
|
||||
|
||||
// Voucher weights (§9.2)
|
||||
VoucherWeightFreeholder = 1.5
|
||||
VoucherWeight45Plus = 1.2
|
||||
VoucherWeight40To45 = 1.0
|
||||
VoucherWeightBelow40 = 0.5
|
||||
VoucherWeightBelow10Ratings = 0.3
|
||||
|
||||
// Minimum counterparties (§9.2)
|
||||
MinCounterpartiesValid = 3 // to be valid
|
||||
MinCounterpartiesFreeholderEligible = 10 // to be Freeholder-eligible
|
||||
MinCounterpartiesFreeholderStatus = 30 // across 3 categories for Freeholder status
|
||||
|
||||
// Freeholder Four Signals (§9.1 — LOCKED)
|
||||
FreeholderStashMaturityDays = 90 // 90 days Stash activity
|
||||
FreeholderStashMaxGapDays = 30 // no gap > 30 days
|
||||
FreeholderMinStandingScore = 4.5 // 4.5+ in at least 3 service categories
|
||||
FreeholderMinCategories = 3 // at least 3 service categories
|
||||
)
|
||||
|
||||
// Rating is a single rating event (§9.2)
|
||||
type Rating struct {
|
||||
RaterID string `json:"rater_id" yaml:"rater_id"`
|
||||
RateeID string `json:"ratee_id" yaml:"ratee_id"`
|
||||
Category string `json:"category" yaml:"category"`
|
||||
Score float64 `json:"score" yaml:"score"` // 0.0-5.0
|
||||
Weight float64 `json:"weight" yaml:"weight"` // derived from rater Voucher_Weight
|
||||
TxRef string `json:"tx_ref" yaml:"tx_ref"`
|
||||
Timestamp int64 `json:"timestamp" yaml:"timestamp"`
|
||||
DecayBucket uint8 `json:"decay_bucket" yaml:"decay_bucket"`
|
||||
}
|
||||
|
||||
// Vouch is a Freeholder vouch with skin-in-the-game (§9.1)
|
||||
type Vouch struct {
|
||||
VoucherID string `json:"voucher_id" yaml:"voucher_id"`
|
||||
VoucheeID string `json:"vouchee_id" yaml:"vouchee_id"`
|
||||
Category string `json:"category" yaml:"category"`
|
||||
BondAmount int64 `json:"bond_amount" yaml:"bond_amount"` // voucher skin-in-the-game
|
||||
Timestamp int64 `json:"timestamp" yaml:"timestamp"`
|
||||
}
|
||||
|
||||
// Slash penalizes a Holder (§9.4)
|
||||
type Slash struct {
|
||||
ReachID string `json:"reach_id" yaml:"reach_id"`
|
||||
Amount float64 `json:"amount" yaml:"amount"`
|
||||
Reason string `json:"reason" yaml:"reason"` // Crack, FraudulentCoverCall, InactivityTimeout
|
||||
Attester string `json:"attester" yaml:"attester"` // Watcher ID
|
||||
Timestamp int64 `json:"timestamp" yaml:"timestamp"`
|
||||
}
|
||||
|
||||
// StandingBucket for display (§9.2)
|
||||
type StandingBucket string
|
||||
|
||||
const (
|
||||
BucketNew StandingBucket = "New" // <10 ratings
|
||||
BucketTrusted StandingBucket = "Trusted" // 4.0-4.4
|
||||
BucketPreferred StandingBucket = "Preferred" // 4.5-4.7
|
||||
BucketTop StandingBucket = "Top" // 4.8+
|
||||
BucketSlashed StandingBucket = "Slashed" // penalized
|
||||
)
|
||||
|
||||
// FreeholderSignals — all four must be present (§9.1 LOCKED)
|
||||
type FreeholderSignals struct {
|
||||
StashMaturity bool `json:"stash_maturity" yaml:"stash_maturity"`
|
||||
MultiDomainStanding bool `json:"multi_domain_standing" yaml:"multi_domain_standing"`
|
||||
CommittedCapital bool `json:"committed_capital" yaml:"committed_capital"`
|
||||
CommunityEndorsement bool `json:"community_endorsement" yaml:"community_endorsement"`
|
||||
}
|
||||
|
||||
// IsFreeholderEligible checks all four signals (§9.1)
|
||||
// No application, no committee, no form. All four must be present.
|
||||
func (s FreeholderSignals) IsFreeholderEligible() bool {
|
||||
return s.StashMaturity && s.MultiDomainStanding && s.CommittedCapital && s.CommunityEndorsement
|
||||
}
|
||||
|
||||
// ComputeDiversityBonus returns the bonus for category count (§9.2)
|
||||
func ComputeDiversityBonus(categoryCount int) float64 {
|
||||
switch {
|
||||
case categoryCount >= 5:
|
||||
return DiversityBonus5Cats
|
||||
case categoryCount == 4:
|
||||
return DiversityBonus4Cats
|
||||
case categoryCount == 3:
|
||||
return DiversityBonus3Cats
|
||||
}
|
||||
return 0.0
|
||||
}
|
||||
|
||||
// GetVoucherWeight returns the weight for a given rater profile (§9.2)
|
||||
func GetVoucherWeight(isFreeholder bool, standingScore float64, ratingCount int) float64 {
|
||||
if isFreeholder {
|
||||
return VoucherWeightFreeholder
|
||||
}
|
||||
if ratingCount < 10 {
|
||||
return VoucherWeightBelow10Ratings
|
||||
}
|
||||
if standingScore >= 4.5 {
|
||||
return VoucherWeight45Plus
|
||||
}
|
||||
if standingScore >= 4.0 {
|
||||
return VoucherWeight40To45
|
||||
}
|
||||
return VoucherWeightBelow40
|
||||
}
|
||||
|
||||
// GetStandingBucket returns the display bucket for a score (§9.2)
|
||||
func GetStandingBucket(score float64, ratingCount int, isSlashed bool) StandingBucket {
|
||||
if isSlashed {
|
||||
return BucketSlashed
|
||||
}
|
||||
if ratingCount < 10 {
|
||||
return BucketNew
|
||||
}
|
||||
switch {
|
||||
case score >= 4.8:
|
||||
return BucketTop
|
||||
case score >= 4.5:
|
||||
return BucketPreferred
|
||||
case score >= 4.0:
|
||||
return BucketTrusted
|
||||
}
|
||||
return BucketNew
|
||||
}
|
||||
|
||||
type Params struct{}
|
||||
|
||||
func DefaultParams() Params { return Params{} }
|
||||
|
||||
type GenesisState struct {
|
||||
Params Params `json:"params" yaml:"params"`
|
||||
Ratings []Rating `json:"ratings" yaml:"ratings"`
|
||||
}
|
||||
|
||||
func DefaultGenesisState() *GenesisState {
|
||||
return &GenesisState{Params: DefaultParams(), Ratings: []Rating{}}
|
||||
}
|
||||
|
||||
func ValidateGenesis(bz json.RawMessage) error { return nil }
|
||||
@@ -0,0 +1,100 @@
|
||||
package types_test
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/oy/openyield/x/standing/types"
|
||||
)
|
||||
|
||||
func TestFreeholderSignalsAllPresent(t *testing.T) {
|
||||
signals := types.FreeholderSignals{
|
||||
StashMaturity: true,
|
||||
MultiDomainStanding: true,
|
||||
CommittedCapital: true,
|
||||
CommunityEndorsement: true,
|
||||
}
|
||||
if !signals.IsFreeholderEligible() {
|
||||
t.Error("All four signals present should be Freeholder eligible (§9.1)")
|
||||
}
|
||||
}
|
||||
|
||||
func TestFreeholderSignalsMissing(t *testing.T) {
|
||||
signals := types.FreeholderSignals{
|
||||
StashMaturity: true,
|
||||
MultiDomainStanding: true,
|
||||
CommittedCapital: true,
|
||||
CommunityEndorsement: false, // missing
|
||||
}
|
||||
if signals.IsFreeholderEligible() {
|
||||
t.Error("Missing one signal should NOT be Freeholder eligible (§9.1: all four must be present)")
|
||||
}
|
||||
}
|
||||
|
||||
func TestDiversityBonus(t *testing.T) {
|
||||
if types.ComputeDiversityBonus(3) != 0.05 {
|
||||
t.Error("3 categories should give +0.05 bonus (§9.2)")
|
||||
}
|
||||
if types.ComputeDiversityBonus(4) != 0.10 {
|
||||
t.Error("4 categories should give +0.10 bonus (§9.2)")
|
||||
}
|
||||
if types.ComputeDiversityBonus(5) != 0.15 {
|
||||
t.Error("5+ categories should give +0.15 bonus (§9.2)")
|
||||
}
|
||||
if types.ComputeDiversityBonus(2) != 0.0 {
|
||||
t.Error("2 categories should give 0 bonus (§9.2)")
|
||||
}
|
||||
}
|
||||
|
||||
func TestVoucherWeights(t *testing.T) {
|
||||
if types.GetVoucherWeight(true, 4.0, 100) != 1.5 {
|
||||
t.Error("Freeholder weight should be 1.5x (§9.2)")
|
||||
}
|
||||
if types.GetVoucherWeight(false, 4.6, 100) != 1.2 {
|
||||
t.Error("4.5+ with 1-2 cats should be 1.2x (§9.2)")
|
||||
}
|
||||
if types.GetVoucherWeight(false, 4.2, 100) != 1.0 {
|
||||
t.Error("4.0-4.5 should be 1.0x (§9.2)")
|
||||
}
|
||||
if types.GetVoucherWeight(false, 3.5, 100) != 0.5 {
|
||||
t.Error("Below 4.0 should be 0.5x (§9.2)")
|
||||
}
|
||||
if types.GetVoucherWeight(false, 4.0, 5) != 0.3 {
|
||||
t.Error("Below 10 ratings should be 0.3x (§9.2)")
|
||||
}
|
||||
}
|
||||
|
||||
func TestStandingBuckets(t *testing.T) {
|
||||
if types.GetStandingBucket(4.9, 100, false) != types.BucketTop {
|
||||
t.Error("4.8+ should be Top (§9.2)")
|
||||
}
|
||||
if types.GetStandingBucket(4.6, 100, false) != types.BucketPreferred {
|
||||
t.Error("4.5-4.7 should be Preferred (§9.2)")
|
||||
}
|
||||
if types.GetStandingBucket(4.2, 100, false) != types.BucketTrusted {
|
||||
t.Error("4.0-4.4 should be Trusted (§9.2)")
|
||||
}
|
||||
if types.GetStandingBucket(3.5, 100, false) != types.BucketNew {
|
||||
t.Error("Below 4.0 should be New (§9.2)")
|
||||
}
|
||||
if types.GetStandingBucket(4.5, 5, false) != types.BucketNew {
|
||||
t.Error("Below 10 ratings should be New (§9.2)")
|
||||
}
|
||||
if types.GetStandingBucket(4.5, 100, true) != types.BucketSlashed {
|
||||
t.Error("Slashed should be Slashed bucket (§9.2)")
|
||||
}
|
||||
}
|
||||
|
||||
func TestLockedConstants(t *testing.T) {
|
||||
if types.FreeholderStashMaturityDays != 90 {
|
||||
t.Error("Stash maturity should be 90 days (§9.1 LOCKED)")
|
||||
}
|
||||
if types.FreeholderMinStandingScore != 4.5 {
|
||||
t.Error("Min standing score should be 4.5 (§9.1 LOCKED)")
|
||||
}
|
||||
if types.FreeholderMinCategories != 3 {
|
||||
t.Error("Min categories should be 3 (§9.1 LOCKED)")
|
||||
}
|
||||
if types.MinCounterpartiesFreeholderStatus != 30 {
|
||||
t.Error("Min counterparties for Freeholder status should be 30 (§9.2)")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,55 @@
|
||||
package types
|
||||
|
||||
import "encoding/json"
|
||||
|
||||
const (
|
||||
ModuleName = "stash"
|
||||
StoreKey = ModuleName
|
||||
RouterKey = ModuleName
|
||||
QuerierRoute = ModuleName
|
||||
)
|
||||
|
||||
// Stash is a per-Holder sovereign storage (§5)
|
||||
// The Holder owns it. No one else can access it without a Window (§10).
|
||||
type Stash struct {
|
||||
HolderID string `json:"holder_id" yaml:"holder_id"`
|
||||
StashID string `json:"stash_id" yaml:"stash_id"`
|
||||
CreatedAt int64 `json:"created_at" yaml:"created_at"`
|
||||
LastActive int64 `json:"last_active" yaml:"last_active"`
|
||||
BalanceGrain int64 `json:"balance_grain" yaml:"balance_grain"`
|
||||
IsStill bool `json:"is_still" yaml:"is_still"` // Still = Holder paused partner access (§10)
|
||||
}
|
||||
|
||||
// StashActivity tracks Stash maturity for Freeholder signals (§9.1: 90 days, no gap >30 days)
|
||||
type StashActivity struct {
|
||||
StashID string `json:"stash_id" yaml:"stash_id"`
|
||||
ActiveDays uint32 `json:"active_days" yaml:"active_days"`
|
||||
MaxGapDays uint32 `json:"max_gap_days" yaml:"max_gap_days"` // must be ≤30 for Freeholder
|
||||
LastActivityDay int64 `json:"last_activity_day" yaml:"last_activity_day"`
|
||||
}
|
||||
|
||||
// MaturityThreshold for Freeholder signal (§9.1: 90 days)
|
||||
const MaturityThresholdDays = 90
|
||||
|
||||
// MaxGapForMaturity for Freeholder signal (§9.1: no gap > 30 days)
|
||||
const MaxGapForMaturity = 30
|
||||
|
||||
// IsMature checks if Stash activity meets the Freeholder maturity threshold (§9.1)
|
||||
func (a StashActivity) IsMature() bool {
|
||||
return a.ActiveDays >= MaturityThresholdDays && a.MaxGapDays <= MaxGapForMaturity
|
||||
}
|
||||
|
||||
type Params struct{}
|
||||
|
||||
func DefaultParams() Params { return Params{} }
|
||||
|
||||
type GenesisState struct {
|
||||
Params Params `json:"params" yaml:"params"`
|
||||
Stashes []Stash `json:"stashes" yaml:"stashes"`
|
||||
}
|
||||
|
||||
func DefaultGenesisState() *GenesisState {
|
||||
return &GenesisState{Params: DefaultParams(), Stashes: []Stash{}}
|
||||
}
|
||||
|
||||
func ValidateGenesis(bz json.RawMessage) error { return nil }
|
||||
@@ -0,0 +1,43 @@
|
||||
package types_test
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/oy/openyield/x/stash/types"
|
||||
)
|
||||
|
||||
func TestMaturityThreshold(t *testing.T) {
|
||||
if types.MaturityThresholdDays != 90 {
|
||||
t.Errorf("MaturityThresholdDays = %d, expected 90 (§9.1)", types.MaturityThresholdDays)
|
||||
}
|
||||
}
|
||||
|
||||
func TestMaxGapForMaturity(t *testing.T) {
|
||||
if types.MaxGapForMaturity != 30 {
|
||||
t.Errorf("MaxGapForMaturity = %d, expected 30 (§9.1: no gap > 30 days)", types.MaxGapForMaturity)
|
||||
}
|
||||
}
|
||||
|
||||
func TestStashIsMature(t *testing.T) {
|
||||
mature := types.StashActivity{ActiveDays: 90, MaxGapDays: 15}
|
||||
if !mature.IsMature() {
|
||||
t.Error("90 days active, 15 max gap should be mature (§9.1)")
|
||||
}
|
||||
|
||||
notEnoughDays := types.StashActivity{ActiveDays: 89, MaxGapDays: 15}
|
||||
if notEnoughDays.IsMature() {
|
||||
t.Error("89 days should NOT be mature (threshold = 90)")
|
||||
}
|
||||
|
||||
gapTooBig := types.StashActivity{ActiveDays: 100, MaxGapDays: 31}
|
||||
if gapTooBig.IsMature() {
|
||||
t.Error("31-day gap should NOT be mature (max gap = 30)")
|
||||
}
|
||||
}
|
||||
|
||||
func TestStashStillFlag(t *testing.T) {
|
||||
stash := types.Stash{IsStill: true}
|
||||
if !stash.IsStill {
|
||||
t.Error("Stash with IsStill=true should reflect Still state (§10: Holder paused partner access)")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
package types
|
||||
|
||||
import "encoding/json"
|
||||
|
||||
const (
|
||||
ModuleName = "vault"
|
||||
StoreKey = ModuleName
|
||||
RouterKey = ModuleName
|
||||
QuerierRoute = ModuleName
|
||||
)
|
||||
|
||||
// Vault is a per-Stand governed storage (§5)
|
||||
// Governed by the Stand Council (§19). Holds shared funds for a Stand.
|
||||
type Vault struct {
|
||||
VaultID string `json:"vault_id" yaml:"vault_id"`
|
||||
StandID string `json:"stand_id" yaml:"stand_id"`
|
||||
CreatedAt int64 `json:"created_at" yaml:"created_at"`
|
||||
BalanceGrain int64 `json:"balance_grain" yaml:"balance_grain"`
|
||||
}
|
||||
|
||||
// VaultAllocation tracks sub-Vault allocations within a Stand
|
||||
type VaultAllocation struct {
|
||||
VaultID string `json:"vault_id" yaml:"vault_id"`
|
||||
SubVaultID string `json:"sub_vault_id" yaml:"sub_vault_id"`
|
||||
Percentage uint32 `json:"percentage" yaml:"percentage"`
|
||||
Purpose string `json:"purpose" yaml:"purpose"`
|
||||
}
|
||||
|
||||
type Params struct{}
|
||||
|
||||
func DefaultParams() Params { return Params{} }
|
||||
|
||||
type GenesisState struct {
|
||||
Params Params `json:"params" yaml:"params"`
|
||||
Vaults []Vault `json:"vaults" yaml:"vaults"`
|
||||
}
|
||||
|
||||
func DefaultGenesisState() *GenesisState {
|
||||
return &GenesisState{Params: DefaultParams(), Vaults: []Vault{}}
|
||||
}
|
||||
|
||||
func ValidateGenesis(bz json.RawMessage) error { return nil }
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user