with one click
async-jobs-and-events
// Queues and workers, domain event publishers, async notifications or projections, or not doing that work inside HTTP handlers.
// Queues and workers, domain event publishers, async notifications or projections, or not doing that work inside HTTP handlers.
Preparing a production release, pushing a vX.Y.Z release tag, running scripts/release.sh, or updating CHANGELOG.md with the changes that are about to be deployed to production.
apps/web UI — routes, @repo/ui, TanStack Start server functions and collections, navigation (Link vs useNavigate), forms (useForm + createFormSubmitHandler + fieldErrorsAsStrings for Zod field errors), Tailwind layout rules, design-system updates, and useEffect / useMountEffect policy.
Multi-channel notifications. Adding a new notification kind, group, or channel; in-app + email delivery; per-user prefs; project-level gates; idempotency.
Review the current conversation context and git changes, then persist durable repository knowledge into `dev-docs/*.md` by domain and into `AGENTS.md` for cross-cutting repo rules. Use after features, fixes, refactors, architecture changes, schema changes, or when the user mentions docs, documentation, design, architecture, business logic, conventions, or `AGENTS.md`.
ClickHouse queries, Goose migrations, chdb test schema, or telemetry storage paths.
Adding or changing routes in `apps/api`. One source of truth (`defineApiEndpoint` + a Zod schema) becomes an HTTP endpoint, an OpenAPI operation, an MCP tool, and a TS SDK method — descriptions and contracts must be written with all four readers in mind.
| name | async-jobs-and-events |
| description | Queues and workers, domain event publishers, async notifications or projections, or not doing that work inside HTTP handlers. |
When to use: Queues and workers, domain event publishers, async notifications or projections, or not doing that work inside HTTP handlers.
Domain events represent facts that happened — state transitions on an aggregate — not instructions for what should happen next. The publisher must never know or care which handlers are subscribed.
ScoreCreated, ScoreStatusChanged. Bad: ScoreDraftSaved (named to route around a handler), ScoreReadyForDiscovery (named after a consumer concern).ScoreCreated — regardless of whether the score is a draft or published.issues:discovery:${scoreId}:${status} instead of splitting into separate event types.A new event type is justified when it represents a genuinely distinct state transition that would exist even with zero handlers — for example, ScoreDeleted is a different fact from ScoreCreated. The test: does the aggregate's lifecycle model include this transition independently of downstream concerns?
// BAD — publisher decides routing based on consumer needs
const eventName = score.draftedAt === null ? “ScorePublished” : “ScoreDraftSaved”
yield* outboxEventWriter.write({ eventName, ... })
// GOOD — one canonical event, consumers filter
yield* outboxEventWriter.write({
eventName: “ScoreCreated”,
payload: { scoreId: score.id, organizationId, projectId, status: score.draftedAt === null ? “published” : “draft” },
})
// Consumer side — handler owns its filtering
ScoreCreated: (event) =>
Effect.all([
// discovery uses status-aware dedupe key, skips drafts internally
pub.publish(“issues”, “discovery”, event.payload, {
dedupeKey: `issues:discovery:${event.payload.scoreId}:${event.payload.status}`,
}),
pub.publish(“annotation-scores”, “publishHumanAnnotation”, event.payload, {
debounceMs: SCORE_PUBLICATION_DEBOUNCE,
}),
])
withTracing from @repo/observability in their pipe chain so that Effect spans flow into the OTel pipeline. See effect-and-errors for tracing rules.OutboxEventWriter service (or a plain OutboxEventWriterShape from createOutboxWriter in @platform/db-postgres) instead of inserting outbox rows directly.createEventsPublisher(queuePublisher) into domain-events instead of persisting an outbox row only to forward it.apps/workers, and durable multi-step workflows live in the Temporal-backed apps/workflows app.getIssueAlignmentState sees status === "running"). Guard the change with patched("<descriptive-id>") from @temporalio/workflow, or pin the new code to a new Worker Deployment Version, before merging. See temporal-developer and its references/typescript/versioning.md for the three-step patched → deprecatePatch → remove flow and Worker Versioning setup. Draining in-flight workflows before deploy is rarely viable here: optimizeEvaluationWorkflow activities are sized up to 75 min (GEPA budget) and the full pipeline can run substantially longer, so a deploy window almost never finds all evaluations:* workflow IDs idle.organizationId and projectId in domain-event payloads, topic/task payloads, and workflow inputs by default. Exceptions: MagicLinkEmailRequested, InvitationEmailRequested, UserDeletionRequested, the domain-events topic payload, the magic-link-email topic payload, the invitation-email topic payload, and the user-deletion topic payload.OutboxEventWriter / OutboxEventWriterShape for transactional boundaries and direct EventsPublisher publication for non-transactional or high-volume worker flows. Downstream side effects should run from the domain-event consumers rather than inline in the delayed task.debounceMs and throttleMsPublishOptions exposes two mutually exclusive delay fields. Both accept a window in ms and coalesce repeated publishes against dedupeKey, but they answer different questions.
debounceMs — fires after N ms of quiet on the dedupe key. Each publish within the window pushes the fire time forward and replaces the pending payload (BullMQ extend: true, replace: true). Use when the task should wait for a stream of events to settle.
Example: trace-end:run after TracesIngested. The batch event fans out one publish per deduped trace id; every new publish for the same trace resets the clock, so end-of-trace work fires once that trace is actually idle. If spans keep arriving every few seconds, that means the trace is still active — not firing is correct.
throttleMs — fires at most once per N ms per dedupe key. The first publish schedules the fire time; subsequent publishes within the window are dropped (BullMQ extend: false, replace: false). Requires dedupeKey. Use when you need a hard upper bound on fire latency and a cap on frequency, and where starvation under a continuous publish stream would be a product bug.
Example: annotation-driven alignment refresh (evaluations:automaticRefreshAlignment, 1h) and its escalation (evaluations:automaticOptimization, 8h). We want at most one refresh per evaluation per hour, firing at most 1h after the first new annotation, even if annotations keep arriving every 30 min.
Ask: if a publisher fires every 30 min forever on the same dedupeKey, what should happen?
debounceMs. Classic debounce. Fire time keeps sliding forward; fires only during quiet periods.N min regardless" → throttleMs. Bounded latency, bounded frequency; never starves.Reaching for debounceMs when the intent is "run at most once per hour". With a continuous publish stream every publish extends the TTL and the task never fires — silent starvation. If the wording in the spec or PR is "at most once per X" or "every X at most", that is throttle semantics; use throttleMs.
// Debounce — wait for events to settle
pub.publish("trace-end", "run", payload, {
dedupeKey: `trace-end:run:${traceId}`,
debounceMs: TRACE_END_DEBOUNCE_MS,
})
// Throttle — at most once per window, bounded latency
pub.publish("issues", "refresh", payload, {
dedupeKey: `issues:refresh:${issueId}`,
throttleMs: ISSUE_REFRESH_THROTTLE_MS,
})
Name the constant to match the semantic: *_DEBOUNCE_MS vs. *_THROTTLE_MS. A constant named for one semantic that is passed as the other is a lie readers will trip over.
When adding a new external system the product talks to:
packages/platform/*-<provider>.For env var naming when wiring config, see env-configuration. For layer rules, see architecture-boundaries.