Cosmic Bull

Rendered from pearl/p/permbook/DISCOVERY.md at commit f191063fd89d in the project repository. The committed file is the source of truth; this page is a rendering of it.

Discovery — permbook

Mandatory discovery gate (docs/DISCOVERY_AND_REUSE.md) for application-factory benchmark #3:

Build a reusable Gno capability that allows an application to define named permissions, grant those permissions to addresses, revoke them, and efficiently determine whether an address currently possesses a given permission. The capability should be suitable for composition by multiple independent Gno applications.

Run 2026-09-21, before any code was written. This document records what was searched, what was found, how each hit was classified, and what was decided.

The headline finding is stated first because it is the finding that most constrains the decision — and unlike benchmark #2, it points toward reuse, not away from it.


1. Headline finding

Cosmic Bull already owns a live implementation that satisfies the literal objective: r/permission_registry. It defines named permissions, grants them to addresses, revokes them, and answers Has(resource, permission, addr) in O(1). It is deployed, bounded, audited, and holds no value. Any consumer that accepts its trust model can use it today and write no new code at all.

That finding is recorded first, and it is not a formality — it nearly ended this benchmark without an implementation. What kept the question open is a specific, quoted property of that realm:

	MaxResources              = 1000
	MaxResourcesPerAdmin      = 20
	MaxPermissionsPerResource = 50
	MaxHoldersPerPermission   = 200

pearl/r/permission_registry/permission_registry.gno:16-27

These are global, shared, and unraisable. The realm is immutable after addpkg and its 526 lines contain no owner, no governance hook, and no upgrade path (verified by full read; grep for mustBeAdmin|assertOwner across the repo returns this file only, and the only admin concept in it is per resource, not per realm). So:

The gap is therefore not "nobody built this." It is that every implementation found puts permission state either in a shared realm with fixed global capacity, or in a library with no bounds and no authorization at all. Nothing found gives a consuming realm permission state that is simultaneously its own, bounded, and authorization-carrying.


2. Sources searched

#SourceHowResult
1Cosmic Bull catalogcatalog/primitives.md, catalog/applications.md3 primitives, 14 applications reviewed
2Cosmic Bull /p/ and /r/ sourceslocal tree; full read of permission_registry (526 lines); API grep across all .gno1 direct hit (§1, §4) — and only one
3On-chain Gno — pearl-1vm/qpaths enumeration, all 634 packages (191 /p/, 443 /r/), then full-source reads11 candidates read in full
4gno.land core corpusexamples/gno.land in the chain-matched source tree (gno@v0.0.0-20260827075919-c4c72fdd288c), 1022 .gno files, plus examples/quarantined/5 non-quarantined + 3 quarantined hits
5Public ecosystem / GitHub / docsdelegated web sweep: gnolang/gno tree + PRs/issues, moul/gno-contracts, onbloc, TERITORI, samouraiworld, gnolang/tx-exports, docs.gno.land, awesome-gno4 new finds — each re-verified locally (§5)

On the method

Source 3 was not keyword-only, for the reason recorded in catalog/primitives.md: keyword-only searching is how the feeledger / bazaar/fee/v1 miss happened. All 191 pearl-1 /p/ paths were dumped and swept. That mattered here — p/samcrew/basedao is a live, real role-and-member store and matches none of access|perm|rbac|role|auth|acl|grant.

Source 4 used the chain-matched tree on disk, so the corpus read is the one the target chain was built from. Its access-control surface came back as exactly five non-quarantined packages:

gno.land/p/moul/authz
gno.land/p/nt/groups/v0
gno.land/p/nt/ownable/v0  (+ /exts/authorizable)
gno.land/p/gnoland/boards/exts/permissions
gno.land/r/gov/dao/v3/memberstore

This set matches the pearl-1 enumeration exactly, which is the corroboration that makes both sweeps trustworthy rather than merely two lists.

Source 5 is the weakest link, and benchmark #2 recorded that a delegated web sweep had previously claimed packages that were not actually deployed. Every load-bearing claim it returned this time was therefore checked against my own enumeration before being used. Two claims were confirmed and are load-bearing; the rest are recorded but carry no decision (§5).


