Compose as the controller does, so a node's shadow can answer same (issue 1126, C1 C2 C3 C5 C6, T1)

Seals are reused and compared by slot (resource, field), never across resources;
one rule says which resolved config values are laid; a seat path that steps back
is owned by the seat it steps back for, whatever the seat order; a folded module
of an old-form build is composed from what runs of it; the operator export's file
is carried as it runs; a differing resource names its differing JSON paths, found
before its bodies are cut, and an answer is fitted to the node's key.
This commit is contained in:
2026-10-28 09:36:36 +01:00
parent be77f66563
commit 16b8d8a1ee
17 changed files with 683 additions and 26 deletions
+160
View File
@@ -0,0 +1,160 @@
package delivery
import (
"bytes"
"encoding/json"
"sort"
"strconv"
"strings"
)
// DifferingPaths is every JSON path at which two bodies of one resource differ, sorted (issue 1126, T1: the shadow
// shows its work): object keys and list places joined by dots (`content.url`, `links.0`), a string that holds a JSON
// object or list read as that JSON and walked on (a runtime's config carried as text), a key or place one side alone
// holds named as it is. A key a secret could be under (secretWords) is named and not walked into. A body that is not
// JSON differs at "" (the whole), and a resource one side alone holds has none. At most MaxPaths: the last then says how many more there are. A path names keys
// alone, never a value, so it holds no secret.
func DifferingPaths(composed, running json.RawMessage) []string {
if composed == nil || running == nil {
return nil // one side alone holds it: its why says so
}
var a, b any
okA := unmarshalNumbers(composed, &a) == nil
okB := unmarshalNumbers(running, &b) == nil
if !okA || !okB {
if bytes.Equal(composed, running) {
return nil
}
return []string{""}
}
var out []string
walkDiffering(a, b, "", &out)
sort.Strings(out)
if len(out) > MaxPaths {
more := len(out) - (MaxPaths - 1)
out = append(out[:MaxPaths-1], "… "+strconv.Itoa(more)+" more")
}
return out
}
func unmarshalNumbers(raw []byte, into *any) error {
d := json.NewDecoder(bytes.NewReader(raw))
d.UseNumber()
return d.Decode(into)
}
// asJSON is a string holding a JSON object or list read as that value; v itself otherwise.
func asJSON(v any) any {
s, ok := v.(string)
if !ok {
return v
}
t := strings.TrimSpace(s)
if t == "" || (t[0] != '{' && t[0] != '[') {
return v
}
var parsed any
if unmarshalNumbers([]byte(t), &parsed) != nil {
return v
}
return parsed
}
func join(at, key string) string {
if at == "" {
return key
}
return at + "." + key
}
func walkDiffering(a, b any, at string, out *[]string) {
a, b = asJSON(a), asJSON(b)
ma, isMA := a.(map[string]any)
mb, isMB := b.(map[string]any)
if isMA && isMB {
keys := map[string]bool{}
for k := range ma {
keys[k] = true
}
for k := range mb {
keys[k] = true
}
for k := range keys {
x, inA := ma[k]
y, inB := mb[k]
switch {
case !inA || !inB:
*out = append(*out, join(at, k))
case secretKey(k):
if !sameAny(x, y) {
*out = append(*out, join(at, k))
}
default:
walkDiffering(x, y, join(at, k), out)
}
}
return
}
la, isLA := a.([]any)
lb, isLB := b.([]any)
if isLA && isLB {
for i := range max(len(la), len(lb)) {
if i >= len(la) || i >= len(lb) {
*out = append(*out, join(at, strconv.Itoa(i)))
continue
}
walkDiffering(la[i], lb[i], join(at, strconv.Itoa(i)), out)
}
return
}
if !sameAny(a, b) {
*out = append(*out, at)
}
}
func sameAny(a, b any) bool {
x, errA := json.Marshal(a)
y, errB := json.Marshal(b)
return errA == nil && errB == nil && bytes.Equal(x, y)
}
// FitAnswer is a with its shown bodies cut until its JSON is within limit bytes (MaxAnswerBytes for the node's key):
// first the bodies' texts, from the last differing module back, keeping their paths; then the paths too. What is cut
// is said in the answer's note. The verdict and every list of modules are never cut.
func FitAnswer(a CandidateAnswer, limit int) CandidateAnswer {
size := func() int {
raw, err := json.Marshal(a)
if err != nil {
return 0
}
return len(raw)
}
if size() <= limit {
return a
}
a.Differs = append([]ModuleDiffers(nil), a.Differs...)
for i := range a.Differs {
a.Differs[i].Bodies = append([]ResourceBodies(nil), a.Differs[i].Bodies...)
}
cutTexts, cutPaths := false, false
for i := len(a.Differs) - 1; i >= 0 && size() > limit; i-- {
for j := range a.Differs[i].Bodies {
a.Differs[i].Bodies[j].Composed, a.Differs[i].Bodies[j].Running = "", ""
}
cutTexts = true
}
for i := len(a.Differs) - 1; i >= 0 && size() > limit; i-- {
for j := range a.Differs[i].Bodies {
a.Differs[i].Bodies[j].Paths = nil
}
cutPaths = true
}
why := "the bodies shown were cut to keep the answer within " + strconv.Itoa(limit>>10) + " KiB"
if cutPaths {
why += ", their paths too"
}
if cutTexts {
a.Note = strings.TrimPrefix(a.Note+"; ", "; ") + why
}
return a
}
+7 -1
View File
@@ -80,6 +80,9 @@ func CompareComposedWith(composed map[string]Composed, running []Composed, sameV
// compareOne is one module's comparison: true when the digests agree, else what differs first.
func compareOne(module string, composed, running Composed, sameValue func(a, b string) (bool, bool)) (ModuleDiffers, bool) {
composed, running = withoutBusUsers(composed), withoutBusUsers(running)
// A sealed value is judged by the value it opens to, slot by slot (issue 1126, C1): a value sealed again at the same
// field of the same resource is the box that runs, never a difference; and a box is never matched across resources.
composed = KeepSeals(composed, SealsIn(running), sameValue)
dc, errC := ComposedDigestOf(composed)
dr, errR := ComposedDigestOf(running)
if errC != nil {
@@ -128,8 +131,11 @@ func compareOne(module string, composed, running Composed, sameValue func(a, b s
found = true
}
if len(bodies) < MaxBodies {
// The paths are found on the whole bodies, before either is cut (issue 1126, T1: three resources over
// MaxBody could not be told apart).
c, r := MaskedPair(x, y, sameValue)
bodies = append(bodies, ResourceBodies{ID: id, Why: why, Composed: cut(c), Running: cut(r)})
bodies = append(bodies, ResourceBodies{ID: id, Why: why, Paths: DifferingPaths(x, y),
Composed: cut(c), Running: cut(r)})
}
}
if found {
+150
View File
@@ -0,0 +1,150 @@
package delivery
import (
"bytes"
"encoding/json"
"strconv"
"git.novox.be/novox/mesh-sdk/go/catalogue"
)
// Seals is, by slot, each value one composition carries sealed to its node's key (issue 1126, C1): a file's `sealed`
// and a vault box (catalogue.VaultSealedField), as their JSON text (a box is carried as its JSON, a sealed value as
// the string it is). A slot is one field of one resource of the composition's module (SealSlot): a box is reused only
// at the slot that holds it, never for another resource whose value opens to the same (a provider's grant and its
// consumer's secret on one node hold one value).
type Seals map[string]string
// SealedFields are the fields of a resource that carry a value sealed to the node's key.
var SealedFields = []string{"sealed", catalogue.VaultSealedField}
// SealSlot is the slot of one sealed field of one resource, by the resource's id (its place, `#<i>`, when it has none).
// The module is the composition's: a slot is never compared across compositions.
func SealSlot(resource, field string) string { return resource + "#" + field }
// SealsIn is every sealed value c carries, by slot; nil when it carries none.
func SealsIn(c Composed) Seals {
var out Seals
for i, raw := range c.Resources {
id, fields, ok := sealedFieldsOf(raw, i)
if !ok {
continue
}
for _, field := range SealedFields {
f, has := fields[field]
if !has {
continue
}
if text := sealedText(f); text != "" {
if out == nil {
out = Seals{}
}
out[SealSlot(id, field)] = text
}
}
}
return out
}
// KeepSeals is c with each sealed value replaced by the one kept at the same slot, where same says the two open to the
// same value (known and same): a value the vault sealed again is then the box that runs, so the composition is the
// one that runs (ADR 0547 §3, issue 1126). A slot kept holds nothing for, or whose value differs or cannot be opened,
// keeps c's. Every other byte of a resource is kept as it is; c itself is not changed.
func KeepSeals(c Composed, kept Seals, same func(a, b string) (same, known bool)) Composed {
if len(kept) == 0 || same == nil {
return c
}
out := c
out.Resources = nil
for i, raw := range c.Resources {
id, fields, ok := sealedFieldsOf(raw, i)
if ok {
for _, field := range SealedFields {
f, has := fields[field]
if !has {
continue
}
ours := sealedText(f)
theirs, held := kept[SealSlot(id, field)]
if !held || ours == "" || theirs == ours {
continue
}
if s, known := same(ours, theirs); !known || !s {
continue
}
value := json.RawMessage(theirs)
if f[0] == '"' {
value, _ = json.Marshal(theirs)
}
if swapped, ok := withField(raw, field, value); ok {
raw = swapped
}
}
}
out.Resources = append(out.Resources, raw)
}
return out
}
// sealedFieldsOf is a resource's id (`#<i>` when it has none) and its top-level fields, false when it is no object.
func sealedFieldsOf(raw json.RawMessage, i int) (string, map[string]json.RawMessage, bool) {
var fields map[string]json.RawMessage
if json.Unmarshal(raw, &fields) != nil {
return "", nil, false
}
var id string
if f, ok := fields["id"]; !ok || json.Unmarshal(f, &id) != nil || id == "" {
id = "#" + strconv.Itoa(i)
}
return id, fields, true
}
// sealedText is a sealed field as its text: a string as it is, a box as its JSON.
func sealedText(f json.RawMessage) string {
var text string
if json.Unmarshal(f, &text) == nil {
return text
}
var compact bytes.Buffer
if json.Compact(&compact, f) != nil {
return string(f)
}
return compact.String()
}
// withField is the object raw with its top-level field replaced by value, every other byte as it was (so a composition
// whose sealed values are those that run is the one that runs, byte for byte); false when raw is no object holding it.
func withField(raw json.RawMessage, field string, value json.RawMessage) (json.RawMessage, bool) {
d := json.NewDecoder(bytes.NewReader(raw))
if t, err := d.Token(); err != nil || t != json.Delim('{') {
return nil, false
}
for d.More() {
t, err := d.Token()
if err != nil {
return nil, false
}
key, _ := t.(string)
before := d.InputOffset()
var skip json.RawMessage
if err := d.Decode(&skip); err != nil {
return nil, false
}
if key != field {
continue
}
after := d.InputOffset()
// The span holds the colon, any space and the value: kept up to the colon, the value replaced.
span := raw[before:after]
colon := bytes.IndexByte(span, ':')
if colon < 0 {
return nil, false
}
out := make([]byte, 0, len(raw)-len(span)+colon+1+len(value))
out = append(out, raw[:int(before)+colon+1]...)
out = append(out, value...)
out = append(out, raw[after:]...)
return out, true
}
return nil, false
}
+10 -2
View File
@@ -48,6 +48,7 @@ import (
"encoding/json"
"errors"
"fmt"
"maps"
"reflect"
"regexp"
"slices"
@@ -355,6 +356,10 @@ type Have struct {
// after a restart while it opens to the vault's value, so a value sealed again moves no composition. Openable by the
// node alone, as the vault hands them over the bus. Empty on every other have.
Sealed []string `json:"sealed,omitempty"`
// SealedAt is Sealed by slot (Seals: a field of a resource, issue 1126, C1): the composer reuses a box only at the
// slot that holds it, so a provider and a consumer of one value on one node each keep their own. Written in Sealed's
// place by a node-engine that knows it; Sealed is read only from a have written before.
SealedAt Seals `json:"sealed-at,omitempty"`
}
// UserListWritten is the bus's user list as the node-engine of the bus node wrote it to the bus module's file, applied
@@ -406,6 +411,8 @@ type HaveNode struct {
// applied (Have.Sealed for the node's own, issue 1126): a value the vault seals again after a restart moves no
// composition, so a restart applies nothing again.
Sealed []string `json:"sealed,omitempty"`
// SealedAt is Sealed by slot (Have.SealedAt).
SealedAt Seals `json:"sealed-at,omitempty"`
// Candidates is the node's answer to each placement candidate naming it, by group (PlacementCandidate): written by
// its shadow composer whenever an answer changes, removed when the candidate key goes. The node applies nothing of a
// candidate.
@@ -586,10 +593,11 @@ func DecodePulse(data []byte) (Pulse, error) {
func SameHave(a, b Have) bool {
ua, ub := a.UserList, b.UserList
a.UserList, b.UserList = nil, nil
if !slices.Equal(a.Sealed, b.Sealed) {
if !slices.Equal(a.Sealed, b.Sealed) || !maps.Equal(a.SealedAt, b.SealedAt) {
return false
}
a.Sealed, b.Sealed = nil, nil
a.SealedAt, b.SealedAt = nil, nil
if !reflect.DeepEqual(a, b) || (ua == nil) != (ub == nil) {
return false
}
@@ -607,7 +615,7 @@ func SameHaveNode(a, b HaveNode) bool {
return a.V == b.V && a.Composed == b.Composed && sameJSON(a.Windows, b.Windows) && sameJSON(a.Busy, b.Busy) &&
sameJSON(a.Strays, b.Strays) && sameJSON(a.Filters, b.Filters) && sameJSON(a.Profile, b.Profile) &&
(a.Record == nil) == (b.Record == nil) && (a.Record == nil || SameNodeRecord(*a.Record, *b.Record)) &&
jsonEqual(a.Sealed, b.Sealed) && jsonEqual(a.Candidates, b.Candidates) && a.Composes == b.Composes &&
jsonEqual(a.Sealed, b.Sealed) && maps.Equal(a.SealedAt, b.SealedAt) && jsonEqual(a.Candidates, b.Candidates) && a.Composes == b.Composes &&
a.ComposesWhy == b.ComposesWhy
}
+15 -7
View File
@@ -163,16 +163,24 @@ type ModuleDiffers struct {
// ResourceBodies is one differing resource of a module: its id, why, and its body composed and as it runs, masked
// (MaskedPair); a side that does not hold it is empty.
type ResourceBodies struct {
ID string `json:"id"`
Why string `json:"why"`
Composed string `json:"composed,omitempty"`
Running string `json:"running,omitempty"`
ID string `json:"id"`
Why string `json:"why"`
// Paths is every JSON path at which the two bodies differ (DifferingPaths: `sealed`, `content.url`, `links.0`), found
// on the whole bodies before they are cut, a JSON value written in a string read as JSON; a path names keys alone,
// never a value. At most MaxPaths, the last then saying how many more.
Paths []string `json:"paths,omitempty"`
Composed string `json:"composed,omitempty"`
Running string `json:"running,omitempty"`
}
// The bounds of a module's shown bodies.
// The bounds of a module's shown bodies, and of one answer as the node's own key holds it: an answer over
// MaxAnswerBytes is fitted (FitAnswer). The node's key (HaveBucket) takes a value of about 1 MiB
// (buscompose.ChainValueBytes) and holds every group's answer beside the node's facts, so one answer keeps to a sixth.
const (
MaxBodies = 20
MaxBody = 8192
MaxBodies = 20
MaxBody = 8192
MaxPaths = 64
MaxAnswerBytes = 160 << 10
)
// ModuleAdded is one module the candidate places on the node and the node does not run: its composition's digest when
+49 -11
View File
@@ -3,7 +3,9 @@ package delivery
import (
"encoding/json"
"errors"
"fmt"
"reflect"
"slices"
"strings"
"testing"
@@ -288,16 +290,30 @@ func TestPlacement(t *testing.T) {
sealedFile := func(id, sealed, mode string) json.RawMessage {
return json.RawMessage(`{"id":"` + id + `","type":"file","module":"nats","path":"/etc/` + id + `","mode":"` + mode + `","sealed":"` + sealed + `"}`)
}
shown := CompareComposedWith(map[string]Composed{"k": comp(sealedFile("a", "box-1", "0600"), sealedFile("b", "box-2", "0600"), file("c", "x"))},
[]Composed{comp(sealedFile("a", "box-1", "0644"), sealedFile("b", "box-3", "0600"), file("c", "y"))},
func(a, b string) (same, known bool) { return a == "box-2" && b == "box-3", true })
if len(shown.Differs) != 1 || len(shown.Differs[0].Bodies) != 3 {
t.Fatalf("every differing resource is not shown: %+v", shown.Differs)
// Issue 1126, C1: a sealed value is judged by the value it opens to at its own slot: b sealed again (box-2 and
// box-3 open to one value) is no difference; d holds at its slot a box of another value, though that value is
// the one b holds, and differs at its path `sealed`.
sameBox := func(a, b string) (same, known bool) {
return (a == "box-2" && b == "box-3") || a == b, true
}
for _, row := range []struct{ id, has, hasNot string }{
{"a", `"mode":"0600"`, "box-1"},
{"b", "another seal of the same value", "box-2"},
{"c", `"content":"x"`, ""},
shown := CompareComposedWith(map[string]Composed{"k": comp(sealedFile("a", "box-1", "0600"), sealedFile("b", "box-2", "0600"),
file("c", "x"), sealedFile("d", "box-2", "0600"))},
[]Composed{comp(sealedFile("a", "box-1", "0644"), sealedFile("b", "box-3", "0600"), file("c", "y"), sealedFile("d", "box-4", "0600"))},
sameBox)
if len(shown.Differs) != 1 || len(shown.Differs[0].Bodies) != 3 {
t.Fatalf("every differing resource is not shown, or b sealed again is shown: %+v", shown.Differs)
}
if c := CompareComposedWith(map[string]Composed{"k": comp(sealedFile("b", "box-2", "0600"))},
[]Composed{comp(sealedFile("b", "box-3", "0600"))}, sameBox); len(c.Same) != 1 {
t.Errorf("a value sealed again at its own slot is not the same: %+v", c)
}
for _, row := range []struct {
id, has, hasNot string
paths []string
}{
{"a", `"mode":"0600"`, "box-1", []string{"mode"}},
{"c", `"content":"x"`, "", []string{"content"}},
{"d", "<masked: another value>", "box-2", []string{"sealed"}},
} {
var body ResourceBodies
for _, b := range shown.Differs[0].Bodies {
@@ -306,10 +322,32 @@ func TestPlacement(t *testing.T) {
}
}
both := body.Composed + body.Running
if !strings.Contains(both, row.has) || (row.hasNot != "" && strings.Contains(both, row.hasNot)) {
t.Errorf("%s shown as %+v", row.id, body)
if !strings.Contains(both, row.has) || (row.hasNot != "" && strings.Contains(both, row.hasNot)) ||
!slices.Equal(body.Paths, row.paths) {
t.Errorf("%s shown as %+v, want paths %v", row.id, body, row.paths)
}
}
// Issue 1126, T1: the paths are found on the whole bodies, before they are cut; JSON in a string is walked into, a
// key a secret could be under is named and never shown.
long := strings.Repeat("x", MaxBody)
conf := func(link, password string) json.RawMessage {
inner, _ := json.Marshal(map[string]any{"pad": long, "links": []string{"a", link}, "password": password})
outer, _ := json.Marshal(map[string]any{"id": "conf", "type": "file", "module": "nats", "path": "/etc/conf",
"mode": "0600", "content": string(inner)})
return outer
}
far := CompareComposedWith(map[string]Composed{"k": comp(conf("b", "hunter2"))}, []Composed{comp(conf("c", "hunter3"))}, nil)
if len(far.Differs) != 1 || len(far.Differs[0].Bodies) != 1 ||
!slices.Equal(far.Differs[0].Bodies[0].Paths, []string{"content.links.1", "content.password"}) ||
!strings.Contains(far.Differs[0].Bodies[0].Composed, "cut at") || strings.Contains(fmt.Sprint(far), "hunter") {
t.Errorf("a difference past the cut: %+v", far.Differs)
}
// And an answer too large for the node's key is fitted: the texts go first, the verdict and the paths stay.
big := CandidateAnswer{Head: "h", Verdict: CandidateDiffers, Differs: far.Differs}
if fitted := FitAnswer(big, 4<<10); len(fitted.Differs[0].Bodies[0].Composed) != 0 || len(fitted.Differs[0].Bodies[0].Paths) != 2 ||
fitted.Verdict != CandidateDiffers || !strings.Contains(fitted.Note, "cut") || big.Differs[0].Bodies[0].Composed == "" {
t.Errorf("fitted as %+v", fitted)
}
if c, r := MaskedPair(json.RawMessage(`{"env":{"DB_PASSWORD":"hunter2","PORT":"80"},"list":[{"token":"t1"}]}`),
json.RawMessage(`{"env":{"DB_PASSWORD":"hunter3","PORT":"81"},"list":[{"token":"t1"}]}`), nil); strings.Contains(c+r, "hunter") ||
strings.Contains(c+r, `"t1"`) || !strings.Contains(r, `"PORT":"81"`) || !strings.Contains(c, "<masked: differs>") ||