use std::time::{SystemTime, UNIX_EPOCH};
use serde_json::{Map, Value};
use super::event_store::SCHEMA_VERSION_FIELD;
use super::{KnlError, KnlResult};
pub const FIELD_SEQ: &str = "seq";
pub const FIELD_EPOCH_MS: &str = "epoch_ms";
pub const FIELD_KIND: &str = "kind";
pub const FIELD_BEAT: &str = "beat";
pub const FIELD_META: &str = "meta";
pub const FIELD_DATA: &str = "data";
pub const ENVELOPE_FIELDS: &[&str] = &[
FIELD_KIND,
FIELD_BEAT,
FIELD_META,
FIELD_DATA,
FIELD_SEQ,
FIELD_EPOCH_MS,
SCHEMA_VERSION_FIELD,
];
pub const KIND_SESSION_OPENED: &str = "session_opened";
pub const KIND_SESSION_CLOSED: &str = "session_closed";
pub const KIND_BUDGET_GRANTED: &str = "budget_granted";
pub const KIND_BUDGET_RESERVED: &str = "budget_reserved";
pub const KIND_BUDGET_REFUSED: &str = "budget_refused";
pub const KIND_BUDGET_SPENT: &str = "budget_spent";
pub const BUDGET_KINDS: &[&str] = &[
KIND_BUDGET_GRANTED,
KIND_BUDGET_RESERVED,
KIND_BUDGET_REFUSED,
KIND_BUDGET_SPENT,
];
pub const FIELD_REASON: &str = "reason";
pub const FIELD_DETAIL: &str = "detail";
pub const FIELD_AMOUNT: &str = "amount";
pub const FIELD_TAG: &str = "tag";
pub const FIELD_DESC: &str = "desc";
pub const FIELD_REMAINING: &str = "remaining";
pub const FIELD_OWNER: &str = "owner";
pub const FIELD_SCOPE_ID: &str = "scope_id";
pub const FIELD_PARENT: &str = "parent";
pub const FIELD_CHILD: &str = "child";
pub const FIELD_OPEN_CHILDREN: &str = "open_children";
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
enum Shape {
Str,
Integer,
}
impl Shape {
fn name(self) -> &'static str {
match self {
Shape::Str => "a string",
Shape::Integer => "a whole number",
}
}
fn accepts(self, value: &Value) -> bool {
match self {
Shape::Str => value.is_string(),
Shape::Integer => is_whole_number(value),
}
}
}
const SESSION_OPENED_DATA: &[(&str, Shape)] =
&[(FIELD_SCOPE_ID, Shape::Str), (FIELD_OWNER, Shape::Str)];
const SESSION_CLOSED_DATA: &[(&str, Shape)] = &[(FIELD_REASON, Shape::Str)];
const BUDGET_GRANTED_DATA: &[(&str, Shape)] = &[(FIELD_AMOUNT, Shape::Integer)];
const BUDGET_MOVE_DATA: &[(&str, Shape)] = &[(FIELD_AMOUNT, Shape::Integer)];
const BUDGET_REFUSED_DATA: &[(&str, Shape)] = &[
(FIELD_AMOUNT, Shape::Integer),
(FIELD_REMAINING, Shape::Integer),
];
fn required_data(kind: &str) -> Option<&'static [(&'static str, Shape)]> {
match kind {
KIND_SESSION_OPENED => Some(SESSION_OPENED_DATA),
KIND_SESSION_CLOSED => Some(SESSION_CLOSED_DATA),
KIND_BUDGET_GRANTED => Some(BUDGET_GRANTED_DATA),
KIND_BUDGET_RESERVED | KIND_BUDGET_SPENT => Some(BUDGET_MOVE_DATA),
KIND_BUDGET_REFUSED => Some(BUDGET_REFUSED_DATA),
_ => None,
}
}
pub fn is_reserved(kind: &str) -> bool {
required_data(kind).is_some()
}
pub fn is_kernel_only(kind: &str) -> bool {
matches!(
kind,
KIND_SESSION_OPENED
| KIND_SESSION_CLOSED
| KIND_BUDGET_GRANTED
| KIND_BUDGET_RESERVED
| KIND_BUDGET_REFUSED
| KIND_BUDGET_SPENT
)
}
fn is_whole_number(value: &Value) -> bool {
match value {
Value::Number(n) => {
n.is_i64() || n.is_u64() || n.as_f64().is_some_and(|f| f.fract() == 0.0)
}
_ => false,
}
}
pub fn json_type_name(value: &Value) -> &'static str {
match value {
Value::Null => "nil",
Value::Bool(_) => "boolean",
Value::Number(_) => "number",
Value::String(_) => "string",
Value::Array(_) => "array",
Value::Object(_) => "table",
}
}
pub fn data_field<'a>(event: &'a Map<String, Value>, name: &str) -> Option<&'a Value> {
event.get(FIELD_DATA)?.as_object()?.get(name)
}
pub fn validate_event(obj: &Map<String, Value>) -> KnlResult<()> {
let kind = match obj.get(FIELD_KIND) {
Some(Value::String(kind)) => kind.as_str(),
Some(other) => {
return Err(KnlError::Validation(format!(
"kind must be a string, got {}",
json_type_name(other)
)));
}
None => {
return Err(KnlError::Validation(
"kind is required (string)".to_string(),
))
}
};
for key in obj.keys() {
if !ENVELOPE_FIELDS.contains(&key.as_str()) {
return Err(KnlError::Validation(format!(
"{key:?} is not part of the envelope (kind / beat / meta / data); a kind's own \
fields go under data"
)));
}
}
match obj.get(FIELD_BEAT) {
None => {}
Some(Value::String(_)) => {}
Some(other) => {
return Err(KnlError::Validation(format!(
"beat must be a string, got {}",
json_type_name(other)
)));
}
}
match obj.get(FIELD_META) {
None => {}
Some(Value::Object(meta)) => {
for (name, value) in meta {
if !matches!(value, Value::String(_) | Value::Number(_) | Value::Bool(_)) {
return Err(KnlError::Validation(format!(
"meta is shallow: {name:?} must be a string, a number or a boolean, got \
{} (nest it under data)",
json_type_name(value)
)));
}
}
}
Some(other) => {
return Err(KnlError::Validation(format!(
"meta must be a table, got {}",
json_type_name(other)
)));
}
}
let data = match obj.get(FIELD_DATA) {
None => None,
Some(Value::Object(data)) => Some(data),
Some(other) => {
return Err(KnlError::Validation(format!(
"data must be a table, got {}",
json_type_name(other)
)));
}
};
let Some(fields) = required_data(kind) else {
return Ok(());
};
let empty = Map::new();
let data = data.unwrap_or(&empty);
for (name, shape) in fields {
match data.get(*name) {
None => {
return Err(KnlError::Validation(format!(
"kernel kind {kind:?} requires data.{name} ({})",
shape.name()
)));
}
Some(value) if !shape.accepts(value) => {
return Err(KnlError::Validation(format!(
"kernel kind {kind:?}: data.{name} must be {}, got {}",
shape.name(),
json_type_name(value)
)));
}
Some(_) => {}
}
}
Ok(())
}
pub fn stamp(obj: &mut Map<String, Value>, seq: u64, epoch_ms: u64) {
obj.insert(FIELD_SEQ.to_string(), Value::from(seq));
obj.insert(FIELD_EPOCH_MS.to_string(), Value::from(epoch_ms));
obj.entry(FIELD_META).or_insert_with(empty_object);
obj.entry(FIELD_DATA).or_insert_with(empty_object);
}
fn empty_object() -> Value {
Value::Object(Map::new())
}
pub fn seq_of(event: &Value) -> u64 {
event.get(FIELD_SEQ).and_then(Value::as_u64).unwrap_or(0)
}
pub fn kind_of(event: &Value) -> &str {
event.get(FIELD_KIND).and_then(Value::as_str).unwrap_or("")
}
pub fn kernel_event(kind: &str, data: Map<String, Value>) -> Map<String, Value> {
let mut obj = Map::new();
obj.insert(FIELD_KIND.to_string(), Value::String(kind.to_string()));
obj.insert(FIELD_DATA.to_string(), Value::Object(data));
obj
}
pub fn now_ms() -> u64 {
SystemTime::now()
.duration_since(UNIX_EPOCH)
.map(|d| d.as_millis() as u64)
.unwrap_or(0)
}
#[cfg(test)]
mod tests {
use super::*;
use serde_json::json;
fn obj(value: Value) -> Map<String, Value> {
match value {
Value::Object(map) => map,
other => panic!("test fixture must be an object, got {other}"),
}
}
#[test]
fn kind_must_be_present_and_a_string() {
let err =
validate_event(&obj(json!({ "data": { "text": "hi" } }))).expect_err("missing kind");
assert!(err.reason().contains("kind is required"), "{err}");
let err = validate_event(&obj(json!({ "kind": 42 }))).expect_err("numeric kind");
assert!(err.reason().contains("kind must be a string"), "{err}");
assert!(err.reason().contains("number"), "{err}");
}
#[test]
fn a_key_outside_the_envelope_is_refused() {
let err = validate_event(&obj(json!({ "kind": "msg_user", "content": "hi" })))
.expect_err("a stray top-level key");
assert!(err.reason().contains("\"content\""), "{err}");
assert!(err.reason().contains("under data"), "{err}");
validate_event(&obj(json!({
"kind": "note",
"beat": "b1",
"meta": { "tag": "a" },
"data": { "text": "hi" },
"seq": 1,
"epoch_ms": 0,
"_schema_version": 1
})))
.expect("the whole envelope");
}
#[test]
fn meta_takes_scalars_and_refuses_nesting() {
validate_event(&obj(json!({
"kind": "note",
"meta": { "label": "a", "attempt": 2, "retried": true }
})))
.expect("a shallow meta");
validate_event(&obj(json!({ "kind": "note", "meta": {} }))).expect("an empty meta");
for nested in [json!({ "deep": { "a": 1 } }), json!({ "deep": [1, 2] })] {
let err = validate_event(&obj(json!({ "kind": "note", "meta": nested })))
.expect_err("a nested meta value");
assert!(err.reason().contains("meta is shallow"), "{err}");
assert!(err.reason().contains("under data"), "{err}");
}
let err = validate_event(&obj(json!({ "kind": "note", "meta": { "x": null } })))
.expect_err("a null meta value");
assert!(err.reason().contains("meta is shallow"), "{err}");
let err = validate_event(&obj(json!({ "kind": "note", "meta": "a" })))
.expect_err("meta must be a table");
assert!(err.reason().contains("meta must be a table"), "{err}");
}
#[test]
fn data_is_an_object_of_any_depth_and_may_be_left_out() {
validate_event(&obj(json!({ "kind": "note" }))).expect("no data at all");
validate_event(&obj(json!({ "kind": "note", "data": {} }))).expect("empty data");
validate_event(&obj(json!({
"kind": "llm_response",
"data": { "content": [{ "type": "text", "text": "ok" }], "usage": { "in": 3 } }
})))
.expect("nested data");
let err = validate_event(&obj(json!({ "kind": "note", "data": [1, 2] })))
.expect_err("data must be a table");
assert!(err.reason().contains("data must be a table"), "{err}");
}
#[test]
fn a_stored_event_always_carries_meta_and_data() {
let mut event = obj(json!({ "kind": "note" }));
stamp(&mut event, 1, 0);
assert_eq!(event[FIELD_DATA], json!({}));
assert_eq!(event[FIELD_META], json!({}));
let mut written = obj(json!({
"kind": "note", "meta": { "label": "a" }, "data": { "text": "hi" }
}));
stamp(&mut written, 2, 0);
assert_eq!(written[FIELD_META], json!({ "label": "a" }));
assert_eq!(written[FIELD_DATA], json!({ "text": "hi" }));
}
#[test]
fn the_data_of_a_callers_kind_is_not_the_kernels_business() {
for event in [
json!({ "kind": "decision" }),
json!({ "kind": "note", "data": { "any": { "nested": [1, 2] } } }),
json!({ "kind": "msg_user", "data": { "content": "hi" } }),
json!({ "kind": "msg_user", "data": {} }),
json!({ "kind": "llm_request", "data": { "model": "m", "messages": [] } }),
json!({ "kind": "llm_response", "data": { "anything": true } }),
json!({ "kind": "llm_call_failed", "data": { "error": "boom" } }),
json!({ "kind": "tool_call", "data": { "name": "sh" } }),
json!({ "kind": "tool_result", "data": { "ok": false } }),
] {
validate_event(&obj(event.clone())).unwrap_or_else(|e| panic!("{event}: {e}"));
}
}
#[test]
fn kernel_kinds_accept_their_documented_data() {
for event in [
json!({ "kind": "session_opened", "data": { "scope_id": "s1", "owner": "anon" } }),
json!({ "kind": "session_closed", "data": { "reason": "closed" } }),
json!({ "kind": "session_closed", "data": { "reason": "error", "detail": "boom" } }),
json!({ "kind": "budget_granted", "data": { "amount": 100 } }),
json!({
"kind": "budget_granted",
"data": { "amount": 100, "tag": "tokens", "desc": "one run" }
}),
json!({ "kind": "budget_reserved", "data": { "amount": 12, "tag": "tokens" } }),
json!({ "kind": "budget_spent", "data": { "amount": 3 } }),
json!({ "kind": "budget_refused", "data": { "amount": 40, "remaining": 7 } }),
] {
validate_event(&obj(event.clone())).unwrap_or_else(|e| panic!("{event}: {e}"));
}
}
#[test]
fn kernel_kinds_reject_a_missing_data_field() {
let err = validate_event(&obj(json!({ "kind": "session_closed", "data": {} })))
.expect_err("reason is required");
assert!(err.reason().contains("session_closed"), "{err}");
assert!(err.reason().contains("reason"), "{err}");
let err =
validate_event(&obj(json!({ "kind": "session_closed" }))).expect_err("no data at all");
assert!(err.reason().contains("reason"), "{err}");
let err = validate_event(&obj(json!({
"kind": "session_opened", "data": { "scope_id": "s1" }
})))
.expect_err("owner is required");
assert!(err.reason().contains("owner"), "{err}");
let err = validate_event(&obj(json!({ "kind": "budget_reserved", "data": {} })))
.expect_err("amount is required");
assert!(err.reason().contains("amount"), "{err}");
let err = validate_event(&obj(json!({
"kind": "budget_refused", "data": { "amount": 5 }
})))
.expect_err("remaining is required");
assert!(err.reason().contains("remaining"), "{err}");
}
#[test]
fn kernel_kinds_reject_a_mistyped_data_field() {
let err = validate_event(&obj(json!({
"kind": "session_closed", "data": { "reason": 7 }
})))
.expect_err("reason must be a string");
assert!(err.reason().contains("must be a string"), "{err}");
assert!(err.reason().contains("number"), "{err}");
let err = validate_event(&obj(json!({
"kind": "budget_spent", "data": { "amount": "lots" }
})))
.expect_err("amount must be a number");
assert!(err.reason().contains("whole number"), "{err}");
}
#[test]
fn no_kind_requires_a_beat() {
for event in [
json!({ "kind": "llm_response", "data": { "content": [] } }),
json!({ "kind": "tool_call", "data": { "name": "sh" } }),
json!({ "kind": "budget_spent", "data": { "amount": 1 } }),
json!({ "kind": "note" }),
] {
validate_event(&obj(event.clone()))
.unwrap_or_else(|e| panic!("{event}: a beat must not be required: {e}"));
}
}
#[test]
fn a_declared_beat_must_be_a_string_on_any_kind() {
for event in [
json!({ "kind": "note", "beat": "b1" }),
json!({ "kind": "llm_response", "beat": "b1", "data": { "content": [] } }),
json!({ "kind": "budget_spent", "beat": "b1", "data": { "amount": 1 } }),
] {
validate_event(&obj(event.clone()))
.unwrap_or_else(|e| panic!("{event}: a string beat must be accepted: {e}"));
}
for event in [
json!({ "kind": "note", "beat": 1 }),
json!({ "kind": "llm_response", "beat": 1, "data": {} }),
json!({ "kind": "tool_call", "beat": [], "data": {} }),
] {
let err =
validate_event(&obj(event.clone())).expect_err("a non-string beat must be refused");
assert!(
err.reason().contains("beat must be a string"),
"{event}: {err}"
);
}
}
#[test]
fn whole_floats_count_as_integers() {
validate_event(&obj(
json!({ "kind": "budget_granted", "data": { "amount": 1.0 } }),
))
.expect("1.0 is a whole number");
let err = validate_event(&obj(
json!({ "kind": "budget_granted", "data": { "amount": 1.5 } }),
))
.expect_err("1.5 is not whole");
assert!(err.reason().contains("whole number"), "{err}");
}
#[test]
fn stamp_overwrites_caller_supplied_envelope_fields() {
let mut event = obj(json!({
"kind": "note", "seq": 999, "epoch_ms": 1
}));
stamp(&mut event, 7, 12_345);
assert_eq!(event.get(FIELD_SEQ).and_then(Value::as_u64), Some(7));
assert_eq!(
event.get(FIELD_EPOCH_MS).and_then(Value::as_u64),
Some(12_345)
);
}
#[test]
fn a_kernel_event_carries_its_fields_under_data() {
let mut data = Map::new();
data.insert(FIELD_REASON.to_string(), Value::from("closed"));
let event = kernel_event(KIND_SESSION_CLOSED, data);
assert_eq!(event[FIELD_KIND], json!("session_closed"));
assert_eq!(event[FIELD_DATA], json!({ "reason": "closed" }));
validate_event(&event).expect("what the kernel builds, the kernel accepts");
assert_eq!(
data_field(&event, FIELD_REASON).and_then(Value::as_str),
Some("closed")
);
assert_eq!(data_field(&event, "nothing"), None);
}
#[test]
fn the_shapes_the_kernel_checks_are_the_kinds_it_writes() {
for kind in [
"session_opened",
"session_closed",
"budget_granted",
"budget_reserved",
"budget_refused",
"budget_spent",
] {
assert!(is_kernel_only(kind), "{kind} must be kernel-only");
assert!(is_reserved(kind), "{kind}'s data shape is the kernel's");
}
for kind in [
"msg_user",
"llm_request",
"llm_response",
"llm_call_failed",
"tool_call",
"tool_result",
"note",
"decision",
"carry",
"budget",
"session",
"",
] {
assert!(!is_kernel_only(kind), "{kind} must not be kernel-only");
assert!(!is_reserved(kind), "{kind}'s data shape is its writer's");
}
}
#[test]
fn shape_validation_says_nothing_about_who_may_write_a_kind() {
for event in [
json!({ "kind": "session_opened", "data": { "scope_id": "s", "owner": "anon" } }),
json!({ "kind": "session_closed", "data": { "reason": "carried over" } }),
json!({ "kind": "budget_granted", "data": { "amount": 1_000_000 } }),
] {
validate_event(&obj(event.clone())).unwrap_or_else(|e| panic!("{event}: {e}"));
}
}
}