Answer a move the vault refuses for holding another value with fields, not words alone (issue 774)

move-in's refusal "the vault already holds another value for ..." was an error's text, which the node-engine and the
controller could only quote. It is now an answer: `refused` with the reason holds-another-value and the held value's
record (generation, changed by, changed at, origin; never the value), `why` in words, and no read-back, so an
engine that does not read `refused` still fails as before ("answered with no read-back"). keepLocked's refusal is a
typed error (HeldAnother) whose text is unchanged for every other path.

Quick fix: a stable prefix in the text. Thorough fix: the fields. Chose thorough, as asked; mesh-host's
moveIntoTheVault has to read `refused` to use them.
This commit is contained in:
2026-10-30 18:30:45 +01:00
parent b7f878226e
commit f3b13de20d
3 changed files with 47 additions and 6 deletions
+22
View File
@@ -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
}
@@ -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 {
+18 -3
View File
@@ -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)