3. Candidate matrix

Eleven live pearl-1 candidates plus six off-chain ones, scored against the objective's own clauses. "Grant→many" is the clause that eliminates the most.

CandidateKindLive on pearl-1Named permsGrant→manyRevokeQuery costBoundedAuthorizationSafe cross-realm handle
r/…/permission_registry (Cosmic Bull)/r/yesyesyesyesO(1)yes, but global+fixedper-resource admin, 2-stepn/a (no handles)
p/nt/groups/v0/p/yesrolesyesyesO(log P + log H)nononeyes (Readonly views)
p/gnoland/boards/exts/permissions/p/yesyesyesyesO(roles held)novia boardsboards-typed only
p/samcrew/basedao/p/yesroles onlyyesyes2 × O(log)nononeno (§4)
p/gnoswap/rbac + r/gnoswap/{rbac,access}bothyesyesno — 1 addr/rolen/aO(1)13 fixedgnoswap-internaln/a
p/moul/authz v1/v2/p/yesno — one authorityyesyesO(log)noyespartial
p/nt/ownable/v0/exts/authorizable/p/yesno — one listyesyesO(log)noownerno
r/gov/dao/v3/memberstore/r/yesfixed tiers T1/T2/T3yesyesO(log)noallowed-DAO gategated
r/g1cz86tl8…/access/r/yesno — paid tiern/an/aO(1)nosingle ownern/a
p/onbloc/access/manager/p/nonumeric IDs, names not storedyesyesO(log)full OZ model
p/akkadia/v0/rbac/p/no (test13)yesyesyesO(roles held)nonone
TERITORI/p/role_manager/p/never deployedyesyesyesO(roles held)nowildcardpre-interrealm
p/thox/accesscontrol (quarantined)/p/noyesyesyesO(R) linearnoadmin — broken (§4)no
p/archive/acl (quarantined)/p/noyesyesnoneregexp per checkno
p/n2p5/mgroup (quarantined)/p/nonoyesyesO(log)noowner

4. Classified findings

EXACT DUPLICATE — none

No relevant existing implementation was found in the searched sources that is simultaneously (a) string-named permissions, (b) grant to arbitrary addresses, (c) bounded, (d) consumer-owned, and (e) live on pearl-1.

That sentence is the mandated one, used in its narrow literal sense: a statement about the five sources in §2, at this date. It is not a claim about the Gno ecosystem as a whole, and "not found" is not "does not exist."

gno.land/r/g1ut6uspuh73e02yauxpmyt8g3wwddaq8utagvm3/permission_registry — live on pearl-1, [addpkg] height = 604615.

Verified this run: the on-chain source fetched via vm/qfile is byte-identical to pearl/r/permission_registry/permission_registry.gno (18070 bytes, diff clean modulo the transport newline). The [addpkg] height is unchanged from its deployment record, which is proof it has not been redeployed.

It is good work and it does the job. Its Has is genuinely O(1) via three-level map nesting (permissions[resource][permission][addr], :64), its admin handoff is two-step with quota checked at consent time (:343-366, the Y4 remediation), it rejects stray coin sends (:127), and its INTEGRATOR CONTRACT (Y7) at :373 correctly warns consumers that Has authenticates nobody:

// INTEGRATOR CONTRACT (Y7): Has takes the subject address explicitly and
// performs NO caller authentication — it answers "does this address hold
// this permission", not "may my caller do this".

It is not modified, not redeployed, and not extended by this work (CLAUDE.md §6). It is instead recommended: the integrator documentation for the new primitive will name it as the zero-new-code path for consumers who accept a shared namespace. The two are not duplicates — see Decision 2.

