From 87c6a56b81cb345ea4d110df189d12fcc40ed753 Mon Sep 17 00:00:00 2001 From: jochen Date: Mon, 31 Aug 2026 02:50:38 +0200 Subject: [PATCH] Model access is a provision answered by a record, not a machine MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit novox/hq ADR 0024, gaps 1 and 2. The user's stated requirement, and the first thing here that no machine can answer: a hosted model is on nobody's node and is reached over the public internet, so the rule that refuses two ends sharing no private network must not apply to it. A licence is a named thing and the name is the operator's — *the personal account*, *the organisation's* — because the whole point is saying which one a given consumer uses, and an anonymous credential hanging off a provider cannot be said. Many to many, so deliberately not a claim: two machines sharing an account is ordinary rather than a collision. Gap 2 is the missing verb, *accept*: take a value somebody supplied, seal it to each holder, discard the plaintext. With the consequence stated rather than hidden — a holder recorded after the key was supplied has no key and the mesh cannot make one, so it is refused by name with the remedy, not silently handed an empty file. Refusal is felt, as the record warns: a mesh holding three ways to reach a model refuses every consumer that has not chosen. So the refusal names the candidates and the exact command. Being right is not the same as being usable. Gaps 3 and 4 — a consumer that is not a machine, and switching as a reaction rather than a declaration — remain gaps. Half-building them would put a conditional in the declaration language, which is what ADR 0024 says plainly to avoid. Its own context, with its own store and its own credential: a licence is a different aggregate from anything inventory owns, and it refers to nodes by name because that is what crossing a context boundary may carry. --- cmd/mesh-control/licence.go | 235 +++++++++++++++ cmd/mesh-control/main.go | 101 +++++++ internal/catalogue/declaration.go | 19 +- internal/catalogue/licence_test.go | 150 ++++++++++ internal/catalogue/resolve.go | 83 ++++++ internal/licences/licences.go | 274 ++++++++++++++++++ .../0001-a-licence-is-a-named-thing.sql | 41 +++ 7 files changed, 902 insertions(+), 1 deletion(-) create mode 100644 cmd/mesh-control/licence.go create mode 100644 internal/catalogue/licence_test.go create mode 100644 internal/licences/licences.go create mode 100644 internal/licences/migrations/0001-a-licence-is-a-named-thing.sql diff --git a/cmd/mesh-control/licence.go b/cmd/mesh-control/licence.go new file mode 100644 index 0000000..a23525b --- /dev/null +++ b/cmd/mesh-control/licence.go @@ -0,0 +1,235 @@ +package main + +import ( + "bufio" + "context" + "encoding/json" + "errors" + "flag" + "fmt" + "os" + "strings" +) + +// licenceCommand is everything about model access the mesh holds. +// +// **A licence is a named thing and the name is the operator's** (novox/hq ADR 0024). *The personal +// account*, *the organisation's account* — those are names a person uses, and the mesh has to use +// them too, because the whole point is saying which one a given consumer uses. +func licenceCommand(ctx context.Context, args []string) error { + if len(args) == 0 { + return errors.New("licence add|list|use|release|key|forget") + } + switch args[0] { + case "add": + return licenceAdd(ctx, args[1:]) + case "list": + return licenceList(ctx) + case "use": + return licenceUse(ctx, args[1:], true) + case "release": + return licenceUse(ctx, args[1:], false) + case "key": + return licenceKey(ctx, args[1:]) + case "forget": + return licenceForget(ctx, args[1:]) + } + return fmt.Errorf("licence %q; it is add, list, use, release, key or forget", args[0]) +} + +func licenceAdd(ctx context.Context, args []string) error { + set := flag.NewFlagSet("licence add", flag.ContinueOnError) + // What a consumer must know that is not secret — a base URL, a model name. Never the key. + serves := set.String("serves", "", + "JSON a consumer must know that is not secret, such as a base URL or a model") + positionals, err := parseAround(set, args) + if err != nil { + return err + } + if len(positionals) != 2 { + return errors.New(`licence add [--serves '{"model":"..."}']`) + } + provider, name := positionals[0], positionals[1] + + values := map[string]any{} + if strings.TrimSpace(*serves) != "" { + if err := json.Unmarshal([]byte(*serves), &values); err != nil { + return fmt.Errorf("--serves is not JSON: %w", err) + } + } + + held, err := openLicences(ctx) + if err != nil { + return err + } + defer held.Close() + if err := held.Add(ctx, name, provider, values); err != nil { + return err + } + fmt.Printf("%s (%s) recorded. Nothing uses it yet, and it has no key:\n"+ + " licence use %s \n licence key %s\n", name, provider, name, name) + return nil +} + +func licenceList(ctx context.Context) error { + held, err := openLicences(ctx) + if err != nil { + return err + } + defer held.Close() + + all, err := held.All(ctx) + if err != nil { + return err + } + if len(all) == 0 { + // Said, not printed as nothing: an empty list and a failed read must never look the same. + fmt.Println("this mesh holds no licences") + return nil + } + for _, one := range all { + holders, err := held.HoldersOf(ctx, one.Name) + if err != nil { + return err + } + fmt.Printf("%s (%s)\n", one.Name, one.Provider) + if len(holders) == 0 { + fmt.Printf(" nobody uses it\n") + } + for _, h := range holders { + // Whether it has a key is the question somebody is actually asking, so it is said + // per holder rather than per licence: the key was sealed to the holders that existed + // when it was supplied, and one recorded afterwards has none. + state := "has no key — supply it again with `licence key " + one.Name + "`" + if h.Sealed != "" { + state = "has a key" + } + fmt.Printf(" %s on %s: %s\n", h.Module, h.Node, state) + } + } + return nil +} + +func licenceUse(ctx context.Context, args []string, using bool) error { + verb := "use" + if !using { + verb = "release" + } + if len(args) != 3 { + return fmt.Errorf("licence %s ", verb) + } + name, node, module := args[0], args[1], args[2] + + held, err := openLicences(ctx) + if err != nil { + return err + } + defer held.Close() + + if !using { + if err := held.StopUsing(ctx, name, node, module); err != nil { + return err + } + fmt.Printf("%s on %s no longer uses %s. Its copy of the key goes on the next push\n", + module, node, name) + return nil + } + if err := held.Use(ctx, name, node, module); err != nil { + return err + } + fmt.Printf("%s on %s uses %s.\n", module, node, name) + // The consequence, said now rather than discovered as a machine that resolves and receives + // nothing: the mesh discarded the plaintext, so a holder added after the key was supplied has + // no key and the mesh cannot make one. + sealed, err := held.KeyFor(ctx, name, node, module) + if err != nil { + return err + } + if sealed == "" { + fmt.Printf(" It has no key yet — the mesh discarded the plaintext when it was supplied "+ + "and cannot seal another. Supply it again:\n licence key %s\n", name) + } + return nil +} + +// licenceKey is the *accept* verb novox/hq ADR 0024 names as missing. +// +// Take a value, seal it to each holder, and discard the plaintext. Every other credential the +// mesh handles it generated itself; an API key arrives from a person, and a mesh that kept +// operator-supplied keys readably is the arrangement this project measured and rejected. +func licenceKey(ctx context.Context, args []string) error { + set := flag.NewFlagSet("licence key", flag.ContinueOnError) + // A file rather than an argument, by default. A key on a command line is a key in shell + // history and in every process listing taken while it ran. + from := set.String("file", "", "read the key from a file instead of standard input") + positionals, err := parseAround(set, args) + if err != nil { + return err + } + if len(positionals) != 1 { + return errors.New("licence key [--file ]") + } + name := positionals[0] + + var value string + if *from != "" { + raw, err := os.ReadFile(*from) + if err != nil { + return err + } + value = strings.TrimSpace(string(raw)) + } else { + fmt.Fprintln(os.Stderr, "reading the key from standard input; it is not echoed anywhere") + reader := bufio.NewReader(os.Stdin) + line, err := reader.ReadString('\n') + if err != nil && line == "" { + return fmt.Errorf("nothing was given on standard input: %w", err) + } + value = strings.TrimSpace(line) + } + + held, err := openLicences(ctx) + if err != nil { + return err + } + defer held.Close() + + inv, err := openInventory(ctx) + if err != nil { + return err + } + defer inv.Close() + + sealed, err := held.Accept(ctx, name, value, func(node string) (string, error) { + return inv.SealingKeyOf(ctx, node) + }) + if err != nil { + return err + } + // Not echoed back, ever. What is stored is unreadable by whoever holds it, the mesh included, + // and printing the value here would put the one copy that matters on a terminal. + fmt.Printf("sealed to %d holder(s). The mesh has discarded the key and cannot read it back\n", + sealed) + fmt.Printf(" run `push` to deliver it\n") + return nil +} + +func licenceForget(ctx context.Context, args []string) error { + if len(args) != 1 { + return errors.New("licence forget ") + } + held, err := openLicences(ctx) + if err != nil { + return err + } + defer held.Close() + if err := held.Forget(ctx, args[0]); err != nil { + return err + } + // Said plainly, because the mesh cannot do it and pretending otherwise is worse than useless: + // a licence outliving its holder is a live credential nobody is watching. + fmt.Printf("%s is forgotten, and every record of who held it with it.\n"+ + " The key itself is not the mesh's to revoke — do that where the licence was bought\n", + args[0]) + return nil +} diff --git a/cmd/mesh-control/main.go b/cmd/mesh-control/main.go index 9abe1e1..08d1117 100644 --- a/cmd/mesh-control/main.go +++ b/cmd/mesh-control/main.go @@ -25,6 +25,7 @@ import ( "github.com/novox/mesh-control/internal/catalogue" "github.com/novox/mesh-control/internal/identity" "github.com/novox/mesh-control/internal/inventory" + "github.com/novox/mesh-control/internal/licences" "github.com/novox/mesh-control/internal/link" "github.com/novox/mesh-control/internal/overlay" "github.com/novox/mesh-control/internal/store" @@ -45,6 +46,7 @@ var held = []struct { }{ {inventory.Name, inventory.Migrations}, {identity.Name, identity.Migrations}, + {licences.Name, licences.Migrations}, } func main() { @@ -69,6 +71,8 @@ func run() error { return buildCommand(ctx, args[1:]) case "builder": return builderCommand(ctx, args[1:]) + case "licence": + return licenceCommand(ctx, args[1:]) case "rotate": return rotateCommand(ctx, args[1:]) case "builds": @@ -144,6 +148,7 @@ func usage() { build [--ref R] have a build machine build it, and record what came out builds [] what has been built lately, and what came of it builder issue a broker account for a build machine, scoped to build work + licence add|list|use|key model access, under the name a person calls it rotate [--consumer ] a new credential for every holder, both ends at once pin which node this one gets a provision from unpin put that question back @@ -1048,6 +1053,15 @@ func planFor(ctx context.Context, inv *inventory.Inventory, nodeName string) (ca if err != nil { return catalogue.Resolution{}, nil, err } + + // What this mesh can answer with a record rather than a machine, and which record each of + // this node's modules was put on. Read across a context boundary by name, which is what + // crossing one is allowed to carry (novox/hq ADR 0008). + world.Licences, world.Using, err = licencesFor(ctx, nodeName) + if err != nil { + return catalogue.Resolution{}, nil, err + } + resolved, err := catalogue.Resolve(shelf, assigned, catalogue.Node{Name: nodeName, Site: site, Capabilities: capabilities, At: onNetwork[nodeName]}, world) @@ -1059,6 +1073,17 @@ func planFor(ctx context.Context, inv *inventory.Inventory, nodeName string) (ca // password a provider is told to create is the one its consumer was given — and sealed to // this node before it was ever written down, so nothing between here and there can read it. for i, n := range resolved.Needs { + if n.ByRecord { + // Answered by something the mesh holds, so there is no pair-wise secret between two + // machines. Its key was supplied by a person and sealed to this node then; the mesh + // discarded the plaintext and cannot make another. + sealed, err := keyFor(ctx, n.From, nodeName, n.For) + if err != nil { + return catalogue.Resolution{}, nil, err + } + resolved.Needs[i].Sealed = sealed + continue + } secret, err := inv.SecretFor(ctx, n.Name, nodeName, n.From) if err != nil { // Said rather than skipped. A machine that resolves cleanly and receives no @@ -2315,3 +2340,79 @@ func builderCommand(ctx context.Context, args []string) error { fmt.Println("This is the only time it is shown.") return nil } + +// openLicences connects to the context that holds which model access exists and who may use it. +func openLicences(ctx context.Context) (*licences.Licences, error) { + held, err := licences.Open(ctx) + if err != nil { + return nil, err + } + if err := held.Ready(ctx, 30*time.Second); err != nil { + held.Close() + return nil, err + } + return held, nil +} + +// licencesFor is what this node can be answered with by record, and what it was put on. +// +// A mesh with no licences at all is the ordinary case and must not be an error: every existing +// mesh is one, and a control plane that refused to plan because nobody had bought an API key +// would be unusable for the thing it already does. +func licencesFor(ctx context.Context, node string) ( + map[string][]catalogue.Record, map[string]map[string]catalogue.Record, error) { + + held, err := openLicences(ctx) + if err != nil { + return nil, nil, err + } + defer held.Close() + + all, err := held.All(ctx) + if err != nil { + return nil, nil, err + } + if len(all) == 0 { + return nil, nil, nil + } + + offered := map[string][]catalogue.Record{} + byName := map[string]catalogue.Record{} + for _, one := range all { + record := catalogue.Record{Name: one.Name, Serves: one.Serves} + offered[licences.Provision] = append(offered[licences.Provision], record) + byName[one.Name] = record + } + + using := map[string]map[string]catalogue.Record{} + for _, one := range all { + holders, err := held.HoldersOf(ctx, one.Name) + if err != nil { + return nil, nil, err + } + for _, h := range holders { + if h.Node != node { + continue + } + if using[h.Module] == nil { + using[h.Module] = map[string]catalogue.Record{} + } + using[h.Module][licences.Provision] = byName[one.Name] + } + } + return offered, using, nil +} + +// keyFor is the licence key sealed to one machine, for one module. +// +// **Empty is not an error here.** The mesh discarded the plaintext when it was supplied, so a +// holder recorded afterwards genuinely has no key — and the declaration refuses that by name, +// where the module and the path are both in view, rather than here. +func keyFor(ctx context.Context, licence, node, module string) (string, error) { + held, err := openLicences(ctx) + if err != nil { + return "", err + } + defer held.Close() + return held.KeyFor(ctx, licence, node, module) +} diff --git a/internal/catalogue/declaration.go b/internal/catalogue/declaration.go index 1331b6d..e131f15 100644 --- a/internal/catalogue/declaration.go +++ b/internal/catalogue/declaration.go @@ -157,6 +157,17 @@ func (r Resolution) Declaration(with Rendering) ([]map[string]any, error) { found = &r.Needs[i] } } + if found != nil && found.ByRecord && found.Sealed == "" { + // Answered by a record whose key has not been supplied since this consumer was + // put on it. **Refused, not skipped.** The mesh discarded the plaintext when the + // key was accepted and cannot seal another, so a machine that resolved cleanly + // would receive no file at all and fail at whatever tried to read it — which is + // the outcome ADR 0024 exists to avoid, arrived at politely. + return nil, fmt.Errorf( + "%s on this machine uses the licence %q and no key has been sealed to it. "+ + "The mesh cannot make one; supply it again with `licence key %s`", + m.Module, found.From, found.From) + } if found == nil || found.Sealed == "" { // Answered on this machine, or answered by a node the mesh could not seal to. // Nothing to write either way, and writing an empty credential file would be @@ -408,11 +419,17 @@ func sortedKeys[V any](m map[string]V) []string { // says so rather than leaving a reader to wonder whether one was meant to be there — a missing // field looks like a bug, and a stated absence looks like a boundary. func boundFile(n Needed, path string) (map[string]any, error) { + // A record has no machine and no address. Saying so is the difference between a reader + // concluding "somewhere with no address" and concluding the mesh failed to fill something in. + where := any(n.At) + if n.ByRecord { + where = "a record in this mesh, not a machine" + } body, err := json.MarshalIndent(map[string]any{ "binding": 1, "provision": n.Name, "from": n.From, - "at": n.At, + "at": where, "serves": n.Serves, "generated": "by the mesh — do not edit; replaced whenever this changes. " + "It carries no credential: the mesh has no way to issue one yet", diff --git a/internal/catalogue/licence_test.go b/internal/catalogue/licence_test.go new file mode 100644 index 0000000..c0c33bc --- /dev/null +++ b/internal/catalogue/licence_test.go @@ -0,0 +1,150 @@ +package catalogue + +import ( + "strings" + "testing" +) + +func aModelUser() Manifest { + return Manifest{Module: "assistant", Requires: []string{"model-access"}, + Binds: map[string]string{"model-access": "/etc/assistant/model.json"}, + Secrets: map[string]string{"model-access": "/etc/assistant/key"}} +} + +// A provision answered by a record rather than a node. +// +// novox/hq ADR 0024: a hosted model is on nobody's machine and is reached over the public +// internet, so the rule that refuses two ends sharing no private network must not apply. This +// node is deliberately not on the private network at all — under the old rule that alone would +// refuse it. +func TestAProvisionAnsweredByARecordDoesNotNeedAPrivateNetwork(t *testing.T) { + got, err := Resolve( + map[string]Manifest{"assistant": aModelUser()}, + []string{"assistant"}, + Node{Name: "workstation"}, + World{ + Licences: map[string][]Record{"model-access": {{Name: "personal", + Serves: map[string]any{"model": "a-model"}}}}, + Using: map[string]map[string]Record{"assistant": {"model-access": {Name: "personal", + Serves: map[string]any{"model": "a-model"}}}}, + }) + if err != nil { + t.Fatalf("a machine off the private network could not be given model access: %v", err) + } + if len(got.Needs) != 1 { + t.Fatalf("the licence was not recorded as something this node takes: %+v", got.Needs) + } + if !got.Needs[0].ByRecord { + t.Fatal("the licence was treated as a machine, so the reachability rule would apply to it") + } + if got.Needs[0].From != "personal" { + t.Fatalf("the licence is not named by what a person calls it: %+v", got.Needs[0]) + } +} + +// Refused when the consumer has not said which — and the refusal names the candidates and the +// command, because ADR 0024 warns this will be felt: a mesh holding three ways to reach a model +// refuses every consumer that has not chosen. +func TestAConsumerThatHasNotSaidWhichLicenceIsRefusedWithTheCandidates(t *testing.T) { + _, err := Resolve( + map[string]Manifest{"assistant": aModelUser()}, + []string{"assistant"}, + Node{Name: "workstation"}, + World{Licences: map[string][]Record{"model-access": { + {Name: "personal"}, {Name: "the-organisation"}, + }}}) + if err == nil { + t.Fatal("a consumer was given model access without anybody saying which") + } + said := err.Error() + for _, want := range []string{"personal", "the-organisation", "licence use"} { + if !strings.Contains(said, want) { + t.Fatalf("the refusal does not name %q, so it is correct and unusable:\n%s", want, said) + } + } +} + +// A model the mesh runs itself answers it locally, and a record is not consulted. +func TestAModelInTheMeshsOwnSetAnswersItWithoutALicence(t *testing.T) { + got, err := Resolve( + map[string]Manifest{ + "assistant": aModelUser(), + "ollama": {Module: "ollama", + Provides: []Offer{{Name: "model-access", Scope: ScopeNode}}}, + }, + []string{"assistant", "ollama"}, + Node{Name: "workstation"}, + World{Licences: map[string][]Record{"model-access": {{Name: "personal"}}}}) + if err != nil { + t.Fatalf("a machine running its own model was asked to choose a licence: %v", err) + } + for _, n := range got.Needs { + if n.ByRecord { + t.Fatal("a record was used although the answer was on this machine") + } + } +} + +// A key that was never supplied is refused by name rather than silently not written. +// +// The mesh discarded the plaintext when the key was accepted and cannot seal another, so a +// machine that resolved cleanly would receive no file and fail at whatever read it. +func TestAModuleOnALicenceWithNoKeyIsRefusedRatherThanLeftEmpty(t *testing.T) { + r := Resolution{ + Node: "workstation", + Modules: []Manifest{aModelUser()}, + Needs: []Needed{{Name: "model-access", From: "personal", ByRecord: true, For: "assistant"}}, + } + _, err := r.Declaration(Rendering{}) + if err == nil { + t.Fatal("a module was given a licence with no key, so it receives nothing and fails later") + } + if !strings.Contains(err.Error(), "licence key personal") { + t.Fatalf("the refusal does not say how to fix it: %v", err) + } +} + +// And with a key, both files arrive: what is public, and what is not. +func TestALicenceDeliversWhatIsPublicAndWhatIsSealed(t *testing.T) { + r := Resolution{ + Node: "workstation", + Modules: []Manifest{aModelUser()}, + Needs: []Needed{{Name: "model-access", From: "personal", ByRecord: true, For: "assistant", + Serves: map[string]any{"model": "a-model"}, Sealed: "sealed-blob"}}, + } + out, err := r.Declaration(Rendering{}) + if err != nil { + t.Fatal(err) + } + files := map[string]map[string]any{} + for _, res := range out { + if path, ok := res["path"].(string); ok { + files[path] = res + } + } + bound, given := files["/etc/assistant/model.json"] + if !given { + t.Fatal("the consumer was not told what it needs to know that is not secret") + } + content, _ := bound["content"].(string) + if !strings.Contains(content, "a-model") { + t.Fatalf("the binding does not carry what the licence serves:\n%s", content) + } + // The binding says it is a record rather than leaving an empty address, which a reader would + // take for something the mesh failed to fill in. + if !strings.Contains(content, "not a machine") { + t.Fatalf("the binding leaves an empty address with no explanation:\n%s", content) + } + key, delivered := files["/etc/assistant/key"] + if !delivered { + t.Fatal("the key was not delivered") + } + if key["sealed"] != "sealed-blob" { + t.Fatalf("the key is not the sealed one: %+v", key) + } + // And never in the open. The whole arrangement is that what travels is unreadable by + // everything between here and the machine. + if strings.Contains(content, "sealed-blob") { + t.Fatal("the key was written into the public file as well") + } +} diff --git a/internal/catalogue/resolve.go b/internal/catalogue/resolve.go index 4b3201c..6725691 100644 --- a/internal/catalogue/resolve.go +++ b/internal/catalogue/resolve.go @@ -40,6 +40,18 @@ type World struct { // start meaning something the day a second provider appears, and one recorded and then made // unnecessary should not quietly stop applying either. Pinned map[string]string + // Licences is every provision answered by a **record rather than a node**, by provision name. + // + // novox/hq ADR 0024: a hosted model is on nobody's machine and is reached over the public + // internet, so the rule that refuses two ends sharing no private network must not apply to + // it. These are the candidates a refusal names. + Licences map[string][]Record + // Using is which record this node's modules were put on, keyed by module then provision. + // + // Per consumer, because that is the whole point: saying WHICH licence a given thing uses. Two + // modules on one machine using different accounts is ordinary rather than a collision. + Using map[string]map[string]Record + // Unchecked takes brokered requirements on trust instead of refusing when nothing answers // them. // @@ -50,6 +62,18 @@ type World struct { Unchecked bool } +// Record is a provision answered by something the mesh holds rather than by a machine. +// +// The name is the operator's — *the personal account*, *the organisation's* — because the whole +// point is saying which one a consumer uses, and an anonymous credential hanging off a provider +// cannot be said (novox/hq ADR 0024). +type Record struct { + // Name is what a person calls it, and what a consumer is put on. + Name string + // Serves is what a consumer must know that is not secret — a base URL, a model name. + Serves map[string]any +} + // Provider is one node answering a mesh-scoped requirement. type Provider struct { // Node is the machine. @@ -101,6 +125,10 @@ type Needed struct { At string // Serves is what the providing module said a consumer needs to know. Serves map[string]any + // ByRecord means this was answered by something the mesh holds rather than by a machine, so + // there is no node to reach and no private network to share. Its credential comes from + // wherever that record's does, not from the pair-wise secret two machines share. + ByRecord bool // Sealed is the credential, closed to this node. Filled in after resolving, because whose // credential it is only becomes answerable once which node answers has been settled. Sealed string @@ -288,6 +316,14 @@ func Resolve(catalogue map[string]Manifest, assigned []string, node Node, world candidates := offers[want] switch len(candidates) { case 0: + if len(world.Licences[want]) > 0 { + // Answered by a record rather than by a module, and the post-pass below settles + // which one. Left alone here: the two questions a refusal must answer — *is + // there anything* and *which one* — have different remedies, and answering the + // first wrongly would send somebody looking for a module to install. + reported[want] = true + continue + } reported[want] = true problems = append(problems, fmt.Sprintf( "nothing provides %q, wanted by %s", want, because[want])) @@ -324,6 +360,53 @@ func Resolve(catalogue map[string]Manifest, assigned []string, node Node, world } } + // What is answered by a record rather than by a machine. + // + // A post-pass, deliberately: nothing about it depends on the order requirements were walked + // in, and putting it in the queue would mean the reachability rule — which must not apply + // here — sitting one branch away from a case it would be wrong for. + // + // **Refused when the consumer has not said which.** ADR 0024 warns this will be felt: a mesh + // holding three ways to reach a model refuses every consumer that has not chosen, which is + // correct and is a great deal of saying-which the first time. So the refusal names the + // candidates and the exact command, because being right is not the same as being usable. + for _, name := range order { + m := catalogue[name] + for _, want := range m.Wants() { + offered, byRecord := world.Licences[want] + if !byRecord || len(offered) == 0 { + continue + } + if satisfied[want] { + // Something in this node's own set answers it -- a model the mesh runs itself, + // most obviously. A record is not consulted when there is a local answer. + continue + } + using, said := world.Using[m.Module][want] + if !said { + if world.Unchecked { + continue + } + names := make([]string, 0, len(offered)) + for _, r := range offered { + names = append(names, r.Name) + } + sort.Strings(names) + problems = append(problems, fmt.Sprintf( + "%s on %s needs %q and has not been told which one to use — say which with "+ + "`licence use %s %s`: %s", + m.Module, node.Name, want, node.Name, m.Module, strings.Join(names, ", "))) + continue + } + needs = append(needs, Needed{ + Name: want, From: using.Name, Serves: using.Serves, ByRecord: true, + // The module that required it, not whatever first mentioned the name: the key is + // sealed per consumer, and a consumer here is a module on a machine. + For: m.Module, + }) + } + } + resolution := Resolution{Node: node.Name, At: node.At, Because: because, Needs: needs} for _, n := range order { resolution.Modules = append(resolution.Modules, catalogue[n]) diff --git a/internal/licences/licences.go b/internal/licences/licences.go new file mode 100644 index 0000000..542a8bc --- /dev/null +++ b/internal/licences/licences.go @@ -0,0 +1,274 @@ +// Package licences is the context that holds which model access exists and who may use it. +// +// novox/hq ADR 0024. It is the first provision answered by a **record rather than a node**: a +// hosted model is on nobody's machine, is reached over the public internet, and the rule that +// refuses two ends sharing no private network must not apply to it. +// +// It owns its store exclusively (novox/hq ADR 0008): a database called `licences`, reached with a +// credential no other context holds — including `inventory`, in the same process. It refers to +// nodes by name, which is what crossing a context boundary is allowed to carry. +package licences + +import ( + "context" + "embed" + "encoding/json" + "errors" + "fmt" + "sort" + "strings" + "time" + + "github.com/jackc/pgx/v5" + "github.com/novox/mesh-control/internal/secrets" + "github.com/novox/mesh-control/internal/store" +) + +// Name is what this context is called: its database and its credential are named after it. +const Name = "licences" + +// Provision is what a module requires in order to be given one. +// +// One name for all of them, because *which* licence is the operator's choice per consumer rather +// than something a module asks for — a module that required `anthropic` by name could never be +// moved onto a mesh-hosted model without editing it. +const Provision = "model-access" + +//go:embed migrations/*.sql +var files embed.FS + +// Migrations are this context's schema changes, in order. +func Migrations() ([]store.Migration, error) { + return store.LoadMigrations(files, "migrations") +} + +// Licences is this context, holding the store it exclusively owns. +type Licences struct{ store *store.Store } + +// Open connects to the licence store. +func Open(ctx context.Context) (*Licences, error) { + s, err := store.Open(ctx, Name) + if err != nil { + return nil, err + } + return &Licences{store: s}, nil +} + +func (l *Licences) Close() { l.store.Close() } + +// Ready waits for the database to answer. +func (l *Licences) Ready(ctx context.Context, within time.Duration) error { + return l.store.Ready(ctx, within) +} + +// A Licence is one way to reach a model, under the name a person calls it. +type Licence struct { + Name string + Provider string + Serves map[string]any + Added time.Time +} + +// A Holder is one consumer using a licence, and whether it has been given the key. +type Holder struct { + Licence string + Node string + Module string + // Sealed is empty when no key has been supplied since this holder was recorded. + Sealed string +} + +// Add records a licence under the operator's own name for it. +func (l *Licences) Add(ctx context.Context, name, provider string, serves map[string]any) error { + if strings.TrimSpace(name) == "" || strings.TrimSpace(provider) == "" { + return errors.New("a licence needs a name and a provider") + } + if serves == nil { + serves = map[string]any{} + } + body, err := json.Marshal(serves) + if err != nil { + return err + } + _, err = l.store.Pool().Exec(ctx, + `insert into licence (name, provider, serves) values ($1, $2, $3) + on conflict (name) do update set provider = excluded.provider, serves = excluded.serves`, + name, provider, body) + return err +} + +// All is every licence this mesh knows about. +func (l *Licences) All(ctx context.Context) ([]Licence, error) { + rows, err := l.store.Pool().Query(ctx, + `select name, provider, serves, added_at from licence order by name`) + if err != nil { + return nil, err + } + defer rows.Close() + + var out []Licence + for rows.Next() { + var one Licence + var body []byte + if err := rows.Scan(&one.Name, &one.Provider, &body, &one.Added); err != nil { + return nil, err + } + if err := json.Unmarshal(body, &one.Serves); err != nil { + return nil, err + } + out = append(out, one) + } + return out, rows.Err() +} + +// Forget removes a licence, and with it every record of who held it. +// +// **A licence outliving its holder is a live credential nobody is watching** (ADR 0024). This is +// the other direction and has the same shape: what the mesh no longer grants, it stops naming. +// The key itself is not the mesh's to revoke — that is done where the licence was bought, and +// saying so is more use than pretending otherwise. +func (l *Licences) Forget(ctx context.Context, name string) error { + tag, err := l.store.Pool().Exec(ctx, `delete from licence where name = $1`, name) + if err != nil { + return err + } + if tag.RowsAffected() == 0 { + return fmt.Errorf("this mesh has no licence called %q", name) + } + return nil +} + +// Use records that a consumer holds a licence. +// +// Recorded before any key exists, deliberately. Who uses what is a decision; the key is a value +// somebody supplies afterwards, and often by a different person. +func (l *Licences) Use(ctx context.Context, licence, node, module string) error { + _, err := l.store.Pool().Exec(ctx, + `insert into licence_holder (licence, node, module) values ($1, $2, $3) + on conflict (licence, node, module) do nothing`, licence, node, module) + if err != nil && strings.Contains(err.Error(), "licence_holder_licence_fkey") { + return fmt.Errorf("this mesh has no licence called %q", licence) + } + return err +} + +// StopUsing takes a consumer off a licence, and its sealed key with it. +func (l *Licences) StopUsing(ctx context.Context, licence, node, module string) error { + _, err := l.store.Pool().Exec(ctx, + `delete from licence_holder where licence = $1 and node = $2 and module = $3`, + licence, node, module) + return err +} + +// HoldersOf is every consumer using a licence. +func (l *Licences) HoldersOf(ctx context.Context, licence string) ([]Holder, error) { + rows, err := l.store.Pool().Query(ctx, + `select licence, node, module, coalesce(sealed, '') from licence_holder + where licence = $1 order by node, module`, licence) + if err != nil { + return nil, err + } + defer rows.Close() + + var out []Holder + for rows.Next() { + var h Holder + if err := rows.Scan(&h.Licence, &h.Node, &h.Module, &h.Sealed); err != nil { + return nil, err + } + out = append(out, h) + } + return out, rows.Err() +} + +// Chosen is the licence a consumer was put on, empty if it was put on none. +func (l *Licences) Chosen(ctx context.Context, node, module string) (string, error) { + var name string + err := l.store.Pool().QueryRow(ctx, + `select licence from licence_holder where node = $1 and module = $2`, node, module). + Scan(&name) + if errors.Is(err, pgx.ErrNoRows) { + return "", nil + } + return name, err +} + +// KeyFor is the sealed key for one holder, empty if none has been supplied since it was recorded. +func (l *Licences) KeyFor(ctx context.Context, licence, node, module string) (string, error) { + var sealed *string + err := l.store.Pool().QueryRow(ctx, + `select sealed from licence_holder where licence = $1 and node = $2 and module = $3`, + licence, node, module).Scan(&sealed) + if errors.Is(err, pgx.ErrNoRows) || sealed == nil { + return "", nil + } + if err != nil { + return "", err + } + return *sealed, nil +} + +// SealingKeys is what Accept needs: each holder's node and the key to seal to it. +type SealingKeys func(node string) (string, error) + +// Accept takes a key somebody supplied, seals it to every holder, and discards the plaintext. +// +// **The missing verb** (ADR 0024). Every credential the mesh handles otherwise it generated +// itself; an API key arrives from a person, and a mesh that kept operator-supplied keys readably +// is the arrangement this project measured and rejected. +// +// **It seals to the holders that exist now.** A holder recorded afterwards has no key, and the +// mesh cannot make one — it discarded the only copy. That is reported rather than hidden: the +// remedy is to supply the key again, which is a thing a person can do, and delivering nothing +// while reporting success is not. +func (l *Licences) Accept(ctx context.Context, licence, value string, keys SealingKeys) (int, error) { + if strings.TrimSpace(value) == "" { + return 0, errors.New("an empty key is not a key") + } + holders, err := l.HoldersOf(ctx, licence) + if err != nil { + return 0, err + } + if len(holders) == 0 { + // Refused rather than stored for later, because storing it for later means storing it + // readably — which is the whole thing this refuses to do. + return 0, fmt.Errorf( + "nothing uses %q yet, and the mesh does not keep a key it cannot seal to somebody. "+ + "Put a consumer on it first, then supply the key", licence) + } + + sealed := 0 + for _, h := range holders { + key, err := keys(h.Node) + if err != nil { + return sealed, err + } + if key == "" { + return sealed, fmt.Errorf( + "%s has no sealing key, so nothing can be sealed to it — it joins again to get one", + h.Node) + } + made, err := secrets.Accept(value, key, key) + if err != nil { + return sealed, err + } + if _, err := l.store.Pool().Exec(ctx, + `update licence_holder set sealed = $4, node_key = $5 + where licence = $1 and node = $2 and module = $3`, + h.Licence, h.Node, h.Module, made.ForConsumer, key); err != nil { + return sealed, err + } + sealed++ + } + return sealed, nil +} + +// Names is every licence's name, sorted — what a refusal lists when a consumer has not chosen. +func Names(all []Licence) []string { + out := make([]string, 0, len(all)) + for _, one := range all { + out = append(out, one.Name) + } + sort.Strings(out) + return out +} diff --git a/internal/licences/migrations/0001-a-licence-is-a-named-thing.sql b/internal/licences/migrations/0001-a-licence-is-a-named-thing.sql new file mode 100644 index 0000000..b3d6742 --- /dev/null +++ b/internal/licences/migrations/0001-a-licence-is-a-named-thing.sql @@ -0,0 +1,41 @@ +-- A licence is a named thing, and the name is the operator's. +-- +-- novox/hq ADR 0024. Not an anonymous credential hanging off a provider: *the personal account*, +-- *the organisation's account* are names a person uses, and the mesh has to use them too, because +-- the whole point is saying WHICH ONE a given consumer uses. +-- +-- **Many to many.** One provider has several licences; one licence serves several consumers. So it +-- is deliberately not a claim — claims are for things only one holder may have, and two machines +-- sharing an account is the ordinary case rather than a collision. + +create table licence ( + -- The operator's name for it. The primary key, because that is what a person types and what a + -- consumer is pinned to. + name text primary key, + -- Which service it is for: anthropic, openai, a model the mesh runs itself. + provider text not null, + -- What a consumer needs to know that is not secret -- a base URL, a model name. The key is + -- never here. + serves jsonb not null default '{}'::jsonb, + added_at timestamptz not null default now() +); + +-- Who holds it, and the key sealed to them. +-- +-- **The node is a name, not a foreign key.** It lives in another context and this one may not join +-- across that boundary (novox/hq ADR 0008); a name is the published identifier and is what +-- crossing a context boundary is allowed to carry. +create table licence_holder ( + licence text not null references licence(name) on delete cascade, + node text not null, + module text not null, + + -- Sealed to that node's key. Null until a key has been supplied while this holder existed -- + -- which is a real state and not an error: the mesh discarded the plaintext, so it cannot seal + -- to a holder that arrived afterwards, and saying so is better than delivering nothing. + sealed text, + node_key text, + + added_at timestamptz not null default now(), + primary key (licence, node, module) +);