Skip to content

feat: rule lifecycle, three severity corrections, and the probe's own conformance (WP-14) - #15

Merged
patsypppe merged 11 commits into
mainfrom
feat/wp-14-rule-lifecycle
Aug 24, 2026
Merged

feat: rule lifecycle, three severity corrections, and the probe's own conformance (WP-14)#15
patsypppe merged 11 commits into
mainfrom
feat/wp-14-rule-lifecycle

Conversation

@patsypppe

Copy link
Copy Markdown
Owner

Implements §3.2, §3.3, §3.4 and §4.1 of docs/superpowers/specs/2026-08-23-sentinel-v1-design.md.

Applying this repository's own precedence rule — where the spec and the repo disagree, the spec wins — found four places where the repo was wrong. Each was verified against a live fetch of the specification rather than inferred.

The probe was sending malformed requests

Three omissions, each grounds for a conformant server to reject every request the harness sends:

  • io.modelcontextprotocol/clientCapabilities is Required: Yes. client.py imported the key and never set it.
  • "Every POST request to the MCP endpoint MUST include an MCP-Protocol-Version header." The probe sent Mcp-Method and Mcp-Name and nothing else. A server enforcing this answers 400 HeaderMismatch to everything — and the harness would have reported it as catastrophically broken.
  • "The client MUST include an Accept header listing both application/json and text/event-stream."

This survived only because neither fixture enforces any of the three. The header value is now derived from the same source as the body's _meta, so they cannot disagree by accident.

Three rules demanded more than the specification does

That is the false-positive class MEASUREMENTS.md publishes as zero. The measurement was honest; the conformant fixture just happens to satisfy all three.

Deprecated Successor Why
MUST/server-info-echoed SHOULD/server-info-echoed serverInfo is "Required: No" under "Servers SHOULD include…"
MUST/tools-list-is-deterministic SHOULD/tools-list-is-deterministic "Servers SHOULD return tools in a deterministic order."
SHOULD/tools-sorted-by-name SENTINEL/STYLE/tools-sorted-by-name The spec asks for deterministic, never for sorted — a stable unsorted manifest conforms fully

The MUST in that same paragraph is a different property — "MUST NOT vary per-connection" — which the deprecated rule never tested, because all twenty of its calls shared one connection. MUST/tools-list-connection-independent opens a second one.

Rule IDs are permanent (HANDOFF §8.8), so all three are deprecated and superseded rather than edited. That required a lifecycle the catalog did not have, plus the SENTINEL/ namespace for rules this project believes in that the spec does not require — they carry a rationale instead of a citation, and no spec gate can ever fail on one.

The deprecation inventory reported two wrong dates and one impossible one

The registry says includeContext was deprecated 2025-11-25 and HTTP+SSE 2025-03-26; the inventory stamped all six 2026-07-28. Worse, two removal windows are not date arithmetic at all: includeContext's "Follows Sampling", and HTTP+SSE's is "Three months after SEP-2596 reaches Final" — an event that has not happened. Computing +12 months from includeContext's own date would have produced 2026-11-25, eight months early.

AfterEvent is now a dead end by construction — it holds a condition and a SEP and nothing else — so no code path can turn it into a number. A report that prints a date it cannot know is worse than one that prints the condition, because the number gets put in a plan and treated as a deadline.

Found while reconciling

  • report/sarif.py emitted "helpUri": "" and a bare "Specification: " for a beyond-spec rule — a citation pointing at nothing, in the one report format a reviewer reads inside GitHub.
  • scripts/measure.py counted only MUST failures as detections, so the non-MUST seeds would have been published as a recall regression.
  • The README's broker scan example omitted --token, so the command as printed exits 1. CI has always passed one.
  • Registry.__len__ counted deprecated rules while all() excluded them, so catalog validate reported 38 where a scan runs 35.

Verification

make check                → exit 0; 104 passed, 1 skipped
sentinel catalog validate → 36 rules validate: 32 MUST, 4 SHOULD
                            3 deprecated rule(s) keep their ids
MUST recall               → 100% (25/25); 28 of 28 seeded violations detected
False positives           → 0

