the tree contract / a lesson, grep → CREATE TREE

Your ASTs were rows all along.

A hands-on lesson: query source code with CSS selectors in DuckDB today, understand the row contract underneath, and see where it's headed — CREATE TREE. Everything badged DEPLOYED runs in your CLI right now:

INSTALL sitting_duck FROM community;  LOAD sitting_duck;

DEPLOYED shipped · EXPERIMENTAL designed, assertion-planned · companion: The Substrate field guide

PRELUDE

Two tables you already have

DEPLOYED

A family of extensions loads hierarchical data into ordinary DuckDB tables: sitting_duck (ASTs, 27 grammars), webbed (XML/HTML), panduck & duck_block_utils (Markdown, DOCX, PDF). No pointers, no nested types, no document store — rows. Here is the same pattern from two very different producers.

A Markdown file through read_markdown_blocks becomes a block per row: type, nesting level, source order, content — the duck_block table format that duck_block_utils, panduck, and the markdown extension all share. A Python file through sitting_duck becomes a node per row (output schema): type, depth, DFS order — plus native extras like descendant_count — and CSS selectors already query it. Compare the two schemas and the same skeleton appears in both: rows in traversal order, a nesting column, a type column. Everything else — ids, attributes, counts — varies by producer. That shared skeleton is not a coincidence, and taking it seriously is what the rest of this lesson does: first by hand (Lessons 1–3), then as a contract (Lesson 4), then as syntax (Lessons 6–8).

That is also the order the work happened in, and the order is the argument. We started with ASTs and CSS: sitting_duck put selectors on source code, and it worked. Then we went backwards, to where CSS came from — HTML — and brought along everything already modeled as duck_blocks: Markdown, DOCX, PDF, not just HTML, with no conversion to or from HTML at any point. The duck_block tree handled it as it stood. Your documents were rows too. Only then did we ask the question this lesson is organized around: what do ASTs and duck_blocks actually need to be trees, what does CSS need on top of that to have anything to say, and what is there only to make it fast? Those three answers are R, S, and O.

LESSON 1

The Ascent, compressed

One task — find every Python function missing a docstring — seven ways. Click the rungs; each adds exactly one thing.

LESSON 2

sitting_duck, as it ships

Start with the table function read_ast('src/**/*.py'): it returns your codebase as rows — node_id, parent_id, type, depth, descendant_count, semantic_type — across 27 tree-sitter grammars. The taxonomy is the point: semantic_type_to_string(semantic_type) = 'DEFINITION_FUNCTION' means function-definition however your grammar spells it, so one query spans Python, Rust, and JavaScript without editing a type name.

And ast_select puts thirty years of muscle memory on top: .fn is a semantic class, #name an id, :not/:has what you already know from CSS. Custom pseudo-classes are SQL macros — :docblock is a macro whose body is itself a selector, so grammar-specific structure hides behind one portable name.

LESSON 3

The encoding: a tree as its own traversal

Node IDs are depth-first order, so structure is adjacency: anchored at a body block, a documented function is three consecutive rows — block, expression_statement, string — and DuckDB's window machinery reads it (with parentage verified, not just position). First-child is an aggregate: min(node_id) per parent; arg_min(type, node_id) reads its type.

This encoding has a name and a history: pre/size/level, from the XML-database era — Grust's XPath accelerator, MonetDB/XQuery's Pathfinder. sitting_duck's node_id / descendant_count / depth is that table column for column, converged on independently. That history pays off directly: DuckDB's recently optimized recursive CTEs (USING KEY, from the same Tübingen group) are about to matter below.

LESSON 4 · R

Required: what makes rows selectable

The selector engine never needed pointer-linked trees. The tree is real; the pointers were optional — what it consumes is rows under three requirements. R1: order matters. R2: a structural basis of level | parent — either one, each derivable from the other. A level column obeys level[n+1] ≤ level[n]+1 (descend at most one, pop freely — that invariant alone characterizes a forest); a parent column derives (order, level) via one recursive CTE, needing only a sibling sort key — without one the source conforms to the sibling-free profile: +, ~, :first-child refused legibly, everything else intact. That's the whole of it: R1 + R2 are the minimal treeness set — everything else in the contract is about what you can say or how fast you can say it. A type accessor isn't required for trees at all, only for CSS-style selection — so it lives in S (as S0°, defaulting every node to type node), and a typeless tree still matches structurally. And R0°, spelled ROOT (col, …), is required iff the relation holds more than one tree: tree-identity columns, constant per tree — delimiting forests, keying tree-granular DML (INSERT OR REPLACE by ROOT = re-derivation; never row surgery), feeding ATTACH. Absent, the relation is one tree; per-tree order resets without ROOT are rejected outright. Accessors may be bare column reads.

slotdeclaresexamplestatusunlocks
R0°tree identityROOT (file_path)required iff >1 treeforests · tree-granular DML · ATTACH coordinate · safe pre-MATCH pruning
R1row ordernode_id — DFS emit order, per treerequireddocument order; the ground adjacency stands on
R2structural basisLEVEL depth · PARENT manager_idrequired (either)treeness itself: descent, containment, every combinator; P13 checkable
LESSON 4 · S

Semantics: what selectors can say

First, the base slot: S0°, the type accessor — total, defaulting every node to type node, because selection needs a type dimension even when trees don't; declare TYPE and type selectors light up. S is the expressive surface: id accessors (#main), classes (.fn), and attributes in two tiers — named accessors, declared per attribute and typed (numeric and date operators live here; [start_line>100] needs a real INT), and a map catch-all for the long tail, string ops only. Named wins; the map serves undeclared names; neither declared → refused legibly. Rule of thumb: hot attributes earn names; the map is for the tail, not the hot path. And the extension mechanism: custom pseudo-classes as pure SQL macros — the macro is the semantics — with per-tree binding: a tree declares its pseudo map (or a prefix), shadowing a shared sel_* library tier, so :docblock can mean the right structure per grammar without collision. The pseudo name is the method, the tree is the type, the macro is the instance — and ambiguity is refused at registration, not discovered at match time. Some predicates are just named selectors: :docblock is a macro whose body is itself a selector, which is where grammar-specific structure goes to hide behind one portable name.

slotdeclaresexamplestatusunlocks
S0°type accessorTYPE kind → fndefault: 'node'type selectors (fn, "select")
S1°id accessorID name → #parse_astoptional#id · ATTACH join inference
S2°classes accessorCLASSES tags → .fnoptional.class
S3°attrs: named tier over mapATTR (start_line := start_line) → [start_line>100]open on concrete · closed on abstracts[attr op v] typed + vectorized · TREEQL step WHERE sees exactly this view
S4pseudo-classes, per-tree boundPSEUDO (leaf := descendant_count = 0) → :leafoptional:docblock · shared sel_* library · domain predicates
LESSON 4 · O

Optimizations: cost only, never results

O accessors buy speed: the non-native basis member, subtree size, child count, next sibling (pre+size+1). Each has a default derivable from (order, level); native columns register as overrides — and every override must test-equal its default. An override that changes a match set is not a fast path; it is a corrupted encoding, flagged at that severity.

The refinement that turns this from rule into theory: the R/O split has a criterion. Type, id, classes are row-local — they depend on nothing but the row. Every O member is row-transcendent: its value summarizes other rows, and that dependence is exactly what scopes it. O values are namespaced to (taxon, load-watermark) — valid within their own tree's materialized extent, never across an attachment splice (a parent tree's descendant_count cannot see attached forests), never above an unloaded frontier. A namespace-checked read yields a Bound; a mismatched read yields an Estimate — estimates may inform join order, never results. The XML era fought this as the dynamic-update problem and answered with renumbering; the contract answers with locality.

