Author SHA1 Message Date
jschoubben 8e32583089 A node names its provisioner client's account whenever a client runs there, filled files or not (issue 774)
mesh/delivery ready: it is merged on the Approve
mesh/check pass 8e325830891c on 6019c6d514a7 | unit 1589 run, 420 kept, 31 skipped | integration 1600 run, 420 kept, 26 skipped | over bound: unit (sl…
The declaration named the account only beside its filled files (node.go), so a node whose every filled-file module
was held this pass named none: on ace from 2026-11-04 01:29 its build modules and ssh-client were held for their
forge binding, and the node-engine refused every write of the files they keep, and of every other module's. The
account is the node's, named whenever the rendering has one. A row holds restic, the provisioned mesh's one
filled-file module, with the client placed: the node's own still names the account; it named none before.

Claude-Session: https://claude.ai/code/session_01JG73KG7AfSdh6FP8VrBqCn
2026-11-04 02:17:01 +01:00
mesh-tools 6019c6d514 Merge pull request 'Node-engine: a held provisioner client keeps its account; a module waiting for a yes has its new build read (issues 774, 1585)' (#204) from fix/frombus-inown-build into main 2026-11-04 01:05:49 +00:00
jschoubben 054754482c The running provisioner account is kept only for a client held this pass, and rows hold the guard (issue 774, review)
mesh/check being carried to 054754482cf4 on 6019c6d514a7 | from 83e135f10187
mesh/delivery merged: source.main moved to novox/mesh-host@6019c6d514a7
The review removed the guard and the suite still passed. The guard is now provisionerHeld, a placed client held as
its stand-in, rather than any placed client, so one placed and not held that composes no account keeps none stale.
Rows in the held client's table: the client taken out of the node's placement while what runs names the account, read
from the running own composition and from the client's running process, names no account; a placed client whose
process names no user names none. With the guard removed the first three fail; with it reduced to "placed", the last.

Claude-Session: https://claude.ai/code/session_01JG73KG7AfSdh6FP8VrBqCn
2026-11-04 01:53:29 +01:00
jschoubben f7129fdb41 node-engine.composition shows the node's own fields and what the last pass held (issue 774)
Reading ace's node composition gave four resources twice and no way to see that one revision named no provisioner
client's account. The verb now shows, for module node, the revision's provisioner account beside what runs names, its
adoption, how many identities, groups and libraries it names, and each module the composer's last pass held with why.

Claude-Session: https://claude.ai/code/session_01JG73KG7AfSdh6FP8VrBqCn
2026-11-04 01:50:21 +01:00
jschoubben f7abd05981 A node composition that drops the provisioner client's account what runs names is said (issue 774)
Applied, a node's own composition naming no account refuses every filled write and hands no module, which on ace
went unsaid from 01:29 to 01:33 but for the refusals themselves. The pass now says it once, naming the account what
runs names. A placed client keeps its account by the previous commit; this says the case that is left, a client no
longer composed here.

Claude-Session: https://claude.ai/code/session_01JG73KG7AfSdh6FP8VrBqCn
2026-11-04 01:48:12 +01:00
jschoubben 514cf23aef The provisioner client's account stays named while the client is held: a held client keeps the node's handing (issue 774)
provisionerAccount reads the user of the client's process from this pass's plan, and a client held as its stub (or
carried as it runs) composes no process: on ace, 2026-11-04 01:29 to 01:33, the node's own composition named no
account, the node-engine refused every filled write, and no module was handed (postgres's consumers, restic, power).
A node now carries the account its client runs as (Node.RunningProvisioner: its own composition's account as it runs,
else the user of the client's running process), and a placed client that composes none this pass keeps it. A row holds
the client in the provisioned mesh, both ways: the account stays and restic keeps its filled files; both were lost
before.

Claude-Session: https://claude.ai/code/session_01JG73KG7AfSdh6FP8VrBqCn
2026-11-04 01:47:19 +01:00
jschoubben 95b509e0d2 A module in InOwn wanted at a build of the new form has its build taken in: the reader and the composition fold by one rule (issue 1585)
frombus skipped reading the build of every module in Scope.InOwn, while compose.compositions folds such a module only
at no src2: fingerprint: a want at src2: (nats on novox, its have waiting for a yes) composed it from the indexer's
unresolved manifest and built() refused it, "no build has published" its server artifact. compose.Folds is now the one
rule; frombus takes the build in, or keeps the module at its wanted build, exactly where it says the module is not
folded. A row composes spiegel, in InOwn and wanted at src2:, from its build's upstream image; it read the indexer's
manifest before.

Claude-Session: https://claude.ai/code/session_01JG73KG7AfSdh6FP8VrBqCn
2026-11-04 01:31:27 +01:00
mesh-tools 83e135f101 Merge pull request 'L3 final: the node-engine reads core-credentials and tool-runner with no fallback, and states its manifest reads (ADR 0601 §4, ADR 0614 §4, issue 774)' (#203) from feat/l3-final into main 2026-11-04 00:23:56 +00:00
jschoubben 30b86ea602 An input carries the modules it holds as their stubs across the bus, and the sandbox names why the node-engine holds one (issue 774, group check 278aa5f0ccbe)
mesh/delivery-group group feat/l3-final merged: every member is merged
mesh/delivery merged: source.main moved to novox/mesh-host@83e135f10187
mesh/check checking 30b86ea602a8 on 83e135f10187 | novox/mesh-host#203 in group feat/l3-final
The stub's mark (Manifest.Stubbed) is no field of a manifest's JSON, so an input a node-engine sent the check's
sandbox lost it: node-tools, held by its manifest's verdict, read as a tool runner with no bundle and refused every
module of ace, g14 and shanks for "its build produced 0 bundle(s)". compose.Input now writes its held modules (catalogue
and each node's kept) beside its fields and marks them again on reading; an input from an engine before the mark is
read by its contract, every module Refused names held as its stub. The sandbox composer also says, in the module's
read, why the input's node-engine holds a module the case carries, so the check names the cause, not a rule about the
stub. One subtest round-trips an input holding a refused tool runner, in both shapes; it refused for 0 bundles before.

Claude-Session: https://claude.ai/code/session_01JG73KG7AfSdh6FP8VrBqCn
2026-11-04 00:45:35 +01:00
jschoubben 32a99144f6 The node record states the reader capabilities of the mesh-sdk the engine is built with (ADR 0614 §4, issue 774)
mesh/delivery closed: a newer head of the same pull request
mesh/delivery-group group feat/l3-final checking: 3 of 5 member(s) ready
mesh/check failed (2): unit: mesh-provisioner-client/cmd/mesh-provisioner-client.TestNatsANodeComingBackFil…; and 1 more | 32a99144f65e on 798d73eb11e9
recordOf writes catalogue.ManifestReads in NodeRecord.ManifestReads, once per build, so the assigner moves a want only
to an engine that reads its manifest. A row holds the engine's own manifest to the baseline's words, so its new build is
never held for a word only it reads.

Claude-Session: https://claude.ai/code/session_01JG73KG7AfSdh6FP8VrBqCn
2026-11-03 23:29:17 +01:00
jschoubben 3544d644ba Pin mesh-sdk 85ba0a9a70d8, the head of feat/l3-final: the contract states the reader capabilities a node-engine reads and the node record carries them (ADR 0614 §4, issue 774)
Claude-Session: https://claude.ai/code/session_01JG73KG7AfSdh6FP8VrBqCn
2026-11-03 23:27:52 +01:00
jschoubben a0ce37fb2c Pin mesh-sdk b7abe203a929, the group's head of feat/l3b-bus: it keeps, deprecated, the exported names the running parts still use
Claude-Session: https://claude.ai/code/session_01JG73KG7AfSdh6FP8VrBqCn
2026-11-03 18:50:12 +01:00
20 changed files with 444 additions and 13 deletions
+6
View File
@@ -509,6 +509,12 @@ func nodeAgent(ctx context.Context, opts options, signer ed25519.PublicKey, trus
// composes its own (issue 774).
Applied: appliedStoreFor(opts.state),
Inactive: inactiveIn(&liveness.Exec{Run: liveness.Runner(opts.run()), System: builtFor}, heldBy(apply.WindowsIn(filepath.Dir(opts.state)))),
Held: func() map[string]string {
if c := composerNow.Load(); c != nil {
return c.LastHeld()
}
return nil
},
Revisions: func(ctx context.Context, key string) ([]agent.Update, error) {
got, err := session.KVHistory(ctx, composedBucket, key)
if err != nil {
+1 -1
View File
@@ -3,7 +3,7 @@ module github.com/novox/mesh-host
go 1.26.0
require (
git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261103155154-cf5812101b0b
git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261103221822-85ba0a9a70d8
github.com/nats-io/nats-server/v2 v2.11.17
github.com/nats-io/nats.go v1.54.0
golang.org/x/crypto v0.57.0
+2 -2
View File
@@ -1,5 +1,5 @@
git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261103155154-cf5812101b0b h1:UPkJqQj+1y6WraOxDidkrokNjA25jxmCk+zIRkujjMc=
git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261103155154-cf5812101b0b/go.mod h1:ouERNpZizzwZ0HLEtAypsY9FR20FyUgUYRni6J7kVCY=
git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261103221822-85ba0a9a70d8 h1:XEbIVImp6yGjA91rp0iZBoDdL5KfamjOuDLwWvqh0hc=
git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261103221822-85ba0a9a70d8/go.mod h1:ouERNpZizzwZ0HLEtAypsY9FR20FyUgUYRni6J7kVCY=
github.com/antithesishq/antithesis-sdk-go v0.7.0-default-no-op h1:Z/MZK75wC/NSrkgqeNIa7jexam9uWzhLmFTSCPI/kn0=
github.com/antithesishq/antithesis-sdk-go v0.7.0-default-no-op/go.mod h1:FQyySiasQQM8735Ddel3MRojmy4dA1IqCeyJ5jmPMbI=
github.com/google/go-tpm v0.9.8 h1:slArAR9Ft+1ybZu0lBwpSmpwhRXaa85hWtMinMyRAWo=
+3
View File
@@ -136,6 +136,9 @@ type Env struct {
// Revisions are a composition key's revisions the bucket keeps, oldest first; nil reads none. The composition a want
// names is taken from them when a newer one replaced it under the same key (ADR 0539 clause 1).
Revisions func(ctx context.Context, key string) ([]Update, error)
// Held is what the node's composer held in its last pass, each module with why, as the verb composition shows it
// for the node's own (issue 774); nil says nothing.
Held func() map[string]string
// Settled is told a module's apply or removal that succeeded, once what runs holds it (Everything), outside the
// agent's lock: the declaration the node applies now names what that composition names, its filled files among
// them. The node-engine's filling tells the provisioner's client then to fill again what it refused (mesh-host
+12
View File
@@ -12,6 +12,7 @@ import (
"time"
"git.novox.be/novox/mesh-sdk/go/catalogue"
"git.novox.be/novox/mesh-sdk/go/declaration"
"git.novox.be/novox/mesh-sdk/go/delivery"
)
@@ -1312,6 +1313,7 @@ func TestTheNodesOwnCompositionIsAppliedWhenItDiffersAndARecordMoveWaitsForAYes(
defer historyMu.Unlock()
history[key] = append(history[key], Update{Key: key, Value: raw, Revision: uint64(len(history[key]) + 1)})
}
b.env.Held = func() map[string]string { return map[string]string{"mesh-build-go": "its binding is unset"} }
stop := b.run()
defer stop()
unsigned := func(module, fp string, rs ...json.RawMessage) (string, []byte, string) {
@@ -1493,6 +1495,16 @@ func TestTheNodesOwnCompositionIsAppliedWhenItDiffersAndARecordMoveWaitsForAYes(
if _, err := b.ag.Composition(context.Background(), "db", "sha256:"+strings.Repeat("0", 64), false); err == nil {
t.Error("a digest no revision has was answered")
}
// Issue 774 (ace, 2026-11-04 01:29): the node's own revision shows its own fields, the provisioner client's account
// and the adoption among them, and what the composer's last pass held.
ownKey, ownRaw, ownDig := encodedAs(t, delivery.Composed{Node: node, Own: delivery.NodeOwn{Provisioner: "mesh-provisioner-client",
Adoption: &declaration.Adoption{Taken: []string{"web"}}}}, func() string { k, _ := delivery.ComposedNodeKey(node); return k }())
keep(ownKey, ownRaw)
if view, err := b.ag.Composition(context.Background(), NodeModule, ownDig, false); err != nil || view.Own == nil ||
view.Own.Provisioner != "mesh-provisioner-client" || view.Own.Adoption == nil || view.Own.Adoption.Taken[0] != "web" ||
view.Own.Held["mesh-build-go"] == "" {
t.Errorf("the node's own composition reads %+v %+v (%v)", view, view.Own, err)
}
// novox/hq ADR 0593 §6: the engine publishes each composition with its plan in its composition state; the verb reads
// that revision by the digest its have and want name, its resources' (the plan left out of the digest).
planned, _ := delivery.DecodeComposed(draw)
+32
View File
@@ -6,6 +6,7 @@ import (
"fmt"
"git.novox.be/novox/mesh-sdk/go/catalogue"
"git.novox.be/novox/mesh-sdk/go/declaration"
"git.novox.be/novox/mesh-sdk/go/delivery"
"github.com/novox/mesh-host/internal/judge"
)
@@ -32,6 +33,26 @@ type CompositionView struct {
Judged JudgedView `json:"judged"`
// Diff is every resource that differs from what runs, when asked.
Diff []judge.ResourceChange `json:"diff,omitempty"`
// Own is, of the node's own composition (module node), its own fields beside what runs names (issue 774: ace's
// own composition dropped the provisioner client's account, and nothing read could show it).
Own *OwnView `json:"own,omitempty"`
}
// OwnView is the node's own fields of a composition (delivery.NodeOwn) beside what runs of them, and what the node's
// composer held in its last pass.
type OwnView struct {
// Provisioner is the account the provisioner's client runs as, as the revision read names it; RunsProvisioner as
// what runs names it. Empty: the declaration names none, and every filled write is refused.
Provisioner string `json:"provisioner_account"`
RunsProvisioner string `json:"runs_provisioner_account,omitempty"`
// Adoption is the revision's adoption: the node adopted, and which of its modules are taken; nil for a converged node.
Adoption *declaration.Adoption `json:"adoption,omitempty"`
// Identities, Groups and Libraries count what the revision names of each.
Identities int `json:"identities,omitempty"`
Groups int `json:"groups,omitempty"`
Libraries int `json:"libraries,omitempty"`
// Held is each module the node's composer held in its last pass, with why.
Held map[string]string `json:"held,omitempty"`
}
// WantView is a want as the verb shows it.
@@ -171,6 +192,17 @@ func (a *Agent) Composition(ctx context.Context, module, digest string, diff boo
}
view.Runs = rv
}
if module == NodeModule {
o := read.C.Own
view.Own = &OwnView{Provisioner: o.Provisioner, Adoption: o.Adoption, Identities: len(o.Identities), Groups: len(o.Groups),
Libraries: len(o.Libraries)}
if running != nil {
view.Own.RunsProvisioner = running.Own.Provisioner
}
if a.env.Held != nil {
view.Own.Held = a.env.Held()
}
}
j := judge.JudgeMove(running, read.C)
view.Judged = JudgedView{Judgement: string(j.Judgement), Words: j.Words}
if a.env.SelfComposed && a.env.Policy != nil && module != NodeModule && (!w.Present || w.Want.Yes != read.Dig) {
+10 -1
View File
@@ -63,6 +63,15 @@ func CompositionsIn(in Input, node string, fps map[string]string, mode Mode) (ou
return compositions(in, node, fps, mode, false)
}
// Folds says whether a module the node runs inside its own composition (inOwn, Node.InOwn) is folded there at the
// fingerprint fp it is composed at: only at no fingerprint of the new form. At one (src2:) it has a composition of its
// own, composed from its build, so every reader of a build (frombus) takes the build in exactly where this says it is
// not folded (issue 1585: nats on novox, folded by the reader and composed at src2: here, refused for its server
// artifact no build had published).
func Folds(inOwn bool, fp string) bool {
return inOwn && !strings.HasPrefix(fp, delivery.SourcePrefix2)
}
func compositions(in Input, node string, fps map[string]string, mode Mode, applied bool) (out map[string]delivery.Composed, skipped, stood []string, err error) {
reading := in
reading.Nodes = append([]Node(nil), in.Nodes...)
@@ -84,7 +93,7 @@ func compositions(in Input, node string, fps map[string]string, mode Mode, appli
// The private network's module is built by nobody: no fingerprint of the new form ever names it.
inOwn[overlay.Name] = true
folds := func(module string) bool {
return module != "" && inOwn[module] && !strings.HasPrefix(fps[module], delivery.SourcePrefix2)
return module != "" && Folds(inOwn[module], fps[module])
}
if !found {
return nil, nil, nil, fmt.Errorf("compose: the mesh has no node %q", node)
@@ -191,6 +191,28 @@ func answerNamesEveryRefusalAndLeavesAnOldBuildToTheNode(t *testing.T) {
!strings.Contains(held["spiegel"], "spiegel has not been built for this mesh") {
t.Errorf("composed spiegel without what runs: %v %v, want spiegel held alone", err, held)
}
// Issue 1585 (nats on novox, met twice): wanted at a build of the new form, a module in InOwn is no longer folded
// (compose.Folds), so its build is taken in and it composes with its image, never from the indexer's manifest as
// "not built for this mesh".
{
bb := mk("lemurs", overlay.Name, "spiegel")
bp := bb.placements["n1"]
bp.Decided.Endpoint = "n1.example:51820"
bb.placements["n1"] = bp
sp := bb.modules["spiegel"]
sp.FP2 = fp("3")
bb.modules["spiegel"] = sp
image := "registry.example:5100/spiegel/site@sha256:" + strings.Repeat("7", 64)
bb.builds["spiegel "+sp.FP2] = &delivery.BuilderBuild{State: delivery.BuildDone, Manifest: sp.Manifest,
Artifacts: []delivery.BuiltArtifact{{Name: "site", Kind: "upstream", Reference: image, Digest: "sha256:" + strings.Repeat("7", 64)}}}
read, err := ComposingInput(ctx, bb, Mesh{}, Scope{Node: "n1", InOwn: []string{"spiegel"}},
map[string]string{"lemurs": fp("e"), "spiegel": sp.FP2})
raw, _ := json.Marshal(read.Catalogue["spiegel"])
if err != nil || read.ComposedHow.Held["spiegel"] != "" || !strings.Contains(string(raw), strings.Repeat("7", 64)) {
t.Errorf("spiegel in InOwn wanted at %s: %v, held %v, read as %s; want its build's image", sp.FP2, err,
read.ComposedHow, raw)
}
}
// (C) Issue 1126, C5: what the others read of a module carried as it runs is what runs, never its stub's: the filter
// opens the machine port its running container publishes (de-spiegel's 20005 on novox, not its software's 8080; a
+41 -4
View File
@@ -234,6 +234,7 @@ func input(ctx context.Context, r Reader, mesh Mesh, s Scope, refuse func(module
}
in, err := b.finish(ctx, r, r.Build, mesh, s, b.in, refuse, wanted)
if err == nil {
runningProvisioner(&in, s.Node, s.Running)
// What runs is kept while a manifest does not yet say what shared code no longer infers (ADR 0601 §4).
for _, k := range keepWhatRuns(&in, s.Node, s.Running) {
if s.Kept != nil {
@@ -876,7 +877,7 @@ func fromBuilds(ctx context.Context, r Reader, build func(ctx context.Context, m
keptAt := maps.Clone(s.KeptAt)
for _, pm := range p.Modules {
if m, ok := modules[pm.Module]; ok && m.FP2 != "" && wanted[pm.Module] != "" && wanted[pm.Module] != m.FP2 &&
keptAt[pm.Module] == "" && !slices.Contains(s.InOwn, pm.Module) {
keptAt[pm.Module] == "" && !compose.Folds(slices.Contains(s.InOwn, pm.Module), wanted[pm.Module]) {
if keptAt == nil {
keptAt = map[string]string{}
}
@@ -891,9 +892,10 @@ func fromBuilds(ctx context.Context, r Reader, build func(ctx context.Context, m
}
for _, pm := range p.Modules {
m, ok := modules[pm.Module]
if !ok || m.FP2 == "" || keptAt[pm.Module] != "" || slices.Contains(s.InOwn, pm.Module) {
// A module it runs inside its own composition is composed there as before: no build of the new form is what
// it runs (Scope.InOwn).
if !ok || m.FP2 == "" || keptAt[pm.Module] != "" || compose.Folds(slices.Contains(s.InOwn, pm.Module), wanted[pm.Module]) {
// A module it runs inside its own composition is composed there as before while it is folded: no build of the
// new form is what it runs (Scope.InOwn). Wanted at a build of the new form it is no longer folded (compose.Folds,
// the composition's own rule), so its build is taken in (issue 1585).
continue
}
if _, refused := in.Refused[pm.Module]; refused {
@@ -1230,3 +1232,38 @@ func withoutHeldData(in compose.Input, node string) compose.Input {
}
return out
}
// runningProvisioner names, on node, the account its provisioner client runs as now (compose.Node.RunningProvisioner): the
// account its own composition as it runs names, else the user of the running process of the module that runs the client.
func runningProvisioner(in *compose.Input, node string, running []delivery.Composed) {
account := ""
for _, c := range running {
if c.Module == "" && c.Own.Provisioner != "" {
account = c.Own.Provisioner
}
}
for _, c := range running {
if account != "" {
break
}
man, ok := in.Catalogue[c.Module]
if c.Module == "" || !ok || !man.ClaimsSeat(compose.NodeProvisionerSeat) {
continue
}
for _, raw := range c.Resources {
var r struct {
Type string `json:"type"`
User string `json:"user"`
}
if json.Unmarshal(raw, &r) == nil && r.Type == "process" && r.User != "" && r.User != "root" {
account = r.User
break
}
}
}
for i := range in.Nodes {
if in.Nodes[i].Name == node {
in.Nodes[i].RunningProvisioner = account
}
}
}
@@ -2,7 +2,9 @@ package frombustest
import (
"context"
"encoding/json"
"reflect"
"slices"
"strings"
"testing"
@@ -204,6 +206,92 @@ func TestTheProvisionedMeshHandsAPlanWithItsProvision(t *testing.T) {
if fetched-archives != 1 {
t.Errorf("restic's archive was read %d times over two compositions, want once", fetched-archives)
}
// Issue 774 (ace, 2026-11-04 01:29): the provisioner client held as its stub (its build not done, so the node keeps
// running what it runs) keeps the account the node's own composition names, and restic stays handed to it with its
// plan; without it every filled write was refused and no module was handed.
if b, err = Provisioned(); err != nil {
t.Fatal(err)
}
fps, ownKey := FPs(), ""
ownKey, _ = delivery.ComposedNodeKey(Node)
resticKey, _ := delivery.ComposedKey(Node, "restic", fps["restic"])
before, _, _, err := frombus.Compose(context.Background(), b, frombus.Mesh{}, frombus.Scope{Node: Node}, fps)
account := before[ownKey].Own.Provisioner
if err != nil || account == "" || len(before[resticKey].Filled) == 0 {
t.Fatalf("the node composes with its client: account %q, restic's filled files %v: %v", account, before[resticKey].Filled, err)
}
var running []delivery.Composed
for _, c := range before {
running = append(running, c)
}
for _, row := range []struct {
own, placed bool // the account read from the running own composition, else the client's running process; the client placed
userless bool // the client placed and not held, its process naming no user: it composes no account
// filledHeld holds every module with a filled file (restic, its build not done) and not the client: the node still
// names the account (issue 774, ace 2026-11-04: its build modules and ssh-client held, no filled file, no account)
filledHeld bool
}{{true, true, false, false}, {false, true, false, false}, {true, false, false, false}, {false, false, false, false},
{true, true, true, false}, {true, true, false, true}} {
own := row.own
runs := running
if !own { // what runs names no account of its own: the client's running process says it
runs = nil
for _, c := range running {
if c.Module == "" {
c.Own.Provisioner = ""
}
runs = append(runs, c)
}
}
bb, err := Provisioned()
if err != nil {
t.Fatal(err)
}
want := account
if row.userless {
// placed and not held, composing no account of its own: no stale account is kept from what runs
mod := bb.Module["mesh-provisioner-client"]
var m map[string]any
if err := json.Unmarshal(bb.Manifests[mod.Manifest], &m); err != nil {
t.Fatal(err)
}
for _, r := range m["resources"].([]any) {
delete(r.(map[string]any), "user")
}
raw, _ := json.Marshal(m)
mod.Manifest = delivery.ManifestDigest(raw)
bb.Module["mesh-provisioner-client"], bb.Manifests[mod.Manifest] = mod, raw
built := *bb.Builds["mesh-provisioner-client "+fps["mesh-provisioner-client"]]
built.Manifest = mod.Manifest
bb.Builds["mesh-provisioner-client "+fps["mesh-provisioner-client"]] = &built
want = ""
} else if row.filledHeld {
bb.Builds["restic "+fps["restic"]] = &delivery.BuilderBuild{State: delivery.BuildBuilding}
} else if row.placed {
// held: its build not done while it runs
bb.Builds["mesh-provisioner-client "+fps["mesh-provisioner-client"]] = &delivery.BuilderBuild{State: delivery.BuildBuilding}
} else {
// taken out of the node's placement while what runs still names it: the account goes with it, never kept stale
p := bb.Placement[Node]
p.Modules = slices.DeleteFunc(slices.Clone(p.Modules), func(m delivery.PlacedModule) bool { return m.Module == "mesh-provisioner-client" })
bb.Placement[Node] = p
want = ""
}
after, _, held, err := frombus.Compose(context.Background(), bb, frombus.Mesh{}, frombus.Scope{Node: Node, Running: runs}, fps)
if _, isHeld := held["mesh-provisioner-client"]; err != nil || isHeld != (row.placed && !row.userless && !row.filledHeld) {
t.Fatalf("%+v: held %v: %v", row, held, err)
}
if got := after[ownKey].Own.Provisioner; got != want {
t.Errorf("%+v: the node's own names %q, want %q", row, got, want)
}
if _, resticHeld := held["restic"]; row.filledHeld && !resticHeld {
t.Errorf("%+v: restic held %v", row, resticHeld)
}
if row.placed && !row.userless && !row.filledHeld && !reflect.DeepEqual(after[resticKey].Filled, before[resticKey].Filled) {
t.Errorf("%+v: restic's filled files %v, want %v", row, after[resticKey].Filled, before[resticKey].Filled)
}
}
}
// statesOf is the bus b as a module's read states hold it.
+5
View File
@@ -224,6 +224,11 @@ type Node struct {
// so it is left to the node: Compositions composes it as a stub (Stub) and carries its resources as they run, each
// after the resource it runs after (issue 1126). Nil composes every folded module from its manifest, as before.
RunsOwn *delivery.Composed
// RunningProvisioner is the account the provisioner's client runs as on this node now, read from what runs (its own
// composition's provisioner account, else the user of the provisioner client's running process), empty when nothing
// says it. A client held as its stub this pass composes no process, so the node keeps this account and its handing
// (provisionerAccount; issue 774: ace, 2026-11-04 01:29, every filled write refused and postgres's consumers unhanded).
RunningProvisioner string
// Held is the modules refused alone on this node, each composed as what runs of it (Carried, in Kept; its Stub when
// nothing of it runs) so the rest is composed (novox/hq issue 1126): none of a held module's own composition is in what
// Compositions answers, so the node keeps what it last applied of it; one whose resources the node's own runs
+97
View File
@@ -0,0 +1,97 @@
package compose
import (
"encoding/json"
"sort"
"git.novox.be/novox/mesh-sdk/go/catalogue"
)
// What an input says of its held modules on the wire (issue 774, the group check of feat/l3-final 278aa5f0ccbe). A held
// module stands in the input as its stub (Stub), and the stub's mark (catalogue.Manifest.Stubbed) is no field of a
// manifest's JSON: an input a node-engine sent the check's sandbox lost it, so the sandbox read node-tools, held by its
// manifest's verdict, as a tool runner with no bundle and refused every module of ace, g14 and shanks for "its build
// produced 0 bundle(s)" instead of naming the verdict. So the input carries the mark itself, by module, for its catalogue
// and for each node's kept manifests, and a reader marks each again. An input from a node-engine before the mark (no
// Held) is read by its contract: a module Refused names stands in Catalogue as its stub.
// heldMarks are the modules an input holds as their stubs: those of its catalogue, and those each node keeps, by node.
type heldMarks struct {
Catalogue []string `json:",omitempty"`
Kept map[string][]string `json:",omitempty"`
}
// inputFields is Input without its JSON methods.
type inputFields Input
// inputWire is an input as it crosses the bus: its fields, and the modules it holds as their stubs.
type inputWire struct {
inputFields
Held *heldMarks `json:",omitempty"`
}
// MarshalJSON writes the input with the modules it holds as their stubs named beside it (heldMarks).
func (in Input) MarshalJSON() ([]byte, error) {
w := inputWire{inputFields: inputFields(in)}
marks := heldMarks{Catalogue: stubbedOf(in.Catalogue)}
for _, n := range in.Nodes {
if kept := stubbedOf(n.Kept); len(kept) > 0 {
if marks.Kept == nil {
marks.Kept = map[string][]string{}
}
marks.Kept[n.Name] = kept
}
}
if len(marks.Catalogue) > 0 || len(marks.Kept) > 0 {
w.Held = &marks
}
return json.Marshal(w)
}
// UnmarshalJSON reads an input and marks again each module it holds as its stub: those it names held, and, by the
// input's contract, every module Refused names that its catalogue holds.
func (in *Input) UnmarshalJSON(raw []byte) error {
var w inputWire
if err := json.Unmarshal(raw, &w); err != nil {
return err
}
*in = Input(w.inputFields)
held := map[string]bool{}
for m := range in.Refused {
held[m] = true
}
if w.Held != nil {
for _, m := range w.Held.Catalogue {
held[m] = true
}
}
for m := range held {
if man, ok := in.Catalogue[m]; ok {
man.Stubbed = true
in.Catalogue[m] = man
}
}
if w.Held != nil {
for i := range in.Nodes {
for _, m := range w.Held.Kept[in.Nodes[i].Name] {
if man, ok := in.Nodes[i].Kept[m]; ok {
man.Stubbed = true
in.Nodes[i].Kept[m] = man
}
}
}
}
return nil
}
// stubbedOf is each module of manifests held as its stub, sorted.
func stubbedOf(manifests map[string]catalogue.Manifest) []string {
var out []string
for name, m := range manifests {
if m.Stubbed {
out = append(out, name)
}
}
sort.Strings(out)
return out
}
+6 -3
View File
@@ -115,6 +115,11 @@ func (d Declared) Body() ([]byte, error) {
}
if len(d.Filled) > 0 {
envelope["filled_files"] = d.Filled
}
// The provisioner client's account is the node's, named whenever a client runs here, filled files or not: a node
// whose every filled-file module is held this pass names none of its files, and still keeps them, so the client's
// writes of them must be accepted (issue 774, ace 2026-11-04: its build modules and ssh-client held, no account).
if d.Provisioner != "" {
envelope["provisioner_account"] = d.Provisioner
}
// An empty declaration is deliberate: the node owns nothing the mesh put there (novox/hq issue 127), and the host refuses an
@@ -202,9 +207,7 @@ func (c *composer) declaration(nodeName string, mode Mode) (Declared, error) {
Filled: composed.Filled, LocalNetwork: resolution.LocalNetworkPorts(rules, with.LocalNetworks),
CoreBoxesWanted: rendered.CoreBoxesWanted, Said: rendered.Said,
Sequence: node.Sequence, Epoch: node.Epoch, Generation: node.Generation}
if len(composed.Filled) > 0 {
out.Provisioner = with.Provisioner
}
out.Provisioner = with.Provisioner
// A handed module only pulled in by another keeps what requires it, for its value of its own in the assignments mirror (issue 765).
for module := range composed.Plans {
if by := resolution.RequiredBy(plan, module); by != "" {
+21
View File
@@ -74,6 +74,12 @@ func (c *composer) places(nodeName string, resolved Resolved) (Placed, error) {
// not sent the field they would refuse.
r.Hands = takesHanded(node)
r.Provisioner = provisionerAccount(plan)
if r.Provisioner == "" && provisionerHeld(plan) {
// The provisioner's client is placed here and held this pass (its stub, or carried as it runs, composes no process
// of its own): it keeps running as it runs, so the account it runs as stays named, and every module stays handed
// to it. A client not held that composes no account, or none placed, names none: no stale account is kept.
r.Provisioner = node.RunningProvisioner
}
r.Assigned = node.Handed
r.ReadsHealth = node.Stated && node.Contract >= ReadinessContract
r.JudgesRoot = node.Stated && node.Contract >= RootContract
@@ -190,6 +196,21 @@ func provisionerAccount(plan catalogue.Resolution) string {
return ""
}
// provisionerHeld says the plan places a module claiming the provisioner client's seat on the machine that this pass
// holds (its stand-in: Stub, or Carried as it runs), which composes none of the client's process.
func provisionerHeld(plan catalogue.Resolution) bool {
for _, m := range plan.Modules {
if m.Stubbed && m.ClaimsSeat(NodeProvisionerSeat) {
return true
}
}
return false
}
// RunsTheProvisionerClient says a module runs its machine's provisioner client (runsTheProvisionerClient), for a reader
// of what runs.
func RunsTheProvisionerClient(m catalogue.Manifest) bool { return runsTheProvisionerClient(m) }
// runsTheProvisionerClient says a module runs its machine's provisioner client: it claims the seat and declares the own
// secret that account is delivered in.
func runsTheProvisionerClient(m catalogue.Manifest) bool {
+8
View File
@@ -150,6 +150,14 @@ func runCase(r Request, c Case, depth string) (CaseVerdict, error) {
for name, m := range base.Catalogue {
shelf[name] = m
}
// A module the input's node-engine holds as refused (compose.Input.Refused: its verdict, or bytes that do not decode) is
// held here as there, and its read says why, whatever the case's own verdicts: the check then names the cause the node
// holds it for, never a pass the node would not compose, nor a rule about its stub (issue 774, group check 278aa5f0ccbe).
for name, why := range base.Refused {
if read, carried := cv.Manifests[name]; carried && read.Accepted {
cv.Manifests[name] = ManifestRead{Refused: "its node-engine holds it: " + why}
}
}
in.Catalogue = shelf
in.Nodes = slices.Clone(in.Nodes)
in.Holdings = slices.Clone(in.Holdings)
+41
View File
@@ -179,6 +179,47 @@ func TestARequestItCannotRunIsRefused(t *testing.T) {
t.Errorf("a module the node-engine holds too: %+v", m)
}
})
// Issue 774 (the group check of feat/l3-final 278aa5f0ccbe): a tool runner its node-engine holds by its manifest's
// verdict is sent as its stub, and the input says it holds it: the sandbox composes the node with it held, never refuses
// it for its stub's missing bundle. An input from an engine before the mark is read by Refused alone, the same way.
t.Run("a refused tool runner the engine sends as its stub", func(t *testing.T) {
raw := `{"module": "runner", "version": "1", "tool-runner": true, ` +
`"build": {"artifacts": [{"name": "runtime", "kind": "bundle", "language": "go", "from": "cmd/runner", "binary": "runner"}]}}`
man, err := catalogue.Decode([]byte(raw))
if err != nil {
t.Fatal(err)
}
in := compose.Input{Catalogue: map[string]catalogue.Manifest{"runner": compose.Stub(man)},
Refused: map[string]string{"runner": "the indexer's judge refused the manifest of runner"},
Nodes: []compose.Node{{Name: "a", Assigned: []string{"runner"}}}}
wire, err := json.Marshal(in)
if err != nil {
t.Fatal(err)
}
var back compose.Input
if err := json.Unmarshal(wire, &back); err != nil || !back.Catalogue["runner"].Stubbed || !strings.Contains(string(wire), `"Held"`) {
t.Fatalf("the input's held mark across the wire: %s, %v", wire, err)
}
older := strings.Replace(string(wire), `"Held":{"Catalogue":["runner"]}`, `"Unsaid":null`, 1)
for name, sent := range map[string]string{"with the mark": string(wire), "from an engine before it": older} {
var r Request
r = Request{V: Version, Machines: []string{"a"}, Depth: DepthCompose,
Cases: []Case{{Name: "change", Manifests: map[string]Manifest{"runner": {Raw: json.RawMessage(raw)}}}}}
if err := json.Unmarshal([]byte(sent), &r.Input); err != nil {
t.Fatal(err)
}
v, err := Run(r)
if err != nil {
t.Fatal(err)
}
if m := v.Cases[0].Machines["a"]; !m.Composes || strings.Contains(m.Reason, "bundle") {
t.Errorf("%s: the node with its tool runner held: %+v", name, m)
}
if read := v.Cases[0].Manifests["runner"]; read.Accepted || !strings.Contains(read.Refused, "the indexer's judge refused the manifest of runner") {
t.Errorf("%s: the runner's read names not why its node-engine holds it: %+v", name, read)
}
}
})
v, err := Run(Request{V: Version, Cases: []Case{{Name: "only-read", Manifests: map[string]Manifest{"demo": {Raw: json.RawMessage(demo)}}}}})
if err != nil || len(v.Cases) != 1 || !v.Cases[0].Manifests["demo"].Accepted || v.Cases[0].Machines != nil || v.SDK == "" {
t.Fatalf("a request with no machines: %+v, %v", v, err)
+2 -1
View File
@@ -270,7 +270,8 @@ func TestTheNodeKeyCarriesTheNodesFacts(t *testing.T) {
}
wantRecord := delivery.NodeRecord{V: delivery.Version, Node: "n1", TunnelKey: "tk", SealingKey: "sk", ServingKey: "pk",
Certificate: "cert", Trusts: keys.Trusts, Capabilities: []string{"apt", "systemd"}, OutwardLinks: []string{"eth0"}, NetworksSaid: true,
Networks: []catalogue.AttachedNetwork{{Link: "eth0", Network: "192.0.2.0/24"}}, Contract: NodeKeyContract}
Networks: []catalogue.AttachedNetwork{{Link: "eth0", Network: "192.0.2.0/24"}}, Contract: NodeKeyContract,
ManifestReads: catalogue.ManifestReads()} // what it reads of a manifest: its contract's (ADR 0614 §4)
if !delivery.SameNodeRecord(*got.Record, wantRecord) {
t.Errorf("record = %+v, want %+v", *got.Record, wantRecord)
}
+8 -1
View File
@@ -3,6 +3,7 @@ package link
import (
"encoding/json"
"sort"
"sync"
"git.novox.be/novox/mesh-sdk/go/catalogue"
"git.novox.be/novox/mesh-sdk/go/delivery"
@@ -27,6 +28,11 @@ type RecordKeys struct {
Trusts []string
}
// manifestReads is what this engine reads of a manifest (novox/hq ADR 0614 §4): the reader capabilities of the mesh-sdk it
// is built with, stated in its record so the assigner moves a want only to an engine that reads its manifest. Computed
// once: it is the build's, and never changes while it runs.
var manifestReads = sync.OnceValue(catalogue.ManifestReads)
// recordOf is the node's record from one facts report and its keys; nil for a report that carries no profile (one that
// says nothing of the machine, such as a rekey), so the record last written stands.
func recordOf(node string, r Report, keys RecordKeys) *delivery.NodeRecord {
@@ -47,7 +53,8 @@ func recordOf(node string, r Report, keys RecordKeys) *delivery.NodeRecord {
return nil
}
rec := &delivery.NodeRecord{V: delivery.Version, Node: node, TunnelKey: keys.TunnelKey, SealingKey: keys.SealingKey,
ServingKey: keys.ServingKey, Certificate: keys.Certificate, Authority: keys.Authority, Trusts: keys.Trusts}
ServingKey: keys.ServingKey, Certificate: keys.Certificate, Authority: keys.Authority, Trusts: keys.Trusts,
ManifestReads: manifestReads()}
for _, c := range p.Capabilities {
if c.Present && c.Name != "" {
rec.Capabilities = append(rec.Capabilities, c.Name)
+32
View File
@@ -218,6 +218,13 @@ type Pass struct {
Earlier bool `json:"earlier,omitempty"`
}
// LastHeld is what the last pass held, each module with why; nil before a pass or when it held none.
func (c *Composer) LastHeld() map[string]string {
c.mu.Lock()
defer c.mu.Unlock()
return maps.Clone(c.last.Held)
}
// askedWithin is how recent a pass answers an ask of the verb compose without a new one: the first wait after a seat
// that did not answer (laterAgain's first).
var askedWithin = 5 * time.Second
@@ -915,6 +922,13 @@ func (c *Composer) compose(ctx context.Context) []agent.Update {
}
c.paid()
pass.Held = held
// The provisioner client's account the node runs with, dropped by this pass, is said: every filled write is refused
// under a declaration that names none (filling.JudgeAccount), and no module is handed to it (issue 774, ace,
// 2026-11-04 01:29). A placed client keeps its account while it is held (compose.Node.RunningProvisioner).
if was, now := ownProvisioner(running), ownProvisioner(mapValues(compositions)); was != "" && now == "" && nodeWanted {
c.sayOnceEach(fmt.Sprintf("the node's own composition names no provisioner client's account, where what runs names %s: "+
"once applied, every filled write is refused and no module is handed to the provisioner", was))
}
// A module refused alone is held: it keeps what it runs, its have says why, and every other module is applied (issue
// 1126: one module's refusal froze the whole node).
done := c.waiting("the agent marking each held module's have")
@@ -1699,3 +1713,21 @@ func (c *Composer) ComposedToday(ctx context.Context, module, fp string) (string
}
return "", false
}
// ownProvisioner is the provisioner client's account the node's own composition among cs names, or "".
func ownProvisioner(cs []delivery.Composed) string {
for _, c := range cs {
if c.Module == "" {
return c.Own.Provisioner
}
}
return ""
}
func mapValues(m map[string]delivery.Composed) []delivery.Composed {
out := make([]delivery.Composed, 0, len(m))
for _, k := range slices.Sorted(maps.Keys(m)) {
out = append(out, m[k])
}
return out
}
+7
View File
@@ -75,6 +75,13 @@ func TestTheManifestConsumesTheBackupEvent(t *testing.T) {
if err != nil {
t.Fatal(err)
}
// ADR 0614 §4: the node-engine's own manifest uses only words every running engine reads (the baseline, what an engine
// stating none is read as), so its new build is never held for a word only it reads.
if needs, err := catalogue.ManifestNeeds(raw); err != nil || len(needs) == 0 {
t.Fatalf("the manifest's needs: %v, %v", needs, err)
} else if missing := catalogue.MissingManifestReads(needs, nil); len(missing) != 0 {
t.Errorf("the manifest needs %v, which an engine stating nothing does not read: its new build would be held", missing)
}
// 774 (the alert table): the state health, one key per node, the node's own health report, kept until
// written again (no TTL: a node that stops writing keeps its last word, which the watchman reads with its time).
// ADR 0577 clause 1: and the state data, one key per module placed on the node that declares data items, kept until