V-3 fix: no access token before the first view is answered, and a first view keeps a newer event's key (issue 1409)

Review round 1, finding 1, and the lead's answer B43. Until the vault's first
view of the licence bindings is answered since its start, licence-put's
access token is refused, saying why. A first view removes a consumer's key it
does not name only when that key was written before the view was asked. The
newer generation's binding staying over a first view (B32) was built in V-2's
keep and is named here.

Builds: design 39 bus-and-switch §17, B31, B32, B43

Claude-Session: https://claude.ai/code/session_01NcbgfHb9d4T9nq5Q6WqdiJ
This commit is contained in:
2026-11-12 01:00:42 +01:00
parent 9cf5c1ddbd
commit 36d9d73984
3 changed files with 72 additions and 8 deletions
@@ -45,6 +45,12 @@ type LicenceBindings struct {
Delete func(consumer string) error
Keys func() ([]string, error)
Log func(string, ...any)
// WrittenAt is when the bus wrote a consumer's key, so a first view keeps a key an event wrote after it was asked
// (B43); nil writes no time, and every key a first view does not name goes.
WrittenAt func(consumer string) (time.Time, error)
// Answered is whether the first view of the bindings has been answered since the vault started; until it has, an
// access token is refused, saying why (B31). Nil is a vault that keeps no first view (its tests).
Answered func() bool
}
// busLicenceBindings is the state on the bus.
@@ -63,6 +69,14 @@ func busLicenceBindings() LicenceBindings {
Delete: func(consumer string) error { return st.Delete(consumer) },
Keys: st.Keys,
Log: say,
WrittenAt: func(consumer string) (time.Time, error) {
e, err := st.Get(consumer)
if err != nil || e == nil {
return time.Time{}, err
}
return e.At, nil
},
Answered: func() bool { return licenceBindingsView != nil && !licenceBindingsView.Status().Answered.IsZero() },
}
}
@@ -111,9 +125,10 @@ func (lb LicenceBindings) Heard(body []byte) error {
return lb.keep(e.Consumer, LicenceBinding{Licence: e.Licence, Key: e.Key, Generation: e.Generation})
}
// FirstView takes the manager's answer to `bindings`, by consumer: every binding it names is kept, and every key the
// state holds for a consumer it does not name is deleted, since that consumer was released.
func (lb LicenceBindings) FirstView(values map[string]json.RawMessage) error {
// FirstView takes the manager's answer to `bindings`, asked at asked, by consumer: every binding it names is kept, and
// every key the state holds for a consumer it does not name is deleted, since that consumer was released, unless an
// event wrote that key after the view was asked (B43): the view does not know that move yet.
func (lb LicenceBindings) FirstView(values map[string]json.RawMessage, asked time.Time) error {
var errs []error
for consumer, raw := range values {
var b LicenceBinding
@@ -128,9 +143,20 @@ func (lb LicenceBindings) FirstView(values map[string]json.RawMessage) error {
return errors.Join(append(errs, err)...)
}
for _, consumer := range held {
if _, named := values[consumer]; !named {
errs = append(errs, lb.Delete(consumer))
if _, named := values[consumer]; named {
continue
}
if lb.WrittenAt != nil && !asked.IsZero() {
at, err := lb.WrittenAt(consumer)
if err != nil {
errs = append(errs, err)
continue
}
if at.After(asked) {
continue // written by an event after the view was asked (B43)
}
}
errs = append(errs, lb.Delete(consumer))
}
return errors.Join(errs...)
}
@@ -192,7 +218,7 @@ func newLicenceBindingsView(ask func(subject string, body any) (json.RawMessage,
return
}
values := f.Values()
if err := lb.FirstView(values); err != nil {
if err := lb.FirstView(values, s.Asked); err != nil {
say("the licence bindings of the first view are not all kept: %v", err)
return
}
@@ -256,6 +282,9 @@ func (v *Vault) licenceRefusal(caller, name string) (bool, string) {
if v.LicenceBindings == nil {
return true, "the vault keeps no licence bindings, so no binding names the licence " + key
}
if v.LicenceBindings.Answered != nil && !v.LicenceBindings.Answered() {
return true, "the vault's first view of the licence bindings has not been answered since it started, so it does not yet know which machine a binding names; it asks the licence manager until it answers (B31)"
}
consumers, err := v.LicenceBindings.Keys()
if err != nil {
return true, "the licence bindings cannot be read: " + err.Error()
@@ -8,6 +8,7 @@ import (
"slices"
"sort"
"testing"
"time"
"git.novox.be/novox/mesh-sdk/go/views"
)
@@ -85,7 +86,7 @@ func TestTheFirstViewOfTheLicenceBindingsIsKept(t *testing.T) {
if err != nil {
t.Fatal(err)
}
if err := lb.FirstView(a.Values); err != nil {
if err := lb.FirstView(a.Values, time.Time{}); err != nil {
t.Fatal(err)
}
want := map[string]LicenceBinding{"laptop": {Licence: "acct-1@example.org", Key: "acct-1_at_example.org", Generation: 1013},
@@ -98,10 +99,28 @@ func TestTheFirstViewOfTheLicenceBindingsIsKept(t *testing.T) {
}
lb.Put = func(string, LicenceBinding) error { return errors.New("the bucket is full") }
held["laptop"] = LicenceBinding{Licence: "api", Key: "api", Generation: 1}
if err := lb.FirstView(a.Values); err == nil {
if err := lb.FirstView(a.Values, time.Time{}); err == nil {
t.Fatal("a first view not kept was not said")
}
// B43: a key an event wrote after the view was asked is kept though the view does not name it; one written before
// the ask and not named goes.
lb, held = memLicenceBindings()
asked := time.Date(2026, 11, 12, 1, 0, 0, 0, time.UTC)
written := map[string]time.Time{"late": asked.Add(time.Second), "gone": asked.Add(-time.Second)}
lb.WrittenAt = func(c string) (time.Time, error) { return written[c], nil }
held["late"] = LicenceBinding{Licence: "api", Key: "api", Generation: 2000}
held["gone"] = LicenceBinding{Licence: "api", Key: "api", Generation: 9}
if err := lb.FirstView(a.Values, asked); err != nil {
t.Fatal(err)
}
if _, ok := held["late"]; !ok {
t.Error("a key an event wrote after the first view was asked was removed by it")
}
if _, ok := held["gone"]; ok {
t.Error("a key written before the first view was asked, which it does not name, was kept")
}
manifest, _ := os.ReadFile("../../module.json")
var m struct {
Invokes, Consumes []string
@@ -73,6 +73,22 @@ func TestATokenIsGivenOnlyToAMachineABindingNames(t *testing.T) {
}
}
access := "licence.jane_at_example.org.access"
// B31: until the first view of the bindings is answered, an access token is refused, saying why.
answered := false
lb.Answered = func() bool { return answered }
if err := lb.Heard([]byte(`{"consumer":"g14","licence":"jane","key":"jane_at_example.org","generation":1}`)); err != nil {
t.Fatal(err)
}
if _, _, err := asks(v, "g14.node-tools", access); err == nil || !strings.Contains(err.Error(), "first view of the licence bindings has not been answered") {
t.Fatalf("a token was handed before the first view was answered: %v", err)
}
answered = true
if _, _, err := asks(v, "g14.node-tools", access); err != nil {
t.Fatalf("once the first view is answered the bound machine is refused: %v", err)
}
if err := lb.Heard([]byte(`{"consumer":"g14","licence":"","key":"","generation":2}`)); err != nil {
t.Fatal(err)
}
if _, _, err := asks(v, "g14.node-tools", access); err == nil || !strings.Contains(err.Error(), "no consumer on g14") {
t.Fatalf("a machine no binding names was handed the token: %v", err)
}