diff --git a/Cargo.lock b/Cargo.lock index 553f5a3..86b9cff 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -255,6 +255,15 @@ dependencies = [ "serde", ] +[[package]] +name = "logline-pocket-runtime" +version = "0.1.0" +dependencies = [ + "logline-who", + "serde", + "serde_json", +] + [[package]] name = "logline-status" version = "0.1.0" diff --git a/Cargo.toml b/Cargo.toml index 4b50841..5685be1 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -11,6 +11,7 @@ members = [ "crates/if_not", "crates/status", "crates/logline-cli", + "crates/logline-pocket-runtime", ] [workspace.package] diff --git a/crates/logline-pocket-runtime/Cargo.toml b/crates/logline-pocket-runtime/Cargo.toml new file mode 100644 index 0000000..1744063 --- /dev/null +++ b/crates/logline-pocket-runtime/Cargo.toml @@ -0,0 +1,18 @@ +[package] +name = "logline-pocket-runtime" +version.workspace = true +edition.workspace = true +license.workspace = true +description = "Pocket Runtime guard for LLM translation drafts. Reduces entropy before canon. Does not become canon. Does not emit canonical receipts. Does not admit runtime actions. (LIP-0008)" + +[lib] +name = "logline_pocket_runtime" +path = "src/lib.rs" + +[dependencies] +logline-who = { path = "../who" } +serde.workspace = true +serde_json.workspace = true + +[lints] +workspace = true diff --git a/crates/logline-pocket-runtime/src/lib.rs b/crates/logline-pocket-runtime/src/lib.rs new file mode 100644 index 0000000..e85c58d --- /dev/null +++ b/crates/logline-pocket-runtime/src/lib.rs @@ -0,0 +1,270 @@ +//! LogLine Pocket Runtime — small guard beside the LLM Translator. +//! +//! The Pocket Runtime sits **before** canon. Its single talent is to reduce +//! the entropy of a translation draft enough that downstream canon emission +//! does not have to invent shape. It does **not** emit canonical receipts, +//! it does **not** admit runtime actions, it does **not** execute, and it +//! does **not** call any LLM. +//! +//! See `LogLine-Foundation/governance/lips/LIP-0008-llm-tier-discipline-and-dossier-discipline.md` +//! for the doctrine that motivates this crate. +//! +//! # Three outcomes +//! +//! - [`PocketRuntimeRuling::AcceptCandidate`] — all nine slots are present, +//! non-empty strings, free of obviously hostile content, and pass the +//! shared `validate_shape` from `logline-who`. The normalized draft is +//! returned for the next stage (canon emission). +//! +//! - [`PocketRuntimeRuling::Ghost`] — one or more required slots are +//! missing or empty. The partial draft is preserved so the caller (UI, +//! Translator session loop, etc.) can ask for clarification without +//! losing what was provided. +//! +//! - [`PocketRuntimeRuling::Reject`] — slots are present but malformed +//! (wrong JSON type, forbidden characters, oversized, or rejected by +//! the shared `validate_shape`). The Reject is structured per-slot so +//! the caller can render a precise correction request. +//! +//! `aux` is preserved across all three outcomes and never affects the +//! nine-slot decision. + +use serde::{Deserialize, Serialize}; +use serde_json::Value; + +use logline_who::{validate_shape, LogLine}; + +/// Pocket Runtime soft length cap for a single slot value. Slots over this +/// limit are rejected; canon emission would also choke on them, and the +/// Pocket Runtime exists precisely to refuse such drafts cheaply. +pub const MAX_SLOT_LEN: usize = 4096; + +/// Names of the nine canonical slots, in canonical order. +pub const SLOTS: [&str; 9] = [ + "who", + "did", + "this", + "when", + "confirmed_by", + "if_ok", + "if_doubt", + "if_not", + "status", +]; + +/// A candidate LogLine draft as the LLM Translator might emit it. Each +/// slot is `Option` because partial drafts are first-class; the +/// Pocket Runtime decides whether the draft can move on, must ghost, or +/// must be rejected. +#[derive(Clone, Debug, Default, Eq, PartialEq, Serialize, Deserialize)] +pub struct LogLineDraft { + pub who: Option, + pub did: Option, + pub this: Option, + pub when: Option, + pub confirmed_by: Option, + pub if_ok: Option, + pub if_doubt: Option, + pub if_not: Option, + pub status: Option, + /// Free-form auxiliary fields the Translator may have inferred. Never + /// affects the nine-slot decision; preserved verbatim across all + /// outcomes. + pub aux: Option, +} + +/// Non-fatal observation about a slot value that was accepted after a +/// normalizing step (e.g. surrounding whitespace trimmed away). +#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)] +pub struct PocketWarning { + pub slot: String, + pub kind: String, + pub detail: String, +} + +/// A slot whose absence prevented acceptance. The caller may treat the +/// set of ghosts as a clarification request to the human or the +/// Translator. +#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)] +pub struct PocketGhost { + pub slot: String, + pub reason: String, +} + +/// A slot whose presence violates a structural rule (wrong type, +/// forbidden character, oversized, shape rejected by `validate_shape`). +/// Carrying the slot name and the offence keeps the Reject directly +/// actionable. +#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)] +pub struct PocketError { + pub slot: String, + pub kind: String, + pub detail: String, +} + +/// The Pocket Runtime's decision about a draft. Always one of three; +/// never a free-form string. +#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)] +#[serde(tag = "ruling", rename_all = "snake_case")] +pub enum PocketRuntimeRuling { + /// All nine slots are present and pass shape and forbidden-char + /// checks. The Translator (or any caller) may proceed to canon + /// emission with `normalized`. + AcceptCandidate { + normalized: LogLineDraft, + warnings: Vec, + }, + /// One or more required slots are missing or empty. The draft cannot + /// proceed to canon, but the partial slots are returned so the + /// caller can refine the request without losing context. + Ghost { + ghosts: Vec, + partial: LogLineDraft, + }, + /// One or more slots are present but malformed. The draft is + /// rejected; the caller must produce a new draft. Errors enumerate + /// every offending slot rather than failing on the first. + Reject { errors: Vec }, +} + +/// Pocket Runtime entry point. Pure function; no I/O, no LLM call, no +/// canon emission, no admission decision. +pub fn check(draft: LogLineDraft) -> PocketRuntimeRuling { + let mut ghosts: Vec = Vec::new(); + let mut errors: Vec = Vec::new(); + let warnings: Vec = Vec::new(); + + // Per-slot pass. Collect every issue rather than fail on first. + let raw: [(&'static str, &Option); 9] = [ + ("who", &draft.who), + ("did", &draft.did), + ("this", &draft.this), + ("when", &draft.when), + ("confirmed_by", &draft.confirmed_by), + ("if_ok", &draft.if_ok), + ("if_doubt", &draft.if_doubt), + ("if_not", &draft.if_not), + ("status", &draft.status), + ]; + + // Strings extracted slot-by-slot (only populated when the slot passed + // the per-slot pass cleanly). Used to construct the canonical LogLine + // for `validate_shape` once every slot is good. + let mut strings: [String; 9] = Default::default(); + + for (i, (slot, val)) in raw.iter().enumerate() { + match val { + None => { + ghosts.push(PocketGhost { + slot: (*slot).to_string(), + reason: "slot_missing".to_string(), + }); + } + Some(Value::Null) => { + ghosts.push(PocketGhost { + slot: (*slot).to_string(), + reason: "slot_null".to_string(), + }); + } + Some(Value::String(s)) => { + if s.trim().is_empty() { + ghosts.push(PocketGhost { + slot: (*slot).to_string(), + reason: "slot_empty_string".to_string(), + }); + } else if has_forbidden_chars(s) { + errors.push(PocketError { + slot: (*slot).to_string(), + kind: "forbidden_char".to_string(), + detail: "slot value contains a null byte or unescaped control character" + .to_string(), + }); + } else if s.len() > MAX_SLOT_LEN { + errors.push(PocketError { + slot: (*slot).to_string(), + kind: "oversized".to_string(), + detail: format!( + "slot value exceeds {} chars (got {})", + MAX_SLOT_LEN, + s.len() + ), + }); + } else { + strings[i] = s.clone(); + } + } + Some(other) => { + errors.push(PocketError { + slot: (*slot).to_string(), + kind: "wrong_type".to_string(), + detail: format!("slot must be a JSON string, got {}", json_type_name(other)), + }); + } + } + } + + // Decision precedence: Reject > Ghost > AcceptCandidate. + // Errors (malformed values) win over ghosts (missing values) because + // a malformed slot signals an upstream bug, not just under-specified + // intent. + if !errors.is_empty() { + return PocketRuntimeRuling::Reject { errors }; + } + if !ghosts.is_empty() { + return PocketRuntimeRuling::Ghost { + ghosts, + partial: draft, + }; + } + + // All nine slots are non-empty strings free of forbidden content. + // Construct the canonical LogLine and ask the shared validator to + // double-check shape. `validate_shape` today re-checks non-empty, + // but it is the authoritative shape gate; if it ever tightens, the + // Pocket Runtime inherits the tightening for free. + let logline = LogLine::new( + &strings[0], + &strings[1], + &strings[2], + &strings[3], + &strings[4], + &strings[5], + &strings[6], + &strings[7], + &strings[8], + ); + if let Err(e) = validate_shape(&logline) { + return PocketRuntimeRuling::Reject { + errors: vec![PocketError { + slot: "(shape)".to_string(), + kind: "shape_invalid".to_string(), + detail: e.to_string(), + }], + }; + } + + PocketRuntimeRuling::AcceptCandidate { + normalized: draft, + warnings, + } +} + +/// Forbidden characters in a slot string value. The list is intentionally +/// short and conservative: control bytes that downstream canon, JCS +/// canonicalization, or transport encoders would have to either escape or +/// reject. The Pocket Runtime refuses them cheaply at the door. +fn has_forbidden_chars(s: &str) -> bool { + s.chars() + .any(|c| c == '\0' || c == '\n' || c == '\r' || c == '\t' || c.is_control()) +} + +fn json_type_name(v: &Value) -> &'static str { + match v { + Value::Null => "null", + Value::Bool(_) => "bool", + Value::Number(_) => "number", + Value::String(_) => "string", + Value::Array(_) => "array", + Value::Object(_) => "object", + } +} diff --git a/crates/logline-pocket-runtime/tests/pocket_runtime.rs b/crates/logline-pocket-runtime/tests/pocket_runtime.rs new file mode 100644 index 0000000..f654761 --- /dev/null +++ b/crates/logline-pocket-runtime/tests/pocket_runtime.rs @@ -0,0 +1,176 @@ +//! LogLine Pocket Runtime — outcome tests for `check`. +//! +//! These tests pin the three-outcome contract (AcceptCandidate / Ghost / +//! Reject), the precedence rule (Reject > Ghost > Accept), and the aux +//! preservation rule. + +use logline_pocket_runtime::{check, LogLineDraft, PocketRuntimeRuling}; +use serde_json::{json, Value}; + +fn s(v: &str) -> Option { + Some(Value::String(v.to_string())) +} + +fn complete_valid_draft() -> LogLineDraft { + LogLineDraft { + who: s("dan"), + did: s("update_host_runtime"), + this: s("lab512"), + when: s("2026-05-18T15:00:00Z"), + confirmed_by: s("dan"), + if_ok: s("emit_logline"), + if_doubt: s("ghost_and_clarify"), + if_not: s("refuse"), + status: s("candidate"), + aux: None, + } +} + +#[test] +fn complete_valid_draft_accepts_candidate() { + let ruling = check(complete_valid_draft()); + match ruling { + PocketRuntimeRuling::AcceptCandidate { + normalized, + warnings, + } => { + assert!(warnings.is_empty(), "no warnings expected on clean draft"); + assert_eq!(normalized.who, s("dan")); + assert_eq!(normalized.status, s("candidate")); + assert!(normalized.aux.is_none()); + } + other => panic!("expected AcceptCandidate, got {other:?}"), + } +} + +#[test] +fn missing_when_ghosts() { + let mut draft = complete_valid_draft(); + draft.when = None; + let ruling = check(draft); + match ruling { + PocketRuntimeRuling::Ghost { ghosts, partial } => { + assert_eq!(ghosts.len(), 1); + assert_eq!(ghosts[0].slot, "when"); + assert_eq!(ghosts[0].reason, "slot_missing"); + // Partial preserves what the caller did provide. + assert_eq!(partial.who, s("dan")); + assert!(partial.when.is_none()); + } + other => panic!("expected Ghost, got {other:?}"), + } +} + +#[test] +fn missing_if_doubt_ghosts() { + let mut draft = complete_valid_draft(); + draft.if_doubt = None; + let ruling = check(draft); + match ruling { + PocketRuntimeRuling::Ghost { ghosts, partial: _ } => { + assert_eq!(ghosts.len(), 1); + assert_eq!(ghosts[0].slot, "if_doubt"); + assert_eq!(ghosts[0].reason, "slot_missing"); + } + other => panic!("expected Ghost, got {other:?}"), + } +} + +#[test] +fn missing_multiple_slots_ghosts() { + let mut draft = complete_valid_draft(); + draft.when = None; + draft.if_ok = None; + draft.if_not = None; + let ruling = check(draft); + match ruling { + PocketRuntimeRuling::Ghost { ghosts, partial: _ } => { + let slots: Vec<&str> = ghosts.iter().map(|g| g.slot.as_str()).collect(); + assert!(slots.contains(&"when")); + assert!(slots.contains(&"if_ok")); + assert!(slots.contains(&"if_not")); + assert_eq!(ghosts.len(), 3, "all three missing slots must be reported"); + } + other => panic!("expected Ghost, got {other:?}"), + } +} + +#[test] +fn invalid_status_rejects() { + // Status contains an embedded newline — a forbidden control char. + let mut draft = complete_valid_draft(); + draft.status = Some(Value::String("candidate\ntampered".to_string())); + let ruling = check(draft); + match ruling { + PocketRuntimeRuling::Reject { errors } => { + assert_eq!(errors.len(), 1); + assert_eq!(errors[0].slot, "status"); + assert_eq!(errors[0].kind, "forbidden_char"); + } + other => panic!("expected Reject, got {other:?}"), + } +} + +#[test] +fn slot_validator_failure_rejects() { + // `did` is a number rather than a string — wrong JSON type. + let mut draft = complete_valid_draft(); + draft.did = Some(json!(42)); + let ruling = check(draft); + match ruling { + PocketRuntimeRuling::Reject { errors } => { + assert_eq!(errors.len(), 1); + assert_eq!(errors[0].slot, "did"); + assert_eq!(errors[0].kind, "wrong_type"); + assert!(errors[0].detail.contains("number")); + } + other => panic!("expected Reject, got {other:?}"), + } +} + +#[test] +fn aux_is_preserved_but_not_required() { + // aux = None on a complete draft → still AcceptCandidate. + let no_aux = check(complete_valid_draft()); + assert!(matches!( + no_aux, + PocketRuntimeRuling::AcceptCandidate { .. } + )); + + // aux = Some(rich object) preserved on AcceptCandidate. + let mut draft = complete_valid_draft(); + draft.aux = Some(json!({ + "duration_hours": 8, + "subjective_state": "rested", + "nested": { "context": "self_report", "tags": ["rest", "minilab"] } + })); + let ruling = check(draft); + match ruling { + PocketRuntimeRuling::AcceptCandidate { + normalized, + warnings: _, + } => { + let aux = normalized.aux.expect("aux must be preserved verbatim"); + assert_eq!(aux["duration_hours"], json!(8)); + assert_eq!(aux["nested"]["tags"], json!(["rest", "minilab"])); + } + other => panic!("expected AcceptCandidate with preserved aux, got {other:?}"), + } + + // aux as a non-object (string) does NOT affect 9-slot validity. + let mut draft = complete_valid_draft(); + draft.aux = Some(json!("free text aux")); + let ruling = check(draft); + assert!(matches!( + ruling, + PocketRuntimeRuling::AcceptCandidate { .. } + )); + + // Conversely: aux must not rescue a missing 9-slot. With aux present + // and `when` missing, the draft still Ghosts. + let mut draft = complete_valid_draft(); + draft.when = None; + draft.aux = Some(json!({"trying": "to rescue"})); + let ruling = check(draft); + assert!(matches!(ruling, PocketRuntimeRuling::Ghost { .. })); +}