feat: conversation review — a nightly report on a supervised person's conversations
Nightly Build / build (push) Successful in 7m40s

The first AgentScope::PerSubject system agent, and the reason that scope
exists. Once a night, for each person with a supervision edge, it reads every
message that person and the assistant exchanged since the previous review —
across all their conversations — and writes one report for the people who
supervise them.

Schema (all registry except reports):
- supervision(subject_user_id, supervisor_user_id): the generic §0.1 edge,
  answering both 'whom does a background agent look at' and 'who may read
  what it produced', with real FKs so deleting a user cascades both ways
- system_agent_coverage(agent_id, subject_user_id, covered_through): the
  per-subject watermark that makes 'everything since last time' a window —
  neither system_agent_runs (history for humans) nor system_agent_state
  (advances before the work), and advanced only on a completed pass so a
  crash re-covers instead of skipping
- reports (owner schema, the second two-homes table after memory_docs):
  instance rows land in system.db, deliberately cleartext to the box owner,
  who is the intended reader (§2); the subject cannot see them structurally

The pass reads the subject's database inside a supervisor's runtime, so the
ephemeral session and run row land in the watcher's file; iteration is over
subjects, so two parents watching one child get one review; and the subject
need not be logged in when their space is unencrypted — via the new
UserManager::open_unencrypted, which refuses an encrypted user outright (no
key to be had) and never registers the pool as unlocked.

The agent declares the new AgentMeta flag allow_tools: false, so its turn
gets an empty tool registry — nothing for a prompt injection in the
transcript to call — and produces its report as its final assistant message,
read back from chat_history and parsed (NOTHING_TO_REPORT sentinel, no row on
quiet days). chat_history::conversation_window is the transcript query; its
four filters (non-ephemeral, depth 0, non-synthetic, non-empty) each guard a
specific way the review would otherwise be wrong, and tool calls are absent
by construction.

Cadence is Run at (hour) rather than Interval — 4am local by default — with
due-ness answered inside has_work against the coverage watermark, so a
machine off for three days covers the whole stretch in one pass. Reports
announce ReportCreated on the system bus (no subscriber yet). run_ephemeral_turn
gains a per-pass system_substitutions map, which the review uses to hand the
model the subject's profile under __SUBJECT_PROFILE__ — the system-context
substitutions describe the session owner, the wrong person here.

