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
+48
View File
@@ -162,6 +162,54 @@ impl UserManager {
self.unlocked.read().map(|m| m.contains_key(id)).unwrap_or(false)
}
/// Open the database of a user whose file is **not encrypted**, without their
/// credentials — for work done *about* them by someone entitled to it.
///
/// For an unencrypted user the password guards the *session*, not the data:
/// the file has no key, so any code in this process can already open it. This
/// makes that explicit and puts the one honest limit in a single place —
/// **an encrypted user is refused**, and not as policy: without their password
/// there is no key to be had, and there must never be a second way to get one.
/// The rule a caller inherits from that is neutral by construction: work over
/// somebody else's history runs unattended for a user who is not encrypted,
/// and only while they are logged in for one who is.
///
/// **Authorization is the caller's**, exactly as for [`Self::open_db`] with a
/// credential-less user — this checks entitlement to a *key*, never
/// entitlement to the *data*. Call it only behind an explicit relation
/// (a `supervision` edge), never behind a role check.
///
/// The pool is **not** registered as unlocked: putting it in that map would
/// make the person look logged in to everything that iterates unlocked users,
/// and would keep their file open for the life of the process. A caller that
/// opened one here owns it and should close it. When the user *is* already
/// unlocked their live pool is returned instead, so a reader never opens a
/// second connection alongside their session.
pub async fn open_unencrypted(&self, id: &str) -> Result<SqlitePool, AuthError> {
if let Some(pool) = self.pool_of(id) {
return Ok(pool);
}
let user = db::users::get(&self.system, id)
.await
.map_err(AuthError::Internal)?
.ok_or(AuthError::UnknownUser)?;
if user.is_encrypted() {
return Err(AuthError::PasswordRequired);
}
if !user.active {
return Err(AuthError::Inactive);
}
let path = self.path_of(id);
if !path.exists() {
return Err(AuthError::MissingDatabase(path));
}
db::open_user_pool(&path, None).await.map_err(AuthError::Internal)
}
/// Login and unlock in one operation.
///
/// For an encrypted user a single Argon2id pass answers both questions: the