Guide

Confidential & External Data

Keep secrets, handles, and large external documents out of retained chart state.

Applications routinely touch values that should not enter retained history: credentials, file handles, database sessions, model clients, decrypted payloads, and documents too large to make every snapshot carry them forever.

The problem is not that these values are untyped. A perfectly valid Schema.String can still be a secret, and a well-formed document can still be the wrong persistence unit. The question is which fact future execution must recover.

Separate durable identity from external material

For an external value, that recoverable fact is usually a stable key. The chart can durably remember draft.md; a provider can interpret that key on whichever runner handles the next attempt. The file contents remain process-local, where ordinary scoping and confidentiality controls still apply.

The example below makes the distinction concrete. CountWords needs the document contents, but the chart commits only a typed Resource claim. Run the final frame and watch the provider acquire the file, give it to one Activity attempt, release it, and close the durable grant—without ever putting the contents into chart state.

Keep material outside retained history

Keep the reference durable and the material local.

A file may be essential to the work without belonging in chart state. The chart retains draft.md; the provider opens its contents for the exact Activity attempt and releases them afterward.

1 · Declare one keyed Resource

Resource.Input marks a value that must be acquired.

DraftFile owns both its durable key Schema and its process-local resource Schema. CountWords asks for DraftFile.Input, so the Activity contract preserves that placement instead of weakening it to an ordinary string or unknown value.

import { Activity, Resource, State, Statechart, States } from "@motive/motive";
import { Schema } from "effect";

export const DraftFile = Resource.make("DraftFile", {
  key: Schema.String,
  resource: Schema.Struct({ contents: Schema.String }),
});

export const CountWords = Activity.make("CountWords", {
  input: Schema.Struct({ file: DraftFile.Input }),
  success: Schema.Finite,
});

export class WordCount extends State.Compound<WordCount>()("WordCount") {
  static states = States.make(() => [Idle]);
}

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

export const WordCountChart = Statechart.make(WordCount);

2 · Mount one provider and request one key

The provider must dominate the Activity that claims it.

provide(DraftFiles) mounts an authored provider occurrence in Counting. DraftFile.acquire("draft.md") records a durable claim in the Activity input; the runner later resolves that claim through the dominating provider before CountWords begins.

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

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

export const DraftFile = Resource.make("DraftFile", {
  key: Schema.String,
  resource: Schema.Struct({ contents: Schema.String }),
});
export const DraftFiles = DraftFile.as("DraftFiles");

export const CountWords = Activity.make("CountWords", {
  input: Schema.Struct({ file: DraftFile.Input }),
  success: Schema.Finite,
});

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

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

class Counting extends State.Atomic<Counting>()("Counting") {
  static transitions = Transitions.make(this, ({ invoke, on, provide }) => [
    provide(DraftFiles),
    invoke(CountWords, () => ({ file: DraftFile.acquire("draft.md") })),
    on(CountWords.Done, Complete, ({ event }) => event.value),
  ]);
}

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

export const WordCountChart = Statechart.make(WordCount);

3 · Bind the process-local lifecycle

Acquisition is scoped; grant closure is durable.

DraftFile.of supplies the runtime binding. acquire yields the process-local value, release closes that value's Activity scope, and releaseGrant records durable closure after the outcome commit. The confidential value itself never becomes chart state.

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

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

export const DraftFile = Resource.make("DraftFile", {
  key: Schema.String,
  resource: Schema.Struct({ contents: Schema.String }),
});
export const DraftFiles = DraftFile.as("DraftFiles");

export const CountWords = Activity.make("CountWords", {
  input: Schema.Struct({ file: DraftFile.Input }),
  success: Schema.Finite,
});

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

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

class Counting extends State.Atomic<Counting>()("Counting") {
  static transitions = Transitions.make(this, ({ invoke, on, provide }) => [
    provide(DraftFiles),
    invoke(CountWords, () => ({ file: DraftFile.acquire("draft.md") })),
    on(CountWords.Done, Complete, ({ event }) => event.value),
  ]);
}

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

export const WordCountChart = Statechart.make(WordCount);

export const makeLive = (record: (step: string) => void) =>
  WordCountChart.toLayer({
    activities: {
      CountWords: ({ input }) =>
        Effect.sync(() => record("activity")).pipe(
          Effect.andThen(Effect.sleep("500 millis")),
          Effect.as(input.file.contents.trim().split(/\s+/u).length),
        ),
    },
  }).pipe(
    Layer.provide(
      DraftFile.toLayer({
        acquire: ({ key }) =>
          Effect.sync(() => {
            record(`acquire:${key}`);
            return { contents: "Statecharts make long work explicit" };
          }),
        release: () => Effect.sync(() => record("release")),
        releaseGrant: () => Effect.sync(() => record("releaseGrant")),
      }),
    ),
    Layer.provideMerge(StatechartEngine.layerMemory),
  );

One definition owns both sides of the boundary

DraftFile begins with a durable key Schema and a process-local resource Schema. Its generated Input placement connects those shapes without pretending they are interchangeable: authored Activity input carries a claim, while the running handler receives the acquired file value.

This is why Resource.Input belongs inside the Activity Schema. It lets authoring, durable encoding, provider resolution, and handler projection all agree about the same field.

Acquisition needs an authored provider

provide(DraftFiles) is part of the chart topology. While Counting is active, that provider dominates the CountWords invocation beside it. DraftFile.acquire("draft.md") selects a key; it does not open the file during transition selection.

The claim is durable enough to survive runner loss. A runner resolves it only when it starts the Activity, using the exact provider occurrence selected by authored topology. The next lesson will make that topology and recursive provider closure explicit.

Scoped release and durable closure are different

The live binding exposes both halves of cleanup. release receives the process-local Resource value and the Activity Exit, so it closes the acquired value's scope whether the handler succeeds, fails, or is interrupted. releaseGrant receives no confidential value; it durably closes the logical grant after termination commits and may be redelivered.

The readout shows the successful order: acquire the keyed value, run the Activity, release its scope, then close the durable grant. Only the key, outcome, and lifecycle facts belong in durable state—not the file contents.

Confidentiality still needs a real policy

Keeping material out of chart state removes one major persistence surface; it does not make the material or its key harmless. Providers still need access control, secret-safe logs, bounded caches, and appropriate transport and storage protections. If a filename itself reveals sensitive information, use an opaque durable identifier instead.

Nor is every large value automatically external. If the exact document is the business fact the chart must reproduce, store it in an appropriate durable system and retain a versioned or content-addressed key. The Resource boundary makes that ownership explicit; it does not decide the product's retention policy for you.

Test the boundary

  1. Run the final frame and watch Counting receive the acquired file before Complete publishes.
  2. Make the Activity fail and verify release still receives a failure Exit.
  3. Interrupt while the Activity is running and verify both cleanup hooks still occur exactly once.
  4. Inspect the retained snapshot and verify it contains the key and outcome, never the file contents.

Next, the category narrows in on the durable half of this contract: choosing keys and placing Resource.Input claims without weakening their Resource identity.