Docs Conformance Linter Design Notes
Conformance Linter Design Notes
On this page
- Why the observation rules exist
- Conformance is not the same question as outcome
- Why the RFC number and section are separate fields
- Why severity and basis are separate axes
- One-sided thresholds
- What the corpus can and cannot vouch for
- Session timers, and the section number that is easy to get wrong
- Why the bracket rules split in two
- An answer listing an extra codec stays legal
- Hold by blanking the address
- Why the To tag rule is hard to trigger
- What the corpus says about the nine newest rules
- Why the ACK branch rule covers only non-2xx
- Why the Record-Route rule compares a suffix and not a list
- The telephone-event rule is not in RFC 4733
- A declined stream that kept its attributes cites section 8.2 of RFC 3264, not section 6
- Opus: the decidable half, and the half that is not
- The four header fields that section 7.3.1 of RFC 3261 exempts
- Two branches, one stack
- Validate a rule against real traffic
- Why RTCP reaches the linter separately
The reasoning behind the SIP conformance rules: why each rule cites the section it cites, why some rules are narrower than the sentence they implement, and what the validation corpus measured for each one.
The operator page, SIP conformance rules, lists the rules, shows how to run and suppress them, and says how to fix the SIP each one flags. This page records why the rules have the shape they have. Read it before you change a rule’s scope, severity or citation.
The code is in src/sip/lint/.
Why the observation rules exist
Most SIP linters read messages against a grammar. They catch a malformed
Contact header. They cannot catch the far end sending payload type 8 when the
SDP declared payload type 0, because they never see the RTP.
sipnab holds the signaling and the media in one process.
- The
OBS-rules compare what the SDP declared against what the wire carried. - That class of defect stays invisible to any tool that reads only text.
Conformance is not the same question as outcome
src/sip/diagnosis.rsanswers “why did this call fail”.- The linter answers “does this traffic obey the specification”.
A call can complete over messages that break four MUSTs. A fully conformant call can hit a busy signal. Keep the two questions apart.
Why the RFC number and section are separate fields
The RFC number and the section are data, not prose inside a sentence. That choice caught a mistake while the module was still new:
- Three sources place the angle-bracket rule for a
ContactURI in RFC 3261 section 20.10. - The sentence actually sits in the preamble of RFC 3261 section 20, above section 20.1.
A citation that nothing can read is a citation that nothing can check.
Why severity and basis are separate axes
Keeping must apart from interop matters more than it looks.
- Some readers cannot tell a broken MUST from a vendor-compatibility hint. Those readers learn to discount both.
- The MUST is the one worth acting on.
One-sided thresholds
Three of the observation rules compare one duration against another. Every comparison runs in one direction only.
- Silence suppression, comfort noise and a congested path all stretch the gap between packets. None of them compresses it.
- So a rule that fires on “slower than declared” fires on a large share of ordinary traffic.
- A rule that fires on “carrying more media than time elapsed” fires only on the impossible.
The packetization measurement reads payload size rather than arrival times wherever the codec has a fixed octet rate. The reason is the same: 20 ms packets stay 20 ms packets however far apart they arrive.
Opus, AMR and the other variable-rate codecs have no fixed octet rate, so the two size rules skip them rather than invent a threshold.
What the corpus can and cannot vouch for
Three of the syntactic rules report zero against the local corpus. The zeros do not all mean the same thing.
SIP-3261-12.1.1-CONTACT-MISSING-IN-2XX is well exercised.
- The corpus holds 1,989 2xx answers to
INVITE. Every one of them carries aContact. - The rule reaches its own code path 1,989 times and declines each time.
- That is the strongest evidence available short of a positive hit.
The two RFC 3262 rules are not exercised.
- The corpus contains exactly one reliable provisional and one
PRACK. - So their silence rests on their unit tests rather than on real traffic.
- Both fire on crafted input. Both stay quiet under their guards.
- But nobody should read their zero as a measurement.
An unexercised rule and a rule with nothing to find produce the same row. Only this note tells them apart.
Session timers, and the section number that is easy to get wrong
RFC 4028 numbers its behavior sections 7 UAC, 8 Proxy, 9 UAS.
- The refresher obligation belongs to the UAS. So the rule cites RFC 4028 section 9.
- Recalling it as section 8 sends a reader to the proxy’s rules. Those rules say something different about the same header field.
The citation came from the table of contents in RFC 4028, not from memory. The angle-bracket rule cites the RFC 3261 section 20 preamble instead of section 20.10 for the same reason.
All four session-timer rules read one message on its own.
- Two header fields that contradict each other sit in the same request.
- A floor is a fixed number.
- A 2xx either names a refresher or does not.
- None of it needs the dialog.
The refresher rule is the one with false-positive potential. So three guards fence it:
- the message has to be a response,
- its status has to be 2xx,
- its
CSeqmethod has to beINVITE.
Measured against the local corpus, all four rules report zero.
- That number means nothing on its own. A rule that never fires and a rule with nothing to find produce the same row.
- So a probe took it apart.
The corpus holds 1,849 messages carrying Session-Expires.
- 471 of them are 2xx answers to
INVITE. - Every one of those 471 names a refresher.
- The rule reaches its own code path 471 times and declines each time.
- That is silence with evidence behind it. It is not a rule that cannot fire.
The 358 values in the corpus that carry no refresher are all requests.
- In a request, a UAC proposes a timer. RFC 4028 section 9 places no obligation there.
- RFC 4028 section 9 puts the obligation on the answer.
No Session-Expires anywhere in the corpus sits below 90.
So both floor rules are quiet for the same checkable reason.
Why the bracket rules split in two
RFC 3261 section 20 gives one sentence for three characters. The three do not behave alike.
A comma or a question mark in a bare URI breaks the MUST outright.
- The receiver splits the value there.
- It reads a different URI than the one sent.
- That is
SIP-3261-20-URI-BRACKETS. It goes in themustruleset.
A semicolon is different.
Contact: sip:a@b;transport=tcpparses as legal SIP. But it means something the sender did not intend.transportis a URI parameter.- Outside the brackets it lands on the header.
- Nothing on the wire breaks a MUST.
So
SIP-3261-19.1.1-URI-PARAM-DEMOTEDreports as interop. It stays out of themustruleset. - It remains the defect most often found at the bottom of a “calls reach the wrong trunk” ticket.
A bare From: sip:alice@example.com;tag=1928301774 trips neither.
RFC 3261 uses that form in its own examples.
tag is a header parameter, so it belongs where it sits.
An answer listing an extra codec stays legal
A widely repeated claim holds that an answer containing a codec absent from the offer breaks RFC 3264. It does not.
- RFC 3264 section 6.1 permits the extra listing in as many words.
- It also explains why it rarely helps. The answerer cannot send with a format that the offer never listed.
So the MUST violation is the absence of any shared format.
SDP-3264-6.1-ANSWER-NO-COMMON-FORMATreports that as an error.- The extra listing reports separately, as info, under
interop. - Some equipment reads the answer as the negotiated set.
- It picks one of the extras.
- It sends media that the far end drops.
- That is worth knowing. It is not a broken MUST.
Hold by blanking the address
sipnab has always found hold through a=sendonly and a=inactive.
- RFC 3264 section 8.4 describes a third mechanism:
setting the connection address to
0.0.0.0.- RFC 2543 defined it.
- Section 8.4 discourages it.
- Until
SDP-3264-8.4-HOLD-CONNECTION-ZEROexisted, a call held that way looked to sipnab like a call that simply stopped.
RFC 3264 section 8.4 keeps one legitimate use. That use is an initial offer from an agent that does not yet know its own address. That is why the first SDP body in a dialog is exempt.
Why the To tag rule is hard to trigger
One message cannot say whether a request sits inside a dialog.
- A message-scoped version of this rule fires on every re-
INVITEin every capture that starts mid-call. - That is most captures.
Two shapes settle it:
- A
REGISTERnever sits inside a dialog. So aTotag there is wrong wherever the capture started. - Otherwise the rule needs the answer to that same transaction to carry a different tag.
- The rule matches the answer on the
Viabranch that RFC 3261 section 8.1.1.7 defines. - A different tag is proof that the responder treated the request as new and chose its own.
- The rule matches the answer on the
An earlier form of this rule compared against every response in the dialog.
It fired on 2,182 dialogs of the validation corpus. 2,160 of them were SUBSCRIBE.
- The cause was not subscriptions.
- A
SUBSCRIBEdialog carriesNOTIFYrequests in the reverse direction. - A response to a
NOTIFYcorrectly carries the subscriber’s tag. That is not the tag theSUBSCRIBEaddressed. - Matching on the transaction took the count to zero.
What the corpus says about the nine newest rules
Measured against the local validation corpus:
- 62 readable captures,
- 39,239 dialogs,
- 3,371 of them carrying SDP,
- 272 with RTP linked to them.
| Rule | Dialogs | Reading |
|---|---|---|
SIP-3261-17.1.1.3-ACK-BRANCH-MISMATCH | 129 (0.3%) | Real hits, and the rate is the one that matters: a rule that read every ACK rather than only the non-2xx ones would have fired on a large share of the 39,239. |
SIP-3261-12.1.1-RECORD-ROUTE-NOT-COPIED | 55 (0.1%) | Real hits. |
SDP-7587-7-OPUS-RTPMAP-RATE | 16 | Real hits. |
SDP-3264-8.3.2-DYNAMIC-PT-REBOUND | 2 | Real hits, and rare, which is what a rule about a re-INVITE changing a codec binding should be. |
SIP-3261-16.6-RECORD-ROUTE-NOT-LOOSE | 0 | Exercised and declined. RECORD-ROUTE-NOT-COPIED fires 55 times on the same corpus, and it only reaches its own body when a request carries a bracketed Record-Route — the exact values this rule reads. So the corpus does hold recorded routes, and every one of them carries lr. |
SIP-3261-7.3.1-SINGULAR-HEADER-REPEATED | 0 | Not exercised. Its silence rests on its unit tests, not on this traffic. |
SIP-3261-8.1.1.7-VIA-BRANCH-DUPLICATE | 0 | Not exercised, and a corpus with no looping request is the expected shape of one. |
SDP-3264-7-TELEPHONE-EVENT-ONE-WAY | 0 | Not exercised. |
SDP-3264-8.2-REJECTED-STREAM-ATTRIBUTES | 0 | Not exercised. |
The distinction in that last column is the point.
- An unexercised rule and a rule with nothing to find produce the same row.
- Only this note tells them apart.
- The same caveat applies to the RFC 3262 rules above.
Why the ACK branch rule covers only non-2xx
RFC 3261 section 17.1.1.3 says an ACK “MUST contain a single Via header field,
and this MUST be equal to the top Via header field of the original request”.
- Reused verbatim, that sentence would report the correct behavior on every answered call in every capture ever taken.
- The reason is that the same section opens by sending the 2xx case somewhere else: “A UAC core that generates an ACK for 2xx MUST instead follow the rules described in Section 13.”
An ACK to a 2xx is a new transaction.
- RFC 3261 section 13.2.2.4 builds it from the dialog’s route set.
- section 8.1.1.7 requires a new branch for it.
An ACK to a non-2xx is different:
- It is hop-by-hop.
- The same INVITE server transaction absorbs it.
- It shares that transaction’s branch.
RFC 3261 section 8.1.1.7 names this as one of exactly two exceptions to
branch uniqueness. The other is
CANCEL.
So the rule reports only where the capture has already shown a final response
between 300 and 699 for that CSeq.
An ACK whose INVITE the capture never carried settles nothing. sipnab skips it.
Why the Record-Route rule compares a suffix and not a list
RFC 3261 section 12.1.1 makes the UAS:
- “copy all Record-Route header field values from the request into the response”,
- and “MUST maintain the order of those values”.
section 12.1.2 has the caller build its route set from the response, in reverse.
- A value the response dropped is a proxy removed from a path it recorded itself into.
- A value reordered sends every in-dialog request through the hops backwards.
- Both fail after the call is up. That is why they arrive as a network ticket rather than a signaling one.
The rule does not report a response carrying more values than the request.
- A capture taken anywhere but at the UAS sees the request before the last recording proxy inserted its value. It sees the response after.
- So “the response has one extra” is the ordinary shape of a proxy-side capture.
- An equality comparison would fire on most of them.
- The rule reports only the request’s own list going missing from the tail of the response’s.
The telephone-event rule is not in RFC 4733
The widely held belief is that RFC 4733 requires both ends to agree a
telephone-event payload type. It does not.
- RFC 4733 contains no offer/answer rule at all.
- section 2.5.1.1 says negotiation happens “by out-of-band means, using SDP, for example”.
- It never says what an omitted
telephone-eventmeans. - The only sender-side obligation in it is about the
eventsparameter.
The binding text is RFC 3264 section 7: “The offerer MAY immediately cease listening for media formats that were listed in the initial offer, but not present in the answer.”
- That is a MAY, so nothing here breaks.
- That is why the rule reports as
interopand not as a MUST violation. - It is also why the rule cites RFC 3264.
This is a one-way fault, not simply no DTMF. The reason:
- Equipment sends the event anyway on the payload type it offered.
- The far end receives a payload type it never agreed to. It either drops it or decodes it as audio.
- DTMF in the other direction works.
The rule needs the stream accepted (port non-zero) and sharing at least one
audio format. A declined stream negotiated nothing. A stream with no common
format is already SDP-3264-6.1-ANSWER-NO-COMMON-FORMAT.
A declined stream that kept its attributes cites section 8.2 of RFC 3264, not section 6
RFC 3264 section 6 is where this is usually attributed. Section 6 does not say it. Its whole statement about a rejected stream is: “To reject an offered stream, the port number in the corresponding stream in the answer MUST be set to zero. Any media formats listed are ignored. At least one MUST be present, as specified by SDP.” Nothing about attributes.
The attribute sentence is in RFC 3264 section 8.2: “the answer MAY omit all attributes present previously, and MAY list just a single media format.”
- That is a MAY, so keeping them is legal.
- The rule reports at
noticeunderinterop.
A stream that the offer already removed at port zero is exempt.
- That is RFC 3264 section 8.2’s own mechanism for tearing a stream down.
- The answer marking it zero too is what section 8.2 requires.
Opus: the decidable half, and the half that is not
RFC 7587 section 7 is unambiguous: “The RTP clock rate in a=rtpmap MUST be 48000,
and the number of channels MUST be 2.”
- Every example in the RFC writes
opus/48000/2. That includes the one titled “16000 Hz clock rate”. - An endpoint signals the narrower band with
maxplaybackrateina=fmtp, never in the rtpmap. - An
a=rtpmapcarrying no channel count isopus/48000/1by RFC 4566 section 6’s default. - RFC 7587 section 7 admits neither.
The rule cites RFC 7587 section 7 and not section 4.1.
- Section 4.1 states the same 48 kHz clock as a fact about the wire: “The RTP timestamp is incremented with a 48000 Hz clock rate for all modes of Opus and all sampling rates”.
- That is not RFC 2119 language.
- The SDP bullet in RFC 7587 section 7 is the only place the requirement is a MUST.
The observation half is deliberately absent.
- “Opus negotiated, and the wire carries 160-octet packets at an 8 kHz cadence” is not decidable from what a stream records.
- 160 octets per 20 ms is 64 kbit/s. That is exactly G.711. It is also a legal Opus CBR configuration.
- Separating the two needs the RTP timestamp cadence.
- The stream store keeps a last timestamp and no first one. So nothing in it yields a clock rate.
A rule that reported legal Opus CBR as a defect would be wrong on ordinary traffic. This one is decidable from the SDP alone.
The four header fields that section 7.3.1 of RFC 3261 exempts
SIP-3261-7.3.1-SINGULAR-HEADER-REPEATED reads the rule the section actually
states: “Multiple header field rows with the same field-name MAY be present in a
message if and only if the entire field-value for that header field is
defined as a comma-separated list.”
The same paragraph writes down its own exception:
WWW-Authenticate,Authorization,Proxy-AuthenticateandProxy-Authorizationmay appear on several rows.- No sender may combine them with commas.
- A
407carrying two challenges is ordinary traffic. So the rule’s list omits all four.
The compact forms count.
- The parser expands them at parse (RFC 3261 section 7.3.3).
- So
i:besideCall-ID:is two rows of one field. - That is exactly the shape a header-smuggling attempt takes.
A parser that reads only one spelling sees a message with one
Call-ID.
Two branches, one stack
RFC 3261 section 8.1.1.7 makes a branch “unique across space and time”. section 16.6 item 8 spells out what that means for a proxy: “the branch parameter will be different for different instances of a spiraled or looped request through a proxy.”
- So two identical branches in one
Viastack say the request returned to an element that failed to re-derive its own value. - That is the loop that RFC 3261 section 16.3’s loop-detection step exists to catch.
- The loop runs unbounded until
Max-Forwardsstops it.
Validate a rule against real traffic
A rule that fires on nearly every dialog is a bug in the rule. It is not a discovery about the traffic.
- runs the whole catalog over a directory of captures named by
SIPNAB_CORPUS, - prints a hit count per rule,
- fails when any rule trips more than 95% of dialogs.
That test also reports how many dialogs carried media that the observation rules could read.
- A zero hit rate on an
OBS-rule means one of two very different things:- the traffic is clean,
- or the rule saw nothing.
- The rule table alone cannot tell them apart.
Why RTCP reaches the linter separately
ObservedMedia::from_streams projects the RTP that the stream store attributed to
the dialog. RTCP arrives separately through with_rtcp.
- The stream store folds reception reports into the stream they describe.
- It keeps no record of which port they landed on.
- That port is the question RFC 5761 section 5.1.1 asks.