15 KiB
issue, status, last_updated
| issue | status | last_updated |
|---|---|---|
| https://github.com/vercel/eve/issues/1765 | superseded | 2026-09-11 |
Versioned wire schema for the session inbox
Superseded by single-workflow sessions. The implementation uses one inbox protocol with no negotiation or migration chain. The proposal below records the previous deployment model, not current guidance.
Purpose
Payloads resumed into session inbox hooks outlive the deployment that wrote
them, but their shape is currently an emergent property of whatever TypeScript
type flows into resumeHook. A shared type is not a wire contract: both sides
recompile together while pinned deployments keep executing the old decode.
That gap produced two silent-loss incidents from one refactor (#1586 →
#1751): consumers on eve ≤0.30.4 require the legacy deliver envelope,
while consumers on 0.30.5–0.31.0 require the raw send command during an
active turn.
This plan gives the session inbox a declared, versioned wire schema: one
module owns every shape that crosses the hook, encoding and decoding are the
only legal boundary operations, and an undecodable payload fails loudly
instead of being reinterpreted. Other durable hook payloads
(turn-control, subagent proxies, runtime-action-result) migrate in
follow-ups once this pattern is proven.
In this document, must, should, and may are normative.
Authoring API
No new framework. Version walking reuses runMigrationChain
(execution/durable-session-migrations/chain.ts), exactly as the durable
session snapshot and turn-workflow input already do. The complete current
wire value is defined by one zod schema in a server/step-only encoder module;
the workflow-safe decoder imports only its inferred type. The session inbox
is the first family; turn-control, the subagent proxies, and the auth-hook
delivery adopt the same split in follow-ups.
// Dependency-free contract imported by encoder and decoder.
export const SESSION_INBOX_WIRE_VERSION = 1;
export type SessionInboxWireTarget =
| { version: 0; variant: "deliver" | "send" }
| { version: 1 };
// One append-only module per shipped version:
// execution/wire/session-inbox-wire.v1.ts (server/step only)
const deliverPayloadSchema = z.object({
context: z.array(z.string()).optional(),
inputResponses: z.array(inputResponseSchema).optional(),
message: userContentSchema.optional(),
outputSchema: jsonObjectSchema.optional(),
}).loose(); // explicit adapter extension point
export const sessionInboxWireV1Schema = z.discriminatedUnion("kind", [
z.object({ kind: z.literal("deliver"), payloads: z.array(deliverPayloadSchema), ... }),
...controls,
]);
export type SessionInboxWireV1 = z.infer<typeof sessionInboxWireV1Schema>;
encodeSessionCommandV1(input) // build → parse complete value once → persist
// execution/wire/session-inbox-wire.v0.ts (dependency-free, temporary)
export const sessionInboxWireV0Migration: VersionMigration = { from: 0, to: 1, migrate };
// Server/step-safe encoder facade selects the target consumer's version.
sessionInboxWire.encode(input, target)
// Workflow-safe decoder: execution/wire/session-inbox-wire.ts
import type { SessionInboxWireV1 } from "./session-inbox-wire.v1.js";
export class SessionInboxWireError extends Error { ... }
const sessionInboxMigrations: readonly VersionMigration[] = [{ from: 0, to: 1, migrate }];
sessionInboxWire.decode(value) // runMigrationChain (initialVersion: 0) → version/kind → trust → normalize
Normative rules:
- The current version has exactly one declared shape. Changing it
must be a new version: bump the constant, add a one-step
VersionMigration, freeze the new shape. Historic versions live on as executable migrations plus frozen payload fixtures (the turn-workflow precedent), not as retained schemas. - Protocol data and migration policy stay separate. A
*.vN.tsmodule owns the immutable schema and version-bound encoder. A*.vN.migration.tsmodule is a pure data transform with no normalization or version-selection dependencies. The encoder and decoder facades own mutable policy: selecting a target, assembling the chain, and normalizing values received from another Workflow VM realm. - The complete transported value is validated once, at encode. The
schema owns the envelope and every eve-owned
DeliverPayloadfield, composing the existing strictinputResponseSchemaandjsonObjectSchema; adapter-specific payload fields are the explicit open extension point. The inferred schema type is the wire type, so runtime validation and TypeScript cannot drift. - Zod stays outside the workflow driver bundle. Every producer is
server-side or a
"use step"body; in the driver bundle those steps are dependency-free stubs. The decoder importsSessionInboxWirewithimport typeand the numeric version from a dependency-free contract module, so zod never enters the self-contained/base64-embedded driver. - Version 0 is the unversioned era (
initialVersion: 0): every shape the family persisted before payloads carriedversion, following the field name every persisted eve structure already uses. - Decode trusts a known current version because that trust is earned by
the single encoder.
runMigrationChainrejects unknown newer versions; known v1 values were produced only by the schema-validating encoder. The decoder checks version and kind, then normalizes away wire-only fields. Legacy v0 is the temporary exception: those writers predate the encoder, so its 0→1 migration defensively checks the historicsend/deliverfields until that cohort ages out under the 30-day timeout. - Encode goes through the wire module too. Every persisted inbox payload — sends and controls alike — is built and validated against the current schema before it persists, so producer drift dies at the producer instead of at a pinned consumer weeks later.
- Per family,
sessionInboxWire.encodemust be the only producer of persisted payloads andsessionInboxWire.decodethe only consumer-side interpretation.
Version signals and their semantics
Two complementary signals, each covering the other's blind side:
producer ──getHookByToken──▶ hook.metadata.sessionInboxWireVersion
(what can they decode?)
producer ──resumeHook──────▶ { version: N, ... } (what did I send?)
consumer ──decode──────────▶ known version → typed payload
unknown version → loud failure, session stays alive
- Envelope
version(consumer-facing). Every versioned payload carriesversion. Decode of an unrecognizedversionmust not fall through to any legacy interpretation: it raisesSessionInboxWireError, which the driver surfaces through a recorded reporting step (a durable trace in the run's event log plus a structured error log — the driver body cannot log directly, since logging pulls Node builtins the workflow bundle rejects), then keeps the session parked. A lost delivery with an operator-visible signal is the designed failure; a reinterpreted delivery is the bug this plan removes. A channel-visible error event may be added when the pattern generalizes beyond the session inbox. - Hook metadata stamp (producer-facing). Consumers stamp
metadata: { sessionInboxWireVersion }atcreateHook. The producer reads the capability marker and passes the resulting target tosessionInboxWire.encode. - Markerless historical classification. Version 0 had two incompatible
shapes. A markerless stable session inbox, or a markerless continuation hook
whose run owns that stable inbox, receives raw
send. That shape is required by eve 0.30.5–0.31.0 and remains accepted by later pre-stamp stable-inbox consumers. A markerless continuation without the stable inbox identifies eve ≤0.30.4 and receives legacydeliver. This tests a concrete historical capability rather than guessing from deployment or package metadata. - Saved receiver addresses. The pinned driver advertises its canonical
session ID and wire version in serialized context. Local subagent input
requests save that address with their reply route. A later producer can
encode for the original receiver without reading hook metadata, even if a
newer deployment handles the reply or the continuation alias changes.
Workflow-tool relays carry the stable inbox token in their existing reply
field because input answers fit the unversioned
sendcontract. - Cost boundary. Saved receiver addresses and compatible commands to
stable inbox tokens skip metadata negotiation and use token-based
resumeHook, retaining Workflow's backend resume deduplication. Other producers read the target withgetHookByToken. Markerless continuation hooks require one additional raw ownership lookup for the stable-inbox capability. For these negotiated sends,resumeHookreceives the inspected hook object so the encoding decision cannot apply to a different hook that later reused the same token. Ownership-only reads use the raw world API; they do not need metadata hydration or an encryption-key lookup.
Compatibility and payoff timeline
The producer now emits the consumer's actual contract: markerless legacy
continuations receive unversioned deliver, markerless stable-inbox consumers
receive unversioned send (required by eve 0.30.5–0.31.0), and stamped
consumers receive their declared version.
| Phase | Emit | Removable |
|---|---|---|
| Now | v0 deliver, v0 send, or v1 according to the target hook |
— |
| Pre-stamp cohorts aged out (30-day session timeout) | current version to stamped hooks | markerless classifier and both v0 encoders |
| Pre-version payloads aged out | current version only | legacy unversioned decode paths, SessionCommand inbox fallback, mirror field |
Each removable row already has its condition written next to the code it deletes; #1765 tracks the cleanups.
Enforcement
Each family carries a frozen contract test with three checks, plus one
mechanical guard in the existing CI lint job (pnpm guard:invariants):
- Frozen shape. The complete current schema serializes to a byte-frozen JSON Schema snapshot. Editing any envelope or eve-owned payload field cannot pass this check — the only green path is bumping the version, adding a migration, and freezing the new schema artifact. The wire type is inferred from the same schema the encoder executes, so runtime validation and TypeScript cannot drift.
- Backwards-compatibility fixtures. Frozen payload JSON per shipped
version must keep decoding on the current build, with frozen expected
normalizations. This is the "current code still reads every payload ever
persisted" invariant. Fixtures pin the pre-serde object; the devalue/zstd
framing belongs to
@workflowand is out of scope. - Round-trip.
encodeoutput is byte-frozen, declarescurrentVersion, and decodes under the current schema — producers cannot drift from the schema they claim to emit. - Invariant guard (rule 40). The explicit session-inbox version registry
must be contiguous from v1, its highest entry must equal
currentVersion, and its entries must exactly match the append-onlyexecution/wire/session-inbox-wire.vN.tsmodules. Every wire-version module must also have a colocated*.test.ts: v0 pins legacy fixtures and migration behavior; v1 pins the complete schema and encoder. A version cannot ship as unregistered or untested protocol history. - Enforcement is layered by what each CI job can check. Rule 40 runs in the lint job and compares declared versions with shipped modules and tests. TypeScript requires an encoder for every registered stamped version. The required unit tier then encodes and decodes every registry entry, while each version's frozen contract pins its exact shape and backwards migration.
- Rule 40 also freezes pure
*.vN.migration.tsmodules with their tests and rejects policy imports from those transforms. A one-time historical rewrite is represented by exact old/new Git blob hashes, so the exception expires as soon as the approved rewrite reachesmain. - Exact current-version bytes stay in the unit contract, where the encoded object can be asserted without decoding workflow-owned serde. The deterministic registry checks cover future stamped-version changes. The agent-channels cross-version redeploy eval remains the end-to-end backstop for the pre-versioning gap: it runs the current producer against an actual eve@0.30.8 consumer.
Alternatives considered
Protobuf (or similar IDL codegen). Rejected for this boundary. The byte
serialization is owned by @workflow's serde (devalue + zstd) — eve hands it
JS objects — so protobuf would nest a second binary encoding inside a devalue
envelope and re-implement the semantics devalue already provides. Proto's
defining strengths (cross-language interop, compact bytes) do not apply to an
eve↔eve-only wire, and its default evolution posture — silently tolerating
unknown fields — is the exact failure mode this plan exists to eliminate.
Its real benefit, an externalized schema with mechanical evolution rules, is
what the frozen schema artifact provides without a second binary format or
codegen toolchain.
A schema library in the workflow-safe decoder. Tried and rejected on
measurement: importing vendored zod there grew weather-agent by +1.45 MB
and zod/mini by +1.63 MB because the driver bundle is self-contained,
contains inline sourcesContent, and is itself base64-embedded. The final
split keeps zod in the server/step-only encoder, where it is already bundled
and can express the complete transported type, while the decoder imports
only the inferred type and a dependency-free version constant.
Non-goals
- Migrating the other
HookPayloadmembers, turn-control, or the turn inbox (follow-ups adopt the same module pattern). - Changing the serde codec, hook APIs, or anything owned by
@workflow. - Any public authoring API: this is framework-internal wire discipline.