1use 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 != ¤t.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 ¤t.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;