Docs MCP Server Design Notes
MCP Server Design Notes
On this page
- Notes for MCP server
- Notes for MCP tool reference
- Notes for MCP protocol
- Notes for MCP deployment
- Notes for Run MCP across an estate
- Notes for core bundle
- Notes for signaling bundle
- Notes for media bundle
- Notes for captures bundle
- Notes for security bundle
- Notes for server bundle
- Notes for TFPS bundle
- Notes for tls bundle
- Notes for vCon bundle
This page records why the MCP server behaves as it does: the reason behind a
limit, refusal or response shape, the history of a change, and the internal
names that implement it. The operator pages under docs/mcp*.md say what to
run and what it returns. This page is for someone changing the code.
Notes for MCP server
Input schema type unions and --strict
schemars renders an optional field as "type": ["string","null"]. That is
legal JSON Schema, and several clients cannot read it: they take type as a
single string and either drop the constraint or refuse the whole tool. sipnab
collapses those unions on input schemas, where a client validates arguments
before calling and a refusal costs the tool. Output schemas keep them,
because sipnab writes an explicit null for an absent optional field.
Collapsing there would advertise a schema its own responses violate.
No CI job runs inspector --cli ... --strict.
no_input_schema_advertises_a_spelling_a_strict_client_may_refuse checks the
same property offline against the live wire, with no network and no package
manager. Run the Inspector command by hand when Inspector’s rules change.
Tool table completeness gate
mcp_tool_table_lists_every_registered_tool in tests/docs_drift_test.rs
fails the build when docs/mcp-tools.md stops naming every registered tool.
Why mcp is not a default feature
The default build excludes mcp so an operator who never exposes the MCP
surface pays no binary size for it.
Why sipnab refuses stdout-writing flags with --mcp
With stdio, stdout is the JSON-RPC wire. Allowing --json or --report
would corrupt the wire with report text and leave the client to fail later on
malformed JSON-RPC, so sipnab refuses the combination at startup.
Notes for MCP tool reference
Bundle size table and its byte-budget test
The “Bytes (default)” and “Bytes with output schemas” table in
docs/mcp-tools.md#choosing-which-tools-load is the compact JSON of each
tool’s tools/list entry, summed per bundle, on a build with every feature.
The unit test each_bundle_stays_within_its_byte_budget in
src/mcp/server.rs fails when a bundle grows past its recorded ceiling.
Update the table in the same change that moves a ceiling. The 0.5.221
measurement came after the filter parameters gained “Values may be bare or
quoted.” The 0.5.222 figures are byte-identical.
Output schemas are off by default because MCP makes them optional and they
are more than half the bytes of tools/list (89,667 bytes without, 270,810
with, at 0.5.222).
Why every capture-derived tool carries source_exhausted
A file source loads on a background thread, so an agent’s first call lands
inside a window a human client never sees. Measured on 0.5.130: on a 921 MB
capture, list_dialogs answered with 6 of 18,241 dialogs. Before that change
only tail_dialogs and capture_status carried source_exhausted. Every
tool that answers from the capture now does, and sipnab omits
truncated: false until the answer is whole, because false claims the page
holds every match. timeline was the last tool returning a bare array. Its
answer became an object so it could carry the two booleans.
Rules behind the fields projection
The MCP surface bounded rows three ways (limit, --mcp-max-rows, cursors)
and columns not at all, so a page fetched to read two values paid for every
other value on every row. fields closes that.
call_idalways survives because every follow-up tool takes a Call-ID. A row an agent cannot address is a row it can do nothing with.- An unknown name fails rather than returning rows without the field, because a silently missing field reads as “no such data”, a wrong answer rather than an error.
- The envelope (
total_matched,truncated,next_cursor,capture_identity) is never projected: those fields are how a caller knows what it did not get. - The projection runs on the serialized page, so
skip_serializing_ifstill decides which fields exist. An absent field does not reappear asnull.
File-root symlink resolution and the overwrite guard
Bare-name checking alone does not hold the --mcp-file-root boundary: a
symlink already in the root is a single bare component, passes every name
check, and the kernel follows it on open. sipnab therefore compares the fully
resolved path against the fully resolved root. That escape needed prior write
access inside the root, so it was never a remote break. The fix shipped
because the reference describes the boundary as absolute.
The overwrite refusal covers any name already taken in the root
(resolve_in_root_for_write in src/mcp/server.rs, using
symlink_metadata so a dangling link still counts as taken).
protected_inputs alone knew only the files the server opened, so a capture
staged in the root for open_capture, or an earlier export, could disappear
under a name collision on a call that reported success.
security_findings.kinds refusal
security_findings.kinds used to accept any string and match nothing, so a
name outside scanner, fraud, digest, reg_flood answered [], the
same bytes a quiet capture returns. reg-flood (the --alert grammar’s
spelling) was the case that caused it. The refusal now lists the vocabulary
and suggests reg_flood by name.
Default of --mcp-max-response-bytes
The 1048576-byte default is under twice the largest response measured over
the sample captures in tests/pcap-samples: 561,420 bytes, list_dialogs
with limit: 1000 on sipp-branch-scenario.pcapng. With the default, no
answer on those captures changes. The largest at the default limit was
52,809 bytes, a get_dialog page on DTMFsipinfo.pcap.
Prompts hidden when a tool is not loaded
prompts/list omits a prompt whose tools are not all registered, rather than
serving it with a note, because a step naming a tool the client cannot call
fails with “tool not found” in the middle of the workflow.
Notes for MCP protocol
Security model: the design reasons
- No tool edits the analysis in place. The rule is narrower than
“read-only” on purpose. Ending a session is visible, and a swap mints a new
capture_identitythat every later answer carries. Rewriting the evidence underneath someone mid-incident is the failure both of those exist to make impossible. - Token comparison. sipnab compares tokens in constant time via the shared
crypto::constant_time_eqhelper, throughauth::TokenVerifier. This is the same code path as the REST API. - One challenge for every rejected credential. Telling expired from revoked
from forged in the
WWW-Authenticateheader would make the header an oracle. A request that presented nothing carries no error code, as RFC 6750 section 3.1 asks, which lets an operator tell a misconfigured client from a wrong token. - No
authorization_serversin the RFC 9728 document. sipnab issues and validates no OAuth tokens. A client sent to fetch one elsewhere would return holding a credential this server rejects. The operator names the URL because behind a TLS-terminating proxy sipnab cannot see the scheme a client used, and RFC 9728 section 3.3 makes a client discard a document whoseresourcedoes not match the URL it requested. - Two caps, refused rather than queued.
--mcp-max-concurrentand--mcp-rate-limit-per-peerare different bounds. An agent that never exceeds the concurrency cap and loops as fast as sipnab answers holds a single slot forever, and only the rate limit stops it. A queue behind either cap is the same resource exhaustion, deferred. Over HTTP the peer is the address, not the socket, so reconnecting mints no fresh allowance. On a shared egress every client behind one address shares one allowance. That is the consequence of rate-limiting what the transport can prove rather than what the caller claims. The per-peer accounting is the same code that meters HEP senders for--hep-rate-limit-per-peer. - Privilege drop. The MCP listener binds after
privilege::drop_privileges(called fromsrc/app/bootstrap.rs). The default port 8731 is at or above 1024 to permit this.
Audit record: the design reasons
- The record includes refused calls. An agent probing for tools that do not exist
is exactly the traffic the record exists to show. The running
N refused since starttotal on a rate-limit refusal separates one confused client from a flood. token=<id>. Two agents on one host present two tokens from one address, and the socket alone does not tell them apart. sipnab percent-encodes the id so an id carrying a space, a quote or a newline cannot forge a field or a line. It cuts an id over 64 characters and marks it…(truncated)(CAPinsrc/mcp/server.rs) so a prefix never reads as a whole id.- Why a file as well as a log line. The log line is a console view:
SIPNAB_LOGfilters it,--quietsuppresses it, and it interleaves with everything else. Those are the right properties for something a person watches and the wrong ones for something produced later: whoever asks “what did the agent look at” after the fact did not choose the log level. - JSON lines. Same shape as
--alert-json, for the same reason:serde_jsonescapes every value, so a crafted header reaching the record throughargscannot end the line or forge a field.argsis a bounded string byte-identical to the log line, including where the bound fell, so the two can never disagree.erroris always present so a reader never has to tell.errorapart from an absent key.seqexists because nothing else makes a missing record detectable. - Write path (
src/mcp/audit.rs). OpenedO_APPEND, created0600when absent (the record carries tool arguments). Onewrite_allper record with the newline attached, under a mutex, to an unbuffered file, so concurrent calls interleave whole records. - No
fsync. Anfsyncper tool call would buy machine-crash durability for a record whose realistic threat is a process that stopped. A record on disk survives panic, signal andshutdown_server, not a power loss before the page cache flushes. - Refuse what the file cannot hold. No result leaves the server that is not in the file, so an operator who finds no record can conclude the call did not happen, rather than that writing the record failed.
- Known gap. Nothing outside
tools/callreaches the audit point:resources/*,completion/complete, subscribe/unsubscribe and the prompt methods. The tracing record has the same gap.
Resources, live views and completions: design reasons
- Why file resources exist.
export_capturereturns a server-local absolute path. That works over stdio and does nothing over HTTP, where the client has no access to the server’s filesystem: the tool reports success and the agent still cannot obtain the bytes. The protocol makes a resource read-only by construction, and a host can grant resource reads without granting tool calls, which no tool annotation can express. - One sandbox. The resource URI goes through the same single-component check and symlink resolution as the file tools, so there is one sandbox, not two that can drift apart.
- 8 MiB per read (
MAX_RESOURCE_BYTESinsrc/mcp/server.rs). JSON-RPC carrying base64 is not a bulk transfer channel: a 128 MB capture becomes roughly 170 MB of one JSON string. Non-UTF-8 files come back as base64 because a lossy conversion would hand the model something the file does not contain. - One renderer. The live views render through the same code path as
list_dialogs, so an operator never holds two versions of the same capture with no way to decide which to believe. The same function buildssipnab://lint/<rule-id>andexplain_rule, andsipnab://filter/<alias>uses the sameexpand_aliasthatfind_problemscompiles. - Unknown Call-ID is an empty page. A live view’s content is what the capture holds right now, and “right now, nothing” is an answer. A subscriber watching a call that has not started, or that the store evicted, needs the URI to have defined content on both sides of the change.
- Completions are not cached. A completion that offered an evicted Call-ID would cost an agent a call to discover, and the agent would conclude the capture was wrong rather than the completion.
- Hidden file template. A template promises the URI resolves. Advertising
one whose every URI would draw a refusal tells an agent captures are
reachable, and it finds out otherwise one call at a time. The same reasoning
hides the vCon template in a build without
vcon. - Unknown completion argument. The empty answer is the specification’s rule. Answering an unknown argument name with another argument’s vocabulary would make a client fill that argument with it.
- Completion ceiling. 100 values (
MAX_VALUESinsrc/mcp/completion.rs), lowered by--mcp-max-rows. A completion reaches the store without the audit line, scope check or rate limittools/callapplies, and a Call-ID is the most identifying row sipnab holds, so one ceiling serves both doors.
Design reasons for subscriptions
- Only live views. A reference page cannot change during the run, and a
file under
--mcp-file-rootchanges only when something outside sipnab writes it. sipnab is a packet analyzer, not a filesystem watcher. Accepting either would be a subscription that can never fire, which a client cannot tell from a quiet network. - Change detection. First the dialog store’s revision counter (an unchanged
counter proves unchanged content and costs one
u64read), then a digest of the rendered content. RTP moves the stream store, so audio never wakes a dialog-list subscriber. A revision bump that leaves the rendering identical sends nothing. - One-second debounce (
DEBOUNCEinsrc/mcp/subscribe.rs). The notification carries no data. Its whole value is oneresources/readround trip through a model on the client side. A shorter interval would send the second notification before the client could act on the first. A capture doing hundreds of calls a second mutates the store thousands of times a second, so one notification per mutation would be a denial of service. - Per-connection state.
MAX_SUBSCRIPTIONS= 16. There is no registry that outlives a connection. A watcher holds a weak handle, so a client that disappears without unsubscribing takes its watchers with it and nothing has to notice the disconnect.
Write verbs and confirmation: design reasons
- Annotation gate. A test walks the registered router and fails if any
tool carries no
readOnlyHint, or if the set of non-read-only tools stops matching the write-verb table in docs/mcp-protocol.md (tests/docs_drift_test.rsalso pins the counts in that page). compare_capturesis not read-only. It changes no state a later answer depends on, but reading through the shared pipeline bumps the process-wide undecodable talliesget_capture_reportreports, and a tool whose effects stay invisible in its own answer should not call itself read-only.build_evidence_packagerefuses an existing name so a second call fails instead of replacing evidence someone may already have sent.- Why elicitation. Before it, a convention guarded
shutdown_serverandopen_capture:dry_rundefaulted to the safe value, so stopping took a deliberate second call. That has a structural hole: the second call comes from the same agent on the same reasoning. It rate-limits an accident. It does not introduce a second party. - A dry run asks nobody. A confirmation with no consequence behind it teaches whoever reads it to click through the next one.
- No capability is not a refusal. Elicitation is a client capability.
Reading “nobody to ask” as “they said no” would make
shutdown_serverimpossible on every stock client. open_capturechecks before and after asking. It settles its three refusals (live source, load in flight, source not drained) before it asks, and again inside the lock that performs the swap. Asking first and refusing afterwards would put the question on a false premise.
Untrusted capture text: design reasons
- Identifiers verbatim. Wrapping a Call-ID, cursor or address turns a working round trip into a lookup miss. The provenance note says they are attacker-chosen too, so the omission does not look accidental.
get_dialoghistory. It used to fence itsdialogsummary while itsmessages[]array, the largest block of sender-written text on the surface, carried no markers and no note explained the absence. It now fences both halves message by message, with the field/body splitget_messageuses.- Reports carry a note, not markers. Fencing a mixed document would tell the agent to distrust sipnab’s own analysis too.
- Fence code points. U+27E6 and U+27E7 carry no meaning in SIP, which makes
rewriting them to ASCII
[]before wrapping affordable. - Bidi controls (
BIDI_CONTROLSinsrc/mcp/shape.rs) are Unicode categoryCf, notCc, so a control-only strip misses them. They are the “Trojan Source” shape aimed at an audit transcript. - Bodies keep
\nand\t. An agent diagnosing one-way audio readsa=lines. Destroying that to harden a boundary that already holds is the wrong trade. A field is single-line by construction after RFC 3261 unfolding. - 256-byte field cap (
MAX_FIELD_BYTESinsrc/mcp/shape.rs). No RFC bounds a display name orUser-Agent, so uncapped, one header is as much room to write instructions as the sender cares to spend, in a context window the operator pays for. 256 clears every honest value: the longestUser-Agentin sipnab’s fixtures is 49 bytes, browser UAs reach about 150. It is about three lines of prose. - The operator page documents the residue because a defense described as
total is one nobody checks. Diagnosis
hintsthat interpolate aReasonheader are not fenced. - Note is the last content block. The markers are inline, so placing the note after the data costs nothing; putting it first would have broken every client that indexes block 0.
Result envelope and stdio: design reasons
- sipnab attaches
structuredContentcentrally, on the way out of every tool call, not per tool: fifty-odd tools build results in fifty-odd places, and a per-tool helper is a rule the next author has to remember. sipnab parses it from the text block rather than serializing a second time, so there is no second serialization to drift. - No
structuredContentfor rendered documents. MCP types the field as an object. Wrapping a document in an invented key would put a shape instructuredContentthat the text block does not have. - stdio. All logging goes through
tracing-subscriberto stderr (Phase 8.0b).tests/parse_path_test.rsverifies no log line leaks to stdout.
Notes for MCP deployment
MCP HTTP transport: details a client has to handle
Running a client against the HTTP transport, not reading the specification, surfaced three details, each of which fails with an error that does not name its cause:
- The reply is
text/event-streameven for a single response, with an emptydata:keepalive as its first frame. Acceptmust offer bothapplication/jsonandtext/event-stream. The server answers 406 otherwise, before any tool runs.- The client must echo the
mcp-session-idfrominitializeon every later request. The server answers 422Unexpected message, expect initialize requestotherwise.
sipnab 0.5.87 answered a tools/list sent before
notifications/initialized, and 0.5.222 still does. The protocol does not promise this, so the
user documentation tells clients to send the notification first.
MCP: why every rejected credential gets the same challenge
sipnab rejects a bearer token that has expired, that the operator revoked,
that someone forged, or that the operator minted for the REST API instead of
MCP. The WWW-Authenticate challenge is identical for all
four. An attacker receives the challenge without authenticating, so a
challenge that distinguished the four would answer “is this token id known?”
for anyone who asked.
MCP: why --mcp-resource-url is not derived from the request
sipnab does not read X-Forwarded-Proto when publishing the RFC 9728
protected-resource metadata, for the same reason it ignores
X-Forwarded-For when rate limiting: a header the client sets is not
evidence. Behind a TLS-terminating proxy sipnab sees a cleartext request, so a
URL derived from its own socket would say http:// for a resource the client
reached over https://, and RFC 9728 section 3.3 makes a conformant client
discard such a document. The operator names the URL. A malformed value is a
startup error rather than a wrong document.
MCP: sipnab parses structuredContent from the text block
Each tool result carries its payload twice: serialized in
result.content[0].text and as JSON in result.structuredContent. sipnab
builds structuredContent by parsing the text block rather than serializing
the payload a second time, so the two views cannot disagree.
MCP: why --mcp-allow-tls-capture is separate from --mcp-allow-open-capture
start_tls_capture installs uprobes on a running process’s TLS library and
reads plaintext from sessions belonging to processes the agent does not own.
Reading a file an operator placed in a directory (open_capture) and
attaching probes to a live daemon are different acts, so each has its own
opt-in.
MCP: why shutdown_server takes two calls and may also elicit
The first shutdown_server call is always a dry run, and stopping a live
capture with unsaved packets also needs save_to or discard_unsaved: true.
An agent can misread “we can stop looking at this now” as an instruction, and
should not be able to end hours of capture on that reading. When the client
declares the elicitation capability, the second call also asks a person.
The two guards stack because the same agent that made the first call makes
the deliberate second call.
MCP: shared TLS handshake limits
The MCP HTTP listener’s TLS handshake limits (each handshake on its own task, 10 seconds each, at most 256 at once) come from the same code as the REST API’s TLS listener.
MCP: feature cost
The mcp feature pulls in the rmcp dependency. The user documentation
described its binary cost as about 3 MB. Nobody re-measured that figure when
it moved here. mcp-http enables mcp, api and
rmcp/transport-streamable-http-server.
Notes for Run MCP across an estate
Federated tracing: provenance of the estate transcripts
The tunnel, NAT and ProxyJump commands in docs/mcp-estate.md were not run
against three real hosts, because one machine was available. The measured
behavior used three (and, for the proxy-mode case, four) sipnab servers on one
host, each with its own --node-name and capture, driven by
contrib/mcp/trace-call.py. The transcripts were first taken on 0.5.97 and
re-run on 0.5.222 (7f9ffaa7) on 2026-10-16.
max_error_us is a live reading. On the original host it reported 0.295 s,
1.944 s and 2.38 s on different runs, all with synchronized=True. The
2026-10-16 re-run reported 0.7245 s. The last of the earlier readings exceeded the 2 s
correlation window, at which point the clock alone could account for a
timing_heuristic match.
Federated tracing: why sipnab omits timing_clock on identifier matches
A clock reading beside an identifier match invites weighing one against the
other, and they do not trade off. sipnab omits timing_clock rather than send a
healthy-looking default. The loop evaluates strategies in descending score and
stops at the first match per leg. It then sorts the legs by score.
Federated tracing: why sipnab does not compute a cross-node identifier
A computed “same id on both nodes” would have to come from what each node
sees independently. Across a re-originating B2BUA, Call-ID, From tag, Via
branch, Contact and usually the SDP are all new on the far side, which is what
re-origination means. Any id derived from the remainder is a heuristic that
would arrive looking like proof and correlate two unrelated calls with no field
left to catch it, while timing_heuristic announces itself as a guess. The same
judgement records the RFC 7329 legacy Session-ID form as an interop notice
rather than a violation, because a single message cannot distinguish a legacy
implementation from a broken one.
Session-ID correlation (src/sip/session_id.rs) intersects the non-nil halves
rather than comparing strings, because the halves swap direction across a B2BUA
and string equality would find nothing.
Notes for core bundle
Why capture_status reports AMI cleartext logins
capture_status reported unanalysed_sip_messages: 0 on a capture full of
Asterisk Manager Interface traffic, and the zero was correct: AMI is not SIP,
so there was no unparsed SIP message to count. Every detector ran on parsed SIP
messages, and this traffic never became one. capture_status gained the
ami_cleartext list because that is where the misleading zero appeared.
- The check costs two literal prefix tests on payloads that already failed the SIP check, so it is not gated behind a flag.
- The sighting key is
(src, dst, dst_port, observation kind). The username is outside the key because this path runs before authentication, so the field is attacker-chosen and keying on it would let a sender grow the map without limit. As a consequencefindings_fromrebuilds each observation withusername: None, socapture_statusnever carries a username althoughobservereads one (bounded byMAX_AMI_VALUE_CHARS, 64). - sipnab reports the port rather than requiring it (
AMI_DEFAULT_PORT), because a deployment that moved AMI off 5038 is the one most likely to assume nobody can find it. - The field is always present: sipnab runs this check on every capture, so
[]means “looked, found none”, unlike optional blocks that sipnab omits when a check never ran.
caveats.media_creating_commands: why sipnab counts relay forks instead of attributing them
rtpengine subscribe, publish and start recording create media belonging to
a call without being one of its two legs. Decoding one as an ordinary leg would
make a two-party call report three streams, after which the one-way-audio and
asymmetry analysis answers the wrong question. sipnab counts them instead.
caveats.tls.read_nothing: why the counts are not the verdict
A TLS handshake carries records sipnab never loads keys for, so
app_data_records > decrypted_records is not a failure by itself. An agent that
derived a verdict from the two counts reported working captures as broken and
sent operators after keys that already worked. read_nothing exists because a
capture of ciphertext nobody can open produces a dialog listing identical to a
quiet network’s.
get_capture_report: history of complete and the default format
get_dialog_reportandrender_ladderanswer for one Call-ID, so before this tool the capture-wide findings (orphaned media, STUN, ICMP quoting SIP or RTP, what retention caps shed) had no MCP path.analysis::analyzebacks the tool andoutput::analysis_reportrenders it.frames_readreads the same process-global counter the Prometheus scrape reports (sipnab_capture_packets_total), so it is the denominator every other number in the run measures against.- sipnab counts
evidence_omittedwhile accumulating, not by subtraction: one evidence row often stands for many occurrences, and the subtraction reported omissions that never existed. - Before the load gate,
completeignored anopen_captureload and read backwards during one. On a 100 MB capture the same tool in the same session answeredcomplete: trueatframes_read: 312andcomplete: falseatframes_read: 365747.source_exhaustedandsource_stopped_earlynow gate it and travel beside it. - The rendered formats append an
INCOMPLETE RUNblock for the same reason--reportdoes: the reader of a rendered report has no exit status and no JSON, and a partial report looks like a whole one. - Before 0.5.125 the default returned the text rendering.
formatchose between markdown and text inside the renderer, which had no JSON arm, so a call with no argument received prose and nothing said the requested structure was absent.
list_dialogs: reasons for the page object and the compound cursor
A bare array hides its size. The tool once returned 50 of 2311 dialogs on a
production capture with nothing marking the cut, and limit alone could not
close the gap because requests above 1000 clamped to the cap, leaving 1311
dialogs unreachable. An agent asked “how many calls failed?” counts the rows it
holds, so a short list reads as a confident wrong answer. total_matched and
truncated name the shortfall, and cursor closes it.
The cursor pairs created_at with the Call-ID because dialogs routinely share
a timestamp (a burst of registrations lands on one millisecond), and a bare
timestamp forces a choice between dropping the rest of that group and serving
it twice. list_dialogs pages on created_at because it never moves: in an
updated_at ordering, a dialog that gained a message mid-sweep would jump past
a cursor already gone by and vanish. tail_dialogs pages on updated_at
because reporting change is its job.
sipp-branch-scenario.pcapng is the example fixture because its by_method
shows all 1334 dialogs are REGISTER, which the two rows on the page cannot.
aggregate_dialogs: why it exists and why it has one dimension
Counting is the operation a language model gets wrong most reliably: an agent
asked “how many calls failed?” counts the rows it holds. The page object fixed
one count (total_matched, for one filter). Every other count still meant
fetching pages and tallying them in the model, or issuing N filtered queries
with buckets guessed in advance.
- Buckets plus
other_countalways equaltotal_matched, because a truncated aggregate that does not say what it left out is a wrong total. Nulls become(none). - One dimension and no time bucketing is a deliberate cap: two dimensions is a pivot table, a pivot table wants a UI, and docs/design/positioning.md puts that outside sipnab.
- sipnab fences sender-written dimensions and not its own derivations,
because fencing those would tell the agent to distrust the analysis. It
fences the codec name (
rtp.codec) because it comes from the SDP.
find_problems: the OPTIONS keepalive observation
On one real capture 98 of 110 find_problems rows carried OPTIONS, and 89 of
those matched an alias solely on retransmits > 3: dead qualify peers, not call
faults. Without by_method an agent had to already suspect the keepalive plane
to exclude it. Before filter was ANDed with the aliases, restricting problems
to one’s own traffic needed a client-side join.
search_messages and get_dialog: zero-padded message index in cursors
The server compares cursors as text, so the message index is zero-padded. A
hand-built #10 sorts before #2 and silently skips eight messages.
get_dialog: documentation and shape history
- Through 0.5.211
get_dialogcarried noschema_version, took an integercursorand returned an integernext_cursor, and had noreturned,first_index,truncatedortruncated_by. Schema version 2 made the cursor the opaque string every other paged tool uses. An integer cursor now fails deserialization (invalid type: integer, a tool error). - Until 0.5.134 the reference said
get_dialogreturned message fields verbatim and pointed readers toget_message. Both tools fenced the whole time.
triage_call: how sipnab selects the termination cause
sipnab reads the last message naming a cause because a call that went through
a challenge, a retry and a clear carries several, and the last says why it
ended. Reason on BYE or CANCEL counts, which is the case the block exists for:
a normally cleared call has no failure response to carry a cause. When one
message carries both protocols, the non-SIP cause wins because a SIP cause
restates final_status_code. sipnab cuts cause_text at MAX_CAUSE_TEXT_CHARS
(200), past every value a gateway produces and short enough that a crafted
header cannot spend a context window. The block comes from one assembler shared
with get_dialog_report and the REST report route.
rtp_stats: interval scoring, placeholders and orphans
- Per-interval MOS exists because a whole-call mean hides a burst shorter than itself: one 90 % loss figure the project had to explain turned out to be three bursts of half a second. sipnab scores each interval on its own jitter and loss and on the stream’s resolved path delay, so the trend and the headline share one basis.
estimate_mosreturns the same number (4.216 at 10 ms jitter) for AMR, AMR-WB, EVS, G.722 and an unidentified codec, because sipnab implements G.113 impairment values only for G.711, G.729 and Opus. For AMR-WB the placeholder is wrong by about a full MOS point in either direction (its nine modes span about 4.49 to 3.51).- A MOS bound skips ungrounded streams because a bound on a placeholder goes
wrong both ways: a healthy AMR-WB stream never appears under
max_mos, and a degraded one appears on a figure that never described it. Oncodec-negotiation.pcapthe two G722 streams score 4.22 from the placeholder arm and would otherwise fall under a 4.5 bound. - sipnab computes
orphanedper response asassociated_dialog.is_none(), so the two fields cannot disagree and a stream reports it from its first packet. - The
orphanedfilter is independent of the MOS bounds because an orphan usually has no dialog to ground its clock, so requiring a bound alongside would filter out most of what the filter exists to find. - The sweep replaces a listing plus one
rtp_statsper dialog, which costs thousands of round trips on a real capture.
Notes for signaling bundle
Why timeline returns an object and keeps empty buckets
aggregate_dialogs is blind to when dialogs occurred, and bucketing a page of
dialogs inside the model is the counting task language models get wrong most
reliably, so timeline counts in the store. Its answer is an object rather
than a bare array because a bare array has no key to carry source_exhausted:
a series drawn while the capture was still loading looked exactly like one
drawn over the whole file. Buckets align to the epoch rather than to the first
dialog, so two captures of the same window produce identical boundaries.
Aligning to the earliest call would shift every boundary whenever that call
changed. The series keeps empty buckets because a gap is a finding, and a series that
drops it renders as continuous traffic on a shorter axis.
group_dialogs design decisions
A rate is the one thing a language model cannot recover from a page of rows: it would have to hold every dialog, classify each outcome and divide. Agents stop early and answer from the rows they hold, which is how a truncated page becomes a confident verdict about a carrier, so the tool computes the rate in the store and reports the population beside it.
- A
nullmetric withnot_groundedrather than0: an ASR of 0 for a group of REGISTER dialogs would name a working registrar a dead trunk. This is the disciplinemos_groundedapplies to a MOS on an unpublished codec, extended to the other seven metrics. unitstravels with the numbers because an ASR of0.65and of65differ by a factor of a hundred. Before 0.5.130group_dialogsandevaluate_expectationsdisagreed (one percent, one ratio), so a threshold copied between them was wrong by a hundredfold in the direction that always passes.- A seizure excludes an INVITE still ringing at the end of the capture: counting it as failed reports a live capture as an outage that looks worse the earlier you look.
408 Request Timeoutdoes not count as delivered: a proxy emits it both for a silent phone and for an unreachable next hop, so crediting it would credit the network for calls that may never have arrived.- ACD averages conversation time, not dialog span: averaging the span bills the caller for ring-back.
- Percentiles use nearest rank because an interpolated p95 is a number no call experienced, and these figures get quoted to carriers as evidence. Two decimal places because a ratio over three dialogs is not accurate to fourteen.
- Groups rank by count, not by metric: ranking by the metric would answer a
different question per call and could not page against
other_count.other_countcarries no metrics because an ASR averaged over the groups that fell off the end is an average of averages across the smallest groups. - The
byvocabulary is a strict superset ofaggregate_dialogs’ so an agent never has to discover per tool which vocabulary it is using. One dimension only: two is a pivot table, and a pivot table wants a UI (docs/design/positioning.md). next_hop, state, codes, addresses and codecs come back unfenced because they are sipnab’s own derivation. Telling an agent to distrust them would tell it to distrust the analysis.
search_by_time, validate_filter, tail_dialogs and await_condition rationale
search_by_timegained a cursor because without one the only way past a truncated window was to narrow it, which put every row past the row ceiling out of reach.validate_filterexists because every other way of trying an expression cost a page of fenced summaries per attempt. It returns a parse failure as a successful call becauseinvalid_paramslands in the error channel, where a model acts on it least.total_matchedisnullon a parse failure because a zero reads as “parsed, matched nothing”. It calls the same compiler as the other tools. A second parser would eventually accept an expressionlist_dialogsrejects.tail_dialogshas noreturnedortotal_matchedbecause “how many are there” is not a question a tail can answer while the store changes. The compound cursor exists because dialogs shareupdated_at. A bare timestamp falls back to the pre-compound strictly after filter, which the tool still accepts, so a client that rebuilds one loses or repeats tied dialogs silently.await_conditionreturns a passed deadline as a successful call because “the fault has not reproduced” and “the tool broke” are different findings. Constants:DEFAULT_TIMEOUT_SECONDS(30),DEFAULT_MCP_MAX_WAIT_SECONDS(60, read asDEFAULT_MAX_WAIT_SECONDS),DEFAULT_POLL_INTERVAL_MS(500),MIN_POLL_INTERVAL_MS(100, so no caller can make a spin loop).scanscan be lower thanpollsbecause both stores number their revisions, so a look at an unchanged capture compares two integers. Nothing the call allocates outlives it: no subscription registry, no per-client filter, no background task. The deadline is what ends the server’s obligation, and a waiting call holds a--mcp-max-concurrentslot while producing nothing.
Correlation tools: find_correlated and get_call_tree
- No cursor on
find_correlated: the design treats a call with more than 1000 correlated legs as a capture problem rather than a paging one. charging_vector_icidmatching across a B2BUA means some intermediary copied a per-dialog identifier onto a second dialog, which no RFC grants. The tool does not return the matched ICID because RFC 7315’s suggested construction embeds the generating proxy’s hostname or address, making it operator-internal rather than opaque.heuristic_onlyexists because a call tree built only from timing is a hypothesis, and an agent that cannot tell presents it as a finding.get_call_treeexists because the TUI stitched B2BUA legs together (thexkey) and nothing on the MCP surface reached it. An agent had to callfind_correlatedon each result and assemble the graph. The walk does not expandtiming_heuristicedges transitively: on a proxy carrying ten calls a second every dialog sits within a guess of every other, so a transitive expansion would return the capture, not a call tree. A queued leg cut off bylimitreportsfollowed: falsebecause claiming the walk searched its subtree would describe work nobody did. An unknowncall_idfails rather than returning an empty tree because “no other legs” and “no such Call-ID” are different facts.
Per-call report, message and ladder tools
output::generate_call_reportbacksget_dialog_report, the same function--call-reportuses.fromandtoarrive unfenced because a rendered report interleaves sipnab’s diagnosis with sender-written header values, and fencing the whole document would tell an agent to distrust the analysis as well. The provenance note carries the warning instead.get_message.extension_headersclosed a projection gap, not a parsing one: the parser always retained vendor headers, and before the field existed the only route to them wasdecode_evidence(file-root opt-in, reachable original capture, one call per frame). Fencing the name as well as the value widens the prompt-injection surface the threat model already tracks rather than adding a new one: the same bytes were reachable throughsearch_messages. The field has no ceiling of its own because the parser’s 200-header and 8 KiB-line limits already bound it.render_ladderoutput is byte-identical to--call-reportwith--no-cli-print. TheINCOMPLETE RUNblock goes after the ladder so no line of the drawing moves.check_codec_negotiationkeepsno_answerandno_sdp_in_captureapart because reporting the first for the second sends the reader hunting a reply that nothing ever called for.
Conformance tools: lint_dialog, validate_message, explain_rule
- The observation rules are the reason
lint_dialogexists: sipnab holds the signaling and the RTP in one process, so it can compare declaration against observation, which a linter reading message text cannot. rfcandsectionare separate fields so an agent quotes the section from the data instead of inventing a plausible one.rules_not_evaluatedexists because a rule that found nothing and a rule that never ran leave the same empty list. No MCP tool raisesOBS-5761-5.1.1-RTCP-MUX-UNANSWEREDbecause the stream store folds an RTCP report into the stream it describes and keeps no record of where it landed..sipnablintdiscovery stops at the project root, and a capture outside any project adopts nothing from above, because inheriting a stranger’s suppression list would switch off rules nobody here turned off.suppressionsandfindings_withheldappear on every call, zeros included, because a missing field and a zero must not be the same bytes. The response names the file because “4 findings suppressed” gives nothing to act on when discovery walked up several directories. Suppression does not short-circuit a rule, which is whysuppressedandbelow_severityare exact whilecappedis a lower bound.explain_rulelists all 41 identifiers on an unknown one because an empty answer would read as “that rule found nothing”.
generate_repro and generate_wireshark_filter rationale
generate_reprotakes the hypothesis as input because a scenario that “reproduces” for unrelated reasons is worse than no scenario. Onlybranch,call_idandtagscan vary because varying needs a generator, and SIPp has none for SDP or User-Agent. An aspect in bothpinandvaryfails because a scenario that silently picked one would encode a theory nobody stated. Identity fields vary by default because replaying a captured Call-ID, tag and branch at a proxy is, to its transaction layer, a retransmission of a call it already answered. A pinned body brings itsContent-Typebecause the aspect pins bytes and the label must describe them. sipnab strips control characters rather than escaping them because a SIP header has no escape for them, and a CR or LF would start a new header in an artifact the operator runs against their own proxy.generate_wireshark_filterexists to hand off to the escalation engineer’s tool. A Call-ID with a control character fails because the display-filter grammar escapes only\and", and a filter that quietly selects the wrong packets is worse than no filter.
Notes for media bundle
Why reconcile_orphans does not use the Unattributed vocabulary
sipnab reports a never-named verdict with relay_was_consulted: false as
“nobody asked”, not with the Unattributed vocabulary. That vocabulary answers
“sipnab asked the relay and it said X”. The MCP server holds no live
reconciler, so reporting an answer nobody received would be the failure that
vocabulary exists to prevent. The relay_control retry counts exist because a
control channel that loses answers shows on the SIP side only as a proxy that
is sometimes slow.
Why media_diagnostics returns applicable: false instead of an empty streams array
An empty streams array would read as “sipnab checked the media and it was
fine”, a different claim from “no media reached the capture point”. The
response therefore carries applicable: false and a reason.
endpoint_reported stays apart from sipnab’s own measurements on purpose.
Nothing authenticates RTCP, and a report describes the path from the sender to the
reporter, which on a mid-path capture is a different segment from the one
sipnab watches. The two disagreeing is normal and informative. Merging them
would destroy that signal, so nothing under the key feeds the MOS.
sipnab emits qos.remarked_to only on a change so that its presence is the
finding.
Why explain_attribution reads authentication from the run configuration
The authentication answer comes from the run’s configuration rather than a per-packet record. That is sound because ingest rejects any datagram that fails authentication, so everything still in the store arrived under the configured posture. With no posture configured the tool reports the weakest reading: a tool whose job is to say what a claim is worth must not round up in the absence of information.
Why export_audio requires --retain-audio
Call audio is content, not signaling, so holding it in memory is an operator decision rather than a side effect of enabling MCP.
Notes for captures bundle
Capture-file MCP tools: design notes
list_capturesreports neither a dialog count nor a last-packet time, because both need the whole file parsed, and a listing that costs a full read of every capture in the root is a listing nobody runs. sipnab readsfirst_packetfrom the first record only, so it is cheap.find_in_capturesexists because the only other way inside another file wasopen_capture, which is destructive: it replaces every dialog and stream and mints a newcapture_identitythat voids every cursor an agent holds. A sweep reads each file into a scratch store instead.- Sweep job bounds. Each running sweep is one OS thread with its own scratch
dialog and stream stores, so
--mcp-sweep-max-running(defaultDEFAULT_MCP_SWEEP_MAX_RUNNING= 4, maximumMAX_MCP_SWEEP_MAX_RUNNING= 64, sixteen times the default) caps threads and stores. A held result keeps one entry per matched file and one per unreadable file, so held memory is--mcp-sweep-max-held-resultstimes the largest result; its maximum (MAX_MCP_SWEEP_MAX_HELD_RESULTS= 256, default 16) is four held results per running sweep at the running maximum, the ratio the defaults carry.--mcp-sweep-result-retention-secs(default 600, maximumMAX_MCP_SWEEP_RESULT_RETENTION_SECS= 43200) matches the--mcp-sweep-deadline-msmaximum: a finished result waits at most as long as the longest sweep may run. - Sweep ceilings.
DEFAULT_MCP_SWEEP_MAX_FILES(20) andDEFAULT_MCP_SWEEP_DEADLINE_MS(30000), whichfind_in_capturesreads asDEFAULT_MAX_FILESandDEFAULT_DEADLINE_MS.MAX_MCP_SWEEP_MAX_FILES(4294967295) is the largest value of the per-callmax_files, a u32.MAX_MCP_SWEEP_DEADLINE_MS(43200000, twelve hours) bounds how long one sweep may keep a thread and a disk busy, since a sweep is a background job holding no MCP slot. sipnab cuts an unreadable file’s reason toMAX_REASON_CHARS(200).unreadablenever skips a file silently, because the file nobody could look in is the one that might hold the call. - Two bounds, not one:
max_filesmakes cost predictable, but a file’s cost is its size, which the caller cannot see, sodeadline_msis the bound that matters. compare_capturesdiffs aggregates rather than dialog lists because two captures never share a Call-ID. Buckets rank by absolute movement because the largest bucket is usually the one that changed least. A value absent from one side counts as zero because a code appearing only inbis the finding. The read runs on a blocking thread because two whole captures inside a tool handler would otherwise hold the single runtime thread the MCP server and the REST API share. It isreadOnlyHint: falsebecause reading through the shared pipeline bumps the process-wide undecodable-frame tallies and the ICMP evidence store (the same behavioropen_capturehas). The vocabulary check runs before either file opens because reading two captures is the most expensive thing the surface does. A side with no dialogs and a read error fails withinternal_error, because every bucket would appear to collapse to zero. sipnab countsdialogs_droppedagainstCli::DEFAULT_DIALOG_LIMIT(100000), not the operator’s--limit.show_evidencereturns a digest mismatch asunresolvablerather than a resolved frame with a warning: returning bytes from a capture that changed after sipnab made the pointer would manufacture the confidence the feature exists to provide.frame_refon lint findings has a name apart fromframebecause a finding cites a message index. The pointer makes it checkable without the list that index counts within. A missing pointer omits the key rather than sending""or frame 0, both of which read as a real pointer.decode_evidencetakes one pointer, not a batch, because a whole packet’s structure per pointer fills a context window. Byte ranges come from the parse itself:SipHeadercarries the byte range of its own logical line, recorded by the parser as it walks. This replaced a second walk of the header grammar paired with the parse positionally. Two walks of one grammar part company over a line with no colon, a non-UTF-8 line, an over-long line or the per-message header cap, and a range pinned one header early still resolves, so it reads as evidence. The old design dropped the whole set on any disagreement. A header that came from no bytes carries no range because a plausible invented range would point at bytes that never said it. sipnab drops the frame-relative pair when the payload does not sit at exactly one place in the frame (two candidate offsets makeframe[start..end]a coin toss). Sources go throughresolve_in_rootso a caller-supplied path never becomes an arbitrary-file-read primitive behind areadOnlyHint. The answer carries no timestamp because the resolver returns frame bytes without the capture record, and a manufactured capture time on an evidence surface is the failure the mechanism exists to prevent.build_evidence_packageends the analysis in an artifact. The rebuilt-frames disclaimer lives inside the directory because the directory is what gets forwarded. Each file takes its ordinal as its name because a Call-ID is arbitrary peer text (separators, leading dash, hundreds of characters, bytes no filesystem wants). The pcapng is one chronological run because splitting by leg hides the interleaving that is usually the finding. sipnab removes a half-written package because it would hold the name against a retry and read as complete evidence.save_findingsis a dead end on purpose: every response on the surface carries attacker-controlled text, so a write verb reachable from that text must not change what an operator reads or come back later as evidence the agent cites. The annotation types stay private to the MCP module so no analysis code can name them, and there is nolist_findings.readable_over_mcp: falseis a constant so a client never infers the dead end from the absence of a reader. The 1000-finding budget refuses rather than accepts-and-discards, because an agent told “recorded” about something thrown away is worse off than one told it was not. sipnab does not checkcall_idagainst the store because a note about a dropped call is still the note that mattered.export_capture: on one measured export, 4,875 of the 5,000 packets that had been on the wire were absent (RTP, RTCP, non-SIP).open_capturereturns as soon as the background read starts because the REST API and the MCP server share one runtime thread, so a multi-gigabyte read inside the handler would stop every other client. sipnab refuses a live source because a live capture’s writer never stops, so a second writer would race it for the life of the process. The tool stays listed without its flag because “not permitted here” and “this build cannot” are different answers. sipnab refuses password arguments because an argument passes through the model’s context, the client’s transcript and the provider’s logs (OWASP LLM02:2025, and the MCP specification’s rule that credentials never pass through the client).- Discrepancy found 2026-10-16: the user page used to say
open_capturerefuses a capture belonging to this run’s own-Iset, and a comment inopen_capture(src/mcp/server.rs) saysresolve_in_rootrefuses it.resolve_in_rootdoes not consultprotected_inputs(onlyresolve_in_root_for_writedoes), and a run of 0.5.222 opened the-Ifile by name and returnedstatus: "loading".
Notes for security bundle
Why top_talkers counts participants rather than dialogs
aggregate_dialogs and group_dialogs group dialogs, and a dialog has two
ends. Asking “who is busiest” of a dialog-shaped answer forces the caller to
pick one end and ignore the other, which reports a trunk’s traffic as belonging
to whichever side the schema happened to name first. top_talkers counts
participants instead, so shares sum above 100%. Rows sort by dialogs,
messages, key so two runs over one capture cannot disagree. share_pct is
null rather than 0 when nothing matched, because a zero would read as a
talker measured to be idle.
Why describe_endpoint exists and refuses two selectors
The rest of the MCP surface keys on Call-ID, which suits “why did this call
fail” but not the question that comes first: an operator handed a complaint
holds a phone, a trunk or a subscriber. Answering from a dialog-centric surface
meant listing every dialog, filtering client-side and re-deriving per-entity
facts the stores already hold. sipnab refuses ip and user together because
the caller could mean their intersection or their union, and picking one
silently would answer a question nobody asked.
Matching an ip on messages rather than on the dialog’s opening socket pair
keeps re-originated legs, re-INVITEs from a second interface and BYEs from the
far side. Matching the dialog record alone would drop exactly the transfers and
hand-offs an operator looks for. failure_rate_pct and
registration.applicable guard their denominators so that an unmeasured
endpoint never reads as a clean one. sipnab counts registrations by request
rather than by dialog method because a REGISTER can arrive inside a dialog
another method opened once Call-ID reuse is in play.
Why a private Contact is an observation, not a finding
Measured against the private corpus on 2026-09-08, 1,660 of 2,226 REGISTER
contacts carried a private host (74.6%), and that estate works. A rule firing
on the observation alone would report three quarters of a healthy fleet. The
finding therefore needs later requests: any request to the private host is a
finding, because a proxy that sends some requests to the source and some to the
header still sends some nobody receives. no-later-requests stays distinct
from rewritten so that a capture that stopped too early does not read as a
clean bill of health. The registered AoR ties later requests to the
endpoint, because a request sent to the private Contact carries no address
belonging to the endpoint.
Why stack fingerprinting exists and how conservative it is
On one capture, top_talkers by=ua reported Asterisk PBX 20.15.2 as one row
at share_pct: 100.0 across 507 dialogs, while tshark found 18 distinct
source addresses behind that label running two different SIP stacks. The banner
cannot tell them apart, and request syntax can. The fingerprint names a library,
never a product, because pjproject ships inside Asterisk res_pjsip,
Grandstream UCM and FreePBX alike. Two agreeing observations are the floor for
an inference because the magic cookie is mandatory for every sender and
callid_has_host alone splits senders roughly in half. MAX_FINGERPRINT_CHARS
(32) bounds echoed values. The fingerprint reads only requests, because a
response echoes the request’s branch, From tag and Call-ID.
Design of evaluate_expectations
The tool’s purpose is to move sipnab from an incident tool to a check that runs on
every commit, with the rules in a checked-in sipnab.expect.toml. Passing the
file’s bytes in rules_toml means the agent judges against what CI judges,
not against its own transcription. The tool refuses both suites or neither,
because two suites have no defined order and none would report a verdict on nothing.
Three failure modes get a gate deleted rather than fixed, and each has an
answer: passing on data never judged (an empty population fails as
unevaluable:, where a MOS threshold on an AMR-WB capture would otherwise
score placeholders and report green), failing on a sample too small to mean anything
(min_sample skips), and lying about coverage (an all-skipped suite reports
not_evaluated, exit code 2). Declaring min_sample >= 1 is the only way to
tolerate an empty population, so a gate goes quiet only where its author wrote
that it may. The verdict tests the sample, because count always produces a
value and checking the value alone let count == 0 pass on an empty capture.
Rule defects are hard errors because a misspelled metric evaluating to “fail”
would look like a real breach, and evaluating to “pass” would be a gate that
checks nothing.
Why generate_fail2ban_rule takes one finding and a derived id
A rule generated from a class of findings would be a policy the tool has no
standing to write. A rule from one finding, with the alert line beside it, is
something an operator can read and decide on. The id derives from the finding
(<rule_name>@<src_ip>@<timestamp>) rather than a counter, because an assigned
id would have to survive eviction, restart and a second server reading the same
capture, and a position in a bounded ring buffer survives none of them. The
not-found message lists the held ids from a scan of the whole ring buffer,
because a truncated scan cannot report what it truncated. The tool checks that
rule_name is a bare identifier before writing it into a regular expression.
The tool returns the NotEnabled refusal text in
src/mcp/tools/expectations.rs
(this server holds no findings: no detection rule was armed) only when the
server has no alert engine at all. A normal server run always attaches one (see
the comment at src/mcp/server.rs around line 90), so with no detector armed
the observed answer is the not_found refusal ending “This server holds: none”.
Notes for server bundle
Design notes for runtime_stats and capture_health
runtime_stats exists because the Prometheus listener is off by default, so
the metrics exist inside the process and nothing can read them. An agent asked
“is this server healthy” could not enable a listener to find out. Absent fields
mean “not readable here” because a 0 on a platform where the value was never
read is worse than an admission. impact.significant exists because a capture
that is itself the reason a proxy started dropping calls was previously
invisible. The response omits the capture-queue fields without a capture meter
because a zero would read as “the queue is clear”. The 30-second sample cap
exists because an MCP call blocks its caller and holds a request slot, and
clients commonly cancel at 60 seconds.
capture_health has no default window because a rate without a stated
interval is not a rate. source_stopped_early exists because a truncated
file previously reached only stderr (0 of 1 file(s) read in full, 1 stopped early). The response type carries no string field at any depth, so it cannot
carry packet content. The test
a_populated_capture_health_response_carries_no_string_value_anywhere
enforces it. The handler opens no device and writes no file, so no path leads
from an MCP call to a capture that transmits or records. attachment code 1
exists so that a server with nothing to read says so, and no code is 0, so a
defaulted response cannot pass for a real one. undecodable_reasons_dropped
counts frames whose number did not fit the fixed-slot tables behind the
counters.
Why server_capabilities reports runtime separately from features
features comes from cfg! at compile time, so it cannot claim a feature the
binary lacks. What the operator enabled is a different question no compile-time
check answers. Without runtime an agent discovers the setup by collecting
refusals mid-investigation. named_backends comes from the same function as
the second line of sipnab --version and REST GET /v1/capabilities.
Notes for TFPS bundle
TFPS JSON-mode provenance
tfps_ctl --json arrived in sippulse/tfps#6, merged into master on
2026-09-18 (commit 984577dc). v0.2.1, the newest tag at that date, rejects
--json. tests/peer_release_honesty_test.rs and
tests/tfps_contract_test.rs record the same facts.
Notes for tls bundle
start_tls_capture: refusal order and attach timing
All refusals happen in plan_tls_targets and start_tls_capture before any
kernel state exists, each with a reason, because an agent told only that a
capture failed cannot tell “this host runs no TLS” from “this server dropped
privileges”, which lead to opposite next steps. The root check runs before the
attach because the attach happens on a background thread and its failure would
otherwise reach the agent minutes later as an empty capture. The one-writer
check runs under the same lock that records the new capture.
The flavors parameter keeps the serde alias flavors (the spelling through
0.5.104). The --uprobe-flavor flag dropped its alias in 0.5.157. The
parameter keeps it because clap refuses an unknown flag while serde ignores an
unknown key. Dropping it would silently widen the probe to every library.
Notes for vCon bundle
vCon MCP tools: design notes
- Backing code for each tool:
export_vcon:output::vcon.validate_vcon:output::vcon_schema.analyze_vcon:output::vcon_analysis, shared withPOST /v1/vcon/analyze.fetch_vcon:app::vcon_fetch, the same function assipnab --vcon-fetch.vcon_directory_report:output::dialog_report::vcon_containers_report, shared with-I <dir> --report.vcon_spool_status:app::vcon_spool::snapshot, withapp::vcon_forward’s file rules.
export_vcontakes afilterbecause agents asked for every failed call used to list dialogs and issue oneexport_vconper row, hundreds of round trips for what--export-vcon-whendoes in one invocation. The tool refusescall_idandfiltertogether because a request naming a dialog and a rule for choosing dialogs has two answers and names neither. A filter matching nothing answers with an empty set because “no call failed” is a finding. There is noformat: a markdown rendering of a document meant to travel between machines would eventually reach something expecting a container.- docs/design/vcon.md records the observer posture (no signature, no consent
attachment,
validation: "none", nourl) in sections 2.1-2.5. It records therecordingformat-versus-provenance distinction in section 4b, the “incomplete record” gap in section 3, and the 204 store probe in section 4a.1. - sipnab builds the completeness caveat from one value into both the
analysisobject and the attachment so the two cannot contradict each other, and the response repeats it because a container over the size budget carries no audio, which looks exactly like a conversation that had none. - An absent
blind_spotson a container from the MCP tools would be a defect: both doors always run the capture analysis. validate_vconresponseschema_version1 also haddeviations,explanationsand a third verdict,valid-except-documented-deviation, for the empty Dialog Object{}that core-03 allowed in prose and rejected in its schema. core-04 section 4.3 replaced it with a typed placeholder, so version 2 dropped them. A validation pass over 4,216 real containers found 2 the schema rejects, with nothing on any surface saying so. The validator walks the vendored schema itself (the draft-07 subset the file uses) becausejsonschemais a dev-dependency and a validator built on it would ship only in the test tree. A keyword outside the subset reportsinvalidso re-vendoring a richer schema fails loudly.