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
106 changes: 106 additions & 0 deletions crates/octoscript-ui-l0/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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"],
Expand Down Expand Up @@ -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.
Expand Down Expand Up @@ -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()) {
Expand Down
134 changes: 134 additions & 0 deletions crates/octoscript-ui-l0/tests/digest.rs
Original file line number Diff line number Diff line change
@@ -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<String>) {
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<String> {
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());
}
37 changes: 37 additions & 0 deletions crates/octoscript-ui-l0/tests/fixtures/digest.card
Original file line number Diff line number Diff line change
@@ -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)
}
}
}
}
24 changes: 24 additions & 0 deletions crates/octoscript-ui-l0/tests/profile.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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| {
Expand Down
22 changes: 22 additions & 0 deletions docs/ui-l0-constructors.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Loading
Loading