🐀 Rat King Awakes

A small durable kernel: one key, one fold, one clock. A design draft; nothing is built.

Rat King Awakes (Awakes for short) runs keyed actors that must survive crashes, restarts and redeploys. A module says what an actor decides when an event arrives. Awakes owns everything else: storing what happened, waking the actor at the right time, and making sure every effect happens once, however many times it’s retried.

It’s built the rat-stack way. Effect services and Layers carry the contract. Alchemy declares the resources. XState draws the machines. A fence of types and lint rules keeps the parts from braiding back together. Rat King, an encrypted agent network on the AT Protocol, is the first host.

One rule above the rest: an event can arrive twice and still count once, and an effect can be retried forever and still happen once.

What it pulls apart

Braided together todaySeparated in Awakes
deciding and doinga pure decide; effects become owed rows, sent after commit
state and its historyfacts as the only authority; a snapshot is just another fact; status is computed
time and workone alarm row per actor; modules ask for a wake and never hold a timer
logic and the place it runsa Store port: a Durable Object or a SQLite process
a job and its retry policyowed rows hold the sealed bytes; every port dedupes on the owed key

Language

TermMeansIs not
Actoreverything Awakes keeps under one keya process, a session, a pane
Moduleone kind of actor: its events, facts and decidea framework or a plugin system
Eventan input, decoded at the edge, with an id taken from the inputan attempt
Foldone event applied in one synchronous transactiona step in a workflow engine
Factan append-only row. Facts are the authority.a log copying some other truth
Snapshota fact that carries a whole state, for modules whose state is a big machinea second authority or a cache
Owed effectsealed bytes plus an idempotency key, saved in the folda job in a queue
Porta service that delivers an owed effect, dedupes on its key, and may answer with a result eventan adapter that decides anything
Bloba large value stored by digest; facts hold the digesta row
Wakethe time an actor asks to be folded againa timer in memory
Hostwhatever provides one transaction and one alarm rowthe kernel

Core

Each actor has one writer and one store. Every event and every alarm becomes one fold: decode, dedupe the event id, rebuild state, call decide, then commit facts, owed effects, the processed id and the alarm row together. Nothing external happens inside. After commit, the drain sends each owed effect’s stored bytes through its port and deletes the row once the port confirms.

The fold loop: idle, folding, draining. An event or alarm starts a fold; the fold commits facts, owed effects and the alarm row in one transaction; draining sends stored bytes and clears owed rows, back to idle. A throw rolls back to idle unchanged. A crash while draining leaves the owed rows for the next alarm.

The discipline comes from @drovr/actor-do, which runs it in production. Awakes extracts it rather than rewriting it: the proof of a clean cut is drovr’s own tests passing unchanged against the kernel.

Contract

A module

import { DateTime, Schema } from "effect"

export interface Owed {
  readonly key: string        // idempotency key, from the input, never the attempt
  readonly port: string       // which port delivers it
  readonly bytes: Uint8Array  // sealed once, sent as is on every retry
}

export interface Decision<F, R> {
  readonly facts: ReadonlyArray<F>
  readonly owed: ReadonlyArray<Owed>
  readonly reply: R                 // returned to the caller after commit
}

export interface Module<S, E, F, R, V> {
  readonly name: string
  readonly event: Schema.Codec<E, unknown>
  readonly fact: Schema.Codec<F, unknown>
  readonly keyOf: (event: E) => string    // which actor, resolved at the edge
  readonly eventId: (event: E) => string
  readonly initial: S
  readonly evolve: (state: S, fact: F) => S
  readonly decide: (state: S, event: E, now: DateTime.Utc) => Decision<F, R>
  readonly view: (state: S) => V          // reads never fold
  readonly wake: (state: S) => DateTime.Utc | null
}

decide is pure. Time arrives as an argument, so the same state, event and now always give the same decision, and a model test can replay any history. A refusal is a decision with no facts and a typed reply. Reads go through view, so they never fold.

Results come back as events. Most outside work has an answer: a model call, an agent turn, the reply to a question. A port answers by delivering a result event to the same actor, and that event’s id is the owed key. A retried call whose result arrives twice folds once, and the first result wins. A chain of steps is then just a chain of folds: a result folds and may owe the next step.

