Skip to content

Selectors & Queries

Chapter 8 showed show, hide, group by, and style picking slices of the model. Every one of those clauses took the same kind of argument — a bare name, an aspect atom, an edge pattern, joined with and/or/not/in — without stopping to explain what that argument is. It’s a selector: one small query language, shared verbatim by views, by policies (forbid, require), and by named query declarations you can write once and reuse everywhere.

query PciServices: service and @@security.zone:"PCI"
policy PciIsolation {
"PCI workloads never talk to the public zone directly"
forbid PciServices > @@security.zone:"Public"
}
view PciBoard {
"PCI-scoped services, violations flagged"
show PciServices
style violating PciIsolation { color: crimson }
}

One query — PciServices — feeds a policy’s forbid and a view’s show; the policy’s findings feed the view’s style violating. Same algebra, three consumers. That’s the point of this chapter.

Three ideas carry the whole thing:

  1. One algebra, two sorts. A selector names either a set of nodes (modules, interfaces, processes, spaces) or a set of edges (the calls and do invocations processes derive). Which one is decidable from the text alone — an arrow makes it edge-sort, nothing else does.
  2. Sigils mark the axis. A bare name is an element or type reference; @path reads a field or a builtin; @@key reads the aspect axis; $name reads a knob; * is “everything.” You can tell what a fragment means without knowing where it sits.
  3. Selectors and expressions meet only at portals. where (…), exists (…), count (…), sum @g of (…) — parenthesized keyword portals. Inside, the grammar starts fresh: * is “everything” in a selector, multiplication in an expression. The two languages never blend mid-term.

Every selector is either node-sort (it denotes elements) or edge-sort (it denotes derived edges). The rule is purely textual: a fragment containing an arrow (>, >>, <>) is edge-sort; everything else is node-sort.

query PciTeam: service and @@security.zone:"PCI" // node-sort — no arrow, a set of elements
query PciEgress: @@security.zone:"PCI" > * // edge-sort — the arrow decides it

and/or require both sides to share a sort — an and mixing a node term with an edge term is a diagnostic, not a silent reinterpretation. Precedence, loosest to tightest: or < and < not < a bare term (an atom, an arrow, an extraction). Parens override:

view PrecedenceDemo {
show service and in Payments or database // reads as (service and in Payments) or database
hide not @@team // complement — everything without a team
}

hide not @@team is legal on its own — a selector built entirely of predicates (not, in, on, where) is node-sort over the implicit universe of everything in scope.

The building blocks of a node-sort selector:

AtomMeaning
*everything in scope
service (a type name)instances of that type — subtypes included
module interface surface process subprocess spaceevery element of that built-in kind, across all types
Payments (an element reference)that one element
@@keyanything carrying that aspect key, any value
@@key:"value" / @@key:Placecarriers with that value or that materialized place
$namean element-kind knob
thisthe bound subject — only inside a portal or a require obligation
outsideelements not selected in this view — hide/style edge sides only
violating <Policy>that policy’s active findings — sort follows the policy

Two rows look similar and mean different things: a type name (service, or a user type like pci_service) matches instances of that type and its subtypes; a kind keyword (module, process, …) matches every element of that structural kind regardless of type. service and process are peers in the table but answer different questions — “which type” versus “which shape.”

export type service pci_service {
"A service in PCI scope."
}
query PciByType: pci_service // matches pci_service instances — and any of ITS subtypes too

@@ is always the aspect axis in a selector — never aspect, which stays declaration-only. A valued aspect atom is glued: no space between the key, the :, and the value:

view ZoneScope {
"One security zone, picked at open time"
knob zone from @@security.zone
show @@security.zone:$zone
}

violating <Policy> selects only a policy’s active findings — waived ones live on the waiver report, not the board:

policy PciIsolation {
forbid @@security.zone:"PCI" > @@security.zone:"Public"
}
query PciFindings: violating PciIsolation // edge-sort here — PciIsolation is a forbid over an arrow

Terms combine only with explicit and/or; not prefixes any term. Alongside atoms, three predicates filter a conjunction — they carry no sort of their own, so they adapt to whatever sort the rest of the conjunction has:

