fix: harden subject memory delivery

This commit is contained in:
2026-10-02 09:05:11 +09:00
parent 07f2b0e8a3
commit f50a0caea9
6 changed files with 1170 additions and 183 deletions
+3
View File
@@ -30,6 +30,9 @@ pub enum ToolError {
/// Internal error
#[error("Internal error: {0}")]
Internal(String),
/// Machine-readable domain conflict that a tool adapter may project as structured output.
#[error("{code}: {message}")]
StructuredConflict { code: String, message: String },
/// Cooperative cancellation completed with bounded terminal output.
#[error("Tool execution cancelled")]
Cancelled(ToolOutput),
+26 -1
View File
@@ -4619,6 +4619,12 @@ pub struct SubjektivMemoryValidateProposalRequest {
pub intent: SubjektivMemoryRevisionIntent,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, JsonSchema)]
#[serde(deny_unknown_fields)]
pub struct SubjektivMemoryReceiptStatusRequest {
pub receipt_id: String,
}
/// Host-resolved staging input. Subject scope and evidence origin are never
/// accepted from model-visible tool arguments.
#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
@@ -4646,6 +4652,7 @@ pub enum SubjektivMemoryBackendOperation {
Read(SubjektivMemoryReadRequest),
ListRevisions(SubjektivMemoryListRevisionsRequest),
ValidateProposal(SubjektivMemoryValidateProposalRequest),
ReceiptStatus(SubjektivMemoryReceiptStatusRequest),
StageExplicit(SubjektivMemoryStageExplicitRequest),
}
@@ -4682,7 +4689,13 @@ pub struct SubjektivMemoryQueryResponse {
pub struct SubjektivMemoryEvidenceCandidate {
pub candidate_id: String,
pub evidence: Vec<memory::extract::StagingEvidence>,
#[schemars(range(min = 0, max = 9_007_199_254_740_991_usize))]
pub evidence_total: usize,
pub evidence_truncated: bool,
pub source_refs: Vec<memory::schema::SourceEvidenceRef>,
#[schemars(range(min = 0, max = 9_007_199_254_740_991_usize))]
pub source_refs_total: usize,
pub source_refs_truncated: bool,
}
#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
@@ -4756,10 +4769,21 @@ pub struct SubjektivMemoryProposalValidationResponse {
pub intent: SubjektivMemoryRevisionIntent,
}
#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
#[serde(rename_all = "snake_case")]
pub enum SubjektivMemoryReceiptStatus {
PendingCommit,
Staged,
Missing,
}
#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
#[serde(deny_unknown_fields)]
pub struct SubjektivMemoryReceiptStatusResponse {
pub receipt_id: String,
pub status: SubjektivMemoryReceiptStatus,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub candidate_id: Option<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
@@ -4781,6 +4805,7 @@ pub enum SubjektivMemoryBackendResponse {
Read(SubjektivMemoryReadResponse),
ListRevisions(SubjektivMemoryListRevisionsResponse),
ProposalValidated(SubjektivMemoryProposalValidationResponse),
ReceiptStatus(SubjektivMemoryReceiptStatusResponse),
Staged(SubjektivMemoryStageExplicitResponse),
}
File diff suppressed because it is too large Load Diff
+57
View File
@@ -4,6 +4,7 @@
//! assigns append-stable `SessionEntryRef` values, and provides sparse overview, bounded
//! range/search, read, and generic evidence projections without granting mutation authority.
use std::collections::HashSet;
use std::sync::Arc;
use crate::session_history::{SessionHistoryMetadata, WorkerHistoryProvenance};
@@ -207,6 +208,14 @@ pub(crate) struct ReadResult {
pub truncated: bool,
}
#[derive(Debug, Clone)]
pub(crate) struct CommittedToolCall {
pub call_id: String,
pub name: String,
pub arguments: String,
pub evidence: SessionEntryEvidence,
}
#[derive(Debug, Clone)]
pub(crate) struct SessionEntryEvidence {
pub segment_id: String,
@@ -622,6 +631,54 @@ impl SessionCapture {
self.evidence_for(reference.id.as_str())
}
pub(crate) fn committed_pending_tool_calls(&self, names: &[&str]) -> Vec<CommittedToolCall> {
let pending_call_ids = self
.entries
.iter()
.filter_map(|entry| {
let Item::ToolResult {
call_id,
content: Some(content),
is_error: false,
..
} = &entry.item
else {
return None;
};
(serde_json::from_str::<serde_json::Value>(content)
.ok()
.and_then(|value| value.get("status").cloned())
.as_ref()
.and_then(serde_json::Value::as_str)
== Some("pending_commit"))
.then_some(call_id.as_str())
})
.collect::<HashSet<_>>();
self.entries
.iter()
.filter_map(|entry| {
let Item::ToolCall {
call_id,
name,
arguments,
..
} = &entry.item
else {
return None;
};
if !names.contains(&name.as_str()) || !pending_call_ids.contains(call_id.as_str()) {
return None;
}
Some(CommittedToolCall {
call_id: call_id.clone(),
name: name.clone(),
arguments: arguments.clone(),
evidence: self.evidence_for_tool_call(call_id)?,
})
})
.collect()
}
pub(crate) fn evidence_for(&self, id: &str) -> Option<SessionEntryEvidence> {
let entry = self.index.iter().find(|entry| entry.id.as_str() == id)?;
let excerpt = self
+442 -9
View File
@@ -16776,6 +16776,9 @@ const SUBJEKTIV_QUERY_MAX_LIMIT: usize = 100;
const SUBJEKTIV_BODY_DEFAULT_LINES: usize = 200;
const SUBJEKTIV_BODY_MAX_LINES: usize = 1_000;
const SUBJEKTIV_EVIDENCE_PAGE_SIZE: usize = 20;
const SUBJEKTIV_CANDIDATE_ANCHOR_LIMIT: usize = 20;
const SUBJEKTIV_ANCHOR_TEXT_MAX_BYTES: usize = 64;
const SUBJEKTIV_NESTED_EVIDENCE_MAX_BYTES: usize = 32 * 1024;
#[derive(Debug, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
@@ -16840,6 +16843,23 @@ async fn scoped_subjektiv_memory_backend(
subjektiv_memory_validate_proposal(&store, &subject_id, input)?,
)
}
server_api::SubjektivMemoryBackendOperation::ReceiptStatus(input) => {
validate_subjektiv_receipt_id(&input.receipt_id)?;
let candidate = store
.staging_candidate(&subject_id, &input.receipt_id)
.map_err(subjektiv_store_error)?;
server_api::SubjektivMemoryBackendResponse::ReceiptStatus(
server_api::SubjektivMemoryReceiptStatusResponse {
receipt_id: input.receipt_id,
status: if candidate.is_some() {
server_api::SubjektivMemoryReceiptStatus::Staged
} else {
server_api::SubjektivMemoryReceiptStatus::Missing
},
candidate_id: candidate.map(|record| record.id),
},
)
}
server_api::SubjektivMemoryBackendOperation::StageExplicit(input) => {
// Re-derive the same subject and immutable Session attribution for the
// write. The request never carries subject, Worker, Runtime, origin,
@@ -16891,7 +16911,7 @@ fn subjektiv_memory_query(
}
if cursor.store_revision != subject.store_revision {
return Err(Error::RepositoryConflict(
"subjektiv query cursor is stale because Memory changed; restart the query".into(),
"stale_cursor: subjektiv query cursor is stale because Memory changed; restart the query".into(),
)
.into());
}
@@ -16983,6 +17003,12 @@ fn subjektiv_memory_read(
.into());
}
let body_offset = input.offset.unwrap_or(0);
if body_offset > 0 && input.revision.is_none() {
return Err(Error::InvalidInput(
"body continuation requires the exact revision returned by the first read".into(),
)
.into());
}
let lines = memory_body_lines(&record.body_md);
if body_offset > lines.len() {
return Err(Error::InvalidInput("body line offset exceeds document length".into()).into());
@@ -17021,6 +17047,8 @@ fn subjektiv_memory_read(
let mut source_candidate_ids = Vec::new();
let mut source_candidates = Vec::new();
let mut derived_from = Vec::new();
let mut evidence_budget = SUBJEKTIV_CANDIDATE_ANCHOR_LIMIT;
let mut source_ref_budget = SUBJEKTIV_CANDIDATE_ANCHOR_LIMIT;
for index in evidence_offset..evidence_end {
if let Some(candidate_id) = record.source_candidate_ids.get(index) {
let candidate = store
@@ -17032,10 +17060,30 @@ fn subjektiv_memory_read(
))
})?;
source_candidate_ids.push(candidate_id.clone());
let evidence_total = candidate.evidence.len();
let source_refs_total = candidate.source_refs.len();
let evidence_take = evidence_total.min(evidence_budget);
let source_refs_take = source_refs_total.min(source_ref_budget);
evidence_budget -= evidence_take;
source_ref_budget -= source_refs_take;
source_candidates.push(server_api::SubjektivMemoryEvidenceCandidate {
candidate_id: candidate.id,
evidence: candidate.evidence,
source_refs: candidate.source_refs,
evidence: candidate
.evidence
.into_iter()
.take(evidence_take)
.map(bounded_subjektiv_staging_evidence)
.collect(),
evidence_total,
evidence_truncated: evidence_total > evidence_take,
source_refs: candidate
.source_refs
.into_iter()
.take(source_refs_take)
.map(bounded_subjektiv_source_ref)
.collect(),
source_refs_total,
source_refs_truncated: source_refs_total > source_refs_take,
});
} else {
let derived_index = index - record.source_candidate_ids.len();
@@ -17046,6 +17094,7 @@ fn subjektiv_memory_read(
});
}
}
bound_subjektiv_nested_evidence(&mut source_candidates)?;
let evidence_has_more = evidence_end < total_evidence;
let evidence_next_cursor = evidence_has_more
.then(|| {
@@ -17180,21 +17229,35 @@ fn subjektiv_memory_validate_proposal(
})
}
fn validate_subjektiv_receipt_id(receipt_id: &str) -> ApiResult<()> {
let receipt_id = receipt_id.trim();
if receipt_id.is_empty() || receipt_id.len() > 512 || receipt_id.chars().any(char::is_control) {
return Err(Error::InvalidInput("explicit Memory receipt_id is invalid".into()).into());
}
Ok(())
}
fn subjektiv_memory_stage_explicit(
store: &crate::subjektiv::SubjektivStore,
mut input: server_api::SubjektivMemoryStageExplicitRequest,
attribution: crate::subjektiv::SubjectSessionAttribution,
) -> ApiResult<server_api::SubjektivMemoryStageExplicitResponse> {
validate_subjektiv_receipt_id(&input.receipt_id)?;
let receipt_id = input.receipt_id.trim();
if receipt_id.is_empty() || receipt_id.len() > 512 || receipt_id.chars().any(char::is_control) {
return Err(Error::InvalidInput("explicit Memory receipt_id is invalid".into()).into());
}
if input.evidence.is_empty() || input.source_refs.is_empty() {
return Err(Error::InvalidInput(
"explicit Memory staging requires host-resolved committed evidence".into(),
)
.into());
}
if input.evidence.len() > SUBJEKTIV_CANDIDATE_ANCHOR_LIMIT
|| input.source_refs.len() > SUBJEKTIV_CANDIDATE_ANCHOR_LIMIT
{
return Err(Error::InvalidInput(format!(
"explicit Memory evidence/source_refs are limited to {SUBJEKTIV_CANDIDATE_ANCHOR_LIMIT} items each"
))
.into());
}
if matches!(input.kind, memory::extract::CandidateKind::Preference)
&& input.evidence.iter().any(|evidence| {
!matches!(
@@ -17454,6 +17517,103 @@ fn memory_body_lines(body: &str) -> Vec<&str> {
}
}
fn bound_subjektiv_nested_evidence(
candidates: &mut [server_api::SubjektivMemoryEvidenceCandidate],
) -> ApiResult<()> {
loop {
let serialized = serde_json::to_vec(candidates)
.map_err(|error| Error::Store(format!("encode subjektiv evidence page: {error}")))?;
if serialized.len() <= SUBJEKTIV_NESTED_EVIDENCE_MAX_BYTES {
return Ok(());
}
let mut removed = false;
for candidate in candidates.iter_mut().rev() {
if candidate.source_refs.pop().is_some() {
candidate.source_refs_truncated = true;
removed = true;
break;
}
if candidate.evidence.pop().is_some() {
candidate.evidence_truncated = true;
removed = true;
break;
}
}
if !removed {
return Err(Error::Store(
"subjektiv evidence metadata exceeds its response byte budget".into(),
)
.into());
}
}
}
fn bounded_subjektiv_staging_evidence(
mut evidence: memory::extract::StagingEvidence,
) -> memory::extract::StagingEvidence {
evidence.kind = memory::schema::EvidenceKind::new(bounded_utf8_bytes(
evidence.kind.as_str().to_string(),
SUBJEKTIV_ANCHOR_TEXT_MAX_BYTES,
));
evidence.origin = evidence.origin.map(bounded_subjektiv_evidence_origin);
evidence.excerpt = evidence
.excerpt
.map(|value| bounded_utf8_bytes(value, SUBJEKTIV_ANCHOR_TEXT_MAX_BYTES));
evidence.summary = evidence
.summary
.map(|value| bounded_utf8_bytes(value, SUBJEKTIV_ANCHOR_TEXT_MAX_BYTES));
evidence
}
fn bounded_subjektiv_source_ref(
mut source: memory::schema::SourceEvidenceRef,
) -> memory::schema::SourceEvidenceRef {
source.origin = source.origin.map(bounded_subjektiv_evidence_origin);
source.evidence_kind = source.evidence_kind.map(|kind| {
memory::schema::EvidenceKind::new(bounded_utf8_bytes(
kind.as_str().to_string(),
SUBJEKTIV_ANCHOR_TEXT_MAX_BYTES,
))
});
source.label = source
.label
.map(|value| bounded_utf8_bytes(value, SUBJEKTIV_ANCHOR_TEXT_MAX_BYTES));
source.summary = source
.summary
.map(|value| bounded_utf8_bytes(value, SUBJEKTIV_ANCHOR_TEXT_MAX_BYTES));
source
}
fn bounded_subjektiv_evidence_origin(
mut origin: memory::schema::EvidenceOrigin,
) -> memory::schema::EvidenceOrigin {
for field in [
&mut origin.account_id,
&mut origin.workspace_id,
&mut origin.runtime_id,
&mut origin.worker_id,
&mut origin.flow_selector,
&mut origin.flow_definition_id,
] {
if let Some(value) = field.take() {
*field = Some(bounded_utf8_bytes(value, SUBJEKTIV_ANCHOR_TEXT_MAX_BYTES));
}
}
origin
}
fn bounded_utf8_bytes(value: String, max_bytes: usize) -> String {
if value.len() <= max_bytes {
return value;
}
let marker = "…";
let mut end = max_bytes.saturating_sub(marker.len());
while end > 0 && !value.is_char_boundary(end) {
end -= 1;
}
format!("{}{marker}", &value[..end])
}
fn bounded_memory_excerpt(body: &str, max_chars: usize) -> String {
let normalized = body.split_whitespace().collect::<Vec<_>>().join(" ");
let mut chars = normalized.chars();
@@ -28772,12 +28932,27 @@ impl From<Error> for ApiError {
severity: HostDiagnosticSeverity::Error,
message: sanitize_backend_error(&ticket_error.to_string()),
}],
Error::RepositoryConflict(message) => subjektiv_conflict_code(message)
.map(|code| {
vec![RuntimeDiagnostic {
code: code.to_string(),
severity: HostDiagnosticSeverity::Error,
message: sanitize_backend_error(message),
}]
})
.unwrap_or_default(),
_ => Vec::new(),
};
Self { error, diagnostics }
}
}
fn subjektiv_conflict_code(message: &str) -> Option<&'static str> {
["revision_conflict", "stale_cursor"]
.into_iter()
.find(|code| message.starts_with(code))
}
impl ApiError {
fn with_diagnostics(error: Error, diagnostics: Vec<RuntimeDiagnostic>) -> Self {
Self { error, diagnostics }
@@ -34968,12 +35143,46 @@ mod tests {
excerpt: Some("Correct the first rule".to_string()),
summary: Some("Human correction".to_string()),
};
let oversized = scoped_subjektiv_memory_backend(
State(api.clone()),
AxumPath(ScopedWorkspacePath {
workspace_id: TEST_WORKSPACE_ID.to_string(),
}),
context.clone(),
Json(server_api::SubjektivMemoryBackendRequest {
operation: server_api::SubjektivMemoryBackendOperation::StageExplicit(
server_api::SubjektivMemoryStageExplicitRequest {
receipt_id: "explicit:session-1:oversized".to_string(),
session_id: "session-1".to_string(),
kind: memory::extract::CandidateKind::Decision,
claim: "Too many anchors".to_string(),
why_useful: "Prove response bounds".to_string(),
staleness: None,
evidence: vec![
explicit_evidence.clone();
SUBJEKTIV_CANDIDATE_ANCHOR_LIMIT + 1
],
source_refs: vec![memory::schema::SourceEvidenceRef {
segment_id: Some("segment-1".to_string()),
entry_range: Some([0, 0]),
evidence_id: Some("E00000000".to_string()),
..Default::default()
}],
proposal: None,
},
),
}),
)
.await
.unwrap_err();
assert!(matches!(oversized.error, Error::InvalidInput(_)));
let Json(explicit) = scoped_subjektiv_memory_backend(
State(api.clone()),
AxumPath(ScopedWorkspacePath {
workspace_id: TEST_WORKSPACE_ID.to_string(),
}),
context,
context.clone(),
Json(server_api::SubjektivMemoryBackendRequest {
operation: server_api::SubjektivMemoryBackendOperation::StageExplicit(
server_api::SubjektivMemoryStageExplicitRequest {
@@ -35007,6 +35216,34 @@ mod tests {
other => panic!("unexpected explicit response: {other:?}"),
};
assert_eq!(staged.candidate_id, "explicit:session-1:call-1");
let Json(status) = scoped_subjektiv_memory_backend(
State(api.clone()),
AxumPath(ScopedWorkspacePath {
workspace_id: TEST_WORKSPACE_ID.to_string(),
}),
context,
Json(server_api::SubjektivMemoryBackendRequest {
operation: server_api::SubjektivMemoryBackendOperation::ReceiptStatus(
server_api::SubjektivMemoryReceiptStatusRequest {
receipt_id: staged.receipt_id.clone(),
},
),
}),
)
.await
.unwrap();
let status = match status {
server_api::SubjektivMemoryBackendResponse::ReceiptStatus(status) => status,
other => panic!("unexpected receipt response: {other:?}"),
};
assert_eq!(
status.status,
server_api::SubjektivMemoryReceiptStatus::Staged
);
assert_eq!(
status.candidate_id.as_deref(),
Some(staged.candidate_id.as_str())
);
let candidate = store
.staging_candidate(&subject.id, &staged.candidate_id)
.unwrap()
@@ -35067,6 +35304,18 @@ mod tests {
store
.revise_memory(&subject.id, &second.id, 1, resolved)
.unwrap();
let revision_conflict = subjektiv_memory_validate_proposal(
&store,
&subject.id,
server_api::SubjektivMemoryValidateProposalRequest {
memory_id: second.id.clone(),
expected_revision: 1,
intent: server_api::SubjektivMemoryRevisionIntent::Revise,
},
)
.unwrap_err();
let revision_api = ApiError::from(revision_conflict.error).into_repository_api_error();
assert_eq!(revision_api.diagnostics[0].code, "revision_conflict");
let default_page = subjektiv_memory_query(
&store,
@@ -35142,7 +35391,51 @@ mod tests {
},
)
.unwrap_err();
assert!(matches!(stale.error, Error::RepositoryConflict(_)));
assert!(matches!(&stale.error, Error::RepositoryConflict(_)));
let stale_api = ApiError::from(stale.error).into_repository_api_error();
assert_eq!(stale_api.diagnostics[0].code, "stale_cursor");
let initial_read = subjektiv_memory_read(
&store,
&subject.id,
server_api::SubjektivMemoryReadRequest {
memory_id: first.id.clone(),
revision: None,
offset: None,
limit: Some(1),
evidence_cursor: None,
},
)
.unwrap();
assert_eq!(initial_read.revision, 1);
assert_eq!(initial_read.body_next_offset, Some(1));
store
.revise_memory(
&subject.id,
&first.id,
1,
crate::subjektiv::MemoryDraft::active(
memory::extract::CandidateKind::Decision,
"Choose stable cursors",
"replacement one\nreplacement two\n",
"Prevents mixed snapshots",
"replace current body",
),
)
.unwrap();
let unbound_continuation = subjektiv_memory_read(
&store,
&subject.id,
server_api::SubjektivMemoryReadRequest {
memory_id: first.id.clone(),
revision: None,
offset: initial_read.body_next_offset,
limit: Some(1),
evidence_cursor: None,
},
)
.unwrap_err();
assert!(matches!(unbound_continuation.error, Error::InvalidInput(_)));
let read = subjektiv_memory_read(
&store,
@@ -35160,7 +35453,147 @@ mod tests {
assert_eq!(read.body_next_offset, Some(2));
assert!(read.body_truncated);
assert_eq!(read.revision, 1);
assert_eq!(read.current_revision, 1);
assert_eq!(read.current_revision, 2);
let nested_count = SUBJEKTIV_CANDIDATE_ANCHOR_LIMIT + 5;
let nested_evidence = (0..nested_count)
.map(|index| memory::extract::StagingEvidence {
id: format!("nested-{index}-{}", "i".repeat(220)),
kind: memory::schema::EvidenceKind::new(format!(
"custom-{index}-{}",
"k".repeat(4_000)
)),
entry_range: Some([index as u64, index as u64]),
origin: Some(memory::schema::EvidenceOrigin {
kind: memory::schema::EvidenceOriginKind::ModelOutput,
account_id: Some(format!("account-{index}-{}", "a".repeat(4_000))),
workspace_id: None,
runtime_id: Some(format!("runtime-{index}-{}", "r".repeat(4_000))),
worker_id: Some(format!("worker-{index}-{}", "w".repeat(4_000))),
flow_selector: Some(format!("flow-{index}-{}", "f".repeat(4_000))),
flow_definition_id: Some(format!("definition-{index}-{}", "d".repeat(4_000))),
flow_definition_revision: None,
}),
excerpt: Some(format!("evidence-{index}-{}", "x".repeat(4_000))),
summary: Some(format!("evidence-summary-{index}-{}", "q".repeat(4_000))),
})
.collect::<Vec<_>>();
let nested_refs = (0..nested_count)
.map(|index| memory::schema::SourceEvidenceRef {
session_id: Some(format!("session-{index}-{}", "u".repeat(220))),
segment_id: Some(format!("segment-{index}-{}", "g".repeat(220))),
entry_range: Some([index as u64, index as u64]),
evidence_id: Some(format!("nested-{index}-{}", "i".repeat(220))),
evidence_kind: Some(memory::schema::EvidenceKind::new(format!(
"source-custom-{index}-{}",
"s".repeat(4_000)
))),
label: Some(format!("source-{index}-{}", "y".repeat(4_000))),
summary: Some(format!("summary-{index}-{}", "z".repeat(4_000))),
origin: Some(memory::schema::EvidenceOrigin {
kind: memory::schema::EvidenceOriginKind::ModelOutput,
account_id: Some(format!("source-account-{index}-{}", "a".repeat(4_000))),
workspace_id: None,
runtime_id: Some(format!("source-runtime-{index}-{}", "r".repeat(4_000))),
worker_id: Some(format!("source-worker-{index}-{}", "w".repeat(4_000))),
flow_selector: Some(format!("source-flow-{index}-{}", "f".repeat(4_000))),
flow_definition_id: Some(format!(
"source-definition-{index}-{}",
"d".repeat(4_000)
)),
flow_definition_revision: Some(index as u64),
}),
..Default::default()
})
.collect::<Vec<_>>();
let nested_candidate = memory::extract::StagingRecord::from_candidate(
"candidate-nested",
"nested-dedup",
memory::schema::SourceRef {
segment_id: "segment-nested".to_string(),
range: [0, nested_count as u64 - 1],
},
memory::extract::ExtractedCandidate {
kind: memory::extract::CandidateKind::Lesson,
claim: "Keep nested provenance bounded".to_string(),
why_useful: "Prevents unbounded read responses".to_string(),
staleness: None,
evidence_ids: nested_evidence.iter().map(|item| item.id.clone()).collect(),
},
nested_evidence,
nested_refs,
);
let nested_candidate = store
.stage_candidate(crate::subjektiv::SubjectStagingRecord::attach(
&subject.id,
nested_candidate,
))
.unwrap();
let (nested_memory, _) = store
.apply_candidate(
&subject.id,
&nested_candidate.id,
crate::subjektiv::MemoryRevisionTarget::Create,
crate::subjektiv::MemoryDraft::active(
memory::extract::CandidateKind::Lesson,
"Keep nested provenance bounded",
"bounded body",
"Prevents unbounded read responses",
"adopt oversized historical candidate",
),
"adopt for response-bound regression",
)
.unwrap();
let nested_read = subjektiv_memory_read(
&store,
&subject.id,
server_api::SubjektivMemoryReadRequest {
memory_id: nested_memory.id,
revision: Some(1),
offset: None,
limit: None,
evidence_cursor: None,
},
)
.unwrap();
assert_eq!(nested_read.source_candidates.len(), 1);
let nested = &nested_read.source_candidates[0];
assert_eq!(nested.evidence.len(), SUBJEKTIV_CANDIDATE_ANCHOR_LIMIT);
assert_eq!(nested.evidence_total, nested_count);
assert!(nested.evidence_truncated);
assert!(!nested.source_refs.is_empty());
assert!(nested.source_refs.len() <= SUBJEKTIV_CANDIDATE_ANCHOR_LIMIT);
assert_eq!(nested.source_refs_total, nested_count);
assert!(nested.source_refs_truncated);
assert!(nested.evidence.iter().all(|item| {
item.excerpt
.as_ref()
.is_none_or(|value| value.len() <= SUBJEKTIV_ANCHOR_TEXT_MAX_BYTES)
}));
assert!(nested.evidence.iter().all(|item| {
item.kind.as_str().len() <= SUBJEKTIV_ANCHOR_TEXT_MAX_BYTES
&& item
.origin
.as_ref()
.and_then(|origin| origin.worker_id.as_ref())
.is_none_or(|value| value.len() <= SUBJEKTIV_ANCHOR_TEXT_MAX_BYTES)
}));
assert!(nested.source_refs.iter().all(|item| {
item.label
.as_ref()
.is_none_or(|value| value.len() <= SUBJEKTIV_ANCHOR_TEXT_MAX_BYTES)
&& item
.evidence_kind
.as_ref()
.is_none_or(|kind| kind.as_str().len() <= SUBJEKTIV_ANCHOR_TEXT_MAX_BYTES)
}));
assert!(
serde_json::to_vec(&nested_read.source_candidates)
.unwrap()
.len()
<= SUBJEKTIV_NESTED_EVIDENCE_MAX_BYTES
);
assert!(serde_json::to_vec(&nested_read).unwrap().len() < 64 * 1024);
let revisions = subjektiv_memory_list_revisions(
&store,
+23 -10
View File
@@ -175,28 +175,41 @@ empty `states` or `kinds` is invalid. Output defaults to 20 and is capped at 100
ordered by `updated_at DESC, memory_id ASC`. Its opaque cursor binds the subject,
canonical filters, offset, and subject `store_revision`; any confirmed-Memory
change makes it a typed stale-cursor conflict rather than silently mixing
snapshots. Revision history is ordered by revision descending. Its first page
snapshots. Revision conflicts and stale cursors retain their diagnostic code as a
structured `{status: "error", error: {code, message}}` tool result instead of
requiring models to parse prose. Revision history is ordered by revision
descending. Its first page
fixes the maximum revision, so later revisions neither duplicate nor displace old
page members.
Read accepts a positive exact revision or resolves the current revision once. A
missing historical revision never falls back to current. Markdown pagination is
line-based (default 200, maximum 1000) and reports `body_truncated` plus
`body_next_offset`. Provenance uses a separate immutable-revision-bound cursor and
returns at most 20 candidate/derivation references per page. Candidate evidence is
the bounded host-resolved anchor saved in staging; raw Session bodies are not
copied into Memory responses. Resolved, retracted, and historical revisions remain
`body_next_offset`; every nonzero continuation offset must also supply the exact
revision returned by the first page, so a current-revision change cannot mix body
versions. Provenance uses a separate immutable-revision-bound cursor and returns at
most 20 candidate/derivation references per page. Expanded candidates share one
page-wide budget of 20 evidence records and 20 source references, plus a hard 32 KiB
serialized budget for the expanded candidate array; displayed anchor text is capped
at 64 UTF-8 bytes and total/truncation metadata reports omissions.
Explicit staging rejects larger anchor sets. Candidate evidence is the bounded host-resolved anchor saved in staging; raw Session bodies are not copied
into Memory responses. Resolved, retracted, and historical revisions remain
addressable by ID.
Remember and ProposeRevision only stage candidates. Entry references are resolved
against the host's committed Session capture, and preference candidates continue
to require exclusively `HumanInput` evidence. When no entry is supplied, a
non-preference request returns `pending_commit`; after the run commits, the host
uses the committed tool-call entry itself as model-origin evidence. An operation
that never commits is not staged. Receipt identity is derived from Session and
tool-call identity, and exact backend retries return the first candidate rather
than creating another one. Neither path changes `memory_records`,
`memory_revisions`, `store_revision`, or a surface snapshot.
reconstructs pending receipts from the durable tool-call/tool-result history and
uses the committed tool-call entry itself as model-origin evidence. Receipt lookup
reports `pending_commit`, `staged`, or `missing`; the post-commit hook retries
immediately, the pre-request hook replays durable pending receipts after Worker
restore, and the fail-closed pre-rewrite hook prevents compaction from dropping an
unstaged receipt. Hook failure and process restart therefore retain an idempotent
retry path, while an operation that never commits is not staged. Receipt identity is derived from Session and tool-call identity,
and exact backend retries return the first candidate rather than creating another
one. Neither path changes `memory_records`, `memory_revisions`, `store_revision`,
or a surface snapshot.
Revision proposals add optional `revision_proposal` metadata to the existing v2
`SubjectStagingRecord` envelope; automatic extraction remains proposal-free and