Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 3 additions & 1 deletion CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,9 @@
publishes immutable CAS-activated revisions with bounded audit, supports
exact rebase and historical composition, and exposes exact effective reads
through CLI, MCP, CompassQL, task context, export, and the viewer. Writes and
stronger curated masks remain deny-by-default capabilities.
stronger curated masks remain deny-by-default capabilities. Add read-only
ingestion preparation so agents can obtain canonical Base references and
source evidence without implementing Compass digest rules themselves.

- Replace the predefined flat call-flow architecture model with the native
`compass.viewer.architecture/1` projection. Production source scope is fixed
Expand Down
5 changes: 5 additions & 0 deletions COMPATIBILITY.md
Original file line number Diff line number Diff line change
Expand Up @@ -60,6 +60,11 @@ majors and unknown fields fail closed. `GROUNDED` is a Compass-issued citation
verification state and must not be interpreted as `INFERRED`, `EXTRACTED`, or
proof of semantic truth.

`compass.agent-graph.ingestion-preparation/1` is a read-only additive contract.
It calculates exact Base record and source-evidence digests for a selected Base
Generation; it does not certify, mutate, or publish an assertion. Apply
re-verifies prepared evidence against the pinned Base Generation.

Overlay writes require explicit CLI or server enablement. Existing MCP servers
without Agent Graph configuration advertise no Agent Graph tools. Configured
read-only servers advertise inspection only; HTTP writes additionally require
Expand Down
109 changes: 109 additions & 0 deletions crates/compass-agent-graph/src/grounding.rs
Original file line number Diff line number Diff line change
Expand Up @@ -783,6 +783,115 @@ fn verify_generation(
Ok(())
}

pub(crate) fn prepare_base_node_ref(
base: &dyn BaseGenerationView,
id: &str,
) -> Result<BaseNodeRef, AgentGraphError> {
let record = find_node(base.graph(), id).ok_or_else(|| {
AgentGraphError::new(
AgentGraphErrorCode::MissingEndpoint,
format!("Base node {id:?} does not exist in the selected generation"),
)
})?;
Ok(BaseNodeRef {
base_generation: base.identity().clone(),
id: record.id.clone(),
kind: record.kind,
record_digest: canonical_digest("compass.agent-graph.base-node-record/1", record)?,
})
}

pub(crate) fn prepare_base_edge_ref(
base: &dyn BaseGenerationView,
id: &str,
) -> Result<BaseEdgeRef, AgentGraphError> {
let record = find_edge(base.graph(), id).ok_or_else(|| {
AgentGraphError::new(
AgentGraphErrorCode::MissingEndpoint,
format!("Base edge {id:?} does not exist in the selected generation"),
)
})?;
Ok(BaseEdgeRef {
base_generation: base.identity().clone(),
id: record.id.clone(),
kind: record.kind,
source: record.source.clone(),
target: record.target.clone(),
record_digest: canonical_digest("compass.agent-graph.base-edge-record/1", record)?,
})
}

pub(crate) fn prepare_source_span(
base: &dyn BaseGenerationView,
request: &crate::preparation::SourceSpanRequest,
) -> Result<GroundingEvidence, AgentGraphError> {
if Path::new(&request.file)
.components()
.any(|component| !matches!(component, Component::Normal(_)))
{
return invalid_citation("source span path must be a confined repository-relative path");
}
let inventory = base
.graph()
.graph
.files
.iter()
.find(|entry| entry.path == request.file)
.ok_or_else(|| {
AgentGraphError::new(
AgentGraphErrorCode::InvalidCitation,
"source span file is not present in the Base Generation inventory",
)
})?;
let bytes = base.source_bytes(&request.file)?.ok_or_else(|| {
AgentGraphError::new(
AgentGraphErrorCode::InvalidCitation,
"source span file bytes are unavailable",
)
})?;
let file_digest = Digest::raw_bytes(&bytes);
if inventory.content_digest != file_digest.as_str() || inventory.byte_size != bytes.len() as u64
{
return invalid_citation("source file bytes do not match the selected Base Generation");
}
let start = usize::try_from(request.start_byte).map_err(|_| {
AgentGraphError::new(
AgentGraphErrorCode::InvalidCitation,
"source start is out of range",
)
})?;
let end = usize::try_from(request.end_byte).map_err(|_| {
AgentGraphError::new(
AgentGraphErrorCode::InvalidCitation,
"source end is out of range",
)
})?;
let excerpt = bytes.get(start..end).ok_or_else(|| {
AgentGraphError::new(
AgentGraphErrorCode::InvalidCitation,
"source span lies outside the verified file bytes",
)
})?;
let (start_line, start_column) = byte_line_column(&bytes, start);
let (end_line, end_column) = byte_line_column(&bytes, end);
let evidence = GroundingEvidence::SourceSpan {
file: request.file.clone(),
anchor: SourceAnchor {
file: request.file.clone(),
start_byte: request.start_byte,
end_byte: request.end_byte,
start_line,
start_column,
end_line,
end_column,
},
file_digest,
excerpt_digest: Digest::raw_bytes(excerpt),
};
verify_evidence(&evidence, base)?;
Ok(evidence)
}

