diff --git a/CONFORMANCE.md b/CONFORMANCE.md index 18300b4..6d0877d 100644 --- a/CONFORMANCE.md +++ b/CONFORMANCE.md @@ -27,8 +27,8 @@ Statuses are stated per the architecture's honesty rule. Built member-layer work | Record is **dialog-sealed** — what two members agreed, not what one asserts | **Built** | Dual-seal verification over canonical bytes; self-dialog rejected at construction | | **No PII in the commons**; narrative stays member-local and erasable | **Built** | Commons types carry hashes, keys, and instants only; erasable content lives in a type-disjoint member-local store | | **Witnessing** with structural independence; a single-witness deployment is a labeled stand-in | **Built (stand-in)** | Witness countersignatures with pairwise-independence verification; the StandIn label is computed, not asserted | -| Covenant: **never average**; full distribution; symmetric; gates whether-never-how-much | **Stub, labeled** | `internal/covenant` package doc declares the non-negotiable invariants it must satisfy when built; built implementation in review | -| Economy: balances a **deterministic function of the sealed record**; zero-sum; escrow-first; earned-never-bought | **Stub, labeled** | `internal/economy` package doc declares the invariants; built implementation in review | +| Covenant: **never average**; full distribution; symmetric; gates whether-never-how-much | **Built, except the trust gate** | `internal/covenant`: full distribution per relation, never averaged (`TestInvariant_L8_NoScalarAggregation`); No Trust requires a justifying comment hash; answers annotate, never erase (`TestAnswersCloseTheSymmetryBreach`). "Never how much" holds: the credit limit is never derived from standing (`TestInvariant_L9_OperatorSetUniformLimit`). **Not yet wired:** "gates whether" — no spend path consults standing before admitting a trust-based trade | +| Economy: balances a **deterministic function of the sealed record**; zero-sum; escrow-first; earned-never-bought | **Built** | `internal/economy`: replay re-derives every balance (`TestOpenReplaysHistory`, `TestOpenDetectsTamper`); zero-sum (`TestInvariant_L1_ZeroSumBalance`); escrow-first, one governed switch (`TestPostRejectedInEscrowMode`, `TestModeSwitchIsOneRecord`); earned never bought (`TestInvariant_L2_EarnedNeverBought`); sovereign unit (`TestInvariant_L3_SovereignUnits`); one operator-set limit (`TestInvariant_L9_OperatorSetUniformLimit`) | | **Single participant identity** — one member, simultaneously contributor and consumer | Design-binding | No producer/consumer split exists in any schema or type; binds every member surface as it lands | | **Forkability** | Built | Consumes the coordination protocol at a published version tag; no coordinator is privileged | @@ -39,14 +39,17 @@ Tests that bind an ID from the Janus invariant registry (`jfa-conformance-suite. | Registry ID | Test | What it adds beyond the package's existing tests | |---|---|---| | **L1** — each exchange moves two balances netting to zero | `internal/economy` `TestInvariant_L1_ZeroSumBalance` | Concurrent payers across two ledgers sharing one store; per-record netting; replay reproduces every balance | +| **L2** — earned never bought, never redeemable for fiat | `internal/economy` `TestInvariant_L2_EarnedNeverBought` | The ledger API, record union, and every record field are pinned to allowlists; the ledger imports only the standard library and canon; no buy/redeem/cash-out/payout verb or route exists in the module, and no function both touches the fiat rail and builds a spend; at runtime escrow holds no credit and a balance rises only by a payer-signed spend | +| **L3** — sovereign units, no conversion | `internal/economy` `TestInvariant_L3_SovereignUnits` | One key yields unlinkable accounts per platform; a spend signed for one platform never posts on, or re-points to, another, and a foreign spend in a store makes it refuse to open; platform identity lives in no governed field; no conversion, rate, peg, or bridge surface exists | | **L6** — no update-in-place, no delete; dismissal is annotation | `internal/record` `TestInvariant_L6_AppendOnly` (dismissal half: `internal/covenant` `TestAnswersCloseTheSymmetryBreach`) | A correction appends and leaves the corrected entry and all earlier proofs intact; an in-place rewrite done directly on storage fails consistency against a checkpoint taken before it; a module-wide source scan finds no update or delete verb on any append-only type — tracked by name (Store/Log/Ledger/Book) or by an "append-only" doc comment, with `market.Catalog` and `techtree.Tree` pinned so they cannot drop out of scope | | **L7** — commons holds hashes, types, timestamps, references only | `internal/record` `TestInvariant_L7_CommonsSchema` | Every commons type, field by field, admits only byte arrays, unsigned integers, `time.Time`, and `[]byte` fields named in an explicit allowlist of ed25519 keys and signatures — no string, map, interface, float, or unlisted `[]byte`; every allowlisted field, padded with a memo, fails verification against a passing unpadded control and never enters a log | | **L8** — full distribution carried; no scalar score anywhere | `internal/covenant` `TestInvariant_L8_NoScalarAggregation` | Two members with equal means and different shapes stay distinguishable; a module-wide source scan for score-named or float-returning exported functions, score-like JSON tags, and float fields in reputation packages | +| **L9** — one uniform limit, set by the operator, never from reputation | `internal/economy` `TestInvariant_L9_OperatorSetUniformLimit` | Policy carries exactly one limit and nothing per-account; members and operator-held keys hit the same floor before and after a change; only the Genesis stewards' quorum moves it; the ledger cannot import the covenant and no function that sets the limit reads standing (residual named: a limit computed from standing in a separate helper is not seen by the per-function scan) | ## Stand-ins and open residuals - **Single-witness StandIn.** Every witnessed checkpoint today has fewer than two independent witnesses; the record layer computes and reports this label structurally. Live federation is the architecture's open problem 2 — the keystone build. -- **Covenant and economy are stubs on main.** Their package docs carry the binding invariants; the built implementations are in the open, stacked PRs and must satisfy those docs to merge. +- **Covenant trust gate not yet wired.** Covenant and economy are built on main and bound to registry tests (see above). The one open half is the covenant's "gates whether": no spend path yet consults a member's standing before admitting a trade on trust. "Never how much" is enforced; "whether" is a named gap, not a claim. - **Record domain-tag rename** (product naming) is pending and must land **before any durable persistence** — retagging after durable logs exist would be a rewrite. - **Named record residuals** (steganographic floor, timestamp covert channel, witness amnesia, liveness gap) are documented in the record package doc rather than pretended away. - **Contributor node — Executor seam built; runtime implementations pending.** `internal/contribute` fixes the node-contribution contract (Executor / ServiceExecutor / Registry / NodeContract) with two invariants enforced by tests: a scavenged node accepts only preemptible executors (owner-never-interrupted), and a Service workload requires pinned placement. `cmd/cloudy-agent` runs it end to end with a **real** Storage executor (opaque sealed shards + the `internal/storage` proof-of-possession) and **placeholder** Compute/Service executors. The heavy runtime port from the coordinator's transitional `internal/agent` — Docker executor, gopsutil hardware sampling, opt-out/allowlist/printers, and the agent→cloudyd→`/v0` relay — is the named next step and lands *behind* this seam without changing it. diff --git a/internal/economy/conformance_test.go b/internal/economy/conformance_test.go index bd01359..1d86366 100644 --- a/internal/economy/conformance_test.go +++ b/internal/economy/conformance_test.go @@ -8,9 +8,20 @@ package economy import ( "errors" + "go/ast" + "go/parser" + "go/token" "math/rand" + "os" + "path/filepath" + "reflect" + "regexp" + "sort" + "strconv" + "strings" "sync" "testing" + "time" ) // TestInvariant_L1_ZeroSumBalance binds registry ID L1: "each exchange moves @@ -138,3 +149,755 @@ func TestInvariant_L1_ZeroSumBalance(t *testing.T) { } } } + +// TestInvariant_L2_EarnedNeverBought binds registry ID L2: "earned never +// bought and never redeemable for fiat" — no purchase path, no redemption +// path. +// +// The package doc says purchase and redemption are absent APIs. Absence is +// only a guarantee if adding one fails a test, so every surface that could +// grow a path is pinned to an allowlist: +// +// - the ledger's whole API: exactly the methods and constructors listed; +// - the record union: exactly Spend and PolicyChange can enter a store; +// - the record shapes: every field named, no fiat-shaped field possible; +// - the import graph: the ledger sees only the standard library and the +// protocol's canon, so it cannot reach the fiat rail, the market, or +// dispute escrow; +// - the module: no exported buy/redeem/cash-out/payout/top-up verb and no +// such route anywhere, and no function that touches the fiat rail also +// moves credit. +// +// And at runtime: in escrow mode no credit exists to buy, and in credit mode +// a balance rises only as the payee of another member's signed spend. +func TestInvariant_L2_EarnedNeverBought(t *testing.T) { + t.Run("ledger API is exactly the allowlist", testL2LedgerAPI) + t.Run("only spends and policy changes are records", testL2RecordUnion) + t.Run("record fields are exactly the allowlist", testEconomyFieldAllowlist) + t.Run("ledger imports only stdlib and canon", testEconomyImports) + t.Run("no purchase or redemption path in the module", testL2NoMoneyPath) + t.Run("credit is created only by a member's own signed spend", testL2EarnedAtRuntime) +} + +// ledgerAPI is every exported method and package-level function the economy +// package may ship. Adding one fails testL2LedgerAPI until it is listed — +// the review moment for any new path that could move credit. +var ledgerAPI = map[string]bool{ + // package-level + "Open": true, "AccountIDFor": true, "NewMemStore": true, + // *Ledger — Post is the ONLY credit-moving verb + "Ledger.Post": true, "Ledger.Enact": true, "Ledger.Policy": true, "Ledger.Balance": true, + // records + "Spend.CanonicalBytes": true, "Spend.Sign": true, "Spend.Verify": true, + "PolicyChange.CanonicalBytes": true, "PolicyChange.Sign": true, "PolicyChange.Verify": true, + // store + "MemStore.Append": true, "MemStore.All": true, +} + +func testL2LedgerAPI(t *testing.T) { + got := map[string]bool{} + for _, f := range economySource(t) { + for _, decl := range f.Decls { + fn, ok := decl.(*ast.FuncDecl) + if !ok || !fn.Name.IsExported() { + continue + } + name := fn.Name.Name + if fn.Recv != nil && len(fn.Recv.List) > 0 { + name = econReceiverName(fn.Recv.List[0].Type) + "." + name + } + got[name] = true + if !ledgerAPI[name] { + t.Errorf("economy exports %s, which is not in ledgerAPI: a new exported function or method is a possible purchase or redemption path — list it only after review", name) + } + } + } + for name := range ledgerAPI { + if !got[name] { + t.Errorf("ledgerAPI lists %s, which economy no longer exports — remove it", name) + } + } +} + +func testL2RecordUnion(t *testing.T) { + // The Record union is sealed by an unexported record() method; only the + // types that declare it can be stored. Pin that set. + want := map[string]bool{"Spend": true, "PolicyChange": true} + got := map[string]bool{} + for _, f := range economySource(t) { + for _, decl := range f.Decls { + fn, ok := decl.(*ast.FuncDecl) + if !ok || fn.Name.Name != "record" || fn.Recv == nil || len(fn.Recv.List) == 0 { + continue + } + got[econReceiverName(fn.Recv.List[0].Type)] = true + } + } + if !reflect.DeepEqual(got, want) { + t.Errorf("Record union members = %v, want exactly %v: a new record type is a new way to move credit", keys(got), keys(want)) + } + + // And a foreign record smuggled into a store never opens: replay admits + // exactly Spend and PolicyChange. + f := newFixture(t, ModeCredit, 100, 0x01, 0x02) + type grant struct{ Spend } // embeds Spend to inherit record(), the classic smuggle + bad := NewMemStore() + if err := bad.Append(0, grant{f.spend(f.members[0], f.members[1], 10, 1)}); err != nil { + t.Fatalf("Append: %v", err) + } + if _, err := Open(f.genesis, f.dir, bad); !errors.Is(err, ErrTampered) { + t.Errorf("Open over a store holding a smuggled record type = %v, want ErrTampered", err) + } +} + +// economyFields is every field of every type the economy stores or is +// configured with, as Type.Field: kind. A fiat amount, currency code, +// payment reference, per-account limit, or memo cannot be added without +// failing testEconomyFieldAllowlist — and the only strings are the +// platform identity and the two-valued mode. +var economyFields = map[string]reflect.Kind{ + "Spend.Platform": reflect.String, + "Spend.From": reflect.Array, + "Spend.To": reflect.Array, + "Spend.Amount": reflect.Uint64, + "Spend.ExchangeHash": reflect.Array, + "Spend.IssuedAt": reflect.Struct, // time.Time + "Spend.Nonce": reflect.Uint64, + "Spend.Signature": reflect.Slice, + "PolicyChange.Platform": reflect.String, + "PolicyChange.Policy": reflect.Struct, + "PolicyChange.Version": reflect.Uint64, + "PolicyChange.At": reflect.Struct, // time.Time + "PolicyChange.Sigs": reflect.Slice, + "Policy.Mode": reflect.String, + "Policy.DebitCap": reflect.Uint64, + "Genesis.Platform": reflect.String, + "Genesis.Stewards": reflect.Slice, + "Genesis.Threshold": reflect.Int, + "Genesis.Policy": reflect.Struct, +} + +func testEconomyFieldAllowlist(t *testing.T) { + timeType := reflect.TypeOf(time.Time{}) + policyType := reflect.TypeOf(Policy{}) + seen := map[string]bool{} + for _, v := range []interface{}{Spend{}, PolicyChange{}, Policy{}, Genesis{}} { + typ := reflect.TypeOf(v) + for i := 0; i < typ.NumField(); i++ { + f := typ.Field(i) + key := typ.Name() + "." + f.Name + seen[key] = true + want, ok := economyFields[key] + if !ok { + t.Errorf("%s is not in economyFields: a new field on a ledger type could carry fiat, a currency, a per-member limit, or a memo — list it only after review", key) + continue + } + if f.Type.Kind() != want { + t.Errorf("%s has kind %s, want %s", key, f.Type.Kind(), want) + } + if want == reflect.Struct && f.Type != timeType && f.Type != policyType { + t.Errorf("%s is a %s, want time.Time or Policy", key, f.Type) + } + } + } + for key := range economyFields { + if !seen[key] { + t.Errorf("economyFields lists %s, which no ledger type carries — remove it", key) + } + } +} + +func testEconomyImports(t *testing.T) { + const canon = "github.com/NTARI-RAND/sohocloud-protocol/canon" + for name, f := range economySourceByFile(t) { + for _, imp := range f.Imports { + path, _ := strconv.Unquote(imp.Path.Value) + if path == canon || !strings.Contains(path, ".") { + continue // the protocol's canon, or the standard library + } + t.Errorf("%s imports %s: the ledger may see only the standard library and canon, so it can never reach the fiat rail, the market, disputes, or the covenant", name, path) + } + } +} + +// moneyVerb is the vocabulary of buying credit or cashing it out, matched as +// a camel-case word anywhere in an exported name. "Withdraw" is deliberately +// absent: withdrawing a dispute is not a payment. +var moneyVerb = regexp.MustCompile(`(^|[a-z0-9])(Buy|Purchase|Redeem|Redemption|CashOut|Cashout|Payout|PayOut|TopUp|Topup|Deposit|Mint|Burn|Convert|Conversion|ExchangeRate|Peg|Bridge|Fiat)([A-Z0-9]|$)`) + +// moneyRoute is the same vocabulary in an HTTP route path. routePath first +// strips any method word, splits camelCase, and turns "_" into "-", so +// "PATCH /v0/redeem", "/creditRedeem" and "/cash_out" are all caught. +// +// Long verbs (purchase, redeem, payout, deposit, ...) match anywhere in the +// path, so "/repurchase" and "/redeemcredit" are caught; none has a common +// innocent superstring. Short verbs (buy, mint, burn, peg) need a non-letter +// on the left, so "peg" inside "/logo.jpeg" does not match but "/buycredits" +// and "/mintcredit" do; "buy" additionally skips "buyer", so "/buyers" and +// "/buyer/42" pass. +// +// Residual, named rather than hidden: a Go 1.22 host-qualified pattern such +// as "POST cloudy.local/v0/redeem" is not read as a route, because stripping +// a host safely would also turn strings like "image/jpeg" into routes. No +// host-qualified pattern exists in Cloudy today. +var moneyRoute = regexp.MustCompile(`(^|[^a-z])(buy([^e]|e[^r]|$)|mint|burn|peg)|purchase|redeem|redemption|cash-?out|pay-?out|top-?up|deposit|convert|exchange-?rate|bridge`) + +// routeMethod is an HTTP method prefix on a Go 1.22 mux pattern, e.g. +// "PATCH " in "PATCH /v0/redeem". +var routeMethod = regexp.MustCompile(`^[A-Z]+ +`) + +var camelBoundary = regexp.MustCompile(`([a-z0-9])([A-Z])`) + +// routePath returns the path of a route-like string literal, normalized for +// moneyRoute, and whether the literal looks like a route at all. +func routePath(lit string) (string, bool) { + p := routeMethod.ReplaceAllString(lit, "") + if !strings.HasPrefix(p, "/") { + return "", false + } + p = camelBoundary.ReplaceAllString(p, "$1-$2") + return strings.ToLower(strings.ReplaceAll(p, "_", "-")), true +} + +// fiatTouch names the identifiers that mean a function is handling the fiat +// rail or fiat escrow: market rails, dispute escalations. +var fiatTouch = map[string]bool{ + "Fiat": true, "AcceptFiat": true, "Escalation": true, + "ActionRefundComplainant": true, "ActionReleaseRespondent": true, "ActionSplit": true, +} + +func testL2NoMoneyPath(t *testing.T) { + root := econModuleRoot(t) + scanned, creditSites := 0, 0 + econWalkSource(t, root, func(rel string, f *ast.File) { + scanned++ + for _, decl := range f.Decls { + fn, ok := decl.(*ast.FuncDecl) + if !ok { + continue + } + if fn.Name.IsExported() && moneyVerb.MatchString(fn.Name.Name) { + t.Errorf("%s: exported %s is named like a way to buy credit or cash it out", rel, fn.Name.Name) + } + if fn.Body == nil { + continue + } + touchesFiat, movesCredit := false, false + ast.Inspect(fn.Body, func(n ast.Node) bool { + switch x := n.(type) { + case *ast.Ident: + if fiatTouch[x.Name] { + touchesFiat = true + } + case *ast.SelectorExpr: + if fiatTouch[x.Sel.Name] { + touchesFiat = true + } + if pkg, ok := x.X.(*ast.Ident); ok && pkg.Name == "economy" && x.Sel.Name == "Spend" { + movesCredit = true + } + case *ast.BasicLit: + if x.Kind == token.STRING && strings.Contains(x.Value, "/") { + if v, err := strconv.Unquote(x.Value); err == nil { + if p, ok := routePath(v); ok && moneyRoute.MatchString(p) { + t.Errorf("%s: route %q is named like a way to buy credit or cash it out", rel, v) + } + } + } + } + return true + }) + if movesCredit { + creditSites++ + } + if touchesFiat && movesCredit { + t.Errorf("%s: %s both touches the fiat rail and builds an economy.Spend — that is a purchase or redemption path", rel, fn.Name.Name) + } + } + }) + if scanned < 20 { + t.Fatalf("scanned only %d source files — the scan is not looking at the module", scanned) + } + if creditSites == 0 { + t.Fatal("found no function that builds an economy.Spend; the fiat/credit separation check is not looking at the composition root") + } +} + +func testL2EarnedAtRuntime(t *testing.T) { + // Escrow: nothing to buy. Every spend is refused and every balance is 0. + esc := newFixture(t, ModeEscrow, 1000, 0x01, 0x02) + if err := esc.ledger.Post(esc.spend(esc.members[0], esc.members[1], 10, 1)); !errors.Is(err, ErrCreditDisabled) { + t.Fatalf("escrow Post = %v, want ErrCreditDisabled", err) + } + for _, m := range esc.members { + if b := esc.ledger.Balance(m.id); b != 0 { + t.Fatalf("escrow balance %d, want 0: credit exists before it can be earned", b) + } + } + + // Credit: a balance rises only as the payee of a spend the PAYER signed. + f := newFixture(t, ModeCredit, 100, 0x01, 0x02, 0x03) + payer, payee, outsider := f.members[0], f.members[1], f.members[2] + + // The payee cannot sign its own incoming credit. + forged := Spend{Platform: f.genesis.Platform, From: payer.id, To: payee.id, Amount: 50, + ExchangeHash: [32]byte{0x5A}, IssuedAt: time.Unix(1700000000, 0).UTC(), Nonce: 1} + forged.Sign(payee.priv) + if err := f.ledger.Post(forged); !errors.Is(err, ErrSignature) { + t.Fatalf("payee-signed spend = %v, want ErrSignature: credit must be issued by the payer", err) + } + // Nor can an outsider (an operator, a processor) sign it. + forged.Sign(outsider.priv) + if err := f.ledger.Post(forged); !errors.Is(err, ErrSignature) { + t.Fatalf("outsider-signed spend = %v, want ErrSignature", err) + } + // Nor can anyone pay themselves into a positive balance. + if err := f.ledger.Post(f.spend(payee, payee, 50, 1)); err == nil { + t.Fatal("self-transfer admitted") + } + if b := f.ledger.Balance(payee.id); b != 0 { + t.Fatalf("payee balance %d after refused attempts, want 0", b) + } + // The honest path: the payer signs. + if err := f.ledger.Post(f.spend(payer, payee, 50, 1)); err != nil { + t.Fatalf("payer-signed spend: %v", err) + } + if b := f.ledger.Balance(payee.id); b != 50 { + t.Fatalf("payee balance %d, want 50", b) + } +} + +// TestInvariant_L3_SovereignUnits binds registry ID L3: "no shared unit no +// conversion between communities." +// +// - the same member key yields unlinkable accounts on different platforms; +// - a spend signed for one platform never posts on another, cannot be +// re-pointed at another, and a foreign spend smuggled into a store makes +// the store refuse to open; +// - platform identity is fixed at Genesis and lives in no governed field, +// so no policy change can rebind a ledger to another community; +// - Amount is a bare count with no denomination, and no conversion, +// exchange-rate, peg, or bridge API exists anywhere in the module +// (enforced with L2's scan and field allowlist). +func TestInvariant_L3_SovereignUnits(t *testing.T) { + t.Run("one key, unlinkable accounts", testL3UnlinkableAccounts) + t.Run("a spend is bound to one platform", testL3SpendBound) + t.Run("platform identity is not governable", testL3PlatformFixed) + t.Run("no conversion surface", testL3NoConversion) +} + +func testL3UnlinkableAccounts(t *testing.T) { + _, pub := seedKey(0x31) + platforms := []string{"cloudy", "agrinet", "lighthouse", "cloudy-2"} + seen := map[AccountID]string{} + for _, p := range platforms { + id := AccountIDFor(p, pub) + if prev, dup := seen[id]; dup { + t.Fatalf("one key yields the same account on %q and %q: the two communities share a unit of identity", prev, p) + } + seen[id] = p + } +} + +func testL3SpendBound(t *testing.T) { + a := newFixture(t, ModeCredit, 100, 0x01, 0x02) + // A second community with the SAME member keys registered. + bGen := a.genesis + bGen.Platform = "cloudy-other" + bDir := mapDirectory{} + var bMembers []member + for _, seed := range []byte{0x01, 0x02} { + m := newTestMember(bGen.Platform, seed) + bMembers = append(bMembers, m) + bDir[m.id] = m.pub + } + bStore := NewMemStore() + bLedger, err := Open(bGen, bDir, bStore) + if err != nil { + t.Fatalf("Open B: %v", err) + } + + // A valid spend on A... + sA := a.spend(a.members[0], a.members[1], 10, 1) + if err := a.ledger.Post(sA); err != nil { + t.Fatalf("Post on A: %v", err) + } + // ...never posts on B, as-is or re-pointed at B's accounts and platform. + if err := bLedger.Post(sA); err == nil { + t.Error("A's spend posted on B") + } + moved := sA + moved.Platform, moved.From, moved.To = bGen.Platform, bMembers[0].id, bMembers[1].id + if err := bLedger.Post(moved); !errors.Is(err, ErrSignature) { + t.Errorf("A's signature re-pointed at B = %v, want ErrSignature", err) + } + for _, m := range bMembers { + if bal := bLedger.Balance(m.id); bal != 0 { + t.Errorf("B balance %d after A's trade, want 0: value crossed communities", bal) + } + } + + // A foreign spend written straight into B's store makes B refuse to open. + if err := bStore.Append(0, sA); err != nil { + t.Fatalf("Append: %v", err) + } + if _, err := Open(bGen, bDir, bStore); !errors.Is(err, ErrTampered) { + t.Errorf("Open over a store holding A's spend = %v, want ErrTampered", err) + } +} + +func testL3PlatformFixed(t *testing.T) { + // Policy — the only governed configuration — has no platform field. + pol := reflect.TypeOf(Policy{}) + for i := 0; i < pol.NumField(); i++ { + if pol.Field(i).Type.Kind() == reflect.String && pol.Field(i).Name != "Mode" { + t.Errorf("Policy.%s is a string: platform identity must not be governable", pol.Field(i).Name) + } + } + // A quorum-signed change for another platform is refused, and after a + // valid change the ledger is still bound to its own platform. + f := newFixture(t, ModeCredit, 100, 0x01, 0x02) + other := f.change(Policy{Mode: ModeCredit, DebitCap: 200}, 1, 2) + other.Platform = "cloudy-other" + other.Sigs = nil + other.Sign(f.stewards[0]) + other.Sign(f.stewards[1]) + if err := f.ledger.Enact(other); err == nil { + t.Error("a policy change for another platform was enacted") + } + if err := f.ledger.Enact(f.change(Policy{Mode: ModeCredit, DebitCap: 200}, 1, 2)); err != nil { + t.Fatalf("Enact: %v", err) + } + s := f.spend(f.members[0], f.members[1], 10, 1) + s.Platform = "cloudy-other" + s.Sign(f.members[0].priv) + if err := f.ledger.Post(s); err == nil { + t.Error("after a policy change the ledger accepted another platform's spend") + } +} + +func testL3NoConversion(t *testing.T) { + // Amount is a bare count of this platform's own unit: no denomination. + if k := reflect.TypeOf(Amount(0)).Kind(); k != reflect.Uint64 { + t.Errorf("Amount kind %s, want uint64: a unit must not carry a denomination", k) + } + // The conversion vocabulary (Convert, ExchangeRate, Peg, Bridge, Fiat) is + // enforced module-wide by testL2NoMoneyPath, and every ledger field by + // testEconomyFieldAllowlist; run them here so L3 fails on its own. + testL2NoMoneyPath(t) + testEconomyFieldAllowlist(t) +} + +// TestInvariant_L9_OperatorSetUniformLimit binds registry ID L9: "one +// uniform limit per operator community, set by that operator; never +// per-member, never derived from reputation." +// +// - one number: Policy carries exactly one limit and nothing per-account; +// - uniform: members and operator-held keys hit exactly the same floor, +// before and after the limit changes; +// - set by the operator's governance: only a quorum of the Genesis +// stewards can move it; a member, an outsider, or a short quorum cannot; +// - never from reputation: the ledger cannot import the covenant, and no +// function anywhere in the module that sets the limit reads a member's +// standing. Residual, named rather than hidden: the source scan is +// per-function, so a limit computed from standing in one function and +// passed into another that sets it would not be seen; the import wall +// and the steward quorum are the structural checks behind it. +func TestInvariant_L9_OperatorSetUniformLimit(t *testing.T) { + t.Run("one number, nothing per member", testL9OneNumber) + t.Run("every account hits the same floor", testL9Uniform) + t.Run("only the stewards' quorum moves it", testL9StewardsOnly) + t.Run("never derived from reputation", testL9NotFromReputation) +} + +func testL9OneNumber(t *testing.T) { + pol := reflect.TypeOf(Policy{}) + var limits []string + for i := 0; i < pol.NumField(); i++ { + f := pol.Field(i) + switch f.Type.Kind() { + case reflect.Map, reflect.Slice, reflect.Array, reflect.Func, reflect.Interface, reflect.Ptr, reflect.Struct: + t.Errorf("Policy.%s is a %s: a per-member or computed limit hides in a field like this", f.Name, f.Type.Kind()) + } + if f.Type == reflect.TypeOf(Amount(0)) { + limits = append(limits, f.Name) + } + } + if len(limits) != 1 || limits[0] != "DebitCap" { + t.Errorf("Policy limit fields = %v, want exactly [DebitCap]", limits) + } + // Post's limit check reads only the policy, never the account: the + // allowlisted API (testL2LedgerAPI) has no per-account setter. + testL2LedgerAPI(t) +} + +func testL9Uniform(t *testing.T) { + // members[3] plays an operator-held key: it is just another account and + // gets no deeper well. + f := newFixture(t, ModeCredit, 100, 0x01, 0x02, 0x03, 0x04) + sink := f.members[0] + payers := f.members[1:] + floor := func(cap Amount, nonce uint64) { + t.Helper() + for _, p := range payers { + need := int64(cap) + int64(f.ledger.Balance(p.id)) + if need > 0 { + if err := f.ledger.Post(f.spend(p, sink, Amount(need), nonce)); err != nil { + t.Fatalf("spend to the floor -%d: %v", cap, err) + } + } + if got := f.ledger.Balance(p.id); got != Balance(-int64(cap)) { + t.Fatalf("balance %d, want exactly -%d", got, cap) + } + if err := f.ledger.Post(f.spend(p, sink, 1, nonce+1)); !errors.Is(err, ErrLimit) { + t.Fatalf("one unit past -%d = %v, want ErrLimit for every account alike", cap, err) + } + } + } + floor(100, 1) + if err := f.ledger.Enact(f.change(Policy{Mode: ModeCredit, DebitCap: 250}, 1, 2)); err != nil { + t.Fatalf("Enact: %v", err) + } + floor(250, 3) +} + +func testL9StewardsOnly(t *testing.T) { + f := newFixture(t, ModeCredit, 100, 0x01, 0x02) + raise := Policy{Mode: ModeCredit, DebitCap: 10000} + + // A short quorum (1 of a 2-of-3) is refused. + if err := f.ledger.Enact(f.change(raise, 1, 1)); !errors.Is(err, ErrQuorum) { + t.Errorf("1 steward of a 2-of-3 quorum = %v, want ErrQuorum", err) + } + // Members' signatures count for nothing, however many. + c := PolicyChange{Platform: f.genesis.Platform, Policy: raise, Version: 1, At: time.Unix(1700000100, 0).UTC()} + for _, m := range f.members { + c.Sign(m.priv) + } + if err := f.ledger.Enact(c); !errors.Is(err, ErrQuorum) { + t.Errorf("members-only change = %v, want ErrQuorum", err) + } + // One steward signing twice is still one. + c = PolicyChange{Platform: f.genesis.Platform, Policy: raise, Version: 1, At: time.Unix(1700000100, 0).UTC()} + c.Sign(f.stewards[0]) + c.Sign(f.stewards[0]) + if err := f.ledger.Enact(c); !errors.Is(err, ErrQuorum) { + t.Errorf("duplicate steward signature = %v, want ErrQuorum", err) + } + if got := f.ledger.Policy().DebitCap; got != 100 { + t.Fatalf("limit moved to %d without the stewards' quorum", got) + } + // The stewards' quorum moves it, for everyone at once. + if err := f.ledger.Enact(f.change(raise, 1, 2)); err != nil { + t.Fatalf("quorum Enact: %v", err) + } + if got := f.ledger.Policy().DebitCap; got != 10000 { + t.Fatalf("limit = %d after quorum, want 10000", got) + } +} + +// reputationRead names the covenant read surface. A selector on the +// covenant package with one of these names, or a call to one of the +// distinctive standing readers (Standing, Relation, Overall, Harm) in a file +// that imports the covenant, means the function is reading a member's +// standing. +var reputationRead = map[string]bool{ + "Standing": true, "Relation": true, "Overall": true, "Harm": true, + "Count": true, "Total": true, "Distribution": true, "RelationStanding": true, +} + +var standingReader = map[string]bool{"Standing": true, "Relation": true, "Overall": true, "Harm": true} + +func testL9NotFromReputation(t *testing.T) { + // The ledger cannot see the covenant at all. + testEconomyImports(t) + + // Anywhere the limit is set — an economy.Policy is built, or a DebitCap + // is assigned — the same function must not read covenant standing. + root := econModuleRoot(t) + setSites := map[string]bool{} + econWalkSource(t, root, func(rel string, f *ast.File) { + covName := "" + for _, imp := range f.Imports { + if p, _ := strconv.Unquote(imp.Path.Value); strings.HasSuffix(p, "/internal/covenant") { + covName = "covenant" + if imp.Name != nil { + covName = imp.Name.Name + } + } + } + for _, decl := range f.Decls { + fn, ok := decl.(*ast.FuncDecl) + if !ok || fn.Body == nil { + continue + } + setsLimit, readsStanding := false, false + ast.Inspect(fn.Body, func(n ast.Node) bool { + switch x := n.(type) { + case *ast.CompositeLit: + // Building an economy.Policy or PolicyChange sets the + // limit; a JSON response that merely echoes DebitCap + // does not, and is not counted. + if sel, ok := x.Type.(*ast.SelectorExpr); ok && (sel.Sel.Name == "Policy" || sel.Sel.Name == "PolicyChange") { + if pkg, ok := sel.X.(*ast.Ident); ok && pkg.Name == "economy" { + setsLimit = true + } + } + // An elided element type, e.g. []economy.Policy{{DebitCap: n}}, + // carries no Type on the inner literal; a DebitCap key there + // still sets the limit. + if x.Type == nil { + for _, el := range x.Elts { + if kv, ok := el.(*ast.KeyValueExpr); ok { + if k, ok := kv.Key.(*ast.Ident); ok && k.Name == "DebitCap" { + setsLimit = true + } + } + } + } + case *ast.AssignStmt: + for _, lhs := range x.Lhs { + if sel, ok := lhs.(*ast.SelectorExpr); ok && sel.Sel.Name == "DebitCap" { + setsLimit = true + } + } + case *ast.SelectorExpr: + if covName == "" { + break + } + if pkg, ok := x.X.(*ast.Ident); ok && pkg.Name == covName && reputationRead[x.Sel.Name] { + readsStanding = true + } + if standingReader[x.Sel.Name] { + readsStanding = true + } + } + return true + }) + if setsLimit && strings.HasPrefix(rel, filepath.Join("internal", "economy")) { + continue // the ledger's own validation, which cannot import the covenant + } + if setsLimit { + setSites[rel] = true + } + if setsLimit && readsStanding { + t.Errorf("%s: %s sets the credit limit and reads covenant standing: the limit must never be derived from reputation", rel, fn.Name.Name) + } + } + }) + // Both composition roots open a ledger with a Policy; if either drops + // out of view the check has gone blind, so pin them. + for _, root := range []string{ + filepath.Join("internal", "consumerapi", "server.go"), + filepath.Join("cmd", "cloudy", "main.go"), + } { + if !setSites[root] { + t.Errorf("%s sets the credit limit but the check did not see it; the scan is not looking at the composition roots", root) + } + } +} + +// --- helpers --------------------------------------------------------------- + +func keys(m map[string]bool) []string { + out := make([]string, 0, len(m)) + for k := range m { + out = append(out, k) + } + sort.Strings(out) + return out +} + +// economySourceByFile parses this package's shipped (non-test) files. +func economySourceByFile(t *testing.T) map[string]*ast.File { + t.Helper() + entries, err := os.ReadDir(".") + if err != nil { + t.Fatalf("ReadDir: %v", err) + } + fset := token.NewFileSet() + out := map[string]*ast.File{} + for _, e := range entries { + n := e.Name() + if e.IsDir() || !strings.HasSuffix(n, ".go") || strings.HasSuffix(n, "_test.go") { + continue + } + f, err := parser.ParseFile(fset, n, nil, parser.ParseComments) + if err != nil { + t.Fatalf("parse %s: %v", n, err) + } + out[n] = f + } + if len(out) < 3 { + t.Fatalf("parsed only %d economy source files", len(out)) + } + return out +} + +func economySource(t *testing.T) []*ast.File { + t.Helper() + var out []*ast.File + for _, f := range economySourceByFile(t) { + out = append(out, f) + } + return out +} + +func econModuleRoot(t *testing.T) string { + t.Helper() + dir, err := os.Getwd() + if err != nil { + t.Fatalf("Getwd: %v", err) + } + for { + if _, err := os.Stat(filepath.Join(dir, "go.mod")); err == nil { + return dir + } + parent := filepath.Dir(dir) + if parent == dir { + t.Fatal("no go.mod above the economy package") + } + dir = parent + } +} + +func econWalkSource(t *testing.T, root string, fn func(rel string, f *ast.File)) { + t.Helper() + fset := token.NewFileSet() + err := filepath.Walk(root, func(path string, info os.FileInfo, err error) error { + if err != nil { + return err + } + if info.IsDir() { + if n := info.Name(); n == ".git" || n == "vendor" || n == "testdata" { + return filepath.SkipDir + } + return nil + } + if !strings.HasSuffix(path, ".go") || strings.HasSuffix(path, "_test.go") { + return nil + } + rel, _ := filepath.Rel(root, path) + f, err := parser.ParseFile(fset, path, nil, parser.ParseComments) + if err != nil { + t.Fatalf("parse %s: %v", rel, err) + } + fn(rel, f) + return nil + }) + if err != nil { + t.Fatalf("walk: %v", err) + } +} + +func econReceiverName(e ast.Expr) string { + switch x := e.(type) { + case *ast.StarExpr: + return econReceiverName(x.X) + case *ast.Ident: + return x.Name + case *ast.IndexExpr: + return econReceiverName(x.X) + } + return "" +}