Facts are the only authority. A module whose state is a big machine emits a Snapshot fact, and its evolve replaces state with it. Rebuild starts from the latest snapshot fact and folds what came after. A snapshot fact carries a schema version like any other fact, so there’s no separate migration story.

Big values go to a blob port. A fact holds a digest; the bytes live in content-addressed storage. The kernel states its row ceiling: celld accepted a 3 MB row and refused 12 MiB with SQLITE_TOOBIG.

What a host provides

import { Context, DateTime, Effect } from "effect"

export interface Tx {
  readonly sql: (query: string, ...params: ReadonlyArray<unknown>) => ReadonlyArray<Record<string, unknown>>
  readonly setAlarm: (at: DateTime.Utc | null) => void  // a row, inside the same transaction
}

export class Store extends Context.Service<Store, {
  readonly transaction: <A>(run: (tx: Tx) => A) => A  // synchronous, all or nothing
}>()("awakes/Store") {}

export class PortError extends Schema.TaggedErrorClass<PortError>()("PortError", { port: Schema.String, key: Schema.String }) {}

export class Ports extends Context.Service<Ports, {
  readonly send: (owed: Owed) => Effect.Effect<void, PortError>  // must dedupe on owed.key
}>()("awakes/Ports") {}

That’s the whole host surface: one synchronous transaction with an alarm row inside it, and ports that dedupe. A Durable Object provides transactionSync and its alarm row. A Bun process provides BEGIN IMMEDIATE on SQLite and one timer that only wakes the loop; the row is the truth.

The service

export class Awakes extends Context.Service<Awakes, {
  readonly deliver: <E, R>(module: string, event: E) => Effect.Effect<R, AwakesError>
  readonly read: <V>(module: string, key: string) => Effect.Effect<V, AwakesError>
  readonly alarm: (key: string) => Effect.Effect<void, AwakesError>
}>()("awakes/Awakes") {}

A cartridge adds Awakes with one Layer line, Awakes.layer(modules), provided with a host Layer: Awakes.durableObject or Awakes.sqlite. With Alchemy, the Durable Object Layer also declares the namespace and its binding, so removing the cartridge removes its resources. Modules are plain values passed in. There’s no registry and no discovery.

Copying one actor’s history into another (forking) happens at the edge: a create event carrying a fact prefix and its digest. The kernel has no cross-actor operation.

Machines

How it nests: one cell per actor key runs one fold loop. The loop rebuilds the module's machine from facts or a snapshot, feeds it one event, and writes back facts and owed effects. Owed effects leave only through ports, after commit.

The fold loop is the kernel’s. Module machines are XState machines run inside it. None of them is a long-lived actor: each fold rebuilds the machine, feeds it one event and keeps only what the transition produced. That’s why nothing lives in memory.

A module can write decide as a machine with one adapter:

import { fromMachine } from "awakes/xstate"

export const timers = fromMachine({
  name: "timers",
  machine: scheduleMachine,
  event: TimerEvent,
  fact: TimerFact,
  eventId: (e) => e.id,
})

The adapter turns a transition into a Decision: facts for the state change, owed rows for the invoked effects, and a wake from the next due time. Machine timers aren’t allowed in modules; the fence rejects after and Effect.sleep inside module code, because a timer can’t survive eviction and the alarm row can.

Invariants

  1. One fold, one transaction. Facts, owed effects, the processed event id and the alarm row commit together. A failed fold leaves storage and the alarm byte-identical.
  2. Nothing external inside a fold. No network, no clock reads, no randomness. decide gets now as an argument.
  3. Events count once. The processed id is written in the fold’s transaction. A repeat id is a no-op.
  4. Effects are sealed once. The bytes saved in the fold are the only bytes ever sent for that owed key, and every port dedupes on it.
  5. One clock. The alarm row is min(module wake, soonest owed retry), set inside the transaction and never later than the one already armed. A host timer only wakes the loop.
  6. Rebuild, don’t migrate. State is the fold of facts from the latest snapshot fact. New code reads old rows.
  7. No timeouts in modules. Anything time-based is a wake. A limit nobody measured isn’t a default.
  8. Failed effects stay owed. They back off on the alarm and are parked as dead with an alert after a measured limit. Never dropped.
  9. Identity comes from input. Event ids and owed keys are derived from what arrived, never from the attempt. A result event’s id is its owed key.
  10. Bounded rows. No fact or owed row exceeds the stated ceiling; larger values go to the blob port.

