← Qnative (provisional) · c3

research/qevents-payloads.md

qevents on the wire: the payload reference

Read: 2026-11-11, from qcontrol origin/main 9e6cd55a0 (crates/qevents/src, extracted read-only), for c3 Phase 1 (design c138). Line numbers are as of that commit. (inferred) marks the reader's inference; everything else is read from code. Used by: the synthetic stream scripts (crates/fixtures/src/streams.rs) and Guide page 3 (the data modelling).

Anything marked (inferred) is my reading, not stated in code or doc comments.

Notation

MarkMeaning
f: Trequired, always serialized
f?: TOption + skip_serializing_if = is_none: key omitted when absent
f: T | nullOption without skip: key always present, value may be null
f?: [T]Vec + skip_serializing_if = Vec::is_empty: omitted when empty
f?: {..}serde_json::Map + skip_serializing_if = Map::is_empty: omitted when empty
anyarbitrary JSON (serde_json::Value or RawJson)
objectJsonObject = serde_json::Map<String, Value> (agent.rs:16)

Integers: u64/i64/u32/u16/u8/i32/usize are JSON numbers; f64 is a JSON number. Decoding: serde derive treats a missing Option field as None, so T | null fields also decode when the key is missing.


1. Envelope

1.1 Shape (public.rs)

// EventRecord (public.rs:31), #[serde(untagged)]: Entity(EntityRecord) | Host(HostRecord)

// EntityRecord (public.rs:40): one OS process lifetime
{
  "timestamp": "2026-06-02T12:34:57.123456789Z", // Timestamp, required
  "severity":  "info",                           // always serialized; #[serde(default = "Severity::default_value")] -> "info" when missing on decode
  "entity_id": "pid:4321:start:1750000000",      // String, required
  "type":      "agent.tool_call",                // from #[serde(flatten)] event: EntityEvent
  "payload":   { ... }                           //   "
}

// HostRecord (public.rs:54): host inventory, NOT scoped to one process
{ "timestamp": "...", "severity": "info", "type": "installation.discovered", "payload": { ... } }

serde attributes that shape the wire:

WhereAttributeEffect
EventRecord public.rs:30untaggedno wrapper key; decode tries Entity first, so a record without entity_id falls to Host
EntityRecord.event public.rs:48, HostRecord.event public.rs:60flattenthe family's type and payload keys sit at the top level beside timestamp
EntityRecord.severity, HostRecord.severity public.rs:44,58default = "Severity::default_value"decode only: missing becomes info. Always serialized
EntityEvent public.rs:66untagged, order: Process, Mcp, Io, Netstat, Llm, Agent, Diagnostic, PluginPlugin is last "so first-party families win untagged deserialization" (public.rs:77)
HostEvent public.rs:94untagged: Installation, Process
every family enum (ProcessEvent public.rs:84, InstallationEvent :102, ProcessInventoryEvent :118, McpEvent mcp.rs:142, IoEvent io.rs:60, NetstatEvent netstat.rs:119, LlmEvent llm.rs:23, AgentEvent agent.rs:27, RunDiagnosticEvent run_diagnostic.rs:22)tag = "type", content = "payload" + rename = "<family>.<kind>" per variantadjacently tagged: {"type": "...", "payload": {...}}
CustomPluginEvent plugin.rs:21a struct, not an enum: event_type renamed to type, custom deserialize_withtype must equal "plugin.event" on decode

Key order as written by serde_json::to_string: timestamp, severity, entity_id, type, payload (struct field order, flatten emitted in place) (inferred from the field order; JSON consumers should not depend on it).

1.2 timestamp (timestamp.rs:16)

Timestamp(String), #[serde(transparent)]: a bare JSON string. Built only via From<DateTime<Utc>> (timestamp.rs:25-28): to_rfc3339_opts(SecondsFormat::Nanos, true), so producer output always has 9 fractional digits and a Z suffix, e.g. 2026-06-02T12:34:56.123456789Z. Decode does not validate (any string is accepted; tests feed 2026-06-02T12:34:57Z). JSON schema: {"type":"string","format":"date-time"}. The same type is used for process.*.payload.started_at and the run context's started_at.

1.3 entity_id (entity.rs:11)

pub fn process_entity_id(pid: u32, start_unix_secs: u64) -> String { format!("pid:{pid}:start:{start_unix_secs}") }
// "pid:4321:start:1750000000"
  • pid + OS-reported process start time in whole Unix seconds, so PID reuse does not merge lifetimes (entity.rs:1-5).
  • Monitor process.started/process.stopped: built in entity_id_from_started_at (public.rs:344-348), which parses payload.started_at as RFC3339 and uses .timestamp().max(0) (fraction truncated, negative clamped to 0). A bad started_at gives Err("invalid process started_at: ...") from TryFrom<ScanEvent>.
  • Captured events (EventRecord::entity, public.rs:125) take entity_id as the caller passes it. The doc requires the "same pinned start-time read used by the process monitor" (entity.rs:9-10). (inferred) Producers call process_entity_id.
  • process.snapshot is a host record: each entry carries its own entity_id inside payload.processes[] (scan.rs:81).
  • README calls it opaque. Treat it as an opaque join key, even though the format is fixed today.

