From 02b542a0592eaadbeb3c123858d1846dc692c744 Mon Sep 17 00:00:00 2001 From: mesh-admin Date: Wed, 14 Oct 2026 00:42:37 +0000 Subject: [PATCH] Keep a layer's placement beside it, and say the layer whole (issue 651) The settings verb's path splits a layer it is given into its config values and its placement, so what a person types still works; a layer reads back and goes into the history whole. Co-Authored-By: Claude Opus 5.5 --- internal/inventory/catalogue.go | 97 ++++++++++++++++++++++++++------- internal/inventory/unseen.go | 28 +++++++--- 2 files changed, 99 insertions(+), 26 deletions(-) diff --git a/internal/inventory/catalogue.go b/internal/inventory/catalogue.go index 6ce3740c..31cc597f 100644 --- a/internal/inventory/catalogue.go +++ b/internal/inventory/catalogue.go @@ -839,7 +839,18 @@ func (i *Inventory) KeepSettings(ctx context.Context, nodeName, module string, v } func (i *Inventory) setSettings(ctx context.Context, nodeName, module string, values map[string]any, judge bool, setBy string) error { - raw, err := json.Marshal(values) + // **Placement is not config** (novox/hq ADR 0348 clause 11, issue 651): the layer is said whole, as + // it always was, and its placement keys are kept beside the assignment, never in the settings row. + // Both are written in one transaction, and the layer a set replaces is the whole of both. + config, placement := catalogue.SplitPlacement(values) + if config == nil { + config = map[string]any{} + } + raw, err := json.Marshal(config) + if err != nil { + return err + } + placed, err := placementJSON(placement) if err != nil { return err } @@ -881,6 +892,9 @@ func (i *Inventory) setSettings(ctx context.Context, nodeName, module string, va do update set values = excluded.values, set_at = now(), set_by = excluded.set_by`, module, raw, nullable(setBy)); err != nil { return wrapModule(err, module) } + if err := keepPlacement(ctx, tx, nil, module, placed, setBy); err != nil { + return err + } return tx.Commit(ctx) } node, err := i.NodeByName(ctx, nodeName) @@ -912,6 +926,9 @@ func (i *Inventory) setSettings(ctx context.Context, nodeName, module string, va if err != nil { return wrapModule(err, module) } + if err := keepPlacement(ctx, tx, &node.ID, module, placed, setBy); err != nil { + return err + } // A given port replaces what the mesh assigned for that port: the assignment is given back, // so the number is free for the next module rather than held for ever in the name of a port // that now lives elsewhere. @@ -942,21 +959,15 @@ func (i *Inventory) judgeSettings(ctx context.Context, nodeName, module string, return err } where, from, adopted = nodeName, nodeName, node.Adopted - var meshWide []byte - err = i.store.Pool().QueryRow(ctx, - `select values from settings where module = $1 and node is null`, module).Scan(&meshWide) - if err != nil && !errors.Is(err, pgx.ErrNoRows) { + under, has, err := i.Layer(ctx, "", module) + if err != nil { return err } - if len(meshWide) > 0 { - var under map[string]any - if err := json.Unmarshal(meshWide, &under); err != nil { - return err - } - layers = append(layers, catalogue.Layer{From: catalogue.MeshWideLayer, Values: under}) + if has { + layers = append(layers, catalogue.LayerOf(catalogue.MeshWideLayer, under)) } } - layers = append(layers, catalogue.Layer{From: from, Values: values}) + layers = append(layers, catalogue.LayerOf(from, values)) if err := catalogue.JudgeSettings(m, layers, adopted); err != nil { return fmt.Errorf("refused: %s on %s cannot compose with the layer %q — %w", module, where, from, err) } @@ -1089,7 +1100,8 @@ func refuseGivenCollisions(ctx context.Context, tx pgx.Tx, nodeID any, node, mod } func wrapModule(err error, module string) error { - if err != nil && strings.Contains(err.Error(), "settings_module_fkey") { + if err != nil && (strings.Contains(err.Error(), "settings_module_fkey") || + strings.Contains(err.Error(), "placement_module_fkey")) { return fmt.Errorf("%w: %s", ErrNoSuchModule, module) } return err @@ -1130,6 +1142,11 @@ func (i *Inventory) ClearSettingsBy(ctx context.Context, nodeName, module, clear `delete from settings where module = $1 and node is not distinct from $2::uuid`, module, nodeID); err != nil { return err } + // And its placement, which is the same layer as a person says it (novox/hq ADR 0348 clause 11). + if _, err := tx.Exec(ctx, + `delete from placement where module = $1 and node is not distinct from $2::uuid`, module, nodeID); err != nil { + return err + } return tx.Commit(ctx) } @@ -1142,10 +1159,13 @@ func (i *Inventory) SettingsFor(ctx context.Context, nodeName, module string) ([ if err != nil { return nil, err } + // Each layer's placement beside it, from the placement the controller holds (novox/hq ADR 0348 + // clause 11): a layer's Values hold its config values only, its Placement where things are. rows, err := i.store.Pool().Query(ctx, - `select node is null, values from settings - where module = $1 and (node is null or node = $2) - order by node is null desc`, module, node.ID) + `select s.node is null, s.values, p.values from settings s + left join placement p on p.module = s.module and p.node is not distinct from s.node + where s.module = $1 and (s.node is null or s.node = $2) + order by s.node is null desc`, module, node.ID) if err != nil { return nil, err } @@ -1154,23 +1174,62 @@ func (i *Inventory) SettingsFor(ctx context.Context, nodeName, module string) ([ var layers []catalogue.Layer for rows.Next() { var meshWide bool - var raw []byte - if err := rows.Scan(&meshWide, &raw); err != nil { + var raw, placed []byte + if err := rows.Scan(&meshWide, &raw, &placed); err != nil { return nil, err } values := map[string]any{} if err := json.Unmarshal(raw, &values); err != nil { return nil, err } + var placement map[string]any + if len(placed) > 0 { + if err := json.Unmarshal(placed, &placement); err != nil { + return nil, err + } + } from := nodeName if meshWide { from = catalogue.MeshWideLayer } - layers = append(layers, catalogue.Layer{From: from, Values: values}) + layers = append(layers, catalogue.Layer{From: from, Values: values, Placement: placement}) } return layers, rows.Err() } +// placementJSON is a layer's placement as the placement table keeps it, or nil when it has none. +func placementJSON(placement map[string]any) ([]byte, error) { + if len(placement) == 0 { + return nil, nil + } + return json.Marshal(placement) +} + +// keepPlacement writes one layer's placement beside its settings row, in the write's transaction: the +// placement the layer says, or none when it says none — a set replaces the layer whole, placement too. +func keepPlacement(ctx context.Context, tx pgx.Tx, nodeID *string, module string, placed []byte, setBy string) error { + if placed == nil { + _, err := tx.Exec(ctx, + `delete from placement where module = $1 and node is not distinct from $2::uuid`, module, nodeID) + return err + } + var err error + if nodeID == nil { + _, err = tx.Exec(ctx, + `insert into placement (node, module, values, set_by) values (null, $1, $2, $3) + on conflict (module) where node is null + do update set values = excluded.values, set_at = now(), set_by = excluded.set_by`, + module, placed, nullable(setBy)) + } else { + _, err = tx.Exec(ctx, + `insert into placement (node, module, values, set_by) values ($1, $2, $3, $4) + on conflict (node, module) where node is not null + do update set values = excluded.values, set_at = now(), set_by = excluded.set_by`, + *nodeID, module, placed, nullable(setBy)) + } + return wrapModule(err, module) +} + // PinProvision records which provider a machine gets a provision from: a module, and the node it // runs on — both, always (novox/hq #258). A provision comes from a module, and the same module on // two machines is two answers, so neither half alone says which. diff --git a/internal/inventory/unseen.go b/internal/inventory/unseen.go index 8acbf13b..023fa315 100644 --- a/internal/inventory/unseen.go +++ b/internal/inventory/unseen.go @@ -20,9 +20,11 @@ func (i *Inventory) Layer(ctx context.Context, nodeName, module string) (map[str if err != nil { return nil, false, err } + // Said whole, as it was set: its config values and the placement kept beside them (novox/hq ADR 0348 + // clause 11), so a person reading it before setting it sees every key they may keep or remove. var raw []byte - err = i.store.Pool().QueryRow(ctx, - `select values from settings where module = $1 and node is not distinct from $2::uuid`, + err = i.store.Pool().QueryRow(ctx, `select `+layerWhole+` from settings s + where s.module = $1 and s.node is not distinct from $2::uuid`, module, nodeID).Scan(&raw) if errors.Is(err, pgx.ErrNoRows) { return nil, false, nil @@ -46,6 +48,9 @@ type PastLayer struct { ReplacedBy string // SetBy is who had set the layer, when it was kept (novox/hq ADR 0277); empty for one set before that was kept. SetBy string + // Why is why it went, where that was said: "moved to placement, ADR 0348 clause 11" for a layer whose + // placement keys migration 0097 moved out of it (ReplacedBy "moved"). + Why string } // LayerOrigin is who set a layer and when (novox/hq ADR 0277): empty words for a layer set before who set it was @@ -79,7 +84,7 @@ func (i *Inventory) SettingsHistory(ctx context.Context, nodeName, module string return nil, err } rows, err := i.store.Pool().Query(ctx, - `select values, set_at, replaced_at, replaced_by, set_by from settings_history + `select values, set_at, replaced_at, replaced_by, set_by, why from settings_history where module = $1 and node is not distinct from $2::uuid order by replaced_at desc`, module, nodeID) if err != nil { @@ -90,13 +95,16 @@ func (i *Inventory) SettingsHistory(ctx context.Context, nodeName, module string for rows.Next() { var raw []byte var p PastLayer - var by *string - if err := rows.Scan(&raw, &p.SetAt, &p.ReplacedAt, &p.ReplacedBy, &by); err != nil { + var by, why *string + if err := rows.Scan(&raw, &p.SetAt, &p.ReplacedAt, &p.ReplacedBy, &by, &why); err != nil { return nil, err } if by != nil { p.SetBy = *by } + if why != nil { + p.Why = *why + } if err := json.Unmarshal(raw, &p.Values); err != nil { return nil, err } @@ -107,9 +115,15 @@ func (i *Inventory) SettingsHistory(ctx context.Context, nodeName, module string // keepReplacedSQL copies a layer into the history before it is replaced or cleared; run in the same // transaction as the write where there is one, so a write that fails leaves no history of it. +// +// The layer is kept whole, as it was set: its config values and its placement (novox/hq ADR 0348 clause 11). const keepReplacedSQL = `insert into settings_history (node, module, values, set_at, replaced_by, set_by) - select node, module, values, set_at, $3, set_by from settings - where module = $1 and node is not distinct from $2::uuid` + select s.node, s.module, ` + layerWhole + `, s.set_at, $3, s.set_by from settings s + where s.module = $1 and s.node is not distinct from $2::uuid` + +// layerWhole is a settings row `s` with the placement kept beside it folded back in: the layer as it was set. +const layerWhole = `s.values || coalesce((select p.values from placement p + where p.module = s.module and p.node is not distinct from s.node), '{}'::jsonb)` // layerNode is the node id of a layer, nil for the whole mesh's. func (i *Inventory) layerNode(ctx context.Context, nodeName string) (*string, error) {