grove
at @5
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#[derive(Clone, Debug)]
105pub struct Agent {
106 pub id: String,
107 pub person: String,
108 pub name: String,
109 pub revoked: bool,
110 pub at: i64,
111}
112
113#[derive(Clone, Debug)]
114pub struct Point {
115 pub n: u64,
116 pub snapshot: Id,
117 pub message: String,
118 pub author: Actor,
119 pub landed_by: Actor,
120 pub change: Option<u64>,
121 pub step: Option<String>,
122 pub at: i64,
123}
124
125#[derive(Clone, Debug, PartialEq)]
126pub struct Step {
127 /// Stable within a change, across revisions.
128 pub id: String,
129 pub snapshot: Id,
130 pub message: String,
131}
132
133#[derive(Clone, Debug)]
134pub struct Revision {
135 pub number: u32,
136 pub base: u64,
137 pub steps: Vec<Step>,
138 pub note: Option<String>,
139 pub by: Actor,
140 pub at: i64,
141}
142
143#[derive(Clone, Debug)]
144pub struct Message {
145 pub id: u32,
146 pub body: String,
147 pub by: Actor,
148 pub at: i64,
149}
150
151#[derive(Clone, Debug)]
152pub struct Evidence {
153 pub revision: u32,
154 pub title: String,
155 pub media_type: String,
156 pub blob: Option<Id>,
157 pub url: Option<String>,
158 pub by: Actor,
159 pub at: i64,
160}
161
162/// Something that happened to a change, for its timeline.
163#[derive(Clone, Debug)]
164pub enum Event {
165 Opened,
166 Revised(u32),
167 State(ChangeState),
168 Said(u32),
169 Attached(usize),
170 Landed { revision: u32, points: (u64, u64) },
171}
172
173#[derive(Clone, Debug)]
174pub struct Change {
175 pub number: u64,
176 pub intent: String,
177 pub proposer: Actor,
178 pub state: ChangeState,
179 pub revisions: Vec<Revision>,
180 pub messages: Vec<Message>,
181 pub evidence: Vec<Evidence>,
182 pub timeline: Vec<(i64, Actor, Event)>,
183 pub at: i64,
184}
185
186impl Change {
187 pub fn latest(&self) -> Option<&Revision> {
188 self.revisions.last()
189 }
190}
191
192#[derive(Clone, Debug)]
193pub struct Project {
194 pub owner: String,
195 pub name: String,
196 pub public: bool,
197 pub points: Vec<Point>,
198 pub changes: Vec<Change>,
199 pub at: i64,
200}
201
202impl Project {
203 pub fn tip(&self) -> u64 {
204 self.points.len() as u64
205 }
206 pub fn point(&self, n: u64) -> Option<&Point> {
207 if n == 0 { None } else { self.points.get(n as usize - 1) }
208 }
209 pub fn change(&self, number: u64) -> Option<&Change> {
210 if number == 0 { None } else { self.changes.get(number as usize - 1) }
211 }
212 fn change_mut(&mut self, number: u64) -> Option<&mut Change> {
213 if number == 0 { None } else { self.changes.get_mut(number as usize - 1) }
214 }
215}
216
217#[derive(Default)]
218pub struct State {
219 pub people: Vec<Person>,
220 pub agents: Vec<Agent>,
221 pub groups: Vec<Group>,
222 pub projects: Vec<Project>,
223 pub records: u64,
224}
225
226// ── Reading fields ─────────────────────────────────────────
227
228fn str_field(v: &Value, key: &str) -> Result<String, String> {
229 v.get(key).and_then(Value::as_str).map(str::to_string).ok_or_else(|| format!("missing {key}"))
230}
231fn opt_str(v: &Value, key: &str) -> Result<Option<String>, String> {
232 match v.get(key) {
233 None | Some(Value::Null) => Ok(None),
234 Some(Value::Str(s)) => Ok(Some(s.clone())),
235 _ => Err(format!("{key} must be a string")),
236 }
237}
238fn int_field(v: &Value, key: &str) -> Result<i64, String> {
239 v.get(key).and_then(Value::as_i64).ok_or_else(|| format!("missing {key}"))
240}
241fn uint(v: &Value, key: &str) -> Result<u64, String> {
242 let n = int_field(v, key)?;
243 u64::try_from(n).map_err(|_| format!("{key} must not be negative"))
244}
245pub fn id_to_json(id: &Id) -> Value {
246 Value::Str(hex::encode(id))
247}
248fn id_value(v: &Value) -> Result<Id, String> {
249 let s = v.as_str().ok_or("an id must be a string")?;
250 let bytes = hex::decode(s).ok_or("an id must be hex")?;
251 bytes.try_into().map_err(|_| "an id must be 32 bytes".to_string())
252}
253fn id_field(v: &Value, key: &str) -> Result<Id, String> {
254 id_value(v.get(key).ok_or_else(|| format!("missing {key}"))?).map_err(|e| format!("{key}: {e}"))
255}
256fn opt_id(v: &Value, key: &str) -> Result<Option<Id>, String> {
257 match v.get(key) {
258 None | Some(Value::Null) => Ok(None),
259 _ => id_field(v, key).map(Some),
260 }
261}
262
263pub fn step_to_json(s: &Step) -> Value {
264 obj([("id", s.id.clone().into()), ("snapshot", id_to_json(&s.snapshot)), ("message", s.message.clone().into())])
265}
266fn step_from_json(v: &Value) -> Result<Step, String> {
267 Ok(Step { id: str_field(v, "id")?, snapshot: id_field(v, "snapshot")?, message: str_field(v, "message")? })
268}
269
270// ── Applying records ───────────────────────────────────────
271
272impl State {
273 pub fn person(&self, username: &str) -> Option<&Person> {
274 self.people.iter().find(|p| p.username == username)
275 }
276 pub fn group(&self, name: &str) -> Option<&Group> {
277 self.groups.iter().find(|g| g.name == name)
278 }
279 /// People and groups share one namespace of owner names.
280 pub fn name_taken(&self, name: &str) -> bool {
281 self.person(name).is_some() || self.group(name).is_some()
282 }
283 /// May `person` act as `owner` (themselves, or a group they belong to)?
284 pub fn owns(&self, owner: &str, person: &str) -> bool {
285 owner == person || self.group(owner).is_some_and(|g| g.members.iter().any(|m| m == person))
286 }
287 pub fn agent(&self, id: &str) -> Option<&Agent> {
288 self.agents.iter().find(|a| a.id == id)
289 }
290 pub fn project(&self, owner: &str, name: &str) -> Option<&Project> {
291 self.projects.iter().find(|p| p.owner == owner && p.name == name)
292 }
293 fn project_mut(&mut self, owner: &str, name: &str) -> Result<&mut Project, String> {
294 self.projects.iter_mut().find(|p| p.owner == owner && p.name == name).ok_or_else(|| format!("no project {owner}/{name}"))
295 }
296
297 /// Fold one record into the state.
298 pub fn apply(&mut self, rec: &Value) -> Result<(), String> {
299 let t = str_field(rec, "t")?;
300 let at = int_field(rec, "at")?;
301 let by = Actor::from_json(rec.get("by").ok_or("missing by")?)?;
302 if t != "person" && self.person(&by.person).is_none() {
303 return Err(format!("unknown person {}", by.person));
304 }
305 if let Some(agent) = &by.agent {
306 match self.agent(agent) {
307 Some(a) if a.person == by.person && !a.revoked => {}
308 _ => return Err(format!("agent {agent} can't act for {}", by.person)),
309 }
310 }
311 match t.as_str() {
312 "person" => {
313 let username = str_field(rec, "username")?;
314 if username != by.person {
315 return Err("a person record is made by that person".into());
316 }
317 if self.name_taken(&username) {
318 return Err(format!("the name {username} is taken"));
319 }
320 self.people.push(Person { display_name: str_field(rec, "display_name")?, username, at });
321 }
322 "agent" => {
323 let id = str_field(rec, "id")?;
324 if self.agent(&id).is_some() {
325 return Err(format!("agent {id} exists"));
326 }
327 self.agents.push(Agent { id, person: by.person.clone(), name: str_field(rec, "name")?, revoked: false, at });
328 }
329 "agent.revoke" => {
330 let id = str_field(rec, "id")?;
331 let a = self.agents.iter_mut().find(|a| a.id == id && a.person == by.person).ok_or("no such agent")?;
332 a.revoked = true;
333 }
334 "group" => {
335 let name = str_field(rec, "name")?;
336 if by.agent.is_some() {
337 return Err("groups are created in person".into());
338 }
339 if self.name_taken(&name) {
340 return Err(format!("the name {name} is taken"));
341 }
342 self.groups.push(Group { name, members: vec![by.person.clone()], at });
343 }
344 "group.add" => {
345 let group = str_field(rec, "group")?;
346 let person = str_field(rec, "person")?;
347 if by.agent.is_some() {
348 return Err("members are added in person".into());
349 }
350 if self.person(&person).is_none() {
351 return Err(format!("no person {person}"));
352 }
353 let g = self.groups.iter_mut().find(|g| g.name == group).ok_or_else(|| format!("no group {group}"))?;
354 if !g.members.contains(&by.person) {
355 return Err(format!("{} isn't in {group}", by.person));
356 }
357 if g.members.contains(&person) {
358 return Err(format!("{person} is already in {group}"));
359 }
360 g.members.push(person);
361 }
362 "project" => {
363 let name = str_field(rec, "name")?;
364 let owner = opt_str(rec, "owner")?.unwrap_or_else(|| by.person.clone());
365 if !self.owns(&owner, &by.person) {
366 return Err(format!("{} can't create projects for {owner}", by.person));
367 }
368 if self.project(&owner, &name).is_some() {
369 return Err(format!("project {owner}/{name} exists"));
370 }
371 let public = matches!(rec.get("public"), Some(Value::Bool(true)));
372 self.projects.push(Project { owner, name, public, points: Vec::new(), changes: Vec::new(), at });
373 }
374 "project.move" => {
375 // A project changes owner; its history goes with it.
376 let (owner, name, to) = (str_field(rec, "owner")?, str_field(rec, "project")?, str_field(rec, "to")?);
377 if by.agent.is_some() || !self.owns(&owner, &by.person) || !self.owns(&to, &by.person) {
378 return Err("a project moves only between owners the mover belongs to, in person".into());
379 }
380 if self.project(&to, &name).is_some() {
381 return Err(format!("{to}/{name} exists"));
382 }
383 self.project_mut(&owner, &name)?.owner = to;
384 }
385 "point" => {
386 // A point recorded directly (the first snapshot of a project, or imported history).
387 let owner = str_field(rec, "owner")?;
388 if !self.owns(&owner, &by.person) {
389 return Err("only the owner records points".into());
390 }
391 let p = self.project_mut(&owner, &str_field(rec, "project")?)?;
392 let n = uint(rec, "n")?;
393 if n != p.tip() + 1 {
394 return Err(format!("point {n} doesn't follow {}", p.tip()));
395 }
396 let author = match rec.get("author") {
397 Some(a) => Actor::from_json(a)?,
398 None => by.clone(),
399 };
400 p.points.push(Point { n, snapshot: id_field(rec, "snapshot")?, message: str_field(rec, "message")?, author, landed_by: by, change: None, step: None, at });
401 }
402 "change" => {
403 let p = self.project_mut(&str_field(rec, "owner")?, &str_field(rec, "project")?)?;
404 let number = uint(rec, "number")?;
405 if number != p.changes.len() as u64 + 1 {
406 return Err(format!("change {number} out of sequence"));
407 }
408 let state = ChangeState::parse(&str_field(rec, "state")?).ok_or("bad state")?;
409 if !matches!(state, ChangeState::Idea | ChangeState::Draft) {
410 return Err("a change opens as an idea or a draft".into());
411 }
412 p.changes.push(Change {
413 number,
414 intent: str_field(rec, "intent")?,
415 proposer: by.clone(),
416 state,
417 revisions: Vec::new(),
418 messages: Vec::new(),
419 evidence: Vec::new(),
420 timeline: vec![(at, by, Event::Opened)],
421 at,
422 });
423 }
424 "revision" => {
425 let p = self.project_mut(&str_field(rec, "owner")?, &str_field(rec, "project")?)?;
426 let tip = p.tip();
427 let c = p.change_mut(uint(rec, "change")?).ok_or("no such change")?;
428 if !c.state.is_open() {
429 return Err("change is closed".into());
430 }
431 let number = uint(rec, "number")? as u32;
432 if number as usize != c.revisions.len() + 1 {
433 return Err("revision out of sequence".into());
434 }
435 let base = uint(rec, "base")?;
436 if base == 0 || base > tip {
437 return Err(format!("base {base} isn't a point"));
438 }
439 let steps = rec.get("steps").and_then(Value::as_array).ok_or("missing steps")?;
440 let steps = steps.iter().map(step_from_json).collect::<Result<Vec<_>, _>>()?;
441 if steps.is_empty() {
442 return Err("a revision needs at least one step".into());
443 }
444 c.revisions.push(Revision { number, base, steps, note: opt_str(rec, "note")?, by: by.clone(), at });
445 if c.state == ChangeState::Idea {
446 c.state = ChangeState::Draft;
447 }
448 c.timeline.push((at, by, Event::Revised(number)));
449 }
450 "state" => {
451 let owner = str_field(rec, "owner")?;
452 let is_owner = self.owns(&owner, &by.person);
453 let p = self.project_mut(&owner, &str_field(rec, "project")?)?;
454 let c = p.change_mut(uint(rec, "change")?).ok_or("no such change")?;
455 let to = ChangeState::parse(&str_field(rec, "state")?).ok_or("bad state")?;
456 check_transition(c, to, &by, is_owner)?;
457 c.state = to;
458 c.timeline.push((at, by, Event::State(to)));
459 }
460 "land" => {
461 let owner = str_field(rec, "owner")?;
462 if !self.owns(&owner, &by.person) || by.agent.is_some() {
463 return Err("only the owner, in person, lands".into());
464 }
465 let p = self.project_mut(&owner, &str_field(rec, "project")?)?;
466 let number = uint(rec, "change")?;
467 let revision = uint(rec, "revision")? as u32;
468 let first = uint(rec, "first")?;
469 if first != p.tip() + 1 {
470 return Err("landing doesn't follow the tip".into());
471 }
472 let c = p.change(number).ok_or("no such change")?;
473 if c.state != ChangeState::Proposed {
474 return Err("only proposed changes land".into());
475 }
476 let rev = c.revisions.get((revision as usize).wrapping_sub(1)).ok_or("no such revision")?.clone();
477 let snapshots = rec.get("snapshots").and_then(Value::as_array).ok_or("missing snapshots")?;
478 let snapshots = snapshots.iter().map(id_value).collect::<Result<Vec<_>, _>>()?;
479 if snapshots.len() != rev.steps.len() {
480 return Err("one snapshot per step".into());
481 }
482 // Everything is checked; from here on nothing can fail.
483 for (i, (step, snapshot)) in rev.steps.iter().zip(snapshots).enumerate() {
484 p.points.push(Point {
485 n: first + i as u64,
486 snapshot,
487 message: step.message.clone(),
488 author: rev.by.clone(),
489 landed_by: by.clone(),
490 change: Some(number),
491 step: Some(step.id.clone()),
492 at,
493 });
494 }
495 let last = p.tip();
496 let c = p.change_mut(number).unwrap();
497 c.state = ChangeState::Landed;
498 c.timeline.push((at, by, Event::Landed { revision, points: (first, last) }));
499 }
500 "say" => {
501 let p = self.project_mut(&str_field(rec, "owner")?, &str_field(rec, "project")?)?;
502 let c = p.change_mut(uint(rec, "change")?).ok_or("no such change")?;
503 let id = c.messages.len() as u32 + 1;
504 c.messages.push(Message { id, body: str_field(rec, "body")?, by: by.clone(), at });
505 c.timeline.push((at, by, Event::Said(id)));
506 }
507 "evidence" => {
508 let p = self.project_mut(&str_field(rec, "owner")?, &str_field(rec, "project")?)?;
509 let c = p.change_mut(uint(rec, "change")?).ok_or("no such change")?;
510 let revision = uint(rec, "revision")? as u32;
511 if revision == 0 || revision as usize > c.revisions.len() {
512 return Err("no such revision".into());
513 }
514 let blob = opt_id(rec, "blob")?;
515 let url = opt_str(rec, "url")?;
516 if blob.is_none() == url.is_none() {
517 return Err("evidence is a blob or a url".into());
518 }
519 c.evidence.push(Evidence { revision, title: str_field(rec, "title")?, media_type: str_field(rec, "type")?, blob, url, by: by.clone(), at });
520 let i = c.evidence.len() - 1;
521 c.timeline.push((at, by, Event::Attached(i)));
522 }
523 other => return Err(format!("unknown record type {other}")),
524 }
525 self.records += 1;
526 Ok(())
527 }
528}
529
530/// Who may move a change between which states.
531fn check_transition(c: &Change, to: ChangeState, by: &Actor, is_owner: bool) -> Result<(), String> {
532 use ChangeState::*;
533 // Decisions are the owner's own: an agent acting for them can't make them.
534 let owner_in_person = is_owner && by.agent.is_none();
535 let is_proposer = by.person == c.proposer.person;
536 match (c.state, to) {
537 (Draft, Proposed) if is_proposer || is_owner => Ok(()),
538 (Proposed, Draft) if is_proposer || is_owner => Ok(()),
539 (Idea | Draft | Proposed, Declined) if owner_in_person => Ok(()),
540 (Idea | Draft | Proposed, Withdrawn) if is_proposer => Ok(()),
541 (Declined | Withdrawn, Draft) if (is_proposer || is_owner) && !c.revisions.is_empty() => Ok(()),
542 (Declined | Withdrawn, Idea) if (is_proposer || is_owner) && c.revisions.is_empty() => Ok(()),
543 (from, to) if from == to => Err(format!("already {}", to.name())),
544 (from, to) => Err(format!("{} can't move a change from {} to {}", by, from.name(), to.name())),
545 }
546}