Five things I was sure about, and what's actually going on.

I got here through bdk-cli. I added randomized unspendable keys to taproot descriptors, so that script-path-only outputs stop wearing the same well-known key that anyone can spot on chain. Then I looked at what that key sits inside, and got hooked. Miniscript is a whole language for spending conditions, with a compiler and a type system behind it. So I sat down with rust-miniscript, and found out how much I'd just been assuming.

1. Compiling a policy just wraps it in a descriptor

What I thought: Policy and Miniscript are kind of the same thing, and compiling just puts a wrapper around it - or(pk(A),pk(B)) goes in, wsh(or(pk(A),pk(B))) comes out.

Actually:

$ bdk-cli compile "or(pk(A),pk(B))"
{
  "descriptor": "wsh(or_b(pk(A),s:pk(B)))#z2tsuf0c"
}

or came back as or_b, and the second key picked up an s:. That's not my policy in a wrapper, it's a translation into another language. Policy is what I write, Miniscript is what the compiler writes. And neither of them is what ends up on chain, by the way - that's plain Script, and Miniscript is a layer on top of it.

2. There is one Policy type

What I thought: there's one policy language, so I figured there's one policy type in the library.

Actually: there are two of them, sitting on opposite sides of Miniscript.

[Concrete Policy] ---compile---> [Miniscript] ---lift---> [Semantic Policy]
#!/usr/bin/env -S cargo +nightly -Zscript
---
[package]
edition = "2024"

[dependencies]
miniscript = { git = "https://github.com/rust-bitcoin/rust-miniscript", features = ["compiler"] }
---
use std::str::FromStr;

use miniscript::policy::{Concrete, Liftable};
use miniscript::{Miniscript, Segwitv0};

fn main() {
    // What I write.
    let concrete = Concrete::<String>::from_str("or(pk(A),pk(B))").unwrap();

    // What the compiler writes.
    let ms: Miniscript<String, Segwitv0> = concrete.compile().unwrap();

    // What analysis gives back, lifted out of the Miniscript.
    let semantic = ms.lift().unwrap();

    println!("concrete   {concrete}");
    println!("miniscript {ms}");
    println!("semantic   {semantic}");
}

chmod +x it and run:

$ ./miniscript-roundtrip.rs
concrete   or(1@pk(A),1@pk(B))
miniscript or_b(pk(A),s:pk(B))
semantic   (pk(A) ∨ pk(B))

Three different things out of one input. And see that 1@? I never typed it. A concrete policy always carries branch weights, and here they just defaulted to equal. The semantic policy has nowhere to put them, because it isn't describing what I want - only what the script means.

How I think about it now: semantic policy is a cut-down subset of concrete policy that exists only for analysis. You don't write one and you can't compile one. You lift it out of a Miniscript and ask it questions - does this policy entail that one, what are the timelocks, how many keys.

3. You can go back from a script to a policy

What I thought: compile goes down, lift goes up, so I can go back and forth.

Actually: it only goes one way, and the run above already shows the leak. The semantic policy came back without the weights: they did their job picking the script, then stopped existing. The other direction is no better - there are several valid Miniscripts for the same policy and the compiler just picks one, so a finished script tells you nothing about which policy it came from.

And it gets worse. Lifting hands you something that looks like a policy and parses as one, so the round trip seems to work right up until it quietly hands you a different script. It's a known trap - rust-miniscript#885 has the story, including a wallet tool that shipped it - and it's why semantic policy prints with and these days.

4. The policy language is part of the spec

What I thought: policy got standardized along with Miniscript.

Actually: BIP-379 covers Miniscript, BIP-380 covers descriptors. The policy language and its compiler sit outside both specs. The BIP barely mentions policy at all - one passing line in a list of things Miniscript is good for: "It can be targeted by spending policy compilers."

My guess at why it stops there: there's no single right answer for a compiler to produce. Same thing as section 3 - one policy has several valid encodings, and which one you get comes down to how that particular compiler trades size against likelihood. A language where every program means one thing is worth standardizing. A heuristic that picks one encoding out of many isn't.

So you can hand a descriptor to another wallet. A policy string, not really: it's a convenience of whichever compiler you happen to be using.

5. Miniscript goes in any descriptor

What I thought: descriptors are the wrapper, Miniscript is what you put in them.

Actually: welllll, yes and no. Descriptors are the general container, and only some of them have a slot for a script. pkh() and wpkh() want a key and refuse a script outright. sh() takes one even though BIP-379 excludes it, and Bitcoin Core flatly refuses the result - which I found out by trying it. And Miniscript with no wrapper at all parses fine, then falls over for a completely different reason: nodes only relay a couple of trivial bare script shapes, so yours would never make it anywhere.

P.S.

Yeah, this was written with an LLM. Nobody writes anything without the fucking things anymore, me included.

But I reread it dozens of times and made hundreds of edits getting here. So, dear reader, note the difference between a post written with an LLM and a post written by one.