Build the recon graph¶
Discover before writing¶
Call kb_types rather than inventing node or relation names:
{"kind":"nodes","type":"endpoint"}
The type entry describes required properties, formats, enums, limits, a
ready-only count, an identity object, and an inventory declaration.
identity.properties lists the properties used in the identity. Parent-scoped
types also expose identity.scope, which names the relation and endpoint that
choose the parent. inventory says whether the type holds
inventory state.
Additional JSON properties are allowed, but required fields are strictly typed
and validated without coercion. The MCP computes the read-only key; callers
never submit a key. A successful type listing does not prove database health,
and counts can be deferred while a pending hub delete is too expensive to count
safely.
Attach scanner evidence to structured facts¶
Suppose kb_ingest_evidence stored a scanner result and returned an
evidence_id. One atomic kb_write can create a DNS-to-service path and attach
the evidence. The scope relations for the new port and service are part of the
same request. The domain and subdomain are the target's own, and the evidence
on each supports that claim. The address is still unattributed, so it is a
candidate. The port and service inherit their state from the address:
{
"nodes": [
{
"type": "domain",
"properties": {"value": "example.com", "scanner": "subfinder"},
"ownership": "owned",
"evidence_add": ["e_aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"]
},
{
"type": "subdomain",
"properties": {"value": "api.example.com"},
"ownership": "owned",
"evidence_add": ["e_aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"]
},
{
"type": "ip_address",
"properties": {"value": "203.0.113.10", "version": 4},
"ownership": "candidate",
"evidence_add": ["e_aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"]
},
{
"type": "port",
"properties": {"number": 443, "transport": "tcp"},
"evidence_add": ["e_aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"]
},
{
"type": "service",
"properties": {"name": "http", "secure": true},
"evidence_add": ["e_aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"]
}
],
"relations": [
{
"type": "has_subdomain",
"source_ref": {"node_index": 0},
"target_ref": {"node_index": 1},
"properties": {},
"evidence_add": ["e_aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"]
},
{
"type": "resolves_to",
"source_ref": {"node_index": 1},
"target_ref": {"node_index": 2},
"properties": {},
"evidence_add": ["e_aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"]
},
{
"type": "has_open_port",
"source_ref": {"node_index": 2},
"target_ref": {"node_index": 3},
"properties": {},
"evidence_add": ["e_aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"]
},
{
"type": "has_service",
"source_ref": {"node_index": 3},
"target_ref": {"node_index": 4},
"properties": {},
"evidence_add": ["e_aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"]
}
]
}
Use the real returned evidence ID; the value above only illustrates its format.
Graph and job IDs are UUIDs, while evidence IDs are content hashes. Each
acknowledgment echoes the node's effective ownership and authorization.
Ownership, authorization and the observation times are top-level fields, never
entries in properties.
Identity and parent scope¶
Unscoped identity comes from the catalog-selected identity properties. Writing
the same endpoint identity later returns its existing UUID, and multiple graph
records can relate to that shared node. Do not copy a parent UUID or session ID
into unscoped properties to force duplication. Use { "id": "..." } for an
existing node or { "node_index": 2 } for a node in the same call.
port, service, finding, dkim_record, parameter, and mta_sts_policy
are parent-scoped. Their identity includes the parent node UUID selected through
has_open_port, has_service, has_finding, has_dkim_selector,
has_parameter, or has_mta_sts_policy, respectively. A new scoped child must arrive with exactly one of its scope
relations in the same kb_write; the complete batch is validated and committed
atomically. An existing scoped child can be patched by ID without repeating its
relation, but it cannot be attached to a different parent.
A scope parent can itself be scoped. has_finding accepts parameter and
dkim_record as sources, so a finding about one query parameter is keyed on that
parameter, which is in turn keyed on its endpoint. The server resolves scoped
nodes parent-first inside the batch, so one kb_write can create the endpoint,
the parameter, the finding and all three relations together.
Delete a scoped child with cascade: true before deleting its scope relation or
parent node. The cascade removes the child's incident scope relation. The server
rejects deletion of that relation or parent while the child exists, preventing
orphan ports, services, findings, DKIM records, parameters, MTA-STS policies,
and domain registrations. A chain deletes innermost first: an endpoint with a parameter that
carries a finding takes three deletes, finding, parameter, endpoint, and the two
outer ones are refused until the level below them is gone.
Inventory state¶
An engagement meets three kinds of foreign asset, and only two of them belong
in the graph. Write the target's own assets as owned and the third-party
infrastructure the target depends on, such as a SaaS host its name CNAMEs to, as
dependency. Write an asset whose attribution is still open as candidate.
Leave unrelated neighbors in evidence and never write them as nodes: the other
tenants of a shared IP address, reverse-IP results from a dependency address,
or names on a certificate that belong to someone else.
Authorization is a separate axis with the values in_scope, out_of_scope and
unknown, because a contract can exclude an owned system or include a
permitted third-party one. Only in_scope authorizes active testing; owned
and unknown do not. The server performs no testing, so the agent enforces this
rule.
Each node type declares one inventory value, and kb_types publishes it
beside the vocabularies and the testing rule:
inventory |
Meaning |
|---|---|
carries |
The node holds its own ownership and authorization. Creating one requires ownership. |
inherits |
A parent-scoped node. It takes both from the root of its scope chain and may only override authorization. |
none |
A vocabulary or shared record, such as technology. It holds no state and refuses the state fields. |
A carries type may also list allowed_ownership, which narrows the ownership
values it accepts. package, a package or container image the target publishes,
lists it so that a package is never a dependency: a third-party component the
target runs is a technology instead.
Creating a carries node requires ownership. candidate needs nothing more;
owned and dependency are claims and need evidence_add on that node in the
same write. Authorization starts at unknown unless the creating write sets it.
A later write that matches the node by identity never changes its stored
state, so a rescan cannot undo a classification. State changes only through a
write addressed by the node's id:
| From | To | Evidence in the same write |
|---|---|---|
candidate |
owned, dependency |
required |
owned |
dependency, and back |
required |
owned, dependency |
candidate |
not required |
candidate |
rejected |
required |
owned, dependency |
rejected |
refused; withdraw to candidate first |
rejected |
owned, dependency |
required |
rejected |
candidate |
refused |
| any authorization | in_scope, out_of_scope |
required; refused on a rejected node |
| any authorization | unknown |
not required; refused on a rejected node |
allowlist_scoped |
set or cleared | required; the node must be in_scope |
A write, or an evidence deletion, that would leave a node holding a claim
(owned, dependency, rejected, in_scope or out_of_scope) with no link
to ready evidence is refused. A refusal from these rules starts with the item
address, such as nodes[2]:, and a refused transition names the current state.
A scoped child's effective authorization is the most restrictive value along its
scope chain. A child may narrow itself to out_of_scope with evidence, and
writing unknown removes that override. When the contract allows only listed
assets under a host, such as ports 80 and 443, mark the host allowlist_scoped.
Its scoped descendants are then out_of_scope until an ID write with evidence
widens a descendant, or an ancestor below the root, to in_scope, so a port
found later is never testable by default. An in_scope override is refused
under a root that is not allowlist-scoped:
{
"nodes": [
{
"id": "22222222-2222-4222-8222-222222222222",
"authorization": "in_scope",
"allowlist_scoped": true,
"evidence_add": ["e_bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb"]
}
]
}
A candidate found to be neither the target's asset nor a dependency moves to
rejected by ID, with the evidence that shows why:
{
"nodes": [
{
"id": "33333333-3333-4333-8333-333333333333",
"ownership": "rejected",
"evidence_add": ["e_cccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccc"]
}
]
}
The node keeps its identity, the other properties its type requires, its
evidence links and its first and last seen. The server removes its other
properties and label, resets authorization to unknown, and purges its
relations and scoped descendants in a job whose ID the acknowledgment returns as
rejection_job_id. The same write may not touch those relations or
descendants. CONFLICT: REJECTION_BLOCKED refuses the rejection while an
owned or dependency node relies on the candidate through cname_to,
dname_to, has_nameserver, has_mail_exchange, has_soa_primary,
has_srv_target, has_svcb_binding, resolves_to, hosted_on,
backed_by_bucket or federates_with; the details name that relation, and
such a candidate is a dependency.
A rejected record blocks its own re-creation. A write that matches a rejected
identity, or that creates a subdomain under a rejected registrable domain,
is refused whole with CONFLICT: REJECTED_IDENTITY. Nothing is written, and
the rejected_items details list every offending nodes[i] with the
rejected_record it hit. Drop those items, the
relations that point at them and the scoped children created under them, then
resend the rest of the batch.
To see the rejected names before writing, list the rejected records:
{"kind":"nodes","ownership":"rejected","limit":100}
Search summaries carry no properties and a rejected record has no label, so read
the returned IDs with kb_get and kind: "nodes": each rejected record keeps
its identity properties.
Deleting the rejected record with kb_delete and cascade: true is an explicit
purge that lifts the block.
Reads show effective state. kb_get node records carry ownership,
authorization, first_seen and last_seen, with allowlist_scoped on a root
and state_root_id on a scoped node. kb_search filters nodes on effective
ownership and authorization and leaves rejected records out unless the
filter asks for them. kb_neighbors nodes carry their effective state too.
Earlier catalog and schema versions¶
Catalog v5 and schema version 4 are a clean cut. A workspace created with catalog v1, v2, v3 or v4, or before schema version 4, is rejected at startup; it is not migrated or opened read-only. Create a new workspace for this version.
Mutable records¶
Creation requires type and properties; an ID patch requires id. Patches
recursively merge objects, while arrays replace as complete values. An empty
object does not clear existing children:
{"nodes":[{"id":"11111111-1111-4111-8111-111111111111","properties":{"scanner":{}}}]}
Remove children explicitly with RFC 6901 JSON Pointers:
{
"nodes": [{
"id": "11111111-1111-4111-8111-111111111111",
"remove_properties": ["/scanner/status", "/scanner/note"]
}]
}
Required identity fields and relation endpoints are immutable. Omitted label or
source values are preserved; explicit null clears those optional metadata
fields. Property null remains literal JSON data, except that a property the
catalog declares rejects it; remove that property with remove_properties.
First and last seen¶
observed_at on a write is the time its facts were observed, and it defaults
to the time of the write. Every node and relation reports first_seen and
last_seen, the earliest and latest observation any write has reported, and an
older observation never moves either one backward. A write is an observation
when it creates the record, matches it by identity (a rescan), supplies
observed_at, or sends properties or remove_properties. A write by ID that
only changes state, label, source or evidence links is not. An observed_at
more than five minutes past the server's clock is refused. A write observed before the record's last_seen may lower
first_seen and adds the properties the record lacks; it neither overwrites nor
removes a stored value. kb_search bounds graph records with first_seen_min,
first_seen_max, last_seen_min and last_seen_max.
All writes validate the merged full record and every check its type declares, including a relation's endpoint value checks, before one transaction commits. One invalid record or missing evidence link rejects the entire batch.