Skip to main content

lean_ctx_protocol/hive/
knowledge.rs

1// SPDX-License-Identifier: Apache-2.0
2
3//! Vault knowledge: the `ctxpkg.research-context.v1` document, its
4//! revisions, and draft proposals streamed from engine sessions.
5//!
6//! - `GET /v1/vaults/{vault_id}/knowledge` (`If-None-Match: "<revision>"`)
7//!   → [`KnowledgeRevisionV1`] with `ETag: "<revision>"`, or 304
8//! - `POST /v1/vaults/{vault_id}/proposals` [`DraftProposalV1`] → [`ProposalAckV1`];
9//!   `Idempotency-Key` = the engine's outbox item id. A draft whose content is
10//!   identical to an existing engine draft of the vault maps to that proposal
11//!   (same `number`), whichever instance sent it first.
12//! - `GET /v1/vaults/{vault_id}/proposals?state=&mine=1` → [`ProposalListV1`]
13//! - 1.6: `GET /v1/vaults/{vault_id}/proposals/{number}` → [`ProposalV1`]:
14//!   the draft exactly as its engine sent it, for engine-side review
15//!
16//! Revision 0 is the empty document. A draft becomes a revision only after
17//! review in Hive.
18//!
19//! Quarantine (1.9, owner O3): a revision that an auto-merge threshold
20//! produced (no person reviewed it) starts `quarantined`. `GET …/knowledge`
21//! delivers the newest *released* revision (`revision` is that delivered
22//! head) and counts the quarantined revisions after it in
23//! `quarantined_after`. An engine never hands quarantined content to any
24//! agent.
25//!
26//! A revision and a patch carry their content in the clear.
27
28use 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";
39/// 1.6: one proposal with its draft.
40pub const PROPOSAL_VIEW_SCHEMA: &str = "leanctx.hive.proposal-view/v1";
41
42/// Format id of the knowledge document (the package format keeps its name).
43pub const RESEARCH_CONTEXT_FORMAT: &str = "ctxpkg.research-context.v1";
44/// 1.9 adds `gotcha`, `dead_end`, `experience` and `convention`.
45pub 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];
56/// Edges between facts; 1.9 adds `explains`. Edges to code, work and actors
57/// belong to the graph view (`super::graph`), not to the document.
58pub const RELATION_TYPES: [&str; 6] = [
59    "supports",
60    "depends_on",
61    "contradicts",
62    "supersedes",
63    "related_to",
64    "explains",
65];
66/// Ops per proposal patch.
67pub const MAX_PATCH_OPS: usize = 50;
68/// 1.9: 5,000 facts (wiki scale).
69pub const MAX_FACTS: usize = 5000;
70pub const MAX_RELATIONS: usize = 15_000;
71pub const MAX_REFERENCES: usize = 50_000;
72/// Characters of the serialized document.
73pub const MAX_DOCUMENT_CHARS: usize = 8_000_000;
74/// Largest knowledge request/response body (a full document fits).
75pub const MAX_KNOWLEDGE_BODY_BYTES: usize = 16 * 1024 * 1024;
76/// 1.9: wiki pages per document, items per page, characters of a prose item.
77pub const MAX_PAGES: usize = 500;
78pub const MAX_PAGE_ITEMS: usize = 200;
79pub const MAX_PROSE_CHARS: usize = 4000;
80
81/// One fact.
82#[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    /// Proposal credits assigned by Hive on merge; never sent in a patch.
93    #[serde(default, skip_serializing_if = "Vec::is_empty")]
94    pub from_contributions: Vec<String>,
95    /// 1.9: where in the code the fact applies ([`CodeAnchorV1`]).
96    #[serde(default, skip_serializing_if = "Vec::is_empty")]
97    pub anchors: Vec<CodeAnchorV1>,
98}
99
100/// 1.9: one item of a wiki page.
101#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
102#[serde(deny_unknown_fields, tag = "kind", rename_all = "snake_case")]
103pub enum PageItemV1 {
104    /// A fact of the document, shown in place.
105    Fact { id: String },
106    /// Connecting text written for the page.
107    Prose { text: String },
108}
109
110/// 1.9: a readable wiki page built from facts (the "tree" of the ctxpkg
111/// mapping). Pages are part of the reviewed document.
112#[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/// A typed edge between two facts.
122#[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/// The knowledge document of one vault.
132#[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    /// 1.9: wiki pages.
143    #[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/// One edit of a proposal patch.
160#[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    /// Also drops the fact's relations and its items on pages (1.9); a page
171    /// left without items goes too.
172    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    /// 1.9.
188    AddPage {
189        page: PageV1,
190    },
191    /// 1.9: replaces the page with `id` (`page.id` must equal it).
192    UpdatePage {
193        id: String,
194        page: PageV1,
195    },
196    /// 1.9.
197    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    /// Checks of one fact on its own (kind, id, bounds).
237    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    /// Checks of one page on its own; fact references are checked against
264    /// the document by [`ResearchContextDocumentV1::validate`].
265    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    /// Structural validation, identical in effect on engine and service.
307    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    /// Checks of one op on its own; applying it is the engine's/service's job.
378    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/// 1.9: whether a revision is delivered to agents.
429#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Default, Serialize, Deserialize)]
430#[serde(rename_all = "snake_case")]
431pub enum RevisionState {
432    #[default]
433    Released,
434    /// Auto-merged and not reviewed yet: never delivered to agents.
435    Quarantined,
436    /// Undone by a later revision that `rolls_back` to an earlier one.
437    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/// One revision of a vault's knowledge; [`KnowledgeRevisionV1::validate`]
453/// refuses a revision without its `document`.
454#[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    /// 1.9: `released` unless a reviewer reads a quarantined revision.
463    #[serde(default, skip_serializing_if = "RevisionState::is_released")]
464    pub state: RevisionState,
465    /// 1.9: quarantined revisions after this delivered head.
466    #[serde(default, skip_serializing_if = "is_zero")]
467    pub quarantined_after: u64,
468    /// 1.9: facts whose state is not `current` ([`super::facts`]).
469    #[serde(default, skip_serializing_if = "Vec::is_empty")]
470    pub fact_states: Vec<super::facts::FactStateV1>,
471    /// 1.9 (ctxpkg ids): the document tree and the revision commit
472    /// (`hive::ctxpkg`), computed by Hive over the stored document; clear
473    /// vaults only. An engine whose own ids differ treats the revision as
474    /// altered.
475    #[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/// Review state of a proposal.
512#[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    /// 1.6: its author withdrew it.
520    Withdrawn,
521    /// 1.9 (section 7): the proposal reached its confirmation threshold.
522    Ready,
523    #[serde(other)]
524    Unknown,
525}
526
527/// Patch of a proposal against `base_revision`, in the clear.
528#[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/// What the engine's screening removed before the draft was queued.
537#[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/// `POST /v1/vaults/{vault_id}/proposals`: a draft from one session.
547#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
548#[serde(deny_unknown_fields)]
549pub struct DraftProposalV1 {
550    pub schema: String,
551    /// Always `draft`; review in Hive moves it on.
552    pub state: ProposalState,
553    /// One line for reviewers; required by validation.
554    #[serde(default, skip_serializing_if = "Option::is_none")]
555    pub summary: Option<String>,
556    /// Reviewer notes.
557    #[serde(default, skip_serializing_if = "Option::is_none")]
558    pub body: Option<String>,
559    /// Source references.
560    #[serde(default, skip_serializing_if = "Vec::is_empty")]
561    pub sources: Vec<String>,
562    pub context_patch: ContextPatchV1,
563    /// Opaque, per-session id (no host or user data).
564    pub session_ref: String,
565    pub engine_screening: EngineScreeningV1,
566    /// 1.9: where the draft comes from; absent means `learned`.
567    #[serde(default, skip_serializing_if = "Option::is_none")]
568    pub origin: Option<DraftOrigin>,
569}
570
571/// 1.9: the origin of a draft. Every origin passes the same engine
572/// admission filter before upload.
573#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
574#[serde(rename_all = "snake_case")]
575pub enum DraftOrigin {
576    /// Learned from sessions (the default).
577    Learned,
578    /// Imported from the repo's docs or git history. Its sources are
579    /// `import:docs:<path>#<slug>` or `import:git:<sha>`.
580    Import,
581    #[serde(other)]
582    Unknown,
583}
584
585/// 1.9: an import source, `import:docs:<path>#<slug>` (repo-relative path,
586/// slug of `[a-z0-9-]`) or `import:git:<sha>` (40 or 64 lowercase hex).
587pub 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/// Response of `POST /v1/vaults/{vault_id}/proposals`.
659#[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/// One proposal in a listing.
667#[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    /// Revision the merge produced, when merged.
675    #[serde(default, skip_serializing_if = "Option::is_none")]
676    pub merged_revision: Option<u64>,
677    /// Whether this instance sent it.
678    pub mine: bool,
679}
680
681/// Response of `GET /v1/vaults/{vault_id}/proposals`.
682#[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/// Response of `GET /v1/vaults/{vault_id}/proposals/{number}` (1.6).
690#[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    /// Whether this instance sent it.
697    pub mine: bool,
698    /// Revision the merge produced, when merged.
699    #[serde(default, skip_serializing_if = "Option::is_none")]
700    pub merged_revision: Option<u64>,
701    /// The draft exactly as its engine sent it.
702    pub draft: DraftProposalV1,
703    /// 1.9 (section 7): who merged or rejected it, a person or the
704    /// auto-merge threshold.
705    #[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}