From 886be5cca788d114bd3854484713dbe455400327 Mon Sep 17 00:00:00 2001 From: jochen Date: Thu, 12 Nov 2026 10:23:22 +0100 Subject: [PATCH] A3h item 2b: NewGateChannel is the gate's operator channel over a process connection MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit It asks and cancels as accepted kinds of the seat operator-channel through the connection's Accept, and reads the router's record by a get in the seat holder's state asks, never through a bucket named in code. Builds: design 47 G §677, §700; lead answers A-272, A-273 Claude-Session: https://claude.ai/code/session_01NcbgfGKaxw984XyAPfLm4h --- .../operatorchannelclient/gate_conn.go | 51 +++++++++++++ .../operatorchannelclient/gate_conn_test.go | 71 +++++++++++++++++++ 2 files changed, 122 insertions(+) create mode 100644 operator-and-conversation/go/clients/operatorchannelclient/gate_conn.go create mode 100644 operator-and-conversation/go/clients/operatorchannelclient/gate_conn_test.go diff --git a/operator-and-conversation/go/clients/operatorchannelclient/gate_conn.go b/operator-and-conversation/go/clients/operatorchannelclient/gate_conn.go new file mode 100644 index 0000000..932d8ff --- /dev/null +++ b/operator-and-conversation/go/clients/operatorchannelclient/gate_conn.go @@ -0,0 +1,51 @@ +package operatorchannelclient + +import ( + "fmt" + + 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/process" +) + +// asksRead is a state of another module read by key: process.State is one. +type asksRead interface { + Get(key string) (*stdio.StateEntry, error) +} + +// NewGateChannel is the operator channel as a process holder's gate reaches it over its own connection (design 47 G §677, +// §700; lead answers A-272, A-273): an ask and its cancel are sent on the seat operator-channel's accept subjects through +// conn's Accept, and the router's record is read by a get of the key `.` in the seat's state asks, whose bucket +// is formed from holder (the module the seat list names for the seat operator-channel) and the contract's StateAsks, on the +// process's own account. state keeps the asks, under keyPrefix (the gate's state approvals and its node). +func NewGateChannel(conn *process.Conn, state Store, keyPrefix string, holder func() (string, error)) *GateChannel { + return gateChannelOver(conn.Accept, func(read string) asksRead { return conn.Read(read) }, state, keyPrefix, holder) +} + +// gateChannelOver is NewGateChannel over the two things it reaches a connection through. +func gateChannelOver(accept func(seat, kind string, body any, opts ...stdio.EmitOptions) error, read func(read string) asksRead, + state Store, keyPrefix string, holder func() (string, error)) *GateChannel { + return &GateChannel{State: state, KeyPrefix: keyPrefix, + Publish: func(module, subject string, body any, id string) error { + var kind string + switch subject { + case seat.AcceptSubjectAsk(module): + kind = seat.AcceptAsk + case seat.AcceptSubjectCancel(module): + kind = seat.AcceptCancel + default: + return fmt.Errorf("%s is no accept subject of the seat %s that %s may use", subject, seat.Seat, module) + } + return accept(seat.Seat, kind, body, stdio.EmitOptions{MessageID: id}) + }, + ReadRecord: func(module, id string) (*stdio.StateEntry, error) { + held, err := holder() + if err != nil { + return nil, fmt.Errorf("the router's record of %s cannot be read: %w", id, err) + } + if held == "" { + return nil, fmt.Errorf("the router's record of %s cannot be read: the seat list names no holder of the seat %s", id, seat.Seat) + } + return read(held + "." + seat.StateAsks).Get(seat.AsksByAskerIDKey(module, id)) + }} +} diff --git a/operator-and-conversation/go/clients/operatorchannelclient/gate_conn_test.go b/operator-and-conversation/go/clients/operatorchannelclient/gate_conn_test.go new file mode 100644 index 0000000..fbacc0c --- /dev/null +++ b/operator-and-conversation/go/clients/operatorchannelclient/gate_conn_test.go @@ -0,0 +1,71 @@ +package operatorchannelclient + +import ( + "encoding/json" + "errors" + "testing" + "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" +) + +type readOf struct { + state string + vals map[string]json.RawMessage +} + +func (r readOf) Get(key string) (*stdio.StateEntry, error) { + if v, ok := r.vals[r.state+"|"+key]; ok { + return &stdio.StateEntry{Key: key, Value: v}, nil + } + return nil, nil +} + +// The gate's channel over a process connection (design 47 G lead answers A-272, A-273): an ask and its cancel go out as +// accepted kinds of the seat operator-channel with the ask's id as the message id, the router's record is the get of +// `.` in the state asks of the seat's holder, and nothing else is reached: another subject, or a seat with no +// holder, is refused. +func TestTheGateChannelOverAConnectionAsksAndReadsOnlyThroughTheSeat(t *testing.T) { + type sent struct{ seat, kind, id string } + var out []sent + accept := func(s, kind string, _ any, opts ...stdio.EmitOptions) error { + out = append(out, sent{s, kind, opts[0].MessageID}) + return nil + } + raw, _ := json.Marshal(seat.AsksByAskerIDValue{Asker: "shell", State: "open", Opened: FormatTime(now)}) + vals := map[string]json.RawMessage{"messenger_holder.asks|shell.a1": raw, "other.asks|shell.a1": json.RawMessage(`{"state":"wrong"}`)} + read := func(r string) asksRead { return readOf{state: r, vals: vals} } + holder := func() (string, error) { return "messenger_holder", nil } + st := &mem{vals: map[string]json.RawMessage{}, revs: map[string]uint64{}} + g := gateChannelOver(accept, read, st, "n1.", holder) + g.Now = func() time.Time { return now } + + 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) + } + if err := g.Cancel("shell", "a1"); err != nil { + t.Fatal(err) + } + if len(out) != 2 || out[0] != (sent{seat.Seat, seat.AcceptAsk, "a1"}) || out[1] != (sent{seat.Seat, seat.AcceptCancel, "cancel.a1"}) { + t.Fatalf("sent %+v, want the ask and its cancel on the seat's accepts", out) + } + if rec, err := g.Record("shell", "a1"); err != nil || rec == nil || rec.State != "open" { + t.Fatalf("record %+v %v, want the one in the holder's state asks", rec, err) + } + if rec, err := g.Record("shell", "zz"); err != nil || rec != nil { + t.Fatalf("a record not there: %+v %v", rec, err) + } + if err := g.Publish("shell", "mesh.seat.operator-channel.accept.ask.other", nil, "x"); err == nil { + t.Fatal("an accept subject naming another caller was sent") + } + for _, c := range []func() (string, error){func() (string, error) { return "", nil }, func() (string, error) { return "", errors.New("unread") }} { + if _, err := gateChannelOver(accept, read, st, "n1.", c).Record("shell", "a1"); err == nil { + t.Fatal("a record was read with no holder of the seat") + } + } +}