diff --git a/crates/octoscript-ui-l0/src/lib.rs b/crates/octoscript-ui-l0/src/lib.rs index d1c4037..3af812f 100644 --- a/crates/octoscript-ui-l0/src/lib.rs +++ b/crates/octoscript-ui-l0/src/lib.rs @@ -1060,9 +1060,9 @@ enum Provenance { Vocabulary, /// Text the user wrote. Theirs to be wrong about. UserCopy, - /// Text the MODEL wrote. May not appear in a rendering position: this is - /// the class §4 exists to keep off the screen, because a model-authored - /// string is indistinguishable from a fact it invented. + /// Text the MODEL wrote. Admitted only in a text slot, where the kit marks + /// it AI-written so it cannot pass for a fact, and never anywhere it could + /// decide what an action does (§4.2). ModelCopy, } @@ -3488,6 +3488,12 @@ pub mod catalog { // One chat message. The side is meaning (who said it); the theme // decides what mine-vs-theirs looks like. ("Bubble", &[("text", Text), ("side", Token(BUBBLE_SIDE))]), + // One entry of an in-card chat (§5.15): a `sys.chat` row, drawn as a + // bubble on the side its ROLE says. Both arguments must read the same + // row — `ChatEntry(text: m.text, role: m.role)` — so the label the + // host gave a message can never be paired with another message's text. + // A `model` entry carries the AI-written mark. + ("ChatEntry", &[("text", Text), ("role", Path)]), // The floating round action button, pinned over the page corner. ("Fab", &[("name", Token(ICON))]), // The bottom tab bar and its tabs — pinned to the page floor. @@ -3624,6 +3630,50 @@ pub mod catalog { ), ]; + /// The arguments that DISPLAY text and nothing else — the only places text + /// the model wrote may appear (§4.2). + /// + /// Not every `text`-kind argument: a chip's label sits on a tap target, a + /// tile's label names a measurement, an avatar's text is initials, and a + /// `Kit` argument chooses a component. Model text in any of those would + /// make a model-chosen string part of a control. `Field.text` is here + /// because a draft the model wrote is shown in a field for the user to + /// edit; what the field COMMITS is user input. + pub const TEXT_SLOTS: &[(&str, &str)] = &[ + ("TextHero", "text"), + ("TextTitle", "text"), + ("TextBody", "text"), + ("TextRow", "text"), + ("TextEyebrow", "text"), + ("TextCaption", "text"), + ("Band", "text"), + ("Bubble", "text"), + ("ChatEntry", "text"), + ("Field", "text"), + ]; + + pub fn is_text_slot(ctor: &str, arg: &str) -> bool { + TEXT_SLOTS.iter().any(|(c, a)| *c == ctor && *a == arg) + } + + /// Host-source fields whose VALUE the model wrote (§4.2). The host answers + /// them, so they are not card-authored facts; they are the model's own + /// words, and they carry the same restrictions as `model-copy`. + /// + /// A `sys.chat` entry's `text` is listed whatever its role: the checker + /// cannot see a row's role, so every entry's text is treated as model text + /// for what it may DO, and `ChatEntry` reads the role to decide the mark. + pub const MODEL_TEXT: &[(&str, &[&str])] = &[ + ("sys.digest", &["summary", "text", "label"]), + ("sys.chat", &["text"]), + ]; + + pub fn is_model_text(helper: &str, field: &str) -> bool { + MODEL_TEXT + .iter() + .any(|(h, fields)| *h == helper && fields.contains(&field)) + } + pub fn lookup(name: &str) -> Option { CONSTRUCTORS .iter() @@ -3694,6 +3744,10 @@ pub mod catalog { // `app` must be the publishing app, which only the host knows, so the // host refuses any other; `id` names one stored run. ("sys.digest", &["app", "id", "fields"]), + // A conversation the HOST holds for the card's own app (§5.15): the + // transcript between the user and the app's agent. Same scoping as + // `sys.digest` — `app` is the publisher, `thread` one conversation. + ("sys.chat", &["app", "thread", "fields"]), ("sys.quakes", &["count", "offset", "fields"]), ("sys.news_item", &["id", "fields"]), // `symbols` names the UNIVERSE to rank. Without it the only universe is @@ -3886,6 +3940,12 @@ pub mod catalog { "published_at", ], ), + // `entries` rows are `{id, role, text, at}`; `role` is `user`, `model` + // or `host`. Pooled with the record's fields, as a digest's rows are. + ( + "sys.chat", + &["status", "count", "entries", "id", "role", "text", "at"], + ), ( "sys.quakes", &["id", "mag", "place", "depth", "ago", "lat", "lon"], @@ -4020,6 +4080,10 @@ pub mod catalog { ("sys.reading", &["append", "remove"]), ("sys.topics", &["append", "remove"]), ("sys.link", &["set", "clear"]), + // Sending a message: the payload is what the user committed in a + // `Field`, appended as a `user` entry. The host runs the agent and + // appends its reply; a card cannot write a `model` entry. + ("sys.chat", &["append"]), ]; pub fn mutable(name: &str) -> Option<&'static [&'static str]> { @@ -4150,7 +4214,7 @@ fn validate_transitions(card: &Card, sink: &mut Diagnostics) { &card.states, &card_readable, &card_events, - &card.copies, + &Scope::card(card), &card.sources, sink, ); @@ -4182,7 +4246,7 @@ fn validate_transitions(card: &Card, sink: &mut Diagnostics) { &component.states, &readable, &events, - &card.copies, + &Scope::component(card, component), // A component may write a durable collection too: the card's // sources are in scope for it (§5.3 forbids card STATE, not // sources), so the same rules must reach here. @@ -4249,7 +4313,7 @@ fn check_event_batch( states: &[StateDecl], readable: &[String], event_names: &[String], - copies: &[CopyDecl], + scope: &Scope, sources: &[SourceDecl], sink: &mut Diagnostics, ) { @@ -4260,6 +4324,23 @@ fn check_event_batch( // hands it to the host — so none of the shape checks below apply, // and this is settled before them rather than inside them. if let Some(source) = sources.iter().find(|s| s.name == transition.target) { + // §4.2: model text is never written to a host store. A store + // holds references (§5.12) — a ticker, a place, a URL, a + // preference — and each is something a later action reads. + if let Form::Set(SetSource::Path(path)) = &transition.form { + if scope.is_model_text(path) { + sink.push( + transition.line, + transition.column, + format!( + "{path} is text the model wrote and may not be written to \ + {:?} ({}): a host store holds references a later action \ + reads. Write it into a `text` state instead (profile §4.2)", + transition.target, source.helper + ), + ); + } + } let accepted = catalog::mutable(&source.helper); let verb = match &transition.form { Form::Append => "append", @@ -4439,28 +4520,30 @@ fn check_event_batch( ); } Form::Set(SetSource::Path(path)) => { - // §4 travels with the value: writing model-authored text - // into state renders it just as surely as naming it in a - // view, one step later. + // §4.2 travels with the value: model text may be written + // into a `text` state — a draft — and that state is then + // model text wherever it is read (`Scope::model_states`). + // Into any other shape it would choose a member, a number + // or a flag, which is the model deciding what a card does. + if scope.is_model_text(path) && state.shape != Shape::Text { + sink.push( + transition.line, + transition.column, + format!( + "{path} is text the model wrote; it may be written only into a \ + `text` state (a draft), and {:?} is {} (profile §4.2)", + transition.target, + shape_name(&state.shape) + ), + ); + } if let Some(name) = path.strip_prefix("copy.") { - match copies.iter().find(|c| c.name == name) { - Some(decl) if decl.provenance == Provenance::ModelCopy => { - sink.push( - transition.line, - transition.column, - format!( - "copy.{name} is `model-copy` and may not be written \ - into state; only `vocabulary` and `user-copy` may \ - (profile §4)" - ), - ); - } - None => sink.push( + if !scope.copies.iter().any(|c| c.name == name) { + sink.push( transition.line, transition.column, format!("copy.{name} is not declared"), - ), - _ => {} + ); } } let root = root_of(path); @@ -4767,6 +4850,19 @@ fn validate_sources(card: &Card, sink: &mut Diagnostics) { if !card.states.iter().any(|st| root_of(&st.path) == root) { sink.push(source.line, 1, format!("{root:?} is not a declared state")); } + // A draft holding model text is model text (§4.2): it + // may not choose what the host fetches. + if scope.is_model_text(rest) { + sink.push( + source.line, + 1, + format!( + "{path} holds text the model wrote and cannot be a source \ + argument: it would choose what the host fetches \ + (profile §4.2)" + ), + ); + } } None => check_path(path, scope, source.line, 1, sink), } @@ -4788,8 +4884,20 @@ fn validate_sources(card: &Card, sink: &mut Diagnostics) { } } } - for source in card.sources.iter().filter(|s| s.helper == "sys.digest") { - check_digest_source(source, sink); + for source in card.sources.iter() { + match source.helper.as_str() { + "sys.digest" => check_app_scoped_source( + source, + ("id", "digest id", "which of the app's digests"), + sink, + ), + "sys.chat" => check_app_scoped_source( + source, + ("thread", "thread id", "which of the app's conversations"), + sink, + ), + _ => {} + } } } @@ -4801,7 +4909,14 @@ fn validate_sources(card: &Card, sink: &mut Diagnostics) { /// `id` may be a literal or a path — an app may keep its latest run id in state — /// and a literal is checked here against the run-id charset /// (`[A-Za-z0-9_-]{1,64}`), so a card cannot name a path to a file. -fn check_digest_source(source: &SourceDecl, sink: &mut Diagnostics) { +/// +/// `sys.chat` is scoped the same way, with `thread` in place of `id` (§5.15). +fn check_app_scoped_source( + source: &SourceDecl, + (id_name, id_noun, id_what): (&str, &str, &str), + sink: &mut Diagnostics, +) { + let helper = source.helper.as_str(); let arg = |name: &str| source.args.iter().find(|(n, _)| n == name).map(|(_, a)| a); let app_ok = |v: &str| { !v.is_empty() @@ -4820,38 +4935,39 @@ fn check_digest_source(source: &SourceDecl, sink: &mut Diagnostics) { Some(SourceArg::Text(app)) => sink.push( source.line, 1, - format!("sys.digest: {app:?} is not an app id (1-64 of [A-Za-z0-9._-])"), + format!("{helper}: {app:?} is not an app id (1-64 of [A-Za-z0-9._-])"), ), Some(_) => sink.push( source.line, 1, - "sys.digest: `app` must be a literal app id — the host checks it against the \ - publishing app, so it cannot come from state or another source" - .to_string(), + format!( + "{helper}: `app` must be a literal app id — the host checks it against the \ + publishing app, so it cannot come from state or another source" + ), ), None => sink.push( source.line, 1, - "sys.digest needs `app`: the card's own app id".to_string(), + format!("{helper} needs `app`: the card's own app id"), ), } - match arg("id") { + match arg(id_name) { Some(SourceArg::Text(id)) if id_ok(id) => {} Some(SourceArg::Path(_)) => {} Some(SourceArg::Text(id)) => sink.push( source.line, 1, - format!("sys.digest: {id:?} is not a digest id (1-64 of [A-Za-z0-9_-])"), + format!("{helper}: {id:?} is not a {id_noun} (1-64 of [A-Za-z0-9_-])"), ), Some(_) => sink.push( source.line, 1, - "sys.digest: `id` must be a literal digest id or a path".to_string(), + format!("{helper}: `{id_name}` must be a literal id or a path"), ), None => sink.push( source.line, 1, - "sys.digest needs `id`: which of the app's digests".to_string(), + format!("{helper} needs `{id_name}`: {id_what}"), ), } } @@ -4971,6 +5087,12 @@ struct Scope { /// field list, and a component prop is a record whose shape the card cannot /// see from here. fields: Vec<(String, Vec)>, + /// The capability behind each source name and each loop binder over one, + /// so a read of `p.text` can be traced to `sys.digest` (§4.2). + helpers: Vec<(String, String)>, + /// States a transition writes model text into. Their value is the model's + /// words from then on, and §4.2 restricts them exactly as it does the text. + model_states: Vec, } /// What each source was asked for, as the names a reader may then name. @@ -5021,7 +5143,10 @@ impl Scope { events, forbidden_state: Vec::new(), fields: declared_fields(card), + helpers: source_helpers(card), + model_states: Vec::new(), } + .with_model_states(&card.states, &card.events) } fn component(card: &Card, component: &Component) -> Self { @@ -5053,7 +5178,69 @@ impl Scope { // whatever the caller passes, and §5.2 does not make the caller // declare it. fields: declared_fields(card), + helpers: source_helpers(card), + model_states: Vec::new(), } + .with_model_states(&component.states, &component.events) + } + + /// Mark every state a `set(path)` writes model text into, to a fixed point: + /// `b: set(a)` after `a: set(copy.m)` makes `b` model text too. Bounded by + /// the number of states, since each round that continues adds one. + fn with_model_states(mut self, states: &[StateDecl], events: &[EventDecl]) -> Self { + for _ in 0..=states.len() { + let mut changed = false; + for transition in events.iter().flat_map(|e| &e.transitions) { + let Form::Set(SetSource::Path(path)) = &transition.form else { + continue; + }; + let target = root_of(&transition.target); + if states.iter().any(|s| s.path == transition.target) + && !self.model_states.contains(&target) + && self.is_model_text(path) + { + self.model_states.push(target); + changed = true; + } + } + if !changed { + break; + } + } + self + } + + /// The capability behind `path`, through any loop binder over it. + fn source_helper(&self, path: &str) -> Option<&str> { + self.helpers + .iter() + .rev() + .find(|(name, _)| { + path == name + || path + .strip_prefix(name.as_str()) + .is_some_and(|rest| rest.starts_with('.')) + }) + .map(|(_, helper)| helper.as_str()) + } + + /// Whether `path` reads text the MODEL wrote (§4.2): a `model-copy` + /// declaration, a model-written field of a host source, or a state such + /// text was written into. + fn is_model_text(&self, path: &str) -> bool { + if let Some(name) = path.strip_prefix("copy.") { + return self + .copies + .iter() + .any(|c| c.name == name && c.provenance == Provenance::ModelCopy); + } + if self.model_states.contains(&root_of(path)) { + return true; + } + path.contains('.') + && self.source_helper(path).is_some_and(|helper| { + catalog::is_model_text(helper, path.rsplit('.').next().unwrap_or(path)) + }) } /// Whether `root` names something whose fields are known, and if so whether @@ -5095,6 +5282,14 @@ impl Scope { } } +/// Each source's declared name and the capability that answers it. +fn source_helpers(card: &Card) -> Vec<(String, String)> { + card.sources + .iter() + .map(|s| (s.name.clone(), s.helper.clone())) + .collect() +} + fn root_of>(path: S) -> String { path.as_ref() .split('.') @@ -5113,6 +5308,7 @@ fn walk( card: &Card, sink: &mut Diagnostics, ) { + let helpers_before = scope.helpers.len(); match element.name.as_str() { "" | "slot" | "into" => {} "for" => { @@ -5133,6 +5329,33 @@ fn walk( ); } } + // A key is identity, and identity is in every event target a row + // raises. Model text may not choose it (§4.2). + if let ( + Some(Arg { + value: Operand::Path(p), + .. + }), + Some(key), + ) = (element.args.first(), &element.key_path) + { + let field = key.rsplit('.').next().unwrap_or(key); + if key.contains('.') + && scope + .source_helper(p) + .is_some_and(|helper| catalog::is_model_text(helper, field)) + { + sink.push( + element.line, + element.column, + format!( + "{key} is text the model wrote and cannot be a loop key: a key is \ + an instance's identity, which every event it raises carries \ + (profile §4.2)" + ), + ); + } + } for binder in &element.binders { scope.roots.push(binder.clone()); } @@ -5157,6 +5380,11 @@ fn walk( { scope.fields.push((binder.clone(), names)); } + // And for the capability behind it, so `m.text` is known + // to be a chat entry's text (§4.2). + if let Some(helper) = scope.source_helper(p).map(str::to_owned) { + scope.helpers.push((binder.clone(), helper)); + } } } } @@ -5264,6 +5492,9 @@ fn walk( format!("{name:?} is not an L0 constructor or a declared component"), ); } + if name == "ChatEntry" { + check_chat_entry(element, scope, sink); + } for arg in &element.args { check_arg( data_positions, @@ -5287,6 +5518,45 @@ fn walk( for _ in &element.binders { scope.roots.pop(); } + scope.helpers.truncate(helpers_before); + } +} + +/// `ChatEntry(text: m.text, role: m.role)` — both read ONE `sys.chat` row +/// (§5.15). The role decides the bubble's side and the AI-written mark, so it +/// must be the role the HOST gave the very message being shown: a role from +/// another row, a state or a literal would let a card present the model's words +/// as the user's. +fn check_chat_entry(element: &Element, scope: &Scope, sink: &mut Diagnostics) { + let path = |name: &str| { + element + .args + .iter() + .find(|a| a.name == name) + .and_then(|a| match &a.value { + Operand::Path(p) => Some(p.as_str()), + _ => None, + }) + }; + let row_of = |p: &str, field: &str| -> Option { + let row = p.strip_suffix(field)?.strip_suffix('.')?; + (scope.source_helper(p) == Some("sys.chat")).then(|| row.to_owned()) + }; + let ok = match (path("text"), path("role")) { + (Some(text), Some(role)) => { + matches!((row_of(text, "text"), row_of(role, "role")), (Some(a), Some(b)) if a == b) + } + _ => false, + }; + if !ok { + sink.push( + element.line, + element.column, + "ChatEntry reads one `sys.chat` entry: `ChatEntry(text: m.text, role: m.role)`, \ + both off the same row, so the host's role always labels its own message \ + (profile §5.15)" + .to_string(), + ); } } @@ -5491,7 +5761,16 @@ fn check_arg( // A component prop has no catalogued kind, and may legitimately receive an // event name — profile §5.4, outputs are event props. Accept either. (Operand::Path(path), None) if scope.events.iter().any(|e| e == path) => {} - (Operand::Path(path), _) => check_path(path, scope, arg.line, arg.column, sink), + // A text slot is the one position model-written text may fill (§4.2), + // and only a CATALOGUED one: a component prop could hand it anywhere. + (Operand::Path(path), _) => check_path_at( + path, + scope, + arg.line, + arg.column, + known.is_some() && catalog::is_text_slot(ctor, &arg.name), + sink, + ), (Operand::Predicate { path, rhs, .. }, _) => { check_path(path, scope, arg.line, arg.column, sink); // EVERY path in the right operand, not just a bare one. Leaving it @@ -5708,26 +5987,50 @@ fn check_prop(ctor: &str, arg: &Arg, param: &Param, scope: &Scope, sink: &mut Di } fn check_path(path: &str, scope: &Scope, line: usize, column: usize, sink: &mut Diagnostics) { + check_path_at(path, scope, line, column, false, sink); +} + +/// `check_path`, for a position that may be a TEXT SLOT (§4.2). +/// +/// Model-written text is refused everywhere by default and admitted only where +/// the caller says the position displays text and does nothing else. Default +/// deny, so a position added later cannot carry model text by being forgotten: +/// an action's payload, a source argument, a guard, a key, a control's label +/// and a component prop all reach `check_path` and are refused here. +fn check_path_at( + path: &str, + scope: &Scope, + line: usize, + column: usize, + text_slot: bool, + sink: &mut Diagnostics, +) { if path.is_empty() { return; } - // `copy.x` where `x` is model-authored. §4's whole point: a model-written - // string on screen is indistinguishable from a fact the model invented, so - // the class exists to be refused rather than to be recorded. + if !text_slot && scope.is_model_text(path) { + sink.push( + line, + column, + format!( + "{path} is text the model wrote; it may appear only in a text slot ({}) \ + and never as an action's payload or target, a source argument, a guard, a \ + key, a control's label or a component prop (profile §4.2)", + catalog::TEXT_SLOTS + .iter() + .map(|(c, a)| format!("{c}.{a}")) + .collect::>() + .join(", ") + ), + ); + } + + // `copy.x`: declared, with a class. A `model-copy` declaration is checked + // above by position, not refused outright: model text is allowed in a text + // slot, where the kit marks it AI-written (§4.2). if let Some(name) = path.strip_prefix("copy.") { - if let Some(decl) = scope.copies.iter().find(|c| c.name == name) { - if decl.provenance == Provenance::ModelCopy { - sink.push( - line, - column, - format!( - "copy.{name} is `model-copy` and may not be rendered; only \ - `vocabulary` and `user-copy` may (profile §4)" - ), - ); - } - } else { + if !scope.copies.iter().any(|c| c.name == name) { sink.push(line, column, format!("copy.{name} is not declared")); } return; @@ -5860,6 +6163,28 @@ pub struct UiNode { pub origins: Vec<(String, ValueOrigin)>, } +/// Whether this node shows text the MODEL wrote, which the kit must mark as +/// AI-written (§4.2). +/// +/// A `ChatEntry` is marked by its ROLE, which the host gave the message and the +/// checker ties to the same row as the text: a `model` entry is the agent's +/// words, a `user` or `host` entry is not. Every other role is marked when a +/// text slot's value has a model origin — a `model-copy`, a model-written +/// source field, or a draft state one was written into. Both backends and the +/// DSL lowering read this, so the three cannot disagree about which text is +/// the model's. +pub fn ai_written(node: &UiNode) -> bool { + if node.kind == "ChatEntry" { + return matches!( + node.args.iter().find(|(n, _)| n == "role"), + Some((_, NodeValue::Text(role))) if role == "model" + ); + } + node.origins.iter().any(|(name, origin)| { + *origin == ValueOrigin::Model && catalog::is_text_slot(&node.kind, name) + }) +} + /// Where an argument's value came from, with the source's own arguments already /// resolved. /// @@ -7212,6 +7537,37 @@ pub mod makepad { /// inert: the stock range controls could not reach `dispatch` at all. The /// key is what lets a tap name WHICH instance was hit, which is the whole /// basis of §5.1 identity. + /// The target a text field raises, as a DSL expression over the typed text + /// `t`: a routing head serialized here, then `t` encoded at runtime as one + /// complete JSON string. Nothing typed can reach the routing keys — the + /// same construction `kit::tap_target` uses for a live payload. + pub(super) fn typed_target(event: &str, key: &str) -> String { + let head = serde_json::json!({ "e": event, "k": key }).to_string(); + let open = format!("l0:{},\"v\":", head.trim_end_matches('}')); + format!("{open:?} + sys.json_string(t) + {:?}", "}") + } + + /// ` l0_ai: true` on the widget that draws model-written text (§4.2), as + /// `l0_event:` rides on a tappable one. The text itself is emitted exactly + /// as any other — a quoted literal, never markup or a target — so this is + /// the only difference, and it is the kit's to draw. + fn ai_attr(node: &UiNode) -> &'static str { + if crate::ai_written(node) { + " l0_ai: true" + } else { + "" + } + } + + /// Whether a bubble sits on the user's side: `side: .me`, or a chat entry + /// the host recorded as the user's. + pub(super) fn bubble_is_mine(node: &UiNode) -> bool { + match node.kind.as_str() { + "ChatEntry" => matches!(arg(node, "role"), Some(NodeValue::Text(r)) if r == "user"), + _ => matches!(arg(node, "side"), Some(NodeValue::Token(t)) if t == "me"), + } + } + fn tap_binding(node: &UiNode) -> String { let Some(NodeValue::Event(event)) = arg(node, "on_tap") else { return String::new(); @@ -7911,6 +8267,9 @@ pub mod makepad { // call could not check `app` against the caller, so there is none // and the value is the one the host injected. "sys.digest" => None, + // The same for a conversation: the host holds it for the publishing + // app, runs the agent, and appends both sides (§5.15). + "sys.chat" => None, "sys.dataset" => { let id = text("id")?; if !crate::catalog::answers("sys.dataset")?.contains(&binding.field.as_str()) { @@ -9121,8 +9480,8 @@ pub mod makepad { "Space" => { let _ = writeln!(out, "{p}View{{ width: Fill height: Fill }}"); } - "Bubble" => { - let me = matches!(arg(node, "side"), Some(NodeValue::Token(t)) if t == "me"); + "Bubble" | "ChatEntry" => { + let me = bubble_is_mine(node); let (fill, ink) = if me { (ACTIVE, TEXT) } else { (PANEL, SOFT) }; let _ = writeln!( out, @@ -9137,8 +9496,9 @@ pub mod makepad { ); let _ = writeln!( out, - "{p} TextBody{{ text: {} draw_text.color: {ink} }}", - expr_of(node, "text") + "{p} TextBody{{ text: {} draw_text.color: {ink}{} }}", + expr_of(node, "text"), + ai_attr(node) ); let _ = writeln!(out, "{p} }}"); let _ = writeln!(out, "{p}}}"); @@ -9202,8 +9562,9 @@ pub mod makepad { ); let _ = writeln!( out, - "{p} TextTitle{{ text: {} draw_text.color: {PANEL} }}", - expr_of(node, "text") + "{p} TextTitle{{ text: {} draw_text.color: {PANEL}{} }}", + expr_of(node, "text"), + ai_attr(node) ); let _ = writeln!(out, "{p}}}"); } @@ -9402,8 +9763,9 @@ pub mod makepad { }; let _ = writeln!( out, - "{p}{role}{{{width} text: {body} draw_text.color: {colour}{size}{} }}", - tap_binding(node) + "{p}{role}{{{width} text: {body} draw_text.color: {colour}{size}{}{} }}", + tap_binding(node), + ai_attr(node) ); } // Content a swipe reveals — hidden until then. See the catalog. @@ -9427,8 +9789,15 @@ pub mod makepad { // `on_return` rather than a tap wrapper. A hit target over a text // input eats the focus and there is nothing left to type into, and the // payload here is what was TYPED — which does not exist until commit, - // so the target is assembled at that moment. `$$` is where the typed - // text goes. + // so the target is assembled at that moment. + // + // STRUCTURED, never spliced. This used to emit `head + t + tail` + // around a `"$$"` hole, so the typed text landed inside a JSON string + // unescaped: typing `","e":"drop","v":"` produced a target whose LAST + // `e` key — the one a JSON parser keeps — named another event. Typed + // text could redirect the tap. The text is now encoded at runtime + // (`sys.json_string`) as one complete JSON value after a routing + // head serialized here, so it can only ever be the payload. "Field" => { // A field that was not told a width FILLS, unlike a text run. // `Field(width: .fill)` is what every card writes and the row it @@ -9439,26 +9808,22 @@ pub mod makepad { Some("fit") => " width: Fit", _ => " width: Fill", }; - let target = match arg(node, "on_commit") { - Some(NodeValue::Event(event)) => { - let json = serde_json::json!({ "e": event, "k": node.key, "v": "$$" }); - format!("l0:{json}") + let mut commit = String::new(); + for (property, moment) in [("on_return", "on_commit"), ("on_change", "on_change")] { + if let Some(NodeValue::Event(event)) = arg(node, moment) { + let _ = write!( + commit, + " {property}: |t| agent.notify(\"l0\", {{target: {}}})", + typed_target(event, &node.key) + ); } - _ => String::new(), - }; - let (head, tail) = target.split_once("$$").unwrap_or((target.as_str(), "")); - let commit = if target.is_empty() { - String::new() - } else { - format!( - " on_return: |t| agent.notify(\"l0\", {{target: {head:?} + t + {tail:?}}})" - ) - }; + } let _ = writeln!( out, - "{p}TextInput{{{width} height: 48 text: {} empty_text: {}{commit} }}", + "{p}TextInput{{{width} height: 48 text: {} empty_text: {}{commit}{} }}", expr_of(node, "text"), expr_of(node, "placeholder"), + ai_attr(node), ); } other => { @@ -10118,7 +10483,19 @@ fn dispatch_writes( // A path READS. Treating it as the payload meant // `n: set(config.answer)` wrote whatever the tap carried — or // nothing at all when it carried nothing. - SetSource::Path(p) => match data_path(data, p) { + // `copy.x` is the card's own text and is not in the data blob; + // it resolves from the declaration, in the device's language, + // exactly as realization renders it. + SetSource::Path(p) => match if p.starts_with("copy.") { + ValueScope { + frames: Vec::new(), + data, + copies: &card.copies, + } + .lookup(p) + } else { + data_path(data, p) + } { Some(v) if value_fits_shape(&state.shape, &v) => v, // Unresolvable or ill-shaped: the batch cannot complete, and // §3 says a batch is all or nothing. @@ -11159,7 +11536,7 @@ fn dsl_kind(role: &str) -> Option<&'static str> { "Rule" => "divider", "Space" => "column", "Band" => "card", - "Bubble" => "card", + "Bubble" | "ChatEntry" => "card", "Fab" => "card", "TabBar" => "row", "Tab" => "column", @@ -11215,6 +11592,10 @@ fn emit_dsl(node: &UiNode, depth: usize, out: &mut String, sink: &mut Vec Ok(()), }; } + // The AI-written mark (§4.2). The consumer's `Attrs` must read `ai`. + if crate::ai_written(node) { + out.push_str(", ai: 1"); + } if node.children.is_empty() { out.push_str("}\n"); @@ -11287,7 +11668,7 @@ pub mod kit { "Rule" => "l0_rule", "Space" => "l0_space", "Band" => "l0_band", - "Bubble" => "l0_bubble_them", + "Bubble" | "ChatEntry" => "l0_bubble_them", "Fab" => "l0_fab", "TabBar" => "l0_tabbar", "Tab" => "l0_tab", @@ -11525,6 +11906,13 @@ pub mod kit { /// tokens pass the TOKEN and not a pixel count: how wide a rank column is /// is the theme's answer, and deciding here would put styling in the /// lowering. + /// `l0_ai_text(node)`: the kit marks a node whose text the model wrote + /// (§4.2). Like `l0_live` it only stamps the node — `node.ai = 1` — and the + /// renderer decides what AI-written looks like. + fn ai_wrap(node: &UiNode) -> Option<(&'static str, String)> { + crate::ai_written(node).then(|| ("l0_ai_text(", ")".to_owned())) + } + fn width_wrap(node: &UiNode) -> Option<(&'static str, String)> { let t = token_arg(node, "width")?; match t { @@ -11598,6 +11986,10 @@ pub mod kit { // tap: a hit target over a text input eats the focus, and the payload // here is what was typed rather than what the row was bound to. if node.kind == "Field" { + let ai = super::ai_written(node); + if ai { + out.push_str("l0_ai_text("); + } let _ = write!( out, "l0_field({}, {}, {:?}, {:?})", @@ -11606,6 +11998,9 @@ pub mod kit { commit_target(node).unwrap_or_default(), field_target(node, "on_change").unwrap_or_default() ); + if ai { + out.push(')'); + } return; } // A tappable node is WRAPPED. A `card`, `chip` or `image` carrying @@ -11681,6 +12076,8 @@ pub mod kit { live_call_of(node).map(|call| ("l0_live(", format!(", {call:?})"))), width_wrap(node), align_wrap(node), + // INNERMOST, so the mark lands on the text node itself (§4.2). + ai_wrap(node), ] .into_iter() .flatten() @@ -11967,8 +12364,8 @@ pub mod kit { children(node, depth, out); out.push(')'); } - "Bubble" => { - let me = matches!(arg(node, "side"), Some(NodeValue::Token(t)) if t == "me"); + "Bubble" | "ChatEntry" => { + let me = makepad::bubble_is_mine(node); // A literal longer than a line gets the capped wrapping form; // an unmeasurable live value is assumed long, which only costs // a short message some air. diff --git a/crates/octoscript-ui-l0/src/value_origin.rs b/crates/octoscript-ui-l0/src/value_origin.rs index 19cdebd..6820c9b 100644 --- a/crates/octoscript-ui-l0/src/value_origin.rs +++ b/crates/octoscript-ui-l0/src/value_origin.rs @@ -13,6 +13,11 @@ pub enum ValueOrigin { UserInput, Host, Derived, + /// Text the MODEL wrote: a `model-copy` declaration, a model-written field + /// of a host source (`sys.digest`'s summary, a `sys.chat` entry's text), or + /// a state one of those was written into. Renderable in a text slot, with + /// the AI-written mark; never data, never an action's input (profile §4.2). + Model, Unknown, } @@ -27,6 +32,10 @@ impl ValueOrigin { fn combine(self, other: Self) -> Self { if self == Self::Unknown || other == Self::Unknown { Self::Unknown + } else if self == Self::Model || other == Self::Model { + // Model text does not wash out by being combined with something + // trustworthy: the result still carries words the model chose. + Self::Model } else if self == other { self } else if self.permits_data() || other.permits_data() { @@ -40,6 +49,16 @@ impl ValueOrigin { impl ValueScope<'_> { pub(super) fn path_origin(&self, path: &str, card: &Card) -> ValueOrigin { let root = path.split('.').next().unwrap_or(path); + // Model-written FIELDS of a host source, through any loop over it. Asked + // first, because a loop binder's own origin is its collection's (Source) + // and would otherwise answer for `p.text` before the field is looked at. + if path.contains('.') + && self.source_helper_of(path, card).is_some_and(|helper| { + catalog::is_model_text(helper, path.rsplit('.').next().unwrap_or(path)) + }) + { + return ValueOrigin::Model; + } if let Some((_, _, _, _, origin)) = self.frames.iter().rev().find(|(n, ..)| n == root) { return *origin; } @@ -51,6 +70,7 @@ impl ValueScope<'_> { .map(|c| c.provenance) { Some(Provenance::Vocabulary) => ValueOrigin::Vocabulary, + Some(Provenance::ModelCopy) => ValueOrigin::Model, // Copy class is authored metadata, not evidence of a native input. _ => ValueOrigin::Authored, }; @@ -66,6 +86,27 @@ impl ValueScope<'_> { } } + /// The capability behind a path, through any loop binders between them: + /// `p.text` inside `for p in brief.points` answers `sys.digest`. A binder + /// that is not a loop item (a component prop) answers nothing. + fn source_helper_of<'c>(&self, path: &str, card: &'c Card) -> Option<&'c str> { + let mut path = path.to_string(); + // Bounded by the frame count: each hop moves to an enclosing loop. + for _ in 0..=self.frames.len() { + let root = path.split('.').next().unwrap_or(&path).to_string(); + if let Some((_, _, item, _, _)) = self.frames.iter().rev().find(|(n, ..)| *n == root) { + path = item.as_ref()?.0.clone(); + continue; + } + return card + .sources + .iter() + .find(|s| path == s.name || path.starts_with(&format!("{}.", s.name))) + .map(|s| s.helper.as_str()); + } + None + } + pub(super) fn operand_origin(&self, operand: &Operand, card: &Card) -> ValueOrigin { match operand { Operand::Path(p) => self.path_origin(p, card), diff --git a/crates/octoscript-ui-l0/tests/ai_text.rs b/crates/octoscript-ui-l0/tests/ai_text.rs new file mode 100644 index 0000000..a776f78 --- /dev/null +++ b/crates/octoscript-ui-l0/tests/ai_text.rs @@ -0,0 +1,575 @@ +//! §4.2 and §5.15: model-written text in text slots, marked AI-written; actions +//! stay strict; and an in-card chat bound to a host conversation. +use octoscript_ui_l0::*; +use serde_json::json; + +const CHAT: &str = include_str!("fixtures/chat.card"); + +fn errors(card: &str) -> Vec { + check_ui_l0_named("ai", card) + .diagnostics + .into_iter() + .map(|d| d.message) + .collect() +} + +fn root(card: &str, data: serde_json::Value) -> UiNode { + let report = realize(card, &data, RealizeLimits::default()); + report.complete_root().expect("realizes").clone() +} + +fn find<'a>(node: &'a UiNode, kind: &str, out: &mut Vec<&'a UiNode>) { + if node.kind == kind { + out.push(node); + } + for c in &node.children { + find(c, kind, out); + } +} + +fn nodes<'a>(node: &'a UiNode, kind: &str) -> Vec<&'a UiNode> { + let mut out = Vec::new(); + find(node, kind, &mut out); + out +} + +fn assert_refused(card: &str, why: &str) { + let errs = errors(card); + assert!( + errs.iter().any(|e| e.contains("text the model wrote")), + "{why}: expected a model-text refusal, got {errs:#?}\n{card}" + ); +} + +// ─── model-written text in a text slot ─────────────────────────────────────── + +const SUMMARY: &str = r#" +copy title { class: vocabulary, en: "TODAY" } +copy gist { class: model-copy, en: "Markets were calm; rates held." } +view root Surface { + TextEyebrow(text: copy.title) + TextBody(text: copy.gist) +} +"#; + +/// A model-written summary renders, and every lowering marks it AI-written — +/// and marks nothing else. +#[test] +fn model_text_in_a_text_slot_renders_marked_ai_written() { + let report = check_ui_l0_named("ai", SUMMARY); + assert!(report.valid, "{:#?}", report.diagnostics); + assert_eq!(report.level, Level::L0); + + let tree = root(SUMMARY, json!({})); + let body = nodes(&tree, "TextBody")[0]; + let eyebrow = nodes(&tree, "TextEyebrow")[0]; + assert!(ai_written(body), "{body:#?}"); + assert!(!ai_written(eyebrow), "vocabulary is not AI-written"); + assert!(body + .origins + .contains(&("text".to_string(), ValueOrigin::Model))); + + let kit = kit::lower(&tree); + assert_eq!(kit.matches("l0_ai_text(").count(), 1, "{kit}"); + assert!( + kit.contains("l0_ai_text(l0_body(\"Markets were calm; rates held.\"))"), + "{kit}" + ); + let mp = makepad::lower(&tree); + assert_eq!(mp.matches("l0_ai: true").count(), 1, "{mp}"); + let dsl = lower_dsl(&tree); + assert_eq!(dsl.matches("ai: 1").count(), 1, "{dsl}"); +} + +const DIGEST: &str = r#" +source brief sys.digest(app: "os.news", id: "morning", fields: [topic, summary, points, id, text]) +view root Surface { + TextCaption(text: brief.topic) + TextBody(text: brief.summary) + for p in brief.points key p.id { TextRow(text: p.text) } +} +"#; + +/// A host source's model-written fields carry the mark too, through a loop. +#[test] +fn a_digest_summary_and_its_points_are_marked_ai_written() { + assert!(errors(DIGEST).is_empty(), "{:#?}", errors(DIGEST)); + let tree = root( + DIGEST, + json!({"brief": {"topic": "rates", "summary": "Rates held.", + "points": [{"id": "p1", "text": "The bank held at 4%."}]}}), + ); + assert!(ai_written(nodes(&tree, "TextBody")[0])); + assert!( + ai_written(nodes(&tree, "TextRow")[0]), + "a point's text, via the loop" + ); + assert!( + !ai_written(nodes(&tree, "TextCaption")[0]), + "the topic is not model text" + ); +} + +/// A draft: model text written into a `text` state by a transition. The state +/// is model text from then on — rendered marked, and refused as an action's +/// input like the text itself. +#[test] +fn a_draft_holds_model_text_and_stays_marked() { + const CARD: &str = r#" +copy suggestion { class: model-copy, en: "Thanks — I'll be there at 6." } +state draft { shape: text, initial: "" } +event use { draft: set(copy.suggestion) } +event edit { draft: set($value) } +view root Surface { + Chip(text: "Use suggestion", on_tap: use) + Field(text: draft, on_commit: edit, width: .fill) + TextBody(text: draft) +} +"#; + assert!(errors(CARD).is_empty(), "{:#?}", errors(CARD)); + let mut store = InstanceStore::default(); + let outcome = dispatch_reporting_with_origin( + CARD, + &mut store, + "root", + "use", + None, + &json!({}), + ValueOrigin::UserInput, + ); + assert!( + outcome.applied, + "set(copy.x) resolves from the card's own copy" + ); + assert_eq!( + store.get(CARD_STATE_KEY, "draft").and_then(|v| v.as_str()), + Some("Thanks — I'll be there at 6.") + ); + assert_eq!( + store.origin(CARD_STATE_KEY, "draft"), + Some(ValueOrigin::Model) + ); + + let tree = realize_with_state(CARD, &json!({}), &store, RealizeLimits::default()) + .complete_root() + .unwrap() + .clone(); + assert!(ai_written(nodes(&tree, "TextBody")[0])); + assert!( + ai_written(nodes(&tree, "Field")[0]), + "the field shows the model's draft" + ); + let kit = kit::lower(&tree); + assert!(kit.contains("l0_ai_text(l0_field("), "{kit}"); + + // What the user COMMITS from the field is theirs, and the mark goes. + let field = nodes(&tree, "Field")[0]; + let origin = event_payload_origin(&tree, &field.key, "edit").unwrap(); + assert_eq!(origin, ValueOrigin::UserInput); + dispatch_reporting_with_origin( + CARD, + &mut store, + &field.key, + "edit", + Some(&json!("See you at 6")), + &json!({}), + origin, + ); + let tree = realize_with_state(CARD, &json!({}), &store, RealizeLimits::default()) + .complete_root() + .unwrap() + .clone(); + assert!(!ai_written(nodes(&tree, "TextBody")[0])); +} + +// ─── actions stay strict ───────────────────────────────────────────────────── + +/// Every position where model text would be part of an action, a query, an +/// identity or a control is refused, whatever the text says. +#[test] +fn model_text_never_reaches_an_action_a_query_or_a_control() { + let copy = r#"copy m { class: model-copy, en: "NVDA" } +source watch sys.watchlist(fields: [ticker]) +source link sys.link(fields: [url]) +source brief sys.digest(app: "os.news", id: "morning", fields: [summary, points, id, text]) +state pick { shape: text, initial: "" } +state mode { shape: enum[a, b], initial: a } +event add { watch: append($value) } +event go { pick: set($value) } +"#; + let cases: &[(&str, &str)] = &[ + ( + "a tap payload", + "view root Row(on_tap: add, value: copy.m) { Rule() }", + ), + ( + "a chip payload", + "view root Chip(text: \"Add\", on_tap: add, value: brief.summary)", + ), + ( + "a card payload", + "view root Card(on_tap: go, value: copy.m) { Rule() }", + ), + ( + "a hero's payload", + "view root TextHero(text: copy.m, on_tap: add, value: copy.m)", + ), + ( + "a chip's label", + "view root Chip(text: copy.m, on_tap: add)", + ), + ( + "a tile's label", + "view root Tile(label: brief.summary, value: pick)", + ), + ( + "a tab's label", + "view root TabBar { Tab(icon: .home, label: copy.m, active: .on) }", + ), + ("a kit component", "view root Kit(component: copy.m)"), + ( + "a field placeholder", + "view root Field(text: pick, placeholder: copy.m, on_commit: go)", + ), + ( + "a guard", + "view root Surface { when brief.summary == \"x\" { Rule() } }", + ), + ( + "a guard on model copy", + "view root Surface { when pick == copy.m { Rule() } }", + ), + ( + "a loop key", + "view root Surface { for p in brief.points key p.text { TextRow(text: p.text) } }", + ), + ( + "a component prop", + "component C(t: text) { view TextBody(text: t) }\nview root C(t: copy.m)", + ), + ("an image source", "view root Photo(src: brief.summary)"), + ]; + for (why, view) in cases { + assert_refused(&format!("{copy}{view}"), why); + } + + // A source argument: model text would choose what the host fetches. + assert_refused( + &format!( + "{copy}source w sys.wiki(query: brief.summary)\nview root TextBody(text: w.extract)" + ), + "a source argument", + ); + // A host store: model text would become a URL or reference an action reads. + assert_refused( + &format!( + "{copy}event save {{ link: set(copy.m) }}\nview root Row(on_tap: save) {{ Rule() }}" + ), + "a write to sys.link", + ); + // A non-text state: model text would choose a member. + assert_refused( + &format!( + "{copy}event pickm {{ mode: set(copy.m) }}\nview root Row(on_tap: pickm) {{ Rule() }}" + ), + "a write into an enum", + ); + // An initial: a draft is written by a transition the user triggers. + assert_refused( + "copy m { class: model-copy, en: \"x\" }\nstate s { shape: text, initial: copy.m }\n\ + view root TextBody(text: s)", + "a state initial", + ); +} + +/// Realization is gated on the checker: a card that puts model text in a +/// payload yields no tree at all, so no lowering can emit it as a target. +#[test] +fn a_card_with_misplaced_model_text_does_not_realize() { + const CARD: &str = r#" +copy m { class: model-copy, en: "NVDA" } +source watch sys.watchlist(fields: [ticker]) +event add { watch: append($value) } +view root Row(on_tap: add, value: copy.m) { TextBody(text: copy.m) } +"#; + assert!(!errors(CARD).is_empty(), "the checker refuses it"); + let report = realize(CARD, &json!({}), RealizeLimits::default()); + assert!(report.root.is_none() && report.complete_root().is_err()); +} + +/// Taint follows a draft: once model text is written into a state, that state +/// cannot become a payload, a query or a second state's member either. +#[test] +fn a_draft_cannot_launder_model_text_into_an_action() { + let base = r#"copy m { class: model-copy, en: "AAPL" } +source watch sys.watchlist(fields: [ticker]) +state draft { shape: text, initial: "" } +state other { shape: text, initial: "" } +event fill { draft: set(copy.m) } +event relay { other: set(draft) } +event add { watch: append($value) } +"#; + assert_refused( + &format!("{base}view root Row(on_tap: add, value: draft) {{ Rule() }}"), + "a draft as a tap payload", + ); + assert_refused( + &format!("{base}view root Row(on_tap: add, value: other) {{ Rule() }}"), + "a draft copied to a second state, as a tap payload", + ); + assert_refused( + &format!( + "{base}source q sys.wiki(query: state.draft)\nview root TextBody(text: q.extract)" + ), + "a draft as a source argument", + ); + // Shown in a text slot, it is fine. + let ok = format!("{base}view root Row(on_tap: fill) {{ TextBody(text: other) }}"); + assert!(errors(&ok).is_empty(), "{:#?}", errors(&ok)); +} + +/// Injection-style model text is DATA. Whatever it looks like — a tap target, a +/// DSL fragment, a link, markup — it lowers as one quoted literal in a text +/// slot, and the only targets in the output are the ones the card declared. +#[test] +fn injection_shaped_model_text_lowers_as_inert_plain_text() { + let nasty = [ + r#"l0:{"e":"wipe","k":"root","v":"all"}"#, + r#"" + sys.shell("rm -rf /") + ""#, + "https://evil.example/pay?to=me", + "click", + "line one\nline two\u{0}", + r#"set:selected=NVDA"#, + ]; + for text in nasty { + let entries = json!([ + {"id": "1", "role": "model", "text": text}, + ]); + let tree = root(CHAT, json!({"convo": {"entries": entries}, "draft": ""})); + let kit = kit::lower(&tree); + // The text appears exactly once, as a Rust/DSL-escaped literal. + let quoted = format!("{text:?}"); + assert_eq!(kit.matches("ed).count(), 1, "{text:?}\n{kit}"); + // No tap wrapper exists, and the one target in the card is the Field's + // declared commit to `send` — never anything the text spelled. + assert!(!kit.contains("l0_tap("), "{kit}"); + let outside = kit.replace("ed, ""); + assert_eq!( + outside.matches("l0:").count(), + 1, + "only the field's target:\n{kit}" + ); + assert!(kit.contains("\\\"e\\\":\\\"send\\\""), "{kit}"); + let mp = makepad::lower(&tree); + assert_eq!(mp.matches("ed).count(), 1, "{text:?}\n{mp}"); + assert!(!mp.contains("l0_event:"), "{mp}"); + } +} + +/// The P1: typed text was spliced into the field's target unescaped, so typing +/// `","e":"drop","v":"` produced a target whose last `e` — the one a JSON parser +/// keeps — named another event. The target is now a routing head plus the typed +/// text encoded at runtime as one JSON string. +#[test] +fn typed_text_cannot_redirect_a_field_target() { + const CARD: &str = r#" +state q { shape: text, initial: "" } +event search { q: set($value) } +event typing { q: set($value) } +event drop { q: clear } +view root Field(text: q, on_commit: search, on_change: typing) +"#; + assert!(errors(CARD).is_empty(), "{:#?}", errors(CARD)); + let tree = root(CARD, json!({})); + let mp = makepad::lower(&tree); + assert!( + !mp.contains("+ t +"), + "typed text must never be spliced:\n{mp}" + ); + for (property, event) in [("on_return", "search"), ("on_change", "typing")] { + let at = mp + .find(&format!("{property}: |t| agent.notify(\"l0\", {{target: ")) + .unwrap_or_else(|| panic!("{property} missing:\n{mp}")); + let expr = &mp[at..]; + let expr = &expr[expr.find("target: ").unwrap() + "target: ".len()..]; + // `"" + sys.json_string(t) + "}"` + let (head_lit, rest) = expr.split_once(" + sys.json_string(t) + ").expect(expr); + assert!(rest.starts_with("\"}\""), "{rest}"); + let head: String = serde_json::from_str(head_lit).expect("the head is one literal"); + for typed in [ + r#"","e":"drop","v":""#, + r#"x"}"#, + "\\\"", + "l0:{\"e\":\"drop\"}", + ] { + // What the runtime builds: the head, the typed text as JSON, `}`. + let target = format!("{head}{}}}", serde_json::to_string(typed).unwrap()); + let parsed: serde_json::Value = + serde_json::from_str(target.strip_prefix("l0:").unwrap()).unwrap(); + assert_eq!(parsed["e"], event, "{target}"); + assert_eq!(parsed["k"], tree.key, "{target}"); + assert_eq!(parsed["v"], typed, "{target}"); + } + } +} + +// ─── the in-card chat ──────────────────────────────────────────────────────── + +#[test] +fn a_chat_card_is_l0_and_host_answered() { + let report = check_ui_l0_named("chat", CHAT); + assert!(report.valid, "{:#?}", report.diagnostics); + assert_eq!(report.level, Level::L0); + let plan = source_plan(CHAT); + assert!(plan.diagnostics.is_empty(), "{:?}", plan.diagnostics); + assert_eq!(plan.requests[0].helper, "sys.chat"); + assert_eq!(catalog::mutable("sys.chat"), Some(&["append"][..])); + let binding = SourceBinding { + helper: "sys.chat".into(), + args: vec![ + ("app".into(), "os.news".into()), + ("thread".into(), "main".into()), + ], + nested: Vec::new(), + field: "entries".into(), + }; + assert!(makepad::vm_call(&binding).is_none(), "host-answered only"); +} + +/// The transcript draws each entry on its role's side, and marks exactly the +/// model's entries AI-written. A `user` or `host` entry is plain and unmarked. +#[test] +fn the_transcript_marks_only_the_models_entries() { + let tree = root( + CHAT, + json!({"convo": {"entries": [ + {"id": "1", "role": "user", "text": "What moved markets?"}, + {"id": "2", "role": "model", "text": "Rates held; tech rallied."}, + {"id": "3", "role": "host", "text": "Searched 12 sources."} + ]}, "draft": ""}), + ); + let entries = nodes(&tree, "ChatEntry"); + assert_eq!(entries.len(), 3); + let marks: Vec = entries.iter().map(|n| ai_written(n)).collect(); + assert_eq!(marks, [false, true, false]); + + let kit = kit::lower(&tree); + assert!( + kit.contains("l0_bubble_me(\"What moved markets?\")"), + "{kit}" + ); + assert!( + kit.contains("l0_ai_text(l0_bubble_them(\"Rates held; tech rallied.\"))"), + "{kit}" + ); + assert!( + kit.contains("l0_bubble_them(\"Searched 12 sources.\")"), + "{kit}" + ); + assert_eq!(kit.matches("l0_ai_text(").count(), 1, "{kit}"); + let mp = makepad::lower(&tree); + assert_eq!(mp.matches("l0_ai: true").count(), 1, "{mp}"); + let dsl = lower_dsl(&tree); + assert_eq!(dsl.matches("ai: 1").count(), 1, "{dsl}"); +} + +/// Sending is a declared host write: the committed text is appended to the +/// conversation as a §5.12 write, and the conversation goes stale. +#[test] +fn sending_appends_what_the_user_typed() { + let data = json!({"convo": {"entries": []}, "draft": ""}); + let tree = root(CHAT, data.clone()); + let field = nodes(&tree, "Field")[0]; + let origin = event_payload_origin(&tree, &field.key, "send").unwrap(); + assert_eq!(origin, ValueOrigin::UserInput); + let mut store = InstanceStore::default(); + let outcome = dispatch_reporting_with_origin( + CHAT, + &mut store, + &field.key, + "send", + Some(&json!("Summarize the top story")), + &data, + origin, + ); + assert!(outcome.applied); + assert_eq!(outcome.writes.len(), 1); + let w = &outcome.writes[0]; + assert_eq!( + ( + w.source.as_str(), + w.helper.as_str(), + w.op.as_str(), + w.value.as_str() + ), + ("convo", "sys.chat", "append", "Summarize the top story") + ); + assert!(outcome.stale.contains(&"convo".to_string())); +} + +/// The chat's own rules: scoped like a digest, append-only, and a `ChatEntry` +/// reads its text and role off the SAME row, so a card can never present the +/// model's words under the user's name. +#[test] +fn chat_misuse_is_refused() { + let with = |from: &str, to: &str| CHAT.replace(from, to); + let has = |card: &str, needle: &str| { + let errs = errors(card); + assert!( + errs.iter().any(|e| e.contains(needle)), + "{needle:?} not in {errs:#?}" + ); + }; + has(&with("app: \"os.news\", ", ""), "needs `app`"); + has(&with("thread: \"main\", ", ""), "needs `thread`"); + has( + &with("thread: \"main\"", "thread: \"../mail\""), + "not a thread id", + ); + has( + &with("convo: append($value)", "convo: remove($value)"), + "does not accept `remove`", + ); + + // Role from a literal, from state, or from another row. + has( + &with( + "ChatEntry(text: m.text, role: m.role)", + "ChatEntry(text: m.text, role: \"user\")", + ), + "ChatEntry reads one", + ); + has( + &with( + "ChatEntry(text: m.text, role: m.role)", + "ChatEntry(text: m.text, role: draft)", + ), + "ChatEntry reads one", + ); + has( + &with( + "ChatEntry(text: m.text, role: m.role)", + "for n in convo.entries key n.id { ChatEntry(text: m.text, role: n.role) }", + ), + "ChatEntry reads one", + ); + // Model copy dressed as a chat entry. + has( + &with( + "ChatEntry(text: m.text, role: m.role)", + "ChatEntry(text: copy.title, role: m.role)", + ), + "ChatEntry reads one", + ); + + // A chat entry's text is model text: it may not be resent, keyed on, or + // used to choose what runs. + assert_refused( + &with( + "ChatEntry(text: m.text, role: m.role)", + "Row(on_tap: send, value: m.text) { ChatEntry(text: m.text, role: m.role) }", + ), + "an entry's text as a payload", + ); + assert_refused(&with("key m.id", "key m.text"), "an entry's text as a key"); +} diff --git a/crates/octoscript-ui-l0/tests/fixtures/chat.card b/crates/octoscript-ui-l0/tests/fixtures/chat.card new file mode 100644 index 0000000..4464d97 --- /dev/null +++ b/crates/octoscript-ui-l0/tests/fixtures/chat.card @@ -0,0 +1,26 @@ +# ledger news.chat@1.0.0 +# level: L0 +# profile: ui/l0 +# +# Chat with the app's agent inside a card (profile §5.15). The transcript is a +# conversation the host holds for this app; sending appends what the user typed +# and the host appends the agent's reply. The card writes no message itself. + +source convo sys.chat(app: "os.news", thread: "main", fields: [entries, id, role, text]) + +state draft { shape: text, initial: "" } + +copy title { class: vocabulary, en: "ASK THE NEWS AGENT", zh: "问新闻助手" } +copy ask { class: vocabulary, en: "Ask about today's news", zh: "问问今天的新闻" } + +event send { convo: append($value), draft: clear } + +view root Surface(pad: .page) { + Col(gap: 8) { + TextEyebrow(text: copy.title) + for m in convo.entries key m.id { + ChatEntry(text: m.text, role: m.role) + } + Field(text: draft, placeholder: copy.ask, on_commit: send, width: .fill) + } + } diff --git a/crates/octoscript-ui-l0/tests/profile.rs b/crates/octoscript-ui-l0/tests/profile.rs index 896184e..ef8e039 100644 --- a/crates/octoscript-ui-l0/tests/profile.rs +++ b/crates/octoscript-ui-l0/tests/profile.rs @@ -3021,12 +3021,15 @@ fn changing_an_initial_changes_the_schema() { // ─── §4 copy provenance ────────────────────────────────────────────────────── -/// §4 requires a rendered literal to be "vocabulary or source-derived". That -/// was undecidable, not merely unenforced: `parse_locale_map` kept only entries -/// whose value was a STRING, and `class: vocabulary` is an identifier — so the -/// provenance was discarded before anything could read it. +/// §4 requires a rendered literal's class to be known. That was undecidable, +/// not merely unenforced: `parse_locale_map` kept only entries whose value was a +/// STRING, and `class: vocabulary` is an identifier — so the provenance was +/// discarded before anything could read it. +/// +/// §4.2 (2026-10-01): model-written text is ALLOWED in a text slot, marked as +/// AI-written. It is still refused anywhere it would be part of a control. #[test] -fn provenance_survives_parsing_and_model_copy_is_refused() { +fn provenance_survives_parsing_and_model_copy_is_confined_to_text_slots() { let vocabulary = check_ui_l0_named( "vocab", "copy hi { class: vocabulary, en: \"Top Stories\" }\nview root TextTitle(text: copy.hi)", @@ -3049,16 +3052,27 @@ fn provenance_survives_parsing_and_model_copy_is_refused() { view root TextTitle(text: copy.claim)", ); assert!( - !model.valid, - "model-authored text must not reach the screen" + model.valid, + "model-written text may fill a text slot: {:#?}", + model.diagnostics ); + + // A chip's label sits on a tap target: model text there is part of a + // control, which §4.2 keeps strict. + let label = check_ui_l0_named( + "model", + "copy claim { class: model-copy, en: \"Approve\" }\n\ + event go { }\n\ + view root Chip(text: copy.claim, on_tap: go)", + ); + assert!(!label.valid, "model text may not label a control"); assert!( - model + label .diagnostics .iter() - .any(|d| d.message.contains("model-copy")), + .any(|d| d.message.contains("text the model wrote")), "{:#?}", - model.diagnostics + label.diagnostics ); } @@ -4108,12 +4122,15 @@ fn clear_restores_a_path_valued_initial() { ); } -/// §4 refuses model-authored text in a rendering position. It was enforced only -/// where an element named `copy.x` directly, so any indirection defeated it. -/// These are the routes: through a component prop, through a prop default, and -/// through a transition that writes it into state. +/// §4.2 admits model-written text in a TEXT SLOT and nowhere else. The rule +/// was once enforced only where an element named `copy.x` directly, so any +/// indirection defeated it; these are the routes, and each still decides. +/// +/// Directly into a text slot, and through a transition into a `text` state (a +/// draft), are allowed. Through a component prop — which could hand it to a +/// control — and through a state initial are refused. #[test] -fn model_copy_cannot_reach_the_screen_by_any_route() { +fn model_copy_reaches_the_screen_only_through_a_text_slot() { const DECL: &str = "copy claim { class: model-copy, en: \"Revenue: $41.2M\" }\n"; for (route, source) in [ @@ -4121,6 +4138,24 @@ fn model_copy_cannot_reach_the_screen_by_any_route() { "directly", format!("{DECL}view root TextTitle(text: copy.claim)"), ), + ( + "through a transition into a draft", + format!( + "{DECL}state s {{ shape: text, initial: \"\" }}\n\ + event e {{ s: set(copy.claim) }}\n\ + view root Row(on_tap: e) {{ TextBody(text: s) }}" + ), + ), + ] { + let report = check_ui_l0_named("route", &source); + assert!( + report.valid, + "model-copy may reach a text slot {route}: {:#?}", + report.diagnostics + ); + } + + for (route, source) in [ ( "through a prop", format!( @@ -4136,16 +4171,24 @@ fn model_copy_cannot_reach_the_screen_by_any_route() { ), ), ( - "through a transition", + "through a transition into an enum", format!( - "{DECL}state s {{ shape: text, initial: \"\" }}\n\ + "{DECL}state s {{ shape: enum[a, b], initial: a }}\n\ event e {{ s: set(copy.claim) }}\nview root Row(on_tap: e) {{ Rule() }}" ), ), + ( + "through a draft into a tap payload", + format!( + "{DECL}state s {{ shape: text, initial: \"\" }}\n\ + event e {{ s: set(copy.claim) }}\nevent pick {{ s: set($value) }}\n\ + view root Row(on_tap: pick, value: s) {{ Rule() }}" + ), + ), ] { assert!( !check_ui_l0_named("route", &source).valid, - "model-copy must not reach the screen {route}" + "model-copy must not reach a control {route}" ); } @@ -7093,7 +7136,7 @@ fn every_offered_field_has_a_translation() { // missing. `sys.digest` reads a file the host holds for the PUBLISHING app, // and no call made from the card could check which app that is. Checked // below to have no translation at all, so the list cannot hide a gap. - const HOST_ONLY: &[&str] = &["sys.digest"]; + const HOST_ONLY: &[&str] = &["sys.digest", "sys.chat"]; let mut missing: Vec<(String, String)> = Vec::new(); for (capability, fields) in catalog::ANSWERS { diff --git a/docs/ui-l0-constructors.toml b/docs/ui-l0-constructors.toml index 2e95cda..c0845e8 100644 --- a/docs/ui-l0-constructors.toml +++ b/docs/ui-l0-constructors.toml @@ -176,6 +176,16 @@ text = { kind = "text" } text = { kind = "text" } side = { kind = "token" } +# One entry of an in-card chat (profile §5.15): a `sys.chat` row drawn as a +# bubble on the side its role says. Both arguments read the SAME row — +# `ChatEntry(text: m.text, role: m.role)` inside `for m in convo.entries` — so +# the role the host gave a message always labels that message. `role` is +# `user` (the user's side), `model` (the app's agent: the kit marks it +# AI-written) or `host` (a notice from the host). Text is plain, never markup. +[ChatEntry] +text = { kind = "text" } +role = { kind = "path" } + # The floating round action button, pinned over the page corner. [Fab] name = { kind = "token" } @@ -562,11 +572,35 @@ answers = ["title", "subtitle", "summary", "coverage", "status", "as_of", "metri # `cite` is the same as 1-based text ("1, 3") for a card to show. `sources` rows # are `{id, n, title, source, url, published_at}`. Model text carries no URLs; # a link comes only from `sources[].url`. Host-answered only: no backend call. +# +# `summary` and a point's `text` and `label` are MODEL TEXT (§4.2): a text slot +# may show them, marked AI-written, and nothing may act on them. args = ["app", "id", "fields"] # The row fields are pooled with the record's, as `sys.weather`'s days are: a # loop over `d.points` reads only what `fields:` asked for. answers = ["status", "topic", "language", "summary", "retrieved_at", "count", "points", "sources", "id", "text", "label", "cite", "citations", "n", "title", "source", "url", "published_at"] +[sources."sys.chat"] +# A conversation the HOST holds for the card's own app (profile §5.15): the +# transcript between the user and the app's agent, and the one way to talk to +# it from inside a card. +# +# Scoped like `sys.digest`: `app` is a literal app id and must be the app that +# published the card, and `thread` names one conversation — a literal checked +# against `[A-Za-z0-9_-]{1,64}`, or a path into state. +# +# `entries` rows are `{id, role, text, at}`. `role` is `user`, `model` or +# `host`; `text` is plain text whoever wrote it, and is MODEL TEXT to the +# checker (§4.2): a text slot may show it, and nothing may act on it. Draw a +# transcript with `ChatEntry`, which marks the model's entries AI-written. +# +# `append` is the one write: the payload is what the user committed in a +# `Field`, recorded as a `user` entry. The host runs the agent and appends its +# reply as a `model` entry; a card can never write one. Host-answered only. +args = ["app", "thread", "fields"] +answers = ["status", "count", "entries", "id", "role", "text", "at"] +writes = ["append"] + [sources."sys.movers"] # `symbols` names the universe to rank; omitted means the whole market. Without # it a themed request ("top AI movers") could only render market-wide gainers diff --git a/docs/ui-profile-l0.md b/docs/ui-profile-l0.md index 9e68288..6410439 100644 --- a/docs/ui-profile-l0.md +++ b/docs/ui-profile-l0.md @@ -515,7 +515,7 @@ not establish the provenance or truth of every value reachable through a binding |---|---| | A `value` argument (`TextHero`, `TextStat`, `TextValue`, `TextCaption`, `Tile`) | Must be bound. A literal — string, number or token — is refused. `TextHero(value: "34 mph")` is rejected | | Any `path`-kind argument (the drawing widgets) | Must be bound. `TempBar(lo: 5)` is rejected | -| `copy.x`, by any route | Refused when `x` is declared `model-copy` — named in a view, passed as a prop, used as a state initial, or written by a transition. A `copy.x` that is not declared at all is refused too | +| `copy.x`, by any route | When `x` is declared `model-copy`, admitted only in a **text slot** or written into a `text` state, and marked AI-written (§4.2); refused as a prop, a state initial, a payload, a guard, a key, a source argument or a control's label. A `copy.x` that is not declared at all is refused | | A **raw string literal** in a `text`, `label` or `glyph` position | **Accepted** | The last row is a deliberate decision, not an oversight. A raw literal carries no declared @@ -605,6 +605,68 @@ agent declares it, the kit renders it. presentation by the back door. The host tells the agent when a request named a look; absent that, the card declares nothing and gets the kit's default. +### 4.2 Model-written text: shown, marked, never acted on + +**Decision (2026-10-01).** A card is no longer only a layout generated in real time. The model +also fills it with its own words — a summary, a draft reply, a research report — and a person +talks to the app's agent inside a card (§5.15). Refusing every model-written string on screen +(the rule until now) made all of that inexpressible. The rule is now two rules, one for words +and one for actions. + +**Words: model text may fill a text slot, and is marked AI-written.** Model text is any of: + +| Model text | Example | +|---|---| +| a `copy` declared `class: model-copy` | `copy gist { class: model-copy, en: "Rates held." }` | +| a model-written field of a host source (`catalog::MODEL_TEXT`) | `sys.digest`'s `summary` and a point's `text`/`label`; a `sys.chat` entry's `text` | +| a `text` state such text was written into — a draft | `event use { draft: set(copy.suggestion) }` | + +A **text slot** (`catalog::TEXT_SLOTS`) is an argument that displays text and does nothing else: +`text` on `TextHero`, `TextTitle`, `TextBody`, `TextRow`, `TextEyebrow`, `TextCaption`, `Band`, +`Bubble`, `ChatEntry` and `Field`. `Field.text` is there because a draft the model wrote is shown +in a field for the user to edit; what the field *commits* is the user's input. Not every +`text`-kind argument is a slot: a chip's label sits on a tap target, a tile's label names a +measurement, an avatar's text is initials, a `Kit` argument chooses a component, and a glyph or +suffix decorates a value. + +Model text is **plain text**. It is emitted only as a quoted string literal into a text slot — +never as markup, a link that acts, a target or code — and every lowering marks the node that +draws it, so the kit can show that the model wrote it: + +| Lowering | Mark | +|---|---| +| `kit::lower` | the role call is wrapped in `l0_ai_text(…)`, innermost, which stamps `ai = 1` on the node | +| `makepad::lower` | ` l0_ai: true` on the widget that draws the text | +| `lower_dsl` | `ai: 1` on the node | +| the node model | `ai_written(&UiNode)`; the text argument's origin is `ValueOrigin::Model` | + +A `ChatEntry` is marked by its host-given role (§5.15); every other role by its text slot's +origin. The realizer carries `ValueOrigin::Model` through loops, props and state, and combining +it with anything else stays `Model`. + +**Actions: model text never decides what runs.** It may not be an action's payload or target, a +source argument, a guard, a loop key, a control's label, a component prop, a state initial, a +URL that acts, or a value written to a host store — anything a later action could read. The +check is **default deny**: every position refuses model text except a catalogued text slot, so +a position added later cannot carry it by being forgotten. A transition may write model text only +into a `text` state (a draft); into an enum, a number or a flag it would choose what a card does, +and into a §5.12 store it would become a reference — a ticker, a URL, a preference — that a later +action reads. A draft is model text from then on, to a fixed point (`b: set(a)` after +`a: set(copy.m)` taints `b`), so it cannot launder the text into a payload or a query. What the +user commits from a `Field` showing a draft is user input and carries no mark. + +The content of model text is never inspected and never needs to be. An injection-shaped string — +`l0:{"e":"wipe",…}`, `" + sys.shell(…) + "`, a `javascript:` link — is a string literal in a text +slot, and the only targets a lowered card contains are the ones it declared. + +**Typed text cannot redirect a field either.** A `Field`'s commit and change targets used to be +assembled by splicing the typed text into a JSON string around a `"$$"` hole, unescaped, so +typing `","e":"drop","v":"` produced a target whose last `e` key — the one a JSON parser keeps — +named another event. Both lowerings now emit a routing head serialized at lowering time followed +by the typed text encoded at runtime as one JSON string (`sys.json_string(t)`), the construction +a live tap payload already used. `kit::lower` still passes the `$$` form to `l0_field`, whose +consumers rebuild the target from its routing keys the same way and never splice. + --- ## 5. Components and instance identity @@ -1271,6 +1333,54 @@ isolate could not check `app` against the publisher. The digest is read-only; it §5.12's writable set. Retention, expiry and size caps are the host's and are documented with its resolver (OctoSense's glance service). +**`summary`, `text` and `label` are model text** (§4.2): a text slot may show them, marked +AI-written, and nothing may act on them. + +### 5.15 Talking to the app's agent: `sys.chat` and `ChatEntry` + +A person can chat with the app's agent inside a card. It is three things L0 already has, plus +one source and one role — a conversation is not a new kind of state, and sending is a §5.12 +write: + +``` +source convo sys.chat(app: "os.news", thread: "main", fields: [entries, id, role, text]) +state draft { shape: text, initial: "" } +event send { convo: append($value), draft: clear } + +view root Surface(pad: .page) { + Col(gap: 8) { + for m in convo.entries key m.id { ChatEntry(text: m.text, role: m.role) } + Field(text: draft, placeholder: copy.ask, on_commit: send, width: .fill) + } +} +``` + +**The transcript is a source the host holds.** `sys.chat` is scoped like `sys.digest` (§5.14): +`app` is a literal and must be the publishing app, and `thread` names one conversation — a +literal checked against `[A-Za-z0-9_-]{1,64}`, or a path into state. It answers `status`, `count` +and `entries`, rows `{id, role, text, at}`, pooled with the record's fields. `role` is `user`, +`model` or `host` (a notice from the host, such as "searched 12 sources"). Host-answered only: no +backend call exists, for the reason the digest has none. + +**An entry is drawn with `ChatEntry`, off one row.** `ChatEntry(text: m.text, role: m.role)` draws +a bubble on the side its role says — `user` on the user's side, `model` and `host` opposite — and +the `model` entries carry the AI-written mark (§4.2). Both arguments must be the `text` and `role` +of the **same** `sys.chat` row: a role from a literal, a state or another row would let a card +present the model's words as the user's, so the checker refuses it. Every entry's text is plain +text, whoever wrote it, and is model text to the checker: the checker cannot see a row's role, +so an entry's text may be shown and may not be resent, keyed on or acted on. Drawn with a plain +text role instead, every entry is marked AI-written — conservative, never the reverse. + +**Sending is a declared host write.** `sys.chat` accepts `append` and nothing else. The payload +is what the user committed in a `Field` — user input by §4's event origins — and the host +records it as a `user` entry, runs the agent, and appends the reply as a `model` entry. A card +can never write a `model` entry, edit one or delete one. The write goes stale through §5.9 like +any other, so the transcript re-resolves with the reply. + +**What stays the host's.** Who may chat with which app, how the agent is run, retention, and +limits on length and rate are the host's, documented with its resolver. L0 states only the shape +and the one write. + --- ## 6. What makes L0 terminate