Registry milestone: mesh-contracts (issue 1966) #1

Merged
mesh-tools merged 99 commits from feat/registry-milestone into main 2026-11-07 14:19:17 +00:00
253 changed files with 38212 additions and 1 deletions
+38 -1
View File
@@ -1,5 +1,42 @@
# mesh-contracts
Novox Mesh — contracts: every seat's definition (verbs, events, fixtures) and the Go and TypeScript packages generated from them (ADR 0613).
The mesh's seat contracts (novox/hq ADR 0608 §5, ADR 0613, ADR 0620, ADR 0625, ADR 0627; design 26 *A seat's
definition*): one language-neutral definition per seat, the Go and TypeScript code generated from it, and the client
of a seat whose callers share logic over it. This repository holds contracts only and no seat: a seat's holder
implements its contract, and no holder's code lives here.
## Where a seat's code goes
- **The definition**, written by hand: `seats/<seat>/seat.json`, its fixtures in `seats/<seat>/fixtures/`, and the
parts several seats include whole in `parts/` (none here now: the registry seats' part is mesh-delivery-contracts').
mesh-sdk's `seatdef` is the one reader of the format; the indexer and the generators use it.
- **A state**, in the definition's `states` (ADR 0608 §1, ADR 0634 §6): `{name, template, writer, key-forms, history,
max-age-days, description}`. `template` is one of ADR 0595's six (`state`, `events`, `work-queue`, `log`, `objects`,
`ledger`); `writer` is `holder` (the default) or `caller`; each key form is `{key, value, description}`, the key a
pattern of named parts between angle brackets (`<asker>.<id>`) and the value its JSON Schema. A definition names no
holder module: a reader takes the holder from its own manifest's `reads`. A fixture of a state is `state:<name>/<key>`.
- **The seat set**, generated: `go/seats` (module `git.novox.be/novox/mesh-contracts/go`), every seat's name, scope,
verbs and provision at the seat list's grain (ADR 0625 §2). mesh-sdk's `catalogue` is its only importer in mesh-sdk.
A change of it rebuilds every importer of the seat set. Its npm package is `ts/` (`@novox/mesh-contracts`, the
seat list alone).
- **A seat defined whole**, generated: `go/seats/<seat without hyphens>`, a Go module of its own importing nothing,
holding the seat's verbs, events and subjects, its message types and a test of its fixtures (ADR 0625 §1). A module
imports only the seats it uses, so a change of one seat rebuilds only its users. Its npm package is
`ts/seats/<seat without hyphens>` (`@novox/mesh-contracts-<seat>`), holding the parts it includes whole: the indexer
counts the npm package a lock resolves, so here too a module is rebuilt only for the seats it uses.
- **A seat's client**, written by hand: `go/clients/<seat without hyphens>client`, a Go module of its own, only where
two or more callers share logic over the seat (ADR 0625 §4, ADR 0627 §1): operator-channel's (checking an ask,
its digest, a warrant's, and an asking module's keeping of its asks), node-engine's (checking compose-candidate's ask
and answer). A client imports its own seat's contract and mesh-sdk's runtime, never another seat's client, and names
no module's subject or tool.
- **Not here**: the value layouts of seats not yet defined whole (they stay in mesh-sdk until their domain moves,
ADR 0625 §9), and a role only one module could ever play (that module's own tools and events, ADR 0625 §8).
## How it is checked
Every file under `go/` and `ts/` outside `go/clients/` is written by the generator (seatgen, the source of the Go and
Node build modules' generate step) and compared on every build: a hand-written file there fails, named (ADR 0625 §5).
`seatgen -check` (mesh-catalog `modules/mesh-build-go/buildstep/cmd/seatgen`) also runs the client check (each client is for a seat defined whole, a module of its own, importing
only its seat's contract and mesh-sdk) and checks `module.json`'s Go and npm package artifacts against the tree.
Made by the mesh from the gitea module's config value `repositories` (novox/hq ADR 0428). Main takes no push: a change reaches it by a pull request whose `mesh/check` passed: the checker's verdict on its head merged onto main, its tests run by their language's convention (novox/hq ADR 0447, ADR 0445).
+490
View File
@@ -0,0 +1,490 @@
{
"$schema": "https://json-schema.org/draft/2020-12/schema",
"$id": "https://git.novox.be/novox/mesh-contracts/format/seat-definition.schema.json",
"title": "A seat's definition (novox/hq ADR 0608 \u00a71, design 26), format 1",
"description": "One file per seat, seats/<seat>/seat.json. A whole definition (grain whole) gives every verb's input and answer, every message's body and where its events are kept; one at the seat list's grain (seat-list) holds what mesh-sdk's compiled seat list held (ADR 0613 \u00a72). At most 256 KiB. Read strictly: a field not named here is refused.",
"type": "object",
"additionalProperties": false,
"required": [
"format",
"seat",
"scope",
"grain",
"domain",
"decision"
],
"properties": {
"format": {
"const": 1
},
"seat": {
"type": "string",
"pattern": "^[a-z][a-z0-9-]*[a-z0-9]$"
},
"scope": {
"enum": [
"mesh",
"node"
]
},
"grain": {
"enum": [
"whole",
"seat-list"
]
},
"domain": {
"description": "the glossary domain the seat belongs to, by its heading (ADR 0636 \u00a75)",
"enum": [
"Module",
"Config",
"Core",
"Provisioning",
"Identity and access",
"Delivery",
"Health and repair",
"Data",
"Connectivity",
"Operator and conversation"
]
},
"decision": {
"type": "string",
"minLength": 1
},
"description": {
"type": "string"
},
"holder-declares": {
"type": "boolean",
"description": "the seat is not in the compiled seat list; its holder's manifest declares it"
},
"delivers": {
"type": "string"
},
"replicated": {
"type": "boolean"
},
"pulled-by-kind": {
"type": "boolean"
},
"yields": {
"type": "boolean"
},
"held-by-the-engine": {
"type": "boolean"
},
"includes": {
"type": "array",
"items": {
"type": "string",
"pattern": "^[a-z][a-z0-9-]*$"
},
"description": "parts/<name>.json taken in whole"
},
"verbs": {
"type": "array",
"items": {
"$ref": "#/$defs/verb"
}
},
"accepts": {
"type": "array",
"items": {
"$ref": "#/$defs/message"
}
},
"events": {
"type": "array",
"items": {
"$ref": "#/$defs/message"
},
"description": "published on mesh.seat.<seat>.event.<name>"
},
"family": {
"type": "object",
"additionalProperties": false,
"required": [
"name",
"max-age-days"
],
"properties": {
"name": {
"type": "string"
},
"max-age-days": {
"const": 7,
"description": "the shared events stream's age: a seat's events ride it, filtered to the seat's subjects"
}
}
},
"holder-events": {
"type": "array",
"items": {
"type": "object",
"additionalProperties": false,
"required": [
"name",
"subject"
],
"properties": {
"name": {
"type": "string",
"pattern": "^[a-z][a-z0-9-]*(\\.[a-z][a-z0-9-]*)*$"
},
"subject": {
"type": "string"
},
"family": {
"type": "string"
},
"body": {
"type": "object",
"description": "a JSON Schema"
}
}
},
"description": "the holder's own events listed beside the seat, keeping the holder's subjects (ADR 0620 \u00a72)"
},
"states": {
"type": "array",
"description": "the layouts of the state the holder keeps on the bus for the seat (ADR 0608 \u00a71, ADR 0634 \u00a76); a whole definition's. No holder module is named: each reader takes the holder from its own manifest's reads",
"items": {
"$ref": "#/$defs/state"
}
},
"provisions": {
"type": "array",
"items": {
"type": "object",
"additionalProperties": false,
"required": [
"name",
"form"
],
"properties": {
"name": {
"type": "string"
},
"form": {
"type": "object",
"description": "a JSON Schema"
}
}
}
},
"receives": {
"type": "array",
"items": {
"type": "object",
"additionalProperties": false,
"required": [
"kind"
],
"properties": {
"kind": {
"type": "string"
},
"comment": {
"type": "string"
},
"dirs": {
"type": "boolean"
},
"offered": {
"type": "boolean"
},
"shape": {
"type": "object",
"additionalProperties": false,
"properties": {
"places": {
"type": "array",
"items": {
"type": "object",
"additionalProperties": false,
"required": [
"field"
],
"properties": {
"field": {
"type": "string"
},
"values": {
"type": "array",
"items": {
"type": "string"
}
}
}
}
},
"order": {
"type": "string"
},
"examples": {
"type": "array",
"items": {
"type": "object"
}
},
"text": {
"type": "boolean"
}
}
}
}
}
},
"paths": {
"type": "array",
"items": {
"type": "object",
"additionalProperties": false,
"required": [
"path"
],
"properties": {
"path": {
"type": "string",
"pattern": "^/"
},
"steps-back-for": {
"type": "string"
}
}
}
},
"defs": {
"type": "object",
"additionalProperties": {
"type": "object",
"description": "a JSON Schema"
}
}
},
"$defs": {
"verb": {
"type": "object",
"additionalProperties": false,
"required": [
"name"
],
"properties": {
"name": {
"type": "string",
"pattern": "^[a-z][a-z0-9-]*$"
},
"description": {
"type": "string"
},
"optional": {
"type": "boolean"
},
"replaces": {
"type": "array",
"items": {
"type": "string"
}
},
"input": {
"type": "object",
"description": "a JSON Schema"
},
"answer": {
"type": "object",
"description": "a JSON Schema"
},
"callers": {
"type": "string"
}
}
},
"message": {
"type": "object",
"additionalProperties": false,
"required": [
"name"
],
"properties": {
"name": {
"type": "string",
"pattern": "^[a-z][a-z0-9-]*$"
},
"body": {
"type": "object",
"description": "a JSON Schema"
},
"by-caller": {
"type": "boolean"
}
}
},
"part": {
"description": "parts/<name>.json",
"type": "object",
"additionalProperties": false,
"required": [
"format",
"part"
],
"properties": {
"format": {
"const": 1
},
"part": {
"type": "string",
"pattern": "^[a-z][a-z0-9-]*$"
},
"description": {
"type": "string"
},
"verbs": {
"type": "array",
"items": {
"$ref": "#/$defs/verb"
}
},
"events": {
"type": "array",
"items": {
"$ref": "#/$defs/message"
}
},
"defs": {
"type": "object",
"additionalProperties": {
"type": "object",
"description": "a JSON Schema"
}
}
}
},
"fixtures": {
"description": "seats/<seat>/fixtures/*.json: data, never functions",
"type": "array",
"items": {
"type": "object",
"additionalProperties": false,
"required": [
"name",
"of",
"given"
],
"properties": {
"name": {
"type": "string"
},
"of": {
"type": "string",
"pattern": "^(verb:[a-z][a-z0-9-]*\\.(input|answer)|accept:[a-z][a-z0-9-]*|event:[a-z][a-z0-9-]*|state:[a-z][a-z0-9-]*/.+|holder-event:[a-z][a-z0-9-]*(\\.[a-z][a-z0-9-]*)*)$"
},
"subject": {
"type": "string"
},
"given": {},
"wire": {},
"why": {
"type": "string"
}
}
}
},
"index": {
"description": "seats/index.json: the seat list's reading order",
"type": "object",
"additionalProperties": false,
"required": [
"format",
"seats"
],
"properties": {
"format": {
"const": 1
},
"seats": {
"type": "array",
"items": {
"type": "string"
},
"uniqueItems": true
}
}
},
"state": {
"type": "object",
"additionalProperties": false,
"required": [
"name",
"template",
"key-forms"
],
"properties": {
"name": {
"type": "string",
"pattern": "^[a-z][a-z0-9-]*$"
},
"template": {
"enum": [
"state",
"events",
"work-queue",
"log",
"objects",
"ledger"
],
"description": "ADR 0595 \u00a72"
},
"writer": {
"enum": [
"holder",
"caller"
],
"description": "holder when not given; caller when the seat's callers write their own keys"
},
"key-forms": {
"type": "array",
"minItems": 1,
"items": {
"$ref": "#/$defs/keyForm"
}
},
"history": {
"type": "integer",
"minimum": 0,
"description": "past values a key keeps; the state template only"
},
"max-age-days": {
"type": "integer",
"minimum": 0,
"description": "days a message is kept; the events, work-queue and log templates only"
},
"description": {
"type": "string"
}
}
},
"keyForm": {
"type": "object",
"additionalProperties": false,
"required": [
"key",
"value"
],
"properties": {
"key": {
"type": "string",
"minLength": 1,
"description": "a pattern of named parts between angle brackets, the rest literal: <asker>.<id>"
},
"value": {
"type": "object",
"description": "a JSON Schema"
},
"description": {
"type": "string"
}
}
}
}
}
+83
View File
@@ -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)
}
}
}
+5
View File
@@ -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-00010101000000-000000000000
@@ -0,0 +1,292 @@
// Package meshmoduleassignerclient is the logic every module that reads where modules are placed shares over the seat
// mesh-module-assigner (novox/hq ADR 0567, ADR 0634 §4, ADR 0625 §4): the keys of its state `assignments` assembled into a
// node's assignments, who stands on which node since when, who holds a seat, and what a assignments says to the composition.
// The layouts are the seat's contract's (package meshmoduleassigner); the check of a proposal is the assigner's own.
package meshmoduleassignerclient
import (
"crypto/sha256"
"encoding/hex"
"encoding/json"
"errors"
"fmt"
"regexp"
"sort"
"strconv"
"strings"
seat "git.novox.be/novox/mesh-contracts/go/seats/meshmoduleassigner"
"git.novox.be/novox/mesh-sdk/go/catalogue"
"git.novox.be/novox/mesh-sdk/go/keys"
)
// Version is the version of every value of the seat's states.
const Version = keys.Version
// NodeWord is the last word of a node key, `<node>.node`; no module is named it.
const NodeWord = "node"
const digestLen = 16
// ErrUnknownVersion is what a decode returns for a value whose version is missing or not Version.
var ErrUnknownVersion = errors.New("meshmoduleassignerclient: unknown value version")
var nameOK = regexp.MustCompile(`^[-/_=a-zA-Z0-9]+$`)
func checkName(kind, name string) error {
if name == "" {
return fmt.Errorf("meshmoduleassignerclient: the %s name is empty", kind)
}
if !nameOK.MatchString(name) {
return fmt.Errorf("meshmoduleassignerclient: the %s name %q holds a dot, a wildcard, whitespace or a character a key refuses", kind, name)
}
return nil
}
func checkVersion(data []byte) error {
var probe struct {
V *int `json:"v"`
}
if err := json.Unmarshal(data, &probe); err != nil {
return fmt.Errorf("meshmoduleassignerclient: value is not JSON: %w", err)
}
if probe.V == nil {
return fmt.Errorf("%w: no version in the value, this reader reads %d", ErrUnknownVersion, Version)
}
if *probe.V != Version {
return fmt.Errorf("%w: the value is version %d, this reader reads %d", ErrUnknownVersion, *probe.V, Version)
}
return nil
}
// CheckAssignments refuses a assignments that names a node or module that is not a name, a module twice, a port that is not
// a number, a binding or pin naming no provider, and a holding by a module not placed on the node.
func CheckAssignments(p seat.Assignments) error {
if err := checkName("node", p.Node); err != nil {
return err
}
seen := map[string]bool{}
for _, m := range p.Modules {
if err := checkName("module", m.Module); err != nil {
return err
}
if seen[m.Module] {
return fmt.Errorf("meshmoduleassignerclient: the assignments of %s names %s twice", p.Node, m.Module)
}
seen[m.Module] = true
for port, at := range m.Ports {
if n, err := strconv.Atoi(port); err != nil || n <= 0 || n > 65535 {
return fmt.Errorf("meshmoduleassignerclient: %s's port %q on %s is not a port number", m.Module, port, p.Node)
}
if at <= 0 || at > 65535 {
return fmt.Errorf("meshmoduleassignerclient: %s's port %s on %s is published on %d, which is not a port number", m.Module, port, p.Node, at)
}
}
for provision, to := range m.Bindings {
if provision == "" || to.Node == "" || to.Module == "" {
return fmt.Errorf("meshmoduleassignerclient: %s on %s has a binding with no provision, node or module", m.Module, p.Node)
}
}
}
for provision, to := range p.Pins {
if provision == "" || to.Node == "" || to.Module == "" {
return fmt.Errorf("meshmoduleassignerclient: the assignments of %s has a pin with no provision, node or module", p.Node)
}
}
for _, h := range p.Holds {
if h.Claim == "" || !seen[h.Module] {
return fmt.Errorf("meshmoduleassignerclient: the assignments of %s has %s hold %q, which is not a module placed there or names no seat", p.Node, h.Module, h.Claim)
}
}
return nil
}
// EncodeAssignments is p as JSON at Version; it refuses what DecodeAssignments refuses.
func EncodeAssignments(p seat.Assignments) ([]byte, error) {
if err := CheckAssignments(p); err != nil {
return nil, err
}
p.V = Version
return json.Marshal(p)
}
// DecodeAssignments reads a assignments file or key value (the form before the node and module keys).
func DecodeAssignments(data []byte) (seat.Assignments, error) {
if err := checkVersion(data); err != nil {
return seat.Assignments{}, err
}
var p seat.Assignments
if err := json.Unmarshal(data, &p); err != nil {
return seat.Assignments{}, fmt.Errorf("meshmoduleassignerclient: value does not fit its shape: %w", err)
}
if err := CheckAssignments(p); err != nil {
return seat.Assignments{}, err
}
return p, nil
}
// SameAssignments says whether two assignments are the same value, so the holder writes only when they differ.
func SameAssignments(a, b seat.Assignments) bool {
a.Revision, b.Revision = "", ""
ja, errA := EncodeAssignments(a)
jb, errB := EncodeAssignments(b)
return errA == nil && errB == nil && string(ja) == string(jb)
}
// Assigned is the modules placed on the node, in the file's order.
func Assigned(p seat.Assignments) []string {
out := make([]string, 0, len(p.Modules))
for _, m := range p.Modules {
out = append(out, m.Module)
}
return out
}
// Chosen is p as the composition names a chosen provider.
func Chosen(p seat.Provider) catalogue.Chosen {
return catalogue.Chosen{Node: p.Node, Module: p.Module}
}
// ChosenPins is the node's pins as the composition reads them; nil when it has none.
func ChosenPins(p seat.Assignments) map[string]catalogue.Chosen {
if len(p.Pins) == 0 {
return nil
}
out := make(map[string]catalogue.Chosen, len(p.Pins))
for provision, to := range p.Pins {
out[provision] = Chosen(to)
}
return out
}
// Bound is, by consumer module and provision, where each binding sends it; nil when none.
func Bound(p seat.Assignments) map[string]map[string]catalogue.Chosen {
var out map[string]map[string]catalogue.Chosen
for _, m := range p.Modules {
if len(m.Bindings) == 0 {
continue
}
if out == nil {
out = map[string]map[string]catalogue.Chosen{}
}
out[m.Module] = make(map[string]catalogue.Chosen, len(m.Bindings))
for provision, to := range m.Bindings {
out[m.Module][provision] = Chosen(to)
}
}
return out
}
// HostPorts is, by module and the port it declares, the host port it is published on; nil when none. The check has
// refused a port that is not a number, so none is dropped here.
func HostPorts(p seat.Assignments) map[string]map[int]int {
var out map[string]map[int]int
for _, m := range p.Modules {
if len(m.Ports) == 0 {
continue
}
if out == nil {
out = map[string]map[int]int{}
}
out[m.Module] = make(map[int]int, len(m.Ports))
for port, at := range m.Ports {
n, _ := strconv.Atoi(port)
out[m.Module][n] = int(at)
}
}
return out
}
// Layers is one module's settings layers on the node, as the composition reads them: the whole mesh's layer when it has
// one, then the node's, which carries the module's config values as mesh-config resolved them for this node (values,
// nil when none) beside its assignments; a module with no layer and no value has none. A module in the settings form
// carries its values in the file (MeshWideValues, Values), which then stand for mesh-config's.
func Layers(m seat.PlacedModule, node string, values map[string]any) []catalogue.Layer {
var out []catalogue.Layer
if m.HasMeshWide || len(m.MeshWide) > 0 || len(m.MeshWideValues) > 0 {
mesh := map[string]any{}
for k, v := range m.MeshWideValues {
mesh[k] = v
}
out = append(out, catalogue.Layer{From: catalogue.MeshWideLayer, Values: mesh, Placement: m.MeshWide})
}
if len(m.Values) > 0 {
values = make(map[string]any, len(m.Values))
for k, v := range m.Values {
values[k] = v
}
}
if m.HasLayer || len(m.Layer) > 0 || len(values) > 0 {
if values == nil {
values = map[string]any{}
}
out = append(out, catalogue.Layer{From: node, Values: values, Placement: m.Layer})
}
return out
}
// Held is the node's holdings as the composition reads them.
func Held(p seat.Assignments) []catalogue.Held {
out := make([]catalogue.Held, 0, len(p.Holds))
for _, h := range p.Holds {
out = append(out, catalogue.Held{Claim: h.Claim, Scope: h.Scope, Node: p.Node, Module: h.Module, Site: h.Site, Kind: h.Kind})
}
return out
}
// InsideAddresses is every node's address on the private network, sorted and without repeats.
func InsideAddresses(assignments map[string]seat.Assignments) []string {
seen := map[string]bool{}
out := []string{}
for _, p := range assignments {
if a := strings.TrimSpace(p.Decided.Address); a != "" && !seen[a] {
seen[a] = true
out = append(out, a)
}
}
sort.Strings(out)
return out
}
// digestForm is a placed module as its digest has always been made: the fields in the order the first writer wrote them,
// so a node key written before this client names a digest this client computes the same.
type digestForm struct {
Module string `json:"module"`
Ports map[string]int64 `json:"ports,omitempty"`
Bindings map[string]digestProvider `json:"bindings,omitempty"`
MeshWide map[string]any `json:"mesh-wide,omitempty"`
HasMesh bool `json:"has-mesh-wide,omitempty"`
Layer map[string]any `json:"layer,omitempty"`
HasLayer bool `json:"has-layer,omitempty"`
MeshValues map[string]any `json:"mesh-wide-values,omitempty"`
Values map[string]any `json:"values,omitempty"`
}
type digestProvider struct {
Node string `json:"node"`
Module string `json:"module"`
}
// Digest is the digest of a placed module's value, as a node key names it.
func Digest(m seat.PlacedModule) string {
var bindings map[string]digestProvider
if len(m.Bindings) > 0 {
bindings = make(map[string]digestProvider, len(m.Bindings))
for k, v := range m.Bindings {
bindings[k] = digestProvider{Node: v.Node, Module: v.Module}
}
}
raw, _ := json.Marshal(digestForm{Module: m.Module, Ports: m.Ports, Bindings: bindings, MeshWide: m.MeshWide, HasMesh: m.HasMeshWide,
Layer: m.Layer, HasLayer: m.HasLayer, MeshValues: m.MeshWideValues, Values: m.Values})
sum := sha256.Sum256(raw)
return hex.EncodeToString(sum[:])[:digestLen]
}
func sortedKeys[V any](m map[string]V) []string {
out := make([]string, 0, len(m))
for k := range m {
out = append(out, k)
}
sort.Strings(out)
return out
}
@@ -0,0 +1,236 @@
package meshmoduleassignerclient
import (
"errors"
"fmt"
"reflect"
"strings"
"testing"
seat "git.novox.be/novox/mesh-contracts/go/seats/meshmoduleassigner"
"git.novox.be/novox/mesh-sdk/go/catalogue"
)
func TestAssignmentsClient(t *testing.T) {
t.Run("proposal id", func(t *testing.T) {
for id, ok := range map[string]bool{"p-20261028t183000-1a2b3c": true, "abc": true, "ab": false, "A-bc": false, "-abc": false, "a.bc": false, "": false} {
if (CheckProposalID(id) == nil) != ok {
t.Errorf("CheckProposalID(%q) = %v", id, CheckProposalID(id))
}
}
})
t.Run("key and file", func(t *testing.T) {
// Research 072 row 37: one node key and one key per module, any other refused.
for key, want := range map[string]struct {
node, module string
kind KeyKind
}{"g14.node": {"g14", "", NodeKind}, "g14.app": {"g14", "app", ModuleKind}} {
if n, m, k, err := ParseKey(key); err != nil || n != want.node || m != want.module || k != want.kind {
t.Errorf("ParseKey(%q) = %q, %q, %d, %v", key, n, m, k, err)
}
}
for _, bad := range []string{"want.g14.x", "g14", "g14.a.b", ".node"} {
if _, _, _, err := ParseKey(bad); err == nil {
t.Errorf("ParseKey(%q) took it", bad)
}
}
if k, err := NodeKey("g14"); err != nil || k != "g14.node" {
t.Errorf("NodeKey = %q, %v", k, err)
}
if k, err := ModuleKey("g14", "app"); err != nil || k != "g14.app" {
t.Errorf("ModuleKey = %q, %v", k, err)
}
if k, err := NodeKeys("g14"); err != nil || k != "g14.>" {
t.Errorf("NodeKeys = %q, %v", k, err)
}
for name, err := range map[string]error{"a dotted node": func() error { _, err := NodeKey("a.b"); return err }(),
"a module named node": func() error { _, err := ModuleKey("g14", "node"); return err }()} {
if err == nil {
t.Errorf("%s was taken", name)
}
}
})
g14 := seat.Assignments{Node: "g14", Revision: "abc",
Decided: seat.NodeDecided{Endpoint: "g14.example:51820", Site: "home", Address: "10.77.0.2", Account: "op", Taken: []string{"zsh"}},
Modules: []seat.PlacedModule{
{Module: "zsh"},
{Module: "app", Ports: map[string]int64{"8080": 18080}, Bindings: map[string]seat.Provider{"postgres": {Node: "novox", Module: "postgres"}}},
},
Pins: map[string]seat.Provider{"shell": {Node: "g14", Module: "zsh"}},
Holds: []seat.Holding{{Claim: "node-shell", Scope: "node", Module: "zsh"}},
}
novox := seat.Assignments{Node: "novox", Decided: seat.NodeDecided{Hub: true, Address: "10.77.0.1"}, Modules: []seat.PlacedModule{{Module: "postgres", Ports: map[string]int64{"5432": 5432}}},
Holds: []seat.Holding{{Claim: "mesh-example", Scope: "mesh", Module: "postgres"}}}
t.Run("value", func(t *testing.T) {
raw, err := EncodeAssignments(g14)
if err != nil {
t.Fatal(err)
}
back, err := DecodeAssignments(raw)
if err != nil || back.V != Version || !SameAssignments(back, g14) {
t.Fatalf("round trip: %+v, %v", back, err)
}
moved := g14
moved.Revision = "def"
if !SameAssignments(g14, moved) {
t.Error("a new revision alone counts as a change")
}
if _, err := DecodeAssignments([]byte(`{"v":9,"node":"g14","modules":[]}`)); !errors.Is(err, ErrUnknownVersion) {
t.Errorf("version 9: %v", err)
}
for name, bad := range map[string]string{
"module twice": `{"v":1,"node":"g14","modules":[{"module":"a"},{"module":"a"}]}`,
"port not number": `{"v":1,"node":"g14","modules":[{"module":"a","ports":{"http":80}}]}`,
"host port zero": `{"v":1,"node":"g14","modules":[{"module":"a","ports":{"80":0}}]}`,
"binding no node": `{"v":1,"node":"g14","modules":[{"module":"a","bindings":{"pg":{"module":"pg"}}}]}`,
"pin no module": `{"v":1,"node":"g14","modules":[],"pins":{"pg":{"node":"x"}}}`,
"hold not placed": `{"v":1,"node":"g14","modules":[],"holds":[{"claim":"s","module":"a"}]}`,
"node not a name": `{"v":1,"node":"g.14","modules":[]}`,
"value not shaped": `{"v":1,"node":"g14","modules":{}}`,
} {
if _, err := DecodeAssignments([]byte(bad)); err == nil {
t.Errorf("%s: taken", name)
}
}
})
t.Run("as the composition reads it", func(t *testing.T) {
if got := Assigned(g14); !reflect.DeepEqual(got, []string{"zsh", "app"}) {
t.Errorf("Assigned = %v, want the file's order", got)
}
if got := ChosenPins(g14); !reflect.DeepEqual(got, map[string]catalogue.Chosen{"shell": {Node: "g14", Module: "zsh"}}) {
t.Errorf("ChosenPins = %v", got)
}
if got := Bound(g14); !reflect.DeepEqual(got, map[string]map[string]catalogue.Chosen{"app": {"postgres": {Node: "novox", Module: "postgres"}}}) {
t.Errorf("Bound = %v", got)
}
if got := HostPorts(g14); !reflect.DeepEqual(got, map[string]map[int]int{"app": {8080: 18080}}) {
t.Errorf("HostPorts = %v", got)
}
if got := Held(g14); !reflect.DeepEqual(got, []catalogue.Held{{Claim: "node-shell", Scope: "node", Node: "g14", Module: "zsh"}}) {
t.Errorf("Held = %v", got)
}
layered := seat.PlacedModule{Module: "app", HasMeshWide: true, Layer: map[string]any{"expose": map[string]any{"80": "mesh"}}}
want := []catalogue.Layer{
{From: catalogue.MeshWideLayer, Values: map[string]any{}},
{From: "g14", Values: map[string]any{"mode": "x"}, Placement: map[string]any{"expose": map[string]any{"80": "mesh"}}},
}
if got := Layers(layered, "g14", map[string]any{"mode": "x"}); !reflect.DeepEqual(got, want) {
t.Errorf("Layers = %#v, want %#v", got, want)
}
if got := Layers(seat.PlacedModule{Module: "a"}, "g14", map[string]any{"k": 1}); len(got) != 1 || got[0].From != "g14" {
t.Errorf("values alone make the node's layer: %#v", got)
}
if got := Layers(seat.PlacedModule{Module: "a", HasLayer: true}, "g14", nil); len(got) != 1 || got[0].Values == nil {
t.Errorf("an empty layer of the node's own is kept, with empty values: %#v", got)
}
if got := Layers(seat.PlacedModule{Module: "a"}, "g14", nil); got != nil {
t.Errorf("no layer and no value is no layer: %#v", got)
}
empty := seat.Assignments{Node: "x"}
if ChosenPins(empty) != nil || Bound(empty) != nil || HostPorts(empty) != nil || len(Held(empty)) != 0 {
t.Error("an empty assignments reads as something")
}
})
// Research 072 row 37: a node's assignments as its node key and module keys, written so a reader never takes a part of
// a write,
t.Run("one key per module and node", func(t *testing.T) {
values, err := AssignmentsValues(g14)
if err != nil || len(values) != 3 || values["g14.node"] == nil || values["g14.zsh"] == nil || values["g14.app"] == nil {
t.Fatalf("AssignmentsValues = %v, %v", values, err)
}
got, err := AssignmentsOf(values)
if err != nil || !SameAssignments(got["g14"], g14) || len(got) != 1 {
t.Fatalf("AssignmentsOf = %+v, %v", got, err)
}
if got := InsideAddresses(map[string]seat.Assignments{"g14": g14, "novox": novox, "x": {Node: "x"}, "y": {Node: "y", Decided: seat.NodeDecided{Address: " 10.77.0.1"}}}); strings.Join(got, ",") != "10.77.0.1,10.77.0.2" {
t.Errorf("InsideAddresses = %v", got)
}
keys := NewKeys()
rev := uint64(0)
apply := func(writes []KeyWrite, upTo int) {
t.Helper()
for i, w := range writes {
if i == upTo {
return
}
rev++
if _, err := keys.Put(w.Key, w.Value, rev, w.Value == nil); err != nil {
t.Fatalf("write %d (%s): %v", i, w.Key, err)
}
}
}
// A node written whole onto no keys: its module keys, then its node key.
rev++
first, err := keys.Writes("g14", &g14)
if err != nil || len(first) != 3 || !first[2].Commit {
t.Fatalf("Writes = %+v, %v", first, err)
}
apply(first, -1)
if p, r, ok := keys.Of("g14"); !ok || !SameAssignments(p, g14) || r != rev {
t.Fatalf("Of after the write = %+v, %d, %v", p, r, ok)
}
// A write under way is never read half: app's new port is not read before the node key names it.
after := cloneAssignments(g14)
after.Modules = []seat.PlacedModule{{Module: "app", Ports: map[string]int64{"8080": 18081}}, {Module: "new"}}
after.Holds, after.Pins = nil, nil
writes, err := keys.Writes("g14", &after)
if err != nil {
t.Fatal(err)
}
var kinds []string
for _, w := range writes {
kinds = append(kinds, fmt.Sprintf("%s:%v:%d", w.Key, w.Value != nil, w.Base))
}
if strings.Join(kinds, " ") != "g14.app:true:3 g14.new:true:0 g14.node:true:4 g14.zsh:false:2" {
t.Fatalf("Writes = %v", kinds)
}
apply(writes, 2)
if p, _, _ := keys.Of("g14"); !SameAssignments(p, g14) {
t.Errorf("the module keys written before the node key are read: %+v", p)
}
apply(writes[2:], -1)
if p, _, _ := keys.Of("g14"); !SameAssignments(p, after) {
t.Errorf("the write done is not read whole: %+v", p)
}
// A module key that stays is not written again.
if again, _ := keys.Writes("g14", &after); len(again) != 1 || !again[0].Commit {
t.Errorf("an unchanged assignments writes %+v", again)
}
// Keys that do not agree keep what the node had, and say so.
stray, _ := EncodeModule(seat.AssignmentsByNodeModuleValue{Node: "g14", Placed: seat.PlacedModule{Module: "app"}})
if _, err := keys.Put("g14.app", stray, 99, false); err == nil {
t.Error("a module key the node key does not name the value of was taken silently")
}
if p, _, _ := keys.Of("g14"); !SameAssignments(p, after) {
t.Errorf("a stray module key moved the assignments: %+v", p)
}
// The holder's next write puts the module key right again.
repair, _ := keys.Writes("g14", &after)
if len(repair) != 2 || repair[0].Key != "g14.app" || repair[0].Base != 99 || !repair[1].Commit {
t.Fatalf("the repair writes %+v", repair)
}
apply(repair, -1)
// No assignments: the node key first, then the module keys; none is read after.
gone, _ := keys.Writes("g14", nil)
kinds = nil
for _, w := range gone {
kinds = append(kinds, w.Key)
}
if strings.Join(kinds, " ") != "g14.node g14.app g14.new" {
t.Errorf("Writes(nil) = %v", kinds)
}
apply(gone, 1)
if _, _, ok := keys.Of("g14"); ok {
t.Error("a node key deleted still places its node")
}
if _, err := DecodeNode([]byte(`{"v":1,"node":"g14","modules":[{"module":"a","digest":"x"},{"module":"a","digest":"x"}]}`)); err == nil {
t.Error("a node key naming a module twice reads")
}
})
}
@@ -0,0 +1,8 @@
module git.novox.be/novox/mesh-contracts/go/clients/meshmoduleassignerclient
go 1.26.0
require (
git.novox.be/novox/mesh-contracts/go/seats/meshmoduleassigner v0.0.0-00010101000000-000000000000
git.novox.be/novox/mesh-sdk/go v0.0.0-00010101000000-000000000000
)
@@ -0,0 +1,53 @@
package meshmoduleassignerclient
import (
"encoding/json"
"fmt"
"strings"
seat "git.novox.be/novox/mesh-contracts/go/seats/meshmoduleassigner"
)
// SeatHoldersKey is a seat's key in the state `seat-holders`: `<seat>` for a seat held once for the mesh (node empty),
// `<seat>.<node>` for a node seat on one node.
func SeatHoldersKey(name, node string) (string, error) {
if err := checkName("seat", name); err != nil {
return "", err
}
if node == "" {
return seat.SeatHoldersBySeatKey(name), nil
}
if err := checkName("node", node); err != nil {
return "", err
}
return seat.SeatHoldersBySeatNodeKey(name, node), nil
}
// HoldersOf reads who holds the seat named (on node, for a node seat) from the state's keys as a reader watched them: the
// node's key first, then the seat's. None when nobody holds it.
func HoldersOf(keys map[string][]byte, name, node string) ([]seat.SeatHoldersHoldersItem, error) {
for _, n := range []string{node, ""} {
key, err := SeatHoldersKey(name, n)
if err != nil {
if n == "" {
return nil, err
}
continue
}
raw, ok := keys[key]
if !ok {
continue
}
var sh seat.SeatHolders
if err := json.Unmarshal(raw, &sh); err != nil {
return nil, fmt.Errorf("meshmoduleassignerclient: the holders of %s are not readable: %w", key, err)
}
if sh.V != Version {
return nil, fmt.Errorf("meshmoduleassignerclient: the holders of %s are of version %d, and this reads %d", key, sh.V, Version)
}
return sh.Holders, nil
}
return nil, nil
}
var _ = strings.Cut
@@ -0,0 +1,65 @@
package meshmoduleassignerclient
import (
"testing"
"time"
seat "git.novox.be/novox/mesh-contracts/go/seats/meshmoduleassigner"
)
// The digest a node key names is kept as the first writer made it, so a node key written before this client reads.
func TestDigestIsTheFirstWritersAndHoldersAndStandingRead(t *testing.T) {
m := seat.PlacedModule{Module: "app", Ports: map[string]int64{"8080": 18080}, Bindings: map[string]seat.Provider{"postgres": {Node: "novox", Module: "postgres"}},
HasMeshWide: true, MeshWide: map[string]any{"a": 1.0}, Layer: map[string]any{"expose": "x"}, HasLayer: true,
MeshWideValues: map[string]any{"k": "v"}, Values: map[string]any{"p": 2.0}}
if got := Digest(m); got != "62b3c2796d264c42" {
t.Errorf("Digest = %s, the first writer's is 62b3c2796d264c42", got)
}
keys := map[string][]byte{
"mesh-module-assigner": []byte(`{"v":1,"seat":"mesh-module-assigner","scope":"mesh","holders":[{"module":"mesh-delivery-assigner","node":"novox"}]}`),
"node-example.ace": []byte(`{"v":1,"seat":"node-example","scope":"node","node":"ace","holders":[{"module":"node-example","node":"ace"}]}`),
"mesh-vault": []byte(`{"v":2,"seat":"mesh-vault","scope":"mesh","holders":[]}`),
}
for _, c := range []struct {
seat, node string
want int
bad bool
}{{"mesh-module-assigner", "", 1, false}, {"node-example", "ace", 1, false}, {"node-example", "g14", 0, false}, {"mesh-vault", "", 0, true}} {
got, err := HoldersOf(keys, c.seat, c.node)
if (err != nil) != c.bad || len(got) != c.want {
t.Errorf("HoldersOf(%s, %q) = %v, %v", c.seat, c.node, got, err)
}
}
// A module stands since the revision of its node's key that first named it; the first revision heard stands since
// the zero time.
p := NewPlaced()
t0 := time.Date(2026, 11, 5, 12, 0, 0, 0, time.UTC)
node := func(modules ...string) []byte {
n := seat.AssignmentsByNodeNodeValue{Node: "ace"}
for _, name := range modules {
n.Modules = append(n.Modules, seat.AssignmentsByNodeNodeValueModulesItem{Module: name, Digest: "d"})
}
raw, err := EncodeNode(n)
if err != nil {
t.Fatal(err)
}
return raw
}
if _, changed, err := p.Put("ace.node", node("a"), false, t0); err != nil || len(changed) != 1 {
t.Fatalf("first: %v, %v", changed, err)
}
if _, changed, _ := p.Put("ace.node", node("a", "b"), false, t0.Add(time.Hour)); len(changed) != 1 || changed[0] != "b" {
t.Fatalf("second: %v", changed)
}
if since, ok := p.Since("ace", "a"); !ok || !since.IsZero() {
t.Errorf("a stands since the zero time: %v %v", since, ok)
}
if since, ok := p.Since("ace", "b"); !ok || !since.Equal(t0.Add(time.Hour)) {
t.Errorf("b stands since the second revision: %v %v", since, ok)
}
if got := p.PlacedNodes("b"); len(got) != 1 || got[0] != "ace" {
t.Errorf("PlacedNodes = %v", got)
}
}
+449
View File
@@ -0,0 +1,449 @@
package meshmoduleassignerclient
import (
"encoding/json"
"errors"
"fmt"
"sort"
"strings"
"sync"
seat "git.novox.be/novox/mesh-contracts/go/seats/meshmoduleassigner"
)
// **One key per module and node** (research 072 row 37; issue 774). The seat's state `assignments` keeps a node's assignments
// under the node's own keys, so a node watches `<node>.>` alone and a change of one module writes that module's key:
// `<node>.node`, the node key (what a person decided of the node, its pins and holdings, and the modules placed on it,
// each with the digest of its module key's value), and `<node>.<module>`, a module key. The node key is the commit of a
// write; a reader takes for each module the node key names the value of its module key whose digest the node key names,
// the current one once the node key is written, the previous one while the write is under way, so a reader never
// composes a part of a write.
// KeyKind is the kind of a key of the state `assignments`.
type KeyKind int
// The kinds of key: a node key and a module key.
const (
NodeKind KeyKind = iota + 1
ModuleKind
)
// NodeKey is a node's node key, `<node>.node`.
func NodeKey(node string) (string, error) {
if err := checkAssignmentsNode(node); err != nil {
return "", err
}
return node + "." + NodeWord, nil
}
// ModuleKey is a module's key on a node, `<node>.<module>`; a module named `node` is refused.
func ModuleKey(node, module string) (string, error) {
if err := checkAssignmentsNode(node); err != nil {
return "", err
}
if err := checkAssignmentsModule(module); err != nil {
return "", err
}
return node + "." + module, nil
}
// NodeKeys is the pattern of every key of one node, `<node>.>`: what a node watches of its own assignments.
func NodeKeys(node string) (string, error) {
if err := checkAssignmentsNode(node); err != nil {
return "", err
}
return node + ".>", nil
}
// ParseKey reads a key of the state `assignments` its node, its module (empty for a node key) and its kind. Any other key is refused, naming it.
func ParseKey(key string) (node, module string, kind KeyKind, err error) {
first, rest, ok := strings.Cut(key, ".")
if !ok || strings.Contains(rest, ".") || checkName("node", first) != nil || checkName("module", rest) != nil {
return "", "", 0, fmt.Errorf("meshmoduleassignerclient: %q is not a assignments key, <node>.node or <node>.<module>", key)
}
switch {
case rest == NodeWord:
return first, "", NodeKind, nil
default:
return first, rest, ModuleKind, nil
}
}
func checkAssignmentsNode(node string) error {
if err := checkName("node", node); err != nil {
return err
}
return nil
}
func checkAssignmentsModule(module string) error {
if err := checkName("module", module); err != nil {
return err
}
if module == NodeWord {
return fmt.Errorf("meshmoduleassignerclient: no module is named %q: it is the word of a node's own key", NodeWord)
}
return nil
}
// EncodeNode is n as JSON at Version.
func EncodeNode(n seat.AssignmentsByNodeNodeValue) ([]byte, error) {
if err := checkAssignmentsNode(n.Node); err != nil {
return nil, err
}
n.V = Version
return json.Marshal(n)
}
// DecodeNode reads a node key's value; it refuses an unknown version, a node or module that is not a name, and a module
// named twice or without a digest.
func DecodeNode(data []byte) (seat.AssignmentsByNodeNodeValue, error) {
if err := checkVersion(data); err != nil {
return seat.AssignmentsByNodeNodeValue{}, err
}
n, err := seat.DecodeAssignmentsByNodeNodeValue(data)
if err != nil {
return n, fmt.Errorf("meshmoduleassignerclient: value does not fit its shape: %w", err)
}
if err := checkAssignmentsNode(n.Node); err != nil {
return seat.AssignmentsByNodeNodeValue{}, err
}
seen := map[string]bool{}
for _, r := range n.Modules {
if err := checkAssignmentsModule(r.Module); err != nil {
return seat.AssignmentsByNodeNodeValue{}, err
}
if seen[r.Module] || r.Digest == "" {
return seat.AssignmentsByNodeNodeValue{}, fmt.Errorf("meshmoduleassignerclient: the node key of %s names %s twice or without a digest", n.Node, r.Module)
}
seen[r.Module] = true
}
return n, nil
}
// EncodeModule is m as JSON at Version.
func EncodeModule(m seat.AssignmentsByNodeModuleValue) ([]byte, error) {
if err := checkAssignmentsNode(m.Node); err != nil {
return nil, err
}
if err := checkAssignmentsModule(m.Placed.Module); err != nil {
return nil, err
}
if m.Previous != nil && m.Previous.Module != m.Placed.Module {
return nil, fmt.Errorf("meshmoduleassignerclient: the key of %s on %s carries %s as its previous value", m.Placed.Module, m.Node, m.Previous.Module)
}
m.V = Version
return json.Marshal(m)
}
// DecodeModule reads a module key's value.
func DecodeModule(data []byte) (seat.AssignmentsByNodeModuleValue, error) {
if err := checkVersion(data); err != nil {
return seat.AssignmentsByNodeModuleValue{}, err
}
m, err := seat.DecodeAssignmentsByNodeModuleValue(data)
if err != nil {
return m, fmt.Errorf("meshmoduleassignerclient: value does not fit its shape: %w", err)
}
if err := checkAssignmentsNode(m.Node); err != nil {
return seat.AssignmentsByNodeModuleValue{}, err
}
if err := checkAssignmentsModule(m.Placed.Module); err != nil {
return seat.AssignmentsByNodeModuleValue{}, err
}
return m, nil
}
// Keys is the state `assignments` as a reader holds it, key by key in either form, and every node's assignments
// assembled from it: the one reader of the state's keys for every part (issue 774), so no part parses them itself. A
// write of the holder's under way is never assembled half: a module key's value is taken only as far as the node key
// names its digest (see the package's comment above NodeWord), and a node whose keys do not agree keeps the
// assignments last assembled for it, Put saying why.
//
// Safe for use from a watch and from a reader at once.
type Keys struct {
mu sync.Mutex
nodes map[string]*keysOf
}
// keysOf is what is held of one node's keys.
type keysOf struct {
node *seat.AssignmentsByNodeNodeValue
nodeRev uint64
modules map[string]seat.AssignmentsByNodeModuleValue
moduleRevs map[string]uint64
last *seat.Assignments // the assignments last assembled whole, nil when none
}
// NewKeys holds no key yet.
func NewKeys() *Keys { return &Keys{nodes: map[string]*keysOf{}} }
// AssignmentsOf is every node's assignments assembled from the state's keys as a reader holds them (key to value; a
// deleted key absent), and an error naming every key that did not read and every node whose keys do not agree (that
// node is then assembled from its module keys' values as they are).
func AssignmentsOf(keys map[string][]byte) (map[string]seat.Assignments, error) {
k := NewKeys()
var errs []error
names := make([]string, 0, len(keys))
for key := range keys {
names = append(names, key)
}
sort.Strings(names)
for _, key := range names {
// A node key read before its module keys does not agree yet; whether the keys agree is asked once all are in.
if _, readErr, _ := k.put(key, keys[key], 0, false); readErr != nil {
errs = append(errs, readErr)
}
}
k.mu.Lock()
for _, node := range sortedKeys(k.nodes) {
if _, _, err := k.nodes[node].assemble(node); err != nil {
errs = append(errs, err)
}
}
k.mu.Unlock()
return k.All(), errors.Join(errs...)
}
// Put takes one key's value in (revision: the bus's revision of it, 0 when the reader has none; deleted: a delete or a
// purge). It answers the node the key is of and an error for a key or a value that does not read, or for a node whose
// keys do not agree; the node then keeps what it had.
func (k *Keys) Put(key string, value []byte, revision uint64, deleted bool) (string, error) {
node, readErr, err := k.put(key, value, revision, deleted)
if readErr != nil {
return node, readErr
}
return node, err
}
// put is Put telling an error of the key or value it took (readErr) from the node's keys not agreeing after it (err).
func (k *Keys) put(key string, value []byte, revision uint64, deleted bool) (node string, readErr, err error) {
node, module, kind, err := ParseKey(key)
if err != nil {
return "", err, nil
}
k.mu.Lock()
defer k.mu.Unlock()
of := k.nodes[node]
if of == nil {
of = &keysOf{modules: map[string]seat.AssignmentsByNodeModuleValue{}, moduleRevs: map[string]uint64{}}
k.nodes[node] = of
}
switch kind {
case NodeKind:
of.node, of.nodeRev = nil, 0
if !deleted {
n, derr := DecodeNode(value)
switch {
case derr != nil:
readErr = fmt.Errorf("the node key %s does not read, so it places nothing there: %w", key, derr)
case n.Node != node:
readErr = fmt.Errorf("the node key %s names the node %q, so it places nothing there", key, n.Node)
default:
of.node, of.nodeRev = &n, revision
}
}
case ModuleKind:
delete(of.modules, module)
delete(of.moduleRevs, module)
if !deleted {
m, derr := DecodeModule(value)
switch {
case derr != nil:
readErr = fmt.Errorf("the module key %s does not read: %w", key, derr)
case m.Node != node || m.Placed.Module != module:
readErr = fmt.Errorf("the module key %s holds %s on %s", key, m.Placed.Module, m.Node)
default:
of.modules[module] = m
}
// Its revision is kept even when its value does not read, so the holder can write over it.
of.moduleRevs[module] = revision
}
}
p, whole, err := of.assemble(node)
switch {
case p == nil:
of.last = nil
case whole || of.last == nil:
of.last = p
}
return node, readErr, err
}
// assemble is the node's assignments from its keys: nil when none places it, whole false (and an error) when the node key
// names a module digest no module key's value has.
func (of *keysOf) assemble(node string) (*seat.Assignments, bool, error) {
if of.node == nil {
return nil, true, nil
}
n := of.node
p := seat.Assignments{V: Version, Node: node, Decided: n.Decided, Pins: n.Pins, Holds: n.Holds, Modules: make([]seat.PlacedModule, 0, len(n.Modules))}
var astray []string
for _, r := range n.Modules {
m, ok := of.modules[r.Module]
switch {
case ok && Digest(m.Placed) == r.Digest:
p.Modules = append(p.Modules, m.Placed)
case ok && m.Previous != nil && Digest(*m.Previous) == r.Digest:
p.Modules = append(p.Modules, *m.Previous)
case ok:
astray = append(astray, r.Module)
p.Modules = append(p.Modules, m.Placed)
default:
astray = append(astray, r.Module)
}
}
if len(astray) > 0 {
return &p, false, fmt.Errorf("the node key of %s names %s at a value no module key holds: %s keeps the assignments it had",
node, strings.Join(astray, ", "), node)
}
return &p, true, nil
}
// Of is node's assignments and the revision of the key it was read from (its node key), and whether
// any key places it.
func (k *Keys) Of(node string) (seat.Assignments, uint64, bool) {
k.mu.Lock()
defer k.mu.Unlock()
of := k.nodes[node]
if of == nil || of.last == nil {
return seat.Assignments{}, 0, false
}
return cloneAssignments(*of.last), of.nodeRev, true
}
// All is every node's assignments, by node, and the revision of each one's key (Of).
func (k *Keys) All() map[string]seat.Assignments {
out, _ := k.AllRevisions()
return out
}
// AllRevisions is All with the revision of each node's key.
func (k *Keys) AllRevisions() (map[string]seat.Assignments, map[string]uint64) {
k.mu.Lock()
nodes := make([]string, 0, len(k.nodes))
for n := range k.nodes {
nodes = append(nodes, n)
}
k.mu.Unlock()
out, revs := map[string]seat.Assignments{}, map[string]uint64{}
for _, n := range nodes {
if p, rev, ok := k.Of(n); ok {
out[n], revs[n] = p, rev
}
}
return out, revs
}
// KeyWrite is one write of a key of the state `assignments`, by compare-and-set: Value nil deletes the key at
// Base, Base 0 creates it, any other Base updates it at that revision.
type KeyWrite struct {
Node string
Key string
Value []byte
Base uint64
// Commit says this write is the node key's: once it is written, the node's assignments is the new one.
Commit bool
}
// Writes are the writes that take node from the assignments its keys hold to after (nil: no assignments), in the order
// they are made: the module keys that change, each carrying the value it replaces; then the node key, the commit; then
// the deletes of the module keys after no longer names. For no assignments the node key goes first, then the module
// keys. A module key whose value stays is not written.
func (k *Keys) Writes(node string, after *seat.Assignments) ([]KeyWrite, error) {
nodeKey, err := NodeKey(node)
if err != nil {
return nil, err
}
k.mu.Lock()
defer k.mu.Unlock()
of := k.nodes[node]
if of == nil {
of = &keysOf{modules: map[string]seat.AssignmentsByNodeModuleValue{}, moduleRevs: map[string]uint64{}}
}
var out []KeyWrite
deleteModules := func(keep map[string]bool) {
for _, module := range sortedKeys(of.moduleRevs) {
if keep[module] {
continue
}
key, _ := ModuleKey(node, module)
out = append(out, KeyWrite{Node: node, Key: key, Base: of.moduleRevs[module]})
}
}
if after == nil {
if of.node != nil {
out = append(out, KeyWrite{Node: node, Key: nodeKey, Base: of.nodeRev, Commit: true})
}
deleteModules(nil)
return out, nil
}
if after.Node != node {
return nil, fmt.Errorf("meshmoduleassignerclient: the assignments written under %s names the node %q", node, after.Node)
}
if err := CheckAssignments(*after); err != nil {
return nil, err
}
committed := map[string]seat.PlacedModule{}
if of.last != nil {
for _, m := range of.last.Modules {
committed[m.Module] = m
}
}
n := seat.AssignmentsByNodeNodeValue{Node: node, Decided: after.Decided, Pins: after.Pins, Holds: after.Holds, Modules: make([]seat.AssignmentsByNodeNodeValueModulesItem, 0, len(after.Modules))}
keep := map[string]bool{}
for _, m := range after.Modules {
if err := checkAssignmentsModule(m.Module); err != nil {
return nil, err
}
digest := Digest(m)
n.Modules = append(n.Modules, seat.AssignmentsByNodeNodeValueModulesItem{Module: m.Module, Digest: digest})
keep[m.Module] = true
was, wasPlaced := committed[m.Module]
if held, ok := of.modules[m.Module]; ok && Digest(held.Placed) == digest &&
(held.Previous == nil || wasPlaced && Digest(was) == digest) {
continue // the key holds it, and no previous value a reader could take in its place
}
v := seat.AssignmentsByNodeModuleValue{Node: node, Placed: m}
if wasPlaced && Digest(was) != digest {
v.Previous = &was
}
raw, err := EncodeModule(v)
if err != nil {
return nil, err
}
key, _ := ModuleKey(node, m.Module)
out = append(out, KeyWrite{Node: node, Key: key, Value: raw, Base: of.moduleRevs[m.Module]})
}
raw, err := EncodeNode(n)
if err != nil {
return nil, err
}
out = append(out, KeyWrite{Node: node, Key: nodeKey, Value: raw, Base: of.nodeRev, Commit: true})
deleteModules(keep)
return out, nil
}
// AssignmentsValues is every key and value of p written whole onto no keys before: its module keys and its node key, as a writer that holds no key of the node (a test's bus, a fresh mesh) writes them.
func AssignmentsValues(p seat.Assignments) (map[string][]byte, error) {
writes, err := NewKeys().Writes(p.Node, &p)
if err != nil {
return nil, err
}
out := make(map[string][]byte, len(writes))
for _, w := range writes {
out[w.Key] = w.Value
}
return out, nil
}
// cloneAssignments is p with its own maps and slices, so a reader's copy is not a held one.
func cloneAssignments(p seat.Assignments) seat.Assignments {
raw, _ := json.Marshal(p)
var out seat.Assignments
_ = json.Unmarshal(raw, &out)
if out.Modules == nil {
out.Modules = []seat.PlacedModule{}
}
return out
}
@@ -0,0 +1,39 @@
package meshmoduleassignerclient
// The operations a proposal's change names (novox/hq ADR 0567 §2), the words of a change's `op`. The seat's contract
// carries the field; its callers (the enrolment builds OpAddNode and OpRemoveNode) and its holder share the words here.
const (
// OpPlace places a module on a node, with its host ports and bindings.
OpPlace = "place"
// OpTakeOff takes a module off a node.
OpTakeOff = "take-off"
// OpPin pins the provider of a provision on a node; OpUnpin removes a pin.
OpPin = "pin"
OpUnpin = "unpin"
// OpHold says a module placed on the node holds a seat; OpUnhold, that it holds it no more.
OpHold = "hold"
OpUnhold = "unhold"
// OpDecide sets a decided field of the node (NodeDecided by its JSON name), its address among them.
OpDecide = "decide"
// OpConfig sets a module's config assignments or values on the node.
OpConfig = "config"
// OpSeed is the node's whole assignments: the take-over (adopt, §7), never given by a caller.
OpSeed = "seed"
// OpAddNode adds a joining node with its decided fields and the public halves of its keys, on the warrant of the
// enrolment that issued its token (novox/hq ADR 0587 §7): the enroller's alone, the proposal's one change.
OpAddNode = "add-node"
// OpRemoveNode takes a joining node off assignments again when its enrolment ends unconfirmed, revoked or expired: the
// enroller's alone, on the same warrant, the proposal's one change, of a node with no module and no holding.
OpRemoveNode = "remove-node"
)
// The states of an open proposal (the field `state` of the state `proposals`).
const (
// ProposalAnswering: its candidates are written and the nodes it must hear from have not all answered.
ProposalAnswering = "answering"
// ProposalFailed: its verdict failed; it stays open, for its proposer to read and withdraw, or for a node's later
// answer to judge it again.
ProposalFailed = "failed"
// ProposalAsked: its verdict passed and the operator is asked to approve it.
ProposalAsked = "asked"
)
@@ -0,0 +1,14 @@
package meshmoduleassignerclient
import "testing"
// The words are the wire's: a holder and a caller that spell one differently refuse each other's proposals.
func TestOpsAndProposalStatesAreTheWire(t *testing.T) {
for got, want := range map[string]string{OpPlace: "place", OpTakeOff: "take-off", OpPin: "pin", OpUnpin: "unpin", OpHold: "hold",
OpUnhold: "unhold", OpDecide: "decide", OpConfig: "config", OpSeed: "seed", OpAddNode: "add-node", OpRemoveNode: "remove-node",
ProposalAnswering: "answering", ProposalFailed: "failed", ProposalAsked: "asked"} {
if got != want {
t.Errorf("%q, want %q", got, want)
}
}
}
@@ -0,0 +1,140 @@
package meshmoduleassignerclient
import (
"fmt"
"maps"
"slices"
"sync"
"time"
seat "git.novox.be/novox/mesh-contracts/go/seats/meshmoduleassigner"
)
// Placed is where the assignments (the seat mesh-assignments's state `assignments`, held by the assigner; ADR 0567, ADR 0549 §3) places each module, and since when:
// the one reader of that state for every part that asks "does module stand on node" (issue 774, design 47's third
// milestone), in place of each part reading the controller's assignments (`mesh-controller_assignments`, which goes
// with the controller's lease, ADR 0549 §6).
//
// Its input is the state's revisions in the order the bus wrote them, as a watch with the history (jetstream's
// IncludeHistory) hands them: Put takes each, a node's node key
// names its modules (a module key says nothing of which stand). A module stands since the revision of its node's key that first named it
// in an unbroken run. A module in the first revision heard of a key stands since the zero time: that revision is the
// key's first (the seed of ADR 0549 §4 placed what already ran there) or the oldest the bus still keeps
// (the state's history), and either way the module may have run there before it. A key's value that does not read places
// nothing on its node, and Put says why.
//
// Safe for use from a watch and from a reader at once.
type Placed struct {
mu sync.Mutex
since map[string]map[string]time.Time // node -> module -> since when it stands
heard map[string]bool // the nodes whose key was heard at all
}
// NewPlaced is a Placed that holds no assignments yet.
func NewPlaced() *Placed {
return &Placed{since: map[string]map[string]time.Time{}, heard: map[string]bool{}}
}
// Put takes one revision of a key of the state `assignments` (its value, whether it is a delete, and when the bus wrote
// it). It answers the node the key is of and the modules whose standing on it changed (placed or removed), in name
// order, and an error for a key or a value that does not read (the node then places nothing).
func (p *Placed) Put(key string, value []byte, deleted bool, at time.Time) (node string, changed []string, err error) {
node, _, kind, err := ParseKey(key)
if err != nil {
return "", nil, err
}
if kind == ModuleKind {
return node, nil, nil // which modules stand is the node key's to say
}
now := map[string]bool{}
if !deleted {
var modules []string
var derr error
var named string
var n seat.AssignmentsByNodeNodeValue
n, derr = DecodeNode(value)
named = n.Node
for _, r := range n.Modules {
modules = append(modules, r.Module)
}
switch {
case derr != nil:
err = fmt.Errorf("the assignments of %s does not read, so it places nothing there: %w", node, derr)
case named != node:
err = fmt.Errorf("the assignments under %s names the node %q, so it places nothing there", key, named)
default:
for _, m := range modules {
now[m] = true
}
}
}
p.mu.Lock()
defer p.mu.Unlock()
if !p.heard[node] {
p.heard[node] = true
at = time.Time{}
}
was := p.since[node]
next := map[string]time.Time{}
for m := range now {
if s, ok := was[m]; ok {
next[m] = s
} else {
next[m] = at
changed = append(changed, m)
}
}
for m := range was {
if !now[m] {
changed = append(changed, m)
}
}
if len(next) == 0 {
delete(p.since, node)
} else {
p.since[node] = next
}
slices.Sort(changed)
return node, changed, err
}
// Seeded says some node's assignments is held: before the assigner's adopt writes the first assignments nothing is placed,
// and a reader keeps what it did before (ADR 0549 §5).
func (p *Placed) Seeded() bool {
p.mu.Lock()
defer p.mu.Unlock()
return len(p.since) > 0
}
// Since says whether module stands on node, and since when (the zero time: since before the revisions kept).
func (p *Placed) Since(node, module string) (time.Time, bool) {
p.mu.Lock()
defer p.mu.Unlock()
s, ok := p.since[node][module]
return s, ok
}
// PlacedNodes is every node module is placed on, in name order.
func (p *Placed) PlacedNodes(module string) []string {
p.mu.Lock()
defer p.mu.Unlock()
var out []string
for node, mods := range p.since {
if _, ok := mods[module]; ok {
out = append(out, node)
}
}
slices.Sort(out)
return out
}
// ByNode is, by node, the modules placed on it in name order.
func (p *Placed) ByNode() map[string][]string {
p.mu.Lock()
defer p.mu.Unlock()
out := make(map[string][]string, len(p.since))
for node, mods := range p.since {
out[node] = slices.Sorted(maps.Keys(mods))
}
return out
}
@@ -0,0 +1,17 @@
package meshmoduleassignerclient
import (
"fmt"
"regexp"
)
var proposalIDOK = regexp.MustCompile(`^[a-z0-9][a-z0-9-]{2,62}$`)
// CheckProposalID refuses an id that is no proposal's: lower-case letters, digits and dashes, 3 to 63 of them, so it is one
// key token. The holder writes proposals under it, and the keys of the asks about a proposal (`assignments.<id>`) carry it.
func CheckProposalID(id string) error {
if !proposalIDOK.MatchString(id) {
return fmt.Errorf("mesh-module-assigner: %q is no proposal id (lower-case letters, digits and dashes, one key token)", id)
}
return nil
}
@@ -0,0 +1,19 @@
package meshmoduleassignerclient
import "sort"
// MachineNames is every machine the seat places modules on, sorted, from its state `assignments` as a reader holds its
// keys: each with a node key `<node>.node`. Module keys and keys of the
// state's other kinds are passed over.
func MachineNames(keys map[string][]byte) []string {
seen := map[string]bool{}
out := []string{}
for k := range keys {
if node, _, kind, err := ParseKey(k); err == nil && kind != ModuleKind && !seen[node] {
seen[node] = true
out = append(out, node)
}
}
sort.Strings(out)
return out
}
@@ -0,0 +1,13 @@
package meshmoduleassignerclient
import (
"reflect"
"testing"
)
func TestMachineNamesAreTheNodesAKeyNames(t *testing.T) {
keys := map[string][]byte{"g14.node": nil, "g14.app": nil, "ace.node": nil, "want.x.y": nil, "bad": nil}
if got, want := MachineNames(keys), []string{"ace", "g14"}; !reflect.DeepEqual(got, want) {
t.Errorf("MachineNames = %v, want %v", got, want)
}
}
+218
View File
@@ -0,0 +1,218 @@
// Package nodeengineclient is the node-engine seat's client (novox/hq ADR 0627 §1, ADR 0625 §4): the checks its callers
// share over the verb compose-candidate's ask and answer (ADR 0593 §7, §9), over the seat's generated contract. The
// node-engine encodes its answer through it, and the checker and the assigner read answers and the asker's bound
// through it. The composing is the node-engine's own; the call is the caller's own, so it holds no transport.
package nodeengineclient
import (
"bytes"
"encoding/json"
"errors"
"fmt"
"regexp"
"strings"
"time"
seat "git.novox.be/novox/mesh-contracts/go/seats/nodeengine"
"git.novox.be/novox/mesh-sdk/go/keys"
)
const (
// AnswerBound is the default bound an asker of compose-candidate waits for a node's answer (the checker's setting
// compose-answer-bound, ADR 0593 §7, §9).
AnswerBound = 60 * time.Second
// NoAnswer is the words a check's verdict names a node with that did not answer within the bound (ADR 0593 §9).
NoAnswer = "not composed on that node: no answer"
// Composed and Refused are an answer's verdict: no module and not the node's own composition is refused by the
// candidate, or at least one is (or the node could not compose with it at all, the answer's Refused).
Composed = "composed"
Refused = "refused"
// Unready is the verdict of a node that cannot compose yet (its shadow not ready, its agent starting): no module is
// named, Refused says why in words, and the checker takes it as a node that did not answer (NoAnswer), deciding by
// this verdict and never by the words.
Unready = "unready"
// NotJudged is the verdict for a node the checker's sandbox composed and could not judge (an older node-engine's
// input that does not say how it composes, ADR 0593 §9): no module is named, Refused says why in words, and the
// checker names the node with them and passes the check with that warning (ADR 0601 §4: a contract is a field, not
// a phrase).
NotJudged = "not-judged"
// The states of one module's composition with the candidate: composed (Digest set), refused by the candidate
// (Words set), or held, refused without the candidate too (Words set), which counts toward no verdict.
ModuleComposed = "composed"
ModuleRefused = "refused"
ModuleHeld = "held"
// Own is the Module name of the node's own composition in an answer.
Own = "node"
// Version is the version an answer is written at and the only one read.
Version = 1
)
// ErrUnknownVersion is what DecodeAnswer returns for an answer whose version is missing or not Version.
var ErrUnknownVersion = errors.New("node-engine: unknown compose-candidate answer version")
var nameOK = regexp.MustCompile(`^[-/_=a-zA-Z0-9]+$`)
func checkAsk(a seat.ComposeCandidateInput) error {
if _, _, err := ParseCompositionKey(a.Candidate); err != nil {
return fmt.Errorf("node-engine: a compose-candidate ask names no candidate: %w", err)
}
return nil
}
func checkModule(m seat.Module, node string) error {
if m.Module == "" {
return fmt.Errorf("node-engine: a module's composition on %s names no module", node)
}
switch m.State {
case ModuleComposed:
if m.Digest == "" {
return fmt.Errorf("node-engine: %s is composed on %s and names no digest", m.Module, node)
}
case ModuleRefused, ModuleHeld:
if m.Words == "" {
return fmt.Errorf("node-engine: %s is %s on %s and says no why", m.Module, m.State, node)
}
default:
return fmt.Errorf("node-engine: %s on %s is %q, neither %s, %s nor %s", m.Module, node, m.State,
ModuleComposed, ModuleRefused, ModuleHeld)
}
return nil
}
// CheckAnswer refuses an answer naming no node or candidate, a negative time taken, an unknown module state, a composed
// module without a digest, a refused or held one without words, modules not sorted and each once, a refusal of the
// whole naming modules, an unready or not-judged one saying no why, and a verdict the modules do not give.
func CheckAnswer(a seat.ComposeCandidateAnswer) error {
if a.Node == "" || !nameOK.MatchString(a.Node) {
return fmt.Errorf("node-engine: the answer's node name %q is empty or holds a dot, a wildcard, whitespace or a character a key refuses", a.Node)
}
if _, _, err := ParseCompositionKey(a.Candidate); err != nil {
return err
}
if a.Took < 0 {
return fmt.Errorf("node-engine: the answer of %s took %d ms", a.Node, a.Took)
}
if (a.Verdict == Unready || a.Verdict == NotJudged) && a.Refused == "" {
return fmt.Errorf("node-engine: %s's answer is %s and says no why", a.Node, a.Verdict)
}
if a.Refused != "" {
if len(a.Modules) > 0 || a.Verdict != Refused && a.Verdict != Unready && a.Verdict != NotJudged {
return fmt.Errorf("node-engine: %s could not compose the candidate (%s), and its answer names modules or is neither %s, %s nor %s",
a.Node, a.Refused, Refused, Unready, NotJudged)
}
return nil
}
refused := false
for i, m := range append([]seat.Module{a.Own}, a.Modules...) {
if err := checkModule(m, a.Node); err != nil {
return err
}
if i > 1 && a.Modules[i-2].Module >= m.Module {
return fmt.Errorf("node-engine: the modules of %s's answer are not sorted and each once (%s after %s)", a.Node, m.Module, a.Modules[i-2].Module)
}
refused = refused || m.State == ModuleRefused
}
switch {
case a.Own.Module != Own:
return fmt.Errorf("node-engine: the node's own composition in %s's answer is named %q, not %q", a.Node, a.Own.Module, Own)
case refused && a.Verdict != Refused:
return fmt.Errorf("node-engine: %s's answer refuses a module and its verdict is %q", a.Node, a.Verdict)
case !refused && a.Verdict != Composed:
return fmt.Errorf("node-engine: %s's answer refuses no module and its verdict is %q", a.Node, a.Verdict)
}
return nil
}
// strictDecode reads data into v, refusing an unknown key.
func strictDecode(data []byte, v any, what string) error {
d := json.NewDecoder(bytes.NewReader(data))
d.DisallowUnknownFields()
d.UseNumber() // a number in an object the contract leaves open (the composition input) keeps its digits as written
if err := d.Decode(v); err != nil {
return fmt.Errorf("node-engine: %s does not fit its shape: %w", what, err)
}
return nil
}
// EncodeAsk is a as JSON; it refuses an ask naming no candidate key.
func EncodeAsk(a seat.ComposeCandidateInput) ([]byte, error) {
if err := checkAsk(a); err != nil {
return nil, err
}
return json.Marshal(a)
}
// DecodeAsk reads an ask strictly; it refuses an unknown key and what EncodeAsk refuses.
func DecodeAsk(data []byte) (seat.ComposeCandidateInput, error) {
var a seat.ComposeCandidateInput
if err := strictDecode(data, &a, "a compose-candidate ask"); err != nil {
return seat.ComposeCandidateInput{}, err
}
return a, checkAsk(a)
}
// EncodeAnswer is a as JSON at Version; it refuses what CheckAnswer refuses.
func EncodeAnswer(a seat.ComposeCandidateAnswer) ([]byte, error) {
if err := CheckAnswer(a); err != nil {
return nil, err
}
a.V = Version
return json.Marshal(a)
}
// DecodeAnswer reads an answer strictly; it refuses an unknown version (ErrUnknownVersion), an unknown key and what
// CheckAnswer refuses.
func DecodeAnswer(data []byte) (seat.ComposeCandidateAnswer, error) {
var probe struct {
V *int64 `json:"v"`
}
if err := json.Unmarshal(data, &probe); err != nil {
return seat.ComposeCandidateAnswer{}, fmt.Errorf("node-engine: a compose-candidate answer is not JSON: %w", err)
}
if probe.V == nil || *probe.V != Version {
return seat.ComposeCandidateAnswer{}, fmt.Errorf("%w: this reader reads %d", ErrUnknownVersion, Version)
}
var a seat.ComposeCandidateAnswer
if err := strictDecode(data, &a, "a compose-candidate answer"); err != nil {
return seat.ComposeCandidateAnswer{}, err
}
return a, CheckAnswer(a)
}
var heads12 = regexp.MustCompile(`^[0-9a-f]{12}$`)
// CompositionKey is the key of a candidate the node is asked to compose: the group's check at its heads, `<escaped
// group>.<heads hash>`, the group escaped as a key part and the heads hash 12 lowercase hex digits (the checker's verdict
// key of the same check).
func CompositionKey(group, headsHash string) (string, error) {
if group == "" {
return "", errors.New("node-engine: the group name is empty")
}
if !heads12.MatchString(headsHash) {
return "", fmt.Errorf("node-engine: %q is not a heads hash, 12 lowercase hex digits", headsHash)
}
return keys.EscapeName(group) + "." + headsHash, nil
}
// ParseCompositionKey is the inverse of CompositionKey; it refuses a key that is not one, naming it.
func ParseCompositionKey(key string) (group, headsHash string, err error) {
i := strings.LastIndex(key, ".")
if i < 0 {
return "", "", fmt.Errorf("node-engine: %q is not a composition key, <group>.<heads hash>", key)
}
part, headsHash := key[:i], key[i+1:]
if !heads12.MatchString(headsHash) {
return "", "", fmt.Errorf("node-engine: %q is not a composition key: %q is not a heads hash, 12 lowercase hex digits", key, headsHash)
}
if part == "" || strings.Contains(part, ".") {
return "", "", fmt.Errorf("node-engine: %q is not a composition key: the group part is empty or holds a dot", key)
}
if group, err = keys.UnescapeName(part); err != nil {
return "", "", fmt.Errorf("node-engine: %q is not a composition key: %w", key, err)
}
return group, headsHash, nil
}
+115
View File
@@ -0,0 +1,115 @@
package nodeengineclient
import (
"errors"
"fmt"
"reflect"
"strings"
"testing"
seat "git.novox.be/novox/mesh-contracts/go/seats/nodeengine"
)
// The verb compose-candidate's ask and answer (ADR 0593 §7, §9): each round-trips, and a reader refuses what it could
// not take, naming it.
func TestComposeCandidateRoundTripsAndRefuses(t *testing.T) {
key, err := CompositionKey("mesh-sdk#7", "0123456789ab")
if err != nil {
t.Fatal(err)
}
raw, err := EncodeAsk(seat.ComposeCandidateInput{Candidate: key, Input: true})
if err != nil {
t.Fatal(err)
}
if a, err := DecodeAsk(raw); err != nil || a.Candidate != key || !a.Input {
t.Fatalf("ask round trip: %v, %+v", err, a)
}
if _, err := DecodeAsk([]byte(`{"candidate":"` + key + `","other":1}`)); err == nil {
t.Error("an ask with an unknown key was read")
}
if _, err := EncodeAsk(seat.ComposeCandidateInput{Candidate: "nope"}); err == nil {
t.Error("an ask naming no candidate key was encoded")
}
good := seat.ComposeCandidateAnswer{Node: "ace", Candidate: key, Verdict: Refused, Took: 812,
Own: seat.Module{Module: Own, State: ModuleComposed, Digest: "sha256:00"},
Modules: []seat.Module{
{Module: "netcheck", Fp: "src2:x", State: ModuleComposed, Digest: "sha256:01", Changed: true},
{Module: "route-proxy", State: ModuleRefused, Words: "the setting domain has no value"},
{Module: "zz", State: ModuleHeld, Words: "held before"},
}, Input: map[string]any{"machines": []any{"ace"}}}
raw, err = EncodeAnswer(good)
if err != nil {
t.Fatal(err)
}
back, err := DecodeAnswer(raw)
if err != nil || back.Verdict != Refused || len(back.Modules) != 3 || !reflect.DeepEqual(back.Input, good.Input) {
t.Fatalf("answer round trip: %v, %+v", err, back)
}
if _, err := DecodeAnswer([]byte(strings.Replace(string(raw), `"took"`, `"other":1,"took"`, 1))); err == nil {
t.Error("an answer with an unknown key was read")
}
if _, err := DecodeAnswer([]byte(strings.Replace(string(raw), `"v":1`, `"v":2`, 1))); !errors.Is(err, ErrUnknownVersion) {
t.Errorf("an answer at another version read as %v", err)
}
numbered := strings.Replace(string(raw), `"machines":["ace"]`, `"bound":12345678901234567890`, 1)
if back, err := DecodeAnswer([]byte(numbered)); err != nil || fmt.Sprint(back.Input["bound"]) != "12345678901234567890" {
t.Errorf("an open input's number did not keep its digits: %v, %+v", err, back.Input)
}
whole := seat.ComposeCandidateAnswer{Node: "ace", Candidate: key, Verdict: Refused, Refused: "the bound passed"}
unready := seat.ComposeCandidateAnswer{Node: "ace", Candidate: key, Verdict: Unready, Refused: "the agent is starting"}
notJudged := seat.ComposeCandidateAnswer{Node: "ace", Candidate: key, Verdict: NotJudged, Refused: "its node-engine sends no running state"}
for _, a := range []seat.ComposeCandidateAnswer{whole, unready, notJudged} {
if raw, err := EncodeAnswer(a); err != nil {
t.Errorf("a %s answer was not encoded: %v", a.Verdict, err)
} else if back, err := DecodeAnswer(raw); err != nil || back.Verdict != a.Verdict {
t.Errorf("a %s answer read back as %+v, %v", a.Verdict, back, err)
}
}
for name, c := range map[string]struct {
from seat.ComposeCandidateAnswer
mutate func(a *seat.ComposeCandidateAnswer)
}{
"verdict not the modules'": {good, func(a *seat.ComposeCandidateAnswer) { a.Verdict = Composed }},
"unknown state": {good, func(a *seat.ComposeCandidateAnswer) { a.Modules[0].State = "fine" }},
"composed without digest": {good, func(a *seat.ComposeCandidateAnswer) { a.Modules[0].Digest = "" }},
"refused without words": {good, func(a *seat.ComposeCandidateAnswer) { a.Modules[1].Words = "" }},
"unsorted": {good, func(a *seat.ComposeCandidateAnswer) { a.Modules[0], a.Modules[1] = a.Modules[1], a.Modules[0] }},
"own misnamed": {good, func(a *seat.ComposeCandidateAnswer) { a.Own.Module = "ace" }},
"whole refusal with modules": {good, func(a *seat.ComposeCandidateAnswer) { a.Refused = "the bound passed" }},
"node with a dot": {good, func(a *seat.ComposeCandidateAnswer) { a.Node = "a.b" }},
"negative took": {good, func(a *seat.ComposeCandidateAnswer) { a.Took = -1 }},
"unready saying no why": {unready, func(a *seat.ComposeCandidateAnswer) { a.Refused = "" }},
"unready naming modules": {unready, func(a *seat.ComposeCandidateAnswer) { a.Modules = good.Modules }},
"not judged saying no why": {notJudged, func(a *seat.ComposeCandidateAnswer) { a.Refused = "" }},
"not judged naming modules": {notJudged, func(a *seat.ComposeCandidateAnswer) { a.Modules = good.Modules }},
} {
a := c.from
a.Modules = append([]seat.Module(nil), c.from.Modules...)
c.mutate(&a)
if _, err := EncodeAnswer(a); err == nil {
t.Errorf("%s: encoded", name)
}
}
}
// A composition key round-trips its group and heads hash, and refuses what is not one.
func TestCompositionKeyRoundTripsAndRefuses(t *testing.T) {
key, err := CompositionKey("fix/1.2", "0123456789ab")
if err != nil {
t.Fatal(err)
}
if g, h, err := ParseCompositionKey(key); err != nil || g != "fix/1.2" || h != "0123456789ab" {
t.Fatalf("round trip: %q %q %v", g, h, err)
}
for _, bad := range []string{"", "nogroup", "g.0123456789AB", "g.short", ".0123456789ab", "a.b.0123456789ab"} {
if _, _, err := ParseCompositionKey(bad); err == nil {
t.Errorf("%q was taken for a composition key", bad)
}
}
if _, err := CompositionKey("", "0123456789ab"); err == nil {
t.Error("a key with no group was made")
}
}
+324
View File
@@ -0,0 +1,324 @@
package nodeengineclient
import (
"bytes"
"crypto/sha256"
"encoding/hex"
"encoding/json"
"fmt"
"io"
"strings"
seat "git.novox.be/novox/mesh-contracts/go/seats/nodeengine"
"git.novox.be/novox/mesh-sdk/go/declaration"
"git.novox.be/novox/mesh-sdk/go/keys"
)
// The compositions (novox/hq ADR 0539 clauses 1 to 4), the node-engine's state composed: its layout is the seat's contract
// (seat.ComposedByNodeModuleFingerprintValue, seat.ComposedByNodeNodeValue); this file is the codec and the digest the
// engine, the assigner, the grants part and the provisioner share. A composition's digest is a function of its content
// alone, so the encoding keeps the field order its first writer used, which the contract's generated type (fields sorted)
// does not: the wire structs below hold it.
// sourcePrefix opens a source fingerprint of the form mesh-catalog's client defines (meshcatalogclient.SourcePrefix2,
// KeyPart); a client imports no other seat's client (ADR 0625 §4), so a composition's field is checked here for that form
// only, and a caller makes the key part with meshcatalogclient.KeyPart.
const sourcePrefix = "src2:"
// checkFingerprintForm refuses a fingerprint that is not `src2:` and 64 lowercase hex digits, naming it.
func checkFingerprintForm(fp string) error {
part, ok := strings.CutPrefix(fp, sourcePrefix)
if !ok || !keys.IsHex64(part) {
return fmt.Errorf("node-engine: %q is not a fingerprint of the new form, %s and 64 lowercase hex digits", fp, sourcePrefix)
}
return nil
}
// Composition is one module's resources composed for one node at one fingerprint, or, with Module and FP empty, the node's
// own resources and parts (Own). Each resource is a declaration resource, kept as JSON and written canonically (keys
// sorted, spacing dropped). Plan is the module's whole provisioning plan, kept as JSON too: part of the encoding, not of
// the digest. Refused says the module cannot be composed at this fingerprint; such a composition holds nothing else.
type Composition struct {
V int
Node string
Module string
FP string
Data bool
Resources []json.RawMessage
Filled []string
Own seat.NodeOwn
Plan json.RawMessage
Refused string
}
type adoptionWire struct {
Taken []string `json:"taken"`
Untaken map[string][]string `json:"untaken,omitempty"`
}
type identityWire struct {
Module string `json:"module"`
Name string `json:"name"`
UID int64 `json:"uid"`
GID int64 `json:"gid"`
Groups []int64 `json:"groups,omitempty"`
Runner string `json:"runner,omitempty"`
}
type groupWire struct {
Name string `json:"name"`
GID int64 `json:"gid"`
}
type libraryWire struct {
Library string `json:"library"`
Root string `json:"root"`
Group string `json:"group"`
GID int64 `json:"gid"`
Home bool `json:"home"`
Taken bool `json:"taken,omitempty"`
Paths []string `json:"paths,omitempty"`
Runner string `json:"runner,omitempty"`
}
type ownWire struct {
Adoption *adoptionWire `json:"adoption,omitempty"`
Identities []identityWire `json:"identities,omitempty"`
Groups []groupWire `json:"groups,omitempty"`
Libraries []libraryWire `json:"libraries,omitempty"`
Provisioner string `json:"provisioner_account,omitempty"`
}
func ownIsEmpty(o seat.NodeOwn) bool {
return o.Adoption == nil && len(o.Identities) == 0 && len(o.Groups) == 0 && len(o.Libraries) == 0 && o.ProvisionerAccount == ""
}
func ownToWire(o seat.NodeOwn) *ownWire {
w := ownWire{Provisioner: o.ProvisionerAccount}
if o.Adoption != nil {
w.Adoption = &adoptionWire{Taken: o.Adoption.Taken, Untaken: o.Adoption.Untaken}
}
for _, i := range o.Identities {
w.Identities = append(w.Identities, identityWire{Module: i.Module, Name: i.Name, UID: i.Uid, GID: i.Gid, Groups: i.Groups, Runner: i.Runner})
}
for _, g := range o.Groups {
w.Groups = append(w.Groups, groupWire{Name: g.Name, GID: g.Gid})
}
for _, l := range o.Libraries {
w.Libraries = append(w.Libraries, libraryWire{Library: l.Library, Root: l.Root, Group: l.Group, GID: l.Gid, Home: l.Home, Taken: l.Taken, Paths: l.Paths, Runner: l.Runner})
}
return &w
}
func ownFromWire(w ownWire) seat.NodeOwn {
o := seat.NodeOwn{ProvisionerAccount: w.Provisioner}
if w.Adoption != nil {
o.Adoption = &seat.Adoption{Taken: w.Adoption.Taken, Untaken: w.Adoption.Untaken}
}
for _, i := range w.Identities {
o.Identities = append(o.Identities, seat.DeclaredIdentity{Module: i.Module, Name: i.Name, Uid: i.UID, Gid: i.GID, Groups: i.Groups, Runner: i.Runner})
}
for _, g := range w.Groups {
o.Groups = append(o.Groups, seat.DeclaredGroup{Name: g.Name, Gid: g.GID})
}
for _, l := range w.Libraries {
o.Libraries = append(o.Libraries, seat.DeclaredLibrary{Library: l.Library, Root: l.Root, Group: l.Group, Gid: l.GID, Home: l.Home, Taken: l.Taken, Paths: l.Paths, Runner: l.Runner})
}
return o
}
// composedWire is a composition as it is written.
type composedWire struct {
V int `json:"v"`
Node string `json:"node"`
Module string `json:"module,omitempty"`
FP string `json:"fp,omitempty"`
Data bool `json:"data,omitempty"`
Resources []json.RawMessage `json:"resources"`
Filled []string `json:"filled_files,omitempty"`
Own *ownWire `json:"own,omitempty"`
Plan json.RawMessage `json:"plan,omitempty"`
Refused string `json:"refused,omitempty"`
// Signature is read and dropped: a composition is unsigned (ADR 0593 §6), and a value the ended controller signed
// still decodes. Nothing writes it.
Signature json.RawMessage `json:"signature,omitempty"`
}
// canonicalJSON is raw with its object keys sorted and its spacing dropped, its numbers kept as written.
func canonicalJSON(raw json.RawMessage) (json.RawMessage, error) {
var v any
d := json.NewDecoder(bytes.NewReader(raw))
d.UseNumber()
if err := d.Decode(&v); err != nil {
return nil, err
}
if _, err := d.Token(); err != io.EOF {
return nil, fmt.Errorf("more after the value")
}
return json.Marshal(v)
}
func (c Composition) check() error {
if err := keys.CheckName("node", c.Node); err != nil {
return err
}
if c.Module == "" {
if c.FP != "" {
return fmt.Errorf("node-engine: the node's own composition names no fingerprint, and this one names %s", c.FP)
}
if c.Data {
return fmt.Errorf("node-engine: the node's own composition holds no module's data")
}
if len(c.Plan) > 0 || c.Refused != "" {
return fmt.Errorf("node-engine: the node's own composition carries no plan and no refusal: only a module's does")
}
} else {
if err := checkFingerprintForm(c.FP); err != nil {
return err
}
if _, err := ComposedKey(c.Node, c.Module, strings.TrimPrefix(c.FP, sourcePrefix)); err != nil {
return err
}
if !ownIsEmpty(c.Own) {
return fmt.Errorf("node-engine: the composition of %s carries the node's own parts, which only <node>.node carries", c.Module)
}
}
if c.Refused != "" && (len(c.Resources) > 0 || len(c.Filled) > 0 || c.Data || len(c.Plan) > 0) {
return fmt.Errorf("node-engine: the composition of %s on %s is refused and holds resources, filled files, data or a plan", c.Module, c.Node)
}
seen := map[string]bool{}
files := map[string][]*declaration.File{}
for _, raw := range c.Resources {
r, err := declaration.ParseResource(raw)
if err != nil {
return fmt.Errorf("node-engine: the composition of %q on %s: %w", c.Module, c.Node, err)
}
if seen[r.Identity()] {
return fmt.Errorf("node-engine: the composition of %q on %s holds the resource %q twice", c.Module, c.Node, r.Identity())
}
seen[r.Identity()] = true
if r.DeclaredModule() != c.Module {
return fmt.Errorf("node-engine: the resource %q is of %q, and this is the composition of %q on %s",
r.Identity(), r.DeclaredModule(), c.Module, c.Node)
}
if f, ok := r.(*declaration.File); ok {
if f.TravelsByDigest() {
return fmt.Errorf("node-engine: the file %q is over %d bytes and travels by digest, not whole",
f.ID, declaration.FileReferenceThreshold)
}
files[f.Path] = append(files[f.Path], f)
}
}
// A file the provisioner fills says no content, so a composition that does not say it is filled would have the
// engine write it empty (issue 1945): refused, naming it.
filledHere := make(map[string]bool, len(c.Filled))
for _, path := range c.Filled {
filledHere[path] = true
}
for path, fs := range files {
for _, f := range fs {
if f.OwnerAtRuntime == declaration.RuntimeOwned && !filledHere[path] && !saysContent(f) {
return fmt.Errorf("node-engine: the file %q says no content and the composition of %q on %s does not say "+
"the provisioner fills %s: the engine would write it empty", f.ID, c.Module, c.Node, path)
}
}
}
for _, path := range c.Filled {
if len(files[path]) == 0 {
return fmt.Errorf("node-engine: the composition of %q fills %q, and declares no file there", c.Module, path)
}
for _, f := range files[path] {
if f.OwnerAtRuntime == "" {
return fmt.Errorf("node-engine: the file %q is filled by the provisioner and says no owner-at-runtime", f.ID)
}
}
}
return nil
}
func saysContent(f *declaration.File) bool {
return f.Content != "" || f.Bytes != "" || f.Digest != "" || f.Sealed != "" || f.VaultSealed != nil
}
func (c Composition) wire() (composedWire, error) {
if err := c.check(); err != nil {
return composedWire{}, err
}
w := composedWire{V: keys.Version, Node: c.Node, Module: c.Module, FP: c.FP, Data: c.Data, Filled: c.Filled,
Refused: c.Refused, Resources: make([]json.RawMessage, len(c.Resources))}
for i, r := range c.Resources {
canon, err := canonicalJSON(r)
if err != nil {
return composedWire{}, fmt.Errorf("node-engine: a resource of %q is not JSON: %w", c.Module, err)
}
w.Resources[i] = canon
}
if len(c.Plan) > 0 {
canon, err := canonicalJSON(c.Plan)
if err != nil {
return composedWire{}, fmt.Errorf("node-engine: the plan of %q is not JSON: %w", c.Module, err)
}
w.Plan = canon
}
if !ownIsEmpty(c.Own) {
w.Own = ownToWire(c.Own)
}
return w, nil
}
// EncodeComposition is c as JSON at the version, each resource canonical. It refuses a composition whose node or
// fingerprint a key refuses, a resource its kind refuses, a resource of another module, a module's composition carrying
// the node's own parts, a file over 64 KiB carried whole, and a filled path no file declares.
func EncodeComposition(c Composition) ([]byte, error) {
w, err := c.wire()
if err != nil {
return nil, err
}
return json.Marshal(w)
}
// DecodeComposition reads a composition; it refuses an unknown version (keys.ErrUnknownVersion), a field it does not
// know, and what EncodeComposition refuses.
func DecodeComposition(data []byte) (Composition, error) {
if err := keys.CheckVersion(data); err != nil {
return Composition{}, err
}
var w composedWire
d := json.NewDecoder(bytes.NewReader(data))
d.DisallowUnknownFields()
if err := d.Decode(&w); err != nil {
return Composition{}, fmt.Errorf("node-engine: a composition does not fit its shape: %w", err)
}
c := Composition{V: w.V, Node: w.Node, Module: w.Module, FP: w.FP, Data: w.Data, Resources: w.Resources,
Filled: w.Filled, Plan: w.Plan, Refused: w.Refused}
if w.Own != nil {
c.Own = ownFromWire(*w.Own)
}
if err := c.check(); err != nil {
return Composition{}, err
}
return c, nil
}
// ComposedDigest is a composition's digest, "sha256:<hex>", of its encoding without its plan. A want names it (ADR 0539
// clause 1), and the node's engine writes it beside the composition. A refused composition has no digest.
func ComposedDigest(c Composition) (string, error) {
if c.Refused != "" {
return "", fmt.Errorf("node-engine: the composition of %s on %s is refused (%s) and has no digest", c.Module, c.Node, c.Refused)
}
c.Plan = nil
raw, err := EncodeComposition(c)
if err != nil {
return "", err
}
sum := sha256.Sum256(raw)
return "sha256:" + hex.EncodeToString(sum[:]), nil
}
// SameComposition says whether two compositions are the same value, their plans included, so the engine writes only
// when they differ.
func SameComposition(a, b Composition) bool {
ra, errA := EncodeComposition(a)
rb, errB := EncodeComposition(b)
return errA == nil && errB == nil && bytes.Equal(ra, rb)
}
@@ -0,0 +1,130 @@
package nodeengineclient
import (
"encoding/json"
"fmt"
"os"
"strings"
"testing"
seat "git.novox.be/novox/mesh-contracts/go/seats/nodeengine"
)
// captured is resources of ace's declaration of 2026-10-24 (the controller's `plan` verb answered it; testdata/captured-ace.json
// keeps three modules of it, never typed by hand), composed here by module under a stand-in fingerprint.
func captured(t *testing.T) (account string, byModule map[string][]json.RawMessage) {
t.Helper()
raw, err := os.ReadFile("testdata/captured-ace.json")
if err != nil {
t.Fatal(err)
}
var c struct {
Account string `json:"provisioner_account"`
Resources []json.RawMessage `json:"resources"`
}
if err := json.Unmarshal(raw, &c); err != nil {
t.Fatal(err)
}
byModule = map[string][]json.RawMessage{}
for _, r := range c.Resources {
var h struct {
Module string `json:"module"`
}
_ = json.Unmarshal(r, &h)
byModule[h.Module] = append(byModule[h.Module], r)
}
return c.Account, byModule
}
func fp(n int) string { return sourcePrefix + fmt.Sprintf("%064x", n) }
// TestCompositionDigests: a composition's digest is the one the first writer computed (the values are the digests
// mesh-sdk's delivery package gave for the same compositions when it moved here), whatever order the contract's generated
// types sort their fields in, and the plan is no part of it.
func TestCompositionDigests(t *testing.T) {
account, by := captured(t)
own := seat.NodeOwn{
Adoption: &seat.Adoption{Taken: []string{"avahi"}, Untaken: map[string][]string{"plex": {"plex.bundle-code"}}},
Identities: []seat.DeclaredIdentity{{Module: "plex", Name: "plex", Uid: 1001, Gid: 1001, Groups: []int64{1002}, Runner: "plex"}},
Groups: []seat.DeclaredGroup{{Name: "mesh-media", Gid: 1002}},
Libraries: []seat.DeclaredLibrary{{Library: "movies", Root: "/srv/media/movies", Group: "mesh-media", Gid: 1002, Taken: true, Paths: []string{"/srv/media/movies"}}},
ProvisionerAccount: "mesh-provisioner-client",
}
cases := []struct {
name string
c Composition
digest string
}{
{"a node's own, the account only", Composition{Node: "ace", Own: seat.NodeOwn{ProvisionerAccount: account}}, "sha256:17fde3a215d522619c18bc80b8c236975e25b2d2980a984aebd7c18376b095a2"},
{"a node's own, every part", Composition{Node: "ace", Own: own}, "sha256:b8ac7ace490a3c483c3ae18a9cda58e607aa6b69167fa274c062949c7b34fdbf"},
{"one package", Composition{Node: "ace", Module: "buildah", FP: fp(7), Resources: by["buildah"]}, "sha256:84e480a7a62333960b3e4be21a7548bbaa09572ef73ca8fb9dc6dfd1228aeb6b"},
{"a service and a file", Composition{Node: "ace", Module: "mesh-wireguard", FP: fp(14), Resources: by["mesh-wireguard"]}, "sha256:d6edec2474f606503e55d4ee2e0b4f5d1ffc25d016e4bc595e86ea67fe205b8c"},
{"a plan beside the resources", Composition{Node: "ace", Module: "hostname", FP: fp(8), Resources: by["hostname"], Plan: json.RawMessage(`{"files":[{"path":"/etc/hostname"}]}`)}, "sha256:49e17d6c471ee65cdef745ca3ac5cea4d37b6a100e11d8434dc64a6e2949e57e"},
}
for _, c := range cases {
got, err := ComposedDigest(c.c)
if err != nil || got != c.digest {
t.Errorf("%s: digest %s, %v; want %s", c.name, got, err, c.digest)
}
raw, err := EncodeComposition(c.c)
if err != nil {
t.Errorf("%s: %v", c.name, err)
continue
}
back, err := DecodeComposition(raw)
if err != nil {
t.Errorf("%s: %v", c.name, err)
continue
}
if !SameComposition(c.c, back) {
t.Errorf("%s: reads back as another composition", c.name)
}
again, _ := ComposedDigest(back)
if again != c.digest {
t.Errorf("%s: read back, its digest is %s", c.name, again)
}
}
a := Composition{Node: "ace", Module: "hostname", FP: fp(8), Resources: by["hostname"], Plan: json.RawMessage(`{"a":1}`)}
b := a
b.Plan = json.RawMessage(`{"a":2}`)
da, _ := ComposedDigest(a)
db, _ := ComposedDigest(b)
if da != db || SameComposition(a, b) {
t.Error("a plan changes the composition's encoding and not its digest")
}
}
// TestCompositionRefusals: what the codec refuses.
func TestCompositionRefusals(t *testing.T) {
_, by := captured(t)
ok := Composition{Node: "ace", Module: "buildah", FP: fp(7), Resources: by["buildah"]}
cases := []struct {
name string
c Composition
want string
}{
{"an old-form fingerprint", Composition{Node: "ace", Module: "buildah", FP: "src1:abc", Resources: ok.Resources}, "new form"},
{"a resource of another module", Composition{Node: "ace", Module: "hostname", FP: fp(8), Resources: ok.Resources}, "is of"},
{"the node's own parts on a module", Composition{Node: "ace", Module: "buildah", FP: fp(7), Own: seat.NodeOwn{ProvisionerAccount: "x"}}, "node's own parts"},
{"a refusal that holds resources", Composition{Node: "ace", Module: "buildah", FP: fp(7), Resources: ok.Resources, Refused: "no secret"}, "refused and holds"},
{"the node's own with a fingerprint", Composition{Node: "ace", FP: fp(7)}, "names no fingerprint"},
{"a node name with a dot", Composition{Node: "a.ce"}, "node"},
}
for _, c := range cases {
if _, err := EncodeComposition(c.c); err == nil || !strings.Contains(err.Error(), c.want) {
t.Errorf("%s: %v, want an error saying %q", c.name, err, c.want)
}
}
if _, err := ComposedDigest(Composition{Node: "ace", Module: "buildah", FP: fp(7), Refused: "no secret"}); err == nil {
t.Error("a refused composition has a digest")
}
if _, err := DecodeComposition([]byte(`{"v":2,"node":"ace","resources":[]}`)); err == nil {
t.Error("a composition of version 2 is read")
}
if _, err := DecodeComposition([]byte(`{"v":1,"node":"ace","resources":[],"extra":1}`)); err == nil {
t.Error("a composition with a field it does not know is read")
}
if _, err := DecodeComposition([]byte(`{"v":1,"node":"ace","resources":[],"signature":"x"}`)); err != nil {
t.Errorf("a signed composition of the ended controller is refused: %v", err)
}
}
@@ -0,0 +1,643 @@
// Package enginereads is how a mesh-delivery part learns what each node's engine reads of a manifest (novox/hq ADR 0618
// §3, §4): it asks the node seat node-engine's verb manifest-reads, on the part's start, on the seat's event
// manifest-reads-changed for that node and when a person asks again, and keeps each node's last answer in a state of its
// own, one key per node, so a node that is away keeps the statement it last made (ADR 0624 §1). The assigner, the
// builder and the delivery view share it; it is the seat node-engine's client's, not mesh-sdk's (ADR 0634 §1, §2).
package enginereads
import (
"context"
"encoding/json"
"errors"
"fmt"
"slices"
"sort"
"strings"
"sync"
"time"
stdio "git.novox.be/novox/mesh-sdk/go"
"git.novox.be/novox/mesh-sdk/go/busretry"
"git.novox.be/novox/mesh-contracts/go/seats/nodeengine"
)
// The verb and the event as the seat's contract names them (mesh-contracts seats/nodeengine), as a manifest names them,
// and the subject the event is said on with the engine's machine last.
const (
Verb = nodeengine.VerbManifestReads
Event = nodeengine.EventManifestReadsChanged
Invoke = "seat:" + nodeengine.Seat + "." + Verb
Consumes = nodeengine.Seat + "." + Event
)
// SubjectPrefix is the event's subject up to the machine, its last token.
var SubjectPrefix = strings.TrimSuffix(nodeengine.SubjectManifestReadsChanged("*"), "*")
// The reasons an engine says its reads, in the event's why (ADR 0618 §4).
const (
// ReadsStarted: the engine started, or what it states changed.
ReadsStarted = "started"
// ReadsReturned: the engine's node returned after being away.
ReadsReturned = "returned"
)
// ContractVersion is the layout version of the verb's answer and the event's body this reader reads.
const ContractVersion = 1
// checkNode refuses a node that is no subject token.
func checkNode(node string) error {
if node == "" || strings.ContainsAny(node, ".*> \t\n") {
return fmt.Errorf("%q is no node", node)
}
return nil
}
// checkReads refuses an empty reader capability.
func checkReads(node string, reads []string) error {
for _, c := range reads {
if strings.TrimSpace(c) == "" {
return fmt.Errorf("the reads of %s name an empty reader capability", node)
}
}
return nil
}
// DecodeAnswer reads the verb's answer as the contract lays it out, and judges it: the layout's version, the node a
// name, no empty capability, and its stated-at an RFC 3339 time (the contract carries no checks; its reader does).
func DecodeAnswer(raw []byte) (nodeengine.ManifestReadsAnswer, time.Time, error) {
var a nodeengine.ManifestReadsAnswer
if err := json.Unmarshal(raw, &a); err != nil {
return a, time.Time{}, err
}
if a.V != ContractVersion {
return a, time.Time{}, fmt.Errorf("the answer's layout is version %d, not %d", a.V, ContractVersion)
}
if err := checkNode(a.Node); err != nil {
return a, time.Time{}, err
}
if err := checkReads(a.Node, a.ManifestReads); err != nil {
return a, time.Time{}, err
}
at, err := time.Parse(time.RFC3339Nano, a.StatedAt)
if err != nil {
return a, time.Time{}, fmt.Errorf("stated-at %q is no RFC 3339 time", a.StatedAt)
}
return a, at, nil
}
// DecodeChanged reads the event's body as the contract lays it out, and judges it as DecodeAnswer does, its why one of
// ReadsStarted and ReadsReturned.
func DecodeChanged(raw []byte) (nodeengine.ManifestReadsChangedEvent, error) {
var e nodeengine.ManifestReadsChangedEvent
if err := json.Unmarshal(raw, &e); err != nil {
return e, err
}
if e.V != ContractVersion {
return e, fmt.Errorf("the event's layout is version %d, not %d", e.V, ContractVersion)
}
if err := checkNode(e.Node); err != nil {
return e, err
}
if e.Why != ReadsStarted && e.Why != ReadsReturned {
return e, fmt.Errorf("the reads of %s changed for %q, not %s or %s", e.Node, e.Why, ReadsStarted, ReadsReturned)
}
if err := checkReads(e.Node, e.ManifestReads); err != nil {
return e, err
}
if _, err := time.Parse(time.RFC3339Nano, e.At); err != nil {
return e, fmt.Errorf("at %q is no RFC 3339 time", e.At)
}
return e, nil
}
// Where a node's statement came from.
const (
// FromVerb: the node's engine answered the verb.
FromVerb = "verb"
// FromRecord: the engine did not answer it (an engine built before the verb, or nobody answered) and the node's record
// `<node>.node` was read once in its place (the fallback, until every counted engine serves the verb; issue 774).
FromRecord = "record"
// FromNone: neither said anything, so the node is read as stating the baseline (ADR 0614 §4).
FromNone = "none"
)
// Version is the version of a statement this package writes and the only one it reads.
const Version = 1
// Statement is one node's key: what its engine states it reads, when it stated it, when it was asked, where it came
// from, whether the engine answered that ask (it is present), and, while a person's ask again waits, that ask.
type Statement struct {
V int `json:"v"`
Node string `json:"node"`
ManifestReads []string `json:"manifest-reads,omitempty"`
StatedAt time.Time `json:"stated-at,omitzero"`
AskedAt time.Time `json:"asked-at,omitzero"`
From string `json:"from"`
Answered bool `json:"answered"`
Words string `json:"words,omitempty"`
AskAgain *Again `json:"ask-again,omitempty"`
}
// Again is a person's ask again of one node, written by the tool and answered by the part that keeps the state.
type Again struct {
By string `json:"by"`
Why string `json:"why"`
At time.Time `json:"at"`
}
// Encode is s as JSON at Version.
func Encode(s Statement) ([]byte, error) {
if s.Node == "" || strings.ContainsAny(s.Node, ".*> ") {
return nil, fmt.Errorf("enginereads: %q is no node", s.Node)
}
s.V = Version
return json.Marshal(s)
}
// Decode reads a statement; it refuses another version and a value naming no node.
func Decode(raw []byte) (Statement, error) {
var s Statement
if err := json.Unmarshal(raw, &s); err != nil {
return Statement{}, err
}
if s.V != Version {
return Statement{}, fmt.Errorf("enginereads: version %d, not %d", s.V, Version)
}
if s.Node == "" {
return Statement{}, errors.New("enginereads: a statement names no node")
}
return s, nil
}
// Caller calls a seat's verb on one node: process.Conn.CallOn.
type Caller func(node, key string, body any) (json.RawMessage, error)
// Ask asks node's engine the verb once and says what it answered. An engine that answered the call with an error is
// present and states nothing through it; one that did not answer is not present.
func Ask(call Caller, node string, now time.Time) Statement {
s := Statement{Node: node, AskedAt: now.UTC(), From: FromNone}
raw, err := call(node, Invoke, map[string]any{})
var answered *busretry.Answered
switch {
case err == nil:
a, at, derr := DecodeAnswer(raw)
if derr == nil && a.Node != node {
derr = fmt.Errorf("it names the node %s", a.Node)
}
if derr != nil {
s.Answered, s.Words = true, "the engine answered what is no statement: "+derr.Error()
return s
}
s.ManifestReads, s.StatedAt, s.From, s.Answered = slices.Clone(a.ManifestReads), at.UTC(), FromVerb, true
case errors.As(err, &answered):
s.Answered, s.Words = true, "the engine does not serve "+Verb+": "+answered.Words
default:
s.Words = "nobody answered " + Verb + ": " + err.Error()
}
return s
}
// NodeOf is the node an event of manifest-reads-changed is about, read from its subject's last token and held equal to
// its body's; ok false for a subject of another event.
func NodeOf(subject string, data []byte) (string, bool, error) {
node, ok := strings.CutPrefix(subject, SubjectPrefix)
if !ok || node == "" || strings.Contains(node, ".") {
return "", false, nil
}
e, err := DecodeChanged(data)
if err != nil {
return node, true, err
}
if e.Node != node {
return node, true, fmt.Errorf("the event on %s names the node %s", subject, e.Node)
}
return node, true, nil
}
// Keeper keeps every node's statement for one part: in memory, and in the part's state when Put is set, so a statement
// outlives the part's restart. Nothing in it waits on a timer: a node is asked when the part says so.
type Keeper struct {
// Call asks the verb; nil asks nothing (every node then reads as the fallback or the baseline).
Call Caller
// Fallback reads a node's record `<node>.node` once where its engine did not answer the verb; nil reads none.
Fallback func(node string) ([]string, bool)
// Put writes a node's key of the part's state, Delete removes it; nil keeps the statements in memory only.
Put func(node string, value []byte) error
Delete func(node string) error
// Changed is told after a node's statement changed.
Changed func()
Logf func(string, ...any)
Now func() time.Time
// Sync asks in the caller's goroutine (tests); otherwise each ask runs on its own.
Sync bool
mu sync.Mutex
by map[string]Statement
asking map[string]bool
}
func (k *Keeper) logf(f string, a ...any) {
if k.Logf != nil {
k.Logf(f, a...)
}
}
func (k *Keeper) now() time.Time {
if k.Now != nil {
return k.Now()
}
return time.Now()
}
// AskNode asks node's engine, unless an ask of it runs already; why is said in the log.
func (k *Keeper) AskNode(node, why string) {
k.mu.Lock()
if k.asking == nil {
k.asking = map[string]bool{}
}
if k.asking[node] {
k.mu.Unlock()
return
}
k.asking[node] = true
k.mu.Unlock()
if k.Sync {
k.ask(node, why)
return
}
go k.ask(node, why)
}
func (k *Keeper) ask(node, why string) {
defer func() {
k.mu.Lock()
delete(k.asking, node)
k.mu.Unlock()
}()
s := Statement{Node: node, AskedAt: k.now().UTC(), From: FromNone, Words: "nothing asks the verb"}
if k.Call != nil {
s = Ask(k.Call, node, k.now())
}
if s.From != FromVerb && k.Fallback != nil {
if reads, ok := k.Fallback(node); ok {
s.ManifestReads, s.From = slices.Clone(reads), FromRecord
k.logf("%s on %s (%s): %s; its record %s.node is read once in its place (until every counted engine serves the verb)",
Verb, node, why, s.Words, node)
}
}
if s.From == FromNone {
k.logf("%s on %s (%s): %s; the node reads as its last statement, or the baseline", Verb, node, why, s.Words)
}
k.take(s)
}
// take keeps s, unless the node did not answer and an earlier statement stands: an away node keeps its last one (ADR 0624
// §1), marked as not answering.
func (k *Keeper) take(s Statement) {
k.mu.Lock()
if k.by == nil {
k.by = map[string]Statement{}
}
if old, ok := k.by[s.Node]; ok && !s.Answered && old.From != FromNone {
old.Answered, old.AskedAt, old.Words, old.AskAgain = false, s.AskedAt, s.Words, nil
s = old
}
k.by[s.Node] = s
k.mu.Unlock()
if k.Put != nil {
raw, err := Encode(s)
if err == nil {
err = k.Put(s.Node, raw)
}
if err != nil {
k.logf("the statement of %s could not be kept: %v", s.Node, err)
}
}
if k.Changed != nil {
k.Changed()
}
}
// Heard takes a change of the part's own state in: at the start, every node's last statement; later, a person's ask again
// (asked now) or a key a person dropped (forgotten). The part's own writes come back here and change nothing.
func (k *Keeper) Heard(node string, raw []byte, deleted bool) {
if deleted {
k.mu.Lock()
_, had := k.by[node]
delete(k.by, node)
k.mu.Unlock()
if had && k.Changed != nil {
k.Changed()
}
return
}
s, err := Decode(raw)
if err != nil || s.Node != node {
k.logf("the key %s of the engine reads is no statement of it, left: %v", node, err)
return
}
if s.AskAgain != nil {
k.AskNode(node, fmt.Sprintf("asked again by %s: %s", s.AskAgain.By, s.AskAgain.Why))
return
}
k.mu.Lock()
if k.by == nil {
k.by = map[string]Statement{}
}
_, had := k.by[node]
if !had {
k.by[node] = s
}
k.mu.Unlock()
if !had && k.Changed != nil {
k.Changed()
}
}
// Forget drops a node the part no longer counts (it left the placement): its key too.
func (k *Keeper) Forget(node string) {
k.mu.Lock()
_, had := k.by[node]
delete(k.by, node)
k.mu.Unlock()
if had && k.Delete != nil {
if err := k.Delete(node); err != nil {
k.logf("the statement of %s could not be removed: %v", node, err)
}
}
}
// Known says whether the part holds a statement of node, from any source.
func (k *Keeper) Known(node string) bool {
k.mu.Lock()
defer k.mu.Unlock()
_, ok := k.by[node]
return ok
}
// Statements is every node's statement, by node.
func (k *Keeper) Statements() map[string]Statement {
k.mu.Lock()
defer k.mu.Unlock()
out := make(map[string]Statement, len(k.by))
for n, s := range k.by {
out[n] = s
}
return out
}
// Reads is, of the nodes given, what each states it reads: its last statement, nil (the baseline) for a node with none.
func (k *Keeper) Reads(nodes []string) map[string][]string {
k.mu.Lock()
defer k.mu.Unlock()
out := make(map[string][]string, len(nodes))
for _, n := range nodes {
out[n] = slices.Clone(k.by[n].ManifestReads)
}
return out
}
// Present is, of the nodes given, those whose engine answered when last asked, with what each states.
func (k *Keeper) Present(nodes []string) map[string][]string {
k.mu.Lock()
defer k.mu.Unlock()
out := map[string][]string{}
for _, n := range nodes {
if s, ok := k.by[n]; ok && s.Answered {
out[n] = slices.Clone(s.ManifestReads)
}
}
return out
}
// State is what the tool needs of the part's state: the runtime's (stdio.KeptState through KeptState), or a test's.
type State interface {
Keys() ([]string, error)
Get(key string) ([]byte, uint64, bool, error)
Put(key string, value []byte) (uint64, error)
Delete(key string) error
// Await waits at most bound for a revision of key after after whose value done says is the answer.
Await(key string, after uint64, done func([]byte) bool, bound time.Duration) ([]byte, error)
}
// Read lists every node's statement in st, by node name, or one node's; a key that does not read is named with why.
func Read(st State, node string) (map[string]any, error) {
keys := []string{node}
if node == "" {
var err error
if keys, err = st.Keys(); err != nil {
return nil, err
}
}
sort.Strings(keys)
nodes := map[string]any{}
for _, key := range keys {
raw, _, found, err := st.Get(key)
switch {
case err != nil:
return nil, err
case !found:
nodes[key] = map[string]any{"from": FromNone, "words": "no statement is kept: read as the baseline"}
continue
}
s, derr := Decode(raw)
if derr != nil {
nodes[key] = map[string]any{"unread": derr.Error()}
continue
}
nodes[key] = s
}
return map[string]any{"nodes": nodes}, nil
}
// AskAgainOf writes node's key with a person's ask again and waits at most bound for the part's answer: the next
// statement it writes there.
func AskAgainOf(st State, node, by, why string, now time.Time, bound time.Duration) (Statement, error) {
if node == "" || strings.ContainsAny(node, ".*> ") {
return Statement{}, fmt.Errorf("%q is no node", node)
}
if strings.TrimSpace(why) == "" {
return Statement{}, errors.New("say why it is asked again")
}
s := Statement{Node: node, From: FromNone}
if raw, _, found, err := st.Get(node); err != nil {
return Statement{}, err
} else if found {
if old, err := Decode(raw); err == nil {
s = old
}
}
s.AskAgain = &Again{By: by, Why: why, At: now.UTC()}
raw, err := Encode(s)
if err != nil {
return Statement{}, err
}
rev, err := st.Put(node, raw)
if err != nil {
return Statement{}, err
}
answer, err := st.Await(node, rev, func(v []byte) bool {
got, err := Decode(v)
return err == nil && got.AskAgain == nil
}, bound)
if err != nil {
return Statement{}, fmt.Errorf("the part did not answer the ask again of %s within %s: %w", node, bound, err)
}
return Decode(answer)
}
// KeptState is a part's state as the runtime keeps it, for the tool.
func KeptState(s stdio.KeptState) State { return keptState{s} }
type keptState struct{ s stdio.KeptState }
func (k keptState) Keys() ([]string, error) { return k.s.Keys() }
func (k keptState) Get(key string) ([]byte, uint64, bool, error) {
e, err := k.s.Get(key)
if err != nil || e == nil {
return nil, 0, false, err
}
return e.Value, e.Revision, true, nil
}
func (k keptState) Put(key string, value []byte) (uint64, error) {
return k.s.Put(key, json.RawMessage(value))
}
func (k keptState) Delete(key string) error { return k.s.Delete(key) }
// Await watches key for the answer, and reads it once too in case the watch heard it before it began; a context's bound,
// no timer (ADR 0437 §8).
func (k keptState) Await(key string, after uint64, done func([]byte) bool, bound time.Duration) ([]byte, error) {
heard := make(chan []byte, 1)
if err := k.s.Watch(key, func(c stdio.StateChange) error {
if c.Op == "put" && c.Revision > after && done(c.Value) {
select {
case heard <- c.Value:
default:
}
}
return nil
}); err != nil {
return nil, err
}
if e, err := k.s.Get(key); err == nil && e != nil && e.Revision > after && done(e.Value) {
return e.Value, nil
}
wait, cancel := context.WithTimeout(context.Background(), bound)
defer cancel()
select {
case v := <-heard:
return v, nil
case <-wait.Done():
return nil, errors.New("it is not running, or cannot read the verb's answer")
}
}
// AnswerWithin is how long the tool waits for the part to answer an ask again: the verb's own call bound and a margin.
var AnswerWithin = 25 * time.Second
// Tool is a part's tool over its state of engine reads (tools-first, ADR 0618 §3): read every node's statement or one,
// ask one node's engine again now, and, where drops, drop one node's key (a node the part keeps by mistake). owner names
// the part in the words.
func Tool(name, owner string, st State, drops bool, now func() time.Time) stdio.Tool {
props := map[string]any{
"node": map[string]any{"type": "string", "description": "one node; every node when left out"},
"ask_again": map[string]any{"type": "string", "description": "\"true\": ask the node's engine " + Verb + " again now, and answer what it states"},
"why": map[string]any{"type": "string", "description": "why it is asked again or dropped, in plain words"},
}
description := "What each node's engine states it reads of a manifest (manifest words, forms and seat names, novox/hq ADR 0618 §3), " +
"as " + owner + " last asked the node-engine seat's verb " + Verb + " and uses it: per node its capabilities, when the engine " +
"stated them and when it was asked, whether it answered (present) and where the statement came from: verb, record (the " +
"engine did not answer and its node record was read once in its place) or none (read as the baseline). A node that is " +
"away keeps its last statement. With node and ask_again, " + owner + " asks that node's engine again and answers its new " +
"statement."
if drops {
props["drop"] = map[string]any{"type": "string", "description": "\"true\": drop the node's key, so it reads as stating the baseline until its engine is asked again"}
description += " With node and drop, the node's key is dropped."
}
return stdio.Tool{Name: name, Description: description, Input: map[string]any{"type": "object", "properties": props},
Run: func(args map[string]any) (any, error) {
str := func(k string) string { s, _ := args[k].(string); return strings.TrimSpace(s) }
node := str("node")
switch {
case str("ask_again") == "true":
return AskAgainOf(st, node, stdio.CallerOf(args), str("why"), now(), AnswerWithin)
case drops && str("drop") == "true":
if node == "" || str("why") == "" {
return nil, errors.New("drop takes node and why")
}
if err := st.Delete(node); err != nil {
return nil, err
}
return map[string]any{"node": node, "dropped": true,
"next": "the node reads as stating the baseline until its engine is asked again (ask_again, its event, or " + owner + "'s start)"}, nil
}
return Read(st, node)
}}
}
// How a node answered a survey (Survey).
const (
// Stated: the engine answered the verb.
Stated = "stated"
// NotServed: the engine answered, but does not serve the verb (an engine built before it).
NotServed = "not served"
// Away: nobody answered within the bound: the node is away, or its engine does not serve the verb and nothing else
// answers for it.
Away = "away"
)
// SurveyRow is one node's row of a survey: how it answered, its statement when it stated one, and its record's reads
// where it did not (the fallback the assigner reads in its place).
type SurveyRow struct {
Node string `json:"node"`
Answer string `json:"answer"`
ManifestReads []string `json:"manifest-reads,omitempty"`
StatedAt time.Time `json:"stated-at,omitzero"`
Record []string `json:"record,omitempty"`
RecordRead bool `json:"record-read,omitempty"`
Words string `json:"words,omitempty"`
}
// Survey asks every node's engine the verb at once, each ask bounded by bound, so the whole survey takes at most about
// bound however many nodes there are; a node that did not answer is named with its record's reads where record reads
// one. Sorted by node.
func Survey(call Caller, nodes []string, record func(node string) ([]string, bool), bound time.Duration, now time.Time) []SurveyRow {
rows := make([]SurveyRow, len(nodes))
var wg sync.WaitGroup
for i, node := range nodes {
wg.Add(1)
go func() {
defer wg.Done()
got := make(chan Statement, 1)
go func() { got <- Ask(call, node, now) }()
var s Statement
wait, cancel := context.WithTimeout(context.Background(), bound)
defer cancel()
select {
case s = <-got:
case <-wait.Done():
s = Statement{Node: node, Words: fmt.Sprintf("no answer within %s", bound)}
}
row := SurveyRow{Node: node, Words: s.Words}
switch {
case s.From == FromVerb:
row.Answer, row.ManifestReads, row.StatedAt = Stated, s.ManifestReads, s.StatedAt
case s.Answered:
row.Answer = NotServed
default:
row.Answer = Away
}
if row.Answer != Stated && record != nil {
row.Record, row.RecordRead = record(node)
}
rows[i] = row
}()
}
wg.Wait()
sort.Slice(rows, func(i, j int) bool { return rows[i].Node < rows[j].Node })
return rows
}
@@ -0,0 +1,40 @@
package enginereads
import (
"strings"
"testing"
)
// The answer and the event are judged as the contract lays them out and its reader checks: the layout's version, a node
// that is a name, no empty capability, a time, and an event's why one of the two.
func TestTheAnswerAndTheEventAreJudged(t *testing.T) {
const at = "2026-11-05T12:00:00Z"
for name, c := range map[string]struct{ body, bad string }{
"whole": {`{"v":1,"node":"ace","manifest-reads":["mesh-config.resolved"],"stated-at":"` + at + `"}`, ""},
"old layout": {`{"v":2,"node":"ace","stated-at":"` + at + `"}`, "version"},
"bad node": {`{"v":1,"node":"a.b","stated-at":"` + at + `"}`, "no node"},
"empty read": {`{"v":1,"node":"ace","manifest-reads":[" "],"stated-at":"` + at + `"}`, "empty reader"},
"no time": {`{"v":1,"node":"ace","stated-at":"yesterday"}`, "RFC 3339"},
} {
_, _, err := DecodeAnswer([]byte(c.body))
if (c.bad == "") != (err == nil) || (err != nil && !strings.Contains(err.Error(), c.bad)) {
t.Errorf("answer %s: %v, want %q", name, err, c.bad)
}
}
for name, c := range map[string]struct{ body, bad string }{
"whole": {`{"v":1,"node":"ace","why":"started","manifest-reads":["a.b"],"at":"` + at + `"}`, ""},
"returned": {`{"v":1,"node":"ace","why":"returned","at":"` + at + `"}`, ""},
"old layout": {`{"v":2,"node":"ace","why":"started","at":"` + at + `"}`, "version"},
"no node": {`{"v":1,"node":"","why":"started","at":"` + at + `"}`, "no node"},
"another why": {`{"v":1,"node":"ace","why":"bored","at":"` + at + `"}`, "bored"},
"no time": {`{"v":1,"node":"ace","why":"started","at":"yesterday"}`, "RFC 3339"},
} {
_, err := DecodeChanged([]byte(c.body))
if (c.bad == "") != (err == nil) || (err != nil && !strings.Contains(err.Error(), c.bad)) {
t.Errorf("event %s: %v, want %q", name, err, c.bad)
}
}
if Verb != "manifest-reads" || Event != "manifest-reads-changed" || Consumes != "node-engine.manifest-reads-changed" || Invoke != "seat:node-engine.manifest-reads" {
t.Errorf("the names are %s %s %s %s", Verb, Event, Consumes, Invoke)
}
}
+17
View File
@@ -0,0 +1,17 @@
module git.novox.be/novox/mesh-contracts/go/clients/nodeengineclient
go 1.26.0
require (
git.novox.be/novox/mesh-contracts/go/seats/nodeengine v0.0.0-00010101000000-000000000000
git.novox.be/novox/mesh-sdk/go v0.0.0-00010101000000-000000000000
)
require (
github.com/klauspost/compress v1.20.0 // indirect
github.com/nats-io/nats.go v1.54.0 // indirect
github.com/nats-io/nkeys v0.4.16 // indirect
github.com/nats-io/nuid v1.0.1 // indirect
golang.org/x/crypto v0.57.0 // indirect
golang.org/x/sys v0.48.0 // indirect
)
+12
View File
@@ -0,0 +1,12 @@
github.com/klauspost/compress v1.20.0 h1:a3C1ke2ohxFymNlb2HWAHjDeKCI90scRskErZkR0ezA=
github.com/klauspost/compress v1.20.0/go.mod h1:LUdAzn7YLVvxLpc7y3V1m40wESHTgc1422pwwBSKYuI=
github.com/nats-io/nats.go v1.54.0 h1:vsXoOxjHp/GmPUN+EcI7uOf/uB+iAP+kEsAFNQN0yzA=
github.com/nats-io/nats.go v1.54.0/go.mod h1:y+DZoD1oBOYfZTU681eTUiUjI0vbqYGixNVFHcjHJ0k=
github.com/nats-io/nkeys v0.4.16 h1:rd5oAuLOb8mnAycB0xleuEBNS1pVVnN0fv/FF34Eypg=
github.com/nats-io/nkeys v0.4.16/go.mod h1:llLgWoI0o4z/Q57q2R1kHfmocyhGV6VG/U18Glg1Afs=
github.com/nats-io/nuid v1.0.1 h1:5iA8DT8V7q8WK2EScv2padNa/rTESc1KdnPw4TC2paw=
github.com/nats-io/nuid v1.0.1/go.mod h1:19wcPz3Ph3q0Jbyiqsd0kePYG7A95tJPxeL+1OSON2c=
golang.org/x/crypto v0.57.0 h1:3ZVCjf8Ggz7zneR/EHRVx68Ctf+2pmIMP2UFhh9cC6M=
golang.org/x/crypto v0.57.0/go.mod h1:Fdz0i5U6CoizGwLda9DttjSk6qlZo25zYNtR+ycvuZA=
golang.org/x/sys v0.48.0 h1:bbX/i/6MgT9BVLM9RT1thmxL04yeTAhbEz4SyadbXoo=
golang.org/x/sys v0.48.0/go.mod h1:hNLxWAXmnKAxqDtdwIYC4bM9oQPEecfsnNMuSxOs3og=
+76
View File
@@ -0,0 +1,76 @@
package nodeengineclient
import (
"crypto/sha256"
"encoding/hex"
"encoding/json"
"errors"
"fmt"
"regexp"
"strings"
"time"
seat "git.novox.be/novox/mesh-contracts/go/seats/nodeengine"
"git.novox.be/novox/mesh-sdk/go/seal"
)
// The adoption handing of novox/hq ADR 0547 clause 9 (issue 1945): the node-engine of the node holding mesh-broker
// hands the bcrypt hashes of the bus accounts, in the bus module's have, sealed to the vault's own key (the seat's
// AccountsHanded), so that of every reader of the have state the vault alone can open them. These are the two ends of
// that box, which the node-engine and the vault both hold.
// HandedAccount is one user of an AccountsHanded box, opened: the bus user and the bcrypt hash the loaded list carries.
type HandedAccount struct {
User string `json:"user"`
Hash string `json:"hash"`
}
// KeyFingerprint is a public key's fingerprint as a handing names it: `sha256:<hex>` of its PEM, without the space
// around it.
func KeyFingerprint(publicPEM string) string {
sum := sha256.Sum256([]byte(strings.TrimSpace(publicPEM)))
return "sha256:" + hex.EncodeToString(sum[:])
}
// bcryptHash is the form of a bcrypt hash: version, cost, and 53 characters of salt and digest.
var bcryptHash = regexp.MustCompile(`^\$2[abxy]?\$[0-9]{2}\$[./A-Za-z0-9]{53}$`)
// IsBcrypt says whether s has the form of a bcrypt hash.
func IsBcrypt(s string) bool { return bcryptHash.MatchString(s) }
// HandAccounts seals the accounts to the vault's public key (SPKI PEM) as the handing of the list whose digest is from.
func HandAccounts(accounts []HandedAccount, vaultPEM, from string, at time.Time) (seat.AccountsHanded, error) {
if strings.TrimSpace(vaultPEM) == "" {
return seat.AccountsHanded{}, errors.New("node-engine: no vault key to seal the handed accounts to")
}
body, err := json.Marshal(accounts)
if err != nil {
return seat.AccountsHanded{}, err
}
box, err := seal.Seal(string(body), vaultPEM)
if err != nil {
return seat.AccountsHanded{}, fmt.Errorf("node-engine: the handed accounts could not be sealed to the vault's key: %w", err)
}
return seat.AccountsHanded{From: from, To: KeyFingerprint(vaultPEM), Users: int64(len(accounts)),
Box: seat.SealedBox{Ct: box.Ct, Eph: box.Eph, Iv: box.IV, Tag: box.Tag, V: int64(box.V)}, At: at.UTC()}, nil
}
// OpenHanded opens a handing with the vault's private key (PEM), refusing one sealed to another key than publicPEM's
// and a box that does not hold as many users as it says.
func OpenHanded(h seat.AccountsHanded, publicPEM, privatePEM string) ([]HandedAccount, error) {
if h.To != KeyFingerprint(publicPEM) {
return nil, fmt.Errorf("node-engine: the handing is sealed to the key %s, not to this one (%s)", h.To, KeyFingerprint(publicPEM))
}
plain, err := seal.Open(seal.Box{V: int(h.Box.V), Eph: h.Box.Eph, IV: h.Box.Iv, Tag: h.Box.Tag, Ct: h.Box.Ct}, privatePEM)
if err != nil {
return nil, fmt.Errorf("node-engine: the handing does not open: %w", err)
}
var out []HandedAccount
if err := json.Unmarshal([]byte(plain), &out); err != nil {
return nil, fmt.Errorf("node-engine: the handing holds no accounts: %w", err)
}
if int64(len(out)) != h.Users {
return nil, fmt.Errorf("node-engine: the handing says %d users and holds %d", h.Users, len(out))
}
return out, nil
}
@@ -0,0 +1,47 @@
package nodeengineclient
import (
"strings"
"testing"
"time"
"git.novox.be/novox/mesh-sdk/go/seal"
)
// A handing is sealed to the vault's key and opened with it alone; another key, a wrong count and a plain value are refused.
func TestAHandingOpensWithTheVaultsKeyAlone(t *testing.T) {
vault, err := seal.GenerateKeyPair()
if err != nil {
t.Fatal(err)
}
other, err := seal.GenerateKeyPair()
if err != nil {
t.Fatal(err)
}
hash := "$2a$10$" + strings.Repeat("a", 53)
accounts := []HandedAccount{{User: "a", Hash: hash}, {User: "b", Hash: hash}}
h, err := HandAccounts(accounts, vault.PublicKey, "sha256:list", time.Date(2026, 11, 5, 10, 0, 0, 0, time.FixedZone("x", 3600)))
if err != nil {
t.Fatal(err)
}
if h.Users != 2 || h.To != KeyFingerprint(vault.PublicKey) || h.At.Location() != time.UTC {
t.Errorf("handing = %+v", h)
}
got, err := OpenHanded(h, vault.PublicKey, vault.PrivateKey)
if err != nil || len(got) != 2 || got[1].User != "b" {
t.Errorf("opened %v, %v", got, err)
}
if _, err := OpenHanded(h, other.PublicKey, other.PrivateKey); err == nil {
t.Error("a handing sealed to the vault opened for another key")
}
h.Users = 3
if _, err := OpenHanded(h, vault.PublicKey, vault.PrivateKey); err == nil {
t.Error("a handing that says 3 users and holds 2 was opened")
}
if !IsBcrypt(hash) || IsBcrypt("plain") {
t.Error("IsBcrypt")
}
if _, err := HandAccounts(accounts, " ", "x", time.Now()); err == nil {
t.Error("sealed to no key")
}
}
+206
View File
@@ -0,0 +1,206 @@
package nodeengineclient
import (
"bytes"
"encoding/json"
"fmt"
"regexp"
seat "git.novox.be/novox/mesh-contracts/go/seats/nodeengine"
"git.novox.be/novox/mesh-sdk/go/keys"
)
// The state have (novox/hq ADR 0539 clause 9): a module's have on a node under `<node>.<module>` and the node's own facts
// under `<node>.node`; their layouts are the seat's contract (seat.HaveByNodeModuleValue, seat.HaveByNodeNodeValue). This
// file is the codec the node-engine, the assigner, the watchman and every reader share: it writes the version, and refuses
// a phase, a health, a judgement or a digest outside the lists.
// The phases of a module's change on a node, as the have states them (the seat's constants, short).
const (
PhaseBackingUp = seat.HaveByNodeModuleValuePhaseBackingUp
PhaseApplying = seat.HaveByNodeModuleValuePhaseApplying
PhaseChecking = seat.HaveByNodeModuleValuePhaseChecking
PhaseRunning = seat.HaveByNodeModuleValuePhaseRunning
PhaseFailed = seat.HaveByNodeModuleValuePhaseFailed
PhaseRemoved = seat.HaveByNodeModuleValuePhaseRemoved
PhaseHeld = seat.HaveByNodeModuleValuePhaseHeld
PhaseWaitingYes = seat.HaveByNodeModuleValuePhaseWaitingYes
)
// The healths a have carries.
const (
HealthOK = seat.HaveByNodeModuleValueHealthOk
HealthFailed = seat.HaveByNodeModuleValueHealthFailed
HealthWaits = seat.HaveByNodeModuleValueHealthWaits
HealthUnknown = seat.HaveByNodeModuleValueHealthUnknown
)
// The judgements of a move that waits for a yes.
const (
JudgedRecreates = seat.HaveByNodeModuleValueJudgementRecreates
JudgedMovesData = seat.HaveByNodeModuleValueJudgementMovesData
JudgedCannot = seat.HaveByNodeModuleValueJudgementCannotJudge
)
// ComposesOwn is a node applying its own compositions (the node facts' composes).
const ComposesOwn = "own"
// composedByTheEndedController is what an engine from before the controller ended wrote in composes: read, never written.
const composedByTheEndedController = "controller"
var digestForm = regexp.MustCompile(`^sha256:[0-9a-f]{64}$`)
func checkDigest(digest, of string) error {
if !digestForm.MatchString(digest) {
return fmt.Errorf("node-engine: %s %q is not sha256:<64 lowercase hex digits>", of, digest)
}
return nil
}
func checkHave(h seat.HaveByNodeModuleValue) error {
switch h.Phase {
case PhaseBackingUp, PhaseApplying, PhaseChecking, PhaseRunning, PhaseFailed, PhaseRemoved, PhaseHeld:
if h.Judgement != "" || h.Waits != "" || h.BuildAlone {
return fmt.Errorf("node-engine: only a have waiting for a yes names a judgement, the composition waiting and whether it changes the build alone")
}
if h.PullWait != nil && h.Phase != PhaseFailed {
return fmt.Errorf("node-engine: only a failed have names the pull it waits on, not one %s", h.Phase)
}
case PhaseWaitingYes:
if h.Judgement != JudgedRecreates && h.Judgement != JudgedMovesData && h.Judgement != JudgedCannot {
return fmt.Errorf("node-engine: a have waiting for a yes names the judgement %q, not recreates, moves-data or cannot-judge", h.Judgement)
}
if err := checkDigest(h.Waits, "the composition a have waits a yes for"); err != nil {
return err
}
default:
return fmt.Errorf("node-engine: phase %q is not backing-up, applying, checking, running, failed, removed, held or waiting-yes", h.Phase)
}
if w := h.PullWait; w != nil && (w.Image == "" || w.Holder == "") {
return fmt.Errorf("node-engine: a have's pull wait names no image or no holder")
}
if h.Composed != "" {
if err := checkDigest(h.Composed, "the have's composition digest"); err != nil {
return err
}
}
switch h.Health {
case HealthOK, HealthFailed, HealthWaits, HealthUnknown:
default:
return fmt.Errorf("node-engine: health %q is not ok, failed, waits or unknown", h.Health)
}
return nil
}
// EncodeHave is h as JSON at the current version; it refuses a phase or health outside the lists.
func EncodeHave(h seat.HaveByNodeModuleValue) ([]byte, error) {
if err := checkHave(h); err != nil {
return nil, err
}
h.V = keys.Version
return json.Marshal(h)
}
// DecodeHave reads a have; it refuses an unknown version (keys.ErrUnknownVersion), a field the contract does not know and
// what EncodeHave refuses.
func DecodeHave(data []byte) (seat.HaveByNodeModuleValue, error) {
if err := keys.CheckVersion(data); err != nil {
return seat.HaveByNodeModuleValue{}, err
}
h, err := seat.DecodeHaveByNodeModuleValue(data)
if err != nil {
return seat.HaveByNodeModuleValue{}, fmt.Errorf("node-engine: value does not fit its shape: %w", err)
}
if err := checkHave(h); err != nil {
return seat.HaveByNodeModuleValue{}, err
}
return h, nil
}
func checkHaveNode(n seat.HaveByNodeNodeValue) error {
switch n.Composes {
case "", ComposesOwn, composedByTheEndedController:
default:
return fmt.Errorf("node-engine: a node composes %q, not %s", n.Composes, ComposesOwn)
}
for group, a := range n.Candidates {
if group == "" {
return fmt.Errorf("node-engine: a node's candidate answer names no group")
}
switch a.Verdict {
case seat.CandidateAnswerVerdictSame, seat.CandidateAnswerVerdictDiffers, seat.CandidateAnswerVerdictRefused:
default:
return fmt.Errorf("node-engine: the answer to %s's candidate has the verdict %q", group, a.Verdict)
}
}
return nil
}
// EncodeHaveNode is n as JSON at the current version; it refuses a candidate answer with a verdict outside the lists.
func EncodeHaveNode(n seat.HaveByNodeNodeValue) ([]byte, error) {
if err := checkHaveNode(n); err != nil {
return nil, err
}
n.V = keys.Version
return json.Marshal(n)
}
// DecodeHaveNode reads a node's facts; it refuses an unknown version (keys.ErrUnknownVersion), a field the contract does
// not know and what EncodeHaveNode refuses.
func DecodeHaveNode(data []byte) (seat.HaveByNodeNodeValue, error) {
if err := keys.CheckVersion(data); err != nil {
return seat.HaveByNodeNodeValue{}, err
}
n, err := seat.DecodeHaveByNodeNodeValue(data)
if err != nil {
return seat.HaveByNodeNodeValue{}, fmt.Errorf("node-engine: value does not fit its shape: %w", err)
}
if err := checkHaveNode(n); err != nil {
return seat.HaveByNodeNodeValue{}, err
}
return n, nil
}
// SameHave says whether two haves are the same value, so a writer writes only when they differ: times compare as
// instants, the user list's and the handed accounts' time as well.
func SameHave(a, b seat.HaveByNodeModuleValue) bool {
return sameJSON(utcHave(a), utcHave(b))
}
// SameHaveNode says whether two node facts are the same value, compared as their JSON (maps sorted, spacing dropped).
func SameHaveNode(a, b seat.HaveByNodeNodeValue) bool { return sameJSON(a, b) }
// SameNodeRecord says whether two records are the same value but for their version.
func SameNodeRecord(a, b seat.NodeRecord) bool {
a.V, b.V = 0, 0
return sameJSON(a, b)
}
func utcHave(h seat.HaveByNodeModuleValue) seat.HaveByNodeModuleValue {
if w := h.PullWait; w != nil {
c := *w
c.Began = c.Began.UTC()
if c.RetriedAt != nil {
t := c.RetriedAt.UTC()
c.RetriedAt = &t
}
h.PullWait = &c
}
if u := h.UserList; u != nil {
c := *u
c.At = c.At.UTC()
if c.Handed != nil {
hd := *c.Handed
hd.At = hd.At.UTC()
c.Handed = &hd
}
h.UserList = &c
}
return h
}
func sameJSON(a, b any) bool {
ra, errA := json.Marshal(a)
rb, errB := json.Marshal(b)
return errA == nil && errB == nil && bytes.Equal(ra, rb)
}
+163
View File
@@ -0,0 +1,163 @@
package nodeengineclient
import (
"errors"
"strings"
"testing"
"time"
seat "git.novox.be/novox/mesh-contracts/go/seats/nodeengine"
"git.novox.be/novox/mesh-sdk/go/keys"
)
const digestA = "sha256:0000000000000000000000000000000000000000000000000000000000000001"
func TestHaveCodec(t *testing.T) {
good := seat.HaveByNodeModuleValue{Phase: PhaseRunning, Health: HealthOK, Composed: digestA, Fp: "src2:x"}
waiting := seat.HaveByNodeModuleValue{Phase: PhaseWaitingYes, Health: HealthOK, Judgement: JudgedRecreates, Waits: digestA}
pull := &seat.PullWait{Image: "i", Holder: "h"}
tests := map[string]struct {
have seat.HaveByNodeModuleValue
bad string
}{
"running": {good, ""},
"waiting for a yes": {waiting, ""},
"unknown phase": {seat.HaveByNodeModuleValue{Phase: "gone", Health: HealthOK}, "phase"},
"unknown health": {seat.HaveByNodeModuleValue{Phase: PhaseRunning, Health: "fine"}, "health"},
"judgement outside a yes": {seat.HaveByNodeModuleValue{Phase: PhaseRunning, Health: HealthOK, Judgement: JudgedCannot}, "judgement"},
"a yes without a digest": {seat.HaveByNodeModuleValue{Phase: PhaseWaitingYes, Health: HealthOK, Judgement: JudgedCannot, Waits: "x"}, "sha256"},
"pull wait on a running": {seat.HaveByNodeModuleValue{Phase: PhaseRunning, Health: HealthOK, PullWait: pull}, "pull"},
"pull wait without holder": {seat.HaveByNodeModuleValue{Phase: PhaseFailed, Health: HealthFailed, PullWait: &seat.PullWait{Image: "i"}}, "holder"},
"bad composed digest": {seat.HaveByNodeModuleValue{Phase: PhaseRunning, Health: HealthOK, Composed: "abc"}, "sha256"},
}
for name, tc := range tests {
t.Run(name, func(t *testing.T) {
raw, err := EncodeHave(tc.have)
if tc.bad != "" {
if err == nil || !strings.Contains(err.Error(), tc.bad) {
t.Fatalf("encode: %v, want it to name %q", err, tc.bad)
}
return
}
if err != nil {
t.Fatal(err)
}
back, err := DecodeHave(raw)
if err != nil {
t.Fatal(err)
}
tc.have.V = keys.Version
if !SameHave(back, tc.have) {
t.Fatalf("round trip differs: %+v", back)
}
})
}
if _, err := DecodeHave([]byte(`{"v":2,"phase":"running","health":"ok"}`)); !errors.Is(err, keys.ErrUnknownVersion) {
t.Fatalf("version 2: %v", err)
}
if _, err := DecodeHave([]byte(`{"v":1,"phase":"running","health":"ok","extra":1}`)); err == nil {
t.Fatal("an unknown field is read")
}
}
func TestSameHaveComparesTimesAsInstants(t *testing.T) {
at := time.Date(2026, 11, 5, 12, 0, 0, 0, time.UTC)
zone := time.FixedZone("x", 3600)
mk := func(tm time.Time) seat.HaveByNodeModuleValue {
return seat.HaveByNodeModuleValue{Phase: PhaseFailed, Health: HealthFailed,
PullWait: &seat.PullWait{Image: "i", Holder: "h", Began: tm},
UserList: &seat.UserListWritten{Source: "s", At: tm}}
}
if !SameHave(mk(at), mk(at.In(zone))) {
t.Fatal("the same instant in another zone differs")
}
if SameHave(mk(at), mk(at.Add(time.Second))) {
t.Fatal("another instant is the same")
}
}
func TestHaveNodeCodec(t *testing.T) {
n := seat.HaveByNodeNodeValue{Composed: digestA, Composes: ComposesOwn,
Candidates: map[string]seat.CandidateAnswer{"g": {Proposal: "p", Verdict: seat.CandidateAnswerVerdictSame}}}
raw, err := EncodeHaveNode(n)
if err != nil {
t.Fatal(err)
}
back, err := DecodeHaveNode(raw)
if err != nil {
t.Fatal(err)
}
n.V = keys.Version
if !SameHaveNode(back, n) {
t.Fatalf("round trip differs: %+v", back)
}
for name, bad := range map[string]seat.HaveByNodeNodeValue{
"composes": {Composes: "other"},
"no group": {Candidates: map[string]seat.CandidateAnswer{"": {Verdict: "same"}}},
"a verdict": {Candidates: map[string]seat.CandidateAnswer{"g": {Verdict: "maybe"}}},
} {
if _, err := EncodeHaveNode(bad); err == nil {
t.Errorf("%s: encoded", name)
}
}
a, b := seat.NodeRecord{Node: "n", V: 1}, seat.NodeRecord{Node: "n", V: 2}
if !SameNodeRecord(a, b) {
t.Error("a record differs by its version")
}
}
func TestHealthAndDataCodecs(t *testing.T) {
at := time.Date(2026, 11, 5, 12, 0, 0, 0, time.UTC)
h := seat.HealthByNodeValue{Node: "n", At: at, Fallback: &seat.LoginFallback{Node: "n", From: "old", Why: "w", At: at}}
raw, err := EncodeHealth(h)
if err != nil {
t.Fatal(err)
}
back, err := DecodeHealth(raw)
h.V = keys.Version
if err != nil || !SameHealth(back, h) {
t.Fatalf("round trip: %v %+v", err, back)
}
later := h
later.At = at.Add(time.Hour)
if !SameHealth(h, later) {
t.Error("a report differs by its time of writing")
}
for name, bad := range map[string]seat.HealthByNodeValue{
"no node": {},
"another node": {Node: "n", Fallback: &seat.LoginFallback{Node: "m", From: "o", Why: "w", At: at}},
"no time": {Node: "n", Fallback: &seat.LoginFallback{Node: "n", From: "o", Why: "w"}},
"no login from": {Node: "n", Fallback: &seat.LoginFallback{Node: "n", Why: "w", At: at}},
} {
if _, err := EncodeHealth(bad); err == nil {
t.Errorf("%s: encoded", name)
}
}
if _, err := DecodeHealth([]byte(`{"v":2,"node":"n"}`)); !errors.Is(err, keys.ErrUnknownVersion) {
t.Errorf("version 2: %v", err)
}
d := seat.DataByNodeModuleValue{Node: "n", Module: "m", Digest: digestA}
raw, err = EncodeData(d)
if err != nil || !strings.Contains(string(raw), `"items":[]`) {
t.Fatalf("data: %v %s", err, raw)
}
if _, err := EncodeData(seat.DataByNodeModuleValue{Node: "n", Module: "node"}); err == nil {
t.Error("the module name node keys")
}
if k, err := HaveNodeKey("n"); err != nil || k != "n.node" {
t.Errorf("node key %q %v", k, err)
}
}
// The lists a node's facts carry (windows, busy, strays, filters) are arrays on the bus, as the engine has always written
// them; the contract once said objects and refused every real value.
func TestHaveNodeReadsTheListsTheEngineWrites(t *testing.T) {
raw := []byte(`{"v":1,"windows":[{"step":"backup"}],"busy":[{"id":"x"}],"strays":[{"kind":"container"}],"filters":[{"name":"f"}]}`)
n, err := DecodeHaveNode(raw)
if err != nil {
t.Fatal(err)
}
if len(n.Windows) != 1 || len(n.Busy) != 1 || len(n.Strays) != 1 || len(n.Filters) != 1 {
t.Fatalf("lists lost: %+v", n)
}
}
+84
View File
@@ -0,0 +1,84 @@
package nodeengineclient
import (
"encoding/json"
"fmt"
"time"
seat "git.novox.be/novox/mesh-contracts/go/seats/nodeengine"
"git.novox.be/novox/mesh-sdk/go/keys"
)
// A node's own health report (the state health, one key per node, written by that node's engine alone) and the data items
// a module declares as the engine placed them (the state data, `<node>.<module>`): their layouts are the seat's contract
// (seat.HealthByNodeValue, seat.DataByNodeModuleValue); this file is their codec, shared by the engine and its readers.
// RootContract is the statement contract from which an engine judges an account's root: a report whose contract is lower
// judged nothing about root, and a reader must not read its empty root verdicts as healthy (HealthByNodeValue.Contract).
const RootContract = 3
// EncodeHealth is h as JSON at the current version; it refuses a report naming no node, and a fall back to the old login
// that names another node, no login fallen back from, no reason or no time.
func EncodeHealth(h seat.HealthByNodeValue) ([]byte, error) {
h.V = keys.Version
if h.Node == "" {
return nil, fmt.Errorf("node-engine: a node's health report names its node")
}
if f := h.Fallback; f != nil && (f.Node != h.Node || f.From == "" || f.Why == "" || f.At.IsZero()) {
return nil, fmt.Errorf("node-engine: a node's fall back to its old login names its node %s, the login fallen back from, why and when", h.Node)
}
return json.Marshal(h)
}
// DecodeHealth reads a report; it refuses another version (keys.ErrUnknownVersion) and a field the contract does not know.
func DecodeHealth(raw []byte) (seat.HealthByNodeValue, error) {
if err := keys.CheckVersion(raw); err != nil {
return seat.HealthByNodeValue{}, err
}
h, err := seat.DecodeHealthByNodeValue(raw)
if err != nil {
return seat.HealthByNodeValue{}, fmt.Errorf("node-engine: a node's health report is not readable: %w", err)
}
return h, nil
}
// SameHealth says two reports say the same, whenever each was written.
func SameHealth(a, b seat.HealthByNodeValue) bool {
a.At, b.At = time.Time{}, time.Time{}
return sameJSON(a, b)
}
// DataKey is a module's key on a node in the state data, `<node>.<module>`, its module part escaped as a have's is.
func DataKey(node, module string) (string, error) { return HaveKey(node, module) }
// DataNodeKeys is the pattern of every data key of a node, `<node>.*`.
func DataNodeKeys(node string) (string, error) {
if err := keys.CheckName("node", node); err != nil {
return "", err
}
return node + ".*", nil
}
// EncodeData is d as JSON at the current version; it refuses a node or module the key refuses.
func EncodeData(d seat.DataByNodeModuleValue) ([]byte, error) {
d.V = keys.Version
if _, err := DataKey(d.Node, d.Module); err != nil {
return nil, err
}
if d.Items == nil {
d.Items = []seat.PlacedDataItem{}
}
return json.Marshal(d)
}
// DecodeData reads a module's data items; it refuses another version (keys.ErrUnknownVersion).
func DecodeData(raw []byte) (seat.DataByNodeModuleValue, error) {
if err := keys.CheckVersion(raw); err != nil {
return seat.DataByNodeModuleValue{}, err
}
d, err := seat.DecodeDataByNodeModuleValue(raw)
if err != nil {
return seat.DataByNodeModuleValue{}, fmt.Errorf("node-engine: a module's data items are not readable: %w", err)
}
return d, nil
}
@@ -0,0 +1,11 @@
package nodeengineclient
import "testing"
// A report below RootContract judged nothing about root; the number is the one engines have written since the root
// judgement began, so a change of it is a change of what a reader trusts.
func TestRootContractIsTheOneEnginesJudgeRootFrom(t *testing.T) {
if RootContract != 3 {
t.Fatalf("RootContract = %d, the engines write 3", RootContract)
}
}
+130
View File
@@ -0,0 +1,130 @@
package nodeengineclient
import (
"fmt"
"regexp"
"strings"
"git.novox.be/novox/mesh-sdk/go/keys"
)
// NodeWord is the last part of a node's own key in the states have and composed.
const NodeWord = "node"
var moduleNameOK = regexp.MustCompile(`^[-/_a-zA-Z0-9]+(\.[-/_a-zA-Z0-9]+)*$`)
// moduleKeyPart is a module's name as a key part (issue 1150): a name may hold a dot (`novox.be`), which a key part may not,
// so a dot is escaped as keys.EscapeName writes it (`novox=2ebe`). A name that is empty, or holds a wildcard, whitespace,
// `=`, or an empty part between dots, is refused.
func moduleKeyPart(module string) (string, error) {
if module == "" {
return "", fmt.Errorf("node-engine: the module name is empty")
}
if !moduleNameOK.MatchString(module) {
return "", fmt.Errorf("node-engine: the module name %q holds a wildcard, whitespace, '=', an empty part between dots or a character a key refuses", module)
}
return keys.EscapeName(module), nil
}
// parseModuleKeyPart is the inverse of moduleKeyPart; it refuses a part that is not the one moduleKeyPart writes for its name.
func parseModuleKeyPart(part string) (string, error) {
if err := keys.CheckName("module", part); err != nil {
return "", err
}
module, err := keys.UnescapeName(part)
if err != nil {
return "", err
}
if back, err := moduleKeyPart(module); err != nil || back != part {
return "", fmt.Errorf("node-engine: the module part %q is not a module name as a key writes it", part)
}
return module, nil
}
// HaveKey is the key, in the state have, of a module's have on a node: `<node>.<module>`. A module named `node` is refused,
// since `<node>.node` is the node's own facts.
func HaveKey(node, module string) (string, error) {
if err := keys.CheckName("node", node); err != nil {
return "", err
}
part, err := moduleKeyPart(module)
if err != nil {
return "", err
}
if module == NodeWord {
return "", fmt.Errorf(`node-engine: the module name "node" is refused: %s.node is the node's own key`, node)
}
return node + "." + part, nil
}
// ParseHaveKey is the inverse of HaveKey and of the node's own key: isNode is true for `<node>.node`, with module empty.
func ParseHaveKey(key string) (node, module string, isNode bool, err error) {
node, module, ok := strings.Cut(key, ".")
if !ok {
return "", "", false, fmt.Errorf("node-engine: %q is not a have key, <node>.<module>", key)
}
if err := keys.CheckName("node", node); err != nil {
return "", "", false, fmt.Errorf("node-engine: %q is not a have key: %w", key, err)
}
if module == NodeWord {
return node, "", true, nil
}
if module, err = parseModuleKeyPart(module); err != nil {
return "", "", false, fmt.Errorf("node-engine: %q is not a have key: %w", key, err)
}
return node, module, false, nil
}
// ComposedKey is the key, in the state composed, of a module's composition for a node at a fingerprint:
// `<node>.<module>.<hex>`, the hex being the fingerprint's key part (64 lowercase hex digits, the mesh-catalog client's
// KeyPart). A module named `node` is refused.
func ComposedKey(node, module, fingerprintPart string) (string, error) {
have, err := HaveKey(node, module)
if err != nil {
return "", err
}
if !keys.IsHex64(fingerprintPart) {
return "", fmt.Errorf("node-engine: %q is not the key part of a fingerprint, 64 lowercase hex digits", fingerprintPart)
}
return have + "." + fingerprintPart, nil
}
// ParseComposedKey is the inverse of ComposedKey and of the node's own key `<node>.node` (isNode, with module and
// fingerprintPart empty). It refuses any other shape, naming the key.
func ParseComposedKey(key string) (node, module, fingerprintPart string, isNode bool, err error) {
refused := func(why error) (string, string, string, bool, error) {
return "", "", "", false, fmt.Errorf("node-engine: %q is not a composition key, <node>.<module>.<fingerprint hex> or <node>.node: %w", key, why)
}
parts := strings.Split(key, ".")
switch len(parts) {
case 2:
if parts[1] != NodeWord {
return refused(fmt.Errorf("a module's composition names its fingerprint"))
}
if err := keys.CheckName("node", parts[0]); err != nil {
return refused(err)
}
return parts[0], "", "", true, nil
case 3:
node, module, isNode, err := ParseHaveKey(parts[0] + "." + parts[1])
if err != nil {
return refused(err)
}
if isNode {
return refused(fmt.Errorf("the node's own key names no fingerprint"))
}
if !keys.IsHex64(parts[2]) {
return refused(fmt.Errorf("%q is not 64 lowercase hex digits", parts[2]))
}
return node, module, parts[2], false, nil
}
return refused(fmt.Errorf("the key has %d parts", len(parts)))
}
// HaveNodeKey is the key, in the state have, of a node's own facts: `<node>.node`.
func HaveNodeKey(node string) (string, error) {
if err := keys.CheckName("node", node); err != nil {
return "", err
}
return node + "." + NodeWord, nil
}
+45
View File
@@ -0,0 +1,45 @@
package nodeengineclient
import (
"strings"
"testing"
)
func TestTheHaveAndComposedKeysKeepTheirForms(t *testing.T) {
fp := strings.Repeat("ab", 32)
for module, want := range map[string]string{"gitea": "n1.gitea", "novox.be": "n1.novox=2ebe", "a/b": "n1.a/b"} {
key, err := HaveKey("n1", module)
if err != nil || key != want {
t.Errorf("HaveKey(%q) = %q, %v", module, key, err)
}
if node, back, isNode, err := ParseHaveKey(key); err != nil || node != "n1" || back != module || isNode {
t.Errorf("ParseHaveKey(%q) = %q %q %v %v", key, node, back, isNode, err)
}
ck, err := ComposedKey("n1", module, fp)
if err != nil || ck != want+"."+fp {
t.Errorf("ComposedKey(%q) = %q, %v", module, ck, err)
}
if node, back, part, isNode, err := ParseComposedKey(ck); err != nil || node != "n1" || back != module || part != fp || isNode {
t.Errorf("ParseComposedKey(%q) = %q %q %q %v %v", ck, node, back, part, isNode, err)
}
}
if node, module, isNode, err := ParseHaveKey("n1.node"); err != nil || node != "n1" || module != "" || !isNode {
t.Errorf("the node's own have key: %q %q %v %v", node, module, isNode, err)
}
if node, _, _, isNode, err := ParseComposedKey("n1.node"); err != nil || node != "n1" || !isNode {
t.Errorf("the node's own composition key: %q %v %v", node, isNode, err)
}
for _, bad := range [][2]string{{"", "m"}, {"n.1", "m"}, {"n1", ""}, {"n1", "node"}, {"n1", "a b"}, {"n1", "a=b"}, {"n1", "a..b"}} {
if _, err := HaveKey(bad[0], bad[1]); err == nil {
t.Errorf("HaveKey(%q, %q) keys", bad[0], bad[1])
}
}
if _, err := ComposedKey("n1", "m", "src2:"+fp); err == nil {
t.Error("a fingerprint with its prefix is a key part")
}
for _, bad := range []string{"n1", "n1.m", "n1.m." + fp[:10], "n1.node." + fp, "n1.m." + fp + ".x"} {
if _, _, _, _, err := ParseComposedKey(bad); err == nil {
t.Errorf("ParseComposedKey(%q) reads a wrong key", bad)
}
}
}
+63
View File
@@ -0,0 +1,63 @@
{
"provisioner_account": "mesh-provisioner-client",
"resources": [
{
"id": "buildah.buildah",
"module": "buildah",
"package": "buildah",
"type": "package"
},
{
"id": "mesh-wireguard.overlay-tools",
"module": "mesh-wireguard",
"package": "wireguard-tools",
"type": "package"
},
{
"content": "# Generated by the mesh. Do not edit \u2014 this file is replaced whenever the\n# peer graph changes, and an edit would survive until the next change and\n# then vanish, which is worse than not being applied at all.\n#\n# node ace, at home, not dialable \u2014 it opens every path itself\n\n[Interface]\nAddress = 10.10.0.2/32\nPostUp = wg set %i private-key /var/lib/mesh-host/overlay.key\n\n# novox \u2014 the hub \u2014 everything not at this site\n[Peer]\nPublicKey = xW0ETQwyR9rHi2XSfOJrKkairE+Fp70FPNNcirqMixY=\nAllowedIPs = 10.10.0.0/24\nEndpoint = 142.132.152.141:51820\nPersistentKeepalive = 25\n",
"id": "mesh-wireguard.overlay-config",
"mode": "0600",
"module": "mesh-wireguard",
"path": "/etc/wireguard/mesh0.conf",
"type": "file"
},
{
"boot": "enabled",
"id": "mesh-wireguard.overlay-up",
"module": "mesh-wireguard",
"restart-on": [
"mesh-wireguard.overlay-config"
],
"state": "running",
"type": "service",
"unit": "wg-quick@mesh0"
},
{
"digest": "sha256:71ed8f5b4e7ac73ab82b7b559b08e5951a82553197d153651cc8be36ff35bcda",
"id": "hostname.bundle-tools",
"module": "hostname",
"path": "/var/lib/mesh/bundles/hostname/tools",
"source": "http://novox.internal:5100/v2/hostname/tools/blobs/sha256:71ed8f5b4e7ac73ab82b7b559b08e5951a82553197d153651cc8be36ff35bcda",
"type": "archive"
},
{
"at": "start",
"content": "# The machine's own names (module hostname, novox/hq ADR 0199, ADR 0223). Every line outside this\n# block is the operator's: kept across every push, changed through the node-hostname verbs add and\n# remove, and given back when this module goes. The mesh's names are not here: the mesh's resolver\n# answers them.\n127.0.0.1\tlocalhost\n::1\tlocalhost\n127.0.1.1\tace\n",
"id": "hostname.own",
"into": "block",
"mode": "0644",
"module": "hostname",
"path": "/etc/hosts",
"type": "file"
},
{
"content": "Ace\n",
"id": "hostname.name",
"mode": "0644",
"module": "hostname",
"path": "/etc/hostname",
"type": "file"
}
],
"filled_files": []
}
+366
View File
@@ -0,0 +1,366 @@
// Code generated by seatgen from the seat definitions; DO NOT EDIT.
// Package meshbroker is the contract of the seat "mesh-broker", defined whole (seats/mesh-broker/seat.json): The mesh's bus, and the bus state modules ask of it in requires (design 25 § 12). create, apply, recreate and drop are granted to the bus module's own credential alone.
package meshbroker
// Seat is the seat's name.
const Seat = "mesh-broker"
const (
VerbList = "list"
VerbShow = "show"
VerbCheck = "check"
VerbOrphans = "orphans"
VerbCreate = "create"
VerbApply = "apply"
VerbRecreate = "recreate"
VerbDrop = "drop"
)
const (
EventCreated = "created"
EventRefused = "refused"
EventDrifted = "drifted"
EventDriftCleared = "drift-cleared"
EventFull = "full"
EventFullCleared = "full-cleared"
EventOrphaned = "orphaned"
EventRecreated = "recreated"
EventDropped = "dropped"
)
// Verbs is every verb the seat serves, in its definition's order: a new copy each call.
func Verbs() []string {
return []string{VerbList, VerbShow, VerbCheck, VerbOrphans, VerbCreate, VerbApply, VerbRecreate, VerbDrop}
}
// Events is every event published on the seat's subject: a new copy each call.
func Events() []string {
return []string{EventCreated, EventRefused, EventDrifted, EventDriftCleared, EventFull, EventFullCleared, EventOrphaned, EventRecreated, EventDropped}
}
// Family is where the seat's events are kept: the shared events stream, filtered to the seat's subjects.
const (
Family = "mesh-broker.events"
FamilyMaxAgeDays = 7
)
// SubjectCreated is where created is published.
const SubjectCreated = "mesh.seat.mesh-broker.event.created"
// SubjectRefused is where refused is published.
const SubjectRefused = "mesh.seat.mesh-broker.event.refused"
// SubjectDrifted is where drifted is published.
const SubjectDrifted = "mesh.seat.mesh-broker.event.drifted"
// SubjectDriftCleared is where drift-cleared is published.
const SubjectDriftCleared = "mesh.seat.mesh-broker.event.drift-cleared"
// SubjectFull is where full is published.
const SubjectFull = "mesh.seat.mesh-broker.event.full"
// SubjectFullCleared is where full-cleared is published.
const SubjectFullCleared = "mesh.seat.mesh-broker.event.full-cleared"
// SubjectOrphaned is where orphaned is published.
const SubjectOrphaned = "mesh.seat.mesh-broker.event.orphaned"
// SubjectRecreated is where recreated is published.
const SubjectRecreated = "mesh.seat.mesh-broker.event.recreated"
// SubjectDropped is where dropped is published.
const SubjectDropped = "mesh.seat.mesh-broker.event.dropped"
// Binding is the binding a module receives in its membership for one entry (ADR 0595 §1, §7).
type Binding struct {
// the entry's name on the bus
BusName string `json:"bus-name"`
// how the bus keeps it
StorageType string `json:"storage-type"`
// its subjects, for a stream
Subjects []string `json:"subjects,omitempty"`
// its template
Template string `json:"template,omitempty"`
}
// Entry is one piece of bus state as a manifest asks it.
type Entry struct {
// its own configuration, where no template fits
Configuration map[string]any `json:"configuration,omitempty"`
// its local name in the module's manifest
LocalName string `json:"local-name"`
// the module asking it
Module string `json:"module"`
// the template's options the module set, within their bounds
Options map[string]any `json:"options,omitempty"`
// how the bus keeps it
StorageType string `json:"storage-type"`
// its template; empty for a configuration of its own
Template string `json:"template,omitempty"`
}
// The values of Entry's storage-type.
const (
EntryStorageTypeStream = "stream"
EntryStorageTypeKeyValue = "key-value"
EntryStorageTypeObjectStore = "object-store"
EntryStorageTypeLedger = "ledger"
)
// The values of Entry's template.
const (
EntryTemplateState = "state"
EntryTemplateEvents = "events"
EntryTemplateWorkQueue = "work-queue"
EntryTemplateLog = "log"
EntryTemplateObjects = "objects"
EntryTemplateLedger = "ledger"
EntryTemplate = ""
)
// Fill is a type of the contract.
type Fill struct {
// bytes held
Bytes int64 `json:"bytes"`
// its cap in bytes
Cap int64 `json:"cap"`
// messages, keys or objects held
Count int64 `json:"count"`
}
// Held is one entry as the bus holds it.
type Held struct {
// its name on the bus, <module>_<local name>
BusName string `json:"bus-name"`
// its own configuration, where no template fits
Configuration map[string]any `json:"configuration,omitempty"`
Fill *Fill `json:"fill,omitempty"`
// its local name in the module's manifest
LocalName string `json:"local-name"`
// the module asking it
Module string `json:"module"`
// the template's options the module set, within their bounds
Options map[string]any `json:"options,omitempty"`
// the settings the bus holds
Settings map[string]any `json:"settings,omitempty"`
// the entry's state: held as asked, refused, waiting to be made, drifted from what is asked, full, or orphaned (no manifest asks it)
State string `json:"state,omitempty"`
// how the bus keeps it
StorageType string `json:"storage-type"`
// its template; empty for a configuration of its own
Template string `json:"template,omitempty"`
// why it is in its state, in plain words; empty when held as asked
Why string `json:"why,omitempty"`
}
// The values of Held's state.
const (
HeldStateHeld = "held"
HeldStateRefused = "refused"
HeldStateWaiting = "waiting"
HeldStateDrifted = "drifted"
HeldStateFull = "full"
HeldStateOrphaned = "orphaned"
)
// The values of Held's storage-type.
const (
HeldStorageTypeStream = "stream"
HeldStorageTypeKeyValue = "key-value"
HeldStorageTypeObjectStore = "object-store"
HeldStorageTypeLedger = "ledger"
)
// The values of Held's template.
const (
HeldTemplateState = "state"
HeldTemplateEvents = "events"
HeldTemplateWorkQueue = "work-queue"
HeldTemplateLog = "log"
HeldTemplateObjects = "objects"
HeldTemplateLedger = "ledger"
HeldTemplate = ""
)
// Named is a type of the contract.
type Named struct {
// the entry's local name
LocalName string `json:"local-name"`
// the module
Module string `json:"module"`
}
// Said is the body of every event of the seat.
type Said struct {
// the settings asked
Asked map[string]any `json:"asked,omitempty"`
Binding *Binding `json:"binding,omitempty"`
// its name on the bus
BusName string `json:"bus-name"`
// its own configuration, where no template fits
Configuration map[string]any `json:"configuration,omitempty"`
Fill *Fill `json:"fill,omitempty"`
// the settings held
Held map[string]any `json:"held,omitempty"`
// its local name in the module's manifest
LocalName string `json:"local-name"`
// the module asking it
Module string `json:"module"`
// the template's options the module set, within their bounds
Options map[string]any `json:"options,omitempty"`
// the indexer's revision of the verdict the bus module acted on (ADR 0622 §2), so a reader orders what it hears
Revision int64 `json:"revision,omitempty"`
// the stream snapshot taken before a recreate or drop
Snapshot string `json:"snapshot,omitempty"`
// how the bus keeps it
StorageType string `json:"storage-type,omitempty"`
// its template; empty for a configuration of its own
Template string `json:"template,omitempty"`
// why: a refusal's cause, a drift's difference, a drop's or recreate's reason
Why string `json:"why,omitempty"`
}
// The values of Said's storage-type.
const (
SaidStorageTypeStream = "stream"
SaidStorageTypeKeyValue = "key-value"
SaidStorageTypeObjectStore = "object-store"
SaidStorageTypeLedger = "ledger"
)
// The values of Said's template.
const (
SaidTemplateState = "state"
SaidTemplateEvents = "events"
SaidTemplateWorkQueue = "work-queue"
SaidTemplateLog = "log"
SaidTemplateObjects = "objects"
SaidTemplateLedger = "ledger"
SaidTemplate = ""
)
// Warranted is a type of the contract.
type Warranted struct {
// the entry's local name
LocalName string `json:"local-name"`
// the module
Module string `json:"module"`
// the operator's warrant, as the operator channel decided it (ADR 0259 §6)
Warrant string `json:"warrant"`
// why, kept in the record
Why string `json:"why"`
}
// ListInput is what list takes.
type ListInput struct {
// one module (optional)
Module string `json:"module,omitempty"`
}
// ListAnswer is what list answers.
type ListAnswer struct {
// every entry
Entries []Held `json:"entries"`
}
// ShowInput is what show takes.
type ShowInput = Named
// ShowAnswer is what show answers.
type ShowAnswer = Held
// CheckInput is what check takes.
type CheckInput struct {
// one module (optional)
Module string `json:"module,omitempty"`
}
// CheckAnswerDifferencesItem is a type of the contract.
type CheckAnswerDifferencesItem struct {
// asked
Asked map[string]any `json:"asked,omitempty"`
// the difference only grows: apply may act on it
Grows bool `json:"grows,omitempty"`
// held
Held map[string]any `json:"held,omitempty"`
// the entry
LocalName string `json:"local-name"`
// the module
Module string `json:"module"`
}
// CheckAnswer is what check answers.
type CheckAnswer struct {
// every difference
Differences []CheckAnswerDifferencesItem `json:"differences"`
}
// OrphansInput is what orphans takes.
type OrphansInput struct {
}
// OrphansAnswer is what orphans answers.
type OrphansAnswer struct {
// every orphan
Entries []Held `json:"entries"`
}
// CreateInput is what create takes.
type CreateInput = Entry
// CreateAnswer is what create answers.
type CreateAnswer = Held
// ApplyInput is what apply takes.
type ApplyInput = Named
// ApplyAnswer is what apply answers.
type ApplyAnswer = Held
// RecreateInput is what recreate takes.
type RecreateInput = Warranted
// RecreateAnswer is what recreate answers.
type RecreateAnswer = Held
// DropInput is what drop takes.
type DropInput = Warranted
// DropAnswer is what drop answers.
type DropAnswer struct {
// it is gone
Dropped bool `json:"dropped"`
// the snapshot taken first
Snapshot string `json:"snapshot,omitempty"`
}
// CreatedEvent is the body of the event created.
type CreatedEvent = Said
// RefusedEvent is the body of the event refused.
type RefusedEvent = Said
// DriftedEvent is the body of the event drifted.
type DriftedEvent = Said
// DriftClearedEvent is the body of the event drift-cleared.
type DriftClearedEvent = Said
// FullEvent is the body of the event full.
type FullEvent = Said
// FullClearedEvent is the body of the event full-cleared.
type FullClearedEvent = Said
// OrphanedEvent is the body of the event orphaned.
type OrphanedEvent = Said
// RecreatedEvent is the body of the event recreated.
type RecreatedEvent = Said
// DroppedEvent is the body of the event dropped.
type DroppedEvent = Said
// BusStateForm is the form of the provision bus-state.
type BusStateForm = Binding
+70
View File
@@ -0,0 +1,70 @@
// Code generated by seatgen from the seat definitions; DO NOT EDIT.
package meshbroker
import (
"encoding/json"
"reflect"
"regexp"
"testing"
)
// TestFixtures reads every fixture of seats/mesh-broker/fixtures: each decodes into its type, a field the type does not
// know is dropped (ADR 0608 §6), and it reads back as its wire says; each subject named is the contract's.
func TestFixtures(t *testing.T) {
cases := []struct {
name, subject, given, wire string
decode func([]byte) (any, error)
subjectIs *regexp.Regexp
}{
{name: "created on the seat's subject", subject: "mesh.seat.mesh-broker.event.created", given: "{\"asked\":{\"max-mib\":64},\"bus-name\":\"mesh-knowledge_ledger\",\"fill\":{\"bytes\":2048,\"cap\":67108864,\"count\":10},\"held\":{\"max-mib\":64},\"local-name\":\"ledger\",\"module\":\"mesh-knowledge\",\"storage-type\":\"ledger\",\"template\":\"ledger\"}", wire: "{\"asked\":{\"max-mib\":64},\"bus-name\":\"mesh-knowledge_ledger\",\"fill\":{\"bytes\":2048,\"cap\":67108864,\"count\":10},\"held\":{\"max-mib\":64},\"local-name\":\"ledger\",\"module\":\"mesh-knowledge\",\"storage-type\":\"ledger\",\"template\":\"ledger\"}",
decode: func(b []byte) (any, error) { var v CreatedEvent; err := json.Unmarshal(b, &v); return v, err }, subjectIs: regexp.MustCompile("^mesh\\.seat\\.mesh-broker\\.event\\.created$")},
{name: "refused on the seat's subject", subject: "mesh.seat.mesh-broker.event.refused", given: "{\"asked\":{\"max-mib\":64},\"bus-name\":\"mesh-knowledge_ledger\",\"fill\":{\"bytes\":2048,\"cap\":67108864,\"count\":10},\"held\":{\"max-mib\":64},\"local-name\":\"ledger\",\"module\":\"mesh-knowledge\",\"storage-type\":\"ledger\",\"template\":\"ledger\",\"why\":\"the bus refused it\"}", wire: "{\"asked\":{\"max-mib\":64},\"bus-name\":\"mesh-knowledge_ledger\",\"fill\":{\"bytes\":2048,\"cap\":67108864,\"count\":10},\"held\":{\"max-mib\":64},\"local-name\":\"ledger\",\"module\":\"mesh-knowledge\",\"storage-type\":\"ledger\",\"template\":\"ledger\",\"why\":\"the bus refused it\"}",
decode: func(b []byte) (any, error) { var v RefusedEvent; err := json.Unmarshal(b, &v); return v, err }, subjectIs: regexp.MustCompile("^mesh\\.seat\\.mesh-broker\\.event\\.refused$")},
{name: "drifted on the seat's subject", subject: "mesh.seat.mesh-broker.event.drifted", given: "{\"asked\":{\"max-mib\":64},\"bus-name\":\"mesh-knowledge_ledger\",\"fill\":{\"bytes\":2048,\"cap\":67108864,\"count\":10},\"held\":{\"max-mib\":64},\"local-name\":\"ledger\",\"module\":\"mesh-knowledge\",\"storage-type\":\"ledger\",\"template\":\"ledger\",\"why\":\"the bus refused it\"}", wire: "{\"asked\":{\"max-mib\":64},\"bus-name\":\"mesh-knowledge_ledger\",\"fill\":{\"bytes\":2048,\"cap\":67108864,\"count\":10},\"held\":{\"max-mib\":64},\"local-name\":\"ledger\",\"module\":\"mesh-knowledge\",\"storage-type\":\"ledger\",\"template\":\"ledger\",\"why\":\"the bus refused it\"}",
decode: func(b []byte) (any, error) { var v DriftedEvent; err := json.Unmarshal(b, &v); return v, err }, subjectIs: regexp.MustCompile("^mesh\\.seat\\.mesh-broker\\.event\\.drifted$")},
{name: "drift-cleared on the seat's subject", subject: "mesh.seat.mesh-broker.event.drift-cleared", given: "{\"asked\":{\"max-mib\":64},\"bus-name\":\"mesh-knowledge_ledger\",\"fill\":{\"bytes\":2048,\"cap\":67108864,\"count\":10},\"held\":{\"max-mib\":64},\"local-name\":\"ledger\",\"module\":\"mesh-knowledge\",\"storage-type\":\"ledger\",\"template\":\"ledger\"}", wire: "{\"asked\":{\"max-mib\":64},\"bus-name\":\"mesh-knowledge_ledger\",\"fill\":{\"bytes\":2048,\"cap\":67108864,\"count\":10},\"held\":{\"max-mib\":64},\"local-name\":\"ledger\",\"module\":\"mesh-knowledge\",\"storage-type\":\"ledger\",\"template\":\"ledger\"}",
decode: func(b []byte) (any, error) { var v DriftClearedEvent; err := json.Unmarshal(b, &v); return v, err }, subjectIs: regexp.MustCompile("^mesh\\.seat\\.mesh-broker\\.event\\.drift-cleared$")},
{name: "full on the seat's subject", subject: "mesh.seat.mesh-broker.event.full", given: "{\"asked\":{\"max-mib\":64},\"bus-name\":\"mesh-knowledge_ledger\",\"fill\":{\"bytes\":2048,\"cap\":67108864,\"count\":10},\"held\":{\"max-mib\":64},\"local-name\":\"ledger\",\"module\":\"mesh-knowledge\",\"storage-type\":\"ledger\",\"template\":\"ledger\"}", wire: "{\"asked\":{\"max-mib\":64},\"bus-name\":\"mesh-knowledge_ledger\",\"fill\":{\"bytes\":2048,\"cap\":67108864,\"count\":10},\"held\":{\"max-mib\":64},\"local-name\":\"ledger\",\"module\":\"mesh-knowledge\",\"storage-type\":\"ledger\",\"template\":\"ledger\"}",
decode: func(b []byte) (any, error) { var v FullEvent; err := json.Unmarshal(b, &v); return v, err }, subjectIs: regexp.MustCompile("^mesh\\.seat\\.mesh-broker\\.event\\.full$")},
{name: "full-cleared on the seat's subject", subject: "mesh.seat.mesh-broker.event.full-cleared", given: "{\"asked\":{\"max-mib\":64},\"bus-name\":\"mesh-knowledge_ledger\",\"fill\":{\"bytes\":2048,\"cap\":67108864,\"count\":10},\"held\":{\"max-mib\":64},\"local-name\":\"ledger\",\"module\":\"mesh-knowledge\",\"storage-type\":\"ledger\",\"template\":\"ledger\"}", wire: "{\"asked\":{\"max-mib\":64},\"bus-name\":\"mesh-knowledge_ledger\",\"fill\":{\"bytes\":2048,\"cap\":67108864,\"count\":10},\"held\":{\"max-mib\":64},\"local-name\":\"ledger\",\"module\":\"mesh-knowledge\",\"storage-type\":\"ledger\",\"template\":\"ledger\"}",
decode: func(b []byte) (any, error) { var v FullClearedEvent; err := json.Unmarshal(b, &v); return v, err }, subjectIs: regexp.MustCompile("^mesh\\.seat\\.mesh-broker\\.event\\.full-cleared$")},
{name: "orphaned on the seat's subject", subject: "mesh.seat.mesh-broker.event.orphaned", given: "{\"asked\":{\"max-mib\":64},\"bus-name\":\"mesh-knowledge_ledger\",\"fill\":{\"bytes\":2048,\"cap\":67108864,\"count\":10},\"held\":{\"max-mib\":64},\"local-name\":\"ledger\",\"module\":\"mesh-knowledge\",\"storage-type\":\"ledger\",\"template\":\"ledger\"}", wire: "{\"asked\":{\"max-mib\":64},\"bus-name\":\"mesh-knowledge_ledger\",\"fill\":{\"bytes\":2048,\"cap\":67108864,\"count\":10},\"held\":{\"max-mib\":64},\"local-name\":\"ledger\",\"module\":\"mesh-knowledge\",\"storage-type\":\"ledger\",\"template\":\"ledger\"}",
decode: func(b []byte) (any, error) { var v OrphanedEvent; err := json.Unmarshal(b, &v); return v, err }, subjectIs: regexp.MustCompile("^mesh\\.seat\\.mesh-broker\\.event\\.orphaned$")},
{name: "recreated on the seat's subject", subject: "mesh.seat.mesh-broker.event.recreated", given: "{\"asked\":{\"max-mib\":64},\"bus-name\":\"mesh-knowledge_ledger\",\"fill\":{\"bytes\":2048,\"cap\":67108864,\"count\":10},\"held\":{\"max-mib\":64},\"local-name\":\"ledger\",\"module\":\"mesh-knowledge\",\"storage-type\":\"ledger\",\"template\":\"ledger\",\"why\":\"the bus refused it\"}", wire: "{\"asked\":{\"max-mib\":64},\"bus-name\":\"mesh-knowledge_ledger\",\"fill\":{\"bytes\":2048,\"cap\":67108864,\"count\":10},\"held\":{\"max-mib\":64},\"local-name\":\"ledger\",\"module\":\"mesh-knowledge\",\"storage-type\":\"ledger\",\"template\":\"ledger\",\"why\":\"the bus refused it\"}",
decode: func(b []byte) (any, error) { var v RecreatedEvent; err := json.Unmarshal(b, &v); return v, err }, subjectIs: regexp.MustCompile("^mesh\\.seat\\.mesh-broker\\.event\\.recreated$")},
{name: "dropped on the seat's subject", subject: "mesh.seat.mesh-broker.event.dropped", given: "{\"asked\":{\"max-mib\":64},\"bus-name\":\"mesh-knowledge_ledger\",\"fill\":{\"bytes\":2048,\"cap\":67108864,\"count\":10},\"held\":{\"max-mib\":64},\"local-name\":\"ledger\",\"module\":\"mesh-knowledge\",\"storage-type\":\"ledger\",\"template\":\"ledger\",\"why\":\"the bus refused it\"}", wire: "{\"asked\":{\"max-mib\":64},\"bus-name\":\"mesh-knowledge_ledger\",\"fill\":{\"bytes\":2048,\"cap\":67108864,\"count\":10},\"held\":{\"max-mib\":64},\"local-name\":\"ledger\",\"module\":\"mesh-knowledge\",\"storage-type\":\"ledger\",\"template\":\"ledger\",\"why\":\"the bus refused it\"}",
decode: func(b []byte) (any, error) { var v DroppedEvent; err := json.Unmarshal(b, &v); return v, err }, subjectIs: regexp.MustCompile("^mesh\\.seat\\.mesh-broker\\.event\\.dropped$")},
{name: "create one entry", subject: "", given: "{\"local-name\":\"ledger\",\"module\":\"mesh-knowledge\",\"options\":{\"max-mib\":64},\"storage-type\":\"ledger\",\"template\":\"ledger\"}", wire: "{\"local-name\":\"ledger\",\"module\":\"mesh-knowledge\",\"options\":{\"max-mib\":64},\"storage-type\":\"ledger\",\"template\":\"ledger\"}",
decode: func(b []byte) (any, error) { var v CreateInput; err := json.Unmarshal(b, &v); return v, err }, subjectIs: nil},
{name: "drop on the operator's warrant", subject: "", given: "{\"local-name\":\"old\",\"module\":\"mesh-knowledge\",\"warrant\":\"w-1\",\"why\":\"the operator approved the drop\"}", wire: "{\"local-name\":\"old\",\"module\":\"mesh-knowledge\",\"warrant\":\"w-1\",\"why\":\"the operator approved the drop\"}",
decode: func(b []byte) (any, error) { var v DropInput; err := json.Unmarshal(b, &v); return v, err }, subjectIs: nil},
{name: "list answers every entry", subject: "", given: "{\"entries\":[{\"bus-name\":\"mesh-knowledge_ledger\",\"fill\":{\"bytes\":2048,\"cap\":67108864,\"count\":10},\"local-name\":\"ledger\",\"module\":\"mesh-knowledge\",\"options\":{\"max-mib\":64},\"settings\":{\"max-mib\":64},\"storage-type\":\"ledger\",\"template\":\"ledger\"}]}", wire: "{\"entries\":[{\"bus-name\":\"mesh-knowledge_ledger\",\"fill\":{\"bytes\":2048,\"cap\":67108864,\"count\":10},\"local-name\":\"ledger\",\"module\":\"mesh-knowledge\",\"options\":{\"max-mib\":64},\"settings\":{\"max-mib\":64},\"storage-type\":\"ledger\",\"template\":\"ledger\"}]}",
decode: func(b []byte) (any, error) { var v ListAnswer; err := json.Unmarshal(b, &v); return v, err }, subjectIs: nil},
}
for _, c := range cases {
v, err := c.decode([]byte(c.given))
if err != nil {
t.Errorf("%s: does not decode: %v", c.name, err)
continue
}
back, err := json.Marshal(v)
if err != nil {
t.Errorf("%s: does not encode: %v", c.name, err)
continue
}
var got, want any
if err := json.Unmarshal(back, &got); err != nil {
t.Fatal(err)
}
if err := json.Unmarshal([]byte(c.wire), &want); err != nil {
t.Fatal(err)
}
if !reflect.DeepEqual(got, want) {
t.Errorf("%s: reads back as %s, want %s", c.name, back, c.wire)
}
if c.subjectIs != nil && !c.subjectIs.MatchString(c.subject) {
t.Errorf("%s: subject %q is not the contract's %s", c.name, c.subject, c.subjectIs)
}
}
}
+3
View File
@@ -0,0 +1,3 @@
module git.novox.be/novox/mesh-contracts/go/seats/meshbroker
go 1.26.0
+285
View File
@@ -0,0 +1,285 @@
// Code generated by seatgen from the seat definitions; DO NOT EDIT.
// Package meshconfig is the contract of the seat "mesh-config", defined whole (seats/mesh-config/seat.json): The mesh's config values: read and set one, and the renames still open. mesh-config's own events stay under its own subjects (ADR 0620 §2).
package meshconfig
import (
"bytes"
"encoding/json"
"errors"
"strings"
"time"
)
// Seat is the seat's name.
const Seat = "mesh-config"
const (
VerbConfig = "config"
VerbRenames = "renames"
)
// Verbs is every verb the seat serves, in its definition's order: a new copy each call.
func Verbs() []string {
return []string{VerbConfig, VerbRenames}
}
// The holder's own events, listed beside the seat and published under the holder's subjects (ADR 0620 §2).
const (
HolderEventConfigChanged = "config-changed"
HolderEventRenamed = "renamed"
HolderEventRetiring = "retiring"
HolderEventRetired = "retired"
)
// HolderSubjectConfigChanged is where the holder publishes config-changed.
func HolderSubjectConfigChanged(holder string) string {
return "mesh.mod." + holder + ".event.config-changed"
}
// HolderSubjectRenamed is where the holder publishes renamed, kept in config.events.
func HolderSubjectRenamed(holder string) string { return "mesh.stream." + holder + ".event.renamed" }
// HolderSubjectRetiring is where the holder publishes retiring, kept in config.events.
func HolderSubjectRetiring(holder string) string { return "mesh.stream." + holder + ".event.retiring" }
// HolderSubjectRetired is where the holder publishes retired, kept in config.events.
func HolderSubjectRetired(holder string) string { return "mesh.stream." + holder + ".event.retired" }
const (
StateResolved = "resolved"
StateKept = "kept"
)
// ResolvedByNodeModuleKeyKey is the key of one value of the state resolved under the form "<node>.<module>.<key>".
func ResolvedByNodeModuleKeyKey(node, module, key string) string {
return node + "." + module + "." + key
}
// ParseResolvedByNodeModuleKeyKey reads the parts of a key of the form "<node>.<module>.<key>", in the order of the form; the last result is false for a key of another form.
func ParseResolvedByNodeModuleKeyKey(key 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
if rest == "" {
return "", "", "", false
}
v4 := rest
rest = ""
if rest != "" {
return "", "", "", false
}
return v0, v2, v4, true
}
// DecodeResolvedByNodeModuleKeyValue reads one value of the state resolved under the form "<node>.<module>.<key>", refusing a field ResolvedByNodeModuleKeyValue does not know and any text after the value.
func DecodeResolvedByNodeModuleKeyValue(raw []byte) (ResolvedByNodeModuleKeyValue, error) {
var v ResolvedByNodeModuleKeyValue
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
}
// 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
Action string `json:"action,omitempty"`
// who gave the value at the controller's terminal
By string `json:"by,omitempty"`
// the config key
Key string `json:"key,omitempty"`
// for history: at most this many, newest
Limit float64 `json:"limit,omitempty"`
// a module: its own value, with node
Module string `json:"module,omitempty"`
// a node
Node string `json:"node,omitempty"`
// for set: the value, of the key's type: a number, a string, a boolean, or for a trusted key an object or a list
Value any `json:"value,omitempty"`
// for set and clear: why
Why string `json:"why,omitempty"`
}
// ConfigAnswer is what config answers.
type ConfigAnswer = map[string]any
// RenamesInput is what renames takes.
type RenamesInput struct {
// one module (optional)
Module string `json:"module,omitempty"`
}
// RenamesAnswerRenamesItem is a type of the contract.
type RenamesAnswerRenamesItem struct {
// when it moved, RFC 3339
At string `json:"at,omitempty"`
// the old name
From string `json:"from"`
// the module
Module string `json:"module"`
// the nodes still holding the old name open
Open []string `json:"open,omitempty"`
// the new name
To string `json:"to"`
}
// RenamesAnswer is what renames answers.
type RenamesAnswer struct {
// every open rename
Renames []RenamesAnswerRenamesItem `json:"renames"`
}
// RenamedHolderEvent is the body of the holder's event renamed.
type RenamedHolderEvent struct {
// when, RFC 3339
At string `json:"at"`
// the old name
From string `json:"from"`
// the module
Module string `json:"module"`
// the new name
To string `json:"to"`
}
// RetiringHolderEvent is the body of the holder's event retiring.
type RetiringHolderEvent struct {
// the ask's id
Ask string `json:"ask,omitempty"`
// the value's or secret's name
Key string `json:"key"`
Kind string `json:"kind"`
// the module
Module string `json:"module"`
// since when, RFC 3339
Since string `json:"since"`
}
// The values of RetiringHolderEvent's kind.
const (
RetiringHolderEventKindValue = "value"
RetiringHolderEventKindSecret = "secret"
)
// RetiredHolderEvent is the body of the holder's event retired.
type RetiredHolderEvent struct {
// the ask's id
Ask string `json:"ask,omitempty"`
// the module
Module string `json:"module"`
// the name
Name string `json:"name"`
// the node
Node string `json:"node,omitempty"`
}
// ResolvedByNodeModuleKeyValue is one value of the state resolved under the key <node>.<module>.<key>.
type ResolvedByNodeModuleKeyValue struct {
Key string `json:"key"`
Module string `json:"module"`
Node string `json:"node"`
// the predefined key the value falls back to
Predefined string `json:"predefined,omitempty"`
// an unset value is the operator's own to set
Required bool `json:"required,omitempty"`
Source string `json:"source"`
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"`
}
+74
View File
@@ -0,0 +1,74 @@
// Code generated by seatgen from the seat definitions; DO NOT EDIT.
package meshconfig
import (
"encoding/json"
"reflect"
"regexp"
"testing"
)
// TestFixtures reads every fixture of seats/mesh-config/fixtures: each decodes into its type, a field the type does not
// know is dropped (ADR 0608 §6), and it reads back as its wire says; each subject named is the contract's.
func TestFixtures(t *testing.T) {
cases := []struct {
name, subject, given, wire string
decode func([]byte) (any, error)
subjectIs *regexp.Regexp
}{
{name: "config-changed, unchanged", subject: "mesh.mod.mesh-config.event.config-changed", given: "{\"key\":\"x\"}", wire: "{\"key\":\"x\"}",
decode: func(b []byte) (any, error) { var v map[string]any; err := json.Unmarshal(b, &v); return v, err }, subjectIs: regexp.MustCompile("^mesh\\.mod\\.[a-z0-9-]+\\.event\\.config-changed$")},
{name: "renamed", subject: "mesh.stream.mesh-config.event.renamed", given: "{\"at\":\"2026-11-04T12:00:00Z\",\"from\":\"old-key\",\"module\":\"messenger\",\"to\":\"new-key\"}", wire: "{\"at\":\"2026-11-04T12:00:00Z\",\"from\":\"old-key\",\"module\":\"messenger\",\"to\":\"new-key\"}",
decode: func(b []byte) (any, error) { var v RenamedHolderEvent; err := json.Unmarshal(b, &v); return v, err }, subjectIs: regexp.MustCompile("^mesh\\.stream\\.[a-z0-9-]+\\.event\\.renamed$")},
{name: "retiring", subject: "mesh.stream.mesh-config.event.retiring", given: "{\"ask\":\"a1\",\"key\":\"old-key\",\"kind\":\"value\",\"module\":\"messenger\",\"since\":\"2026-11-04T12:00:00Z\"}", wire: "{\"ask\":\"a1\",\"key\":\"old-key\",\"kind\":\"value\",\"module\":\"messenger\",\"since\":\"2026-11-04T12:00:00Z\"}",
decode: func(b []byte) (any, error) { var v RetiringHolderEvent; err := json.Unmarshal(b, &v); return v, err }, subjectIs: regexp.MustCompile("^mesh\\.stream\\.[a-z0-9-]+\\.event\\.retiring$")},
{name: "retired", subject: "mesh.stream.mesh-config.event.retired", given: "{\"ask\":\"a1\",\"module\":\"messenger\",\"name\":\"old-key\",\"node\":\"ace\"}", wire: "{\"ask\":\"a1\",\"module\":\"messenger\",\"name\":\"old-key\",\"node\":\"ace\"}",
decode: func(b []byte) (any, error) { var v RetiredHolderEvent; err := json.Unmarshal(b, &v); return v, err }, subjectIs: regexp.MustCompile("^mesh\\.stream\\.[a-z0-9-]+\\.event\\.retired$")},
{name: "a module's value that falls back to a predefined key (captured)", subject: "", given: "{\"key\":\"font-size\",\"module\":\"dunst\",\"node\":\"g14\",\"predefined\":\"font-size\",\"source\":\"module\",\"value\":13}", wire: "{\"key\":\"font-size\",\"module\":\"dunst\",\"node\":\"g14\",\"predefined\":\"font-size\",\"source\":\"module\",\"value\":13}",
decode: func(b []byte) (any, error) {
var v ResolvedByNodeModuleKeyValue
err := json.Unmarshal(b, &v)
return v, err
}, subjectIs: nil},
{name: "a module's own value (captured)", subject: "", given: "{\"key\":\"width\",\"module\":\"dunst\",\"node\":\"g14\",\"source\":\"module\",\"value\":500}", wire: "{\"key\":\"width\",\"module\":\"dunst\",\"node\":\"g14\",\"source\":\"module\",\"value\":500}",
decode: func(b []byte) (any, error) {
var v ResolvedByNodeModuleKeyValue
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},
}
for _, c := range cases {
v, err := c.decode([]byte(c.given))
if err != nil {
t.Errorf("%s: does not decode: %v", c.name, err)
continue
}
back, err := json.Marshal(v)
if err != nil {
t.Errorf("%s: does not encode: %v", c.name, err)
continue
}
var got, want any
if err := json.Unmarshal(back, &got); err != nil {
t.Fatal(err)
}
if err := json.Unmarshal([]byte(c.wire), &want); err != nil {
t.Fatal(err)
}
if !reflect.DeepEqual(got, want) {
t.Errorf("%s: reads back as %s, want %s", c.name, back, c.wire)
}
if c.subjectIs != nil && !c.subjectIs.MatchString(c.subject) {
t.Errorf("%s: subject %q is not the contract's %s", c.name, c.subject, c.subjectIs)
}
}
}
+3
View File
@@ -0,0 +1,3 @@
module git.novox.be/novox/mesh-contracts/go/seats/meshconfig
go 1.26.0
+685
View File
@@ -0,0 +1,685 @@
// Code generated by seatgen from the seat definitions; DO NOT EDIT.
// Package meshmoduleassigner is the contract of the seat "mesh-module-assigner", defined whole (seats/mesh-module-assigner/seat.json): The seat that places modules on nodes (ADR 0567): the state of assignments and the proposals that change it.
package meshmoduleassigner
import (
"bytes"
"encoding/json"
"errors"
"strings"
"time"
)
// Seat is the seat's name.
const Seat = "mesh-module-assigner"
const (
VerbAssignments = "assignments"
VerbProposals = "proposals"
VerbPropose = "propose"
VerbWithdraw = "withdraw"
VerbAgain = "again"
)
const (
EventAssignmentsProposed = "assignments-proposed"
EventAssignmentsWithdrawn = "assignments-withdrawn"
EventAssignmentsJudged = "assignments-judged"
EventAssignmentsChanged = "assignments-changed"
)
// Verbs is every verb the seat serves, in its definition's order: a new copy each call.
func Verbs() []string {
return []string{VerbAssignments, VerbProposals, VerbPropose, VerbWithdraw, VerbAgain}
}
// Events is every event published on the seat's subject: a new copy each call.
func Events() []string {
return []string{EventAssignmentsProposed, EventAssignmentsWithdrawn, EventAssignmentsJudged, EventAssignmentsChanged}
}
// Family is where the seat's events are kept: the shared events stream, filtered to the seat's subjects.
const (
Family = "mesh-module-assigner.events"
FamilyMaxAgeDays = 7
)
// SubjectAssignmentsProposed is where assignments-proposed is published.
const SubjectAssignmentsProposed = "mesh.seat.mesh-module-assigner.event.assignments-proposed"
// SubjectAssignmentsWithdrawn is where assignments-withdrawn is published.
const SubjectAssignmentsWithdrawn = "mesh.seat.mesh-module-assigner.event.assignments-withdrawn"
// SubjectAssignmentsJudged is where assignments-judged is published.
const SubjectAssignmentsJudged = "mesh.seat.mesh-module-assigner.event.assignments-judged"
// SubjectAssignmentsChanged is where assignments-changed is published.
const SubjectAssignmentsChanged = "mesh.seat.mesh-module-assigner.event.assignments-changed"
const (
StateAssignments = "assignments"
StateProposals = "proposals"
StateCandidates = "candidates"
StatePulses = "pulses"
StateSeatHolders = "seat-holders"
)
// AssignmentsByNodeNodeKey is the key of one value of the state assignments under the form "<node>.node".
func AssignmentsByNodeNodeKey(node string) string { return node + ".node" }
// ParseAssignmentsByNodeNodeKey reads the parts of a key of the form "<node>.node", in the order of the form; the last result is false for a key of another form.
func ParseAssignmentsByNodeNodeKey(key string) (string, bool) {
rest := key
v0, after0, found0 := strings.Cut(rest, ".node")
if !found0 || v0 == "" {
return "", false
}
rest = after0
if rest != "" {
return "", false
}
return v0, true
}
// DecodeAssignmentsByNodeNodeValue reads one value of the state assignments under the form "<node>.node", refusing a field AssignmentsByNodeNodeValue does not know and any text after the value.
func DecodeAssignmentsByNodeNodeValue(raw []byte) (AssignmentsByNodeNodeValue, error) {
var v AssignmentsByNodeNodeValue
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
}
// AssignmentsByNodeModuleKey is the key of one value of the state assignments under the form "<node>.<module>".
func AssignmentsByNodeModuleKey(node, module string) string { return node + "." + module }
// ParseAssignmentsByNodeModuleKey reads the parts of a key of the form "<node>.<module>", in the order of the form; the last result is false for a key of another form.
func ParseAssignmentsByNodeModuleKey(key string) (string, string, bool) {
rest := key
v0, after0, found0 := strings.Cut(rest, ".")
if !found0 || v0 == "" {
return "", "", false
}
rest = after0
if rest == "" {
return "", "", false
}
v2 := rest
rest = ""
if rest != "" {
return "", "", false
}
return v0, v2, true
}
// DecodeAssignmentsByNodeModuleValue reads one value of the state assignments under the form "<node>.<module>", refusing a field AssignmentsByNodeModuleValue does not know and any text after the value.
func DecodeAssignmentsByNodeModuleValue(raw []byte) (AssignmentsByNodeModuleValue, error) {
var v AssignmentsByNodeModuleValue
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
}
// ProposalsByProposalKey is the key of one value of the state proposals under the form "<proposal>".
func ProposalsByProposalKey(proposal string) string { return proposal }
// ParseProposalsByProposalKey reads the parts of a key of the form "<proposal>", in the order of the form; the last result is false for a key of another form.
func ParseProposalsByProposalKey(key string) (string, bool) {
rest := key
if rest == "" {
return "", false
}
v0 := rest
rest = ""
if rest != "" {
return "", false
}
return v0, true
}
// DecodeProposalsByProposalValue reads one value of the state proposals under the form "<proposal>", refusing a field ProposalsByProposalValue does not know and any text after the value.
func DecodeProposalsByProposalValue(raw []byte) (ProposalsByProposalValue, error) {
var v ProposalsByProposalValue
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
}
// CandidatesByProposalNodeKey is the key of one value of the state candidates under the form "<proposal>.<node>".
func CandidatesByProposalNodeKey(proposal, node string) string { return proposal + "." + node }
// ParseCandidatesByProposalNodeKey reads the parts of a key of the form "<proposal>.<node>", in the order of the form; the last result is false for a key of another form.
func ParseCandidatesByProposalNodeKey(key string) (string, string, bool) {
rest := key
v0, after0, found0 := strings.Cut(rest, ".")
if !found0 || v0 == "" {
return "", "", false
}
rest = after0
if rest == "" {
return "", "", false
}
v2 := rest
rest = ""
if rest != "" {
return "", "", false
}
return v0, v2, true
}
// DecodeCandidatesByProposalNodeValue reads one value of the state candidates under the form "<proposal>.<node>", refusing a field CandidatesByProposalNodeValue does not know and any text after the value.
func DecodeCandidatesByProposalNodeValue(raw []byte) (CandidatesByProposalNodeValue, error) {
var v CandidatesByProposalNodeValue
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
}
// PulsesByAnswerProposalNodeKey is the key of one value of the state pulses under the form "answer.<proposal>.<node>".
func PulsesByAnswerProposalNodeKey(proposal, node string) string {
return "answer." + proposal + "." + node
}
// ParsePulsesByAnswerProposalNodeKey reads the parts of a key of the form "answer.<proposal>.<node>", in the order of the form; the last result is false for a key of another form.
func ParsePulsesByAnswerProposalNodeKey(key string) (string, string, bool) {
rest := key
if !strings.HasPrefix(rest, "answer.") {
return "", "", false
}
rest = rest[7:]
v1, after1, found1 := strings.Cut(rest, ".")
if !found1 || v1 == "" {
return "", "", false
}
rest = after1
if rest == "" {
return "", "", false
}
v3 := rest
rest = ""
if rest != "" {
return "", "", false
}
return v1, v3, true
}
// DecodePulsesByAnswerProposalNodeValue reads one value of the state pulses under the form "answer.<proposal>.<node>", refusing a field PulsesByAnswerProposalNodeValue does not know and any text after the value.
func DecodePulsesByAnswerProposalNodeValue(raw []byte) (PulsesByAnswerProposalNodeValue, error) {
var v PulsesByAnswerProposalNodeValue
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
}
// PulsesByAskProposalKey is the key of one value of the state pulses under the form "ask.<proposal>".
func PulsesByAskProposalKey(proposal string) string { return "ask." + proposal }
// ParsePulsesByAskProposalKey reads the parts of a key of the form "ask.<proposal>", in the order of the form; the last result is false for a key of another form.
func ParsePulsesByAskProposalKey(key string) (string, bool) {
rest := key
if !strings.HasPrefix(rest, "ask.") {
return "", false
}
rest = rest[4:]
if rest == "" {
return "", false
}
v1 := rest
rest = ""
if rest != "" {
return "", false
}
return v1, true
}
// DecodePulsesByAskProposalValue reads one value of the state pulses under the form "ask.<proposal>", refusing a field PulsesByAskProposalValue does not know and any text after the value.
func DecodePulsesByAskProposalValue(raw []byte) (PulsesByAskProposalValue, error) {
var v PulsesByAskProposalValue
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
}
// SeatHoldersBySeatKey is the key of one value of the state seat-holders under the form "<seat>".
func SeatHoldersBySeatKey(seat string) string { return seat }
// ParseSeatHoldersBySeatKey reads the parts of a key of the form "<seat>", in the order of the form; the last result is false for a key of another form.
func ParseSeatHoldersBySeatKey(key string) (string, bool) {
rest := key
if rest == "" {
return "", false
}
v0 := rest
rest = ""
if rest != "" {
return "", false
}
return v0, true
}
// DecodeSeatHoldersBySeatValue reads one value of the state seat-holders under the form "<seat>", refusing a field SeatHoldersBySeatValue does not know and any text after the value.
func DecodeSeatHoldersBySeatValue(raw []byte) (SeatHoldersBySeatValue, error) {
var v SeatHoldersBySeatValue
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
}
// SeatHoldersBySeatNodeKey is the key of one value of the state seat-holders under the form "<seat>.<node>".
func SeatHoldersBySeatNodeKey(seat, node string) string { return seat + "." + node }
// ParseSeatHoldersBySeatNodeKey reads the parts of a key of the form "<seat>.<node>", in the order of the form; the last result is false for a key of another form.
func ParseSeatHoldersBySeatNodeKey(key string) (string, string, bool) {
rest := key
v0, after0, found0 := strings.Cut(rest, ".")
if !found0 || v0 == "" {
return "", "", false
}
rest = after0
if rest == "" {
return "", "", false
}
v2 := rest
rest = ""
if rest != "" {
return "", "", false
}
return v0, v2, true
}
// DecodeSeatHoldersBySeatNodeValue reads one value of the state seat-holders under the form "<seat>.<node>", refusing a field SeatHoldersBySeatNodeValue does not know and any text after the value.
func DecodeSeatHoldersBySeatNodeValue(raw []byte) (SeatHoldersBySeatNodeValue, error) {
var v SeatHoldersBySeatNodeValue
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
}
// Assignments is one node's assignments assembled.
type Assignments struct {
Decided NodeDecided `json:"decided"`
Holds []Holding `json:"holds,omitempty"`
Modules []PlacedModule `json:"modules"`
Node string `json:"node"`
// by provision, the provider chosen for the node
Pins map[string]Provider `json:"pins,omitempty"`
Revision string `json:"revision,omitempty"`
V int64 `json:"v,omitempty"`
}
// AssignmentsEventWarrant is who approved it, through which channel, when.
type AssignmentsEventWarrant struct {
At time.Time `json:"at"`
Warrant string `json:"warrant"`
Words string `json:"words"`
}
// AssignmentsEvent is what each assignments event carries.
type AssignmentsEvent struct {
// for assignments-withdrawn, why it ended
Ended string `json:"ended,omitempty"`
// for assignments-changed, by node the revisions before and after and the modules added, removed or changed
Nodes []map[string]any `json:"nodes,omitempty"`
Proposal Proposal `json:"proposal"`
// who approved it, through which channel, when
Warrant *AssignmentsEventWarrant `json:"warrant,omitempty"`
}
// FoundTunnel is a tunnel found on the machine when it was adopted: everything but its private key.
type FoundTunnel struct {
Address string `json:"address,omitempty"`
Config string `json:"config,omitempty"`
Interface string `json:"interface"`
Mtu int64 `json:"mtu,omitempty"`
Peers []FoundTunnelPeer `json:"peers,omitempty"`
Port int64 `json:"port,omitempty"`
PublicKey string `json:"public-key,omitempty"`
Range string `json:"range,omitempty"`
Unit string `json:"unit,omitempty"`
}
// FoundTunnelPeer is one peer of a found tunnel.
type FoundTunnelPeer struct {
Address string `json:"address,omitempty"`
PublicKey string `json:"public-key"`
}
// Holding is one seat a module placed on the node holds.
type Holding struct {
Claim string `json:"claim"`
Kind string `json:"kind,omitempty"`
Module string `json:"module"`
Scope string `json:"scope,omitempty"`
Site string `json:"site,omitempty"`
}
// NodeDecided is what a person decides of a node; no secret and no key material.
type NodeDecided struct {
Account string `json:"account,omitempty"`
AccountHome string `json:"account-home,omitempty"`
Address string `json:"address,omitempty"`
Adopted bool `json:"adopted,omitempty"`
AgentAccount string `json:"agent-account,omitempty"`
AgentAccountHome string `json:"agent-account-home,omitempty"`
Endpoint string `json:"endpoint,omitempty"`
Hub bool `json:"hub,omitempty"`
// on the hub, the name a person gave a peer of that tunnel, by its public key
PeerNames map[string]string `json:"peer-names,omitempty"`
PublicDomain string `json:"public-domain,omitempty"`
Site string `json:"site,omitempty"`
Taken []string `json:"taken,omitempty"`
// the tunnel found on the node when it was adopted
Tunnel *FoundTunnel `json:"tunnel,omitempty"`
}
// PlacedModule is one module placed on a node.
type PlacedModule struct {
// by provision, the provider each requirement that keeps data is bound to
Bindings map[string]Provider `json:"bindings,omitempty"`
HasLayer bool `json:"has-layer,omitempty"`
HasMeshWide bool `json:"has-mesh-wide,omitempty"`
// this node's settings layer of the module's assignments
Layer map[string]any `json:"layer,omitempty"`
// the whole mesh's settings layer of the module's assignments
MeshWide map[string]any `json:"mesh-wide,omitempty"`
// config values a person set in the whole mesh's layer, for a module in the settings form
MeshWideValues map[string]any `json:"mesh-wide-values,omitempty"`
Module string `json:"module"`
// the host port each declared port is published on, by the port the software listens on
Ports map[string]int64 `json:"ports,omitempty"`
// config values a person set in this node's layer, for a module in the settings form
Values map[string]any `json:"values,omitempty"`
}
// Proposal is an open proposal.
type Proposal struct {
Again *time.Time `json:"again,omitempty"`
Ask string `json:"ask,omitempty"`
At time.Time `json:"at"`
// by node whose key it changes, the revision of the key it was laid over (0: none)
Base map[string]int64 `json:"base"`
By string `json:"by"`
Changes []ProposalChange `json:"changes"`
ExportDigest string `json:"export-digest,omitempty"`
HandBack bool `json:"hand-back,omitempty"`
ID string `json:"id"`
Must []string `json:"must,omitempty"`
Seed bool `json:"seed,omitempty"`
// answering, failed or asked
State string `json:"state"`
V int64 `json:"v,omitempty"`
Verdict *Verdict `json:"verdict,omitempty"`
Warrant *Warrant `json:"warrant,omitempty"`
Why string `json:"why"`
Withdrawn string `json:"withdrawn,omitempty"`
Words string `json:"words,omitempty"`
Written *time.Time `json:"written,omitempty"`
}
// ProposalChangeKeys is the public halves of a joining node's keys.
type ProposalChangeKeys struct {
Identity string `json:"identity"`
Overlay string `json:"overlay,omitempty"`
Sealing string `json:"sealing"`
Serving string `json:"serving,omitempty"`
}
// ProposalChange is one change of a proposal.
type ProposalChange struct {
Bindings map[string]Provider `json:"bindings,omitempty"`
Claim string `json:"claim,omitempty"`
Decided *NodeDecided `json:"decided,omitempty"`
Field string `json:"field,omitempty"`
// the public halves of a joining node's keys
Keys *ProposalChangeKeys `json:"keys,omitempty"`
Kind string `json:"kind,omitempty"`
Layer map[string]any `json:"layer,omitempty"`
MeshWide map[string]any `json:"mesh-wide,omitempty"`
MeshWideValues map[string]any `json:"mesh-wide-values,omitempty"`
Module string `json:"module,omitempty"`
Node string `json:"node"`
// place, take-off, pin, unpin, hold, unhold, decide, config, seed, add-node or remove-node
Op string `json:"op"`
Ports map[string]int64 `json:"ports,omitempty"`
Provider *Provider `json:"provider,omitempty"`
Provision string `json:"provision,omitempty"`
Scope string `json:"scope,omitempty"`
Site string `json:"site,omitempty"`
// a decided field's value
Value any `json:"value,omitempty"`
Values map[string]any `json:"values,omitempty"`
// the node's whole assignments, for a seed
Whole *Assignments `json:"whole,omitempty"`
}
// Provider is the module on a node that answers a requirement or holds a seat.
type Provider struct {
Module string `json:"module"`
Node string `json:"node"`
}
// SeatHoldersHoldersItem is a type of the contract.
type SeatHoldersHoldersItem struct {
Module string `json:"module"`
Node string `json:"node"`
}
// SeatHolders is one key of seat-holders.
type SeatHolders struct {
Holders []SeatHoldersHoldersItem `json:"holders"`
Node string `json:"node,omitempty"`
// the claim names no seat of the seat list
Outside bool `json:"outside,omitempty"`
Scope string `json:"scope"`
Seat string `json:"seat"`
V int64 `json:"v,omitempty"`
}
// Verdict is the holder's judgement of a proposal's answers.
type Verdict struct {
// every node's answer to its candidate, by node
Answers map[string]map[string]any `json:"answers,omitempty"`
At time.Time `json:"at"`
Carried map[string][]string `json:"carried,omitempty"`
Fails []string `json:"fails,omitempty"`
Intended map[string][]string `json:"intended,omitempty"`
Notes []string `json:"notes,omitempty"`
Silent []string `json:"silent,omitempty"`
// waits, pass or fail
State string `json:"state"`
Unintended map[string][]string `json:"unintended,omitempty"`
Until *time.Time `json:"until,omitempty"`
}
// Warrant is the enrolment's warrant an add-node carries.
type Warrant struct {
Address string `json:"address"`
Ask string `json:"ask"`
Node string `json:"node"`
Warrant string `json:"warrant"`
}
// AssignmentsInput is what assignments takes.
type AssignmentsInput struct {
// "true": the changes in order, from the stream and the state's revisions
History string `json:"history,omitempty"`
// one module: the nodes it is placed on
Module string `json:"module,omitempty"`
// one node
Node string `json:"node,omitempty"`
// "true": who holds each seat, and on which node (the state seat-holders); with node, that node's
Seats string `json:"seats,omitempty"`
}
// The values of AssignmentsInput's history.
const (
AssignmentsInputHistoryTrue = "true"
AssignmentsInputHistoryFalse = "false"
)
// The values of AssignmentsInput's seats.
const (
AssignmentsInputSeatsTrue = "true"
AssignmentsInputSeatsFalse = "false"
)
// AssignmentsAnswer is what assignments answers.
type AssignmentsAnswer = map[string]any
// ProposalsInput is what proposals takes.
type ProposalsInput struct {
// one proposal's id
ID string `json:"id,omitempty"`
}
// ProposalsAnswer is what proposals answers.
type ProposalsAnswer = map[string]any
// ProposeInput is what propose takes.
type ProposeInput struct {
// the changes, each an object with `op`: place (node, module, ports, bindings), take-off (node, module), pin (node, provision, provider {node, module}), unpin (node, provision), hold (node, module, claim, scope, kind, site), unhold (node, module, claim), decide (node, field, value: a decided field of the node, its address among them), config (node, module, mesh-wide, layer, mesh-wide-values, values), add-node (node, decided, keys: a joining node, mesh-enrolment's alone, the proposal's one change, with the enrolment's warrant, its tunnel key alone at the operator's yes; ADR 0587 §7), remove-node (node: a joining node never confirmed, taken off when its enrolment ends; mesh-enrolment's alone, on the same warrant)
Changes []map[string]any `json:"changes"`
// for an add-node alone: the enrolment's warrant {node, address, ask, warrant}, naming the change's node and address
Warrant map[string]any `json:"warrant,omitempty"`
// why, shown to the operator with the proposal
Why string `json:"why"`
}
// ProposeAnswer is what propose answers.
type ProposeAnswer = map[string]any
// WithdrawInput is what withdraw takes.
type WithdrawInput struct {
// the proposal's id
ID string `json:"id"`
// why, kept in the event
Why string `json:"why"`
}
// WithdrawAnswer is what withdraw answers.
type WithdrawAnswer = map[string]any
// AgainInput is what again takes.
type AgainInput struct {
// the proposal's id
ID string `json:"id"`
}
// AgainAnswer is what again answers.
type AgainAnswer = map[string]any
// AssignmentsProposedEvent is the body of the event assignments-proposed.
type AssignmentsProposedEvent = AssignmentsEvent
// AssignmentsWithdrawnEvent is the body of the event assignments-withdrawn.
type AssignmentsWithdrawnEvent = AssignmentsEvent
// AssignmentsJudgedEvent is the body of the event assignments-judged.
type AssignmentsJudgedEvent = AssignmentsEvent
// AssignmentsChangedEvent is the body of the event assignments-changed.
type AssignmentsChangedEvent = AssignmentsEvent
// AssignmentsByNodeNodeValueModulesItem is a type of the contract.
type AssignmentsByNodeNodeValueModulesItem struct {
// the digest of the module key's value it stands for
Digest string `json:"digest"`
Module string `json:"module"`
}
// AssignmentsByNodeNodeValue is one value of the state assignments under the key <node>.node.
type AssignmentsByNodeNodeValue struct {
Decided NodeDecided `json:"decided"`
Holds []Holding `json:"holds,omitempty"`
// the modules placed, in the order the composition reads them
Modules []AssignmentsByNodeNodeValueModulesItem `json:"modules"`
Node string `json:"node"`
Pins map[string]Provider `json:"pins,omitempty"`
V int64 `json:"v,omitempty"`
}
// AssignmentsByNodeModuleValue is one value of the state assignments under the key <node>.<module>.
type AssignmentsByNodeModuleValue struct {
Node string `json:"node"`
Placed PlacedModule `json:"placed"`
// the value it replaces while a write is under way
Previous *PlacedModule `json:"previous,omitempty"`
V int64 `json:"v,omitempty"`
}
// ProposalsByProposalValue is one value of the state proposals under the key <proposal>.
type ProposalsByProposalValue = Proposal
// CandidatesByProposalNodeValue is one value of the state candidates under the key <proposal>.<node>.
type CandidatesByProposalNodeValue struct {
// the node's assignments with the proposal laid over it; absent when the proposal takes its key away
Assignments *Assignments `json:"assignments,omitempty"`
Proposal string `json:"proposal"`
// the proposal changes this node's composition through another's key
Reached bool `json:"reached,omitempty"`
V int64 `json:"v,omitempty"`
}
// PulsesByAnswerProposalNodeValue is one value of the state pulses under the key answer.<proposal>.<node>.
type PulsesByAnswerProposalNodeValue struct {
V int64 `json:"v,omitempty"`
}
// PulsesByAskProposalValue is one value of the state pulses under the key ask.<proposal>.
type PulsesByAskProposalValue struct {
V int64 `json:"v,omitempty"`
}
// SeatHoldersBySeatValue is one value of the state seat-holders under the key <seat>.
type SeatHoldersBySeatValue = SeatHolders
// SeatHoldersBySeatNodeValue is one value of the state seat-holders under the key <seat>.<node>.
type SeatHoldersBySeatNodeValue = SeatHolders
@@ -0,0 +1,96 @@
// Code generated by seatgen from the seat definitions; DO NOT EDIT.
package meshmoduleassigner
import (
"encoding/json"
"reflect"
"regexp"
"testing"
)
// TestFixtures reads every fixture of seats/mesh-module-assigner/fixtures: each decodes into its type, a field the type does not
// know is dropped (ADR 0608 §6), and it reads back as its wire says; each subject named is the contract's.
func TestFixtures(t *testing.T) {
cases := []struct {
name, subject, given, wire string
decode func([]byte) (any, error)
subjectIs *regexp.Regexp
}{
{name: "a proposal says assignments-proposed", subject: "mesh.seat.mesh-module-assigner.event.assignments-proposed", given: "{\"proposal\":{\"at\":\"2026-11-05T12:00:00Z\",\"base\":{\"ace\":3},\"by\":\"operator\",\"changes\":[{\"module\":\"plex\",\"node\":\"ace\",\"op\":\"place\",\"ports\":{\"32400\":32400}}],\"id\":\"p-20261105t120000-aabbccdd\",\"state\":\"answering\",\"why\":\"move plex\"}}", wire: "{\"proposal\":{\"at\":\"2026-11-05T12:00:00Z\",\"base\":{\"ace\":3},\"by\":\"operator\",\"changes\":[{\"module\":\"plex\",\"node\":\"ace\",\"op\":\"place\",\"ports\":{\"32400\":32400}}],\"id\":\"p-20261105t120000-aabbccdd\",\"state\":\"answering\",\"why\":\"move plex\"}}",
decode: func(b []byte) (any, error) {
var v AssignmentsProposedEvent
err := json.Unmarshal(b, &v)
return v, err
}, subjectIs: regexp.MustCompile("^mesh\\.seat\\.mesh-module-assigner\\.event\\.assignments-proposed$")},
{name: "a node's key", subject: "", given: "{\"decided\":{\"address\":\"10.0.0.2\"},\"modules\":[{\"digest\":\"0123456789abcdef\",\"module\":\"plex\"}],\"node\":\"ace\",\"v\":1}", wire: "{\"decided\":{\"address\":\"10.0.0.2\"},\"modules\":[{\"digest\":\"0123456789abcdef\",\"module\":\"plex\"}],\"node\":\"ace\",\"v\":1}",
decode: func(b []byte) (any, error) {
var v AssignmentsByNodeNodeValue
err := json.Unmarshal(b, &v)
return v, err
}, subjectIs: nil},
{name: "a node that came with a tunnel", subject: "", given: "{\"decided\":{\"tunnel\":{\"interface\":\"wg0\",\"peers\":[{\"address\":\"10.77.0.9/32\",\"public-key\":\"k2\"}],\"port\":51820,\"public-key\":\"k1\"}},\"modules\":[],\"node\":\"ace\",\"v\":1}", wire: "{\"decided\":{\"tunnel\":{\"interface\":\"wg0\",\"peers\":[{\"address\":\"10.77.0.9/32\",\"public-key\":\"k2\"}],\"port\":51820,\"public-key\":\"k1\"}},\"modules\":[],\"node\":\"ace\",\"v\":1}",
decode: func(b []byte) (any, error) {
var v AssignmentsByNodeNodeValue
err := json.Unmarshal(b, &v)
return v, err
}, subjectIs: nil},
{name: "a module's key", subject: "", given: "{\"node\":\"ace\",\"placed\":{\"bindings\":{\"database\":{\"module\":\"postgres\",\"node\":\"ace\"}},\"module\":\"plex\",\"ports\":{\"32400\":32400}},\"v\":1}", wire: "{\"node\":\"ace\",\"placed\":{\"bindings\":{\"database\":{\"module\":\"postgres\",\"node\":\"ace\"}},\"module\":\"plex\",\"ports\":{\"32400\":32400}},\"v\":1}",
decode: func(b []byte) (any, error) {
var v AssignmentsByNodeModuleValue
err := json.Unmarshal(b, &v)
return v, err
}, subjectIs: nil},
{name: "an open proposal", subject: "", given: "{\"at\":\"2026-11-05T12:00:00Z\",\"base\":{\"ace\":3},\"by\":\"operator\",\"changes\":[{\"module\":\"plex\",\"node\":\"ace\",\"op\":\"place\",\"ports\":{\"32400\":32400}}],\"id\":\"p-20261105t120000-aabbccdd\",\"state\":\"answering\",\"why\":\"move plex\"}", wire: "{\"at\":\"2026-11-05T12:00:00Z\",\"base\":{\"ace\":3},\"by\":\"operator\",\"changes\":[{\"module\":\"plex\",\"node\":\"ace\",\"op\":\"place\",\"ports\":{\"32400\":32400}}],\"id\":\"p-20261105t120000-aabbccdd\",\"state\":\"answering\",\"why\":\"move plex\"}",
decode: func(b []byte) (any, error) {
var v ProposalsByProposalValue
err := json.Unmarshal(b, &v)
return v, err
}, subjectIs: nil},
{name: "a candidate", subject: "", given: "{\"proposal\":\"p-20261105t120000-aabbccdd\",\"v\":1}", wire: "{\"proposal\":\"p-20261105t120000-aabbccdd\",\"v\":1}",
decode: func(b []byte) (any, error) {
var v CandidatesByProposalNodeValue
err := json.Unmarshal(b, &v)
return v, err
}, subjectIs: nil},
{name: "the bound on an answer", subject: "", given: "{\"v\":1}", wire: "{\"v\":1}",
decode: func(b []byte) (any, error) {
var v PulsesByAnswerProposalNodeValue
err := json.Unmarshal(b, &v)
return v, err
}, subjectIs: nil},
{name: "a seat held once", subject: "", given: "{\"holders\":[{\"module\":\"mesh-delivery-assigner\",\"node\":\"novox\"}],\"scope\":\"mesh\",\"seat\":\"mesh-module-assigner\",\"v\":1}", wire: "{\"holders\":[{\"module\":\"mesh-delivery-assigner\",\"node\":\"novox\"}],\"scope\":\"mesh\",\"seat\":\"mesh-module-assigner\",\"v\":1}",
decode: func(b []byte) (any, error) { var v SeatHoldersBySeatValue; err := json.Unmarshal(b, &v); return v, err }, subjectIs: nil},
{name: "a node seat on a node", subject: "", given: "{\"holders\":[{\"module\":\"node-files\",\"node\":\"ace\"}],\"node\":\"ace\",\"scope\":\"node\",\"seat\":\"node-files\",\"v\":1}", wire: "{\"holders\":[{\"module\":\"node-files\",\"node\":\"ace\"}],\"node\":\"ace\",\"scope\":\"node\",\"seat\":\"node-files\",\"v\":1}",
decode: func(b []byte) (any, error) {
var v SeatHoldersBySeatNodeValue
err := json.Unmarshal(b, &v)
return v, err
}, subjectIs: nil},
}
for _, c := range cases {
v, err := c.decode([]byte(c.given))
if err != nil {
t.Errorf("%s: does not decode: %v", c.name, err)
continue
}
back, err := json.Marshal(v)
if err != nil {
t.Errorf("%s: does not encode: %v", c.name, err)
continue
}
var got, want any
if err := json.Unmarshal(back, &got); err != nil {
t.Fatal(err)
}
if err := json.Unmarshal([]byte(c.wire), &want); err != nil {
t.Fatal(err)
}
if !reflect.DeepEqual(got, want) {
t.Errorf("%s: reads back as %s, want %s", c.name, back, c.wire)
}
if c.subjectIs != nil && !c.subjectIs.MatchString(c.subject) {
t.Errorf("%s: subject %q is not the contract's %s", c.name, c.subject, c.subjectIs)
}
}
}
+3
View File
@@ -0,0 +1,3 @@
module git.novox.be/novox/mesh-contracts/go/seats/meshmoduleassigner
go 1.26.0
File diff suppressed because it is too large Load Diff
File diff suppressed because one or more lines are too long
+3
View File
@@ -0,0 +1,3 @@
module git.novox.be/novox/mesh-contracts/go/seats/nodeengine
go 1.26.0
@@ -0,0 +1,214 @@
// Package meshconditionsclient is the logic every observer, healer and reader of conditions shares over the seat
// mesh-conditions (novox/hq ADR 0564, ADR 0634 §4): how a finding's key is made and checked, the scopes and the bounds,
// the order a person reads conditions in, and the answers a notification offers. The layouts are the seat's contract's.
package meshconditionsclient
import (
"fmt"
"regexp"
"sort"
"strings"
"time"
seat "git.novox.be/novox/mesh-contracts/health-and-repair/go/seats/meshconditions"
"git.novox.be/novox/mesh-sdk/go/views"
)
// The two severities, how soon the operator is needed.
const (
Urgent = "urgent"
Warning = "warning"
)
// The scopes a condition's key starts with: what kind of thing is wrong.
const (
ScopeMachine = "machine"
ScopePlan = "plan"
ScopeCall = "call"
ScopeBuild = "build"
ScopeMerge = "merge"
ScopeProvider = "provider"
ScopeSeat = "seat"
ScopeBus = "bus"
ScopeCore = "core"
ScopeProbe = "probe"
ScopeMesh = "mesh"
// ScopeDelivery is a delivery by its id (novox/hq ADR 0239); ScopeModule a module on a machine, `<module>.<machine>`.
ScopeDelivery = "delivery"
ScopeModule = "module"
)
// Scopes is every scope, in the order a person reads them.
var Scopes = []string{ScopeMachine, ScopePlan, ScopeCall, ScopeBuild, ScopeMerge, ScopeProvider,
ScopeSeat, ScopeBus, ScopeCore, ScopeProbe, ScopeMesh, ScopeDelivery, ScopeModule}
// Who resolves a condition.
const (
ResolverSelf = "self"
ResolverOperator = "operator"
ResolverAgent = "agent"
)
// ResolverHealer is the resolver of a condition a registered healer works on.
func ResolverHealer(name string) string { return "healer:" + name }
// The bounds a condition keeps.
const (
KeptEvidence = 10
KeptTried = 10
KeptGaps = 8
MaxSilence = 7 * 24 * time.Hour
ReopenWithin = 10 * time.Minute
)
// CauseOperatorAnswer is the cause of an act the operator chose.
const CauseOperatorAnswer = "operator-answer"
// The changes a condition event says.
const (
ChangeRaised = "raised"
ChangeReopened = "reopened"
ChangeSeverity = "severity"
ChangeResolver = "resolver"
ChangeSilenced = "silenced"
ChangeUnsilenced = "silence-ended"
ChangeCleared = "cleared"
ChangeWords = "words"
)
var unsafeKey = regexp.MustCompile(`[^A-Za-z0-9_=/-]`)
// Key is a condition's key: its scope, the parts of its id and a token, each made safe for a bus key.
func Key(scope, id, token string) string {
var parts []string
for _, p := range append(append([]string{scope}, strings.Split(id, ".")...), token) {
p = unsafeKey.ReplaceAllString(strings.TrimSpace(p), "_")
if p == "" {
p = "_"
}
parts = append(parts, p)
}
return strings.Join(parts, ".")
}
// EventFor is the seat's event that says a change.
func EventFor(change string) string {
switch change {
case ChangeRaised, ChangeReopened:
return seat.EventConditionRaised
case ChangeCleared:
return seat.EventConditionCleared
}
return seat.EventConditionChanged
}
// ShowKeeper is the call that shows one condition whole.
func ShowKeeper(key string) string { return seat.Seat + "." + seat.VerbConditions + " key=" + key }
// SilenceAction is the answer a notification offers to stop a condition's messages for a week.
func SilenceAction(key string) seat.Action {
return seat.Action{Label: "Silence for a week", Verb: seat.Seat + "." + seat.VerbSilence, Level: seat.ActionLevelAcknowledge,
Arguments: map[string]string{"key": key, "for": "7d", "why": "", "cause": CauseOperatorAnswer}}
}
// CheckFinding refuses a finding the keeper could not make a condition of, naming why.
func CheckFinding(f seat.FindingsByKeyValue) error {
known := false
for _, s := range Scopes {
known = known || s == f.Subject.Scope
}
token := f.Ref
if token == "" {
token = f.Kind
}
switch {
case !known:
return fmt.Errorf("health: a finding's scope is one of %s, not %q", strings.Join(Scopes, ", "), f.Subject.Scope)
case strings.TrimSpace(f.Subject.ID) == "":
return fmt.Errorf("health: a %s finding names what it is about", f.Subject.Scope)
case strings.TrimSpace(f.Kind) == "":
return fmt.Errorf("health: the finding %s has no kind", f.Key)
case f.Key != Key(f.Subject.Scope, f.Subject.ID, token):
return fmt.Errorf("health: the finding's key %q is not %q, its scope, id and kind", f.Key, Key(f.Subject.Scope, f.Subject.ID, token))
case f.Severity != Urgent && f.Severity != Warning:
return fmt.Errorf("health: the finding %s is urgent or a warning, not %q", f.Key, f.Severity)
case strings.TrimSpace(f.Summary) == "":
return fmt.Errorf("health: the finding %s says nothing", f.Key)
case strings.TrimSpace(f.Source) == "":
return fmt.Errorf("health: the finding %s does not say what saw it", f.Key)
case strings.TrimSpace(f.Observer) == "":
return fmt.Errorf("health: the finding %s does not name its observer", f.Key)
case len(f.Evidence) > KeptEvidence:
return fmt.Errorf("health: the finding %s keeps %d observations, at most %d", f.Key, len(f.Evidence), KeptEvidence)
}
return nil
}
// Order sorts conditions as a person reads them: urgent first, then oldest raised, then by key.
func Order(list []seat.ConditionsByKeyValue) {
raised := func(c seat.ConditionsByKeyValue) time.Time { return c.Raised }
sort.SliceStable(list, func(i, j int) bool {
if list[i].Severity != list[j].Severity {
return list[i].Severity == Urgent
}
if ri, rj := raised(list[i]), raised(list[j]); !ri.Equal(rj) {
return ri.Before(rj)
}
return list[i].Key < list[j].Key
})
}
// SilencedAt says whether the condition's messages are silenced at now.
func SilencedAt(c seat.ConditionsByKeyValue, now time.Time) bool {
if c.Silenced == nil {
return false
}
until := c.Silenced.Until
return until != nil && now.Before(*until)
}
// Escalated says the condition waits on the operator after healers tried.
func Escalated(c seat.ConditionsByKeyValue) bool {
return c.Resolver == ResolverOperator && len(c.Tried) > 0
}
// FindingVersion is the layout version of a finding's value.
const FindingVersion = 1
// FindingState is a part's own state `findings`, where the condition keeper reads what a part saw: the runtime's kept
// state (mesh-sdk's KeptState) satisfies it.
type FindingState interface {
Put(key string, value any) (uint64, error)
Delete(key string) error
}
// FindingWriter writes and clears a part's findings of a module in its own state `findings`, in this seat's layout; it
// is mesh-sdk's views.FindingWriter. A finding is a warning about the module its ID names, and is checked
// (CheckFinding) before it is written.
type FindingWriter struct{ State FindingState }
var _ views.FindingWriter = FindingWriter{}
// FindingFor is the value and key of the seat's finding for f.
func FindingFor(f views.Finding) seat.FindingsByKeyValue {
at := f.At.UTC()
return seat.FindingsByKeyValue{V: FindingVersion, Key: Key(ScopeModule, f.ID, f.Kind), Kind: f.Kind,
Subject: seat.Subject{Scope: ScopeModule, ID: f.ID}, Severity: Warning, Summary: f.Summary, Headline: f.Headline,
Explanation: f.Explanation, Evidence: []seat.Evidence{{At: at, Said: f.Summary}}, Source: f.Observer + "." + f.Kind,
Observer: f.Observer, At: at}
}
// Put writes the finding.
func (w FindingWriter) Put(f views.Finding) error {
v := FindingFor(f)
if err := CheckFinding(v); err != nil {
return err
}
_, err := w.State.Put(v.Key, v)
return err
}
// Delete clears the finding.
func (w FindingWriter) Delete(f views.Finding) error {
return w.State.Delete(Key(ScopeModule, f.ID, f.Kind))
}
@@ -0,0 +1,195 @@
package meshconditionsclient
import (
"os"
"reflect"
"strings"
"testing"
"time"
seat "git.novox.be/novox/mesh-contracts/health-and-repair/go/seats/meshconditions"
"git.novox.be/novox/mesh-sdk/go/views"
)
// ADR 0564 clause 3: the key rule, the checks of a finding, the order and the silence a person reads conditions by.
func TestAFindingsKeyAndTheOrderOfConditions(t *testing.T) {
if k := Key("machine", "g14.local", "silent"); k != "machine.g14.local.silent" {
t.Errorf("key %s", k)
}
if k := Key("call", "a b", ""); k != "call.a_b._" {
t.Errorf("an unsafe part and an empty token: %s", k)
}
good := seat.FindingsByKeyValue{Key: "machine.g14.silent", Kind: "silent", Severity: Warning, Summary: "x", Source: "S1",
Observer: "mesh-delivery-watchman", Subject: seat.Subject{Scope: ScopeMachine, ID: "g14"}}
for _, c := range []struct {
name string
change func(*seat.FindingsByKeyValue)
refuse string
}{
{"a good finding", func(*seat.FindingsByKeyValue) {}, ""},
{"an unknown scope", func(f *seat.FindingsByKeyValue) { f.Subject.Scope = "moon" }, "scope is one of"},
{"a key that is not its subject's", func(f *seat.FindingsByKeyValue) { f.Key = "machine.g14.other" }, "is not"},
{"a reference as the key's last part", func(f *seat.FindingsByKeyValue) { f.Ref = "gone"; f.Key = "machine.g14.gone" }, ""},
{"no summary", func(f *seat.FindingsByKeyValue) { f.Summary = " " }, "says nothing"},
{"no observer", func(f *seat.FindingsByKeyValue) { f.Observer = "" }, "observer"},
{"a severity of neither kind", func(f *seat.FindingsByKeyValue) { f.Severity = "info" }, "urgent or a warning"},
{"too much evidence", func(f *seat.FindingsByKeyValue) { f.Evidence = make([]seat.Evidence, KeptEvidence+1) }, "keeps 11"},
} {
f := good
c.change(&f)
err := CheckFinding(f)
if (c.refuse == "") != (err == nil) || (err != nil && !strings.Contains(err.Error(), c.refuse)) {
t.Errorf("%s: %v", c.name, err)
}
}
list := []seat.ConditionsByKeyValue{
{Key: "b", Severity: Warning, Raised: when("2026-11-01T10:00:00Z")},
{Key: "c", Severity: Urgent, Raised: when("2026-11-02T10:00:00Z")},
{Key: "a", Severity: Urgent, Raised: when("2026-11-01T10:00:00.5Z"), Silenced: &seat.ConditionsByKeyValueSilenced{Until: ptr(when("2026-11-03T00:00:00Z"))}},
}
Order(list)
if got := list[0].Key + list[1].Key + list[2].Key; got != "acb" {
t.Errorf("order %s, want urgent oldest first", got)
}
now := time.Date(2026, 11, 2, 0, 0, 0, 0, time.UTC)
if !SilencedAt(list[0], now) || SilencedAt(list[0], now.Add(48*time.Hour)) || SilencedAt(list[1], now) {
t.Error("silence is until its end")
}
if EventFor(ChangeReopened) != seat.EventConditionRaised || EventFor(ChangeSilenced) != seat.EventConditionChanged ||
EventFor(ChangeCleared) != seat.EventConditionCleared {
t.Error("a change is said as its event")
}
if a := SilenceAction("k"); a.Verb != "mesh-conditions.silence" || ShowKeeper("k") != "mesh-conditions.conditions key=k" {
t.Errorf("the seat's addresses: %+v", a)
}
}
type fakeFindings struct {
put map[string]any
deleted []string
}
func (f *fakeFindings) Put(key string, value any) (uint64, error) {
if f.put == nil {
f.put = map[string]any{}
}
f.put[key] = value
return 1, nil
}
func (f *fakeFindings) Delete(key string) error { f.deleted = append(f.deleted, key); return nil }
func TestTheFindingWriterWritesAndClearsAModulesFindingInTheSeatsLayout(t *testing.T) {
state := &fakeFindings{}
var w views.FindingWriter = FindingWriter{State: state}
f := views.Finding{Observer: "mesh-config", Kind: "unanswered", ID: "mesh-config.mesh-vault.secrets", Summary: "no first view",
Headline: "waits", Explanation: "look", At: time.Date(2026, 11, 5, 12, 0, 0, 0, time.UTC)}
if err := w.Put(f); err != nil {
t.Fatal(err)
}
key := "module.mesh-config.mesh-vault.secrets.unanswered"
v, ok := state.put[key].(seat.FindingsByKeyValue)
if !ok || v.Key != key || v.Severity != Warning || v.Source != "mesh-config.unanswered" || v.Subject.Scope != ScopeModule || len(v.Evidence) != 1 {
t.Fatalf("written %#v", state.put)
}
if err := CheckFinding(v); err != nil {
t.Error(err)
}
if err := w.Delete(f); err != nil || len(state.deleted) != 1 || state.deleted[0] != key {
t.Errorf("deleted %v: %v", state.deleted, err)
}
if err := w.Put(views.Finding{Observer: "x", Kind: "k", ID: "i"}); err == nil {
t.Error("a finding that says nothing was written")
}
}
func when(s string) time.Time {
t, err := time.Parse(time.RFC3339Nano, s)
if err != nil {
panic(err)
}
return t
}
func ptr(t time.Time) *time.Time { return &t }
type fakeBucket struct {
kept map[string][]byte
puts, dels int
}
func (b *fakeBucket) Get(key string) ([]byte, bool, error) { v, ok := b.kept[key]; return v, ok, nil }
func (b *fakeBucket) Put(key string, v []byte) error {
if b.kept == nil {
b.kept = map[string][]byte{}
}
b.kept[key] = v
b.puts++
return nil
}
func (b *fakeBucket) Delete(key string) error { delete(b.kept, key); b.dels++; return nil }
// The goldens are what mesh-sdk's go/health EncodeFinding wrote for the same findings: the helpers write the same
// values, read by the seat's strict decoder.
func TestAnObservationIsWrittenAsMeshSDKsHealthWroteItsFinding(t *testing.T) {
at := time.Date(2026, 11, 5, 12, 0, 0, 0, time.UTC)
for _, c := range []struct {
golden string
o Observation
run string
}{
{"finding_full.json", Observation{Scope: ScopeMachine, ID: "g14", Kind: "silent-kind", Ref: "silent", Machine: "g14", Also: []string{"ace"},
Severity: Urgent, Summary: "g14 is silent", Said: "no pulse", Headline: "g14 is silent", Explanation: "It said nothing.",
Resolved: "It speaks.", Needs: "look", Source: "S1", Resolver: "operator"}, "r1"},
{"finding_minimal.json", Observation{Scope: ScopeModule, ID: "nats.g14", Kind: "build-failed", Summary: "failed",
Source: "builder.build-failed"}, ""},
} {
raw, err := os.ReadFile("testdata/" + c.golden)
if err != nil {
t.Fatal(err)
}
want, err := seat.DecodeFindingsByKeyValue(raw)
if err != nil {
t.Fatalf("%s: %v", c.golden, err)
}
observer := want.Observer
got, err := EncodeFinding(c.o.Finding(observer, c.run, at))
if err != nil {
t.Fatalf("%s: %v", c.golden, err)
}
have, err := seat.DecodeFindingsByKeyValue(got)
if err != nil || !reflect.DeepEqual(have, want) {
t.Errorf("%s: wrote %s, want %s (%v)", c.golden, got, raw, err)
}
if back, err := DecodeFinding(raw); err != nil || !reflect.DeepEqual(back, want) {
t.Errorf("%s: read back %v", c.golden, err)
}
}
}
func TestRaiseWritesWhenTheWordsChangeAndClearDeletesWhatIsThere(t *testing.T) {
b := &fakeBucket{}
o := Observation{Scope: ScopeModule, ID: "m", Kind: "k", Summary: "one", Source: "x.k"}
at := when("2026-11-05T12:00:00Z")
if w, err := Raise(b, "obs", "", o, at); err != nil || !w {
t.Fatalf("first: %v %v", w, err)
}
if w, err := Raise(b, "obs", "r2", o, at.Add(time.Hour)); err != nil || w || b.puts != 1 {
t.Fatalf("the same words later: wrote %v, %d puts, %v", w, b.puts, err)
}
o.Summary = "two"
if w, err := Raise(b, "obs", "", o, at); err != nil || !w || b.puts != 2 {
t.Fatalf("changed: %v %d %v", w, b.puts, err)
}
if _, err := Raise(b, "obs", "", Observation{Scope: "moon", ID: "m", Kind: "k"}, at); err == nil || b.puts != 2 {
t.Errorf("a finding the keeper cannot use was written: %v", err)
}
if c, err := Clear(b, ScopeModule, "m", "k"); err != nil || !c || b.dels != 1 {
t.Fatalf("clear: %v %v %d", c, err, b.dels)
}
if c, err := Clear(b, ScopeModule, "m", "k"); err != nil || c || b.dels != 1 {
t.Errorf("clearing what is not there: %v %v %d", c, err, b.dels)
}
if FindingsBucket("obs") != "obs_findings" {
t.Error(FindingsBucket("obs"))
}
}
@@ -0,0 +1,88 @@
// Package findings is a part's own bucket `findings` as the state the seat mesh-conditions' client writes into
// (meshconditionsclient.FindingState), so a part gives mesh-sdk's views the FindingWriter of the seat's layout without
// each writing the same adapter over its bucket (novox/hq ADR 0564 §2, ADR 0635 §4).
package findings
import (
"context"
"encoding/json"
"errors"
"github.com/nats-io/nats.go/jetstream"
"git.novox.be/novox/mesh-contracts/health-and-repair/go/clients/meshconditionsclient"
"git.novox.be/novox/mesh-sdk/go/views"
)
// ErrUnbound says the bucket is not on the bus yet, so the finding is not written (the part says it in its log).
var ErrUnbound = errors.New("the findings state is not bound yet")
// State is the bucket the part's state `findings` is, given when it is bound (process.AwaitedBucket.KV): nil is not bound yet.
type State struct {
Ctx context.Context
KV func() jetstream.KeyValue
}
var _ meshconditionsclient.FindingState = State{}
// Put writes one value under its key as JSON.
func (s State) Put(key string, value any) (uint64, error) {
kv := s.KV()
if kv == nil {
return 0, ErrUnbound
}
data, err := json.Marshal(value)
if err != nil {
return 0, err
}
return kv.Put(s.Ctx, key, data)
}
// Delete clears one key.
func (s State) Delete(key string) error {
kv := s.KV()
if kv == nil {
return ErrUnbound
}
return kv.Delete(s.Ctx, key)
}
// Writer is the part's views.FindingWriter over its bucket; the function is what views.ProcessReader.Findings and the
// emitter's Findings take.
func Writer(ctx context.Context, kv func() jetstream.KeyValue) views.FindingWriter {
return meshconditionsclient.FindingWriter{State: State{Ctx: ctx, KV: kv}}
}
// Funcs is a findings state held as the two functions a part keeps over it (the assigner's findings, which also say
// its conditions): a value is put as JSON.
type Funcs struct {
PutBytes func(key string, value []byte) error
DeleteKey func(key string) error
}
var _ meshconditionsclient.FindingState = Funcs{}
// Put writes one value under its key as JSON; the state not bound (no function) is ErrUnbound.
func (f Funcs) Put(key string, value any) (uint64, error) {
if f.PutBytes == nil {
return 0, ErrUnbound
}
data, err := json.Marshal(value)
if err != nil {
return 0, err
}
return 0, f.PutBytes(key, data)
}
// Delete clears one key.
func (f Funcs) Delete(key string) error {
if f.DeleteKey == nil {
return ErrUnbound
}
return f.DeleteKey(key)
}
// WriterOver is the part's views.FindingWriter over its two functions.
func WriterOver(put func(string, []byte) error, del func(string) error) views.FindingWriter {
return meshconditionsclient.FindingWriter{State: Funcs{PutBytes: put, DeleteKey: del}}
}
@@ -0,0 +1,71 @@
package findings
import (
"context"
"encoding/json"
"errors"
"testing"
"time"
"github.com/nats-io/nats.go/jetstream"
"git.novox.be/novox/mesh-sdk/go/views"
)
// fakeKV is the two calls a findings state makes of its bucket.
type fakeKV struct {
jetstream.KeyValue
held map[string][]byte
}
func (f *fakeKV) Put(_ context.Context, key string, value []byte) (uint64, error) {
f.held[key] = value
return uint64(len(f.held)), nil
}
func (f *fakeKV) Delete(_ context.Context, key string, _ ...jetstream.KVDeleteOpt) error {
delete(f.held, key)
return nil
}
// A part's finding is written in the seat mesh-conditions' layout under the key the condition keeper reads, cleared by the
// same finding, and refused while the bucket is not bound (the part says that in its log).
func TestAFindingIsWrittenInTheSeatsLayoutAndClearedAndRefusedUnbound(t *testing.T) {
kv := &fakeKV{held: map[string][]byte{}}
var bound jetstream.KeyValue
w := Writer(context.Background(), func() jetstream.KeyValue { return bound })
f := views.Finding{Observer: "mesh-delivery-teller", Kind: views.UnansweredKind, ID: "mesh-delivery-teller.asks", Summary: "s", Headline: "h",
Explanation: "e", At: time.Date(2026, 11, 5, 15, 0, 0, 0, time.UTC)}
if err := w.Put(f); !errors.Is(err, ErrUnbound) {
t.Fatalf("an unbound bucket took the finding: %v", err)
}
bound = kv
if err := w.Put(f); err != nil {
t.Fatal(err)
}
var got struct{ Key, Kind, Observer, Source string }
for key, value := range kv.held {
if err := json.Unmarshal(value, &got); err != nil || got.Key != key || got.Kind != views.UnansweredKind ||
got.Observer != f.Observer || got.Source != f.Observer+"."+f.Kind {
t.Fatalf("%s holds %s (%v)", key, value, err)
}
}
if len(kv.held) != 1 {
t.Fatalf("the bucket holds %d findings, want 1", len(kv.held))
}
if err := w.Delete(f); err != nil || len(kv.held) != 0 {
t.Fatalf("the finding is not cleared: %v, %d left", err, len(kv.held))
}
// The same finding over two functions (a part whose findings also say its conditions).
held := map[string][]byte{}
o := WriterOver(func(k string, v []byte) error { held[k] = v; return nil }, func(k string) error { delete(held, k); return nil })
if err := o.Put(f); err != nil || len(held) != 1 {
t.Fatalf("over functions: %v, %d held", err, len(held))
}
if err := o.Delete(f); err != nil || len(held) != 0 {
t.Fatalf("over functions not cleared: %v, %d left", err, len(held))
}
if err := WriterOver(nil, nil).Put(f); !errors.Is(err, ErrUnbound) {
t.Fatalf("no functions took the finding: %v", err)
}
}
@@ -0,0 +1,16 @@
module git.novox.be/novox/mesh-contracts/health-and-repair/go/clients/meshconditionsclient
go 1.26.0
require git.novox.be/novox/mesh-contracts/health-and-repair/go/seats/meshconditions v0.0.0-00010101000000-000000000000
require git.novox.be/novox/mesh-sdk/go v0.0.0-00010101000000-000000000000
require (
github.com/klauspost/compress v1.20.1 // indirect
github.com/nats-io/nats.go v1.54.0
github.com/nats-io/nkeys v0.4.16 // indirect
github.com/nats-io/nuid v1.0.1 // indirect
golang.org/x/crypto v0.57.0 // indirect
golang.org/x/sys v0.48.0 // indirect
)
@@ -0,0 +1,24 @@
github.com/antithesishq/antithesis-sdk-go v0.7.0-default-no-op h1:Z/MZK75wC/NSrkgqeNIa7jexam9uWzhLmFTSCPI/kn0=
github.com/antithesishq/antithesis-sdk-go v0.7.0-default-no-op/go.mod h1:FQyySiasQQM8735Ddel3MRojmy4dA1IqCeyJ5jmPMbI=
github.com/google/go-tpm v0.9.8 h1:slArAR9Ft+1ybZu0lBwpSmpwhRXaa85hWtMinMyRAWo=
github.com/google/go-tpm v0.9.8/go.mod h1:h9jEsEECg7gtLis0upRBQU+GhYVH6jMjrFxI8u6bVUY=
github.com/klauspost/compress v1.20.1 h1:T7kKElXUMXrUJ2E9QhQhxFtcK5rPyLdsGZvdbLMPdiQ=
github.com/klauspost/compress v1.20.1/go.mod h1:LUdAzn7YLVvxLpc7y3V1m40wESHTgc1422pwwBSKYuI=
github.com/minio/highwayhash v1.0.4 h1:asJizugGgchQod2ja9NJlGOWq4s7KsAWr5XUc9Clgl4=
github.com/minio/highwayhash v1.0.4/go.mod h1:GGYsuwP/fPD6Y9hMiXuapVvlIUEhFhMTh0rxU3ik1LQ=
github.com/nats-io/jwt/v2 v2.8.1 h1:V0xpGuD/N8Mi+fQNDynXohVvp7ZztevW5io8CUWlPmU=
github.com/nats-io/jwt/v2 v2.8.1/go.mod h1:nWnOEEiVMiKHQpnAy4eXlizVEtSfzacZ1Q43LIRavZg=
github.com/nats-io/nats-server/v2 v2.11.17 h1:GKEghcFK6A+aFx11Yf1LjgLC3txAwvyhnYzhBIQZA8I=
github.com/nats-io/nats-server/v2 v2.11.17/go.mod h1:B1sFVz4StNosQ903ak4N1G01Fl/9f8e06mXpFIE2K24=
github.com/nats-io/nats.go v1.54.0 h1:vsXoOxjHp/GmPUN+EcI7uOf/uB+iAP+kEsAFNQN0yzA=
github.com/nats-io/nats.go v1.54.0/go.mod h1:y+DZoD1oBOYfZTU681eTUiUjI0vbqYGixNVFHcjHJ0k=
github.com/nats-io/nkeys v0.4.16 h1:rd5oAuLOb8mnAycB0xleuEBNS1pVVnN0fv/FF34Eypg=
github.com/nats-io/nkeys v0.4.16/go.mod h1:llLgWoI0o4z/Q57q2R1kHfmocyhGV6VG/U18Glg1Afs=
github.com/nats-io/nuid v1.0.1 h1:5iA8DT8V7q8WK2EScv2padNa/rTESc1KdnPw4TC2paw=
github.com/nats-io/nuid v1.0.1/go.mod h1:19wcPz3Ph3q0Jbyiqsd0kePYG7A95tJPxeL+1OSON2c=
golang.org/x/crypto v0.57.0 h1:3ZVCjf8Ggz7zneR/EHRVx68Ctf+2pmIMP2UFhh9cC6M=
golang.org/x/crypto v0.57.0/go.mod h1:Fdz0i5U6CoizGwLda9DttjSk6qlZo25zYNtR+ycvuZA=
golang.org/x/sys v0.48.0 h1:bbX/i/6MgT9BVLM9RT1thmxL04yeTAhbEz4SyadbXoo=
golang.org/x/sys v0.48.0/go.mod h1:hNLxWAXmnKAxqDtdwIYC4bM9oQPEecfsnNMuSxOs3og=
golang.org/x/time v0.15.0 h1:bbrp8t3bGUeFOx08pvsMYRTCVSMk89u4tKbNOZbp88U=
golang.org/x/time v0.15.0/go.mod h1:Y4YMaQmXwGQZoFaVFk4YpCt4FLQMYKZe9oeV/f4MSno=
@@ -0,0 +1,115 @@
// Package heard is the finding a part writes while one of its consumers or watches does not hear the
// family it follows (novox/hq ADR 0615 §5; review round 1 of the registry milestone): the consumer not there yet, a
// hearing that could not start, a watch that cannot be opened. Without it the part's health stays healthy and its
// status says answered while the events it waits on are lost to it. The finding names the observer and the family,
// says what is not heard and why, and is deleted when the consumer binds: at the part's next start, or where the part
// binds again at a change of its membership, then; the condition keeper raises and clears the condition from it (ADR 0564 §2).
package heard
import (
"encoding/json"
"fmt"
"strings"
"time"
"git.novox.be/novox/mesh-contracts/health-and-repair/go/clients/meshconditionsclient"
"git.novox.be/novox/mesh-sdk/go/views"
)
// Kind is the finding's kind.
const Kind = "family-not-heard"
// ID is the finding's subject: `<observer>.<family>`, the family by the name the part follows it under (a stream, an
// event, a state's watch).
func ID(observer, family string) string { return observer + "." + family }
// Key is the finding's key in the observer's findings state.
func Key(observer, family string) string {
return meshconditionsclient.Key(meshconditionsclient.ScopeModule, ID(observer, family), Kind)
}
// Words are the finding's words: its summary, headline and explanation. consequence says what the observer misses while
// it does not hear family ("the placement is the first view's alone").
func Words(observer, family, consequence string, why error) (summary, headline, explanation string) {
reason := "it gave no reason"
if why != nil {
reason = strings.TrimSpace(why.Error())
}
summary = fmt.Sprintf("%s does not hear %s: %s (%s)", observer, family, consequence, reason)
headline = fmt.Sprintf("%s does not hear %s", observer, family)
explanation = fmt.Sprintf("%s follows %s on a consumer of its own, and that consumer is not there or could not be heard, so %s. "+
"This clears when the consumer binds: at %s's next start, or sooner where %s binds again at a change of its membership. "+
"Look at whether the grants part made %s's consumers, then start %s again if it does not bind by itself.", observer, family, consequence, observer, observer, observer, observer)
return summary, headline, explanation
}
// Finding is observer's finding that it does not hear family, in the layout of the seat mesh-conditions' state findings.
func Finding(observer, family, consequence string, why error, at time.Time) (string, []byte, error) {
summary, headline, explanation := Words(observer, family, consequence, why)
v := meshconditionsclient.FindingFor(views.Finding{Observer: observer, Kind: Kind, ID: ID(observer, family), Summary: summary,
Headline: headline, Explanation: explanation, At: at})
if err := meshconditionsclient.CheckFinding(v); err != nil {
return v.Key, nil, err
}
value, err := json.Marshal(v)
return v.Key, value, err
}
// Writer writes and deletes the finding in the observer's findings state. Put or Delete nil writes nothing, said in the
// log, so the part still says it.
type Writer struct {
Observer string
Put func(key string, value []byte) error
Delete func(key string) error
Logf func(string, ...any)
Now func() time.Time
}
// Bind binds the observer's consumer of family (bind makes it and starts hearing it): the finding is deleted when it
// binds, and written, with why, when it does not. It answers bind's error, so a caller that cannot go on without the
// family still stops.
func (w Writer) Bind(family, consequence string, bind func() error) error {
if err := bind(); err != nil {
w.NotHeard(family, consequence, err)
return err
}
w.Heard(family)
return nil
}
// NotHeard says in the log and in the finding that the observer does not hear family.
func (w Writer) NotHeard(family, consequence string, why error) {
now := time.Now
if w.Now != nil {
now = w.Now
}
summary, _, _ := Words(w.Observer, family, consequence, why)
w.logf("%s", summary)
if w.Put == nil {
w.logf("the finding %s is not written: no findings state", Key(w.Observer, family))
return
}
key, value, err := Finding(w.Observer, family, consequence, why, now())
if err == nil {
err = w.Put(key, value)
}
if err != nil {
w.logf("the finding %s is not written: %v", key, err)
}
}
// Heard deletes the finding: the consumer of family is bound.
func (w Writer) Heard(family string) {
if w.Delete == nil {
return
}
if err := w.Delete(Key(w.Observer, family)); err != nil {
w.logf("the finding %s is not deleted: %v", Key(w.Observer, family), err)
}
}
func (w Writer) logf(format string, args ...any) {
if w.Logf != nil {
w.Logf(format, args...)
}
}
@@ -0,0 +1,36 @@
package heard
import (
"errors"
"strings"
"testing"
"time"
)
// A consumer that does not bind writes the finding, with why, under the observer's findings key; the one that binds
// deletes it; and a state that is not bound is said in the log, not written.
func TestBindWritesTheFindingUntilTheConsumerBinds(t *testing.T) {
puts := map[string][]byte{}
var logs []string
w := Writer{Observer: "mesh-x", Put: func(k string, v []byte) error { puts[k] = v; return nil },
Delete: func(k string) error { delete(puts, k); return nil }, Logf: func(f string, a ...any) { logs = append(logs, f) },
Now: func() time.Time { return time.Date(2026, 11, 5, 12, 0, 0, 0, time.UTC) }}
key := Key("mesh-x", "placement")
if want := "mesh-x.placement"; ID("mesh-x", "placement") != want || !strings.Contains(key, want) || !strings.Contains(key, Kind) {
t.Fatalf("the finding's key %q", key)
}
if err := w.Bind("placement", "the first view is the only picture", func() error { return errors.New("no consumer") }); err == nil {
t.Fatal("a failed bind was not answered")
}
if v := string(puts[key]); !strings.Contains(v, "no consumer") || !strings.Contains(v, "mesh-x does not hear placement") {
t.Errorf("the finding is %q", v)
}
if err := w.Bind("placement", "", func() error { return nil }); err != nil || len(puts) != 0 {
t.Errorf("a bound consumer left %v (%v)", puts, err)
}
w.Put = nil
w.NotHeard("placement", "x", nil)
if len(logs) < 2 || !strings.Contains(logs[len(logs)-1], "not written") {
t.Errorf("an unbound state was not said: %v", logs)
}
}
@@ -0,0 +1,147 @@
package meshconditionsclient
import (
"encoding/json"
"fmt"
"time"
seat "git.novox.be/novox/mesh-contracts/health-and-repair/go/seats/meshconditions"
)
// What an observer says it sees wrong, raised and cleared as a finding in its own state `findings` (novox/hq ADR 0564
// clause 2, ADR 0634 §4): one write shared by every observer (the self-check, the healers, the watchman, the builder,
// the indexer, the node-engine), in this seat's layout. The condition keeper raises and clears the conditions from them.
// FindingsState is the name of an observer's state of findings.
const FindingsState = "findings"
// FindingsBucket is the bucket of an observer's findings, `<module>_findings`.
func FindingsBucket(observer string) string { return observer + "_" + FindingsState }
// Observation is one thing an observer sees wrong now: what it is about (Scope, ID, Machine, Also), which kind of fault
// (Kind; Ref when the key's last part is not the kind), and its words.
type Observation struct {
Scope, ID, Kind, Ref, Machine string
Also []string
// Severity is Urgent or Warning; Warning when empty.
Severity string
// Summary is one line; Said the one observation kept as evidence (the summary when empty).
Summary, Said string
Headline, Explanation, Resolved, Needs string
// Source is the row, probe or event that saw it.
Source string
// Resolver is who resolves it, self when empty.
Resolver string
Actions []seat.Action
// Since is when the finding is stamped; the zero value leaves it to the caller's moment.
Since time.Time
}
// Key is the finding's key: `<scope>.<id>.<token>`, the token Ref or else Kind.
func (o Observation) Key() string {
token := o.Ref
if token == "" {
token = o.Kind
}
return Key(o.Scope, o.ID, token)
}
// Finding is the observation as the seat's finding written by observer in run, at the moment at.
func (o Observation) Finding(observer, run string, at time.Time) seat.FindingsByKeyValue {
at = at.UTC()
said := o.Said
if said == "" {
said = o.Summary
}
sev := o.Severity
if sev == "" {
sev = Warning
}
f := seat.FindingsByKeyValue{V: FindingVersion, Key: o.Key(), Kind: o.Kind,
Subject: seat.Subject{Scope: o.Scope, ID: o.ID, Machine: o.Machine, Also: o.Also},
Severity: sev, Summary: o.Summary, Headline: o.Headline, Explanation: o.Explanation, Resolved: o.Resolved,
Needs: o.Needs, Actions: o.Actions, Evidence: []seat.Evidence{{At: at, Said: said}}, Source: o.Source,
Resolver: o.Resolver, Observer: observer, Run: run, At: at}
if o.Ref != "" && o.Ref != o.Kind {
f.Ref = o.Ref
}
return f
}
// EncodeFinding is f as JSON at FindingVersion; it refuses what CheckFinding refuses.
func EncodeFinding(f seat.FindingsByKeyValue) ([]byte, error) {
f.V = FindingVersion
if f.Evidence == nil {
f.Evidence = []seat.Evidence{}
}
if err := CheckFinding(f); err != nil {
return nil, err
}
return json.Marshal(f)
}
// DecodeFinding reads a finding's value; it refuses another version and what CheckFinding refuses. A finding written
// before the field was named `ref` (novox/hq issue 1952) is read by its old name, `token`.
func DecodeFinding(data []byte) (seat.FindingsByKeyValue, error) {
f, err := seat.DecodeFindingsByKeyValue(data)
if err != nil {
return seat.FindingsByKeyValue{}, fmt.Errorf("health: a finding is not readable: %w", err)
}
if f.V != FindingVersion {
return seat.FindingsByKeyValue{}, fmt.Errorf("health: a finding of version %d, and this reads %d", f.V, FindingVersion)
}
if err := CheckFinding(f); err != nil {
return seat.FindingsByKeyValue{}, err
}
return f, nil
}
// Says is what a finding says, the moments and the run that wrote it aside: two findings that say the same are one
// write (novox/hq ADR 0289 §1), so the condition moves only when the words do.
func Says(f seat.FindingsByKeyValue) string {
f.At, f.Run, f.V = time.Time{}, "", 0
ev := make([]seat.Evidence, len(f.Evidence))
for i, e := range f.Evidence {
ev[i] = seat.Evidence{Said: e.Said}
}
f.Evidence = ev
raw, _ := json.Marshal(f)
return string(raw)
}
// FindingBucket is the part of an observer's state `findings` that Raise and Clear use.
type FindingBucket interface {
// Get answers the value at key; found is false when there is none.
Get(key string) (value []byte, found bool, err error)
Put(key string, value []byte) error
Delete(key string) error
}
// Raise writes the observation as observer's finding in run at at, only when what it says differs from the finding kept
// (so the keeper's condition moves only then); wrote says whether it wrote. A finding the keeper could not make a
// condition of is refused, nothing written.
func Raise(b FindingBucket, observer, run string, o Observation, at time.Time) (wrote bool, err error) {
f := o.Finding(observer, run, at)
value, err := EncodeFinding(f)
if err != nil {
return false, err
}
if kept, found, err := b.Get(f.Key); err != nil {
return false, err
} else if found {
if old, err := DecodeFinding(kept); err == nil && Says(old) == Says(f) {
return false, nil
}
}
return true, b.Put(f.Key, value)
}
// Clear deletes the finding about scope and id of kind (its token); one not there is nothing to clear, and cleared says
// whether one was.
func Clear(b FindingBucket, scope, id, token string) (cleared bool, err error) {
key := Key(scope, id, token)
if _, found, err := b.Get(key); err != nil || !found {
return false, err
}
return true, b.Delete(key)
}
@@ -0,0 +1 @@
{"v":1,"key":"machine.g14.silent","kind":"silent-kind","ref":"silent","subject":{"scope":"machine","id":"g14","machine":"g14","also":["ace"]},"severity":"urgent","summary":"g14 is silent","headline":"g14 is silent","explanation":"It said nothing.","resolved":"It speaks.","needs":"look","evidence":[{"at":"2026-11-05T12:00:00Z","said":"no pulse"}],"source":"S1","resolver":"operator","observer":"mesh-delivery-watchman","run":"r1","at":"2026-11-05T12:00:00Z"}
@@ -0,0 +1 @@
{"v":1,"key":"module.nats.g14.build-failed","kind":"build-failed","subject":{"scope":"module","id":"nats.g14"},"severity":"warning","summary":"failed","headline":"","explanation":"","resolved":"","evidence":[{"at":"2026-11-05T12:00:00Z","said":"failed"}],"source":"builder.build-failed","observer":"mesh-delivery-builder","at":"2026-11-05T12:00:00Z"}
@@ -0,0 +1,98 @@
// Package meshhandactsclient is the logic every module that records an act by hand shares over the seat mesh-hand-acts
// (novox/hq ADR 0565 clauses 10 and 11, ADR 0634 §4): the recorder, which asks the seat alone, and what makes an act a
// repair. The act's layout is the seat's contract's.
package meshhandactsclient
import (
"encoding/json"
"errors"
"fmt"
"strings"
seat "git.novox.be/novox/mesh-contracts/health-and-repair/go/seats/meshhandacts"
"git.novox.be/novox/mesh-sdk/go/views"
)
// The verbs an act is recorded under in the log, and the causes and kinds that are no repair.
const (
VerbRecord = "hand-act record"
VerbDrill = "hand-act drill"
VerbWarrant = "warrant"
// CauseDrill is a drill's cause, never counted as a repair; CauseOperatorAnswer an act the operator chose.
CauseDrill = "drill"
CauseOperatorAnswer = "operator-answer"
// KindRecordedBuilds is a push that only moved recorded builds: no repair (ADR 0242).
KindRecordedBuilds = "recorded-builds"
)
// Repair says the act counts toward a cause repeated: not a drill, not an operator's answer, not recorded builds.
func Repair(a seat.Act) bool {
return a.Cause != CauseDrill && a.Verb != VerbDrill && a.Cause != CauseOperatorAnswer && a.Kind != KindRecordedBuilds
}
// Request is one act a recorder records: what, why, its cause, the condition it addresses, and its verb in the log (a
// repair when empty).
type Request struct {
What string `json:"what"`
Why string `json:"why"`
Cause string `json:"cause,omitempty"`
Condition string `json:"condition,omitempty"`
Verb string `json:"verb,omitempty"`
}
// Taken is which log took an act, and what its holder answered.
type Taken struct {
Log string `json:"log"`
Answer json.RawMessage `json:"answer,omitempty"`
}
// Recorder records acts for a module through the seat mesh-hand-acts alone. An act the seat does not take is an error
// that says nothing was recorded, never a quiet success.
type Recorder struct {
// Ask calls a seat verb by its address (`seat:<seat>.<verb>`): the runtime's ask.
Ask func(key string, body any) (json.RawMessage, error)
}
// NoResponder says err is the bus finding nobody to answer: the seat has no holder now.
func NoResponder(err error) bool {
return err != nil && strings.Contains(err.Error(), "no responders")
}
// Record records one act.
func (r Recorder) Record(q Request) (Taken, error) {
if strings.TrimSpace(q.What) == "" || strings.TrimSpace(q.Why) == "" {
return Taken{}, errors.New("a hand act says what was done and why: nothing was recorded")
}
body := map[string]any{"what": q.What, "why": q.Why}
for k, v := range map[string]string{"cause": q.Cause, "condition": q.Condition, "verb": q.Verb} {
if v != "" {
body[k] = v
}
}
return r.record(seat.VerbHandAct, body)
}
// RecordHandAct records a part's act before it does it, as mesh-sdk's views.HandActs asks: the recorder is the
// interface's implementation, and an act the seat does not take is the error that says nothing was recorded.
func (r Recorder) RecordHandAct(a views.HandAct) error {
_, err := r.Record(Request{What: a.What, Why: a.Why, Cause: a.Cause, Verb: a.Verb})
return err
}
var _ views.HandActs = Recorder{}
// RecordWarrant records what the asking module did on the operator's warrant for its ask.
func (r Recorder) RecordWarrant(asker, ask string) (Taken, error) {
return r.record(seat.VerbWarranted, map[string]any{"asker": asker, "ask": ask})
}
func (r Recorder) record(verb string, body any) (Taken, error) {
answer, err := r.Ask("seat:"+seat.Seat+"."+verb, body)
if err == nil {
return Taken{Log: seat.Seat, Answer: answer}, nil
}
if NoResponder(err) {
return Taken{}, fmt.Errorf("%s has no holder that answers: nothing was recorded (%w)", seat.Seat, err)
}
return Taken{}, fmt.Errorf("%s.%s refused, nothing was recorded: %w", seat.Seat, verb, err)
}
@@ -0,0 +1,93 @@
package meshhandactsclient
import (
"encoding/json"
"errors"
"fmt"
"reflect"
"strings"
"testing"
seat "git.novox.be/novox/mesh-contracts/health-and-repair/go/seats/meshhandacts"
"git.novox.be/novox/mesh-sdk/go/views"
)
// ADR 0565 clauses 10 and 11 (issue 1953 L5-S2, S4, L5b): an act reads the controller's entries unchanged, and a
// recorder records through the seat alone, the controller's verbs being gone, and says loudly when nothing was recorded.
func TestAnActIsRecordedInTheLogThatAnswers(t *testing.T) {
// The shape of mesh-controller internal/link HandAct, as its bucket keeps an entry (handacts.go at 0fbb5ef3).
raw := `{"id":"act-1","at":"2026-10-28T09:00:00Z","by":"person.jochen","verb":"hand-act record","why":"a stuck consumer",` +
`"cause":"consumer-lost","condition":"bus.EVENTS.x.consumer-lost","via":"telegram","ask":"a-1","proofs":["P1"],` +
`"requested-by":"bus.EVENTS.x.consumer-lost","outcome":"done"}`
var a seat.Act
if err := json.Unmarshal([]byte(raw), &a); err != nil {
t.Fatal(err)
}
again, _ := json.Marshal(a)
var want, got map[string]any
_ = json.Unmarshal([]byte(raw), &want)
_ = json.Unmarshal(again, &got)
if !reflect.DeepEqual(want, got) || !Repair(a) || Repair(seat.Act{Cause: CauseDrill}) || Repair(seat.Act{Verb: VerbDrill}) {
t.Errorf("the controller's entry reads back as %s", again)
}
noResponders := errors.New("nats: no responders available for request")
for _, c := range []struct {
name string
seat error
ctrl error
log string
refused string
asked string
warrants bool
}{
{name: "the seat answers", log: seat.Seat, asked: "seat:mesh-hand-acts.hand-act"},
{name: "no holder: nothing recorded, the controller never asked", seat: noResponders, refused: "nothing was recorded",
asked: "seat:mesh-hand-acts.hand-act"},
{name: "the seat refuses", seat: errors.New("why is required"), refused: "refused, nothing was recorded",
asked: "seat:mesh-hand-acts.hand-act"},
{name: "a warrant without a holder", seat: noResponders, warrants: true, refused: "nothing was recorded",
asked: "seat:mesh-hand-acts.warranted"},
{name: "a warrant recorded", warrants: true, log: seat.Seat, asked: "seat:mesh-hand-acts.warranted"},
} {
var asked []string
r := Recorder{Ask: func(key string, body any) (json.RawMessage, error) {
asked = append(asked, key)
if strings.HasPrefix(key, "seat:mesh-hand-acts.") {
return json.RawMessage(`{}`), c.seat
}
return json.RawMessage(`{}`), c.ctrl
}}
var taken Taken
var err error
if c.warrants {
taken, err = r.RecordWarrant("mesh-config", "ask-1")
} else {
taken, err = r.Record(Request{What: "silenced", Why: "known", Cause: "silent", Verb: "conditions silence"})
}
if (c.refused != "") != (err != nil) || (err != nil && !strings.Contains(err.Error(), c.refused)) || taken.Log != c.log ||
strings.Join(asked, ",") != c.asked {
t.Errorf("%s: took %q, %v, asked %v", c.name, taken.Log, err, asked)
}
}
if _, err := (Recorder{}).Record(Request{What: "x"}); err == nil {
t.Error("an act with no why was recorded")
}
}
func TestTheRecorderIsMeshSDKsHandActRecorder(t *testing.T) {
var asked []string
r := Recorder{Ask: func(key string, body any) (json.RawMessage, error) {
asked = append(asked, key+" "+fmt.Sprint(body))
return nil, nil
}}
var acts views.HandActs = r
if err := acts.RecordHandAct(views.HandAct{What: "asked a view again", Why: "it is stale", Cause: "stale"}); err != nil {
t.Fatal(err)
}
if len(asked) != 1 || !strings.HasPrefix(asked[0], "seat:mesh-hand-acts.hand-act ") || !strings.Contains(asked[0], "asked a view again") {
t.Errorf("asked %v", asked)
}
if err := acts.RecordHandAct(views.HandAct{What: "x"}); err == nil {
t.Error("an act with no why was taken")
}
}
@@ -0,0 +1,16 @@
module git.novox.be/novox/mesh-contracts/health-and-repair/go/clients/meshhandactsclient
go 1.26.0
require git.novox.be/novox/mesh-contracts/health-and-repair/go/seats/meshhandacts v0.0.0-00010101000000-000000000000
require git.novox.be/novox/mesh-sdk/go v0.0.0-00010101000000-000000000000
require (
github.com/klauspost/compress v1.20.1 // indirect
github.com/nats-io/nats.go v1.54.0 // indirect
github.com/nats-io/nkeys v0.4.16 // indirect
github.com/nats-io/nuid v1.0.1 // indirect
golang.org/x/crypto v0.57.0 // indirect
golang.org/x/sys v0.48.0 // indirect
)
@@ -0,0 +1,24 @@
github.com/antithesishq/antithesis-sdk-go v0.7.0-default-no-op h1:Z/MZK75wC/NSrkgqeNIa7jexam9uWzhLmFTSCPI/kn0=
github.com/antithesishq/antithesis-sdk-go v0.7.0-default-no-op/go.mod h1:FQyySiasQQM8735Ddel3MRojmy4dA1IqCeyJ5jmPMbI=
github.com/google/go-tpm v0.9.8 h1:slArAR9Ft+1ybZu0lBwpSmpwhRXaa85hWtMinMyRAWo=
github.com/google/go-tpm v0.9.8/go.mod h1:h9jEsEECg7gtLis0upRBQU+GhYVH6jMjrFxI8u6bVUY=
github.com/klauspost/compress v1.20.1 h1:T7kKElXUMXrUJ2E9QhQhxFtcK5rPyLdsGZvdbLMPdiQ=
github.com/klauspost/compress v1.20.1/go.mod h1:LUdAzn7YLVvxLpc7y3V1m40wESHTgc1422pwwBSKYuI=
github.com/minio/highwayhash v1.0.4 h1:asJizugGgchQod2ja9NJlGOWq4s7KsAWr5XUc9Clgl4=
github.com/minio/highwayhash v1.0.4/go.mod h1:GGYsuwP/fPD6Y9hMiXuapVvlIUEhFhMTh0rxU3ik1LQ=
github.com/nats-io/jwt/v2 v2.8.1 h1:V0xpGuD/N8Mi+fQNDynXohVvp7ZztevW5io8CUWlPmU=
github.com/nats-io/jwt/v2 v2.8.1/go.mod h1:nWnOEEiVMiKHQpnAy4eXlizVEtSfzacZ1Q43LIRavZg=
github.com/nats-io/nats-server/v2 v2.11.17 h1:GKEghcFK6A+aFx11Yf1LjgLC3txAwvyhnYzhBIQZA8I=
github.com/nats-io/nats-server/v2 v2.11.17/go.mod h1:B1sFVz4StNosQ903ak4N1G01Fl/9f8e06mXpFIE2K24=
github.com/nats-io/nats.go v1.54.0 h1:vsXoOxjHp/GmPUN+EcI7uOf/uB+iAP+kEsAFNQN0yzA=
github.com/nats-io/nats.go v1.54.0/go.mod h1:y+DZoD1oBOYfZTU681eTUiUjI0vbqYGixNVFHcjHJ0k=
github.com/nats-io/nkeys v0.4.16 h1:rd5oAuLOb8mnAycB0xleuEBNS1pVVnN0fv/FF34Eypg=
github.com/nats-io/nkeys v0.4.16/go.mod h1:llLgWoI0o4z/Q57q2R1kHfmocyhGV6VG/U18Glg1Afs=
github.com/nats-io/nuid v1.0.1 h1:5iA8DT8V7q8WK2EScv2padNa/rTESc1KdnPw4TC2paw=
github.com/nats-io/nuid v1.0.1/go.mod h1:19wcPz3Ph3q0Jbyiqsd0kePYG7A95tJPxeL+1OSON2c=
golang.org/x/crypto v0.57.0 h1:3ZVCjf8Ggz7zneR/EHRVx68Ctf+2pmIMP2UFhh9cC6M=
golang.org/x/crypto v0.57.0/go.mod h1:Fdz0i5U6CoizGwLda9DttjSk6qlZo25zYNtR+ycvuZA=
golang.org/x/sys v0.48.0 h1:bbX/i/6MgT9BVLM9RT1thmxL04yeTAhbEz4SyadbXoo=
golang.org/x/sys v0.48.0/go.mod h1:hNLxWAXmnKAxqDtdwIYC4bM9oQPEecfsnNMuSxOs3og=
golang.org/x/time v0.15.0 h1:bbrp8t3bGUeFOx08pvsMYRTCVSMk89u4tKbNOZbp88U=
golang.org/x/time v0.15.0/go.mod h1:Y4YMaQmXwGQZoFaVFk4YpCt4FLQMYKZe9oeV/f4MSno=
@@ -0,0 +1,8 @@
module git.novox.be/novox/mesh-contracts/health-and-repair/go/clients/meshwatchdogsclient
go 1.26.0
require (
git.novox.be/novox/mesh-contracts/health-and-repair/go/seats/meshwatchdogs v0.0.0-00010101000000-000000000000
git.novox.be/novox/mesh-sdk/go v0.0.0-00010101000000-000000000000
)
@@ -0,0 +1,205 @@
// Package meshwatchdogsclient is the client of the seat mesh-watchdogs: the keys, the check and the codec of the seat's
// three states presence, pulse and stuck, which every module that reads or writes them shares (the watchman that writes
// them, the assigner and the self-check that read them). The layouts are the seat's contract; this file writes the
// version and refuses what the earlier codec refused (novox/hq ADR 0433, ADR 0634 §4).
package meshwatchdogsclient
import (
"encoding/json"
"errors"
"fmt"
"strings"
"time"
seat "git.novox.be/novox/mesh-contracts/health-and-repair/go/seats/meshwatchdogs"
"git.novox.be/novox/mesh-sdk/go/keys"
)
// The three values of the seat's states, in the seat's types.
type (
// NodePresence is whether a node is online (state presence, key `<node>`).
NodePresence = seat.PresenceByNodeValue
// PartPresence is whether a part's instance is online (state presence, key `part.<part>.<instance>`).
PartPresence = seat.PresenceByPartPartInstanceValue
// Pulse is the watched key mirrored, its revision, and who owes the next move (state pulse).
Pulse = seat.PulseByBucketKeyValue
// Stuck is the key that stopped moving, what it waited for, since when, and whether it moved again (state stuck).
Stuck = seat.StuckByBucketKeyValue
)
// PartWord is the first part of a part's presence key; a node of that name is refused.
const PartWord = "part"
// PulseMoveBound is how long a move may take: a pulse key lives for this time minus the move's age.
const PulseMoveBound = 120 * time.Second
// PresenceHistory is the number of revisions the presence state keeps per key.
const PresenceHistory = 20
// PresenceNodeKey is the key of a node's presence: `<node>`. A node named `part` is refused.
func PresenceNodeKey(node string) (string, error) {
if err := keys.CheckName("node", node); err != nil {
return "", err
}
if node == PartWord {
return "", errors.New(`the node name "part" is refused: part.<part>.<instance> is a part's key`)
}
return seat.PresenceByNodeKey(node), nil
}
// PresencePartKey is the key of a part's instance: `part.<part>.<instance>`.
func PresencePartKey(part, instance string) (string, error) {
if err := keys.CheckName("part", part); err != nil {
return "", err
}
if err := keys.CheckName("instance", instance); err != nil {
return "", err
}
return seat.PresenceByPartPartInstanceKey(part, instance), nil
}
// ParsePresenceKey is the inverse of PresenceNodeKey and PresencePartKey: for a node's key part and instance are
// empty, for a part's key node is.
func ParsePresenceKey(key string) (node, part, instance string, err error) {
if !strings.Contains(key, ".") {
node, err = PresenceNodeKey(key)
if err != nil {
return "", "", "", fmt.Errorf("%q is not a presence key: %w", key, err)
}
return node, "", "", nil
}
fields := strings.Split(key, ".")
if len(fields) != 3 || fields[0] != PartWord {
return "", "", "", fmt.Errorf("%q is not a presence key, <node> or part.<part>.<instance>", key)
}
if _, err := PresencePartKey(fields[1], fields[2]); err != nil {
return "", "", "", fmt.Errorf("%q is not a presence key: %w", key, err)
}
return "", fields[1], fields[2], nil
}
// PulseKey is the key of the move watched at key of bucket: `<bucket>.<key>`. The bucket is the full bucket name and
// holds no dot; the key may.
func PulseKey(bucket, key string) (string, error) {
if err := keys.CheckName("bucket", bucket); err != nil {
return "", err
}
if key == "" || strings.HasPrefix(key, ".") || strings.HasSuffix(key, ".") || strings.Contains(key, "..") {
return "", fmt.Errorf("the pulse key %q is empty or has an empty part", key)
}
for _, part := range strings.Split(key, ".") {
if err := keys.CheckName("key", part); err != nil {
return "", err
}
}
return seat.PulseByBucketKeyKey(bucket, key), nil
}
// ParsePulseKey is the inverse of PulseKey, splitting at the first dot.
func ParsePulseKey(pulseKey string) (bucket, key string, err error) {
bucket, key, ok := seat.ParsePulseByBucketKeyKey(pulseKey)
if !ok {
return "", "", fmt.Errorf("%q is not a pulse key, <bucket>.<key>", pulseKey)
}
if _, err := PulseKey(bucket, key); err != nil {
return "", "", fmt.Errorf("%q is not a pulse key: %w", pulseKey, err)
}
return bucket, key, nil
}
// StuckKey is the key of the key that stopped moving: `<bucket>.<key>`, formed as PulseKey forms a pulse's.
func StuckKey(bucket, key string) (string, error) {
k, err := PulseKey(bucket, key)
if err != nil {
return "", fmt.Errorf("no stuck key: %w", err)
}
return k, nil
}
// ParseStuckKey is the inverse of StuckKey, splitting at the first dot.
func ParseStuckKey(stuckKey string) (bucket, key string, err error) {
bucket, key, err = ParsePulseKey(stuckKey)
if err != nil {
return "", "", fmt.Errorf("%q is not a stuck key: %w", stuckKey, err)
}
return bucket, key, nil
}
// PulseTTL is the time to live of a pulse key for a move of the given age: PulseMoveBound minus the age. At or past
// the bound the move is stalled, with no time left and nothing to write.
func PulseTTL(moveAge time.Duration) (ttl time.Duration, stalled bool) {
if moveAge >= PulseMoveBound {
return 0, true
}
return PulseMoveBound - moveAge, false
}
// EncodeNodePresence is p as JSON at the current version.
func EncodeNodePresence(p NodePresence) ([]byte, error) {
p.V = keys.Version
return json.Marshal(p)
}
// DecodeNodePresence reads a node's presence; it refuses an unknown version (keys.ErrUnknownVersion).
func DecodeNodePresence(data []byte) (NodePresence, error) {
if err := keys.CheckVersion(data); err != nil {
return NodePresence{}, err
}
return decodeStrict[NodePresence](data, "presence")
}
// EncodePartPresence is p as JSON at the current version.
func EncodePartPresence(p PartPresence) ([]byte, error) {
p.V = keys.Version
return json.Marshal(p)
}
// DecodePartPresence reads a part's presence; it refuses an unknown version (keys.ErrUnknownVersion).
func DecodePartPresence(data []byte) (PartPresence, error) {
if err := keys.CheckVersion(data); err != nil {
return PartPresence{}, err
}
return decodeStrict[PartPresence](data, "presence")
}
// EncodePulse is p as JSON at the current version.
func EncodePulse(p Pulse) ([]byte, error) {
p.V = keys.Version
return json.Marshal(p)
}
// DecodePulse reads a pulse; it refuses an unknown version (keys.ErrUnknownVersion).
func DecodePulse(data []byte) (Pulse, error) {
if err := keys.CheckVersion(data); err != nil {
return Pulse{}, err
}
return decodeStrict[Pulse](data, "pulse")
}
// EncodeStuck is s as JSON at the current version.
func EncodeStuck(s Stuck) ([]byte, error) {
s.V = keys.Version
return json.Marshal(s)
}
// DecodeStuck reads a stuck value; it refuses an unknown version (keys.ErrUnknownVersion).
func DecodeStuck(data []byte) (Stuck, error) {
if err := keys.CheckVersion(data); err != nil {
return Stuck{}, err
}
return decodeStrict[Stuck](data, "stuck")
}
func decodeStrict[T any](data []byte, what string) (T, error) {
var v T
if err := json.Unmarshal(data, &v); err != nil {
return v, fmt.Errorf("the %s value does not fit its shape: %w", what, err)
}
return v, nil
}
// SameStuck says whether two stuck values are the same, so a writer writes only when they differ.
func SameStuck(a, b Stuck) bool {
return a.V == b.V && a.Bucket == b.Bucket && a.Key == b.Key && a.Waited == b.Waited && a.Since.Equal(b.Since) &&
a.Moved == b.Moved
}
@@ -0,0 +1,140 @@
package meshwatchdogsclient
import (
"encoding/json"
"errors"
"reflect"
"testing"
"time"
"git.novox.be/novox/mesh-sdk/go/keys"
)
func sameJSON(t *testing.T, got []byte, want string) {
t.Helper()
var g, w any
if err := json.Unmarshal(got, &g); err != nil {
t.Fatal(err)
}
if err := json.Unmarshal([]byte(want), &w); err != nil {
t.Fatal(err)
}
if !reflect.DeepEqual(g, w) {
t.Fatalf("wrote %s, the earlier codec wrote %s", got, want)
}
}
// TestTheValuesAreWhatTheEarlierCodecWrote holds the values equal to what go/delivery's codec wrote for the same values
// (the golden texts are its output; the field order is the contract's now, so values are compared decoded), reads them
// back, and refuses an unknown version.
func TestTheValuesAreWhatTheEarlierCodecWrote(t *testing.T) {
since := time.Date(2026, 10, 25, 11, 52, 58, 51397201, time.UTC)
for name, row := range map[string]struct {
encode func() ([]byte, error)
decode func([]byte) (any, error)
golden string
want any
}{
"node online": {
func() ([]byte, error) { return EncodeNodePresence(NodePresence{Online: true}) },
func(b []byte) (any, error) { return DecodeNodePresence(b) },
`{"v":1,"online":true}`, NodePresence{V: 1, Online: true},
},
"part offline": {
func() ([]byte, error) { return EncodePartPresence(PartPresence{}) },
func(b []byte) (any, error) { return DecodePartPresence(b) },
`{"v":1,"online":false}`, PartPresence{V: 1},
},
"pulse": {
func() ([]byte, error) {
return EncodePulse(Pulse{Bucket: "mesh-delivery-assigner_wants", Key: "want.a.m", Revision: 7, Owes: "a"})
},
func(b []byte) (any, error) { return DecodePulse(b) },
`{"v":1,"bucket":"mesh-delivery-assigner_wants","key":"want.a.m","revision":7,"owes":"a"}`,
Pulse{V: 1, Bucket: "mesh-delivery-assigner_wants", Key: "want.a.m", Revision: 7, Owes: "a"},
},
"stuck": {
func() ([]byte, error) {
return EncodeStuck(Stuck{Bucket: "mesh-delivery-watchman_presence", Key: "part.b.n", Waited: "b on n to come back", Since: since, Moved: true})
},
func(b []byte) (any, error) { return DecodeStuck(b) },
`{"v":1,"bucket":"mesh-delivery-watchman_presence","key":"part.b.n","waited":"b on n to come back","since":"2026-10-25T11:52:58.051397201Z","moved":true}`,
Stuck{V: 1, Bucket: "mesh-delivery-watchman_presence", Key: "part.b.n", Waited: "b on n to come back", Since: since, Moved: true},
},
} {
t.Run(name, func(t *testing.T) {
raw, err := row.encode()
if err != nil {
t.Fatal(err)
}
sameJSON(t, raw, row.golden)
back, err := row.decode([]byte(row.golden))
if err != nil || !reflect.DeepEqual(back, row.want) {
t.Fatalf("read %+v, %v; want %+v", back, err, row.want)
}
if _, err := row.decode([]byte(`{"v":2}`)); !errors.Is(err, keys.ErrUnknownVersion) {
t.Fatalf("version 2: %v", err)
}
if _, err := row.decode([]byte(`{}`)); !errors.Is(err, keys.ErrUnknownVersion) {
t.Fatalf("no version: %v", err)
}
})
}
if !SameStuck(Stuck{V: 1, Since: since}, Stuck{V: 1, Since: since.In(time.FixedZone("x", 3600))}) || SameStuck(Stuck{Moved: true}, Stuck{}) {
t.Fatal("SameStuck")
}
}
// TestTheKeysAreWhatTheEarlierCodecFormed holds the key forms and their refusals.
func TestTheKeysAreWhatTheEarlierCodecFormed(t *testing.T) {
if k, err := PresenceNodeKey("ace"); err != nil || k != "ace" {
t.Fatal(k, err)
}
if k, err := PresencePartKey("mesh-delivery-builder", "novox"); err != nil || k != "part.mesh-delivery-builder.novox" {
t.Fatal(k, err)
}
for _, bad := range []string{"part", "", "a.b"} {
if _, err := PresenceNodeKey(bad); err == nil {
t.Fatalf("node %q accepted", bad)
}
}
for key, want := range map[string][3]string{"ace": {"ace", "", ""}, "part.p.i": {"", "p", "i"}} {
n, p, i, err := ParsePresenceKey(key)
if err != nil || [3]string{n, p, i} != want {
t.Fatal(key, n, p, i, err)
}
}
for _, bad := range []string{"part.p", "x.p.i", "part..i", "a.b.c.d", "part"} {
if _, _, _, err := ParsePresenceKey(bad); err == nil {
t.Fatalf("presence key %q accepted", bad)
}
}
k, err := PulseKey("b_w", "want.a.m")
if err != nil || k != "b_w.want.a.m" {
t.Fatal(k, err)
}
if s, err := StuckKey("b_w", "want.a.m"); err != nil || s != k {
t.Fatal(s, err)
}
for _, f := range []func(string) (string, string, error){ParsePulseKey, ParseStuckKey} {
if b, key, err := f(k); err != nil || b != "b_w" || key != "want.a.m" {
t.Fatal(b, key, err)
}
for _, bad := range []string{"nodot", ".k", "b.", "b..k", "b.k."} {
if _, _, err := f(bad); err == nil {
t.Fatalf("pulse key %q accepted", bad)
}
}
}
for _, bad := range [][2]string{{"", "k"}, {"b.c", "k"}, {"b", ""}, {"b", "k..j"}, {"b", "k j"}} {
if _, err := PulseKey(bad[0], bad[1]); err == nil {
t.Fatalf("pulse key %q accepted", bad)
}
}
if ttl, stalled := PulseTTL(30 * time.Second); stalled || ttl != 90*time.Second {
t.Fatal(ttl, stalled)
}
if ttl, stalled := PulseTTL(PulseMoveBound); !stalled || ttl != 0 {
t.Fatal(ttl, stalled)
}
}
@@ -0,0 +1,385 @@
// Code generated by seatgen from the seat definitions; DO NOT EDIT.
// Package meshconditions is the contract of the seat "mesh-conditions", defined whole (seats/mesh-conditions/seat.json): What is wrong with the mesh now (ADR 0564): the open conditions raised from the observers' findings and cleared when a finding goes.
package meshconditions
import (
"bytes"
"encoding/json"
"errors"
"time"
)
// Seat is the seat's name.
const Seat = "mesh-conditions"
const (
VerbConditions = "conditions"
VerbSilence = "silence"
VerbResync = "resync"
)
const (
EventConditionRaised = "condition-raised"
EventConditionChanged = "condition-changed"
EventConditionCleared = "condition-cleared"
)
// Verbs is every verb the seat serves, in its definition's order: a new copy each call.
func Verbs() []string {
return []string{VerbConditions, VerbSilence, VerbResync}
}
// Events is every event published on the seat's subject: a new copy each call.
func Events() []string {
return []string{EventConditionRaised, EventConditionChanged, EventConditionCleared}
}
// Family is where the seat's events are kept: the shared events stream, filtered to the seat's subjects.
const (
Family = "mesh-conditions.events"
FamilyMaxAgeDays = 7
)
// SubjectConditionRaised is where condition-raised is published.
const SubjectConditionRaised = "mesh.seat.mesh-conditions.event.condition-raised"
// SubjectConditionChanged is where condition-changed is published.
const SubjectConditionChanged = "mesh.seat.mesh-conditions.event.condition-changed"
// SubjectConditionCleared is where condition-cleared is published.
const SubjectConditionCleared = "mesh.seat.mesh-conditions.event.condition-cleared"
const (
StateConditions = "conditions"
StateFindings = "findings"
)
// ConditionsByKeyKey is the key of one value of the state conditions under the form "<key>".
func ConditionsByKeyKey(key string) string { return key }
// ParseConditionsByKeyKey reads the parts of a key of the form "<key>", in the order of the form; the last result is false for a key of another form.
func ParseConditionsByKeyKey(key string) (string, bool) {
rest := key
if rest == "" {
return "", false
}
v0 := rest
rest = ""
if rest != "" {
return "", false
}
return v0, true
}
// DecodeConditionsByKeyValue reads one value of the state conditions under the form "<key>", refusing a field ConditionsByKeyValue does not know and any text after the value.
func DecodeConditionsByKeyValue(raw []byte) (ConditionsByKeyValue, error) {
var v ConditionsByKeyValue
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
}
// FindingsByKeyKey is the key of one value of the state findings under the form "<key>".
func FindingsByKeyKey(key string) string { return key }
// ParseFindingsByKeyKey reads the parts of a key of the form "<key>", in the order of the form; the last result is false for a key of another form.
func ParseFindingsByKeyKey(key string) (string, bool) {
rest := key
if rest == "" {
return "", false
}
v0 := rest
rest = ""
if rest != "" {
return "", false
}
return v0, true
}
// DecodeFindingsByKeyValue reads one value of the state findings under the form "<key>", refusing a field FindingsByKeyValue does not know and any text after the value.
func DecodeFindingsByKeyValue(raw []byte) (FindingsByKeyValue, error) {
var v FindingsByKeyValue
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
}
// Action is one answer the operator may give from a notification.
type Action struct {
Arguments map[string]string `json:"arguments,omitempty"`
Label string `json:"label"`
Level string `json:"level,omitempty"`
Machine string `json:"machine,omitempty"`
// the seat verb the operator channel calls
Verb string `json:"verb"`
}
// The values of Action's level.
const (
ActionLevelAcknowledge = "acknowledge"
ActionLevelApprove = "approve"
)
// ConditionEventGapsItem is a type of the contract.
type ConditionEventGapsItem struct {
Cleared *time.Time `json:"cleared,omitempty"`
Reopened *time.Time `json:"reopened,omitempty"`
}
// ConditionEventSilenced is a type of the contract.
type ConditionEventSilenced struct {
By string `json:"by,omitempty"`
Since *time.Time `json:"since,omitempty"`
Until *time.Time `json:"until,omitempty"`
Why string `json:"why,omitempty"`
}
// ConditionEventTriedItem is a type of the contract.
type ConditionEventTriedItem struct {
At *time.Time `json:"at,omitempty"`
By string `json:"by,omitempty"`
Outcome string `json:"outcome,omitempty"`
What string `json:"what,omitempty"`
}
// ConditionEvent is a condition and what changed.
type ConditionEvent struct {
Actions []Action `json:"actions,omitempty"`
At time.Time `json:"at"`
Change string `json:"change"`
Cleared *time.Time `json:"cleared,omitempty"`
Count int64 `json:"count,omitempty"`
Epoch int64 `json:"epoch,omitempty"`
Event string `json:"event"`
// newest first, at most 10
Evidence []Evidence `json:"evidence,omitempty"`
Explanation string `json:"explanation,omitempty"`
First *time.Time `json:"first,omitempty"`
Gaps []ConditionEventGapsItem `json:"gaps,omitempty"`
Headline string `json:"headline,omitempty"`
// scope.id.token
Key string `json:"key"`
Kind string `json:"kind,omitempty"`
LastObserved *time.Time `json:"last-observed,omitempty"`
LooksUnread string `json:"looks-unread,omitempty"`
Needs string `json:"needs,omitempty"`
Observations int64 `json:"observations,omitempty"`
Raised *time.Time `json:"raised,omitempty"`
Resolved string `json:"resolved,omitempty"`
// self, operator, agent or healer:<name>
Resolver string `json:"resolver,omitempty"`
Severity string `json:"severity,omitempty"`
// the tool call that shows it
Show string `json:"show,omitempty"`
Silenced *ConditionEventSilenced `json:"silenced,omitempty"`
// the row, probe or event that saw it
Source string `json:"source,omitempty"`
Subject *Subject `json:"subject,omitempty"`
// one line
Summary string `json:"summary,omitempty"`
Tried []ConditionEventTriedItem `json:"tried,omitempty"`
Was string `json:"was,omitempty"`
Why string `json:"why,omitempty"`
}
// The values of ConditionEvent's change.
const (
ConditionEventChangeRaised = "raised"
ConditionEventChangeReopened = "reopened"
ConditionEventChangeSeverity = "severity"
ConditionEventChangeResolver = "resolver"
ConditionEventChangeSilenced = "silenced"
ConditionEventChangeSilenceEnded = "silence-ended"
ConditionEventChangeCleared = "cleared"
ConditionEventChangeWords = "words"
)
// The values of ConditionEvent's severity.
const (
ConditionEventSeverityUrgent = "urgent"
ConditionEventSeverityWarning = "warning"
)
// Evidence is one observation, as it was said.
type Evidence struct {
At time.Time `json:"at"`
Said string `json:"said"`
}
// Subject is what the condition is about.
type Subject struct {
// the other machines it concerns
Also []string `json:"also,omitempty"`
// its id within the scope
ID string `json:"id"`
// the machine it concerns, when there is one
Machine string `json:"machine,omitempty"`
// machine, plan, call, build, merge, provider, seat, bus, core, probe, mesh, delivery or module
Scope string `json:"scope"`
}
// ConditionsInput is what conditions takes.
type ConditionsInput struct {
// with history: how many days back (default 7, at most 90)
Days string `json:"days,omitempty"`
// "true": every raising, change, silence and clearing lately, oldest first
History string `json:"history,omitempty"`
// a condition's key: that one whole; with history, only its transitions
Key string `json:"key,omitempty"`
// only those about this machine
Machine string `json:"machine,omitempty"`
// only this scope: machine, plan, call, build, merge, provider, seat, bus, core, probe, mesh, delivery or module
Scope string `json:"scope,omitempty"`
// only urgent, or only warning
Severity string `json:"severity,omitempty"`
}
// The values of ConditionsInput's history.
const (
ConditionsInputHistoryTrue = "true"
ConditionsInputHistoryFalse = "false"
)
// ConditionsAnswer is what conditions answers.
type ConditionsAnswer = map[string]any
// SilenceInput is what silence takes.
type SilenceInput struct {
// the cause in a word (the condition's kind when absent)
Cause string `json:"cause,omitempty"`
// how long: 30m, 4h, 2d; at most 7d
For string `json:"for"`
// the condition's key
Key string `json:"key"`
// why, required and recorded
Why string `json:"why"`
}
// SilenceAnswer is what silence answers.
type SilenceAnswer = map[string]any
// ResyncInput is what resync takes.
type ResyncInput struct {
}
// ResyncAnswer is what resync answers.
type ResyncAnswer = map[string]any
// ConditionRaisedEvent is the body of the event condition-raised.
type ConditionRaisedEvent = ConditionEvent
// ConditionChangedEvent is the body of the event condition-changed.
type ConditionChangedEvent = ConditionEvent
// ConditionClearedEvent is the body of the event condition-cleared.
type ConditionClearedEvent = ConditionEvent
// ConditionsByKeyValueGapsItem is a type of the contract.
type ConditionsByKeyValueGapsItem struct {
Cleared *time.Time `json:"cleared,omitempty"`
Reopened *time.Time `json:"reopened,omitempty"`
}
// ConditionsByKeyValueSilenced is a type of the contract.
type ConditionsByKeyValueSilenced struct {
By string `json:"by,omitempty"`
Since *time.Time `json:"since,omitempty"`
Until *time.Time `json:"until,omitempty"`
Why string `json:"why,omitempty"`
}
// ConditionsByKeyValueTriedItem is a type of the contract.
type ConditionsByKeyValueTriedItem struct {
At *time.Time `json:"at,omitempty"`
By string `json:"by,omitempty"`
Outcome string `json:"outcome,omitempty"`
What string `json:"what,omitempty"`
}
// ConditionsByKeyValue is one value of the state conditions under the key <key>.
type ConditionsByKeyValue struct {
Actions []Action `json:"actions,omitempty"`
Count int64 `json:"count,omitempty"`
Epoch int64 `json:"epoch,omitempty"`
// newest first, at most 10
Evidence []Evidence `json:"evidence,omitempty"`
Explanation string `json:"explanation,omitempty"`
First *time.Time `json:"first,omitempty"`
Gaps []ConditionsByKeyValueGapsItem `json:"gaps,omitempty"`
Headline string `json:"headline,omitempty"`
// scope.id.token
Key string `json:"key"`
Kind string `json:"kind"`
LastObserved time.Time `json:"last-observed"`
LooksUnread string `json:"looks-unread,omitempty"`
Needs string `json:"needs,omitempty"`
Observations int64 `json:"observations,omitempty"`
Raised time.Time `json:"raised"`
Resolved string `json:"resolved,omitempty"`
// self, operator, agent or healer:<name>
Resolver string `json:"resolver,omitempty"`
Severity string `json:"severity"`
Silenced *ConditionsByKeyValueSilenced `json:"silenced,omitempty"`
// the row, probe or event that saw it
Source string `json:"source,omitempty"`
Subject Subject `json:"subject"`
// one line
Summary string `json:"summary,omitempty"`
Tried []ConditionsByKeyValueTriedItem `json:"tried,omitempty"`
}
// The values of ConditionsByKeyValue's severity.
const (
ConditionsByKeyValueSeverityUrgent = "urgent"
ConditionsByKeyValueSeverityWarning = "warning"
)
// FindingsByKeyValue is one value of the state findings under the key <key>.
type FindingsByKeyValue struct {
Actions []Action `json:"actions,omitempty"`
At time.Time `json:"at"`
// newest first, at most 10
Evidence []Evidence `json:"evidence,omitempty"`
Explanation string `json:"explanation,omitempty"`
Headline string `json:"headline,omitempty"`
// scope.id.token
Key string `json:"key"`
Kind string `json:"kind"`
Needs string `json:"needs,omitempty"`
// the module that wrote it
Observer string `json:"observer"`
// the key's last part when it is not the kind
Ref string `json:"ref,omitempty"`
Resolved string `json:"resolved,omitempty"`
// self, operator, agent or healer:<name>
Resolver string `json:"resolver,omitempty"`
// what of its own saw it last
Run string `json:"run,omitempty"`
Severity string `json:"severity"`
// the row, probe or event that saw it
Source string `json:"source"`
Subject Subject `json:"subject"`
// one line
Summary string `json:"summary"`
// layout version, 1
V int64 `json:"v"`
}
// The values of FindingsByKeyValue's severity.
const (
FindingsByKeyValueSeverityUrgent = "urgent"
FindingsByKeyValueSeverityWarning = "warning"
)
@@ -0,0 +1,54 @@
// Code generated by seatgen from the seat definitions; DO NOT EDIT.
package meshconditions
import (
"encoding/json"
"reflect"
"regexp"
"testing"
)
// TestFixtures reads every fixture of seats/mesh-conditions/fixtures: each decodes into its type, a field the type does not
// know is dropped (ADR 0608 §6), and it reads back as its wire says; each subject named is the contract's.
func TestFixtures(t *testing.T) {
cases := []struct {
name, subject, given, wire string
decode func([]byte) (any, error)
subjectIs *regexp.Regexp
}{
{name: "a condition raised from a finding, as the keeper holds it (captured; empty fields left out)", subject: "", given: "{\"count\":1,\"evidence\":[{\"at\":\"2026-10-26T20:19:41.474823121Z\",\"said\":\"mesh-build-node on ace uses mesh-build-node.cache as found: used as found: owned by 10005:10005, mode 700, as found; mesh-build-node, mode 0711 was declared and not given it — `ace/node-engine.hand-over` with the directory's path, then your yes on your channel, with its path, hands it to the mesh\"}],\"explanation\":\"Needs you: hand the directory over from the control-node, as the operator; the details name it and the line to type. A directory of mesh-build-node was already on ace, with another owner or mode than mesh-build-node declares.\",\"headline\":\"mesh-build-node waits for a directory on ace\",\"key\":\"module.mesh-build-node.ace.directory-used-as-found\",\"kind\":\"directory-used-as-found\",\"last-observed\":\"2026-10-26T20:19:41.474823121Z\",\"needs\":\"hand the directory over from the control-node, as the operator; the details name it and the line to type.\",\"observations\":1,\"raised\":\"2026-10-31T08:19:57.076843727Z\",\"resolved\":\"mesh-build-node's directory on ace is the mesh's\",\"resolver\":\"self\",\"severity\":\"warning\",\"source\":\"health-report\",\"subject\":{\"id\":\"mesh-build-node.ace\",\"machine\":\"ace\",\"scope\":\"module\"},\"summary\":\"mesh-build-node on ace uses mesh-build-node.cache as found: used as found: owned by 10005:10005, mode 700, as found; mesh-build-node, mode 0711 was declared and not given it — `ace/node-engine.hand-over` with the directory's path, then your yes on your channel, with its path, hands it to the mesh\"}", wire: "{\"count\":1,\"evidence\":[{\"at\":\"2026-10-26T20:19:41.474823121Z\",\"said\":\"mesh-build-node on ace uses mesh-build-node.cache as found: used as found: owned by 10005:10005, mode 700, as found; mesh-build-node, mode 0711 was declared and not given it — `ace/node-engine.hand-over` with the directory's path, then your yes on your channel, with its path, hands it to the mesh\"}],\"explanation\":\"Needs you: hand the directory over from the control-node, as the operator; the details name it and the line to type. A directory of mesh-build-node was already on ace, with another owner or mode than mesh-build-node declares.\",\"headline\":\"mesh-build-node waits for a directory on ace\",\"key\":\"module.mesh-build-node.ace.directory-used-as-found\",\"kind\":\"directory-used-as-found\",\"last-observed\":\"2026-10-26T20:19:41.474823121Z\",\"needs\":\"hand the directory over from the control-node, as the operator; the details name it and the line to type.\",\"observations\":1,\"raised\":\"2026-10-31T08:19:57.076843727Z\",\"resolved\":\"mesh-build-node's directory on ace is the mesh's\",\"resolver\":\"self\",\"severity\":\"warning\",\"source\":\"health-report\",\"subject\":{\"id\":\"mesh-build-node.ace\",\"machine\":\"ace\",\"scope\":\"module\"},\"summary\":\"mesh-build-node on ace uses mesh-build-node.cache as found: used as found: owned by 10005:10005, mode 700, as found; mesh-build-node, mode 0711 was declared and not given it — `ace/node-engine.hand-over` with the directory's path, then your yes on your channel, with its path, hands it to the mesh\"}",
decode: func(b []byte) (any, error) { var v ConditionsByKeyValue; err := json.Unmarshal(b, &v); return v, err }, subjectIs: nil},
{name: "a condition with an empty evidence trail of headline (captured; empty fields left out)", subject: "", given: "{\"count\":1,\"evidence\":[{\"at\":\"2026-10-30T13:03:46.432309782Z\",\"said\":\"The backup of postgres failed on ace\"}],\"explanation\":\"Needs you: answer whether to back up again or go on without a backup. The backup before applying postgres at 978b5db8cb4e on ace failed: node-backup.now could not be asked: not asked: nothing on this machine answers mesh.seat.node-backup.tool.now.ace.\",\"headline\":\"The backup of postgres failed on ace\",\"key\":\"module.ace.postgres.978b5db8cb4ef746ce68d82cc616d1439a53266d335e60f368dc44c4cb25610f.backup\",\"kind\":\"backup\",\"last-observed\":\"2026-10-30T13:03:46.432309782Z\",\"needs\":\"answer whether to back up again or go on without a backup\",\"observations\":1,\"raised\":\"2026-10-31T08:19:57.069818178Z\",\"resolver\":\"self\",\"severity\":\"warning\",\"source\":\"assigner.backup\",\"subject\":{\"id\":\"ace.postgres.978b5db8cb4ef746ce68d82cc616d1439a53266d335e60f368dc44c4cb25610f\",\"machine\":\"ace\",\"scope\":\"module\"},\"summary\":\"The backup of postgres failed on ace\"}", wire: "{\"count\":1,\"evidence\":[{\"at\":\"2026-10-30T13:03:46.432309782Z\",\"said\":\"The backup of postgres failed on ace\"}],\"explanation\":\"Needs you: answer whether to back up again or go on without a backup. The backup before applying postgres at 978b5db8cb4e on ace failed: node-backup.now could not be asked: not asked: nothing on this machine answers mesh.seat.node-backup.tool.now.ace.\",\"headline\":\"The backup of postgres failed on ace\",\"key\":\"module.ace.postgres.978b5db8cb4ef746ce68d82cc616d1439a53266d335e60f368dc44c4cb25610f.backup\",\"kind\":\"backup\",\"last-observed\":\"2026-10-30T13:03:46.432309782Z\",\"needs\":\"answer whether to back up again or go on without a backup\",\"observations\":1,\"raised\":\"2026-10-31T08:19:57.069818178Z\",\"resolver\":\"self\",\"severity\":\"warning\",\"source\":\"assigner.backup\",\"subject\":{\"id\":\"ace.postgres.978b5db8cb4ef746ce68d82cc616d1439a53266d335e60f368dc44c4cb25610f\",\"machine\":\"ace\",\"scope\":\"module\"},\"summary\":\"The backup of postgres failed on ace\"}",
decode: func(b []byte) (any, error) { var v ConditionsByKeyValue; err := json.Unmarshal(b, &v); return v, err }, subjectIs: nil},
{name: "a finding of the bus scope with its zero time (captured from the watchman; empty fields left out)", subject: "", given: "{\"at\":\"0001-01-01T00:00:00Z\",\"evidence\":[{\"at\":\"0001-01-01T00:00:00Z\",\"said\":\"1 held for EVENTS.controller (counted again every 5m0s while any is held, so one delivered again or dropped ends this within it)\"}],\"key\":\"bus.EVENTS.controller.max-deliveries\",\"kind\":\"max-deliveries\",\"needs\":\"deliver them again or drop them, from the mesh MCP server.\",\"observer\":\"mesh-delivery-watchman\",\"severity\":\"warning\",\"source\":\"S9\",\"subject\":{\"id\":\"EVENTS.controller\",\"scope\":\"bus\"},\"summary\":\"the controller's consumer on EVENTS gave up on a message; it is kept in DEAD_LETTERS until delivered again or dropped, with why\",\"v\":1}", wire: "{\"at\":\"0001-01-01T00:00:00Z\",\"evidence\":[{\"at\":\"0001-01-01T00:00:00Z\",\"said\":\"1 held for EVENTS.controller (counted again every 5m0s while any is held, so one delivered again or dropped ends this within it)\"}],\"key\":\"bus.EVENTS.controller.max-deliveries\",\"kind\":\"max-deliveries\",\"needs\":\"deliver them again or drop them, from the mesh MCP server.\",\"observer\":\"mesh-delivery-watchman\",\"severity\":\"warning\",\"source\":\"S9\",\"subject\":{\"id\":\"EVENTS.controller\",\"scope\":\"bus\"},\"summary\":\"the controller's consumer on EVENTS gave up on a message; it is kept in DEAD_LETTERS until delivered again or dropped, with why\",\"v\":1}",
decode: func(b []byte) (any, error) { var v FindingsByKeyValue; err := json.Unmarshal(b, &v); return v, err }, subjectIs: nil},
{name: "a finding of the module scope with its machine (captured from the watchman; empty fields left out)", subject: "", given: "{\"at\":\"2026-10-26T20:19:41.474823121Z\",\"evidence\":[{\"at\":\"2026-10-26T20:19:41.474823121Z\",\"said\":\"mesh-build-node on ace uses mesh-build-node.cache as found: owned by 10005:10005, mode 700, as found\"}],\"explanation\":\"Needs you: hand the directory over from the control-node, as the operator.\",\"headline\":\"mesh-build-node waits for a directory on ace\",\"key\":\"module.mesh-build-node.ace.directory-used-as-found\",\"kind\":\"directory-used-as-found\",\"needs\":\"hand the directory over from the control-node, as the operator; the details name it and the line to type.\",\"observer\":\"mesh-delivery-watchman\",\"resolved\":\"mesh-build-node's directory on ace is the mesh's\",\"severity\":\"warning\",\"source\":\"health-report\",\"subject\":{\"id\":\"mesh-build-node.ace\",\"machine\":\"ace\",\"scope\":\"module\"},\"summary\":\"mesh-build-node on ace uses mesh-build-node.cache as found\",\"v\":1}", wire: "{\"at\":\"2026-10-26T20:19:41.474823121Z\",\"evidence\":[{\"at\":\"2026-10-26T20:19:41.474823121Z\",\"said\":\"mesh-build-node on ace uses mesh-build-node.cache as found: owned by 10005:10005, mode 700, as found\"}],\"explanation\":\"Needs you: hand the directory over from the control-node, as the operator.\",\"headline\":\"mesh-build-node waits for a directory on ace\",\"key\":\"module.mesh-build-node.ace.directory-used-as-found\",\"kind\":\"directory-used-as-found\",\"needs\":\"hand the directory over from the control-node, as the operator; the details name it and the line to type.\",\"observer\":\"mesh-delivery-watchman\",\"resolved\":\"mesh-build-node's directory on ace is the mesh's\",\"severity\":\"warning\",\"source\":\"health-report\",\"subject\":{\"id\":\"mesh-build-node.ace\",\"machine\":\"ace\",\"scope\":\"module\"},\"summary\":\"mesh-build-node on ace uses mesh-build-node.cache as found\",\"v\":1}",
decode: func(b []byte) (any, error) { var v FindingsByKeyValue; err := json.Unmarshal(b, &v); return v, err }, subjectIs: nil},
}
for _, c := range cases {
v, err := c.decode([]byte(c.given))
if err != nil {
t.Errorf("%s: does not decode: %v", c.name, err)
continue
}
back, err := json.Marshal(v)
if err != nil {
t.Errorf("%s: does not encode: %v", c.name, err)
continue
}
var got, want any
if err := json.Unmarshal(back, &got); err != nil {
t.Fatal(err)
}
if err := json.Unmarshal([]byte(c.wire), &want); err != nil {
t.Fatal(err)
}
if !reflect.DeepEqual(got, want) {
t.Errorf("%s: reads back as %s, want %s", c.name, back, c.wire)
}
if c.subjectIs != nil && !c.subjectIs.MatchString(c.subject) {
t.Errorf("%s: subject %q is not the contract's %s", c.name, c.subject, c.subjectIs)
}
}
}
@@ -0,0 +1,3 @@
module git.novox.be/novox/mesh-contracts/health-and-repair/go/seats/meshconditions
go 1.26.0
@@ -0,0 +1,223 @@
// Code generated by seatgen from the seat definitions; DO NOT EDIT.
// Package meshhandacts is the contract of the seat "mesh-hand-acts", defined whole (seats/mesh-hand-acts/seat.json): Acts done by hand, recorded with who, why and cause (ADR 0565): the log every module records through.
package meshhandacts
import (
"bytes"
"encoding/json"
"errors"
"strings"
"time"
)
// Seat is the seat's name.
const Seat = "mesh-hand-acts"
const (
VerbHandAct = "hand-act"
VerbHandActs = "hand-acts"
VerbWarranted = "warranted"
)
const (
EventActRecorded = "act-recorded"
)
// Verbs is every verb the seat serves, in its definition's order: a new copy each call.
func Verbs() []string {
return []string{VerbHandAct, VerbHandActs, VerbWarranted}
}
// Events is every event published on the seat's subject: a new copy each call.
func Events() []string {
return []string{EventActRecorded}
}
// Family is where the seat's events are kept: the shared events stream, filtered to the seat's subjects.
const (
Family = "mesh-hand-acts.events"
FamilyMaxAgeDays = 7
)
// SubjectActRecorded is where act-recorded is published.
const SubjectActRecorded = "mesh.seat.mesh-hand-acts.event.act-recorded"
const (
StateActs = "acts"
StateWarrants = "warrants"
)
// ActsByIDKey is the key of one value of the state acts under the form "<id>".
func ActsByIDKey(id string) string { return id }
// ParseActsByIDKey reads the parts of a key of the form "<id>", in the order of the form; the last result is false for a key of another form.
func ParseActsByIDKey(key string) (string, bool) {
rest := key
if rest == "" {
return "", false
}
v0 := rest
rest = ""
if rest != "" {
return "", false
}
return v0, true
}
// DecodeActsByIDValue reads one value of the state acts under the form "<id>", refusing a field ActsByIDValue does not know and any text after the value.
func DecodeActsByIDValue(raw []byte) (ActsByIDValue, error) {
var v ActsByIDValue
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
}
// WarrantsByAskerAskKey is the key of one value of the state warrants under the form "<asker>.<ask>".
func WarrantsByAskerAskKey(asker, ask string) string { return asker + "." + ask }
// ParseWarrantsByAskerAskKey reads the parts of a key of the form "<asker>.<ask>", in the order of the form; the last result is false for a key of another form.
func ParseWarrantsByAskerAskKey(key string) (string, string, bool) {
rest := key
v0, after0, found0 := strings.Cut(rest, ".")
if !found0 || v0 == "" {
return "", "", false
}
rest = after0
if rest == "" {
return "", "", false
}
v2 := rest
rest = ""
if rest != "" {
return "", "", false
}
return v0, v2, true
}
// DecodeWarrantsByAskerAskValue reads one value of the state warrants under the form "<asker>.<ask>", refusing a field WarrantsByAskerAskValue does not know and any text after the value.
func DecodeWarrantsByAskerAskValue(raw []byte) (WarrantsByAskerAskValue, error) {
var v WarrantsByAskerAskValue
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
}
// Act is one act done by hand.
type Act struct {
Arguments []string `json:"arguments,omitempty"`
Ask string `json:"ask,omitempty"`
At time.Time `json:"at"`
// the caller the runtime proved
By string `json:"by"`
Carried []string `json:"carried,omitempty"`
Cause string `json:"cause"`
Condition string `json:"condition,omitempty"`
ID string `json:"id"`
// what the act was read to be from what it did
Kind string `json:"kind,omitempty"`
// which log holds it, set when it is read
Log string `json:"log,omitempty"`
Outcome string `json:"outcome,omitempty"`
Proofs []string `json:"proofs,omitempty"`
RequestedBy string `json:"requested-by,omitempty"`
Verb string `json:"verb"`
Via string `json:"via,omitempty"`
Why string `json:"why"`
}
// HandActInput is what hand-act takes.
type HandActInput struct {
// the cause in a word, or a condition's kind
Cause string `json:"cause,omitempty"`
// the key of the condition it addressed, if any (optional)
Condition string `json:"condition,omitempty"`
// the act's verb in the log: a repair when absent, `hand-act drill` for a drill, or a recorder's own
Verb string `json:"verb,omitempty"`
// what was done, in a line
What string `json:"what"`
// why it had to be done by hand
Why string `json:"why"`
}
// HandActAnswer is what hand-act answers.
type HandActAnswer = map[string]any
// HandActsInput is what hand-acts takes.
type HandActsInput struct {
// only this cause
Cause string `json:"cause,omitempty"`
// how many days back (default 14)
Days string `json:"days,omitempty"`
// "true": every warrant recorded
Warrants string `json:"warrants,omitempty"`
// only acts whose who contains this
Who string `json:"who,omitempty"`
}
// The values of HandActsInput's warrants.
const (
HandActsInputWarrantsTrue = "true"
HandActsInputWarrantsFalse = "false"
)
// HandActsAnswer is what hand-acts answers.
type HandActsAnswer = map[string]any
// WarrantedInput is what warranted takes.
type WarrantedInput struct {
// its ask's id
Ask string `json:"ask"`
// the module that asked
Asker string `json:"asker"`
}
// WarrantedAnswer is what warranted answers.
type WarrantedAnswer = map[string]any
// ActRecordedEvent is the body of the event act-recorded.
type ActRecordedEvent = Act
// ActsByIDValue is one value of the state acts under the key <id>.
type ActsByIDValue struct {
Arguments []string `json:"arguments,omitempty"`
Ask string `json:"ask,omitempty"`
At time.Time `json:"at"`
// the caller the runtime proved
By string `json:"by"`
Carried []string `json:"carried,omitempty"`
Cause string `json:"cause"`
Condition string `json:"condition,omitempty"`
ID string `json:"id"`
// what the act was read to be from what it did
Kind string `json:"kind,omitempty"`
// which log holds it, set when it is read
Log string `json:"log,omitempty"`
Outcome string `json:"outcome,omitempty"`
Proofs []string `json:"proofs,omitempty"`
RequestedBy string `json:"requested-by,omitempty"`
Verb string `json:"verb"`
Via string `json:"via,omitempty"`
Why string `json:"why"`
}
// WarrantsByAskerAskValue is one value of the state warrants under the key <asker>.<ask>.
type WarrantsByAskerAskValue struct {
Ask string `json:"ask"`
Asker string `json:"asker"`
At *time.Time `json:"at,omitempty"`
By string `json:"by,omitempty"`
Option string `json:"option,omitempty"`
}
@@ -0,0 +1,3 @@
module git.novox.be/novox/mesh-contracts/health-and-repair/go/seats/meshhandacts
go 1.26.0
@@ -0,0 +1,198 @@
// Code generated by seatgen from the seat definitions; DO NOT EDIT.
// Package meshhealers is the contract of the seat "mesh-healers", defined whole (seats/mesh-healers/seat.json): The healers (ADR 0564): each a registered response to one kind of condition, within a budget and the mesh-wide brake.
package meshhealers
import (
"bytes"
"encoding/json"
"errors"
"time"
)
// Seat is the seat's name.
const Seat = "mesh-healers"
const (
VerbHealers = "healers"
VerbHeal = "heal"
VerbDrill = "drill"
)
const (
EventHealerActed = "healer-acted"
)
// Verbs is every verb the seat serves, in its definition's order: a new copy each call.
func Verbs() []string {
return []string{VerbHealers, VerbHeal, VerbDrill}
}
// Events is every event published on the seat's subject: a new copy each call.
func Events() []string {
return []string{EventHealerActed}
}
// Family is where the seat's events are kept: the shared events stream, filtered to the seat's subjects.
const (
Family = "mesh-healers.events"
FamilyMaxAgeDays = 7
)
// SubjectHealerActed is where healer-acted is published.
const SubjectHealerActed = "mesh.seat.mesh-healers.event.healer-acted"
const (
StateAttempts = "attempts"
StateWould = "would"
)
// AttemptsByAttemptKey is the key of one value of the state attempts under the form "<attempt>".
func AttemptsByAttemptKey(attempt string) string { return attempt }
// ParseAttemptsByAttemptKey reads the parts of a key of the form "<attempt>", in the order of the form; the last result is false for a key of another form.
func ParseAttemptsByAttemptKey(key string) (string, bool) {
rest := key
if rest == "" {
return "", false
}
v0 := rest
rest = ""
if rest != "" {
return "", false
}
return v0, true
}
// DecodeAttemptsByAttemptValue reads one value of the state attempts under the form "<attempt>", refusing a field AttemptsByAttemptValue does not know and any text after the value.
func DecodeAttemptsByAttemptValue(raw []byte) (AttemptsByAttemptValue, error) {
var v AttemptsByAttemptValue
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
}
// WouldByWouldKey is the key of one value of the state would under the form "<would>".
func WouldByWouldKey(would string) string { return would }
// ParseWouldByWouldKey reads the parts of a key of the form "<would>", in the order of the form; the last result is false for a key of another form.
func ParseWouldByWouldKey(key string) (string, bool) {
rest := key
if rest == "" {
return "", false
}
v0 := rest
rest = ""
if rest != "" {
return "", false
}
return v0, true
}
// DecodeWouldByWouldValue reads one value of the state would under the form "<would>", refusing a field WouldByWouldValue does not know and any text after the value.
func DecodeWouldByWouldValue(raw []byte) (WouldByWouldValue, error) {
var v WouldByWouldValue
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
}
// HealersInput is what healers takes.
type HealersInput struct {
// how many days of acts back (default 7)
Days string `json:"days,omitempty"`
}
// HealersAnswer is what healers answers.
type HealersAnswer = map[string]any
// HealInput is what heal takes.
type HealInput struct {
// the condition's key
Key string `json:"key"`
// why now, kept with the act
Why string `json:"why"`
}
// HealAnswer is what heal answers.
type HealAnswer = map[string]any
// DrillInput is what drill takes.
type DrillInput struct {
// the key of the condition the drill is meant to raise, if any (optional)
Condition string `json:"condition,omitempty"`
// what was done on purpose, in a line
What string `json:"what"`
// what the drill tests
Why string `json:"why"`
}
// DrillAnswer is what drill answers.
type DrillAnswer = map[string]any
// HealerActedEvent is the body of the event healer-acted.
type HealerActedEvent struct {
Act string `json:"act,omitempty"`
At time.Time `json:"at"`
Budget string `json:"budget,omitempty"`
By string `json:"by,omitempty"`
Condition string `json:"condition"`
Event string `json:"event"`
Healer string `json:"healer"`
Kind string `json:"kind,omitempty"`
Outcome string `json:"outcome"`
Said string `json:"said,omitempty"`
Show string `json:"show,omitempty"`
}
// The values of HealerActedEvent's outcome.
const (
HealerActedEventOutcomeActing = "acting"
HealerActedEventOutcomeActed = "acted"
HealerActedEventOutcomeFailed = "failed"
HealerActedEventOutcomeEscalated = "escalated"
)
// AttemptsByAttemptValue is one value of the state attempts under the key <attempt>.
type AttemptsByAttemptValue struct {
Act string `json:"act"`
Begun time.Time `json:"begun"`
// what the budget counts by: the condition's key unless the healer says another
BudgetKey string `json:"budget-key"`
Condition string `json:"condition"`
Finished *time.Time `json:"finished,omitempty"`
Healer string `json:"healer"`
Kind string `json:"kind"`
Outcome string `json:"outcome"`
Said string `json:"said,omitempty"`
}
// The values of AttemptsByAttemptValue's outcome.
const (
AttemptsByAttemptValueOutcomeActing = "acting"
AttemptsByAttemptValueOutcomeActed = "acted"
AttemptsByAttemptValueOutcomeFailed = "failed"
AttemptsByAttemptValueOutcomeEscalated = "escalated"
)
// WouldByWouldValue is one value of the state would under the key <would>.
type WouldByWouldValue struct {
Act string `json:"act"`
At time.Time `json:"at"`
Condition string `json:"condition"`
Healer string `json:"healer"`
Kind string `json:"kind,omitempty"`
Why string `json:"why,omitempty"`
}
@@ -0,0 +1,48 @@
// Code generated by seatgen from the seat definitions; DO NOT EDIT.
package meshhealers
import (
"encoding/json"
"reflect"
"regexp"
"testing"
)
// TestFixtures reads every fixture of seats/mesh-healers/fixtures: each decodes into its type, a field the type does not
// know is dropped (ADR 0608 §6), and it reads back as its wire says; each subject named is the contract's.
func TestFixtures(t *testing.T) {
cases := []struct {
name, subject, given, wire string
decode func([]byte) (any, error)
subjectIs *regexp.Regexp
}{
{name: "an act a healer would have made, kept from the controller's time (captured)", subject: "", given: "{\"act\":\"assert the module's buckets, logs and streams again through the grants part (grants_assert)\",\"at\":\"2026-11-02T08:15:56.49507336Z\",\"condition\":\"bus.EVENTS.4fs9xzyDY7CD2Cf1wVFYDK_1.consumer-lost\",\"healer\":\"H3\",\"kind\":\"consumer-lost\",\"why\":\"the controller's lease names a controller (or none is written, so one may take it): every act is written as would\"}", wire: "{\"act\":\"assert the module's buckets, logs and streams again through the grants part (grants_assert)\",\"at\":\"2026-11-02T08:15:56.49507336Z\",\"condition\":\"bus.EVENTS.4fs9xzyDY7CD2Cf1wVFYDK_1.consumer-lost\",\"healer\":\"H3\",\"kind\":\"consumer-lost\",\"why\":\"the controller's lease names a controller (or none is written, so one may take it): every act is written as would\"}",
decode: func(b []byte) (any, error) { var v WouldByWouldValue; err := json.Unmarshal(b, &v); return v, err }, subjectIs: nil},
}
for _, c := range cases {
v, err := c.decode([]byte(c.given))
if err != nil {
t.Errorf("%s: does not decode: %v", c.name, err)
continue
}
back, err := json.Marshal(v)
if err != nil {
t.Errorf("%s: does not encode: %v", c.name, err)
continue
}
var got, want any
if err := json.Unmarshal(back, &got); err != nil {
t.Fatal(err)
}
if err := json.Unmarshal([]byte(c.wire), &want); err != nil {
t.Fatal(err)
}
if !reflect.DeepEqual(got, want) {
t.Errorf("%s: reads back as %s, want %s", c.name, back, c.wire)
}
if c.subjectIs != nil && !c.subjectIs.MatchString(c.subject) {
t.Errorf("%s: subject %q is not the contract's %s", c.name, c.subject, c.subjectIs)
}
}
}
@@ -0,0 +1,3 @@
module git.novox.be/novox/mesh-contracts/health-and-repair/go/seats/meshhealers
go 1.26.0
@@ -0,0 +1,103 @@
// Code generated by seatgen from the seat definitions; DO NOT EDIT.
// Package meshselfcheck is the contract of the seat "mesh-self-check", defined whole (seats/mesh-self-check/seat.json): The mesh checking itself (ADR 0564): probes run every five minutes, each failure a finding the seat's conditions raise.
package meshselfcheck
import "time"
// Seat is the seat's name.
const Seat = "mesh-self-check"
const (
VerbDoctor = "doctor"
VerbProbes = "probes"
)
const (
EventChecked = "checked"
)
// Verbs is every verb the seat serves, in its definition's order: a new copy each call.
func Verbs() []string {
return []string{VerbDoctor, VerbProbes}
}
// Events is every event published on the seat's subject: a new copy each call.
func Events() []string {
return []string{EventChecked}
}
// Family is where the seat's events are kept: the shared events stream, filtered to the seat's subjects.
const (
Family = "mesh-self-check.events"
FamilyMaxAgeDays = 7
)
// SubjectChecked is where checked is published.
const SubjectChecked = "mesh.seat.mesh-self-check.event.checked"
// ProbeVerdict is one probe's verdict.
type ProbeVerdict struct {
Error string `json:"error,omitempty"`
Found []string `json:"found,omitempty"`
FoundCount int64 `json:"found-count,omitempty"`
ID string `json:"id"`
More string `json:"more,omitempty"`
Took string `json:"took,omitempty"`
Unconfirmed []string `json:"unconfirmed,omitempty"`
Verdict string `json:"verdict"`
}
// The values of ProbeVerdict's verdict.
const (
ProbeVerdictVerdictPassed = "passed"
ProbeVerdictVerdictFailed = "failed"
ProbeVerdictVerdictFailedToRun = "failed-to-run"
ProbeVerdictVerdictDeferred = "deferred"
)
// DoctorInput is what doctor takes.
type DoctorInput struct {
// one probe's id (D6): its verdict alone, with every finding
Probe string `json:"probe,omitempty"`
// "true": run every probe now and answer the verdict
Run string `json:"run,omitempty"`
}
// The values of DoctorInput's run.
const (
DoctorInputRunTrue = "true"
DoctorInputRunFalse = "false"
)
// DoctorAnswer is what doctor answers.
type DoctorAnswer = map[string]any
// ProbesInput is what probes takes.
type ProbesInput struct {
}
// ProbesAnswer is what probes answers.
type ProbesAnswer = map[string]any
// CheckedEventCounts is a type of the contract.
type CheckedEventCounts struct {
Deferred int64 `json:"deferred,omitempty"`
Failed int64 `json:"failed,omitempty"`
FailedToRun int64 `json:"failed-to-run,omitempty"`
Passed int64 `json:"passed,omitempty"`
}
// CheckedEvent is the body of the event checked.
type CheckedEvent struct {
At time.Time `json:"at"`
Controller string `json:"controller,omitempty"`
Counts *CheckedEventCounts `json:"counts,omitempty"`
IntervalSeconds int64 `json:"interval-seconds,omitempty"`
Probes []ProbeVerdict `json:"probes"`
Run string `json:"run"`
Started *time.Time `json:"started,omitempty"`
Took string `json:"took,omitempty"`
Unsaid int64 `json:"unsaid,omitempty"`
Why string `json:"why,omitempty"`
}
@@ -0,0 +1,3 @@
module git.novox.be/novox/mesh-contracts/health-and-repair/go/seats/meshselfcheck
go 1.26.0
@@ -0,0 +1,267 @@
// Code generated by seatgen from the seat definitions; DO NOT EDIT.
// Package meshwatchdogs is the contract of the seat "mesh-watchdogs", defined whole (seats/mesh-watchdogs/seat.json): The watchman's seat (ADR 0564, ADR 0433): signals that expire unless renewed, what stopped moving, who is present, the dead letters.
package meshwatchdogs
import (
"bytes"
"encoding/json"
"errors"
"strings"
"time"
)
// Seat is the seat's name.
const Seat = "mesh-watchdogs"
const (
VerbSignals = "signals"
VerbStuck = "stuck"
VerbPresence = "presence"
VerbDeadLetters = "dead-letters"
VerbResync = "resync"
)
// Verbs is every verb the seat serves, in its definition's order: a new copy each call.
func Verbs() []string {
return []string{VerbSignals, VerbStuck, VerbPresence, VerbDeadLetters, VerbResync}
}
const (
StatePresence = "presence"
StatePulse = "pulse"
StateStuck = "stuck"
)
// PresenceByNodeKey is the key of one value of the state presence under the form "<node>".
func PresenceByNodeKey(node string) string { return node }
// ParsePresenceByNodeKey reads the parts of a key of the form "<node>", in the order of the form; the last result is false for a key of another form.
func ParsePresenceByNodeKey(key string) (string, bool) {
rest := key
if rest == "" {
return "", false
}
v0 := rest
rest = ""
if rest != "" {
return "", false
}
return v0, true
}
// DecodePresenceByNodeValue reads one value of the state presence under the form "<node>", refusing a field PresenceByNodeValue does not know and any text after the value.
func DecodePresenceByNodeValue(raw []byte) (PresenceByNodeValue, error) {
var v PresenceByNodeValue
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
}
// PresenceByPartPartInstanceKey is the key of one value of the state presence under the form "part.<part>.<instance>".
func PresenceByPartPartInstanceKey(part, instance string) string {
return "part." + part + "." + instance
}
// ParsePresenceByPartPartInstanceKey reads the parts of a key of the form "part.<part>.<instance>", in the order of the form; the last result is false for a key of another form.
func ParsePresenceByPartPartInstanceKey(key string) (string, string, bool) {
rest := key
if !strings.HasPrefix(rest, "part.") {
return "", "", false
}
rest = rest[5:]
v1, after1, found1 := strings.Cut(rest, ".")
if !found1 || v1 == "" {
return "", "", false
}
rest = after1
if rest == "" {
return "", "", false
}
v3 := rest
rest = ""
if rest != "" {
return "", "", false
}
return v1, v3, true
}
// DecodePresenceByPartPartInstanceValue reads one value of the state presence under the form "part.<part>.<instance>", refusing a field PresenceByPartPartInstanceValue does not know and any text after the value.
func DecodePresenceByPartPartInstanceValue(raw []byte) (PresenceByPartPartInstanceValue, error) {
var v PresenceByPartPartInstanceValue
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
}
// PulseByBucketKeyKey is the key of one value of the state pulse under the form "<bucket>.<key>".
func PulseByBucketKeyKey(bucket, key string) string { return bucket + "." + key }
// ParsePulseByBucketKeyKey reads the parts of a key of the form "<bucket>.<key>", in the order of the form; the last result is false for a key of another form.
func ParsePulseByBucketKeyKey(key string) (string, string, bool) {
rest := key
v0, after0, found0 := strings.Cut(rest, ".")
if !found0 || v0 == "" {
return "", "", false
}
rest = after0
if rest == "" {
return "", "", false
}
v2 := rest
rest = ""
if rest != "" {
return "", "", false
}
return v0, v2, true
}
// DecodePulseByBucketKeyValue reads one value of the state pulse under the form "<bucket>.<key>", refusing a field PulseByBucketKeyValue does not know and any text after the value.
func DecodePulseByBucketKeyValue(raw []byte) (PulseByBucketKeyValue, error) {
var v PulseByBucketKeyValue
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
}
// StuckByBucketKeyKey is the key of one value of the state stuck under the form "<bucket>.<key>".
func StuckByBucketKeyKey(bucket, key string) string { return bucket + "." + key }
// ParseStuckByBucketKeyKey reads the parts of a key of the form "<bucket>.<key>", in the order of the form; the last result is false for a key of another form.
func ParseStuckByBucketKeyKey(key string) (string, string, bool) {
rest := key
v0, after0, found0 := strings.Cut(rest, ".")
if !found0 || v0 == "" {
return "", "", false
}
rest = after0
if rest == "" {
return "", "", false
}
v2 := rest
rest = ""
if rest != "" {
return "", "", false
}
return v0, v2, true
}
// DecodeStuckByBucketKeyValue reads one value of the state stuck under the form "<bucket>.<key>", refusing a field StuckByBucketKeyValue does not know and any text after the value.
func DecodeStuckByBucketKeyValue(raw []byte) (StuckByBucketKeyValue, error) {
var v StuckByBucketKeyValue
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
}
// SignalsInput is what signals takes.
type SignalsInput struct {
// one row, e.g. S1
Row string `json:"row,omitempty"`
}
// SignalsAnswer is what signals answers.
type SignalsAnswer = map[string]any
// StuckInput is what stuck takes.
type StuckInput struct {
// only those on this machine
Machine string `json:"machine,omitempty"`
}
// StuckAnswer is what stuck answers.
type StuckAnswer = map[string]any
// PresenceInput is what presence takes.
type PresenceInput struct {
// one machine
Machine string `json:"machine,omitempty"`
}
// PresenceAnswer is what presence answers.
type PresenceAnswer = map[string]any
// DeadLettersInput is what dead-letters takes.
type DeadLettersInput struct {
// with deliver or drop: the cause in a word (dead-letter when absent)
Cause string `json:"cause,omitempty"`
// a consumer's name, or <stream>.<consumer>: only what that one gave up on
Consumer string `json:"consumer,omitempty"`
// a dead letter's id: deliver it again to the consumer that gave it up (needs why)
Deliver string `json:"deliver,omitempty"`
// a dead letter's id: drop it for good (needs why)
Drop string `json:"drop,omitempty"`
// a dead letter's id, as the list gives it: that one whole, with its message
ID string `json:"id,omitempty"`
// how many to list, newest first (default 50); only when listing
Limit string `json:"limit,omitempty"`
// with deliver or drop: why, required and recorded
Why string `json:"why,omitempty"`
}
// DeadLettersAnswer is what dead-letters answers.
type DeadLettersAnswer = map[string]any
// ResyncInput is what resync takes.
type ResyncInput struct {
}
// ResyncAnswer is what resync answers.
type ResyncAnswer = map[string]any
// PresenceByNodeValue is one value of the state presence under the key <node>.
type PresenceByNodeValue struct {
Online bool `json:"online"`
V int64 `json:"v,omitempty"`
}
// PresenceByPartPartInstanceValue is one value of the state presence under the key part.<part>.<instance>.
type PresenceByPartPartInstanceValue struct {
Online bool `json:"online"`
V int64 `json:"v,omitempty"`
}
// PulseByBucketKeyValue is one value of the state pulse under the key <bucket>.<key>.
type PulseByBucketKeyValue struct {
Bucket string `json:"bucket"`
Key string `json:"key"`
Owes string `json:"owes,omitempty"`
Revision int64 `json:"revision"`
V int64 `json:"v,omitempty"`
}
// StuckByBucketKeyValue is one value of the state stuck under the key <bucket>.<key>.
type StuckByBucketKeyValue struct {
Bucket string `json:"bucket"`
Key string `json:"key"`
// it has moved again
Moved bool `json:"moved,omitempty"`
Since time.Time `json:"since"`
V int64 `json:"v,omitempty"`
// what it waited for, in words
Waited string `json:"waited,omitempty"`
}
@@ -0,0 +1,56 @@
// Code generated by seatgen from the seat definitions; DO NOT EDIT.
package meshwatchdogs
import (
"encoding/json"
"reflect"
"regexp"
"testing"
)
// TestFixtures reads every fixture of seats/mesh-watchdogs/fixtures: each decodes into its type, a field the type does not
// know is dropped (ADR 0608 §6), and it reads back as its wire says; each subject named is the contract's.
func TestFixtures(t *testing.T) {
cases := []struct {
name, subject, given, wire string
decode func([]byte) (any, error)
subjectIs *regexp.Regexp
}{
{name: "a node's agent online (captured)", subject: "", given: "{\"online\":true,\"v\":1}", wire: "{\"online\":true,\"v\":1}",
decode: func(b []byte) (any, error) { var v PresenceByNodeValue; err := json.Unmarshal(b, &v); return v, err }, subjectIs: nil},
{name: "a delivery part's instance online (captured)", subject: "", given: "{\"online\":true,\"v\":1}", wire: "{\"online\":true,\"v\":1}",
decode: func(b []byte) (any, error) {
var v PresenceByPartPartInstanceValue
err := json.Unmarshal(b, &v)
return v, err
}, subjectIs: nil},
{name: "a thing that has not moved since a time, with what it waited for (captured)", subject: "", given: "{\"bucket\":\"mesh-delivery-watchman_presence\",\"key\":\"part.mesh-delivery-builder.novox\",\"since\":\"2026-10-25T11:52:58.051397201+01:00\",\"v\":1,\"waited\":\"mesh-delivery-builder on novox to come back online\"}", wire: "{\"bucket\":\"mesh-delivery-watchman_presence\",\"key\":\"part.mesh-delivery-builder.novox\",\"since\":\"2026-10-25T11:52:58.051397201+01:00\",\"v\":1,\"waited\":\"mesh-delivery-builder on novox to come back online\"}",
decode: func(b []byte) (any, error) { var v StuckByBucketKeyValue; err := json.Unmarshal(b, &v); return v, err }, subjectIs: nil},
}
for _, c := range cases {
v, err := c.decode([]byte(c.given))
if err != nil {
t.Errorf("%s: does not decode: %v", c.name, err)
continue
}
back, err := json.Marshal(v)
if err != nil {
t.Errorf("%s: does not encode: %v", c.name, err)
continue
}
var got, want any
if err := json.Unmarshal(back, &got); err != nil {
t.Fatal(err)
}
if err := json.Unmarshal([]byte(c.wire), &want); err != nil {
t.Fatal(err)
}
if !reflect.DeepEqual(got, want) {
t.Errorf("%s: reads back as %s, want %s", c.name, back, c.wire)
}
if c.subjectIs != nil && !c.subjectIs.MatchString(c.subject) {
t.Errorf("%s: subject %q is not the contract's %s", c.name, c.subject, c.subjectIs)
}
}
}
@@ -0,0 +1,3 @@
module git.novox.be/novox/mesh-contracts/health-and-repair/go/seats/meshwatchdogs
go 1.26.0
+127
View File
@@ -0,0 +1,127 @@
{
"module": "mesh-health-and-repair-contracts",
"version": "1",
"build": {
"artifacts": [
{
"name": "go-meshconditions",
"kind": "package",
"language": "go",
"paths": [
"go/seats/meshconditions"
],
"series": "0.1",
"generated-from": "."
},
{
"name": "go-meshselfcheck",
"kind": "package",
"language": "go",
"paths": [
"go/seats/meshselfcheck"
],
"series": "0.1",
"generated-from": "."
},
{
"name": "go-meshhealers",
"kind": "package",
"language": "go",
"paths": [
"go/seats/meshhealers"
],
"series": "0.1",
"generated-from": "."
},
{
"name": "go-meshwatchdogs",
"kind": "package",
"language": "go",
"paths": [
"go/seats/meshwatchdogs"
],
"series": "0.1",
"generated-from": "."
},
{
"name": "go-meshhandacts",
"kind": "package",
"language": "go",
"paths": [
"go/seats/meshhandacts"
],
"series": "0.1",
"generated-from": "."
},
{
"name": "go-meshhandactsclient",
"kind": "package",
"language": "go",
"paths": [
"go/clients/meshhandactsclient"
],
"series": "0.1"
},
{
"name": "go-meshconditionsclient",
"kind": "package",
"language": "go",
"paths": [
"go/clients/meshconditionsclient"
],
"series": "0.1"
},
{
"name": "ts-meshconditions",
"kind": "package",
"language": "typescript",
"paths": [
"ts/seats/meshconditions"
],
"series": "0.1",
"generated-from": "."
},
{
"name": "ts-meshselfcheck",
"kind": "package",
"language": "typescript",
"paths": [
"ts/seats/meshselfcheck"
],
"series": "0.1",
"generated-from": "."
},
{
"name": "ts-meshhealers",
"kind": "package",
"language": "typescript",
"paths": [
"ts/seats/meshhealers"
],
"series": "0.1",
"generated-from": "."
},
{
"name": "ts-meshwatchdogs",
"kind": "package",
"language": "typescript",
"paths": [
"ts/seats/meshwatchdogs"
],
"series": "0.1",
"generated-from": "."
},
{
"name": "ts-meshhandacts",
"kind": "package",
"language": "typescript",
"paths": [
"ts/seats/meshhandacts"
],
"series": "0.1",
"generated-from": "."
}
]
},
"resources": []
}
+10
View File
@@ -0,0 +1,10 @@
{
"format": 1,
"seats": [
"mesh-conditions",
"mesh-hand-acts",
"mesh-healers",
"mesh-self-check",
"mesh-watchdogs"
]
}
@@ -0,0 +1,117 @@
[
{
"name": "a condition raised from a finding, as the keeper holds it (captured; empty fields left out)",
"of": "state:conditions/<key>",
"given": {
"count": 1,
"evidence": [
{
"at": "2026-10-26T20:19:41.474823121Z",
"said": "mesh-build-node on ace uses mesh-build-node.cache as found: used as found: owned by 10005:10005, mode 700, as found; mesh-build-node, mode 0711 was declared and not given it \u2014 `ace/node-engine.hand-over` with the directory's path, then your yes on your channel, with its path, hands it to the mesh"
}
],
"explanation": "Needs you: hand the directory over from the control-node, as the operator; the details name it and the line to type. A directory of mesh-build-node was already on ace, with another owner or mode than mesh-build-node declares.",
"headline": "mesh-build-node waits for a directory on ace",
"key": "module.mesh-build-node.ace.directory-used-as-found",
"kind": "directory-used-as-found",
"last-observed": "2026-10-26T20:19:41.474823121Z",
"needs": "hand the directory over from the control-node, as the operator; the details name it and the line to type.",
"observations": 1,
"raised": "2026-10-31T08:19:57.076843727Z",
"resolved": "mesh-build-node's directory on ace is the mesh's",
"resolver": "self",
"severity": "warning",
"source": "health-report",
"subject": {
"id": "mesh-build-node.ace",
"machine": "ace",
"scope": "module"
},
"summary": "mesh-build-node on ace uses mesh-build-node.cache as found: used as found: owned by 10005:10005, mode 700, as found; mesh-build-node, mode 0711 was declared and not given it \u2014 `ace/node-engine.hand-over` with the directory's path, then your yes on your channel, with its path, hands it to the mesh"
}
},
{
"name": "a condition with an empty evidence trail of headline (captured; empty fields left out)",
"of": "state:conditions/<key>",
"given": {
"count": 1,
"evidence": [
{
"at": "2026-10-30T13:03:46.432309782Z",
"said": "The backup of postgres failed on ace"
}
],
"explanation": "Needs you: answer whether to back up again or go on without a backup. The backup before applying postgres at 978b5db8cb4e on ace failed: node-backup.now could not be asked: not asked: nothing on this machine answers mesh.seat.node-backup.tool.now.ace.",
"headline": "The backup of postgres failed on ace",
"key": "module.ace.postgres.978b5db8cb4ef746ce68d82cc616d1439a53266d335e60f368dc44c4cb25610f.backup",
"kind": "backup",
"last-observed": "2026-10-30T13:03:46.432309782Z",
"needs": "answer whether to back up again or go on without a backup",
"observations": 1,
"raised": "2026-10-31T08:19:57.069818178Z",
"resolver": "self",
"severity": "warning",
"source": "assigner.backup",
"subject": {
"id": "ace.postgres.978b5db8cb4ef746ce68d82cc616d1439a53266d335e60f368dc44c4cb25610f",
"machine": "ace",
"scope": "module"
},
"summary": "The backup of postgres failed on ace"
}
},
{
"name": "a finding of the bus scope with its zero time (captured from the watchman; empty fields left out)",
"of": "state:findings/<key>",
"given": {
"v": 1,
"key": "bus.EVENTS.controller.max-deliveries",
"kind": "max-deliveries",
"subject": {
"id": "EVENTS.controller",
"scope": "bus"
},
"severity": "warning",
"summary": "the controller's consumer on EVENTS gave up on a message; it is kept in DEAD_LETTERS until delivered again or dropped, with why",
"needs": "deliver them again or drop them, from the mesh MCP server.",
"observer": "mesh-delivery-watchman",
"source": "S9",
"evidence": [
{
"at": "0001-01-01T00:00:00Z",
"said": "1 held for EVENTS.controller (counted again every 5m0s while any is held, so one delivered again or dropped ends this within it)"
}
],
"at": "0001-01-01T00:00:00Z"
}
},
{
"name": "a finding of the module scope with its machine (captured from the watchman; empty fields left out)",
"of": "state:findings/<key>",
"given": {
"v": 1,
"key": "module.mesh-build-node.ace.directory-used-as-found",
"kind": "directory-used-as-found",
"subject": {
"id": "mesh-build-node.ace",
"machine": "ace",
"scope": "module"
},
"severity": "warning",
"summary": "mesh-build-node on ace uses mesh-build-node.cache as found",
"explanation": "Needs you: hand the directory over from the control-node, as the operator.",
"headline": "mesh-build-node waits for a directory on ace",
"needs": "hand the directory over from the control-node, as the operator; the details name it and the line to type.",
"resolved": "mesh-build-node's directory on ace is the mesh's",
"observer": "mesh-delivery-watchman",
"source": "health-report",
"evidence": [
{
"at": "2026-10-26T20:19:41.474823121Z",
"said": "mesh-build-node on ace uses mesh-build-node.cache as found: owned by 10005:10005, mode 700, as found"
}
],
"at": "2026-10-26T20:19:41.474823121Z"
}
}
]
@@ -0,0 +1,647 @@
{
"format": 1,
"seat": "mesh-conditions",
"scope": "mesh",
"grain": "whole",
"domain": "Health and repair",
"decision": "novox/hq ADR 0564, ADR 0634, ADR 0635",
"description": "What is wrong with the mesh now (ADR 0564): the open conditions raised from the observers' findings and cleared when a finding goes.",
"verbs": [
{
"name": "conditions",
"description": "What is wrong with the mesh now: every open condition, urgent first, then oldest, raised from the observers' findings (the watchman, the self-check, the healers, the assigner) and cleared when the finding goes, never by hand. Given a key, that one whole with its evidence; with history, every transition lately.",
"input": {
"properties": {
"days": {
"description": "with history: how many days back (default 7, at most 90)",
"type": "string"
},
"history": {
"description": "\"true\": every raising, change, silence and clearing lately, oldest first",
"enum": [
"true",
"false"
],
"type": "string"
},
"key": {
"description": "a condition's key: that one whole; with history, only its transitions",
"type": "string"
},
"machine": {
"description": "only those about this machine",
"type": "string"
},
"scope": {
"description": "only this scope: machine, plan, call, build, merge, provider, seat, bus, core, probe, mesh, delivery or module",
"type": "string"
},
"severity": {
"description": "only urgent, or only warning",
"type": "string"
}
},
"type": "object"
},
"answer": {
"type": "object",
"description": "the open conditions, or one whole, or the history"
}
},
{
"name": "silence",
"description": "Stop one condition's messages for a while, a hand act with why: it stays open and in status, and the silence is kept as its transition in history.",
"input": {
"properties": {
"cause": {
"description": "the cause in a word (the condition's kind when absent)",
"type": "string"
},
"for": {
"description": "how long: 30m, 4h, 2d; at most 7d",
"type": "string"
},
"key": {
"description": "the condition's key",
"type": "string"
},
"why": {
"description": "why, required and recorded",
"type": "string"
}
},
"required": [
"key",
"for",
"why"
],
"type": "object"
},
"answer": {
"type": "object",
"description": "the condition now silenced"
}
},
{
"name": "resync",
"description": "Read every observer's findings again and raise and clear what differs from the open conditions: the recovery of a keeper that missed a change.",
"input": {
"properties": {},
"type": "object"
},
"answer": {
"type": "object",
"description": "what was raised and cleared again"
}
}
],
"events": [
{
"name": "condition-raised",
"body": {
"$ref": "#/defs/ConditionEvent"
}
},
{
"name": "condition-changed",
"body": {
"$ref": "#/defs/ConditionEvent"
}
},
{
"name": "condition-cleared",
"body": {
"$ref": "#/defs/ConditionEvent"
}
}
],
"family": {
"name": "mesh-conditions.events",
"max-age-days": 7
},
"states": [
{
"name": "conditions",
"template": "state",
"key-forms": [
{
"key": "<key>",
"value": {
"type": "object",
"description": "one open condition",
"properties": {
"key": {
"type": "string",
"description": "scope.id.token"
},
"kind": {
"type": "string"
},
"subject": {
"$ref": "#/defs/Subject"
},
"severity": {
"type": "string",
"enum": [
"urgent",
"warning"
]
},
"summary": {
"type": "string",
"description": "one line"
},
"headline": {
"type": "string"
},
"explanation": {
"type": "string"
},
"resolved": {
"type": "string"
},
"needs": {
"type": "string"
},
"actions": {
"type": "array",
"items": {
"$ref": "#/defs/Action"
}
},
"evidence": {
"type": "array",
"description": "newest first, at most 10",
"items": {
"$ref": "#/defs/Evidence"
}
},
"source": {
"type": "string",
"description": "the row, probe or event that saw it"
},
"resolver": {
"type": "string",
"description": "self, operator, agent or healer:<name>"
},
"raised": {
"type": "string",
"format": "date-time"
},
"last-observed": {
"type": "string",
"format": "date-time"
},
"first": {
"type": "string",
"format": "date-time"
},
"gaps": {
"type": "array",
"items": {
"type": "object",
"properties": {
"cleared": {
"type": "string",
"format": "date-time"
},
"reopened": {
"type": "string",
"format": "date-time"
}
}
}
},
"observations": {
"type": "integer"
},
"count": {
"type": "integer"
},
"tried": {
"type": "array",
"items": {
"type": "object",
"properties": {
"at": {
"type": "string",
"format": "date-time"
},
"what": {
"type": "string"
},
"outcome": {
"type": "string"
},
"by": {
"type": "string"
}
}
}
},
"silenced": {
"type": "object",
"properties": {
"until": {
"type": "string",
"format": "date-time"
},
"by": {
"type": "string"
},
"why": {
"type": "string"
},
"since": {
"type": "string",
"format": "date-time"
}
}
},
"epoch": {
"type": "integer"
},
"looks-unread": {
"type": "string"
}
},
"required": [
"key",
"kind",
"subject",
"severity",
"raised",
"last-observed"
]
},
"description": "one open condition, without its looks it keeps from its finding"
}
],
"description": "the open conditions the keeper holds, one key each"
},
{
"name": "findings",
"template": "state",
"writer": "caller",
"key-forms": [
{
"key": "<key>",
"value": {
"type": "object",
"description": "one thing an observer sees wrong now",
"properties": {
"v": {
"type": "integer",
"description": "layout version, 1"
},
"key": {
"type": "string",
"description": "scope.id.token"
},
"kind": {
"type": "string"
},
"subject": {
"$ref": "#/defs/Subject"
},
"severity": {
"type": "string",
"enum": [
"urgent",
"warning"
]
},
"summary": {
"type": "string",
"description": "one line"
},
"headline": {
"type": "string"
},
"explanation": {
"type": "string"
},
"resolved": {
"type": "string"
},
"needs": {
"type": "string"
},
"actions": {
"type": "array",
"items": {
"$ref": "#/defs/Action"
}
},
"evidence": {
"type": "array",
"description": "newest first, at most 10",
"items": {
"$ref": "#/defs/Evidence"
}
},
"source": {
"type": "string",
"description": "the row, probe or event that saw it"
},
"resolver": {
"type": "string",
"description": "self, operator, agent or healer:<name>"
},
"ref": {
"type": "string",
"description": "the key's last part when it is not the kind"
},
"observer": {
"type": "string",
"description": "the module that wrote it"
},
"run": {
"type": "string",
"description": "what of its own saw it last"
},
"at": {
"type": "string",
"format": "date-time"
}
},
"required": [
"v",
"key",
"kind",
"subject",
"severity",
"summary",
"source",
"observer",
"at"
]
},
"description": "one finding of the observer that wrote it"
}
],
"description": "every observer's findings, one key per finding; a key written raises or changes the condition of the same key, a key deleted clears it"
}
],
"defs": {
"Subject": {
"type": "object",
"description": "what the condition is about",
"properties": {
"scope": {
"type": "string",
"description": "machine, plan, call, build, merge, provider, seat, bus, core, probe, mesh, delivery or module"
},
"id": {
"type": "string",
"description": "its id within the scope"
},
"machine": {
"type": "string",
"description": "the machine it concerns, when there is one"
},
"also": {
"type": "array",
"description": "the other machines it concerns",
"items": {
"type": "string"
}
}
},
"required": [
"scope",
"id"
]
},
"Evidence": {
"type": "object",
"description": "one observation, as it was said",
"properties": {
"at": {
"type": "string",
"format": "date-time"
},
"said": {
"type": "string"
}
},
"required": [
"at",
"said"
]
},
"Action": {
"type": "object",
"description": "one answer the operator may give from a notification",
"properties": {
"label": {
"type": "string"
},
"verb": {
"type": "string",
"description": "the seat verb the operator channel calls"
},
"machine": {
"type": "string"
},
"arguments": {
"type": "object",
"additionalProperties": {
"type": "string"
}
},
"level": {
"type": "string",
"enum": [
"acknowledge",
"approve"
]
}
},
"required": [
"label",
"verb"
]
},
"ConditionEvent": {
"type": "object",
"description": "a condition and what changed",
"properties": {
"key": {
"type": "string",
"description": "scope.id.token"
},
"kind": {
"type": "string"
},
"subject": {
"$ref": "#/defs/Subject"
},
"severity": {
"type": "string",
"enum": [
"urgent",
"warning"
]
},
"summary": {
"type": "string",
"description": "one line"
},
"headline": {
"type": "string"
},
"explanation": {
"type": "string"
},
"resolved": {
"type": "string"
},
"needs": {
"type": "string"
},
"actions": {
"type": "array",
"items": {
"$ref": "#/defs/Action"
}
},
"evidence": {
"type": "array",
"description": "newest first, at most 10",
"items": {
"$ref": "#/defs/Evidence"
}
},
"source": {
"type": "string",
"description": "the row, probe or event that saw it"
},
"resolver": {
"type": "string",
"description": "self, operator, agent or healer:<name>"
},
"raised": {
"type": "string",
"format": "date-time"
},
"last-observed": {
"type": "string",
"format": "date-time"
},
"first": {
"type": "string",
"format": "date-time"
},
"gaps": {
"type": "array",
"items": {
"type": "object",
"properties": {
"cleared": {
"type": "string",
"format": "date-time"
},
"reopened": {
"type": "string",
"format": "date-time"
}
}
}
},
"observations": {
"type": "integer"
},
"count": {
"type": "integer"
},
"tried": {
"type": "array",
"items": {
"type": "object",
"properties": {
"at": {
"type": "string",
"format": "date-time"
},
"what": {
"type": "string"
},
"outcome": {
"type": "string"
},
"by": {
"type": "string"
}
}
}
},
"silenced": {
"type": "object",
"properties": {
"until": {
"type": "string",
"format": "date-time"
},
"by": {
"type": "string"
},
"why": {
"type": "string"
},
"since": {
"type": "string",
"format": "date-time"
}
}
},
"epoch": {
"type": "integer"
},
"looks-unread": {
"type": "string"
},
"event": {
"type": "string"
},
"at": {
"type": "string",
"format": "date-time"
},
"change": {
"type": "string",
"enum": [
"raised",
"reopened",
"severity",
"resolver",
"silenced",
"silence-ended",
"cleared",
"words"
]
},
"was": {
"type": "string"
},
"why": {
"type": "string"
},
"cleared": {
"type": "string",
"format": "date-time"
},
"show": {
"type": "string",
"description": "the tool call that shows it"
}
},
"required": [
"key",
"event",
"at",
"change"
]
}
}
}
@@ -0,0 +1,321 @@
{
"format": 1,
"seat": "mesh-hand-acts",
"scope": "mesh",
"grain": "whole",
"domain": "Health and repair",
"decision": "novox/hq ADR 0565, ADR 0634, ADR 0635",
"description": "Acts done by hand, recorded with who, why and cause (ADR 0565): the log every module records through.",
"verbs": [
{
"name": "hand-act",
"description": "Record one act done by hand: what, why, its cause in a word or a condition's kind (the word a second act for the same reason uses) and the condition it addresses. Who did it is the caller the runtime proves, never said by the caller. Said as act-recorded.",
"input": {
"properties": {
"cause": {
"description": "the cause in a word, or a condition's kind",
"type": "string"
},
"condition": {
"description": "the key of the condition it addressed, if any (optional)",
"type": "string"
},
"verb": {
"description": "the act's verb in the log: a repair when absent, `hand-act drill` for a drill, or a recorder's own",
"type": "string"
},
"what": {
"description": "what was done, in a line",
"type": "string"
},
"why": {
"description": "why it had to be done by hand",
"type": "string"
}
},
"required": [
"what",
"why"
],
"type": "object"
},
"answer": {
"type": "object",
"description": "the act recorded"
}
},
{
"name": "hand-acts",
"description": "What was done by hand lately, over this log and the controller's: who, why and the cause of each, and the causes recorded twice within a fortnight (a healer is wanted).",
"input": {
"properties": {
"cause": {
"description": "only this cause",
"type": "string"
},
"days": {
"description": "how many days back (default 14)",
"type": "string"
},
"warrants": {
"description": "\"true\": every warrant recorded",
"enum": [
"true",
"false"
],
"type": "string"
},
"who": {
"description": "only acts whose who contains this",
"type": "string"
}
},
"type": "object"
},
"answer": {
"type": "object",
"description": "the acts lately"
}
},
{
"name": "warranted",
"description": "Record what a module did on the operator's warrant: who chose, through which channel, with which proofs and which answer read from the router's own record of that module's ask, never from the caller; once per ask. Refused for an ask still open, ended without a choice, or not that module's.",
"input": {
"properties": {
"ask": {
"description": "its ask's id",
"type": "string"
},
"asker": {
"description": "the module that asked",
"type": "string"
}
},
"required": [
"asker",
"ask"
],
"type": "object"
},
"answer": {
"type": "object",
"description": "the warrant recorded"
}
}
],
"events": [
{
"name": "act-recorded",
"body": {
"$ref": "#/defs/Act"
}
}
],
"family": {
"name": "mesh-hand-acts.events",
"max-age-days": 7
},
"states": [
{
"name": "acts",
"template": "log",
"key-forms": [
{
"key": "<id>",
"value": {
"type": "object",
"description": "one act done by hand",
"properties": {
"id": {
"type": "string"
},
"at": {
"type": "string",
"format": "date-time"
},
"by": {
"type": "string",
"description": "the caller the runtime proved"
},
"verb": {
"type": "string"
},
"arguments": {
"type": "array",
"items": {
"type": "string"
}
},
"why": {
"type": "string"
},
"cause": {
"type": "string"
},
"condition": {
"type": "string"
},
"kind": {
"type": "string",
"description": "what the act was read to be from what it did"
},
"carried": {
"type": "array",
"items": {
"type": "string"
}
},
"via": {
"type": "string"
},
"ask": {
"type": "string"
},
"proofs": {
"type": "array",
"items": {
"type": "string"
}
},
"requested-by": {
"type": "string"
},
"outcome": {
"type": "string"
},
"log": {
"type": "string",
"description": "which log holds it, set when it is read"
}
},
"required": [
"id",
"at",
"by",
"verb",
"why",
"cause"
]
},
"description": "one act"
}
],
"max-age-days": 400,
"description": "every act, kept 400 days"
},
{
"name": "warrants",
"template": "state",
"key-forms": [
{
"key": "<asker>.<ask>",
"value": {
"type": "object",
"description": "what a module did on the operator's warrant, made once per ask",
"properties": {
"ask": {
"type": "string"
},
"asker": {
"type": "string"
},
"option": {
"type": "string"
},
"by": {
"type": "string"
},
"at": {
"type": "string",
"format": "date-time"
}
},
"required": [
"ask",
"asker"
]
},
"description": "one warrant"
}
],
"description": "warrants recorded, one key per asker and ask"
}
],
"defs": {
"Act": {
"type": "object",
"description": "one act done by hand",
"properties": {
"id": {
"type": "string"
},
"at": {
"type": "string",
"format": "date-time"
},
"by": {
"type": "string",
"description": "the caller the runtime proved"
},
"verb": {
"type": "string"
},
"arguments": {
"type": "array",
"items": {
"type": "string"
}
},
"why": {
"type": "string"
},
"cause": {
"type": "string"
},
"condition": {
"type": "string"
},
"kind": {
"type": "string",
"description": "what the act was read to be from what it did"
},
"carried": {
"type": "array",
"items": {
"type": "string"
}
},
"via": {
"type": "string"
},
"ask": {
"type": "string"
},
"proofs": {
"type": "array",
"items": {
"type": "string"
}
},
"requested-by": {
"type": "string"
},
"outcome": {
"type": "string"
},
"log": {
"type": "string",
"description": "which log holds it, set when it is read"
}
},
"required": [
"id",
"at",
"by",
"verb",
"why",
"cause"
]
}
}
}
@@ -0,0 +1,14 @@
[
{
"name": "an act a healer would have made, kept from the controller's time (captured)",
"of": "state:would/<would>",
"given": {
"act": "assert the module's buckets, logs and streams again through the grants part (grants_assert)",
"at": "2026-11-02T08:15:56.49507336Z",
"condition": "bus.EVENTS.4fs9xzyDY7CD2Cf1wVFYDK_1.consumer-lost",
"healer": "H3",
"kind": "consumer-lost",
"why": "the controller's lease names a controller (or none is written, so one may take it): every act is written as would"
}
}
]
@@ -0,0 +1,250 @@
{
"format": 1,
"seat": "mesh-healers",
"scope": "mesh",
"grain": "whole",
"domain": "Health and repair",
"decision": "novox/hq ADR 0564, ADR 0634, ADR 0635",
"description": "The healers (ADR 0564): each a registered response to one kind of condition, within a budget and the mesh-wide brake.",
"verbs": [
{
"name": "healers",
"description": "The healers: each registered response to one kind of condition, its repair, its budget and what happens when it is spent; every act taken lately with its outcome, and the mesh-wide brake.",
"input": {
"properties": {
"days": {
"description": "how many days of acts back (default 7)",
"type": "string"
}
},
"type": "object"
},
"answer": {
"type": "object",
"description": "the registered healers, acts lately and the brake"
}
},
{
"name": "heal",
"description": "Take one healer's act for one open condition now, within its budget and the brake.",
"input": {
"properties": {
"key": {
"description": "the condition's key",
"type": "string"
},
"why": {
"description": "why now, kept with the act",
"type": "string"
}
},
"required": [
"key",
"why"
],
"type": "object"
},
"answer": {
"type": "object",
"description": "the act taken"
}
},
{
"name": "drill",
"description": "Record a drill: something broken on purpose to see the mesh raise and clear it, with what it tests. A drill is never a repair: no healer is wanted for it however often it is run.",
"input": {
"properties": {
"condition": {
"description": "the key of the condition the drill is meant to raise, if any (optional)",
"type": "string"
},
"what": {
"description": "what was done on purpose, in a line",
"type": "string"
},
"why": {
"description": "what the drill tests",
"type": "string"
}
},
"required": [
"what",
"why"
],
"type": "object"
},
"answer": {
"type": "object",
"description": "the drill recorded"
}
}
],
"events": [
{
"name": "healer-acted",
"body": {
"type": "object",
"description": "a healer's act",
"properties": {
"event": {
"type": "string"
},
"at": {
"type": "string",
"format": "date-time"
},
"healer": {
"type": "string"
},
"by": {
"type": "string"
},
"condition": {
"type": "string"
},
"kind": {
"type": "string"
},
"act": {
"type": "string"
},
"outcome": {
"type": "string",
"enum": [
"acting",
"acted",
"failed",
"escalated"
]
},
"said": {
"type": "string"
},
"budget": {
"type": "string"
},
"show": {
"type": "string"
}
},
"required": [
"event",
"at",
"healer",
"condition",
"outcome"
]
}
}
],
"family": {
"name": "mesh-healers.events",
"max-age-days": 7
},
"states": [
{
"name": "attempts",
"template": "state",
"key-forms": [
{
"key": "<attempt>",
"value": {
"type": "object",
"description": "one act of a healer, created before it is made and finished after, so a second instance cannot begin the same act",
"properties": {
"healer": {
"type": "string"
},
"condition": {
"type": "string"
},
"kind": {
"type": "string"
},
"budget-key": {
"type": "string",
"description": "what the budget counts by: the condition's key unless the healer says another"
},
"act": {
"type": "string"
},
"outcome": {
"type": "string",
"enum": [
"acting",
"acted",
"failed",
"escalated"
]
},
"said": {
"type": "string"
},
"begun": {
"type": "string",
"format": "date-time"
},
"finished": {
"type": "string",
"format": "date-time"
}
},
"required": [
"healer",
"condition",
"kind",
"budget-key",
"act",
"outcome",
"begun"
]
},
"description": "one act, under the healers' own key"
}
],
"description": "the acts begun, which the keeper reads to write a condition's tries"
},
{
"name": "would",
"template": "state",
"key-forms": [
{
"key": "<would>",
"value": {
"type": "object",
"description": "an act the healers would have taken while the controller's lease names a controller; it counts for no budget",
"properties": {
"healer": {
"type": "string"
},
"condition": {
"type": "string"
},
"kind": {
"type": "string"
},
"act": {
"type": "string"
},
"at": {
"type": "string",
"format": "date-time"
},
"why": {
"type": "string"
}
},
"required": [
"healer",
"condition",
"act",
"at"
]
},
"description": "one act not taken"
}
],
"description": "the acts the healers would have taken"
}
]
}
@@ -0,0 +1,165 @@
{
"format": 1,
"seat": "mesh-self-check",
"scope": "mesh",
"grain": "whole",
"domain": "Health and repair",
"decision": "novox/hq ADR 0564, ADR 0634, ADR 0635",
"description": "The mesh checking itself (ADR 0564): probes run every five minutes, each failure a finding the seat's conditions raise.",
"verbs": [
{
"name": "doctor",
"description": "The self-check: the last run's verdict at once, each probe passed, failed, deferred (its facts not on the bus yet, named) or could not run, and how long ago. With run, a run now; with probe, one probe's verdict whole. Runs every five minutes on its own; each failure is a finding.",
"input": {
"properties": {
"probe": {
"description": "one probe's id (D6): its verdict alone, with every finding",
"type": "string"
},
"run": {
"description": "\"true\": run every probe now and answer the verdict",
"enum": [
"true",
"false"
],
"type": "string"
}
},
"type": "object"
},
"answer": {
"type": "object",
"description": "the last run's verdict, or one probe's, or a run now"
}
},
{
"name": "probes",
"description": "The registry: what each probe asserts, where it reads its facts, and the finding it writes.",
"input": {
"properties": {},
"type": "object"
},
"answer": {
"type": "object",
"description": "the registry of probes"
}
}
],
"events": [
{
"name": "checked",
"body": {
"type": "object",
"description": "one run of the self-check",
"properties": {
"run": {
"type": "string"
},
"at": {
"type": "string",
"format": "date-time"
},
"started": {
"type": "string",
"format": "date-time"
},
"took": {
"type": "string"
},
"interval-seconds": {
"type": "integer"
},
"counts": {
"type": "object",
"properties": {
"passed": {
"type": "integer"
},
"failed": {
"type": "integer"
},
"failed-to-run": {
"type": "integer"
},
"deferred": {
"type": "integer"
}
}
},
"probes": {
"type": "array",
"items": {
"$ref": "#/defs/ProbeVerdict"
}
},
"controller": {
"type": "string"
},
"why": {
"type": "string"
},
"unsaid": {
"type": "integer"
}
},
"required": [
"run",
"at",
"probes"
]
}
}
],
"family": {
"name": "mesh-self-check.events",
"max-age-days": 7
},
"defs": {
"ProbeVerdict": {
"type": "object",
"description": "one probe's verdict",
"properties": {
"id": {
"type": "string"
},
"verdict": {
"type": "string",
"enum": [
"passed",
"failed",
"failed-to-run",
"deferred"
]
},
"found": {
"type": "array",
"items": {
"type": "string"
}
},
"unconfirmed": {
"type": "array",
"items": {
"type": "string"
}
},
"error": {
"type": "string"
},
"took": {
"type": "string"
},
"found-count": {
"type": "integer"
},
"more": {
"type": "string"
}
},
"required": [
"id",
"verdict"
]
}
}
}
@@ -0,0 +1,29 @@
[
{
"name": "a node's agent online (captured)",
"of": "state:presence/<node>",
"given": {
"online": true,
"v": 1
}
},
{
"name": "a delivery part's instance online (captured)",
"of": "state:presence/part.<part>.<instance>",
"given": {
"online": true,
"v": 1
}
},
{
"name": "a thing that has not moved since a time, with what it waited for (captured)",
"of": "state:stuck/<bucket>.<key>",
"given": {
"bucket": "mesh-delivery-watchman_presence",
"key": "part.mesh-delivery-builder.novox",
"since": "2026-10-25T11:52:58.051397201+01:00",
"v": 1,
"waited": "mesh-delivery-builder on novox to come back online"
}
}
]
@@ -0,0 +1,243 @@
{
"format": 1,
"seat": "mesh-watchdogs",
"scope": "mesh",
"grain": "whole",
"domain": "Health and repair",
"decision": "novox/hq ADR 0564, ADR 0634, ADR 0635",
"description": "The watchman's seat (ADR 0564, ADR 0433): signals that expire unless renewed, what stopped moving, who is present, the dead letters.",
"verbs": [
{
"name": "signals",
"description": "Every watchdog of the signals table the watchman keeps, each a key that expires unless its signal renews it: its row, what renews it, its bound, the age of its newest signal, and the finding its expiry wrote.",
"input": {
"properties": {
"row": {
"description": "one row, e.g. S1",
"type": "string"
}
},
"type": "object"
},
"answer": {
"type": "object",
"description": "the watchdog table"
}
},
{
"name": "stuck",
"description": "Every want, build or step the watchman says stopped moving past its bound, with since when and the words of its finding.",
"input": {
"properties": {
"machine": {
"description": "only those on this machine",
"type": "string"
}
},
"type": "object"
},
"answer": {
"type": "object",
"description": "what stopped moving"
}
},
{
"name": "presence",
"description": "Every machine and tool runner the watchman sees connected, with its last word, and those it says silent; a machine that said it sleeps is shown as sleeping, not silent.",
"input": {
"properties": {
"machine": {
"description": "one machine",
"type": "string"
}
},
"type": "object"
},
"answer": {
"type": "object",
"description": "who is connected and who is silent"
}
},
{
"name": "dead-letters",
"description": "Every message a consumer on the bus gave up on, kept in DEAD_LETTERS: whose consumer, the subject, how often it was handed over and when it was given up, newest first. With id: that one whole. With deliver or drop, a hand act with why.",
"input": {
"properties": {
"cause": {
"description": "with deliver or drop: the cause in a word (dead-letter when absent)",
"type": "string"
},
"consumer": {
"description": "a consumer's name, or <stream>.<consumer>: only what that one gave up on",
"type": "string"
},
"deliver": {
"description": "a dead letter's id: deliver it again to the consumer that gave it up (needs why)",
"type": "string"
},
"drop": {
"description": "a dead letter's id: drop it for good (needs why)",
"type": "string"
},
"id": {
"description": "a dead letter's id, as the list gives it: that one whole, with its message",
"type": "string"
},
"limit": {
"description": "how many to list, newest first (default 50); only when listing",
"type": "string"
},
"why": {
"description": "with deliver or drop: why, required and recorded",
"type": "string"
}
},
"type": "object"
},
"answer": {
"type": "object",
"description": "the dead letters, one whole, or the act done"
}
},
{
"name": "resync",
"description": "Rebuild presence from the bus's connections, and the pulses and findings from the keys the watchman watches, and take every dead-letter notice waiting: the recovery of a watchman that missed a change.",
"input": {
"properties": {},
"type": "object"
},
"answer": {
"type": "object",
"description": "what was rebuilt"
}
}
],
"states": [
{
"name": "presence",
"template": "state",
"key-forms": [
{
"key": "<node>",
"value": {
"type": "object",
"description": "whether a node is online",
"properties": {
"v": {
"type": "integer"
},
"online": {
"type": "boolean"
}
},
"required": [
"online"
]
},
"description": "a node"
},
{
"key": "part.<part>.<instance>",
"value": {
"type": "object",
"description": "whether a part's instance is online",
"properties": {
"v": {
"type": "integer"
},
"online": {
"type": "boolean"
}
},
"required": [
"online"
]
},
"description": "a part's instance"
}
],
"history": 20,
"description": "who is connected; the entry's own time says since when"
},
{
"name": "pulse",
"template": "state",
"key-forms": [
{
"key": "<bucket>.<key>",
"value": {
"type": "object",
"description": "a watched key mirrored, its revision, and who owes the next move",
"properties": {
"v": {
"type": "integer"
},
"bucket": {
"type": "string"
},
"key": {
"type": "string"
},
"revision": {
"type": "integer"
},
"owes": {
"type": "string"
}
},
"required": [
"bucket",
"key",
"revision"
]
},
"description": "the move watched at a key of a state"
}
],
"description": "one key per watched move, living for the move's bound minus its age"
},
{
"name": "stuck",
"template": "state",
"key-forms": [
{
"key": "<bucket>.<key>",
"value": {
"type": "object",
"description": "what stopped moving",
"properties": {
"v": {
"type": "integer"
},
"bucket": {
"type": "string"
},
"key": {
"type": "string"
},
"waited": {
"type": "string",
"description": "what it waited for, in words"
},
"since": {
"type": "string",
"format": "date-time"
},
"moved": {
"type": "boolean",
"description": "it has moved again"
}
},
"required": [
"bucket",
"key",
"since"
]
},
"description": "a key whose pulse expired"
}
],
"description": "what stopped moving; no key is removed, one that moves again is written as moved"
}
]
}
@@ -0,0 +1,12 @@
{
"lockfileVersion": 3,
"name": "@novox/mesh-health-and-repair-contracts-mesh-conditions",
"packages": {
"": {
"name": "@novox/mesh-health-and-repair-contracts-mesh-conditions",
"version": "0.1.0"
}
},
"requires": true,
"version": "0.1.0"
}
@@ -0,0 +1,13 @@
{
"description": "The contract of the seat mesh-conditions, generated from its definition.",
"exports": {
".": "./src/index.ts"
},
"files": [
"src"
],
"name": "@novox/mesh-health-and-repair-contracts-mesh-conditions",
"type": "module",
"types": "./src/index.ts",
"version": "0.1.0"
}
@@ -0,0 +1,280 @@
// Code generated by seatgen from the seat definitions; DO NOT EDIT.
// The contract of the seat "mesh-conditions", defined whole (seats/mesh-conditions/seat.json): What is wrong with the mesh now (ADR 0564): the open conditions raised from the observers' findings and cleared when a finding goes.
export const seat = "mesh-conditions";
export const verbs = {
conditions: "conditions",
silence: "silence",
resync: "resync",
} as const;
export const events = {
conditionRaised: "condition-raised",
conditionChanged: "condition-changed",
conditionCleared: "condition-cleared",
} as const;
export const family = { name: "mesh-conditions.events", maxAgeDays: 7 } as const;
export const conditionRaisedSubject = "mesh.seat.mesh-conditions.event.condition-raised";
export const conditionChangedSubject = "mesh.seat.mesh-conditions.event.condition-changed";
export const conditionClearedSubject = "mesh.seat.mesh-conditions.event.condition-cleared";
export const states = {
conditions: "conditions",
findings: "findings",
} as const;
/** The key of one value of the state conditions under the form "<key>". */
export function conditionsByKeyKey(key: string): string {
return key;
}
/** The parts of a key of the form "<key>", or undefined for a key of another form. */
export function parseConditionsByKeyKey(_key: string): { key: string } | undefined {
let _rest = _key;
if (_rest === "") return undefined;
const key = _rest;
_rest = "";
if (_rest !== "") return undefined;
return { key };
}
/** The key of one value of the state findings under the form "<key>". */
export function findingsByKeyKey(key: string): string {
return key;
}
/** The parts of a key of the form "<key>", or undefined for a key of another form. */
export function parseFindingsByKeyKey(_key: string): { key: string } | undefined {
let _rest = _key;
if (_rest === "") return undefined;
const key = _rest;
_rest = "";
if (_rest !== "") return undefined;
return { key };
}
/** one answer the operator may give from a notification */
export interface Action {
"arguments"?: Record<string, string>;
"label": string;
"level"?: "acknowledge" | "approve";
"machine"?: string;
/** the seat verb the operator channel calls */
"verb": string;
}
export interface ConditionEventGapsItem {
"cleared"?: string;
"reopened"?: string;
}
export interface ConditionEventSilenced {
"by"?: string;
"since"?: string;
"until"?: string;
"why"?: string;
}
export interface ConditionEventTriedItem {
"at"?: string;
"by"?: string;
"outcome"?: string;
"what"?: string;
}
/** a condition and what changed */
export interface ConditionEvent {
"actions"?: Action[];
"at": string;
"change": "raised" | "reopened" | "severity" | "resolver" | "silenced" | "silence-ended" | "cleared" | "words";
"cleared"?: string;
"count"?: number;
"epoch"?: number;
"event": string;
/** newest first, at most 10 */
"evidence"?: Evidence[];
"explanation"?: string;
"first"?: string;
"gaps"?: ConditionEventGapsItem[];
"headline"?: string;
/** scope.id.token */
"key": string;
"kind"?: string;
"last-observed"?: string;
"looks-unread"?: string;
"needs"?: string;
"observations"?: number;
"raised"?: string;
"resolved"?: string;
/** self, operator, agent or healer:<name> */
"resolver"?: string;
"severity"?: "urgent" | "warning";
/** the tool call that shows it */
"show"?: string;
"silenced"?: ConditionEventSilenced;
/** the row, probe or event that saw it */
"source"?: string;
"subject"?: Subject;
/** one line */
"summary"?: string;
"tried"?: ConditionEventTriedItem[];
"was"?: string;
"why"?: string;
}
/** one observation, as it was said */
export interface Evidence {
"at": string;
"said": string;
}
/** what the condition is about */
export interface Subject {
/** the other machines it concerns */
"also"?: string[];
/** its id within the scope */
"id": string;
/** the machine it concerns, when there is one */
"machine"?: string;
/** machine, plan, call, build, merge, provider, seat, bus, core, probe, mesh, delivery or module */
"scope": string;
}
/** is what conditions takes. */
export interface ConditionsInput {
/** with history: how many days back (default 7, at most 90) */
"days"?: string;
/** "true": every raising, change, silence and clearing lately, oldest first */
"history"?: "true" | "false";
/** a condition's key: that one whole; with history, only its transitions */
"key"?: string;
/** only those about this machine */
"machine"?: string;
/** only this scope: machine, plan, call, build, merge, provider, seat, bus, core, probe, mesh, delivery or module */
"scope"?: string;
/** only urgent, or only warning */
"severity"?: string;
}
/** is what conditions answers. */
export type ConditionsAnswer = Record<string, unknown>;
/** is what silence takes. */
export interface SilenceInput {
/** the cause in a word (the condition's kind when absent) */
"cause"?: string;
/** how long: 30m, 4h, 2d; at most 7d */
"for": string;
/** the condition's key */
"key": string;
/** why, required and recorded */
"why": string;
}
/** is what silence answers. */
export type SilenceAnswer = Record<string, unknown>;
/** is what resync takes. */
export interface ResyncInput {
}
/** is what resync answers. */
export type ResyncAnswer = Record<string, unknown>;
/** is the body of the event condition-raised. */
export type ConditionRaisedEvent = ConditionEvent;
/** is the body of the event condition-changed. */
export type ConditionChangedEvent = ConditionEvent;
/** is the body of the event condition-cleared. */
export type ConditionClearedEvent = ConditionEvent;
export interface ConditionsByKeyValueGapsItem {
"cleared"?: string;
"reopened"?: string;
}
export interface ConditionsByKeyValueSilenced {
"by"?: string;
"since"?: string;
"until"?: string;
"why"?: string;
}
export interface ConditionsByKeyValueTriedItem {
"at"?: string;
"by"?: string;
"outcome"?: string;
"what"?: string;
}
/** is one value of the state conditions under the key <key>. */
export interface ConditionsByKeyValue {
"actions"?: Action[];
"count"?: number;
"epoch"?: number;
/** newest first, at most 10 */
"evidence"?: Evidence[];
"explanation"?: string;
"first"?: string;
"gaps"?: ConditionsByKeyValueGapsItem[];
"headline"?: string;
/** scope.id.token */
"key": string;
"kind": string;
"last-observed": string;
"looks-unread"?: string;
"needs"?: string;
"observations"?: number;
"raised": string;
"resolved"?: string;
/** self, operator, agent or healer:<name> */
"resolver"?: string;
"severity": "urgent" | "warning";
"silenced"?: ConditionsByKeyValueSilenced;
/** the row, probe or event that saw it */
"source"?: string;
"subject": Subject;
/** one line */
"summary"?: string;
"tried"?: ConditionsByKeyValueTriedItem[];
}
/** is one value of the state findings under the key <key>. */
export interface FindingsByKeyValue {
"actions"?: Action[];
"at": string;
/** newest first, at most 10 */
"evidence"?: Evidence[];
"explanation"?: string;
"headline"?: string;
/** scope.id.token */
"key": string;
"kind": string;
"needs"?: string;
/** the module that wrote it */
"observer": string;
/** the key's last part when it is not the kind */
"ref"?: string;
"resolved"?: string;
/** self, operator, agent or healer:<name> */
"resolver"?: string;
/** what of its own saw it last */
"run"?: string;
"severity": "urgent" | "warning";
/** the row, probe or event that saw it */
"source": string;
"subject": Subject;
/** one line */
"summary": string;
/** layout version, 1 */
"v": number;
}
@@ -0,0 +1,12 @@
{
"compilerOptions": {
"target": "ES2022",
"module": "NodeNext",
"moduleResolution": "NodeNext",
"noEmit": true,
"strict": true,
"skipLibCheck": true,
"forceConsistentCasingInFileNames": true
},
"include": ["src/**/*.ts"]
}
@@ -0,0 +1,12 @@
{
"lockfileVersion": 3,
"name": "@novox/mesh-health-and-repair-contracts-mesh-hand-acts",
"packages": {
"": {
"name": "@novox/mesh-health-and-repair-contracts-mesh-hand-acts",
"version": "0.1.0"
}
},
"requires": true,
"version": "0.1.0"
}
@@ -0,0 +1,13 @@
{
"description": "The contract of the seat mesh-hand-acts, generated from its definition.",
"exports": {
".": "./src/index.ts"
},
"files": [
"src"
],
"name": "@novox/mesh-health-and-repair-contracts-mesh-hand-acts",
"type": "module",
"types": "./src/index.ts",
"version": "0.1.0"
}
@@ -0,0 +1,160 @@
// Code generated by seatgen from the seat definitions; DO NOT EDIT.
// The contract of the seat "mesh-hand-acts", defined whole (seats/mesh-hand-acts/seat.json): Acts done by hand, recorded with who, why and cause (ADR 0565): the log every module records through.
export const seat = "mesh-hand-acts";
export const verbs = {
handAct: "hand-act",
handActs: "hand-acts",
warranted: "warranted",
} as const;
export const events = {
actRecorded: "act-recorded",
} as const;
export const family = { name: "mesh-hand-acts.events", maxAgeDays: 7 } as const;
export const actRecordedSubject = "mesh.seat.mesh-hand-acts.event.act-recorded";
export const states = {
acts: "acts",
warrants: "warrants",
} as const;
/** The key of one value of the state acts under the form "<id>". */
export function actsByIDKey(id: string): string {
return id;
}
/** The parts of a key of the form "<id>", or undefined for a key of another form. */
export function parseActsByIDKey(_key: string): { id: string } | undefined {
let _rest = _key;
if (_rest === "") return undefined;
const id = _rest;
_rest = "";
if (_rest !== "") return undefined;
return { id };
}
/** The key of one value of the state warrants under the form "<asker>.<ask>". */
export function warrantsByAskerAskKey(asker: string, ask: string): string {
return asker + "." + ask;
}
/** The parts of a key of the form "<asker>.<ask>", or undefined for a key of another form. */
export function parseWarrantsByAskerAskKey(_key: string): { asker: string; ask: string } | undefined {
let _rest = _key;
const _at0 = _rest.indexOf(".");
if (_at0 <= 0) return undefined;
const asker = _rest.slice(0, _at0);
_rest = _rest.slice(_at0 + 1);
if (_rest === "") return undefined;
const ask = _rest;
_rest = "";
if (_rest !== "") return undefined;
return { asker, ask };
}
/** one act done by hand */
export interface Act {
"arguments"?: string[];
"ask"?: string;
"at": string;
/** the caller the runtime proved */
"by": string;
"carried"?: string[];
"cause": string;
"condition"?: string;
"id": string;
/** what the act was read to be from what it did */
"kind"?: string;
/** which log holds it, set when it is read */
"log"?: string;
"outcome"?: string;
"proofs"?: string[];
"requested-by"?: string;
"verb": string;
"via"?: string;
"why": string;
}
/** is what hand-act takes. */
export interface HandActInput {
/** the cause in a word, or a condition's kind */
"cause"?: string;
/** the key of the condition it addressed, if any (optional) */
"condition"?: string;
/** the act's verb in the log: a repair when absent, `hand-act drill` for a drill, or a recorder's own */
"verb"?: string;
/** what was done, in a line */
"what": string;
/** why it had to be done by hand */
"why": string;
}
/** is what hand-act answers. */
export type HandActAnswer = Record<string, unknown>;
/** is what hand-acts takes. */
export interface HandActsInput {
/** only this cause */
"cause"?: string;
/** how many days back (default 14) */
"days"?: string;
/** "true": every warrant recorded */
"warrants"?: "true" | "false";
/** only acts whose who contains this */
"who"?: string;
}
/** is what hand-acts answers. */
export type HandActsAnswer = Record<string, unknown>;
/** is what warranted takes. */
export interface WarrantedInput {
/** its ask's id */
"ask": string;
/** the module that asked */
"asker": string;
}
/** is what warranted answers. */
export type WarrantedAnswer = Record<string, unknown>;
/** is the body of the event act-recorded. */
export type ActRecordedEvent = Act;
/** is one value of the state acts under the key <id>. */
export interface ActsByIDValue {
"arguments"?: string[];
"ask"?: string;
"at": string;
/** the caller the runtime proved */
"by": string;
"carried"?: string[];
"cause": string;
"condition"?: string;
"id": string;
/** what the act was read to be from what it did */
"kind"?: string;
/** which log holds it, set when it is read */
"log"?: string;
"outcome"?: string;
"proofs"?: string[];
"requested-by"?: string;
"verb": string;
"via"?: string;
"why": string;
}
/** is one value of the state warrants under the key <asker>.<ask>. */
export interface WarrantsByAskerAskValue {
"ask": string;
"asker": string;
"at"?: string;
"by"?: string;
"option"?: string;
}
@@ -0,0 +1,12 @@
{
"compilerOptions": {
"target": "ES2022",
"module": "NodeNext",
"moduleResolution": "NodeNext",
"noEmit": true,
"strict": true,
"skipLibCheck": true,
"forceConsistentCasingInFileNames": true
},
"include": ["src/**/*.ts"]
}
+12
View File
@@ -0,0 +1,12 @@
{
"lockfileVersion": 3,
"name": "@novox/mesh-health-and-repair-contracts-mesh-healers",
"packages": {
"": {
"name": "@novox/mesh-health-and-repair-contracts-mesh-healers",
"version": "0.1.0"
}
},
"requires": true,
"version": "0.1.0"
}
@@ -0,0 +1,13 @@
{
"description": "The contract of the seat mesh-healers, generated from its definition.",
"exports": {
".": "./src/index.ts"
},
"files": [
"src"
],
"name": "@novox/mesh-health-and-repair-contracts-mesh-healers",
"type": "module",
"types": "./src/index.ts",
"version": "0.1.0"
}
@@ -0,0 +1,127 @@
// Code generated by seatgen from the seat definitions; DO NOT EDIT.
// The contract of the seat "mesh-healers", defined whole (seats/mesh-healers/seat.json): The healers (ADR 0564): each a registered response to one kind of condition, within a budget and the mesh-wide brake.
export const seat = "mesh-healers";
export const verbs = {
healers: "healers",
heal: "heal",
drill: "drill",
} as const;
export const events = {
healerActed: "healer-acted",
} as const;
export const family = { name: "mesh-healers.events", maxAgeDays: 7 } as const;
export const healerActedSubject = "mesh.seat.mesh-healers.event.healer-acted";
export const states = {
attempts: "attempts",
would: "would",
} as const;
/** The key of one value of the state attempts under the form "<attempt>". */
export function attemptsByAttemptKey(attempt: string): string {
return attempt;
}
/** The parts of a key of the form "<attempt>", or undefined for a key of another form. */
export function parseAttemptsByAttemptKey(_key: string): { attempt: string } | undefined {
let _rest = _key;
if (_rest === "") return undefined;
const attempt = _rest;
_rest = "";
if (_rest !== "") return undefined;
return { attempt };
}
/** The key of one value of the state would under the form "<would>". */
export function wouldByWouldKey(would: string): string {
return would;
}
/** The parts of a key of the form "<would>", or undefined for a key of another form. */
export function parseWouldByWouldKey(_key: string): { would: string } | undefined {
let _rest = _key;
if (_rest === "") return undefined;
const would = _rest;
_rest = "";
if (_rest !== "") return undefined;
return { would };
}
/** is what healers takes. */
export interface HealersInput {
/** how many days of acts back (default 7) */
"days"?: string;
}
/** is what healers answers. */
export type HealersAnswer = Record<string, unknown>;
/** is what heal takes. */
export interface HealInput {
/** the condition's key */
"key": string;
/** why now, kept with the act */
"why": string;
}
/** is what heal answers. */
export type HealAnswer = Record<string, unknown>;
/** is what drill takes. */
export interface DrillInput {
/** the key of the condition the drill is meant to raise, if any (optional) */
"condition"?: string;
/** what was done on purpose, in a line */
"what": string;
/** what the drill tests */
"why": string;
}
/** is what drill answers. */
export type DrillAnswer = Record<string, unknown>;
/** is the body of the event healer-acted. */
export interface HealerActedEvent {
"act"?: string;
"at": string;
"budget"?: string;
"by"?: string;
"condition": string;
"event": string;
"healer": string;
"kind"?: string;
"outcome": "acting" | "acted" | "failed" | "escalated";
"said"?: string;
"show"?: string;
}
/** is one value of the state attempts under the key <attempt>. */
export interface AttemptsByAttemptValue {
"act": string;
"begun": string;
/** what the budget counts by: the condition's key unless the healer says another */
"budget-key": string;
"condition": string;
"finished"?: string;
"healer": string;
"kind": string;
"outcome": "acting" | "acted" | "failed" | "escalated";
"said"?: string;
}
/** is one value of the state would under the key <would>. */
export interface WouldByWouldValue {
"act": string;
"at": string;
"condition": string;
"healer": string;
"kind"?: string;
"why"?: string;
}
@@ -0,0 +1,12 @@
{
"compilerOptions": {
"target": "ES2022",
"module": "NodeNext",
"moduleResolution": "NodeNext",
"noEmit": true,
"strict": true,
"skipLibCheck": true,
"forceConsistentCasingInFileNames": true
},
"include": ["src/**/*.ts"]
}
@@ -0,0 +1,12 @@
{
"lockfileVersion": 3,
"name": "@novox/mesh-health-and-repair-contracts-mesh-self-check",
"packages": {
"": {
"name": "@novox/mesh-health-and-repair-contracts-mesh-self-check",
"version": "0.1.0"
}
},
"requires": true,
"version": "0.1.0"
}

Some files were not shown because too many files have changed in this diff Show More