feat: connect Memory provenance to durable Journal context
This commit is contained in:
@@ -698,6 +698,30 @@ impl LocalSubjectHost {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
impl SubjektivHost for LocalSubjectHost {
|
impl SubjektivHost for LocalSubjectHost {
|
||||||
|
fn memory_journal(
|
||||||
|
&self,
|
||||||
|
context: &SubjektivHostContext,
|
||||||
|
request: subjektiv::journal::memory_links::MemoryJournalRequest,
|
||||||
|
) -> Result<subjektiv::journal::memory_links::MemoryJournalResponse, SubjektivHostError> {
|
||||||
|
self.body(context)?;
|
||||||
|
let reader = subjektiv::journal::JournalHostContext::validated_reader(
|
||||||
|
&self.catalog.store,
|
||||||
|
&self.binding.subject_id,
|
||||||
|
)
|
||||||
|
.map_err(journal::journal_error)?;
|
||||||
|
self.catalog
|
||||||
|
.store
|
||||||
|
.execute_memory_journal_with_source_presence(&reader, request, |session| {
|
||||||
|
crate::subjektiv_sessions::original_session_presence(
|
||||||
|
&self.catalog.state_root,
|
||||||
|
&self.binding.scope_id,
|
||||||
|
&self.binding.subject_id,
|
||||||
|
&self.catalog.store,
|
||||||
|
session,
|
||||||
|
)
|
||||||
|
})
|
||||||
|
.map_err(journal::journal_error)
|
||||||
|
}
|
||||||
fn journal_recording_granted(&self) -> bool {
|
fn journal_recording_granted(&self) -> bool {
|
||||||
self.live.load(Ordering::Acquire)
|
self.live.load(Ordering::Acquire)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -233,7 +233,7 @@ pub(crate) fn prepare_worker_deletion(
|
|||||||
.map_err(|error| crate::store::StandaloneStoreError::JournalRetention(error.to_string()))
|
.map_err(|error| crate::store::StandaloneStoreError::JournalRetention(error.to_string()))
|
||||||
}
|
}
|
||||||
|
|
||||||
fn journal_error(error: JournalError) -> SubjektivHostError {
|
pub(super) fn journal_error(error: JournalError) -> SubjektivHostError {
|
||||||
match error {
|
match error {
|
||||||
JournalError::Conflict(message) => SubjektivHostError::Conflict {
|
JournalError::Conflict(message) => SubjektivHostError::Conflict {
|
||||||
code: "journal_conflict".into(),
|
code: "journal_conflict".into(),
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ use session_store::{Store, WorkerMetadataStore};
|
|||||||
use std::sync::atomic::AtomicBool;
|
use std::sync::atomic::AtomicBool;
|
||||||
|
|
||||||
mod compaction;
|
mod compaction;
|
||||||
|
mod memory_links;
|
||||||
|
|
||||||
struct Fixture {
|
struct Fixture {
|
||||||
_temp: tempfile::TempDir,
|
_temp: tempfile::TempDir,
|
||||||
|
|||||||
@@ -0,0 +1,565 @@
|
|||||||
|
//! Connected standalone Host -> Subjektiv Memory/Journal -> real FsStore coverage.
|
||||||
|
//! Candidate and Journal decisions are explicit test inputs; no model, Runtime or live DB
|
||||||
|
//! is used. The parent Journal fixture owns isolated temporary storage and binding cleanup.
|
||||||
|
//! Selected by the ordinary standalone lib tests, with no additional feature or service.
|
||||||
|
|
||||||
|
use super::*;
|
||||||
|
use memory::{
|
||||||
|
backend::MemoryStageCandidateOperation,
|
||||||
|
extract::{CandidateKind, ExtractedCandidate, StagingEvidence},
|
||||||
|
schema::{EvidenceKind, EvidenceOriginKind, SourceEvidenceRef, SourceRef},
|
||||||
|
};
|
||||||
|
use subjektiv::{
|
||||||
|
CandidateDecision, CandidateDecisionRequest, MemoryChangeTarget, MemoryDraft, MemoryRecord,
|
||||||
|
StagingResolution, SubjectStagingRecord,
|
||||||
|
journal::memory_links::{
|
||||||
|
JournalTargetAvailability, MemoryJournalLinks, MemoryJournalRequest, MemoryJournalResponse,
|
||||||
|
MemoryJournalSelector, SourceConnection, UnconnectedReason,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
fn adopt_original(fixture: &Fixture) -> (MemoryRecord, SubjectStagingRecord, StagingResolution) {
|
||||||
|
let evidence_index = session_store::public_index::read_fs_session_evidence_index(
|
||||||
|
fixture.sessions.root_dir(),
|
||||||
|
fixture.context.session_id.parse().unwrap(),
|
||||||
|
session_store::SessionPublicIndexLimits {
|
||||||
|
max_bytes: 8 * 1024 * 1024,
|
||||||
|
max_entries: 20_000,
|
||||||
|
max_segments: 64,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
let range = *evidence_index
|
||||||
|
.ranges
|
||||||
|
.get(&(fixture.segment.to_string(), "Efirst".into()))
|
||||||
|
.unwrap()
|
||||||
|
.first()
|
||||||
|
.unwrap();
|
||||||
|
let staged = fixture
|
||||||
|
.host()
|
||||||
|
.stage_candidate(
|
||||||
|
&fixture.context,
|
||||||
|
MemoryStageCandidateOperation {
|
||||||
|
source: SourceRef {
|
||||||
|
segment_id: fixture.segment.to_string(),
|
||||||
|
range,
|
||||||
|
},
|
||||||
|
extract_run_id: "explicit-test-extraction-input".into(),
|
||||||
|
candidate: ExtractedCandidate {
|
||||||
|
kind: CandidateKind::Preference,
|
||||||
|
claim: "Keep correction history".into(),
|
||||||
|
why_useful: "Preserves the reason for later decisions".into(),
|
||||||
|
staleness: None,
|
||||||
|
evidence_ids: vec!["Efirst".into()],
|
||||||
|
},
|
||||||
|
evidence: vec![StagingEvidence {
|
||||||
|
id: "Efirst".into(),
|
||||||
|
kind: EvidenceKind::new(EvidenceKind::MESSAGE),
|
||||||
|
entry_range: Some(range),
|
||||||
|
origin: None,
|
||||||
|
excerpt: None,
|
||||||
|
summary: None,
|
||||||
|
}],
|
||||||
|
source_refs: vec![SourceEvidenceRef {
|
||||||
|
session_id: Some(fixture.context.session_id.clone()),
|
||||||
|
segment_id: Some(fixture.segment.to_string()),
|
||||||
|
entry_range: Some(range),
|
||||||
|
evidence_id: Some("Efirst".into()),
|
||||||
|
..Default::default()
|
||||||
|
}],
|
||||||
|
},
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
let store = &fixture.host().catalog.store;
|
||||||
|
let subject = &fixture.connection.binding.subject_id;
|
||||||
|
let candidate = store
|
||||||
|
.staging_candidate(subject, &staged.staging_id)
|
||||||
|
.unwrap()
|
||||||
|
.unwrap();
|
||||||
|
let receipt = store
|
||||||
|
.decide_candidate(
|
||||||
|
subject,
|
||||||
|
CandidateDecisionRequest {
|
||||||
|
request_id: "explicit-test-adoption-input".into(),
|
||||||
|
candidate_id: staged.staging_id,
|
||||||
|
reason: "Adopt the committed original evidence, not the Journal summary".into(),
|
||||||
|
decision: CandidateDecision::Apply {
|
||||||
|
target: MemoryChangeTarget::Create,
|
||||||
|
draft: MemoryDraft::active(
|
||||||
|
CandidateKind::Preference,
|
||||||
|
"Keep correction history",
|
||||||
|
"Preserve earlier decisions and their corrections",
|
||||||
|
"Preserves the reason for later decisions",
|
||||||
|
"Adopt original HumanInput",
|
||||||
|
),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
(receipt.memory.unwrap(), candidate, receipt.resolution)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn create_entry(adapter: &LocalJournalHost, state: JournalEntryState) -> JournalReceipt {
|
||||||
|
save(
|
||||||
|
adapter,
|
||||||
|
"record-original-context",
|
||||||
|
&adapter.generation.initial_journal.state_token,
|
||||||
|
JournalAction::Create {
|
||||||
|
title: "A proposal was corrected".into(),
|
||||||
|
body: "Proposal A changed to B; only part was performed, outcome unconfirmed.".into(),
|
||||||
|
ongoing: if state == JournalEntryState::Open {
|
||||||
|
"Remaining work is not complete".into()
|
||||||
|
} else {
|
||||||
|
String::new()
|
||||||
|
},
|
||||||
|
state,
|
||||||
|
source_end: (state == JournalEntryState::Fixed).then(|| {
|
||||||
|
adapter
|
||||||
|
.generation
|
||||||
|
.selected_ranges
|
||||||
|
.last()
|
||||||
|
.unwrap()
|
||||||
|
.source
|
||||||
|
.pointer
|
||||||
|
.clone()
|
||||||
|
}),
|
||||||
|
correction_refs: vec![],
|
||||||
|
},
|
||||||
|
adapter.generation.selected_ranges.clone(),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn assert_originals_unchanged(
|
||||||
|
fixture: &Fixture,
|
||||||
|
memory: &MemoryRecord,
|
||||||
|
candidate: &SubjectStagingRecord,
|
||||||
|
resolution: &StagingResolution,
|
||||||
|
) {
|
||||||
|
let catalog = StandaloneSubjects::open_existing(&fixture.host().catalog.state_root).unwrap();
|
||||||
|
let store = &catalog.store;
|
||||||
|
let subject = &fixture.connection.binding.subject_id;
|
||||||
|
assert_eq!(
|
||||||
|
serde_json::to_value(store.memory(subject, &memory.id).unwrap().unwrap()).unwrap(),
|
||||||
|
serde_json::to_value(memory).unwrap(),
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
serde_json::to_value(
|
||||||
|
store
|
||||||
|
.staging_candidate(subject, &candidate.id)
|
||||||
|
.unwrap()
|
||||||
|
.unwrap()
|
||||||
|
)
|
||||||
|
.unwrap(),
|
||||||
|
serde_json::to_value(candidate).unwrap(),
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
serde_json::to_value(
|
||||||
|
store
|
||||||
|
.staging_resolution(subject, &candidate.id)
|
||||||
|
.unwrap()
|
||||||
|
.unwrap()
|
||||||
|
)
|
||||||
|
.unwrap(),
|
||||||
|
serde_json::to_value(resolution).unwrap(),
|
||||||
|
);
|
||||||
|
assert_eq!(store.list_memories(subject).unwrap().len(), 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
fn selector(memory: &MemoryRecord, candidate: &SubjectStagingRecord) -> MemoryJournalSelector {
|
||||||
|
MemoryJournalSelector {
|
||||||
|
memory_id: memory.id.clone(),
|
||||||
|
change_id: memory.change_id.clone(),
|
||||||
|
candidate_id: candidate.id.clone(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn links(
|
||||||
|
fixture: &Fixture,
|
||||||
|
selector: &MemoryJournalSelector,
|
||||||
|
reconcile: bool,
|
||||||
|
) -> MemoryJournalLinks {
|
||||||
|
let request = if reconcile {
|
||||||
|
MemoryJournalRequest::Reconcile {
|
||||||
|
selector: selector.clone(),
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
MemoryJournalRequest::Links {
|
||||||
|
selector: selector.clone(),
|
||||||
|
}
|
||||||
|
};
|
||||||
|
let MemoryJournalResponse::Links(links) = fixture
|
||||||
|
.host()
|
||||||
|
.memory_journal(&fixture.context, request)
|
||||||
|
.unwrap()
|
||||||
|
else {
|
||||||
|
panic!("expected Memory Journal links")
|
||||||
|
};
|
||||||
|
links
|
||||||
|
}
|
||||||
|
|
||||||
|
fn read_request(
|
||||||
|
selector: &MemoryJournalSelector,
|
||||||
|
journal: &str,
|
||||||
|
entry: &str,
|
||||||
|
) -> MemoryJournalRequest {
|
||||||
|
MemoryJournalRequest::Read {
|
||||||
|
selector: selector.clone(),
|
||||||
|
source_index: 0,
|
||||||
|
journal_id: journal.into(),
|
||||||
|
entry_id: entry.into(),
|
||||||
|
expected_token: None,
|
||||||
|
byte_offset: 0,
|
||||||
|
max_bytes: MAX_JOURNAL_READ_BYTES,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn assert_link(links: &MemoryJournalLinks, journal: &str, entry: &str, reconsideration: bool) {
|
||||||
|
assert_eq!(links.sources.len(), 1);
|
||||||
|
assert_eq!(links.sources[0].source_index, 0);
|
||||||
|
assert_eq!(
|
||||||
|
links.sources[0].connection,
|
||||||
|
SourceConnection::Linked {
|
||||||
|
journal_id: journal.into(),
|
||||||
|
entry_id: entry.into(),
|
||||||
|
target: JournalTargetAvailability::Available,
|
||||||
|
reconsideration,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn saved_link_reads_after_original_worker_and_session_deletion_from_replacement_host() {
|
||||||
|
let mut fixture = Fixture::new("Please preserve the reasons for my corrected decisions");
|
||||||
|
let (memory, candidate, resolution) = adopt_original(&fixture);
|
||||||
|
let selector = selector(&memory, &candidate);
|
||||||
|
assert_eq!(
|
||||||
|
candidate.evidence[0].origin.as_ref().unwrap().kind,
|
||||||
|
EvidenceOriginKind::HumanInput
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
candidate.source_refs[0].origin,
|
||||||
|
candidate.evidence[0].origin
|
||||||
|
);
|
||||||
|
assert!(
|
||||||
|
candidate.evidence[0]
|
||||||
|
.excerpt
|
||||||
|
.as_ref()
|
||||||
|
.unwrap()
|
||||||
|
.contains("corrected decisions")
|
||||||
|
);
|
||||||
|
let (adapter, _, _) = fixture.adapter();
|
||||||
|
let recorded = create_entry(&adapter, JournalEntryState::Fixed);
|
||||||
|
let entry = recorded.entry_id.clone().unwrap();
|
||||||
|
let journal = adapter.grant.journal_id.clone();
|
||||||
|
let connected = links(&fixture, &selector, true);
|
||||||
|
assert_link(&connected, &journal, &entry, false);
|
||||||
|
let request = read_request(&selector, &journal, &entry);
|
||||||
|
let before = fixture
|
||||||
|
.host()
|
||||||
|
.memory_journal(&fixture.context, request.clone())
|
||||||
|
.unwrap();
|
||||||
|
save(
|
||||||
|
&adapter,
|
||||||
|
"checkpoint-original",
|
||||||
|
&recorded.state_token,
|
||||||
|
JournalAction::Checkpoint {
|
||||||
|
record_operation_ids: vec![recorded.request_id],
|
||||||
|
},
|
||||||
|
adapter.generation.selected_ranges.clone(),
|
||||||
|
);
|
||||||
|
adapter.finish().unwrap();
|
||||||
|
fixture
|
||||||
|
.host()
|
||||||
|
.catalog
|
||||||
|
.store
|
||||||
|
.close_journal_generation(
|
||||||
|
&adapter.context,
|
||||||
|
&adapter.generation.id,
|
||||||
|
&adapter.generation.job,
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
let state = fixture.host().catalog.state_root.clone();
|
||||||
|
let subject = fixture.connection.binding.subject_id.clone();
|
||||||
|
let original_session = fixture.context.session_id.clone();
|
||||||
|
let (worker, workers) = fixture.stop_worker();
|
||||||
|
workers.delete(worker).unwrap();
|
||||||
|
assert!(matches!(
|
||||||
|
workers.load(worker),
|
||||||
|
Err(crate::store::StandaloneStoreError::WorkerNotFound(_))
|
||||||
|
));
|
||||||
|
assert!(!fixture.sessions.root_dir().join(&original_session).exists());
|
||||||
|
assert!(
|
||||||
|
!retained_workers_root(&state)
|
||||||
|
.join(worker.to_string())
|
||||||
|
.exists()
|
||||||
|
);
|
||||||
|
|
||||||
|
let replacement_worker = protocol::WorkerId::now_v7();
|
||||||
|
let mut replacement = StandaloneSubjects::open_existing(&state)
|
||||||
|
.unwrap()
|
||||||
|
.connect(&subject, replacement_worker)
|
||||||
|
.unwrap();
|
||||||
|
let replacement_context = SubjektivHostContext {
|
||||||
|
worker_id: replacement_worker.to_string(),
|
||||||
|
session_id: session_store::SessionId::now_v7().to_string(),
|
||||||
|
};
|
||||||
|
let MemoryJournalResponse::Links(saved) = replacement
|
||||||
|
.host
|
||||||
|
.memory_journal(
|
||||||
|
&replacement_context,
|
||||||
|
MemoryJournalRequest::Links {
|
||||||
|
selector: selector.clone(),
|
||||||
|
},
|
||||||
|
)
|
||||||
|
.unwrap()
|
||||||
|
else {
|
||||||
|
panic!("expected saved links")
|
||||||
|
};
|
||||||
|
assert_link(&saved, &journal, &entry, false);
|
||||||
|
let after = replacement
|
||||||
|
.host
|
||||||
|
.memory_journal(&replacement_context, request)
|
||||||
|
.unwrap();
|
||||||
|
let MemoryJournalResponse::Read(read) = &after else {
|
||||||
|
panic!("expected saved Journal text")
|
||||||
|
};
|
||||||
|
assert!(
|
||||||
|
read.page
|
||||||
|
.as_ref()
|
||||||
|
.unwrap()
|
||||||
|
.text
|
||||||
|
.contains("outcome unconfirmed")
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
read.source.original_source,
|
||||||
|
subjektiv::journal::memory_links::OriginalSourceAvailability::Missing
|
||||||
|
);
|
||||||
|
// Reading the saved edge does not depend on a live Session fetch or a replacement Job.
|
||||||
|
let MemoryJournalResponse::Read(before) = before else {
|
||||||
|
panic!("expected Journal text")
|
||||||
|
};
|
||||||
|
assert_eq!(before.page, read.page);
|
||||||
|
assert!(
|
||||||
|
replacement.host.jobs().is_err(),
|
||||||
|
"the reader has no model/Job executor"
|
||||||
|
);
|
||||||
|
assert_originals_unchanged(&fixture, &memory, &candidate, &resolution);
|
||||||
|
replacement.close().unwrap();
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn late_journal_fix_connects_existing_memory_without_using_checkpoint_as_event_end() {
|
||||||
|
let fixture = Fixture::new("Keep the reasons behind corrected decisions");
|
||||||
|
let (memory, candidate, resolution) = adopt_original(&fixture);
|
||||||
|
let selector = selector(&memory, &candidate);
|
||||||
|
assert_eq!(
|
||||||
|
links(&fixture, &selector, false).sources[0].connection,
|
||||||
|
SourceConnection::Unconnected {
|
||||||
|
reason: UnconnectedReason::NotReconciled
|
||||||
|
}
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
links(&fixture, &selector, true).sources[0].connection,
|
||||||
|
SourceConnection::Unconnected {
|
||||||
|
reason: UnconnectedReason::SourceNotAdmitted
|
||||||
|
}
|
||||||
|
);
|
||||||
|
let (adapter, _, _) = fixture.adapter();
|
||||||
|
let recorded = create_entry(&adapter, JournalEntryState::Open);
|
||||||
|
let entry = recorded.entry_id.clone().unwrap();
|
||||||
|
let checkpoint = save(
|
||||||
|
&adapter,
|
||||||
|
"checkpoint-open",
|
||||||
|
&recorded.state_token,
|
||||||
|
JournalAction::Checkpoint {
|
||||||
|
record_operation_ids: vec![recorded.request_id],
|
||||||
|
},
|
||||||
|
adapter.generation.selected_ranges.clone(),
|
||||||
|
);
|
||||||
|
let unconnected = links(&fixture, &selector, true);
|
||||||
|
assert_eq!(
|
||||||
|
unconnected.sources[0].connection,
|
||||||
|
SourceConnection::Unconnected {
|
||||||
|
reason: UnconnectedReason::JournalNotFixed
|
||||||
|
}
|
||||||
|
);
|
||||||
|
assert_eq!(links(&fixture, &selector, true), unconnected);
|
||||||
|
let state = adapter.state().unwrap();
|
||||||
|
let open = state.current_open.unwrap();
|
||||||
|
assert_eq!(open.id, entry);
|
||||||
|
assert_eq!(open.state, JournalEntryState::Open);
|
||||||
|
assert!(open.source_end.is_none());
|
||||||
|
let last = adapter.generation.selected_ranges.last().unwrap();
|
||||||
|
assert_eq!(state.current_journal.process_order, last.source.order + 1);
|
||||||
|
assert_eq!(state.current_journal.process_byte, 0);
|
||||||
|
save(
|
||||||
|
&adapter,
|
||||||
|
"fix-later",
|
||||||
|
&checkpoint.state_token,
|
||||||
|
JournalAction::Fix {
|
||||||
|
entry_id: entry.clone(),
|
||||||
|
source_end: adapter
|
||||||
|
.generation
|
||||||
|
.selected_ranges
|
||||||
|
.last()
|
||||||
|
.unwrap()
|
||||||
|
.source
|
||||||
|
.pointer
|
||||||
|
.clone(),
|
||||||
|
},
|
||||||
|
adapter.generation.selected_ranges.clone(),
|
||||||
|
);
|
||||||
|
// A plain saved-state read must not silently perform reconciliation.
|
||||||
|
assert_eq!(links(&fixture, &selector, false), unconnected);
|
||||||
|
let connected = links(&fixture, &selector, true);
|
||||||
|
assert_link(&connected, &adapter.grant.journal_id, &entry, false);
|
||||||
|
assert_eq!(links(&fixture, &selector, true), connected);
|
||||||
|
assert_eq!(links(&fixture, &selector, false), connected);
|
||||||
|
assert_originals_unchanged(&fixture, &memory, &candidate, &resolution);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn host_rejects_foreign_subject_worker_context_and_closed_connection_for_all_link_operations() {
|
||||||
|
let mut fixture = Fixture::new("Keep correction history");
|
||||||
|
let (memory, candidate, resolution) = adopt_original(&fixture);
|
||||||
|
let selector = selector(&memory, &candidate);
|
||||||
|
let (adapter, _, _) = fixture.adapter();
|
||||||
|
let recorded = create_entry(&adapter, JournalEntryState::Fixed);
|
||||||
|
let journal = adapter.grant.journal_id.clone();
|
||||||
|
let entry = recorded.entry_id.unwrap();
|
||||||
|
assert_link(&links(&fixture, &selector, true), &journal, &entry, false);
|
||||||
|
let requests = [
|
||||||
|
MemoryJournalRequest::Reconcile {
|
||||||
|
selector: selector.clone(),
|
||||||
|
},
|
||||||
|
MemoryJournalRequest::Links {
|
||||||
|
selector: selector.clone(),
|
||||||
|
},
|
||||||
|
read_request(&selector, &journal, &entry),
|
||||||
|
MemoryJournalRequest::Related {
|
||||||
|
journal_id: journal,
|
||||||
|
entry_id: entry,
|
||||||
|
after: None,
|
||||||
|
limit: 32,
|
||||||
|
},
|
||||||
|
];
|
||||||
|
let catalog = StandaloneSubjects::open_existing(&fixture.host().catalog.state_root).unwrap();
|
||||||
|
let foreign_subject = catalog.create("different-reader", None).unwrap();
|
||||||
|
let foreign_worker = protocol::WorkerId::now_v7();
|
||||||
|
let mut foreign = catalog
|
||||||
|
.connect(&foreign_subject.id, foreign_worker)
|
||||||
|
.unwrap();
|
||||||
|
let foreign_context = SubjektivHostContext {
|
||||||
|
worker_id: foreign_worker.to_string(),
|
||||||
|
session_id: session_store::SessionId::now_v7().to_string(),
|
||||||
|
};
|
||||||
|
let wrong_worker = SubjektivHostContext {
|
||||||
|
worker_id: foreign_worker.to_string(),
|
||||||
|
..fixture.context.clone()
|
||||||
|
};
|
||||||
|
for request in &requests {
|
||||||
|
assert!(
|
||||||
|
foreign
|
||||||
|
.host
|
||||||
|
.memory_journal(&foreign_context, request.clone())
|
||||||
|
.is_err()
|
||||||
|
);
|
||||||
|
assert!(
|
||||||
|
fixture
|
||||||
|
.host()
|
||||||
|
.memory_journal(&wrong_worker, request.clone())
|
||||||
|
.is_err()
|
||||||
|
);
|
||||||
|
}
|
||||||
|
foreign.close().unwrap();
|
||||||
|
fixture.connection.close().unwrap();
|
||||||
|
for request in requests {
|
||||||
|
assert!(
|
||||||
|
fixture
|
||||||
|
.host()
|
||||||
|
.memory_journal(&fixture.context, request)
|
||||||
|
.is_err()
|
||||||
|
);
|
||||||
|
}
|
||||||
|
assert_originals_unchanged(&fixture, &memory, &candidate, &resolution);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn journal_correction_finds_related_memory_without_revising_or_retargeting_its_evidence() {
|
||||||
|
let fixture = Fixture::new("Keep correction history");
|
||||||
|
let (memory, candidate, resolution) = adopt_original(&fixture);
|
||||||
|
let selector = selector(&memory, &candidate);
|
||||||
|
fixture.append("correction", "The earlier result was not confirmed");
|
||||||
|
let (adapter, _, _) = fixture.adapter();
|
||||||
|
let ranges = &adapter.generation.selected_ranges;
|
||||||
|
assert_eq!(ranges.len(), 2);
|
||||||
|
let original = save(
|
||||||
|
&adapter,
|
||||||
|
"original-event",
|
||||||
|
&adapter.generation.initial_journal.state_token,
|
||||||
|
JournalAction::Create {
|
||||||
|
title: "Original context".into(),
|
||||||
|
body: "A change was proposed; execution remained uncertain".into(),
|
||||||
|
ongoing: String::new(),
|
||||||
|
state: JournalEntryState::Fixed,
|
||||||
|
source_end: Some(ranges[0].source.pointer.clone()),
|
||||||
|
correction_refs: vec![],
|
||||||
|
},
|
||||||
|
vec![ranges[0].clone()],
|
||||||
|
);
|
||||||
|
let entry = original.entry_id.unwrap();
|
||||||
|
let journal = &adapter.grant.journal_id;
|
||||||
|
assert_link(&links(&fixture, &selector, true), journal, &entry, false);
|
||||||
|
let corrected = save(
|
||||||
|
&adapter,
|
||||||
|
"correction-event",
|
||||||
|
&original.state_token,
|
||||||
|
JournalAction::Create {
|
||||||
|
title: "Correction".into(),
|
||||||
|
body: "The earlier result was unconfirmed; reconsider its associated Memory".into(),
|
||||||
|
ongoing: String::new(),
|
||||||
|
state: JournalEntryState::Fixed,
|
||||||
|
source_end: Some(ranges[1].source.pointer.clone()),
|
||||||
|
correction_refs: vec![entry.clone()],
|
||||||
|
},
|
||||||
|
vec![ranges[1].clone()],
|
||||||
|
);
|
||||||
|
assert_link(&links(&fixture, &selector, false), journal, &entry, true);
|
||||||
|
let correction_entry = corrected.entry_id.unwrap();
|
||||||
|
for target in [&entry, &correction_entry] {
|
||||||
|
let MemoryJournalResponse::Related(related) = fixture
|
||||||
|
.host()
|
||||||
|
.memory_journal(
|
||||||
|
&fixture.context,
|
||||||
|
MemoryJournalRequest::Related {
|
||||||
|
journal_id: journal.clone(),
|
||||||
|
entry_id: target.clone(),
|
||||||
|
after: None,
|
||||||
|
limit: 32,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
.unwrap()
|
||||||
|
else {
|
||||||
|
panic!("expected related Memory")
|
||||||
|
};
|
||||||
|
assert_eq!(related.items.len(), 1);
|
||||||
|
assert_eq!(related.items[0].selector, selector);
|
||||||
|
assert_eq!(related.items[0].source_index, 0);
|
||||||
|
assert!(related.items[0].reconsideration);
|
||||||
|
assert!(related.next.is_none());
|
||||||
|
}
|
||||||
|
let MemoryJournalResponse::Read(read) = fixture
|
||||||
|
.host()
|
||||||
|
.memory_journal(&fixture.context, read_request(&selector, journal, &entry))
|
||||||
|
.unwrap()
|
||||||
|
else {
|
||||||
|
panic!("expected original context")
|
||||||
|
};
|
||||||
|
assert!(
|
||||||
|
read.page
|
||||||
|
.unwrap()
|
||||||
|
.text
|
||||||
|
.contains("execution remained uncertain")
|
||||||
|
);
|
||||||
|
assert_originals_unchanged(&fixture, &memory, &candidate, &resolution);
|
||||||
|
}
|
||||||
@@ -178,6 +178,58 @@ pub(crate) fn authorized_sessions_path(
|
|||||||
.map_err(|error: Failure| SubjektivHostError::Unavailable(error.message))
|
.map_err(|error: Failure| SubjektivHostError::Unavailable(error.message))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Checks only saved ownership and filesystem metadata, never Session bytes or a Worker restore.
|
||||||
|
/// Inaccessible/corrupt storage stays unknown rather than being reported as absent.
|
||||||
|
pub(crate) fn original_session_presence(
|
||||||
|
state_root: &Path,
|
||||||
|
scope_id: &str,
|
||||||
|
subject_id: &str,
|
||||||
|
store: &SubjektivStore,
|
||||||
|
session_id: &str,
|
||||||
|
) -> Option<bool> {
|
||||||
|
let attribution = store.session_attribution(session_id).ok()??;
|
||||||
|
if store.scope_id() != scope_id
|
||||||
|
|| attribution.subject_id != subject_id
|
||||||
|
|| attribution.runtime_id != scope_id
|
||||||
|
{
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
let worker: WorkerId = attribution.worker_id.parse().ok()?;
|
||||||
|
let session: SessionId = session_id.parse().ok()?;
|
||||||
|
let active = StandaloneWorkerStore::open_existing(state_root).ok()?;
|
||||||
|
let (workers, record) = match active.load(worker) {
|
||||||
|
Ok(record) => (active, record),
|
||||||
|
Err(StandaloneStoreError::WorkerNotFound(_)) => {
|
||||||
|
let root = state_root.join("subjektiv").join("retained-workers");
|
||||||
|
match std::fs::metadata(&root) {
|
||||||
|
Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Some(false),
|
||||||
|
Err(_) => return None,
|
||||||
|
Ok(_) => {}
|
||||||
|
}
|
||||||
|
let retained = StandaloneWorkerStore::open_existing(root).ok()?;
|
||||||
|
match retained.load(worker) {
|
||||||
|
Ok(record) => (retained, record),
|
||||||
|
Err(StandaloneStoreError::WorkerNotFound(_)) => return Some(false),
|
||||||
|
Err(_) => return None,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Err(_) => return None,
|
||||||
|
};
|
||||||
|
if record.worker_id != worker
|
||||||
|
|| !record
|
||||||
|
.subject
|
||||||
|
.as_ref()
|
||||||
|
.is_some_and(|binding| binding.scope_id == scope_id && binding.subject_id == subject_id)
|
||||||
|
{
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
match std::fs::metadata(workers.sessions_dir(worker).join(session.to_string())) {
|
||||||
|
Ok(metadata) if metadata.is_dir() => Some(true),
|
||||||
|
Err(error) if error.kind() == std::io::ErrorKind::NotFound => Some(false),
|
||||||
|
_ => None,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
fn authorized_index_with_reader(
|
fn authorized_index_with_reader(
|
||||||
state_root: &Path,
|
state_root: &Path,
|
||||||
scope_id: &str,
|
scope_id: &str,
|
||||||
|
|||||||
@@ -0,0 +1,668 @@
|
|||||||
|
//! Additive, immutable provenance-to-context links, not additional evidence for a Memory claim.
|
||||||
|
//!
|
||||||
|
//! Reconciliation reads only saved candidate anchors and the Host-attested Journal source ledger.
|
||||||
|
//! It never fetches history, starts Workers, changes extraction/consolidation, fixes an open event,
|
||||||
|
//! or mutates Memory. A stable evidence reference identifies a public entry, not its legacy numeric
|
||||||
|
//! display range. Missing stable identity stays unconnected; numeric ranges are not guessed to be
|
||||||
|
//! canonical order. Saved edges remain usable without the original runtime and cannot be retargeted.
|
||||||
|
//! Journal content is never copied here. Explicit erasure retains edges and exposes the tombstone.
|
||||||
|
|
||||||
|
use super::{
|
||||||
|
JournalError, JournalHostContext, JournalResult, JournalSourcePointer, JournalTextField,
|
||||||
|
JournalTextPage, MAX_JOURNAL_PAGE, MAX_JOURNAL_RESPONSE_BYTES,
|
||||||
|
};
|
||||||
|
use crate::{MAX_STAGING_ANCHORS, SubjectStagingRecord, SubjektivStore};
|
||||||
|
use memory::schema::SourceEvidenceRef;
|
||||||
|
use rusqlite::{Connection, OptionalExtension, Transaction, params};
|
||||||
|
use serde::{Deserialize, Serialize};
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests;
|
||||||
|
|
||||||
|
pub(crate) fn migrate(tx: &Transaction<'_>) -> feature_storage::Result<()> {
|
||||||
|
tx.execute_batch(include_str!("schema.sql"))?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||||
|
#[serde(deny_unknown_fields)]
|
||||||
|
pub struct MemoryJournalSelector {
|
||||||
|
pub memory_id: String,
|
||||||
|
pub change_id: String,
|
||||||
|
pub candidate_id: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||||
|
#[serde(tag = "action", rename_all = "snake_case", deny_unknown_fields)]
|
||||||
|
pub enum MemoryJournalRequest {
|
||||||
|
Reconcile {
|
||||||
|
selector: MemoryJournalSelector,
|
||||||
|
},
|
||||||
|
/// Reads saved link state only; does not implicitly retry reconciliation.
|
||||||
|
Links {
|
||||||
|
selector: MemoryJournalSelector,
|
||||||
|
},
|
||||||
|
/// Continues reconciliation over immutable source indices, at most MAX_STAGING_ANCHORS per call.
|
||||||
|
ReconcilePage {
|
||||||
|
selector: MemoryJournalSelector,
|
||||||
|
source_offset: usize,
|
||||||
|
limit: usize,
|
||||||
|
},
|
||||||
|
/// Reads another saved source page without reconciliation.
|
||||||
|
LinksPage {
|
||||||
|
selector: MemoryJournalSelector,
|
||||||
|
source_offset: usize,
|
||||||
|
limit: usize,
|
||||||
|
},
|
||||||
|
Read {
|
||||||
|
selector: MemoryJournalSelector,
|
||||||
|
source_index: usize,
|
||||||
|
journal_id: String,
|
||||||
|
entry_id: String,
|
||||||
|
expected_token: Option<String>,
|
||||||
|
byte_offset: usize,
|
||||||
|
max_bytes: usize,
|
||||||
|
},
|
||||||
|
Related {
|
||||||
|
journal_id: String,
|
||||||
|
entry_id: String,
|
||||||
|
after: Option<RelatedMemoryCursor>,
|
||||||
|
limit: usize,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||||
|
#[serde(
|
||||||
|
tag = "kind",
|
||||||
|
content = "value",
|
||||||
|
rename_all = "snake_case",
|
||||||
|
deny_unknown_fields
|
||||||
|
)]
|
||||||
|
pub enum MemoryJournalResponse {
|
||||||
|
Links(MemoryJournalLinks),
|
||||||
|
Read(MemoryJournalRead),
|
||||||
|
Related(RelatedMemories),
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
|
||||||
|
#[serde(rename_all = "snake_case")]
|
||||||
|
pub enum OriginalSourceAvailability {
|
||||||
|
/// No saved Host attestation. None of these values imply a live history fetch.
|
||||||
|
Unknown,
|
||||||
|
Missing,
|
||||||
|
Available,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
|
||||||
|
#[serde(rename_all = "snake_case")]
|
||||||
|
pub enum JournalTargetAvailability {
|
||||||
|
Available,
|
||||||
|
Unavailable,
|
||||||
|
Erased,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
|
||||||
|
#[serde(rename_all = "snake_case")]
|
||||||
|
pub enum UnconnectedReason {
|
||||||
|
NotReconciled,
|
||||||
|
InsufficientSource,
|
||||||
|
SourceNotAdmitted,
|
||||||
|
AmbiguousSource,
|
||||||
|
JournalNotFixed,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||||
|
#[serde(tag = "state", rename_all = "snake_case", deny_unknown_fields)]
|
||||||
|
pub enum SourceConnection {
|
||||||
|
Unconnected {
|
||||||
|
reason: UnconnectedReason,
|
||||||
|
},
|
||||||
|
Linked {
|
||||||
|
journal_id: String,
|
||||||
|
entry_id: String,
|
||||||
|
target: JournalTargetAvailability,
|
||||||
|
/// A saved correction invites reconsideration, never an automatic Memory edit.
|
||||||
|
reconsideration: bool,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||||
|
#[serde(deny_unknown_fields)]
|
||||||
|
pub struct MemoryJournalSourceLink {
|
||||||
|
pub source_index: usize,
|
||||||
|
pub original_source: OriginalSourceAvailability,
|
||||||
|
pub connection: SourceConnection,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||||
|
#[serde(deny_unknown_fields)]
|
||||||
|
pub struct MemoryJournalLinks {
|
||||||
|
pub selector: MemoryJournalSelector,
|
||||||
|
pub sources: Vec<MemoryJournalSourceLink>,
|
||||||
|
pub total_sources: usize,
|
||||||
|
pub next_source_index: Option<usize>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||||
|
#[serde(deny_unknown_fields)]
|
||||||
|
pub struct MemoryJournalRead {
|
||||||
|
pub source: MemoryJournalSourceLink,
|
||||||
|
/// Absent for an unavailable or explicitly erased target; never falls back to another entry.
|
||||||
|
pub page: Option<JournalTextPage>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||||
|
#[serde(deny_unknown_fields)]
|
||||||
|
pub struct RelatedMemoryCursor {
|
||||||
|
pub memory_id: String,
|
||||||
|
pub change_id: String,
|
||||||
|
pub candidate_id: String,
|
||||||
|
pub source_index: usize,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||||
|
#[serde(deny_unknown_fields)]
|
||||||
|
pub struct RelatedMemory {
|
||||||
|
pub selector: MemoryJournalSelector,
|
||||||
|
pub source_index: usize,
|
||||||
|
pub reconsideration: bool,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||||
|
#[serde(deny_unknown_fields)]
|
||||||
|
pub struct RelatedMemories {
|
||||||
|
pub items: Vec<RelatedMemory>,
|
||||||
|
pub next: Option<RelatedMemoryCursor>,
|
||||||
|
}
|
||||||
|
|
||||||
|
fn invalid(message: &str) -> JournalError {
|
||||||
|
JournalError::InvalidInput(message.into())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn identifier(value: &str) -> JournalResult<()> {
|
||||||
|
if value.is_empty() || value.len() > 256 || value.chars().any(char::is_control) {
|
||||||
|
return Err(invalid("invalid Memory/Journal selector identifier"));
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn selector_valid(selector: &MemoryJournalSelector) -> JournalResult<()> {
|
||||||
|
identifier(&selector.memory_id)?;
|
||||||
|
identifier(&selector.change_id)?;
|
||||||
|
identifier(&selector.candidate_id)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn candidate(
|
||||||
|
store: &SubjektivStore,
|
||||||
|
tx: &Connection,
|
||||||
|
host: &JournalHostContext,
|
||||||
|
selector: &MemoryJournalSelector,
|
||||||
|
) -> JournalResult<SubjectStagingRecord> {
|
||||||
|
super::store::check_host(store, tx, host, false)?;
|
||||||
|
selector_valid(selector)?;
|
||||||
|
// The immutable change-candidate edge, not the current Memory projection or a supplied
|
||||||
|
// candidate ID alone, authorizes this provenance selector.
|
||||||
|
let raw: Option<String> = tx
|
||||||
|
.query_row(
|
||||||
|
"SELECT s.record_json FROM staging_records s JOIN memory_change_candidates c
|
||||||
|
ON c.subject_id=s.subject_id AND c.candidate_id=s.candidate_id
|
||||||
|
WHERE c.subject_id=?1 AND c.memory_id=?2 AND c.change_id=?3 AND c.candidate_id=?4",
|
||||||
|
params![
|
||||||
|
host.subject_id,
|
||||||
|
selector.memory_id,
|
||||||
|
selector.change_id,
|
||||||
|
selector.candidate_id
|
||||||
|
],
|
||||||
|
|r| r.get(0),
|
||||||
|
)
|
||||||
|
.optional()?;
|
||||||
|
let record: SubjectStagingRecord = serde_json::from_str(
|
||||||
|
&raw.ok_or_else(|| JournalError::NotFound("Memory change candidate membership".into()))?,
|
||||||
|
)?;
|
||||||
|
Ok(record)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug)]
|
||||||
|
struct SavedLink {
|
||||||
|
reason: String,
|
||||||
|
journal_id: Option<String>,
|
||||||
|
entry_id: Option<String>,
|
||||||
|
session_id: Option<String>,
|
||||||
|
order: Option<u64>,
|
||||||
|
}
|
||||||
|
|
||||||
|
fn saved(
|
||||||
|
tx: &Connection,
|
||||||
|
host: &JournalHostContext,
|
||||||
|
candidate_id: &str,
|
||||||
|
index: usize,
|
||||||
|
) -> JournalResult<Option<SavedLink>> {
|
||||||
|
Ok(tx.query_row(
|
||||||
|
"SELECT reason,journal_id,entry_id,source_session_id,source_order FROM memory_journal_links
|
||||||
|
WHERE subject_id=?1 AND candidate_id=?2 AND source_index=?3",
|
||||||
|
params![host.subject_id, candidate_id, index],
|
||||||
|
|r| Ok(SavedLink { reason:r.get(0)?,journal_id:r.get(1)?,entry_id:r.get(2)?,session_id:r.get(3)?,order:r.get(4)? }),
|
||||||
|
).optional()?)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn original_availability(
|
||||||
|
tx: &Connection,
|
||||||
|
journal: &str,
|
||||||
|
order: u64,
|
||||||
|
) -> JournalResult<OriginalSourceAvailability> {
|
||||||
|
let unavailable: Option<String> = tx
|
||||||
|
.query_row(
|
||||||
|
"SELECT unavailable_json FROM journal_sources WHERE journal_id=?1 AND source_order=?2",
|
||||||
|
params![journal, order],
|
||||||
|
|r| r.get(0),
|
||||||
|
)
|
||||||
|
.optional()?;
|
||||||
|
Ok(match unavailable {
|
||||||
|
None => OriginalSourceAvailability::Unknown,
|
||||||
|
Some(raw) => {
|
||||||
|
let ranges: Vec<super::JournalByteRange> = serde_json::from_str(&raw)?;
|
||||||
|
if ranges.is_empty() {
|
||||||
|
OriginalSourceAvailability::Available
|
||||||
|
} else {
|
||||||
|
OriginalSourceAvailability::Missing
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
fn project(
|
||||||
|
tx: &Connection,
|
||||||
|
host: &JournalHostContext,
|
||||||
|
candidate_id: &str,
|
||||||
|
index: usize,
|
||||||
|
) -> JournalResult<MemoryJournalSourceLink> {
|
||||||
|
let Some(link) = saved(tx, host, candidate_id, index)? else {
|
||||||
|
return Ok(MemoryJournalSourceLink {
|
||||||
|
source_index: index,
|
||||||
|
original_source: OriginalSourceAvailability::Unknown,
|
||||||
|
connection: SourceConnection::Unconnected {
|
||||||
|
reason: UnconnectedReason::NotReconciled,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
};
|
||||||
|
if link.reason != "linked" {
|
||||||
|
let reason = match link.reason.as_str() {
|
||||||
|
"insufficient_source" => UnconnectedReason::InsufficientSource,
|
||||||
|
"source_not_admitted" => UnconnectedReason::SourceNotAdmitted,
|
||||||
|
"ambiguous_source" => UnconnectedReason::AmbiguousSource,
|
||||||
|
"journal_not_fixed" => UnconnectedReason::JournalNotFixed,
|
||||||
|
_ => return Err(invalid("invalid saved connection reason")),
|
||||||
|
};
|
||||||
|
return Ok(MemoryJournalSourceLink {
|
||||||
|
source_index: index,
|
||||||
|
original_source: OriginalSourceAvailability::Unknown,
|
||||||
|
connection: SourceConnection::Unconnected { reason },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
let journal_id = link
|
||||||
|
.journal_id
|
||||||
|
.ok_or_else(|| invalid("saved link lacks Journal"))?;
|
||||||
|
let entry_id = link
|
||||||
|
.entry_id
|
||||||
|
.ok_or_else(|| invalid("saved link lacks entry"))?;
|
||||||
|
let session = link
|
||||||
|
.session_id
|
||||||
|
.ok_or_else(|| invalid("saved link lacks Session"))?;
|
||||||
|
let order = link
|
||||||
|
.order
|
||||||
|
.ok_or_else(|| invalid("saved link lacks source order"))?;
|
||||||
|
let erased: bool = tx.query_row(
|
||||||
|
"SELECT EXISTS(SELECT 1 FROM journal_erased_sessions WHERE subject_id=?1 AND session_id=?2)",
|
||||||
|
params![host.subject_id, session], |r| r.get(0),
|
||||||
|
)?;
|
||||||
|
let exists: bool = tx.query_row(
|
||||||
|
"SELECT EXISTS(SELECT 1 FROM journal_entries e JOIN journals j ON j.id=e.journal_id
|
||||||
|
WHERE e.subject_id=?1 AND e.journal_id=?2 AND e.id=?3 AND j.source_session_id=?4)",
|
||||||
|
params![host.subject_id, journal_id, entry_id, session],
|
||||||
|
|r| r.get(0),
|
||||||
|
)?;
|
||||||
|
let reconsideration = tx.query_row(
|
||||||
|
"SELECT EXISTS(SELECT 1 FROM journal_corrections WHERE subject_id=?1 AND target_id=?2)",
|
||||||
|
params![host.subject_id, entry_id],
|
||||||
|
|r| r.get(0),
|
||||||
|
)?;
|
||||||
|
Ok(MemoryJournalSourceLink {
|
||||||
|
source_index: index,
|
||||||
|
original_source: original_availability(tx, &journal_id, order)?,
|
||||||
|
connection: SourceConnection::Linked {
|
||||||
|
journal_id,
|
||||||
|
entry_id,
|
||||||
|
target: if erased {
|
||||||
|
JournalTargetAvailability::Erased
|
||||||
|
} else if exists {
|
||||||
|
JournalTargetAvailability::Available
|
||||||
|
} else {
|
||||||
|
JournalTargetAvailability::Unavailable
|
||||||
|
},
|
||||||
|
reconsideration,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
struct Resolved {
|
||||||
|
journal: String,
|
||||||
|
entry: String,
|
||||||
|
session: String,
|
||||||
|
order: u64,
|
||||||
|
}
|
||||||
|
|
||||||
|
fn resolve(
|
||||||
|
tx: &Connection,
|
||||||
|
host: &JournalHostContext,
|
||||||
|
source: &SourceEvidenceRef,
|
||||||
|
) -> JournalResult<Result<Resolved, &'static str>> {
|
||||||
|
let Some(evidence) = source.evidence_id.as_deref() else {
|
||||||
|
return Ok(Err("insufficient_source"));
|
||||||
|
};
|
||||||
|
if !evidence.starts_with('E')
|
||||||
|
|| evidence.len() < 2
|
||||||
|
|| evidence.len() > 129
|
||||||
|
|| !evidence[1..]
|
||||||
|
.bytes()
|
||||||
|
.all(|c| c.is_ascii_alphanumeric() || c == b'-')
|
||||||
|
|| (source.session_id.is_none() && source.segment_id.is_none())
|
||||||
|
{
|
||||||
|
return Ok(Err("insufficient_source"));
|
||||||
|
}
|
||||||
|
// Segment identity may be omitted only when an immutable Session attribution disambiguates it.
|
||||||
|
// A candidate's primary legacy source range is not substituted for an individual anchor.
|
||||||
|
let segment = match source.segment_id.as_deref() {
|
||||||
|
Some(raw) => match uuid::Uuid::parse_str(raw) {
|
||||||
|
Ok(id) => Some(id),
|
||||||
|
Err(_) => return Ok(Err("insufficient_source")),
|
||||||
|
},
|
||||||
|
None => None,
|
||||||
|
};
|
||||||
|
let mut stmt = tx.prepare(
|
||||||
|
"SELECT j.id,j.source_session_id,s.source_order,s.pointer_json FROM journal_sources s
|
||||||
|
JOIN journals j ON j.id=s.journal_id
|
||||||
|
JOIN subject_session_attributions a ON a.subject_id=j.subject_id AND a.session_id=j.source_session_id
|
||||||
|
WHERE j.subject_id=?1 AND s.entry_ref=?2
|
||||||
|
AND (?3 IS NULL OR j.source_session_id=?3)
|
||||||
|
AND (?4 IS NULL OR json_extract(s.pointer_json,'$.segment_id')=?4)
|
||||||
|
LIMIT 2",
|
||||||
|
)?;
|
||||||
|
let found = stmt
|
||||||
|
.query_map(
|
||||||
|
params![
|
||||||
|
host.subject_id,
|
||||||
|
evidence,
|
||||||
|
source.session_id,
|
||||||
|
segment.map(|s| s.to_string())
|
||||||
|
],
|
||||||
|
|r| {
|
||||||
|
Ok((
|
||||||
|
r.get::<_, String>(0)?,
|
||||||
|
r.get::<_, String>(1)?,
|
||||||
|
r.get::<_, u64>(2)?,
|
||||||
|
r.get::<_, String>(3)?,
|
||||||
|
))
|
||||||
|
},
|
||||||
|
)?
|
||||||
|
.collect::<Result<Vec<_>, _>>()?;
|
||||||
|
if found.is_empty() {
|
||||||
|
return Ok(Err("source_not_admitted"));
|
||||||
|
}
|
||||||
|
if found.len() != 1 {
|
||||||
|
return Ok(Err("ambiguous_source"));
|
||||||
|
}
|
||||||
|
let (journal, session, order, pointer) = &found[0];
|
||||||
|
let pointer: JournalSourcePointer = serde_json::from_str(pointer)?;
|
||||||
|
if pointer.entry_ref != evidence || segment.is_some_and(|s| s != pointer.segment_id) {
|
||||||
|
return Ok(Err("ambiguous_source"));
|
||||||
|
}
|
||||||
|
// A fixed endpoint covers (previous_fixed_end, source_end]. The Session start is the first
|
||||||
|
// interval's lower boundary. Processing checkpoints and open entries cannot supply an end.
|
||||||
|
let mut stmt = tx.prepare(
|
||||||
|
"SELECT e.id FROM journal_entries e WHERE e.subject_id=?1 AND e.journal_id=?2
|
||||||
|
AND e.state='fixed' AND e.source_order>=?3
|
||||||
|
AND NOT EXISTS(SELECT 1 FROM journal_entries p WHERE p.journal_id=e.journal_id
|
||||||
|
AND p.position<e.position AND p.state='fixed' AND p.source_order>=?3)
|
||||||
|
ORDER BY e.position LIMIT 2",
|
||||||
|
)?;
|
||||||
|
let entries = stmt
|
||||||
|
.query_map(params![host.subject_id, journal, order], |r| {
|
||||||
|
r.get::<_, String>(0)
|
||||||
|
})?
|
||||||
|
.collect::<Result<Vec<_>, _>>()?;
|
||||||
|
match entries.as_slice() {
|
||||||
|
[entry] => Ok(Ok(Resolved {
|
||||||
|
journal: journal.clone(),
|
||||||
|
entry: entry.clone(),
|
||||||
|
session: session.clone(),
|
||||||
|
order: *order,
|
||||||
|
})),
|
||||||
|
[] => Ok(Err("journal_not_fixed")),
|
||||||
|
_ => Ok(Err("ambiguous_source")),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn reconcile(
|
||||||
|
tx: &Connection,
|
||||||
|
host: &JournalHostContext,
|
||||||
|
record: &SubjectStagingRecord,
|
||||||
|
indices: std::ops::Range<usize>,
|
||||||
|
) -> JournalResult<()> {
|
||||||
|
for index in indices {
|
||||||
|
let source = &record.source_refs[index];
|
||||||
|
if saved(tx, host, &record.id, index)?.is_some_and(|s| s.reason == "linked") {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
let (reason, resolved) = match resolve(tx, host, source)? {
|
||||||
|
Ok(link) => ("linked", Some(link)),
|
||||||
|
Err(reason) => (reason, None),
|
||||||
|
};
|
||||||
|
tx.execute(
|
||||||
|
"INSERT INTO memory_journal_links(subject_id,candidate_id,source_index,reason,
|
||||||
|
journal_id,entry_id,source_session_id,source_order) VALUES(?1,?2,?3,?4,?5,?6,?7,?8)
|
||||||
|
ON CONFLICT(subject_id,candidate_id,source_index) DO UPDATE SET
|
||||||
|
reason=excluded.reason,journal_id=excluded.journal_id,entry_id=excluded.entry_id,
|
||||||
|
source_session_id=excluded.source_session_id,source_order=excluded.source_order",
|
||||||
|
params![
|
||||||
|
host.subject_id,
|
||||||
|
record.id,
|
||||||
|
index,
|
||||||
|
reason,
|
||||||
|
resolved.as_ref().map(|r| r.journal.as_str()),
|
||||||
|
resolved.as_ref().map(|r| r.entry.as_str()),
|
||||||
|
resolved.as_ref().map(|r| r.session.as_str()),
|
||||||
|
resolved.as_ref().map(|r| r.order)
|
||||||
|
],
|
||||||
|
)?;
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
impl SubjektivStore {
|
||||||
|
/// Scoped Memory provenance bridge. Reconcile is additive and idempotent, so a lost response
|
||||||
|
/// needs no operation ledger: retry the same selector and reread saved edges. Reads never reconcile.
|
||||||
|
pub fn execute_memory_journal(
|
||||||
|
&self,
|
||||||
|
host: &JournalHostContext,
|
||||||
|
request: MemoryJournalRequest,
|
||||||
|
) -> JournalResult<MemoryJournalResponse> {
|
||||||
|
let response = match request {
|
||||||
|
MemoryJournalRequest::Reconcile { selector } => {
|
||||||
|
self.memory_journal_links(host, selector, true, 0, MAX_STAGING_ANCHORS)?
|
||||||
|
}
|
||||||
|
MemoryJournalRequest::Links { selector } => {
|
||||||
|
self.memory_journal_links(host, selector, false, 0, MAX_STAGING_ANCHORS)?
|
||||||
|
}
|
||||||
|
MemoryJournalRequest::ReconcilePage {
|
||||||
|
selector,
|
||||||
|
source_offset,
|
||||||
|
limit,
|
||||||
|
} => self.memory_journal_links(host, selector, true, source_offset, limit)?,
|
||||||
|
MemoryJournalRequest::LinksPage {
|
||||||
|
selector,
|
||||||
|
source_offset,
|
||||||
|
limit,
|
||||||
|
} => self.memory_journal_links(host, selector, false, source_offset, limit)?,
|
||||||
|
MemoryJournalRequest::Read {
|
||||||
|
selector,
|
||||||
|
source_index,
|
||||||
|
journal_id,
|
||||||
|
entry_id,
|
||||||
|
expected_token,
|
||||||
|
byte_offset,
|
||||||
|
max_bytes,
|
||||||
|
} => {
|
||||||
|
identifier(&journal_id)?;
|
||||||
|
identifier(&entry_id)?;
|
||||||
|
if !(4..=super::MAX_JOURNAL_READ_BYTES).contains(&max_bytes) {
|
||||||
|
return Err(invalid("text page must be 4..16384 bytes"));
|
||||||
|
}
|
||||||
|
if let Some(token) = expected_token.as_deref() {
|
||||||
|
identifier(token)?;
|
||||||
|
}
|
||||||
|
let mut source = self.database.try_with_connection(|tx| {
|
||||||
|
let record = candidate(self, tx, host, &selector)?;
|
||||||
|
if source_index >= record.source_refs.len() {
|
||||||
|
return Err(invalid("source index outside candidate anchors"));
|
||||||
|
}
|
||||||
|
let link = project(tx, host, &selector.candidate_id, source_index)?;
|
||||||
|
match &link.connection {
|
||||||
|
SourceConnection::Linked {
|
||||||
|
journal_id: saved_journal,
|
||||||
|
entry_id: saved_entry,
|
||||||
|
..
|
||||||
|
} if saved_journal == &journal_id && saved_entry == &entry_id => Ok(link),
|
||||||
|
_ => Err(JournalError::PermissionDenied(
|
||||||
|
"read requires the exact saved Journal link".into(),
|
||||||
|
)),
|
||||||
|
}
|
||||||
|
})?;
|
||||||
|
let page = match &source.connection {
|
||||||
|
SourceConnection::Linked {
|
||||||
|
target: JournalTargetAvailability::Available,
|
||||||
|
..
|
||||||
|
} => {
|
||||||
|
match self.read_journal_text(
|
||||||
|
host,
|
||||||
|
&journal_id,
|
||||||
|
Some(&entry_id),
|
||||||
|
JournalTextField::Body,
|
||||||
|
expected_token.as_deref(),
|
||||||
|
byte_offset,
|
||||||
|
max_bytes,
|
||||||
|
) {
|
||||||
|
Ok(page) => Some(page),
|
||||||
|
// Erasure between the saved-edge check and shared text read must not
|
||||||
|
// fall back, expose cached text, or pretend the link never existed.
|
||||||
|
Err(JournalError::NotFound(_)) => {
|
||||||
|
source = self.database.try_with_connection(|tx| {
|
||||||
|
project(tx, host, &selector.candidate_id, source_index)
|
||||||
|
})?;
|
||||||
|
None
|
||||||
|
}
|
||||||
|
Err(error) => return Err(error),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
_ => None,
|
||||||
|
};
|
||||||
|
MemoryJournalResponse::Read(MemoryJournalRead { source, page })
|
||||||
|
}
|
||||||
|
MemoryJournalRequest::Related {
|
||||||
|
journal_id,
|
||||||
|
entry_id,
|
||||||
|
after,
|
||||||
|
limit,
|
||||||
|
} => self.related_memories(host, &journal_id, &entry_id, after.as_ref(), limit)?,
|
||||||
|
};
|
||||||
|
if serde_json::to_string_pretty(&response)?.len() > MAX_JOURNAL_RESPONSE_BYTES {
|
||||||
|
return Err(invalid("Memory Journal response exceeds byte budget"));
|
||||||
|
}
|
||||||
|
Ok(response)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn memory_journal_links(
|
||||||
|
&self,
|
||||||
|
host: &JournalHostContext,
|
||||||
|
selector: MemoryJournalSelector,
|
||||||
|
do_reconcile: bool,
|
||||||
|
source_offset: usize,
|
||||||
|
limit: usize,
|
||||||
|
) -> JournalResult<MemoryJournalResponse> {
|
||||||
|
if !(1..=MAX_STAGING_ANCHORS).contains(&limit) {
|
||||||
|
return Err(invalid("source page exceeds staging anchor bound"));
|
||||||
|
}
|
||||||
|
self.database.try_transaction(|tx| {
|
||||||
|
let record = candidate(self, tx, host, &selector)?;
|
||||||
|
let total_sources = record.source_refs.len();
|
||||||
|
if source_offset > total_sources {
|
||||||
|
return Err(invalid("source offset outside immutable candidate anchors"));
|
||||||
|
}
|
||||||
|
let end = source_offset.saturating_add(limit).min(total_sources);
|
||||||
|
if do_reconcile {
|
||||||
|
reconcile(tx, host, &record, source_offset..end)?;
|
||||||
|
}
|
||||||
|
let sources = (source_offset..end)
|
||||||
|
.map(|index| project(tx, host, &selector.candidate_id, index))
|
||||||
|
.collect::<JournalResult<Vec<_>>>()?;
|
||||||
|
Ok(MemoryJournalResponse::Links(MemoryJournalLinks {
|
||||||
|
selector,
|
||||||
|
sources,
|
||||||
|
total_sources,
|
||||||
|
next_source_index: (end < total_sources).then_some(end),
|
||||||
|
}))
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
fn related_memories(
|
||||||
|
&self,
|
||||||
|
host: &JournalHostContext,
|
||||||
|
journal_id: &str,
|
||||||
|
entry_id: &str,
|
||||||
|
after: Option<&RelatedMemoryCursor>,
|
||||||
|
limit: usize,
|
||||||
|
) -> JournalResult<MemoryJournalResponse> {
|
||||||
|
identifier(journal_id)?;
|
||||||
|
identifier(entry_id)?;
|
||||||
|
if !(1..=MAX_JOURNAL_PAGE).contains(&limit) {
|
||||||
|
return Err(invalid("related page must be 1..32"));
|
||||||
|
}
|
||||||
|
if let Some(cursor) = after {
|
||||||
|
identifier(&cursor.memory_id)?;
|
||||||
|
identifier(&cursor.change_id)?;
|
||||||
|
identifier(&cursor.candidate_id)?;
|
||||||
|
if i64::try_from(cursor.source_index).is_err() {
|
||||||
|
return Err(invalid("invalid related source index"));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
self.database.try_with_connection(|tx| {
|
||||||
|
super::store::check_host(self,tx,host,false)?;
|
||||||
|
// Saved target identity authorizes reverse lookup after erasure as well as before it.
|
||||||
|
let known:bool=tx.query_row(
|
||||||
|
"SELECT EXISTS(SELECT 1 FROM journal_entries WHERE subject_id=?1 AND journal_id=?2 AND id=?3)
|
||||||
|
OR EXISTS(SELECT 1 FROM memory_journal_links WHERE subject_id=?1 AND journal_id=?2 AND entry_id=?3)",
|
||||||
|
params![host.subject_id,journal_id,entry_id],|r|r.get(0))?;
|
||||||
|
if !known { return Err(JournalError::NotFound("Journal entry".into())); }
|
||||||
|
let mut stmt=tx.prepare(
|
||||||
|
"SELECT m.memory_id,m.change_id,l.candidate_id,l.source_index,
|
||||||
|
EXISTS(SELECT 1 FROM journal_corrections c WHERE c.subject_id=l.subject_id AND c.target_id=l.entry_id)
|
||||||
|
FROM memory_journal_links l JOIN memory_change_candidates m
|
||||||
|
ON m.subject_id=l.subject_id AND m.candidate_id=l.candidate_id
|
||||||
|
WHERE l.subject_id=?1 AND l.reason='linked'
|
||||||
|
AND ((l.journal_id=?2 AND l.entry_id=?3)
|
||||||
|
OR EXISTS(SELECT 1 FROM journal_corrections c WHERE c.subject_id=?1 AND c.entry_id=?3 AND c.target_id=l.entry_id))
|
||||||
|
AND (?4 IS NULL OR (m.memory_id,m.change_id,l.candidate_id,l.source_index)>(?4,?5,?6,?7))
|
||||||
|
ORDER BY m.memory_id,m.change_id,l.candidate_id,l.source_index LIMIT ?8")?;
|
||||||
|
let mut items=stmt.query_map(params![host.subject_id,journal_id,entry_id,
|
||||||
|
after.map(|c|c.memory_id.as_str()),after.map(|c|c.change_id.as_str()),
|
||||||
|
after.map(|c|c.candidate_id.as_str()),after.map(|c|c.source_index),limit+1],
|
||||||
|
|r|Ok(RelatedMemory {selector:MemoryJournalSelector {memory_id:r.get(0)?,change_id:r.get(1)?,candidate_id:r.get(2)?},
|
||||||
|
source_index:r.get(3)?,reconsideration:r.get(4)?}))?.collect::<Result<Vec<_>,_>>()?;
|
||||||
|
let more=items.len()>limit;
|
||||||
|
items.truncate(limit);
|
||||||
|
let next=if more {items.last().map(|last|RelatedMemoryCursor {
|
||||||
|
memory_id:last.selector.memory_id.clone(),change_id:last.selector.change_id.clone(),
|
||||||
|
candidate_id:last.selector.candidate_id.clone(),source_index:last.source_index })} else {None};
|
||||||
|
Ok(MemoryJournalResponse::Related(RelatedMemories {items,next}))
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,33 @@
|
|||||||
|
-- References outlive explicit Journal erasure. Do not add target-row foreign keys:
|
||||||
|
-- lifecycle erasure deletes Journal content but retains its Session tombstone.
|
||||||
|
CREATE TABLE memory_journal_links (
|
||||||
|
subject_id TEXT NOT NULL,
|
||||||
|
candidate_id TEXT NOT NULL,
|
||||||
|
source_index INTEGER NOT NULL CHECK(source_index >= 0),
|
||||||
|
reason TEXT NOT NULL CHECK(reason IN (
|
||||||
|
'insufficient_source', 'source_not_admitted', 'ambiguous_source',
|
||||||
|
'journal_not_fixed', 'linked'
|
||||||
|
)),
|
||||||
|
journal_id TEXT,
|
||||||
|
entry_id TEXT,
|
||||||
|
source_session_id TEXT,
|
||||||
|
source_order INTEGER,
|
||||||
|
PRIMARY KEY(subject_id, candidate_id, source_index),
|
||||||
|
FOREIGN KEY(subject_id, candidate_id)
|
||||||
|
REFERENCES staging_records(subject_id, candidate_id) ON DELETE RESTRICT,
|
||||||
|
CHECK((reason = 'linked' AND journal_id IS NOT NULL AND entry_id IS NOT NULL
|
||||||
|
AND source_session_id IS NOT NULL AND source_order IS NOT NULL AND source_order >= 0)
|
||||||
|
OR (reason <> 'linked' AND journal_id IS NULL AND entry_id IS NULL
|
||||||
|
AND source_session_id IS NULL AND source_order IS NULL))
|
||||||
|
);
|
||||||
|
CREATE INDEX memory_journal_links_reverse
|
||||||
|
ON memory_journal_links(subject_id, journal_id, entry_id, candidate_id, source_index);
|
||||||
|
CREATE TRIGGER memory_journal_links_identity BEFORE UPDATE ON memory_journal_links
|
||||||
|
WHEN OLD.subject_id <> NEW.subject_id OR OLD.candidate_id <> NEW.candidate_id
|
||||||
|
OR OLD.source_index <> NEW.source_index
|
||||||
|
BEGIN SELECT RAISE(ABORT, 'Journal source reference identity is immutable'); END;
|
||||||
|
CREATE TRIGGER memory_journal_links_connected_immutable BEFORE UPDATE ON memory_journal_links
|
||||||
|
WHEN OLD.reason = 'linked'
|
||||||
|
BEGIN SELECT RAISE(ABORT, 'connected Journal references are immutable'); END;
|
||||||
|
CREATE TRIGGER memory_journal_links_retained BEFORE DELETE ON memory_journal_links
|
||||||
|
BEGIN SELECT RAISE(ABORT, 'Journal references are retained'); END;
|
||||||
File diff suppressed because it is too large
Load Diff
@@ -38,9 +38,11 @@
|
|||||||
//! traversing already reflected pages. Receipt/entry keysets remain append-stable.
|
//! traversing already reflected pages. Receipt/entry keysets remain append-stable.
|
||||||
|
|
||||||
mod lifecycle;
|
mod lifecycle;
|
||||||
|
pub mod memory_links;
|
||||||
pub(crate) use lifecycle::migrate as migrate_lifecycle;
|
pub(crate) use lifecycle::migrate as migrate_lifecycle;
|
||||||
pub mod recording;
|
pub mod recording;
|
||||||
pub mod service;
|
pub mod service;
|
||||||
|
mod source_status;
|
||||||
mod store;
|
mod store;
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod tests;
|
mod tests;
|
||||||
|
|||||||
@@ -0,0 +1,79 @@
|
|||||||
|
//! Optional Host metadata observations for original material, independent of saved Journal reading.
|
||||||
|
//!
|
||||||
|
//! Presence checks must not fetch original history or restore a Worker. A failed/unauthorized
|
||||||
|
//! metadata lookup is unknown, not missing, and cannot prevent a saved Journal body from being read.
|
||||||
|
|
||||||
|
use super::memory_links::{
|
||||||
|
MemoryJournalRequest, MemoryJournalResponse, OriginalSourceAvailability,
|
||||||
|
};
|
||||||
|
use super::{JournalHostContext, JournalResult, SubjektivStore};
|
||||||
|
|
||||||
|
impl SubjektivStore {
|
||||||
|
/// Executes the saved-reference operation first, then annotates original-source absence using
|
||||||
|
/// Host-owned metadata only. `Some(false)` is confirmed absence; `None` is an unavailable lookup.
|
||||||
|
/// Presence alone cannot attest that a particular historical entry is still readable.
|
||||||
|
pub fn execute_memory_journal_with_source_presence(
|
||||||
|
&self,
|
||||||
|
host: &JournalHostContext,
|
||||||
|
request: MemoryJournalRequest,
|
||||||
|
mut presence: impl FnMut(&str) -> Option<bool>,
|
||||||
|
) -> JournalResult<MemoryJournalResponse> {
|
||||||
|
let selector = match &request {
|
||||||
|
MemoryJournalRequest::Reconcile { selector }
|
||||||
|
| MemoryJournalRequest::Links { selector }
|
||||||
|
| MemoryJournalRequest::ReconcilePage { selector, .. }
|
||||||
|
| MemoryJournalRequest::LinksPage { selector, .. }
|
||||||
|
| MemoryJournalRequest::Read { selector, .. } => Some(selector.clone()),
|
||||||
|
MemoryJournalRequest::Related { .. } => None,
|
||||||
|
};
|
||||||
|
let mut response = self.execute_memory_journal(host, request)?;
|
||||||
|
let Some(selector) = selector else {
|
||||||
|
return Ok(response);
|
||||||
|
};
|
||||||
|
// Membership and scoped reader authority were checked by the operation. Candidate JSON is
|
||||||
|
// immutable; never infer source ownership from its presence without checking attribution.
|
||||||
|
let candidate = self.staging_candidate(&host.subject_id, &selector.candidate_id)?;
|
||||||
|
let mut observations = std::collections::HashMap::new();
|
||||||
|
let mut annotate =
|
||||||
|
|link: &mut super::memory_links::MemoryJournalSourceLink| -> JournalResult<()> {
|
||||||
|
let source = candidate
|
||||||
|
.as_ref()
|
||||||
|
.and_then(|c| c.source_refs.get(link.source_index));
|
||||||
|
let session = source.and_then(|s| s.session_id.as_deref());
|
||||||
|
let observed = if let Some(session) = session {
|
||||||
|
if let Some(value) = observations.get(session) {
|
||||||
|
*value
|
||||||
|
} else {
|
||||||
|
let owned = self
|
||||||
|
.session_attribution(session)?
|
||||||
|
.is_some_and(|a| a.subject_id == host.subject_id);
|
||||||
|
let value = if owned { presence(session) } else { None };
|
||||||
|
observations.insert(session.to_owned(), value);
|
||||||
|
value
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
None
|
||||||
|
};
|
||||||
|
link.original_source = match observed {
|
||||||
|
Some(false) => OriginalSourceAvailability::Missing,
|
||||||
|
// Preserve explicit saved missing-byte attestations. Directory/registry presence
|
||||||
|
// does not prove the referenced entry survived rewind or storage corruption.
|
||||||
|
_ if link.original_source == OriginalSourceAvailability::Missing => {
|
||||||
|
OriginalSourceAvailability::Missing
|
||||||
|
}
|
||||||
|
_ => OriginalSourceAvailability::Unknown,
|
||||||
|
};
|
||||||
|
Ok(())
|
||||||
|
};
|
||||||
|
match &mut response {
|
||||||
|
MemoryJournalResponse::Links(page) => {
|
||||||
|
for source in &mut page.sources {
|
||||||
|
annotate(source)?;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
MemoryJournalResponse::Read(read) => annotate(&mut read.source)?,
|
||||||
|
MemoryJournalResponse::Related(_) => {}
|
||||||
|
}
|
||||||
|
Ok(response)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -613,6 +613,11 @@ static MIGRATIONS: &[FeatureMigration] = &[
|
|||||||
"fence erased journal Sessions and permit explicit content erasure",
|
"fence erased journal Sessions and permit explicit content erasure",
|
||||||
journal::migrate_lifecycle,
|
journal::migrate_lifecycle,
|
||||||
),
|
),
|
||||||
|
FeatureMigration::new(
|
||||||
|
10,
|
||||||
|
"add immutable candidate Journal context links",
|
||||||
|
journal::memory_links::migrate,
|
||||||
|
),
|
||||||
];
|
];
|
||||||
|
|
||||||
pub const REGISTRATION: FeatureRegistration =
|
pub const REGISTRATION: FeatureRegistration =
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ pub use crate::feature::builtin::memory_surface_lifecycle::SubjektivSurfaceLifec
|
|||||||
pub use crate::feature::builtin::journal::{
|
pub use crate::feature::builtin::journal::{
|
||||||
JournalJobFinalizer, JournalJobHost, JournalJobToolsFeature, JournalRecordingFeature,
|
JournalJobFinalizer, JournalJobHost, JournalJobToolsFeature, JournalRecordingFeature,
|
||||||
};
|
};
|
||||||
|
pub use subjektiv::journal::memory_links::{MemoryJournalRequest, MemoryJournalResponse};
|
||||||
pub use subjektiv::journal::transport;
|
pub use subjektiv::journal::transport;
|
||||||
use transport::{JournalRecordingReason, JournalRecordingRequest, JournalRecordingResponse};
|
use transport::{JournalRecordingReason, JournalRecordingRequest, JournalRecordingResponse};
|
||||||
|
|
||||||
@@ -79,6 +80,17 @@ pub trait SubjektivHost: Send + Sync + 'static {
|
|||||||
"Journal recording not granted".into(),
|
"Journal recording not granted".into(),
|
||||||
))
|
))
|
||||||
}
|
}
|
||||||
|
/// Additive provenance links and direct Journal reads, separate from recording/Memory mutation.
|
||||||
|
/// Restricted Job Hosts inherit denial rather than the main Subject reader capability.
|
||||||
|
fn memory_journal(
|
||||||
|
&self,
|
||||||
|
_context: &SubjektivHostContext,
|
||||||
|
_request: MemoryJournalRequest,
|
||||||
|
) -> Result<MemoryJournalResponse, SubjektivHostError> {
|
||||||
|
Err(SubjektivHostError::Unavailable(
|
||||||
|
"Memory Journal references not granted".into(),
|
||||||
|
))
|
||||||
|
}
|
||||||
/// Separate explicit grant for decision/surface execution; Profile policy
|
/// Separate explicit grant for decision/surface execution; Profile policy
|
||||||
/// alone must never authorize consolidation tools.
|
/// alone must never authorize consolidation tools.
|
||||||
fn consolidation_granted(&self) -> bool {
|
fn consolidation_granted(&self) -> bool {
|
||||||
@@ -207,6 +219,22 @@ fn decode<T: serde::de::DeserializeOwned>(
|
|||||||
}
|
}
|
||||||
|
|
||||||
impl SubjektivHost for BackendSubjektivHost {
|
impl SubjektivHost for BackendSubjektivHost {
|
||||||
|
fn memory_journal(
|
||||||
|
&self,
|
||||||
|
_context: &SubjektivHostContext,
|
||||||
|
request: MemoryJournalRequest,
|
||||||
|
) -> Result<MemoryJournalResponse, SubjektivHostError> {
|
||||||
|
decode(
|
||||||
|
self.client
|
||||||
|
.execute(WorkspaceRequest::json(
|
||||||
|
WorkspaceRequestMethod::Post,
|
||||||
|
format!("/api/w/{}/subjektiv/journal/memory", self.workspace_id),
|
||||||
|
serde_json::to_string(&request)
|
||||||
|
.map_err(|error| SubjektivHostError::Unavailable(error.to_string()))?,
|
||||||
|
))
|
||||||
|
.map_err(|error| SubjektivHostError::Unavailable(error.to_string()))?,
|
||||||
|
)
|
||||||
|
}
|
||||||
fn journal_recording_granted(&self) -> bool {
|
fn journal_recording_granted(&self) -> bool {
|
||||||
// This adapter exists only for explicitly enabled, authenticated Host connections.
|
// This adapter exists only for explicitly enabled, authenticated Host connections.
|
||||||
// The Backend still verifies the main Worker/Subject binding on every request.
|
// The Backend still verifies the main Worker/Subject binding on every request.
|
||||||
@@ -438,6 +466,12 @@ impl SubjektivHostConnection {
|
|||||||
}
|
}
|
||||||
Ok(Self { host, context })
|
Ok(Self { host, context })
|
||||||
}
|
}
|
||||||
|
pub fn memory_journal(
|
||||||
|
&self,
|
||||||
|
request: MemoryJournalRequest,
|
||||||
|
) -> Result<MemoryJournalResponse, SubjektivHostError> {
|
||||||
|
self.host.memory_journal(&self.context, request)
|
||||||
|
}
|
||||||
pub fn memory(
|
pub fn memory(
|
||||||
&self,
|
&self,
|
||||||
operation: SubjektivMemoryBackendOperation,
|
operation: SubjektivMemoryBackendOperation,
|
||||||
|
|||||||
@@ -28,6 +28,7 @@ use subjektiv::api::*;
|
|||||||
mod backend_job_profile;
|
mod backend_job_profile;
|
||||||
mod subjektiv_job;
|
mod subjektiv_job;
|
||||||
mod subjektiv_journal;
|
mod subjektiv_journal;
|
||||||
|
mod subjektiv_memory_journal;
|
||||||
mod worker_operations;
|
mod worker_operations;
|
||||||
mod worker_protocol;
|
mod worker_protocol;
|
||||||
pub(crate) use worker_operations::{
|
pub(crate) use worker_operations::{
|
||||||
@@ -13123,6 +13124,7 @@ fn build_inner_router(api: WorkspaceApi) -> Router {
|
|||||||
post(scoped_submit_backend_job_result),
|
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/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", post(subjektiv_journal::job_route))
|
||||||
.route("/api/w/{workspace_id}/subjektiv/journal/job/finish", post(subjektiv_journal::finish_route))
|
.route("/api/w/{workspace_id}/subjektiv/journal/job/finish", post(subjektiv_journal::finish_route))
|
||||||
.fallback(get(static_or_spa_fallback))
|
.fallback(get(static_or_spa_fallback))
|
||||||
@@ -36928,6 +36930,7 @@ mod tests {
|
|||||||
mod subject_spawn_tests;
|
mod subject_spawn_tests;
|
||||||
mod subjektiv_jobs_tests;
|
mod subjektiv_jobs_tests;
|
||||||
mod subjektiv_journal_tests;
|
mod subjektiv_journal_tests;
|
||||||
|
mod subjektiv_memory_journal_tests;
|
||||||
mod ticket_evidence_tests;
|
mod ticket_evidence_tests;
|
||||||
mod ticket_worker_claim_tests;
|
mod ticket_worker_claim_tests;
|
||||||
mod ticket_worker_launch_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
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,4 +1,13 @@
|
|||||||
[
|
[
|
||||||
|
{
|
||||||
|
"path": "crates/subjektiv/src/journal/memory_links/tests.rs",
|
||||||
|
"kind": "saved_data",
|
||||||
|
"reason": "最古対応v1 schemaの保存済Subject JSONとSQLをそのまま作成し、最新migrationが元出典を保持する検証に必要な固定綴り。現行writerや状態判断には使わない。",
|
||||||
|
"lines": [
|
||||||
|
{ "sha256": "e30d8ddbb500aa10a4a23c52a1f34abbf1b6a8f3692501bb3b74084b63db911c", "count": 1 },
|
||||||
|
{ "sha256": "ed4d9edc0c05bf5ab788da52e8b1e44f4543c57d13b9a002144dbdc4430b5837", "count": 1 }
|
||||||
|
]
|
||||||
|
},
|
||||||
{
|
{
|
||||||
"path": "crates/manifest/src/lib.rs",
|
"path": "crates/manifest/src/lib.rs",
|
||||||
"kind": "saved_data",
|
"kind": "saved_data",
|
||||||
|
|||||||
Reference in New Issue
Block a user