Guidance for Claude Code working in this repository. This file is the index: brief project meta, plus pointers into docs/docs/ (Docusaurus, for humans) and .claude/skills/ (for Claude when building Act apps). When in doubt, follow the link.
This file is auto-loaded into context — that is not the same as having read it. Before drafting a slice plan or making the first edit, deliberately consult:
- Working on a branch —
git branch --show-currentshould not returnmaster(ormain). If it does, create a feature branch first (act-<issue>-<slug>); never accumulate edits or commits on master. - Development Workflow — the "Changing a port interface" rule, the "Pre-handoff workflow," the doc audit step. If a slice touches
libs/act/src/types/ports.ts, updatinglibs/act-tck/src/is part of the same slice, not a follow-up. - Rules for contributing to this repo — durable workflow rules (100% coverage gate, naming, no manual version bumps, integration helpers in separate packages, no
--no-verify). - Safety-critical one-liners — load-bearing per-feature gotchas. Re-skim the ones relevant to the file you're about to change.
Skipping this checklist is how duplicated work (per-adapter tests that should have lived in the TCK), master-branch edits, and unnecessary major bumps slip in. Read the rules first; they answer most "should I…?" questions before they reach the user.
Act is an event sourcing + CQRS framework for TypeScript, built around DDD aggregates and reaction-driven workflows. The core philosophy: any system distills into Actions → {State} ← Reactions.
pnpm monorepo with two main sections:
-
/libs— core framework libraries@rotorsoft/act— core event sourcing framework@rotorsoft/act-pg— PostgreSQL adapter (production)@rotorsoft/act-sqlite— SQLite/libSQL adapter (embedded/single-node)@rotorsoft/act-patch— immutable deep-merge patch utility@rotorsoft/act-http— HTTP integrations (umbrella).webhookfor reaction-driven POST delivery,receiverfor inbound webhook ingestion, thessesubpath for incremental state broadcast (the surface once published standalone as the now-removed@rotorsoft/act-sse), plus the auto-generated API subpaths (trpc,hono,openapi) that walk a builtIActregistry and emit one route per action — guide at docs/docs/guides/auto-generated-api.md@rotorsoft/act-pino— pino-backedLoggeradapter@rotorsoft/act-notify— hybrid notify-broker decorator:withBroker(store, broker)delegates every durable Store method and rides an external broker (Redis implemented, Kafka scaffolded, Loopback for tests) for cross-process wakeups only — lifts the LISTEN/NOTIFY fanout ceiling without touching durability. TCK-proven over PostgresStore@rotorsoft/act-otel— Prometheus metrics bridge:instrument(app)maintains the canonical metric set from the observability guide off the lifecycle events. Leaf package — core stays metrics-free by design@rotorsoft/act-crypto— authenticated envelope encryption (AES-256-GCM + versioned wire format) for adapters that want column-level encryption with operator-controlled keys. Leaf package — adapters depend on it, core does not.@rotorsoft/act-ops— operational primitives (idempotency, retry budgets, poison-message classification). Zero dep on@rotorsoft/actby design — so non-Act receivers (forwarded-bus consumers, Express endpoints, queue workers) can speak the same contract without pulling in the orchestrator@rotorsoft/act-tck— Test Compatibility Kit for Store/Cache/Logger ports
-
/packages— example applicationscalculator— simple state machine; rebuild and close demoswolfdesk— complex ticketing (from "Learning Domain-Driven Design")server,client— tRPC + React example
pnpm install # install
pnpm build # build all packages
pnpm test # run all tests with coverage
pnpm typecheck # tsc --noEmit
pnpm lint / lint:fix # biome
pnpm clean # remove build artifacts
pnpm scrub # remove all node_modules + build artifacts
pnpm dev:calculator # run examples
pnpm dev:wolfdesk
pnpm dev:http # multi-transport demo (trpc + hono rest + openapi) — server + client concurrently
vitest # watch mode
pnpm -F calculator test # one package
npx vitest run packages/calculator/test/invariants.spec.ts # one file
pnpm -F wolfdesk drizzle:migrate # wolfdesk migrations (its tests also self-migrate)
pnpm act # interactive contracts explorer (current dir)
pnpm act packages/wolfdesk # explore a specific package
pnpm act -q TicketOpened # non-interactive: print one entity, exit- Node ≥ 22.23.1, pnpm ≥ 11.9.0 (not npm/yarn)
- TypeScript 7 (
typescript@^7.0.2, the native Go compiler —tscis the native binary) across the framework, libs, and example apps. Thedocspackage alone stays ontypescript@6— a documented, temporary island — because typedoc caps itstypescriptpeer at6.xand the API-reference generation (pnpm -F docs build:all) can't run on TS7 yet. pnpm's per-package isolation resolves the docs symlink to its own TS6 install; the rest of the workspace hoists TS7. Revisit once typedoc supports TS7. - TypeScript strict mode everywhere
- All actions, events, and state require Zod schemas
- Events are immutable — never mutate event data; evolve via versioned event names
- All actions need actor context (
{ id, name }) - ESM only (
"type": "module",.jsimport extensions) - Public API stability is governed by STABILITY.md — read before changing any builder API,
IActmethod,Store/Cachecontract, lifecycle event, or public type export
Conventional commits, validated by hook:
<type>(<scope>): <subject>
# Types: feat, fix, docs, style, refactor, test, chore
# Scope: package name (act, act-pg, calculator, wolfdesk, etc.)
# Subject: imperative mood, lowercase, no period
When the user wants to scaffold or extend an app using Act, use the scaffold-act-app skill. It owns the full app-building surface: spec parsing, state/slice/projection design, monorepo layout, tRPC API, React client, SSE wiring, tests. The skill description triggers it automatically when the user asks to build, scaffold, or translate a domain model.
The act CLI (shipped from @rotorsoft/act-diagram as a bin) is the build-time companion to act-inspector. Where the inspector shows runtime state, act shows the structural contract — every event, action, slice, projection, state, and reaction the parser can see, with producer/consumer relationships, captured Zod schemas, and deprecation status (via the _v<n> convention). Detail views can jump straight into $EDITOR at the source line.
pnpm act— interactive: pick a category → entry → view detail → optionally open in$EDITOR.pnpm act -q <name>— non-interactive, exits after printing. Used by CI smoke tests inci-cd.yml.
If schemas aren't being captured for an event, the parser is best-effort: it walks .emits({...}) literally. Shorthand ({ TicketOpened }) records the identifier name; explicit Zod expressions are captured verbatim.
| Topic | File |
|---|---|
| Project intro & key concepts | docs/docs/intro.md |
| State/Slice/Projection/Act builders | docs/docs/concepts/state-management.md |
| Event sourcing model, settle, lifecycle events, projection rebuild, close-the-books | docs/docs/concepts/event-sourcing.md |
| Configuration, snapshotting, batched projection replay | docs/docs/concepts/configuration.md |
| Errors, retry pattern, blocked streams, debugging | docs/docs/concepts/error-handling.md |
| Testing patterns | docs/docs/concepts/testing.md |
| Real-time / SSE | docs/docs/concepts/real-time.md |
| Optimistic concurrency, leasing, why no framework-level dedup | docs/docs/architecture/concurrency-model.md |
| Cache, snapshots, time-travel queries | docs/docs/architecture/cache-and-snapshots.md |
| Correlation, drain, settle internals | docs/docs/architecture/correlation-and-drain.md |
Cross-process reactions (Store.notify) |
docs/docs/architecture/cross-process-reactions.md |
| Reaction priority lanes (saturated drain) | docs/docs/architecture/priority-lanes.md |
| Close-the-books phase semantics (explicit + online) | docs/docs/architecture/close-cycle.md |
Online close-the-books policies — .autocloses / .archives |
docs/docs/guides/close-policies.md |
| Event schema evolution (versioned event names) | docs/docs/architecture/event-schema-evolution.md |
| Deliberate ES design choices & non-goals (compensation, schema evolution, projection idempotency) | docs/docs/architecture/design-decisions.md |
| Store / Cache / Logger contracts and adapters | docs/docs/architecture/extension-points.md |
| Behavior-contract checklist (documented runtime guarantees → backing tests) | docs/docs/architecture/behavior-contracts.md |
| Production deployment checklist | docs/docs/guides/production-checklist.md |
| Database-backed projections (Drizzle, batched replay) | docs/docs/guides/projections-to-database.md |
External integration (inline webhook vs forwarded bus, idempotency contract, recovery) |
docs/docs/guides/external-integration.md |
Auto-generated API surfaces (trpc, hono, openapi subpaths + deployment recipes) |
docs/docs/guides/auto-generated-api.md |
Adding a new @rotorsoft/act-* package |
docs/docs/guides/contributing-new-package.md |
| Third-party adapter onboarding (TCK from a fresh repo + conformance badge) | docs/docs/guides/tck-conformance.md |
Inspecting contracts with the act CLI |
docs/docs/guides/contracts-cli.md |
When an Act application hits the edges (events table growing without bound, cooldowns after terminal state, regulated retention windows, partition-drop archival), the playbook lives in recipes/ at the repo root — separate from libs/ (framework code), docs/ (framework reference), and book/ (design-history essays).
| Topic | File |
|---|---|
| Top-level operator landing + envelope of safe operation | recipes/README.md |
| Scaling decision tree (symptoms → recipe) | recipes/scaling/README.md |
Close-the-books patterns (.autocloses({...})) |
recipes/scaling/close-the-books/README.md |
Cold-tier archival (.archives(...) + S3 / JSONL) |
recipes/scaling/archival/README.md |
Scale-out by splitting stores (per-context/tenant ActOptions.scoped) |
recipes/scaling/split-stores/README.md |
Hybrid store — event log and subscriptions on separate databases, behind one Store |
recipes/scaling/hybrid-store/README.md |
| Lift the LISTEN/NOTIFY fanout ceiling (act-notify + Redis) | recipes/scaling/notify-broker/README.md |
| Partitioning gating page (the "don't" page) | recipes/scaling/partitioning/README.md |
| HASH-on-stream partition recipe (SQL + run.sh) | recipes/scaling/partitioning/hash-on-stream/ |
| RANGE-on-id (single-aggregate giants, docs only) | recipes/scaling/partitioning/range-on-id/ |
| RANGE-on-created (bulk archival via DROP PARTITION) | recipes/scaling/partitioning/range-on-created/ |
| Prometheus metrics via act-otel (alert rules + runnable scrape demo) | recipes/observability/prometheus/README.md |
| Temporal / timing recipes landing (one-shot vs recurring) | recipes/temporal/README.md |
Recurring timers (tick-emits-next-tick over one-shot .defer) |
recipes/temporal/recurring-timers/README.md |
Recipe conventions: each folder has a README + optional runnable artifacts (.sql files using {{schema}} / {{table}} placeholders, run.sh wrappers, .ts samples that compile against the live @rotorsoft/act API). The framework code stays unchanged — recipes are operator playbooks, not framework features. New recipes land here when an operator hits a real wall the existing pages don't cover.
Per-package PERFORMANCE.md files track benchmark history with before/after numbers per optimization. READMEs link to them; READMEs themselves stay narrative.
libs/act/PERFORMANCE.md— drain/cache/correlatelibs/act-pg/PERFORMANCE.md— Postgres-specific (incl.notifylatency)
Benchmarks must run on real adapters. InMemoryStore is the fastest possible read/write path (no I/O, no SQL planner) — measuring perf optimizations against it understates wins and ignores the index/lock/connection-pool dimensions that the production adapters live in. Every perf claim that ships in a PERFORMANCE.md table needs numbers from act-pg (port 5431 docker) or act-sqlite. InMemory may appear as a baseline reference, never as the primary number. New benches go in the relevant adapter package's bench/ or scripts/, not just libs/act/bench/.
These are easy to get subtly wrong. Read the linked docs before editing related code.
- Cross-process reactions: call
store(adapter)beforeact()...build()— the orchestrator wires thenotifysubscription at construction. Late injection silently does nothing. Scoped Acts (ActOptions.scoped) bind notify againstoptions.scoped.storeinstead — same contract, different source. See cross-process-reactions. - Per-Act scoped ports:
ActOptions.scopedrequires bothstoreandcachetogether — sharing a cache across distinct stores would collide on stream keys. The framework threads the bag via AsyncLocalStorage; internalstore()/cache()calls resolve transparently. Use for multi-tenant SaaS, parallel test workers, or hybrid storage. Single-tenant apps stay on the singleton path. See extension-points.md § Scoped ports. - Projection rebuild: always
app.reset(targets), neverstore().reset(targets)directly. Onlyapp.resetraises the orchestrator's drain-armed flag — without it, a settled app short-circuits and skips the replay. See event-sourcing.md § Projection Rebuild. - Blocked-stream recovery —
unblockresumes,resetrebuilds. A stream blocks onretry >= maxRetries, when a handler throwsNonRetryableError(blocks on first attempt), or when it arrives at claim withretrystrictly greater thanmaxRetries— the budget spent with no error ever raised, which only happens when the lease was lost every round (#1418; the lever there isleaseMillis, notmaxRetries). That last check stands down while the store is failing (breaker.failing), becauseclaimraisesretrybefore any handler runs and a pass that dies on a store call leaves the count raised with nothing behind it (#1592). Recovery path isapp.unblock(input)— preserves the watermark, stream resumes from where it stopped.app.reset(input)is for projection rebuilds: it sets the watermark to-1and replays every event. Don't confuse them — usingresetto "clear a blocked webhook" would re-fire every historical webhook. Both accept eitherstring[]or aStreamFilter(regex/exact/source/blocked). Useapp.blocked_streams()to discover what's blocked before recovering. See error-handling.md § Blocked Streams. - Non-retryable errors signal permanent failure.
NonRetryableError(exported from@rotorsoft/act) tells drain "this is permanent, block now" — the finalizer recognizeserror instanceof NonRetryableErrorand forcesblock = blockOnErrorregardless oflease.retry. Use it in handlers for failures that won't recover on retry (4xx responses, validation errors, "user deleted" 404s).act-http/webhookalready throwsNonRetryableWebhookErrorfor 4xx. It does not overrideblockOnError: false— operators who explicitly opted out of blocking keep that behavior. See error-handling.md § Non-retryable errors. - Windowed close prunes, it does not retire.
app.close([{ stream, before }])and.autocloses({ keep: { days } })delete only the prefix below the closest safe__snapshot__— no tombstone, no seed, subscriptions and cache untouched, stream stays live.keepis type-gated behind.snap(...)(nothing to prune behind otherwise) and days-denominated with a one-day floor — close is low-cadence housekeeping; never put ms/seconds/minutes on a close-facing surface. No qualifying snapshot ⇒ the stream lands inskipped, not an error. See close-policies.md and close-cycle.md. - Layering is enforced by a test, not by memory.
libs/act/test/architecture.spec.tsfails the build ifinternal/importsnode:async_hooks, imports a value (not a type) fromscoped.ts, declares module-level mutable state, or re-exports anything from outsideinternal/; if any module other thanscoped.tsconstructs, runs, or reads anAsyncLocalStorage; or ifports.tspublishes the ports ALS instance (theScopedtype is public — it typesActOptions.scoped; the instance is not). The rule these encode:internal/holds stateless implementations that RECEIVE what they need (ReactionDeps.reaction_scope,DrainDeps.run_scoped) and never reach for ambient state;scoped.tsowns every ambient context and every operation on one. Each rule exists because it was broken — #1541 put an ALS ininternal/, and the first "fix" moved the declaration one directory over whileinternal/kept importing and reading it. Relocating a declaration is not decoupling a dependency. If a rule blocks you, the answer is almost always to inject the thing, not to widen the rule. - Reactions auto-inject
reactingTo: inside a slice handler,app.do(...)automatically threads the triggering event asreactingTo. Pass an explicit fourth argument only when overriding. See state-management.md § Auto-injectedreactingTo. - Single-key records:
state({}),.on({}),.emits({})accept exactly one key. Multi-key throws at runtime. - Cross-slice event schemas: when two same-name state partials declare the same event in
.emits({...}), both must reference the same Zod schema instance. The merge throws on different references — extract shared event schemas to a module (export const TicketOpened = z.object({...})) and import in every slice that declares them. See state-management.md § Cross-slice event schemas. - Deprecated event versions throw on emit: the
_v<digits>naming convention is load-bearing. AddingFoo_v2to.emits({...})auto-deprecatesFoo; any static.emit("Foo")targeting the legacy version throws atact().build(). Reducers (.patch({Foo: ...})) stay silent — replay of historical events never warns. Dynamic emits do not warn at runtime — the one-line startup advisory enumerates every deprecated event in scope, andapp.registry.deprecated_events(state_name)exposes the set for callers that want to layer their own policy. The orchestrator andevent-sourcing.tsstay deprecation-unaware by design. See event-schema-evolution.md § The versioning convention is the deprecation signal. - Tests: prefer
fixture(builder)from@rotorsoft/act/testfor the common case (per-test isolation, parallel-safe, auto-cleanup) andsandbox(builder)for multi-Act orbeforeAll-shared setups. Legacystore().seed()inbeforeEach+dispose()()inafterAllstill works for tests that exercise the singleton port mechanism itself. In tests, prefer the explicitawait app.correlate(); await app.drain();pair oversettle()so cycle counts are deterministic — and note the pair is mandatory, not stylistic:claimfollows the work mark and onlycorrelateraises one, so a baredrain()after a commit finds nothing (#1488). - Reaction backoff is a persisted per-stream schedule.
ReactionOptions.backoffpaces retries by persistingdeferred_at = now + delayon the stream via a due-markedack(#1262) — the same store mechanism as an explicitdefer, except the due-ack carries the climbingretryso the budget keeps accruing towardblockOnError, whereas a plain defer passesretry: -1(a defer is not a failure). Because the schedule lives in the store, every competing worker honors the window (the stream is excluded fromclaimuntildeferred_at), no worker re-claims and phantom-bumpsretrymid-window, andretryadvances once per real attempt — so a stream blocks after exactlymaxRetriesattempts regardless of worker count. The effective backoff is the configured delay, honored precisely and decoupled fromleaseMillis(the lease is released on the due-ack, not held through the window). See error-handling.md § Backoff. - Lanes give intra-process responsiveness, not just deployment shapes.
.withLane({...})spawns oneDrainControllerper declared lane plus the implicit"default".Act._drainAllruns every controller'sdrain()in parallel viaPromise.all, so a slow handler holding the slow lane's lease doesn't block the fast lane's claim — even in a single process with noACT_ONLY_LANES. Per-laneLaneConfig.leaseMillis/streamLimit/cycleMsoverride caller-passedDrainOptions(the whole point ofwithLane({leaseMillis: 30_000})is to give the lane its own budget — a caller-level override would erase it). Lane assignments must agree across every reaction targeting the sametargetregardless of source (#1325) — a stream drains on one lane andsubscribekeys lane per-target, so disagreement throws atclassifyRegistry, because lanes have nomax()merge analogous to priority. That guard only sees static resolvers — a.to(fn)lane is a function until an event arrives — so correlate applies the same rules at resolution time, logging rather than throwing (a throw there pins the checkpoint for the whole app, #1420): a disagreement keeps the first-discovered lane and reports it (#1567), and an undeclared lane is rerouted to"default"and reported (#1564), because no controller claims it. Re-laning is restart-driven:subscribe()UPSERTs each stream's lane on every call; online re-laning while workers hold leases is not supported. See concepts/configuration.md § Lanes and guides/production-checklist.md § Sizing lanes.
Source-of-truth for what lives where:
libs/act/src/state-builder.ts,slice-builder.ts,projection-builder.ts,act-builder.ts— public builder APIslibs/act/src/internal/event-sourcing.ts—load(),action(),snap()libs/act/src/internal/correlate-cycle.ts,drain-cycle.ts,settle.ts— reaction pipelinelibs/act/src/internal/close-cycle.ts— close-the-books orchestrationlibs/act/src/ports.ts+libs/act/src/adapters/— port singletons and in-memory defaultslibs/act-pg/src/PostgresStore.ts,libs/act-sqlite/src/SqliteStore.ts— production adapterslibs/act/src/types/— public type contracts (Store,Cache,Logger,Snapshot, errors)
-
Strict TypeScript everywhere — type-check before pushing
-
Pre-commit hooks run lint/format; pre-push runs the full test suite (don't bypass with
--no-verifyunless asked) -
Use Zod schemas for all runtime validation
-
Never modify event data structures in place — evolve via versioned event names
-
Keep state machines focused; split concerns into separate slices
-
Adding a feature to core: update
libs/act/src/types/, implement, test, demo in an example, ensure all three stores (InMemory/Postgres/Sqlite) support it -
Adding a new
/libspackage: see contributing-new-package.md — seed a baseline tag before the first merge or semantic-release defaults to1.0.0 -
Respecting the stability charter. The public API is covered by STABILITY.md. Before changing any of the surfaces below, decide whether the change is additive (new optional method, new optional field, new event name) or breaking (rename, removal, narrowed type, changed semantics). Additive changes are fine in any release; breaking changes require a
BREAKING CHANGE:commit footer that drives a major version bump and a written migration note inRELEASE_NOTES_*.mdor the changelog. If you're not sure which category your change falls into, stop and ask. Files holding charter-covered surface:libs/act/src/builders/{act,state,slice,projection,…}-builder.ts— fluent builder DSLlibs/act/src/act.ts— theIActinterface (do,load,query,query_array,drain,settle,correlate,reset,close) and lifecycle event names/shapeslibs/act/src/types/ports.ts—Store,Cache,Loggerinterfaceslibs/act/src/types/index.ts(and what it re-exports) — public type surfacelibs/act/src/ports.ts— the public port singletons (store(),cache(),log(),dispose(), etc.) andSNAP_EVENT/TOMBSTONE_EVENTconstants
Out of scope for the charter — change freely: anything in
libs/act/src/internal/, performance characteristics, log formats, adapter implementation details outside the contract. -
Gating new public surface — write an RFC. The charter catches changes to the public surface; the
rfcs/process gates additions before they calcify. Any PR that adds a new public export, builder method, port method, or lifecycle event needs a one-page RFC (copyrfcs/0000-template.md→rfcs/NNNN-<slug>.md) capturing motivation, the surface added, alternatives considered, and stability impact. The PR template carries the checklist line; seerfcs/README.mdfor what does and doesn't require one. CI enforces this (scripts/check-rfc-gate.mjs, therfc-gatejob in.github/workflows/ci-cd.yml): a PR that grows the public-surface stability snapshot (libs/act-tck/test/__snapshots__/all-packages-stability.spec.ts.snap) without adding anrfcs/NNNN-*.mdfile — or linking one in the PR body or a commit message — fails. The gate only fires on additions (the snapshot grew); pure renames/removals and non-surface diffs pass, since the charter and the snapshot diff already cover those. A false positive is fixed the cheap way — open the RFC — or, when the growth is demonstrably not public surface (internal implementation text embedded in the snapshot), declared away with an auditablerfc-gate: exempt — <reason>line in the PR body, which the gate accepts. -
Changing a port interface (Store, Cache, Logger). When you add, remove, or change a method on a port in
libs/act/src/types/ports.ts, you must also update the matchingrunStoreTck/runCacheTck/runLoggerTckinlibs/act-tck/src/. The TCK is the executable contract — adapters validate themselves against it. Rules:- Add or update cases in
libs/act-tck/src/{store,cache,logger}-tck.ts. - If the method is optional, add a flag to the matching
Capabilitiestype and gate the new tests on it so existing adapters keep passing until they opt in. - Update the
docs/docs/guides/writing-a-*.mdwalkthrough for that port (if it exists yet). - Run the TCK against every in-tree adapter (InMemory, act-pg, act-sqlite, act-pino).
Example:
Store.query_stats(input, options)from #639 landed as a required method (not capability-gated — every adapter implements it). The TCK gained adescribe("query_stats", …)block instore-tck.tsin lockstep with the port change.
- Add or update cases in
Branch work isn't done until /release-check passes cleanly. Per-slice pnpm test -F <pkg> during development is fine; the final gate is not optional.
Sequence at the end of a feature branch:
-
/release-check— runs typecheck + tests + 100% coverage + lint + build + charter-diff in parallel. See .claude/commands/release-check.md. -
If coverage < 100% on any metric: run
/coverageto see the uncovered lines, then write the fault-injection test or restructure the code to remove the branch. Seefeedback_full_coverage.mdin memory and the patterns inlibs/act-pg/test/store.error.spec.ts/libs/act-sqlite/test/store.error.spec.ts. -
For substantive tickets (anything that touched
libs/act/src/, added a new public method, changed semantics, or migrated a callsite to a new primitive): run/book-note <ticket-slug>and write the narrative essay. See .claude/commands/book-note.md andbook/README.md. Skip only for pure chore/deps/docs PRs. The essay captures the why and the rejected designs — the part that won't be visible from the diff once it's merged. Do this BEFORE opening the PR, so the book entry lands with the code. -
Doc audit — any PR that changes a public surface, renames a method, migrates a callsite to a new primitive, or alters described semantics must update the relevant docs in the same PR. Run the stale-reference grep:
grep -rln "<old-name-or-shape>" docs/docs book CLAUDE.md STABILITY.md libs/*/README.md
Hits get fixed inline; do not leave them for a "follow-up PR." Specifically:
- Port changes (
Store/Cache/Logger) → checkdocs/docs/architecture/extension-points.mdand the matchingdocs/docs/guides/writing-a-{store,cache,logger}.md. The method-list snippet in extension-points goes stale every time the interface gains, loses, or renames a method. - Orchestrator /
ActAPI changes → checkdocs/docs/concepts/(especiallyevent-sourcing.md,error-handling.md,state-management.md). - Internal subsystem refactors (close-cycle, drain, settle, correlate) → check the matching
docs/docs/architecture/page. Pseudocode and ASCII pipeline diagrams there often spell out the old shape ("Phase X: query backward, limit:1") — grep for the literal description, not just the method name. - Lifecycle event additions/changes → check
docs/docs/concepts/error-handling.mdanddocs/docs/guides/production-checklist.md. The pattern that catches this: the PR's commit message says "we changed X" — every place that describes X in the docs needs the same update. Treat the docs as part of the public surface.
A doc claim about runtime behavior ships with its test. Any sentence in
docs/docs/**or a public port/Actdoc-comment that asserts a concrete runtime guarantee ("with_snapsresumes from the latest snapshot", "drain is at-least-once", "the cache is invalidated only onConcurrencyError", "subscribekeeps the maximum priority") must have a TCK or unit test that fails if the claim stops holding. When you add or change such a claim, add or update its row in the behavior-contract checklist. Thewith_snapsregression (#1024) shipped because the doc and the code diverged with nothing executable pinning the claim — the checklist is the standing guard against a repeat. - Port changes (
-
Only then: announce "ready for review", show the diff summary, offer to open a PR via
/pr.
Don't invent ad-hoc gates. Running pnpm typecheck or eyeballing pnpm test output once doesn't substitute for the gate. Reach for the slash command first; narrow to ad-hoc tooling only for targeted debugging mid-development.
Why this exists: each step closes a failure mode that has actually shipped. The gate (step 1) caught zero issues during development of ACT-639's eight slices because per-slice tests were run ad-hoc — the merge gate verifies the full matrix (typecheck against the workspace, lint across changed files, build of every adapter, 100% coverage including newly-added defensive branches). The book-note step (step 3) exists in narrative form for the same reason: ACT-639's PR almost shipped without one because the workflow didn't enforce it, and once a PR merges the reasoning behind rejected designs lives only in the author's head until it's lost. The doc-audit step (step 4) exists because the same #639 PR shipped without updating docs/docs/architecture/close-cycle.md (which still described the old per-stream query pattern) and docs/docs/guides/writing-a-store.md (which still referred to an earlier "planned" name for the same primitive) — both required follow-up PRs that should have been part of the original change.
- READMEs show current patterns and strategies — not historical benchmarks
PERFORMANCE.mdtracks evolution with per-optimization before/after numbers- New optimization → benchmark goes in
PERFORMANCE.md, README links to it - Deep reference goes in
docs/docs/(Docusaurus), procedural app-building guidance in.claude/skills/scaffold-act-app/, contributor workflow indocs/docs/guides/ - Doc snippets are type-checked. Every fenced
```tsblock indocs/docs/**is tangled and compiled against the live@rotorsoft/actsource in CI (pnpm -F docs check:snippets). A new snippet must be a self-contained program or carry ano-checkmarker on its info string if it's a deliberate fragment. See docs/scripts/README.md.
Durable workflow rules the AI assistant follows when working on the framework itself. Project-management concerns, not framework API guidance — for the latter, see "Safety-critical one-liners" above.
- 100% coverage on every metric is a merge gate.
pnpm testmust report 100% statements / branches / functions / lines before a PR ships. No exceptions for "defensive?? 0fallback" or "rollback path that mirrors an existing untested branch." Fault-injection patterns exist (seelibs/act-pg/test/store.error.spec.tsandlibs/act-sqlite/test/store.error.spec.ts) — use them. A 99.95% PR is not ready. No/* c8 ignore */or/* v8 ignore */markers inlibs/act/src/,libs/act-pg/src/,libs/act-sqlite/src/,libs/act-tck/src/, or any adapter underlibs/act-*/src/. If a defensive branch can't be hit, either remove it (the contract guarantees the value) or write a test that hits it via private-state mutation. Theact-diagramCLI is the only exception — its CLI surface guards genuine runtime conditions tests can't exercise (TTY checks, FS race conditions, non-Error throws) and uses ignore markers with inline justifications. - Integration helpers live in separate packages, never in core. HTTP delivery, message-bus forwarders, webhook signers, etc. go in their own
@rotorsoft/act-*package (precedent:act-http,act-pino,act-pg,act-sqlite,act-tck,act-patch). Core stays governed bySTABILITY.md. - No manual version bumps. Semantic-release owns the
versionfield inpackage.json. The only manual version event is seeding the baseline0.0.0tag when adding a new package. Manual bumps create diffs that conflict with the auto-bump commit. - Don't modify working code without explicit approval. Propose changes first when the user hasn't asked for code. Refactors-while-you're-here are the most common way to expand the blast radius of a small request.
- Conventional-commit subject must be lowercase.
feat(act): add foonotfeat(act): Add foo. The commitlint hook will reject otherwise. - Never
--no-verifyor--no-gpg-sign. The pre-commit hook runs lint-staged; the pre-push hook runs tests on master. Bypassing either ships unverified work. If a hook fails, fix the underlying issue. - PR auto-close uses GitHub numbers, not project keys.
Closes #735(auto-closes on merge), notCloses ACT-604(doesn't). Project keys go in the PR title and body for searchability.
The codebase uses two distinct casings. Which one you pick is determined by whether the identifier is on the public surface or not — there is no third "shifting style by context" option.
Public — camelCase. This is the convention, not a legacy carve-out.
Anything reachable from a package's src/index.ts (or subpath index.ts for act-http/act-ops) is camelCase:
- Exported functions:
verifyWebhook,applyPatchMessage,withIdempotency,runStoreTck,webhookMiddleware,minSafeTtl,extractIdempotencyKey,checkWebhook. - Public type fields:
IAct.forgetreturn{eventCount},forgotteneventeventCount,StateNode.varName,EventNode.hasCustomPatch,WebhookConfig.timeoutMs/idempotencyKey,HttpDeliveryErrorInit.responseBody,VerifyOptions.maxAgeSeconds,RetryProfile.safetyFactor,Projection.batchHandler,QueryStreamsResult.maxEventId,InMemoryIdempotencyStoreoptionsttlMs/maxEntries/retryProfile. - ActOptions / RetryOptions / ReactionOptions / Backoff / DrainOptions / SettleOptions / LaneConfig fields:
maxRetries,blockOnError,baseMs,maxMs,jitter,strategy,leaseMillis,eventLimit,streamLimit,cycleMs,debounceMs,maxSubscribedStreams,onlyLanes,settleDebounceMs,maxPasses,expectedVersion,reactingTo,asOf,maxSize. - Builder methods:
withState,withProjection,withReaction,withActor,withLane,withSlice. - Top-level factories: short single-word lowercase —
act,state,slice,projection,sensitive,webhook,receiver,broadcast. - Public class names + types — PascalCase always:
StateNode,BroadcastChannel,InMemoryStore,ConsoleLogger,RetryProfile,WebhookConfig,IdempotencyStore. Suffix withXxxOptions/XxxResult/XxxConfigwhen applicable.
Internal — short snake_case, no exceptions:
- Internal helper functions:
run_close_cycle,classify_registry,compute_backoff_delay,build_handle,merge_event_register,current_version_of. - Local variables:
event_to_state,last_event_name,stream_info,raw_body,headers_bag. - Function parameters: TS doesn't enforce parameter names as part of the public contract (callers pass positional args), so parameters are snake_case even on public method signatures —
event_name,state_name,skip_validation. - Private/protected class fields and methods:
_snake_casewith underscore prefix —_drain_controllers,_reactive_events,_arm_all,_wire_notify,_max_event_id_by_stream. - Internal type fields (types not re-exported through any
src/index.ts):Classification.static_targets,HandleResult.next_attempt_at,AuditPass.on_event,SettleDeps.on_settled. - Grouping prefixes/postfixes when several related identifiers share a structural role:
pii_*,make_*,is_*,compute_*,*_by_stream.
Parameter properties are banned by erasableSyntaxOnly: declare each field explicitly above the constructor and assign in the body. Constructor parameter names stay camelCase (matches the external call site); rename to _snake_case only on the assignment to the field.
The boundary is enforced mechanically by runStabilityTck from @rotorsoft/act-tck. Every package has a test/stability.spec.ts that snapshots the source text of every declared entry point plus its transitive relative re-exports. Any rename / removal / signature change on the public surface shows up as a snapshot diff in the PR. New packages opt in by adding @rotorsoft/act-tck as a devDep + project reference and dropping in their own stability.spec.ts.
Any options bag that ships to act().build() / openapi(app, ...) / similar entry points is validated with Zod, not hand-written if (x < min) throw ladders. Out-of-range values throw ZodError at the entry point so misconfiguration surfaces at startup, not on the first cycle tick. The standard:
| Element | Convention |
|---|---|
| Schema name | <Type>OptionsSchema — AutocloseOptionsSchema, SseOptionsSchema, OpenAPIOptionsSchema |
| Schema visibility | Internal const, never re-exported. The public surface is the inferred type + resolver, not the schema itself. |
| Inferred type | type <Type>Config = z.infer<typeof <Type>OptionsSchema> |
| Resolver | resolve<Type>Config(options: <Type>Options): <Type>Config — camelCase, parses + applies defaults in one call |
| Defaults | DEFAULT_* SCREAMING_SNAKE constants, referenced from the schema via .default(DEFAULT_*) so there's one source of truth |
| Custom error messages | { message: "..." } on the individual constraint when callers depend on the wording (test assertions, structured logs). Don't override Zod's default for shape errors callers don't read. |
Single home: every builder-facing act-core config bag (schema + resolver + DEFAULT_*) lives in libs/act/src/internal/config.ts — one discoverable module, one section per bag (Backoff, Reaction, Action, Lane, Drain/Settle, Act, Autoclose, CircuitBreaker, Fold). Feature logic (the CircuitBreaker state machine, the fold engine, the autoclose window math in autoclose-window.ts) imports its resolved-config type from there. A new config bag adds a section to config.ts and wires its resolve<Type>Config at the declaration/runtime site (.on(...) / .do(...) / .withLane(...) / act().build(...) / drain(...) / settle(...)) — never a hand-rolled range check. Validation is fail-closed but non-tightening: reject NaN/±Infinity/nonsensical-negatives, never newly reject a value that works today. The sibling env/package config (config()) stays in the public libs/act/src/config.ts; @rotorsoft/act-http transport bags (sse-wiring.ts, openapi/index.ts) stay in that package. Never reinvent *_MIN / *_MAX companion constants or scatter ladders across the resolver.
See error-handling.md — covers ValidationError, InvariantError, ConcurrencyError, StreamClosedError, NonRetryableError, the retry pattern, blocked streams, per-reaction options, recovery via app.unblock / app.blocked_streams, and debugging (logging, lifecycle events, query_array/query_streams introspection).
For UI/frontend changes, start the dev server and exercise the feature in a browser before reporting done — type-check and tests verify code correctness, not feature correctness.
This repo uses Claude Code's hooks, slash commands, and subagents. See .claude/README.md for the full overview, end-to-end workflow examples, and tuning tips.
Quick reference:
- Hooks auto-typecheck files you edit, summarize work-in-progress state on turn end, and inject branch/dirty-file context on every prompt.
- Slash commands:
/pr,/release-check,/charter-diff,/coverage,/book-note,/scaffold-package. - Subagents:
act-code-reviewer(charter-aware),act-test-author(TCK + fault-injection patterns),act-doc-writer(project voice). - Skill:
scaffold-act-appfor translating specs into a working monorepo.
The typical "ticket → PR" flow: implement → /coverage → act-code-reviewer (pre-PR) → /charter-diff (if touched) → /pr <issue#>. The hooks fill the gap between "I think it's done" and "it is done."