Epistemic Graph: Service Mode Reference¶
Quick Start¶
# 1. Build the standard server; `full` includes required `server` + `security`.
cargo build --release
# 2. Load secrets/policy from deployment configuration and start durably.
: "${GRAPH_SERVICE_AUTH_SECRET:?required}"
: "${EPISTEMIC_GRAPH_SIGNER_KEYS_JSON:?required}"
: "${GRAPH_SERVICE_PERSIST_DIR:?required}"
: "${GRAPH_SERVICE_SOCKET:?required}"
export EPISTEMIC_GRAPH_AUDIENCE=epistemic-graph
export EPISTEMIC_GRAPH_TENANT=tenant:default
export EPISTEMIC_GRAPH_POLICY_VERSION=policy:initial
epistemic-graph-server
# 3. Use from Python
python -c "
from epistemic_graph import EpistemicGraphClient
import asyncio
async def main():
context = {
'principal': 'service:client',
'tenant': 'tenant:default',
'audience': 'epistemic-graph',
'agent_id': 'service:client',
'roles': ['graph-client'],
'scopes': ['kg:read', 'kg:write'],
'policy_version': 'policy:initial',
'delegation': [],
}
client = await EpistemicGraphClient.connect(verified_context=context)
await client.nodes.add('node:example', {'type': 'Entity'})
print(await client.nodes.has('node:example'))
await client.close()
asyncio.run(main())
"
CLI Reference¶
# Start the service (background daemon)
epistemic-graph-service start [--socket-path PATH] [--tcp-addr HOST:PORT] [--auth-secret SECRET]
# Stop the service (SIGTERM to PID)
epistemic-graph-service stop
# Check if service is running
epistemic-graph-service status
# Send a ping
epistemic-graph-service ping
# List registered graphs
epistemic-graph-service graphs list
Server Binary Arguments¶
| Argument | Env Var | Default | Description |
|---|---|---|---|
--socket-path |
GRAPH_SERVICE_SOCKET |
platform runtime socket | UDS socket path |
--socket-mode |
GRAPH_SERVICE_SOCKET_MODE |
0600 |
Octal mode applied to the UDS socket after bind; refused at startup if malformed or if it will grant world ("other") access |
--tcp-addr |
GRAPH_SERVICE_TCP_ADDR |
None | Optional native TCP listener; a routable address requires TLS |
--tcp-tls-cert / --tcp-tls-key |
GRAPH_SERVICE_TLS_CERT / GRAPH_SERVICE_TLS_KEY |
— | PEM identity required together for routable native TCP |
--tcp-tls-client-ca |
GRAPH_SERVICE_TLS_CLIENT_CA |
— | Optional CA bundle enabling required client certificates |
--auth-secret |
GRAPH_SERVICE_AUTH_SECRET |
— (required) | Non-empty HMAC-SHA256 secret for eg2. envelopes |
| — | EPISTEMIC_GRAPH_REQUIRE_OIDC |
unset ⇒ required (secure by default since 2026-07-22) | OIDC identity binding is mandatory unless explicitly opted out (false/0/no/off); see deployment.md § Migrating to OIDC-required |
| — | EPISTEMIC_GRAPH_OIDC_JWT_ISSUER / EPISTEMIC_GRAPH_OIDC_JWT_AUDIENCE / EPISTEMIC_GRAPH_OIDC_JWKS_URL |
— (required unless opted out) | Keycloak realm issuer / audience / JWKS URL |
--persist-dir |
GRAPH_SERVICE_PERSIST_DIR |
— (required for served mode) | Durable store and replay-ledger directory |
--metrics-addr |
GRAPH_SERVICE_METRICS_ADDR |
None (disabled) | Prometheus /metrics HTTP listener (e.g. 127.0.0.1:9101) |
Every auxiliary listener is loopback-only. A bare enable token or port resolves to loopback, and a non-loopback auxiliary address is rejected at startup.
Prometheus metrics¶
With the metrics cargo feature (on by default) and --metrics-addr /
GRAPH_SERVICE_METRICS_ADDR set, the server exposes Prometheus text-format
metrics over a minimal HTTP listener (src/metrics.rs, CONCEPT:EG-KG.txn.per-graph-write-isolation). The
listener is completely separate from the MessagePack RPC transports, serves
the full registry on any path, and stays disabled unless an address is
configured — so multiple shards never collide on a default port. Recommended
bind: 127.0.0.1:9101 (one port per shard).
| Metric | Type | Labels | Meaning |
|---|---|---|---|
epistemic_graph_requests_total |
counter | op |
Requests dispatched, per protocol operation |
epistemic_graph_request_duration_seconds |
histogram | op |
Dispatch latency (100 µs – 30 s buckets) |
epistemic_graph_in_flight_requests |
gauge | — | Requests currently holding an admission permit |
epistemic_graph_inflight_permits_available |
gauge | — | Admission-semaphore permits remaining. Admission is try-acquire — nothing queues; excess is shed as BUSY (so there is no "waiting" series) |
epistemic_graph_busy_rejections_total |
counter | — | Requests shed with BUSY |
epistemic_graph_graph_ops_total |
counter | graph |
Graph-targeted ops admitted past the ACL |
epistemic_graph_graph_nodes / _edges |
gauge | graph |
Per-graph size, refreshed on mutation (O(1) counts) |
epistemic_graph_auth_failures_total |
counter | — | HMAC authentication rejections |
epistemic_graph_access_denied_total |
counter | — | Isolation-ACL denials |
The graph label is capped at 128 distinct names; graphs beyond the cap
aggregate under __overflow__ (deleting a graph frees its slot), so an
unbounded tenant namespace cannot explode time-series cardinality.
Durable persistence¶
Served mode requires GRAPH_SERVICE_PERSIST_DIR. The redb store is authoritative:
each accepted mutation is committed before acknowledgement and restart recovery reads
that same store. There is no in-memory-only served profile, alternate snapshot format,
checkpoint RPC, or write-behind durability mode.
Identity-managed tenant access¶
When an identity-store principal creates a tenant graph, its tenant read/write
binding belongs to the identity store. The role ID is tenant:<slug> when it
meets identity identifier limits.
Otherwise it is tenant-sha256:<digest>, with the full SHA-256 hex digest of
the exact UTF-8 slug bytes; graph grants retain the original tenant spelling.
The RBAC projection prefixes that role ID with idm:. Use the Identity access
operations to change that role's graph grants or remove a user's role binding. Later identity
updates preserve those decisions. Existing projected roles are not imported.
Replicated CreateGraph carries only an opaque principal fingerprint. If that
value matches a managed principal, the engine withholds automatic tenant binding:
it cannot prove whether the value is that user's original ID or another caller's
fingerprint. Graph creation still succeeds. This also applies to users whose raw
IDs already have the fingerprint format. Provision their tenant access separately
through authorized Identity operations. Local verified creation retains automatic
binding. This does not resolve the broader clustered provisioning/ordering gap.
The RbacAdmin operations continue to manage tenant roles for principals outside
the identity store. They cannot modify the store's idm: namespace.
Wire Protocol¶
Communication uses length-prefixed MessagePack framing (see
src/server.rs::handle_connection and src/protocol.rs), not JSON or
newline delimiting. Each message — in both directions — is:
┌────────────────────────┬──────────────────────────────┐
│ 4-byte big-endian u32 │ MessagePack-encoded body │
│ (body length in bytes) │ (a map; see shapes below) │
└────────────────────────┴──────────────────────────────┘
Because the frame length is explicit, binary payloads containing 0x0A
(newline) bytes round-trip intact — newline framing will corrupt them.
Request Shape¶
The body is a MessagePack map with this structure (shown as Python, which is
exactly what epistemic_graph/client.py::_send packs):
import msgpack
request = {
"id": 1, # u64 correlation id
"graph": "graph:example", # target graph name
"auth_token": "eg2.<claims>.<hmac>", # see Authentication Protocol
"agent_id": "service:client", # must match verified authority
"method": "AddNode",
"params": {
"node_id": "n1",
# node/edge properties travel as nested MessagePack bytes
"properties_msgpack": msgpack.packb({"type": "Agent"}),
},
}
body = msgpack.packb(request)
frame = len(body).to_bytes(4, byteorder="big") + body
agent_id is an authenticated assertion, never authority by itself. The server
derives the effective actor from the checked envelope and rejects a conflict.
Response Shape¶
A MessagePack map with id plus exactly one of result / error:
{"id": 1, "result": "ok"} # success
{"id": 1, "error": "Graph 'unknown' not found"} # failure
{
"id": 7,
"error": "ACCESS_DENIED: agent 'worker2' lacks Write access to graph 'agent:worker1'",
}
Postgres wire transactions (CONCEPT:EG-KG.compute.kg-transaction-is-pinned)¶
When built --features pgwire and bound via EPISTEMIC_GRAPH_PGWIRE_ADDR, the
Postgres wire shim supports BEGIN / COMMIT / ROLLBACK over a mixed-store
transaction that buffers BOTH graph-node DML (INSERT/UPDATE/DELETE over
nodes, including the … SELECT / … FROM join forms) and user-table DDL/DML,
applying them at COMMIT.
- RYOW — a
SELECTinside an open transaction sees the transaction's own uncommitted node writes (the read runs over the live snapshot overlaid with the buffered ops). - Aborted transactions — after any statement inside the block errors, every
later statement except
COMMIT/ROLLBACKis rejected with SQLSTATE25P02("current transaction is aborted…");COMMITwhile aborted behaves asROLLBACK. - One graph per transaction — the transaction is pinned to the graph selected
at
BEGIN;SET graphis rejected while a transaction is open (a transaction stays within one redb shard, CONCEPT:EG-KG.sharding.semantic-embedding-store-backed). ReadyForQuerystatus —BEGIN/COMMIT/ROLLBACKdrive the driver-visibleT(in-transaction) /E(failed) /I(idle) status correctly.
COMMIT durability — sequenced, NOT two-phase (2PC)¶
COMMIT is best-effort ordered across stores. It (a) replays the buffered
graph-node ops as ONE atomic in-memory batch (a single topology write guard) and
records them as ONE durable group in a single redb WriteTransaction, THEN (b) commits the
user-table transaction. Each store commits atomically within itself, but the two
are sequenced — there is a narrow partial-failure window: if the graph group
commits and the user-table commit then fails, the graph writes are durable while
the table writes are not (the COMMIT returns an error and the block ends). This is
an intentional trade-off (no distributed two-phase commit across the two engines).
A transaction confined to a single store (only node ops, or only table ops) has no
cross-store window and is fully atomic.
Authentication Protocol¶
eg2. is the only request envelope accepted by src/server/auth.rs. It binds
the request id, graph, canonical method/body digest, timestamp, nonce,
idempotency key, authenticated principal, tenant, audience, effective agent,
roles, scopes, policy version, and delegation path under HMAC-SHA256. Tokens are
compared in constant time. Unknown prefixes and malformed, stale, replayed, or
policy-mismatched envelopes fail before dispatch.
The server refuses to open a listener unless all of these are true:
- the binary includes the
securityfeature; GRAPH_SERVICE_AUTH_SECRETis non-empty;EPISTEMIC_GRAPH_AUDIENCE,EPISTEMIC_GRAPH_TENANT, andEPISTEMIC_GRAPH_POLICY_VERSIONare non-empty;GRAPH_SERVICE_PERSIST_DIRprovides the durable replay ledger;EPISTEMIC_GRAPH_SIGNER_KEYS_JSONcontains non-empty trusted signer ids and keys;- since 2026-07-22,
EPISTEMIC_GRAPH_REQUIRE_OIDCis not explicitly opted out (false/0/no/off) ANDEPISTEMIC_GRAPH_OIDC_JWT_ISSUER(plus its audience and JWKS URL) is configured — OIDC identity binding is required by default; see deployment.md § Migrating to OIDC-required.
EPISTEMIC_GRAPH_ENVELOPE_SKEW_SECS controls the accepted timestamp window and
replay-retention horizon. Nonce acceptance is committed durably before dispatch,
so a process restart cannot make a captured envelope reusable.
Fresh-store identity bootstrap¶
An empty durable identity/RBAC store does not grant ordinary graph or admin
access. It permits exactly one bootstrap mutation in __commons__: a trusted
signer-backed RegisterIdentity request that registers the envelope's own
principal/effective agent as System, with empty teams and roles, no delegation,
and the single exact scope security:bootstrap. The detached operation signature
must check against EPISTEMIC_GRAPH_SIGNER_KEYS_JSON and its signer id must equal
the checked principal. Once the first rule exists, this bootstrap path closes;
all operations, including later identity administration, require normal durable
RBAC and capability checks.
Remote native TCP also requires TLS; the request envelope authenticates and
authorizes a request but does not provide transport confidentiality. Native
federation signs the same eg2. contract and fails before dialing when its
claims, secret, or TLS boundary are incomplete.
Multi-Graph Management¶
# Create a new agent-scoped graph
await client.tenants.create("agent:researcher", "Agent")
# List all graphs
graphs = await client.tenants.list()
# [{"name": "__bus__", "type": "Bus"}, {"name": "agent:researcher", "type": "Agent"}]
# Target a specific graph for operations
researcher = await EpistemicGraphClient.connect(graph_name="agent:researcher")
await researcher.nodes.add("finding:1", {"content": "..."})
Dynamic Communication Channels¶
1:1 Peer-to-Peer¶
await client.channels.create(
channel_id="channel:p2p:planner:researcher",
channel_type="PeerToPeer",
creator="agent:planner",
initial_members=["agent:researcher"],
)
await client.channels.send_message(
"channel:p2p:planner:researcher", "agent:planner", "Need data on X"
)
msgs = await client.channels.get_messages("channel:p2p:planner:researcher")
Many-to-Many Group¶
await client.channels.create(
channel_id="channel:group:brainstorm-1",
channel_type="Group",
creator="agent:planner",
initial_members=["agent:researcher"],
)
# Other agents can join dynamically
await client.channels.join("channel:group:brainstorm-1", "agent:writer")
# When done, close creates a KG imprint
imprint = await client.channels.close(
"channel:group:brainstorm-1",
topic_metadata="Project brainstorm session #1",
)
Channel Lifecycle¶
On close, the channel records: - All participant agent IDs (preserved as graph edges) - Message count and timestamps - Optional conversation summary embedding - Topic metadata for future retrieval
Isolation Policy¶
ACLs are enforced in dispatch (src/server.rs::check_graph_access calling
src/isolation.rs::check_access) for every graph-targeted operation.
Violations return an ACCESS_DENIED: ... error response.
Enforcement is unconditional:
- Every caller comes from checked
eg2.authority. The unsignedagent_idrequest field cannot establish or change identity. - Native wire session objects may exist before authentication, but they cannot run, enter QoS/admission accounting, evaluate ACLs, or access state until a non-empty checked identity and opaque principal scope are bound. There is no anonymous, graph-name, or empty-string identity bucket.
- An empty durable identity/RBAC store grants no graph access. It admits only
the exact signer-backed
security:bootstrapself-registration described above. - After that first
Systemrule commits, normal durable graph ACL, admin RBAC, and row-level policy govern every request. - Unowned, undecodable, and untagged rows are denied unless explicitly public or authorized by owner/grant policy.
CreateGraphrecords the caller as the graph owner — ownership is what peer-deny and manager-access resolve against.DeleteGraphrequires Write access to the target graph.- Cross-graph reads (
DiffAgainst,Vf2SubgraphMatch) also check Read access on the secondary graph.
| Requester | Target Graph Type | Read | Write |
|---|---|---|---|
| Any agent | __bus__ |
✅ | ✅ |
| Owner | agent:<self> |
✅ | ✅ |
| Peer | agent:<other> |
❌ | ❌ |
| Manager | agent:<subordinate> |
✅ | ✅ |
| Team member | team:<name> |
✅ | ❌ |
| Team manager | team:<name> |
✅ | ✅ |
| Any agent | global:<name> |
✅ | ❌ |
| Unbound transport (before checked identity) | Any graph | Cannot run | Cannot run |