Skip to content

Commit ac02abb

Browse files
hyperpolymathclaude
andcommitted
feat(verisimdb): Phase 1 Step 1 — temporal observation field on octad
Add caller-supplied real-world observation time (territory clock) to the octad model, distinct from the existing ingestion clock. - verisim-octad: new OctadTemporalInput { observed_at: DateTime<Utc> } threaded through OctadInput, OctadStatus.observed_at (Option), and the InMemoryOctadStore create/update/WAL-replay paths. Updates that do not supply temporal preserve the prior observed_at; updates that do supply it override. - verisim-api: new TemporalRequest { observed_at: String } on OctadRequest (RFC 3339); OctadStatusResponse.observed_at surfaced. to_octad_input is now fallible — bad RFC 3339 returns 400 BadRequest rather than being silently dropped. - verisim-normalizer: OctadStatus literals in test fixtures updated to populate the new field (None for synthetic test entities). Closes the temporal-shape gap from email-octad-experiment 2026-04-27 veridicality run (genesis run scored 0.38 on temporal due to the ingestion clock being the only available timestamp). With this change the territory clock from an email's Date: header round-trips through POST/GET intact. Tests: - verisim-octad: test_observed_at_round_trip_and_preservation test_observed_at_absent_when_not_supplied - verisim-api: test_temporal_observed_at_round_trip (HTTP layer, includes 400-on-bad-RFC3339 negative path) Workspace cargo check + verisim-octad/normalizer test suites pass (42 + 68 + 1 new). No regressions. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1 parent 4d435dd commit ac02abb

5 files changed

Lines changed: 249 additions & 11 deletions

File tree

verisimdb/rust-core/verisim-api/src/lib.rs

