From 93a8a3b311a054ab74793ebd2fcfcd8efebf93eb Mon Sep 17 00:00:00 2001 From: cloudinit-bot Date: Mon, 17 Aug 2026 21:18:35 +0000 Subject: [PATCH] docs(P01): complete Orgs+Window foundation phase ---ci--- project: oy phase: 1 milestone: v0.2 status: complete phase_role: execution requirements: covered: [REQ-015, REQ-016, REQ-017, REQ-012] partial: [] ---/ci--- Phase 1 (Orgs+Window foundation) complete. 3 new modules (x/window, x/stand, x/guild) + lexicon meta-test scaffolding (G-004). 143 tests total (53 v0.1 baseline + 90 new), 100% coverage on new packages. Window = fullest primitive (lifecycle Open->Active->Revoked-> Expired, rate-limit, append-only audit log). 9-type Stand enum. Guild Hand-Pass @ 0% fee. G-003 by-ID-string import invariant test green. G-004/G-009 lexicon meta-test + self-test table green. Tagged v0.1.1. --- .ciagent/CHECKPOINT.json | 8 +- .ciagent/oy/P1_SHIP_VERIFICATION.md | 71 ++++ lexicon/lexicon.go | 87 +++++ lexicon_meta_test.go | 177 +++++++++ x/guild/types/types.go | 117 ++++++ x/guild/types/types_test.go | 264 ++++++++++++++ x/stand/types/genesis.go | 49 +++ x/stand/types/genesis_test.go | 126 +++++++ x/stand/types/types.go | 136 +++++++ x/stand/types/types_test.go | 295 +++++++++++++++ x/window/types/genesis.go | 29 ++ x/window/types/genesis_test.go | 139 +++++++ x/window/types/types.go | 175 +++++++++ x/window/types/types_test.go | 537 ++++++++++++++++++++++++++++ 14 files changed, 2206 insertions(+), 4 deletions(-) create mode 100644 .ciagent/oy/P1_SHIP_VERIFICATION.md create mode 100644 lexicon/lexicon.go create mode 100644 lexicon_meta_test.go create mode 100644 x/guild/types/types.go create mode 100644 x/guild/types/types_test.go create mode 100644 x/stand/types/genesis.go create mode 100644 x/stand/types/genesis_test.go create mode 100644 x/stand/types/types.go create mode 100644 x/stand/types/types_test.go create mode 100644 x/window/types/genesis.go create mode 100644 x/window/types/genesis_test.go create mode 100644 x/window/types/types.go create mode 100644 x/window/types/types_test.go diff --git a/.ciagent/CHECKPOINT.json b/.ciagent/CHECKPOINT.json index 401801d..108ae63 100644 --- a/.ciagent/CHECKPOINT.json +++ b/.ciagent/CHECKPOINT.json @@ -1,11 +1,11 @@ { - "phase": 0, - "stage": "plan", + "phase": 1, + "stage": "execute", "milestone": "v0.2", "milestone_type": "feature", "tag_base": "v0.1.x", - "phase_role": "pre_execution", + "phase_role": "execution", "project": "oy", "attempts": 0, - "updated_at": "2026-08-17T21:00:00Z" + "updated_at": "2026-08-17T21:15:00Z" } \ No newline at end of file diff --git a/.ciagent/oy/P1_SHIP_VERIFICATION.md b/.ciagent/oy/P1_SHIP_VERIFICATION.md new file mode 100644 index 0000000..f718601 --- /dev/null +++ b/.ciagent/oy/P1_SHIP_VERIFICATION.md @@ -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. \ No newline at end of file diff --git a/lexicon/lexicon.go b/lexicon/lexicon.go new file mode 100644 index 0000000..db127a3 --- /dev/null +++ b/lexicon/lexicon.go @@ -0,0 +1,87 @@ +// 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) +} diff --git a/lexicon_meta_test.go b/lexicon_meta_test.go new file mode 100644 index 0000000..b6a7488 --- /dev/null +++ b/lexicon_meta_test.go @@ -0,0 +1,177 @@ +// 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. +// +// The synthetic strings are assembled from fragments so this file does not +// contain any banned term as a literal substring (it would otherwise trip +// its own scan; the meta-test file is also excluded from the scan, but the +// self-test keeps the source clean for readability/searchability). +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)) + } + // Each synthetic string embeds exactly one banned term in a plausible + // sentence context. Each must be detected. + synthetic := []string{ + "open a " + terms[0] + " here", // bank + "make a " + terms[1] + " now", // deposit + "compounding " + terms[2] + " rate", // interest + "the " + terms[3] + " is 5pct", // yield + "foreign " + terms[4] + " pair", // currency + "price in " + terms[5], // dollar + "price in " + terms[6], // euro + "freeze the " + terms[7], // account + "move to " + terms[8] + " now", // savings + "the " + terms[9] + " lost money", // depositor + } + if len(synthetic) != len(terms) { + t.Fatalf("synthetic table len = %d, want %d", 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 +} diff --git a/x/guild/types/types.go b/x/guild/types/types.go new file mode 100644 index 0000000..745f7da --- /dev/null +++ b/x/guild/types/types.go @@ -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 +} diff --git a/x/guild/types/types_test.go b/x/guild/types/types_test.go new file mode 100644 index 0000000..51f4710 --- /dev/null +++ b/x/guild/types/types_test.go @@ -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) +} diff --git a/x/stand/types/genesis.go b/x/stand/types/genesis.go new file mode 100644 index 0000000..7a5eb8d --- /dev/null +++ b/x/stand/types/genesis.go @@ -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 +} diff --git a/x/stand/types/genesis_test.go b/x/stand/types/genesis_test.go new file mode 100644 index 0000000..f68ae25 --- /dev/null +++ b/x/stand/types/genesis_test.go @@ -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) + } +} diff --git a/x/stand/types/types.go b/x/stand/types/types.go new file mode 100644 index 0000000..c5d0287 --- /dev/null +++ b/x/stand/types/types.go @@ -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 +} diff --git a/x/stand/types/types_test.go b/x/stand/types/types_test.go new file mode 100644 index 0000000..3841e96 --- /dev/null +++ b/x/stand/types/types_test.go @@ -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) +} diff --git a/x/window/types/genesis.go b/x/window/types/genesis.go new file mode 100644 index 0000000..b35b38f --- /dev/null +++ b/x/window/types/genesis.go @@ -0,0 +1,29 @@ +package types + +import "fmt" + +// ValidateAuditLogs enforces the append-only audit-log invariants (REQ-015): +// 1. entry-ids are unique (no duplicate entry-id in the slice) +// 2. timestamps are non-decreasing (append-only ordering) +// +// This is the data-engineer's genesis schema (G-008); the test assertions live +// in types_test.go (security-engineer's territory). Called by ValidateGenesis +// in types.go. +func ValidateAuditLogs(logs []AuditEntry) error { + seen := make(map[string]bool, len(logs)) + var lastTs int64 = -1 + for i, e := range logs { + if e.EntryID == "" { + return fmt.Errorf("audit log [%d]: empty entry-id", i) + } + if seen[e.EntryID] { + return fmt.Errorf("audit log: duplicate entry-id %q", e.EntryID) + } + seen[e.EntryID] = true + if i > 0 && e.Timestamp < lastTs { + return fmt.Errorf("audit log: timestamps must be non-decreasing (entry %q)", e.EntryID) + } + lastTs = e.Timestamp + } + return nil +} diff --git a/x/window/types/genesis_test.go b/x/window/types/genesis_test.go new file mode 100644 index 0000000..1d2422a --- /dev/null +++ b/x/window/types/genesis_test.go @@ -0,0 +1,139 @@ +package types_test + +import ( + "encoding/json" + "testing" + + "github.com/oy/openyield/x/window/types" +) + +// genesis_test.go holds the security-engineer's test assertions for the +// data-engineer's genesis.go schema (G-008 split). The general lifecycle +// and lexicon tests live in types_test.go; this file focuses on the +// append-only audit-log genesis invariants (REQ-015, P1-01-03). + +// TestGenesisAuditLogAppendOnlyShape asserts the GenesisState carries an +// AuditLogs slice and the empty default is non-nil. +func TestGenesisAuditLogAppendOnlyShape(t *testing.T) { + gs := types.DefaultGenesisState() + if gs.AuditLogs == nil { + t.Fatal("DefaultGenesisState.AuditLogs should be non-nil empty slice") + } + // GenesisState must round-trip through JSON with the audit_logs field. + bz, err := json.Marshal(gs) + if err != nil { + t.Fatalf("marshal: %v", err) + } + var back types.GenesisState + if err := json.Unmarshal(bz, &back); err != nil { + t.Fatalf("unmarshal: %v", err) + } + if back.AuditLogs == nil { + t.Error("unmarshalled AuditLogs should be non-nil") + } +} + +// TestGenesisValidateAuditLogAppendOnlyOrdering is the data-engineer's +// genesis invariant: timestamps must be non-decreasing (append-only). +func TestGenesisValidateAuditLogAppendOnlyOrdering(t *testing.T) { + cases := []struct { + name string + logs []types.AuditEntry + wantErr bool + }{ + { + name: "single entry ok", + logs: []types.AuditEntry{{EntryID: "e1", Timestamp: 100}}, + wantErr: false, + }, + { + name: "equal timestamps ok (append-only allows equal)", + logs: []types.AuditEntry{ + {EntryID: "e1", Timestamp: 100}, + {EntryID: "e2", Timestamp: 100}, + }, + wantErr: false, + }, + { + name: "strictly increasing ok", + logs: []types.AuditEntry{ + {EntryID: "e1", Timestamp: 100}, + {EntryID: "e2", Timestamp: 200}, + {EntryID: "e3", Timestamp: 300}, + }, + wantErr: false, + }, + { + name: "decreasing rejected", + logs: []types.AuditEntry{ + {EntryID: "e1", Timestamp: 300}, + {EntryID: "e2", Timestamp: 100}, + }, + wantErr: true, + }, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + err := types.ValidateAuditLogs(tc.logs) + if tc.wantErr && err == nil { + t.Error("expected error, got nil") + } + if !tc.wantErr && err != nil { + t.Errorf("expected nil, got: %v", err) + } + }) + } +} + +// TestGenesisValidateAuditLogNoDupEntryIDs is the data-engineer's genesis +// invariant: entry-ids must be unique. +func TestGenesisValidateAuditLogNoDupEntryIDs(t *testing.T) { + logs := []types.AuditEntry{ + {EntryID: "e1", Timestamp: 100}, + {EntryID: "e1", Timestamp: 200}, // dup id + } + if err := types.ValidateAuditLogs(logs); err == nil { + t.Error("ValidateAuditLogs should reject duplicate entry-ids") + } +} + +// TestGenesisValidateAuditLogRejectsEmptyEntryID asserts the schema rejects +// empty entry-ids (every audit entry must be identifiable). +func TestGenesisValidateAuditLogRejectsEmptyEntryID(t *testing.T) { + logs := []types.AuditEntry{{EntryID: "", Timestamp: 100}} + if err := types.ValidateAuditLogs(logs); err == nil { + t.Error("ValidateAuditLogs should reject empty entry-id") + } +} + +// TestGenesisValidateGenesisSurfacesAuditLogErrors asserts ValidateGenesis +// composes the audit-log validation into the full genesis validation. +func TestGenesisValidateGenesisSurfacesAuditLogErrors(t *testing.T) { + gs := types.GenesisState{ + Windows: []types.Window{{WindowID: "w1"}}, + AuditLogs: []types.AuditEntry{ + {EntryID: "e1", Timestamp: 200}, + {EntryID: "e2", Timestamp: 100}, // out of order + }, + } + bz, _ := json.Marshal(gs) + if err := types.ValidateGenesis(bz); err == nil { + t.Error("ValidateGenesis should surface audit-log ordering error") + } +} + +// TestGenesisValidateGenesisCleanAuditLog asserts a clean audit log passes +// full genesis validation. +func TestGenesisValidateGenesisCleanAuditLog(t *testing.T) { + gs := types.GenesisState{ + Windows: []types.Window{{WindowID: "w1"}}, + AuditLogs: []types.AuditEntry{ + {EntryID: "e1", Timestamp: 100, Action: "open", Result: "ok", GranterRef: "reach:g"}, + {EntryID: "e2", Timestamp: 200, Action: "revoke", Result: "ok", GranterRef: "reach:g"}, + }, + } + bz, _ := json.Marshal(gs) + if err := types.ValidateGenesis(bz); err != nil { + t.Errorf("ValidateGenesis should accept clean audit log, got: %v", err) + } +} diff --git a/x/window/types/types.go b/x/window/types/types.go new file mode 100644 index 0000000..7fa1df7 --- /dev/null +++ b/x/window/types/types.go @@ -0,0 +1,175 @@ +package types + +import ( + "encoding/json" + "fmt" +) + +const ( + ModuleName = "window" + StoreKey = ModuleName + RouterKey = ModuleName + QuerierRoute = ModuleName +) + +// ScopeKind enumerates the access scopes a Window can open (§4.4, REQ-015). +// A Window's scope is a structured (kind, resource-id) pair so downstream +// modules (Pacts, Partners, Orgs) reference the scope by value, not by +// importing this package's structs (G-003 by-ID-string invariant). +type ScopeKind string + +const ( + ScopeReadStash ScopeKind = "ReadStash" // read a Holder's Stash + ScopeReadStanding ScopeKind = "ReadStanding" // read a Reach's Standing + ScopeProcessPassActForStand ScopeKind = "ProcessPassActForStand" // process a Pass-Act on behalf of a Stand +) + +// Scope is a structured scope pair: what the Window opens. +type Scope struct { + Kind ScopeKind `json:"kind" yaml:"kind"` + ResourceID string `json:"resource_id" yaml:"resource_id"` +} + +// RateLimit caps the number of actions a Window permits (REQ-015). +// A-206: simple counter semantics (actionsConsumed vs maxActions); the +// rate-limit algorithm (token bucket vs sliding window) is deferred to v0.3. +type RateLimit struct { + MaxActions uint32 `json:"max_actions" yaml:"max_actions"` + PerDurationSeconds int64 `json:"per_duration_seconds" yaml:"per_duration_seconds"` + ActionsConsumed uint32 `json:"actions_consumed" yaml:"actions_consumed"` +} + +// Consume increments actions-consumed by one. Returns true if the action was +// permitted (under the cap), false if the cap was reached (blocked). +// A-206: counter semantics — once actions-consumed == max-actions, further +// consumes are blocked until the window resets (v0.3 will define reset). +func (r *RateLimit) Consume() bool { + if r.ActionsConsumed >= r.MaxActions { + return false + } + r.ActionsConsumed++ + return true +} + +// AuditEntry is an append-only audit-log entry for a Window (REQ-015). +// Append-only ordering is enforced by ValidateGenesis (timestamps non-decreasing). +type AuditEntry struct { + EntryID string `json:"entry_id" yaml:"entry_id"` + Timestamp int64 `json:"timestamp" yaml:"timestamp"` + Action string `json:"action" yaml:"action"` + Result string `json:"result" yaml:"result"` + GranterRef string `json:"granter_ref" yaml:"granter_ref"` +} + +// WindowStatus enumerates the lifecycle states of a Window (REQ-015). +type WindowStatus string + +const ( + StatusOpen WindowStatus = "Open" // window created, not yet active + StatusActive WindowStatus = "Active" // window is live and consumable + StatusRevoked WindowStatus = "Revoked" // Holder revoked before expiry + StatusExpired WindowStatus = "Expired" // window end-time has passed +) + +// WindowStatusCount is the locked count of WindowStatus enum values. +// A regression firewall: changing the lifecycle shape breaks this const's test. +const WindowStatusCount = 4 + +// Window is a Holder-authorized, scope-bounded, time-limited, revocable +// delegation of access (REQ-015). Modeled on x/authz Grant + x/feegrant +// FeeAllowance + ocap caveat-bound tokens (macaroons), with a rate-limit and +// append-only audit log. +type Window struct { + WindowID string `json:"window_id" yaml:"window_id"` + GrantorHolder string `json:"grantor_holder" yaml:"grantor_holder"` + Grantee string `json:"grantee" yaml:"grantee"` + Scope Scope `json:"scope" yaml:"scope"` + Start int64 `json:"start" yaml:"start"` + End int64 `json:"end" yaml:"end"` + RateLimit RateLimit `json:"rate_limit" yaml:"rate_limit"` + Revoked bool `json:"revoked" yaml:"revoked"` + Status WindowStatus `json:"status" yaml:"status"` + AuditLogRefs []string `json:"audit_log_refs" yaml:"audit_log_refs"` +} + +// Revoke transitions a Window to the Revoked status (REQ-015). +// Revoke is idempotent: revoking an already-revoked window is a no-op +// (returns nil). Revoking an expired window is also a no-op (expired is +// a terminal state that wins over revoke). The audit-log entry for the +// revoke action is the caller's responsibility (skeleton stub). +func (w *Window) Revoke() error { + // Expired is terminal: revoke is a no-op on an expired window. + if w.Status == StatusExpired { + return nil + } + // Idempotent: revoking an already-revoked window is a no-op. + if w.Status == StatusRevoked { + return nil + } + w.Status = StatusRevoked + w.Revoked = true + return nil +} + +// Expire transitions a Window to the Expired status. Used by the (future) +// keeper's end-block sweep when now > End. Expire is terminal: a later +// Revoke on an expired window is a no-op. +func (w *Window) Expire() { + w.Status = StatusExpired +} + +// Activate transitions a Window from Open to Active (REQ-015 lifecycle). +// Only an Open window can be activated. +func (w *Window) Activate() error { + if w.Status != StatusOpen { + return fmt.Errorf("cannot activate window in status %q", w.Status) + } + w.Status = StatusActive + return nil +} + +// Params for the window module (skeleton — no tunables in v0.2). +type Params struct{} + +func DefaultParams() Params { return Params{} } + +// GenesisState defines the window module genesis state (REQ-015). +// AuditLogs is the append-only audit-log slice; ValidateGenesis enforces +// non-decreasing timestamps + no dup entry-ids (data-engineer schema, G-008). +type GenesisState struct { + Params Params `json:"params" yaml:"params"` + Windows []Window `json:"windows" yaml:"windows"` + AuditLogs []AuditEntry `json:"audit_logs" yaml:"audit_logs"` +} + +func DefaultGenesisState() *GenesisState { + return &GenesisState{ + Params: DefaultParams(), + Windows: []Window{}, + AuditLogs: []AuditEntry{}, + } +} + +// ValidateGenesis performs ID-uniqueness checks (A-212 upgrade from v0.1 +// no-op): rejects duplicate window-ids. Append-only audit-log ordering and +// entry-id uniqueness are enforced by genesis.go's ValidateAuditLogs. +func ValidateGenesis(bz json.RawMessage) error { + var gs GenesisState + if err := json.Unmarshal(bz, &gs); err != nil { + return fmt.Errorf("window: invalid genesis: %w", err) + } + seen := make(map[string]bool, len(gs.Windows)) + for _, w := range gs.Windows { + if w.WindowID == "" { + return fmt.Errorf("window: empty window-id") + } + if seen[w.WindowID] { + return fmt.Errorf("window: duplicate window-id %q", w.WindowID) + } + seen[w.WindowID] = true + } + if err := ValidateAuditLogs(gs.AuditLogs); err != nil { + return fmt.Errorf("window: %w", err) + } + return nil +} diff --git a/x/window/types/types_test.go b/x/window/types/types_test.go new file mode 100644 index 0000000..46ac0fb --- /dev/null +++ b/x/window/types/types_test.go @@ -0,0 +1,537 @@ +package types_test + +import ( + "encoding/json" + "go/parser" + "go/token" + "os" + "path/filepath" + "runtime" + "strings" + "testing" + + "github.com/oy/openyield/lexicon" + "github.com/oy/openyield/x/window/types" +) + +// TestWindowStatusCountLockedConst asserts the WindowStatus enum count is +// exactly 4 (Open, Active, Revoked, Expired). A regression firewall: adding +// or removing a status breaks this test. +func TestWindowStatusCountLockedConst(t *testing.T) { + if types.WindowStatusCount != 4 { + t.Errorf("WindowStatusCount = %d, expected 4 (Open/Active/Revoked/Expired LOCKED)", types.WindowStatusCount) + } + statuses := []types.WindowStatus{ + types.StatusOpen, types.StatusActive, types.StatusRevoked, types.StatusExpired, + } + if len(statuses) != 4 { + t.Errorf("expected 4 WindowStatus consts, got %d", len(statuses)) + } + seen := map[types.WindowStatus]bool{} + for _, s := range statuses { + if seen[s] { + t.Errorf("duplicate WindowStatus %q", s) + } + seen[s] = true + } +} + +// TestWindowLifecycleOpenActiveRevokedExpired walks the full lifecycle: +// Open → Active → Revoked → Expired (terminal). +func TestWindowLifecycleOpenActiveRevokedExpired(t *testing.T) { + w := types.Window{Status: types.StatusOpen} + if w.Status != types.StatusOpen { + t.Fatalf("expected Open, got %q", w.Status) + } + if err := w.Activate(); err != nil { + t.Fatalf("Activate: %v", err) + } + if w.Status != types.StatusActive { + t.Fatalf("expected Active, got %q", w.Status) + } + if err := w.Revoke(); err != nil { + t.Fatalf("Revoke: %v", err) + } + if w.Status != types.StatusRevoked { + t.Fatalf("expected Revoked, got %q", w.Status) + } + if !w.Revoked { + t.Fatal("Revoked flag should be true after Revoke()") + } + // Expire is terminal and is invoked by the keeper end-block sweep. + w.Expire() + // Note: once Revoked, Expire() sets Status to Expired — the lifecycle + // test exercises each transition; the terminal-wins-over-revoke invariant + // is tested separately (TestRevokeAfterExpireIsNoOp). +} + +// TestRevokeTransitionsToRevoked asserts Revoke() on an Active window moves +// it to Revoked and sets the Revoked flag. +func TestRevokeTransitionsToRevoked(t *testing.T) { + w := types.Window{Status: types.StatusActive} + if err := w.Revoke(); err != nil { + t.Fatalf("Revoke on Active: %v", err) + } + if w.Status != types.StatusRevoked { + t.Errorf("expected Revoked, got %q", w.Status) + } + if !w.Revoked { + t.Error("Revoked flag should be true") + } +} + +// TestRevokeAfterExpireIsNoOp asserts Expired is terminal: a Revoke() call +// on an Expired window is a no-op (status stays Expired, no error). +func TestRevokeAfterExpireIsNoOp(t *testing.T) { + w := types.Window{Status: types.StatusExpired} + if err := w.Revoke(); err != nil { + t.Fatalf("Revoke on Expired should be no-op, got error: %v", err) + } + if w.Status != types.StatusExpired { + t.Errorf("Revoke on Expired should not change status; got %q", w.Status) + } +} + +// TestDoubleRevokeIdempotent asserts revoking an already-revoked window is +// idempotent (no error, status stays Revoked). The plan says "double-revoke +// is idempotent OR error (test both paths)" — the skeleton implements the +// idempotent path (returns nil); this test locks that behavior. +func TestDoubleRevokeIdempotent(t *testing.T) { + w := types.Window{Status: types.StatusActive} + _ = w.Revoke() + if w.Status != types.StatusRevoked { + t.Fatalf("first Revoke failed: %q", w.Status) + } + if err := w.Revoke(); err != nil { + t.Fatalf("second Revoke should be idempotent (no error), got: %v", err) + } + if w.Status != types.StatusRevoked { + t.Errorf("double-revoke should keep status Revoked; got %q", w.Status) + } +} + +// TestActivateOnlyFromOpen asserts Activate rejects non-Open windows. +func TestActivateOnlyFromOpen(t *testing.T) { + w := types.Window{Status: types.StatusRevoked} + if err := w.Activate(); err == nil { + t.Error("Activate on Revoked should error") + } + w2 := types.Window{Status: types.StatusActive} + if err := w2.Activate(); err == nil { + t.Error("Activate on already-Active should error") + } +} + +// TestRateLimitConsumeIncrementsAndBlocks asserts the A-206 counter +// semantics: each Consume() increments actions-consumed while under the +// cap, and blocks (returns false) once the cap is reached. +func TestRateLimitConsumeIncrementsAndBlocks(t *testing.T) { + tt := []struct { + name string + maxActions uint32 + consumeN int + wantLast bool // expected return of the Nth consume + wantCount uint32 + }{ + {"under cap", 5, 3, true, 3}, + {"exactly cap", 3, 3, true, 3}, + {"at cap then block", 2, 3, false, 2}, // 3rd consume blocked + {"zero cap blocks all", 0, 1, false, 0}, + } + for _, tc := range tt { + t.Run(tc.name, func(t *testing.T) { + r := types.RateLimit{MaxActions: tc.maxActions} + var last bool + for i := 0; i < tc.consumeN; i++ { + last = r.Consume() + } + if last != tc.wantLast { + t.Errorf("last Consume() = %v, want %v", last, tc.wantLast) + } + if r.ActionsConsumed != tc.wantCount { + t.Errorf("ActionsConsumed = %d, want %d", r.ActionsConsumed, tc.wantCount) + } + }) + } +} + +// TestScopeKindEnumCoverage asserts all three ScopeKind values are distinct +// and non-empty (REQ-015 scope set). +func TestScopeKindEnumCoverage(t *testing.T) { + kinds := []types.ScopeKind{ + types.ScopeReadStash, types.ScopeReadStanding, types.ScopeProcessPassActForStand, + } + if len(kinds) != 3 { + t.Errorf("expected 3 ScopeKind consts, got %d", len(kinds)) + } + seen := map[types.ScopeKind]bool{} + for _, k := range kinds { + if k == "" { + t.Error("empty ScopeKind") + } + if seen[k] { + t.Errorf("duplicate ScopeKind %q", k) + } + seen[k] = true + } +} + +// TestScopeStruct asserts Scope carries kind + resource-id. +func TestScopeStruct(t *testing.T) { + s := types.Scope{Kind: types.ScopeReadStash, ResourceID: "reach:abc"} + if s.Kind != types.ScopeReadStash { + t.Errorf("Kind = %q", s.Kind) + } + if s.ResourceID != "reach:abc" { + t.Errorf("ResourceID = %q", s.ResourceID) + } +} + +// TestDefaultGenesisStateEmpty asserts DefaultGenesisState returns empty +// slices (not nil) for Windows and AuditLogs. +func TestDefaultGenesisStateEmpty(t *testing.T) { + gs := types.DefaultGenesisState() + if gs == nil { + t.Fatal("DefaultGenesisState returned nil") + } + if len(gs.Windows) != 0 { + t.Errorf("Default Windows len = %d, want 0", len(gs.Windows)) + } + if gs.Windows == nil { + t.Error("Default Windows should be non-nil empty slice") + } + if len(gs.AuditLogs) != 0 { + t.Errorf("Default AuditLogs len = %d, want 0", len(gs.AuditLogs)) + } + if gs.AuditLogs == nil { + t.Error("Default AuditLogs should be non-nil empty slice") + } +} + +// TestValidateGenesisRejectsDupWindowIDs asserts A-212: duplicate window-ids +// are rejected (upgrade from v0.1's no-op ValidateGenesis). +func TestValidateGenesisRejectsDupWindowIDs(t *testing.T) { + gs := types.GenesisState{ + Windows: []types.Window{ + {WindowID: "w1"}, + {WindowID: "w1"}, // dup + }, + } + bz, _ := json.Marshal(gs) + if err := types.ValidateGenesis(bz); err == nil { + t.Error("ValidateGenesis should reject duplicate window-ids") + } +} + +// TestValidateGenesisAcceptsUniqueIDs asserts a clean genesis validates. +func TestValidateGenesisAcceptsUniqueIDs(t *testing.T) { + gs := types.GenesisState{ + Windows: []types.Window{ + {WindowID: "w1"}, + {WindowID: "w2"}, + }, + } + bz, _ := json.Marshal(gs) + if err := types.ValidateGenesis(bz); err != nil { + t.Errorf("ValidateGenesis should accept unique ids, got: %v", err) + } +} + +// TestValidateGenesisRejectsEmptyWindowID asserts empty window-id is rejected. +func TestValidateGenesisRejectsEmptyWindowID(t *testing.T) { + gs := types.GenesisState{ + Windows: []types.Window{{WindowID: ""}}, + } + bz, _ := json.Marshal(gs) + if err := types.ValidateGenesis(bz); err == nil { + t.Error("ValidateGenesis should reject empty window-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") + } +} + +// TestAuditLogAppendOnlyOrdering asserts ValidateAuditLogs rejects +// non-decreasing timestamps (append-only invariant, data-engineer schema). +func TestAuditLogAppendOnlyOrdering(t *testing.T) { + tt := []struct { + name string + logs []types.AuditEntry + wantErr bool + }{ + { + name: "empty ok", + logs: []types.AuditEntry{}, + }, + { + name: "non-decreasing ok", + logs: []types.AuditEntry{ + {EntryID: "a1", Timestamp: 100}, + {EntryID: "a2", Timestamp: 100}, + {EntryID: "a3", Timestamp: 200}, + }, + }, + { + name: "decreasing rejected", + logs: []types.AuditEntry{ + {EntryID: "a1", Timestamp: 200}, + {EntryID: "a2", Timestamp: 100}, // out of order + }, + wantErr: true, + }, + { + name: "dup entry-id rejected", + logs: []types.AuditEntry{ + {EntryID: "a1", Timestamp: 100}, + {EntryID: "a1", Timestamp: 200}, // dup id + }, + wantErr: true, + }, + { + name: "empty entry-id rejected", + logs: []types.AuditEntry{ + {EntryID: "", Timestamp: 100}, + }, + wantErr: true, + }, + } + for _, tc := range tt { + t.Run(tc.name, func(t *testing.T) { + err := types.ValidateAuditLogs(tc.logs) + if tc.wantErr && err == nil { + t.Error("expected error, got nil") + } + if !tc.wantErr && err != nil { + t.Errorf("expected nil, got: %v", err) + } + }) + } +} + +// TestValidateGenesisRejectsBadAuditLog asserts ValidateGenesis surfaces +// audit-log errors. +func TestValidateGenesisRejectsBadAuditLog(t *testing.T) { + gs := types.GenesisState{ + AuditLogs: []types.AuditEntry{ + {EntryID: "a1", Timestamp: 200}, + {EntryID: "a2", Timestamp: 100}, // out of order + }, + } + bz, _ := json.Marshal(gs) + if err := types.ValidateGenesis(bz); err == nil { + t.Error("ValidateGenesis should reject out-of-order audit logs") + } +} + +// TestAuditEntryStruct asserts AuditEntry carries all required fields. +func TestAuditEntryStruct(t *testing.T) { + e := types.AuditEntry{ + EntryID: "a1", + Timestamp: 100, + Action: "revoke", + Result: "ok", + GranterRef: "reach:granter", + } + if e.EntryID != "a1" || e.Timestamp != 100 || e.Action != "revoke" || + e.Result != "ok" || e.GranterRef != "reach:granter" { + t.Error("AuditEntry fields not set correctly") + } +} + +// TestWindowStructFields asserts Window carries all required fields. +func TestWindowStructFields(t *testing.T) { + w := types.Window{ + WindowID: "w1", + GrantorHolder: "reach:grantor", + Grantee: "reach:grantee", + Scope: types.Scope{Kind: types.ScopeReadStash, ResourceID: "stash:1"}, + Start: 100, + End: 200, + RateLimit: types.RateLimit{MaxActions: 5, PerDurationSeconds: 60}, + Status: types.StatusOpen, + AuditLogRefs: []string{"a1", "a2"}, + } + if w.WindowID != "w1" || w.GrantorHolder != "reach:grantor" || + w.Grantee != "reach:grantee" || w.Start != 100 || w.End != 200 || + w.Status != types.StatusOpen || len(w.AuditLogRefs) != 2 { + t.Error("Window fields not set correctly") + } +} + +// TestModuleConsts asserts the four Cosmos-convention module consts. +func TestModuleConsts(t *testing.T) { + if types.ModuleName != "window" { + t.Errorf("ModuleName = %q, want %q", types.ModuleName, "window") + } + if types.StoreKey != "window" { + t.Errorf("StoreKey = %q", types.StoreKey) + } + if types.RouterKey != "window" { + t.Errorf("RouterKey = %q", types.RouterKey) + } + if types.QuerierRoute != "window" { + 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) ------------------------------------------------- +// +// The lexicon firewall scans the window package's .go files for the 9 banned +// terms. v0.1 is lexicon-clean in practice but has ZERO lexicon tests (G-002); +// this is the NEW v0.2 firewall. The project-wide meta-test in P1-04-02 +// extends this to all x/**/*.go files. + +// TestLexiconNoBannedTermsInWindowPackage scans every non-test .go file in +// the window/types package directory for the 9 banned terms (case-insensitive). +// Production files only — the test file itself contains the banned terms as +// the list of things to forbid, which is the standard lexicon-test +// bootstrapping pattern. The project-wide meta-test (P1-04-02) scans all +// x/**/*.go (including tests) with self-exclusion. +func TestLexiconNoBannedTermsInWindowPackage(t *testing.T) { + pkgDir := packageDir(t, "github.com/oy/openyield/x/window/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 window/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) + } + } +} + +// --- G-003 by-ID-string import invariant ----------------------------------------- +// +// A-203/G-003: no production (non-test) .go file under x/ may import another +// x//types package by struct (enforced as a TESTED invariant, not +// just a convention). The skeleton keeps ALL inter-module refs by-ID-string +// to avoid import cycles. This test scans every non-test .go file under x/ +// using go/parser and asserts no import path matches +// github.com/oy/openyield/x//types. + +// TestG003NoCrossModuleStructImportsInProduction scans every non-test .go +// file under x/ for imports of other x//types packages. +func TestG003NoCrossModuleStructImportsInProduction(t *testing.T) { + xRoot := repoXRoot(t) + fset := token.NewFileSet() + violations := []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 + } + // Skip test files (G-003 is about production code only). + if strings.HasSuffix(path, "_test.go") { + return nil + } + // Parse imports only (no type checking needed). + f, perr := parser.ParseFile(fset, path, nil, parser.ImportsOnly) + if perr != nil { + return perr + } + // Derive this file's own module to allow same-package imports. + ownTypesPkg := ownTypesImport(path) + for _, imp := range f.Imports { + ip := strings.Trim(imp.Path.Value, `"`) + // Allow a file to import its OWN types package (rare; e.g. an + // alias file). Block imports of OTHER x//types packages. + if isForeignTypesImport(ip) && ip != ownTypesPkg { + rel, _ := filepath.Rel(xRoot, path) + violations = append(violations, rel+" -> "+ip) + } + } + return nil + }) + if err != nil { + t.Fatalf("walk: %v", err) + } + if len(violations) > 0 { + t.Errorf("G-003 violation: production files importing foreign x//types:\n %s", + strings.Join(violations, "\n ")) + } +} + +// isForeignTypesImport reports whether ip is an x//types import +// (the form that would create a cross-module struct dependency). It returns +// true only for imports matching github.com/oy/openyield/x//types. +func isForeignTypesImport(ip string) bool { + const prefix = "github.com/oy/openyield/x/" + if !strings.HasPrefix(ip, prefix) { + return false + } + rest := strings.TrimPrefix(ip, prefix) + // x//types has exactly one "/" after the prefix and ends in /types. + // x//types/foo would be a sub-package (also blocked). + parts := strings.Split(rest, "/") + if len(parts) < 2 { + return false + } + return parts[len(parts)-1] == "types" +} + +// ownTypesImport returns the x//types import path a file at the +// given path belongs to, or "" if the file is not under a types package. +func ownTypesImport(path string) string { + dir := filepath.Dir(path) + if filepath.Base(dir) != "types" { + return "" + } + module := filepath.Base(filepath.Dir(dir)) + return "github.com/oy/openyield/x/" + module + "/types" +} + +// packageDir resolves a Go import path to its filesystem directory by +// walking up from this test file. The v0.2 skeleton has zero external deps, +// so we use runtime.Caller rather than go/build (which would need GOPATH +// setup); the test file's own location anchors the resolution. +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/window/types/types_test.go + // repoRoot = .../oy (4 dirs up: types -> window -> x -> oy) + repoRoot := filepath.Dir(filepath.Dir(filepath.Dir(filepath.Dir(file)))) + rel := strings.TrimPrefix(importPath, "github.com/oy/openyield/") + return filepath.Join(repoRoot, rel) +} + +// repoXRoot returns the absolute path to the repo's x/ directory. +func repoXRoot(t *testing.T) string { + t.Helper() + _, file, _, ok := runtime.Caller(0) + if !ok { + t.Fatal("runtime.Caller failed") + } + // file = .../oy/x/window/types/types_test.go -> x/ is 3 dirs up from file + return filepath.Dir(filepath.Dir(filepath.Dir(file))) +}