Hosts

HostTransactionAlarmFits
Durable Object on celldtransactionSync, real savepoint nesting_cf_ALARM row in the same SQLite connection (celld ce4f4dc)actors addressed over the network
Durable Object on CloudflaretransactionSyncone alarm per object, at least oncethe same, hosted
Bun process with bun:sqliteBEGIN IMMEDIATEan alarm row plus one timer that only wakes the loopwork that needs a local filesystem

The same test suite runs against every host. A host passes or it isn’t a host.

Storage

create table awakes_fact (
  actor text not null,
  seq integer not null,
  kind text not null,
  bytes blob not null,
  at text not null,
  primary key (actor, seq)
);

create table awakes_event (
  actor text not null,
  event_id text not null,
  primary key (actor, event_id)
);

create table awakes_owed (
  actor text not null,
  key text not null,
  port text not null,
  bytes blob not null,
  attempts integer not null default 0,
  next_at text,
  dead integer not null default 0,
  primary key (actor, key)
);

Three tables, the same for every module. The alarm row belongs to the host. There’s no receipt table: a port’s dedupe is the receipt. Facts and processed event ids before the latest snapshot fact may be pruned, which is also the retention rule for awakes_event.

First module: timers

Timers are what the kernel was first sketched for: wake me at a time, or on a schedule, even when nothing is running to hold a timer. A timer is an actor keyed by (owner, key). Its wake is delivered through a port, which on Rat King is the owner’s mailbox.

Schema

import { Schema } from "effect"

export const ScheduleId = Schema.NonEmptyString.pipe(Schema.brand("ScheduleId"))
export const Generation = Schema.Int.check(Schema.isGreaterThanOrEqualTo(0)).pipe(Schema.brand("Generation"))

export const TickId = Schema.Struct({ scheduleId: ScheduleId, generation: Generation, dueAt: Schema.DateTimeUtc })

export const Trigger = Schema.TaggedUnion({
  At: { at: Schema.DateTimeUtc },
  Every: { cron: Schema.NonEmptyString, tz: Schema.NonEmptyString },
})

export const TimerEvent = Schema.TaggedUnion({
  Create: { owner: Schema.NonEmptyString, key: Schema.NonEmptyString, trigger: Trigger, payload: Schema.Uint8Array, endsAt: Schema.optionalKey(Schema.DateTimeUtc) },
  Supersede: { id: ScheduleId, trigger: Trigger, payload: Schema.Uint8Array, endsAt: Schema.optionalKey(Schema.DateTimeUtc) },
  End: { id: ScheduleId },
  Ack: { tick: TickId },
})

export const TimerFact = Schema.TaggedUnion({
  Scheduled: { id: ScheduleId, generation: Generation, trigger: Trigger, payloadDigest: Schema.NonEmptyString, endsAt: Schema.optionalKey(Schema.DateTimeUtc) },
  Superseded: { id: ScheduleId, from: Generation, to: Generation },
  Ended: { id: ScheduleId, generation: Generation },
  TickSealed: { tick: TickId },
  TickSent: { tick: TickId },
  TickAcked: { tick: TickId },
})

ScheduleId is sha256(owner, key), so creating twice names one schedule. A Create with an identical trigger, digest and endsAt is a no-op; anything different is refused with ScheduleKeyConflict, and changing a schedule is Supersede, on purpose. The cron dialect is pinned: five fields, parsed by Effect’s Cron.parse(expr, tz). A tick’s id contains its dueAt, so two instants can’t share an id. The sealed wake is an owed row keyed by the tick id; its bytes live there, not in the fact.