CASE STUDY · WHY duck_blocks SHIPS NO O
sitting_duck stores parent ids and descendant counts natively; duck_block_utils stores neither — and both are right. ASTs are deep (one function can be hundreds of nodes) and queried structurally, so O pays daily. Document blocks are bigger, flatter, and spliced constantly — assembled, sliced, concatenated — and id-free, O-free rows compose by concatenation; O columns would be perpetually stale under that splice rate. Omitting O isn't a gap. It's the correct point on the depth × splice-rate tradeoff, and the contract holds both ends.
slotdeclaresexamplestatusunlocks
O1°the non-native basis memberPARENT parent_id OPTIMIZEoptional overrideskips the derivation CTE
O2°descendant_count / sizeSIZE descendant_count OPTIMIZEoptionalO(1) subtree extent · range pruning · :has acceleration
O3°child_countCHILDREN kids OPTIMIZEoptional:empty and fan-out without scanning
O4°next_siblingNEXT next_id OPTIMIZEoptionalsibling hop = pre + size + 1
LESSON 4 · W

World: linking trees without touching them

Trees link to other trees, and the linkage has laws. W1: attachments are root-restricted foreign keys — child-tree roots reference parent-tree nodes, interior nodes never do (that restriction keeps ancestry a forest, not a graph), and the child root splices in at level(parent)+1. W2: loaders — demand materialization with a tri-state frontier (loaded / unloaded / absent; the distinction matters). Both are parent-parameterized: bindings are deterministic functions of the parent row's local columns — a code fence's language attr selecting the parser is the archetype. W3: epoch manifests, provenance keyed by (parent, params), refresh by selector.

The governing law, W-locality: within-tree match sets are invariant under any change to W. Attach, detach, load, refresh — a single tree's answers never move. W belongs to binding, planning, and provenance; never to semantics. And dangling references are drift telemetry, not errors: absence at a frontier is a fact.

slotdeclaresexamplestatusunlocks
W1°attachments: root FK + level spliceATTACH ledger TO coa.account USING (account_id)optionalcross-taxon selection · ontology mapping · hierarchy × facts
W2°loaders: tri-state frontierLOAD FROM fs_scan(:parent)optionalvirtual trees · demand loading (as-if-materialized)
W3°epoch manifestsREFRESH TREE fs BRANCH 'dir#var'optionalrefresh-by-selector · provenance

Now read the four block letters back. We taught them in dependency order — R structure, S speech, O speed, W world — because each stratum leans only on the ones before it. Rearrange the initials and they spell the contract's name: ROWS — Required, Optimizations, World, Semantics. Nobody planned the acronym; it was found — which is how you know the factoring is right.

LESSON 5

Who conforms already

sitting_duck v2 is splitting into a pluggable stack — languages as packages over a sitting_duckling core — and the contract sits a level below even that, as its own extension (working names: duck_nest / duckent) that sitting_duck and duck_block_utils both build on. Documents conform by construction: blocks already ship as ordered, leveled rows, so section h2 + table is the same machinery pointed at markdown. And the parent-basis relaxation admits a third source: every adjacency list in your warehouse — org charts, category trees, BOMs, parent-pointer file hierarchies — conforms via one recursive CTE, which USING KEY just made fast.

LESSON 6

duckent I — CREATE TREE: speaking Tree, fluently

EXPERIMENTAL

DuckDB 2.0's runtime-extensible PEG parser lets extensions add syntax and reuse the rest of SQL — so the ROWS contract becomes DDL. The shape specification is the contract, slot for slot, and it scales from two lines to full dress. Minimal: one slot makes rows a tree — type defaults to node, so * and every structural combinator already work. Typical: an abstract tree (SHAPE ONLY — required whenever there's no AS, so sourcelessness is declared intent, never an accident) instantiated with LIKE, which copies semantics, never source — the universal LIKE contract. Full dress adds the S tiers and O declarations.

Read the full-dress figure as a tour of the blocks: ROOT is tree identity (R0); LEVEL/TYPE the required basis; ATTR the named/typed tier (bulk-bindable via COLUMNS('a_(.*)') AS '\1') over an ATTRS MAP catch-all; PSEUDO binds pseudo-classes per tree — the value is an ordinary expression in the tree's row scope, exactly like a generated column or CHECK constraint, so calling a macro is just calling it: docblock := has_docblock(file_path, node_id). No lambda syntax, no binding machinery — SQL already knows how to evaluate an expression per row. A PSEUDO PREFIX pulls in a whole shared library, shadowed by anything tree-local. And every OPTIMIZE annotation auto-generates its conformance assertion: an O override that diverges from its derivable default is flagged as corruption, not speed. Miss a capability and the refusal is legible at bind time: "tree org declares no CLASSES accessor."

LESSON 7

duckent II — MATCH, and the query it abstracts

EXPERIMENTAL

Think of MATCH as Lesson 3, automated. The selector compiles into exactly the machinery you hand-wrote there — the QUALIFY windows, the grouped firsts, the anti-joins — generated against whatever a tree's shape declares. That's what "abstracts sitting_duck" means precisely: sitting_duck's ast_select is the special case where the basis is level, the O columns are native, and the vocabulary is the code taxonomy; MATCH is the same compilation, parameterized by any shape. Semantics consult declarations — R, S, and attachments — never state, so the answers can't depend on caches or load timing.

And the two bases earn their existence here. Level basis is what a producer emits when it already walks the structure — a parser streams DFS order and depth for free, which is why sitting_duck stores node_id/depth/descendant_count natively (pre/size/level: deep trees, structural queries daily, so the O columns are worth storing). Parent basis is how the world's data already sits — every org chart and category table is an adjacency list — and the recursive CTE that DFS-numbers it (fast under USING KEY) is the encoder. You can write it once to understand it; then the DDL internalizes it and you never write it again.

LESSON 8

duckent III — MATCH ∘ SQL: one pipeline, one host door

EXPERIMENTAL

MATCH is a clause, not a function. Bare, it flows straight into the pipeline you already know — FROM … USING TREE … MATCH … SELECT … WHERE … GROUP BY — terminated by the next clause keyword, scanned outside balanced brackets. No outer string literal, so quote-doubling dissolves. And because the shape projection is the matcher's view, not the result type, MATCH emits raw rows: the WHERE after it is ordinary SQL filtering on everything the source knows, no attribute macros required.

When attribute operators run out — subqueries, BETWEEN, real expression trees — SQL does not go into CSS. CSS stays wholly portable: it extends by pseudo-classes, and minting one is a declaration, not a hack — PSEUDO (busy := child_count > 2) and :busy exists. The host door is a second language on the same matcher: MATCH USING TREEQL, one step per line, each a combinator keyword plus parenthesized clauses whose keywords are the S slots — TYPE, ID, CLASS, ATTR, PSEUDO — plus a native WHERE that is arbitrary SQL against that step's projection row (closed shapes still refuse undeclared columns: power added, visibility unchanged). AS y names a step, and a named step is a joined alias you can read dotted in the SELECT after the match. The boundary doctrine, quoted in every refusal: CSS extends by pseudo-classes; TREEQL integrates the host; never both in one language.

