🐀 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 today | Separated in Awakes |
|---|---|
| deciding and doing | a pure decide; effects become owed rows, sent after commit |
| state and its history | facts as the only authority; a snapshot is just another fact; status is computed |
| time and work | one alarm row per actor; modules ask for a wake and never hold a timer |
| logic and the place it runs | a Store port: a Durable Object or a SQLite process |
| a job and its retry policy | owed rows hold the sealed bytes; every port dedupes on the owed key |
Language
| Term | Means | Is not |
|---|---|---|
| Actor | everything Awakes keeps under one key | a process, a session, a pane |
| Module | one kind of actor: its events, facts and decide | a framework or a plugin system |
| Event | an input, decoded at the edge, with an id taken from the input | an attempt |
| Fold | one event applied in one synchronous transaction | a step in a workflow engine |
| Fact | an append-only row. Facts are the authority. | a log copying some other truth |
| Snapshot | a fact that carries a whole state, for modules whose state is a big machine | a second authority or a cache |
| Owed effect | sealed bytes plus an idempotency key, saved in the fold | a job in a queue |
| Port | a service that delivers an owed effect, dedupes on its key, and may answer with a result event | an adapter that decides anything |
| Blob | a large value stored by digest; facts hold the digest | a row |
| Wake | the time an actor asks to be folded again | a timer in memory |
| Host | whatever provides one transaction and one alarm row | the 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 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
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
- 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.
- Nothing external inside a fold. No network, no clock reads, no randomness.
decidegetsnowas an argument. - Events count once. The processed id is written in the fold’s transaction. A repeat id is a no-op.
- 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.
- 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. - Rebuild, don’t migrate. State is the fold of facts from the latest snapshot fact. New code reads old rows.
- No timeouts in modules. Anything time-based is a wake. A limit nobody measured isn’t a default.
- Failed effects stay owed. They back off on the alarm and are parked as dead with an alert after a measured limit. Never dropped.
- 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.
- Bounded rows. No fact or owed row exceeds the stated ceiling; larger values go to the blob port.
Hosts
| Host | Transaction | Alarm | Fits |
|---|---|---|---|
| Durable Object on celld | transactionSync, real savepoint nesting | _cf_ALARM row in the same SQLite connection (celld ce4f4dc) | actors addressed over the network |
| Durable Object on Cloudflare | transactionSync | one alarm per object, at least once | the same, hosted |
Bun process with bun:sqlite | BEGIN IMMEDIATE | an alarm row plus one timer that only wakes the loop | work 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
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
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
- One seal per tick. The owed row keyed by the tick id holds the only bytes ever sent for it.
- The fence. No send for a tick whose generation isn’t the schedule’s current one. Superseding stops future sends, not sent ones.
- 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.
Acknever fails. An unknown or already acked tick is a no-op, so an owner can’t retry forever.- Delivery spread is a hash, not a knob.
dueAtstays exact; delivery is offset by a hash of the tick id, as drovr’swakeSpreadFordoes.
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
| Module | Actor key | Authority | Owed effects |
|---|---|---|---|
| timers | (owner, key) | facts | sealed wake through a delivery port |
| journeys | (tenant, contact, journey) | snapshot facts | sends, receipts, projections |
| capture | session | facts | index append, telemetry |
| runs | run id | snapshot plus journal today; facts later | asks 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
| Case | Sequence | Outcome |
|---|---|---|
| Crash inside a fold | the transaction throws or the process dies | storage and alarm byte-identical; the event is redelivered and folds once |
| Crash between commit and send | owed row saved, crash before the port | the drain sends the saved bytes after restart |
| Crash between send and delete | port accepted, crash before the row is deleted | resend of identical bytes; the port dedupes on the key |
| Duplicate event | the same event id arrives again | no-op; the processed id is in the fold’s transaction |
| Eviction | the host idles and drops the actor | nothing lives in memory; the alarm wakes it |
| Redeploy | new code, old rows | state is rebuilt from facts or the snapshot |
| Port down for hours | every send fails | rows stay owed, back off on the alarm, park as dead with an alert |
| DST fall-back (timers) | the 09:00 local instant repeats | next instant from now in UTC; the id contains dueAt |
Proof plan
- Extracted, not rewritten. drovr’s actor-do tests pass unchanged against the kernel.
- Model test. Generated event sequences with crashes and restores, checking every invariant after every step, for each module.
- Same suite, every host. A Durable Object on celld and a Bun SQLite process run identical tests.
- Atomic fold. A fold failing after its fact write leaves storage and the alarm byte-identical.
- Alarm property. After any interleaving of events and crashes, the armed alarm is never later than the soonest wake or owed retry.
- A cold actor wakes. An alarm on an evicted actor fires on time after a host restart.
- Cartridge test. Add Awakes with one Layer line; delete the package and its line, and
check test buildpasses with no loose resources.
Fence
- Module files can’t import a port, a host,
fetch,Date.noworMath.random. decide,evolveandwakereturn plain values; noEffectin their types.- Module machines can’t use
after, delayed events orEffect.sleep. - Owed keys and event ids are branded types built only from decoded input.
Left out on purpose
| Left out | Why |
|---|---|
| a workflow DSL | a module is a fold and a machine; steps are owed effects |
| a module registry or discovery | modules are values passed to one Layer |
| cross-actor transactions | one actor, one writer; actors talk through ports |
| a queue | owed rows plus one alarm are the queue |
| retries as configuration | owed rows back off on the alarm; the dead limit is measured first |
| timeouts | a wake is a fact; a timeout nobody measured is a guess |
| a second store such as Redis | a crash between two stores double-fires or loses work |
Open
- Where the package lives. Its own repo, or in the Rat King monorepo next to its first host.
- The extraction path. 1. Lift actor-do’s fold behind drovr’s unchanged tests. 2. Timers, with an existing schedule user as the first real customer. 3. Capture as the Bun host, once the cross-host suite exists. 4. Runs, after results-as-events, replies, key resolution and blobs are in the contract.
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 art | What it does | We take | We leave |
|---|---|---|---|
drovr @drovr/actor-do | One 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 test | Journeys, projections, birth replay |
Mischief ScheduleAlarm | ScheduleAlarm.set(at or null) as a port, with the DO adapter in the worker | The port shape | Its own schedule DO, eventually |
| Cloudflare DO alarms | One alarm per object, at-least-once, retried on failure | Rows plus one alarm at the soonest deadline. At-least-once makes dedupe mandatory. | — |
Cloudflare Agents this.schedule | Delay, date, cron and interval in the agent’s SQLite, driven by alarms | The same substrate | Random schedule ids |
Effect workflow and cluster | Workflow.idempotencyKey, DurableClock, DurableDeferred, ClusterCron with calculateNextRunFromPrevious and skipIfOlderThan | Identity from input, never the attempt; one boolean for catch-up | The engine: no DO runner today |
@xstate/effect | createEffectActor(logic, { snapshot }); declared fromEffect actors restart on restore; timers are Effect.sleep fibers | Declared actors for effects | Machine timers for anything that must survive eviction |
| Temporal schedules | Overlap policies and a catch-up window | The question they answer | The policies: one unacked at a time covers it |
| Restate | Idempotency keys returning the original result; a production key collision | Same key, different payload is refused | — |
| BullMQ job schedulers and DBOS | Upsert a schedule by name | Create is a keyed upsert | Redis, Postgres: a second store |
| SQS FIFO and Cloud Tasks | Dedupe inside a time window | The warning: windows let duplicates back in | Windows |
| Rivet actor schedules | Schedules that survive sleep and crashes | Sleeping actors woken by their own schedule | — |