diff --git a/modules/mesh-knowledge/cmd/mesh-knowledge/kb.go b/modules/mesh-knowledge/cmd/mesh-knowledge/kb.go index e14584596..55e823e4a 100644 --- a/modules/mesh-knowledge/cmd/mesh-knowledge/kb.go +++ b/modules/mesh-knowledge/cmd/mesh-knowledge/kb.go @@ -38,11 +38,25 @@ const ( EventDrafted = "drafted" EventConfirmed = "confirmed" EventReturned = "returned" + // The events of a record written in a draft, section by section, and of the move to the ledger (novox/hq + // to-be 55 §1.1, ADR 0592 §34). + EventOpened = "opened" + EventWritten = "written" + EventRemoved = "removed" + EventOrdered = "ordered" + EventSubmitted = "submitted" + EventDropped = "dropped" + EventTakenOver = "taken-over" + EventPartReviewed = "part-reviewed" + EventMoved = "moved" + EventChecked = "checked" + EventFormed = "formed" ) // Events are the events, in the order the manifest declares them. var Events = []string{EventReserved, EventProposed, EventReviewed, EventAccepted, EventChanged, EventImported, - EventDrafted, EventConfirmed, EventReturned} + EventDrafted, EventConfirmed, EventReturned, EventOpened, EventWritten, EventRemoved, EventOrdered, EventSubmitted, + EventDropped, EventTakenOver, EventPartReviewed, EventMoved, EventChecked, EventFormed} // Settings are the operator's values for this module, merged by the mesh into one JSON file. type Settings struct { diff --git a/modules/mesh-knowledge/cmd/mesh-knowledge/ledger.go b/modules/mesh-knowledge/cmd/mesh-knowledge/ledger.go new file mode 100644 index 000000000..956fcbe71 --- /dev/null +++ b/modules/mesh-knowledge/cmd/mesh-knowledge/ledger.go @@ -0,0 +1,32 @@ +package main + +// The ledger the records are kept in (novox/hq ADR 0597 §11, to-be 55 §2.1): one instance per record, a type for each +// kind, every change an operation the module hands to the tool runner's apply. + +// LedgerName is the local name the module asks its ledger for in `requires`. +const LedgerName = "records" + +// The operations the ledger's types allow (ADR 0597 §11: reserve, propose, write-section, submit, take-over, review, ask, +// warrant, accept and withdraw; and those the module's other changes of a record are, so each leaves its own mark in +// the history). +const ( + OpReserve = "reserve" + OpPropose = "propose" + OpWriteSection = "write-section" + OpSubmit = "submit" + OpTakeOver = "take-over" + OpReview = "review" + OpAsk = "ask" + OpWarrant = "warrant" + OpAccept = "accept" + OpWithdraw = "withdraw" + OpRetract = "retract" + OpDraft = "draft" + OpDrop = "drop" + OpImport = "import" + OpForm = "form" +) + +// LedgerOperations are the operations, in the order the manifest's types allow them. +var LedgerOperations = []string{OpReserve, OpPropose, OpWriteSection, OpSubmit, OpDrop, OpImport, OpForm, OpTakeOver, + OpReview, OpAsk, OpWarrant, OpAccept, OpWithdraw, OpRetract, OpDraft} diff --git a/modules/mesh-knowledge/cmd/mesh-knowledge/manifest_test.go b/modules/mesh-knowledge/cmd/mesh-knowledge/manifest_test.go index 76f777378..50f6dcfad 100644 --- a/modules/mesh-knowledge/cmd/mesh-knowledge/manifest_test.go +++ b/modules/mesh-knowledge/cmd/mesh-knowledge/manifest_test.go @@ -5,9 +5,12 @@ import ( "os" "path/filepath" "reflect" + "slices" "strings" "testing" "time" + + "git.novox.be/novox/mesh-sdk/go/catalogue" ) // The manifest says what the code does (novox/hq to-be 55 §1): the seat it declares and holds once for the mesh @@ -16,7 +19,7 @@ import ( type manifest struct { Module string `json:"module"` - Requires []string `json:"requires"` + Requires []json.RawMessage `json:"requires"` Contributes map[string]map[string]any `json:"contributes"` Seats []seat `json:"seats"` Claims []seat `json:"claims"` @@ -70,12 +73,13 @@ func TestItDeclaresAndHoldsTheKnowledgeBaseSeatOnceForTheMesh(t *testing.T) { if !reflect.DeepEqual(m.Emits, Events) { t.Fatalf("emits %v, the code says %v", m.Emits, Events) } - // Its records are in a database in the store (ADR 0290 rule 7), not in buckets. - if !reflect.DeepEqual(m.Requires, []string{"postgres-database"}) || m.Contributes["postgres-database"]["name"] != "knowledge" || len(m.State) != 0 { - t.Fatalf("requires %v, contributes %v, state %v", m.Requires, m.Contributes, m.State) + // Until the database is dropped on the operator's yes, the database stays asked for (novox/hq ADR 0592 §14, §15); the + // records and the module's other kept data are asked for beside it (TestItAsksForItsBusStateInTheTypedForm). + if string(m.Requires[0]) != `"postgres-database"` || m.Contributes["postgres-database"]["name"] != "knowledge" || len(m.State) != 0 { + t.Fatalf("requires %s, contributes %v, state %v", m.Requires, m.Contributes, m.State) } - // It asks the operator on the router, hears the router's word, and calls exactly what it calls. - if !reflect.DeepEqual(m.Uses, []string{"operator-channel"}) || len(m.Consumes) != 0 { + // It asks the operator on the router, hears the router's word, and hears the judge's answers. + if !reflect.DeepEqual(m.Uses, []string{"operator-channel"}) || !reflect.DeepEqual(m.Consumes, []string{"claude-code.judged"}) { t.Fatalf("uses %v, consumes %v", m.Uses, m.Consumes) } if !reflect.DeepEqual(m.Invokes, []string{"seat:issue-tracker.reserve", "claude-code.claude_code_judge", "claude-code.claude_code_judged", "records.records_status", @@ -212,3 +216,60 @@ func TestTheHealthToolReadsTheWatchsVerdictAndSaysWhenItIsStale(t *testing.T) { t.Fatalf("a stale verdict: %+v", a) } } + +// Its kept data is asked for in `requires` in the typed long form, and the manifest reads whole as the mesh reads it +// (design 55 §1, §2.1; design 32 §4): the ledger of the records with its two types, the module's other states, and the +// stream of the parts' verdicts, kept for good, refusing when full, granted by a trusted config value. +func TestItAsksForItsBusStateInTheTypedForm(t *testing.T) { + _, raw := readManifest(t) + m, err := catalogue.Decode([]byte(raw)) + if err != nil { + t.Fatal(err) + } + var names []string + byName := map[string]catalogue.BusStateEntry{} + for _, e := range m.BusState { + names = append(names, e.Name) + byName[e.Name] = e + } + if !reflect.DeepEqual(names, []string{"records", "drafts", "asked", "findings", "sent", "reviews"}) { + t.Fatalf("bus state %v", names) + } + rec := byName["records"] + if rec.Storage != catalogue.StorageLedger || rec.Template != catalogue.TemplateLedger || len(rec.Types) != 2 { + t.Fatalf("records %+v", rec) + } + if !reflect.DeepEqual(rec.Types[0].Schema, rec.Types[1].Schema) || !reflect.DeepEqual(rec.Types[0].Operations, rec.Types[1].Operations) || + rec.Types[0].Name != KindDecision || rec.Types[1].Name != KindResearch { + t.Fatalf("the two types differ in more than their names") + } + have := map[string]bool{} + for _, op := range rec.Types[0].Operations { + have[op.Name] = true + } + for _, op := range LedgerOperations { + if !have[op] { + t.Errorf("the code applies the operation %s, which the ledger's types do not allow", op) + } + } + if len(have) != len(LedgerOperations) { + t.Errorf("the types allow %d operations, the code names %d", len(have), len(LedgerOperations)) + } + for _, n := range []string{"drafts", "asked", "findings", "sent"} { + if byName[n].Storage != catalogue.StorageKeyValue || byName[n].Template != catalogue.TemplateState { + t.Errorf("%s is %+v, a state", n, byName[n]) + } + } + rv := byName["reviews"] + var grant string + _ = json.Unmarshal(rv.Configuration[catalogue.OptionGrant], &grant) + if rv.Storage != catalogue.StorageStream || rv.Template != "" || grant != "readers" || string(rv.Configuration[catalogue.ConfigWhenFull]) != `"refuse"` { + t.Fatalf("reviews %+v", rv) + } + if !slices.Contains(catalogue.TerminalKeys(m), "readers") { + t.Fatalf("readers is not a terminal key (%v): the grant names a value only the controller's terminal or the operator's Approve sets", catalogue.TerminalKeys(m)) + } + if len(m.Reads) != 0 { + t.Fatalf("it reads %v, and no module reads another's bus state (ADR 0596 §2)", m.Reads) + } +} diff --git a/modules/mesh-knowledge/files/readers.json b/modules/mesh-knowledge/files/readers.json new file mode 100644 index 000000000..aea1d275d --- /dev/null +++ b/modules/mesh-knowledge/files/readers.json @@ -0,0 +1,3 @@ +{ + "readers": {} +} diff --git a/modules/mesh-knowledge/module.json b/modules/mesh-knowledge/module.json index f4ed646fe..314de843f 100644 --- a/modules/mesh-knowledge/module.json +++ b/modules/mesh-knowledge/module.json @@ -3,7 +3,81 @@ "version": "1", "slug": "knowledge", "requires": [ - "postgres-database" + "postgres-database", + { + "name": "records", "storage": "ledger", "template": "ledger", "options": {"max-mib": 1024}, + "types": [ + { + "name": "decision", + "schema": { + "kind": {"type": "string"}, + "number": {"type": "integer"}, + "entry": {"type": "object"}, + "version": {"type": "object"}, + "pending-sections": {"type": "object"}, + "title": {"type": "string"}, + "text": {"type": "string"}, + "digest": {"type": "string", "digest-of": "text"}, + "why": {"type": "string"}, + "reason": {"type": "string"} + }, + "operations": [ + {"name": "reserve", "effect": "set-fields", "fields": ["kind", "number", "entry"], "sequence": true, "author": true}, + {"name": "propose", "effect": "set-fields", "fields": ["kind", "number", "entry", "pending-sections"], "sequence": true, "author": true}, + {"name": "write-section", "effect": "set-entry", "into": "pending-sections", "key": "title", "fields": ["title", "digest"], "sequence": true, "author": true}, + {"name": "submit", "effect": "set-fields", "fields": ["kind", "number", "entry", "pending-sections"], "sequence": true, "author": true}, + {"name": "drop", "effect": "set-fields", "fields": ["kind", "number", "entry", "pending-sections"], "sequence": true, "author": true}, + {"name": "import", "effect": "set-fields", "fields": ["kind", "number", "entry", "pending-sections"], "sequence": true, "author": true}, + {"name": "form", "effect": "set-fields", "fields": ["kind", "number", "entry", "pending-sections"], "sequence": true, "author": true}, + {"name": "take-over", "effect": "set-fields", "fields": ["kind", "number", "entry"], "sequence": true, "author": true}, + {"name": "review", "effect": "set-fields", "fields": ["kind", "number", "entry"], "sequence": true, "author": true}, + {"name": "ask", "effect": "set-fields", "fields": ["kind", "number", "entry"], "sequence": true, "author": true}, + {"name": "warrant", "effect": "set-fields", "fields": ["kind", "number", "entry"], "sequence": true, "author": true}, + {"name": "accept", "effect": "set-fields", "fields": ["kind", "number", "entry"], "sequence": true, "author": true}, + {"name": "withdraw", "effect": "set-fields", "fields": ["kind", "number", "entry"], "sequence": true, "author": true}, + {"name": "retract", "effect": "set-fields", "fields": ["kind", "number", "entry"], "sequence": true, "author": true}, + {"name": "draft", "effect": "set-fields", "fields": ["kind", "number", "entry"], "sequence": true, "author": true} + ] + }, + { + "name": "research", + "schema": { + "kind": {"type": "string"}, + "number": {"type": "integer"}, + "entry": {"type": "object"}, + "version": {"type": "object"}, + "pending-sections": {"type": "object"}, + "title": {"type": "string"}, + "text": {"type": "string"}, + "digest": {"type": "string", "digest-of": "text"}, + "why": {"type": "string"}, + "reason": {"type": "string"} + }, + "operations": [ + {"name": "reserve", "effect": "set-fields", "fields": ["kind", "number", "entry"], "sequence": true, "author": true}, + {"name": "propose", "effect": "set-fields", "fields": ["kind", "number", "entry", "pending-sections"], "sequence": true, "author": true}, + {"name": "write-section", "effect": "set-entry", "into": "pending-sections", "key": "title", "fields": ["title", "digest"], "sequence": true, "author": true}, + {"name": "submit", "effect": "set-fields", "fields": ["kind", "number", "entry", "pending-sections"], "sequence": true, "author": true}, + {"name": "drop", "effect": "set-fields", "fields": ["kind", "number", "entry", "pending-sections"], "sequence": true, "author": true}, + {"name": "import", "effect": "set-fields", "fields": ["kind", "number", "entry", "pending-sections"], "sequence": true, "author": true}, + {"name": "form", "effect": "set-fields", "fields": ["kind", "number", "entry", "pending-sections"], "sequence": true, "author": true}, + {"name": "take-over", "effect": "set-fields", "fields": ["kind", "number", "entry"], "sequence": true, "author": true}, + {"name": "review", "effect": "set-fields", "fields": ["kind", "number", "entry"], "sequence": true, "author": true}, + {"name": "ask", "effect": "set-fields", "fields": ["kind", "number", "entry"], "sequence": true, "author": true}, + {"name": "warrant", "effect": "set-fields", "fields": ["kind", "number", "entry"], "sequence": true, "author": true}, + {"name": "accept", "effect": "set-fields", "fields": ["kind", "number", "entry"], "sequence": true, "author": true}, + {"name": "withdraw", "effect": "set-fields", "fields": ["kind", "number", "entry"], "sequence": true, "author": true}, + {"name": "retract", "effect": "set-fields", "fields": ["kind", "number", "entry"], "sequence": true, "author": true}, + {"name": "draft", "effect": "set-fields", "fields": ["kind", "number", "entry"], "sequence": true, "author": true} + ] + } + ] + }, + {"name": "drafts", "storage": "key-value", "template": "state", "options": {"key-ttl": true}}, + {"name": "asked", "storage": "key-value", "template": "state"}, + {"name": "findings", "storage": "key-value", "template": "state"}, + {"name": "sent", "storage": "key-value", "template": "state"}, + {"name": "reviews", "storage": "stream", "configuration": {"when-full": "refuse", "max-mib": 512, "max-age-days": "none", "grant": "readers"}} ], "contributes": { "postgres-database": { @@ -74,7 +148,21 @@ "imported", "drafted", "confirmed", - "returned" + "returned", + "opened", + "written", + "removed", + "ordered", + "submitted", + "dropped", + "taken-over", + "part-reviewed", + "moved", + "checked", + "formed" + ], + "consumes": [ + "claude-code.judged" ], "uses": [ "operator-channel" @@ -96,6 +184,11 @@ "seat:mesh-delivery.groups" ], "config": { + "readers": { + "type": "object", + "description": "The modules heard on the reviews of records (the stream of each part's verdict), by name, each with why; empty grants none. Set only at the controller's terminal or on the operator's Approve.", + "default": {} + }, "judge-model": { "type": "string", "description": "The model the knowledge base's drafting and confirming judgements ask, by the agent's name for it, such as sonnet; empty is sonnet.", @@ -138,6 +231,15 @@ "merge": "json", "template": "files/settings.json" }, + { + "id": "readers", + "type": "file", + "path": "${dir:state}/readers.json", + "mode": "0600", + "takes-config": ["readers"], + "merge": "json", + "template": "files/readers.json" + }, { "id": "prepare", "type": "process",