Adding a negative narrowed every positive
outl's query fence could only say what a block is. Adding not-tag, not-prop, not-status, not-kind, not-since and not-text meant going back through every filter that already shipped and asking which of them were lenient in a way a complement could no longer afford. tag: matched by substring, so tag: ops also hit #opsec, which is one extra row you can see and ignore until you negate it and the row is simply gone. This is why the fix is one Filter::Not wrapper answered by a single ! rather than six hand-written negative variants, why prop: had to ship in the same change, why not-since: deliberately reads oddly, why every permissive-but-empty filter value became a parse error, and what the narrowing broke for queries that used to work.
tag: ops used to match #opsec.
It had matched it since the query fence shipped, and it never mattered. A filter that is slightly too generous puts one extra row into a list you are already reading. You see it, you shrug, you move on. Then the DSL learned to say not-tag:, which is the same predicate with a ! in front, and the same generosity became #opsec being removed from an answer with nothing on screen to say it had ever been a candidate.
That is the whole shape of this change. Issue #323 asked for one thing, “open work tasks, minus anything parked under #someday”, and getting it meant walking back through every positive filter the DSL already had and asking which of them were permissive in a way a complement could no longer afford.
the first version wrote two of six negatives by hand
The obvious implementation is the one I wrote first: add a NotTag variant, add a NotProp variant, give each an arm in the engine, ship.
Two of the six filters became negatable. The other four, status, kind, since and text, did not, for no reason except that the issue’s example did not need them. And the two that existed each carried their own copy of “does this block have tag x”, sitting a few lines below the copy the positive used.
That second part is the defect, and it is the one that survives review most easily, because on the day you write it the two copies agree. They agree the next week too. The question is what happens to them over a year, and the answer is specific rather than general: two implementations of one predicate drift, and the half that drifts is always the one that silently removes results. An over-inclusive positive shows you a row you can look at and dismiss. An over-inclusive negative deletes a row from the answer, and a user cannot notice a block that is not there.
So there is one negative filter, not six:
pub enum Filter {
Status(StatusFilter),
Tag(TagFilter),
Prop(PropFilter),
Kind(KindFilter),
Since(u32),
Text(String),
/// Negation of any of the above — what every `not-<key>` parses
/// to. Boxed because the enum would otherwise be recursive by
/// value; one allocation per negated directive, at parse time.
Not(Box<Filter>),
}
The parser strips exactly one not- prefix, parses the rest as if the prefix were not there, and wraps the result:
let (base, negated) = key
.strip_prefix("not-")
.map_or((key, false), |rest| (rest, true));
And the engine’s only negative arm is a ! over the positive’s own arm:
// Every `not-<key>` lands here. One `!` over the positive's
// own arm is the whole implementation, which is what makes
// `tag: x` plus `not-tag: x` return nothing: there is no
// second matcher to disagree with the first.
Filter::Not(inner) => !matches(inner, entry, status, is_journal, today),
tag: x together with not-tag: x returns nothing. Not because two implementations were kept in step, but because there is only one. The same shape means the next directive arrives negatable: add the variant, add the match arm, and not-<key> is live with no negative-filter work to remember. A hand-written NotFoo variant is now the thing to refuse in review, which is recorded as invariant 14 in the repo’s CLAUDE.md rather than as a comment in the file, because a comment does not survive the person who wrote it.
The complement law is pinned by a test that iterates the whole Filter set rather than the pairs that shipped first, over seven filters, three blocks and all three journal states:
#[test]
fn no_filter_and_its_negation_can_both_match() {
That is the same move as turning client parity into an exhaustive match: make the suite ask the question, because I will not remember to.
tag: had to narrow, and a narrowing breaks queries
Here is where the change stops being additive.
tag: was a substring test against each block’s cached lowercase text. #opsec contains #ops, so tag: ops matched it. #ops-team too. Documented behaviour was always the boundary match, #ops/deploy yes and #opsec no, and the implementation had quietly been the looser thing the whole time.
Fixing only the negative was not available. A negative that stops at the boundary while its positive does not is two matchers again, and tag: ops plus not-tag: ops would return #opsec rows. So tag: narrowed, and has_tag routes through the boundary-aware predicate that backlinks and tag counting already used:
fn has_tag(entry: &BlockEntry, t: &TagFilter) -> bool {
entry.text_fold.contains(t.needle())
&& outl_md::text_contains_tag_or_child(&entry.text, t.name())
}
The cached fold is still the first gate, because a boundary match is a subset of a substring match, so a block whose text does not contain #ops at all never reaches the tokenizer. That matters on a filter which auto-runs on every page load.
The cost is real and it is not recoverable by configuration. A fence that said tag: op and relied on it answering for #ops now returns less. Nothing warns about it, because the query still parses and still runs; it simply finds fewer blocks than it did last week. The instruction is to spell the tag, or its parent namespace, in full. I would rather make that trade once, loudly, in a changelog, than keep a substring match whose negation silently deletes #workflow from a query written to hide #work.
The short-circuit gate has a subtlety I would have missed if a test had not gone looking. to_lowercase is context-free per character with one exception: Greek Σ folds to ς at the end of a word and σ otherwise, decided by what follows it. The needle is folded from the tag name alone and the haystack from the whole block, so in principle they could fold differently and the cheap gate could hide a block the tokenizer would match. The test brute-forces the entire code-point space instead of arguing:
let divergent: Vec<char> = (0u32..0x11_0000)
.filter_map(char::from_u32)
.filter(|c| !(c.is_alphanumeric() || matches!(c, '-' | '_' | '/')))
.filter(|c| format!("ΑΣ{c}").to_lowercase().starts_with("ασ"))
.collect();
It comes back empty: every character that keeps a preceding Σ non-final is also a character the tokenizer accepts into a tag name, so the name never ends there and both sides fold the same bytes. The comment above it says what the test is actually resting on, which is that Unicode’s Cased set lines up with Rust’s is_alphanumeric. True today, nobody’s promise. That is a blind spot with a tripwire on it rather than a blind spot.
prop: shipped in the same change, because a complement needs a positive
not-prop: was half of what issue #323 asked for. prop: did not exist.
A negation with no positive counterpart is a filter whose complement cannot be written. You could exclude every block carrying a status:: and you could not select one, which is a strange thing to ship on purpose and a stranger one to ship by accident. So the positive landed in the same change, and the block index grew a field to answer it:
/// Block properties (`key:: value`), in document order.
///
/// Both population paths already carry them — the disk path off
/// [`OutlineNode::properties`], the tree path off
/// [`IdentifiedNode::properties`] — so this is a copy, not a
/// second parse.
pub properties: Vec<(String, String)>,
Lowercased once at index time, so the filter allocates nothing per block.
The end-to-end test for this exists because of what a unit test cannot see. The engine’s own tests build a BlockEntry by hand, which proves the matcher and nothing about where its inputs come from. If block properties never reached BlockEntry::properties, every not-prop: would be trivially true and the filter would look like it works, because it returns results. It just would not exclude anything. a_block_property_reaches_the_index_and_not_prop_can_see_it in crates/outl-exec/tests/query_negative_filters.rs drives the whole path a real fence takes, blocks projected from the tree into a WorkspaceIndex and embeds out the other end.
not-since: reads oddly and gets to keep reading oddly
since: 7d means “a journal dated within the last seven days”. Its exact complement is therefore everything else, which includes every ordinary page in the workspace, because an ordinary page was never a journal.
That is not what anybody types not-since: 7d expecting. The friendly reading is “older than seven days”, and implementing the friendly reading means writing a second matcher wearing a helpful name, which is the thing this entire change exists to not do. since: 7d plus not-since: 7d would then return rows, and the one property the six negatives are worth anything for is gone.
So the honest complement ships and the surprise is documented. docs/query.md says to read it as !since rather than as “older than”, and says the one you probably wanted is not-since: 7d paired with kind: journal.
every value that can only match nothing is now a parse error
This is the part of the change with the largest blast radius per line.
Consider not-tag: with nothing after it. The old parser was happy to build a filter with an empty tag name. The tokenizer never emits an empty tag, so the filter matched nothing, so the negation excluded nothing, so the query returned every single block the user had just asked to hide. Exit zero. No message.
Now run the same reasoning over the positive side: tag: with no name returns no results, the user notices immediately, and they go and look at their query. The identical typo is self-reporting in one direction and invisible in the other. That asymmetry is why these are errors now rather than permissive readings:
- An empty tag, on any surface.
- An empty
text:needle. Empty is a substring of everything, sotext:would match the whole workspace andnot-text:would erase it. - A dangling colon.
not-prop: status:is a parse error and not a wildcard, because reading it as “any status” drops far more than the query asked for. - A tag name outside the tokenizer’s alphabet. This DSL has no trailing comments, so
not-tag: research # parked stuffmakes the whole tail the name, and that name can never equal a tag token.
That last one is the one I would not have found by thinking about it. The error message names the offending character rather than saying the value is invalid:
if let Some(bad) = name
.chars()
.find(|c| !(c.is_alphanumeric() || matches!(c, '-' | '_' | '/')))
{
return Err(format!(
"tag '{name}' contains {bad:?}, which cannot appear in a tag \
(letters, digits, '-', '_' and '/' only)"
));
}
The same reasoning reaches the surfaces that are not the fence. The MCP tool takes not_tags as an array, and a non-string entry used to be dropped from the list; [null, "someday"] became ["someday"] and the caller was told it succeeded. The JS binding had the same hole in notTag and notProp. Both are errors now. And tag on the MCP tool is a scalar, so {"tag": ["work", "ops"]} used to drop the filter entirely and return the workspace as a match, which is precisely the shape an LLM reaches for first after it has just learned that not_tags takes an array:
/// [`opt_str`] answers `None` for a wrong type, which is right for a
/// field whose absence means "no opinion". It is wrong for a **filter**:
/// `{"tag": ["work", "ops"]}` would drop the filter entirely and return
/// the whole workspace as a match.
Some of these were live before this change and none of them were reported, which is the argument in miniature. A filter that quietly does nothing generates no bug reports, because its output is a plausible list of blocks.
One more entry in the same changelog section, because it is the same argument one layer down: since: 3м used to panic rather than report an unknown unit, because the value was split on its last byte and that is not a character boundary when the unit is multi-byte. The query runtime carries auto_run() == true, so that panic fired on every load of the page holding the fence, inside the TUI event loop or outl mcp serve, on no path anything could catch. A parse error in this runtime has to be an error, and it has to be reachable.
the law is per surface, because the CLI filters pages
outl query --tag=ops and a fence’s tag: ops share a name and not a matcher, and this change deliberately did not unify them.
The CLI filters pages: it asks whether the page’s subtree mentions #ops exactly and case-sensitively, so #ops/deploy is a different tag. The fence filters blocks, answers for namespace children, and ignores case. A property filter is spelled --prop key=value there and prop: key: value here, and the CLI’s reads the page’s own key:: property rather than properties on blocks inside it.
What holds on both is narrower than “the filters agree” and is the only part worth guaranteeing: each negative is the exact complement of its own positive, on its own surface. --tag=x --not-tag=x returns nothing and so does tag: x plus not-tag: x.
The DSL gets that structurally. The CLI cannot, because its flags are clap fields and not enum variants, so its negatives are hand-paired and the law has to be a test instead of a type:
#[test]
fn query_every_filter_and_its_negation_return_nothing() {
Six pairs, including --priority / --not-priority, which has no counterpart in the fence at all.
The CLI also grew the mirror of the fence’s parse errors, with one addition specific to having two spellings in one product. --not-prop "status: done" is the fence’s spelling typed at the shell. Taken literally it is a key named status: done, which no page carries, so the exclusion never fires and the command hands back exactly the pages it was told to drop. It is rejected now, and the message names the separator the CLI actually wants rather than saying the value is malformed.
what it cost
Thirty-one files, 3,004 insertions, 914 deletions, and the runtime came apart on the way: one 732-line query.rs is now a 556-line parser, a 508-line engine and a 326-line public surface, because it crossed the file-size ratchet mid-change.
The user-visible cost is the narrowing. Queries relying on tag: matching by substring return fewer blocks than they did, silently, and the only notice anyone gets is a changelog entry. Every filter value that used to be accepted and do nothing is now a hard parse error, so a fence that has been sitting on a page quietly excluding nothing will start failing on the next page load, with a line number. I think both are right and I am not going to pretend either is free.
And the ceiling the DSL shipped with is still there. not landed as a wrapper rather than as grammar, so there is still no or, no parens and no precedence. “Tagged #meeting or #code-review” remains unexpressible, and, exactly like “minus the parked ones” was before this, it is unexpressible silently: an unknown key gets a line number, a missing capability just reads as a query returning too much. That is issue #323’s own open question 3 and it is not closed.
The lesson isn’t that substring matching is sloppy. It’s that “too permissive” is not a property of a predicate at all. It is a property of a predicate plus the direction you read it in, and the moment you add a complement you flip that direction under every filter you already shipped. Go back and re-audit the lenient ones before the complement makes them invisible, because the version of the bug that reaches a user is never the extra row.
The parser is crates/outl-exec/src/runtimes/query/dsl.rs, the engine’s one negative arm sits next to it in engine.rs, the boundary predicate lives in crates/outl-md/src/tag.rs, and the rule is invariant 14 in the outl repository’s CLAUDE.md. If you want to see what the fence is for before reading how it works, it is the thing that replaced my task manager in the journal is the only page I open.