Files
mesh-controller/internal/licences/adapters/adapters.go
T
jschoubben ddb41baaf4 Model access is vendor-agnostic: rename provider→vendor, add adapter seam (Phase A)
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
2026-09-06 23:49:12 +02:00

156 lines
7.0 KiB
Go

// 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 }