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