Skip to content
AuthorPascal DateAugust 14, 2026 Rev2.7

Coding guidelines

The conventions every binabik codebase follows. They're distilled from how the brain (robot-voice-chat) is built; the same patterns apply to the MCP servers and other services. This page is the current rule; the code is the current truth.

Audience

Engineers writing or reviewing binabik Python and TypeScript code — the brain, the shared MCP services, and the L3 skill-services.

The one rule that drives all the others

Program to ports, not implementations. Every capability that touches hardware, the network, or has more than one variant is an abstract port; the concrete adapter is chosen at composition time from config. Everything below follows from that.

Language, tooling & stack

  • Python 3.11+ (requires-python = ">=3.11" in every repo), managed with uv (lockfile committed). Node with pnpm. The brain image ships on Python 3.12; the on-robot MCP services run 3.11. robot-mcp-kit is the one exception, at >=3.10, and the robot decides that: two consumers import it through a --system-site-packages venv on the ROS distro's interpreter, which is 3.10 on Humble (BIN-426). It is CI-tested across 3.10–3.13, so code that lands there cannot use StrEnum or datetime.UTC.
  • ruff for lint + format (line-length 100); mypy --strict for types. Both must be clean — zero warnings — and are enforced in CI. Pre-commit hooks run them on staged files. Run the full local check before every commit:
    uv run --no-sync pytest -q && uv run --no-sync ruff check && uv run --no-sync mypy
    
  • Backend framework: FastAPI (async-native) + Uvicorn/uvloop, Socket.IO (python-socketio, ASGI) for realtime, orjson responses.
  • Never ASCII-escape display or model-facing text. When you json.dumps(...) a value whose string result is shown in the UI or handed to the planner as a turn (a snippet, an event summary, a recovery/teaching trace line), pass ensure_ascii=False (or use orjson, which never escapes). The stdlib default ensure_ascii=True rewrites ° as its six-character \uXXXX escape; because that string is used directly — not re-parsed through json.loads — React renders the escape verbatim and the model echoes it into its summary. ensure_ascii=True is only acceptable on pure machine paths that always round-trip back through a JSON parser (e.g. tool-call arguments). (BIN-103)
  • Config: two shapes, chosen by sizeHydra (composition + DI) + Pydantic v2 (validation) where implementations are selectable, and a frozen dataclass built from the environment where they are not. See Configuration; pick deliberately, don't assume Hydra.
  • External calls use native async SDK clients (AsyncGroq, AsyncOpenAI) over a shared httpx.AsyncClient (HTTP/2, keep-alive) — never thread-pool-wrapped sync clients.
  • Logging: structlog (JSON in production).
  • Persistence: everything you write goes in a database, never a JSON or markdown file. Read from three places only — the config tree, the env file, the key store — and write through robot_mcp_kit.store.SqliteStore, declaring each table's Kind (record / definition / belief / derived) and its retention. Declare columns too where one row holds more than one kindr1-abstraction's waypoints is the worked example, and the kit refuses a Schema whose table kind is not exactly conservative_kind() of its columns, since a DELETE takes the whole row. For an fts5 index, declare it Kind.DERIVED and its five shadow tables need no mention at all: Schema.not_ours() derives them. Never give a shadow table a Kind of its own — it is not your table. (Both of these were local overlays until BIN-574; if you find a page still saying the vocabulary is three kinds and per-table only, that page is stale.) A CI test per repo (tests/…/test_writes_go_to_the_database.py) fails a write-shaped call outside a dict-with-reasons allowlist. The rule and its engine choice are argued on the brain app spec; the shared class and the two guards are robot-mcp-kit §4.9. The useful test for the boundary is authorship: a file a human is meant to edit is config; anything the application produces is a row.
  • Frontend: Vite + React + TypeScript, Tailwind + shadcn/ui, Zustand for client state, TanStack Query for server state, socket.io-client.

Don't add

No ORM, no message broker, and no third-party DI container — in a Hydra repo, instantiate is the DI container and FastAPI Depends() covers request scope. Adding another is redundant.

No new database engine, either — and note that this entry used to say "no database (the app is stateless)", which stopped being true with BIN-531 and is the opposite of what BIN-546 now requires. The engine is settled: SQLite, one file per service, behind the kit's store class. A Mongo or a Redis means a process to supervise on both hosts and — worse — a network dependency for data whose whole job is to survive when the network is down. Where a document shape is genuinely wanted, a TEXT column plus json_extract() and an indexed generated column is one.

Repository layout, and what an MCP server owes its consumer