One last collision, from our own toolchain: the taxonomy's 28th grammar is SQL itself, whose trees contain select nodes. Keyword-typed selectors quote like identifiers — MATCH "select" > "from" — and the pipeline keeps flowing. And TREEQL is more than language number two: every registered language normalizes to it, EXPLAIN MATCH prints it, and cross-language differentials compare its normal forms.

LESSON 9

duckent IV — forests, attachments, virtual worlds

EXPERIMENTAL

Forests and DML. ROOT makes DML tree-granular: append trees, drop trees by key, and INSERT OR REPLACE is re-derivation — re-parse a file, replace its tree, epoch bumps. Within-tree row surgery doesn't exist; the 2004 renumbering problem is answered by granularity: renumbering never crosses a partition you didn't wholly replace.

Attachments. Trees link by root-restricted foreign key — child roots reference parent nodes, interior nodes structurally can't (their only cross-tree coordinate is ROOT, constant per tree) — splicing in at level(parent)+1, with bindings that are deterministic functions of the parent row: a code fence's language attr selecting the parser is the archetype. The first thing attachments buy is ontology mapping: shared reference trees — NCBI's tree of life, WordNet, HS tariff codes — attached many-to-one, every row that names a concept keying into one taxonomy, with subtree membership as a pseudo-class; if you've ever shipped a closure table, you already built that tree's O-block. The second is the analytics workhorse, hierarchy × facts: every subtree rollup today is the same three-part boilerplate — recursively collect descendant ids, semi-join the fact table, aggregate — rewritten per hierarchy, per question. Attach the facts to the tree once and the boilerplate becomes one selector plus one aggregate. (Chains extend arbitrarily far — network to service to file to AST — each boundary a loader; the details are in the field guide.)

Virtual trees. Trees too large to materialize whole: materialize two levels, load branches on demand — selector pushdown decides which branches, unloaded is distinct from absent, and matching must equal the fully-materialized ideal within the selector's scope (so demand loading is an evaluation strategy, never a semantics change). Refresh is by selector; every load carries an epoch manifest.

LESSON 10

duckent V — what's built, what's next

The first three milestones now run as a macro prototype on DuckDB 1.5.5. Here is what exists, what building it proved, and where the next line of code goes.

EXPERIMENTAL

As of this revision, M0, M1, and M1½ run. The duckent repository holds them as SQL macros under sql/: a catalog of shapes and trees, the projection compiler onto the canonical columns, both basis derivations, forest DML with P13 checked on ingest, and a TREEQL match compiler with captures. Every mutating operation is a pure tree_compile_* macro that returns SQL text; a small Python runner executes it today, a bound table function will in the extension. Eleven sqllogictest files pass and nine planted mutants die, and the same .test files are meant to run unchanged against the C++ build.

Building it settled things the design could not. The derived _parent and _size equal sitting_duck's native parent_id and descendant_count on every row of both parquet fixtures, which is the O law holding on real data. DuckDB's query() refuses text from any macro whose body contains a SELECT, which is why the compilers that must read the catalog are executed by the runner rather than fed to it. A partitioned row_number() without ORDER BY does not preserve scan order, so frozen order captures the scan sequence first. And the harness did its job: two mutants survived their first run, and the answer was a multi-root test and a re-scoped mutant, never a manifest edit.

The review that gated the merge found the defect the tests had not: tree_ddl_alter rebuilt a tree from its create-time source and silently dropped every partition ingested since. It now refuses, naming the partitions and the way around. That is doctrine 3 and doctrine 4 in one finding, and it is the kind of thing the FINDINGS file exists to record.

Next is M2: nested HAS and NOT groups through a recursive fold over the selector IR, the CSS front-end lowering tree-sitter-css output to the same IR, and the differential against shipped ast_select on every selector both support. Then M3's O layer, M4's introspection and generated cheatsheet, and the TREEQL text parser. The C++ port replaces the derivations' recursive CTEs with one stack walk over pre-order, which is what "a tree is its own traversal" means operationally. If you want to help, the spec and the plan are in docs/superpowers/, the open decisions are where a second opinion is worth the most, and issues and pull requests are welcome; so is a note.

LESSON 11

Two doors we're leaving open

Text-to-ASTCSS. Text-to-SQL is a twenty-year benchmark field because SQL had the corpus; Text-to-XPath died with XML. Selectors arguably out-corpus SQL — every frontend tutorial since 2006 — and they fit in a grammar file, so a small local model can be constrained-decoded: it cannot emit an invalid selector, and spends its whole budget on which one. “Which functions are missing docstrings?” → .fn:not(:has(:docblock)). An execution-verified corpus (the engine as the verifier) is in progress. If you want to know more about Text-to-ASTCSS, message me: github.com/teaguesterling, or open an issue on duckent.

CSS is a policy engine. We've been using half of CSS: selectors are the address system, but CSS is selectors plus declarations plus a cascade — rules about what they select, conflicts resolved by specificity. Two lines make a permission system for code edits (see aside). The rest — worlds, audits-as-anti-joins, delegation — is in the companion field guide; this lesson only points at it. If you want to know more about CSS as a policy engine, look at umwelt, and at the selector surfaces it rides on: sitting_duck, pluckit, squackit. Or send me a note.

click to see the real rows — every column
duckent's own README.md · first 14 of 164 rows · all 7 columns · DuckDB 1.5 + markdown + duck_block_utils
kindelement_typecontentlevelencodingattributeselement_order
blockheadingduckent1text{"heading_level": "1", "id": "duckent"}1
blockparagraphTree semantics for ordered relations: the contract every duck sits on.1text{}2
blockparagraphNULL1text{}3
inlinetextduckent is a (planned) DuckDB extension that implements 2text{}4
inlineboldtree semantics over ordered relations2text{}5
inlinetext: the layer 2text{}6
inlineitalicbelow2text{}7
inlinetext every parser. It defines what makes a set of rows a tree, what a CSS-style selector means over those rows, and how a 2text{}8
inlinecodeMATCH2text{}9
inlinetext compiles to ordinary SQL. Parsers such as 2text{}10
inlinelinksitting_duck2text{"href": "https://github.com/teaguesterling/sitting_duck"}11
inlinetext (ASTs) and 2text{}12
inlinelinkduck_block_utils2text{"href": "https://github.com/teaguesterling/duckdb_duck_block_utils"}13
inlinetext (documents), and every adjacency list already in your warehouse (org charts, category trees, BOMs, file hierarchies), become 2text{}14
click to see the real rows — every column, plus ast_select
# app.py — the file being parsed
"""A tiny app: one documented function, one not."""


def greet(name):
    """Return a greeting for name."""
    return f"Hello, {name}"


def shout(name):
    return greet(name).upper()
