grove
at @11
1//! grove's state: what the log's records add up to.
2//!
3//! Every record is a JSON object `{"t": type, "at": ms, "by": actor, …}`. `apply` folds one
4//! record into the state. Replaying the log from the start rebuilds the state exactly, so
5//! `apply` must be deterministic and must accept only records the store would have written:
6//! a record that doesn't fit means the log is damaged, and the store refuses to open.
7//!
8//! `apply` is all or nothing: it checks a record completely before changing anything, so the
9//! store can use it to validate a write before appending it.
10
11use crate::hex;
12use crate::json::{obj, Value};
13use crate::objects::Id;
14
15#[derive(Clone, Debug, PartialEq, Eq)]
16pub struct Actor {
17 pub person: String,
18 /// The agent acting for the person, if it wasn't the person themselves.
19 pub agent: Option<String>,
20}
21
22impl Actor {
23 pub fn person(name: &str) -> Actor {
24 Actor { person: name.into(), agent: None }
25 }
26 pub fn agent(person: &str, agent: &str) -> Actor {
27 Actor { person: person.into(), agent: Some(agent.into()) }
28 }
29 pub fn to_json(&self) -> Value {
30 obj([("person", self.person.clone().into()), ("agent", self.agent.clone().into())])
31 }
32 fn from_json(v: &Value) -> Result<Actor, String> {
33 Ok(Actor {
34 person: str_field(v, "person")?,
35 agent: match v.get("agent") {
36 None | Some(Value::Null) => None,
37 Some(a) => Some(a.as_str().ok_or("agent must be a string")?.to_string()),
38 },
39 })
40 }
41}
42
43impl std::fmt::Display for Actor {
44 fn fmt(&self, f: &mut std::fmt::Formatter) -> std::fmt::Result {
45 match &self.agent {
46 Some(a) => write!(f, "{a} for {}", self.person),
47 None => write!(f, "{}", self.person),
48 }
49 }
50}
51
52#[derive(Clone, Copy, Debug, PartialEq, Eq)]
53pub enum ChangeState {
54 Idea,
55 Draft,
56 Proposed,
57 Landed,
58 Declined,
59 Withdrawn,
60}
61
62impl ChangeState {
63 pub fn name(self) -> &'static str {
64 match self {
65 ChangeState::Idea => "idea",
66 ChangeState::Draft => "draft",
67 ChangeState::Proposed => "proposed",
68 ChangeState::Landed => "landed",
69 ChangeState::Declined => "declined",
70 ChangeState::Withdrawn => "withdrawn",
71 }
72 }
73 pub fn parse(s: &str) -> Option<ChangeState> {
74 Some(match s {
75 "idea" => ChangeState::Idea,
76 "draft" => ChangeState::Draft,
77 "proposed" => ChangeState::Proposed,
78 "landed" => ChangeState::Landed,
79 "declined" => ChangeState::Declined,
80 "withdrawn" => ChangeState::Withdrawn,
81 _ => return None,
82 })
83 }
84 pub fn is_open(self) -> bool {
85 matches!(self, ChangeState::Idea | ChangeState::Draft | ChangeState::Proposed)
86 }
87}
88
89#[derive(Clone, Debug)]
90pub struct Person {
91 pub username: String,
92 pub display_name: String,
93 pub at: i64,
94}
95
96/// An owner made of people: any member can act for it, and make its decisions in person.
97#[derive(Clone, Debug)]
98pub struct Group {
99 pub name: String,
100 pub members: Vec<String>,
101 pub at: i64,
102}
103
104/// An invitation, stored as the BLAKE3 hash of its code. It lets someone join the server, and
105/// if it names a project, makes them a contributor to it. Each code works once.
106#[derive(Clone, Debug)]
107pub struct Invite {
108 pub hash: String,
109 pub by: String,
110 pub project: Option<(String, String)>,
111 pub used_by: Option<String>,
112 pub at: i64,
113}
114
115/// An access token, stored as the BLAKE3 hash of its secret. A token acts as its person, or
116/// as one of their agents.
117#[derive(Clone, Debug)]
118pub struct Token {
119 pub hash: String,
120 pub person: String,
121 pub agent: Option<String>,
122 pub revoked: bool,
123 pub at: i64,
124}
125
126#[derive(Clone, Debug)]
127pub struct Agent {
128 pub id: String,
129 pub person: String,
130 pub name: String,
131 pub revoked: bool,
132 pub at: i64,
133}
134
135#[derive(Clone, Debug)]
136pub struct Point {
137 pub n: u64,
138 pub snapshot: Id,
139 pub message: String,
140 pub author: Actor,
141 pub landed_by: Actor,
142 pub change: Option<u64>,
143 pub step: Option<String>,
144 pub at: i64,
145}
146
147#[derive(Clone, Debug, PartialEq)]
148pub struct Step {
149 /// Stable within a change, across revisions.
150 pub id: String,
151 pub snapshot: Id,
152 pub message: String,
153}
154
155#[derive(Clone, Debug)]
156pub struct Revision {
157 pub number: u32,
158 pub base: u64,
159 pub steps: Vec<Step>,
160 pub note: Option<String>,
161 pub by: Actor,
162 pub at: i64,
163}
164
165#[derive(Clone, Debug)]
166pub struct Message {
167 pub id: u32,
168 pub body: String,
169 pub by: Actor,
170 pub at: i64,
171}
172
173#[derive(Clone, Debug)]
174pub struct Evidence {
175 pub revision: u32,
176 pub title: String,
177 pub media_type: String,
178 pub blob: Option<Id>,
179 pub url: Option<String>,
180 pub by: Actor,
181 pub at: i64,
182}
183
184/// Something that happened to a change, for its timeline.
185#[derive(Clone, Debug)]
186pub enum Event {
187 Opened,
188 Revised(u32),
189 State(ChangeState),
190 Said(u32),
191 Attached(usize),
192 Landed { revision: u32, points: (u64, u64) },
193}
194
195#[derive(Clone, Debug)]
196pub struct Change {
197 pub number: u64,
198 pub intent: String,
199 pub proposer: Actor,
200 pub state: ChangeState,
201 pub revisions: Vec<Revision>,
202 pub messages: Vec<Message>,
203 pub evidence: Vec<Evidence>,
204 pub timeline: Vec<(i64, Actor, Event)>,
205 pub at: i64,
206}
207
208impl Change {
209 pub fn latest(&self) -> Option<&Revision> {
210 self.revisions.last()
211 }
212}
213
214#[derive(Clone, Debug)]
215pub struct Project {
216 pub owner: String,
217 pub name: String,
218 pub public: bool,
219 pub points: Vec<Point>,
220 pub changes: Vec<Change>,
221 /// People invited to take part: they can open changes, revise, discuss and attach evidence.
222 pub contributors: Vec<String>,
223 pub at: i64,
224}
225
226impl Project {
227 pub fn tip(&self) -> u64 {
228 self.points.len() as u64
229 }
230 pub fn point(&self, n: u64) -> Option<&Point> {
231 if n == 0 { None } else { self.points.get(n as usize - 1) }
232 }
233 pub fn change(&self, number: u64) -> Option<&Change> {
234 if number == 0 { None } else { self.changes.get(number as usize - 1) }
235 }
236 fn change_mut(&mut self, number: u64) -> Option<&mut Change> {
237 if number == 0 { None } else { self.changes.get_mut(number as usize - 1) }
238 }
239}
240
241#[derive(Default)]
242pub struct State {
243 pub people: Vec<Person>,
244 pub agents: Vec<Agent>,
245 pub groups: Vec<Group>,
246 pub tokens: Vec<Token>,
247 pub invites: Vec<Invite>,
248 pub projects: Vec<Project>,
249 pub records: u64,
250}
251
252// ── Reading fields ─────────────────────────────────────────
253
254fn str_field(v: &Value, key: &str) -> Result<String, String> {
255 v.get(key).and_then(Value::as_str).map(str::to_string).ok_or_else(|| format!("missing {key}"))
256}
257fn opt_str(v: &Value, key: &str) -> Result<Option<String>, String> {
258 match v.get(key) {
259 None | Some(Value::Null) => Ok(None),
260 Some(Value::Str(s)) => Ok(Some(s.clone())),
261 _ => Err(format!("{key} must be a string")),
262 }
263}
264fn int_field(v: &Value, key: &str) -> Result<i64, String> {
265 v.get(key).and_then(Value::as_i64).ok_or_else(|| format!("missing {key}"))
266}
267fn uint(v: &Value, key: &str) -> Result<u64, String> {
268 let n = int_field(v, key)?;
269 u64::try_from(n).map_err(|_| format!("{key} must not be negative"))
270}
271pub fn id_to_json(id: &Id) -> Value {
272 Value::Str(hex::encode(id))
273}
274fn id_value(v: &Value) -> Result<Id, String> {
275 let s = v.as_str().ok_or("an id must be a string")?;
276 let bytes = hex::decode(s).ok_or("an id must be hex")?;
277 bytes.try_into().map_err(|_| "an id must be 32 bytes".to_string())
278}
279fn id_field(v: &Value, key: &str) -> Result<Id, String> {
280 id_value(v.get(key).ok_or_else(|| format!("missing {key}"))?).map_err(|e| format!("{key}: {e}"))
281}
282fn opt_id(v: &Value, key: &str) -> Result<Option<Id>, String> {
283 match v.get(key) {
284 None | Some(Value::Null) => Ok(None),
285 _ => id_field(v, key).map(Some),
286 }
287}
288
289pub fn step_to_json(s: &Step) -> Value {
290 obj([("id", s.id.clone().into()), ("snapshot", id_to_json(&s.snapshot)), ("message", s.message.clone().into())])
291}
292fn step_from_json(v: &Value) -> Result<Step, String> {
293 Ok(Step { id: str_field(v, "id")?, snapshot: id_field(v, "snapshot")?, message: str_field(v, "message")? })
294}
295
296// ── Applying records ───────────────────────────────────────
297
298impl State {
299 pub fn person(&self, username: &str) -> Option<&Person> {
300 self.people.iter().find(|p| p.username == username)
301 }
302 pub fn group(&self, name: &str) -> Option<&Group> {
303 self.groups.iter().find(|g| g.name == name)
304 }
305 /// People and groups share one namespace of owner names.
306 pub fn name_taken(&self, name: &str) -> bool {
307 self.person(name).is_some() || self.group(name).is_some()
308 }
309 /// May `person` act as `owner` (themselves, or a group they belong to)?
310 pub fn owns(&self, owner: &str, person: &str) -> bool {
311 owner == person || self.group(owner).is_some_and(|g| g.members.iter().any(|m| m == person))
312 }
313 /// Owners (and their group's members) and invited contributors take part in a project.
314 pub fn can_contribute(&self, owner: &str, name: &str, person: &str) -> bool {
315 self.owns(owner, person) || self.project(owner, name).is_some_and(|p| p.contributors.iter().any(|c| c == person))
316 }
317 /// An unused invite, by its hash.
318 pub fn open_invite(&self, hash: &str) -> Option<&Invite> {
319 self.invites.iter().find(|i| i.hash == hash && i.used_by.is_none())
320 }
321 /// Who a token acts as, if it's valid.
322 pub fn token_actor(&self, hash: &str) -> Option<Actor> {
323 let t = self.tokens.iter().find(|t| t.hash == hash && !t.revoked)?;
324 if let Some(a) = &t.agent {
325 if self.agent(a).is_none_or(|a| a.revoked) {
326 return None;
327 }
328 }
329 Some(Actor { person: t.person.clone(), agent: t.agent.clone() })
330 }
331 pub fn agent(&self, id: &str) -> Option<&Agent> {
332 self.agents.iter().find(|a| a.id == id)
333 }
334 pub fn project(&self, owner: &str, name: &str) -> Option<&Project> {
335 self.projects.iter().find(|p| p.owner == owner && p.name == name)
336 }
337 fn project_mut(&mut self, owner: &str, name: &str) -> Result<&mut Project, String> {
338 self.projects.iter_mut().find(|p| p.owner == owner && p.name == name).ok_or_else(|| format!("no project {owner}/{name}"))
339 }
340
341 /// Fold one record into the state.
342 pub fn apply(&mut self, rec: &Value) -> Result<(), String> {
343 let t = str_field(rec, "t")?;
344 let at = int_field(rec, "at")?;
345 let by = Actor::from_json(rec.get("by").ok_or("missing by")?)?;
346 if t != "person" && self.person(&by.person).is_none() {
347 return Err(format!("unknown person {}", by.person));
348 }
349 if let Some(agent) = &by.agent {
350 match self.agent(agent) {
351 Some(a) if a.person == by.person && !a.revoked => {}
352 _ => return Err(format!("agent {agent} can't act for {}", by.person)),
353 }
354 }
355 if matches!(t.as_str(), "change" | "revision" | "state" | "say" | "evidence") {
356 let (owner, name) = (str_field(rec, "owner")?, str_field(rec, "project")?);
357 if !self.can_contribute(&owner, &name, &by.person) {
358 return Err(format!("{} isn't a contributor to {owner}/{name}; the owner can send an invite", by.person));
359 }
360 }
361 match t.as_str() {
362 "person" => {
363 let username = str_field(rec, "username")?;
364 if username != by.person {
365 return Err("a person record is made by that person".into());
366 }
367 if self.name_taken(&username) {
368 return Err(format!("the name {username} is taken"));
369 }
370 self.people.push(Person { display_name: str_field(rec, "display_name")?, username, at });
371 }
372 "agent" => {
373 let id = str_field(rec, "id")?;
374 if self.agent(&id).is_some() {
375 return Err(format!("agent {id} exists"));
376 }
377 self.agents.push(Agent { id, person: by.person.clone(), name: str_field(rec, "name")?, revoked: false, at });
378 }
379 "agent.revoke" => {
380 let id = str_field(rec, "id")?;
381 let a = self.agents.iter_mut().find(|a| a.id == id && a.person == by.person).ok_or("no such agent")?;
382 a.revoked = true;
383 }
384 "invite" => {
385 if by.agent.is_some() {
386 return Err("invites are sent in person".into());
387 }
388 let hash = str_field(rec, "hash")?;
389 let project = match (opt_str(rec, "owner")?, opt_str(rec, "project")?) {
390 (Some(o), Some(n)) => {
391 if !self.owns(&o, &by.person) || self.project(&o, &n).is_none() {
392 return Err(format!("{} can't invite people to {o}/{n}", by.person));
393 }
394 Some((o, n))
395 }
396 (None, None) => None,
397 _ => return Err("an invite names both owner and project, or neither".into()),
398 };
399 if self.invites.iter().any(|i| i.hash == hash) {
400 return Err("invite exists".into());
401 }
402 self.invites.push(Invite { hash, by: by.person.clone(), project, used_by: None, at });
403 }
404 "invite.use" => {
405 let hash = str_field(rec, "hash")?;
406 let person = by.person.clone();
407 let invite = self.open_invite(&hash).ok_or("that invite doesn't exist or was already used")?.clone();
408 if let Some((o, n)) = &invite.project {
409 let p = self.project_mut(o, n)?;
410 if !p.contributors.contains(&person) {
411 p.contributors.push(person.clone());
412 }
413 }
414 self.invites.iter_mut().find(|i| i.hash == hash).unwrap().used_by = Some(person);
415 }
416 "token" => {
417 // Tokens are issued by a person, in person, for themselves or one of their agents.
418 if by.agent.is_some() {
419 return Err("tokens are issued in person".into());
420 }
421 let hash = str_field(rec, "hash")?;
422 let agent = opt_str(rec, "agent")?;
423 if let Some(a) = &agent {
424 match self.agent(a) {
425 Some(ag) if ag.person == by.person && !ag.revoked => {}
426 _ => return Err(format!("no agent {a} of {}", by.person)),
427 }
428 }
429 if self.tokens.iter().any(|t| t.hash == hash) {
430 return Err("token exists".into());
431 }
432 self.tokens.push(Token { hash, person: by.person.clone(), agent, revoked: false, at });
433 }
434 "token.revoke" => {
435 let hash = str_field(rec, "hash")?;
436 let t = self.tokens.iter_mut().find(|t| t.hash == hash && t.person == by.person).ok_or("no such token")?;
437 t.revoked = true;
438 }
439 "group" => {
440 let name = str_field(rec, "name")?;
441 if by.agent.is_some() {
442 return Err("groups are created in person".into());
443 }
444 if self.name_taken(&name) {
445 return Err(format!("the name {name} is taken"));
446 }
447 self.groups.push(Group { name, members: vec![by.person.clone()], at });
448 }
449 "group.add" => {
450 let group = str_field(rec, "group")?;
451 let person = str_field(rec, "person")?;
452 if by.agent.is_some() {
453 return Err("members are added in person".into());
454 }
455 if self.person(&person).is_none() {
456 return Err(format!("no person {person}"));
457 }
458 let g = self.groups.iter_mut().find(|g| g.name == group).ok_or_else(|| format!("no group {group}"))?;
459 if !g.members.contains(&by.person) {
460 return Err(format!("{} isn't in {group}", by.person));
461 }
462 if g.members.contains(&person) {
463 return Err(format!("{person} is already in {group}"));
464 }
465 g.members.push(person);
466 }
467 "project" => {
468 let name = str_field(rec, "name")?;
469 let owner = opt_str(rec, "owner")?.unwrap_or_else(|| by.person.clone());
470 if !self.owns(&owner, &by.person) {
471 return Err(format!("{} can't create projects for {owner}", by.person));
472 }
473 if self.project(&owner, &name).is_some() {
474 return Err(format!("project {owner}/{name} exists"));
475 }
476 let public = matches!(rec.get("public"), Some(Value::Bool(true)));
477 self.projects.push(Project { owner, name, public, points: Vec::new(), changes: Vec::new(), contributors: Vec::new(), at });
478 }
479 "project.move" => {
480 // A project changes owner; its history goes with it.
481 let (owner, name, to) = (str_field(rec, "owner")?, str_field(rec, "project")?, str_field(rec, "to")?);
482 if by.agent.is_some() || !self.owns(&owner, &by.person) || !self.owns(&to, &by.person) {
483 return Err("a project moves only between owners the mover belongs to, in person".into());
484 }
485 if self.project(&to, &name).is_some() {
486 return Err(format!("{to}/{name} exists"));
487 }
488 self.project_mut(&owner, &name)?.owner = to;
489 }
490 "point" => {
491 // A point recorded directly (the first snapshot of a project, or imported history).
492 let owner = str_field(rec, "owner")?;
493 if !self.owns(&owner, &by.person) {
494 return Err("only the owner records points".into());
495 }
496 let p = self.project_mut(&owner, &str_field(rec, "project")?)?;
497 let n = uint(rec, "n")?;
498 if n != p.tip() + 1 {
499 return Err(format!("point {n} doesn't follow {}", p.tip()));
500 }
501 let author = match rec.get("author") {
502 Some(a) => Actor::from_json(a)?,
503 None => by.clone(),
504 };
505 p.points.push(Point { n, snapshot: id_field(rec, "snapshot")?, message: str_field(rec, "message")?, author, landed_by: by, change: None, step: None, at });
506 }
507 "change" => {
508 let p = self.project_mut(&str_field(rec, "owner")?, &str_field(rec, "project")?)?;
509 let number = uint(rec, "number")?;
510 if number != p.changes.len() as u64 + 1 {
511 return Err(format!("change {number} out of sequence"));
512 }
513 let state = ChangeState::parse(&str_field(rec, "state")?).ok_or("bad state")?;
514 if !matches!(state, ChangeState::Idea | ChangeState::Draft) {
515 return Err("a change opens as an idea or a draft".into());
516 }
517 p.changes.push(Change {
518 number,
519 intent: str_field(rec, "intent")?,
520 proposer: by.clone(),
521 state,
522 revisions: Vec::new(),
523 messages: Vec::new(),
524 evidence: Vec::new(),
525 timeline: vec![(at, by, Event::Opened)],
526 at,
527 });
528 }
529 "revision" => {
530 let p = self.project_mut(&str_field(rec, "owner")?, &str_field(rec, "project")?)?;
531 let tip = p.tip();
532 let c = p.change_mut(uint(rec, "change")?).ok_or("no such change")?;
533 if !c.state.is_open() {
534 return Err("change is closed".into());
535 }
536 let number = uint(rec, "number")? as u32;
537 if number as usize != c.revisions.len() + 1 {
538 return Err("revision out of sequence".into());
539 }
540 let base = uint(rec, "base")?;
541 if base == 0 || base > tip {
542 return Err(format!("base {base} isn't a point"));
543 }
544 let steps = rec.get("steps").and_then(Value::as_array).ok_or("missing steps")?;
545 let steps = steps.iter().map(step_from_json).collect::<Result<Vec<_>, _>>()?;
546 if steps.is_empty() {
547 return Err("a revision needs at least one step".into());
548 }
549 c.revisions.push(Revision { number, base, steps, note: opt_str(rec, "note")?, by: by.clone(), at });
550 if c.state == ChangeState::Idea {
551 c.state = ChangeState::Draft;
552 }
553 c.timeline.push((at, by, Event::Revised(number)));
554 }
555 "state" => {
556 let owner = str_field(rec, "owner")?;
557 let is_owner = self.owns(&owner, &by.person);
558 let p = self.project_mut(&owner, &str_field(rec, "project")?)?;
559 let c = p.change_mut(uint(rec, "change")?).ok_or("no such change")?;
560 let to = ChangeState::parse(&str_field(rec, "state")?).ok_or("bad state")?;
561 check_transition(c, to, &by, is_owner)?;
562 c.state = to;
563 c.timeline.push((at, by, Event::State(to)));
564 }
565 "land" => {
566 let owner = str_field(rec, "owner")?;
567 if !self.owns(&owner, &by.person) || by.agent.is_some() {
568 return Err("only the owner, in person, lands".into());
569 }
570 let p = self.project_mut(&owner, &str_field(rec, "project")?)?;
571 let number = uint(rec, "change")?;
572 let revision = uint(rec, "revision")? as u32;
573 let first = uint(rec, "first")?;
574 if first != p.tip() + 1 {
575 return Err("landing doesn't follow the tip".into());
576 }
577 let c = p.change(number).ok_or("no such change")?;
578 if c.state != ChangeState::Proposed {
579 return Err("only proposed changes land".into());
580 }
581 let rev = c.revisions.get((revision as usize).wrapping_sub(1)).ok_or("no such revision")?.clone();
582 let snapshots = rec.get("snapshots").and_then(Value::as_array).ok_or("missing snapshots")?;
583 let snapshots = snapshots.iter().map(id_value).collect::<Result<Vec<_>, _>>()?;
584 if snapshots.len() != rev.steps.len() {
585 return Err("one snapshot per step".into());
586 }
587 // Everything is checked; from here on nothing can fail.
588 for (i, (step, snapshot)) in rev.steps.iter().zip(snapshots).enumerate() {
589 p.points.push(Point {
590 n: first + i as u64,
591 snapshot,
592 message: step.message.clone(),
593 author: rev.by.clone(),
594 landed_by: by.clone(),
595 change: Some(number),
596 step: Some(step.id.clone()),
597 at,
598 });
599 }
600 let last = p.tip();
601 let c = p.change_mut(number).unwrap();
602 c.state = ChangeState::Landed;
603 c.timeline.push((at, by, Event::Landed { revision, points: (first, last) }));
604 }
605 "say" => {
606 let p = self.project_mut(&str_field(rec, "owner")?, &str_field(rec, "project")?)?;
607 let c = p.change_mut(uint(rec, "change")?).ok_or("no such change")?;
608 let id = c.messages.len() as u32 + 1;
609 c.messages.push(Message { id, body: str_field(rec, "body")?, by: by.clone(), at });
610 c.timeline.push((at, by, Event::Said(id)));
611 }
612 "evidence" => {
613 let p = self.project_mut(&str_field(rec, "owner")?, &str_field(rec, "project")?)?;
614 let c = p.change_mut(uint(rec, "change")?).ok_or("no such change")?;
615 let revision = uint(rec, "revision")? as u32;
616 if revision == 0 || revision as usize > c.revisions.len() {
617 return Err("no such revision".into());
618 }
619 let blob = opt_id(rec, "blob")?;
620 let url = opt_str(rec, "url")?;
621 if blob.is_none() == url.is_none() {
622 return Err("evidence is a blob or a url".into());
623 }
624 c.evidence.push(Evidence { revision, title: str_field(rec, "title")?, media_type: str_field(rec, "type")?, blob, url, by: by.clone(), at });
625 let i = c.evidence.len() - 1;
626 c.timeline.push((at, by, Event::Attached(i)));
627 }
628 other => return Err(format!("unknown record type {other}")),
629 }
630 self.records += 1;
631 Ok(())
632 }
633}
634
635/// Who may move a change between which states.
636fn check_transition(c: &Change, to: ChangeState, by: &Actor, is_owner: bool) -> Result<(), String> {
637 use ChangeState::*;
638 // Decisions are the owner's own: an agent acting for them can't make them.
639 let owner_in_person = is_owner && by.agent.is_none();
640 let is_proposer = by.person == c.proposer.person;
641 match (c.state, to) {
642 (Draft, Proposed) if is_proposer || is_owner => Ok(()),
643 (Proposed, Draft) if is_proposer || is_owner => Ok(()),
644 (Idea | Draft | Proposed, Declined) if owner_in_person => Ok(()),
645 (Idea | Draft | Proposed, Withdrawn) if is_proposer => Ok(()),
646 (Declined | Withdrawn, Draft) if (is_proposer || is_owner) && !c.revisions.is_empty() => Ok(()),
647 (Declined | Withdrawn, Idea) if (is_proposer || is_owner) && c.revisions.is_empty() => Ok(()),
648 (from, to) if from == to => Err(format!("already {}", to.name())),
649 (from, to) => Err(format!("{} can't move a change from {} to {}", by, from.name(), to.name())),
650 }
651}