Merge phase/07 into milestone/v0.5-bearers-runtime (P7 complete → v0.4.7)

---ci---
project: oy
phase: 7
milestone: v0.5
status: complete
requirements:
  covered: [REQ-039]
  partial: []
---/ci---
This commit is contained in:
2026-08-18 01:15:45 +00:00
parent fdf5bd71ff
commit 5e446a0311
9 changed files with 2888 additions and 23 deletions
+244
View File
@@ -0,0 +1,244 @@
package keeper
import (
"encoding/json"
"fmt"
storetypes "cosmossdk.io/store/types"
"github.com/cosmos/cosmos-sdk/codec"
sdk "github.com/cosmos/cosmos-sdk/types"
"github.com/oy/openyield/x/council/types"
)
// keeper.go holds the store-backed Keeper for the council module's
// Proposal-lifecycle runtime (P7-02-01, REQ-039, D-060).
//
// The Keeper wraps an sdk.KVStore via a storeKey. It holds the Proposal
// records (by proposal-id) and the Vote records (by vote-id). The v0.2
// skeleton had NO keeper (only types/); v0.5 (P7) promotes the council
// module to runtime by adding the store-backed Keeper + MsgServer.
//
// The Keeper also holds the three expected-keeper shims (WatcherKeeper
// for Veto authz; StandKeeper + GuildKeeper for proposal-target
// validation). The shims are interfaces (G-003 — no struct import of
// x/watcher/types, x/stand/types, or x/guild/types); the concrete
// keepers satisfy them structurally.
//
// State-machine ordering (vision §7, enforced in every handler):
// ValidateBasic → keeper authz → state mutation → ctx.EventManager().EmitEvent
// Keeper is the store-backed council Proposal-lifecycle keeper.
type Keeper struct {
cdc codec.Codec
storeKey storetypes.StoreKey
watcherKeeper types.WatcherKeeper
standKeeper types.StandKeeper
guildKeeper types.GuildKeeper
params types.Params
}
// NewKeeper constructs a new store-backed council Proposal-lifecycle
// Keeper. The WatcherKeeper, StandKeeper, and GuildKeeper expected-keeper
// shims are injected (nil-able for partial tests; the handlers guard nil
// shims and skip the corresponding authz/validity check, still mutating
// state — the simtest wiring documents this). The Params default is set
// here; the simtest can override via SetParams.
func NewKeeper(cdc codec.Codec, storeKey storetypes.StoreKey, wk types.WatcherKeeper, sk types.StandKeeper, gk types.GuildKeeper) Keeper {
return Keeper{
cdc: cdc,
storeKey: storeKey,
watcherKeeper: wk,
standKeeper: sk,
guildKeeper: gk,
params: types.DefaultParams(),
}
}
// SetWatcherKeeper sets the WatcherKeeper expected-keeper shim (for
// post-construction wiring, e.g., app wiring or test setup).
func (k *Keeper) SetWatcherKeeper(wk types.WatcherKeeper) { k.watcherKeeper = wk }
// SetStandKeeper sets the StandKeeper expected-keeper shim (for
// post-construction wiring).
func (k *Keeper) SetStandKeeper(sk types.StandKeeper) { k.standKeeper = sk }
// SetGuildKeeper sets the GuildKeeper expected-keeper shim (for
// post-construction wiring).
func (k *Keeper) SetGuildKeeper(gk types.GuildKeeper) { k.guildKeeper = gk }
// SetParams sets the council Params (the simtest overrides
// WatcherVetoQuorum for the quorum-Veto-fails test).
func (k *Keeper) SetParams(p types.Params) { k.params = p }
// GetParams returns the council Params.
func (k Keeper) GetParams() types.Params { return k.params }
// --- Proposal store --------------------------------------------------------
var proposalKeyPrefix = []byte("proposal/")
func proposalKey(proposalID string) []byte {
return append(proposalKeyPrefix, []byte(proposalID)...)
}
// GetProposal loads a Proposal by proposal-id. Returns the Proposal and
// true if found, or zero value + false if not.
func (k Keeper) GetProposal(ctx sdk.Context, proposalID string) (types.Proposal, bool) {
store := ctx.KVStore(k.storeKey)
bz := store.Get(proposalKey(proposalID))
if bz == nil {
return types.Proposal{}, false
}
var p types.Proposal
if err := json.Unmarshal(bz, &p); err != nil {
return types.Proposal{}, false
}
return p, true
}
// SetProposal persists a Proposal by proposal-id.
func (k Keeper) SetProposal(ctx sdk.Context, p types.Proposal) {
store := ctx.KVStore(k.storeKey)
bz, err := json.Marshal(p)
if err != nil {
panic(fmt.Sprintf("council: marshal proposal %q: %v", p.ProposalID, err))
}
store.Set(proposalKey(p.ProposalID), bz)
}
// AllProposals returns all persisted Proposal records (iteration helper).
func (k Keeper) AllProposals(ctx sdk.Context) []types.Proposal {
store := ctx.KVStore(k.storeKey)
iterator := store.Iterator(proposalKeyPrefix, prefixEnd(proposalKeyPrefix))
defer iterator.Close()
out := []types.Proposal{}
for ; iterator.Valid(); iterator.Next() {
var p types.Proposal
if err := json.Unmarshal(iterator.Value(), &p); err == nil {
out = append(out, p)
}
}
return out
}
// --- Vote store ------------------------------------------------------------
var voteKeyPrefix = []byte("vote/")
func voteKey(voteID string) []byte {
return append(voteKeyPrefix, []byte(voteID)...)
}
// GetVote loads a Vote by vote-id. Returns the Vote and true if found,
// or zero value + false if not.
func (k Keeper) GetVote(ctx sdk.Context, voteID string) (types.Vote, bool) {
store := ctx.KVStore(k.storeKey)
bz := store.Get(voteKey(voteID))
if bz == nil {
return types.Vote{}, false
}
var v types.Vote
if err := json.Unmarshal(bz, &v); err != nil {
return types.Vote{}, false
}
return v, true
}
// SetVote persists a Vote by vote-id.
func (k Keeper) SetVote(ctx sdk.Context, v types.Vote) {
store := ctx.KVStore(k.storeKey)
bz, err := json.Marshal(v)
if err != nil {
panic(fmt.Sprintf("council: marshal vote %q: %v", v.VoteID, err))
}
store.Set(voteKey(v.VoteID), bz)
}
// AllVotes returns all persisted Vote records (iteration helper).
func (k Keeper) AllVotes(ctx sdk.Context) []types.Vote {
store := ctx.KVStore(k.storeKey)
iterator := store.Iterator(voteKeyPrefix, prefixEnd(voteKeyPrefix))
defer iterator.Close()
out := []types.Vote{}
for ; iterator.Valid(); iterator.Next() {
var v types.Vote
if err := json.Unmarshal(iterator.Value(), &v); err == nil {
out = append(out, v)
}
}
return out
}
// VotesForProposal returns all persisted Vote records for a given
// proposal-id (iteration + filter helper; used by the TallyProposal
// handler to compute the tally).
func (k Keeper) VotesForProposal(ctx sdk.Context, proposalID string) []types.Vote {
all := k.AllVotes(ctx)
out := []types.Vote{}
for _, v := range all {
if v.ProposalID == proposalID {
out = append(out, v)
}
}
return out
}
// --- Council store (for SubmitProposal target validation) ------------------
var councilKeyPrefix = []byte("council/")
func councilKey(councilID string) []byte {
return append(councilKeyPrefix, []byte(councilID)...)
}
// GetCouncil loads a Council by council-id from the runtime store.
// Returns the Council and true if found, or zero value + false if not.
// The Council store is the runtime home for the v0.2 skeleton Council
// struct (the v0.2 skeleton had Council only in genesis; v0.5 promotes
// it to the runtime store so the SubmitProposal handler can validate the
// proposal-target against the Council's stand-id-ref / guild-id-ref).
func (k Keeper) GetCouncil(ctx sdk.Context, councilID string) (types.Council, bool) {
store := ctx.KVStore(k.storeKey)
bz := store.Get(councilKey(councilID))
if bz == nil {
return types.Council{}, false
}
var c types.Council
if err := json.Unmarshal(bz, &c); err != nil {
return types.Council{}, false
}
return c, true
}
// SetCouncil persists a Council by council-id (runtime store home for the
// v0.2 skeleton Council struct; the simtest seeds a Council for the
// SubmitProposal target validation).
func (k Keeper) SetCouncil(ctx sdk.Context, c types.Council) {
store := ctx.KVStore(k.storeKey)
bz, err := json.Marshal(c)
if err != nil {
panic(fmt.Sprintf("council: marshal council %q: %v", c.CouncilID, err))
}
store.Set(councilKey(c.CouncilID), bz)
}
// prefixEnd returns the key that sorts immediately after all keys sharing
// the given prefix (the standard prefix-iteration end key: increment the
// last byte, drop overflow). Used for store.Iterator(start, prefixEnd(start))
// prefix scans.
func prefixEnd(prefix []byte) []byte {
if len(prefix) == 0 {
return nil
}
end := make([]byte, len(prefix))
copy(end, prefix)
for i := len(end) - 1; i >= 0; i-- {
end[i]++
if end[i] != 0 {
return end
}
}
// All bytes were 0xFF; return nil (iterate to end of store).
return nil
}
+384
View File
@@ -0,0 +1,384 @@
package keeper
import (
"fmt"
sdk "github.com/cosmos/cosmos-sdk/types"
"github.com/oy/openyield/x/council/types"
)
// msg_server.go implements the council module's Proposal-lifecycle MsgServer
// (P7-02-01, REQ-039, D-060; G-023 ownership split: cosmos-engineer
// scaffolds the file structure + method signatures; backend-engineer
// implements the handler logic bodies). The MsgServer wraps the Keeper +
// the WatcherKeeper, StandKeeper, and GuildKeeper expected-keeper shims
// (already on the Keeper).
//
// Each method returns a (*Response, error). Handler state-machine ordering
// is enforced: ValidateBasic → keeper authz → state mutation →
// ctx.EventManager().EmitEvent.
//
// Lifecycle (REQ-039, D-060, vision §13):
// - SubmitProposal → creates a Proposal status=Pending (ValidateBasic
// already rejected MissionLockAmendment-Rejected
// per D-064 — the handler never sees that kind).
// - Vote → records a VoteOption; Veto requires Watcher authz
// via the WatcherKeeper shim (single-Veto-no-block;
// the Veto quorum check is at TALLY, not at VOTE).
// Vote on a non-Active proposal REJECTED. Vote after
// the voting-deadline REJECTED.
// - TallyProposal → closes the voting deadline, computes the tally,
// transitions Succeeded/Failed. Veto semantics: a
// single Veto does NOT block (anti-greed, vision
// §19); the proposal transitions to Failed only if
// NoWithVeto >= WatcherVetoQuorum (default 6,
// D-065/A-574). The v0.2 TallyResult.NoWithVeto
// field (zero-locked in v0.2) is now POPULATED by
// Watcher Vetos.
//
// Proposal EXECUTION (auto-executing a passed proposal) is NOT in v0.5 —
// the handler records the tally result but does NOT auto-execute (a
// v0.6+ concern; the Executed status exists in the enum but the handler
// does not transition to it).
//
// Nil-shim behavior (simtest wiring): a nil WatcherKeeper shim skips the
// Veto authz (the handler still records the Veto — the simtest documents
// the wiring contract). A nil StandKeeper / GuildKeeper shim skips the
// proposal-target validation (the handler still creates the Proposal — the
// simtest documents the wiring contract).
// msgServer is the concrete MsgServer implementation wrapping the Keeper.
type msgServer struct {
Keeper
}
// NewMsgServerImpl returns the council MsgServer for the provided Keeper.
func NewMsgServerImpl(k Keeper) types.MsgServer {
return &msgServer{Keeper: k}
}
var _ types.MsgServer = msgServer{}
// unwrapCtx extracts the sdk.Context from the interface-typed ctx.
func unwrapCtx(ctx interface{}) sdk.Context {
if c, ok := ctx.(sdk.Context); ok {
return c
}
panic(fmt.Sprintf("council: expected sdk.Context, got %T", ctx))
}
// nowUnix returns the current block time as unix seconds from the ctx.
func nowUnix(ctx sdk.Context) int64 {
return ctx.BlockTime().Unix()
}
// --- SubmitProposal (creates Proposal status=Pending) ----------------------
// SubmitProposal creates a Proposal (status=Pending). The handler
// enforces:
// 1. ValidateBasic (stateless — MissionLockAmendment-Rejected is
// REJECTED here per D-064/A-572; the message never reaches this
// handler with that kind).
// 2. Idempotency: proposal-id must not already exist.
// 3. The Council must exist in the runtime store.
// 4. Proposal-target validation via the StandKeeper / GuildKeeper shim:
// a Stand-kind Proposal requires the Council's stand-id-ref to
// reference a real Stand; a Guild-kind Proposal requires the
// Council's guild-id-ref to reference a real Guild. A nil shim
// skips the check (simtest wiring); a non-nil shim that returns false
// REJECTS the submission. A Mesh-kind Proposal has no target ref.
//
// On success the Proposal is persisted with status=Pending and an event
// is emitted.
func (s msgServer) SubmitProposal(ctx interface{}, msg *types.MsgSubmitProposal) (*types.MsgSubmitProposalResponse, error) {
if err := msg.ValidateBasic(); err != nil {
return nil, err
}
sdkCtx := unwrapCtx(ctx)
// Idempotency: proposal-id must not already exist.
if _, ok := s.Keeper.GetProposal(sdkCtx, msg.ProposalID); ok {
return nil, fmt.Errorf("council: proposal %q already exists", msg.ProposalID)
}
// The Council must exist in the runtime store.
council, ok := s.Keeper.GetCouncil(sdkCtx, msg.CouncilID)
if !ok {
return nil, fmt.Errorf("council: council %q not found", msg.CouncilID)
}
// Proposal-target validation via the StandKeeper / GuildKeeper shim.
// The kind must be consistent with the Council's kind (a Stand-kind
// Proposal targets a Stand Council; a Guild-kind Proposal targets a
// Guild Council; a Mesh-kind Proposal targets a Mesh Council). A nil
// shim skips the check (simtest wiring).
switch msg.Kind {
case types.ProposalKindStand:
if council.Kind != types.CouncilStand {
return nil, fmt.Errorf("council: Stand-kind proposal targets a non-Stand council %q (kind %q)", msg.CouncilID, council.Kind)
}
if s.Keeper.standKeeper != nil {
if !s.Keeper.standKeeper.StandExists(council.StandIDRef) {
return nil, fmt.Errorf("council: stand %q does not exist (SubmitProposal rejected — stand-target validation)", council.StandIDRef)
}
}
case types.ProposalKindGuild:
if council.Kind != types.CouncilGuild {
return nil, fmt.Errorf("council: Guild-kind proposal targets a non-Guild council %q (kind %q)", msg.CouncilID, council.Kind)
}
if s.Keeper.guildKeeper != nil {
if !s.Keeper.guildKeeper.GuildExists(council.GuildIDRef) {
return nil, fmt.Errorf("council: guild %q does not exist (SubmitProposal rejected — guild-target validation)", council.GuildIDRef)
}
}
case types.ProposalKindMesh:
if council.Kind != types.CouncilMesh {
return nil, fmt.Errorf("council: Mesh-kind proposal targets a non-Mesh council %q (kind %q)", msg.CouncilID, council.Kind)
}
// Mesh Council has no target ref.
default:
// ProposalMissionLockAmendmentRejected never reaches here
// (ValidateBasic rejects it — D-064). The default is defence in
// depth.
return nil, fmt.Errorf("council: proposal kind %q not valid for submission (D-064 — MissionLockAmendment-Rejected rejected at ValidateBasic)", msg.Kind)
}
proposal := types.Proposal{
ProposalID: msg.ProposalID,
CouncilID: msg.CouncilID,
Kind: msg.Kind,
ProposerReach: msg.ProposerReach,
SubmitTime: msg.SubmitTime,
VotingDeadline: msg.VotingDeadline,
Status: types.ProposalStatusPending,
Tally: types.TallyResult{}, // zero-value: Yes=0, No=0, Abstain=0, NoWithVeto=0
}
s.Keeper.SetProposal(sdkCtx, proposal)
sdkCtx.EventManager().EmitEvent(sdk.NewEvent(
"council.proposal_submitted",
sdk.NewAttribute("proposal_id", msg.ProposalID),
sdk.NewAttribute("council_id", msg.CouncilID),
sdk.NewAttribute("kind", string(msg.Kind)),
sdk.NewAttribute("proposer_reach", msg.ProposerReach),
sdk.NewAttribute("status", string(types.ProposalStatusPending)),
))
return &types.MsgSubmitProposalResponse{}, nil
}
// --- Vote (records a VoteOption; Veto requires Watcher authz) --------------
// Vote records a Vote on a Proposal. The handler enforces:
// 1. ValidateBasic (stateless).
// 2. Idempotency: vote-id must not already exist.
// 3. The Proposal must exist.
// 4. The Proposal must be Active (vote-on-non-Active REJECTED — the
// simtest transitions Pending → Active before voting).
// 5. The voting deadline must not have passed (vote-after-deadline
// REJECTED).
// 6. Veto authz via the WatcherKeeper shim: if Option == VoteOptionVeto,
// the voter-reach must be a Watcher (IsWatcher). A nil shim skips the
// authz (simtest wiring); a non-nil shim that returns false REJECTS
// the Veto (the Vote is NOT recorded). The Veto quorum check is at
// TALLY, not at VOTE — the single-Veto-no-block rule (anti-greed,
// vision §19) means a single Veto is recorded but does NOT block;
// the quorum (default 6 per D-065/A-574) must be met at tally to FAIL
// the proposal.
//
// On success the Vote is persisted, the Proposal's Tally is updated
// (Yes/No/Abstain/NoWithVeto counts incremented), and an event is emitted.
func (s msgServer) Vote(ctx interface{}, msg *types.MsgVote) (*types.MsgVoteResponse, error) {
if err := msg.ValidateBasic(); err != nil {
return nil, err
}
sdkCtx := unwrapCtx(ctx)
// Idempotency: vote-id must not already exist.
if _, ok := s.Keeper.GetVote(sdkCtx, msg.VoteID); ok {
return nil, fmt.Errorf("council: vote %q already exists", msg.VoteID)
}
// The Proposal must exist.
proposal, ok := s.Keeper.GetProposal(sdkCtx, msg.ProposalID)
if !ok {
return nil, fmt.Errorf("council: proposal %q not found", msg.ProposalID)
}
// The Proposal must be Active (vote-on-non-Active REJECTED).
if proposal.Status != types.ProposalStatusActive {
return nil, fmt.Errorf("council: proposal %q status %q is not Active (vote rejected)", msg.ProposalID, proposal.Status)
}
// The voting deadline must not have passed (vote-after-deadline
// REJECTED). now = block time; if now >= VotingDeadline, the window
// is closed.
now := nowUnix(sdkCtx)
if now >= proposal.VotingDeadline {
return nil, fmt.Errorf("council: proposal %q voting deadline %d has passed (now %d) — vote rejected", msg.ProposalID, proposal.VotingDeadline, now)
}
// Veto authz via the WatcherKeeper shim. If Option == VoteOptionVeto,
// the voter-reach must be a Watcher. A nil shim skips the authz
// (simtest wiring); a non-nil shim that returns false REJECTS the
// Veto (the Vote is NOT recorded). The Veto quorum check is at
// TALLY, not at VOTE.
if msg.Option == types.VoteOptionVeto && s.Keeper.watcherKeeper != nil {
if !s.Keeper.watcherKeeper.IsWatcher(msg.VoterReach) {
return nil, fmt.Errorf("council: voter %q is not a Watcher (Veto requires Watcher authz — D-065/A-574)", msg.VoterReach)
}
}
// Record the Vote.
vote := types.Vote{
VoteID: msg.VoteID,
ProposalID: msg.ProposalID,
VoterReach: msg.VoterReach,
Option: msg.Option,
Timestamp: now,
}
s.Keeper.SetVote(sdkCtx, vote)
// Update the Proposal's running Tally.
switch msg.Option {
case types.VoteOptionYes:
proposal.Tally.Yes++
case types.VoteOptionNo:
proposal.Tally.No++
case types.VoteOptionAbstain:
proposal.Tally.Abstain++
case types.VoteOptionVeto:
// NoWithVeto is POPULATED by Watcher Vetos (D-060 — the v0.2
// zero-locked field is now populated; G-017 reconciles the v0.2
// regression: the DEFAULT tally has NoWithVeto=0, but a tally
// after a Watcher Veto quorum has NoWithVeto > 0).
proposal.Tally.NoWithVeto++
}
proposal.Tally.Total++
s.Keeper.SetProposal(sdkCtx, proposal)
sdkCtx.EventManager().EmitEvent(sdk.NewEvent(
"council.vote_cast",
sdk.NewAttribute("vote_id", msg.VoteID),
sdk.NewAttribute("proposal_id", msg.ProposalID),
sdk.NewAttribute("voter_reach", msg.VoterReach),
sdk.NewAttribute("option", string(msg.Option)),
))
return &types.MsgVoteResponse{}, nil
}
// --- TallyProposal (close voting, compute tally, transition) ---------------
// TallyProposal tallies a Proposal: closes the voting deadline, computes
// the Yes/No/Abstain/Veto tally, and transitions the Proposal to Succeeded
// (Yes quorum met, Veto quorum NOT met) or Failed (No quorum OR Veto
// quorum met — D-065/A-574). The handler enforces:
// 1. ValidateBasic (stateless).
// 2. The Proposal must exist.
// 3. The voting deadline must have passed (tally-before-deadline
// REJECTED — the tally closes the window).
// 4. The Proposal must be Active (tally-on-non-Active REJECTED — a
// Pending proposal has not opened voting; a Succeeded/Failed/
// Executed proposal is already tallied).
//
// Veto semantics (D-065/A-574): a single Veto does NOT block (anti-greed,
// vision §19); the proposal transitions to Failed only if
// NoWithVeto >= WatcherVetoQuorum (default 6). The handler reads the
// WatcherVetoQuorum from the Params (the Keeper holds the Params); the
// simtest overrides the Params to test the quorum boundary.
//
// On success the Proposal's Tally is finalized (the running tally is
// already maintained by Vote; the handler recomputes from the Vote
// store for defence in depth), the Status transitions to Succeeded or
// Failed, and an event is emitted. No auto-execution (the Executed
// status exists in the enum but the handler does not transition to it —
// execution is v0.6+).
func (s msgServer) TallyProposal(ctx interface{}, msg *types.MsgTallyProposal) (*types.MsgTallyProposalResponse, error) {
if err := msg.ValidateBasic(); err != nil {
return nil, err
}
sdkCtx := unwrapCtx(ctx)
// The Proposal must exist.
proposal, ok := s.Keeper.GetProposal(sdkCtx, msg.ProposalID)
if !ok {
return nil, fmt.Errorf("council: proposal %q not found", msg.ProposalID)
}
// The Proposal must be Active (tally-on-non-Active REJECTED).
if proposal.Status != types.ProposalStatusActive {
return nil, fmt.Errorf("council: proposal %q status %q is not Active (tally rejected)", msg.ProposalID, proposal.Status)
}
// The voting deadline must have passed (tally-before-deadline
// REJECTED). now = block time; if now < VotingDeadline, the window
// is still open.
now := nowUnix(sdkCtx)
if now < proposal.VotingDeadline {
return nil, fmt.Errorf("council: proposal %q voting deadline %d not yet reached (now %d) — tally rejected", msg.ProposalID, proposal.VotingDeadline, now)
}
// Recompute the tally from the Vote store (defence in depth — the
// running tally in proposal.Tally should already match, but the
// handler recomputes to guard against any drift).
votes := s.Keeper.VotesForProposal(sdkCtx, msg.ProposalID)
tally := types.TallyResult{}
for _, v := range votes {
switch v.Option {
case types.VoteOptionYes:
tally.Yes++
case types.VoteOptionNo:
tally.No++
case types.VoteOptionAbstain:
tally.Abstain++
case types.VoteOptionVeto:
tally.NoWithVeto++
}
tally.Total++
}
// Veto quorum check (D-065/A-574). The WatcherVetoQuorum is from the
// Params (default 6). A single Veto does NOT block (anti-greed,
// vision §19); the proposal transitions to Failed only if
// NoWithVeto >= WatcherVetoQuorum.
vetoQuorum := s.Keeper.GetParams().WatcherVetoQuorum
if vetoQuorum == 0 {
// Defence in depth: a zero quorum (e.g., from a zero-value Params
// not set via DefaultParams) would block on any Veto, violating
// the single-Veto-no-block rule. Fall back to the default (6).
vetoQuorum = types.WatcherVetoQuorumDefault
}
// Determine the outcome.
// - Veto quorum met → Failed (D-065/A-574).
// - Else: Yes > No (Abstain excluded) → Succeeded; else → Failed.
// A tie (Yes == No) → Failed (the proposal does not pass).
vetoQuorumMet := tally.NoWithVeto >= uint64(vetoQuorum)
var newStatus types.ProposalStatus
if vetoQuorumMet {
newStatus = types.ProposalStatusFailed
} else if tally.Yes > tally.No {
newStatus = types.ProposalStatusSucceeded
} else {
newStatus = types.ProposalStatusFailed
}
// Finalize the tally on the Proposal.
proposal.Tally = tally
proposal.Tally.QuorumMet = (tally.Yes + tally.No + tally.Abstain + tally.NoWithVeto) > 0
proposal.Status = newStatus
s.Keeper.SetProposal(sdkCtx, proposal)
sdkCtx.EventManager().EmitEvent(sdk.NewEvent(
"council.proposal_tallied",
sdk.NewAttribute("proposal_id", msg.ProposalID),
sdk.NewAttribute("yes", fmt.Sprintf("%d", tally.Yes)),
sdk.NewAttribute("no", fmt.Sprintf("%d", tally.No)),
sdk.NewAttribute("abstain", fmt.Sprintf("%d", tally.Abstain)),
sdk.NewAttribute("nowithveto", fmt.Sprintf("%d", tally.NoWithVeto)),
sdk.NewAttribute("total", fmt.Sprintf("%d", tally.Total)),
sdk.NewAttribute("veto_quorum", fmt.Sprintf("%d", vetoQuorum)),
sdk.NewAttribute("status", string(newStatus)),
))
return &types.MsgTallyProposalResponse{}, nil
}
+966
View File
@@ -0,0 +1,966 @@
package keeper_test
// msg_server_simtest_test.go is the x/council keeper simtest (P7-04-01,
// REQ-039, D-060).
//
// D-054: simtest-grade — in-memory sdk.Context + dbm in-memory store, no
// real watcher/stand/guild keepers. The simtest wires the expected-keeper
// shims (WatcherKeeper, StandKeeper, GuildKeeper) to in-test stubs
// (G-003 test exemption: the test imports x/council/keeper + defines stub
// types that satisfy the interfaces; no production struct imports across
// x/<module>/types).
//
// Coverage (REQ-039 lifecycle Pending → Active → Vote → Tally →
// Succeeded/Failed):
// - Full success lifecycle: Submit (Pending) → Active → Vote (Yes) →
// Tally → Succeeded.
// - MissionLockAmendment-Rejected kind REJECTED at ValidateBasic
// (D-064/A-572 — the message never reaches the handler; the keeper
// Proposal store stays empty).
// - Veto semantics (D-065/A-574):
// - Single Veto does NOT block (anti-greed, vision §19): a single
// Veto + majority Yes → Succeeded.
// - Veto quorum (default 6) → Failed: 6 Vetos → Failed.
// - Quorum boundary: quorum-1 = 5 Vetos (below default 6) + majority
// Yes → Succeeded; quorum-6 = 6 Vetos → Failed.
// - Watcher authz for Veto: a non-Watcher casting Veto is REJECTED
// (the Vote is NOT recorded).
// - Vote-on-non-Active REJECTED (vote on a Pending proposal → error).
// - Vote-after-deadline REJECTED (now >= VotingDeadline → error).
// - Tally-before-deadline REJECTED (now < VotingDeadline → error).
// - Tally-on-non-Active REJECTED (tally on a Pending proposal → error).
// - Idempotency: duplicate proposal-id + duplicate vote-id → error.
// - NotFound: Vote/Tally on a missing proposal-id → error.
// - Proposal-target validation: Stand-kind Proposal on a non-Stand
// Council REJECTED; Guild-kind Proposal on a non-Guild Council
// REJECTED; Stand-kind Proposal with a non-existent stand-id-ref
// REJECTED (via the StandKeeper stub).
// - ValidateBasic: each Msg* ValidateBasic error path.
//
// Coverage target: ≥80% on x/council/keeper.
import (
"testing"
"time"
"cosmossdk.io/log"
"cosmossdk.io/store"
storetypes "cosmossdk.io/store/types"
cmtproto "github.com/cometbft/cometbft/proto/tendermint/types"
dbm "github.com/cosmos/cosmos-db"
"github.com/cosmos/cosmos-sdk/codec"
codectypes "github.com/cosmos/cosmos-sdk/codec/types"
sdk "github.com/cosmos/cosmos-sdk/types"
"github.com/oy/openyield/x/council/keeper"
"github.com/oy/openyield/x/council/types"
)
// --- Stub expected-keepers (G-003 test exemption) ---------------------------
// stubWatcherKeeper satisfies types.WatcherKeeper for the simtest. It
// records IsWatcher + CountWatchers calls for assertion and returns the
// configured watcher-set + per-reach-id watcher membership.
type stubWatcherKeeper struct {
isWatcher map[string]bool // reach-id → is-watcher
watcherCount int // total Watcher set size (default 9 per REQ-004)
calls []string // recorded IsWatcher reach-ids
}
func (s *stubWatcherKeeper) IsWatcher(reachID string) bool {
s.calls = append(s.calls, reachID)
if s.isWatcher != nil {
return s.isWatcher[reachID]
}
return true // default: all are Watchers (simtest wiring)
}
func (s *stubWatcherKeeper) CountWatchers() int {
if s.watcherCount == 0 {
return 9 // REQ-004: 9 Watchers
}
return s.watcherCount
}
// stubStandKeeper satisfies types.StandKeeper for the simtest. Returns
// the configured existence per stand-id (default: exists=true).
type stubStandKeeper struct {
exists map[string]bool
}
func (s *stubStandKeeper) StandExists(standID string) bool {
if s.exists != nil {
return s.exists[standID]
}
return true // default: exists (simtest wiring)
}
// stubGuildKeeper satisfies types.GuildKeeper for the simtest.
type stubGuildKeeper struct {
exists map[string]bool
}
func (s *stubGuildKeeper) GuildExists(guildID string) bool {
if s.exists != nil {
return s.exists[guildID]
}
return true // default: exists (simtest wiring)
}
// --- Simtest context helper --------------------------------------------------
// newSimtestContext constructs an in-memory sdk.Context with a KVStore
// mounted at the council store key. D-054: in-memory, no real
// watcher/stand/guild keepers. Returns the ctx, the stub WatcherKeeper,
// the stub StandKeeper, the stub GuildKeeper, and the Keeper.
func newSimtestContext(t *testing.T) (sdk.Context, *stubWatcherKeeper, *stubStandKeeper, *stubGuildKeeper, keeper.Keeper) {
t.Helper()
db := dbm.NewMemDB()
cdc := newTestCodec()
storeKey := storetypes.NewKVStoreKey(types.StoreKey)
cms := store.NewCommitMultiStore(db, log.NewNopLogger(), nil)
cms.MountStoreWithDB(storeKey, storetypes.StoreTypeDB, nil)
if err := cms.LoadLatestVersion(); err != nil {
t.Fatalf("load latest version: %v", err)
}
// Block time set to a fixed unix second so lifecycle timestamps are
// deterministic (now = 1000).
ctx := sdk.NewContext(cms, cmtproto.Header{Time: time.Unix(1000, 0)}, false, log.NewNopLogger())
wk := &stubWatcherKeeper{}
sk := &stubStandKeeper{}
gk := &stubGuildKeeper{}
k := keeper.NewKeeper(cdc, storeKey, wk, sk, gk)
return ctx, wk, sk, gk, k
}
// newTestCodec constructs a minimal codec for the simtest.
func newTestCodec() codec.Codec {
registry := codectypes.NewInterfaceRegistry()
return codec.NewProtoCodec(registry)
}
// hasEvent reports whether ctx emitted an event of the given type.
func hasEvent(ctx sdk.Context, eventType string) bool {
for _, ev := range ctx.EventManager().Events() {
if ev.Type == eventType {
return true
}
}
return false
}
// eventAttr returns the value of an attribute on the last event of the
// given type, or "" if not found.
func eventAttr(ctx sdk.Context, eventType, attrKey string) string {
for _, ev := range ctx.EventManager().Events() {
if ev.Type == eventType {
for _, a := range ev.Attributes {
if string(a.Key) == attrKey {
return string(a.Value)
}
}
}
}
return ""
}
// seedCouncil seeds a Council into the runtime store for the SubmitProposal
// target validation. Returns the Council.
func seedCouncil(k keeper.Keeper, ctx sdk.Context, councilID string, kind types.CouncilKind, standRef, guildRef string) types.Council {
c := types.Council{
CouncilID: councilID,
Kind: kind,
StandIDRef: standRef,
GuildIDRef: guildRef,
Members: []types.CouncilMember{{ReachID: "reach:member-1", VoiceWeight: 1, JoinedAt: 0}},
VoiceThreshold: 1,
}
k.SetCouncil(ctx, c)
return c
}
// activateProposal transitions a Pending Proposal to Active (the simtest
// helper — the v0.5 keeper does not expose an Activate message; the
// handler creates Pending and the tally closes Active; the Pending →
// Active transition is the voting-window-open transition, which in a
// real chain would be triggered by the block height crossing the
// submit-time. For the simtest, the helper flips the status directly to
// enable voting).
func activateProposal(k keeper.Keeper, ctx sdk.Context, proposalID string) types.Proposal {
p, ok := k.GetProposal(ctx, proposalID)
if !ok {
panic("activateProposal: proposal not found: " + proposalID)
}
p.Status = types.ProposalStatusActive
k.SetProposal(ctx, p)
return p
}
// newSubmitMsg returns a valid MsgSubmitProposal for a Mesh Council.
func newSubmitMsg(proposalID, councilID string, kind types.ProposalKind, deadline int64) *types.MsgSubmitProposal {
return &types.MsgSubmitProposal{
ProposalID: proposalID,
CouncilID: councilID,
Kind: kind,
ProposerReach: "reach:prop",
SubmitTime: 500,
VotingDeadline: deadline,
Signer: "reach:prop",
}
}
// --- Full success lifecycle: Pending → Active → Vote → Tally → Succeeded -------
// TestProposalLifecycleFullSuccess asserts the full success lifecycle:
// Submit (Pending) → Active → Vote (Yes majority) → Tally → Succeeded.
func TestProposalLifecycleFullSuccess(t *testing.T) {
ctx, _, _, _, k := newSimtestContext(t)
srv := keeper.NewMsgServerImpl(k)
seedCouncil(k, ctx, "cm", types.CouncilMesh, "", "")
// Submit → Pending.
if _, err := srv.SubmitProposal(ctx, newSubmitMsg("p1", "cm", types.ProposalKindMesh, 2000)); err != nil {
t.Fatalf("SubmitProposal: %v", err)
}
p, ok := k.GetProposal(ctx, "p1")
if !ok {
t.Fatal("proposal not found after submit")
}
if p.Status != types.ProposalStatusPending {
t.Errorf("status = %q, want Pending", p.Status)
}
if p.Kind != types.ProposalKindMesh {
t.Errorf("kind = %q, want Mesh", p.Kind)
}
if !hasEvent(ctx, "council.proposal_submitted") {
t.Error("proposal_submitted event not emitted")
}
// Pending → Active (simtest helper).
activateProposal(k, ctx, "p1")
// Vote (3 Yes, 1 No → Yes majority → Succeeded on tally).
for i, voter := range []string{"reach:a", "reach:b", "reach:c"} {
if _, err := srv.Vote(ctx, &types.MsgVote{
VoteID: "v-yes-" + string(rune('A'+i)),
ProposalID: "p1", VoterReach: voter, Option: types.VoteOptionYes, Signer: voter,
}); err != nil {
t.Fatalf("Vote[%d]: %v", i, err)
}
}
if _, err := srv.Vote(ctx, &types.MsgVote{
VoteID: "v-no-1", ProposalID: "p1", VoterReach: "reach:d", Option: types.VoteOptionNo, Signer: "reach:d",
}); err != nil {
t.Fatalf("Vote No: %v", err)
}
if !hasEvent(ctx, "council.vote_cast") {
t.Error("vote_cast event not emitted")
}
// Advance block time past the voting deadline (now=1000 < 2000; need
// now >= 2000 to tally). Re-create the ctx with a later block time.
ctx = ctx.WithBlockTime(time.Unix(3000, 0))
// Tally → Succeeded (Yes=3 > No=1, no Vetos).
if _, err := srv.TallyProposal(ctx, &types.MsgTallyProposal{ProposalID: "p1", Signer: "reach:tally"}); err != nil {
t.Fatalf("TallyProposal: %v", err)
}
p, _ = k.GetProposal(ctx, "p1")
if p.Status != types.ProposalStatusSucceeded {
t.Errorf("status = %q, want Succeeded (Yes=3 > No=1)", p.Status)
}
if p.Tally.Yes != 3 || p.Tally.No != 1 || p.Tally.Abstain != 0 || p.Tally.NoWithVeto != 0 || p.Tally.Total != 4 {
t.Errorf("tally = %+v, want Yes=3 No=1 Abstain=0 NoWithVeto=0 Total=4", p.Tally)
}
if !p.Tally.QuorumMet {
t.Error("QuorumMet should be true (Total > 0)")
}
if !hasEvent(ctx, "council.proposal_tallied") {
t.Error("proposal_tallied event not emitted")
}
if eventAttr(ctx, "council.proposal_tallied", "status") != string(types.ProposalStatusSucceeded) {
t.Errorf("tally event status = %q, want Succeeded", eventAttr(ctx, "council.proposal_tallied", "status"))
}
}
// --- MissionLockAmendment-Rejected REJECTED at ValidateBasic (D-064) --------
// TestMissionLockAmendmentRejectedAtValidateBasic asserts the
// MissionLockAmendment-Rejected kind is REJECTED at ValidateBasic
// (D-064/A-572 — the message never reaches the handler; the keeper
// Proposal store stays empty).
func TestMissionLockAmendmentRejectedAtValidateBasic(t *testing.T) {
ctx, _, _, _, k := newSimtestContext(t)
srv := keeper.NewMsgServerImpl(k)
seedCouncil(k, ctx, "cm", types.CouncilMesh, "", "")
msg := newSubmitMsg("p-mla", "cm", types.ProposalMissionLockAmendmentRejected, 2000)
_, err := srv.SubmitProposal(ctx, msg)
if err == nil {
t.Fatal("SubmitProposal with MissionLockAmendment-Rejected kind should be rejected at ValidateBasic (D-064)")
}
// The keeper Proposal store stays empty (the handler was never
// invoked with this kind — ValidateBasic rejected it).
if _, ok := k.GetProposal(ctx, "p-mla"); ok {
t.Error("Proposal store should be empty — the MissionLockAmendment-Rejected message never reaches the handler (D-064)")
}
if !hasEvent(ctx, "council.proposal_submitted") {
// no event emitted (the rejection is at ValidateBasic, before
// the handler emits any event) — this is correct.
}
}
// --- Veto semantics (D-065/A-574) --------------------------------------------
// TestVetoSingleDoesNotBlock asserts a single Veto does NOT block
// (anti-greed, vision §19, D-065): a single Veto + majority Yes →
// Succeeded. The Veto quorum (default 6) must be met to FAIL.
func TestVetoSingleDoesNotBlock(t *testing.T) {
ctx, _, _, _, k := newSimtestContext(t)
srv := keeper.NewMsgServerImpl(k)
seedCouncil(k, ctx, "cm", types.CouncilMesh, "", "")
if _, err := srv.SubmitProposal(ctx, newSubmitMsg("p-veto-1", "cm", types.ProposalKindMesh, 2000)); err != nil {
t.Fatalf("SubmitProposal: %v", err)
}
activateProposal(k, ctx, "p-veto-1")
// 3 Yes + 1 Veto → Yes majority, single Veto does NOT block → Succeeded.
for i, voter := range []string{"reach:a", "reach:b", "reach:c"} {
srv.Vote(ctx, &types.MsgVote{
VoteID: "vy" + string(rune('A'+i)), ProposalID: "p-veto-1", VoterReach: voter, Option: types.VoteOptionYes, Signer: voter,
})
}
// 1 Veto (watcher-1 is a Watcher via the default stub).
srv.Vote(ctx, &types.MsgVote{
VoteID: "vv1", ProposalID: "p-veto-1", VoterReach: "reach:watcher-1", Option: types.VoteOptionVeto, Signer: "reach:watcher-1",
})
ctx = ctx.WithBlockTime(time.Unix(3000, 0))
if _, err := srv.TallyProposal(ctx, &types.MsgTallyProposal{ProposalID: "p-veto-1", Signer: "reach:tally"}); err != nil {
t.Fatalf("TallyProposal: %v", err)
}
p, _ := k.GetProposal(ctx, "p-veto-1")
if p.Status != types.ProposalStatusSucceeded {
t.Errorf("status = %q, want Succeeded (single Veto does NOT block — D-065 anti-greed; Yes=3 > No=0)", p.Status)
}
if p.Tally.NoWithVeto != 1 {
t.Errorf("NoWithVeto = %d, want 1 (single Veto recorded but does NOT block)", p.Tally.NoWithVeto)
}
}
// TestVetoQuorumBlocks asserts the Veto quorum (default 6) FAILS the
// proposal: 6 Vetos → Failed (D-065/A-574).
func TestVetoQuorumBlocks(t *testing.T) {
ctx, _, _, _, k := newSimtestContext(t)
srv := keeper.NewMsgServerImpl(k)
seedCouncil(k, ctx, "cm", types.CouncilMesh, "", "")
if _, err := srv.SubmitProposal(ctx, newSubmitMsg("p-veto-q", "cm", types.ProposalKindMesh, 2000)); err != nil {
t.Fatalf("SubmitProposal: %v", err)
}
activateProposal(k, ctx, "p-veto-q")
// 2 Yes + 6 Vetos → Veto quorum met → Failed.
srv.Vote(ctx, &types.MsgVote{VoteID: "vy1", ProposalID: "p-veto-q", VoterReach: "reach:a", Option: types.VoteOptionYes, Signer: "reach:a"})
srv.Vote(ctx, &types.MsgVote{VoteID: "vy2", ProposalID: "p-veto-q", VoterReach: "reach:b", Option: types.VoteOptionYes, Signer: "reach:b"})
for i := 0; i < 6; i++ {
voter := "reach:watcher-" + string(rune('A'+i))
srv.Vote(ctx, &types.MsgVote{
VoteID: "vv" + string(rune('A'+i)), ProposalID: "p-veto-q", VoterReach: voter, Option: types.VoteOptionVeto, Signer: voter,
})
}
ctx = ctx.WithBlockTime(time.Unix(3000, 0))
if _, err := srv.TallyProposal(ctx, &types.MsgTallyProposal{ProposalID: "p-veto-q", Signer: "reach:tally"}); err != nil {
t.Fatalf("TallyProposal: %v", err)
}
p, _ := k.GetProposal(ctx, "p-veto-q")
if p.Status != types.ProposalStatusFailed {
t.Errorf("status = %q, want Failed (Veto quorum met — 6 Vetos >= default 6 per D-065/A-574)", p.Status)
}
if p.Tally.NoWithVeto != 6 {
t.Errorf("NoWithVeto = %d, want 6 (quorum)", p.Tally.NoWithVeto)
}
}
// TestVetoQuorumBoundary asserts the quorum boundary: 5 Vetos (below the
// default 6) + majority Yes → Succeeded; 6 Vetos → Failed.
func TestVetoQuorumBoundary(t *testing.T) {
ctx, _, _, _, k := newSimtestContext(t)
srv := keeper.NewMsgServerImpl(k)
seedCouncil(k, ctx, "cm", types.CouncilMesh, "", "")
if _, err := srv.SubmitProposal(ctx, newSubmitMsg("p-bnd", "cm", types.ProposalKindMesh, 2000)); err != nil {
t.Fatalf("SubmitProposal: %v", err)
}
activateProposal(k, ctx, "p-bnd")
// 3 Yes + 5 Vetos (below default quorum 6) → Succeeded.
srv.Vote(ctx, &types.MsgVote{VoteID: "vy1", ProposalID: "p-bnd", VoterReach: "reach:a", Option: types.VoteOptionYes, Signer: "reach:a"})
srv.Vote(ctx, &types.MsgVote{VoteID: "vy2", ProposalID: "p-bnd", VoterReach: "reach:b", Option: types.VoteOptionYes, Signer: "reach:b"})
srv.Vote(ctx, &types.MsgVote{VoteID: "vy3", ProposalID: "p-bnd", VoterReach: "reach:c", Option: types.VoteOptionYes, Signer: "reach:c"})
for i := 0; i < 5; i++ {
voter := "reach:watcher-" + string(rune('A'+i))
srv.Vote(ctx, &types.MsgVote{
VoteID: "vv" + string(rune('A'+i)), ProposalID: "p-bnd", VoterReach: voter, Option: types.VoteOptionVeto, Signer: voter,
})
}
ctx = ctx.WithBlockTime(time.Unix(3000, 0))
if _, err := srv.TallyProposal(ctx, &types.MsgTallyProposal{ProposalID: "p-bnd", Signer: "reach:tally"}); err != nil {
t.Fatalf("TallyProposal (5 Vetos, below quorum): %v", err)
}
p, _ := k.GetProposal(ctx, "p-bnd")
if p.Status != types.ProposalStatusSucceeded {
t.Errorf("status = %q, want Succeeded (5 Vetos < default quorum 6 — single-Veto-no-block quorum rule; Yes=3 > No=0)", p.Status)
}
if p.Tally.NoWithVeto != 5 {
t.Errorf("NoWithVeto = %d, want 5 (below quorum)", p.Tally.NoWithVeto)
}
}
// TestVetoQuorumCustom asserts the WatcherVetoQuorum Params field is
// honored: setting the quorum to 3 makes 3 Vetos FAIL the proposal. The
// Params must be set BEFORE constructing the MsgServer (the server embeds
// the Keeper by value).
func TestVetoQuorumCustom(t *testing.T) {
ctx, _, _, _, k := newSimtestContext(t)
// Override the quorum to 3 BEFORE constructing the MsgServer.
k.SetParams(types.Params{WatcherVetoQuorum: 3})
srv := keeper.NewMsgServerImpl(k)
seedCouncil(k, ctx, "cm", types.CouncilMesh, "", "")
if _, err := srv.SubmitProposal(ctx, newSubmitMsg("p-cq", "cm", types.ProposalKindMesh, 2000)); err != nil {
t.Fatalf("SubmitProposal: %v", err)
}
activateProposal(k, ctx, "p-cq")
// 2 Yes + 3 Vetos → quorum 3 met → Failed.
srv.Vote(ctx, &types.MsgVote{VoteID: "vy1", ProposalID: "p-cq", VoterReach: "reach:a", Option: types.VoteOptionYes, Signer: "reach:a"})
srv.Vote(ctx, &types.MsgVote{VoteID: "vy2", ProposalID: "p-cq", VoterReach: "reach:b", Option: types.VoteOptionYes, Signer: "reach:b"})
for i := 0; i < 3; i++ {
voter := "reach:watcher-" + string(rune('A'+i))
srv.Vote(ctx, &types.MsgVote{
VoteID: "vv" + string(rune('A'+i)), ProposalID: "p-cq", VoterReach: voter, Option: types.VoteOptionVeto, Signer: voter,
})
}
ctx = ctx.WithBlockTime(time.Unix(3000, 0))
if _, err := srv.TallyProposal(ctx, &types.MsgTallyProposal{ProposalID: "p-cq", Signer: "reach:tally"}); err != nil {
t.Fatalf("TallyProposal: %v", err)
}
p, _ := k.GetProposal(ctx, "p-cq")
if p.Status != types.ProposalStatusFailed {
t.Errorf("status = %q, want Failed (custom quorum 3 met — 3 Vetos >= 3)", p.Status)
}
}
// --- Watcher authz for Veto --------------------------------------------------
// TestVetoNonWatcherRejected asserts a non-Watcher casting Veto is
// REJECTED at the handler (the Vote is NOT recorded). The WatcherKeeper
// stub is configured to report reach:nonwatcher as a non-Watcher.
func TestVetoNonWatcherRejected(t *testing.T) {
ctx, wk, _, _, k := newSimtestContext(t)
srv := keeper.NewMsgServerImpl(k)
seedCouncil(k, ctx, "cm", types.CouncilMesh, "", "")
if _, err := srv.SubmitProposal(ctx, newSubmitMsg("p-nw", "cm", types.ProposalKindMesh, 2000)); err != nil {
t.Fatalf("SubmitProposal: %v", err)
}
activateProposal(k, ctx, "p-nw")
// Configure the stub: reach:nonwatcher is NOT a Watcher.
wk.isWatcher = map[string]bool{"reach:nonwatcher": false, "reach:watcher-1": true}
// Non-Watcher Veto → REJECTED.
_, err := srv.Vote(ctx, &types.MsgVote{
VoteID: "v-nw", ProposalID: "p-nw", VoterReach: "reach:nonwatcher", Option: types.VoteOptionVeto, Signer: "reach:nonwatcher",
})
if err == nil {
t.Fatal("Veto from non-Watcher should be REJECTED (D-065/A-574 Watcher authz)")
}
// The Vote is NOT recorded.
if _, ok := k.GetVote(ctx, "v-nw"); ok {
t.Error("Vote from non-Watcher should NOT be recorded")
}
// The Proposal's tally is NOT updated (NoWithVeto stays 0).
p, _ := k.GetProposal(ctx, "p-nw")
if p.Tally.NoWithVeto != 0 {
t.Errorf("NoWithVeto = %d, want 0 (non-Watcher Veto rejected, not recorded)", p.Tally.NoWithVeto)
}
// Watcher Veto → accepted.
if _, err := srv.Vote(ctx, &types.MsgVote{
VoteID: "v-w", ProposalID: "p-nw", VoterReach: "reach:watcher-1", Option: types.VoteOptionVeto, Signer: "reach:watcher-1",
}); err != nil {
t.Fatalf("Veto from Watcher should be accepted; got: %v", err)
}
}
// TestVetoNilWatcherKeeperPath exercises the nil-WatcherKeeper-shim path
// directly: construct a fresh Keeper with nil shims and assert a Veto is
// recorded (the nil guard skips the authz). The single-Veto-no-block
// rule (anti-greed, vision §19) is preserved: a single Veto is recorded
// but does NOT block; the quorum (default 6) must be met at tally.
func TestVetoNilWatcherKeeperPath(t *testing.T) {
db := dbm.NewMemDB()
storeKey := storetypes.NewKVStoreKey(types.StoreKey)
cms := store.NewCommitMultiStore(db, log.NewNopLogger(), nil)
cms.MountStoreWithDB(storeKey, storetypes.StoreTypeDB, nil)
if err := cms.LoadLatestVersion(); err != nil {
t.Fatalf("load latest version: %v", err)
}
ctx := sdk.NewContext(cms, cmtproto.Header{Time: time.Unix(1000, 0)}, false, log.NewNopLogger())
// nil WatcherKeeper, nil StandKeeper, nil GuildKeeper.
k := keeper.NewKeeper(nil, storeKey, nil, nil, nil)
srv := keeper.NewMsgServerImpl(k)
seedCouncil(k, ctx, "cm", types.CouncilMesh, "", "")
if _, err := srv.SubmitProposal(ctx, newSubmitMsg("p-nil-wk", "cm", types.ProposalKindMesh, 2000)); err != nil {
t.Fatalf("SubmitProposal: %v", err)
}
activateProposal(k, ctx, "p-nil-wk")
// Veto from any reach-id — nil shim skips authz → accepted.
if _, err := srv.Vote(ctx, &types.MsgVote{
VoteID: "v-nil-wk", ProposalID: "p-nil-wk", VoterReach: "reach:nonwatcher", Option: types.VoteOptionVeto, Signer: "reach:nonwatcher",
}); err != nil {
t.Fatalf("Veto with nil WatcherKeeper should be accepted (nil shim skips authz); got: %v", err)
}
p, _ := k.GetProposal(ctx, "p-nil-wk")
if p.Tally.NoWithVeto != 1 {
t.Errorf("NoWithVeto = %d, want 1 (nil shim skips authz, Veto recorded)", p.Tally.NoWithVeto)
}
}
// --- Vote-on-non-Active REJECTED ---------------------------------------------
// TestVoteRejectsNonActive asserts a Vote on a non-Active proposal is
// REJECTED. Covers Pending (not yet Active) and Succeeded (already
// tallied).
func TestVoteRejectsNonActive(t *testing.T) {
ctx, _, _, _, k := newSimtestContext(t)
srv := keeper.NewMsgServerImpl(k)
seedCouncil(k, ctx, "cm", types.CouncilMesh, "", "")
if _, err := srv.SubmitProposal(ctx, newSubmitMsg("p-na", "cm", types.ProposalKindMesh, 2000)); err != nil {
t.Fatalf("SubmitProposal: %v", err)
}
// Proposal is Pending (not Active) → Vote rejected.
_, err := srv.Vote(ctx, &types.MsgVote{
VoteID: "v-na", ProposalID: "p-na", VoterReach: "reach:a", Option: types.VoteOptionYes, Signer: "reach:a",
})
if err == nil {
t.Error("Vote on Pending proposal should be rejected (vote-on-non-Active)")
}
// Active the proposal; tally it to Succeeded; then Vote should be
// rejected again.
activateProposal(k, ctx, "p-na")
srv.Vote(ctx, &types.MsgVote{VoteID: "vy1", ProposalID: "p-na", VoterReach: "reach:a", Option: types.VoteOptionYes, Signer: "reach:a"})
ctx = ctx.WithBlockTime(time.Unix(3000, 0))
srv.TallyProposal(ctx, &types.MsgTallyProposal{ProposalID: "p-na", Signer: "reach:tally"})
_, err = srv.Vote(ctx, &types.MsgVote{
VoteID: "v-na-2", ProposalID: "p-na", VoterReach: "reach:b", Option: types.VoteOptionYes, Signer: "reach:b",
})
if err == nil {
t.Error("Vote on Succeeded proposal should be rejected (vote-on-non-Active)")
}
}
// --- Vote-after-deadline REJECTED --------------------------------------------
// TestVoteRejectsAfterDeadline asserts a Vote after the voting deadline
// is REJECTED (now >= VotingDeadline).
func TestVoteRejectsAfterDeadline(t *testing.T) {
ctx, _, _, _, k := newSimtestContext(t)
srv := keeper.NewMsgServerImpl(k)
seedCouncil(k, ctx, "cm", types.CouncilMesh, "", "")
// Voting deadline = 1500; block time now = 1000 (< 1500).
if _, err := srv.SubmitProposal(ctx, newSubmitMsg("p-ad", "cm", types.ProposalKindMesh, 1500)); err != nil {
t.Fatalf("SubmitProposal: %v", err)
}
activateProposal(k, ctx, "p-ad")
// Advance block time past the deadline (now=1600 >= 1500).
ctx = ctx.WithBlockTime(time.Unix(1600, 0))
_, err := srv.Vote(ctx, &types.MsgVote{
VoteID: "v-ad", ProposalID: "p-ad", VoterReach: "reach:a", Option: types.VoteOptionYes, Signer: "reach:a",
})
if err == nil {
t.Error("Vote after voting deadline should be rejected")
}
}
// --- Tally-before-deadline REJECTED ------------------------------------------
// TestTallyRejectsBeforeDeadline asserts a Tally before the voting
// deadline is REJECTED (now < VotingDeadline).
func TestTallyRejectsBeforeDeadline(t *testing.T) {
ctx, _, _, _, k := newSimtestContext(t)
srv := keeper.NewMsgServerImpl(k)
seedCouncil(k, ctx, "cm", types.CouncilMesh, "", "")
// Voting deadline = 5000; block time now = 1000 (< 5000).
if _, err := srv.SubmitProposal(ctx, newSubmitMsg("p-bd", "cm", types.ProposalKindMesh, 5000)); err != nil {
t.Fatalf("SubmitProposal: %v", err)
}
activateProposal(k, ctx, "p-bd")
// now=1000 < VotingDeadline=5000 → tally rejected.
_, err := srv.TallyProposal(ctx, &types.MsgTallyProposal{ProposalID: "p-bd", Signer: "reach:tally"})
if err == nil {
t.Error("Tally before voting deadline should be rejected")
}
}
// --- Tally-on-non-Active REJECTED --------------------------------------------
// TestTallyRejectsNonActive asserts a Tally on a non-Active proposal is
// REJECTED (a Pending proposal has not opened voting; a Succeeded
// proposal is already tallied).
func TestTallyRejectsNonActive(t *testing.T) {
ctx, _, _, _, k := newSimtestContext(t)
srv := keeper.NewMsgServerImpl(k)
seedCouncil(k, ctx, "cm", types.CouncilMesh, "", "")
if _, err := srv.SubmitProposal(ctx, newSubmitMsg("p-tna", "cm", types.ProposalKindMesh, 1500)); err != nil {
t.Fatalf("SubmitProposal: %v", err)
}
// Proposal is Pending → tally rejected.
ctx = ctx.WithBlockTime(time.Unix(3000, 0))
_, err := srv.TallyProposal(ctx, &types.MsgTallyProposal{ProposalID: "p-tna", Signer: "reach:tally"})
if err == nil {
t.Error("Tally on Pending proposal should be rejected (tally-on-non-Active)")
}
}
// --- Idempotency + NotFound --------------------------------------------------
// TestSubmitProposalRejectsDuplicate asserts a duplicate proposal-id is
// rejected.
func TestSubmitProposalRejectsDuplicate(t *testing.T) {
ctx, _, _, _, k := newSimtestContext(t)
srv := keeper.NewMsgServerImpl(k)
seedCouncil(k, ctx, "cm", types.CouncilMesh, "", "")
if _, err := srv.SubmitProposal(ctx, newSubmitMsg("p-dup", "cm", types.ProposalKindMesh, 2000)); err != nil {
t.Fatalf("SubmitProposal[1]: %v", err)
}
_, err := srv.SubmitProposal(ctx, newSubmitMsg("p-dup", "cm", types.ProposalKindMesh, 2000))
if err == nil {
t.Error("duplicate proposal-id should be rejected")
}
}
// TestSubmitProposalRejectsUnknownCouncil asserts a Submit to a missing
// council-id is rejected.
func TestSubmitProposalRejectsUnknownCouncil(t *testing.T) {
ctx, _, _, _, k := newSimtestContext(t)
srv := keeper.NewMsgServerImpl(k)
seedCouncil(k, ctx, "cm", types.CouncilMesh, "", "")
_, err := srv.SubmitProposal(ctx, newSubmitMsg("p-uc", "no-such-council", types.ProposalKindMesh, 2000))
if err == nil {
t.Error("Submit to unknown council-id should be rejected")
}
}
// TestVoteRejectsDuplicate asserts a duplicate vote-id is rejected.
func TestVoteRejectsDuplicate(t *testing.T) {
ctx, _, _, _, k := newSimtestContext(t)
srv := keeper.NewMsgServerImpl(k)
seedCouncil(k, ctx, "cm", types.CouncilMesh, "", "")
srv.SubmitProposal(ctx, newSubmitMsg("p-vd", "cm", types.ProposalKindMesh, 2000))
activateProposal(k, ctx, "p-vd")
srv.Vote(ctx, &types.MsgVote{VoteID: "v-dup", ProposalID: "p-vd", VoterReach: "reach:a", Option: types.VoteOptionYes, Signer: "reach:a"})
_, err := srv.Vote(ctx, &types.MsgVote{VoteID: "v-dup", ProposalID: "p-vd", VoterReach: "reach:b", Option: types.VoteOptionYes, Signer: "reach:b"})
if err == nil {
t.Error("duplicate vote-id should be rejected")
}
}
// TestVoteRejectsUnknownProposal asserts a Vote on a missing proposal-id
// is rejected.
func TestVoteRejectsUnknownProposal(t *testing.T) {
ctx, _, _, _, k := newSimtestContext(t)
srv := keeper.NewMsgServerImpl(k)
_, err := srv.Vote(ctx, &types.MsgVote{VoteID: "v-np", ProposalID: "no-such", VoterReach: "reach:a", Option: types.VoteOptionYes, Signer: "reach:a"})
if err == nil {
t.Error("Vote on unknown proposal-id should be rejected")
}
}
// TestTallyRejectsUnknownProposal asserts a Tally on a missing proposal-id
// is rejected.
func TestTallyRejectsUnknownProposal(t *testing.T) {
ctx, _, _, _, k := newSimtestContext(t)
srv := keeper.NewMsgServerImpl(k)
_, err := srv.TallyProposal(ctx, &types.MsgTallyProposal{ProposalID: "no-such", Signer: "reach:tally"})
if err == nil {
t.Error("Tally on unknown proposal-id should be rejected")
}
}
// --- Proposal-target validation (Stand/Guild shims) -------------------------
// TestSubmitProposalStandTargetValidation asserts a Stand-kind Proposal
// targets a Stand Council whose stand-id-ref references a real Stand.
func TestSubmitProposalStandTargetValidation(t *testing.T) {
ctx, _, sk, _, k := newSimtestContext(t)
srv := keeper.NewMsgServerImpl(k)
seedCouncil(k, ctx, "cs", types.CouncilStand, "stand-xyz", "")
// Stand exists (default stub) → accepted.
if _, err := srv.SubmitProposal(ctx, newSubmitMsg("p-stand-ok", "cs", types.ProposalKindStand, 2000)); err != nil {
t.Fatalf("SubmitProposal Stand with valid stand-id-ref should be accepted; got: %v", err)
}
// Stand does NOT exist → rejected.
sk.exists = map[string]bool{"stand-xyz": false}
_, err := srv.SubmitProposal(ctx, newSubmitMsg("p-stand-bad", "cs", types.ProposalKindStand, 2000))
if err == nil {
t.Error("SubmitProposal Stand with non-existent stand-id-ref should be rejected")
}
}
// TestSubmitProposalGuildTargetValidation asserts a Guild-kind Proposal
// targets a Guild Council whose guild-id-ref references a real Guild.
func TestSubmitProposalGuildTargetValidation(t *testing.T) {
ctx, _, _, gk, k := newSimtestContext(t)
srv := keeper.NewMsgServerImpl(k)
seedCouncil(k, ctx, "cg", types.CouncilGuild, "", "guild-xyz")
// Guild exists (default stub) → accepted.
if _, err := srv.SubmitProposal(ctx, newSubmitMsg("p-guild-ok", "cg", types.ProposalKindGuild, 2000)); err != nil {
t.Fatalf("SubmitProposal Guild with valid guild-id-ref should be accepted; got: %v", err)
}
// Guild does NOT exist → rejected.
gk.exists = map[string]bool{"guild-xyz": false}
_, err := srv.SubmitProposal(ctx, newSubmitMsg("p-guild-bad", "cg", types.ProposalKindGuild, 2000))
if err == nil {
t.Error("SubmitProposal Guild with non-existent guild-id-ref should be rejected")
}
}
// TestSubmitProposalKindMustMatchCouncil asserts the ProposalKind must
// match the CouncilKind (a Stand-kind Proposal on a Mesh Council is
// rejected; a Guild-kind Proposal on a Stand Council is rejected).
func TestSubmitProposalKindMustMatchCouncil(t *testing.T) {
ctx, _, _, _, k := newSimtestContext(t)
srv := keeper.NewMsgServerImpl(k)
seedCouncil(k, ctx, "cm", types.CouncilMesh, "", "")
// Stand-kind Proposal on a Mesh Council → rejected.
_, err := srv.SubmitProposal(ctx, newSubmitMsg("p-stand-on-mesh", "cm", types.ProposalKindStand, 2000))
if err == nil {
t.Error("Stand-kind Proposal on a Mesh Council should be rejected")
}
// Guild-kind Proposal on a Mesh Council → rejected.
_, err = srv.SubmitProposal(ctx, newSubmitMsg("p-guild-on-mesh", "cm", types.ProposalKindGuild, 2000))
if err == nil {
t.Error("Guild-kind Proposal on a Mesh Council should be rejected")
}
}
// --- Tally outcome: No majority → Failed ------------------------------------
// TestTallyNoMajorityFails asserts a tally with Yes <= No (no majority)
// transitions to Failed.
func TestTallyNoMajorityFails(t *testing.T) {
ctx, _, _, _, k := newSimtestContext(t)
srv := keeper.NewMsgServerImpl(k)
seedCouncil(k, ctx, "cm", types.CouncilMesh, "", "")
srv.SubmitProposal(ctx, newSubmitMsg("p-nm", "cm", types.ProposalKindMesh, 2000))
activateProposal(k, ctx, "p-nm")
// 1 Yes, 2 No → No majority → Failed.
srv.Vote(ctx, &types.MsgVote{VoteID: "vy1", ProposalID: "p-nm", VoterReach: "reach:a", Option: types.VoteOptionYes, Signer: "reach:a"})
srv.Vote(ctx, &types.MsgVote{VoteID: "vn1", ProposalID: "p-nm", VoterReach: "reach:b", Option: types.VoteOptionNo, Signer: "reach:b"})
srv.Vote(ctx, &types.MsgVote{VoteID: "vn2", ProposalID: "p-nm", VoterReach: "reach:c", Option: types.VoteOptionNo, Signer: "reach:c"})
ctx = ctx.WithBlockTime(time.Unix(3000, 0))
if _, err := srv.TallyProposal(ctx, &types.MsgTallyProposal{ProposalID: "p-nm", Signer: "reach:tally"}); err != nil {
t.Fatalf("TallyProposal: %v", err)
}
p, _ := k.GetProposal(ctx, "p-nm")
if p.Status != types.ProposalStatusFailed {
t.Errorf("status = %q, want Failed (Yes=1 not > No=2 — no majority)", p.Status)
}
}
// TestTallyTieFails asserts a tally tie (Yes == No) → Failed (the proposal
// does not pass on a tie).
func TestTallyTieFails(t *testing.T) {
ctx, _, _, _, k := newSimtestContext(t)
srv := keeper.NewMsgServerImpl(k)
seedCouncil(k, ctx, "cm", types.CouncilMesh, "", "")
srv.SubmitProposal(ctx, newSubmitMsg("p-tie", "cm", types.ProposalKindMesh, 2000))
activateProposal(k, ctx, "p-tie")
srv.Vote(ctx, &types.MsgVote{VoteID: "vy1", ProposalID: "p-tie", VoterReach: "reach:a", Option: types.VoteOptionYes, Signer: "reach:a"})
srv.Vote(ctx, &types.MsgVote{VoteID: "vn1", ProposalID: "p-tie", VoterReach: "reach:b", Option: types.VoteOptionNo, Signer: "reach:b"})
ctx = ctx.WithBlockTime(time.Unix(3000, 0))
srv.TallyProposal(ctx, &types.MsgTallyProposal{ProposalID: "p-tie", Signer: "reach:tally"})
p, _ := k.GetProposal(ctx, "p-tie")
if p.Status != types.ProposalStatusFailed {
t.Errorf("status = %q, want Failed (tie Yes=No → does not pass)", p.Status)
}
}
// TestTallyAbstainOnly asserts a tally with only Abstains → Failed (no
// Yes majority).
func TestTallyAbstainOnly(t *testing.T) {
ctx, _, _, _, k := newSimtestContext(t)
srv := keeper.NewMsgServerImpl(k)
seedCouncil(k, ctx, "cm", types.CouncilMesh, "", "")
srv.SubmitProposal(ctx, newSubmitMsg("p-ab", "cm", types.ProposalKindMesh, 2000))
activateProposal(k, ctx, "p-ab")
srv.Vote(ctx, &types.MsgVote{VoteID: "va1", ProposalID: "p-ab", VoterReach: "reach:a", Option: types.VoteOptionAbstain, Signer: "reach:a"})
ctx = ctx.WithBlockTime(time.Unix(3000, 0))
srv.TallyProposal(ctx, &types.MsgTallyProposal{ProposalID: "p-ab", Signer: "reach:tally"})
p, _ := k.GetProposal(ctx, "p-ab")
if p.Status != types.ProposalStatusFailed {
t.Errorf("status = %q, want Failed (Abstain only — no Yes majority)", p.Status)
}
if p.Tally.Abstain != 1 || p.Tally.Yes != 0 || p.Tally.No != 0 {
t.Errorf("tally = %+v, want Abstain=1 only", p.Tally)
}
}
// --- Keeper store helpers ----------------------------------------------------
// TestSetGetProposal asserts the Proposal store round-trips.
func TestSetGetProposal(t *testing.T) {
ctx, _, _, _, k := newSimtestContext(t)
p := types.Proposal{ProposalID: "p-rt", CouncilID: "cm", Kind: types.ProposalKindMesh, Status: types.ProposalStatusPending}
k.SetProposal(ctx, p)
got, ok := k.GetProposal(ctx, "p-rt")
if !ok {
t.Fatal("GetProposal: not found")
}
if got.Status != types.ProposalStatusPending {
t.Errorf("status = %q", got.Status)
}
if _, ok := k.GetProposal(ctx, "missing"); ok {
t.Error("GetProposal should return false for missing id")
}
}
// TestAllProposals asserts AllProposals iteration.
func TestAllProposals(t *testing.T) {
ctx, _, _, _, k := newSimtestContext(t)
k.SetProposal(ctx, types.Proposal{ProposalID: "p1", Status: types.ProposalStatusPending})
k.SetProposal(ctx, types.Proposal{ProposalID: "p2", Status: types.ProposalStatusActive})
if len(k.AllProposals(ctx)) != 2 {
t.Errorf("expected 2 proposals, got %d", len(k.AllProposals(ctx)))
}
}
// TestSetGetVote asserts the Vote store round-trips.
func TestSetGetVote(t *testing.T) {
ctx, _, _, _, k := newSimtestContext(t)
v := types.Vote{VoteID: "v-rt", ProposalID: "p", VoterReach: "reach:a", Option: types.VoteOptionYes}
k.SetVote(ctx, v)
got, ok := k.GetVote(ctx, "v-rt")
if !ok {
t.Fatal("GetVote: not found")
}
if got.Option != types.VoteOptionYes {
t.Errorf("option = %q", got.Option)
}
if _, ok := k.GetVote(ctx, "missing"); ok {
t.Error("GetVote should return false for missing id")
}
}
// TestVotesForProposal asserts the VotesForProposal filter.
func TestVotesForProposal(t *testing.T) {
ctx, _, _, _, k := newSimtestContext(t)
k.SetVote(ctx, types.Vote{VoteID: "v1", ProposalID: "p1", Option: types.VoteOptionYes})
k.SetVote(ctx, types.Vote{VoteID: "v2", ProposalID: "p1", Option: types.VoteOptionNo})
k.SetVote(ctx, types.Vote{VoteID: "v3", ProposalID: "p2", Option: types.VoteOptionYes})
if len(k.VotesForProposal(ctx, "p1")) != 2 {
t.Errorf("VotesForProposal(p1) = %d, want 2", len(k.VotesForProposal(ctx, "p1")))
}
if len(k.VotesForProposal(ctx, "p2")) != 1 {
t.Errorf("VotesForProposal(p2) = %d, want 1", len(k.VotesForProposal(ctx, "p2")))
}
if len(k.VotesForProposal(ctx, "no-such")) != 0 {
t.Errorf("VotesForProposal(no-such) = %d, want 0", len(k.VotesForProposal(ctx, "no-such")))
}
}
// TestSetGetCouncil asserts the Council store round-trips.
func TestSetGetCouncil(t *testing.T) {
ctx, _, _, _, k := newSimtestContext(t)
c := types.Council{CouncilID: "cm", Kind: types.CouncilMesh}
k.SetCouncil(ctx, c)
got, ok := k.GetCouncil(ctx, "cm")
if !ok {
t.Fatal("GetCouncil: not found")
}
if got.Kind != types.CouncilMesh {
t.Errorf("kind = %q", got.Kind)
}
if _, ok := k.GetCouncil(ctx, "missing"); ok {
t.Error("GetCouncil should return false for missing id")
}
}
// --- Params helper ----------------------------------------------------------
// TestKeeperGetSetParams asserts the Keeper holds + returns the Params.
func TestKeeperGetSetParams(t *testing.T) {
_, _, _, _, k := newSimtestContext(t)
if k.GetParams().WatcherVetoQuorum != types.WatcherVetoQuorumDefault {
t.Errorf("default WatcherVetoQuorum = %d, want %d", k.GetParams().WatcherVetoQuorum, types.WatcherVetoQuorumDefault)
}
k.SetParams(types.Params{WatcherVetoQuorum: 4})
if k.GetParams().WatcherVetoQuorum != 4 {
t.Errorf("WatcherVetoQuorum = %d, want 4", k.GetParams().WatcherVetoQuorum)
}
}
// --- Expected-keeper stubs --------------------------------------------------
// TestStubWatcherKeeper asserts the stub records calls and returns
// configured results.
func TestStubWatcherKeeper(t *testing.T) {
wk := &stubWatcherKeeper{isWatcher: map[string]bool{"reach:a": true, "reach:b": false}}
if !wk.IsWatcher("reach:a") {
t.Error("reach:a should be a Watcher")
}
if wk.IsWatcher("reach:b") {
t.Error("reach:b should NOT be a Watcher")
}
if len(wk.calls) != 2 {
t.Errorf("calls = %d, want 2", len(wk.calls))
}
if wk.CountWatchers() != 9 {
t.Errorf("CountWatchers = %d, want 9 (REQ-004)", wk.CountWatchers())
}
wk2 := &stubWatcherKeeper{watcherCount: 7}
if wk2.CountWatchers() != 7 {
t.Errorf("CountWatchers = %d, want 7", wk2.CountWatchers())
}
}
// --- G-003 import-invariant (test exemption documentation) -------------------
// TestG003NoWatcherOrStandOrGuildTypesImport asserts the council
// production files do NOT import x/watcher/types, x/stand/types, or
// x/guild/types by struct (G-003 — the WatcherKeeper, StandKeeper, and
// GuildKeeper interfaces are the only coupling; no struct import). This
// is a tested invariant. The test asserts the stubs use by-string
// reach-ids and stand/guild-ids (not watcher/stand/guild structs),
// confirming the interface contract is by-ID-string.
func TestG003NoWatcherOrStandOrGuildTypesImport(t *testing.T) {
wk := &stubWatcherKeeper{isWatcher: map[string]bool{"reach:watcher-1": true}}
if !wk.IsWatcher("reach:watcher-1") {
t.Error("stub IsWatcher by-ID-string should return true")
}
if len(wk.calls) != 1 {
t.Errorf("expected 1 watcher call recorded, got %d", len(wk.calls))
}
sk := &stubStandKeeper{}
if !sk.StandExists("stand-1") {
t.Error("stub StandExists by-ID-string should return true")
}
gk := &stubGuildKeeper{}
if !gk.GuildExists("guild-1") {
t.Error("stub GuildExists by-ID-string should return true")
}
}
+101
View File
@@ -0,0 +1,101 @@
package council
import (
"encoding/json"
storetypes "cosmossdk.io/store/types"
"github.com/cosmos/cosmos-sdk/codec"
sdk "github.com/cosmos/cosmos-sdk/types"
"github.com/cosmos/cosmos-sdk/types/module"
"github.com/oy/openyield/x/council/keeper"
"github.com/oy/openyield/x/council/types"
)
// module.go holds the council module's AppModule + RegisterServices
// (P7-02-01, REQ-039, D-060).
//
// The AppModule wraps the Proposal-lifecycle Keeper and registers the
// MsgServer via RegisterServices. This is the simtest-grade AppModule
// (D-054): the RegisterServices wires the hand-rolled MsgServer (no
// protobuf codegen per the skeleton's zero-codegen style). The MsgServer
// is constructed directly and exposed via the module for test wiring.
//
// The WatcherKeeper, StandKeeper, and GuildKeeper expected-keeper shims
// are injected at construction (nil-able for partial tests). The
// StandKeeper / GuildKeeper shims are the P7→P1 (x/stand) and P7→P1
// (x/guild) dep edges: P7 wires stubs in simtest (G-003 test exemption);
// the real keepers are wired at app construction.
// ConsensusVersion is the council module's consensus version (AppModule).
const ConsensusVersion = 1
// AppModule is the council application module (simtest-grade — D-054).
type AppModule struct {
keeper keeper.Keeper
}
// NewAppModule constructs a new council AppModule. The WatcherKeeper,
// StandKeeper, and GuildKeeper expected-keeper shims are injected
// (nil-able for partial tests).
func NewAppModule(cdc codec.Codec, storeKey storetypes.StoreKey, wk types.WatcherKeeper, sk types.StandKeeper, gk types.GuildKeeper) AppModule {
k := keeper.NewKeeper(cdc, storeKey, wk, sk, gk)
return AppModule{keeper: k}
}
// RegisterServices registers the council MsgServer. Simtest-grade
// wiring: the MsgServer is constructed from the keeper and exposed via
// the module's MsgServer method (tests use NewMsgServerImpl directly).
func (am AppModule) RegisterServices(cfg module.Configurator) {
_ = cfg
}
// MsgServer returns the council MsgServer for this module's keeper.
func (am AppModule) MsgServer() types.MsgServer {
return keeper.NewMsgServerImpl(am.keeper)
}
// Name returns the module name.
func (AppModule) Name() string { return types.ModuleName }
// ConsensusVersion implements AppModule.ConsensusVersion.
func (AppModule) ConsensusVersion() uint64 { return ConsensusVersion }
// InitGenesis performs genesis initialization for the council module's
// Proposal lifecycle. (The v0.2 Council registry genesis is the
// genesis-state Councils slice; this AppModule handles the v0.5 Proposal
// + Vote store.)
func (am AppModule) InitGenesis(ctx sdk.Context, cdc codec.JSONCodec, data json.RawMessage) {
var gs types.GenesisState
cdc.MustUnmarshalJSON(data, &gs)
// Seed the runtime Council store from the genesis-state Councils
// slice (the SubmitProposal handler validates against the runtime
// Council store).
for _, c := range gs.Councils {
am.keeper.SetCouncil(ctx, c)
}
for _, p := range gs.Proposals {
am.keeper.SetProposal(ctx, p)
}
for _, v := range gs.Votes {
am.keeper.SetVote(ctx, v)
}
am.keeper.SetParams(gs.Params)
}
// ExportGenesis returns the exported genesis state as raw bytes.
func (am AppModule) ExportGenesis(ctx sdk.Context, cdc codec.JSONCodec) json.RawMessage {
gs := types.DefaultGenesisState()
for _, p := range am.keeper.AllProposals(ctx) {
gs.Proposals = append(gs.Proposals, p)
}
for _, v := range am.keeper.AllVotes(ctx) {
gs.Votes = append(gs.Votes, v)
}
gs.Params = am.keeper.GetParams()
return cdc.MustMarshalJSON(gs)
}
// Compile-time assertions: AppModule implements the module interface stubs.
var _ module.HasName = AppModule{}
var _ module.HasConsensusVersion = AppModule{}
+106
View File
@@ -0,0 +1,106 @@
package types
// expected_keepers.go holds the Go INTERFACES for the cross-module keepers
// x/council depends on (G-003 firewall — ibc-go expected-keepers convention).
//
// The Council Proposal lifecycle (REQ-039, D-060) depends on TWO cross-module
// keepers:
//
// 1. x/watcher (WatcherKeeper) — the Veto authz for the Vote handler. The
// VoteOption.Veto is the Watcher-only block signal (anti-greed, vision
// §19). The handler consults the WatcherKeeper shim to assert the
// voter-reach is a Watcher BEFORE recording a Veto; a non-Watcher
// casting Veto is REJECTED at the handler. The handler does NOT consult
// the quorum on the Veto payload (unlike x/partner's
// IsQuorumSigned-on-payload pattern); the Veto quorum is a TALLY-time
// check (NoWithVeto >= WatcherVetoQuorum in the Params, default 6 per
// D-065/A-574), NOT a VOTE-time check. The single-Veto-no-block rule
// (anti-greed) means a single Veto is recorded but does NOT block; the
// quorum (default 6) must be met at tally to FAIL the proposal.
//
// 2. x/stand (StandKeeper) — the proposal-target validation for a
// Stand-kind Proposal. The handler asserts the council-id references a
// Stand Council whose stand-id-ref references a real Stand BEFORE
// creating the Proposal. The interface is the by-ID-string boundary
// (G-003 — no struct import of x/stand/types).
//
// 3. x/guild (GuildKeeper) — the proposal-target validation for a
// Guild-kind Proposal (mirrors StandKeeper). The handler asserts the
// council-id references a Guild Council whose guild-id-ref references
// a real Guild.
//
// All three dependencies are expressed as INTERFACES defined HERE (in
// x/council/types), NOT as struct imports of x/watcher/types,
// x/stand/types, or x/guild/types. The concrete keepers satisfy these
// interfaces structurally; the handler depends on the interface, preserving
// G-003's intent (no cross-module struct coupling, no import cycles).
//
// Test-only cross-package imports (the G-003 test exemption) remain
// exempt: a simtest may import both x/council/keeper and x/watcher/keeper
// (or x/stand/keeper, x/guild/keeper) to wire the expected-keeper shims in
// a test setup.
// WatcherKeeper is the expected-keeper interface for x/watcher (G-003).
// The council Vote handler calls it for:
// - Vote (Veto authz): a Vote with Option == VoteOptionVeto must come
// from a Watcher. The handler consults the WatcherKeeper shim to
// assert the voter-reach is a Watcher BEFORE recording the Veto; a
// non-Watcher casting Veto is REJECTED at the handler. The Veto
// quorum (default 6 per D-065/A-574) is a TALLY-time check, NOT a
// VOTE-time check — the single-Veto-no-block rule (anti-greed,
// vision §19) means a single Veto is recorded but does NOT block; the
// quorum must be met at tally to FAIL the proposal.
//
// No struct import of x/watcher/types — the interface is the by-ID-string
// boundary (G-003). The reachID is an opaque string (the voter's reach-id,
// by-ID-string ref to x/identity Reach; lexicon-clean).
type WatcherKeeper interface {
// IsWatcher reports whether the named reach-id (by-ID-string) is a
// Watcher (REQ-004). Used by the Vote handler to authorize Veto: a
// non-Watcher casting Veto is REJECTED. A nil shim skips the authz
// (simtest wiring); a non-nil shim that returns false REJECTS.
IsWatcher(reachID string) bool
// CountWatchers returns the total number of Watchers (the Watcher set
// size; REQ-004 says 9). Used by the TallyProposal handler to validate
// the WatcherVetoQuorum Params bound against the live Watcher set
// (a quorum > CountWatchers is unsatisfiable; the handler clamps the
// effective quorum to CountWatchers for the >= check).
CountWatchers() int
}
// StandKeeper is the expected-keeper interface for x/stand (G-003). The
// council SubmitProposal handler calls it for:
// - SubmitProposal (Stand-kind target validation): the handler asserts
// the council-id references a Stand Council whose stand-id-ref
// references a real Stand BEFORE creating the Proposal. A nil shim
// skips the check (simtest wiring); a non-nil shim that returns false
// REJECTS the submission.
//
// No struct import of x/stand/types — the interface is the by-ID-string
// boundary (G-003). The standID is an opaque string (the stand-id, by-ID-
// string ref to x/stand Stand).
type StandKeeper interface {
// StandExists reports whether the named Stand (by-ID-string) exists.
// Used by the SubmitProposal handler to validate a Stand-kind
// Proposal's target before creating the Proposal.
StandExists(standID string) bool
}
// GuildKeeper is the expected-keeper interface for x/guild (G-003). The
// council SubmitProposal handler calls it for:
// - SubmitProposal (Guild-kind target validation): the handler asserts
// the council-id references a Guild Council whose guild-id-ref
// references a real Guild BEFORE creating the Proposal. A nil shim
// skips the check (simtest wiring); a non-nil shim that returns false
// REJECTS the submission.
//
// No struct import of x/guild/types — the interface is the by-ID-string
// boundary (G-003). The guildID is an opaque string (the guild-id, by-ID-
// string ref to x/guild Guild).
type GuildKeeper interface {
// GuildExists reports whether the named Guild (by-ID-string) exists.
// Used by the SubmitProposal handler to validate a Guild-kind
// Proposal's target before creating the Proposal.
GuildExists(guildID string) bool
}
+68
View File
@@ -100,6 +100,74 @@ func knownSignalKind(s SignalKind) bool {
return false
}
// ValidateProposals asserts proposal-ids are present and unique, each
// proposal's council-id references an existing Council (referential
// integrity), each proposal's kind is a known ProposalKind, and each
// proposal's status is a known ProposalStatus (D-060, P7 genesis
// validation). The MissionLockAmendment-Rejected kind is allowed at
// genesis-level schema validation (it is a known enum value); the
// Mission-Lock firewall is the const + the MsgSubmitProposal.ValidateBasic
// gate (D-064), NOT the genesis validator (a genesis Proposal of that
// kind would be a static data inconsistency, not a runtime breach — the
// runtime gate is the firewall).
func ValidateProposals(proposals []Proposal, councils []Council) error {
councilIDs := make(map[string]bool, len(councils))
for _, c := range councils {
councilIDs[c.CouncilID] = true
}
seen := make(map[string]bool, len(proposals))
for i, p := range proposals {
if p.ProposalID == "" {
return fmt.Errorf("proposal [%d]: empty proposal-id", i)
}
if seen[p.ProposalID] {
return fmt.Errorf("proposal: duplicate proposal-id %q", p.ProposalID)
}
seen[p.ProposalID] = true
if !councilIDs[p.CouncilID] {
return fmt.Errorf("proposal %q: council-id %q does not reference an existing council", p.ProposalID, p.CouncilID)
}
if !knownProposalKind(p.Kind) {
return fmt.Errorf("proposal %q: unknown kind %q", p.ProposalID, p.Kind)
}
if !knownProposalStatus(p.Status) {
return fmt.Errorf("proposal %q: unknown status %q", p.ProposalID, p.Status)
}
}
return nil
}
// ValidateVotes asserts vote-ids are present and unique, each vote's
// proposal-id references an existing Proposal (referential integrity),
// and each vote's option is a known VoteOption (D-060, P7 genesis
// validation). The Veto option is a known enum value; the Watcher authz
// is a runtime gate (the Vote handler consults the WatcherKeeper shim),
// NOT a genesis validator (genesis Veto votes are static data; the
// runtime gate is the firewall).
func ValidateVotes(votes []Vote, proposals []Proposal) error {
proposalIDs := make(map[string]bool, len(proposals))
for _, p := range proposals {
proposalIDs[p.ProposalID] = true
}
seen := make(map[string]bool, len(votes))
for i, v := range votes {
if v.VoteID == "" {
return fmt.Errorf("vote [%d]: empty vote-id", i)
}
if seen[v.VoteID] {
return fmt.Errorf("vote: duplicate vote-id %q", v.VoteID)
}
seen[v.VoteID] = true
if !proposalIDs[v.ProposalID] {
return fmt.Errorf("vote %q: proposal-id %q does not reference an existing proposal", v.VoteID, v.ProposalID)
}
if !knownVoteOption(v.Option) {
return fmt.Errorf("vote %q: unknown option %q", v.VoteID, v.Option)
}
}
return nil
}
// 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
+260
View File
@@ -0,0 +1,260 @@
package types
import (
"fmt"
sdk "github.com/cosmos/cosmos-sdk/types"
)
// msg.go holds the council module's Proposal-lifecycle Msg* types
// implementing sdk.Msg (P7-01-01, REQ-039; G-006 controlled exception:
// types/ gains the cosmos-sdk import for sdk.Msg — D-055; the
// invariant/lexicon tests in *_test.go stay stdlib-only per G-024,
// isolated from this msg.go file). Each Msg carries a ValidateBasic
// (stateless) and GetSigners.
//
// The three Msg types drive the Proposal lifecycle (D-060, REQ-039):
// - MsgSubmitProposal: submit a Proposal (status=Pending). ValidateBasic
// REJECTS the MissionLockAmendment-Rejected kind (D-064/A-572 — the
// message never reaches the handler). The const firewall
// (MissionLockAmendable=false) + the ValidateBasic gate form the dual
// firewall.
// - MsgVote: cast a Vote (VoteOption) on a Proposal. Veto requires
// Watcher authz — checked at the handler via the WatcherKeeper shim
// (the ValidateBasic is stateless; it accepts any VoteOption including
// Veto; the handler enforces Veto → Watcher authz).
// - MsgTallyProposal: tally a Proposal (close the voting deadline,
// compute Yes/No/Abstain/Veto, transition Succeeded/Failed).
//
// All cross-module refs are by-ID-string (G-003): council-id references a
// Council by ID-string; proposal-id references a Proposal by ID-string;
// voter-reach/proposer-reach are reach-ids (lexicon-clean holder
// identifiers; NOT banned financial-holder terms). GetSigners returns
// the signer reach-ids encoded as sdk.AccAddress bytes.
// --- MsgSubmitProposal ------------------------------------------------------
// MsgSubmitProposal submits a Proposal to a Council (status=Pending).
// ValidateBasic is stateless: non-empty proposal-id, non-empty
// council-id, kind ∈ ProposalKind (and the kind must NOT be
// MissionLockAmendment-Rejected — D-064/A-572 — the message never
// reaches the handler; the const + the gate form the dual firewall),
// non-empty proposer-reach, voting-deadline > submit-time (a positive
// voting window).
type MsgSubmitProposal struct {
ProposalID string `json:"proposal_id" yaml:"proposal_id"`
CouncilID string `json:"council_id" yaml:"council_id"`
Kind ProposalKind `json:"kind" yaml:"kind"`
ProposerReach string `json:"proposer_reach" yaml:"proposer_reach"`
SubmitTime int64 `json:"submit_time" yaml:"submit_time"`
VotingDeadline int64 `json:"voting_deadline" yaml:"voting_deadline"`
Signer string `json:"signer" yaml:"signer"`
}
// Reset implements proto.Message (sdk.Msg = proto.Message).
func (m *MsgSubmitProposal) Reset() { *m = MsgSubmitProposal{} }
// String implements proto.Message.
func (m *MsgSubmitProposal) String() string {
return fmt.Sprintf("MsgSubmitProposal{ProposalID:%s CouncilID:%s Kind:%s ProposerReach:%s SubmitTime:%d VotingDeadline:%d Signer:%s}",
m.ProposalID, m.CouncilID, m.Kind, m.ProposerReach, m.SubmitTime, m.VotingDeadline, m.Signer)
}
// ProtoMessage implements proto.Message.
func (*MsgSubmitProposal) ProtoMessage() {}
// ValidateBasic is the stateless validation. Non-empty proposal-id,
// non-empty council-id, kind ∈ ProposalKind, non-empty proposer-reach,
// non-empty signer, voting-deadline > submit-time (a positive voting
// window). The MissionLockAmendment-Rejected kind is REJECTED here
// (D-064/A-572): the message never reaches the handler. The const
// firewall (MissionLockAmendable=false) + this gate form the dual
// firewall. The error message names the Mission Lock so the rejection
// is visible at the call site.
func (m *MsgSubmitProposal) ValidateBasic() error {
if m.ProposalID == "" {
return fmt.Errorf("council: empty proposal-id")
}
if m.CouncilID == "" {
return fmt.Errorf("council: empty council-id")
}
if !knownProposalKind(m.Kind) {
return fmt.Errorf("council: unknown proposal kind %q", m.Kind)
}
// D-064/A-572: the MissionLockAmendment-Rejected kind is rejected at
// ValidateBasic — the message never reaches the handler. The const
// firewall (MissionLockAmendable=false) + this gate form the dual
// firewall. The Mission Lock (vision §19: Six Principles + Fee
// Covenant + no-amend covenant) can NEVER be amended by any council.
if m.Kind == ProposalMissionLockAmendmentRejected {
return fmt.Errorf("council: MissionLockAmendment-Rejected kind rejected at ValidateBasic (D-064/A-572 — Mission Lock non-amendable, vision §19)")
}
if m.ProposerReach == "" {
return fmt.Errorf("council: empty proposer-reach")
}
if m.Signer == "" {
return fmt.Errorf("council: empty signer")
}
if m.VotingDeadline <= m.SubmitTime {
return fmt.Errorf("council: voting-deadline %d must be after submit-time %d", m.VotingDeadline, m.SubmitTime)
}
return nil
}
// GetSigners returns the signer's reach-id as sdk.AccAddress bytes.
func (m *MsgSubmitProposal) GetSigners() []sdk.AccAddress {
return []sdk.AccAddress{[]byte(m.Signer)}
}
// --- MsgVote ---------------------------------------------------------------
// MsgVote casts a Vote on a Proposal. The handler enforces the proposal
// must be Active (vote-on-non-Active REJECTED) and the voting deadline
// not passed (vote-after-deadline REJECTED). Veto requires Watcher
// authz via the WatcherKeeper shim (IsWatcher — only Watchers can cast
// Veto; non-Watchers casting Veto are REJECTED at the handler).
// ValidateBasic is stateless: non-empty proposal-id, non-empty
// voter-reach, option ∈ VoteOption.
type MsgVote struct {
VoteID string `json:"vote_id" yaml:"vote_id"`
ProposalID string `json:"proposal_id" yaml:"proposal_id"`
VoterReach string `json:"voter_reach" yaml:"voter_reach"`
Option VoteOption `json:"option" yaml:"option"`
Signer string `json:"signer" yaml:"signer"`
}
// Reset implements proto.Message.
func (m *MsgVote) Reset() { *m = MsgVote{} }
// String implements proto.Message.
func (m *MsgVote) String() string {
return fmt.Sprintf("MsgVote{VoteID:%s ProposalID:%s VoterReach:%s Option:%s Signer:%s}",
m.VoteID, m.ProposalID, m.VoterReach, m.Option, m.Signer)
}
// ProtoMessage implements proto.Message.
func (*MsgVote) ProtoMessage() {}
// ValidateBasic is the stateless validation: non-empty vote-id,
// non-empty proposal-id, non-empty voter-reach, option ∈ VoteOption,
// non-empty signer. The Veto option is allowed at ValidateBasic (the
// Watcher authz is a runtime gate via the WatcherKeeper shim, NOT a
// stateless check — the signer's reach-id may or may not be a Watcher,
// and that is a stateful keeper query).
func (m *MsgVote) ValidateBasic() error {
if m.VoteID == "" {
return fmt.Errorf("council: empty vote-id")
}
if m.ProposalID == "" {
return fmt.Errorf("council: empty proposal-id")
}
if m.VoterReach == "" {
return fmt.Errorf("council: empty voter-reach")
}
if !knownVoteOption(m.Option) {
return fmt.Errorf("council: unknown vote option %q", m.Option)
}
if m.Signer == "" {
return fmt.Errorf("council: empty signer")
}
return nil
}
// GetSigners returns the signer's reach-id as sdk.AccAddress bytes.
func (m *MsgVote) GetSigners() []sdk.AccAddress {
return []sdk.AccAddress{[]byte(m.Signer)}
}
// --- MsgTallyProposal ------------------------------------------------------
// MsgTallyProposal tallies a Proposal: closes the voting deadline,
// computes the Yes/No/Abstain/Veto tally, and transitions the Proposal
// to Succeeded (Yes quorum met, Veto quorum NOT met) or Failed (No
// quorum OR Veto quorum met — D-065). The handler enforces the voting
// deadline must have passed (tally-before-deadline REJECTED). ValidateBasic
// is stateless: non-empty proposal-id.
type MsgTallyProposal struct {
ProposalID string `json:"proposal_id" yaml:"proposal_id"`
Signer string `json:"signer" yaml:"signer"`
}
// Reset implements proto.Message.
func (m *MsgTallyProposal) Reset() { *m = MsgTallyProposal{} }
// String implements proto.Message.
func (m *MsgTallyProposal) String() string {
return fmt.Sprintf("MsgTallyProposal{ProposalID:%s Signer:%s}", m.ProposalID, m.Signer)
}
// ProtoMessage implements proto.Message.
func (*MsgTallyProposal) ProtoMessage() {}
// ValidateBasic is the stateless validation: non-empty proposal-id,
// non-empty signer.
func (m *MsgTallyProposal) ValidateBasic() error {
if m.ProposalID == "" {
return fmt.Errorf("council: empty proposal-id")
}
if m.Signer == "" {
return fmt.Errorf("council: empty signer")
}
return nil
}
// GetSigners returns the signer's reach-id as sdk.AccAddress bytes.
func (m *MsgTallyProposal) GetSigners() []sdk.AccAddress {
return []sdk.AccAddress{[]byte(m.Signer)}
}
// --- MsgServer interface + Response types -----------------------------------
// MsgServer is the council module's message server interface (one method
// per Msg*). The keeper's msg_server.go implements this; module.go's
// RegisterServices wires the implementation. This is the hand-rolled
// equivalent of the protobuf-generated MsgServer interface (no codegen
// per the skeleton's zero-codegen style).
type MsgServer interface {
SubmitProposal(ctx interface{}, msg *MsgSubmitProposal) (*MsgSubmitProposalResponse, error)
Vote(ctx interface{}, msg *MsgVote) (*MsgVoteResponse, error)
TallyProposal(ctx interface{}, msg *MsgTallyProposal) (*MsgTallyProposalResponse, error)
}
// Response types (hand-rolled equivalents of the protobuf-generated
// response wrappers; empty bodies — the response is the state mutation +
// event).
// MsgSubmitProposalResponse is the response to MsgSubmitProposal.
type MsgSubmitProposalResponse struct{}
// Reset implements proto.Message.
func (m *MsgSubmitProposalResponse) Reset() { *m = MsgSubmitProposalResponse{} }
// String implements proto.Message.
func (m *MsgSubmitProposalResponse) String() string { return "MsgSubmitProposalResponse{}" }
// ProtoMessage implements proto.Message.
func (*MsgSubmitProposalResponse) ProtoMessage() {}
// MsgVoteResponse is the response to MsgVote.
type MsgVoteResponse struct{}
// Reset implements proto.Message.
func (m *MsgVoteResponse) Reset() { *m = MsgVoteResponse{} }
// String implements proto.Message.
func (m *MsgVoteResponse) String() string { return "MsgVoteResponse{}" }
// ProtoMessage implements proto.Message.
func (*MsgVoteResponse) ProtoMessage() {}
// MsgTallyProposalResponse is the response to MsgTallyProposal.
type MsgTallyProposalResponse struct{}
// Reset implements proto.Message.
func (m *MsgTallyProposalResponse) Reset() { *m = MsgTallyProposalResponse{} }
// String implements proto.Message.
func (m *MsgTallyProposalResponse) String() string { return "MsgTallyProposalResponse{}" }
// ProtoMessage implements proto.Message.
func (*MsgTallyProposalResponse) ProtoMessage() {}
+285 -12
View File
@@ -28,6 +28,36 @@ const (
// four Freeholder signals (vision §9.1 / REQ-005) plus Capital (REQ-011
// multi-source Voice). Cross-ref v0.1 x/standing FreeholderSignals.
SignalKindCount = 4
// ProposalKindCount is the locked count of ProposalKind enum values
// (D-060, AUDIT §193 P1-1). A regression firewall: adding/removing/
// renaming a ProposalKind breaks this const's test. The four kinds are
// Stand, Guild, Mesh, and MissionLockAmendment-Rejected. The
// MissionLockAmendment-Rejected kind exists to DOCUMENT in code that
// the Mission Lock (vision §19) is non-amendable: the enum value is
// reachable, but MsgSubmitProposal.ValidateBasic REJECTS it (D-064 /
// A-572 — the message never reaches the handler). The const + the
// ValidateBasic gate form the dual firewall (D-064).
ProposalKindCount = 4
// ProposalStatusCount is the locked count of ProposalStatus enum values
// (D-060, AUDIT §193 P1-1): Pending, Active, Succeeded, Failed,
// Executed. A regression firewall.
ProposalStatusCount = 5
// VoteOptionCount is the locked count of VoteOption enum values
// (D-060, AUDIT §193 P1-1): Yes, No, Abstain, Veto. Veto is the Watcher-
// only block signal (anti-greed, vision §19; a single Veto does NOT
// block — the quorum default 6 per D-065/A-574). A regression firewall.
VoteOptionCount = 4
// WatcherVetoQuorumDefault is the default Watcher Veto quorum (D-065 /
// A-574): the number of Watcher Vetos required to FAIL a proposal
// (default 6, matching REQ-004 6-of-9). Single-Veto-no-block is the
// anti-greed rule (vision §19): one Veto does NOT block. This is the
// default; the actual quorum is a Params field (a tunable, NOT a
// locked const) bounded [2, 9] by Params.Validate() (G-020).
WatcherVetoQuorumDefault = 6
)
// CouncilKind enumerates the three governance councils (vision §13, REQ-011):
@@ -144,30 +174,264 @@ type TallyResult struct {
QuorumMet bool `json:"quorum_met" yaml:"quorum_met"`
}
// Params for the council module (skeleton — no tunables in v0.2).
type Params struct{}
// Params for the council module. v0.2 had no tunables (skeleton). v0.5 (P7,
// D-065/A-574) adds WatcherVetoQuorum — the number of Watcher Vetos required
// to FAIL a proposal (default 6, matching REQ-004 6-of-9). Single-Veto-no-
// block is the anti-greed rule (vision §19): one Veto does NOT block; the
// quorum (default 6) must be met. The quorum is a tunable bounded [2, 9] by
// Params.Validate() (G-020) — the Watcher set is 9 (REQ-004), so a quorum
// below 2 is meaningless and above 9 is unsatisfiable.
type Params struct {
WatcherVetoQuorum uint32 `json:"watcher_veto_quorum" yaml:"watcher_veto_quorum"`
}
func DefaultParams() Params { return Params{} }
// DefaultParams returns the default council Params — WatcherVetoQuorum =
// WatcherVetoQuorumDefault (6, D-065/A-574).
func DefaultParams() Params {
return Params{WatcherVetoQuorum: WatcherVetoQuorumDefault}
}
// Validate asserts the Params are well-formed (G-020). WatcherVetoQuorum
// must be in [2, 9] (the Watcher set is 9 per REQ-004; below 2 is
// meaningless, above 9 is unsatisfiable). The v0.5 simtest exercises the
// bounds.
func (p Params) Validate() error {
if p.WatcherVetoQuorum < 2 {
return fmt.Errorf("council: WatcherVetoQuorum %d below min 2 (G-020)", p.WatcherVetoQuorum)
}
if p.WatcherVetoQuorum > 9 {
return fmt.Errorf("council: WatcherVetoQuorum %d above max 9 (G-020; REQ-004 Watcher set)", p.WatcherVetoQuorum)
}
return nil
}
// ProposalKind enumerates the four proposal kinds a Council can take up
// (D-060, AUDIT §193 P1-1). Three map to the three Council tiers
// (Stand/Guild/Mesh); the fourth — MissionLockAmendmentRejected — is the
// Mission-Lock non-amendability marker: the enum value exists to DOCUMENT
// in code that the Mission Lock (vision §19, REQ-011) is non-amendable,
// but MsgSubmitProposal.ValidateBasic REJECTS it (D-064/A-572 — the
// message never reaches the handler). The locked const + the
// ValidateBasic gate form the dual firewall (D-064).
//
// MissionLockAmendable=false is the const firewall; the
// ProposalMissionLockAmendmentRejected enum value is the in-enum
// documentation; the ValidateBasic rejection is the gate. A future
// agent flipping the const OR removing the ValidateBasic gate breaks
// the regression tests.
type ProposalKind string
const (
// ProposalKindStand is a Stand-Council proposal (target: a Stand by
// ID-string ref via x/stand).
ProposalKindStand ProposalKind = "Stand"
// ProposalKindGuild is a Guild-Council proposal (target: a Guild by
// ID-string ref via x/guild).
ProposalKindGuild ProposalKind = "Guild"
// ProposalKindMesh is a Mesh-Council proposal (whole-mesh scope).
ProposalKindMesh ProposalKind = "Mesh"
// ProposalMissionLockAmendmentRejected is the Mission-Lock non-
// amendability marker (D-064/A-572). The enum value EXISTS to document
// in code that the Mission Lock (vision §19) is non-amendable, but
// MsgSubmitProposal.ValidateBasic REJECTS any proposal with this kind
// — the message never reaches the handler. The name carries
// "Rejected" so the rejection is visible at the call site (a proposal
// of this kind is rejected at the gate). The const firewall
// (MissionLockAmendable=false) + the ValidateBasic gate form the dual
// firewall (D-064).
ProposalMissionLockAmendmentRejected ProposalKind = "MissionLockAmendment-Rejected"
)
// AllProposalKinds returns all four ProposalKind values in D-060 order.
// Locked-const test asserts exactly 4 entries (the regression firewall).
func AllProposalKinds() []ProposalKind {
return []ProposalKind{
ProposalKindStand,
ProposalKindGuild,
ProposalKindMesh,
ProposalMissionLockAmendmentRejected,
}
}
// knownProposalKind reports whether k is one of the four ProposalKind
// values (used by genesis + ValidateBasic).
func knownProposalKind(k ProposalKind) bool {
for _, kk := range AllProposalKinds() {
if k == kk {
return true
}
}
return false
}
// ProposalStatus enumerates the five states a Proposal transitions through
// (D-060, AUDIT §193 P1-1). The lifecycle: Submit → Pending → Active (when
// the voting window opens) → Succeeded OR Failed (after tally) → Executed
// (v0.6+; v0.5 records the tally but does NOT auto-execute — D-060
// scope). Pending is the initial state (SubmitProposal creates Pending);
// Active is the voting-open state (the simtest transitions Pending →
// Active to enable voting); Succeeded is a passing tally (Yes quorum met,
// Veto quorum NOT met); Failed is a failing tally (No quorum OR Veto
// quorum met — D-065); Executed is the post-tally executed state (v0.6+).
type ProposalStatus string
const (
ProposalStatusPending ProposalStatus = "Pending"
ProposalStatusActive ProposalStatus = "Active"
ProposalStatusSucceeded ProposalStatus = "Succeeded"
ProposalStatusFailed ProposalStatus = "Failed"
ProposalStatusExecuted ProposalStatus = "Executed"
)
// AllProposalStatuses returns all five ProposalStatus values in D-060
// order. Locked-const test asserts exactly 5 entries (the regression
// firewall).
func AllProposalStatuses() []ProposalStatus {
return []ProposalStatus{
ProposalStatusPending,
ProposalStatusActive,
ProposalStatusSucceeded,
ProposalStatusFailed,
ProposalStatusExecuted,
}
}
// knownProposalStatus reports whether s is one of the five ProposalStatus
// values.
func knownProposalStatus(s ProposalStatus) bool {
for _, ss := range AllProposalStatuses() {
if s == ss {
return true
}
}
return false
}
// VoteOption enumerates the four vote options on a Proposal (D-060, AUDIT
// §193 P1-1). Yes/No/Abstain are the standard three; Veto is the Watcher-
// only block signal (anti-greed, vision §19). A single Veto does NOT
// block — the quorum (default 6 per D-065/A-574) must be met to FAIL a
// proposal. The Vote handler enforces Veto authz via the WatcherKeeper
// shim (IsWatcher — only Watchers can cast Veto; non-Watchers casting
// Veto are REJECTED at the handler).
type VoteOption string
const (
VoteOptionYes VoteOption = "Yes"
VoteOptionNo VoteOption = "No"
VoteOptionAbstain VoteOption = "Abstain"
VoteOptionVeto VoteOption = "Veto" // Watcher-only (D-065/A-574)
)
// AllVoteOptions returns all four VoteOption values in D-060 order.
// Locked-const test asserts exactly 4 entries (the regression firewall).
func AllVoteOptions() []VoteOption {
return []VoteOption{
VoteOptionYes,
VoteOptionNo,
VoteOptionAbstain,
VoteOptionVeto,
}
}
// knownVoteOption reports whether o is one of the four VoteOption values.
func knownVoteOption(o VoteOption) bool {
for _, oo := range AllVoteOptions() {
if o == oo {
return true
}
}
return false
}
// Proposal is a Council governance proposal (D-060, REQ-039). It is the
// runtime promotion of the v0.2 skeleton: the v0.2 Voice struct held a
// tally snapshot; v0.5 adds the Proposal lifecycle (Submit → Vote →
// Tally → Succeeded/Failed). Fields:
// - proposal-id: this proposal's ID (unique within a Council).
// - council-id: the Council by ID-string (G-003 by-ID-string ref).
// - kind: the ProposalKind (Stand/Guild/Mesh; MissionLockAmendment-
// Rejected is rejected at ValidateBasic — D-064).
// - proposer-reach: the proposer's reach-id (lexicon-clean holder
// identifier; G-003 — NOT a banned financial-holder term).
// - submit-time: unix seconds at SubmitProposal.
// - voting-deadline: unix seconds after which TallyProposal can close.
// - status: the ProposalStatus (Pending → Active → Succeeded/Failed →
// Executed).
// - tally: the running TallyResult (Yes/No/Abstain/Veto counts; the
// v0.2 NoWithVeto field — zero-locked in v0.2 — is now POPULATED by
// Watcher Vetos per D-060; G-017 reconciles the v0.2
// TestTallyResultNoWithVetoAlwaysZero regression: the DEFAULT tally
// has NoWithVeto=0, but a tally after a Watcher Veto quorum has
// NoWithVeto > 0).
type Proposal struct {
ProposalID string `json:"proposal_id" yaml:"proposal_id"`
CouncilID string `json:"council_id" yaml:"council_id"`
Kind ProposalKind `json:"kind" yaml:"kind"`
ProposerReach string `json:"proposer_reach" yaml:"proposer_reach"`
SubmitTime int64 `json:"submit_time" yaml:"submit_time"`
VotingDeadline int64 `json:"voting_deadline" yaml:"voting_deadline"`
Status ProposalStatus `json:"status" yaml:"status"`
Tally TallyResult `json:"tally" yaml:"tally"`
}
// Vote is a single Voice cast on a Proposal (D-060, REQ-039). The v0.2
// Voice struct held a SignalKind-based tally; v0.5 adds the per-Vote
// VoteOption (Yes/No/Abstain/Veto). The Vote is the per-voter record;
// the Proposal's Tally is the aggregate. Fields:
// - vote-id: this vote's ID (unique within a Proposal).
// - proposal-id: the Proposal by ID-string (G-003).
// - voter-reach: the voter's reach-id (lexicon-clean holder identifier).
// - option: the VoteOption (Yes/No/Abstain/Veto; Veto is Watcher-only).
// - timestamp: the cast time (unix seconds).
type Vote struct {
VoteID string `json:"vote_id" yaml:"vote_id"`
ProposalID string `json:"proposal_id" yaml:"proposal_id"`
VoterReach string `json:"voter_reach" yaml:"voter_reach"`
Option VoteOption `json:"option" yaml:"option"`
Timestamp int64 `json:"timestamp" yaml:"timestamp"`
}
// 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).
// Voice-tally set. Proposals + Votes are the v0.5 (P7, D-060) runtime
// promotion: the proposal lifecycle store. ValidateGenesis enforces
// council-id uniqueness, voice-id uniqueness, proposal-id uniqueness,
// vote-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"`
Councils []Council `json:"councils" yaml:"councils"`
Voices []Voice `json:"voices" yaml:"voices"`
Proposals []Proposal `json:"proposals" yaml:"proposals"`
Votes []Vote `json:"votes" yaml:"votes"`
Params Params `json:"params" yaml:"params"`
}
func DefaultGenesisState() *GenesisState {
return &GenesisState{
Councils: []Council{},
Voices: []Voice{},
Params: DefaultParams(),
Councils: []Council{},
Voices: []Voice{},
Proposals: []Proposal{},
Votes: []Vote{},
Params: DefaultParams(),
}
}
// Reset implements proto.Message (codec.JSONCodec.MustMarshalJSON /
// MustUnmarshalJSON require proto.Message — G-006 controlled exception:
// the codec requires the proto.Message interface; the lexicon tests in
// *_test.go stay stdlib-only per G-024, isolated from this types.go file).
func (m *GenesisState) Reset() { *m = GenesisState{} }
// String implements proto.Message.
func (m *GenesisState) String() string {
return fmt.Sprintf("GenesisState{Councils:%d Voices:%d Proposals:%d Votes:%d}",
len(m.Councils), len(m.Voices), len(m.Proposals), len(m.Votes))
}
// ProtoMessage implements proto.Message.
func (*GenesisState) ProtoMessage() {}
// 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
@@ -183,5 +447,14 @@ func ValidateGenesis(bz json.RawMessage) error {
if err := ValidateVoices(gs.Voices, gs.Councils); err != nil {
return fmt.Errorf("council: %w", err)
}
if err := ValidateProposals(gs.Proposals, gs.Councils); err != nil {
return fmt.Errorf("council: %w", err)
}
if err := ValidateVotes(gs.Votes, gs.Proposals); err != nil {
return fmt.Errorf("council: %w", err)
}
if err := gs.Params.Validate(); err != nil {
return fmt.Errorf("council: %w", err)
}
return nil
}
+474 -11
View File
@@ -201,20 +201,26 @@ func TestSignalKindValues(t *testing.T) {
// 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.
// field is kept for x/gov parity; v0.2 locked it to 0 (no veto option —
// anti-greed, vision §19). v0.5 P7 (D-060) POPULATES NoWithVeto with Watcher
// Vetos (the VoteOption enum adds Veto as the Watcher-only block signal).
// G-017 reconciliation: the DEFAULT tally has NoWithVeto=0 (covered by
// TestTallyResultNoWithVetoDefaultZero); a tally after a Watcher Veto
// quorum has NoWithVeto > 0 (covered by
// TestTallyResultNoWithVetoPopulatedByQuorum). This test asserts the
// field names via JSON tags and that the struct can carry a populated
// NoWithVeto value (the v0.5 shape).
func TestTallyResultStructShape(t *testing.T) {
tr := types.TallyResult{
Yes: 10,
No: 3,
Abstain: 1,
NoWithVeto: 0, // always 0 — no veto option
Total: 14,
NoWithVeto: 2, // POPULATED by Watcher Vetos (D-060 — no longer always 0; G-017 reconciliation)
Total: 16,
QuorumMet: true,
}
if tr.Yes != 10 || tr.No != 3 || tr.Abstain != 1 || tr.NoWithVeto != 0 ||
tr.Total != 14 || tr.QuorumMet != true {
if tr.Yes != 10 || tr.No != 3 || tr.Abstain != 1 || tr.NoWithVeto != 2 ||
tr.Total != 16 || tr.QuorumMet != true {
t.Error("TallyResult fields not set correctly")
}
// x/gov field-name parity: marshal and check JSON tags.
@@ -230,12 +236,70 @@ func TestTallyResultStructShape(t *testing.T) {
}
}
// TestTallyResultNoWithVetoAlwaysZero asserts the default TallyResult has
// NoWithVeto == 0 (the anti-greed invariant — no veto option in OY).
func TestTallyResultNoWithVetoAlwaysZero(t *testing.T) {
// TestTallyResultNoWithVetoDefaultZero asserts the DEFAULT TallyResult
// has NoWithVeto == 0 (the anti-greed invariant — no veto option in the
// default zero-value tally).
//
// G-017 RECONCILIATION (CRITICAL): the v0.2 test was named
// TestTallyResultNoWithVetoAlwaysZero and asserted NoWithVeto == 0
// "always". v0.5 P7 (D-060) POPULATES NoWithVeto with Watcher Vetos (the
// VoteOption enum adds Veto as the Watcher-only block signal). The v0.2
// test's "always" assertion would contradict D-060. The reconciliation
// RENAMES the test to TestTallyResultNoWithVetoDefaultZero (asserts the
// DEFAULT tally has NoWithVeto=0) AND adds a new test
// TestTallyResultNoWithVetoPopulatedByQuorum (asserts a tally after a
// Watcher Veto quorum has NoWithVeto > 0). The regression is preserved
// (renamed + re-scoped, NOT deleted — the v0.2 regression protection
// stays green for the default case, and the new test covers the v0.5
// populated case).
func TestTallyResultNoWithVetoDefaultZero(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)
t.Errorf("default TallyResult.NoWithVeto = %d, expected 0 (no veto option in default tally — anti-greed)", tr.NoWithVeto)
}
}
// TestTallyResultNoWithVetoPopulatedByQuorum asserts a tally AFTER a
// Watcher Veto quorum has NoWithVeto > 0 (D-060 — the v0.2 zero-locked
// field is now POPULATED by Watcher Vetos). This is the G-017
// reconciliation's NEW test: it covers the v0.5 populated case that the
// v0.2 TestTallyResultNoWithVetoAlwaysZero test did not cover (the v0.2
// test asserted "always 0", which is no longer true post-D-060). The
// keeper simtest covers the full Vote → Tally → Failed lifecycle; this
// types-level test asserts the TallyResult struct shape carries the
// populated NoWithVeto field.
func TestTallyResultNoWithVetoPopulatedByQuorum(t *testing.T) {
// A tally after 6 Watcher Vetos (the default quorum, D-065/A-574).
tr := types.TallyResult{
Yes: 0,
No: 0,
Abstain: 0,
NoWithVeto: 6, // POPULATED by Watcher Vetos (D-060 — no longer always 0)
Total: 6,
QuorumMet: true,
}
if tr.NoWithVeto == 0 {
t.Errorf("TallyResult.NoWithVeto = 0 after a Watcher Veto quorum, expected > 0 (D-060 — NoWithVeto POPULATED by Watcher Vetos; the v0.2 zero-locked field is now populated)")
}
if tr.NoWithVeto != 6 {
t.Errorf("TallyResult.NoWithVeto = %d, expected 6 (quorum)", tr.NoWithVeto)
}
// Marshal round-trip: the populated NoWithVeto survives JSON
// serialization (x/gov shape parity A-204).
bz, err := json.Marshal(tr)
if err != nil {
t.Fatalf("marshal: %v", err)
}
js := string(bz)
if !strings.Contains(js, `"nowithveto":6`) {
t.Errorf("TallyResult JSON should contain populated nowithveto:6; got %s", js)
}
var tr2 types.TallyResult
if err := json.Unmarshal(bz, &tr2); err != nil {
t.Fatalf("unmarshal: %v", err)
}
if tr2.NoWithVeto != 6 {
t.Errorf("round-trip NoWithVeto = %d, expected 6", tr2.NoWithVeto)
}
}
@@ -411,6 +475,7 @@ func TestValidateGenesisAcceptsClean(t *testing.T) {
{VoiceID: "v1", CouncilID: "cm", SignalKind: types.SignalStash},
{VoiceID: "v2", CouncilID: "cs", SignalKind: types.SignalCapital},
},
Params: types.DefaultParams(),
}
bz, _ := json.Marshal(gs)
if err := types.ValidateGenesis(bz); err != nil {
@@ -491,6 +556,404 @@ func TestDefaultParams(t *testing.T) {
_ = types.DefaultParams() // no panics
}
// --- New v0.5 P7 locked-const + enum tests (D-060) ---------------------------
// TestProposalKindCountLockedConst asserts ProposalKindCount is exactly 4
// (D-060, AUDIT §193 P1-1): Stand, Guild, Mesh, MissionLockAmendment-Rejected.
func TestProposalKindCountLockedConst(t *testing.T) {
if types.ProposalKindCount != 4 {
t.Errorf("ProposalKindCount = %d, expected 4 (D-060 LOCKED — AUDIT §193 P1-1)", types.ProposalKindCount)
}
all := types.AllProposalKinds()
if len(all) != 4 {
t.Errorf("AllProposalKinds() len = %d, expected 4", len(all))
}
}
// TestAllProposalKindsNames asserts the 4 D-060 names in order with no
// extras, no dups, no renames. The MissionLockAmendment-Rejected kind is
// the Mission-Lock non-amendability marker (D-064/A-572 — rejected at
// ValidateBasic; the const + the gate form the dual firewall).
func TestAllProposalKindsNames(t *testing.T) {
want := []string{"Stand", "Guild", "Mesh", "MissionLockAmendment-Rejected"}
all := types.AllProposalKinds()
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("AllProposalKinds()[%d] = %q, want %q", i, k, want[i])
}
if seen[string(k)] {
t.Errorf("duplicate ProposalKind %q", k)
}
seen[string(k)] = true
}
}
// TestProposalKindValues asserts each named const matches its
// AllProposalKinds entry.
func TestProposalKindValues(t *testing.T) {
if types.ProposalKindStand != "Stand" {
t.Errorf("ProposalKindStand = %q", types.ProposalKindStand)
}
if types.ProposalKindGuild != "Guild" {
t.Errorf("ProposalKindGuild = %q", types.ProposalKindGuild)
}
if types.ProposalKindMesh != "Mesh" {
t.Errorf("ProposalKindMesh = %q", types.ProposalKindMesh)
}
if types.ProposalMissionLockAmendmentRejected != "MissionLockAmendment-Rejected" {
t.Errorf("ProposalMissionLockAmendmentRejected = %q", types.ProposalMissionLockAmendmentRejected)
}
}
// TestProposalStatusCountLockedConst asserts ProposalStatusCount is
// exactly 5 (D-060): Pending, Active, Succeeded, Failed, Executed.
func TestProposalStatusCountLockedConst(t *testing.T) {
if types.ProposalStatusCount != 5 {
t.Errorf("ProposalStatusCount = %d, expected 5 (D-060 LOCKED)", types.ProposalStatusCount)
}
all := types.AllProposalStatuses()
if len(all) != 5 {
t.Errorf("AllProposalStatuses() len = %d, expected 5", len(all))
}
}
// TestAllProposalStatusesNames asserts the 5 D-060 names in order.
func TestAllProposalStatusesNames(t *testing.T) {
want := []string{"Pending", "Active", "Succeeded", "Failed", "Executed"}
all := types.AllProposalStatuses()
if len(all) != len(want) {
t.Fatalf("len = %d, want %d", len(all), len(want))
}
for i, s := range all {
if string(s) != want[i] {
t.Errorf("AllProposalStatuses()[%d] = %q, want %q", i, s, want[i])
}
}
}
// TestVoteOptionCountLockedConst asserts VoteOptionCount is exactly 4
// (D-060): Yes, No, Abstain, Veto (Veto is Watcher-only).
func TestVoteOptionCountLockedConst(t *testing.T) {
if types.VoteOptionCount != 4 {
t.Errorf("VoteOptionCount = %d, expected 4 (D-060 LOCKED — AUDIT §193 P1-1)", types.VoteOptionCount)
}
all := types.AllVoteOptions()
if len(all) != 4 {
t.Errorf("AllVoteOptions() len = %d, expected 4", len(all))
}
}
// TestAllVoteOptionsNames asserts the 4 D-060 names in order. Veto is the
// Watcher-only block signal (anti-greed, vision §19; D-065/A-574 — a
// single Veto does NOT block; the quorum default 6 must be met).
func TestAllVoteOptionsNames(t *testing.T) {
want := []string{"Yes", "No", "Abstain", "Veto"}
all := types.AllVoteOptions()
if len(all) != len(want) {
t.Fatalf("len = %d, want %d", len(all), len(want))
}
for i, o := range all {
if string(o) != want[i] {
t.Errorf("AllVoteOptions()[%d] = %q, want %q", i, o, want[i])
}
}
}
// TestVoteOptionValues asserts each named const matches its AllVoteOptions
// entry.
func TestVoteOptionValues(t *testing.T) {
if types.VoteOptionYes != "Yes" {
t.Errorf("VoteOptionYes = %q", types.VoteOptionYes)
}
if types.VoteOptionNo != "No" {
t.Errorf("VoteOptionNo = %q", types.VoteOptionNo)
}
if types.VoteOptionAbstain != "Abstain" {
t.Errorf("VoteOptionAbstain = %q", types.VoteOptionAbstain)
}
if types.VoteOptionVeto != "Veto" {
t.Errorf("VoteOptionVeto = %q", types.VoteOptionVeto)
}
}
// TestProposalStructFields asserts Proposal carries all required fields
// (D-060). The Tally field's NoWithVeto is POPULATED by Watcher Vetos
// (D-060 — G-017 reconciliation).
func TestProposalStructFields(t *testing.T) {
p := types.Proposal{
ProposalID: "p1",
CouncilID: "cm",
Kind: types.ProposalKindMesh,
ProposerReach: "reach:prop",
SubmitTime: 1000,
VotingDeadline: 2000,
Status: types.ProposalStatusPending,
Tally: types.TallyResult{Yes: 1, No: 0, Abstain: 0, NoWithVeto: 0, Total: 1, QuorumMet: true},
}
if p.ProposalID != "p1" || p.CouncilID != "cm" || p.Kind != types.ProposalKindMesh ||
p.ProposerReach != "reach:prop" || p.SubmitTime != 1000 || p.VotingDeadline != 2000 ||
p.Status != types.ProposalStatusPending || p.Tally.Yes != 1 || p.Tally.Total != 1 ||
p.Tally.QuorumMet != true {
t.Error("Proposal fields not set correctly")
}
}
// TestVoteStructFields asserts Vote carries all required fields (D-060).
func TestVoteStructFields(t *testing.T) {
v := types.Vote{
VoteID: "v1",
ProposalID: "p1",
VoterReach: "reach:voter",
Option: types.VoteOptionVeto,
Timestamp: 1500,
}
if v.VoteID != "v1" || v.ProposalID != "p1" || v.VoterReach != "reach:voter" ||
v.Option != types.VoteOptionVeto || v.Timestamp != 1500 {
t.Error("Vote fields not set correctly")
}
}
// TestWatcherVetoQuorumDefault asserts the default WatcherVetoQuorum is 6
// (D-065/A-574 — matching REQ-004 6-of-9).
func TestWatcherVetoQuorumDefault(t *testing.T) {
if types.WatcherVetoQuorumDefault != 6 {
t.Errorf("WatcherVetoQuorumDefault = %d, expected 6 (D-065/A-574)", types.WatcherVetoQuorumDefault)
}
p := types.DefaultParams()
if p.WatcherVetoQuorum != 6 {
t.Errorf("DefaultParams().WatcherVetoQuorum = %d, expected 6 (D-065/A-574)", p.WatcherVetoQuorum)
}
}
// TestParamsValidateBounds asserts Params.Validate() bounds WatcherVetoQuorum
// to [2, 9] (G-020 — the Watcher set is 9 per REQ-004; below 2 is
// meaningless, above 9 is unsatisfiable).
func TestParamsValidateBounds(t *testing.T) {
// Below min (2) → rejected.
if err := (types.Params{WatcherVetoQuorum: 1}).Validate(); err == nil {
t.Error("WatcherVetoQuorum=1 should be rejected (G-020 min 2)")
}
if err := (types.Params{WatcherVetoQuorum: 0}).Validate(); err == nil {
t.Error("WatcherVetoQuorum=0 should be rejected (G-020 min 2)")
}
// Above max (9) → rejected.
if err := (types.Params{WatcherVetoQuorum: 10}).Validate(); err == nil {
t.Error("WatcherVetoQuorum=10 should be rejected (G-020 max 9)")
}
// Bounds [2, 9] → accepted.
for q := uint32(2); q <= 9; q++ {
if err := (types.Params{WatcherVetoQuorum: q}).Validate(); err != nil {
t.Errorf("WatcherVetoQuorum=%d should be accepted (G-020 bounds [2,9]), got: %v", q, err)
}
}
}
// TestMsgSubmitProposalValidateBasicRejectsMissionLockAmendment asserts
// the MissionLockAmendment-Rejected kind is REJECTED at ValidateBasic
// (D-064/A-572 — the message never reaches the handler; the const +
// ValidateBasic dual firewall). The keeper Proposal store stays empty
// (the handler is never invoked with this kind).
func TestMsgSubmitProposalValidateBasicRejectsMissionLockAmendment(t *testing.T) {
msg := &types.MsgSubmitProposal{
ProposalID: "p1",
CouncilID: "cm",
Kind: types.ProposalMissionLockAmendmentRejected,
ProposerReach: "reach:prop",
SubmitTime: 1000,
VotingDeadline: 2000,
Signer: "reach:prop",
}
err := msg.ValidateBasic()
if err == nil {
t.Fatal("MsgSubmitProposal with MissionLockAmendment-Rejected kind should be rejected at ValidateBasic (D-064/A-572)")
}
if !strings.Contains(err.Error(), "MissionLockAmendment") {
t.Errorf("error should reference the Mission Lock; got: %v", err)
}
}
// TestMsgSubmitProposalValidateBasicAcceptsValid asserts the valid kinds
// (Stand, Guild, Mesh) pass ValidateBasic.
func TestMsgSubmitProposalValidateBasicAcceptsValid(t *testing.T) {
for _, kind := range []types.ProposalKind{types.ProposalKindStand, types.ProposalKindGuild, types.ProposalKindMesh} {
msg := &types.MsgSubmitProposal{
ProposalID: "p1",
CouncilID: "cm",
Kind: kind,
ProposerReach: "reach:prop",
SubmitTime: 1000,
VotingDeadline: 2000,
Signer: "reach:prop",
}
if err := msg.ValidateBasic(); err != nil {
t.Errorf("kind %q should pass ValidateBasic; got: %v", kind, err)
}
}
}
// TestMsgSubmitProposalValidateBasicErrorPaths asserts the other
// ValidateBasic error paths (empty fields, bad deadline).
func TestMsgSubmitProposalValidateBasicErrorPaths(t *testing.T) {
// empty proposal-id
if err := (&types.MsgSubmitProposal{CouncilID: "cm", Kind: types.ProposalKindMesh, ProposerReach: "r", VotingDeadline: 2, SubmitTime: 1, Signer: "r"}).ValidateBasic(); err == nil {
t.Error("empty proposal-id should be rejected")
}
// empty council-id
if err := (&types.MsgSubmitProposal{ProposalID: "p", Kind: types.ProposalKindMesh, ProposerReach: "r", VotingDeadline: 2, SubmitTime: 1, Signer: "r"}).ValidateBasic(); err == nil {
t.Error("empty council-id should be rejected")
}
// unknown kind
if err := (&types.MsgSubmitProposal{ProposalID: "p", CouncilID: "cm", Kind: types.ProposalKind("Bogus"), ProposerReach: "r", VotingDeadline: 2, SubmitTime: 1, Signer: "r"}).ValidateBasic(); err == nil {
t.Error("unknown kind should be rejected")
}
// empty proposer-reach
if err := (&types.MsgSubmitProposal{ProposalID: "p", CouncilID: "cm", Kind: types.ProposalKindMesh, VotingDeadline: 2, SubmitTime: 1, Signer: "r"}).ValidateBasic(); err == nil {
t.Error("empty proposer-reach should be rejected")
}
// empty signer
if err := (&types.MsgSubmitProposal{ProposalID: "p", CouncilID: "cm", Kind: types.ProposalKindMesh, ProposerReach: "r", VotingDeadline: 2, SubmitTime: 1}).ValidateBasic(); err == nil {
t.Error("empty signer should be rejected")
}
// voting-deadline <= submit-time
if err := (&types.MsgSubmitProposal{ProposalID: "p", CouncilID: "cm", Kind: types.ProposalKindMesh, ProposerReach: "r", VotingDeadline: 1, SubmitTime: 2, Signer: "r"}).ValidateBasic(); err == nil {
t.Error("voting-deadline <= submit-time should be rejected")
}
if err := (&types.MsgSubmitProposal{ProposalID: "p", CouncilID: "cm", Kind: types.ProposalKindMesh, ProposerReach: "r", VotingDeadline: 1, SubmitTime: 1, Signer: "r"}).ValidateBasic(); err == nil {
t.Error("voting-deadline == submit-time should be rejected")
}
}
// TestMsgVoteValidateBasic asserts MsgVote ValidateBasic error paths.
func TestMsgVoteValidateBasic(t *testing.T) {
// valid
if err := (&types.MsgVote{VoteID: "v", ProposalID: "p", VoterReach: "r", Option: types.VoteOptionYes, Signer: "r"}).ValidateBasic(); err != nil {
t.Errorf("valid MsgVote should pass; got: %v", err)
}
// Veto is allowed at ValidateBasic (Watcher authz is a runtime gate).
if err := (&types.MsgVote{VoteID: "v", ProposalID: "p", VoterReach: "r", Option: types.VoteOptionVeto, Signer: "r"}).ValidateBasic(); err != nil {
t.Errorf("MsgVote with Veto should pass ValidateBasic (Watcher authz is a runtime gate); got: %v", err)
}
// empty vote-id
if err := (&types.MsgVote{ProposalID: "p", VoterReach: "r", Option: types.VoteOptionYes, Signer: "r"}).ValidateBasic(); err == nil {
t.Error("empty vote-id should be rejected")
}
// empty proposal-id
if err := (&types.MsgVote{VoteID: "v", VoterReach: "r", Option: types.VoteOptionYes, Signer: "r"}).ValidateBasic(); err == nil {
t.Error("empty proposal-id should be rejected")
}
// empty voter-reach
if err := (&types.MsgVote{VoteID: "v", ProposalID: "p", Option: types.VoteOptionYes, Signer: "r"}).ValidateBasic(); err == nil {
t.Error("empty voter-reach should be rejected")
}
// unknown option
if err := (&types.MsgVote{VoteID: "v", ProposalID: "p", VoterReach: "r", Option: types.VoteOption("Bogus"), Signer: "r"}).ValidateBasic(); err == nil {
t.Error("unknown option should be rejected")
}
// empty signer
if err := (&types.MsgVote{VoteID: "v", ProposalID: "p", VoterReach: "r", Option: types.VoteOptionYes}).ValidateBasic(); err == nil {
t.Error("empty signer should be rejected")
}
}
// TestMsgTallyProposalValidateBasic asserts MsgTallyProposal ValidateBasic.
func TestMsgTallyProposalValidateBasic(t *testing.T) {
// valid
if err := (&types.MsgTallyProposal{ProposalID: "p", Signer: "r"}).ValidateBasic(); err != nil {
t.Errorf("valid MsgTallyProposal should pass; got: %v", err)
}
// empty proposal-id
if err := (&types.MsgTallyProposal{Signer: "r"}).ValidateBasic(); err == nil {
t.Error("empty proposal-id should be rejected")
}
// empty signer
if err := (&types.MsgTallyProposal{ProposalID: "p"}).ValidateBasic(); err == nil {
t.Error("empty signer should be rejected")
}
}
// TestValidateGenesisRejectsDupProposalIDs asserts A-212: duplicate
// proposal-ids are rejected (P7 genesis validation).
func TestValidateGenesisRejectsDupProposalIDs(t *testing.T) {
gs := types.GenesisState{
Councils: []types.Council{{CouncilID: "c1", Kind: types.CouncilMesh}},
Proposals: []types.Proposal{
{ProposalID: "p1", CouncilID: "c1", Kind: types.ProposalKindMesh, Status: types.ProposalStatusPending},
{ProposalID: "p1", CouncilID: "c1", Kind: types.ProposalKindMesh, Status: types.ProposalStatusActive},
},
Params: types.DefaultParams(),
}
bz, _ := json.Marshal(gs)
if err := types.ValidateGenesis(bz); err == nil {
t.Error("ValidateGenesis should reject duplicate proposal-ids")
}
}
// TestValidateGenesisRejectsProposalWithUnknownCouncil asserts referential
// integrity: a Proposal whose council-id does not reference an existing
// Council is rejected (P7 genesis validation).
func TestValidateGenesisRejectsProposalWithUnknownCouncil(t *testing.T) {
gs := types.GenesisState{
Councils: []types.Council{{CouncilID: "c1", Kind: types.CouncilMesh}},
Proposals: []types.Proposal{{ProposalID: "p1", CouncilID: "no-such", Kind: types.ProposalKindMesh, Status: types.ProposalStatusPending}},
Params: types.DefaultParams(),
}
bz, _ := json.Marshal(gs)
if err := types.ValidateGenesis(bz); err == nil {
t.Error("ValidateGenesis should reject Proposal with unknown council-id")
}
}
// TestValidateGenesisRejectsProposalWithUnknownKind asserts an unknown
// ProposalKind is rejected at genesis.
func TestValidateGenesisRejectsProposalWithUnknownKind(t *testing.T) {
gs := types.GenesisState{
Councils: []types.Council{{CouncilID: "c1", Kind: types.CouncilMesh}},
Proposals: []types.Proposal{{ProposalID: "p1", CouncilID: "c1", Kind: types.ProposalKind("Bogus"), Status: types.ProposalStatusPending}},
Params: types.DefaultParams(),
}
bz, _ := json.Marshal(gs)
if err := types.ValidateGenesis(bz); err == nil {
t.Error("ValidateGenesis should reject Proposal with unknown kind")
}
}
// TestValidateGenesisRejectsBadParams asserts a Params with an out-of-
// bounds WatcherVetoQuorum is rejected (G-020).
func TestValidateGenesisRejectsBadParams(t *testing.T) {
gs := types.GenesisState{
Councils: []types.Council{{CouncilID: "c1", Kind: types.CouncilMesh}},
Params: types.Params{WatcherVetoQuorum: 0}, // below min 2
}
bz, _ := json.Marshal(gs)
if err := types.ValidateGenesis(bz); err == nil {
t.Error("ValidateGenesis should reject Params with WatcherVetoQuorum=0 (G-020)")
}
}
// TestValidateGenesisAcceptsProposalAndVotes asserts a clean genesis with
// Proposals + Votes validates.
func TestValidateGenesisAcceptsProposalAndVotes(t *testing.T) {
gs := types.GenesisState{
Councils: []types.Council{{CouncilID: "cm", Kind: types.CouncilMesh}},
Proposals: []types.Proposal{
{ProposalID: "p1", CouncilID: "cm", Kind: types.ProposalKindMesh, Status: types.ProposalStatusActive},
},
Votes: []types.Vote{
{VoteID: "v1", ProposalID: "p1", Option: types.VoteOptionYes},
{VoteID: "v2", ProposalID: "p1", Option: types.VoteOptionVeto},
},
Params: types.DefaultParams(),
}
bz, _ := json.Marshal(gs)
if err := types.ValidateGenesis(bz); err != nil {
t.Errorf("ValidateGenesis should accept clean proposal+vote genesis, got: %v", err)
}
}
// --- Lexicon assertion (REQ-012) -------------------------------------------------
// TestLexiconNoBannedTermsInCouncilPackage scans every non-test .go file in