first 30 of 54 rows · all 21 columns · struct columns shown as text · DuckDB 1.5 + sitting_duck
node_idtypesemantic_typeflagsnamequalified_namesignature_typeparametersmodifiersannotationsfile_pathlanguagestart_lineend_lineparent_iddepthsibling_indexchildren_countdescendant_countscopepeek
0moduleDEFINITION_MODULE8NULLNULL[][]NULLapp.pypython111NULL00353{'current': NULL, 'function': NULL, 'class': NULL, 'module': NULL, 'stack': [{'id': 0, 'kind': DEFINITION_MODULE}]}"""A tiny app: one documented function, one not."""
1expression_statementEXECUTION_STATEMENT0NULLNULL[][]NULLapp.pypython1101014{'current': 0, 'function': NULL, 'class': NULL, 'module': 0, 'stack': NULL}"""A tiny app: one documented function, one not."""
2stringLITERAL_STRING0"""A tiny app: one documented function, one not."""NULLNULL[][]NULLapp.pypython1112033{'current': 0, 'function': NULL, 'class': NULL, 'module': 0, 'stack': NULL}"""A tiny app: one documented function, one not."""
3string_startPARSER_PUNCTUATION1NULLNULL[][]NULLapp.pypython1123000{'current': 0, 'function': NULL, 'class': NULL, 'module': 0, 'stack': NULL}"""
4string_contentLITERAL_STRING0NULLNULL[][]NULLapp.pypython1123100{'current': 0, 'function': NULL, 'class': NULL, 'module': 0, 'stack': NULL}A tiny app: one documented function, one not.
5string_endPARSER_PUNCTUATION1NULLNULL[][]NULLapp.pypython1123200{'current': 0, 'function': NULL, 'class': NULL, 'module': 0, 'stack': NULL}"""
6function_definitionDEFINITION_FUNCTION30greet[{'semantic_type': DEFINITION_FUNCTION, 'name': greet, 'index': 1}]NULL[{'name': name, 'type': ''}][]NULLapp.pypython46011523{'current': 0, 'function': NULL, 'class': NULL, 'module': 0, 'stack': [{'id': 0, 'kind': DEFINITION_MODULE}, {'id': 6, 'kind': DEFINITION_FUNCTION}]}def greet(name):
7defDEFINITION_FUNCTION1NULLNULL[][]NULLapp.pypython4462000{'current': 6, 'function': 6, 'class': NULL, 'module': 0, 'stack': NULL}def
8identifierNAME_IDENTIFIER2greetNULLfunction_name[][in_function_definition]NULLapp.pypython4462100{'current': 6, 'function': 6, 'class': NULL, 'module': 0, 'stack': NULL}greet
9parametersORGANIZATION_LIST0NULLNULL[][]NULLapp.pypython4462233{'current': 6, 'function': 6, 'class': NULL, 'module': 0, 'stack': NULL}(name)
10(PARSER_DELIMITER1NULLNULL[][]NULLapp.pypython4493000{'current': 6, 'function': 6, 'class': NULL, 'module': 0, 'stack': NULL}(
11identifierNAME_IDENTIFIER2nameNULLidentifier[][in_parameters]NULLapp.pypython4493100{'current': 6, 'function': 6, 'class': NULL, 'module': 0, 'stack': NULL}name
12)PARSER_DELIMITER1NULLNULL[][]NULLapp.pypython4493200{'current': 6, 'function': 6, 'class': NULL, 'module': 0, 'stack': NULL})
13:PARSER_PUNCTUATION1NULLNULL[][]NULLapp.pypython4462300{'current': 6, 'function': 6, 'class': NULL, 'module': 0, 'stack': NULL}:
14blockORGANIZATION_BLOCK0NULLNULL[][]NULLapp.pypython56624215{'current': 6, 'function': 6, 'class': NULL, 'module': 0, 'stack': NULL}"""Return a greeting for name."""
15expression_statementEXECUTION_STATEMENT0NULLNULL[][]NULLapp.pypython55143014{'current': 6, 'function': 6, 'class': NULL, 'module': 0, 'stack': NULL}"""Return a greeting for name."""
16stringLITERAL_STRING0"""Return a greeting for name."""NULLNULL[][]NULLapp.pypython55154033{'current': 6, 'function': 6, 'class': NULL, 'module': 0, 'stack': NULL}"""Return a greeting for name."""
17string_startPARSER_PUNCTUATION1NULLNULL[][]NULLapp.pypython55165000{'current': 6, 'function': 6, 'class': NULL, 'module': 0, 'stack': NULL}"""
18string_contentLITERAL_STRING0NULLNULL[][]NULLapp.pypython55165100{'current': 6, 'function': 6, 'class': NULL, 'module': 0, 'stack': NULL}Return a greeting for name.
19string_endPARSER_PUNCTUATION1NULLNULL[][]NULLapp.pypython55165200{'current': 6, 'function': 6, 'class': NULL, 'module': 0, 'stack': NULL}"""
20return_statementFLOW_JUMP0NULLNULL[][]NULLapp.pypython66143129{'current': 6, 'function': 6, 'class': NULL, 'module': 0, 'stack': NULL}return f"Hello, {name}"
21returnFLOW_JUMP1NULLNULL[][]NULLapp.pypython66204000{'current': 6, 'function': 6, 'class': NULL, 'module': 0, 'stack': NULL}return
22stringLITERAL_STRING0f"Hello, {name}"NULLNULL[][]NULLapp.pypython66204147{'current': 6, 'function': 6, 'class': NULL, 'module': 0, 'stack': NULL}f"Hello, {name}"
23string_startPARSER_PUNCTUATION1NULLNULL[][]NULLapp.pypython66225000{'current': 6, 'function': 6, 'class': NULL, 'module': 0, 'stack': NULL}f"
24string_contentLITERAL_STRING0NULLNULL[][]NULLapp.pypython66225100{'current': 6, 'function': 6, 'class': NULL, 'module': 0, 'stack': NULL}Hello,
25interpolationLITERAL_STRING0NULLNULL[][]NULLapp.pypython66225233{'current': 6, 'function': 6, 'class': NULL, 'module': 0, 'stack': NULL}{name}
26{PARSER_DELIMITER1NULLNULL[][]NULLapp.pypython66256000{'current': 6, 'function': 6, 'class': NULL, 'module': 0, 'stack': NULL}{
27identifierNAME_IDENTIFIER2nameNULLidentifier[][in_interpolation]NULLapp.pypython66256100{'current': 6, 'function': 6, 'class': NULL, 'module': 0, 'stack': NULL}name
28}PARSER_DELIMITER1NULLNULL[][]NULLapp.pypython66256200{'current': 6, 'function': 6, 'class': NULL, 'module': 0, 'stack': NULL}}
29string_endPARSER_PUNCTUATION1NULLNULL[][]NULLapp.pypython66225300{'current': 6, 'function': 6, 'class': NULL, 'module': 0, 'stack': NULL}"
-- the selector, shipped today: functions with no string in their body
FROM ast_select('app.py', '.fn:not(:has(string))')
SELECT name, start_line;
┌ name  ┬ start_line ┐
│ shout │ 9          │
└───────┴────────────┘
Lesson 1's spelling, .fn:not(:has(:docblock)), puts a custom pseudo-class inside :has(); today's ast_select accepts only a type, #id, or .class there. duckent's matcher lifts that limit.
duckent V — what's built, the mutants, decisions, doctrine

What is built

milestonescopestatusevidence
M0catalog and projectionrunningtree_ddl_create/alter/drop, LIKE, SHAPE ONLY, ATTR defaults, P13 on ingest, tree-granular insert/replace/delete, introspection; tests 11_ddl, 12_dml
M1derivationsrunninglevel→parent→level and parent→(order,level)→parent identities on the fixtures; P13 property walks; sibling-free profile; tests 10_projection, 20_derivations
M1½TREEQL matchingrunningtree_steps, printer, linear join-chain compiler, captures with one row per embedding, NULL-definite clauses, per-query SEMANTIC overlay, refusals naming SEMANTIC and SIBLING_ORDER; tests 30_selector, 31_match, 32_traversal
M2nesting, css, differentialnextrecursive fold over the IR for HAS/NOT; tree-sitter-css lowered to TREE_SELECTOR; differential against shipped ast_select; P22 normal forms; P23 representation swaps
M3O layerplannedgenerated conformance per OPTIMIZE; planner-side ranges; the phantom-join benchmark
M4introspectionplannedcheatsheet generated from the catalog views
M-LANGlanguage layerplannedTREEQL text parser, language registration API (D-N10), bare-safety registry