1.4 severity (severity.rs:22)

enum Severity { Debug, Info, Warn, Error }, rename_all = "lowercase": wire values "debug" | "info" | "warn" | "error". Ordered debug < info < warn < error (derives Ord). Serde decode accepts only those four strings. The "warning" alias exists only in FromStr (severity.rs:62), for CLI parsing. Severity::default_value() = Info (severity.rs:32).

Gating happens at the emit site (severity.rs:6-9, 105-190): SeverityFilter{min} defaults to info, so debug events are not constructed or emitted unless --log-level/QCONTROL_LOG is debug or trace (trace folds into debug). Downstream consumers do not filter.

Where defaults come from:

  • EventRecord::entity(ts, id, ev) (public.rs:125) sets severity = ev.default_severity().
  • EntityEvent::default_severity() (public.rs:224): Info for Process, Mcp, Netstat, Agent, Plugin. Delegates to IoEvent::default_severity (io.rs:152), LlmEvent::default_severity (llm.rs:55), RunDiagnosticEvent::default_severity (run_diagnostic.rs:46).
  • EventRecord::host(ts, ev) (public.rs:138) hard-codes Info.
  • TryFrom<ScanEvent> for EventRecord (public.rs:291) keeps the ScanEvent's severity. ScanEvent::new (scan.rs:393) uses ScanEventKind::default_severity() (scan.rs:464): installation.tap_error = Error, everything else Info. ScanEvent doc (scan.rs:20-21) says "tap skips are warn", but no variant defaults to warn. (inferred) A producer sets it via ScanEvent::new_with_severity (scan.rs:398), presumably on installation.tap_result.
  • RunRecord::event (run.rs:140) uses RunEvent::default_severity() (run.rs:230). Same values, plus run.started = Info (run.rs:250).

1.5 Type index: record kind and default severity

typerecorddefault severitypayload struct
process.startedentityinfoProcessStarted scan.rs:94
process.stoppedentityinfoProcessStopped scan.rs:134
process.snapshothostinfoProcessSnapshot scan.rs:72
installation.discoveredhostinfoInstallationRecord scan.rs:157
installation.detailshostinfoInstallationDetailsRecord scan.rs:205
installation.snapshothostinfoInstallationSnapshot scan.rs:192
installation.tap_resulthostinfo (warn for skips, see 1.4)InstallationTapResult scan.rs:325
installation.tap_errorhosterrorInstallationTapError scan.rs:371
mcp.request / .response / .error / .notification / .session_close / .diagnostic / .oauthentityinfo (all)mcp.rs
file.open / .read / .write / .closeentityinfoio.rs
exec.spawn / exec.exitentityinfoio.rs
connection.open / .update / .closeentityinfoio.rs
connection.mitm_successentitydebugio.rs:468
connection.mitm_failureentityerrorio.rs:487
connection.proxy_errorentityerrorio.rs:512
connection.intake_fallbackentitywarnio.rs:532
http.request / .response / .exchange_closeentityinfoio.rs
sse.open / .event / .closeentityinfoio.rs
websocket.open / .send / .recv / .closeentityinfoio.rs
websocket.traceentitydebugio.rs:948
netstat.snapshotentityinfonetstat.rs:91
llm.request / .response / .usage / .rate_limitentityinfollm.rs
llm.provider_matched / .provider_unmatchedentitydebugllm.rs:83,103
agent.message / .tool_call / .tool_decision / .tool_resultentityinfoagent.rs
run.adapter_errorentitywarnrun_diagnostic.rs:68
run.plugin_load_successentitydebugrun_diagnostic.rs:96
run.plugin_load_failureentityerrorrun_diagnostic.rs:110
plugin.evententityinfoplugin.rs:21
run.startednot on the monitor streaminforun.rs:98 (TryFrom<RunEvent> rejects it, public.rs:286)

run.* diagnostics are "provisional; consumers are instructed not to build integrations around this family yet" (public.rs:74-75).

1.6 Internal run record (not the monitor stream)

qcontrol run NDJSON (RunRecord, run.rs:24) is the internal shape. It has no entity_id and carries a nested run object. CHANGELOG ("unified monitor consumer stream") says the public stream replaced it with entity_id.

