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//! - `GET /v1/vaults/{vault_id}/knowledge/revisions?after=&limit=` → [`RevisionListV1`]
9//! - `POST /v1/vaults/{vault_id}/proposals` [`DraftProposalV1`] → [`ProposalAckV1`];
10//!   `Idempotency-Key` = the engine's outbox item id. A draft whose content is
11//!   identical to an existing engine draft of the vault maps to that proposal
12//!   (same `number`), whichever instance sent it first.
13//! - `GET /v1/vaults/{vault_id}/proposals?state=&mine=1` → [`ProposalListV1`]
14//! - 1.6: `GET /v1/vaults/{vault_id}/proposals/{number}` → [`ProposalV1`]:
15//!   the draft exactly as its engine sent it (a sealed patch stays sealed),
16//!   for engine-side review of sealed vaults
17//! - `PUT /v1/vaults/{vault_id}/knowledge` [`KnowledgePutRequestV1`] →
18//!   [`KnowledgeRevisionV1`], or 409 `revision_conflict`; needs `knowledge.merge`.
19//!   The new revision is always `expected_revision + 1` (a sealed revision is
20//!   sealed for that number). 1.6: `merged_proposals` marks those proposals
21//!   merged at the new revision in the same transaction
22//!
23//! - 1.9: `GET /v1/vaults/{vault_id}/knowledge/diff?from=&to=` →
24//!   [`RevisionDiffV1`]: the ops from one released revision to another, so
25//!   an engine with a large document fetches deltas (in the clear only; a
26//!   sealed vault answers `409 invalid_state` reason `sealed`)
27//!
28//! Revision 0 is the empty document. A draft becomes a revision only after
29//! review in Hive.
30//!
31//! Quarantine (1.9, owner O3): a revision that an auto-merge threshold
32//! produced (no person reviewed it) starts `quarantined`. `GET …/knowledge`
33//! delivers the newest *released* revision (`revision` is that delivered
34//! head) and counts the quarantined revisions after it in
35//! `quarantined_after`; `?revision=N` reads a quarantined one for review
36//! (right `knowledge.merge`). An engine never hands quarantined content to
37//! any agent except for that review. A person releases it, or rolls it back,
38//! within 24 hours; otherwise Hive proposes a task (origin
39//! `quarantine_timeout`). A rollback appends a new revision with the document
40//! of an earlier one (`rolls_back`). In a sealed vault an engine performs
41//! it, because only an engine can re-seal for the new number.
42
43use 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";
57/// 1.6: one proposal with its draft.
58pub const PROPOSAL_VIEW_SCHEMA: &str = "leanctx.hive.proposal-view/v1";
59/// Most proposals one revision may merge.
60pub const MAX_MERGED_PROPOSALS: usize = 50;
61
62/// Format id of the knowledge document (the package format keeps its name).
63pub const RESEARCH_CONTEXT_FORMAT: &str = "ctxpkg.research-context.v1";
64/// 1.9 adds `gotcha`, `dead_end`, `experience` and `convention`.
65pub 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];
76/// Edges between facts; 1.9 adds `explains`. Edges to code, work and actors
77/// belong to the graph view (`super::graph`), not to the document.
78pub const RELATION_TYPES: [&str; 6] = [
79    "supports",
80    "depends_on",
81    "contradicts",
82    "supersedes",
83    "related_to",
84    "explains",
85];
86/// Ops per proposal patch.
87pub const MAX_PATCH_OPS: usize = 50;
88/// 1.9: 5,000 facts (wiki scale); engines fetch deltas with
89/// [`RevisionDiffV1`].
90pub const MAX_FACTS: usize = 5000;
91pub const MAX_RELATIONS: usize = 15_000;
92pub const MAX_REFERENCES: usize = 50_000;
93/// Characters of the serialized document.
94pub const MAX_DOCUMENT_CHARS: usize = 8_000_000;
95/// Largest knowledge request/response body (a full document fits).
96pub const MAX_KNOWLEDGE_BODY_BYTES: usize = 16 * 1024 * 1024;
97/// 1.9: wiki pages per document, items per page, characters of a prose item.
98pub const MAX_PAGES: usize = 500;
99pub const MAX_PAGE_ITEMS: usize = 200;
100pub const MAX_PROSE_CHARS: usize = 4000;
101/// Revisions per page.
102pub const MAX_REVISIONS_PAGE: usize = 100;
103
104/// One fact.
105#[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    /// Proposal credits assigned by Hive on merge; never sent in a patch.
116    #[serde(default, skip_serializing_if = "Vec::is_empty")]
117    pub from_contributions: Vec<String>,
118    /// 1.9: where in the code the fact applies ([`CodeAnchorV1`]).
119    #[serde(default, skip_serializing_if = "Vec::is_empty")]
120    pub anchors: Vec<CodeAnchorV1>,
121}
122
123/// 1.9: one item of a wiki page.
124#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
125#[serde(deny_unknown_fields, tag = "kind", rename_all = "snake_case")]
126pub enum PageItemV1 {
127    /// A fact of the document, shown in place.
128    Fact { id: String },
129    /// Connecting text written for the page.
130    Prose { text: String },
131}
132
133/// 1.9: a readable wiki page built from facts (the "tree" of the ctxpkg
134/// mapping). Pages are part of the reviewed document.
135#[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/// A typed edge between two facts.
145#[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/// The knowledge document of one vault.
155#[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    /// 1.9: wiki pages.
166    #[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/// One edit of a proposal patch.
183#[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    /// Also drops the fact's relations and its items on pages (1.9); a page
194    /// left without items goes too.
195    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    /// 1.9.
211    AddPage {
212        page: PageV1,
213    },
214    /// 1.9: replaces the page with `id` (`page.id` must equal it).
215    UpdatePage {
216        id: String,
217        page: PageV1,
218    },
219    /// 1.9.
220    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    /// Checks of one fact on its own (kind, id, bounds).
260    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    /// Checks of one page on its own; fact references are checked against
287    /// the document by [`ResearchContextDocumentV1::validate`].
288    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    /// Structural validation, identical in effect on engine and service.
330    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    /// Checks of one op on its own; applying it is the engine's/service's job.
401    pub fn validate(&self) -> Result<(), ValidationError> {
402        self.check(false)
403    }
404
405    /// 1.9: an op Hive wrote ([`RevisionDiffV1`]); its facts carry their
406    /// full `from_contributions` in the target revision.
407    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/// 1.9: whether a revision is delivered to agents.
468#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Default, Serialize, Deserialize)]
469#[serde(rename_all = "snake_case")]
470pub enum RevisionState {
471    #[default]
472    Released,
473    /// Auto-merged and not reviewed yet: never delivered to agents.
474    Quarantined,
475    /// Undone by a later revision that `rolls_back` to an earlier one.
476    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/// One revision of a vault's knowledge; exactly one of `document`/`sealed`.
492#[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    /// 1.9: `released` unless a reviewer reads a quarantined revision.
503    #[serde(default, skip_serializing_if = "RevisionState::is_released")]
504    pub state: RevisionState,
505    /// 1.9: quarantined revisions after this delivered head.
506    #[serde(default, skip_serializing_if = "is_zero")]
507    pub quarantined_after: u64,
508    /// 1.9: facts whose state is not `current` ([`super::facts`]); absent
509    /// for sealed vaults, whose states stay on the engines.
510    #[serde(default, skip_serializing_if = "Vec::is_empty")]
511    pub fact_states: Vec<super::facts::FactStateV1>,
512    /// 1.9 (ctxpkg ids): the document tree and the revision commit
513    /// (`hive::ctxpkg`), computed by Hive over the stored document; clear
514    /// vaults only. An engine whose own ids differ treats the revision as
515    /// altered.
516    #[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/// One entry of the revision history.
567#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
568pub struct RevisionSummaryV1 {
569    pub revision: u64,
570    pub created_at: UtcTimestamp,
571    /// Proposal number the revision merged, if any.
572    #[serde(default, skip_serializing_if = "Option::is_none")]
573    pub proposal: Option<u64>,
574    /// `sha256` hex of the canonical document (or ciphertext, when sealed).
575    pub digest: String,
576    /// 1.9.
577    #[serde(default, skip_serializing_if = "RevisionState::is_released")]
578    pub state: RevisionState,
579    /// 1.9: the earlier revision whose document this one restores.
580    #[serde(default, skip_serializing_if = "Option::is_none")]
581    pub rolls_back: Option<u64>,
582    /// 1.9 (ctxpkg ids): the document tree and the revision commit
583    /// (`hive::ctxpkg`), computed by Hive over the stored document; clear
584    /// vaults only. An engine whose own ids differ treats the revision as
585    /// altered.
586    #[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/// Response of `GET /v1/vaults/{vault_id}/knowledge/revisions`.
593#[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    /// Pass as `after` for the next page; absent on the last page.
599    #[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/// Review state of a proposal.
631#[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    /// 1.6: its author withdrew it.
639    Withdrawn,
640    /// 1.9 (section 7): a sealed proposal that reached its auto-merge
641    /// threshold; the next engine with `knowledge.merge` merges it.
642    Ready,
643    #[serde(other)]
644    Unknown,
645}
646
647/// Patch of a proposal against `base_revision`: `ops` in the clear, or
648/// `sealed` (the encrypted `ops` array) for sealed vaults.
649#[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/// What the engine's screening removed before the draft was queued.
660#[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/// `POST /v1/vaults/{vault_id}/proposals`: a draft from one session.
670#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
671#[serde(deny_unknown_fields)]
672pub struct DraftProposalV1 {
673    pub schema: String,
674    /// Always `draft`; review in Hive moves it on.
675    pub state: ProposalState,
676    /// One line for reviewers; required in the clear. 1.5: absent for
677    /// sealed vaults, where nothing readable leaves the engine (the real
678    /// summary is inside the sealed patch; Hive shows a placeholder).
679    #[serde(default, skip_serializing_if = "Option::is_none")]
680    pub summary: Option<String>,
681    /// Reviewer notes; absent for sealed vaults.
682    #[serde(default, skip_serializing_if = "Option::is_none")]
683    pub body: Option<String>,
684    /// Source references; empty for sealed vaults.
685    #[serde(default, skip_serializing_if = "Vec::is_empty")]
686    pub sources: Vec<String>,
687    pub context_patch: ContextPatchV1,
688    /// Opaque, per-session id (no host or user data).
689    pub session_ref: String,
690    pub engine_screening: EngineScreeningV1,
691    /// 1.9: where the draft comes from; absent means `learned`. Always in
692    /// the clear, sealed vaults included.
693    #[serde(default, skip_serializing_if = "Option::is_none")]
694    pub origin: Option<DraftOrigin>,
695}
696
697/// 1.9: the origin of a draft. Every origin passes the same engine
698/// admission filter before upload.
699#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
700#[serde(rename_all = "snake_case")]
701pub enum DraftOrigin {
702    /// Learned from sessions (the default).
703    Learned,
704    /// Imported from the repo's docs or git history. Its sources are
705    /// `import:docs:<path>#<slug>` or `import:git:<sha>`; in a sealed
706    /// vault they travel only inside the sealed patch, because paths and
707    /// commit hashes are metadata too.
708    Import,
709    #[serde(other)]
710    Unknown,
711}
712
713/// 1.9: an import source, `import:docs:<path>#<slug>` (repo-relative path,
714/// slug of `[a-z0-9-]`) or `import:git:<sha>` (40 or 64 lowercase hex).
715pub 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/// Response of `POST /v1/vaults/{vault_id}/proposals`.
797#[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/// One proposal in a listing.
805#[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    /// Revision the merge produced, when merged.
813    #[serde(default, skip_serializing_if = "Option::is_none")]
814    pub merged_revision: Option<u64>,
815    /// Whether this instance sent it.
816    pub mine: bool,
817}
818
819/// Response of `GET /v1/vaults/{vault_id}/proposals`.
820#[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/// `PUT /v1/vaults/{vault_id}/knowledge`: compare-and-set a whole revision.
828#[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    /// 1.6: proposals this revision merges (draft or open ones, ≤ 50,
838    /// unique); Hive marks them merged at the new revision.
839    #[serde(default, skip_serializing_if = "Vec::is_empty")]
840    pub merged_proposals: Vec<u64>,
841    /// 1.9: this revision restores the document of that earlier revision
842    /// (a rollback); it merges no proposals.
843    #[serde(default, skip_serializing_if = "Option::is_none")]
844    pub rolls_back: Option<u64>,
845}
846
847/// Response of `GET /v1/vaults/{vault_id}/proposals/{number}` (1.6).
848#[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    /// Whether this instance sent it.
855    pub mine: bool,
856    /// Revision the merge produced, when merged.
857    #[serde(default, skip_serializing_if = "Option::is_none")]
858    pub merged_revision: Option<u64>,
859    /// The draft exactly as its engine sent it.
860    pub draft: DraftProposalV1,
861    /// 1.9 (section 7): who merged or rejected it, a person or the
862    /// auto-merge threshold.
863    #[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    /// A person merged, or released a quarantined revision.
909    Released,
910    /// A threshold merged a revision into quarantine (reviewers only act).
911    Quarantined,
912    /// A rollback revision undid `revision`.
913    RolledBack,
914    #[serde(other)]
915    Unknown,
916}
917
918/// 1.9 `knowledge.event` (service → client), on the vault channel: the
919/// engine fetches the delivered head (or the quarantined revision, to
920/// review it) over HTTP.
921#[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    /// With `quarantined`: when the quarantine ends in a task (24 hours).
928    #[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/// 1.9: `GET /v1/vaults/{vault_id}/knowledge/diff?from=&to=`.
936#[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    /// Applied in order to revision `from`, they give revision `to` exactly
943    /// (its digest), credits included: unlike patches, these ops carry
944    /// `from_contributions`, the fact's full list in `to`; applying
945    /// `update_fact` keeps earlier credits and appends the missing ones.
946    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}