B2h AS17 (2a): the assigner's ask of a held revert has its kind and key

The asks state gains the kind revert, keyed revert.<node>.<module>.<hex>
after the reverted build's fingerprint; like the backup ask it needs no
proof of who answered, so the view may answer it, and an asking that ends
unanswered while the hold stands is written again.

Builds: design 47 G §710, §300

Claude-Session: https://claude.ai/code/session_01NcbgfGKaxw984XyAPfLm4h
This commit is contained in:
2026-11-12 09:24:04 +01:00
parent c4ba9eb23e
commit 42c3db1d5b
4 changed files with 34 additions and 12 deletions
+20 -7
View File
@@ -41,10 +41,13 @@ const (
// AskKindOutside is the kind of the assigner's ask about a moved module whose failing health check has waited past its bound
// on an outside cause (design 47 G §706): go on, or count it as failed. `outside.<node>.<module>.<fingerprint hex>`.
AskKindOutside = "outside"
// AskKindRevert is the kind of the assigner's ask about a held revert, one with no restore point (design 47 G §710): keep it
// held, or start the reverted build on the live data. `revert.<node>.<module>.<fingerprint hex>`, the reverted build's.
AskKindRevert = "revert"
)
// AskKey is an ask's key read whole: its Kind, and the parts its kind has: a merge's Group and heads Hash, a hold's Module
// and fingerprint FP, a restore's and a backup's Node, Module and FP, a record's Node, Module and the digest Composed of the
// and fingerprint FP, a restore's, a backup's, an outside's and a revert's Node, Module and FP, a record's Node, Module and the digest Composed of the
// composition it would allow, the assignments' Proposal.
type AskKey struct {
Proposal string
@@ -63,11 +66,11 @@ func AskNeedsProof(kind string) (bool, error) {
switch kind {
case meshdeliveryclient.AskKindMerge:
return true, nil
case AskKindHold, AskKindRestore, AskKindBackup, AskKindRecord, AskKindAssignments, AskKindOutside:
case AskKindHold, AskKindRestore, AskKindBackup, AskKindRecord, AskKindAssignments, AskKindOutside, AskKindRevert:
return false, nil
}
return false, fmt.Errorf("asks: %q is not a kind of ask, %s, %s, %s, %s, %s, %s or %s", kind, meshdeliveryclient.AskKindMerge, AskKindHold,
AskKindRestore, AskKindBackup, AskKindRecord, AskKindAssignments, AskKindOutside)
return false, fmt.Errorf("asks: %q is not a kind of ask, %s, %s, %s, %s, %s, %s, %s or %s", kind, meshdeliveryclient.AskKindMerge, AskKindHold,
AskKindRestore, AskKindBackup, AskKindRecord, AskKindAssignments, AskKindOutside, AskKindRevert)
}
// HoldAskKey is the key of the ask about a module's held fingerprint: `hold.<module>.<hex>`.
@@ -119,6 +122,16 @@ func BackupAskKey(node, module, fingerprint string) (string, error) {
return AskKindBackup + "." + key, nil
}
// RevertAskKey is the key of the ask about a held revert of a module on a node to the reverted build's fingerprint:
// `revert.<node>.<module>.<hex>` (design 47 G §710).
func RevertAskKey(node, module, fingerprint string) (string, error) {
key, err := BackupAskKey(node, module, fingerprint)
if err != nil {
return "", err
}
return AskKindRevert + strings.TrimPrefix(key, AskKindBackup), nil
}
// AssignmentsAskKey is the key of the authorising ask about a proposal of assignments: `assignments.<proposal id>` (ADR 0567 §5).
func AssignmentsAskKey(proposal string) (string, error) {
if err := meshmoduleassignerclient.CheckProposalID(proposal); err != nil {
@@ -140,7 +153,7 @@ func RecordAskKey(node, module, composed string) (string, error) {
return AskKindRecord + "." + have + "." + strings.TrimPrefix(composed, "sha256:"), nil
}
// ParseAnyAskKey reads an ask key of any kind (a merge, hold, restore, backup, record or assignments ask); it refuses an unknown
// ParseAnyAskKey reads an ask key of any kind (a merge, hold, restore, backup, record, assignments, outside or revert ask); it refuses an unknown
// kind and a key whose parts do not fit its kind, naming the key.
func ParseAnyAskKey(key string) (AskKey, error) {
kind, rest, _ := strings.Cut(key, ".")
@@ -180,10 +193,10 @@ func ParseAnyAskKey(key string) (AskKey, error) {
}
k.Kind = kind
return k, nil
case AskKindBackup:
case AskKindBackup, AskKindRevert:
node, module, part, isNode, err := nodeengineclient.ParseComposedKey(rest)
if err != nil || isNode {
return refused(fmt.Errorf("a backup ask is backup.<node>.<module>.<fingerprint hex>"))
return refused(fmt.Errorf("a %s ask is %s.<node>.<module>.<fingerprint hex>", kind, kind))
}
fp, err := catalogkey.ParseKeyPart(part)
if err != nil {
@@ -19,6 +19,7 @@ func TestTheAssignersAskKeysKeepTheirFormsAndTheValueItsRules(t *testing.T) {
backup, _ := BackupAskKey("n1", "novox.be", fp)
assignments, _ := AssignmentsAskKey("p-20261028t183000-1a2b3c")
record, _ := RecordAskKey("n1", "gitea", digest)
revert, _ := RevertAskKey("n1", "nats", fp)
for key, want := range map[string]struct {
got string
back AskKey
@@ -28,6 +29,7 @@ func TestTheAssignersAskKeysKeepTheirFormsAndTheValueItsRules(t *testing.T) {
"backup": {backup, AskKey{Kind: "backup", Node: "n1", Module: "novox.be", FP: fp}},
"assignments": {assignments, AskKey{Kind: "assignments", Proposal: "p-20261028t183000-1a2b3c"}},
"record": {record, AskKey{Kind: "record", Node: "n1", Module: "gitea", Composed: digest}},
"revert": {revert, AskKey{Kind: "revert", Node: "n1", Module: "nats", FP: fp}},
} {
back, err := ParseAnyAskKey(want.got)
if err != nil || back != want.back {
@@ -35,7 +37,7 @@ func TestTheAssignersAskKeysKeepTheirFormsAndTheValueItsRules(t *testing.T) {
}
}
for got, want := range map[string]string{hold: "hold.gitea." + hex, restore: "restore.n1.gitea." + hex, backup: "backup.n1.novox=2ebe." + hex,
assignments: "assignments.p-20261028t183000-1a2b3c", record: "record.n1.gitea." + strings.Repeat("cd", 32)} {
assignments: "assignments.p-20261028t183000-1a2b3c", record: "record.n1.gitea." + strings.Repeat("cd", 32), revert: "revert.n1.nats." + hex} {
if got != want {
t.Errorf("key %q, want %q", got, want)
}
@@ -45,7 +47,7 @@ func TestTheAssignersAskKeysKeepTheirFormsAndTheValueItsRules(t *testing.T) {
t.Errorf("a merge ask key: %+v, %v", merge, err)
}
for _, bad := range []string{"", "other.x", "hold.gitea", "hold.gitea.src2:" + hex, "restore.n1", "backup.n1.node", "backup.n1.gitea." + hex[:60],
"assignment.AB", "record.n1.gitea." + hex[:10], "record.n1.node." + hex, "merge.g"} {
"assignment.AB", "record.n1.gitea." + hex[:10], "record.n1.node." + hex, "merge.g", "revert.n1.node", "revert.n1.nats." + hex[:60]} {
if _, err := ParseAnyAskKey(bad); err == nil {
t.Errorf("ParseAnyAskKey(%q) reads a wrong key", bad)
}
@@ -53,8 +55,10 @@ func TestTheAssignersAskKeysKeepTheirFormsAndTheValueItsRules(t *testing.T) {
if proof, err := AskNeedsProof("merge"); err != nil || !proof {
t.Error("a merge ask needs proof")
}
if proof, err := AskNeedsProof("hold"); err != nil || proof {
t.Error("a hold ask needs no proof")
for _, kind := range []string{"hold", "revert"} {
if proof, err := AskNeedsProof(kind); err != nil || proof {
t.Errorf("a %s ask needs no proof", kind)
}
}
if _, err := AskNeedsProof("view"); err == nil {
t.Error("an unknown kind of ask is taken")
@@ -42,6 +42,7 @@ func anAskEndedAtTheChannelIsWrittenAgain(t *testing.T) {
holdKey, _ := asks.HoldAskKey("nats", fNew)
restoreKey, _ := asks.RestoreAskKey("novox", "nats", fNew)
backupKey, _ := asks.BackupAskKey("novox", "nats", fNew)
revertKey, _ := asks.RevertAskKey("novox", "nats", fNew)
recordKey, _ := asks.RecordAskKey("novox", "nats", digest)
assignmentsKey, _ := asks.AssignmentsAskKey("p-20261028t183000-1a2b3c")
need := RecordNeed{Node: "novox", Digest: digest, Judgement: JudgedMovesData}
@@ -64,6 +65,9 @@ func anAskEndedAtTheChannelIsWrittenAgain(t *testing.T) {
{name: "hold", key: holdKey, a: ptr(HoldAsk(holdKey, "nats", fNew, "novox", "w", AskingSeconds(asked), asked)), outcome: occ.OutcomeExpired},
{name: "restore", key: restoreKey, a: ptr(RestoreAsk(restoreKey, "novox", "nats", fNew, "w", AskingSeconds(asked), asked)), outcome: occ.OutcomeCancelled},
{name: "backup", key: backupKey, a: ptr(BackupAsk(backupKey, "novox", "nats", fNew, digest, "w", 0, asked)), outcome: occ.OutcomeCancelled},
{name: "revert", key: revertKey, a: ptr(assignerAsk(AskingSeconds(asked), oc.Ask{ID: meshdeliveryclient.AskingID(revertKey, AskingSeconds(asked)),
Headline: "h", Explanation: "e", Who: occ.Operator, Expires: occ.FormatTime(asked.Add(AskLasts)), OnExpiry: "o",
Options: []oc.Option{{ID: "hold", Label: "Hold", Does: "d", Level: occ.Acknowledge}}})), outcome: occ.OutcomeExpired},
{name: "record", key: recordKey, a: &record, outcome: occ.OutcomeCancelled},
{name: "record of another put", key: recordKey, a: &record, outcome: occ.OutcomeExpired, put: &other},
{name: "record a put asked", key: recordKey, a: &byPut, outcome: occ.OutcomeExpired, put: &put, why: "put"},
@@ -18,7 +18,7 @@ import (
)
// AskLasts is how long an ask of the assigner's rules stands at the operator channel before it lapses unanswered: the hold,
// restore, record and backup asks.
// restore, record, backup, outside and revert asks.
const AskLasts = 7 * 24 * time.Hour
// writtenAgainOnEnd is, per kind of the assigner's asks, whether an asking that ended at the channel without an answer while
@@ -31,6 +31,7 @@ var writtenAgainOnEnd = map[string]string{
asks.AskKindBackup: "",
asks.AskKindOutside: "", // raised again at once while the wait stands (design 47 G §706); the wait's end takes the key away
asks.AskKindRecord: "",
asks.AskKindRevert: "", // raised again at once while the hold stands (design 47 G §710); one answered hold is not asked again
asks.AskKindAssignments: "a proposal's authorising ask that lapsed ends the proposal (ADR 0567 §5); the verb again asks it again (§11)",
}