Watch
1
0
Fork
You've already forked souveraine
0

sessiond: carry the caller's intent into the trail

This commit is contained in:
Fimeg 2026-07-26 14:38:40 -04:00
commit 43eee1e3c5
3 changed files with 123 additions and 4 deletions

View file

@ -402,6 +402,16 @@ pub struct ForensicEntry {
pub snapshot: StateSnapshot,
/// Why this decision was made (human-readable).
pub reason: String,
/// The caller's declared intent, when one was given.
///
/// A chain is one decision and several verbs (doctrine §13). Without this
/// the trail records the leaves and loses the thing that produced them —
/// reconstruction sees four unrelated calls and cannot tell a considered
/// sequence from four accidents. Skipped entirely when absent, so entries
/// without an intent serialize byte-for-byte as they did before and the
/// hash contract does not move.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub intent: Option<String>,
}
/// What kind of forensic event occurred.
@ -508,6 +518,11 @@ struct TrailWriter {
/// the rotation rules can be tested at a few hundred bytes instead of by
/// writing 12 MiB of real entries.
max_bytes: u64,
/// The intent the current caller declared, stamped onto every entry
/// produced while it is set. Lives here because `append` already takes
/// this lock — anywhere else would need threading through every call site
/// that records anything.
intent: Option<String>,
/// True while writes are failing, so the warning is one per outage edge
/// instead of one per tick. A trail that cannot write is exactly the kind
/// of failure that must not drown out what it was recording.
@ -599,6 +614,7 @@ impl ForensicLog {
event,
snapshot,
reason: reason.to_string(),
intent: w.intent.clone(),
};
w.next_seq += 1;
w.write(&entry);
@ -614,6 +630,13 @@ impl ForensicLog {
}
}
/// Declare what the caller is doing. Every entry recorded until this is
/// cleared carries it. Set around one request, not held across them — an
/// intent that outlives its chain mislabels whatever comes next.
pub fn set_intent(&self, intent: Option<String>) {
self.writer.lock().unwrap_or_else(|e| e.into_inner()).intent = intent;
}
/// Get recent entries (for IPC queries).
pub fn recent(&self, count: usize) -> Vec<ForensicEntry> {
let entries = self.entries.lock().unwrap_or_else(|e| e.into_inner());
@ -630,6 +653,7 @@ impl TrailWriter {
last_hash: String::new(),
bytes: 0,
max_bytes: FORENSIC_MAX_BYTES,
intent: None,
failed: false,
}
}
@ -642,6 +666,7 @@ impl TrailWriter {
last_hash: String::new(),
bytes: 0,
max_bytes,
intent: None,
failed: false,
};
@ -3266,6 +3291,54 @@ mod tests {
assert_eq!(fresh[0].1, "", "and does not claim a predecessor it cannot verify");
}
#[test]
fn an_intent_stamps_every_entry_it_covers_and_no_others() {
// The point of the field: a chain is one decision and several verbs,
// and a trail that records only the leaves cannot tell a considered
// sequence from four accidents.
let log = ForensicLog::new();
let snap = a_snapshot();
log.append(ForensicEvent::Heartbeat, snap.clone(), "before");
log.set_intent(Some("quiet the room".into()));
log.append(ForensicEvent::Heartbeat, snap.clone(), "during");
log.append(ForensicEvent::Heartbeat, snap.clone(), "still during");
log.set_intent(None);
log.append(ForensicEvent::Heartbeat, snap, "after");
let got: Vec<_> = log
.recent(10)
.into_iter()
.map(|e| (e.reason, e.intent))
.collect();
assert_eq!(got[0].1, None, "entries before the intent are untouched");
assert_eq!(got[1].1.as_deref(), Some("quiet the room"));
assert_eq!(got[2].1.as_deref(), Some("quiet the room"));
assert_eq!(
got[3].1, None,
"an intent that outlived its request would mislabel whatever came next"
);
}
#[test]
fn an_entry_without_an_intent_serializes_exactly_as_it_did_before() {
// `skip_serializing_if` is what keeps the hash contract from moving:
// an entry with no intent must not gain an `"intent":null`, or every
// line written before today would fail verification against the code
// that wrote it.
let entry = ForensicEntry {
seq: 0,
prev: String::new(),
ts: 0,
event: ForensicEvent::Heartbeat,
snapshot: a_snapshot(),
reason: "x".into(),
intent: None,
};
let json = serde_json::to_string(&entry).expect("serializes");
assert!(!json.contains("intent"), "absent must mean absent: {json}");
}
#[test]
fn a_trail_with_nowhere_to_write_still_answers_ipc() {
// Memory-only is the honest answer when there is no home; it must not

View file

@ -203,6 +203,25 @@ pub const VERBS: &[VerbDoc] = &[
},
];
/// A request plus the caller's declared intent.
///
/// The wire shape is unchanged — `{"op":"panel","on":false}` still parses,
/// because `intent` is optional and flattened alongside the tagged enum. A
/// caller composing several verbs into one decision (doctrine §13) sets it
/// once per verb and the trail joins the leaves back to the intent that
/// produced them: `{"op":"panel","on":false,"intent":"quiet the room"}`.
///
/// Declared, never verified. It is a label the caller supplies about itself,
/// so it is evidence in exactly the sense doctrine §9 means — useful for
/// reconstruction, never a basis for a decision. Nothing branches on it.
#[derive(Debug, Clone, Deserialize)]
pub struct Envelope {
#[serde(flatten)]
pub request: Request,
#[serde(default)]
pub intent: Option<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(tag = "op", rename_all = "snake_case")]
pub enum Request {
@ -371,6 +390,21 @@ mod tests {
assert_eq!(VERBS.len(), 12, "a Request variant was added without a VerbDoc");
}
#[test]
fn the_envelope_is_backward_compatible() {
// Every line written before intent existed must still parse, or the
// shell and the reporters stop talking to the daemon on upgrade —
// TASK-28's failure mode, caused by the fix for it.
let e: Envelope = serde_json::from_str(r#"{"op":"status"}"#).unwrap();
assert!(matches!(e.request, Request::Status));
assert_eq!(e.intent, None);
let e: Envelope =
serde_json::from_str(r#"{"op":"panel","on":false,"intent":"quiet the room"}"#).unwrap();
assert!(matches!(e.request, Request::Panel { on: false }));
assert_eq!(e.intent.as_deref(), Some("quiet the room"));
}
#[test]
fn a_refusal_code_survives_the_wire() {
// A chain branches on the code, so it has to arrive intact.

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, RefusalCode, Request, SensorSource, SensorValue, LOCKED_ACK_TIMEOUT_SECS,
MAX_REQUEST_BYTES, VERBS,
Envelope, 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
@ -519,8 +519,20 @@ fn handle_connection(stream: UnixStream, shared: Arc<Shared>) {
if line.is_empty() {
continue;
}
let response = match serde_json::from_str::<Request>(line) {
Ok(req) => handle_request(req, &shared, Some(&writer), &mut heartbeat),
let response = match serde_json::from_str::<Envelope>(line) {
Ok(env) => {
// The intent is set for exactly this request and cleared after,
// so an entry recorded by the clock thread between two requests
// is never mislabelled with a chain it had nothing to do with.
shared
.lock()
.device_state
.forensic
.set_intent(env.intent.clone());
let out = handle_request(env.request, &shared, Some(&writer), &mut heartbeat);
shared.lock().device_state.forensic.set_intent(None);
out
}
Err(e) => refuse(RefusalCode::UnsupportedOp, &format!("malformed request: {e}")),
};
if respond(&mut writer, response).is_err() {