From 1d3f24250c6d85314a3f643cccebeabc90c2d053 Mon Sep 17 00:00:00 2001 From: jochen Date: Thu, 12 Nov 2026 06:25:56 +0100 Subject: [PATCH] A3e item 1: operatorchannelclient implements the gate's channel and re-exports its Kept MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Builds: design 47 G §677, §700, lead answers A-219, A-224, A-228 Claude-Session: https://claude.ai/code/session_01NcbgfGKaxw984XyAPfLm4h --- .../go/clients/operatorchannelclient/ask.go | 3 +- .../clients/operatorchannelclient/client.go | 22 ++--- .../operatorchannelclient/client_test.go | 59 ++++++++++++ .../go/clients/operatorchannelclient/gate.go | 90 +++++++++++++++++++ 4 files changed, 156 insertions(+), 18 deletions(-) create mode 100644 operator-and-conversation/go/clients/operatorchannelclient/gate.go diff --git a/operator-and-conversation/go/clients/operatorchannelclient/ask.go b/operator-and-conversation/go/clients/operatorchannelclient/ask.go index f0a28cb..06c0d8f 100644 --- a/operator-and-conversation/go/clients/operatorchannelclient/ask.go +++ b/operator-and-conversation/go/clients/operatorchannelclient/ask.go @@ -18,6 +18,7 @@ import ( "time" seat "git.novox.be/novox/mesh-contracts/operator-and-conversation/go/seats/operatorchannel" + "git.novox.be/novox/mesh-sdk/go/approval" "git.novox.be/novox/mesh-sdk/go/operatorask" ) @@ -178,7 +179,7 @@ const ( MostOpen = 3 ApproveLasts = 24 * time.Hour DestroyLasts = 10 * time.Minute - HeadlineLength = 60 + HeadlineLength = approval.HeadlineLength LabelLength = 24 ) diff --git a/operator-and-conversation/go/clients/operatorchannelclient/client.go b/operator-and-conversation/go/clients/operatorchannelclient/client.go index 6f656aa..da574cb 100644 --- a/operator-and-conversation/go/clients/operatorchannelclient/client.go +++ b/operator-and-conversation/go/clients/operatorchannelclient/client.go @@ -19,6 +19,7 @@ import ( seat "git.novox.be/novox/mesh-contracts/operator-and-conversation/go/seats/operatorchannel" stdio "git.novox.be/novox/mesh-sdk/go" + "git.novox.be/novox/mesh-sdk/go/approval" ) // Client asks as one module. @@ -57,23 +58,10 @@ func (c *Client) now() time.Time { return time.Now() } -// Kept is one ask as the module keeps it. -// -// An asker that asks for a call keeps beside it the act the ask binds, the call's whole arguments, and whether the act's -// run was recorded in the hand-act log (design 46 7-approval-mark §13): Take keeps all three as it marks the ask. -type Kept struct { - Ask seat.Ask `json:"ask"` - Asked time.Time `json:"asked"` - Acted bool `json:"acted,omitempty"` - Ended string `json:"ended,omitempty"` - Answer *seat.Warrant `json:"answer,omitempty"` - // Act is the act the ask's options bind, as its asker digested it. - Act Act `json:"act,omitempty"` - // Arguments are the asked call's arguments whole. - Arguments json.RawMessage `json:"arguments,omitempty"` - // Recorded says the act's run is in the hand-act log (`mesh-hand-acts.warranted` answered). - Recorded bool `json:"recorded,omitempty"` -} +// Kept is one ask as the module keeps it: mesh-sdk's go/approval's form (design 47 G lead answer A-228) over the generated +// contract's ask and warrant. An asker that asks for a call keeps beside it the act the ask binds, the call's whole +// arguments, and whether the act's run was recorded in the hand-act log (design 46 7-approval-mark §13). +type Kept = approval.Kept[seat.Ask, seat.Warrant] func (c *Client) key(id string) string { return c.KeyPrefix + "ask." + id } diff --git a/operator-and-conversation/go/clients/operatorchannelclient/client_test.go b/operator-and-conversation/go/clients/operatorchannelclient/client_test.go index 000c2ca..7fd0ce8 100644 --- a/operator-and-conversation/go/clients/operatorchannelclient/client_test.go +++ b/operator-and-conversation/go/clients/operatorchannelclient/client_test.go @@ -8,6 +8,7 @@ import ( seat "git.novox.be/novox/mesh-contracts/operator-and-conversation/go/seats/operatorchannel" stdio "git.novox.be/novox/mesh-sdk/go" + "git.novox.be/novox/mesh-sdk/go/approval" "git.novox.be/novox/mesh-sdk/go/process" ) @@ -200,3 +201,61 @@ func TestWhereReadsTheRecordsWarrantTyped(t *testing.T) { t.Fatalf("Where = %q, %+v, %v; want chosen and the record's warrant", state, w, err) } } + +// The gate's channel (design 47 G lead answers A-219, A-228): the gate's ask raised to the operator at the level approve, +// the router's record read in the gate's form with its warrant whole, and a warrant claimed once, only for the act its +// option binds. +func TestTheGateChannelTranslatesTheGatesForms(t *testing.T) { + st := &mem{vals: map[string]json.RawMessage{}, revs: map[string]uint64{}} + records := map[string]seat.AsksByAskerIDValue{} + g := &GateChannel{State: st, KeyPrefix: "n1.", Now: func() time.Time { return now }, + Publish: func(module, subject string, body any, id string) error { + if subject != seat.AcceptSubjectAsk(module) { + t.Fatalf("published on %s", subject) + } + return nil + }, + ReadRecord: func(module, id string) (*stdio.StateEntry, error) { + rec, ok := records[module+"."+id] + if !ok { + return nil, nil + } + raw, _ := json.Marshal(rec) + return &stdio.StateEntry{Value: raw}, nil + }} + digest, _ := ActDigest(releaseHQ) + a := approval.Ask{ID: "a1", About: digest, Headline: "Run wipe?", Explanation: "Wipes.", Expires: FormatTime(now.Add(time.Hour)), + OnExpiry: "nothing runs", Options: []approval.Option{{ID: "approve", Label: "Approve", Does: "runs", Binds: digest}}} + if err := g.Ask("shell", a); err != nil { + t.Fatal(err) + } + var k Kept + if e, _ := st.Get("n1.ask.a1"); e == nil || json.Unmarshal(e.Value, &k) != nil || k.Ask.Who != Operator || k.Ask.Options[0].Level != Approve { + t.Fatalf("kept %+v", k) + } + if rec, err := g.Record("shell", "a1"); rec != nil || err != nil { + t.Fatalf("a record not yet there: %+v %v", rec, err) + } + w := seat.Warrant{Ask: "a1", Asker: "shell", Outcome: OutcomeChosen, Option: "approve", Level: Approve, At: FormatTime(now), + AskDigest: Digest(k.Ask), Channel: "telegram", By: &seat.Person{Who: Operator, Kind: "telegram"}} + records["shell.a1"] = seat.AsksByAskerIDValue{Asker: "shell", State: "chosen", Opened: FormatTime(now), Warrant: &w} + rec, err := g.Record("shell", "a1") + if err != nil || rec == nil || rec.State != "chosen" || rec.Warrant == nil || rec.Warrant.Channel != "telegram" || rec.Warrant.Option != "approve" { + t.Fatalf("record %+v %v", rec, err) + } + if h, err := g.Claim("shell", *rec.Warrant, map[string]string{"verb": "other"}, func() { t.Fatal("ran another act") }); h != approval.Claimed || err == nil { + t.Fatalf("another act: %s %v", h, err) + } + delete(st.vals, "n1.ask.a1") + _ = g.Ask("shell", a) + runs := 0 + for i := 0; i < 2; i++ { + h, err := g.Claim("shell", *rec.Warrant, releaseHQ, func() { runs++ }) + if want := []approval.Claim{approval.Claimed, approval.ClaimDone}[i]; h != want || err != nil { + t.Fatalf("claim %d: %s %v", i, h, err) + } + } + if runs != 1 || g.Decided("shell") != seat.SubjectDecided("shell") { + t.Fatalf("runs %d", runs) + } +} diff --git a/operator-and-conversation/go/clients/operatorchannelclient/gate.go b/operator-and-conversation/go/clients/operatorchannelclient/gate.go new file mode 100644 index 0000000..0e6cfa0 --- /dev/null +++ b/operator-and-conversation/go/clients/operatorchannelclient/gate.go @@ -0,0 +1,90 @@ +package operatorchannelclient + +import ( + "encoding/json" + "fmt" + "time" + + seat "git.novox.be/novox/mesh-contracts/operator-and-conversation/go/seats/operatorchannel" + stdio "git.novox.be/novox/mesh-sdk/go" + "git.novox.be/novox/mesh-sdk/go/approval" + "git.novox.be/novox/mesh-sdk/go/operatorask" +) + +// GateChannel is the operator channel as mesh-sdk's approval gate reaches it (design 47 G lead answers A-219, A-228): it +// translates the gate's own forms to and from the generated contract's, asking under the marked module's name through a +// Client whose asks are kept in the gate's state under KeyPrefix. +type GateChannel struct { + // State is the gate's state approvals; KeyPrefix is put before every key the client keeps there. + State Store + KeyPrefix string + Now func() time.Time + // Publish says an accept on the seat under module's name; ReadRecord reads the router's record `.` of the + // seat's state asks, both on the gate's holder's own account (design 47 G §700). + Publish func(module, subject string, body any, id string) error + ReadRecord func(module, id string) (*stdio.StateEntry, error) +} + +var _ approval.Channel = (*GateChannel)(nil) + +func (g *GateChannel) client(module string) *Client { + return &Client{Module: module, State: g.State, KeyPrefix: g.KeyPrefix, Now: g.Now, + Publish: func(subject string, body any, id string) error { return g.Publish(module, subject, body, id) }, + Record: func(id string) (*stdio.StateEntry, error) { return g.ReadRecord(module, id) }} +} + +// Ask raises the gate's ask to the operator, every option at the level approve. +func (g *GateChannel) Ask(module string, a approval.Ask) error { + ask := seat.Ask{ID: a.ID, About: a.About, Who: Operator, Headline: a.Headline, Explanation: a.Explanation, + Expires: a.Expires, OnExpiry: a.OnExpiry} + for _, o := range a.Options { + ask.Options = append(ask.Options, seat.Option{ID: o.ID, Label: o.Label, Does: o.Does, Level: Approve, Binds: o.Binds}) + } + return g.client(module).Ask(ask) +} + +// Record is the router's record of module's ask in the gate's form, nil while there is none. +func (g *GateChannel) Record(module, id string) (*approval.Record, error) { + e, err := g.ReadRecord(module, id) + if err != nil || e == nil { + return nil, err + } + var rec seat.AsksByAskerIDValue + if err := json.Unmarshal(e.Value, &rec); err != nil { + return nil, fmt.Errorf("the router's record of %s cannot be read: %w", id, err) + } + out := &approval.Record{State: rec.State, Answer: rec.Answer, Opened: rec.Opened, Expires: rec.Expires, OfferedOn: rec.OfferedOn} + if w := rec.Warrant; w != nil { + whole, err := json.Marshal(w) + if err != nil { + return nil, err + } + out.Warrant = &approval.Warrant{Ask: w.Ask, Asker: w.Asker, Option: w.Option, Channel: w.Channel, At: w.At, Whole: whole} + } + return out, nil +} + +// Claim takes the warrant once with Take, holding the chosen option to act before run. +func (g *GateChannel) Claim(module string, w approval.Warrant, act operatorask.Act, run func()) (approval.Claim, error) { + var sw seat.Warrant + if err := json.Unmarshal(w.Whole, &sw); err != nil { + return approval.ClaimRefused, fmt.Errorf("the warrant for the ask %s cannot be read: %w", w.Ask, err) + } + h, err := g.client(module).Take(sw, func(o seat.Option, _ seat.Warrant) error { + if err := Performs(o, act); err != nil { + return err + } + run() + return nil + }) + return approval.Claim(h), err +} + +// Cancel takes module's ask back. +func (g *GateChannel) Cancel(module, id string) error { return g.client(module).Cancel(id) } + +// Decided is the subject of the router's `decided` for module. +func (g *GateChannel) Decided(module string) string { return seat.SubjectDecided(module) } + +// Seat is the seat the gate's asks are raised on. +func (g *GateChannel) Seat() string { return seat.Seat }