Seat list reader: calls the seat mesh-catalog's seat-definitions and hears its changed and removed events, acts on nothing new until read, two runtime tools; bootstrap gains mesh-conditions, operator-channel and the contracts' current files (ADR 0636 §1-§4, §7; issue 1966)

Claude-Session: https://claude.ai/code/session_01JG73KJNrH5EgFXMKKYSrig
This commit is contained in:
2026-11-05 13:42:20 +01:00
parent 1aca4991d4
commit b611bb9ad5
10 changed files with 2383 additions and 170 deletions
+15 -1
View File
@@ -3,8 +3,22 @@
"seat": "mesh-catalog",
"scope": "mesh",
"grain": "seat-list",
"decision": "novox/hq ADR 0604",
"decision": "novox/hq ADR 0636",
"verbs": [
{
"name": "seat-definitions",
"description": "The seat list (ADR 0636 §4): with no seat, every trunk seat with its digest and the revision of the list; with a seat, that trunk definition resolved with the parts it includes. Reads only.",
"optional": true,
"input": {
"properties": {
"seat": {
"description": "the seat's name; none lists every trunk seat",
"type": "string"
}
},
"type": "object"
}
},
{
"name": "modules",
"description": "Every module the trunk holds, each with its repository, its directory there, the merge commit it was last changed at and its fingerprint (or why it has none): the catalog of the mesh, read from its holder's state. Reads only.",
+646
View File
@@ -0,0 +1,646 @@
{
"format": 1,
"seat": "mesh-conditions",
"scope": "mesh",
"grain": "whole",
"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"
]
}
}
}
+49
View File
@@ -218,5 +218,54 @@
]
}
}
],
"states": [
{
"name": "resolved",
"template": "state",
"key-forms": [
{
"key": "<node>.<module>.<key>",
"value": {
"type": "object",
"description": "the value a module on a node takes for a config key, and where it came from",
"properties": {
"node": {
"type": "string"
},
"module": {
"type": "string"
},
"key": {
"type": "string"
},
"value": {},
"source": {
"type": "string"
},
"predefined": {
"type": "string",
"description": "the predefined key the value falls back to"
},
"required": {
"type": "boolean",
"description": "an unset value is the operator's own to set"
},
"trusted": {
"type": "boolean"
}
},
"required": [
"node",
"module",
"key",
"source"
]
},
"description": "one resolved value"
}
],
"description": "the value each module on each node takes for each config key, built from the sources alone"
}
]
}
+227 -4
View File
@@ -2,8 +2,9 @@
"format": 1,
"seat": "mesh-hand-acts",
"scope": "mesh",
"grain": "seat-list",
"decision": "novox/hq ADR 0565",
"grain": "whole",
"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",
@@ -36,6 +37,10 @@
"why"
],
"type": "object"
},
"answer": {
"type": "object",
"description": "the act recorded"
}
},
{
@@ -65,6 +70,10 @@
}
},
"type": "object"
},
"answer": {
"type": "object",
"description": "the acts lately"
}
},
{
@@ -86,12 +95,226 @@
"ask"
],
"type": "object"
},
"answer": {
"type": "object",
"description": "the warrant recorded"
}
}
],
"events": [
{
"name": "act-recorded"
"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"
]
}
}
}
+152 -3
View File
@@ -2,8 +2,9 @@
"format": 1,
"seat": "mesh-watchdogs",
"scope": "mesh",
"grain": "seat-list",
"decision": "novox/hq ADR 0564",
"grain": "whole",
"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",
@@ -16,6 +17,10 @@
}
},
"type": "object"
},
"answer": {
"type": "object",
"description": "the watchdog table"
}
},
{
@@ -29,6 +34,10 @@
}
},
"type": "object"
},
"answer": {
"type": "object",
"description": "what stopped moving"
}
},
{
@@ -42,6 +51,10 @@
}
},
"type": "object"
},
"answer": {
"type": "object",
"description": "who is connected and who is silent"
}
},
{
@@ -54,7 +67,7 @@
"type": "string"
},
"consumer": {
"description": "a consumer's name, or \u003cstream\u003e.\u003cconsumer\u003e: only what that one gave up on",
"description": "a consumer's name, or <stream>.<consumer>: only what that one gave up on",
"type": "string"
},
"deliver": {
@@ -79,6 +92,10 @@
}
},
"type": "object"
},
"answer": {
"type": "object",
"description": "the dead letters, one whole, or the act done"
}
},
{
@@ -87,7 +104,139 @@
"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"
}
]
}
+361
View File
@@ -709,6 +709,367 @@
"name": "node-engine.events",
"max-age-days": 7
},
"states": [
{
"name": "have",
"template": "state",
"key-forms": [
{
"key": "<node>.<module>",
"value": {
"type": "object",
"description": "one module's state on a node: what the engine has built, composed and run",
"properties": {
"v": {
"type": "integer"
},
"fp": {
"type": "string",
"description": "the fingerprint held"
},
"composed": {
"type": "string",
"description": "the composition's digest"
},
"build": {
"type": "string"
},
"phase": {
"type": "string",
"description": "how far it has come"
},
"health": {
"type": "string",
"description": "how it is"
},
"detail": {
"type": "string"
},
"user-list": {
"type": "object",
"description": "the user list written"
},
"judgement": {
"type": "string"
},
"waits": {
"type": "string",
"description": "what it waits for"
},
"build-alone": {
"type": "boolean"
},
"kept": {
"type": "array",
"items": {
"type": "string"
}
},
"sealed": {
"type": "array",
"items": {
"type": "string"
}
},
"sealed-at": {
"type": "object"
},
"pull-wait": {
"type": "object",
"description": "a pull the engine waits for"
},
"handed": {
"type": "object",
"description": "the accounts handed"
}
},
"required": [
"v",
"phase",
"health"
]
},
"description": "one module on a node"
},
{
"key": "<node>.node",
"value": {
"type": "object",
"description": "a node's own record",
"properties": {
"v": {
"type": "integer"
},
"composed": {
"type": "string"
},
"windows": {
"type": "object"
},
"busy": {
"type": "object"
},
"strays": {
"type": "object"
},
"filters": {
"type": "object"
},
"profile": {
"type": "object"
},
"record": {
"type": "object",
"description": "the node's record: its tunnel and certificate"
},
"sealed": {
"type": "array",
"items": {
"type": "string"
}
},
"sealed-at": {
"type": "object"
},
"candidates": {
"type": "object",
"additionalProperties": {
"type": "object"
}
},
"composes": {
"type": "string",
"description": "own, or the controller's"
},
"composes-why": {
"type": "string"
}
},
"required": [
"v"
]
},
"description": "the node's own record"
}
],
"history": 20,
"description": "what each node has of each module, written by its engine"
},
{
"name": "composed",
"template": "state",
"key-forms": [
{
"key": "<node>.<module>.<fingerprint>",
"value": {
"type": "object",
"description": "one composition of a module on a node under a fingerprint",
"properties": {
"V": {
"type": "integer"
},
"Node": {
"type": "string"
},
"Module": {
"type": "string"
},
"FP": {
"type": "string"
},
"Data": {
"type": "boolean"
},
"Resources": {
"type": "array",
"items": {
"type": "object"
}
},
"Filled": {
"type": "array",
"items": {
"type": "string"
}
},
"Own": {
"type": "object",
"description": "the node's own adoption, identities, groups, libraries and provisioner account"
},
"Plan": {
"type": "object",
"description": "the module's plan"
},
"Refused": {
"type": "string",
"description": "why the composition was refused"
}
},
"required": [
"Node",
"Module",
"FP"
]
},
"description": "one composition"
}
],
"description": "the compositions of one node, kept in a bucket per node named composed-<node>"
},
{
"name": "data",
"template": "state",
"key-forms": [
{
"key": "<node>.<module>",
"value": {
"type": "object",
"description": "the data items a module has on a node",
"properties": {
"v": {
"type": "integer"
},
"node": {
"type": "string"
},
"module": {
"type": "string"
},
"digest": {
"type": "string"
},
"items": {
"type": "array",
"items": {
"type": "object"
}
}
},
"required": [
"node",
"module",
"digest",
"items"
]
},
"description": "a module's data on a node"
}
],
"description": "the data items of each module on each node"
},
{
"name": "packages",
"template": "state",
"key-forms": [
{
"key": "<node>",
"value": {
"type": "object",
"description": "the packages a node declares",
"properties": {
"node": {
"type": "string"
},
"packages": {
"type": "array",
"items": {
"type": "object",
"properties": {
"package": {
"type": "string"
},
"modules": {
"type": "array",
"items": {
"type": "string"
}
},
"absent": {
"type": "boolean"
}
}
}
}
},
"required": [
"node",
"packages"
]
},
"description": "a node's declared packages"
}
],
"description": "the packages each node declares"
},
{
"name": "health",
"template": "state",
"key-forms": [
{
"key": "<node>",
"value": {
"type": "object",
"description": "a node's health as its engine reports it",
"properties": {
"v": {
"type": "integer"
},
"node": {
"type": "string"
},
"network": {
"type": "object"
},
"peers": {
"type": "array",
"items": {
"type": "object"
}
},
"units": {
"type": "object"
},
"relogin": {
"type": "array",
"items": {
"type": "object"
}
},
"found": {
"type": "array",
"items": {
"type": "object"
}
},
"fallback": {
"type": "object"
},
"root": {
"type": "array",
"items": {
"type": "object"
}
},
"contract": {
"type": "integer"
},
"at": {
"type": "string",
"format": "date-time"
}
},
"required": [
"v",
"node",
"at"
]
},
"description": "a node's health"
}
],
"description": "each node's health as its engine reports it"
}
],
"defs": {
"Act": {
"type": "object",
+567
View File
@@ -0,0 +1,567 @@
{
"format": 1,
"seat": "operator-channel",
"scope": "mesh",
"grain": "whole",
"decision": "novox/hq ADR 0259",
"holder-declares": true,
"description": "Asking a person and answering on a channel (ADR 0259): held by the router, the messenger module, whose manifest declares the seat; written from that seat entry (design 26).",
"verbs": [
{
"name": "open",
"description": "What is open now: every message the operator was sent about something still wrong, urgent first, oldest first.",
"input": {
"type": "object",
"properties": {},
"description": "no arguments"
},
"answer": {
"type": "object",
"description": "the open messages"
}
},
{
"name": "history",
"description": "What was said to the operator lately, newest first, the refusals, and what waits to be sent on each channel.",
"input": {
"type": "object",
"properties": {
"limit": {
"type": "integer",
"description": "at most this many of each (default 50)"
}
}
},
"answer": {
"type": "object",
"description": "the sends, refusals and what waits"
}
},
{
"name": "notify",
"description": "Tell the operator something, as a module that uses the seat: a key, urgent or warning, one line, and what it is about; clear says it is over.",
"input": {
"type": "object",
"properties": {
"key": {
"type": "string",
"description": "the message's key"
},
"severity": {
"type": "string",
"enum": [
"urgent",
"warning"
]
},
"summary": {
"type": "string",
"description": "one line"
},
"subject": {
"type": "string",
"description": "what it is about"
},
"headline": {
"type": "string",
"description": "plain words"
},
"explanation": {
"type": "string",
"description": "one or two plain sentences"
},
"resolved": {
"type": "string",
"description": "the line said when it is over"
},
"clear": {
"type": "boolean",
"description": "it is over"
}
}
},
"answer": {
"type": "object",
"description": "what was sent"
}
},
{
"name": "tell",
"description": "Tell the operator a plain notice that needs no answer and opens no condition (ADR 0380).",
"input": {
"type": "object",
"properties": {
"headline": {
"type": "string",
"description": "plain words"
},
"lines": {
"type": "array",
"items": {
"type": "string"
},
"description": "one or two plain lines"
},
"subject": {
"type": "string",
"description": "what it is about"
}
}
},
"answer": {
"type": "object",
"description": "what was sent"
}
},
{
"name": "asks",
"description": "The asks the router holds: open first, then the last month's, each with how it ended.",
"input": {
"type": "object",
"properties": {},
"description": "no arguments"
},
"answer": {
"type": "object",
"description": "the asks"
}
}
],
"accepts": [
{
"name": "ask",
"by-caller": true,
"body": {
"$ref": "#/defs/Ask"
}
},
{
"name": "cancel",
"by-caller": true,
"body": {
"type": "object",
"properties": {
"id": {
"type": "string",
"description": "the ask's id"
}
},
"required": [
"id"
]
}
},
{
"name": "condition",
"by-caller": true,
"body": {
"$ref": "#/defs/Condition"
}
}
],
"events": [
{
"name": "decided",
"by-caller": true,
"body": {
"$ref": "#/defs/Warrant"
}
}
],
"family": {
"name": "operator-channel.decided",
"max-age-days": 7
},
"states": [
{
"name": "asks",
"template": "state",
"key-forms": [
{
"key": "<asker>.<id>",
"value": {
"type": "object",
"description": "one ask the router holds and how it stands"
},
"description": "one ask the router holds and how it stands"
}
],
"description": "the asks a module raised and the router holds, one key per asker and ask"
}
],
"holder-events": [
{
"name": "refused",
"subject": "mesh.mod.<holder>.event.refused"
},
{
"name": "offered",
"subject": "mesh.stream.<holder>.event.offered",
"family": "router.asks",
"body": {
"type": "object",
"properties": {
"id": {
"type": "string",
"description": "the ask's id"
},
"asker": {
"type": "string",
"description": "its asker"
},
"headline": {
"type": "string",
"description": "its headline"
},
"channels": {
"type": "array",
"items": {
"type": "string"
},
"description": "the channels it was offered on"
}
},
"required": [
"id",
"asker"
]
}
},
{
"name": "closed",
"subject": "mesh.stream.<holder>.event.closed",
"family": "router.asks",
"body": {
"type": "object",
"properties": {
"id": {
"type": "string",
"description": "the ask's id"
},
"asker": {
"type": "string",
"description": "its asker"
},
"how": {
"type": "string",
"enum": [
"answered",
"expired",
"cancelled",
"replaced",
"unlinked"
]
}
},
"required": [
"id",
"asker",
"how"
]
}
}
],
"defs": {
"Ask": {
"type": "object",
"properties": {
"id": {
"type": "string",
"description": "the asker's own id"
},
"headline": {
"type": "string",
"description": "a few plain words"
},
"explanation": {
"type": "string",
"description": "what happened and what it means"
},
"options": {
"type": "array",
"items": {
"$ref": "#/defs/Option"
},
"description": "the answers it offers"
},
"who": {
"type": "string",
"description": "who may answer: operator"
},
"expires": {
"type": "string",
"description": "when it ends unanswered, RFC 3339"
},
"on-expiry": {
"type": "string",
"description": "what the asker then does"
},
"about": {
"type": "string",
"description": "what it is about: a newer ask about it replaces the older"
},
"urgent": {
"type": "boolean",
"description": "urgent"
},
"whole": {
"type": "string",
"description": "the explanation with its values whole"
},
"details": {
"type": "string",
"description": "its secondary words"
},
"whole-details": {
"type": "string",
"description": "the details whole"
}
},
"required": [
"id",
"headline",
"explanation",
"options",
"who",
"expires",
"on-expiry"
]
},
"Option": {
"type": "object",
"properties": {
"id": {
"type": "string",
"description": "the option's id"
},
"label": {
"type": "string",
"description": "the button's label"
},
"does": {
"type": "string",
"description": "what it does, in plain words"
},
"level": {
"type": "string",
"description": "the proof its answer needs"
},
"binds": {
"type": "string",
"description": "the digest of exactly what the asker performs when it is chosen"
}
},
"required": [
"id",
"label",
"does",
"level"
]
},
"Warrant": {
"type": "object",
"properties": {
"ask": {
"type": "string",
"description": "the ask"
},
"asker": {
"type": "string",
"description": "its asker"
},
"about": {
"type": "string",
"description": "what it was about"
},
"outcome": {
"type": "string",
"enum": [
"chosen",
"expired",
"cancelled",
"replaced",
"refused"
]
},
"option": {
"type": "string",
"description": "the option chosen"
},
"label": {
"type": "string",
"description": "its label"
},
"level": {
"type": "string",
"description": "its level"
},
"by": {
"$ref": "#/defs/Person"
},
"channel": {
"type": "string",
"description": "the module it came through"
},
"proofs": {
"type": "array",
"items": {
"type": "string"
},
"description": "the proofs present"
},
"at": {
"type": "string",
"description": "when, RFC 3339"
},
"ask-digest": {
"type": "string",
"description": "the digest of the ask as the router took it"
},
"words": {
"type": "string",
"description": "why it ended without a choice, or what refused it"
}
},
"required": [
"ask",
"asker",
"outcome",
"at"
]
},
"Person": {
"type": "object",
"properties": {
"who": {
"type": "string",
"description": "the role"
},
"kind": {
"type": "string",
"description": "the channel kind"
},
"identity": {
"type": "string",
"description": "the account"
},
"display": {
"type": "string",
"description": "the name shown"
},
"verified": {
"type": "string",
"description": "how the router knew"
}
},
"required": [
"who",
"kind",
"identity",
"verified"
]
},
"Condition": {
"type": "object",
"properties": {
"v": {
"type": "integer",
"description": "the form's version, 1"
},
"key": {
"type": "string",
"description": "the condition's key"
},
"severity": {
"type": "string",
"description": "urgent or warning"
},
"subject": {
"type": "object",
"properties": {
"scope": {
"type": "string",
"description": "scope"
},
"id": {
"type": "string",
"description": "id"
},
"machine": {
"type": "string",
"description": "machine"
}
},
"required": [
"scope",
"id"
]
},
"state": {
"type": "string",
"description": "raised or cleared"
},
"summary": {
"type": "string",
"description": "one line"
},
"headline": {
"type": "string",
"description": "plain words"
},
"needs": {
"type": "string",
"description": "what it needs"
},
"explanation": {
"type": "string",
"description": "plain words"
},
"resolved": {
"type": "string",
"description": "the line said when it is over"
},
"actions": {
"type": "array",
"items": {
"type": "object",
"properties": {
"label": {
"type": "string",
"description": "label"
},
"verb": {
"type": "string",
"description": "verb"
},
"machine": {
"type": "string",
"description": "machine"
},
"arguments": {
"type": "object",
"additionalProperties": {
"type": "string"
}
},
"level": {
"type": "string",
"description": "level"
}
},
"required": [
"label",
"verb"
]
},
"description": "the answers it offers"
}
},
"required": [
"v",
"key",
"subject",
"state"
]
}
}
}
+248 -108
View File
@@ -1,17 +1,21 @@
// Package seatlist is the runtime's reader of the seat list (novox/hq ADR 0635 §1, §3): the seat definitions the
// indexer keeps on the bus, asked once through the owner's tool and followed on its event `seat-definition-changed`.
// The manifest validator, the node-engine, the tool runner, claude-code, the grants part and every other caller of
// `catalogue` get their seats here and give them to `catalogue` as an input; mesh-sdk compiles no seat list and imports
// no contracts module.
// Package seatlist is the runtime's reader of the seat list (novox/hq ADR 0635 §1, ADR 0636 §1, §3, §4): the seat
// definitions the seat mesh-catalog keeps, read through its verb `seat-definitions` and followed on its events
// `seat-definition-changed` and `seat-definition-removed`. The manifest validator, the node-engine, the tool runner,
// claude-code, the grants part and every other caller of `catalogue` get their seats here and give them to `catalogue`
// as an input; mesh-sdk compiles no seat list and imports no contracts module.
//
// **What it asks and hears.** The owner's tool Tool, with no seat for every trunk definition (the answer's shape is
// Listing) and with one seat for that seat's (the answer is the owner's own, read as Answer); the owner's event
// ChangedEvent, whose body names the seat and its source (Changed). Only trunk definitions are held: a pull request's are
// the indexer's to judge. Nothing polls and nothing waits on a timer: a reader that could not ask says so in its
// Status and reads again on the next event or on Reload, which the host module offers as a tool (steering it).
// **What it asks and hears.** The seat's verb with no seat (every trunk definition's name and digest, Listing) and with
// one seat (that definition, Answer); the seat's two events, whose bodies name the seat (Changed). It calls the seat,
// never the module that holds it, and only trunk definitions are held: a pull request's are the indexer's to judge.
// Nothing polls and nothing waits on a timer: a reader that could not ask says so in its Status and reads again on the
// next event or on Reload, which the host offers as a tool (Tools).
//
// **What it names.** The tool's and the event's names, which ADR 0635 §1 gives the owner. It names no module and no
// seat: the owner's module is the caller's (Config.Owner), the one its manifest's `invokes` lists.
// **A reader that has not heard acts on nothing new** (ADR 0636 §1): MayAct says what a host may start, compose, grant
// or judge meanwhile, which is only the modules whose seats the bootstrapped definitions name (ADR 0636 §2). It keeps
// running what it runs; a missing or removed definition only refuses a new claim, invoke or grant of that seat (Has).
//
// **What it names.** The one seat that serves the list (Seat and Verb), which ADR 0636 §4 fixes as the runtime's own
// way in; no module, no other seat.
package seatlist
import (
@@ -22,43 +26,56 @@ import (
"strings"
"sync"
stdio "git.novox.be/novox/mesh-sdk/go"
"git.novox.be/novox/mesh-sdk/go/catalogue"
"git.novox.be/novox/mesh-sdk/go/seatdef"
)
const (
// Tool is the owner's tool that answers a seat definition.
Tool = "indexer_seat_definition"
// ChangedEvent is the owner's event that says a definition moved.
// Seat is the seat that serves the seat list (ADR 0636 §4); the bootstrapped definitions name it (ADR 0636 §2).
Seat = "mesh-catalog"
// Verb is its verb: with no seat, every trunk seat with its digest; with a seat, that definition.
Verb = "seat-definitions"
// ChangedEvent and RemovedEvent are its events: a trunk's definition moved, or went.
ChangedEvent = "seat-definition-changed"
RemovedEvent = "seat-definition-removed"
// SourceTrunk is the only source this reader holds.
SourceTrunk = "trunk"
// SourceBootstrap is what a definition is held from before the seat has named it.
SourceBootstrap = "bootstrap"
)
// Bus is what the reader needs of its host: a call of a tool by its key (`<module>.<tool>`) and a subscription to an
// event pattern. StdioBus is the one a bundle has; the node-engine and the tool runner give theirs.
// Key is the address a reader asks (`seat:<seat>.<verb>`, as stdio.Ask takes it); a host's manifest `invokes` lists the
// verb. Pattern is what it subscribes to for an event (`<seat>.<event>`), which the host's manifest `consumes`.
func Key() string { return "seat:" + Seat + "." + Verb }
// Pattern is the subscription of one of the seat's events.
func Pattern(event string) string { return Seat + "." + event }
// Bus is what the reader needs of its host: a call by its address and a subscription to an event pattern. StdioBus is
// the one a bundle has; the node-engine and the tool runner give theirs.
type Bus interface {
Ask(key string, body any) (json.RawMessage, error)
Subscribe(pattern string, handle func(key string, body json.RawMessage) error) error
}
// Entry is one definition as the owner answers it: the seat, its source, the digest of the file, and the definition
// resolved with the parts it includes. It is the owner's SeatDefinitionEntry, read here by the fields it needs.
// Entry is one definition as the seat answers it: the seat, its source, the digest of the file, and, in an answer for one
// seat, the definition resolved with the parts it includes.
type Entry struct {
Seat string `json:"seat"`
Source string `json:"source"`
Source string `json:"source,omitempty"`
Digest string `json:"digest,omitempty"`
Definition json.RawMessage `json:"definition,omitempty"`
Unparsed string `json:"unparsed,omitempty"`
}
// Listing is the owner's answer to the tool with no seat: every trunk definition and the revision of its state.
// Listing is the seat's answer with no seat: every trunk seat with its digest, and the revision of its state.
type Listing struct {
Revision uint64 `json:"revision"`
Definitions []Entry `json:"definitions"`
Revision uint64 `json:"revision"`
Seats []Entry `json:"seats"`
}
// Answer is the owner's answer to the tool with one seat.
// Answer is the seat's answer with one seat.
type Answer struct {
Seat string `json:"seat"`
Found bool `json:"found"`
@@ -66,20 +83,18 @@ type Answer struct {
Revision uint64 `json:"revision"`
}
// Changed is the event's body.
// Changed is an event's body.
type Changed struct {
Seat string `json:"seat"`
Source string `json:"source"`
Digest string `json:"digest"`
Source string `json:"source,omitempty"`
Digest string `json:"digest,omitempty"`
}
// Config is what a host gives Start.
type Config struct {
// Owner is the module that keeps the definitions, as the host's manifest `invokes` names it.
Owner string
// Bus is the host's way to the owner; StdioBus() for a bundle.
// Bus is the host's way to the seat; StdioBus() for a bundle.
Bus Bus
// Bootstrap are the definitions known before the owner is heard (ADR 0635 §3); Bootstrapped() is the foundation's.
// Bootstrap are the definitions known before the seat is heard (ADR 0636 §2); Bootstrapped() is the mesh's.
Bootstrap []seatdef.Definition
// OnChange, when set, is called after the held set changed, with the seats in reading order. It runs on the
// goroutine that heard the change: it returns quickly.
@@ -90,46 +105,69 @@ type Config struct {
type Reader struct {
cfg Config
mu sync.Mutex
defs map[string]seatdef.Definition // by seat
boot map[string]bool // seats held from the bootstrap, not yet from the owner
// what the last ask of the owner came to (Status)
mu sync.Mutex
defs map[string]seatdef.Definition // by seat
digests map[string]string // by seat: the digest of the trunk file held
boot map[string]bool // seats held from the bootstrap, not yet from the seat
refused map[string]string // seats whose trunk definition cannot be parsed, with why: the one that stands stays
// what the last ask of the seat came to (Status)
spoken bool
revision uint64
lastErr string
}
// Status is what the reader holds and what it waits for (ADR 0635 §3), for the host's health and its tool: Heard says
// the owner has answered, Waiting are the seats still held from the bootstrap alone (the owner has not named them),
// LastError is why the last ask failed, and Revision is the owner's state revision of the last listing.
// Held is one definition a reader holds: its seat, where from, and the digest of the trunk file it came from.
type Held struct {
Seat string `json:"seat"`
Source string `json:"source"`
Digest string `json:"digest,omitempty"`
}
// Status is what the reader holds and what it waits for (ADR 0636 §1, §7), for the host's health and its tool: Heard says
// the seat has answered, Waiting are the seats still held from the bootstrap alone (the seat has not named them), Refused
// are the seats whose trunk definition did not parse (the one held stands), LastError is why the last ask failed, and
// Revision is the seat's state revision of the last listing.
type Status struct {
Heard bool `json:"heard"`
Held int `json:"held"`
Waiting []string `json:"waiting,omitempty"`
LastError string `json:"last-error,omitempty"`
Revision uint64 `json:"revision,omitempty"`
Heard bool `json:"heard"`
Held []Held `json:"held"`
Waiting []string `json:"waiting,omitempty"`
Refused map[string]string `json:"refused,omitempty"`
LastError string `json:"last-error,omitempty"`
Revision uint64 `json:"revision,omitempty"`
}
// Status is the reader's state of mind.
func (r *Reader) Status() Status {
r.mu.Lock()
defer r.mu.Unlock()
st := Status{Heard: r.spoken, Held: len(r.defs), LastError: r.lastErr, Revision: r.revision}
for seat := range r.boot {
st.Waiting = append(st.Waiting, seat)
st := Status{Heard: r.spoken, LastError: r.lastErr, Revision: r.revision, Held: []Held{}}
for seat := range r.defs {
h := Held{Seat: seat, Source: SourceTrunk, Digest: r.digests[seat]}
if r.boot[seat] {
h.Source = SourceBootstrap
st.Waiting = append(st.Waiting, seat)
}
st.Held = append(st.Held, h)
}
sort.Slice(st.Held, func(i, j int) bool { return st.Held[i].Seat < st.Held[j].Seat })
sort.Strings(st.Waiting)
if len(r.refused) > 0 {
st.Refused = map[string]string{}
for k, v := range r.refused {
st.Refused[k] = v
}
}
return st
}
// Health is Status as the one line a module's health says: empty when the owner has been heard and nothing waits.
// Health is Status as the one line a module's health says: empty when the seat has been heard and nothing waits.
func (st Status) Health() string {
switch {
case !st.Heard:
return fmt.Sprintf("the seat list is held from the foundation's bootstrap (%d seats): the definitions' owner has not answered%s",
st.Held, withError(st.LastError))
return fmt.Sprintf("the seat list is held from the bootstrap (%d seats) and nothing new is acted on: the seat %s has not answered%s",
len(st.Held), Seat, withError(st.LastError))
case len(st.Waiting) > 0:
return fmt.Sprintf("%d seats are held from the bootstrap alone, the owner names none of them: %s", len(st.Waiting), strings.Join(st.Waiting, ", "))
return fmt.Sprintf("%d seats are held from the bootstrap alone, the seat %s names none of them: %s", len(st.Waiting), Seat, strings.Join(st.Waiting, ", "))
}
return ""
}
@@ -141,24 +179,23 @@ func withError(e string) string {
return " (" + e + ")"
}
// Start returns a reader holding the bootstrap, asks the owner for every definition and follows its event. A first
// ask the owner did not answer is no failure: the reader holds the bootstrap and reads again on the owner's next
// event or on Reload. Start returns the subscription's error alone, which the host may ask again by calling Start again
// on a new reader.
// Start returns a reader holding the bootstrap, asks the seat for every definition and follows its two events. A first
// ask the seat did not answer is no failure: the reader holds the bootstrap, acts on nothing new (MayAct) and reads again
// on the seat's next event or on Reload. Start returns the subscription's error alone, which the host may ask again by
// calling Start again on a new reader.
func Start(cfg Config) (*Reader, error) {
if cfg.Bus == nil {
return nil, errors.New("seatlist: no bus to read the seat list through")
}
if cfg.Owner == "" || strings.ContainsAny(cfg.Owner, ". \t") {
return nil, fmt.Errorf("seatlist: the owner %q is not a module name", cfg.Owner)
}
r := &Reader{cfg: cfg, defs: map[string]seatdef.Definition{}, boot: map[string]bool{}}
r := &Reader{cfg: cfg, defs: map[string]seatdef.Definition{}, digests: map[string]string{}, boot: map[string]bool{}, refused: map[string]string{}}
for _, d := range cfg.Bootstrap {
r.defs[d.Seat] = d
r.boot[d.Seat] = true
}
if err := cfg.Bus.Subscribe("*."+ChangedEvent, r.heard); err != nil {
return r, fmt.Errorf("seatlist: following %s: %w", ChangedEvent, err)
for _, ev := range []string{ChangedEvent, RemovedEvent} {
if err := cfg.Bus.Subscribe(Pattern(ev), r.heard); err != nil {
return r, fmt.Errorf("seatlist: following %s: %w", ev, err)
}
}
_ = r.Reload()
return r, nil
@@ -182,6 +219,35 @@ func (r *Reader) Definitions() map[string]seatdef.Definition {
return out
}
// Has says whether the reader holds a definition of the seat: a claim, invoke or grant of a seat it does not hold is
// refused, and nothing already running is stopped for it (ADR 0636 §3).
func (r *Reader) Has(seat string) bool {
r.mu.Lock()
defer r.mu.Unlock()
_, ok := r.defs[seat]
return ok
}
// MayAct says whether a host may start, compose, grant or judge a module that claims, invokes or provides the given seats
// (ADR 0636 §1): once the seat list has been read, any; before, only a module whose seats are all bootstrapped
// ones (ADR 0636 §2), and at least one. It changes nothing a module already runs.
func (r *Reader) MayAct(seats ...string) bool {
r.mu.Lock()
defer r.mu.Unlock()
if r.spoken {
return true
}
if len(seats) == 0 {
return false
}
for _, s := range seats {
if !r.boot[s] {
return false
}
}
return true
}
func (r *Reader) seatsLocked() []catalogue.Seat {
ds := make([]seatdef.Definition, 0, len(r.defs))
for _, d := range r.defs {
@@ -190,80 +256,112 @@ func (r *Reader) seatsLocked() []catalogue.Seat {
return catalogue.SeatsFromDefinitions(ds)
}
// Reload asks the owner for every trunk definition and holds what it answers, a seat the owner holds replacing the
// bootstrap's. An answer that cannot be read is returned and changes nothing.
// Reload asks the seat for its trunk seats and digests and then for each definition it holds a different digest of,
// and drops the trunk seats it no longer lists; a bootstrapped seat the seat does not list stays and waits. An answer that
// cannot be read is returned and changes nothing; one definition that does not parse leaves the one held standing and is
// said in Status.
func (r *Reader) Reload() error {
raw, err := r.cfg.Bus.Ask(r.cfg.Owner+"."+Tool, map[string]any{})
raw, err := r.cfg.Bus.Ask(Key(), map[string]any{})
if err != nil {
return r.failed(fmt.Errorf("seatlist: asking %s.%s: %w", r.cfg.Owner, Tool, err))
return r.failed(fmt.Errorf("seatlist: asking %s: %w", Key(), err))
}
var l Listing
if err := json.Unmarshal(raw, &l); err != nil {
return r.failed(fmt.Errorf("seatlist: %s.%s answered what is not a listing: %w", r.cfg.Owner, Tool, err))
return r.failed(fmt.Errorf("seatlist: %s answered what is not a listing: %w", Key(), err))
}
parsed := map[string]seatdef.Definition{}
for _, e := range l.Definitions {
if e.Source != SourceTrunk || e.Unparsed != "" || len(e.Definition) == 0 {
listed := map[string]bool{}
var first error
for _, e := range l.Seats {
if e.Seat == "" || (e.Source != "" && e.Source != SourceTrunk) {
continue
}
d, err := seatdef.Parse(e.Definition)
if err != nil {
return r.failed(fmt.Errorf("seatlist: the definition of %s does not parse: %w", e.Seat, err))
listed[e.Seat] = true
r.mu.Lock()
same := !r.boot[e.Seat] && e.Digest != "" && r.digests[e.Seat] == e.Digest
r.mu.Unlock()
if same {
continue
}
if err := r.fetch(e.Seat); err != nil && first == nil {
first = err
}
parsed[d.Seat] = d
}
r.mu.Lock()
r.spoken, r.revision, r.lastErr = true, l.Revision, ""
for seat := range r.defs {
if !r.boot[seat] {
delete(r.defs, seat)
}
r.spoken, r.revision = true, l.Revision
if first == nil {
r.lastErr = ""
}
for seat, d := range parsed {
r.defs[seat] = d
delete(r.boot, seat)
for seat := range r.defs {
if !r.boot[seat] && !listed[seat] {
r.dropLocked(seat)
}
}
r.mu.Unlock()
r.changed()
if first != nil {
return r.failed(first)
}
return nil
}
// heard takes the owner's event: a trunk definition moved, so that seat is asked again. An error is returned so the
// bus offers the event again.
func (r *Reader) heard(_ string, body json.RawMessage) error {
// fetch asks the seat for one definition and holds it, or drops it when the seat holds none.
func (r *Reader) fetch(seat string) error {
raw, err := r.cfg.Bus.Ask(Key(), map[string]any{"seat": seat})
if err != nil {
return fmt.Errorf("seatlist: asking %s for %s: %w", Key(), seat, err)
}
var a Answer
if err := json.Unmarshal(raw, &a); err != nil {
return fmt.Errorf("seatlist: %s answered for %s what is not an answer: %w", Key(), seat, err)
}
r.mu.Lock()
defer r.mu.Unlock()
switch {
case !a.Found || a.Entry == nil || (a.Entry.Source != "" && a.Entry.Source != SourceTrunk):
r.dropLocked(seat)
case a.Entry.Unparsed != "" || len(a.Entry.Definition) == 0:
r.refused[seat] = a.Entry.Unparsed // the definition that stands stays: a trunk's that does not parse is never written over it
default:
d, err := seatdef.Parse(a.Entry.Definition)
if err != nil {
r.refused[seat] = err.Error()
return nil
}
r.defs[seat], r.digests[seat] = d, a.Entry.Digest
delete(r.boot, seat)
delete(r.refused, seat)
}
return nil
}
// dropLocked forgets a seat: its definition is gone, so a new claim of it is refused (ADR 0636 §3).
func (r *Reader) dropLocked(seat string) {
delete(r.defs, seat)
delete(r.digests, seat)
delete(r.boot, seat)
delete(r.refused, seat)
}
// heard takes one of the seat's events: a trunk definition moved, so that seat is asked again, or went, so it is dropped.
// An error is returned so the bus offers the event again.
func (r *Reader) heard(key string, body json.RawMessage) error {
var c Changed
if err := json.Unmarshal(body, &c); err != nil || c.Seat == "" {
return nil
}
if c.Source != SourceTrunk {
if c.Source != "" && c.Source != SourceTrunk {
return nil
}
raw, err := r.cfg.Bus.Ask(r.cfg.Owner+"."+Tool, map[string]any{"seat": c.Seat})
if err != nil {
if strings.HasSuffix(key, "."+RemovedEvent) {
r.mu.Lock()
r.dropLocked(c.Seat)
r.mu.Unlock()
r.changed()
return nil
}
if err := r.fetch(c.Seat); err != nil {
return r.failed(err)
}
var a Answer
if err := json.Unmarshal(raw, &a); err != nil {
return err
}
r.mu.Lock()
switch {
case !a.Found || a.Entry == nil || a.Entry.Source != SourceTrunk:
if !r.boot[c.Seat] {
delete(r.defs, c.Seat)
}
case a.Entry.Unparsed != "" || len(a.Entry.Definition) == 0:
// the definition that stands stays: a trunk's that does not parse is never written over it
default:
d, err := seatdef.Parse(a.Entry.Definition)
if err != nil {
r.mu.Unlock()
return fmt.Errorf("seatlist: the definition of %s does not parse: %w", c.Seat, err)
}
r.defs[c.Seat] = d
delete(r.boot, c.Seat)
}
r.mu.Unlock()
r.changed()
return nil
}
@@ -280,3 +378,45 @@ func (r *Reader) changed() {
r.cfg.OnChange(r.Seats())
}
}
// Tools are the reader's two runtime tools (ADR 0636 §7), for a host's bundle: read, which says what the reader holds
// (every definition, its source and digest) and what it waits for; and again, which makes it read the list now, and
// answers only to the callers the host's list names (the operator and the healers). The host names the tools and the list;
// again records nothing but the read it asked.
func Tools(r *Reader, read, again string, callers stdio.Callers) []stdio.Tool {
return []stdio.Tool{
{Name: read,
Description: "The seat list this part holds (novox/hq ADR 0636): each seat's definition with its source (trunk or bootstrap) and " +
"the digest of the file it came from, the seats still held from the bootstrap alone, definitions refused because they " +
"do not parse, whether the seat " + Seat + " has answered and why not.",
Input: map[string]any{"type": "object", "properties": map[string]any{}},
Run: func(map[string]any) (any, error) {
return map[string]any{"status": r.Status(), "health": r.Status().Health()}, nil
}},
{Name: again,
Description: "Read the seat list again now, from the seat " + Seat + " (a hand act for a part that waits or holds a stale definition), " +
"and answer what it holds afterwards. Answered only to the operator and the healers.",
Input: map[string]any{"type": "object", "properties": map[string]any{}},
Run: func(args map[string]any) (any, error) {
if err := Refusal(again, callers, stdio.CallerOf(args)); err != nil {
return nil, err
}
err := r.Reload()
st := r.Status()
out := map[string]any{"status": st, "health": st.Health()}
if err != nil {
out["error"] = err.Error()
}
return out, nil
}},
}
}
// Refusal is why caller may not make the reader read again, or nil: the caller's proof, never an argument, against the
// list of the operator and the healers.
func Refusal(tool string, callers stdio.Callers, caller string) error {
if callers.Allows(caller) {
return nil
}
return fmt.Errorf("%s is refused to %q: it answers only %s (novox/hq ADR 0636 §7)", tool, caller, strings.Join(callers, ", "))
}
+117 -54
View File
@@ -3,50 +3,57 @@ package seatlist
import (
"encoding/json"
"errors"
"fmt"
"strings"
"testing"
stdio "git.novox.be/novox/mesh-sdk/go"
"git.novox.be/novox/mesh-sdk/go/catalogue"
"git.novox.be/novox/mesh-sdk/go/seatdef"
)
// fakeOwner is the definitions' owner: the tool answers from its table and the event is delivered by the test.
// fakeOwner is the seat mesh-catalog: its verb answers from its table and the events are delivered by the test.
type fakeOwner struct {
table map[string]string // seat -> definition JSON, trunk
down bool
asked []string
handler func(string, json.RawMessage) error
pattern string
table map[string]string // seat -> definition JSON, trunk
down bool
asked []string // "key seat"
handlers map[string]func(string, json.RawMessage) error
}
func (f *fakeOwner) Ask(key string, body any) (json.RawMessage, error) {
f.asked = append(f.asked, key)
args, _ := body.(map[string]any)
seat, _ := args["seat"].(string)
f.asked = append(f.asked, key+" "+seat)
if f.down {
return nil, errors.New("no answer")
}
args, _ := body.(map[string]any)
if seat, _ := args["seat"].(string); seat != "" {
if seat != "" {
raw, ok := f.table[seat]
if !ok {
return json.Marshal(Answer{Seat: seat})
}
return json.Marshal(Answer{Seat: seat, Found: true, Entry: &Entry{Seat: seat, Source: SourceTrunk, Definition: json.RawMessage(raw)}})
return json.Marshal(Answer{Seat: seat, Found: true, Entry: &Entry{Seat: seat, Source: SourceTrunk, Digest: digest(raw), Definition: json.RawMessage(raw)}})
}
l := Listing{Revision: 7}
for seat, raw := range f.table {
l.Definitions = append(l.Definitions, Entry{Seat: seat, Source: SourceTrunk, Definition: json.RawMessage(raw)})
l.Seats = append(l.Seats, Entry{Seat: seat, Source: SourceTrunk, Digest: digest(raw)})
}
return json.Marshal(l)
}
func digest(raw string) string { return fmt.Sprintf("%d", len(raw)) }
func (f *fakeOwner) Subscribe(pattern string, h func(string, json.RawMessage) error) error {
f.pattern, f.handler = pattern, h
if f.handlers == nil {
f.handlers = map[string]func(string, json.RawMessage) error{}
}
f.handlers[pattern] = h
return nil
}
func (f *fakeOwner) say(seat, source string) error {
func (f *fakeOwner) say(event, seat, source string) error {
b, _ := json.Marshal(Changed{Seat: seat, Source: source, Digest: "d"})
return f.handler("mesh-owner."+ChangedEvent, b)
return f.handlers[Pattern(event)](Pattern(event), b)
}
func def(seat, scope, verb string) string {
@@ -62,41 +69,51 @@ func names(r *Reader) string {
return strings.Join(out, ",")
}
// ADR 0635 §1, §3: the seat list comes from the owner on the bus, starting from the bootstrap, and follows its event.
func TestTheSeatListIsReadFromTheOwnerAndFollowsItsEvent(t *testing.T) {
// ADR 0636 §1, §3, §4: the seat list is read through the seat's verb, starting from the bootstrap, and follows its two events.
func TestTheSeatListIsReadFromTheSeatAndFollowsItsEvents(t *testing.T) {
boot, err := seatdef.Parse([]byte(def("boot-seat", "mesh", "go")))
if err != nil {
t.Fatal(err)
}
owner := &fakeOwner{down: true, table: map[string]string{
"node-b": def("node-b", "node", "put"), "mesh-a": def("mesh-a", "mesh", "get"), "boot-seat": def("boot-seat", "mesh", "other")}}
var told [][]catalogue.Seat
r, err := Start(Config{Owner: "mesh-owner", Bus: owner, Bootstrap: []seatdef.Definition{boot},
OnChange: func(s []catalogue.Seat) { told = append(told, s) }})
var told int
r, err := Start(Config{Bus: owner, Bootstrap: []seatdef.Definition{boot}, OnChange: func([]catalogue.Seat) { told++ }})
if err != nil {
t.Fatal(err)
}
if owner.pattern != "*."+ChangedEvent {
t.Fatalf("subscribed to %q", owner.pattern)
if owner.handlers[Pattern(ChangedEvent)] == nil || owner.handlers[Pattern(RemovedEvent)] == nil || Pattern(ChangedEvent) != "mesh-catalog.seat-definition-changed" {
t.Fatalf("subscribed to %v", owner.handlers)
}
if owner.asked[0] != "seat:mesh-catalog.seat-definitions " {
t.Fatalf("first asked %q: the seat's verb, never a module", owner.asked[0])
}
steps := []struct {
name string
act func() error
want string
err string
asks int // asks the step made
}{
{"the owner has not answered: the bootstrap stands", func() error { return nil }, "boot-seat", ""},
{"an event while the owner is down is offered again", func() error { return owner.say("mesh-a", SourceTrunk) }, "boot-seat", "no answer"},
{"the owner answers: its seats in reading order, mesh seats first, the owner's definition replacing the bootstrap's",
func() error { owner.down = false; return r.Reload() }, "boot-seat,mesh-a,node-b", ""},
{"the seat has not answered: the bootstrap stands", func() error { return nil }, "boot-seat", "", 0},
{"an event while the seat is down is offered again", func() error { return owner.say(ChangedEvent, "mesh-a", SourceTrunk) }, "boot-seat", "no answer", 1},
{"the seat answers: its seats in reading order, its definition replacing the bootstrap's, each fetched once",
func() error { owner.down = false; return r.Reload() }, "boot-seat,mesh-a,node-b", "", 4},
{"a read again fetches nothing whose digest it holds", func() error { return r.Reload() }, "boot-seat,mesh-a,node-b", "", 1},
{"a moved definition is asked again", func() error {
owner.table["mesh-a"] = def("mesh-a", "mesh", "renamed")
return owner.say("mesh-a", SourceTrunk)
}, "boot-seat,mesh-a,node-b", ""},
{"a seat the owner holds no more goes", func() error { delete(owner.table, "node-b"); return owner.say("node-b", SourceTrunk) }, "boot-seat,mesh-a", ""},
{"a pull request's definition is not held", func() error { owner.asked = nil; return owner.say("mesh-z", "pull") }, "boot-seat,mesh-a", ""},
return owner.say(ChangedEvent, "mesh-a", SourceTrunk)
}, "boot-seat,mesh-a,node-b", "", 1},
{"a definition that does not parse leaves the one that stands", func() error {
owner.table["mesh-a"] = `{"format":1,"seat":"mesh-a","verbs":"no"}`
return owner.say(ChangedEvent, "mesh-a", SourceTrunk)
}, "boot-seat,mesh-a,node-b", "", 1},
{"a seat the seat removes goes", func() error { delete(owner.table, "node-b"); return owner.say(RemovedEvent, "node-b", SourceTrunk) }, "boot-seat,mesh-a", "", 0},
{"a pull request's definition is not held and not asked", func() error { return owner.say(ChangedEvent, "mesh-z", "pull") }, "boot-seat,mesh-a", "", 0},
{"a listing that drops a trunk seat drops it", func() error { delete(owner.table, "mesh-a"); return r.Reload() }, "boot-seat", "", 1},
}
for _, s := range steps {
before := len(owner.asked)
err := s.act()
if (err == nil) != (s.err == "") || (err != nil && !strings.Contains(err.Error(), s.err)) {
t.Fatalf("%s: error %v, want %q", s.name, err, s.err)
@@ -104,47 +121,63 @@ func TestTheSeatListIsReadFromTheOwnerAndFollowsItsEvent(t *testing.T) {
if got := names(r); got != s.want {
t.Fatalf("%s: seats %q, want %q", s.name, got, s.want)
}
if got := len(owner.asked) - before; got != s.asks {
t.Fatalf("%s: asked %d times (%v), want %d", s.name, got, owner.asked[before:], s.asks)
}
}
if len(owner.asked) != 0 {
t.Fatalf("a pull request's event asked the owner: %v", owner.asked)
if st := r.Status(); len(st.Refused) != 0 || !r.Has("boot-seat") || r.Has("mesh-a") {
t.Fatalf("status %+v, has mesh-a %v", st, r.Has("mesh-a"))
}
if v := r.Seats()[1].Serves[0]; v.Name != "renamed" || v.Input["type"] != "object" {
t.Fatalf("the moved definition's verb is %+v, want renamed ", v)
}
if len(told) != 3 {
t.Fatalf("OnChange was told %d times", len(told))
if told < 5 {
t.Fatalf("OnChange was told %d times", told)
}
}
func TestStartRefusesWhatCannotRead(t *testing.T) {
for name, cfg := range map[string]Config{
"no bus": {Owner: "m"},
"an owner with a dot": {Owner: "m.tool", Bus: &fakeOwner{}},
"no owner": {Bus: &fakeOwner{}},
} {
if _, err := Start(cfg); err == nil {
t.Fatalf("%s: started", name)
}
if _, err := Start(Config{}); err == nil {
t.Fatal("started without a bus")
}
}
// ADR 0635 §3: before the owner is heard the reader holds the foundation's definitions and says it waits.
func TestTheBootstrapParsesAndIsHeldUntilTheOwnerAnswers(t *testing.T) {
// ADR 0636 §1, §2: before the seat is heard the reader holds the bootstrapped definitions, says it waits and lets a host act
// on the modules those definitions name only.
func TestTheBootstrapIsHeldAndNothingNewIsActedOnUntilTheSeatAnswers(t *testing.T) {
boot := Bootstrapped()
if len(boot) == 0 {
t.Fatal("no bootstrapped definition")
held := map[string]bool{}
for _, d := range boot {
held[d.Seat] = true
}
for _, seat := range []string{"mesh-catalog", "mesh-conditions", "operator-channel", "node-engine", "mesh-broker", "mesh-config"} {
if !held[seat] {
t.Fatalf("the bootstrap lacks %s (ADR 0636 §2)", seat)
}
}
owner := &fakeOwner{down: true}
r, err := Start(Config{Owner: "mesh-owner", Bus: owner, Bootstrap: boot})
r, err := Start(Config{Bus: owner, Bootstrap: boot})
if err != nil {
t.Fatal(err)
}
if got := len(r.Seats()); got != len(boot) {
t.Fatalf("the reader holds %d seats, the bootstrap has %d", got, len(boot))
if got := len(r.Definitions()); got != len(boot) {
t.Fatalf("the reader holds %d definitions, the bootstrap has %d", got, len(boot))
}
st := r.Status()
if st.Heard || len(st.Waiting) != len(boot) || st.LastError == "" {
t.Fatalf("status %+v: want the owner not heard, every bootstrapped seat waiting and the error said", st)
if st.Heard || len(st.Waiting) != len(boot) || st.LastError == "" || st.Health() == "" {
t.Fatalf("status %+v: want the seat not heard, every bootstrapped seat waiting, the error and the health said", st)
}
acts := []struct {
seats []string
want bool
}{
{[]string{"node-engine"}, true},
{[]string{"mesh-catalog", "mesh-config"}, true},
{[]string{"node-engine", "node-files"}, false},
{[]string{"node-files"}, false},
{nil, false},
}
for _, a := range acts {
if got := r.MayAct(a.seats...); got != a.want {
t.Fatalf("waiting: MayAct(%v) = %v, want %v", a.seats, got, a.want)
}
}
owner.down = false
owner.table = map[string]string{"node-b": def("node-b", "node", "put")}
@@ -152,6 +185,36 @@ func TestTheBootstrapParsesAndIsHeldUntilTheOwnerAnswers(t *testing.T) {
t.Fatal(err)
}
if st := r.Status(); !st.Heard || len(st.Waiting) != len(boot) || st.LastError != "" || st.Revision != 7 {
t.Fatalf("status after the owner answered %+v: want heard, the bootstrap's seats the owner does not hold still waiting, no error, revision 7", st)
t.Fatalf("status after the seat answered %+v: want heard, the bootstrap's seats it does not hold still waiting, no error, revision 7", st)
}
if !r.MayAct("node-files") || !r.MayAct() {
t.Fatal("after the seat answered, a host acts on any module")
}
}
// ADR 0636 §7: the two tools read what is held and read again now, the second to its callers only.
func TestTheTwoToolsReadTheHeldListAndReadItAgainForTheirCallers(t *testing.T) {
owner := &fakeOwner{down: true, table: map[string]string{"node-b": def("node-b", "node", "put")}}
r, err := Start(Config{Bus: owner, Bootstrap: Bootstrapped()})
if err != nil {
t.Fatal(err)
}
callers := stdio.Callers{"person.jo", "mesh-healers"}
tools := Tools(r, "seat_list", "seat_list_again", callers)
out, err := tools[0].Run(map[string]any{})
if err != nil || out.(map[string]any)["health"] == "" || !strings.Contains(fmt.Sprint(out.(map[string]any)["status"]), "bootstrap") {
t.Fatalf("read: %v %v", out, err)
}
for caller, allowed := range map[string]bool{"person.jo": true, "ace.mesh-healers": true, "ace.mesh-other": false, "person.al": false, stdio.Unproven: false} {
if err := Refusal("seat_list_again", callers, caller); (err == nil) != allowed {
t.Fatalf("%s: %v, allowed %v", caller, err, allowed)
}
}
if _, err := tools[1].Run(map[string]any{}); err == nil || !strings.Contains(err.Error(), "refused") || len(owner.asked) != 1 {
t.Fatalf("a call without a proven caller read the list again: %v, asks %v", err, owner.asked)
}
owner.down = false
if err := r.Reload(); err != nil || !r.Status().Heard || len(r.Status().Held) != len(Bootstrapped())+1 {
t.Fatalf("again: %v %+v", err, r.Status())
}
}
+1
View File
@@ -48,3 +48,4 @@ internal/beside/beside.go: mesh-catalog
overlay/overlay.go: the-private-network
provisions/provisions.go: mesh-provisioner
provisions/provisions.go: mesh-vault
seatlist/seatlist.go: mesh-catalog