The prototype is macros because semantics had to stop moving before a port. The C++ extension, from the DuckDB extension template, replaces the recursive CTEs with an O(n) stack walk (parent from the stack, size at pop, P13 in passing), the parent-basis encoder with a hash DFS, and the runner's emulation with bound table functions. The .test files carry unchanged.

Planted mutants: all must die

A suite no mutant can fail tests nothing. Each is a deliberate wrong implementation that the milestone's suite must detect; a mutant that survives is a missing test, never a manifest edit. Ids are permanent.

mutantthe wrong implementationstatus
MN1reverse the source-order tiebreak in DFS derivationplanted, killed
MN2derive parent as nearest prior at level, not level − 1planted, killed
MN3let a semantics-path function read an O accessor (must be caught by a boundary test, not review luck)M2
MN4an O2 override returns size + 1 (conformance must flag corruption)M3
MN5:has implemented as child-onlyM2
MN6sibling combinators silently no-op under the sibling-free profile (must refuse, not empty-match)planted, killed
MN7an unknown pseudo-class raises instead of being preserved and countedM2
MN8built-in parser and alternate parser disagree on a > b c groupingM2
MN9adding an attachment changes a single-tree match setW
MN10the planner uses a parent-tree descendant_count to prune a cross-taxon descendant walkW
MN11an O-derived prune above an unloaded frontier using stale sizeW
MN12a pseudo-class bound both tree-locally and in the shared tier resolves to the shared macro (tree-local must shadow)M2
MN13a typed comparison [start_line>100] silently served via the string mapM2
MN14an adjacency or subtree computation crossing ROOT partitions (must be ill-typed, not guarded)planted, killed — survived its first run until a multi-root test existed
MN15INSERT of a partition violating P13 acceptedplanted, killed
MN16the matcher skips an unloaded branch instead of loading itW
MN17a pre-MATCH row filter on a non-ROOT column acceptedplanted, killed — had to mutate the whole delete compiler; a second ROOT guard shadowed the fragment
MN18a closed abstract silently serves an undeclared attribute via the underlying columnplanted, killed
MN19a TREEQL step WHERE compiled against the raw relation instead of the projection viewplanted, killed (re-scoped from css)
MN20bare [WHERE] (no head) acceptedRETIRED with the css host escape; number reserved
MN21an S-empty tree fails structural matching (* > * and P13 must work with zero S declared)planted, killed
MN22two languages disagree on a shared denotation, or a language compiler consults stateM2 / M-LANG
MN23a delimiter-required language (jq) parsed bareM-LANG
MN24normalization to TREEQL is not semantics-preserving: lang → TREEQL → plan ≠ lang → planM2
MN25a representation swap (column ↔ expression, pseudo → class) changes a match set (P23)M2

Open decisions

idquestionstate
D-N1attrs accessor as MAP vs per-attribute expressionssettled as ATTR (a select list) plus ATTR MAP dispatching on MAP, JSON, VARIANT, or STRUCT
D-N2match provenance shapeminimal now: tree, language, unknown-pseudo count
D-N3sibling-free profile: warn once or refuse per queryrefuse per query (adopted)
D-N4cross-tree attachment in core or aboveinterface in core, registry above; no per-tree allow-lists, the join keys already constrain
D-N5incremental materializationtri-state + demand-load callback slot in core; caching above
D-N6explicit USING TREE AUTO opt-in column sniffingdefault no: sniffing misbinds silently
D-N7shape-registration API for producersopen
D-N8O3/O4 clause spellings CHILDREN … OPTIMIZE, NEXT … OPTIMIZEprovisional
D-N9capture residue: alias and table-name collisionsopen
D-N10language-registration APIopen
D-N12first-step keywordnone (adopted); ROOT rejected
D-N13keyword for CSS ~candidate FOLLOWING; SIBLING is adjacent
D-N14nested HAS/NOT step groups in TREEQLadopted in the core design; the proposal to follow
D-N15clause operands as bare identifiers or string literalsopen; the examples use both
D-N16ATTR versus ATTRS spellingthe core design uses ATTR, ATTR MAP, ATTR JSON

Doctrine the build inherits

#rule
1One semantics, N surfaces. Exactly one matcher; every language is a front-end bound to it by differential test, and TREEQL is its canonical text.
2Semantics consult declarations, never state. The tree_catalog / tree_state split is the module boundary.
3Every claim is a test or is marked open. FINDINGS.md records what first contact showed, especially when the design was wrong.
4Fail legibly. Bind-time refusals with the missing slot named, never empty results.
5Unknowns are preserved, excluded, reported.
duckent I — the DDL slot by slot, the design space, the experiment log

The DDL, slot by slot

Every clause of CREATE TREE names one slot of the ROWS contract. Nothing in the grammar exists that the contract did not ask for.

clauseblockrequired?declaresexample
ROOT (col, …)R0°iff the relation holds >1 treetree identity, constant per tree: delimits forests, keys tree-granular DML, is the ATTACH coordinateROOT (file_path)
ORDER colR1yes (defaults to row order)document order within a tree; the ground adjacency stands onORDER node_id
LEVEL col · PARENT colR2one of the twothe structural basis; each derives the other. PARENT needs SIBLING ORDER (…) for the full profileLEVEL depth
TYPE exprS0°no (default 'node')the type accessor, total; type selectors light up when declaredTYPE semantic_type_to_string(semantic_type)
ID exprS1°no#id; also ATTACH join inferenceID name
CLASSES exprS2°no.class; VARCHAR[] or one fixed classCLASSES tags
ATTR (n := expr, …)S3° named tiernotyped, vectorizable attributes; typed operators require this tier; bulk-bindableATTR (COLUMNS('a_(.*)') AS '\1')
ATTRS MAP colS3° map tiernostring-only catch-all for the long tail; named wins, map serves undeclared namesATTRS MAP extra
PSEUDO (n := expr, …) · PSEUDO PREFIX 'p'S4nopseudo-classes as row-scope expressions (generated-column precedent); PREFIX imports a shared sel_* tier, shadowed by tree-local bindingsPSEUDO (leaf := descendant_count = 0)
PARENT col OPTIMIZEO1°nothe non-native basis member, natively stored; skips the derivation CTEPARENT parent_id OPTIMIZE
SIZE col OPTIMIZEO2°nosubtree extent: O(1) ranges, :has accelerationSIZE descendant_count OPTIMIZE
CHILDREN col OPTIMIZEO3°no · spelling provisional (D-N8)fan-out without scanning; :emptyCHILDREN kids OPTIMIZE
NEXT col OPTIMIZEO4°no · spelling provisional (D-N8)sibling hop; default pre + size + 1NEXT next_id OPTIMIZE

Every OPTIMIZE auto-generates its conformance assertion: the override must test-equal its derivable default over the whole relation, queryable as an assert_* view. A divergence is flagged as a corrupted encoding, never accepted as a fast path.

Abstract trees, LIKE, and SHAPE ONLY

