sessiond: carry the caller's intent into the trail
This commit is contained in:
parent
c44deda7c2
commit
43eee1e3c5
3 changed files with 123 additions and 4 deletions
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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() {
|
||||
|
|
|
|||
Loading…
Reference in a new issue