diff --git a/go/clients/meshconfigclient/client.go b/go/clients/meshconfigclient/client.go new file mode 100644 index 0000000..9f99ddb --- /dev/null +++ b/go/clients/meshconfigclient/client.go @@ -0,0 +1,83 @@ +// Package meshconfigclient is the logic every reader of the seat mesh-config's state kept shares (novox/hq ADR 0565 +// clause 5, ADR 0634 ยง4): the checker and the assigner's composition check lay a pull request's own held values over its +// modules' values. The layout of a value is the seat's contract's; this holds what the readers share over it: a pull +// request's key, a held value's key and the rule that a value is the one its key names. +package meshconfigclient + +import ( + "encoding/json" + "fmt" + "strings" + + seat "git.novox.be/novox/mesh-contracts/go/seats/meshconfig" +) + +// PullKey is a pull request's key, `owner/repository#number` with `/` and `#` as dots, the form the seat keeps a pull +// request's values under. The three parts hold no dot, star, angle bracket or space. +func PullKey(pull string) (string, error) { + owner, repository, number, err := splitPull(pull) + if err != nil { + return "", err + } + return owner + "." + repository + "." + number, nil +} + +func splitPull(pull string) (owner, repository, number string, err error) { + owner, rest, ok := strings.Cut(strings.TrimSpace(pull), "/") + repository, number, ok2 := strings.Cut(rest, "#") + if !ok || !ok2 || owner == "" || repository == "" || number == "" || strings.ContainsAny(owner+repository+number, ".*> ") { + return "", "", "", fmt.Errorf("meshconfig: %q is not a pull request, owner/repository#number", pull) + } + return owner, repository, number, nil +} + +// HeldValueKey is a held value's key in the state kept: the pull request's key, the node, the module and the config key, +// joined by dots. +func HeldValueKey(pull, node, module, key string) (string, error) { + owner, repository, number, err := splitPull(pull) + if err != nil { + return "", err + } + if node == "" || module == "" || key == "" { + return "", fmt.Errorf("meshconfig: a held value names a node, a module and a key") + } + return seat.KeptByOwnerRepositoryNumberNodeModuleKeyKey(owner, repository, number, node, module, key), nil +} + +// Kept is one held value, as the seat types it. +type Kept = seat.KeptByOwnerRepositoryNumberNodeModuleKeyValue + +// CheckKept refuses a held value that names no pull request, head, declaration, node, module or key. +func CheckKept(v Kept) error { + if _, err := PullKey(v.Pull); err != nil { + return err + } + if v.Head == "" || v.Declaration == "" || v.Node == "" || v.Module == "" || v.Key == "" { + return fmt.Errorf("meshconfig: a held value of %s names a head, a declaration, a node, a module and a key", v.Pull) + } + return nil +} + +// DecodeKept reads a value of the state kept under its key: the value the seat types, checked, and the one its key +// names. A value kept under another key than its own fields make is refused, so a reader never lays a value over the +// wrong pull request or module. +func DecodeKept(key string, raw []byte) (Kept, error) { + v, err := seat.DecodeKeptByOwnerRepositoryNumberNodeModuleKeyValue(raw) + if err != nil { + return Kept{}, fmt.Errorf("meshconfig: the held value %s is not one: %w", key, err) + } + if err := CheckKept(v); err != nil { + return Kept{}, err + } + want, err := HeldValueKey(v.Pull, v.Node, v.Module, v.Key) + if err != nil { + return Kept{}, err + } + if want != key { + return Kept{}, fmt.Errorf("meshconfig: the held value kept under %s names %s", key, want) + } + return v, nil +} + +// ValueOf is the value held, as the operator approved it, in its JSON form. +func ValueOf(v Kept) (json.RawMessage, error) { return json.Marshal(v.Value) } diff --git a/go/clients/meshconfigclient/client_test.go b/go/clients/meshconfigclient/client_test.go new file mode 100644 index 0000000..ede862f --- /dev/null +++ b/go/clients/meshconfigclient/client_test.go @@ -0,0 +1,45 @@ +package meshconfigclient + +import ( + "strings" + "testing" +) + +// The keys are the earlier form of go/delivery's HeldValueKey and ConfigCandidateKey (golden), which the holder writes. +func TestKeysAreTheEarlierForm(t *testing.T) { + if k, err := HeldValueKey("novox/mesh-catalog#42", "novox", "mail", "quota"); k != "novox.mesh-catalog.42.novox.mail.quota" || err != nil { + t.Errorf("a held value's key is %q, %v", k, err) + } + for pull, want := range map[string]string{"novox/mesh-catalog#42": "novox.mesh-catalog.42", "x#1": "", "a/b": "", "a/b.c#1": ""} { + if got, err := PullKey(pull); got != want || (want == "") != (err != nil) { + t.Errorf("%s keyed %q, %v", pull, got, err) + } + } + if _, err := HeldValueKey("novox/mesh-catalog#42", "", "mail", "quota"); err == nil { + t.Error("a held value without a node was keyed") + } +} + +const held = `{"pull":"novox/mesh-catalog#434","head":"abc1234def","declaration":"sha256:3a7b","node":"ace","module":"n8n","key":"media-gid","value":2000,"by":"the operator","approved":"operator@telegram","at":"2026-11-05T12:30:00Z"}` + +func TestAHeldValueIsTheOneItsKeyNames(t *testing.T) { + key := "novox.mesh-catalog.434.ace.n8n.media-gid" + v, err := DecodeKept(key, []byte(held)) + if err != nil || v.Node != "ace" || v.Value != float64(2000) { + t.Fatalf("read %+v, %v", v, err) + } + if raw, err := ValueOf(v); err != nil || string(raw) != "2000" { + t.Errorf("the value is %s, %v", raw, err) + } + for name, c := range map[string]struct{ key, raw string }{ + "another pull request's key": {"novox.mesh-catalog.435.ace.n8n.media-gid", held}, + "another module's key": {"novox.mesh-catalog.434.ace.plex.media-gid", held}, + "no head": {key, strings.Replace(held, `"head":"abc1234def",`, "", 1)}, + "a field it does not know": {key, strings.Replace(held, `"by"`, `"extra":1,"by"`, 1)}, + "a pull that is not one": {key, strings.Replace(held, "novox/mesh-catalog#434", "novox", 1)}, + } { + if _, err := DecodeKept(c.key, []byte(c.raw)); err == nil { + t.Errorf("%s was read", name) + } + } +} diff --git a/go/clients/meshconfigclient/go.mod b/go/clients/meshconfigclient/go.mod new file mode 100644 index 0000000..fc30b01 --- /dev/null +++ b/go/clients/meshconfigclient/go.mod @@ -0,0 +1,5 @@ +module git.novox.be/novox/mesh-contracts/go/clients/meshconfigclient + +go 1.26.0 + +require git.novox.be/novox/mesh-contracts/go/seats/meshconfig v0.0.0-20261105000000-000000000000 diff --git a/go/seats/meshconfig/contract.go b/go/seats/meshconfig/contract.go index 3c126d1..f7107e2 100644 --- a/go/seats/meshconfig/contract.go +++ b/go/seats/meshconfig/contract.go @@ -8,6 +8,7 @@ import ( "encoding/json" "errors" "strings" + "time" ) // Seat is the seat's name. @@ -47,6 +48,7 @@ func HolderSubjectRetired(holder string) string { return "mesh.stream." + holder const ( StateResolved = "resolved" + StateKept = "kept" ) // ResolvedByNodeModuleKeyKey is the key of one value of the state resolved under the form "..". @@ -92,6 +94,64 @@ func DecodeResolvedByNodeModuleKeyValue(raw []byte) (ResolvedByNodeModuleKeyValu return v, nil } +// KeptByOwnerRepositoryNumberNodeModuleKeyKey is the key of one value of the state kept under the form ".....". +func KeptByOwnerRepositoryNumberNodeModuleKeyKey(owner, repository, number, node, module, key string) string { + return owner + "." + repository + "." + number + "." + node + "." + module + "." + key +} + +// ParseKeptByOwnerRepositoryNumberNodeModuleKeyKey reads the parts of a key of the form ".....", in the order of the form; the last result is false for a key of another form. +func ParseKeptByOwnerRepositoryNumberNodeModuleKeyKey(key string) (string, string, string, string, string, string, bool) { + rest := key + v0, after0, found0 := strings.Cut(rest, ".") + if !found0 || v0 == "" { + return "", "", "", "", "", "", false + } + rest = after0 + v2, after2, found2 := strings.Cut(rest, ".") + if !found2 || v2 == "" { + return "", "", "", "", "", "", false + } + rest = after2 + v4, after4, found4 := strings.Cut(rest, ".") + if !found4 || v4 == "" { + return "", "", "", "", "", "", false + } + rest = after4 + v6, after6, found6 := strings.Cut(rest, ".") + if !found6 || v6 == "" { + return "", "", "", "", "", "", false + } + rest = after6 + v8, after8, found8 := strings.Cut(rest, ".") + if !found8 || v8 == "" { + return "", "", "", "", "", "", false + } + rest = after8 + if rest == "" { + return "", "", "", "", "", "", false + } + v10 := rest + rest = "" + if rest != "" { + return "", "", "", "", "", "", false + } + return v0, v2, v4, v6, v8, v10, true +} + +// DecodeKeptByOwnerRepositoryNumberNodeModuleKeyValue reads one value of the state kept under the form ".....", refusing a field KeptByOwnerRepositoryNumberNodeModuleKeyValue does not know and any text after the value. +func DecodeKeptByOwnerRepositoryNumberNodeModuleKeyValue(raw []byte) (KeptByOwnerRepositoryNumberNodeModuleKeyValue, error) { + var v KeptByOwnerRepositoryNumberNodeModuleKeyValue + dec := json.NewDecoder(bytes.NewReader(raw)) + dec.DisallowUnknownFields() + if err := dec.Decode(&v); err != nil { + return v, err + } + if dec.More() { + return v, errors.New("text after the value") + } + return v, nil +} + // ConfigInput is what config takes. type ConfigInput struct { // list (when not given), set, clear or history @@ -197,3 +257,29 @@ type ResolvedByNodeModuleKeyValue struct { Trusted bool `json:"trusted,omitempty"` Value any `json:"value,omitempty"` } + +// KeptByOwnerRepositoryNumberNodeModuleKeyValue is one value of the state kept under the key ...... +type KeptByOwnerRepositoryNumberNodeModuleKeyValue struct { + // who approved it, as the proposal kept it + Approved string `json:"approved,omitempty"` + // when it was kept + At *time.Time `json:"at,omitempty"` + By string `json:"by,omitempty"` + // the digest of what the declaration holds the value to; the value stays only while it is the same + Declaration string `json:"declaration"` + // the head the declaration was read at + Head string `json:"head"` + // the config key + Key string `json:"key"` + Module string `json:"module"` + Node string `json:"node"` + // the proposal's id + Proposal string `json:"proposal,omitempty"` + // the pull request, owner/repository#number + Pull string `json:"pull"` + // the value held, as the operator approved it + Value any `json:"value"` + // where the value was proposed from + Via string `json:"via,omitempty"` + Why string `json:"why,omitempty"` +} diff --git a/go/seats/meshconfig/contract_test.go b/go/seats/meshconfig/contract_test.go index 7a994e6..fed1bff 100644 --- a/go/seats/meshconfig/contract_test.go +++ b/go/seats/meshconfig/contract_test.go @@ -37,6 +37,12 @@ func TestFixtures(t *testing.T) { err := json.Unmarshal(b, &v) return v, err }, subjectIs: nil}, + {name: "a held value, as mesh-config writes it (the holder's own test values)", subject: "", given: "{\"approved\":\"operator@telegram\",\"at\":\"2026-11-05T12:30:00Z\",\"by\":\"the operator\",\"declaration\":\"sha256:3a7bd3e2360a3d29eea436fcfb7e44c735d117c42d1c1835420b6b9942dd4f1b\",\"head\":\"abc1234def\",\"key\":\"media-gid\",\"module\":\"n8n\",\"node\":\"ace\",\"proposal\":\"p-1\",\"pull\":\"novox/mesh-catalog#434\",\"value\":2000,\"via\":\"telegram\",\"why\":\"the group the media share is owned by\"}", wire: "{\"approved\":\"operator@telegram\",\"at\":\"2026-11-05T12:30:00Z\",\"by\":\"the operator\",\"declaration\":\"sha256:3a7bd3e2360a3d29eea436fcfb7e44c735d117c42d1c1835420b6b9942dd4f1b\",\"head\":\"abc1234def\",\"key\":\"media-gid\",\"module\":\"n8n\",\"node\":\"ace\",\"proposal\":\"p-1\",\"pull\":\"novox/mesh-catalog#434\",\"value\":2000,\"via\":\"telegram\",\"why\":\"the group the media share is owned by\"}", + decode: func(b []byte) (any, error) { + var v KeptByOwnerRepositoryNumberNodeModuleKeyValue + err := json.Unmarshal(b, &v) + return v, err + }, subjectIs: nil}, {name: "renames answers the open windows", subject: "", given: "{\"renames\":[{\"at\":\"2026-11-04T12:00:00Z\",\"from\":\"old-key\",\"module\":\"messenger\",\"open\":[\"g14\"],\"to\":\"new-key\"}]}", wire: "{\"renames\":[{\"at\":\"2026-11-04T12:00:00Z\",\"from\":\"old-key\",\"module\":\"messenger\",\"open\":[\"g14\"],\"to\":\"new-key\"}]}", decode: func(b []byte) (any, error) { var v RenamesAnswer; err := json.Unmarshal(b, &v); return v, err }, subjectIs: nil}, } diff --git a/module.json b/module.json index ca90d2c..1a93c8e 100644 --- a/module.json +++ b/module.json @@ -4,6 +4,15 @@ "slug": "contracts", "build": { "artifacts": [ + { + "name": "go-meshconfigclient", + "kind": "package", + "language": "go", + "paths": [ + "go/clients/meshconfigclient" + ], + "series": "0.1" + }, { "name": "go-nodeengineclient", "kind": "package", diff --git a/seats/mesh-config/fixtures/states.json b/seats/mesh-config/fixtures/states.json index ad3d582..edc2be3 100644 --- a/seats/mesh-config/fixtures/states.json +++ b/seats/mesh-config/fixtures/states.json @@ -21,5 +21,25 @@ "source": "module", "value": 500 } + }, + { + "name": "a held value, as mesh-config writes it (the holder's own test values)", + "of": "state:kept/.....", + "why": "no tool reads the state kept; the values are the holder's test (modules/mesh-config pulls_test.go): key novox.mesh-catalog.434.ace.n8n.media-gid", + "given": { + "pull": "novox/mesh-catalog#434", + "head": "abc1234def", + "declaration": "sha256:3a7bd3e2360a3d29eea436fcfb7e44c735d117c42d1c1835420b6b9942dd4f1b", + "node": "ace", + "module": "n8n", + "key": "media-gid", + "value": 2000, + "by": "the operator", + "via": "telegram", + "why": "the group the media share is owned by", + "approved": "operator@telegram", + "proposal": "p-1", + "at": "2026-11-05T12:30:00Z" + } } ] diff --git a/seats/mesh-config/seat.json b/seats/mesh-config/seat.json index 83c63f3..9a555b1 100644 --- a/seats/mesh-config/seat.json +++ b/seats/mesh-config/seat.json @@ -267,6 +267,80 @@ } ], "description": "the value each module on each node takes for each config key, built from the sources alone" + }, + { + "name": "kept", + "template": "state", + "key-forms": [ + { + "key": ".....", + "value": { + "type": "object", + "description": "an approved held value, kept apart from the values mesh-config resolves, laid only over the composition of its own pull request at its head (ADR 0565 clause 5)", + "properties": { + "pull": { + "type": "string", + "description": "the pull request, owner/repository#number" + }, + "head": { + "type": "string", + "description": "the head the declaration was read at" + }, + "declaration": { + "type": "string", + "description": "the digest of what the declaration holds the value to; the value stays only while it is the same" + }, + "node": { + "type": "string" + }, + "module": { + "type": "string" + }, + "key": { + "type": "string", + "description": "the config key" + }, + "value": { + "description": "the value held, as the operator approved it" + }, + "by": { + "type": "string" + }, + "via": { + "type": "string", + "description": "where the value was proposed from" + }, + "why": { + "type": "string" + }, + "approved": { + "type": "string", + "description": "who approved it, as the proposal kept it" + }, + "proposal": { + "type": "string", + "description": "the proposal's id" + }, + "at": { + "type": "string", + "format": "date-time", + "description": "when it was kept" + } + }, + "required": [ + "pull", + "head", + "declaration", + "node", + "module", + "key", + "value" + ] + }, + "description": "one held value: the pull request (owner, repository and number, none holding a dot), the node, the module and the config key, which may hold dots" + } + ], + "description": "the approved held values of open pull requests, read by the checker and the assigner's composition check" } ] } diff --git a/ts/seats/meshconfig/src/index.ts b/ts/seats/meshconfig/src/index.ts index e8fa023..c5e0a15 100644 --- a/ts/seats/meshconfig/src/index.ts +++ b/ts/seats/meshconfig/src/index.ts @@ -34,6 +34,7 @@ export function retiredHolderSubject(holder: string): string { export const states = { resolved: "resolved", + kept: "kept", } as const; /** The key of one value of the state resolved under the form "..". */ @@ -59,6 +60,41 @@ export function parseResolvedByNodeModuleKeyKey(key: string): { node: string; mo return { node, module, key }; } +/** The key of one value of the state kept under the form ".....". */ +export function keptByOwnerRepositoryNumberNodeModuleKeyKey(owner: string, repository: string, number: string, node: string, module: string, key: string): string { + return owner + "." + repository + "." + number + "." + node + "." + module + "." + key; +} + +/** The parts of a key of the form ".....", or undefined for a key of another form. */ +export function parseKeptByOwnerRepositoryNumberNodeModuleKeyKey(key: string): { owner: string; repository: string; number: string; node: string; module: string; key: string } | undefined { + let rest = key; + const at0 = rest.indexOf("."); + if (at0 <= 0) return undefined; + const owner = rest.slice(0, at0); + rest = rest.slice(at0 + 1); + const at2 = rest.indexOf("."); + if (at2 <= 0) return undefined; + const repository = rest.slice(0, at2); + rest = rest.slice(at2 + 1); + const at4 = rest.indexOf("."); + if (at4 <= 0) return undefined; + const number = rest.slice(0, at4); + rest = rest.slice(at4 + 1); + const at6 = rest.indexOf("."); + if (at6 <= 0) return undefined; + const node = rest.slice(0, at6); + rest = rest.slice(at6 + 1); + const at8 = rest.indexOf("."); + if (at8 <= 0) return undefined; + const module = rest.slice(0, at8); + rest = rest.slice(at8 + 1); + if (rest === "") return undefined; + const key = rest; + rest = ""; + if (rest !== "") return undefined; + return { owner, repository, number, node, module, key }; +} + /** is what config takes. */ export interface ConfigInput { /** list (when not given), set, clear or history */ @@ -158,3 +194,29 @@ export interface ResolvedByNodeModuleKeyValue { "value"?: unknown; } +/** is one value of the state kept under the key ...... */ +export interface KeptByOwnerRepositoryNumberNodeModuleKeyValue { + /** who approved it, as the proposal kept it */ + "approved"?: string; + /** when it was kept */ + "at"?: string; + "by"?: string; + /** the digest of what the declaration holds the value to; the value stays only while it is the same */ + "declaration": string; + /** the head the declaration was read at */ + "head": string; + /** the config key */ + "key": string; + "module": string; + "node": string; + /** the proposal's id */ + "proposal"?: string; + /** the pull request, owner/repository#number */ + "pull": string; + /** the value held, as the operator approved it */ + "value": unknown; + /** where the value was proposed from */ + "via"?: string; + "why"?: string; +} +