OpenWOP openwop.dev

Status: Stable · v2.1.0 (2026-09-11) · RFC 0176 (§A–§B, §D–§E), 0171 §A, 0170 §A.3.

Why this exists

The v2 cut renames event types that are persisted, indexed, and unique-keyed in production stores, and fork and replay read those rows verbatim. This document states how a v2 host reads what a v1 host wrote, what happens to a run in flight at the cut, and what each persisted store becomes — so two hosts read one log one way.

The codemap is data

spec/v2/event-codemap.json, shipped in @openwop/spec-artifacts, is the only authority for the v1→v2 event-type mapping; every row is decided (RFC 0171 §A). A host MUST NOT carry a private mapping. A vendor-prefixed v1 type the codemap does not name MUST be read under its own name unchanged, where "vendor-prefixed" means the first segment is an org registered in the extensions object of spec/v2/declaration.json (events.md §Rules; RFC 0171 §A.1 — openwop. is the only reserved prefix). An unregistered first segment is not a vendor prefix and falls to the refusal below. An org is registered by pull request against the corpus and takes effect on the @openwop/spec-artifacts release that carries it; a shipped entry is append-only, because deregistering an org would convert every log already written under it from pass-through to refusal (RFC 0180).

The era key

eventLogSchemaVersion is the era key and is required on every run snapshot (schemas/v2/run-snapshot.schema.json).

ValueMeaning
absentOn a store a v1 host has ever written, the run MUST read as 2 (v1 era).
2v1 era; every reader translates through the codemap.
3v2 era; a v2 host MUST stamp 3 on every run it creates.
< 2The v1 rule is unchanged: snapshot fallback, no projection write-through.

Discovery MUST advertise the value the host writes for new runs and nothing else; a host MUST hold one constant for this axis.

Absent stays era 2 forever; it is never backfilled. A host MUST NOT rewrite historical rows to add an explicit 2, and a reader MUST NOT require one. The trichotomy is sound only because a v2 host stamps 3 on every run it creates: if any creation path is left unstamped after the cut, the runs it makes are indistinguishable from pre-cut runs and every reader will translate them as era 2 — a silent wrong read, not an error. So a host with more than one creation path MUST begin stamping 3 on all of them in the same change; staging that across deploys is the failure this rule exists to prevent.

Collapsing to one constant is a precondition for advertising, not a consequence. A host whose creation paths disagree — one writing 2, another writing nothing — has no single value to advertise, and whatever it publishes is false for some of its own runs. Unify the writers first, then advertise. This is the same class of constraint as the writer rule below and is ordered the same way: the store is made coherent before the wire describes it.

The snapshot field is required on the wire, and MAY be synthesized. schemas/v2/run-snapshot.schema.json requires eventLogSchemaVersion, but an era-2 run predates the key and has nothing stored. The snapshot is a read projection, so the host MUST supply 2 from the absent-⇒-2 rule rather than fail the read; a missing stored era is not a read error. The consequence is worth stating plainly: on the wire this field is never absent, so it cannot falsify a host's era handling on its own. What falsifies that is the vocabulary of the events themselves, which is why the reader and writer rules below carry the obligation and this field only reports it.

The reader rule

A v2 host reading a run in era 2 MUST translate every event through the codemap at the storage boundary:

  • type is mapped; the payload is projected per RFC 0171 §B.
  • sequence MUST be preserved verbatim, including 0.
  • eventId, timestamp, causationId, and vendor fields pass through.
  • A type the codemap does not name and that carries no reserved vendor prefix MUST fail the read with event_type_unmapped (spec/v2/errors.json, 500). A malformed row MUST fail the read rather than default any field.

The rule binds every reader: poll, SSE, fork, replay divergence, debug bundle, summary memory. The translation is a read projection. A host MUST NOT rewrite era-2 rows in place; a background backfill that stamps 3 and rewrites type under the same (runId, sequence) key is permitted only as an atomic per-run operation with the original preserved, because the fork prefix must stay byte-equivalent to the translated parent (replay.md, RFC 0041 §C).

The writer rule

The era key is fixed when the run is created and fixes the log's vocabulary for the run's lifetime. An append to a run in era 2 MUST use v1 vocabulary — the name the codemap maps from, not the v2 name it maps to. A host that upgrades mid-flight MUST NOT begin writing v2 names into a log the reader translates as era 2: the reader would map an already-mapped name a second time, or fail the read with event_type_unmapped on a name the codemap does not carry on its v1 side. A run created after the upgrade is era 3 and is written in v2 vocabulary, untranslated.

This binds every writer for as long as an era-2 run stays open, which on a host with human-approval interrupts can be days. Draining era-2 runs before serving v2 is not the path — see §"Runs pinned to v1" — so the writer rule is what makes an in-flight run safe across the cut. Its witness is v2-era-2-append-vocabulary.

The v1 wire of an era-3 log

The reader rule above is written for a v2 reader of an era-2 log. Through the overlap a host serves BOTH majors (versioning.md §5) and v1 operations keep their /v1/… path keys unchanged (§1.2), so the mirror case is forced and the corpus owed it a rule: a run created today is era 3 and its log is stored in v2 vocabulary, yet the same log must still be readable on /v1/… exactly as it was before the cut.

A host serving both majors MUST therefore map an era-3 log's type back to its v1 spelling on the v1 read path, through the same codemap row, inverted. This is well defined and not a private mapping: spec/v2/event-codemap.json is a bijection — 118 rows, 118 distinct v1 names, 118 distinct v2 names, no many-to-one fold — so the inverse of a row is exact. A host MUST verify that property at load rather than assume it; if a future row folds two v1 names onto one v2 name, the inverse stops being a function and the host MUST refuse to serve the v1 representation rather than guess which spelling to emit.

