Guide

Expiration and Acknowledgement

Race one durable Resource deadline against committed evidence for acknowledged erasure.

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.

DeadlineResource.expires

One durable Timer member per key commits an Expire disposition.

EvidenceeraseOnAcknowledged

One 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:

Publish 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

  1. Create a Resource with neither policy and verify elapsed time produces no Timer or disposition.
  2. Create an expiring Resource and verify the Timer input is the decoded durable key.
  3. Recover before the deadline and verify the same deadline and Timer occurrence remain armed.
  4. Let the Timer fire and verify Expire commits before settle runs.
  5. Acknowledge first and verify the evidence decodes, Acknowledge commits, and the exact Timer is canceled.
  6. Expire first, deliver a later acknowledgement event, and verify no second settlement intent is produced.
  7. Redeliver either winning settlement with the same commit coordinate and verify provider cleanup remains idempotent.
  8. 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.