Guide

Scoped Acquisition and Release

Close process-local Resource values and durable attempt grants at their distinct owning boundaries.

Opening a file, checking out a database connection, or constructing a model client must not turn an Activity into manual try/finally bookkeeping. The acquired value belongs to one attempt. Motive opens it before the handler runs and closes it however that attempt stops—success, modeled failure, defect, retry, or interruption.

That process-local scope is only half the lifecycle. The engine also records a logical Resource grant so recovery can prove which attempt held which authority. Closing the local value and closing that durable grant are separate operations because they have different data, timing, and delivery guarantees.

One attempt owns two closures

Process-local scope

acquire → handler → release

Lives on one runner. May hold the acquired value. Closes immediately with the Activity's full Exit.

Durable grant

attempt admitted → releaseGrant

Lives in engine history. Never contains the acquired value. Closes from a committed disposition that may be redelivered.

Both lifetimes use the same exact grant identity, but they are not duplicate cleanup hooks. release relinquishes the concrete process object. releaseGrant tells external grant authority that the engine has durably decided why that attempt ended.

Run the first frame through completion. In the second, start the read and interrupt it while the Activity is still running. The order stays acquire → activity → release → releaseGrant; only the Exit and durable release reason change.

Close every lifetime at its owning boundary

Acquired values live exactly as long as their attempt.

A provider opens one process-local value for one Activity attempt. Follow normal completion and interruption to see immediate scoped cleanup and durable grant closure happen as separate operations.

1 · Complete one Activity attempt

Completion closes two different lifetimes.

acquire resolves doc-42 into one process-local SourceDocument value. When ReadDocument succeeds, release receives that value and the Activity Exit immediately; after the transition commits, releaseGrant closes the durable grant with reason Succeeded and a commit coordinate.

import {
  Activity,
  Resource,
  State,
  Statechart,
  StatechartEngine,
  States,
  Transitions,
} from "@motive/motive";
import { Effect, Exit, Layer, Schema } from "effect";

export class StartRead extends Schema.TaggedClass<StartRead>()("StartRead", {}) {}

export const SourceDocument = Resource.make("SourceDocument", {
  key: Schema.NonEmptyString,
  resource: Schema.Struct({ title: Schema.String, body: Schema.String }),
});
export const Documents = SourceDocument.as("Documents");

export const ReadDocument = Activity.make("ReadDocument", {
  input: Schema.Struct({ document: SourceDocument.Input }),
  success: Schema.Finite,
});

class DocumentReader extends State.Compound<DocumentReader>()("DocumentReader") {
  static states = States.make(() => [Idle, Reading, Complete]);
}

class Idle extends State.Atomic<Idle>()("Idle") {
  static transitions = Transitions.make(this, ({ on }) => [
    on(StartRead, Reading),
  ]);
}

class Reading extends State.Atomic<Reading>()("Reading") {
  static transitions = Transitions.make(this, ({ invoke, on, provide }) => [
    provide(Documents),
    invoke(ReadDocument, () => ({ document: SourceDocument.acquire("doc-42") })),
    on(ReadDocument.Done, Complete, ({ event }) => event.value),
  ]);
}

class Complete extends State.Done<Complete>()("Complete", Schema.Finite) {}

export const DocumentReaderChart = Statechart.make(DocumentReader);

export const makeLive = (record: (step: string) => void) =>
  DocumentReaderChart.toLayer({
    activities: {
      ReadDocument: ({ input }) =>
        Effect.sync(() => record("activity")).pipe(
          Effect.andThen(Effect.sleep("650 millis")),
          Effect.as(input.document.body.trim().split(/\s+/u).length),
        ),
    },
  }).pipe(
    Layer.provide(
      SourceDocument.toLayer({
        acquire: ({ grant, key }) =>
          Effect.sync(() => {
            record(`acquire:${key}:${String(grant.attempt)}`);
            return {
              title: "Incident response guide",
              body: "Statecharts keep external material behind a typed boundary",
            };
          }),
        release: ({ exit }) =>
          Effect.sync(() => record(`release:${Exit.isSuccess(exit) ? "Success" : "Failure"}`)),
        releaseGrant: ({ commit, reason }) =>
          Effect.sync(() => record(`grant:${reason}:${String(commit.seq)}`)),
      }),
    ),
    Layer.provideMerge(StatechartEngine.layerMemory),
  );

2 · Leave while the Activity is running

Interruption closes them too.

InterruptRead exits Reading while ReadDocument is still running. The Activity fiber is interrupted, release sees its failed Exit and closes the process-local value, then the committed transition closes the durable grant as Interrupted. Cleanup does not depend on a happy-path Done event.

import {
  Activity,
  Resource,
  State,
  Statechart,
  StatechartEngine,
  States,
  Transitions,
} from "@motive/motive";
import { Effect, Exit, Layer, Schema } from "effect";

