ledger¶
Writes confirmed facts into the findings ledger — the model's structured working memory, re-shown to it at the top of context on every turn.
This is the answer to "the model forgot the credential it found forty messages ago." The ledger lives outside the summarized history, so context compaction never touches it: a credential recorded now is still in front of the model after the conversation has been summarized three times. Anything not recorded here can be summarized away.
Registered only when
KB_ENABLED=true (the
default) — the same switch as recall, with no flag of its own. For
the operator's view, see the memory guide.
Parameters¶
| Parameter | Type | Default | Description |
|---|---|---|---|
op |
add | list | remove | graph | profile |
required | Record, read, retract, read the resource graph, or read one resource profile. |
section |
see below | — | Required for add; an optional filter for list. |
entry |
object | — | The record to store. Required for add. |
entry_id |
integer | — | Required for remove (entry to retract) or profile (asset/target entry to inspect); ids come from list, graph, or a successful resource add. |
artifact_ref |
integer | — | Artifact id linking an entry to its evidence — the N from an artifact#N reference. Required for observed connections; connection references must be positive integer ids of existing artifacts. |
offset |
integer ≥ 0 | 0 |
For graph or profile, continue from the previous page's next_offset. Restart at 0 after inventory edits. |
There is no update operation. Use add with the same identity to enrich a
fact or replace a connection's assertion. To correct the identity itself through
the tool, remove the old entry and add the replacement.
Sections and their identity fields¶
Ten sections. Each has identity fields (marked *) that an add must supply.
| Section | Holds | Fields |
|---|---|---|
assets |
Resources you are protecting | kind*, value*, name, environment, tags, notes |
targets |
Subjects of an offensive assessment | kind*, value*, name, environment, tags, notes |
connections |
Resource relationships and direct reachability | source_id or source_vantage, target_id*, relation*, provenance*, note; reaches also requires protocol*, port*, optional observed_at and service |
containers |
Last-known Docker lifecycle observations | Compose scope, name, service, state, readiness, published ports, target IDs, timestamp, log artifact |
hosts |
Machines in scope | ip*, hostname* (at least one), os, technologies, note |
services |
Listening services | host* (or ip*), port*, protocol, service, version, technologies, note |
credentials |
Recovered credentials | username*, host* or source*, secret, kind, validated |
findings |
Confirmed weaknesses | title*, severity, host, description, status, remediation, note |
flags |
Captured flags | value*, label, host |
next_steps |
Planned work | step* |
For assets and targets, kind is one of domain, ip, url, or
repository, and the value is normalized and validated per kind: IP addresses are
canonicalized and reject zone identifiers, domains are IDNA-encoded and
lowercased, repository values must be absolute local paths (~/path is accepted
and expanded), and URL identity preserves path, query, and fragment case. Extra
fields are rejected rather than silently dropped. Optional environment (128
characters), tags (up to 20 strings of 64 characters, without commas), and
multiline notes (4,096 characters) provide assessment context. Tags are trimmed
and deduplicated. Names and context fields never affect identity.
For connections, resource endpoints must be existing assets or targets entry
ids, obtained from list, graph, or a successful resource add. A reaches
source may instead be an explicit source_vantage label. IDs must be
positive integers within SQLite's signed 64-bit range. At the tool boundary,
integer-valued provider numbers such as 1.0 are normalized to integers because
Gemini's protobuf arguments represent JSON numbers as doubles. This applies to
source_id, target_id, connection artifact_ref, and reaches.port; strings, booleans,
fractional numbers, nonfinite numbers, and out-of-range values are rejected.
The storage model remains strict about integer IDs. Extra fields are rejected.
The supported directions are:
| Relation | Source kind | Target kind | Meaning |
|---|---|---|---|
resolves_to |
domain |
ip |
DNS resolution has been observed or explicitly declared. |
deploys_to |
repository |
domain, ip, or url |
The repository deploys to this resource. |
reaches |
domain, ip, or an explicit source_vantage |
domain or ip |
The named source can directly reach this transport and destination port. |
For reaches, provide exactly one source_id or source_vantage, a protocol
of tcp or udp, and an integer destination port from 1 through 65535.
source_vantage is a stable, explicit label for the actual execution environment,
such as runner:assessment-a or container:scanner-a; it has at most 200 characters.
It is not an inventory resource or a default "your machine" location. Keep labels
consistent across observations and distinct across execution environments. An
optional service label, such as https or ssh, has at most 80 characters.
Labels are trimmed, case-sensitive, and cannot contain control characters.
Resource-backed source and target identities must be different.
An observed reaches connection defaults observed_at to the evidence artifact
creation time. An explicit ISO8601 timestamp with timezone (for example
2026-10-05T12:30:00Z) is stored in UTC and cannot be later than the evidence.
Supply a real observation timestamp from the evidence or omit it; do not invent one. Declared connections cannot have
observed_at. source_vantage, protocol, port, service, and observed_at
are forbidden on resolves_to and deploys_to.
provenance is either observed or declared:
- Observed: supply a top-level
artifact_refpointing to the tool output that supports the connection. The artifact must exist in the same engagement knowledge base. A DNS lookup can establishresolves_to; deployment evidence can establishdeploys_to. Evidence forreachesmust establish direct transport reachability from the named source to that destination and port. - Declared: supply a non-empty
notedescribing the explicit user declaration or configuration. An evidence artifact is optional, but must exist if supplied.
Do not infer DNS or deployment relationships from similar resource names. A
URL's hostname alone proves only that the URL uses that hostname; it does not
prove a DNS address or deployment relationship.
Likewise, a URL, DNS result, deployment, or service listing alone cannot establish
reachability from another environment. Record only actual connection observations
or explicit configuration. A reaches arrow establishes neither successful
authentication nor permission to use a service. Adjacent arrows do not prove
forwarding, gateway access, or an end-to-end multi-hop route. This operation only
records facts; it does not run a connection test or configure access.
With the knowledge base, ledger, and a network-capable tool available, Kerkov is instructed to
record confirmed host connections automatically, including ordinary SSH access
from the local runner. Successful network-capable tool results expose an artifact#N
evidence handle even for short output, so the agent can attach the confirming
result without a separate evidence search. Successful SSH authentication or
verified remote command output can confirm access; merely starting a process,
seeing a password prompt, or listing an open port cannot. The agent interprets
the evidence and records the relationship; the map does not parse terminal text
or assume that a successful tool invocation proves a connection. Failed tool
results remain stored for recall and diagnostics, but do not advertise a
connection-recording evidence handle. Agents restricted to tools such as
read, recall, and ledger do not receive the connection-recording policy.
The source is the environment that made the connection. A connection made from an already accessed host is recorded from that host, rather than from the local runner. The line describes the last observed connection and can remain after the SSH session closes; it is not a live session indicator.
Asset and target roles do not change permissions
Recording something as an asset rather than a target is bookkeeping, not enforcement. Tool approval is governed entirely by approval rules.
Identity is required, not inferred¶
An add that cannot be identified is rejected with a message naming the missing
field. That looks strict, and it is deliberate: the failure mode of guessing is
silent in both directions.
A host recorded with neither an IP nor a hostname gets a content hash for a key,
which never merges with its own later enrichment — so you end up with three rows
for one machine, each holding a third of what is known. And services recorded
without a host would all collapse onto the same port, merging 80 on four
different machines into one row.
Merging, and how identity firms up¶
add merges by identity: re-adding a host with more detail enriches the existing
row rather than creating a second one. This is the normal way an entry grows —
first the IP, later the OS, later a note.
Identity itself firms up over time, so each section also carries alias keys: the
hostname a host was first known by before its IP was confirmed, the source a
credential was recorded against before it was tied to a host, the credential
recorded before its kind was known. A lookup checks key or alias, folds
duplicates into the oldest row, and promotes that row to the canonical key — so
learning more about a fact never forks it.
For the fact sections, ownership and evidence merge as follows:
engagement_id— the first contributor keeps ownership. Cleanup deletes by owner, so letting an enriching agent take ownership would let its cleanup delete the master's facts.artifact_ref— a supplied reference replaces the existing evidence link; a later detail-only add without a reference preserves it.
Resources and connections belong to the root engagement, including those added
by a delegated agent. Connections deduplicate by normalized resource identity
and relation, so referring to the same resource's asset or target entry updates
the same connection. For reaches, identity additionally includes the explicit
source identity, transport protocol, and destination port. TCP and UDP, different
ports, reverse directions, and different source environments remain separate.
Service labels and observation timestamps describe evidence and do not alter identity.
Re-adding an observed connection replaces its note and
evidence with the new validated observation; it does not accumulate observations.
For reaches, an older observed_at cannot overwrite a newer observation, even
when submitted later. The tool reports outcome: stale_observation_retained,
and retains the newer record unchanged. Equal observation times may replace
metadata and evidence. Removing an observation is the way to retract it.
A later declaration cannot downgrade an observed connection: its observed record
and evidence stay intact. The tool explicitly reports that the declaration and its
note were not stored, with outcome: observation_retained in metadata.
Re-adding a declared connection replaces its declaration,
and an observation can promote it to observed. To intentionally replace an
observed connection with a declaration, remove the connection first.
Retract a connection by its connection entry id when it is no longer true.
Connection cleanup follows resource identity:
- Changing a name or retaining a resource under another role preserves its connections. If equivalent entries merge, connections follow the surviving resource entry.
- Changing the resource's kind or value retracts its old connections, unless another asset or target entry still represents the old identity.
- Removing the last asset/target entry for a resource removes its incident connections. Deleting an evidence artifact retracts observed connections supported by it; declared connections remain with their evidence link cleared.
Resource graph¶
op=graph returns a compact JSON page with nodes, edges, and next_offset from
one consistent ledger snapshot. Nodes represent resources by their normalized kind and value.
Each explicit reachability source also produces a kind: vantage node whose
value is its label and whose roles and entry_ids are empty. Vantage nodes
disappear when their last connection is retracted; no resource records are created.
Container observations also produce distinct kind: container nodes, with their
own observation entry IDs, lifecycle metadata, and links to saved endpoint node
IDs. These observation IDs cannot be used as connection endpoints. Published
ports do not create reachability edges. Container details remain available with
list filtered to containers.
A resource recorded as both an asset and a target becomes one node carrying both roles. Credentials, findings, and the other fact sections are not graph nodes.
Each page contains at most 5,000 JSON characters and remains intact in model
history, including when generic output limits are lower. Follow a non-null
next_offset with {"op":"graph","offset":<next_offset>} until it is null.
Offsets count nodes first, then edges; an edge's endpoint may be on an earlier
page. Each call reads a fresh snapshot, so restart at 0 after inventory edits.
Metadata counts describe the whole graph, not just the current page.
If a single resource's URL, path, or name is too large to fit, the page keeps its
kind, roles, and entry IDs but omits its value and names, marking those fields
in omitted_fields. Use list for the full record. The stored identity and the
application graph are unchanged.
| Object | Fields |
|---|---|
| Node | kind, value, roles (asset/target), entry_ids, and optional names keyed by role. |
| Edge | source_id or source_vantage, target_id, relation, connection_ids, provenances, and optional artifact_refs. Reachability includes protocol, port, and optional service and observed_at. |
To add a connection, use a node's integer entry_ids as source_id or
target_id; to retract one, use the edge's connection_ids with remove.
Each resource endpoint's source_id and target_id is the first entry_ids value
of its node, so these are usable ledger IDs too. A vantage-backed edge instead
has source_vantage containing the source label, with no source_id.
Hashes and full evidence
notes are omitted from tool output to keep small graphs below the default output
clipping threshold. Read full connection notes with op=list and
section=connections; retrieve evidence with recall using an artifact_refs ID.
Edges include recorded resolves_to, deploys_to, and reaches connections with their
provenance and evidence. The graph also derives uses_host edges from URL
resources to existing domain or IP resources whose identity exactly matches the
URL hostname. These edges have derived provenance; they perform no DNS lookup,
create no ledger entries, and cannot be added or removed through the tool. Edit
or remove the underlying resource to change a derived edge.
Graph projection is read-only. Application code can use FindingsLedger.graph()
for the full graph with stable identity-based node and edge id hashes, edge
source/target references to those node IDs, role-specific node records, and
full edge evidence records containing connection_id, provenance,
artifact_ref, and note. Reachability evidence also retains any service and
observed_at; the edge surfaces the latest observation's metadata. Virtual vantage
nodes have deterministic vantage: IDs and empty records. Existing resource
and legacy relation shapes remain unchanged. Artifact bodies are excluded.
The API supplies inventory and direct reachability data to the host-grouped
browser network map. It displays vantage origins and recorded reaches routes
with transport, service and evidence details.
Resource profiles¶
{"op":"profile","entry_id":12} reads the asset/target profile for resource
entry #12. It combines both roles for the same normalized kind/value and uses
one coherent snapshot of the engagement ledger. This is the same projection used
by the web console; it is available to CLI conversations and delegated agents.
The JSON includes resource (with an identity-based graph ID), role-specific
records, exact-address hosts, services, findings, incident connections,
related_resources, current-record activity, full section counts, and a
truncated flag. Sections are limited to 200 records and individual fact fields
to 8,192 characters, with truncated_fields identifying clipped text. Model-facing
JSON pages are capped at 5,000 characters and bypass generic history clipping.
Follow next_offset with the same entry_id and offset until it is null;
restart at zero after edits. Findings and services precede hosts, role records,
connections, related resources, and activity. Counts and the truncation flag
describe the full bounded projection, rather than one page. Node records are
omitted to avoid repeating role context. Oversized fields are marked in
omitted_fields, preserving handles without shortening identities; use filtered
list calls and recall to expand records and evidence. Artifact bodies, credential/flag sections, raw tool
arguments and conversation transcripts are excluded.
Observations match explicit ip/hostname fields on hosts and host/ip on
services or findings. Domains and IPs normalize normally; URL paths and absolute
repository paths match exactly, preserving case. For a URL or repository finding,
record its full address in host. Profiles do not propagate facts across DNS,
deployment or URL-host edges and do not attach findings without an address.
Finding fields such as description, status and remediation, and host/service
technologies, are shown when recorded. Missing fields remain unknown. In the
always-in-context ledger block, descriptions and remediation are represented by
ledger op=list pointers, and status and technologies are bounded so these
details do not crowd out host and service facts.
last_recorded_at is the newest linked ledger update; last_observed_at is the
newest explicitly timestamped incident connection observation. A database write
time is not represented as an observation time. Activity describes the current
records and their creation/update times, not earlier versions or all past scans.
Opening a profile writes nothing and does not call tools or models. A missing or
non-resource entry ID fails safely.
Injection and trimming¶
The ledger is serialized and inserted as a system message on every turn, under the
header === FINDINGS LEDGER (working memory — kept verbatim) ===. It is never
written into the conversation history, so compaction never sees it and no
exclusion logic is needed.
Where it lands depends on the provider. Providers that cache a stable prompt prefix — currently Anthropic and DeepSeek — get the ledger as the final message, after the stable history, so a ledger update does not invalidate the cached system-plus-tools-plus-history prefix. Every other provider gets it high, right after the base prompt. The tradeoff is head-position salience against recency salience; the cost of the alternative is re-billing the whole prefix on every turn.
Because the block sits outside compaction, it cannot be reclaimed once it grows — so serialization trims to 16,000 bytes (roughly 4k tokens) and appends a count of what it omitted. Trimming drops individual entries, not whole sections: entries are ordered by section priority, then least-recently-touched first, and that order is walked greedily keeping whatever still fits. The priority order is credentials, flags, findings, next steps, assets and targets, connections, hosts, services, so enumeration gives way before conclusions — but a section can be partially retained, and one oversized high-priority entry can be skipped while smaller lower-priority entries after it are kept.
Nothing is deleted by trimming. op=list still reads everything.
Approval¶
Auto-approved by the packaged rules:
- allow: "ledger({})"
Approval widens to the whole tool — every call writes only to local engagement state. Nothing is executed, nothing is sent anywhere.
Returns¶
| Op | Output | Metadata |
|---|---|---|
add |
Recorded to <section> ledger as entry #<id>., or a notice when an observation prevents storing a declaration or an older route observation. |
entry_id, section; outcome: observation_retained for an ignored declaration or stale_observation_retained for an older observation |
list |
One line per entry: #<id> [<section>] <entry>, plus (artifact#N) where an evidence link exists. Empty ledger says so. |
count |
remove |
Removed ledger entry #<id>. |
entry_id |
profile |
Resource profile JSON for an asset/target entry_id. |
entry_id |
graph |
Bounded JSON page with nodes, edges, and next_offset (null on the last page); both arrays are empty when there are no resources. |
node_count, edge_count (whole graph) |
A remove of an id that does not exist fails. list on an empty ledger succeeds
with count: 0 — nothing recorded yet is an answer, not an error.
If no knowledge base is active for the session, every op fails saying so.
Examples¶
Record a credential with where it works:
{
"op": "add",
"section": "credentials",
"entry": {
"username": "svc_backup",
"host": "10.10.10.5",
"secret": "Backup2024!",
"kind": "smb",
"validated": true
}
}
A finding, linked to the artifact that proves it:
{
"op": "add",
"section": "findings",
"entry": {
"title": "Unauthenticated Solr admin on 8983",
"severity": "high",
"host": "10.10.10.5"
},
"artifact_ref": 42
}
Enrich a host already recorded by hostname — this merges, it does not duplicate:
{
"op": "add",
"section": "hosts",
"entry": { "hostname": "dc01.corp.local", "ip": "10.10.10.5", "os": "Windows Server 2019" }
}
Scope a target:
{ "op": "add", "section": "targets", "entry": { "kind": "url", "value": "https://target.example/app" } }
Record resources before connecting them:
{ "op": "add", "section": "assets", "entry": { "kind": "domain", "value": "api.example.com" } }
{ "op": "add", "section": "targets", "entry": { "kind": "ip", "value": "192.0.2.10" } }
Use the actual returned entry ids. If the domain is #12, the IP is #13, and
artifact#42 contains the DNS observation:
{
"op": "add",
"section": "connections",
"entry": {
"source_id": 12,
"target_id": 13,
"relation": "resolves_to",
"provenance": "observed"
},
"artifact_ref": 42
}
For an explicit deployment declaration, first record the repository:
{ "op": "add", "section": "assets", "entry": { "kind": "repository", "value": "/work/api" } }
If it returns #15, record what the user declared:
{
"op": "add",
"section": "connections",
"entry": {
"source_id": 15,
"target_id": 12,
"relation": "deploys_to",
"provenance": "declared",
"note": "The user confirmed that /work/api deploys to api.example.com."
}
}
For a directly observed TCP connection from a known execution environment to
domain #12, record the real observation time and evidence. This example assumes
artifact#43 contains that connection observation from runner:assessment-a:
{
"op": "add",
"section": "connections",
"entry": {
"source_vantage": "runner:assessment-a",
"target_id": 12,
"relation": "reaches",
"protocol": "tcp",
"port": 443,
"service": "https",
"provenance": "observed",
"observed_at": "2026-10-05T12:30:00Z"
},
"artifact_ref": 43
}
For a configured connection, use provenance: declared with a note explaining
the explicit configuration, and omit observed_at. When the actual source is
already represented by a domain or IP inventory record, replace source_vantage
with its source_id.
Read the recorded connections or the combined graph:
{ "op": "list", "section": "connections" }
{ "op": "graph" }
Read one section, then retract a fact that turned out wrong:
{ "op": "list", "section": "credentials" }
{ "op": "remove", "entry_id": 7 }
Storage and privacy¶
Stored in the same database as recall —
~/.kerkov/engagements/<root-engagement-id>.kb.sqlite, table ledger_entries —
so it inherits the same root-keying, concurrency settings, and lifecycle. A
database created before the ledger existed migrates in place, keeping its
artifacts.
Tree-shared like the knowledge base: delegated agents both write to the ledger and receive the injected copy. Because tools pass by reference, an agent's writes are recorded under the master's engagement id — cosmetic, since the row is tree-shared either way.
Secrets are stored and re-injected unmasked
A recorded credential is kept in cleartext and re-shown to the model in full on every turn. That is the point — a masked credential is not usable — but it means the database and the provider conversation both contain it. Storage is local and Kerkov adds no telemetry, but the injected block is part of every provider request, so against a hosted provider every turn sends the ledger in cleartext.
Related¶
recall— the evidence archive. The ledger holds the conclusion;recallholds the tool output behind it. Link the two withartifact_ref.- Memory guide — what survives compaction, operator-side.
/assetand/target— writing the resource sections yourself, without going through the model.
Observed reachability defaults observed_at to the evidence artifact's server-side
creation time. An explicit observation time must not be later than that artifact.
Use source_id for IP addresses and existing domain resources; source_vantage
is an execution-environment label. Record confirmed access from both the local
runner and pivot hosts, and keep ordinary port scan results in services
instead of duplicating each service as a reachability record.