Review of L2, round 2: a refusal at the first compose after a start keeps what the module's have said runs (1); each have of a self-composing node carries the values its composition holds sealed, and the composer reuses them after a restart, so a rotated secret moves no composition at a restart (2); pin mesh-sdk dd9d9d5 (issue 1126, ADR 0547 §3)
mesh/delivery closed: a newer head of the same pull request
mesh/check checking 4476e5808047 on ff9386dcccc2 | novox/mesh-host#149 in group feat/1126-nodes-compose

This commit is contained in:
2026-10-26 20:23:16 +01:00
parent 58de6aa10a
commit 4476e58080
9 changed files with 255 additions and 72 deletions
+32 -24
View File
@@ -55,6 +55,9 @@ type Env struct {
// keeps the vault's box.
Running func() []delivery.Composed
Unseal func(sealed string) ([]byte, error)
// Held is the sealed values a module's have says it runs (delivery.Have.Sealed), kept by the agent on the bus: what
// runs after a restart, before the agent applied anything, and after the switch once the kept declaration is old.
Held func(module string) []string
}
// VaultSecretChanged is the vault's event that a secret changed: a name and its generation, never a value.
@@ -431,39 +434,44 @@ func (c *Composer) refused(why string) {
}
}
// runningBox is the box the node already runs that opens to the same value as box, or box: read before c.mu is taken,
// since the agent's lock is taken to read what runs.
// runningBox is the box the node already runs that opens to the same value as box, or box: what the agent runs, and
// what each wanted module's have says it runs sealed (Held). Read before c.mu is taken, since the agent's lock is taken
// to read what runs.
func (c *Composer) runningBox(box string) string {
if c.env.Running == nil || c.env.Unseal == nil {
if c.env.Unseal == nil || (c.env.Running == nil && c.env.Held == nil) {
return box
}
value, err := c.env.Unseal(box)
if err != nil {
return box
}
for _, comp := range c.env.Running() {
for _, raw := range comp.Resources {
var r map[string]json.RawMessage
if json.Unmarshal(raw, &r) != nil {
continue
}
for _, field := range []string{"sealed", catalogue.VaultSealedField} {
f, ok := r[field]
if !ok {
continue
}
var held string
if json.Unmarshal(f, &held) != nil {
held = string(f) // a vault box is carried as its JSON
}
if held == "" || held == box {
continue
}
if v, err := c.env.Unseal(held); err == nil && bytes.Equal(v, value) {
return held
}
var held []string
if c.env.Running != nil {
for _, comp := range c.env.Running() {
held = append(held, agent.SealedIn(comp)...)
}
}
if c.env.Held != nil {
c.mu.Lock()
modules := make([]string, 0, len(c.wants))
for m := range c.wants {
if m != agent.NodeModule {
modules = append(modules, m)
}
}
c.mu.Unlock()
sort.Strings(modules)
for _, m := range modules {
held = append(held, c.env.Held(m)...)
}
}
for _, h := range held {
if h == box {
continue
}
if v, err := c.env.Unseal(h); err == nil && bytes.Equal(v, value) {
return h
}
}
return box
}
+52 -2
View File
@@ -90,10 +90,12 @@ func TestTheNodeComposesItsWantsFromTheBus(t *testing.T) {
env := b.env()
// The vault's own: keeper's password sealed to n1's key, sealed again (other bytes) at every ask.
var asks, generation int
sealedAt := map[string]int{} // each box handed, by the generation it opens to
env.Own = func(context.Context, map[string]any) (json.RawMessage, error) {
b.mu.Lock()
defer b.mu.Unlock()
asks++
sealedAt[fmt.Sprintf(`{"ask":%d}`, asks)] = generation + 1
return json.RawMessage(fmt.Sprintf(`{"node":"n1","key":"sk","own":[{"module":"keeper","name":"password","origin":"made",`+
`"generation":%d,"sealed":{"ask":%d}}]}`, generation+1, asks)), nil
}
@@ -207,6 +209,7 @@ func TestTheNodeComposesItsWantsFromTheBus(t *testing.T) {
if !strings.Contains(handed, `{\"ask\":`) {
t.Fatalf("keeper's composition does not carry the sealed value: %s", handed)
}
before := handed
b.put(t, ResolvedBucket, "n1.other.k", []byte(`{"value":2}`))
quiet(t, out)
b.mu.Lock()
@@ -214,8 +217,55 @@ func TestTheNodeComposesItsWantsFromTheBus(t *testing.T) {
b.mu.Unlock()
// The vault says the secret changed: the node composes again on that word, with no other input moving.
secretChanged <- []byte(`{"name":"own/n1/keeper/password","generation":2}`)
if u := next(t, out); u.Key != keeperKey {
t.Fatalf("a new generation handed %s, want keeper again", u.Key)
rotated := next(t, out)
if rotated.Key != keeperKey {
t.Fatalf("a new generation handed %s, want keeper again", rotated.Key)
}
// Review of L2 (round 2): rotated, then the node-engine restarts. The agent has applied nothing yet, so the box the
// node runs comes from keeper's have (Held); the vault seals the value again, other bytes, and the box the have holds
// is kept, since it opens to the same value: the composition does not move. The box from before the rotation opens
// to the old value, so the vault's is taken.
boxOf := func(raw string) string {
c, err := delivery.DecodeComposed([]byte(raw))
if err != nil {
t.Fatal(err)
}
sealed := agent.SealedIn(c)
if len(sealed) != 1 {
t.Fatalf("keeper's composition carries %v", sealed)
}
return sealed[0]
}
for _, row := range []struct {
name, held string
kept bool
}{{"the have holds the rotated value", boxOf(string(rotated.Value)), true}, {"the have holds the value before the rotation", boxOf(before), false}} {
restarted := env
restarted.Running = func() []delivery.Composed { return nil }
restarted.Held = func(module string) []string {
if module == "keeper" {
return []string{row.held}
}
return nil
}
restarted.Unseal = func(box string) ([]byte, error) {
b.mu.Lock()
defer b.mu.Unlock()
if g, ok := sealedAt[box]; ok {
return []byte(fmt.Sprintf("password-%d", g)), nil
}
return nil, fmt.Errorf("not sealed to n1: %s", box)
}
again := New(restarted)
again.wants = map[string]delivery.Want{"keeper": {FP: fp, Self: true}}
rows, err := reader{c: again}.OwnSecrets(ctx, "n1")
if err != nil || len(rows) != 1 {
t.Fatalf("%s: own secrets %+v, %v", row.name, rows, err)
}
if kept := rows[0].Sealed == row.held; kept != row.kept {
t.Errorf("%s: composed with %s (held %s), kept %v, want %v", row.name, rows[0].Sealed, row.held, kept, row.kept)
}
}
// The pair credentials asked (ADR 0550 §5): the keys go to the vault's own with their role, a consumer's comes back as