Skip to content

Actors

An actor is a thing with identity that enforces a rule: an order that can't ship twice, a seat that can't be double-booked, a submission that moves through review. There's one instance per id, it owns its own state, and it's the only thing allowed to change that state — a handler asks the actor to do something; it never writes the actor's data directly.

This is the counterpart to a workflow. A workflow is a process that runs to completion; an actor is a thing that persists and guards an invariant. Reach for an actor when "this can't happen twice" or "only X is allowed when in state Y" has to hold no matter who asks.

Shape

The actor borrows its data shape, key, and lifecycle states from a defineSchema and fills in transitions per state:

ts
import { defineActor, defineSchema } from "@nwire/forge";
import { z } from "zod";

export const SubmissionData = defineSchema({
  name: "submission",
  key: "submissionId",
  fields: {
    submissionId: z.string(),
    studentId:    z.string(),
    verdict:      z.string().optional(),
  },
  states: {
    submitted:      { initial: true },
    "under-review": {},
    graded:         { final: true },
  },
});

export const Submission = defineActor(
  "submission",
  ({ data, validate, recordThat, states, when, after }) => {
    const { submitted, underReview, graded } = states;   // callable state bodies

    // Reactions, scoped per state — return a state to transition, nothing to stay.
    submitted(() => {
      // assign folds the actor's data — pure, no side effects
      when(AnswerWasSubmitted,         (e, { assign }) => { assign({ ...e }); });        // stay
      when(SubmissionWasAutoGraded,    (e, { assign }) => { assign({ ...e }); return graded; });
      when(SubmissionWasFlaggedForReview, () => underReview);
    });

    underReview(() => {
      when(SubmissionWasManuallyGraded, () => graded);
      // Schedule a deferred action while in this state. Cancelled
      // automatically on state transition.
      after("review-reminder", "3d", "submissions.send-review-reminder");
    });
    // `graded` is final in the schema — it declares no reactions.

    // Pure invariant-enforcing method, callable via ctx.actor(Submission, id).
    const flag = (reason: string) => {
      validate({ reason }, [() => data.verdict === undefined || "already graded"]);
      recordThat(SubmissionFlaggedForReview({ submissionId: data.submissionId, reason }));
    };

    return { flag };
  },
  {
    schema: SubmissionData,

    // Studio-aware: when in this state for > threshold, surface as "stuck"
    stuckThresholds: {
      "under-review": 48 * 60 * 60 * 1000,
    },

    // Hard SLA — Studio raises an alert and routes to escalateTo
    slas: {
      "under-review": {
        maxDurationMs: 7 * 24 * 60 * 60 * 1000,
        escalateTo: "curriculum-lead",
      },
    },
  },
);

Usage from a handler

ts
const flagSubmission = defineAction({
  name: "submissions.flag",
  schema: FlagSubmissionInput,
  handler: async (ctx) => {
    const { input } = ctx;
    const submission = await ctx.actor(Submission, input.submissionId);
    // submission.state, submission.stateName, submission.key — plus methods
    return submission.flag(input.reason);   // throws if invariant violated
  },
});

What goes in assign vs methods

  • assign(patch) inside a when — pure data fold. Called during the runtime's state-transition phase. Cannot read or call anything; read the live data to compute the next value.
  • methods (defined in the closure, returned as a plain object) — pure invariant-enforcers called from handlers via ctx.actor(Actor, id). They validate invariants and recordThat an event (or throw). Used for "only X can do Y when in state Z" rules.

Studio-aware metadata

FieldWhat Studio does with it
stuckThresholds: Record<state, ms>Surfaces actor instances exceeding the threshold in the stuck-state inbox
slas: Record<state, { maxDurationMs, escalateTo? }>Hard SLA alerts with escalation routing

The write-path rule

An actor is one of two ways to write state, and they don't mix: state that guards an invariant goes through an actor (locked, version-checked); plain data goes straight to the database. See write paths for why a single operation picks one, never both.

See also

MIT licensed.