Node-engine: while the seat list is unread, the node's own composition lets through the holder of the seat mesh-catalog's resources and accounts over what runs (ADR 0636 §1; issue 1966)

Claude-Session: https://claude.ai/code/session_01NcbgfFSnn24M3GcqttoU7i
This commit is contained in:
2026-11-05 16:22:15 +01:00
parent c1a1efa62a
commit ae26376a90
2 changed files with 184 additions and 0 deletions
+122
View File
@@ -28,6 +28,7 @@ import (
"git.novox.be/novox/mesh-delivery/contracts/go/clients/meshcatalogclient"
"git.novox.be/novox/mesh-delivery/contracts/go/seats/meshcatalog"
"git.novox.be/novox/mesh-delivery/modules/mesh-delivery-builder/builds"
"git.novox.be/novox/mesh-sdk/go/declaration"
"github.com/novox/mesh-host/internal/buckets"
"github.com/novox/mesh-host/internal/compositions"
engcomp "github.com/novox/mesh-host/internal/compositions"
@@ -1784,6 +1785,18 @@ const WaitsForTheSeatList = "the node has not read the seat list, so it composes
// cannot judge before the list was read, so only the last applied one runs (ADR 0636 §1).
const WaitsForTheSeatListOwn = "the node has not read the seat list, so it composes nothing new of itself and keeps what runs (novox/hq ADR 0636 §1)"
// WaitsForTheSeatListOwnPart is why the rest of the node's own composition is held: of it the node composes what the holder
// of the seat mesh-catalog on this node needs, so that holder can start and the list can be read (ADR 0636 §1).
const WaitsForTheSeatListOwnPart = "the node has not read the seat list, so it composes of itself only what the holder of the seat mesh-catalog needs " +
"and keeps what runs of the rest (novox/hq ADR 0636 §1)"
// Unread is the gate of a node whose reader of the seat list could not start: it has no list and can never read one, so it
// acts on nothing new, as a reader that waits does, where a nil gate would act on everything (ADR 0636 §1).
type Unread struct{}
// MayAct is false for every seat.
func (Unread) MayAct(...string) bool { return false }
// holdUntilListed takes out of compositions every module's composition that is new (no running composition has its
// digest) while the reader may not act on its seats, and names the module in held: it keeps running what it runs, and
// the pass after the list was read hands it. The node's own composition is held the same way, whatever it folds, under
@@ -1808,6 +1821,16 @@ func (c *Composer) holdUntilListed(ctx context.Context, r reader, compositions m
continue
}
module, why = agent.NodeModule, WaitsForTheSeatListOwn
// The list is read from the holder of the seat mesh-catalog, which the node's own composition may have to
// start: its part is composed whatever the list says, over what the node's own composition runs, and only that.
if part, ok := c.holderPart(ctx, r, comp, running); ok {
compositions[key] = part
if held == nil {
held = map[string]string{}
}
held[module] = WaitsForTheSeatListOwnPart
continue
}
} else if c.env.SeatList.MayAct(c.seatsOf(ctx, r, module)...) {
continue
}
@@ -1822,6 +1845,105 @@ func (c *Composer) holdUntilListed(ctx context.Context, r reader, compositions m
return held
}
// holderPart is the node's own composition as it runs with, of the new one comp, what the holders of the seat mesh-catalog
// on this node need: their resources and their accounts, each replacing the one of the same id or name or following the
// rest. False when the new composition has nothing of a holder: the caller holds the composition whole then.
func (c *Composer) holderPart(ctx context.Context, r reader, comp compositions.Composed, running []compositions.Composed) (compositions.Composed, bool) {
holders := c.claimants(ctx, r, meshcatalog.Seat)
if len(holders) == 0 {
return compositions.Composed{}, false
}
out := compositions.Composed{V: comp.V, Node: comp.Node, Plan: comp.Plan}
for _, run := range running {
if run.Module == "" {
out = run
out.Resources = slices.Clone(run.Resources)
out.Filled = slices.Clone(run.Filled)
out.Own.Identities = slices.Clone(run.Own.Identities)
break
}
}
belongs := func(module, id string) bool {
for _, h := range holders {
if module == h || strings.HasPrefix(id, h+".") {
return true
}
}
return false
}
touched := false
paths := map[string]bool{}
for _, raw := range comp.Resources {
var head struct{ ID, Module, Type, Path string }
if json.Unmarshal(raw, &head) != nil || !belongs(head.Module, head.ID) {
continue
}
touched = true
if head.Type == "file" {
paths[head.Path] = true
}
at := slices.IndexFunc(out.Resources, func(have json.RawMessage) bool {
var h struct{ ID string }
return json.Unmarshal(have, &h) == nil && h.ID == head.ID
})
if at >= 0 {
out.Resources[at] = raw
} else {
out.Resources = append(out.Resources, raw)
}
}
for _, id := range comp.Own.Identities {
if !belongs(id.Module, "") {
continue
}
touched = true
at := slices.IndexFunc(out.Own.Identities, func(have declaration.Identity) bool { return have.Module == id.Module && have.Name == id.Name })
if at >= 0 {
out.Own.Identities[at] = id
} else {
out.Own.Identities = append(out.Own.Identities, id)
}
}
for _, p := range comp.Filled {
if paths[p] && !slices.Contains(out.Filled, p) {
out.Filled = append(out.Filled, p)
}
}
return out, touched
}
// claimants is the modules, sorted, whose manifest claims the seat.
func (c *Composer) claimants(ctx context.Context, r reader, seat string) []string {
c.mu.Lock()
recs := maps.Clone(c.modules)
c.mu.Unlock()
var out []string
for _, module := range slices.Sorted(maps.Keys(recs)) {
if recs[module].Manifest == "" {
continue
}
raw, err := r.Manifest(ctx, recs[module].Manifest)
if err != nil {
continue
}
var man struct {
Claims []struct {
Name string `json:"name"`
} `json:"claims"`
}
if json.Unmarshal(raw, &man) != nil {
continue
}
for _, cl := range man.Claims {
if cl.Name == seat {
out = append(out, module)
break
}
}
}
return out
}
// seatsOf is the seats a module claims, invokes or provides, from the manifest its module record names; none when it
// cannot be read, which the reader judges as a module no bootstrapped seat names.
func (c *Composer) seatsOf(ctx context.Context, r reader, module string) []string {