fn validate_json_pointer(pointer: &str) -> Result<(), AgentGraphError> {
if pointer.len() > 4_096 || (!pointer.is_empty() && !pointer.starts_with('/')) {
return invalid_citation("JSON pointer is invalid or exceeds 4096 bytes");
Expand Down
5 changes: 5 additions & 0 deletions crates/compass-agent-graph/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ mod maintenance;
mod overlay;
mod paths;
mod policy;
mod preparation;
mod rebase;
mod repository;

Expand Down Expand Up @@ -54,6 +55,10 @@ pub use overlay::{
};
pub use paths::{AGENT_GRAPH_DATABASE_NAME, AgentGraphPaths};
pub use policy::{OperationPermission, WriteAuthority, WriteGrant};
pub use preparation::{
AGENT_GRAPH_INGESTION_PREPARATION_SCHEMA_V1, IngestionPreparation, IngestionPreparationRequest,
SourceSpanRequest, prepare_ingestion,
};
pub use rebase::{
AGENT_GRAPH_REBASE_COMMIT_SCHEMA_V1, AGENT_GRAPH_REBASE_PLAN_SCHEMA_V1, RebaseCommitRequest,
RebaseDisposition, RebaseItem, RebasePlan, RebaseSubject,
Expand Down
178 changes: 178 additions & 0 deletions crates/compass-agent-graph/src/preparation.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,178 @@
use std::collections::BTreeSet;

use serde::{Deserialize, Serialize};

use crate::contract::validate_bounded_text;
use crate::grounding::{prepare_base_edge_ref, prepare_base_node_ref, prepare_source_span};
use crate::{
AgentGraphError, AgentGraphErrorCode, AgentGraphLimits, BaseEdgeRef, BaseFactRef,
BaseGenerationId, BaseGenerationView, BaseNodeRef, GroundingEvidence, GroundingSubmission,
OverlayId, OverlayRevisionId,
};

pub const AGENT_GRAPH_INGESTION_PREPARATION_SCHEMA_V1: &str =
"compass.agent-graph.ingestion-preparation/1";

#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase", deny_unknown_fields)]
pub struct SourceSpanRequest {
pub file: String,
pub start_byte: u64,
pub end_byte: u64,
}

#[derive(Clone, Debug, Default, Eq, PartialEq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase", deny_unknown_fields)]
pub struct IngestionPreparationRequest {
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub base_node_ids: Vec<String>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub base_edge_ids: Vec<String>,
pub source_spans: Vec<SourceSpanRequest>,
}

impl IngestionPreparationRequest {
pub fn validate(&self, limits: AgentGraphLimits) -> Result<(), AgentGraphError> {
let limits = limits.validate()?;
if self.source_spans.is_empty() {
return Err(AgentGraphError::new(
AgentGraphErrorCode::GroundingFailed,
"ingestion preparation requires at least one source span",
));
}
let base_facts = self
.base_node_ids
.len()
.saturating_add(self.base_edge_ids.len());
if base_facts > limits.max_candidates {
return Err(AgentGraphError::new(
AgentGraphErrorCode::LimitExceeded,
format!(
"ingestion preparation names {base_facts} Base facts; maximum is {}",
limits.max_candidates
),
));
}
let citations = base_facts.saturating_add(self.source_spans.len());
if citations > limits.max_citations_per_assertion {
return Err(AgentGraphError::new(
AgentGraphErrorCode::LimitExceeded,
format!(
"ingestion preparation would create {citations} citations; maximum is {}",
limits.max_citations_per_assertion
),
));
}
validate_unique_ids("baseNodeIds", &self.base_node_ids)?;
validate_unique_ids("baseEdgeIds", &self.base_edge_ids)?;
let mut spans = BTreeSet::new();
for span in &self.source_spans {
validate_bounded_text("sourceSpans.file", &span.file, 4_096, false)?;
if span.start_byte >= span.end_byte {
return Err(AgentGraphError::new(
AgentGraphErrorCode::InvalidCitation,
"source span startByte must be less than endByte",
));
}
if !spans.insert((&span.file, span.start_byte, span.end_byte)) {
return Err(AgentGraphError::new(
AgentGraphErrorCode::InvalidCitation,
"ingestion preparation contains a duplicate source span",
));
}
}
Ok(())
}
}

#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase", deny_unknown_fields)]
pub struct IngestionPreparation {
pub schema: String,
pub overlay: OverlayId,
pub base_generation: BaseGenerationId,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub expected_revision: Option<OverlayRevisionId>,
pub base_nodes: Vec<BaseNodeRef>,
pub base_edges: Vec<BaseEdgeRef>,
pub grounding: GroundingSubmission,
}

pub fn prepare_ingestion(
base: &dyn BaseGenerationView,
overlay: OverlayId,
expected_revision: Option<OverlayRevisionId>,
request: &IngestionPreparationRequest,
limits: AgentGraphLimits,
) -> Result<IngestionPreparation, AgentGraphError> {
request.validate(limits)?;

let mut node_ids = request.base_node_ids.clone();
node_ids.sort();
let mut edge_ids = request.base_edge_ids.clone();
edge_ids.sort();
let mut spans = request.source_spans.clone();
spans.sort_by(|left, right| {
(&left.file, left.start_byte, left.end_byte).cmp(&(
&right.file,
right.start_byte,
right.end_byte,
))
});

let base_nodes = node_ids
.iter()
.map(|id| prepare_base_node_ref(base, id))
.collect::<Result<Vec<_>, _>>()?;
let base_edges = edge_ids
.iter()
.map(|id| prepare_base_edge_ref(base, id))
.collect::<Result<Vec<_>, _>>()?;
let mut evidence = spans
.iter()
.map(|span| prepare_source_span(base, span))
.collect::<Result<Vec<_>, _>>()?;
evidence.extend(base_nodes.iter().cloned().map(|node| {
let record_digest = node.record_digest.clone();
GroundingEvidence::BaseFact {
fact: BaseFactRef::Node(node),
record_digest,
}
}));
evidence.extend(base_edges.iter().cloned().map(|edge| {
let record_digest = edge.record_digest.clone();
GroundingEvidence::BaseFact {
fact: BaseFactRef::Edge(edge),
record_digest,
}
}));
let grounding = GroundingSubmission {
schema: crate::grounding::GROUNDING_SCHEMA_V1.to_owned(),
policy_id: crate::grounding::DEFAULT_GROUNDING_POLICY_ID.to_owned(),
evidence,
};
grounding.validate(limits)?;
Ok(IngestionPreparation {
schema: AGENT_GRAPH_INGESTION_PREPARATION_SCHEMA_V1.to_owned(),
overlay,
base_generation: base.identity().clone(),
expected_revision,
base_nodes,
base_edges,
grounding,
})
}

fn validate_unique_ids(field: &str, ids: &[String]) -> Result<(), AgentGraphError> {
let mut seen = BTreeSet::new();
for id in ids {
validate_bounded_text(field, id, 4_096, false)?;
if !seen.insert(id) {
return Err(AgentGraphError::new(
AgentGraphErrorCode::InvalidIdentifier,
format!("{field} contains duplicate ID {id:?}"),
));
}
}
Ok(())
}
Loading
Loading