mesh-grants: compose the user list again when the vault says account-issued, so a minted account reaches the bus within one pass (issue 774, ADR 0561 §1)

This commit is contained in:
2026-10-28 21:54:35 +01:00
parent 71aa3e86c6
commit 3c6fb7fc9e
5 changed files with 54 additions and 8 deletions
+3 -2
View File
@@ -134,8 +134,9 @@ func run(ctx context.Context, getenv func(string) string, logOut io.Writer) erro
})
}
// The controller makes this module's durable consumer on EVENTS from the manifest's consumes (filtered on the two
// condition subjects); it is pulled and every message acknowledged, so its pending never grows. Where it is not there
// yet the pass says so and hears again at the next change.
// condition subjects and the vault's account-issued; ADR 0561 §6 gives that to the part from the lease write); it is
// pulled and every message acknowledged, so its pending never grows. Where it is not there yet the pass says so and
// hears again at the next change. The vault's bus-accounts is watched beside it, so a hash is composed either way.
f.Hear = func(handle func(string, []byte)) error {
cons, err := js.Consumer(ctx, "EVENTS", account.Node+"_"+account.Module)
if err != nil {
+35
View File
@@ -1909,6 +1909,41 @@ func TestGrants(t *testing.T) {
t.Errorf("a part with no bus address wrote %+v: %v", res.Accounts, res.Errors)
}
})
// 774/AC5 (novox/hq ADR 0561 §1): the vault's account-issued wakes a pass, so the next list carries the new user's hash
// within one pass; an event of another kind does not.
t.Run("the vault's account-issued composes the list again", func(t *testing.T) {
b := fixture()
var handle func(string, []byte)
p := partOn(b)
f := &grants.Follower{Part: p, Watch: &fakeWatcher{opened: make(chan string, 16)}, Logf: func(string, ...any) {},
Hear: func(h func(string, []byte)) error { handle = h; return nil }}
ctx, cancel := context.WithCancel(context.Background())
done := make(chan struct{})
go func() { f.Run(ctx); close(done) }()
defer func() { cancel(); <-done }()
waitFor(t, func() bool { return b.count(grants.CallUsers) > 0 && handle != nil })
held := func() bool {
for _, c := range p.Last().UserList.Credentials.Users {
if c.User == "a.mod-x" {
return c.HashHeld
}
}
return false
}
if held() {
t.Fatal("a.mod-x has a hash before the vault made one")
}
b.mu.Lock()
b.states[grants.ReadAccounts] = map[string][]byte{"a.mod-x": []byte(`{"user":"a.mod-x","hash":"$2a$10$x","generation":1}`)}
b.mu.Unlock()
handle(buscompose.AccountIssuedSubject, []byte(`{"user":"a.mod-x","kind":"module","node":"a","module":"mod-x","generation":1}`))
waitFor(t, held)
if !slices.Contains(grants.Consumes, grants.EventAccountIssued) || !grants.IsAccountIssued(grants.EventAccountIssued) ||
grants.IsAccountIssued("mesh.mod.mesh-vault.event.secret-changed") {
t.Errorf("the part hears %v", grants.Consumes)
}
})
}
type fakeAsker struct {
@@ -28,9 +28,9 @@ type Follower struct {
Logf func(string, ...any)
// Asked watches the part's own ask to compose now (a tool's compose-now), waking at each; nil watches none.
Asked func(wake func()) error
// Hear hears the controller seat's condition events (Consumes) and hands each, by event name, with its body, to
// handle; nil hears none. A condition of the kind root-not-free raised or cleared wakes a pass: a machine's
// root-free word changed.
// Hear hears the controller seat's condition events and the vault's account-issued (Consumes) and hands each, by event
// name, with its body, to handle; nil hears none. A condition of the kind root-not-free raised or cleared wakes a pass:
// a machine's root-free word changed; and so does an account the vault minted (ADR 0561 §1), whose hash the list carries.
Hear func(handle func(event string, body []byte)) error
wake chan struct{}
@@ -124,7 +124,7 @@ func (f *Follower) open() {
}
if f.Hear != nil && !f.heard_ {
err := f.Hear(func(event string, body []byte) {
if IsRootNotFree(body) {
if IsAccountIssued(event) || IsRootNotFree(body) {
f.Wake()
}
})
@@ -23,8 +23,17 @@ const (
KindRootNotFree = "root-not-free"
)
// EventAccountIssued is the vault's word that it minted an account (novox/hq ADR 0561 §1): its hash is in the vault's
// bus-accounts, so a pass composes the list with it.
const EventAccountIssued = "mesh-vault." + buscompose.AccountIssuedEvent
// Consumes is every event the part hears, as its manifest's consumes name them.
var Consumes = []string{EventConditionRaised, EventConditionCleared}
var Consumes = []string{EventConditionRaised, EventConditionCleared, EventAccountIssued}
// IsAccountIssued says an event heard is the vault's account-issued, by its name or its bus subject.
func IsAccountIssued(event string) bool {
return event == EventAccountIssued || event == buscompose.AccountIssuedSubject
}
// EventSubject is the bus subject of a controller seat event named `mesh-controller.<event>`.
func EventSubject(event string) string {
+2 -1
View File
@@ -16,7 +16,8 @@
],
"consumes": [
"mesh-controller.condition-raised",
"mesh-controller.condition-cleared"
"mesh-controller.condition-cleared",
"mesh-vault.account-issued"
],
"uses": [
"operator-channel"