Let a module's code announce the states it keeps, in Go and TypeScript (issue 1790, S10, ADR 0495 §2 §5)

process.Conn.Keeps, stdio.Keeps and the TypeScript keeps publish the announcement on
the module's own subject; afterwards a state the code did not announce is refused at
the call, so announcement and code cannot drift. The package goes to 0.1.15 for keeps.
This commit is contained in:
2026-10-22 17:42:24 +02:00
parent bb8b3bb43b
commit 57d2f31f05
15 changed files with 410 additions and 4 deletions
+6
View File
@@ -24,6 +24,12 @@ type Conn struct {
mu sync.Mutex
next uint64
pending map[string]map[uint64]context.CancelCauseFunc
// What Keeps announced (keeps.go): the states' names (nil before Keeps), the announcement, and whether a
// reconnect announces it again.
kept map[string]bool
announced []byte
reannounces bool
}
// errPermissionRefused marks a context cancelled because the bus refused this account's publish of the
+1
View File
@@ -38,6 +38,7 @@ var sendGrants = []string{
"$KV.mesh-mail_sends.>",
"mesh.mod.mesh-mail.event.mail.send.failed",
"mesh.mod.mesh-mail.event.mail.send.sent",
"mesh.mod.mesh-mail.event.states",
"mesh.seat.operator-channel.call.tell.*.novox~mesh-mail~send",
"mesh.seat.operator-channel.call.tell.novox~mesh-mail~send",
"mesh.seat.operator-channel.tool.tell",
+71
View File
@@ -0,0 +1,71 @@
package process
import (
"encoding/json"
"fmt"
"github.com/nats-io/nats.go"
"git.novox.be/novox/mesh-sdk/go/announce"
)
// Keeps says on the bus, as this process's module, every state it keeps and which of them others may read (novox/hq
// ADR 0495 clause 2; issue 1790), on announce.Subject; what a reader is granted is what the owner announced (clause 3).
// It publishes at once and again after every reconnect, so the controller hears it whenever it missed it. From then on
// a state of the module that it did not announce is refused at the call, naming it, so code and announcement cannot
// drift (clause 5). It refuses a state no bucket could be named by and a state given twice.
func (c *Conn) Keeps(states ...announce.State) error {
if err := c.remember(states); err != nil {
return err
}
c.mu.Lock()
first := !c.reannounces
c.reannounces = true
c.mu.Unlock()
if first {
before := c.nc.Opts.ReconnectedCB
c.nc.SetReconnectHandler(func(nc *nats.Conn) {
if before != nil {
before(nc)
}
_ = c.publishKeeps()
})
}
return c.publishKeeps()
}
// remember makes the announcement of states and binds only them from now on.
func (c *Conn) remember(states []announce.State) error {
data, err := announce.Encode(announce.States{Module: c.account.Module, Node: c.account.Node, States: states})
if err != nil {
return err
}
names := map[string]bool{}
for _, s := range states {
names[s.Name] = true
}
c.mu.Lock()
c.kept, c.announced = names, data
c.mu.Unlock()
return nil
}
func (c *Conn) publishKeeps() error {
c.mu.Lock()
data := c.announced
c.mu.Unlock()
if err := c.Emit(announce.Event, json.RawMessage(data)); err != nil {
return fmt.Errorf("announcing what %s keeps: %w", c.account.Module, err)
}
return nil
}
// refusedUnannounced is the error of a bind of a state Keeps did not name, or nil (also before Keeps).
func (c *Conn) refusedUnannounced(name string) error {
c.mu.Lock()
defer c.mu.Unlock()
if c.kept != nil && !c.kept[name] {
return fmt.Errorf("the state %q is not among the states %s announced it keeps (Conn.Keeps)", name, c.account.Module)
}
return nil
}
+45
View File
@@ -0,0 +1,45 @@
//go:build integration
package process
import (
"strings"
"testing"
"github.com/nats-io/nats.go/jetstream"
"git.novox.be/novox/mesh-sdk/go/announce"
)
// 1790/S10: on a real bus, a process's Keeps lands on its module's own announcement subject, in the events stream, as
// the module's announcement; a state it did not announce is then refused at the call.
func TestAProcessAnnouncesWhatItKeepsOnTheBus(t *testing.T) {
url, account := grantedSendProcess(t)
_, admin := adminJS(t, url)
stream, err := admin.CreateStream(ctxFor(t), jetstream.StreamConfig{Name: "MAIL_STATES", Subjects: []string{announce.Subject("mesh-mail")}})
if err != nil {
t.Fatal(err)
}
conn, err := Connect(account)
if err != nil {
t.Fatal(err)
}
defer conn.Close()
if err := conn.Keeps(announce.State{Name: "accounts", Readable: true}, announce.State{Name: "drafts"}); err != nil {
t.Fatal(err)
}
msg, err := stream.GetMsg(ctxFor(t), 1)
if err != nil {
t.Fatalf("no announcement in the stream: %v", err)
}
if msg.Subject != "mesh.mod.mesh-mail.event.states" || msg.Header.Get("x-source") != "mesh-mail" {
t.Fatalf("announced on %s with %v", msg.Subject, msg.Header)
}
got, err := announce.Decode(msg.Data)
if err != nil || !got.Readable("accounts") || !got.Keeps("drafts") || got.Readable("drafts") || got.Node != "novox" {
t.Fatalf("announced %s (%v)", msg.Data, err)
}
if _, err := conn.State("outbox").Get("k"); err == nil || !strings.Contains(err.Error(), `"outbox"`) {
t.Fatalf("an unannounced state was bound: %v", err)
}
}
+28
View File
@@ -0,0 +1,28 @@
package process
import (
"strings"
"testing"
"git.novox.be/novox/mesh-sdk/go/announce"
)
// After Keeps a process binds only the states it announced (novox/hq ADR 0495 clause 5): any other of its module's
// states is refused at the call, naming it, before the bus is asked; before Keeps nothing is refused.
func TestAProcessBindsOnlyTheStatesItAnnounced(t *testing.T) {
c := &Conn{account: Account{Module: "mesh-mail", Node: "novox"}}
if err := c.remember([]announce.State{{Name: "accounts", Readable: true}}); err != nil {
t.Fatal(err)
}
_, err := c.State("drafts").Get("k")
if err == nil || !strings.Contains(err.Error(), `"drafts"`) || !strings.Contains(err.Error(), "Keeps") {
t.Fatalf("an unannounced state was bound: %v", err)
}
if err := c.remember([]announce.State{{Name: "a_b"}}); err == nil {
t.Fatal("an announcement no bucket could be named by was accepted")
}
got, err := announce.Decode(c.announced)
if err != nil || got.Module != "mesh-mail" || got.Node != "novox" || !got.Readable("accounts") {
t.Fatalf("the announcement is %s (%v)", c.announced, err)
}
}
+3
View File
@@ -39,6 +39,9 @@ func (s State) bucket() string { return s.Module + "_" + s.Name }
func (c *Conn) State(name string) State {
bucket := c.account.Module + "_" + name
return State{Module: c.account.Module, Name: name, kv: func(ctx context.Context) (jetstream.KeyValue, error) {
if err := c.refusedUnannounced(name); err != nil {
return nil, err
}
js, err := jetstream.New(c.nc)
if err != nil {
return nil, err