Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
57 changes: 55 additions & 2 deletions crates/octoscript-ui-l0/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -3666,6 +3666,12 @@ 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 {
Expand Down Expand Up @@ -3744,6 +3750,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.
Expand Down Expand Up @@ -3810,6 +3819,25 @@ 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",
&[
Expand Down Expand Up @@ -4084,6 +4112,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]> {
Expand Down Expand Up @@ -4376,6 +4407,25 @@ 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)`
Expand Down Expand Up @@ -4891,6 +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.chat" => check_app_scoped_source(
source,
("thread", "thread id", "which of the app's conversations"),
Expand Down Expand Up @@ -8266,7 +8319,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,
Expand Down Expand Up @@ -10166,7 +10219,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,
Expand Down
162 changes: 162 additions & 0 deletions crates/octoscript-ui-l0/tests/mail.rs
Original file line number Diff line number Diff line change
@@ -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");
}
7 changes: 6 additions & 1 deletion crates/octoscript-ui-l0/tests/profile.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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"];
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 {
Expand Down
16 changes: 16 additions & 0 deletions docs/ui-l0-constructors.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
51 changes: 50 additions & 1 deletion docs/ui-profile-l0.md
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down Expand Up @@ -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
Expand Down
Loading