zeroclaw-labs/zeroclaw · error

'since' must be before 'until'

Error message

'since' must be before 'until'

What it means

LucidMemory::recall parses the optional since/until RFC 3339 window filters and requires since < until strictly — the check uses s >= u, so equal instants are rejected too. It fires before the local sqlite lookup and any lucid subprocess, purely from argument validation, and exists so an inverted range never silently returns zero results.

Source

Thrown at crates/zeroclaw-memory/src/lucid.rs:448

        let until_dt = until
            .map(chrono::DateTime::parse_from_rfc3339)
            .transpose()
            .map_err(|e| {
                ::zeroclaw_log::record!(
                    WARN,
                    ::zeroclaw_log::Event::new(module_path!(), ::zeroclaw_log::Action::Reject)
                        .with_outcome(::zeroclaw_log::EventOutcome::Failure)
                        .with_attrs(
                            ::serde_json::json!({"field": "until", "error": format!("{}", e)})
                        ),
                    "recall window bound rejected"
                );
                anyhow::Error::msg(format!("invalid 'until' date (expected RFC 3339): {e}"))
            })?;
        if let (Some(s), Some(u)) = (&since_dt, &until_dt)
            && s >= u
        {
            anyhow::bail!("'since' must be before 'until'");
        }

        let recall_query = normalize_recent_recall_query(query);

        let local_results = self
            .local
            .recall(recall_query, limit, session_id, since, until)
            .await?;
        if limit == 0
            || local_results.len() >= limit
            || local_results.len() >= self.local_hit_threshold
        {
            return Ok(local_results);
        }

        if self.in_failure_cooldown() {
            return Ok(local_results);
        }

View on GitHub (pinned to 88bb9c8533)

Solutions

  1. Order the arguments so since is strictly earlier than until.
  2. If the intent is a single instant, widen until by at least one second (or one unit of your choice).
  3. Normalize both timestamps to UTC before sending so offsets cannot invert the comparison.

Example fix

// before
memory.recall(query, limit, session_id,
    Some("2026-01-10T00:00:00Z"),  // since (later)
    Some("2026-01-02T00:00:00Z"),  // until (earlier)
).await?;

// after
memory.recall(query, limit, session_id,
    Some("2026-01-02T00:00:00Z"),  // since (earlier)
    Some("2026-01-10T00:00:00Z"),  // until (later)
).await?;
Defensive patterns

Strategy: validation

Validate before calling

fn valid_recall_window(since: Option<&str>, until: Option<&str>) -> bool {
    match (since, until) {
        (Some(s), Some(u)) => match (
            chrono::DateTime::parse_from_rfc3339(s),
            chrono::DateTime::parse_from_rfc3339(u),
        ) {
            (Ok(s), Ok(u)) => s < u, // strict: equal instants are rejected
            _ => false,
        },
        _ => true,
    }
}

Type guard

fn is_valid_recall_window(since: Option<&str>, until: Option<&str>) -> bool {
    valid_recall_window(since, until)
}

Try / catch

if !valid_recall_window(since, until) {
    return Ok(Vec::new()); // or surface a 4xx to the caller
}
memory.recall(query, limit, session_id, since, until).await;

Prevention

When it happens

Trigger: Calling recall (or a higher path reaching it) with both filters where since is on or after until, e.g. since="2026-01-10T00:00:00Z", until="2026-01-02T00:00:00Z", or both set to the same instant.

Common situations: Swapped since/until arguments; inclusive single-instant ranges where both ends are equal; timezone offsets (+02:00 vs Z) making the since instant land after until; UI date pickers sending default zero or identical dates.

Related errors


AI-assisted analysis of zeroclaw-labs/zeroclaw@88bb9c8533 (2026-08-23). Data as JSON: /api/errors/894aef3c59ced5b5. Report an issue: GitHub.