PredicateMeaning (node-sort)
in <Module | Space>structural descendant, or member of that space
in <Process> / in thisparticipates in the process
on <plane>present on that plane
where ( <expr> )an expression predicate — see Expressions below
query PaymentsInternals: in Payments // everything structurally under Payments
query DeploymentTargets: service and on deployment // services present on the deployment plane
query BigFanIn: where (@connections > 20) // an expression predicate, standing alone

in this is the one predicate whose meaning depends on what it’s attached to: bound to a process, it means “participates in this process”; bound to a structural subject (a module, a space), it means “is a descendant of this.” It only parses where a subject is actually bound — inside a where/exists/aggregate portal, or as the subject side of a require obligation.

An arrow always has both sides written — no open sides, no juxtaposition. A side is an atom, this, $knob, *, or a parenthesized node selector:

query DirectToPayments: service > Payments // direct edges service → Payments
query UpstreamOfPayments: * >> Payments // the whole upstream cone, as edges
query NearPayments: * <> Payments within 2 // edges within 2 undirected hops
  • X > Y — base edges from an X to a Y.
  • X >> Y — edges on any directed path of length ≥ 1 from an X-node to a Y-node. exists (this >> Payments) reads “eventually reaches Payments.”
  • X <> Y — edges with one endpoint on each side, either direction. X <> Y within N bounds the path length; plain X <> Y is within 1.

within N (a number or $knob) is legal only on >>/<> — writing it after a plain > is a targeted diagnostic pointing you at >>.

Chains are hop union, not a path constraint. A > B > C derives the same way a process’s own call chain does — two independent edges, unioned, not “a path through B”:

query CheckoutHops: Customer > Gateway > Orders > Payments
// ≡ (Customer > Gateway) or (Gateway > Orders) or (Orders > Payments)

Because it’s a union, a chain inside a require obligation is a trap: only the first hop constrains this, so require service: this > B > C passes vacuously the moment any B > C edge exists anywhere. Reach for this >> C (reachability) when you mean a real multi-hop obligation.

in <Process>/in this, on <plane>, and where (<expr>) also compose onto an edge-sort conjunction — where there is limited to the one edge builtin, @kind (call or do):

query InvocationEdges: (* > *) and where (@kind = "do") // just the do/invocation edges

in this, bound to a process, pulls in that process’s own derived edges — including through any subprocess or process it invokes via do. That splice-inclusion matters: without it, an audit obligation would falsely flag every process that reports through a shared do EmitOpsEvent helper instead of calling the audit log directly.

policy ProcessesAudited {
require process: (* > AuditLog) and in this
}

Four extractions turn an edge-sort selector back into a node set — the only sort conversion in the language:

query CallersOfPayments: sources of (* > Payments) // origin endpoints
query CalleesOfPayments: targets of (Payments > *) // destination endpoints
query PaymentsNeighborhood: Payments or nodes of (* <> Payments within 2)
query PaymentsProcessOwners: owners of (process and in Payments) // who owns those processes' steps

sources of/targets of need a directed pattern (>/>>); <> only supports nodes of. And they’re precise on purpose — inbound edge count and inbound caller count are different questions with different spellings: count (* > Payments) versus count (sources of (* > Payments)) (two edges from the same caller count once in the second, twice in the first).

nodes of/sources of/targets of never invent an anchor: nodes of (* <> X) is empty if X has no edges at all. Write the anchor explicitly, as PaymentsNeighborhood does above (Payments or nodes of (…)).

owners of is the one that reaches inside a process: it returns whoever owns any owned step — a call, a note, an await, a control-construct head — for the processes/subprocesses in its argument. A TODO-owned step contributes its synthesized stub, which carries no aspects — so a zone-shaped rule below fails closed on unowned work rather than silently passing it:

policy PciControl {
"No Public-zone decision-maker inside a PCI process"
forbid (process and @@security.zone:"PCI"
and where (exists ((owners of (this)) and not @@security.zone:"PCI")))
}