Every binabik Python repo has the same shape, so a new service is a copy of the template rather than a design exercise. This is the one statement of it: each design spec used to restate the whole list, five near-identical copies that drifted, so the specs now point here and carry only what is peculiar to their own repo.

  • Structuresrc/<package>/ layout (never flat); a config/ tree only in the repos that actually compose config with Hydra (see Configuration — most don't, and an empty one is a lie); tests/{unit,integration}/; a committed README.md (quickstart + how-to table + troubleshooting table) and CHANGELOG.md (Keep-a-Changelog + SemVer); .pre-commit-config.yaml; .github/ (CI + dependabot.yml); .env.example committed with .env gitignored; optionally a multi-stage non-root Dockerfile.
  • Pre-commit — trailing-whitespace, end-of-file-fixer, check-yaml/toml/merge-conflict/large-files, ruff (--fix), ruff-format, and a local mypy hook run via uv run so it picks up the pydantic.mypy plugin. Dependabot weekly for the uv ecosystem.
  • Optional extras are for genuinely optional paths (the --extra observability pattern) — never for something the default config needs; see Versioning & packaging metadata.

MCP servers specifically — one repo per capability, from the copy-me template:

  • Transport: stdio when the consumer supervises the process (one or two, co-located); otherwise sse / streamable_http with its own systemd unit or container, its own logs and restart policy, and the consumer holding nothing but a URL.
  • Tools: bare-named, with docstrings the planner can act on. Mark slow tools blocking; everything else is fire-and-forget. Expect a ~30 s call timeout by default (a deployed brain raises it — see R1 robot stack), and tolerate a client health-checking you with list_tools().
  • Resilience: the consumer reconnects on an interval, so design for a clean restart at any moment and never assume connection order at boot.
  • Deploy: a hardened systemd unit (NoNewPrivileges, PrivateTmp, ProtectSystem=strict), a dedicated user, journald, bound to localhost unless a remote brain must reach it — the two binabik robot-side servers bind 0.0.0.0 for exactly that reason.

Frontend & UI

The brain app (robot-voice-chat/frontend) is the only user-facing surface, so it carries a few standing conventions:

  • Type and colour are brand decisions, not per-component taste. The fonts and the palette every Binabik surface shares are on Brand — fonts & style; use the existing tokens rather than introducing a new font stack or hex value.
  • Every interactive control has hover text. Give each <button> / clickable control a native HTML title describing what it does — a concise, action-oriented phrase that says more than any visible label (e.g. a "Stop" button → title="Stop the robot and cancel this mission"). This is the default; don't add a button without a title.
  • Icon-only controls also get an aria-label. A title is not a reliable accessible name for screen readers, so any button whose meaning is carried only by an icon gets a matching aria-label (and aria-pressed/aria-checked when it's a toggle). Buttons with visible text already have an accessible name — a title is enough there.
  • Follow components/Header.tsx as the reference for both — it sets title + aria-label (+ aria-pressed) consistently. There's no shared tooltip component; keep using the native attributes so the behaviour stays uniform.
  • Warnings, errors and notices use the shared notice classes — never hand-rolled colours. frontend/src/index.css defines one notice system: a tone (notice-warning / notice-danger / notice-info) composed with a shape (notice-strip full-bleed under the top bar, notice-card inline in a page, notice-overlay over the camera frame), plus notice-action for the notice's own button. Reach for those; only layout utilities (mx-5, pointer-events-auto, w-full) compose on top. The rule exists because ad-hoc Tailwind pairs don't survive being reused on a different surface: bg-amber-500/15 text-amber-300 was written for the Visual view's notices, which float over a dark video frame, and when BIN-140 moved the same banners into the light app chrome the contrast fell to 1.2:1 — amber text on an amber fill, on a cost warning and on the note explaining why voice input was missing (BIN-157). Each tone therefore carries two palettes and the shape picks the one matching its surface. A warning nobody can read is a warning that isn't there, so when a notice conveys spend or a lost capability, treat its contrast as functional, not cosmetic: check it against the surface it will actually render on.
  • Chrome floating over the camera gets its contrast from a scrim, not from opacity. A fixed low-opacity fill cannot be legible over both a dark and a bright frame, and scene brightness is not ours to choose — the Visual view's top bar dropped to 2.1:1 over a bright sim shelf (BIN-159). Put the dark base on the stage's own decorative layer (.stage-backdrop, below the panels) rather than on the bar: a gradient on the bar itself fades out across its own content and tints the panels beneath it, which is backwards. Then verify against the worst case — composite the fill over pure white, not over the frame you happen to have on screen.

    Corollary, and it is the one that got missed: a control's contrast is a property of where it is, so moving it is a colour change. A scrim is a gradient — it has to fade out, or it would black out the video — so any chrome that travels can travel out of its own base. BIN-163 slid the Visual view's chrome ~57 px down to clear the peeked navbar and took its backing nowhere: over a white frame the bottom toggle went from 6.42:1 / 6.39:1 (off / on) to 3.05:1 / 2.32:1, sub-AA in both states, at 0.22 scrim alpha against 0.61 — a contrast bug with a purely geometric cause, which is why BIN-164's colour work could lift it from 1.38:1 to 1.98:1 and no further (BIN-168). Do not answer it by darkening the band until the number passes; that trades legible chrome for an unusable camera. Declare the band's stops relative to the same measured offset the chrome yields by (--stage-scrim-offsetnavPeekHeight) so the profile travels with it, and fill the strip it vacates with the first stop's value — behind an opaque bar, that is invisible. Every control then keeps exactly the alpha it was tuned against and nothing on screen gets darker. Use a background offset, not a transform: paint cannot reflow the frame at any value, and translating the layer would leave an un-scrimmed sliver under the bar's edge. - A control's state is a defined pair, and the inactive half needs a boundary. On/off state chips use state-chip + state-on / state-off (frontend/src/index.css), which are geometrically identical, so toggling recolours the chip and never shifts the layout. Both carry a ring as well as a fill, because an inactive state is a pale fill on a near-white page (~1.2:1) — the boundary is what makes it a chip rather than stray text — and the ink is chosen against the chip's own fill, not against the page. The Fleet self-improvement default read as bare text when off (BIN-161), which is a poor answer for the switch that decides whether robots spend money learning: off and broken must never look the same. - A control with no text leans entirely on its boundary — outline the handle too. The switch beside that chip is the same pair in a different shape (switch-track + switch-on / switch-off, with the geometry in the base class so the variants are pixel-identical). It had the same defect and one more: its track was invisible when off and its white handle was invisible on the track, so the control was a blank pill holding a blank dot. A badge can fall back on its label; a switch has none, so the ring is the whole affordance — one --switch-line per variant, drawn on the track and the handle. Off is var(--border) fill + var(--muted-foreground) ring (7.58:1 on the white card, 6.15:1 on its own fill); on keeps --primary with a darkened-red seam. Note which half of the pair the reporter is describing before fixing: the first BIN-161 pass fixed the adjacent badge, and the red switch Pascal actually named stayed broken for another round. - Two overlays must not share an edge and a stacking level — and the one that moves must not be the content. When two independently-owned overlays are pinned to the same screen edge at the same z-index, whichever paints last hides the other, and no z-index change can fix it: raising one merely picks which of the two is unusable. One of them has to give ground. In BIN-163 the app's peeked navbar (fixed inset-x-0 top-0 z-40, owned by App.tsx) and the Visual view's own control row (absolute inset-x-0 top-0 z-40, owned by VisualCommandView.tsx) were exactly this, and the fix has three parts worth reusing: 1. The overlay yields, never the page. Move the overlay out of the way with a CSS transform, not with top / margin / padding. A transform is outside layout, so nothing reflows — which matters most where the content is a live camera frame: a video that jumps every time some chrome appears is a worse bug than the occlusion. Corollary: put the transform only on the overlays, never on the element being watched or on any box that sizes it. 2. Measure the thing you're yielding to. Read its height at runtime (ResizeObserver + offsetHeight, published through the UI store) instead of hard-coding the pixel you see today. A bar's height is a layout outcome — it changes when it wraps, gains a row, or has strips stacked into it — and a stale constant is how the collision comes back with nobody noticing. 3. Shift every overlay on that edge, by the same amount. They are positioned relative to each other, so moving only the topmost one just relocates the collision one layer down onto the notice column or the dialog below it. A uniform translate preserves every existing gap. 4. An overlay-only yield does not reach the page — so enumerate what is in flow on that edge too. A transform moves the elements you put it on and nothing else, which is exactly why it is safe and exactly how BIN-163 left half a bug behind: Layout's notice strips are in flow, got no yield, and stayed under the bar (BIN-168). When the thing being covered cannot become an overlay, the overlay is what gives ground — offset it by the in-flow block's measured height (0 when the block is empty, so the common case is untouched) rather than pulling the block out of flow to hand it the mechanism you already have. - A warning about spend must not be occludable — and geometry alone does not get you there. BillableServiceBanner exists so that a paid API being called is visible from wherever you are, with its off switch to hand; it was hoisted out of the chat page for exactly that reason (BIN-140), which is also why it must stay in flow — it has to show in the default state, not only when some bar is open. That makes "you can hide the cost warning by opening the menu" a real defect, not a layout nit (BIN-168). Two cheap mechanisms, and use both: put the strip below whatever chrome could cover it (measured, above), and give it the higher stacking levelrelative buys a z-index without leaving the flow. The second is what covers the ~200 ms an animating bar spends sweeping across the strip, and what keeps the invariant when the next overlay forgets the offset. Same asymmetry as the fail-safe rule for billable indicators below: a warning that can be concealed is worth little more than no warning. Corollary for the grip of any collapsible chrome: it moves with the surface it opens. A handle left pinned to the edge lands on the strip, and being earlier in the DOM than the block it collides with it also loses the hit test — so the bar stops being openable by pointer at all. - A reveal must be triggered by intent, not by travel — and it needs something to aim at. An invisible hover strip is not an affordance: it fires on the way past, so the gesture of reaching for whatever sits just inside it is the gesture that covers it up (BIN-163 again — a 6 px aria-hidden tripwire). Give the reveal a visible handle (a real <button>, so Enter/Space work and opening never needs a pointer, with the usual title + aria-label + aria-expanded), gate hover on a dwell of ~300 ms that a pointer merely passing through will not satisfy, and let it close on leaving and on Escape. A global Escape handler must stay additive: bind it only while the thing is open, never call preventDefault/stopPropagation, and bail on event.defaultPrevented — otherwise hiding a bar becomes the reason a modal stops closing. - Register every theme colour as rgb(var(--x-rgb) / <alpha-value>) — a bare var(--x) makes the whole theme partly inert. Tailwind has to compose an alpha into the value it is given. A colour registered as a bare var(--primary) is unparseable to it, so it cannot build rgb(r g b / .1) — and it responds by emitting no rule at all for bg-primary/10. No build error, no lint warning, no type error, and the JSX still reads correctly. Store the channels (--primary-rgb: 219 36 36) and let the config read them through <alpha-value>; keep a derived --primary: rgb(var(--primary-rgb)) so hand-written CSS still has a real colour and the value lives in exactly one place.

    That one line in tailwind.config.ts had silently swallowed 117 utilities across 26 files — an admin sidebar with no active highlight, three text inputs with outline-none and a focus ring that compiled to nothing (so no focus indicator at all), and a ToggleBtn whose on state was the half without a fill, so the toggles read backwards. It cost four separate "hard to read" reports (BIN-157, BIN-159, BIN-161 twice) and misled three engineers before anyone grepped the compiled CSS (BIN-164).

    The sibling failure is the same silence from the other direction: a utility naming a token that doesn't exist. bg-muted emitted nothing for months because the theme had --muted-foreground and no --muted (BIN-162) — and text-primary-foreground was near-black-on-red (3.66:1) for the same reason. So keep the palette closed: every X-foreground / X-ink token has its X, and vice versa.

  • A class that compiles to nothing is invisible to the toolchain, so the guard must be a compiled-CSS check. Nothing in tsc, ruff, eslint or a code review can see this class of defect; more care demonstrably does not work, because three agents in a row read the same correct-looking JSX. robot-voice-chat/frontend/src/theme.guard.test.ts runs the real config through the real PostCSS pipeline over all of frontend/src and fails on any colour utility that produces no rule — plus the structural invariants above. Two things make it hold up: it strips comments (these files quote the dead classes they fixed, and a backtick span reads as a perfect one-token class list), and it only flags an alpha modifier when the modifier-less base does compile, which makes it immune to prose like click-to-pick that a naive sweep reports as a to-* utility. Mutation-check any replacement: a guard that stays green when you reintroduce the bug is decoration. When a fill "isn't showing up", grep the built CSS (backend/src/robot_voice_chat/static/assets/index-*.css) for the rule before blaming opacity or stacking — "it's in the JSX" is not evidence, it is the entire bug.

  • A visible fill forces a darker ink — and the arithmetic decides, not taste. Putting any tint behind text lowers its contrast, and for ink that was already close to the 4.5:1 line there is no fill light enough to rescue it: solving for the required fill luminance lands above pure white. Two tokens hit this wall in one afternoon. --muted-foreground was slate-500 (4.76:1 on white, 4.34:1 on a slate-100 chip) — which is why BIN-162 refused to define --muted at all, correctly, since defining it alone would have traded an invisible fill for sub-AA text. And --primary red is 4.90:1 on white but only 4.18:1 on its own 10% tint, so the accent-chip family — and .btn-accent, in hand-written CSS where color-mix() gave no protection — was under AA the moment the fills appeared. The fix in both cases is a named ink token paired with the surface (--muted-foreground → slate-600, 6.92:1 on the chip and 7.58:1 on white; --primary-ink = --primary at 70% over black, 7.15:1 on a /10 tint), never a lighter fill.

    Never dim an ink token with an alpha modifier — it is the one edit that got worse when the mechanism above was fixed. text-muted-foreground/50 composites to 2.34:1 on white and text-primary/50 to 2.15:1, but while the theme was inert both emitted nothing, so the element inherited a readable colour from its parent and looked fine. Repairing the theme turned each into real, unreadable CSS — a regression introduced by a fix, and one the compiled-CSS guard structurally cannot catch, because a dimmed ink emits a perfectly valid rule. An ink token is already at its contrast floor; halving it lands near 2.2:1 every time. Two remedies, in order of preference: if the text is decoration (DebugPanel's () after a tool name), delete the wrapper and let it inherit the parent's ink; otherwise go full strength. If it genuinely must recede, that is a different token, not a fainter one.

  • Turning on ~100 never-seen fills is a visual change; audit it, don't just compile it. When a mechanism fix makes many dormant utilities render at once, "it now emits a rule" is only half the work. Compute the real composited ratio for each fill and for any text on it — over the worst-case surface, which for chrome above a camera frame means compositing against pure white — and restyle whatever lands under AA before shipping. Write the computed number into the stylesheet next to the pair, the way index.css does; a ratio in a comment is the only form of this that survives the next edit.

    A file the audit skipped is a hole in the audit, so give it its own issue. BIN-164 left DebugPanel.tsx alone because a concurrent agent held that file for BIN-102 — the right call under the parallel-agent rules, and exactly how a sweep quietly ships at 96%. What made it recoverable was naming the three deferred sites in the changelog bullet and the count in the PR, so the follow-up was a lookup rather than a re-audit. Record the exception where the work is recorded, never only in the PR conversation. - An indicator of live state is derived, never remembered — and a socket event is a memory. agent_active, surveillance_status and friends say what was true at the instant they were sent; nothing ever un-says them, so a store field fed by one latches. Draw a live badge from it and the badge outlives the thing it describes: BIN-165 shipped four of them (● watching, an amber ring, MONITORING, an orange Surveillance · watching execution) all still on screen while GET /api/surveillance reported {"enabled": false, "active": false}. Emitting a matching "off" event fixes the instance; gating every consumer on the authoritative flag fixes the class, because the next exit path someone forgets can no longer produce a lie. Two rules follow: 1. One reader per latched value, and it returns the derived answer. useFloor() is the only place debugStore.agentActive is read; components take the gated result. A source-scanning test (floorGate.guard.test.ts) keeps it that way — the fix that matters is the one consumer number five inherits for free. 2. Pick the fail-safe direction explicitly, and say so in the code. For anything billable the two errors are not symmetric: showing "off" while it bills costs money, showing "on" while it is idle costs trust, and a badge nobody believes is worth less than no badge. So believe an authoritative "it is running" on its own (a lost event must never hide a bill), believe an authoritative "off" on its own, and let the prompt- but-latching event decide only inside those bounds. Verify against the API, not the source: the whole failure mode is that state and screen disagree, so a regression test asserts what renders given a stale event plus an authoritative "off" — and gets mutation-checked, because a test that passes with the gate removed is not a test.

Architecture: ports & adapters

The backend is strictly layered (hexagonal); dependencies point inward only:

Transport   api/ (REST)      websocket/ (Socket.IO)        ← adapters in
Application pipeline/ (orchestrator, dispatcher, queue)    ← use cases
Domain      services/**/base.py  (abstract ports)          ← interfaces
Adapters    services/**/*.py     (concrete implementations)← adapters out
  • The application/pipeline layer imports only abstract ports (base.py), never a concrete adapter. This is enforced in CI with an import-linter contract.
  • Define a port as abc.ABC with @abstractmethods when it is instantiated via Hydra (_target_ needs a concrete class). Use Protocol only for purely structural, never-instantiated contracts.
  • Ports today: Recorder, AudioPlayer, DeviceProbe, UtteranceSource, STTProvider, LLMProvider, TTSProvider, ToolBackend, plus the modality ports InputChannel / SpeechOutput. Provide a Null* adapter (e.g. NullToolBackend) for the "disabled" case rather than sprinkling if enabled.
  • Fallback chains (recorder, audio player) are explicit composite adapters (Chain of Responsibility), not inline try/except.
  • One brain build serves every robot. Anything robot-, task-, or customer-specific is a registered service or a config value — never a fork.

Dependency injection & composition

  • A single CompositionRoot.build() assembles the object graph once at startup; adapters declare their dependencies in their constructors — no globals, no service locator.
  • instantiate(cfg.stt) reads the _target_ from the composed config and builds the right class. Switching an implementation is a one-line config change — there are no if provider == ... factory ladders anywhere.
  • All initialization happens in FastAPI's lifespan, never at import time. Load Hydra config with the Compose API (not @hydra.main) so the process stays a clean long-running service.
  • Routes receive ports, not concretes, via thin Depends() providers that pull singletons from app.state. Tests override them with app.dependency_overrides[...].

FastMCP(lifespan=…) is not FastAPI's lifespan — it is per session. (BIN-381)

The rule above says initialize in lifespan, and it is right for the brain, which is a real FastAPI app. On an MCP server the same word means something else, and the difference is invisible until you measure it: FastMCP forwards lifespan to the low-level MCP server, where it is entered when a client session initialises and exited when that session ends — not to the ASGI app. Nothing warns you. The constructor accepts it, mcp.settings.lifespan really is your function, and the server starts cleanly.

binabik-world-state put a background poll there, following this very bullet. Against a real server:

A) before any client connects -> poll: None      ← nothing running at process start
B) client 1 connects           -> poll: RUNNING
   poll stopped                                  ← client 1's session ends
C) client 2 connects           -> poll: RUNNING

