Graph Engine Design
Status: implemented. It superseded the linear stage pipeline in
internal/engine/processor.go, which no longer exists; where this document and
the code disagree, the code is right and the disagreement is a bug in this file.
Summary
urlinsane becomes a generic graph engine. A target string expands into a typed property graph; operator plugins match patterns in that graph, do work, and expand it further. Expansion stops when a round produces no new eligible work, or when a limit binds — depth, budget, round cap or interrupt. Analyzers then run over the whole graph and a report is generated from it.
Typosquatting is the first workload expressed this way, not the shape of the engine. The same engine covers usernames, packages, repositories and emails because those are node types with different operators attached.
The inversion: the graph is the data, not the execution plan. Today a DAG orders collectors inside a hardcoded pipeline. Here the graph is the result.
Two layers, deliberately:
- A compiled plan (§5) — a static, inspectable, pinnable artifact naming
every operator that may run, its trigger, and the limits. This is what
--explainprints and what--planpins for reproducibility. - Pattern dispatch in rounds (§6) — the runtime mechanism. An operator runs when its pattern matches a node, which may be long after that node was created; matches are dispatched one generation per round, with a barrier between.
The plan answers “what will this do?”. Dispatch answers “when?”. Conflating them is what made the earlier revisions of this document inconsistent.
Where the DAG is, honestly
The entity graph is cyclic by nature — domain → ip → (PTR) → domain is
correct data, not a bug. The type-flow graph is cyclic for the same reason.
Neither can be topologically sorted, and termination never relies on
acyclicity (§8).
So nothing is topologically sorted to decide execution order. internal/graph/dag
survives for plan presentation: condensing the type-flow graph into strongly
connected components and ordering that condensation, so --explain renders in
a readable layered form. That requires Tarjan SCC plus a topo sort of the
condensation, not the current plain Kahn — a change to that package, not a
deletion.
1. Data model
type Node struct {
ID NodeID // hash(Type, Canonical(Key)) — stable for the node's life
Type NodeType
Key string // canonicalized at admission
Props Props // ordered field list, §1.3
}
type Edge struct {
ID EdgeID // hash(From, Rel, To)
From, To NodeID
Rel Rel
Props Props
}
These are the applier’s types. Operators never construct them — they emit
NodeRef/EdgeRef/PropSet (§4.3) and the applier canonicalizes, assigns
identity and applies merge policy.
Nodes and edges carry no provenance, status, or findings. Those live in
side tables keyed by NodeID / EdgeID. The reason is §1.2.
1.1 Edge classes
Every relation declares a class, which governs depth accounting (§8) and variant eligibility (§8):
| Class | Relations | Depth cost |
|---|---|---|
| Structural | LOCAL_PART, HAS_DOMAIN, HAS_TLD, OWNER, HOSTED_ON |
0 |
| Variant | VARIANT_OF |
0 (bounded by the terminal rule instead) |
| Observation | MANIFEST, RESOLVES_TO, NS, MX, PTR_TO, IN_ASN, REGISTERED_BY, EXISTS_ON |
1 |
Relation names read in the direction of the arrow: email --HAS_DOMAIN-->
domain. Direction is load-bearing — seed closure and depth both propagate from
the From node to the To node — so a name that reads backwards against its
arrow is a standing invitation to wire an edge the wrong way round.
The line between the first and last class is whether the edge required a
network call. Everything structural is derived from parsing the target string
alone. MANIFEST looks like decomposition but requires fetching the
repository, so it is an observation and costs depth — which is also what keeps
a repo scan from silently varying every declared dependency (§8).
1.2 Three layers of identity
| Layer | Contents | Purpose |
|---|---|---|
NodeID |
hash(Type, Canonical(Key)) |
in-memory identity — scheduler, seen-set, cache, edges and side tables all key on this |
| Addressed form | node (Type, Key, Props), edge (From, Rel, To, Props) — only |
gets the CID; this is what diffs between runs |
| Side tables | provenance, per-pair status, belief, findings, truncation ledger | keyed by NodeID, except the ledger which is keyed by canonical candidate key; persisted beside the block, never inside it. Belief is engine-internal and not exposed to analyzers (§9) |
Keeping provenance out of the addressed form is what makes two identical scans produce identical CIDs. With run ids and timestamps inside, every node would differ on every run and cross-run diffing — a primary use case — would be impossible.
NodeID is stable for the node’s whole life; the CID changes as props
accumulate. Both are needed and they are not the same thing.
Canonicalization is a required registry field (§2) and runs before the
admission decision, not alongside it. Every candidate is canonicalized as it
leaves the applier’s front door, whether or not it is ultimately admitted —
otherwise the truncation ledger’s denylist (§8) would compare raw keys and
Example.com would slip past a row recorded for example.com.
Without canonicalization the convergence the whole design rests on fails
silently the first time one operator emits Example.com and another
example.com. A key that fails to canonicalize is refused with a recorded
status and never becomes a node.
1.3 Props are an ordered field list
map[string]any cannot be deterministically encoded — map iteration order is
unspecified and any has no stable IPLD representation. Since a content
address over the props is load-bearing, that is disqualifying.
Each type registers an ordered field list; a field’s position is its stable index, and values are stored positionally.
type Props struct {
typ NodeType
values []Value // parallel to typ.Fields, addressed by index
}
func (p Props) Get(f Field) Value // Field resolved once from the registry
func (p *Props) Set(f Field, v Value) error
func (p Props) Each(fn func(Field, Value)) // declaration order
Field handles are resolved from the registry when an operator registers, so an
unknown field name fails at registration rather than returning a per-access
boolean at runtime.
Values come from a closed kind set: String, Int, Float, Bool, Bytes, Time.
Ordering is structural rather than imposed, which buys:
- Deterministic encoding with no sort step — order is a property of the type, so identical values encode identically by construction.
- Meaningful presentation order — report columns follow declaration order,
not
created, live, punycode, rankalphabetical noise. - Compact encoding — values are positional, so field-name strings are not repeated per node. At the 10⁵-node scale a wide scan reaches, that decides whether the graph fits in memory.
- Cheap access — an index, not a map hash.
The cost is an evolution rule that must be enforced: fields are append-only. Reordering or deleting one changes the meaning of every content address already in the store — corrupting diffs rather than failing loudly. Removal is by tombstone; the slot stays, marked deprecated. Each type carries a schema version so a decoder meeting a block from a newer binary detects trailing fields it does not know.
It does not preserve them. Props is sized from the registry schema, so there
is nowhere to hold a slot the running binary does not declare; an earlier draft
of this section promised round-tripping, which would silently drop the unknown
values and change the CID on re-save — data loss disguised as a successful
write. The implemented behaviour is to refuse: store fails the
rehydrate CID check rather than writing back a truncated node. Preserving them
properly needs an unknown-slots escape hatch in Props, which is not built.
1.4 Conflicting assertions
Two operators may assert the same field on the same node — whois and rdap
both produce registration dates. Under concurrent dispatch, last-write-wins
would be decided by network timing, which is nondeterministic.
Every field therefore declares a merge policy:
{Name: "created", Kind: Time, Merge: Precedence("rdap", "whois")}
All assertions are retained in the provenance side table; the materialized value is selected by declared precedence, falling back to lowest operator id. Deterministic regardless of arrival order — and disagreement between two sources is preserved as signal for analyzers rather than silently resolved by whoever answered first.
2. Type registry
This table is authoritative. Every type named anywhere in this document appears here; nothing else is a type.
Every entry carries a capability, a canonicalization function and a schema version (§1.3); versions start at 1 and rise only when a field is appended or tombstoned.
| Type | Cap | Canonicalization |
|---|---|---|
domain |
Nameable | lowercase, IDNA→punycode, strip trailing dot |
username |
Nameable | platform-qualified platform:handle, lowercased |
package |
Nameable | registry:name; PEP 503 for PyPI, lowercase for npm |
repo |
Nameable | host + owner + name, lowercased |
email |
Nameable | lowercase domain, preserve local-part case |
tld |
Observed | ICANN public suffix, lowercased |
ip |
Observed | normalized v4/v6 text form |
asn |
Observed | AS + decimal, no leading zeros |
registrant |
Observed | normalized org/handle string |
platform |
Observed | lowercase host |
Relations are registered in the same registry with their class (§1.1) and their own ordered field list.
Three canonicalization rules are easy to get wrong and are therefore spelled out rather than left to the implementer:
usernameandpackagekeys must be qualified —github:acme,npm:lodash. An unqualified key is a canonicalization refusal (§1.2), not a best-effort guess. Without qualificationgithub:acmeandpypi:acmecollapse into one node, which is exactly the convergence hazard qualification exists to prevent; the argument was previously made for packages only and applies identically to usernames.tldis the ICANN public suffix, falling back to the final label when the suffix is private or unrecognised — soexample.co.ukyieldsco.ukbutacme.blogspot.comyieldscom. Atldnode should always name something registry-operated, or clustering by TLD means nothing.- Host canonicalization maps for lookup without validating labels. Strict IDNA validation refuses precisely the malformed names variant algorithms are built to generate, so the scanner would drop its own most interesting output.
Field declarations live with the type, in the same registration call as the capability and canonicalization. This document does not enumerate them: they are per-type, append-only (§1.3), and belong in code where the tombstone rule is enforceable.
Nameable means variant operators may apply — necessary but not sufficient;
eligibility also requires seed-closure membership (§8).
A thing is a node only if it has identity worth converging on across the
graph. IPs, domains, registrants and ASNs qualify. Registration dates and
geolocations do not — they are props. A nameserver is a domain reached by an
NS edge, not a type of its own; making it separate would split
ns1.example.com into two nodes and destroy the convergence that justifies
this design. Mail hosts likewise: an MX edge to a domain.
3. Seed expansion
Not special-cased. The target becomes a seed node and decomposer operators match it like any other operator:
bob@example.com
email ──LOCAL_PART──→ username(bob)
└─HAS_DOMAIN──→ domain(example.com) ──HAS_TLD──→ tld(com)
github.com/acme/tool
repo(github.com/acme/tool) ──HOSTED_ON──→ platform(github.com)
├─OWNER──────→ username(acme)
└─MANIFEST───→ package(...) ← observation, not decomposition
Decomposition edges are structural and cost no depth (§8). MANIFEST is the
exception in that diagram: reading a repository’s manifest is a network call,
so it is an observation edge, and the packages it yields are outside the seed
closure (§8). Supporting a new input form — a Docker image ref, a bare IP — is
one plugin.
4. Operators
type Operator interface {
Id() string
Version() int // bump to invalidate cached results
Trigger() Trigger // when this runs
Emits() Effects // everything this may produce
Resource() string // rate-limit class: "dns", "whois", "npm", "http"
Exec(ctx context.Context, v View) (Delta, Outcome)
}
type Trigger struct {
On Selector // Types{...} or Caps{Nameable}
Where []Condition // HasProp(f), HasEdge(rel), BeliefAbove(t)
Reads Reads // []Field and []Rel, resolved at registration
}
type Effects struct {
Nodes []NodeType
Rels []Rel
Props []Field
}
Emits declares relations and props, not just node types, for two reasons. An
operator like geo emits no node type at all — only props on an ip — and
would otherwise be invisible to plan compilation. And without declared props,
the plan cannot detect a Where: HasProp(punycode) that no operator in the
plan can ever satisfy: a permanently dead operator the plan would list as
active, which defeats the point of --explain.
Reads holds resolved Field and Rel handles rather than strings, so an
unknown name fails at registration — the same rule §1.3 applies to props.
4.1 Triggers are patterns, not producer dependencies
An earlier revision gave operators Requires() []string — ids of operators
that must run first. That repeats the mistake the current collector DAG makes:
depending on producers rather than on data. geo does not depend on the
ip operator having run; it depends on there being an IP. Requires("ip")
breaks the moment a second operator also produces IPs, or ip is deselected.
Binding to a pattern dissolves it:
dns : On domain, Where HasProp(punycode) → ip, domain (via NS, MX)
geo : On ip → props on ip
ptr : On ip → domain
whois : On domain → registrant, props
rdap : On domain → registrant, props
dns needing normalized punycode is expressed as a data condition, satisfied
by whichever operator produced it. There is no ordering declaration anywhere in
the system, and no cycle risk from the ordering mechanism because there is no
ordering mechanism.
A Selector binds by type or by capability, so one omission algorithm covers
every Nameable type instead of being registered four times.
A variant operator is any operator that declares VARIANT_OF in its
Effects.Rels. That declaration — not a naming convention or a separate plugin
family — is what subjects it to the terminal rule and the seed-closure
restriction (§8), and it is checkable at plan-compile time.
4.2 Views are patterns, not snapshots of everything
type View interface {
Type() NodeType
Key() string
Prop(f Field) Value // only fields declared in Trigger.Reads
Edges(rel Rel) []*Edge // only relations declared in Trigger.Reads
}
Props are filtered too, not just relations. An earlier revision handed the
operator the whole *Node, which let it read fields it never declared. That
silently breaks the cache: the read-set digest (§6.3) is built from
Trigger.Reads, so an undeclared field could change without changing the key,
and the operator would be served a stale result forever. Filtering both props
and relations is what makes “pure function of its declared inputs” true rather
than aspirational, and it is enforced by the view rather than trusted to plugin
authors.
Edges, not neighbours: relation props carry data operators need. VARIANT_OF
holds algorithm and distance, which the ported per-node analyzers (§4.4)
read directly. Returning bare nodes would hide them.
Operators cannot query arbitrary graph state — that would make results order-dependent, runs irreproducible and caching unsound.
Because dispatch waits for the pattern, a view is never the empty
neighbourhood an at-creation snapshot would give. An operator needing the
origin of a variant declares Where: HasEdge(VARIANT_OF) and reads it; that is
how today’s origin/variant analyzers port over.
Anything needing whole-graph context is an analyzer (§9), which runs on a stable graph.
4.3 Operators never mutate
They return a Delta; the scheduler is the single writer.
type Delta struct {
Nodes []NodeRef // type + raw key; the applier canonicalizes and assigns NodeID
Edges []EdgeRef // From/To as NodeRef, Rel, props
Props []PropSet // (NodeRef|EdgeRef, Field, Value)
}
Operators emit NodeRef, not Node: they cannot compute a NodeID because
canonicalization (§1.2) belongs to the registry and the applier, and letting a
plugin mint an identity is how convergence quietly breaks. Every prop write goes
through PropSet so the field’s merge policy (§1.4) applies uniformly — a
Node carrying a whole Props block would bypass it.
Deltas are additive only. Nothing removes a node, edge or prop, which is what makes the graph monotonic within a run and a delta safely replayable.
Concurrent operators are race-free by construction, with no locking and nothing for plugin authors to get wrong, and each step is a diffable, replayable value. The applier is single-threaded, which is not a bottleneck: operator work is network-bound and dominates by orders of magnitude.
4.4 Family mapping
| Today | Becomes |
|---|---|
Algorithm |
operator with Selector{Caps: Nameable}, emitting VARIANT_OF edges |
Collector |
operator emitting observed nodes, edges and props |
Analyzer (per-node) |
operator with Where: HasEdge(VARIANT_OF), emitting props |
Analyzer (global) |
graph analyzer, §9 |
5. The compiled plan
Before anything runs, the seed, the registry and the CLI flags compile into a plan:
type Plan struct {
Hash string // identifies this plan exactly
Seed SeedSpec // type, canonical key, scope
Operators []OpBinding // defined once in §12.2
Model ModelRef // id + CID of the execution model, §10.4
Limits Limits // depth, budgets, ttl, rate classes
}
Hash covers the engine model and every plugin model (§10.6) as well as the
operators. A plan pinning operator versions but letting models float would
reproduce neither the traversal nor, where a plugin model decides what an
operator emits, the graph.
Compilation prunes operators unreachable from the seed by intersecting Emits
against triggers. Because the type-flow graph is cyclic, reachability is
transitive closure, not a topological walk — pruning is an over-approximation
and is presented as such, since Emits is a may not a must.
Compilation also reports dead operators: one whose Where names a prop or
relation that nothing in the plan declares in its Effects can never fire.
--explain lists these separately from active operators rather than implying
they will run — which is the whole reason Effects covers props and relations
and not just node types (§4).
The plan is the answer to “what will this do?”:
--explainrenders it, SCC-condensed and layered, and exits 0 — dead operators are listed, not fatal, since a plan that cannot fire an operator is usually the user narrowing scope rather than an error.--plan FILEwrites it; supplying an existing file pins it. A pinned plan plus a warm cache reproduces a run exactly, which is what makes results citable.- Execution dispatches only operators present in the plan. Runtime never reaches past it.
6. Execution
The scheduler responds to three kinds of event, not one. Every earlier revision of this document assumed deltas were the only trigger, and three separate bugs followed from that single assumption: a retry that could never fire, a belief condition that could never re-evaluate, and a tree parent chosen by network timing.
| Event | Raised by | Effect |
|---|---|---|
| Delta | an operator returning | apply it; re-evaluate triggers for touched nodes |
| Timer | a retriable failure | re-enqueue the pair after backoff, inside its round |
| Barrier | a dispatch round draining | finalize parents, belief, gating and truncation |
6.1 Rounds and the barrier
The scheduler runs in rounds. Round 0 is seeding: the engine admits the canonicalized seed node and runs barrier 0, which gives the seed its prior belief. Every round after that evaluates triggers against the graph as of the previous barrier, dispatches every eligible pair, and drains — including any outstanding timers. Then its barrier runs, and only at a barrier are irreversible decisions taken:
- tree parents are finalized, once and for all (§10.3),
- belief is recomputed for every node from its props as of this barrier,
BeliefAbovegates are evaluated for the next round,- truncation admits its prefix (§8).
Parents are fixed once; belief is not. A node created in round g has only the
props its creating operator set — observation operators on it dispatch in
round g+1 and land at barrier g+1. Computing belief once at creation would
therefore leave it a bare transition prior forever, which is the opposite of
what §10.2 needs. Recomputation is safe because belief is a pure function of
(parent belief, current props) and the parent never moves: the same run
recomputes the same values in the same order.
What recomputation cannot do is reverse a decision already taken on an earlier value. A node pruned at barrier g stays pruned even if barrier g+1 would have raised it — pruning is irreversible by construction (§6.1), and the truncation ledger (§8) records it so the loss is visible rather than silent.
All four are irreversible. A pruned node is gone; a whois already sent cannot be recalled. Taking them on provisional belief — belief derived from whichever parent happened to arrive first — makes the final graph depend on network timing, and no amount of later re-parenting repairs it, because re-parenting cannot un-prune a node or un-send a request.
Rounds, not depth waves. An earlier revision keyed barriers on depth. That
was wrong, and wrong in the way that mattered most: depth counts observation
hops only (§8), so decomposition, every variant, and the dns/whois calls
on those variants all sit at depth 0. Belief would first be computed at the
barrier ending depth 0 — after the hundred thousand whois calls it exists to
prevent. Gating was structurally a wave too late.
Rounds fix it because they follow dispatch, not distance. Decomposers run in
round 1, variant operators in round 2, and observation operators on those
variants in round 3 — with a barrier in between, so belief is available exactly
where BeliefAbove needs it.
A round is exactly one dispatch generation. Deltas arriving mid-round mark
pairs eligible; they are dispatched by the next round, never the current
one. This is the load-bearing rule. If a delta dispatched immediately, dns
emitting an ip in round 3 would let geo run on that ip in round 3 too —
before barrier 3 computed its belief — reproducing the depth-wave bug one level
down. One generation per round is what keeps “belief is final before anything
acts on it” true at every level, not just the first.
Parents are finalized once. A node’s parent is fixed at the barrier of the round that created it, chosen by §10.3’s rule over the in-edges present at that barrier. Every operator dispatched in a round drains before its barrier, so every in-edge created in that round is present. In-edges arriving in later rounds never re-parent — consistent with §10.3’s existing rule that later in-edges are not used as evidence, and what keeps parent choice a function of round membership rather than of arrival order.
Depth survives as a budget and termination concept (§8) only. It no longer defines synchronization.
The cost is real and accepted: no cross-round pipelining. Round g+1 cannot begin before round g drains. Reproducibility is a stated goal of this design; pipelining is not.
6.2 Retry lives inside its round
A retriable failure schedules a timer and stays in its own round; the barrier
waits for it. Each pair has a bounded attempt count and each resource class a
deadline. When that deadline passes, the pair records timeout permanently for
the round and the barrier proceeds.
This is what makes retry compatible with the barrier. A retry landing after the barrier would add props to a node whose belief was already final and whose descendants were already gated on it. Bounding retries inside the round fixes the outcome set before belief is computed, so a slow service delays its round rather than corrupting it — and deadline truncation is reported exactly like budget truncation (§8).
6.3 Dispatch
When a delta touches node N, triggers for N are re-evaluated and newly-matching
(node, operator) pairs are marked eligible for the next round (§6.1) —
never dispatched into the current one. Workers execute the current round’s pairs
concurrently; the applier serialises deltas and is the single writer.
Re-dispatch is bounded by the read-set. A pair runs once per
(NodeID, operator id, read-set digest). New data the operator declared it
reads makes it eligible again; new data it does not read does not. A hard cap
of 3 revisions per pair is the loop backstop.
Re-evaluating a BeliefAbove gate does not consume a revision — a pair that
stayed skipped never ran, so there is nothing to revise. Only an actual
execution counts, which keeps a gate that flickers across barriers from
exhausting the cap before the operator has run even once.
Belief is deliberately not part of the read-set digest. BeliefAbove is a
barrier-time condition, evaluated once per round, so it never participates in
delta-driven re-dispatch and never needs the digest to notice it changed.
Admission control, not queue dropping. The applier never blocks and never
discards queued work. When the frontier exceeds its bound — a limit on
in-flight candidates, distinct from the per-type and global node budgets of §8
— the scheduler stops admitting new nodes and writes frontier rows to the
truncation ledger. That removes both the deadlock and the silent loss.
Rate limiting is two-layer. Each operator declares one Resource() class
with its own token bucket; beneath it the HTTP/DNS transport limits per host.
Today’s single global --delay is meaningless once one run talks to DNS,
whois, npm, PyPI and GitHub — the limit protecting the strictest service
throttles everything else to the same crawl.
Cache key covers everything the operator reads:
H(operator id, version, modelCIDs, NodeID, readSetDigest, resourceConfigDigest)
Trigger.Reads supplies the read-set digest — the same declaration doing
double duty. resourceConfigDigest covers config that changes what a service
returns: nameservers for dns, registry URL and token for npm, proxy and UA
policy for http. Version() is what makes a fixed operator stop serving its old wrong answers,
and modelCIDs does the same for a retrained plugin model (§10.6).
A cache hit still occupies its round: the delta is applied at the same point a
live call’s would be, so a warm run and a cold run produce the same rounds, the
same barriers and the same graph. A cache that short-circuited the round
structure would make --plan reproducibility depend on cache state, which is
exactly what pinning is meant to eliminate.
7. Failure is data
An operator error is not merely a log line. For a scanner, how a lookup
failed is the finding: NXDOMAIN proves the name is free, SERVFAIL means
misconfigured, a timeout means nothing was learned. Collapsing all three into
“error” discards the signal the tool exists to collect.
Every (node, operator) pair records a terminal status in the side table, with
its provenance:
| Status | Meaning | Example |
|---|---|---|
ok |
learned something positive | A records returned; package exists on registry |
empty |
authoritatively determined absence | NXDOMAIN; registry 404 |
failed |
the lookup itself broke | SERVFAIL; malformed response |
timeout |
nothing was learned | deadline passed (§6.2) |
skipped |
never attempted | gated off by BeliefAbove (§10.2), or a variant operator refused outside the seed closure (§8) |
skipped is the one status that is not terminal. Belief is recomputed at
every barrier (§6.1), so a pair gated off at barrier g becomes eligible again at
barrier g+1 if belief has since risen above the threshold; recording skipped
terminally would make the first gate permanent and quietly starve nodes the
model later favours. Closure refusals are the exception — the seed closure never
grows, so that skip is final.
The ok/empty split is the one that matters and is easy to get wrong:
NXDOMAIN is empty, not failed and not ok. It is a successful
determination of absence, which is exactly the signal a squatting scanner
exists to collect — and it is what makes live (§9) mean anything. Reports
distinguish “confirmed absent” from “could not determine”, and a run with
widespread timeouts is visibly degraded rather than quietly thin.
Transient failures schedule a timer and retry inside their own round (§6.2), bounded by an attempt count and the resource’s deadline; they are not left to delta-driven re-dispatch, which would never fire because a failure produces no delta. Permanent failures record and move on. An operator failing never fails the run.
8. Termination and truncation
- Seen-set over
(NodeID, operator id, read-set digest)— convergent and cyclic paths never redo work. - Depth counts observation hops only, default 3; structural and variant
edges cost nothing (§1.1). It is a budget and termination concept only —
synchronization is by dispatch round (§6.1), not by depth. Under whole-edge
counting
bob@example.comspent its budget on decomposition and never reached an IP, silently gutting the composite-input case this design exists for. - Variants are terminal. A node reached by
VARIANT_OFis never handed back to a variant operator. -
Variant roots are seed-closure members. The seed closure is the seed plus everything reachable from it by structural edges.
ptremitsdomainnodes, which areNameable; without this rule every reverse-PTR domain on every variant’s IP becomes a new variant root — the exact explosion the capability flag was meant to prevent, arriving by an edge the terminal rule does not cover.Because
MANIFESTis an observation edge (§1.1), manifest-derived packages fall outside the closure.urlinsane typo github.com/acme/tooltherefore does not silently generate typo variants of several hundred dependencies; varying those is an expliciturlinsane typo package <name>or the dependency-confusion analysis, which needs no variants at all. - The engine enforces this, not the plugin, in two places. The scheduler
will not dispatch a variant operator against a node outside the closure, so
the work is never done; and the applier rejects any
VARIANT_OFedge whose source is outside it, so an operator that emits one anyway cannot smuggle it in. Dispatch-side gating is the optimization, applier-side rejection is the invariant. An invariant whose whole purpose is preventing combinatorial explosion must not be opt-in for plugin authors. - Budgets, per type and global.
- A round cap. Depth bounds observation hops, but a cyclic type-flow
(
ptr → domain → dns → ip → ptr) keeps generating rounds until depth bites, and an oscillating trigger pattern might not converge at all. The cap is the backstop the per-pair revision cap cannot provide, and hitting it is reported like any other truncation. - Depth and seed-closure membership are scheduler state, held in the side table alongside provenance and status. Both are derived from structure, and keeping them out of the addressed form stops them perturbing CIDs.
Belief pruning is truncation and is reported as such. A pruned candidate
was never admitted, so it has no NodeID and cannot carry a per-pair status.
Truncation of every kind — belief threshold, budget, round cap, round deadline
— is therefore recorded in a truncation ledger: a side table of
(type, key, depth, belief, reason) rows for candidates the engine declined to
admit. The report renders it exactly like any other section.
Truncation is not the same as an invariant rejection, and only the former
denies. A candidate refused because its key would not canonicalize, or because
an operator tried to root a variant on it from outside the closure, is recorded
as a rejection and nothing more. Putting those in the ledger would deny the
candidate forever — and a node refused as the source of one bad VARIANT_OF
edge may still be perfectly legitimate when another operator reaches it by an
observation edge. (Found while implementing phase 0: the two paths look alike
until you ask what a second operator finding the same candidate should see.)
The ledger is also a denylist, not merely a record. A declined candidate
that a later operator re-emits is refused again by the applier and the existing
row is kept. Without that, “pruning is irreversible” (§6.1) would be false in
the one case that matters — a second operator reaching the same candidate would
re-admit it, and whether a node exists would depend on how many operators
happened to find it. Without this it is
the quieter version of the same bug: the node never gets a dns result, so
--filter absent under-reports while the output still reads as complete.
Truncation is deterministic and reported. It happens at a barrier (§6.1),
where belief is final: candidates for the next round are sorted by
(-belief, depth, type, key) — the execution model’s belief (§10.2) first, so
a bound budget spends itself on the most promising frontier rather than
alphabetically — and the prefix is admitted. The ordering is total and fixed
under a pinned plan, so output stays byte-identical across runs even when
truncated. A truncated graph that reads as complete is a correctness bug, not a
cosmetic one.
9. Analysis
Analyzers run once, after expansion stops — for any reason, including Ctrl-C. There is exactly one lifetime.
type Analyzer interface {
Id() string
Exec(ctx context.Context, g *Graph) ([]Finding, error)
}
type Finding struct {
Kind string // "campaign", "dep-confusion", "high-risk-variant"
Severity Severity // info | low | medium | high | critical
Nodes []NodeID // admitted nodes this concerns
Declined []LedgerRef // ledger rows this concerns, §8
Summary string
Evidence []Provenance
}
A finding can concern a candidate that was never admitted — a pruned package is
still a dependency-confusion gap — which is why Declined references ledger
rows and not just NodeIDs.
Graph exposes nodes and edges plus read-only side-table access: status,
provenance, plugin-model scores (§10.6) and the truncation ledger — but not
the engine’s belief.
The asymmetry is deliberate and worth stating, because both are model output
sitting in the same side table. A plugin score is an observation about the
entity — how parked a page looks, how plausible a variant reads — and is
legitimate evidence for a finding. Engine belief is a scheduling artifact
about traversal order, and means nothing about the entity itself. Withholding belief is what makes §10.2’s “the execution model never contributes
to a reported number” true by construction rather than by convention; an
analyzer that could read it could launder it into a Severity, and no reviewer
would spot it. Belief remains visible under --verbose for diagnostics,
outside the analysis path. An analyzer that could not see
status could not tell “confirmed absent” from “never tried”, which is the
distinction §7 exists to preserve — and one that could not see the ledger would
report a truncated graph as complete.
The valuable analyses exist only because infrastructure is nodes:
- Campaign clustering — variants sharing an IP, nameserver or registrant.
- Scoring — resolves + MX + recent registration + low edit distance. This is rule-based and entirely independent of the execution model (§10.2), which never contributes to a reported number.
- Dependency confusion — an internal
packagewith noEXISTS_ONedge to a public registry.
Two derived terms the CLI exposes, defined here so nothing else has to invent them:
-
Existence is a rollup of §7’s statuses, in three values rather than two: a node is live if any observation operator returned
ok, absent if none did and at least one returnedempty, and unknown if every attempt ended infailed,timeoutorskipped.This replaces
registered/unregistered, which were domain-only and could not express the third case. Collapsing “confirmed absent” into “unregistered” discards exactly the distinction §7 exists to preserve — a variant nobody could resolve is not a variant proven free — and the trichotomy generalizes: a package absent from a registry isabsentin the same sense a domain returningNXDOMAINis. -
A node’s risk is the maximum
Severityamong findings referencing it. It is what--filter risk>Nand--fail-on SEVselect on, and the only user-facing score in the system.Severityis a named ordered enum —info | low | medium | high | critical— rather than a bare int, so--fail-on highmeans the same thing in every release; an integer scale would drift silently the first time a new level was inserted.
Findings land in the side table, so reporting is a graph query rather than a
parallel data path. Evidence carrying provenance is what lets a report answer
“why is this here?” instead of asserting a score.
10. The execution model
One HMM, trained offline and loaded as an artifact, whose sole job is to steer expansion. It is not a scoring model for the report.
- States — a small set of latent node statuses.
- Initial distribution —
P(state)for the seed, which has no parent. Barrier 0 (§6.1) assigns it; every other node’s belief derives from a parent. - Transitions —
P(state | parent state, Rel), conditioned on the relation, so sharing an IP transmits far more belief than sharing a TLD. - Emissions —
P(observed props | state)over props that operators in the plan actually produce: resolves, MX present, registrar, registration age. An emission over a prop nothing emits is dead weight in the model, the same way aWherenothing satisfies is a dead operator (§5).
10.1 Inference is the forward algorithm, and nothing else
The expansion tree gives every node exactly one parent — chosen deterministically at a barrier, §10.3 — so the path seed → node is a sequence, which is what an HMM is defined over. That makes this a literal HMM rather than a graph model borrowing the name.
Forward filtering is inherently incremental: a node’s belief is a pure function of its parent’s belief and its own props, recomputed at every barrier as those props arrive (§6.1). No backward pass, no Viterbi, no belief propagation, no whole-graph inference.
This shape is forced by the purpose. A model that runs after expansion can only annotate a graph that has already been built — by which point every network call it might have saved has already been made. To affect execution it has to be available during execution, which means forward-only.
10.2 What it is used for
Execution control, exclusively. All three are evaluated at a round barrier (§6.1) against belief recomputed from props as of that barrier, so an admitted node’s belief sharpens each round as observations arrive:
- Frontier ordering —
(-belief, depth, type, key)decides which candidates enter the next round when a budget binds. - Pruning — candidates below threshold never enter the next round.
- Operator gating —
Where: BeliefAbove(t), so nothing whois-es what the model considers uninteresting.
The first two rank candidates, which have no props yet, so their belief is the transition prior alone — parent belief pushed through the edge’s relation. Only gating reads the belief of an admitted node, which does carry observations. Both are “belief” and both are deterministic, but a candidate’s is necessarily less informed, and the ledger records it so a pruning decision can be second-guessed after the fact.
Explicitly not used for: report scores, findings, severities, or anything a user reads as a probability. This is what removes the calibration requirement (§10.5): a miscalibrated model that mis-ranks the frontier wastes network calls, whereas a miscalibrated model in a report makes a false accusation.
10.3 Determinism and convergent paths
A node reachable by several edges has several candidate parents. The tree
parent is the minimum by (depth, Rel, parent NodeID) over the complete
candidate set for the round that created the node — which is precisely why
parent selection happens at a barrier (§6.1) rather than at admission.
Min-so-far is not min-final: a parent revised after the fact revises belief
after the gating and pruning that belief already drove, and pruning cannot be
undone.
Belief is then recomputed at every barrier from (parent belief, the node's
props as of that barrier) rather than accumulated observation by observation,
so it depends neither on which operator returned first nor on which edge
arrived first — only on which round each edge belongs to, which is
deterministic. The parent is fixed; the belief sharpens. Under
a pinned plan the expansion tree, the beliefs, the gating decisions and the
admission order are all reproducible.
Later in-edges are not used as evidence: a domain independently reached from two suspicious variants gains nothing from the second. That is a real loss of signal, and recovering it would require belief propagation over the cyclic graph — not worth the cost for what is an execution heuristic.
10.4 Artifact — trainable, loadable, pinnable
A model is a dag-cbor block with a schema and a CID, like everything else here: states, the initial distribution, transition and emission tables in log space, smoothing parameters, and training provenance (corpus CIDs, algorithm, RNG seed, date).
--model NAME[@cid]; the default ships indatasets/.- The model CID enters the plan hash (§5). A pinned plan pins the model, so
a reproduced run reproduces the same traversal. Without this,
--planreproducibility fails the first time the model is retrained — the graph itself would differ, not merely its annotations. datasets train— a maintainer command, running Baum-Welch over recorded expansion traces. The RNG seed is recorded so training reproduces. It sits incmd/datasetsrather than besidetypobecause it builds reference data, asimportanddownloaddo, and the scanner only ever consumes what it makes (§12).- Traces are not a by-product:
typo --trace FILEwrites the(parent belief, relation, props, outcome)tuples training needs. Recording is opt-in because it persists observation data a normal scan discards, and a tool that silently accumulated a corpus of everything it had ever looked up would be making that choice on the user’s behalf.
Numerics: log space throughout, Dirichlet priors on transitions, an explicit OOV symbol for unseen emissions.
10.5 Limits, and why they are tolerable
Training needs recorded traces, and supervised training needs labels this project does not yet have. Unsupervised EM produces clusters that still require interpretation.
What makes that tolerable is the failure mode. Because the model only steers execution, a bad model degrades efficiency, not correctness — it explores in a worse order and wastes calls. A uniform model reduces exactly to today’s behaviour: breadth-first, unranked. The engine therefore ships and runs correctly before any model exists, and a model that turns out to be poor can be dropped without invalidating a single result.
10.6 Models inside plugins
The model above is the engine’s, and it is the only one that touches scheduling. Operators may carry models of their own — a variant operator scoring which typos a human would plausibly make, a collector classifying parked pages from response features. Those are separate in every sense that matters:
| Execution model (§10.1–§10.5) | Plugin models | |
|---|---|---|
| Owner | the engine | the operator |
| Affects | admission, pruning, gating | what the operator emits |
| Output | side-table belief, never data | graph structure, and side-table scores |
| Count | exactly one | any number |
| Corpus | recorded expansion traces | whatever that plugin needs |
internal/model is therefore a library, not the engine’s model: HMM
primitives (forward, Baum-Welch, log-space arithmetic, smoothing), the dag-cbor
artifact format, and loading. The engine instantiates one; plugins instantiate
their own; neither knows about the other’s states or alphabet.
Three integration rules, all forced by reproducibility:
- Plugin model CIDs enter the plan hash.
OpBindingcarriesModels []ModelRefbesideFlagsandConfig(§12.2). This is not optional: a variant operator whose name model changed emits different variants, so a plan pinning operator versions but letting their models float would fail to reproduce the graph itself — a much worse failure than the engine model’s, which only reorders traversal. - Plugin model CIDs enter that operator’s cache key (§6).
Version()covers the operator’s code; a model is data the code reads, so a retrained model with unchanged code must still invalidate. - Flags follow §12.2 namespacing.
--modelselects the engine’s;--<operator>.modelselects a plugin’s.--list modelsgroups by owner.
datasets train takes a target — train exec for the engine model,
train <operator> for a plugin’s — because their corpora, state spaces and
alphabets have nothing to do with each other.
One placement rule for what a plugin model produces. Structural decisions — which variants exist — go into the graph and are pinned by the plan. Derived judgment scores go to the side table, for the same reason execution belief does: as props they would make every node’s CID depend on a model version and break cross-run diffing the moment anything is retrained.
Unlike engine belief, plugin scores are visible to analyzers (§9). They describe the entity rather than the traversal, so a finding may cite one as evidence — which is also why they, not belief, are what a plugin model is for.
11. Output and determinism
Two modes, which are different things and are documented as such:
- Report — every format except ndjson. Emitted after analysis, with nodes
sorted by
(type, key), edges by(From, Rel, To), ledger rows by(type, key, reason), and props already in structural order (§1.3). It always carries the truncation ledger (§8) and, when expansion stopped early, a partial marker (§12.4). Byte-identical across runs, so “what changed since last week” is a diff rather than noise. - Event stream —
-o ndjsononly. Emitted as facts are found, explicitly unordered, for piping into other tools. It cannot be canonically sorted and does not claim to be.
--save is a separate sink from stdout, so it never conflicts with -o: each
sink gets the form implied by its own format. --save out.json writes a sorted
report to the file whatever stdout is doing, and --save out.ndjson writes the
stream form — the format determines the mode, not the sink.
11.1 What forces the partial marker
Partial is derived, not passed in. Any run-level truncation, and any non-empty truncation ledger, marks the report partial whatever the caller believed — including belief-gated declines, which are working exactly as configured and still leave the report incomplete. The caller never observes a budget bounding expansion mid-run, so trusting a flag here would let the most common truncation of all render as a complete scan.
12. CLI
urlinsane typo [<scope>] <target> [flags] scan
urlinsane report <target> [flags] render a saved scan of it
Two verbs, and the split is between scanning and not scanning. An earlier
draft of this section specified five — typo, explain, why, list,
train — on the grounds that only the first scans. That grouping is true and
useless: it puts four unlike things together because of what they are not.
explain, why and list are flags on typo. Each takes the same target, the
same scope positional and the same selection flags, because what each describes
is the scan: which operators the plan compiled, what produced a result, which
algorithms are registered for the target’s type. A verb re-accepting that entire
surface is typo under another name with a second parser to keep in step, and
the drift shows up as a plan explain prints that typo would not have run.
The objection that a suppressing flag gives typo several exit paths before it
has parsed a target is answered by parsing first: target and scope are resolved
for every invocation, and only then does a suppressing flag decide whether to
run the plan or print it. That ordering is what makes --explain describe the
run it suppressed.
report is the one that genuinely differs, because it does not scan and does
not describe a scan it is about to run — it renders one that already happened.
It takes a target, but rejects everything that shapes a scan, since --depth
and --algorithm cannot change what a finished graph contains. A flag on typo
whose presence invalidated the rest of typo’s surface would be worse than a
verb. It reads from the content-addressed store (§12.7): the target names the
scan, --at CID names an exact one, and the replay re-encodes and CID-checks
every node and edge, so what is rendered is byte-identical to what was saved.
That is also why filters live there — --filter was always a display filter
over built rows, so re-filtering a ten-minute scan costs nothing.
train is not a scanner concern at all. It runs Baum-Welch over stored graphs
(§10.4), which is maintainer work on reference data, and it belongs beside the
other reference-data commands in cmd/datasets.
The scope positional is a node type, optional and narrowing, comma-separated for several:
urlinsane typo bob@example.com # broad: every nameable in the seed closure
urlinsane typo username bob@example.com # narrow: vary bob only
urlinsane typo domain bob@example.com # narrow: vary example.com only
urlinsane typo username,domain bob@example.com
urlinsane typo package npm:lodash
urlinsane typo repo github.com/acme/tool
Omitted, scope is every Nameable node in the seed closure — including the
composite node itself, so a bare email seed also yields whole-email variants.
Supplied, it filters that set. The target parses identically either way; scope
never changes how the string is read, only what gets varied.
Scope is an applier invariant, not a dispatch hint. The scheduler skips
variant operators on out-of-scope nodes as an optimization, and the applier
rejects any VARIANT_OF edge whose source type the scope excludes — the same
two-layer arrangement the seed-closure rule uses, and for the same reason: a
dispatch gate is something an operator can be written around. A scope that was
only validated, hashed into the plan and printed by explain would leave
typo username bob@example.com silently identical to the unscoped run, which is
the one thing the positional exists to prevent. The rejection is a distinct kind
(outside-scope, not outside-seed-closure) and writes no ledger row: an
out-of-scope root is a legitimate variant root the user chose not to expand, and
denying the candidate would poison a later run with a wider scope.
There is no --scope flag — the positional is the only spelling. This retires
--type (cmd/urlinsane/typo.go:105) and entity.Classify as the seeding
path (internal/config/config.go:178), which today reads bob@example.com as
user purely because it contains an @. Parsing rule: with two positionals
the first must be registered Nameable types; with one, it is the target.
12.1 Progressive disclosure
typo currently carries ~25 flags in one undifferentiated block, with
Category values nothing uses. --help shows what most runs need;
--help-all shows everything.
-d, --depth int observation hops from the seed (default 3)
-f, --filter strings live, absent, unknown, risk>N (all defined in §9)
-a, --algorithm ids restrict variant generation (§12.10)
-l, --language ids languages the language-driven algorithms run over
-k, --keyboard ids keyboards the keyboard-driven algorithms run over
-c, --collect ids restrict observation; ^id excludes
-o, --output string table | json | ndjson | csv | dot (default table)
--save PATH write the report to PATH; format from extension
--save-graph persist the graph to the store; prints its root CID
--fail-on SEV exit 2 if any finding reaches SEV
--quiet silence stderr; the exit code is the whole answer
--tui interactive view of the same scan (§12.8)
--plan FILE write the plan, or pin an existing one
--model NAME[@cid] execution model; --<op>.model for plugin models (§10.6)
-v, --verbose
Timeouts, delays, TTL, budgets, workers and user-agent policy move behind
--help-all, unchanged in behaviour. Operator-owned flags such as
--nameservers and --registry are governed by §12.2 instead: they appear in
--help when their operator is in the plan.
Four cleanups: --options/--ids/--opts — three aliases for one hidden
flag — collapse into the single --list TOPIC; --registered/--unregistered, two booleans
settable to contradict each other, become --filter live and --filter absent
— which also gains the unknown case they could not express; --format,
--file and --dir collapse into -o plus --save; and --explain becomes
--explain, which suppresses the run it describes.
--filter selects rows in the report, never work in the scan. It is applied
after expansion, over built rows. Narrowing the scan is what --depth,
--algorithm and the scope positional are for. Two selection languages
competing over the same run would be worse than the confusion of one, so
--filter gains no scan-side twin — instead §12.6 requires a filtered-empty
report to say what it hid.
12.2 Flags are scoped to the plan
--nameservers is meaningless for a package scan; --registry is meaningless
for a domain scan. Today all ~25 flags apply to every run, so the help text is
a union in which most entries are inert for whatever the user is actually
doing.
Flags are therefore declared by the operator that consumes them, and the compiled plan (§5) decides which are relevant:
type OpBinding struct {
Id string
Version int
Trigger Trigger
Resource string
Flags []Flag // declared by the operator, namespaced by its id
Config Values // parsed values
Models []ModelRef // this operator's models, §10.6
}
This is the single definition; §5 and §10.6 refer to it rather than restating it.
Three rules:
- Every flag is always registered; only help is filtered. The plan is
compiled from flags, so flags cannot depend on the plan — a two-pass parse
would be circular. Registering everything and filtering the help text
sidesteps that entirely, because the real problem is discoverability, not
parsing.
urlinsane typo package --helpshows registry flags;typo domain --helpshows resolver flags. With no scope the plan is still the seed closure, which for a bare domain target is narrow anyway; only a composite seed like an email or repo yields several scopes, and there help groups by scope instead of filtering. - Namespaced, with a bare form when unambiguous.
--npm.registryalways works; bare--registryworks when exactly one operator in the plan declares it. A collision makes only the bare form an error, and at registration time. - An inapplicable flag is an error, not a no-op.
typo domain x.com --registry npmfails with “--registryis declared by operatornpm, which is not in this plan (scope: domain)”. A silently inert flag is worse than a rejected one — the user believes they configured something.
The mechanism already exists: urfave/cli carries Category on every flag, and
§12.1 notes nothing currently uses it. Category becomes the owning operator,
and the help template filters on the active plan. No new flag library, no
custom parser.
This also tightens two things elsewhere in the design:
OpBinding.Configis part of the plan, so--planpins flag values, not just operator selection. A pinned plan reproduces configuration.resourceConfigDigestin the cache key (§6) is computed fromConfig, so changing--nameserversinvalidates thednsoperator’s cached results and nothing else’s. Scoped ownership is what makes that precise rather than a blunt global cache flush.
Help now has three tiers: common flags, plan-relevant flags (--help), and
everything (--help-all).
12.3 Progress, and where output goes
One rule decides every question about where output goes:
stderr is for the human, stdout is for the machine.
Progress and diagnostics go to stderr and are suppressed when stdout is not a
TTY. The report goes to stdout. So
urlinsane typo acme.com -o json > out.json shows a live scan on the terminal
and writes clean JSON to the file, and no flag is needed to say so — the
common case needs no configuration at all.
The current bar is progressbar.DefaultSilent(1000) — hardcoded total, never
displayed. The default view is a one-shot run: a bar while it works, the report
when it finishes.
acme.com · domain · 27 algorithms · 9 observers
████████████████████░░░░░░░░░░ 68% 1,284 nodes round 4/64 0:08
dns 240/s whois 2/s geo 11/s
Round and depth answer “how much is left” in a way a spinner cannot, because
expansion is round-based. The per-resource rates are the part worth keeping from
a richer display: whois 2/s beside dns 240/s explains a stalled scan without
the user having to know that whois is rate-limited. The bar is replaced by the
report when the scan ends — it is progress, not output.
--tui opens an interactive view of the same scan (§12.8). It is opt-in:
the default path is a one-shot command, so nothing about existing scripts, CI or
muscle memory changes, and the interactive view can land later without a
migration. --tui with a non-TTY stdout is an error, not a silent downgrade,
under §12.2’s rule that an inapplicable flag is an error rather than a no-op.
Findings are not shown during the scan: analysis is a distinct final phase,
rendered as analyzing… when expansion stops.
-o ndjson emits a record as soon as that record is done — when every
operator bound to a node’s type has returned, failed or been skipped. -o json
is one document and can only be written at the end; ndjson is one object per
node and there is no reason to hold them. A node is emitted once, complete: a
record that appeared early and was later amended would force every consumer to
keep state and de-duplicate. ndjson is already the one format that declines to
promise an ordering (§11), so completion order costs nothing — and it is more
useful than sorted order, being the order the answers actually arrive in.
12.4 Interruption and exit codes
Ctrl-C stops expansion at the end of the current round, not mid-round: the barrier still runs, so parents, belief and the truncation ledger are finalized rather than left half-computed. Analyzers then run over what exists and the report is marked partial. A second Ctrl-C aborts immediately without a report, for when a round is stuck behind a slow resource. The first Ctrl-C says which of the two just happened, because otherwise an impatient second press looks like the first did nothing:
^C
stopping at the end of round 4 — press ^C again to abort now
A ten-minute scan has produced something worth keeping, and round-by-round
expansion guarantees it is a coherent prefix rather than an arbitrary
cross-section. PARTIAL appears in the header of every format including
json, so a truncated scan is never mistaken downstream for a complete one.
Exit 0 clean, 1 execution error, 2 a finding at or above --fail-on.
That is what makes the tool a CI gate for dependency confusion; today the only
way to react to results is to parse stdout.
--quiet silences stderr entirely, making the exit code the whole answer:
urlinsane typo --quiet --fail-on high npm:acme-internal # $? is the result
It does not suppress first-run setup (§12.6): a silent thirty-second first run is indistinguishable from a hang.
NO_COLOR and non-TTY stdout are respected.
12.5 Existence is three-state, and always shown
live, absent and unknown are the most valuable distinction this tool
draws and the easiest to lose. Absent is “we asked, it is not there”;
unknown is “we could not tell”. Collapsing them turns a broken network into a
clean bill of health.
So the split is unconditional, not a flag: a glyph in every row, and the counts in the footer of every run.
● live 42 ○ absent 1,203 ? unknown 39
● green, ○ dim, ? amber. The legend costs one line and prevents the
single most likely misreading of the output.
This is also why --filter must explain an empty result (§12.6) rather than
printing nothing: -f live over a scan where every lookup timed out yields the
same empty table as a scan that found nothing, and those are opposite outcomes.
12.6 Say what was not done
Three kinds of gap are currently silent, and each makes a degraded run indistinguishable from a complete one.
Omitted operators. An operator whose dependency is missing is left out of the plan rather than included and failed (§4), which is right — but it must be reported, or a scan with no geolocation looks like a scan of a target with no geolocation.
Declined candidates. The truncation ledger (§8) already records every candidate refused by a budget, a depth bound or a frontier cap. It is written and never surfaced.
First-run setup. The embedded datasets are extracted on first run. Silently, today — and when extraction fails, the operators that needed them simply vanish.
⚠ 2 observers were not in this plan
geo no maxmind database (not shipped; see scripts/mmdb.sh)
npm no registry dataset (datasets import not run)
⚠ 412 candidates were declined
budget 380 node cap reached at round 6
depth 32 beyond --depth 3
An empty report explains its own emptiness in the same spirit:
no rows matched --filter live
1,284 hidden: 1,203 absent · 39 unknown · 42 live but below --depth 2
⚠ 39 unknown — every dns attempt timed out. the network, not the result.
The last line is the point. It separates a clean negative from a broken run, which is the difference between “this brand is unsquatted” and “our resolver is down”.
12.7 Provenance: --why
The graph records how every node was reached — which operator produced it, under which relation, with which props (§1.3). An investigator’s first question about a suspicious hit is “how did you get here”, and there is no way to ask it.
It is a flag rather than a verb (§12) because the answer is about a particular scan: the same target, scope and selection that produced the hit are what locate it in the stored graph.
urlinsane typo acme.com --save-graph # earlier: scanned and kept
urlinsane typo acme.com --why acmе.com # now: ask about one result
acmе.com · domain · live · critical
reached by
acme.com ──VARIANT_OF── acmе.com
algorithm hr homoglyph replacement
distance 1
change e → е (U+0065 → U+0435, cyrillic small letter ie)
observed
dns-a ✓ 91.195.240.1 0.31s
dns-ns ✓ ns1.above.com, ns2.above.com 0.28s
whois ✓ created 2026-07-30, registrar Namecheap
ptr ✗ timeout after 10s
findings
critical homoglyph in a registrable label
high shares nameservers with 4 other variants of this seed
--why reads a stored graph rather than re-scanning: re-observing one name
repeats network work and can disagree with the run being explained, which is
the one thing an explanation must not do.
That requires the scan to have been saved, and saving is opt-in
(--save-graph) for the reason §10.4 gives about traces: a tool that
silently accumulated a corpus of everything it had ever looked up would be
making that choice on the user’s behalf. --why without a saved graph is an
error that says which flag would have produced one — not a silent re-scan.
--save-graph takes no path. The store is content-addressed and shared, so a
scan’s root CID is its name and the store is where report <target>
and --resume both look; writing one run to a loose run.dag would put it
somewhere neither could find it. --store DIR selects a different store, and
--save-graph prints the root CID it wrote.
The store already exists (internal/store), carries no timestamp or run
id in its root, and is therefore content-addressed in a way that makes two
identical scans produce the same CID — so --why, cross-run diffing and --resume
are all the same mechanism seen from different angles.
12.8 The interactive view
--tui opens the same scan as a navigable view, borrowing from two programs
that already solved halves of this problem: htop for live density, nvim
for modal navigation.
┌──────────────────────────────────────────────────────────────────────────────┐
│ dns [|||||||||||||||||||||| ] 240/s 1284 round 4/64 │
│ whois [||| ] 2/s 37 depth 2/3 │
│ geo [||||||||| ] 11/s 118 nodes 1,284 │
│ ────────────────────────────────────────── live 42 absent 1203 ? 39 │
├──────────────────────────────────────────────────────────────────────────────┤
│ SEV NAME EX ALG D DNS NS MX WHO GEO PTR PROGRESS │
│ ⣿ acmе.com ● hr 1 ✓ ✓ ✓ ✓ ✓ ⠋ [|||||||| ] 5/6 │
│ ⣿ acme-support.com ● cb 2 ✓ ✓ ⠋ · · · [||| ] 2/6 │
│ ⣶ acnne.com ● cr 1 ✓ ✓ ✓ ✓ ✓ ✓ [||||||||||] 6/6 │
│ acme.net ○ tld 1 ✓ − − − − − [||||||||||] 1/1 │
│ acmee.com ? cr 1 ✗ ⠋ · · · · [ ] 0/6 │
├──────────────────────────────────────────────────────────────────────────────┤
│ NORMAL results sort:sev filter:— acme.com · domain 0:08 │
│ : │
└──────────────────────────────────────────────────────────────────────────────┘
Progress is per target, and per observer within it. The default bar (§12.3)
reports one number for the whole run, which averages away the only thing worth
knowing: which target is stuck, and on what. Every node is observed by several
operators independently, so the row is where progress belongs and the column is
where a stalled resource shows up — WHO reading ⠋ down several rows while
DNS is ✓ everywhere identifies the bottleneck without the user knowing which
resources are rate-limited. The htop-style meters above say the same thing in
aggregate.
✓ |
returned ok |
✗ |
failed — SERVFAIL, refused, error |
⠋ |
in flight |
· |
not yet attempted |
− |
not applicable: the node is absent, so nothing downstream runs |
− is load-bearing. acme.net is absent, so ns/mx/whois/geo/ptr never run — it
is 1/1 with a full gauge, not 1/6 and stalled. Without it most rows in a
typical scan would look permanently unfinished, because most variants are
absent. It also makes the three-state existence (§12.5) self-evident per row:
acmee.com shows ✗ then a retry in flight, which is why it is ? and not
○.
Rows are selected with the standard keys, and h/l work the pane. l
opens the detail pane on the right for the selected row and moves focus into it;
h closes it and returns. The direction is the meaning — l moves right, into
the detail; h moves left, back to the list — so the pane needs no dedicated
binding and no toggle to remember.
| Key | |
|---|---|
j k · ↓ ↑ |
select row |
gg G |
first, last |
C-d C-u |
half page |
C-f C-b |
page |
l → |
open the detail pane, focus it |
h ← |
close the detail pane |
/ n N |
search, next, previous |
gd |
provenance (§12.7) |
C-o C-i |
jump back, forward |
┌─ results ──────────────────────────────┬─ acmе.com ───────────────────┐
│ SEV NAME EX ALG D PROGRESS │ domain · live · critical │
│ ⣿ acmе.com ● hr 1 [||||| ] │ │
│ ⣿ acme-support.com ● cb 2 [|| ] │ dns 91.195.240.1 │
│ ⣶ acnne.com ● cr 1 [||||||] │ ns ns1.above.com +4 ▸ │
│ acme.net ○ tld 1 [||||||] │ whois 2026-07-30 Namecheap │
│ acmee.com ? cr 1 [ ] │ geo DE · Frankfurt │
│ │ ptr ✗ timeout after 10s │
│ │ │
│ │ VARIANT_OF acme.com │
│ │ hr · d1 · e→е U+0435 │
└────────────────────────────────────────┴──────────────────────────────┘
The pane is closed by default, because at eighty columns it costs half the width
of the table it is describing. l is cheap enough that opening it per row is
not a burden, and h gets the width back.
The pane is itself a row list, navigated and highlighted the same way. Its
fields are rows: j/k moves through them with the selection highlighted, and
l descends again — from the ns row into the four other variants sharing that
nameserver, from a registrant into everything they registered. h pops back.
Panes stack, so navigation is a path through the graph rather than a toggle
between two fixed views.
That is what makes §12.7’s provenance and the traversal below the same gesture instead of two features: descending from a row is following an edge.
12.9 One component, composed
The view is deliberately not a screen layout. Everything visible is a list of rows, and the differences between the results table, the detail pane, the ledger, the plan and the findings are the rows they carry — not the code that draws them.
| Component | Responsibility |
|---|---|
list |
rows, columns, selection, highlight, scroll, sort, filter, fold |
meters |
the per-resource gauges of the top panel |
status |
mode, active sort and filter, target, elapsed |
cmdline |
: and /, with completion over the same registries list uses |
overlay |
help (g?), confirmations, errors |
stack |
the pane stack: l pushes, h pops |
Three properties follow, and they are the reason for the arrangement rather than consequences of it:
- A new pane is data, not code. The ledger, the plan and the findings are
lists over different row sets. Adding one — say, a buffer of every node sharing a nameserver — is a query and a column spec. - Behaviour is written once. Sorting, filtering, search, highlight and
fold live in
list, so they work identically in the detail pane and the results table. A binding that works in one works in all of them, which is what makes the keymap learnable. - The engine is not consulted about presentation. A
listis fed rows and knows nothing aboutgraph, so the view can be tested without a scan and the engine carries no rendering concerns. This is the §13 layering applied to the interface: the same reason operators are pure functions of a view.
The row source is an interface, not a type: anything that can produce rows and
say what a row descends into can be a pane. That is the seam the TUI is built
on, and it is why --tui can be added without the engine knowing it exists.
The graph is navigable. The engine produces variants, the addresses they resolve to, the nameservers those share and the registrants behind those. A report flattens that to a list and discards the edges — which is the part an investigation needs: this variant is suspicious → what else shares its nameserver → who registered those. That is a traversal, and the modal idioms already exist for it:
| Idiom | Here |
|---|---|
| buffers | projections of one graph: results, findings, ledger, plan |
| splits | list ▏detail |
jumplist C-o / C-i |
move along graph edges, and back |
gd |
go to provenance — the edge that produced this node (§12.7) |
: |
the flags, available mid-run: :filter live, :depth 4 |
/ |
fuzzy find over names |
| folds | collapse variants by algorithm, or unfold one row to named columns |
htop’s F-key hints remain as an affordance for discovery, but every one is also
a : command, so the two idioms do not compete for the same action. g?
overlays the keymap for the current buffer, so nothing has to be memorised.
The observer columns collapse as the terminal narrows — named, then glyphs, then
a bare ratio — and zo unfolds the selected row to the named form without
resizing anything else:
wide (>140) DNS ✓ NS ✓ MX ✓ WHOIS ✓ GEO ✓ PTR ⠋ [|||||||| ] 5/6
default ✓ ✓ ✓ ✓ ✓ ⠋ [|||||||| ] 5/6
narrow (<100) ✓✓✓✓✓⠋ 5/6
:ls lists the buffers — results, findings, ledger, plan, log — each a
projection of the same graph rather than a separate fetch.
The scan keeps running while the graph is browsed; rounds land in the background and the list grows under the cursor.
:report runs analysis without waiting for the scan. Analysis is a distinct
final phase (§9); :report runs it over whatever exists at that moment and opens
the result as a buffer, marked PARTIAL under the same rule as an interrupted
run (§12.4). A scan that has found something worth acting on at round 6 should
not have to reach round 64 before it can be read, and the round barrier (§6.1)
guarantees the graph it reads is a coherent prefix rather than a half-applied
delta. :export then writes that report in any of the formats of §11.
:depth 4 mid-run is the argument for the whole view. In a one-shot command,
discovering the bound was too low means re-running from scratch and paying for
every lookup again. Here it widens the frontier and the existing graph stands.
The truncation ledger (§8) and the omitted operators (§12.6) are buffers rather
than warnings that scroll past, each carrying the command that lifts the limit:
depth 32 — beyond depth 3 — :depth 4 to admit these.
12.10 Selecting what runs, and what it runs over
Four flags, and the distinction between them is the point:
| Flag | Selects | Default |
|---|---|---|
-a, --algorithm |
which variant operators generate | all |
-l, --language |
which languages those algorithms run over | all registered |
-k, --keyboard |
which keyboard layouts they run over | all registered |
-c, --collect |
which observation operators run | all in the plan |
-a picks the algorithm; -l and -k pick the data it runs over. -a hr
selects homoglyph replacement; -l ru decides whose homoglyphs. They compose
rather than compete, which is why the old --algorithms alone could not express
what --languages did, and why collapsing them into one flag would lose a
distinction the engine already makes: variant.Options carries Languages and
Keyboards as parameters to the operators, while Algorithms selects the
operators themselves.
The defaults are “everything registered”, and deliberately so. A tool whose
failure mode is missing a squatted name should not narrow its own recall by
default; a scan that generates too many candidates is visibly expensive, while a
scan that generates too few is silently wrong. Locale-derived defaults were
considered and rejected for the same reason — a homoglyph attack on a .com is
cross-script by definition, so inferring ru from a .ru TLD would suppress
exactly the case the algorithm exists to catch.
Exclusion is first-class, because it is the common case. A ^ prefix removes
an id from the set that would otherwise run:
urlinsane typo acme.com -c ^whois # everything except whois
urlinsane typo acme.com -c dns,ptr # only these
urlinsane typo acme.com -a ^cb,^afx # drop the noisy generators
Dropping one slow or rate-limited observer is far more common than enumerating
the ones to keep, and ^ spells that without a second --no-collect flag whose
interaction with the positive form would have to be defined. Mixing forms in one
flag is an error rather than a precedence rule to memorise.
^ rather than a bare - because -whois is indistinguishable from a flag to
any argument parser, and rather than ! or ~ because both are shell
metacharacters that would need quoting. In the interactive view the same syntax
works on :algo and :collect, where no parser is in the way.
An unknown id is an error, not an empty selection: variant.Select already
reports unknown algorithms rather than silently matching nothing, and -c and
-l follow it. A typo that quietly scanned with no observers would be the worst
possible outcome — a clean report produced by doing nothing.
This requires one engine change: observe.Options currently carries only
dependencies (Resolver, Whois, Geo, Prober) and observe.New builds the
full set, so there is nowhere to express a selection. It gains an Ids []string
alongside them, applied where variant.Select applies its own.
13. Testing and observability
The purity constraints buy four clean layers:
- Operators — pure functions of a view; a literal view in, an expected delta out. No engine, no network. An operator carrying a model (§10.6) pins it by CID in the test, since the model is an input like any other.
- Scheduler — synthetic operators exercise round barriers, timer-driven retry and its deadline, parent finalization under convergent in-edges, pattern re-dispatch, the revision cap, admission control, budgets, cyclic data and failure policy, without a single real plugin. Two invariants deserve dedicated tests because violating them is silent: a declined candidate re-emitted by a second operator stays declined (§8), and a pair gated off at one barrier still runs at a later one once belief rises (§7). This is the subtlest machinery in the design and the layer most worth over-testing.
- End-to-end — golden graphs from recorded fixtures, compared after canonical sorting.
- Reproducibility — the same pinned plan replayed against the same fixtures must produce byte-identical output, including the truncation ledger, and must do so with a cold cache and a warm one. Nearly every constraint in this document exists to make that test pass; without it they are unenforced conventions.
Observability: --explain for the plan, per-operator counts and timings under
--verbose, truncation always surfaced in the report.
14. Package layout
internal/
graph/
model.go Node, Edge, NodeID, EdgeID, edge classes
registry.go type + relation registration, capabilities, canonicalization
props.go ordered field list, kinds, merge policy, schema evolution
delta.go Delta, apply, admission, canonicalization rejection
view.go pattern-scoped view
trigger.go Selector, Condition, match index
schedule.go delta/timer/barrier events, rounds, read-set digests, admission
limit.go resource classes and transport-level host limiting
cache.go (operator, version, models, node, read-set, resource-config) keys
plan.go compilation, pruning, pinning, --explain rendering
side.go provenance, status, belief, findings, truncation ledger
dag/ SCC condensation + topo sort of the condensation, for plan layout
model/ library — used by the engine and by plugins alike (§10.6)
hmm.go forward step, Baum-Welch, log-space arithmetic
codec.go dag-cbor model blocks, CID identity, training provenance
smooth.go Dirichlet priors, OOV emissions
trace.go expansion-trace recording for `train exec` (§10.4)
store/ content-addressed scan storage, replay, diffing
plugins/ everything that acts on the graph, one directory per plugin
plugins.go the three registries: operators, analyzers, algorithms
decompose/ domain, email, pkg, repo + all
variant/ the 27 algorithms, one each + all
observe/ dns, ptr, whois, idn, geo, + all
pkg, usr, repo + observetest
analyze/ campaign, scoring, depconfusion + all
report/ table, json, ndjson, csv, dot + all
Every plugin is one directory, and every family is a library plus its
plugins. The family package holds what its plugins share — observe owns
Options, the per-call timeout and the schema vocabulary; variant owns Spec,
the Generate signature and the keyboard and language combinators — and each
plugin imports it. Composition lives in <family>/all, which imports the
plugins; without that separation a plugin importing its family would import its
siblings through it, and the cycle is not hypothetical.
An earlier arrangement grouped by target instead — network, service, social, repo — on the reasoning that an npm plugin contributes both an operator and an algorithm and belongs in one place. That holds for a third-party plugin and not for the shipped ones: splitting dns from whois from geo meant either duplicating the shared vocabulary or exporting a package’s internals to itself. Cohesion is real, and the directory tree should not fight it.
15. Migration
Hard cutover. db.Domain is retired as the runtime payload and every plugin
moves to Operator in one pass; there is no adapter shim. The branch is not
shippable until the cutover completes — the accepted cost of not maintaining
two engines.
Retired: internal/engine/processor.go with its Stage/stageFunc;
db.Domain as runtime payload with its embedded record slices;
internal.Algorithm, internal.Collector, internal.Analyzer; entity.Type
as a closed enum and entity.Classify as the seeding path; --type,
--registered and --unregistered (replaced by --filter live|absent|unknown,
§9), --format, --file, --dir, --options, and the global
--delay/--random throttle (replaced by per-resource buckets, §6.3).
Kept: internal/engine/dag, moved to internal/graph/dag and reworked for SCC condensation; the
store’s content-addressing approach.
Demoted rather than dropped: the language and keyboard plugin families. Both
were plugin interfaces wrapping what is only data — a language is a directory
of curated .lst files, a keyboard is a layout — and neither had behaviour a
plugin needed to supply. Languages are now rows the dataset database answers
(internal/dataset), keyboards are layouts compiled in from pkg/kb, and a new
language is added by adding a dataset directory rather than by writing Go. What
they feed, the variant operators, is unchanged.
Phases
| # | Scope | Done when |
|---|---|---|
| 0 | graph core: model, registry, props, identity, delta, applier invariants |
round-trip and canonicalization tests pass; CID stable across runs; applier rejects out-of-closure VARIANT_OF |
| 1 | Scheduler: triggers, match index, rounds and barriers, timers, re-dispatch, admission, budgets, status, cache | synthetic operators exercise cycles, budgets, timer retry, barrier parent finalization, revision cap |
| 2 | Plan: compilation, pruning, pinning, --explain |
pinned plan reproduces a run byte-identically with a cold cache and a warm one (§13) |
| 3 | Decomposers and variant operators | every current algorithm ported; closure rule (enforced since phase 0) exercised by real variants |
| 4 | Observation operators | every current collector ported; rate classes assigned |
| 5 | Analyzers, report, CLI | feature parity with today’s output, plus findings and the truncation ledger |
| 6 | Execution model + train: trace recording, forward filtering, Baum-Welch, model blocks, belief gating |
beliefs reproduce under a pinned plan; gating measurably cuts network calls |
| 7 | Store rework to nodes and edges; --resume |
cross-run diff works; resume continues a partial graph |
--resume depends on phase 7 and is not available before it — the store today
models an entity union with embedded records and cannot rehydrate a partial
graph.
Phase 6 is separable from the engine: everything before it runs with a uniform model, which reduces to breadth-first unranked expansion (§10.5), so the engine ships without waiting on model work.
Plugin models (§10.6) are not a phase. They arrive with whichever operator
wants one, in phase 3 or 4, and pull internal/model forward to that point —
the library lands when the first model does, engine or plugin, whichever comes
first.
16. Open
Everything previously listed here has been decided and moved into the body. What remains genuinely undecided:
- Per-type budget defaults (global default and depth are set: unbounded and 3).
- Per-resource retry attempt counts, round deadlines (§6.2) and the round cap (§8) — the mechanisms are fixed, the numbers are not.
- Whether
--filtergrows into an expression language or stays a keyword set. - In the interactive view (§12.8): whether a
rescan-one-node binding should exist at all, given it would put two
observations of the same node from different moments into one content-addressed
graph, which §1.2 says must not blur; whether
:qmid-scan cancels at the round barrier and still writes a partial report or discards; and which TUI library, since eitherbubbleteaortviewis a new dependency. - Whether a
certtype is worth adding — it was removed from the registry because no operator emits one, so plan compilation would report it dead (§5). It returns when a TLS operator does. - Training corpus for phase 6: whether to record expansion traces from real runs, bootstrap from public blocklists, or hand-label a seed set. §10.5 makes this non-blocking — a uniform model ships — but it decides when gating actually starts paying for itself.
- State cardinality — how many latent statuses earn their keep over a binary
split, given the model only has to rank a frontier. Still open, but no longer
constrained by the plumbing:
BeliefModel.Stepcarries an opaquegraph.Statebetween parent and child, so a model of any width propagates its own posterior. An earlier signature passed the parent’s scalar, which forced a model to reconstruct a distribution from one number — exact at two states, and at three or more the maximum-entropy guess rather than the posterior the parent had. That silently answered this question as “two” as a side effect of a type signature, and a wider model would have produced plausible, wrong numbers.