use serde::{Deserialize, Serialize};
use std::collections::HashSet;
use super::anchor::{CodeAnchorV1, validate_anchors};
use super::{require_schema, validate_id, validate_text};
use crate::{UtcTimestamp, ValidationError};
pub const KNOWLEDGE_REVISION_SCHEMA: &str = "leanctx.hive.knowledge-revision/v1";
pub const DRAFT_PROPOSAL_SCHEMA: &str = "leanctx.hive.proposal/v1";
pub const PROPOSAL_ACK_SCHEMA: &str = "leanctx.hive.proposal-ack/v1";
pub const PROPOSAL_LIST_SCHEMA: &str = "leanctx.hive.proposal-list/v1";
pub const PROPOSAL_VIEW_SCHEMA: &str = "leanctx.hive.proposal-view/v1";
pub const RESEARCH_CONTEXT_FORMAT: &str = "ctxpkg.research-context.v1";
pub const FACT_KINDS: [&str; 9] = [
"claim",
"definition",
"decision",
"observation",
"procedure",
"gotcha",
"dead_end",
"experience",
"convention",
];
pub const RELATION_TYPES: [&str; 6] = [
"supports",
"depends_on",
"contradicts",
"supersedes",
"related_to",
"explains",
];
pub const MAX_PATCH_OPS: usize = 50;
pub const MAX_FACTS: usize = 5000;
pub const MAX_RELATIONS: usize = 15_000;
pub const MAX_REFERENCES: usize = 50_000;
pub const MAX_DOCUMENT_CHARS: usize = 8_000_000;
pub const MAX_KNOWLEDGE_BODY_BYTES: usize = 16 * 1024 * 1024;
pub const MAX_PAGES: usize = 500;
pub const MAX_PAGE_ITEMS: usize = 200;
pub const MAX_PROSE_CHARS: usize = 4000;
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct FactV1 {
pub id: String,
pub statement: String,
pub kind: String,
#[serde(rename = "appliesTo", default, skip_serializing_if = "Vec::is_empty")]
pub applies_to: Vec<String>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub sources: Vec<String>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub from_contributions: Vec<String>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub anchors: Vec<CodeAnchorV1>,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(deny_unknown_fields, tag = "kind", rename_all = "snake_case")]
pub enum PageItemV1 {
Fact { id: String },
Prose { text: String },
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct PageV1 {
pub id: String,
pub title: String,
#[serde(default)]
pub items: Vec<PageItemV1>,
}
#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct RelationV1 {
pub from: String,
pub to: String,
#[serde(rename = "type")]
pub kind: String,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct ResearchContextDocumentV1 {
pub format: String,
#[serde(default)]
pub facts: Vec<FactV1>,
#[serde(default)]
pub relations: Vec<RelationV1>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub open_gaps: Vec<String>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub pages: Vec<PageV1>,
}
impl Default for ResearchContextDocumentV1 {
fn default() -> Self {
Self {
format: RESEARCH_CONTEXT_FORMAT.into(),
facts: Vec::new(),
relations: Vec::new(),
open_gaps: Vec::new(),
pages: Vec::new(),
}
}
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(deny_unknown_fields, tag = "op", rename_all = "snake_case")]
pub enum PatchOpV1 {
AddFact {
fact: FactV1,
},
UpdateFact {
id: String,
fact: FactV1,
},
RetractFact {
id: String,
},
AddRelation {
relation: RelationV1,
},
RemoveRelation {
relation: RelationV1,
},
AddGap {
text: String,
},
CloseGap {
text: String,
},
AddPage {
page: PageV1,
},
UpdatePage {
id: String,
page: PageV1,
},
RemovePage {
id: String,
},
}
pub(crate) fn fact_id_ok(id: &str) -> bool {
let bytes = id.as_bytes();
!bytes.is_empty()
&& bytes.len() <= 100
&& bytes[0].is_ascii_alphanumeric()
&& bytes
.iter()
.all(|byte| byte.is_ascii_alphanumeric() || matches!(byte, b'.' | b'_' | b':' | b'-'))
}
fn unique_texts(
field: &str,
items: &[String],
max_items: usize,
max_chars: usize,
) -> Result<(), ValidationError> {
if items.len() > max_items {
return Err(ValidationError::new(format!(
"{field} allows at most {max_items} entries"
)));
}
let mut seen = HashSet::new();
for item in items {
validate_text(item, max_chars, field, false)?;
if !seen.insert(item) {
return Err(ValidationError::new(format!(
"{field} must not contain duplicates"
)));
}
}
Ok(())
}
impl FactV1 {
pub fn validate(&self) -> Result<(), ValidationError> {
if !fact_id_ok(&self.id) {
return Err(ValidationError::new(format!(
"fact id {:?} may contain only letters, numbers, dot, underscore, colon and hyphen (max 100)",
self.id
)));
}
if !FACT_KINDS.contains(&self.kind.as_str()) {
return Err(ValidationError::new(format!(
"fact {:?} has unsupported kind {:?}",
self.id, self.kind
)));
}
validate_text(&self.statement, 2000, "statement", true)?;
unique_texts("appliesTo", &self.applies_to, 16, 500)?;
unique_texts("sources", &self.sources, 16, 2000)?;
if self.from_contributions.len() > 16 {
return Err(ValidationError::new(
"from_contributions allows at most 16 entries",
));
}
validate_anchors(&self.anchors)
}
}
impl PageV1 {
pub fn validate(&self) -> Result<(), ValidationError> {
if !fact_id_ok(&self.id) {
return Err(ValidationError::new("page id is malformed"));
}
validate_text(&self.title, 200, "page title", false)?;
if self.items.is_empty() || self.items.len() > MAX_PAGE_ITEMS {
return Err(ValidationError::new(format!(
"a page has 1-{MAX_PAGE_ITEMS} items"
)));
}
for item in &self.items {
match item {
PageItemV1::Fact { id } if !fact_id_ok(id) => {
return Err(ValidationError::new("page fact reference is malformed"));
}
PageItemV1::Prose { text } => {
validate_text(text, MAX_PROSE_CHARS, "prose", true)?;
}
PageItemV1::Fact { .. } => {}
}
}
Ok(())
}
}
impl RelationV1 {
fn validate_kind(&self) -> Result<(), ValidationError> {
if !RELATION_TYPES.contains(&self.kind.as_str()) {
return Err(ValidationError::new(format!(
"relation type {:?} is not supported",
self.kind
)));
}
if self.from == self.to {
return Err(ValidationError::new("relation points a fact to itself"));
}
Ok(())
}
}
impl ResearchContextDocumentV1 {
pub fn validate(&self) -> Result<(), ValidationError> {
if self.format != RESEARCH_CONTEXT_FORMAT {
return Err(ValidationError::new(format!(
"format must be {RESEARCH_CONTEXT_FORMAT}"
)));
}
if self.facts.len() > MAX_FACTS || self.relations.len() > MAX_RELATIONS {
return Err(ValidationError::new(format!(
"at most {MAX_FACTS} facts and {MAX_RELATIONS} relations"
)));
}
unique_texts("open_gaps", &self.open_gaps, 20, 500)?;
let mut ids = HashSet::new();
let mut references = 0usize;
for fact in &self.facts {
fact.validate()?;
if !ids.insert(fact.id.as_str()) {
return Err(ValidationError::new(format!(
"fact id {:?} is not unique",
fact.id
)));
}
references += fact.applies_to.len() + fact.sources.len();
}
if references > MAX_REFERENCES {
return Err(ValidationError::new(format!(
"too many applicability and source references (max {MAX_REFERENCES})"
)));
}
let mut edges = HashSet::new();
for relation in &self.relations {
relation.validate_kind()?;
if !ids.contains(relation.from.as_str()) || !ids.contains(relation.to.as_str()) {
return Err(ValidationError::new("relation points to a missing fact"));
}
if !edges.insert(relation) {
return Err(ValidationError::new("relation repeats"));
}
}
if self.pages.len() > MAX_PAGES {
return Err(ValidationError::new(format!("at most {MAX_PAGES} pages")));
}
let mut page_ids = HashSet::new();
for page in &self.pages {
page.validate()?;
if !page_ids.insert(page.id.as_str()) {
return Err(ValidationError::new("page id is not unique"));
}
for item in &page.items {
if let PageItemV1::Fact { id } = item
&& !ids.contains(id.as_str())
{
return Err(ValidationError::new("a page shows a missing fact"));
}
}
}
let chars = serde_json::to_string(self)
.map_err(|error| ValidationError::new(format!("document: {error}")))?
.chars()
.count();
if chars > MAX_DOCUMENT_CHARS {
return Err(ValidationError::new(format!(
"the document exceeds {MAX_DOCUMENT_CHARS} characters"
)));
}
Ok(())
}
}
impl PatchOpV1 {
pub fn validate(&self) -> Result<(), ValidationError> {
let fact_ok = validate_patch_fact;
match self {
Self::AddFact { fact } => fact_ok(fact),
Self::UpdateFact { id, fact } => {
if id != &fact.id {
return Err(ValidationError::new("update_fact id must match fact.id"));
}
fact_ok(fact)
}
Self::RetractFact { id } => {
if fact_id_ok(id) {
Ok(())
} else {
Err(ValidationError::new("retract_fact id is malformed"))
}
}
Self::AddRelation { relation } | Self::RemoveRelation { relation } => {
relation.validate_kind()
}
Self::AddGap { text } | Self::CloseGap { text } => {
validate_text(text, 500, "gap", false)
}
Self::AddPage { page } => page.validate(),
Self::UpdatePage { id, page } => {
if id != &page.id {
return Err(ValidationError::new("update_page id must match page.id"));
}
page.validate()
}
Self::RemovePage { id } => {
if fact_id_ok(id) {
Ok(())
} else {
Err(ValidationError::new("remove_page id is malformed"))
}
}
}
}
}
fn validate_patch_fact(fact: &FactV1) -> Result<(), ValidationError> {
if !fact.from_contributions.is_empty() {
return Err(ValidationError::new(
"from_contributions is assigned by Hive and never sent in a patch",
));
}
fact.validate()
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Default, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum RevisionState {
#[default]
Released,
Quarantined,
RolledBack,
#[serde(other)]
Unknown,
}
impl RevisionState {
fn is_released(&self) -> bool {
*self == Self::Released
}
}
fn is_zero(value: &u64) -> bool {
*value == 0
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct KnowledgeRevisionV1 {
pub schema: String,
pub vault_id: String,
pub revision: u64,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub document: Option<ResearchContextDocumentV1>,
pub updated_at: UtcTimestamp,
#[serde(default, skip_serializing_if = "RevisionState::is_released")]
pub state: RevisionState,
#[serde(default, skip_serializing_if = "is_zero")]
pub quarantined_after: u64,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub fact_states: Vec<super::facts::FactStateV1>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub ctxpkg_tree: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub ctxpkg_commit: Option<String>,
}
impl KnowledgeRevisionV1 {
pub fn validate(&self) -> Result<(), ValidationError> {
require_schema(&self.schema, KNOWLEDGE_REVISION_SCHEMA)?;
super::validate_vault_id(&self.vault_id)?;
if self.state != RevisionState::Released && self.quarantined_after != 0 {
return Err(ValidationError::new(
"quarantined_after counts after a delivered head only",
));
}
let document = self
.document
.as_ref()
.ok_or_else(|| ValidationError::new("a revision carries its document"))?;
document.validate()?;
let ids: HashSet<&str> = document.facts.iter().map(|f| f.id.as_str()).collect();
for (i, state) in self.fact_states.iter().enumerate() {
state.validate()?;
if !ids.contains(state.fact.as_str()) {
return Err(ValidationError::new("a fact state names a missing fact"));
}
if i > 0 && self.fact_states[i - 1].fact >= state.fact {
return Err(ValidationError::new(
"fact_states must be sorted by fact without repeats",
));
}
}
Ok(())
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum ProposalState {
Draft,
Open,
Merged,
Rejected,
Withdrawn,
Ready,
#[serde(other)]
Unknown,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct ContextPatchV1 {
pub base_revision: u64,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub ops: Vec<PatchOpV1>,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct EngineScreeningV1 {
pub scanner_version: u32,
pub secrets_removed: u32,
pub pii_removed: u32,
pub items_dropped: u32,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct DraftProposalV1 {
pub schema: String,
pub state: ProposalState,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub summary: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub body: Option<String>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub sources: Vec<String>,
pub context_patch: ContextPatchV1,
pub session_ref: String,
pub engine_screening: EngineScreeningV1,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub origin: Option<DraftOrigin>,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum DraftOrigin {
Learned,
Import,
#[serde(other)]
Unknown,
}
pub fn import_source_ok(source: &str) -> bool {
if let Some(sha) = source.strip_prefix("import:git:") {
return matches!(sha.len(), 40 | 64)
&& sha
.bytes()
.all(|b| b.is_ascii_digit() || (b'a'..=b'f').contains(&b));
}
let Some(rest) = source.strip_prefix("import:docs:") else {
return false;
};
let Some((path, slug)) = rest.rsplit_once('#') else {
return false;
};
!slug.is_empty()
&& slug
.bytes()
.all(|b| b.is_ascii_lowercase() || b.is_ascii_digit() || b == b'-')
&& !path.is_empty()
&& !path.starts_with('/')
&& !path.contains('\\')
&& path
.split('/')
.all(|part| !part.is_empty() && part != "." && part != "..")
}
impl DraftProposalV1 {
pub fn validate(&self) -> Result<(), ValidationError> {
require_schema(&self.schema, DRAFT_PROPOSAL_SCHEMA)?;
if self.state != ProposalState::Draft {
return Err(ValidationError::new("engine proposals are drafts"));
}
if let Some(summary) = &self.summary {
validate_text(summary, 200, "summary", false)?;
}
if let Some(body) = &self.body {
validate_text(body, 20_000, "body", true)?;
}
unique_texts("sources", &self.sources, 16, 2000)?;
match self.origin {
Some(DraftOrigin::Import) => {
if self.sources.is_empty() {
return Err(ValidationError::new("an import names its sources"));
}
if !self.sources.iter().all(|s| import_source_ok(s)) {
return Err(ValidationError::new(
"import sources are import:docs:<path>#<slug> or import:git:<sha>",
));
}
}
Some(DraftOrigin::Unknown) => {
return Err(ValidationError::new("unknown draft origin"));
}
Some(DraftOrigin::Learned) | None => {}
}
validate_id(&self.session_ref, "session_ref")?;
if self.summary.is_none() {
return Err(ValidationError::new("a draft needs a summary"));
}
let ops = &self.context_patch.ops;
if ops.is_empty() {
return Err(ValidationError::new("a patch carries ops"));
}
if ops.len() > MAX_PATCH_OPS {
return Err(ValidationError::new(format!(
"a patch carries 1-{MAX_PATCH_OPS} ops"
)));
}
ops.iter().try_for_each(PatchOpV1::validate)
}
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct ProposalAckV1 {
pub schema: String,
pub number: u64,
pub state: ProposalState,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct ProposalSummaryV1 {
pub number: u64,
pub state: ProposalState,
pub summary: String,
pub base_revision: u64,
pub created_at: UtcTimestamp,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub merged_revision: Option<u64>,
pub mine: bool,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct ProposalListV1 {
pub schema: String,
pub vault_id: String,
pub proposals: Vec<ProposalSummaryV1>,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct ProposalV1 {
pub schema: String,
pub number: u64,
pub state: ProposalState,
pub created_at: UtcTimestamp,
pub mine: bool,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub merged_revision: Option<u64>,
pub draft: DraftProposalV1,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub decided_by: Option<super::tasks::DecidedByV1>,
}
impl ProposalV1 {
pub fn validate(&self) -> Result<(), ValidationError> {
require_schema(&self.schema, PROPOSAL_VIEW_SCHEMA)?;
self.draft.validate()
}
}
#[cfg(test)]
mod tests {
use super::*;
fn fact(id: &str) -> FactV1 {
FactV1 {
id: id.into(),
statement: "Hive never blocks a tool call.".into(),
kind: "decision".into(),
applies_to: vec![],
sources: vec![],
from_contributions: vec![],
anchors: vec![],
}
}
#[test]
fn documents_reject_dangling_and_repeated_relations() {
let mut document = ResearchContextDocumentV1 {
facts: vec![fact("a"), fact("b")],
..Default::default()
};
let edge = RelationV1 {
from: "a".into(),
to: "b".into(),
kind: "supports".into(),
};
document.relations = vec![edge.clone()];
assert!(document.validate().is_ok());
document.relations.push(edge);
assert!(document.validate().is_err());
document.relations = vec![RelationV1 {
from: "a".into(),
to: "missing".into(),
kind: "supports".into(),
}];
assert!(document.validate().is_err());
}
#[test]
fn patches_never_carry_credits() {
let mut credited = fact("a");
credited.from_contributions = vec!["p1".into()];
assert!(PatchOpV1::AddFact { fact: credited }.validate().is_err());
assert!(PatchOpV1::AddFact { fact: fact("a") }.validate().is_ok());
}
}