rulewhy
Shapes are not a second catalog kind. An abstract tree is a tree with SHAPE ONLY and no source.One kind, existing keywords. The separate SHAPE catalog was designed, then superseded: the footnote defending the mismatch became unnecessary, which is the sign the second design was right.
SHAPE ONLY is required whenever there is no AS clause.Sourcelessness must be declared intent, not an accident that surfaces three queries later. It also forces the ROOT slot to exist before any row does.
LIKE copies semantics, never source.The universal LIKE contract: structure, never data. CREATE TREE t LIKE ast AS FROM … instantiates; CREATE TREE py LIKE ast (SHAPE ONLY, PSEUDO (…)) refines and stays abstract.
FROM <shape-only tree> fails at bind: "SHAPE ONLY; no source bound."Fail legibly (doctrine 4).
Concrete trees are open: every column unclaimed by R/O is a named attribute. Abstract trees are closed: the declared interface is the type.Openness makes [start_line > 100] work with zero declaration; closedness is what the P14 differential test depends on. Escape on a closed shape is composition, not a hole: USING TREE (LIKE shape, ATTR extra := col).
Reserved future syntax: LIKE ast EXCLUDING OPostgres's INCLUDING/EXCLUDING option family, over the four blocks.

Applying a shape ad hoc: the design space, with verdicts

#candidateverdict
AFROM 'code.parquet' USING TREE ast MATCH '.fn'Recommended. A postfix modifier on a FROM item, exact precedent USING SAMPLE. Reads as the pipeline it is: source → semantics → query. Anonymous form: USING TREE (LEVEL depth).
BTREE 'code.parquet' SHAPE astAccepted as sugar. Statement-level shorthand mirroring TABLE src; desugars to A.
CTREE ast FROM 'code.parquet'Ranked down. Semantics before source reverses the read order every other construct teaches; statement-initial TREE collides with B and crowds CREATE TREE.
DFROM TREE 'code.parquet' (SHAPE ast …)Ranked down. Reads as a function call that isn't one; everything it does, A does with less grammar.
EFROM tree('code.parquet', shape := 'ast')Kept as the compatibility spelling. A real table function, no new grammar, works pre-2.0; A desugars to it.

Keyword order inside the modifier was its own decision: USING TREE ast over USING SHAPE ast (to a duckdb-spatial audience, SHAPE reads shapefile) and over USING TREE SHAPE ast (two keywords where one carries the meaning). PEG hygiene wants one unambiguous head token per extension's modifiers, and TREE is it.

The experiment log, re-run today

The DDL was designed by prototyping on shipped DuckDB, not on paper. Every ingredient below was re-executed for this page on DuckDB 1.5.5.

ingredientstatementresult
postfix modifier precedentFROM range(100) USING SAMPLE 10 ROWS✓ 10 rows: the USING-modifier chain is live syntax
statement shorthand precedentTABLE src✓ candidate B has a native twin
soft keywordscolumns named tree, shape, match✓ all valid identifiers: zero reserved-word cost
bulk ATTR bindingSELECT COLUMNS('attr_(.*)') AS '\1' FROM src✓ columns lang, kind: the star-expression machinery is the borrowed tool
name-to-relation indirectionquery_table('src')✓ the macro bridge's hinge
fast recursive CTEsWITH RECURSIVE walk USING KEY (email) AS (…)✓ the parent-basis encoder runs (see duckent II)
macro compositionshape macro over query_table, match macro nesting it✓ the modifier chain already runs as macro composition (see duckent II)
duckent II — the compiled query and the encoder, running on DuckDB today

The macro bridge: what MATCH compiles to, running today

Pre-2.0, the whole DDL compiles to two table macros. A shape is a projection macro onto canonical columns (_root, _pre, _level, _type, _id, _size, …). MATCH generates its query against those columns and nothing else. Below is the flagship selector compiled by hand exactly the way the compiler will emit it, run against the prelude's app.py on DuckDB 1.5.5 with sitting_duck.

LOAD sitting_duck;
CREATE TABLE app AS FROM read_ast('app.py');

-- a SHAPE compiles to a projection macro onto canonical columns
CREATE MACRO shape_ast(src) AS TABLE
  SELECT file_path AS _root, node_id AS _pre, depth AS _level,
         semantic_type_to_string(semantic_type) AS _type, name AS _id,
         descendant_count AS _size, parent_id AS _parent,
         (flags & 6) = 6 AS is_def,          -- ATTR: binds a name as a DEFINITION
         type AS node_type, start_line
  FROM query_table(src);

-- MATCH '.fn:not(:has(:docblock))' compiles against the canonical columns only
CREATE MACRO match_fn_without_docblock(src) AS TABLE
  WITH n AS (FROM shape_ast(src)),
  docblock AS (   -- :docblock = block > expression_statement:first-child > string:first-child
    SELECT b._pre, b._size FROM n b
    JOIN n e ON e._parent = b._pre AND e._pre = b._pre + 1   -- first child: next row in pre-order
    JOIN n s ON s._parent = e._pre AND s._pre = e._pre + 1
    WHERE b.node_type = 'block' AND e.node_type = 'expression_statement' AND s.node_type = 'string')
  SELECT f._id AS name, f.start_line
  FROM n f
  WHERE f._type = 'DEFINITION_FUNCTION' AND f.is_def          -- .fn
    AND NOT EXISTS (FROM docblock d                         -- :not(:has(…)) over the pre/size range
                    WHERE d._pre BETWEEN f._pre + 1 AND f._pre + f._size);

FROM match_fn_without_docblock('app');
┌───────┬────────────┐
│ name  │ start_line │
│ shout │ 9          │
└───────┴────────────┘

Two things to notice. The :has is a range test on _pre bounded by _size: that is O2 doing its job, and the default (scan forward until _level drops) gives the identical answer, which is the O law. And .fn needed one more predicate than the type alone: sitting_duck gives the def keyword the same semantic type as the function it opens, so the shape must declare which rows are definitions. That is what an S declaration is for: the compiler consulted a declaration, not a heuristic.

SELECT _pre, _level, _type, is_def, node_type FROM shape_ast('app') WHERE _pre IN (6, 7, 30, 31);
┌──────┬────────┬─────────────────────┬────────┬─────────────────────┐
│ _pre │ _level │ _type               │ is_def │ node_type           │
│ 6    │ 1      │ DEFINITION_FUNCTION │ true   │ function_definition │
│ 7    │ 2      │ DEFINITION_FUNCTION │ false  │ def                 │
│ 30   │ 1      │ DEFINITION_FUNCTION │ true   │ function_definition │
│ 31   │ 2      │ DEFINITION_FUNCTION │ false  │ def                 │
└──────┴────────┴─────────────────────┴────────┴─────────────────────┘

This is also P14 enforced mechanically. MATCH physically cannot see a column the projection did not emit; on a closed shape, a TREEQL step WHERE peek LIKE … refuses because peek is simply not there.

The encoder: parent basis → (order, level), in full

The lesson figure elides the recursive CTE to three dots. Here it is whole, run on DuckDB 1.5.5 with USING KEY. Every adjacency list in your warehouse conforms through exactly this query, and CREATE TREE org (PARENT manager_email, SIBLING ORDER (hire_date), …) is the promise that you never write it again.

CREATE TABLE employees(email VARCHAR, role VARCHAR, manager_email VARCHAR, hire_date DATE);
INSERT INTO employees VALUES
 ('ada@co','cto',NULL,'2019-01-07'),
 ('bo@co','manager','ada@co','2020-03-02'),
 ('cy@co','manager','ada@co','2019-06-11'),
 ('di@co','engineer','bo@co','2021-09-20'),
 ('ed@co','engineer','bo@co','2020-11-16'),
 ('fay@co','engineer','cy@co','2022-02-01'),
 ('gus@co','intern','fay@co','2024-06-03');

