Join: merge A6 (wip/registry-a6)

Conflict in mesh-knowledge main.go, the propose tool's description: #609 (79c2fc7f)
names ideas and the research-active-at-most limit, A6 names a research effort's
partial new version; both sentences kept, A6's rule count (31).
This commit is contained in:
2026-11-04 15:12:01 +01:00
65 changed files with 7605 additions and 265 deletions
+54
View File
@@ -670,6 +670,7 @@ func (h *Holder) showAsk(key string, rec AskRecord, now time.Time) {
target := h.open[rec.Record]
h.mu.Unlock()
h.Logf("[messenger] %s asks %q (%s), shown on %s", asker, a.Headline, a.ID, rec.Record)
h.sayOffered(key, rec)
if target != nil {
h.mu.Lock()
target.Ask = key
@@ -917,6 +918,7 @@ func (h *Holder) closeAsk(key string, outcome asks.Outcome, words string, w *ask
rev = newRev
}
h.keepAsk(key, rec, rev)
h.sayClosed(key, rec, outcome, words)
if rec.Asker == SelfAsker {
// The router's own ask: it does what was decided, and tells nobody else (selfact.go).
h.markTold(key)
@@ -1416,3 +1418,55 @@ func (h *Holder) AsksNow() []AskView {
})
return out
}
// sayOffered says that an ask was offered, once: its id, asker, headline and the kinds of channel it was offered on; never an
// option, a ticket or a warrant's proof, so it says no more than `asks` already answers (novox/hq ADR 0616 §7). A held ask is
// said when it is released and shown, not before.
func (h *Holder) sayOffered(key string, rec AskRecord) {
if h.Emit == nil || rec.State != StateOpen {
return
}
h.mu.Lock()
if h.offeredSaid == nil {
h.offeredSaid = map[string]bool{}
}
done := h.offeredSaid[key]
h.offeredSaid[key] = true
h.mu.Unlock()
if done {
return
}
kinds := map[string]bool{}
for _, t := range rec.Tickets {
kinds[t.Kind] = true
}
var on []string
for k := range kinds {
on = append(on, k)
}
sort.Strings(on)
if err := h.Emit("offered", map[string]any{"id": rec.Ask.ID, "asker": rec.Asker, "headline": rec.Ask.Headline, "channels": on}); err != nil {
h.Logf("[messenger] that the ask %s was offered could not be said: %v", key, err)
}
}
// sayClosed says how an ask ended, once per ask (its end is recorded by compare-and-set, so a second close never gets here): answered,
// expired, cancelled by its asker, replaced by a newer ask, or ended by the router itself on an unlink or allow-link.
func (h *Holder) sayClosed(key string, rec AskRecord, outcome asks.Outcome, words string) {
if h.Emit == nil {
return
}
how := string(outcome)
switch {
case outcome == asks.OutcomeChosen:
how = "answered"
case outcome == asks.OutcomeCancelled && words != "its asker took it back":
how = "ended by the router"
}
h.mu.Lock()
delete(h.offeredSaid, key)
h.mu.Unlock()
if err := h.Emit("closed", map[string]any{"id": rec.Ask.ID, "asker": rec.Asker, "how": how}); err != nil {
h.Logf("[messenger] that the ask %s ended could not be said: %v", key, err)
}
}
@@ -0,0 +1,96 @@
package main
import (
"encoding/json"
"strings"
"testing"
"time"
"git.novox.be/novox/mesh-sdk/go/asks"
)
// The router says when an ask starts and ends (ADR 0616 §7): `offered` once when an ask is offered, `closed` once for each of the five ends
// (answered, expired, cancelled by its asker, replaced, ended by the router), with how it ended and never an option, a ticket or a
// proof; a held ask is said when it is released.
func TestTheRouterSaysWhenAnAskIsOfferedAndWhenItEnds(t *testing.T) {
h, tg, _, c, store, _ := newRouter(t)
link(h, store, c, "42", c.now().Add(-2*time.Hour))
type said struct {
event string
body map[string]any
}
var got []said
h.Emit = func(event string, body any) error {
raw, _ := json.Marshal(body)
var m map[string]any
_ = json.Unmarshal(raw, &m)
got = append(got, said{event, m})
return nil
}
ends := func() (out []string) {
for _, s := range got {
if s.event == "closed" {
out = append(out, s.body["asker"].(string)+"."+s.body["id"].(string)+"="+s.body["how"].(string))
}
}
return
}
// Answered.
submit(t, h, "mesh-delivery", heldAsk(c, "d-1"))
settle(h, c)
if len(got) != 1 || got[0].event != "offered" || got[0].body["id"] != "d-1" || got[0].body["asker"] != "mesh-delivery" {
t.Fatalf("%+v", got)
}
release := ticketFor(tg.sends[0], "Release")
if _, err := h.Chosen("telegram", choiceOf(release, "42", c.now())); err != nil {
t.Fatal(err)
}
// Replaced, then cancelled by its asker.
a := ackAsk(c, "d-2")
a.About = "delivery.hq"
submit(t, h, "mesh-delivery", a)
b := ackAsk(c, "d-3")
b.About = "delivery.hq"
submit(t, h, "mesh-delivery", b)
if err := h.TakeAsk("cancel", "mesh-delivery", json.RawMessage(`{"id":"d-3"}`)); err != nil {
t.Fatal(err)
}
// Expired.
e := heldAsk(c, "d-4")
e.About = "other"
submit(t, h, "mesh-delivery", e)
settle(h, c)
c.pass(3 * time.Hour)
stand(h, c)
h.Tick()
// Ended by the router itself (an unlink or allow-link ends its own asks the same way).
f := ackAsk(c, "d-5")
f.About = "third"
submit(t, h, "mesh-delivery", f)
if err := h.closeAsk("mesh-delivery.d-5", asks.OutcomeCancelled, "root decided to allow a link", nil, "", nil); err != nil {
t.Fatal(err)
}
want := "mesh-delivery.d-1=answered mesh-delivery.d-2=replaced mesh-delivery.d-3=cancelled mesh-delivery.d-4=expired mesh-delivery.d-5=ended by the router"
if gotEnds := strings.Join(ends(), " "); gotEnds != want {
t.Fatalf("the ends %q, want %q", gotEnds, want)
}
// Once each: a second close loses and says nothing; five asks were offered, once each; no option, ticket or proof is in a body.
_ = h.closeAsk("mesh-delivery.d-1", asks.OutcomeCancelled, "again", nil, "", nil)
offered := 0
for _, s := range got {
if s.event == "offered" {
offered++
}
for k := range s.body {
if k != "id" && k != "asker" && k != "headline" && k != "channels" && k != "how" {
t.Errorf("%s carries %s, which the seat's contract does not have", s.event, k)
}
if k == "options" || k == "tickets" || k == "token" || k == "proofs" || k == "warrant" {
t.Errorf("%s carries %s", s.event, k)
}
}
}
if offered != 5 || len(ends()) != 5 {
t.Fatalf("%d offered, %d closed", offered, len(ends()))
}
}
@@ -313,6 +313,8 @@ type Holder struct {
Logf func(string, ...any)
// Emit states a fact as this module (refused); nil states nothing.
Emit func(event string, body any) error
// offeredSaid are the asks whose offering was said, so it is said once.
offeredSaid map[string]bool
// Ask calls a seat verb through the mesh, as this module: the answers the operator chooses.
Ask Asker
// RootFree asks each machine's engine's root-free (the node seat node-engine's, ADR 0563 §6); nil asks it through Ask
@@ -94,7 +94,7 @@ func TestItDeclaresTheSeatsOfAnAskAndHoldsTheOperatorChannel(t *testing.T) { //
}
// It performs nothing: the verbs it calls read the open conditions, whether root is free, and which issues
// track which conditions.
if !reflect.DeepEqual(m.Emits, []string{"refused"}) || !reflect.DeepEqual(m.Invokes, []string{
if !reflect.DeepEqual(m.Emits, []string{"refused", "offered", "closed"}) || !reflect.DeepEqual(m.Invokes, []string{
"seat:mesh-conditions.conditions", "seat:" + EngineSeat + ".root-free", TrackingVerb}) {
t.Fatalf("emits %v invokes %v", m.Emits, m.Invokes)
}
+2 -1
View File
@@ -2,5 +2,6 @@
"time-zone": "",
"only-what-needs-me": [],
"messages-per-hour": 100,
"settle-seconds": 120
"settle-seconds": 120,
"readers": {"offered": ["mesh-knowledge"], "closed": ["mesh-knowledge"]}
}
+25 -2
View File
@@ -86,7 +86,24 @@
"intake.standing"
],
"emits": [
"refused"
"refused",
"offered",
"closed"
],
"streams": [
{
"name": "asks",
"events": [
"offered",
"closed"
],
"max-age-days": 7,
"max-mib": 64,
"grant": {
"config": "readers",
"events": "{key}"
}
}
],
"invokes": [
"seat:mesh-conditions.conditions",
@@ -117,6 +134,12 @@
"messenger_unlink"
],
"config": {
"readers": {
"type": "object",
"description": "Who may hear an ask offered or closed (the stream of the asks, kept seven days), per event: offered and closed, each a list of module names, on every machine. The knowledge base alone by default, for its overview page; an asker's own answers (decided) are never on it (ADR 0616 §7).",
"trusted": true,
"default": {"offered": ["mesh-knowledge"], "closed": ["mesh-knowledge"]}
},
"time-zone": {
"type": "string",
"description": "The time zone every time a message says is said in, an IANA name such as Europe/Brussels; empty is this machine's own zone.",
@@ -173,7 +196,7 @@
"type": "file",
"path": "${dir:state}/settings.json",
"mode": "0600",
"takes-config": ["time-zone", "desktop-machines", "only-what-needs-me", "messages-per-hour", "settle-seconds"],
"takes-config": ["time-zone", "desktop-machines", "only-what-needs-me", "messages-per-hour", "settle-seconds", "readers"],
"merge": "json",
"template": "files/settings.json",
"owner": "messenger"