Skip to main content

harn_vm/llm/plan/
document.rs

1//! Typed collaborative plan documents and their deterministic event replay.
2
3use std::collections::{BTreeMap, BTreeSet};
4
5use serde::{Deserialize, Serialize};
6use sha2::{Digest, Sha256};
7
8use super::PLAN_SCHEMA_VERSION;
9
10pub const PLAN_DOCUMENT_SCHEMA_VERSION: &str = "harn.plan_document.v1";
11pub const PLAN_DOCUMENT_ARTIFACT_KIND: &str = "plan_document";
12pub const PLAN_DOCUMENT_SCHEMA_ARTIFACT: &str = "schemas/plan-document-v1.schema.json";
13
14#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)]
15#[serde(rename_all = "snake_case")]
16pub enum PlanStepStatus {
17    Pending,
18    InProgress,
19    Completed,
20    Blocked,
21    Cancelled,
22}
23
24#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)]
25pub struct PlanStep {
26    pub id: String,
27    pub content: String,
28    pub status: PlanStepStatus,
29    pub priority: Option<serde_json::Value>,
30}
31
32#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)]
33#[serde(rename_all = "snake_case")]
34pub enum PlanApprovalState {
35    Unrequested,
36    Requested,
37    Approved,
38    Rejected,
39}
40
41#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)]
42pub struct PlanApproval {
43    pub state: PlanApprovalState,
44    #[serde(default, skip_serializing_if = "Option::is_none")]
45    pub request_id: Option<String>,
46    #[serde(default, skip_serializing_if = "Option::is_none")]
47    pub reviewer: Option<String>,
48    #[serde(default, skip_serializing_if = "Vec::is_empty")]
49    pub reviewers: Vec<String>,
50    #[serde(default, skip_serializing_if = "Option::is_none")]
51    pub approved_at: Option<String>,
52    #[serde(default, skip_serializing_if = "Option::is_none")]
53    pub reason: Option<String>,
54}
55
56#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)]
57pub struct PlanArtifact {
58    #[serde(rename = "_type")]
59    pub type_name: String,
60    pub schema_version: String,
61    pub id: String,
62    pub tool: String,
63    pub title: String,
64    pub summary: String,
65    pub steps: Vec<PlanStep>,
66    pub assumptions: Vec<String>,
67    pub open_questions: Vec<String>,
68    pub verification_commands: Vec<String>,
69    pub approval: PlanApproval,
70}
71
72#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)]
73pub struct PlanAuthor {
74    pub id: String,
75    #[serde(default, skip_serializing_if = "Option::is_none")]
76    pub display_name: Option<String>,
77}
78
79#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)]
80pub struct PlanSource {
81    pub kind: String,
82    #[serde(default, skip_serializing_if = "Option::is_none")]
83    pub uri: Option<String>,
84}
85
86#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)]
87pub struct PlanRevision {
88    pub revision_id: String,
89    #[serde(default, skip_serializing_if = "Option::is_none")]
90    pub parent_revision_id: Option<String>,
91    pub markdown: String,
92    pub plan: PlanArtifact,
93    pub author: PlanAuthor,
94    pub source: PlanSource,
95    pub created_at: String,
96    pub operation: PlanRevisionOperation,
97}
98
99#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)]
100#[serde(tag = "kind", rename_all = "snake_case")]
101pub enum PlanRevisionOperation {
102    Create {
103        event_id: String,
104    },
105    Edit {
106        event_id: String,
107    },
108    Comment {
109        event_id: String,
110        comment_id: String,
111    },
112    CommentState {
113        event_id: String,
114        comment_id: String,
115        state: PlanCommentState,
116    },
117}
118
119#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)]
120pub struct PlanTextRange {
121    pub start: usize,
122    pub end: usize,
123}
124
125#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)]
126pub struct PlanCommentAnchor {
127    #[serde(default, skip_serializing_if = "Option::is_none")]
128    pub step_id: Option<String>,
129    #[serde(default, skip_serializing_if = "Option::is_none")]
130    pub quoted_text: Option<String>,
131    #[serde(default, skip_serializing_if = "Option::is_none")]
132    pub range: Option<PlanTextRange>,
133}
134
135#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)]
136#[serde(rename_all = "snake_case")]
137pub enum PlanCommentState {
138    Open,
139    Addressed,
140    Resolved,
141    Reopened,
142}
143
144impl PlanCommentState {
145    pub fn is_unresolved(&self) -> bool {
146        !matches!(self, Self::Resolved)
147    }
148}
149
150#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)]
151pub struct PlanComment {
152    pub comment_id: String,
153    pub anchor: PlanCommentAnchor,
154    pub body: String,
155    pub state: PlanCommentState,
156    pub author: PlanAuthor,
157    pub created_at: String,
158    pub updated_at: String,
159}
160
161#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)]
162pub struct PlanCommentResolutionReceipt {
163    pub receipt_id: String,
164    pub comment_id: String,
165    pub input_revision_id: String,
166    pub output_revision_id: String,
167    pub agent_run_id: String,
168    pub event_id: String,
169    #[serde(default, skip_serializing_if = "Option::is_none")]
170    pub explanation: Option<String>,
171    pub created_at: String,
172}
173
174#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)]
175pub struct PlanDocument {
176    #[serde(rename = "_type")]
177    pub type_name: String,
178    pub schema_version: String,
179    pub document_id: String,
180    pub current_revision: PlanRevision,
181    pub comments: Vec<PlanComment>,
182    pub resolution_receipts: Vec<PlanCommentResolutionReceipt>,
183    pub created_at: String,
184    pub updated_at: String,
185}
186
187impl PlanDocument {
188    pub fn unresolved_comments(&self) -> impl Iterator<Item = &PlanComment> {
189        self.comments
190            .iter()
191            .filter(|comment| comment.state.is_unresolved())
192    }
193
194    pub fn validate(&self) -> Result<(), PlanDocumentError> {
195        require(
196            self.type_name == PLAN_DOCUMENT_ARTIFACT_KIND,
197            "document _type must be plan_document",
198        )?;
199        require(
200            self.schema_version == PLAN_DOCUMENT_SCHEMA_VERSION,
201            "unsupported plan document schema_version",
202        )?;
203        require(
204            !self.document_id.trim().is_empty(),
205            "document_id is required",
206        )?;
207        require(
208            !self.created_at.trim().is_empty(),
209            "document created_at is required",
210        )?;
211        require(
212            self.updated_at == self.current_revision.created_at,
213            "document updated_at must match the current revision",
214        )?;
215        validate_revision(&self.current_revision)?;
216
217        let mut comment_ids = BTreeSet::new();
218        for comment in &self.comments {
219            require(
220                comment_ids.insert(comment.comment_id.as_str()),
221                "comment_id values must be unique",
222            )?;
223            validate_comment(comment, &self.current_revision)?;
224        }
225        let comments = self
226            .comments
227            .iter()
228            .map(|comment| comment.comment_id.as_str())
229            .collect::<BTreeSet<_>>();
230        let mut receipt_ids = BTreeSet::new();
231        for receipt in &self.resolution_receipts {
232            require(
233                receipt_ids.insert(receipt.receipt_id.as_str()),
234                "receipt_id values must be unique",
235            )?;
236            require(
237                comments.contains(receipt.comment_id.as_str()),
238                "resolution receipt references an unknown comment",
239            )?;
240            for value in [
241                &receipt.input_revision_id,
242                &receipt.output_revision_id,
243                &receipt.agent_run_id,
244                &receipt.event_id,
245                &receipt.created_at,
246            ] {
247                require(
248                    !value.trim().is_empty(),
249                    "resolution receipt fields are required",
250                )?;
251            }
252            require(
253                receipt.receipt_id == resolution_receipt_id(receipt)?,
254                "receipt_id does not match immutable resolution receipt state",
255            )?;
256        }
257        validate_revision_identity(
258            &self.current_revision,
259            &self.comments,
260            &self.resolution_receipts,
261        )?;
262        Ok(())
263    }
264}
265
266#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)]
267#[serde(tag = "kind", rename_all = "snake_case")]
268pub enum PlanDocumentEvent {
269    Created {
270        event_id: String,
271        document: PlanDocument,
272    },
273    Updated {
274        event_id: String,
275        input_revision_id: String,
276        document: PlanDocument,
277    },
278}
279
280impl PlanDocumentEvent {
281    pub fn document(&self) -> &PlanDocument {
282        match self {
283            Self::Created { document, .. } | Self::Updated { document, .. } => document,
284        }
285    }
286
287    pub fn event_id(&self) -> &str {
288        match self {
289            Self::Created { event_id, .. } | Self::Updated { event_id, .. } => event_id,
290        }
291    }
292
293    pub fn to_artifact_record(
294        &self,
295    ) -> Result<crate::orchestration::ArtifactRecord, PlanDocumentError> {
296        let document = self.document();
297        document.validate()?;
298        let mut metadata = BTreeMap::new();
299        metadata.insert(
300            "schema_version".to_string(),
301            serde_json::Value::String(PLAN_DOCUMENT_SCHEMA_VERSION.to_string()),
302        );
303        metadata.insert(
304            "document_id".to_string(),
305            serde_json::Value::String(document.document_id.clone()),
306        );
307        metadata.insert(
308            "revision_id".to_string(),
309            serde_json::Value::String(document.current_revision.revision_id.clone()),
310        );
311        Ok(crate::orchestration::ArtifactRecord {
312            type_name: "artifact".to_string(),
313            id: format!(
314                "plan_document_event_{}",
315                self.event_id().trim_start_matches("plan_event_")
316            ),
317            kind: PLAN_DOCUMENT_ARTIFACT_KIND.to_string(),
318            title: Some(document.current_revision.plan.title.clone()),
319            text: Some(document.current_revision.markdown.clone()),
320            data: Some(serde_json::to_value(self).map_err(|error| {
321                PlanDocumentError::Invalid(format!("cannot persist plan document event: {error}"))
322            })?),
323            source: Some(document.current_revision.source.kind.clone()),
324            created_at: document.updated_at.clone(),
325            freshness: Some("fresh".to_string()),
326            priority: Some(80),
327            lineage: document
328                .current_revision
329                .parent_revision_id
330                .iter()
331                .cloned()
332                .collect(),
333            relevance: None,
334            estimated_tokens: None,
335            stage: Some("plan".to_string()),
336            metadata,
337        }
338        .normalize())
339    }
340}
341
342#[derive(Clone, Debug, PartialEq, Eq, thiserror::Error)]
343pub enum PlanDocumentError {
344    #[error(
345        "plan document {document_id} revision conflict: expected {expected_revision_id}, current revision is {current_revision_id}"
346    )]
347    Conflict {
348        document_id: String,
349        expected_revision_id: String,
350        current_revision_id: String,
351    },
352    #[error("invalid collaborative plan document: {0}")]
353    Invalid(String),
354    #[error("plan comment {0} was not found")]
355    CommentNotFound(String),
356    #[error("invalid plan comment transition from {from:?} to {to:?}")]
357    InvalidCommentTransition {
358        from: PlanCommentState,
359        to: PlanCommentState,
360    },
361    #[error("plan document replay failed at event {event_id}: {message}")]
362    Replay { event_id: String, message: String },
363}
364
365#[derive(Clone, Debug)]
366pub struct CreatePlanDocument {
367    pub document_id: String,
368    pub markdown: String,
369    pub plan: PlanArtifact,
370    pub author: PlanAuthor,
371    pub source: PlanSource,
372    pub created_at: String,
373    pub event_id: String,
374}
375
376#[derive(Clone, Debug)]
377pub struct EditPlanDocument {
378    pub expected_revision_id: String,
379    pub markdown: String,
380    pub plan: PlanArtifact,
381    pub author: PlanAuthor,
382    pub source: PlanSource,
383    pub created_at: String,
384    pub event_id: String,
385}
386
387#[derive(Clone, Debug)]
388pub struct AddPlanComment {
389    pub expected_revision_id: String,
390    pub comment_id: String,
391    pub anchor: PlanCommentAnchor,
392    pub body: String,
393    pub author: PlanAuthor,
394    pub created_at: String,
395    pub event_id: String,
396}
397
398#[derive(Clone, Debug)]
399pub struct ChangePlanCommentState {
400    pub expected_revision_id: String,
401    pub comment_id: String,
402    pub state: PlanCommentState,
403    pub author: PlanAuthor,
404    pub source: PlanSource,
405    pub created_at: String,
406    pub event_id: String,
407    pub agent_run_id: Option<String>,
408    pub explanation: Option<String>,
409}
410
411#[derive(Clone, Debug, PartialEq, Eq)]
412pub struct PlanDocumentStore {
413    document: PlanDocument,
414    events: Vec<PlanDocumentEvent>,
415}
416
417impl PlanDocumentStore {
418    pub fn create(input: CreatePlanDocument) -> Result<Self, PlanDocumentError> {
419        require(
420            !input.document_id.trim().is_empty(),
421            "document_id is required",
422        )?;
423        validate_plan(&input.plan)?;
424        let revision = make_revision(
425            None,
426            input.markdown,
427            input.plan,
428            input.author,
429            input.source,
430            input.created_at.clone(),
431            PlanRevisionOperation::Create {
432                event_id: input.event_id.clone(),
433            },
434            RevisionState {
435                comments: &[],
436                receipts: &[],
437            },
438        )?;
439        let document = PlanDocument {
440            type_name: PLAN_DOCUMENT_ARTIFACT_KIND.to_string(),
441            schema_version: PLAN_DOCUMENT_SCHEMA_VERSION.to_string(),
442            document_id: input.document_id,
443            current_revision: revision,
444            comments: Vec::new(),
445            resolution_receipts: Vec::new(),
446            created_at: input.created_at.clone(),
447            updated_at: input.created_at,
448        };
449        document.validate()?;
450        let event = PlanDocumentEvent::Created {
451            event_id: input.event_id,
452            document: document.clone(),
453        };
454        Ok(Self {
455            document,
456            events: vec![event],
457        })
458    }
459
460    pub fn current(&self) -> &PlanDocument {
461        &self.document
462    }
463
464    pub fn resume(document: PlanDocument) -> Result<Self, PlanDocumentError> {
465        document.validate()?;
466        Ok(Self {
467            document,
468            events: Vec::new(),
469        })
470    }
471
472    pub fn events(&self) -> &[PlanDocumentEvent] {
473        &self.events
474    }
475
476    pub fn edit(&mut self, input: EditPlanDocument) -> Result<&PlanDocument, PlanDocumentError> {
477        self.require_revision(&input.expected_revision_id)?;
478        validate_plan(&input.plan)?;
479        let parent = self.document.current_revision.revision_id.clone();
480        let revision = make_revision(
481            Some(parent.clone()),
482            input.markdown,
483            input.plan,
484            input.author,
485            input.source,
486            input.created_at.clone(),
487            PlanRevisionOperation::Edit {
488                event_id: input.event_id.clone(),
489            },
490            RevisionState {
491                comments: &self.document.comments,
492                receipts: &self.document.resolution_receipts,
493            },
494        )?;
495        self.document.current_revision = revision;
496        self.document.updated_at = input.created_at;
497        self.commit(input.event_id, parent)
498    }
499
500    pub fn add_comment(
501        &mut self,
502        input: AddPlanComment,
503    ) -> Result<&PlanDocument, PlanDocumentError> {
504        self.require_revision(&input.expected_revision_id)?;
505        require(
506            !self
507                .document
508                .comments
509                .iter()
510                .any(|comment| comment.comment_id == input.comment_id),
511            "comment_id values must be unique",
512        )?;
513        let comment = PlanComment {
514            comment_id: input.comment_id.clone(),
515            anchor: input.anchor,
516            body: input.body,
517            state: PlanCommentState::Open,
518            author: input.author.clone(),
519            created_at: input.created_at.clone(),
520            updated_at: input.created_at.clone(),
521        };
522        validate_comment(&comment, &self.document.current_revision)?;
523        let parent = self.document.current_revision.revision_id.clone();
524        self.document.comments.push(comment);
525        self.revise_unchanged_content(
526            input.author,
527            PlanSource {
528                kind: "comment".to_string(),
529                uri: None,
530            },
531            input.created_at,
532            PlanRevisionOperation::Comment {
533                event_id: input.event_id.clone(),
534                comment_id: input.comment_id,
535            },
536        )?;
537        self.commit(input.event_id, parent)
538    }
539
540    pub fn change_comment_state(
541        &mut self,
542        input: ChangePlanCommentState,
543    ) -> Result<&PlanDocument, PlanDocumentError> {
544        self.require_revision(&input.expected_revision_id)?;
545        let index = self
546            .document
547            .comments
548            .iter()
549            .position(|comment| comment.comment_id == input.comment_id)
550            .ok_or_else(|| PlanDocumentError::CommentNotFound(input.comment_id.clone()))?;
551        let prior_state = self.document.comments[index].state.clone();
552        require_comment_transition(&prior_state, &input.state)?;
553        let resolution_agent_run_id = if matches!(
554            input.state,
555            PlanCommentState::Addressed | PlanCommentState::Resolved
556        ) {
557            Some(input.agent_run_id.clone().ok_or_else(|| {
558                PlanDocumentError::Invalid(
559                    "addressed and resolved comments require agent_run_id".to_string(),
560                )
561            })?)
562        } else {
563            None
564        };
565        let parent = self.document.current_revision.revision_id.clone();
566        self.document.comments[index].state = input.state.clone();
567        self.document.comments[index].updated_at = input.created_at.clone();
568        if let Some(agent_run_id) = resolution_agent_run_id.as_ref() {
569            self.document
570                .resolution_receipts
571                .push(PlanCommentResolutionReceipt {
572                    receipt_id: String::new(),
573                    comment_id: input.comment_id.clone(),
574                    input_revision_id: parent.clone(),
575                    output_revision_id: String::new(),
576                    agent_run_id: agent_run_id.clone(),
577                    event_id: input.event_id.clone(),
578                    explanation: input.explanation.clone(),
579                    created_at: input.created_at.clone(),
580                });
581        }
582        self.revise_unchanged_content(
583            input.author,
584            input.source,
585            input.created_at.clone(),
586            PlanRevisionOperation::CommentState {
587                event_id: input.event_id.clone(),
588                comment_id: input.comment_id.clone(),
589                state: input.state.clone(),
590            },
591        )?;
592        if resolution_agent_run_id.is_some() {
593            let output_revision_id = self.document.current_revision.revision_id.clone();
594            let receipt = self
595                .document
596                .resolution_receipts
597                .last_mut()
598                .expect("provisional resolution receipt was inserted");
599            receipt.output_revision_id = output_revision_id;
600            receipt.receipt_id = resolution_receipt_id(receipt)?;
601        }
602        self.commit(input.event_id, parent)
603    }
604
605    pub fn replay(events: &[PlanDocumentEvent]) -> Result<Self, PlanDocumentError> {
606        let Some(first) = events.first() else {
607            return Err(PlanDocumentError::Invalid(
608                "plan document replay requires at least one event".to_string(),
609            ));
610        };
611        let PlanDocumentEvent::Created { document, .. } = first else {
612            return Err(PlanDocumentError::Replay {
613                event_id: first.event_id().to_string(),
614                message: "first event must be created".to_string(),
615            });
616        };
617        if document.current_revision.parent_revision_id.is_some()
618            || !matches!(
619                &document.current_revision.operation,
620                PlanRevisionOperation::Create { .. }
621            )
622            || revision_operation_event_id(&document.current_revision.operation) != first.event_id()
623        {
624            return Err(PlanDocumentError::Replay {
625                event_id: first.event_id().to_string(),
626                message: "created event must contain a root create revision".to_string(),
627            });
628        }
629        document
630            .validate()
631            .map_err(|error| PlanDocumentError::Replay {
632                event_id: first.event_id().to_string(),
633                message: error.to_string(),
634            })?;
635        let mut current = document.clone();
636        let mut event_ids = BTreeSet::from([first.event_id()]);
637        for event in &events[1..] {
638            let PlanDocumentEvent::Updated {
639                event_id,
640                input_revision_id,
641                document,
642            } = event
643            else {
644                return Err(PlanDocumentError::Replay {
645                    event_id: event.event_id().to_string(),
646                    message: "created event may only appear first".to_string(),
647                });
648            };
649            if !event_ids.insert(event_id.as_str()) {
650                return Err(PlanDocumentError::Replay {
651                    event_id: event_id.clone(),
652                    message: "event_id values must be unique".to_string(),
653                });
654            }
655            if matches!(
656                &document.current_revision.operation,
657                PlanRevisionOperation::Create { .. }
658            ) || revision_operation_event_id(&document.current_revision.operation) != event_id
659            {
660                return Err(PlanDocumentError::Replay {
661                    event_id: event_id.clone(),
662                    message: "event envelope does not match revision operation".to_string(),
663                });
664            }
665            if input_revision_id != &current.current_revision.revision_id {
666                return Err(PlanDocumentError::Replay {
667                    event_id: event_id.clone(),
668                    message: format!(
669                        "expected input revision {}, found {}",
670                        current.current_revision.revision_id, input_revision_id
671                    ),
672                });
673            }
674            if document.document_id != current.document_id
675                || document.created_at != current.created_at
676                || document.current_revision.parent_revision_id.as_deref()
677                    != Some(input_revision_id.as_str())
678            {
679                return Err(PlanDocumentError::Replay {
680                    event_id: event_id.clone(),
681                    message: "document identity or revision lineage changed".to_string(),
682                });
683            }
684            let prior_receipt_ids = current
685                .resolution_receipts
686                .iter()
687                .map(|receipt| receipt.receipt_id.as_str())
688                .collect::<BTreeSet<_>>();
689            if current.comments.iter().any(|prior| {
690                !document
691                    .comments
692                    .iter()
693                    .any(|comment| comment.comment_id == prior.comment_id)
694            }) || current.resolution_receipts.iter().any(|prior| {
695                !document
696                    .resolution_receipts
697                    .iter()
698                    .any(|receipt| receipt.receipt_id == prior.receipt_id)
699            }) {
700                return Err(PlanDocumentError::Replay {
701                    event_id: event_id.clone(),
702                    message: "comments and resolution receipts are append-only".to_string(),
703                });
704            }
705            for prior in &current.comments {
706                let comment = document
707                    .comments
708                    .iter()
709                    .find(|comment| comment.comment_id == prior.comment_id)
710                    .expect("append-only comment was checked");
711                if prior.anchor != comment.anchor
712                    || prior.body != comment.body
713                    || prior.author != comment.author
714                    || prior.created_at != comment.created_at
715                {
716                    return Err(PlanDocumentError::Replay {
717                        event_id: event_id.clone(),
718                        message: "comment identity fields changed during replay".to_string(),
719                    });
720                }
721                if prior.state != comment.state {
722                    require_comment_transition(&prior.state, &comment.state).map_err(|error| {
723                        PlanDocumentError::Replay {
724                            event_id: event_id.clone(),
725                            message: error.to_string(),
726                        }
727                    })?;
728                }
729            }
730            for receipt in document
731                .resolution_receipts
732                .iter()
733                .filter(|receipt| !prior_receipt_ids.contains(receipt.receipt_id.as_str()))
734            {
735                if receipt.input_revision_id != *input_revision_id
736                    || receipt.output_revision_id != document.current_revision.revision_id
737                    || receipt.event_id != *event_id
738                {
739                    return Err(PlanDocumentError::Replay {
740                        event_id: event_id.clone(),
741                        message: "resolution receipt does not bind the replayed transition"
742                            .to_string(),
743                    });
744                }
745            }
746            document
747                .validate()
748                .map_err(|error| PlanDocumentError::Replay {
749                    event_id: event_id.clone(),
750                    message: error.to_string(),
751                })?;
752            current = document.clone();
753        }
754        Ok(Self {
755            document: current,
756            events: events.to_vec(),
757        })
758    }
759
760    pub fn replay_artifacts(
761        artifacts: &[crate::orchestration::ArtifactRecord],
762    ) -> Result<Self, PlanDocumentError> {
763        let events = artifacts
764            .iter()
765            .map(|artifact| {
766                require(
767                    artifact.kind == PLAN_DOCUMENT_ARTIFACT_KIND,
768                    "plan document replay artifact has the wrong kind",
769                )?;
770                let data = artifact.data.clone().ok_or_else(|| {
771                    PlanDocumentError::Invalid(
772                        "plan document replay artifact is missing data".to_string(),
773                    )
774                })?;
775                serde_json::from_value::<PlanDocumentEvent>(data).map_err(|error| {
776                    PlanDocumentError::Invalid(format!(
777                        "invalid persisted plan document event: {error}"
778                    ))
779                })
780            })
781            .collect::<Result<Vec<_>, _>>()?;
782        Self::replay(&events)
783    }
784
785    fn require_revision(&self, expected: &str) -> Result<(), PlanDocumentError> {
786        let current = &self.document.current_revision.revision_id;
787        if expected == current {
788            return Ok(());
789        }
790        Err(PlanDocumentError::Conflict {
791            document_id: self.document.document_id.clone(),
792            expected_revision_id: expected.to_string(),
793            current_revision_id: current.clone(),
794        })
795    }
796
797    fn revise_unchanged_content(
798        &mut self,
799        author: PlanAuthor,
800        source: PlanSource,
801        created_at: String,
802        operation: PlanRevisionOperation,
803    ) -> Result<(), PlanDocumentError> {
804        let previous = &self.document.current_revision;
805        let revision = make_revision(
806            Some(previous.revision_id.clone()),
807            previous.markdown.clone(),
808            previous.plan.clone(),
809            author,
810            source,
811            created_at.clone(),
812            operation,
813            RevisionState {
814                comments: &self.document.comments,
815                receipts: &self.document.resolution_receipts,
816            },
817        )?;
818        self.document.current_revision = revision;
819        self.document.updated_at = created_at;
820        Ok(())
821    }
822
823    fn commit(
824        &mut self,
825        event_id: String,
826        input_revision_id: String,
827    ) -> Result<&PlanDocument, PlanDocumentError> {
828        self.document.validate()?;
829        self.events.push(PlanDocumentEvent::Updated {
830            event_id,
831            input_revision_id,
832            document: self.document.clone(),
833        });
834        Ok(&self.document)
835    }
836}
837
838fn validate_revision(revision: &PlanRevision) -> Result<(), PlanDocumentError> {
839    require(
840        !revision.revision_id.trim().is_empty(),
841        "revision_id is required",
842    )?;
843    require(
844        !revision.markdown.trim().is_empty(),
845        "editable markdown is required",
846    )?;
847    require(
848        !revision.author.id.trim().is_empty(),
849        "revision author id is required",
850    )?;
851    require(
852        !revision.source.kind.trim().is_empty(),
853        "revision source kind is required",
854    )?;
855    require(
856        !revision.created_at.trim().is_empty(),
857        "revision created_at is required",
858    )?;
859    validate_plan(&revision.plan)?;
860    validate_revision_operation(&revision.operation)?;
861    Ok(())
862}
863
864fn validate_revision_operation(operation: &PlanRevisionOperation) -> Result<(), PlanDocumentError> {
865    let (event_id, comment_id) = match operation {
866        PlanRevisionOperation::Create { event_id } | PlanRevisionOperation::Edit { event_id } => {
867            (event_id, None)
868        }
869        PlanRevisionOperation::Comment {
870            event_id,
871            comment_id,
872        }
873        | PlanRevisionOperation::CommentState {
874            event_id,
875            comment_id,
876            ..
877        } => (event_id, Some(comment_id)),
878    };
879    require(
880        !event_id.trim().is_empty(),
881        "revision operation event_id is required",
882    )?;
883    if let Some(comment_id) = comment_id {
884        require(
885            !comment_id.trim().is_empty(),
886            "revision operation comment_id is required",
887        )?;
888    }
889    Ok(())
890}
891
892fn revision_operation_event_id(operation: &PlanRevisionOperation) -> &str {
893    match operation {
894        PlanRevisionOperation::Create { event_id }
895        | PlanRevisionOperation::Edit { event_id }
896        | PlanRevisionOperation::Comment { event_id, .. }
897        | PlanRevisionOperation::CommentState { event_id, .. } => event_id,
898    }
899}
900
901fn validate_plan(plan: &PlanArtifact) -> Result<(), PlanDocumentError> {
902    require(
903        plan.type_name == "plan_artifact",
904        "plan _type must be plan_artifact",
905    )?;
906    require(
907        plan.schema_version == PLAN_SCHEMA_VERSION,
908        "unsupported executable plan schema_version",
909    )?;
910    require(!plan.id.trim().is_empty(), "plan id is required")?;
911    let mut step_ids = BTreeSet::new();
912    for step in &plan.steps {
913        require(!step.id.trim().is_empty(), "plan step id is required")?;
914        require(
915            step_ids.insert(step.id.as_str()),
916            "plan step id values must be unique",
917        )?;
918        require(
919            !step.content.trim().is_empty(),
920            "plan step content is required",
921        )?;
922        if let Some(priority) = &step.priority {
923            require(
924                priority.is_null()
925                    || priority.is_string()
926                    || priority.is_i64()
927                    || priority.is_u64(),
928                "plan step priority must be a string, integer, or null",
929            )?;
930        }
931    }
932    Ok(())
933}
934
935fn validate_comment(
936    comment: &PlanComment,
937    revision: &PlanRevision,
938) -> Result<(), PlanDocumentError> {
939    require(
940        !comment.comment_id.trim().is_empty(),
941        "comment_id is required",
942    )?;
943    require(!comment.body.trim().is_empty(), "comment body is required")?;
944    require(
945        !comment.author.id.trim().is_empty(),
946        "comment author id is required",
947    )?;
948    let anchor = &comment.anchor;
949    require(
950        anchor.step_id.is_some() || anchor.quoted_text.is_some() || anchor.range.is_some(),
951        "comment anchor requires step_id, quoted_text, or range",
952    )?;
953    let step_matches = anchor
954        .step_id
955        .as_deref()
956        .is_some_and(|step_id| revision.plan.steps.iter().any(|step| step.id == step_id));
957    let quote_is_usable = anchor
958        .quoted_text
959        .as_deref()
960        .is_some_and(|quoted_text| !quoted_text.is_empty());
961    let range_is_usable = anchor.range.as_ref().is_some_and(|range| {
962        range.start < range.end
963            && range.end <= revision.markdown.len()
964            && revision.markdown.is_char_boundary(range.start)
965            && revision.markdown.is_char_boundary(range.end)
966    });
967    require(
968        step_matches || quote_is_usable || range_is_usable,
969        "comment anchor has no usable step, quote, or range fallback",
970    )?;
971    Ok(())
972}
973
974fn require_comment_transition(
975    from: &PlanCommentState,
976    to: &PlanCommentState,
977) -> Result<(), PlanDocumentError> {
978    let allowed = matches!(
979        (from, to),
980        (
981            PlanCommentState::Open | PlanCommentState::Reopened,
982            PlanCommentState::Addressed | PlanCommentState::Resolved
983        ) | (
984            PlanCommentState::Addressed,
985            PlanCommentState::Resolved | PlanCommentState::Reopened
986        ) | (PlanCommentState::Resolved, PlanCommentState::Reopened)
987    );
988    if allowed {
989        Ok(())
990    } else {
991        Err(PlanDocumentError::InvalidCommentTransition {
992            from: from.clone(),
993            to: to.clone(),
994        })
995    }
996}
997
998#[derive(Clone, Copy)]
999struct RevisionState<'a> {
1000    comments: &'a [PlanComment],
1001    receipts: &'a [PlanCommentResolutionReceipt],
1002}
1003
1004fn make_revision(
1005    parent_revision_id: Option<String>,
1006    markdown: String,
1007    plan: PlanArtifact,
1008    author: PlanAuthor,
1009    source: PlanSource,
1010    created_at: String,
1011    operation: PlanRevisionOperation,
1012    state: RevisionState<'_>,
1013) -> Result<PlanRevision, PlanDocumentError> {
1014    let mut revision = PlanRevision {
1015        revision_id: String::new(),
1016        parent_revision_id,
1017        markdown,
1018        plan,
1019        author,
1020        source,
1021        created_at,
1022        operation,
1023    };
1024    revision.revision_id = revision_id(&revision, state)?;
1025    Ok(revision)
1026}
1027
1028fn revision_id(
1029    revision: &PlanRevision,
1030    state: RevisionState<'_>,
1031) -> Result<String, PlanDocumentError> {
1032    let receipt_state = state
1033        .receipts
1034        .iter()
1035        .map(|receipt| {
1036            serde_json::json!({
1037                "comment_id": receipt.comment_id,
1038                "input_revision_id": receipt.input_revision_id,
1039                "agent_run_id": receipt.agent_run_id,
1040                "event_id": receipt.event_id,
1041                "explanation": receipt.explanation,
1042                "created_at": receipt.created_at,
1043            })
1044        })
1045        .collect::<Vec<_>>();
1046    stable_id(
1047        "plan_revision",
1048        &serde_json::json!({
1049            "parent_revision_id": revision.parent_revision_id,
1050            "markdown": revision.markdown,
1051            "plan": revision.plan,
1052            "author": revision.author,
1053            "source": revision.source,
1054            "created_at": revision.created_at,
1055            "operation": revision.operation,
1056            "comments": state.comments,
1057            "resolution_receipts": receipt_state,
1058        }),
1059    )
1060}
1061
1062fn validate_revision_identity(
1063    revision: &PlanRevision,
1064    comments: &[PlanComment],
1065    receipts: &[PlanCommentResolutionReceipt],
1066) -> Result<(), PlanDocumentError> {
1067    let expected = revision_id(revision, RevisionState { comments, receipts })?;
1068    require(
1069        revision.revision_id == expected,
1070        "revision_id does not match immutable document state",
1071    )
1072}
1073
1074fn resolution_receipt_id(
1075    receipt: &PlanCommentResolutionReceipt,
1076) -> Result<String, PlanDocumentError> {
1077    stable_id(
1078        "plan_receipt",
1079        &serde_json::json!({
1080            "comment_id": receipt.comment_id,
1081            "input_revision_id": receipt.input_revision_id,
1082            "output_revision_id": receipt.output_revision_id,
1083            "agent_run_id": receipt.agent_run_id,
1084            "event_id": receipt.event_id,
1085        }),
1086    )
1087}
1088
1089fn stable_id(prefix: &str, value: &serde_json::Value) -> Result<String, PlanDocumentError> {
1090    let canonical = crate::canonical_json::to_vec(value);
1091    let digest = hex::encode(Sha256::digest(canonical));
1092    Ok(format!("{prefix}_{}", &digest[..16]))
1093}
1094
1095fn require(condition: bool, message: &str) -> Result<(), PlanDocumentError> {
1096    if condition {
1097        Ok(())
1098    } else {
1099        Err(PlanDocumentError::Invalid(message.to_string()))
1100    }
1101}
1102
1103pub fn plan_document_json_schema() -> serde_json::Value {
1104    serde_json::json!({
1105        "$schema": "https://json-schema.org/draft/2020-12/schema",
1106        "$id": "https://harnlang.com/schemas/plan-document-v1.json",
1107        "title": "Harn collaborative plan document",
1108        "type": "object",
1109        "additionalProperties": false,
1110        "required": [
1111            "_type", "schema_version", "document_id", "current_revision",
1112            "comments", "resolution_receipts", "created_at", "updated_at"
1113        ],
1114        "properties": {
1115            "_type": {"const": PLAN_DOCUMENT_ARTIFACT_KIND},
1116            "schema_version": {"const": PLAN_DOCUMENT_SCHEMA_VERSION},
1117            "document_id": {"type": "string", "minLength": 1},
1118            "current_revision": {"$ref": "#/$defs/revision"},
1119            "comments": {"type": "array", "items": {"$ref": "#/$defs/comment"}},
1120            "resolution_receipts": {
1121                "type": "array",
1122                "items": {"$ref": "#/$defs/resolution_receipt"}
1123            },
1124            "created_at": {"type": "string", "minLength": 1},
1125            "updated_at": {"type": "string", "minLength": 1}
1126        },
1127        "$defs": {
1128            "author": {
1129                "type": "object",
1130                "additionalProperties": false,
1131                "required": ["id"],
1132                "properties": {
1133                    "id": {"type": "string", "minLength": 1},
1134                    "display_name": {"type": "string"}
1135                }
1136            },
1137            "source": {
1138                "type": "object",
1139                "additionalProperties": false,
1140                "required": ["kind"],
1141                "properties": {
1142                    "kind": {"type": "string", "minLength": 1},
1143                    "uri": {"type": "string"}
1144                }
1145            },
1146            "plan_step": {
1147                "type": "object",
1148                "additionalProperties": false,
1149                "required": ["id", "content", "status", "priority"],
1150                "properties": {
1151                    "id": {"type": "string", "minLength": 1},
1152                    "content": {"type": "string", "minLength": 1},
1153                    "status": {
1154                        "enum": ["pending", "in_progress", "completed", "blocked", "cancelled"]
1155                    },
1156                    "priority": {"type": ["string", "integer", "null"]}
1157                }
1158            },
1159            "approval": {
1160                "type": "object",
1161                "additionalProperties": false,
1162                "required": ["state"],
1163                "properties": {
1164                    "state": {"enum": ["unrequested", "requested", "approved", "rejected"]},
1165                    "request_id": {"type": "string"},
1166                    "reviewer": {"type": "string"},
1167                    "reviewers": {"type": "array", "items": {"type": "string"}},
1168                    "approved_at": {"type": "string"},
1169                    "reason": {"type": "string"}
1170                }
1171            },
1172            "plan_artifact": {
1173                "type": "object",
1174                "additionalProperties": false,
1175                "required": [
1176                    "_type", "schema_version", "id", "tool", "title", "summary",
1177                    "steps", "assumptions", "open_questions", "verification_commands",
1178                    "approval"
1179                ],
1180                "properties": {
1181                    "_type": {"const": "plan_artifact"},
1182                    "schema_version": {"const": PLAN_SCHEMA_VERSION},
1183                    "id": {"type": "string", "minLength": 1},
1184                    "tool": {"type": "string"},
1185                    "title": {"type": "string"},
1186                    "summary": {"type": "string"},
1187                    "steps": {"type": "array", "items": {"$ref": "#/$defs/plan_step"}},
1188                    "assumptions": {"type": "array", "items": {"type": "string"}},
1189                    "open_questions": {"type": "array", "items": {"type": "string"}},
1190                    "verification_commands": {
1191                        "type": "array", "items": {"type": "string"}
1192                    },
1193                    "approval": {"$ref": "#/$defs/approval"}
1194                }
1195            },
1196            "revision": {
1197                "type": "object",
1198                "additionalProperties": false,
1199                "required": [
1200                    "revision_id", "markdown", "plan", "author", "source", "created_at",
1201                    "operation"
1202                ],
1203                "properties": {
1204                    "revision_id": {"type": "string", "minLength": 1},
1205                    "parent_revision_id": {"type": "string", "minLength": 1},
1206                    "markdown": {"type": "string", "minLength": 1},
1207                    "plan": {"$ref": "#/$defs/plan_artifact"},
1208                    "author": {"$ref": "#/$defs/author"},
1209                    "source": {"$ref": "#/$defs/source"},
1210                    "created_at": {"type": "string", "minLength": 1},
1211                    "operation": {
1212                        "type": "object",
1213                        "additionalProperties": false,
1214                        "required": ["kind", "event_id"],
1215                        "properties": {
1216                            "kind": {
1217                                "enum": ["create", "edit", "comment", "comment_state"]
1218                            },
1219                            "event_id": {"type": "string", "minLength": 1},
1220                            "comment_id": {"type": "string", "minLength": 1},
1221                            "state": {
1222                                "enum": ["open", "addressed", "resolved", "reopened"]
1223                            }
1224                        }
1225                    }
1226                }
1227            },
1228            "anchor": {
1229                "type": "object",
1230                "additionalProperties": false,
1231                "properties": {
1232                    "step_id": {"type": "string", "minLength": 1},
1233                    "quoted_text": {"type": "string", "minLength": 1},
1234                    "range": {
1235                        "type": "object",
1236                        "additionalProperties": false,
1237                        "required": ["start", "end"],
1238                        "properties": {
1239                            "start": {"type": "integer", "minimum": 0},
1240                            "end": {"type": "integer", "minimum": 1}
1241                        }
1242                    }
1243                },
1244                "anyOf": [
1245                    {"required": ["step_id"]},
1246                    {"required": ["quoted_text"]},
1247                    {"required": ["range"]}
1248                ]
1249            },
1250            "comment": {
1251                "type": "object",
1252                "additionalProperties": false,
1253                "required": [
1254                    "comment_id", "anchor", "body", "state", "author",
1255                    "created_at", "updated_at"
1256                ],
1257                "properties": {
1258                    "comment_id": {"type": "string", "minLength": 1},
1259                    "anchor": {"$ref": "#/$defs/anchor"},
1260                    "body": {"type": "string", "minLength": 1},
1261                    "state": {"enum": ["open", "addressed", "resolved", "reopened"]},
1262                    "author": {"$ref": "#/$defs/author"},
1263                    "created_at": {"type": "string", "minLength": 1},
1264                    "updated_at": {"type": "string", "minLength": 1}
1265                }
1266            },
1267            "resolution_receipt": {
1268                "type": "object",
1269                "additionalProperties": false,
1270                "required": [
1271                    "receipt_id", "comment_id", "input_revision_id",
1272                    "output_revision_id", "agent_run_id", "event_id", "created_at"
1273                ],
1274                "properties": {
1275                    "receipt_id": {"type": "string", "minLength": 1},
1276                    "comment_id": {"type": "string", "minLength": 1},
1277                    "input_revision_id": {"type": "string", "minLength": 1},
1278                    "output_revision_id": {"type": "string", "minLength": 1},
1279                    "agent_run_id": {"type": "string", "minLength": 1},
1280                    "event_id": {"type": "string", "minLength": 1},
1281                    "explanation": {"type": "string"},
1282                    "created_at": {"type": "string", "minLength": 1}
1283                }
1284            }
1285        }
1286    })
1287}
1288
1289pub fn plan_document_schema_contract() -> BTreeMap<&'static str, &'static str> {
1290    BTreeMap::from([
1291        ("artifact_kind", PLAN_DOCUMENT_ARTIFACT_KIND),
1292        ("schema_version", PLAN_DOCUMENT_SCHEMA_VERSION),
1293    ])
1294}
1295
1296#[cfg(test)]
1297mod tests;