Merge pull request 'go: a module reads the values its bucket still keeps for a key (ADR 0307, issue 502)' (#22) from adr-0307-state-history into main

This commit was merged in pull request #22.
This commit is contained in:
2026-10-12 08:21:13 +00:00
3 changed files with 168 additions and 1 deletions
+1 -1
View File
@@ -3,7 +3,7 @@
`stdio.Serve("", tools)` speaks MCP over stdio to the node's tool runtime, as the module the runtime
names (novox/hq ADR 0188, ADR 0193). A `Tool` is a name, a description, an argument schema and a
function; calls run concurrently. `stdio.CallerOf(args)` inside a tool is who called it: the bus user the runtime took from the subject the call arrived on, named in the call's `_meta`, never read from the arguments — or `stdio.Unproven` when the runtime named none (novox/hq issue 365). A tool named `<seat>.<verb>` is the seat's implementation.
`stdio.Emit(type, body)` publishes an event as the module, `stdio.Subscribe(pattern, handler)` receives the module's events (taken when the handler returns nil, offered again when it returns an error — the one rule is in the repository README, "Taking an event") and `stdio.Ask(key, body)` calls a tool, and `stdio.State(name)` gets, puts, deletes, lists and watches the module's declared state, and `stdio.Log(name)` appends to, reads, and measures a log of operations the module declared — all through the runtime (novox/hq ADR 0193, 0198, 0201, 0297). Standard library only.
`stdio.Emit(type, body)` publishes an event as the module, `stdio.Subscribe(pattern, handler)` receives the module's events (taken when the handler returns nil, offered again when it returns an error — the one rule is in the repository README, "Taking an event") and `stdio.Ask(key, body)` calls a tool, and `stdio.State(name)` gets, puts, deletes, lists, watches and reads the kept history of the module's declared state, and `stdio.Log(name)` appends to, reads, and measures a log of operations the module declared — all through the runtime (novox/hq ADR 0193, 0198, 0201, 0297, 0307). Standard library only.
## Installing
+25
View File
@@ -31,6 +31,16 @@ type StateChange struct {
Current bool `json:"current"`
}
// StateRevision is one value a bucket still keeps for a key: what was written, at which revision, by which
// operation ("put" or "delete", whose value is null) and when (novox/hq ADR 0307).
type StateRevision struct {
Key string `json:"key"`
Value json.RawMessage `json:"value"`
Revision uint64 `json:"revision"`
Op string `json:"op"`
At time.Time `json:"at"`
}
// KeptState is one state this module keeps, or reads.
type KeptState struct{ name string }
@@ -58,6 +68,21 @@ func (s KeptState) Get(key string) (*StateEntry, error) {
return &e, json.Unmarshal(raw, &e)
}
// History is every value the bucket still keeps for key, oldest first — as many as the state declared it
// keeps, deletes included — and empty for a key it holds nothing for (novox/hq ADR 0307). A read, like Get:
// the owner's instances and its readers may ask it, and it writes nothing.
func (s KeptState) History(key string) ([]StateRevision, error) {
raw, err := s.ask("history", map[string]any{"key": key})
if err != nil {
return nil, err
}
revisions := []StateRevision{}
if len(raw) == 0 || string(raw) == "null" {
return revisions, nil
}
return revisions, json.Unmarshal(raw, &revisions)
}
// Put writes a key — the owner's instances only — and returns the revision written.
func (s KeptState) Put(key string, value any) (uint64, error) {
raw, err := s.ask("put", map[string]any{"key": key, "value": value})
+142
View File
@@ -0,0 +1,142 @@
package stdio
import (
"bufio"
"encoding/json"
"io"
"strings"
"sync"
"testing"
"time"
)
// historying serves a bundle whose one tool, "history", runs run, answering each mesh/state ask with
// answer beside the loop, as the runtime does; it returns what the bundle asked.
func historying(t *testing.T, answer func(message) map[string]any, run func()) []message {
t.Helper()
t.Setenv(ServedAs, "issues")
inR, inW := io.Pipe()
outR, outW := io.Pipe()
go func() {
_ = ServeOn("", []Tool{{Name: "history", Run: func(map[string]any) (any, error) { run(); return "done", nil }}}, inR, outW)
}()
var wmu, amu sync.Mutex
var asked []message
send := func(m map[string]any) {
m["jsonrpc"] = "2.0"
b, _ := json.Marshal(m)
wmu.Lock()
defer wmu.Unlock()
_, _ = inW.Write(append(b, '\n'))
}
done := make(chan struct{})
go func() {
lines := bufio.NewScanner(outR)
for lines.Scan() {
var m message
if json.Unmarshal(lines.Bytes(), &m) != nil {
continue
}
if strings.HasPrefix(m.Method, "mesh/state.") {
amu.Lock()
asked = append(asked, m)
amu.Unlock()
a := answer(m)
a["id"] = m.ID
send(a)
continue
}
if m.Method == "" {
close(done)
return
}
}
}()
send(map[string]any{"id": 1, "method": "tools/call", "params": map[string]any{"name": "history"}})
t.Cleanup(func() { _ = inW.Close() })
select {
case <-done:
case <-time.After(10 * time.Second):
t.Fatal("the bundle did not answer")
}
amu.Lock()
defer amu.Unlock()
return asked
}
// History asks mesh/state.history with the state and the key alone, and hands back every kept value oldest
// first, a delete with its null value; nothing kept is an empty history; a refusal is the runtime's error
// (novox/hq ADR 0307).
func TestHistoryIsEveryKeptValueOldestFirst(t *testing.T) {
kept := []map[string]any{
{"key": "401", "value": map[string]any{"status": "open"}, "revision": 4, "op": "put", "at": "2026-10-10T19:46:00Z"},
{"key": "401", "value": map[string]any{"status": "fixing"}, "revision": 9, "op": "put", "at": "2026-10-11T08:01:02Z"},
{"key": "401", "value": nil, "revision": 12, "op": "delete", "at": "2026-10-11T09:30:00+02:00"},
}
var got, none, null []StateRevision
var refused error
asked := historying(t, func(m message) map[string]any {
var p map[string]any
_ = json.Unmarshal(m.Params, &p)
switch {
case p["state"] != "issues":
return map[string]any{"error": map[string]any{"code": -32000, "message": "issues was issued no state " + p["state"].(string)}}
case p["key"] == "401":
return map[string]any{"result": kept}
case p["key"] == "402":
return map[string]any{"result": nil}
}
return map[string]any{"result": []any{}}
}, func() {
var err error
if got, err = State("issues").History("401"); err != nil {
t.Errorf("history of 401: %v", err)
}
if none, err = State("issues").History("999"); err != nil {
t.Errorf("history of 999: %v", err)
}
if null, err = State("issues").History("402"); err != nil {
t.Errorf("history of 402: %v", err)
}
var h []StateRevision
h, refused = State("elsewhere.issues").History("401")
if h != nil {
t.Errorf("a refused history handed back %v", h)
}
})
if len(asked) != 4 {
t.Fatalf("asked %d times, not 4", len(asked))
}
var p map[string]any
_ = json.Unmarshal(asked[0].Params, &p)
if asked[0].Method != "mesh/state.history" || p["state"] != "issues" || p["key"] != "401" || len(p) != 2 {
t.Fatalf("asked %s %v", asked[0].Method, p)
}
if len(got) != 3 {
t.Fatalf("history of 401 is %d values, not 3: %+v", len(got), got)
}
want := []struct {
rev uint64
op string
value string
at time.Time
}{
{4, "put", `{"status":"open"}`, time.Date(2026, 10, 10, 19, 46, 0, 0, time.UTC)},
{9, "put", `{"status":"fixing"}`, time.Date(2026, 10, 11, 8, 1, 2, 0, time.UTC)},
{12, "delete", `null`, time.Date(2026, 10, 11, 7, 30, 0, 0, time.UTC)},
}
for i, w := range want {
r := got[i]
if r.Key != "401" || r.Revision != w.rev || r.Op != w.op || string(r.Value) != w.value || !r.At.Equal(w.at) {
t.Fatalf("value %d is %+v, not %+v", i, r, w)
}
}
if none == nil || len(none) != 0 || null == nil || len(null) != 0 {
t.Fatalf("nothing kept read as %v and %v, not an empty history", none, null)
}
if refused == nil || !strings.Contains(refused.Error(), "issued no state elsewhere.issues") {
t.Fatalf("a state not issued answered %v", refused)
}
}