{ "timestamp": "...", "severity": "info",
  "run": { "id": "<opaque>", "run_pid": 7, "agent_pid"?: 42, "started_at": "<Timestamp>", "version": "0.1.0",
           "plugins": ["mcp","llm-events"] /* always serialized; default [] on decode */, "agent_id"?: "claude-cli", "installation_id"?: "..." },
  "type": "...", "payload": {...} }   // RunContext run.rs:38; RunEvent untagged run.rs:71 (Started first, Plugin last)

2. Shared sub-objects

// ScanAgent (scan.rs:56)
{ "id": string, "name": string, "vendor": string, "kind": AiSystemKind }
// AiSystemKind (agent_identity.rs:32), snake_case: "cli" | "ide" | "desktop_app" | "vscode_extension" | "other"
// AiMatch (agent_identity.rs:41)
{ "strategy": "executable_name" | "executable_path" | "bundle_path" | "arg_pattern" | "explicit_agent",
  "value": string, "confidence": "low" | "medium" | "high" }

// AgentEventContext (agent.rs:68) and LlmEventContext (llm.rs:126): identical on the wire, separate types
{ "session_id": string, "name": string /* agent family e.g. "claude", "codex" */, "raw"?: object }

// mcp SessionContext (mcp.rs:107): always under payload.session
{ "session_id"?: string /* MCP-Session-Id header; absent while pending, e.g. during initialize */,
  "host"?: string, "transport": "http" | "stdio",
  "protocol_version"?: string, "advertised_server_name"?: string /* serverInfo.name from initialize */ }
// RawJson (mcp.rs:30): serializes as the embedded JSON value (object/array/...), NOT a quoted string

// io Connection (io.rs:317)
{ "id": u64 /* plugin-assigned at open, stable */, "fd": i64 /* in-process proxy fd, not the program's */,
  "transport": "tcp" | "unknown", "direction": "outbound" | "inbound",
  "src": Endpoint, "dst": Endpoint,
  "domain": string | null, "protocol": "http/1" | "http/2" | "unknown" | null,
  "tls": { "enabled": bool, "version": string | null, "cipher": string | null } }   // ConnectionTls io.rs:595
// Endpoint (io.rs:547)
{ "host": string | null, "port": u16 | null }
//   Host (io.rs:558) untagged Ip(IpAddr) | Name(String). Custom Serialize (io.rs:569) writes the text either way.
//   Host::parse (io.rs:581) strips one pair of [] for IPv6 literals; non-IP text is kept verbatim.
// HttpExchange (io.rs:613)
{ "id": u64 /* exchange id, same across request/response/close */, "connection_id": u64 | null /* -> Connection.id */,
  "fd": i64, "stream_id": u32 | null /* HTTP/2 only */, "version": "http/1.0" | "http/1.1" | "http/2" | "unknown" }
// SseStream (io.rs:806) and WebSocketStream (io.rs:863): same shape
{ "http_exchange_id": u64 /* -> http.*.exchange.id */, "connection_id": u64 | null, "fd": i64, "version": HttpVersion }
// HeaderField (io.rs:788)  { "name": string, "value": string }
// HttpRequestRef (io.rs:704) { "method": string, "path": string | null, "raw_target": string | null }

Lenient string enums (io.rs:18-47, custom Deserialize): any unrecognised value decodes to Unknown (serializes as "unknown"). They are io Transport, Protocol, ConnectionUpdateReason, HttpVersion, HttpCloseReason, WebSocketMessageKind and WebSocketCloseInitiator. WebSocketCloseInitiator also accepts client_to_server/server_to_client. Their schema is type: string with examples, not enum. Every other enum is strict serde, including Direction, Tool.kind, and all mcp/llm/agent/scan enums.


3. Payloads by family

3.1 process.* (scan.rs; envelope enums public.rs:84,118)

process.started: ProcessStarted scan.rs:94. Entity record.

{ "pid": u32, "installation_id"?: string, "ppid"?: u32, "exe": string,
  "argv"?: [string], "cwd"?: string, "agent"?: ScanAgent, "matches"?: [AiMatch],
  "started_at": Timestamp /* OS start time */, "rule"?: string /* first matching config rule id */ }

agent is absent for explicitly instrumented generic processes (scan.rs:113-115).

process.stopped: ProcessStopped scan.rs:134. Entity record. Polling sees only that the process is gone, so there is no exit status (scan.rs:131-132). Join adapter and installation context through entity_id.

{ "pid": u32, "exe": string, "started_at": Timestamp, "duration_ms": u64, "rule"?: string }

process.snapshot: ProcessSnapshot scan.rs:72. Host record. Authoritative running set, emitted at startup and on explicit refresh (scan.rs:67-70). Consumers replace their state, then apply deltas.

