consumeOnSuccess ends retention when an Activity successfully spends an input. Created Resources
need two other endings: a deadline may expire them, or trusted evidence may acknowledge that their
recoverable representation is no longer needed.
Neither ending is a cleanup callback. It is an authored fact that must win in durable state before the provider changes the external world.
Retention never ends by implication
Time passing does not erase a key. Reading, acquiring, or offering a Resource does not acknowledge it. Process-local cleanup does not settle it. Without an explicit lifecycle policy, a created Resource remains retained.
Resource.expiresOne durable Timer member per key commits an Expire disposition.
eraseOnAcknowledgedOne authored acknowledgement commits decoded evidence with the key.
Both policies belong to the Resource definition. Every placement therefore agrees on what can end retention, while the chart still controls where deadlines and acknowledgement events are admitted.
Expiration is an authored Timer family
Resource.expires accepts an ordinary Timer.each site whose input Schema exactly matches the
Resource key Schema:
const Retention = Timer.relative("Retention", { input: Schema.String });
const Expirations = Retention.each("Expirations");
const AccessCode = Resource.make("AccessCode", {
key: Schema.String,
material: Schema.Struct({ secret: Schema.String }),
resource: Schema.Struct({ secret: Schema.String }),
}).pipe(Resource.expires(Expirations));
Every created key arms one exact family member at the active provider occurrence. The timer input is the decoded key. Its deadline, key, generation, and occurrence identity are durable, so recovery restores the same citizen rather than choosing a fresh time-to-live.
When that member fires, Motive first commits one Expire disposition. Only then may the runner call
the provider's settle binding. A Timer handler never reaches around the chart to delete the
external representation itself.
Acknowledgement carries evidence
Resource.eraseOnAcknowledged(AdmissionReceipt) declares the exact evidence Schema. An authored
transition commits the key and evidence through acknowledge:
on(CodeAdmitted).acknowledge(Codes, ({ event }) => ({
key: event.key,
evidence: event.evidence,
}));
Offering, reading, acquiring, or successfully using the Resource is not acknowledgement. The fact must be explicit because it authorizes erasure of something intended to survive process and chart lifetimes.
Schema proves the evidence structure; it does not prove authenticity. The host or Resource
authority must verify the receipt before admitting CodeAdmitted. The statechart then preserves
the verified evidence as part of the committed Acknowledge disposition.
Publication opens one terminal race
Acknowledged erasure gives a created output a three-part durable lifecycle:
PublishThe created representation may become externally available.
AcknowledgeExpirePublish is not terminal. It marks the recoverable representation as externally available after
the Activity output commits. Acknowledge and Expire then compete for the same retained key.
If acknowledgement commits first, Motive cancels the exact expiration member. If expiration commits first, a later acknowledgement event may still be handled by the chart, but it cannot produce a second settlement intent. The durable ledger—not callback timing—decides the winner.
Follow retained creation, expiration, and both sides of the final race:
Terminal retention
One committed fact closes retention.
Create one retained key, add an exact durable deadline, then race that deadline against authenticated acknowledgement evidence.
1 · Make retention explicit
Elapsed time alone cannot erase a Resource.
The provider prepares and publishes a durable access-code key from process-local material. With no expiration or acknowledgement policy, it remains retained: elapsed wall time, reading the key, and acquiring it do not invent an erasure decision.
import { Activity, Resource, State, Statechart, States, Transitions } from "@motive/motive";
import { Schema } from "effect";
export class Issue extends Schema.TaggedClass<Issue>()("Issue", {}) {}
export const AccessCode = Resource.make("AccessCode", {
key: Schema.String,
material: Schema.Struct({ secret: Schema.String }),
resource: Schema.Struct({ secret: Schema.String }),
});
const Codes = AccessCode.as("Codes");
export const IssueCode = Activity.make("IssueCode", {
success: Schema.Struct({ code: AccessCode.Output }),
});
export class CodeLifecycle extends State.Compound<CodeLifecycle>()("CodeLifecycle") {
static states = States.make(() => [Idle, Issuing, Available]);
static transitions = Transitions.make(this, ({ provide }) => [
provide(Codes),
]);
}
class Idle extends State.Atomic<Idle>()("Idle") {
static transitions = Transitions.make(this, ({ on }) => [
on(Issue, Issuing),
]);
}
class Issuing extends State.Atomic<Issuing>()("Issuing") {
static transitions = Transitions.make(this, ({ invoke, on }) => [
invoke(IssueCode),
on(IssueCode.Done, Available, ({ event }) => ({ key: event.value.code })),
]);
}
class Available extends State.Atomic<Available>()("Available", {
key: Schema.String,
}) {}
export const CodeLifecycleChart = Statechart.make(CodeLifecycle);
2 · Give every key a deadline
expires makes a Timer family part of Resource policy.
Retention.each identifies one ordinary Timer occurrence for every created key. Its input is the decoded key, its family identity is canonical, and delivery commits one Expire disposition before the provider enacts cleanup.
import { Activity, Resource, State, Statechart, States, Timer, Transitions } from "@motive/motive";
import { Schema } from "effect";
const Retention = Timer.relative("Retention", { input: Schema.String });
const Expirations = Retention.each("Expirations");
export const AccessCode = Resource.make("AccessCode", {
key: Schema.String,
material: Schema.Struct({ secret: Schema.String }),
resource: Schema.Struct({ secret: Schema.String }),
}).pipe(Resource.expires(Expirations));
export class Issue extends Schema.TaggedClass<Issue>()("Issue", {}) {}
const Codes = AccessCode.as("Codes");
export const IssueCode = Activity.make("IssueCode", {
success: Schema.Struct({ code: AccessCode.Output }),
});
export class CodeLifecycle extends State.Compound<CodeLifecycle>()("CodeLifecycle") {
static states = States.make(() => [Idle, Issuing, Available]);
static transitions = Transitions.make(this, ({ provide }) => [
provide(Codes),
]);
}
class Idle extends State.Atomic<Idle>()("Idle") {
static transitions = Transitions.make(this, ({ on }) => [
on(Issue, Issuing),
]);
}
class Issuing extends State.Atomic<Issuing>()("Issuing") {
static transitions = Transitions.make(this, ({ invoke, on }) => [
invoke(IssueCode),
on(IssueCode.Done, Available, ({ event }) => ({ key: event.value.code })),
]);
}
class Available extends State.Atomic<Available>()("Available", {
key: Schema.String,
}) {}
export const CodeLifecycleChart = Statechart.make(CodeLifecycle);
3 · Race committed terminal facts
Acknowledgement and expiration admit exactly one winner.
eraseOnAcknowledged admits evidence only through an authored acknowledge action. If Acknowledge commits first it cancels the exact deadline; if Expire commits first, a later acknowledgement is fenced instead of erasing or resurrecting the entry again.
import { Activity, Resource, State, Statechart, States, Timer, Transitions } from "@motive/motive";
import { Duration, Effect, Layer, Schema } from "effect";
export class AdmissionReceipt extends Schema.TaggedClass<AdmissionReceipt>()("AdmissionReceipt", {
receipt: Schema.String,
}) {}
export class CodeAdmitted extends Schema.TaggedClass<CodeAdmitted>()("CodeAdmitted", {
key: Schema.String,
evidence: AdmissionReceipt,
}) {}
const Retention = Timer.relative("Retention", { input: Schema.String });
const Expirations = Retention.each("Expirations");
export const AccessCode = Resource.make("AccessCode", {
key: Schema.String,
material: Schema.Struct({ secret: Schema.String }),
resource: Schema.Struct({ secret: Schema.String }),
}).pipe(Resource.eraseOnAcknowledged(AdmissionReceipt), Resource.expires(Expirations));
export class Issue extends Schema.TaggedClass<Issue>()("Issue", {}) {}
const Codes = AccessCode.as("Codes");
export const IssueCode = Activity.make("IssueCode", {
success: Schema.Struct({ code: AccessCode.Output }),
});
export class CodeLifecycle extends State.Compound<CodeLifecycle>()("CodeLifecycle") {
static states = States.make(() => [Idle, Issuing, Available]);
static transitions = Transitions.make(this, ({ provide }) => [
provide(Codes),
]);
}
class Idle extends State.Atomic<Idle>()("Idle") {
static transitions = Transitions.make(this, ({ on }) => [
on(Issue, Issuing),
]);
}
class Issuing extends State.Atomic<Issuing>()("Issuing") {
static transitions = Transitions.make(this, ({ invoke, on }) => [
invoke(IssueCode),
on(IssueCode.Done, Available, ({ event }) => ({ key: event.value.code })),
]);
}
class Available extends State.Atomic<Available>()("Available", {
key: Schema.String,
}) {
static transitions = Transitions.make(this, ({ on }) => [
on(CodeAdmitted).acknowledge(Codes, ({ event }) => ({
key: event.key,
evidence: event.evidence,
})),
]);
}
export const CodeLifecycleChart = Statechart.make(CodeLifecycle);
export const makeLive = (record: (step: string) => void) =>
CodeLifecycleChart.toLayer({
timers: {
Retention: ({ input }) =>
Effect.sync(() => record(`arm:${input}`)).pipe(Effect.as(Duration.seconds(3))),
},
activities: {
IssueCode: () =>
Effect.sleep("350 millis").pipe(
Effect.as({ code: { secret: "process-only-access-code" } }),
),
},
}).pipe(
Layer.provide(
AccessCode.toLayer({
prepare: () =>
Effect.sync(() => {
record("prepare:access-code-3");
return "access-code-3";
}),
abandon: () => Effect.void,
settle: ({ disposition }) => Effect.sync(() => record(`settle:${disposition._tag}`)),
}),
),
);
Retained creation has no hidden deadline
The first frame creates access-code-1 with no expiration or acknowledgement policy. Its key
becomes chart data, but there is no Timer member and no terminal disposition. Waiting longer does
not change that answer.
This is the positive control for retention: if the model does not author an ending, the runtime must not infer one from elapsed wall time, a process restart, or ordinary Resource use.
Expiration commits before external cleanup
The second frame creates access-code-2 and arms Expirations[access-code-2]. When its durable
deadline fires, the chart commits Expire, closes that exact Timer member, and prevents a new
grant from being admitted for the expired key.
A grant already acquired before soft expiry may finish its scoped work. Expiration ends future retention; it does not revoke a process-local value in the middle of the Activity that owns it.
Evidence can close the same lifecycle first
The final frame first commits Publish, then leaves both endings available. Choose
“Acknowledge before deadline” to commit the decoded receipt and close the Timer, or let the exact
deadline commit Expire.
Both outcomes call the same settle boundary with different typed dispositions. The provider can
erase, revoke, archive, or tombstone according to the winning reason without guessing why the key
became terminal.
Settlement is idempotent enactment
settle runs only after the winning disposition commits. Its arguments carry the key, claim path,
provider and owner addresses, exact disposition, and commit coordinate. Provider implementations
must use that coordinate as an idempotency boundary.
If a runner disappears after changing the external world but before acknowledging its work, recovery may deliver the same settlement again. That is redelivery of one durable intent—not permission to publish, acknowledge, expire, or erase twice.
Test both winners
- Create a Resource with neither policy and verify elapsed time produces no Timer or disposition.
- Create an expiring Resource and verify the Timer input is the decoded durable key.
- Recover before the deadline and verify the same deadline and Timer occurrence remain armed.
- Let the Timer fire and verify
Expirecommits beforesettleruns. - Acknowledge first and verify the evidence decodes,
Acknowledgecommits, and the exact Timer is canceled. - Expire first, deliver a later acknowledgement event, and verify no second settlement intent is produced.
- Redeliver either winning settlement with the same commit coordinate and verify provider cleanup remains idempotent.
- Acquire before soft expiry and verify the existing grant can finish while a new acquisition is refused.
Next, Component Definitions and Placements begins reusable topology: define one state tree once, then place it with concrete authored identity.