docs/system-agents.md gains the conversation review section; CLAUDE.md
documents the scope, the tables and the tool-less design.
This commit is contained in:
2026-08-02 20:30:27 +01:00
parent e6818408cb
commit 4f10528368
19 changed files with 2394 additions and 35 deletions
+435
View File
@@ -0,0 +1,435 @@
//! Accessor for `reports` — the documents system agents write about a stretch
//! of time (blueprint §13).
//!
//! **The pool is the audience.** Like [`super::memory_docs`], one owner schema
//! backs two homes and the file a row lands in decides who may read it: a user's
//! own encrypted database holds the reports that belong to them, `system.db`
//! holds the instance ones — written about someone, for the people who supervise
//! them. Nothing in here filters by reader, because there is nothing to filter:
//! a subject's tools only ever reach their own pool. The separation is
//! structural, not a predicate someone has to remember to add.
//!
//! Which file a producer writes into falls out of its own scope with no new
//! concept: `AgentScope::PerUser` passes `ctx.pool`, `AgentScope::Instance`
//! passes the registry pool it already holds.
//!
//! **A report is immutable.** It is a snapshot of a window that has closed, so
//! there is no `update`: the only write after [`create`] is [`mark_read`], and
//! even that is once — see its "first reader wins" note.
use anyhow::Result;
use sqlx::SqlitePool;
/// Severity, in ascending order of "someone should look at this". Free text in
/// the column; these are the vocabulary the UI knows how to render.
pub const SEVERITY_INFO: &str = "info";
pub const SEVERITY_NOTICE: &str = "notice";
pub const SEVERITY_ALERT: &str = "alert";
/// The report belongs to whoever owns the file it is in — the default, and the
/// only meaningful value inside a `{userid}.db`.
pub const AUDIENCE_OWNER: &str = "owner";
/// An instance report (`system.db`) for the admins.
pub const AUDIENCE_ADMINS: &str = "admins";
/// An instance report for whoever holds a [`super::supervision`] edge over its
/// `subject_user_id` — the audience that is *computed*, not enumerated, so adding
/// a second parent to the edge widens the readership of every past report at once.
pub const AUDIENCE_SUPERVISORS: &str = "supervisors";
/// A report with its body. Use [`ReportSummary`] for listings — the body is the
/// bulk of the row and a list never renders it.
#[derive(Debug, Clone, sqlx::FromRow)]
pub struct Report {
pub id: i64,
pub kind: String,
pub title: String,
pub summary: Option<String>,
pub body: String,
pub severity: String,
pub subject_user_id: Option<String>,
pub audience: String,
pub period_start: Option<String>,
pub period_end: Option<String>,
pub produced_by: String,
pub producer_user_id: Option<String>,
pub run_id: Option<i64>,
pub metadata: Option<String>,
pub read_at: Option<String>,
pub read_by: Option<String>,
pub created_at: String,
}
/// A listing row: everything but `body`.
#[derive(Debug, Clone, sqlx::FromRow)]
pub struct ReportSummary {
pub id: i64,
pub kind: String,
pub title: String,
pub summary: Option<String>,
pub severity: String,
pub subject_user_id: Option<String>,
pub audience: String,
pub period_start: Option<String>,
pub period_end: Option<String>,
pub produced_by: String,
pub producer_user_id: Option<String>,
pub run_id: Option<i64>,
pub metadata: Option<String>,
pub read_at: Option<String>,
pub read_by: Option<String>,
pub created_at: String,
}
/// The fields a producer supplies. `kind`, `title`, `body` and `produced_by` are
/// the ones with no sensible default; everything else has one.
#[derive(Debug, Clone)]
pub struct NewReport<'a> {
/// Producer-declared type — data, never an enum (§0.1). Groups the UI.
pub kind: &'a str,
pub title: &'a str,
/// One line for lists and for the notification that announces it.
pub summary: Option<&'a str>,
/// Markdown.
pub body: &'a str,
pub severity: &'a str,
/// Who the report is about. `None` for a report about nobody in particular.
pub subject_user_id: Option<&'a str>,
pub audience: &'a str,
/// The window covered, ISO-8601. Both `None` for a point-in-time report.
pub period_start: Option<&'a str>,
pub period_end: Option<&'a str>,
/// The system agent's id.
pub produced_by: &'a str,
/// Whose runtime ran the pass — for an instance report, not the subject.
pub producer_user_id: Option<&'a str>,
/// `system_agent_runs.id`. A bare snapshot: for an instance report that row
/// lives in the acting user's file, not this one.
pub run_id: Option<i64>,
/// JSON counters. Never contents — the body is the only place text belongs.
pub metadata: Option<&'a str>,
}
/// Hand-written, not derived, for the same reason `RoleAttrs`'s is: a derived
/// `Default` would leave `severity` and `audience` empty strings, and both are
/// `NOT NULL` columns whose value the UI dispatches on. The defaults are the
/// quiet, narrow ones — informational, and readable only by the file's owner.
impl Default for NewReport<'_> {
fn default() -> Self {
Self {
kind: "",
title: "",
summary: None,
body: "",
severity: SEVERITY_INFO,
subject_user_id: None,
audience: AUDIENCE_OWNER,
period_start: None,
period_end: None,
produced_by: "",
producer_user_id: None,
run_id: None,
metadata: None,
}
}
}
/// How to narrow a [`list`]. All-`None` lists everything, newest first.
#[derive(Debug, Clone, Default)]
pub struct ListFilter<'a> {
pub kind: Option<&'a str>,
pub subject_user_id: Option<&'a str>,
/// Only reports nobody has acknowledged yet.
pub unread_only: bool,
/// Only reports created at or after this ISO timestamp.
pub since: Option<&'a str>,
pub limit: Option<i64>,
}
const SUMMARY_COLS: &str = "id, kind, title, summary, severity, subject_user_id, audience, \
period_start, period_end, produced_by, producer_user_id, run_id, metadata, \
read_at, read_by, created_at";
/// Write a report. Returns its id.
pub async fn create(pool: &SqlitePool, report: &NewReport<'_>) -> Result<i64> {
let id = sqlx::query_scalar::<_, i64>(
"INSERT INTO reports
(kind, title, summary, body, severity, subject_user_id, audience,
period_start, period_end, produced_by, producer_user_id, run_id, metadata)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
RETURNING id",
)
.bind(report.kind)
.bind(report.title)
.bind(report.summary)
.bind(report.body)
.bind(report.severity)
.bind(report.subject_user_id)
.bind(report.audience)
.bind(report.period_start)
.bind(report.period_end)
.bind(report.produced_by)
.bind(report.producer_user_id)
.bind(report.run_id)
.bind(report.metadata)
.fetch_one(pool)
.await?;
Ok(id)
}
/// Fetch one report, body included.
pub async fn get(pool: &SqlitePool, id: i64) -> Result<Option<Report>> {
let row = sqlx::query_as::<_, Report>(
"SELECT id, kind, title, summary, body, severity, subject_user_id, audience,
period_start, period_end, produced_by, producer_user_id, run_id, metadata,
read_at, read_by, created_at
FROM reports WHERE id = ?",
)
.bind(id)
.fetch_optional(pool)
.await?;
Ok(row)
}
/// List reports newest first, without their bodies.
///
/// `id DESC` breaks ties: `created_at` has second resolution, and two reports of
/// the same pass land inside one tick often enough that the order would
/// otherwise be whatever SQLite felt like.
pub async fn list(pool: &SqlitePool, filter: &ListFilter<'_>) -> Result<Vec<ReportSummary>> {
// The SQL text is assembled only from these literals — every caller-supplied
// value goes through a bind, in the same order the predicates were pushed.
let mut predicates: Vec<&str> = Vec::new();
if filter.kind.is_some() { predicates.push("kind = ?"); }
if filter.subject_user_id.is_some() { predicates.push("subject_user_id = ?"); }
if filter.unread_only { predicates.push("read_at IS NULL"); }
if filter.since.is_some() { predicates.push("created_at >= ?"); }
let mut sql = format!("SELECT {SUMMARY_COLS} FROM reports");
if !predicates.is_empty() {
sql.push_str(" WHERE ");
sql.push_str(&predicates.join(" AND "));
}
sql.push_str(" ORDER BY created_at DESC, id DESC");
if filter.limit.is_some() {
sql.push_str(" LIMIT ?");
}
let mut query = sqlx::query_as::<_, ReportSummary>(sqlx::AssertSqlSafe(sql));
if let Some(kind) = filter.kind { query = query.bind(kind); }
if let Some(subject) = filter.subject_user_id { query = query.bind(subject); }
if let Some(since) = filter.since { query = query.bind(since); }
if let Some(limit) = filter.limit { query = query.bind(limit); }
Ok(query.fetch_all(pool).await?)
}
/// How many reports nobody has acknowledged — the badge count.
pub async fn unread_count(pool: &SqlitePool) -> Result<i64> {
let n = sqlx::query_scalar::<_, i64>("SELECT count(*) FROM reports WHERE read_at IS NULL")
.fetch_one(pool)
.await?;
Ok(n)
}
/// Acknowledge a report on behalf of `user_id`. Returns whether this call is the
/// one that marked it.
///
/// **First reader wins, and that is the semantics, not an optimisation.** An
/// instance report can have several readers (two admins); an alert about the
/// same evening is one thing to deal with, dealt with once. The `read_at IS
/// NULL` guard makes the write idempotent and keeps `read_by` pointing at
/// whoever actually took it, instead of whoever opened it last.
pub async fn mark_read(pool: &SqlitePool, id: i64, user_id: &str) -> Result<bool> {
let n = sqlx::query(
"UPDATE reports SET read_at = datetime('now'), read_by = ?
WHERE id = ? AND read_at IS NULL",
)
.bind(user_id)
.bind(id)
.execute(pool)
.await?
.rows_affected();
Ok(n > 0)
}
/// Delete a report. Returns whether a row was removed.
pub async fn delete(pool: &SqlitePool, id: i64) -> Result<bool> {
let n = sqlx::query("DELETE FROM reports WHERE id = ?")
.bind(id)
.execute(pool)
.await?
.rows_affected();
Ok(n > 0)
}
#[cfg(test)]
mod tests {
use super::*;
use std::path::PathBuf;
/// A standalone owner-schema database in a throwaway temp dir, as in
/// `memory_docs`: `tag` plus a counter keep parallel tests off one file.
async fn owner_pool(tag: &str) -> (SqlitePool, PathBuf) {
use std::sync::atomic::{AtomicU64, Ordering};
static SEQ: AtomicU64 = AtomicU64::new(0);
let n = SEQ.fetch_add(1, Ordering::Relaxed);
let dir = std::env::temp_dir()
.join(format!("skald-reports-{}-{tag}-{n}", std::process::id()));
let _ = std::fs::remove_dir_all(&dir);
std::fs::create_dir_all(&dir).unwrap();
let pool = crate::db::create_user_pool(&dir.join("owner.db"), None).await.unwrap();
(pool, dir)
}
#[tokio::test]
async fn create_stores_every_field_and_defaults_the_rest() {
let (pool, dir) = owner_pool("create").await;
// The minimum a producer must supply: the defaults fill the rest.
let bare = create(&pool, &NewReport {
kind: "usage-digest",
title: "Your week with the assistant",
body: "You asked for a calendar three times and got nowhere.",
produced_by: "usage-digest",
..Default::default()
}).await.unwrap();
let got = get(&pool, bare).await.unwrap().unwrap();
assert_eq!(got.severity, SEVERITY_INFO, "a bare report is informational");
assert_eq!(got.audience, AUDIENCE_OWNER, "...and readable only by its file's owner");
assert!(got.subject_user_id.is_none());
assert!(got.read_at.is_none(), "a fresh report is unread");
assert!(!got.created_at.is_empty());
// A full instance report: subject and run_id are bare snapshots, so
// neither has to exist anywhere in this file.
let full = create(&pool, &NewReport {
kind: "conversation-review",
title: "Something to look at",
summary: Some("one line for the notification"),
body: "# Detail\n\nnarrated, not quoted.",
severity: SEVERITY_ALERT,
subject_user_id: Some("u-nobody"),
audience: AUDIENCE_ADMINS,
period_start: Some("2026-07-28T00:00:00Z"),
period_end: Some("2026-07-29T00:00:00Z"),
produced_by: "conversation-review",
producer_user_id: Some("u-someone-else"),
run_id: Some(4242),
metadata: Some(r#"{"sessions_scanned":7}"#),
}).await.unwrap();
let got = get(&pool, full).await.unwrap().unwrap();
assert_eq!(got.severity, SEVERITY_ALERT);
assert_eq!(got.audience, AUDIENCE_ADMINS);
assert_eq!(got.subject_user_id.as_deref(), Some("u-nobody"));
assert_eq!(got.producer_user_id.as_deref(), Some("u-someone-else"));
assert_eq!(got.run_id, Some(4242));
assert_eq!(got.period_end.as_deref(), Some("2026-07-29T00:00:00Z"));
assert!(got.body.starts_with("# Detail"));
assert!(get(&pool, 9999).await.unwrap().is_none());
pool.close().await;
let _ = std::fs::remove_dir_all(&dir);
}
#[tokio::test]
async fn list_narrows_and_orders_newest_first() {
let (pool, dir) = owner_pool("list").await;
let mk = |kind: &'static str, subject: Option<&'static str>| {
let pool = pool.clone();
async move {
create(&pool, &NewReport {
kind,
title: "t",
body: "b",
subject_user_id: subject,
produced_by: "agent",
..Default::default()
}).await.unwrap()
}
};
let first = mk("usage-digest", None).await;
let second = mk("conversation-review", Some("u-kid")).await;
let third = mk("conversation-review", Some("u-other")).await;
// Newest first, with `id` breaking the same-second tie.
let all = list(&pool, &ListFilter::default()).await.unwrap();
assert_eq!(all.iter().map(|r| r.id).collect::<Vec<_>>(), vec![third, second, first]);
let by_kind = list(&pool, &ListFilter {
kind: Some("conversation-review"), ..Default::default()
}).await.unwrap();
assert_eq!(by_kind.iter().map(|r| r.id).collect::<Vec<_>>(), vec![third, second]);
let by_subject = list(&pool, &ListFilter {
subject_user_id: Some("u-kid"), ..Default::default()
}).await.unwrap();
assert_eq!(by_subject.len(), 1);
assert_eq!(by_subject[0].id, second);
// Two filters compose, and `limit` applies after the ordering.
let both = list(&pool, &ListFilter {
kind: Some("conversation-review"),
subject_user_id: Some("u-other"),
..Default::default()
}).await.unwrap();
assert_eq!(both.len(), 1);
assert_eq!(both[0].id, third);
let capped = list(&pool, &ListFilter { limit: Some(2), ..Default::default() }).await.unwrap();
assert_eq!(capped.iter().map(|r| r.id).collect::<Vec<_>>(), vec![third, second]);
// `since` is inclusive, and a future timestamp excludes everything.
assert!(list(&pool, &ListFilter {
since: Some("2999-01-01T00:00:00Z"), ..Default::default()
}).await.unwrap().is_empty());
pool.close().await;
let _ = std::fs::remove_dir_all(&dir);
}
/// Several admins share one instance report; whoever gets there first is the
/// one who took it, and the count reflects the household, not each reader.
#[tokio::test]
async fn acknowledgement_is_shared_and_first_writer_wins() {
let (pool, dir) = owner_pool("read").await;
let id = create(&pool, &NewReport {
kind: "conversation-review", title: "t", body: "b",
audience: AUDIENCE_ADMINS, produced_by: "agent", ..Default::default()
}).await.unwrap();
let other = create(&pool, &NewReport {
kind: "usage-digest", title: "t2", body: "b", produced_by: "agent", ..Default::default()
}).await.unwrap();
assert_eq!(unread_count(&pool).await.unwrap(), 2);
assert_eq!(list(&pool, &ListFilter { unread_only: true, ..Default::default() })
.await.unwrap().len(), 2);
assert!(mark_read(&pool, id, "u-anna").await.unwrap(), "the first reader takes it");
assert!(!mark_read(&pool, id, "u-bruno").await.unwrap(), "the second changes nothing");
let got = get(&pool, id).await.unwrap().unwrap();
assert_eq!(got.read_by.as_deref(), Some("u-anna"), "read_by keeps whoever took it");
assert!(got.read_at.is_some());
assert_eq!(unread_count(&pool).await.unwrap(), 1);
let unread = list(&pool, &ListFilter { unread_only: true, ..Default::default() })
.await.unwrap();
assert_eq!(unread.len(), 1);
assert_eq!(unread[0].id, other);
assert!(!mark_read(&pool, 9999, "u-anna").await.unwrap(), "an absent report marks nothing");
assert!(delete(&pool, id).await.unwrap());
assert!(!delete(&pool, id).await.unwrap(), "a second delete is a no-op");
assert!(get(&pool, id).await.unwrap().is_none());
pool.close().await;
let _ = std::fs::remove_dir_all(&dir);
}
}