mirror of
https://github.com/akitaonrails/ai-memory.git
synced 2026-10-02 03:24:46 +08:00
fix(okf): give a date-only expires_at an offset-bearing stale_after (#917)
conform_frontmatter copied expires_at into the OKF stale_after key verbatim. expires_at also accepts a bare YYYY-MM-DD (documented in docs/usage.md as end of day, UTC), so such a page was written, and exported by export-okf, with stale_after: 2026-10-01. OKF v0.2 now requires every timestamp to carry an explicit UTC offset (knowledge-catalog #323), and its earlier text read a bare date as the start of that day, a day before ai-memory's TTL hides the page. The end-of-day rule moves into ai_memory_core::parse_expires_at_instant, which the wiki's TTL validation and the OKF derivation now share. A date-only value becomes the instant it names (2026-10-01T23:59:59.999999Z); an RFC 3339 value is still carried verbatim, so existing pages keep byte-identical frontmatter. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01CKb3cisaT5r2kiW9mYiWvt
This commit is contained in:
co-authored by
Claude Opus 5.5
parent
1417119eec
commit
eaee835782
@@ -48,6 +48,12 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
||||
unchanged — this only makes the existing warning reliably visible. (#903)
|
||||
|
||||
### Fixed
|
||||
- A page whose `expires_at` is a bare date (`2026-10-01`) now gets an OKF
|
||||
`stale_after` that names the instant the TTL expires it
|
||||
(`2026-10-01T23:59:59.999999Z`) instead of the date copied verbatim. OKF
|
||||
v0.2 requires every timestamp to carry an explicit UTC offset, and read a
|
||||
bare date as the start of that day, a day earlier than ai-memory's
|
||||
end-of-day TTL. An RFC 3339 `expires_at` is still carried unchanged. (#917)
|
||||
- The default log filter's `rmcp=warn` cap (#894) no longer raises rmcp
|
||||
above a quieter `log_level`. A target directive beats the global level
|
||||
either way, so with `log_level = "error"` or `"off"` the cap re-enabled the
|
||||
|
||||
@@ -68,7 +68,7 @@ pub use observation::{NewObservation, NewSession, Observation, ObservationKind};
|
||||
pub use page::{
|
||||
FeedbackKind, LinkTarget, MAX_ENTITIES_PER_PAGE, MAX_ENTITY_LEN, NewPage, Page, PageEvidence,
|
||||
PageEvidenceKind, Relation, Tier, frontmatter_entity_names, normalize_entities,
|
||||
normalize_entity,
|
||||
normalize_entity, parse_expires_at_instant,
|
||||
};
|
||||
pub use routing_snippet::{
|
||||
COMPACT_SNIPPET_BODY, MARKER_END, MARKER_START, SNIPPET_BODY, compact_block, find_marker_line,
|
||||
|
||||
@@ -98,12 +98,21 @@ pub fn conform_frontmatter(path: &str, frontmatter: &mut Value) {
|
||||
map.insert("description".into(), Value::String(summary.to_string()));
|
||||
}
|
||||
|
||||
// stale_after ← existing TTL. `expires_at` is stored as an ISO-8601
|
||||
// string by the TTL machinery; carry it verbatim.
|
||||
// stale_after ← existing TTL. OKF timestamps must carry an explicit
|
||||
// UTC offset, but `expires_at` also accepts a bare `YYYY-MM-DD`
|
||||
// (end of that day, UTC): render that as the instant it names. An
|
||||
// RFC 3339 value already conforms and is carried verbatim, so pages
|
||||
// written before this rule keep byte-identical frontmatter.
|
||||
if !map.contains_key("stale_after")
|
||||
&& let Some(expires) = map.get("expires_at").and_then(Value::as_str)
|
||||
&& let Some(instant) = crate::page::parse_expires_at_instant(expires)
|
||||
{
|
||||
map.insert("stale_after".into(), Value::String(expires.to_string()));
|
||||
let stale_after = if expires.trim().parse::<jiff::Timestamp>().is_ok() {
|
||||
expires.to_string()
|
||||
} else {
|
||||
instant.to_string()
|
||||
};
|
||||
map.insert("stale_after".into(), Value::String(stale_after));
|
||||
}
|
||||
|
||||
// sources ← session provenance already stamped by the consolidator.
|
||||
@@ -248,6 +257,32 @@ mod tests {
|
||||
assert_eq!(fm["tier"], "episodic");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_date_only_ttl_becomes_an_offset_bearing_stale_after() {
|
||||
let mut fm = json!({"expires_at": "2026-10-01"});
|
||||
conform_frontmatter("notes/x.md", &mut fm);
|
||||
let once = fm.clone();
|
||||
conform_frontmatter("notes/x.md", &mut fm);
|
||||
assert_eq!(once, fm, "second conform changed bytes");
|
||||
// The TTL's own reading of a bare date: the end of that day, UTC.
|
||||
assert_eq!(fm["stale_after"], "2026-10-01T23:59:59.999999Z");
|
||||
assert_eq!(fm["expires_at"], "2026-10-01");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_rfc3339_ttl_is_carried_into_stale_after_verbatim() {
|
||||
let mut fm = json!({"expires_at": "2026-10-01T12:00:00-03:00"});
|
||||
conform_frontmatter("notes/x.md", &mut fm);
|
||||
assert_eq!(fm["stale_after"], "2026-10-01T12:00:00-03:00");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_unparseable_ttl_derives_no_stale_after() {
|
||||
let mut fm = json!({"expires_at": "soon"});
|
||||
conform_frontmatter("notes/x.md", &mut fm);
|
||||
assert!(fm.get("stale_after").is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_explicit_type_is_never_overwritten() {
|
||||
let mut fm = json!({"type": "Custom Thing"});
|
||||
|
||||
@@ -248,6 +248,24 @@ pub fn frontmatter_entity_names(frontmatter: &serde_json::Value) -> Vec<String>
|
||||
normalize_entities(merged)
|
||||
}
|
||||
|
||||
/// The instant a frontmatter `expires_at` value names: an RFC 3339
|
||||
/// timestamp, or a bare `YYYY-MM-DD` meaning the end of that day in UTC.
|
||||
/// `None` when the value is neither. The wiki's TTL validation and the
|
||||
/// OKF `stale_after` derivation both read it, so the two cannot disagree
|
||||
/// on when a page expires.
|
||||
#[must_use]
|
||||
pub fn parse_expires_at_instant(raw: &str) -> Option<Timestamp> {
|
||||
let raw = raw.trim();
|
||||
if let Ok(ts) = raw.parse::<Timestamp>() {
|
||||
return Some(ts);
|
||||
}
|
||||
let date = raw.parse::<jiff::civil::Date>().ok()?;
|
||||
date.at(23, 59, 59, 999_999_000)
|
||||
.to_zoned(jiff::tz::TimeZone::UTC)
|
||||
.ok()
|
||||
.map(|zoned| zoned.timestamp())
|
||||
}
|
||||
|
||||
/// A link target discovered in a page body.
|
||||
///
|
||||
/// A bare `[[path]]` / `[label](path)` resolves within the source page's
|
||||
|
||||
@@ -2454,20 +2454,7 @@ pub(crate) fn parse_expires_at(
|
||||
if raw.is_empty() {
|
||||
return Ok(None);
|
||||
}
|
||||
if let Ok(ts) = raw.parse::<jiff::Timestamp>() {
|
||||
return Ok(Some(ts));
|
||||
}
|
||||
if let Ok(date) = raw.parse::<jiff::civil::Date>() {
|
||||
let ts = date
|
||||
.at(23, 59, 59, 999_999_000)
|
||||
.to_zoned(jiff::tz::TimeZone::UTC)
|
||||
.map_err(|e| {
|
||||
ai_memory_wiki_error(&format!(
|
||||
"invalid expires_at date in frontmatter for {}: {e}",
|
||||
path.as_str()
|
||||
))
|
||||
})?
|
||||
.timestamp();
|
||||
if let Some(ts) = ai_memory_core::parse_expires_at_instant(raw) {
|
||||
return Ok(Some(ts));
|
||||
}
|
||||
Err(ai_memory_wiki_error(&format!(
|
||||
@@ -2884,6 +2871,52 @@ mod tests {
|
||||
assert!(ai_memory_core::okf::is_conformant(&parsed.frontmatter));
|
||||
}
|
||||
|
||||
/// OKF requires every timestamp to carry an explicit UTC offset, while
|
||||
/// the TTL key also accepts a bare date. The file on disk must name the
|
||||
/// instant the TTL machinery hides the page, not the date as typed.
|
||||
#[tokio::test]
|
||||
async fn a_date_only_ttl_lands_on_disk_as_an_okf_stale_after_instant() {
|
||||
let tmp = TempDir::new().unwrap();
|
||||
let store = Store::open(tmp.path()).unwrap();
|
||||
let ws = store.writer.get_or_create_workspace("w").await.unwrap();
|
||||
let proj = store
|
||||
.writer
|
||||
.get_or_create_project(ws, "p", None)
|
||||
.await
|
||||
.unwrap();
|
||||
let wiki = Wiki::new(tmp.path(), store.writer.clone()).unwrap();
|
||||
|
||||
let frontmatter = serde_json::json!({"title": "Freeze", "expires_at": "2099-08-01"});
|
||||
wiki.write_page(req(
|
||||
ws,
|
||||
proj,
|
||||
"notes/freeze.md",
|
||||
"release freeze",
|
||||
frontmatter.clone(),
|
||||
))
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
let raw =
|
||||
std::fs::read_to_string(wiki.project_root(ws, proj).join("notes/freeze.md")).unwrap();
|
||||
let parsed = crate::markdown::parse(&raw).unwrap();
|
||||
assert_eq!(parsed.frontmatter["expires_at"], "2099-08-01");
|
||||
assert_eq!(
|
||||
parsed.frontmatter["stale_after"],
|
||||
"2099-08-01T23:59:59.999999Z"
|
||||
);
|
||||
let path = PagePath::new("notes/freeze.md").unwrap();
|
||||
let ttl = parse_expires_at(&path, &frontmatter).unwrap().unwrap();
|
||||
assert_eq!(
|
||||
parsed.frontmatter["stale_after"]
|
||||
.as_str()
|
||||
.unwrap()
|
||||
.parse::<jiff::Timestamp>()
|
||||
.unwrap(),
|
||||
ttl
|
||||
);
|
||||
}
|
||||
|
||||
/// Refused on every platform, not only the case-insensitive ones: the
|
||||
/// wiki is synced between them, so what it accepts cannot depend on the
|
||||
/// filesystem underneath.
|
||||
|
||||
+1
-1
@@ -58,7 +58,7 @@ families. Summary of what conformance requires:
|
||||
| `generated.by` | actor convention: `process:ai-memory/<version>` for the zero-LLM consolidator and system writers; `<provider-model>` (e.g. `openai-compat/qwen3:32b`) for LLM-written pages; `human:<user>` for wiki edits attributed via the watcher |
|
||||
| `generated.at` | the page version's `updated_at` |
|
||||
| `sources` | session provenance: pages already stamped with `session_id`/`agent` get `[{resource: "ai-memory://session/<uuid>", author: "<agent>"}]` |
|
||||
| `stale_after` | existing `expires_at` (TTL), when present |
|
||||
| `stale_after` | existing `expires_at` (TTL), when present: an RFC 3339 value verbatim, a bare `YYYY-MM-DD` as the end of that day in UTC (`2026-10-01T23:59:59.999999Z`), since OKF timestamps carry an explicit offset |
|
||||
| `status` | `deprecated` when TTL-expired but retained; otherwise omitted (spec default `stable`) |
|
||||
|
||||
Extension fields kept verbatim (unknown keys are conformant): `tier`,
|
||||
|
||||
Reference in New Issue
Block a user