Test plan

  • make check green
  • MUST recall 100%, false positives 0
  • Both fixture scans run and their real output pasted into the README
  • Broker scan re-verified against a full make up stack (the stripe/smokescreen image failed to pull locally; postgres/broker/envoy were up and current)

Extends SN-HND-001 from the MVP tier to v1: three artifacts a company can
take -- the sentinel scanner, an extracted Go server kit, and a fleet service.

Section 3 exists because applying this repository's own precedence rule --
where the spec and the repo disagree, the spec wins -- found four places where
the repo is currently wrong, each verified against a live fetch rather than
inferred:

  * broker's error codes sit in -32000..-32019, which the revision retired
    ("new implementations SHOULD NOT use codes from this sub-range at all").
    CLAUDE.md and HANDOFF 7.2 both mandate it. They are wrong.
  * MUST/server-info-echoed and MUST/tools-list-is-deterministic grade SHOULDs
    as MUSTs, and SHOULD/tools-sorted-by-name demands something the spec never
    asks for at any severity. That is the false-positive class MEASUREMENTS.md
    promises is zero.
  * the probe never sends clientCapabilities, which is Required: Yes, so every
    request the harness sends is malformed.
  * the deprecation inventory stamps all six features 2026-07-28; the registry
    says includeContext is 2025-11-25 and HTTP+SSE is 2025-03-26, and neither
    removal window is expressible as +12 months.

Rule ids are permanent, so the severity corrections deprecate and supersede
rather than edit -- which requires a rule lifecycle the catalog does not have
yet, and gets one in WP-14.

The differentiator is gray-box evidence: the README already names what would
settle each of the five unverifiable MUSTs, and v1 accepts it. Absence of
evidence still returns INDETERMINATE and never a pass.

16 work packages, WP-14 through WP-29.
The gap is that no rule reads them, not that they are absent.
17 tasks across five work packages: the four verified spec debts, then five new
rule modules taking the catalog from 35 rules to roughly 74.

Three rules were corrected while writing the plan, against spec text fetched
rather than remembered: the MRTR new-id requirement is a client obligation and
has no server rule; the notification 202 is conditional and demanding it
outright would be a false positive; and GET/DELETE 405 is a SHOULD. The
Origin rule uses an RFC 2606 .invalid origin so that a served request is
evidence of no validation rather than of a permissive allowlist.
…editing a published id

Rule ids are permanent (HANDOFF 8.8): once published, an id never changes
meaning, or every historical report becomes uninterpretable. That leaves no way
to fix a rule whose severity turns out to be wrong -- which three of them are.

BaseRule gains introduced_in, deprecated_in and superseded_by, and Registry.all
excludes deprecated rules unless asked. validate_registry now requires a
deprecated rule to name a successor that exists, forbids two LIVE rules from
sharing a slug, and explicitly permits a deprecated rule and its successor to
share one -- which is the whole point of the mechanism.

Also introduces the SENTINEL/ namespace for rules this project believes in that
the specification does not require. They carry a rationale instead of a
citation, because a citation field pointing at nothing is how a catalog starts
lying.
Three omissions, each grounds for a conformant server to reject every request
the harness sends:

  * clientCapabilities is Required: Yes on every request. client.py imported
    the key and never set it.
  * "Every POST request to the MCP endpoint MUST include an
    MCP-Protocol-Version header." The probe sent Mcp-Method and Mcp-Name and
    nothing else, so a server enforcing this would answer 400 HeaderMismatch
    to everything -- and the harness would report it as broken.
  * "The client MUST include an Accept header listing both application/json
    and text/event-stream."

The header value is read back out of meta(version=version) rather than from
self.protocol_version, so header and body come from one source and cannot
disagree by accident; when the caller asks for an unversioned body the header
is omitted rather than manufactured, which is exactly the mismatch the header
rule exists to detect.

Literal is the point; wrong is not. Every override is preserved so a rule can
still break one field deliberately.
…sible one

The registry says includeContext was deprecated 2025-11-25 and HTTP+SSE
2025-03-26; the inventory stamped all six 2026-07-28 and computed +12 months
from it.

Worse, two removal windows are not date arithmetic at all. includeContext's
"Follows Sampling (SEP-2577)", and HTTP+SSE's is "three months after SEP-2596
reaches Final" -- an event that has not happened. Computing twelve months from
includeContext's own deprecation date would have produced 2026-11-25, eight
months early.

