From 572a2b3a4b3be6c0659d85c259f242da236abe7b Mon Sep 17 00:00:00 2001 From: jochen Date: Thu, 12 Nov 2026 01:18:43 +0100 Subject: [PATCH] mesh-recovery: the seat's verbs, answered by the process with the proved caller MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- .../mesh-recovery/internal/recovery/verbs.go | 112 ++++++++++++++++++ .../internal/recovery/verbs_test.go | 63 ++++++++++ 2 files changed, 175 insertions(+) create mode 100644 modules/mesh-recovery/internal/recovery/verbs.go create mode 100644 modules/mesh-recovery/internal/recovery/verbs_test.go diff --git a/modules/mesh-recovery/internal/recovery/verbs.go b/modules/mesh-recovery/internal/recovery/verbs.go new file mode 100644 index 000000000..105868eb7 --- /dev/null +++ b/modules/mesh-recovery/internal/recovery/verbs.go @@ -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 +} diff --git a/modules/mesh-recovery/internal/recovery/verbs_test.go b/modules/mesh-recovery/internal/recovery/verbs_test.go new file mode 100644 index 000000000..9b2ca8860 --- /dev/null +++ b/modules/mesh-recovery/internal/recovery/verbs_test.go @@ -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) + } +}