//! Context receipts, scoped guidance delivery, and omission tracking (W4, G07, G11, G12).
//!
//! Implements Section 7 of CONTRACTS.md:
//! - Context receipts record final assembled request, guidance source/version/spans,
//! included and omitted optional materials, omission reasons, and revisions.
//! - Required directives are preserved intact or report an explicit unresolved-budget / conflict outcome;
//! never silently truncated.
//! - Excluded optional materials remain addressable by source reference across restarts.
use chrono::{SecondsFormat, TimeZone, Utc};
use serde::{Deserialize, Serialize};
use std::collections::HashMap;
use crate::digest::sha256_hex;
use crate::models::{Message, Role};
use crate::orientation::OrientationRevision;
/// The renderer whose output this delivery is.
pub const ORIENTATION_RENDERER: &str = "orientation-v1";
/// The orientation revision as the provider message it is delivered as.
#[must_use]
fn orientation_message(revision: &OrientationRevision) -> Message {
Message {
role: Role::from(revision.carrier_role.as_str()),
content: Some(revision.text.clone()),
reasoning_content: None,
tool_calls: None,
tool_call_id: None,
}
}
/// Render the installed runtime frontier for the provider request.
#[must_use]
pub fn format_context_summary(summary: &str, timestamp: i64) -> String {
let timestamp = Utc
.timestamp_opt(timestamp, 0)
.single()
.unwrap_or_else(Utc::now)
.to_rfc3339_opts(SecondsFormat::Secs, true);
crate::harness_block::format_context_summary_block(
summary,
×tamp,
crate::harness_block::DEFAULT_FORMAT_STYLE,
)
}
/// The configured watermark share reserved for required header guidance.
/// Three quarters leaves room for causal history and the current turn.
#[must_use]
pub const fn guidance_budget_tokens(watermark_tokens: usize) -> usize {
watermark_tokens.saturating_mul(3) / 4
}
/// Stable inputs that are allowed to affect the prompt header and receiving edge.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub struct StablePrefixInputs<'a> {
pub session_id: &'a str,
pub behavior_revision: Option<&'a str>,
pub guidance_budget_tokens: usize,
pub context_epoch: Option<&'a str>,
pub base_orientation_id: Option<&'a str>,
pub effective_orientation_id: Option<&'a str>,
pub orientation: Option<&'a OrientationRevision>,
pub request_id: Option<&'a str>,
pub renderer: Option<&'a str>,
pub attempt_id: Option<&'a str>,
}
impl<'a> StablePrefixInputs<'a> {
#[must_use]
pub const fn new(
session_id: &'a str,
behavior_revision: Option<&'a str>,
guidance_budget_tokens: usize,
) -> Self {
Self {
session_id,
behavior_revision,
guidance_budget_tokens,
context_epoch: None,
base_orientation_id: None,
effective_orientation_id: None,
orientation: None,
request_id: None,
renderer: None,
attempt_id: None,
}
}
#[must_use]
pub const fn with_epoch(mut self, epoch: &'a str) -> Self {
self.context_epoch = Some(epoch);
self
}
#[must_use]
pub const fn with_orientation(
mut self,
base: Option<&'a str>,
effective: Option<&'a str>,
) -> Self {
self.base_orientation_id = base;
self.effective_orientation_id = effective;
self
}
#[must_use]
pub const fn with_orientation_revision(mut self, revision: &'a OrientationRevision) -> Self {
self.orientation = Some(revision);
self
}
#[must_use]
pub const fn with_request_id(mut self, request_id: &'a str) -> Self {
self.request_id = Some(request_id);
self
}
#[must_use]
pub const fn with_renderer(mut self, renderer: &'a str) -> Self {
self.renderer = Some(renderer);
self
}
#[must_use]
pub const fn with_attempt_id(mut self, attempt_id: &'a str) -> Self {
self.attempt_id = Some(attempt_id);
self
}
}
/// Requirement level of a guidance item.
#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, Serialize, Deserialize)]
#[serde(tag = "type", rename_all = "snake_case")]
pub enum GuidanceRequirement {
/// Small scoped directive that must be preserved intact or reported as budget/conflict.
Required,
/// Optional supporting material budgeted according to priority.
Optional { priority: i32 },
}
/// A versioned guidance item or scoped directive.
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub struct GuidanceItem {
pub id: String,
pub version: String,
pub scope: String,
pub content: String,
pub requirement: GuidanceRequirement,
pub estimated_tokens: usize,
}
impl GuidanceItem {
#[must_use]
pub fn required(
id: impl Into<String>,
version: impl Into<String>,
scope: impl Into<String>,
content: impl Into<String>,
) -> Self {
let content_str = content.into();
let estimated = estimate_tokens(&content_str);
Self {
id: id.into(),
version: version.into(),
scope: scope.into(),
content: content_str,
requirement: GuidanceRequirement::Required,
estimated_tokens: estimated,
}
}
#[must_use]
pub fn optional(
id: impl Into<String>,
version: impl Into<String>,
scope: impl Into<String>,
content: impl Into<String>,
priority: i32,
) -> Self {
let content_str = content.into();
let estimated = estimate_tokens(&content_str);
Self {
id: id.into(),
version: version.into(),
scope: scope.into(),
content: content_str,
requirement: GuidanceRequirement::Optional { priority },
estimated_tokens: estimated,
}
}
#[must_use]
pub fn is_required(&self) -> bool {
matches!(self.requirement, GuidanceRequirement::Required)
}
}
/// Rough token estimate (~4 characters per token).
fn estimate_tokens(text: &str) -> usize {
text.len().div_ceil(4)
}
/// Strongly-typed span tracking byte and character offsets in assembled prompt text.
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub struct ContextSpan {
/// UTF-8 byte start offset in assembled text (presentation separator excluded).
pub byte_start: usize,
/// UTF-8 byte end offset in assembled text (presentation separator excluded).
pub byte_end: usize,
/// Unicode scalar value (char) start offset in assembled text (presentation separator excluded).
pub char_start: usize,
/// Unicode scalar value (char) end offset in assembled text (presentation separator excluded).
pub char_end: usize,
}
impl ContextSpan {
#[must_use]
pub fn new(byte_start: usize, byte_end: usize, char_start: usize, char_end: usize) -> Self {
Self {
byte_start,
byte_end,
char_start,
char_end,
}
}
#[must_use]
pub fn slice_bytes<'a>(&self, text: &'a str) -> Option<&'a str> {
text.get(self.byte_start..self.byte_end)
}
#[must_use]
pub fn slice_chars(&self, text: &str) -> Option<String> {
let chars: Vec<char> = text.chars().collect();
if self.char_end <= chars.len() && self.char_start <= self.char_end {
Some(chars[self.char_start..self.char_end].iter().collect())
} else {
None
}
}
#[must_use]
pub fn byte_len(&self) -> usize {
self.byte_end.saturating_sub(self.byte_start)
}
#[must_use]
pub fn char_len(&self) -> usize {
self.char_end.saturating_sub(self.char_start)
}
}
/// Span of content included in the assembled prompt context.
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub struct IncludedSpan {
pub source_id: String,
pub version: String,
pub scope: String,
#[serde(default)]
pub byte_start: usize,
#[serde(default)]
pub byte_end: usize,
pub char_start: usize,
pub char_end: usize,
pub token_count: usize,
pub intact: bool,
}
impl IncludedSpan {
#[must_use]
#[allow(clippy::too_many_arguments)]
pub fn new(
source_id: impl Into<String>,
version: impl Into<String>,
scope: impl Into<String>,
byte_start: usize,
byte_end: usize,
char_start: usize,
char_end: usize,
token_count: usize,
intact: bool,
) -> Self {
Self {
source_id: source_id.into(),
version: version.into(),
scope: scope.into(),
byte_start,
byte_end,
char_start,
char_end,
token_count,
intact,
}
}
#[must_use]
pub fn span(&self) -> ContextSpan {
ContextSpan::new(
self.byte_start,
self.byte_end,
self.char_start,
self.char_end,
)
}
#[must_use]
pub fn slice_bytes<'a>(&self, text: &'a str) -> Option<&'a str> {
self.span().slice_bytes(text)
}
#[must_use]
pub fn slice_chars(&self, text: &str) -> Option<String> {
self.span().slice_chars(text)
}
#[must_use]
pub fn byte_len(&self) -> usize {
self.byte_end.saturating_sub(self.byte_start)
}
#[must_use]
pub fn char_len(&self) -> usize {
self.char_end.saturating_sub(self.char_start)
}
}
/// Reason why an item was omitted from context.
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
#[serde(tag = "kind", rename_all = "snake_case")]
pub enum OmissionReason {
BudgetExceeded { budget: usize, needed: usize },
Superseded { by_id: String, by_version: String },
ScopeMismatch { expected_scope: String },
}
/// Explicit record of an omitted optional material with addressable reference (G12).
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub struct OmittedItem {
pub source_id: String,
pub version: String,
pub scope: String,
pub reason: OmissionReason,
pub recoverable_ref: String,
}
/// Status of guidance assembly.
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
#[serde(tag = "kind", rename_all = "snake_case")]
pub enum GuidanceStatus {
DeliveredIntact,
UnresolvedBudget {
required_tokens: usize,
available_tokens: usize,
},
Conflict {
conflicting_ids: Vec<String>,
reason: String,
},
}
/// Context receipt referencing the final assembled request (CONTRACTS.md §7, G12, M4 Receiving Contract).
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub struct ContextReceipt {
pub receipt_id: String,
pub session_id: String,
pub timestamp_ms: i64,
pub behavior_revision: Option<String>,
pub included_spans: Vec<IncludedSpan>,
pub omitted_items: Vec<OmittedItem>,
pub guidance_status: GuidanceStatus,
pub total_budget_tokens: usize,
pub consumed_tokens: usize,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub context_epoch: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub base_orientation_id: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub effective_orientation_id: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub carrier_hash: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub carrier_role: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub carrier_placement: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub carrier_scope: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub carrier_mode: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub first_object: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub renderer: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub request_id: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub attempt_id: Option<String>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub source_links: Vec<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub frontier_state: Option<String>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub work_links: Vec<String>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub subsequent_action_links: Vec<String>,
}
#[derive(Debug)]
pub struct ContextReceiptInit {
pub receipt_id: String,
pub session_id: String,
pub timestamp_ms: i64,
pub behavior_revision: Option<String>,
pub included_spans: Vec<IncludedSpan>,
pub omitted_items: Vec<OmittedItem>,
pub guidance_status: GuidanceStatus,
pub total_budget_tokens: usize,
pub consumed_tokens: usize,
}
impl ContextReceipt {
#[must_use]
pub fn new(init: ContextReceiptInit) -> Self {
Self {
receipt_id: init.receipt_id,
session_id: init.session_id,
timestamp_ms: init.timestamp_ms,
behavior_revision: init.behavior_revision,
included_spans: init.included_spans,
omitted_items: init.omitted_items,
guidance_status: init.guidance_status,
total_budget_tokens: init.total_budget_tokens,
consumed_tokens: init.consumed_tokens,
context_epoch: None,
base_orientation_id: None,
effective_orientation_id: None,
carrier_hash: None,
carrier_role: None,
carrier_placement: None,
carrier_scope: None,
carrier_mode: None,
first_object: None,
renderer: None,
request_id: None,
attempt_id: None,
source_links: Vec::new(),
frontier_state: None,
work_links: Vec::new(),
subsequent_action_links: Vec::new(),
}
}
#[must_use]
pub fn is_intact(&self) -> bool {
matches!(self.guidance_status, GuidanceStatus::DeliveredIntact)
&& self.included_spans.iter().all(|s| s.intact)
}
#[must_use]
pub fn has_omissions(&self) -> bool {
!self.omitted_items.is_empty()
}
/// Retrieve an omitted source item by reference identifier or exact revision (G12).
#[must_use]
pub fn find_omitted_by_ref<'a>(
&'a self,
source_ref: &str,
library: &'a [GuidanceItem],
) -> Option<&'a GuidanceItem> {
let omitted = self.omitted_items.iter().find(|item| {
item.recoverable_ref == source_ref
|| item.source_id == source_ref
|| format!("guidance:{}", item.source_id) == source_ref
|| format!("guidance:{}@{}", item.source_id, item.version) == source_ref
|| format!("{}@{}", item.source_id, item.version) == source_ref
})?;
library
.iter()
.find(|g| g.id == omitted.source_id && g.version == omitted.version)
}
/// Retrieve an omitted source item by exact id and revision.
#[must_use]
pub fn find_omitted_by_version<'a>(
&'a self,
source_id: &str,
version: &str,
library: &'a [GuidanceItem],
) -> Option<&'a GuidanceItem> {
let omitted = self
.omitted_items
.iter()
.find(|item| item.source_id == source_id && item.version == version)?;
library
.iter()
.find(|g| g.id == omitted.source_id && g.version == omitted.version)
}
/// Verifies that the spans recorded in this receipt match the actual content of a real request string.
pub fn verify_request_content(&self, text: &str) -> Result<(), Vec<String>> {
let mut errors = Vec::new();
for span in &self.included_spans {
match span.slice_bytes(text) {
Some(sliced) => {
if span.byte_len() > 0 && sliced.is_empty() {
errors.push(format!(
"span for {} was empty in request content",
span.source_id
));
}
}
None => {
errors.push(format!(
"span for {} (bytes {}..{}) out of bounds for request of length {}",
span.source_id,
span.byte_start,
span.byte_end,
text.len()
));
}
}
}
if errors.is_empty() {
Ok(())
} else {
Err(errors)
}
}
/// Verifies that the spans recorded in this receipt match the system message content of a real Message slice.
pub fn verify_request_messages(&self, messages: &[Message]) -> Result<(), Vec<String>> {
let system_contents: Vec<&str> = messages
.iter()
.filter(|m| m.role == crate::models::Role::System)
.filter_map(|m| m.content.as_deref())
.collect();
if system_contents.is_empty() && !self.included_spans.is_empty() {
return Err(vec![
"no system messages found in request messages".to_string()
]);
}
let combined = system_contents.join("\n\n");
self.verify_request_content(&combined)
}
}
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub struct PromptBlockReceipt {
/// Position in the exact message array sent to the provider.
pub index: usize,
/// Native provider role for this block.
pub role: String,
/// SHA-256 of the canonical JSON bytes for this message.
pub sha256: String,
/// Number of canonical JSON bytes represented by `sha256`.
pub byte_len: usize,
}
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum RequestChange {
Initial,
Unchanged,
Appended,
Replaced,
Rebuilt,
}
/// Receipt for the real prompt-bearing message sequence.
///
/// This is deliberately outside the provider prompt. The block digests make the
/// stable prefix and the first semantic change inspectable without re-rendering
/// prior tail text into a new header.
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub struct FirstChangeReceipt {
pub receipt_id: String,
pub session_id: String,
pub timestamp_ms: i64,
pub first_changed_block: Option<usize>,
pub stable_prefix_blocks: usize,
pub stable_prefix_sha256: String,
pub change: RequestChange,
pub reason: String,
pub blocks: Vec<PromptBlockReceipt>,
}
/// The one compiled request representation used by daemon/provider callers.
#[derive(Clone, Debug, Serialize, Deserialize)]
pub struct RequestAssembly {
pub messages: Vec<Message>,
pub context_receipt: ContextReceipt,
pub first_change: FirstChangeReceipt,
}
fn canonical_message_bytes(message: &Message) -> Vec<u8> {
// Message serialization is infallible for the closed provider message type.
// Keep a total fallback so receipt generation cannot panic on a future field.
serde_json::to_vec(message).unwrap_or_default()
}
impl ContextReceipt {
/// Compile a provider request around a stable leading system header.
///
/// Leading system messages are assembled once as one required guidance block;
/// every non-system message remains an ordered causal tail. This shape makes
/// it impossible for a previously sent tail message to become part of a
/// regenerated header merely because a later turn was assembled.
#[must_use]
pub fn compile_messages(
messages: Vec<Message>,
prefix: StablePrefixInputs<'_>,
previous: Option<&FirstChangeReceipt>,
) -> RequestAssembly {
let header_count = messages
.iter()
.take_while(|message| message.role == Role::System)
.count();
let header_content = messages[..header_count]
.iter()
.filter_map(|message| message.content.as_deref())
.collect::<Vec<_>>()
.join("\n\n");
let header_version = sha256_hex(header_content.as_bytes());
let (header_message, guidance) = if header_content.is_empty() {
GuidanceAssemblyEngine::assemble(
&[],
prefix.guidance_budget_tokens,
prefix.session_id,
prefix.behavior_revision.or(Some(header_version.as_str())),
0,
)
} else {
let item = GuidanceItem::required(
"request:header",
header_version.clone(),
"request/header",
header_content.clone(),
);
GuidanceAssemblyEngine::assemble(
&[item],
prefix.guidance_budget_tokens,
prefix.session_id,
prefix.behavior_revision.or(Some(header_version.as_str())),
0,
)
};
let mut compiled = Vec::with_capacity(messages.len().saturating_sub(header_count) + 2);
let orientation_msg = prefix.orientation.map(orientation_message);
let orientation_placement = prefix.orientation.map(|r| r.carrier_placement.as_str());
// Placement handling: leading_system or header placed first
if let (Some(msg), Some(place)) = (&orientation_msg, orientation_placement) {
if place == "leading_system" || place == "header" {
compiled.push(msg.clone());
}
}
if let Some(msg) = header_message {
compiled.push(msg);
}
// Scoped directive placed between header and conversation tail
if let (Some(msg), Some(place)) = (&orientation_msg, orientation_placement) {
if place == "scoped_directive" {
compiled.push(msg.clone());
}
}
compiled.extend(messages.into_iter().skip(header_count));
// Tail placement
if let (Some(msg), Some(place)) = (&orientation_msg, orientation_placement) {
if place == "tail" {
compiled.push(msg.clone());
}
}
let mut blocks = Vec::with_capacity(compiled.len());
let mut serialized_blocks = Vec::with_capacity(compiled.len());
for (index, message) in compiled.iter().enumerate() {
let bytes = canonical_message_bytes(message);
blocks.push(PromptBlockReceipt {
index,
role: message.role.as_str().to_string(),
sha256: sha256_hex(&bytes),
byte_len: bytes.len(),
});
serialized_blocks.push(bytes);
}
let common = previous
.map(|prior| {
prior
.blocks
.iter()
.zip(blocks.iter())
.take_while(|(old, new)| old.role == new.role && old.sha256 == new.sha256)
.count()
})
.unwrap_or(0);
let (first_changed_block, change, reason) = match previous {
None => (
(!blocks.is_empty()).then_some(0),
RequestChange::Initial,
"initial request header and causal tail".to_string(),
),
Some(prior) if common == blocks.len() && blocks.len() == prior.blocks.len() => (
None,
RequestChange::Unchanged,
"prompt-bearing request unchanged".to_string(),
),
Some(prior) if common == prior.blocks.len() && blocks.len() > prior.blocks.len() => (
Some(common),
RequestChange::Appended,
"new causal evidence appended after the stable prefix".to_string(),
),
Some(prior) if blocks.len() < prior.blocks.len() && common == blocks.len() => (
Some(common),
RequestChange::Rebuilt,
"causal history was rebuilt after the prior request".to_string(),
),
Some(_) => (
Some(common),
RequestChange::Replaced,
"prompt-bearing block changed deliberately".to_string(),
),
};
let mut prefix_bytes = Vec::new();
for bytes in serialized_blocks.iter().take(common) {
prefix_bytes.extend_from_slice(bytes);
}
let stable_prefix_sha256 = sha256_hex(&prefix_bytes);
let mut guidance = guidance;
if let Some(epoch) = prefix.context_epoch {
guidance.context_epoch = Some(epoch.to_string());
}
if let Some(base) = prefix.base_orientation_id {
guidance.base_orientation_id = Some(base.to_string());
}
if let Some(eff) = prefix.effective_orientation_id {
guidance.effective_orientation_id = Some(eff.to_string());
}
if let Some(req_id) = prefix.request_id {
guidance.request_id = Some(req_id.to_string());
}
if let Some(att_id) = prefix.attempt_id {
guidance.attempt_id = Some(att_id.to_string());
}
if let Some(renderer) = prefix.renderer {
guidance.renderer = Some(renderer.to_string());
}
if let Some(revision) = prefix.orientation {
guidance.carrier_hash = Some(revision.carrier_hash.clone());
guidance.carrier_role = Some(revision.carrier_role.as_str().to_string());
guidance.carrier_placement = Some(revision.carrier_placement.as_str().to_string());
guidance.carrier_scope = Some(revision.scope.label());
guidance.carrier_mode = Some(revision.mode.as_str().to_string());
guidance.first_object = Some(revision.target.clone());
if guidance.renderer.is_none() {
guidance.renderer = Some(ORIENTATION_RENDERER.to_string());
}
guidance.source_links = revision.source_refs.clone();
guidance.frontier_state = Some("ready".to_string());
}
let first_change = FirstChangeReceipt {
receipt_id: guidance.receipt_id.clone(),
session_id: prefix.session_id.to_string(),
timestamp_ms: 0,
first_changed_block,
stable_prefix_blocks: common,
stable_prefix_sha256,
change,
reason,
blocks,
};
RequestAssembly {
messages: compiled,
context_receipt: guidance,
first_change,
}
}
}
/// Engine for assembling scoped guidance under bounded token budgets.
pub struct GuidanceAssemblyEngine;
impl GuidanceAssemblyEngine {
/// Assembles guidance items into provider system messages while emitting an explicit ContextReceipt.
///
/// Rules:
/// 1. Small required directives must be delivered intact.
/// 2. If required directives exceed budget, never silently truncate; return UnresolvedBudget.
/// 3. Conflicting directives return Conflict.
/// 4. Optional items are included by priority until budget is reached; excess items are recorded
/// with explicit omission reasons and recoverable source references.
pub fn assemble(
items: &[GuidanceItem],
budget_tokens: usize,
session_id: &str,
behavior_revision: Option<&str>,
now_ms: i64,
) -> (Option<Message>, ContextReceipt) {
let receipt_id = format!("rcpt-{}", uuid::Uuid::new_v4().simple());
// Check for conflicts: e.g., identical scopes with contradictory directives
let mut scope_map: HashMap<&str, Vec<&GuidanceItem>> = HashMap::new();
for item in items {
scope_map.entry(&item.scope).or_default().push(item);
}
for (scope, scoped_items) in &scope_map {
if scoped_items.len() > 1 && scope.starts_with("exclusive:") {
let ids = scoped_items.iter().map(|i| i.id.clone()).collect();
let receipt = ContextReceipt::new(ContextReceiptInit {
receipt_id,
session_id: session_id.to_string(),
timestamp_ms: now_ms,
behavior_revision: behavior_revision.map(String::from),
included_spans: Vec::new(),
omitted_items: Vec::new(),
guidance_status: GuidanceStatus::Conflict {
conflicting_ids: ids,
reason: format!("mutually exclusive directives present for scope {scope}"),
},
total_budget_tokens: budget_tokens,
consumed_tokens: 0,
});
return (None, receipt);
}
}
// Separate required vs optional
let mut required = Vec::new();
let mut optional = Vec::new();
for item in items {
if item.is_required() {
required.push(item);
} else {
optional.push(item);
}
}
// Calculate required tokens
let required_tokens: usize = required.iter().map(|i| i.estimated_tokens).sum();
if required_tokens > budget_tokens {
let receipt = ContextReceipt::new(ContextReceiptInit {
receipt_id,
session_id: session_id.to_string(),
timestamp_ms: now_ms,
behavior_revision: behavior_revision.map(String::from),
included_spans: Vec::new(),
omitted_items: Vec::new(),
guidance_status: GuidanceStatus::UnresolvedBudget {
required_tokens,
available_tokens: budget_tokens,
},
total_budget_tokens: budget_tokens,
consumed_tokens: 0,
});
return (None, receipt);
}
// Optional items sorted by priority (highest first)
optional.sort_by(|a, b| {
let p_a = match a.requirement {
GuidanceRequirement::Optional { priority } => priority,
_ => 0,
};
let p_b = match b.requirement {
GuidanceRequirement::Optional { priority } => priority,
_ => 0,
};
p_b.cmp(&p_a)
});
let mut included_spans = Vec::new();
let mut omitted_items = Vec::new();
let mut assembled_content = String::new();
let mut consumed_tokens = 0;
// 1. Include all required items intact
for item in required {
if !assembled_content.is_empty() {
assembled_content.push_str("\n\n");
}
let byte_start = assembled_content.len();
let char_start = assembled_content.chars().count();
assembled_content.push_str(&item.content);
let byte_end = assembled_content.len();
let char_end = char_start + item.content.chars().count();
included_spans.push(IncludedSpan::new(
item.id.clone(),
item.version.clone(),
item.scope.clone(),
byte_start,
byte_end,
char_start,
char_end,
item.estimated_tokens,
true,
));
consumed_tokens += item.estimated_tokens;
}
// 2. Budget optional items
let remaining_budget = budget_tokens.saturating_sub(consumed_tokens);
let mut optional_budget_left = remaining_budget;
for item in optional {
if item.estimated_tokens <= optional_budget_left {
if !assembled_content.is_empty() {
assembled_content.push_str("\n\n");
}
let byte_start = assembled_content.len();
let char_start = assembled_content.chars().count();
assembled_content.push_str(&item.content);
let byte_end = assembled_content.len();
let char_end = char_start + item.content.chars().count();
included_spans.push(IncludedSpan::new(
item.id.clone(),
item.version.clone(),
item.scope.clone(),
byte_start,
byte_end,
char_start,
char_end,
item.estimated_tokens,
true,
));
optional_budget_left = optional_budget_left.saturating_sub(item.estimated_tokens);
consumed_tokens += item.estimated_tokens;
} else {
omitted_items.push(OmittedItem {
source_id: item.id.clone(),
version: item.version.clone(),
scope: item.scope.clone(),
reason: OmissionReason::BudgetExceeded {
budget: budget_tokens,
needed: item.estimated_tokens,
},
recoverable_ref: format!("guidance:{}@{}", item.id, item.version),
});
}
}
let message = if assembled_content.is_empty() {
None
} else {
Some(Message::system(assembled_content))
};
let receipt = ContextReceipt::new(ContextReceiptInit {
receipt_id,
session_id: session_id.to_string(),
timestamp_ms: now_ms,
behavior_revision: behavior_revision.map(String::from),
included_spans,
omitted_items,
guidance_status: GuidanceStatus::DeliveredIntact,
total_budget_tokens: budget_tokens,
consumed_tokens,
});
(message, receipt)
}
}
/// Persistent snapshot containing guidance items and context receipts across sessions/restarts.
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub struct GuidanceStoreSnapshot {
pub items: Vec<GuidanceItem>,
pub receipts: Vec<ContextReceipt>,
}
/// Durable store for guidance directives and context receipts across sessions/restarts (G11).
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct GuidanceStore {
items: Vec<GuidanceItem>,
receipts: Vec<ContextReceipt>,
}
impl GuidanceStore {
#[must_use]
pub fn new() -> Self {
Self {
items: Vec::new(),
receipts: Vec::new(),
}
}
/// Inserts or replaces a guidance item. Replaces only if both id and version match.
pub fn insert_item(&mut self, item: GuidanceItem) {
if let Some(pos) = self
.items
.iter()
.position(|i| i.id == item.id && i.version == item.version)
{
self.items[pos] = item;
} else {
self.items.push(item);
}
}
/// Retrieve the latest revision of an item by id.
#[must_use]
pub fn get_item(&self, id: &str) -> Option<&GuidanceItem> {
self.items.iter().rev().find(|i| i.id == id)
}
/// Retrieve an exact historical revision of an item by id and version.
#[must_use]
pub fn get_item_version(&self, id: &str, version: &str) -> Option<&GuidanceItem> {
self.items
.iter()
.find(|i| i.id == id && i.version == version)
}
#[must_use]
pub fn list_items(&self) -> &[GuidanceItem] {
&self.items
}
pub fn record_receipt(&mut self, receipt: ContextReceipt) {
self.receipts.push(receipt);
}
#[must_use]
pub fn receipts(&self) -> &[ContextReceipt] {
&self.receipts
}
#[must_use]
pub fn get_receipt(&self, receipt_id: &str) -> Option<&ContextReceipt> {
self.receipts.iter().find(|r| r.receipt_id == receipt_id)
}
#[must_use]
pub fn latest_receipt(&self, session_id: &str) -> Option<&ContextReceipt> {
self.receipts
.iter()
.rev()
.find(|r| r.session_id == session_id)
}
/// Serialize items and context receipts to JSON string for persistent storage.
pub fn export_json(&self) -> Result<String, serde_json::Error> {
let snapshot = GuidanceStoreSnapshot {
items: self.items.clone(),
receipts: self.receipts.clone(),
};
serde_json::to_string(&snapshot)
}
/// Restore items and context receipts from JSON string after restart (G11).
pub fn import_json(&mut self, json_str: &str) -> Result<(), serde_json::Error> {
if let Ok(snapshot) = serde_json::from_str::<GuidanceStoreSnapshot>(json_str) {
for item in snapshot.items {
self.insert_item(item);
}
for receipt in snapshot.receipts {
self.record_receipt(receipt);
}
return Ok(());
}
let loaded: Vec<GuidanceItem> = serde_json::from_str(json_str)?;
for item in loaded {
self.insert_item(item);
}
Ok(())
}
}
impl Default for GuidanceStore {
fn default() -> Self {
Self::new()
}
}