Repository: tweag/nickel
Pull request #1964 (2024-06-18 16:35:07)

Add dedicated node and constructor for general custom contracts
Continue to address #1466.

After introducing a special AST node and constructor for contracts built from a predicate in #1955, this PR introduces another variant for generic custom contracts. For backward compatibility reasons, we still need to support custom contracts given as naked functions, but this PR migrates examples, the stdlib and the documentation to using this new node through the `std.contract.custom` constructor. The only module that isn't migrated is the `internals` module, because it was harder to do without breaking things (it can surely be done, but it requires a bit more changes), and because those primitive contracts aren't really user-facing and thus it's less important for error messages and the like that they follow the new convention. As this PR is already sizeable, this is left for future work (or maybe we don't want to do it at all).

The documentation is updated in way that already assumes the as-of-yet nonexistent `std.contract.from_validator`, planned in a follow-up PR, because it was harder to pretend that it won't exist, or put differently, it would have required more effort to update the documentation once again with this new intermediate type of custom contract than to just leave a placeholder and pretend it already exists.

#1955 introduced predicates as functions, where the AST node also stores the function's parameter - the idea was to be more typed, and avoid having something else than a function lying in a `CustomContract` node. Unfortunately, I realized while working on this PR that custom contracts as predicates can actually also be match expressions. We could add a variant for things that are either functions or match expressions, but it starts to be verbose and to cause more code duplication. For now, I reverted to using a generic `RichTerm` there, where the only hypothesis is that it's a WHNF that can be applied to some arguments.


Patch:
diff --git a/core/src/eval/mod.rs b/core/src/eval/mod.rs
index 11bfa494..d6347c8c 100644
--- a/core/src/eval/mod.rs
+++ b/core/src/eval/mod.rs
@@ -90,8 +90,8 @@ use crate::{
         pattern::compile::Compile,
         record::{Field, RecordData},
         string::NickelString,
-        BinaryOp, BindingType, CustomContract, LetAttrs, MatchBranch, MatchData, RecordOpKind,
-        RichTerm, RuntimeContract, StrChunk, Term, UnaryOp,
+        BinaryOp, BindingType, LetAttrs, MatchBranch, MatchData, RecordOpKind, RichTerm,
+        RuntimeContract, StrChunk, Term, UnaryOp,
     },
 };
 
