From c1657a9083e031a99be4cc5aab408fbcc5cc11b9 Mon Sep 17 00:00:00 2001 From: Miya Date: Thu, 17 Sep 2026 03:33:45 +0200 Subject: [PATCH 1/5] =?UTF-8?q?docs(spec):=20body-level=20`on`=20=E2=80=94?= =?UTF-8?q?=20buffered=20subscriptions=20with=20idle/deadline=20bounds=20(?= =?UTF-8?q?#432)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Specify how a running flow subscribes to events after it starts, keeps events that arrive while a step runs, and ends a wait when events stop. The motivating case is a flow that babysits the pull request it opened: there is no "no more feedback" signal, so a flow ends on provider state and uses idle/deadline time bounds only as a backstop. Adds docs/EVENT-AWAIT.md and points to it from SURFACE.md §6, kernel DESIGN.md §1.4 (noting wait.event is not yet produced and timeout_at_ms is not enforced), and RFC-0001 §7 open questions. Spec only; no code. Co-Authored-By: Claude Opus 5 (1M context) Session-Id: 6c4b0b76-e06f-498b-aa62-2ac0700fe146 --- docs/EVENT-AWAIT.md | 225 +++++++++++++++++++++ docs/RFC-0001-everything-is-a-relayflow.md | 1 + docs/SURFACE.md | 1 + kernel/DESIGN.md | 6 + 4 files changed, 233 insertions(+) create mode 100644 docs/EVENT-AWAIT.md diff --git a/docs/EVENT-AWAIT.md b/docs/EVENT-AWAIT.md new file mode 100644 index 00000000..72db201c --- /dev/null +++ b/docs/EVENT-AWAIT.md @@ -0,0 +1,225 @@ +# Awaiting events inside a run + +*Companion to RFC-0001 (settled decision 13) and `docs/SURFACE.md`. Status: +proposed, 2026-09-17. Governs the resident verb `on` when it is used inside a +running flow body rather than as the flow's entry condition.* + +## 1. The problem + +A flow that opens a pull request is not finished when the PR exists. CI fails, +reviewers comment, the base branch moves. The work is "babysit this PR until +it is merged, closed, or handed to a human" — and nobody can know in advance +when the last piece of feedback will arrive. + +RFC-0001 already names the shape: *"No process runs between events: the +handler wakes, executes to its next await, parks"* (SURFACE.md §1), durable +awaits are first-class (RFC §1 comparison table), and an epoch summary carries +"active waits" (decision 8). What is not specified is: + +1. how a body subscribes to events *after* it has started, +2. what happens to events that arrive while the body is busy running a step, +3. how a wait ends when the events simply stop coming. + +The third is the babysitting question. There is no signal for "no more +feedback". A flow ends on **state** (the PR is merged, closed, or ready) and +uses **time** only as a backstop. This document makes both expressible. + +"Babysit" here is the PR-maintenance job, not what covenant 3 forbids: a flow +watching its own PR runs toward a declared end (`merged`, `closed`, a declared +human gate, or a stated time bound) and never stops to ask permission for work +inside its scope. + +## 2. What exists today + +Verified against `main` at `85e7e372`: + +- `kernel/DESIGN.md` §1.4 defines `wait.event` with `wait_id`, an exact-match + `event_key`, and a nullable `timeout_at_ms`; §1.7 defines `wait.completed` + with `timeout` among its reasons. +- `relayflowd-core/src/state.rs` folds `wait.event` into `StepState::Waiting`, + and `relayflowd/src/engine/remote.rs` `emit_event` closes every open wait + whose `event_key` matches. +- **No step produces `wait.event`.** Only `wait.human` is appended (manual + recovery, `machine/recovery.rs`). +- **`timeout_at_ms` is never read.** A wait with a timeout would wait forever. +- **An event with no open wait is dropped.** `emit_event` returns + `matched: 0` and journals nothing, so an event that lands while the body is + running a fix step is lost. +- **The authored `Ctx` has no `on`.** `packages/surface/src/context.ts` + exposes `human`, `dispatch`, and `done`; the authored executor throws + `unsupported_verb` for `human` and `dispatch` (#400). + +## 3. Surface + +`on` keeps its meaning — "these events matter to this flow" — and gains a +second position. At the top level it is an entry condition (gate 2). Inside a +body it opens a **subscription**: a durable, buffered cursor over matching +events for the rest of the run. + +```ts +export default flow("implement-and-babysit", async (f, input) => { + const pr = await f.agent("implementer", { task: input.issue.title }); + + const activity = f.on(github.pullRequest(pr.repo, pr.number).activity(), { + settle: "2m", // coalesce a burst (a review with 12 comments) into one wake + idle: "72h", // no matching event for 72h ends the wait + deadline: "14d" // hard cap from the moment the subscription opened + }); + + for (let round = 1; round <= 10; round++) { + const state = await f.github.pullRequest(pr).read(); // re-read, never trust the event + if (state.merged || state.closed) return f.done("success"); + if (isReadyAtHead(state)) return f.done("needs_human"); + + if (needsWork(state)) { + await f.agent("babysitter", { task: renderFixTask(state) }); + continue; // re-read before waiting + } + + const wake = await activity.next(); // parks; no process runs + if (wake.kind !== "events") { + await f.github.issue(input.issue).comment(stalledNotice(pr, wake.kind)); + return f.done("needs_human"); + } + } + return f.done("needs_human"); // round cap reached +}); +``` + +The subscription is closed by `activity.close()`, by `done()`, or by run +cancel. It is never left open past the run. + +### `next()` result + +```ts +type Wake = + | { kind: "events"; events: readonly EventFrame[]; offset: number } + | { kind: "idle" } // idle budget elapsed with no matching event + | { kind: "deadline" }; // subscription deadline reached +``` + +`events` is every event buffered since the previous `next()`, in arrival +order, after `settle` has elapsed with no newer arrival. The flow decides what +a timeout means; the kernel never turns one into a failure. + +### Options + +| option | default | meaning | +|---|---|---| +| `settle` | `0` | after the first buffered event, wait until no further event has arrived for this long before waking; bounded by `idle` and `deadline` | +| `idle` | required | per `next()`: wake with `idle` if no event has been buffered for this long, measured from the later of subscription open and the previous wake | +| `deadline` | required | absolute cap fixed when the subscription opens; every later `next()` wakes with `deadline` at that instant, however recently events arrived | +| `includeSelf` | `false` | deliver events caused by this run's own identity (gate 8) | + +`idle` and `deadline` are required on purpose. A subscription with no end is +a run with no end, and covenant 2 (no unexpected failures, enforced by +preflight) means the author states the bound. `flows check` refuses a +body-level `on` without both, with `unbounded_subscription`. + +## 4. Semantic laws + +1. **Level-triggered, not edge-triggered.** An event is a wake-up, not the + truth. The body re-reads provider state through the mount after every wake + and before every wait. Payloads are late, reordered, and metadata-only; + state decides. Garden's babysitter learned this the hard way and re-reads + PR state itself on every wake. +2. **Nothing matching is lost while the subscription is open.** Events that + arrive while the body runs another step are buffered and delivered by the + next `next()`. +3. **Open, then read.** Events that happened before the subscription opened + are not delivered. A body closes that gap by opening the subscription and + then reading state, which law 1 already requires. +4. **Delivery is at-least-once and deduplicated by provider delivery id.** A + redelivered webhook appends nothing. +5. **Self-caused events are filtered by default.** A babysitter's own push + emits `pull_request.synchronize`; delivering it would wake the flow to + react to itself. The router compares the event actor with the run's + identity. Cloud's integration-watch dispatcher already applies this guard to + PR reviewer personas. +6. **Time is journaled, not recomputed.** Each `next()` journals the absolute + instants it will wake at. Resume re-arms those instants against the real + clock (kernel DESIGN §3 step 4); an instant that passed while the cell slept + fires immediately. +7. **Waiting is free.** A parked `next()` holds no lease, no sandbox, and no + process, and spends zero tokens. `deadline` is the cost bound; the author's + loop cap bounds rounds of agent work. +8. **A subscription is not a trigger.** It never starts a run, and it does not + participate in trigger-plane liveness (`staleAfterMs`). Silence on a + subscription is an expected outcome surfaced as `idle`, not a dead trigger. + +## 5. Kernel (additive) + +No new step kind and no new verb. The kernel vocabulary stays closed +(decision 13). + +1. **`subscription.opened`** — `subscription_id` (deterministic from run id, + step id and declaration), `event_types`, `pattern` (the recursive-subset + match already used by `TriggerSpec.pattern`), `stream` + (`subscription/`), `deadline_at_ms`, `include_self`. +2. **`subscription.closed`** — `subscription_id`, `completionReason` + (`closed` \| `run_completed` \| `canceled` \| `deadline`). +3. **Buffered delivery** — matching events become `stream.appended` on the + subscription's stream, carrying the provider delivery id as the idempotency + key. Events for a closed or unknown subscription are refused, not buffered. +4. **`wait.event` extension** — alongside `event_key`, a wait may name + `stream`, `from_offset`, `settle_ms`, `idle_at_ms`, and `deadline_at_ms`. + It completes with `event_received` and `result: { from_offset, next_offset }` + once the stream has entries at or past `from_offset` and `settle_ms` has + passed since the newest of them; otherwise with `timeout` and + `result: { timeout: "idle" | "deadline" }` at the earlier of the two + instants. Adding result fields keeps `wait.completed`'s reason enum + unchanged. +5. **Timeouts are enforced.** The scheduler arms `timeout_at_ms`, + `idle_at_ms`, and `deadline_at_ms` as durable timers for every open wait, + including `wait.human`. This closes the gap in §2 for existing waits too. +6. **Epoch summary** carries open subscriptions with their stream offsets and + deadlines alongside `open_waits`. + +## 6. Router contract (Cloud) + +The event router is Cloud's, not the kernel's (decision 15: the kernel is +tenant-unaware). + +- A `subscription.opened` entry is projected to the router as a binding of + `(run_id, subscription_id)` to its event types and pattern. It is removed on + `subscription.closed`. +- The router matches incoming `EventFrameV1` frames against open bindings, + applies the self-actor filter, and calls `stream.append` with the provider + delivery id. A matching frame for a sleeping cell wakes the cell. +- The router never decides whether the flow is done. It only delivers. + +## 7. Acceptance + +The crash-injection suite is the gate (AGENTS.md standard 5). A conforming +implementation proves: + +1. An event emitted while the body is running a step is delivered by the next + `next()`. +2. `kill -9` after `stream.appended` and before the wait completes, then + resume: the event is delivered exactly once to the body. +3. `kill -9` with an open wait whose `idle_at_ms` passes during the outage: + resume wakes with `idle` immediately and does not re-run earlier steps. +4. `idle` measures from the previous wake; `deadline` does not move when events + arrive. +5. Three events inside `settle` produce one wake carrying all three. +6. A redelivered frame with the same delivery id appends nothing. +7. An event whose actor is the run's identity is not delivered unless + `includeSelf` is set. +8. After `close()` or `done()`, a matching frame is refused and nothing is + appended. +9. A body-level `on` without `idle` or `deadline` fails `flows check` with + `unbounded_subscription`. +10. `wait.human` with `timeout_at_ms` completes with `timeout` at that instant. + +## 8. Open questions + +- **Pattern language.** v0 `wait.event` is exact-match; triggers already carry + a recursive-subset `pattern`. Leaning: reuse `pattern` for subscriptions and + keep `event_key` for exact-match waits. +- **Buffer bound.** A noisy PR could append thousands of frames between wakes. + Leaning: cap buffered frames per subscription and deliver a + `truncated: true` marker. Law 1 means the body loses nothing it needs, since + it re-reads state anyway. +- **Relationship to `f.human`.** #400 needs a durable approval wait. Leaning: + `f.human(question, { to, timeout })` lowers to `wait.human` with the timers + from §5.5, and resolves to `false` on timeout. diff --git a/docs/RFC-0001-everything-is-a-relayflow.md b/docs/RFC-0001-everything-is-a-relayflow.md index f1566750..c9697c34 100644 --- a/docs/RFC-0001-everything-is-a-relayflow.md +++ b/docs/RFC-0001-everything-is-a-relayflow.md @@ -222,6 +222,7 @@ The gates exist to be sold, not admired. The consumer list, in order of proof va - **Spec / journal / protocol versioning.** Three artifacts version independently: the **journal format** (additive-only entry fields, `journal_version` stamped per segment; readers read every past version, writers write only the newest), the **spec schema** (semver; compilers always emit latest; the kernel supports a window), and the **SDK protocol** (versioned handshake, N−1 compatibility). Proposed unifying policy: **upgrades apply only at epoch boundaries, and epochs are cheap** (decision #8) — a resident flow finishes its current epoch on the versions it started with; the next epoch opens on the new ones. Because the journal replays results, not code, an old segment ever needs only an old *reader*, never old *code* — that is the structural escape from Temporal's versioning hell. Stays open until a real kernel upgrade has been executed under a live resident run. +- **Ending a resident wait.** Decision 13 makes `on` a resident verb, but nothing says how a body subscribes after it starts, keeps events that arrive while a step runs, or ends a wait when events stop. There is no signal for "no more feedback": a flow ends on provider state and uses time only as a backstop. Proposed: body-level `on` opens a buffered subscription whose `next()` wakes with events, `idle`, or `deadline`, both bounds required at preflight — `docs/EVENT-AWAIT.md`. ## 8. What this replaces in the charter diff --git a/docs/SURFACE.md b/docs/SURFACE.md index 695dca93..d99e9673 100644 --- a/docs/SURFACE.md +++ b/docs/SURFACE.md @@ -821,6 +821,7 @@ kernel primitives. - Are YAML helper verbs (`slack:`, `mcp:`) core spec vocabulary or compile-time expansion into `run`/effect steps? Leaning: expansion — the kernel spec stays seven words; helpers stay a surface concern. - Helper generation cadence: generated from relayfile adapter manifests at build time vs published per-adapter packages. Leaning: generated, with hand-tuned verb names for the top providers. +- `on` inside a running body (subscribe after start, buffer events while a step runs, end on `idle`/`deadline`). Proposed in [`docs/EVENT-AWAIT.md`](EVENT-AWAIT.md); motivating case is a flow that babysits the PR it opened. ## 7. Broker transport covenant diff --git a/kernel/DESIGN.md b/kernel/DESIGN.md index cfa0d926..497459a3 100644 --- a/kernel/DESIGN.md +++ b/kernel/DESIGN.md @@ -83,6 +83,12 @@ Payload: Step parks on an external event. Payload: `wait_id` (ULID), `event_key` (exact-match string in v0), `timeout_at_ms` (nullable). +Proposed additive extension (`docs/EVENT-AWAIT.md` §5): buffered +subscriptions (`subscription.opened` / `subscription.closed` over a durable +stream) and stream-backed waits with `settle_ms`, `idle_at_ms`, and +`deadline_at_ms`. As of v2.0.14 no step produces `wait.event`, and +`timeout_at_ms` is not yet enforced for either `wait.event` or `wait.human`. + ### 1.5 `wait.human` Durable human await. Payload: `wait_id`, `prompt` (what is being asked), `requested_of` (identity string), `options` (nullable list), From 3c919817e4c34ac9a51fb4c2be0ee57b8dcb5920 Mon Sep 17 00:00:00 2001 From: Miya Date: Thu, 17 Sep 2026 06:07:28 +0200 Subject: [PATCH 2/5] docs(spec): settle bounded event-await decisions Session-Id: 01a09c40-ce3b-7f11-a7df-b6b7ccab6fd9 --- docs/EVENT-AWAIT.md | 93 +++++++++++++++++++++++++++++++++------------ 1 file changed, 68 insertions(+), 25 deletions(-) diff --git a/docs/EVENT-AWAIT.md b/docs/EVENT-AWAIT.md index 72db201c..ebf401cb 100644 --- a/docs/EVENT-AWAIT.md +++ b/docs/EVENT-AWAIT.md @@ -95,18 +95,27 @@ cancel. It is never left open past the run. type Wake = | { kind: "events"; events: readonly EventFrame[]; offset: number } | { kind: "idle" } // idle budget elapsed with no matching event - | { kind: "deadline" }; // subscription deadline reached + | { kind: "deadline"; pending: { from: number; to: number } | null } + | { kind: "overflow"; retained: number; bytes: number; from: number }; + // the bounded stream closed before dropping a frame ``` `events` is every event buffered since the previous `next()`, in arrival -order, after `settle` has elapsed with no newer arrival. The flow decides what -a timeout means; the kernel never turns one into a failure. +order, after `settle` has elapsed with no newer arrival. `idle` also ends a +still-active settle window: when the idle instant is reached with buffered +frames, `next()` returns that one batch as `events`, rather than extending a +burst forever. `deadline` is different: it is a hard cap and wins at or after +its recorded instant; `pending` makes any durable unread range visible rather +than silently discarding it. `overflow` closes the subscription before the +would-exceed frame is appended; the body re-reads provider state and may open +a fresh bounded subscription. The flow decides what an `idle`, `deadline`, or +`overflow` means; the kernel never turns one into a failure. ### Options | option | default | meaning | |---|---|---| -| `settle` | `0` | after the first buffered event, wait until no further event has arrived for this long before waking; bounded by `idle` and `deadline` | +| `settle` | `0` | after the first buffered event, wait until no further event has arrived for this long before waking; an `idle` instant with a non-empty batch ends settle and returns that batch, while `deadline` remains a hard cap | | `idle` | required | per `next()`: wake with `idle` if no event has been buffered for this long, measured from the later of subscription open and the previous wake | | `deadline` | required | absolute cap fixed when the subscription opens; every later `next()` wakes with `deadline` at that instant, however recently events arrived | | `includeSelf` | `false` | deliver events caused by this run's own identity (gate 8) | @@ -125,7 +134,9 @@ body-level `on` without both, with `unbounded_subscription`. PR state itself on every wake. 2. **Nothing matching is lost while the subscription is open.** Events that arrive while the body runs another step are buffered and delivered by the - next `next()`. + next `next()`. The one bounded exception is an explicit `overflow`: the + subscription closes before the would-exceed frame is accepted, and the + body receives `overflow` rather than a silently truncated batch. 3. **Open, then read.** Events that happened before the subscription opened are not delivered. A body closes that gap by opening the subscription and then reading state, which law 1 already requires. @@ -136,10 +147,13 @@ body-level `on` without both, with `unbounded_subscription`. react to itself. The router compares the event actor with the run's identity. Cloud's integration-watch dispatcher already applies this guard to PR reviewer personas. -6. **Time is journaled, not recomputed.** Each `next()` journals the absolute - instants it will wake at. Resume re-arms those instants against the real - clock (kernel DESIGN §3 step 4); an instant that passed while the cell slept - fires immediately. +6. **Time and delivery order are journaled, not recomputed.** Each `next()` + journals the absolute instants it will wake at. The router's append and the + scheduler's timer claim serialize through one per-subscription journal + order: for `idle`, an append committed before its timer claim wins and + returns the buffered batch; at an exact `deadline` tie the deadline wins. + Resume re-arms those instants against the real clock (kernel DESIGN §3 step + 4); an instant that passed while the cell slept fires immediately. 7. **Waiting is free.** A parked `next()` holds no lease, no sandbox, and no process, and spends zero tokens. `deadline` is the cost bound; the author's loop cap bounds rounds of agent work. @@ -155,20 +169,36 @@ No new step kind and no new verb. The kernel vocabulary stays closed 1. **`subscription.opened`** — `subscription_id` (deterministic from run id, step id and declaration), `event_types`, `pattern` (the recursive-subset match already used by `TriggerSpec.pattern`), `stream` - (`subscription/`), `deadline_at_ms`, `include_self`. + (`subscription/`), `deadline_at_ms`, `include_self`, and + the immutable provider binding: integration installation, canonical + resource scope, authorization snapshot, router binding generation, and + durable ingress offset. Opening is a two-party handshake: Cloud first + records the fenced binding at that ingress offset, then the journal appends + `subscription.opened`; `f.on()` is not visible to the body until both have + completed. Recovery removes a prepared binding that has no matching journal + entry, and otherwise restores the same generation and replays ingress after + its offset before acknowledging the body. This closes the journal-to-router + race without delivering frames that predate opening. 2. **`subscription.closed`** — `subscription_id`, `completionReason` - (`closed` \| `run_completed` \| `canceled` \| `deadline`). + (`closed` \| `run_completed` \| `canceled` \| `deadline` \| `overflow`). 3. **Buffered delivery** — matching events become `stream.appended` on the subscription's stream, carrying the provider delivery id as the idempotency - key. Events for a closed or unknown subscription are refused, not buffered. + key. A stream holds at most **1,000 frames or 1 MiB of encoded frame bytes**, + whichever is reached first. On a would-exceed append, the router atomically + records closure with `overflow`, removes the binding, and leaves the frame + unappended; the next `next()` returns `overflow` with the retained range. + Events for a closed or unknown subscription are refused, not buffered. 4. **`wait.event` extension** — alongside `event_key`, a wait may name `stream`, `from_offset`, `settle_ms`, `idle_at_ms`, and `deadline_at_ms`. It completes with `event_received` and `result: { from_offset, next_offset }` once the stream has entries at or past `from_offset` and `settle_ms` has - passed since the newest of them; otherwise with `timeout` and - `result: { timeout: "idle" | "deadline" }` at the earlier of the two - instants. Adding result fields keeps `wait.completed`'s reason enum - unchanged. + passed since the newest of them. An idle claim with a non-empty unsettled + batch completes the same way, so continuous arrivals cannot extend settle + forever. An exact or later deadline claim completes with `timeout` and + `result: { timeout: "deadline", pending }`, even when unread entries + exist; idle completes with `result: { timeout: "idle" }` only when no + buffered entries won the serialized race. Adding result fields keeps + `wait.completed`'s reason enum unchanged. 5. **Timeouts are enforced.** The scheduler arms `timeout_at_ms`, `idle_at_ms`, and `deadline_at_ms` as durable timers for every open wait, including `wait.human`. This closes the gap in §2 for existing waits too. @@ -180,12 +210,18 @@ No new step kind and no new verb. The kernel vocabulary stays closed The event router is Cloud's, not the kernel's (decision 15: the kernel is tenant-unaware). -- A `subscription.opened` entry is projected to the router as a binding of - `(run_id, subscription_id)` to its event types and pattern. It is removed on - `subscription.closed`. +- A `subscription.opened` entry is projected to the router as a fenced binding + of `(run_id, subscription_id, generation, ingress_offset)` to its event + types, pattern, provider installation, and canonical resource scope. It is + removed on `subscription.closed`. The open handshake records the binding and + ingress offset before the body can observe the subscription; recovery + replays ingress strictly after that offset before acknowledging the binding. - The router matches incoming `EventFrameV1` frames against open bindings, - applies the self-actor filter, and calls `stream.append` with the provider - delivery id. A matching frame for a sleeping cell wakes the cell. + first proving the frame came through the bound installation and is within + the bound resource scope. It then applies the self-actor filter and calls + `stream.append` with the provider delivery id. A user-authored pattern never + broadens that installation or resource scope. A matching frame for a + sleeping cell wakes the cell. - The router never decides whether the flow is done. It only delivers. ## 7. Acceptance @@ -210,16 +246,23 @@ implementation proves: 9. A body-level `on` without `idle` or `deadline` fails `flows check` with `unbounded_subscription`. 10. `wait.human` with `timeout_at_ms` completes with `timeout` at that instant. +11. A frame that arrives after the router binding is prepared but before the + body can observe `f.on()` is replayed from the recorded ingress offset; + a crash at either side of that handoff produces neither a ghost binding nor + a missed post-open frame. +12. The 1,001st frame or first byte beyond 1 MiB closes the subscription and + returns `overflow`; no frame is silently dropped and later matching frames + are refused until the body explicitly opens a fresh subscription. +13. A frame racing an idle timer follows the serialized append/timer order; + a non-empty batch at idle ends settle as `events`. A frame at the exact + deadline loses to `deadline`, whose result reports any durable unread + range. ## 8. Open questions - **Pattern language.** v0 `wait.event` is exact-match; triggers already carry a recursive-subset `pattern`. Leaning: reuse `pattern` for subscriptions and keep `event_key` for exact-match waits. -- **Buffer bound.** A noisy PR could append thousands of frames between wakes. - Leaning: cap buffered frames per subscription and deliver a - `truncated: true` marker. Law 1 means the body loses nothing it needs, since - it re-reads state anyway. - **Relationship to `f.human`.** #400 needs a durable approval wait. Leaning: `f.human(question, { to, timeout })` lowers to `wait.human` with the timers from §5.5, and resolves to `false` on timeout. From e271c9d10dfed4520ff88db73afd9dcb71b91798 Mon Sep 17 00:00:00 2001 From: Miya Date: Thu, 17 Sep 2026 06:14:51 +0200 Subject: [PATCH 3/5] docs(spec): define event buffer and overflow recovery Session-Id: 01a09c40-ce3b-7f11-a7df-b6b7ccab6fd9 --- docs/EVENT-AWAIT.md | 26 +++++++++++++++++++------- 1 file changed, 19 insertions(+), 7 deletions(-) diff --git a/docs/EVENT-AWAIT.md b/docs/EVENT-AWAIT.md index ebf401cb..bf6074a4 100644 --- a/docs/EVENT-AWAIT.md +++ b/docs/EVENT-AWAIT.md @@ -183,11 +183,18 @@ No new step kind and no new verb. The kernel vocabulary stays closed (`closed` \| `run_completed` \| `canceled` \| `deadline` \| `overflow`). 3. **Buffered delivery** — matching events become `stream.appended` on the subscription's stream, carrying the provider delivery id as the idempotency - key. A stream holds at most **1,000 frames or 1 MiB of encoded frame bytes**, - whichever is reached first. On a would-exceed append, the router atomically - records closure with `overflow`, removes the binding, and leaves the frame - unappended; the next `next()` returns `overflow` with the retained range. - Events for a closed or unknown subscription are refused, not buffered. + key. A stream holds at most **1,000 unread frames or 1 MiB of unread encoded + frame bytes**, measured after this subscription consumer's acknowledged + offset; consumed prefixes are eligible for normal journal compaction and do + not count against the next batch. On a would-exceed append, closure uses the + converse of the open handshake: the router first durably fences the binding + as `closing: overflow` and refuses further appends, the journal appends + `subscription.closed(overflow)`, and only then is the binding removed. If a + cell dies between those records, recovery completes the idempotent close + from the fenced binding; it never restores that generation as open. The + would-exceed frame is unappended and the next `next()` returns `overflow` + with the retained range. Events for a closed or unknown subscription are + refused, not buffered. 4. **`wait.event` extension** — alongside `event_key`, a wait may name `stream`, `from_offset`, `settle_ms`, `idle_at_ms`, and `deadline_at_ms`. It completes with `event_received` and `result: { from_offset, next_offset }` @@ -251,12 +258,17 @@ implementation proves: a crash at either side of that handoff produces neither a ghost binding nor a missed post-open frame. 12. The 1,001st frame or first byte beyond 1 MiB closes the subscription and - returns `overflow`; no frame is silently dropped and later matching frames - are refused until the body explicitly opens a fresh subscription. + returns `overflow` when that many **unread** frames or bytes are pending; + a consumer that keeps up does not overflow on lifetime volume. No frame is + silently dropped and later matching frames are refused until the body + explicitly opens a fresh subscription. 13. A frame racing an idle timer follows the serialized append/timer order; a non-empty batch at idle ends settle as `events`. A frame at the exact deadline loses to `deadline`, whose result reports any durable unread range. +14. `kill -9` after the router fences an overflow but before + `subscription.closed(overflow)` commits, then resume: recovery completes + the overflow close and never restores the prior binding as open. ## 8. Open questions From 33bdef8bf2d715a660455c49f7cf49efb6efe88e Mon Sep 17 00:00:00 2001 From: Miya Date: Thu, 17 Sep 2026 06:22:16 +0200 Subject: [PATCH 4/5] docs(spec): settle overflow wait recovery Session-Id: 01a09c40-ce3b-7f11-a7df-b6b7ccab6fd9 --- docs/EVENT-AWAIT.md | 30 +++++++++++++++++++----------- 1 file changed, 19 insertions(+), 11 deletions(-) diff --git a/docs/EVENT-AWAIT.md b/docs/EVENT-AWAIT.md index bf6074a4..e3cb43ae 100644 --- a/docs/EVENT-AWAIT.md +++ b/docs/EVENT-AWAIT.md @@ -175,10 +175,13 @@ No new step kind and no new verb. The kernel vocabulary stays closed durable ingress offset. Opening is a two-party handshake: Cloud first records the fenced binding at that ingress offset, then the journal appends `subscription.opened`; `f.on()` is not visible to the body until both have - completed. Recovery removes a prepared binding that has no matching journal - entry, and otherwise restores the same generation and replays ingress after - its offset before acknowledging the body. This closes the journal-to-router - race without delivering frames that predate opening. + completed. Recovery first honors any durable `closing: overflow` fence for + that generation: it completes the close and its active wait, never restores + or replays the binding. Absent that fence, recovery removes a prepared + binding that has no matching journal entry, and otherwise restores the same + generation and replays ingress after its offset before acknowledging the + body. This closes the journal-to-router race without delivering frames that + predate opening. 2. **`subscription.closed`** — `subscription_id`, `completionReason` (`closed` \| `run_completed` \| `canceled` \| `deadline` \| `overflow`). 3. **Buffered delivery** — matching events become `stream.appended` on the @@ -188,13 +191,17 @@ No new step kind and no new verb. The kernel vocabulary stays closed offset; consumed prefixes are eligible for normal journal compaction and do not count against the next batch. On a would-exceed append, closure uses the converse of the open handshake: the router first durably fences the binding - as `closing: overflow` and refuses further appends, the journal appends + as `closing: overflow` and refuses further appends. The same serialized + transaction settles any open `wait.event` with + `event_received` / `result: { wake: "overflow", retained, bytes, from }`; + the surface maps that terminal result to `Wake.overflow`, so no later settle, + idle, or deadline claim can win it. The journal then appends `subscription.closed(overflow)`, and only then is the binding removed. If a - cell dies between those records, recovery completes the idempotent close - from the fenced binding; it never restores that generation as open. The - would-exceed frame is unappended and the next `next()` returns `overflow` - with the retained range. Events for a closed or unknown subscription are - refused, not buffered. + cell dies between those records, recovery completes the idempotent close and + wait settlement from the fenced binding; it never restores that generation + as open. The would-exceed frame is unappended and the next `next()` returns + `overflow` with the retained range. Events for a closed or unknown + subscription are refused, not buffered. 4. **`wait.event` extension** — alongside `event_key`, a wait may name `stream`, `from_offset`, `settle_ms`, `idle_at_ms`, and `deadline_at_ms`. It completes with `event_received` and `result: { from_offset, next_offset }` @@ -268,7 +275,8 @@ implementation proves: range. 14. `kill -9` after the router fences an overflow but before `subscription.closed(overflow)` commits, then resume: recovery completes - the overflow close and never restores the prior binding as open. + the overflow close and any open `next()` as `overflow`; it never restores + the prior binding as open or re-arms its settle, idle, or deadline timer. ## 8. Open questions From 5fa199cc3929d45fb64e9f764bf3729ad390cca3 Mon Sep 17 00:00:00 2001 From: Miya Date: Thu, 17 Sep 2026 06:30:24 +0200 Subject: [PATCH 5/5] docs(spec): serialize overflow closure with waits Session-Id: 01a09c40-ce3b-7f11-a7df-b6b7ccab6fd9 --- docs/EVENT-AWAIT.md | 47 +++++++++++++++++++++++++++++---------------- 1 file changed, 30 insertions(+), 17 deletions(-) diff --git a/docs/EVENT-AWAIT.md b/docs/EVENT-AWAIT.md index e3cb43ae..b80976ae 100644 --- a/docs/EVENT-AWAIT.md +++ b/docs/EVENT-AWAIT.md @@ -148,10 +148,15 @@ body-level `on` without both, with `unbounded_subscription`. identity. Cloud's integration-watch dispatcher already applies this guard to PR reviewer personas. 6. **Time and delivery order are journaled, not recomputed.** Each `next()` - journals the absolute instants it will wake at. The router's append and the - scheduler's timer claim serialize through one per-subscription journal - order: for `idle`, an append committed before its timer claim wins and - returns the buffered batch; at an exact `deadline` tie the deadline wins. + journals the absolute instants it will wake at. Router appends, scheduler + timer claims, and overflow-close commands serialize through one + per-subscription journal order. For `idle`, an append committed before its + timer claim wins and returns the buffered batch; at an exact `deadline` tie + the deadline wins. A router overflow fence blocks new external appends but + does not settle a wait itself: it submits the overflow-close command to this + same sequencer. If a normal completion already committed first, it is + delivered once and the following `next()` observes overflow; once the + overflow command commits, no later settle, idle, or deadline claim can win. Resume re-arms those instants against the real clock (kernel DESIGN §3 step 4); an instant that passed while the cell slept fires immediately. 7. **Waiting is free.** A parked `next()` holds no lease, no sandbox, and no @@ -191,17 +196,19 @@ No new step kind and no new verb. The kernel vocabulary stays closed offset; consumed prefixes are eligible for normal journal compaction and do not count against the next batch. On a would-exceed append, closure uses the converse of the open handshake: the router first durably fences the binding - as `closing: overflow` and refuses further appends. The same serialized - transaction settles any open `wait.event` with - `event_received` / `result: { wake: "overflow", retained, bytes, from }`; - the surface maps that terminal result to `Wake.overflow`, so no later settle, - idle, or deadline claim can win it. The journal then appends - `subscription.closed(overflow)`, and only then is the binding removed. If a - cell dies between those records, recovery completes the idempotent close and - wait settlement from the fenced binding; it never restores that generation - as open. The would-exceed frame is unappended and the next `next()` returns - `overflow` with the retained range. Events for a closed or unknown - subscription are refused, not buffered. + as `closing: overflow` and refuses further appends. It then submits an + overflow-close command to the same per-subscription journal sequencer as + append and timer claims. When that command wins, it atomically settles any + open `wait.event` with `event_received` / + `result: { wake: "overflow", retained, bytes, from }`, appends + `subscription.closed(overflow)`, and makes the surface return + `Wake.overflow`; only then is the binding removed. A normal completion that + committed before the close command remains valid once, and the following + `next()` observes the closed overflow state. If a cell dies between these + records, recovery submits/completes the same idempotent close command from + the fenced binding; it never restores that generation as open. The + would-exceed frame is unappended. Events for a closed or unknown subscription + are refused, not buffered. 4. **`wait.event` extension** — alongside `event_key`, a wait may name `stream`, `from_offset`, `settle_ms`, `idle_at_ms`, and `deadline_at_ms`. It completes with `event_received` and `result: { from_offset, next_offset }` @@ -275,8 +282,14 @@ implementation proves: range. 14. `kill -9` after the router fences an overflow but before `subscription.closed(overflow)` commits, then resume: recovery completes - the overflow close and any open `next()` as `overflow`; it never restores - the prior binding as open or re-arms its settle, idle, or deadline timer. + the idempotent overflow-close command. It never restores the prior binding + as open; a normal wait result committed before that command remains valid + once, while an open or later `next()` receives `overflow` and no later + settle, idle, or deadline claim can win. +15. An overflow close, a stream append, and a timer claim submitted + concurrently are serialized in one journal order. The single earlier + normal completion, if any, is preserved; after overflow commits, the + subscription cannot produce another normal wake. ## 8. Open questions