Skip to content

Commit

Artifacts contracts: folios, their four kinds, dashboard datasets, folio events and the artifacts scopes are typed and validated the same in TypeScript and Rust, with nothing using them yet

syntaqxcommitted Parent59b9844Browse files
20 files+3559−340/20 viewed
+2−2
119119 "token_endpoint_auth_methods_supported": ["none"],
120120 // A client may ask for some of these with `scope`; the person
121121 // approving can trim them. Asking for none gives the agent preset.
122− "scopes_supported": g1t_contracts::scopes::Scope::ALL.map(|scope| scope.as_str()).to_vec(),
122+ "scopes_supported": g1t_contracts::scopes::offered_scopes().iter().map(|scope| scope.as_str()).collect::<Vec<_>>(),
123123 "service_documentation": "https://docs.g1t.sh/guides/authentication/",
124124 })
125125 }
245245 "authorization_servers": [services.addresses.api],
246246 "bearer_methods_supported": ["header"],
247247 "resource_documentation": "https://docs.g1t.sh/guides/bring-your-own-agent/",
248− "scopes_supported": g1t_contracts::scopes::Scope::ALL.map(|scope| scope.as_str()).to_vec(),
248+ "scopes_supported": g1t_contracts::scopes::offered_scopes().iter().map(|scope| scope.as_str()).collect::<Vec<_>>(),
249249 }))?
250250 }
251251 ("POST", "/oauth/register") => register(request).await?,
+582−0
1+//! Datasets: the safe query layer behind dashboards (Artifacts mode,
2+//! docs/ARTIFACTS_MODE.md section 3.4). Mirrors
3+//! `packages/contracts/src/datasets.ts`; the tests here keep the catalog the
4+//! same and run both validators over `datasets.fixtures.json`.
5+//!
6+//! Not SQL: a query names a dataset from a declared catalog, one measure,
7+//! at most one dimension, an interval, filters on declared fields and a
8+//! range. The service that owns the data ([`DatasetSpec::service`]) answers
9+//! `query_dataset` for the viewer, over only what the viewer can read, with
10+//! a fixed query per measure and dimension, and caps the rows. The Rust
11+//! owners (work, actions, billing) validate with [`DatasetQuery::validate`]
12+//! before they run anything.
13+
14+use serde::{Deserialize, Serialize};
15+
16+#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, Serialize, Deserialize)]
17+#[serde(rename_all = "snake_case")]
18+pub enum DatasetId {
19+ Issues,
20+ PullRequests,
21+ WorkflowRuns,
22+ Deployments,
23+ Spend,
24+ AgentSessions,
25+}
26+
27+impl DatasetId {
28+ pub const ALL: [DatasetId; 6] = [
29+ DatasetId::Issues,
30+ DatasetId::PullRequests,
31+ DatasetId::WorkflowRuns,
32+ DatasetId::Deployments,
33+ DatasetId::Spend,
34+ DatasetId::AgentSessions,
35+ ];
36+
37+ pub fn as_str(self) -> &'static str {
38+ match self {
39+ DatasetId::Issues => "issues",
40+ DatasetId::PullRequests => "pull_requests",
41+ DatasetId::WorkflowRuns => "workflow_runs",
42+ DatasetId::Deployments => "deployments",
43+ DatasetId::Spend => "spend",
44+ DatasetId::AgentSessions => "agent_sessions",
45+ }
46+ }
47+
48+ /// Its entry in [`DATASETS`].
49+ pub fn spec(self) -> &'static DatasetSpec {
50+ DATASETS.iter().find(|spec| spec.id == self).expect("every dataset is in the catalog")
51+ }
52+}
53+
54+/// How rows are summed up. `count` takes no field; `rate` a rate field; the
55+/// rest a measure field.
56+#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
57+#[serde(rename_all = "snake_case")]
58+pub enum MeasureOp {
59+ Count,
60+ Sum,
61+ Avg,
62+ P50,
63+ P95,
64+ Rate,
65+}
66+
67+impl MeasureOp {
68+ pub fn as_str(self) -> &'static str {
69+ match self {
70+ MeasureOp::Count => "count",
71+ MeasureOp::Sum => "sum",
72+ MeasureOp::Avg => "avg",
73+ MeasureOp::P50 => "p50",
74+ MeasureOp::P95 => "p95",
75+ MeasureOp::Rate => "rate",
76+ }
77+ }
78+}
79+
80+#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
81+pub struct Measure {
82+ pub op: MeasureOp,
83+ #[serde(default, skip_serializing_if = "Option::is_none")]
84+ pub field: Option<String>,
85+}
86+
87+#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
88+#[serde(rename_all = "snake_case")]
89+pub enum Interval {
90+ Day,
91+ Week,
92+ Month,
93+}
94+
95+#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
96+#[serde(rename_all = "snake_case")]
97+pub enum FilterOp {
98+ Eq,
99+ Neq,
100+ In,
101+ Gte,
102+ Lte,
103+}
104+
105+impl FilterOp {
106+ pub fn as_str(self) -> &'static str {
107+ match self {
108+ FilterOp::Eq => "eq",
109+ FilterOp::Neq => "neq",
110+ FilterOp::In => "in",
111+ FilterOp::Gte => "gte",
112+ FilterOp::Lte => "lte",
113+ }
114+ }
115+}
116+
117+/// Text for `eq`/`neq` on a dimension, a list for `in`, a number on a
118+/// measure.
119+#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
120+#[serde(untagged)]
121+pub enum FilterValue {
122+ Text(String),
123+ Number(f64),
124+ List(Vec<String>),
125+}
126+
127+#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
128+pub struct Filter {
129+ pub field: String,
130+ pub op: FilterOp,
131+ pub value: FilterValue,
132+}
133+
134+#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
135+pub enum RangePreset {
136+ #[serde(rename = "7d")]
137+ Days7,
138+ #[serde(rename = "30d")]
139+ Days30,
140+ #[serde(rename = "90d")]
141+ Days90,
142+}
143+
144+impl RangePreset {
145+ pub fn days(self) -> u32 {
146+ match self {
147+ RangePreset::Days7 => 7,
148+ RangePreset::Days30 => 30,
149+ RangePreset::Days90 => 90,
150+ }
151+ }
152+}
153+
154+/// A preset counted back from now, or between two times: dates
155+/// (`2026-10-01`) or RFC 3339 UTC times; `to` is exclusive.
156+#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
157+#[serde(untagged)]
158+pub enum DatasetRange {
159+ Preset(RangePreset),
160+ Between { from: String, to: String },
161+}
162+
163+#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
164+pub struct DatasetQuery {
165+ pub dataset: DatasetId,
166+ pub measure: Measure,
167+ #[serde(default, skip_serializing_if = "Option::is_none")]
168+ pub group_by: Option<String>,
169+ #[serde(default, skip_serializing_if = "Option::is_none")]
170+ pub interval: Option<Interval>,
171+ /// Which of the dataset's time fields the range and interval use; its
172+ /// first when absent.
173+ #[serde(default, skip_serializing_if = "Option::is_none")]
174+ pub time: Option<String>,
175+ #[serde(default, skip_serializing_if = "Option::is_none")]
176+ pub filters: Option<Vec<Filter>>,
177+ #[serde(default, skip_serializing_if = "Option::is_none")]
178+ pub range: Option<DatasetRange>,
179+ #[serde(default, skip_serializing_if = "Option::is_none")]
180+ pub limit: Option<u32>,
181+}
182+
183+#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
184+#[serde(rename_all = "snake_case")]
185+pub enum ColumnType {
186+ String,
187+ Number,
188+ Time,
189+ Money,
190+}
191+
192+#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
193+pub struct DatasetColumn {
194+ pub name: String,
195+ #[serde(rename = "type")]
196+ pub kind: ColumnType,
197+}
198+
199+/// What `query_dataset` answers.
200+#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
201+pub struct DatasetResult {
202+ pub columns: Vec<DatasetColumn>,
203+ /// Each a string, a number or null.
204+ pub rows: Vec<Vec<serde_json::Value>>,
205+ /// More rows matched than were returned.
206+ pub truncated: bool,
207+ /// The viewer cannot read everything the query covers.
208+ pub partial: bool,
209+ /// RFC 3339.
210+ pub as_of: String,
211+}
212+
213+/// The service that owns a dataset.
214+#[derive(Clone, Copy, Debug, PartialEq, Eq)]
215+pub enum DatasetService {
216+ Work,
217+ Actions,
218+ Deployments,
219+ Billing,
220+ Agents,
221+}
222+
223+impl DatasetService {
224+ pub fn as_str(self) -> &'static str {
225+ match self {
226+ DatasetService::Work => "work",
227+ DatasetService::Actions => "actions",
228+ DatasetService::Deployments => "deployments",
229+ DatasetService::Billing => "billing",
230+ DatasetService::Agents => "agents",
231+ }
232+ }
233+}
234+
235+/// What a viewer needs to query a dataset.
236+#[derive(Clone, Copy, Debug, PartialEq, Eq)]
237+pub enum DatasetNeeds {
238+ Member,
239+ /// The workspace's billing role.
240+ Billing,
241+}
242+
243+impl DatasetNeeds {
244+ pub fn as_str(self) -> &'static str {
245+ match self {
246+ DatasetNeeds::Member => "member",
247+ DatasetNeeds::Billing => "billing",
248+ }
249+ }
250+}
251+
252+#[derive(Debug)]
253+pub struct DatasetSpec {
254+ pub id: DatasetId,
255+ pub label: &'static str,
256+ pub service: DatasetService,
257+ pub needs: DatasetNeeds,
258+ /// Time fields, the default first.
259+ pub times: &'static [&'static str],
260+ /// Text fields to group and filter by.
261+ pub dimensions: &'static [&'static str],
262+ /// Number fields for sum, avg, p50, p95, and number filters.
263+ pub measures: &'static [&'static str],
264+ /// Yes-or-no fields `rate` gives the share of.
265+ pub rates: &'static [&'static str],
266+}
267+
268+/// The catalog.
269+pub const DATASETS: [DatasetSpec; 6] = [
270+ DatasetSpec {
271+ id: DatasetId::Issues,
272+ label: "Issues",
273+ service: DatasetService::Work,
274+ needs: DatasetNeeds::Member,
275+ times: &["created_at", "closed_at"],
276+ dimensions: &["repo", "label", "state", "author_kind", "assignee_kind", "milestone"],
277+ measures: &["time_to_close_hours", "comments"],
278+ rates: &["closed"],
279+ },
280+ DatasetSpec {
281+ id: DatasetId::PullRequests,
282+ label: "Pull requests",
283+ service: DatasetService::Work,
284+ needs: DatasetNeeds::Member,
285+ times: &["created_at", "merged_at", "closed_at"],
286+ dimensions: &["repo", "label", "state", "author_kind", "base_branch"],
287+ measures: &["cycle_time_hours", "time_to_first_review_hours", "review_count", "additions", "deletions", "changed_files"],
288+ rates: &["merged"],
289+ },
290+ DatasetSpec {
291+ id: DatasetId::WorkflowRuns,
292+ label: "Workflow runs",
293+ service: DatasetService::Actions,
294+ needs: DatasetNeeds::Member,
295+ times: &["started_at", "completed_at"],
296+ dimensions: &["repo", "workflow", "branch", "event", "conclusion", "runner_kind"],
297+ measures: &["duration_seconds", "queue_seconds"],
298+ rates: &["succeeded"],
299+ },
300+ DatasetSpec {
301+ id: DatasetId::Deployments,
302+ label: "Deployments",
303+ service: DatasetService::Deployments,
304+ needs: DatasetNeeds::Member,
305+ times: &["created_at"],
306+ dimensions: &["repo", "project", "environment", "state"],
307+ measures: &["duration_seconds", "time_to_restore_hours"],
308+ rates: &["failed"],
309+ },
310+ DatasetSpec {
311+ id: DatasetId::Spend,
312+ label: "Spend",
313+ service: DatasetService::Billing,
314+ needs: DatasetNeeds::Billing,
315+ times: &["day"],
316+ dimensions: &["product", "project", "person", "model"],
317+ measures: &["amount_micros"],
318+ rates: &[],
319+ },
320+ DatasetSpec {
321+ id: DatasetId::AgentSessions,
322+ label: "Agent sessions",
323+ service: DatasetService::Agents,
324+ needs: DatasetNeeds::Member,
325+ times: &["started_at"],
326+ dimensions: &["agent", "repo", "outcome", "model", "trigger"],
327+ measures: &["duration_seconds", "cost_micros", "tokens"],
328+ rates: &["succeeded"],
329+ },
330+];
331+
332+/// The most rows a query returns.
333+pub const MAX_ROWS: u32 = 100;
334+/// The most filters on one query.
335+pub const MAX_FILTERS: usize = 10;
336+/// The most values in an `in` filter.
337+pub const MAX_IN_VALUES: usize = 50;
338+/// The longest `{ from, to }` range, in days.
339+pub const MAX_RANGE_DAYS: u64 = 366;
340+
341+const DAY_MS: u64 = 86_400_000;
342+
343+/// A range end as milliseconds since the epoch: a real date (`2026-10-01`)
344+/// or an RFC 3339 UTC time with at most milliseconds. `None` otherwise.
345+pub fn range_time(text: &str) -> Option<u64> {
346+ let bytes = text.as_bytes();
347+ let digits = |range: std::ops::Range<usize>| -> Option<u64> {
348+ let part = text.get(range)?;
349+ if part.is_empty() || !part.bytes().all(|b| b.is_ascii_digit()) {
350+ return None;
351+ }
352+ part.parse().ok()
353+ };
354+ if bytes.len() < 10 || bytes[4] != b'-' || bytes[7] != b'-' {
355+ return None;
356+ }
357+ let (year, month, day) = (digits(0..4)?, digits(5..7)?, digits(8..10)?);
358+ if !(1..=12).contains(&month) || day < 1 || day > days_in_month(year, month) {
359+ return None;
360+ }
361+ let full = if bytes.len() == 10 {
362+ format!("{text}T00:00:00Z")
363+ } else {
364+ // T hh:mm:ss, optional .f{1,3}, Z.
365+ if bytes.len() < 20 || bytes[10] != b'T' || bytes[13] != b':' || bytes[16] != b':' || *bytes.last()? != b'Z' {
366+ return None;
367+ }
368+ let (hour, minute, second) = (digits(11..13)?, digits(14..16)?, digits(17..19)?);
369+ if hour > 23 || minute > 59 || second > 59 {
370+ return None;
371+ }
372+ match bytes.len() {
373+ 20 => {}
374+ 22..=24 if bytes[19] == b'.' => {
375+ digits(20..bytes.len() - 1)?;
376+ }
377+ _ => return None,
378+ }
379+ text.to_owned()
380+ };
381+ crate::time::parse_rfc3339(&full)
382+}
383+
384+fn days_in_month(year: u64, month: u64) -> u64 {
385+ match month {
386+ 2 if (year % 4 == 0 && year % 100 != 0) || year % 400 == 0 => 29,
387+ 2 => 28,
388+ 4 | 6 | 9 | 11 => 30,
389+ _ => 31,
390+ }
391+}
392+
393+impl DatasetQuery {
394+ /// What is wrong with it, if anything: the same rules, and the same
395+ /// words, as `datasetQueryError` in TypeScript. It checks the query
396+ /// against the catalog only; who may run it is the owning service's to
397+ /// decide.
398+ pub fn validate(&self) -> Result<(), String> {
399+ let spec = self.dataset.spec();
400+ let name = self.dataset.as_str();
401+ let field = self.measure.field.as_deref();
402+ match self.measure.op {
403+ MeasureOp::Count => {
404+ if field.is_some() {
405+ return Err("count takes no field.".to_owned());
406+ }
407+ }
408+ MeasureOp::Rate => {
409+ let Some(field) = field else { return Err("rate needs a field.".to_owned()) };
410+ if !spec.rates.contains(&field) {
411+ return Err(format!("{name} has no yes-or-no field {field} to take the rate of."));
412+ }
413+ }
414+ op => {
415+ let Some(field) = field else { return Err(format!("{} needs a field.", op.as_str())) };
416+ if !spec.measures.contains(&field) {
417+ return Err(format!("{name} has no number field {field}."));
418+ }
419+ }
420+ }
421+ if let Some(group_by) = self.group_by.as_deref()
422+ && !spec.dimensions.contains(&group_by)
423+ {
424+ return Err(format!("{name} can't be grouped by {group_by}."));
425+ }
426+ if let Some(time) = self.time.as_deref()
427+ && !spec.times.contains(&time)
428+ {
429+ return Err(format!("{name} has no time field {time}."));
430+ }
431+ let filters = self.filters.as_deref().unwrap_or_default();
432+ if filters.len() > MAX_FILTERS {
433+ return Err(format!("A query takes at most {MAX_FILTERS} filters."));
434+ }
435+ for filter in filters {
436+ let (field, op) = (filter.field.as_str(), filter.op.as_str());
437+ if spec.dimensions.contains(&field) {
438+ match (filter.op, &filter.value) {
439+ (FilterOp::In, FilterValue::List(values)) if !values.is_empty() && values.len() <= MAX_IN_VALUES => {}
440+ (FilterOp::In, _) => return Err(format!("in on {field} takes a list of 1 to {MAX_IN_VALUES} values.")),
441+ (FilterOp::Eq | FilterOp::Neq, FilterValue::Text(_)) => {}
442+ (FilterOp::Eq | FilterOp::Neq, _) => return Err(format!("{op} on {field} takes text.")),
443+ _ => return Err(format!("{field} is text: filter it with eq, neq or in.")),
444+ }
445+ } else if spec.measures.contains(&field) {
446+ match (filter.op, &filter.value) {
447+ (FilterOp::In, _) => return Err(format!("{field} is a number: filter it with eq, neq, gte or lte.")),
448+ (_, FilterValue::Number(n)) if n.is_finite() => {}
449+ _ => return Err(format!("{op} on {field} takes a number.")),
450+ }
451+ } else {
452+ return Err(format!("{name} can't be filtered by {field}."));
453+ }
454+ }
455+ if let Some(DatasetRange::Between { from, to }) = &self.range {
456+ let (Some(from), Some(to)) = (range_time(from), range_time(to)) else {
457+ return Err("A range's from and to are dates or RFC 3339 UTC times.".to_owned());
458+ };
459+ if from >= to {
460+ return Err("A range's from comes before its to.".to_owned());
461+ }
462+ if to - from > MAX_RANGE_DAYS * DAY_MS {
463+ return Err(format!("A range is at most {MAX_RANGE_DAYS} days."));
464+ }
465+ }
466+ if let Some(limit) = self.limit
467+ && !(1..=MAX_ROWS).contains(&limit)
468+ {
469+ return Err(format!("limit is between 1 and {MAX_ROWS}."));
470+ }
471+ Ok(())
472+ }
473+}
474+
475+#[cfg(test)]
476+mod tests {
477+ use super::*;
478+
479+ /// The two validators agree on every case in the shared fixtures.
480+ #[test]
481+ fn agrees_with_the_typescript_validator_on_the_fixtures() {
482+ let fixtures: serde_json::Value = serde_json::from_str(include_str!("../../../packages/contracts/src/datasets.fixtures.json")).unwrap();
483+ let cases = fixtures["cases"].as_array().unwrap();
484+ assert!(cases.len() > 20);
485+ for case in cases {
486+ let outcome = serde_json::from_value::<DatasetQuery>(case["query"].clone())
487+ .map_err(|_| "*".to_owned())
488+ .and_then(|query| query.validate());
489+ match case["error"].as_str() {
490+ None => assert_eq!(outcome, Ok(()), "{}", case["query"]),
491+ Some("*") => assert!(outcome.is_err(), "{} should be refused", case["query"]),
492+ Some(error) => assert_eq!(outcome, Err(error.to_owned()), "{}", case["query"]),
493+ }
494+ }
495+ }
496+
497+ /// The site's copy of the catalog names the same datasets, services and
498+ /// fields, in the same order.
499+ #[test]
500+ fn the_typescript_mirror_has_the_same_catalog() {
501+ let ts = include_str!("../../../packages/contracts/src/datasets.ts");
502+ let catalog = ts
503+ .split_once("export const DATASETS: Record<DatasetId, DatasetSpec> = {")
504+ .and_then(|(_, rest)| rest.split_once("\n};"))
505+ .map(|(table, _)| table)
506+ .expect("DATASETS in datasets.ts");
507+ let quoted = |line: &str, key: &str| -> Vec<String> {
508+ let start = format!("{key}: [");
509+ let list = line.split_once(&start).and_then(|(_, rest)| rest.split_once(']')).map(|(list, _)| list).unwrap_or_else(|| panic!("{key} in {line}"));
510+ list.split('"').skip(1).step_by(2).map(str::to_owned).collect()
511+ };
512+ let lines: Vec<&str> = catalog.lines().filter(|line| line.contains("service:")).collect();
513+ assert_eq!(lines.len(), DATASETS.len());
514+ for (line, spec) in lines.iter().zip(DATASETS.iter()) {
515+ assert!(line.trim_start().starts_with(&format!("{}: {{", spec.id.as_str())), "{line}");
516+ assert!(line.contains(&format!("label: \"{}\"", spec.label)), "{line}");
517+ assert!(line.contains(&format!("service: \"{}\"", spec.service.as_str())), "{line}");
518+ assert!(line.contains(&format!("needs: \"{}\"", spec.needs.as_str())), "{line}");
519+ assert_eq!(quoted(line, "times"), spec.times, "{line}");
520+ assert_eq!(quoted(line, "dimensions"), spec.dimensions, "{line}");
521+ assert_eq!(quoted(line, "measures"), spec.measures, "{line}");
522+ assert_eq!(quoted(line, "rates"), spec.rates, "{line}");
523+ }
524+ for (name, value) in [("DATASET_MAX_ROWS", MAX_ROWS as usize), ("DATASET_MAX_FILTERS", MAX_FILTERS), ("DATASET_MAX_IN_VALUES", MAX_IN_VALUES), ("DATASET_MAX_RANGE_DAYS", MAX_RANGE_DAYS as usize)] {
525+ assert!(ts.contains(&format!("export const {name} = {value};")), "{name}");
526+ }
527+ }
528+
529+ #[test]
530+ fn every_dataset_reads_back_and_has_a_time_field() {
531+ for id in DatasetId::ALL {
532+ assert_eq!(serde_json::to_value(id).unwrap(), id.as_str());
533+ let spec = id.spec();
534+ assert!(!spec.times.is_empty(), "{}", id.as_str());
535+ // A field is one thing: never both a dimension and a number.
536+ for field in spec.dimensions {
537+ assert!(!spec.measures.contains(field) && !spec.rates.contains(field), "{field}");
538+ }
539+ }
540+ assert_eq!(DatasetId::Spend.spec().needs, DatasetNeeds::Billing);
541+ }
542+
543+ #[test]
544+ fn a_query_travels_snake_case() {
545+ let query = DatasetQuery {
546+ dataset: DatasetId::PullRequests,
547+ measure: Measure { op: MeasureOp::P95, field: Some("cycle_time_hours".to_owned()) },
548+ group_by: Some("repo".to_owned()),
549+ interval: Some(Interval::Week),
550+ time: None,
551+ filters: Some(vec![Filter { field: "state".to_owned(), op: FilterOp::Eq, value: FilterValue::Text("merged".to_owned()) }]),
552+ range: Some(DatasetRange::Preset(RangePreset::Days90)),
553+ limit: None,
554+ };
555+ let json = serde_json::to_value(&query).unwrap();
556+ assert_eq!(
557+ json,
558+ serde_json::json!({
559+ "dataset": "pull_requests",
560+ "measure": { "op": "p95", "field": "cycle_time_hours" },
561+ "group_by": "repo",
562+ "interval": "week",
563+ "filters": [{ "field": "state", "op": "eq", "value": "merged" }],
564+ "range": "90d"
565+ })
566+ );
567+ assert_eq!(serde_json::from_value::<DatasetQuery>(json).unwrap(), query);
568+ let between: DatasetRange = serde_json::from_value(serde_json::json!({ "from": "2026-01-01", "to": "2026-02-01" })).unwrap();
569+ assert!(matches!(between, DatasetRange::Between { .. }));
570+ }
571+
572+ #[test]
573+ fn range_times_are_real_dates_or_utc_times() {
574+ assert_eq!(range_time("1970-01-02"), Some(DAY_MS));
575+ assert_eq!(range_time("2026-10-02T05:16:19Z"), Some(1_790_918_179_000));
576+ assert_eq!(range_time("2026-10-02T05:16:19.5Z"), Some(1_790_918_179_500));
577+ assert_eq!(range_time("2024-02-29"), crate::time::parse_rfc3339("2024-02-29T00:00:00Z"));
578+ for bad in ["2026-02-29", "2026-04-31", "2026-13-01", "2026-10-02T24:00:00Z", "2026-10-02T05:16:19", "2026-10-02T05:16:19+02:00", "26-10-02", "2026-1-02", "today", ""] {
579+ assert_eq!(range_time(bad), None, "{bad}");
580+ }
581+ }
582+}
+4−0
976976 /// space, so it never reaches a repository's timeline or webhooks.
977977 pub const DOC_PAGE_EVENTS: [&str; 4] = ["doc.page.created", "doc.page.updated", "doc.page.archived", "doc.page.stale"];
978978
979+/// The `folio.*` types (Artifacts mode), with their payloads, live in
980+/// [`crate::folios`]; nothing publishes or subscribes to them yet.
981+pub use crate::folios::FOLIO_EVENTS;
982+
979983 /// What every `doc.page.*` event carries (`DocPageEventData` in
980984 /// events.ts). `doc.page.updated` adds `versionId`, `kind` and `authors`;
981985 /// `doc.page.stale` adds `repoId`, `repo`, `commit`, `pull`, `paths` and
+930−0
1+//! Folios: what people call artifacts (Artifacts mode, docs/ARTIFACTS_MODE.md).
2+//! One mode for docs, slides, designs and dashboards, kept by the docs
3+//! service (`services/docs`, TypeScript). Code says "folio"; people see
4+//! "artifact" in the UI, URLs, the `artifact` MCP tool, REST paths and the
5+//! `artifacts:*` scopes. The Cloudflare Artifacts git store and workflow run
6+//! artifacts ([`crate::actions`]) are something else.
7+//!
8+//! This is the subset of `packages/contracts/src/folios.ts` the Rust API
9+//! needs: the shapes it sends and reads back, the arguments of the docs
10+//! service's RPC, the validators it runs before calling, and the `folio.*`
11+//! event types. Agent edits stay JSON ([`serde_json::Value`]): the docs
12+//! service applies them; [`agent_edit_error`] checks their envelope. Wire
13+//! shapes are snake_case; the tests keep field names, RPC methods and
14+//! validators the same as the TypeScript (`folios.fixtures.json`).
15+//!
16+//! Nothing uses this yet: Phase 1 publishes the events, Phase 3 the API.
17+
18+use serde::{Deserialize, Serialize};
19+use serde_json::Value;
20+
21+use crate::User;
22+use crate::datasets::DatasetQuery;
23+
24+// --- Kinds and roles ----------------------------------------------------------
25+
26+#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, Serialize, Deserialize)]
27+#[serde(rename_all = "snake_case")]
28+pub enum FolioKind {
29+ Doc,
30+ Slides,
31+ Design,
32+ Dashboard,
33+}
34+
35+impl FolioKind {
36+ pub const ALL: [FolioKind; 4] = [FolioKind::Doc, FolioKind::Slides, FolioKind::Design, FolioKind::Dashboard];
37+
38+ pub fn as_str(self) -> &'static str {
39+ match self {
40+ FolioKind::Doc => "doc",
41+ FolioKind::Slides => "slides",
42+ FolioKind::Design => "design",
43+ FolioKind::Dashboard => "dashboard",
44+ }
45+ }
46+
47+ pub fn parse(text: &str) -> Option<FolioKind> {
48+ FolioKind::ALL.into_iter().find(|kind| kind.as_str() == text)
49+ }
50+
51+ /// As the Artifacts home's tiles name it.
52+ pub fn label(self) -> &'static str {
53+ match self {
54+ FolioKind::Doc => "Docs",
55+ FolioKind::Slides => "Slides",
56+ FolioKind::Design => "Design",
57+ FolioKind::Dashboard => "Dashboard",
58+ }
59+ }
60+
61+ /// One of it, in a sentence: "a doc", "a deck".
62+ pub fn noun(self) -> &'static str {
63+ match self {
64+ FolioKind::Doc => "doc",
65+ FolioKind::Slides => "deck",
66+ FolioKind::Design => "design",
67+ FolioKind::Dashboard => "dashboard",
68+ }
69+ }
70+
71+ /// Only a doc holds other folios.
72+ pub fn can_have_children(self) -> bool {
73+ self == FolioKind::Doc
74+ }
75+
76+ /// Whether it starts from Markdown (doc, slides) or a JSON spec.
77+ pub fn takes_markdown(self) -> bool {
78+ matches!(self, FolioKind::Doc | FolioKind::Slides)
79+ }
80+}
81+
82+/// What someone may do with a folio, weakest first.
83+#[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)]
84+#[serde(rename_all = "snake_case")]
85+pub enum FolioRole {
86+ View,
87+ Comment,
88+ Edit,
89+ Manage,
90+}
91+
92+#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
93+#[serde(rename_all = "snake_case")]
94+pub enum GeneralAccess {
95+ None,
96+ Workspace,
97+ Link,
98+}
99+
100+impl GeneralAccess {
101+ pub fn label(self) -> &'static str {
102+ match self {
103+ GeneralAccess::None => "Restricted",
104+ GeneralAccess::Workspace => "Everyone in the workspace",
105+ GeneralAccess::Link => "Anyone in the workspace with the link",
106+ }
107+ }
108+}
109+
110+/// How agents change a folio: file suggestions (or proposals), or edit.
111+#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
112+#[serde(rename_all = "snake_case")]
113+pub enum AgentMode {
114+ Suggest,
115+ Edit,
116+}
117+
118+/// The most ops one agent edit carries.
119+pub const MAX_OPS: usize = 200;
120+/// The longest title, in characters.
121+pub const MAX_TITLE: usize = 200;
122+/// The most people a folio is shared with in one change.
123+pub const MAX_SHARE: usize = 50;
124+/// The most folios a page of a list holds.
125+pub const LIST_MAX: u32 = 100;
126+
127+/// Whether `text` names who a grant is for: `user:<id>`, `agent:<id>` or
128+/// `team:<slug>`.
129+pub fn is_principal(text: &str) -> bool {
130+ let Some((kind, id)) = text.split_once(':') else { return false };
131+ matches!(kind, "user" | "agent" | "team")
132+ && (1..=64).contains(&id.len())
133+ && id.bytes().all(|b| b.is_ascii_alphanumeric() || matches!(b, b'_' | b'.' | b'-'))
134+}
135+
136+/// The folio id at the end of an address segment (`q4-roadmap-fol_…`).
137+pub fn folio_id_from(segment: &str) -> Option<&str> {
138+ let start = segment.len().checked_sub(30)?;
139+ let id = segment.get(start..)?;
140+ let suffix = id.strip_prefix("fol_")?;
141+ suffix
142+ .bytes()
143+ .all(|b| b"0123456789abcdefghjkmnpqrstvwxyz".contains(&b))
144+ .then_some(id)
145+}
146+
147+// --- Shapes -------------------------------------------------------------------
148+
149+/// Enough to link to a folio.
150+#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
151+pub struct FolioRef {
152+ pub id: String,
153+ pub kind: FolioKind,
154+ pub title: String,
155+ pub icon: Option<String>,
156+ /// `<title-slug>-<id>`.
157+ pub slug: String,
158+ /// `/<ws>/-/artifacts/<slug>`.
159+ pub path: String,
160+}
161+
162+#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
163+pub struct FolioSpaceRef {
164+ pub id: String,
165+ pub slug: String,
166+ pub name: String,
167+ /// `workspace` (Open), `team` or `private` (Members only).
168+ pub kind: String,
169+}
170+
171+#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
172+pub struct FolioInheritedFrom {
173+ /// `space` or `folio`.
174+ pub kind: String,
175+ pub id: String,
176+ pub name: String,
177+}
178+
179+#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
180+pub struct FolioSource {
181+ pub title: String,
182+ pub href: String,
183+}
184+
185+/// A folio for one viewer. People are member profiles (`MemberProfile` in
186+/// chat.ts), kept as JSON.
187+#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
188+pub struct Folio {
189+ #[serde(flatten)]
190+ pub folio: FolioRef,
191+ pub workspace_id: String,
192+ /// None: its owner's Private section.
193+ pub space: Option<FolioSpaceRef>,
194+ pub parent_id: Option<String>,
195+ pub position: f64,
196+ pub owner: Value,
197+ pub created_by: Value,
198+ pub created_at: String,
199+ pub updated_at: String,
200+ pub edited_by: Option<Value>,
201+ pub edited_at: String,
202+ pub trashed_at: Option<String>,
203+ pub viewer_role: FolioRole,
204+ pub favorite: bool,
205+ pub private: bool,
206+ pub shared_count: u32,
207+ pub general_access: GeneralAccess,
208+ pub general_role: Option<FolioRole>,
209+ pub inherit: bool,
210+ pub inherited_from: Option<FolioInheritedFrom>,
211+ pub agent_mode: AgentMode,
212+ pub excerpt: String,
213+ /// What a card draws; never data values.
214+ pub preview: Option<Value>,
215+ pub source: Option<FolioSource>,
216+ pub stale: bool,
217+ pub has_children: bool,
218+}
219+
220+#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
221+#[serde(rename_all = "snake_case")]
222+pub enum FolioListTab {
223+ All,
224+ Yours,
225+ Shared,
226+}
227+
228+#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
229+pub struct FolioListQuery {
230+ pub tab: FolioListTab,
231+ #[serde(default, skip_serializing_if = "Option::is_none")]
232+ pub kinds: Option<Vec<FolioKind>>,
233+ #[serde(default, skip_serializing_if = "Option::is_none")]
234+ pub space_id: Option<String>,
235+ #[serde(default, skip_serializing_if = "Option::is_none")]
236+ pub owner: Option<String>,
237+ #[serde(default, skip_serializing_if = "Option::is_none")]
238+ pub project: Option<String>,
239+ #[serde(default, skip_serializing_if = "Option::is_none")]
240+ pub q: Option<String>,
241+ #[serde(default, skip_serializing_if = "Option::is_none")]
242+ pub cursor: Option<String>,
243+ #[serde(default, skip_serializing_if = "Option::is_none")]
244+ pub limit: Option<u32>,
245+}
246+
247+impl FolioListQuery {
248+ pub fn validate(&self) -> Result<(), String> {
249+ match self.limit {
250+ Some(limit) if !(1..=LIST_MAX).contains(&limit) => Err(format!("limit is between 1 and {LIST_MAX}.")),
251+ _ => Ok(()),
252+ }
253+ }
254+}
255+
256+#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
257+pub struct FolioList {
258+ pub items: Vec<Folio>,
259+ pub next_cursor: Option<String>,
260+}
261+
262+/// Content to start from: `markdown` for docs and slides, `spec` for
263+/// designs and dashboards (one of them; a union in TypeScript).
264+#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)]
265+pub struct FolioContentInput {
266+ #[serde(default, skip_serializing_if = "Option::is_none")]
267+ pub markdown: Option<String>,
268+ #[serde(default, skip_serializing_if = "Option::is_none")]
269+ pub spec: Option<Value>,
270+}
271+
272+#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
273+pub struct ShareWith {
274+ pub principal: String,
275+ pub role: FolioRole,
276+}
277+
278+#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
279+pub struct NewFolio {
280+ pub kind: FolioKind,
281+ #[serde(default, skip_serializing_if = "Option::is_none")]
282+ pub title: Option<String>,
283+ #[serde(default, skip_serializing_if = "Option::is_none")]
284+ pub icon: Option<String>,
285+ /// None: the creator's Private section.
286+ #[serde(default, skip_serializing_if = "Option::is_none")]
287+ pub space_id: Option<String>,
288+ #[serde(default, skip_serializing_if = "Option::is_none")]
289+ pub parent_id: Option<String>,
290+ #[serde(default, skip_serializing_if = "Option::is_none")]
291+ pub template_id: Option<String>,
292+ #[serde(default, skip_serializing_if = "Option::is_none")]
293+ pub content: Option<FolioContentInput>,
294+ #[serde(default, skip_serializing_if = "Option::is_none")]
295+ pub source: Option<FolioSource>,
296+ #[serde(default, skip_serializing_if = "Option::is_none")]
297+ pub share_with: Option<Vec<ShareWith>>,
298+}
299+
300+impl NewFolio {
301+ /// What is wrong with it, in the same words as `newFolioError`.
302+ /// Whether its space and parent exist and allow it is the docs
303+ /// service's to say.
304+ pub fn validate(&self) -> Result<(), String> {
305+ if self.title.as_deref().unwrap_or_default().chars().count() > MAX_TITLE {
306+ return Err(format!("A title is at most {MAX_TITLE} characters."));
307+ }
308+ if let Some(content) = &self.content {
309+ if self.kind.takes_markdown() && content.markdown.is_none() {
310+ return Err(format!("A {} starts from markdown.", self.kind.noun()));
311+ }
312+ if !self.kind.takes_markdown() && !content.spec.as_ref().is_some_and(Value::is_object) {
313+ return Err(format!("A {} starts from a spec.", self.kind.noun()));
314+ }
315+ if self.template_id.is_some() {
316+ return Err("Start from a template or from content, not both.".to_owned());
317+ }
318+ }
319+ let share = self.share_with.as_deref().unwrap_or_default();
320+ if share.len() > MAX_SHARE {
321+ return Err(format!("Share with at most {MAX_SHARE} at once."));
322+ }
323+ if let Some(row) = share.iter().find(|row| !is_principal(&row.principal)) {
324+ return Err(format!("{} is not user:, agent: or team: and an id.", row.principal));
325+ }
326+ Ok(())
327+ }
328+}
329+
330+#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)]
331+pub struct FolioChange {
332+ #[serde(default, skip_serializing_if = "Option::is_none")]
333+ pub title: Option<String>,
334+ /// Some(None) clears it.
335+ #[serde(default, skip_serializing_if = "Option::is_none")]
336+ pub icon: Option<Option<String>>,
337+ #[serde(default, skip_serializing_if = "Option::is_none")]
338+ pub cover: Option<Option<String>>,
339+ #[serde(default, skip_serializing_if = "Option::is_none")]
340+ pub projects: Option<Vec<String>>,
341+}
342+
343+#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
344+pub struct FolioMove {
345+ /// None: Private.
346+ pub space_id: Option<String>,
347+ pub parent_id: Option<String>,
348+ #[serde(default, skip_serializing_if = "Option::is_none")]
349+ pub before_id: Option<String>,
350+}
351+
352+/// One change from the share dialog.
353+#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
354+#[serde(tag = "op", rename_all = "snake_case")]
355+pub enum FolioAccessChange {
356+ Grant {
357+ principal: String,
358+ role: FolioRole,
359+ #[serde(default, skip_serializing_if = "Option::is_none")]
360+ notify: Option<String>,
361+ },
362+ Revoke {
363+ principal: String,
364+ },
365+ General {
366+ access: GeneralAccess,
367+ role: Option<FolioRole>,
368+ },
369+ Inherit {
370+ inherit: bool,
371+ },
372+ AgentMode {
373+ agent_mode: Option<AgentMode>,
374+ },
375+}
376+
377+impl FolioAccessChange {
378+ /// What is wrong with it, in the same words as `folioAccessChangeError`.
379+ pub fn validate(&self) -> Result<(), String> {
380+ match self {
381+ FolioAccessChange::Grant { principal, notify, .. } => {
382+ if !is_principal(principal) {
383+ return Err(format!("{principal} is not user:, agent: or team: and an id."));
384+ }
385+ if notify.as_deref().is_some_and(|text| text.chars().count() > 2_000) {
386+ return Err("A message is at most 2000 characters.".to_owned());
387+ }
388+ Ok(())
389+ }
390+ FolioAccessChange::Revoke { principal } if !is_principal(principal) => Err(format!("{principal} is not user:, agent: or team: and an id.")),
391+ FolioAccessChange::General { access: GeneralAccess::None, role } => match role {
392+ None => Ok(()),
393+ Some(_) => Err("Restricted takes no role.".to_owned()),
394+ },
395+ FolioAccessChange::General { access, role } => match role {
396+ None => Err(format!("{} needs a role.", access.label())),
397+ Some(FolioRole::Manage) => Err("General access gives view, comment or edit, never full access.".to_owned()),
398+ Some(_) => Ok(()),
399+ },
400+ _ => Ok(()),
401+ }
402+ }
403+
404+ /// The docs service method it goes to.
405+ pub fn method(&self) -> &'static str {
406+ match self {
407+ FolioAccessChange::Grant { .. } | FolioAccessChange::Revoke { .. } => "set_folio_grant",
408+ _ => "set_folio_general_access",
409+ }
410+ }
411+}
412+
413+#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
414+pub struct FolioVersion {
415+ pub id: String,
416+ pub folio_id: String,
417+ pub created_at: String,
418+ pub authors: Vec<Value>,
419+ /// created, edit, agent, suggestion, proposal or restore.
420+ pub kind: String,
421+ pub note: Option<String>,
422+}
423+
424+#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
425+pub struct FolioTemplate {
426+ pub id: String,
427+ pub kind: FolioKind,
428+ pub name: String,
429+ pub description: String,
430+ pub icon: Option<String>,
431+ pub builtin: bool,
432+ pub body: String,
433+ pub created_by: Option<Value>,
434+}
435+
436+// --- Agent edits --------------------------------------------------------------
437+
438+/// A JavaScript `String(value)` of a field, for messages that must read the
439+/// same as the TypeScript's.
440+fn js_string(value: Option<&Value>) -> String {
441+ match value {
442+ None => "undefined".to_owned(),
443+ Some(Value::String(text)) => text.clone(),
444+ Some(Value::Object(_)) => "[object Object]".to_owned(),
445+ Some(Value::Array(items)) => items.iter().map(|item| js_string(Some(item))).collect::<Vec<_>>().join(","),
446+ Some(other) => other.to_string(),
447+ }
448+}
449+
450+/// What is wrong with an agent edit's envelope (`FolioAgentEdit` in
451+/// folios.ts), in the same words as `folioAgentEditError`: its kind, and a
452+/// doc's target and Markdown or the other kinds' list of ops. Returns the
453+/// kind when it is well formed. Each op is the docs service's to check.
454+pub fn agent_edit_error(edit: &Value) -> Result<FolioKind, String> {
455+ let Some(edit) = edit.as_object() else { return Err("An edit is an object.".to_owned()) };
456+ let Some(kind) = edit.get("kind").and_then(Value::as_str).and_then(FolioKind::parse) else {
457+ return Err(format!("There is no kind of artifact called {}.", js_string(edit.get("kind"))));
458+ };
459+ if edit.get("note").is_some_and(|note| !note.is_null() && !note.is_string()) {
460+ return Err("note is text.".to_owned());
461+ }
462+ if kind == FolioKind::Doc {
463+ if !edit.get("markdown").is_some_and(Value::is_string) {
464+ return Err("A doc edit has markdown.".to_owned());
465+ }
466+ if edit.contains_key("ops") {
467+ return Err("A doc edit has a target and markdown, not ops.".to_owned());
468+ }
469+ let Some(target) = edit.get("target").and_then(Value::as_object) else {
470+ return Err("A doc edit has a target.".to_owned());
471+ };
472+ let text = |key: &str| target.get(key).and_then(Value::as_str);
473+ return match text("kind") {
474+ Some("append" | "document") => Ok(kind),
475+ Some("section") if text("heading").is_some_and(|heading| !heading.is_empty()) => Ok(kind),
476+ Some("section") => Err("A section target names its heading.".to_owned()),
477+ Some("blocks") if text("from_block").is_some() && text("to_block").is_some() => Ok(kind),
478+ Some("blocks") => Err("A blocks target names from_block and to_block.".to_owned()),
479+ _ => Err("A doc edit's target is append, document, section or blocks.".to_owned()),
480+ };
481+ }
482+ if edit.contains_key("markdown") || edit.contains_key("target") {
483+ return Err(format!("A {} edit has ops, not a target and markdown.", kind.noun()));
484+ }
485+ let Some(ops) = edit.get("ops").and_then(Value::as_array).filter(|ops| !ops.is_empty()) else {
486+ return Err(format!("A {} edit has a list of ops.", kind.noun()));
487+ };
488+ if ops.len() > MAX_OPS {
489+ return Err(format!("An edit has at most {MAX_OPS} ops."));
490+ }
491+ if !ops.iter().all(|op| op.get("op").is_some_and(Value::is_string)) {
492+ return Err("Each op is an object with an op.".to_owned());
493+ }
494+ Ok(kind)
495+}
496+
497+// --- The docs service's folio RPC ---------------------------------------------
498+
499+/// Every method the docs service answers for folios, as `FOLIO_RPC_METHODS`
500+/// in folios.ts.
501+pub const FOLIO_RPC_METHODS: [&str; 46] = [
502+ "folio_list",
503+ "folio_sidebar",
504+ "folio",
505+ "create_folio",
506+ "update_folio",
507+ "move_folio",
508+ "duplicate_folio",
509+ "trash_folio",
510+ "restore_folio",
511+ "delete_folio",
512+ "folio_trash",
513+ "favorite_folio",
514+ "folio_content",
515+ "edit_folio",
516+ "folio_access",
517+ "set_folio_grant",
518+ "set_folio_general_access",
519+ "request_folio_access",
520+ "join_space",
521+ "leave_space",
522+ "search_folios",
523+ "folio_versions",
524+ "folio_version",
525+ "restore_folio_version",
526+ "folio_templates",
527+ "save_folio_template",
528+ "delete_folio_template",
529+ "export_folio",
530+ "folio_suggestions",
531+ "decide_folio_suggestion",
532+ "folio_proposals",
533+ "decide_folio_proposal",
534+ "folio_thread",
535+ "folio_threads",
536+ "query_tile",
537+ "query_dataset",
538+ "query_dataset_for_agent",
539+ "folios_for_agent",
540+ "read_folio_for_agent",
541+ "create_folio_as_agent",
542+ "edit_folio_as_agent",
543+ "share_folio_as_agent",
544+ "recall_folios_for_agent",
545+ "stale_folios_for_agent",
546+ "mark_folio_current",
547+ "reindex_folios",
548+];
549+
550+/// `folio_list`.
551+#[derive(Clone, Debug, Serialize, Deserialize)]
552+pub struct FolioListArgs {
553+ pub workspace: String,
554+ pub viewer: User,
555+ pub query: FolioListQuery,
556+}
557+
558+/// `folio`, `duplicate_folio`, `trash_folio`, `restore_folio`,
559+/// `delete_folio`, `folio_content`, `folio_access`, `folio_versions`.
560+#[derive(Clone, Debug, Serialize, Deserialize)]
561+pub struct FolioArgs {
562+ pub workspace: String,
563+ pub viewer: User,
564+ pub folio_id: String,
565+}
566+
567+/// `create_folio`.
568+#[derive(Clone, Debug, Serialize, Deserialize)]
569+pub struct CreateFolioArgs {
570+ pub workspace: String,
571+ pub viewer: User,
572+ pub input: NewFolio,
573+}
574+
575+/// `update_folio`.
576+#[derive(Clone, Debug, Serialize, Deserialize)]
577+pub struct UpdateFolioArgs {
578+ pub workspace: String,
579+ pub viewer: User,
580+ pub folio_id: String,
581+ pub change: FolioChange,
582+}
583+
584+/// `move_folio`.
585+#[derive(Clone, Debug, Serialize, Deserialize)]
586+pub struct MoveFolioArgs {
587+ pub workspace: String,
588+ pub viewer: User,
589+ pub folio_id: String,
590+ #[serde(rename = "move")]
591+ pub to: FolioMove,
592+}
593+
594+/// `edit_folio`: a `FolioAgentEdit`, as JSON.
595+#[derive(Clone, Debug, Serialize, Deserialize)]
596+pub struct EditFolioArgs {
597+ pub workspace: String,
598+ pub viewer: User,
599+ pub folio_id: String,
600+ pub edit: Value,
601+}
602+
603+/// `set_folio_grant` and `set_folio_general_access` (see
604+/// [`FolioAccessChange::method`]).
605+#[derive(Clone, Debug, Serialize, Deserialize)]
606+pub struct FolioAccessArgs {
607+ pub workspace: String,
608+ pub viewer: User,
609+ pub folio_id: String,
610+ pub change: FolioAccessChange,
611+}
612+
613+/// `folio_version` and `restore_folio_version`.
614+#[derive(Clone, Debug, Serialize, Deserialize)]
615+pub struct FolioVersionArgs {
616+ pub workspace: String,
617+ pub viewer: User,
618+ pub folio_id: String,
619+ pub version_id: String,
620+}
621+
622+/// `folio_templates`.
623+#[derive(Clone, Debug, Serialize, Deserialize)]
624+pub struct FolioTemplatesArgs {
625+ pub workspace: String,
626+ pub viewer: User,
627+ pub kind: Option<FolioKind>,
628+}
629+
630+/// `search_folios`.
631+#[derive(Clone, Debug, Serialize, Deserialize)]
632+pub struct SearchFoliosArgs {
633+ pub workspace: String,
634+ pub viewer: User,
635+ pub query: FolioSearchQuery,
636+}
637+
638+#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
639+pub struct FolioSearchQuery {
640+ pub q: String,
641+ #[serde(default, skip_serializing_if = "Option::is_none")]
642+ pub kinds: Option<Vec<FolioKind>>,
643+ #[serde(default, skip_serializing_if = "Option::is_none")]
644+ pub space_id: Option<String>,
645+ #[serde(default, skip_serializing_if = "Option::is_none")]
646+ pub project: Option<String>,
647+ #[serde(default, skip_serializing_if = "Option::is_none")]
648+ pub owner: Option<String>,
649+ /// `words` or `hybrid`.
650+ #[serde(default, skip_serializing_if = "Option::is_none")]
651+ pub mode: Option<String>,
652+ #[serde(default, skip_serializing_if = "Option::is_none")]
653+ pub limit: Option<u32>,
654+}
655+
656+/// `query_dataset`.
657+#[derive(Clone, Debug, Serialize, Deserialize)]
658+pub struct QueryDatasetArgs {
659+ pub workspace: String,
660+ pub viewer: User,
661+ pub query: DatasetQuery,
662+}
663+
664+// --- Events -------------------------------------------------------------------
665+
666+/// The `folio.*` types the docs service will publish (Phase 1), with no
667+/// `repoId` on the event: a folio may be private, so it never reaches a
668+/// repository's timeline or webhooks. Nothing subscribes to them yet.
669+pub const FOLIO_EVENTS: [&str; 6] = ["folio.created", "folio.updated", "folio.trashed", "folio.restored", "folio.shared", "folio.stale"];
670+
671+/// What every `folio.*` event carries (`FolioEventData` in events.ts).
672+/// Event data is camelCase, as every event on the bus.
673+#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
674+#[serde(rename_all = "camelCase")]
675+pub struct FolioEvent {
676+ pub workspace: String,
677+ pub workspace_id: String,
678+ pub folio_id: String,
679+ pub kind: FolioKind,
680+ pub space_id: Option<String>,
681+ /// None unless every member of the workspace can read it.
682+ pub title: Option<String>,
683+}
684+
685+/// `folio.updated`: a version.
686+#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
687+#[serde(rename_all = "camelCase")]
688+pub struct FolioUpdated {
689+ #[serde(flatten)]
690+ pub folio: FolioEvent,
691+ pub version_id: String,
692+ /// edit, agent, suggestion, proposal or restore.
693+ pub version_kind: String,
694+ /// Member keys.
695+ pub authors: Vec<String>,
696+}
697+
698+/// `folio.shared`: who was given access, never content.
699+#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
700+#[serde(rename_all = "camelCase")]
701+pub struct FolioShared {
702+ #[serde(flatten)]
703+ pub folio: FolioEvent,
704+ pub principals: Vec<String>,
705+ pub role: FolioRole,
706+}
707+
708+/// `folio.stale`: code it cites changed.
709+#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
710+#[serde(rename_all = "camelCase")]
711+pub struct FolioStale {
712+ #[serde(flatten)]
713+ pub folio: FolioEvent,
714+ /// `owner/name`.
715+ pub repo: String,
716+ pub commit: String,
717+ pub pull: Option<u32>,
718+ pub paths: Vec<String>,
719+ pub owners: Vec<String>,
720+}
721+
722+#[cfg(test)]
723+mod tests {
724+ use super::*;
725+ use serde_json::json;
726+
727+ const TS: &str = include_str!("../../../packages/contracts/src/folios.ts");
728+ const EVENTS_TS: &str = include_str!("../../../packages/contracts/src/events.ts");
729+
730+ fn fixtures() -> Value {
731+ serde_json::from_str(include_str!("../../../packages/contracts/src/folios.fixtures.json")).unwrap()
732+ }
733+
734+ /// The body of `export type <name> = ...` up to its closing `};`.
735+ fn ts_type<'a>(source: &'a str, name: &str) -> &'a str {
736+ let start = format!("export type {name} = ");
737+ source
738+ .split_once(&start)
739+ .and_then(|(_, rest)| rest.split_once("\n};"))
740+ .map(|(body, _)| body)
741+ .unwrap_or_else(|| panic!("{name} in the TypeScript"))
742+ }
743+
744+ /// Every key `value` serializes to is a field of one of the TypeScript
745+ /// types named.
746+ fn fields_match(value: &Value, source: &str, types: &[&str]) {
747+ let bodies: Vec<&str> = types.iter().map(|name| ts_type(source, name)).collect();
748+ for key in value.as_object().unwrap().keys() {
749+ assert!(
750+ bodies.iter().any(|body| body.contains(&format!(" {key}: ")) || body.contains(&format!(" {key}?: "))),
751+ "{key} is not a field of {types:?}"
752+ );
753+ }
754+ }
755+
756+ fn folio() -> Folio {
757+ serde_json::from_value(json!({
758+ "id": "fol_01jb2k7x9hfq0b3zj0f5s2m8ra", "kind": "slides", "title": "Q4 roadmap", "icon": null,
759+ "slug": "q4-roadmap-fol_01jb2k7x9hfq0b3zj0f5s2m8ra", "path": "/acme/-/artifacts/q4-roadmap-fol_01jb2k7x9hfq0b3zj0f5s2m8ra",
760+ "workspace_id": "wsp_1", "space": { "id": "spc_1", "slug": "general", "name": "General", "kind": "workspace" },
761+ "parent_id": null, "position": 1.5,
762+ "owner": { "kind": "user", "id": "usr_1", "name": "ana", "display_name": "Ana" },
763+ "created_by": { "kind": "agent", "id": "agt_1", "name": "g1t", "display_name": "g1t" },
764+ "created_at": "2026-10-09T00:00:00.000Z", "updated_at": "2026-10-09T00:00:00.000Z",
765+ "edited_by": null, "edited_at": "2026-10-09T00:00:00.000Z", "trashed_at": null,
766+ "viewer_role": "edit", "favorite": false, "private": false, "shared_count": 2,
767+ "general_access": "none", "general_role": null, "inherit": true,
768+ "inherited_from": { "kind": "space", "id": "spc_1", "name": "General" }, "agent_mode": "suggest",
769+ "excerpt": "", "preview": { "kind": "slides", "aspect": "16:9", "count": 3, "layout": "title", "title": "Q4" },
770+ "source": { "title": "#product", "href": "/acme/-/chat/c/1" }, "stale": false, "has_children": false
771+ }))
772+ .unwrap()
773+ }
774+
775+ #[test]
776+ fn shapes_have_the_typescript_field_names() {
777+ fields_match(&serde_json::to_value(folio()).unwrap(), TS, &["FolioRef", "Folio"]);
778+ let new = NewFolio {
779+ kind: FolioKind::Doc,
780+ title: Some("Plan".to_owned()),
781+ icon: Some("🗺️".to_owned()),
782+ space_id: Some("spc_1".to_owned()),
783+ parent_id: Some("fol_1".to_owned()),
784+ template_id: Some("tpl_1".to_owned()),
785+ content: Some(FolioContentInput { markdown: Some("# Plan".to_owned()), spec: None }),
786+ source: Some(FolioSource { title: "t".to_owned(), href: "/h".to_owned() }),
787+ share_with: Some(vec![ShareWith { principal: "user:usr_2".to_owned(), role: FolioRole::View }]),
788+ };
789+ fields_match(&serde_json::to_value(&new).unwrap(), TS, &["NewFolio"]);
790+ let query = FolioListQuery {
791+ tab: FolioListTab::Shared,
792+ kinds: Some(vec![FolioKind::Design]),
793+ space_id: Some("s".to_owned()),
794+ owner: Some("user:u".to_owned()),
795+ project: Some("acme/web".to_owned()),
796+ q: Some("roadmap".to_owned()),
797+ cursor: Some("c".to_owned()),
798+ limit: Some(20),
799+ };
800+ fields_match(&serde_json::to_value(&query).unwrap(), TS, &["FolioListQuery"]);
801+ let change = FolioChange { title: Some("x".to_owned()), icon: Some(None), cover: Some(None), projects: Some(vec![]) };
802+ fields_match(&serde_json::to_value(&change).unwrap(), TS, &["FolioChange"]);
803+ let to = FolioMove { space_id: None, parent_id: None, before_id: Some("fol_2".to_owned()) };
804+ assert_eq!(serde_json::to_value(&to).unwrap(), json!({ "space_id": null, "parent_id": null, "before_id": "fol_2" }));
805+ let list = FolioList { items: vec![], next_cursor: None };
806+ assert_eq!(serde_json::to_value(&list).unwrap(), json!({ "items": [], "next_cursor": null }));
807+ let version = FolioVersion { id: "ver_1".to_owned(), folio_id: "fol_1".to_owned(), created_at: "t".to_owned(), authors: vec![], kind: "edit".to_owned(), note: None };
808+ fields_match(&serde_json::to_value(&version).unwrap(), TS, &["FolioVersion"]);
809+ let template = FolioTemplate {
810+ id: "tpl_1".to_owned(),
811+ kind: FolioKind::Dashboard,
812+ name: "Engineering health".to_owned(),
813+ description: String::new(),
814+ icon: None,
815+ builtin: true,
816+ body: "{}".to_owned(),
817+ created_by: None,
818+ };
819+ fields_match(&serde_json::to_value(&template).unwrap(), TS, &["FolioTemplate"]);
820+ // Every field of the TypeScript `Folio` comes back from Rust too.
821+ let rust: Vec<String> = serde_json::to_value(folio()).unwrap().as_object().unwrap().keys().cloned().collect();
822+ for body in [ts_type(TS, "FolioRef"), ts_type(TS, "Folio")] {
823+ for line in body.lines() {
824+ let line = line.trim_start();
825+ if let Some((key, _)) = line.split_once(':').filter(|(key, _)| !key.is_empty() && key.bytes().all(|b| b.is_ascii_lowercase() || b == b'_')) {
826+ assert!(rust.contains(&key.to_owned()), "Folio has no {key} in Rust");
827+ }
828+ }
829+ }
830+ }
831+
832+ #[test]
833+ fn access_changes_travel_tagged_by_op() {
834+ let change = FolioAccessChange::General { access: GeneralAccess::Link, role: Some(FolioRole::Comment) };
835+ assert_eq!(serde_json::to_value(&change).unwrap(), json!({ "op": "general", "access": "link", "role": "comment" }));
836+ assert_eq!(change.method(), "set_folio_general_access");
837+ let grant: FolioAccessChange = serde_json::from_value(json!({ "op": "grant", "principal": "team:design", "role": "edit" })).unwrap();
838+ assert_eq!(grant.method(), "set_folio_grant");
839+ let mode: FolioAccessChange = serde_json::from_value(json!({ "op": "agent_mode", "agent_mode": "edit" })).unwrap();
840+ assert_eq!(mode, FolioAccessChange::AgentMode { agent_mode: Some(AgentMode::Edit) });
841+ }
842+
843+ /// The validators agree with the TypeScript's on the shared fixtures.
844+ #[test]
845+ fn agrees_with_the_typescript_validators_on_the_fixtures() {
846+ let fixtures = fixtures();
847+ let check = |case: &Value, outcome: Result<(), String>| match case["error"].as_str() {
848+ None => assert_eq!(outcome, Ok(()), "{case}"),
849+ Some("*") => assert!(outcome.is_err(), "{case} should be refused"),
850+ Some(error) => assert_eq!(outcome, Err(error.to_owned()), "{case}"),
851+ };
852+ for case in fixtures["new_folio"].as_array().unwrap() {
853+ let outcome = serde_json::from_value::<NewFolio>(case["input"].clone()).map_err(|_| "*".to_owned()).and_then(|input| input.validate());
854+ check(case, outcome);
855+ }
856+ for case in fixtures["access_change"].as_array().unwrap() {
857+ let outcome = serde_json::from_value::<FolioAccessChange>(case["change"].clone()).map_err(|_| "*".to_owned()).and_then(|change| change.validate());
858+ check(case, outcome);
859+ }
860+ for case in fixtures["agent_edit"].as_array().unwrap() {
861+ check(case, agent_edit_error(&case["edit"]).map(|_| ()));
862+ }
863+ for case in fixtures["folio_ids"].as_array().unwrap() {
864+ assert_eq!(folio_id_from(case["segment"].as_str().unwrap()), case["id"].as_str(), "{case}");
865+ }
866+ }
867+
868+ #[test]
869+ fn the_typescript_mirror_has_the_same_kinds_methods_and_events() {
870+ for kind in FolioKind::ALL {
871+ assert!(TS.contains(&format!(" {}: \"{}\",", kind.as_str(), kind.label())), "{}", kind.as_str());
872+ assert_eq!(serde_json::to_value(kind).unwrap(), kind.as_str());
873+ }
874+ let methods: Vec<&str> = TS
875+ .split_once("export const FOLIO_RPC_METHODS = [")
876+ .and_then(|(_, rest)| rest.split_once("] as const"))
877+ .map(|(list, _)| list)
878+ .expect("FOLIO_RPC_METHODS in folios.ts")
879+ .lines()
880+ .filter_map(|line| line.trim().strip_prefix('"').and_then(|rest| rest.split_once('"')).map(|(name, _)| name))
881+ .collect();
882+ assert_eq!(methods, FOLIO_RPC_METHODS);
883+ for event in FOLIO_EVENTS {
884+ assert!(EVENTS_TS.contains(&format!(" \"{event}\": FolioEventData")), "{event} in events.ts");
885+ }
886+ let event = FolioEvent {
887+ workspace: "acme".to_owned(),
888+ workspace_id: "wsp_1".to_owned(),
889+ folio_id: "fol_1".to_owned(),
890+ kind: FolioKind::Doc,
891+ space_id: None,
892+ title: None,
893+ };
894+ fields_match(&serde_json::to_value(&event).unwrap(), EVENTS_TS, &["FolioEventData"]);
895+ }
896+
897+ #[test]
898+ fn folio_events_read_as_published_and_name_no_repository_id() {
899+ let data = json!({
900+ "workspace": "acme", "workspaceId": "wsp_1", "folioId": "fol_1", "kind": "doc", "spaceId": null, "title": null,
901+ "repo": "acme/web", "commit": "abc", "pull": 431, "paths": ["src/export.ts"], "owners": ["user:usr_1"]
902+ });
903+ let stale: FolioStale = serde_json::from_value(data).unwrap();
904+ assert_eq!((stale.folio.folio_id.as_str(), stale.pull), ("fol_1", Some(431)));
905+ let json = serde_json::to_value(&stale).unwrap();
906+ assert!(json.get("repoId").is_none());
907+ let shared: FolioShared = serde_json::from_value(json!({
908+ "workspace": "acme", "workspaceId": "wsp_1", "folioId": "fol_1", "kind": "slides", "spaceId": "spc_1", "title": "Q4",
909+ "principals": ["user:usr_2"], "role": "comment"
910+ }))
911+ .unwrap();
912+ assert_eq!(shared.role, FolioRole::Comment);
913+ // Not offered to webhooks.
914+ for event in FOLIO_EVENTS {
915+ assert!(!crate::webhooks::EVENT_TYPES.contains(&event), "{event}");
916+ }
917+ }
918+
919+ #[test]
920+ fn principals_and_kinds() {
921+ assert!(is_principal("user:usr_01jb"));
922+ assert!(is_principal("team:platform-web"));
923+ assert!(!is_principal("user:"));
924+ assert!(!is_principal("group:x"));
925+ assert!(!is_principal("user:a b"));
926+ assert!(FolioKind::Doc.can_have_children());
927+ assert!(FolioKind::ALL.iter().filter(|kind| kind.can_have_children()).count() == 1);
928+ assert!(FolioRole::Manage > FolioRole::Edit && FolioRole::Comment > FolioRole::View);
929+ }
930+}
+2−0
1717 pub mod checks;
1818 pub mod codeowners;
1919 pub mod credentials;
20+pub mod datasets;
2021 pub mod deploy_keys;
2122 pub mod events;
23+pub mod folios;
2224 pub mod github;
2325 pub mod guardrails;
2426 pub mod identity;
+101−28
4343 Secrets,
4444 Runners,
4545 Models,
46+ /// Artifacts mode's docs, slides, designs and dashboards (folios in
47+ /// code). Not offered yet: see [`Resource::offered`].
48+ Artifacts,
4649 }
4750
4851 impl Resource {
49− pub const ALL: [Resource; 21] = [
52+ pub const ALL: [Resource; 22] = [
5053 Resource::Repo,
5154 Resource::Code,
5255 Resource::Security,
6871 Resource::Secrets,
6972 Resource::Runners,
7073 Resource::Models,
74+ Resource::Artifacts,
7175 ];
7276
7377 pub fn as_str(self) -> &'static str {
9397 Resource::Secrets => "secrets",
9498 Resource::Runners => "runners",
9599 Resource::Models => "models",
100+ Resource::Artifacts => "artifacts",
96101 }
97102 }
98103
120125 Resource::Secrets => "Secrets and variables",
121126 Resource::Runners => "Self-hosted runners",
122127 Resource::Models => "AI Gateway",
128+ Resource::Artifacts => "Artifacts",
123129 }
124130 }
131+
132+ /// Whether tokens are offered it yet. A resource that is not is in the
133+ /// table (so its scopes parse, and the TypeScript mirror lists it under
134+ /// `UPCOMING_RESOURCES`) but nothing hands it out: presets, full
135+ /// access, OAuth and the token form leave it out, and no operation
136+ /// needs it. Artifacts is offered once its API ships (Phase 3 of
137+ /// docs/ARTIFACTS_MODE.md).
138+ pub fn offered(self) -> bool {
139+ !matches!(self, Resource::Artifacts)
140+ }
125141 }
126142
127143 /// How much of a resource.
194210 RunnersAdmin,
195211 ModelsRead,
196212 ModelsWrite,
213+ ArtifactsRead,
214+ ArtifactsWrite,
215+ ArtifactsAdmin,
197216 }
198217
199218 impl Scope {
200219 /// Every scope, grouped by resource, least first.
201− pub const ALL: [Scope; 42] = [
220+ pub const ALL: [Scope; 45] = [
202221 Scope::RepoRead,
203222 Scope::RepoWrite,
204223 Scope::RepoAdmin,
241260 Scope::RunnersAdmin,
242261 Scope::ModelsRead,
243262 Scope::ModelsWrite,
263+ Scope::ArtifactsRead,
264+ Scope::ArtifactsWrite,
265+ Scope::ArtifactsAdmin,
244266 ];
245267
246268 pub fn as_str(self) -> &'static str {
287309 Scope::RunnersAdmin => "runners:admin",
288310 Scope::ModelsRead => "models:read",
289311 Scope::ModelsWrite => "models:write",
312+ Scope::ArtifactsRead => "artifacts:read",
313+ Scope::ArtifactsWrite => "artifacts:write",
314+ Scope::ArtifactsAdmin => "artifacts:admin",
290315 }
291316 }
292317
370395 Scope::RunnersAdmin => "Register and remove self-hosted runners, change their groups and settings",
371396 Scope::ModelsRead => "See the workspace's AI Gateway requests: their models, tokens, cost and status",
372397 Scope::ModelsWrite => "Send model requests through the AI Gateway, which uses the workspace's AI credit",
398+ Scope::ArtifactsRead => "List, read and search artifacts you can see, their versions, and the numbers their dashboards show",
399+ Scope::ArtifactsWrite => "Create, rename, move, edit, trash and restore artifacts, and propose changes to them",
400+ Scope::ArtifactsAdmin => "Share artifacts, change who can open them, and delete them for good",
373401 }
374402 }
403+
404+ /// Whether tokens are offered it yet: its resource's [`Resource::offered`].
405+ pub fn offered(self) -> bool {
406+ self.resource().offered()
407+ }
408+}
409+
410+/// Every scope tokens are offered, in table order: what OAuth advertises.
411+pub fn offered_scopes() -> Vec<Scope> {
412+ Scope::ALL.into_iter().filter(|scope| scope.offered()).collect()
375413 }
376414
377415 impl Serialize for Scope {
394432 let mut scopes: Vec<Scope> = text
395433 .split(|c: char| c.is_whitespace() || c == ',')
396434 .filter_map(Scope::parse)
435+ .filter(|scope| scope.offered())
397436 .collect();
398437 normalize(&mut scopes);
399438 scopes
438477 pub fn group(self) -> ResourceGroup {
439478 match self {
440479 Resource::Account | Resource::Notifications => ResourceGroup::Account,
441− Resource::Workspace | Resource::Billing | Resource::Runners | Resource::Models => ResourceGroup::Workspace,
480+ Resource::Workspace | Resource::Billing | Resource::Runners | Resource::Models | Resource::Artifacts => ResourceGroup::Workspace,
442481 _ => ResourceGroup::Repository,
443482 }
444483 }
477516
478517 /// Every resource at its highest level: all a token can be given.
479518 pub fn everything() -> Vec<Scope> {
480− top_scopes(&Scope::ALL)
519+ top_scopes(&offered_scopes())
481520 }
482521
483522 /// Scopes as permissions: each resource held, by name, at its highest
495534 pub fn resolve_permissions(asked: &std::collections::BTreeMap<String, String>, personal: bool) -> Result<Vec<Scope>, String> {
496535 let mut scopes = Vec::new();
497536 for (name, level) in asked {
498− let Some(resource) = Resource::parse(name) else {
537+ let Some(resource) = Resource::parse(name).filter(|resource| resource.offered()) else {
499538 return Err(format!("There is no permission called {name}."));
500539 };
501540 let level = level.trim().to_ascii_lowercase();
550589
551590 /// Its scopes; `None` for full access.
552591 pub fn scopes(self) -> Option<Vec<Scope>> {
553− let reads = || Scope::ALL.into_iter().filter(|scope| scope.level() == Level::Read);
592+ let reads = || Scope::ALL.into_iter().filter(|scope| scope.level() == Level::Read && scope.offered());
554593 match self {
555594 Preset::ReadOnly => Some(reads().collect()),
556595 Preset::Agent => {
14001439 assert!(scopes.contains(&Scope::PullRequestsWrite));
14011440 assert!(scopes.contains(&Scope::AgentsRun));
14021441 assert!(scopes.iter().all(|scope| !scope.dangerous()), "{scopes:?}");
1403− for read in Scope::ALL.into_iter().filter(|scope| scope.level() == Level::Read) {
1404− // Every read but the machines work runs on.
1442+ for read in Scope::ALL.into_iter().filter(|scope| scope.level() == Level::Read && scope.offered()) {
1443+ // Every read offered but the machines work runs on.
14051444 assert_eq!(scopes.contains(&read), read != Scope::RunnersRead, "{read:?}");
14061445 }
14071446 assert!(Preset::ReadOnly.scopes().unwrap().iter().all(|scope| scope.level() == Level::Read));
14391478 }
14401479
14411480 #[test]
1481+ fn artifacts_scopes_exist_but_are_not_offered_yet() {
1482+ for scope in [Scope::ArtifactsRead, Scope::ArtifactsWrite, Scope::ArtifactsAdmin] {
1483+ assert_eq!(scope.resource(), Resource::Artifacts);
1484+ assert!(!scope.offered());
1485+ assert_eq!(Scope::parse(scope.as_str()), Some(scope));
1486+ // Nothing hands it out: not presets, full access, OAuth or the form.
1487+ for preset in [Preset::ReadOnly, Preset::Agent, Preset::Ci] {
1488+ assert!(!preset.scopes().unwrap().contains(&scope), "{}", preset.as_str());
1489+ }
1490+ assert!(!everything().contains(&scope));
1491+ assert!(!offered_scopes().contains(&scope));
1492+ assert!(!oauth_default().contains(&scope));
1493+ assert!(parse_scopes(scope.as_str()).is_empty());
1494+ // And no operation needs it yet.
1495+ assert!(OPERATIONS.iter().all(|(_, needed)| *needed != scope));
1496+ }
1497+ assert!(Scope::ArtifactsAdmin.includes(Scope::ArtifactsWrite));
1498+ assert!(Scope::ArtifactsAdmin.dangerous());
1499+ assert_eq!(Resource::Artifacts.group(), ResourceGroup::Workspace);
1500+ let asked = std::collections::BTreeMap::from([("artifacts".to_owned(), "read".to_owned())]);
1501+ assert_eq!(resolve_permissions(&asked, true), Err("There is no permission called artifacts.".to_owned()));
1502+ assert_eq!(offered_scopes().len(), Scope::ALL.len() - 3);
1503+ }
1504+
1505+ #[test]
14421506 fn checks_are_reported_with_checks_write_which_ci_gets() {
14431507 assert_eq!(scope_for("create_check_run"), Some(Scope::ChecksWrite));
14441508 assert_eq!(scope_for("create_commit_status"), Some(Scope::ChecksWrite));
15431607 assert!(!back.contains_key("code"));
15441608 // Lower levels held beside a higher one say nothing more.
15451609 assert_eq!(top_scopes(&[Scope::RepoRead, Scope::RepoAdmin, Scope::RepoWrite]), vec![Scope::RepoAdmin]);
1546− // Every resource's top, and nothing a level can lose.
1610+ // Every offered resource's top, and nothing a level can lose.
15471611 let all = everything();
1548− assert_eq!(all.len(), Resource::ALL.len());
1549− for scope in Scope::ALL {
1612+ assert_eq!(all.len(), Resource::ALL.into_iter().filter(|resource| resource.offered()).count());
1613+ for scope in offered_scopes() {
15501614 assert!(all.iter().any(|held| held.includes(scope)), "{scope:?}");
15511615 }
15521616 }
16881752 .map(|(table, _)| table)
16891753 .unwrap_or_else(|| panic!("{start} in scopes.ts"))
16901754 };
1691− let scopes: Vec<&str> = section("export const SCOPES = [")
1692− .lines()
1693− .filter_map(|line| line.split_once("scope: \"").and_then(|(_, rest)| rest.split_once('"')).map(|(scope, _)| scope))
1694− .collect();
1695− let expected: Vec<&str> = Scope::ALL.iter().map(|scope| scope.as_str()).collect();
1696− assert_eq!(scopes, expected);
1755+ let names = |table: &str| -> Vec<String> {
1756+ section(table)
1757+ .lines()
1758+ .filter_map(|line| line.split_once("scope: \"").and_then(|(_, rest)| rest.split_once('"')).map(|(scope, _)| scope.to_owned()))
1759+ .collect()
1760+ };
1761+ // Offered scopes in `SCOPES`, the rest in `UPCOMING_SCOPES`.
1762+ let offered: Vec<String> = Scope::ALL.iter().filter(|scope| scope.offered()).map(|scope| scope.as_str().to_owned()).collect();
1763+ let upcoming: Vec<String> = Scope::ALL.iter().filter(|scope| !scope.offered()).map(|scope| scope.as_str().to_owned()).collect();
1764+ assert_eq!(names("export const SCOPES = ["), offered);
1765+ assert_eq!(names("export const UPCOMING_SCOPES = ["), upcoming);
16971766 let operations: Vec<(String, String)> = section("export const OPERATION_SCOPES = [")
16981767 .lines()
16991768 .filter_map(|line| {
17191788 .unwrap_or_else(|| vec!["*"]);
17201789 assert_eq!(mirrored, expected, "{}", preset.as_str());
17211790 }
1722− // Each resource with its group, in the same order.
1723− let resources = ts
1724− .split_once("export const SCOPE_RESOURCES")
1725− .and_then(|(_, rest)| rest.split_once("
1791+ // Each resource with its group, in the same order: offered ones in
1792+ // `SCOPE_RESOURCES`, the rest in `UPCOMING_RESOURCES`.
1793+ for (table, offered) in [("export const SCOPE_RESOURCES", true), ("export const UPCOMING_RESOURCES", false)] {
1794+ let resources = ts
1795+ .split_once(table)
1796+ .and_then(|(_, rest)| rest.split_once("
17261797 ];"))
1727− .map(|(table, _)| table)
1728− .expect("SCOPE_RESOURCES in scopes.ts");
1729− let rows: Vec<&str> = resources.lines().filter(|line| line.trim_start().starts_with("{ resource:")).collect();
1730− assert_eq!(rows.len(), Resource::ALL.len());
1731− for (row, resource) in rows.iter().zip(Resource::ALL) {
1732− assert!(row.contains(&format!("resource: \"{}\"", resource.as_str())), "{row}");
1733− assert!(row.contains(&format!("group: \"{}\"", resource.group().as_str())), "{row}");
1798+ .map(|(table, _)| table)
1799+ .unwrap_or_else(|| panic!("{table} in scopes.ts"));
1800+ let rows: Vec<&str> = resources.lines().filter(|line| line.trim_start().starts_with("{ resource:")).collect();
1801+ let expected: Vec<Resource> = Resource::ALL.into_iter().filter(|resource| resource.offered() == offered).collect();
1802+ assert_eq!(rows.len(), expected.len(), "{table}");
1803+ for (row, resource) in rows.iter().zip(expected) {
1804+ assert!(row.contains(&format!("resource: \"{}\"", resource.as_str())), "{row}");
1805+ assert!(row.contains(&format!("group: \"{}\"", resource.group().as_str())), "{row}");
1806+ }
17341807 }
17351808 }
17361809 }
+22−0
796796 - `packages/contracts/src/docs.ts`: the source for the new `folios.ts` and `datasets.ts`.
797797 - `apps/web/app/routes.ts`, plus `apps/web/app/lib/workspace-nav.ts`, `apps/web/app/components/rail.tsx` and `apps/web/app/components/shell.tsx`: mode wiring.
798798 - `services/agents/src/tools.ts` and `apps/api/src/tools.rs`: agent and MCP tools.
799+
800+---
801+
802+## 10. Decided in Phase 0
803+
804+Phase 0 shipped the contracts with no runtime change. Where this plan was open, it settled these:
805+
806+- **Scopes not offered yet.** `artifacts:read|write|admin` are in `Scope::ALL` (at the end) behind `Resource::offered()`, which is false for Artifacts. Presets, full access as a list (`everything()`), OAuth's `scopes_supported`, `parse_scopes` and `resolve_permissions` leave them out, and no operation needs them. The TypeScript mirror keeps them in `UPCOMING_SCOPES` and `UPCOMING_RESOURCES`, so the token form, the OAuth checklist and apps/docs never show them. Phase 3 makes `offered()` true, moves the rows to the end of `SCOPES` and `SCOPE_RESOURCES`, adds `artifacts:read` to the presets and adds the docs scope table.
807+- **Dataset catalog.** Section 3.4 named the datasets. Phase 0 fixed their fields (`DATASETS` in `datasets.ts` and `datasets.rs`):
808+ - per dataset: `times` (the default first), `dimensions` (text), `measures` (numbers) and `rates` (yes-or-no, for `rate`);
809+ - `DatasetQuery.time` picks the time field;
810+ - `DatasetResult.partial` drives "Based on what you can see";
811+ - limits: 10 filters, 50 values per `in`, 100 rows, and a `{ from, to }` range of at most 366 days, given as dates or RFC 3339 UTC times.
812+ The 5a services implement exactly these fields.
813+- **Three more RPC methods.** `folio_content` and `edit_folio` are a person's (or their token's) read and edit in the agent form, for REST `/content` and the MCP `get`/`edit` actions. `query_dataset` is a person's query, for `POST /datasets/query` and MCP `query_data`. `FolioAccessChange` also carries `inherit` and `agent_mode` changes. The client sends grants and revokes to `set_folio_grant` and the rest to `set_folio_general_access`.
814+- **Events.** Payloads are camelCase, like every event on the bus. `title` is null unless the whole workspace can read the folio. `folio.updated` uses `versionKind`, because `kind` is the folio's kind. `folio.stale` names the repository as `owner/name` only. `FOLIO_EVENTS` and the payload structs are in `folios.rs` (re-exported from `events.rs`). `subscribers.rs` is untouched until Phase 1 publishes them.
815+- **Validators.**
816+ - They are pure and return an error sentence or null (`Result` in Rust), and the words are the same in both languages.
817+ - Shared cases in `datasets.fixtures.json` and `folios.fixtures.json` are run by both test suites.
818+ - Contracts files import only types from each other, because Node runs the tests on the files as they are. So `dashboardOpError` takes the query validator (`datasetQueryError`) as an argument.
819+- **Ids.** `fol_` for folios and `prp_` for proposals. Versions, templates and files keep `ver_`, `tpl_` and `fil_`.
820+- **Slides themes** are a slug plus an optional `#rrggbb` accent. Phase 4 names the themes.
+44−0
1+{
2+ "about": "Dataset queries both validators must agree on: datasetQueryError/parseDatasetQuery (datasets.ts) and DatasetQuery::validate (crates/contracts/src/datasets.rs). error null is valid; \"*\" is any error (a malformed query, which each side words its own way).",
3+ "cases": [
4+ { "query": { "dataset": "pull_requests", "measure": { "op": "count" }, "interval": "week", "group_by": "repo", "time": "merged_at", "range": "90d" }, "error": null },
5+ { "query": { "dataset": "workflow_runs", "measure": { "op": "rate", "field": "succeeded" }, "filters": [{ "field": "workflow", "op": "in", "value": ["CI", "Deploy"] }] }, "error": null },
6+ { "query": { "dataset": "issues", "measure": { "op": "p95", "field": "time_to_close_hours" }, "range": { "from": "2026-01-01", "to": "2026-04-01T00:00:00Z" }, "limit": 100 }, "error": null },
7+ { "query": { "dataset": "deployments", "measure": { "op": "avg", "field": "duration_seconds" }, "filters": [{ "field": "duration_seconds", "op": "gte", "value": 30 }, { "field": "environment", "op": "eq", "value": "production" }] }, "error": null },
8+ { "query": { "dataset": "spend", "measure": { "op": "sum", "field": "amount_micros" }, "group_by": "product", "interval": "month" }, "error": null },
9+ { "query": { "dataset": "agent_sessions", "measure": { "op": "count" }, "group_by": "agent", "range": { "from": "2024-02-29", "to": "2024-03-01" } }, "error": null },
10+
11+ { "query": { "dataset": "issues", "measure": { "op": "count", "field": "comments" } }, "error": "count takes no field." },
12+ { "query": { "dataset": "issues", "measure": { "op": "rate" } }, "error": "rate needs a field." },
13+ { "query": { "dataset": "issues", "measure": { "op": "rate", "field": "comments" } }, "error": "issues has no yes-or-no field comments to take the rate of." },
14+ { "query": { "dataset": "issues", "measure": { "op": "sum" } }, "error": "sum needs a field." },
15+ { "query": { "dataset": "pull_requests", "measure": { "op": "avg", "field": "merged" } }, "error": "pull_requests has no number field merged." },
16+ { "query": { "dataset": "pull_requests", "measure": { "op": "count" }, "group_by": "author" }, "error": "pull_requests can't be grouped by author." },
17+ { "query": { "dataset": "pull_requests", "measure": { "op": "count" }, "time": "deployed_at" }, "error": "pull_requests has no time field deployed_at." },
18+ { "query": { "dataset": "workflow_runs", "measure": { "op": "count" }, "filters": [{ "field": "workflow", "op": "in", "value": [] }] }, "error": "in on workflow takes a list of 1 to 50 values." },
19+ { "query": { "dataset": "workflow_runs", "measure": { "op": "count" }, "filters": [{ "field": "workflow", "op": "eq", "value": 3 }] }, "error": "eq on workflow takes text." },
20+ { "query": { "dataset": "workflow_runs", "measure": { "op": "count" }, "filters": [{ "field": "branch", "op": "gte", "value": "main" }] }, "error": "branch is text: filter it with eq, neq or in." },
21+ { "query": { "dataset": "workflow_runs", "measure": { "op": "count" }, "filters": [{ "field": "duration_seconds", "op": "in", "value": ["1"] }] }, "error": "duration_seconds is a number: filter it with eq, neq, gte or lte." },
22+ { "query": { "dataset": "workflow_runs", "measure": { "op": "count" }, "filters": [{ "field": "duration_seconds", "op": "lte", "value": "60" }] }, "error": "lte on duration_seconds takes a number." },
23+ { "query": { "dataset": "workflow_runs", "measure": { "op": "count" }, "filters": [{ "field": "secret", "op": "eq", "value": "x" }] }, "error": "workflow_runs can't be filtered by secret." },
24+ { "query": { "dataset": "issues", "measure": { "op": "count" }, "range": { "from": "2026-02-30", "to": "2026-03-10" } }, "error": "A range's from and to are dates or RFC 3339 UTC times." },
25+ { "query": { "dataset": "issues", "measure": { "op": "count" }, "range": { "from": "2026-03-10", "to": "2026-03-10" } }, "error": "A range's from comes before its to." },
26+ { "query": { "dataset": "issues", "measure": { "op": "count" }, "range": { "from": "2025-01-01", "to": "2026-01-03" } }, "error": "A range is at most 366 days." },
27+ { "query": { "dataset": "issues", "measure": { "op": "count" }, "range": { "from": "yesterday", "to": "today" } }, "error": "A range's from and to are dates or RFC 3339 UTC times." },
28+ { "query": { "dataset": "issues", "measure": { "op": "count" }, "limit": 101 }, "error": "limit is between 1 and 100." },
29+ { "query": { "dataset": "issues", "measure": { "op": "count" }, "limit": 0 }, "error": "limit is between 1 and 100." },
30+ { "query": { "dataset": "issues", "measure": { "op": "count" }, "filters": [
31+ { "field": "repo", "op": "eq", "value": "a" }, { "field": "repo", "op": "eq", "value": "b" }, { "field": "repo", "op": "eq", "value": "c" },
32+ { "field": "repo", "op": "eq", "value": "d" }, { "field": "repo", "op": "eq", "value": "e" }, { "field": "repo", "op": "eq", "value": "f" },
33+ { "field": "repo", "op": "eq", "value": "g" }, { "field": "repo", "op": "eq", "value": "h" }, { "field": "repo", "op": "eq", "value": "i" },
34+ { "field": "repo", "op": "eq", "value": "j" }, { "field": "repo", "op": "eq", "value": "k" }
35+ ] }, "error": "A query takes at most 10 filters." },
36+
37+ { "query": { "dataset": "secrets", "measure": { "op": "count" } }, "error": "*" },
38+ { "query": { "dataset": "issues", "measure": { "op": "median" } }, "error": "*" },
39+ { "query": { "dataset": "issues" }, "error": "*" },
40+ { "query": { "dataset": "issues", "measure": { "op": "count" }, "range": "1y" }, "error": "*" },
41+ { "query": { "dataset": "issues", "measure": { "op": "count" }, "interval": "hour" }, "error": "*" },
42+ { "query": "SELECT * FROM issues", "error": "*" }
43+ ]
44+}
+52−0
1+import assert from "node:assert/strict";
2+import { readFileSync } from "node:fs";
3+import { test } from "node:test";
4+
5+import { DATASETS, DATASET_IDS, datasetQueryError, datasetTime, parseDatasetQuery, type DatasetQuery } from "./datasets.ts";
6+
7+const fixtures = JSON.parse(readFileSync(new URL("./datasets.fixtures.json", import.meta.url), "utf8")) as {
8+ cases: { query: unknown; error: string | null }[];
9+};
10+
11+test("agrees with the Rust validator on every shared case", () => {
12+ assert.ok(fixtures.cases.length > 20);
13+ for (const { query, error } of fixtures.cases) {
14+ const parsed = parseDatasetQuery(query);
15+ const label = JSON.stringify(query);
16+ if (error === null) assert.ok("query" in parsed, `${label}: ${"error" in parsed ? parsed.error : ""}`);
17+ else if (error === "*") assert.ok("error" in parsed, `${label} should be refused`);
18+ else assert.deepEqual(parsed, { error }, label);
19+ }
20+});
21+
22+test("a parsed query keeps only what the catalog knows", () => {
23+ const parsed = parseDatasetQuery({ dataset: "issues", measure: { op: "count" }, sql: "DROP TABLE issues", group_by: "repo" });
24+ assert.ok("query" in parsed);
25+ assert.equal("sql" in parsed.query, false);
26+ assert.equal(parsed.query.group_by, "repo");
27+});
28+
29+test("every dataset has a time field and fields that are one thing each", () => {
30+ assert.deepEqual(Object.keys(DATASETS), [...DATASET_IDS]);
31+ for (const id of DATASET_IDS) {
32+ const spec = DATASETS[id];
33+ assert.ok(spec.times.length > 0, id);
34+ for (const field of spec.dimensions) assert.ok(!spec.measures.includes(field) && !spec.rates.includes(field), `${id}.${field}`);
35+ }
36+ assert.equal(DATASETS.spend.needs, "billing");
37+});
38+
39+test("a typed query is checked against the catalog", () => {
40+ const ok: DatasetQuery = { dataset: "workflow_runs", measure: { op: "rate", field: "succeeded" }, interval: "day", range: "30d" };
41+ assert.equal(datasetQueryError(ok), null);
42+ assert.equal(datasetQueryError({ ...ok, limit: 2.5 }), "limit is between 1 and 100.");
43+});
44+
45+test("range ends are real dates or UTC times", () => {
46+ assert.equal(datasetTime("1970-01-02"), 86_400_000);
47+ assert.equal(datasetTime("2026-10-02T05:16:19Z"), 1_790_918_179_000);
48+ assert.equal(datasetTime("2026-10-02T05:16:19.5Z"), 1_790_918_179_500);
49+ for (const bad of ["2026-02-29", "2026-04-31", "2026-13-01", "2026-10-02T24:00:00Z", "2026-10-02T05:16:19", "2026-10-02T05:16:19+02:00", "26-10-02", "2026-1-02", "today", ""]) {
50+ assert.equal(datasetTime(bad), null, bad);
51+ }
52+});
+251−0
1+/**
2+ * Datasets: the safe query layer behind dashboards (Artifacts mode,
3+ * docs/ARTIFACTS_MODE.md, section 3.4). Mirrors
4+ * `crates/contracts/src/datasets.rs`; a Rust test keeps the catalog the
5+ * same and runs both validators over `datasets.fixtures.json`.
6+ *
7+ * Not SQL. A query names a dataset from a declared catalog, one measure,
8+ * at most one dimension to group by, a time interval, filters on declared
9+ * fields and a range. The service that owns the data (`service` in the
10+ * catalog) runs it for the viewer, over only what the viewer can read, with
11+ * a fixed query per measure and dimension, and caps the rows.
12+ *
13+ * Values never live in a folio: not in its Yjs document, versions, text,
14+ * preview, index or templates. Only the query does.
15+ *
16+ * Wire shapes are snake_case.
17+ */
18+
19+export type DatasetId = "issues" | "pull_requests" | "workflow_runs" | "deployments" | "spend" | "agent_sessions";
20+
21+export const DATASET_IDS: readonly DatasetId[] = ["issues", "pull_requests", "workflow_runs", "deployments", "spend", "agent_sessions"];
22+
23+/** How rows are summed up. `count` takes no field; `rate` takes a rate field; the rest a measure field. */
24+export type DatasetMeasureOp = "count" | "sum" | "avg" | "p50" | "p95" | "rate";
25+export const DATASET_MEASURE_OPS: readonly DatasetMeasureOp[] = ["count", "sum", "avg", "p50", "p95", "rate"];
26+
27+export type DatasetInterval = "day" | "week" | "month";
28+export const DATASET_INTERVALS: readonly DatasetInterval[] = ["day", "week", "month"];
29+
30+export type DatasetFilterOp = "eq" | "neq" | "in" | "gte" | "lte";
31+export const DATASET_FILTER_OPS: readonly DatasetFilterOp[] = ["eq", "neq", "in", "gte", "lte"];
32+
33+export type DatasetRangePreset = "7d" | "30d" | "90d";
34+export const DATASET_RANGE_PRESETS: readonly DatasetRangePreset[] = ["7d", "30d", "90d"];
35+
36+/**
37+ * A time range: a preset counted back from now, or between two times.
38+ * `from` and `to` are dates (`2026-10-01`) or RFC 3339 UTC times
39+ * (`2026-10-01T09:00:00Z`); `to` is exclusive.
40+ */
41+export type DatasetRange = DatasetRangePreset | { from: string; to: string };
42+
43+export type DatasetFilter = {
44+ field: string;
45+ op: DatasetFilterOp;
46+ /** Text for `eq`/`neq` on a dimension, a list for `in`, a number on a measure. */
47+ value: string | number | string[];
48+};
49+
50+export type DatasetQuery = {
51+ dataset: DatasetId;
52+ measure: { op: DatasetMeasureOp; field?: string | null };
53+ /** A declared dimension only. */
54+ group_by?: string | null;
55+ /** A time series, over the dataset's time field. */
56+ interval?: DatasetInterval | null;
57+ /** Which of the dataset's time fields the range and interval use; its first when left out. */
58+ time?: string | null;
59+ filters?: DatasetFilter[] | null;
60+ /** The tile's own range; else the dashboard's. */
61+ range?: DatasetRange | null;
62+ /** At most `DATASET_MAX_ROWS`. */
63+ limit?: number | null;
64+};
65+
66+export type DatasetColumnType = "string" | "number" | "time" | "money";
67+
68+export type DatasetResult = {
69+ columns: { name: string; type: DatasetColumnType }[];
70+ rows: (string | number | null)[][];
71+ /** More rows matched than were returned. */
72+ truncated: boolean;
73+ /** The viewer cannot read everything the query covers: shown as "Based on what you can see". */
74+ partial: boolean;
75+ /** When the numbers were computed, RFC 3339. */
76+ as_of: string;
77+};
78+
79+/** The services that own datasets, each answering `query_dataset` for its own. */
80+export type DatasetService = "work" | "actions" | "deployments" | "billing" | "agents";
81+
82+/** What a viewer needs to query a dataset: membership, or the workspace's billing role. */
83+export type DatasetNeeds = "member" | "billing";
84+
85+export type DatasetSpec = {
86+ label: string;
87+ service: DatasetService;
88+ needs: DatasetNeeds;
89+ /** Time fields, the default first. */
90+ times: string[];
91+ /** Fields to group and filter by (text). */
92+ dimensions: string[];
93+ /** Number fields for sum, avg, p50, p95, and number filters. */
94+ measures: string[];
95+ /** Yes-or-no fields `rate` gives the share of. */
96+ rates: string[];
97+};
98+
99+/** The catalog. One dataset per line: the Rust mirror test reads it that way. */
100+export const DATASETS: Record<DatasetId, DatasetSpec> = {
101+ issues: { label: "Issues", service: "work", needs: "member", times: ["created_at", "closed_at"], dimensions: ["repo", "label", "state", "author_kind", "assignee_kind", "milestone"], measures: ["time_to_close_hours", "comments"], rates: ["closed"] },
102+ pull_requests: { label: "Pull requests", service: "work", needs: "member", times: ["created_at", "merged_at", "closed_at"], dimensions: ["repo", "label", "state", "author_kind", "base_branch"], measures: ["cycle_time_hours", "time_to_first_review_hours", "review_count", "additions", "deletions", "changed_files"], rates: ["merged"] },
103+ workflow_runs: { label: "Workflow runs", service: "actions", needs: "member", times: ["started_at", "completed_at"], dimensions: ["repo", "workflow", "branch", "event", "conclusion", "runner_kind"], measures: ["duration_seconds", "queue_seconds"], rates: ["succeeded"] },
104+ deployments: { label: "Deployments", service: "deployments", needs: "member", times: ["created_at"], dimensions: ["repo", "project", "environment", "state"], measures: ["duration_seconds", "time_to_restore_hours"], rates: ["failed"] },
105+ spend: { label: "Spend", service: "billing", needs: "billing", times: ["day"], dimensions: ["product", "project", "person", "model"], measures: ["amount_micros"], rates: [] },
106+ agent_sessions: { label: "Agent sessions", service: "agents", needs: "member", times: ["started_at"], dimensions: ["agent", "repo", "outcome", "model", "trigger"], measures: ["duration_seconds", "cost_micros", "tokens"], rates: ["succeeded"] },
107+};
108+
109+/** The most rows a query returns. */
110+export const DATASET_MAX_ROWS = 100;
111+/** The most filters on one query. */
112+export const DATASET_MAX_FILTERS = 10;
113+/** The most values in an `in` filter. */
114+export const DATASET_MAX_IN_VALUES = 50;
115+/** The longest `{ from, to }` range, in days. */
116+export const DATASET_MAX_RANGE_DAYS = 366;
117+
118+const DAY_MS = 86_400_000;
119+const TIME = /^(\d{4})-(\d{2})-(\d{2})(?:T(\d{2}):(\d{2}):(\d{2})(?:\.(\d{1,3}))?Z)?$/;
120+
121+/** A range end as milliseconds since the epoch, or null when it is not a real date or RFC 3339 UTC time. */
122+export function datasetTime(text: string): number | null {
123+ const match = TIME.exec(text);
124+ if (!match) return null;
125+ const [year, month, day] = [Number(match[1]), Number(match[2]), Number(match[3])];
126+ const [hour, minute, second] = [Number(match[4] ?? 0), Number(match[5] ?? 0), Number(match[6] ?? 0)];
127+ const millis = Number((match[7] ?? "").padEnd(3, "0") || 0);
128+ if (month < 1 || month > 12 || day < 1 || day > daysInMonth(year, month)) return null;
129+ if (hour > 23 || minute > 59 || second > 59) return null;
130+ return Date.UTC(year, month - 1, day, hour, minute, second, millis);
131+}
132+
133+function daysInMonth(year: number, month: number): number {
134+ if (month === 2) return (year % 4 === 0 && year % 100 !== 0) || year % 400 === 0 ? 29 : 28;
135+ return [4, 6, 9, 11].includes(month) ? 30 : 31;
136+}
137+
138+/**
139+ * What is wrong with a query, or null. The same rules, and the same
140+ * words, as `DatasetQuery::validate` in Rust. It checks the query against
141+ * the catalog only; who may run it is the owning service's to decide.
142+ */
143+export function datasetQueryError(query: DatasetQuery): string | null {
144+ const spec = DATASETS[query.dataset];
145+ if (!spec) return `There is no dataset called ${query.dataset}.`;
146+ const name = query.dataset;
147+ const { op } = query.measure;
148+ const field = query.measure.field ?? null;
149+ if (op === "count") {
150+ if (field !== null) return "count takes no field.";
151+ } else if (op === "rate") {
152+ if (field === null) return "rate needs a field.";
153+ if (!spec.rates.includes(field)) return `${name} has no yes-or-no field ${field} to take the rate of.`;
154+ } else {
155+ if (field === null) return `${op} needs a field.`;
156+ if (!spec.measures.includes(field)) return `${name} has no number field ${field}.`;
157+ }
158+ const groupBy = query.group_by ?? null;
159+ if (groupBy !== null && !spec.dimensions.includes(groupBy)) return `${name} can't be grouped by ${groupBy}.`;
160+ const time = query.time ?? null;
161+ if (time !== null && !spec.times.includes(time)) return `${name} has no time field ${time}.`;
162+ const filters = query.filters ?? [];
163+ if (filters.length > DATASET_MAX_FILTERS) return `A query takes at most ${DATASET_MAX_FILTERS} filters.`;
164+ for (const filter of filters) {
165+ if (spec.dimensions.includes(filter.field)) {
166+ if (filter.op === "in") {
167+ if (!Array.isArray(filter.value) || filter.value.length === 0 || filter.value.length > DATASET_MAX_IN_VALUES) {
168+ return `in on ${filter.field} takes a list of 1 to ${DATASET_MAX_IN_VALUES} values.`;
169+ }
170+ } else if (filter.op === "eq" || filter.op === "neq") {
171+ if (typeof filter.value !== "string") return `${filter.op} on ${filter.field} takes text.`;
172+ } else {
173+ return `${filter.field} is text: filter it with eq, neq or in.`;
174+ }
175+ } else if (spec.measures.includes(filter.field)) {
176+ if (filter.op === "in") return `${filter.field} is a number: filter it with eq, neq, gte or lte.`;
177+ if (typeof filter.value !== "number" || !Number.isFinite(filter.value)) return `${filter.op} on ${filter.field} takes a number.`;
178+ } else {
179+ return `${name} can't be filtered by ${filter.field}.`;
180+ }
181+ }
182+ const range = query.range ?? null;
183+ if (range !== null && typeof range === "object") {
184+ const from = datasetTime(range.from);
185+ const to = datasetTime(range.to);
186+ if (from === null || to === null) return "A range's from and to are dates or RFC 3339 UTC times.";
187+ if (from >= to) return "A range's from comes before its to.";
188+ if (to - from > DATASET_MAX_RANGE_DAYS * DAY_MS) return `A range is at most ${DATASET_MAX_RANGE_DAYS} days.`;
189+ }
190+ const limit = query.limit ?? null;
191+ if (limit !== null && (!Number.isInteger(limit) || limit < 1 || limit > DATASET_MAX_ROWS)) return `limit is between 1 and ${DATASET_MAX_ROWS}.`;
192+ return null;
193+}
194+
195+/** A query from untrusted JSON: well-formed and valid, or why not. Unknown keys are dropped. */
196+export function parseDatasetQuery(input: unknown): { query: DatasetQuery } | { error: string } {
197+ if (!isObject(input)) return { error: "A query is an object." };
198+ if (typeof input.dataset !== "string" || !(DATASET_IDS as readonly string[]).includes(input.dataset)) {
199+ return { error: `There is no dataset called ${String(input.dataset)}.` };
200+ }
201+ const measure = input.measure;
202+ if (!isObject(measure) || typeof measure.op !== "string" || !(DATASET_MEASURE_OPS as readonly string[]).includes(measure.op)) {
203+ return { error: "measure.op is count, sum, avg, p50, p95 or rate." };
204+ }
205+ if (!optional(measure.field, "string")) return { error: "measure.field is text." };
206+ if (!optional(input.group_by, "string")) return { error: "group_by is text." };
207+ if (!optional(input.time, "string")) return { error: "time is text." };
208+ if (input.interval != null && !(DATASET_INTERVALS as readonly unknown[]).includes(input.interval)) return { error: "interval is day, week or month." };
209+ if (input.limit != null && typeof input.limit !== "number") return { error: "limit is a number." };
210+ let filters: DatasetFilter[] | null = null;
211+ if (input.filters != null) {
212+ if (!Array.isArray(input.filters)) return { error: "filters is a list." };
213+ filters = [];
214+ for (const filter of input.filters) {
215+ if (!isObject(filter) || typeof filter.field !== "string" || !(DATASET_FILTER_OPS as readonly unknown[]).includes(filter.op)) {
216+ return { error: "A filter has a field and an op: eq, neq, in, gte or lte." };
217+ }
218+ const value = filter.value;
219+ const ok =
220+ typeof value === "string" || (typeof value === "number" && Number.isFinite(value)) || (Array.isArray(value) && value.every((item) => typeof item === "string"));
221+ if (!ok) return { error: "A filter's value is text, a number or a list of text." };
222+ filters.push({ field: filter.field, op: filter.op as DatasetFilterOp, value: value as DatasetFilter["value"] });
223+ }
224+ }
225+ let range: DatasetRange | null = null;
226+ if (input.range != null) {
227+ if (typeof input.range === "string" && (DATASET_RANGE_PRESETS as readonly string[]).includes(input.range)) range = input.range as DatasetRangePreset;
228+ else if (isObject(input.range) && typeof input.range.from === "string" && typeof input.range.to === "string") range = { from: input.range.from, to: input.range.to };
229+ else return { error: "range is 7d, 30d, 90d or { from, to }." };
230+ }
231+ const query: DatasetQuery = {
232+ dataset: input.dataset as DatasetId,
233+ measure: { op: measure.op as DatasetMeasureOp, field: (measure.field as string | null | undefined) ?? null },
234+ group_by: (input.group_by as string | null | undefined) ?? null,
235+ interval: (input.interval as DatasetInterval | null | undefined) ?? null,
236+ time: (input.time as string | null | undefined) ?? null,
237+ filters,
238+ range,
239+ limit: (input.limit as number | null | undefined) ?? null,
240+ };
241+ const error = datasetQueryError(query);
242+ return error ? { error } : { query };
243+}
244+
245+function isObject(value: unknown): value is Record<string, unknown> {
246+ return typeof value === "object" && value !== null && !Array.isArray(value);
247+}
248+
249+function optional(value: unknown, type: "string"): boolean {
250+ return value === undefined || value === null || typeof value === type;
251+}
+30−0
488488 * (`stalePagesForAgent` in docs.ts).
489489 */
490490 "doc.page.stale": DocPageEventData & { repoId: string; repo: string; commit: string; pull: number | null; paths: string[]; owners: string[] };
491+ /**
492+ * Artifacts (folios, services/docs): a folio was made. Like `doc.page.*`,
493+ * published with no `repoId`, never offered to webhooks, and readers check
494+ * access with the docs service before showing anything of it. Not
495+ * published yet: Phase 1 of docs/ARTIFACTS_MODE.md starts them.
496+ */
497+ "folio.created": FolioEventData;
498+ /** A folio's content changed: a version (`versionKind`) with everyone whose changes are in it. */
499+ "folio.updated": FolioEventData & { versionId: string; versionKind: "edit" | "agent" | "suggestion" | "proposal" | "restore"; authors: string[] };
500+ /** A folio went to the trash (with everything under it; one event for the folio asked about). */
501+ "folio.trashed": FolioEventData;
502+ "folio.restored": FolioEventData;
503+ /** Someone was given access: who (member keys) and the role. Never content. */
504+ "folio.shared": FolioEventData & { principals: string[]; role: "view" | "comment" | "edit" | "manage" };
505+ /** Code a folio cites changed. The repository is in `data` as `owner/name` only. */
506+ "folio.stale": FolioEventData & { repo: string; commit: string; pull: number | null; paths: string[]; owners: string[] };
507+};
508+
509+/**
510+ * What every `folio.*` event carries. `title` is null unless every member
511+ * of the workspace can read the folio, so a private folio's name never
512+ * travels.
513+ */
514+export type FolioEventData = {
515+ workspace: string;
516+ workspaceId: string;
517+ folioId: string;
518+ kind: "doc" | "slides" | "design" | "dashboard";
519+ spaceId: string | null;
520+ title: string | null;
491521 };
492522
493523 /** What every `doc.page.*` event carries. */
+155−0
1+/**
2+ * Dashboards (`kind: "dashboard"`, beta): a dashboard's definition and the
3+ * changes agents make to it. Artifacts mode, docs/ARTIFACTS_MODE.md
4+ * section 3.4. Wire shapes are snake_case.
5+ *
6+ * In the Yjs document: `Y.Map("dashboard")` holds `DashboardSettings`;
7+ * `Y.Array("tiles")` holds one `Y.Map` per tile (`DashboardTile`). Only the
8+ * definition is stored. Numbers are computed per viewer when the page asks
9+ * (`query_tile`), and never saved in the folio, its versions, text, preview,
10+ * index or templates.
11+ *
12+ * A tile's query is a `DatasetQuery` (datasets.ts). Ops are checked here
13+ * with the query validator passed in (`datasetQueryError`), so this file
14+ * stays free of imports Node can't run as is in tests.
15+ */
16+import type { DatasetQuery, DatasetRange } from "./datasets";
17+
18+export type DashboardTileType = "stat" | "line" | "area" | "bar" | "stacked_bar" | "table" | "list" | "markdown";
19+export const DASHBOARD_TILE_TYPES: readonly DashboardTileType[] = ["stat", "line", "area", "bar", "stacked_bar", "table", "list", "markdown"];
20+
21+export const DASHBOARD_TILE_TYPE_LABELS: Record<DashboardTileType, string> = {
22+ stat: "Number",
23+ line: "Line chart",
24+ area: "Area chart",
25+ bar: "Bar chart",
26+ stacked_bar: "Stacked bars",
27+ table: "Table",
28+ list: "List",
29+ markdown: "Text",
30+};
31+
32+/** Where a tile sits on the 12-column grid: column, row, width and height in cells. */
33+export type DashboardGrid = { x: number; y: number; w: number; h: number };
34+export const DASHBOARD_COLUMNS = 12;
35+export const DASHBOARD_MAX_ROWS = 200;
36+export const DASHBOARD_MAX_TILES = 60;
37+
38+export type DashboardRefresh = "manual" | "5m" | "1h";
39+export const DASHBOARD_REFRESHES: readonly DashboardRefresh[] = ["manual", "5m", "1h"];
40+
41+/** Filters every tile starts from; a tile's own `range` wins over the dashboard's. */
42+export type DashboardFilters = { range: DatasetRange; project?: string | null; repos?: string[] | null; team?: string | null };
43+
44+export type DashboardSettings = { filters: DashboardFilters; refresh: DashboardRefresh };
45+
46+/** How a tile draws its rows. Every field is optional and defaults by tile type. */
47+export type DashboardViz = {
48+ /** Show a number as money, a percentage, a duration or plain. */
49+ format?: "number" | "money" | "percent" | "duration" | null;
50+ /** Lines and bars: show the legend. */
51+ legend?: boolean;
52+ /** stat: draw the sparkline when the query has an interval. */
53+ sparkline?: boolean;
54+ /** Colours by series name; else the theme's order. */
55+ colors?: Record<string, string> | null;
56+};
57+
58+export type DashboardTile = {
59+ id: string;
60+ type: DashboardTileType;
61+ title: string;
62+ description: string;
63+ /** Null on a `markdown` tile, which has `markdown` instead. */
64+ query: DatasetQuery | null;
65+ markdown?: string | null;
66+ viz: DashboardViz;
67+ grid: DashboardGrid;
68+};
69+
70+/** The Yjs names a dashboard uses. */
71+export const DASHBOARD_MAP = "dashboard";
72+export const DASHBOARD_TILES = "tiles";
73+
74+/** What a card shows of a dashboard: tile boxes and titles, never numbers. */
75+export type DashboardPreview = { kind: "dashboard"; tiles: { type: DashboardTileType; title: string; grid: DashboardGrid }[] };
76+
77+/** A change an agent makes to a dashboard. */
78+export type DashboardOp =
79+ /** Add a tile, or replace the one with its id. */
80+ | { op: "upsert_tile"; tile: DashboardTile }
81+ | { op: "delete_tiles"; ids: string[] }
82+ | { op: "set_filters"; filters: DashboardFilters }
83+ | { op: "set_layout"; tiles: { id: string; grid: DashboardGrid }[] };
84+
85+export const DASHBOARD_OPS: readonly DashboardOp["op"][] = ["upsert_tile", "delete_tiles", "set_filters", "set_layout"];
86+
87+/** What is wrong with a tile's place on the grid, or null. */
88+export function dashboardGridError(grid: unknown): string | null {
89+ if (!isObject(grid)) return "grid is { x, y, w, h }.";
90+ const { x, y, w, h } = grid;
91+ if (![x, y, w, h].every((n) => typeof n === "number" && Number.isInteger(n))) return "grid's x, y, w and h are whole numbers.";
92+ const [gx, gy, gw, gh] = [x, y, w, h] as number[];
93+ if (gx < 0 || gy < 0 || gw < 1 || gh < 1) return "grid starts at 0, 0 and is at least 1 by 1.";
94+ if (gx + gw > DASHBOARD_COLUMNS) return `A tile fits in ${DASHBOARD_COLUMNS} columns.`;
95+ if (gy + gh > DASHBOARD_MAX_ROWS) return `A dashboard is at most ${DASHBOARD_MAX_ROWS} rows tall.`;
96+ return null;
97+}
98+
99+/**
100+ * What is wrong with a dashboard op, or null. `queryError` checks a tile's
101+ * query: pass `datasetQueryError` from datasets.ts. Whether ids exist is the
102+ * room's to say.
103+ */
104+export function dashboardOpError(op: unknown, queryError: (query: DatasetQuery) => string | null): string | null {
105+ if (!isObject(op) || typeof op.op !== "string") return "A dashboard change has an op.";
106+ switch (op.op) {
107+ case "upsert_tile": {
108+ const tile = op.tile;
109+ if (!isObject(tile)) return "upsert_tile needs a tile.";
110+ if (typeof tile.id !== "string" || tile.id.length === 0) return "A tile has an id.";
111+ if (typeof tile.type !== "string" || !(DASHBOARD_TILE_TYPES as readonly string[]).includes(tile.type)) return `There is no tile type called ${String(tile.type)}.`;
112+ if (typeof tile.title !== "string" || tile.title.length > 200) return "A tile's title is text of at most 200 characters.";
113+ if (tile.description !== undefined && typeof tile.description !== "string") return "A tile's description is text.";
114+ if (tile.type === "markdown") {
115+ if (tile.query != null) return "A text tile has no query.";
116+ if (typeof tile.markdown !== "string") return "A text tile has markdown.";
117+ } else {
118+ if (!isObject(tile.query)) return "A chart tile has a query.";
119+ const error = queryError(tile.query as DatasetQuery);
120+ if (error) return error;
121+ }
122+ return dashboardGridError(tile.grid);
123+ }
124+ case "delete_tiles":
125+ return Array.isArray(op.ids) && op.ids.length > 0 && op.ids.every((id) => typeof id === "string" && id.length > 0) ? null : "delete_tiles needs ids.";
126+ case "set_filters": {
127+ const filters = op.filters;
128+ if (!isObject(filters)) return "set_filters needs filters.";
129+ const range = filters.range;
130+ const preset = typeof range === "string" && ["7d", "30d", "90d"].includes(range);
131+ if (!preset && !(isObject(range) && typeof range.from === "string" && typeof range.to === "string")) return "range is 7d, 30d, 90d or { from, to }.";
132+ if (isObject(range)) {
133+ const error = queryError({ dataset: "issues", measure: { op: "count" }, range: { from: range.from as string, to: range.to as string } });
134+ if (error) return error;
135+ }
136+ if (filters.repos != null && !(Array.isArray(filters.repos) && filters.repos.every((repo) => typeof repo === "string"))) return "repos is a list of owner/name.";
137+ return null;
138+ }
139+ case "set_layout": {
140+ if (!Array.isArray(op.tiles) || op.tiles.length === 0) return "set_layout needs tiles.";
141+ for (const tile of op.tiles) {
142+ if (!isObject(tile) || typeof tile.id !== "string") return "set_layout's tiles are { id, grid }.";
143+ const error = dashboardGridError(tile.grid);
144+ if (error) return error;
145+ }
146+ return null;
147+ }
148+ default:
149+ return `There is no dashboard op called ${op.op}.`;
150+ }
151+}
152+
153+function isObject(value: unknown): value is Record<string, unknown> {
154+ return typeof value === "object" && value !== null && !Array.isArray(value);
155+}
+186−0
1+/**
2+ * Design (`kind: "design"`): a frame-based layout canvas and the changes
3+ * agents make to it. Artifacts mode, docs/ARTIFACTS_MODE.md section 3.3.
4+ * Wire shapes are snake_case.
5+ *
6+ * In the Yjs document: `Y.Map("canvas")` holds `DesignCanvas`; `Y.Map("nodes")`
7+ * maps each node's id to a `Y.Map` of `DesignNode` (a `text` node's content
8+ * and an `html` node's source are `Y.Text`). Frames lay their children out
9+ * themselves (`row`, `column`), so agents describe structure, not
10+ * coordinates.
11+ */
12+
13+export type DesignNodeType = "frame" | "rect" | "ellipse" | "line" | "arrow" | "text" | "image" | "html" | "component" | "instance";
14+export const DESIGN_NODE_TYPES: readonly DesignNodeType[] = ["frame", "rect", "ellipse", "line", "arrow", "text", "image", "html", "component", "instance"];
15+
16+export type DesignFrameLayout = "free" | "row" | "column";
17+export type DesignAlign = "start" | "center" | "end" | "stretch";
18+
19+export type DesignCanvas = { background: string | null; grid: number | null };
20+
21+/** A node as stored: every field but `id` and `type` may be absent. */
22+export type DesignNode = {
23+ id: string;
24+ type: DesignNodeType;
25+ /** The frame or component it sits in; null at the top. */
26+ parent: string | null;
27+ /** Its order among its siblings: a fractional index. */
28+ index: string;
29+ x: number;
30+ y: number;
31+ w: number;
32+ h: number;
33+ rotation?: number;
34+ fill?: string | null;
35+ stroke?: string | null;
36+ stroke_width?: number;
37+ radius?: number;
38+ opacity?: number;
39+ name?: string;
40+ locked?: boolean;
41+ hidden?: boolean;
42+ /** Frames and components. */
43+ layout?: DesignFrameLayout;
44+ gap?: number;
45+ padding?: number;
46+ align?: DesignAlign;
47+ /** `line` and `arrow`: the nodes their ends are bound to. */
48+ from_node?: string | null;
49+ to_node?: string | null;
50+ /** `image`: a file of the folio, never bytes. */
51+ file?: string;
52+ /** `instance`: the component it is an instance of, and what it changes. */
53+ component_id?: string;
54+ overrides?: Record<string, unknown>;
55+};
56+
57+/**
58+ * A node as an agent writes it: nested, so `children` imply `parent` and
59+ * `index`. Sizes and positions are optional inside `row` and `column`
60+ * frames. `text` is a text node's content; `html` an html node's source.
61+ */
62+export type DesignNodeSpec = Partial<Omit<DesignNode, "parent" | "index" | "type">> & {
63+ type: DesignNodeType;
64+ text?: string;
65+ html?: string;
66+ children?: DesignNodeSpec[];
67+};
68+
69+/** Limits the room enforces when it saves. */
70+export const DESIGN_MAX_NODES = 5_000;
71+export const DESIGN_MAX_HTML_BYTES = 200 * 1024;
72+export const DESIGN_MAX_FILE_BYTES = 25 * 1024 * 1024;
73+/** The deepest a node spec nests. */
74+export const DESIGN_MAX_DEPTH = 32;
75+
76+/** The Yjs names the canvas uses. */
77+export const DESIGN_CANVAS_MAP = "canvas";
78+export const DESIGN_NODES_MAP = "nodes";
79+
80+/** What a card shows of a design: its first frame's top nodes, simplified, at most `DESIGN_PREVIEW_NODES`. */
81+export type DesignPreview = {
82+ kind: "design";
83+ frame: { name: string; w: number; h: number } | null;
84+ nodes: { type: DesignNodeType; x: number; y: number; w: number; h: number; fill: string | null }[];
85+};
86+export const DESIGN_PREVIEW_NODES = 50;
87+
88+/** A change an agent makes to a design. */
89+export type DesignOp =
90+ /** Add nodes, or change the ones whose `id` exists. Nested specs are placed in their parents. */
91+ | { op: "upsert_nodes"; parent?: string | null; nodes: DesignNodeSpec[] }
92+ | { op: "delete_nodes"; ids: string[] }
93+ /** Replace a frame's children (and its own fields) with a spec. */
94+ | { op: "replace_frame"; frame_id: string; spec: DesignNodeSpec }
95+ | { op: "set_html"; node_id: string; html: string }
96+ | { op: "move"; ids: string[]; dx: number; dy: number };
97+
98+export const DESIGN_OPS: readonly DesignOp["op"][] = ["upsert_nodes", "delete_nodes", "replace_frame", "set_html", "move"];
99+
100+/** How many nodes a spec list makes, its children included, and how deep it goes. */
101+export function countDesignSpecs(specs: readonly DesignNodeSpec[], depth = 1): { nodes: number; depth: number } {
102+ let nodes = 0;
103+ let deepest = specs.length > 0 ? depth : depth - 1;
104+ for (const spec of specs) {
105+ nodes += 1;
106+ if (spec.children && spec.children.length > 0) {
107+ const inner = countDesignSpecs(spec.children, depth + 1);
108+ nodes += inner.nodes;
109+ deepest = Math.max(deepest, inner.depth);
110+ }
111+ }
112+ return { nodes, depth: deepest };
113+}
114+
115+/** What is wrong with a node spec, or null. */
116+export function designSpecError(spec: unknown, depth = 1): string | null {
117+ if (depth > DESIGN_MAX_DEPTH) return `Node specs nest at most ${DESIGN_MAX_DEPTH} deep.`;
118+ if (!isObject(spec)) return "A node spec is an object.";
119+ if (typeof spec.type !== "string" || !(DESIGN_NODE_TYPES as readonly string[]).includes(spec.type)) return `There is no node type called ${String(spec.type)}.`;
120+ for (const key of ["x", "y", "w", "h", "rotation", "stroke_width", "radius", "opacity", "gap", "padding"]) {
121+ if (spec[key] !== undefined && (typeof spec[key] !== "number" || !Number.isFinite(spec[key]))) return `${key} is a number.`;
122+ }
123+ if (spec.opacity !== undefined && ((spec.opacity as number) < 0 || (spec.opacity as number) > 1)) return "opacity is between 0 and 1.";
124+ if (spec.layout !== undefined && !["free", "row", "column"].includes(spec.layout as string)) return "layout is free, row or column.";
125+ if (typeof spec.html === "string" && utf8Bytes(spec.html) > DESIGN_MAX_HTML_BYTES) return "An html node holds at most 200 KB.";
126+ if (spec.html !== undefined && spec.type !== "html") return "Only an html node has html.";
127+ if (spec.type === "instance" && typeof spec.component_id !== "string") return "An instance names its component_id.";
128+ if (spec.children !== undefined) {
129+ if (!Array.isArray(spec.children)) return "children is a list.";
130+ if (spec.children.length > 0 && spec.type !== "frame" && spec.type !== "component") return "Only frames and components have children.";
131+ for (const child of spec.children) {
132+ const error = designSpecError(child, depth + 1);
133+ if (error) return error;
134+ }
135+ }
136+ return null;
137+}
138+
139+/** What is wrong with a design op, or null. Checks its shape; whether its ids exist is the room's to say. */
140+export function designOpError(op: unknown): string | null {
141+ if (!isObject(op) || typeof op.op !== "string") return "A design change has an op.";
142+ const ids = (key: string) =>
143+ Array.isArray(op[key]) && (op[key] as unknown[]).length > 0 && (op[key] as unknown[]).every((item) => typeof item === "string" && item.length > 0) ? null : `${op.op} needs ${key}.`;
144+ const id = (key: string) => (typeof op[key] === "string" && (op[key] as string).length > 0 ? null : `${op.op} needs ${key}.`);
145+ switch (op.op) {
146+ case "upsert_nodes": {
147+ if (!Array.isArray(op.nodes) || op.nodes.length === 0) return "upsert_nodes needs nodes.";
148+ for (const spec of op.nodes) {
149+ const error = designSpecError(spec);
150+ if (error) return error;
151+ }
152+ if (countDesignSpecs(op.nodes as DesignNodeSpec[]).nodes > DESIGN_MAX_NODES) return `A design holds at most ${DESIGN_MAX_NODES} nodes.`;
153+ return null;
154+ }
155+ case "delete_nodes":
156+ return ids("ids");
157+ case "replace_frame": {
158+ const error = id("frame_id") ?? designSpecError(op.spec);
159+ if (error) return error;
160+ return countDesignSpecs([op.spec as DesignNodeSpec]).nodes > DESIGN_MAX_NODES ? `A design holds at most ${DESIGN_MAX_NODES} nodes.` : null;
161+ }
162+ case "set_html":
163+ if (id("node_id")) return id("node_id");
164+ if (typeof op.html !== "string") return "set_html needs html.";
165+ return utf8Bytes(op.html) > DESIGN_MAX_HTML_BYTES ? "An html node holds at most 200 KB." : null;
166+ case "move":
167+ if (ids("ids")) return ids("ids");
168+ return typeof op.dx === "number" && typeof op.dy === "number" && Number.isFinite(op.dx) && Number.isFinite(op.dy) ? null : "move needs dx and dy.";
169+ default:
170+ return `There is no design op called ${op.op}.`;
171+ }
172+}
173+
174+/** The length of `text` in UTF-8, in bytes. */
175+function utf8Bytes(text: string): number {
176+ let bytes = 0;
177+ for (const char of text) {
178+ const code = char.codePointAt(0)!;
179+ bytes += code < 0x80 ? 1 : code < 0x800 ? 2 : code < 0x10000 ? 3 : 4;
180+ }
181+ return bytes;
182+}
183+
184+function isObject(value: unknown): value is Record<string, unknown> {
185+ return typeof value === "object" && value !== null && !Array.isArray(value);
186+}
+124−0
1+/**
2+ * Slides (`kind: "slides"`): a deck's model and the changes agents make to
3+ * it. Artifacts mode, docs/ARTIFACTS_MODE.md section 3.2. Wire shapes are
4+ * snake_case.
5+ *
6+ * In the Yjs document: `Y.Map("deck")` holds `SlidesDeck`; `Y.Array("slides")`
7+ * holds one `Y.Map` per slide (`SlideMeta`); each slide region is a root
8+ * `XmlFragment` named by `slideFragment(id, region)`, a BlockNote editor of
9+ * its own.
10+ *
11+ * Agents, templates, export and the text rendition use the Markdown form:
12+ * slides separated by `---` lines, each starting `<!-- slide: <layout> -->`,
13+ * `::: left` / `::: right` for columns, and `Note:` for speaker notes.
14+ */
15+
16+export type SlideLayout = "title" | "title-body" | "two-column" | "section" | "image" | "quote" | "big-number" | "blank";
17+export const SLIDE_LAYOUTS: readonly SlideLayout[] = ["title", "title-body", "two-column", "section", "image", "quote", "big-number", "blank"];
18+
19+export const SLIDE_LAYOUT_LABELS: Record<SlideLayout, string> = {
20+ title: "Title",
21+ "title-body": "Title and body",
22+ "two-column": "Two columns",
23+ section: "Section",
24+ image: "Image",
25+ quote: "Quote",
26+ "big-number": "Big number",
27+ blank: "Blank",
28+};
29+
30+export type SlideRegion = "title" | "body" | "left" | "right" | "notes";
31+
32+/** The regions each layout has; every slide also has `notes`. */
33+export const SLIDE_LAYOUT_REGIONS: Record<SlideLayout, readonly SlideRegion[]> = {
34+ title: ["title", "body", "notes"],
35+ "title-body": ["title", "body", "notes"],
36+ "two-column": ["title", "left", "right", "notes"],
37+ section: ["title", "notes"],
38+ image: ["title", "body", "notes"],
39+ quote: ["body", "notes"],
40+ "big-number": ["title", "body", "notes"],
41+ blank: ["body", "notes"],
42+};
43+
44+export type SlidesAspect = "16:9" | "4:3";
45+export const SLIDES_ASPECTS: readonly SlidesAspect[] = ["16:9", "4:3"];
46+
47+/** The deck's settings: `Y.Map("deck")`. `theme` names one of the editor's themes. */
48+export type SlidesDeck = { theme: string; aspect: SlidesAspect; accent: string | null };
49+
50+/** One slide's own fields: an entry of `Y.Array("slides")`. */
51+export type SlideMeta = { id: string; layout: SlideLayout; background: string | null; hidden: boolean; transition: "none" };
52+
53+/** The Yjs names the deck uses. */
54+export const SLIDES_DECK_MAP = "deck";
55+export const SLIDES_ARRAY = "slides";
56+
57+/** The root fragment that holds one region of one slide. */
58+export function slideFragment(slideId: string, region: SlideRegion): string {
59+ return `slide:${slideId}:${region}`;
60+}
61+
62+/** The most slides in a deck. */
63+export const SLIDES_MAX = 300;
64+
65+/** What a card shows of a deck: its first slide, never more. */
66+export type SlidesPreview = { kind: "slides"; aspect: SlidesAspect; count: number; layout: SlideLayout; title: string };
67+
68+/** A change an agent makes to a deck. Markdown is in the deck's Markdown form. */
69+export type SlidesOp =
70+ /** Replace every slide. */
71+ | { op: "replace_deck"; markdown: string }
72+ /** Add slides after one (null: at the start). */
73+ | { op: "insert_slides"; after_slide_id: string | null; markdown: string }
74+ /** Replace one slide with what the Markdown holds (one slide or more). */
75+ | { op: "replace_slide"; slide_id: string; markdown: string }
76+ | { op: "delete_slides"; slide_ids: string[] }
77+ /** Move a slide after another (null: to the start). */
78+ | { op: "move_slide"; slide_id: string; after_slide_id: string | null }
79+ | { op: "set_notes"; slide_id: string; markdown: string }
80+ | { op: "set_theme"; theme: string; accent: string | null };
81+
82+export const SLIDES_OPS: readonly SlidesOp["op"][] = ["replace_deck", "insert_slides", "replace_slide", "delete_slides", "move_slide", "set_notes", "set_theme"];
83+
84+/** The largest Markdown one op carries, in characters. */
85+export const SLIDES_MAX_MARKDOWN = 200_000;
86+
87+const THEME = /^[a-z0-9-]{1,40}$/;
88+const COLOR = /^#[0-9a-f]{6}$/i;
89+
90+/** What is wrong with a slides op, or null. Checks its shape; whether its ids exist is the room's to say. */
91+export function slidesOpError(op: unknown): string | null {
92+ if (!isObject(op) || typeof op.op !== "string") return "A slides change has an op.";
93+ const markdown = () => {
94+ if (typeof op.markdown !== "string") return `${op.op} needs markdown.`;
95+ if (op.markdown.length > SLIDES_MAX_MARKDOWN) return `${op.op}'s markdown is too long.`;
96+ return null;
97+ };
98+ const id = (key: string, nullable = false) =>
99+ (nullable && op[key] === null) || (typeof op[key] === "string" && (op[key] as string).length > 0) ? null : `${op.op} needs ${key}.`;
100+ switch (op.op) {
101+ case "replace_deck":
102+ return markdown();
103+ case "insert_slides":
104+ return id("after_slide_id", true) ?? markdown();
105+ case "replace_slide":
106+ case "set_notes":
107+ return id("slide_id") ?? markdown();
108+ case "delete_slides":
109+ return Array.isArray(op.slide_ids) && op.slide_ids.length > 0 && op.slide_ids.every((item) => typeof item === "string" && item.length > 0)
110+ ? null
111+ : "delete_slides needs slide_ids.";
112+ case "move_slide":
113+ return id("slide_id") ?? id("after_slide_id", true);
114+ case "set_theme":
115+ if (typeof op.theme !== "string" || !THEME.test(op.theme)) return "set_theme needs a theme name.";
116+ return op.accent === null || (typeof op.accent === "string" && COLOR.test(op.accent)) ? null : "accent is a colour like #8b7cf6, or null.";
117+ default:
118+ return `There is no slides op called ${op.op}.`;
119+ }
120+}
121+
122+function isObject(value: unknown): value is Record<string, unknown> {
123+ return typeof value === "object" && value !== null && !Array.isArray(value);
124+}
+57−0
1+{
2+ "about": "Cases both folio validators must agree on: folios.ts (newFolioError, folioAccessChangeError, folioAgentEditError, folioIdFrom) and crates/contracts/src/folios.rs. error null is valid; \"*\" is any error (a malformed input, which each side words its own way).",
3+ "new_folio": [
4+ { "input": { "kind": "doc" }, "error": null },
5+ { "input": { "kind": "slides", "title": "Q4 roadmap", "space_id": "spc_1", "content": { "markdown": "<!-- slide: title -->\n# Q4" } }, "error": null },
6+ { "input": { "kind": "dashboard", "content": { "spec": { "tiles": [] } }, "share_with": [{ "principal": "team:platform", "role": "view" }] }, "error": null },
7+ { "input": { "kind": "design", "template_id": "tpl_1" }, "error": null },
8+ { "input": { "kind": "doc", "content": { "spec": {} } }, "error": "A doc starts from markdown." },
9+ { "input": { "kind": "design", "content": { "markdown": "# no" } }, "error": "A design starts from a spec." },
10+ { "input": { "kind": "doc", "content": { "markdown": "# Hi" }, "template_id": "tpl_1" }, "error": "Start from a template or from content, not both." },
11+ { "input": { "kind": "doc", "share_with": [{ "principal": "everyone", "role": "view" }] }, "error": "everyone is not user:, agent: or team: and an id." },
12+ { "input": { "kind": "doc", "title": "xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx" }, "error": "A title is at most 200 characters." },
13+ { "input": { "kind": "spreadsheet" }, "error": "*" },
14+ { "input": { "kind": "doc", "share_with": [{ "principal": "user:usr_1", "role": "owner" }] }, "error": "*" }
15+ ],
16+ "access_change": [
17+ { "change": { "op": "grant", "principal": "user:usr_1", "role": "edit" }, "error": null },
18+ { "change": { "op": "grant", "principal": "agent:agt_1", "role": "comment", "notify": "Have a look" }, "error": null },
19+ { "change": { "op": "revoke", "principal": "team:design" }, "error": null },
20+ { "change": { "op": "general", "access": "workspace", "role": "view" }, "error": null },
21+ { "change": { "op": "general", "access": "none", "role": null }, "error": null },
22+ { "change": { "op": "inherit", "inherit": false }, "error": null },
23+ { "change": { "op": "agent_mode", "agent_mode": null }, "error": null },
24+ { "change": { "op": "grant", "principal": "usr_1", "role": "edit" }, "error": "usr_1 is not user:, agent: or team: and an id." },
25+ { "change": { "op": "general", "access": "link", "role": "manage" }, "error": "General access gives view, comment or edit, never full access." },
26+ { "change": { "op": "general", "access": "none", "role": "view" }, "error": "Restricted takes no role." },
27+ { "change": { "op": "general", "access": "workspace", "role": null }, "error": "Everyone in the workspace needs a role." },
28+ { "change": { "op": "publish" }, "error": "*" },
29+ { "change": { "op": "agent_mode", "agent_mode": "always" }, "error": "*" }
30+ ],
31+ "agent_edit": [
32+ { "edit": { "kind": "doc", "target": { "kind": "append" }, "markdown": "More." }, "error": null },
33+ { "edit": { "kind": "doc", "target": { "kind": "section", "heading": "Pricing" }, "markdown": "## Pricing\n…", "note": "Updated prices", "marks_current": true }, "error": null },
34+ { "edit": { "kind": "slides", "ops": [{ "op": "set_theme", "theme": "night", "accent": null }] }, "error": null },
35+ { "edit": { "kind": "dashboard", "ops": [{ "op": "delete_tiles", "ids": ["t1"] }], "suggest_only": true }, "error": null },
36+ { "edit": "replace everything", "error": "An edit is an object." },
37+ { "edit": { "kind": "sheet", "ops": [] }, "error": "There is no kind of artifact called sheet." },
38+ { "edit": { "ops": [] }, "error": "There is no kind of artifact called undefined." },
39+ { "edit": { "kind": "doc", "target": { "kind": "append" }, "markdown": "x", "note": 5 }, "error": "note is text." },
40+ { "edit": { "kind": "doc", "target": { "kind": "append" } }, "error": "A doc edit has markdown." },
41+ { "edit": { "kind": "doc", "markdown": "x", "target": { "kind": "append" }, "ops": [] }, "error": "A doc edit has a target and markdown, not ops." },
42+ { "edit": { "kind": "doc", "markdown": "x" }, "error": "A doc edit has a target." },
43+ { "edit": { "kind": "doc", "markdown": "x", "target": { "kind": "section" } }, "error": "A section target names its heading." },
44+ { "edit": { "kind": "doc", "markdown": "x", "target": { "kind": "blocks", "from_block": "a" } }, "error": "A blocks target names from_block and to_block." },
45+ { "edit": { "kind": "doc", "markdown": "x", "target": { "kind": "line" } }, "error": "A doc edit's target is append, document, section or blocks." },
46+ { "edit": { "kind": "slides", "markdown": "# x" }, "error": "A deck edit has ops, not a target and markdown." },
47+ { "edit": { "kind": "design", "ops": [] }, "error": "A design edit has a list of ops." },
48+ { "edit": { "kind": "dashboard", "ops": ["delete everything"] }, "error": "Each op is an object with an op." }
49+ ],
50+ "folio_ids": [
51+ { "segment": "q4-roadmap-fol_01jb2k7x9hfq0b3zj0f5s2m8ra", "id": "fol_01jb2k7x9hfq0b3zj0f5s2m8ra" },
52+ { "segment": "fol_01jb2k7x9hfq0b3zj0f5s2m8ra", "id": "fol_01jb2k7x9hfq0b3zj0f5s2m8ra" },
53+ { "segment": "q4-roadmap-pag_01jb2k7x9hfq0b3zj0f5s2m8ra", "id": null },
54+ { "segment": "fol_01jb2k7x9hfq0b3zj0f5s2m8rI", "id": null },
55+ { "segment": "templates", "id": null }
56+ ]
57+}
+188−0
1+import assert from "node:assert/strict";
2+import { readFileSync } from "node:fs";
3+import { test } from "node:test";
4+
5+import type { ServiceBinding } from "./clients.ts";
6+import { datasetQueryError } from "./datasets.ts";
7+import { dashboardGridError, dashboardOpError } from "./folios-dashboard.ts";
8+import { DESIGN_MAX_NODES, countDesignSpecs, designOpError } from "./folios-design.ts";
9+import { SLIDE_LAYOUTS, SLIDE_LAYOUT_REGIONS, slideFragment, slidesOpError } from "./folios-slides.ts";
10+import {
11+ FOLIO_KINDS,
12+ FOLIO_RPC_METHODS,
13+ FOLIO_ROUTE_SEGMENTS,
14+ folioAccessChangeError,
15+ folioAgentEditError,
16+ folioCanHaveChildren,
17+ folioIdFrom,
18+ folioListQueryError,
19+ folioPath,
20+ folioSlug,
21+ foliosClient,
22+ linkedFolioIds,
23+ newFolioError,
24+ type FolioAccessChange,
25+ type NewFolio,
26+} from "./folios.ts";
27+import type { User } from "./identity.ts";
28+
29+const fixtures = JSON.parse(readFileSync(new URL("./folios.fixtures.json", import.meta.url), "utf8")) as {
30+ new_folio: { input: unknown; error: string | null }[];
31+ access_change: { change: unknown; error: string | null }[];
32+ agent_edit: { edit: unknown; error: string | null }[];
33+ folio_ids: { segment: string; id: string | null }[];
34+};
35+
36+function expect(label: string, actual: string | null, error: string | null) {
37+ if (error === null) assert.equal(actual, null, label);
38+ else if (error === "*") assert.notEqual(actual, null, `${label} should be refused`);
39+ else assert.equal(actual, error, label);
40+}
41+
42+test("agrees with the Rust validators on every shared case", () => {
43+ for (const { input, error } of fixtures.new_folio) expect(JSON.stringify(input), newFolioError(input as NewFolio), error);
44+ for (const { change, error } of fixtures.access_change) expect(JSON.stringify(change), folioAccessChangeError(change as FolioAccessChange), error);
45+ for (const { edit, error } of fixtures.agent_edit) expect(JSON.stringify(edit), folioAgentEditError(edit), error);
46+ for (const { segment, id } of fixtures.folio_ids) assert.equal(folioIdFrom(segment), id, segment);
47+});
48+
49+test("addresses are flat, end in the id and survive renames", () => {
50+ const id = "fol_01jb2k7x9hfq0b3zj0f5s2m8ra";
51+ assert.equal(folioPath("acme", "Q4 roadmap: pricing", id), `/acme/-/artifacts/q4-roadmap-pricing-${id}`);
52+ assert.equal(folioSlug("", id), id);
53+ assert.equal(folioSlug("Café ✨ notes", id), `cafe-notes-${id}`);
54+ assert.equal(folioIdFrom(folioSlug("Anything at all", id)), id);
55+ assert.deepEqual(linkedFolioIds(`see /acme/-/artifacts/x-${id} and ${id}`), [id]);
56+ for (const segment of FOLIO_ROUTE_SEGMENTS) assert.equal(folioIdFrom(segment), null, segment);
57+});
58+
59+test("only a doc holds other folios", () => {
60+ assert.deepEqual(FOLIO_KINDS.filter(folioCanHaveChildren), ["doc"]);
61+});
62+
63+test("list queries are checked", () => {
64+ assert.equal(folioListQueryError({ tab: "shared", kinds: ["slides"], limit: 50 }), null);
65+ assert.equal(folioListQueryError({ tab: "everything" as "all" }), "tab is all, yours or shared.");
66+ assert.equal(folioListQueryError({ tab: "all", limit: 500 }), "limit is between 1 and 100.");
67+ assert.equal(folioListQueryError({ tab: "all", kinds: ["sheet" as "doc"] }), "There is no kind of artifact called sheet.");
68+});
69+
70+test("the client calls exactly the docs service's folio methods", async () => {
71+ const called: string[] = [];
72+ const bodies: Record<string, unknown>[] = [];
73+ const binding: ServiceBinding = {
74+ fetch: async (input: RequestInfo | URL, init?: RequestInit) => {
75+ called.push(String(input).replace("https://service/rpc/", ""));
76+ bodies.push(JSON.parse(String(init?.body)));
77+ return new Response(JSON.stringify({ ok: true, value: null }), { headers: { "content-type": "application/json" } });
78+ },
79+ } as ServiceBinding;
80+ const client = foliosClient(binding);
81+ const viewer = { id: "usr_1" } as User;
82+ const ws = "acme";
83+ const f = "fol_1";
84+ await Promise.all([
85+ client.list(ws, viewer, { tab: "all" }),
86+ client.sidebar(ws, viewer),
87+ client.folio(ws, viewer, f),
88+ client.create(ws, viewer, { kind: "doc" }),
89+ client.update(ws, viewer, f, { title: "x" }),
90+ client.move(ws, viewer, f, { space_id: null, parent_id: null }),
91+ client.duplicate(ws, viewer, f),
92+ client.trash(ws, viewer, f),
93+ client.restore(ws, viewer, f),
94+ client.delete(ws, viewer, f),
95+ client.trashed(ws, viewer),
96+ client.favorite(ws, viewer, f, true),
97+ client.content(ws, viewer, f),
98+ client.edit(ws, viewer, f, { kind: "doc", target: { kind: "append" }, markdown: "x" }),
99+ client.access(ws, viewer, f),
100+ client.changeAccess(ws, viewer, f, { op: "grant", principal: "user:usr_2", role: "view" }),
101+ client.changeAccess(ws, viewer, f, { op: "general", access: "workspace", role: "view" }),
102+ client.requestAccess(ws, viewer, f),
103+ client.joinSpace(ws, viewer, "spc_1"),
104+ client.leaveSpace(ws, viewer, "spc_1"),
105+ client.search(ws, viewer, { q: "roadmap" }),
106+ client.versions(ws, viewer, f),
107+ client.version(ws, viewer, f, "ver_1"),
108+ client.restoreVersion(ws, viewer, f, "ver_1"),
109+ client.templates(ws, viewer),
110+ client.saveTemplate(ws, viewer, { folio_id: f, name: "T" }),
111+ client.deleteTemplate(ws, viewer, "tpl_1"),
112+ client.export(ws, viewer, f),
113+ client.suggestions(ws, viewer, f),
114+ client.decideSuggestion(ws, viewer, "sug_1", "accept"),
115+ client.proposals(ws, viewer, f),
116+ client.decideProposal(ws, viewer, "prp_1", "reject"),
117+ client.thread(ws, viewer, f, { op: "resolve", thread_id: "thr_1" }),
118+ client.threads(ws, viewer, f),
119+ client.queryTile(ws, viewer, f, "t1"),
120+ client.queryDataset(ws, viewer, { dataset: "issues", measure: { op: "count" } }),
121+ client.queryDatasetForAgent(ws, "agt_1", viewer, { folio_id: f, tile_id: "t1" }),
122+ client.foliosForAgent(ws, "agt_1", viewer, { tab: "all" }),
123+ client.readForAgent(ws, "agt_1", viewer, f),
124+ client.createAsAgent(ws, "agt_1", viewer, { kind: "doc", title: "Notes", where: "private" }),
125+ client.editAsAgent(ws, "agt_1", viewer, f, { kind: "slides", ops: [{ op: "delete_slides", slide_ids: ["s1"] }] }),
126+ client.shareAsAgent(ws, "agt_1", viewer, f, { user_ids: ["usr_2"], role: "view" }, { kind: "people", user_ids: ["usr_1", "usr_2"] }),
127+ client.recallForAgent(ws, "agt_1", viewer, { query: "pricing" }),
128+ client.staleForAgent(ws, "agt_1", viewer),
129+ client.markCurrent(ws, viewer, f),
130+ client.reindex(ws, viewer),
131+ ]);
132+ assert.deepEqual([...new Set(called)].sort(), [...FOLIO_RPC_METHODS].sort());
133+ // Every body names the workspace and the viewer, and keys are snake_case.
134+ for (const body of bodies) {
135+ assert.equal(body.workspace, ws);
136+ assert.ok(body.viewer);
137+ for (const key of Object.keys(body)) assert.match(key, /^[a-z_]+$/, key);
138+ }
139+});
140+
141+test("slides ops are checked by shape", () => {
142+ assert.equal(slidesOpError({ op: "insert_slides", after_slide_id: null, markdown: "---" }), null);
143+ assert.equal(slidesOpError({ op: "move_slide", slide_id: "s1", after_slide_id: "s2" }), null);
144+ assert.equal(slidesOpError({ op: "set_theme", theme: "night", accent: "#8b7cf6" }), null);
145+ assert.equal(slidesOpError({ op: "set_theme", theme: "night", accent: "lavender" }), "accent is a colour like #8b7cf6, or null.");
146+ assert.equal(slidesOpError({ op: "replace_slide", markdown: "# x" }), "replace_slide needs slide_id.");
147+ assert.equal(slidesOpError({ op: "delete_slides", slide_ids: [] }), "delete_slides needs slide_ids.");
148+ assert.equal(slidesOpError({ op: "shuffle" }), "There is no slides op called shuffle.");
149+ for (const layout of SLIDE_LAYOUTS) assert.ok(SLIDE_LAYOUT_REGIONS[layout].includes("notes"), layout);
150+ assert.equal(slideFragment("s1", "left"), "slide:s1:left");
151+});
152+
153+test("design ops are checked by shape and size", () => {
154+ const frame = { type: "frame", name: "Home", layout: "column", gap: 16, children: [{ type: "text", text: "Hello" }, { type: "html", html: "<b>hi</b>" }] };
155+ assert.equal(designOpError({ op: "upsert_nodes", nodes: [frame] }), null);
156+ assert.deepEqual(countDesignSpecs([frame as never]), { nodes: 3, depth: 2 });
157+ assert.equal(designOpError({ op: "upsert_nodes", nodes: [{ type: "rect", children: [{ type: "text" }] }] }), "Only frames and components have children.");
158+ assert.equal(designOpError({ op: "upsert_nodes", nodes: [{ type: "text", html: "<b>" }] }), "Only an html node has html.");
159+ assert.equal(designOpError({ op: "upsert_nodes", nodes: [{ type: "blob" }] }), "There is no node type called blob.");
160+ assert.equal(designOpError({ op: "set_html", node_id: "n1", html: "x".repeat(200 * 1024 + 1) }), "An html node holds at most 200 KB.");
161+ assert.equal(designOpError({ op: "set_html", node_id: "n1", html: "é".repeat(100 * 1024) }), null);
162+ assert.equal(designOpError({ op: "set_html", node_id: "n1", html: "é".repeat(100 * 1024 + 1) }), "An html node holds at most 200 KB.");
163+ const many = Array.from({ length: DESIGN_MAX_NODES + 1 }, () => ({ type: "rect" }));
164+ assert.equal(designOpError({ op: "upsert_nodes", nodes: many }), `A design holds at most ${DESIGN_MAX_NODES} nodes.`);
165+ assert.equal(designOpError({ op: "move", ids: ["a"], dx: 1, dy: Number.NaN }), "move needs dx and dy.");
166+ assert.equal(designOpError({ op: "instance" }), "There is no design op called instance.");
167+});
168+
169+test("dashboard ops check tiles, their queries and the grid", () => {
170+ const tile = {
171+ id: "t1",
172+ type: "line",
173+ title: "Merged per week",
174+ description: "",
175+ query: { dataset: "pull_requests", measure: { op: "count" }, interval: "week", time: "merged_at" },
176+ viz: {},
177+ grid: { x: 0, y: 0, w: 6, h: 4 },
178+ };
179+ assert.equal(dashboardOpError({ op: "upsert_tile", tile }, datasetQueryError), null);
180+ assert.equal(dashboardOpError({ op: "upsert_tile", tile: { ...tile, query: { ...tile.query, group_by: "secret" } } }, datasetQueryError), "pull_requests can't be grouped by secret.");
181+ assert.equal(dashboardOpError({ op: "upsert_tile", tile: { ...tile, type: "markdown", query: null, markdown: "Notes" } }, datasetQueryError), null);
182+ assert.equal(dashboardOpError({ op: "upsert_tile", tile: { ...tile, type: "markdown" } }, datasetQueryError), "A text tile has no query.");
183+ assert.equal(dashboardOpError({ op: "upsert_tile", tile: { ...tile, grid: { x: 8, y: 0, w: 6, h: 4 } } }, datasetQueryError), "A tile fits in 12 columns.");
184+ assert.equal(dashboardOpError({ op: "set_filters", filters: { range: "30d", repos: ["acme/web"] } }, datasetQueryError), null);
185+ assert.equal(dashboardOpError({ op: "set_filters", filters: { range: { from: "2026-02-30", to: "2026-03-01" } } }, datasetQueryError), "A range's from and to are dates or RFC 3339 UTC times.");
186+ assert.equal(dashboardOpError({ op: "set_layout", tiles: [{ id: "t1", grid: { x: 0, y: 0, w: 0, h: 1 } }] }, datasetQueryError), "grid starts at 0, 0 and is at least 1 by 1.");
187+ assert.equal(dashboardGridError({ x: 0, y: 0, w: 12, h: 1 }), null);
188+});
+801−0
1+/**
2+ * Folios: what people call artifacts. Artifacts mode is one mode for docs,
3+ * slides, designs and dashboards, each private, shared with people and
4+ * agents, in a space or open to the workspace, and edited live together.
5+ * Kept by the docs service (`services/docs`). Plan and decisions:
6+ * docs/ARTIFACTS_MODE.md.
7+ *
8+ * Naming: code says "folio", people see "artifact" (UI text, URLs
9+ * `/<ws>/-/artifacts/...`, the `artifact` MCP tool, REST paths, the
10+ * `artifacts:*` scopes). The Cloudflare Artifacts git store and workflow
11+ * run artifacts are something else and keep their names.
12+ *
13+ * Wire shapes are snake_case end to end. Each kind's own model is in its
14+ * own file: folios-slides.ts, folios-design.ts, folios-dashboard.ts.
15+ * `crates/contracts/src/folios.rs` mirrors what the Rust API needs, and a
16+ * Rust test runs both validators over `folios.fixtures.json`.
17+ *
18+ * Access, in one place (section 2.1 of the plan):
19+ *
20+ * - Every folio has one owner, always a person, who has `manage`.
21+ * - It sits in a space, or in its owner's Private section (`space` null).
22+ * - A person's role is the highest of: owner; explicit grants on it or an
23+ * ancestor it inherits from; their space role when it inherits up to a
24+ * folio in a space; and the general access of its access root
25+ * (`workspace`: every member; `link`: members who opened the link).
26+ * - `inherit: false` makes a folio its own access root ("Only people
27+ * invited"). "Private" (the lock) is computed: only the owner can read it.
28+ * - An agent's role is never higher than its asker's, narrowed to what
29+ * every person in the audience can read.
30+ */
31+import type { MemberProfile } from "./chat";
32+import type { ServiceBinding } from "./clients";
33+import type { DatasetQuery, DatasetResult } from "./datasets";
34+import type {
35+ DocAgentAbilities,
36+ DocAgentMode,
37+ DocAudience,
38+ DocBlockOutline,
39+ DocDiffLine,
40+ DocEditTarget,
41+ DocRepoSpace,
42+ DocRole,
43+ DocSpace,
44+ DocSpaceKind,
45+ DocSuggestion,
46+ DocThread,
47+ DocThreadAction,
48+} from "./docs";
49+import type { DashboardOp, DashboardPreview } from "./folios-dashboard";
50+import type { DesignOp, DesignPreview } from "./folios-design";
51+import type { SlidesOp, SlidesPreview } from "./folios-slides";
52+import type { User } from "./identity";
53+import type { Result } from "./result";
54+
55+// ── Kinds ─────────────────────────────────────────────────────────────
56+
57+export type FolioKind = "doc" | "slides" | "design" | "dashboard";
58+export const FOLIO_KINDS: readonly FolioKind[] = ["doc", "slides", "design", "dashboard"];
59+
60+/** The kinds as the Artifacts home's tiles name them. */
61+export const FOLIO_KIND_LABELS: Record<FolioKind, string> = {
62+ doc: "Docs",
63+ slides: "Slides",
64+ design: "Design",
65+ dashboard: "Dashboard",
66+};
67+
68+/** One of a kind, in a sentence: "a doc", "a deck"... */
69+export const FOLIO_KIND_NOUNS: Record<FolioKind, string> = {
70+ doc: "doc",
71+ slides: "deck",
72+ design: "design",
73+ dashboard: "dashboard",
74+};
75+
76+export function isFolioKind(value: unknown): value is FolioKind {
77+ return typeof value === "string" && (FOLIO_KINDS as readonly string[]).includes(value);
78+}
79+
80+/** Only a doc holds other folios: a doc with children is the folder. */
81+export function folioCanHaveChildren(kind: FolioKind): boolean {
82+ return kind === "doc";
83+}
84+
85+// ── Roles and access ──────────────────────────────────────────────────
86+
87+/** What someone may do with a folio, weakest first. The same roles as a space's. */
88+export type FolioRole = DocRole;
89+export const FOLIO_ROLES: readonly FolioRole[] = ["view", "comment", "edit", "manage"];
90+
91+/** Who may open a folio besides its owner, grants and space. */
92+export type FolioGeneralAccess = "none" | "workspace" | "link";
93+export const FOLIO_GENERAL_ACCESS: readonly FolioGeneralAccess[] = ["none", "workspace", "link"];
94+
95+export const FOLIO_GENERAL_ACCESS_LABELS: Record<FolioGeneralAccess, string> = {
96+ none: "Restricted",
97+ workspace: "Everyone in the workspace",
98+ link: "Anyone in the workspace with the link",
99+};
100+
101+/** General access never gives `manage`. */
102+export type FolioGeneralRole = Exclude<FolioRole, "manage">;
103+export const FOLIO_GENERAL_ROLES: readonly FolioGeneralRole[] = ["view", "comment", "edit"];
104+
105+/** Space kinds as Artifacts names them (the database keeps workspace / team / private). */
106+export const FOLIO_SPACE_KIND_LABELS: Record<DocSpaceKind, string> = {
107+ workspace: "Open",
108+ team: "Team",
109+ private: "Members only",
110+};
111+
112+/** Who a grant names: `user:<id>`, `agent:<id>` or `team:<slug>`. */
113+export type FolioPrincipal = string;
114+
115+const PRINCIPAL = /^(user|agent|team):[A-Za-z0-9_.-]{1,64}$/;
116+
117+export function isFolioPrincipal(value: unknown): value is FolioPrincipal {
118+ return typeof value === "string" && PRINCIPAL.test(value);
119+}
120+
121+/** The deepest a folio tree goes. */
122+export const FOLIO_MAX_DEPTH = 10;
123+/** The longest title, in characters. */
124+export const FOLIO_MAX_TITLE = 200;
125+/** The most people a folio is shared with in one change. */
126+export const FOLIO_MAX_SHARE = 50;
127+/** A subtree larger than this has its access rebuilt by a queue job, not inline. */
128+export const FOLIO_INLINE_REACL = 2_000;
129+
130+// ── Addresses ─────────────────────────────────────────────────────────
131+
132+const FOLIO_ID = /(fol_[0-9a-hjkmnp-tv-z]{26})$/;
133+
134+/** The words of a title as an address: `q4-roadmap`. At most 50 characters. */
135+export function folioTitleSlug(title: string): string {
136+ return title
137+ .normalize("NFKD")
138+ .replace(/[̀-ͯ]/g, "")
139+ .toLowerCase()
140+ .replace(/[^a-z0-9]+/g, "-")
141+ .replace(/^-+|-+$/g, "")
142+ .slice(0, 50)
143+ .replace(/-+$/g, "");
144+}
145+
146+/** A folio's last address segment, `<title-slug>-<id>`: only the id is read, so renames keep links working. */
147+export function folioSlug(title: string, id: string): string {
148+ const words = folioTitleSlug(title);
149+ return words ? `${words}-${id}` : id;
150+}
151+
152+/** A folio's address. Flat: moving it between spaces and Private never breaks a link. */
153+export function folioPath(workspace: string, title: string, id: string): string {
154+ return `/${workspace}/-/artifacts/${folioSlug(title, id)}`;
155+}
156+
157+/** The folio id at the end of an address segment, or null. */
158+export function folioIdFrom(segment: string | null | undefined): string | null {
159+ return FOLIO_ID.exec(String(segment ?? ""))?.[1] ?? null;
160+}
161+
162+/** Every folio id a text links to, for backlinks. */
163+export function linkedFolioIds(text: string): string[] {
164+ return [...new Set(text.match(/fol_[0-9a-hjkmnp-tv-z]{26}/g) ?? [])];
165+}
166+
167+/** Segments the site's routes use under `-/artifacts/`: never a folio. */
168+export const FOLIO_ROUTE_SEGMENTS: readonly string[] = ["live", "api", "query", "threads", "upload", "export", "new", "templates", "trash", "stale", "spaces", "repo"];
169+
170+// ── Folios ────────────────────────────────────────────────────────────
171+
172+/** Enough to link to a folio. */
173+export type FolioRef = {
174+ id: string;
175+ kind: FolioKind;
176+ title: string;
177+ /** An emoji, or null for the kind's icon. */
178+ icon: string | null;
179+ /** `<title-slug>-<id>`. */
180+ slug: string;
181+ /** `/<ws>/-/artifacts/<slug>`. */
182+ path: string;
183+};
184+
185+/** The space a folio sits in. */
186+export type FolioSpaceRef = { id: string; slug: string; name: string; kind: DocSpaceKind };
187+
188+/** Where a folio's access comes from when it inherits. */
189+export type FolioInheritedFrom = { kind: "space" | "folio"; id: string; name: string };
190+
191+/** What a card draws: written when the folio is saved, never data values. */
192+export type FolioPreview = { kind: "doc"; lines: string[] } | SlidesPreview | DesignPreview | DashboardPreview;
193+
194+/** A folio as lists and its page show it, for one viewer. */
195+export type Folio = FolioRef & {
196+ workspace_id: string;
197+ /** Null: its owner's Private section. */
198+ space: FolioSpaceRef | null;
199+ parent_id: string | null;
200+ position: number;
201+ owner: MemberProfile;
202+ created_by: MemberProfile;
203+ created_at: string;
204+ /** Any change: rename, move, share. */
205+ updated_at: string;
206+ /** Content changes: "Edited 45m ago". */
207+ edited_by: MemberProfile | null;
208+ edited_at: string;
209+ trashed_at: string | null;
210+ viewer_role: FolioRole;
211+ favorite: boolean;
212+ /** Only its owner can read it (the lock). */
213+ private: boolean;
214+ /** How many people, agents and teams it is shared with directly. */
215+ shared_count: number;
216+ /** The access root's general access. */
217+ general_access: FolioGeneralAccess;
218+ general_role: FolioGeneralRole | null;
219+ /** False: "Only people invited", its own access root. */
220+ inherit: boolean;
221+ inherited_from: FolioInheritedFrom | null;
222+ /** How agents change it: its own, else its space's, else `suggest`. */
223+ agent_mode: DocAgentMode;
224+ excerpt: string;
225+ preview: FolioPreview | null;
226+ /** Where it was written up from, such as a chat thread, when it was. */
227+ source: { title: string; href: string } | null;
228+ /** Possibly out of date: code it cites changed. */
229+ stale: boolean;
230+ has_children: boolean;
231+};
232+
233+/** A row of the sidebar's trees. */
234+export type FolioTreeNode = {
235+ id: string;
236+ kind: FolioKind;
237+ parent_id: string | null;
238+ position: number;
239+ title: string;
240+ icon: string | null;
241+ slug: string;
242+ /** It restricts access below where it sits (a lock on the row). */
243+ restricted: boolean;
244+ stale?: boolean;
245+};
246+
247+export type FolioListTab = "all" | "yours" | "shared";
248+export const FOLIO_LIST_TABS: readonly FolioListTab[] = ["all", "yours", "shared"];
249+
250+export type FolioListQuery = {
251+ tab: FolioListTab;
252+ kinds?: FolioKind[] | null;
253+ space_id?: string | null;
254+ /** A member key, `user:<id>`. */
255+ owner?: string | null;
256+ /** `owner/name`. */
257+ project?: string | null;
258+ /** Words or meaning; hybrid search when given. */
259+ q?: string | null;
260+ cursor?: string | null;
261+ limit?: number | null;
262+};
263+
264+/** The most folios a page of a list holds. */
265+export const FOLIO_LIST_MAX = 100;
266+
267+export type FolioList = { items: Folio[]; next_cursor: string | null };
268+
269+export type FoliosSidebarSpace = DocSpace & { joined: boolean; tree: FolioTreeNode[] };
270+
271+export type FoliosSidebar = {
272+ favorites: FolioRef[];
273+ /** Joined open spaces, the viewer's team spaces and Members-only spaces. */
274+ spaces: FoliosSidebarSpace[];
275+ /** The viewer's own folios in no space. */
276+ private_tree: FolioTreeNode[];
277+ /** The tops of what is shared with the viewer: the highest ancestor they can read. */
278+ shared: FolioRef[];
279+ /** Projects' docs: repositories' `docs/` folders, read-only. */
280+ repos: DocRepoSpace[];
281+ can_create_space: boolean;
282+ trash_count: number;
283+ stale_count: number;
284+};
285+
286+/** Content to start a folio with: Markdown for docs and slides, a spec for designs and dashboards. */
287+export type FolioContentInput = { markdown: string } | { spec: unknown };
288+
289+export type NewFolio = {
290+ kind: FolioKind;
291+ title?: string | null;
292+ icon?: string | null;
293+ /** Null or absent: the creator's Private section. */
294+ space_id?: string | null;
295+ /** A doc to sit under. */
296+ parent_id?: string | null;
297+ template_id?: string | null;
298+ content?: FolioContentInput | null;
299+ source?: { title: string; href: string } | null;
300+ share_with?: { principal: FolioPrincipal; role: FolioRole }[] | null;
301+};
302+
303+export type FolioChange = {
304+ title?: string;
305+ icon?: string | null;
306+ cover?: string | null;
307+ projects?: string[];
308+};
309+
310+/** Where to put a folio: a space (null: Private) and a parent doc, before a sibling or last. */
311+export type FolioMove = { space_id: string | null; parent_id: string | null; before_id?: string | null };
312+
313+// ── Sharing ───────────────────────────────────────────────────────────
314+
315+/** Where a row of "Who has access" comes from. */
316+export type FolioAccessSource =
317+ | { kind: "owner" }
318+ /** A grant on this folio: editable here. */
319+ | { kind: "grant" }
320+ /** A grant on a parent it inherits from: change it there. */
321+ | { kind: "folio"; id: string; title: string; path: string }
322+ /** The space it inherits from. */
323+ | { kind: "space"; id: string; name: string };
324+
325+export type FolioAccessRow = {
326+ principal: FolioPrincipal;
327+ /** A person or agent; a team is shown by name. */
328+ profile: MemberProfile | { kind: "team"; id: string; name: string; display_name: string };
329+ role: FolioRole;
330+ source: FolioAccessSource;
331+};
332+
333+/** The share dialog. */
334+export type FolioAccessList = {
335+ folio_id: string;
336+ owner: MemberProfile;
337+ rows: FolioAccessRow[];
338+ general_access: FolioGeneralAccess;
339+ general_role: FolioGeneralRole | null;
340+ inherit: boolean;
341+ inherited_from: FolioInheritedFrom | null;
342+ /** The folio's own setting; null follows its space's. */
343+ agent_mode: DocAgentMode | null;
344+ /** The viewer may change any of it. */
345+ can_share: boolean;
346+ /** Public links are off until a workspace turns them on (later). */
347+ public_link: "off";
348+};
349+
350+/** One change from the share dialog. */
351+export type FolioAccessChange =
352+ /** Share with someone, or change their role; `notify` is an optional message. */
353+ | { op: "grant"; principal: FolioPrincipal; role: FolioRole; notify?: string | null }
354+ | { op: "revoke"; principal: FolioPrincipal }
355+ /** `none` takes no role; `workspace` and `link` take one, never `manage`. */
356+ | { op: "general"; access: FolioGeneralAccess; role: FolioGeneralRole | null }
357+ /** Follow the space or parent (true), or "Only people invited" (false). */
358+ | { op: "inherit"; inherit: boolean }
359+ | { op: "agent_mode"; agent_mode: DocAgentMode | null };
360+
361+// ── History, proposals, templates ─────────────────────────────────────
362+
363+export type FolioVersionKind = "created" | "edit" | "agent" | "suggestion" | "proposal" | "restore";
364+
365+export type FolioVersion = {
366+ id: string;
367+ folio_id: string;
368+ created_at: string;
369+ authors: MemberProfile[];
370+ kind: FolioVersionKind;
371+ note: string | null;
372+};
373+
374+export type FolioVersionDetail = FolioVersion & {
375+ /** The folio's text rendition then. */
376+ text: string;
377+ /** Against the version before it. */
378+ diff: DocDiffLine[];
379+};
380+
381+export type FolioProposalStatus = "open" | "accepted" | "rejected" | "stale";
382+
383+/** An agent's whole change to a slides deck, design or dashboard, which a person previews and applies or rejects. */
384+export type FolioProposal = {
385+ id: string;
386+ folio_id: string;
387+ author: MemberProfile;
388+ asked_by: MemberProfile | null;
389+ note: string | null;
390+ /** "Adds slides 4–6; rewrites the title slide". */
391+ summary: string;
392+ status: FolioProposalStatus;
393+ created_at: string;
394+ decided_by: MemberProfile | null;
395+ decided_at: string | null;
396+};
397+
398+/** A doc's tracked change, as Docs has them, on a folio. */
399+export type FolioSuggestion = Omit<DocSuggestion, "page_id"> & { folio_id: string };
400+
401+export type FolioTemplate = {
402+ id: string;
403+ kind: FolioKind;
404+ name: string;
405+ description: string;
406+ icon: string | null;
407+ builtin: boolean;
408+ /** Markdown (doc, slides) or a JSON spec (design, dashboard). */
409+ body: string;
410+ created_by: MemberProfile | null;
411+};
412+
413+// ── Live ──────────────────────────────────────────────────────────────
414+
415+/** JSON text frames on a folio's socket, besides the Yjs protocol's binary ones. */
416+export type FoliosLiveEvent =
417+ | { type: "folio.updated"; folio: Folio }
418+ | { type: "folio.trashed"; folio_id: string }
419+ | { type: "suggestion.created" | "suggestion.updated"; suggestion: FolioSuggestion }
420+ | { type: "proposal.created" | "proposal.updated"; proposal: FolioProposal }
421+ | { type: "version.created"; version: FolioVersion }
422+ /** Whether it is possibly out of date changed: ask again. */
423+ | { type: "folio.staleness" }
424+ /** Its sharing changed: ask for the access list again to refresh badges. */
425+ | { type: "folio.access" }
426+ /** The viewer's role changed, or their access ended (`role` null; the socket then closes with 4403). */
427+ | { type: "access"; role: FolioRole | null };
428+
429+// ── Agents ────────────────────────────────────────────────────────────
430+
431+/** Who will see what an agent says. More than 20 people reads as the workspace. */
432+export type FolioAudience = DocAudience;
433+
434+export type FolioAgentAbilities = DocAgentAbilities;
435+
436+/** Options on any agent edit. */
437+export type FolioEditOptions = {
438+ note?: string | null;
439+ /** Never edit directly: file a suggestion (doc) or a proposal (others). */
440+ suggest_only?: boolean | null;
441+ /** The edit brings it up to date with the code it cites. */
442+ marks_current?: boolean | null;
443+};
444+
445+/** An agent's (or a token's) change to a folio, in the kind's own terms. */
446+export type FolioAgentEdit = (
447+ | { kind: "doc"; target: DocEditTarget; markdown: string }
448+ | { kind: "slides"; ops: SlidesOp[] }
449+ | { kind: "design"; ops: DesignOp[] }
450+ | { kind: "dashboard"; ops: DashboardOp[] }
451+) &
452+ FolioEditOptions;
453+
454+/** The most ops one edit carries. */
455+export const FOLIO_MAX_OPS = 200;
456+
457+export type FolioAgentEditResult =
458+ | { mode: "applied"; version_id: string | null; folio: FolioRef; summary: string }
459+ | { mode: "suggested"; suggestion: FolioSuggestion; folio: FolioRef }
460+ | { mode: "proposed"; proposal: FolioProposal; folio: FolioRef };
461+
462+/**
463+ * A folio in the form an agent reads and writes: a doc's Markdown with its
464+ * block ids; a deck's Markdown with slide ids; a design's node spec; a
465+ * dashboard's spec (tiles and queries, never values).
466+ */
467+export type FolioAgentRead = {
468+ folio: FolioRef & { edited_at: string };
469+ space: { id: string; slug: string; name: string; agent_mode: DocAgentMode } | null;
470+ /** Markdown for doc and slides; JSON text for design and dashboard. */
471+ content: string;
472+ /** doc: top-level blocks, for `blocks` targets. */
473+ blocks?: DocBlockOutline[];
474+ can: FolioAgentAbilities;
475+ /** False: someone the agent is talking to can't read it, so don't quote it there. */
476+ audience_can_read: boolean;
477+};
478+
479+/** One passage recalled for an agent: part of a folio, or of a repository's docs. */
480+export type FolioPassage = {
481+ folio: FolioRef | null;
482+ repo_file: { repo: string; path: string; href: string } | null;
483+ space_name: string;
484+ heading: string | null;
485+ text: string;
486+ score: number;
487+ updated_at: string;
488+ stale: boolean;
489+};
490+
491+export type FolioSearchHit = FolioRef & {
492+ space_name: string | null;
493+ snippet: string;
494+ edited_at: string;
495+ heading?: string | null;
496+ matched?: "words" | "meaning" | "both" | null;
497+};
498+
499+// ── Validators (pure; mirrored in Rust) ───────────────────────────────
500+
501+function isObject(value: unknown): value is Record<string, unknown> {
502+ return typeof value === "object" && value !== null && !Array.isArray(value);
503+}
504+
505+const isRole = (value: unknown): value is FolioRole => typeof value === "string" && (FOLIO_ROLES as readonly string[]).includes(value);
506+
507+/** What is wrong with a new folio, or null. Whether its space and parent exist and allow it is the service's to say. */
508+export function newFolioError(input: NewFolio): string | null {
509+ if (!isFolioKind(input.kind)) return `There is no kind of artifact called ${String(input.kind)}.`;
510+ const title = input.title ?? "";
511+ if ([...title].length > FOLIO_MAX_TITLE) return `A title is at most ${FOLIO_MAX_TITLE} characters.`;
512+ const content = input.content ?? null;
513+ if (content !== null) {
514+ const wantsMarkdown = input.kind === "doc" || input.kind === "slides";
515+ if (wantsMarkdown && typeof (content as { markdown?: unknown }).markdown !== "string") return `A ${FOLIO_KIND_NOUNS[input.kind]} starts from markdown.`;
516+ if (!wantsMarkdown && !isObject((content as { spec?: unknown }).spec)) return `A ${FOLIO_KIND_NOUNS[input.kind]} starts from a spec.`;
517+ if (input.template_id != null) return "Start from a template or from content, not both.";
518+ }
519+ const share = input.share_with ?? [];
520+ if (share.length > FOLIO_MAX_SHARE) return `Share with at most ${FOLIO_MAX_SHARE} at once.`;
521+ for (const row of share) {
522+ if (!isFolioPrincipal(row.principal)) return `${String(row.principal)} is not user:, agent: or team: and an id.`;
523+ if (!isRole(row.role)) return `There is no role called ${String(row.role)}.`;
524+ }
525+ return null;
526+}
527+
528+/** What is wrong with a change from the share dialog, or null. */
529+export function folioAccessChangeError(change: FolioAccessChange): string | null {
530+ switch (change.op) {
531+ case "grant":
532+ if (!isFolioPrincipal(change.principal)) return `${String(change.principal)} is not user:, agent: or team: and an id.`;
533+ if (!isRole(change.role)) return `There is no role called ${String(change.role)}.`;
534+ if (change.notify != null && [...change.notify].length > 2_000) return "A message is at most 2000 characters.";
535+ return null;
536+ case "revoke":
537+ return isFolioPrincipal(change.principal) ? null : `${String(change.principal)} is not user:, agent: or team: and an id.`;
538+ case "general":
539+ if (!(FOLIO_GENERAL_ACCESS as readonly string[]).includes(change.access)) return `There is no general access called ${String(change.access)}.`;
540+ if (change.access === "none") return change.role === null ? null : "Restricted takes no role.";
541+ if (change.role === null) return `${FOLIO_GENERAL_ACCESS_LABELS[change.access]} needs a role.`;
542+ return (FOLIO_GENERAL_ROLES as readonly string[]).includes(change.role) ? null : "General access gives view, comment or edit, never full access.";
543+ case "inherit":
544+ return typeof change.inherit === "boolean" ? null : "inherit is true or false.";
545+ case "agent_mode":
546+ return change.agent_mode === null || change.agent_mode === "suggest" || change.agent_mode === "edit" ? null : "agent_mode is suggest, edit or null.";
547+ default:
548+ return `There is no access change called ${String((change as { op?: unknown }).op)}.`;
549+ }
550+}
551+
552+/**
553+ * What is wrong with an agent edit's envelope, or null: its kind, and a
554+ * doc's target and Markdown or the other kinds' list of ops. Each op is
555+ * checked by its kind's validator (`slidesOpError`, `designOpError`,
556+ * `dashboardOpError`).
557+ */
558+export function folioAgentEditError(edit: unknown): string | null {
559+ if (!isObject(edit)) return "An edit is an object.";
560+ if (!isFolioKind(edit.kind)) return `There is no kind of artifact called ${String(edit.kind)}.`;
561+ if (edit.note != null && typeof edit.note !== "string") return "note is text.";
562+ if (edit.kind === "doc") {
563+ if (typeof edit.markdown !== "string") return "A doc edit has markdown.";
564+ if (edit.ops !== undefined) return "A doc edit has a target and markdown, not ops.";
565+ const target = edit.target;
566+ if (!isObject(target)) return "A doc edit has a target.";
567+ switch (target.kind) {
568+ case "append":
569+ case "document":
570+ return null;
571+ case "section":
572+ return typeof target.heading === "string" && target.heading.length > 0 ? null : "A section target names its heading.";
573+ case "blocks":
574+ return typeof target.from_block === "string" && typeof target.to_block === "string" ? null : "A blocks target names from_block and to_block.";
575+ default:
576+ return "A doc edit's target is append, document, section or blocks.";
577+ }
578+ }
579+ if (edit.markdown !== undefined || edit.target !== undefined) return `A ${FOLIO_KIND_NOUNS[edit.kind]} edit has ops, not a target and markdown.`;
580+ if (!Array.isArray(edit.ops) || edit.ops.length === 0) return `A ${FOLIO_KIND_NOUNS[edit.kind]} edit has a list of ops.`;
581+ if (edit.ops.length > FOLIO_MAX_OPS) return `An edit has at most ${FOLIO_MAX_OPS} ops.`;
582+ return edit.ops.every((op) => isObject(op) && typeof op.op === "string") ? null : "Each op is an object with an op.";
583+}
584+
585+/** What is wrong with a list query, or null. */
586+export function folioListQueryError(query: FolioListQuery): string | null {
587+ if (!(FOLIO_LIST_TABS as readonly string[]).includes(query.tab)) return "tab is all, yours or shared.";
588+ for (const kind of query.kinds ?? []) if (!isFolioKind(kind)) return `There is no kind of artifact called ${String(kind)}.`;
589+ const limit = query.limit ?? null;
590+ if (limit !== null && (!Number.isInteger(limit) || limit < 1 || limit > FOLIO_LIST_MAX)) return `limit is between 1 and ${FOLIO_LIST_MAX}.`;
591+ return null;
592+}
593+
594+// ── The docs service's folio RPC ──────────────────────────────────────
595+
596+/** Every method the docs service answers for folios at `/rpc/<method>` (plan section 7). */
597+export const FOLIO_RPC_METHODS = [
598+ // Lists and navigation.
599+ "folio_list",
600+ "folio_sidebar",
601+ "folio",
602+ // Changing folios.
603+ "create_folio",
604+ "update_folio",
605+ "move_folio",
606+ "duplicate_folio",
607+ "trash_folio",
608+ "restore_folio",
609+ "delete_folio",
610+ "folio_trash",
611+ "favorite_folio",
612+ // A person's (or a token's) own read and edit in the agent form.
613+ "folio_content",
614+ "edit_folio",
615+ // Sharing and spaces.
616+ "folio_access",
617+ "set_folio_grant",
618+ "set_folio_general_access",
619+ "request_folio_access",
620+ "join_space",
621+ "leave_space",
622+ // Search, history, templates and export.
623+ "search_folios",
624+ "folio_versions",
625+ "folio_version",
626+ "restore_folio_version",
627+ "folio_templates",
628+ "save_folio_template",
629+ "delete_folio_template",
630+ "export_folio",
631+ // Suggestions, proposals and comments.
632+ "folio_suggestions",
633+ "decide_folio_suggestion",
634+ "folio_proposals",
635+ "decide_folio_proposal",
636+ "folio_thread",
637+ "folio_threads",
638+ // Dashboards.
639+ "query_tile",
640+ "query_dataset",
641+ "query_dataset_for_agent",
642+ // Agents.
643+ "folios_for_agent",
644+ "read_folio_for_agent",
645+ "create_folio_as_agent",
646+ "edit_folio_as_agent",
647+ "share_folio_as_agent",
648+ "recall_folios_for_agent",
649+ "stale_folios_for_agent",
650+ "mark_folio_current",
651+ "reindex_folios",
652+] as const;
653+
654+export type FolioRpcMethod = (typeof FOLIO_RPC_METHODS)[number];
655+
656+export type FoliosApi = {
657+ // ── The site, and the API acting for a person ───────────────────────
658+ list(workspace: string, viewer: User, query: FolioListQuery): Promise<Result<FolioList>>;
659+ sidebar(workspace: string, viewer: User): Promise<Result<FoliosSidebar>>;
660+ /** A folio and the viewer's role in it; records the visit (which is what makes a link folio readable). Not found when they can't read it. */
661+ folio(workspace: string, viewer: User, folioId: string): Promise<Result<Folio>>;
662+ create(workspace: string, viewer: User, input: NewFolio): Promise<Result<Folio>>;
663+ update(workspace: string, viewer: User, folioId: string, change: FolioChange): Promise<Result<Folio>>;
664+ /** Edit role where it is and where it goes. Refuses moving under itself, under a non-doc, or deeper than `FOLIO_MAX_DEPTH`. */
665+ move(workspace: string, viewer: User, folioId: string, move: FolioMove): Promise<Result<Folio>>;
666+ duplicate(workspace: string, viewer: User, folioId: string): Promise<Result<Folio>>;
667+ /** To the trash, with everything under it. */
668+ trash(workspace: string, viewer: User, folioId: string): Promise<Result<Folio>>;
669+ restore(workspace: string, viewer: User, folioId: string): Promise<Result<Folio>>;
670+ /** For good: only from the trash, manage role. */
671+ delete(workspace: string, viewer: User, folioId: string): Promise<Result<boolean>>;
672+ trashed(workspace: string, viewer: User): Promise<Result<Folio[]>>;
673+ favorite(workspace: string, viewer: User, folioId: string, on: boolean): Promise<Result<boolean>>;
674+ /** The folio in its agent form, for a person or their token. */
675+ content(workspace: string, viewer: User, folioId: string): Promise<Result<FolioAgentRead>>;
676+ /** Applies an edit in the kind's terms as the viewer: edit role, else a suggestion or proposal with comment role. */
677+ edit(workspace: string, viewer: User, folioId: string, edit: FolioAgentEdit): Promise<Result<FolioAgentEditResult>>;
678+
679+ access(workspace: string, viewer: User, folioId: string): Promise<Result<FolioAccessList>>;
680+ /** One change from the share dialog: grants go to `set_folio_grant`, the rest to `set_folio_general_access`. */
681+ changeAccess(workspace: string, viewer: User, folioId: string, change: FolioAccessChange): Promise<Result<FolioAccessList>>;
682+ /** Asks the owner and managers for access; they get an inbox item. */
683+ requestAccess(workspace: string, viewer: User, folioId: string, message?: string | null): Promise<Result<boolean>>;
684+ joinSpace(workspace: string, viewer: User, spaceId: string): Promise<Result<boolean>>;
685+ leaveSpace(workspace: string, viewer: User, spaceId: string): Promise<Result<boolean>>;
686+
687+ search(workspace: string, viewer: User, query: { q: string; kinds?: FolioKind[] | null; space_id?: string | null; project?: string | null; owner?: string | null; mode?: "words" | "hybrid" | null; limit?: number | null }): Promise<Result<FolioSearchHit[]>>;
688+ versions(workspace: string, viewer: User, folioId: string): Promise<Result<FolioVersion[]>>;
689+ version(workspace: string, viewer: User, folioId: string, versionId: string): Promise<Result<FolioVersionDetail>>;
690+ restoreVersion(workspace: string, viewer: User, folioId: string, versionId: string): Promise<Result<FolioVersion>>;
691+ templates(workspace: string, viewer: User, kind?: FolioKind | null): Promise<Result<FolioTemplate[]>>;
692+ saveTemplate(workspace: string, viewer: User, input: { folio_id: string; name: string; description?: string | null }): Promise<Result<FolioTemplate>>;
693+ deleteTemplate(workspace: string, viewer: User, templateId: string): Promise<Result<boolean>>;
694+ export(workspace: string, viewer: User, folioId: string, format?: "markdown" | "json" | null): Promise<Result<{ filename: string; content_type: string; body: string }>>;
695+
696+ suggestions(workspace: string, viewer: User, folioId: string): Promise<Result<FolioSuggestion[]>>;
697+ decideSuggestion(workspace: string, viewer: User, suggestionId: string, decision: "accept" | "reject"): Promise<Result<FolioSuggestion>>;
698+ proposals(workspace: string, viewer: User, folioId: string): Promise<Result<FolioProposal[]>>;
699+ decideProposal(workspace: string, viewer: User, proposalId: string, decision: "accept" | "reject"): Promise<Result<FolioProposal>>;
700+ thread(workspace: string, viewer: User, folioId: string, action: DocThreadAction): Promise<Result<unknown>>;
701+ threads(workspace: string, viewer: User, folioId: string): Promise<Result<DocThread[]>>;
702+
703+ /** A dashboard tile's numbers for the viewer: the tile's query, read from the room. */
704+ queryTile(workspace: string, viewer: User, folioId: string, tileId: string): Promise<Result<DatasetResult>>;
705+ /** A query as the viewer, for the API. */
706+ queryDataset(workspace: string, viewer: User, query: DatasetQuery): Promise<Result<DatasetResult>>;
707+
708+ // ── Agents (services/agents) ────────────────────────────────────────
709+ //
710+ // Each takes the agent and the person it acts for (`viewer`). The agent
711+ // never reads or changes more than that person can, narrowed to what
712+ // everyone in `audience` can read. A folio it may not read is not found.
713+
714+ foliosForAgent(workspace: string, agentId: string, viewer: User, query: FolioListQuery, audience?: FolioAudience | null): Promise<Result<FolioList>>;
715+ readForAgent(workspace: string, agentId: string, viewer: User, folioId: string, audience?: FolioAudience | null): Promise<Result<FolioAgentRead>>;
716+ /**
717+ * A new folio: the viewer owns it, the agent made it and gets `edit` on
718+ * it. `where` is a space the viewer can edit, `private`, or
719+ * `conversation` (Private plus `view` for these people).
720+ */
721+ createAsAgent(
722+ workspace: string,
723+ agentId: string,
724+ viewer: User,
725+ input: { kind: FolioKind; title: string; content?: FolioContentInput | null; template_id?: string | null; where: { space_id: string } | "private" | { conversation: string[] }; parent_id?: string | null; source?: { title: string; href: string } | null },
726+ ): Promise<Result<FolioRef>>;
727+ editAsAgent(workspace: string, agentId: string, viewer: User, folioId: string, edit: FolioAgentEdit): Promise<Result<FolioAgentEditResult>>;
728+ /** `view` or `comment` for people already in the conversation, when the viewer has `manage`. Never general access, `edit` or `manage`. */
729+ shareAsAgent(workspace: string, agentId: string, viewer: User, folioId: string, input: { user_ids: string[]; role: "view" | "comment" }, audience: FolioAudience): Promise<Result<FolioAccessList>>;
730+ recallForAgent(workspace: string, agentId: string, viewer: User, input: { query: string; limit?: number | null; spaces?: string[] | null; kinds?: FolioKind[] | null }, audience?: FolioAudience | null): Promise<Result<FolioPassage[]>>;
731+ staleForAgent(workspace: string, agentId: string, viewer: User, options?: { repo?: string | null; since?: string | null }, audience?: FolioAudience | null): Promise<Result<Folio[]>>;
732+ /** As the asker narrowed to repositories every audience member can read; spend only when the audience is the asker alone. */
733+ queryDatasetForAgent(workspace: string, agentId: string, viewer: User, input: { query: DatasetQuery } | { folio_id: string; tile_id: string }, audience?: FolioAudience | null): Promise<Result<DatasetResult>>;
734+ markCurrent(workspace: string, viewer: User, folioId: string): Promise<Result<boolean>>;
735+ /** Indexes the workspace's folios again in the background. Workspace owners. */
736+ reindex(workspace: string, viewer: User): Promise<Result<boolean>>;
737+};
738+
739+export function foliosClient(service: ServiceBinding): FoliosApi {
740+ const call = async <T>(method: FolioRpcMethod, args: object): Promise<T> => {
741+ const response = await service.fetch(`https://service/rpc/${method}`, {
742+ method: "POST",
743+ headers: { "content-type": "application/json" },
744+ body: JSON.stringify(args),
745+ });
746+ if (!response.ok) throw new Error(`${method} failed with status ${response.status}`);
747+ return (await response.json()) as T;
748+ };
749+ return {
750+ list: (workspace, viewer, query) => call("folio_list", { workspace, viewer, query }),
751+ sidebar: (workspace, viewer) => call("folio_sidebar", { workspace, viewer }),
752+ folio: (workspace, viewer, folioId) => call("folio", { workspace, viewer, folio_id: folioId }),
753+ create: (workspace, viewer, input) => call("create_folio", { workspace, viewer, input }),
754+ update: (workspace, viewer, folioId, change) => call("update_folio", { workspace, viewer, folio_id: folioId, change }),
755+ move: (workspace, viewer, folioId, move) => call("move_folio", { workspace, viewer, folio_id: folioId, move }),
756+ duplicate: (workspace, viewer, folioId) => call("duplicate_folio", { workspace, viewer, folio_id: folioId }),
757+ trash: (workspace, viewer, folioId) => call("trash_folio", { workspace, viewer, folio_id: folioId }),
758+ restore: (workspace, viewer, folioId) => call("restore_folio", { workspace, viewer, folio_id: folioId }),
759+ delete: (workspace, viewer, folioId) => call("delete_folio", { workspace, viewer, folio_id: folioId }),
760+ trashed: (workspace, viewer) => call("folio_trash", { workspace, viewer }),
761+ favorite: (workspace, viewer, folioId, on) => call("favorite_folio", { workspace, viewer, folio_id: folioId, on }),
762+ content: (workspace, viewer, folioId) => call("folio_content", { workspace, viewer, folio_id: folioId }),
763+ edit: (workspace, viewer, folioId, edit) => call("edit_folio", { workspace, viewer, folio_id: folioId, edit }),
764+ access: (workspace, viewer, folioId) => call("folio_access", { workspace, viewer, folio_id: folioId }),
765+ changeAccess: (workspace, viewer, folioId, change) =>
766+ call(change.op === "grant" || change.op === "revoke" ? "set_folio_grant" : "set_folio_general_access", { workspace, viewer, folio_id: folioId, change }),
767+ requestAccess: (workspace, viewer, folioId, message) => call("request_folio_access", { workspace, viewer, folio_id: folioId, message: message ?? null }),
768+ joinSpace: (workspace, viewer, spaceId) => call("join_space", { workspace, viewer, space_id: spaceId }),
769+ leaveSpace: (workspace, viewer, spaceId) => call("leave_space", { workspace, viewer, space_id: spaceId }),
770+ search: (workspace, viewer, query) => call("search_folios", { workspace, viewer, query }),
771+ versions: (workspace, viewer, folioId) => call("folio_versions", { workspace, viewer, folio_id: folioId }),
772+ version: (workspace, viewer, folioId, versionId) => call("folio_version", { workspace, viewer, folio_id: folioId, version_id: versionId }),
773+ restoreVersion: (workspace, viewer, folioId, versionId) => call("restore_folio_version", { workspace, viewer, folio_id: folioId, version_id: versionId }),
774+ templates: (workspace, viewer, kind) => call("folio_templates", { workspace, viewer, kind: kind ?? null }),
775+ saveTemplate: (workspace, viewer, input) => call("save_folio_template", { workspace, viewer, input }),
776+ deleteTemplate: (workspace, viewer, templateId) => call("delete_folio_template", { workspace, viewer, template_id: templateId }),
777+ export: (workspace, viewer, folioId, format) => call("export_folio", { workspace, viewer, folio_id: folioId, format: format ?? null }),
778+ suggestions: (workspace, viewer, folioId) => call("folio_suggestions", { workspace, viewer, folio_id: folioId }),
779+ decideSuggestion: (workspace, viewer, suggestionId, decision) => call("decide_folio_suggestion", { workspace, viewer, suggestion_id: suggestionId, decision }),
780+ proposals: (workspace, viewer, folioId) => call("folio_proposals", { workspace, viewer, folio_id: folioId }),
781+ decideProposal: (workspace, viewer, proposalId, decision) => call("decide_folio_proposal", { workspace, viewer, proposal_id: proposalId, decision }),
782+ thread: (workspace, viewer, folioId, action) => call("folio_thread", { workspace, viewer, folio_id: folioId, action }),
783+ threads: (workspace, viewer, folioId) => call("folio_threads", { workspace, viewer, folio_id: folioId }),
784+ queryTile: (workspace, viewer, folioId, tileId) => call("query_tile", { workspace, viewer, folio_id: folioId, tile_id: tileId }),
785+ queryDataset: (workspace, viewer, query) => call("query_dataset", { workspace, viewer, query }),
786+ foliosForAgent: (workspace, agentId, viewer, query, audience) => call("folios_for_agent", { workspace, agent_id: agentId, viewer, query, audience: audience ?? null }),
787+ readForAgent: (workspace, agentId, viewer, folioId, audience) => call("read_folio_for_agent", { workspace, agent_id: agentId, viewer, folio_id: folioId, audience: audience ?? null }),
788+ createAsAgent: (workspace, agentId, viewer, input) => call("create_folio_as_agent", { workspace, agent_id: agentId, viewer, input }),
789+ editAsAgent: (workspace, agentId, viewer, folioId, edit) => call("edit_folio_as_agent", { workspace, agent_id: agentId, viewer, folio_id: folioId, edit }),
790+ shareAsAgent: (workspace, agentId, viewer, folioId, input, audience) =>
791+ call("share_folio_as_agent", { workspace, agent_id: agentId, viewer, folio_id: folioId, ...input, audience }),
792+ recallForAgent: (workspace, agentId, viewer, input, audience) =>
793+ call("recall_folios_for_agent", { workspace, agent_id: agentId, viewer, ...input, audience: audience ?? null }),
794+ staleForAgent: (workspace, agentId, viewer, options, audience) =>
795+ call("stale_folios_for_agent", { workspace, agent_id: agentId, viewer, repo: options?.repo ?? null, since: options?.since ?? null, audience: audience ?? null }),
796+ queryDatasetForAgent: (workspace, agentId, viewer, input, audience) =>
797+ call("query_dataset_for_agent", { workspace, agent_id: agentId, viewer, ...input, audience: audience ?? null }),
798+ markCurrent: (workspace, viewer, folioId) => call("mark_folio_current", { workspace, viewer, folio_id: folioId }),
799+ reindex: (workspace, viewer) => call("reindex_folios", { workspace, viewer }),
800+ };
801+}
+1−1
11 const ALPHABET = "0123456789abcdefghjkmnpqrstvwxyz";
22
3−export type IdPrefix = "usr" | "ses" | "tok" | "key" | "rep" | "int" | "att" | "evt" | "dpl" | "prj" | "dom" | "dep" | "dst" | "chn" | "msg" | "agt" | "arp" | "asn" | "mem" | "rtn" | "drf" | "spc" | "pag" | "ver" | "thr" | "cmt" | "sug" | "tpl" | "fil" | "rds";
3+export type IdPrefix = "usr" | "ses" | "tok" | "key" | "rep" | "int" | "att" | "evt" | "dpl" | "prj" | "dom" | "dep" | "dst" | "chn" | "msg" | "agt" | "arp" | "asn" | "mem" | "rtn" | "drf" | "spc" | "pag" | "ver" | "thr" | "cmt" | "sug" | "tpl" | "fil" | "rds" | "fol" | "prp";
44
55 let lastMs = 0;
66 let lastCounter = 0;
+5−0
1414 export * from "./connectors";
1515 export * from "./context";
1616 export * from "./d1";
17+export * from "./datasets";
1718 export * from "./docs";
1819 export * from "./deploy-keys";
1920 export * from "./deployments";
2021 export * from "./events";
22+export * from "./folios";
23+export * from "./folios-dashboard";
24+export * from "./folios-design";
25+export * from "./folios-slides";
2126 export * from "./github";
2227 export * from "./guardrails";
2328 export * from "./identity";
+22−3
3030 | "webhooks"
3131 | "secrets"
3232 | "runners"
33− | "models";
33+ | "models"
34+ | "artifacts";
3435
3536 export type ScopeLevel = "read" | "write" | "run" | "delete" | "admin";
3637
8081 { scope: "models:write", description: "Send model requests through the AI Gateway, which uses the workspace's AI credit" },
8182 ] as const;
8283
83−export type Scope = (typeof SCOPES)[number]["scope"];
84+/**
85+ * Scopes of resources being built that tokens are not offered yet (Rust:
86+ * `Resource::offered`). They parse in Rust and are typed here, but no
87+ * preset, full access, OAuth request or token form hands them out, and no
88+ * operation needs them. When one ships, its rows move to the end of
89+ * `SCOPES` and `SCOPE_RESOURCES`.
90+ */
91+export const UPCOMING_SCOPES = [
92+ { scope: "artifacts:read", description: "List, read and search artifacts you can see, their versions, and the numbers their dashboards show" },
93+ { scope: "artifacts:write", description: "Create, rename, move, edit, trash and restore artifacts, and propose changes to them" },
94+ { scope: "artifacts:admin", description: "Share artifacts, change who can open them, and delete them for good" },
95+] as const;
96+
97+export type Scope = (typeof SCOPES)[number]["scope"] | (typeof UPCOMING_SCOPES)[number]["scope"];
8498
8599 /** Where a resource sits on the token form; only a person's token may hold account ones. */
86100 export type ResourceGroup = "repository" | "workspace" | "account";
110124 { resource: "models", label: "AI Gateway", group: "workspace" },
111125 ];
112126
127+/** Resources not offered yet, as `UPCOMING_SCOPES`: settings never show them. */
128+export const UPCOMING_RESOURCES: { resource: ScopeResource; label: string; group: ResourceGroup }[] = [
129+ { resource: "artifacts", label: "Artifacts", group: "workspace" },
130+];
131+
113132 const LEVEL_ORDER: Record<ScopeLevel, number> = { read: 0, write: 1, run: 2, delete: 3, admin: 4 };
114133
115134 export function scopeResource(scope: Scope): ScopeResource {
131150 }
132151
133152 export function describeScope(scope: Scope): string {
134− return SCOPES.find((row) => row.scope === scope)?.description ?? scope;
153+ return [...SCOPES, ...UPCOMING_SCOPES].find((row) => row.scope === scope)?.description ?? scope;
135154 }
136155
137156 /** Whether holding `held` gives `needed`: the same resource, at its level or lower. */