Watch
1
0
Fork
You've already forked souveraine
0

sessiond: make the verb surface enumerable and its refusals branchable

This commit is contained in:
Fimeg 2026-07-26 13:00:58 -04:00
commit 18bd4a0974
2 changed files with 233 additions and 14 deletions

View file

@ -40,9 +40,175 @@ pub const DEFAULT_PAM_SERVICE: &str = "souveraine-sessiond";
/// before deciding the shell is broken and taking the lock back.
pub const LOCKED_ACK_TIMEOUT_SECS: u64 = 15;
/// Why a request was refused, in a form a caller can branch on.
///
/// Refusals were prose. A human reads "a live shell owns the session lock" and
/// knows what to do; an agent composing several verbs into one intent
/// (doctrine §13) cannot branch on a sentence. The reason stays — it is the
/// only thing that explains *this* refusal rather than its class — but the
/// code is what a chain reads.
///
/// The taxonomy is deliberately small. RedFlag's executor exit codes are the
/// precedent (`NET_LAYER_PLAN.md` cites them for the same reason): a fixed
/// vocabulary a caller can exhaust, not a growing list it must keep up with.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum RefusalCode {
/// The op does not exist, or the line did not parse as a request.
/// Retrying is pointless; the caller is speaking a vocabulary this daemon
/// does not have. `describe` is the cure.
UnsupportedOp,
/// The op exists and the arguments are wrong — out of range, mismatched,
/// or self-contradictory. Retrying with the same arguments is pointless.
InvalidArgument,
/// The op and arguments are fine and the machine's current state forbids
/// it. This is the one a chain most often wants: it means *not now*, and
/// the state that forbade it is worth reading before deciding what next.
RefusedByState,
/// The caller may not do this. Reserved — nothing issues it yet, because
/// caller identity is uid 1000 for everything (audit P3). It exists so
/// that when capability tokens land, callers already branch on it.
NotPermitted,
/// A dependency this op needs is absent — no compositor, no PAM stack, no
/// sensor. Not the caller's fault and possibly transient.
Unavailable,
}
impl RefusalCode {
pub fn as_str(self) -> &'static str {
match self {
RefusalCode::UnsupportedOp => "unsupported_op",
RefusalCode::InvalidArgument => "invalid_argument",
RefusalCode::RefusedByState => "refused_by_state",
RefusalCode::NotPermitted => "not_permitted",
RefusalCode::Unavailable => "unavailable",
}
}
}
/// One entry in the verb table `describe` returns.
///
/// Serialize-only: it is a const table on this side of the wire and JSON on the
/// other, so the borrowed `&'static` fields never need to come back.
#[derive(Debug, Clone, Serialize)]
pub struct VerbDoc {
/// The `op` string, exactly as it goes on the wire.
pub op: &'static str,
/// True when the op changes something. A caller planning a chain needs to
/// know which steps are reversible reads and which are not.
pub mutates: bool,
/// What it does, in one line.
pub summary: &'static str,
/// The refusal codes this op can return. A chain plans its branches from
/// this rather than discovering them by being refused.
pub refuses: &'static [RefusalCode],
/// A minimal request line that parses. Knowing an op exists is not enough
/// to call it — `panel` needs `on`, `sensor_input` needs a source and a
/// matching value — and a caller should not have to discover required
/// fields by being refused. The test round-trips every one of these, so an
/// example that stops parsing fails the build rather than the agent.
pub example: &'static str,
}
/// The verb table. Every `Request` variant appears here; a test enforces it.
///
/// This exists because the surface was not enumerable. An agent that owns the
/// device (doctrine §13) has to be able to ask what it may do rather than be
/// told, and a verb that is missing from this table is as unreachable as one
/// that was never written.
pub const VERBS: &[VerbDoc] = &[
VerbDoc {
op: "describe",
mutates: false,
summary: "this table — the verbs, which ones mutate, how each can refuse",
refuses: &[],
example: r#"{"op":"describe"}"#,
},
VerbDoc {
op: "status",
mutates: false,
summary: "liveness and who currently owns the session lock",
refuses: &[],
example: r#"{"op":"status"}"#,
},
VerbDoc {
op: "shell_ready",
mutates: true,
summary: "the shell announces itself; this connection becomes the heartbeat",
refuses: &[RefusalCode::RefusedByState],
example: r#"{"op":"shell_ready"}"#,
},
VerbDoc {
op: "locked_ack",
mutates: true,
summary: "the shell's lock surface reached compositor-acknowledged secure",
refuses: &[RefusalCode::RefusedByState],
example: r#"{"op":"locked_ack"}"#,
},
VerbDoc {
op: "lock",
mutates: true,
summary: "sessiond takes the session lock itself; refused while a live shell owns it",
refuses: &[RefusalCode::RefusedByState, RefusalCode::Unavailable],
example: r#"{"op":"lock"}"#,
},
VerbDoc {
op: "device_state",
mutates: false,
summary: "the unified state machine: state, panel, evidence, confidence, health",
refuses: &[],
example: r#"{"op":"device_state"}"#,
},
VerbDoc {
op: "sensor_input",
mutates: true,
summary: "report a sensor reading as evidence; never an authority (doctrine §9)",
refuses: &[RefusalCode::InvalidArgument],
example: r#"{"op":"sensor_input","source":"proximity","value":{"near":true}}"#,
},
VerbDoc {
op: "input",
mutates: true,
summary: "real user input happened; resets the idle budget",
refuses: &[],
example: r#"{"op":"input","trigger":"touch"}"#,
},
VerbDoc {
op: "panel",
mutates: true,
summary: "the DPMS executor reports the panel's real power state",
refuses: &[],
example: r#"{"op":"panel","on":false}"#,
},
VerbDoc {
op: "forensic_log",
mutates: false,
summary: "recent trail entries from the in-memory buffer",
refuses: &[],
example: r#"{"op":"forensic_log","count":50}"#,
},
VerbDoc {
op: "get_policy",
mutates: false,
summary: "read the timed policy — the Auto-Lock shaped settings",
refuses: &[],
example: r#"{"op":"get_policy"}"#,
},
VerbDoc {
op: "set_policy",
mutates: true,
summary: "change the timed policy; persisted, so a setting survives restart",
refuses: &[RefusalCode::InvalidArgument, RefusalCode::Unavailable],
example: r#"{"op":"set_policy","lock_blank_after_secs":15}"#,
},
];
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(tag = "op", rename_all = "snake_case")]
pub enum Request {
/// The verb table — what this daemon can be asked, which ops mutate, and
/// how each can refuse. A caller composing verbs reads this first.
Describe,
/// Liveness + who currently owns the session lock.
Status,
/// The shell announces itself. The connection carrying this request
@ -179,6 +345,41 @@ mod tests {
assert!(matches!(req, Request::Status));
}
#[test]
fn every_request_variant_is_in_the_verb_table() {
// The table is the vocabulary an agent composes over (doctrine §13).
// A verb missing from it is as unreachable as one never written, so
// this test is the thing that keeps them from drifting apart.
//
// Checked by round-tripping each op string through the deserializer:
// if `describe` advertises an op the daemon cannot parse, the entry is
// a lie, and if a variant is added without a table entry the count
// below fails.
for v in VERBS {
let parsed: Result<Request, _> = serde_json::from_str(v.example);
assert!(
parsed.is_ok(),
"describe advertises `{}` with example `{}`, which the daemon cannot parse: {:?}",
v.op,
v.example,
parsed.err()
);
}
// And the other direction: every variant must be advertised. Bump this
// deliberately when a verb is added, having added its VerbDoc.
assert_eq!(VERBS.len(), 12, "a Request variant was added without a VerbDoc");
}
#[test]
fn a_refusal_code_survives_the_wire() {
// A chain branches on the code, so it has to arrive intact.
let raw = serde_json::to_string(&RefusalCode::RefusedByState).unwrap();
assert_eq!(raw, r#""refused_by_state""#);
let back: RefusalCode = serde_json::from_str(&raw).unwrap();
assert_eq!(back, RefusalCode::RefusedByState);
}
#[test]
fn phase_serializes_snake_case() {
assert_eq!(

View file

@ -22,8 +22,8 @@ use crate::sessiond::idle;
use crate::sessiond::lock::{self, LockController, Msg, SessionOutcome};
use crate::sessiond::lockhint;
use crate::sessiond::protocol::{
InputTrigger, Phase, Request, SensorSource, SensorValue, LOCKED_ACK_TIMEOUT_SECS,
MAX_REQUEST_BYTES,
InputTrigger, Phase, RefusalCode, Request, SensorSource, SensorValue, LOCKED_ACK_TIMEOUT_SECS,
MAX_REQUEST_BYTES, VERBS,
};
/// How often the machine's clock runs. One second is fine: the shortest
@ -412,8 +412,14 @@ fn start_ack_timer(shared: &Arc<Shared>, gen: u64) {
});
}
fn refusal(reason: &str) -> serde_json::Value {
serde_json::json!({ "ok": false, "reason": reason })
/// A refusal a caller can branch on.
///
/// `code` is the class, `reason` is this particular one. Doctrine §13: an
/// agent composing verbs needs to know *which kind* of no it got — not now,
/// never, or wrong arguments are three different next moves and a sentence
/// does not distinguish them.
fn refuse(code: RefusalCode, reason: &str) -> serde_json::Value {
serde_json::json!({ "ok": false, "code": code.as_str(), "reason": reason })
}
fn respond(writer: &mut UnixStream, value: serde_json::Value) -> std::io::Result<()> {
@ -447,7 +453,7 @@ fn handle_connection(stream: UnixStream, shared: Arc<Shared>) {
}
}
if line.len() as u64 >= MAX_REQUEST_BYTES {
let _ = respond(&mut writer, refusal("request exceeds size limit"));
let _ = respond(&mut writer, refuse(RefusalCode::InvalidArgument, "request exceeds size limit"));
break;
}
let line = line.trim();
@ -456,7 +462,7 @@ fn handle_connection(stream: UnixStream, shared: Arc<Shared>) {
}
let response = match serde_json::from_str::<Request>(line) {
Ok(req) => handle_request(req, &shared, Some(&writer), &mut heartbeat),
Err(e) => refusal(&format!("malformed request: {e}")),
Err(e) => refuse(RefusalCode::UnsupportedOp, &format!("malformed request: {e}")),
};
if respond(&mut writer, response).is_err() {
break;
@ -487,6 +493,18 @@ fn handle_request(
heartbeat: &mut Option<u64>,
) -> serde_json::Value {
match req {
Request::Describe => {
serde_json::json!({
"ok": true,
"verbs": VERBS.iter().map(|v| serde_json::json!({
"op": v.op,
"mutates": v.mutates,
"summary": v.summary,
"refuses": v.refuses.iter().map(|c| c.as_str()).collect::<Vec<_>>(),
"example": v.example,
})).collect::<Vec<_>>(),
})
}
Request::Status => {
let d = shared.lock();
serde_json::json!({
@ -505,7 +523,7 @@ fn handle_request(
// authority must disconnect first; its connection handler clears
// shell_alive before a replacement can register.
if d.shell_alive {
return refusal("shell authority is already registered");
return refuse(RefusalCode::RefusedByState, "shell authority is already registered");
}
d.heartbeat_gen += 1;
d.shell_alive = true;
@ -527,7 +545,7 @@ fn handle_request(
.wait_timeout_while(d, deadline, |d| d.controller.is_some())
.unwrap_or_else(|e| e.into_inner());
if timed_out.timed_out() {
return refusal("lock session did not release in time");
return refuse(RefusalCode::Unavailable, "lock session did not release in time");
}
info!("handoff: lock released to shell (gen {gen})");
serde_json::json!({ "ok": true, "held": true, "must_lock": true })
@ -542,7 +560,7 @@ fn handle_request(
Request::LockedAck => {
let mut d = shared.lock();
if heartbeat.is_none() {
return refusal("locked_ack from a connection that never sent shell_ready");
return refuse(RefusalCode::RefusedByState, "locked_ack from a connection that never sent shell_ready");
}
d.phase = Phase::Released;
info!("shell lock confirmed");
@ -564,14 +582,14 @@ fn handle_request(
match d.phase {
Phase::Holding => serde_json::json!({ "ok": true, "already": true }),
Phase::Released | Phase::AwaitingShellLock if d.shell_alive => {
refusal("a live shell owns the session lock; use the shell's lock IPC")
refuse(RefusalCode::RefusedByState, "a live shell owns the session lock; use the shell's lock IPC")
}
_ => {
drop(d);
if spawn_lock_session(shared) {
serde_json::json!({ "ok": true })
} else {
refusal("could not start a lock session")
refuse(RefusalCode::Unavailable, "could not start a lock session")
}
}
}
@ -601,7 +619,7 @@ fn handle_request(
evidence.touch_active = *v;
}
_ => {
return refusal("sensor source/value mismatch");
return refuse(RefusalCode::InvalidArgument, "sensor source/value mismatch");
}
}
let conf = evidence.confidence();
@ -716,7 +734,7 @@ fn handle_request(
if let Some(v) = lock_ack_budget_secs {
if v == 0 {
drop(d);
return refusal("lock_ack_budget_secs must be at least 1");
return refuse(RefusalCode::InvalidArgument, "lock_ack_budget_secs must be at least 1");
}
p.lock_ack_budget = Duration::from_secs(v);
}
@ -730,7 +748,7 @@ fn handle_request(
if let Some(budget) = p.lock_blank_after {
if p.dim_warning && p.dim_grace >= budget {
drop(d);
return refusal("dim_grace_secs must be shorter than lock_blank_after_secs");
return refuse(RefusalCode::InvalidArgument, "dim_grace_secs must be shorter than lock_blank_after_secs");
}
}
let applied = serde_json::json!({