App events
Declare versioned application facts and consume service events with retryable handlers.
Declare an app event when another application needs to react to a fact that has already happened. The publisher owns its event schema; each subscriber owns its handler and effects. Use a Query or Action when a caller needs an immediate answer or asks another application to do work.
Who receives an event
- A service event has no
place. Cloud delivers it to every application that subscribes to it; deploying the application approves its declared subscriptions. - A resource event has a
place. Cloud delivers it only to a subscription that a person stands behind with a mandate, such as an automation that runs on that person's behalf. Before every delivery Cloud checks, through the reader of the place's Type, that this person may still read the place. An event never carries the rights of its publisher or of whoever caused it. - Applications receive service events only. An application subscription to a
resource event stays
blockedwithresource_events_unsupported. This is the rule, not a missing feature: applications are not readers of places.
Person-mandated subscriptions are not available yet, so resource events are
stored and listed in the catalog but not delivered. automation metadata does
not grant authority or install a Routine.
Deployed applications are trusted parts of the installation and share the platform database and NATS credentials. Deploying an application is approval for its declared subscriptions. Core accepts an event only when its source application declared that event type and version.
Built-in resource events
Spaces and Notebooks publish these version 1 events. Each data schema is a
strict empty object ({}); the envelope's subject and place carry public
resource IDs accepted by the corresponding Type readers.
| Full event type | Subject Type | Place Type | When it is published |
|---|---|---|---|
spaces.item.created |
spaces.item |
spaces.space |
A task, calendar event, stored occurrence override, or split series is created. |
spaces.item.completed |
spaces.item |
spaces.space |
An existing item changes from open to completed, including a drag, calendar cancellation, or transfer into a done column. |
notebooks.notebook.created |
notebooks.notebook |
notebooks.notebook |
A notebook is created. |
notebooks.notebook.updated |
notebooks.notebook |
notebooks.notebook |
Notebook metadata or settings change. |
notebooks.note.created |
notebooks.note |
notebooks.notebook |
A note is created, including welcome notes, path creation, copies, and restored copies. |
notebooks.note.updated |
notebooks.note |
notebooks.notebook |
Persisted Markdown, title, the moved note’s parent or order, lock state, or recovery status changes, coalesced as described below. |
The place Types have permission-aware readers: spaces.space.read and
notebooks.notebook.read. Subject readers are spaces.item.read,
notebooks.notebook.read, and notebooks.note.read. Read content through those
readers; events contain no titles, text, names, email addresses, content counts,
parent IDs, or previous-place references. Repeating completion or reopening an
item does not publish spaces.item.completed.
A Spaces transfer that completes an item publishes under its new place only.
Its source receives no resource event naming the destination. A note move
carries only the note and its current notebook refs; it omits parent and position.
Moving notes between notebooks remains unsupported; copying creates a new note
and emits note.created in the destination notebook.
notebooks.note.updated uses trailing coalescing. A changed note records its
first pending change and latest change in the domain transaction. A minute
sweep publishes at most four notes, oldest pending first, when the latest
change is at least 30 seconds old or the first pending change is at least
five minutes old. These are eligibility thresholds; scheduler alignment and
backlog can add delay. Pending changes survive restarts, including a save
followed by locking the note. The sweep uses the note's current notebook and
clears its marker atomically with the outbox event. A durable tick claim and
row locks prevent replicas or retries from multiplying the batch limit.
See the shared capacity budget.
Only the moved note is announced. Sibling position renumbering, resetting a
level to automatic order, and a parent's derived hasChildren change do not
mark notes. Cursor advancement, binary snapshot maintenance, rebuildable
indexes, and unchanged or rejected saves do not count as content changes.
Collaborative edits mark the note when persisted to its snapshot, including
shared-log snapshot workers and server-side edits, rather than on each Yjs
update. Existing workspace and collaborative text streams retain their own behavior.
Creates and completions remain transactional discrete facts. Notebook metadata
updates also remain transactional: one update affects one notebook and does
not fan out to its notes. These event rates equal the corresponding domain
write rates; they have no aggregate event throttle. The domain helpers can
participate in a caller-owned transaction, so they rely on the platform's
one-second outbox reconciliation instead of waking before that transaction
commits. The note sweep owns its transaction and calls wake() after commit.
Applications do not receive these events. They are resource events: an
application subscription to them stays blocked with
resource_events_unsupported, and only subscriptions a person stands behind
with a mandate will receive them (see Who receives an event).
Declare events and subscriptions
Add events and subscribes to the same defineCapabilities() declaration
that owns the application's Types. Event keys are local IDs; subscription keys
are the publisher's full event type.
import { defineCapabilities } from "@k2b/cloud";
import { z } from "zod";
const userCreated = z.object({
userId: z.uuid(),
provider: z.enum(["local", "ipa"]),
}).strict();
export const publisherCapabilities = defineCapabilities({
protocolVersion: 2,
types: {
user: {
title: "User",
description: "An account in this Cloud installation.",
},
},
events: {
"user.created": {
version: 1,
title: "User created",
description: "A new account was created.",
subject: "user",
schema: userCreated,
},
},
});
export const subscriberCapabilities = defineCapabilities({
protocolVersion: 2,
subscribes: {
"core.user.created": {
version: 1,
schema: userCreated,
async run(envelope, context) {
console.info("User event received", {
eventId: context.eventId,
userId: envelope.data.userId,
});
},
},
},
});The publisher example belongs to an app registered as core; its local
user.created becomes core.user.created. The real core.user.created data
contains only userId (UUID) and provider (local or ipa). Read current
domain state through the owning service when a handler needs more information.
An event declaration has:
| Field | Contract |
|---|---|
version |
Positive integer describing the data contract |
title, description |
Human-readable meaning of the fact |
subject |
Local Type key identifying what the event concerns |
place |
Optional local Type key with a declared reader |
schema |
Zod schema for the event's data |
automation |
Optional false to exclude the event from automation discovery; the catalog defaults to true |
Producer data schemas must have a closed object root and closed nested objects
(for example, z.strictObject). Strings need maxLength through .max(n);
UUID strings have an implicit 36-character bound. Arrays need maxItems
through .max(n). Finite enums and literals are supported; schema references,
recursive schemas and open records are rejected. Schemas must preserve their
JSON shape. The complete envelope
must fit in 4096 bytes, measured as PostgreSQL jsonb::text, including
separator spaces. Startup rejects unbounded declarations and required minimum
data that cannot fit with a UUID-reference envelope. A schema may still allow
individual values that exceed the remaining envelope budget; publishing checks
the exact complete envelope inside the transaction and rejects those values.
Store large content in the owning application's durable
storage and publish only the small fact needed to react.
A subscription declares the exact version, its Zod schema, and
run(envelope, context). The framework parses data with that subscriber's
schema before invoking the handler; envelope.data is inferred as
z.output<typeof schema>. Its stable identity is
<subscriber-app>:<full-event-type>@<version>, for example
filesv2:core.user.created@1. Inspect the current catalog and subscription
status rather than assuming that a declaration is deliverable.
Discover event declarations
cld capabilities catalog --json and --jsonl include event
and subscription manifests unchanged. Each app also has an events catalog
with full event, subject and place type IDs, version, title, description,
automation flag, data JSON Schema and matching subscribers. Subscriber states
and firstSeenAt come from Core's persisted subscription records. Publisher
and subscriber discovery use the capability catalog's app access filter,
including subscribers outside the current publisher page.
Administrators can use cld events ls for the persisted event catalog,
including declarations of offline publishers. Its table shows the place type
(— for service events) and automation flag (localized yes/no). Filter by
publisher with --app <appId> and by metadata with --automation true|false;
combine them to narrow the result. --json and --jsonl retain
the full filtered { events: [...] } response, including schemas and subscribers.
MCP clients use two read-only tools on the Cloud MCP server:
cloud__events__listaccepts optionalappId, booleanautomation,cursor, andlimit(1–100, default 100). It returns{ events, nextCursor? }. Each event containsappId(publisher),type,version,title,description,subject,place(full Type ID ornull), andautomation.cloud__events__readaccepts the exact{ type, version }from discovery. It returns{ event }with those fields plusdataSchema(JSON Schema) andsubscribers(app ID, subscription ID, state, reason, and optionalfirstSeenAt).
MCP discovery uses the same live, hash-checked declarations and access filter
as the capability catalog. It needs OAuth read or admin scope; scopes do
not expand app access. Hidden apps and their subscriptions are omitted. An
unknown, hidden, or stale declaration returns EVENT_NOT_FOUND on read. A
catalog lookup failure returns APP_UNAVAILABLE.
List pages also respect the MCP response byte limit and may contain fewer
entries than requested. Pass nextCursor unchanged with the same filters until
it is absent. A read whose schema and subscriber list exceed the result budget returns
RESPONSE_TOO_LARGE.
These tools discover declarations only. They do not watch events, create
subscriptions, or install Assistant Routines. A declared place and
automation: true remain catalog metadata; they do not enable resource-event
delivery or grant authority.
Read the envelope
Every delivery carries this shape:
{
"id": "11111111-1111-4111-8111-111111111111",
"type": "core.user.created",
"version": 1,
"source": "core",
"time": "2026-01-01T00:00:00.000Z",
"subject": { "type": "core.user", "id": "22222222-2222-4222-8222-222222222222" },
"cause": { "requestId": "fixture-request" },
"data": { "userId": "22222222-2222-4222-8222-222222222222", "provider": "local" }
}id is a UUID, time is an ISO timestamp, and source is the publishing app
ID. subject.type and optional place.type are full Type IDs. place has
{ type, id }; cause, when present, has only requestId. A request ID is
correlation metadata, not a user's authority.
Publish in the domain transaction
Create the publisher with appEvents(capabilities) from
@k2b/cloud/events, using the exact declaration object passed to
defineApp({ capabilities }). Cloud binds that object to the registered app
ID. Calling publish before binding fails.
publish(tx, localId, input) accepts the application's transaction, a local
event key, and { subject, data, requestId? }, where subject is the subject
ID. For an event with a place, also pass its ID as place. Cloud derives both
Type IDs, the event type and version, and the source app from the declaration;
requestId becomes cause.requestId in the envelope.
import { sql } from "@k2b/cloud";
import { appEvents } from "@k2b/cloud/events";
import { publisherCapabilities } from "./capabilities";
const events = appEvents(publisherCapabilities);
export async function recordCreatedUser(userId: string, provider: "local" | "ipa") {
await sql.begin(async (tx) => {
// Perform the domain write in this same transaction.
await events.publish(tx, "user.created", {
subject: userId,
data: { userId, provider },
});
});
events.wake();
}When importing the capability handlers would create a service dependency cycle, use a type-only import and resolve the application's platform binding lazily:
import type { publisherCapabilities } from "./capabilities";
const events = appEvents<typeof publisherCapabilities>("inventory");The app ID must match defineApp; app.start({ capabilities }) installs the
binding before lifecycle startup. Publication uses that binding's real schemas,
refs, and byte validation. It rejects an unknown or unstarted app before
writing. Standalone service tests still require
createAppEventTestHarness(appId, capabilities); there is no app-owned setter.
The committed outbox row survives a process crash. Call wake() only after
the transaction commits; periodic reconciliation also picks up committed
rows. Never substitute an in-memory callback for the transactional outbox.
Make handlers safe to retry
Delivery is at least once. Use the event ID and subscription identity to make effects idempotent in the subscriber's durable data. A crash after an effect and before acknowledgement can cause the same event to run again. A manual redelivery can also repeat it.
The handler context contains subscriptionId, eventId, attempt, and an
abort signal. Delivery runs as the subscribing service; it does not inherit
a publisher request's user authority. Keep domain permission and ownership
rules in the service that performs the effect. Honor cancellation and throw
when the work fails so the delivery receives a visible retry or failure state.
Subscriptions and event declarations persist while their applications are offline. Only a live subscriber's changed manifest can remove its subscription. An unreachable subscriber waits from 30 seconds up to one hour between checks without spending a handler attempt; the 30-day delivery retention bound still applies. Connection loss after sending and request timeouts spend an attempt; eight failed attempts end the delivery. The framework answers after a 30-second handler deadline and Core waits 45 seconds. Honor the signal even when a timeout has already returned: work that ignores cancellation can still finish effects.
When a live publisher removes a declaration, Core retains its history but removes
it from the current catalog, blocks its subscriptions with event_not_declared,
and rejects new events of that type. Offline publishers retain their declarations.
Core normally ingests the shared stream serially. A never-seen declaration
gets two short registration retries (after 1 and 5 seconds), then becomes a
visible rejected event with event_not_declared; later events can continue.
Deterministically invalid envelopes and unusable stored schemas are also rejected
without retrying ingestion. Core strips unknown top-level envelope fields, so
additive metadata from a newer publisher does not block older Core instances.
Administrators inspect rejection counts and metadata with cld events status;
that response never includes payloads.
Delivery order is Core acceptance order within a subscription and subject.
A failed earlier delivery holds later deliveries for that pair. Recovered
ingestion dead letters are accepted when replayed, so they can arrive after
newer events; there is no global or event-time ordering guarantee.
For the retry schedule, retention budget, and recovery commands, see Operate app events.
Test a handler locally
Use appEventFixture and deliverAppEvent from @k2b/cloud/events/testing
to exercise the real declaration compiler and delivery validator without a
running application. A standalone declaration needs an explicit appId;
declarations already bound by defineApp use that binding. Fix the event ID
and time. Test schema rejection, a repeated event, and recovery after a failed
effect against the subscriber's real service boundary.
import { appEventFixture, deliverAppEvent } from "@k2b/cloud/events/testing";
import { publisherCapabilities, subscriberCapabilities } from "./capabilities";
const event = appEventFixture(
publisherCapabilities,
"user.created",
{
subject: "22222222-2222-4222-8222-222222222222",
data: { userId: "22222222-2222-4222-8222-222222222222", provider: "local" },
},
{
appId: "core",
id: "11111111-1111-4111-8111-111111111111",
time: "2026-01-01T00:00:00.000Z",
},
);
await deliverAppEvent(subscriberCapabilities, "core.user.created", event, { appId: "filesv2" });
await deliverAppEvent(subscriberCapabilities, "core.user.created", event, { attempt: 2 });For a terminal workflow:
cld events record core.user.created --shape-only --limit 3 --out ./user-created.jsonl
cld events replay ./user-created.jsonl --handler ./src/replay-user-created.ts#handlerecord requires --shape-only. --limit N inspects the last N accepted
stored events of that type (at most 100), then returns one synthetic fixture
per distinct shape. A shape combines version, present object keys, and array
length buckets: 0, 1, 2–4, 5–16, and 17 or more. Scalar values are excluded.
Fixture IDs are deterministic and distinct across shapes. Core synthesizes
values from the stored declaration; this command does not export retained
payload values. Unsupported synthesis returns shape_unavailable.
--out creates a new file and refuses to overwrite an existing one. Without
--out, text and --jsonl emit JSONL; --json emits one array.
replay reads the current catalog and validates each fixture's envelope,
version, Type references, and data JSON Schema before calling the selected
local function. The default export is used when #export is omitted. It
passes the envelope and a delivery context with attempt 1 and subscription
ID replay:<event-type>@<version>. Fixtures are processed sequentially; a
failure stops replay and reports how many finished.
Replay sends no fixture to Cloud and does not invoke a production subscriber.
The selected local handler controls its own effects. Its output is a count:
{ "delivered": 3 }.