Standards for anyone (human or coding agent) making changes in this repo. Read this before you start. These are conventions the project already follows; keep them consistent.
actor-ts is a pre-1.0 actor-model framework for TypeScript that
runs on Bun, Node.js (≥ 24), and Deno. ESM throughout; Bun is
the primary toolchain (bun test, bunx tsc). Runtime dependencies are
deliberately tiny — fastify + ts-pattern — and everything else
(Express, Hono, ws, brokers, SQL/Cassandra drivers, S3, …) is an
optional peer dependency, lazy-loaded on demand.
-
Conventional Commits:
type(scope): subject. Types in use:feat,fix,refactor,chore,docs,test,ci,build. Scope is the module/area, e.g.http,http/websocket,io,persistence/postgres,testkit,cluster,deps,deps-dev,readme,changelog,roadmap,integration. -
Small, focused commits. Each commit should keep
bun run typecheck+bun testgreen — so a bisect never lands on a broken tree. -
The body explains what + why (and the mechanics for non-trivial changes). Reference issues as
#NNN; close them withCloses #NNN(see Issues & workflow). -
Commits that only touch CI-maintained artifacts (e.g. the README test/coverage badges) use
[skip ci]. -
Commit as the private identity
~/.gitconfigdeclares — the one the whole history already uses. This is a personal project; a work address does not belong in it. The config is correct, but something in the tooling has been observed substituting a work address at commit time, and a wrong author only surfaces afterwards in the log. So pin the identity explicitly instead of trusting that the config is honoured — environment variables outrank both--localconfig and-c user.email=…:name=$(git config user.name); mail=$(git config user.email) GIT_AUTHOR_NAME="$name" GIT_AUTHOR_EMAIL="$mail" \ GIT_COMMITTER_NAME="$name" GIT_COMMITTER_EMAIL="$mail" \ git commit -F <message-file>
Reading the values back out of
git configis deliberate: it keeps the address itself out of this file, and it makes the recipe work unchanged in a fork, where the right author is whoever is doing the work.Applies to merge commits too. Verify afterwards with
git log --format='%an <%ae> | %cn <%ce>' -1— checkinggit config user.emailproves nothing, since the override does not live there. Nothing is pushed by the agent, so a wrong author is always still fixable: rewind the branch with a mixedgit reset <base>, re-commit the same file sets with the identity pinned, and confirm the rewrite changed nothing but authorship by comparinggit rev-parse HEAD^{tree}against the old tip's tree.
developis the integration branch — all ongoing development lands there.mainholds releases only: it moves only when a release is cut (a--no-ffmerge fromdevelop, see Release strategy), never via direct feature work.- All work happens on a feature branch under
features/…— one branch per unit of work, branched offdevelop(e.g.features/ws-backpressure,features/fix-mqtt-reconnect; even fixes and chores use thefeatures/prefix). The sole exception is cutting a release, which uses arelease/vX.Y.Zbranch (see Release strategy). No direct commits todevelop, not even small fixes or follow-ups — everything lands through a branch. Delete the branch after it merges. - Always integrate with a merge commit (
git merge --no-ff) — never rebase, never fast-forward. This holds in both directions:features/…→developand, at release time,develop→main. History stays a true graph; it is never rewritten or flattened. - Do not push. The agent commits locally only — on its
features/…branch and when merging intodevelop; the human pushesdevelop. The single exception is cutting a release (below) — mergingdevelop→mainand creating the tag/GitHub Release is explicitly authorized. mainis branch-protected — merges require a pull request and theteststatus check; the maintainer (admin) may bypass for the release merge.
SemVer, and the project is pre-1.0:
- patch
0.x.Y— bug fixes only, no breaking changes. - minor
0.X.0— new features; may include breaking changes. 1.0.0— the API-stability commitment.
Tags are vX.Y.Z; GitHub Releases are cut as normal Latest releases
(not flagged pre-release) — gh release create without --prerelease.
CHANGELOG (CHANGELOG.md) follows Keep a Changelog: an
[Unreleased] section with Added / Changed / Fixed / Removed /
Security subsections. Breaking changes are flagged prominently
(a BREAKING marker + a short migration note). Reference issues as
#NNN.
Cutting a release (only when explicitly asked) — promotes develop to main:
-
On a
release/vX.Y.Zbranch offdevelop: bumpversioninpackage.jsonand move[Unreleased]→[X.Y.Z](dated) inCHANGELOG.md; commit (chore(release): vX.Y.Z). Merge it intodevelop(--no-ff) and pushdevelop. -
Re-measure the comparison benchmarks and carry the figures to every surface that quotes them. Those numbers name the version that produced them —
environment.actorTsVersionin each result file comes frompackage.json, and the docs tables label their columns with it — so a release that skips this ships figures attributed to a version that never ran. Order matters: bump and commit first (step 1), then measure, so the results carry the new version and a clean commit rather than recording themselves as-dirty.bun run bench:compare -- --rounds=100 # every arm, machine otherwise idle, hours bun run bench:compare:report # regenerates RESULTS.md
Then update the five hand-maintained surfaces in a second commit on the release branch:
README.md,docs/.../reference/benchmarks.mdx(EN + DE) and thetell/askfigures quoted indocs/.../reference/faq.mdx(EN + DE). Version labels belong in the docs tables and inRESULTS.md, never inREADME.md— the README is the summary and links to the full tables for the pins. #1322.The cross-language arms need a JDK and a .NET SDK; if a toolchain is missing, re-measure the arms you can rather than skipping the step. Each result file carries its own date and commit and
RESULTS.mdprints one environment row per arm, precisely so a stale arm is visible as stale instead of averaging in silently. -
Merge
develop→mainwithgit merge --no-ff, then pushmain. -
gh release create vX.Y.Z --target main(a normal Latest release, no--prerelease) with emoji-sectioned notes (## 🚀 New features,## ⚠️ Breaking changes,## 🔒 Security,## 🐛 Fixed, …) matching the style of prior releases.
Publishing the release triggers .github/workflows/publish.yml, which
runs typecheck + test + build and then npm publish --provenance via
npm Trusted Publishing (OIDC) — no long-lived token. It is
version-guarded, so re-running is safe. Locally, prepublishOnly runs
clean + build + typecheck + test.
Pre-1.0, a hard cut is fine. Remove or replace an API directly — no
deprecation cycle is required. Flag it as BREAKING in the CHANGELOG
with a one-line migration note, and update every in-repo caller
(examples, tests, docs) in the same change. (Post-1.0 this tightens to
conservative SemVer.) See docs/.../reference/version-policy.mdx.
- Docs are Starlight MDX under
docs/src/content/docs/(English), mirrored 1:1 underdocs/src/content/docs/de/(German). Every content change updates BOTH languages — code samples stay identical, prose is translated. Thei18nlabel tracks translation work. - Feature or behavior changes also update
README.mdandCHANGELOG.md. - The README test-count / coverage badges are bot-maintained — a CI
workflow pushes
chore(readme): update test count + coverage stats [skip ci]commits directly todevelopafter test runs. Do NOT edit those numbers by hand (the bot overwrites them, with CI-measured values that skip the quarantined multi-node suites viaACTOR_TS_SKIP_FLAKY_MNS— see Verification gates — so they differ slightly from a local full run). After pushingdevelop, fetch again before branching — a bot commit may already have landed on top. - Adding a page: keep
docs/scripts/scaffold.mjsand the Astro sidebar (docs/astro.config.mjs) in sync — same path and label.
graphify-out/ holds a committed knowledge graph of the repository —
graph.json, graph.html, GRAPH_REPORT.md and cache/. It is tracked
on purpose (.gitignore says why): the cache keys on repo-relative
paths plus a hash of the extraction prompt, so a fresh clone replays it
instead of paying a ~7.2M-token semantic re-extraction of docs/. Only
run-local files (manifest.json, cost.json, .graphify_*) are ignored.
-
The graph is bot-maintained.
.github/workflows/graphify.ymlruns weekly (and onworkflow_dispatch), re-extracts the code side, and pusheschore(graphify): refresh knowledge graph [skip ci]todevelop— the same shape as the README badge bot above, so the same caveat applies: fetch again before branching. -
Weekly, not per push, and that is deliberate.
graph.jsonis ~36 MB and reorders on rebuild, so it deltas poorly; a commit per push would add gigabytes to the history. Landing a large refactor is what the manual dispatch is for. -
CI refreshes code nodes only. Documentation nodes come from semantic extraction, which needs an LLM — run
/graphify . --updatein an agent session after a docs sweep. Community labels are re-derived on every rebuild (Louvain ids move when nodes change), so hand-curated labels do not survive automation. #1345. -
The graphify version in that workflow is pinned because the AST cache lives under
cache/ast/v<version>/. Bumping it orphans the committed cache and commits a second copy — a deliberate change, never a drive-by. -
Local git hooks are deliberately not used.
graphify hook installofferspost-commit/post-checkout; the checkout hook runs a full re-extraction on every branch switch and leaves the tree dirty, which with several worktrees in play fires constantly. The merge driver it registers (.gitattributes,merge=graphify) is kept — it union-merges two branches that both rebuilt the graph. Configure it directly, once per clone; do not reach forgraphify hook install, which would reinstate the hooks along with it:git config merge.graphify.name 'graphify graph.json union merge' git config merge.graphify.driver 'graphify merge-driver %O %A %B'
Without it git just falls back to a normal merge, which on a 36 MB reordered JSON means a conflict you resolve by rebuilding.
-
bun run typecheck(build tsconfig — excludesexamples/,tests/andbenchmarks/) passes. -
bun run typecheck:devpasses too — same compile plus those three trees. Green since #540 and gated by thetypecheck (dev)workflow, so a regression is a red check rather than a number that drifts. It is the only gate that sees the library from a caller's side, which is a whole class of defect on its own: an exported class narrower than the interface it implements still satisfiesimplements, and an exported type whose properties are all optional is satisfied by nothing at all. Neither shows up inbun test(which transpiles without checking) or inbun run typecheck(which never compiles a call site).tsconfig.dev.jsonexcludes the three trees whose imports another manifest resolves — the example frontends, the broker runners, and three examples demonstrating an undeclared optional peer. Its header says which CI job covers each. Adding to that list is not a way to make a compile error go away: the rule is a different manifest, not a difficult error. -
bun testis green. Line coverage floor is ≥ 90 % —bun run test:coverage:gate.That command enforces two kinds of floor, from the two artifacts of one
bun test --coveragerun. The aggregate ≥ 90 % comes from theAll filesrow of bun's text table. Per-module floors —src/cluster/≥ 90 % andsrc/persistence/≥ 90 % — come from the lcov report, asΣ LH / Σ LFper path prefix, because a rollup of bun's per-file percentages would average a ten-line barrel against a thousand-line coordinator. All three numbers are configured inscripts/coverage-gate.mjsand nowhere else, deliberately: an environment override in a workflow file is a second place the number lives and a way to loosen the gate without the loosening showing up in a diff of the gate.CI runs that same script —
test.ymlruns the suite once with both coverage reporters and hands the captured log and the lcov report tobun scripts/coverage-gate.mjs --log=… --lcov=…, which is also where the README badge's coverage figure now comes from. The workflow used to re-derive the aggregate in bash and gate on that, so the number CI enforced and the numbertest:coverage:gateenforced were two implementations of one parse; the module floors ran in neither. The script refuses--logwithout--lcov, so a CI step can never report a pass having evaluated half the gate. #541, #1016.Ratchet policy: a floor may be raised, never lowered silently. Raising one is ordinary work — do it when a release is cut, or when a module has held well above its floor for a while. Lowering one requires the measured figure that forces it, written down beside the number, and it is worth asking first whether the honest change is a test rather than a floor. The history here is the reason: the aggregate floor was 89 until
83b0a4afdropped it to 80, because quarantining the worker-thread suites (#538) had taken hosted CI to 86 % — a defensible call, but one whose reasoning lived only in a commit message, with nothing undertests/even namingCOVERAGE_LINE_FLOOR. Every floor is now pinned from below bytests/unit/ci/CoverageGate.test.ts, which also fails when the script and this file stop quoting the same aggregate number, and whentest.ymlstarts quoting it again — so lowering a floor means editing that test, in the same commit, on purpose.The aggregate went 80 → 90 on 2026-08-25 (#541), and the measurement the policy above asks for lives beside the constant in
scripts/coverage-gate.mjs: 93.63 % on the CI population locally (bun 1.4.0,ACTOR_TS_SKIP_FLAKY_MNS=1) against 93 % from the badge bot's hosted run, with the same lcov reduced toΣ LH / Σ LFreading 92.85 %. The 13-point band the old floor left is a 3-point one, and 90 clears every candidate statistic, so #1016 changing which one the aggregate is cannot turn CI red on its own fix. -
Three suites do not run in CI at all.
ACTOR_TS_SKIP_FLAKY_MNS=1intest.yml,multi-runtime.ymlandpublish.ymlskipstests/multi-node/LeaseMajority.test.ts,tests/multi-node/ParallelPubSub.test.tsandtests/unit/testkit/ParallelMultiNodeSpec.test.ts— Bun on GitHub's hosted runners cannot respawn functional worker threads after the first worker test, which also starves LeaseMajority's lease arbitration into a false split-brain. A localbun testruns them; a green CI check says nothing about them..github/workflows/nightly-flakes.ymlruns them nightly with the flag OFF; its header carries the exit criterion (14 consecutive green nights), anddocs/…/testing/diagnosing-flakes.mdxstates it in prose. #538. -
Repeat-run flake hunting:
bun run test:stress(scripts/stress-test.mjs) loops the suite N times and aggregates failures by test identity, splitting flaky (failed in some runs) from consistently failing (broken, not flaky). It dropsACTOR_TS_SKIP_FLAKY_MNSfrom the child environment by default — a harness that inherited it would report a reliable pass rate over exactly the tests known not to be reliable. Not a per-commit gate; reach for it when a test fails intermittently, or when a nightly names one. #290. -
Cross-runtime:
bun run smokerunstests/smoke/cases/*.mjson Bun, Node, and Deno. Add a smoke case for anything runtime-sensitive. A case must release every handle it opens on every path, not just the happy one: a socket abandoned on a timeout or an error keeps Deno's event loop alive, and the run then hangs after its last green line instead of exiting — no exit code, so the gate stops being a gate (#1196). The runner's watchdog demotes that to a warning after 15 s; it does not excuse it.deno test -A --trace-leaksover the suspect case names the op. -
Examples:
bun run test:examplesspawns every runnable snippet underexamples/and asserts on its output (~90 s). A change to asrc/API that an example calls needs it; theexamplesworkflow gates it, and its path filter carriessrc/**for that reason.Every standalone example is classified in
tests/examples/examples.manifest.json— either runnable, with a substring of its output that must appear, or skipped with the reason it cannot run (a Docker broker, cloud credentials, an optional peer nothing declares). The runner fails when the manifest and the tree disagree in either direction, so a new example is not finished until it has an entry. The output assertion is not decoration:exited 0is also whatexamples/io/grpc-sensor.tsdoes after ten failed actor starts, so a runnable case without anexpectwould gate on nothing.Runs on Bun only, deliberately — the cross-runtime question belongs to
bun run smoke, whose cases are written runtime-neutral; the examples are written for Bun. -
Benchmarks: a change to a
src/API thatbenchmarks/calls also needsbun run typecheck:bench(benchmarks-only compile) and, for anything that could break at runtime,bun run bench:smoke(~30 s — every suite, one unwarmed iteration each). The build tsconfig excludesbenchmarks/, so nothing else catches an orphaned benchmark; thebenchmarksworkflow gates both. The benchmarks are part of the adoption sweep for a breaking change, exactly like tests and examples. -
DevTools UI: the UI has its own Angular toolchain in a nested
devtools-ui/package, installed once withbun run ui:installand deliberately not a bun workspace — hoisting would put@angular/corein the rootnode_modulesand in Dependabot's view of a manifest that ships two runtime dependencies, and Angular pins a TypeScript the library does not use (#483).bun run build:uifails hard without it;bun run typecheckskips the UI half with a warning locally and fails hard under CI, which is what keepstypecheck,bun testandbun run smokeworking from a fresh clone.bun run build:libistscalone, for the jobs that wantdist/and have no opinion about the UI.The UI has two test runners, and their file patterns must stay disjoint. The framework-free half (
format,history,flamegraph,profileTree,stateDiff,actorsTree,uptime, and the chart-option builders) runs underbun testfromdevtools-ui/tests/*.test.tsand needs no DOM. The Angular half runs under Vitest in jsdom, asbun run test:ui, fromdevtools-ui/src/**/*.ng-spec.ts. The.ng-spec.tssuffix is not a style choice:bun testcollects*.spec.tsanywhere in the tree and would try to run specs that need Vitest and a DOM. Renaming them back breaks the root suite, not just the UI one (#487).A change under
devtools-ui/needsbun run build:uiin the same commit —src/devtools/generated/UiAssets.tsis generated but committed, and a stale one is valid TypeScript, so nothing else notices.bun run check:uiasserts it (and gates thebuildworkflow) by comparing asource-hashover the UI sources, the build script and the bundled dependencies. It deliberately does not compare the bundle's bytes: those vary with the OS and the Bun release that produced them, so a byte diff is not a staleness signal. Which means review is the only thing that ever looks at the embedded payload — hence.gitattributesgivesUiAssets.tsa plain textualdiffand not-diff. Restoring-diff(or otherwise hiding those bytes) removes the last check on them; thegit shownoise it saves is a per-clone problem with per-clone fixes (git diff --stat, a pathspec exclude,.git/info/attributes). -
Security scanning is CI-side, with one local half.
bun run lint:auditisbun audit --audit-level=highoverbun.lockand gatespackage-health.yml; run it after any dependency change, because that is the one that can turn it red. It reads the lockfile deliberately — GitHub's dependency graph resolves only the ranges inpackage.json, so Dependabot anddependency-review-actionare blind to the shipped closure and are not used as gates here. Advisories that predate the gate are suppressed by ID in the script and listed inSECURITY.md;tests/unit/ci/SecurityPolicy.test.tsfails if the two sets differ, so never silence one without the other. CodeQL (codeql.yml, pull requests + weekly) and the workflow-hygiene invariants asserted bytests/unit/ci/WorkflowHygiene.test.ts— SHA-pinned actions, explicit read-only workflow permissions, frozen installs — are the rest of it. A new workflow file has to satisfy that test on the firstbun test. -
Don't hand-edit the README test/coverage badges — CI updates them on push to
develop.
-
Code must run on Bun, Node ≥ 24, and Deno. Runtime-specific primitives (HTTP serve, sockets, workers, SQLite, …) live behind small abstractions in
src/runtime/and auto-detect at startup. -
Optional peer dependencies:
import()them lazily with a clear "install it withbun add …" error on failure, and declare them inpeerDependenciesandpeerDependenciesMeta.<pkg>.optional = true.Then declare the package a second time, in one of exactly two dependency contexts. Which one is not a preference — it follows from how the adapter is actually exercised:
- Root
devDependencieswhen a suite underbun test, or atests/smoke/case, imports the real module. What that buys is narrower than it looks, and worth stating exactly, because the obvious answer is wrong: installing a package makes no existing suite exercise it. Nothing intests/is conditioned on module availability, and every adapter path runs against a hand-rolled fake (FakeCassandraClient,FakeMemcached,mock.module('@aws-sdk/client-s3', …)) — which is the right shape for fast feedback and stays. What the fakes cannot cover is the seam between themselves and reality: each adapter reaches its peer through a hand-written structural type (MemjsClientStatic,CassandraDriver,WebsocketServerLike), and a fake satisfies that stub by construction, so the stub is checked against nothing. A root devDependency is justified by a test that imports the real module and asserts the shape the adapter destructures — seetests/unit/ci/OptionalPeerModuleShapes.test.ts. Use a literal specifier there: it is the only form that pins the package at the install, and the only oneknipcan attribute to the manifest entry, which is what keeps it out ofknip.jsonc'signoreDependencies. tests/integration/brokers/package.jsonwhen the adapter earns its coverage against a live broker in Docker. Those packages are absent from the rootnode_modulesby design — the rationale is intsconfig.dev.json's exclude entry andtests/integration/brokers/README.md— and that is what keeps the root install tiny (two runtime dependencies) and keeps heavyweight driver closures out ofbun audit's surface.
A peer in neither context is the defect (#676): nothing installs it, so the structural stub standing in for its types is checked against nothing, and no gate notices —
bun run typechecknever compiles a call site and the adapter suites all pass against their fakes.tests/unit/ci/OptionalPeerDeclarations.test.tsasserts the split, in both directions, so it cannot rot silently again.Three traps, all silent:
-
bun add <pkg>no-ops when<pkg>is already an optional peer — bun treats it as declared and does nothing. Write thedevDependenciesentry by hand and materialise it withbun install. -
A package that ships no types of its own (
ws,memjs) needs its@types/*alongside, or the literal import failstypecheck:devundernoImplicitAny. -
A root devDependency enters
bun audit's surface, sobun run lint:auditis the gate that decides whether a peer can live there at all — a driver whose closure carries an unfixable high advisory cannot, and that is a security decision, not a packaging one. Do not reach for a new--ignore: every suppression inlint:auditpredates the gate, and adding one to get a change through is how a gate stops gating. Record the gap in the guard's allow-list instead and raise it.cassandra-driveris the worked example, and how it ended is the lesson. No published version clears the gate — 4.9.0 hard-pinsadm-zip: ~0.5.10and GHSA-xcpc-8h2w-3j85 is fixed only in 0.6.0 — so it spent two waves in the allow-list, declared nowhere and typed against nothing. The answer was not a suppression and not anoverridespin (which would clear our audit and leave every consumer resolving the same range): it was the second context.tests/integration/brokers/package.jsondeclares it andtests/integration/brokers/cassandra/earns the coverage against a live cluster, so the driver's closure is never in the root lockfile forbun auditto read. An unfixable advisory is a reason to move a peer to the brokers manifest, not a reason to silence a gate or to leave a stub unchecked (#676).
- Root
-
Strict TypeScript. ESM with the
.jsimport suffix on relative imports (required by the build's module resolution). -
Discriminated-union handling via
ts-pattern(match(x).with(…).exhaustive()). -
Every
matcharm delegates to a privateonXxxhandler. Wherever amatch(…)dispatches an incoming message, event, or command — an actor'sonReceive/onCommand/onEvent(or a router it calls), a cluster-event subscription (cluster.subscribe(evt => match(evt)…)), or a wire/system-command dispatcher — every arm (each.with(…)and any.otherwise(…)) is a thin call into a private method (.with({ kind: 'data' }, (m) => this.onData(m)),.otherwise((m) => this.onUnhandled(m))), never an inline body, even a one-liner — no exceptions. Name iton+ the PascalCase discriminant (onData,onMemberUp,onCreate); type the parameter as the named variant type (see next bullet), or omit it for payload-free kinds. Keeps the matcher a scannable dispatch table. Exempt: matches on internal state (a state machine / behavior / directive reducer) or that compute a value in a helper (config, codec, route, priority) stay inline. -
Measured-hot-path exemption. Where a
match(…)dispatches on a path a benchmark in this repository has measured as hot, it may be aswitchonkindinstead — everycasestill a thinonXxxdelegation, exhaustiveness restored by adefaultthat assigns the scrutinee tonever(the shapedecodeCrdtincrdt/DistributedData.tsdocuments as the reference) — and the site must carry a comment naming the benchmark and the measured delta. The exemption is per-site and evidence-carrying: aswitchwithout that comment is a style violation, and the comment is the token the pattern-matching conformance sweep (#494) recognises as exempt and must not convert back.The rule it bends is a good one — a matcher reads as a dispatch table where a chain of ifs reads as logic — and the arms staying delegations is what keeps that. What changes is the construct, and only where a number justifies it: building a matcher and one closure per arm is free at a call rate of one per request and is not at one per actor lifecycle. Two sites qualify today (
ActorCell.handleSystemCommand,BoundedMailbox.enqueue), and a third needs its own measurement, not an appeal to these. -
interfacefor contracts and heritage,typefor everything else. A declaration is aninterfacewhen it prescribes function heads — any method, call or construct signature — or when itextendsanother shape. Everything else is atype X = { … }: plain data shapes, unions, mapped and conditional types. The split follows what the declaration is for. An interface states a contract someone implements, andextendsreads as a hierarchy where an intersection only reads as conjunction; a data shape states a value's layout, and theretypecomposes with the union aliases the project already uses (type XOptions,type Command). A function-typed property (onLost?: () => void) is not a function head — that shape stays atype. An interface may extend a type alias, so a contract built on a plain data base is writteninterface X extends XBase { … }withXBasestaying atype; the mixture is intended. -
Discriminated unions are defined as named variant types. Declare each tagged union as a union of named members (
type Command = DepositCommand | WithdrawCommand | BalanceCommand), never an inline object-literal union — including the union alias itself (type Command, nottype Cmd). Name a variantPascalCase(kind)+ a role suffix matching the union (Command/Event/Message) — collision-safe (Set,Get,Publishnever bare); keep variant types module-local where the union is. Handlers take the named variant type (onDeposit(c: DepositCommand)), notExtract<Union, { kind }>. -
The discriminant field is always
kind(nevertypeortag) — including the WebSocket/wire protocols of the examples.typecollides with thetypekeyword;kindis the single project-wide convention. -
Pass the actor class, not a closure around it. Every slot typed
ActorClassOrFactory—spawn/spawnAnonymous,withEntityActor/withActor/withSingletonActor, theentityActor/singletonActor/actor/childfields, theRouter.*routee — takesMyActordirectly;actorFactoryOfdoes the wrapping.spawn(() => new MyActor(), 'x')is a leftover from thePropsera and reads as noise. The factory form is for constructor arguments (() => new Worker(database)) and for anything the class form cannot express — nothing else. Per-actor configuration is the third argument,ActorOptions, never a closure. -
Spell out abbreviations in identifiers — types, classes, files, aliases, generic type parameters, methods, fields, and locals/params, plus the
kindstring-literal values. Full words:Command/Message/Acknowledgment/NegativeAcknowledgment/Terminate/Increment/DirectMessage/Request/Response/Function/Context/Connection/Arguments/Directory/Repository/Deduplication/PersistenceId/Implementation/Constructor(notCmd/Msg/Ack/Nak/Nack/Term/Inc/Dm/Req/Res/Fn/Ctx/Conn/Args/Dir/Repo/Dedup/Pid/Impl/Ctor). Two exceptions only: (1) single-letter loop/lambda/catch vars (m,e,i) may stay; (2) names mirroring an external API or established domain acronyms stay verbatim — nats.js (.ack()/.nak(),max_msgs), prom-client (inc()/dec()/set()), amqplib (noAck), DOM (AudioContext),MsgPack(MessagePack), andPubSub,K8s,AMQP,MQTT,SQL,S3,DNS,CBOR. -
HOCON config keys go through
src/config/ConfigKeys.ts(typed, single source of truth). Options resolve with precedence: explicit options > HOCON > built-in defaults — layered withmergeOptionsfromsrc/util/OptionsMerge.ts, whereundefinedon a higher layer means "not set" and falls through rather than shadowing. A key inreference.confmust be reachable fromConfigKeysand read by something insrc/—tests/unit/config/NoDeadConfigKeys.test.tsfails otherwise. A knowingly-unimplemented key goes in that test'sKNOWN_DEAD_KEYSwith the issue that will remove it; adding a key nothing reads is not an option. -
JSDoc explains the why — constraints, rationale, non-obvious trade-offs — not a restatement of the code. Match the surrounding comment density; no narration or noise.
-
The template is always a separate
.htmlfile — never an inline string. Every@ComponentusestemplateUrl: './XComponent.html', pointing at a file named after the component and sitting beside it. This holds without exception, including for a component that renders no markup of its own:EChartComponent.htmlis a lone HTML comment explaining why it is empty, which says more thantemplate: ''did and keeps the rule free of edge cases to argue about.The reason is that markup and logic are read, reviewed and edited by different motions. A hundred-line template inside a decorator pushes the class it belongs to off the screen, gives the markup no HTML tooling — no formatter, no tag matching, no syntax awareness — and makes a diff that touches one
<span>look like a change to the component. It also puts HTML inside a template literal, where a stray backtick or${terminates the string and the error surfaces asNG1010: template must be a string, nowhere near the character that caused it. That has actually happened here, twice, both times from a backtick inside an HTML comment.stylesmay stay inline: they are usually a line or two of:hostrules, and the UI's real styling lives indevtools-ui/src/styles/. -
Nothing else needs adjusting when a template moves out. The
source-hashbehindbun run check:uihashes every file underdevtools-ui/src, extension-blind, so a template-only edit already marks the committed bundle stale — verified by making one and watching the check fail. Size budgets are unaffected too: the compiler inlines the template into the component's chunk, so attribution and the per-panel numbers do not move.
A module-level SCREAMING_SNAKE constant lives in one of four places.
Check them in order and take the first that matches:
XOptions.ts— it is the built-in default of anXOptionsTypefield, or a bound that file'sXOptionsValidatorchecks. This covers the lowerCamelCase default objects of the same family too (defaultFailureDetectorOptions,defaultPhiAccrualOptions).- It stays where it is — a closed list of six kinds, not a loophole:
- wire/format vocabulary whose meaning is the codec beside it —
JsonTree.tstags,CborCodec.tstag numbers,BodyCodec.tsflags andATS1_MAGIC,Protocol.tsHEADER_SIZE; - algorithm-derived sizes fixed by a primitive chosen in that file —
Encryption.tsIV_LENGTH/KEY_LENGTH,MAX_KEY_VERSION; - a regex or lookup table that is the implementation —
Html.tsESCAPES,Duration.tsUNIT_MS,MimeTypes.tsDEFAULT_MIME_TYPES,SystemPaths.tsGROUP_POLICIES; - a singleton or sentinel needing a class or symbol from the same
file —
NOOP_TRACER,Metrics.ts'sNOOP_*,Behaviors.ts's five{ kind }objects,BackoffSupervisor.tsRESPAWN_TICK; - a value derived from another constant in the same file —
FRAMING_TAGS,RESERVED_TAGS,HISTORY_MAXIMUM_SPAN_MS; - a protocol declaration — bounds in a
*Frames.tsthat define the wire schema a client validates against (TRACING_BUFFER_*).
- wire/format vocabulary whose meaning is the codec beside it —
src/<subsystem>/Constants.ts— every other tuned value: cap, bound, timeout, buffer size, retry limit, protocol size. One file per top-level directory undersrc/; nested directories fold up (src/http/websocket/*→src/http/Constants.ts), root-level files usesrc/Constants.ts. Create it once a subsystem has two such constants, or one that more than one file reads.src/util/Constants.ts— only when two or more top-level subsystems consume it.src/util/has no outward import, so it is the one module everything may depend on without coupling subsystems.
Further rules:
- A
Constants.tsimports nothing from its own subsystem — cycle-free by construction, the same propertyXOptions.tshas. Importingsrc/config/ConfigKeys.jsor anotherConstants.tsis fine. - Rule 3 is what rule 1 cannot express. A default shared by two
options types has no single
XOptions.tsto sit in — co-location would put it in both.DEFAULT_HEARTBEAT_INTERVAL_MSandDEFAULT_SQLITE_BUSY_TIMEOUT_MSare that case. AnXOptions.tsmust never import a functional module to reach a constant. - Move the declaration with its JSDoc verbatim, and carry
as constand explicit type annotations across. Dropping them is how a "pure move" silently widens a type:'drop-head' as constbecomesstring, aReadonlySetbecomes mutable. - Constants move,
ConfigKeysreads do not.tests/unit/config/NoDeadConfigKeys.test.tsmatchesConfigKeys.<group>and.<leaf>in the same file, so relocating a reader breaks it even when behaviour is identical.bun run typecheckcannot see that failure. - Naming:
DEFAULT_<DOMAIN>_<UNIT>with the unit suffix. Prefix a vendor limit with the vendor (DYNAMODB_MAX_BATCH_ITEMS) — a bareMAX_BATCH_ITEMSis unambiguous in one driver and meaningless in a shared namespace. - Public names stay public. Barrels re-export from the new location, so relocating a declaration is never a breaking change.
- Two constants may share a value and still both stay:
MAX_WALL_CLOCK_SKEW_MS(24 h security cap) andDEFAULT_TOMBSTONE_TTL_MS(retention window) are a documented non-merge, as are the three unrelatedEMPTYsentinels.
-
Every configurable thing has one
XOptions.tsfile with three exports, all in the "Options" family — there is no separate "Settings" concept:XOptionsType— the plain options-object shape (a bare{ … }you can pass directly).XOptionsBuilder— the fluent builder,extends OptionsBuilder<XOptionsType>(broker actors viaBrokerOptionsBuilder<XOptionsType>).XOptions— bothtype XOptions = XOptionsBuilder | XOptionsType(the accepted-input union used in every consumer signature) andconst XOptions = XOptionsBuilder(value alias, soXOptions.create()/new XOptions()resolve to the builder).
Naming lockstep with no divergence: builder method
withX⇔ fieldx⇔ HOCON leafkebab(x), with any unit suffix dropped (e.g.withQos⇔qos⇔qos, neverdefaultQos;withGossipIntervalMs⇔gossipIntervalMs⇔gossip-interval;withCleanupMs⇔cleanupMs⇔cleanup-interval). The suffix goes because HOCON carries the unit in the value and the reader isgetDuration, which takes30sand a bare millisecond count alike — repeatingMsin the key names one unit next to a value in another. The field keeps it, because TypeScript has nowhere else to say what30_000means. #1405 converted the last leaves that spelled it the other way, and the retired spellings are refused at startup rather than ignored. Multi-arg sugar is fine when the field still matches the stem (withCredentials(u, p)→ fieldcredentials;withCircuitBreaker(f, r)→ fieldcircuitBreaker). -
An optional fourth export,
XOptionsValidator, when the options have fields with real constraints (ports, positive durations/counts, byte sizes, enums, non-empty strings/arrays, URLs, cross-field rules). Itextends OptionsValidator<XOptionsType>(broker actors viaBrokerOptionsValidator<XOptionsType>) and implementsrules(s)with the protected check helpers (port,positiveNumber,positiveInt,nonNegativeInt,oneOf,nonEmptyString,url, …) plusfail(field, reason, value)for cross-field/bespoke rules. Helpers take only the field name (typo-checked againstXOptionsType) and are a no-op onundefined— an unset optional always passes; required-ness stays where it was (BrokerActor.requiredOptions()/ an explicit guard). Options that are all booleans / strings / callbacks get no validator. Rejections throwOptionsError(source-agnostic — distinct fromBrokerOptionsErrorfor missing required fields andConfigErrorfor malformed HOCON).- Validation runs once, at consume time, on the merged settings, so the
builder, a plain object, and HOCON are all covered and cross-field rules see
the final values. Broker actors return
new XOptionsValidator()from theoptionsValidator()hook (run inpreStartafter the required-field check); non-broker consumers callnew XOptionsValidator().validate(settings)once in their constructor, right after the defaults spread. This is not aresolvehelper — the merge stays a plain spread; validation is a separate void assertion.OptionsBuilderhas no set-time validation.
- Validation runs once, at consume time, on the merged settings, so the
builder, a plain object, and HOCON are all covered and cross-field rules see
the final values. Broker actors return
-
All option-relevant types are co-located in
XOptions.ts— including theXOptionsTypedeclaration (the config contract read byreadOptionsFromConfig) and, when present, theXOptionsValidatorclass. The functional file (actor/store/factory) imports the type contracts (XOptions+XOptionsType) type-only from./XOptions.js, and — when it validates — additionally value-importsXOptionsValidator. There is no runtime cycle:XOptions.tsnever imports the functional file, so the value edge only runs one way. -
A builder is its settings.
OptionsBuilder.setwrites each field as an own enumerable property, so a builder instance is structurally a bag of the fields you set (thewithX/buildmethods stay on the prototype and never surface when it's spread or serialized). Consumers take theXOptionsunion and read the argument directly — there is noresolvehelper:const s = options as XOptionsType(or, to snapshot / merge,{ ...defaults, ...(options as Partial<XOptionsType>) }). A plain object and a builder are fully interchangeable. Keep the union (XOptions) in the signature — a methods-only builder is not assignable to a bareXOptionsType(TS weak-type check). Broker actors need nothing:BrokerActor's constructor takes the union and snapshots it, so subclasses justsuper(options). A subclass/consumer that chains builder methods on its parameter must type that parameterXOptionsBuilder(the union has no methods). -
Builder-first is the documented/primary style — docs and examples show the builder; the plain object is the shorthand alternative (mention it once per page, don't lead with it).
-
Never nest a builder into a call — always assign it to its own contextual local variable first (
const mqttOptions = MqttOptions .create()…; new MqttActor(mqttOptions)), then pass the variable. -
Write builder chains multi-line — one
.withX()per line — when there are two or more. A chain with a single.withX()stays on one line (const mqttOptions = MqttOptions.create().withClientId('x')) — forcing a lone call onto its own line reads worse. Two or more calls always go one-per-line (never a single-line multi-call chain). -
HOCON precedence is unchanged — the builder / plain object feeds only the highest-precedence explicit layer; unset fields fall through to HOCON, then built-in defaults.
-
Issue-first. Before starting work, check for an existing issue (
gh issue list, or search the tracker). If one exists, work against it and take its discussion into account. If none exists, open one first — for traceability — using the matching template in.github/ISSUE_TEMPLATE/(bug / feature / documentation / security). -
Close via the commit body: when the work lands, close the issue with a
Closes #NNN(orFixes #NNN) line in the commit body. GitHub resolves it once the commit reaches the repository's default branch — heredevelop, notmain— so the issue closes on the nextdeveloppush rather than at release time. There is no release-window in which to reconsider: only add the line when the issue is genuinely finished. -
Open an issue before non-trivial work to align on the approach first.
-
Comment on the issue whenever the work changes course. If something you find while working changes the diagnosis, the approach, the scope, or your confidence in any of them, say so on the issue as you find it — a new comment, not an edit to the body, so the sequence stays readable.
The commit message records what was done and why; it is a poor place for what turned out to be wrong on the way there, and it is invisible to anyone reading the issue later. What is worth a comment:
- The report is inaccurate or stale. The defect is already fixed, half-fixed, differently caused than described, or reproduces only under a precondition the report omits. Say which part still stands.
- The obvious fix does not work. Record the attempt and why it
failed, so the next person does not spend the same hour. (
Object.assignreintroducing a prototype-pollution bug verbatim, because it is[[Set]]too, is exactly this.) - A chosen bound, default or name changed after measuring. Give the numbers that moved it.
- The scope moved. The fix turns out to need a different layer, a new seam, or an API change the issue never mentioned — or part of it belongs in another issue. Note the split and where the rest went.
- A verification step proved nothing. If a check you relied on was invalid, that matters more than the result it produced.
This is the same reasoning as Issue-first: the value is traceability for whoever picks the thread up next, including you in six months. A duplicate, a wrong severity, or a fix that was tried and abandoned is worth more written down than re-derived.
- Label taxonomy:
priority: {high,medium,low},severity: {critical,high,medium,low},security,i18n,infrastructure,dependencies,production-goal, plus the standardbug/enhancement/documentation. Audit-catalog items use the title prefixes[Security]/[Feature]. production-goalmarks the path to production readiness — it is a gate, not a batch marker, so it belongs on any issue that blocks or defines that path regardless of which review found it, including ones filed long before. Filtering on it should answer "what is still between us and running this for real", which is why it is applied to existing issues rather than duplicating them.- Security-first posture: cap untrusted input (e.g. WebSocket /
wire-frame size limits), never trust client-supplied integrity fields,
use crypto-grade randomness for wire identifiers. A security-relevant
change gets a
SecurityCHANGELOG entry and aseverity:label.