feat: connect Memory provenance to durable Journal context

This commit is contained in:
2026-10-16 23:54:54 +09:00
parent 52e914eebc
commit b544e53bd7
16 changed files with 3396 additions and 1 deletions
+3
View File
@@ -28,6 +28,7 @@ use subjektiv::api::*;
mod backend_job_profile;
mod subjektiv_job;
mod subjektiv_journal;
mod subjektiv_memory_journal;
mod worker_operations;
mod worker_protocol;
pub(crate) use worker_operations::{
@@ -13123,6 +13124,7 @@ fn build_inner_router(api: WorkspaceApi) -> Router {
post(scoped_submit_backend_job_result),
)
.route("/api/w/{workspace_id}/subjektiv/journal/recording", post(subjektiv_journal::recording_route))
.route("/api/w/{workspace_id}/subjektiv/journal/memory", post(subjektiv_memory_journal::route))
.route("/api/w/{workspace_id}/subjektiv/journal/job", post(subjektiv_journal::job_route))
.route("/api/w/{workspace_id}/subjektiv/journal/job/finish", post(subjektiv_journal::finish_route))
.fallback(get(static_or_spa_fallback))
@@ -36928,6 +36930,7 @@ mod tests {
mod subject_spawn_tests;
mod subjektiv_jobs_tests;
mod subjektiv_journal_tests;
mod subjektiv_memory_journal_tests;
mod ticket_evidence_tests;
mod ticket_worker_claim_tests;
mod ticket_worker_launch_tests;
@@ -0,0 +1,107 @@
//! Authenticated Subject-body access to durable Memory/Journal links.
//!
//! The current singleton grants access; identifiers in the request do not. Saved
//! Journal reads deliberately use a reader context, not a source Session, Runtime
//! lookup or restore. Domain storage owns link matching, persistence and paging.
use super::*;
use subjektiv::journal::memory_links::{MemoryJournalRequest, MemoryJournalResponse};
use subjektiv::journal::{JournalError, JournalHostContext};
fn domain(error: JournalError) -> Error {
match error {
JournalError::PermissionDenied(message) => Error::WorkspacePermissionDenied(message),
JournalError::Conflict(message) => Error::RepositoryConflict(message),
JournalError::InvalidInput(message) => Error::InvalidInput(message),
JournalError::NotFound(id) => {
Error::InvalidInput(format!("Journal record not found: {id}"))
}
JournalError::Subject(error) => subjektiv_store_error(error),
other => Error::Store(other.to_string()),
}
}
pub(super) async fn route(
State(api): State<WorkspaceApi>,
AxumPath(path): AxumPath<ScopedWorkspacePath>,
source: Option<axum::Extension<crate::worker_source::VerifiedRuntimeRequestSource>>,
Json(request): Json<MemoryJournalRequest>,
) -> ApiResult<Json<MemoryJournalResponse>> {
let source = source
.ok_or_else(|| {
Error::WorkspacePermissionDenied(
"Memory Journal requires signed Runtime request identity".into(),
)
})?
.0;
let worker_id = source.worker_id.ok_or_else(|| {
Error::WorkspacePermissionDenied("Memory Journal requires bound Worker identity".into())
})?;
let worker = RuntimeWorkerRef::new(source.runtime_id, worker_id);
// This is entirely store-owned, including callbacks while a Host parent waits
// on Runtime. Do not put it behind Host or consult live Runtime/config state.
api.store
.db_executor()
.run(move || {
validate_workspace_scope(&api, &path.workspace_id)?;
let projection = api
.store
.worker_registry_projection(&path.workspace_id, &worker)?
.ok_or_else(|| {
Error::WorkspacePermissionDenied(
"Memory Journal Worker registration missing".into(),
)
})?;
// No Job grant implicitly expands to all of a Subject's saved Journal.
if projection.job.is_some() {
return Err(Error::WorkspacePermissionDenied(
"Memory Journal is not authorized for Job Workers".into(),
)
.into());
}
let lease = api
.store
.require_current_worker_singleton_owner(&path.workspace_id, &worker)?
.ok_or_else(|| {
Error::WorkspacePermissionDenied(
"Memory Journal requires the current Subject Worker".into(),
)
})?;
let (subject_id, authority) = subjektiv_scope_from_singleton_key(&lease.key)?;
require_subjektiv_worker_authority(
authority,
SubjektivWorkerAuthority::Subject,
"Memory Journal access",
)?;
let store = open_subjektiv_store(&api)?;
let host = JournalHostContext::validated_reader(&store, subject_id).map_err(domain)?;
store
.execute_memory_journal_with_source_presence(&host, request, |session| {
// Absence is known only when neither retained Worker registration nor an
// archive location remains. Runtime disconnection is not material deletion.
// These SQL lookups never fetch Session bytes or restore the old Worker.
let attribution = store.session_attribution(session).ok()??;
if attribution.subject_id != subject_id {
return None;
}
let target =
RuntimeWorkerRef::new(&attribution.runtime_id, &attribution.worker_id);
let archive = api
.config_store
.worker_session_archive(&path.workspace_id, &target, session)
.ok()?;
let registered = api
.store
.get_worker_registry(&path.workspace_id, &target)
.ok()?;
if archive.is_none() && registered.is_none() {
Some(false)
} else {
None
}
})
.map(Json)
.map_err(domain)
.map_err(ApiError::from)
})
.await
}
@@ -0,0 +1,728 @@
//! Connected ordinary library tests: production router, signed Runtime authentication,
//! singleton/Job authority and Subjektiv FeatureStorage/SQLite. Extraction output and
//! Host admission/generation are fixture inputs; staging/adoption and canonical Session
//! serialization are real. No Runtime execution, model, external config or network is used.
use super::*;
use subjektiv::journal::memory_links::*;
use subjektiv::journal::transport::{JournalJobRequest, JournalTextRead, JournalTextTarget};
use subjektiv::journal::*;
const RUNTIME: &str = "runtime-memory-journal";
const JOURNAL_BODY: &str =
"A was proposed; B was chosen after checking the constraint. Work remains.";
fn route_path() -> String {
format!("/api/w/{TEST_WORKSPACE_ID}/subjektiv/journal/memory")
}
async fn response(app: Router, request: Request<Body>) -> (StatusCode, Vec<u8>) {
let response = app.oneshot(request).await.unwrap();
let status = response.status();
let bytes = to_bytes(response.into_body(), 128 * 1024).await.unwrap();
(status, bytes.to_vec())
}
struct Fixture {
_dir: tempfile::TempDir,
api: WorkspaceApi,
identity: worker_runtime::auth::RuntimeIdentityMaterial,
store: crate::subjektiv::SubjektivStore,
host: JournalHostContext,
job: JournalJobContext,
worker: RuntimeWorkerRef,
source_worker: RuntimeWorkerRef,
source_root: PathBuf,
selector: MemoryJournalSelector,
journal_id: String,
entry_id: String,
ranges: Vec<JournalSourceRange>,
}
impl Fixture {
async fn new() -> Self {
use session_store::{LogEntry, Store as _, WorkerSessionStore};
let dir = tempfile::tempdir().unwrap();
let mut api = test_api(dir.path()).await;
let identity = worker_runtime::auth::RuntimeIdentityMaterial::generate(RUNTIME).unwrap();
configure_runtime_request_auth(&mut api, &identity, RUNTIME);
let store = open_subjektiv_store(&api).unwrap();
let subject = store
.create_subject(crate::subjektiv::SubjectRole::new("memory-journal").unwrap())
.unwrap();
let worker = Self::body_worker(&api, &subject.id);
let source_worker = RuntimeWorkerRef::new(RUNTIME, Uuid::new_v4().to_string());
seed_worker_source_member(&api, RUNTIME, &source_worker.worker_id);
let session = session_store::new_session_id();
let segment = Uuid::new_v4();
let source_root = dir.path().join("old-worker/session");
let history = [
"Choose B after checking the constraint",
"Correction: B still needs validation",
]
.into_iter()
.enumerate()
.map(|(index, text)| session_store::LoggedHistoryEntry {
item: session_store::LoggedItem::Message {
role: session_store::LoggedRole::User,
content: vec![session_store::LoggedContentPart::Text { text: text.into() }],
},
metadata: session_store::LoggedSessionHistoryMetadata {
entry_id: session_store::LoggedSessionHistoryEntryId(format!("original-{index}")),
origin: session_store::LoggedSessionHistoryOrigin::LocalHumanInput,
derivation: None,
},
})
.collect();
WorkerSessionStore::new(&source_root)
.unwrap()
.create_segment(
session,
segment,
&[LogEntry::AnnotatedSegmentStart {
ts: 1,
session_id: session,
system_prompt: None,
config: agen::llm_client::RequestConfig::default(),
history,
forked_from: None,
compacted_from: None,
}],
)
.unwrap();
let reader = session_store::public_material::SessionPublicMaterialReader::open_worker(
&source_root,
segment,
Default::default(),
)
.unwrap();
let ranges: Vec<_> = reader
.descriptors()
.map(|d| JournalSourceRange {
source: JournalSource {
session_id: session.to_string(),
pointer: JournalSourcePointer {
segment_id: d.identity.segment_id,
entry_ref: d.identity.entry_ref.clone(),
},
order: d.canonical_order,
content_sha256: d.sha256.clone(),
total_bytes: d.total_bytes,
},
bytes: JournalByteRange {
start: 0,
end: d.total_bytes,
},
})
.collect();
assert_eq!(ranges.len(), 2);
let attr = store
.record_session_attribution(
crate::subjektiv::SubjectSessionAttribution::new(
&subject.id,
RUNTIME,
&source_worker.worker_id,
session.to_string(),
)
.unwrap(),
)
.unwrap();
let host = JournalHostContext::validated_session(&store, attr.clone()).unwrap();
let journal_id = store
.accept_journal_sources(&host, "original activity", &ranges)
.unwrap();
let binding = subjektiv::api::JobAttemptBinding {
job_id: "fixture-journal-generation".into(),
attempt_id: "fixture-attempt".into(),
};
let generation = store
.prepare_journal_generation(&host, &journal_id, binding.clone(), ranges.clone())
.unwrap();
let job = JournalJobContext::validated(&store, &host, &generation.id, binding).unwrap();
for (descriptor, range) in reader.descriptors().zip(&ranges) {
subjektiv::journal::service::execute_bounded(
&store,
&job,
JournalJobRequest::ReadText {
read: JournalTextRead {
target: JournalTextTarget::Source {
pointer: range.source.pointer.clone(),
},
offset: 0,
limit: MAX_JOURNAL_READ_BYTES,
content_token: None,
},
},
|source, start, limit| {
assert_eq!(source, &range.source);
let page = reader.read_content(descriptor, start, limit).unwrap();
assert_eq!(page.next_offset, range.bytes.end);
Ok(subjektiv::journal::recording::JournalMaterial {
range: range.clone(),
text: page.content,
})
},
)
.unwrap();
}
let receipt = store
.execute_journal(
&job,
JournalOperation {
request_id: "initial-open".into(),
expected_state: generation.initial_journal.state_token,
action: JournalAction::Create {
title: "Choosing the approach".into(),
body: JOURNAL_BODY.into(),
ongoing: "Validation pending".into(),
state: JournalEntryState::Open,
source_end: None,
correction_refs: vec![],
},
covered_ranges: vec![ranges[0].clone()],
},
)
.unwrap();
let entry_id = receipt.entry_id.unwrap();
let kind = memory::extract::CandidateKind::Lesson;
let evidence_id = ranges[0].source.pointer.entry_ref.clone();
let candidate = memory::extract::StagingRecord::from_candidate(
"extracted-candidate",
"extraction-run",
memory::schema::SourceRef {
segment_id: segment.to_string(),
range: [0, 0],
},
memory::extract::ExtractedCandidate {
kind: kind.clone(),
claim: "Check the constraint before choosing".into(),
why_useful: "Avoid repeating the rejected approach".into(),
staleness: None,
evidence_ids: vec![evidence_id.clone()],
},
vec![memory::extract::StagingEvidence {
id: evidence_id.clone(),
kind: memory::schema::EvidenceKind::new("message"),
entry_range: Some([0, 0]),
origin: Some(serde_json::from_value(json!({"kind":"human_input"})).unwrap()),
excerpt: Some("Choose B after checking the constraint".into()),
summary: None,
}],
vec![memory::schema::SourceEvidenceRef {
session_id: Some(attr.session_id),
segment_id: Some(segment.to_string()),
entry_range: Some([0, 0]),
evidence_id: Some(evidence_id),
origin: Some(serde_json::from_value(json!({"kind":"human_input"})).unwrap()),
evidence_kind: Some(memory::schema::EvidenceKind::new("message")),
..Default::default()
}],
);
let (staged, _) = store
.stage_candidate_with_attribution(
crate::subjektiv::SubjectStagingRecord::attach(&subject.id, candidate),
store
.session_attribution(&session.to_string())
.unwrap()
.unwrap(),
)
.unwrap();
let (memory, _) = store
.apply_candidate(
&subject.id,
&staged.id,
crate::subjektiv::MemoryChangeTarget::Create,
crate::subjektiv::MemoryDraft::active(
kind,
&staged.claim,
"Check before choosing.",
&staged.why_useful,
"Adopt the extracted lesson",
),
"Accepted after comparison",
)
.unwrap();
let selector = MemoryJournalSelector {
memory_id: memory.id,
change_id: memory.change_id,
candidate_id: staged.id,
};
Self {
_dir: dir,
api,
identity,
store,
host,
job,
worker,
source_worker,
source_root,
selector,
journal_id,
entry_id,
ranges,
}
}
fn body_worker(api: &WorkspaceApi, subject: &str) -> RuntimeWorkerRef {
let memory = api
.config_store
.get_workspace_memory_settings(TEST_WORKSPACE_ID)
.unwrap();
let key = crate::subjektiv::subject_worker_singleton_key(subject).unwrap();
let reservation = api
.config_store
.reserve_worker_create(
TEST_WORKSPACE_ID,
RUNTIME,
&format!("body-{subject}"),
&format!("body-input-{subject}"),
Some(&key),
&memory,
)
.unwrap();
let worker = RuntimeWorkerRef::new(RUNTIME, reservation.worker_id.to_string());
seed_worker_source_member(api, RUNTIME, &worker.worker_id);
api.config_store
.complete_worker_create_reservation(TEST_WORKSPACE_ID, reservation.worker_id)
.unwrap();
worker
}
fn request(&self, worker: Option<&str>, request: &MemoryJournalRequest) -> Request<Body> {
runtime_source_request(
&self.identity,
worker,
"POST",
&route_path(),
serde_json::to_vec(request).unwrap(),
)
}
async fn call_as(
&self,
worker: &RuntimeWorkerRef,
request: MemoryJournalRequest,
) -> (StatusCode, Vec<u8>) {
response(
build_router(self.api.clone()),
self.request(Some(&worker.worker_id), &request),
)
.await
}
async fn call(&self, request: MemoryJournalRequest) -> MemoryJournalResponse {
let (status, bytes) = self.call_as(&self.worker, request).await;
assert_eq!(
status,
StatusCode::OK,
"{}",
String::from_utf8_lossy(&bytes)
);
serde_json::from_slice(&bytes).unwrap()
}
fn fix_initial(&self) {
self.store
.execute_journal(
&self.job,
JournalOperation {
request_id: "fix-initial".into(),
expected_state: self
.store
.read_journal(&self.host, &self.journal_id)
.unwrap()
.state_token,
action: JournalAction::Fix {
entry_id: self.entry_id.clone(),
source_end: self.ranges[0].source.pointer.clone(),
},
covered_ranges: vec![self.ranges[0].clone()],
},
)
.unwrap();
}
fn add_correction(&self) -> String {
self.store
.execute_journal(
&self.job,
JournalOperation {
request_id: "later-correction".into(),
expected_state: self
.store
.read_journal(&self.host, &self.journal_id)
.unwrap()
.state_token,
action: JournalAction::Create {
title: "Correction".into(),
body: "The chosen approach still needs validation.".into(),
ongoing: String::new(),
state: JournalEntryState::Fixed,
source_end: Some(self.ranges[1].source.pointer.clone()),
correction_refs: vec![self.entry_id.clone()],
},
covered_ranges: vec![self.ranges[1].clone()],
},
)
.unwrap()
.entry_id
.unwrap()
}
fn read_request(&self) -> MemoryJournalRequest {
MemoryJournalRequest::Read {
selector: self.selector.clone(),
source_index: 0,
journal_id: self.journal_id.clone(),
entry_id: self.entry_id.clone(),
expected_token: None,
byte_offset: 0,
max_bytes: 4096,
}
}
}
#[tokio::test]
async fn memory_journal_router_requires_signed_bound_worker_not_user_or_forged_identity() {
let f = Fixture::new().await;
let request = MemoryJournalRequest::Links {
selector: f.selector.clone(),
};
let body = serde_json::to_vec(&request).unwrap();
let app = build_router(f.api.clone());
let anonymous = Request::builder()
.method("POST")
.uri(route_path())
.header(CONTENT_TYPE, "application/json")
.body(Body::from(body.clone()))
.unwrap();
assert_eq!(
response(app.clone(), anonymous).await.0,
StatusCode::UNAUTHORIZED
);
let forged = Request::builder()
.method("POST")
.uri(route_path())
.header(CONTENT_TYPE, "application/json")
.header(
worker_runtime::auth::RUNTIME_REQUEST_SOURCE_PROOF_HEADER,
"forged",
)
.body(Body::from(body.clone()))
.unwrap();
assert_eq!(
response(app.clone(), forged).await.0,
StatusCode::UNAUTHORIZED
);
let token = seed_test_workspace_owner_token(f.api.store.as_ref(), "memory-journal-user").await;
let user = Request::builder()
.method("POST")
.uri(route_path())
.header(CONTENT_TYPE, "application/json")
.header("authorization", format!("Bearer {token}"))
.body(Body::from(body))
.unwrap();
assert_eq!(response(app.clone(), user).await.0, StatusCode::FORBIDDEN);
assert_eq!(
response(app, f.request(None, &request)).await.0,
StatusCode::FORBIDDEN
);
}
#[tokio::test]
async fn memory_journal_router_default_worker_and_job_have_no_subject_capability() {
let f = Fixture::new().await;
let request = MemoryJournalRequest::Reconcile {
selector: f.selector.clone(),
};
let ordinary = RuntimeWorkerRef::new(RUNTIME, Uuid::new_v4().to_string());
seed_worker_source_member(&f.api, RUNTIME, &ordinary.worker_id);
let (status, bytes) = f.call_as(&ordinary, request.clone()).await;
assert_eq!(
status,
StatusCode::FORBIDDEN,
"{}",
String::from_utf8_lossy(&bytes)
);
// Even a singleton owner cannot reuse its body authority through a restricted Job binding.
let job = BackendJobRequest {
job_id: "restricted-job".into(),
purpose: "test".into(),
input_ref: "fixture://job".into(),
input: json!({}),
instruction: "No Memory Journal grant".into(),
profile: "builtin:backend-job".into(),
grants: Default::default(),
serialization_key: None,
source_worker: None,
notification_target: None,
limits: Default::default(),
};
let now = Utc::now().to_rfc3339();
let reserved = f
.api
.store
.reserve_backend_job(TEST_WORKSPACE_ID, &job, &now)
.unwrap();
f.api
.store
.claim_backend_job_attempt_dispatch(
TEST_WORKSPACE_ID,
&job.job_id,
&reserved.attempt.attempt_id,
&now,
)
.unwrap();
f.api
.store
.bind_backend_job_attempt_worker(
TEST_WORKSPACE_ID,
&job.job_id,
&reserved.attempt.attempt_id,
&f.worker,
None,
&now,
)
.unwrap();
let (status, bytes) = f.call_as(&f.worker, request).await;
assert_eq!(
status,
StatusCode::FORBIDDEN,
"{}",
String::from_utf8_lossy(&bytes)
);
let reader = JournalHostContext::validated_reader(
&f.store,
&f.store
.read_journal(&f.host, &f.journal_id)
.unwrap()
.subject_id,
)
.unwrap();
let MemoryJournalResponse::Links(links) = f
.store
.execute_memory_journal(
&reader,
MemoryJournalRequest::Links {
selector: f.selector.clone(),
},
)
.unwrap()
else {
panic!("links required")
};
assert_eq!(
links.sources[0].connection,
SourceConnection::Unconnected {
reason: UnconnectedReason::NotReconciled
}
);
}
#[tokio::test]
async fn memory_journal_router_reconciles_only_fixed_events_and_retries_without_duplication() {
let f = Fixture::new().await;
let reconcile = MemoryJournalRequest::Reconcile {
selector: f.selector.clone(),
};
let MemoryJournalResponse::Links(open) = f.call(reconcile.clone()).await else {
panic!("links required")
};
assert_eq!(
open.sources[0].connection,
SourceConnection::Unconnected {
reason: UnconnectedReason::JournalNotFixed
}
);
assert_eq!(
f.store
.list_journal_entries(&f.host, &f.journal_id, None, 32)
.unwrap()
.items[0]
.state,
JournalEntryState::Open
);
f.fix_initial();
// Reading stored state alone must not silently trigger reconciliation.
assert_eq!(
f.call(MemoryJournalRequest::Links {
selector: f.selector.clone()
})
.await,
MemoryJournalResponse::Links(open)
);
let linked = f.call(reconcile.clone()).await;
assert_eq!(f.call(reconcile).await, linked);
assert_eq!(
f.call(MemoryJournalRequest::Links {
selector: f.selector.clone()
})
.await,
linked
);
let MemoryJournalResponse::Links(links) = linked else {
panic!("links required")
};
assert_eq!(links.sources.len(), 1);
assert_eq!(
links.sources[0].connection,
SourceConnection::Linked {
journal_id: f.journal_id.clone(),
entry_id: f.entry_id.clone(),
target: JournalTargetAvailability::Available,
reconsideration: false,
}
);
assert_eq!(
f.store
.list_journal_entries(&f.host, &f.journal_id, None, 32)
.unwrap()
.items
.len(),
1
);
}
#[tokio::test]
async fn memory_journal_router_reads_saved_text_after_original_session_and_source_worker_removal() {
let f = Fixture::new().await;
f.fix_initial();
f.call(MemoryJournalRequest::Reconcile {
selector: f.selector.clone(),
})
.await;
assert!(f.source_root.exists());
fs::remove_dir_all(&f.source_root).unwrap();
f.api
.store
.delete_worker_registry(TEST_WORKSPACE_ID, &f.source_worker)
.unwrap();
assert!(!f.source_root.exists());
assert!(
f.api
.store
.get_worker_registry(TEST_WORKSPACE_ID, &f.source_worker)
.unwrap()
.is_none()
);
// No Runtime adapter exists for RUNTIME, so a restore/history dependency would fail.
let MemoryJournalResponse::Read(read) = f.call(f.read_request()).await else {
panic!("read required")
};
assert_eq!(
read.source.original_source,
OriginalSourceAvailability::Missing
);
assert_eq!(read.page.unwrap().text, JOURNAL_BODY);
let MemoryJournalResponse::Related(related) = f
.call(MemoryJournalRequest::Related {
journal_id: f.journal_id.clone(),
entry_id: f.entry_id.clone(),
after: None,
limit: 32,
})
.await
else {
panic!("related required")
};
assert_eq!(related.items.len(), 1);
assert_eq!(related.items[0].selector, f.selector);
assert!(!related.items[0].reconsideration);
assert!(related.next.is_none());
}
#[tokio::test]
async fn memory_journal_router_ids_do_not_grant_cross_subject_reads_or_reconciliation() {
let f = Fixture::new().await;
f.fix_initial();
f.call(MemoryJournalRequest::Reconcile {
selector: f.selector.clone(),
})
.await;
let other = f
.store
.create_subject(crate::subjektiv::SubjectRole::new("other").unwrap())
.unwrap();
let other_worker = Fixture::body_worker(&f.api, &other.id);
for request in [
MemoryJournalRequest::Reconcile {
selector: f.selector.clone(),
},
MemoryJournalRequest::Links {
selector: f.selector.clone(),
},
f.read_request(),
MemoryJournalRequest::Related {
journal_id: f.journal_id.clone(),
entry_id: f.entry_id.clone(),
after: None,
limit: 32,
},
] {
let (status, bytes) = f.call_as(&other_worker, request).await;
assert_eq!(
status,
StatusCode::BAD_REQUEST,
"{}",
String::from_utf8_lossy(&bytes)
);
assert!(!String::from_utf8_lossy(&bytes).contains(JOURNAL_BODY));
}
// A model cannot override the authenticated Subject with an additional JSON field.
let mut body = serde_json::to_value(f.read_request()).unwrap();
body["subject_id"] = json!(other.id);
let request = runtime_source_request(
&f.identity,
Some(&f.worker.worker_id),
"POST",
&route_path(),
serde_json::to_vec(&body).unwrap(),
);
assert_eq!(
response(build_router(f.api.clone()), request).await.0,
StatusCode::UNPROCESSABLE_ENTITY
);
}
#[tokio::test]
async fn memory_journal_router_correction_reverse_lookup_preserves_memory_and_original_link() {
let f = Fixture::new().await;
f.fix_initial();
f.call(MemoryJournalRequest::Reconcile {
selector: f.selector.clone(),
})
.await;
let subject = f
.store
.read_journal(&f.host, &f.journal_id)
.unwrap()
.subject_id;
let before =
serde_json::to_value(f.store.memory(&subject, &f.selector.memory_id).unwrap()).unwrap();
let correction = f.add_correction();
let MemoryJournalResponse::Related(related) = f
.call(MemoryJournalRequest::Related {
journal_id: f.journal_id.clone(),
entry_id: correction,
after: None,
limit: 32,
})
.await
else {
panic!("related required")
};
assert_eq!(related.items.len(), 1);
assert_eq!(related.items[0].selector, f.selector);
assert!(related.items[0].reconsideration);
let MemoryJournalResponse::Read(read) = f.call(f.read_request()).await else {
panic!("read required")
};
assert_eq!(read.page.unwrap().text, JOURNAL_BODY);
assert!(matches!(read.source.connection, SourceConnection::Linked {
entry_id, reconsideration:true, ..
} if entry_id == f.entry_id));
assert_eq!(
serde_json::to_value(f.store.memory(&subject, &f.selector.memory_id).unwrap()).unwrap(),
before
);
}