export class StartRead extends Schema.TaggedClass<StartRead>()("StartRead", {}) {}
export class InterruptRead extends Schema.TaggedClass<InterruptRead>()("InterruptRead", {}) {}

export const SourceDocument = Resource.make("SourceDocument", {
  key: Schema.NonEmptyString,
  resource: Schema.Struct({ title: Schema.String, body: Schema.String }),
});
export const Documents = SourceDocument.as("Documents");

export const ReadDocument = Activity.make("ReadDocument", {
  input: Schema.Struct({ document: SourceDocument.Input }),
  success: Schema.Finite,
});

class DocumentReader extends State.Compound<DocumentReader>()("DocumentReader") {
  static states = States.make(() => [Idle, Reading, Interrupted]);
}

class Idle extends State.Atomic<Idle>()("Idle") {
  static transitions = Transitions.make(this, ({ on }) => [
    on(StartRead, Reading),
  ]);
}

class Reading extends State.Atomic<Reading>()("Reading") {
  static transitions = Transitions.make(this, ({ invoke, on, provide }) => [
    provide(Documents),
    invoke(ReadDocument, () => ({ document: SourceDocument.acquire("doc-42") })),
    on(ReadDocument.Done),
    on(InterruptRead, Interrupted),
  ]);
}

class Interrupted extends State.Atomic<Interrupted>()("Interrupted") {}

export const DocumentReaderChart = Statechart.make(DocumentReader);

export const makeLive = (record: (step: string) => void) =>
  DocumentReaderChart.toLayer({
    activities: {
      ReadDocument: () => Effect.sync(() => record("activity")).pipe(Effect.andThen(Effect.never)),
    },
  }).pipe(
    Layer.provide(
      SourceDocument.toLayer({
        acquire: ({ grant, key }) =>
          Effect.sync(() => {
            record(`acquire:${key}:${String(grant.attempt)}`);
            return {
              title: "Incident response guide",
              body: "Statecharts keep external material behind a typed boundary",
            };
          }),
        release: ({ exit }) =>
          Effect.sync(() => record(`release:${Exit.isSuccess(exit) ? "Success" : "Interrupt"}`)),
        releaseGrant: ({ commit, reason }) =>
          Effect.sync(() => record(`grant:${reason}:${String(commit.seq)}`)),
      }),
    ),
    Layer.provideMerge(StatechartEngine.layerMemory),
  );

acquire runs before the handler

For every Activity attempt, the runner resolves all Resource.Input claims through their pinned provider occurrence before calling the Activity implementation. Only successfully acquired, Schema-decoded values reach the handler.

If acquisition fails, the handler never starts and release does not run for a value that was never acquired. The acquisition error remains distinct from handler failure; declare an acquisitionError Schema when the provider can fail in a modeled way.

release closes the concrete value now

release: ({ resource, exit, grant }) =>
  closeDocument(resource, exit).pipe(Effect.annotateLogs("grant", grant.id));

release receives the acquired resource, the exact grant, and the Activity's complete Exit. That Exit distinguishes success from typed failure, defect, and interruption, so cleanup can make the same decision it would inside an Effect-scoped finalizer.

The callback cannot add a modeled Activity error; its error channel is never. Cleanup must be designed as reliable local finalization. If the process disappears before it runs, another runner cannot reconstruct the vanished handle merely to close it—which is exactly why durable closure is separate.

releaseGrant enacts committed closure

releaseGrant: ({ grant, reason, commit }) => grantAuthority.close(grant, reason, commit);

releaseGrant receives no process-local Resource value. It receives the grant identity, a durable reason such as Succeeded, Failed, Interrupted, Cancelled, or Superseded, and the commit coordinate that authorized the closure.

The engine invokes it only after the owning macrostep commits. If the runner dies after commit but before enactment is acknowledged, the closure may be delivered again. Implement it as an idempotent durable operation keyed by the grant and commit—not as an in-memory destructor.

Retries create fresh attempt scopes

A retry does not reuse the failed attempt's acquired value. Motive closes that attempt's local scope, commits its grant as Failed, then starts the next attempt with a fresh exact grant and a fresh acquisition. The Activity arming remains related, while attempt identity and any acquired process object do not.

This boundary prevents a poisoned connection, expired credential, or partially consumed stream from leaking across retries. If reuse is desirable, put pooling behind the provider; the Activity still receives a clean per-attempt acquisition contract.

Test both failure directions

  1. Complete an attempt and verify release sees a successful Exit before releaseGrant receives Succeeded.
  2. Interrupt an active owner and verify release sees interruption while the durable grant closes as Interrupted.
  3. Fail acquisition and verify neither the handler nor release runs for the absent value.
  4. Fail or defect the handler and verify the acquired value is still released with the complete failure Cause.
  5. Redeliver the same committed grant closure and verify the external authority remains correct.

Next, Durable publication with Resource.Output reverses the boundary: an Activity produces process-local material, a provider prepares its durable key, and Motive withholds success until the committed publication is confirmed.