fix: keep memory reads globally bounded

This commit is contained in:
2026-10-02 09:52:32 +09:00
parent 860c34b8da
commit 8f8c2676b4
5 changed files with 593 additions and 96 deletions
+9 -2
View File
@@ -4689,6 +4689,11 @@ pub struct SubjektivMemoryQueryResponse {
pub has_more: bool,
}
/// Maximum pretty-serialized model-visible content for one subject Memory read.
/// This stays below the Worker's generic 64 KiB tool-content ceiling so the
/// result remains valid JSON with explicit continuation coordinates.
pub const SUBJEKTIV_MEMORY_READ_MAX_TOOL_CONTENT_BYTES: usize = 56 * 1024;
#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
#[serde(deny_unknown_fields)]
pub struct SubjektivMemoryEvidenceCandidate {
@@ -4733,8 +4738,10 @@ pub struct SubjektivMemoryReadResponse {
#[schemars(range(min = 0, max = 9_007_199_254_740_991_usize))]
pub body_next_byte_offset: Option<usize>,
pub body_truncated: bool,
/// Candidate ids on this evidence page. The corresponding bounded anchors
/// are returned in `source_candidates` with the same order.
/// Candidate ids on this evidence page. A compatible historical candidate
/// may repeat across pages while its nested anchors advance through the same
/// evidence cursor. Corresponding bounded anchors retain the same order in
/// `source_candidates`.
pub source_candidate_ids: Vec<String>,
pub source_candidates: Vec<SubjektivMemoryEvidenceCandidate>,
/// Derivation references on this evidence page.
@@ -268,7 +268,17 @@ impl Tool for SubjektivReadTool {
}
};
let value = value.map_err(|error| ToolError::ExecutionFailed(error.to_string()))?;
Ok(json_output(summary, &value)?)
let output = json_output(summary, &value)?;
if matches!(self.operation, ReadOperation::Read)
&& output.content.as_ref().is_some_and(|content| {
content.len() > server_api::SUBJEKTIV_MEMORY_READ_MAX_TOOL_CONTENT_BYTES
})
{
return Err(ToolError::ExecutionFailed(
"subjektiv Memory read exceeded its model-visible output budget".into(),
));
}
Ok(output)
}
}
@@ -976,6 +986,40 @@ mod tests {
}
}
#[derive(Debug)]
struct ReadWorkspaceClient(server_api::SubjektivMemoryReadResponse);
impl WorkspaceClient for ReadWorkspaceClient {
fn workspace_id(&self) -> Option<&str> {
Some("workspace-test")
}
fn kind(&self) -> &str {
"read-test"
}
fn is_available(&self) -> bool {
true
}
fn execute(
&self,
request: WorkspaceRequest,
) -> Result<WorkspaceResponse, WorkspaceClientError> {
let request: SubjektivMemoryBackendRequest =
serde_json::from_str(request.body.as_deref().unwrap()).unwrap();
assert!(matches!(
request.operation,
SubjektivMemoryBackendOperation::Read(_)
));
Ok(WorkspaceResponse {
status: 200,
body: serde_json::to_string(&SubjektivMemoryBackendResponse::Read(self.0.clone()))
.unwrap(),
})
}
}
#[derive(Debug, Default)]
struct RecordingWorkspaceClient {
requests: Mutex<Vec<WorkspaceRequest>>,
@@ -1130,6 +1174,112 @@ mod tests {
assert!(parsed.entry_refs.is_empty());
}
#[tokio::test]
async fn read_tool_preserves_bounded_model_visible_json_with_escape_heavy_content() {
let origin = || memory::schema::EvidenceOrigin {
kind: memory::schema::EvidenceOriginKind::ModelOutput,
account_id: Some("a".repeat(64)),
workspace_id: Some("workspace-test".into()),
runtime_id: Some("r".repeat(64)),
worker_id: Some("w".repeat(64)),
flow_selector: Some("f".repeat(64)),
flow_definition_id: Some("d".repeat(64)),
flow_definition_revision: Some(1),
};
let evidence = (0..MAX_EVIDENCE_REFS)
.map(|index| memory::extract::StagingEvidence {
id: format!("evidence-{index}"),
kind: memory::schema::EvidenceKind::new("model_output"),
entry_range: Some([index as u64, index as u64]),
origin: Some(origin()),
excerpt: Some("\\\u{0000}".repeat(32)),
summary: Some("\\\u{0001}".repeat(32)),
})
.collect::<Vec<_>>();
let source_refs = (0..MAX_EVIDENCE_REFS)
.map(|index| memory::schema::SourceEvidenceRef {
session_id: Some(format!("session-{index}")),
segment_id: Some(format!("segment-{index}")),
entry_range: Some([index as u64, index as u64]),
evidence_id: Some(format!("evidence-{index}")),
origin: Some(origin()),
evidence_kind: Some(memory::schema::EvidenceKind::new("model_output")),
label: Some("\\\u{0002}".repeat(32)),
summary: Some("\\\u{0003}".repeat(32)),
})
.collect::<Vec<_>>();
let response = server_api::SubjektivMemoryReadResponse {
memory_id: "memory-1".into(),
revision: 1,
current_revision: 1,
kind: CandidateKind::Lesson,
state: server_api::SubjektivMemoryState::Active,
claim: "Escape-aware read".into(),
body_md: "\\\u{0000}\u{0001}".repeat(2_000),
why_useful: "Proves the actual ToolOutput remains parseable".into(),
staleness: None,
change_reason: "test".into(),
created_at: "2026-01-01T00:00:00Z".into(),
updated_at: "2026-01-01T00:00:00Z".into(),
body_offset: 0,
body_byte_offset: 0,
body_next_offset: Some(0),
body_next_byte_offset: Some(6_000),
body_truncated: true,
source_candidate_ids: vec!["candidate-1".into()],
source_candidates: vec![server_api::SubjektivMemoryEvidenceCandidate {
candidate_id: "candidate-1".into(),
evidence,
evidence_total: MAX_EVIDENCE_REFS,
evidence_truncated: false,
source_refs,
source_refs_total: MAX_EVIDENCE_REFS,
source_refs_truncated: false,
}],
derived_from: Vec::new(),
evidence_next_cursor: None,
evidence_has_more: false,
};
let mut oversized_response = response.clone();
oversized_response.body_md =
"\\\u{0000}".repeat(server_api::SUBJEKTIV_MEMORY_READ_MAX_TOOL_CONTENT_BYTES);
let tool = SubjektivReadTool {
state: SubjektivMemoryState {
client: Arc::new(ReadWorkspaceClient(response)),
capture: feature().state.capture,
},
operation: ReadOperation::Read,
};
let output = tool
.execute(
r#"{"memory_id":"memory-1"}"#,
ToolExecutionContext::new("call-read", "batch-read", 0),
)
.await
.unwrap();
let content = output.content.unwrap();
assert!(content.len() <= server_api::SUBJEKTIV_MEMORY_READ_MAX_TOOL_CONTENT_BYTES);
let value: serde_json::Value = serde_json::from_str(&content).unwrap();
assert_eq!(value["body_truncated"], true);
assert_eq!(value["body_next_byte_offset"], 6_000);
let oversized_tool = SubjektivReadTool {
state: SubjektivMemoryState {
client: Arc::new(ReadWorkspaceClient(oversized_response)),
capture: feature().state.capture,
},
operation: ReadOperation::Read,
};
let error = oversized_tool
.execute(
r#"{"memory_id":"memory-1"}"#,
ToolExecutionContext::new("call-oversized", "batch-read", 1),
)
.await
.unwrap_err();
assert!(error.to_string().contains("model-visible output budget"));
}
#[tokio::test]
async fn backend_conflict_discriminants_survive_as_structured_tool_output() {
for code in ["revision_conflict", "stale_cursor"] {
+388 -76
View File
@@ -16776,8 +16776,8 @@ const SUBJEKTIV_QUERY_MAX_LIMIT: usize = 100;
const SUBJEKTIV_BODY_DEFAULT_LINES: usize = 200;
const SUBJEKTIV_BODY_MAX_LINES: usize = 1_000;
const SUBJEKTIV_BODY_MAX_BYTES: usize = 16 * 1024;
const SUBJEKTIV_EVIDENCE_PAGE_SIZE: usize = 1;
const SUBJEKTIV_CANDIDATE_ANCHOR_LIMIT: usize = crate::subjektiv::MAX_STAGING_ANCHORS;
const SUBJEKTIV_EVIDENCE_ANCHOR_PAGE_SIZE: usize = 2;
const SUBJEKTIV_ANCHOR_TEXT_MAX_BYTES: usize = 64;
const SUBJEKTIV_NESTED_EVIDENCE_MAX_BYTES: usize = 40 * 1024;
@@ -16807,7 +16807,14 @@ struct SubjektivEvidenceCursor {
subject_id: String,
memory_id: String,
revision: u64,
/// Candidate/derivation reference offset within the immutable revision.
offset: usize,
/// Nested offsets keep compatible historical candidates with more anchors
/// resumable without admitting new over-limit records.
#[serde(default)]
evidence_offset: usize,
#[serde(default)]
source_ref_offset: usize,
}
async fn scoped_subjektiv_memory_backend(
@@ -17038,21 +17045,26 @@ fn subjektiv_memory_read(
.map(|(line, byte)| (Some(line), Some(byte)))
.unwrap_or((None, None));
let evidence_offset = if let Some(cursor) = input.evidence_cursor {
let cursor: SubjektivEvidenceCursor = decode_subjektiv_cursor("evidence", &cursor)?;
if cursor.subject_id != subject_id
|| cursor.memory_id != record.id
|| cursor.revision != record.revision
{
return Err(Error::InvalidInput(
"subjektiv evidence cursor does not match subject, Memory, or revision".into(),
let (evidence_offset, nested_evidence_offset, nested_source_ref_offset) =
if let Some(cursor) = input.evidence_cursor {
let cursor: SubjektivEvidenceCursor = decode_subjektiv_cursor("evidence", &cursor)?;
if cursor.subject_id != subject_id
|| cursor.memory_id != record.id
|| cursor.revision != record.revision
{
return Err(Error::InvalidInput(
"subjektiv evidence cursor does not match subject, Memory, or revision".into(),
)
.into());
}
(
cursor.offset,
cursor.evidence_offset,
cursor.source_ref_offset,
)
.into());
}
cursor.offset
} else {
0
};
} else {
(0, 0, 0)
};
let total_evidence = record
.source_candidate_ids
.len()
@@ -17062,61 +17074,89 @@ fn subjektiv_memory_read(
Error::InvalidInput("subjektiv evidence cursor offset is invalid".into()).into(),
);
}
let evidence_end = evidence_offset
.saturating_add(SUBJEKTIV_EVIDENCE_PAGE_SIZE)
.min(total_evidence);
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
.staging_candidate(subject_id, candidate_id)
.map_err(subjektiv_store_error)?
.ok_or_else(|| {
Error::Store(format!(
"subjektiv candidate provenance `{candidate_id}` is missing"
))
})?;
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
.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();
let reference = &record.derived_from[derived_index];
derived_from.push(server_api::SubjektivMemoryRevisionRef {
memory_id: reference.memory_id.clone(),
revision: reference.revision,
});
let mut next_evidence_offset = evidence_offset;
let mut next_nested_evidence_offset = 0;
let mut next_nested_source_ref_offset = 0;
if let Some(candidate_id) = record.source_candidate_ids.get(evidence_offset) {
let candidate = store
.staging_candidate(subject_id, candidate_id)
.map_err(subjektiv_store_error)?
.ok_or_else(|| {
Error::Store(format!(
"subjektiv candidate provenance `{candidate_id}` is missing"
))
})?;
let evidence_total = candidate.evidence.len();
let source_refs_total = candidate.source_refs.len();
if nested_evidence_offset > evidence_total || nested_source_ref_offset > source_refs_total {
return Err(Error::InvalidInput(
"subjektiv nested evidence cursor offset is invalid".into(),
)
.into());
}
let evidence_end = nested_evidence_offset
.saturating_add(SUBJEKTIV_EVIDENCE_ANCHOR_PAGE_SIZE)
.min(evidence_total);
let source_ref_end = nested_source_ref_offset
.saturating_add(SUBJEKTIV_EVIDENCE_ANCHOR_PAGE_SIZE)
.min(source_refs_total);
source_candidate_ids.push(candidate_id.clone());
source_candidates.push(server_api::SubjektivMemoryEvidenceCandidate {
candidate_id: candidate.id,
evidence: candidate
.evidence
.into_iter()
.skip(nested_evidence_offset)
.take(evidence_end - nested_evidence_offset)
.map(bounded_subjektiv_staging_evidence)
.collect(),
evidence_total,
evidence_truncated: nested_evidence_offset > 0 || evidence_end < evidence_total,
source_refs: candidate
.source_refs
.into_iter()
.skip(nested_source_ref_offset)
.take(source_ref_end - nested_source_ref_offset)
.map(bounded_subjektiv_source_ref)
.collect(),
source_refs_total,
source_refs_truncated: nested_source_ref_offset > 0
|| source_ref_end < source_refs_total,
});
if evidence_end < evidence_total || source_ref_end < source_refs_total {
next_nested_evidence_offset = evidence_end;
next_nested_source_ref_offset = source_ref_end;
} else {
next_evidence_offset += 1;
}
} else if evidence_offset < total_evidence {
if nested_evidence_offset != 0 || nested_source_ref_offset != 0 {
return Err(Error::InvalidInput(
"subjektiv derivation cursor cannot contain nested offsets".into(),
)
.into());
}
let derived_index = evidence_offset - record.source_candidate_ids.len();
let reference = &record.derived_from[derived_index];
derived_from.push(server_api::SubjektivMemoryRevisionRef {
memory_id: reference.memory_id.clone(),
revision: reference.revision,
});
next_evidence_offset += 1;
} else if nested_evidence_offset != 0 || nested_source_ref_offset != 0 {
return Err(Error::InvalidInput(
"subjektiv evidence cursor points beyond the final reference".into(),
)
.into());
}
bound_subjektiv_nested_evidence(&mut source_candidates)?;
let evidence_has_more = evidence_end < total_evidence;
bound_subjektiv_nested_evidence(&source_candidates)?;
let evidence_has_more = next_evidence_offset < total_evidence
|| next_nested_evidence_offset != 0
|| next_nested_source_ref_offset != 0;
let evidence_next_cursor = evidence_has_more
.then(|| {
encode_subjektiv_cursor(
@@ -17125,13 +17165,15 @@ fn subjektiv_memory_read(
subject_id: subject_id.to_string(),
memory_id: record.id.clone(),
revision: record.revision,
offset: evidence_end,
offset: next_evidence_offset,
evidence_offset: next_nested_evidence_offset,
source_ref_offset: next_nested_source_ref_offset,
},
)
})
.transpose()?;
Ok(server_api::SubjektivMemoryReadResponse {
let mut response = server_api::SubjektivMemoryReadResponse {
memory_id: record.id,
revision: record.revision,
current_revision: current.revision,
@@ -17154,7 +17196,15 @@ fn subjektiv_memory_read(
derived_from,
evidence_next_cursor,
evidence_has_more,
})
};
fit_subjektiv_read_body_to_output_budget(
&mut response,
&lines,
body_offset,
body_byte_offset,
body_limit,
)?;
Ok(response)
}
fn subjektiv_memory_list_revisions(
@@ -17532,6 +17582,76 @@ fn api_proposal_intent(
}
}
fn fit_subjektiv_read_body_to_output_budget(
response: &mut server_api::SubjektivMemoryReadResponse,
lines: &[&str],
line_offset: usize,
byte_offset: usize,
line_limit: usize,
) -> ApiResult<()> {
let serialized_len = |response: &server_api::SubjektivMemoryReadResponse| {
serde_json::to_vec_pretty(response)
.map(|value| value.len())
.map_err(|error| Error::Store(format!("encode subjektiv Memory read: {error}")))
};
if serialized_len(response)? <= server_api::SUBJEKTIV_MEMORY_READ_MAX_TOOL_CONTENT_BYTES {
return Ok(());
}
let initial_body_len = response.body_md.len();
let mut lower = 0usize;
let mut upper = initial_body_len;
while lower < upper {
let candidate_limit = lower + (upper - lower).div_ceil(2);
set_subjektiv_read_body_page(
response,
lines,
line_offset,
byte_offset,
line_limit,
candidate_limit,
);
if serialized_len(response)? <= server_api::SUBJEKTIV_MEMORY_READ_MAX_TOOL_CONTENT_BYTES {
lower = candidate_limit;
} else {
upper = candidate_limit - 1;
}
}
set_subjektiv_read_body_page(response, lines, line_offset, byte_offset, line_limit, lower);
let final_len = serialized_len(response)?;
if final_len > server_api::SUBJEKTIV_MEMORY_READ_MAX_TOOL_CONTENT_BYTES {
return Err(Error::Store(format!(
"subjektiv Memory metadata/provenance requires {final_len} bytes and exceeds the {} byte read-output budget",
server_api::SUBJEKTIV_MEMORY_READ_MAX_TOOL_CONTENT_BYTES
))
.into());
}
if initial_body_len > 0 && response.body_md.is_empty() {
return Err(Error::Store(
"subjektiv Memory metadata/provenance leaves no room for resumable body content".into(),
)
.into());
}
Ok(())
}
fn set_subjektiv_read_body_page(
response: &mut server_api::SubjektivMemoryReadResponse,
lines: &[&str],
line_offset: usize,
byte_offset: usize,
line_limit: usize,
byte_limit: usize,
) {
let (body_md, body_next) =
bounded_memory_body_page(lines, line_offset, byte_offset, line_limit, byte_limit);
response.body_md = body_md;
response.body_truncated = body_next.is_some();
(response.body_next_offset, response.body_next_byte_offset) = body_next
.map(|(line, byte)| (Some(line), Some(byte)))
.unwrap_or((None, None));
}
fn bounded_memory_body_page(
lines: &[&str],
line_offset: usize,
@@ -17580,7 +17700,7 @@ fn memory_body_lines(body: &str) -> Vec<&str> {
}
fn bound_subjektiv_nested_evidence(
candidates: &mut [server_api::SubjektivMemoryEvidenceCandidate],
candidates: &[server_api::SubjektivMemoryEvidenceCandidate],
) -> ApiResult<()> {
let serialized = serde_json::to_vec(candidates)
.map_err(|error| Error::Store(format!("encode subjektiv evidence page: {error}")))?;
@@ -35673,6 +35793,7 @@ mod tests {
nested_candidate,
))
.unwrap();
let escape_heavy_body = "\\\u{0000}\u{0001}".repeat(8_000);
let (nested_memory, _) = store
.apply_candidate(
&subject.id,
@@ -35681,7 +35802,7 @@ mod tests {
crate::subjektiv::MemoryDraft::active(
memory::extract::CandidateKind::Lesson,
"Keep nested provenance bounded",
"bounded body",
escape_heavy_body.clone(),
"Prevents unbounded read responses",
"adopt oversized historical candidate",
),
@@ -35692,7 +35813,7 @@ mod tests {
&store,
&subject.id,
server_api::SubjektivMemoryReadRequest {
memory_id: nested_memory.id,
memory_id: nested_memory.id.clone(),
revision: Some(1),
offset: None,
byte_offset: None,
@@ -35703,12 +35824,15 @@ mod tests {
.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.len(), SUBJEKTIV_EVIDENCE_ANCHOR_PAGE_SIZE);
assert_eq!(nested.evidence_total, nested_count);
assert!(!nested.evidence_truncated);
assert_eq!(nested.source_refs.len(), SUBJEKTIV_CANDIDATE_ANCHOR_LIMIT);
assert!(nested.evidence_truncated);
assert_eq!(
nested.source_refs.len(),
SUBJEKTIV_EVIDENCE_ANCHOR_PAGE_SIZE
);
assert_eq!(nested.source_refs_total, nested_count);
assert!(!nested.source_refs_truncated);
assert!(nested.source_refs_truncated);
assert!(nested.evidence.iter().all(|item| {
item.excerpt
.as_ref()
@@ -35737,7 +35861,195 @@ mod tests {
.len()
<= SUBJEKTIV_NESTED_EVIDENCE_MAX_BYTES
);
assert!(serde_json::to_vec(&nested_read).unwrap().len() < 64 * 1024);
assert!(
serde_json::to_vec_pretty(&nested_read).unwrap().len()
<= server_api::SUBJEKTIV_MEMORY_READ_MAX_TOOL_CONTENT_BYTES
);
assert!(nested_read.body_truncated);
assert!(nested_read.body_next_offset.is_some());
let mut nested_evidence_ids = nested
.evidence
.iter()
.map(|item| item.id.clone())
.collect::<Vec<_>>();
let mut nested_source_sessions = nested
.source_refs
.iter()
.filter_map(|item| item.session_id.clone())
.collect::<Vec<_>>();
let mut nested_cursor = nested_read.evidence_next_cursor.clone();
while let Some(cursor) = nested_cursor {
let page = subjektiv_memory_read(
&store,
&subject.id,
server_api::SubjektivMemoryReadRequest {
memory_id: nested_memory.id.clone(),
revision: Some(1),
offset: None,
byte_offset: None,
limit: None,
evidence_cursor: Some(cursor),
},
)
.unwrap();
let candidate = &page.source_candidates[0];
nested_evidence_ids.extend(candidate.evidence.iter().map(|item| item.id.clone()));
nested_source_sessions.extend(
candidate
.source_refs
.iter()
.filter_map(|item| item.session_id.clone()),
);
nested_cursor = page.evidence_next_cursor;
}
assert_eq!(nested_evidence_ids.len(), nested_count);
assert_eq!(nested_source_sessions.len(), nested_count);
let mut reconstructed_body = nested_read.body_md.clone();
let mut next_line = nested_read.body_next_offset;
let mut next_byte = nested_read.body_next_byte_offset;
while let Some(offset) = next_line {
let page = subjektiv_memory_read(
&store,
&subject.id,
server_api::SubjektivMemoryReadRequest {
memory_id: nested_memory.id.clone(),
revision: Some(1),
offset: Some(offset),
byte_offset: next_byte,
limit: None,
evidence_cursor: None,
},
)
.unwrap();
assert!(
serde_json::to_vec_pretty(&page).unwrap().len()
<= server_api::SUBJEKTIV_MEMORY_READ_MAX_TOOL_CONTENT_BYTES
);
reconstructed_body.push_str(&page.body_md);
next_line = page.body_next_offset;
next_byte = page.body_next_byte_offset;
}
assert_eq!(reconstructed_body, escape_heavy_body);
// Simulate a row accepted before the write-admission anchor cap. The
// compatible schema remains readable, and its nested anchors advance
// through the ordinary evidence cursor instead of becoming orphaned.
let legacy_count = SUBJEKTIV_CANDIDATE_ANCHOR_LIMIT + 7;
let legacy_workspace_id = store.workspace_id().to_string();
let legacy_evidence = (0..legacy_count)
.map(|index| memory::extract::StagingEvidence {
id: format!("legacy-evidence-{index}"),
kind: memory::schema::EvidenceKind::new("model_output"),
entry_range: Some([index as u64, index as u64]),
origin: Some(memory::schema::EvidenceOrigin {
kind: memory::schema::EvidenceOriginKind::ModelOutput,
account_id: None,
workspace_id: Some(legacy_workspace_id.clone()),
runtime_id: None,
worker_id: None,
flow_selector: None,
flow_definition_id: None,
flow_definition_revision: None,
}),
excerpt: Some(format!("legacy excerpt {index}")),
summary: None,
})
.collect::<Vec<_>>();
let legacy_refs = (0..legacy_count)
.map(|index| memory::schema::SourceEvidenceRef {
session_id: Some(format!("legacy-session-{index}")),
segment_id: Some("legacy-segment".to_string()),
entry_range: Some([index as u64, index as u64]),
evidence_id: Some(format!("legacy-evidence-{index}")),
evidence_kind: Some(memory::schema::EvidenceKind::new("model_output")),
origin: Some(memory::schema::EvidenceOrigin {
kind: memory::schema::EvidenceOriginKind::ModelOutput,
account_id: None,
workspace_id: Some(legacy_workspace_id.clone()),
runtime_id: None,
worker_id: None,
flow_selector: None,
flow_definition_id: None,
flow_definition_revision: None,
}),
..Default::default()
})
.collect::<Vec<_>>();
let legacy_candidate = memory::extract::StagingRecord::from_candidate(
"candidate-legacy-over-anchor-limit",
"legacy-over-anchor-limit-dedup",
memory::schema::SourceRef {
segment_id: "legacy-segment".to_string(),
range: [0, legacy_count as u64 - 1],
},
memory::extract::ExtractedCandidate {
kind: memory::extract::CandidateKind::Lesson,
claim: "Preserve compatible historical provenance".to_string(),
why_useful: "Upgrade must not orphan accepted anchors".to_string(),
staleness: None,
evidence_ids: legacy_evidence.iter().map(|item| item.id.clone()).collect(),
},
legacy_evidence,
legacy_refs,
);
let legacy_candidate = store
.stage_legacy_candidate_for_test(crate::subjektiv::SubjectStagingRecord::attach(
&subject.id,
legacy_candidate,
))
.unwrap();
let (legacy_memory, _) = store
.apply_candidate(
&subject.id,
&legacy_candidate.id,
crate::subjektiv::MemoryRevisionTarget::Create,
crate::subjektiv::MemoryDraft::active(
memory::extract::CandidateKind::Lesson,
"Preserve compatible historical provenance",
"legacy body",
"Upgrade must not orphan accepted anchors",
"upgrade fixture",
),
"adopt legacy candidate",
)
.unwrap();
let mut legacy_cursor = None;
let mut retrieved_evidence = Vec::new();
let mut retrieved_sessions = Vec::new();
loop {
let page = subjektiv_memory_read(
&store,
&subject.id,
server_api::SubjektivMemoryReadRequest {
memory_id: legacy_memory.id.clone(),
revision: Some(1),
offset: None,
byte_offset: None,
limit: None,
evidence_cursor: legacy_cursor,
},
)
.unwrap();
let candidate = &page.source_candidates[0];
retrieved_evidence.extend(candidate.evidence.iter().map(|item| item.id.clone()));
retrieved_sessions.extend(
candidate
.source_refs
.iter()
.filter_map(|item| item.session_id.clone()),
);
if !page.evidence_has_more {
break;
}
legacy_cursor = page.evidence_next_cursor;
}
assert_eq!(retrieved_evidence.len(), legacy_count);
assert_eq!(retrieved_sessions.len(), legacy_count);
for index in 0..legacy_count {
assert!(retrieved_evidence.contains(&format!("legacy-evidence-{index}")));
assert!(retrieved_sessions.contains(&format!("legacy-session-{index}")));
}
let revisions = subjektiv_memory_list_revisions(
&store,
+30 -6
View File
@@ -899,6 +899,7 @@ impl SubjektivStore {
pub fn stage_candidate(&self, record: SubjectStagingRecord) -> Result<SubjectStagingRecord> {
validate_staging_record(self.workspace_id(), &record)?;
validate_staging_admission(&record)?;
let raw = serde_json::to_string(&record)?;
self.database.try_transaction(|transaction| {
require_active_subject(transaction, &record.subject_id)?;
@@ -926,6 +927,7 @@ impl SubjektivStore {
}
attach_session_to_candidate(&mut record, &attribution.session_id)?;
validate_staging_record(self.workspace_id(), &record)?;
validate_staging_admission(&record)?;
let candidate_raw = serde_json::to_string(&record)?;
let attribution_raw = serde_json::to_string(&attribution)?;
@@ -938,6 +940,21 @@ impl SubjektivStore {
})
}
/// Inserts a structurally valid record using the pre-admission-limit path so
/// upgrade tests can prove that compatible historical rows stay readable.
#[cfg(test)]
pub(crate) fn stage_legacy_candidate_for_test(
&self,
record: SubjectStagingRecord,
) -> Result<SubjectStagingRecord> {
validate_staging_record(self.workspace_id(), &record)?;
let raw = serde_json::to_string(&record)?;
self.database.try_transaction(|transaction| {
require_active_subject(transaction, &record.subject_id)?;
write_staging_candidate(transaction, self.workspace_id(), record, raw)
})
}
pub fn record_session_attribution(
&self,
attribution: SubjectSessionAttribution,
@@ -1899,12 +1916,6 @@ fn validate_staging_record(workspace_id: &str, record: &SubjectStagingRecord) ->
));
}
if record.evidence.len() > MAX_STAGING_ANCHORS || record.source_refs.len() > MAX_STAGING_ANCHORS
{
return Err(SubjektivError::InvalidRecord(format!(
"staging evidence/source_refs are limited to {MAX_STAGING_ANCHORS} items each"
)));
}
let mut evidence_ids = HashSet::new();
for evidence in &record.evidence {
validate_label("evidence id", &evidence.id)?;
@@ -1967,6 +1978,19 @@ fn validate_staging_record(workspace_id: &str, record: &SubjectStagingRecord) ->
Ok(())
}
/// Applies the current write-admission contract separately from structural
/// validation. Persisted rows from older compatible implementations remain
/// readable even when a later release tightens admission limits.
fn validate_staging_admission(record: &SubjectStagingRecord) -> Result<()> {
if record.evidence.len() > MAX_STAGING_ANCHORS || record.source_refs.len() > MAX_STAGING_ANCHORS
{
return Err(SubjektivError::InvalidRecord(format!(
"staging evidence/source_refs are limited to {MAX_STAGING_ANCHORS} items each"
)));
}
Ok(())
}
fn validate_revision_proposal_metadata(proposal: &RevisionProposal) -> Result<()> {
validate_label("revision proposal Memory id", &proposal.memory_id)?;
if proposal.expected_revision == 0 {
+15 -11
View File
@@ -184,17 +184,21 @@ 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) with an additional 16 KiB UTF-8 response
budget. It reports `body_truncated`, `body_next_offset`, and
`body_next_byte_offset`; an oversized single line resumes from the returned byte
boundary within that same line. Every 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 one
candidate/derivation reference per page. A staged candidate contains at most 10
evidence records and 10 source references, all returned together under a hard 40 KiB
serialized budget for the expanded candidate array; displayed anchor text is capped
at 64 UTF-8 bytes. Every staging path rejects larger anchor sets, so accepted
provenance is never made unreachable. Candidate evidence is the bounded
line-based (default 200, maximum 1000) with a preliminary 16 KiB UTF-8 body
cap and a global 56 KiB budget over the exact pretty-serialized model-visible
response. JSON escaping, provenance, and metadata therefore reduce the current
body page before the Worker content ceiling is reached. It reports
`body_truncated`, `body_next_offset`, and `body_next_byte_offset`; an oversized
or escape-heavy single line resumes from the returned UTF-8 byte boundary within
that same line. Every 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 one
candidate/derivation reference per page. New staged candidates contain at most 10
evidence records and 10 source references; read pages project at most two of each,
with displayed anchor text capped at 64 UTF-8 bytes and nested offsets carried by
the ordinary evidence cursor. Compatible candidates persisted before the admission
cap remain readable across as many evidence pages as needed, while every current
staging path rejects new 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.