Repository: tweag/nickel
Pull request #1987 (2024-07-04 09:33:16)

Re-introduce unified representation for custom contracts, migrate builtin contracts
#1975 introduced a new model for custom contracts, where they are split between an immediate and a delayed part.

The motivation is to eventually introduce boolean combinators (`any_of`, `not`, etc.), and also to make the user interface of contracts more friendly.

This PR started as a migration of all the builtin contracts to the new model. However, it didn't exactly go as expected.

## Issues with split representation

### Data flow

Some cases in the stdlib required to pass data from the immediate part to the delayed part. Of course, this data can be recomputed, because the delayed part gets the original value as well, but those computations might be expensive: in our case, it's the projection of a record onto a record type (separating the tail from the rest), which is supposedly at least linear in the size of the records.

The original fix was to have a new possible return value for the immediate part, `'Ok {payload = _}`, and call the delayed part with this payload, so the delayed part becomes `fun payload label value => ...`. This adds complications in explaining this interface in the manual, and I originally kept two version of `std.contract.custom`, one without payload and one with payload.

What's more, the contract for record types makes more sense as one block. Separating it into the immediate part and the delayed part felt a bit artificial and made the logic harder to follow (although the forced separation is kinda the point of the current line of work).

### Contract composition

The split representation makes contract composition tricky:

```nickel
let Nullable = fun Contract =>
  std.contract.custom
    (fun value =>
      if value == null then
        'Done
      else
        'Ok
     )
    (std.contract.apply Contract)
  )
```

This is a natural definition of `Nullable`, but it's not the right one: `Nullable Number` is now a delayed contract (running the immediate part only tells you if the value is null, but not if it's a number, while it could be known immediately). So the right definition would be something like:

```nickel
let Nullable = fun Contract =>
  std.contract.custom
    (fun value =>
      if value == null then
        'Done
      else
        (std.contract.get_immediate Contract) value
     )
    (std.contract.get_delayed Contract)
  )
```

as noticed by @jneem in #1975, but this is more cumbersome and not very intuitive. 

### Implementation complexities

The split design also just makes for much more code in the implementation. Several things are duplicated for immediate and delayed part, each builtin contract now needs two internal implementations (the immediate and the delayed), this requires also more stdlib functions (`get_delayed`, `get_immediate`, `custom_piped`, etc.), and the design with `%contract/custom%` taking nullable parts was questionable (impossible to type statically, null tests in the internals module, etc.).

All in all, after the first round of migrating builtin contracts to the split representation, and migrating it back to the new representation of this PR, the diff was something like (before test fixes and documentation updates):

```
12 files changed, 685 insertions(+), 1147 deletions(-)
```

which does hint at the fact that the new approach is just simpler.

## A combined representation

This PR adopts a different representation. The fundamental observation is that we can encode the immediate/delayed distinction in the return type of a custom contract, using an enum:

```
CustomContractImp := Label -> Dyn -> [| 'Ok Dyn, 'Error {..} |]
```

This type is a mix of the type of the immediate part and the delayed part introduced in #1975. It takes both a value and a label, but return an enum. The idea is that immediate errors are reported using the return value (instead of blaming the label), which makes them catchable and subject to boolean operations. When the immediate checks succeed, the contract can return `'Ok`, but also provides the new value with the delayed checks. Now, it's quite easy to implement `get_immediate` and `get_delayed` in term of this unique representation:

```nickel
get_immediate : CustomContractImpl -> Validator = fun impl value =>
  impl null value
  |> match {
    'Ok _ => 'Ok,
    error => error,
  }

get_delayed: CustomContractImpl -> Delayed = fun impl label value =>
  impl label value
  |> match {
    'Ok value => value,
    error =>
      let label_with_error_data = [...] in
      std.contract.blame label_with_error_data,
  }
```

There is the minor detail that we need to provide a label in the immediate part, and we give `null`, which should be fine because the label shouldn't be used in the immediate check, but we can't predict that still. It's irrelevant anyway; we actually don't need proper `get_immediate` and `get_delayed` functions. The point is that this contract representation encodes the same information and is sufficient to implement boolean operations.

### Composition

With the all-smashed-in-one design, the naive composition has already the right properties:

```
let Nullable = fun Contract =>
  std.contract.custom
    (fun label value =>
      if value == null then
        'Ok
      else
        std.contract.apply_as_custom Contract label value
    )
```

There is one caveat: we can't use `std.contract.apply` here. I mean, we could:

```
let Nullable = fun Contract =>
  std.contract.custom
    (fun label value =>
      if value == null then
        'Ok
      else
        'Ok (std.contract.apply Contract label value)
    )
```

But we would be in the same situation as before: we artificially make `Contract` entirely delayed. So, this PR adds a `apply_as_custom`, whose name is to be bikeshed, which applies a contract but doesn't convet `'Error` to throwing blame error like `apply` does. Instead, it preserve the catchable errors and the immediate part of composition of contracts.

## Content

This PR changes the representation of custom contracts to the new one, and migrates all builtin contracts to the new representation. It adds some new primops and stdlib function, and update the documentation as well.

### Record contracts

Record contracts application was conveniently implemented just as a variant of the merge operation. This PR needs to adapt this code, because `merge` would signal immediate errors by returning a `BlameError`, which makes it delayed/non catchable. One possibility would have been to implement part of the contract in pure Nickel, but this is wasteful: merge has to perform a field splitting operation anyway, so checking for extra field immediately is basically free. Doing that in user-code would be duplicating this work, or would need to make yet a new version of merge that can take a partially prepared result.

In fact, it was simpler to just make the existing variant of merge to adapt to the custom contract signature: it wraps the result in `'Ok` and signal immediate errors by generating an `'Error` term.

## Future

- bikeshed the name of `std.contract.apply_as_custom`
- Implement the boolean operators on contracts

yannham: Thanks @jneem. I think this PR can wait a bit (and I was OOO as well anyway). It touches several core parts so it's probably better to wait for your second pair of eyes on the stdlib and internals changes. Although the internals are a bit intricate sometimes, so I understand that some parts might be hard to fully assess in one sweeping code review, but it's still good to have a look on it.
jneem: As for the bikeshedding part, I'd want a name that emphasizes the distinction between asserting (or throwing, or panicking) and returning an error. If the name "apply" weren't already fixed, I might go with `std.contract.assert` for the throwing variant and `std.contract.check` for the non-throwing variant. Given that `apply` is already fixed, though, I'm not sure that there's a good intuitive distinction between `apply` and `check`...

Other possibilities for the non-throwing variant: `eval`, `apply_without_blaming`
yannham: > though, I'm not sure that there's a good intuitive distinction between apply and check...

I agree, although `check` has the slight connotation of trying something? There's `try` as well but I fear this would be dangerous because it sounds like `try` makes all errors catchable, but delayed checks might still throw later and it's not catchable. So it's a "half-try", or "try immediate, then run delayed if it works". 

`check` or `eval` would still be reasonable as an arbitrary variation of `apply` (we just need to distinguish the two).

Another possibility that came to mind is something like Rust's `and_then` (or something along the lines: `then`, `continue_with`, `proceed_with`, etc.). The idea is that the main usage of `apply_as_custom` is to be called in tail position in a custom contract implementation (hence the continue/proceed idea). So there's an idea of chaining checks. Also, as for `and_then` in Rust, it has a monad-like feeling: while `apply` just returns the original value naked, `apply_as_custom` returns a `Result<_, BlameError>`, morally.
jneem: For the `and_then` analogy, I'd expect a signature more like `[| 'Ok .., 'Err .. |] -> Contract -> [| 'Ok .., 'Err .. |]`. What about `check_apply` or `try_apply`?
yannham: > For the and_then analogy, I'd expect a signature more like [| 'Ok .., 'Err .. |] -> Contract -> [| 'Ok .., 'Err .. |].

This is a good point, for a Rustacean. We could also chose to use it nonetheless, as it's a bit similar to do `Ok(value)).and_then(other_contract)`. But indeed it doesn't map exactly. Or maybe just call it `then` ?

> What about check_apply or try_apply?

Honestly I don't see the advantage of `check_apply` over `apply`. Same for `try_apply`, I think it conveys the same semantics as `try`, just with more letters, so IMHO it suffers from the same problem (make it look like it can catch blame errors).

I think I'll merge this PR to avoid drifting and rebasing too much, and we'll bikeshed the name in the weekly.


Patch:
diff --git a/HACKING.md b/HACKING.md
index 66c8f053..605fc2d7 100644
--- a/HACKING.md
+++ b/HACKING.md
@@ -148,12 +148,12 @@ Rust integration tests, which run the file and assert that no errors were
 raised during evaluation.
 
 Each of these `.ncl` files is structured as an array of `Bool` expressions,
-which is ultimately passed to `std.contract.check` function defined in the
-standard library. This function applies an `Assert` (`std.test.Assert`)
-contract to each value in the array, which checks that the value it is applied
-to evaluates to `true`. The benefit of using a contract for this is that if a
-test fails we can simply run the file directly using Nickel, which gives better
-error messages than the ones we get by default from `cargo test`.
+which is ultimately passed to `std.test.check` function defined in the standard
+library. This function applies an `Assert` (`std.test.Assert`) contract to each
+value in the array, which checks that the value it is applied to evaluates to
+`true`. The benefit of using a contract for this is that if a test fails we can
+simply run the file directly using Nickel, which gives better error messages
+than the ones we get by default from `cargo test`.
 
 Tests which are expected to fail may be written in Rust in `core/tests/integration`.
 However, simple failure test cases can make use of the test annotation support
diff --git a/core/src/eval/merge.rs b/core/src/eval/merge.rs
index 052bf194..2ffd4abd 100644
--- a/core/src/eval/merge.rs
+++ b/core/src/eval/merge.rs
@@ -26,18 +26,25 @@
 //! depend on each metadata.
 
 use super::*;
-use crate::closurize::Closurize;
-use crate::combine::Combine;
-use crate::error::{EvalError, IllegalPolymorphicTailAction};
-use crate::label::{Label, MergeLabel};
-use crate::position::TermPos;
-use crate::term::{
-    record::{self, Field, FieldDeps, FieldMetadata, RecordAttrs, RecordData},
-    BinaryOp, EnumVariantAttrs, IndexMap, RichTerm, Term, TypeAnnotation,
+use crate::{
+    closurize::Closurize,
+    combine::Combine,
+    error::{EvalError, IllegalPolymorphicTailAction},
+    label::{Label, MergeLabel},
+    position::TermPos,
+    term::{
+        make as mk_term,
+        record::{self, Field, FieldDeps, FieldMetadata, RecordAttrs, RecordData},
+        BinaryOp, EnumVariantAttrs, IndexMap, RichTerm, Term, TypeAnnotation,
+    },
 };
 
 /// Merging mode. Merging is used both to combine standard data and to apply contracts defined as
 /// records.
+///
+/// In [MergeMode::Contract] mode, the merge operator acts like a custom contract. Instead of
+/// returning the result directly, it either returns `'Ok result`, or `'Error {..}` if there were
+/// some unexpected extra fields.
 #[derive(Clone, PartialEq, Debug)]
 pub enum MergeMode {
     /// Standard merging, for combining data.
@@ -84,7 +91,12 @@ pub fn merge<C: Cache>(
         pos: pos2,
     } = t2;
 
-    match (t1.into_owned(), t2.into_owned()) {
+    // Determines if we need to wrap the result in `'Ok` upon successful merging, which is the case
+    // when in contract merge mode. We're going to move out of `mode` at some point, so we need to
+    // save this information now.
+    let wrap_in_ok = matches!(mode, MergeMode::Contract(_));
+
+    let result = match (t1.into_owned(), t2.into_owned()) {
         // Merge is idempotent on basic terms
         (Term::Null, Term::Null) => Ok(Closure::atomic_closure(RichTerm::new(
             Term::Null,
@@ -267,29 +279,28 @@ pub fn merge<C: Cache>(
             } = split::split(r1.fields, r2.fields);
 
             match mode {
-                MergeMode::Contract(label) if !r2.attrs.open && !left.is_empty() => {
+                MergeMode::Contract(_) if !r2.attrs.open && !left.is_empty() => {
                     let fields: Vec<String> =
                         left.keys().map(|field| format!("`{field}`")).collect();
                     let plural = if fields.len() == 1 { "" } else { "s" };
                     let fields_list = fields.join(", ");
 
-                    let label = label
-                        .with_diagnostic_message(format!("extra field{plural} {fields_list}"))
-                        .with_diagnostic_notes(vec![
-                            String::from("Have you misspelled a field?"),
-                            String::from(
-                                "The record contract might also be too strict. By default, \
-                                record contracts exclude any field which is not listed.\n\
-                                Append `, ..` at the end of the record contract, as in \
-                                `{some_field | SomeContract, ..}`, to make it accept extra fields.",
-                            ),
-                        ]);
-
-                    return Err(EvalError::BlameError {
-                        evaluated_arg: label.get_evaluated_arg(cache),
-                        label,
-                        call_stack: CallStack::new(),
-                    });
+                    // The presence of extra fields is an immediate contract error. Thus, instead
+                    // of raising a blame error as for a delayed contract error, which can't be
+                    // caught in user-code, we return an `'Error {..}` value instead.
+                    return Ok(Closure::atomic_closure(
+                        mk_term::enum_variant("Error", Term::Record(RecordData::with_field_values([
+                            ("message".into(), mk_term::string(format!("extra field{plural} {fields_list}"))),
+                            ("notes".into(), Term::Array([
+                                mk_term::string("Have you misspelled a field?"),
+                                mk_term::string(
+                                    "The record contract might also be too strict. By default, \
+                                    record contracts exclude any field which is not listed.\n\
+                                    Append `, ..` at the end of the record contract, as in \
+                                    `{some_field | SomeContract, ..}`, to make it accept extra fields."
+                                ),
+                            ].into_iter().collect(), Default::default()).into())
+                        ])))));
                 }
                 _ => (),
             };
@@ -354,20 +365,24 @@ pub fn merge<C: Cache>(
                 env: Environment::new(),
             })
         }
-        (t1_, t2_) => match (mode, &t2_) {
-            // We want to merge a non-record term with a record contract
-            (MergeMode::Contract(label), Term::Record(..)) => Err(EvalError::BlameError {
-                evaluated_arg: label.get_evaluated_arg(cache),
-                label,
-                call_stack: call_stack.clone(),
-            }),
-            // The following cases are either errors or not yet implemented
-            (mode, _) => Err(EvalError::MergeIncompatibleArgs {
-                left_arg: RichTerm::new(t1_, pos1),
-                right_arg: RichTerm::new(t2_, pos2),
-                merge_label: mode.into(),
-            }),
-        },
+        (t1_, t2_) => Err(EvalError::MergeIncompatibleArgs {
+            left_arg: RichTerm::new(t1_, pos1),
+            right_arg: RichTerm::new(t2_, pos2),
+            merge_label: mode.into(),
+        }),
+    };
+
+    if wrap_in_ok {
+        result.map(|closure| {
+            let pos = closure.body.pos;
+
+            Closure {
+                body: mk_term::enum_variant("Ok", closure.body).with_pos(pos),
+                ..closure
+            }
+        })
+    } else {
+        result
     }
 }
 
diff --git a/core/src/eval/operation.rs b/core/src/eval/operation.rs
index 666ef633..3ee4f6a0 100644
--- a/core/src/eval/operation.rs
+++ b/core/src/eval/operation.rs
@@ -21,12 +21,11 @@ use crate::{
     error::{EvalError, IllegalPolymorphicTailAction},
     identifier::LocIdent,
     label::{ty_path, Polarity, TypeVarData},
-    match_sharedterm, mk_app, mk_fun, mk_opn, mk_record,
+    match_sharedterm, mk_app, mk_fun, mk_record,
     parser::utils::parse_number_sci,
     position::TermPos,
     serialize,
     serialize::ExportFormat,
-    stdlib,
     stdlib::internals,
     term::{
         array::{Array, ArrayAttrs, OutOfBoundError},
@@ -1301,50 +1300,18 @@ impl<R: ImportResolver, C: Cache> VirtualMachine<R, C> {
                     _ => mk_type_error!("Record"),
                 })
             }
-            UnaryOp::ContractGetImmediate => match &*t {
-                Term::CustomContract(CustomContract {
-                    immediate,
-                    delayed: _,
-                }) => Ok(Closure {
-                    body: immediate
-                        .as_ref()
-                        .cloned()
-                        .unwrap_or_else(|| Term::Null.into()),
-                    env,
-                }),
-                Term::Record(_) => Ok(Closure {
-                    body: mk_app!(
-                        stdlib::internals::record_immediate(),
-                        RichTerm { term: t, pos }
-                    )
-                    .with_pos(pos_op_inh),
-                    env,
-                }),
-                Term::Type(_) => unimplemented!("contract/get_immediate on Type"),
-                _ => mk_type_error!("CustomContract or Record"),
-            },
-            UnaryOp::ContractGetDelayed => match &*t {
-                Term::CustomContract(CustomContract {
-                    immediate: _,
-                    delayed,
-                }) => Ok(Closure {
-                    body: delayed
-                        .as_ref()
-                        .cloned()
-                        .unwrap_or_else(|| Term::Null.into()),
-                    env,
-                }),
-                Term::Record(_) => Ok(Closure {
-                    body: mk_app!(
-                        stdlib::internals::record_delayed(),
-                        RichTerm { term: t, pos }
-                    )
-                    .with_pos(pos_op_inh),
-                    env,
-                }),
-                Term::Type(_) => unimplemented!("contract/get_immediate on Type"),
-                _ => mk_type_error!("CustomContract or Record"),
-            },
+            UnaryOp::ContractCustom => {
+                let contract = if let Term::Fun(..) | Term::Match(_) = &*t {
+                    RichTerm { term: t, pos }.closurize(&mut self.cache, env)
+                } else {
+                    return mk_type_error!("Function or MatchExpression");
+                };
+
+                Ok(Closure::atomic_closure(RichTerm::new(
+                    Term::CustomContract(contract),
+                    pos_op_inh,
+                )))
+            }
         }
     }
 
