catalogue: a module's own object bucket, its reader grant and a stream that keeps its history, so the knowledge base can keep its records on the bus (ADR 0592 §5, §6, §9)

Claude-Session: https://claude.ai/code/session_01NcbgfJ9Gum47zkFKWRaovL
This commit is contained in:
2026-11-02 18:17:14 +01:00
parent 3d87ddcc9a
commit 04d951d9da
4 changed files with 180 additions and 2 deletions
+5
View File
@@ -612,6 +612,11 @@ type Manifest struct {
// hears a part of it only where that setting's value names it.
Streams []StreamDeclaration `json:"streams,omitempty"`
// Objects are the object buckets this module owns on the bus (novox/hq ADR 0592 §5): each named locally and
// bounded in size; the mesh creates each once, with no age limit, and refuses a new object when it is full. Only its
// module writes it; another reads it only through a `reads` entry and the bucket's grant (ADR 0592 §9).
Objects []ObjectsDeclaration `json:"objects,omitempty"`
// Settings are the defaults this module gives its settings (novox/hq ADR 0262): each key a file,
// a contribution or a served fact asks for as `${setting:<key>}`, its default, and why that
// default. Only a preference has one — a font size, a width, a number of workers — and a value
+104
View File
@@ -0,0 +1,104 @@
package catalogue
import (
"bytes"
"encoding/json"
"fmt"
)
// A module's own object bucket (novox/hq ADR 0592 §5, §9).
//
// A module declares an object bucket as it declares a state (ADR 0201) or a stream (ADR 0363): named locally,
// with its maximum size. The mesh creates it once, keeps every object with no age limit, and refuses a new object
// when it is full rather than dropping one; only its module writes it. Another module reads it only as a restricted
// stream is heard: a `reads` entry `<module>.<name>` naming it, and a module the owner's trusted config value of the
// bucket's grant names (ADR 0363 §4, §5), read-only. This is the shape only: its checks are the indexer's judge's.
// The mesh's bounds on a module's object bucket: its size in MiB (ADR 0592 §5, §25; the bound ADR 0363 §1 sets for a
// stream). A bucket says its size: there is no default.
const (
ObjectsLeastMiB = 1
ObjectsMostMiB = 8192
)
// FullWarnPercent is how full, in percent of its cap, a history stream or an object bucket is when the mesh raises its
// condition, in one place for every module (novox/hq ADR 0592 §6, §25).
const FullWarnPercent = 75
// ObjectsDeclaration is one object bucket a module owns.
type ObjectsDeclaration struct {
// Name is its local name, one plain name as a state's (lower-case letters, digits and hyphens).
Name string `json:"name"`
// MaxMiB bounds it, ObjectsLeastMiB to ObjectsMostMiB.
MaxMiB int `json:"max-mib"`
// Grant names the trusted config value of the module whose entries name the modules that may read it; absent,
// no other module reads it.
Grant *ObjectsGrant `json:"grant,omitempty"`
}
// ObjectsGrant says which trusted config value of the owning module grants another module the read of a bucket
// (novox/hq ADR 0592 §9): the value has a stream grant's shape (GrantedKeys), and a module any of its keys names, wherever
// it runs or as `<machine>.<module>`, reads the whole bucket, read-only. One value may grant a module's streams and its
// object buckets alike.
type ObjectsGrant struct {
// Config is the trusted config value of the module that grants it (ADR 0348 §8): set only at the controller's
// terminal or on the operator's Approve.
Config string `json:"config"`
}
// UnmarshalJSON reads an object bucket strictly: an unknown key is refused here, not passed over.
func (o *ObjectsDeclaration) UnmarshalJSON(raw []byte) error {
type plain ObjectsDeclaration
var p plain
dec := json.NewDecoder(bytes.NewReader(raw))
dec.DisallowUnknownFields()
if err := dec.Decode(&p); err != nil {
return fmt.Errorf("an object bucket is {name, max-mib, grant}: %w", typedUnknown(err))
}
*o = ObjectsDeclaration(p)
return nil
}
// UnmarshalJSON reads an object bucket's grant strictly.
func (g *ObjectsGrant) UnmarshalJSON(raw []byte) error {
type plain ObjectsGrant
var p plain
dec := json.NewDecoder(bytes.NewReader(raw))
dec.DisallowUnknownFields()
if err := dec.Decode(&p); err != nil {
return fmt.Errorf("an object bucket's grant is {config}: %w", typedUnknown(err))
}
*g = ObjectsGrant(p)
return nil
}
// ObjectsBucket is the bucket's name on the bus, as a state's is: the module and the local name joined by an
// underscore, which neither may hold, so two modules never derive one bucket.
func ObjectsBucket(module, local string) string { return module + "_" + local }
// Bucket is this bucket's name on the bus for its module.
func (o ObjectsDeclaration) Bucket(module string) string { return ObjectsBucket(module, o.Name) }
// ObjectsNamed is the module's object bucket of the local name, and whether it declares one.
func (m Manifest) ObjectsNamed(local string) (ObjectsDeclaration, bool) {
for _, o := range m.Objects {
if o.Name == local {
return o, true
}
}
return ObjectsDeclaration{}, false
}
// ObjectsReader says whether the restricting config values of a bucket's grant let module, on machine, read it
// (novox/hq ADR 0592 §9): a bucket with no grant is read by its owner alone, and a value of another shape grants nothing.
func (o ObjectsDeclaration) ObjectsReader(values []any, module, machine string) bool {
if o.Grant == nil {
return false
}
for _, v := range values {
if len(GrantedKeys(v, module, machine)) > 0 {
return true
}
}
return false
}
+55
View File
@@ -0,0 +1,55 @@
package catalogue
import (
"strings"
"testing"
)
// A module declares its own object bucket and a stream that keeps its history (ADR 0592 §5, §6, §9): both are read with
// their bounds, the bucket's grant names its readers through a trusted config value as a restricted stream's does, and
// an unknown key in either is refused, not passed over.
func TestAModuleDeclaresAnObjectBucketAndAHistoryStream(t *testing.T) {
m, err := decodeForTest([]byte(`{"module":"mesh-knowledge","version":"1",` +
`"streams":[{"name":"versions","events":["version.**"],"max-mib":2048,"retention":"history","grant":{"config":"readers","events":"{key}.**"}}],` +
`"objects":[{"name":"sections","max-mib":4096,"grant":{"config":"readers"}},{"name":"own","max-mib":1}]}`))
if err != nil {
t.Fatal(err)
}
s := m.Streams[0]
if !s.KeepsHistory() || s.Days() != 0 || s.Cap() != 2048 {
t.Fatalf("history stream read as %+v (days %d)", s, s.Days())
}
o, ok := m.ObjectsNamed("sections")
if !ok || o.MaxMiB != 4096 || o.Grant == nil || o.Grant.Config != "readers" || o.Bucket(m.Module) != "mesh-knowledge_sections" {
t.Fatalf("object bucket read as %+v", o)
}
readers := []any{map[string]any{"records": []any{"knowledge-web", "ace.mesh-view"}}}
if !o.ObjectsReader(readers, "knowledge-web", "g14") || !o.ObjectsReader(readers, "mesh-view", "ace") ||
o.ObjectsReader(readers, "mesh-view", "g14") || o.ObjectsReader(readers, "other", "ace") {
t.Fatal("the bucket's grant does not name its readers as a restricted stream's does")
}
own, _ := m.ObjectsNamed("own")
if own.ObjectsReader(readers, "knowledge-web", "g14") {
t.Fatal("a bucket with no grant is read by another module")
}
for name, raw := range map[string]string{
"bucket": `{"module":"x","version":"1","objects":[{"name":"a","max-mib":1,"max-age-days":3}]}`,
"bucket grant": `{"module":"x","version":"1","objects":[{"name":"a","max-mib":1,"grant":{"config":"r","events":"{key}"}}]}`,
} {
if _, err := decodeStrictObjects(raw); err == nil || !strings.Contains(err.Error(), "object bucket") {
t.Errorf("%s: an unknown key was not refused in the object bucket's words: %v", name, err)
}
}
}
func decodeStrictObjects(raw string) (Manifest, error) {
m, err := decodeForTest([]byte(raw))
if err == nil && m.SetAside() != "" {
return m, errString(m.SetAside())
}
return m, err
}
type errString string
func (e errString) Error() string { return string(e) }
+16 -2
View File
@@ -35,6 +35,12 @@ const GrantKey = "{key}"
// is the mesh's own limits retention — kept until it ages or the stream fills, dropping its oldest.
const StreamRetentionWorkQueue = "work-queue"
// StreamRetentionHistory is a stream's declared retention that keeps its history (novox/hq ADR 0592 §6): every message
// is kept with no maximum age, and when the stream reaches its maximum size the bus refuses a new message rather than
// dropping its oldest. It takes no max-age-days, max-deliveries or duplicates; the mesh raises a condition when it
// passes FullWarnPercent of its cap.
const StreamRetentionHistory = "history"
// StreamLeastDeliveries and StreamMostDeliveries bound a work-queue stream's max-deliveries (novox/hq ADR
// 0436 §6): how many times a message is handed over before it is given up as a dead letter.
const (
@@ -57,7 +63,8 @@ type StreamDeclaration struct {
// Grant restricts it, when said.
Grant *StreamGrant `json:"grant,omitempty"`
// Retention is how the stream decides what to keep when it fills (novox/hq ADR 0436 §6): empty for the
// mesh's own limits retention (dropping its oldest), or StreamRetentionWorkQueue.
// mesh's own limits retention (dropping its oldest), StreamRetentionWorkQueue, or StreamRetentionHistory
// (novox/hq ADR 0592 §6).
Retention string `json:"retention,omitempty"`
// MaxDeliveries bounds how many times a work-queue message is handed over, 1 to 10; refused without
// retention work-queue (novox/hq ADR 0436 §6).
@@ -104,14 +111,21 @@ func (g *StreamGrant) UnmarshalJSON(raw []byte) error {
return nil
}
// Days is the stream's age in days, the default where none is said.
// Days is the stream's age in days, the default where none is said, and 0 (no maximum age) for a stream that keeps its
// history.
func (s StreamDeclaration) Days() int {
if s.KeepsHistory() {
return 0
}
if s.MaxAgeDays == 0 {
return StreamDefaultDays
}
return s.MaxAgeDays
}
// KeepsHistory says the stream keeps every message and refuses a new one when full (StreamRetentionHistory).
func (s StreamDeclaration) KeepsHistory() bool { return s.Retention == StreamRetentionHistory }
// Cap is the stream's size in MiB, the default where none is said.
func (s StreamDeclaration) Cap() int {
if s.MaxMiB == 0 {