OpenWOP openwop.dev

Status: Stable · v2.1.0 (2026-09-11) · RFC 0172, 0179, 0176.

Why this exists

v1 negotiated on one scalar, could not advertise two majors, split engineVersion across two types, and presumed a /v2/ path space that the /v1/v1 defect already showed is the wrong model. This document is the one place a v2 host reads to learn how a major is selected, what each version axis means, and what a release is.

1. Major negotiation (RFC 0172 §A)

1.1 Advertisement

A v2 host MUST advertise protocolVersions[] (grammar ^(0|[1-9][0-9])\.(0|[1-9][0-9])$ per member) containing every <major>.<minor> it serves, and a root preferredVersion that MUST be a member of protocolVersions[]. Both are REQUIRED root metadata in schemas/v2/capabilities.schema.json (see capabilities.md). Through the overlap a host serves ["1.<n>", "2.<m>"]; after v1 end-of-support it serves ["2.<m>"].

Through the overlap preferredVersion MUST name a 1.x member. A header-less request is a v1 client's request: capabilities.md §1 makes the header-less representation the v1 document, and §1.3 makes the header-less default preferredVersion's major, so on a host whose protocolVersions[] contains any 1.x member the two rules agree only when preferredVersion is that 1.x. A host that drops v1 from protocolVersions[] advertises a 2.x preferredVersion and its header-less representation becomes the closed v2 root. On a host serving a single major, preferredVersion MUST equal protocolVersion (RFC 0179 §A.1). A v2 consumer reads preferredVersion as the header-less default; when it is absent on a v1 document the consumer's default is max(protocolVersions[]), else protocolVersion (RFC 0179 §A.2). The suite's --target-major defaults from it (RFC 0168 §D.3).

1.2 Paths

