diff --git a/go/buscompose/names_test.go b/go/buscompose/names_test.go index 3284dff2..c934da93 100644 --- a/go/buscompose/names_test.go +++ b/go/buscompose/names_test.go @@ -29,6 +29,10 @@ func TestTheBusNamesAgree(t *testing.T) { if got := (Principal{Kind: KindLedger, Node: "a", Module: "m"}).Username(); got != "ledger.a.m" { t.Fatalf("a ledger account is named %q", got) } + // G §469: a state's bucket is capped at its max-mib; naming none is unbounded. + if (Bucket{}).MaxBytes() != -1 || (Bucket{MaxMiB: 2}).MaxBytes() != 2<<20 { + t.Fatal("a bucket's cap is not its max-mib in bytes") + } // A seat's family is the shared events stream filtered to its subjects: a definition says the stream's age. for _, st := range MeshStreams() { if st.Name == EventsStream && int64(st.MaxAge) != seatdef.SharedEventsMaxAgeDays*24*60*60 { diff --git a/go/buscompose/state.go b/go/buscompose/state.go index 1a84d114..8e2d7340 100644 --- a/go/buscompose/state.go +++ b/go/buscompose/state.go @@ -24,6 +24,16 @@ type Bucket struct { // KeyTTL says the bucket allows a time to live per key (novox/hq ADR 0433 §1): its owner writes each key with its // own, and the server writes a delete marker, kept KeyTTLMarkerTTL, when it ends. KeyTTL bool + // MaxMiB is the bucket's cap in MiB (design 47 G §469); zero is no cap. + MaxMiB int +} + +// MaxBytes is the bucket's cap in bytes as the bus's maximum size: -1, unbounded, where it names none. +func (b Bucket) MaxBytes() int64 { + if b.MaxMiB <= 0 { + return -1 + } + return int64(b.MaxMiB) * 1024 * 1024 } // BucketName is the bucket a module's state lives in: the module and the local name joined by an diff --git a/go/catalogue/state.go b/go/catalogue/state.go index 2bca54ed..daf041a9 100644 --- a/go/catalogue/state.go +++ b/go/catalogue/state.go @@ -35,11 +35,14 @@ type StateDeclaration struct { // 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"` + // MaxMiB is the bucket's cap in MiB, between ObjectsLeastMiB and ObjectsMostMiB, as an old objects section's + // is (design 47 G §469, §695); zero is no cap, as on main. + MaxMiB int `json:"max-mib,omitempty"` // KeptLayout is the layout its holder says it writes: its form and its catch-up tool (design 47). KeptLayout } -// UnmarshalJSON reads a bucket as its bare name, or as {name, history, ttl-seconds, per-machine, key-ttl, read-only, form, catch-up}. +// UnmarshalJSON reads a bucket as its bare name, or as {name, history, ttl-seconds, per-machine, key-ttl, read-only, max-mib, form, catch-up}. func (s *StateDeclaration) UnmarshalJSON(raw []byte) error { trimmed := bytes.TrimSpace(raw) if len(trimmed) > 0 && trimmed[0] == '"' { @@ -47,21 +50,28 @@ func (s *StateDeclaration) UnmarshalJSON(raw []byte) error { } type plain StateDeclaration var full plain + var said struct { + MaxMiB *int `json:"max-mib"` + } + _ = json.Unmarshal(trimmed, &said) 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, read-only, form, catch-up}: %w", typedUnknown(err)) + return fmt.Errorf("a state is either a name or {name, history, ttl-seconds, per-machine, key-ttl, read-only, max-mib, form, catch-up}: %w", typedUnknown(err)) } if err := full.KeptLayout.refuse(fmt.Sprintf("state %q", full.Name)); err != nil { return err } + if said.MaxMiB != nil && (*said.MaxMiB < ObjectsLeastMiB || *said.MaxMiB > ObjectsMostMiB) { + return fmt.Errorf("state %q: max-mib is between %d and %d, not %d", full.Name, ObjectsLeastMiB, ObjectsMostMiB, *said.MaxMiB) + } *s = StateDeclaration(full) return nil } // 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 && !s.ReadOnly && s.KeptLayout == (KeptLayout{}) { + if s.History == 0 && s.TTLSeconds == 0 && !s.PerMachine && !s.KeyTTL && !s.ReadOnly && s.MaxMiB == 0 && s.KeptLayout == (KeptLayout{}) { return json.Marshal(s.Name) } type plain StateDeclaration diff --git a/go/catalogue/state_test.go b/go/catalogue/state_test.go index 0da094f7..a5549cc1 100644 --- a/go/catalogue/state_test.go +++ b/go/catalogue/state_test.go @@ -46,6 +46,22 @@ func TestAStateMayHaveKeysThatExpireOneByOne(t *testing.T) { 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) } + // G §469, §695: an old state entry takes max-mib, written back, bounded as an objects section's. + capped, err := decodeForTest([]byte(`{"module":"x","version":"1","state":[{"name":"a","max-mib":256},"b"]}`)) + if err != nil || capped.State[0].MaxMiB != 256 || capped.State[1].MaxMiB != 0 { + t.Fatalf("max-mib read as %+v, %v", capped.State, err) + } + if out, _ := json.Marshal(capped.State); string(out) != `[{"name":"a","max-mib":256},"b"]` { + t.Fatalf("max-mib written back as %s", out) + } + for _, bad := range []string{"0", "-1", "8193"} { + if _, err := decodeForTest([]byte(`{"module":"x","version":"1","state":[{"name":"a","max-mib":` + bad + `}]}`)); err == nil { + t.Errorf("max-mib %s was read", bad) + } + } + if _, err := decodeForTest([]byte(`{"module":"x","version":"1","state":[{"name":"a","max-mib":8192}]}`)); err != nil { + t.Errorf("max-mib 8192 refused: %v", err) + } 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)