Take the tunnel key at issue and place a joining node on the operator's yes, so the hub peers it before redeem
The operator's decision of 2026-11-02 01:05: issue requires overlay_key, the machine's tunnel public key; the vault's enrolment-token tunnel declares all five fields a token needs (hub_key, hub_endpoint, address, key, range). An add-node is proposed at the yes with the tunnel key alone; the identity keys come whole at redeem or not at all. A new remove-node, mesh-enrolment's alone on the same warrant and the proposal's one change, takes a joining node running nothing off placement when its enrolment ends unconfirmed (RemovedNodes names the key the holder deletes; LayChanges lays nothing for it and refuses a node that runs modules or holds seats). NodeLogin gains Acts, the hand acts that warranted its rotations. Tested as rows of TestPlacement, TestNodeRecord and the two seat tests; no new test function (1438 unit tests). (issue 774, review round 1b) Claude-Session: https://claude.ai/code/session_01NcbgfJ9Gum47zkFKWRaovL
This commit is contained in:
@@ -47,12 +47,16 @@ func enrolmentVerbs() []Verb {
|
||||
"refused at once, naming every finding, for a name that is not a node name or that a node record, a placement " +
|
||||
"key or an open enrolment holds, and for every call before the controller's lease is handed away. Otherwise " +
|
||||
"reserves the lowest free private-network address, asks the operator under the seat's own name and, on the " +
|
||||
"warrant, asks the vault for the token and says enrolment-issued. The token itself is read only through tokens.",
|
||||
"warrant, adds the node to placement with its tunnel key, asks the vault for the token and says enrolment-issued. " +
|
||||
"The token itself is read only through tokens.",
|
||||
Input: schema(map[string]string{
|
||||
"node": "the new machine's name in the mesh",
|
||||
"why": "why it joins, shown to the operator with the ask",
|
||||
"expires": "how long the token lasts, a duration such as 1h or 24h: one hour when not given, at most 24 hours",
|
||||
}, []string{"node", "why"})},
|
||||
// The operator's decision of 2026-11-02 01:05: the tunnel key is given at issue, so on the Approve the node
|
||||
// enters placement with it and the hub peers it before redeem.
|
||||
"overlay_key": "the new machine's private network (tunnel) public key, as `nox-mesh-host key` prints it",
|
||||
}, []string{"node", "why", "overlay_key"})},
|
||||
{Name: EnrolmentVerbRedeem, Description: "Spend a token: asked by a joining node-engine dialled as enrol.<node> " +
|
||||
"alone, with the public halves of its keys and its identity key's proof over them. Spends the token, asks the " +
|
||||
"vault for the node's account and certificate, writes the node into placement, and answers the account sealed " +
|
||||
|
||||
@@ -32,7 +32,9 @@ func placementVerbs() []Verb {
|
||||
"unpin (node, provision), hold (node, module, claim, scope, kind, site), unhold (node, module, claim), decide " +
|
||||
"(node, field, value: a decided field of the node, its address among them), config (node, module, mesh-wide, " +
|
||||
"layer, mesh-wide-values, values), add-node (node, decided, keys: a joining node, mesh-enrolment's alone, the " +
|
||||
"proposal's one change, with the enrolment's warrant; ADR 0587 §7)",
|
||||
"proposal's one change, with the enrolment's warrant, its tunnel key alone at the operator's yes; ADR 0587 §7), " +
|
||||
"remove-node (node: a joining node never confirmed, taken off when its enrolment ends; mesh-enrolment's alone, " +
|
||||
"on the same warrant)",
|
||||
"items": map[string]any{"type": "object", "required": []string{"op", "node"}}}
|
||||
return []Verb{
|
||||
{Name: PlacementVerbPlacement, Optional: true, Description: "Read placement: one node's key, the nodes one " +
|
||||
|
||||
@@ -256,9 +256,11 @@ var defaultSeats = append([]Seat{
|
||||
"sealed": "\"true\": answer the token sealed to the operator key",
|
||||
"drop": "\"true\": drop the node's token and its hash"}, []string{"node"}, "sealed", "drop"),
|
||||
"tunnel", "the node's first tunnel, built by mesh-enrolment at issue and carried in the token: the hub's "+
|
||||
"public key, the hub's endpoint and the node's tunnel address",
|
||||
"public key, the hub's endpoint, the node's tunnel address, its own tunnel key and the network's range",
|
||||
map[string]string{"hub_key": "the hub's tunnel public key", "hub_endpoint": "the hub's endpoint, host:port",
|
||||
"address": "the joining node's tunnel address"})},
|
||||
"address": "the joining node's tunnel address",
|
||||
"key": "the joining machine's own tunnel public key, as given at issue",
|
||||
"range": "the private network's range, a prefix"})},
|
||||
{Name: "node-account", Optional: true, Description: "Mint a joining node's bus account node.<node>, sealed " +
|
||||
"to the sealing key it presented, and write its hash; asked again for the same node and keys, answers the " +
|
||||
"account it already minted; with retire, the account it minted for a revoked enrolment retired. Writes the " +
|
||||
|
||||
@@ -104,7 +104,7 @@ func TestTheVaultsSeatPromisesItsIntakeVerbs(t *testing.T) {
|
||||
// and the node's address.
|
||||
if name == "enrolment-token" {
|
||||
tunnel, _ := props["tunnel"].(map[string]any)
|
||||
if tunnel["type"] != "object" || !slices.Equal(tunnel["required"].([]string), []string{"address", "hub_endpoint", "hub_key"}) {
|
||||
if tunnel["type"] != "object" || !slices.Equal(tunnel["required"].([]string), []string{"address", "hub_endpoint", "hub_key", "key", "range"}) {
|
||||
t.Errorf("enrolment-token's tunnel is %v", tunnel)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
package catalogue
|
||||
|
||||
import (
|
||||
"slices"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
@@ -31,6 +32,10 @@ func TestHoldingAMeshSeatRequiresServingItsVerbs(t *testing.T) {
|
||||
t.Fatalf("mesh-enrolment: in the set %v, %+v", ok, enrolment)
|
||||
}
|
||||
six := []string{"issue", "redeem", "confirm", "revoke", "tokens", "again"}
|
||||
// The tunnel key is given at issue (the operator, 2026-11-02 01:05).
|
||||
if issue := enrolment.Serves[0]; issue.Name != "issue" || !slices.Contains(issue.Input["required"].([]string), "overlay_key") {
|
||||
t.Errorf("issue requires %v", issue.Input["required"])
|
||||
}
|
||||
holder := func(serves ...string) Manifest {
|
||||
return Manifest{Module: "mesh-enrolment", Tools: serves,
|
||||
Claims: []Claim{{Name: EnrolmentSeat, Scope: ScopeMesh, Serves: serves}}}
|
||||
|
||||
@@ -58,6 +58,8 @@ type NodeLogin struct {
|
||||
Stage string `json:"stage,omitempty"`
|
||||
Since time.Time `json:"since"`
|
||||
ListRevision uint64 `json:"list-revision,omitempty"`
|
||||
// Acts are the hand-act ids that warranted the node's rotations, oldest first (ADR 0583 §2): the vault records each.
|
||||
Acts []string `json:"acts,omitempty"`
|
||||
}
|
||||
|
||||
// FirstLogin is a node's first login, `node.<n>`, the one every node had before rotation; SecondLogin its other.
|
||||
@@ -89,6 +91,11 @@ func (l NodeLogin) check() error {
|
||||
if l.Login != FirstLogin(l.Node) && l.Login != SecondLogin(l.Node) {
|
||||
return fmt.Errorf("delivery: the node %s logs in as %q, neither of its two logins", l.Node, l.Login)
|
||||
}
|
||||
for _, act := range l.Acts {
|
||||
if strings.TrimSpace(act) == "" {
|
||||
return fmt.Errorf("delivery: the node %s names an empty hand act among its rotations' warrants", l.Node)
|
||||
}
|
||||
}
|
||||
if !l.Rotates() {
|
||||
if l.Stage != "" || l.ListRevision != 0 {
|
||||
return fmt.Errorf("delivery: the node %s rotates to nothing and names stage %q", l.Node, l.Stage)
|
||||
|
||||
@@ -139,6 +139,11 @@ func TestNodeRecord(t *testing.T) {
|
||||
t.Errorf("node login round trip: %+v, %v", back, err)
|
||||
}
|
||||
}
|
||||
// The hand acts that warranted its rotations, as the vault records them.
|
||||
if raw, err := EncodeNodeLogin(NodeLogin{Node: "g14", Login: "node.g14--b", Acts: []string{"act-1"}}); err != nil ||
|
||||
!strings.Contains(string(raw), `"acts":["act-1"]`) {
|
||||
t.Errorf("a login with its acts: %s, %v", raw, err)
|
||||
}
|
||||
if l, err := DecodeNodeLogin([]byte(`{"v":1,"node":"g14","login":"node.g14","rotating":"","since":"2026-11-02T01:00:00Z"}`)); err != nil || l.Rotates() {
|
||||
t.Errorf("an empty rotating: %+v, %v", l, err)
|
||||
}
|
||||
@@ -148,6 +153,7 @@ func TestNodeRecord(t *testing.T) {
|
||||
"a rotation to itself": {Node: "g14", Login: "node.g14", Rotating: "node.g14", Stage: RotationBegun},
|
||||
"a rotation, no stage": {Node: "g14", Login: "node.g14", Rotating: "node.g14--b"},
|
||||
"a rotation, done stage": {Node: "g14", Login: "node.g14", Rotating: "node.g14--b", Stage: "done"},
|
||||
"an empty act": {Node: "g14", Login: "node.g14", Acts: []string{""}},
|
||||
} {
|
||||
if _, err := EncodeNodeLogin(l); err == nil {
|
||||
t.Errorf("node login with %s: taken", name)
|
||||
|
||||
@@ -68,6 +68,10 @@ const (
|
||||
// OpAddNode adds a joining node with its decided fields and the public halves of its keys, on the warrant of the
|
||||
// enrolment that issued its token (novox/hq ADR 0587 §7): mesh-enrolment's alone, the proposal's one change.
|
||||
OpAddNode = "add-node"
|
||||
// OpRemoveNode takes a joining node off placement again when its enrolment ends unconfirmed, revoked or expired
|
||||
// (the operator's decision of 2026-11-02 01:05): mesh-enrolment's alone, on the same warrant, the proposal's one
|
||||
// change, of a node with no module and no holding. The holder deletes its key (RemovedNodes).
|
||||
OpRemoveNode = "remove-node"
|
||||
)
|
||||
|
||||
// The states of an open proposal.
|
||||
@@ -111,13 +115,15 @@ type PlacementProposalChange struct {
|
||||
// Whole is the node's whole placement (OpSeed).
|
||||
Whole *Placement `json:"whole,omitempty"`
|
||||
// Decided and Keys are a joining node's (OpAddNode): its decided fields, the address the operator approved among
|
||||
// them, and the public halves of the keys it presented at redeem.
|
||||
// them, and the public halves of its keys: at the operator's yes its tunnel key alone, given at issue (the operator's
|
||||
// decision of 2026-11-02 01:05); the identity keys are bound at redeem, checked against the node account's digest.
|
||||
Decided *NodeDecided `json:"decided,omitempty"`
|
||||
Keys *JoiningKeys `json:"keys,omitempty"`
|
||||
}
|
||||
|
||||
// JoiningKeys are the public halves of the keys a joining node presented at redeem (ADR 0587 §6, §7): its identity,
|
||||
// private network, sealing and serving keys, as Redeem carries them. Public halves alone, never a private key.
|
||||
// JoiningKeys are the public halves of a joining node's keys (ADR 0587 §6, §7): its private network (tunnel) key, given
|
||||
// at issue, and its identity, sealing and serving keys, presented at redeem, as Redeem carries them. An add-node at the
|
||||
// operator's yes carries the tunnel key alone. Public halves alone, never a private key.
|
||||
type JoiningKeys struct {
|
||||
Identity string `json:"identity"`
|
||||
Overlay string `json:"overlay,omitempty"`
|
||||
@@ -165,8 +171,12 @@ func (c PlacementProposalChange) check() error {
|
||||
}
|
||||
return c.Whole.check()
|
||||
case OpAddNode:
|
||||
if c.Decided == nil || c.Decided.Address == "" || c.Keys == nil || c.Keys.Identity == "" || c.Keys.Sealing == "" {
|
||||
return fmt.Errorf("delivery: the add-node of %s names no address or no identity and sealing key", c.Node)
|
||||
if c.Decided == nil || c.Decided.Address == "" || c.Keys == nil || c.Keys.Overlay == "" {
|
||||
return fmt.Errorf("delivery: the add-node of %s names no address or no tunnel key", c.Node)
|
||||
}
|
||||
// The identity keys come at redeem, whole or not at all: an identity with no sealing key, or the reverse.
|
||||
if (c.Keys.Identity == "") != (c.Keys.Sealing == "") || (c.Keys.Identity == "" && c.Keys.Serving != "") {
|
||||
return fmt.Errorf("delivery: the add-node of %s names part of its identity keys; they come whole at redeem", c.Node)
|
||||
}
|
||||
if c.Decided.Hub {
|
||||
return fmt.Errorf("delivery: the add-node of %s makes it the hub, which a joining node never is", c.Node)
|
||||
@@ -175,8 +185,13 @@ func (c PlacementProposalChange) check() error {
|
||||
return fmt.Errorf("delivery: the add-node of %s places a module; a module is placed by a proposal of its own", c.Node)
|
||||
}
|
||||
return nil
|
||||
case OpRemoveNode:
|
||||
if c.Module != "" || c.Decided != nil || c.Keys != nil || c.Whole != nil {
|
||||
return fmt.Errorf("delivery: the remove-node of %s names more than its node", c.Node)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
return fmt.Errorf("delivery: %q is no change of placement: place, take-off, pin, unpin, hold, unhold, decide, config or add-node", c.Op)
|
||||
return fmt.Errorf("delivery: %q is no change of placement: place, take-off, pin, unpin, hold, unhold, decide, config, add-node or remove-node", c.Op)
|
||||
}
|
||||
|
||||
// LayChanges is the placements of the nodes the changes name with the changes laid over current, in the order given;
|
||||
@@ -222,6 +237,16 @@ func LayChanges(current map[string]Placement, changes []PlacementProposalChange)
|
||||
continue
|
||||
}
|
||||
p = Placement{V: Version, Node: c.Node, Decided: *c.Decided, Modules: []PlacedModule{}}
|
||||
case OpRemoveNode:
|
||||
// Only a joining node never confirmed goes this way: one with no module and no holding (a node that runs
|
||||
// anything is taken off module by module, by a proposal the operator approves).
|
||||
cur, has := current[c.Node]
|
||||
if !has {
|
||||
findings = append(findings, fmt.Sprintf("change %d: %s is not placed, so there is nothing to remove", i+1, c.Node))
|
||||
} else if len(cur.Modules)+len(cur.Holds) > 0 {
|
||||
findings = append(findings, fmt.Sprintf("change %d: %s runs modules or holds seats, so it is no joining node to remove", i+1, c.Node))
|
||||
}
|
||||
continue
|
||||
case OpPlace:
|
||||
m := PlacedModule{Module: c.Module, Ports: c.Ports, Bindings: c.Bindings}
|
||||
if at >= 0 {
|
||||
@@ -472,12 +497,13 @@ func (p PlacementProposal) check() error {
|
||||
return p.checkAddNode()
|
||||
}
|
||||
|
||||
// checkAddNode refuses an add-node that is not the proposal's one change, carries no warrant, or whose warrant names
|
||||
// another node or address than the change; and a warrant on any other proposal (ADR 0587 §7).
|
||||
// checkAddNode refuses an add-node or a remove-node that is not the proposal's one change, carries no warrant, or whose
|
||||
// warrant names another node (or, for an add-node, another address) than the change; and a warrant on any other
|
||||
// proposal (ADR 0587 §7).
|
||||
func (p PlacementProposal) checkAddNode() error {
|
||||
adds := 0
|
||||
for _, c := range p.Changes {
|
||||
if c.Op == OpAddNode {
|
||||
if c.Op == OpAddNode || c.Op == OpRemoveNode {
|
||||
adds++
|
||||
}
|
||||
}
|
||||
@@ -494,6 +520,12 @@ func (p PlacementProposal) checkAddNode() error {
|
||||
if w == nil || w.Ask == "" || w.Warrant == "" {
|
||||
return fmt.Errorf("delivery: the add-node of %s carries no warrant of its enrolment", c.Node)
|
||||
}
|
||||
if c.Op == OpRemoveNode {
|
||||
if w.Node != c.Node {
|
||||
return fmt.Errorf("delivery: the remove-node of %s carries the warrant of %s", c.Node, w.Node)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
if w.Node != c.Node || w.Address != c.Decided.Address {
|
||||
return fmt.Errorf("delivery: the add-node of %s at %s carries the warrant of %s at %s", c.Node, c.Decided.Address,
|
||||
w.Node, w.Address)
|
||||
@@ -501,7 +533,18 @@ func (p PlacementProposal) checkAddNode() error {
|
||||
return nil
|
||||
}
|
||||
|
||||
// AddNodeRefusal is why the holder of mesh-placement refuses an add-node from caller, the module the runtime proved
|
||||
// RemovedNodes are the nodes whose key a proposal deletes (its remove-node), which LayChanges leaves out of what it lays.
|
||||
func (p PlacementProposal) RemovedNodes() []string {
|
||||
var out []string
|
||||
for _, c := range p.Changes {
|
||||
if c.Op == OpRemoveNode {
|
||||
out = append(out, c.Node)
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// AddNodeRefusal is why the holder of mesh-placement refuses an add-node or a remove-node from caller, the module the runtime proved
|
||||
// called (ADR 0587 §7), or "": only mesh-enrolment adds a node; what the proposal itself must hold, DecodePlacementProposal
|
||||
// refuses.
|
||||
func AddNodeRefusal(p PlacementProposal, caller string) string {
|
||||
@@ -509,7 +552,7 @@ func AddNodeRefusal(p PlacementProposal, caller string) string {
|
||||
return err.Error()
|
||||
}
|
||||
if p.Warrant != nil && caller != EnrolmentModule {
|
||||
return fmt.Sprintf("an add-node is mesh-enrolment's alone, and %s proposed it", caller)
|
||||
return fmt.Sprintf("an add-node or a remove-node is mesh-enrolment's alone, and %s proposed it", caller)
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
@@ -691,14 +691,15 @@ func TestPlacement(t *testing.T) {
|
||||
}
|
||||
// 774/S42/AC1 (ADR 0587 §7): an add-node carries its enrolment's warrant naming its node and address, as its one
|
||||
// change, from mesh-enrolment alone; a warrant naming another node or address is refused.
|
||||
// At the operator's yes it carries the tunnel key alone (the operator, 2026-11-02 01:05).
|
||||
joining := PlacementProposalChange{Op: OpAddNode, Node: "lab", Decided: &NodeDecided{Address: "10.0.0.7"},
|
||||
Keys: &JoiningKeys{Identity: "id", Sealing: "seal"}}
|
||||
Keys: &JoiningKeys{Overlay: "wg"}}
|
||||
add := PlacementProposal{ID: id, By: "ace.mesh-enrolment", Why: "lab joins", At: time.Unix(1, 0).UTC(),
|
||||
Changes: []PlacementProposalChange{joining}, Base: map[string]uint64{"lab": 0}, State: ProposalAnswering,
|
||||
Warrant: &AddNodeWarrant{Node: "lab", Address: "10.0.0.7", Ask: "a-1", Warrant: "w-1"}}
|
||||
if raw, err := EncodePlacementProposal(add); err != nil {
|
||||
t.Errorf("an add-node: %v", err)
|
||||
} else if back, err := DecodePlacementProposal(raw); err != nil || back.Warrant == nil || back.Changes[0].Keys.Digest() != KeysDigest("id", "", "seal", "") {
|
||||
} else if back, err := DecodePlacementProposal(raw); err != nil || back.Warrant == nil || back.Changes[0].Keys.Overlay != "wg" {
|
||||
t.Errorf("an add-node round trip: %+v, %v", back, err)
|
||||
}
|
||||
if AddNodeRefusal(add, EnrolmentModule) != "" || !strings.Contains(AddNodeRefusal(add, AssignerModule), "mesh-enrolment's alone") {
|
||||
@@ -708,7 +709,7 @@ func TestPlacement(t *testing.T) {
|
||||
t.Errorf("an add-node laid: %+v, %v", laid, f)
|
||||
}
|
||||
if _, f := LayChanges(current, []PlacementProposalChange{{Op: OpAddNode, Node: "g14", Decided: &NodeDecided{Address: "10.0.0.9"},
|
||||
Keys: &JoiningKeys{Identity: "id", Sealing: "seal"}}}); len(f) != 1 || !strings.Contains(f[0], "placed already") {
|
||||
Keys: &JoiningKeys{Overlay: "wg"}}}); len(f) != 1 || !strings.Contains(f[0], "placed already") {
|
||||
t.Errorf("an add-node of a placed node: %v", f)
|
||||
}
|
||||
for name, mut := range map[string]func(*PlacementProposal){
|
||||
@@ -719,6 +720,16 @@ func TestPlacement(t *testing.T) {
|
||||
p.Warrant = &AddNodeWarrant{Node: "lab", Address: "10.0.0.8", Ask: "a", Warrant: "w"}
|
||||
},
|
||||
"no warrant": func(p *PlacementProposal) { p.Warrant = nil },
|
||||
"no tunnel key": func(p *PlacementProposal) {
|
||||
c := joining
|
||||
c.Keys = &JoiningKeys{Identity: "id", Sealing: "seal"}
|
||||
p.Changes = []PlacementProposalChange{c}
|
||||
},
|
||||
"half its identity keys": func(p *PlacementProposal) {
|
||||
c := joining
|
||||
c.Keys = &JoiningKeys{Overlay: "wg", Identity: "id"}
|
||||
p.Changes = []PlacementProposalChange{c}
|
||||
},
|
||||
"beside another change": func(p *PlacementProposal) {
|
||||
p.Changes = append(p.Changes, PlacementProposalChange{Op: OpPlace, Node: "lab", Module: "zsh"})
|
||||
},
|
||||
@@ -738,6 +749,32 @@ func TestPlacement(t *testing.T) {
|
||||
t.Errorf("an add-node with %s: taken", name)
|
||||
}
|
||||
}
|
||||
// With its identity keys whole, as bound at redeem, it is taken too.
|
||||
whole := add
|
||||
whole.Changes = []PlacementProposalChange{joining}
|
||||
whole.Changes[0].Keys = &JoiningKeys{Overlay: "wg", Identity: "id", Sealing: "seal"}
|
||||
if _, err := EncodePlacementProposal(whole); err != nil {
|
||||
t.Errorf("an add-node with its identity keys: %v", err)
|
||||
}
|
||||
// An enrolment's end takes its never-confirmed node off again: mesh-enrolment's alone, of a node running nothing.
|
||||
remove := add
|
||||
remove.Changes = []PlacementProposalChange{{Op: OpRemoveNode, Node: "lab"}}
|
||||
if _, err := EncodePlacementProposal(remove); err != nil || AddNodeRefusal(remove, AssignerModule) == "" ||
|
||||
AddNodeRefusal(remove, EnrolmentModule) != "" || !slices.Equal(remove.RemovedNodes(), []string{"lab"}) {
|
||||
t.Errorf("a remove-node: %v, %q", err, AddNodeRefusal(remove, AssignerModule))
|
||||
}
|
||||
joined := map[string]Placement{"lab": {V: Version, Node: "lab", Decided: NodeDecided{Address: "10.0.0.7"}, Modules: []PlacedModule{}}}
|
||||
if laid, f := LayChanges(joined, remove.Changes); len(f) != 0 || len(laid) != 0 {
|
||||
t.Errorf("a joining node removed: %v, %v", laid, f)
|
||||
}
|
||||
if _, f := LayChanges(current, []PlacementProposalChange{{Op: OpRemoveNode, Node: "g14"}}); len(f) != 1 ||
|
||||
!strings.Contains(f[0], "runs modules") {
|
||||
t.Errorf("a running node removed: %v", f)
|
||||
}
|
||||
remove.Warrant = &AddNodeWarrant{Node: "pi", Address: "10.0.0.7", Ask: "a", Warrant: "w"}
|
||||
if _, err := EncodePlacementProposal(remove); err == nil {
|
||||
t.Error("a remove-node on another node's warrant: taken")
|
||||
}
|
||||
for name, mut := range map[string]func(*PlacementProposal){
|
||||
"no id": func(p *PlacementProposal) { p.ID = "" },
|
||||
"no proposer": func(p *PlacementProposal) { p.By = "" },
|
||||
|
||||
Reference in New Issue
Block a user