Detection engine
The architectural view of the two engines described functionally in Detection layers.
Publication note. This page describes structure and interfaces. The tuned constants — score weights, verdict thresholds, timing windows, keyword lexicon contents and the fence lists — are intentionally not published. They are tuning data for a countermeasure, and publishing them would be publishing the way around it.
Two engines, one boundary#
| Domain matcher | Risk engine | |
|---|---|---|
| Question | Is this hostname on a list? | Does this page behave like a gambling site? |
| Input | A hostname | Visible text + a structural fingerprint + the domain verdict |
| Output | Binary decision + the matched rule | A score, a graded verdict, and per-source reasons |
| State | One immutable rule-set snapshot | Stateless |
| Used by | DNS loop, app detection, browser URL bar, content watch | Content watch only |
Both are pure Kotlin with no Android imports.
The domain matcher#
class DomainMatcher(ruleSet: BlocklistRuleSet = BlocklistRuleSet.EMPTY) {
fun updateRules(newRules: BlocklistRuleSet) // atomic volatile write
fun currentVersion(): Long
fun evaluate(rawHost: String): BlockDecision
}Three properties make it usable on the DNS hot path:
- Atomic rule swap. Replacing the rule set is a single volatile write of an immutable snapshot. An update never locks the loop and never requires restarting the tunnel.
- Suffix probing, not iteration. Exact and subdomain matching is a hash-set probe over the hostname and each of its parent domains —
O(labels), notO(rules). Only the small wildcard and keyword layers iterate. - Snapshot capture. Each evaluation captures the snapshot once, so a mid-evaluation update cannot produce an inconsistent decision.
evaluate returns a BlockDecision carrying the match type and the specific rule that fired. Nothing in Haven blocks without being able to say why.
Normalisation#
Before matching, a hostname is normalised — lowercased, trailing dot removed, homoglyphs folded — and a second, further de-obfuscated form is derived for the fuzzy layer. Evasion that relies on look-alike characters therefore collapses onto the real term before any rule is consulted.
Rule kinds#
The rule set carries six synced kinds (domain, wildcard, strong keyword, weak keyword, encrypted-DNS resolver, allowlist) plus two local-only kinds for the user's own filters.
The distinction is enforced at the sync boundary: an incoming delta is mapped through an explicit allow-list of kinds, so a remote update can never write or delete the user's own rules, and an unknown kind from a future backend is skipped rather than crashing an older app.
App detection reuses the matcher#
Android package names are reverse-DNS names, so com.example.brand becomes brand.example.com and is judged by the full stack. The display label is folded into a pseudo-hostname and judged the same way. A curated operator-namespace list is the third belt.
Three consequences fall out of the reuse rather than being implemented separately:
- The synced blocklist doubles as an app blocklist — no app update needed.
- Evasion in app names collapses exactly as it does for domains.
- The user's allowlist naturally exonerates the matching app.
Verdicts are memoised per package tagged with the rule-set version they were computed against, so a blocklist update naturally re-judges every package, and the steady state costs one map probe. Only a package's first sighting reads its label.
The risk engine#
class RiskEngine(private val sources: List<RiskSignalSource>) {
fun assess(
context: RiskContext,
thresholds: RiskThresholds,
escalatedFromWarn: Boolean,
disabledSourceIds: Set<String>,
): RiskAssessment
}The engine owns no detection logic of its own. It runs each enabled source over the context, sums the contributions, applies the anchor rule, and maps the total onto a verdict. Adding a detection layer is a registration change in one dependency-injection module and nothing else.
Sources return a contribution carrying points, human-readable reasons, and an anchor flag marking a signal as gambling-specific rather than merely corroborating. The engine will not return a block without at least one anchor — see the anchor rule.
The engine is stateless. The one piece of state the behaviour needs — warn escalation across repeat visits — deliberately lives outside it, keyed per host in the service, so the scoring itself stays a clean function of its inputs.
Thresholds and per-source toggles are read from a settings store, so the product can be tuned without a code change.
One traversal per scan#
Walking a live accessibility tree is the expensive part, so it happens exactly once per scan. That single pass produces a plain, Android-free node snapshot; the visible text and the structural fingerprint are both derived from that snapshot by a pure extractor. The traversal is hard-bounded by a shared node budget so one pass stays cheap even on an endless promotional page.
The browser's address-bar node is kept in the tree but stripped of its text, so the structural fingerprint is unchanged while the address itself cannot become evidence.
Diagnostic output#
Each assessment can render a single dense log line: per-source scores, the total, the verdict, an anchored marker, and the reasons. It is structured so scores are greppable during measurement runs, and it is what makes tuning an evidence-based exercise rather than a guess.
Extensibility seams#
Four model-driven sources are defined as interfaces with inert no-op implementations: screenshot classification, provider-logo detection, screenshot-embedding similarity and DOM-template similarity. They report themselves unavailable and the engine skips them.
They ship inert because each needs an artifact the device does not have — a trained classifier, a logo model, an embedding model, or a large crawled corpus of page templates. Rather than implying a capability that is not there, the seam is real and the implementation is honestly empty: supplying a working implementation is the only change needed to activate a layer, with no edit to the engine or to any existing source.
The heavy inputs are passed as opaque handles so imaging types never leak into the pure domain layer.