OpenWOP openwop.dev
FieldValue
RFC0186
TitleThree payload seats the hosts measured and the corpus lacked
StatusAccepted
Author(s)David Tufts (@davidscotttufts)
Created2026-09-17
Updated2026-09-17 (Draft → Active; comment window waived (additive, 7-day) by the steward under the bootstrap rule — every seat is a strict widening measured from two hosts' persisted payloads, and the witness ships in the same PR) · 2026-09-18 (Active → Accepted). Evidence tier: tier-1 — steward-verified (GOVERNANCE.md §"Acceptance evidence tiers"): openwop.requirement.0186.payload-seats is executed-pass on the reference host's certified bundle (suite 2.4.1, witness b8a7d1d6941d…, all three profiles certified).
Affectsschemas/v2/run-event-payloads.schema.json (conversationExchanged, interruptResolved, nodeSuspended), schemas/v2/conversation-event.schema.json (ConversationExchangedPayload becomes an alias), schemas/v2/suspend-request.schema.json (ApprovalData.onTimeout), scripts/check-payload-closure-hatched.mjs (a MODELLED disposition), conformance/src/scenarios/v2-payload-seats-0186.test.ts
Compatibilityadditive (COMPATIBILITY.md §2.1) — three optional properties added, one closed def widened to the union of two existing shapes, one orphan turned into an alias; no required field, no retype, no MUST relaxed
Supersedes—
Superseded by—

Summary

RFC 0185 gave hosts a hatch for facts the corpus does not model. Both production hosts then measured what they actually record, and three of those facts turned out to deserve a named seat rather than a vendor key: the conversation turn content, the cause of an unattended resolution, and the disposition of an approval gate that times out. This RFC seats them. Every one is a strict widening; every one came from a host's persisted payloads, not from a reading of the spec.

Motivation

RFC 0185 §E declined to invent seats from the reporting side, on the principle that a seat's shape has to come from the host that fills it. This is the RFC that principle was waiting for. The three shapes below were each answered on the /crosstalk v2 bus between 2026-09-16 and 2026-09-17, by the host that emits them, from emit sites and write-seam audits — not from samples.

Proposal

§A.1 conversation.exchanged — one def, not two

The corpus carried two closed defs for one event, and they contradicted each other:

run-event-payloads.conversationExchanged           { conversationId, turnIndex, outcome? }   codemap-bound
conversation-event.ConversationExchangedPayload    { conversationId, turnIndex, turn }       orphaned; turn REQUIRED

A payload with turn failed the bound def; one with outcome failed the orphan. spec/v2/event-codemap.json bound the first, while a fully specified ConversationTurn (messageId, from, content, ts, role, turnIndex) sat beside the second, $refd by nothing outside its own file.

Measured: the tier-1 host emits {conversationId, turnIndex, turn} — never outcome — from persistExchange.ts:52, and had been failing the bound def for as long as both existed. The tier-2 host emits {conversationId, turnIndex, outcome?} — never turn — from conversationGate.node.ts:214, where outcome is the ctx.suspend resume value. Neither ever writes both.

Decision: the bound def becomes the union {conversationId, turnIndex, turn?, outcome?}, with turn a $ref to ConversationTurn. Both optional, so the result is a strict widening of both prior shapes and no host breaks. outcome survives because it is not a synonym for turn — it carries the resume value. ConversationExchangedPayload becomes an alias of the union so conversation-event.schema.json's own oneOf and any consumer that reached that name keep resolving; turn is no longer required there.

Rejected: flipping the codemap to the orphan. That trades the tier-1 host's breakage for the tier-2 host's, and turn-required would have refused a shape one host has emitted for months.

oneOf safety, checked rather than assumed: ConversationOpenedPayload requires initialTurn and ConversationClosedPayload requires finalTurn; the union is additionalProperties: false and names neither, so a payload can match at most one branch. The scenario asserts it.

§A.2 reason — a cause, on two defs

RFC 0183 established two axes on interruptResolved: decision (what was decided — governance) and action (how the resume applied). The tier-1 host then measured a third fact its gate timeout records: reason: 'timeout' — what triggered the resolution without a human. Quorum-rejected and nobody-answered are the same decision: 'rejected' with different provenance, and the second is the host acting on its own.

The tier-2 host drops the same fact on 350 rows of node.suspended, which has no reason either.

Decision: optional reason (string, 1–64) on both interruptResolved and nodeSuspended. A string with a documented domain, not an enum, because the domain is host-grown: timeout, condition, false-positive, over-threshold, quorum-reject, timer are the values recorded today. The four vocabularies on one event are disjoint by construction — kind is the interrupt kind, decision the governance outcome, action the resume action, reason the cause — so none can collide.

Not seated: interrupt.resolved.outcome. Measured single-valued 'rejected' on the emitting host — a duplicate of decision, and that host is deleting it.

§A.3 ApprovalData.onTimeout — the one field that was genuinely missing

RFC 0185's closure gate waived approvalRequested pending host input. The tier-2 host answered with the full historical key set — {title, message, timeout, onTimeout}, 280 rows — and with the observation that these payloads were the wrong model, not extra keys on the right one: the right one already existed.

suspend-request.schema.json $defs.ApprovalData seats title, artifactId, artifactType, actions, description, artifactData and the quorum fields, and suspend-request itself carries timeoutMs. So:

host keyseatnote
titleApprovalData.titlealready seated
messageApprovalData.descriptionsame fact — the prose under the title on the approval card (node.approvalMessage)
timeoutsuspend-request.timeoutMsmilliseconds; 0 means no timer, which timeoutMs MUST read the same way
onTimeoutnothingunmodelled anywhere in spec/v1, spec/v2 or any schema

Decision: optional onTimeout on ApprovalData, enum reject | approve | escalate — taken from the host's own type (onTimeout?: 'reject' | 'approve' | 'escalate') and its zod schema, not from a sample. Every gate with a timer needs a disposition; escalate pairs with a host-defined target. It goes on ApprovalData rather than suspend-request because it is approval-kind policy and suspend-request is kind-agnostic.

The closure gate learns a third disposition. approvalRequested moves from PENDING to a new MODELLED map in check-payload-closure-hatched.mjs: a deliberate open → closed narrowing whose remedy was a seat, cited. Hatching it would only invite hosts back to bare keys. Shrink-only like PENDING; a MODELLED def that turns out to be hatched fails the gate.

clarificationRequested is a deletion (2.3.2). Neither host emits it: the tier-2 host has no emitter (no sample in 3000 runs — clarifications route through SuspendManager and surface as node.suspended{kind:'clarification'}), and the tier-1 host measured one hit, a type list in its divergence classifier, with zero write-seam samples (2026-09-17). A def with no population is a deletion under RFC 0185 §E: ClarificationData is the only shape, the def stays closed, and the row moves to MODELLED so a future hatch fails the gate.

Compatibility

additive, against COMPATIBILITY.md §2.2: no required field added, removed or retyped; no optional field retyped; the one event-type shape change is a superset of both prior shapes; no endpoint contract touched; no MUST relaxed; no error code's meaning changed. A $ref alias in place of a closed def is a widening for the one consumer that reached it (turn was required; now optional).

Conformance

conformance/src/scenarios/v2-payload-seats-0186.test.ts (server-free, unconditional — schema validation needs no host). It asserts both host shapes validate against the union and a payload with neither optional still does; the union stays closed; the alias resolves; the conversation-event oneOf matches exactly one branch for an exchanged and a closed payload; reason validates on both defs, refuses empty, and neither def reopens; onTimeout admits the three values, refuses a fourth, and ApprovalData still refuses the legacy bare message.

Sabotage-proved: removing turn from the union, removing reason from nodeSuspended, and widening the onTimeout enum each red the scenario; restore green.

Unresolved

  • Whether reason's domain should close to an enum once both hosts' values stabilise. Not now: the values are host-grown and a fifth would otherwise need an RFC.
  • escalate names a target the corpus does not model (escalationTarget, escalationTimeout on one host). Out of scope here; recorded so it is not rediscovered.

Falsifiability — one row per normative requirement

RequirementObservable — what an outside party seesWho can cause the conditionVerdict
§A the three seats admit the shapes both hosts persist, under one bound defopenwop.requirement.0186.payload-seats — each host's conversation.exchanged payload validates; the oneOf matches exactly one branchthe suite, unaidedwitnessable — unaided, executed-pass on the reference host's certified bundle
§A the orphan is an alias and the oneOf cannot double-matcha payload matching two branches is refused at schema levelthe corpus gatewitnessable — unaided (corpus)

Acceptance criteria

#CriterionEvidence
1Both hosts' conversation.exchanged shapes validate against one bound defscenario §A.1; hosts' emit sites cited
2The orphan is an alias, and the oneOf cannot double-matchscenario §A.1
3reason seats on interruptResolved and nodeSuspended without reopening eitherscenario §A.2
4onTimeout is a closed enum from the host's typescenario §A.3
5approvalRequested leaves PENDING for a cited MODELLED dispositioncheck-payload-closure-hatched.mjs
6No seat was invented from the reporting sideevery value domain above cites the host that recorded it