Skip to content

Action

A typed command. Something a user — or another system — wants done. The verb of your domain.

Shape

ts
import { defineAction } from "@nwire/forge";
import { z } from "zod";

export const submitAnswer = defineAction({
  name: "submissions.submit-answer",   // routing key — unique across the app
  description: "Avi taps Submit on a Hebrew Letters exercise.",
  persona: "Avi (9, beginner)",        // Studio-aware: who triggers this
  journeyStep: "J3-submit",            // Studio-aware: journey position
  capability: "submit-answer",         // Studio-aware: capability tag
  slo: { p95LatencyMs: 200, successRate: 0.999 },
  tags: ["student-facing", "write-path"],
  policy: "student-self",              // optional authz tag

  input: z.object({
    submissionId: z.string(),
    studentId: z.string(),
    exerciseId: z.string(),
    answer: z.string(),
  }),

  emits: [AnswerWasSubmitted],         // declared intent — Studio draws edges

  retry: { max: 3, backoff: "exponential", baseDelayMs: 100 },

  // Inline handler — the common case where contract + handler ship together.
  // Omit it for a schema-only contract you dispatch toward (cross-module).
  handler: async ({ input }) =>
    AnswerWasSubmitted({ ...input, submittedAt: new Date().toISOString() }),
});

Two registration shapes

Inline (common case — handler ships next to the contract):

ts
defineAction({ ..., handler: async (ctx) => Event(...) })

Schema-only contract (when the contract is referenced from another module — e.g., mastery dispatches submissions.grade-submission). The caller imports a defineAction with no handler; the owning module declares the same action with its handler:

ts
// submissions-contract/grade-submission.action.ts — the typed contract
export const gradeSubmission = defineAction({ name, schema, emits })

// submissions/grade-submission.action.ts — same name, with the handler
export const gradeSubmission = defineAction({
  name, schema, emits,
  handler: async (ctx) => SubmissionGraded({ ... }),
})

An action IS a handler — registering the action registers its handler. The caller dispatches toward the contract via ctx.request(...).

What the handler can do

ts
defineAction({
  name: "submissions.submit-answer",
  input: SubmitAnswerInput,
  handler: async (ctx) => {
    const { input } = ctx;
    ctx.envelope.tenant    // tenant id (multi-tenant scope)
    ctx.envelope.userId    // authenticated user id
    ctx.logger.info(...)   // envelope-scoped logger

    // Read a projection (no mutation)
    const history = await ctx.query(submissionsByStudent, { studentId: input.studentId })

    // Dispatch another action — derived envelope, full causation chain
    const grade = await ctx.request(scoreSubmission, { ... })  // ask + await the result
    await ctx.send(notifyStudent, { ... })                     // fire-and-forget → MessageRef

    // Announce facts as side-effects
    await ctx.emit(DraftWasScored, { ... })          // local — this context only
    await ctx.publish(SubmissionWasGraded, { ... })  // cross-context — event must be .public()

    // Load + use an actor (invariant enforcement)
    const submission = await ctx.actor(Submission, input.submissionId)
    return submission.flag(input.reason)             // method returns event

    // External boundary
    const charge = await ctx.externalCall(chargeStripe, { ... })

    return AnswerWasSubmitted({ ... })               // event the actor folds
  },
})

Two verbs announce facts, and they carry different reach. ctx.emit is local fanout — when listeners in this bounded context fire, and the event never crosses the boundary. ctx.publish announces a cross-context fact: the same local fold, then the outbound bus/sink leg. publish accepts only events marked .public() and throws otherwise, so an internal event can't leak by accident. See events and listeners.

Actions carry the same marker: submitAnswer.public() returns a clone marked as part of the app's contract, which a composed neighbour may dispatch with ctx.send. Everything unmarked stays internal to the app.

What the handler MUST NOT do:

  • ❌ touch actor state directly — return an event; the actor's assign folds it
  • ❌ throw on validation errors that the schema should catch — let zod handle it
  • ❌ side-effect outside the envelope (uncontrolled fetch / db write) — go through ctx.externalCall so Studio sees it

Studio-aware metadata

The optional fields drive Studio's UX. None affect runtime behavior — they're intent declarations the framework reads at design time and the runtime scores observed reality against:

FieldWhat Studio does with it
personaGroups actions by triggering human; renders persona journey strips
journeyStepLays out the EventStorming canvas in causal time order
capabilityFilters / groups in the actions list
slo: { p95LatencyMs, successRate }Renders SLO scorecard with observed latency / success rate
tagsFree-form filtering
emits: [Event]Draws Command → Event edges on the EventStorm canvas

See also

Convention for the next sections: each Concept page links to its Primitives reference for the full API, and to adjacent concepts.

MIT licensed.