From 77aff52e045e4e105006f1f5819a37d905e71755 Mon Sep 17 00:00:00 2001 From: ymote <151983+ymote@users.noreply.github.com> Date: Sun, 27 Sep 2026 18:25:20 -0700 Subject: [PATCH] Add sys.digest: a host-resolved digest source for L0 cards An app's agent findings become a source the host resolves, with provenance, instead of text the card carries (OctoSense ADR 0002 section 7). - Catalog: `sys.digest(app, id, fields)`, answering one template-independent record: status, topic, language, summary, retrieved_at, count, points ({id, text, label, cite, citations}) and sources ({id, n, title, source, url, published_at}). Row fields are pooled with the record's, as a forecast's days are. - Checker: `app` is required and must be a literal app id (the host compares it with the publisher); `id` is required, a literal in the run-id charset [A-Za-z0-9_-]{1,64} or a path into state. - Host-answered only: vm_call has no lowering, and the translation test checks that it never gains one. - Read-only (not in MUTABLE). - Docs: profile section 5.14 and the TOML entry; tests and a fixture card. Co-Authored-By: Claude Opus 5.5 --- crates/octoscript-ui-l0/src/lib.rs | 106 ++++++++++++++ crates/octoscript-ui-l0/tests/digest.rs | 134 ++++++++++++++++++ .../tests/fixtures/digest.card | 37 +++++ crates/octoscript-ui-l0/tests/profile.rs | 24 ++++ docs/ui-l0-constructors.toml | 22 +++ docs/ui-profile-l0.md | 47 ++++++ 6 files changed, 370 insertions(+) create mode 100644 crates/octoscript-ui-l0/tests/digest.rs create mode 100644 crates/octoscript-ui-l0/tests/fixtures/digest.card diff --git a/crates/octoscript-ui-l0/src/lib.rs b/crates/octoscript-ui-l0/src/lib.rs index f00a7e8..cff93c6 100644 --- a/crates/octoscript-ui-l0/src/lib.rs +++ b/crates/octoscript-ui-l0/src/lib.rs @@ -3689,6 +3689,11 @@ pub mod catalog { ("sys.news_digest", &["query", "language", "count", "fields"]), ("sys.news_status", &["query", "language", "fields"]), ("sys.dataset", &["id", "fields"]), + // A digest the HOST holds for the card's own app: an agent run's + // findings, with the sources they cite (ADR 0002 §7 in OctoSense). + // `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"]), ("sys.quakes", &["count", "offset", "fields"]), ("sys.news_item", &["id", "fields"]), // `symbols` names the UNIVERSE to rank. Without it the only universe is @@ -3851,6 +3856,36 @@ pub mod catalog { ], ), ("sys.news_status", &["status", "message", "count"]), + // One template-independent shape, whatever workflow produced the run. + // `points` rows are `{id, text, label, cite, citations}` — `citations` + // are indexes into `sources`, `cite` the same as 1-based text — and + // `sources` rows `{id, n, title, source, url, published_at}`. Links + // come only from `sources`: model text carries none. The row fields + // are pooled with the record's, as `sys.weather`'s days are (see + // `declared_fields`): a loop over `d.points` reads what `fields:` asked. + ( + "sys.digest", + &[ + "status", + "topic", + "language", + "summary", + "retrieved_at", + "count", + "points", + "sources", + "id", + "text", + "label", + "cite", + "citations", + "n", + "title", + "source", + "url", + "published_at", + ], + ), ( "sys.quakes", &["id", "mag", "place", "depth", "ago", "lat", "lon"], @@ -4753,6 +4788,72 @@ 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); + } +} + +/// `sys.digest` names WHOSE digest and WHICH one, and both are required. +/// +/// `app` is a literal: the host compares it with the app that published the +/// card and refuses any other, so a card that computed it (a path into state or +/// another source) would be asking the host to trust a value the card chose. +/// `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) { + let arg = |name: &str| source.args.iter().find(|(n, _)| n == name).map(|(_, a)| a); + let app_ok = |v: &str| { + !v.is_empty() + && v.len() <= 64 + && v.bytes() + .all(|b| b.is_ascii_alphanumeric() || matches!(b, b'.' | b'_' | b'-')) + }; + let id_ok = |v: &str| { + !v.is_empty() + && v.len() <= 64 + && v.bytes() + .all(|b| b.is_ascii_alphanumeric() || matches!(b, b'_' | b'-')) + }; + match arg("app") { + Some(SourceArg::Text(app)) if app_ok(app) => {} + 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._-])"), + ), + 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(), + ), + None => sink.push( + source.line, + 1, + "sys.digest needs `app`: the card's own app id".to_string(), + ), + } + match arg("id") { + 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_-])"), + ), + Some(_) => sink.push( + source.line, + 1, + "sys.digest: `id` must be a literal digest id or a path".to_string(), + ), + None => sink.push( + source.line, + 1, + "sys.digest needs `id`: which of the app's digests".to_string(), + ), + } } /// Profile §6, condition 1: the declaration graph must be acyclic. @@ -7805,6 +7906,11 @@ pub mod makepad { }; Some(format!("sys.airquality({lat}, {lon}, {path:?})")) } + // Host-answered only. The digest is a file the host holds for the + // 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.dataset" => { let id = text("id")?; if !crate::catalog::answers("sys.dataset")?.contains(&binding.field.as_str()) { diff --git a/crates/octoscript-ui-l0/tests/digest.rs b/crates/octoscript-ui-l0/tests/digest.rs new file mode 100644 index 0000000..7fd1be4 --- /dev/null +++ b/crates/octoscript-ui-l0/tests/digest.rs @@ -0,0 +1,134 @@ +//! `sys.digest`: a host-resolved digest source (OctoSense ADR 0002 §7). +use octoscript_ui_l0::{ + catalog, check_ui_l0_named, makepad, realize, source_plan, NodeValue, SourceArg, SourceBinding, + UiNode, +}; +use serde_json::json; + +const CARD: &str = include_str!("fixtures/digest.card"); + +fn texts(node: &UiNode, out: &mut Vec) { + for (_, v) in &node.args { + if let NodeValue::Text(t) = v { + out.push(t.clone()); + } + } + for c in &node.children { + texts(c, out); + } +} + +fn errors(card: &str) -> Vec { + check_ui_l0_named("digest", card) + .diagnostics + .into_iter() + .map(|d| d.message) + .collect() +} + +#[test] +fn a_digest_card_is_l0_and_the_host_resolves_it() { + let report = check_ui_l0_named("digest", CARD); + assert!(report.valid, "{:?}", report.diagnostics); + assert_eq!(report.level, octoscript_ui_l0::Level::L0); + let plan = source_plan(CARD); + assert!(plan.diagnostics.is_empty(), "{:?}", plan.diagnostics); + let request = &plan.requests[0]; + assert_eq!( + (request.name.as_str(), request.helper.as_str()), + ("brief", "sys.digest") + ); + assert!(request + .args + .contains(&("app".into(), SourceArg::Text("os.news".into())))); + assert!(request + .args + .contains(&("id".into(), SourceArg::Text("morning".into())))); +} + +#[test] +fn the_card_shows_only_what_the_digest_holds() { + let data = json!({"brief": { + "status": "ready", "topic": "city infrastructure", "language": "en", + "summary": "Three cities invest in transit and water.", "retrieved_at": "2026-09-20T08:00:00Z", + "count": 1, + "points": [{"id": "p1", "text": "Harbor City orders 120 electric buses.", "label": "", "cite": "1", "citations": [0]}], + "sources": [{"id": "s1", "n": "1", "title": "Harbor City approves electric bus order", "source": "Harbor Daily", "url": "https://example.invalid/buses", "published_at": "2026-09-19T10:00:00Z"}] + }}); + let report = realize(CARD, &data, Default::default()); + let root = report.complete_root().expect("realizes"); + let mut out = Vec::new(); + texts(root, &mut out); + for want in [ + "Three cities invest in transit and water.", + "Harbor City orders 120 electric buses.", + "Harbor Daily", + "city infrastructure", + ] { + assert!(out.iter().any(|t| t == want), "{want:?} not in {out:?}"); + } + assert!(!out.iter().any(|t| t == "No digest yet"), "{out:?}"); + + // A missing digest: an empty record the host marks failed. The card says + // so, from its own vocabulary, and states nothing else. + let data = json!({"brief": {"status": "missing", "points": [], "sources": []}, "$status": {"brief": "failed"}}); + let report = realize(CARD, &data, Default::default()); + let mut out = Vec::new(); + texts(report.complete_root().expect("realizes"), &mut out); + assert!(out.iter().any(|t| t == "No digest yet"), "{out:?}"); +} + +#[test] +fn app_and_id_are_required_and_checked() { + let with = |args: &str| CARD.replace("app: \"os.news\", id: \"morning\",", args); + assert!(errors(&with("id: \"morning\",")) + .iter() + .any(|e| e.contains("needs `app`"))); + assert!(errors(&with("app: \"os.news\",")) + .iter() + .any(|e| e.contains("needs `id`"))); + // A traversal is not an id; neither is an app id with a slash. + assert!(errors(&with("app: \"os.news\", id: \"../mail/x\",")) + .iter() + .any(|e| e.contains("not a digest id"))); + assert!(errors(&with("app: \"os/news\", id: \"morning\",")) + .iter() + .any(|e| e.contains("not an app id"))); + // `app` is never computed: the host compares it with the publisher. + let card = with("app: state.owner, id: \"morning\",").replace( + "copy label", + "state owner { shape: text, initial: \"os.news\" }\ncopy label", + ); + assert!( + errors(&card) + .iter() + .any(|e| e.contains("must be a literal app id")), + "{:?}", + errors(&card) + ); + // `id` may come from state. + let card = with("app: \"os.news\", id: state.run,").replace( + "copy label", + "state run { shape: text, initial: \"morning\" }\ncopy label", + ); + assert!(errors(&card).is_empty(), "{:?}", errors(&card)); + // Only what the digest answers, and no other argument. + assert!(!errors(&CARD.replace("fields: [topic,", "fields: [password, topic,")).is_empty()); + assert!(!errors(&with("app: \"os.news\", id: \"morning\", path: \"/etc\",")).is_empty()); +} + +#[test] +fn the_digest_is_read_only_and_has_no_backend_call() { + assert!(catalog::mutable("sys.digest").is_none()); + assert!(catalog::answers("sys.digest").unwrap().contains(&"sources")); + let binding = SourceBinding { + helper: "sys.digest".into(), + field: "summary".into(), + nested: vec![], + args: vec![ + ("app".into(), "os.news".into()), + ("id".into(), "morning".into()), + ], + }; + assert!(makepad::vm_call(&binding).is_none()); +} diff --git a/crates/octoscript-ui-l0/tests/fixtures/digest.card b/crates/octoscript-ui-l0/tests/fixtures/digest.card new file mode 100644 index 0000000..aae5188 --- /dev/null +++ b/crates/octoscript-ui-l0/tests/fixtures/digest.card @@ -0,0 +1,37 @@ +# ledger news.glance-digest@1.0.0 +# level: L0 +# profile: ui/l0 +# +# A glance card bound to a digest the host holds for its own app. The card +# carries no text of its own beyond vocabulary: the summary, the points and +# every source come from `sys.digest`. + +source brief sys.digest(app: "os.news", id: "morning", fields: [topic, summary, points, sources, + id, text, cite, n, title, source]) + +copy label { class: vocabulary, en: "NEWS DIGEST", zh: "新闻摘要" } +copy missing { class: vocabulary, en: "No digest yet", zh: "暂无摘要" } + +view root Surface(pad: .page) { + Col(gap: 6) { + Row(gap: 8) { + TextCaption(text: copy.label, width: .fill) + TextCaption(text: brief.topic) + } + when brief.$state == .failed { TextBody(text: copy.missing) } + TextBody(text: brief.summary, width: .fill) + for p in brief.points key p.id { + Row(gap: 8) { + TextRow(text: p.text, width: .fill) + TextCaption(text: p.cite) + } + } + for s in brief.sources key s.id { + Row(gap: 8) { + TextCaption(text: s.n) + TextCaption(text: s.source) + TextCaption(text: s.title, width: .fill) + } + } + } + } diff --git a/crates/octoscript-ui-l0/tests/profile.rs b/crates/octoscript-ui-l0/tests/profile.rs index 48c2b58..896184e 100644 --- a/crates/octoscript-ui-l0/tests/profile.rs +++ b/crates/octoscript-ui-l0/tests/profile.rs @@ -7088,8 +7088,32 @@ fn every_offered_field_has_a_translation() { ("sys.weather", "days"), ]; + // Capabilities only the HOST answers (§5.11), by design: the source plan's + // data blob is their one route, and a backend call is refused rather than + // 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"]; + let mut missing: Vec<(String, String)> = Vec::new(); for (capability, fields) in catalog::ANSWERS { + if HOST_ONLY.contains(capability) { + for field in *fields { + for f in [field.to_string(), format!("0.{field}")] { + let binding = octoscript_ui_l0::SourceBinding { + helper: (*capability).to_string(), + args: vec![("app".into(), "os.news".into()), ("id".into(), "r1".into())], + nested: Vec::new(), + field: f, + }; + assert!( + octoscript_ui_l0::makepad::vm_call(&binding).is_none(), + "{capability} is host-answered only, yet lowers {field:?} to a call" + ); + } + } + continue; + } for field in *fields { // A collection is addressed by row; ask for row 0. let answered = [field.to_string(), format!("0.{field}")].iter().any(|f| { diff --git a/docs/ui-l0-constructors.toml b/docs/ui-l0-constructors.toml index 360cc06..2e95cda 100644 --- a/docs/ui-l0-constructors.toml +++ b/docs/ui-l0-constructors.toml @@ -545,6 +545,28 @@ answers = ["status", "message", "count"] args = ["id", "fields"] answers = ["title", "subtitle", "summary", "coverage", "status", "as_of", "metric1_label", "metric1_value", "metric2_label", "metric2_value", "pick1_title", "pick1_body", "pick1_source", "url1", "pick2_title", "pick2_body", "pick2_source", "url2", "pick3_title", "pick3_body", "pick3_source", "url3", "evidence_title", "evidence_body"] +[sources."sys.digest"] +# A digest the HOST holds for the card's own app: the findings of one agent run +# (a workflow template's result), with the sources it read. The card states +# nothing of its own; every sentence and every link is in the digest. +# +# `app` is a literal app id and must be the app that published the card. Only +# the host knows the publisher, so the host refuses any other and a card can +# never read another app's digests. `id` names one stored run: a literal +# (checked against `[A-Za-z0-9_-]{1,64}`) or a path into state. +# +# One shape whatever template produced the run. `status` is `ready`, +# `partial`, `failed`, `missing` or `expired`; a missing or expired digest is an +# empty record with `$state` `.failed`, never an error. `points` rows are +# `{id, text, label, cite, citations}`: `citations` index into `sources`, and +# `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. +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.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 6a97ff9..9e68288 100644 --- a/docs/ui-profile-l0.md +++ b/docs/ui-profile-l0.md @@ -1224,6 +1224,53 @@ second state and an event that writes it. `initial:` still admits no expression, or at L1: `initial: here.lat * 2` is refused, because the L1 grammar admits arithmetic in an argument value and this is a declaration. +### 5.14 A digest the host holds: `sys.digest` + +An app's agent reads the news, a topic or a market and writes down what it found. A card that +shows those findings must not carry them as text of its own — that would be the card stating +facts, which §4 forbids — so the findings become a **source the host resolves**, with the +provenance beside them: + +``` +source brief sys.digest(app: "os.news", id: "morning", + fields: [topic, summary, points, sources, id, text, cite, n, title, source]) +``` + +**Whose digest.** `app` is a literal app id and must be the app that published the card. Only +the host knows the publisher — it is the caller, never an argument — so the host refuses a card +naming any other app, and a card can read only its own app's digests. A card cannot compute +`app` from state or another source; the checker refuses anything but a literal. `id` names one +stored digest: a literal checked against `[A-Za-z0-9_-]{1,64}` (the run-id charset, so an id can +never be a path) or a path into state. + +**One shape, whatever produced it.** Every digest answers the same record, independent of the +workflow template that made it: + +| Field | Value | +|---|---| +| `status` | `ready`, `partial`, `failed`, `missing` or `expired` | +| `topic`, `language` | what was researched, and in which language the text is | +| `summary` | the model's summary; no URLs | +| `retrieved_at` | when the host fetched the newest source (RFC 3339) | +| `count` | the number of points | +| `points` | rows `{id, text, label, cite, citations}`: `citations` index `sources`, `cite` is the same as 1-based text (`"1, 3"`) | +| `sources` | rows `{id, n, title, source, url, published_at}`: `n` is the 1-based number `cite` refers to | + +The row fields are pooled with the record's in `fields:`, as a forecast's days are. + +**Links come only from `sources`.** Model text — `summary`, a point's `text` or `label` — +carries no URL; the workflow refuses one and the host checks again. A card that offers a link +binds `s.url` from a source row, which the host retrieved itself. + +**Absence is a state, not an error.** A digest that does not exist, has expired or failed to +parse resolves to an empty record (`status` says which) with `$state` `.failed`, so the card +renders its own "nothing yet" vocabulary (§5.9). It never fails the card. + +**Host-answered only** (§5.11). No backend lowering exists: a call made from the card's +isolate could not check `app` against the publisher. The digest is read-only; it is not in +§5.12's writable set. Retention, expiry and size caps are the host's and are documented with +its resolver (OctoSense's glance service). + --- ## 6. What makes L0 terminate