Summary
v1 has one scalar the wire negotiates on, no way to advertise two majors that COMPATIBILITY §5 nonetheless requires, an engineVersion that is an integer at the root and a string on five carriers, bundleVersion declared four ways (one of them a pattern that rejected its own documented value), and three normative texts presuming a /v2/ path space that the /v1/v1 defect (RFC 0149 §A) already showed is the wrong model. v2 negotiates a major with protocolVersions[], a root preferredVersion, and an OpenWOP-Version request header on a bare origin; v1 keeps its /v1/ path keys through the overlap and v2 operations are unversioned; every axis RFC 0167 §E.1 enumerates gets a disposition here; schemas/v2/ lives under /spec/v2/; the corpus tag is the only release event and every human-surface version is generated with --check.
Motivation
version-negotiation.md:46–55(RFC 0165 §A) shipsprotocolVersions[]and says "v2 defines the negotiation that acts on it". This is that definition.rest-endpoints.md:12–14says breaking changes go to/v2/, servers may serve both side by side, and servers MUST return400for unversioned roots;grpc-transport.md:49–50says v2 would introduceopenwop.v2.Enginealongside;api/asyncapi.yaml:42carriespathname: /v1on the server — the AsyncAPI mirror of theservers[].urlbug RFC 0149 §A fixed in OpenAPI and left in place here, andrest-endpoints.md:15makes the parity gate enforce the asymmetry.- The
engineVersionsplit is recorded atversion-negotiation.md:57–63"rather than fixed";debug-bundle.schema.json:17declared abundleVersionpattern that rejected"1"until RFC 0167 corrected it. docs/PROTOCOL-STATUS.md§"Artifact Versions" lists two axes; the README banner once advertised three wrong versions on one line (check-advertised-versions.mjs:18–23); nov1.4.0tag was ever cut; OpenAPI and AsyncAPIinfo.versionare hand-maintained and untracked.- The A2A lane already solved the header-less default once:
a2a-integration.md:349serves thepreferredVersioncard when noA2A-Versionheader is present. v2 applies the same rule to the protocol itself.
Proposal
§A. Major negotiation
§A.1 A v2 host advertises protocolVersions[] (RFC 0165 §A grammar) containing every major.minor it serves and a root preferredVersion that MUST be a member of protocolVersions[]. During the overlap a host serves ["1.<n>", "2.<m>"]; after Phase 5 it serves ["2.<m>"].
§A.2 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}, RFC 0149 §A): /runs, /runs/{runId}, /.well-known/openwop, … There is no /v2/ path space. rest-endpoints.md §Versioning bullets 1–3 are retracted; the MUST that servers return 400 for unversioned roots is inverted: an unversioned path is the v2 surface.
§A.3 A request on an unversioned path MAY carry OpenWOP-Version: <major> (integer major only; OpenWOP-Version: 2). A host MUST serve the named major when it is in protocolVersions[] and MUST answer 406 Not Acceptable with error code protocol_version_unsupported and a details.protocolVersions[] echo when it is not. When the header is absent, a host MUST serve preferredVersion's major; and while protocolVersions[] contains any 1.x member, preferredVersion MUST be that 1.x — otherwise this rule and RFC 0176 §C.1 (no header ⇒ the v1 document through the overlap) contradict each other, which the reference host found by being unable to satisfy both (Phase 3 P3-E4). A request on a /v1/… path key MUST NOT carry OpenWOP-Version with a value other than 1; a host MUST answer 400 protocol_version_mismatch otherwise.
Corrected 2026-09-28 (Class 3,
docs/COMPATIBILITY-CORRECTIONS-LOG.md). "When the header is absent, a host MUST servepreferredVersion's major" was written for/.well-known/openwop, the resource RFC 0176 §C.1 governs, and stated for every unversioned path. That contradictedversioning.md§1.2 ("an unversioned path is the v2 surface"): through the overlappreferredVersionis 1.x, and v1 has no unversioned operation paths. The default now applies to/.well-known/openwop; every other unversioned path is served major 2.
§A.4 Responses on any path carry OpenWOP-Version: <major>.<minor> naming the contract that produced them. Reporting a version other than the one used is the silent downgrade host-sample-test-seams.md:959 forbids; the dual-stack scenario (§Conformance) falsifies it.
§A.5 Client precedence when both majors are advertised: a v2 client selects the highest major it implements that the host lists; a v1 client (no header, /v1/ paths) is unaffected. minClientVersion (axis 15) becomes first-class: normative, not v1's advisory hint. When a host advertises it, it MUST use the axis-1 <major>.<minor> grammar. A host MAY refuse a client below it; a refusal MUST be 426 Upgrade Required with client_version_unsupported. (Corrected 2026-09-27, after an architect review: this sentence read "minClientVersion (axis 15) becomes a MUST: a host MAY refuse…", which put a MUST and a MAY on the same obligation. The MUST was never on refusal. It is on the grammar of an advertised value and on the shape of a refusal, which is what spec/v2/core/versioning.md §1.5 and the v2-min-client-version scenario already state. Row 15 below and row C5.8 are corrected with it.)
§B. Every axis, dispositioned
The table is RFC 0167 §E.1 with the decision made. unify = one type and one grammar in v2 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.
| # | Axis | v2 disposition | Grammar in v2 | Owner of the text |
|---|---|---|---|---|
| 1 | protocolVersion | first-class (scalar kept as preferredVersion's twin for v1 readers through the overlap; removed after Phase 5) | ^(0|[1-9][0-9])\.(0|[1-9][0-9])$ | this RFC |
| 2 | protocolVersions[] + preferredVersion | first-class, negotiation input | as #1 | this RFC |
| 3 | engineVersion | unify: integer everywhere; per-event carriers become integer, minimum 0; codemod openwop.codemod.engine-version-unify | integer | this RFC (C.9 reads) |
| 4 | eventLogSchemaVersion | first-class | integer; the v2 value and the reader rule are C.9's decision — this RFC only keeps the axis | C.9 |
| 5 | per-event schemaVersion | first-class | integer; closed-enum growth rule (C.4 §0) | C.4 |
| 6 | schemaVersions map | first-class, key grammar = the C.4 envelope-kind grammar | additionalProperties: false over declared kinds | C.4 |
| 7 | version.pinned | first-class | integer min/max; the v1-pinned-run disposition | C.9 |
| 8 | contractProvenance | delete | — | C.2 |
| 9 | minimumSuiteVersion | retire into the declaration file | semver | C.2 |
| 10 | bundleVersion | unify to one const family; certification v3 "3", export "2", debug "2" | string const | C.1 |
| 11 | A2A protocolVersions[]/preferredVersion | first-class facet | ^[0-9]+\.[0-9]+$ | C.8 |
| 12 | MCP protocolVersions[]/preferredVersion | first-class facet | date | C.8 |
| 13 | multiAgent.executionModel.version | first-class | integer with a schema maximum read by the suite | C.4 |
| 14 | OpenAPI/AsyncAPI info.version | generated from the corpus tag; PROTOCOL-STATUS rows | semver | this RFC |
| 15 | minClientVersion | first-class: grammar MUST when advertised; refusal MAY, as 426 (§A.5; corrected 2026-09-27) | as #1 | this RFC |
| 16 | channel schemaVersion/compatibleWith | first-class | integer / range | C.4 |
| 17 | webhook signature scheme | retire into deprecations.json | — | C.4 |
| 18 | pack engines.openwop + registryVersion | first-class with the absent-ceiling rule | semver range / semver | C.10 |
One grammar covers protocol, envelope kind, and pack axes wherever a version is a major.minor (#1, #2, #11, #15); typeId@<semver> (RFC 0013 UQ2) is a pack axis and C.10 decides pin-or-float; the 2 in typeId@2.0.0 never means OpenWOP-Version: 2.
§C. Where v2 lives
§C.1 spec/v2/core/ and spec/v2/ext/<name>/ (RFC 0167 §B.7); schemas/v2/ with every $id under https://openwop.dev/spec/v2/; published at /spec/v2/ by the site generator iterating spec/v*/ (MAINTAINERS §"Major bump" steps 7–8). schemas/ (flat, v1 $ids) is read-only from the cut. The org question (RFC 0167 UQ1): the same organization and site serve /spec/v2/; the fallback, if the domain moves, is that $id values are immutable identifiers and the site publishes a redirect — recorded as decided, not open.
§C.2 Retractions: rest-endpoints.md §Versioning bullets 1–3; grpc-transport.md:49–50 (C.8 owns the proto's fate; this RFC only retracts the /v1 ↔ /v2 analogy); AsyncAPI servers.production.pathname becomes empty and channel addresses carry their own path, exactly as OpenAPI path keys do. A new corpus check, scripts/check-path-parity.mjs, resolves OpenAPI, AsyncAPI, and (if kept) the proto to absolute paths for the shared event stream and fails on any difference; the canonical OpenAPI contains no seam or test-mode operation (those move to C.1's seams profile).
§C.3 The v2 path manifest (spec/v2/path-manifest.json, generated) carries operations and channels; resolvedPath on a bare origin, exactly one version segment for v1 rows (/v1/…) and none for v2 rows.
§D. One release identity
§D.1 The corpus tag openwop/v2.<minor>.<patch> is the only release event; suite, SDKs, registry, and site derive from it. Every human-surface version (README banner, PROTOCOL-STATUS, OpenAPI/AsyncAPI info.version, conformance/package.json) is generated with --check in the merge gate; the identity check (check-published-suite-identity.mjs) and the advertised-versions check keep their three-outcome discipline; the published tarball digest equals the tree's as a release precondition.
§D.2 PROTOCOL-STATUS gains one row per axis in §B (18 rows), including OpenAPI and AsyncAPI info.version.
Migration table
| Row | Kind | v1 | v2 | Codemod | Persisted data |
|---|---|---|---|---|---|
openwop.migration.C5.1 | unify | engineVersion integer at root, string on five carriers | integer everywhere | openwop.codemod.engine-version-unify | legacy-stamped (persisted run docs keep the string; the reader normalises) |
openwop.migration.C5.2 | behavior | channel state-key prefix conventions | typed channels + reducers (C.4) | — (a prose convention; the C.9 read-time adapter is the witness) | translated |
openwop.migration.C5.3 | add | — | root preferredVersion | — | not-persisted |
openwop.migration.C5.4 | add | — | OpenWOP-Version request/response header; protocol_version_unsupported, protocol_version_mismatch, client_version_unsupported error codes (registered in C.4's errors.json) | — | not-persisted |
openwop.migration.C5.5 | behavior | /v1/<op> path keys | unversioned /<op> path keys for v2 (v1 keys retained through the overlap) | — (a v2 SDK reads the v2 path manifest; no persisted artifact carries a path) | not-persisted |
openwop.migration.C5.6 | behavior | 400 for unversioned roots (rest-endpoints.md bullet 3) | unversioned roots are the v2 surface | — | not-persisted |
openwop.migration.C5.7 | rename | $id base https://openwop.dev/spec/v1/ | https://openwop.dev/spec/v2/ (new files; v1 $ids immutable) | — (new schema files, not rewrites) | never-upgraded |
openwop.migration.C5.8 | require | minClientVersion advisory | first-class: <major>.<minor> grammar when advertised; a refusal is 426 client_version_unsupported (§A.5; corrected 2026-09-27) | — | not-persisted |
openwop.migration.C5.9 | behavior | OpenAPI/AsyncAPI info.version hand-maintained | generated from the corpus tag | — (generator) | not-persisted |
Persisted-data disposition
| Store | v1 artifact | Disposition |
|---|---|---|
| Run documents / snapshots (openwop-app SQL, MyndHyve Firestore) | engineVersion as a string | legacy-stamped; the reader normalises to integer; openwop.codemod.engine-version-unify for exported artifacts |
| Event logs | per-event engineVersion string | legacy-stamped; sequence space untouched (C.9) |
Published schemas at /spec/v1/ | v1 $ids | never-upgraded; served read-only |
| SDK path manifests | /v1/… resolved paths | never-upgraded for SDK 1; SDK 2 consumes the v2 manifest |
Compatibility
breaking (v2). This PR changes no v1.x wire shape. The v1.x additive root preferredVersion is filed as its own small RFC under RFC 0165 §A so both majors can be advertised before the cut; it is optional, MUST be a member of protocolVersions[], and is ignored by v1 consumers.
Conformance
v2 scenarios (suite 2.0.0): dual-stack-negotiation (gated on a host advertising two majors: the same run is created and read through /v1/runs with no header and through /runs with OpenWOP-Version: 2; response headers name the contract used; a header naming an unlisted major yields 406; OpenWOP-Version: 2 on a /v1/ path yields 400), preferred-version-default (unaided: a header-less request on an unversioned path is served as preferredVersion), min-client-version (gated), path-parity (corpus), release-identity (corpus: every human-surface version generated; tarball digest equals the tree).
Falsifiability — one row per normative requirement
| Requirement | Observable | Who can cause the condition | Verdict |
|---|---|---|---|
§A.1 preferredVersion ∈ protocolVersions[] | discovery document — openwop.requirement.0172.preferred-version-default | the suite, unaided | witnessable — unaided |
§A.3 header selects a listed major; unlisted ⇒ 406; wrong header on /v1/ ⇒ 400 | response status and headers — openwop.requirement.0172.dual-stack-negotiation | the suite, on a dual-advertising host | witnessable — gated on two majors |
§A.3 absent header ⇒ preferredVersion | response OpenWOP-Version — openwop.requirement.0172.preferred-version-default | the suite, unaided | witnessable — unaided |
| §A.4 response header equals the contract used | dual-stack scenario — openwop.requirement.0172.dual-stack-negotiation, openwop.requirement.0171.header-scheme | the suite | witnessable — gated |
§A.5 minClientVersion refusal | 426 — openwop.requirement.0172.min-client-version | the suite, gated on a host that sets it | witnessable — gated |
§B.3 engineVersion integer everywhere | schema validation of snapshot and events — openwop.requirement.0170.id-grammar, openwop.requirement.0171.event-type-closed | the suite, unaided | witnessable — unaided |
| §C.2 path parity; no seam in canonical OpenAPI | corpus gate | the corpus gate | witnessable — unaided (corpus) |
| §D.1 generated versions; tarball digest | corpus gate; publish preflight | the corpus gate | witnessable — unaided (corpus) |
§A.3 OpenWOP-Version is honored or refused, never ignored | openwop.requirement.0172.version-header-honored — the header-less and 2.0 representations are fetched and compared byte-for-byte | any host, including one that does not implement major 2 | witnessable — unaided |
RFC 0167 G1 (openwop.gap.0167.1, the header-less default has no v1 precedent) is classified here as witnessable-gated and carried to openwop.gap.0172.1 with the dual-stack scenario as its witness.
Adversarial review
- A v1 client never sends the header, so the header cannot be the sole selector. Disposition: it is not — the
/v1/path key selects v1 through the overlap; the header selects among unversioned majors and defaults topreferredVersion(§A.2–§A.3; the A2A precedent ata2a-integration.md:349). rest-endpoints.mdbullet 3 MUSTs a400on unversioned roots — the plan inverts a MUST. Disposition: yes, and only in v2; v1 hosts keep the MUST. Recorded as rowC5.6(behavior) and named in §C.2 as a retraction, not a silent edit.OpenWOP-Versionon a response is a new header on the v1 surface if a host emits it on/v1/paths. Disposition: emitting it on v1 responses is additive (RFC 0165 §C.1 precedent forOpenWOP-*); MAY in v1.x, MUST in v2.- The engineVersion codemod cannot know whether a string carrier was "the decimal rendering of the root integer." Disposition: the codemod refuses any value that is not
^(0|[1-9][0-9]*)$; the refusal fixture is committed. typeId@2.0.0andOpenWOP-Version: 2share a digit and no meaning. Disposition: stated in §B; C.10 owns the pack axis.- The axis table asserted
eventLogSchemaVersionv2 value3, pre-empting C.9. Disposition: retracted; the row keeps the axis first-class and names C.9 as the owner of the value. - New error codes:
protocol_version_unsupported,protocol_version_mismatch,client_version_unsupported. No collision (interop_version_unsupportedis the A2A/MCP boundary code and stays);426is already the documentedminClientVersionstatus (capabilities.md:168,version-negotiation.md:265); the flat envelope carries no status field so nothing else changes. Registered in C.4'serrors.json. - No v1 scenario asserts the
400on unversioned roots (only path-resolution tests exist), so a dual-stack host does not fail a v1 leg by serving v2 there. - The
/v1/path prefix is apathsurface, not a discovery shape. Disposition:deprecations.schema.jsonkindgainspath(additive enum growth on a non-wire data schema); the row is re-kinded; the alias-coverage gate treatspathas a visible kind. - MAINTAINERS step 6 edits
spec/v1/auth.mdat the cut while RFC 0167 says v1 is read-only from the cut. Disposition: the status-legend pointer is the one permitted v1 edit at the cut; recorded as G2.
Alternatives considered
/v2/path space. Rejected: three texts presume it and all inherit the/v1/v1composition hazard; two path manifests per major; RFC 0149 §A's bare-origin rule already exists.- Two discovery documents (
/.well-known/openwopand/.well-known/openwop-v2). Rejected by C.9: one fetch answers both majors. - Keep
engineVersionsplit with a reader rule only. Rejected: Axiom 2 (one name per thing) and the codemod is trivial. - Do nothing. Rejected: COMPATIBILITY §5 requires a coexistence plan and none exists.
Unresolved questions
- Whether
OpenWOP-Versionon requests should acceptmajor.minor(a client pinning a minor) or integer major only. Recommended: integer major only; a minor pin is whatminClientVersionand additive rules already cover.
Implementation notes (non-normative)
The v1.x preferredVersion RFC is a one-field additive change (schema optional field + RFC 0165 §A cross-reference) and can land before Phase 3 so both hosts advertise it in their Phase 4 legs. check-path-parity.mjs and the generated info.version land with the v2 tree in Phase 3.
Acceptance criteria
- [x]
Draft → Active: RFC text; rowsC5.1–C5.9; codemodopenwop.codemod.engine-version-unifygreen on its four legs; register rows; ledger row; adversarial review. (This PR.) - [x]
Active → Accepted(Phase 3):spec/v2/core/versioning.md; rootpreferredVersioninschemas/v2/; the three retractions;check-path-parity.mjs; generatedinfo.version;spec/v2/path-manifest.json; the five scenarios in suite 2.0.0; dual-stack passing on openwop-app from both majors. — evidence: all seven declared requirement ids areexecuted-passon ALL THREE committed bundles, andopenwop.requirement.0172.dual-stack-negotiationpasses all four legs on openwop-app itself, which is what this criterion asks for by name.check-path-parity.mjsgreen. Path corrected in this edit: the operation set isspec/v2/path-manifest.json; nospec/v2/operation-path-manifest.jsonexists or ever did.
References
- RFC 0167 §A, §B.1, §E.1, G1, UQ1; RFC 0149 §A/§C; RFC 0165 §A; RFC 0152/0153 (
preferredVersionprecedent); RFC 0013 UQ2; RFC 0021 UQ1. spec/v1/version-negotiation.md§"Protocol version grammar", §"protocolVersions[]", §"engineVersion axis is split", gaps V1–V5;rest-endpoints.md§Versioning;grpc-transport.md;api/asyncapi.yamlservers;a2a-integration.md:349;docs/PROTOCOL-STATUS.md;MAINTAINERS.md§"Major bump to v2.x";scripts/check-published-suite-identity.mjs,check-advertised-versions.mjs.