beck_core/
secure.rs

1//! §3.5's security properties, as checks rather than as intentions.
2//!
3//! [`docs/03-type-and-effect-system.md`](../../../../../docs/03-type-and-effect-system.md) §3.5:
4//! "Placement-as-type makes vulnerability classes *unrepresentable*." This module is where three of
5//! that table's rows stop being prose:
6//!
7//! | property | mechanism, here |
8//! |---|---|
9//! | secrets cannot reach the browser | [`sendable`] at every tier crossing, and `secret[T]` is not |
10//! | the log holds data, never code or views | [`storable`], checked at compile time |
11//! | authority is one chokepoint | a `cap.*` effect is discharged only inside `decide`'s validator |
12//!
13//! # Two axes, not one
14//!
15//! "Is it stored" and "does it cross" are independent questions, and conflating them is how systems
16//! end up choosing between an incomplete audit trail and a leak. Beck answers them separately:
17//!
18//! | | Sendable | Storable | |
19//! |---|---|---|---|
20//! | ordinary data | ✓ | ✓ | a `Str`, a model of them |
21//! | `Html` | ✓ | ✗ | a patch stream crosses; replay recomputes a view rather than reading it back |
22//! | **`internal[T]`** | ✗ | ✓ | why an account was suspended: recorded forever, never rendered |
23//! | `secret[T]`, a closure | ✗ | ✗ | a token must reach neither the browser nor the log (§3.7 F5) |
24//!
25//! The third row was empty until the question "what if you want a table but not a data object" was
26//! asked directly. Without it, an event that has to record a fact a client must never see forces a
27//! choice between dropping it from the log and trusting that no view renders it.
28//!
29//! The others in that table are checked elsewhere and named in the Phase 2 report: `ingress`/
30//! `durable` being undischargeable on the client is [`crate::place`]; escaping in `html""` is
31//! [`crate::html`]; effect-derived NetworkPolicy and grants are `beck-infra`; the macro phase's
32//! capability restriction is `beck-macro`; and the tamper-evident history is the replay harness.
33//!
34//! # What "crosses a boundary" means concretely
35//!
36//! Not every value in a program crosses. In Phase 2's topology exactly three do, and each is a type
37//! the splitter already names:
38//!
39//! * the **command** type — the browser's entire write surface, client → server;
40//! * the **event** type — appended to the log, and read back by replay;
41//! * the **state** type — the fold's accumulator, which the view consumes and whose rendering the
42//!   client subscribes to.
43//!
44//! So the Sendable check is not a whole-program dataflow analysis; it is three types and the
45//! transitive closure of their fields. That is a much stronger position than it sounds: a secret
46//! cannot reach the browser without being *reachable from* one of those three, and if it is, the
47//! type says so.
48
49use std::collections::{BTreeMap, BTreeSet};
50use std::sync::Arc;
51
52use beck_diag::{Diagnostic, Diagnostics, Span};
53
54use crate::check::Program;
55use crate::core::{CoreKind, Prim};
56use crate::ty::{Effect, Tier, Ty, TyDecl};
57
58/// Why a type may not cross a boundary.
59#[derive(Clone, Debug, PartialEq, Eq)]
60pub struct NotSendable {
61    /// The offending type, by name — `secret[Str]`, or the function type.
62    pub offender: String,
63    /// The field path that reaches it: `State.config.api_key`.
64    pub path: Vec<String>,
65    pub why: &'static str,
66}
67
68impl NotSendable {
69    pub fn flow(&self) -> String {
70        self.path.join(".")
71    }
72}
73
74/// May a value of this type cross a tier boundary? §3.5: "Boundary crossings require `Sendable`;
75/// `secret[T]` isn't."
76pub fn sendable(ty: &Ty, types: &BTreeMap<Arc<str>, TyDecl>) -> Result<(), NotSendable> {
77    check(ty, types, Rule::Sendable)
78}
79
80/// May a value of this type be written to the log?
81///
82/// Strictly stronger than [`sendable`]: a rendered view can cross a boundary — that is what a patch
83/// stream *is* — but it cannot be stored, because replay must reconstruct it rather than read it
84/// back. [`docs/19-phase-1-report.md`](../../../../../docs/19-phase-1-report.md) §19.9 predicted this:
85/// the runtime refusal in `value_to_repr` was "the right thing to have while the proof is missing",
86/// and this is the proof. The refusal stays, now unreachable from a program that compiles.
87pub fn storable(ty: &Ty, types: &BTreeMap<Arc<str>, TyDecl>) -> Result<(), NotSendable> {
88    check(ty, types, Rule::Storable)
89}
90
91#[derive(Clone, Copy, PartialEq, Eq)]
92enum Rule {
93    Sendable,
94    Storable,
95}
96
97fn check(ty: &Ty, types: &BTreeMap<Arc<str>, TyDecl>, rule: Rule) -> Result<(), NotSendable> {
98    fn go(
99        ty: &Ty,
100        types: &BTreeMap<Arc<str>, TyDecl>,
101        rule: Rule,
102        path: &mut Vec<String>,
103        seen: &mut BTreeSet<Arc<str>>,
104    ) -> Result<(), NotSendable> {
105        let fail = |offender: String, why: &'static str, path: &Vec<String>| {
106            Err(NotSendable {
107                offender,
108                path: path.clone(),
109                why,
110            })
111        };
112        match ty {
113            Ty::Var(_) => Ok(()),
114            Ty::Fun(..) => fail(
115                format!("{ty}"),
116                "a function is code, and code does not cross a boundary as data",
117                path,
118            ),
119            Ty::Con(name, args) => {
120                match name.as_ref() {
121                    Ty::SECRET => {
122                        return fail(
123                            format!("{ty}"),
124                            "`secret[T]` is deliberately not Sendable: that is the whole mechanism",
125                            path,
126                        )
127                    }
128                    // The other half of the pair, and the only asymmetric case: `internal[T]` is
129                    // *storable* — that is what it exists for — and never crosses.
130                    Ty::INTERNAL if rule == Rule::Sendable => {
131                        return fail(
132                            format!("{ty}"),
133                            "`internal[T]` is recorded and never shown: it may be written to the \
134                             log and may not cross a boundary",
135                            path,
136                        )
137                    }
138                    Ty::HTML | Ty::ATTR if rule == Rule::Storable => {
139                        return fail(
140                            format!("{ty}"),
141                            "a view is derived from state, so storing one would make replay read \
142                             it back rather than recompute it",
143                            path,
144                        )
145                    }
146                    _ => {}
147                }
148                for (i, a) in args.iter().enumerate() {
149                    path.push(format!("[{i}]"));
150                    go(a, types, rule, path, seen)?;
151                    path.pop();
152                }
153                let Some(decl) = types.get(name.as_ref()) else {
154                    return Ok(());
155                };
156                if !seen.insert(name.clone()) {
157                    // A recursive type: its fields have already been walked.
158                    return Ok(());
159                }
160                let out = match decl {
161                    TyDecl::Model { fields, .. } => {
162                        for (f, t) in fields {
163                            path.push(f.to_string());
164                            go(t, types, rule, path, seen)?;
165                            path.pop();
166                        }
167                        Ok(())
168                    }
169                    TyDecl::Union { variants, .. } => {
170                        for v in variants {
171                            for (f, t) in &v.fields {
172                                path.push(format!("{}.{f}", v.name));
173                                go(t, types, rule, path, seen)?;
174                                path.pop();
175                            }
176                        }
177                        Ok(())
178                    }
179                    TyDecl::Newtype { inner, .. } | TyDecl::Alias { ty: inner, .. } => {
180                        go(inner, types, rule, path, seen)
181                    }
182                };
183                seen.remove(name.as_ref());
184                out
185            }
186        }
187    }
188    let mut path = vec![format!("{ty}")];
189    let mut seen = BTreeSet::new();
190    go(ty, types, rule, &mut path, &mut seen)
191}
192
193/// One step of `beck explain flow <T>`: where a type is reachable, and whether that is allowed.
194#[derive(Clone, Debug)]
195pub struct Reach {
196    pub what: Arc<str>,
197    pub tier: Tier,
198    pub blocked: Option<&'static str>,
199}
200
201/// §4.7's `beck explain flow ApiKey`: every definition whose signature mentions a type, the tier it
202/// runs on, and whether that is a leak.
203pub fn flow(program: &Program, ty_name: &str) -> Vec<Reach> {
204    let mut out = Vec::new();
205    for name in &program.def_order {
206        let Some(d) = program.defs.get(name) else {
207            continue;
208        };
209        let mentions = std::iter::once(&d.ret)
210            .chain(d.params.iter().map(|(_, _, t)| t))
211            .any(|t| mentions_type(t, ty_name, &program.types));
212        if !mentions {
213            continue;
214        }
215        out.push(Reach {
216            what: d.name.clone(),
217            tier: d.tier,
218            blocked: (d.tier == Tier::Client).then_some("a client cannot hold a `secret[T]`"),
219        });
220    }
221    for s in &program.signals {
222        if !mentions_type(&s.ty, ty_name, &program.types) {
223            continue;
224        }
225        out.push(Reach {
226            what: s.name.clone(),
227            tier: s.tier,
228            blocked: (s.tier == Tier::Client).then_some("a client cannot hold a `secret[T]`"),
229        });
230    }
231    out
232}
233
234fn mentions_type(ty: &Ty, name: &str, types: &BTreeMap<Arc<str>, TyDecl>) -> bool {
235    fn go(
236        ty: &Ty,
237        name: &str,
238        types: &BTreeMap<Arc<str>, TyDecl>,
239        seen: &mut BTreeSet<Arc<str>>,
240    ) -> bool {
241        match ty {
242            Ty::Var(_) => false,
243            Ty::Fun(ps, r, _) => {
244                ps.iter().any(|p| go(p, name, types, seen)) || go(r, name, types, seen)
245            }
246            Ty::Con(n, args) => {
247                if n.as_ref() == name {
248                    return true;
249                }
250                if args.iter().any(|a| go(a, name, types, seen)) {
251                    return true;
252                }
253                if !seen.insert(n.clone()) {
254                    return false;
255                }
256                match types.get(n.as_ref()) {
257                    Some(TyDecl::Model { fields, .. }) => {
258                        fields.iter().any(|(_, t)| go(t, name, types, seen))
259                    }
260                    Some(TyDecl::Union { variants, .. }) => variants
261                        .iter()
262                        .any(|v| v.fields.iter().any(|(_, t)| go(t, name, types, seen))),
263                    Some(TyDecl::Newtype { inner, .. }) | Some(TyDecl::Alias { ty: inner, .. }) => {
264                        go(inner, name, types, seen)
265                    }
266                    None => false,
267                }
268            }
269        }
270    }
271    go(ty, name, types, &mut BTreeSet::new())
272}
273
274/// Run §3.5's checks over a placed program.
275///
276/// Split in two because the two halves have different *scopes*, which multi-module compilation
277/// made visible and single-file compilation could not:
278///
279/// * **Boundaries** are a property of one module's types and placements. A module that puts a
280///   secret in something the client subscribes to is wrong on its own terms, and should be told so
281///   without waiting for whatever imports it.
282/// * **Capability discharge** is a property of the *whole program*, because the chokepoint is one
283///   `decide` node and it lives wherever the wiring lives. A policy module holding `cap.session`
284///   is not an error; it is a policy module. It becomes an error only if, once linked, nothing
285///   reaches it from the validator.
286///
287/// Running the second per module reported every correctly-factored authority module as a violation
288/// — which is how this distinction was found.
289pub fn check_security(program: &Program, diags: &mut Diagnostics) {
290    check_boundaries(program, diags);
291    check_capabilities(program, diags);
292}
293
294/// The per-module half.
295pub fn check_boundaries(program: &Program, diags: &mut Diagnostics) {
296    boundaries(program, diags);
297}
298
299/// The whole-program half — run once, after linking.
300pub fn check_capabilities(program: &Program, diags: &mut Diagnostics) {
301    capabilities(program, diags);
302}
303
304/// The three types that cross, and what they are allowed to contain.
305fn boundaries(program: &Program, diags: &mut Diagnostics) {
306    for s in &program.signals {
307        // The durable accumulator, and the events that build it, are what the log holds.
308        if s.effects.contains(&Effect::Durable) {
309            let state = element(&s.ty);
310            if let Err(bad) = storable(&state, &program.types) {
311                reject(
312                    diags,
313                    "B0411",
314                    format!("`{}` is durable, so its state must be storable", s.name),
315                    s.span,
316                    &bad,
317                    "the log is the only description of this program's history; a value it cannot \
318                     read back is a state replay would not reproduce",
319                );
320            }
321        }
322        // Anything the browser subscribes to crosses to the browser.
323        if s.tier == Tier::Client {
324            let carried = element(&s.ty);
325            if let Err(bad) = sendable(&carried, &program.types) {
326                reject(
327                    diags,
328                    "B0410",
329                    format!(
330                        "`{}` runs on the client, so its value must be Sendable",
331                        s.name
332                    ),
333                    s.span,
334                    &bad,
335                    "this value crosses to the browser; §3.5's whole claim is that the compiler \
336                     proves it cannot carry a secret",
337                );
338            }
339        }
340    }
341
342    // The command union is the client's entire write surface (§3.5), so it crosses by definition.
343    if let Some(TyDecl::Union { .. }) = program.types.get("Command") {
344        if let Err(bad) = sendable(&Ty::con("Command"), &program.types) {
345            let span = program
346                .signals
347                .first()
348                .map(|s| s.span)
349                .unwrap_or(Span::NONE);
350            reject(
351                diags,
352                "B0410",
353                "`Command` is what clients send, so it must be Sendable".to_string(),
354                span,
355                &bad,
356                "a command is minted in the browser: a secret in one would be a secret the browser \
357                 already had",
358            );
359        }
360    }
361
362    // A definition placed on the client has its whole signature cross with it.
363    for name in &program.def_order {
364        let Some(d) = program.defs.get(name) else {
365            continue;
366        };
367        if d.tier != Tier::Client {
368            continue;
369        }
370        for t in std::iter::once(&d.ret).chain(d.params.iter().map(|(_, _, t)| t)) {
371            if let Err(bad) = sendable(t, &program.types) {
372                // Deliberately not "anything unsendable": a client-placed definition may perfectly
373                // well take a closure or return `Html`, and those fail `sendable` for reasons that
374                // have nothing to do with a leak. The two that are leaks are named.
375                let kind = if bad.offender.starts_with("secret[") {
376                    "a secret"
377                } else if bad.offender.starts_with("internal[") {
378                    "an internal fact"
379                } else {
380                    continue;
381                };
382                reject(
383                    diags,
384                    "B0410",
385                    format!("`{}` runs on the client and handles {kind}", d.name),
386                    d.span,
387                    &bad,
388                    "`beck explain flow` shows the whole path; the fix is to keep the \
389                     definition on a tier that can hold it",
390                );
391            }
392        }
393    }
394}
395
396fn reject(
397    diags: &mut Diagnostics,
398    code: &'static str,
399    message: String,
400    span: Span,
401    bad: &NotSendable,
402    note: &str,
403) {
404    diags.push(
405        Diagnostic::error(code, message, span)
406            .with_primary_label(format!("`{}` reaches it at `{}`", bad.offender, bad.flow()))
407            .with_note(bad.why)
408            .with_note(note.to_string()),
409    );
410}
411
412fn element(t: &Ty) -> Ty {
413    match t {
414        Ty::Con(n, args)
415            if (n.as_ref() == Ty::SIGNAL || n.as_ref() == Ty::STREAM) && args.len() == 1 =>
416        {
417            args[0].clone()
418        }
419        other => other.clone(),
420    }
421}
422
423/// §3.5: "Only `validate` — the `ingress` consumer, holding `Session` capabilities — turns commands
424/// into events; forgetting an auth check means the `cap.*` effect goes undischarged."
425///
426/// A capability is *held* at exactly one place in a Beck program: the validator `decide` is given,
427/// because that is the only function handed a `Proposal`, and a `Proposal` is the only thing
428/// carrying a `Session`. So a `cap.*` effect anywhere the validator does not reach is a capability
429/// nobody can discharge — a requirement with no holder, which is what a missing auth check looks
430/// like from the type system's side.
431fn capabilities(program: &Program, diags: &mut Diagnostics) {
432    // A program with no `decide` node has no chokepoint, so it is a library rather than an
433    // application, and "this capability has no holder" is not a statement anyone can make about it.
434    // The check runs again over the linked program, which is where the question has an answer.
435    if !has_chokepoint(program) {
436        return;
437    }
438    let authorised = reachable_from_validator(program);
439    for name in &program.def_order {
440        let Some(d) = program.defs.get(name) else {
441            continue;
442        };
443        let caps: Vec<&Effect> = d
444            .effects
445            .iter()
446            .filter(|e| matches!(e, Effect::Cap(_)))
447            .collect();
448        if caps.is_empty() || authorised.contains(name) {
449            continue;
450        }
451        let names: Vec<String> = caps.iter().map(|e| e.name()).collect();
452        diags.push(
453            Diagnostic::error(
454                "B0412",
455                format!("`{name}` requires a capability nothing can discharge"),
456                d.span,
457            )
458            .with_primary_label(format!("needs {{{}}}", names.join(", ")))
459            .with_note(
460                "a `Session` reaches exactly one place in a Beck program: the validator `decide` is \
461                 given, which is the only function handed a `Proposal`. Authority is one chokepoint \
462                 (docs/03 §3.5), so a capability required outside it has no holder",
463            )
464            .with_fix(
465                "call this from `validate` — or, if it genuinely needs no authority, drop the \
466                 `cap.*` from its `uses`",
467            ),
468        );
469    }
470}
471
472/// Does this program have an authority chokepoint at all?
473fn has_chokepoint(program: &Program) -> bool {
474    program.signals.iter().any(|s| {
475        matches!(
476            &s.expr.kind,
477            CoreKind::Prim {
478                op: Prim::Decide,
479                ..
480            }
481        )
482    })
483}
484
485/// Every definition reachable from the validator `decide` was given.
486fn reachable_from_validator(program: &Program) -> BTreeSet<Arc<str>> {
487    let mut roots: Vec<Arc<str>> = Vec::new();
488    for s in &program.signals {
489        if let CoreKind::Prim {
490            op: Prim::Decide,
491            args,
492        } = &s.expr.kind
493        {
494            if let Some(v) = args.get(2) {
495                let mut names = BTreeSet::new();
496                crate::place::mentions(v, &mut names);
497                roots.extend(names);
498            }
499        }
500    }
501    let mut out: BTreeSet<Arc<str>> = BTreeSet::new();
502    while let Some(n) = roots.pop() {
503        if !out.insert(n.clone()) {
504            continue;
505        }
506        if let Some(d) = program.defs.get(&n) {
507            let mut names = BTreeSet::new();
508            crate::place::mentions(&d.body, &mut names);
509            roots.extend(names);
510        }
511    }
512    out
513}
514
515#[cfg(test)]
516mod tests {
517    use super::*;
518    use crate::{check_str, compile_str};
519
520    fn types() -> BTreeMap<Arc<str>, TyDecl> {
521        BTreeMap::from([
522            (
523                Arc::from("Config"),
524                TyDecl::Model {
525                    name: Arc::from("Config"),
526                    params: Vec::new(),
527                    fields: vec![
528                        (Arc::from("host"), Ty::str_()),
529                        (Arc::from("key"), Ty::secret(Ty::str_())),
530                    ],
531                },
532            ),
533            (
534                Arc::from("State"),
535                TyDecl::Model {
536                    name: Arc::from("State"),
537                    params: Vec::new(),
538                    fields: vec![(Arc::from("config"), Ty::con("Config"))],
539                },
540            ),
541        ])
542    }
543
544    #[test]
545    fn a_secret_is_not_sendable_however_deeply_it_is_buried() {
546        let t = types();
547        assert!(sendable(&Ty::str_(), &t).is_ok());
548        let bad = sendable(&Ty::con("State"), &t).expect_err("State reaches a secret");
549        // The path is the diagnostic: §4.7's `beck explain flow` is this string.
550        assert_eq!(bad.flow(), "State.config.key");
551        assert_eq!(bad.offender, "secret[Str]");
552        // …and through a collection, too.
553        assert!(sendable(&Ty::list(Ty::con("Config")), &t).is_err());
554        assert!(sendable(&Ty::map(Ty::str_(), Ty::con("Config")), &t).is_err());
555    }
556
557    #[test]
558    fn a_view_may_cross_a_boundary_but_may_not_be_stored() {
559        // The distinction docs/19 §19.9 could not express: a patch stream is a view crossing a
560        // boundary, and it is fine; a view *in the log* is a state replay would read rather than
561        // recompute.
562        let t = types();
563        assert!(sendable(&Ty::html(), &t).is_ok());
564        assert!(storable(&Ty::html(), &t).is_err());
565    }
566
567    #[test]
568    fn a_recursive_type_terminates() {
569        let t = BTreeMap::from([(
570            Arc::from("Tree"),
571            TyDecl::Model {
572                name: Arc::from("Tree"),
573                params: Vec::new(),
574                fields: vec![(Arc::from("kids"), Ty::list(Ty::con("Tree")))],
575            },
576        )]);
577        assert!(sendable(&Ty::con("Tree"), &t).is_ok());
578    }
579
580    #[test]
581    fn a_state_that_caches_a_view_is_refused_at_compile_time() {
582        // The exact program docs/19 §19.9 named as compiling today and writing `unit` into the log:
583        // "`model State: cached: Html` compiles today, and the encoder would have written `unit`
584        // into a snapshot — silently". It does not compile now.
585        let src = crate::split::tests::TODO.replace(
586            "model State:\n    todos: Map[Id, Todo]",
587            "model State:\n    todos: Map[Id, Todo]\n    cached: Html",
588        );
589        let (_, d, _) = compile_str("t.beck", &src);
590        let codes: Vec<&str> = d.iter().map(|x| x.code).collect();
591        assert!(codes.contains(&"B0411"), "got {codes:?}");
592    }
593
594    #[test]
595    fn a_secret_in_the_command_union_is_refused() {
596        // "Clients can only *propose*": the command union is the browser's entire write surface, so
597        // a secret in one would be a secret the browser already held.
598        let src = crate::split::tests::TODO.replace(
599            "union Command:\n    Add(id: Id, text: Str)",
600            "union Command:\n    Add(id: Id, text: Str, token: secret[Str])",
601        );
602        let (_, d, _) = compile_str("t.beck", &src);
603        let codes: Vec<&str> = d.iter().map(|x| x.code).collect();
604        assert!(codes.contains(&"B0410"), "got {codes:?}");
605    }
606
607    #[test]
608    fn a_capability_required_outside_the_chokepoint_has_no_holder() {
609        let src = crate::split::tests::TODO.replace(
610            "def done_class(t: Todo) -> Str:",
611            "def audit(t: Todo) -> Str uses cap.admin:\n    return t.text\n\n\
612             def done_class(t: Todo) -> Str:",
613        );
614        let (_, d, _) = compile_str("t.beck", &src);
615        let codes: Vec<&str> = d.iter().map(|x| x.code).collect();
616        assert!(codes.contains(&"B0412"), "got {codes:?}");
617    }
618
619    #[test]
620    fn a_capability_required_inside_the_chokepoint_is_exactly_what_it_is_for() {
621        // The same effect, reached from `validate`, is not an error — it is the design. And it
622        // moves the whole authority path to the server, because no other tier discharges `cap.*`.
623        let src = crate::split::tests::TODO
624            .replace(
625                "def owned(s: State, p: Proposal, id: Id, evs: list[Event])",
626                "def admin(p: Proposal) -> Bool uses cap.admin:\n\
627                 \x20   return p.session.actor != \"\"\n\n\
628                 def owned(s: State, p: Proposal, id: Id, evs: list[Event])",
629            )
630            .replace(
631                "    match map_get(s.todos, id):\n        case Some(value):\n            if value.owner != p.session.actor:",
632                "    match map_get(s.todos, id):\n        case Some(value):\n            if not admin(p):",
633            );
634        let (program, d, map) = check_str("t.beck", &src);
635        assert!(!d.has_errors(), "{}", d.render(&map));
636        let mut diags = Diagnostics::new();
637        let solution = crate::place::solve(&program, None);
638        let mut program = program;
639        crate::place::apply(&mut program, &solution);
640        check_security(&program, &mut diags);
641        assert!(
642            !diags.iter().any(|x| x.code == "B0412"),
643            "{}",
644            diags.render(&map)
645        );
646        assert_eq!(
647            program.defs["admin"].tier,
648            Tier::Server,
649            "only the server holds a capability"
650        );
651    }
652
653    #[test]
654    fn explain_flow_names_the_definitions_a_type_reaches() {
655        let src = "\
656model Config:
657    key: secret[Str]
658
659def load() -> Config uses env:
660    return Config(key=secret_env(\"API_KEY\"))
661
662def host(c: Config) -> Str:
663    return \"api.example.com\"
664";
665        let (program, d, map) = check_str("t.beck", src);
666        assert!(!d.has_errors(), "{}", d.render(&map));
667        let reached: Vec<String> = flow(&program, "Config")
668            .into_iter()
669            .map(|r| r.what.to_string())
670            .collect();
671        assert_eq!(reached, ["load", "host"]);
672    }
673}
674
675#[cfg(test)]
676mod quadrants {
677    use super::*;
678
679    /// The four combinations, asserted as a table, because the point of `internal[T]` is that the
680    /// two axes are *independent* — and a table is the only way to see that they are.
681    #[test]
682    fn the_two_axes_are_independent() {
683        let types: BTreeMap<Arc<str>, TyDecl> = BTreeMap::new();
684        let quad = |t: &Ty| (sendable(t, &types).is_ok(), storable(t, &types).is_ok());
685
686        assert_eq!(quad(&Ty::str_()), (true, true), "ordinary data does both");
687        assert_eq!(
688            quad(&Ty::html()),
689            (true, false),
690            "a view crosses as patches and is never read back from the log"
691        );
692        assert_eq!(
693            quad(&Ty::internal(Ty::str_())),
694            (false, true),
695            "`internal[T]` is the quadrant `secret[T]` alone left empty"
696        );
697        assert_eq!(
698            quad(&Ty::secret(Ty::str_())),
699            (false, false),
700            "a token reaches neither the browser nor the log (§3.7 F5)"
701        );
702        assert_eq!(
703            quad(&Ty::fun(vec![Ty::int()], Ty::int())),
704            (false, false),
705            "code is not data in either direction"
706        );
707    }
708
709    #[test]
710    fn an_internal_field_is_found_however_deeply_it_is_buried() {
711        let types = BTreeMap::from([(
712            Arc::from("Suspension"),
713            TyDecl::Model {
714                name: Arc::from("Suspension"),
715                params: Vec::new(),
716                fields: vec![
717                    (Arc::from("at"), Ty::int()),
718                    (Arc::from("reason"), Ty::internal(Ty::str_())),
719                ],
720            },
721        )]);
722        let bad = sendable(&Ty::list(Ty::con("Suspension")), &types)
723            .expect_err("a list of them still cannot cross");
724        assert_eq!(bad.flow(), "list[Suspension].[0].reason");
725        // …and the same type is perfectly storable, which is the whole point.
726        assert!(storable(&Ty::list(Ty::con("Suspension")), &types).is_ok());
727    }
728}