1use serde::{Deserialize, Serialize};
29use std::collections::HashSet;
30
31use super::anchor::{CodeAnchorV1, validate_anchors};
32use super::{require_schema, validate_id, validate_text};
33use crate::{UtcTimestamp, ValidationError};
34
35pub const KNOWLEDGE_REVISION_SCHEMA: &str = "leanctx.hive.knowledge-revision/v1";
36pub const DRAFT_PROPOSAL_SCHEMA: &str = "leanctx.hive.proposal/v1";
37pub const PROPOSAL_ACK_SCHEMA: &str = "leanctx.hive.proposal-ack/v1";
38pub const PROPOSAL_LIST_SCHEMA: &str = "leanctx.hive.proposal-list/v1";
39pub const PROPOSAL_VIEW_SCHEMA: &str = "leanctx.hive.proposal-view/v1";
41
42pub const RESEARCH_CONTEXT_FORMAT: &str = "ctxpkg.research-context.v1";
44pub const FACT_KINDS: [&str; 9] = [
46 "claim",
47 "definition",
48 "decision",
49 "observation",
50 "procedure",
51 "gotcha",
52 "dead_end",
53 "experience",
54 "convention",
55];
56pub const RELATION_TYPES: [&str; 6] = [
59 "supports",
60 "depends_on",
61 "contradicts",
62 "supersedes",
63 "related_to",
64 "explains",
65];
66pub const MAX_PATCH_OPS: usize = 50;
68pub const MAX_FACTS: usize = 5000;
70pub const MAX_RELATIONS: usize = 15_000;
71pub const MAX_REFERENCES: usize = 50_000;
72pub const MAX_DOCUMENT_CHARS: usize = 8_000_000;
74pub const MAX_KNOWLEDGE_BODY_BYTES: usize = 16 * 1024 * 1024;
76pub const MAX_PAGES: usize = 500;
78pub const MAX_PAGE_ITEMS: usize = 200;
79pub const MAX_PROSE_CHARS: usize = 4000;
80
81#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
83#[serde(deny_unknown_fields)]
84pub struct FactV1 {
85 pub id: String,
86 pub statement: String,
87 pub kind: String,
88 #[serde(rename = "appliesTo", default, skip_serializing_if = "Vec::is_empty")]
89 pub applies_to: Vec<String>,
90 #[serde(default, skip_serializing_if = "Vec::is_empty")]
91 pub sources: Vec<String>,
92 #[serde(default, skip_serializing_if = "Vec::is_empty")]
94 pub from_contributions: Vec<String>,
95 #[serde(default, skip_serializing_if = "Vec::is_empty")]
97 pub anchors: Vec<CodeAnchorV1>,
98}
99
100#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
102#[serde(deny_unknown_fields, tag = "kind", rename_all = "snake_case")]
103pub enum PageItemV1 {
104 Fact { id: String },
106 Prose { text: String },
108}
109
110#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
113#[serde(deny_unknown_fields)]
114pub struct PageV1 {
115 pub id: String,
116 pub title: String,
117 #[serde(default)]
118 pub items: Vec<PageItemV1>,
119}
120
121#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)]
123#[serde(deny_unknown_fields)]
124pub struct RelationV1 {
125 pub from: String,
126 pub to: String,
127 #[serde(rename = "type")]
128 pub kind: String,
129}
130
131#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
133#[serde(deny_unknown_fields)]
134pub struct ResearchContextDocumentV1 {
135 pub format: String,
136 #[serde(default)]
137 pub facts: Vec<FactV1>,
138 #[serde(default)]
139 pub relations: Vec<RelationV1>,
140 #[serde(default, skip_serializing_if = "Vec::is_empty")]
141 pub open_gaps: Vec<String>,
142 #[serde(default, skip_serializing_if = "Vec::is_empty")]
144 pub pages: Vec<PageV1>,
145}
146
147impl Default for ResearchContextDocumentV1 {
148 fn default() -> Self {
149 Self {
150 format: RESEARCH_CONTEXT_FORMAT.into(),
151 facts: Vec::new(),
152 relations: Vec::new(),
153 open_gaps: Vec::new(),
154 pages: Vec::new(),
155 }
156 }
157}
158
159#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
161#[serde(deny_unknown_fields, tag = "op", rename_all = "snake_case")]
162pub enum PatchOpV1 {
163 AddFact {
164 fact: FactV1,
165 },
166 UpdateFact {
167 id: String,
168 fact: FactV1,
169 },
170 RetractFact {
173 id: String,
174 },
175 AddRelation {
176 relation: RelationV1,
177 },
178 RemoveRelation {
179 relation: RelationV1,
180 },
181 AddGap {
182 text: String,
183 },
184 CloseGap {
185 text: String,
186 },
187 AddPage {
189 page: PageV1,
190 },
191 UpdatePage {
193 id: String,
194 page: PageV1,
195 },
196 RemovePage {
198 id: String,
199 },
200}
201
202pub(crate) fn fact_id_ok(id: &str) -> bool {
203 let bytes = id.as_bytes();
204 !bytes.is_empty()
205 && bytes.len() <= 100
206 && bytes[0].is_ascii_alphanumeric()
207 && bytes
208 .iter()
209 .all(|byte| byte.is_ascii_alphanumeric() || matches!(byte, b'.' | b'_' | b':' | b'-'))
210}
211
212fn unique_texts(
213 field: &str,
214 items: &[String],
215 max_items: usize,
216 max_chars: usize,
217) -> Result<(), ValidationError> {
218 if items.len() > max_items {
219 return Err(ValidationError::new(format!(
220 "{field} allows at most {max_items} entries"
221 )));
222 }
223 let mut seen = HashSet::new();
224 for item in items {
225 validate_text(item, max_chars, field, false)?;
226 if !seen.insert(item) {
227 return Err(ValidationError::new(format!(
228 "{field} must not contain duplicates"
229 )));
230 }
231 }
232 Ok(())
233}
234
235impl FactV1 {
236 pub fn validate(&self) -> Result<(), ValidationError> {
238 if !fact_id_ok(&self.id) {
239 return Err(ValidationError::new(format!(
240 "fact id {:?} may contain only letters, numbers, dot, underscore, colon and hyphen (max 100)",
241 self.id
242 )));
243 }
244 if !FACT_KINDS.contains(&self.kind.as_str()) {
245 return Err(ValidationError::new(format!(
246 "fact {:?} has unsupported kind {:?}",
247 self.id, self.kind
248 )));
249 }
250 validate_text(&self.statement, 2000, "statement", true)?;
251 unique_texts("appliesTo", &self.applies_to, 16, 500)?;
252 unique_texts("sources", &self.sources, 16, 2000)?;
253 if self.from_contributions.len() > 16 {
254 return Err(ValidationError::new(
255 "from_contributions allows at most 16 entries",
256 ));
257 }
258 validate_anchors(&self.anchors)
259 }
260}
261
262impl PageV1 {
263 pub fn validate(&self) -> Result<(), ValidationError> {
266 if !fact_id_ok(&self.id) {
267 return Err(ValidationError::new("page id is malformed"));
268 }
269 validate_text(&self.title, 200, "page title", false)?;
270 if self.items.is_empty() || self.items.len() > MAX_PAGE_ITEMS {
271 return Err(ValidationError::new(format!(
272 "a page has 1-{MAX_PAGE_ITEMS} items"
273 )));
274 }
275 for item in &self.items {
276 match item {
277 PageItemV1::Fact { id } if !fact_id_ok(id) => {
278 return Err(ValidationError::new("page fact reference is malformed"));
279 }
280 PageItemV1::Prose { text } => {
281 validate_text(text, MAX_PROSE_CHARS, "prose", true)?;
282 }
283 PageItemV1::Fact { .. } => {}
284 }
285 }
286 Ok(())
287 }
288}
289
290impl RelationV1 {
291 fn validate_kind(&self) -> Result<(), ValidationError> {
292 if !RELATION_TYPES.contains(&self.kind.as_str()) {
293 return Err(ValidationError::new(format!(
294 "relation type {:?} is not supported",
295 self.kind
296 )));
297 }
298 if self.from == self.to {
299 return Err(ValidationError::new("relation points a fact to itself"));
300 }
301 Ok(())
302 }
303}
304
305impl ResearchContextDocumentV1 {
306 pub fn validate(&self) -> Result<(), ValidationError> {
308 if self.format != RESEARCH_CONTEXT_FORMAT {
309 return Err(ValidationError::new(format!(
310 "format must be {RESEARCH_CONTEXT_FORMAT}"
311 )));
312 }
313 if self.facts.len() > MAX_FACTS || self.relations.len() > MAX_RELATIONS {
314 return Err(ValidationError::new(format!(
315 "at most {MAX_FACTS} facts and {MAX_RELATIONS} relations"
316 )));
317 }
318 unique_texts("open_gaps", &self.open_gaps, 20, 500)?;
319 let mut ids = HashSet::new();
320 let mut references = 0usize;
321 for fact in &self.facts {
322 fact.validate()?;
323 if !ids.insert(fact.id.as_str()) {
324 return Err(ValidationError::new(format!(
325 "fact id {:?} is not unique",
326 fact.id
327 )));
328 }
329 references += fact.applies_to.len() + fact.sources.len();
330 }
331 if references > MAX_REFERENCES {
332 return Err(ValidationError::new(format!(
333 "too many applicability and source references (max {MAX_REFERENCES})"
334 )));
335 }
336 let mut edges = HashSet::new();
337 for relation in &self.relations {
338 relation.validate_kind()?;
339 if !ids.contains(relation.from.as_str()) || !ids.contains(relation.to.as_str()) {
340 return Err(ValidationError::new("relation points to a missing fact"));
341 }
342 if !edges.insert(relation) {
343 return Err(ValidationError::new("relation repeats"));
344 }
345 }
346 if self.pages.len() > MAX_PAGES {
347 return Err(ValidationError::new(format!("at most {MAX_PAGES} pages")));
348 }
349 let mut page_ids = HashSet::new();
350 for page in &self.pages {
351 page.validate()?;
352 if !page_ids.insert(page.id.as_str()) {
353 return Err(ValidationError::new("page id is not unique"));
354 }
355 for item in &page.items {
356 if let PageItemV1::Fact { id } = item
357 && !ids.contains(id.as_str())
358 {
359 return Err(ValidationError::new("a page shows a missing fact"));
360 }
361 }
362 }
363 let chars = serde_json::to_string(self)
364 .map_err(|error| ValidationError::new(format!("document: {error}")))?
365 .chars()
366 .count();
367 if chars > MAX_DOCUMENT_CHARS {
368 return Err(ValidationError::new(format!(
369 "the document exceeds {MAX_DOCUMENT_CHARS} characters"
370 )));
371 }
372 Ok(())
373 }
374}
375
376impl PatchOpV1 {
377 pub fn validate(&self) -> Result<(), ValidationError> {
379 let fact_ok = validate_patch_fact;
380 match self {
381 Self::AddFact { fact } => fact_ok(fact),
382 Self::UpdateFact { id, fact } => {
383 if id != &fact.id {
384 return Err(ValidationError::new("update_fact id must match fact.id"));
385 }
386 fact_ok(fact)
387 }
388 Self::RetractFact { id } => {
389 if fact_id_ok(id) {
390 Ok(())
391 } else {
392 Err(ValidationError::new("retract_fact id is malformed"))
393 }
394 }
395 Self::AddRelation { relation } | Self::RemoveRelation { relation } => {
396 relation.validate_kind()
397 }
398 Self::AddGap { text } | Self::CloseGap { text } => {
399 validate_text(text, 500, "gap", false)
400 }
401 Self::AddPage { page } => page.validate(),
402 Self::UpdatePage { id, page } => {
403 if id != &page.id {
404 return Err(ValidationError::new("update_page id must match page.id"));
405 }
406 page.validate()
407 }
408 Self::RemovePage { id } => {
409 if fact_id_ok(id) {
410 Ok(())
411 } else {
412 Err(ValidationError::new("remove_page id is malformed"))
413 }
414 }
415 }
416 }
417}
418
419fn validate_patch_fact(fact: &FactV1) -> Result<(), ValidationError> {
420 if !fact.from_contributions.is_empty() {
421 return Err(ValidationError::new(
422 "from_contributions is assigned by Hive and never sent in a patch",
423 ));
424 }
425 fact.validate()
426}
427
428#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Default, Serialize, Deserialize)]
430#[serde(rename_all = "snake_case")]
431pub enum RevisionState {
432 #[default]
433 Released,
434 Quarantined,
436 RolledBack,
438 #[serde(other)]
439 Unknown,
440}
441
442impl RevisionState {
443 fn is_released(&self) -> bool {
444 *self == Self::Released
445 }
446}
447
448fn is_zero(value: &u64) -> bool {
449 *value == 0
450}
451
452#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
455pub struct KnowledgeRevisionV1 {
456 pub schema: String,
457 pub vault_id: String,
458 pub revision: u64,
459 #[serde(default, skip_serializing_if = "Option::is_none")]
460 pub document: Option<ResearchContextDocumentV1>,
461 pub updated_at: UtcTimestamp,
462 #[serde(default, skip_serializing_if = "RevisionState::is_released")]
464 pub state: RevisionState,
465 #[serde(default, skip_serializing_if = "is_zero")]
467 pub quarantined_after: u64,
468 #[serde(default, skip_serializing_if = "Vec::is_empty")]
470 pub fact_states: Vec<super::facts::FactStateV1>,
471 #[serde(default, skip_serializing_if = "Option::is_none")]
476 pub ctxpkg_tree: Option<String>,
477 #[serde(default, skip_serializing_if = "Option::is_none")]
478 pub ctxpkg_commit: Option<String>,
479}
480
481impl KnowledgeRevisionV1 {
482 pub fn validate(&self) -> Result<(), ValidationError> {
483 require_schema(&self.schema, KNOWLEDGE_REVISION_SCHEMA)?;
484 super::validate_vault_id(&self.vault_id)?;
485 if self.state != RevisionState::Released && self.quarantined_after != 0 {
486 return Err(ValidationError::new(
487 "quarantined_after counts after a delivered head only",
488 ));
489 }
490 let document = self
491 .document
492 .as_ref()
493 .ok_or_else(|| ValidationError::new("a revision carries its document"))?;
494 document.validate()?;
495 let ids: HashSet<&str> = document.facts.iter().map(|f| f.id.as_str()).collect();
496 for (i, state) in self.fact_states.iter().enumerate() {
497 state.validate()?;
498 if !ids.contains(state.fact.as_str()) {
499 return Err(ValidationError::new("a fact state names a missing fact"));
500 }
501 if i > 0 && self.fact_states[i - 1].fact >= state.fact {
502 return Err(ValidationError::new(
503 "fact_states must be sorted by fact without repeats",
504 ));
505 }
506 }
507 Ok(())
508 }
509}
510
511#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
513#[serde(rename_all = "snake_case")]
514pub enum ProposalState {
515 Draft,
516 Open,
517 Merged,
518 Rejected,
519 Withdrawn,
521 Ready,
523 #[serde(other)]
524 Unknown,
525}
526
527#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
529#[serde(deny_unknown_fields)]
530pub struct ContextPatchV1 {
531 pub base_revision: u64,
532 #[serde(default, skip_serializing_if = "Vec::is_empty")]
533 pub ops: Vec<PatchOpV1>,
534}
535
536#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
538#[serde(deny_unknown_fields)]
539pub struct EngineScreeningV1 {
540 pub scanner_version: u32,
541 pub secrets_removed: u32,
542 pub pii_removed: u32,
543 pub items_dropped: u32,
544}
545
546#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
548#[serde(deny_unknown_fields)]
549pub struct DraftProposalV1 {
550 pub schema: String,
551 pub state: ProposalState,
553 #[serde(default, skip_serializing_if = "Option::is_none")]
555 pub summary: Option<String>,
556 #[serde(default, skip_serializing_if = "Option::is_none")]
558 pub body: Option<String>,
559 #[serde(default, skip_serializing_if = "Vec::is_empty")]
561 pub sources: Vec<String>,
562 pub context_patch: ContextPatchV1,
563 pub session_ref: String,
565 pub engine_screening: EngineScreeningV1,
566 #[serde(default, skip_serializing_if = "Option::is_none")]
568 pub origin: Option<DraftOrigin>,
569}
570
571#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
574#[serde(rename_all = "snake_case")]
575pub enum DraftOrigin {
576 Learned,
578 Import,
581 #[serde(other)]
582 Unknown,
583}
584
585pub fn import_source_ok(source: &str) -> bool {
588 if let Some(sha) = source.strip_prefix("import:git:") {
589 return matches!(sha.len(), 40 | 64)
590 && sha
591 .bytes()
592 .all(|b| b.is_ascii_digit() || (b'a'..=b'f').contains(&b));
593 }
594 let Some(rest) = source.strip_prefix("import:docs:") else {
595 return false;
596 };
597 let Some((path, slug)) = rest.rsplit_once('#') else {
598 return false;
599 };
600 !slug.is_empty()
601 && slug
602 .bytes()
603 .all(|b| b.is_ascii_lowercase() || b.is_ascii_digit() || b == b'-')
604 && !path.is_empty()
605 && !path.starts_with('/')
606 && !path.contains('\\')
607 && path
608 .split('/')
609 .all(|part| !part.is_empty() && part != "." && part != "..")
610}
611
612impl DraftProposalV1 {
613 pub fn validate(&self) -> Result<(), ValidationError> {
614 require_schema(&self.schema, DRAFT_PROPOSAL_SCHEMA)?;
615 if self.state != ProposalState::Draft {
616 return Err(ValidationError::new("engine proposals are drafts"));
617 }
618 if let Some(summary) = &self.summary {
619 validate_text(summary, 200, "summary", false)?;
620 }
621 if let Some(body) = &self.body {
622 validate_text(body, 20_000, "body", true)?;
623 }
624 unique_texts("sources", &self.sources, 16, 2000)?;
625 match self.origin {
626 Some(DraftOrigin::Import) => {
627 if self.sources.is_empty() {
628 return Err(ValidationError::new("an import names its sources"));
629 }
630 if !self.sources.iter().all(|s| import_source_ok(s)) {
631 return Err(ValidationError::new(
632 "import sources are import:docs:<path>#<slug> or import:git:<sha>",
633 ));
634 }
635 }
636 Some(DraftOrigin::Unknown) => {
637 return Err(ValidationError::new("unknown draft origin"));
638 }
639 Some(DraftOrigin::Learned) | None => {}
640 }
641 validate_id(&self.session_ref, "session_ref")?;
642 if self.summary.is_none() {
643 return Err(ValidationError::new("a draft needs a summary"));
644 }
645 let ops = &self.context_patch.ops;
646 if ops.is_empty() {
647 return Err(ValidationError::new("a patch carries ops"));
648 }
649 if ops.len() > MAX_PATCH_OPS {
650 return Err(ValidationError::new(format!(
651 "a patch carries 1-{MAX_PATCH_OPS} ops"
652 )));
653 }
654 ops.iter().try_for_each(PatchOpV1::validate)
655 }
656}
657
658#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
660pub struct ProposalAckV1 {
661 pub schema: String,
662 pub number: u64,
663 pub state: ProposalState,
664}
665
666#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
668pub struct ProposalSummaryV1 {
669 pub number: u64,
670 pub state: ProposalState,
671 pub summary: String,
672 pub base_revision: u64,
673 pub created_at: UtcTimestamp,
674 #[serde(default, skip_serializing_if = "Option::is_none")]
676 pub merged_revision: Option<u64>,
677 pub mine: bool,
679}
680
681#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
683pub struct ProposalListV1 {
684 pub schema: String,
685 pub vault_id: String,
686 pub proposals: Vec<ProposalSummaryV1>,
687}
688
689#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
691pub struct ProposalV1 {
692 pub schema: String,
693 pub number: u64,
694 pub state: ProposalState,
695 pub created_at: UtcTimestamp,
696 pub mine: bool,
698 #[serde(default, skip_serializing_if = "Option::is_none")]
700 pub merged_revision: Option<u64>,
701 pub draft: DraftProposalV1,
703 #[serde(default, skip_serializing_if = "Option::is_none")]
706 pub decided_by: Option<super::tasks::DecidedByV1>,
707}
708
709impl ProposalV1 {
710 pub fn validate(&self) -> Result<(), ValidationError> {
711 require_schema(&self.schema, PROPOSAL_VIEW_SCHEMA)?;
712 self.draft.validate()
713 }
714}
715
716#[cfg(test)]
717mod tests {
718 use super::*;
719
720 fn fact(id: &str) -> FactV1 {
721 FactV1 {
722 id: id.into(),
723 statement: "Hive never blocks a tool call.".into(),
724 kind: "decision".into(),
725 applies_to: vec![],
726 sources: vec![],
727 from_contributions: vec![],
728 anchors: vec![],
729 }
730 }
731
732 #[test]
733 fn documents_reject_dangling_and_repeated_relations() {
734 let mut document = ResearchContextDocumentV1 {
735 facts: vec![fact("a"), fact("b")],
736 ..Default::default()
737 };
738 let edge = RelationV1 {
739 from: "a".into(),
740 to: "b".into(),
741 kind: "supports".into(),
742 };
743 document.relations = vec![edge.clone()];
744 assert!(document.validate().is_ok());
745 document.relations.push(edge);
746 assert!(document.validate().is_err());
747 document.relations = vec![RelationV1 {
748 from: "a".into(),
749 to: "missing".into(),
750 kind: "supports".into(),
751 }];
752 assert!(document.validate().is_err());
753 }
754
755 #[test]
756 fn patches_never_carry_credits() {
757 let mut credited = fact("a");
758 credited.from_contributions = vec!["p1".into()];
759 assert!(PatchOpV1::AddFact { fact: credited }.validate().is_err());
760 assert!(PatchOpV1::AddFact { fact: fact("a") }.validate().is_ok());
761 }
762}