Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
25 commits
Select commit Hold shift + click to select a range
045a60c
fix(reborn): persist chat timeline timestamps
italic-jinxin Jul 7, 2026
59003fb
fix(reborn): harden chat timestamp persistence
italic-jinxin Jul 7, 2026
4d516b7
fix(reborn): address timestamp review feedback
italic-jinxin Jul 7, 2026
cade547
fix(reborn): map timestamp thread errors
italic-jinxin Jul 7, 2026
6f187d4
fix(reborn): derive timestamp violation errors
italic-jinxin Jul 7, 2026
95d14a0
Merge branch 'main' into issue-3535-chat-timestamps
italic-jinxin Jul 7, 2026
c6baafd
fix(stress): classify timestamp thread errors
italic-jinxin Jul 7, 2026
c51b3ca
fix(reborn): optimize timestamp update validation
italic-jinxin Jul 7, 2026
3cd0db8
fix(stress): separate timestamp failure bucket
italic-jinxin Jul 7, 2026
d17d491
Merge branch 'main' into issue-3535-chat-timestamps
italic-jinxin Jul 7, 2026
57709dd
Merge branch 'main' into issue-3535-chat-timestamps
italic-jinxin Jul 7, 2026
a22dfc7
Merge branch 'main' into issue-3535-chat-timestamps
italic-jinxin Jul 8, 2026
d358f67
test(reborn): avoid outbound target coverage stack overflow
italic-jinxin Jul 8, 2026
bff89fa
test(reborn): avoid skill activate coverage stack overflow
italic-jinxin Jul 8, 2026
7b83ef0
test(reborn): wait for trigger mutator gateway attempts
italic-jinxin Jul 8, 2026
b4c5a7a
test(reborn): align stack test helper signatures
italic-jinxin Jul 8, 2026
7b3ecf1
test(webui): assert reopened timeline timestamps
italic-jinxin Jul 8, 2026
05c628a
test(webui): validate reopened timeline timestamp format
italic-jinxin Jul 8, 2026
acac69a
test(reborn): run over-budget skill activation on larger stack
italic-jinxin Jul 8, 2026
b82f9b3
Merge branch 'main' into issue-3535-chat-timestamps
italic-jinxin Jul 8, 2026
76556cd
Merge branch 'main' into issue-3535-chat-timestamps
italic-jinxin Jul 8, 2026
3648f9a
Merge branch 'main' into issue-3535-chat-timestamps
italic-jinxin Jul 8, 2026
396095b
test(reborn): avoid project create coverage stack overflow
italic-jinxin Jul 8, 2026
be41d81
Merge branch 'main' into issue-3535-chat-timestamps
italic-jinxin Jul 8, 2026
3563126
Merge branch 'main' into issue-3535-chat-timestamps
italic-jinxin Jul 8, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions crates/ironclaw_loop_support/src/compaction_task.rs
Original file line number Diff line number Diff line change
Expand Up @@ -787,6 +787,8 @@ mod tests {
sequence: 1,
kind,
status: MessageStatus::Finalized,
created_at: None,
updated_at: None,
actor_id: None,
source_binding_id: None,
reply_target_binding_id: None,
Expand Down
2 changes: 2 additions & 0 deletions crates/ironclaw_product_workflow/src/inbound_turn.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1163,6 +1163,8 @@ mod tests {
sequence: 1,
kind: ironclaw_threads::MessageKind::User,
status: ironclaw_threads::MessageStatus::Submitted,
created_at: None,
updated_at: None,
actor_id: None,
source_binding_id: None,
reply_target_binding_id: None,
Expand Down
2 changes: 2 additions & 0 deletions crates/ironclaw_product_workflow/src/reborn_services.rs
Original file line number Diff line number Diff line change
Expand Up @@ -6607,6 +6607,7 @@ fn map_timeline_probe_error(error: SessionThreadError) -> RebornServicesError {
match error {
SessionThreadError::Serialization(_)
| SessionThreadError::Deserialization(_)
| SessionThreadError::InvalidMessageTimestamp { .. }
| SessionThreadError::Backend(_) => RebornServicesError::from_status_kind(
RebornServicesErrorCode::Unavailable,
RebornServicesErrorKind::TimelineUnavailable,
Expand Down Expand Up @@ -6647,6 +6648,7 @@ fn map_thread_error(error: SessionThreadError) -> RebornServicesError {
SessionThreadError::GeneratedThreadId(_)
| SessionThreadError::Serialization(_)
| SessionThreadError::Deserialization(_)
| SessionThreadError::InvalidMessageTimestamp { .. }
| SessionThreadError::Backend(_) => RebornServicesError::service_unavailable(true),
}
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -192,6 +192,8 @@ fn fake_thread_history(owner: &WebUiAuthenticatedCaller, thread_id: &str) -> Thr
sequence: 1,
kind: MessageKind::User,
status: MessageStatus::Submitted,
created_at: None,
updated_at: None,
actor_id: Some(owner.user_id.as_str().to_string()),
source_binding_id: Some("webui-src:test".to_string()),
reply_target_binding_id: Some("webui-reply:test".to_string()),
Expand Down
4 changes: 4 additions & 0 deletions crates/ironclaw_reborn/src/loop_exit_applier/tests/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -839,6 +839,8 @@ async fn thread_checkpoint_evidence_rejects_wrong_run_and_malformed_result_ref_r
sequence: next_sequence,
kind: MessageKind::ToolResultReference,
status: MessageStatus::Finalized,
created_at: None,
updated_at: None,
actor_id: None,
source_binding_id: None,
reply_target_binding_id: None,
Expand All @@ -856,6 +858,8 @@ async fn thread_checkpoint_evidence_rejects_wrong_run_and_malformed_result_ref_r
sequence: next_sequence + 1,
kind: MessageKind::ToolResultReference,
status: MessageStatus::Finalized,
created_at: None,
updated_at: None,
actor_id: None,
source_binding_id: None,
reply_target_binding_id: None,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -106,6 +106,8 @@ fn context_window_to_records(window: ContextWindow) -> Vec<ThreadMessageRecord>
sequence: message.sequence,
kind: message.kind,
status: MessageStatus::Finalized,
created_at: None,
updated_at: None,
actor_id: None,
source_binding_id: None,
reply_target_binding_id: None,
Expand Down Expand Up @@ -549,6 +551,8 @@ mod tests {
sequence: 0,
kind,
status,
created_at: None,
updated_at: None,
actor_id: None,
source_binding_id: None,
reply_target_binding_id: None,
Expand Down Expand Up @@ -697,6 +701,8 @@ mod tests {
sequence: 0,
kind: MessageKind::ToolResultReference,
status: MessageStatus::Finalized,
created_at: None,
updated_at: None,
actor_id: None,
source_binding_id: None,
reply_target_binding_id: None,
Expand Down
132 changes: 129 additions & 3 deletions crates/ironclaw_threads/src/contract.rs
Original file line number Diff line number Diff line change
Expand Up @@ -188,9 +188,11 @@ pub struct SessionThreadRecord {
/// before activity timestamps existed; such records sort oldest.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub created_at: Option<DateTime<Utc>>,
/// Last time the thread saw activity (a message was appended). Drives
/// the sidebar "Recent" ordering — newest activity first. Bumped on
/// every append; `None` for legacy records (sorts oldest).
/// Last time the thread saw durable user-visible transcript activity.
/// Drives the sidebar "Recent" ordering — newest activity first. Writers
/// that touch both a message and the thread activity stamp reuse one
/// timestamp for that logical change; pure idempotent replays remain
/// side-effect-free. `None` for legacy records (sorts oldest).
#[serde(default, skip_serializing_if = "Option::is_none")]
pub updated_at: Option<DateTime<Utc>>,
}
Expand All @@ -203,6 +205,16 @@ pub struct ThreadMessageRecord {
pub sequence: u64,
pub kind: MessageKind,
pub status: MessageStatus,
/// When this message row was first persisted. `None` for legacy records
/// written before per-message timestamps existed.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub created_at: Option<DateTime<Utc>>,
/// Last time the message row was materially changed. Draft finalization
/// updates this so UI refreshes can show the reply completion time instead
/// of the browser refresh time. Writers capture one timestamp per logical
/// message mutation so related message/thread stamps stay atomic.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub updated_at: Option<DateTime<Utc>>,
pub actor_id: Option<String>,
pub source_binding_id: Option<String>,
pub reply_target_binding_id: Option<String>,
Expand All @@ -222,6 +234,51 @@ pub struct ThreadMessageRecord {
pub redaction_ref: Option<String>,
}

/// New transcript rows must carry durable timestamps. Legacy rows persisted
/// before this field existed still deserialize with `None` and remain readable,
/// but all current writers should fail loudly if they try to append a fresh
/// row without both stamps.
pub(crate) fn validate_new_message_timestamps(
message: &ThreadMessageRecord,
context: &'static str,
) -> Result<(), crate::error::SessionThreadError> {
if message.created_at.is_none() || message.updated_at.is_none() {
return Err(crate::error::SessionThreadError::InvalidMessageTimestamp {
message_id: message.message_id,
context,
violation: crate::error::TimestampViolation::MissingDurableTimestamps,
});
}
Ok(())
}

/// Existing timestamp values are append-only metadata: once a row has either
/// stamp, mutation paths may update it but must not erase it. This keeps
/// legacy `None` values compatible while catching accidental nullification of
/// newly-written rows.
/// Lightweight timestamp-only variant for hot mutation paths. Callers usually
/// already hold a mutable message and should not clone large payloads just to
/// prove timestamp metadata was not erased.
pub(crate) fn validate_message_timestamp_fields_not_cleared(
message_id: ThreadMessageId,
before_created_at: Option<DateTime<Utc>>,
before_updated_at: Option<DateTime<Utc>>,
after_created_at: Option<DateTime<Utc>>,
after_updated_at: Option<DateTime<Utc>>,
context: &'static str,
) -> Result<(), crate::error::SessionThreadError> {
let created_at_cleared = before_created_at.is_some() && after_created_at.is_none();
let updated_at_cleared = before_updated_at.is_some() && after_updated_at.is_none();
if created_at_cleared || updated_at_cleared {
return Err(crate::error::SessionThreadError::InvalidMessageTimestamp {
message_id,
context,
violation: crate::error::TimestampViolation::ClearedDurableTimestamps,
});
}
Ok(())
}
Comment thread
coderabbitai[bot] marked this conversation as resolved.

/// Summary artifact over a stable transcript sequence range.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct SummaryArtifact {
Expand Down Expand Up @@ -583,6 +640,29 @@ mod tests {
}
}

fn sample_message() -> ThreadMessageRecord {
let now = Utc::now();
ThreadMessageRecord {
message_id: ThreadMessageId::new(),
thread_id: ThreadId::new("thread-contract").unwrap(),
sequence: 1,
kind: MessageKind::User,
status: MessageStatus::Accepted,
created_at: Some(now),
updated_at: Some(now),
actor_id: Some("actor-a".to_string()),
source_binding_id: None,
reply_target_binding_id: None,
turn_id: None,
turn_run_id: None,
tool_result_ref: None,
tool_result_provider_call: None,
content: Some("hello".to_string()),
attachments: Vec::new(),
redaction_ref: None,
}
}

#[test]
fn text_constructor_carries_no_attachments() {
let content = MessageContent::text("hello");
Expand Down Expand Up @@ -635,6 +715,50 @@ mod tests {
assert!(validate_attachment_refs(&[at_cap]).is_ok());
}

#[test]
fn validate_new_message_timestamps_rejects_missing_stamps() {
let mut message = sample_message();
message.created_at = None;

let err = validate_new_message_timestamps(&message, "test message")
.expect_err("new messages without created_at must be rejected");

assert!(matches!(
err,
crate::error::SessionThreadError::InvalidMessageTimestamp {
context: "test message",
violation: crate::error::TimestampViolation::MissingDurableTimestamps,
..
}
));
}

#[test]
fn validate_message_timestamp_fields_not_cleared_rejects_nullification() {
let before = sample_message();
let mut after = before.clone();
after.updated_at = None;

let err = validate_message_timestamp_fields_not_cleared(
after.message_id,
before.created_at,
before.updated_at,
after.created_at,
after.updated_at,
"test update",
)
.expect_err("message updates must not clear existing timestamps");

assert!(matches!(
err,
crate::error::SessionThreadError::InvalidMessageTimestamp {
context: "test update",
violation: crate::error::TimestampViolation::ClearedDurableTimestamps,
..
}
));
}

#[test]
fn into_text_keeps_text_when_attachment_free() {
// The non-lossy use: `into_text` is the text-only accessor for content
Expand Down Expand Up @@ -717,6 +841,8 @@ mod tests {
}"#;
let record: ThreadMessageRecord = serde_json::from_str(json).unwrap();
assert!(record.attachments.is_empty());
assert_eq!(record.created_at, None);
assert_eq!(record.updated_at, None);
assert_eq!(record.content.as_deref(), Some("legacy row"));
}
}
15 changes: 15 additions & 0 deletions crates/ironclaw_threads/src/error.rs
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,15 @@ use thiserror::Error;

use crate::{MessageStatus, ThreadMessageId};

/// Specific timestamp contract violation on a transcript message.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Error)]
pub enum TimestampViolation {
#[error("missing durable timestamps")]
MissingDurableTimestamps,
#[error("cleared durable timestamps")]
ClearedDurableTimestamps,
}

/// Canonical thread/transcript service errors.
#[derive(Debug, Error)]
pub enum SessionThreadError {
Expand Down Expand Up @@ -48,6 +57,12 @@ pub enum SessionThreadError {
},
#[error("invalid attachment on inbound message: {0}")]
InvalidAttachment(String),
#[error("{context} timestamp violation on message {message_id}: {violation}")]
InvalidMessageTimestamp {
message_id: ThreadMessageId,
context: &'static str,
violation: TimestampViolation,
},
#[error("failed to create generated thread id: {0}")]
GeneratedThreadId(String),
#[error("serialization error: {0}")]
Expand Down
Loading
Loading