-- the encoder: parent basis -> (order, level); sibling key = hire_date
WITH RECURSIVE walk USING KEY (email) AS (
  SELECT email, role, manager_email, 0 AS level,
         [row_number() OVER (ORDER BY hire_date, email)] AS path
  FROM employees WHERE manager_email IS NULL
  UNION ALL
  SELECT e.email, e.role, e.manager_email, w.level + 1,
         w.path || [row_number() OVER (PARTITION BY e.manager_email ORDER BY e.hire_date, e.email)]
  FROM employees e JOIN walk w ON e.manager_email = w.email
)
SELECT row_number() OVER (ORDER BY path) - 1 AS node_id,
       level, role AS type, email AS id, manager_email AS parent
FROM walk ORDER BY node_id;
┌─────────┬───────┬──────────┬─────────┬─────────┐
│ node_id │ level │ type     │ id      │ parent  │
│ 0       │ 0     │ cto      │ ada@co  │ NULL    │
│ 1       │ 1     │ manager  │ cy@co   │ ada@co  │
│ 2       │ 2     │ engineer │ fay@co  │ cy@co   │
│ 3       │ 3     │ intern   │ gus@co  │ fay@co  │
│ 4       │ 1     │ manager  │ bo@co   │ ada@co  │
│ 5       │ 2     │ engineer │ ed@co   │ bo@co   │
│ 6       │ 2     │ engineer │ di@co   │ bo@co   │
└─────────┴───────┴──────────┴─────────┴─────────┘

Read the output as a tree: cy@co comes before bo@co because the sibling key is hire date, and the node ids are depth-first, so manager:has(> engineer) is a range test again. Without a sibling key the derivation still yields a valid pre-order, but not a canonical one, so the tree conforms in the sibling-free profile: +, ~, and :first-child are refused with the missing slot named; everything else works.

The two lemmas (P17: round trips are identities)

directionderivationneedschecked by
level → parentparent(n) = the nearest prior row at level(n) − 1, within the same ROOT partitionnothing beyond R1 + R2level → parent → level is the identity
parent → (order, level)DFS numbering: the recursive CTE above, path-ordereda sibling ordering key for the full profileparent → (order, level) → parent is the identity, given the key
well-formedness (P13)level[n+1] ≤ level[n] + 1 per partition: descend at most one, pop freelyROOT declared if the relation holds more than one treeproperty tests over generated level sequences; ingest-time check on INSERT
duckent III — TREEQL, the canonical form, and what moved

TREEQL: a keyword match language isomorphic to the S block

One step per line, each a combinator keyword plus parenthesized predicate clauses. The clause keywords are the slot names, so declaration and query speak the same words: CREATE TREE … TYPE kind on one side, (TYPE 'fn') on the other.

clauseslotnotes
TYPE 'fn'S0
ID 'parse_ast'S1
CLASS 'fn'S2repeatable
ATTR start_line > 100S3full SQL comparison operators over declared attributes; portable despite the operator richness, because the portability boundary is declared-attrs versus arbitrary expressions, not operator poverty
PSEUDO 'leaf'S4repeatable
WHERE <expr>the host doorarbitrary row-scope SQL, compiled against the step's projection view, never the raw relation (MN19 lives here now). The system's only host door.
grammar sketch; the first step takes no combinator (D-N12), like the first FROM item
match_treeql := 'MATCH' 'USING' 'TREEQL' step+
step         := combinator? '(' clause (',' clause)* ')' ('AS' ident)?
              | combinator                          -- bare step = any node
combinator   := 'CHILD' | 'DESCENDANT' | 'SIBLING'  -- SIBLING = adjacent (+)
clause       := 'TYPE' expr | 'ID' expr | 'CLASS' expr
              | 'ATTR' comparison | 'PSEUDO' expr | 'WHERE' expr

Semantics by desugaring: each step is a join against the same projection; the combinator compiles to a structural predicate over the basis (CHILD: parent equality; DESCENDANT: the pre/size range; SIBLING: adjacency under the same parent). Clauses compile to S-accessor predicates. Bare-safe by construction: every step opens with a keyword or a parenthesis, so no token collides with clause-keyword termination.

Worked example, and where CSS stops

FROM code
MATCH USING TREEQL
           (TYPE 'node')
CHILD      (ID 'parse_ast')
DESCENDANT (CLASS 'fn')
SIBLING    (ATTR start_line > 100)
CHILD      (PSEUDO 'first-child', PSEUDO 'leaf')
SIBLING    (WHERE child_count > 2)
CHILD      (CLASS 'if', WHERE child_count BETWEEN 4 AND 8) AS y
DESCENDANT (TYPE 'x')
CHILD
SELECT y.name
WHERE  something > 1

The first five steps have an exact CSS spelling: node > #parse_ast .fn + [start_line>100] > :first-child:leaf. The three WHERE steps have none, and that is the point: CSS is the portable, corpus-rich surface (browser muscle memory, Text-to-ASTCSS), TREEQL is the one that can afford a ten-line pattern and a host predicate. Result-set WHERE, SELECT, and GROUP BY follow bare, unambiguous because a step-level WHERE is always parenthesized.

Captures

ruleconsequence
AS y names a step; CSS spells it postfix @y; tree-sitter queries spell it @y tooone capture syntax per language, one meaning
a capture is a joined relation alias with statement scopeSELECT *, z adds the captured row as a struct; WHERE baz IN y.ipsum reads through it; SELECT * alone is the subject row
one output row per full-pattern embeddingthe join semantics' natural answer to multiplicity
captures under NOT or HAS refusea negated or existential context has no row to bind

The canonical-form role

Every registered language normalizes to TREEQL. EXPLAIN MATCH prints it, so a plan has one human-readable spelling. Cross-language equivalence (P22) compares TREEQL normal forms instead of pairwise fixtures. The lesson's "selector → window functions" compilation gains its middle rung: CSS → TREEQL → SQL. And a second constrained-decoding target sits beside CSS: no phrasebook, but a grammar so SQL-shaped the constraint is cheap. MN24 guards the whole arrangement: lang → TREEQL → plan must equal lang → plan, or EXPLAIN lies.

The rules, as they stand

formstatusrule
MATCH '.fn:not(:has(:docblock))'string form, pre-2.0A plain string literal; dollar-quote for gnarly cases. Stays legal after 2.0.
MATCH .fn:not(:has(:docblock)) SELECT …bare form, 2.0Ends at the next top-level SQL clause keyword, scanned outside balanced brackets. Bare eligibility is a per-language registry flag: css, xpath, tree-sitter queries, and TREEQL qualify; jq does not, because its own select collides with clause termination (MN23).
MATCH USING <lang> … · $lang( … )language tagSelects the parser through the language registry. $( ) is needed only for language tags and quoting certainty; bare CSS is always eligible.
TREEQL step WHEREthe host doorCompiled against the step's projection view. Unqualified names resolve to the step's own row; other steps' aliases may be referenced explicitly.
.fn[WHERE …] in cssrefusedWith the routing hint: css has no host escape — mint a PSEUDO, or MATCH USING TREEQL.
MATCH "select" > "from"legalKeyword-typed selectors quote like identifiers; clause-keyword exit wins by ordered choice.

What moved, and why

