mesh-grants: write the bus accounts the mesh wants to a state only the vault reads, so the vault can mint them; grants_accounts reads it (issue 774, ADR 0561 §2, §9)
This commit is contained in:
@@ -214,6 +214,10 @@ func (s ownStore) Put(state, key string, value []byte) (uint64, error) {
|
||||
return s.c.State(state).Put(key, json.RawMessage(value))
|
||||
}
|
||||
|
||||
func (s ownStore) Keys(state string) ([]string, error) { return s.c.State(state).Keys() }
|
||||
|
||||
func (s ownStore) Delete(state, key string) error { return s.c.State(state).Delete(key) }
|
||||
|
||||
// emitting is the process connection as the part's emitter: its events, with no options.
|
||||
type emitting struct{ c *process.Conn }
|
||||
|
||||
|
||||
@@ -62,14 +62,9 @@ func main() {
|
||||
}
|
||||
}
|
||||
|
||||
// keptStates is what the part keeps, each readable (ADR 0495 §2-§3): the view and a session read them.
|
||||
func keptStates() []announce.State {
|
||||
var out []announce.State
|
||||
for _, s := range grants.States {
|
||||
out = append(out, announce.State{Name: s, Readable: true})
|
||||
}
|
||||
return out
|
||||
}
|
||||
// keptStates is what the part keeps (ADR 0495 §2-§3): each readable, for the view and a session, but the wanted accounts,
|
||||
// by the vault alone (ADR 0561 §2).
|
||||
func keptStates() []announce.State { return grants.Announced() }
|
||||
|
||||
// run is the process: it returns when ctx ends, or when it has no credential or cannot dial the bus at all. A bucket or
|
||||
// read the bus refuses never ends it.
|
||||
@@ -118,7 +113,8 @@ func run(ctx context.Context, getenv func(string) string, logOut io.Writer) erro
|
||||
bus := newBusReader(ctx, js)
|
||||
f.Part = &grants.Part{Read: bus, Call: conn, Store: ownStore{conn}, Emit: emitting{conn},
|
||||
Publish: memberships{ctx: ctx, nc: nc, js: js}, Create: creating{ctx: ctx, js: js},
|
||||
Objects: sealedObjects{ctx: ctx, js: js}, Logf: logger.Printf}
|
||||
Objects: sealedObjects{ctx: ctx, js: js}, Logf: logger.Printf,
|
||||
Bus: grants.BusAddress{URL: account.URL, Fingerprint: account.Fingerprint}}
|
||||
f.Watch = bus
|
||||
// The operator's word on take-over and hand-back is their yes on their channel: the part asks it itself, and acts on
|
||||
// the warrant once (mesh-sdk asker), after its refusals.
|
||||
|
||||
@@ -39,6 +39,10 @@ func (runtimeStore) Put(state, key string, value []byte) (uint64, error) {
|
||||
return stdio.State(state).Put(key, json.RawMessage(value))
|
||||
}
|
||||
|
||||
func (runtimeStore) Keys(state string) ([]string, error) { return stdio.State(state).Keys() }
|
||||
|
||||
func (runtimeStore) Delete(state, key string) error { return stdio.State(state).Delete(key) }
|
||||
|
||||
// runtimeBus calls tools and says events through the runtime.
|
||||
type runtimeBus struct{}
|
||||
|
||||
@@ -113,6 +117,13 @@ func toolsOf(p *grants.Part) []stdio.Tool {
|
||||
Run: func(a map[string]any) (any, error) {
|
||||
return p.HandBack(stdio.CallerOf(a), grants.Answer(a, "why"))
|
||||
}},
|
||||
{Name: grants.ToolAccounts, Description: "The bus accounts the mesh wants (novox/hq ADR 0561 §2, §9): every account the vault " +
|
||||
"mints that placement composes (a module's, a process's, a machine's provisioner client's), as the grants part last " +
|
||||
"wrote it to its state accounts, which the vault alone reads: user, kind, node, module, process, the bus's address and " +
|
||||
"fingerprint, a module's claims with the verbs each seat promises, and when the key was written; one user's or all; and " +
|
||||
"what the last pass wrote, deleted or kept. Never a password. Read-only: grants_compose_now writes them again.",
|
||||
Input: schema(map[string]string{"user": "one bus user, e.g. ace.mesh-build-go"}),
|
||||
Run: func(a map[string]any) (any, error) { return p.Accounts(grants.Answer(a, "user")) }},
|
||||
{Name: grants.ToolTakeOverState, Description: "Where the switch stands: the list the bus node follows, the ask open now, the request, and " +
|
||||
"every act with what became of it (asked, switched, declined, expired, refused and why).",
|
||||
Input: schema(nil),
|
||||
|
||||
@@ -6,6 +6,7 @@ import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"io/fs"
|
||||
"maps"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"slices"
|
||||
@@ -216,6 +217,27 @@ func (s ownStore) Put(state, key string, value []byte) (uint64, error) {
|
||||
return b.revs[state+"/"+key], nil
|
||||
}
|
||||
|
||||
func (s ownStore) Keys(state string) ([]string, error) {
|
||||
b := s.b
|
||||
b.mu.Lock()
|
||||
defer b.mu.Unlock()
|
||||
var out []string
|
||||
for k := range b.own[state] {
|
||||
out = append(out, k)
|
||||
}
|
||||
slices.Sort(out)
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func (s ownStore) Delete(state, key string) error {
|
||||
b := s.b
|
||||
b.mu.Lock()
|
||||
defer b.mu.Unlock()
|
||||
b.writes = append(b.writes, state+" delete")
|
||||
delete(b.own[state], key)
|
||||
return nil
|
||||
}
|
||||
|
||||
func (b *fakeBus) Emit(event string, _ any) error {
|
||||
b.mu.Lock()
|
||||
defer b.mu.Unlock()
|
||||
@@ -1649,6 +1671,70 @@ func TestGrants(t *testing.T) {
|
||||
t.Errorf("the refusal was not said: %v", logged)
|
||||
}
|
||||
})
|
||||
|
||||
// 774/AC4 (novox/hq ADR 0561 §2): every account the vault mints that the records compose is written to the state
|
||||
// accounts once, with the bus's address and a module's claims, read by the vault alone; a key is deleted when placement
|
||||
// no longer names its user, and never in a pass whose reads failed.
|
||||
t.Run("wanted accounts: written once each, read by the vault alone, retired only when placement drops them", func(t *testing.T) {
|
||||
b := fixture()
|
||||
b.loaded = shadowOf(t, b)
|
||||
p := partOn(b)
|
||||
p.Bus = grants.BusAddress{URL: "nats://a.mesh:4222", Fingerprint: "ab"}
|
||||
p.Compose()
|
||||
p.Compose()
|
||||
wrote := 0
|
||||
for _, w := range b.writes {
|
||||
if w == grants.StateAccounts {
|
||||
wrote++
|
||||
}
|
||||
}
|
||||
held := slices.Sorted(maps.Keys(b.own[grants.StateAccounts]))
|
||||
if !slices.Equal(held, []string{"a.mod-x", "a.nats", "b.mod-y"}) || wrote != 3 {
|
||||
t.Fatalf("accounts %v written %d times in two passes, want the three modules' once each", held, wrote)
|
||||
}
|
||||
nats, err := buscompose.ReadWantedAccount("a.nats", b.own[grants.StateAccounts]["a.nats"])
|
||||
if err != nil || nats.URL != "nats://a.mesh:4222" || nats.Fingerprint != "ab" || len(nats.Claims) != 1 ||
|
||||
nats.Claims[0].Seat != catalogue.BrokerSeat || nats.At.IsZero() {
|
||||
t.Errorf("a.nats is wanted as %+v (%v)", nats, err)
|
||||
}
|
||||
var readers []string
|
||||
for _, s := range grants.Announced() {
|
||||
if s.Name == grants.StateAccounts {
|
||||
if s.Readable {
|
||||
t.Error("the wanted accounts are announced readable by anyone")
|
||||
}
|
||||
readers = s.Readers
|
||||
}
|
||||
}
|
||||
if !slices.Equal(readers, []string{"mesh-vault"}) || buscompose.ReaderMay("mesh-delivery-view", buscompose.GrantsAccounts) {
|
||||
t.Errorf("the wanted accounts are read by %v", readers)
|
||||
}
|
||||
// A read that fails retires nothing; once read, the user placement dropped is retired.
|
||||
delete(b.states[grants.ReadAssignments], "b.mod-y")
|
||||
b.refused[grants.ReadResolved] = true
|
||||
if res := p.Compose(); res.Accounts == nil || len(res.Accounts.Deleted) != 0 || b.own[grants.StateAccounts]["b.mod-y"] == nil {
|
||||
t.Fatalf("a pass whose read failed deleted %+v", res.Accounts)
|
||||
}
|
||||
delete(b.refused, grants.ReadResolved)
|
||||
if res := p.Compose(); res.Accounts == nil || !slices.Equal(res.Accounts.Deleted, []string{"b.mod-y"}) {
|
||||
t.Fatalf("the user placement dropped is not retired: %+v", res.Accounts)
|
||||
}
|
||||
// The tool reads them, one and all, each with when it was written.
|
||||
one, err := p.Accounts("a.nats")
|
||||
all, err2 := p.Accounts("")
|
||||
if err != nil || err2 != nil || one.(map[string]any)["count"] != 1 || all.(map[string]any)["count"] != 2 ||
|
||||
one.(map[string]any)["accounts"].([]buscompose.WantedAccount)[0].At.IsZero() {
|
||||
t.Errorf("the tool reads %v and %v (%v %v)", one, all, err, err2)
|
||||
}
|
||||
if _, err := p.Accounts("b.mod-y"); err == nil {
|
||||
t.Error("the tool reads an account no longer wanted")
|
||||
}
|
||||
// With no bus address no account is written, and the pass says why.
|
||||
bare := partOn(fixture())
|
||||
if res := bare.Compose(); res.Accounts != nil || !strings.Contains(strings.Join(res.Errors, " "), "no bus address") {
|
||||
t.Errorf("a part with no bus address wrote %+v: %v", res.Accounts, res.Errors)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
type fakeAsker struct {
|
||||
|
||||
@@ -0,0 +1,212 @@
|
||||
package grants
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"slices"
|
||||
"sort"
|
||||
|
||||
"git.novox.be/novox/mesh-sdk/go/announce"
|
||||
"git.novox.be/novox/mesh-sdk/go/buscompose"
|
||||
"git.novox.be/novox/mesh-sdk/go/catalogue"
|
||||
)
|
||||
|
||||
// **The accounts the mesh wants, said to the vault** (novox/hq ADR 0561 §2, issue 774). The bus composer derives every
|
||||
// user from placement, the manifests and the wants; for each account the vault mints (a module's, a process's, a
|
||||
// machine's provisioner client's), the part writes one key of its state `accounts`, keyed by the bus user, with the
|
||||
// fields the controller's credential carries beside the password. A key is written only when it differs, and deleted
|
||||
// only when placement no longer names its user and every input of the pass was read: a refused read never retires an
|
||||
// account. The vault alone reads the state (buscompose.NamedReaders); it mints each user it holds no value for.
|
||||
|
||||
// StateAccounts is the part's state of wanted accounts.
|
||||
const StateAccounts = buscompose.AccountsState
|
||||
|
||||
// AccountsReaders are the only modules that may read StateAccounts.
|
||||
var AccountsReaders = buscompose.NamedReaders[buscompose.GrantsAccounts]
|
||||
|
||||
// Announced is what the part says it keeps: every state readable by any reader, but the wanted accounts, by the vault
|
||||
// alone (ADR 0495 §2-§3, ADR 0561 §2).
|
||||
func Announced() []announce.State {
|
||||
var out []announce.State
|
||||
for _, s := range States {
|
||||
if s == StateAccounts {
|
||||
out = append(out, announce.State{Name: s, Readers: slices.Clone(AccountsReaders)})
|
||||
continue
|
||||
}
|
||||
out = append(out, announce.State{Name: s, Readable: true})
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// BusAddress is where every node on the private network reaches the bus, and its certificate's fingerprint: the part's
|
||||
// own credential says both, since the controller gave every node's account the bus holder's overlay name (its
|
||||
// brokerReachableAt).
|
||||
type BusAddress struct {
|
||||
URL string
|
||||
Fingerprint string
|
||||
}
|
||||
|
||||
// WantedAccounts is every account the vault mints that the records compose, keyed by user, with the bus's address. A
|
||||
// node, the controller, an enrolment user or a person is never one (ADR 0561 §4, §5). Pure.
|
||||
func WantedAccounts(in Inputs, recs buscompose.Records, bus BusAddress) (map[string]buscompose.WantedAccount, error) {
|
||||
principals, err := buscompose.Users(recs)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out := map[string]buscompose.WantedAccount{}
|
||||
for _, p := range principals {
|
||||
w := buscompose.WantedAccount{User: p.Username(), Kind: p.Kind, Node: p.Node, Module: p.Module, Process: p.Process,
|
||||
URL: bus.URL, Fingerprint: bus.Fingerprint}
|
||||
switch p.Kind {
|
||||
case buscompose.KindModule, buscompose.KindNodeTools:
|
||||
if m, ok := manifestOn(in, p.Node, p.Module); ok {
|
||||
w.Claims = ClaimsOf(m)
|
||||
}
|
||||
case buscompose.KindProcess, buscompose.KindProvisioner:
|
||||
default:
|
||||
continue
|
||||
}
|
||||
out[w.User] = w
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// manifestOn is the manifest a node runs of module: the build its want names, else the trunk's.
|
||||
func manifestOn(in Inputs, node, module string) (catalogue.Manifest, bool) {
|
||||
if m, ok := in.Running[node+"."+module]; ok {
|
||||
return m, true
|
||||
}
|
||||
m, ok := in.Manifests[module]
|
||||
return m, ok
|
||||
}
|
||||
|
||||
// ClaimsOf is a module's claims as its credential carries them: the controller's claimsFor, over the mesh's own seats
|
||||
// (its seat table is seeded from them): each claim's seat and scope, and the verbs the runtime serves for it, the claim's
|
||||
// own or every verb the seat promises; a seat the mesh does not define keeps the claim's scope and no verbs.
|
||||
func ClaimsOf(m catalogue.Manifest) []buscompose.ClaimedSeat {
|
||||
if len(m.Claims) == 0 {
|
||||
return nil
|
||||
}
|
||||
byName := map[string]catalogue.Seat{}
|
||||
for _, s := range catalogue.DefaultSeats() {
|
||||
byName[s.Name] = s
|
||||
}
|
||||
var out []buscompose.ClaimedSeat
|
||||
for _, c := range m.Claims {
|
||||
claimed := buscompose.ClaimedSeat{Seat: c.Name, Scope: c.At()}
|
||||
if s, known := byName[c.Name]; known {
|
||||
claimed.Scope = s.Scope
|
||||
claimed.Serves = c.ServesFor(catalogue.Manifest{Tools: catalogue.VerbNames(s.Serves)})
|
||||
}
|
||||
out = append(out, claimed)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// Accounts answers the tool accounts (ADR 0561 §9): the wanted accounts as the part last wrote them, one user's or all,
|
||||
// each with when it was written; and what the last pass did to them. Never a password: the state holds none.
|
||||
func (p *Part) Accounts(user string) (any, error) {
|
||||
users := []string{user}
|
||||
if user == "" {
|
||||
keys, err := p.Store.Keys(StateAccounts)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("its own %s cannot be listed: %w", StateAccounts, err)
|
||||
}
|
||||
users = keys
|
||||
}
|
||||
out := []buscompose.WantedAccount{}
|
||||
for _, u := range users {
|
||||
raw, _, found, err := p.Store.Get(StateAccounts, u)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("its own %s %s cannot be read: %w", StateAccounts, u, err)
|
||||
}
|
||||
if !found {
|
||||
if user != "" {
|
||||
return nil, fmt.Errorf("no account %s is wanted: placement composes no module, process or provisioner client of that user", user)
|
||||
}
|
||||
continue
|
||||
}
|
||||
w, err := buscompose.ReadWantedAccount(u, raw)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out = append(out, w)
|
||||
}
|
||||
answer := map[string]any{"accounts": out, "count": len(out), "read_by": AccountsReaders}
|
||||
if raw, _, found, err := p.Store.Get(StateUserList, KeyPass); err == nil && found {
|
||||
var res Result
|
||||
if json.Unmarshal(raw, &res) == nil && res.Accounts != nil {
|
||||
answer["last_pass"] = res.Accounts
|
||||
}
|
||||
}
|
||||
return answer, nil
|
||||
}
|
||||
|
||||
// AccountsWritten is what one pass did to the state of wanted accounts.
|
||||
type AccountsWritten struct {
|
||||
Wanted int `json:"wanted"`
|
||||
Written []string `json:"written,omitempty"`
|
||||
Deleted []string `json:"deleted,omitempty"`
|
||||
// Kept are keys placement no longer names that this pass did not delete, with why.
|
||||
Kept []string `json:"kept,omitempty"`
|
||||
}
|
||||
|
||||
// writeAccounts writes each wanted account whose key differs, and deletes the key of a user no longer placed, but only
|
||||
// when every input of the pass was read.
|
||||
func (p *Part) writeAccounts(res *Result, in Inputs, recs buscompose.Records) {
|
||||
if p.Bus.URL == "" {
|
||||
res.Errors = append(res.Errors, "the wanted accounts are not written: the part knows no bus address to give them (its own credential names none)")
|
||||
return
|
||||
}
|
||||
wanted, err := WantedAccounts(in, recs, p.Bus)
|
||||
if err != nil {
|
||||
res.Errors = append(res.Errors, "the wanted accounts could not be composed: "+err.Error())
|
||||
return
|
||||
}
|
||||
done := &AccountsWritten{Wanted: len(wanted)}
|
||||
res.Accounts = done
|
||||
for _, user := range sortedKeys(wanted) {
|
||||
w := wanted[user]
|
||||
if raw, _, found, err := p.Store.Get(StateAccounts, user); err != nil {
|
||||
res.Errors = append(res.Errors, fmt.Sprintf("its own %s %s cannot be read: %v", StateAccounts, user, err))
|
||||
continue
|
||||
} else if found {
|
||||
if was, err := buscompose.ReadWantedAccount(user, raw); err == nil && was.Same(w) {
|
||||
continue
|
||||
}
|
||||
}
|
||||
w.At = p.now()
|
||||
data, _ := json.Marshal(w)
|
||||
if _, err := p.Store.Put(StateAccounts, user, data); err != nil {
|
||||
res.Errors = append(res.Errors, fmt.Sprintf("its own %s %s cannot be written: %v", StateAccounts, user, err))
|
||||
continue
|
||||
}
|
||||
done.Written = append(done.Written, user)
|
||||
}
|
||||
held, err := p.Store.Keys(StateAccounts)
|
||||
if err != nil {
|
||||
res.Errors = append(res.Errors, fmt.Sprintf("its own %s cannot be listed, so no key of a user gone is deleted: %v", StateAccounts, err))
|
||||
return
|
||||
}
|
||||
failed := in.ReadFailures()
|
||||
placed := Placed(in)
|
||||
for _, user := range held {
|
||||
if _, still := wanted[user]; still {
|
||||
continue
|
||||
}
|
||||
switch {
|
||||
case placed(user):
|
||||
done.Kept = append(done.Kept, user+": placement still names it (composed with no account this pass)")
|
||||
continue
|
||||
case len(failed) > 0:
|
||||
done.Kept = append(done.Kept, user+": a read of this pass failed, so no account is retired")
|
||||
continue
|
||||
}
|
||||
if err := p.Store.Delete(StateAccounts, user); err != nil {
|
||||
res.Errors = append(res.Errors, fmt.Sprintf("its own %s %s cannot be deleted: %v", StateAccounts, user, err))
|
||||
continue
|
||||
}
|
||||
done.Deleted = append(done.Deleted, user)
|
||||
}
|
||||
sort.Strings(done.Kept)
|
||||
}
|
||||
@@ -31,7 +31,7 @@ const (
|
||||
)
|
||||
|
||||
// States is every state the part keeps.
|
||||
var States = []string{StateAssertions, StateMemberships, StateTakeover, StateUserList}
|
||||
var States = []string{StateAccounts, StateAssertions, StateMemberships, StateTakeover, StateUserList}
|
||||
|
||||
// The events the part says.
|
||||
const (
|
||||
@@ -48,6 +48,9 @@ var Emits = []string{EventAsserted, EventMembership, EventUserList}
|
||||
type Store interface {
|
||||
Get(state, key string) ([]byte, uint64, bool, error)
|
||||
Put(state, key string, value []byte) (uint64, error)
|
||||
// Keys and Delete are for the wanted accounts alone (accounts.go): every other state is only put.
|
||||
Keys(state string) ([]string, error)
|
||||
Delete(state, key string) error
|
||||
}
|
||||
|
||||
// Emitter says one of the part's events.
|
||||
@@ -104,9 +107,11 @@ type Result struct {
|
||||
// free, <why>`: a verified sender is composed as from no root-free machine there (issue 1945).
|
||||
RootNotFree []string `json:"root_not_free,omitempty"`
|
||||
// Sealed is the list sealed to the bus node as the key `user-list.sealed` names it (ADR 0547 clause 7).
|
||||
Sealed *SealedState `json:"sealed,omitempty"`
|
||||
Errors []string `json:"errors,omitempty"`
|
||||
PublishedOrAsserted string `json:"published_or_asserted"`
|
||||
Sealed *SealedState `json:"sealed,omitempty"`
|
||||
// Accounts is what the pass wrote of the wanted accounts the vault mints from (ADR 0561 §2).
|
||||
Accounts *AccountsWritten `json:"accounts,omitempty"`
|
||||
Errors []string `json:"errors,omitempty"`
|
||||
PublishedOrAsserted string `json:"published_or_asserted"`
|
||||
}
|
||||
|
||||
// NothingTakesEffect is what every composition says it did on the bus beyond its own state and the memberships.
|
||||
@@ -129,6 +134,9 @@ type Part struct {
|
||||
Logf func(string, ...any)
|
||||
// Objects is the part's object bucket of sealed lists: the process's connection; nil in the tools.
|
||||
Objects Objects
|
||||
// Bus is where the nodes reach the bus, which each wanted account carries (accounts.go): the process's own credential's;
|
||||
// empty in the tools, which write no account.
|
||||
Bus BusAddress
|
||||
// Say says a condition to the operator: the process's; nil in the tools.
|
||||
Say func(asks.Condition) error
|
||||
// Forget takes away the record the asker kept of an ask it failed to publish; Nonce is a test's id suffix.
|
||||
@@ -233,6 +241,10 @@ func (p *Part) Compose() Result {
|
||||
}
|
||||
}
|
||||
}
|
||||
// The accounts the vault mints from, said in the state only it reads (ADR 0561 §2).
|
||||
if p.Store != nil {
|
||||
p.writeAccounts(&res, in, recs)
|
||||
}
|
||||
// The list itself, sealed to the bus node and named in `user-list.sealed` (ADR 0547 clause 7).
|
||||
if list, n, leftOut, err := ComposeList(recs, in.Hashes); err != nil {
|
||||
res.Errors = append(res.Errors, "the user list could not be composed whole: "+err.Error())
|
||||
|
||||
@@ -21,10 +21,12 @@ const (
|
||||
ToolTakeOver = "grants_take_over"
|
||||
ToolTakeOverState = "grants_take_over_state"
|
||||
ToolHandBack = "grants_hand_back"
|
||||
ToolAccounts = "grants_accounts"
|
||||
)
|
||||
|
||||
// Tools is every tool, in the manifest's order.
|
||||
var Tools = []string{ToolGrants, ToolMembership, ToolUserList, ToolComposeNow, ToolAssert, ToolTakeOver, ToolHandBack, ToolTakeOverState}
|
||||
var Tools = []string{ToolGrants, ToolMembership, ToolUserList, ToolComposeNow, ToolAssert, ToolTakeOver, ToolHandBack, ToolTakeOverState,
|
||||
ToolAccounts}
|
||||
|
||||
// WritesNoGrant is what grants says of the part's own grants in this milestone.
|
||||
const WritesNoGrant = "none: the grants part writes no grants.<module>.<fp> before ADR 0547's third milestone (clause 5); the controller's are beside"
|
||||
|
||||
@@ -40,9 +40,14 @@
|
||||
"grants_assert",
|
||||
"grants_take_over",
|
||||
"grants_take_over_state",
|
||||
"grants_hand_back"
|
||||
"grants_hand_back",
|
||||
"grants_accounts"
|
||||
],
|
||||
"state": [
|
||||
{
|
||||
"name": "accounts",
|
||||
"history": 5
|
||||
},
|
||||
{
|
||||
"name": "assertions",
|
||||
"history": 5
|
||||
|
||||
Reference in New Issue
Block a user