contenox
Browse docs/

The event plane

The runtime’s event-dispatch layer landed in the first week of August 2026, behind the opt-in beta flag; five days later the mechanism under it was extracted into libevents, an importable Go package at the module root, and released in v0.39.0. The package is the consumer-side state of an event log. Durable cursors hold a named consumer’s position — rewinding one is the replay verb, so a consumer restarted behind its cursor re-reads exactly what it had not settled. Firing claims record which (trigger, event) pairs have already run: a claim is one conflict-ignoring INSERT against the primary key, never a select-then-insert, so at-most-once is the primary key’s guarantee rather than a race the caller wins. Listener subscriptions carry per-event-type context filters — correlation lives as data on the subscription, this session’s event rather than any event of the type — and every subscribed type becomes a topic row, so fan-out is an indexed lookup instead of a scan of all listeners. Staged events hold a payload back until a due time, drained into the ordinary append path in the same transaction that deletes the staged row — schedules and timeouts on the event rails instead of a second scheduler. What the package deliberately does not carry is the log itself: each importer already has one, shaped for its own retention. What must not fork between importers is the consumer semantics — the claim, the takeover, the reversible outcome row — because a consumer written against one definition must behave identically against the other.

The subtlest decision is a constructor argument. A claim whose host died mid-run must eventually be taken over — the takeover is a second conditional UPDATE whose freshness predicate is re-evaluated under the row’s write lock, so of two racing hosts only one can observe it true — but how stale is stale enough is not the package’s to know. The bound must exceed the caller’s longest legitimate run: an SMTP send’s timeout, an agent chain’s turn ceiling. So libevents takes it as a required parameter with no default, because a copied constant rots when the workload it was derived from changes — and the tree is its own honest example. The in-tree bound sat at 30 minutes until the extraction forced the derivation to be written down: the runtime’s hard ceiling on a single agent turn is one hour, the shortest useful fired chain is judgment plus actuation — two turns — and 2 × 1h is the first bound no live firing can reach. The constant became two hours, carrying its derivation as the comment and a standing instruction to re-derive it whenever the turn deadline moves. Overshooting costs only how long a dead host’s firing waits for its retry; undershooting steals a slow but living firing and executes it twice. Settled outcomes stay reversible: the package carries the operator’s retry verb, turning one settled firing back into a reclaimable row — backdated past the bound so the next claim takes it immediately, while the original timestamp keeps dating the first attempt — and it refuses to touch a running row still inside the bound, so a live run cannot be stolen by an impatient retry.

The layer this mechanism serves is operator-authored end to end. Triggers are trigger-*.json files, discovered like every other system file — workspace first, home fallback — binding one exact event type to a named task chain. A trigger grants timing, never capability: the fired chain runs under a human-in-the-loop policy envelope, exactly as if it had been started by hand. Events carry a hop count, a fired chain stamps hop+1 on the events it causes, and an event past the hop budget is refused rather than fired — recorded as refused on its firing row — so a trigger listening on the consequences of its own chain dies out after a few generations instead of looping forever. Live and catch-up dispatch write the same claims table: a host that runs an engine fires matching triggers the moment it appends, the standalone dispatcher catches up on everything appended while nothing ran, and the overlap between them dedups structurally through the same firing rows. contenox events firings is the observability verb — not what was appended, but what was actually done with it: dispatched, failed, refused, or claimed by a host that died before it could record an outcome. And reading firings never appends an event and never fires a trigger, an invariant stated in the code rather than assumed: the tool an operator reaches for during an incident must not be able to amplify the incident.

None of this stops at the machine’s edge, and none of it needs an inbound port to cross it. A runtime paired to a relay holds one outbound HTTPS connection — it dials, nothing in the wire package listens, and the endpoint is configuration rather than a hostname baked anywhere. A client attached through that relay is served by the same agent factory as a local session, an approval raised by that work is routed back to the device that started it, and the events the session appends land on the same durable log and fire the same triggers under the same claims. That is the capability as it exists in code today: work arriving over HTTP becomes durable events on the rails everything local uses, and chains start on this machine because something the paired device did matched a trigger — one definition of the claim, the takeover, and the outcome, however the work arrived.

This is the fifth time this lineage has built eventing, and the first time the consumer side was built at all. The April 2025 MVP ran a live pubsub bus between its services; a later event system — sources, mappings, a bridge, a store — was designed, shipped, and eventually deleted whole from this tree’s history; the hosted platform era got as far as a live dispatch handler whose contracts survive here by name in the code — a firing must never delay or abort the append, one tenant’s triggers never fire on another’s events, tenant since become workspace; and the runtime this repo ships has carried its own in-process live bus all along, now demoted to the nudge the durable log reconciles. Every generation got the storage right and stopped there: events were appended durably, published live, and never consumed with a durable outcome, a resumable position, or a reversible terminal state. Those three are exactly what libevents is — which is why the extraction took the consumer side and left the log behind.

What it proved

  • At-most-once can be a schema property instead of a protocol. One conflict-ignoring INSERT against a primary key is the entire claim; of two racing hosts the database admits exactly one, with no lock manager, no lease service, and nothing to coordinate but the table itself.
  • A required parameter is honest where an exported constant would lie. The stale-claim bound must come from the importer’s own longest legitimate run — and writing that derivation down moved the tree’s own bound from 30 minutes to 2 hours on the spot. A copied constant would have carried the wrong value silently.
  • Observation must never append. Listing firings cannot fire a trigger or write an event, so the diagnostic surface cannot amplify the incident it is diagnosing.
  • Delay belongs on the event, not in a scheduler. A staged payload with a due time, drained into the ordinary append path in one transaction, puts schedules and timeouts on the same rails as everything else — no second scheduler to build, operate, or keep consistent.
  • Timing and capability stay separable. A trigger decides only when a chain starts; what the chain may do is still its tool allowlist and its policy envelope, exactly as if a human had started it. Autonomy grew without the permission model moving.

Esc to close