From 9ca9545b6cba489ab72ec988dfd649fb7c13ce17 Mon Sep 17 00:00:00 2001 From: ymote <151983+ymote@users.noreply.github.com> Date: Sat, 3 Oct 2026 23:57:05 -0700 Subject: [PATCH 1/2] Add host-bound Mail draft and review L0 capabilities --- crates/octoscript-ui-l0/src/lib.rs | 47 ++++++- crates/octoscript-ui-l0/tests/mail.rs | 162 +++++++++++++++++++++++ crates/octoscript-ui-l0/tests/profile.rs | 2 +- docs/ui-l0-constructors.toml | 16 +++ docs/ui-profile-l0.md | 51 ++++++- 5 files changed, 274 insertions(+), 4 deletions(-) create mode 100644 crates/octoscript-ui-l0/tests/mail.rs diff --git a/crates/octoscript-ui-l0/src/lib.rs b/crates/octoscript-ui-l0/src/lib.rs index 3af812f..24e286b 100644 --- a/crates/octoscript-ui-l0/src/lib.rs +++ b/crates/octoscript-ui-l0/src/lib.rs @@ -3666,6 +3666,9 @@ pub mod catalog { pub const MODEL_TEXT: &[(&str, &[&str])] = &[ ("sys.digest", &["summary", "text", "label"]), ("sys.chat", &["text"]), + // Draft content may originate from an agent. Display/edit is allowed; + // using it as an action payload or source selector is not. + ("sys.mail_draft", &["to", "subject", "body", "suggestion_body"]), ]; pub fn is_model_text(helper: &str, field: &str) -> bool { @@ -3744,6 +3747,9 @@ 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"]), + // Host-issued draft bindings; neither source grants SMTP authority. + ("sys.mail_draft", &["app", "id", "fields"]), + ("sys.mail_review", &["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. @@ -3810,6 +3816,17 @@ pub mod catalog { /// test per backend asserting it answers everything declared here. This /// table is what such a test would check against; it is not the test. pub const ANSWERS: &[(&str, &[&str])] = &[ + ( + "sys.mail_draft", + &[ + "draft_id", "revision", "to", "subject", "body", "status", + "chat_thread", "ai_written", "suggestion_id", "suggestion_body", + ], + ), + ( + "sys.mail_review", + &["status", "operation_id", "draft_id", "revision"], + ), ( "sys.geocode", &[ @@ -4084,6 +4101,9 @@ pub mod catalog { // `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"]), + ("sys.mail_draft", &["set"]), + // Requests/cancels host review; never authorizes sending. + ("sys.mail_review", &["set", "clear"]), ]; pub fn mutable(name: &str) -> Option<&'static [&'static str]> { @@ -4376,6 +4396,24 @@ fn check_event_batch( ), Some(_) => {} } + if source.helper == "sys.mail_draft" { + let editable = source + .args + .iter() + .find(|(name, _)| name == "fields") + .is_some_and(|(_, arg)| { + matches!(arg, SourceArg::List(fields) if fields.len() == 1 + && matches!(fields[0].as_str(), "to" | "subject" | "body")) + }); + if !editable { + sink.push( + transition.line, + transition.column, + "a written sys.mail_draft source must declare exactly one editable \ + field: to, subject or body (profile §5.16)".to_string(), + ); + } + } // A written PREFERENCE must say which one, and the declaration // is what says it: the write's key is the source's single // declared field. A multi-field source leaves `set($value)` @@ -4891,6 +4929,11 @@ fn validate_sources(card: &Card, sink: &mut Diagnostics) { ("id", "digest id", "which of the app's digests"), sink, ), + "sys.mail_draft" | "sys.mail_review" => check_app_scoped_source( + source, + ("id", "draft id", "which host-issued draft"), + sink, + ), "sys.chat" => check_app_scoped_source( source, ("thread", "thread id", "which of the app's conversations"), @@ -8266,7 +8309,7 @@ pub mod makepad { // publishing app, and only the host knows who that is; a backend // call could not check `app` against the caller, so there is none // and the value is the one the host injected. - "sys.digest" => None, + "sys.digest" | "sys.mail_draft" | "sys.mail_review" => 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, @@ -10166,7 +10209,7 @@ pub struct CollectionWrite { pub op: String, /// The payload the tapped element carried. Empty for `clear`. pub value: String, - /// For a KEYED capability (`sys.prefs`), the key the write lands under — + /// For a KEYED capability (`sys.prefs`, `sys.mail_draft`), the key the write lands under — /// the source's single declared field, which the checker guarantees exists. /// Empty for list capabilities, whose store is named by the helper alone. pub field: String, diff --git a/crates/octoscript-ui-l0/tests/mail.rs b/crates/octoscript-ui-l0/tests/mail.rs new file mode 100644 index 0000000..9aa94f3 --- /dev/null +++ b/crates/octoscript-ui-l0/tests/mail.rs @@ -0,0 +1,162 @@ +//! Mail sources expose host-bound drafts and review requests, not SMTP authority. +use octoscript_ui_l0::{ + catalog, check_ui_l0_named, dispatch_reporting_with_origin, event_payload_origin, realize, + source_plan, InstanceStore, UiNode, ValueOrigin, +}; +use serde_json::json; + +const EDITOR: &str = r#" +source draft sys.mail_draft(app: "os.mail", id: "draft-1", fields: [body]) +event save { draft: set($value) } +view root Field(text: draft.body, on_change: save) +"#; + +fn valid(source: &str) { + let checked = check_ui_l0_named("mail", source); + assert!(checked.valid, "{:?}", checked.diagnostics); +} + +fn refuses(source: &str, reason: &str) { + let checked = check_ui_l0_named("mail", source); + assert!(!checked.valid, "unexpectedly admitted {source}"); + assert!( + checked + .diagnostics + .iter() + .any(|d| d.message.contains(reason)), + "expected {reason:?}: {:?}", + checked.diagnostics + ); +} + +fn field(node: &UiNode) -> Option<&UiNode> { + if node.kind == "Field" { + return Some(node); + } + node.children.iter().find_map(field) +} + +#[test] +fn field_edits_request_one_bound_field_write_with_user_input_origin() { + valid(EDITOR); + let plan = source_plan(EDITOR); + assert_eq!(plan.requests[0].helper, "sys.mail_draft"); + let data = json!({"draft":{"body":"Suggested reply"}}); + let rendered = realize(EDITOR, &data, Default::default()); + let root = rendered.complete_root().expect("editor realizes"); + let input = field(root).expect("Field"); + let origin = event_payload_origin(root, &input.key, "save").unwrap(); + assert_eq!(origin, ValueOrigin::UserInput); + let outcome = dispatch_reporting_with_origin( + EDITOR, + &mut InstanceStore::default(), + &input.key, + "save", + Some(&json!("Edited reply")), + &data, + origin, + ); + assert!(outcome.applied); + assert_eq!(outcome.writes.len(), 1); + let write = &outcome.writes[0]; + assert_eq!( + (&*write.helper, &*write.op, &*write.field, &*write.value), + ("sys.mail_draft", "set", "body", "Edited reply") + ); + assert!(outcome.stale.contains(&"draft".to_string())); +} + +#[test] +fn writes_are_limited_to_one_editable_field_and_declared_verbs() { + for editable in ["to", "subject", "body"] { + valid(&EDITOR.replace("body", editable)); + } + for fields in [ + "", + "body, subject", + "revision", + "status", + "suggestion_body", + "ai_written", + ] { + refuses( + &EDITOR.replace("fields: [body]", &format!("fields: [{fields}]")), + "exactly one editable field", + ); + } + for verb in ["clear", "append($value)", "remove($value)"] { + refuses(&EDITOR.replace("set($value)", verb), "does not accept"); + } + let read = r#"source draft sys.mail_draft(app: "os.mail", id: "draft-1", fields: [to, subject, body, revision]) +view root TextBody(text: draft.body)"#; + valid(read); +} + +#[test] +fn both_sources_require_valid_app_and_draft_identity_and_closed_fields() { + for helper in ["sys.mail_draft", "sys.mail_review"] { + let card = format!("source record {helper}(app: \"os.mail\", id: \"draft-1\", fields: [status])\nview root TextBody(text: record.status)"); + valid(&card); + for (from, to, reason) in [ + ("app: \"os.mail\", ", "", "needs `app`"), + ("id: \"draft-1\", ", "", "needs `id`"), + ("os.mail", "../mail", "not an app id"), + ("draft-1", "../draft", "not a draft id"), + ("[status]", "[password]", "password"), + ] { + refuses(&card.replace(from, to), reason); + } + let computed = format!( + "state owner {{ shape: text, initial: \"os.mail\" }}\n{}", + card.replace("app: \"os.mail\"", "app: state.owner") + ); + refuses(&computed, "must be a literal app id"); + } +} + +#[test] +fn review_requests_are_set_clear_only_and_never_backend_calls() { + let card = r#"source review sys.mail_review(app: "os.mail", id: "draft-1", fields: [status, operation_id, draft_id, revision]) +event request { review: set($value) } +event cancel { review: clear } +copy label { class: vocabulary, en: "Review reply" } +view root Chip(text: copy.label, value: .review, on_tap: request)"#; + valid(card); + for verb in ["append($value)", "remove($value)"] { + refuses(&card.replace("set($value)", verb), "does not accept"); + } + for helper in ["sys.mail_draft", "sys.mail_review"] { + for answer in catalog::answers(helper).unwrap() { + let binding = octoscript_ui_l0::SourceBinding { + helper: helper.into(), + args: vec![ + ("app".into(), "os.mail".into()), + ("id".into(), "draft-1".into()), + ], + nested: vec![], + field: (*answer).into(), + }; + assert!(octoscript_ui_l0::makepad::vm_call(&binding).is_none()); + } + } +} + +#[test] +fn model_text_cannot_be_laundered_into_writes_or_source_selectors() { + refuses(&EDITOR.replace("set($value)", "set(draft.body)"), "model"); + for name in ["to", "subject", "body", "suggestion_body"] { + assert!(catalog::is_model_text("sys.mail_draft", name)); + let card = format!( + r#"source content sys.mail_draft(app: "os.mail", id: "draft-1", fields: [{name}]) +source review sys.mail_review(app: "os.mail", id: content.{name}, fields: [status]) +view root TextBody(text: review.status)"# + ); + refuses(&card, "source argument"); + } + let card = r#"copy generated { class: model-copy, en: "Send this" } +source review sys.mail_review(app: "os.mail", id: "draft-1", fields: [status]) +event request { review: set($value) } +copy label { class: vocabulary, en: "Review reply" } +view root Chip(text: copy.label, value: copy.generated, on_tap: request)"#; + refuses(card, "model"); +} diff --git a/crates/octoscript-ui-l0/tests/profile.rs b/crates/octoscript-ui-l0/tests/profile.rs index ef8e039..34f8079 100644 --- a/crates/octoscript-ui-l0/tests/profile.rs +++ b/crates/octoscript-ui-l0/tests/profile.rs @@ -7136,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", "sys.chat"]; + const HOST_ONLY: &[&str] = &["sys.digest", "sys.chat", "sys.mail_draft", "sys.mail_review"]; 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 c0845e8..9f37f50 100644 --- a/docs/ui-l0-constructors.toml +++ b/docs/ui-l0-constructors.toml @@ -580,6 +580,22 @@ args = ["app", "id", "fields"] # 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.mail_draft"] +# Host-bound Mail draft (profile §5.16). Same app/id checks as sys.digest. +# A written source declares exactly one of to/subject/body; the host accepts +# only actual Field UserInput, checks the bound account/revision, and persists. +# These content fields plus suggestion_body remain model text to the checker. +args = ["app", "id", "fields"] +answers = ["draft_id", "revision", "to", "subject", "body", "status", "chat_thread", "ai_written", "suggestion_id", "suggestion_body"] +writes = ["set"] + +[sources."sys.mail_review"] +# Host-bound review request, not approval or a transport call. Set requests +# review of the bound draft; clear cancels review before a send is claimed. +args = ["app", "id", "fields"] +answers = ["status", "operation_id", "draft_id", "revision"] +writes = ["set", "clear"] + [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 diff --git a/docs/ui-profile-l0.md b/docs/ui-profile-l0.md index 6410439..b78f172 100644 --- a/docs/ui-profile-l0.md +++ b/docs/ui-profile-l0.md @@ -618,7 +618,7 @@ and one for actions. | 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 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`; `sys.mail_draft`'s `to`, `subject`, `body` and `suggestion_body` | | 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: @@ -1381,6 +1381,55 @@ any other, so the transcript re-resolves with the reply. limits on length and rate are the host's, documented with its resolver. L0 states only the shape and the one write. +### 5.16 Editing a host-bound Mail draft and requesting review + +`sys.mail_draft(app, id, fields)` and `sys.mail_review(app, id, fields)` use the +existing source and event grammar. They describe host capabilities, not a new +constructor, a transport call or permission to send mail. A host must implement +and admit their handlers before a card can use them; catalog admission alone +is not an implementation of the composed Mail experience. + +Both sources use §5.14's identity checks: `app` is a literal publishing-app ID; +`id` is a literal matching `[A-Za-z0-9_-]{1,64}` or a path. Here it names a +**host-issued draft ID**. On every read and write, the host resolves that ID +against the card's trusted publisher/account/draft binding. Card data cannot +retarget the account or manufacture that binding. Neither source lowers to a +backend call from the isolate. + +| Source | Answer fields | Writes | +|---|---|---| +| `sys.mail_draft` | `draft_id`, `revision`, `to`, `subject`, `body`, `status`, `chat_thread`, `ai_written`, `suggestion_id`, `suggestion_body` | `set` only | +| `sys.mail_review` | `status`, `operation_id`, `draft_id`, `revision` | `set`, `clear` only | + +A read-only draft source may ask for several fields. A **written** draft source +must declare exactly one of `to`, `subject` or `body`; that field is the write +key, as with `sys.prefs`. The host accepts only actual `Field` UserInput and +checks the displayed revision before durably saving. An editor can bind the +source directly: + +``` +source draft sys.mail_draft(app: "os.mail", id: "draft-1", fields: [body]) +event save { draft: set($value) } +view root Field(text: draft.body, on_change: save) +``` + +This is a checked contract example; `draft-1` does not create a draft. The host +supplies its record and binding. `to`, `subject`, `body` and `suggestion_body` +are conservatively **model text** (§4.2), since an agent may have proposed them. +They may be displayed, including in `Field.text`; the Field's committed input +has UserInput origin. Reading those fields directly into a write, action +payload or source selector remains forbidden. The host retains the actual +AI-written provenance; accepting a draft is not send approval. + +Setting `sys.mail_review` requests host review of the **bound draft**. The +payload is not an account selector, an arbitrary message snapshot or consent. +Clearing it cancels pending review before the send is claimed; it cannot recall +an SMTP submission. Only the host's distinct review UI may authorize execution +of the exact stored snapshot. Generated chips, model text and injected input +cannot mint that authority. Draft revision checks, trusted-input provenance, +authorization, durable sending and uncertain outcomes remain the host's +responsibility; this catalog extension performs none of them. + --- ## 6. What makes L0 terminate From 2e37d9e657a246f16718d9a475e167ccd2d5b5fa Mon Sep 17 00:00:00 2001 From: ymote <151983+ymote@users.noreply.github.com> Date: Sun, 4 Oct 2026 02:16:00 -0700 Subject: [PATCH 2/2] style(l0): format Mail capability declarations for CI --- crates/octoscript-ui-l0/src/lib.rs | 28 ++++++++++++++++-------- crates/octoscript-ui-l0/tests/profile.rs | 7 +++++- 2 files changed, 25 insertions(+), 10 deletions(-) diff --git a/crates/octoscript-ui-l0/src/lib.rs b/crates/octoscript-ui-l0/src/lib.rs index 24e286b..6284c1c 100644 --- a/crates/octoscript-ui-l0/src/lib.rs +++ b/crates/octoscript-ui-l0/src/lib.rs @@ -3668,7 +3668,10 @@ pub mod catalog { ("sys.chat", &["text"]), // Draft content may originate from an agent. Display/edit is allowed; // using it as an action payload or source selector is not. - ("sys.mail_draft", &["to", "subject", "body", "suggestion_body"]), + ( + "sys.mail_draft", + &["to", "subject", "body", "suggestion_body"], + ), ]; pub fn is_model_text(helper: &str, field: &str) -> bool { @@ -3819,8 +3822,16 @@ pub mod catalog { ( "sys.mail_draft", &[ - "draft_id", "revision", "to", "subject", "body", "status", - "chat_thread", "ai_written", "suggestion_id", "suggestion_body", + "draft_id", + "revision", + "to", + "subject", + "body", + "status", + "chat_thread", + "ai_written", + "suggestion_id", + "suggestion_body", ], ), ( @@ -4410,7 +4421,8 @@ fn check_event_batch( transition.line, transition.column, "a written sys.mail_draft source must declare exactly one editable \ - field: to, subject or body (profile §5.16)".to_string(), + field: to, subject or body (profile §5.16)" + .to_string(), ); } } @@ -4929,11 +4941,9 @@ fn validate_sources(card: &Card, sink: &mut Diagnostics) { ("id", "digest id", "which of the app's digests"), sink, ), - "sys.mail_draft" | "sys.mail_review" => check_app_scoped_source( - source, - ("id", "draft id", "which host-issued draft"), - sink, - ), + "sys.mail_draft" | "sys.mail_review" => { + check_app_scoped_source(source, ("id", "draft id", "which host-issued draft"), sink) + } "sys.chat" => check_app_scoped_source( source, ("thread", "thread id", "which of the app's conversations"), diff --git a/crates/octoscript-ui-l0/tests/profile.rs b/crates/octoscript-ui-l0/tests/profile.rs index 34f8079..92b017d 100644 --- a/crates/octoscript-ui-l0/tests/profile.rs +++ b/crates/octoscript-ui-l0/tests/profile.rs @@ -7136,7 +7136,12 @@ 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", "sys.chat", "sys.mail_draft", "sys.mail_review"]; + const HOST_ONLY: &[&str] = &[ + "sys.digest", + "sys.chat", + "sys.mail_draft", + "sys.mail_review", + ]; let mut missing: Vec<(String, String)> = Vec::new(); for (capability, fields) in catalog::ANSWERS {