phase
phase() and system() are the two execution wrappers. They are the same function at
different nesting depths: each brackets its callback with telemetry so the shape of a
thought is recorded as it happens rather than reconstructed afterward.
function phase<T>(name: string, fn: () => Promise<T>): Promise<T>
function system<T>(name: string, fn: () => Promise<T>): Promise<T>
Both are ambient globals, both return whatever the callback returns, both rethrow. Neither swallows.
The three levels
tick driven for you — the host wraps every loop iteration
└─ phase a named stage you declare
└─ system a unit of work inside it
loop(async ({ stop }) => {
const messages = await phase("render", async () => {
const knowledge = await system("catalogue", () => kernel.knowledge.list())
return air.render({ base: await kernel.base(), state: knowledge })
})
const done = await phase("invoke", async () => { /* ... */ })
if (done) stop()
})
tick you never call — the host wraps every loop iteration in one and advances the
counter. phase sets the current phase for its duration; system reads it and stamps
against it.
Which one to reach for
A phase is a stage of thinking. Sense, render, invoke, act. There are a handful per tick and their names are the vertebrae of the loop — a reader who knows only your phase names knows the shape of your cognition.
A system is a unit of work. An embed, a catalogue read, a fan-out. There may be many per phase, and their names are what you read when a phase is slow and you need to know which part.
The mechanical difference is small: phase sets clock.phase for its duration and clears
it after; system does not, and instead carries the enclosing phase in its payload.
system outside a phase is legal — phase is then null.
Both are wake-scoped. Calling either outside a loop body throws
COGNET_ACCESSED_BEFORE_LOAD — they resolve the current wake's clock, and load, boot and
shutdown carry none.
What they emit
Each level is a four-state span on CognetEventMap:
| Event | Payload |
|---|---|
cognet:tick:start | { tick } |
cognet:tick:complete | { tick, durationMs } |
cognet:tick:failed | { tick, error, durationMs } |
cognet:tick:interrupted | { tick } |
cognet:phase:start | { tick, phase } |
cognet:phase:complete | { tick, phase, durationMs } |
cognet:phase:failed | { tick, phase, error, durationMs } |
cognet:phase:interrupted | { tick, phase } |
cognet:system:start | { tick, phase, system } |
cognet:system:complete | { tick, phase, system, durationMs } |
cognet:system:failed | { tick, phase, system, error, durationMs } |
cognet:system:interrupted | { tick, phase, system } |
The bracket identity is in the payload — tick number, phase name, system name. That is what lets a reader pair a start with its own end when siblings of the same stem appear in one run.
system.phase is string | null. The three levels nest but do not require each other.
Interrupted is a third outcome
await phase("invoke", async () => {
// user hits Escape mid-stream
})
// → cognet:phase:interrupted, and the cause rethrows unchanged
When the wake's signal is aborted, whatever fn() threw is cancellation surfacing
through the call stack, not a bug in the phase's own work. Emitting :failed for that
would conflate a cancelled thought with a broken one, and telemetry full of meaningless red
is telemetry nobody reads.
Real failures still emit :failed and rethrow, wrapped as a structured AxonError.
Telemetry never swallows — the wrapper is a bracket, not a catch.
The distinction is checked against the signal at the moment of the throw, so it is the wake's actual state that decides, not a guess from the error's shape.
Naming is instrumentation
Phase and system names are the axis of every flame graph, every timing breakdown, every "where did this tick go". They are the one place a cognet author writes something the devtools read directly.
Name the stage, not the code. "render" and "invoke" survive a refactor;
"buildMessagesFromEntries" does not. Keep them stable across ticks — a name that varies
per call fragments the aggregate and makes the graph unreadable.
Do not interpolate identifiers into names. The identity goes in the payload; the name is the category.
Fire-and-forget, durably
The emit calls are never awaited. void emit(...) — a phase does not pay latency to be
observed.
Durable in the machine regardless: kernel.emit commits
each to the session's log and forwards it to the runtime bus. Flame-graph and devtools
material, never rendered to the user.
The world clock lives in cognet:*, not kernel:*. Since cognition moved into the cognet
layer, ticks and phases are the brain's own clock — the kernel neither knows nor cares
that a brain ticks.
What is not yours
The durable record around a wake stays the kernel's: kernel:run:*, engine calls,
cognet:load / cognet:unload, and the knowledge mutation events.
cognet:load and cognet:unload are the one family in CognetEventMap the brain does not
produce. Exec'ing an untrusted artifact is the most failure-prone step in boot, and a
cognet that dies inside load() cannot narrate its own failure — whatever bracket it
opened would never close. The kernel is the only loader, so it is the only thing positioned
to record both halves honestly.