{ "processes": [ { "entity_id": string, /* + every ProcessStarted field, flattened (scan.rs:83) */ } ] }  // required; [] valid

3.2 installation.* (scan.rs; all host records; envelope public.rs:102)

installation.discovered: InstallationRecord scan.rs:157. An upsert / current-state record.

{ "id"?: string,            // lower-case hex SHA-256 of logical exe path (canonical), filename, ctime, mtime, size (scan.rs:158-170);
                            // absent only when exe metadata unreadable. == process.started.installation_id == run.installation_id
  "agent": ScanAgent, "executable_path": string, "app_path"?: string, "version"?: string, "config_path"?: string,
  "tap": { "status": "tapped" | "not_tapped" | "requires_attention",   // InstallationTapState scan.rs:298, status scan.rs:314
           "rule"?: string, "policies"?: [string] /* Option<Vec>: [] = discovery only; omitted when no rule */ } }

installation.snapshot: InstallationSnapshot scan.rs:192. { "installations": [InstallationRecord] } (required).

installation.details: InstallationDetailsRecord scan.rs:205, with InstallationDetails scan.rs:228 flattened into the payload (no details key). Emitted after .discovered, only for adapters that support inspection. Join on id. Read from config files on disk, never by executing the binary. SECRETS-BEARING (scan.rs:236-238).

{ "id"?: string, "agent": ScanAgent,
  "version"?: string, "default_model"?: string,
  "mcp_servers"?: [ { "name": string, "transport"?: string /* e.g. stdio|http|sse, verbatim */, "command"?: string,
                      "args"?: [string], "url"?: string,
                      "env"?: { ... } /* verbatim INCLUDING VALUES */, "headers"?: { ... } /* verbatim INCLUDING VALUES */ } ],  // McpServerDetail scan.rs:259
  "skills"?: [ { "name": string, "description"?: string } ],    // SkillDetail scan.rs:285
  "plugins"?: [string], "settings"?: { ... } /* agent-specific config keys as found */, "warnings"?: [string] }

Every field empty and no warnings means "installed but unconfigured". Any warnings entry means the sibling fields are partial (scan.rs:222-226).

installation.tap_result: InstallationTapResult scan.rs:325.

{ "id"?: string, "agent": ScanAgent, "target": string /* tap slot: canonical install path */,
  "outcome": "fresh" | "no_op" | "refresh_shim" | "adopt_replaced" | "pending",   // TapOutcome scan.rs:351
  "tap_type"?: string, "policies"?: [string] }

installation.tap_error: InstallationTapError scan.rs:371. No stable error-kind taxonomy (scan.rs:364-369).

{ "id"?: string, "agent": ScanAgent, "target": string, "tap_type"?: string, "policies"?: [string], "error": string }

