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:
@@ -329,6 +329,14 @@ impl FsStore {
|
||||
}
|
||||
|
||||
impl Store for FsStore {
|
||||
fn retain_session_before_publication(
|
||||
&self,
|
||||
session_id: SessionId,
|
||||
pin: crate::retention::SessionRetentionPin,
|
||||
) -> Result<(), StoreError> {
|
||||
self.retention().retain_before_publication(session_id, pin)
|
||||
}
|
||||
|
||||
fn append(
|
||||
&self,
|
||||
session_id: SessionId,
|
||||
|
||||
@@ -109,6 +109,23 @@ impl SessionRetentionStore {
|
||||
Ok(guard)
|
||||
}
|
||||
|
||||
/// Protect a Host-owned Session before its first source publication. This is
|
||||
/// retention intent, not proof that any original material exists. The owner must
|
||||
/// establish it before accepting input and use the same control store for writes
|
||||
/// and deletion. Source-serving APIs must still verify actual material separately.
|
||||
pub fn retain_before_publication(
|
||||
&self,
|
||||
session: SessionId,
|
||||
pin: SessionRetentionPin,
|
||||
) -> Result<(), StoreError> {
|
||||
let _guard = self.lock_live(session)?;
|
||||
let mut state = self.read(session)?;
|
||||
if state.pins.insert(pin) {
|
||||
self.save(session, &state)?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Call `source_exists` under the same lock used by deletion. Replaying the
|
||||
/// same pin is idempotent; missing source never becomes a successful lease.
|
||||
pub fn pin(
|
||||
@@ -184,6 +201,43 @@ pub(crate) fn fs_retention(root: &Path) -> SessionRetentionStore {
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn publication_intent_survives_reopen_without_attesting_missing_originals() {
|
||||
let temp = tempfile::tempdir().unwrap();
|
||||
let source = temp.path().join("source");
|
||||
let controls = temp.path().join("controls");
|
||||
let session = crate::new_session_id();
|
||||
let pin = SessionRetentionPin(uuid::Uuid::now_v7());
|
||||
let retention = SessionRetentionStore::new(&controls);
|
||||
retention.retain_before_publication(session, pin).unwrap();
|
||||
let reopened = SessionRetentionStore::new(&controls);
|
||||
assert_eq!(reopened.pins(session).unwrap(), [pin]);
|
||||
// An early owner is not a successful source-availability receipt.
|
||||
assert!(
|
||||
matches!(reopened.pin(session, pin, || Ok(source.is_file())), Err(StoreError::SessionSourceMissing(id)) if id == session)
|
||||
);
|
||||
{
|
||||
let _publication = reopened.lock_live(session).unwrap();
|
||||
fs::write(&source, "committed before notification").unwrap();
|
||||
}
|
||||
assert!(
|
||||
matches!(reopened.delete(session, SessionDeletionIntent::Ordinary, || { fs::remove_file(&source)?; Ok(()) }), Err(StoreError::SessionPinned(id)) if id == session)
|
||||
);
|
||||
assert_eq!(
|
||||
fs::read_to_string(&source).unwrap(),
|
||||
"committed before notification"
|
||||
);
|
||||
reopened
|
||||
.delete(session, SessionDeletionIntent::ErasePermanently, || {
|
||||
fs::remove_file(&source)?;
|
||||
Ok(())
|
||||
})
|
||||
.unwrap();
|
||||
assert!(
|
||||
matches!(retention.retain_before_publication(session, pin), Err(StoreError::SessionErased(id)) if id == session)
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn pin_survives_reopen_blocks_delete_and_releases_idempotently() {
|
||||
let temp = tempfile::tempdir().unwrap();
|
||||
|
||||
@@ -42,6 +42,9 @@ pub enum StoreError {
|
||||
#[error("session was permanently erased: {0}")]
|
||||
SessionErased(SessionId),
|
||||
|
||||
#[error("session source retention is unsupported by this store")]
|
||||
SessionRetentionUnsupported,
|
||||
|
||||
#[error("retained observation read limit exceeded")]
|
||||
ReadLimitExceeded,
|
||||
|
||||
@@ -89,6 +92,17 @@ pub enum StoreError {
|
||||
/// take `(SessionId, SegmentId)` so segments can be physically grouped
|
||||
/// per Session on disk (or per session_id in a DB).
|
||||
pub trait Store: Send + Sync {
|
||||
/// Establish durable retention intent before the Host accepts public input.
|
||||
/// This does not attest source availability; it must use the same control store
|
||||
/// as publishers and physical deletion. Unsupported stores fail closed.
|
||||
fn retain_session_before_publication(
|
||||
&self,
|
||||
_session_id: SessionId,
|
||||
_pin: crate::retention::SessionRetentionPin,
|
||||
) -> Result<(), StoreError> {
|
||||
Err(StoreError::SessionRetentionUnsupported)
|
||||
}
|
||||
|
||||
/// Append a single log entry to the segment log.
|
||||
///
|
||||
/// One committed line per successful call. Implementations must not expose
|
||||
|
||||
@@ -806,6 +806,15 @@ where
|
||||
S: crate::Store,
|
||||
W: Send + Sync,
|
||||
{
|
||||
fn retain_session_before_publication(
|
||||
&self,
|
||||
session_id: SessionId,
|
||||
pin: crate::retention::SessionRetentionPin,
|
||||
) -> Result<(), crate::StoreError> {
|
||||
self.session_store
|
||||
.retain_session_before_publication(session_id, pin)
|
||||
}
|
||||
|
||||
fn append(
|
||||
&self,
|
||||
session_id: SessionId,
|
||||
|
||||
@@ -716,6 +716,17 @@ impl WorkerSessionStore {
|
||||
}
|
||||
|
||||
impl Store for WorkerSessionStore {
|
||||
fn retain_session_before_publication(
|
||||
&self,
|
||||
session_id: SessionId,
|
||||
pin: crate::retention::SessionRetentionPin,
|
||||
) -> Result<(), StoreError> {
|
||||
self.retention
|
||||
.as_ref()
|
||||
.ok_or(StoreError::SessionRetentionUnsupported)?
|
||||
.retain_before_publication(session_id, pin)
|
||||
}
|
||||
|
||||
fn append(
|
||||
&self,
|
||||
session_id: SessionId,
|
||||
|
||||
Reference in New Issue
Block a user