feat: connect Memory provenance to durable Journal context

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