From ddb41baaf417b24db8b6cb0e85a6f6889fc82104 Mon Sep 17 00:00:00 2001 From: jochen Date: Sun, 6 Sep 2026 23:49:12 +0200 Subject: [PATCH] =?UTF-8?q?Model=20access=20is=20vendor-agnostic:=20rename?= =?UTF-8?q?=20provider=E2=86=92vendor,=20add=20adapter=20seam=20(Phase=20A?= =?UTF-8?q?)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ADR 0050 Phase A. Rename the licence's `provider` field to `vendor` — the inventory already uses "provider" for which node answers a brokered provision, and one word must not carry two facts — and route the licence layer's sealing and delivery through a per-vendor adapter selected by that field. The rename touches the Go struct/params/SQL in internal/licences, the operator CLI, and the schema: 0001 (the consolidated schema) now creates the column as `vendor`; a new guarded 0002 renames it on a database that predates the change, and is a no-op on a fresh one. The adapter (internal/licences/adapters) has a `shape` and the two verbs a static-key vendor needs — accept (the generic anonymous-box seal) and deliver (the sealed blob unchanged). refresh/identity/usage are named as optional capability interfaces so the refreshable-grant seam exists before its code. A registry maps vendor→shape (anthropic→static-key for now, with a Phase-B TODO to swap it to refreshable-grant); an unknown vendor is refused clearly. Behaviour is unchanged from the operator's view except the field name. Claude-Session: https://claude.ai/code/session_01LrgweAeERJYBg88c5cKDzF --- cmd/mesh-control/licence.go | 10 +- internal/licences/adapters/adapters.go | 155 ++++++++++++++++++ internal/licences/licences.go | 69 ++++++-- internal/licences/licences_test.go | 4 +- .../0001-a-licence-is-a-named-thing.sql | 10 +- .../migrations/0002-vendor-not-provider.sql | 22 +++ 6 files changed, 245 insertions(+), 25 deletions(-) create mode 100644 internal/licences/adapters/adapters.go create mode 100644 internal/licences/migrations/0002-vendor-not-provider.sql diff --git a/cmd/mesh-control/licence.go b/cmd/mesh-control/licence.go index 4ef272d..3f80257 100644 --- a/cmd/mesh-control/licence.go +++ b/cmd/mesh-control/licence.go @@ -47,9 +47,9 @@ func licenceAdd(ctx context.Context, args []string) error { return err } if len(positionals) != 2 { - return errors.New(`licence add [--serves '{"model":"..."}']`) + return errors.New(`licence add [--serves '{"model":"..."}']`) } - provider, name := positionals[0], positionals[1] + vendor, name := positionals[0], positionals[1] values := map[string]any{} if strings.TrimSpace(*serves) != "" { @@ -63,11 +63,11 @@ func licenceAdd(ctx context.Context, args []string) error { return err } defer held.Close() - if err := held.Add(ctx, name, provider, values); err != nil { + if err := held.Add(ctx, name, vendor, 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) + " licence use %s \n licence key %s\n", name, vendor, name, name) return nil } @@ -92,7 +92,7 @@ func licenceList(ctx context.Context) error { if err != nil { return err } - fmt.Printf("%s (%s)\n", one.Name, one.Provider) + fmt.Printf("%s (%s)\n", one.Name, one.Vendor) if len(holders) == 0 { fmt.Printf(" nobody uses it\n") } diff --git a/internal/licences/adapters/adapters.go b/internal/licences/adapters/adapters.go new file mode 100644 index 0000000..21dac65 --- /dev/null +++ b/internal/licences/adapters/adapters.go @@ -0,0 +1,155 @@ +// Package adapters is the per-vendor lifecycle a licence needs, selected by the licence's vendor. +// +// novox/hq ADR 0050: model access is one consumer-facing, vendor-blind provision, and *a vendor is +// an adapter* keyed by `licence.vendor`. A consumer names `model-access` and never a vendor; which +// vendor answers, and the lifecycle that vendor's credential needs, lives here — exactly as +// `public-dns` is one neutral interface answered by registrar-scoped providers (ADR 0044). +// +// **Phase A ships only the `static-key` shape.** A static-key vendor implements almost nothing: the +// credential is an operator-supplied value, sealed to each holder by the generic anonymous box +// (ADR 0024) and delivered unchanged. The refreshable-grant machinery — central rotation, an +// identity guard, a usage reading, refresh-token-stripped delivery — is Phase B, and its verbs are +// named here as optional capabilities (Refresher, Identifier, UsageReader) so the seam exists +// before the code does. The abstraction earns its keep by making the common vendor small, not the +// rare one clever (ADR 0050). +package adapters + +import ( + "context" + "fmt" + "sort" + "strings" + + "github.com/novox/mesh-control/internal/secrets" +) + +// Shape is how a vendor's credential behaves, and the switch the ADR 0050 carve-out turns on. +type Shape string + +const ( + // StaticKey is an operator-supplied value with nothing to rotate. The full "the mesh cannot + // read what it stores" guarantee holds: accept seals it and discards the plaintext. + StaticKey Shape = "static-key" + // RefreshableGrant is an OAuth-style grant a manager node refreshes centrally (ADR 0050, + // Phase B). It carries the one bounded relaxation of that guarantee. No adapter of this shape + // ships in Phase A. + RefreshableGrant Shape = "refreshable-grant" +) + +// Adapter is a vendor's lifecycle, selected by a licence's vendor. +// +// Only these verbs are required, and they are all a static-key vendor needs: what shape it is, how +// a supplied value is sealed, and what a consumer is delivered. The optional capabilities below are +// found by type assertion, so a static-key adapter simply does not implement them. +type Adapter interface { + // Vendor is the name a licence's `vendor` field carries to select this adapter. + Vendor() string + // Shape is static-key or refreshable-grant. + Shape() Shape + // Accept takes an operator-supplied credential and seals it to one holder — the `accept` verb + // ADR 0024 defines. For a static key this is the generic seal, unchanged. + Accept(value, consumerKey, providerKey string) (secrets.Sealed, error) + // Deliver is the credential value a consumer receives. For a sealed static key it is the sealed + // blob unchanged: the mesh never unseals it, only the holding node's private key can. A + // refreshable-grant adapter (Phase B) strips the refresh token here. + Deliver(sealed string) string +} + +// Refresher is implemented only by a refreshable-grant adapter (ADR 0050, Phase B): the +// lease / rotate / publish machinery a manager node runs. A static-key adapter does not implement +// it, and a caller finds its absence by a type assertion. +type Refresher interface { + Refresh(ctx context.Context, licence string) error +} + +// Identifier is the mis-binding guard (ADR 0050, Phase B): a vendor whose credential carries an +// account identity worth checking implements it. A static-key adapter does not. +type Identifier interface { + Identity(credential string) (string, error) +} + +// UsageReader is a vendor's usage reading (ADR 0050 / ADR 0054, Phase B), mapped to the common +// normalised grain. A static-key adapter does not implement it. +type UsageReader interface { + Usage(ctx context.Context, licence string) ([]UsageRow, error) +} + +// UsageRow is the vendor-neutral usage grain ADR 0054 fixes: the metric is vendor-defined and no +// common unit is forced. Defined here as the Phase-B seam; nothing in Phase A produces one. +type UsageRow struct { + Licence string + Consumer string + Period string + Metric string + Value float64 + // Raw is the vendor's own response, kept so a reading can be re-derived if the normalisation is + // later found wrong. + Raw []byte +} + +// registry maps a vendor name to the shape its adapter has. +// +// The single place a vendor is taught to the mesh. For Phase A the only entry is anthropic as a +// static-key vendor, which is what the lab bed exercises. +// +// TODO(Phase B, ADR 0050): anthropic's real credential is a subscription OAuth grant, so its entry +// becomes RefreshableGrant and a refreshable-grant adapter is registered for it; the static-key +// path for the same vendor's plain API keys moves to a separate `anthropic-api-key` vendor, the +// early second static-key case ADR 0050 names. Static-key vendors are added here as one line each. +var registry = map[string]Shape{ + "anthropic": StaticKey, +} + +// For returns the adapter for a vendor, refusing an unknown one clearly. +// +// The refusal names what this mesh does know, because a vendor typo and a vendor this build has no +// adapter for are the same symptom to whoever hits it, and the list is the difference between fixing +// the name and filing a bug. +func For(vendor string) (Adapter, error) { + shape, known := registry[vendor] + if !known { + return nil, fmt.Errorf( + "this mesh has no adapter for the vendor %q; it knows: %s", + vendor, strings.Join(vendors(), ", ")) + } + switch shape { + case StaticKey: + return staticKey{vendor: vendor}, nil + default: + // A vendor registered with a shape this build does not implement yet — the refreshable-grant + // seam. Said plainly rather than answered with a static-key adapter that would silently + // mishandle it. + return nil, fmt.Errorf( + "the vendor %q needs a %s adapter, which this build does not ship yet", vendor, shape) + } +} + +// vendors is every vendor this mesh has an adapter for, sorted — what a refusal lists. +func vendors() []string { + out := make([]string, 0, len(registry)) + for v := range registry { + out = append(out, v) + } + sort.Strings(out) + return out +} + +// staticKey is the adapter for a vendor whose credential is a single operator-supplied value. +// +// It holds no vendor-specific logic, and that is the point: accept is the generic seal, deliver is +// the value, and refresh / identity / usage are not implemented at all. It is one type shared by +// every static-key vendor rather than one per vendor, because the sealing is vendor-independent. +type staticKey struct{ vendor string } + +func (s staticKey) Vendor() string { return s.vendor } +func (s staticKey) Shape() Shape { return StaticKey } + +// Accept is the generic anonymous-box seal (ADR 0024): a static key needs nothing vendor-specific. +func (s staticKey) Accept(value, consumerKey, providerKey string) (secrets.Sealed, error) { + return secrets.Accept(value, consumerKey, providerKey) +} + +// Deliver hands the consumer the credential value. For a sealed static key that is the sealed blob +// unchanged — there is no vendor step between the store and the node, and the mesh never sees the +// plaintext. +func (s staticKey) Deliver(sealed string) string { return sealed } diff --git a/internal/licences/licences.go b/internal/licences/licences.go index 542a8bc..8e8e4a8 100644 --- a/internal/licences/licences.go +++ b/internal/licences/licences.go @@ -20,7 +20,7 @@ import ( "time" "github.com/jackc/pgx/v5" - "github.com/novox/mesh-control/internal/secrets" + "github.com/novox/mesh-control/internal/licences/adapters" "github.com/novox/mesh-control/internal/store" ) @@ -63,10 +63,14 @@ func (l *Licences) Ready(ctx context.Context, within time.Duration) error { // 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 + Name string + // Vendor is which company sells this licence, and selects the adapter that runs its lifecycle + // (novox/hq ADR 0050). Named `vendor`, not `provider`: the inventory already uses "provider" + // for *which node answers a brokered provision*, and one word must not carry two unrelated + // facts. + Vendor string + Serves map[string]any + Added time.Time } // A Holder is one consumer using a licence, and whether it has been given the key. @@ -79,9 +83,9 @@ type Holder struct { } // 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") +func (l *Licences) Add(ctx context.Context, name, vendor string, serves map[string]any) error { + if strings.TrimSpace(name) == "" || strings.TrimSpace(vendor) == "" { + return errors.New("a licence needs a name and a vendor") } if serves == nil { serves = map[string]any{} @@ -91,16 +95,16 @@ func (l *Licences) Add(ctx context.Context, name, provider string, serves map[st 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) + `insert into licence (name, vendor, serves) values ($1, $2, $3) + on conflict (name) do update set vendor = excluded.vendor, serves = excluded.serves`, + name, vendor, 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`) + `select name, vendor, serves, added_at from licence order by name`) if err != nil { return nil, err } @@ -110,7 +114,7 @@ func (l *Licences) All(ctx context.Context) ([]Licence, error) { for rows.Next() { var one Licence var body []byte - if err := rows.Scan(&one.Name, &one.Provider, &body, &one.Added); err != nil { + if err := rows.Scan(&one.Name, &one.Vendor, &body, &one.Added); err != nil { return nil, err } if err := json.Unmarshal(body, &one.Serves); err != nil { @@ -194,6 +198,12 @@ func (l *Licences) Chosen(ctx context.Context, node, module string) (string, err } // KeyFor is the sealed key for one holder, empty if none has been supplied since it was recorded. +// +// What is stored is what is delivered, routed through the vendor's adapter so a refreshable-grant +// vendor can strip its refresh token here in Phase B (novox/hq ADR 0050). For a static-key vendor +// that step is the identity — the sealed blob is what the holder receives — so this is unchanged +// for today's vendors. An unregistered vendor is not consulted: a static-key blob delivers as it is, +// and a licence whose key was accepted at all necessarily had a registered adapter. func (l *Licences) KeyFor(ctx context.Context, licence, node, module string) (string, error) { var sealed *string err := l.store.Pool().QueryRow(ctx, @@ -205,9 +215,32 @@ func (l *Licences) KeyFor(ctx context.Context, licence, node, module string) (st if err != nil { return "", err } + if adapter, err := l.adapterFor(ctx, licence); err == nil { + return adapter.Deliver(*sealed), nil + } return *sealed, nil } +// vendorOf reads a licence's vendor, the field that selects its adapter. +func (l *Licences) vendorOf(ctx context.Context, licence string) (string, error) { + var vendor string + err := l.store.Pool().QueryRow(ctx, + `select vendor from licence where name = $1`, licence).Scan(&vendor) + if errors.Is(err, pgx.ErrNoRows) { + return "", fmt.Errorf("this mesh has no licence called %q", licence) + } + return vendor, err +} + +// adapterFor is the adapter a licence's vendor selects (novox/hq ADR 0050). +func (l *Licences) adapterFor(ctx context.Context, licence string) (adapters.Adapter, error) { + vendor, err := l.vendorOf(ctx, licence) + if err != nil { + return nil, err + } + return adapters.For(vendor) +} + // SealingKeys is what Accept needs: each holder's node and the key to seal to it. type SealingKeys func(node string) (string, error) @@ -225,6 +258,14 @@ func (l *Licences) Accept(ctx context.Context, licence, value string, keys Seali if strings.TrimSpace(value) == "" { return 0, errors.New("an empty key is not a key") } + // The vendor selects the adapter that seals it (novox/hq ADR 0050). A static-key vendor's accept + // is the generic seal; the dispatch is what lets a refreshable-grant vendor do otherwise in + // Phase B without this layer changing. An unknown vendor is refused here, before any key is + // touched. + adapter, err := l.adapterFor(ctx, licence) + if err != nil { + return 0, err + } holders, err := l.HoldersOf(ctx, licence) if err != nil { return 0, err @@ -248,7 +289,7 @@ func (l *Licences) Accept(ctx context.Context, licence, value string, keys Seali "%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) + made, err := adapter.Accept(value, key, key) if err != nil { return sealed, err } diff --git a/internal/licences/licences_test.go b/internal/licences/licences_test.go index f81af90..c941e92 100644 --- a/internal/licences/licences_test.go +++ b/internal/licences/licences_test.go @@ -173,11 +173,11 @@ func TestUsingALicenceThatDoesNotExistIsRefused(t *testing.T) { // apart without a schema knowing anything about sessions. func TestTwoSessionsOnOneMachineHoldDifferentLicences(t *testing.T) { held, ctx := fresh(t) - for _, l := range []struct{ name, provider string }{ + for _, l := range []struct{ name, vendor string }{ {"personal", "anthropic"}, {"company", "anthropic"}, } { - if err := held.Add(ctx, l.name, l.provider, map[string]any{"model": "a-model"}); err != nil { + if err := held.Add(ctx, l.name, l.vendor, map[string]any{"model": "a-model"}); err != nil { t.Fatal(err) } } 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 index b3d6742..b22e749 100644 --- a/internal/licences/migrations/0001-a-licence-is-a-named-thing.sql +++ b/internal/licences/migrations/0001-a-licence-is-a-named-thing.sql @@ -1,10 +1,10 @@ -- 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*, +-- novox/hq ADR 0024. Not an anonymous credential hanging off a vendor: *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 +-- **Many to many.** One vendor 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. @@ -12,8 +12,10 @@ 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, + -- Which company sells it: anthropic, openai, a model the mesh runs itself. Selects the adapter + -- that runs this licence's lifecycle (novox/hq ADR 0050). Named `vendor`, not `provider`: the + -- inventory already uses "provider" for which node answers a brokered provision. + vendor 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, diff --git a/internal/licences/migrations/0002-vendor-not-provider.sql b/internal/licences/migrations/0002-vendor-not-provider.sql new file mode 100644 index 0000000..9c49f88 --- /dev/null +++ b/internal/licences/migrations/0002-vendor-not-provider.sql @@ -0,0 +1,22 @@ +-- Vendor, not provider. +-- +-- novox/hq ADR 0050 renames the licence's vendor field from `provider` to `vendor`. "provider" is +-- already the word the inventory uses for *which node answers a brokered provision*; reusing it for +-- *which company sells this licence* would collide two unrelated facts on one word. The consolidated +-- schema (0001) now creates the column as `vendor`; this migration carries an existing database the +-- same distance. +-- +-- **Guarded so it is a no-op on a fresh database.** A database created after 0001 was updated +-- already has `vendor` and no `provider` column, and PostgreSQL has no `RENAME COLUMN IF EXISTS` -- +-- so the rename is wrapped in an explicit existence check. On a database that predates the rename +-- the `provider` column is present and is renamed; on a fresh one nothing is done, and both end with +-- exactly the same schema. +do $$ +begin + if exists ( + select 1 from information_schema.columns + where table_name = 'licence' and column_name = 'provider' + ) then + alter table licence rename column provider to vendor; + end if; +end $$;