diff --git a/go/catalogue/state.go b/go/catalogue/state.go index 85a747e..19f07eb 100644 --- a/go/catalogue/state.go +++ b/go/catalogue/state.go @@ -31,9 +31,13 @@ type StateDeclaration struct { // KeyTTL says each key of the bucket lives the time its writer gives it, and the server writes a delete marker // when it ends (novox/hq ADR 0433 ยง1): the bucket is made with the server's time to live per message. KeyTTL bool `json:"key-ttl,omitempty"` + // ReadOnly says only the module's processes whose own bus.state names the state write it; the module's tools, + // the tool runner's account, read it and never write it (the lead's ruling, 2026-11-04: nats' warrant spend record + // `spent`). The judge's rule and the grants follow from it. + ReadOnly bool `json:"read-only,omitempty"` } -// UnmarshalJSON reads a bucket as its bare name, or as {name, history, ttl-seconds}. +// UnmarshalJSON reads a bucket as its bare name, or as {name, history, ttl-seconds, per-machine, key-ttl, read-only}. func (s *StateDeclaration) UnmarshalJSON(raw []byte) error { trimmed := bytes.TrimSpace(raw) if len(trimmed) > 0 && trimmed[0] == '"' { @@ -44,7 +48,7 @@ func (s *StateDeclaration) UnmarshalJSON(raw []byte) error { dec := json.NewDecoder(bytes.NewReader(trimmed)) dec.DisallowUnknownFields() if err := dec.Decode(&full); err != nil { - return fmt.Errorf("a state is either a name or {name, history, ttl-seconds, per-machine, key-ttl}: %w", typedUnknown(err)) + return fmt.Errorf("a state is either a name or {name, history, ttl-seconds, per-machine, key-ttl, read-only}: %w", typedUnknown(err)) } *s = StateDeclaration(full) return nil @@ -52,7 +56,7 @@ func (s *StateDeclaration) UnmarshalJSON(raw []byte) error { // MarshalJSON writes back the short form when there is nothing else to say. func (s StateDeclaration) MarshalJSON() ([]byte, error) { - if s.History == 0 && s.TTLSeconds == 0 && !s.PerMachine && !s.KeyTTL { + if s.History == 0 && s.TTLSeconds == 0 && !s.PerMachine && !s.KeyTTL && !s.ReadOnly { return json.Marshal(s.Name) } type plain StateDeclaration diff --git a/go/catalogue/state_test.go b/go/catalogue/state_test.go index cf6cb7e..0da094f 100644 --- a/go/catalogue/state_test.go +++ b/go/catalogue/state_test.go @@ -2,6 +2,7 @@ package catalogue import ( "encoding/json" + "strings" "testing" ) @@ -28,17 +29,25 @@ func TestAManifestMaySayWhatStateItKeepsAndReads(t *testing.T) { } // 1154/AC1: a state entry may say key-ttl; it is read, written back, and a bare name or an entry without it reads as before. +// G-sdk-contracts 4: read-only likewise, and given twice it is refused. func TestAStateMayHaveKeysThatExpireOneByOne(t *testing.T) { m, err := decodeForTest([]byte(`{"module":"mesh-delivery-watchman","version":"1",` + - `"state":[{"name":"pulse","key-ttl":true},"stuck",{"name":"seen","history":5}]}`)) + `"state":[{"name":"pulse","key-ttl":true},"stuck",{"name":"seen","history":5},{"name":"spent","read-only":true}]}`)) if err != nil { t.Fatal(err) } if !m.State[0].KeyTTL || m.State[1].KeyTTL || m.State[2].KeyTTL { t.Fatalf("key-ttl not read as said: %+v", m.State) } + if !m.State[3].ReadOnly || m.State[0].ReadOnly || m.State[1].ReadOnly { + t.Fatalf("read-only not read as said: %+v", m.State) + } out, _ := json.Marshal(m.State) - if string(out) != `[{"name":"pulse","key-ttl":true},"stuck",{"name":"seen","history":5}]` { + if string(out) != `[{"name":"pulse","key-ttl":true},"stuck",{"name":"seen","history":5},{"name":"spent","read-only":true}]` { t.Fatalf("written back as %s", out) } + if _, err := decodeForTest([]byte(`{"module":"nats","version":"1","state":[{"name":"spent","read-only":true,"read-only":false}]}`)); err == nil || + !strings.Contains(err.Error(), "the field state[0].read-only is given twice") { + t.Fatalf("read-only given twice: %v", err) + } }