Agent reason: the first call of a session should say who is asking, what is
waiting and what to do, in under 8 KiB, from the same lists the viewer
draws. books.start now carries book.id and book.url (the book's path on the
server, never a path on a disk), you {principal, kind, scopes, client_id},
drafts {building, awaiting_evidence}, reconciliation {tracked,
never_reconciled, behind}, backup {last_at, through_seq, stale}, the
prompts, and needs_attention (the twelve most urgent items, problems first,
with every item counted by kind), beside next_steps now in the envelope's
shape (tool or prompt, why, required; attention problems are required). A
new book is told to run the onboard prompt first, then to record the
company's facts and set up the chart; an active one is offered a draft
entry. The tool carries _meta anthropic/alwaysLoad, so a client that defers
tools loads it up front. The size fit shortens attention items and waiting
drafts after the convention summaries and before the lists, as before.
agent {model, reasoning_effort, harness} is accepted and written to the
call's access record (the access log shows it as agent), self-reported and
never checked, and used for nothing else: not in the answer, not in any
decision. Who asked is the request's alone: it is cleared when the request
ends, so a later resource read on the same thread cannot see it.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HS8WZziDnyxunC23MUxrLp
198 lines
7.3 KiB
Rust
198 lines
7.3 KiB
Rust
//! Who is calling, what their token allows, and the record of each call
|
|
//! (plan 3.3, I-9), for every book the books server serves.
|
|
//!
|
|
//! ```text
|
|
//! GOAL: a call reaches a book only with a token for that book and for
|
|
//! what the call does, and every call that reaches it is recorded
|
|
//! before it is answered.
|
|
//! SOURCE: plan 3.3 (scopes), I-9 ("every access through the server is
|
|
//! logged, and logging fails closed"), section 7's record.
|
|
//! INVARIANTS:
|
|
//! 1. The HTTP door (local.rs) puts a Caller on every request it lets
|
|
//! through; a call that reaches a book with no Caller is refused.
|
|
//! 2. A call runs only if its Caller's token has the scope it needs
|
|
//! (scopes.rs); otherwise it is refused, unrun.
|
|
//! 3. Every call, run or refused, gets one record, written before its
|
|
//! answer goes (a call that ran, on its own blocking thread while it
|
|
//! still counts as running, so a close waits for its record too);
|
|
//! if the record can't be written, the book stops taking requests
|
|
//! (SharedStore's stop) and the answer says so, a call refused as not
|
|
//! run keeping its "not run" code.
|
|
//! 4. A record's head is the journal's head after the call's own last
|
|
//! write (taken under the store's lock as it lets go), whatever the
|
|
//! call answered; none for a call that wrote nothing.
|
|
//! ```
|
|
|
|
use ledger_store::access::{Access, AccessLog, Client, Outcome, Principal, digest};
|
|
use ledger_store::auth::{Scope, Token};
|
|
use ledger_store::chain::Head;
|
|
use rmcp::service::{RequestContext, RoleServer};
|
|
use std::sync::Arc;
|
|
|
|
/// Who made a request: the token the door matched (INVARIANT 1).
|
|
#[derive(Clone, Debug)]
|
|
pub struct Caller {
|
|
/// The token.
|
|
pub token: Token,
|
|
}
|
|
|
|
impl Caller {
|
|
/// As the access log names them.
|
|
pub fn principal(&self) -> Principal {
|
|
Principal {
|
|
id: self.token.id.clone(),
|
|
kind: "agent".into(),
|
|
label: self.token.name.clone(),
|
|
}
|
|
}
|
|
|
|
/// Whether the token has `scope`.
|
|
pub fn may(&self, scope: Scope) -> bool {
|
|
self.token.scopes.contains(&scope)
|
|
}
|
|
}
|
|
|
|
/// The caller of a request, as the door left it.
|
|
pub fn caller(context: &RequestContext<RoleServer>) -> Option<Caller> {
|
|
context
|
|
.extensions
|
|
.get::<axum::http::request::Parts>()
|
|
.and_then(|parts| parts.extensions.get::<Caller>())
|
|
.cloned()
|
|
}
|
|
|
|
/// The client program, as it named itself at `initialize`.
|
|
fn client(context: &RequestContext<RoleServer>) -> Option<Client> {
|
|
context.peer.peer_info().map(|info| Client {
|
|
name: info.client_info.name.clone(),
|
|
version: info.client_info.version.clone(),
|
|
})
|
|
}
|
|
|
|
fn request_id(context: &RequestContext<RoleServer>) -> String {
|
|
match &context.id {
|
|
rmcp::model::NumberOrString::Number(n) => n.to_string(),
|
|
rmcp::model::NumberOrString::String(s) => s.to_string(),
|
|
}
|
|
}
|
|
|
|
/// One call on its way through: who, what, and the log it goes into.
|
|
#[derive(Clone)]
|
|
pub(crate) struct Call {
|
|
log: Arc<AccessLog>,
|
|
caller: Option<Caller>,
|
|
client: Option<Client>,
|
|
request_id: String,
|
|
action: &'static str,
|
|
subject: String,
|
|
args_digest: Option<String>,
|
|
agent: Option<ledger_store::access::Agent>,
|
|
}
|
|
|
|
/// Why a call wasn't run, for its record and its answer.
|
|
pub(crate) const UNAUTHORIZED: &str = "unauthorized";
|
|
/// The token lacks the scope.
|
|
pub(crate) const INSUFFICIENT_SCOPE: &str = "insufficient_scope";
|
|
/// The books were closing.
|
|
pub(crate) const CLOSING: &str = "closing";
|
|
/// The server had stopped.
|
|
pub(crate) const STOPPED: &str = "stopped";
|
|
/// A file's bytes or text came as an argument (D-46).
|
|
pub(crate) const INLINE_FILE: &str = "inline_file";
|
|
|
|
/// What a refused call is told when its record couldn't be kept, or a
|
|
/// call that ran is told instead of its answer (INVARIANT 3).
|
|
pub(crate) const NOT_RECORDED: &str = "this call's access record couldn't be written, so the books server stopped taking requests, to keep every access on record: ask the human to restart it";
|
|
/// The same, for a call that ran.
|
|
pub(crate) const RAN_NOT_RECORDED: &str = "this call ran, but its access record couldn't be written, so the books server stopped taking requests, to keep every access on record: ask the human to restart it, then check whether the call's change is in the books before trying again";
|
|
|
|
impl Call {
|
|
/// A call to `subject` (a tool's name, a resource's address). Its
|
|
/// arguments' digest is added later ([`Call::digested`]): hashing a
|
|
/// large call's arguments waits until it is counted as running, so a
|
|
/// close beginning meanwhile waits for it.
|
|
pub(crate) fn new(
|
|
log: &Arc<AccessLog>,
|
|
context: &RequestContext<RoleServer>,
|
|
action: &'static str,
|
|
subject: &str,
|
|
) -> Self {
|
|
Self {
|
|
log: log.clone(),
|
|
caller: caller(context),
|
|
client: client(context),
|
|
request_id: request_id(context),
|
|
action,
|
|
subject: subject.to_string(),
|
|
args_digest: None,
|
|
agent: None,
|
|
}
|
|
}
|
|
|
|
/// The digest of a call's arguments (blocking work: a call's
|
|
/// arguments may be megabytes).
|
|
pub(crate) fn digest_of(
|
|
args: Option<&serde_json::Map<String, serde_json::Value>>,
|
|
) -> Option<String> {
|
|
args.map(|a| digest(&serde_json::Value::Object(a.clone())))
|
|
}
|
|
|
|
/// This call, with its arguments' digest.
|
|
pub(crate) fn digested(&mut self, args_digest: Option<String>) {
|
|
self.args_digest = args_digest;
|
|
}
|
|
|
|
/// This call, with what the agent said about itself (`books.start`).
|
|
pub(crate) fn with_agent(&mut self, agent: Option<ledger_store::access::Agent>) {
|
|
self.agent = agent;
|
|
}
|
|
|
|
/// The client program's name, as it named itself at `initialize`.
|
|
pub(crate) fn client_name(&self) -> Option<String> {
|
|
self.client.as_ref().map(|c| c.name.clone())
|
|
}
|
|
|
|
/// The call's caller, if the door named one.
|
|
pub(crate) fn caller(&self) -> Option<&Caller> {
|
|
self.caller.as_ref()
|
|
}
|
|
|
|
/// Record the call's outcome, on a blocking thread; Err only if it
|
|
/// couldn't be written (INVARIANT 3).
|
|
pub(crate) async fn record(&self, result: Outcome, head_after: Option<Head>) -> Result<(), ()> {
|
|
let me = self.clone();
|
|
tokio::task::spawn_blocking(move || me.record_blocking(result, head_after))
|
|
.await
|
|
.unwrap_or(Err(()))
|
|
}
|
|
|
|
/// [`Call::record`], on this thread (one that may block).
|
|
pub(crate) fn record_blocking(
|
|
&self,
|
|
result: Outcome,
|
|
head_after: Option<Head>,
|
|
) -> Result<(), ()> {
|
|
let access = Access {
|
|
principal: self
|
|
.caller
|
|
.as_ref()
|
|
.map_or_else(Principal::unknown, Caller::principal),
|
|
client: self.client.clone(),
|
|
request_id: Some(self.request_id.clone()),
|
|
action: self.action.to_string(),
|
|
subject: self.subject.clone(),
|
|
args_digest: self.args_digest.clone(),
|
|
result,
|
|
head_after,
|
|
agent: self.agent.clone(),
|
|
};
|
|
match self.log.record(&access) {
|
|
Ok(_) => Ok(()),
|
|
Err(e) => {
|
|
eprintln!("wanderbooks server: an access record couldn't be written: {e}");
|
|
Err(())
|
|
}
|
|
}
|
|
}
|
|
}
|