A Resource key is not a label attached to a process object. It is the durable instruction Motive can commit, compare, replay, and resolve again after the original runner is gone.
Choose that key with the same care as any other application identity. It should remain meaningful across retries, recovery, provider processes, and deployment boundaries. If future work must reopen the exact same bytes, a mutable filename is not enough—use a versioned id or content address.
One Resource owns both Schemas
Resource.make names the durable and process-local sides together:
export const SourceDocument = Resource.make("SourceDocument", {
key: Schema.NonEmptyString,
resource: Schema.Struct({
title: Schema.String,
body: Schema.String,
}),
});
key owns the address that may enter durable history. resource owns the value a handler may use
inside one process-local scope. Keeping them on one definition prevents a stringly “document id”
from drifting away from the loader, value Schema, and provider authority that give it meaning.
The example below exposes both views. After Index doc-42, compare the durable-key cell with the
handler-field cell: the chart commits doc-42; the handler receives a decoded SourceDocument
value; the final outcome retains only the eight-word count.
Give external material a durable address
A Resource key is a recovery instruction.
The chart commits a stable document key. The Activity handler receives the document value. Resource.Input preserves both views without asking application code to cast or reconstruct either one.
1 · Define identity and acquired value separately
One Resource owns two different Schemas.
SourceDocument.key describes the stable identity that may enter durable history. SourceDocument.resource describes the process-local value an Activity handler may receive. Resource.Input keeps both views attached to the same definition.
import { Activity, Resource, State, Statechart } from "@motive/motive";
import { Schema } from "effect";
export const SourceDocument = Resource.make("SourceDocument", {
key: Schema.NonEmptyString,
resource: Schema.Struct({
title: Schema.String,
body: Schema.String,
}),
});
export const IndexDocument = Activity.make("IndexDocument", {
input: Schema.Struct({ document: SourceDocument.Input }),
success: Schema.Finite,
});
class DocumentIndex extends State.Atomic<DocumentIndex>()("DocumentIndex") {}
export const DocumentIndexChart = Statechart.make(DocumentIndex);
2 · Author a claim at the Activity boundary
Resource.acquire constructs a durable claim, not the document.
The authored input contains SourceDocument.acquire("doc-42"). That call performs no I/O. It records which Resource, key, field path, and provider occurrence the runner must resolve; the handler later receives the acquired SourceDocument value at input.document.
import { Activity, Resource, State, Statechart, States, Transitions } from "@motive/motive";
import { Schema } from "effect";
export class StartIndex extends Schema.TaggedClass<StartIndex>()("StartIndex", {}) {}
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 IndexDocument = Activity.make("IndexDocument", {
input: Schema.Struct({ document: SourceDocument.Input }),
success: Schema.Finite,
});
class DocumentIndex extends State.Compound<DocumentIndex>()("DocumentIndex") {
static states = States.make(() => [Idle, Indexing, Indexed]);
}
class Idle extends State.Atomic<Idle>()("Idle") {
static transitions = Transitions.make(this, ({ on }) => [
on(StartIndex, Indexing),
]);
}
class Indexing extends State.Atomic<Indexing>()("Indexing") {
static transitions = Transitions.make(this, ({ invoke, on, provide }) => [
provide(Documents),
invoke(IndexDocument, () => ({
document: SourceDocument.acquire("doc-42"),
})),
on(IndexDocument.Done, Indexed, ({ event }) => event.value),
]);
}
class Indexed extends State.Done<Indexed>()("Indexed", Schema.Finite) {}
export const DocumentIndexChart = Statechart.make(DocumentIndex);
Resource.Input is a Schema placement
SourceDocument.Input belongs directly inside the Activity input Schema:
export const IndexDocument = Activity.make("IndexDocument", {
input: Schema.Struct({ document: SourceDocument.Input }),
success: Schema.Finite,
});
It is not Schema.Unknown, a service lookup, or a convention attached after decoding. The
placement preserves the exact Resource definition and field path while projecting the right value
at each boundary: authoring supplies a durable claim; the running handler sees
SourceDocument.resource.Type at input.document.
Because the placement is part of the Schema tree, it composes through nested structs, arrays, and other supported Schema structure without losing which field owns the claim. That path later gives provider diagnostics and grant identity an exact coordinate.
Resource.acquire is inert authoring data
invoke(IndexDocument, () => ({
document: SourceDocument.acquire("doc-42"),
}));
SourceDocument.acquire("doc-42") validates and constructs a Resource claim. It does not open
the document, contact a provider, or run an Effect while the transition is selected. The engine can
therefore commit and replay the same authored input deterministically.
Only when the Activity attempt starts does the runner resolve that claim through the dominating provider occurrence. The immutable attempt record pins the exact provider authority used for that incarnation; recovery does not perform a fresh ambient lookup.
Keys should identify recoverable meaning
A good key answers “what must another runner ask for?” without smuggling in process-local access:
- Prefer stable domain ids, version ids, object-store keys, or content hashes.
- Avoid open handles, clients, callbacks, bearer tokens, and expiring signed URLs.
- Treat the key itself as sensitive when it reveals a tenant, filename, or secret-bearing locator.
- Include version identity when rereading a mutable key could change the meaning of replayed work.
- Let the key Schema reject malformed or ambiguous identities before a claim is admitted.
Opaque ids are often safer, but opacity is not the goal. The goal is a canonical, Schema-owned address whose meaning the provider can reproduce.
Input placements belong on Activity input
Resource.Input is an acquisition boundary, so Motive admits it only inside an Activity input
contract. Resource.Output belongs on Activity success and represents output material moving in
the opposite direction. Putting either placement on the wrong side is an authoring refusal, not a
runtime cast.
That structural rule keeps the Activity signature honest: inputs are values the attempt must acquire before it can run; outputs are values a provider must turn into durable keys before success can publish.
Test the durable projection
- Encode the Activity input and verify durable data contains the Resource key, never the acquired document value.
- Start the attempt and verify the handler receives the decoded
resourceshape at the same field. - Recover the attempt on a new runner and verify the same key is acquired again.
- Reject a malformed key before provider acquisition begins.
- Change a mutable external document and verify a versioned key still identifies the intended content.
Next, Scoped acquisition and release follows the claim across the runtime boundary: provider selection, acquisition failure, process-local cleanup, and durable grant closure.