From 29dfb28f389909ade2c43520f002690e9fba83e2 Mon Sep 17 00:00:00 2001 From: jochen Date: Sun, 1 Nov 2026 09:12:54 +0100 Subject: [PATCH] Hold only the module whose bus account nobody issued, never the whole node (issue 203) The 1585 group's refusal turned one unissued account into a refusal of the node's whole composition: novox's mesh-hand-acts, which the vault had minted but the controller's store does not hold, stopped every take-in on the machine that runs the bus. The module is now held out of the composition alone, in issue 203's words, and the rest composes; no placeholder is sealed. A held module of the former form keeps the node's own composition as last written, so the node-engine never removes it. Counting the vault's accounts as issued needs the controller to read mesh-vault.bus-accounts, which mesh-sdk's named readers give to the grants part alone (ADR 0547 clause 9): not built here. Controller research piece: none moved; this narrows a refusal in the composition the controller hands over (blocking-fault recovery). Claude-Session: https://claude.ai/code/session_01NcbgfJ9Gum47zkFKWRaovL --- cmd/mesh-controller/compose_perform.go | 2 +- cmd/mesh-controller/compose_sdk.go | 82 +++++++++++++++---- cmd/mesh-controller/compose_sdk_test.go | 71 ++++++++++++++++ cmd/mesh-controller/composed_live.go | 3 + cmd/mesh-controller/composed_writer.go | 26 ++++++ .../credential_on_assign_test.go | 19 +++-- .../provisioner_account_integration_test.go | 12 +-- cmd/mesh-controller/sendable.go | 3 + 8 files changed, 188 insertions(+), 30 deletions(-) diff --git a/cmd/mesh-controller/compose_perform.go b/cmd/mesh-controller/compose_perform.go index dd4ae33e..a77aec24 100644 --- a/cmd/mesh-controller/compose_perform.go +++ b/cmd/mesh-controller/compose_perform.go @@ -87,7 +87,7 @@ func perform(ctx context.Context, open *stores, in *meshInput, node string, d co } // The node's own secrets: a module's superuser password, an administrator, an account. A bus credential nobody issued is - // refused before this (unissuedBusCredential), a given value under an old key by the composition. + // held out of the composition before this (holdBackUnissuedBusAccounts), a given value under an old key by the composition. for _, one := range d.StoodIn { module, name, ok := strings.Cut(one, "/") if !ok || strings.Contains(one, " ") { diff --git a/cmd/mesh-controller/compose_sdk.go b/cmd/mesh-controller/compose_sdk.go index a44c4421..e28a4fb8 100644 --- a/cmd/mesh-controller/compose_sdk.go +++ b/cmd/mesh-controller/compose_sdk.go @@ -184,11 +184,10 @@ func composeQuestion(ctx context.Context, open *stores, node string, mode compos if err := refusedBus(in, node); err != nil { return sendable{}, nil, err } - if err := unissuedBusCredential(in, node, d); err != nil { - return sendable{}, nil, err - } + held := holdBackUnissuedBusAccounts(in, node, &d) sayFound(ctx, d) out := sendableOf(d) + out.heldForCredential = held out.fileReferences = readsFileReferences(in.Input, node) if !withOrder { out.Sequence, out.Epoch, out.Generation = 0, 0, 0 @@ -215,12 +214,19 @@ func refusedBus(in *meshInput, node string) error { return nil } -// unissuedBusCredential refuses a composition that stands in for a bus credential nobody issued: the module's `broker` own -// secret (its account `.`) or the provisioner client's (`provisioner.`). A bus credential is minted by -// `module issue`, never invented: a send that made one would seal a placeholder its process cannot read (novox/hq issue -// 203). mesh-sdk's composition stands one in, in the Foreseeing mode, since a node's answer to a proposal is asked before -// the vault mints it (mesh-sdk #141, issue 774), so the send refuses it here, in the words the composition used to. -func unissuedBusCredential(in *meshInput, node string, d compose.Declared) error { +// holdBackUnissuedBusAccounts holds out of d every module that stands in for a bus credential nobody issued: the module's +// `broker` own secret (its account `.`) or the provisioner client's (`provisioner.`). A bus credential is +// minted, never invented: a send that made one would seal a placeholder its process cannot read (novox/hq issue 203). +// mesh-sdk's composition stands one in, in the Foreseeing mode, since a node's answer to a proposal is asked before the vault +// mints it (mesh-sdk #141, issue 774). +// +// **That one module is held, never the node.** Its resources, its stand-ins and its build leave this composition, it is named +// among the left out with issue 203's words (said where the composition's other lines go), and the rest of the node composes: +// a refusal of the whole node stopped every take-in on the machine that runs the bus for one account (2026-11-01, novox's +// mesh-hand-acts, which the vault had in fact minted; the controller cannot read the vault's accounts yet, so such a module +// stays held until its account is in the controller's store). The modules held are answered, sorted. +func holdBackUnissuedBusAccounts(in *meshInput, node string, d *compose.Declared) []string { + why := map[string]string{} for _, one := range d.StoodIn { module, name, ok := strings.Cut(one, "/") if !ok || strings.Contains(one, " ") { @@ -235,13 +241,53 @@ func unissuedBusCredential(in *meshInput, node string, d compose.Declared) error default: continue } - if !slices.Contains(in.Input.BusMinted, user) { - return fmt.Errorf("%s on %s has no bus credential: nothing was issued for %s, and a push would seal a "+ - "placeholder its process cannot read (novox/hq issue 203). `module issue %s --node %s`, then push again", - module, node, user, module, node) + if _, said := why[module]; said || slices.Contains(in.Input.BusMinted, user) { + continue + } + why[module] = fmt.Sprintf("it has no bus credential: nothing was issued for %s, and a push would seal a placeholder "+ + "its process cannot read (novox/hq issue 203). `module issue %s --node %s`, then push again", user, module, node) + } + if len(why) == 0 { + return nil + } + held := make([]string, 0, len(why)) + for module := range why { + held = append(held, module) + } + sort.Strings(held) + ofHeld := func(s string) bool { + module, _, _ := strings.Cut(s, "/") + _, out := why[module] + return out + } + kept := d.Resources[:0:0] + for _, r := range d.Resources { + module := d.ModuleOf[fmt.Sprint(r["id"])] + if module == "" { + module, _ = r["module"].(string) + } + if _, out := why[module]; !out { + kept = append(kept, r) } } - return nil + d.Resources = kept + d.StoodIn = slices.DeleteFunc(d.StoodIn, ofHeld) + d.Foreseen = slices.DeleteFunc(d.Foreseen, ofHeld) + d.Modules = slices.DeleteFunc(d.Modules, func(m string) bool { _, out := why[m]; return out }) + if d.LeftOutWhy == nil { + d.LeftOutWhy = map[string]string{} + } + for _, module := range held { + delete(d.BuildIDs, module) + d.LeftOutWhy[module] = why[module] + if !slices.Contains(d.LeftOut, module) { + d.LeftOut = append(d.LeftOut, module) + } + d.Said = append(d.Said, fmt.Sprintf("%s on %s is held out of %s's composition, and the rest of %s composes: %s", + module, node, node, node, why[module])) + } + sort.Strings(d.LeftOut) + return held } // markUnresolvable is an error of a node whose own set does not compose, as planFor marked it. @@ -288,10 +334,9 @@ func composeSendValidated(ctx context.Context, open *stores, node string) (senda if err := refusedBus(in, node); err != nil { return sendable{}, nil, err } - // Before perform makes what the composition stands in for: a bus credential is never one of them. - if err := unissuedBusCredential(in, node, d); err != nil { - return sendable{}, nil, err - } + // Before perform makes what the composition stands in for: a bus credential is never one of them. The module + // whose account nobody issued is held out of this composition, and the rest of the node composes. + held := holdBackUnissuedBusAccounts(in, node, &d) if len(byController) > 0 { if d, problems, err = sealedByTheController(in, node, d, byController); err != nil { return sendable{}, nil, err @@ -308,6 +353,7 @@ func composeSendValidated(ctx context.Context, open *stores, node string) (senda if len(left) == 0 && len(in.wouldMake) == 0 { sayFound(ctx, d) out := sendableOf(d) + out.heldForCredential = held out.fileReferences = readsFileReferences(in.Input, node) // The sender stamps the order it allots (order.stamp); a composition for a send carries none of its own. out.Sequence, out.Epoch, out.Generation = 0, 0, 0 diff --git a/cmd/mesh-controller/compose_sdk_test.go b/cmd/mesh-controller/compose_sdk_test.go index f20caa22..a6282b2d 100644 --- a/cmd/mesh-controller/compose_sdk_test.go +++ b/cmd/mesh-controller/compose_sdk_test.go @@ -7,6 +7,7 @@ import ( "git.novox.be/novox/mesh-sdk/go/catalogue" "git.novox.be/novox/mesh-sdk/go/compose" + sdkdelivery "git.novox.be/novox/mesh-sdk/go/delivery" ) // A declaration as the SDK composes it is the one the controller sends, byte for byte, whatever it carries: the body is the @@ -85,3 +86,73 @@ func TestADeclarationThatStandsInForAValueIsStillForeseen(t *testing.T) { } } } + +// 2026-11-01 (issue 203's refusal after the 1585 group): a module standing in for a bus account nobody issued is held out of +// the composition, alone; the rest of the node composes, with no stand-in left. An issued account holds nothing. And a held +// module with no fingerprint of the new form keeps the node's own composition as last written. +func TestAnUnissuedBusAccountHoldsItsModuleAloneAndNeverTheNode(t *testing.T) { + declared := func() compose.Declared { + return compose.Declared{ + Resources: []map[string]any{ + {"id": "hands.broker", "type": "file", "sealed": catalogue.ForeseenSealed}, + {"id": "hands.unit", "type": "service"}, + {"id": "client.broker", "type": "file", "sealed": catalogue.ForeseenSealed}, + {"id": "web.config", "type": "file", "content": "ok"}, + }, + ModuleOf: map[string]string{"hands.broker": "hands", "hands.unit": "hands", "client.broker": "client", + "web.config": "web"}, + BuildIDs: map[string]string{"hands": "b-1", "client": "b-2", "web": "b-3"}, + Modules: []string{"client", "hands", "web"}, + StoodIn: []string{"hands/broker", "client/" + compose.ProvisionerSecret}, + Foreseen: []string{"hands/broker", "client/" + compose.ProvisionerSecret}, + } + } + for name, c := range map[string]struct { + minted []string + held []string + }{ + "issued by nobody": {held: []string{"client", "hands"}}, + "the module's account issued": {minted: []string{"anchor.hands"}, held: []string{"client"}}, + "both issued": {minted: []string{"anchor.hands", "provisioner.anchor"}}, + } { + in := &meshInput{Input: compose.Input{BusMinted: c.minted}} + d := declared() + held := holdBackUnissuedBusAccounts(in, "anchor", &d) + if !reflect.DeepEqual(held, c.held) { + t.Errorf("%s: held %v, want %v", name, held, c.held) + continue + } + if len(c.held) < 2 { + continue // an issued account's stand-in is the send's to make (perform), not this one's + } + if left := stillForeseen(d); len(left) != 0 { + t.Errorf("%s: the rest of the node still stands in for %v", name, left) + } + if !reflect.DeepEqual(d.Modules, []string{"web"}) || len(d.Resources) != 1 || d.BuildIDs["hands"] != "" { + t.Errorf("%s: the rest of the node is not what composes: %v %v %v", name, d.Modules, d.Resources, d.BuildIDs) + } + if !reflect.DeepEqual(d.LeftOut, []string{"client", "hands"}) || + !strings.Contains(d.LeftOutWhy["hands"], "module issue hands --node anchor") || + !strings.Contains(d.LeftOutWhy["client"], "provisioner.anchor") || len(d.Said) != 2 { + t.Errorf("%s: the held modules are not said in issue 203's words: %v %v %v", name, d.LeftOut, d.LeftOutWhy, d.Said) + } + } + + own, err := sdkdelivery.ComposedNodeKey("anchor") + if err != nil { + t.Fatal(err) + } + for name, c := range map[string]struct { + fps map[string]string + keepOwn bool + }{ + "a held module of the new form": {fps: map[string]string{"hands": sdkdelivery.SourcePrefix2 + "x"}, keepOwn: true}, + "a held module of the former form": {fps: map[string]string{}}, + } { + comps := map[string]sdkdelivery.Composed{own: {Node: "anchor"}} + said := withoutNodeOwnForHeld("anchor", comps, c.fps, []string{"hands"}) + if _, kept := comps[own]; kept != c.keepOwn || (said == "") != c.keepOwn { + t.Errorf("%s: the node's own composition kept %v (want %v), said %q", name, kept, c.keepOwn, said) + } + } +} diff --git a/cmd/mesh-controller/composed_live.go b/cmd/mesh-controller/composed_live.go index d910c03e..0c46ec06 100644 --- a/cmd/mesh-controller/composed_live.go +++ b/cmd/mesh-controller/composed_live.go @@ -124,6 +124,9 @@ func composeModulesByNode(ctx context.Context, open *stores, nodes []string) (ma errsOf[node] = append(errsOf[node], fmt.Errorf("%s: %w", node, err)) return } + if out := withoutNodeOwnForHeld(node, comps, fps, declared.heldForCredential); out != "" { + fmt.Printf("compositions: %s\n", out) + } if len(moved) > 0 { fmt.Printf("compositions: %s's %s compose with the node's own: no fingerprint of the new form names them\n", node, strings.Join(moved, ", ")) diff --git a/cmd/mesh-controller/composed_writer.go b/cmd/mesh-controller/composed_writer.go index 4a7c7a10..b45cd8c3 100644 --- a/cmd/mesh-controller/composed_writer.go +++ b/cmd/mesh-controller/composed_writer.go @@ -486,3 +486,29 @@ func controllerSigner(ctx context.Context, open *stores) (func(sdkdelivery.Compo return sdkdelivery.SignComposedBy(c, public, func(m []byte) ([]byte, error) { return ident.Sign(ctx, m) }) }, nil } + +// withoutNodeOwnForHeld takes the node's own composition out of comps when a module held for a bus credential nobody +// issued (holdBackUnissuedBusAccounts) has no fingerprint of the new form: its resources would have been in the node's own, +// and the node's own written without them would have the node-engine remove a module that may be running. The node's +// own stays as last written; every module's composition is written. Answers what it said, "" when nothing was taken. +func withoutNodeOwnForHeld(node string, comps map[string]sdkdelivery.Composed, fps map[string]string, held []string) string { + var old []string + for _, module := range held { + if !strings.HasPrefix(fps[module], sdkdelivery.SourcePrefix2) { + old = append(old, module) + } + } + if len(old) == 0 { + return "" + } + own, err := sdkdelivery.ComposedNodeKey(node) + if err != nil { + return "" + } + if _, ok := comps[own]; !ok { + return "" + } + delete(comps, own) + return fmt.Sprintf("%s's own composition is not written this pass: %s, held for a bus account nobody issued, would "+ + "leave it, and the node-engine would remove what may be running", node, strings.Join(old, ", ")) +} diff --git a/cmd/mesh-controller/credential_on_assign_test.go b/cmd/mesh-controller/credential_on_assign_test.go index 0cbc1262..6001080b 100644 --- a/cmd/mesh-controller/credential_on_assign_test.go +++ b/cmd/mesh-controller/credential_on_assign_test.go @@ -3,6 +3,7 @@ package main import ( + "slices" "strings" "testing" @@ -39,17 +40,23 @@ func TestAssigningAModuleThatSpeaksOnTheBusNamesItsCredential(t *testing.T) { t.Fatalf("an assignment whose credential could not be issued does not name the verb:\n%s", said) } - // And the push refuses to send it, naming the same verb, rather than sealing a placeholder. + // And the push holds talker out, naming the same verb, rather than sealing a placeholder; the rest of the + // machine composes (2026-11-01: one unissued account refused the whole node that runs the bus). plan, settings, err := resolveNode(ctx, open, "laptop") if err != nil { t.Fatal(err) } - _, err = declarationFor(ctx, open, "laptop", plan, settings) - if err == nil { - t.Fatal("a push sealed a placeholder where talker's bus credential belongs") + held, err := declarationFor(ctx, open, "laptop", plan, settings) + if err != nil { + t.Fatalf("one unissued account refused the whole machine: %v", err) } - if !strings.Contains(err.Error(), "module issue talker --node laptop") || !strings.Contains(err.Error(), "issue 203") { - t.Fatalf("the refusal does not say what to run: %v", err) + why := held.leftOutWhy["talker"] + if !slices.Contains(held.LeftOut, "talker") || !strings.Contains(why, "module issue talker --node laptop") || + !strings.Contains(why, "issue 203") { + t.Fatalf("talker is not held out in issue 203's words: %v %q", held.LeftOut, why) + } + if body, err := held.Body(); err != nil || strings.Contains(string(body), catalogue.ForeseenSealed) { + t.Fatalf("a push would seal a placeholder where talker's bus credential belongs: %v\n%s", err, body) } // Once the user is minted, the push goes on to the credential the mesh sealed, and re-assigning diff --git a/cmd/mesh-controller/provisioner_account_integration_test.go b/cmd/mesh-controller/provisioner_account_integration_test.go index 61f2515a..03157804 100644 --- a/cmd/mesh-controller/provisioner_account_integration_test.go +++ b/cmd/mesh-controller/provisioner_account_integration_test.go @@ -27,9 +27,10 @@ func TestTheProvisionerClientsAccountIsIssuedForItsServiceAndNeverInvented(t *te Username: "laptop.mesh-provisioner", Kind: inventory.BusModule, Node: "laptop", Module: "mesh-provisioner"}); err != nil { t.Fatal(err) } - // The composition refuses it in the send's words (mesh-sdk's go/compose: TestTheSendsRefusalsAreRefusedInTheSendsWords). - if _, _, err := composeQuestion(ctx, open, "laptop", compose.Foreseeing, false); err == nil || !strings.Contains(err.Error(), "provisioner.laptop") { - t.Fatalf("a push would seal a placeholder for the client's account before it was issued: %v", err) + // The composition holds the module out in the send's words, and composes the rest of the machine. + if held, _, err := composeQuestion(ctx, open, "laptop", compose.Foreseeing, false); err != nil || + !strings.Contains(held.leftOutWhy[m.Module], "provisioner.laptop") { + t.Fatalf("a push would seal a placeholder for the client's account before it was issued: %v %v", err, held.leftOutWhy) } if err := issueProvisionerClient(ctx, inv, m, "laptop", broker.Broker{Fingerprint: "ab"}, "10.0.0.1:4222"); err != nil { @@ -45,8 +46,9 @@ func TestTheProvisionerClientsAccountIsIssuedForItsServiceAndNeverInvented(t *te if !slices.ContainsFunc(states, func(s inventory.SecretState) bool { return s.Name == broker.ProvisionerSecret }) { t.Fatalf("the client's account is not sealed to the machine for its service: %+v", states) } - if _, _, err := composeQuestion(ctx, open, "laptop", compose.Foreseeing, false); err != nil && strings.Contains(err.Error(), "provisioner.laptop") { - t.Fatalf("an issued client account is refused: %v", err) + if held, _, err := composeQuestion(ctx, open, "laptop", compose.Foreseeing, false); err != nil || + slices.Contains(held.heldForCredential, m.Module) { + t.Fatalf("an issued client account is held: %v %v", err, held.leftOutWhy) } if !runsTheProvisionerClient(m) { t.Fatal("the module is not read as running the client") diff --git a/cmd/mesh-controller/sendable.go b/cmd/mesh-controller/sendable.go index 446ebf06..b77d02bb 100644 --- a/cmd/mesh-controller/sendable.go +++ b/cmd/mesh-controller/sendable.go @@ -73,6 +73,9 @@ type sendable struct { LeftOut []string // leftOutWhy is why each was, for push and plan to say; never on the wire. leftOutWhy map[string]string + // heldForCredential are the modules held out because their bus account was issued by nobody + // (holdBackUnissuedBusAccounts), sorted; never on the wire. + heldForCredential []string // withheld is every consumer this machine's grants leave out, because its identity overflows the // provision's bound (novox/hq ADR 0225); for push and plan to say, never on the wire. withheld []catalogue.Overflow