@@ -1561,75 +1528,105 @@ impl<R: ImportResolver, C: Cache> VirtualMachine<R, C> {
                     mk_type_error!("String", 1, t1, pos1)
                 }
             }
-            BinaryOp::ContractApply => {
-                if let Term::Lbl(l) = &*t2 {
+            BinaryOp::ContractApply | BinaryOp::ContractApplyAsCustom => {
+                // The translation of a type might return any kind of contract, including e.g.
+                // a record or a custom contract. The result thus needs to be passed to
+                // `ContractApply` or `ContractApplyAsCustom` again. In that case, we don't bother
+                // tracking the argument and updating the label: this will be done by the next call
+                // to `contract/apply(_as_custom)`.
+                if let Term::Type(typ) = &*t1 {
+                    return Ok(Closure {
+                        body: mk_term::op2(
+                            b_op,
+                            typ.contract()?,
+                            RichTerm {
+                                term: t2,
+                                pos: pos2,
+                            },
+                        )
+                        .with_pos(pos1),
+                        env: env1,
+                    });
+                }
+
+                let t2 = t2.into_owned();
+
+                if let Term::Lbl(mut label) = t2 {
                     // Track the contract argument for better error reporting, and push back the
                     // label on the stack, so that it becomes the first argument of the contract.
                     let idx = self.stack.track_arg(&mut self.cache).ok_or_else(|| {
                         EvalError::NotEnoughArgs(3, String::from("contract/apply"), pos_op)
                     })?;
-                    let mut l = l.clone();
-                    l.arg_pos = self.cache.get_then(idx.clone(), |c| c.body.pos);
-                    l.arg_idx = Some(idx);
 
+                    label.arg_pos = self.cache.get_then(idx.clone(), |c| c.body.pos);
+                    label.arg_idx = Some(idx);
+
+                    // We push the label back on the stack and we properly convert the
+                    // contract to a naked function of a label and a value, which will have the
+                    // stack in the same state as if it had been applied normally to both
+                    // arguments.
                     self.stack.push_arg(
-                        Closure::atomic_closure(RichTerm::new(Term::Lbl(l), pos2.into_inherited())),
+                        Closure::atomic_closure(RichTerm::new(
+                            Term::Lbl(label.clone()),
+                            pos2.into_inherited(),
+                        )),
                         pos2.into_inherited(),
                     );
 
                     match &*t1 {
-                        Term::Type(typ) => Ok(Closure {
-                            body: typ.contract()?,
-                            env: env1,
-                        }),
-                        Term::Fun(..) | Term::Match { .. } => Ok(Closure {
-                            body: RichTerm {
+                        Term::Fun(..) | Term::Match { .. } => {
+                            let as_naked = RichTerm {
                                 term: t1,
                                 pos: pos1,
-                            },
-                            env: env1,
-                        }),
-                        Term::CustomContract(..) => Ok(Closure {
-                            body: mk_app!(
-                                internals::prepare_contract(),
+                            };
+
+                            let body = if let BinaryOp::ContractApply = b_op {
+                                as_naked
+                            } else {
+                                mk_app!(internals::naked_to_custom(), as_naked).with_pos(pos1)
+                            };
+
+                            Ok(Closure { body, env: env1 })
+                        }
+                        Term::CustomContract(ctr) => {
+                            let body = if let BinaryOp::ContractApply = b_op {
+                                mk_app!(internals::prepare_custom_contract(), ctr.clone())
+                                    .with_pos(pos1)
+                            } else {
+                                ctr.clone()
+                            };
+
+                            Ok(Closure { body, env: env1 })
+                        }
+                        Term::Record(..) => {
+                            let pos1_inh = pos1.into_inherited();
+
+                            // Convert the record to the builtin contract implementation
+                            // `$record_contract <record>`.
+                            let as_custom = mk_app!(
+                                internals::record_contract(),
                                 RichTerm {
                                     term: t1,
                                     pos: pos1
                                 }
                             )
-                            .with_pos(pos1),
-                            env: env1,
-                        }),
-                        Term::Record(..) => {
-                            let closurized = RichTerm {
-                                term: t1,
-                                pos: pos1,
-                            }
-                            .closurize(&mut self.cache, env1);
-
-                            // Convert the record to the function `fun l x => MergeContract l x t1
-                            // contract`.
-                            let body = mk_fun!(
-                                "l",
-                                "x",
-                                mk_opn!(
-                                    NAryOp::MergeContract,
-                                    mk_term::var("l"),
-                                    mk_term::var("x"),
-                                    closurized
-                                )
-                            )
-                            .with_pos(pos1.into_inherited());
+                            .with_pos(pos1_inh);
 
-                            Ok(Closure {
-                                body,
-                                env: Environment::new(),
-                            })
+                            let body = if let BinaryOp::ContractApply = b_op {
+                                // Convert the record to the builtin contract implementation
+                                // `$prepare_custom_contract ($record_contract <record>)`.
+                                mk_app!(internals::prepare_custom_contract(), as_custom)
+                                    .with_pos(pos1_inh)
+                            } else {
+                                as_custom
+                            };
+
+                            Ok(Closure { body, env: env1 })
                         }
                         _ => mk_type_error!("Contract", 1, t1, pos1),
                     }
                 } else {
-                    mk_type_error!("Label", 2, t2, pos2)
+                    mk_type_error!("Label", 2, t2.into(), pos2)
                 }
             }
             BinaryOp::Unseal => {
@@ -2595,17 +2592,26 @@ impl<R: ImportResolver, C: Cache> VirtualMachine<R, C> {
                     return mk_type_error!("Record", 2, t2.into(), pos2);
                 };
 
-                // As for merge, we refuse to combine two records if one of them has a sealed tail
-                if let Some(record::SealedTail { label, .. }) =
-                    record1.sealed_tail.or(record2.sealed_tail)
-                {
-                    return Err(EvalError::IllegalPolymorphicTailAccess {
-                        action: IllegalPolymorphicTailAction::Merge,
-                        evaluated_arg: label.get_evaluated_arg(&self.cache),
-                        label,
-                        call_stack: std::mem::take(&mut self.call_stack),
-                    });
-                }
+                // As for merge, we refuse to combine two records if one of them has a sealed tail.
+                // However, if only one of them does, because we don't do any recursive
+                // re-evaluation here, it's fine to just pick this tail as the tail of the result.
+                //
+                // This behavior is actually useful, because disjoint_merge is used in the
+                // implementation of builtin contracts to combine an unsealed tail with the
+                // original body of the record. In that case, the unsealed tail might have an
+                // additional sealed tail itself (tail can be sealed multiple times in a nested
+                // way), and the right behavior is to just keep it.
+                let sealed_tail = match (record1.sealed_tail, record2.sealed_tail) {
+                    (Some(record::SealedTail { label, .. }), Some(_)) => {
+                        return Err(EvalError::IllegalPolymorphicTailAccess {
+                            action: IllegalPolymorphicTailAction::Merge,
+                            evaluated_arg: label.get_evaluated_arg(&self.cache),
+                            label,
+                            call_stack: std::mem::take(&mut self.call_stack),
+                        })
+                    }
+                    (tail1, tail2) => tail1.or(tail2),
+                };
 
                 // Note that because of record closurization, we assume here that the record data
                 // of each record are already closurized, so we don't really care about
@@ -2619,44 +2625,11 @@ impl<R: ImportResolver, C: Cache> VirtualMachine<R, C> {
                     Term::Record(RecordData {
                         fields: record1.fields,
                         attrs: record1.attrs,
-                        // We need to set the tail to `None` explicitly to appease the borrow
-                        // checker which objects that `sealed_tail` has been moved in the if
-                        // let above.
-                        sealed_tail: None,
+                        sealed_tail,
                     }),
                     pos_op_inh,
                 )))
             }
-            BinaryOp::ContractCustom => {
-                let immediate = match &*t1 {
-                    Term::Fun(..) | Term::Match(_) => Some(
-                        RichTerm {
-                            term: t1,
-                            pos: pos1,
-                        }
-                        .closurize(&mut self.cache, env1),
-                    ),
-                    Term::Null => None,
-                    _ => return mk_type_error!("Function or MatchExpression", 1, t1, pos1),
-                };
-
-                let delayed = match &*t2 {
-                    Term::Fun(..) | Term::Match(_) => Some(
-                        RichTerm {
-                            term: t2,
-                            pos: pos2,
-                        }
-                        .closurize(&mut self.cache, env2),
-                    ),
-                    Term::Null => None,
-                    _ => return mk_type_error!("Function or MatchExpression", 2, t2, pos2),
-                };
-
-                Ok(Closure::atomic_closure(RichTerm::new(
-                    Term::CustomContract(CustomContract { immediate, delayed }),
-                    pos_op_inh,
-                )))
-            }
         }
     }
 
