mesh-config seat: the state kept typed (an approved held value, under owner.repository.number.node.module.key), a fixture from the holder's test values, regenerated; a client meshconfigclient with the keys and the check the checker and the assigner share (B2f; ADR 0565 §5, ADR 0634 §4; issue 1966)
Claude-Session: https://claude.ai/code/session_01NcbgfFSnn24M3GcqttoU7i
This commit is contained in:
@@ -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) }
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
@@ -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 "<node>.<module>.<key>".
|
||||
@@ -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 "<owner>.<repository>.<number>.<node>.<module>.<key>".
|
||||
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 "<owner>.<repository>.<number>.<node>.<module>.<key>", 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 "<owner>.<repository>.<number>.<node>.<module>.<key>", 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 <owner>.<repository>.<number>.<node>.<module>.<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"`
|
||||
}
|
||||
|
||||
@@ -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},
|
||||
}
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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/<owner>.<repository>.<number>.<node>.<module>.<key>",
|
||||
"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"
|
||||
}
|
||||
}
|
||||
]
|
||||
|
||||
@@ -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": "<owner>.<repository>.<number>.<node>.<module>.<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"
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
@@ -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 "<node>.<module>.<key>". */
|
||||
@@ -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 "<owner>.<repository>.<number>.<node>.<module>.<key>". */
|
||||
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 "<owner>.<repository>.<number>.<node>.<module>.<key>", 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 <owner>.<repository>.<number>.<node>.<module>.<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;
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user