Capacity decides when a Resource claim may begin. Settlement decides what survives after the Activity attempt ends.
Those decisions run on two different clocks. A process-local value must be cleaned up promptly; a durable key may remain usable, be consumed after success, or later be erased by another explicit policy. Treating both as “cleanup” loses the distinction Motive is built to preserve.
One attempt closes two lifetimes
Every acquired Resource has a value lifetime and a grant lifetime. The value belongs to the running Activity fiber. The grant belongs to the durable Activity attempt.
acquireopen value scopeActivitysucceed, fail, or interruptrelease(value, exit)close value scope
releaseGrantclose attempt identitysettleenact a committed disposition, if any
release receives the acquired value and the complete Activity Exit, so it can close a socket,
return a pool handle, or zero process-local secret material. It runs for success, modeled failure,
defect, interruption, cancellation, and supersession.
releaseGrant cannot see that process-local value. It receives the durable key, provider and
owner addresses, exact grant identity, release reason, and commit coordinate. It closes the
logical grant after termination commits and may be redelivered after runner loss.
Retain is a policy, not a settlement callback
A Resource retains its durable key by default. Retain means successful use produces no
settlement operation at all:
success: "Retain"Release the value and close the grant. Emit no durable disposition.
Resource.consumeOnSuccessCommit one Consume disposition only with successful Activity settlement.
Retention is the safe default for credentials, leases, document handles, and evidence that one reader does not own. Another Activity can later acquire the same key under a fresh grant.
Resource.consumeOnSuccess moves the decision onto the Resource definition. Every Input
placement of that definition now shares the same success lifecycle, and toLayer requires the
provider to implement the exact settle contract.
Follow the same voucher through retained success, consumed success, and modeled failure:
Committed settlement
One outcome closes two lifetimes.
Compare retained success, consumed success, and modeled failure without collapsing scoped value cleanup into durable key settlement.
1 · End the process-local lifetime
Retain closes the grant without settling the key.
The default Resource policy is Retain. RedeemVoucher acquires and releases its process-local credit value, then the committed outcome closes the logical grant as Succeeded. No settlement operation is emitted for voucher-1.
import { Activity, Resource, State, Statechart, States, Transitions } from "@motive/motive";
import { Schema } from "effect";
export class RedemptionFailed extends Schema.TaggedError<RedemptionFailed>()(
"RedemptionFailed",
{},
) {}
export class Begin extends Schema.TaggedClass<Begin>()("Begin", {
willFail: Schema.Boolean,
}) {}
export const Voucher = Resource.make("Voucher", {
key: Schema.String,
resource: Schema.Struct({ credit: Schema.Finite }),
});
const Vouchers = Voucher.as("Vouchers");
export const RedeemVoucher = Activity.make("RedeemVoucher", {
input: Schema.Struct({ voucher: Voucher.Input, willFail: Schema.Boolean }),
success: Schema.String,
error: RedemptionFailed,
});
export class Redemption extends State.Compound<Redemption>()("Redemption") {
static states = States.make(() => [Idle, Redeeming, Complete, Failed]);
}
class Idle extends State.Atomic<Idle>()("Idle") {
static transitions = Transitions.make(this, ({ on }) => [
on(Begin, Redeeming, ({ event }) => ({ willFail: event.willFail })),
]);
}
class Redeeming extends State.Atomic<Redeeming>()("Redeeming", {
willFail: Schema.Boolean,
}) {
static transitions = Transitions.make(this, ({ invoke, on, provide }) => [
provide(Vouchers),
invoke(RedeemVoucher, ({ state }) => ({
voucher: Voucher.acquire("voucher-1"),
willFail: state.willFail,
})),
on(RedeemVoucher.Done, Complete, ({ event }) => event.value),
on(RedeemVoucher.Error, Failed, ({ event }) => event.error),
]);
}
class Complete extends State.Done<Complete>()("Complete", Schema.String) {}
class Failed extends State.Error<Failed>()(
"Failed",
Schema.Union([RedemptionFailed, Resource.Unavailable(Voucher)]),
) {}
export const RedemptionChart = Statechart.make(Redemption);
2 · Choose the durable lifecycle
consumeOnSuccess commits one Consume disposition.
Resource.consumeOnSuccess changes the Voucher definition, not one handler call. After the successful Activity outcome commits, the provider receives one idempotent Consume settlement for voucher-1. Scoped release remains a separate earlier responsibility.
import { Activity, Resource, State, Statechart, States, Transitions } from "@motive/motive";
import { Schema } from "effect";
export class RedemptionFailed extends Schema.TaggedError<RedemptionFailed>()(
"RedemptionFailed",
{},
) {}
export const Voucher = Resource.make("Voucher", {
key: Schema.String,
resource: Schema.Struct({ credit: Schema.Finite }),
}).pipe(Resource.consumeOnSuccess);
export class Begin extends Schema.TaggedClass<Begin>()("Begin", {
willFail: Schema.Boolean,
}) {}
const Vouchers = Voucher.as("Vouchers");
export const RedeemVoucher = Activity.make("RedeemVoucher", {
input: Schema.Struct({ voucher: Voucher.Input, willFail: Schema.Boolean }),
success: Schema.String,
error: RedemptionFailed,
});
export class Redemption extends State.Compound<Redemption>()("Redemption") {
static states = States.make(() => [Idle, Redeeming, Complete, Failed]);
}
class Idle extends State.Atomic<Idle>()("Idle") {
static transitions = Transitions.make(this, ({ on }) => [
on(Begin, Redeeming, ({ event }) => ({ willFail: event.willFail })),
]);
}
class Redeeming extends State.Atomic<Redeeming>()("Redeeming", {
willFail: Schema.Boolean,
}) {
static transitions = Transitions.make(this, ({ invoke, on, provide }) => [
provide(Vouchers),
invoke(RedeemVoucher, ({ state }) => ({
voucher: Voucher.acquire("voucher-1"),
willFail: state.willFail,
})),
on(RedeemVoucher.Done, Complete, ({ event }) => event.value),
on(RedeemVoucher.Error, Failed, ({ event }) => event.error),
]);
}
class Complete extends State.Done<Complete>()("Complete", Schema.String) {}
class Failed extends State.Error<Failed>()(
"Failed",
Schema.Union([RedemptionFailed, Resource.Unavailable(Voucher)]),
) {}
export const RedemptionChart = Statechart.make(Redemption);
3 · Prove the negative direction
Failure releases the value without authorizing consumption.
When RedeemVoucher fails, release closes the failed Activity scope and releaseGrant closes the logical grant with reason Failed. No Consume disposition is committed, so the durable voucher remains available for a later valid attempt.
import { Activity, Resource, State, Statechart, States, Transitions } from "@motive/motive";
import { Effect, Layer, Schema } from "effect";
export class RedemptionFailed extends Schema.TaggedError<RedemptionFailed>()(
"RedemptionFailed",
{},
) {}
export const Voucher = Resource.make("Voucher", {
key: Schema.String,
resource: Schema.Struct({ credit: Schema.Finite }),
}).pipe(Resource.consumeOnSuccess);
export class Begin extends Schema.TaggedClass<Begin>()("Begin", {
willFail: Schema.Boolean,
}) {}
const Vouchers = Voucher.as("Vouchers");
export const RedeemVoucher = Activity.make("RedeemVoucher", {
input: Schema.Struct({ voucher: Voucher.Input, willFail: Schema.Boolean }),
success: Schema.String,
error: RedemptionFailed,
});
export class Redemption extends State.Compound<Redemption>()("Redemption") {
static states = States.make(() => [Idle, Redeeming, Complete, Failed]);
}
class Idle extends State.Atomic<Idle>()("Idle") {
static transitions = Transitions.make(this, ({ on }) => [
on(Begin, Redeeming, ({ event }) => ({ willFail: event.willFail })),
]);
}
class Redeeming extends State.Atomic<Redeeming>()("Redeeming", {
willFail: Schema.Boolean,
}) {
static transitions = Transitions.make(this, ({ invoke, on, provide }) => [
provide(Vouchers),
invoke(RedeemVoucher, ({ state }) => ({
voucher: Voucher.acquire("voucher-1"),
willFail: state.willFail,
})),
on(RedeemVoucher.Done, Complete, ({ event }) => event.value),
on(RedeemVoucher.Error, Failed, ({ event }) => event.error),
]);
}
class Complete extends State.Done<Complete>()("Complete", Schema.String) {}
class Failed extends State.Error<Failed>()(
"Failed",
Schema.Union([RedemptionFailed, Resource.Unavailable(Voucher)]),
) {}
export const RedemptionChart = Statechart.make(Redemption);
export const makeLive = (record: (step: string) => void) =>
RedemptionChart.toLayer({
activities: {
RedeemVoucher: ({ input }) =>
Effect.sync(() => record("activity")).pipe(
Effect.andThen(Effect.sleep("500 millis")),
Effect.andThen(
input.willFail
? Effect.fail(new RedemptionFailed())
: Effect.succeed(`redeemed:${input.voucher.credit}`),
),
),
},
}).pipe(
Layer.provide(
Voucher.toLayer({
acquire: ({ key }) =>
Effect.sync(() => {
record(`acquire:${key}`);
return { credit: 25 };
}),
release: () => Effect.sync(() => record("release")),
releaseGrant: ({ reason }) => Effect.sync(() => record(`grant:${reason}`)),
settle: ({ disposition }) => Effect.sync(() => record(`settle:${disposition._tag}`)),
}),
),
);
Release closes the value on every exit
All three frames perform acquire → activity → release. The successful and failed handlers return
different modeled outcomes, but neither is allowed to leak the acquired { credit: 25 } value.
release is scoped finalization, not a durable business decision. Its failure channel is never:
providers should make local cleanup total and keep any operational observation inside the Effect.
It does not decide whether voucher-1 still exists.
Grant closure records why the attempt ended
After the Activity outcome commits, releaseGrant closes the exact grant as Succeeded or
Failed. Other terminal paths carry their own reasons, including Interrupted, Cancelled, and
Superseded.
This boundary is durable and idempotent. A runner may call it again during recovery with the same grant identity and commit coordinate. The provider must treat that as redelivery of one closure, not as a second grant.
Consume follows committed success
The consuming frame adds one fact that the retained frame does not have:
const Voucher = Resource.make("Voucher", {
key: Schema.String,
resource: Schema.Struct({ credit: Schema.Finite }),
}).pipe(Resource.consumeOnSuccess);
Handler return is not enough. Motive first accepts the RedeemVoucher.Done outcome and commits the
chart transition. That same commit contains one Consume disposition for the exact key, claim
path, provider, and owner. The runner then enacts settle as a post-commit capability.
Settlement failure cannot turn a committed chart success into RedeemVoucher.Error. It belongs to
durable enactment and redelivery. Provider implementations therefore use the supplied commit
coordinate as their idempotency boundary.
Failure proves consumption is conditional
In the final frame, RedeemVoucher fails with the modeled RedemptionFailed error. The value is
still released and the grant still closes as Failed, but no Consume disposition exists. The
durable voucher remains available for a later valid attempt.
The same negative rule holds for defects, interruption, cancellation, and supersession. None may masquerade as successful use merely because acquisition happened.
Test both clocks
- Complete a retained Resource Activity and verify
releaseandreleaseGrantrun whilesettleremains absent. - Complete a
consumeOnSuccessActivity and verify exactly one committedConsumedisposition. - Redeliver that disposition with the same commit coordinate and verify the provider does not consume twice.
- Produce a modeled failure and verify the value is released, the grant closes as
Failed, and no settlement operation exists. - Interrupt an acquired attempt and verify the same scoped cleanup with an
Interruptedgrant reason. - Crash after commit but before provider acknowledgement, recover, and verify settlement resumes without changing the chart outcome.
Next, Expiration and Acknowledgement adds two other ways to end retention: a durable deadline and authenticated evidence.