mesh-recovery: the seat's verbs, answered by the process with the proved caller

Builds: design 47 G §516, §554, §656, §682, §691, A-107, A-115
Each acting verb answers one of accepted, busy or error; arguments are read afresh per call and refused when unknown (local-name, from, aside-only, discard-aside) or without why; restore takes exactly one of snapshot and request (request is refused until built); the hand-act log names the proved account and the asker.

Claude-Session: https://claude.ai/code/session_01NcbgfGKaxw984XyAPfLm4h
This commit is contained in:
2026-11-12 01:18:43 +01:00
parent 2dd02da76e
commit 572a2b3a4b
2 changed files with 175 additions and 0 deletions
@@ -0,0 +1,112 @@
package recovery
import (
"bytes"
"encoding/json"
"errors"
"fmt"
"strings"
seat "git.novox.be/novox/mesh-contracts/go/seats/meshrecovery"
"git.novox.be/novox/mesh-sdk/go/process"
)
// Handlers are the seat's verbs as the process answers them over the bus with the caller the bus proved (§554, §656, A-107):
// every acting verb answers one of accepted, busy or error and nothing more (§691); the result is the event.
func (e *Engine) Handlers() map[string]process.SeatHandler {
h := map[string]process.SeatHandler{}
h[seat.VerbSnapshotKept] = actOf(e, func(sk seat.SnapshotKeptInput) (Act, error) {
return Act{Kind: KindTake, Module: sk.Module, Kept: sk.Kept, Form: sk.Form, Fingerprint: sk.Fingerprint, CatchUp: sk.CatchUp, Why: sk.Why}, nil
})
restoreKept := actOf(e, func(rk seat.RestoreKeptInput) (Act, error) {
return Act{Kind: KindRestore, Module: rk.Module, Kept: rk.Kept, Form: rk.Form, Fingerprint: rk.Fingerprint, Snapshot: rk.Snapshot, FromBackup: rk.FromBackup, Why: rk.Why}, nil
})
h[seat.VerbRestoreKept] = func(caller string, body json.RawMessage) (any, error) {
// aside-only and discard-aside are the operator's hand tools alone (§516, §554).
var loose map[string]json.RawMessage
_ = json.Unmarshal(body, &loose)
for _, k := range []string{"aside-only", "discard-aside"} {
if _, ok := loose[k]; ok {
return Answer{Error: k + " is the operator's hand tool alone, not an argument of restore-kept"}, nil
}
}
return restoreKept(caller, body)
}
h[seat.VerbKeptPoints] = func(_ string, body json.RawMessage) (any, error) {
var in seat.KeptPointsInput
if err := strict(body, &in); err != nil {
return nil, err
}
return e.KeptPoints(in.Module, in.Kept)
}
h[seat.VerbTake] = actOf(e, func(tk seat.TakeInput) (Act, error) {
return Act{Kind: KindTake, Module: tk.Module, Kept: tk.Kept, Fingerprint: tk.Fingerprint, Why: tk.Why, Asker: tk.Asker}, nil
})
h[seat.VerbRestore] = actOf(e, func(rs seat.RestoreInput) (Act, error) {
if (rs.Snapshot == "") == (rs.Request == "") {
return Act{}, errors.New("restore takes exactly one of snapshot and request")
}
if rs.Request != "" {
return Act{}, errors.New("the restore of a keeper's snapshot by request is not built yet (design 47 G §681)")
}
return Act{Kind: KindRestore, Module: rs.Module, Kept: rs.Kept, Snapshot: rs.Snapshot, FromBackup: rs.FromBackup, Why: rs.Why, Asker: rs.Asker}, nil
})
h[seat.VerbAside] = actOf(e, func(ad seat.AsideInput) (Act, error) {
return Act{Kind: KindAside, Module: ad.Module, Kept: ad.Kept, Why: ad.Why, Asker: ad.Asker}, nil
})
h[seat.VerbDiscardAside] = actOf(e, func(da seat.DiscardAsideInput) (Act, error) {
return Act{Kind: KindDiscardAside, Module: da.Module, Kept: da.Kept, AsideID: da.ID, Why: da.Why, Asker: da.Asker}, nil
})
h[seat.VerbRestoreAside] = actOf(e, func(ra seat.RestoreAsideInput) (Act, error) {
return Act{Kind: KindRestoreAside, Module: ra.Module, Kept: ra.Kept, AsideID: ra.ID, Why: ra.Why, Asker: ra.Asker}, nil
})
h[seat.VerbExpirePoints] = actOf(e, func(ex seat.ExpirePointsInput) (Act, error) {
return Act{Kind: KindExpire, Why: ex.Why, Asker: ex.Asker}, nil
})
h[seat.VerbRetry] = func(caller string, body json.RawMessage) (any, error) {
var in seat.RetryInput
if err := strict(body, &in); err != nil {
return Answer{Error: err.Error()}, nil
}
return e.Retry(in.Module, in.Kept, in.Why, caller, in.Asker), nil
}
h[seat.VerbCancel] = func(caller string, body json.RawMessage) (any, error) {
var in seat.CancelInput
if err := strict(body, &in); err != nil {
return Answer{Error: err.Error()}, nil
}
return e.Cancel(in.Act, in.Why, caller, in.Asker), nil
}
return h
}
// actOf is one acting verb: its arguments are read afresh for each call (calls come on concurrent bus callbacks), a call
// without the `why` the hand-act log keeps is refused, and the act is accepted as the engine says.
func actOf[T any](e *Engine, build func(T) (Act, error)) process.SeatHandler {
return func(caller string, body json.RawMessage) (any, error) {
var in T
if err := strict(body, &in); err != nil {
return Answer{Error: err.Error()}, nil
}
a, err := build(in)
if err != nil {
return Answer{Error: err.Error()}, nil
}
if a.Why == "" {
return Answer{Error: "why is required: the hand-act log keeps it"}, nil
}
a.Caller = caller
return e.Accept(a), nil
}
}
// strict reads a call's arguments, refusing a field the verb does not take (no `local-name`, no `from`: §516) and a call
// that leaves out the `why` the hand-act log keeps (§682).
func strict(body json.RawMessage, into any) error {
dec := json.NewDecoder(bytes.NewReader(body))
dec.DisallowUnknownFields()
if err := dec.Decode(into); err != nil {
return fmt.Errorf("the arguments are refused: %s", strings.TrimPrefix(err.Error(), "json: "))
}
return nil
}
@@ -0,0 +1,63 @@
package recovery_test
import (
"encoding/json"
"strings"
"testing"
seat "git.novox.be/novox/mesh-contracts/go/seats/meshrecovery"
"mesh-recovery/internal/recovery"
)
func call(t *testing.T, r *rig, verb, body string) recovery.Answer {
t.Helper()
out, err := r.e.Handlers()[verb]("node.engine", json.RawMessage(body))
if err != nil {
t.Fatalf("%s: %v", verb, err)
}
r.e.Wait()
return out.(recovery.Answer)
}
// Every verb of the seat's definition is answered; an acting verb answers one of accepted, busy and error and nothing more
// (§691); an argument the verb does not take (`local-name`, `from`), `aside-only` and `discard-aside` on restore-kept, a
// restore naming both a snapshot and a request, and a call without `why` are refused naming the fault (§516, §682, §554).
func TestEveryVerbIsAnsweredAndAnActingVerbAnswersOneForm(t *testing.T) {
r := newRig(t)
h := r.e.Handlers()
for _, v := range seat.Verbs() {
if h[v] == nil {
t.Errorf("verb %s has no handler", v)
}
}
taken := call(t, r, "snapshot-kept", `{"module":"app","kept":"notes","form":2,"fingerprint":"fp1","why":"form change"}`)
if taken.Accepted == "" || taken.Busy != "" || taken.Error != "" {
t.Fatalf("answer %+v", taken)
}
for name, c := range map[string]struct{ verb, body, want string }{
"local-name": {"snapshot-kept", `{"module":"app","kept":"notes","form":2,"fingerprint":"fp1","why":"w","local-name":"x"}`, "local-name"},
"from": {"restore-kept", `{"module":"app","kept":"notes","form":2,"fingerprint":"fp1","why":"w","from":"x"}`, "from"},
"aside-only": {"restore-kept", `{"module":"app","kept":"notes","form":2,"fingerprint":"fp1","why":"w","aside-only":true}`, "aside-only"},
"discard": {"restore-kept", `{"module":"app","kept":"notes","form":2,"fingerprint":"fp1","why":"w","discard-aside":true}`, "discard-aside"},
"both": {"restore", `{"module":"app","kept":"notes","snapshot":"a","request":"b","why":"w"}`, "exactly one"},
"no why": {"restore", `{"module":"app","kept":"notes","snapshot":"a"}`, "why"},
} {
if got := call(t, r, c.verb, c.body); !strings.Contains(got.Error, c.want) || got.Accepted != "" {
t.Errorf("%s: answer %+v", name, got)
}
}
if len(r.s.named(seat.EventEnded)) != 1 {
t.Errorf("a refused call acted: %+v", r.s.events)
}
out, err := h["kept-points"]("tools", json.RawMessage(`{"module":"app"}`))
if kp, ok := out.(seat.KeptPointsAnswer); err != nil || !ok || len(kp.Points) != 1 {
t.Errorf("kept-points %+v %v", out, err)
}
if got := call(t, r, "take", `{"module":"app","kept":"notes","fingerprint":"fp9","why":"by hand","asker":"op@shanks"}`); got.Accepted == "" {
t.Errorf("take %+v", got)
}
if !strings.Contains(r.s.handActs[len(r.s.handActs)-1], "node.engine|op@shanks|take app notes|by hand") {
t.Errorf("hand acts %v: the account the bus proved and the asker are both logged", r.s.handActs)
}
}