diff --git a/modules/mesh-vault/cmd/mesh-vault/move.go b/modules/mesh-vault/cmd/mesh-vault/move.go index 8db5f853..be9dab1b 100644 --- a/modules/mesh-vault/cmd/mesh-vault/move.go +++ b/modules/mesh-vault/cmd/mesh-vault/move.go @@ -74,8 +74,22 @@ type MovedIn struct { // ReadBack is the value the vault's store holds now, sealed to the key the second step sent (NaCl anonymous // box, base64). ReadBack string `json:"read_back"` + // Refused is set, with no read-back, when nothing was taken because the vault holds another value for the name: its + // reason ("holds-another-value") and the held value's record, never the value (the sweep of 2026-10-30, issue 774: + // the refusal was words alone). Why is the same in words. + Refused *MoveRefused `json:"refused,omitempty"` + Why string `json:"why,omitempty"` } +// MoveRefused is why a move took nothing, as fields. +type MoveRefused struct { + Reason string `json:"reason"` + Held *HeldAnother `json:"held,omitempty"` +} + +// RefusedHeldAnother is MoveRefused's reason when the vault holds another value for the name. +const RefusedHeldAnother = "holds-another-value" + // PairSecret is the vault's name for one row of the controller's pair credentials. func PairSecret(provider, provision, consumer, consumerModule, local string) (string, error) { parts := map[string]string{"the provider's machine": provider, "the provision": provision, @@ -213,6 +227,14 @@ func (v *Vault) MoveTake(caller, call, sealed, readBackTo string) (MovedIn, erro m := c.move changed, err := v.keepWith(c.secret, string(value), caller, "", movedOrigins[m.Origin], keepOptions{ neverReplace: true, madeAt: m.MadeAt, movedFrom: &MovedFrom{By: caller, Generation: m.Generation}}) + var another *HeldAnother + if errors.As(err, &another) { + // Answered, not failed: the caller reads the reason and the held record. A caller that reads no `refused` finds + // no read-back and fails as loudly as before. + v.log("did not move in %s from %s: %v", c.secret, caller, err) + return MovedIn{Name: c.secret, Generation: another.Generation, + Refused: &MoveRefused{Reason: RefusedHeldAnother, Held: another}, Why: err.Error()}, nil + } if err != nil { return MovedIn{}, err } diff --git a/modules/mesh-vault/cmd/mesh-vault/move_test.go b/modules/mesh-vault/cmd/mesh-vault/move_test.go index 4da010aa..b6270be4 100644 --- a/modules/mesh-vault/cmd/mesh-vault/move_test.go +++ b/modules/mesh-vault/cmd/mesh-vault/move_test.go @@ -198,9 +198,13 @@ func TestTheMoveNeverReplacesAValueTheVaultHolds(t *testing.T) { if _, err := v.Rotate(ControllerCaller, "pair/anchor/database/shanks/gitea", "jochen", "a test"); err != nil { t.Fatal(err) } - if _, err := moveIn(t, v, "node.anchor", pair, "the-pair-value"); err == nil || - !strings.Contains(err.Error(), "never replaces") { - t.Fatalf("a move over a value the vault changed since: %v", err) + // Refused as fields, not words alone (the sweep of 2026-10-30, issue 774): the reason and the held record, no + // read-back, so a caller that reads no `refused` still fails. + refused, err := moveIn(t, v, "node.anchor", pair, "the-pair-value") + if err != nil || refused.Refused == nil || refused.Refused.Reason != RefusedHeldAnother || refused.ReadBack != "" || + refused.Refused.Held == nil || refused.Refused.Held.Generation != 2 || refused.Refused.Held.Origin != OriginMade || + !strings.Contains(refused.Why, "never replaces") { + t.Fatalf("a move over a value the vault changed since: %+v, %v", refused, err) } rec, _, _ := v.Store.Read("pair/anchor/database/shanks/gitea") if rec.Generation != 2 || rec.Origin != OriginMade { diff --git a/modules/mesh-vault/cmd/mesh-vault/vault.go b/modules/mesh-vault/cmd/mesh-vault/vault.go index 03056f49..6613c1c0 100644 --- a/modules/mesh-vault/cmd/mesh-vault/vault.go +++ b/modules/mesh-vault/cmd/mesh-vault/vault.go @@ -147,6 +147,22 @@ func (v *Vault) keepWith(name, value, by, via, origin string, opt keepOptions) ( return c, err } +// HeldAnother is the refusal of a value the vault never replaces: it holds another value for the name. Its fields are the +// held value's record, never the value, so a caller reads them rather than the words. +type HeldAnother struct { + Name string `json:"name"` + Generation int `json:"generation"` + ChangedBy string `json:"changed_by"` + ChangedAt time.Time `json:"changed_at"` + Origin string `json:"origin,omitempty"` +} + +func (h *HeldAnother) Error() string { + return fmt.Sprintf("the vault already holds another value for %s (generation %d, changed by %s at %s), and the move "+ + "never replaces a value the vault holds: nothing was taken", h.Name, h.Generation, h.ChangedBy, + h.ChangedAt.Format(time.RFC3339)) +} + // BusAccountValue says value is a bus account as secret names it: the controller's or the vault's JSON, with its user // (or one its name gives) and a password (accountOf). func BusAccountValue(secret, value string) bool { @@ -204,9 +220,8 @@ func (v *Vault) keepLocked(name, value, by, via, origin string, opt keepOptions) } } if opt.neverReplace { - return Changed{}, fmt.Errorf("the vault already holds another value for %s (generation %d, changed by %s "+ - "at %s), and the move never replaces a value the vault holds: nothing was taken", name, - before.Generation, before.ChangedBy, before.ChangedAt.Format(time.RFC3339)) + return Changed{}, &HeldAnother{Name: name, Generation: before.Generation, ChangedBy: before.ChangedBy, + ChangedAt: before.ChangedAt, Origin: before.Origin} } } atRest, err := seal.Seal(value, v.Own.PublicKey)