inventory: forgetting a module says what goes with it, and refuses until told

`module forget` cascaded. The settings, the module's own secrets and the ports the mesh
chose all name the module by a foreign key that cascades, so removing the row took all
three and reported "forgotten" — an action succeeding into a state its own verify would
reject (novox/hq 04-ISSUES/017). A sealed secret is not recoverable afterwards, because
the mesh discarded the plaintext when it made it.

It now reads what it would destroy, names each thing one at a time, and refuses.
`--and-what-it-holds` is how somebody says they mean it, and the removal then reports
what went — this being the only record that any of it ever existed.

Reported as "operator settings do not persist, because re-registering a module
cascade-deletes them". Half of that is wrong, and the test now says so out loud: the
upsert is on the name, so `module add` at a new version leaves the settings, the secrets
and the ports exactly where they were. The command that destroyed them was `forget`, and
a wrong belief about which command destroys data is expensive in both directions — it
sends people looking for a fault that is not there, and leaves the real one unexamined.

Checked by internal/inventory/forget_test.go, which writes all three, re-registers the
module at a new version, reads them back, and only then tries to forget it.

Claude-Session: https://claude.ai/code/session_01LrgweAeERJYBg88c5cKDzF
This commit is contained in:
2026-09-10 21:11:15 +02:00
parent 0fb2ab7716
commit f5f860fd2b
3 changed files with 419 additions and 7 deletions
+164 -2
View File
@@ -204,10 +204,165 @@ func (i *Inventory) Provided(ctx context.Context, name string) (bool, error) {
return source != nil && *source == "the control plane", nil
}
// ForgetModule removes a module, unless a machine is running it, and never one the control plane
// provides.
// ErrStillHolds is why a module cannot be forgotten without saying so first.
//
// Its own error because it is not a fault either: the operator settings, the module's own secrets
// and the ports the mesh chose for it are all keyed on the module by name and all cascade when the
// row goes. Removing the module removes them, silently, and none of them can be recovered — a
// sealed secret least of all, because the mesh discarded the plaintext when it made it.
var ErrStillHolds = errors.New("the mesh still holds things for that module")
// Holdings is everything keyed on a module that would go with it.
//
// **Named one at a time rather than counted.** "3 settings" tells somebody there is something to
// lose and not whether they can afford to lose it; "the mesh-wide layer, and anchor's" tells them
// what to write down before they type the command again.
type Holdings struct {
// Mesh is true when a mesh-wide settings layer exists for the module.
Mesh bool
// Nodes are the machines with a settings layer of their own for it, sorted.
Nodes []string
// Secrets are the module's own secrets, as "<name> on <node>", sorted. These are the ones the
// mesh cannot make again: what is stored is sealed to a machine and the plaintext is gone.
Secrets []string
// Ports are the ports the mesh chose for it, as "<wanted> on <node>", sorted. Made once and
// kept (novox/hq ADR 0038) — removing the module gives that promise up.
Ports []string
}
// Any reports whether removing the module would discard anything.
func (h Holdings) Any() bool {
return h.Mesh || len(h.Nodes) > 0 || len(h.Secrets) > 0 || len(h.Ports) > 0
}
// Lines is what would be lost, one thing per line, for a person about to decide.
func (h Holdings) Lines() []string {
var out []string
if h.Mesh {
out = append(out, " settings, for the whole mesh")
}
for _, n := range h.Nodes {
out = append(out, " settings, on "+n)
}
for _, s := range h.Secrets {
out = append(out, " its own secret "+s+" — sealed, so the mesh cannot make it again")
}
for _, p := range h.Ports {
out = append(out, " the port "+p)
}
return out
}
// HeldFor is everything the mesh keeps that is keyed on one module.
//
// Read rather than counted at the moment of removal, because the answer is the whole of what a
// person needs in order to say yes.
func (i *Inventory) HeldFor(ctx context.Context, name string) (Holdings, error) {
var held Holdings
rows, err := i.store.Pool().Query(ctx,
`select coalesce(n.name, '') from settings s left join node n on n.id = s.node
where s.module = $1 order by n.name nulls first`, name)
if err != nil {
return Holdings{}, err
}
for rows.Next() {
var node string
if err := rows.Scan(&node); err != nil {
rows.Close()
return Holdings{}, err
}
if node == "" {
held.Mesh = true
continue
}
held.Nodes = append(held.Nodes, node)
}
rows.Close()
if err := rows.Err(); err != nil {
return Holdings{}, err
}
for _, read := range []struct {
query string
into *[]string
}{
{`select s.name || ' on ' || n.name from module_secret s join node n on n.id = s.node
where s.module = $1 order by n.name, s.name`, &held.Secrets},
{`select p.wanted::text || ' on ' || n.name from port_assignment p
join node n on n.id = p.node where p.module = $1 order by n.name, p.wanted`, &held.Ports},
} {
rows, err := i.store.Pool().Query(ctx, read.query, name)
if err != nil {
return Holdings{}, err
}
for rows.Next() {
var one string
if err := rows.Scan(&one); err != nil {
rows.Close()
return Holdings{}, err
}
*read.into = append(*read.into, one)
}
rows.Close()
if err := rows.Err(); err != nil {
return Holdings{}, err
}
}
return held, nil
}
// ForgetModule removes a module, unless a machine is running it, unless the mesh still holds
// things for it, and never one the control plane provides.
//
// **Refused rather than cascaded.** The settings, own-secrets and port assignments all name the
// module by a foreign key that cascades, so the row going takes them with it and says nothing.
// That is an action succeeding into a state its own verify would reject (novox/hq 04-ISSUES/017):
// the command reports "forgotten", the operator re-registers the module a moment later, and what
// comes back is a module with none of its configuration and none of its secrets — with nothing
// anywhere naming the moment they were lost.
func (i *Inventory) ForgetModule(ctx context.Context, name string) error {
if err := i.mayForget(ctx, name); err != nil {
return err
}
held, err := i.HeldFor(ctx, name)
if err != nil {
return err
}
if held.Any() {
return fmt.Errorf("%w:\n%s\n\nAll of it goes when the module does. Run "+
"`module forget %s --and-what-it-holds` if that is what you mean",
ErrStillHolds, strings.Join(held.Lines(), "\n"), name)
}
return i.discard(ctx, name)
}
// DiscardModule removes a module and everything the mesh holds for it, having been told to.
//
// The same checks as ForgetModule except the one about what is held: a machine running it still
// refuses, and a module the control plane provides still refuses, because neither of those is
// something an operator can consent to on the module's behalf.
func (i *Inventory) DiscardModule(ctx context.Context, name string) (Holdings, error) {
if err := i.mayForget(ctx, name); err != nil {
return Holdings{}, err
}
held, err := i.HeldFor(ctx, name)
if err != nil {
return Holdings{}, err
}
if err := i.discard(ctx, name); err != nil {
return Holdings{}, err
}
// Returned so the caller can say what went, rather than "forgotten". A person who has just
// destroyed a sealed secret should be able to read which one from the output.
return held, nil
}
// mayForget is the part of forgetting that is not about what is held.
func (i *Inventory) mayForget(ctx context.Context, name string) error {
provided, err := i.Provided(ctx, name)
if errors.Is(err, pgx.ErrNoRows) {
return fmt.Errorf("%w: %s", ErrNoSuchModule, name)
}
if err != nil {
return err
}
@@ -235,10 +390,17 @@ func (i *Inventory) ForgetModule(ctx context.Context, name string) error {
on = append(on, node)
}
rows.Close()
if err := rows.Err(); err != nil {
return err
}
if len(on) > 0 {
return fmt.Errorf("%w: %s. Unassign it first", ErrStillAssigned, strings.Join(on, ", "))
}
return nil
}
// discard is the removal itself, once it has been decided.
func (i *Inventory) discard(ctx context.Context, name string) error {
tag, err := i.store.Pool().Exec(ctx, `delete from module where name = $1`, name)
if err != nil {
return err