changeeffect
*[WHERE …] removed from cssCSS is wholly portable: no host escape, no dialect split, no flag. Its narrow use cases route to TREEQL step WHERE or to pseudo-class minting, which the S4 machinery already provides.
MN20 retiredThe "bare [WHERE] accepted" mutant has nothing to guard. Its number stays reserved: mutant ids are stable, never renumbered.
MN19 re-scopedSame test, new home: a TREEQL step WHERE compiled against the raw relation instead of the projection view must die.
P23 representation invariance, MN25Accessor representation is semantically invisible: a materialized column and an equivalent expression give byte-identical match sets across the full selector battery; a pseudo-class promoted into an ingest-time class satisfies :x before ≡ .x after. Any swap that changes a match set is MN25.
data / code doctrineClasses (S2) are data: row-carried, enumerable, closed per epoch, GROUP-BY-able. Pseudos (S4) are code: registered predicates, open-world. Promotion is the sanctioned migration, and P23 certifies it. Introspection enumerates classes and never pseudo truths.

Rejected spellings, and why

spellingrejected because
.fn (WHERE …)Whitespace is the descendant combinator, and it is sacred.
.fn(FILTER …)Collides visually with pseudo-class arguments.
*[WHERE …] as a css escapeIt split css into a portable and a host-bound dialect and needed a head rule to disarm a stray-space accident. Moving the host door to TREEQL removes the dialect, the flag, and MN20 in one stroke.
MATCH … ENDCASE…END is sui generis; two closers is one too many.
MATCH ( … )Reads as a match(…) function call.
ROOT as the first-step keywordOverloads the R0 DDL keyword and falsely implies root-anchoring; SQL's first FROM item needs no JOIN keyword either (D-N12). Anchor explicitly with PSEUDO 'root' if wanted.

Open at this writing: D-N9 residue on captures (alias and table-name collisions); D-N10, the language-registration API; D-N13, a keyword for CSS ~ (candidate FOLLOWING; SIBLING maps to adjacent +); D-N14, nested HAS ( step+ ) and NOT ( step+ ) groups so TREEQL can spell :not(:has(…)) (adopted in the core design, pending in the proposal).

duckent IV — DML semantics, the W laws, and the boilerplate replaced

Forest DML: what each statement means

statementgranularitysemantics
INSERT INTO t FROM read_ast('new.py')whole treesAppends partitions with new ROOT values. P13 is checked per partition on ingest; a malformed level sequence is rejected, not stored (MN15).
DELETE FROM t WHERE file_path = 'old.py'whole treesDrops partitions by ROOT. Interior rows cannot be deleted individually; a non-ROOT predicate here is ill-typed.
INSERT OR REPLACE INTO t FROM read_ast('mod.py')whole treesRe-derivation spelled in shipped syntax: replace the partition, bump its epoch. Renumbering never crosses a partition you did not wholly replace, which is the answer to the 2004 dynamic-update problem.
within-tree row surgery—Does not exist. The ROOT key is a primary key at tree granularity.
pre-MATCH WHERE on a non-ROOT columnrefused or routedP21: removing interior rows before tree interpretation corrupts level sequences; only ROOT-granular pruning is safe before MATCH. Post-match WHERE is the raw filter (MN17).
adjacency across partitionsill-typedThe whole R block (order, level invariant, derivations, O defaults) is scoped by ROOT; a window that crosses a partition is a type error, not a guarded edge case (MN14).

W: the laws of linking

lawstatementconsequence
W1 — attachmentsA child tree's root references a parent-tree node via a root-restricted foreign key and splices in at level(parent) + 1. Interior nodes structurally cannot: their only cross-tree coordinate is ROOT, constant per tree.Ancestry stays a forest, not a graph. ATTACH child TO parent.node infers the join from ROOT ↔ the parent's ID accessor; USING (…) covers mismatches only.
W2 — loadersDemand materialization with a tri-state frontier: loaded / unloaded / absent. Bindings are deterministic functions of the parent row's local columns (a code fence's language attribute selecting the parser is the archetype).Matching must equal the fully-materialized ideal within the selector's scope (P20), so a loader is an evaluation strategy, never a semantics change.
W3 — epochsEvery load carries an epoch manifest; provenance is keyed by (parent, params); refresh is by selector.REFRESH TREE fs BRANCH 'dir#var' reloads exactly the branches the selector reaches.
W-localityWithin-tree match sets are invariant under any change to W. Attach, detach, load, refresh: a single tree's answers never move.W belongs to binding, planning, and provenance; never to semantics. MN9 (an attachment changes a single-tree match set) must die.

Frontier states and what the matcher does

statemeaningmatcher behaviourO accessors
loadedthe branch is materialized at the current epochmatch normallyBound (namespace matches)
unloadedthe branch exists but is not residentthe selector reaching it must load it (P20). Skipping it turns "unloaded" into "absent" silently (MN16).Estimate only: may inform join order, never results (MN11)
absentthe reference dangles: nothing is therea fact, reported as drift telemetry, not an errorn/a

O values are namespaced to (taxon, load-watermark). A parent tree's descendant_count cannot see an attached forest (MN10) or reach above an unloaded frontier. A namespace-checked read yields a Bound; a mismatched one yields an Estimate. Bound versus Estimate is a type, not a convention: the XML era fought this as the dynamic-update problem and answered with renumbering; the contract answers with locality.

Example 2, run today: the boilerplate the attachment replaces

The chart-of-accounts rollup is the community's canonical clunky-CTE case. Below, the boilerplate as it stands on DuckDB 1.5.5, with real numbers, followed by the duckent IV spelling.

CREATE TABLE coa(account_id INT, name VARCHAR, parent_id INT);
INSERT INTO coa VALUES (1,'root',NULL),(2,'opex',1),(3,'salaries',2),(4,'travel',2),
                       (5,'capex',1),(6,'servers',5),(7,'airfare',4);
CREATE TABLE ledger(entry_id INT, account_id INT, fy INT, amount DECIMAL(12,2));
INSERT INTO ledger VALUES (1,3,2026,120000),(2,7,2026,4200),(3,6,2026,80000),
                          (4,3,2025,110000),(5,4,2026,900);

-- today: collect descendant ids, semi-join the facts, aggregate. Per hierarchy. Per question.
WITH RECURSIVE under_opex AS (
  SELECT account_id FROM coa WHERE name = 'opex'
  UNION ALL
  SELECT c.account_id FROM coa c JOIN under_opex u ON c.parent_id = u.account_id)
SELECT sum(amount) AS opex_2026
FROM ledger
WHERE fy = 2026 AND account_id IN (FROM under_opex);
┌───────────┐
│ opex_2026 │
│ 125100.00 │
└───────────┘

-- duckent IV: attach the facts once, then one selector plus one aggregate
ATTACH ledger TO coa.account USING (account_id);
FROM ledger MATCH 'account#opex * entry[fy="2026"]' SELECT sum(amount);

Open decisions surfaced here

idquestionlean
D-N4Cross-tree attachment: in-core v1, or the layer above?Define the interface in core (attachment metadata, spliced-level semantics in the matcher) but no registry UI; declaring and managing attachments belongs to the vocabulary layer. Root-restriction is normative either way.
D-N5Incremental materialization (VIRTUAL TREE)Core ships the tri-state frontier and a demand-load callback slot in v1, nothing more. Caching, TTLs, and load scheduling live above.
D-N3Sibling-free profile: warn once, or refuse per query?Refuse per query; warnings get ignored.