Post-Processors¶
Post-processors compute composite edges and risk scores from raw graph state after the batch write phase. They run in server/internal/analysis/processors/ and are orchestrated by analysis.RunPostProcessors().
All composite edges carry: scan_id, last_seen, confidence (0.0-1.0), risk_weight, is_composite=true, source_collector. source_collector is detector provenance, not lifecycle ownership.
Dependency DAG¶
auth_strength ──┬── can_reach ─────┬── cross_service_credential_chain
├── cross_protocol └── can_exfiltrate
└── confused_deputy
has_access_to ───┬── can_reach
└── cross_protocol
can_execute
shadows
poisoned_description
poisoned_instructions
can_impersonate
ALL ───── risk_score
Execution Order¶
| # | Processor | Dependencies |
|---|---|---|
| 1 | auth_strength | none (pre-pass) |
| 2 | has_access_to | none |
| 3 | can_execute | none |
| 4 | shadows (+ POISONS_CONTEXT) | none |
| 5 | poisoned_description | none |
| 6 | poisoned_instructions | none |
| 7 | taints | none (reads INGESTS_UNTRUSTED + schema_keys) |
| 8 | can_reach | auth_strength, has_access_to |
| 9 | cross_service_credential_chain | has_access_to, can_reach |
| 10 | ifc_violation | has_access_to (reads INGESTS_UNTRUSTED) |
| 11 | can_exfiltrate | can_reach |
| 12 | can_impersonate | none |
| 13 | confused_deputy | auth_strength, can_reach |
| 14 | cross_protocol | auth_strength, has_access_to |
| 15 | risk_score | all of 1-14 |
Scope compatibility policy¶
The ingest v1 model permits evidence from many collection points in one graph. Any processor that compares otherwise unrelated observations must use the shared exact predicate below; a prose judgment at each call site is not sufficient.
- Artifact-local evidence composes only with the same weak artifact.
- Two network-context observations compose only when both the collection point and network context match.
- Collection-point evidence composes with collection-point or network-context evidence only when the collection point matches.
- No other cross-scope join is permitted.
| Processor family | Scope policy |
|---|---|
shadows / POISONS_CONTEXT, taints, credential-derived can_reach, can_exfiltrate, can_impersonate, confused_deputy |
Shared compatible-scope predicate required |
auth_strength, has_access_to, can_execute, poisoned self-edges, ifc_violation, cross_protocol, risk_score |
Bound to one node, server, resource, host, or already-scoped graph path |
cross_service_credential_chain |
The sole approved global join: observed credential material with merge_key=value_hash and identical value_hash |
Positive and negative tests enforce this matrix. Endpoint-derived service observations are network-context scoped; that separates vantages but does not claim an immutable service identity across endpoint replacement or SaaS tenants.
Processor Interface¶
type PostProcessor interface {
Name() string
Dependencies() []string
Process(ctx context.Context, db graph.GraphDB, scanID string) (ProcessingStats, error)
}
ProcessingStats returns: ProcessorName, EdgesCreated, NodesUpdated, Duration, Error.
The Execution Order table above is the canonical sequence. The numbered section headings below predate later additions; new processors (
auth_strength,taints,ifc_violation,confused_deputy) and thePOISONS_CONTEXTpass are documented under their own headings.
auth_strength (pre-pass)¶
Computes: a paired effective authentication tuple and numeric
auth_strength on MCPServer / A2AAgent, plus derived effective assessment
properties on incoming TRUSTS_SERVER edges.
Configured auth_method / auth_assurance / auth_evidence and protocol
runtime observed_auth_* remain immutable provenance. For MCP, a reachable
HTTP, internally valid observed tuple with a method other than unknown
becomes
effective_auth_method, effective_auth_assurance, and
effective_auth_evidence, with effective_auth_source=observed. An unknown,
unavailable, partial, or contradictory observation falls back atomically to
the configured tuple (effective_auth_source=configured); fields are never
coalesced independently. A2A runtime evidence wins only for the exact bounded
read-only observation auth_probe_method=get_task_nonexistent,
auth_probe_status=anonymous_protocol_access, and observed
none/unauthenticated/anonymous_probe_succeeded, with bounded detail
task_not_found_v1 or task_not_found_v0_3. Card retrieval, protected or
inconclusive probe diagnostics, missing metadata, and every other A2A observed
tuple retain configured-derived behavior.
Both raw provenance channels are optional and atomic. A valid known observed
tuple can therefore materialize effective authentication without a configured
tuple. If neither a valid known observed tuple nor a complete valid configured
tuple remains, the pass sets every derived effective_auth_* property and
auth_strength to null so retired owners cannot leave stale posture behind.
The evidence-aware policy is none=100/unauthenticated only for an
effective_auth_source=observed tuple carrying
anonymous_probe_succeeded, basic=85/weak, apiKey=70/weak,
bearer=50/moderate, oauth=25/strong, oidc=20/strong, and mtls=10/strong.
Unknown/custom and configured/unconfirmed-none methods receive
effective_auth_assurance=unknown and a null numeric property.
For each TRUSTS_SERVER, the pass writes effective_risk_weight,
effective_auth_assessment_complete, and effective_auth_source. Exact
reachable observed none/unauthenticated/anonymous_probe_succeeded sets every
incoming trust edge to 0.1, complete, source observed: anonymous runtime
access bypasses every configured client credential. Every other runtime
posture preserves each relationship's own configured risk_weight and
auth_assessment_complete, source configured; one observed authenticated
access path cannot rewrite unrelated configured paths. The raw fields are not
overwritten.
1. has_access_to¶
Computes: MCPTool -[HAS_ACCESS_TO]-> MCPResource
Links tools to resources on the same server when capability or description indicates access.
Three Cypher passes:
- Capability-DB: Tool has database_access capability AND resource URI scheme is postgres/mysql/mongodb/redis. Confidence: 0.7.
- Capability-File: Tool has file_read or file_write AND resource URI scheme is file. Confidence: 0.7.
- Description match: Tool description contains the complete resource name,
or at least two distinct meaningful (four-or-more-character) tokens from a
hyphen/underscore-normalized resource name. Requiring two tokens avoids
inferring access from generic one-word overlap. Confidence: 0.9.
All edges: risk_weight=0.2, match_type recorded for evidence. Confidence,
match type, weight, collector, and scan metadata are refreshed on MERGE so a
prior inference cannot retain stale evidence.
2. can_execute¶
Computes: MCPTool -[CAN_EXECUTE]-> Host
Links tools to their server's host when the tool has shell_access or
code_execution in capability_surface. The YAML classifiers require
execution-specific terms; database-only names such as execute_query do not
produce either capability.
Pattern:
MATCH (s:MCPServer)-[:PROVIDES_TOOL]->(t:MCPTool), (s)-[:RUNS_ON]->(h:Host)
WHERE ANY(cap IN t.capability_surface WHERE cap IN ['shell_access', 'code_execution'])
MERGE (t)-[e:CAN_EXECUTE]->(h)
Confidence: 0.8, risk_weight: 0.1. Both values refresh on MERGE. This is a metadata-derived execution candidate, not observed command execution.
3. shadows¶
Computes: MCPTool -[SHADOWS]-> MCPTool (cross-server)
Detects tool shadowing: a tool on one server names another server's tool in its description (toLower(t1.description) CONTAINS toLower(t2.name)), which lets it impersonate or override that tool.
Pattern requires s1 <> s2 and t1 <> t2. The match is intentionally target-specific — t1's description must reference t2 by name. It does not branch on the has_cross_references node flag: that flag is target-blind (true if t1 references any sibling tool, see modules/mcp/signals.go), so OR-ing it in made one flagged tool shadow every tool on every other server (a cartesian fan-out of false positives). has_cross_references still feeds tool risk scoring as a node property (server/internal/analysis/riskscore/tool.go).
Confidence scales with injection patterns: 0.9 when has_injection_patterns=true, 0.6 otherwise. Risk weight: 0.4.
POISONS_CONTEXT (second pass): the shadows processor runs a second Cypher pass that emits MCPTool -[POISONS_CONTEXT]-> MCPTool when the source has has_injection_patterns=true and the sink carries a high-blast capability (shell_access, code_execution, credential_access, email_send), scoped to a single agent: both tools must be co-resident under one AgentInstance via (:AgentInstance)-[:TRUSTS_SERVER]->(:MCPServer)-[:PROVIDES_TOOL]->(:MCPTool). This deliberately widens the narrow SHADOWS guard (no description-naming requirement) to model context poisoning while the agent scope prevents a cross-tenant cross product. Fan-out is truncated to 20 sinks per (agent, source) pair to prevent a cartesian blow-up — the query groups on WITH a, src so the cap is genuinely per-agent-per-source, not a single global bucket, and keeps the first 20 sinks by objectid (collect(DISTINCT snk)[..20], deterministic via ORDER BY) rather than dropping the over-cap group entirely. Truncation, not suppression, is deliberate: silently emitting zero edges for a source with >20 sinks would blind the detector in its highest-risk case and let an attacker evade it by registering a 21st sink. The cap is regression-gated by a Go integration test (poisons_context_perf_integration_test.go) that runs in the test-integration CI job; scripts/perf-check.sh remains the operator-facing runtime heuristic, enforcing a ≤200 poisoned-pair-per-agent ceiling (10 source tools × 20 sinks). Confidence: 0.6, risk_weight: 0.4, source_collector='mcp'.
4. poisoned_description¶
Computes: MCPTool -[POISONED_DESCRIPTION]-> MCPTool (self-loop)
Flags tools whose descriptions contain injection patterns (detected by the rules engine during collection and stored as has_injection_patterns=true).
Confidence: 1.0, risk_weight: 0.8. Self-loop edge -- the finding is about the tool itself.
5. poisoned_instructions¶
Computes: InstructionFile -[POISONED_INSTRUCTIONS]-> InstructionFile (self-loop)
Flags instruction files marked is_suspicious=true by the Config Collector (imperative overrides, exfiltration commands, hidden Unicode).
Confidence: 1.0, risk_weight: 0.7, source_collector: config.
taints¶
Computes: MCPTool -[TAINTS]-> MCPTool (cross-server)
Emits a TAINTS edge when a tool that ingests untrusted input (it has an INGESTS_UNTRUSTED edge, or source_trust='private') shares ≥2 input-schema keys with a tool on another server. The schema overlap is computed in pure Cypher against the schema_keys node property (emitted collector-side — no APOC dependency). The ≥2 threshold avoids matching every {type, name} pair. No processor dependencies (reads raw INGESTS_UNTRUSTED edges + node properties), but registered before can_reach so its edges can influence the reachability walk. Confidence: 0.7, risk_weight: 0.3, source_collector='mcp'.
6. can_reach¶
Computes: AgentInstance -[CAN_REACH]-> MCPResource
The inferred transitive-access summary edge. Two passes:
Direct path (3 hops):
AgentInstance -[TRUSTS_SERVER]-> MCPServer -[PROVIDES_TOOL]-> MCPTool -[HAS_ACCESS_TO]-> MCPResource
effective_risk_weight
(confirmed observed no-auth trust = 1.0, static-key = 0.8, OAuth = 0.5).
Credential chain (6 hops):
AgentInstance -> MCPServer(s1) -> MCPTool(file_read|credential_access)
MCPServer(s2) -[AUTHENTICATES_WITH]-> Identity -[USES_CREDENTIAL]-> Credential
MCPServer(s2) -> MCPTool -> MCPResource
effective_auth_assurance to be explicitly unauthenticated or weak
for s1. Unauthenticated is eligible only when
effective_auth_source=observed; configured weak authentication remains
eligible. The paired effective tuple applies valid live MCP initialize evidence
as described above; missing/unknown auth evidence does not match. The credential
may come from any supported config location; it must have non-empty observed, exposed material with
merge_key=value_hash and identity_basis=value_hash. Confidence: 0.6.
When multiple credential paths connect the same (agent, resource), the
processor orders candidates by the complete stable object-ID tuple
(a, s1, t1, s2, i, c, t2, r) and selects the first before MERGE.
Neo4j relationship IDs are retained only as post-selection evidence and never
participate in winner selection, so witness topology cannot flip with
relationship recreation order. Evidence nodes follow that tuple, and evidence
relationships follow (TRUSTS_SERVER, PROVIDES_TOOL, AUTHENTICATES_WITH,
USES_CREDENTIAL, PROVIDES_TOOL, HAS_ACCESS_TO).
Verified-reach upgrade (3rd pass): after building the CAN_REACH edges,
can_reach re-correlates any persisted per-agent raw
CREDENTIAL_REACH_VERIFIED edge
(emitted by the campaign runner's cred-reach scenario) against the freshly
rebuilt edges. On a full match it upgrades the CAN_REACH edge in place —
reach_evidence_state='verified', confidence=1.0, plus the verified
scenario/run/oracle/staged-observation/cleanup metadata. It creates no new edge and no second finding,
so risk is never double-counted; findings.go reads reach_evidence_state and
raises the finding's evidence state to verified.
The ingest pipeline admits that raw campaign edge only after generic validation
and campaign-specific envelope/current-topology prevalidation, all before
normalization, BeginScan, canonical writes, and reconciliation. Rejected
positive or negative submissions leave canonical edges and coverage untouched;
diagnostics are limited to a sanitized Postgres rejection audit.
Before the Cypher upgrade, Go reconstructs witness V1 from each raw edge and
recomputes its unkeyed fingerprint; invalid evidence remains stored for
diagnosis but is excluded from the validated relationship-ID allowlist.
Re-correlation then requires a.objectid = witness.agent_id, exact live
credential hash/merge key, the exact providing server/resource identity, the
fixed scenario/oracle/stage/outcome contract, and equality between the complete
ordered witness topology and the current CAN_REACH.evidence_node_ids; every
normalized kind must still label its corresponding node. Only a positive
publication revision is required—revision equality is deliberately not a gate.
Because evidence identity is AgentInstance -> MCPResource and coverage also
includes the agent, two agents sharing one credential/resource cannot overwrite
or over-upgrade each other.
7. cross_service_credential_chain¶
Computes: AgentInstance -[CAN_REACH]-> Credential (upstream provider
credential material or references)
Joins Config Collector and LiteLLM Looter emissions on Credential.value_hash:
AgentInstance -> MCPServer -[AUTHENTICATES_WITH]-> Identity
-[USES_CREDENTIAL]-> Credential(c1)
where c1.value_hash matches...
LiteLLMGateway -[EXPOSES_CREDENTIAL]-> Credential(c1master)
LiteLLMGateway -[EXPOSES_CREDENTIAL]-> Credential(c2, type IN [apiKey, virtual_key])
Both c1 and c1master must have a non-empty hash and explicit
merge_key=value_hash, identity_basis=value_hash, material_status=observed,
and exposure_status=exposed. Optional HAS_ENV_VAR location evidence is not
part of the correlation path.
Evidence nodes are ordered (agent, server, identity, c1, c1master, gateway,
c2). Raw relationship evidence is ordered (TRUSTS_SERVER,
AUTHENTICATES_WITH, USES_CREDENTIAL, EXPOSES_CREDENTIAL(master),
EXPOSES_CREDENTIAL(upstream)); the c1/c1master value-hash correlation is
recorded separately as synthetic evidence.
Emits: (AgentInstance)-[:CAN_REACH]->(c2) with evidence including
merge_value_hash, via_gateway, upstream_provider. The resulting finding
variant is credential_chain_observed_material only when c2 explicitly carries
observed, exposed, non-identity material; masked/hashed targets are
credential_chain_reference. Confidence: 0.95, hops metadata: 6.
via_gateway uses the gateway name when present, then its endpoint, and finally
its immutable object ID. The final fallback is required because a standalone
LiteLLM loot collection references the gateway without claiming fingerprint-owned
display properties.
The same single query also computes credential blast radius at the canonical
value_hash grain: count(DISTINCT agent) across every configured credential
node representing that secret. The global count is written to every matching
c1 and c1master; two config nodes with the same secret therefore cannot
race to leave a smaller per-node count on the shared master. Candidate paths
are then ordered by their stable seven-node object-ID tuple and reduced to one
winner per (agent, upstream credential) before MERGE, so repeated runs and
multiple config paths cannot overwrite evidence nondeterministically.
blast_radius then amplifies the server credential-handling risk term (see
risk-scoring.md).
The value_hash is the cross-collector merge primitive -- same secret value regardless of how each collector derives its objectid.
ifc_violation¶
Computes: MCPTool -[IFC_VIOLATION]-> MCPTool
Emits an information-flow-control violation edge when an untrusted-input tool (INGESTS_UNTRUSTED -> MCPResource) shares a resource within 3 HAS_ACCESS_TO hops with a sink tool carrying a high-impact capability (credential_access, file_write, email_send). The 1..3 hop cap is the false-positive / performance guard. Depends on has_access_to. Confidence: 0.6, risk_weight: 0.3, source_collector='mcp'.
IFC_VIOLATION carries source_collector='mcp' as detector provenance.
Its lifecycle is nevertheless part of the global composite epoch: any promoted
complete domain causes all detectors, including IFC, to rebuild from the
retained current raw projection.
8. can_exfiltrate¶
Computes: AgentInstance -[CAN_EXFILTRATE_VIA]-> MCPTool
Requires both conditions:
1. Agent CAN_REACH a resource with sensitivity critical or high
2. Agent trusts a server with a tool having an outbound capability: email_send, network_outbound, file_write, auto_fetch_render, or allowlisted_proxy
Pattern correlates inferred data access with a matched output-channel
capability. It does not observe data transfer or prove runtime invocability.
The auto_fetch_render / allowlisted_proxy classes broaden the set of
candidate channels (see detection-rules.md for the auto_fetch_render
host-side caveat). Confidence: 0.8.
9. can_impersonate¶
Computes: A2AAgent -[CAN_IMPERSONATE]-> A2AAgent (bidirectional)
Uses TF-IDF cosine similarity on skill descriptions. For each pair of A2A agents (from different providers):
1. Loads all agents from Neo4j
2. Builds per-agent document from concatenated skill descriptions
3. Computes TF-IDF vectors via similarity.NewCorpus
4. Emits bidirectional CAN_IMPERSONATE edges where cosine similarity > 0.8
Writes edges via db.WriteEdges() (batch) rather than Cypher MERGE. Risk weight: 0.6.
Agents from the same provider are excluded (impersonation assumes cross-provider).
10. cross_protocol¶
Computes: A2AAgent -[CAN_REACH]-> MCPResource
The cross-protocol shared-host correlation that single-protocol scanners cannot express:
MATCH (ext:A2AAgent)-[:DELEGATES_TO*1..3]->(int:A2AAgent)
MATCH (int)-[:RUNS_ON]->(h:Host)<-[:RUNS_ON]-(s:MCPServer)
MATCH (a:AgentInstance)-[:TRUSTS_SERVER]->(s)-[:PROVIDES_TOOL]->(t:MCPTool)-[:HAS_ACCESS_TO]->(r:MCPResource)
WHERE ext.effective_auth_assurance = 'unauthenticated'
AND ext.effective_auth_source = 'observed'
AND ext.effective_auth_evidence = 'anonymous_probe_succeeded'
Requires exact, observed unauthenticated evidence for the external A2A agent;
configured no-auth claims and missing auth are not runtime proof and do not
match. The pivot is host co-location: an A2A
agent delegates to another agent recorded on the same host as an MCP server.
The edge carries cross_protocol=true and confidence 0.5. Finding and pre-built
query output label it a shared_host hypothesis, not proven A2A-to-MCP
invocation.
confused_deputy¶
Computes: A2AAgent -[CONFUSED_DEPUTY]-> A2AAgent
Flags a confused-deputy escalation when an unauthenticated or weak A2A agent
DELEGATES_TO a strong one. Unauthenticated callers require
effective_auth_source=observed; configured weak callers remain eligible.
Unknown/custom methods are excluded. The low-trust caller effectively borrows
the callee's privileges. Depends on the
auth_strength pre-pass and can_reach (ordering).
source_collector='a2a'; confidence 0.8, risk weight 0.3.
11. risk_score¶
Computes: risk_score, risk_score_min, risk_score_max,
risk_assessment_complete, and risk_unknown_factors on AgentInstance,
A2AAgent, MCPServer, and MCPTool nodes.
Depends on ALL prior processors (uses their edges for scoring). It reads every
page of each scored kind under one graph revision before writing any scores;
an incomplete page, revision/total change, or count mismatch aborts the
processor rather than publishing scores for a partial node set. Per-node
calculation and update failures are aggregated while best-effort scoring
continues across every node kind; the joined error fails the processor and
withholds publication. Per-kind scoring functions live in
analysis/riskscore/.
Agent score (0-100): - 0.30 x credential exposure - 0.25 x blast radius (reachable resources) - 0.20 x auth posture - 0.15 x tool surface - 0.10 x poisoning exposure
A2A agent score (0-100): - 0.30 x auth strength - 0.30 x cross-protocol blast radius - 0.25 x delegation surface - 0.15 x impersonation exposure
Server score (0-100): - 0.35 x auth strength - 0.25 x tool risk - 0.20 x exposure - 0.20 x credential handling
Tool score (0-100): - 0.30 x capability class - 0.25 x poisoning indicators - 0.25 x access sensitivity - 0.20 x input validation signals
Composite epoch replacement¶
When at least one explicit complete raw domain is promoted,
beginCompositeEpoch() atomically removes every is_composite=true edge and
clears processor-owned Credential.blast_radius. All registered processors
then rebuild the global derived graph from the retained current raw projection
in dependency order.
CALL {
MATCH ()-[r]->()
WHERE r.is_composite = true
WITH r
DELETE r
RETURN count(r) AS deleted
}
CALL {
MATCH (c:Credential)
WHERE c.blast_radius IS NOT NULL
REMOVE c.blast_radius
RETURN count(c) AS cleared
}
RETURN deleted
The epoch is global because a narrow MCP, config, or A2A replacement can
invalidate a composite whose source_collector names another domain. Examples
include cross-protocol host pivots, transitive reachability, exfiltration, and
cross-service credential chains. Keeping the prior epoch available while
recomputing would also let downstream processors consume stale HAS_ACCESS_TO
or CAN_REACH inputs and incorrectly refresh them into the new epoch.
When a partial scan adds or updates a confirmed raw fact, that mutation also starts a new derived epoch so analysis reflects the retained graph plus the new positive evidence. Omitted partial-scan facts are retained rather than treated as absent. When there are neither accepted raw mutations nor promoted complete domains, derived processing is skipped and the current composite epoch is left untouched. If epoch retirement or any processor fails, analysis is incomplete and publication is withheld. The mutable projection may contain an incomplete new epoch, but the previously published PostgreSQL revision and its frozen evidence remain unchanged.
This invariant requires every composite edge to be produced by the registered
processor set and every processor to rebuild solely from retained raw facts or
outputs of earlier processors in the declared order. New processors must obey
that contract; manually persisted is_composite=true relationships are not
preserved across complete replacements.
INGESTS_UNTRUSTED raw-edge accumulation¶
INGESTS_UNTRUSTED is a raw edge (is_composite=false), so composite
cleanup never touches it. It participates in the same observation-owner
reconciliation as every other new raw edge: a complete MCP domain removes the
prior MCP token, while partial/unknown coverage retains it.
Findings Snapshot Stage (pipeline)¶
After post-processing and graph-total capture, the ingest pipeline materializes a candidate findings snapshot. PostgreSQL finalization deletes and replaces all rows for that scan in one transaction, including an empty retry.
Every finding-producing processor records the exact witness node object IDs and
Neo4j relationship IDs selected by its detector on the composite edge. During
the same ingest, QueryFindings dereferences those IDs once and atomic
finalization stores the resulting node/edge snapshots as JSONB with the finding
row. Finding detail serves that frozen witness graph; it does not re-run
detector-like LIMIT 1 queries against the mutable projection.
When all required graph, analysis, and snapshot stages succeed, the same
transaction inserts an immutable posture_publications revision, persists its
export, advances posture_state, and records any published collection
limitations. Collection coverage may remain limited: confirmed findings are
then current, while missing findings are not an all-clear and comparison is
disabled. Otherwise the snapshot can remain historical but the published
pointer does not move. A degraded retry using the currently published scan ID
preserves the prior published rows and export until a new safe revision
commits.
Integration-test isolation¶
Post-processors operate on the whole graph, not a scan-scoped subgraph: risk_score lists every node of a kind, shadows/taints/can_reach MATCH across all servers, etc. That is correct for production (one scan, one graph) but it means two integration-test binaries cannot safely share one Neo4j concurrently — a DETACH DELETE in one binary can vanish a node mid-traversal in another, surfacing as Neo.ClientError.Statement.EntityNotFound or phantom zero-count assertions. Because go test ./... runs package test binaries in parallel, every DB-touching package (analysis, analysis/processors, graph) holds an exclusive advisory file lock (server/internal/dbtest) for its run via TestMain. The lock is a no-op when AGENTHOUND_NEO4J_URI is unset, so unit-only (-short) runs keep full parallelism. New packages that run post-processors against a live DB must add the same TestMain guard.