Two faults from one mistake: nothing runs until a client connects, and the first client to disconnect tears the thing down for every client still attached — which for a per-robot service with two brains on it means one operator's exit silently degrades the other's.

So for a long-lived process-wide task in an MCP server, do not use lifespan. Own the task where its lifetime belongs — arm it idempotently from the code path that needs it (ensure_body_poll() called by the read it serves) — so it outlives every session, or wrap the ASGI app's own lifespan if you genuinely need it at boot. A service nobody has connected to doing nothing is usually the right behaviour anyway.

And test that it RUNS, not only that it behaves. That poll shipped with eight passing tests; every one called start_body_poll() itself, so all eight proved the loop worked and none proved it was ever started. Pinning the enable flag did not help either — the flag was correct and the wiring was not, and asserting a flag reads exactly like asserting the feature. Drive the real server over a real session and assert on what the downstream actually received. Same defect landed independently in the brain's watchdog (BIN-377), whose whole loop sat behind an unrelated flag.

Configuration

Hydra is not the stack-wide mechanism — check the repo before you reach for it

Two live repos of ten use Hydra: robot-voice-chat (heavily — everything below is written from it) and segmentation-modal. The other eight — grasp-service, r1-abstraction, binabik-world-state, binabik-r1-vision, binabik-scene-perception, robot-mcp-kit, robot-mcp-memory, skill-service-template — configure themselves from a single frozen dataclass built out of the environment, and pull in no hydra-core at all. That is not drift; it is the right call at that size, and a comment claiming Hydra selection in a repo that has none is a real defect (it was found and corrected in binabik-scene-perception's base.py).

The deciding question is whether an implementation is selectable. Hydra earns its complexity when a port has more than one adapter that a deployment picks between (the brain's STT/LLM/TTS providers; the retired ros2-memory's recorder=ros2bag|none). When a service has exactly one of everything and a dozen tunables, the config is the env, and Hydra buys nothing but a config tree to keep in sync.

The env-dataclass convention, for the eight:

  • One module, one @dataclass(frozen=True), one loader. grasp_service/config.py is the pattern: a frozen Settings, a load_settings() that is the only place os.environ is read, and typed _int / _float helpers so a malformed value fails at startup with the variable's name rather than at first use.
  • Every field carries an inline comment naming what it bounds and, where the default is deliberately not the obvious one, why. This is the substitute for a commented YAML tree, and it is what makes the env table in the docs derivable from one file.
  • The fail-safe rule below still applies, and matters more here: there is no schema to catch a missing key, so a guard that protects against cost or harm must default to enabled with a warning, never to silently off.
  • The launcher's defaults are part of the contract. r1ctl deliberately overrides two of grasp-service's own defaults (GRASP_POLICY=deterministic over tiered, GRASP_STRATEGY_PACK=r1 over minimal), so the service's default and the deployed value differ on purpose — pin both ends with tests, as BIN-177 did, or the next reader "fixes" the disagreement.

The rest of this section is the Hydra half, and describes robot-voice-chat:

  • Two jobs, split deliberately: Hydra config groups + instantiate select and construct implementations; Pydantic v2 validates and types the composed result so a bad value fails at startup, never at request time.
  • One config group (a directory under config/) per port; one YAML per selectable implementation, each carrying its _target_.
  • No secrets in YAML or version control. API keys come from the environment (via .env, gitignored; .env.example committed), are injected through OmegaConf env resolvers, and are typed SecretStr.
  • No hardcoded values outside the config tree. Business rules (e.g. the 30 s MCP timeout) live as named config fields with a comment.
  • A Pydantic default is not a runtime value — the composed YAML is. load_config() builds AppConfig to raise on bad values and then returns the raw composed DictConfig, so a field that exists only in schema.py never reaches the running app. Two rules follow: every new section gets a block in config/config.yaml (that is the source of truth, not the schema), and a typed section is read by coercing it through its model at composition time (_load_agent, _load_observability, _load_idle_gate, …) — never getattr(cfg, "section", None), which turns a missing key into a silent no-op.
  • A guard that protects against cost or harm must fail safe. Absent config means enabled with defaults plus a warning, not "off". BIN-140 shipped the opposite: a good idle gate, correct unit tests, no idle_gate: key — so getattr(_CFG, "idle_gate", None) returned None, both live brains ran with no cost guard, and nothing was logged. Test through the real path (load_config() → composition → the built object): a test that constructs the config model itself stays green while production is inert.
  • Whole-deployment presets live under config/deployment/ and compose everything for one site.

Async & concurrency

  • The hot path is fully async on one event loop. Anything running in a background thread (audio capture, VAD) bridges onto the loop with loop.call_soon_threadsafe / asyncio.run_coroutine_threadsafe.
  • Shared mutable state (AppState) protects every field with a lock or threading.Event; expose it through typed accessors, not raw attributes.
  • Put a timeout on every blocking/external operation. No unbounded waits.
  • A wrapper coroutine takes a factory, never an already-constructed coroutine. A helper of the shape async def guarded(call: Awaitable[Any], ...) — the _try / with_timeout / never_raises wrapper every service grows — reads as harmless and has a cancellation hole in it. Written at the call site as gather(guarded(client.call_tool(...)), guarded(...), …), the inner coroutines are constructed as arguments, before any wrapper has started running. Cancel that round in the one event-loop iteration before the wrappers are first stepped and each takes CancelledError at position zero: its body never executes, so the coroutine it was holding is never awaited and is finalised by the collector instead.

    Take call: Callable[[], Awaitable[Any]] and construct inside the wrapper, so a wrapper that never starts has nothing to drop. That removes the window rather than narrowing it — no try/finally can help, because the finally belongs to a body that never ran.

    Measured in binabik-world-state (BIN-514): stopping the background body poll mid-round dropped seven of its eight tool calls, every time. Nothing had been sent, so the robot never noticed — the damage was to CI, below.

Security

  • Secrets only in env / .env; validate required keys at startup with a clear error; SecretStr prevents accidental logging.
  • Validate all HTTP bodies with Pydantic; validate enums/names against the known set.
  • CORS: explicit allowlist in production — the app refuses * when server.environment == production.
  • Subprocess calls use argument lists, never shell=True; validate any numeric inputs (e.g. ALSA card numbers) before use.
  • TTS temp files: UUID names under a 700 dir, deleted right after playback.
  • Admin endpoints are disabled unless an Argon2 ADMIN_PASSWORD_HASH is set (no plaintext, no defaults); bearer tokens are HMAC-signed and constant-time compared. Serve admin over TLS (reverse proxy).
  • A SecurityHeadersMiddleware sets HSTS, X-Content-Type-Options, X-Frame-Options, Referrer-Policy, and a CSP.

Logging & observability

  • Log through structlog with event names + structured fields (log.info("tts_synthesized", provider=..., duration_ms=...)), not f-string prose.
  • Bind a request_id per request and a turn_id per pipeline run via structlog.contextvars, so all lines for one interaction correlate.
  • Time every major stage (STT, LLM first-token, LLM total, TTS/chunk, tool call) and log duration_ms.
  • Health/monitoring: /api/livez (process up), /api/readyz (503 if a required dependency is down). OpenTelemetry, Prometheus, and Sentry are flag-gated and lazy-imported — off by default.

Testing

  • pytest + pytest-asyncio (asyncio_mode = "auto"), filterwarnings = ["error"].
  • filterwarnings = ["error"] has one blind spot — a finaliser — and PytestUnraisableExceptionWarning is the only thing that covers it, so never ignore it. warnings.warn inside __del__ cannot propagate: the promoted exception becomes an unraisable, reported through sys.unraisablehook rather than failing the test that caused it. Ignore that warning and the suite is silent about every abandoned task, unclosed coroutine and un-retrieved future in the codebase — which is the one class of defect error was supposed to be catching for you.

    Worse than silent, intermittently. binabik-world-state carried ignore::pytest.PytestUnraisableExceptionWarning from its first commit, over an async wrapper that dropped seven coroutines per cancelled poll (the Async bullet above). Enough of them were finalised together to re-enter pytest's unraisable hook during its own lazy import tracemalloc, which then found the half-built module — so the 3.11 leg failed with RuntimeError: Failed to process unraisable exception and an AttributeError about a "partially initialized module", having discarded the actual exception. A check that cannot tell you what it found, in a test that had passed, on a leg that went green on re-run (BIN-514).

    So: delete the ignore, fix what it was hiding, and confirm the suite raises none — then a new unraisable is news rather than noise. Two things make the report readable when one does arrive: import tracemalloc in tests/conftest.py, so pytest's lazy import can only ever be a dict lookup on a finished module, and PYTHONTRACEMALLOC=1 when you need the allocation site of the object that failed (a diagnostic to switch on for a run, not a default — it costs memory). - Because every capability is a port, substitute fakes at the interface boundary — no patching of internals. FakeSTT, FakeLLM (scripted chunks), FakeTTS, FakeToolBackend, etc. - Mock HTTP at the transport with respx so SDK code runs unmodified; monkeypatch asyncio.create_subprocess_exec for subprocess adapters. - Integration: httpx.AsyncClient + ASGITransport for routes; the socket.io test client for WebSocket. - The seam every unit test fakes is the seam nothing tests — cover it for real, once. Faking at the port boundary is the right default, and it has one blind spot that follows directly from it: the code inside the fake's boundary. If every unit test swaps out the transport, then the transport itself has no test at all, and line coverage will not tell you — it will report the opposite, because the module around it is exercised heavily.

    The kit's ToolClient is the cautionary case (BIN-179). Its streamable-HTTP path called streamablehttp_client, which the kit's own declared floor (mcp>=1.28.1) marks @deprecated. Under the mandatory filterwarnings = ["error"] that warning was raised inside _connect, the retry loop read the exception as a transient transport failure, and retried it to exhaustion — so the transport was unusable in any test suite, while production (where a DeprecationWarning is only a warning) carried on working. Every unit test replaced _connect with a fake session, so none of them could see it: a path at ~100% coverage that could not be used by the very suites that measured it.

    The fix is the general rule, not a patch: a thin integration layer that drives the real thingrobot-mcp-kit/tests/integration/ stands up a live FastMCP server on an ephemeral loopback port and drives a real ToolClient over both transports (selection, call, list_tools schemas, fetch_prompt, error classification, a full Async Task Contract cycle). It needs no network, no robot and no credentials, which is what makes it affordable in CI. One such test per transport or protocol boundary is worth more than any amount of coverage on the code that wraps it. - Coverage: the standard is above 80% on service/pipeline modules — 80 is the floor, not the target. Say it that way round, because a gate is written as fail_under = 80 (or --cov-fail-under=80), which passes at exactly 80.0%: a repo sitting on the line is meeting the gate and missing the standard, and the next uncovered branch turns it red. Land new code with headroom above the floor rather than against it, and when you raise a repo's coverage, raise its fail_under with it so the ratchet only turns one way. - The frontend has a suite too: npm test (vitest + jsdom, @testing-library/react). It is deliberately thin — it is not there to re-test React — and covers the UI state neither tsc nor a build can see: a badge that misreports a billable service, a control whose off state is indistinguishable from broken, a derived value a consumer might bypass. robot-voice-chat/frontend/src/components/SurveillanceWindow.test.tsx is the pattern (render with the store in the exact state the bug was reported in, stub the API the indicator is gated on, assert the rendered text); floorGate.guard.test.ts is the other kind, a source scan that pins an invariant a render test can't reach. Config lives in vitest.config.ts, separate from vite.config.ts so a production build never needs vitest, and the CI step is guarded like every other check. - In an async component test, wait for the UI — never for the spy. A fetch spy records the call when the request is issued: before the response, before the state update, before the control that was disabled during the read becomes clickable again. So a test that proceeds on toHaveBeenCalledTimes(1) and then clicks is clicking a disabled button. It still passes on an idle laptop, because the response usually flushes first — and fails whenever the machine is busy, which is a CI runner. BIN-368 was exactly this: green locally forever, ~1 run in 3 red under contention, and the stack trace pointed at waitFor timing out, which sent the first diagnosis after the wrong file and a timeout budget that was never the problem (waitFor tolerates a multi-second stall — its catch-up tick beats its own timeout). Wait for something the landed response rendered.

    The other half of the same rule: leave nothing in flight when a test ends. A response resolving after teardown runs setState against a dismantled jsdom, and React throws ReferenceError: window is not defined as an unhandled error — attributed to whichever test runs next, not to the one that leaked it. Await the effect of the last request you trigger, not merely the fact of it.

    And when you fix a flake, make it deterministic first: have the stub answer on a later macrotask, the way a real backend does, so the in-flight window is observable. The failure then happens every run instead of hiding until CI is loaded, which is the difference between a fix you can demonstrate and a fix you hope worked. PlannerFrame.test.tsx is the worked example.

Continuous integration

Every Python repo runs the same GitHub Actions gate on push + PR: pytestmypy (strict) → import-linterruff check + ruff format --checkuv run pip-audit (the SCA step surfaces dependency CVEs). Keep them all green — they are the same commands you run locally under uv run.

The brain's frontend (robot-voice-chat/frontend — the one TypeScript surface) mirrors that in a second job: vitesttsc --noEmitvite buildeslintnpm audit, with the same ordering rule (correctness first, lint after) and the same if: guard on every step. npm run lint is eslint 9 flat-config and blocks (--max-warnings 0).

The SCA step is uv run pip-audit. uvx pip-audit audits nothing.

uvx is uv tool run: it installs the tool into its own ephemeral virtualenv and runs it there. pip-audit with no arguments audits the environment it is running in — so uvx pip-audit diligently audits pip-audit's own dependencies and reports No known vulnerabilities found. It never sees your project. uv run pip-audit executes inside the project venv uv sync just built, which is the tree that ships.

This is not theoretical: robot-mcp-memory and mcp-perception-buffer both passed clean while carrying mcp 1.27.2 (PYSEC-2026-3483) and pydantic-settings 2.14.1 (GHSA-4xgf-cpjx-pc3j). ros2-memory, whose step was already uv run pip-audit, found both. A green badge that cannot fail is the BIN-171 failure mode with a different cause — the appearance of a gate with none of the substance — and it is worse than no SCA step, because it answers the question nobody re-asks. Tracked as BIN-184.

Two habits that follow. Prove a new gate can go red before you trust it: point it at a version you know is vulnerable and watch it fail (the mutation-check rule, applied to CI). And continue-on-error: true on top of a broken invocation is two layers of nothing — if the step is advisory, say so deliberately and make sure it is at least looking at the right tree, so the advisory it prints is real.

\"Every repo\" is a claim to check, not to assume

grasp-service — the flagship L3 skill — had no .github/workflows directory at all until BIN-155, so not one of those gates had ever run on a push or a PR. The symptom was exactly what you'd predict: ruff format --check had drifted red on 16 files and nobody knew, because nothing ran it. When you add a repo, add its ci.yml in the same PR; when you touch an existing one, confirm the workflow exists before trusting a green PR page — git ls-tree -r origin/main --name-only | grep ^.github is the whole check, and it is worth running on the repo you are about to trust. A scaffold with no ci.yml is the worst case of this, because every repo cloned from it starts ungated too.

It then happened again, to a dependency: binabik-scene-perception — the L5 scene core that binabik-r1-vision and every future robot's scene service import — had no .github/ either, so its entire suite, mypy and ruff format --check had never run on it (fixed 2026-07-29). A library is the worst place for this, because its consumers' green badges get read as evidence about it. So enumerate the dependencies, not just the services.

Answer a stack-wide question with git, against origin/main — never a filesystem sweep

This is its own rule because it produced two false audit findings in one night, in opposite directions, from two different people:

  • A sweep for coverage gates ran a recursive grep from a stale working directory and over the shared _binabik/ checkouts, and concluded no repo enforced coverage. Eight did. The finding reached Linear before it was caught.
  • An hour later, an independent re-check of the same question broke its own pipeline (a head that wasn't reached inside a subshell) and reported "no gate" for all eleven repos, and was nearly filed too.

Both failure modes are silent and both look like a finding rather than a bug, because an empty result reads as "verified absent". Three habits make the answer trustworthy:

  1. Ask git, not the filesystem. git grep <pattern> origin/main -- <path> and git ls-tree -r origin/main --name-only read the ref, so a shared checkout that is nine commits behind — or mid-edit by another agent — cannot answer for it. The _binabik/ copies are reference checkouts; they are routinely stale by design.
  2. cd explicitly in every command. A shell's working directory persists between calls, so a sweep can silently run inside one repo and answer for the whole workspace.
  3. Prove the negative. Before reporting "none of them do X", run the same command against a case you know does X and watch it match. An empty result you have not falsified is not evidence — it is an untested query, and reporting it is the research equivalent of a gate that cannot go red.

A declared script is a claim that the check exists — run it once

Same family, one level down: robot-voice-chat/frontend's package.json advertised "lint": "eslint ." for months while eslint was in neither dependency block and no config file existed, so the script had never once run — npm run lint exited at sh: eslint: command not found — and the frontend CI job never called it either (BIN-311). Nothing was red, because nothing ran. The cost was not hypothetical: four eslint-disable comments had been written against rules nobody was enforcing, which is what a suppression for an absent linter looks like from the inside.

A script name in package.json (or a [tool.*] block, or a Makefile target) reads to the next engineer as a check that exists. So when you declare one, run it once before you commit, and wire it into CI in the same PR — a check nobody invokes decays to a comment. Two properties make it stick afterwards: the script must fail on warnings (eslint --max-warnings 0, like ruff check's zero-warning bar), and a test must assert the tool is installed, not merely mentioned — robot-voice-chat's test_ci_config.py checks that every npm run <name> in the workflow is declared, that every declared script's binary maps to a package actually in dependencies/devDependencies, and that the linter's config file exists.

Turning a dormant linter on is also the cheapest it will ever be, and the existing suppression comments tell you which rules the authors thought were on — reconstruct that set rather than inventing one, and the first run reports a handful of findings instead of hundreds. Here it was two, across 71 files.

A failing check must never mask the ones after it

Workflow steps run under bash -e, so the first failure aborts the job and every later step is skipped. In robot-voice-chat that let a cosmetic ruff format --check diff silence pytest, mypy and lint-imports for weeks — the badge was red for a formatting nit while the suite wasn't running at all (BIN-126). Every check step therefore carries

if: ${{ !cancelled() && steps.install.outcome == 'success' }}

so all of them run and report, the job still fails if any one fails, and a failed dependency install skips them instead of failing them spuriously. Correctness checks come first, cosmetics last. Mirror this in any new repo's ci.yml, and pin it with a testrobot-voice-chat/backend/tests/unit/test_ci_config.py and grasp-service/tests/test_ci_config.py both assert the guards, the step list and the ordering stay put, because a gate that gets quietly deleted is worse than one that never existed: the badge keeps saying "passing".

The guard also matters when you introduce a gate. Adding ruff format --check to a repo that isn't formatted yet aborts the job right there — so land the reformat too, in its own commit, and let the guards ensure the correctness checks report either way (BIN-155).

One ruff, one formatting

ruff format output changes between releases, so the ruff-pre-commit rev must match the ruff version uv.lock resolves — otherwise committing through pre-commit reformats code that CI then rejects. Bump the hook rev whenever the lock's ruff moves.

SCA: what ships blocks, what only builds informs

An advisory step that can never fail is a step nobody reads. robot-voice-chat's npm audit was continue-on-error for everything, and ten advisories accumulated unnoticed — one of them a real open redirect in react-router, in the bundle of an installed PWA (BIN-169).

Making the whole audit blocking is the mirror-image mistake, and the same audit shows why: its loudest finding was a critical in the Vitest UI server — a server CI never starts (vitest run) — while the genuinely exploitable item was rated high three lines below. A gate that blocks on what cannot happen is a gate someone switches off, so severity alone is not the axis. Reachability is. Split the step in two:

Scope Policy
Advisories in dependencies that ship (runtime / the browser bundle), high+critical blocking
Everything else — dev/build-time tools, moderate/low advisory (continue-on-error)

In robot-voice-chat/frontend that is npm run audit:prod (scripts/audit-prod.mjsnpm audit --omit=dev --json) as a blocking step, next to a plain npm audit --audit-level=moderate that still reports. Python repos run uv run pip-audit advisory-only today (never uvx — see above); the same principle applies when one of them is tightened.

A blocking gate needs a documented way to say "reviewed, does not apply" — or the first inapplicable finding gets it disabled. The accept-list entry carries the advisory id, why it cannot apply here, and a reviewBy date; and an entry that outlives its advisory (it got fixed) or its date also fails the gate, so the list can't become the place advisories go to die. One entry exists today: react-router's RSC-mode CSRF bypass, which the advisory scopes to the unstable RSC APIs the SPA doesn't use, and whose only patched version needs React ≥ 19.2.7.

Two habits go with it. Check npm audit / pip-audit before merging a new dependency — BIN-165's (welcome) first frontend test harness landed one version below a fix, which is where the critical came from. And declare a security bump in the manifest, not only in the lockfile (see Versioning & packaging metadata).

Authenticating private git dependencies in CI

The MCP services pull robot-mcp-kit as a private git dependency, so CI's default GITHUB_TOKEN can't clone it. CI authenticates it with a read-only deploy key — the RMK_DEPLOY_KEY repo secret is written to ~/.ssh and an insteadOf git rewrite routes that one clone over SSH before uv sync. Local dev needs your own git SSH access to the github.com/binabik-ai org for the same reason; otherwise uv sync fails on robot-mcp-kit (see each service's README).

One key per private repo, each on its own Host alias — never one global core.sshCommand. Deploy keys are per-repository, so a second private dependency needs a second key, and core.sshCommand has nowhere to put it: it is one identity for every host. binabik-r1-vision set it to its binabik-scene-perception key, which was right until the kit became a plain dependency too (BIN-426) — from that same day uv sync offered the scene key to robot-mcp-kit, was refused, and every run on every branch died four steps before pytest for three months (BIN-462). Give each key an alias and rewrite only its own repo's URL, so a third dependency is two more lines rather than a redesign:

printf 'Host %s\n  HostName github.com\n  User git\n  IdentityFile %s\n  IdentitiesOnly yes\n\n' \
  "$alias_host" "$key_file" >> ~/.ssh/config
git config --global \
  url."git@$alias_host:binabik-ai/$repo".insteadOf "https://github.com/binabik-ai/$repo"
# A typo'd alias falls back to the DEFAULT identity with no error at all, producing a
# clone failure that reads exactly like a missing secret. Prove it resolves:
ssh -G "$alias_host" < /dev/null | grep -qix "identityfile $key_file" || exit 1

A non-empty secret proves nothing — prove the key parses. Every gate in those workflows sits behind that one clone, so a key that cannot authenticate means ruff, mypy and pytest never execute — and the failure it produces (Load key "…/rmk_key": error in libcrypto) reads like an infrastructure hiccup rather than an unrun test suite. On grasp-service the secret was set, to bytes ssh-keygen could not load: its emptiness guard passed, four consecutive workflows ran no test, type check or lint at all, and three PRs merged under the badge (BIN-171). The appearance of coverage with none of the substance is worse than no CI. An if [ -z "$KEY" ] check passes in exactly that case, so every consumer's key step checks the bytes it just wrote, before uv sync:

if ! ssh-keygen -y -f ~/.ssh/rmk_key < /dev/null > /dev/null 2>&1; then
  echo "::error::RMK_DEPLOY_KEY is set but is not a loadable OpenSSH private key …"
  exit 1
fi

A GitHub secret cannot be read back, so that message is the entire diagnosis anyone gets: make it name the four ways it goes wrong — (1) the public half pasted instead of the private -----BEGIN OPENSSH PRIVATE KEY----- block, (2) mangled line breaks (a PEM flattened onto one line, or CRLF), (3) no trailing newline after the END line, (4) a passphrase-protected key, which CI cannot unlock — followed by the fix that preserves the bytes: gh secret set RMK_DEPLOY_KEY --repo binabik-ai/<repo> < deploy_key (check it first with ssh-keygen -y -f deploy_key), never by pasting into the web form. Pass the secret through env:, never ${{ secrets.* }} interpolation, so it is not substituted into script text Actions echoes — and check the step's env: actually binds every secret its script reads, because one that is read but never bound is empty on every run, which the guard then reports as "not set on this repository". Guard the whole thing with a test, and have that test strip echo bodies before it searches — the guard names ssh-keygen -y -f inside its own error message, so a plain substring search still passes after the check itself is deleted (BIN-172). robot-voice-chat pins it in backend/tests/unit/test_ci_config.py.

Derive the list of repos needing a key from pyproject.toml, don't type it into the test. A hand-written list is what let BIN-462 run for three months: the test knew about one private dependency because someone had written its secret name into an assertion, so when BIN-426 added a second the test stayed green while CI went red the same day. Read the [tool.uv.sources] entries pointing at github.com/binabik-ai/… and assert each has a key — then a new private dependency fails the suite locally, before the push. Assert the parse found something too, or an expression that silently matches nothing reports "all authenticated" over an empty set. binabik-r1-vision/tests/test_ci_config.py is the pattern.

The secret is per-repository, so a new consumer's CI cannot pass until someone adds it in the GitHub UI — grasp-service hit exactly that when it finally got a workflow (BIN-155). Check gh secret list --repo binabik-ai/<repo> when wiring one up, and fail on a dedicated first step that names the fix rather than letting uv sync die twenty lines into a git clone on an opaque Permission denied (publickey). Which repos are currently behind is a Linear question, not a fact for this page — a hand-maintained status list here has already gone stale once. To check: git grep -l 'ssh-keygen -y -f' -- .github in each kit consumer.

Both halves need placing, and both need admin — the public half is a deploy key on the dependency, the private half a secret on the consumer, two repos and two settings pages. write is not enough for either, so whoever generates the keypair usually cannot install it: BIN-462 stalled with the private key sitting at mode 0750 in one person's home directory on phyai4090 and the admin rights belonging to another. Confirm before assigning the ticket — gh api repos/binabik-ai/<repo>/collaborators/<user>/permission — and prefer generating the keypair on the machine of whoever can install both halves, so no private key has to be handed around.

Versioning & packaging metadata

Packaging metadata is a claim about reality, so keep it true. The BIN-134 changelog backfill turned up four repos where it had quietly stopped being (BIN-142), all of the same shape:

  • One version, in one place. A package's __version__ and its pyproject.toml version move in the same commit, together with the CHANGELOG.md section that release ships. If a repo carries both, guard them with a test that parses pyproject.toml (see robot-mcp-memory/tests/test_version.py) — robot-mcp-memory had 0.2.0 in __init__.py and 0.1.0 in pyproject.toml for six weeks.
  • A declared floor names the version that introduced what you import — and it is a testable claim, not an aspiration. >=0.2 for code that needs a 0.4 symbol is a lie the lockfile happens to cover: robot-mcp-memory declared robot-mcp-kit>=0.3 while importing ParamStore, which the 0.4.0 release introduced — tag v0.3.0 has no learning.py at all — and it only ever worked because uv.lock pinned a sha from after the symbol landed and before the version bump. Anyone resolving from pyproject.toml got an unimportable tree. Same for a floor below what another dependency already forces (mcp>=1.2 under robot-mcp-kit[mcp], which requires mcp>=1.28.1). The check is a resolve, not a read: if the floor is right, installing exactly it must import.
  • A ceiling is part of the claim whenever an upstream has removed something you use. An unbounded floor means the next uv lock may resolve a major that deleted your API — mcp 2.0.0 removed mcp.server.fastmcp, so every server built on it needs mcp>=1.28.1,<2 or a routine lockfile refresh silently produces a tree that cannot import. Pin the ceiling with a comment saying which API it protects and which issue lifts it (BIN-185), so it is a decision with an exit rather than a mystery.
  • A security bump belongs in pyproject.toml, not only in uv.lock. uv sync --frozen (CI, the systemd units) gets the locked version, but any resolve from pyproject.toml follows the declared floor. Floor a transitive directly when its parent still accepts a vulnerable version, with a comment saying why and when the entry can go — and put it in the group it actually belongs to (ros2-memory's msgpack reaches the tree only through pip-audit → cachecontrol, so it is a dev floor, not a runtime one).
  • Tag every release, vX.Y.Z, or the changelog's compare links and any @vX.Y.Z install line are fiction. Internal libraries consumed as private git dependencies are the ones that matter most (robot-mcp-kit).
  • A comment that names a deleted symbol is stale metadata too. grasp-service justified its llm extra with a GRASP_POLICY=llm that no longer existed. When a rewrite deletes a module or an env value, grep pyproject.toml for it as well.
  • The default path's dependencies are not extras. An extra is a promise that the feature is opt-in; if config defaults to needing it, the extra is a trap, because every installer has to remember a flag. grasp-service kept anthropic behind that same llm extra while GRASP_POLICY defaulted to tiered — and r1ctl install runs a plain uv sync, so the robot ran the default policy without its dependency until the first grasp died on the import (BIN-156). Declare it in dependencies, ideally at the site that imports it (the extra moved into robot-mcp-kit[recovery], since the kit's RecoveryAgent is the importer), and reserve extras for genuinely optional paths.

Verify from the CONSUMER's position, not the producer's (BIN-343 … BIN-347)

binabik-world-state's first deployment produced five bugs in one afternoon, and the thing they have in common is worth more than any of them individually: every one passed every check that existed, when asked from the robot's side.

The check that said "fine"
Bound to 127.0.0.1, so the brain could never read it the port was open; r1ctl status said up; the whole suite connects over loopback
Every "free" read ran the grounder and billed a test named test_the_default_read_is_the_free_one — asserting trigger: "explicit", the paid one
A snapshot took 14–16 s the suite's fake answers instantly, so no test can measure latency
An available block explained a failure that never happened every block-level test passed; nothing compared available against reason
A working gripper reported as absent grep gripper_state tests/ returned nothing at all

Four of those are structurally invisible to a suite that fakes the transport or runs on the same host. So:

  • A reachability claim is about the wire. Assert it on the module (assert mcp.settings.host == "0.0.0.0"), because a session test dials loopback too. Then curl once from the machine that will really call it — the whole of BIN-344 was one ss -ltn away, comparing a service against its peers.
  • A cost claim is about what was called, not what was returned. Assert the tool log: assert not robot.named("capture_scene"). A test that pins the arguments your code happens to send pins the bug when the bug is in those arguments.
  • …and "nothing was called" needs the call to have been possible. BIN-391's first draft asserted capture_scene was never run after a jog, and passed against a build that captured on every stop: the fake carried no tool definition, so _resolve_tool returned None and the code bailed before the call. An absence-assertion is only as strong as the fake's ability to say "yes" — arm it, then mutate and watch the assertion fail.
  • A latency claim needs ordering and bounds, not seconds. Park each concurrent read on a barrier so a sequential implementation deadlocks; give the slow one a deadline and assert the block degrades, not the clock.
  • Assert invariants by iterating, not by naming. "No available block carries a failure reason", over every block, means the next capability inherits the check on the day it is added. Naming them means it does not.
  • A default that means "free" is a claim, and it needs a test at every layer that has a copy of it. BIN-347 had two — the MCP tool and the method behind it — and fixing the tool left the method spending for a round.

And the standing one: mutation-test the guard. Four of these five had tests nearby that stayed green while the behaviour was wrong. A check you have not watched fail is a check you have not written.

Behavioral contracts

Invariants the brain depends on — preserve them when changing pipeline or tool-dispatch code:

  • Stream TTS ahead of the LLM — synthesize each sentence as soon as a boundary (.!?\n) arrives; never wait for the full response.
  • VAD keeps a pre-speech ring buffer and is silenced while speaking/processing so the robot never hears itself.
  • Non-blocking tools are fire-and-forget (errors logged, never raised); blocking tools clear the TTS queue, speak a short ack, run, then do a follow-up LLM call.
  • Degrade, don't crash: an MCP server that fails to connect logs a warning and the app starts with reduced tools; STT/LLM/TTS errors emit an error event and return to idle.
  • A recovery path may never fail the thing it recovers. Fallbacks, retries and recovery tiers run after something already went wrong, so an exception there replaces a diagnosable failure with an opaque one. Make the extra machinery's own faults non-fatal: log loudly, fall back to the deterministic behaviour, and return the original failure. Check the optional backend at startup rather than at first use, and name which piece is missing — the fixes usually differ. grasp-service's tiered policy is the cautionary tale: its recovery sub-agent's anthropic import failed, the exception escaped RecoveryAgent.run() through the engine and the MCP boundary, and every grasp() returned Error executing tool grasp: No module named 'anthropic' — the flagship skill dead, and the static path's real reason lost (BIN-156). The one thing degradation may not swallow is cancellation: catch Exception, never BaseException, so CancelledError still unwinds.