v1 operations keep their /v1/… path keys unchanged through the overlap. v2 operations are unversioned path keys on a bare origin (servers[].url = https://{host}): /runs, /runs/{runId}, /.well-known/openwop. There is no /v2/ path space. An unversioned path is the v2 surface; the v1 MUST that servers answer 400 for unversioned roots is retracted for v2.

A host that advertises a major in protocolVersions[] MUST reach, under that major, every operation named in spec/v2/path-manifest.json that it serves under the other. Advertising a major is a claim about the path space, not about /.well-known/openwop alone — that resource's representation is selected by the request header (§1.3), so it answers correctly for a host that has mounted nothing else, and every discovery-level probe of the advertisement passes with it. Concretely: if /v1/<op> answers and the unversioned /<op> returns 404 under the advertised major, the advertisement overstates what the host serves and the host MUST NOT advertise that major until the surface is reachable. The pairing is normative because a lone 404 cannot distinguish "this host does not serve that operation" from "this host serves it and did not mount it under this major", and only the second is a defect.

The manifest is the scope, and that qualifier is load-bearing. An earlier wording quantified over every operation it serves, which is not jointly satisfiable with conformance.md §"Test seams": the seams profile mounts the real path space /conformance/seams/…, and that same document requires spec/v2/path-manifest.json and api/v2/openapi.yaml to contain no seam operation. Under the unqualified reading a host serving seams under one major owed them under the other, while the manifest against which the claim is measured was forbidden to name them. Surfaces a host serves that the manifest does not name — seam paths, and any path the protocol does not define — are not bound by this paragraph; §5 records what the protocol does and does not say about the second class. The narrowing does not weaken the case the rule exists for: the defect that motivated it was POST /webhooks answering 404 under major 2 while POST /v1/webhooks answered 201, and webhooks is a manifest operation.

spec/v2/path-manifest.json (generated) carries operations (method, path, operationId) and channels (name, address) on a bare origin, and every path in it is unversioned — there are no /v1 rows. The /v1 twin of a manifest row is derived by prefixing, which is what the pairing above compares. OpenAPI (api/v2/openapi.yaml), AsyncAPI (api/v2/asyncapi.yaml), and any kept proto MUST resolve to identical absolute paths for the shared event stream (scripts/check-path-parity.mjs); the canonical OpenAPI MUST contain no seam or test-mode operation (those live in the seams profile, see conformance.md).

1.3 The request header

A request on an unversioned path MAY carry OpenWOP-Version: <major> or OpenWOP-Version: <major>.<minor>2 and 2.0 select the same major and a host MUST accept both. Only the major selects; a minor in the header is informational, and what pins a minor is minClientVersion plus the additive rules.

ConditionHost behavior
Header names a major in protocolVersions[]MUST serve that major
Header names a major not in protocolVersions[]MUST answer 406 protocol_version_unsupported with details.protocolVersions[] echoing the list
Header absent on an unversioned pathMUST serve preferredVersion's major
/v1/… path with OpenWOP-Version other than 1MUST answer 400 protocol_version_mismatch

A request on a /v1/… path key MUST NOT carry OpenWOP-Version with a value other than 1. All three codes are rows in spec/v2/errors.json (see errors.md).

1.4 The response header

Every protocol response MUST carry OpenWOP-Version: <major>.<minor> naming the contract that produced it. Reporting a version other than the one used is a silent downgrade and non-conformant; the dual-stack-negotiation scenario falsifies it. Emitting the header on /v1/ responses is additive in v1.x and REQUIRED in v2.

A protocol response is one produced by an operation named in spec/v2/path-manifest.json (or its /v1/ twin through the overlap); a shell, a hosting fallback or a proprietary route on the same origin has no version to name. Errata 2026-09-10: this read "any path" — §1.2's quantifier defect again.

On a manifest-named path, a non-protocol response MUST NOT carry OpenWOP-Version and MUST NOT be application/json; a reader, a cache or the suite MUST NOT count a response without the header, or with a text/html body, as reaching the operation (reachedUnderMajor2). A vendor path (§5) is not a shared name and is unconstrained.

Content negotiation on a shared name is permitted, with conditions. A host MAY serve a protocol operation and a page under one unversioned name, selecting on Accept, iff:

1. A request identifying as a protocol client — OpenWOP-Version present, or an Accept admitting application/json without preferring text/html (absent and / included) — MUST get the protocol response for the applicable major (§1.3) with OpenWOP-Version; only an explicit text/html preference selects the page. 2. The page obeys the paragraph above. 3. The response carries Vary: Accept, OpenWOP-Version.

Otherwise the page MUST move off the shared name.

1.5 Client precedence and minClientVersion

When both majors are advertised, a v2 client MUST select the highest major it implements that the host lists; a v1 client (no header, /v1/ paths) is unaffected. minClientVersion (axis 15, grammar as axis 1) is a MUST: a host MAY refuse a client below it with 426 client_version_unsupported.

OpenWOP-Version on a request selects by MAJOR; the <major>.<minor> spelling is accepted because protocolVersions[] members are <major>.<minor> and a client echoing one back is the obvious thing to do — the conformance driver does exactly that. A minor pin is what minClientVersion and the additive rules cover (RFC 0172 UQ1, recommended disposition; the integer-only reading was corrected in Phase 4 after it contradicted the suite that tests it).

2. The 18 version axes (RFC 0172 §B; RFC 0167 §E.1)

unify = one type and grammar with a codemod; first-class = own schema-enforced grammar and negotiation rule; retire = absorbed into the capability record's {status, since, until?}; delete = removed with a register row.

#AxisDispositionv2 grammarOwner
1protocolVersionfirst-class; kept as preferredVersion's twin for v1 readers through the overlap, removed after^(0|[1-9][0-9])\.(0|[1-9][0-9])$this document
2protocolVersions[] + preferredVersionfirst-class, negotiation inputas #1this document
3engineVersionunify: integer everywhere; codemod openwop.codemod.engine-version-unifyinteger, minimum 0this document
4eventLogSchemaVersionfirst-class, the era keyinteger; v2 writes 3persistence.md
5per-event schemaVersionfirst-class; §0 growth ruleintegerevents.md
6schemaVersions mapfirst-class; keys = envelope-kind grammaradditionalProperties: false over declared kindsevents.md
7version.pinnedfirst-class; the v1-pinned-run dispositioninteger min/maxpersistence.md
8contractProvenancedeletecapabilities.md
9minimumSuiteVersionretire into spec/v2/declaration.jsonsemvercapabilities.md
10bundleVersionunify to one const family: certification v3 "3", export "2", debug "2"string constconformance.md
11A2A versions[] / preferredVersionfirst-class facet of a2a^[0-9]+\.[0-9]+$interop.md
12MCP revisions[] / preferredVersionfirst-class facet of mcpdateinterop.md
13multiAgent.executionModel.versionfirst-classinteger with a schema maximum the suite readsevents.md
14OpenAPI / AsyncAPI info.versiongenerated from the corpus tagsemverthis document
15minClientVersionfirst-class MUST (§1.5)as #1this document
16channel schemaVersion / compatibleWithfirst-classinteger / rangeevents.md
17webhook signature schemeretire into deprecations.jsonwebhooks.md
18pack engines.openwop + registryVersionfirst-class with the absent-ceiling rulesemver range / semverpacks.md

One grammar covers protocol, envelope-kind, and pack axes wherever a version is <major>.<minor> (#1, #2, #11, #15). typeId@<semver> is a pack axis (packs.md); the 2 in typeId@2.0.0 never means OpenWOP-Version: 2. docs/PROTOCOL-STATUS.md carries one row per axis (RFC 0172 §D.2).

2.1 engineVersion (axis 3)

engineVersion MUST be an integer (minimum 0) at the discovery root and on every per-event carrier. A persisted v1 run document that carries the string form is legacy-stamped: the reader MUST normalise it to an integer and MUST NOT rewrite the stored document. The codemod openwop.codemod.engine-version-unify MUST refuse any value not matching ^(0|[1-9][0-9]*)$.

2.2 eventLogSchemaVersion (axis 4; RFC 0176 §A.2)

eventLogSchemaVersion is the era key. A v2 host MUST stamp 3 on every run it creates. A run document without the field on a store that has ever been written by a v1 host MUST read as 2 (v1 era). The v1 rule for < 2 (snapshot fallback, no projection write-through) is unchanged. Discovery advertises the value the host writes for new runs and nothing else; the schema floor is minimum 2. The reader contract is persistence.md.

3. Where v2 lives (RFC 0172 §C)

spec/v2/core/ and spec/v2/ext/<key>/ hold the prose; schemas/v2/ holds every v2 schema with $id under https://openwop.dev/spec/v2/; the site publishes them at /spec/v2/. The flat schemas/ tree (v1 $ids) is read-only from the cut; v1 $id values are immutable identifiers, and a domain move is answered by a redirect, never a rewrite. AsyncAPI servers.production.pathname is empty and every channel address carries its own path, exactly as OpenAPI path keys do.

4. One release identity (RFC 0172 §D)

The corpus tag v2.<minor>.<patch> (release candidates v2.0.0-rc.<n>) is the only release event; suite, SDKs, registry, and site derive from it. spec/v2/release.json carries the next tag as version and is bumped only by the release PR that cuts the tag. Every human-surface version (README banner, docs/PROTOCOL-STATUS.md, OpenAPI and AsyncAPI info.version, conformance/package.json) MUST be generated from it and checked with --check in the merge gate; the published tarball digest MUST equal the tree's as a release precondition. The identity and advertised-versions checks keep their three-outcome discipline (conformance.md).

A consumer that vendors any file from schemas/, api/, or spec/ MUST pin to a published tag, record it, and refuse a sync from any other ref; a v1.x consumer MUST NOT vendor schemas/v2/ (RFC 0176 §E.1).

5. The overlap (RFC 0167 §B.5; RFC 0176)

Through the overlap a host MUST advertise both majors (§1.1), MUST emit OpenWOP-Version on every response (§1.4), and MUST serve /.well-known/openwop as one resource whose representation the request header selects (capabilities.md). The dual-stack scenario creates one run through /v1/runs with no header and reads it through /runs with OpenWOP-Version: 2; the response headers name the contract used.

A run minted under major 1 and read under major 2 MUST be named by its tenant-bound projection <tenantId>/<the v1 id> (identity.md §5). A host MUST NOT return the bare v1 id in a major-2 response body. Normative since 2026-09-04, when a conformance check asserting byte-equality with the v1 id and a host implementing identity.md §5 could not both hold — §5 had no reading.

The projection is mandatory for a reason that is not stylistic: a tenant-bound id carries the segment identity.md §5's 403 id_tenant_mismatch check reads, and a bare id has none, so the cross-tenant refusal cannot run on it at all. A legacy unprefixed form in documents would be a class of long-lived identifiers on which major 2's tenant isolation is structurally inapplicable; the grammar in ids.schema.json has no legacy branch and MUST NOT acquire one.

The overlap ends at v1 end-of-support (overview.md), when protocolVersions[] drops the 1.<n> member and every alias carrying the v1-end-of-support trigger is removed.

Retirement is atomic, and that is a consequence of §1.1 rather than a separate rule. Through the overlap preferredVersion MUST name a 1.x member; a host that drops v1 from protocolVersions[] advertises a 2.x preferredVersion. There is no legal intermediate state in which both majors are advertised and 2.x is preferred, so flipping preferredVersion ahead of the drop is not a smaller first step — it is the same step. Dropping v1 therefore retires the whole /v1 path space at once, not incrementally.

Retirement flips every header-less request's contract. Through the overlap a header-less request on an unversioned name is served major 1 (§1.3); where the v1 surface lives under /v1/ that name is not a v1 key and falls through to whatever else is served there — typically a page. At end-of-support the same request is served major 2 and the page starts answering the operation. A /v1/-counting inventory cannot see this. Test: manifest top-level segments ∩ anything else served unversioned ({agents, prompts, runs} on the host that found it). A non-empty intersection MUST be resolved before end-of-support: move the page, or serve it under §1.4's conditions.

Host-proprietary paths live at /host/<org>/… (RFC 0181). Every vendor namespace — capability records (capabilities.md §3.2), error codes, event types, pack properties — is keyed to an org registered in spec/v2/declaration.json; paths join that pattern. A host MAY serve operations the manifest does not name under /host/<org>/… for its registered org: no major in the path, served regardless of OpenWOP-Version, never a protocol operation, never measured, outside §1.4. An org MUST NOT be named after a manifest segment under /host/ (reservedOrgs); a host SHOULD advertise the mount under extensions.<org>.<name>. A /v1/host/<org>/… twin MAY ride the overlap and retires atomically with /v1.

6. Migration rows (RFC 0172)

Rowv1v2
C5.1engineVersion integer at root, string on five carriersinteger everywhere; codemod engine-version-unify
C5.3root preferredVersion
C5.4OpenWOP-Version request/response header; three error codes
C5.5/v1/<op> path keysunversioned /<op> keys (v1 keys retained through the overlap)
C5.6400 for unversioned rootsunversioned roots are the v2 surface
C5.7$id base /spec/v1//spec/v2/ (new files; v1 $ids immutable)
C5.8minClientVersion advisoryMUST (§1.5)
C5.9info.version hand-maintainedgenerated from the corpus tag

Row C5.2 (channel state-key prefixes → typed channels) is owned by events.md. Every row is a spec/v1/migrations.json entry; the persisted-data disposition for each is not-persisted except C5.1 (legacy-stamped) and C5.7 (never-upgraded).