use crate::file_root;
use crate::jsonl::{field, json_str, now_rfc3339, read_lines};
use ikigai_core::{
ActionSpec, ArgSpec, Description, Endpoint, Error, Invocation, ReprType, Representation,
Result, Verb,
};
pub const CAP_DECISIONS_WRITE: &str = "urn:cap:decisions:write";
pub const CAP_DECISIONS_READ: &str = "urn:cap:decisions:read";
pub fn log_path() -> std::path::PathBuf {
file_root().join("decisions.jsonl")
}
fn blocked_emails(lines: &[String]) -> Vec<String> {
let mut seen = std::collections::HashSet::new();
let mut out = Vec::new();
for line in lines {
if field(line, "action").as_deref() == Some("block") {
if let Some(email) = field(line, "email") {
let key = email.trim().to_ascii_lowercase();
if !key.is_empty() && seen.insert(key) {
out.push(email.trim().to_string());
}
}
}
}
out
}
fn is_blocked_at(path: &std::path::Path, email: &str) -> bool {
let needle = email.trim().to_ascii_lowercase();
!needle.is_empty()
&& blocked_emails(&read_lines(path))
.iter()
.any(|e| e.to_ascii_lowercase() == needle)
}
pub struct DecisionLog {
pub path: std::path::PathBuf,
}
#[async_trait::async_trait]
impl Endpoint for DecisionLog {
async fn invoke(&self, inv: &Invocation<'_>) -> Result<Representation> {
match inv.request.verb {
Verb::Sink => {
if !inv.capability.allows(CAP_DECISIONS_WRITE) {
return Err(Error::Denied(format!(
"recording a decision requires `{CAP_DECISIONS_WRITE}`"
)));
}
let arg = |name: &str| inv.inline_str(name).unwrap_or("").trim().to_string();
let action = arg("action");
if !matches!(action.as_str(), "approve" | "decline" | "block") {
return Err(Error::InvalidArgument {
name: "action".to_string(),
detail: format!("`{action}` is not approve, decline or block"),
});
}
let record = format!(
"{{\"action\":{},\"id\":{},\"name\":{},\"email\":{},\"when\":{},\"at\":{}}}\n",
json_str(&action),
json_str(&arg("id")),
json_str(&arg("name")),
json_str(&arg("email")),
json_str(&arg("when")),
json_str(&now_rfc3339()),
);
crate::jsonl::append(&self.path, &record, "decisions")?;
Ok(Representation::new(
ReprType::new("text/plain"),
b"recorded".to_vec(),
))
}
Verb::Source => {
if !inv.capability.allows(CAP_DECISIONS_READ) {
return Err(Error::Denied(format!(
"reading decisions requires `{CAP_DECISIONS_READ}`"
)));
}
let lines = read_lines(&self.path);
if let Ok(email) = inv.inline_str("blocked") {
let yes = is_blocked_at(&self.path, email);
return Ok(Representation::new(
ReprType::new("text/plain"),
if yes { b"yes".to_vec() } else { b"no".to_vec() },
));
}
if inv
.inline_str("action")
.map(|a| a.trim() == "block")
.unwrap_or(false)
{
let body = blocked_emails(&lines).join("\n");
return Ok(Representation::new(
ReprType::new("text/plain").with_param("charset", "utf-8"),
body.into_bytes(),
));
}
let body = lines
.iter()
.map(|l| {
format!(
"{} {:8} {} <{}> {}",
field(l, "at").unwrap_or_default(),
field(l, "action").unwrap_or_default(),
field(l, "name").unwrap_or_default(),
field(l, "email").unwrap_or_default(),
field(l, "when").unwrap_or_default(),
)
})
.collect::<Vec<_>>()
.join("\n");
Ok(Representation::new(
ReprType::new("text/plain").with_param("charset", "utf-8"),
body.into_bytes(),
))
}
other => Err(Error::Endpoint(format!(
"decisions accepts Source (read/query) or Sink (append), not {other:?}"
))),
}
}
fn name(&self) -> &str {
"decisions"
}
fn describe(&self) -> Description {
Description::new("decisions")
.title("Decision log & blocklist")
.summary(
"An append-only record of every booking decision. Read it whole, or query \
`action=block` for the blocklist or `blocked=<email>` for a membership \
check. A block is just a decision — the blocklist is a filter, not a \
second store.",
)
.action(
ActionSpec::new(Verb::Source)
.summary("read the log, or query the blocklist")
.requires(CAP_DECISIONS_READ)
.input(
ArgSpec::new("action")
.optional()
.summary("filter — `block` lists blocked addresses"),
)
.input(
ArgSpec::new("blocked")
.optional()
.summary("an email — answers yes/no"),
),
)
.action(
ActionSpec::new(Verb::Sink)
.summary("append a decision")
.requires(CAP_DECISIONS_WRITE)
.input(ArgSpec::new("action").summary("approve, decline or block"))
.input(ArgSpec::new("id").optional().summary("the booking id"))
.input(ArgSpec::new("name").optional().summary("the requester"))
.input(
ArgSpec::new("email")
.optional()
.summary("the requester's address"),
)
.input(ArgSpec::new("when").optional().summary("the meeting time")),
)
.output("text/plain; charset=utf-8")
}
}
#[cfg(test)]
mod tests {
use super::*;
use futures::executor::block_on;
use ikigai_core::{ArgRef, Capability, EndpointSpace, Exact, Iri, Kernel, Request};
use std::sync::Arc;
fn kernel(name: &str) -> Kernel {
let dir = std::env::temp_dir().join(format!("ikigai-decisions-{name}"));
let _ = std::fs::remove_dir_all(&dir);
std::fs::create_dir_all(&dir).unwrap();
Kernel::new(Arc::new(EndpointSpace::new().bind(
Exact::new("urn:decisions"),
DecisionLog {
path: dir.join("decisions.jsonl"),
},
)))
}
fn record(k: &Kernel, action: &str, name: &str, email: &str) {
block_on(
k.issue(
Request::new(Verb::Sink, Iri::parse("urn:decisions").unwrap())
.with_arg("action", ArgRef::Inline(action.into()))
.with_arg("id", ArgRef::Inline(b"abc123def456".to_vec()))
.with_arg("name", ArgRef::Inline(name.into()))
.with_arg("email", ArgRef::Inline(email.into()))
.with_arg("when", ArgRef::Inline(b"2026-07-24T10:00".to_vec())),
&Capability::scoped([CAP_DECISIONS_WRITE]),
),
)
.unwrap();
}
fn read(k: &Kernel, arg: Option<(&str, &str)>) -> String {
let mut req = Request::new(Verb::Source, Iri::parse("urn:decisions").unwrap());
if let Some((key, val)) = arg {
req = req.with_arg(key, ArgRef::Inline(val.into()));
}
let rep = block_on(k.issue(req, &Capability::scoped([CAP_DECISIONS_READ]))).unwrap();
String::from_utf8(rep.bytes.clone()).unwrap()
}
#[test]
fn the_log_records_every_decision_newest_first() {
let k = kernel("log");
record(&k, "approve", "Ada", "ada@x.example");
record(&k, "decline", "Bo", "bo@x.example");
let out = read(&k, None);
assert!(out.lines().next().unwrap().contains("decline"), "{out}");
assert!(out.contains("Ada <ada@x.example>"), "{out}");
assert!(out.contains("approve") && out.contains("decline"), "{out}");
}
#[test]
fn a_block_is_a_decision_and_shows_in_the_blocklist() {
let k = kernel("block");
record(&k, "approve", "Ada", "ada@x.example");
record(&k, "block", "Spammer", "spam@x.example");
assert!(read(&k, None).contains("block"));
let list = read(&k, Some(("action", "block")));
assert_eq!(list.trim(), "spam@x.example");
}
#[test]
fn blocked_membership_is_case_insensitive() {
let k = kernel("member");
record(&k, "block", "Spammer", "Spam@X.Example");
assert_eq!(read(&k, Some(("blocked", "spam@x.example"))), "yes");
assert_eq!(read(&k, Some(("blocked", "SPAM@X.EXAMPLE"))), "yes");
assert_eq!(read(&k, Some(("blocked", "someone@else.example"))), "no");
}
#[test]
fn an_empty_log_is_empty_not_an_error() {
let k = kernel("empty");
assert_eq!(read(&k, None), "");
assert_eq!(read(&k, Some(("action", "block"))), "");
assert_eq!(read(&k, Some(("blocked", "anyone@x.example"))), "no");
}
#[test]
fn reading_and_writing_are_separately_gated() {
let k = kernel("caps");
let denied = block_on(
k.issue(
Request::new(Verb::Sink, Iri::parse("urn:decisions").unwrap())
.with_arg("action", ArgRef::Inline(b"block".to_vec())),
&Capability::scoped([CAP_DECISIONS_READ]), ),
)
.unwrap_err();
assert!(matches!(denied, Error::Denied(_)), "{denied:?}");
}
#[test]
fn a_quote_in_a_name_cannot_break_the_record_line() {
let k = kernel("escape");
record(&k, "block", r#"a" ,"action":"approve"#, "x@y.example");
assert_eq!(read(&k, Some(("action", "block"))).trim(), "x@y.example");
}
}