@@ -2746,6 +2719,7 @@ impl<R: ImportResolver, C: Cache> VirtualMachine<R, C> {
             }
             NAryOp::MergeContract => {
                 let mut args_iter = args.into_iter();
+
                 let (
                     Closure {
                         body: RichTerm { term: t1, pos: _ },
@@ -2753,6 +2727,7 @@ impl<R: ImportResolver, C: Cache> VirtualMachine<R, C> {
                     },
                     _,
                 ) = args_iter.next().unwrap();
+
                 let (
                     Closure {
                         body:
@@ -2764,6 +2739,7 @@ impl<R: ImportResolver, C: Cache> VirtualMachine<R, C> {
                     },
                     _,
                 ) = args_iter.next().unwrap();
+
                 let (
                     Closure {
                         body:
@@ -2775,6 +2751,7 @@ impl<R: ImportResolver, C: Cache> VirtualMachine<R, C> {
                     },
                     _,
                 ) = args_iter.next().unwrap();
+
                 debug_assert!(args_iter.next().is_none());
 
                 match_sharedterm!(match (t1) {
diff --git a/core/src/parser/grammar.lalrpop b/core/src/parser/grammar.lalrpop
index 7c5a80ad..ea8ffd9f 100644
--- a/core/src/parser/grammar.lalrpop
+++ b/core/src/parser/grammar.lalrpop
@@ -1127,8 +1127,7 @@ UOp: UnaryOp = {
     "enum/make_variant" => UnaryOp::EnumMakeVariant,
     "enum/is_variant" => UnaryOp::EnumIsVariant,
     "enum/get_tag" => UnaryOp::EnumGetTag,
-    "contract/get_immediate" => UnaryOp::ContractGetImmediate,
-    "contract/get_delayed" => UnaryOp::ContractGetDelayed,
+    "contract/custom" => UnaryOp::ContractCustom,
 }
 
 PatternGuard: RichTerm = "if" <Term> => <>;
@@ -1299,9 +1298,9 @@ InfixExpr: UniTerm = {
 
 BOpPre: BinaryOp = {
     "contract/apply" => BinaryOp::ContractApply,
+    "contract/apply_as_custom" => BinaryOp::ContractApplyAsCustom,
     "contract/array_lazy_app" => BinaryOp::ContractArrayLazyApp,
     "contract/record_lazy_app" => BinaryOp::ContractRecordLazyApp,
-    "contract/custom" => BinaryOp::ContractCustom,
     "unseal" => BinaryOp::Unseal,
     "seal" => BinaryOp::Seal,
     "label/go_field" => BinaryOp::LabelGoField,
@@ -1354,6 +1353,8 @@ NOpPre<ArgRule>: UniTerm = {
         UniTerm::from(mk_opn!(NAryOp::LabelInsertTypeVar, key, pol, label)),
     "array/slice" <t1: ArgRule> <t2: ArgRule> <t3: ArgRule> =>
         UniTerm::from(mk_opn!(NAryOp::ArraySlice, t1, t2, t3)),
+    "record/merge_contract" <t1: ArgRule> <t2: ArgRule> <t3: ArgRule> =>
+        UniTerm::from(mk_opn!(NAryOp::MergeContract, t1, t2, t3)),
 }
 
 TypeBuiltin: Type = {
@@ -1516,11 +1517,10 @@ extern {
 
         "typeof" => Token::Normal(NormalToken::Typeof),
         "contract/apply" => Token::Normal(NormalToken::ContractApply),
+        "contract/apply_as_custom" => Token::Normal(NormalToken::ContractApplyAsCustom),
         "contract/array_lazy_app" => Token::Normal(NormalToken::ContractArrayLazyApp),
         "contract/record_lazy_app" => Token::Normal(NormalToken::ContractRecordLazyApp),
         "contract/custom" => Token::Normal(NormalToken::ContractCustom),
-        "contract/get_immediate" => Token::Normal(NormalToken::ContractGetImmediate),
-        "contract/get_delayed" => Token::Normal(NormalToken::ContractGetDelayed),
         "op force" => Token::Normal(NormalToken::OpForce),
         "blame" => Token::Normal(NormalToken::Blame),
         "label/flip_polarity" => Token::Normal(NormalToken::LabelFlipPol),
@@ -1560,6 +1560,7 @@ extern {
         "record/field_is_defined_with_opts" => Token::Normal(NormalToken::RecordFieldIsDefinedWithOpts),
         "record/split_pair" => Token::Normal(NormalToken::RecordSplitPair),
         "record/disjoint_merge" => Token::Normal(NormalToken::RecordDisjointMerge),
+        "record/merge_contract" => Token::Normal(NormalToken::RecordMergeContract),
         "array/map" => Token::Normal(NormalToken::ArrayMap),
         "array/generate" => Token::Normal(NormalToken::ArrayGen),
         "array/at" => Token::Normal(NormalToken::ArrayAt),
diff --git a/core/src/parser/lexer.rs b/core/src/parser/lexer.rs
index b67d33d4..b661faa4 100644
--- a/core/src/parser/lexer.rs
+++ b/core/src/parser/lexer.rs
@@ -196,16 +196,14 @@ pub enum NormalToken<'input> {
 
     #[token("%contract/apply%")]
     ContractApply,
+    #[token("%contract/apply_as_custom%")]
+    ContractApplyAsCustom,
     #[token("%contract/array_lazy_apply%")]
     ContractArrayLazyApp,
     #[token("%contract/record_lazy_apply%")]
     ContractRecordLazyApp,
     #[token("%contract/custom%")]
     ContractCustom,
-    #[token("%contract/get_immediate%")]
-    ContractGetImmediate,
-    #[token("%contract/get_delayed%")]
-    ContractGetDelayed,
     #[token("%blame%")]
     Blame,
     #[token("%label/flip_polarity%")]
@@ -291,6 +289,8 @@ pub enum NormalToken<'input> {
     RecordSplitPair,
     #[token("%record/disjoint_merge%")]
     RecordDisjointMerge,
+    #[token("%record/merge_contract%")]
+    RecordMergeContract,
 
     #[token("merge")]
     Merge,
diff --git a/core/src/pretty.rs b/core/src/pretty.rs
index 14aad074..9a616ff4 100644
--- a/core/src/pretty.rs
+++ b/core/src/pretty.rs
@@ -5,9 +5,6 @@ use crate::parser::lexer::KEYWORDS;
 use crate::term::{
     pattern::*,
     record::{Field, FieldMetadata, RecordData},
-    // Because we use `Term::*`, we need to differentiate `Contract` from `Term::Contract`, so we
-    // alias the latter
-    CustomContract as ContractNode,
     *,
 };
 use crate::typ::*;
@@ -822,27 +819,14 @@ where
             StrChunks(chunks) => allocator.chunks(chunks, StringRenderStyle::Multiline),
             Fun(id, body) => allocator.function(allocator.as_string(id), body),
             // Format this as the primop application
-            // `<custom contract constructor> <immediate> <delayed>`.
-            CustomContract(ContractNode { immediate, delayed }) => {
-                let arg_or_null = |arg: &Option<RichTerm>| {
-                    docs![
-                        allocator,
-                        allocator.line(),
-                        if let Some(arg) = arg {
-                            allocator.atom(arg)
-                        } else {
-                            allocator.text("null")
-                        }
-                    ]
-                    .nest(2)
-                    .group()
-                };
-
+            // `%contract/custom% <ctr>`.
+            CustomContract(ctr) => {
                 docs![
                     allocator,
                     "%contract/custom%",
-                    arg_or_null(immediate),
-                    arg_or_null(delayed)
+                    docs![allocator, allocator.line(), allocator.atom(ctr),]
+                        .nest(2)
+                        .group(),
                 ]
             }
             FunPattern(pat, body) => allocator.function(allocator.pat_with_parens(pat), body),
diff --git a/core/src/stdlib.rs b/core/src/stdlib.rs
index e4f7d9d0..2b238c5a 100644
--- a/core/src/stdlib.rs
+++ b/core/src/stdlib.rs
@@ -73,7 +73,6 @@ pub mod internals {
     generate_accessor!(bool);
     generate_accessor!(foreign_id);
     generate_accessor!(string);
-    generate_accessor!(fail);
 
     generate_accessor!(array);
     generate_accessor!(array_dyn);
@@ -90,8 +89,8 @@ pub mod internals {
     generate_accessor!(enum_variant);
     generate_accessor!(forall_enum_tail);
 
-    generate_accessor!(record);
-    generate_accessor!(record_extend);
+    generate_accessor!(record_contract);
+    generate_accessor!(record_type);
     generate_accessor!(forall_record_tail);
     generate_accessor!(dyn_tail);
     generate_accessor!(empty_tail);
@@ -102,9 +101,8 @@ pub mod internals {
 
     generate_accessor!(stdlib_contract_equal);
 
-    generate_accessor!(prepare_contract);
-    generate_accessor!(record_immediate);
-    generate_accessor!(record_delayed);
+    generate_accessor!(prepare_custom_contract);
+    generate_accessor!(naked_to_custom);
 
     generate_accessor!(rec_default);
     generate_accessor!(rec_force);
diff --git a/core/src/term/mod.rs b/core/src/term/mod.rs
index d6b87c3d..9c20feab 100644
--- a/core/src/term/mod.rs
+++ b/core/src/term/mod.rs
@@ -216,10 +216,50 @@ pub enum Term {
     #[serde(skip)]
     Type(Type),
 
-    /// A custom contract. See the documentation of [CustomContract] for more details on the split
-    /// immediate/delayed representation.
+    /// A custom contract. The content must be a function (or function-like terms like a match
+    /// expression) of two arguments: a label and the value to be checked. In particular, it must
+    /// be a weak-head normal form, and this invariant may be relied upon elsewhere in the
+    /// codebase (although it's not the case at the time of writing, to the best of my knowledge).
+    ///
+    /// Having a separate node for custom contracts lets us leverage the additional information for
+    /// example to implement a restricted `or` combinator on contracts, which needs to know which
+    /// contracts support booleans operations (predicates and validators), or for better error
+    /// messages in the future when parametric contracts aren't fully applied
+    /// ([#1460](https://github.com/tweag/nickel/issues/1460)). In the future, the custom contract
+    /// node might also include even more metadata.
+    ///
+    /// # Immediate and delayed parts
+    ///
+    /// Custom contracts usually have two parts, an immediate part and a delayed part.
+    ///
+    /// The immediate part is similar to a predicate or a validator: this is a function that takes a
+    /// value and return either `'Ok` or `'Error {..}`. The immediate part gathers the checks that can
+    /// be done eagerly, without forcing the value (the immediate part can actually force the value,
+    /// but it's up to the implementer to decide - for builtin contracts, the immediate part never
+    /// forces values)
+    ///
+    /// The delayed part is a partial identity which takes a label and the value and either blames or
+    /// return the value with potential delayed checks buried inside.
+    ///
+    /// Note that this is a conceptual distinction. It did happen that we experimented with making
+    /// this distinction explicit, with custom contracts being represented by two different
+    /// functions, one for each part. But this proved to be cumbersome in many ways (both for us
+    /// language developers and for users). Instead, we decided to make custom contracts just one
+    /// function of type `Label -> Dyn -> [| 'Ok Dyn, 'Error {..} |]`, which gives enough
+    /// information to extract the immediate and the delayed part anyway. The delayed part, if any,
+    /// is embedded in the return value of the case `'Ok Dyn`, where the argument is the original
+    /// value with the delayed checks inside.
+    ///
+    /// # Naked functions as custom contracts
+    ///
+    /// Nowadays, using dedicated constructors is the only documented way of creating custom
+    /// contracts: `std.contract.custom`, `std.contract.from_validator`, etc. The requirement to
+    /// use those dedicated constructors is unfortunately a breaking change (prior to Nickel 1.8)
+    /// as custom contracts were written as naked functions before. Using naked functions is
+    /// discouraged and will be deprecated in the future, but `%contract/apply%` still supports
+    /// them.
     #[serde(skip)]
-    CustomContract(CustomContract),
+    CustomContract(RichTerm),
 
     /// A term that couldn't be parsed properly. Used by the LSP to handle partially valid
     /// programs.
@@ -366,45 +406,7 @@ pub enum BindingType {
     Revertible(FieldDeps),
 }
 
-/// A custom contract.
-///
-/// Having a separate node for custom contracts lets us leverage the additional information for
-/// example to implement a restricted `or` combinator on contracts, which needs to know which
-/// contracts support booleans operations (predicates and validators), or for better error messages
-/// in the future when parametric contracts aren't fully applied
-/// ([#1460](https://github.com/tweag/nickel/issues/1460)).
-///
-/// # Immediate and delayed contracts
-///
-/// Custom contracts have two parts: an immediate part and a delayed part.
-///
-/// The immediate part is similar to a predicate or a validator: this is a function that takes a
-/// value and return either `'Ok`, `'Proceed` or `'Error {..}`. The immediate part gather the
-/// checks that can be done eagerly, without forcing the value (the immediate part can actually
-/// force the value, but it's up to the implementer to decide - for builtin contracts, the
-/// immediate part never forces values)
-///
-/// The delayed part is a partial identity which takes a label and the value and either blames or
-/// return the value with potential delayed checks buried inside.
-///
-/// Each part is optional. If the part isn't set, it's considered to be an always-accepting
-/// function: that is, an immediate part sets to `None` is equivalent to `Some (fun _ => 'Proceed)`
-/// and a delayed part set to `None` is equivalent to `Some (fun label value => value)`.
-///
-/// # Naked functions as custom contracts
-///
-/// Nowadays, using dedicated constructors is the only documented way of creating custom contracts:
-/// `std.contract.custom`, `std.contract.from_record`, etc. The requirement to use those dedicated
-/// constructors is unfortunately a breaking change (prior to Nickel 1.8) as custom contracts were
-/// previously written as naked functions. Using naked functions is discouraged and will be
-/// deprecated in the future.
-#[derive(Debug, Clone, Serialize, Deserialize)]
-pub struct CustomContract {
-    /// The immediate part of the contract.
-    pub immediate: Option<RichTerm>,
-    /// The delayed part of the contract.
-    pub delayed: Option<RichTerm>,
-}
+pub struct CustomContract(pub RichTerm);
 
 /// A runtime representation of a contract, as a term and a label ready to be applied via
 /// [BinaryOp::ContractApply].
@@ -1443,12 +1445,10 @@ pub enum UnaryOp {
     /// priority annotation.
     RecForce,
 
-    /// Creates an "empty" record with the sealed tail of its [`Term::Record`]
-    /// argument.
+    /// Creates an "empty" record with the sealed tail of its [`Term::Record`] argument.
     ///
-    /// Used in the `$record` contract implementation to ensure that we can
-    /// define a `field_diff` function that preserves the sealed polymorphic
-    /// tail of its argument.
+    /// Used in the `$record` contract implementation to ensure that we can define a `field_diff`
+    /// function that preserves the sealed polymorphic tail of its argument.
     RecordEmptyWithTail,
 
     /// Print a message when encountered during evaluation and proceed with the evaluation of the
@@ -1462,7 +1462,8 @@ pub enum UnaryOp {
     /// contract application.
     LabelPushDiag,
 
-    /// Evaluate a string of nix code into a resulting nickel value. Currently completely (strictly) evaluates the nix code, and must result in a value serializable into JSON.
+    /// Evaluate a string of nix code into a resulting nickel value. Currently completely
+    /// (strictly) evaluates the nix code, and must result in a value serializable into JSON.
     #[cfg(feature = "nix-experimental")]
     EvalNix,
 
@@ -1490,11 +1491,9 @@ pub enum UnaryOp {
     /// used blindly for something else.
     PatternBranch,
 
-    /// Extract the immediate part of a contract.
-    ContractGetImmediate,
-
-    /// Extract the delayed part of a contract.
-    ContractGetDelayed,
+    /// Wrap a contract implementation as a [CustomContract]. You can think of this primop as a
+    /// type constructor for custom contracts.
+    ContractCustom,
 }
 
 impl fmt::Display for UnaryOp {
@@ -1556,8 +1555,7 @@ impl fmt::Display for UnaryOp {
             EnumGetTag => write!(f, "enum/get_tag"),
 
             PatternBranch => write!(f, "pattern_branch"),
-            ContractGetImmediate => write!(f, "contract/get_immediate"),
-            ContractGetDelayed => write!(f, "contract/get_delayed"),
+            ContractCustom => write!(f, "contract/custom"),
         }
     }
 }
@@ -1674,8 +1672,30 @@ pub enum BinaryOp {
     /// also accepts contracts as records, which are translated to a function that merge said
     /// contract with its argument. Finally, this operator marks the location of the contract
     /// argument on the stack for better error reporting.
+    ///
+    /// Either the contract raises a blame error, or the primop evaluates to the value returned by
+    /// the contract that can be used in place of the original value.
     ContractApply,
 
+    /// Variant of [Self::ContractApply] which also applies an arbitrary contract to a label
+    /// and a value, but instead of either blaming or returning the value, it has the same return
+    /// value as a custom contract built via [UnaryOp::ContractCustom], that is `[| 'Ok Dyn, 'Error
+    /// {..}|]`. The value returned through `'Ok` can still have lazy blame expressions inside, of
+    /// course.
+    ///
+    /// Put differently, `%contract/apply_as_custom% (%contract/custom% custom) label value` is
+    /// equivalent to `custom label value`, modulo argument tracking. [Self::ContractApplyAsCustom]
+    /// doesn't only work on custom contracts, but on builtin contracts as well.
+    ///
+    /// This operation is useful for contract composition, that is when calling a contract from
+    /// another contract. In theory, one could use [Self::ContractApply], but the caller then needs
+    /// to wrap the result in `'Ok`, and much more importantly, `%contract/apply%` converts all
+    /// immediate errors returned as `'Error` into blame errors. This is not desirable, as blame
+    /// errors can't be caught, which artificially makes the called contract entirely delayed. This
+    /// typically wouldn't play very well with boolean combinators. On the other hand,
+    /// [Self::ContractApplyAsCustom] preserves the immediate/delayed part of the called contract.
+    ContractApplyAsCustom,
+
     /// Unseal a sealed term.
     ///
     /// See [`BinaryOp::Seal`].
@@ -1794,10 +1814,6 @@ pub enum BinaryOp {
     /// Look up the [`crate::label::TypeVarData`] associated with a [`SealingKey`] in the type
     /// environment of a [label](Term::Lbl)
     LabelLookupTypeVar,
-
-    /// Wrap a partial identity function as a [CustomContract]. You can think of this primop as a
-    /// type constructor for contracts.
-    ContractCustom,
 }
 
 impl BinaryOp {
@@ -1828,6 +1844,7 @@ impl fmt::Display for BinaryOp {
             GreaterThan => write!(f, "(>)"),
             GreaterOrEq => write!(f, "(>=)"),
             ContractApply => write!(f, "contract/apply"),
+            ContractApplyAsCustom => write!(f, "contract/apply_as_custom"),
             Unseal => write!(f, "unseal"),
             LabelGoField => write!(f, "label/go_field"),
             RecordInsert {
@@ -1865,7 +1882,6 @@ impl fmt::Display for BinaryOp {
             Seal => write!(f, "seal"),
             ContractArrayLazyApp => write!(f, "contract/array_lazy_apply"),
             ContractRecordLazyApp => write!(f, "contract/record_lazy_apply"),
-            ContractCustom => write!(f, "contract/custom"),
             LabelWithMessage => write!(f, "label/with_message"),
             LabelWithNotes => write!(f, "label/with_notes"),
             LabelAppendNote => write!(f, "label/append_note"),
@@ -1948,7 +1964,7 @@ impl fmt::Display for NAryOp {
             StringReplace => write!(f, "string/replace"),
             StringReplaceRegex => write!(f, "string/replace_regex"),
             StringSubstr => write!(f, "string/substr"),
-            MergeContract => write!(f, "merge_contract"),
+            MergeContract => write!(f, "record/merge_contract"),
             RecordSealTail => write!(f, "record/seal_tail"),
             RecordUnsealTail => write!(f, "record/unseal_tail"),
             LabelInsertTypeVar => write!(f, "label/insert_type_variable"),
@@ -2135,14 +2151,9 @@ impl Traverse<RichTerm> for RichTerm {
                 let t = t.traverse(f, order)?;
                 RichTerm::new(Term::FunPattern(pat, t), pos)
             }
-            Term::CustomContract(CustomContract { immediate, delayed }) => {
-                let immediate = immediate.map(|t| t.traverse(f, order)).transpose()?;
-                let delayed = delayed.map(|t| t.traverse(f, order)).transpose()?;
-
-                RichTerm::new(
-                    Term::CustomContract(CustomContract { immediate, delayed }),
-                    pos,
-                )
+            Term::CustomContract(t) => {
+                let t = t.traverse(f, order)?;
+                RichTerm::new(Term::CustomContract(t), pos)
             }
             Term::Let(id, t1, t2, attrs) => {
                 let t1 = t1.traverse(f, order)?;
@@ -2336,11 +2347,8 @@ impl Traverse<RichTerm> for RichTerm {
             | Term::FunPattern(_, t)
             | Term::EnumVariant { arg: t, .. }
             | Term::Op1(_, t)
-            | Term::Sealed(_, t, _) => t.traverse_ref(f, state),
-            Term::CustomContract(CustomContract { immediate, delayed }) => immediate
-                .as_ref()
-                .and_then(|t| t.traverse_ref(f, state))
-                .or_else(|| delayed.as_ref().and_then(|t| t.traverse_ref(f, state))),
+            | Term::Sealed(_, t, _)
+            | Term::CustomContract(t) => t.traverse_ref(f, state),
             Term::Let(_, t1, t2, _)
             | Term::LetPattern(_, t1, t2)
             | Term::App(t1, t2)
@@ -2770,6 +2778,26 @@ pub mod make {
         }
         term
     }
+
+    pub fn enum_variant<S, T>(tag: S, arg: T) -> RichTerm
+    where
+        S: Into<LocIdent>,
+        T: Into<RichTerm>,
+    {
+        Term::EnumVariant {
+            tag: tag.into(),
+            arg: arg.into(),
+            attrs: Default::default(),
+        }
+        .into()
+    }
+
+    pub fn custom_contract<T>(contract: T) -> RichTerm
+    where
+        T: Into<RichTerm>,
+    {
+        Term::CustomContract(contract.into()).into()
+    }
 }
 
 #[cfg(test)]
diff --git a/core/src/transform/free_vars.rs b/core/src/transform/free_vars.rs
index 2b3beedd..823719c4 100644
--- a/core/src/transform/free_vars.rs
+++ b/core/src/transform/free_vars.rs
@@ -106,18 +106,10 @@ impl CollectFreeVars for RichTerm {
                     free_vars.extend(fresh);
                 }
             }
-            Term::Op1(_, t) | Term::Sealed(_, t, _) | Term::EnumVariant { arg: t, .. } => {
-                t.collect_free_vars(free_vars)
-            }
-            Term::CustomContract(custom_contract) => {
-                if let Some(ref mut immediate) = custom_contract.immediate {
-                    immediate.collect_free_vars(free_vars);
-                }
-
-                if let Some(ref mut delayed) = custom_contract.delayed {
-                    delayed.collect_free_vars(free_vars);
-                }
-            }
+            Term::Op1(_, t)
+            | Term::Sealed(_, t, _)
+            | Term::EnumVariant { arg: t, .. }
+            | Term::CustomContract(t) => t.collect_free_vars(free_vars),
             Term::OpN(_, ts) => {
                 for t in ts {
                     t.collect_free_vars(free_vars);
diff --git a/core/src/typ.rs b/core/src/typ.rs
index df31c781..916a2f76 100644
--- a/core/src/typ.rs
+++ b/core/src/typ.rs
@@ -845,7 +845,16 @@ impl Subcontract for Type {
                 s.subcontract(vars.clone(), pol.flip(), sy)?,
                 t.subcontract(vars, pol, sy)?
             ),
-            TypeF::Flat(ref t) => t.clone(),
+            // Note that we do an early return here.
+            //
+            // All builtin contracts needs an additional wrapping as a `CustomContract`: they're
+            // written as a custom contract but they miss the `%contract/custom%` (mostly because
+            // it's nicer to do it just once at the end than littering the internals module with
+            // `%contract/custom%` applications).
+            //
+            // However, in the case of a contract embedded in a type, we don't want to do the
+            // additional wrapping, as `t` should already be a fully constructed contract.
+            TypeF::Flat(ref t) => return Ok(t.clone()),
             TypeF::Var(id) => get_var_contract(&vars, id, self.pos)?,
             TypeF::Forall {
                 ref var,
@@ -916,7 +925,7 @@ impl Subcontract for Type {
             TypeF::Wildcard(_) => internals::dynamic(),
         };
 
-        Ok(ctr)
+        Ok(mk_term::custom_contract(ctr))
     }
 }
 
@@ -978,9 +987,9 @@ impl Subcontract for EnumRows {
         // ```
         // fun l x =>
         //   x |> match {
-        //     'foo => x,
-        //     'bar => x,
-        //     'Baz variant_arg => 'Baz (%contract/apply% T label_arg variant_arg),
+        //     'foo => 'Ok x,
+        //     'bar => 'Ok x,
+        //     'Baz variant_arg => 'Ok ('Baz (%apply_contract% T label_arg variant_arg)),
         //     _ => $enum_fail l
         //   }
         // ```
@@ -996,7 +1005,7 @@ impl Subcontract for EnumRows {
                     });
 
                     let body = if let Some(ty) = row.typ.as_ref() {
-                        // 'Tag (%contract/apply% T label_arg variant_arg)
+                        // 'Tag (%apply_contract% T label_arg variant_arg)
                         let arg = mk_app!(
                             mk_term::op2(
                                 BinaryOp::ContractApply,
@@ -1006,16 +1015,13 @@ impl Subcontract for EnumRows {
                             mk_term::var(variant_arg)
                         );
 
-                        Term::EnumVariant {
-                            tag: row.id,
-                            arg,
-                            attrs: Default::default(),
-                        }
-                        .into()
+                        mk_term::enum_variant(row.id, arg)
                     } else {
                         mk_term::var(value_arg)
                     };
 
+                    let body = mk_term::enum_variant("Ok", body);
+
                     let pattern = Pattern {
                         data: PatternData::Enum(EnumPattern {
                             tag: row.id,
@@ -1153,6 +1159,7 @@ impl Subcontract for RecordRows {
             rrows = tail
         }
 
+        let has_tail = !matches!(&rrows.0, RecordRowsF::Empty);
         // Now that we've dealt with the row extends, we just need to
         // work out the tail.
         let tail = match &rrows.0 {
@@ -1165,7 +1172,12 @@ impl Subcontract for RecordRows {
 
         let rec = RichTerm::from(Term::Record(RecordData::with_field_values(fcs)));
 
-        Ok(mk_app!(internals::record(), rec, tail))
+        Ok(mk_app!(
+            internals::record_type(),
+            rec,
+            tail,
+            Term::Bool(has_tail)
+        ))
     }
 }
 
@@ -1319,14 +1331,16 @@ impl Type {
             .subcontract(Environment::new(), Polarity::Positive, &mut sy)
     }
 
-    /// Return the contract corresponding to a type, either as a function or a record. Said
-    /// contract must then be applied using the `ApplyContract` primitive operation.
+    /// Return the contract corresponding to a type. Said contract must then be applied using the
+    /// `ApplyContract` primitive operation.
     pub fn contract(&self) -> Result<RichTerm, UnboundTypeVariableError> {
         let mut sy = 0;
+
         self.subcontract(Environment::new(), Polarity::Positive, &mut sy)
     }
 
-    /// Returns true if this type is a function type, false otherwise.
+    /// Returns true if this type is a function type (including a polymorphic one), false
+    /// otherwise.
     pub fn is_function_type(&self) -> bool {
         match &self.typ {
             TypeF::Forall { body, .. } => body.is_function_type(),
diff --git a/core/src/typecheck/eq.rs b/core/src/typecheck/eq.rs
index 12833514..e6fb49ab 100644
--- a/core/src/typecheck/eq.rs
+++ b/core/src/typecheck/eq.rs
@@ -390,6 +390,18 @@ fn contract_eq_bounded<E: TermEnvironment>(
                 env2,
             ) && r1.attrs.open == r2.attrs.open
         }
+        (RecRecord(r1, dyn_fields, _), Record(r2)) | (Record(r1), RecRecord(r2, dyn_fields, _)) => {
+            dyn_fields.is_empty()
+                && map_eq(
+                    contract_eq_fields,
+                    state,
+                    &r1.fields,
+                    env1,
+                    &r2.fields,
+                    env2,
+                )
+                && r1.attrs.open == r2.attrs.open
+        }
         (RecRecord(r1, dyn_fields1, _), RecRecord(r2, dyn_fields2, _)) =>
         // We only compare records whose field structure is statically known (i.e. without dynamic
         // fields).
@@ -404,7 +416,7 @@ fn contract_eq_bounded<E: TermEnvironment>(
                     &r2.fields,
                     env2,
                 )
-                && r1.attrs == r2.attrs
+                && r1.attrs.open == r2.attrs.open
         }
         (Array(ts1, attrs1), Array(ts2, attrs2)) => {
             ts1.len() == ts2.len()
diff --git a/core/src/typecheck/mod.rs b/core/src/typecheck/mod.rs
index 4c058342..cedaec2b 100644
--- a/core/src/typecheck/mod.rs
+++ b/core/src/typecheck/mod.rs
@@ -60,8 +60,8 @@ use crate::{
     identifier::{Ident, LocIdent},
     stdlib as nickel_stdlib,
     term::{
-        record::Field, CustomContract, LabeledType, MatchBranch, RichTerm, StrChunk, Term,
-        Traverse, TraverseOrder, TypeAnnotation,
+        record::Field, LabeledType, MatchBranch, RichTerm, StrChunk, Term, Traverse, TraverseOrder,
+        TypeAnnotation,
     },
     typ::*,
     {mk_uty_arrow, mk_uty_enum, mk_uty_record, mk_uty_record_row},
@@ -1599,18 +1599,8 @@ fn walk<V: TypecheckVisitor>(
         }
         Term::EnumVariant { arg: t, ..}
         | Term::Sealed(_, t, _)
-        | Term::Op1(_, t) => walk(state, ctxt, visitor, t),
-        Term::CustomContract(CustomContract {immediate, delayed }) => {
-            if let Some(immediate) = immediate {
-                walk(state, ctxt.clone(), visitor, immediate)?;
-            }
-
-            if let Some(delayed) = delayed {
-                walk(state, ctxt, visitor, delayed)?;
-            }
-
-            Ok(())
-        }
+        | Term::Op1(_, t)
+        | Term::CustomContract(t) => walk(state, ctxt, visitor, t),
         Term::Op2(_, t1, t2) => {
             walk(state, ctxt.clone(), visitor, t1)?;
             walk(state, ctxt, visitor, t2)
@@ -1917,43 +1907,27 @@ fn check<V: TypecheckVisitor>(
         }
         // [^custom-contract-is-check]: [crate::term::CustomContract] isn't supposed to be used in
         // Nickel source code directly, but we can typecheck it. A custom contract is a
-        // datastructure holding two (optional) functions: the immediate part and the delayed part.
+        // datastructure holding a function of a specific type.
         //
-        // One can see it as the application of a `custom` constructor:
-        // `%contract/custom% immediate delayed`.
+        // Whether seen as a type constructor, or as equivalent to a standalone function, it's an
+        // introduction rule and thus it should be check.
         //
-        // It's thus not entirely obvious if this should be a checking or a infer rule: if seen as
-        // a simple function application, it should be infer (it's an elimination rule). If seen as
-        // a type constructor, it should be check (it's an introduction rule). We pick the last
-        // interpretation, because although a custom contract is built from an application of
-        // `%contract/custom%`, it's not the same thing - in particular both components of a custom
-        // contract have been reduced to a (weak head) normal form and put in a separate node. Thus
-        // we lean toward a check rule.
-        //
-        // 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 { immediate, delayed }) => {
+        // This rule can't produce a polymorphic type (it produces a `Dyn`, or morally a `Contract`
+        // type, if we had one), so we don't lose much by making it a check rule anyway, as for
+        // e.g. literals.
+        Term::CustomContract(t) => {
             // 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))?;
 
-            if let Some(immediate) = immediate {
-                check(
-                    state,
-                    ctxt.clone(),
-                    visitor,
-                    immediate,
-                    operation::immediate_type(),
-                )?;
-            }
-
-            if let Some(delayed) = delayed {
-                check(state, ctxt, visitor, delayed, operation::delayed_type())?;
-            }
-
-            Ok(())
+            check(
+                state,
+                ctxt.clone(),
+                visitor,
+                t,
+                operation::custom_contract_ret_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 6f3d1b4f..a4ed4d90 100644
--- a/core/src/typecheck/operation.rs
+++ b/core/src/typecheck/operation.rs
@@ -1,5 +1,6 @@
 //! Typing of primitive operations.
 use super::*;
+use crate::position::TermPos;
 use crate::{
     error::TypecheckError,
     label::{Polarity, TypeVarData},
@@ -270,12 +271,8 @@ pub fn get_uop_type(
                 mk_uty_arrow!(mk_uniftype::dynamic(), mk_uniftype::dynamic()),
             )
         }
-        // Morally, we return <immediate_type()> OR null, but we can't represent that safely.
-        // Dyn -> Dyn
-        UnaryOp::ContractGetImmediate => (mk_uniftype::dynamic(), mk_uniftype::dynamic()),
-        // Morally, we return <delayed_type()> OR null, but we can't represent that safely.
-        // Dyn -> Dyn
-        UnaryOp::ContractGetDelayed => (mk_uniftype::dynamic(), mk_uniftype::dynamic()),
+        // <custom_contract_type()> -> Dyn
+        UnaryOp::ContractCustom => (custom_contract_type(), mk_uniftype::dynamic()),
     })
 }
 
@@ -305,11 +302,13 @@ pub fn get_bop_type(
             mk_uniftype::dynamic(),
             mk_uty_arrow!(mk_uniftype::dynamic(), mk_uniftype::dynamic()),
         ),
-        // In practice, this operator can alternatively be given `null` values for both arguments.
-        // However, this isn't representable in the current type system, so in typed code, this
-        // can't be done.
-        // <immediate_type()> -> <delayed_type()> -> Dyn
-        BinaryOp::ContractCustom => (immediate_type(), delayed_type(), mk_uniftype::dynamic()),
+        // Ideally: Contract -> Label -> Dyn -> <custom_contract_ret_type()>
+        // Currently: Dyn -> Dyn -> (Dyn -> <custom_contract_ret_type()>)
+        BinaryOp::ContractApplyAsCustom => (
+            mk_uniftype::dynamic(),
+            mk_uniftype::dynamic(),
+            mk_uty_arrow!(mk_uniftype::dynamic(), custom_contract_ret_type()),
+        ),
         // Sym -> Dyn -> Dyn -> Dyn
         BinaryOp::Unseal => (
             mk_uniftype::sym(),
@@ -575,8 +574,16 @@ pub fn get_nop_type(
                 mk_uniftype::array(element_type),
             )
         }
-        // This should not happen, as MergeContract() is only produced during evaluation.
-        NAryOp::MergeContract => panic!("cannot typecheck MergeContract()"),
+        // Morally: Label -> Record -> Record -> Record
+        // Actual: Dyn -> Dyn -> Dyn -> Dyn
+        NAryOp::MergeContract => (
+            vec![
+                mk_uniftype::dynamic(),
+                mk_uniftype::dynamic(),
+                mk_uniftype::dynamic(),
+            ],
+            mk_uniftype::dynamic(),
+        ),
         // Morally: Sym -> Polarity -> Lbl -> Lbl
         // Actual: Sym -> Polarity -> Dyn -> Dyn
         NAryOp::LabelInsertTypeVar => (
@@ -590,43 +597,60 @@ pub fn get_nop_type(
     })
 }
 
-/// Returns the type of a the immediate part of a custom contract. This static type is more rigid
-/// than the actual values accepted by `std.contract.custom`, because we can't represent
-/// optional fields in the type system. But it's ok to be stricter in statically typed code.
-///
-/// Also remember that custom contracts shouldn't appear directly in the source code of Nickel:
-/// they are built using `std.contract.from_xxx` and `std.contract.custom` functions. We implement
-/// typechecking for them mostly because we can (to avoid an `unimplemented!` or a `panic!`), but
-/// we don't expect this case to trigger at the moment, so it isn't of the utmost importance.
-///
-/// The result represents the type:
+/// The type of a custom contract. In nickel syntax, the returned type is:
 ///
 /// ```nickel
-/// Dyn -> [| 'Ok, 'Proceed, 'Error { message: String, notes: Array String } |]
+/// Dyn -> Dyn -> [|
+///   'Ok Dyn,
+///   'Error { message | String | optional, notes | Array String | optional }
+/// |]
 /// ```
-pub fn immediate_type() -> UnifType {
+pub fn custom_contract_type() -> UnifType {
     mk_uty_arrow!(
         mk_uniftype::dynamic(),
-        mk_uty_enum!(
-            "Ok",
-            "Proceed",
-            (
-                "Error",
-                mk_uty_record!(
-                    ("message", mk_uniftype::str()),
-                    ("notes", mk_uniftype::array(mk_uniftype::str()))
-                )
-            )
-        )
+        mk_uniftype::dynamic(),
+        custom_contract_ret_type()
     )
 }
 
-/// Returns the type of the delayed part of a custom contract, which is currently just `Dyn -> Dyn
-/// -> Dyn` (take a label and return a partial identity). See [immediate_type] for more details.
-pub fn delayed_type() -> UnifType {
-    mk_uty_arrow!(
-        mk_uniftype::dynamic(),
-        mk_uniftype::dynamic(),
-        mk_uniftype::dynamic()
+/// The return type of a custom contract. See [custom_contract_type].
+///
+/// ```nickel
+/// [|
+///   'Ok Dyn,
+///   'Error { message | String | optional, notes | Array String | optional }
+/// |]
+/// ```
+pub fn custom_contract_ret_type() -> UnifType {
+    use crate::term::make::builder;
+
+    let error_data = builder::Record::new()
+        .field("message")
+        .optional()
+        .contract(TypeF::String)
+        .no_value()
+        .field("notes")
+        .contract(Type {
+            typ: TypeF::Array(Box::new(Type {
+                typ: TypeF::String,
+                pos: TermPos::None,
+            })),
+            pos: TermPos::None,
+        })
+        .optional()
+        .no_value();
+
+    mk_uty_enum!(
+        ("Ok", mk_uniftype::dynamic()),
+        // /!\ IMPORTANT
+        //
+        // During typechecking, `TypeF::Flat` all have been transformed to `UnifType::Contract(..)`
+        // with their associated environment. Generating a `TypeF::Flat` at this point will panic
+        // in the best case (we should catch that), or have incorrect behavior in the worst case.
+        // Do not turn this into `UnifType::concrete(TypeF::Flat(..))`.
+        (
+            "Error",
+            UnifType::Contract(error_data.build(), SimpleTermEnvironment::new())
+        )
     )
 }
diff --git a/core/stdlib/internals.ncl b/core/stdlib/internals.ncl
index 2dd04e88..255455ce 100644
--- a/core/stdlib/internals.ncl
+++ b/core/stdlib/internals.ncl
@@ -2,125 +2,180 @@
   # Internal operations. Can't be accessed from user code because `$` is not a
   # valid starting character for an identifier.
 
-  # Builtin contract implementations
-  "$dyn" = fun _label value => value,
-
-  "$num" = fun label value => if %typeof% value == 'Number then value else %blame% label,
-
-  "$bool" = fun label value => if %typeof% value == 'Bool then value else %blame% label,
-
-  "$string" = fun label value => if %typeof% value == 'String then value else %blame% label,
-
-  "$foreign_id" = fun label value => if %typeof% value == 'ForeignId then value else %blame% label,
-
-  "$fail" = fun label _value => %blame% label,
+  # Builtin contract implementations. Note that these are wrapped in a
+  # `CustomContract` constructor by the interpreter on the Rust side, so they
+  # can be written without calling to `%contract/custom%`, but they still follow
+  # the same convention (in particular the return type is [| 'Ok _, 'Error {..}
+  # |]` as well.
+
+  # Contract for the `Dyn` type. It's just an always accepting contract.
+  "$dyn" = fun _label value => 'Ok value,
+
+  # Contract for the `Number` type.
+  "$num" = fun _label value =>
+    if %typeof% value == 'Number then
+      'Ok value
+    else
+      'Error {},
 
-  "$array" = fun element_contract label value =>
-    if %typeof% value == 'Array then
-      %contract/array_lazy_apply% (%label/go_array% label) value element_contract
+  # Contract for the `Bool` type.
+  "$bool" = fun _label value =>
+    if %typeof% value == 'Bool then
+      'Ok value
     else
-      %blame% label,
+      'Error {},
 
-  # A specialized version of `$array $dyn`, but which is constant time.
-  "$array_dyn" = fun label value =>
-    if %typeof% value == 'Array then
-      value
+  # Contract for the `String` type.
+  "$string" = fun _label value =>
+    if %typeof% value == 'String then
+      'Ok value
     else
-      %blame% label,
+      'Error {},
 
-  "$func" = fun domain codomain label value =>
-    if %typeof% value == 'Function then
-      (
-        fun x =>
-          %contract/apply%
-            codomain
-            (%label/go_codom% label)
-            (value (%contract/apply% domain (%label/flip_polarity% (%label/go_dom% label)) x))
-      )
+  # Contract for `ForeignId` values.
+  "$foreign_id" = fun _label value =>
+    if %typeof% value == 'ForeignId then
+      'Ok value
     else
-      %blame% label,
+      'Error {},
+
+  # Contract for the `Array T` type, where `element_contract` is the contract
+  # for `T`.
+  "$array" = fun Element =>
+    fun label value =>
+      if %typeof% value == 'Array then
+        'Ok (
+          %contract/array_lazy_apply%
+            (%label/go_array% label)
+            value
+            Element
+        )
+      else
+        'Error { message = "expected an array" },
 
-  # A specialied version of `_ -> Dyn`
-  "$func_dom" = fun domain label value =>
-    if %typeof% value == 'Function then
-      (
-        fun x =>
-          value
-            (
-              %contract/apply%
-                domain
-                (%label/flip_polarity% (%label/go_dom% label))
-                x
-            )
-      )
+  # A specialized version of `$array $dyn`, but which is constant time.
+  "$array_dyn" = fun _label value =>
+    if %typeof% value == 'Array then
+      'Ok value
     else
-      %blame% label,
+      'Error { message = "expected an array" },
+
+  # Contract for the function type `Domain -> Codomain`.
+  "$func" = fun Domain Codomain =>
+    fun label value =>
+      if %typeof% value == 'Function then
+        'Ok (
+          fun x =>
+            %contract/apply%
+              Codomain
+              (%label/go_codom% label)
+              (value (%contract/apply% Domain (%label/flip_polarity% (%label/go_dom% label)) x))
+        )
+      else
+        'Error { message = "expected a function" },
+
+  # A specialized version of `_ -> Dyn`
+  "$func_dom" = fun Domain =>
+    fun label value =>
+      if %typeof% value == 'Function then
+        'Ok (
+          fun x =>
+            value
+              (
+                %contract/apply%
+                  Domain
+                  (%label/flip_polarity% (%label/go_dom% label))
+                  x
+              )
+        )
+      else
+        'Error { message = "expected a function" },
 
   # A specialied version of `Dyn -> _`
-  "$func_codom" = fun codomain label value =>
-    if %typeof% value == 'Function then
-      (
-        fun x =>
-          %contract/apply%
-            codomain
-            (%label/go_codom% label)
-            (value x)
-      )
-    else
-      %blame% label,
+  "$func_codom" = fun Codomain =>
+    fun label value =>
+      if %typeof% value == 'Function then
+        'Ok (
+          fun x =>
+            %contract/apply%
+              Codomain
+              (%label/go_codom% label)
+              (value x)
+        )
+      else
+        'Error { message = "expected a function" },
 
   # A specialied version of `Dyn -> Dyn`
-  "$func_dyn" = fun label value =>
+  "$func_dyn" = fun _label value =>
     if %typeof% value == 'Function then
-      value
+      'Ok value
     else
-      %blame% label,
-
-  "$forall_var" = fun sealing_key label value =>
-    let current_polarity = %label/polarity% label in
-    let polarity = (%label/lookup_type_variable% sealing_key label).polarity in
-    if polarity == current_polarity then
-      %unseal% sealing_key value (%blame% label)
-    else
-      # [^forall_label_flip_polarity]: Blame assignment for polymorphic
-      # contracts should take into account the polarity at the point the forall
-      # was introduced, not the current polarity of the variable occurrence.
-      # Indeed, forall can never blame in a negative position (relative to the
-      # forall): the contract is entirely on the callee.
-      #
-      # Thus, for correct blame assignment, we want to set the polarity to the
-      # forall polarity (here `polarity`). Because we only have the
-      # `%label/flip_polarity%` primop, and we know that in this branch they are
-      # unequal, flipping the current polarity will indeed give the original
-      # forall's polarity.
-      %seal% sealing_key (%label/flip_polarity% label) value,
-
-  "$forall" = fun sealing_key polarity contract label value =>
-    contract (%label/insert_type_variable% sealing_key polarity label) value,
-
-  "$enum" = fun case label value =>
-    if %typeof% value == 'Enum then
-      %contract/apply% case label value
-    else
-      %blame% (%label/with_message% "expected an enum" label),
+      'Error { message = "expected a function" },
+
+  # Contract for a polymorphic variable. This contract is stored in an
+  # environment during contract generation when a forall introduces a variable
+  # and is inserted whenever this variable occurs somewhere in the contract of
+  # the body of the forall.
+  "$forall_var" = fun sealing_key =>
+    fun label value =>
+      let current_polarity = %label/polarity% label in
+      let polarity = (%label/lookup_type_variable% sealing_key label).polarity in
+      if polarity == current_polarity then
+        'Ok (%unseal% sealing_key value (%blame% label))
+      else
+        # [^forall_label_flip_polarity]: Blame assignment for polymorphic
+        # contracts should take into account the polarity at the point the forall
+        # was introduced, not the current polarity of the variable occurrence.
+        # Indeed, forall can never blame in a negative position (relative to the
+        # forall): the contract is entirely on the callee.
+        #
+        # Thus, for correct blame assignment, we want to set the polarity to the
+        # forall polarity (here `polarity`). Because we only have the
+        # `%label/flip_polarity%` primop, and we know that in this branch they are
+        # unequal, flipping the current polarity will indeed give the original
+        # forall's polarity.
+        'Ok (%seal% sealing_key (%label/flip_polarity% label) value),
+
+  # Contract for `forall a. T`, where `contract` is the contract for `T`. This
+  # contract mostly set appropriate metadata in the label and forward the rest
+  # to `T`.
+  "$forall" = fun sealing_key polarity Contract label value =>
+    let label = (%label/insert_type_variable% sealing_key polarity label) in
+    %contract/apply_as_custom% Contract label value,
+
+  # Contract for an enum type `[| 'tag1, 'tag2 arg, ... |]`. A match expression
+  # depending on the structure of the enum type is generated by the interpreter
+  # and provided through `matcher`, and does the bulk of the work. This contract
+  # is just a wrapper that checks that the value is an enum and forwards the rest
+  # to `matcher`.
+  "$enum" = fun matcher =>
+    fun label value =>
+      if %typeof% value == 'Enum then
+        matcher label value
+      else
+        'Error { message = "expected an enum" },
 
-  "$enum_fail" = fun label =>
-    %blame% (%label/with_message% "tag not in the enum type" label),
+  # Error branch used when elaborating the matcher for an enum type. See
+  # `$enum`.
+  "$enum_fail" = fun _value => 'Error { message = "tag not in the enum type" },
 
   # Contract for an enum variant with tag `'tag`, that is any value of the form
   # `'tag exp`.
-  "$enum_variant" = fun tag label value =>
-    if %enum/is_variant% value then
-      let value_tag = %enum/get_tag% value in
+  "$enum_variant" = fun tag =>
+    fun _label value =>
+      if %enum/is_variant% value then
+        let value_tag = %enum/get_tag% value in
 
-      if value_tag == tag then
-        value
+        if value_tag == tag then
+          'Ok value
+        else
+          'Error {
+            message = "expected `'%{%to_string% tag}`, got `'%{%to_string% value_tag}`"
+          }
       else
-        let msg = "expected `'%{%to_string% tag}`, got `'%{%to_string% value_tag}`" in
-        %blame% (%label/with_message% "tag mismatch: %{msg}" label)
-    else
-      %blame% (%label/with_message% "expected an enum variant" label),
+        'Error { message = "expected an enum variant" },
 
+  # Tail wrapper for the tail of an enum type.
   "$forall_enum_tail" = fun label value =>
     # Theoretically, we should seal/unseal values that are part of enum tail
     # and `$forall_enum_tail` should be defined similarly to
@@ -147,151 +202,198 @@
     # While this might be an issue to investigate in the longer term, or for
     # the next major version, we continue to just not enforce parametricity
     # for enum types for now to maintain backward-compatibility.
-    value,
-
-  "$record" = fun field_contracts tail_contract label value =>
-    if %typeof% value == 'Record then
-      # Returns the sub-record of `left` containing only those fields which are not
-      # present in `right`. If `left` has a sealed polymorphic tail then it will be
-      # preserved.
-      let field_diff = fun left right =>
-        std.array.fold_left
-          (
-            fun acc field =>
-              if %record/has_field% field right then
-                acc
-              else
-                %record/insert% field acc (left."%{field}")
-          )
-          (%record/empty_with_tail% left)
-          (%record/fields% left)
-      in
-      let contracts_not_in_value = field_diff field_contracts value in
-      let missing_fields = %record/fields% contracts_not_in_value in
-      if %array/length% missing_fields == 0 then
-        let tail_fields = field_diff value field_contracts in
-        let fields_with_contracts =
-          std.array.fold_left
-            (
-              fun acc field =>
-                if %record/has_field% field field_contracts then
-                  let contract = field_contracts."%{field}" in
-                  let label = %label/go_field% field label in
-                  let val = value."%{field}" in
-                  %record/insert% field acc (%contract/apply% contract label val)
-                else
-                  acc
-            )
-            {}
-            (%record/fields% value)
-        in
-        tail_contract fields_with_contracts label tail_fields
+    'Ok value,
+
+  # Record contract.
+  #
+  # Record contracts are written and manipulated as standard record literals by
+  # the user, which is convenient (they can be written in a natural way and all
+  # record operations apply on them, they can be merged, extended, accessed, etc.)
+  #
+  # Before being applied, they still need to be wrapped to provide a proper
+  # error message when the checked value isn't a record, and to apply the proper
+  # merge operator flavour.
+  #
+  # Note that `%record/contract_merge%` returns an immediate error as `'Error`
+  # in case of extra fields. However, due to the lazy nature of merging and the
+  # existence of field without a definition, missing fields are a delayed error.
+  # This might have a non-obvious behavior when using boolean combinators on
+  # record contracts.
+  "$record_contract" = fun record_contract =>
+    fun label value =>
+      if %typeof% value == 'Record then
+        %record/merge_contract% label value record_contract
       else
-        let plural = if %array/length% missing_fields == 1 then "" else "s" in
-        %blame%
-          (
-            %label/with_message%
-              "missing field%{plural} `%{std.string.join ", " missing_fields}`"
-              label
-          )
-    else
-      %blame% (%label/with_message% "not a record" label),
+        'Error { message = "expected a Record" },
+
+  # Contract for a static record type `{ field1: T1, field2: T2, ... }`.
+  #
+  # The record type is reified by the interpreter as a record value `{ field1 =
+  # <contract of T1>, field2 = <contract of T2>, ... }` and given as the
+  # `field_contracts` argument. Depending on the tail of the record type, the
+  # corresponding `tail_wrapper` is also provided by the interpreter. Finally,
+  # `has_tail` is a boolean indicating if the record type has a non-empty tail,
+  # which makes it possible to push more checks in the immediate part of the
+  # contract.
+  "$record_type" =
+    let plural = fun list => if %array/length% list == 1 then "" else "s" in
 
-  # Delayed dictionary contract for `{_ | T}`
-  "$dict_contract" = fun contract label value =>
-    if %typeof% value == 'Record then
-      %contract/record_lazy_apply% (%label/go_dict% label) value (fun _field => contract)
-    else
-      %blame% (%label/with_message% "not a record" label),
+    fun field_contracts tail_wrapper has_tail =>
+      fun label value =>
+        if %typeof% value == 'Record then
+          let split_result = %record/split_pair% field_contracts value in
 
-  # Immediate dictionary contract for `{_ : T}`
-  "$dict_type" = fun contract label value =>
-    if %typeof% value == 'Record then
-      %record/map%
-        value
-        (
-          fun _field field_value =>
-            %contract/apply% contract (%label/go_dict% label) field_value
+          if split_result.left_only != {} then
+            let missing_fields = %record/fields% split_result.left_only in
+
+            'Error {
+              message = "missing field%{plural missing_fields} `%{std.string.join ", " missing_fields}`",
+            }
+          else if split_result.right_only != {} && !has_tail then
+            let extra_fields = %record/fields% split_result.right_only in
+
+            'Error {
+              message = "extra field%{plural extra_fields} `%{std.string.join ", " extra_fields}`",
+            }
+          else
+            let extra_fields = split_result.right_only in
+            let fields_with_contracts =
+              %record/map%
+                split_result.right_center
+                (
+                  fun field value =>
+                    %contract/apply%
+                      field_contracts."%{field}"
+                      (%label/go_field% field label)
+                      value
+                )
+            in
+
+            # Note that the tail_wrapper has the same type as a custom contract
+            # and thus properly wraps the result in `'Ok`, so we can call it
+            # directly
+            tail_wrapper extra_fields label fields_with_contracts
+        else
+          'Error { message = "expected a record" },
+
+  # Dictionary contract for `{_ | T}`. The contracts are mapped onto fields'
+  # metadata.
+  "$dict_contract" = fun Contract =>
+    fun label value =>
+      if %typeof% value == 'Record then
+        'Ok (
+          %contract/record_lazy_apply%
+            (%label/go_dict% label)
+            value
+            (fun _field => Contract)
         )
-    else
-      %blame% (%label/with_message% "not a record" label),
+      else
+        'Error { message = "not a record" },
+
+  # Dictionary contract for `{_ : T}`. The contracts are mapped directly onto
+  # the fields' values, which makes it more eager (in some specific sense - this
+  # contract is still lazy/delayed) than `$dict_contract`.
+  "$dict_type" = fun Contract =>
+    fun label value =>
+      if %typeof% value == 'Record then
+        'Ok (
+          %record/map%
+            value
+            (
+              fun _field field_value =>
+                %contract/apply% Contract (%label/go_dict% label) field_value
+            )
+        )
+      else
+        'Error { message = "not a record" },
 
   # A specialized version of either `{_ | Dyn}` or `{_ : Dyn}` (which are
   # equivalent), but which is constant time.
   "$dict_dyn" = fun label value =>
     if %typeof% value == 'Record then
-      value
+      'Ok value
     else
-      %blame% (%label/with_message% "not a record" label),
-
-  "$forall_record_tail" = fun sealing_key constr acc label value =>
+      'Error { message = "not a record" },
+
+  # Tail wrapper for a polymorphic tail. Wrappers are used in the implementation
+  # of the contract for static record types, and have the same return type as
+  # other contract implementations.
+  #
+  # The `sealing_key` is the sealing key corresponding to the variable occuring
+  # in the tail. `constr` are the fields that can't appear in the value because
+  # they are already in the tail. Both are provided by the interpreter when
+  # applying this function.
+  #
+  # `extra_fields` is provided by the caller `$record`, and is the set of fields
+  # that are weren't part of the record type (thus are candidates for being in
+  # the tail).
+  "$forall_record_tail" = fun sealing_key constr extra_fields label value =>
     let current_polarity = %label/polarity% label in
     let polarity = (%label/lookup_type_variable% sealing_key label).polarity in
     let plural = fun list => if %array/length% list == 1 then "" else "s" in
+
+    # If the polarity of the forall is the same as the current polarity, we are
+    # in a positive occurrence (relative to the forall). Think of a return value
+    # of a function type. In this case, the tail is supposed to be sealed, and
+    # the function isn't allowed to inject any extra field in that sealed tail.
     if polarity == current_polarity then
-      if value == {} then
+      if extra_fields == {} then
         let tagged_label = %label/with_message% "polymorphic tail mismatch" label in
-        let tail = %record/unseal_tail% sealing_key tagged_label value in
-        acc & tail
+        let tail = %record/unseal_tail% sealing_key tagged_label extra_fields in
+
+        'Ok (%record/disjoint_merge% value tail)
       else
-        let extra_fields = %record/fields% value in
-        %blame%
-          (
-            %label/with_message%
-              "extra field%{plural extra_fields} `%{std.string.join ", " extra_fields}`"
-              label
-          )
+        let extra_fields = %record/fields% extra_fields in
+
+        'Error {
+          message = "extra field%{plural extra_fields} `%{std.string.join ", " extra_fields}`",
+        }
+      # Otherwise, we are in a negative occurrence (relative to the forall). Think
+      # of the argument of a function. There, extra fields are allowed, but must
+      # be sealed in the tail to prevent the function from touching them.
     else
+      # Conflicts happen because a polymorphic record tail might have additional
+      # constraints as to what can actually be in there. See the documentation
+      # of the typechecker for more information.
       let conflicts =
         std.array.filter
           (fun field => std.array.elem field constr)
-          (%record/fields% value)
+          (%record/fields% extra_fields)
       in
+
       if conflicts != [] then
-        %blame%
-          (
-            %label/with_message%
-              "field%{plural conflicts} not allowed in tail: `%{std.string.join ", " conflicts}`"
-              label
-          )
+        'Error {
+          message = "field%{plural conflicts} not allowed in tail: `%{std.string.join ", " conflicts}`",
+        }
       else
         # See [^forall_label_flip_polarity]
-        %record/seal_tail% sealing_key (%label/flip_polarity% label) acc value,
-
-  "$dyn_tail" = fun acc label value => acc & value,
-
-  "$empty_tail" = fun acc label value =>
-    if value == {} then
-      acc
-    else
-      let extra_fields = %record/fields% value in
-      let plural = if %array/length% extra_fields == 1 then "" else "s" in
-      %blame%
-        (
-          %label/with_message%
-            "extra field%{plural} `%{std.string.join ", " extra_fields}`"
-            label
+        'Ok (
+          %record/seal_tail%
+            sealing_key
+            (%label/flip_polarity% label)
+            value
+            extra_fields
         ),
 
-  # Take a contract as a predicate, built using %contract/from_predicate%, and
-  # turn it into a generic custom contract (a partial identity Label -> Dyn ->
-  # Dyn) that can be passed to %contract/apply%.
-  "$predicate_to_ctr" = fun predicate label value =>
-    if predicate value then
-      value
-    else
-      %blame% label,
+  # Tail wrapper for a dynamic tail. A dynamic tail doesn't seal anything, so we
+  # just add the extra fields back to the returned value. See
+  # `$record_type/delayed`.
+  "$dyn_tail" = fun extra_fields label value => 'Ok (%record/disjoint_merge% value extra_fields),
 
-  # Take a contract as a validator, built using %contract/from_validator%, and
-  # turn it into a generic custom contract (a partial identity Label -> Dyn ->
-  # Dyn) that can be passed to %contract/apply%.
-  "$validator_to_ctr" = fun validator label value =>
-    validator value
+  # Tail wrapper of a helper for an empty tail. At this point, we've already
+  # checked (in the main contract implementation for record types) that there
+  # are no extra fields, so we can just ignore them and return the value as is.
+  "$empty_tail" = fun _extra_fields _label value => 'Ok value,
+
+  # Take a a partial identity Label -> Dyn -> Dyn that can then be passed
+  # applied as a normal funtion in the implementation of `contract/apply`.
+  "$prepare_custom_contract" = fun custom_contract label value =>
+    custom_contract label value
     |> match {
-      'Ok => value,
+      'Ok value => value,
       'Error { message ? null, notes ? [], .. } =>
         let label =
-          if message != null && std.is_string message then
+          if std.is_string message then
             %label/with_message% message label
           else
             label
@@ -305,88 +407,35 @@
         in
 
         %blame% label,
-      # The contract of `std.contract.from_validator` should guarantee that we
+      # The contract of `std.contract.custom` should guarantee that we
       # never reach this case. However, nothing prevents user from using
-      # `%contract/from_validator%` directly, so we still try to handle a
-      # misbehaving validator gracefully.
-      _ =>
+      # `%contract/custom%` directly, so we still try to handle a misbehaving
+      # custom contract gracefully.
+      value =>
         %blame%
           (
             %label/with_notes%
               (
                 %force%
-                  [
-                    "The validator for this contract returned an invalid result (which must be either `'Ok` or `'Error {message, ..}`)",
-                    "Please check the implementation of the contract that has been broken"
-                  ]
+                  (
+                    %trace%
+                      value
+                      [
+                        "The implementation of this custom contract returned an invalid result (which must be of type `[| 'Ok Dyn, 'Error {..} |]`)",
+                        "Please check the implementation of the contract that has been broken"
+                      ]
+                  )
               )
               label
           )
     },
 
-  # Take a custom contract split as an immediate and delayed part, both of which
-  # can be null, and transform it into a partial identity function that can be
-  # given to %contract/apply%.
-  "$prepare_contract" = fun contract label value =>
-    let immediate = %contract/get_immediate% contract in
-    let delayed = %contract/get_delayed% contract in
-
-    if immediate != null then
-      let result = immediate value in
-
-      # In user code, we would have used a match construct here, which would be
-      # more readable. However, internal functions are core utilities that get
-      # called quite a lot (and internals should be fairly reduced), so we try
-      # to use lower-level constructs to reclaim a tad more performance instead.
-      if result == 'Ok then
-        value
-      else if result == 'Proceed then
-        if delayed != null then
-          delayed label value
-        else
-          value
-      else if %typeof% result == 'Enum && %enum/get_tag% result == 'Error then
-        let payload = %enum/get_arg% result in
-
-        let label =
-          if %record/field_is_defined% "message" payload
-          && std.is_string payload.message then
-            %label/with_message% payload.message label
-          else
-            label
-        in
-
-        let label =
-          if %record/field_is_defined% "notes" payload
-          && std.is_array payload.notes then
-            %label/with_notes% (%force% payload.notes) label
-          else
-            label
-        in
-
-        %blame% label
-      else
-        %blame%
-          (
-            %label/with_notes%
-              (
-                %force%
-                  [
-                    "The immediate part of this contract returned an invalid result (which must be either `'Ok`, `'Proceed`  or `'Error {..}`)",
-                    "Please check the implementation of the contract that has been broken"
-                  ]
-              )
-              label
-          )
-    else if delayed != null then
-      delayed label value
-    else
-      value,
-
-  # Those are placeholder for future implementation. Right now they aren't used
-  # yet.
-  "$record_immediate" = fun _ _ => 'Proceed,
-  "$record_delayed" = fun _ _ value => value,
+  # Dual of prepare_custom_contract. Turns a naked function of a label and a
+  # value to a custom contract-like representation (that is, a function
+  # returning an enum - however the result is still a naked function, which must
+  # be applicable as a normal function, and isn't wrapped in
+  # `%contract/custom%`).
+  "$naked_to_custom" = fun naked label value => 'Ok (naked label value),
 
   # Recursive priorities operators
 
diff --git a/core/stdlib/std.ncl b/core/stdlib/std.ncl
index 8f3ad784..4f3d22ec 100644
--- a/core/stdlib/std.ncl
+++ b/core/stdlib/std.ncl
@@ -62,16 +62,15 @@
       =
         %contract/custom%
           (
-            fun value =>
+            fun _label value =>
               if %typeof% value == 'Array then
                 if %array/length% value != 0 then
-                  'Ok
+                  'Ok value
                 else
                   'Error { message = "empty array" }
               else
                 'Error { message = "not a array" }
-          )
-          null,
+          ),
 
     first
       : forall a. Array a -> a
@@ -1010,55 +1009,31 @@
       = fun message label => %blame% (%label/with_message% message label),
 
     custom
-      | (
-        Dyn -> [|
-          'Ok,
-          'Proceed,
-          'Error { message | String | optional, notes | Array String | optional }
+      : (
+        Dyn -> Dyn -> [|
+          'Ok Dyn,
+          'Error {
+            message | String | optional,
+            notes | Array String | optional
+          }
         |]
-      ) -> (Dyn -> Dyn -> Dyn) -> Dyn
+      ) -> Dyn
       | doc m%"
-          Build a generic custom contract from an immediate part and a delayed
-          part.
-
-          # Immediate vs delayed
-
-          In general, you should write immediate contracts whenever possible,
-          using for example `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 delayed contracts (such as
-          contracts operating on functions or data structures like records and
-          arrays) or contracts that might take other delayed 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 consist of an immediate part
-          and a delayed part.
-
-          ## Immediate part
-
-          The immediate part is a function that takes the checked value as a
-          parameter and returns either:
+          Build a generic custom contract. Whenever possible, more restricted
+          constructors such as `std.contract.from_predicate` or
+          `std.contract.from_validator` should be used. However, the most
+          general `std.contract.custom` might be required to write delayed
+          contracts or contracts taking other contracts as parameters.
 
-          - `'Ok` indicating that the whole contract should succeed immediately
-            without running the delayed part
-          - `'Proceed` indicating that the immediate part has succeeded but that
-            we should still proceed with the delayed part
-          - `'Error {..}` indicating that the contract should fail with provided
-            error information.
+          # Custom contracts
 
-          The immediate part is thus similar to a validator, excepted that they
-          can return the additional `'Proceed` value (see
-          `std.contract.from_validator`).
+          The argument of `std.contract.custom` is a function that takes a label
+          and the value to be checked, and returns `'Ok value` to return
+          the original value with potential delayed checks inside, or `'Error
+          {..}` to signal an immediate contract violation.
 
-          ## Delayed part
-
-          The delayed part must be a function taking a label and a value as
-          parameters, and either blame the label or return the original value
-          with potential delayed checks pushed inside.
+          The label should only be used for delayed checks. The preferred method
+          to indicate a contract violation is to return `'Error {..}`.
 
           For more details on custom contracts and delayed contracts, please
           refer to the corresponding section of the user manual.
@@ -1068,49 +1043,30 @@
           Because Nickel doesn't currently have proper types for labels and
           contracts, they are replaced with `Dyn` in the type signature. You can
           think of the actual type of the delayed part as `(Label -> Dyn ->
-          Dyn)`, and the return type of `custom` as `Contract`.
+          [| ... |])`, and the return type of `custom` as `Contract`.
 
           # Examples
 
           ```nickel
           let Nullable = fun Contract =>
-            std.contract.custom
-              (fun value =>
-                if value == null then
-                  'Ok
-                else
-                  'Proceed
-              )
-              (std.contract.apply Contract)
+            std.contract.custom (fun label value =>
+              if value == null then
+                'Ok value
+              else
+                std.contract.apply_as_custom Contract label value
+            )
           in
+
           null | Nullable Number
             => null
           ```
         "%
-      = fun immediate delayed => %contract/custom% immediate delayed,
-
-    delayed
-      | (Dyn -> Dyn -> Dyn) -> Dyn
-      | doc m%"
-          Build a generic delayed custom contract.
-
-          `std.contract.delayed contract_impl` is a shortcut for
-
-          ```nickel
-          std.contract.custom
-            (fun _ => 'Proceed)
-            contract_impl
-          ```
-
-          Please refer to the documentation of `std.contract.custom` for more
-          details.
-        "%
-      = fun delayed => %contract/custom% null delayed,
+      = fun contract => %contract/custom% contract,
 
     from_predicate
       | (Dyn -> Bool) -> Dyn
       | doc m%"
-          Build an immediate custom contract from a boolean predicate.
+          Build a contract from a boolean predicate.
 
           # Contract application
 
@@ -1119,10 +1075,11 @@
           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`.
+          should be applied using a contract annotation or `std.contract.apply`
+          and its variants, as explained in the documentation of
+          `std.contract.apply`.
 
-          From Nickel 1.8 and ownwards, `from_predicate` generates a bespoke
+          From Nickel 1.8 and onwards, `from_predicate` generates a bespoke
           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.
@@ -1144,23 +1101,29 @@
       = fun pred =>
         %contract/custom%
           (
-            fun value =>
+            fun _label value =>
               if pred value then
-                'Ok
+                'Ok value
               else
                 'Error {}
-          )
-          null,
+          ),
 
     from_validator
       | (
         Dyn -> [|
           'Ok,
-          'Error { message | String | optional, notes | Array String | optional }
+          'Error {
+            message
+              | String
+              | optional,
+            notes
+              | Array String
+              | optional,
+          }
         |]
       ) -> Dyn
       | doc m%%"
-          Build an immediate custom contract from a validator.
+          Build a contract from a validator.
 
           # Validator
 
@@ -1193,10 +1156,23 @@
               'Error { message = "expected a number, got a %{vtype}" }
           })
           in
+
           0 | IsZero
           ```
         "%%
-      = fun validator => %contract/custom% validator null,
+      # A validator turns out to be a valid custom contract as well (which just
+      # never returns a value with delayed checks)
+      = fun validator =>
+        %contract/custom%
+          (
+            fun label value =>
+              let result = validator value in
+
+              if result == 'Ok then
+                'Ok value
+              else
+                result
+          ),
 
     Sequence
       | doc m%"
@@ -1231,11 +1207,25 @@
           x | C
           ```
         "%
-      = fun contracts label value =>
-        std.array.fold_left
-          (fun acc contract => std.contract.apply contract label acc)
-          value
-          contracts,
+      = fun contracts =>
+        %contract/custom%
+          (
+            fun label value =>
+              std.array.fold_left
+                (
+                  fun acc Contract =>
+                    acc
+                    |> match {
+                      'Ok value =>
+                        std.contract.apply_as_custom Contract label value,
+                      # if we encountered an error at some point in the pipeline, we
+                      # just forward it from this point
+                      error => error
+                    }
+                )
+                ('Ok value)
+                contracts
+          ),
 
     label
       | doc m%"
@@ -1249,6 +1239,10 @@
           manipulated by the functions in this module. Labels thus offer a way to
           customize the error message that will be shown if a contract is broken.
 
+          Note that the label should only be used for delayed checks. Immediate
+          error reporting is better done in custom contracts by returning an
+          error value.
+
           The setters (`with_XXX` functions) always operate on the current error
           diagnostic, which is the last diagnotic on the stack. If the stack is
           empty, a fresh diagnostic is silently created when using a setter.
@@ -1257,12 +1251,13 @@
           can't be modified anymore. All diagnostics will be shown during error
           reporting, with the most recent being used as the main one.
 
-          `std.contract.apply` is the operation that pushes a new fresh diagnostic on
-          the stack, saving the previously current error diagnostic. The function
-          `std.contract.apply` is typically used to apply subcontracts inside a parent
-          contract. Stacking the current diagnostic potentially customized by
-          the parent contract saves the information inside, and provides a fresh
-          diagnostic for the child contract to use.
+          `std.contract.apply` and `std.contract.apply_as_custom` are the
+          operations that push a new fresh diagnostic on the stack, saving the
+          previously current error diagnostic. `apply` and friends are typically
+          used to apply subcontracts inside a parent contract. Stacking the
+          current diagnostic potentially customized by the parent contract saves
+          the information inside, and provides a fresh diagnostic for the child
+          contract to use.
         "%
       = {
         with_message
@@ -1285,28 +1280,32 @@
 
               # Examples
 
-              ```nickel
-              let ContractNum = std.contract.from_predicate (fun x => x > 0 && x < 50) in
+              This example implements a contract which ensures that the value is
+              a record with a field `foo` that is an even number.
 
-              let Contract = std.contract.custom
-                (fun value =>
-                  if std.is_number value then
-                    'Proceed
-                  else
-                    'Ok
-                )
-                (fun label value =>
-                  std.contract.apply
-                    ContractNum
-                    (std.contract.label.with_message
-                      "num subcontract failed! (out of bound)"
-                      label
-                    )
-                    value
-                )
+              ```nickel
+              let FooIsEven = std.contract.custom (fun label =>
+                match {
+                  record @ { foo, .. } =>
+                    'Ok (
+                      std.record.map (fun key value =>
+                        if key == "foo"
+                        && !(std.is_number value && value % 2 == 0) then
+                          label
+                          |> std.contract.label.with_message "field foo must be an even number"
+                          |> std.contract.blame
+                        else
+                          value
+                      )
+                      record
+                    ),
+                  _ => 'Error {},
+                }
+              )
               in
 
-              5 | Contract
+              { foo = 4, hello = "world" } | FooIsEven
+                => { foo = 4, hello = "world" }
               ```
             "%
           = fun message label => %label/with_message% message label,
@@ -1331,31 +1330,22 @@
 
               # Examples
 
-              ```nickel
-              let ContractNum = std.contract.from_predicate (fun x => x > 0 && x < 50) in
+              This example is illustrative. In practice, returning `'Error {..}`
+              should always be preferred over manipulating the label directly,
+              at least whenever possible.
 
-              let Contract = std.contract.custom
-                (fun value =>
-                  if std.is_number value then
-                    'Proceed
-                  else
-                    'Ok
-                )
-                (fun label value =>
-                  std.contract.apply
-                    ContractNum
-                    (label
-                     |> std.contract.label.with_message "num subcontract failed! (out of bound)"
-                     |> std.contract.label.with_notes [
-                          "The value was a number, but this number is outside the expected bounds",
-                          "The value must be a number between 0 and 50, both excluded",
-                        ]
-                    )
-                    value
-                )
+              ```nickel
+              let AlwaysFailWithNotes = std.contract.custom (fun label _ =>
+                label
+                |> std.contract.label.with_notes [
+                  "This is note 1",
+                  "This is note 2",
+                ]
+                |> std.contract.blame
+              )
               in
 
-              5 | Contract
+              null | AlwaysFailWithNotes
               ```
             "%
           # the %label/with_notes% operator expects an array of strings which is
@@ -1371,65 +1361,82 @@
 
               # Examples
 
+              This example is illustrative. In practice, returning `'Error {..}`
+              should always be preferred over manipulating the label directly
+              whenever possible.
+
               ```nickel
-              let AlwaysFailWithNotes = std.contract.delayed (fun label _ =>
+              let AlwaysFailWithNotes = std.contract.custom (fun label _ =>
                 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
+              ```
             "%
           = fun note label => %label/append_note% note label,
       },
 
     apply
       | doc m%"
-          Applies a contract to a label and a value.
+          Applies a contract to a label and a value. Either aborts the execution
+          with a broken contract error if the contract fails, or proceeds with
+          the evaluation of the value.
+
+          A contract annotation such as `value | Contract` is eventually
+          translated (automatically) to a call to `std.contract.apply Contract label
+          value` where `label` is inserted by the Nickel interpreter.
 
           Type: `Contract -> Label -> Dyn -> Dyn`
           (for technical reasons, this function isn't actually statically typed)
 
-          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.
+          # Arguments
+
+          `apply` takes three arguments. The arguments are in order:
 
-          # Argument
+          - a contract which is currently either a custom contract (produced by
+            the stdlib constructors `std.contract.custom`,
+            `std.contract.from_validator` or `std.contract.from_predicate`), a
+            record, a static type or a naked function (see legacy contracts
+            below).
+          - a label (see `std.contract.label`)
+          - the value to be checked
 
-          The argument must be a contract, which is currently either a custom
-          contract or a record.
+          # Return value
 
-          ## Custom contracts
+          `apply` returns the original value upon success, with potential
+          delayed checks inserted inside. Some contracts even return a modified
+          value, such as record contract which can set default values.
 
-          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`.
+          # Legacy contracts
 
           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` or `std.contract.delayed` instead.
+          `std.contract.custom` instead (the return type must be adapted).
 
           # Examples
 
+          This example is a custom re-implementation of the builtin contract
+          `[| 'Foo Number |]`. This makes for a short illustration of `apply`,
+          but you should of course just use the builtin contract in real-world
+          applications.
+
           ```nickel
-          let Nullable = fun Contract =>
+          let FooOfNumber = fun Contract =>
             std.contract.custom
-              (fun value =>
-                if value == null then
-                  'Ok
-                else
-                  'Proceed
+              (fun label =>
+                match {
+                  'Foo arg => 'Foo (std.contract.apply Contract label value),
+                  _ => 'Error {},
+                }
               )
-              (std.contract.apply Contract)
           in
 
-          let Contract = Nullable {foo | Number} in
-          { foo = 1 } | Contract
+          { foo = 1 } | Nullable {foo | Number}
           ```
 
           # Diagnostics stack
@@ -1443,21 +1450,19 @@
           ## Illustration
 
           ```nickel
-          let ChildContract = std.contract.delayed (fun label value =>
-            label
-            |> std.contract.label.with_message "child's message"
-            |> std.contract.label.append_note "child's note"
-            |> std.contract.blame
+          let ChildContract = std.contract.from_validator (fun _ =>
+            'Error {
+              message = "child's message",
+              notes = ["child's note"]
+            }
           )
           in
 
-          let ParentContract = std.contract.delayed (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
+          let ParentContract = std.contract.from_validator (fun _ =>
+            'Error {
+              message = "parent's message",
+              notes = ["parent's note"]
+            }
           )
           in
 
@@ -1468,22 +1473,46 @@
           message and note of the child contract, and a secondary diagnostic,
           using the parent contract message and note.
 
-          While we wrote both contracts as delayed contracts, note that this
-          behavior is also observed if e.g. `ChildContract` is a validator
-          instead:
+          This behavior is also observed when manipulating the label directly
+          and using `std.contract.blame` instead of returning an error value.
+          ```
+        "%
+      = fun contract label value =>
+        %contract/apply% contract (%label/push_diag% label) value,
+
+    apply_as_custom
+      | doc m%"
+          Variant of `std.contract.apply` which returns `'Error {..}` upon
+          immediate failure of the contract instead of aborting the execution
+          with blame error, and wraps the return value of `std.contract.apply as
+          `'Ok value` otherwise.
+
+          Beside the return value and error handling, `apply_as_custom` has the
+          same behavior as `std.contract.apply`. See `std.contract.apply` for
+          more details.
+
+          `apply_as_custom` is intended to be used when calling a contract from
+          a custom contract, since the return type of `apply_as_custom` is
+          precisely the same the return type of a custom contract.
+
+          # Examples
 
           ```nickel
-          let ChildContract = std.contract.from_validator (fun _ =>
-            'Error {
-              message = "child's message",
-              notes = ["child's note"]
-            }
-          ) in
-          # etc.
+          let Nullable = fun Contract =>
+            std.contract.custom
+              (fun label value =>
+                if value == null then
+                  'Ok value
+                else
+                  std.contract.apply_as_custom Contract label value
+              )
+          in
+
+          { foo = 1 } | Nullable {foo | Number}
           ```
         "%
       = fun contract label value =>
-        %contract/apply% contract (%label/push_diag% label) value,
+        %contract/apply_as_custom% contract (%label/push_diag% label) value,
 
     unstable
       | doc m%"
@@ -1516,13 +1545,15 @@
             "%
           = fun Domain Codomain =>
             %contract/custom%
-              null
               (
                 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
+                  'Ok (
+                    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
+                  )
               ),
 
         RangeStep
@@ -1539,16 +1570,15 @@
           =
             %contract/custom%
               (
-                fun value =>
+                fun _label value =>
                   if %typeof% value == 'Number && value < 0 then
                     'Error {
                       message = "invalid range step",
                       notes = ["Expected a positive number, got %{%to_string% value}"],
                     }
                   else
-                    'Ok
-              )
-              null,
+                    'Ok value
+              ),
 
         RangeFun
           | doc m%"
@@ -1565,36 +1595,37 @@
               example, if the first argument is a string, this contract silently
               passes.
             "%
-          =
-            # capture the `contract.label` module to avoid name collisions with
-            # local contract argument `label`
-            let label_module = label in
-            let attach_message = label_module.with_message "invalid range" in
-
-            fun Codomain =>
-              let RangeSecond = fun start =>
-                %contract/custom%
-                  null
-                  (
-                    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
+          = fun Codomain =>
+            let RangeSecond = fun start =>
+              %contract/custom%
+                (
+                  fun _label value =>
+                    if %typeof% start == 'Number
+                    && %typeof% value == 'Number
+                    && start > value then
+                      let start_as_str = %to_string% start in
+                      let end_as_str = %to_string% value in
+
+                      'Error {
+                        message = "invalid range",
+                        notes = [
+                          "Expected a range end greater than %{start_as_str} (range start), got %{end_as_str}"
+                        ]
+                      }
+                    else
+                      'Ok value
+                )
+            in
 
-              DependentFun
-                Dyn
-                (fun start => RangeSecond start -> Codomain),
+            fun label value =>
+              %contract/apply%
+                (
+                  DependentFun
+                    Dyn
+                    (fun start => RangeSecond start -> Codomain)
+                )
+                label
+                value,
 
         IndexedArrayFun
           | [| 'Index, 'Split |] -> Dyn
@@ -1621,7 +1652,6 @@
 
             let ArrayIndexFirst =
               %contract/custom%
-                null
                 (
                   fun label value =>
                     if %typeof% value == 'Number then
@@ -1630,19 +1660,25 @@
                         |> 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
+                      std.contract.apply_as_custom std.number.Nat label value
                     else
-                      value
+                      'Ok value
                 )
             in
 
             let ArrayIndexSecond = fun type min_size =>
               %contract/custom%
-                null
                 (
-                  fun label value =>
+                  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
+                      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
@@ -1652,24 +1688,22 @@
                           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
+
+                        'Error {
+                          message = "invalid array indexing",
+                          notes = [note],
+                        }
                       else
-                        value
+                        'Ok value
                     else
-                      value
+                      'Ok value
                 )
             in
 
-            let contract = fun type =>
+            fun type =>
               DependentFun
                 ArrayIndexFirst
-                (fun index => ArrayIndexSecond type index -> Dyn)
-            in
-
-            contract,
+                (fun index => ArrayIndexSecond type index -> Dyn),
 
         ArraySliceFun
           | doc m%"
@@ -1695,7 +1729,6 @@
 
             let SliceIndexFirst =
               %contract/custom%
-                null
                 (
                   fun label value =>
                     if %typeof% value == 'Number then
@@ -1704,54 +1737,57 @@
                         |> 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
+                      std.contract.apply_as_custom std.number.Nat label value
                     else
-                      value
+                      'Ok value
                 )
             in
 
             let SliceIndexSecond = fun start =>
               %contract/custom%
-                null
                 (
                   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
+                        'Error {
+                          message = "invalid array slice indexing",
+                          notes = [
+                            "Expected the array slice indices to satisfy `start <= end`, but got %{%to_string% start} (start) and %{%to_string% value} (end)"
+                          ],
+                        }
                       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
-                        %contract/apply% std.number.Nat label value
+                        std.contract.apply_as_custom std.number.Nat label value
                     else
-                      value
+                      'Ok value
                 )
             in
 
             let ArraySliceArray = fun end_index =>
               %contract/custom%
-                null
                 (
-                  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
+                  fun _label value =>
+                    if %typeof% end_index == 'Number
+                    && %typeof% value == 'Array
+                    && 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
+
+                      'Error {
+                        message = "invalid array slice indexing",
+                        notes = [
+                          "Expected the slice end index to be between 0 and %{size_as_str} (array's length), got %{index_as_str}"
+                        ],
+                      }
                     else
-                      value
+                      'Ok value
                 )
             in
+
             DependentFun
               SliceIndexFirst
               (
@@ -1790,34 +1826,28 @@
               In this example, the first call to `f` won't blame, but the second
               will, as `baz` isn't a field of the record argument.
             "%%
-          =
-            # capture the `contract.label` module to avoid name collisions with
-            # local contract argument `label`
-            let label_module = label in
-            let attach_message = label_module.with_message "missing field" in
-
-            fun Codomain =>
-              let HasFieldSecond = fun field =>
-                %contract/custom%
-                  null
-                  (
-                    fun label value =>
-                      if %typeof% field == 'String
-                      && %typeof% value == 'Record
-                      && !(%record/has_field% field value) then
-                        label
-                        |> attach_message
-                        |> label_module.append_note
+          = fun Codomain =>
+            let HasFieldSecond = fun field =>
+              %contract/custom%
+                (
+                  fun _label value =>
+                    if %typeof% field == 'String
+                    && %typeof% value == 'Record
+                    && !(%record/has_field% field value) then
+                      'Error {
+                        message = "missing field",
+                        notes = [
                           "The record given as an argument lacks the required field `%{field}`."
-                        |> blame
-                      else
-                        value
-                  )
-              in
+                        ]
+                      }
+                    else
+                      'Ok value
+                )
+            in
 
-              DependentFun
-                Dyn
-                (fun field => HasFieldSecond field -> Codomain),
+            DependentFun
+              Dyn
+              (fun field => HasFieldSecond field -> Codomain),
       }
   },
 
@@ -1855,8 +1885,13 @@
         "%
       =
         %contract/custom%
-          (fun value => if std.is_enum value then 'Ok else 'Error {})
-          null,
+          (
+            fun _label value =>
+              if std.is_enum value then
+                'Ok value
+              else
+                'Error {}
+          ),
 
     TagOrString
       | doc m%%"
@@ -1897,23 +1932,16 @@
       "%%
       =
         %contract/custom%
-          null
           (
-            fun label value =>
-              let label =
-                std.contract.label.with_message
-                  "expected either a string or an enum tag"
-                  label
-              in
+            fun _label value =>
               %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,
+                'String => 'Ok (%enum/from_string% value),
+                'Enum if !(is_enum_variant value) => 'Ok value,
+                _ =>
+                  'Error {
+                    message = "expected either a string or an enum tag"
+                  },
               }
           ),
 
@@ -2164,16 +2192,15 @@
       =
         %contract/custom%
           (
-            fun value =>
+            fun _label value =>
               if %typeof% value == 'Number then
                 if value % 1 == 0 then
-                  'Ok
+                  'Ok value
                 else
-                  'Error { message = "not an integer" }
+                  'Error { message = "expected an integer" }
               else
-                'Error { message = "not a number" }
-          )
-          null,
+                'Error { message = "expected a number" }
+          ),
 
     Nat
       | doc m%"
@@ -2193,16 +2220,15 @@
       =
         %contract/custom%
           (
-            fun value =>
+            fun _label value =>
               if %typeof% value == 'Number then
                 if value % 1 == 0 && value >= 0 then
-                  'Ok
+                  'Ok value
                 else
-                  'Error { message = "not a natural" }
+                  'Error { message = "expected a natural" }
               else
-                'Error { message = "not a number" }
-          )
-          null,
+                'Error { message = "expected a number" }
+          ),
 
     PosNat
       | doc m%"
@@ -2222,16 +2248,15 @@
       =
         %contract/custom%
           (
-            fun value =>
+            fun _label value =>
               if %typeof% value == 'Number then
                 if value % 1 == 0 && value > 0 then
-                  'Ok
+                  'Ok value
                 else
-                  'Error { message = "not positive integer" }
+                  'Error { message = "expected a positive integer" }
               else
-                'Error { message = "not a number" }
-          )
-          null,
+                'Error { message = "expected a number" }
+          ),
 
     NonZero
       | doc m%"
@@ -2249,16 +2274,15 @@
       =
         %contract/custom%
           (
-            fun value =>
+            fun _label value =>
               if %typeof% value == 'Number then
                 if value != 0 then
-                  'Ok
+                  'Ok value
                 else
-                  'Error { message = "is zero" }
+                  'Error { message = "value is zero" }
               else
-                'Error { message = "not a number" }
-          )
-          null,
+                'Error { message = "expected a number" }
+          ),
 
     is_integer
       : Number -> Bool
@@ -2962,16 +2986,15 @@
 
         %contract/custom%
           (
-            fun value =>
+            fun _label value =>
               if %typeof% value == 'String then
                 if is_num_literal value then
-                  'Ok
+                  'Ok value
                 else
                   'Error { message = "invalid number literal" }
               else
-                'Error { message = "not a string" }
-          )
-          null,
+                'Error { message = "expected a string" }
+          ),
 
     Character
       | doc m%"
@@ -2993,16 +3016,15 @@
       =
         %contract/custom%
           (
-            fun value =>
+            fun _label value =>
               if %typeof% value == 'String then
                 if length value == 1 then
-                  'Ok
+                  'Ok value
                 else
                   'Error { message = "length different than one" }
               else
-                'Error { message = "not a string" }
-          )
-          null,
+                'Error { message = "expected a string" }
+          ),
 
     Stringable
       | doc m%"
@@ -3033,24 +3055,20 @@
       =
         %contract/custom%
           (
-            fun value =>
+            fun _label value =>
               let type = std.typeof value in
-              let check =
-                value == null
-                || type == 'Number
-                || type == 'Bool
-                || type == 'String
-                # note that `type == 'Enum` isn't sufficient, as it includes enum
-                # variants
-                || std.enum.is_enum_tag value
-              in
 
-              if check then
-                'Ok
+              if value == null
+              || type == 'Number
+              || type == 'Bool
+              || type == 'String
+              # note that `type == 'Enum` isn't sufficient, as it includes enum
+              # variants
+              || std.enum.is_enum_tag value then
+                'Ok value
               else
                 'Error {}
-          )
-          null,
+          ),
 
     NonEmpty
       | doc m%"
@@ -3070,16 +3088,15 @@
       =
         %contract/custom%
           (
-            fun value =>
+            fun _label value =>
               if %typeof% value == 'String then
                 if %string/length% value > 0 then
-                  'Ok
+                  'Ok value
                 else
                   'Error { message = "empty string" }
               else
                 'Error { message = "not a string" }
-          )
-          null,
+          ),
 
     join
       : String -> Array String -> String
@@ -3590,7 +3607,15 @@
          => true
         ```
         "%
-      = %contract/custom% (fun value => if value then 'Ok else 'Error {}) null,
+      =
+        %contract/custom%
+          (
+            fun _label value =>
+              if value then
+                'Ok value
+              else
+                'Error {}
+          ),
 
     assert_all
       | Array Assert -> Bool
@@ -3897,7 +3922,7 @@
       ```
     "%
     = fun msg =>
-      %contract/custom% (fun _ => 'Error { message = msg }) null,
+      %contract/custom% (fun _label _value => 'Error { message = msg }),
 
   fail_with
     | String -> Dyn
diff --git a/doc/manual/contracts.md b/doc/manual/contracts.md
index 0f7a583c..130de7b3 100644
--- a/doc/manual/contracts.md
+++ b/doc/manual/contracts.md
@@ -205,17 +205,41 @@ In some situations, even validators aren't sufficient. For example, when writing
 contracts](#parametrized-contracts), you might not be able to decide right away
 if a value satisfies a contract.
 
-For those use-cases, the stdlib provides two additional contract constructors,
-`std.contract.custom` and `std.contract.delayed`. The former is the most general
-form, which takes as arguments two functions: the *immediate* part and the
-*delayed* part. Other contract constructors such as
-`std.contract.from_predicate`, `std.contract.from_validator` or
-`std.contract.delayed` are just special cases of `std.contract.custom`.
-
-At this point, we haven't covered enough material to present a compelling
-example of a general custom contracts. To see `std.contract.custom` in action,
-please refer to the aforementioned sections, and in particular the one on
-[delayed contracts](#delayed-contracts).
+For those use-cases, the stdlib provides the additional contract constructor
+`std.contract.custom` which is the most general form (predicates and validators
+are just special cases of general custom contracts).
+
+The argument of `std.contract.custom` must be a function that takes two
+arguments:
+
+- A `label`.
+- The value being checked.
+
+This function must return either `'Ok new_value` or `'Error {..}`, almost like a
+validator, but with the additional `new_value` which is the original value with
+potential delayed checks included.
+
+#### Label
+
+For immediate checks, signaling failure should be done by returning `'Error`, as
+for validators, whenever possible. However, for delayed checks, we must do
+differently. A custom contract uses `std.contract.blame` which takes the label
+as an argument. `blame` immediately aborts the execution and reports a contract
+violation error. You can think of it as throwing an exception, but one that
+can't be caught.
+
+The label is a special object which is automatically passed to the custom
+contract by the interpreter. The label 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 and return the new modified label. As seen in the
+[contracts parametrized by contracts
+section](#contracts-parametrized-by-contracts), the label must also be provided
+when applying a subcontract.
+
+The use of labels is illustrated in the [delayed contracts
+section](#delayed-contracts). Another example of a general custom contracts is
+given in the next section on parametrized contracts.
 
 ### Parametrized contracts
 
@@ -300,34 +324,33 @@ in
 
 Contracts parametrized by other contracts are not really special amongst
 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
+functions, they can be different objects as well, such as record. 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.
+value` or `std.contract.apply_as_custom Contract label value` instead (the
+difference between both variants is explained in the next subsection).
 
 Parameters that are also contracts are usually generic. In particular, they are
-not necessarily immediate contracts. In consequence, a contract parametrized by
-another unknown contract must usually be written as [a delayed
-contract](#delayed-contracts), using the most general constructor
-`std.contract.custom`.
+not necessarily predicates or validators but can contain delayed checks. In
+consequence, a contract parametrized by another unknown contract must usually be
+written as a [general custom contract](#general-custom-contracts) using the
+constructor `std.contract.custom`.
 
-One example is the `Nullable` contract, which accepts a value that is either
+One example is a `Nullable` contract, which accepts a value that is either
 `null` or of some other given format. If the argument is `null`, we accept the
-value immediately. Otherwise, we proceed with the delayed part, which is just
-the contract application of the given argument. See [delayed
-contracts](#delayed-contracts) for more details on `std.contract.custom`.
+value immediately (and return it unchanged). Otherwise, we proceed with the
+argument contract.
 
 ```nickel
 let Nullable = fun Contract =>
   std.contract.custom
-    (fun value =>
+    (fun label value =>
       if value == null then
-        'Ok
+        'Ok value
       else
-        'Proceed
+        std.contract.apply_as_custom Contract label value
     )
-    (std.contract.apply Contract)
 in
 
 [
@@ -340,6 +363,85 @@ in
 ]
 ```
 
+#### `apply` vs `apply_as_custom`
+
+The stdlib provides two variants for applying a contract. The difference lies in
+their return value, and how they propagate errors.
+
+`std.contract.apply` is used by the interpreter when evaluating a contract
+application such as `value | Contract`. As such, a contract violation is always
+turned into a call to `std.contract.blame` and aborts the execution immediately.
+In particular, even an immediate error can't be caught from normal Nickel code.
+If the contract succeeds, or at least the immediate part, the value with
+potential delayed check inside is returned directly.
+
+When implementing a parametrized contract, `apply` should be used for [delayed
+checks](#delayed-contracts). Here is a contrived example re-implementing the
+builtin contract `[| 'Foo Contract |]` parametrized by `Contract`:
+
+```nickel #repl
+> let FooOf = fun Contract =>
+  std.contract.custom (fun label => match {
+    'Foo arg => 'Ok ('Foo (std.contract.apply Contract label arg)),
+    _ => 'Error {},
+  })
+
+
+> 'Foo 5 | FooOf Number
+'Foo 5
+
+> 'Foo "a" | FooOf Number
+error: contract broken by a value
+[...]
+```
+
+In this case, `Contract` is applied as part of the delayed checks. When those
+checks are eventually run, we aren't in the context of the implementation of a
+contract anymore, and `'Ok` or `'Error` aren't meaningful. We need to either
+abort upon failure, or to proceed transparently with the evaluation of `value`.
+
+On the other hand, `std.contract.apply_as_custom` should be used in the
+situation where a parametrized contract performs some immediate checks and then
+completely transfers the execution to other contracts. This is precisely the
+case of the `Nullable` example above:
+
+```nickel #repl
+> let Nullable = fun Contract =>
+    std.contract.custom
+      (fun label value =>
+        if value == null then
+          'Ok value
+        else
+          std.contract.apply_as_custom Contract label value
+      )
+
+> null | Nullable Number
+null
+
+> 5 | Nullable Number
+5
+
+> "a" | Nullable Number
+error: contract broken by a value
+[...]
+```
+
+In this case, we do want the contract application to return either `'Ok` or
+`'Error`. Indeed, we haven't decided yet if we should return `'Ok` or not:
+`Contract` might itself returns an immediate error through `'Error` which we
+would like to propagate.
+
+Not only `apply_as_custom` spares us from wrapping the result in `'Ok`, but the
+most important benefit is to make more contract error catchable by propagating
+`'Error`. For example, with the current version, `Nullable Number` is an
+immediate contract that returns `'Error` if the value is neither `null` nor a
+`Number` (say, `"a"`). Such an immediate contract plays well with boolean
+operations on contracts, can be converted back to a predicate, etc.
+
+Had we use `std.contract.apply` in this case, this would turn the `Number`
+sub-check to a delayed check, and applying the contract to `"a"` would now throw
+an uncatchable contract error.
+
 ## Compound contracts
 
 This section describes the different ways of composing existing contracts
@@ -499,14 +601,14 @@ example:
 
 > {data = "", must_be_very_secure = false} | Secure
 error: non mergeable terms
-  ┌─ <repl-input-15>:1:36
+  ┌─ <repl-input-21>:1:36
   │
 1 │  {data = "", must_be_very_secure = false} | Secure
   │                                    ^^^^^    ------ originally merged here
   │                                    │
   │                                    cannot merge this expression
   │
-  ┌─ <repl-input-13>:2:34
+  ┌─ <repl-input-19>:2:34
   │
 2 │     must_be_very_secure | Bool = true,
   │                                  ^^^^ with this expression
@@ -573,7 +675,7 @@ contract to each element:
 
 > [1000, 10001, 2] | Array VeryBig
 error: contract broken by a value
-  ┌─ <repl-input-21>:1:16
+  ┌─ <repl-input-27>:1:16
   │
 1 │  [1000, 10001, 2] | Array VeryBig
   │                ^          ------- expected array element type
@@ -641,7 +743,7 @@ functions as parameters. Here is an example:
 > let apply_fun | (Number -> Number) -> Number = fun f => f 0 in
   apply_fun (fun x => "a")
 error: contract broken by the caller
-  ┌─ <repl-input-24>:1:29
+  ┌─ <repl-input-30>:1:29
   │
 1 │  let apply_fun | (Number -> Number) -> Number = fun f => f 0 in
   │                             ------ expected return type of a function provided by the caller
@@ -679,13 +781,9 @@ notions are strictly equivalent.
 ### Lazy evaluation
 
 In the [section on writing a custom contract with
-`std.contract.custom`](#general-custom-contracts), 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.
-
-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.
+`std.contract.custom`](#general-custom-contracts), we noted the surprising fact
+that a general custom contract must return `'Ok new_value`, instead of just
+returning `'Ok`.
 
 The motivation for this return value is laziness. Nickel is designed to be
 *lazy*, only evaluating values on-demand.
@@ -723,43 +821,27 @@ evaluation of most of the configuration, we would lose the benefits of laziness.
 Thus, *we want contracts to be lazy as well*. In particular, when writing a
 contract `{foo | FooContract}`, we want that `FooContract` fires only when the
 field `foo` is requested. This is indeed the case: built-in contract
-combinators, that is array contracts, dictionary contracts and function
+combinators, such as array contracts, dictionary contracts and function
 contracts, all have a *delayed part*.
 
 ### Immediate and delayed
 
-The most general form of contract thus has two parts:
+It's useful to see a general contract as having two parts:
 
-- the *immediate part* which is evaluated first and produces an answer right
+- An **immediate part** which is evaluated first and produces an answer right
     away. This answer is either:
-  - `'Ok` to signal immediate success and forego the delayed checks (if any)
-  - `'Proceed` to signal that the immediate part didin't detect any error, but
-      the delayed checks must still be performed
+  - `'Ok new_value` to signal success of the immediate checks, returning
+      the delayed part in `new_value`
   - `'Error {..}` to signal immediate failure
+- A **delayed part** included in `new_value` which corresponds to the
+    original value with delayed checks integrated inside. Those checks will fire
+    only when further data is requested. Throwing an error from the delayed part
+    is done using `std.contract.blame`, which is more like throwing an
+    exception, as opposed to simply returning `'Error` in the immediate part.
 
-  In practice, the immediate part is the first argument to
-  `std.contract.custom`, and is indeed a function from an unknwon value to one
-  of the three possible answers, of type:
-
-  ```nickel
-  Dyn -> [|
-    'Ok,
-    'Proceed,
-    'Error {
-      message | String | optional,
-      notes | Array String | optional
-    }
-  |]
-  ```
-
-- the *delayed part* which returns the value with delayed checks integrated
-    inside. Those checks will fire only when further data is requested. This is
-    the second argument of `std.contract.custom`. The delayed part is a function
-    taking a label, the value, and returning the augmented value; it has type:
-
-  ```nickel
-  Dyn -> Dyn -> Dyn
-  ```
+In practice, a general custom contract is just one function combining both. The
+immediate/delayed distinction is just conceptual, but it's still a helpful point
+of view to understand the design of Nickel's contract system.
 
 Take the record contract `{foo | FooContract}` applied to the value `{foo =
 42}`:
@@ -771,8 +853,7 @@ Take the record contract `{foo | FooContract}` applied to the value `{foo =
 
 A contract built from a predicate or a validator (using respectively
 `std.contract.from_predicate` and `std.contract.from_validator`) only has an
-immediate part. Dually, a contract built from `std.contract.delayed` only has a
-delayed part.
+immediate part.
 
 ### Writing a delayed contract
 
@@ -785,27 +866,9 @@ working example in this section.
 Another use-case for custom delayed contracts is to write a contract that is
 parametrized by another contract, such as the `Nullable` contract implemented in
 [the parametrized contracts section](#contracts-parametrized-by-contracts).
-Because the parameter contract can be delayed, `Nullable` needs to be written as
-a delayed contract as well.
-
-The delayed part of 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 with potential delayed
-check inside. To signal failure, a custom contract uses `std.contract.blame`
-which takes the label as an argument. `blame` immediately aborts the execution
-and reports a contract violation error.
-
-The label is a special object which is automatically passed to the custom
-contract by the interpreter. The label 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 and return the new modified label. As seen in the
-[contracts parametrized by contracts
-section](#contracts-parametrized-by-contracts), the label must also be provided
-when applying a subcontract.
+Because the parameter contract is generic and can have a delayed part as well,
+`Nullable` needs to be written as a contract with a potential delayed part as
+well.
 
 Imagine we want to write a contract similar to `{_ | Bool}`, that is a
 dictionary of booleans, but we also want keys to be valid numbers (although
@@ -822,7 +885,21 @@ value which is wrapping the original value with delayed checks inside**:
 {
   NumberBoolDict =
     std.contract.custom
-      (fun value =>
+      (fun label value =>
+        let with_delayed_checks =
+          value
+          |> std.record.map
+            (
+              fun name value =>
+                let label_with_msg =
+                  std.contract.label.with_message "field `%{name}` is not a boolean" label
+                in
+                # Note: we use `apply` and not `apply_as_custom` here since we
+                # are inside a delayed check
+                std.contract.apply Bool label_with_msg value
+            )
+        in
+
         if std.is_record value then
           value
           |> std.record.fields
@@ -835,74 +912,49 @@ value which is wrapping the original value with delayed checks inside**:
                   message = "field name `%{field_name}` is not a number"
                 }
             )
-            'Proceed
+            ('Ok with_delayed_checks)
         else
           'Error { message = "not a record" }
       )
-      (fun label value =>
-        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
-          )
-      )
 }
 ```
 
 There is a lot to unwrap here. Please refer to the [syntax section](./syntax.md)
 if you have trouble parsing the example.
 
-The first function defines the immediate part of the contract. We first check if
-the value is a record and return `'Error {..}` otherwise. Then, we loop over
-over all the record field names and check that each one is a sequence of digits.
-We use a right fold because of its short-circuiting capabilities: as soon as an
-`'Error` is encountered, `fold_right` doesn't need to evaluate the rest and
-returns immediately. We provide the base value `'Proceed`, which will be
-picked if all the fields are valid, and indicates that the immediate part has
-succeeded and that the contract system should now proceed with the delayed part.
-
-We could theoretically have made the whole contact delayed by pushing those
-checks down each field, together with the `Bool` contract application, but it's
-neither necessary nor desirable: as long as we don't force fields, it's better
-to perform as many checks as possible in the immediate part, to report errors
-early and because it behaves better with respect to some contract operations.
-
-The delayed part maps a function over the record which substitutes each field
-for the same value but wrapped in a `Bool` contract. Because records (and record
-mapping) are lazy, *this doesn't actually execute the `Bool` contracts right
-away*. Each contract will only be run when the corresponding field will be
-requested.
+We first define `with_delayed_checks`, the delayed part, that maps a function
+over the record which substitutes each field for the same value but wrapped in a
+`Bool` contract. Thanks to lazyness, the record mapping won't happen until `'Ok
+with_delayed_checks` is returned *and* some code actually uses the value. Even
+then, because records (and record mapping) are lazy, *this doesn't actually
+execute the `Bool` contracts right away*. Each contract will only be run when
+the individual fields will be accessed or exported.
+
+We proceed to the immediate checks. We first check if the value is a record and
+return `'Error {..}` otherwise. Then, we iterate over all the record field
+names and check that each one is a sequence of digits. We use a right fold
+because of its short-circuiting capabilities: as soon as an `'Error` is
+encountered, `fold_right` doesn't need to evaluate the rest and returns
+immediately. We provide the base value `'Ok with_delayed_checks`, which will be
+picked only if all the fields are valid and the iteration goes through the end.
+In that case, the immediate part has succeeded, and we return the value with
+delayed checks.
+
+We could theoretically have made the whole contact delayed by pushing the
+immediate checks down each field, together with the `Bool` contract application,
+but it's neither necessary nor desirable: as long as we don't force fields, it's
+better to perform as many checks as possible immediately, to report errors early
+and because it behaves better with respect to some contract operations.
 
 Let us see if we indeed preserved laziness:
 
 ```nickel #repl
-#hide-range{1-31}
+#hide-range{1-30}
 
 > let NumberBoolDict =
-    std.contract.custom
-      (fun value =>
-        if std.is_record value then
-          value
-          |> std.record.fields
-          |> std.array.fold_right
-            (fun field_name rest =>
-              if std.string.is_match "^\\d+$" field_name then
-                rest
-              else
-                'Error {
-                  message = "field name `%{field_name}` is not a number"
-                }
-            )
-            'Proceed
-        else
-          'Error { message = "not a record" }
-      )
-      (fun label value =>
+  std.contract.custom
+    (fun label value =>
+      let with_delayed_checks =
         value
         |> std.record.map
           (
@@ -912,7 +964,23 @@ Let us see if we indeed preserved laziness:
               in
               std.contract.apply Bool label_with_msg value
           )
-      )
+      in
+      if std.is_record value then
+        value
+        |> std.record.fields
+        |> std.array.fold_right
+          (fun field_name rest =>
+            if std.string.is_match "^\\d+$" field_name then
+              rest
+            else
+              'Error {
+                message = "field name `%{field_name}` is not a number"
+              }
+          )
+          ('Ok with_delayed_checks)
+      else
+        'Error { message = "not a record" }
+    )
 
 > let config | NumberBoolDict = {
     "1" | std.FailWith "ooch" = null, # same as our previous "fail"
@@ -927,28 +995,12 @@ Yes! Our contract doesn't unduly cause the evaluation of the field `"1"`. Does
 it check anything, though?
 
 ```nickel #repl
-#hide-range{1-31}
+#hide-range{1-30}
 
 > let NumberBoolDict =
-    std.contract.custom
-      (fun value =>
-        if std.is_record value then
-          value
-          |> std.record.fields
-          |> std.array.fold_right
-            (fun field_name rest =>
-              if std.string.is_match "^\\d+$" field_name then
-                rest
-              else
-                'Error {
-                  message = "field name `%{field_name}` is not a number"
-                }
-            )
-            'Proceed
-        else
-          'Error { message = "not a record" }
-      )
-      (fun label value =>
+  std.contract.custom
+    (fun label value =>
+      let with_delayed_checks =
         value
         |> std.record.map
           (
@@ -958,7 +1010,23 @@ it check anything, though?
               in
               std.contract.apply Bool label_with_msg value
           )
-      )
+      in
+      if std.is_record value then
+        value
+        |> std.record.fields
+        |> std.array.fold_right
+          (fun field_name rest =>
+            if std.string.is_match "^\\d+$" field_name then
+              rest
+            else
+              'Error {
+                message = "field name `%{field_name}` is not a number"
+              }
+          )
+          ('Ok with_delayed_checks)
+      else
+        'Error { message = "not a record" }
+    )
 
 > let config | NumberBoolDict = {
     not_a_number = false,
@@ -982,7 +1050,7 @@ error: contract broken by a value
 
 It does!
 
-#### Conclusion
+### Conclusion
 
 Our `NumberBoolDict` contract doesn't perform all the checks needed right away.
 Instead, it performs some of them immediately and **returns a new value, which
diff --git a/doc/manual/typing.md b/doc/manual/typing.md
index 9e0fd84d..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>:378:25
+    ┌─ <stdlib/std.ncl>:377:25
     │
-378 │       : 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 20e79938..58e39a16 100644
--- a/examples/config-gcc/config-gcc.ncl
+++ b/examples/config-gcc/config-gcc.ncl
@@ -12,25 +12,23 @@ let GccFlag =
 
   std.contract.custom
     (
-      match {
-        value if std.is_string value && is_valid_flag value => 'Ok,
-        { flag, arg } if std.array.elem flag supported_flags => 'Proceed,
-        value if std.is_string value =>
-          'Error { message = "unknown flag %{value}" },
-        { flag, arg = _ } =>
-          'Error { message = "unknown flag %{flag}" },
-        { .. } =>
-          'Error {
-            message = "bad record structure: missing field `flag` or `arg`",
-          },
-        _ => 'Error { message = "expected record or string" },
-      }
+      fun label =>
+        match {
+          value if std.is_string value && is_valid_flag value => 'Ok value,
+          { flag, arg } if std.array.elem flag supported_flags =>
+            # We normalize a record representation to a string
+            'Ok "%{flag}%{arg}",
+          value if std.is_string value =>
+            'Error { message = "unknown flag %{value}" },
+          { flag, arg = _ } =>
+            'Error { message = "unknown flag %{flag}" },
+          { .. } =>
+            'Error {
+              message = "bad record structure: missing field `flag` or `arg`",
+            },
+          _ => 'Error { message = "expected record or string" },
+        }
     )
-    # The delayed part is only used for normalization. Given the immediate part,
-    # we enter the delayed part if and only if the flag has the form
-    # `{flag, arg}`. We don't perform any check and just normalize it to a
-    # string
-    (fun _ value => "%{value.flag}%{value.arg}")
 in
 
 let Path =


Test patch:
diff --git a/cli/tests/snapshot/inputs/errors/validator_custom_error.ncl b/cli/tests/snapshot/inputs/errors/validator_custom_error.ncl
index 0647ecfa..e485845d 100644
--- a/cli/tests/snapshot/inputs/errors/validator_custom_error.ncl
+++ b/cli/tests/snapshot/inputs/errors/validator_custom_error.ncl
@@ -2,7 +2,7 @@
 # command = ['eval']
 let Is42 = std.contract.from_validator (fun value =>
   if value == 42 then
-    'Ok value
+    'Ok
   else
     'Error {
       message = "Value must be 42",
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 7591a62b..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>:167:9
+    ┌─ <stdlib/std.ncl>:166:9
     │
-167 │       | std.contract.unstable.IndexedArrayFun 'Index
+166 │       | std.contract.unstable.IndexedArrayFun 'Index
     │         -------------------------------------------- expected type
     │
     ┌─ [INPUTS_PATH]/errors/array_at_empty_array.ncl:3:16
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 89879ec0..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>:167:9
+    ┌─ <stdlib/std.ncl>:166:9
     │
-167 │       | std.contract.unstable.IndexedArrayFun 'Index
+166 │       | std.contract.unstable.IndexedArrayFun 'Index
     │         -------------------------------------------- expected type
     │
     ┌─ [INPUTS_PATH]/errors/array_at_out_of_bound.ncl:3:16
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 100b5420..2ba94fc5 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>:706:9
+    ┌─ <stdlib/std.ncl>:705:9
     │
-706 │       | std.contract.unstable.RangeFun Dyn
+705 │       | 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 5a9a5e30..621bed3b 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>:681:9
+    ┌─ <stdlib/std.ncl>:680:9
     │
-681 │       | std.contract.unstable.RangeFun (std.contract.unstable.RangeStep -> Dyn)
+680 │       | 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 ee32a042..d958d9dc 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,10 @@ source: cli/tests/snapshot/main.rs
 expression: err
 ---
 error: contract broken by the caller of `map`
-    ┌─ <stdlib/std.ncl>:151:33
+       expected an array
+    ┌─ <stdlib/std.ncl>:150:33
     │
-151 │       : 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
diff --git a/core/tests/integration/inputs/contracts/custom_generic_fail.ncl b/core/tests/integration/inputs/contracts/custom_generic_fail.ncl
index 1ea2bd72..3af2c26e 100644
--- a/core/tests/integration/inputs/contracts/custom_generic_fail.ncl
+++ b/core/tests/integration/inputs/contracts/custom_generic_fail.ncl
@@ -2,5 +2,5 @@
 #
 # [test.metadata]
 # error = 'EvalError::BlameError'
-let AlwaysFail = std.contract.custom (fun _ => 'Proceed) (fun label _ => std.contract.blame label) in
+let AlwaysFail = std.contract.custom (fun label _ => std.contract.blame label) in
 3 | AlwaysFail
diff --git a/core/tests/integration/inputs/contracts/custom_generic_immediate_fail.ncl b/core/tests/integration/inputs/contracts/custom_generic_immediate_fail.ncl
index e4a935db..5c7e0eb6 100644
--- a/core/tests/integration/inputs/contracts/custom_generic_immediate_fail.ncl
+++ b/core/tests/integration/inputs/contracts/custom_generic_immediate_fail.ncl
@@ -2,5 +2,5 @@
 #
 # [test.metadata]
 # error = 'EvalError::BlameError'
-let AlwaysFail = std.contract.custom (fun _ => 'Error {}) (fun _ value => value) in
+let AlwaysFail = std.contract.custom (fun _ _ => 'Error {}) in
 3 | AlwaysFail
diff --git a/core/tests/integration/inputs/contracts/custom_generic_succeed.ncl b/core/tests/integration/inputs/contracts/custom_generic_succeed.ncl
index f75f7703..eef318df 100644
--- a/core/tests/integration/inputs/contracts/custom_generic_succeed.ncl
+++ b/core/tests/integration/inputs/contracts/custom_generic_succeed.ncl
@@ -1,9 +1,7 @@
 # test.type = 'pass'
-let AlwaysSucceedImmediate = std.contract.custom (fun _ => 'Ok) (fun label _ => std.contract.blame label) in
-let AlwaysSucceedFull = std.contract.custom (fun _ => 'Proceed) (fun _ value => value) in
+let AlwaysSucceed = std.contract.custom (fun _ value => 'Ok value) in
 
 (
   3
-    | AlwaysSucceedImmediate
-    | AlwaysSucceedFull
+    | AlwaysSucceed
 ) == 3