@@ -1151,7 +1151,7 @@ pub fn subst<C: Cache>(
         // Do not substitute under lambdas: mutually recursive function could cause an infinite
         // loop. Although avoidable, this requires some care and is not currently needed.
         | v @ Term::Fun(..)
-        | v @ Term::CustomContract(CustomContract::Predicate(..))
+        | v @ Term::CustomContract(_)
         | v @ Term::Lbl(_)
         | v @ Term::ForeignId(_)
         | v @ Term::SealingKey(_)
diff --git a/core/src/eval/operation.rs b/core/src/eval/operation.rs
index 32c05ff3..a9e0e88e 100644
--- a/core/src/eval/operation.rs
+++ b/core/src/eval/operation.rs
@@ -233,6 +233,7 @@ impl<R: ImportResolver, C: Cache> VirtualMachine<R, C> {
                     Term::Str(_) => "String",
                     Term::Enum(_) | Term::EnumVariant { .. } => "Enum",
                     Term::Fun(..) | Term::Match { .. } => "Function",
+                    Term::CustomContract(_) => "CustomContract",
                     Term::Array(..) => "Array",
                     Term::Record(..) | Term::RecRecord(..) => "Record",
                     Term::Lbl(..) => "Label",
@@ -1195,18 +1196,44 @@ impl<R: ImportResolver, C: Cache> VirtualMachine<R, C> {
                 })
             }
             UnaryOp::ContractFromPredicate => {
-                if let Term::Fun(id, body) = &*t {
+                if matches!(&*t, Term::Fun(..) | Term::Match(_)) {
                     Ok(Closure {
                         body: RichTerm::new(
-                            Term::CustomContract(CustomContract::Predicate(*id, body.clone())),
+                            Term::CustomContract(CustomContract::Predicate(RichTerm {
+                                term: t,
+                                pos,
+                            })),
                             pos,
                         ),
                         env,
                     })
                 } else {
-                    Err(mk_type_error!("contract_from_predicate", "Function"))
+                    Err(mk_type_error!(
+                        "contract/from_predicate",
+                        "Function or MatchExpression"
+                    ))
+                }
+            }
+            UnaryOp::ContractCustom => {
+                if matches!(&*t, Term::Fun(..) | Term::Match(_)) {
+                    Ok(Closure {
+                        body: RichTerm::new(
+                            Term::CustomContract(CustomContract::PartialIdentity(RichTerm {
+                                term: t,
+                                pos,
+                            })),
+                            pos,
+                        ),
+                        env,
+                    })
+                } else {
+                    Err(mk_type_error!(
+                        "contract/custom",
+                        "Function or MatchExpression"
+                    ))
                 }
             }
+
             #[cfg(feature = "nix-experimental")]
             UnaryOp::EvalNix => {
                 if let Term::Str(s) = &*t {
@@ -1540,8 +1567,8 @@ impl<R: ImportResolver, C: Cache> VirtualMachine<R, C> {
                         pos2.into_inherited(),
                     );
 
-                    match *t1 {
-                        Term::Type(ref typ) => Ok(Closure {
+                    match &*t1 {
+                        Term::Type(typ) => Ok(Closure {
                             body: typ.contract()?,
                             env: env1,
                         }),
@@ -1552,16 +1579,15 @@ impl<R: ImportResolver, C: Cache> VirtualMachine<R, C> {
                             },
                             env: env1,
                         }),
-                        Term::CustomContract(CustomContract::Predicate(ref id, ref body)) => {
-                            Ok(Closure {
-                                body: mk_app!(
-                                    internals::predicate_to_ctr(),
-                                    RichTerm::new(Term::Fun(*id, body.clone()), pos1)
-                                )
+                        Term::CustomContract(CustomContract::PartialIdentity(ctr)) => Ok(Closure {
+                            body: ctr.clone(),
+                            env: env1,
+                        }),
+                        Term::CustomContract(CustomContract::Predicate(pred)) => Ok(Closure {
+                            body: mk_app!(internals::predicate_to_ctr(), pred.clone())
                                 .with_pos(pos1),
-                                env: env1,
-                            })
-                        }
+                            env: env1,
+                        }),
                         Term::Record(..) => {
                             let closurized = RichTerm {
                                 term: t1,
diff --git a/core/src/parser/grammar.lalrpop b/core/src/parser/grammar.lalrpop
index 00c711e2..8eb00a4d 100644
--- a/core/src/parser/grammar.lalrpop
+++ b/core/src/parser/grammar.lalrpop
@@ -1082,6 +1082,7 @@ UOp: UnaryOp = {
     "label/go_array" => UnaryOp::LabelGoArray,
     "label/go_dict" => UnaryOp::LabelGoDict,
     "contract/from_predicate" => UnaryOp::ContractFromPredicate,
+    "contract/custom" => UnaryOp::ContractCustom,
     "enum/embed" <Ident> => UnaryOp::EnumEmbed(<>),
     "array/map"  => UnaryOp::ArrayMap,
     "array/generate" => UnaryOp::ArrayGen,
@@ -1514,6 +1515,7 @@ extern {
         "contract/array_lazy_app" => Token::Normal(NormalToken::ContractArrayLazyApp),
         "contract/record_lazy_app" => Token::Normal(NormalToken::ContractRecordLazyApp),
         "contract/from_predicate" => Token::Normal(NormalToken::ContractFromPredicate),
+        "contract/custom" => Token::Normal(NormalToken::ContractCustom),
         "op force" => Token::Normal(NormalToken::OpForce),
         "blame" => Token::Normal(NormalToken::Blame),
         "label/flip_polarity" => Token::Normal(NormalToken::LabelFlipPol),
diff --git a/core/src/parser/lexer.rs b/core/src/parser/lexer.rs
index d816b74a..94c0c7da 100644
--- a/core/src/parser/lexer.rs
+++ b/core/src/parser/lexer.rs
@@ -202,6 +202,8 @@ pub enum NormalToken<'input> {
     ContractRecordLazyApp,
     #[token("%contract/from_predicate%")]
     ContractFromPredicate,
+    #[token("%contract/custom%")]
+    ContractCustom,
     #[token("%blame%")]
     Blame,
     #[token("%label/flip_polarity%")]
diff --git a/core/src/pretty.rs b/core/src/pretty.rs
index 98bd078d..9c4cad7a 100644
--- a/core/src/pretty.rs
+++ b/core/src/pretty.rs
@@ -268,7 +268,7 @@ where
 
         loop {
             match body.as_ref() {
-                Term::Fun(id, rt) | Term::CustomContract(CustomContract::Predicate(id, rt)) => {
+                Term::Fun(id, rt) => {
                     builder = docs![self, builder, self.line(), self.as_string(id)];
                     body = rt;
                 }
@@ -821,18 +821,21 @@ where
             Str(v) => allocator.escaped_string(v).double_quotes(),
             StrChunks(chunks) => allocator.chunks(chunks, StringRenderStyle::Multiline),
             Fun(id, body) => allocator.function(allocator.as_string(id), body),
+            CustomContract(ContractNode::PartialIdentity(ctr)) => docs![
+                allocator,
+                "%contract/custom%",
+                docs![allocator, allocator.line(), ctr.pretty(allocator).parens()]
+                    .nest(2)
+                    .group()
+            ],
             FunPattern(pat, body) => allocator.function(allocator.pat_with_parens(pat), body),
             // Format this as the application `std.contract.from_predicate <pred>`.
-            CustomContract(ContractNode::Predicate(id, pred)) => docs![
+            CustomContract(ContractNode::Predicate(pred)) => docs![
                 allocator,
                 "%contract/from_predicate%",
-                docs![
-                    allocator,
-                    allocator.line(),
-                    allocator.function(allocator.as_string(id), pred).parens()
-                ]
-                .nest(2)
-                .group()
+                docs![allocator, allocator.line(), pred.pretty(allocator).parens()]
+                    .nest(2)
+                    .group()
             ],
             Lbl(_lbl) => allocator.text("%<label>").append(allocator.line()),
             Let(id, rt, body, attrs) => docs![
diff --git a/core/src/term/mod.rs b/core/src/term/mod.rs
index 5dd6751b..144c8b14 100644
--- a/core/src/term/mod.rs
+++ b/core/src/term/mod.rs
@@ -221,13 +221,18 @@ pub enum Term {
     /// the value with potential delayed checks buried inside) or a predicate. Ideally, both
     /// would fall under then `CustomContract` node.
     ///
-    /// For now, we only put predicates built using `std.contract.from_predicate` here.
-    ///
-    /// The reason for having a separate node (instead of encoding everything as partial identities
-    /// under a normal `Fun` node) is that we can leverage the metadata for example to implement a
-    /// restricted `or` combinator on contracts, which needs to know which contracts are built from
-    /// predicates, or for better error messages in the future when parametric contracts aren't
-    /// fully applied ([#1460](https://github.com/tweag/nickel/issues/1460)).
+    /// Custom contracts built from `std.contract.custom` are stored in this node. Note that, for
+    /// backward compatibility, users can also use naked functions [Term::Fun] as a custom
+    /// contract. But this is discouraged and will be deprecated in the future.
+    ///
+    /// The reason for having a separate node is that we can leverage the metadata for example to
+    /// implement a restricted `or` combinator on contracts, which needs to know which contracts
+    /// are built from predicates, or for better error messages in the future when parametric
+    /// contracts aren't fully applied ([#1460](https://github.com/tweag/nickel/issues/1460)).
+    ///
+    /// Also, custom contracts aren't supposed to be applied using the standard function
+    /// application, because we need to perform additional bookkeeping upon application. So there's
+    /// no strong incentive to represent them as naked functions.
     #[serde(skip)]
     CustomContract(CustomContract),
 
@@ -378,23 +383,30 @@ pub enum BindingType {
 
 /// A term representing a custom contract.
 ///
-/// This term doesn't currently include generic custom contracts (functions `Label -> Dyn -> Dyn`)
-/// for backward compatibility reasons. In the future, we want to have all custom contracts
-/// represented as [CustomContract]s, requiring the use of a dedicated constructor:
-/// `std.contract.from_function`, `std.contract.from_record`, etc in user code. The requirement of
-/// this dedicated constructors is unfortunately a breaking change for existing custom contracts
-/// oftetn written as bare functions.
+/// This term doesn't currently necessarily include all generic custom contracts occurring in a
+/// program (functions `Label -> Dyn -> Dyn`). For backward compatibility reasons, we need to
+/// support naked functions - custom contracts that don't use the corresponding
+/// `std.contract.custom` constructor.
+///
+/// In the future, we want to have all custom contracts represented as [CustomContract]s, requiring
+/// the use of a dedicated constructor: `std.contract.custom`, `std.contract.from_record`,
+/// etc in user code. The requirement of these dedicated constructors is unfortunately a breaking
+/// change for existing custom contracts previously written as naked functions. Using naked
+/// functions is discouraged and will be deprecated in the future.
 ///
 /// In the meantime, we can put _some_ contracts here without breaking things (the one that are
 /// already built using a special constructor, such as `std.contract.from_predicate`). Maintaining
-/// those additional data (if a contract came from `from_predicate` or is a bare function) is
+/// those additional data (if a contract came from `from_predicate` or is a naked function) is
 /// useful for implementing some contract operations, such as the `or` combinator, or provide
 /// better error messages in some situations.
 #[derive(Debug, Clone, Serialize, Deserialize)]
 pub enum CustomContract {
+    /// A generic custom contract, represented as a partial identity function of type `Label -> Dyn
+    /// -> Dyn`.
+    PartialIdentity(RichTerm),
     /// A contract built from a predicate. The argument is a function of type
     /// `Dyn -> Bool`.
-    Predicate(LocIdent, RichTerm),
+    Predicate(RichTerm),
 }
 
 /// A runtime representation of a contract, as a term and a label ready to be applied via
@@ -919,11 +931,9 @@ impl Term {
             Term::Bool(_) => Some("Bool".to_owned()),
             Term::Num(_) => Some("Number".to_owned()),
             Term::Str(_) => Some("String".to_owned()),
-            Term::Fun(_, _)
-            | Term::FunPattern(_, _)
+            Term::Fun(_, _) | Term::FunPattern(_, _) => Some("Function".to_owned()),
             // We could print a separate type for predicates. For the time being, we just consider
             // it to be the function resulting of `$predicate_to_ctr pred`.
-            | Term::CustomContract(CustomContract::Predicate(..)) => Some("Function".to_owned()),
             Term::Match { .. } => Some("MatchExpression".to_owned()),
             Term::Lbl(_) => Some("Label".to_owned()),
             Term::Enum(_) => Some("EnumTag".to_owned()),
@@ -935,6 +945,7 @@ impl Term {
             Term::Annotated(..) => Some("Annotated".to_owned()),
             Term::Type(_) => Some("Type".to_owned()),
             Term::ForeignId(_) => Some("ForeignId".to_owned()),
+            Term::CustomContract(_) => Some("CustomContract".to_owned()),
             Term::Let(..)
             | Term::LetPattern(..)
             | Term::App(_, _)
@@ -975,7 +986,8 @@ impl Term {
             | Term::Fun(..)
             // match expressions are function
             | Term::Match {..}
-            | Term::CustomContract(CustomContract::Predicate(..))
+            // Custom contracts are values, usually wrapping a function
+            | Term::CustomContract(_)
             | Term::Lbl(_)
             | Term::Enum(_)
             | Term::EnumVariant {..}
@@ -1047,7 +1059,7 @@ impl Term {
             | Term::Array(..)
             | Term::Fun(..)
             | Term::FunPattern(..)
-            | Term::CustomContract(CustomContract::Predicate(..))
+            | Term::CustomContract(_)
             | Term::App(_, _)
             | Term::Match { .. }
             | Term::Var(_)
@@ -1105,7 +1117,7 @@ impl Term {
             | Term::LetPattern(..)
             | Term::Fun(..)
             | Term::FunPattern(..)
-            | Term::CustomContract(CustomContract::Predicate(..))
+            | Term::CustomContract(_)
             | Term::App(..)
             | Term::Op1(..)
             | Term::Op2(..)
@@ -1293,10 +1305,14 @@ pub enum UnaryOp {
     /// See `GoDom`.
     LabelGoDict,
 
-    /// Wrap a predicate function as a [CustomContract::Predicate]. You can think of this primop as
-    /// one type constructor for contracts.
+    /// Wrap a predicate function as a [CustomContract]. You can think of this primop as
+    /// a type constructor for custom contracts.
     ContractFromPredicate,
 
+    /// Wrap a partial identity function as a [CustomContract]. You can think of this primop as a
+    /// type constructor for contracts.
+    ContractCustom,
+
     /// Force the evaluation of its argument and proceed with the second.
     Seq,
 
@@ -1508,6 +1524,7 @@ impl fmt::Display for UnaryOp {
             LabelGoArray => write!(f, "label/go_array"),
             LabelGoDict => write!(f, "label/go_dict"),
             ContractFromPredicate => write!(f, "contract/from_predicate"),
+            ContractCustom => write!(f, "contract/custom"),
             Seq => write!(f, "seq"),
             DeepSeq => write!(f, "deep_seq"),
             ArrayLength => write!(f, "array/length"),
@@ -2088,6 +2105,18 @@ impl Traverse<RichTerm> for RichTerm {
                 let t = t.traverse(f, order)?;
                 RichTerm::new(Term::FunPattern(pat, t), pos)
             }
+            Term::CustomContract(CustomContract::Predicate(t)) => {
+                let t = t.traverse(f, order)?;
+                RichTerm::new(Term::CustomContract(CustomContract::Predicate(t)), pos)
+            }
+            Term::CustomContract(CustomContract::PartialIdentity(t)) => {
+                let t = t.traverse(f, order)?;
+                RichTerm::new(
+                    Term::CustomContract(CustomContract::PartialIdentity(t)),
+                    pos,
+                )
+            }
+
             Term::Let(id, t1, t2, attrs) => {
                 let t1 = t1.traverse(f, order)?;
                 let t2 = t2.traverse(f, order)?;
@@ -2278,7 +2307,8 @@ impl Traverse<RichTerm> for RichTerm {
             }),
             Term::Fun(_, t)
             | Term::FunPattern(_, t)
-            | Term::CustomContract(CustomContract::Predicate(_, t))
+            | Term::CustomContract(CustomContract::Predicate(t))
+            | Term::CustomContract(CustomContract::PartialIdentity(t))
             | Term::EnumVariant { arg: t, .. }
             | Term::Op1(_, t)
             | Term::Sealed(_, t, _) => t.traverse_ref(f, state),
diff --git a/core/src/transform/free_vars.rs b/core/src/transform/free_vars.rs
index afaf9d0c..da4ab2cb 100644
--- a/core/src/transform/free_vars.rs
+++ b/core/src/transform/free_vars.rs
@@ -44,7 +44,7 @@ impl CollectFreeVars for RichTerm {
             | Term::Enum(_)
             | Term::Import(_)
             | Term::ResolvedImport(_) => (),
-            Term::Fun(id, t) | Term::CustomContract(CustomContract::Predicate(id, t)) => {
+            Term::Fun(id, t) => {
                 let mut fresh = HashSet::new();
 
                 t.collect_free_vars(&mut fresh);
@@ -106,7 +106,11 @@ impl CollectFreeVars for RichTerm {
                     free_vars.extend(fresh);
                 }
             }
-            Term::Op1(_, t) | Term::Sealed(_, t, _) | Term::EnumVariant { arg: t, .. } => {
+            Term::Op1(_, t)
+            | Term::Sealed(_, t, _)
+            | Term::EnumVariant { arg: t, .. }
+            | Term::CustomContract(CustomContract::Predicate(t))
+            | Term::CustomContract(CustomContract::PartialIdentity(t)) => {
                 t.collect_free_vars(free_vars)
             }
             Term::OpN(_, ts) => {
diff --git a/core/src/typecheck/mod.rs b/core/src/typecheck/mod.rs
index b758fcae..c86e49bd 100644
--- a/core/src/typecheck/mod.rs
+++ b/core/src/typecheck/mod.rs
@@ -1472,10 +1472,6 @@ fn walk<V: TypecheckVisitor>(
             ctxt.type_env.insert(id.ident(), mk_uniftype::dynamic());
             walk(state, ctxt, visitor, t)
         }
-        Term::CustomContract(CustomContract::Predicate(id, t)) => {
-            ctxt.type_env.insert(id.ident(), mk_uniftype::dynamic());
-            walk(state, ctxt, visitor, t)
-        }
         Term::FunPattern(pat, t) => {
             let PatternTypeData { bindings: pat_bindings, ..} = pat.pattern_types(state, &ctxt, pattern::TypecheckMode::Walk)?;
             ctxt.type_env.extend(pat_bindings.into_iter().map(|(id, typ)| (id.ident(), typ)));
@@ -1603,7 +1599,9 @@ fn walk<V: TypecheckVisitor>(
         }
         Term::EnumVariant { arg: t, ..}
         | Term::Sealed(_, t, _)
-        | Term::Op1(_, t) => walk(state, ctxt.clone(), visitor, t),
+        | Term::Op1(_, t)
+        | Term::CustomContract(CustomContract::Predicate(t)) | Term::CustomContract(CustomContract::PartialIdentity(t))
+        => walk(state, ctxt.clone(), visitor, t),
         Term::Op2(_, t1, t2) => {
             walk(state, ctxt.clone(), visitor, t1)?;
             walk(state, ctxt, visitor, t2)
@@ -1927,16 +1925,38 @@ fn check<V: TypecheckVisitor>(
         // Additionally, because this rule can't produce a polymorphic type (it produces a `Dyn`,
         // or morally a `Contract` type, if we had one), we don't lose anything by making it a
         // check rule, as for e.g. literals.
-        Term::CustomContract(CustomContract::Predicate(id, body)) => {
-            visitor.visit_ident(id, mk_uniftype::dynamic());
+        Term::CustomContract(CustomContract::Predicate(body)) => {
+            // The overall type of a custom contract is currently `Dyn`, as we don't have a better
+            // one.
+            ty.unify(mk_uniftype::dynamic(), state, &ctxt)
+                .map_err(|err| err.into_typecheck_err(state, rt.pos))?;
 
+            // A predicate must be of type `Dyn -> Bool`.
+            check(
+                state,
+                ctxt,
+                visitor,
+                body,
+                mk_uniftype::arrow(mk_uniftype::dynamic(), mk_uniftype::bool()),
+            )
+        }
+        // See [^predicate-is-check]. We took `Predicate` as an example, but this reasoning applies
+        // to other kind of custom contracts, such as `PartialIdentity`.
+        Term::CustomContract(CustomContract::PartialIdentity(body)) => {
             // The overall type of a custom contract is currently `Dyn`, as we don't have a better
             // one.
             ty.unify(mk_uniftype::dynamic(), state, &ctxt)
                 .map_err(|err| err.into_typecheck_err(state, rt.pos))?;
 
-            // The body of a predicate must be of type `Bool`.
-            check(state, ctxt, visitor, body, mk_uniftype::bool())
+            // The type of the implementation of a custom contract is `Label -> Dyn -> Dyn` (but
+            // remember that we don't actually have a `Label` type).
+            let partial_id_type = mk_uty_arrow!(
+                mk_uniftype::dynamic(),
+                mk_uniftype::dynamic(),
+                mk_uniftype::dynamic()
+            );
+
+            check(state, ctxt, visitor, body, partial_id_type)
         }
         Term::Array(terms, _) => {
             let ty_elts = state.table.fresh_type_uvar(ctxt.var_level);
diff --git a/core/src/typecheck/operation.rs b/core/src/typecheck/operation.rs
index 33a72908..feacb42c 100644
--- a/core/src/typecheck/operation.rs
+++ b/core/src/typecheck/operation.rs
@@ -34,6 +34,7 @@ pub fn get_uop_type(
                 "String",
                 "Enum",
                 "Function",
+                "CustomContract",
                 "Array",
                 "Record",
                 "Label",
@@ -84,6 +85,17 @@ pub fn get_uop_type(
             mk_uty_arrow!(mk_uniftype::dynamic(), mk_uniftype::bool()),
             mk_uniftype::dynamic(),
         ),
+        // Morally returns a contract, but we don't have a proper type for that yet
+        // (Dyn -> Dyn -> Bool) -> Dyn
+        UnaryOp::ContractCustom => (
+            mk_uty_arrow!(
+                mk_uniftype::dynamic(),
+                mk_uniftype::dynamic(),
+                mk_uniftype::dynamic()
+            ),
+            mk_uniftype::dynamic(),
+        ),
+
         // forall rows a. { id: a | rows} -> a
         UnaryOp::RecordAccess(id) => {
             let rows = state.table.fresh_rrows_uvar(var_level);
diff --git a/core/stdlib/std.ncl b/core/stdlib/std.ncl
index 73fa8331..f033240d 100644
--- a/core/stdlib/std.ncl
+++ b/core/stdlib/std.ncl
@@ -59,14 +59,18 @@
             [ 1 ]
           ```
         "%
-      = fun label value =>
-        if %typeof% value == 'Array then
-          if %array/length% value != 0 then
-            value
-          else
-            %blame% (%label/with_message% "empty array" label)
-        else
-          %blame% (%label/with_message% "not a array" label),
+      =
+        %contract/custom%
+          (
+            fun label value =>
+              if %typeof% value == 'Array then
+                if %array/length% value != 0 then
+                  value
+                else
+                  %blame% (%label/with_message% "empty array" label)
+              else
+                %blame% (%label/with_message% "not a array" label)
+          ),
 
     first
       : forall a. Array a -> a
@@ -1001,9 +1005,53 @@
         "%
       = fun message label => %blame% (%label/with_message% message label),
 
+    custom
+      | doc m%"
+          Build a generic custom contract.
+
+          Type: `(Dyn -> Dyn -> Dyn) -> Contract`
+          (for technical reasons, this function isn't actually statically typed)
+
+          # Custom contract
+
+          In general, you should use more specific contract constructors
+          whenever possible, such as `std.contract.from_predicate` or
+          `std.contract.from_validator`, as they are easier to write and support
+          more operations.
+
+          However, those specialized constructors might not cover all your
+          needs, in particular when implementing lazy contracts (such as
+          contracts operating on functions or data structures like records and
+          arrays) or contracts that might take other lazy contracts as
+          parameters and apply them (using `std.contract.apply`).
+
+          Custom contracts built using `std.contract.custom` are the most
+          general form of custom contracts. They take a label and a value as
+          parameters, and either blame the label or return the original value
+          with potential delayed checks pushed inside. For more details on
+          custom contracts, please refer to the corresponding section of the
+          user manual.
+
+          # Examples
+
+          ```nickel
+          let Nullable = fun Contract =>
+            std.contract.custom (fun label value =>
+              if value == null then
+                value
+              else
+                std.contract.apply Contract label value
+            )
+          in
+          null | Nullable Number
+            => null
+          ```
+        "%
+      = fun custom => %contract/custom% custom,
+
     from_predicate
       | doc m%"
-          Generates a contract from a boolean predicate.
+          Build a custom contract from a boolean predicate.
 
           Type: `(Dyn -> Bool) -> Contract`
           (for technical reasons, this function isn't actually statically typed)
@@ -1011,18 +1059,17 @@
           # Contract application
 
           Before Nickel 1.8, `from_predicate` used to wrap the predicate as a
-          function of a label and value. You could thus theoretically apply it
-          as a normal function, as in `(std.contract.from_predicate my_pred)
-          label value`. While possible, this wasn't recommended nor really
-          officially supported: all contracts should be applied using a contract
-          annotation or `std.contract.apply`, as explained in the documentation
-          of `std.contract.apply`.
+          naked function of a label and value. You could thus theoretically
+          apply it as a normal function, as in
+          `(std.contract.from_predicate my_pred) label value`. While possible,
+          this wasn't recommended nor really officially supported: all contracts
+          should be applied using a contract annotation or `std.contract.apply`,
+          as explained in the documentation of `std.contract.apply`.
 
           From Nickel 1.8 and ownwards, `from_predicate` generates a bespoke
-          opaque value that can only be used in an annotation, with
-          `std.contract.apply` or any other function operation on contracts, but
-          the result of `from_predicate` doesn't support direct application
-          anymore.
+          contract value that can only be used in an annotation, be passed to
+          `std.contract.apply` or other operations on contracts, but can't be
+          used as normal function anymore.
 
           # Examples
 
@@ -1123,7 +1170,7 @@
               ```nickel
               let ContractNum = std.contract.from_predicate (fun x => x > 0 && x < 50) in
 
-              let Contract = fun label value =>
+              let Contract = std.contract.custom (fun label value =>
                 if std.is_number value then
                   std.contract.apply
                     ContractNum
@@ -1134,6 +1181,7 @@
                     value
                 else
                   value
+              )
               in
 
               5 | Contract
@@ -1164,7 +1212,7 @@
               ```nickel
               let ContractNum = std.contract.from_predicate (fun x => x > 0 && x < 50) in
 
-              let Contract = fun label value =>
+              let Contract = std.contract.custom (fun label value =>
                 if std.is_number value then
                   std.contract.apply
                     ContractNum
@@ -1178,6 +1226,7 @@
                     value
                 else
                   value
+              )
               in
 
               5 | Contract
@@ -1197,11 +1246,12 @@
               # Examples
 
               ```nickel
-              let AlwaysFailWithNotes = fun label _value =>
+              let AlwaysFailWithNotes = std.contract.custom (fun label _value =>
                 label
                 |> std.contract.label.append_note "This is note 1"
                 |> std.contract.label.append_note "This is note 2"
                 |> std.contract.blame
+              )
               in
               null | AlwaysFailWithNotes
             "%
@@ -1215,21 +1265,43 @@
           Type: `Contract -> Label -> Dyn -> Dyn`
           (for technical reasons, this function isn't actually statically typed)
 
-          Nickel supports user-defined contracts defined as functions, but also
-          as records. Moreover, the interpreter performs additional bookkeeping
-          for error reporting when applying a contract in an expression
-          `value | Contract`. You should not use standard function application
-          to apply a contract, but this function instead.
+          Nickel supports user-defined contracts defined as predicates, as
+          general functions, as records, etc. The interpreter also performs additional
+          bookkeeping for error reporting when applying a contract in an
+          expression `value | Contract`. You should thus not use standard
+          function application to apply a contract, even when defined as a
+          function, but use `std.contract.apply` instead.
+
+          # Argument
+
+          The argument must be a contract, which is currently either a custom
+          contract or a record.
+
+          ## Custom contracts
+
+          When applying a custom contract, the argument must have been produced
+          by one of the stdlib function for creating custom contracts, such as
+          `std.contract.from_predicate` or `std.contract.custom`.
+
+          For backward-compatibility reasons, the argument can also be a naked
+          function `Label -> Dyn -> Dyn`, but this is discouraged and will be
+          deprecated in the future. For such a contract, please wrap it using
+          `std.contract.custom` instead.
 
           # Examples
 
           ```nickel
-          let Nullable = fun param_contract label value =>
-            if value == null then null
-            else std.contract.apply param_contract label value
+          let Nullable = fun param_contract =>
+            std.contract.custom (fun label value =>
+              if value == null then
+                null
+              else
+                std.contract.apply param_contract label value
+            )
           in
           let Contract = Nullable {foo | Number} in
-          ({foo = 1} | Contract)
+          ({ foo = 1 } | Contract)
+          => { foo = 1 }
           ```
 
           # Diagnostics stack
@@ -1243,20 +1315,22 @@
           ## Illustration
 
           ```nickel
-          let ChildContract = fun label value =>
+          let ChildContract = std.contract.custom (fun label value =>
             label
             |> std.contract.label.with_message "child's message"
             |> std.contract.label.append_note "child's note"
             |> std.contract.blame
+          )
           in
 
-          let ParentContract = fun label value =>
+          let ParentContract = std.contract.custom (fun label value =>
             let label =
               label
               |> std.contract.label.with_message "parent's message"
               |> std.contract.label.append_note "parent's note"
             in
             std.contract.apply ChildContract label value
+          )
           in
 
           null | ParentContract
@@ -1298,11 +1372,15 @@
                 (some_function arg_checked | (Bar arg_checked))
               ```
             "%
-          = fun Domain Codomain label function =>
-            fun arg =>
-              let arg_with_ctr = std.contract.apply Domain label arg in
-              function arg_with_ctr
-              |> contract.apply (Codomain arg_with_ctr) label,
+          = fun Domain Codomain =>
+            %contract/custom%
+              (
+                fun label function =>
+                  fun arg =>
+                    let arg_with_ctr = std.contract.apply Domain label arg in
+                    function arg_with_ctr
+                    |> std.contract.apply (Codomain arg_with_ctr) label
+              ),
 
         PosNumber
           | doc m%"
@@ -1315,14 +1393,18 @@
               positive: the fact that the value is a number is assumed to be
               checked by an associated static type annotation.
             "%
-          = fun label value =>
-            if %typeof% value == 'Number then
-              if value >= 0 then
-                value
-              else
-                %blame% (%label/append_note% "Expected a positive number, got %{%to_string% value}" label)
-            else
-              value,
+          =
+            %contract/custom%
+              (
+                fun label value =>
+                  if %typeof% value == 'Number then
+                    if value >= 0 then
+                      value
+                    else
+                      %blame% (%label/append_note% "Expected a positive number, got %{%to_string% value}" label)
+                  else
+                    value
+              ),
 
         RangeStep
           | doc m%"
@@ -1337,8 +1419,15 @@
               positive: the fact that the value is a number is assumed to be
               checked by an associated static type annotation.
             "%
-          = fun label =>
-            PosNumber (%label/with_message% "invalid range step" label),
+          =
+            %contract/custom%
+              (
+                fun label value =>
+                  %contract/apply%
+                    PosNumber
+                    (%label/with_message% "invalid range step" label)
+                    value
+              ),
 
         RangeFun
           | doc m%"
@@ -1362,19 +1451,23 @@
             let attach_message = label_module.with_message "invalid range" in
 
             fun Codomain =>
-              let RangeSecond = fun start label value =>
-                if %typeof% start == 'Number && %typeof% value == 'Number then
-                  if start > value then
-                    let start_as_str = %to_string% start in
-                    let end_as_str = %to_string% value in
-                    label
-                    |> attach_message
-                    |> label_module.append_note "Expected a range end greater than %{start_as_str} (range start), got %{end_as_str}"
-                    |> blame
-                  else
-                    value
-                else
-                  value
+              let RangeSecond = fun start =>
+                %contract/custom%
+                  (
+                    fun label value =>
+                      if %typeof% start == 'Number && %typeof% value == 'Number then
+                        if start > value then
+                          let start_as_str = %to_string% start in
+                          let end_as_str = %to_string% value in
+                          label
+                          |> attach_message
+                          |> label_module.append_note "Expected a range end greater than %{start_as_str} (range start), got %{end_as_str}"
+                          |> blame
+                        else
+                          value
+                      else
+                        value
+                  )
               in
 
               DependentFun
@@ -1382,7 +1475,7 @@
                 (fun start => RangeSecond start -> Codomain),
 
         IndexedArrayFun
-          | [| 'Index, 'Split |] -> Dyn -> Dyn
+          | [| 'Index, 'Split |] -> Dyn
           | doc m%"
               **Warning**: this is an unstable item. It might be renamed,
               modified or deleted in any subsequent minor Nickel version.
@@ -1404,46 +1497,55 @@
             let label_module = label in
             let attach_message = label_module.with_message "invalid array indexing" in
 
-            let ArrayIndexFirst = fun label value =>
-              if %typeof% value == 'Number then
-                let label =
-                  label
-                  |> attach_message
-                  |> label_module.append_note "Expected array index to be a positive integer, got %{%to_string% value} "
-                in
-                std.contract.apply std.number.Nat label value
-              else
-                value
+            let ArrayIndexFirst =
+              %contract/custom%
+                (
+                  fun label value =>
+                    if %typeof% value == 'Number then
+                      let label =
+                        label
+                        |> attach_message
+                        |> label_module.append_note "Expected array index to be a positive integer, got %{%to_string% value} "
+                      in
+                      std.contract.apply std.number.Nat label value
+                    else
+                      value
+                )
             in
 
-            let ArrayIndexSecond = fun type min_size label value =>
-              if %typeof% min_size == 'Number && %typeof% value == 'Array then
-                let max_idx = type |> match { 'Index => %array/length% value - 1, 'Split => %array/length% value } in
-                if min_size > max_idx then
-                  let index_as_str = %to_string% min_size in
-                  let max_as_str = %to_string% max_idx in
-                  let note =
-                    if %array/length% value == 0 then
-                      "Can't index into an empty array"
+            let ArrayIndexSecond = fun type min_size =>
+              %contract/custom%
+                (
+                  fun label value =>
+                    if %typeof% min_size == 'Number && %typeof% value == 'Array then
+                      let max_idx = type |> match { 'Index => %array/length% value - 1, 'Split => %array/length% value } in
+                      if min_size > max_idx then
+                        let index_as_str = %to_string% min_size in
+                        let max_as_str = %to_string% max_idx in
+                        let note =
+                          if %array/length% value == 0 then
+                            "Can't index into an empty array"
+                          else
+                            "Expected an array index between 0 and %{max_as_str} (included), got %{index_as_str}"
+                        in
+                        label
+                        |> attach_message
+                        |> label_module.append_note note
+                        |> blame
+                      else
+                        value
                     else
-                      "Expected an array index between 0 and %{max_as_str} (included), got %{index_as_str}"
-                  in
-                  label
-                  |> attach_message
-                  |> label_module.append_note note
-                  |> blame
-                else
-                  value
-              else
-                value
+                      value
+                )
             in
-            let contract = fun type label function =>
+
+            let contract = fun type =>
               DependentFun
                 ArrayIndexFirst
                 (fun index => ArrayIndexSecond type index -> Dyn)
-                label
-                function
-            in contract,
+            in
+
+            contract,
 
         ArraySliceFun
           | doc m%"
@@ -1467,49 +1569,61 @@
             let label_module = label in
             let attach_message = label_module.with_message "invalid array slice indexing" in
 
-            let SliceIndexFirst = fun label value =>
-              if %typeof% value == 'Number then
-                let label =
-                  label
-                  |> attach_message
-                  |> label_module.append_note "Expected the array slice start index to be a positive integer, got %{%to_string% value}"
-                in
-                std.contract.apply std.number.Nat label value
-              else
-                value
+            let SliceIndexFirst =
+              %contract/custom%
+                (
+                  fun label value =>
+                    if %typeof% value == 'Number then
+                      let label =
+                        label
+                        |> attach_message
+                        |> label_module.append_note "Expected the array slice start index to be a positive integer, got %{%to_string% value}"
+                      in
+                      std.contract.apply std.number.Nat label value
+                    else
+                      value
+                )
             in
 
-            let SliceIndexSecond = fun start label value =>
-              if %typeof% start == 'Number && %typeof% value == 'Number then
-                if value < start then
-                  label
-                  |> attach_message
-                  |> label_module.append_note "Expected the array slice indices to satisfy `start <= end`, but got %{%to_string% start} (start) and %{%to_string% value} (end)"
-                  |> blame
-                else
-                  let label =
-                    label
-                    |> attach_message
-                    |> label_module.append_note "Expected the array slice end index to be a positive integer, got %{%to_string% value}"
-                  in
-                  std.contract.apply std.number.Nat label value
-              else
-                value
+            let SliceIndexSecond = fun start =>
+              %contract/custom%
+                (
+                  fun label value =>
+                    if %typeof% start == 'Number && %typeof% value == 'Number then
+                      if value < start then
+                        label
+                        |> attach_message
+                        |> label_module.append_note "Expected the array slice indices to satisfy `start <= end`, but got %{%to_string% start} (start) and %{%to_string% value} (end)"
+                        |> blame
+                      else
+                        let label =
+                          label
+                          |> attach_message
+                          |> label_module.append_note "Expected the array slice end index to be a positive integer, got %{%to_string% value}"
+                        in
+                        std.contract.apply std.number.Nat label value
+                    else
+                      value
+                )
             in
 
-            let ArraySliceArray = fun end_index label value =>
-              if %typeof% end_index == 'Number && %typeof% value == 'Array then
-                if end_index > %array/length% value then
-                  let index_as_str = %to_string% end_index in
-                  let size_as_str = %to_string% (%array/length% value) in
-                  label
-                  |> attach_message
-                  |> label_module.append_note "Expected the slice end index to be between 0 and %{size_as_str} (array's length), got %{index_as_str}"
-                  |> blame
-                else
-                  value
-              else
-                value
+            let ArraySliceArray = fun end_index =>
+              %contract/custom%
+                (
+                  fun label value =>
+                    if %typeof% end_index == 'Number && %typeof% value == 'Array then
+                      if end_index > %array/length% value then
+                        let index_as_str = %to_string% end_index in
+                        let size_as_str = %to_string% (%array/length% value) in
+                        label
+                        |> attach_message
+                        |> label_module.append_note "Expected the slice end index to be between 0 and %{size_as_str} (array's length), got %{index_as_str}"
+                        |> blame
+                      else
+                        value
+                    else
+                      value
+                )
             in
             DependentFun
               SliceIndexFirst
@@ -1556,17 +1670,21 @@
             let attach_message = label_module.with_message "missing field" in
 
             fun Codomain =>
-              let HasFieldSecond = fun field label value =>
-                if %typeof% field == 'String
-                && %typeof% value == 'Record
-                && !(%record/has_field% field value) then
-                  label
-                  |> attach_message
-                  |> label_module.append_note
-                    "The record given as an argument lacks the required field `%{field}`."
-                  |> blame
-                else
-                  value
+              let HasFieldSecond = fun field =>
+                %contract/custom%
+                  (
+                    fun label value =>
+                      if %typeof% field == 'String
+                      && %typeof% value == 'Record
+                      && !(%record/has_field% field value) then
+                        label
+                        |> attach_message
+                        |> label_module.append_note
+                          "The record given as an argument lacks the required field `%{field}`."
+                        |> blame
+                      else
+                        value
+                  )
               in
 
               DependentFun
@@ -1646,22 +1764,26 @@
         serialized | Schema
         ```
       "%%
-      = fun label value =>
-        let label =
-          std.contract.label.with_message
-            "expected either a string or an enum tag"
-            label
-        in
-        %typeof% value
-        |> match {
-          'String => %enum/from_string% value,
-          'Enum =>
-            if is_enum_variant value then
-              std.contract.blame label
-            else
-              value,
-          _ => std.contract.blame label,
-        },
+      =
+        %contract/custom%
+          (
+            fun label value =>
+              let label =
+                std.contract.label.with_message
+                  "expected either a string or an enum tag"
+                  label
+              in
+              %typeof% value
+              |> match {
+                'String => %enum/from_string% value,
+                'Enum =>
+                  if is_enum_variant value then
+                    std.contract.blame label
+                  else
+                    value,
+                _ => std.contract.blame label,
+              }
+          ),
 
     is_enum_tag
       : Dyn -> Bool
@@ -1907,14 +2029,18 @@
           42
         ```
       "%
-      = fun label value =>
-        if %typeof% value == 'Number then
-          if value % 1 == 0 then
-            value
-          else
-            %blame% (%label/with_message% "not an integer" label)
-        else
-          %blame% (%label/with_message% "not a number" label),
+      =
+        %contract/custom%
+          (
+            fun label value =>
+              if %typeof% value == 'Number then
+                if value % 1 == 0 then
+                  value
+                else
+                  %blame% (%label/with_message% "not an integer" label)
+              else
+                %blame% (%label/with_message% "not a number" label)
+          ),
 
     Nat
       | doc m%"
@@ -1931,14 +2057,18 @@
           error
         ```
       "%
-      = fun label value =>
-        if %typeof% value == 'Number then
-          if value % 1 == 0 && value >= 0 then
-            value
-          else
-            %blame% (%label/with_message% "not a natural" label)
-        else
-          %blame% (%label/with_message% "not a number" label),
+      =
+        %contract/custom%
+          (
+            fun label value =>
+              if %typeof% value == 'Number then
+                if value % 1 == 0 && value >= 0 then
+                  value
+                else
+                  %blame% (%label/with_message% "not a natural" label)
+              else
+                %blame% (%label/with_message% "not a number" label)
+          ),
 
     PosNat
       | doc m%"
@@ -1955,14 +2085,18 @@
           error
         ```
       "%
-      = fun label value =>
-        if %typeof% value == 'Number then
-          if value % 1 == 0 && value > 0 then
-            value
-          else
-            %blame% (%label/with_message% "not positive integer" label)
-        else
-          %blame% (%label/with_message% "not a number" label),
+      =
+        %contract/custom%
+          (
+            fun label value =>
+              if %typeof% value == 'Number then
+                if value % 1 == 0 && value > 0 then
+                  value
+                else
+                  %blame% (%label/with_message% "not positive integer" label)
+              else
+                %blame% (%label/with_message% "not a number" label)
+          ),
 
     NonZero
       | doc m%"
@@ -1977,14 +2111,18 @@
           error
         ```
       "%
-      = fun label value =>
-        if %typeof% value == 'Number then
-          if value != 0 then
-            value
-          else
-            %blame% (%label/with_message% "non-zero" label)
-        else
-          %blame% (%label/with_message% "not a number" label),
+      =
+        %contract/custom%
+          (
+            fun label value =>
+              if %typeof% value == 'Number then
+                if value != 0 then
+                  value
+                else
+                  %blame% (%label/with_message% "is zero" label)
+              else
+                %blame% (%label/with_message% "not a number" label)
+          ),
 
     is_integer
       : Number -> Bool
@@ -2649,14 +2787,18 @@
       =
         let pattern = m%"^[+-]?(\d+(\.\d*)?(e[+-]?\d+)?|\.\d+(e[+-]?\d+)?)$"% in
         let is_num_literal = %string/is_match% pattern in
-        fun l s =>
-          if %typeof% s == 'String then
-            if is_num_literal s then
-              s
-            else
-              %blame% (%label/with_message% "invalid number literal" l)
-          else
-            %blame% (%label/with_message% "not a string" l),
+
+        %contract/custom%
+          (
+            fun label value =>
+              if %typeof% value == 'String then
+                if is_num_literal value then
+                  value
+                else
+                  %blame% (%label/with_message% "invalid number literal" label)
+              else
+                %blame% (%label/with_message% "not a string" label)
+          ),
 
     Character
       | doc m%"
@@ -2675,14 +2817,18 @@
           => error
         ```
       "%
-      = fun l s =>
-        if %typeof% s == 'String then
-          if length s == 1 then
-            s
-          else
-            %blame% (%label/with_message% "length different than one" l)
-        else
-          %blame% (%label/with_message% "not a string" l),
+      =
+        %contract/custom%
+          (
+            fun label value =>
+              if %typeof% value == 'String then
+                if length value == 1 then
+                  value
+                else
+                  %blame% (%label/with_message% "length different than one" label)
+              else
+                %blame% (%label/with_message% "not a string" label)
+          ),
 
     Stringable
       | doc m%"
@@ -2711,7 +2857,7 @@
         ```
       "%
       =
-        std.contract.from_predicate
+        %contract/from_predicate%
           (
             fun value =>
               let type = std.typeof value in
@@ -2739,14 +2885,18 @@
           => error
         ```
       "%
-      = fun l s =>
-        if %typeof% s == 'String then
-          if %string/length% s > 0 then
-            s
-          else
-            %blame% (%label/with_message% "empty string" l)
-        else
-          %blame% (%label/with_message% "not a string" l),
+      =
+        %contract/custom%
+          (
+            fun label value =>
+              if %typeof% value == 'String then
+                if %string/length% value > 0 then
+                  value
+                else
+                  %blame% (%label/with_message% "empty string" label)
+              else
+                %blame% (%label/with_message% "not a string" label)
+          ),
 
     join
       : String -> Array String -> String
@@ -3239,7 +3389,16 @@
          => true
         ```
         "%
-      = fun label value => if value then true else %blame% label,
+      =
+        %contract/custom%
+          (
+            fun label value =>
+              if value then
+                true
+              else
+                %blame%
+                  label
+          ),
 
     assert_all
       | Array Assert -> Bool
@@ -3385,6 +3544,7 @@
       'Array,
       'Record,
       'ForeignId,
+      'CustomContract,
       'Type,
       'Other
     |]
@@ -3544,7 +3704,12 @@
         => error: contract broken by a value: message
       ```
     "%
-    = fun msg label value => std.contract.blame_with_message msg label,
+    = fun msg =>
+      %contract/custom%
+        (
+          fun label value =>
+            std.contract.blame_with_message msg label
+        ),
 
   fail_with
     | String -> Dyn
diff --git a/doc/manual/contracts.md b/doc/manual/contracts.md
index cf0407f8..f49aa8a1 100644
--- a/doc/manual/contracts.md
+++ b/doc/manual/contracts.md
@@ -58,6 +58,9 @@ are available. `Dyn` is a contract that never fails.
 
 ## User-defined contracts
 
+The ability to check arbitrary properties is where run-time contracts really
+shine. This section explains how to define and use your very own contract.
+
 ### Preamble: idempotency
 
 As you will see in this section, Nickel contracts are more than mere boolean
@@ -79,40 +82,99 @@ optimizations such as contract deduplication. **While non-idempotent contracts
 shouldn't wreak havoc on your program, they might lead to surprising results**,
 such as a change in behavior e.g. when refactoring a program to a new version
 that should be identical, typically because the deduplication optimization
-doesn't fire anymore.
+doesn't fire anymore. You must always ensure that you write idempotent
+contracts.
 
-### By hand
+### With `from_predicate`
 
-The ability to check arbitrary properties is where run-time contracts really
-shine. Let us see how to define our very own contract. Here is an example of a
-custom contract:
+The simplest way to write a custom contract is to use
+`std.contract.from_predicate`. This function takes a predicate, which is a
+function taking a value and returning a boolean (that is, a function of type
+`Dyn -> Bool`) and converts it to a contract.
+
+Here is a contract that checks if a string is equal to `"foo"`:
+
+```nickel
+{ IsFoo = std.contract.from_predicate ((==) "foo") }
+```
+
+The syntax `(==)` turns the equality operator `==` into a function, and is
+shorthand for `fun x y => x == y`. The partial application `(==) "foo"` is then
+the function `fun y => "foo" == y`, which is exactly the condition we want.
+
+Here is another example of a port number contract:
 
 ```nickel
 {
-  IsFoo = fun label value =>
-    value |> match {
-      "foo" => value,
-      value if std.is_string value =>
-        std.contract.blame_with_message "not equal to \"foo\"" label,
-      _ =>
-        std.contract.blame_with_message "not a string" label,
-    }
+  Port =
+    std.contract.from_predicate
+      (
+        fun value =>
+          std.is_number value
+          && std.number.is_integer value
+          && value >= 0
+          && value <= 65535
+      )
 }
 ```
 
-A custom contract is a function of two arguments:
+One drawback of `std.contract.from_predicate` is that it doesn't allow for
+custom error messages. `from_predicate` is useful to quickly define contracts
+based on a boolean condition and when the contract is simple enough to not
+require a custom error message: as for primitive types, just seeing the name of
+the contract should be sufficient for other developers to understand what went
+wrong.
+
+### With `from_validator`
+
+TODO once `from_validator` is implemented.
+
+### With `custom`
+
+In some situations, even validators aren't sufficient. For example, when writing
+[lazy contracts](#laziness) or [parametrized
+contracts](#parametrized-contracts), you might not be able to decide right away
+if a value satisfies a contract. Please refer to the aforementioned sections for
+more details. In this case, the most general form for creating a custom contract
+is `std.contract.custom`. Here is the `IsFoo` contract written with `custom`:
 
-- A `label`. The label is provided by the interpreter and contains tracking
-  information for error reporting. The label can be used by the contract to
-  customize the error reporting as well.
+```nickel
+{
+  IsFoo =
+    std.contract.custom
+      (
+        fun label value =>
+          value |> match {
+            "foo" => value,
+            value if std.is_string value =>
+              std.contract.blame_with_message "not equal to \"foo\"" label,
+            _ =>
+              std.contract.blame_with_message "not a string" label,
+          }
+      )
+}
+```
+
+A general custom contract is a function of two arguments:
+
+- A `label`.
 - The value being checked.
 
 Upon success, the contract must return the original value. We will see
-the reason why in the [laziness](#laziness) section. To signal failure,
-a custom contract uses `std.contract.blame`. Custom contracts can use
-the label to customize error reporting upon failure using the functions
-from `std.contract.label`, which set various attributes of the label.
-`std.contract.blame_with_message message label` is just shorthand for:
+the reason why in the [laziness section](#laziness). To signal failure,
+a custom contract uses `std.contract.blame` which takes the label as an argument.
+
+The label is a special object that is automatically passed to the custom
+contract by the interpreter and which contains tracking information for error
+reporting. Custom contracts can use the label to customize error reporting upon
+failure using the functions from `std.contract.label`, which set various
+attributes of the label. As we will see in the [contracts parametrized by
+contracts section](#contracts-parametrized-by-contracts), the label must also be
+provided when applying a subcontract.
+
+In the example above, we used `std.contract.blame_with_message message label`,
+which is just shorthand for setting the message of the label and then calling
+`std.contract.blame`:
 
 ```nickel #parse
 label
@@ -129,14 +191,18 @@ appropriate error messages. Let us try:
 ```nickel #repl
 # hide-range{1-10}
 
-> let IsFoo = fun label value =>
-    if std.is_string value then
-      if value == "foo" then
-        value
-      else
-        std.contract.blame_with_message "not equal to \"foo\"" label
-    else
-      std.contract.blame_with_message "not a string" label
+> let IsFoo =
+  std.contract.custom
+    (
+      fun label value =>
+        if std.is_string value then
+          if value == "foo" then
+            value
+          else
+            std.contract.blame_with_message "not equal to \"foo\"" label
+        else
+          std.contract.blame_with_message "not a string" label
+    )
 
 > 1 | IsFoo
 error: contract broken by a value
@@ -152,43 +218,10 @@ error: contract broken by a value
 "foo"
 ```
 
-### With `from_predicate`
-
-Our contract is simple: in the end, it tests the condition `value == "foo"`.
-Unfortunately, it has a few cascading ifs that don't look very nice. This is a
-necessary evil if you want to provide distinct error messages (`not a string`
-and `not a "foo"`). However, one could argue in this case that the information
-of the contract's name (which is printed for contract violation errors amongst
-other data) is enough to understand the error. In this case, we can write our
-contract more succinctly as:
-
-```nickel
-{ IsFoo = std.contract.from_predicate ((==) "foo") }
-```
-
-`std.contract.from_predicate` takes a predicate (a function of one argument
-which returns a boolean) and converts it to a contract. The syntax `(==)` turns
-the equality operator `==` into a function, and is shorthand for `fun x y => x
-== y`. The partial application `(==) "foo"` is then the function `fun y => "foo"
-== y`, which is exactly the condition we want. `from_predicate` is useful to
-quickly define contracts based on a boolean condition, and when the contract is
-simple enough to not require a custom error message.
-
-Here is an example of a port number contract:
-
-```nickel
-{
-  Port =
-    std.contract.from_predicate
-      (
-        fun value =>
-          std.is_number value
-          && std.number.is_integer value
-          && value >= 0
-          && value <= 65535
-      )
-}
-```
+Note that there is absolutely no good reason to write the particular `IsFoo`
+contract as a general custom contract: a validator is better suited for this
+simple case. We did it for illustrative purpose. Later sections will show more
+relevant examples where `custom` is required.
 
 ### Parametrized contracts
 
@@ -254,7 +287,9 @@ parameters must appear first, before the `label` and `value` arguments:
 let Between = fun min max =>
   std.contract.from_predicate (fun value =>
     value >= min &&
-    value <= max) in
+    value <= max)
+in
+
 let Schema = {
   level | Between 5 10,
   strength | Between 0 1,
@@ -270,24 +305,33 @@ in
 ### Contracts parametrized by contracts
 
 Contracts parametrized by other contracts are not really special amongst
-parametrized contracts, but note that although contracts can be functions, we
-will see soon that they can be different objects as well. Even with a contract
-defined as a function, contract application behaves differently than bare
-function application. Thus, when manually handling another contract `Contract`,
-do not apply it as a function `Contract label value`, but use
-`std.contract.apply Contract label value` instead.
+parametrized contracts, but note that although contracts can be built from
+functions, we will see soon that they can be different objects as well. Contract
+application behaves differently than bare function application. Thus, when
+manually handling another unknown contract `Contract`, do not apply it as a
+function `Contract label value`, but use `std.contract.apply Contract label
+value` instead.
 
-One example of a contract parametrized by another contract is a `Nullable`
-contract, that accepts a value that is either null or of some other given
-format:
+Parameters that are also contracts are usually generic. In particular, they are
+not necessarily predicates or validators. In consequence, a contract
+parametrized by another unknown contract must usually be written using the most
+general constructor `std.contract.custom`.
+
+One example is the `Nullable` contract, which accepts a value that is either
+`null` or of some other given format:
 
 ```nickel
-let Nullable = fun contract label value =>
-  if value == null then
-    value
-  else
-    std.contract.apply contract label value
+let Nullable = fun Contract =>
+  std.contract.custom
+    (
+      fun label value =>
+        if value == null then
+          value
+        else
+          std.contract.apply Contract label value
+    )
 in
+
 [
   # succeeds
   null | Nullable Number,
@@ -629,13 +673,16 @@ strings and values satisfy `Contract`, for example:
 
 ## Laziness
 
-In the [section on writing a custom contract by hand](#by-hand), we noted the
-strange fact that a custom contract must return a value, instead of just
-returning e.g. a boolean to indicate success or failure. A contract could even
-always return `null`, as failure is handled separately by aborting. Moreover,
-the contracts we have written so far always returned the original unmodified
-value upon success, which doesn't sound very useful: after all, the caller and
-the interpreter already had access to this value to begin with.
+In the [section on writing a custom contract with
+`std.contract.custom`](#with-custom), we noted the strange fact that a general
+custom contract must return a value, instead of just returning e.g. a boolean to
+indicate success or failure. A custom contract could even theoretically always
+return `null`, as failure is handled separately by aborting, which is a bit
+unsettling (although there is no reasonable justification for doing that!).
+
+What's more, the contracts we have written so far always returned the original
+value unmodified upon success, which doesn't sound very useful: after all, the
+caller and the interpreter already had access to this value to begin with.
 
 The motivation for this return value is laziness. Nickel is designed to be
 *lazy*, only evaluating values on-demand.
@@ -675,13 +722,12 @@ combinators.
 
 ### Writing lazy contracts
 
-Imagine we want to write a contract similar to `{_ : Bool}`, that is a
-dictionary of booleans, but we also want keys to be number literals (although
-represented as strings). A valid value could look like
-`{"1": true, "2": false, "10": true}`. If we used boolean predicates as the
-default for contracts, it would be impossible to make it lazy: as soon as your
-contract is called, you would need to produce a `true` or `false` answer, and
-checking that fields are all `Bool` requires evaluating them first.
+Imagine we want to write a contract similar to `{_ | Bool}`, that is a
+dictionary of booleans, but we also want keys to be valid number (although
+represented as strings). A valid value could look like `{"1": true, "2": false,
+"10": true}`. If we use a predicate, it would be impossible to make it lazy: as
+soon as your contract is called, you would need to produce a `true` or `false`
+answer, and checking that fields are all `Bool` requires evaluating them first.
 
 What we can do is to not perform all the checks right away, but **return a new
 value which is wrapping the original value with delayed checks inside**. This
@@ -689,33 +735,37 @@ is the rationale behind contracts returning a value. Let us see:
 
 ```nickel
 {
-  NumberBoolDict = fun label value =>
-    if std.is_record value then
-      let check_fields =
-        value
-        |> std.record.fields
-        |> std.array.fold_left
-          (
-            fun acc field_name =>
-              if std.string.is_match "^\\d+$" field_name then
-                acc # unused and always null through iteration
-              else
-                std.contract.blame_with_message "field name `%{field_name}` is not a number" label
-          )
-          null
-      in
-      value
-      |> std.record.map
-        (
-          fun name value =>
-            let label_with_msg =
-              std.contract.label.with_message "field `%{name}` is not a boolean" label
+  NumberBoolDict =
+    std.contract.custom
+      (
+        fun label value =>
+          if std.is_record value then
+            let check_fields =
+              value
+              |> std.record.fields
+              |> std.array.fold_left
+                (
+                  fun acc field_name =>
+                    if std.string.is_match "^\\d+$" field_name then
+                      acc # unused and always null through iteration
+                    else
+                      std.contract.blame_with_message "field name `%{field_name}` is not a number" label
+                )
+                null
             in
-            std.contract.apply Bool label_with_msg value
-        )
-      |> std.seq check_fields
-    else
-      std.contract.blame_with_message "not a record" label
+            value
+            |> std.record.map
+              (
+                fun name value =>
+                  let label_with_msg =
+                    std.contract.label.with_message "field `%{name}` is not a boolean" label
+                  in
+                  std.contract.apply Bool label_with_msg value
+              )
+            |> std.seq check_fields
+          else
+            std.contract.blame_with_message "not a record" label
+    )
 }
 ```
 
@@ -757,35 +807,39 @@ value and continues with the second argument (here, our wrapped `value`).
 Let us see if we indeed preserved laziness:
 
 ```nickel #repl
-#hide-range{1-29}
-
-> let NumberBoolDict = fun label value =>
-    if std.is_record value then
-      let check_fields =
-        value
-        |> std.record.fields
-        |> std.array.fold_left
-          (
-            fun acc field_name =>
-              if std.string.is_match "^\\d+$" field_name then
-                acc # unused and always null through iteration
-              else
-                std.contract.blame_with_message "field name `%{field_name}` is not a number" label
-          )
-          null
-      in
-      value
-      |> std.record.map
-        (
-          fun name value =>
-            let label_with_msg =
-              std.contract.label.with_message "field `%{name}` is not a boolean" label
+#hide-range{1-33}
+
+> let NumberBoolDict =
+    std.contract.custom
+      (
+        fun label value =>
+          if std.is_record value then
+            let check_fields =
+              value
+              |> std.record.fields
+              |> std.array.fold_left
+                (
+                  fun acc field_name =>
+                    if std.string.is_match "^\\d+$" field_name then
+                      acc # unused and always null through iteration
+                    else
+                      std.contract.blame_with_message "field name `%{field_name}` is not a number" label
+                )
+                null
             in
-            std.contract.apply Bool label_with_msg value
-        )
-      |> std.seq check_fields
-    else
-      std.contract.blame_with_message "not a record" label
+            value
+            |> std.record.map
+              (
+                fun name value =>
+                  let label_with_msg =
+                    std.contract.label.with_message "field `%{name}` is not a boolean" label
+                  in
+                  std.contract.apply Bool label_with_msg value
+              )
+            |> std.seq check_fields
+          else
+            std.contract.blame_with_message "not a record" label
+    )
 
 > let config | NumberBoolDict = {
     "1" | std.FailWith "ooch" = null, # same as our previous "fail"
@@ -800,35 +854,39 @@ Yes! Our contract doesn't unduly cause the evaluation of the field `"1"`. Does
 it check anything, though?
 
 ```nickel #repl
-#hide-range{1-29}
-
-> let NumberBoolDict = fun label value =>
-    if std.is_record value then
-      let check_fields =
-        value
-        |> std.record.fields
-        |> std.array.fold_left
-          (
-            fun acc field_name =>
-              if std.string.is_match "^\\d+$" field_name then
-                acc # unused and always null through iteration
-              else
-                std.contract.blame_with_message "field name `%{field_name}` is not a number" label
-          )
-          null
-      in
-      value
-      |> std.record.map
-        (
-          fun name value =>
-            let label_with_msg =
-              std.contract.label.with_message "field `%{name}` is not a boolean" label
+#hide-range{1-33}
+
+> let NumberBoolDict =
+    std.contract.custom
+      (
+        fun label value =>
+          if std.is_record value then
+            let check_fields =
+              value
+              |> std.record.fields
+              |> std.array.fold_left
+                (
+                  fun acc field_name =>
+                    if std.string.is_match "^\\d+$" field_name then
+                      acc # unused and always null through iteration
+                    else
+                      std.contract.blame_with_message "field name `%{field_name}` is not a number" label
+                )
+                null
             in
-            std.contract.apply Bool label_with_msg value
-        )
-      |> std.seq check_fields
-    else
-      std.contract.blame_with_message "not a record" label
+            value
+            |> std.record.map
+              (
+                fun name value =>
+                  let label_with_msg =
+                    std.contract.label.with_message "field `%{name}` is not a boolean" label
+                  in
+                  std.contract.apply Bool label_with_msg value
+              )
+            |> std.seq check_fields
+          else
+            std.contract.blame_with_message "not a record" label
+    )
 
 > let config | NumberBoolDict = {
     not_a_number = false,
@@ -864,7 +922,8 @@ Thus, performing the check for field names right away is fine: early error
 reporting is a good thing, and the field names are readily available, so we
 don't force the evaluation of something that wouldn't be evaluated without the
 contract. We could have made this check lazy by putting it inside each field,
-together with the `Bool` contract application.
+together with the `Bool` contract application, but in this case it's not
+necessary.
 
 #### Conclusion
 
@@ -872,5 +931,5 @@ Our `NumberBoolDict` contract doesn't perform all the checks needed right away.
 Instead, **it returns a new value, which is wrapping the original value with
 delayed checks inside**. Doing so preserves laziness of the language and only
 triggers the checks when the values are used or exported in a configuration.
-This is the reason for contracts to return a value, which must be the original
-value with potential delayed checks inside.
+This is the reason for custom contracts to return a value, which must be the
+original value with potential delayed checks inside.
diff --git a/doc/manual/types-vs-contracts.md b/doc/manual/types-vs-contracts.md
index ea291fce..0c9a1dd0 100644
--- a/doc/manual/types-vs-contracts.md
+++ b/doc/manual/types-vs-contracts.md
@@ -110,7 +110,7 @@ Usually, you should do **nothing**.
 
 At last, both type annotations and contracts come in handy for debugging. In
 this case, you don't have to follow the previous advice, and you can add type
-or contract annotation pretty much anywhere you see fit.
+and contract annotations pretty much anywhere you see fit.
 
 One useful pattern is to use a type wildcard `_`, which lets the typechecker
 figure out the type of an expression for you. If a dynamically typed expression
diff --git a/doc/manual/typing.md b/doc/manual/typing.md
index f1b67a2b..7163ab19 100644
--- a/doc/manual/typing.md
+++ b/doc/manual/typing.md
@@ -588,9 +588,9 @@ calling to the statically typed `std.array.filter` from dynamically typed code:
 ```nickel #repl
 > std.array.filter (fun x => if x % 2 == 0 then x else null) [1,2,3,4,5,6]
 error: contract broken by the caller of `filter`
-    ┌─ <stdlib/std.ncl>:373:25
+    ┌─ <stdlib/std.ncl>:377:25
     │
-373 │       : forall a. (a -> Bool) -> Array a -> Array a
+377 │       : forall a. (a -> Bool) -> Array a -> Array a
     │                         ---- expected return type of a function provided by the caller
     │
     ┌─ <repl-input-6>:1:55
diff --git a/examples/config-gcc/config-gcc.ncl b/examples/config-gcc/config-gcc.ncl
index 8bd8adce..624fff6b 100644
--- a/examples/config-gcc/config-gcc.ncl
+++ b/examples/config-gcc/config-gcc.ncl
@@ -10,52 +10,65 @@ let GccFlag =
       std.array.elem (std.string.substring 0 1 string) supported_flags
     in
 
-  fun label =>
-    match {
-      value if std.is_string value && is_valid_flag value =>
-        value,
-      { flag, arg } if std.array.elem flag supported_flags =>
-        # Normalize the tag to a string
-        "%{flag}%{arg}",
-      value if std.is_string value =>
-        std.contract.blame_with_message "unknown flag %{value}" label,
-      { flag, arg = _ } =>
-        std.contract.blame_with_message "unknown flag %{flag}" label,
-      { .. } =>
-        std.contract.blame_with_message
-          "bad record structure: missing field `flag` or `arg`"
-          label,
-      _ => std.contract.blame_with_message "expected record or string" label,
-    }
+  std.contract.custom
+    (
+      fun label =>
+        match {
+          value if std.is_string value && is_valid_flag value =>
+            value,
+          { flag, arg } if std.array.elem flag supported_flags =>
+            # Normalize the tag to a string
+            "%{flag}%{arg}",
+          value if std.is_string value =>
+            std.contract.blame_with_message "unknown flag %{value}" label,
+          { flag, arg = _ } =>
+            std.contract.blame_with_message "unknown flag %{flag}" label,
+          { .. } =>
+            std.contract.blame_with_message
+              "bad record structure: missing field `flag` or `arg`"
+              label,
+          _ => std.contract.blame_with_message "expected record or string" label,
+        }
+    )
 in
 
 let Path =
   let pattern = m%"^(.+)/([^/]+)$"% in
-  fun label value =>
-    if std.is_string value then
-      if std.string.is_match pattern value then
-        value
-      else
-        std.contract.blame_with_message "invalid path" label
-    else
-      std.contract.blame_with_message "not a string" label
+  std.contract.custom
+    (
+      fun label value =>
+        if std.is_string value then
+          if std.string.is_match pattern value then
+            value
+          else
+            std.contract.blame_with_message "invalid path" label
+        else
+          std.contract.blame_with_message "not a string" label
+    )
 in
 
-let SharedObjectFile = fun label value =>
-  if std.is_string value then
-    if std.string.is_match m%"\.so$"% value then
-      value
-    else
-      std.contract.blame_with_message "not an .so file" label
-  else
-    std.contract.blame_with_message "not a string" label
+let SharedObjectFile =
+  std.contract.custom
+    (
+      fun label value =>
+        if std.is_string value then
+          if std.string.is_match m%"\.so$"% value then
+            value
+          else
+            std.contract.blame_with_message "not an .so file" label
+        else
+          std.contract.blame_with_message "not a string" label
+    )
 in
 
-let OptLevel = fun label =>
-  match {
-    value @ (0 or 1 or 2) => value,
-    _ => std.contract.blame label,
-  }
+let OptLevel =
+  std.contract.from_predicate
+    (
+      match {
+        0 or 1 or 2 => true,
+        _ => false,
+      }
+    )
 in
 
 let Contract = {
diff --git a/examples/simple-contracts/simple-contract-bool.ncl b/examples/simple-contracts/simple-contract-bool.ncl
index 164da161..50d019b8 100644
--- a/examples/simple-contracts/simple-contract-bool.ncl
+++ b/examples/simple-contracts/simple-contract-bool.ncl
@@ -3,11 +3,15 @@
 # Example of simple custom contract, parametrized by a first argument.
 # In practice, for this kind of simple predicate, one should rather use
 # `contract.from_predicate`
-let EqualsTo = fun reference_value label value =>
-  if reference_value == value then
-    value
-  else
-    std.contract.blame label
+let EqualsTo = fun reference_value =>
+  std.contract.custom
+    (
+      fun label value =>
+        if reference_value == value then
+          value
+        else
+          std.contract.blame label
+    )
 in
 
 let AlwaysTrue = EqualsTo true in
diff --git a/examples/simple-contracts/simple-contract-div.ncl b/examples/simple-contracts/simple-contract-div.ncl
index e2ab82f2..66d10e53 100644
--- a/examples/simple-contracts/simple-contract-div.ncl
+++ b/examples/simple-contracts/simple-contract-div.ncl
@@ -2,18 +2,20 @@
 
 # /!\ THIS EXAMPLE IS EXPECTED TO FAIL
 # Illustrates a basic contract violation.
-let Even = fun label value =>
-  if std.is_number value && value % 2 == 0 then
-    value
-  else
-    std.contract.blame label
+let Even =
+  std.contract.from_predicate
+    (
+      fun value =>
+        std.is_number value && value % 2 == 0
+    )
 in
 
-let DivBy3 = fun label value =>
-  if std.is_number value && value % 3 == 0 then
-    value
-  else
-    std.contract.blame label
+let DivBy3 =
+  std.contract.from_predicate
+    (
+      fun value =>
+        std.is_number value && value % 3 == 0
+    )
 in
 
 # Will cause an error! 4 is not divisible by 3.
diff --git a/lsp/nls/src/background.rs b/lsp/nls/src/background.rs
index 772ad572..3beacacd 100644
--- a/lsp/nls/src/background.rs
+++ b/lsp/nls/src/background.rs
@@ -1,7 +1,7 @@
 use std::{
     collections::{HashMap, HashSet},
     path::PathBuf,
-    time::Duration,
+    time::{Duration, Instant},
 };
 
 use anyhow::anyhow;
@@ -21,6 +21,9 @@ use crate::{
 
 const EVAL_TIMEOUT: Duration = Duration::from_secs(1);
 const RECURSION_LIMIT: usize = 128;
+// The duration during which a file causing the evaluator to timeout will be blacklisted from further
+// evaluations
+const BLACKLIST_DURATION: Duration = Duration::from_secs(30);
 
 #[derive(Debug, Serialize, Deserialize)]
 enum Command {
@@ -139,9 +142,8 @@ struct SupervisorState {
     eval_stack: Vec<Url>,
 
     // If evaluating a file causes the worker to time out or crash, we blacklist that file
-    // and refuse to evaluate it anymore. This could be relaxed (e.g. maybe we're willing to
-    // try again after a certain amount of time?).
-    banned_files: HashSet<Url>,
+    // and refuse to evaluate it for `BLACKLIST_DURATION`
+    banned_files: HashMap<Url, Instant>,
 }
 
 impl SupervisorState {
@@ -151,7 +153,7 @@ impl SupervisorState {
             response_tx,
             contents: HashMap::new(),
             deps: HashMap::new(),
-            banned_files: HashSet::new(),
+            banned_files: HashMap::new(),
             eval_stack: Vec::new(),
         })
     }
@@ -227,14 +229,17 @@ impl SupervisorState {
                 self.deps.insert(uri, deps);
             }
             Command::EvalFile { uri } => {
-                if !self.banned_files.contains(&uri) {
-                    // If we re-request an evaluation, remove the old one. (This is quadratic in the
-                    // size of the eval stack, but it only contains unique entries so we don't expect it
-                    // to get big.)
-                    if let Some(idx) = self.eval_stack.iter().position(|u| u == &uri) {
-                        self.eval_stack.remove(idx);
+                match self.banned_files.get(&uri) {
+                    Some(blacklist_time) if blacklist_time.elapsed() < BLACKLIST_DURATION => {}
+                    _ => {
+                        // If we re-request an evaluation, remove the old one. (This is quadratic in the
+                        // size of the eval stack, but it only contains unique entries so we don't expect it
+                        // to get big.)
+                        if let Some(idx) = self.eval_stack.iter().position(|u| u == &uri) {
+                            self.eval_stack.remove(idx);
+                        }
+                        self.eval_stack.push(uri)
                     }
-                    self.eval_stack.push(uri);
                 }
             }
         }
@@ -271,7 +276,7 @@ impl SupervisorState {
                         // Most likely the background eval timed out (but it could be something
                         // more exotic, like failing to spawn the subprocess).
                         warn!("background eval failed: {e}");
-                        self.banned_files.insert(uri);
+                        self.banned_files.insert(uri, Instant::now());
                     }
                 }
             }


Test patch:
diff --git a/cli/tests/snapshot/snapshots/snapshot__eval_stderr_array_at_empty_array.ncl.snap b/cli/tests/snapshot/snapshots/snapshot__eval_stderr_array_at_empty_array.ncl.snap
index 1c19afdb..401ef8b1 100644
--- a/cli/tests/snapshot/snapshots/snapshot__eval_stderr_array_at_empty_array.ncl.snap
+++ b/cli/tests/snapshot/snapshots/snapshot__eval_stderr_array_at_empty_array.ncl.snap
@@ -4,9 +4,9 @@ expression: err
 ---
 error: contract broken by the caller of `at`
        invalid array indexing
-    ┌─ <stdlib/std.ncl>:162:9
+    ┌─ <stdlib/std.ncl>:166:9
     │
-162 │       | std.contract.unstable.IndexedArrayFun 'Index
+166 │       | std.contract.unstable.IndexedArrayFun 'Index
     │         -------------------------------------------- expected type
     │
     ┌─ [INPUTS_PATH]/errors/array_at_empty_array.ncl:3:16
@@ -21,5 +21,3 @@ note:
   │
 3 │ std.array.at 0 []
   │ ----------------- (1) calling at
-
-
diff --git a/cli/tests/snapshot/snapshots/snapshot__eval_stderr_array_at_out_of_bound.ncl.snap b/cli/tests/snapshot/snapshots/snapshot__eval_stderr_array_at_out_of_bound.ncl.snap
index 749e2a07..7e00925c 100644
--- a/cli/tests/snapshot/snapshots/snapshot__eval_stderr_array_at_out_of_bound.ncl.snap
+++ b/cli/tests/snapshot/snapshots/snapshot__eval_stderr_array_at_out_of_bound.ncl.snap
@@ -4,9 +4,9 @@ expression: err
 ---
 error: contract broken by the caller of `at`
        invalid array indexing
-    ┌─ <stdlib/std.ncl>:162:9
+    ┌─ <stdlib/std.ncl>:166:9
     │
-162 │       | std.contract.unstable.IndexedArrayFun 'Index
+166 │       | std.contract.unstable.IndexedArrayFun 'Index
     │         -------------------------------------------- expected type
     │
     ┌─ [INPUTS_PATH]/errors/array_at_out_of_bound.ncl:3:16
@@ -21,5 +21,3 @@ note:
   │
 3 │ std.array.at 2 [1]
   │ ------------------ (1) calling at
-
-
diff --git a/cli/tests/snapshot/snapshots/snapshot__eval_stderr_array_range_reversed_indices.ncl.snap b/cli/tests/snapshot/snapshots/snapshot__eval_stderr_array_range_reversed_indices.ncl.snap
index dcd64061..83a3dffa 100644
--- a/cli/tests/snapshot/snapshots/snapshot__eval_stderr_array_range_reversed_indices.ncl.snap
+++ b/cli/tests/snapshot/snapshots/snapshot__eval_stderr_array_range_reversed_indices.ncl.snap
@@ -4,9 +4,9 @@ expression: err
 ---
 error: contract broken by the caller of `range`
        invalid range
-    ┌─ <stdlib/std.ncl>:673:9
+    ┌─ <stdlib/std.ncl>:677:9
     │
-673 │       | std.contract.unstable.RangeFun Dyn
+677 │       | std.contract.unstable.RangeFun Dyn
     │         ---------------------------------- expected type
     │
     ┌─ [INPUTS_PATH]/errors/array_range_reversed_indices.ncl:3:19
diff --git a/cli/tests/snapshot/snapshots/snapshot__eval_stderr_array_range_step_negative_step.ncl.snap b/cli/tests/snapshot/snapshots/snapshot__eval_stderr_array_range_step_negative_step.ncl.snap
index b80bb099..f4c4f6b9 100644
--- a/cli/tests/snapshot/snapshots/snapshot__eval_stderr_array_range_step_negative_step.ncl.snap
+++ b/cli/tests/snapshot/snapshots/snapshot__eval_stderr_array_range_step_negative_step.ncl.snap
@@ -4,9 +4,9 @@ expression: err
 ---
 error: contract broken by the caller of `range_step`
        invalid range step
-    ┌─ <stdlib/std.ncl>:648:9
+    ┌─ <stdlib/std.ncl>:652:9
     │
-648 │       | std.contract.unstable.RangeFun (std.contract.unstable.RangeStep -> Dyn)
+652 │       | std.contract.unstable.RangeFun (std.contract.unstable.RangeStep -> Dyn)
     │         ----------------------------------------------------------------------- expected type
     │
     ┌─ [INPUTS_PATH]/errors/array_range_step_negative_step.ncl:3:27
diff --git a/cli/tests/snapshot/snapshots/snapshot__eval_stderr_caller_contract_violation.ncl.snap b/cli/tests/snapshot/snapshots/snapshot__eval_stderr_caller_contract_violation.ncl.snap
index f5e40cca..17416597 100644
--- a/cli/tests/snapshot/snapshots/snapshot__eval_stderr_caller_contract_violation.ncl.snap
+++ b/cli/tests/snapshot/snapshots/snapshot__eval_stderr_caller_contract_violation.ncl.snap
@@ -3,9 +3,9 @@ source: cli/tests/snapshot/main.rs
 expression: err
 ---
 error: contract broken by the caller of `map`
-    ┌─ <stdlib/std.ncl>:146:33
+    ┌─ <stdlib/std.ncl>:150:33
     │
-146 │       : forall a b. (a -> b) -> Array a -> Array b
+150 │       : forall a b. (a -> b) -> Array a -> Array b
     │                                 ------- expected type of the argument provided by the caller
     │
     ┌─ [INPUTS_PATH]/errors/caller_contract_violation.ncl:3:31
@@ -18,5 +18,3 @@ note:
   │
 3 │ std.array.map std.function.id 'not-an-array
   │ ------------------------------------------- (1) calling map
-
-