3.3 mcp.* (mcp.rs; McpEvent mcp.rs:143, #[non_exhaustive])

Every payload carries session: SessionContext (section 2). request_id is the JSON-RPC id, always stringified. exchange_id is the observer's HTTP exchange id, pairing requests and replies independently of MCP sessions.

mcp.request: Request mcp.rs:201

{ "exchange_id"?: u64, "session": {...}, "method": string, "request_id": string,
  "tool_name"?: string /* tools/call */, "prompt_name"?: string /* prompts/get */,
  "resource_uri"?: string /* resources/read|subscribe|unsubscribe */, "params"?: any /* raw JSON-RPC params */ }

mcp.response: Response mcp.rs:234

{ "exchange_id"?: u64, "session": {...}, "method": string | null /* null when uncorrelated */, "request_id": string,
  "tool_name"?: string, "prompt_name"?: string, "resource_uri"?: string,
  "is_error"?: bool /* tools/call result.isError: tool failed, RPC succeeded */, "result"?: any }

mcp.error: McpError mcp.rs:274

{ "exchange_id"?: u64, "session": {...}, "method": string | null, "request_id": string | null /* null when error has no id */,
  "tool_name"?: string, "prompt_name"?: string, "resource_uri"?: string,
  "error": { "code": i64, "message": string, "data"?: any } }        // JsonRpcError mcp.rs:299

mcp.notification: Notification mcp.rs:316. No request_id.

{ "session": {...}, "method": string, "source": "client" | "server", "resource_uri"?: string, "params"?: any }

mcp.session_close: SessionClose mcp.rs:336

{ "session": {...}, "reason": "transport_close" | "delete" | "timeout" }

mcp.diagnostic: Diagnostic mcp.rs:360. category is an internally tagged enum (tag = "category", snake_case, mcp.rs:379) flattened into the payload.

{ "session": {...}, "category": <see below>, /* + category fields */ "phase"?: "request" | "response",
  "request_id"?: string, "body"?: string /* best-effort UTF-8 */ }
// category variants (mcp.rs:380):
//   "transport_failure" + http_status: u16 | "parse_failure" + detail: string | "buffer_limit_exceeded" + bytes: usize
//   "malformed_sse_line" | "duplicate_sse_field" + field: string | "invalid_sse_utf8" | "invalid_sse_retry"
//   "truncated_sse_data" + bytes: usize | "sse_control_event" + event_type: string

mcp.oauth: OAuth mcp.rs:407

{ "session": {...},
  "step": "challenge" | "resource_discovery" | "server_discovery" | "registration" | "token_exchange" | "token_refresh",
  "status": u16, "error"?: string, "error_description"?: string, "resource_metadata_url"?: string, "scope"?: string,
  "authorization_servers"?: [string], "issuer"?: string, "client_id"?: string }

3.4 file.* / exec.* (io.rs; IoEvent io.rs:61, #[non_exhaustive])

FileInfo (io.rs:219) = { "id": u64 | null /* set after a successful tracked open */, "fd": i64, "path": string /* at open time */ }. Metadata only, no file bytes (io.rs:217).

typestructpayload
file.openFileOpen io.rs:230{ file, flags: i64, mode: u64, result: i64 /* fd or negative */ }
file.readFileRead io.rs:243{ file, count: u64 /* requested */, result: i64 }
file.writeFileWrite io.rs:253{ file, count: u64, result: i64 }
file.closeFileClose io.rs:263{ file, result: i64 }
exec.spawnExecSpawn io.rs:275{ id: u64, path: string, argv: [string] /* incl argv[0], always present */, cwd: string | null }
exec.exitExecExit io.rs:288{ id: u64 /* = exec.spawn.id */, path: string, pid: i64, status: ExitStatus }

ExitStatus (io.rs:301) is internally tagged (tag = "kind", snake_case): { "kind": "exited", "code": i32 } | { "kind": "signaled", "signal": i32 }.

3.5 connection.* (io.rs)

typestructpayload
connection.openConnectionOpen io.rs:435{ connection: Connection }
connection.updateConnectionUpdate io.rs:445{ reason: "tls" | "domain" | "protocol" | "unknown", connection } (full snapshot)
connection.closeConnectionClose io.rs:454{ connection, result: i64 /* close() return */ }
connection.mitm_successio.rs:468{ connection, sni?: string, upstream?: string /* host:port */, alpn?: string }
connection.mitm_failureio.rs:487{ connection, sni?: string, upstream?: string, stage: string /* upstream_tls, downstream_tls, alpn_mismatch, ... */, reason: string /* unstable text */ }
connection.proxy_errorio.rs:512{ connection, upstream?: string, stage: string /* upstream_connect, runtime_session_open, listener_accept, ... */, reason: string }
connection.intake_fallbackio.rs:532{ connection, upstream?: string, from: string /* e.g. tls_mitm */, to: string /* e.g. raw_connect */, reason: string }

3.6 http.* (io.rs)

http.request: HttpRequest io.rs:683

{ "exchange": HttpExchange,
  "connection": { "domain": string | null, "protocol": "http/1" | "http/2" | "unknown" | null,
                  "tls": { "enabled": bool, "version": string | null } },   // HttpConnection io.rs:663, HttpTls io.rs:674 (no cipher)
  "method": string, "scheme": string | null, "authority": string | null /* host[:port] */,
  "path": string | null /* decoded */, "raw_target": string | null, "headers": [HeaderField] }

http.response: HttpResponse io.rs:715

{ "exchange": HttpExchange, "request_ref": HttpRequestRef, "status_code": u16, "headers": [HeaderField] }

http.exchange_close: HttpExchangeClose io.rs:772. One per exchange.

{ "exchange": HttpExchange, "request_ref": HttpRequestRef,
  "reason": "complete" | "aborted" | "parse_error" | "connection_closed" | "upgraded" | "unknown",   // HttpCloseReason io.rs:728
  "request_done": bool, "response_done": bool, "status_code": u16 | null }

3.7 sse.* (io.rs). No event data bytes (io.rs:836-838)

typestructpayload
sse.openSseOpen io.rs:820{ stream: SseStream, content_type: string }
sse.eventSseEvent io.rs:828{ stream, event_name: string | null, id: string | null, retry_ms: u64 | null, data_byte_len: u64 }
sse.closeSseClose io.rs:843{ stream, reason: HttpCloseReason }

3.8 websocket.* (io.rs). No message bytes

typestructpayload
websocket.openWebSocketOpen io.rs:877{ stream: WebSocketStream, subprotocol: string | null, extensions: string | null }
websocket.sendWebSocketMessage io.rs:890{ stream, kind: "text" | "binary" | "unknown", byte_len: u64 } (client to server)
websocket.recvWebSocketMessage io.rs:890same shape (server to client)
websocket.closeWebSocketClose io.rs:930{ stream, initiator: "client" | "server" | "unknown", code: u16 | null, reason: string | null, clean: bool }

websocket.trace: WebSocketTrace io.rs:948. Debug only, metadata only, no headers or bytes (io.rs:942-946). Every optional field is omitted, not null.

{ "stage": string /* observer_attached, frame_parse_failed, ... */, "connection"?: Connection, "stream"?: WebSocketStream,
  "direction"?: string /* client_to_server|server_to_client */, "path"?: string, "status_code"?: u16, "byte_len"?: u64,
  "buffered_len"?: u64, "opcode"?: u8, "compressed"?: bool, "extensions"?: string,
  "outcome"?: string /* attached|opened|skipped|failed|finished */, "reason"?: string }

3.9 netstat.snapshot (netstat.rs; NetstatEvent netstat.rs:120)

NetstatSnapshot netstat.rs:91. One process-wide TCP/UDP sample from inside the wrapped process. This is not a connection.* lifecycle event.

{ "sequence": u64 /* from 1; a gap = a dropped snapshot */, "pid": u32 /* publisher pid */, "duration_ns": u64,
  "window": Traffic /* since previous snapshot */, "total": Traffic /* since publisher start */,
  "send_bps": u64, "recv_bps": u64,
  "connections": [ {                                  // NetstatConnection netstat.rs:61; REQUIRED, [] valid; null or missing fails decode
      "protocol": "tcp" | "udp", "local_addr": string | null, "local_port": u16 /* 0 = unknown */,
      "remote_addr": string | null, "remote_port": u16, "window": Traffic, "total": Traffic,
      "send_bps": u64, "recv_bps": u64 } ] }          // rows ordered by window bytes, largest first (netstat.rs:53)
// Traffic = NetstatTraffic netstat.rs:37: { "bytes_sent": u64, "bytes_recv": u64, "send_fail": u64, "recv_fail": u64 }

3.10 llm.* (llm.rs; LlmEvent llm.rs:24)

Every payload except the provider diagnostics has context: LlmEventContext (section 2).

llm.request: Request llm.rs:159

{ "context": {...}, "model": string, "request_id"?: string, "system_instructions"?: string }

llm.response: Response llm.rs:175

{ "context": {...}, "model"?: string, "request_id"?: string,
  "phase": "completed" | "error" | "transport_error",            // ResponsePhase llm.rs:301
  "duration_ms"?: u64, "input_tokens"?: u64, "output_tokens"?: u64 /* excl. reasoning */, "reasoning_tokens"?: u64,
  "cache_read_tokens"?: u64, "cache_write_tokens"?: u64, "cost_usd"?: f64, "error"?: string }

llm.usage: Usage llm.rs:214

{ "context": {...}, "model"?: string, "request_id"?: string, "input_tokens"?: u64, "output_tokens"?: u64,
  "reasoning_tokens"?: u64, "cache_read_tokens"?: u64, "cache_write_tokens"?: u64, "total_tokens"?: u64,
  "model_context_window"?: u64, "raw"?: object /* "safe to surface" */ }

llm.rate_limit: RateLimit llm.rs:251

{ "context": {...}, "request_id"?: string, "scheme": string /* e.g. openai_http, codex_app_server */,
  "buckets": { "<bucket name>": {                               // BTreeMap: required object, keys sorted
      "limit"?: f64, "remaining"?: f64, "reset_unix"?: u64, "status"?: string, "utilization"?: f64 /* 0.0..=1.0 */,
      "used_percent"?: f64, "window_duration_mins"?: u64 } },  // RateLimitBucket llm.rs:274
  "plan_type"?: string, "rate_limit_reached_type"?: string, "raw"?: object }

llm.provider_matched: LlmProviderMatched llm.rs:83. Debug. No context, no session.

{ "provider": string /* anthropic, openai */, "request_kind": string /* = qllm::RequestKind::label, e.g. anthropic_messages */,
  "method"?: string, "path"?: string }

llm.provider_unmatched: LlmProviderUnmatched llm.rs:103. Debug. { "method"?: string, "path"?: string, "host"?: string }

3.11 agent.* (agent.rs; AgentEvent agent.rs:28)

Every payload has context: AgentEventContext (section 2). No agent.session_start yet (TODO agent.rs:29).

Tool (agent.rs:109) is internally tagged (tag = "kind", rename_all = "snake_case"), with a strict decode (an unknown kind fails):

{ "kind": "builtin", "name": string }                   // Bash, exec_command, ...
{ "kind": "mcp", "server": string, "name": string }     // server = user's config alias from mcp__<alias>__<tool>, NOT mcp session.advertised_server_name (mcp.rs:124-127)
{ "kind": "skill", "name": string, "path"?: string }    // defined but not emitted yet (TODO agent.rs:122)
{ "kind": "unknown" }                                   // tool could not be classified
typestructpayload
agent.messageMessage agent.rs:80{ context, role: "user" | "assistant" | "system" | "tool", prompt?: string /* after adapter redaction */, prompt_char_length?: u64 }
agent.tool_callToolCall agent.rs:192{ context, tool: Tool, call_id?: string, arguments?: object }
agent.tool_decisionToolDecision agent.rs:207{ context, tool, call_id?: string, decision: "approved" | "denied", source: string /* config, user, policy */ }
agent.tool_resultToolResult agent.rs:239{ context, tool, call_id?: string, success: bool, duration_ms?: u64, output?: [ { "type": "text", "text": string } ], structured_output?: any }

output blocks: ToolResultOutputBlock agent.rs:232, internally tagged tag = "type". Only text exists.

3.12 run.* diagnostics (run_diagnostic.rs; RunDiagnosticEvent run_diagnostic.rs:23). Provisional, entity records

typestructpayload
run.adapter_errorAdapterError run_diagnostic.rs:68{ source: string /* plugin_socket_decode, plugin_socket_read, mitm_revert, ... */, reason: string /* bounded: json_decode, socket_read, socket_accept, ... */, byte_len?: u32, sample_hash?: string /* short non-crypto hex */ }
run.plugin_load_successPluginLoadSuccess run_diagnostic.rs:96{ name: string /* as in QCONTROL_PLUGINS */, path: string }
run.plugin_load_failurePluginLoadFailure run_diagnostic.rs:110{ name, path, reason: string }

run.started (run stream only, RunStarted run.rs:98): { exe, cmd /* argv0 */, args: [string] /* always serialized */, cwd, agent?: { id, name, vendor, kind: AiSystemKind, matches: [ {strategy, value, confidence} ] } }. RunAgentAttribution is at run.rs:115 and RunAgentMatch at run.rs:130. No pid; the pid is in run.agent_pid.

3.13 plugin.event (plugin.rs)

CustomPluginEvent plugin.rs:21. The fallback for any plugin-socket type outside the reserved prefixes.

{ "type": "plugin.event",                    // const PLUGIN_EVENT_TYPE plugin.rs:13; decode rejects any other value
  "payload": { "plugin_name": string, "event": string /* plugin-defined type, e.g. custom.foo */,
               "data": any /* plugin's payload, unmodified; decode also accepts legacy key "payload" (plugin.rs:76) */ } }

CHANGELOG "Pending - Custom plugin data": the rename payload.payload to payload.data is a breaking output change. Serialization emits only data. Readers should accept both: use data when the key exists, else payload.


4. Correlation keys: where they sit

KeyLocationJoins
entity_idenvelope (entity records); process.snapshot.payload.processes[].entity_ideverything from one process lifetime
installation_idprocess.started.payload.installation_id; run.installation_id (run stream)installation.*.payload.id
idinstallation.{discovered,details,tap_result,tap_error}.payload.idsame SHA-256 install identity
session_idmcp.*: payload.session.session_id (optional); agent.* / llm.* (except provider_*): payload.context.session_id (required)conversation or MCP session
call_idagent.tool_call / .tool_decision / .tool_result payload.call_idtool lifecycle
request_idmcp.request/.response (string), mcp.error (string or null), mcp.diagnostic (optional); llm.request/.response/.usage/.rate_limit (optional)JSON-RPC id / provider request
exchange_idmcp.request/.response/.error payload.exchange_id (u64, optional)observer HTTP exchange (inferred): same id space as http.*.exchange.id, not stated
exchange.idhttp.*.payload.exchange.idsse.*.stream.http_exchange_id, websocket.*.stream.http_exchange_id
connection.idconnection.*.payload.connection.idhttp.*.exchange.connection_id, sse/websocket.*.stream.connection_id
fdconnection.fd, exchange.fd, stream.fdproxy-side handle. IoEvent::connection_fd() io.rs:173 unifies these
file.idfile.*.payload.file.idopen/read/write/close of one file session
exec idexec.spawn.payload.id = exec.exit.payload.idchild spawn/exit; exec.exit.pid is the child pid
sequence, pidnetstat.snapshot.payloadgaps mean dropped samples

5. Constructors and helpers

  • EventRecord::entity(ts, entity_id, EntityEvent) public.rs:125 sets severity to the event's default. EventRecord::host(ts, HostEvent) public.rs:138 sets severity to info.
  • EventRecord::{entity_id() -> Option<&str>, event_type() -> &str, timestamp(), severity()} public.rs:146-174.
  • EventRecord::session_id() public.rs:179: mcp.* gives payload.session.session_id (Option), agent.* gives payload.context.session_id (always Some), llm.* gives the context session (None for provider_matched/unmatched). Everything else gives None.
  • EventRecord::installation_id() public.rs:199: only process.started gives payload.installation_id. Everything else, including installation.*, gives None.
  • EntityEvent::{event_type, default_severity} public.rs:211,224. Per-family event_type() on every enum.
  • TryFrom<ScanEvent> for EventRecord public.rs:291 computes entity_id from started_at. TryFrom<RunEvent> for EntityEvent public.rs:274 rejects only run.started.
  • process_entity_id(pid, start_unix_secs) entity.rs:11.
  • ScanEvent::{new, new_with_severity, installation_discovered, installation_details, installation_snapshot, tap_result, tap_error, process_snapshot, process_started, process_stopped} scan.rs:393-443.
  • McpEvent::{session(), session_id()} mcp.rs:175,189. AgentEvent::session_id() -> &str agent.rs:53. LlmEvent::session_id() -> Option<&str> llm.rs:66.
  • IoEvent::connection_fd() -> Option<i64> io.rs:173. It is None for file/exec. websocket.trace uses stream.fd, else connection.fd.
  • Tool::builtin(name), Tool::display_name() (name, server/name, skill:name, <unknown>), Tool::parse_wire_name("mcp__srv__tool") agent.rs:150-179.
  • Host::parse(text) io.rs:581. RawJson(String) mcp.rs:30.
  • CustomPluginEvent::{from_parts, plugin_name, typ, data, payload} plugin.rs:31-55. CustomPluginPayload::new plugin.rs:81.
  • RunRecord::{event, event_with_severity, started, from_line}, RunContext::new(..).with_agent_pid/with_plugins/with_agent_id/with_installation_id run.rs:140-213.
  • Severity: filter_from_log_level(Option<&str>), SeverityFilter::{new, enabled, min}, install_global_filter, global_filter severity.rs:105-190.
  • LlmEventContext and AgentEventContext convert both ways with From (llm.rs:136-154).

Plugin-socket ingress (plugin_socket.rs; input side, not the public output):

  • Lines are {plugin_name, type, payload} (PluginEvent :62). PluginSocketRecord::{from_line, from_value, from_wire} :151-234 drop empty names or types and null payloads.
  • Reserved prefixes (:28) are mcp. file. exec. connection. http. sse. websocket. netstat. agent. internal: (each must decode as a typed event or be dropped) and llm. run. plugin. (always dropped). Any other type is wrapped as plugin.event.
  • internal:qproxy.{mitm_success,mitm_failure,proxy_error,intake_fallback,websocket_trace} (QproxyDiagnosticEvent :275) is accepted only from plugin_name == "qproxy" and becomes the public connection.* / websocket.trace. internal:llm.* is accepted only from "llm-events" (:240). Those become qllm::ProviderEvent / LlmDiagnosticEvent (defined in the qllm crate, not here), which adapters map to llm.* / agent.*.

6. Secret-bearing and content-bearing fields

Documented in code:

  • installation.details is "a secrets-bearing artifact" (scan.rs:236-238). mcp_servers[].env and mcp_servers[].headers are verbatim, values included (scan.rs:275-280; the test uses GITHUB_TOKEN: "ghp_secret", scan.rs:607,636). settings is agent config keyed as found, which (inferred) can also hold credentials.
  • run.adapter_error deliberately never echoes failed socket lines. It carries byte_len + sample_hash instead (run_diagnostic.rs:62-66).
  • websocket.trace excludes headers and bytes "that can contain credentials or user content" (io.rs:942-946).
  • File, SSE and WebSocket events carry lengths, not bytes (io.rs:217, 836-838, 894-895).
  • llm.usage.raw and llm.rate_limit.raw are described as "safe to surface" (llm.rs:244,267).
  • agent.message.prompt is text "after the adapter applies the agent's redaction policy" (agent.rs:85). It is still user content.

Not documented as secret, but content-bearing (inferred):

  • http.request.headers / http.response.headers are name/value pairs with no redaction stated (Authorization, Cookie).
  • mcp.* params / result / error.data are verbatim RawJson passthrough. mcp.diagnostic.body is a raw body excerpt.
  • agent.tool_call.arguments, agent.tool_result.output / structured_output, llm.request.system_instructions, and context.raw (agent/llm).
  • exec.spawn.argv and process.started.argv (command lines can carry tokens). mcp.oauth.client_id and scope.
  • Also for JS/TS consumers: u64 ids (exchange_id, connection.id, exchange.id, netstat counters) can exceed 2^53.

Qpoint Brand Style Guide