"Design or add async specifications (events, scheduled jobs, handlers) for a domain in the v2 pub-sub architecture. Use when modeling an event-driven flow -- API-triggered events, fan-out jobs, or workers; enforces the AsyncAPI handbook's three allowed patterns, message/snapshot/operation shapes, x-emits cross-links, and full validation."
Install
npx skillscat add specfuse/authoring/design-async Install via the SkillsCat registry.
Design or add async specifications (events, scheduled jobs) for a domain in the v2 pub-sub architecture.
Enforces: .specfuse/authoring/handbooks/AsyncAPI_Handbook.md
Before writing any files, read and internalize the authoritative rules:
- Read
.specfuse/authoring/handbooks/AsyncAPI_Handbook.md— the full handbook, especially Section 1 (Architectural Principles) - Read
api/specs/CLAUDE.md— Section 10 (AsyncAPI Files — Critical Rules) - Read
.specfuse/authoring/samples/message-samples.yaml— canonical templates to copy from
Architectural constraints — ALWAYS adopt these
The v2 async architecture is deliberately minimal. Adopt these by default on every design task. If a scenario seems to require deviation, stop and ask the user before proceeding — explain what you want to introduce and why the simpler model is insufficient.
- Pub-sub only. Every message is published to a topic. Every consumer subscribes. There are no command queues, no point-to-point sends, no request/reply.
- The handler reacting to an event does the actual work directly. No intermediate command dispatch for the same domain.
- Scheduled jobs are thin fan-out dispatchers. Query DB → publish one event per work item → subscribers handle each independently with per-item retry and DLQ. The job itself does NO heavy processing. The narrow exception (single-output jobs with no per-item semantics) must be obvious — ask if uncertain.
- Events carry IDs + changed state only. Never embed the full aggregate.
- No language-specific fields in specs. No class names, namespaces, or package paths — the generator derives them.
When you want to deviate: STOP. Tell the user you think the v2 model is insufficient for this specific scenario. Explain the tradeoff. Wait for explicit approval before introducing anything new.
Inputs
The user will describe the async process they want to spec. Clarify:
- Which domain does this belong to?
- What triggers the process? (API operation, cron schedule, another event)
- What is the expected outcome?
- If fan-out is involved, confirm the per-item semantics (identifies that thin dispatcher is the right choice)
Design Process
Step 1: Identify the pattern
In v2, there are three patterns (and only three). If you find yourself wanting something else, stop and ask.
- API-triggered event → handler: An API write operation emits an event; one or more subscribers react and do the work.
- Scheduled job fan-out: A cron-triggered thin dispatcher queries the DB and publishes one event per work item; separate subscribers handle each independently.
- Scheduled job direct (narrow exception): A cron job produces a single output with no per-item semantics and does the work directly. Must be justified — when in doubt, default to fan-out.
Step 2: Design the message flow
Map out the full chain before writing any files. Example (API-triggered):
[API] POST /orders/{id}/finalize
└─ emits → OrderFinalized (event-topic)
├─ handler 1 → generates the fulfillment plan (does the work directly)
├─ handler 2 → sends notifications
└─ handler 3 → updates dashboard metricsExample (scheduled job fan-out):
CRON trigger → {Job}Job message (run-* operation, action: send, x-worker required)
└─ dispatcher → queries DB for work items
└─ publishes N × {SomeEvent} (to relevant event-topic)
└─ subscriber (on-* operation, action: receive) → processes each item independently (with retry + DLQ)Present the flow to the user and confirm before creating files.
Step 3: Create the files
For each element in the flow, create the required files in order:
Messages first (in domains/{domain}/messages/):
- Event:
{EventName}.yaml— past tense, withx-message-category: event,x-label,x-version - Scheduled job trigger:
{JobName}Job.yaml— withx-message-category: scheduledJob,x-label,x-version,x-scheduled-job - Reuse existing OpenAPI models via
$ref: '../models/{Model}.yaml'— NEVER duplicate schemas
Channels (in domains/{domain}/channels/):
- Add new messages to existing channels if appropriate
- Create new event-topic channels only for new aggregates
- Each domain has exactly one scheduled-trigger channel (
{domain}-jobs.yaml) - Every channel needs:
address,description,messages,x-domain,x-channel-type(event-topicorscheduled-trigger)
Async operations (in domains/{domain}/async-operations/):
- Verb prefixes and their rules:
on-*(action: receive,x-workerrequired) — event handlers. These are workers.run-*(action: send,x-workerrequired) — scheduled job dispatchers. Cron-triggered publishers that query DB and fan out events. These are workers.emit-*(action: send, NOx-worker) — publishing declarations owned byon-*workers. These are NOT workers.
- Every
on-*andrun-*operation needs:action,channel,tags(one tag),messages,x-worker - Every
emit-*operation needs:action,channel,tags(one tag),messages— but NOx-worker - Event-topic receivers (
on-*) need:x-subscriptionwithname(MUST equal the operation file stem). Filters are derived from themessages:list — DO NOT author afilterfield. AND-merge tenant/channel scoping viax-subscription.requiredHeaders(e.g.,{ tenantId: '<guid>' }or{ channel: email }). Usex-subscription.filterOverrideonly as a justified escape hatch withdescription≥ 40 chars. on-*workers that publish events need: (1) a separateemit-*operation referencing the emitted message, AND (2)x-emitson theon-*operation listing the events in{Entity}.{Action}format (two-segment label — tenancy lives in envelope ApplicationProperties)run-*scheduled jobs publish directly via their channel — they do NOT useemit-*,x-emits, orx-subscriptionx-workerfields:idempotent,inboxDedup(defaulttrue),concurrency,timeout— NOtype,handlerName, ornamespace
Root asyncapi.yaml:
- Add new channels and operations under the correct domain section
- Add new tags to
info.tagsif this is a new domain
OpenAPI operations (x-emits):
- For events triggered by API operations, update the triggering OpenAPI operation file
- Add
x-emitswith the event's{Entity}.{Action}label (must match the message'sx-label) - This creates the bidirectional link — the AsyncAPI side has NO reverse pointer; the validator computes it
- Cross-spec validation will fail if the OpenAPI
x-emits.eventhas no matching AsyncAPI message
Step 4: Add recommended extensions
For each message, consider:
x-partition-keyif the message requires ordered delivery (events for the same aggregate that must be applied in sequence)
For each operation, add:
x-observabilitywith at leastcriticalitychosen fromlow | medium | high | critical(legacynormalis rejected — migrate tomedium); addsla,metrics,alertOnDlqas appropriate
For each entity field carrying PII, sensitive, or encrypted data, declare:
x-classification: [pii | sensitive | encrypted]on the property schema (Vendor_Extensions §1.5). This drives snapshot inclusion review (x-snapshot-pii-acknowledgedjustification required) and AI access shaping.
For each event message, infer the action class from the suffix (handbook §2.2) and shape the payload accordingly:
*Created/*Updated/*Deleted— payload =<aggregateId>+ the appropriatebefore/aftersnapshot$refs- State-transition (anything else) — payload =
<aggregateId>+before+after(+ optionalcontext) ANDx-trigger-whenpredicate overBefore.*/After.*snapshot fields
Snapshots live in domains/{domain}/events/{Entity}Snapshot.yaml — one per event-emitting entity, reused across all of its events. See .specfuse/authoring/samples/message-samples.yaml §1 for canonical examples of every action class.
Step 5: Validate
Run the full async validation:
./scripts/specfuse/validate-async-structure.sh
./scripts/specfuse/validate-async-spectral.shFix any errors before presenting the result.
Step 6: Create or update flow documentation
This step is MANDATORY — do not skip it.
- Check if a flow doc exists in
api/docs/flows/{domain}/that covers this process - If yes: update it to reflect the new/changed operations and messages
- If no: create a new flow doc following the template in
api/docs/flows/README.md
The flow doc must include:
- Mermaid sequence diagram showing the complete message flow
- Messages and Operations tables
- Error handling at each step
- Monitoring section based on x-observability values
Checklist (verify before finishing)
- Confirmed the design fits the v2 pub-sub architecture — no commands, no sagas, no point-to-point queues
- Scheduled jobs are thin fan-out dispatchers (or the narrow exception is explicitly justified)
- Every message has
x-message-category(eventorscheduledJob),x-label,x-version -
x-label.entitymatches a schema withx-entityin OpenAPI - Every
on-*andrun-*operation hastags,x-worker(slim form),action,channel - Every
emit-*operation hastags,action,channel,messages— but NOx-worker - Every operation has
x-observabilitywithcriticalityfromlow | medium | high | critical(NOT legacynormal) - Event topic receivers have
x-subscriptionwithname(= operation file stem); filters are derived frommessages:(no authoredfilterfield); userequiredHeadersfor tenant/channel scoping when needed -
on-*workers that publish events have a separateemit-*send operation ANDx-emitson theon-*operation -
run-*scheduled jobs useaction: sendwithx-worker— they publish directly, noemit-*orx-subscriptionneeded -
emit-*operations have NOx-worker(they are declarations, not workers) - Message payloads use
$refto existing OpenAPI models for shared types (enums, value objects); event payloads use snapshot$refs intoevents/{Entity}Snapshot.yaml(never raw entity types) - Action class shape is correct per name suffix:
*Created(after) /*Updated(before+after) /*Deleted(before) / state-transition (before+after, REQUIRESx-trigger-when) - State-transition events have
x-trigger-whenpredicate;*Created/*Updated/*Deleteddo NOT - Snapshots include scalar columns + owned VOs only (no navigation, no child collections); declare
x-snapshot-pii-acknowledgedper-field if any source entity field isx-classification: [pii \| sensitive] - Labels are exactly two segments (
{Entity}.{Action}); tenancy lives in envelope ApplicationProperties - Files follow naming conventions (PascalCase messages and snapshots, kebab-case operations/channels)
- Root
asyncapi.yamlupdated with new channels and operations - Flow documentation created/updated in
api/docs/flows/{domain}/ - OpenAPI operations updated with
x-emitsfor events they trigger - No removed/forbidden extensions used (
x-source-aggregate,x-target-aggregate,x-event,x-command,x-saga,x-dispatches,x-worker.type/handlerName/namespace,x-action-class,x-pii/x-sensitivebooleans, three-segment labels, authoredx-subscription.filter) - Validation passes with 0 errors