D1 (enrolment): the vault's signers and the node-engine's have are read at the holders of their seats

Reads in the seat form seat:<seat>.<state> are resolved by the seat list's holder when read; the manifest reads and invokes follow (seat:mesh-module-assigner.assignments for the holder lookup). The vault's module name goes.

Builds: design 47 G §428, A-210, A-128, A-318

Claude-Session: https://claude.ai/code/session_01NcbgfGKaxw984XyAPfLm4h
This commit is contained in:
2026-11-12 23:02:50 +01:00
parent cd9a373067
commit b289c0d361
8 changed files with 80 additions and 23 deletions
@@ -1,8 +1,12 @@
package main
import (
"encoding/json"
"fmt"
"os"
"testing"
"git.novox.be/novox/mesh-sdk/go/seatlist"
)
// The module's unit tests, as two tables (the repository's test budget): the operator's side (issue, the tokens verb, the
@@ -20,6 +24,7 @@ func TestIssueTokensAndTheManifest(t *testing.T) {
{"S54/AC1 tokens answers each stage and relays the sealed token of one node", tokensAnswersEachStageAndRelaysTheSealedTokenOfOneNode},
{"S54/AC2 the manifest says what the code does", theManifestSaysWhatTheCodeDoes},
{"S56 the bus node's loaded list is read from the grants part's pass", theLoadedListIsReadFromTheGrantsPass},
{"A-210 a seat's state is read at the seat's holder", aSeatsStateIsReadAtItsHolder},
{"DLV-M6 the assignments is read from its first view, never from the assigner's state", theAssignmentsIsReadFromItsFirstView},
} {
t.Run(c.name, c.run)
@@ -65,7 +70,7 @@ func theLoadedListIsReadFromTheGrantsPass(t *testing.T) {
// view and views family, ADR 0615 §1), every other state through the runtime; the runtime is never asked for assignments.
func theAssignmentsIsReadFromItsFirstView(t *testing.T) {
pics := memPictures{AssignmentsRead: {"joiner.node": `{"v":1,"node":"joiner","decided":{"address":"10.9.0.11"},"modules":[]}`}}
rest := memRead{AssignmentsRead: {"stale.node": `{"v":1,"node":"stale","modules":[]}`}, "node-engine.have": {}}
rest := memRead{AssignmentsRead: {"stale.node": `{"v":1,"node":"stale","modules":[]}`}, haveRead: {}}
h := &Holder{Read: followedReader{pics: pics, rest: rest}}
p, err := h.assignments()
if err != nil {
@@ -74,7 +79,7 @@ func theAssignmentsIsReadFromItsFirstView(t *testing.T) {
if _, ok := p["joiner"]; !ok || len(p) != 1 {
t.Errorf("assignments = %v, want joiner's alone, from the first view", p)
}
if keys, err := h.Read.Keys("node-engine.have"); err != nil || len(keys) != 0 {
if keys, err := h.Read.Keys(haveRead); err != nil || len(keys) != 0 {
t.Errorf("node-engine.have read as %v, %v: want it through the runtime", keys, err)
}
}
@@ -87,3 +92,23 @@ func (p memPictures) Keys(state string) ([]string, error) {
return memRead(p).Keys(state)
}
func (p memPictures) Get(state, key string) ([]byte, bool, error) { return memRead(p).Get(state, key) }
// A read in the seat form `seat:<seat>.<state>` is a read of the state of the module the seat list names as the seat's holder
// (design 47 G A-210, A-128): the vault's signers and the node-engine's have are asked of the modules that hold their seats, whatever
// those are called, and a state named otherwise is read as it is.
func aSeatsStateIsReadAtItsHolder(t *testing.T) {
call := func(key string, _ any) (json.RawMessage, error) {
if key != seatlist.HolderKey() {
return nil, fmt.Errorf("asked %s", key)
}
return json.RawMessage(`{"seats":[{"seat":"mesh-vault","holders":[{"module":"another-vault"}]},{"seat":"node-engine","holders":[]}]}`), nil
}
for state, want := range map[string]string{signersRead: "another-vault.signers", "mesh-config.resolved": "mesh-config.resolved"} {
if got, err := resolveState(call, state); err != nil || got != want {
t.Errorf("%s is read as %q, %v; want %q", state, got, err, want)
}
}
if got, err := resolveState(call, haveRead); err == nil {
t.Errorf("%s with no holder is read as %q, want an error naming the seat", haveRead, got)
}
}
@@ -231,7 +231,7 @@ func (h *Holder) firstTunnel(assignments map[string]meshmoduleassigner.Assignmen
}
key := ""
if k, err := nodeengineclient.HaveNodeKey(hub.Node); err == nil {
if raw, found, err := h.Read.Get("node-engine.have", k); err == nil && found {
if raw, found, err := h.Read.Get(haveRead, k); err == nil && found {
if hn, err := nodeengineclient.DecodeHaveNode(raw); err == nil && hn.Record != nil {
key = hn.Record.TunnelKey
}
@@ -259,7 +259,7 @@ func (h *Holder) firstTunnel(assignments map[string]meshmoduleassigner.Assignmen
// hasRecord says the node already writes a record (a node of the mesh, ADR 0587 §4: "a node record ... holds it").
func (h *Holder) hasRecord(node string) bool {
keys, err := h.Read.Keys("node-engine.have")
keys, err := h.Read.Keys(haveRead)
if err != nil {
return false
}
@@ -225,7 +225,7 @@ func TestMeshEnrolmentWholeOnARealBus(t *testing.T) {
}
enrolments := mk(Module+"_"+enrolmentsState, true) // the runtime's bucket of the bed's credential's module
// No controller lease bucket at all: the controller ended by its unassignment (issue 1126).
logins, assignments := mk(nodeLoginsBucket, false), mk("mesh-delivery-assigner_assignments", false)
logins, assignments := mk("mesh-vault_node-logins", false), mk("mesh-delivery-assigner_assignments", false)
have := mk("node-engine_have", false)
if _, err := assignments.Put(ctx, "hub.node", []byte(hubAssignments)); err != nil {
t.Fatal(err)
@@ -28,7 +28,7 @@ func issueRefusesNamingEveryFinding(t *testing.T) {
{"a bad name and a long expiry", nil, "Ace.1", 48 * time.Hour, []string{"not a valid node name", "outside zero to 24h0m0s"}},
{"an assignments key and a node record", func(r *rig) {
r.rd["mesh-delivery-assigner.assignments"]["ace.node"] = `{"v":1,"node":"ace","modules":[]}`
r.rd["node-engine.have"]["ace"] = `{}`
r.rd[haveRead]["ace"] = `{}`
}, "ace", 0, []string{"ace has an assignments key already"}},
{"an open enrolment", func(r *rig) { r.seed("ace", "10.9.0.2", StageLive) }, "ace", 0, []string{"an enrolment of ace is open already"}},
{"no tunnel key and no range", func(r *rig) {
@@ -40,7 +40,7 @@ func issueRefusesNamingEveryFinding(t *testing.T) {
}, "ace", 0, []string{"assignments names no hub"}},
{"a hub with no key and no endpoint", func(r *rig) {
r.rd["mesh-delivery-assigner.assignments"]["hub.node"] = strings.Replace(hubAssignments, `"endpoint":"192.0.2.1:51820",`, "", 1)
delete(r.rd["node-engine.have"], "hub.node")
delete(r.rd[haveRead], "hub.node")
}, "ace", 0, []string{"the hub hub has no tunnel key in its record and no endpoint in its assignments"}},
}
for _, c := range cases {
@@ -235,7 +235,7 @@ func newRig(t *testing.T) *rig {
r := &rig{t: t, st: newMem(), vault: &fakeVault{fail: map[string]bool{}}, asker: &fakeAsker{}, now: time.Date(2026, 11, 2, 10, 0, 0, 0, time.UTC), loaded: true}
r.rd = memRead{
"mesh-delivery-assigner.assignments": {"hub.node": hubAssignments},
"node-engine.have": {"hub.node": hubRecord},
haveRead: {"hub.node": hubRecord},
}
r.h = &Holder{Store: r.st, Read: r.rd, Vault: r.vault, Asker: r.asker, AskLasts: time.Hour,
Say: func(event string, body any, id string) error {
@@ -10,12 +10,14 @@ import (
"errors"
"fmt"
"os"
"strings"
"time"
"git.novox.be/novox/mesh-contracts/health-and-repair/go/clients/meshhandactsclient"
mcseat "git.novox.be/novox/mesh-contracts/health-and-repair/go/seats/meshconditions"
occ "git.novox.be/novox/mesh-contracts/operator-and-conversation/go/clients/operatorchannelclient"
stdio "git.novox.be/novox/mesh-sdk/go"
"git.novox.be/novox/mesh-sdk/go/seatlist"
"git.novox.be/novox/mesh-sdk/go/views"
)
@@ -74,13 +76,39 @@ func (runtimeStore) Put(key string, v any, ttl time.Duration) (uint64, error) {
func (runtimeStore) Delete(key string) error { return stdio.State(State).Delete(key) }
func (runtimeStore) Keys() ([]string, error) { return stdio.State(State).Keys() }
// runtimeReader reads another module's state through the runtime.
type runtimeReader struct{}
// runtimeReader reads another module's state through the runtime: a state in the seat form `seat:<seat>.<state>` in the state of
// the module that holds the seat when it is read, as the manifest's `reads` names it (design 47 G A-210, A-128).
type runtimeReader struct{ call Call }
func (runtimeReader) Keys(state string) ([]string, error) { return stdio.State(state).Keys() }
// resolveState is the state a read names, a seat's turned into `<holder>.<state>` by the seat list's holder of the seat; any
// other name is returned as it is.
func resolveState(call Call, state string) (string, error) {
seatState, ok := strings.CutPrefix(state, "seat:")
if !ok {
return state, nil
}
seat, name, _ := strings.Cut(seatState, ".")
module, err := seatlist.HolderOf(seatlist.HolderCaller(call), seat)
if err != nil {
return "", err
}
return module + "." + name, nil
}
func (runtimeReader) Get(state, key string) ([]byte, bool, error) {
e, err := stdio.State(state).Get(key)
func (r runtimeReader) Keys(state string) ([]string, error) {
name, err := resolveState(r.call, state)
if err != nil {
return nil, err
}
return stdio.State(name).Keys()
}
func (r runtimeReader) Get(state, key string) ([]byte, bool, error) {
name, err := resolveState(r.call, state)
if err != nil {
return nil, false, err
}
e, err := stdio.State(name).Get(key)
if err != nil || e == nil {
return nil, false, err
}
@@ -159,7 +187,7 @@ func SeatEventSubject(event string) string { return "mesh.seat." + Seat + ".even
func newHolder(v *views.Reader) *Holder {
call := Call(stdio.Ask)
rec := meshhandactsclient.Recorder{Ask: stdio.Ask}
rd := followedReader{pics: v, rest: runtimeReader{}}
rd := followedReader{pics: v, rest: runtimeReader{call: call}}
client := occ.New("asked")
return &Holder{
Store: runtimeStore{}, Read: rd, Vault: vaultOver{call: call, read: rd}, Asker: client,
@@ -1,22 +1,25 @@
package main
import "git.novox.be/novox/mesh-contracts/identity-and-access/go/seats/meshnodeenroller"
import (
"git.novox.be/novox/mesh-contracts/identity-and-access/go/seats/meshnodeenroller"
mv "git.novox.be/novox/mesh-contracts/identity-and-access/go/seats/meshvault"
)
// The holders, states and buckets the enrolment names beside its own, taken from its manifest (module.json: its module, its
// `reads`, its state, the seats it invokes); a unit row checks them (TestTheNamesAreTheManifests). No contract names a
// holder (novox/hq ADR 0625 §4, ADR 0634 §1).
const (
vaultModule = "mesh-vault"
assignerModule = "mesh-delivery-assigner"
// enrolmentsState is the holder's state of open enrolments; its bucket is the runtime's, of the credential's module
// (design 47 G A-296), never named here.
enrolmentsState = meshnodeenroller.StateEnrolments
// signersRead is the vault's state signers as the enrolment's manifest reads it, signersKey the key holding its signing key.
signersRead = vaultModule + ".signers"
// signersRead is the seat mesh-vault's state signers as the enrolment's manifest reads it (`seat:<seat>.<state>`, resolved at
// the seat's holder when it is read, design 47 G A-210, A-128), signersKey the key holding its signing key; haveRead is the
// seat node-engine's state have the same way.
signersRead = "seat:" + mv.Seat + ".signers"
signersKey = "key"
// nodeLoginsBucket is the vault's state node-logins (the holder reads it through the grants part, not directly).
nodeLoginsBucket = vaultModule + "_node-logins"
haveRead = "seat:node-engine.have"
// assignmentsState is the assigner's state assignments, which the enrolment reads (reads: through its first view).
assignmentsState = "assignments"
)
+4 -3
View File
@@ -34,12 +34,13 @@
"seat:mesh-vault.enrolment-token",
"seat:mesh-vault.node-account",
"seat:mesh-vault.sign",
"mesh-delivery-assigner.assigner_read_assignments"
"mesh-delivery-assigner.assigner_read_assignments",
"seat:mesh-module-assigner.assignments"
],
"reads": [
"mesh-grants.user-list",
"mesh-vault.signers",
"node-engine.have"
"seat:mesh-vault.signers",
"seat:node-engine.have"
],
"state": [
{