Two alternatives are rejected, and naming them is the point of this section. Storing v1 spellings under an era-3 stamp makes the stamp a lie, and the closed-enum scenario would pass it by luck on any run whose types happen to be identity rows. Serving v2 names on /v1/… breaks the v1 wire, which the overlap exists to preserve. Neither is a smaller change than the inverse map; they are the same change with the honesty removed.

The seat

The adapter MUST sit at the storage boundary every reader passes through — the storage interface's event-list method, not a wrapper some call sites bypass. A host leg MUST name its seat in its ADR.

The seat is a claims-check (conformance.md §Witness class): it is discharged by that disclosure and by audit, never by the wire. v2-v1-events-translated drives poll, SSE and a fork, and what those three legs witness is that those three readers translate. They do not witness the seat. Three wrappers pass them exactly as one correctly seated adapter does, and the rule binds every reader — including the ones the suite has no name for. A universal is not discharged by three examples: the scenario catches a reader that was missed, not an adapter that was misplaced.

Forking a v1 run

A fork of an era-2 run MUST produce a prefix byte-equivalent to the translated parent, and its run.started MUST carry the legacy Subject where the parent had none (RFC 0176 §A.5; replay.md, identity.md).

Runs pinned to v1

A non-terminal run a v2 host inherits carries version.pinned events naming change ids. The host MUST continue it or cancel it, never follow a pin silently.

ConditionRequirement
Every pinned change id is still implementedThe run MUST continue under the reader rule; the pin is honored verbatim and version.pinned is never rewritten.
Any pinned change id is no longer implementedThe host MUST cancel the run with run.cancelled reason v1_pin_unsupported and cancelledBy: "v2-cutover"; the certification bundle reports the count.
Suspended on an interrupt at the cutThe run continues under the row above; its outstanding token is resolvable under kid: legacy until expiresAt (RFC 0170 §E.1), and the run reads through the adapter.

"Drain" is retired as the only path. Multi-region skew is read-side only: after the cut a v2 region MUST NOT accept an era-2 write for a run it has already stamped 3 (RFC 0176 §B.3). Discovery's minClientVersion rule is RFC 0172 row C5.8.

Everything else a v1 host persisted

ArtifactRequirement
Certification bundlesNever upgraded. A v1 bundle substantiates no new certification after 2026-11-10; every host produces a fresh v2-rc bundle before the cut (conformance.md).
Webhook deliveriesA host advertising both majors MUST dual-emit the X-openwop- and OpenWOP- header families through the overlap; a v2 receiver MUST accept a v1-signed delivery (X-openwop-*, scheme v1) verifying the same bytes; per-subscription secrets are unchanged; deliveries queued before the cut are drained under their own retry policy with the payload they were serialized with (webhooks.md).
Interrupt resume tokensDrained: a token that is not ow2.-prefixed is a v1 token and resolves under kid: legacy until expiresAt; new tokens carry the ow2. prefix (identity.md). The rule is written over the prefix and never over a segment count: the v1 {token} path parameter carries no pattern, so a conforming v1 token may be a single opaque row key, and a count-based rule leaves such a host with no drain rule at all.
Layer-1 and Layer-2 records (idempotency, idempotent responses, invocation claims and logs, effect-escape ledger, dispatch outbox, envelope correlations)Unchanged; keyed on ids the cut does not rename. GET /runs/{runId}/effects and GET /runs/{runId}/compensation are new reads over them (security-defaults.md).
Owner stampsA run without a Subject MUST be legacy-stamped at first v2 read and MUST NOT be rewritten later (RFC 0170 §A.3). A host's stored owner fields are projected to the Subject; the projection is the host's to name.
Audit logNever upgraded (RFC 0170).

Per-store disposition

A host's ADR MUST name every store it persists and give each one disposition from the closed set below.

DispositionMeaning
unchangedRows keep their shape and keys; a v2 reader consumes them as they are.
translatedRows are read through the codemap at the storage boundary; never rewritten in place except the atomic per-run backfill.
drainedRows complete under their own v1 contract until exhausted or expired; no new rows of the v1 shape are written.
legacy-stampedA missing v2 field is given its legacy value at first v2 read and never rewritten.
never-upgradedRows remain v1 evidence only; v2 evidence is produced fresh.
not-persistedNothing to migrate.

Template — one row per store:

Storev1 artifactDisposition
eventsv1 vocabulary; UNIQUE (runId, sequence)translated
runsno eventLogSchemaVersion; owner fieldslegacy-stamped
interruptstwo-segment tokensdrained
webhook subscriptions and queued deliveriessubscriptions; serialized deliveriesunchanged; drained
idempotency, invocation, outbox, correlation tableskeyed recordsunchanged
audit logaudit factsnever-upgraded
certification bundlesv1 bundlesnever-upgraded
host-internal tablesoutside the wireunchanged

The reference hosts' dispositions are recorded in RFC 0176; a host MUST NOT decide a store's disposition during the migration.

The corpus-tag pin

A consumer that vendors any file from schemas/, api/, or spec/ MUST pin to a published openwop-conformance/vX.Y.Z tag, MUST record the tag, and MUST refuse a sync from any other ref. A v1.x consumer MUST NOT vendor schemas/v2/ (RFC 0176 §E.1). The corpus-tag-pinned check verifies that each consumer's recorded tag resolves (conformance.md).

See also: overview.md, events.md, replay.md, identity.md, webhooks.md.