1use serde::{Deserialize, Serialize};
44use std::collections::HashSet;
45
46use super::anchor::{CodeAnchorV1, validate_anchors};
47use super::sealed::SealedBlobV1;
48use super::{require_schema, validate_hex64, validate_id, validate_text};
49use crate::{UtcTimestamp, ValidationError};
50
51pub const KNOWLEDGE_REVISION_SCHEMA: &str = "leanctx.hive.knowledge-revision/v1";
52pub const REVISION_LIST_SCHEMA: &str = "leanctx.hive.revision-list/v1";
53pub const DRAFT_PROPOSAL_SCHEMA: &str = "leanctx.hive.proposal/v1";
54pub const PROPOSAL_ACK_SCHEMA: &str = "leanctx.hive.proposal-ack/v1";
55pub const PROPOSAL_LIST_SCHEMA: &str = "leanctx.hive.proposal-list/v1";
56pub const KNOWLEDGE_PUT_REQUEST_SCHEMA: &str = "leanctx.hive.knowledge-put-request/v1";
57pub const PROPOSAL_VIEW_SCHEMA: &str = "leanctx.hive.proposal-view/v1";
59pub const MAX_MERGED_PROPOSALS: usize = 50;
61
62pub const RESEARCH_CONTEXT_FORMAT: &str = "ctxpkg.research-context.v1";
64pub const FACT_KINDS: [&str; 9] = [
66 "claim",
67 "definition",
68 "decision",
69 "observation",
70 "procedure",
71 "gotcha",
72 "dead_end",
73 "experience",
74 "convention",
75];
76pub const RELATION_TYPES: [&str; 6] = [
79 "supports",
80 "depends_on",
81 "contradicts",
82 "supersedes",
83 "related_to",
84 "explains",
85];
86pub const MAX_PATCH_OPS: usize = 50;
88pub const MAX_FACTS: usize = 5000;
91pub const MAX_RELATIONS: usize = 15_000;
92pub const MAX_REFERENCES: usize = 50_000;
93pub const MAX_DOCUMENT_CHARS: usize = 8_000_000;
95pub const MAX_KNOWLEDGE_BODY_BYTES: usize = 16 * 1024 * 1024;
97pub const MAX_PAGES: usize = 500;
99pub const MAX_PAGE_ITEMS: usize = 200;
100pub const MAX_PROSE_CHARS: usize = 4000;
101pub const MAX_REVISIONS_PAGE: usize = 100;
103
104#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
106#[serde(deny_unknown_fields)]
107pub struct FactV1 {
108 pub id: String,
109 pub statement: String,
110 pub kind: String,
111 #[serde(rename = "appliesTo", default, skip_serializing_if = "Vec::is_empty")]
112 pub applies_to: Vec<String>,
113 #[serde(default, skip_serializing_if = "Vec::is_empty")]
114 pub sources: Vec<String>,
115 #[serde(default, skip_serializing_if = "Vec::is_empty")]
117 pub from_contributions: Vec<String>,
118 #[serde(default, skip_serializing_if = "Vec::is_empty")]
120 pub anchors: Vec<CodeAnchorV1>,
121}
122
123#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
125#[serde(deny_unknown_fields, tag = "kind", rename_all = "snake_case")]
126pub enum PageItemV1 {
127 Fact { id: String },
129 Prose { text: String },
131}
132
133#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
136#[serde(deny_unknown_fields)]
137pub struct PageV1 {
138 pub id: String,
139 pub title: String,
140 #[serde(default)]
141 pub items: Vec<PageItemV1>,
142}
143
144#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)]
146#[serde(deny_unknown_fields)]
147pub struct RelationV1 {
148 pub from: String,
149 pub to: String,
150 #[serde(rename = "type")]
151 pub kind: String,
152}
153
154#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
156#[serde(deny_unknown_fields)]
157pub struct ResearchContextDocumentV1 {
158 pub format: String,
159 #[serde(default)]
160 pub facts: Vec<FactV1>,
161 #[serde(default)]
162 pub relations: Vec<RelationV1>,
163 #[serde(default, skip_serializing_if = "Vec::is_empty")]
164 pub open_gaps: Vec<String>,
165 #[serde(default, skip_serializing_if = "Vec::is_empty")]
167 pub pages: Vec<PageV1>,
168}
169
170impl Default for ResearchContextDocumentV1 {
171 fn default() -> Self {
172 Self {
173 format: RESEARCH_CONTEXT_FORMAT.into(),
174 facts: Vec::new(),
175 relations: Vec::new(),
176 open_gaps: Vec::new(),
177 pages: Vec::new(),
178 }
179 }
180}
181
182#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
184#[serde(deny_unknown_fields, tag = "op", rename_all = "snake_case")]
185pub enum PatchOpV1 {
186 AddFact {
187 fact: FactV1,
188 },
189 UpdateFact {
190 id: String,
191 fact: FactV1,
192 },
193 RetractFact {
196 id: String,
197 },
198 AddRelation {
199 relation: RelationV1,
200 },
201 RemoveRelation {
202 relation: RelationV1,
203 },
204 AddGap {
205 text: String,
206 },
207 CloseGap {
208 text: String,
209 },
210 AddPage {
212 page: PageV1,
213 },
214 UpdatePage {
216 id: String,
217 page: PageV1,
218 },
219 RemovePage {
221 id: String,
222 },
223}
224
225pub(crate) fn fact_id_ok(id: &str) -> bool {
226 let bytes = id.as_bytes();
227 !bytes.is_empty()
228 && bytes.len() <= 100
229 && bytes[0].is_ascii_alphanumeric()
230 && bytes
231 .iter()
232 .all(|byte| byte.is_ascii_alphanumeric() || matches!(byte, b'.' | b'_' | b':' | b'-'))
233}
234
235fn unique_texts(
236 field: &str,
237 items: &[String],
238 max_items: usize,
239 max_chars: usize,
240) -> Result<(), ValidationError> {
241 if items.len() > max_items {
242 return Err(ValidationError::new(format!(
243 "{field} allows at most {max_items} entries"
244 )));
245 }
246 let mut seen = HashSet::new();
247 for item in items {
248 validate_text(item, max_chars, field, false)?;
249 if !seen.insert(item) {
250 return Err(ValidationError::new(format!(
251 "{field} must not contain duplicates"
252 )));
253 }
254 }
255 Ok(())
256}
257
258impl FactV1 {
259 pub fn validate(&self) -> Result<(), ValidationError> {
261 if !fact_id_ok(&self.id) {
262 return Err(ValidationError::new(format!(
263 "fact id {:?} may contain only letters, numbers, dot, underscore, colon and hyphen (max 100)",
264 self.id
265 )));
266 }
267 if !FACT_KINDS.contains(&self.kind.as_str()) {
268 return Err(ValidationError::new(format!(
269 "fact {:?} has unsupported kind {:?}",
270 self.id, self.kind
271 )));
272 }
273 validate_text(&self.statement, 2000, "statement", true)?;
274 unique_texts("appliesTo", &self.applies_to, 16, 500)?;
275 unique_texts("sources", &self.sources, 16, 2000)?;
276 if self.from_contributions.len() > 16 {
277 return Err(ValidationError::new(
278 "from_contributions allows at most 16 entries",
279 ));
280 }
281 validate_anchors(&self.anchors)
282 }
283}
284
285impl PageV1 {
286 pub fn validate(&self) -> Result<(), ValidationError> {
289 if !fact_id_ok(&self.id) {
290 return Err(ValidationError::new("page id is malformed"));
291 }
292 validate_text(&self.title, 200, "page title", false)?;
293 if self.items.is_empty() || self.items.len() > MAX_PAGE_ITEMS {
294 return Err(ValidationError::new(format!(
295 "a page has 1-{MAX_PAGE_ITEMS} items"
296 )));
297 }
298 for item in &self.items {
299 match item {
300 PageItemV1::Fact { id } if !fact_id_ok(id) => {
301 return Err(ValidationError::new("page fact reference is malformed"));
302 }
303 PageItemV1::Prose { text } => {
304 validate_text(text, MAX_PROSE_CHARS, "prose", true)?;
305 }
306 PageItemV1::Fact { .. } => {}
307 }
308 }
309 Ok(())
310 }
311}
312
313impl RelationV1 {
314 fn validate_kind(&self) -> Result<(), ValidationError> {
315 if !RELATION_TYPES.contains(&self.kind.as_str()) {
316 return Err(ValidationError::new(format!(
317 "relation type {:?} is not supported",
318 self.kind
319 )));
320 }
321 if self.from == self.to {
322 return Err(ValidationError::new("relation points a fact to itself"));
323 }
324 Ok(())
325 }
326}
327
328impl ResearchContextDocumentV1 {
329 pub fn validate(&self) -> Result<(), ValidationError> {
331 if self.format != RESEARCH_CONTEXT_FORMAT {
332 return Err(ValidationError::new(format!(
333 "format must be {RESEARCH_CONTEXT_FORMAT}"
334 )));
335 }
336 if self.facts.len() > MAX_FACTS || self.relations.len() > MAX_RELATIONS {
337 return Err(ValidationError::new(format!(
338 "at most {MAX_FACTS} facts and {MAX_RELATIONS} relations"
339 )));
340 }
341 unique_texts("open_gaps", &self.open_gaps, 20, 500)?;
342 let mut ids = HashSet::new();
343 let mut references = 0usize;
344 for fact in &self.facts {
345 fact.validate()?;
346 if !ids.insert(fact.id.as_str()) {
347 return Err(ValidationError::new(format!(
348 "fact id {:?} is not unique",
349 fact.id
350 )));
351 }
352 references += fact.applies_to.len() + fact.sources.len();
353 }
354 if references > MAX_REFERENCES {
355 return Err(ValidationError::new(format!(
356 "too many applicability and source references (max {MAX_REFERENCES})"
357 )));
358 }
359 let mut edges = HashSet::new();
360 for relation in &self.relations {
361 relation.validate_kind()?;
362 if !ids.contains(relation.from.as_str()) || !ids.contains(relation.to.as_str()) {
363 return Err(ValidationError::new("relation points to a missing fact"));
364 }
365 if !edges.insert(relation) {
366 return Err(ValidationError::new("relation repeats"));
367 }
368 }
369 if self.pages.len() > MAX_PAGES {
370 return Err(ValidationError::new(format!("at most {MAX_PAGES} pages")));
371 }
372 let mut page_ids = HashSet::new();
373 for page in &self.pages {
374 page.validate()?;
375 if !page_ids.insert(page.id.as_str()) {
376 return Err(ValidationError::new("page id is not unique"));
377 }
378 for item in &page.items {
379 if let PageItemV1::Fact { id } = item
380 && !ids.contains(id.as_str())
381 {
382 return Err(ValidationError::new("a page shows a missing fact"));
383 }
384 }
385 }
386 let chars = serde_json::to_string(self)
387 .map_err(|error| ValidationError::new(format!("document: {error}")))?
388 .chars()
389 .count();
390 if chars > MAX_DOCUMENT_CHARS {
391 return Err(ValidationError::new(format!(
392 "the document exceeds {MAX_DOCUMENT_CHARS} characters"
393 )));
394 }
395 Ok(())
396 }
397}
398
399impl PatchOpV1 {
400 pub fn validate(&self) -> Result<(), ValidationError> {
402 self.check(false)
403 }
404
405 pub fn validate_authored(&self) -> Result<(), ValidationError> {
408 self.check(true)
409 }
410
411 fn check(&self, authored: bool) -> Result<(), ValidationError> {
412 let fact_ok = |fact: &FactV1| {
413 if authored {
414 fact.validate()
415 } else {
416 validate_patch_fact(fact)
417 }
418 };
419 match self {
420 Self::AddFact { fact } => fact_ok(fact),
421 Self::UpdateFact { id, fact } => {
422 if id != &fact.id {
423 return Err(ValidationError::new("update_fact id must match fact.id"));
424 }
425 fact_ok(fact)
426 }
427 Self::RetractFact { id } => {
428 if fact_id_ok(id) {
429 Ok(())
430 } else {
431 Err(ValidationError::new("retract_fact id is malformed"))
432 }
433 }
434 Self::AddRelation { relation } | Self::RemoveRelation { relation } => {
435 relation.validate_kind()
436 }
437 Self::AddGap { text } | Self::CloseGap { text } => {
438 validate_text(text, 500, "gap", false)
439 }
440 Self::AddPage { page } => page.validate(),
441 Self::UpdatePage { id, page } => {
442 if id != &page.id {
443 return Err(ValidationError::new("update_page id must match page.id"));
444 }
445 page.validate()
446 }
447 Self::RemovePage { id } => {
448 if fact_id_ok(id) {
449 Ok(())
450 } else {
451 Err(ValidationError::new("remove_page id is malformed"))
452 }
453 }
454 }
455 }
456}
457
458fn validate_patch_fact(fact: &FactV1) -> Result<(), ValidationError> {
459 if !fact.from_contributions.is_empty() {
460 return Err(ValidationError::new(
461 "from_contributions is assigned by Hive and never sent in a patch",
462 ));
463 }
464 fact.validate()
465}
466
467#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Default, Serialize, Deserialize)]
469#[serde(rename_all = "snake_case")]
470pub enum RevisionState {
471 #[default]
472 Released,
473 Quarantined,
475 RolledBack,
477 #[serde(other)]
478 Unknown,
479}
480
481impl RevisionState {
482 fn is_released(&self) -> bool {
483 *self == Self::Released
484 }
485}
486
487fn is_zero(value: &u64) -> bool {
488 *value == 0
489}
490
491#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
493pub struct KnowledgeRevisionV1 {
494 pub schema: String,
495 pub vault_id: String,
496 pub revision: u64,
497 #[serde(default, skip_serializing_if = "Option::is_none")]
498 pub document: Option<ResearchContextDocumentV1>,
499 #[serde(default, skip_serializing_if = "Option::is_none")]
500 pub sealed: Option<SealedBlobV1>,
501 pub updated_at: UtcTimestamp,
502 #[serde(default, skip_serializing_if = "RevisionState::is_released")]
504 pub state: RevisionState,
505 #[serde(default, skip_serializing_if = "is_zero")]
507 pub quarantined_after: u64,
508 #[serde(default, skip_serializing_if = "Vec::is_empty")]
511 pub fact_states: Vec<super::facts::FactStateV1>,
512 #[serde(default, skip_serializing_if = "Option::is_none")]
517 pub ctxpkg_tree: Option<String>,
518 #[serde(default, skip_serializing_if = "Option::is_none")]
519 pub ctxpkg_commit: Option<String>,
520}
521
522impl KnowledgeRevisionV1 {
523 pub fn validate(&self) -> Result<(), ValidationError> {
524 require_schema(&self.schema, KNOWLEDGE_REVISION_SCHEMA)?;
525 super::validate_vault_id(&self.vault_id)?;
526 if self.state != RevisionState::Released && self.quarantined_after != 0 {
527 return Err(ValidationError::new(
528 "quarantined_after counts after a delivered head only",
529 ));
530 }
531 match (&self.document, &self.sealed) {
532 (Some(document), None) => {
533 document.validate()?;
534 let ids: HashSet<&str> = document.facts.iter().map(|f| f.id.as_str()).collect();
535 for (i, state) in self.fact_states.iter().enumerate() {
536 state.validate()?;
537 if !ids.contains(state.fact.as_str()) {
538 return Err(ValidationError::new("a fact state names a missing fact"));
539 }
540 if i > 0 && self.fact_states[i - 1].fact >= state.fact {
541 return Err(ValidationError::new(
542 "fact_states must be sorted by fact without repeats",
543 ));
544 }
545 }
546 Ok(())
547 }
548 (None, Some(sealed)) => {
549 if !self.fact_states.is_empty()
550 || self.ctxpkg_tree.is_some()
551 || self.ctxpkg_commit.is_some()
552 {
553 return Err(ValidationError::new(
554 "a sealed revision carries no fact states and no ctxpkg ids",
555 ));
556 }
557 sealed.validate()
558 }
559 _ => Err(ValidationError::new(
560 "a revision carries exactly one of document or sealed",
561 )),
562 }
563 }
564}
565
566#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
568pub struct RevisionSummaryV1 {
569 pub revision: u64,
570 pub created_at: UtcTimestamp,
571 #[serde(default, skip_serializing_if = "Option::is_none")]
573 pub proposal: Option<u64>,
574 pub digest: String,
576 #[serde(default, skip_serializing_if = "RevisionState::is_released")]
578 pub state: RevisionState,
579 #[serde(default, skip_serializing_if = "Option::is_none")]
581 pub rolls_back: Option<u64>,
582 #[serde(default, skip_serializing_if = "Option::is_none")]
587 pub ctxpkg_tree: Option<String>,
588 #[serde(default, skip_serializing_if = "Option::is_none")]
589 pub ctxpkg_commit: Option<String>,
590}
591
592#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
594pub struct RevisionListV1 {
595 pub schema: String,
596 pub vault_id: String,
597 pub revisions: Vec<RevisionSummaryV1>,
598 #[serde(default, skip_serializing_if = "Option::is_none")]
600 pub next_after: Option<u64>,
601}
602
603impl RevisionListV1 {
604 pub fn validate(&self) -> Result<(), ValidationError> {
605 require_schema(&self.schema, REVISION_LIST_SCHEMA)?;
606 super::validate_vault_id(&self.vault_id)?;
607 if self.revisions.len() > MAX_REVISIONS_PAGE {
608 return Err(ValidationError::new("revision page is too long"));
609 }
610 let mut previous = None;
611 for summary in &self.revisions {
612 validate_hex64(&summary.digest, "digest")?;
613 if summary
614 .rolls_back
615 .is_some_and(|target| target >= summary.revision)
616 {
617 return Err(ValidationError::new(
618 "a rollback restores an earlier revision",
619 ));
620 }
621 if previous.is_some_and(|previous| summary.revision <= previous) {
622 return Err(ValidationError::new("revisions must ascend"));
623 }
624 previous = Some(summary.revision);
625 }
626 Ok(())
627 }
628}
629
630#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
632#[serde(rename_all = "snake_case")]
633pub enum ProposalState {
634 Draft,
635 Open,
636 Merged,
637 Rejected,
638 Withdrawn,
640 Ready,
643 #[serde(other)]
644 Unknown,
645}
646
647#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
650#[serde(deny_unknown_fields)]
651pub struct ContextPatchV1 {
652 pub base_revision: u64,
653 #[serde(default, skip_serializing_if = "Vec::is_empty")]
654 pub ops: Vec<PatchOpV1>,
655 #[serde(default, skip_serializing_if = "Option::is_none")]
656 pub sealed: Option<SealedBlobV1>,
657}
658
659#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
661#[serde(deny_unknown_fields)]
662pub struct EngineScreeningV1 {
663 pub scanner_version: u32,
664 pub secrets_removed: u32,
665 pub pii_removed: u32,
666 pub items_dropped: u32,
667}
668
669#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
671#[serde(deny_unknown_fields)]
672pub struct DraftProposalV1 {
673 pub schema: String,
674 pub state: ProposalState,
676 #[serde(default, skip_serializing_if = "Option::is_none")]
680 pub summary: Option<String>,
681 #[serde(default, skip_serializing_if = "Option::is_none")]
683 pub body: Option<String>,
684 #[serde(default, skip_serializing_if = "Vec::is_empty")]
686 pub sources: Vec<String>,
687 pub context_patch: ContextPatchV1,
688 pub session_ref: String,
690 pub engine_screening: EngineScreeningV1,
691 #[serde(default, skip_serializing_if = "Option::is_none")]
694 pub origin: Option<DraftOrigin>,
695}
696
697#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
700#[serde(rename_all = "snake_case")]
701pub enum DraftOrigin {
702 Learned,
704 Import,
709 #[serde(other)]
710 Unknown,
711}
712
713pub fn import_source_ok(source: &str) -> bool {
716 if let Some(sha) = source.strip_prefix("import:git:") {
717 return matches!(sha.len(), 40 | 64)
718 && sha
719 .bytes()
720 .all(|b| b.is_ascii_digit() || (b'a'..=b'f').contains(&b));
721 }
722 let Some(rest) = source.strip_prefix("import:docs:") else {
723 return false;
724 };
725 let Some((path, slug)) = rest.rsplit_once('#') else {
726 return false;
727 };
728 !slug.is_empty()
729 && slug
730 .bytes()
731 .all(|b| b.is_ascii_lowercase() || b.is_ascii_digit() || b == b'-')
732 && !path.is_empty()
733 && !path.starts_with('/')
734 && !path.contains('\\')
735 && path
736 .split('/')
737 .all(|part| !part.is_empty() && part != "." && part != "..")
738}
739
740impl DraftProposalV1 {
741 pub fn validate(&self) -> Result<(), ValidationError> {
742 require_schema(&self.schema, DRAFT_PROPOSAL_SCHEMA)?;
743 if self.state != ProposalState::Draft {
744 return Err(ValidationError::new("engine proposals are drafts"));
745 }
746 if let Some(summary) = &self.summary {
747 validate_text(summary, 200, "summary", false)?;
748 }
749 if let Some(body) = &self.body {
750 validate_text(body, 20_000, "body", true)?;
751 }
752 unique_texts("sources", &self.sources, 16, 2000)?;
753 match self.origin {
754 Some(DraftOrigin::Import) => {
755 if self.context_patch.sealed.is_none() && self.sources.is_empty() {
756 return Err(ValidationError::new(
757 "an import in the clear names its sources",
758 ));
759 }
760 if !self.sources.iter().all(|s| import_source_ok(s)) {
761 return Err(ValidationError::new(
762 "import sources are import:docs:<path>#<slug> or import:git:<sha>",
763 ));
764 }
765 }
766 Some(DraftOrigin::Unknown) => {
767 return Err(ValidationError::new("unknown draft origin"));
768 }
769 Some(DraftOrigin::Learned) | None => {}
770 }
771 validate_id(&self.session_ref, "session_ref")?;
772 let patch = &self.context_patch;
773 match (&patch.sealed, patch.ops.is_empty()) {
774 (None, false) => {
775 if self.summary.is_none() {
776 return Err(ValidationError::new("a draft in the clear needs a summary"));
777 }
778 if patch.ops.len() > MAX_PATCH_OPS {
779 return Err(ValidationError::new(format!(
780 "a patch carries 1-{MAX_PATCH_OPS} ops"
781 )));
782 }
783 patch.ops.iter().try_for_each(PatchOpV1::validate)
784 }
785 (Some(sealed), true) => {
786 if self.summary.is_some() || self.body.is_some() || !self.sources.is_empty() {
787 return Err(ValidationError::new("sealed drafts carry no plaintext"));
788 }
789 sealed.validate()
790 }
791 _ => Err(ValidationError::new("a patch carries either ops or sealed")),
792 }
793 }
794}
795
796#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
798pub struct ProposalAckV1 {
799 pub schema: String,
800 pub number: u64,
801 pub state: ProposalState,
802}
803
804#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
806pub struct ProposalSummaryV1 {
807 pub number: u64,
808 pub state: ProposalState,
809 pub summary: String,
810 pub base_revision: u64,
811 pub created_at: UtcTimestamp,
812 #[serde(default, skip_serializing_if = "Option::is_none")]
814 pub merged_revision: Option<u64>,
815 pub mine: bool,
817}
818
819#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
821pub struct ProposalListV1 {
822 pub schema: String,
823 pub vault_id: String,
824 pub proposals: Vec<ProposalSummaryV1>,
825}
826
827#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
829#[serde(deny_unknown_fields)]
830pub struct KnowledgePutRequestV1 {
831 pub schema: String,
832 pub expected_revision: u64,
833 #[serde(default, skip_serializing_if = "Option::is_none")]
834 pub document: Option<ResearchContextDocumentV1>,
835 #[serde(default, skip_serializing_if = "Option::is_none")]
836 pub sealed: Option<SealedBlobV1>,
837 #[serde(default, skip_serializing_if = "Vec::is_empty")]
840 pub merged_proposals: Vec<u64>,
841 #[serde(default, skip_serializing_if = "Option::is_none")]
844 pub rolls_back: Option<u64>,
845}
846
847#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
849pub struct ProposalV1 {
850 pub schema: String,
851 pub number: u64,
852 pub state: ProposalState,
853 pub created_at: UtcTimestamp,
854 pub mine: bool,
856 #[serde(default, skip_serializing_if = "Option::is_none")]
858 pub merged_revision: Option<u64>,
859 pub draft: DraftProposalV1,
861 #[serde(default, skip_serializing_if = "Option::is_none")]
864 pub decided_by: Option<super::tasks::DecidedByV1>,
865}
866
867impl ProposalV1 {
868 pub fn validate(&self) -> Result<(), ValidationError> {
869 require_schema(&self.schema, PROPOSAL_VIEW_SCHEMA)?;
870 self.draft.validate()
871 }
872}
873
874impl KnowledgePutRequestV1 {
875 pub fn validate(&self) -> Result<(), ValidationError> {
876 require_schema(&self.schema, KNOWLEDGE_PUT_REQUEST_SCHEMA)?;
877 if self.merged_proposals.len() > MAX_MERGED_PROPOSALS
878 || self.merged_proposals.iter().collect::<HashSet<_>>().len()
879 != self.merged_proposals.len()
880 {
881 return Err(ValidationError::new(format!(
882 "merged_proposals lists at most {MAX_MERGED_PROPOSALS} distinct proposals"
883 )));
884 }
885 if let Some(target) = self.rolls_back {
886 if target >= self.expected_revision {
887 return Err(ValidationError::new(
888 "a rollback restores an earlier revision",
889 ));
890 }
891 if !self.merged_proposals.is_empty() {
892 return Err(ValidationError::new("a rollback merges no proposals"));
893 }
894 }
895 match (&self.document, &self.sealed) {
896 (Some(document), None) => document.validate(),
897 (None, Some(sealed)) => sealed.validate(),
898 _ => Err(ValidationError::new(
899 "a put carries exactly one of document or sealed",
900 )),
901 }
902 }
903}
904
905#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
906#[serde(rename_all = "snake_case")]
907pub enum KnowledgeEventKind {
908 Released,
910 Quarantined,
912 RolledBack,
914 #[serde(other)]
915 Unknown,
916}
917
918#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
922pub struct KnowledgeEventV1 {
923 pub channel: String,
924 pub seq: u64,
925 pub revision: u64,
926 pub event: KnowledgeEventKind,
927 #[serde(default, skip_serializing_if = "Option::is_none")]
929 pub review_by: Option<UtcTimestamp>,
930 pub server_time: UtcTimestamp,
931}
932
933pub const REVISION_DIFF_SCHEMA: &str = "leanctx.hive.revision-diff/v1";
934
935#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
937pub struct RevisionDiffV1 {
938 pub schema: String,
939 pub vault_id: String,
940 pub from: u64,
941 pub to: u64,
942 pub ops: Vec<PatchOpV1>,
947}
948
949impl RevisionDiffV1 {
950 pub fn validate(&self) -> Result<(), ValidationError> {
951 require_schema(&self.schema, REVISION_DIFF_SCHEMA)?;
952 super::validate_vault_id(&self.vault_id)?;
953 if self.to <= self.from {
954 return Err(ValidationError::new("a diff goes forward"));
955 }
956 self.ops.iter().try_for_each(PatchOpV1::validate_authored)
957 }
958}
959
960#[cfg(test)]
961mod tests {
962 use super::*;
963
964 fn fact(id: &str) -> FactV1 {
965 FactV1 {
966 id: id.into(),
967 statement: "Hive never blocks a tool call.".into(),
968 kind: "decision".into(),
969 applies_to: vec![],
970 sources: vec![],
971 from_contributions: vec![],
972 anchors: vec![],
973 }
974 }
975
976 #[test]
977 fn documents_reject_dangling_and_repeated_relations() {
978 let mut document = ResearchContextDocumentV1 {
979 facts: vec![fact("a"), fact("b")],
980 ..Default::default()
981 };
982 let edge = RelationV1 {
983 from: "a".into(),
984 to: "b".into(),
985 kind: "supports".into(),
986 };
987 document.relations = vec![edge.clone()];
988 assert!(document.validate().is_ok());
989 document.relations.push(edge);
990 assert!(document.validate().is_err());
991 document.relations = vec![RelationV1 {
992 from: "a".into(),
993 to: "missing".into(),
994 kind: "supports".into(),
995 }];
996 assert!(document.validate().is_err());
997 }
998
999 #[test]
1000 fn patches_never_carry_credits() {
1001 let mut credited = fact("a");
1002 credited.from_contributions = vec!["p1".into()];
1003 assert!(PatchOpV1::AddFact { fact: credited }.validate().is_err());
1004 assert!(PatchOpV1::AddFact { fact: fact("a") }.validate().is_ok());
1005 }
1006}