Schedule

Schedule machine: active holds waiting and ticking. An alarm at the next dueAt moves waiting to ticking, which invokes one tick; when the tick finishes, the next instant is computed from now and it waits again. Supersede re-enters active with a new generation. End, endsAt passing or no next instant goes to ended.

ticking invokes the tick machine, so “one unacked tick at a time” is the shape of the chart, not a check. Superseding re-enters active, which stops the old tick and starts again at waiting under the new generation. An At trigger that has fired, or an endsAt that has passed, has no next instant, so waiting falls straight through to ended.

import { assign, sendTo } from "xstate"
import { setupEffect } from "@xstate/effect"

export const scheduleMachine = setupEffect({
  actors: { tick: tickMachine },
  guards: {
    dueReached: ({ context, event }) => context.nextDueAt !== null && event.now >= context.nextDueAt,
    noNextInstant: ({ context }) => context.nextDueAt === null,
  },
  actions: {
    bumpGeneration: assign({ generation: ({ context }) => context.generation + 1 }),
    nextFromNow: assign({ nextDueAt: ({ context, event }) => nextInstant(context.schedule, event.now) }),
  },
}).createMachine({
  id: "schedule",
  initial: "active",
  states: {
    active: {
      initial: "waiting",
      on: {
        SUPERSEDE: { target: "active", reenter: true, actions: "bumpGeneration" },
        END: "ended",
      },
      states: {
        waiting: {
          always: { guard: "noNextInstant", target: "#schedule.ended" },
          on: { ALARM: { guard: "dueReached", target: "ticking" } },
        },
        ticking: {
          invoke: {
            id: "tick",
            src: "tick",
            input: ({ context }) => ({ tick: context.tickId, currentGeneration: context.generation }),
            onDone: { target: "waiting", actions: "nextFromNow" },
          },
          on: {
            ALARM: { actions: sendTo("tick", ({ event }) => event) },
            ACKED: { actions: sendTo("tick", ({ event }) => event) },
          },
        },
      },
    },
    ended: { type: "final" },
  },
})

nextInstant(schedule, now) is pure: Cron.parse with the pinned timezone, first instant strictly after now, in UTC, or null past endsAt. Missed instants after downtime aren’t ticks.

Tick

Tick machine: live holds due, sealed and owed. due seals once into sealed; sealed sends the stored bytes to sent, or on failure waits in owed until the alarm resends the same bytes. FENCED from anywhere in live goes to void. sent goes to acked on the ack. sent is not fenced.

The fence sits on the parent state live, so one transition covers due, sealed and owed. sent is outside it on purpose: once the port has the wake it’s delivered. A failed send waits in owed for the next alarm. Through the adapter, sealed becomes an owed row and owed is just that row with attempts above zero; the kernel’s drain does the sending, not the machine.

export const tickMachine = setupEffect({
  guards: { fresh: ({ context }) => context.tick.generation === context.currentGeneration },
}).createMachine({
  id: "tick",
  initial: "live",
  states: {
    live: {
      initial: "due",
      on: { FENCED: "void" },
      states: {
        due: { on: { SEALED: { guard: "fresh", target: "sealed" } } },
        sealed: { on: { SENT: "#tick.sent", SEND_FAILED: "owed" } },
        owed: { on: { ALARM: "sealed" } },
      },
    },
    sent: { on: { ACKED: "acked" } },
    acked: { type: "final", output: { acked: true } },
    void: { type: "final", output: { acked: false } },
  },
})

Timer rules

  1. One seal per tick. The owed row keyed by the tick id holds the only bytes ever sent for it.
  2. The fence. No send for a tick whose generation isn’t the schedule’s current one. Superseding stops future sends, not sent ones.
  3. One unacked tick per schedule. The owner chooses when to ack, which may be hours later. Awakes never times out or duplicates a held tick.
  4. Ack never fails. An unknown or already acked tick is a no-op, so an owner can’t retry forever.
  5. Delivery spread is a hash, not a knob. dueAt stays exact; delivery is offset by a hash of the tick id, as drovr’s wakeSpreadFor does.

