From 95b509e0d2b8aa199409b1760abe93501d0a0b55 Mon Sep 17 00:00:00 2001 From: jochen Date: Wed, 4 Nov 2026 01:31:27 +0100 Subject: [PATCH 1/5] 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 --- internal/compose/composed.go | 11 +++++++++- internal/compose/frombus/answer_rows_test.go | 22 ++++++++++++++++++++ internal/compose/frombus/frombus.go | 9 ++++---- 3 files changed, 37 insertions(+), 5 deletions(-) diff --git a/internal/compose/composed.go b/internal/compose/composed.go index 03be678..5d5df71 100644 --- a/internal/compose/composed.go +++ b/internal/compose/composed.go @@ -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) diff --git a/internal/compose/frombus/answer_rows_test.go b/internal/compose/frombus/answer_rows_test.go index daee654..5954cfc 100644 --- a/internal/compose/frombus/answer_rows_test.go +++ b/internal/compose/frombus/answer_rows_test.go @@ -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 diff --git a/internal/compose/frombus/frombus.go b/internal/compose/frombus/frombus.go index ce704e6..6e36d45 100644 --- a/internal/compose/frombus/frombus.go +++ b/internal/compose/frombus/frombus.go @@ -876,7 +876,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 +891,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 { -- 2.54.0 From 514cf23aefe14ce90dec700a5b9fd4fbb194bf07 Mon Sep 17 00:00:00 2001 From: jochen Date: Wed, 4 Nov 2026 01:47:19 +0100 Subject: [PATCH 2/5] 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 --- internal/compose/frombus/frombus.go | 36 ++++++++++++++++ .../frombus/frombustest/frombustest_test.go | 42 +++++++++++++++++++ internal/compose/input.go | 5 +++ internal/compose/rendering.go | 21 ++++++++++ 4 files changed, 104 insertions(+) diff --git a/internal/compose/frombus/frombus.go b/internal/compose/frombus/frombus.go index 6e36d45..1fbda4b 100644 --- a/internal/compose/frombus/frombus.go +++ b/internal/compose/frombus/frombus.go @@ -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 { @@ -1231,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 + } + } +} diff --git a/internal/compose/frombus/frombustest/frombustest_test.go b/internal/compose/frombus/frombustest/frombustest_test.go index e6a0311..eb5eca8 100644 --- a/internal/compose/frombus/frombustest/frombustest_test.go +++ b/internal/compose/frombus/frombustest/frombustest_test.go @@ -204,6 +204,48 @@ 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 _, own := range []bool{true, false} { + 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) + } + } + b.Builds["mesh-provisioner-client "+fps["mesh-provisioner-client"]] = &delivery.BuilderBuild{State: delivery.BuildBuilding} + after, _, held, err := frombus.Compose(context.Background(), b, frombus.Mesh{}, frombus.Scope{Node: Node, Running: runs}, fps) + if _, isHeld := held["mesh-provisioner-client"]; err != nil || !isHeld { + t.Fatalf("the client with no done build is not held: %v %v", held, err) + } + if got := after[ownKey].Own.Provisioner; got != account { + t.Errorf("the client held (account from its own composition: %v): the node's own names %q, want %q", own, got, account) + } + if !reflect.DeepEqual(after[resticKey].Filled, before[resticKey].Filled) { + t.Errorf("the client held (account from its own composition: %v): restic's filled files %v, want %v", own, after[resticKey].Filled, before[resticKey].Filled) + } + } } // statesOf is the bus b as a module's read states hold it. diff --git a/internal/compose/input.go b/internal/compose/input.go index d8eae82..2898d77 100644 --- a/internal/compose/input.go +++ b/internal/compose/input.go @@ -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 diff --git a/internal/compose/rendering.go b/internal/compose/rendering.go index c8db3e0..c6b195b 100644 --- a/internal/compose/rendering.go +++ b/internal/compose/rendering.go @@ -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 == "" && provisionerPlaced(plan) { + // The provisioner's client is placed here but composes no account this pass (held as its stub, which runs no + // process of its own composition): it keeps running as it runs, so the account it runs as stays named, and every + // module stays handed to it. + 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 "" } +// provisionerPlaced says the plan places a module claiming the provisioner client's seat on the machine, whether or not +// it composes the client's process this pass. +func provisionerPlaced(plan catalogue.Resolution) bool { + for _, m := range plan.Modules { + if 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 { -- 2.54.0 From f7abd0598191f63a9e8b9173dd69b1d752675259 Mon Sep 17 00:00:00 2001 From: jochen Date: Wed, 4 Nov 2026 01:48:12 +0100 Subject: [PATCH 3/5] 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 --- internal/selfcompose/selfcompose.go | 25 +++++++++++++++++++++++++ 1 file changed, 25 insertions(+) diff --git a/internal/selfcompose/selfcompose.go b/internal/selfcompose/selfcompose.go index a8f81a0..45a07f6 100644 --- a/internal/selfcompose/selfcompose.go +++ b/internal/selfcompose/selfcompose.go @@ -915,6 +915,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 +1706,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 +} -- 2.54.0 From f7129fdb4102346513dba62f579d75c1923c4a63 Mon Sep 17 00:00:00 2001 From: jochen Date: Wed, 4 Nov 2026 01:50:21 +0100 Subject: [PATCH 4/5] 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 --- cmd/mesh-host/nodeagent.go | 6 ++++++ internal/agent/agent.go | 3 +++ internal/agent/agent_test.go | 12 +++++++++++ internal/agent/composition.go | 32 +++++++++++++++++++++++++++++ internal/selfcompose/selfcompose.go | 7 +++++++ 5 files changed, 60 insertions(+) diff --git a/cmd/mesh-host/nodeagent.go b/cmd/mesh-host/nodeagent.go index 00b5aa2..2ee61ae 100644 --- a/cmd/mesh-host/nodeagent.go +++ b/cmd/mesh-host/nodeagent.go @@ -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 { diff --git a/internal/agent/agent.go b/internal/agent/agent.go index b9ff6ff..6fc5f71 100644 --- a/internal/agent/agent.go +++ b/internal/agent/agent.go @@ -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 diff --git a/internal/agent/agent_test.go b/internal/agent/agent_test.go index 9887661..4efc91e 100644 --- a/internal/agent/agent_test.go +++ b/internal/agent/agent_test.go @@ -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) diff --git a/internal/agent/composition.go b/internal/agent/composition.go index 938c2aa..4035b8b 100644 --- a/internal/agent/composition.go +++ b/internal/agent/composition.go @@ -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) { diff --git a/internal/selfcompose/selfcompose.go b/internal/selfcompose/selfcompose.go index 45a07f6..6607fc6 100644 --- a/internal/selfcompose/selfcompose.go +++ b/internal/selfcompose/selfcompose.go @@ -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 -- 2.54.0 From 054754482cf4fe6f5449dce0484492f9905dc8de Mon Sep 17 00:00:00 2001 From: jochen Date: Wed, 4 Nov 2026 01:53:29 +0100 Subject: [PATCH 5/5] The running provisioner account is kept only for a client held this pass, and rows hold the guard (issue 774, review) 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 --- .../frombus/frombustest/frombustest_test.go | 55 ++++++++++++++++--- internal/compose/rendering.go | 16 +++--- 2 files changed, 54 insertions(+), 17 deletions(-) diff --git a/internal/compose/frombus/frombustest/frombustest_test.go b/internal/compose/frombus/frombustest/frombustest_test.go index eb5eca8..4264ff3 100644 --- a/internal/compose/frombus/frombustest/frombustest_test.go +++ b/internal/compose/frombus/frombustest/frombustest_test.go @@ -2,7 +2,9 @@ package frombustest import ( "context" + "encoding/json" "reflect" + "slices" "strings" "testing" @@ -223,7 +225,11 @@ func TestTheProvisionedMeshHandsAPlanWithItsProvision(t *testing.T) { for _, c := range before { running = append(running, c) } - for _, own := range []bool{true, false} { + 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 + }{{true, true, false}, {false, true, false}, {true, false, false}, {false, false, false}, {true, true, 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 @@ -234,16 +240,47 @@ func TestTheProvisionedMeshHandsAPlanWithItsProvision(t *testing.T) { runs = append(runs, c) } } - b.Builds["mesh-provisioner-client "+fps["mesh-provisioner-client"]] = &delivery.BuilderBuild{State: delivery.BuildBuilding} - after, _, held, err := frombus.Compose(context.Background(), b, frombus.Mesh{}, frombus.Scope{Node: Node, Running: runs}, fps) - if _, isHeld := held["mesh-provisioner-client"]; err != nil || !isHeld { - t.Fatalf("the client with no done build is not held: %v %v", held, err) + bb, err := Provisioned() + if err != nil { + t.Fatal(err) } - if got := after[ownKey].Own.Provisioner; got != account { - t.Errorf("the client held (account from its own composition: %v): the node's own names %q, want %q", own, got, account) + 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.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 = "" } - if !reflect.DeepEqual(after[resticKey].Filled, before[resticKey].Filled) { - t.Errorf("the client held (account from its own composition: %v): restic's filled files %v, want %v", own, after[resticKey].Filled, before[resticKey].Filled) + 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) { + 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 row.placed && !row.userless && !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) } } } diff --git a/internal/compose/rendering.go b/internal/compose/rendering.go index c6b195b..7aaae27 100644 --- a/internal/compose/rendering.go +++ b/internal/compose/rendering.go @@ -74,10 +74,10 @@ 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 == "" && provisionerPlaced(plan) { - // The provisioner's client is placed here but composes no account this pass (held as its stub, which runs no - // process of its own composition): it keeps running as it runs, so the account it runs as stays named, and every - // module stays handed to it. + 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 @@ -196,11 +196,11 @@ func provisionerAccount(plan catalogue.Resolution) string { return "" } -// provisionerPlaced says the plan places a module claiming the provisioner client's seat on the machine, whether or not -// it composes the client's process this pass. -func provisionerPlaced(plan catalogue.Resolution) bool { +// 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.ClaimsSeat(NodeProvisionerSeat) { + if m.Stubbed && m.ClaimsSeat(NodeProvisionerSeat) { return true } } -- 2.54.0