diff --git a/modules/mesh-vault/README.md b/modules/mesh-vault/README.md index dad246e7a..27af4c7a5 100644 --- a/modules/mesh-vault/README.md +++ b/modules/mesh-vault/README.md @@ -136,6 +136,21 @@ the declaration sealed by the vault to their machine, which the controller carri machine's sealing key as its own node-engine stated it in its own name, in `move-in`'s first step (`sealing_key`). A machine whose engine stated none, any other key, any other module and any other name are refused and recorded. +**A licence's two secrets, for the licence manager and the machines its bindings name** (design 39 bus-and-switch +§15 to §17, B2, B3, B8, B21, B25, B31, B32, B42; `licences.go`, `licencebindings.go`). The licence manager hands +each licence's grant and access token to the vault with `mesh-vault.licence-put`, in two steps: the licence's key, the +part (`grant` or `access`) and the generation, answered with a call and a key made for it; then the call and the value +sealed to that key. The caller's generation is the record's: the newest is kept, an older one refused, and +`secret-changed` says the name `licence..` and its generation, never the value. `get` hands a grant, sealed to +the key it is asked with, with no machine guard (B3); it hands an access token only to a machine on which a consumer's binding +names that licence, by the licence's key as the manager says it. Which licence each consumer is bound to the vault +keeps in its own state `licence-bindings` (one key per consumer: licence, key, generation; deleted on a release), +learned from the manager's `binding.changed`, which it consumes, and at its start from its first view, +`anthropic-licence-manager.bindings`, asked until it answers. A first view never replaces a binding of a newer +generation, and an access token asked before the first view answered is refused, saying why. The vault never reads +the manager's state. `vault_first_views` shows this first view beside the assignments', and `vault_first_view_again` +asks one again (`of`: `mesh-delivery-assigner.assignments` or `claude-licence-manager.bindings`; absent, both). + ## Taking values in: `secret-ask`, `issued`, `rotate` Design 57's V2 (issue 675; ADR 0349 clauses 8 to 10, ADR 0277 §22–§27 and ADR 0305 with the vault in the diff --git a/modules/mesh-vault/cmd/mesh-vault/assignmentsview.go b/modules/mesh-vault/cmd/mesh-vault/assignmentsview.go index 644029f0f..23cf68f31 100644 --- a/modules/mesh-vault/cmd/mesh-vault/assignmentsview.go +++ b/modules/mesh-vault/cmd/mesh-vault/assignmentsview.go @@ -168,25 +168,48 @@ func leaverKey(key string) (string, bool) { return node, ok && node != "" } -// viewTools are the read tool of the first view and its re-read, as every reader has them (design 25 § 12.2). +// firstViews are the vault's first views begun, by the owner's state each follows (`.`): the assigner's +// assignments and the licence manager's bindings (design 39 bus-and-switch B21, B42). +func firstViews() ([]string, map[string]*views.Follower) { + all := map[string]*views.Follower{} + if assignmentsView != nil { + all[assignerModule+".assignments"] = assignmentsView + } + if licenceBindingsView != nil { + all["claude-licence-manager.bindings"] = licenceBindingsView + } + names := make([]string, 0, len(all)) + for n := range all { + names = append(names, n) + } + sort.Strings(names) + return names, all +} + +// viewTools are the read tool of the first views and their re-read, as every reader has them (design 25 § 12.2; design +// 39 bus-and-switch B21, B42). func viewTools(env *Env) []toolDef { return []toolDef{ {Name: FirstViewsTool, - Description: "The vault's first view of the assigner's assignments: the tool it asks, the revision its picture holds, when it " + - "asked and was answered, how many asks (and by hand), and whether it is waiting.", + Description: "The vault's first views, of the assigner's assignments and of the licence manager's bindings: for each, the tool " + + "it asks, the revision its picture holds, when it asked and was answered, how many asks (and by hand), and whether it is waiting.", run: func(string, map[string]any) (any, error) { - if assignmentsView == nil { - return nil, fmt.Errorf("the vault reads the assigner's assignments only when it runs as the module") + names, all := firstViews() + if len(names) == 0 { + return nil, fmt.Errorf("the vault keeps its first views only when it runs as the module") } - return map[string]any{"first-views": []views.Status{assignmentsView.Status()}}, nil + out := []views.Status{} + for _, n := range names { + out = append(out, all[n].Status()) + } + return map[string]any{"first-views": out}, nil }}, {Name: FirstViewAgainTool, - Description: "Ask the first view of the assigner's assignments once more by hand, with why, recorded in the vault's log.", - Input: map[string]any{"why": str("why the first view is asked again")}, + Description: "Ask a first view once more by hand, with why, recorded in the vault's log: of names it by its owner's state " + + "(mesh-delivery-assigner.assignments or claude-licence-manager.bindings); absent, both are asked again.", + Input: map[string]any{"why": str("why the first view is asked again"), + "of": str("the first view, by its owner's state: mesh-delivery-assigner.assignments or claude-licence-manager.bindings; absent for both")}, run: func(caller string, a map[string]any) (any, error) { - if assignmentsView == nil { - return nil, fmt.Errorf("the vault reads the assigner's assignments only when it runs as the module") - } why := strings.TrimSpace(arg(a, "why")) if why == "" { return nil, fmt.Errorf("say why: a first view asked by hand keeps its why") @@ -194,8 +217,23 @@ func viewTools(env *Env) []toolDef { if err := fromAGrantedCaller(caller, FirstViewAgainTool); err != nil { return nil, err } - say("the first view of the assigner's assignments is asked again by %s: %s", caller, why) - return map[string]any{"asked-again": true}, assignmentsView.Again(context.Background(), why) + names, all := firstViews() + if of := strings.TrimSpace(arg(a, "of")); of != "" { + if all[of] == nil { + return nil, fmt.Errorf("the vault keeps no first view of %q here; it keeps %v", of, names) + } + names = []string{of} + } + if len(names) == 0 { + return nil, fmt.Errorf("the vault keeps its first views only when it runs as the module") + } + for _, n := range names { + say("the first view of %s is asked again by %s: %s", n, caller, why) + if err := all[n].Again(context.Background(), why); err != nil { + return nil, err + } + } + return map[string]any{"asked-again": true}, nil }}, } } diff --git a/modules/mesh-vault/cmd/mesh-vault/licencebindings_test.go b/modules/mesh-vault/cmd/mesh-vault/licencebindings_test.go index 3f7d23a9f..a9858b11b 100644 --- a/modules/mesh-vault/cmd/mesh-vault/licencebindings_test.go +++ b/modules/mesh-vault/cmd/mesh-vault/licencebindings_test.go @@ -1,12 +1,15 @@ package main import ( + "context" "encoding/json" "errors" "os" "slices" "sort" "testing" + + "git.novox.be/novox/mesh-sdk/go/views" ) // memLicenceBindings is the state licence-bindings in memory. @@ -113,3 +116,52 @@ func TestTheFirstViewOfTheLicenceBindingsIsKept(t *testing.T) { t.Fatalf("the manifest invokes %v, consumes %v, keeps %s", m.Invokes, m.Consumes, m.State) } } + +// design 39 bus-and-switch B21, B42: vault_first_views lists the licence bindings' first view beside the assignments'; +// vault_first_view_again asks the one its of names, and both when of is absent; an of it keeps no view of is refused. +func TestTheFirstViewToolsCoverTheLicenceBindings(t *testing.T) { + follower := func(owner, state string) *views.Follower { + return &views.Follower{Owner: owner, Tool: owner + ".read", State: state, + Ask: func(context.Context, string) (views.Answer, error) { + return views.Answer{Owner: owner, State: state, Values: map[string]json.RawMessage{}}, nil + }} + } + keptA, keptL := assignmentsView, licenceBindingsView + t.Cleanup(func() { assignmentsView, licenceBindingsView = keptA, keptL }) + assignmentsView, licenceBindingsView = follower(assignerModule, "assignments"), follower("claude-licence-manager", "bindings") + run := map[string]toolDef{} + for _, d := range viewTools(nil) { + run[d.Name] = d + } + listed, err := run[FirstViewsTool].run("person.operator", nil) + if err != nil { + t.Fatal(err) + } + var states []string + for _, s := range listed.(map[string]any)["first-views"].([]views.Status) { + states = append(states, s.Owner+"."+s.State) + } + if !slices.Equal(states, []string{"claude-licence-manager.bindings", "mesh-delivery-assigner.assignments"}) { + t.Errorf("vault_first_views lists %v", states) + } + byHand := func() (int, int) { return assignmentsView.Status().ByHand, licenceBindingsView.Status().ByHand } + again := run[FirstViewAgainTool].run + for _, c := range []struct { + of string + assignments, bindings int + refused bool + }{ + {of: "claude-licence-manager.bindings", bindings: 1}, + {of: "", assignments: 1, bindings: 2}, + {of: "mesh-delivery-assigner.assignments", assignments: 2, bindings: 2}, + {of: "claude-licence-manager.licences", assignments: 2, bindings: 2, refused: true}, + } { + _, err := again("person.operator", map[string]any{"why": "a test", "of": c.of}) + if (err != nil) != c.refused { + t.Errorf("of %q: %v", c.of, err) + } + if a, b := byHand(); a != c.assignments || b != c.bindings { + t.Errorf("of %q: asked by hand %d and %d times, want %d and %d", c.of, a, b, c.assignments, c.bindings) + } + } +}