Delivering wakes as mail

On Rat King the timer port is the owner’s mailbox, and the mailbox adds two properties the timer relies on. A tick never expires there: Awakes leaves expiresAt unset, and the mailbox has no TTL, retry ceiling or cleanup, so a tick can be held for hours. After a lease handover the tick is redelivered with the same envelope id, so an owner that keys its work on the tick id joins the work already running instead of starting a second copy.

Candidate modules

ModuleActor keyAuthorityOwed effects
timers(owner, key)factssealed wake through a delivery port
journeys(tenant, contact, journey)snapshot factssends, receipts, projections
capturesessionfactsindex append, telemetry
runsrun idsnapshot plus journal today; facts laterasks and steps, with results as events

Each one is a module value and a port. None of them changes the kernel. Runs need results-as-events, replies, key resolution and blobs, which is why they come last.

Failure cases

CaseSequenceOutcome
Crash inside a foldthe transaction throws or the process diesstorage and alarm byte-identical; the event is redelivered and folds once
Crash between commit and sendowed row saved, crash before the portthe drain sends the saved bytes after restart
Crash between send and deleteport accepted, crash before the row is deletedresend of identical bytes; the port dedupes on the key
Duplicate eventthe same event id arrives againno-op; the processed id is in the fold’s transaction
Evictionthe host idles and drops the actornothing lives in memory; the alarm wakes it
Redeploynew code, old rowsstate is rebuilt from facts or the snapshot
Port down for hoursevery send failsrows stay owed, back off on the alarm, park as dead with an alert
DST fall-back (timers)the 09:00 local instant repeatsnext instant from now in UTC; the id contains dueAt

Proof plan

Fence

Left out on purpose

Left outWhy
a workflow DSLa module is a fold and a machine; steps are owed effects
a module registry or discoverymodules are values passed to one Layer
cross-actor transactionsone actor, one writer; actors talk through ports
a queueowed rows plus one alarm are the queue
retries as configurationowed rows back off on the alarm; the dead limit is measured first
timeoutsa wake is a fact; a timeout nobody measured is a guess
a second store such as Redisa crash between two stores double-fires or loses work

Open

Appendix: prior art

Read 2026-10-17 from each project’s own docs or source. Each says what we take and what we leave.

Prior artWhat it doesWe takeWe leave
drovr @drovr/actor-doOne DO per (tenant, contact, journey). A fold is one storage transaction: snapshot, processed keys, wake set, receipts, intent and projection debt. Nothing external inside. Alarm no later than the soonest. FNV wake spread.The fold discipline, intent debt as TickSealed, the spread, the atomic-fold testJourneys, projections, birth replay
Mischief ScheduleAlarmScheduleAlarm.set(at or null) as a port, with the DO adapter in the workerThe port shapeIts own schedule DO, eventually
Cloudflare DO alarmsOne alarm per object, at-least-once, retried on failureRows plus one alarm at the soonest deadline. At-least-once makes dedupe mandatory.—
Cloudflare Agents this.scheduleDelay, date, cron and interval in the agent’s SQLite, driven by alarmsThe same substrateRandom schedule ids
Effect workflow and clusterWorkflow.idempotencyKey, DurableClock, DurableDeferred, ClusterCron with calculateNextRunFromPrevious and skipIfOlderThanIdentity from input, never the attempt; one boolean for catch-upThe engine: no DO runner today
@xstate/effectcreateEffectActor(logic, { snapshot }); declared fromEffect actors restart on restore; timers are Effect.sleep fibersDeclared actors for effectsMachine timers for anything that must survive eviction
Temporal schedulesOverlap policies and a catch-up windowThe question they answerThe policies: one unacked at a time covers it
RestateIdempotency keys returning the original result; a production key collisionSame key, different payload is refused—
BullMQ job schedulers and DBOSUpsert a schedule by nameCreate is a keyed upsertRedis, Postgres: a second store
SQS FIFO and Cloud TasksDedupe inside a time windowThe warning: windows let duplicates back inWindows
Rivet actor schedulesSchedules that survive sleep and crashesSleeping actors woken by their own schedule—