Two integrator hazards are recorded here because they belong to the honest picture and not to a marketing comparison:

  1. The name race is not bound to an identity. Has("treasury", …) keys on a bare string. A consumer that does not also assert GetAdmin("treasury") == expected is trusting whoever won the race for that name. The API offers GetAdmin but nothing in it binds a read to an expected admin, and the Y7 contract covers caller derivation only.
  2. The ceilings above are permanent. Not a defect — a deliberate bound on a permissionless shared registry, and the right call for that design. It is a ceiling on that architecture, which is the point of §1.

No other Cosmic Bull code is a candidate. grep -rlE 'func (Has|Grant|Revoke| AddRole|GrantRole|Authorize|IsAuthorized|CanDo)' across pearl/ returns permission_registry, plus r/grants (a funding-escrow market — Grant there is a noun about money, not a verb about authority) and a test file.

REUSABLE EXISTING PRIMITIVE — composed

gno.land/p/nt/groups/v0 — live on pearl-1, and the substrate this work builds on rather than replacing.

A Group holds a base address set plus a bptree registry of named Roles, each with its own addrset.Set of members. Mapped onto the objective, the four verbs are already there:

Objective clausegroups primitive
define a named permissiong.AddRole(name)
grant to an addressr.Members().Add(addr)
revoker.Members().Remove(addr)
queryr.Members().Has(addr)

Its query profile is the best of any named-permission candidate found, and for a structural reason worth stating: cost is O(log P + log H) — two independent B+-tree descents — and is therefore independent of how many permissions the subject already holds. Every other named-permission implementation found (boards/exts/permissions, akkadia/rbac, TERITORI/role_manager) answers the query by iterating the roles the address holds, so a heavily-privileged address is the expensive case. That is the wrong asymptotic for an authorization check on a hot path.

It also ships the cross-realm safety model this objective needs, in its own words (doc.gno:20-38):

  1. Do not ACCEPT a *Group or *Role from an external/untrusted caller …
  2. Do not RETURN a *Group or *Role from any method or function callable by untrusted realms. Return *ReadonlyGroup or *ReadonlyRole instead.
  3. Do not TRUST a *ReadonlyGroup or *ReadonlyRole received from an untrusted caller — it is a live handle over the sender's data, not a snapshot.

readonly.gno backs that with concrete structs holding unexported fields and read-only methods, and IterateRoles deliberately hands the callback a *ReadonlyRole so that plumbing an untrusted callback into iteration cannot escalate into mutation.

Reused wholesale. No tree, set, or iteration code is written by this work.

Dependency chain verified live on pearl-1 before committing to it: p/nt/groups/v0p/moul/addrset + p/nt/bptree/v0, both present in the 191-package /p/ enumeration.

What groups does not supply, and what therefore constitutes the delta: no bound on role count or member-set size; no authorization of any kind (it authenticates nobody, by design); no permission-versus-membership semantics (it has a "base set" concept that is meaningless for permissions); and GetRole returns a mutable *Role, so the discipline in rule 2 is a convention the consumer must keep, not something the type system keeps for it.

gno.land/p/gnoland/boards/exts/permissions — live on pearl-1, and the single most important structural precedent found: it is exactly the layer this work proposes, already built on groups, by the gno.land core authors. It proves the composition is the intended one.

Not reusable directly because every signature is welded to boards types — boards.Permission, boards.Role, boards.PermissionSet — so a non-boards application cannot import it. It also answers HasPermission(user, perm) by iterating RolesContaining(user) and reading a PermissionSet out of role meta, which is the O(roles-held) profile rejected above. Its ValidatorFunc registry carries an explicit SECURITY note that validators run with full mutation access under the owning realm's authority — the stored-callback hazard, and a feature deliberately not carried forward.

