From 085f17e935dbda5225ddba63ac3c4cf195f77807 Mon Sep 17 00:00:00 2001 From: jochen Date: Sat, 31 Oct 2026 09:29:32 +0100 Subject: [PATCH] Let a node composing itself hand a module exactly when the vault judged its plan handed, through one rule (catalogue.AssignmentHands), so the vault and the node never judge postgres's secrets two ways again (issue 774) --- go/buscompose/accounts.go | 17 ++++++++++----- go/catalogue/declaration.go | 4 ++++ go/catalogue/handed.go | 34 ++++++++++++++++++++++++++--- go/catalogue/handed_secrets_test.go | 12 ++++++++++ go/compose/frombus/frombus.go | 19 ++++++++++++++++ go/compose/frombus/frombus_test.go | 9 ++++++++ go/compose/input.go | 4 ++++ go/compose/render.go | 2 +- go/compose/rendering.go | 1 + 9 files changed, 92 insertions(+), 10 deletions(-) diff --git a/go/buscompose/accounts.go b/go/buscompose/accounts.go index 4c2b3fd..5a08036 100644 --- a/go/buscompose/accounts.go +++ b/go/buscompose/accounts.go @@ -187,12 +187,17 @@ func HeldCredential(w WantedAccount, password string) ([]byte, error) { Claims: w.Claims}) } -// **One issuer at every moment: minting turns on at the lease write** (novox/hq ADR 0561 §3, §6). The controller's lease -// is the one key `holder` of its bucket (LeaseBucket). The operator's credential hands it away by writing a value that -// names its new holder in `handed-to`; while the key holds that, the vault mints and the grants part creates the durable -// consumers an account needs. A controller's value, a key deleted or expired, or one that cannot be read or parsed, turn -// both off: when in doubt nothing is minted, so the controller and the vault never both mint one account. No clock and -// no setting turns it. +// **The lease write** (novox/hq ADR 0561 §3, §6). The controller's lease is the one key `holder` of its bucket +// (LeaseBucket). The operator's credential hands it away by writing a value that names its new holder in `handed-to`; +// while the key holds that, the grants part creates the durable consumers an account needs, the assigner reads the grants +// part's grants (delivery.GrantSource), and the vault retires and rotates accounts. A controller's value, a key deleted or +// expired, or one that cannot be read or parsed, turn those off. No clock and no setting turns it. +// +// **Minting is no longer on the lease** (issue 774, research 072's gap "vault mints after the lease"): the vault is the +// one issuer of every account the grants part wants, whoever holds the lease, and the controller mints none once a vault +// is installed (it forwards `module issue` to the vault's issue-account). One issuer at every moment holds by who mints, +// not by the lease: an account whose value the vault holds is never minted anew, and a value the controller hands or +// moves never replaces one the vault minted. // ControllerLease is the lease's bucket as a reader names it; the vault and the grants part alone read it // (NamedReaders), the controller's announcement marking it readable by nobody. diff --git a/go/catalogue/declaration.go b/go/catalogue/declaration.go index 69b0a11..babf872 100644 --- a/go/catalogue/declaration.go +++ b/go/catalogue/declaration.go @@ -277,6 +277,10 @@ type Rendering struct { // templated files are handed to the client (handed.go); without either, it composes as before. Hands bool Provisioner string + // Assigned is, by module, whether the vault judged it handed (AssignmentHands of the plan in its assignment), as the + // vault answered the node composing itself; where it names a module, Handing follows it alone (issue 774). Nil, or a + // module it does not name, is judged by the manifest and the machine, as the composer of the plans does. + Assigned map[string]bool } // RootField is a user resource's field saying the account must never become root without a person diff --git a/go/catalogue/handed.go b/go/catalogue/handed.go index bda7dcd..0a03796 100644 --- a/go/catalogue/handed.go +++ b/go/catalogue/handed.go @@ -1,6 +1,7 @@ package catalogue import ( + "encoding/json" "errors" "fmt" "maps" @@ -294,16 +295,43 @@ func handedBy(m Manifest, archive Built) *Handed { return h } -// handing is whether this composition hands the module's templated files to the provisioner. +// handing is whether this composition hands the module's templated files to the provisioner: handed (Handing), with +// the templates its build kept. A module the vault judged handed whose build kept none is refused where its secrets +// are placed ("nothing would fill it"), never composed with values the vault does not answer. func (with Rendering) handing(m Manifest) bool { - return m.Moved && m.Handed != nil && with.Hands && with.Provisioner != "" + return with.Handing(m) && m.Handed != nil } // Handing is whether a machine composed with this rendering hands the module to the provisioner, its files and // its own secrets (design 57 P6, V4): a moved module there, assigned or only pulled in by another (issue 765), on a // machine that can take it. The controller makes and seals none of such a module's secrets for that machine: its // client asks the vault. -func (with Rendering) Handing(m Manifest) bool { return with.handing(m) } +// +// **Where the vault judged the module, its word is the rule** (Rendering.Assigned, issue 774): the vault leaves a +// module's own secrets out of `own` exactly when the plan in its assignment hands it (AssignmentHands), so a node +// composing itself hands exactly those, whatever its own reading of the manifest and the machine says. On 2026-10-31 +// postgres on novox was held: its plan handed its secrets, the vault answered none of them, and the node, judging by +// the manifest and the machine, composed them itself and refused for want of `reader`. Two readers, one rule. +func (with Rendering) Handing(m Manifest) bool { + if hands, judged := with.Assigned[m.Module]; judged { + return hands + } + return m.Moved && m.Handed != nil && with.Hands && with.Provisioner != "" +} + +// AssignmentHands is whether a module's value in the seat mesh-provisioner's plans (the assignments mirror's shape, +// `{"provisioning": }`) hands its module to the provisioner: Plan.Hands of the plan it carries, false with no +// plan. A value that does not read is an error, never "not handed". The one rule the vault judges `own` by and a node +// composing itself follows (Rendering.Assigned, issue 774). +func AssignmentHands(raw []byte) (bool, error) { + var a struct { + Provisioning *Plan `json:"provisioning"` + } + if err := json.Unmarshal(raw, &a); err != nil { + return false, err + } + return a.Provisioning != nil && a.Provisioning.Hands(), nil +} // requiredByWords begins why a module only pulled in by another is on a machine (Resolution.Because): `required by // `. diff --git a/go/catalogue/handed_secrets_test.go b/go/catalogue/handed_secrets_test.go index d7f988a..a8a8760 100644 --- a/go/catalogue/handed_secrets_test.go +++ b/go/catalogue/handed_secrets_test.go @@ -65,6 +65,15 @@ func TestAHandedModulesSecretsAreNamedAndNoValueIsSealed(t *testing.T) { if want := []PlanSecret{{Name: "token", Path: path}}; !reflect.DeepEqual(c.Plans["bot"].Secrets, want) { t.Fatalf("the plan names secrets %+v, want %+v", c.Plans["bot"].Secrets, want) } + // Issue 774 (postgres on novox, 2026-10-31): where the vault judged it handed, its secrets are the vault's even on a + // machine the node reads as not taking a filled file, and nothing is refused for a value the vault never answers. + judged, err := r.Compose(Rendering{ArtifactStore: "store:5000", Assigned: map[string]bool{"bot": true}}) + if err != nil { + t.Fatalf("a module the vault judged handed is refused: %v", err) + } + if want := []PlanSecret{{Name: "token", Path: path}}; !reflect.DeepEqual(judged.Plans["bot"].Secrets, want) { + t.Errorf("judged handed by the vault, the plan names secrets %+v, want %+v", judged.Plans["bot"].Secrets, want) + } // A process naming the token's file still restarts when its client writes it. for _, res := range c.Resources { if res["id"] == "bot.daemon" && !strings.Contains(strings.Join(stringsIn(res["restart-on"]), " "), "bot.needs-token") { @@ -99,6 +108,9 @@ func TestAMovedModulesSecretsAreSealedAsBeforeWhereItIsNotHanded(t *testing.T) { for name, with := range map[string]Rendering{ "an engine that does not take a filled file alone": {Provisioner: "mesh-provisioner-client", ArtifactStore: "store:5000"}, "no provisioner client on the machine": {Hands: true, ArtifactStore: "store:5000"}, + // Issue 774: where the vault judged the module not handed, the node composes it so, whatever else it reads. + "the vault judged it not handed": {Hands: true, Provisioner: "mesh-provisioner-client", ArtifactStore: "store:5000", + Assigned: map[string]bool{"bot": false}}, } { c, got := composeWith(t, notifierOn(t, held(t, botMoved(), builtBot(botText))), sealedForBot(with)) if got != before { diff --git a/go/compose/frombus/frombus.go b/go/compose/frombus/frombus.go index 315d20c..a87ecf0 100644 --- a/go/compose/frombus/frombus.go +++ b/go/compose/frombus/frombus.go @@ -94,6 +94,15 @@ type SignersReader interface { VaultAuthority(ctx context.Context) string } +// HandedReader is a Reader that also says, of the composing node's modules, which the vault judged handed to the +// provisioner when it answered OwnSecrets (the vault's `own`, its `handed`: catalogue.AssignmentHands of the plan in +// each module's assignment). The node's composition then hands exactly those (compose.Node.Handed, issue 774), so the +// vault and the node never judge one module two ways. Nil, or a Reader without it (a vault older than the field), +// composes by the manifest and the machine, as before. +type HandedReader interface { + Handed(ctx context.Context, node string) map[string]bool +} + // Mesh is what is true of the whole mesh and is no part's state on the bus yet: the private network's range and the // internal names' suffix as genesis was told them, and the mesh's authority certificate (public). Empty fields take // compose's defaults. @@ -408,6 +417,16 @@ func input(ctx context.Context, r Reader, mesh Mesh, s Scope, refuse func(module if in.OwnSecrets, err = r.OwnSecrets(ctx, s.Node); err != nil { return compose.Input{}, fmt.Errorf("frombus: the own secrets of %s's modules: %w", s.Node, err) } + // And which of its modules the vault left out of that answer as handed: the node hands exactly those. + if hr, ok := r.(HandedReader); ok { + if handed := hr.Handed(ctx, s.Node); handed != nil { + for i := range in.Nodes { + if in.Nodes[i].Name == s.Node { + in.Nodes[i].Handed = handed + } + } + } + } } // The core's credentials travel as the vault's box sealed to the machine (ADR 0349 §16): the same values, which // the composition reads for a core module as its kept boxes. diff --git a/go/compose/frombus/frombus_test.go b/go/compose/frombus/frombus_test.go index aa521a4..acf7718 100644 --- a/go/compose/frombus/frombus_test.go +++ b/go/compose/frombus/frombus_test.go @@ -27,6 +27,7 @@ import ( // bus is a Reader over values held in memory, counting whose config values were read. type bus struct { vaultAuthority string + handed map[string]bool placements map[string]delivery.Placement records map[string]delivery.NodeRecord modules map[string]delivery.Module @@ -79,6 +80,7 @@ func (b *bus) Placements(context.Context) (map[string]delivery.Placement, error) return b.placements, nil } func (b *bus) VaultAuthority(context.Context) string { return b.vaultAuthority } +func (b *bus) Handed(context.Context, string) map[string]bool { return b.handed } func (b *bus) Records(context.Context) (map[string]delivery.NodeRecord, error) { return b.records, nil } func (b *bus) Modules(context.Context) (map[string]delivery.Module, error) { return b.modules, nil } func (b *bus) Manifest(_ context.Context, digest string) ([]byte, error) { @@ -245,6 +247,13 @@ func TestInput(t *testing.T) { bundled.Authority != "the mesh's certificate\nthe vault's certificate\n" { t.Errorf("the authority with the vault's published: %q, %v", bundled.Authority, err) } + // Issue 774: the modules the vault judged handed when it answered own are the node's to hand, and no other node's. + judged := mk() + judged.handed = map[string]bool{"media": true, "xterm": false} + if in, err := Input(ctx, judged, Mesh{}, Scope{Node: "laptop"}); err != nil || + !reflect.DeepEqual(in.Nodes[1].Handed, judged.handed) || in.Nodes[0].Handed != nil { + t.Errorf("the vault's handed verdicts: %+v, %v", in.Nodes, err) + } if !reflect.DeepEqual(b.valuesRead, []string{"laptop." + overlay.Name, "laptop.xterm", "laptop.media"}) { t.Errorf("laptop read config values %v; a node reads its own slice alone", b.valuesRead) } diff --git a/go/compose/input.go b/go/compose/input.go index dbd2900..4e09f53 100644 --- a/go/compose/input.go +++ b/go/compose/input.go @@ -142,6 +142,10 @@ type Node struct { Place Place // Assigned is the modules assigned to it, in the order the controller reads them (inv.Assigned). Assigned []string + // Handed is, by module, whether the vault judged it handed to the provisioner when it answered this node's own + // secrets (catalogue.AssignmentHands of the plan in its assignment): the node's own composition hands exactly those + // (catalogue.Rendering.Assigned, issue 774). Nil where the vault said nothing of it, as for the controller. + Handed map[string]bool // Capabilities are the ones present on it (inv.ProfileOf). Capabilities map[string]bool // PublicDomain is the domain it composes its routed names under (inv.PublicDomainOf). diff --git a/go/compose/render.go b/go/compose/render.go index 66d9814..4dd0ab1 100644 --- a/go/compose/render.go +++ b/go/compose/render.go @@ -65,7 +65,7 @@ func (c *composer) render(nodeName string, mode Mode) (Rendered, error) { } // And each module's own secrets: a module handed to the provisioner has its secrets from the vault through the client. - handing := catalogue.Rendering{Hands: r.Hands, Provisioner: r.Provisioner} + handing := catalogue.Rendering{Hands: r.Hands, Provisioner: r.Provisioner, Assigned: r.Assigned} needed, foreseenOwn, err := c.ownSecretsFor(node, plan, handing, mode) if err != nil { return Rendered{}, err diff --git a/go/compose/rendering.go b/go/compose/rendering.go index ee619cf..d942bc6 100644 --- a/go/compose/rendering.go +++ b/go/compose/rendering.go @@ -73,6 +73,7 @@ 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) + r.Assigned = node.Handed r.ReadsHealth = node.Stated && node.Contract >= ReadinessContract r.JudgesRoot = node.Stated && node.Contract >= RootContract r.ReadsBusy = node.Stated && node.Contract >= BusyContract