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 withuv(lockfile committed). Node withpnpm. The brain image ships on Python 3.12; the on-robot MCP services run 3.11.robot-mcp-kitis the one exception, at>=3.10, and the robot decides that: two consumers import it through a--system-site-packagesvenv 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 useStrEnumordatetime.UTC. rufffor lint + format (line-length 100);mypy --strictfor 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:- Backend framework: FastAPI (async-native) + Uvicorn/uvloop, Socket.IO
(
python-socketio, ASGI) for realtime,orjsonresponses. - 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), passensure_ascii=False(or useorjson, which never escapes). The stdlib defaultensure_ascii=Truerewrites°as its six-character\uXXXXescape; because that string is used directly — not re-parsed throughjson.loads— React renders the escape verbatim and the model echoes it into its summary.ensure_ascii=Trueis 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 size — Hydra (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 sharedhttpx.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'sKind(record / definition / belief / derived) and its retention. Declarecolumnstoo where one row holds more than one kind —r1-abstraction'swaypointsis the worked example, and the kit refuses aSchemawhose table kind is not exactlyconservative_kind()of its columns, since aDELETEtakes the whole row. For an fts5 index, declare itKind.DERIVEDand its five shadow tables need no mention at all:Schema.not_ours()derives them. Never give a shadow table aKindof 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.
- Structure —
src/<package>/layout (never flat); aconfig/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 committedREADME.md(quickstart + how-to table + troubleshooting table) andCHANGELOG.md(Keep-a-Changelog + SemVer);.pre-commit-config.yaml;.github/(CI +dependabot.yml);.env.examplecommitted with.envgitignored; optionally a multi-stage non-rootDockerfile. - Pre-commit — trailing-whitespace, end-of-file-fixer,
check-yaml/toml/merge-conflict/large-files,
ruff(--fix),ruff-format, and a localmypyhook run viauv runso it picks up thepydantic.mypyplugin. Dependabot weekly for the uv ecosystem. - Optional extras are for genuinely optional paths (the
--extra observabilitypattern) — never for something the default config needs; see Versioning & packaging metadata.
MCP servers specifically — one repo per capability, from the copy-me template:
- Transport:
stdiowhen the consumer supervises the process (one or two, co-located); otherwisesse/streamable_httpwith 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 bind0.0.0.0for 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 HTMLtitledescribing 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 atitle. - Icon-only controls also get an
aria-label. Atitleis not a reliable accessible name for screen readers, so any button whose meaning is carried only by an icon gets a matchingaria-label(andaria-pressed/aria-checkedwhen it's a toggle). Buttons with visible text already have an accessible name — atitleis enough there. - Follow
components/Header.tsxas the reference for both — it setstitle+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.cssdefines one notice system: a tone (notice-warning/notice-danger/notice-info) composed with a shape (notice-stripfull-bleed under the top bar,notice-cardinline in a page,notice-overlayover the camera frame), plusnotice-actionfor 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-300was 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-offset←navPeekHeight) 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 usestate-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-lineper variant, drawn on the track and the handle. Off isvar(--border)fill +var(--muted-foreground)ring (7.58:1 on the white card, 6.15:1 on its own fill); on keeps--primarywith 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 samez-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 byApp.tsx) and the Visual view's own control row (absolute inset-x-0 top-0 z-40, owned byVisualCommandView.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 CSStransform, not withtop/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.BillableServiceBannerexists 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 level —relativebuys 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 pxaria-hiddentripwire). Give the reveal a visible handle (a real<button>, so Enter/Space work and opening never needs a pointer, with the usualtitle+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 callpreventDefault/stopPropagation, and bail onevent.defaultPrevented— otherwise hiding a bar becomes the reason a modal stops closing. - Register every theme colour asrgb(var(--x-rgb) / <alpha-value>)— a barevar(--x)makes the whole theme partly inert. Tailwind has to compose an alpha into the value it is given. A colour registered as a barevar(--primary)is unparseable to it, so it cannot buildrgb(r g b / .1)— and it responds by emitting no rule at all forbg-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.tshad silently swallowed 117 utilities across 26 files — an admin sidebar with no active highlight, three text inputs withoutline-noneand a focus ring that compiled to nothing (so no focus indicator at all), and aToggleBtnwhose 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-mutedemitted nothing for months because the theme had--muted-foregroundand no--muted(BIN-162) — andtext-primary-foregroundwas near-black-on-red (3.66:1) for the same reason. So keep the palette closed: everyX-foreground/X-inktoken has itsX, 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,eslintor 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.tsruns the real config through the real PostCSS pipeline over all offrontend/srcand 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 likeclick-to-pickthat a naive sweep reports as ato-*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-foregroundwas slate-500 (4.76:1 on white, 4.34:1 on a slate-100 chip) — which is why BIN-162 refused to define--mutedat all, correctly, since defining it alone would have traded an invisible fill for sub-AA text. And--primaryred 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 wherecolor-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=--primaryat 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/50composites to 2.34:1 on white andtext-primary/50to 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.cssdoes; 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.tsxalone 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_statusand 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 orangeSurveillance · watching execution) all still on screen whileGET /api/surveillancereported{"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 placedebugStore.agentActiveis 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.ABCwith@abstractmethods when it is instantiated via Hydra (_target_needs a concrete class). UseProtocolonly for purely structural, never-instantiated contracts. - Ports today:
Recorder,AudioPlayer,DeviceProbe,UtteranceSource,STTProvider,LLMProvider,TTSProvider,ToolBackend, plus the modality portsInputChannel/SpeechOutput. Provide aNull*adapter (e.g.NullToolBackend) for the "disabled" case rather than sprinklingif 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 noif 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 fromapp.state. Tests override them withapp.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.pyis the pattern: a frozenSettings, aload_settings()that is the only placeos.environis read, and typed_int/_floathelpers 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.
r1ctldeliberately overrides two of grasp-service's own defaults (GRASP_POLICY=deterministicovertiered,GRASP_STRATEGY_PACK=r1overminimal), 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 +
instantiateselect 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.examplecommitted), are injected through OmegaConf env resolvers, and are typedSecretStr. - 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()buildsAppConfigto raise on bad values and then returns the raw composedDictConfig, so a field that exists only inschema.pynever reaches the running app. Two rules follow: every new section gets a block inconfig/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, …) — nevergetattr(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 — sogetattr(_CFG, "idle_gate", None)returnedNone, 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 orthreading.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_raiseswrapper every service grows — reads as harmless and has a cancellation hole in it. Written at the call site asgather(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 takesCancelledErrorat 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 — notry/finallycan help, because thefinallybelongs 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;SecretStrprevents accidental logging. - Validate all HTTP bodies with Pydantic; validate enums/names against the known set.
- CORS: explicit allowlist in production — the app refuses
*whenserver.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
700dir, deleted right after playback. - Admin endpoints are disabled unless an Argon2
ADMIN_PASSWORD_HASHis set (no plaintext, no defaults); bearer tokens are HMAC-signed and constant-time compared. Serve admin over TLS (reverse proxy). - A
SecurityHeadersMiddlewaresets HSTS,X-Content-Type-Options,X-Frame-Options,Referrer-Policy, and a CSP.
Logging & observability¶
- Log through
structlogwith event names + structured fields (log.info("tts_synthesized", provider=..., duration_ms=...)), not f-string prose. - Bind a
request_idper request and aturn_idper pipeline run viastructlog.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 — andPytestUnraisableExceptionWarningis the only thing that covers it, so never ignore it.warnings.warninside__del__cannot propagate: the promoted exception becomes an unraisable, reported throughsys.unraisablehookrather 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 defecterrorwas supposed to be catching for you.Worse than silent, intermittently.
binabik-world-statecarriedignore::pytest.PytestUnraisableExceptionWarningfrom 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 lazyimport tracemalloc, which then found the half-built module — so the 3.11 leg failed withRuntimeError: Failed to process unraisable exceptionand anAttributeErrorabout 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 tracemallocintests/conftest.py, so pytest's lazy import can only ever be a dict lookup on a finished module, andPYTHONTRACEMALLOC=1when 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 withrespxso SDK code runs unmodified; monkeypatchasyncio.create_subprocess_execfor subprocess adapters. - Integration:httpx.AsyncClient+ASGITransportfor 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
ToolClientis the cautionary case (BIN-179). Its streamable-HTTP path calledstreamablehttp_client, which the kit's own declared floor (mcp>=1.28.1) marks@deprecated. Under the mandatoryfilterwarnings = ["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 aDeprecationWarningis only a warning) carried on working. Every unit test replaced_connectwith 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 thing —
robot-mcp-kit/tests/integration/stands up a live FastMCP server on an ephemeral loopback port and drives a realToolClientover both transports (selection, call,list_toolsschemas,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 asfail_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 itsfail_underwith 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 neithertscnor 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.tsxis 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.tsis the other kind, a source scan that pins an invariant a render test can't reach. Config lives invitest.config.ts, separate fromvite.config.tsso 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 wasdisabledduring the read becomes clickable again. So a test that proceeds ontoHaveBeenCalledTimes(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 atwaitFortiming out, which sent the first diagnosis after the wrong file and a timeout budget that was never the problem (waitFortolerates 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
setStateagainst a dismantled jsdom, and React throwsReferenceError: window is not definedas 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.tsxis the worked example.
Continuous integration¶
Every Python repo runs the same GitHub Actions gate on push + PR:
pytest → mypy (strict) → import-linter → ruff check + ruff format --check →
uv 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: vitest → tsc --noEmit → vite build → eslint → npm 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
grepfrom 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
headthat 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:
- Ask git, not the filesystem.
git grep <pattern> origin/main -- <path>andgit ls-tree -r origin/main --name-onlyread 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. cdexplicitly 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.- 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
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
test — robot-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.mjs → npm 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 itspyproject.tomlversionmove in the same commit, together with theCHANGELOG.mdsection that release ships. If a repo carries both, guard them with a test that parsespyproject.toml(seerobot-mcp-memory/tests/test_version.py) —robot-mcp-memoryhad0.2.0in__init__.pyand0.1.0inpyproject.tomlfor six weeks. - A declared floor names the version that introduced what you import — and it is a testable
claim, not an aspiration.
>=0.2for code that needs a 0.4 symbol is a lie the lockfile happens to cover:robot-mcp-memorydeclaredrobot-mcp-kit>=0.3while importingParamStore, which the 0.4.0 release introduced — tagv0.3.0has nolearning.pyat all — and it only ever worked becauseuv.lockpinned a sha from after the symbol landed and before the version bump. Anyone resolving frompyproject.tomlgot an unimportable tree. Same for a floor below what another dependency already forces (mcp>=1.2underrobot-mcp-kit[mcp], which requiresmcp>=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 lockmay resolve a major that deleted your API —mcp2.0.0 removedmcp.server.fastmcp, so every server built on it needsmcp>=1.28.1,<2or 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 inuv.lock.uv sync --frozen(CI, the systemd units) gets the locked version, but any resolve frompyproject.tomlfollows 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'smsgpackreaches the tree only throughpip-audit → cachecontrol, so it is adevfloor, not a runtime one). - Tag every release,
vX.Y.Z, or the changelog's compare links and any@vX.Y.Zinstall 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-servicejustified itsllmextra with aGRASP_POLICY=llmthat no longer existed. When a rewrite deletes a module or an env value, greppyproject.tomlfor 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-servicekeptanthropicbehind that samellmextra whileGRASP_POLICYdefaulted totiered— andr1ctl installruns a plainuv sync, so the robot ran the default policy without its dependency until the first grasp died on the import (BIN-156). Declare it independencies, ideally at the site that imports it (the extra moved intorobot-mcp-kit[recovery], since the kit'sRecoveryAgentis 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. Thencurlonce from the machine that will really call it — the whole of BIN-344 was oness -ltnaway, 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_scenewas never run after a jog, and passed against a build that captured on every stop: the fake carried no tool definition, so_resolve_toolreturnedNoneand 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
errorevent 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'santhropicimport failed, the exception escapedRecoveryAgent.run()through the engine and the MCP boundary, and everygrasp()returnedError 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: catchException, neverBaseException, soCancelledErrorstill unwinds.