Package permbook is a bounded, authorization-carrying facade over gno.land/p/nt/groups/v0. It lets a realm define named permissions, grant them to addresses, revoke them, and ask "does this address currently hold this permission" without iterating anything.
It holds no coins, imports no banker, stores no callbacks, and owns no package-level state. The importing realm allocates a *Book and keeps it private.
What this package adds, and what it does not
It adds exactly four things to groups, and deliberately nothing else:
- an ADMIN bound to the book, with a two-step handoff;
- BOUNDS on permission count, holders per permission, and name shape;
- permission semantics instead of membership semantics — no base set, no metadata slot, and an empty permission is pruned rather than kept;
- a cross-realm surface that is safe by CONSTRUCTION — the *groups.Group is unexported and no method returns a mutable handle to it.
Everything else — the B+-tree registry, the member sets, ordered iteration, the readonly views — comes from groups. This package writes no data-structure code.
The query
Example
1if !book.Has("withdraw", who) {
2 panic("not authorized")
3}
Has costs two independent B+-tree descents: O(log P) to find the permission, O(log H) to find the holder. It is NOT a function of how many permissions `who` already holds. That matters: every other named-permission implementation surveyed answers this by iterating the subject's permissions, which makes the most-privileged address the most expensive to check — the wrong asymptotic for an authorization check on a hot path.
Authorization
Mutators take (_ int, rlm realm) and identify the principal as rlm.Previous().Address(), after asserting rlm.IsCurrent(). The consuming realm threads its own cur:
Example
1func Grant(cur realm, perm string, addr address) {
2 if err := book.Grant(0, cur, perm, addr); err != nil {
3 panic(err)
4 }
5}
IsCurrent() rejects a stale or stashed realm value, so a hostile realm cannot replay an old cur to impersonate the admin. This is the same shape as p/nt/ownable/v0 and p/nt/ownable/v0/exts/authorizable.
Lifecycle of a permission
A permission comes into existence on its first grant and ceases to exist when its last holder is revoked. There is no create step and no reserved name. Has returns false either way, so the distinction is invisible to a caller and the book does not accumulate empty buckets.
Example
1(absent) ──Grant──> held by 1..MaxHoldersPerPermission ──Revoke last──> (absent)
2 │
3 └── DropPermission ──> (absent)
Admin handoff is two-step
NominateAdmin records a nominee; only AcceptAdmin, called by that nominee, moves the admin. A one-step transfer to a well-formed-but-unowned address is permanently fatal — address.IsValid only checks bech32 form, so a mistyped address passes validation and leaves a book nobody can ever grant or revoke on again. This is the Y4 finding from the audit of Cosmic Bull's own r/permission_registry, carried forward.
Consumer contract — the parts this package CANNOT enforce
-
THREAD A LIVE cur. rlm.IsCurrent() proves the realm value came from a live crossing frame, and rlm.Previous().Address() is then the principal that crossed into your realm. A consumer that wraps a permbook mutator in a NON-crossing exported helper resolves its importer's caller instead of its importer — Class-2 designation forgery, in the consumer. Call permbook mutators from your own crossing entrypoints, passing that entrypoint's own cur.
-
DO NOT LEAK THE *Book. No method here returns a mutable handle, but a consumer that exports its *Book — or returns it from a function reachable by another realm — hands out every mutator on it, and borrow rule #2 commits those writes under YOUR realm's authority. Keep it in an unexported package-level variable.
-
Has ANSWERS ABOUT AN ADDRESS, NOT ABOUT YOUR CALLER. It performs no authentication. Derive the address from your own crossing entrypoint's cur.Previous().Address() and pass it in.
-
CHOOSE LIMITS DELIBERATELY. They are fixed for the life of a Book. A consumer that needs different bounds later must allocate a second Book; nothing here migrates state between them.
Note on the groups base set
A groups.Group carries a base address set alongside its named roles. This package never writes to it, so it is provably empty for any Book, and "in the base set" can never mean "holds a permission".