diff --git a/README.md b/README.md index 4dff6f2..d3b2ed7 100644 --- a/README.md +++ b/README.md @@ -53,7 +53,7 @@ From zero to a working OneBrain vault in three steps: ```bash # 1. Verify the install onebrain --version -# → onebrain 3.4.9 +# → onebrain 3.4.12 # 2. Scaffold a vault and let init pull the OneBrain plugin mkdir my-vault && cd my-vault diff --git a/crates/onebrain-cli/src/commands/token_check.rs b/crates/onebrain-cli/src/commands/token_check.rs index e6e058e..5910639 100644 --- a/crates/onebrain-cli/src/commands/token_check.rs +++ b/crates/onebrain-cli/src/commands/token_check.rs @@ -613,7 +613,7 @@ mod tests { std::thread::spawn(move || { // Accumulate until the end of the HTTP headers (`\r\n\r\n`) so // a request split across TCP segments isn't misread from a - // single partial `read` (Copilot). + // single partial `read`. let mut req_bytes = Vec::with_capacity(8192); let mut chunk = [0u8; 1024]; while req_bytes.len() < 8192 { diff --git a/crates/onebrain-cli/src/commands/token_gain.rs b/crates/onebrain-cli/src/commands/token_gain.rs index 209cc5c..ca0951a 100644 --- a/crates/onebrain-cli/src/commands/token_gain.rs +++ b/crates/onebrain-cli/src/commands/token_gain.rs @@ -1200,7 +1200,7 @@ mod tests { std::thread::spawn(move || { // Accumulate until the end of the HTTP headers (`\r\n\r\n`) so // a request split across TCP segments isn't misread from a - // single partial `read` (Copilot). + // single partial `read`. let mut req_bytes = Vec::with_capacity(8192); let mut chunk = [0u8; 1024]; while req_bytes.len() < 8192 { diff --git a/crates/onebrain-token/src/gain/rollup.rs b/crates/onebrain-token/src/gain/rollup.rs index a3e11c5..f63fcfe 100644 --- a/crates/onebrain-token/src/gain/rollup.rs +++ b/crates/onebrain-token/src/gain/rollup.rs @@ -68,21 +68,31 @@ impl RollupValue { } } +/// UTC datetime for a unix timestamp, falling back to the Unix EPOCH (not the +/// current time) for an out-of-range `ts`. An out-of-range value can't happen +/// for real gain events (they carry `now_ts()` seconds), but bucketing a corrupt +/// one under 1970 makes it obviously wrong and self-flagging, rather than +/// silently recording it under TODAY and polluting the current period's rollup. +fn utc_or_epoch(ts: i64) -> DateTime { + DateTime::from_timestamp(ts, 0) + .unwrap_or_else(|| DateTime::from_timestamp(0, 0).expect("unix epoch is a valid timestamp")) +} + /// `YYYY-MM-DD` (UTC) for a unix timestamp — the daily rollup key's period /// segment. `pub(crate)` so [`super::pivot::query_events`] buckets raw /// events on the exact same day boundary the rollup does. pub(crate) fn day_key(ts: i64) -> String { - let dt = DateTime::from_timestamp(ts, 0).unwrap_or_else(Utc::now); + let dt = utc_or_epoch(ts); format!("{:04}-{:02}-{:02}", dt.year(), dt.month(), dt.day()) } fn month_key(ts: i64) -> String { - let dt = DateTime::from_timestamp(ts, 0).unwrap_or_else(Utc::now); + let dt = utc_or_epoch(ts); format!("{:04}-{:02}", dt.year(), dt.month()) } fn year_key(ts: i64) -> String { - let dt = DateTime::from_timestamp(ts, 0).unwrap_or_else(Utc::now); + let dt = utc_or_epoch(ts); format!("{:04}", dt.year()) } @@ -256,6 +266,16 @@ mod tests { use crate::level::OptLevel; use tempfile::tempdir; + #[test] + fn utc_or_epoch_falls_back_to_1970_not_now_for_out_of_range_ts() { + // An out-of-range ts (`from_timestamp` → None) must bucket under the + // Unix epoch (obviously wrong, self-flagging), never silently under + // today's date where it would pollute the current period's rollup. + assert_eq!(utc_or_epoch(i64::MAX).year(), 1970); + assert_eq!(day_key(i64::MAX), "1970-01-01"); + assert_eq!(year_key(i64::MIN), "1970"); + } + fn event(ts: i64, surface: Surface, transform: &str, before: u64, after: u64) -> GainEvent { GainEvent { ts,