gno.land/p/samcrew/basedao — live on pearl-1 (confirmed independently against my own /p/ enumeration, line 178; the web sweep's claim was correct). Maintains both a forward index (member → roles) and a reverse index (role → members), which gives a clean two-lookup HasRole. Genuinely the closest live /p/ in query shape.

Disqualified on evidence, not on taste:

type MembersStore struct {
	Roles   *avl.Tree // role name -> *Role
	Members *avl.Tree // string -> *avl.Tree [roles -> struct{}]
}

type Role struct {
	Name        string
	Description string
	Color       string
	Members     *avl.Tree // string -> struct{}
}

members.gno:10-31

Both container fields and Role.Members are exported mutable *avl.Tree handles. That is precisely the hazard groups/v0 rule 2 exists to prevent: any realm holding a *MembersStore can call m.Roles.Set(...) directly and, by borrow rule #2, the write commits under the allocating realm's authority. There are no readonly views anywhere in the package. Additionally: keys are string, not address, so nothing validates an address at all; AddRoleToMember (:297) applies no bound of any kind; and RemoveRole (:272) iterates every member of the role in one call, which is unbounded gas. The in-monorepo copy sits under examples/quarantined/, which the examples README defines as not audited and not modernized to the current interrealm spec.

gno.land/p/gnoswap/rbac, r/gnoswap/rbac, r/gnoswap/access — all three live. Disqualified by one quoted line from p/gnoswap/rbac/doc.gno:

"Each role can only have one assigned address. For multi-address authorization, consider creating multiple roles or implementing a wrapper."

Confirmed in the realm source: IsAuthorized(role, caller) reduces to caller == roleAddresses[role] (r/gnoswap/access/access.gno). This is a role→address routing table for a known set of system contracts, not a grant-to-many capability. Correct for gnoswap; not the objective.

gno.land/r/gov/dao/v3/memberstore — live. Three fixed tiers T1/T2/T3 carrying voting power and invitation points, not arbitrary named permissions. Its Get(_ int, rlm realm) is a textbook correct secondary-realm-parameter guard and is cited as a pattern precedent, but access to the store is gated to dao.InAllowedDAOs(currealm), so it is closed infrastructure.

gno.land/p/moul/authz v1/v2 and gno.land/p/nt/ownable/v0/exts/authorizable — both live, both correct, both single unnamed authority sets. authz has one MemberAuthority; authorizable has one auth list plus an ownable superuser. Neither has a concept of which permission. authorizable is cited as the pattern precedent for the (_ int, rlm realm, …) + rlm.IsCurrent() shape a /p/ must use to authorize. p/moul/authz's README carries an explicit "not security-audited" disclaimer.

gno.land/r/g1cz86tl8…/access — live, and a false positive on name alone: it is GnoPulse's paid-subscription gate, with a banker, a withdrawal path, and HasAccess(addr) meaning "has paid." Recorded because "an access realm already exists on pearl-1" is exactly the claim that is true from a package listing and wrong from the source.

DISQUALIFIED ON EVIDENCE — off-chain near-misses

p/thox/accesscontrol (upstream examples/quarantined/) — the most literal match to the objective's wording anywhere: CreateRole, GrantRole, RevokeRole, RenounceRole, SetRoleAdmin, HasAccount. It is also the clearest argument against adopting a near-match without reading it.

Correction to a claim from the web sweep, recorded because getting it wrong would misrepresent upstream: gnolang/gno PR #2307 which added this package was merged, not rejected; it was quarantined later in a bulk action.

p/archive/acl (quarantined) — HasPerm(addr, verb, resource) runs regexp.MatchString per permission per check, holds unbounded perms slices, and has no revocation whatsoever: // TODO: helpers to remove permissions.

p/n2p5/mgroup (quarantined) — owner, backup owners, one member set. No named permissions.

p/onbloc/access/manager — an OpenZeppelin AccessManager v5.6.1 port, and by some distance the most rigorous access-control code in the ecosystem. Excluded for two independent reasons, the first decisive:

  1. It is not on pearl-1. Independently verified against my own enumeration: the @onbloc namespace on pearl-1 contains exactly diff, int256, json, uint256. A gnolang/tx-exports archive carries it under a pearl path, which is a genuine divergence I could not explain and did not rely on.
  2. Roles are uint64 IDs and labels are deliberately not stored in state (event-only), so there is no on-chain name→role mapping. The objective asks for named permissions.

p/akkadia/v0/rbac (test13 archive only, no source repo locatable) and TERITORI/p/role_manager (never deployed on any archived chain, still on the pre-interrealm std.Address API) — both are true named-permission models and both answer the query by iterating the subject's roles. Recorded as prior art; neither is reachable as a dependency.

GENUINELY NEW

The delta, stated as narrowly as it can honestly be stated. Not a permission system — a bounded, authorization-carrying facade over groups:

  1. An explicit admin bound to the permission set, with cur-threaded grant/revoke and a two-step admin handoff. groups authenticates nobody. The two-step form is not invention: it is the audited Y4 finding from Cosmic Bull's own permission_registry (:304-366), which recorded that a one-step transfer to a well-formed-but-unowned address is permanently fatal.
  2. Consumer-chosen bounds on permission count, holders per permission, and name length/charset — the objective's "bounded state and gas", which groups does not provide and which no live /p/ candidate provides.
  3. Permission semantics rather than membership semantics: no base set, an explicit lifecycle (a permission comes into being on first grant and is pruned on last revoke, so an empty permission does not linger as state), and no per-role metadata slot — deliberately, since groups' own doc devotes a section to the hazard of storing mutable pointers in meta.
  4. A cross-realm surface that is safe by construction, not by convention: the *Group is unexported and never escapes; nothing returns a mutable handle; reads return values or groups' own readonly views.

5. Decisions

Decision 1 — build a new /p/ primitive, permbook, composing gno.land/p/nt/groups/v0.

Justified by elimination against the objective's own clause "suitable for composition by multiple independent Gno applications":

The intervention is deliberately small and the smallness is the point: the benchmark's own metric is dependency-weighted new code, and a thin, audited, bounded layer written once beats the same ~60 lines re-derived by every consuming application. That is the same value boards/exts/permissions delivers to boards — generalized, and typed to address instead of to one application's structs.

Decision 2 — permbook does not replace permission_registry; the two are different architectures and both are documented.

Stated plainly so a reviewer can disagree on evidence. The distinguishing property is who owns the state:

r/permission_registryp/permbook
state lives ina third-party realmthe consumer's own realm
namespaceone global, rivalrousprivate per consumer
capacityfixed forever, sharedchosen by the consumer
consumer writesnothingits own crossing wrappers
consumer must trustthe registry realm + the name raceonly itself

That is "shared service" versus "embedded library." Both are legitimate; they are not duplicates. The integrator docs will say so and will name permission_registry as the zero-code path.

Decision 3 — the admin lives inside permbook, authorized via (_ int, rlm realm, …) + rlm.IsCurrent().

Counter to the ecosystem's near-universal posture — the sweep found that every /p/ permission library examined declares that it cannot authenticate callers and punts authorization to the consumer. Taken here because the objective lists "explicit authorization" as a design requirement, and because punting it is what makes each of those libraries a substrate rather than a capability. The mechanism is not invented: it is the live, correct shape in p/nt/ownable/v0 and p/nt/ownable/v0/exts/authorizable.

Decision 4 — ownable/v0 is not composed for the admin, and the reason is recorded rather than assumed.

ownable.Ownable was read in full before rejecting it. Its owner field is unexported (ownable.gno:21-23) and its only handoff is one-step TransferOwnership (:61), which authorizes against the current owner. A two-step model needs the write to happen when the nominee calls, which ownable cannot express and which cannot be layered on from outside without leaving the one-step path reachable. So permbook stores admin and pendingAdmin as its own unexported fields. This costs roughly thirty lines and buys the audited Y4 semantics; composing ownable here would have been reuse for its own sake.

Decision 5 — no stored closures, no callbacks, no validator hooks.

Directly counter to boards/exts/permissions' ValidatorFunc registry and its WithPermission(cb func()) form, and for the reason that package's own SECURITY note gives: a stored callback runs with full mutation access under the owning realm's authority. A general-purpose primitive cannot assume its consumers' code is vetted. Same rule as duebook.

Decision 6 — no economic mechanism, no value custody, no banker.

The objective says so explicitly, and the failure mode is live on pearl-1: r/g1cz86tl8…/access is what happens when a permission gate acquires a price list, a credit balance, and a withdrawal path. permbook imports no banker and holds no coins.

Decision 7 — ship a reference consumer realm.

For the reason established in benchmark #2: the central claims here — "a revoked address is refused in the next transaction", "the admin gate holds against a non-admin caller", "the bounds actually panic" — are claims about persistent state across transactions. A vm/qeval against a pure package is a single ephemeral evaluation and cannot falsify any of them. Only a realm, called repeatedly, can. It will hold no coins.


6. Known limitations, recorded before implementation

  1. permbook cannot authenticate an EOA. rlm.IsCurrent() proves the realm value came from a live crossing frame; rlm.Previous().Address() is then the principal. A consumer that threads a cur from the wrong frame, or that wraps permbook in a non-crossing exported helper, reintroduces the Class-2 designation-forgery bug. This is the top item in the consumer contract and the package cannot enforce it — the same shape as duebook's caller-supplied clock, and the same honest admission.
  2. The consumer owns the state and can leak it. permbook never returns a mutable handle, but it cannot stop a consumer from exposing the *Book itself. groups rule 2, inherited.
  3. Bounds are chosen at construction and are then fixed for that book. A consumer that picks them too low has the same problem permission_registry has — except that it chose them, it can deploy a second book, and its realm is the only one affected.
  4. Two bptree descents is not one map lookup. permission_registry's three-level Go map is O(1); permbook is O(log P + log H). The trade is deterministic ordered iteration and per-consumer isolation, and it is a trade, not a free win.
  5. No permission hierarchy, no wildcards, no delegation. One flat set of named permissions per book. archive/acl's regexp matching and role_manager's "*" wildcard were both seen and both declined — a wildcard makes "does this address hold permission X" stop being a lookup.
  6. Source 5 is a delegated web sweep. Its two load-bearing claims (basedao live on pearl-1; onbloc/access/manager absent from pearl-1) were independently re-verified against my own enumeration and are used. Its other findings are recorded but carry no decision.
  7. Ecosystem-wide uniqueness is not claimed. Five sources, one date.

7. Evidence

Commands and reads that produced the findings above, so they can be re-run.

RPC=https://rpc.pearl.testnets.gno.land:443

# Source 3 — full pearl-1 enumeration (634 packages), not keyword-only
curl -s "$RPC/abci_query?path=%22vm/qpaths%22&data=0x$(printf 'gno.land/p/' | xxd -p)"
curl -s "$RPC/abci_query?path=%22vm/qpaths%22&data=0x$(printf 'gno.land/r/' | xxd -p)"
# -> 191 /p/, 443 /r/

# byte-verification of the deployed permission_registry against the local tree
diff <(curl … vm/qfile … permission_registry/permission_registry.gno) \
     pearl/r/permission_registry/permission_registry.gno
# -> identical, 18070 bytes; [addpkg] height = 604615 unchanged

# Source 4 — chain-matched corpus, 1022 .gno files
GNOSRC="$HOME/go/pkg/mod/github.com/gnolang/gno@v0.0.0-20260827075919-c4c72fdd288c"
find "$GNOSRC/examples/gno.land" -type d \
  | grep -iE 'acl|rbac|access|permission|role|capab|grant|authz|authoriz|ownable|group|member'
grep -rlE '^func .*(GrantRole|RevokeRole|HasPermission|HasRole|AddPermission)' \
  "$GNOSRC/examples/gno.land" --include='*.gno' | grep -v _test

# Source 2 — Cosmic Bull's own tree
grep -rlE 'func (Has|Grant|Revoke|AddRole|GrantRole|Authorize|IsAuthorized|CanDo)' \
  --include='*.gno' pearl/
# -> permission_registry, grants (money), one test file

# dependency availability for the composition decision
grep -E 'p/nt/groups/v0|p/nt/bptree/v0|p/moul/addrset' /tmp/pearl-p-packages.txt

Full-source reads (whole files, not outlines — an outline is an author's claim, not evidence):

Re-run this gate before deployment, per docs/DISCOVERY_AND_REUSE.md step 8.