The window is now one of three variants. FollowsFeature chases the chain, so
includeContext inherits Sampling's 2027-07-28. AfterEvent is a dead end by
construction -- it holds a condition and a SEP and nothing else, and
months_remaining returns None for it -- so no code path can turn it into a
number. A report that prints a date it cannot know is worse than one that
prints the condition, because the number gets put in a plan and treated as a
deadline.

The six registry rows are checked in verbatim and a test compares against them,
so the next registry change fails a test instead of drifting silently.
…ace can fail

A SENTINEL/ rule is an opinion this project holds and the specification does
not. Letting one fail --gate must would make a conformance verdict
unfalsifiable, so gate() filters to Namespace.MCP before deciding the exit
code.

The text report marks a deprecated finding and names its successor; the JSON
report gains namespace, deprecated and supersededBy per finding. schemaVersion
stays at 1 -- adding fields is additive, and the v2 bump is WP-24.
serverInfo is "Required: No" under "Servers SHOULD include the following field
in every result's _meta". Tool ordering is "Servers SHOULD return tools in a
deterministic order". And the spec asks for deterministic, never for sorted --
a stable but unsorted manifest conforms fully, so tools-sorted-by-name was not
a spec rule at any severity and moves to the beyond-spec namespace.

That is the false-positive class MEASUREMENTS.md publishes as zero. The
measurement was honest; the conformant fixture just happens to satisfy all
three, so none of them ever fired.

Ids are permanent, so all three are deprecated and superseded rather than
edited, and --include-deprecated-rules reproduces an archived report. The three
check bodies move verbatim into catalog/checks.py so a deprecated rule and its
successor cannot drift apart -- which would make the deprecation notice a lie.

Live catalog: 35 rules, 31 MUST + 4 SHOULD.
…SARIF

report/sarif.py emitted "helpUri": "" and a help text ending in a bare
"Specification: " with nothing after it for a SENTINEL/ rule -- a citation
pointing at nothing, in the one report format a reviewer reads inside GitHub.
That is precisely the failure the namespace was introduced to make impossible,
so it must not be reintroduced by the renderer. A beyond-spec rule now omits
helpUri entirely and carries its rationale instead, with the namespace and
lifecycle in properties.

Also: `sentinel catalog validate` counted deprecated rules in its headline, so
it reported 38 where a scan runs 35. The headline is now the live count and
the deprecated rules get their own line naming each successor.

And two assertions predated the namespace: severity-in-the-id and
citation-as-helpUri are MCP-namespace properties, and validate_registry
already scopes both. The tests now do too, plus a new one asserting the
beyond-spec case positively rather than merely exempting it.
"This set ... MUST NOT vary per-connection or as a side effect of other
requests on the connection." Proving that needs two connections; the
deprecated rule made twenty calls on one, so a server returning a stable list
per connection and a different stable list on the next passed it.

Compares membership, not order, and holds the credential constant -- the spec
permits the set to vary by authorization, and order is graded SHOULD by the
successor rule, so comparing either would make one defect fail two rules.
The non-conformant fixture now returns a different SET of tools per connection,
not merely a different order, so MUST/tools-list-connection-independent has
something to catch. SEEDED_VIOLATIONS names the three successors instead of the
deprecated ids.

Two things the reconciliation turned up that were not in the plan:

scripts/measure.py counted only MUST failures as detections, so the three
non-MUST seeds would have been scored as misses and published as a recall
regression. Recall is now computed per namespace/severity bucket. MUST recall
stays 100% (25/25); all 28 seeded violations are detected.

The README's broker scan example omitted --token. resources/read is
authenticated, so the command as printed exits 1 on resource-not-found-is-
invalid-params -- CI has always passed a token. The example now does too, and
says why.

runbook.md, demo/README.md and scripts/demo.py each hard-coded "26 MUST
failures", and MIGRATION.md's deterministic-ordering entry named two deprecated
ids and recommended calling tools/list a hundred times -- which is precisely
the test that cannot see the per-connection MUST.
@patsypppe
patsypppe merged commit a20e553 into main Aug 24, 2026
7 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant