Internals Walkthroughs - NormB/sipnab GitHub Wiki
Six ordered checklists for the changes people actually make.
- Each step names the test that fails if you skip it.
- Where no test enforces a step, it carries (unenforced).
- That is prose, and prose is what gets forgotten.
- So those are the steps to be deliberate about.
Checking the enforcement claims here meant making the change and watching the gate fail. More often than expected, it meant watching it pass.
Where the first draft of this page and reality disagreed, reality won:
- Adding a CLI flag does not trip
docs_drift_test. - The compiler turns away an MCP tool that holds a lock across an await before clippy gets a word in.
-
Three of the six checklists turned out to name gates that do not fire at all
for the change they sat beside.
- Those steps now carry (unenforced).
The pattern behind all three: a gate that hardcodes its subjects cannot see a new subject.
- Examples of hardcoded subjects:
- Three flood scenarios.
- Two output-flag behaviors.
- Until it was rewritten to enumerate the directory, a list of schema filenames.
- Such a gate protects what exists from regressing.
- It does not notice an addition.
- Assume nothing enforces a new thing until you have watched it fail.
- Add the
#[arg]field toCliincli.rs, with ahelp_headingmatching its group.- โ
cli_help_testchecks the grouping of the ~160-flag help output.
- โ
- Wire it wherever it takes effect.
- Usually that is
plan(). - So the flag becomes part of
RunPlanrather than at the point of use.
- Usually that is
- Give it a row in
settings.rs.- The row names the
[section] keyit shares with the config file.- It also names how the two combine
(
Merge::Override,Either,UnionorOff).
- It also names how the two combine
(
- Or the row names why it has no key
(
Action,Input,Secret,PerRun). - A standing setting gets a key, added
to
config.rsbeside its peers. - โ
settings::tests::every_flag_has_exactly_one_rowfails immediately withnot classified: zzz-gate-probe. - Once the row names a key, the two reference gates in the same module demand two things:
-
docs/cli-reference.mdsaysConfig: [section] key. -
docs/config-reference.mdnames the flag.
-
-
SIPNAB_SETTINGS_APPLY=1 cargo test --features full --lib settingswrites both.- Then
python3 scripts/build-site-pages.pycarries them to the site.
- Then
- โ
config_wiring_testcatches a config key that is never read.
- The row names the
- Add a test that references the flag by name.
- Any test, anywhere under
tests/, including a.trycmdgolden. - โ
flag_coverage_testfails immediately without one.-
zzz-gate-probein its message is the flag you just added.
-
- Any test, anywhere under
- Document it in at least two examples across the docs.
- โ
doc_example_coverage_testfails with--your-flag: 0 example(s)until you do.
- โ
- Add a default-value case to
cli_defaults_test.- (unenforced โ that test does not enumerate the CLI, so a missing case passes silently.)
A config key with no flag goes in settings.rs's FILE_ONLY list with its reason.
- Verified 2026-10-03:
- A throwaway
zzz-gate-probeflag failedevery_flag_has_exactly_one_row. - A throwaway
[capture] zzz_probe_keyfailed two checks:-
every_config_key_is_named_once(unclassified key). -
every_config_key_has_a_row_naming_its_flags(no row in docs/config-reference.md).
-
- A throwaway
Verified: adding a throwaway #[arg(long = "zzz-gate-probe")] had these results.
- It failed
flag_coverage_testanddoc_example_coverage_test. -
docs_drift_test,cli_defaults_testandconfig_wiring_testall stayed green. -
docs_drift_testguards the other direction.- That is a flag named in the docs that the CLI does not have.
The two gates that do fire, in the order you meet them:
sequenceDiagram
autonumber
participant Dev as you
participant Cli as src/cli.rs
participant FC as flag_coverage_test
participant DE as doc_example_coverage_test
participant CI
Dev->>Cli: add #[arg] field
Dev->>FC: cargo test
FC-->>Dev: FAIL โ flag referenced by no test
Dev->>Dev: add a test or a .trycmd golden
Dev->>DE: cargo test
DE-->>Dev: FAIL โ 0 example(s) in the docs
Dev->>Dev: document it twice
Dev->>CI: push
Note over CI: both gates are ratchets โ the exemption lists may only shrink
Model it on the newest one, StreamLossMap.
Its commit touched exactly these places.
- Add the variant to
Viewinstate.rs. - Add the renderer.
- It is a module beside
loss_map.rs. - Register it in
render/mod.rs.
- It is a module beside
- Keep the computation out of the renderer.
-
StreamLossMap's binning core lives inrtp/loss_map.rswith its own unit tests. - So the view logic is testable without a terminal.
-
- Add the controller in
controllers/.- Route it in
controllers/mod.rs.
- Route it in
- Add the key that opens it, in whatever view it opens from.
- Document that key in
HELP_TEXTinhelp.rsand indocs/keybindings.md.- โ
keybinding_drift_testfails withthese handled KeyCode::Char keys are neither in the F1 help nor allow-listed. - It names both the view and the character.
- โ
- Add snapshot coverage in
tui_snapshot_test.- At minimum, the populated case and the empty/degenerate case.
- Mirror the keybinding into
website/content/docs/keybindings.md.- (unenforced for a new key โ the mirroring obligation is a convention. See CONTRIBUTING.)
Verified: binding KeyCode::Char('Y') in the call-list controller without
touching the help failed two assertions in keybinding_drift_test.
- Add the handler to
server.rsunder the#[tool_router]impl.- Give it a
#[tool(name = โฆ, description = โฆ)]attribute.
- Give it a
- Write the description as a statement of what the tool returns.
- Never instruct the model to "trust", "verify", "act on" or "ensure" anything about the content.
- That is the prompt-injection rule (D22 in
../design/implementation-plan-phases-8-10.md). - (Enforced by
tests/mcp_tool_descriptions_test.rs, which fails on any of those four words in a tool description.) -
(Corrected 2026-08-05.)
- This used to call the rule unenforced.
- It also used to claim
server.rscited ascripts/check-tool-descriptions.shthat does not exist. - Both halves are now wrong.
-
server.rsno longer carries the citation. - The rule shipped as a Rust test rather than the shell script the plan named.
-
- That test also carries
every_cited_script_exists.- It fails if the dangling citation ever comes back.
- It now scans every tracked
.rsfile whole, not the first 40 lines of one.- The reason: the narrow version asserted nothing once the citation went away.
- Take the store guard, project what you need into owned data, drop the
guard, and only then
.await. - Bound the response with
resolve_limit_with_cap().- Pass the caller's
limitand the server'srow_cap. - Also bound it with the body-bytes cap (
--mcp-max-body-bytesmoves it). - A tool that pages with a cursor also takes
max_response_bytes.- It passes its serialized page, after any
fieldsprojection, throughSipnabMcp::fit_to_budget. - It passes the cursor that resumes after each row.
- So the whole response stays inside
--mcp-max-response-bytes(budget.rs).
- It passes its serialized page, after any
-
Not
resolve_limit().- That one takes no ceiling.
- So a tool wired to it silently ignores
--mcp-max-rows. - The operator sets a limit and the tool does not honor it.
-
shape.rssays as much at the definition.- It is "kept for callers with no configured ceiling".
- The server is not one of them.
- This step used to name the wrong function.
- That is how a recipe written for the reader who does not yet know manufactures the very defect it exists to prevent.
- Pass the caller's
- Add an end-to-end case to
mcp_stdio_test.- For HTTP-visible tools, also add one to
mcp_http_test.
- For HTTP-visible tools, also add one to
The step that drops the guard is not a style preference.
- The rmcp router requires each handler's future to be
Send. - A
parking_lotguard held across an await makes it non-Send. - So the build fails on the
#[tool]attribute with "future cannot be sent between threads safely".- That happens before
clippy::await_holding_lock(denied at the workspace level) is even consulted.
- That happens before
- There are two independent mechanisms. The stricter one is the compiler.
sequenceDiagram
autonumber
participant Client as MCP client
participant Tool as tool handler
participant DS as DialogStore
participant Shape as shape.rs
Client->>Tool: call_tool
Tool->>DS: read()
DS-->>Tool: guard
Tool->>Tool: project into owned summaries
Tool->>DS: drop(guard)
Note over Tool: the guard is gone โ the future is Send again
Tool->>Shape: resolve_limit_with_cap, body cap
Shape-->>Tool: bounded payload
Tool-->>Client: CallToolResult
- Add the module under
src/security/.- Put it beside
scanner_detect.rsorreg_flood.rs. - Export it from
security/mod.rs.
- Put it beside
- Keep detector state bounded and attacker-keyed maps capped.
- A detector is by definition fed hostile input.
- (unenforced โ
resource_bounds_testis three hand-written scenarios overDialogStoreandRtpStream. It has no way to discover your module.)
- Run its check from
run_detectorsinsecurity/detectors.rs.- That returns the finding as an
Effectfor the batch loop to file. - Emit through the alert engine in
alerting.rsrather than printing. - So every sink (CLI, TUI,
fail2ban, MCPsecurity_findings) gets it. - A new rule name also belongs in two places:
-
SECURITY_FINDING_KINDS(mcp/server.rs).- This is the vocabulary
security_findings.kindsaccepts. - A finding filed under a name missing from it is one no caller can filter for.
- This is the vocabulary
-
DetectionEngines::armed_kinds(batch.rs).- This is what tells an agent the run armed the detector at all.
-
- (enforced โ
security_findings_kinds_match_the_names_the_detectors_file_underreads thekind:literals inbatch.rsand fails on the difference.)
- That returns the finding as an
- Add regression coverage to
security_test.- Include the false-positive case.
- A detector that fires on normal traffic is worse than no detector.
- (unenforced โ nothing requires a new detector to have a test.)
Verified: a new src/security/ module held an uncapped HashMap<IpAddr, u64>.
mod.rs exported it.
It left resource_bounds_test at 3/3 and security_test at 38/38.
Both steps above are real obligations with no gate behind them.
That is exactly why this page writes them down.
- If it can transmit anything, it must be opt-in.
- It must respect the HEP-origin restriction (SN-01).
- HEP-sourced packets are ineligible for active response without
--hep-allow-kill.
- Project through
output/model.rs.- Do not build a bespoke JSON object for a dialog or a stream.
- (unenforced for a new surface โ
summary_consistency_testpins the three surfaces that already exist, CLI/API/MCP, againstDialogSummary/StreamSummary. A fourth writer is invisible to it.)
- Add the writer beside
json.rs/cli_print.rs.- Emit through
sink.rs.
- Emit through
- If it is machine-readable, add or extend a schema in
tests/schemas/.- There is no registration step.
- The
all_schemas_compilecase injson_schema_testenumerates the directory. - So a new file becomes a validator the moment it lands.
- A malformed one fails there.
- What is still on you is live-output validation.
- That means proving the surface actually emits what the schema describes.
- Add behavior coverage to
output_behavior_test.- (unenforced โ it is two tests about
--json-prettyand--call-reportexit codes.)
- (unenforced โ it is two tests about
History, and the reason the schema step now reads that way.
A deliberately malformed zzz_gate_probe.schema.json was once dropped into
tests/schemas/.
It left json_schema_test at 6 passed, 0 failed.
The test walked a hardcoded list of four filenames and never opened the file.
That experiment is what motivated the switch to directory enumeration.
The test's own doc comment records it.
The model, writer and behavior-coverage steps are still convention with no gate behind them.
- Document it in
docs/output-formats.md.- Mirror it into the website page.
- The flag that selects it then owes the CLI-flag checklist above.
- A new finding kind or evidence count label in
src/analysis.rschanges two published contracts.- A test holds each one to the table.
- Give the variant its arm in
ordinalandmeta(oras_stranddescriptionfor aCountLabel). The compiler insists. - Give it its place in
ALL, which aconstassertion checks. - Then add it to
tests/schemas/capture_analysis.schema.json.-
json_schema_testfails until thekindenum or thecountsproperties name it.
-
- Last, the YANG module.
- Users already hold its current revision, so do not bless over it.
- Add an entry to
REVISIONSinsrc/analysis/yang.rswith the day's date. - Point
MODULE_TEXT'sinclude_str!at the new file name. - Run
SIPNAB_BLESS_YANG=1 cargo test --features full --test yang_module_test. - Keep the previous file under
yang/beside the new one. -
yang_module_testfails until the committed module names the kind. -
scripts/check-yang.pyrunspyang --check-update-fromover the pair.
- Add
fuzz/fuzz_targets/<name>.rscalling one parser entry point on&[u8]. - Register the
[[bin]]infuzz/Cargo.toml.-
(unenforced, and this is the trap.)
- An unregistered file under
fuzz_targets/is not a broken build. It is an invisible one. - Cargo never compiles it.
- So
fuzz-checkand the pre-pushcd fuzz && cargo checkboth pass. - Your target silently never runs.
- An unregistered file under
-
(unenforced, and this is the trap.)
- Seed
fuzz/corpus/with at least one real input.-
(unenforced โ despite the name,
fuzz_corpus_replaynever opensfuzz/corpus/.)- It has no filesystem access at all.
- It drives the parsers with an adversarial seed set and a mutation sweep defined in the file itself.
-
(unenforced โ despite the name,
- Add the same entry point to
smoke_fuzz_test.- Coverage-guided fuzzing needs nightly and runs weekly.
- The smoke floor runs in every
cargo test. - It is what actually catches the regression.
- When a target finds a crash, commit the reproducer into the corpus in the
same change as the fix.
- The seeding step's replay does not read the corpus.
- So add the input to
smoke_fuzz_testtoo, if you want it replayed on everycargo test.
Verified: an unregistered fuzz/fuzz_targets/zzz_gate_probe.rs contained a
hard compile error.
- It left
cd fuzz && cargo checkat exit 0. - Registering the same file made the same error fail the build with exit 101.
- The gate only ever sees registered targets.