Imported from nzneit/offbook (
AGENTS.md). Install upstream withnpx skills add nzneit/offbook. Copyright stays with the author.
AGENTS.md — Offbook
Guidance for any agent (or human) working in this repo. CLAUDE.md is a symlink to this file.
What this is
Offbook — a local dev tool (TypeScript/Bun) that mocks a browser application's MQTT-over-WebSockets backend services from their AsyncAPI specs: bidirectional dev-time contract validation + DST-inspired async/timing emulation. Goal: move contract-break and async-bug detection from deploy-time to dev-time. The repo is currently design docs + test fixtures (pre-build); no app code yet.
Doc map (which doc is canonical for what)
docs/specs/contracts.md— the frozen v1 interfaces & HTTP API. The synchronization point; build against this. Canonical for types/endpoints/config schemas.docs/specs/design.md— decisions & rationale (§1–§12). Canonical for "why".docs/specs/l2-scenarios.md— the L2 scenario authoring format.docs/specs/build-plan.md— tech stack, repo scaffold, tiered dependency graph, per-module acceptance, spike specs.docs/specs/demo-app.md— the demo webapp / R-006–R-007 spike-harness design (demo-app/, connect fingerprint,demo --serve; R-033).docs/specs/adoption.md— the adoption surface: README +docs/guides/,offbook doctor, first-run error audit, executable doc gates (R-034–R-036, D-016).docs/specs/doc-system.md— how this documentation system is organized.README.md+docs/guides/— adopter-facing derived docs: on any conflict,contracts.md/l2-scenarios.mdwin — fix the guide.skills/offbook-onboard/— the bundled onboarding skill (R-042), derived one step further: contracts > guides > skill — on any conflict the skill is wrong, fix it.REQUIREMENTS.md— the enumerable v1 requirements registry (R-###); the answer to "what needs building, and is it done".DECISIONS.md— the decision ledger (D-###); forward-authoritative provenance.docs/intake/— open review-round items (start from_TEMPLATE.md); resolve intoR-###/D-###, then move todocs/archive/.docs/archive/— resolved intake + the historical decision-logs (original G/F/S/P/EQ ids, intact).fixtures/asyncapi/— test specs + their README (incl. the Fixture quality bar).
Conflict rule: if any doc disagrees with docs/specs/contracts.md on an interface/API detail, the contract wins — fix the other doc. REQUIREMENTS.md indexes the specs; it is never a competing source of truth.
Doc-system gate. The corpus is validated by bun scripts/check-docs.ts: unique/contiguous R-###/D-### ids, resolvable COVERS anchors, lifecycle consistency (built/tested require a trace), and well-formed intake. A tested requirement's TEST files must carry a matching arrow-tag comment (// [utest->R-###], or itest/stest); the checker verifies tags in both directions (missing and dangling). Run it before committing; it is the CI/pre-commit gate. See docs/specs/doc-system.md for the full design.
Hard constraints (violating these defeats the purpose)
- Transport isolation. Only
src/broker/may importaedes(or any MQTT/transport package); everything else operates on the normalized message model. A lint rule enforces this. - Normalized message has no
direction— direction lives on theChannelrecord (normalized once from the spec). The message is{ topic, payload, qos?, retain?, delayMs? }. - Validation = observe-and-surface, never block-at-broker. A real MQTT broker is payload-agnostic; surfacing loudly is more prod-faithful.
- Use
@asyncapi/parser(parses/validates the doc via Spectral→Ajv) + Ajv directly for runtime payloads. Never hand-roll schema interpretation; test against theexternal-ref/qos-retainfixtures (the §5 correctness bar). - MQTT 3.1.1 only (QoS 0/1/2, default 1; no MQTT 5). The scheduler lives in the engine (
broker.emitis publish-now); seeded determinism via Mulberry32.
Vocabulary
- client = the connecting app under development (this adopter's client is a browser application). mock = the tool's own emissions.
- Direction (on the
Channel):toClient/fromClient. v3send→toClient,receive→fromClient; v2subscribe→toClient,publish→fromClient(publish = the service receives ⇒ the client publishes). - MQTT terms (
topic,qos,retain, bindings) stay concrete — generalize the client vocabulary, not the MQTT transport.
Review angles (for /code-review of these docs/fixtures)
- Doc-consistency — cross-references (
§N), inter-doc contradictions (contracts is canonical), incomplete decision-sweeps (e.g. renamed terms), contract self-consistency. - Fixture-semantics — "does this fixture actually test what it claims?" See the Fixture quality bar in
fixtures/asyncapi/README.md(no vacuous values; claim↔content; full-path/both-direction coverage; internal consistency; negative cases). The validity-only angle misses all of these.
Status & next
v1 is complete: all 40 v1 requirements are tested, including the two empirical spikes R-006 (WS-fidelity) and R-007 (capture the browser application's connect()), closed 2026-08-03 by the authoritative runs of the real browser application against broker/'s Aedes defaults (D-026): go on the defaults unchanged — MQTT 3.1.1 (level 4) over ws, no auth, QoS 0/1 only; the sanitized capture is fixtures/connect/real-client.json (deployment-specific values are not published), pinned by src/broker/connect-profile.test.ts. The build tiers: model → broker/registry/ingestion → engine/validation → scenarios/control-plane → cli (the full verb set incl. up/down process management over the G14 runfile, watch modes, init), plus the four cross-cutting v1 gates (R-028–R-031). R-033 (tested): the demo-app/ spike-harness webapp + connect fingerprint (docs/specs/demo-app.md), which rehearsed both spikes and stays the rehearsal surface for re-runs. The aedes 1.x bump is taken (D-031, 2026-08-10): ^1.1.1 with the 1.x defaults unchanged, D-024's measured migration delta plus lifecycle guards on the broker seam (pre-start emit/getState, single-lifecycle start()), and a green R-033 rehearsal re-run (Chromium + Firefox, zero discrepancies) — all three of D-021's deferred range changes are now discharged. The adoption surface is tested (R-034–R-036: README + guides with executable quickstart/cookbook gates, offbook doctor, the first-run error audit — docs/specs/adoption.md). The AsyncAPI support range is declared and hardened (R-037–R-039, D-018): 2.0.0–2.6.0, 3.0.0, 3.1.0, payloads validated under draft-07, with the R-028 gate extended over multi-format.yaml (3.1.0) and v2-oldest.yaml (2.0.0). The per-channel initial-state opt-out is tested (R-040, D-025): topicOverrides.<address>.initialState: false declares a reactive-only channel (no L1 floor; four spec-load warnings; handler-wins warn-log; GET /v1/topics marks suppressed channels). The embedding-onboarding surface is tested (R-041–R-043, D-028; adoption.md §8–§10, hardened by a 2026-08-07/08 adversarial-review + FMEA round — the resolved intake file carries the fork-by-fork record): reference-quality init templates + a doctor-advertised edit loop + the wiring guide's app-connection recipe (R-041), a bundled agent skill (skills/offbook-onboard/, installed into the app repo by offbook skill install) as the conversational front door plus offbook --version and doctor's skill-staleness check (R-042), and the first-light integrity hardening (R-043: status connects line, port-conflict attribution, staleness honesty — all CLI-local over existing surfaces: offbook.log lines + the runfile/probe). The embedding surface was then hardened by the 2026-08-09 PR-13 deep-review round (D-029; 23 items, two merge-blocking classes: credential embedding and degenerate-install crashes; the archived intake carries the record). D-029's open obligation is closed by D-030: both detached-server spawn sites pass a logSafeEnv() child env (FORCE_COLOR/CLICOLOR_FORCE/DEBUG_COLORS stripped, NO_COLOR=1) so offbook.log stays ANSI-clean for the R-043 parsers even under a color-forcing parent shell. Instance discovery is tested (R-044–R-046, D-032; R-047 built): management verbs resolve cwd-first then a machine-local pointer registry with token-based identity (GET /v1/server), up [dir] starts a project's instance without cd, refusals print paste-ready --run-dir selector tables (exit 2), and the docs sweep dropped the cwd premise from the guides and the onboarding skill. The mutate scope now covers src/cli/{runfile,registry,resolve,guard}.ts, and D-032's campaign obligation is discharged: the focused full campaign ran 2026-08-19 (first pass: guard 100%, runfile 87.16%, registry 78.48%, resolve 80.21%), a hardening round killed 67 of the 88 undetected mutants with behavioral tests, and the verification re-run scored 95.44% overall — guard 100%, runfile 97.25%, registry 93.67%, resolve 95.14%, 0 no-coverage, 0 timeouts; the remaining survivors are accepted equivalents, each with its argument in D-032's mutation appendix, and each is now enforced inline as a reasoned Stryker disable next-line annotation (12 annotations over the 21 equivalents, named mutators only, never all and never the block form) so the changed-file gate scores 100 deterministically instead of failing at 96.27; the annotations also ignore 11 co-located previously-killed variants, enumerated in D-032's mutation-gate-enforcement appendix.
Working notes
- Git identity is the user's to set — don't run
git config user.*on their behalf. Commit/push only when asked. bun run mutate(Stryker) needs a Node >= 20 binary onPATHto host the Stryker CLI process itself — Bun cannot (a@babel/generatorCJS/ESM-interop crash:TypeError: generator is not a function); the runner plugin itself still drivesbun test. Mutation testing is manual full campaigns plus a changed-file PR gate (.github/workflows/mutation.yml+scripts/mutation-gate.mjs, D-027): small PRs are gated on zero undetected mutants in touched engine files; large PRs loud-skip with a sticky comment (labelsmutate-force/mutate-skipoverride); after any Stryker/runner bump, run a full campaign before engine PRs resume. The runner sanitizes bunfig.toml for its child runs (forces coverage=false), which is why the always-on gate and mutation runs compose safely.nvm use defaultputs a Node 24 on PATH forbun run mutate/ focusedstryker runinvocations.- Mutation runs can orphan sandbox servers. A mutant that breaks shutdown in an
up-spawning test leaves a detachedbun .stryker-tmp/sandbox-*/src/cli/serve.tssquatting fixture ports (observed twice on 2026-08-19: ports 19810/19010/12910), and every laterup-spawning test then fails with the R-043 port-conflict attribution instead of its expected message. After any Stryker run, sweepps aux | grep stryker-tmpand kill leftovers before trusting suite results. Since D-033 a leaked server no longer poisons the next run silently: the band claim bind-probes its band's spawned-server ports and steps over a band whose ports are still held, announcing[test/ports] band N skippedon stderr. That is a step-over, never a kill — an in-run reaper at concurrency 4 would SIGKILL sibling workers' live runs and score their mutants KILLED. bun test <single-file>may exit 1 with zero failures — the per-file coverage floor (bunfig.toml) judges partially-imported files. Exit 1 with 0 fails = coverage floor, not a test failure; gate on fullbun testruns.- Tests that boot the real server inherit
process.cwd()—upbakesprojectDir: process.cwd()into the boot file, so a premise like "no services.yaml in cwd" is inverted by any strayoffbook initat the checkout root. It happened (2026-08-12): the spawned server booted, leaked onto the pinned fixture ports, and every later run failed with the R-043 port-conflict attribution instead of the expected message. Pin cwd to a scratch dir (process.chdirin try/finally), assert the fixture ports are free before the test ends, and assert a path-specific marker (e.g.server failed to start), not just the shared doctor hint — see theup: busy port and failed boottest intest/cli-dispatch.test.tsfor the pattern. - Test ports are band-mapped (D-033). Anything the suite binds in 11800-19999 must go through
port(base)/portStr(base)fromtest/ports.ts— never a bare literal, never a hardcoded digit in an asserted string, and never arithmetic on aport()result (do it inside the call:port(19000 + n)). Each test process claims a band at preload time by binding a sentinel on127.0.0.1:(11700 + band), and band 0 is the identity map, so an ordinary local run binds exactly the numbers written in the source: every defect in this scheme is invisible locally and bites only in a second concurrent process (the Stryker workers at--concurrency 4), as an EADDRINUSE that scores a mutant KILLED.test/port-hygiene.test.tsis the gate that makes it a fact rather than a convention; the[test/ports] band Nline on stderr says which band a run got. If a third party squats a base port (Expo defaults to 19000/19001, which this suite allocates), force a band:OFFBOOK_TEST_BAND=<0..24> bun test. Ports outside that window — the real defaults 9001/1883/9080, the out-of-range fixture sentinels — stay plain literals on purpose. - Internal imports: upward reaches (anything needing
../) use#src/…/#scripts/…(package.jsonimports); same-directory and downward stay relative, explicit.tsextensions. Enforced bytest/import-style.test.ts(D-013). A fourth alias,#test/*, exists for one reason:src/**/*.test.tsfiles allocate their ports through#test/ports.ts(D-033). It is a src→test edge, so it is fenced — only*.test.tsfiles may use it, enforced by the same test, because importingtest/ports.tsfrom shipped code would bind a socket and write to stderr as an import side effect (and the R-043 log parsers are stderr-sensitive, D-030). - Never run
biome migrateunattended (D-021, D-023). On the v1 config it rewrote"rules": { "recommended": true }as"rules": { "preset": "none" }, which deletes the rule set instead of preserving it:biome check .then exits 0 on code containingany,==and unused vars, so the lint gate dies silently and CI stays green. The correct spelling is"preset": "recommended", andtest/lint-gate.test.tsnow fails if it ever changes back. After any biome config change, re-verify with a planted violation and check the exit code, not the printed summary. - Biome's "safe" fixes are not all safe here (D-023).
noUselessEscapeInRegexunescaped the\.inMQTT_EXTENSION_KEY, which is a no-op to the regex engine but breaks D-019's character-for-character transcription of the upstream schema key thattest/upstream-drift.test.tscompares byte-for-byte. It is suppressed inline with that reason. Read what--writechanged before trusting it; the test suite caught this one, but a less-covered invariant would have slipped through. - TypeScript 7 ships
tsconly (D-022) — notsserver.js, no programmatictypescriptmodule API undernode_modules/typescript/lib. Nothing in the repo imports it as a module, so the gates are unaffected, but Stryker's core sandbox preprocessing loadstypescriptregardless ofcheckersconfig (found when mutation first ran in CI, D-027), no-op'd via thestryker.conf.jsontsconfigFilesentinel and pinned bytest/stryker-tsconfig-noop.test.ts. An editor set to "use workspace TypeScript version" finds no language server and silently falls back to its own bundled TypeScript. Expect the editor and thetypecheckgate to be different compilers; when they disagree,bun run typecheckis the authority. - Dependency bumps: refresh ≠ range change. Taking a newer build of an already-declared range is routine; requiring a version you previously did not is a decision.
bun updateconflates them — it rewritespackage.jsonfloors even for packages whose version did not move — so refresh withbun update, thengit checkout package.json && bun installto keep the change lockfile-only. Range changes get their own entry inDECISIONS.md, their own PR, and a measurement (D-020, D-021). - CI (GitHub Actions):
.github/workflows/ci.ymlruns the gate set (check-docs→lint→typecheck→demo-app:build→ fullbun test) on PRs and main pushes; main pushes also uploaddemo-app/dist/+coverage/artifacts. Bun is pinned there (1.3.14) so the bunfig coverage-gate semantics stay as verified; bump the pin deliberately, in its own PR. Amainruleset requires thegatescheck (repo-admin bypass keeps direct pushes possible). Themutationrequired check runs the changed-file gate on PRs (D-027); full campaigns stay out of CI.