where (<expr>), exists (<selector>), count (<selector>), and <agg> @g of (<selector>) are the portals where the expression language lives — the one grammar shared by where predicates, table columns, and style computed values (Chapter 8).

Getters are sigiled, same as everywhere: @version/@repo.url read fields (Chapter 9), @@team/@@security.zone read aspects. A bare identifier in an expression is a literal, not a reference — where (@status = active) compares against the identifier active, not an element named active.

A short roster of builtins — reserved getter names, valid without declaring anything:

BuiltinSubjectMeaning
@nameany nodethe element’s name
@typeany nodethe element’s type
@connectionsany nodecount of edges touching it
@todosany nodeopen TODO count
@kindedgecall | do
@estimatedLatencyprocess/subprocessworst-case system latency
@distprocess/subprocesstrue if any owned action resolves to emergent control
@reversible / @compensatedprocess/subprocesshas saga content / that content is fully covered
policy LatencyBudget {
severity: warning
forbid process and where (@estimatedLatency > 500ms)
}

A duration literal like 500ms compares directly against @estimatedLatency — the comparison coerces to seconds on either side, so a latency-shaped field getter works the same way.

Operators: comparisons = != < <= > >=, booleans and or not, arithmetic + - * /, precedence loosest to tightest or < and < not < comparison < + - < * /. Parens group as usual:

policy CapacityGuards {
severity: advisory
forbid @@domain and where (sum @connections of (service and in this) > 300)
}

Aggregates: count (<sel>) over either sort, count distinct @g of (<sel>), and sum|min|max @g of (<sel>) (these need a node-sort selector, since they read a getter per element):

view ProcessAudit {
show process and in Checkout
table {
column @name
column "Teams": count distinct @@team of (service and in this and not Gateway)
}
}
policy PaymentsCostBudget {
severity: warning
forbid Payments and where (max @cost of (* > Payments) > 500)
}

exists evaluates a selector with this bound to the current subject, true iff the result is non-empty — one rule for membership (exists (this and PiiServices)) and reachability (exists (this >> PiiServices)) alike. It’s also what makes the owners of governance rule above (Extraction) work: exists ((owners of (this)) and not @@security.zone:"PCI") reads “some owner of this process’s steps is NOT PCI-zoned.”

Functions are explicit casts, never implicit: colorize(v) (categorical → a swatch-per-value legend), heat(v [, min, max]) (numeric → a gradient), css(v) (the value is already a color, used verbatim). They exist because a value never silently becomes a render color — you saw them driving style in Chapter 8; the cast is the same function whether it’s typed inside a style block or any other expression slot.

query Name: <selector> declares a reusable selector — at file top level or inside a module body. A query carries its sort: use a node-sort query wherever a node-sort selector is expected, an edge-sort one wherever an edge-sort selector is expected. Using it where the sort doesn’t fit is a targeted diagnostic, not a silent reinterpretation.

query PiiServices: service and @@data:"pii"

export query publishes a query across package boundaries the same way an exported subprocess crosses spaces — only file-root queries are exportable:

export query PciServices: @@security.zone:"PCI"

The opening example already showed the payoff: one query feeding a show, a policy’s forbid, and the resulting style violating. Here’s the full arc again, spelled out end to end — declare once, reuse in a view’s selection, in a policy’s rule, and in that view’s own compliance styling:

query PiiServices: service and @@data:"pii"
policy PiiAudited {
"Every PII-touching service is audited somewhere downstream"
severity: warning
require PiiServices: this >> Audit
}
view PiiExposure {
"Everything that can reach PII data, PII services flagged"
show PiiServices or nodes of (* >> PiiServices within 3)
style violating PiiAudited { color: crimson }
style PiiServices { widget { icon: shield } }
}

PiiServices never has to be re-derived — the view’s show, the policy’s require subject, and (transitively, through the policy) the view’s style violating all read the same declaration. Rename the aspect key once, in one place, and every consumer stays correct.

Chapter 32: Policies & Governance →forbid, require, except waivers, and when change gates built on the selector core this chapter covered.