COND7 (the condition's owner): a condition carries the finding's owner and waiting-on; a failed head is the operator's two hours after its since

The keeper copies owner and waiting-on from the finding at raising (and into a condition kept from before G at its next
change), hands a pipeline-failed to operator-channel after the setting owner-handover-seconds (default two hours, timed
from the finding's since as a bound with its own time to live), says each change as the change owner, and the tool
conditions_owner sets an owner now, written in the hand-act log.

Builds: design 47 G §540, §685, §678, §557, A-58 (seed-waits: the indexer sets the finding's waiting-on; no kind rule here)

Claude-Session: https://claude.ai/code/session_01NcbgfGKaxw984XyAPfLm4h
This commit is contained in:
2026-11-11 22:48:54 +01:00
parent 469763547c
commit b15c511528
8 changed files with 237 additions and 6 deletions
@@ -235,6 +235,9 @@ func (k *Keeper) BoundPassed(key string) error {
if strings.HasPrefix(key, askedPrefix) {
return k.AskPassed(key)
}
if strings.HasPrefix(key, ownerPrefix) {
return k.OwnerPassed(key)
}
node, module := splitNodeModule(key)
k.mu.Lock()
k.init()
@@ -12,6 +12,7 @@ import (
"encoding/json"
"errors"
"fmt"
"regexp"
"sort"
"strings"
"sync"
@@ -51,6 +52,16 @@ const (
BoundsState = "bounds"
)
// The hand-over of a failed head (design 47 G §540): the condition `pipeline-failed` is its commit's author's, and OwnerHandover
// after the finding's `since` it is the operator's, the seat the operator is asked through (a seat's name, §451).
const (
HandoverKind = "pipeline-failed"
HandoverOwner = "operator-channel"
OwnerHandover = 2 * time.Hour
ownerPrefix = "owner="
handedOverWhy = "handed to the operator after %s"
)
// HistoryKeptFor is how long a transition is kept (clause 3).
const HistoryKeptFor = 90 * 24 * time.Hour
@@ -109,6 +120,8 @@ type Keeper struct {
Bound time.Duration
// AskBound is how long an observer has to state its findings after an ask (the setting ask-bound-seconds, §557); AskedWithin when zero.
AskBound time.Duration
// Handover is how long a failed head stays its author's (the setting owner-handover-seconds, §557); OwnerHandover when zero.
Handover time.Duration
mu sync.Mutex
findings map[string]map[string]seat.Finding
@@ -366,6 +379,7 @@ func (k *Keeper) settleOnce(key string) error {
}
k.dropHeld(key, "")
next := k.derive(key, was, open, f, now)
k.armHandover(next, f, now)
events := transitions(was, next, open)
if len(events) == 0 && open && sameBody(was, next) {
return nil
@@ -382,6 +396,9 @@ func (k *Keeper) settleOnce(key string) error {
delete(k.cleared, key)
k.mu.Unlock()
for _, e := range events {
if e.Change == cc.ChangeOwner && next.Owner == HandoverOwner {
e.Why = fmt.Sprintf(handedOverWhy, k.handover())
}
e.Condition, e.At, e.Event = next, now, cc.EventFor(e.Change)
e.Show = cc.ShowKeeper(next.Key)
k.tell(e)
@@ -414,6 +431,10 @@ func (k *Keeper) derive(key string, was Condition, open bool, f *seat.Finding, n
}
k.mu.Unlock()
}
if !open || c.Owner == "" && c.WaitingOn == "" {
c.Owner, c.WaitingOn = f.Owner, f.WaitingOn
}
k.handOver(&c, *f, now)
k.overlay(&c, now)
if !open {
k.mu.Lock()
@@ -428,6 +449,54 @@ func (k *Keeper) derive(key string, was Condition, open bool, f *seat.Finding, n
return c
}
// handOver makes a failed head the operator's once its Handover has passed since the finding's `since`: only while it is still
// the author's, so a hand's choice of another owner stands (design 47 G §540).
func (k *Keeper) handOver(c *Condition, f seat.Finding, now time.Time) {
if c.Kind != HandoverKind || c.Owner == "" || c.Owner != f.Owner || c.Owner == HandoverOwner {
return
}
since := f.Since
if since.IsZero() {
since = f.At
}
if !now.Before(since.Add(k.handover())) {
c.Owner = HandoverOwner
}
}
func (k *Keeper) handover() time.Duration {
if k.Handover > 0 {
return k.Handover
}
return OwnerHandover
}
// armHandover keeps the end of a failed head's author's time as a bound with its own time to live, so that the keeper hears it
// end on the bus and settles the condition again; after a restart the same key stands, or the time has passed and derive
// hands it over at once (design 47 G §540: timed from `since`).
func (k *Keeper) armHandover(c Condition, f *seat.Finding, now time.Time) {
if f == nil || c.Kind != HandoverKind || c.Owner != f.Owner || c.Owner == HandoverOwner {
return
}
since := f.Since
if since.IsZero() {
since = f.At
}
ttl := since.Add(k.handover()).Sub(now)
if ttl < time.Second {
return
}
if err := k.Store.CreateTTL(BoundsState, ownerPrefix+c.Key, map[string]any{"key": c.Key, "until": since.Add(k.handover())},
ttl); err != nil && !errors.Is(err, stdio.ErrChanged) {
k.Logf("the hand-over of %s to the operator could not be timed: %v", c.Key, err)
}
}
// OwnerPassed is a failed head's author's time ending: the condition is settled again, which hands it over.
func (k *Keeper) OwnerPassed(bound string) error {
return k.Settle(strings.TrimPrefix(bound, ownerPrefix))
}
// fromFinding is what the condition says of its finding: its words, subject, severity and source, and its evidence
// newest first (clause 2). A healer's escalation is not undone by the finding saying a warning again.
func (k *Keeper) fromFinding(c *Condition, f seat.Finding, now time.Time) {
@@ -519,6 +588,9 @@ func transitions(was, now Condition, open bool) []Event {
case was.Silenced != nil && now.Silenced == nil:
out = append(out, Event{Change: cc.ChangeUnsilenced})
}
if was.Owner != now.Owner {
out = append(out, Event{Change: cc.ChangeOwner, Was: was.Owner})
}
if was.Needs != now.Needs || !sameActions(was.Actions, now.Actions) {
out = append(out, Event{Change: cc.ChangeWords, Was: was.Needs})
}
@@ -626,6 +698,45 @@ func (k *Keeper) Resync() (int, error) {
return len(keys), k.settleAll(keys)
}
// SetOwner is the hand act `conditions_owner`: it sets an open condition's owner now, in a form §678 gives, and says the
// change as any change of a condition (design 47 G §540, §685).
func (k *Keeper) SetOwner(key, owner, by string) (Condition, error) {
if !ownerForm.MatchString(owner) {
return Condition{}, fmt.Errorf("the owner %q is none of a session who@node, a seat's name or forge:<account> (design 47 G §678)", owner)
}
for i := 0; i < ConditionWrites; i++ {
raw, rev, found, err := k.Store.Get(ConditionsState, key)
if err != nil {
return Condition{}, err
}
if !found || string(raw) == "null" {
return Condition{}, fmt.Errorf("no condition %s is open", key)
}
c, err := decodeCondition(raw)
if err != nil {
return Condition{}, err
}
was := c.Owner
if was == owner {
return c, nil
}
now := k.Now().UTC()
c.Owner = owner
if _, err := k.Store.Update(ConditionsState, key, c.Stored(), rev); errors.Is(err, stdio.ErrChanged) {
continue
} else if err != nil {
return Condition{}, err
}
k.tell(Event{Condition: c, Event: seat.EventConditionChanged, At: now, Change: cc.ChangeOwner, Was: was,
Why: "set by " + by, Show: cc.ShowKeeper(key)})
return c, nil
}
return Condition{}, fmt.Errorf("the condition %s kept moving under the owner change", key)
}
// ownerForm is §678's three forms of an owner: who@node, a seat's name, forge:<account>.
var ownerForm = regexp.MustCompile(`^([a-z0-9][a-z0-9._-]*@[a-z0-9][a-z0-9-]*|[a-z][a-z0-9-]*|forge:[A-Za-z0-9._-]+)$`)
// Silence stops a condition's messages for a while, a hand act with why (clause 3, Limits 1): kept as its transition in
// history, naming who and why.
func (k *Keeper) Silence(key string, d time.Duration, by, why string) (Condition, error) {
@@ -64,7 +64,7 @@ func TestTheManifestSaysWhatTheCodeDoes(t *testing.T) {
}
// Clause 12: each state with the tool that reads it, each act with the tool that starts it.
readBy := map[string]string{ConditionsState: Seat + ".conditions", HistoryState: HistoryTool,
HeldState: HeldTool, BoundsState: HeldTool, ViewsState: FindingsViewsTool, "a resync": Seat + ".resync", "what it holds of observers": FindingsViewsTool, "what it reads": InputsTool}
HeldState: HeldTool, BoundsState: HeldTool, ViewsState: FindingsViewsTool, "a resync": Seat + ".resync", "an owner": OwnerTool, "what it holds of observers": FindingsViewsTool, "what it reads": InputsTool}
tools := map[string]bool{}
for _, tool := range Tools(&Keeper{}) {
tools[tool.Name] = true
@@ -60,6 +60,9 @@ type Condition struct {
Count int
Tried []Tried
Resolver string
// Owner and WaitingOn are who the condition is about and what it waits on (design 47 G §685): from the finding when
// the condition is raised, and changed only as §540 says.
Owner, WaitingOn string
// Silenced is nil when no silence is in force.
Silenced *Silence
Epoch int64
@@ -110,7 +113,7 @@ func (c Condition) Stored() seat.ConditionsByKeyValue {
Headline: c.Headline, Explanation: c.Explanation, Resolved: c.Resolved, Needs: c.Needs, Actions: c.Actions,
Evidence: c.Evidence, Source: c.Source, Raised: c.Raised, LastObserved: c.LastObserved, First: timeP(c.First),
Observations: int64(c.Observations), Count: int64(c.Count), Resolver: c.Resolver, Epoch: c.Epoch,
LooksUnread: c.LooksUnread}
LooksUnread: c.LooksUnread, Owner: c.Owner, WaitingOn: c.WaitingOn}
for _, g := range c.Gaps {
v.Gaps = append(v.Gaps, seat.ConditionsByKeyValueGapsItem{Cleared: timeP(g.Cleared), Reopened: timeP(g.Reopened)})
}
@@ -130,7 +133,7 @@ func conditionOf(v seat.ConditionsByKeyValue) Condition {
Headline: v.Headline, Explanation: v.Explanation, Resolved: v.Resolved, Needs: v.Needs, Actions: v.Actions,
Evidence: v.Evidence, Source: v.Source, Raised: v.Raised, LastObserved: v.LastObserved, First: timeOf(v.First),
Observations: int(v.Observations), Count: int(v.Count), Resolver: v.Resolver, Epoch: v.Epoch,
LooksUnread: v.LooksUnread}
LooksUnread: v.LooksUnread, Owner: v.Owner, WaitingOn: v.WaitingOn}
for _, g := range v.Gaps {
c.Gaps = append(c.Gaps, Gap{Cleared: timeOf(g.Cleared), Reopened: timeOf(g.Reopened)})
}
@@ -161,7 +164,7 @@ func (e Event) Said() seat.ConditionEvent {
Headline: s.Headline, Explanation: s.Explanation, Resolved: s.Resolved, Needs: s.Needs, Actions: s.Actions,
Evidence: s.Evidence, Source: s.Source, Raised: timeP(s.Raised), LastObserved: timeP(s.LastObserved), First: s.First,
Observations: s.Observations, Count: s.Count, Resolver: s.Resolver, Epoch: s.Epoch, LooksUnread: s.LooksUnread,
Event: e.Event, At: e.At, Change: e.Change, Was: e.Was, Why: e.Why, Cleared: e.Cleared, Show: e.Show}
Owner: s.Owner, WaitingOn: s.WaitingOn, Event: e.Event, At: e.At, Change: e.Change, Was: e.Was, Why: e.Why, Cleared: e.Cleared, Show: e.Show}
for _, g := range s.Gaps {
v.Gaps = append(v.Gaps, seat.ConditionEventGapsItem{Cleared: g.Cleared, Reopened: g.Reopened})
}
@@ -181,7 +184,7 @@ func eventOf(v seat.ConditionEvent) Event {
Headline: v.Headline, Explanation: v.Explanation, Resolved: v.Resolved, Needs: v.Needs, Actions: v.Actions,
Evidence: v.Evidence, Source: v.Source, Raised: timeOf(v.Raised), LastObserved: timeOf(v.LastObserved),
First: v.First, Observations: v.Observations, Count: v.Count, Resolver: v.Resolver, Epoch: v.Epoch,
LooksUnread: v.LooksUnread}
LooksUnread: v.LooksUnread, Owner: v.Owner, WaitingOn: v.WaitingOn}
if v.Subject != nil {
s.Subject = *v.Subject
}
@@ -0,0 +1,81 @@
package main
import (
"testing"
"time"
cc "git.novox.be/novox/mesh-contracts/health-and-repair/go/clients/meshconditionsclient"
seat "git.novox.be/novox/mesh-contracts/health-and-repair/go/seats/meshconditions"
)
// Design 47 G §540, §685, A-58: a condition carries the finding's owner and waiting-on; a failed head is its author's for two
// hours after the finding's `since` and then the operator's, said as the change `owner`; a hand sets an owner in §678's forms;
// a newer finding does not undo it.
func TestTheKeeperHandsAFailedHeadToTheOperatorAfterTwoHours(t *testing.T) {
r := newRig()
failed := func(owner string) (string, []byte) {
f := seat.Finding{Scope: "pipeline", ID: "abc123", Kind: HandoverKind, Since: r.now, Subject: seat.Subject{Scope: "pipeline", ID: "abc123"},
Severity: cc.Warning, Summary: "a head failed", Headline: "failed", Explanation: "It failed.", Source: "runner",
Observer: watchman, At: r.now, Owner: owner, WaitingOn: "node-engine", Evidence: []seat.Evidence{{At: r.now, Said: "failed"}}}
raw, err := encodeFinding(f)
if err != nil {
t.Fatal(err)
}
return cc.Key("pipeline", "abc123", HandoverKind), raw
}
key, raw := failed("forge:jochen")
if err := r.k.Finding(watchman, key, raw, false); err != nil {
t.Fatal(err)
}
if c, ok := r.open(key); !ok || c.Owner != "forge:jochen" || c.WaitingOn != "node-engine" {
t.Fatalf("raised with the finding's owner and waiting-on: %+v", c)
}
if _, ok := r.st.ttls["bounds/"+ownerPrefix+key]; !ok || r.st.ttls["bounds/"+ownerPrefix+key] != 2*time.Hour {
t.Fatalf("the two hours are timed as a bound of their own: %v", r.st.ttls)
}
r.changes()
// An hour in, the finding said again changes nothing; at two hours the bound's end settles it into the operator's.
r.now = r.now.Add(time.Hour)
if err := r.k.Finding(watchman, key, raw, false); err != nil || r.changes() != "" {
t.Fatalf("an hour in nothing changes: %v", err)
}
r.now = r.now.Add(time.Hour)
if err := r.k.BoundPassed(ownerPrefix + key); err != nil {
t.Fatal(err)
}
if got := r.changes(); got != "owner "+key {
t.Fatalf("said %q", got)
}
if c, _ := r.open(key); c.Owner != HandoverOwner || c.WaitingOn != "node-engine" {
t.Fatalf("handed to the operator: %+v", c)
}
// The finding said again does not give it back to the author, and a restarted keeper that finds the two hours past hands it over alone.
if err := r.k.Finding(watchman, key, raw, false); err != nil || r.changes() != "" {
t.Fatalf("the hand-over stands: %v", err)
}
// A hand sets another owner in a form of §678, and refuses a form it does not give.
if _, err := r.k.SetOwner(key, "Not An Owner", "jochen"); err == nil {
t.Error("a form §678 does not give is refused")
}
if c, err := r.k.SetOwner(key, "jochen@shanks", "jochen"); err != nil || c.Owner != "jochen@shanks" || r.changes() != "owner "+key {
t.Fatalf("a hand's owner: %+v %v", c, err)
}
if err := r.k.Finding(watchman, key, raw, false); err != nil || r.changes() != "" {
t.Fatalf("a hand's owner stands: %v", err)
}
// A newer failing head is a finding of its own with two hours of its own.
r2 := newRig()
r2.now = r.now
r2.k.Handover = time.Hour
other, rawOther := failed("forge:marie")
if err := r2.k.Finding(watchman, other, rawOther, false); err != nil {
t.Fatal(err)
}
r2.now = r2.now.Add(2 * time.Hour)
if err := r2.k.Settle(other); err != nil {
t.Fatal(err)
}
if c, _ := r2.open(other); c.Owner != HandoverOwner {
t.Fatalf("a restarted keeper, the time past, hands it over from `since`: %+v", c)
}
}
@@ -12,6 +12,7 @@ import (
const (
SettingAskBound = "ask-bound-seconds"
SettingViewWaits = "view-waits-seconds"
SettingHandover = "owner-handover-seconds"
defaultViewWaits = 5 * time.Minute
)
@@ -32,5 +33,6 @@ func seconds(read func(key string) (json.RawMessage, error), key string, def tim
// ReadSettings sets the keeper's bounds and the first views' wait from the settings.
func ReadSettings(k *Keeper, read func(key string) (json.RawMessage, error)) (viewWaits time.Duration) {
k.AskBound = seconds(read, SettingAskBound, AskedWithin)
k.Handover = seconds(read, SettingHandover, OwnerHandover)
return seconds(read, SettingViewWaits, defaultViewWaits)
}
@@ -6,6 +6,7 @@ package main
import (
"encoding/json"
"errors"
"fmt"
"slices"
"strconv"
@@ -21,8 +22,9 @@ import (
// Verbs are the seat's verbs, as the manifest claims them.
var Verbs = []string{"conditions", "silence", "resync"}
// The keeper's own read tools.
// The keeper's own tools: the owner act, and the read tools.
const (
OwnerTool = "conditions_owner"
HistoryTool = "conditions_history"
HeldTool = "conditions_held"
InputsTool = "conditions_inputs"
@@ -78,6 +80,13 @@ func Tools(k *Keeper) []stdio.Tool {
Run: func(args map[string]any) (any, error) {
return k.ResyncVerb(stdio.CallerOf(args), strArg(args, "module"))
}},
{Name: OwnerTool, Description: "Set an open condition's owner now: a session who@node, a seat's name or forge:<account>; " +
"said as the condition's change and written in the hand-act log (design 47 G §540).",
Input: obj(map[string]any{"key": str("the condition's key"), "owner": str("the new owner"), "why": str("why")},
"key", "owner", "why"),
Run: func(args map[string]any) (any, error) {
return k.OwnerVerb(stdio.CallerOf(args), strArg(args, "key"), strArg(args, "owner"), strArg(args, "why"))
}},
{Name: FindingsViewsTool, Description: "What the keeper holds of each observer and node: its last stated set with its at, " +
"its pending keys and its open asks (design 47 G §390).",
Run: func(map[string]any) (any, error) { return k.FindingsViews(), nil }},
@@ -98,6 +107,22 @@ func Tools(k *Keeper) []stdio.Tool {
}
}
// OwnerVerb is `conditions_owner`: it writes the call in the hand-act log, then sets the owner.
func (k *Keeper) OwnerVerb(by, key, owner, why string) (any, error) {
if strings.TrimSpace(why) == "" {
return nil, errors.New("an owner change says why")
}
record := k.Record
if record == nil {
record = recordHandAct
}
if err := record("set the owner of "+key+" to "+owner, why+" (by "+by+")", OwnerTool); err != nil {
return nil, fmt.Errorf("the hand act was not recorded, so nothing was changed: %w", err)
}
c, err := k.SetOwner(key, owner, by)
return c.Stored(), err
}
// ConditionsVerb is `conditions`: the list, one whole, or the history.
func (k *Keeper) ConditionsVerb(args map[string]any) (any, error) {
key := strArg(args, "key")
+6
View File
@@ -60,6 +60,12 @@
"minimum": 1,
"default": 300
},
"owner-handover-seconds": {
"type": "number",
"description": "How long a failed head stays its commit's author's, in seconds, before the keeper hands its condition to the operator: 7200 is two hours.",
"minimum": 1,
"default": 7200
},
"view-waits-seconds": {
"type": "number",
"description": "How long a first view may stay unanswered, in seconds, before the keeper raises view-waits for it: 300 is five minutes.",