Lines changed: 123 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -46,8 +46,8 @@ use verisim_planner::{
4646
use verisim_octad::{
4747
BoundingBox, Coordinates, OctadConfig, OctadDocumentInput, OctadGraphInput,
4848
OctadId, OctadInput, OctadProvenanceInput, OctadSemanticInput, OctadSnapshot,
49-
OctadSpatialInput, OctadStore, OctadTensorInput, OctadVectorInput,
50-
InMemoryOctadStore, ProvenanceStore, SpatialStore,
49+
OctadSpatialInput, OctadStore, OctadTemporalInput, OctadTensorInput,
50+
OctadVectorInput, InMemoryOctadStore, ProvenanceStore, SpatialStore,
5151
};
5252
use verisim_provenance::InMemoryProvenanceStore;
5353
use verisim_spatial::InMemorySpatialStore;
@@ -238,6 +238,8 @@ pub struct OctadRequest {
238238
pub relationships: Option<Vec<(String, String)>>,
239239
/// Tensor data
240240
pub tensor: Option<TensorRequest>,
241+
/// Temporal observation (real-world clock, distinct from ingestion time)
242+
pub temporal: Option<TemporalRequest>,
241243
/// Provenance event
242244
pub provenance: Option<ProvenanceRequest>,
243245
/// Spatial coordinates
@@ -246,6 +248,14 @@ pub struct OctadRequest {
246248
pub metadata: Option<std::collections::HashMap<String, String>>,
247249
}
248250

251+
/// Temporal observation in request — caller-supplied real-world time
252+
#[derive(Debug, Serialize, Deserialize)]
253+
pub struct TemporalRequest {
254+
/// RFC 3339 timestamp of the underlying real-world event (e.g. an email's
255+
/// `Date:` header). Stored on the octad as `OctadStatus.observed_at`.
256+
pub observed_at: String,
257+
}
258+
249259
/// Provenance event data in request
250260
#[derive(Debug, Serialize, Deserialize)]
251261
pub struct ProvenanceRequest {
@@ -277,8 +287,10 @@ pub struct SpatialRequest {
277287
}
278288

279289
impl OctadRequest {
280-
/// Convert to OctadInput
281-
fn to_octad_input(&self) -> OctadInput {
290+
/// Convert to OctadInput. Fallible because the temporal field is supplied
291+
/// as RFC 3339 text and may fail to parse — surface that as 400 Bad Request
292+
/// rather than swallowing it as a missing observation.
293+
fn to_octad_input(&self) -> Result<OctadInput, ApiError> {
282294
let mut input = OctadInput::default();
283295

284296
if let (Some(title), Some(body)) = (&self.title, &self.body) {
@@ -322,6 +334,17 @@ impl OctadRequest {
322334
});
323335
}
324336

337+
if let Some(temporal) = &self.temporal {
338+
let observed_at = chrono::DateTime::parse_from_rfc3339(&temporal.observed_at)
339+
.map_err(|e| {
340+
ApiError::BadRequest(format!(
341+
"temporal.observed_at must be RFC 3339: {e}"
342+
))
343+
})?
344+
.with_timezone(&chrono::Utc);
345+
input.temporal = Some(OctadTemporalInput { observed_at });
346+
}
347+
325348
if let Some(provenance) = &self.provenance {
326349
input.provenance = Some(OctadProvenanceInput {
327350
event_type: provenance.event_type.clone(),
@@ -346,7 +369,7 @@ impl OctadRequest {
346369
input.metadata = metadata.clone();
347370
}
348371

349-
input
372+
Ok(input)
350373
}
351374
}
352375

@@ -378,6 +401,11 @@ pub struct OctadResponse {
378401
pub struct OctadStatusResponse {
379402
pub created_at: String,
380403
pub modified_at: String,
404+
/// RFC 3339 territory clock — the real-world time the entity was observed,
405+
/// distinct from `created_at` (the database ingestion time). Omitted from
406+
/// the JSON when the entity has no caller-supplied observation time.
407+
#[serde(skip_serializing_if = "Option::is_none")]
408+
pub observed_at: Option<String>,
381409
pub version: u64,
382410
}
383411

@@ -388,6 +416,7 @@ impl From<&verisim_octad::Octad> for OctadResponse {
388416
status: OctadStatusResponse {
389417
created_at: h.status.created_at.to_rfc3339(),
390418
modified_at: h.status.modified_at.to_rfc3339(),
419+
observed_at: h.status.observed_at.map(|t| t.to_rfc3339()),
391420
version: h.status.version,
392421
},
393422
has_graph: h.graph_node.is_some(),
@@ -856,7 +885,7 @@ async fn create_octad_handler(
856885
State(state): State<AppState>,
857886
Json(request): Json<OctadRequest>,
858887
) -> Result<(StatusCode, Json<OctadResponse>), ApiError> {
859-
let input = request.to_octad_input();
888+
let input = request.to_octad_input()?;
860889

861890
let octad = state
862891
.octad_store
@@ -895,7 +924,7 @@ async fn update_octad_handler(
895924
) -> Result<Json<OctadResponse>, ApiError> {
896925
validate_octad_id(&id)?;
897926
let octad_id = OctadId::new(&id);
898-
let input = request.to_octad_input();
927+
let input = request.to_octad_input()?;
899928

900929
let octad = state
901930
.octad_store
@@ -2246,6 +2275,7 @@ mod tests {
22462275
types: None,
22472276
relationships: None,
22482277
tensor: None,
2278+
temporal: None,
22492279
metadata: None,
22502280
provenance: None,
22512281
spatial: None,
@@ -2286,6 +2316,91 @@ mod tests {
22862316
assert_eq!(response.status(), StatusCode::OK);
22872317
}
22882318

2319+
#[tokio::test]
2320+
async fn test_temporal_observed_at_round_trip() {
2321+
// Phase 1 gap closure: caller-supplied real-world time (e.g. an email's
2322+
// Date: header) must round-trip through POST/GET as RFC 3339 in the
2323+
// OctadStatusResponse.observed_at field.
2324+
let state = create_test_state().await;
2325+
let app = build_router(state);
2326+
2327+
let create_request = OctadRequest {
2328+
title: Some("Email subject".to_string()),
2329+
body: Some("Email body".to_string()),
2330+
embedding: Some(vec![0.1, 0.2, 0.3]),
2331+
types: None,
2332+
relationships: None,
2333+
tensor: None,
2334+
temporal: Some(TemporalRequest {
2335+
observed_at: "2026-04-27T15:30:00Z".to_string(),
2336+
}),
2337+
metadata: None,
2338+
provenance: None,
2339+
spatial: None,
2340+
};
2341+
2342+
let response = app
2343+
.clone()
2344+
.oneshot(
2345+
Request::builder()
2346+
.method("POST")
2347+
.uri("/octads")
2348+
.header("content-type", "application/json")
2349+
.body(Body::from(
2350+
serde_json::to_string(&create_request).expect("serialize create request"),
2351+
))
2352+
.expect("build POST request"),
2353+
)
2354+
.await
2355+
.expect("oneshot create");
2356+
assert_eq!(response.status(), StatusCode::CREATED);
2357+
2358+
let body = axum::body::to_bytes(response.into_body(), 1024 * 1024)
2359+
.await
2360+
.expect("read create body");
2361+
let created: OctadResponse = serde_json::from_slice(&body).expect("decode response");
2362+
2363+
let observed = created
2364+
.status
2365+
.observed_at
2366+
.as_ref()
2367+
.expect("create response surfaces observed_at");
2368+
assert!(
2369+
observed.starts_with("2026-04-27T15:30:00"),
2370+
"observed_at must reflect caller-supplied time, got {observed}"
2371+
);
2372+
2373+
// Bad RFC 3339 must surface as 400, not be silently dropped
2374+
let bad_request = OctadRequest {
2375+
title: Some("Bad".to_string()),
2376+
body: Some("Bad".to_string()),
2377+
embedding: Some(vec![0.1, 0.2, 0.3]),
2378+
types: None,
2379+
relationships: None,
2380+
tensor: None,
2381+
temporal: Some(TemporalRequest {
2382+
observed_at: "not-a-date".to_string(),
2383+
}),
2384+
metadata: None,
2385+
provenance: None,
2386+
spatial: None,
2387+
};
2388+
let bad_response = app
2389+
.oneshot(
2390+
Request::builder()
2391+
.method("POST")
2392+
.uri("/octads")
2393+
.header("content-type", "application/json")
2394+
.body(Body::from(
2395+
serde_json::to_string(&bad_request).expect("serialize bad request"),
2396+
))
2397+
.expect("build bad POST request"),
2398+
)
2399+
.await
2400+
.expect("oneshot bad create");
2401+
assert_eq!(bad_response.status(), StatusCode::BAD_REQUEST);
2402+
}
2403+
22892404
#[tokio::test]
22902405
async fn test_text_search() {
22912406
let state = create_test_state().await;
@@ -2299,6 +2414,7 @@ mod tests {
22992414
types: None,
23002415
relationships: None,
23012416
tensor: None,
2417+
temporal: None,
23022418
metadata: None,
23032419
provenance: None,
23042420
spatial: None,

verisimdb/rust-core/verisim-normalizer/src/lib.rs

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -721,6 +721,7 @@ mod tests {
721721
id: OctadId::new("test-1"),
722722
created_at: Utc::now(),
723723
modified_at: Utc::now(),
724+
observed_at: None,
724725
version: 1,
725726
modality_status: ModalityStatus::default(),
726727
},
@@ -742,6 +743,7 @@ mod tests {
742743
id: OctadId::new("empty-1"),
743744
created_at: Utc::now(),
744745
modified_at: Utc::now(),
746+
observed_at: None,
745747
version: 1,
746748
modality_status: ModalityStatus::default(),
747749
},

verisimdb/rust-core/verisim-normalizer/src/regeneration.rs

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -960,6 +960,7 @@ mod tests {
960960
id: OctadId::new("rich-1"),
961961
created_at: Utc::now(),
962962
modified_at: Utc::now(),
963+
observed_at: None,
963964
version: 1,
964965
modality_status: ModalityStatus::default(),
965966
},
@@ -991,6 +992,7 @@ mod tests {
991992
id: OctadId::new("doc-1"),
992993
created_at: Utc::now(),
993994
modified_at: Utc::now(),
995+
observed_at: None,
994996
version: 1,
995997
modality_status: ModalityStatus::default(),
996998
},
@@ -1013,6 +1015,7 @@ mod tests {
10131015
id: OctadId::new("empty-1"),
10141016
created_at: Utc::now(),
10151017
modified_at: Utc::now(),
1018+
observed_at: None,
10161019
version: 1,
10171020
modality_status: ModalityStatus::default(),
10181021
},

verisimdb/rust-core/verisim-octad/src/lib.rs

Lines changed: 28 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -114,10 +114,17 @@ impl From<&str> for OctadId {
114114
pub struct OctadStatus {
115115
/// Entity ID
116116
pub id: OctadId,
117-
/// When the entity was created
117+
/// When the entity was created (ingestion time, set by the database)
118118
pub created_at: DateTime<Utc>,
119-
/// When last modified
119+
/// When last modified (ingestion time)
120120
pub modified_at: DateTime<Utc>,
121+
/// Caller-supplied real-world observation time, distinct from `created_at`.
122+
/// `created_at` records when the entity entered the database; `observed_at`
123+
/// records when the underlying event happened in the territory the entity
124+
/// represents (e.g. an email's `Date:` header). Optional because not every
125+
/// entity has a meaningful real-world timestamp.
126+
#[serde(default, skip_serializing_if = "Option::is_none")]
127+
pub observed_at: Option<DateTime<Utc>>,
121128
/// Current version
122129
pub version: u64,
123130
/// Status per modality
@@ -178,6 +185,10 @@ pub struct OctadInput {
178185
pub semantic: Option<OctadSemanticInput>,
179186
/// Document content (optional)
180187
pub document: Option<OctadDocumentInput>,
188+
/// Temporal observation time (optional). When supplied, `OctadStatus.observed_at`
189+
/// is populated. Distinct from the version snapshot (which the database always
190+
/// writes at ingestion time): this is the territory's clock, not the database's.
191+
pub temporal: Option<OctadTemporalInput>,
181192
/// Provenance event (optional)
182193
pub provenance: Option<OctadProvenanceInput>,
183194
/// Spatial coordinates (optional)
@@ -232,6 +243,15 @@ pub struct OctadDocumentInput {
232243
pub fields: HashMap<String, String>,
233244
}
234245

246+
/// Temporal modality input — the entity's real-world observation time
247+
#[derive(Debug, Clone, Serialize, Deserialize)]
248+
pub struct OctadTemporalInput {
249+
/// When the underlying event happened in the territory the entity represents
250+
/// (e.g. an email's `Date:` header). UTC; callers must convert from local
251+
/// timezones before submission.
252+
pub observed_at: DateTime<Utc>,
253+
}
254+
235255
/// Provenance modality input — records a lineage event
236256
#[derive(Debug, Clone, Serialize, Deserialize)]
237257
pub struct OctadProvenanceInput {
@@ -429,6 +449,12 @@ impl OctadBuilder {
429449
self
430450
}
431451

452+
/// Add real-world observation time (territory clock, not database ingestion time)
453+
pub fn with_observed_at(mut self, observed_at: DateTime<Utc>) -> Self {
454+
self.input.temporal = Some(OctadTemporalInput { observed_at });
455+
self
456+
}
457+
432458
/// Add metadata
433459
pub fn with_metadata(mut self, key: &str, value: &str) -> Self {
434460
self.input.metadata.insert(key.to_string(), value.to_string());

0 commit comments

Comments
 (0)