fix: fence Journal originals before publication and reconcile removal intent

Protect attributed originals before the first public commit; distinguish pending Session material from missing committed sources. Gate removal admission by the Journal Feature and resolve Subject ownership from durable singleton authority. Resume explicit erasure through signed delete transport and proof-bound purge plans without creating a Journal Job or archive.

Validation: changed-crate suites, root check, doc/fmt/diff/naming checks, Server/Runtime builds and worker_environment isolation suite passed. Public standalone scripted dialog connects Host, automatic Job, sequential tools and drain while dialog continues. Backend public embedded WorkerRemove tests connect removal forwarding, durable Jobs, retention, Runtime and SQLite; Session/attribution/profile fixture setup bypasses creation/auth routes. Runtime signed generated-client tests separately exercise request authentication.

Limits: real-model semantic acceptance condition 6 remains unevaluated without a new explicit model/credential/budget grant. Conditions 1/7/8/11 do not have complete product E2E coverage: worker_environment only verifies isolated startup/connection/cleanup. Compaction tests connect local hooks/reader/store rather than the full public dialog-to-Journal chain. Erasure racing a previously prepared Archive removal plan fails closed with conflict; automatic stale-plan replacement is not implemented.
This commit is contained in:
2026-10-16 03:59:45 +09:00
parent 9debf43645
commit 5bd49760e5
19 changed files with 1846 additions and 149 deletions
+118 -3
View File
@@ -7812,7 +7812,10 @@ where
});
}
match state {
SubjektivSessionAttributionState::Confirmed { .. } => return Ok(()),
SubjektivSessionAttributionState::Confirmed { subject_id, .. } => {
return self
.retain_journal_source_before_publication(session_id, subject_id);
}
SubjektivSessionAttributionState::OutcomeUnknown { .. }
| SubjektivSessionAttributionState::Failed { .. } => true,
SubjektivSessionAttributionState::LegacyUnknown { .. } => false,
@@ -7967,8 +7970,44 @@ where
}
self.persist_subjektiv_session_attribution(SubjektivSessionAttributionState::Confirmed {
session_id,
subject_id: output.subject_id,
})
subject_id: output.subject_id.clone(),
})?;
self.retain_journal_source_before_publication(session_id, &output.subject_id)
}
fn retain_journal_source_before_publication(
&self,
session_id: SessionId,
subject_id: &str,
) -> Result<(), WorkerError> {
let Some(connection) = self.subjektiv_host_connection().map_err(|error| {
WorkerError::SubjektivSessionAttribution {
state: "retention_unconfirmed",
message: error.to_string(),
}
})?
else {
return Ok(());
};
if !connection.host.journal_recording_granted() {
return Ok(());
}
let pin = session_store::retention::SessionRetentionPin(
match self.workspace_client().workspace_id() {
Some(workspace) => subjektiv::journal::source_retention_pin(
workspace,
subject_id,
&session_id.to_string(),
),
None => session_id,
},
);
// Confirmation can be replayed after response loss. Persist this intent before
// exposing the Worker to input, not after the first RunCommitted notification.
// It does not claim that an original Session has materialized yet.
self.store
.retain_session_before_publication(session_id, pin)?;
Ok(())
}
fn persist_subjektiv_session_attribution(
@@ -11116,6 +11155,82 @@ mod build_summary_prompt_tests {
assert!(worker.backend_job().is_none());
}
#[tokio::test]
async fn journal_attribution_protects_first_publication_before_any_admission_notification() {
for enabled in [false, true] {
let client = Arc::new(AttributionLifecycleClient::new([]));
let (mut worker, store, _temp) = attribution_lifecycle_worker(client.clone()).await;
if enabled {
worker.manifest.feature.subjektiv.profile.enabled = true;
worker
.manifest
.feature
.subjektiv
.bind_workspace_settings(manifest::WorkspaceMemorySettingsSnapshot {
workspace_id: "workspace-test".into(),
language: "English".into(),
})
.unwrap();
}
let session = worker.session_id();
assert!(store.session_store.list_sessions().unwrap().is_empty());
worker
.finalize_subjektiv_session_attribution(
true,
SubjektivSessionAttributionLifecycle::NewSession,
)
.unwrap();
let pin = session_store::retention::SessionRetentionPin(
subjektiv::journal::source_retention_pin(
"workspace-test",
"subject-test",
&session.to_string(),
),
);
let expected = if enabled { vec![pin] } else { vec![] };
assert_eq!(
store.session_store.retention().pins(session).unwrap(),
expected
);
// Reopen/replayed confirmation does not depend on a notification or add an owner.
worker
.finalize_subjektiv_session_attribution(
true,
SubjektivSessionAttributionLifecycle::RestoredSession,
)
.unwrap();
assert_eq!(client.requests.lock().unwrap().len(), 1);
assert_eq!(
store.session_store.retention().pins(session).unwrap(),
expected
);
worker.materialize_durable_session_head().await.unwrap();
let segment = worker.segment_id();
store
.append(
session,
segment,
&LogEntry::TurnEnd {
ts: 1,
turn_count: 1,
},
)
.unwrap();
// Simulate stop after the public commit but before RunCommitted/Host admission.
if enabled {
assert!(
matches!(store.session_store.delete_session(session), Err(StoreError::SessionPinned(id)) if id == session)
);
assert!(store.session_store.exists(session, segment).unwrap());
store
.session_store
.release_session_pin(session, pin)
.unwrap();
}
assert!(store.session_store.delete_session(session).unwrap());
}
}
#[tokio::test]
async fn subject_maintenance_job_binding_is_immutable_and_has_no_subject_body_attribution() {
for (subjektiv_consolidation, subjektiv_journal) in [(true, false), (false, true)] {