OCP Core — normative specification (v1.0-draft)

The key words MUST, MUST NOT, SHOULD, MAY are to be interpreted as in RFC 2119. The Zod contracts published as @schwaizer/ocp-protocol (mirrored as JSON Schemas at /ocp/schemas/1.0/ and rendered at /ocp/reference) are the authoritative message shapes; the golden vectors (/ocp/vectors/) are the compatibility contract. This document holds the behavioral rules the schemas cannot express.

1. Roles

Surface (renders chat, publishes context, executes surface-tools), Hub (owns sessions, identity, turns, tool routing, policy, audit), Companion (remote, authenticated I/O device bound to a Surface session), Provider (data/tools behind the Hub). Surfaces and Companions MUST NOT communicate directly — the Hub brokers everything.

2. Versioning & forward compatibility

  • Every message carries v = major.minor. Within a major version all changes are ADDITIVE-ONLY.
  • Receivers MUST ignore unknown message types and unknown envelope/payload fields. Receivers MUST NOT reject a structurally valid envelope because its type is unknown.
  • A KNOWN message type whose payload fails validation is malformed, not future: receivers MUST reject it (an error reply or connection-level handling), never silently ignore it as if the type were unknown.
  • A relay that RE-SERIALISES a frame MUST carry unknown payload fields through unchanged. "Ignore unknown fields" is a rule for terminal receivers; for a broker, validating against an older schema and re-emitting the result is not ignoring the field, it is DELETING it and forwarding a hollow frame. See sealing.md §4.1 — a stripped sealed field is the difference between a peer that cannot talk and a peer that reads an empty answer.
  • Vendor extensions use x-<vendor>.<name> message types and tool names.

3. Envelope

Every frame is {v, id, type, ts, sid?, replyTo?, payload} (schema: envelope.ts). id MUST be unique per sender (ULID RECOMMENDED). replyTo correlates a response/error/confirm to the request it answers.

Delivery is AT-LEAST-ONCE across reconnects: receivers MUST dedup on id.

4. Errors

One error message type (schema: errors.ts) with a registered code set. replyTo MUST reference the failed request when one exists. Pairing failures MUST be uniform (PAIR_DENIED) — implementations MUST NOT reveal whether a token was expired, replayed, or malformed.

5. Sessions

  • session.open negotiates capabilities; the Hub answers session.linked with the INTERSECTION. Absent capability ⇒ defined fallback: no tools → Q&A only; stt/tts none → modality handled by the peer or unavailable.
  • Heartbeats (session.heartbeat) every 10 s in BOTH directions; a peer is stale after 2 misses (~25 s).
  • Companion drop → session.suspended with a 60 s grace; rejoin requires the resume key AND a device-key signature. After grace → session.closed.
  • Surface drop → the session closes immediately (surface_gone); nothing may apply without a live Surface to render approvals.
  • Hard TTLs: 30 min idle, 4 h absolute. All session state is revocable at any time (Surface UI, Hub device list, admin).

5.4 Sessions that outlive an authentication epoch

The four-hour absolute TTL is not a guess about how long people work. It exists because session.refresh renews a token WITHOUT re-proving possession, so an attacker holding a stolen resume key inherits whatever remains of the session — and a bound that never resets is what keeps that remainder small.

Work that legitimately spans days therefore has a real problem and a wrong fix. The wrong fix is extending the TTL, which does nothing but lengthen the stolen key's life. What a longSession deployment does instead is move the bound: the four hours become the life of an EPOCH, and crossing one requires a FRESH device-key proof of possession, exactly as a resume does. The session identity persists; the authentication does not.

Rules for a Hub offering it:

  • Both peers MUST declare longSession; the settled value is the intersection, like every other capability.
  • Each epoch MUST end no later than the absolute TTL, and the next MUST begin with a fresh PoP over the current token hash. A refresh MUST NOT start one.
  • A peer that cannot re-prove MUST be closed, not carried. The point of the epoch is that possession is demonstrated again, and a Hub that waives it on a bad day has simply extended the TTL by another name.
  • The sid is stable across epochs, which is the whole purpose: an audit trail that fragments every four hours records the protocol's bookkeeping rather than the work.

A deployment with no such need declares nothing and behaves exactly as §12 describes, which is the behaviour every Hub written before this section has.

5.1 Where answers are generated

Speech negotiates where it runs; so does answer generation. A peer that can generate answers — not merely render them — declares capabilities.answer.

  • The Hub settles the session on answerMode: hub (the Hub or its Provider answers) or companion (the paired Companion answers, on-device or via a provider under the visitor's own key). It MUST NOT settle on companion unless the Companion declared answer: true.
  • answerMode is OPTIONAL in session.linked and absent MUST be read as hub. A Hub that never sends it and a peer that never reads it interoperate exactly as before this section existed.
  • In companion mode the Companion MAY originate turn.delta, turn.citation, turn.done and turn.error for a turn it is answering. The Hub relays them to the Surface. The Hub remains the authorization point: tool calls still travel as action.request and are re-authorized per call (§9), and writes are still confirm-gated (§10). A Companion that answers does NOT thereby gain the right to act.

5.2 Answer provenance

session.linked.answeredBy and turn.done.answeredBy carry {mode, operator, brainId?}.

This is disclosure, not routing, and what it discloses is WHO RUNS THE MODEL and nothing further: in hub mode the question reaches the Hub operator and whatever provider it runs; in companion mode it reaches a model on the visitor's own device, or one under the visitor's own API key. A client that cannot tell which mode it is in cannot honestly tell its user, and the two are otherwise indistinguishable from the outside.

companion MUST NOT be read, or presented to a user, as "the question does not leave the device". The Hub brokers every frame (§1), so unless the session settles on sealing (§5.3), turn.input and every turn.delta cross the relay in plaintext and reach the Surface, which is how the page renders the transcript. companion-provider.md §5 states the claim this field earns and forbids every broader one.

session.linked states what the session settled on; turn.done states what actually answered. Implementations MUST NOT assume they agree — a Companion whose model becomes unavailable mid-session is the ordinary case, and that is precisely the divergence a reader needs to see.

5.3 Content sealing

Where answers are generated says nothing about what the relay can read: the Hub brokers every frame, so in either mode the content crosses it in plaintext unless sealed. Sessions MAY negotiate end-to-end sealing between the Surface and the Companion — offered as capabilities.seal, settled as session.linked.sealMode, absent MUST be read as 'none'.

Settlement is a SEPARATE top-level field, not a capability echo, and it is a compatibility signal rather than a downgrade control; what cannot be downgraded is the Surface's offer, which travels out of band. The full profile — key agreement, the SAS, the AEAD contract, the sealed/clear inventory, and an explicit statement of what sealing does NOT protect — is sealing.md, which is normative wherever a session settles on e2e.

6. Context

context.set is a FULL REPLACE of the Surface's context, carrying a monotonic rev. Hubs MAY reject serialized contexts > 32 KB with CONTEXT_TOO_LARGE; Surfaces MUST truncate selection.text to ≤ 8192 UTF-16 code units (the schema-enforced limit; the serialized byte size may be larger for non-ASCII text — the 32 KB context ceiling is the byte-level backstop). resource.kind, id, and selection.ref are opaque to the protocol; the Provider interprets them.

7. Identity tiers

T0 anonymous (public content, no writes, strict quotas) · T1 site-identified (ocp.identify with a tenant-signed visitor JWT) · T2 hub-authenticated. A paired Companion NEVER exceeds the tier of the Surface session it joined, and its token may carry a lower ceiling. The Hub re-derives effective permission on EVERY call: tier × scopes × resource ACL. Client claims are never authority.

A tier says how much a session may do; it does not say WHO is doing it. Pairing binds a device — the token is cnf-bound to a key and every later frame proves possession — which is right for a kiosk, where the visitor is deliberately anonymous, and insufficient for a workflow that must record that a named person approved a write.

A claim MAY therefore carry a principal (pairing.ts), and a Hub that accepts one MUST verify it itself: a claim is an assertion by an unauthenticated caller, and stamping an unverified subject onto a session would turn transport-provided identity into a laundering service for a payload field. A Hub that has verified one MUST put it on session.linked and MUST attribute Companion-originated frames to it, so a Surface recording "approved by …" reads the transport rather than the actor's own account of themselves.

It is OPTIONAL, and deliberately not required for write: true tools. The anonymous kiosk is a case this protocol exists to serve and a global requirement would price it out. A deployment that needs attribution says so — in its .well-known declaration, and by refusing claims that arrive without one.

8. Turns

  • turn.input → streamed turn.delta_ + turn.citation_ → turn.done (echoing contextRev) or turn.error.
  • ONE in-flight turn per session. A new turn.input while a turn streams IMPLIES cancellation: the Hub MUST emit turn.error{TURN_ABORTED} for the superseded turn before streaming the new one (voice barge-in).
  • turn.cancel stops the identified turn; the Hub responds turn.error{TURN_ABORTED}.
  • An in-flight turn does NOT survive a disconnect: after session.resumed the Hub MUST send turn.error{TURN_ABORTED} for any turn that was streaming.
  • Audio input (audioRef) requires the negotiated audio capability.

9. Tools

MCP-shaped definitions (schema: tools.ts), two locations: hub and surface. Manifests are hints — the Hub MUST re-check authorization on every invocation. Tools with write: true MUST be gated by a confirm (§10). Surface tool scope SHOULD be limited to the current resource.

The catalogue is NEVER sealed: name, location and write are the inputs to this re-check and to the write gate, and a Hub that cannot read write cannot know which calls to gate (sealing.md §5). A sealed session therefore still discloses the site's tool graph, and which tools a visitor used and when — just not with what arguments or results.

9.1 What a tool result carries

The protocol does not read a tool result: action.result.output is unknown (schema: tools.ts). Everything in this section is a convention on that payload's CONTENT — an OPTIONAL contract between the party that computes an answer and the party that phrases it. No frame changes here.

A tool whose result contains a COMPUTED FIGURE — a number the tool derived rather than echoed — SHOULD return, alongside whatever raw fields it already returns:

  • card — the deterministic answer, already written the way the party that computed it writes numbers: its own grouping, precision, currency placement and language. This is the DISPLAY form, and it is what a screen renders.
  • speech — the same answer as a voice must read it: no grouping characters, the currency as a WORD, the digits plain. This is the SPOKEN form.
  • figures — the load-bearing values as data, keyed by a name the tool chooses and left unformatted, so a consumer can check that the figure it is about to speak is still the figure the tool computed.

An answerer that receives them SHOULD place them verbatim rather than recompute them. A Hub relaying the result forwards output as it arrived — the payload is opaque to it, and §2's carry-through rule is what keeps it that way.

The party that computed the number is the only one that knows how it should be written. Everyone downstream holds strictly less: the Hub sees an opaque object, and a model asked to phrase the answer sees a bare number and INVENTS the presentation — grouping, precision and currency all guessed from whatever its training makes likeliest rather than from the site whose figure it is. The first deployment to run this path computed a Swiss tax figure exactly, returned it as raw API fields, and read it back to the visitor with comma grouping, a stray rappen and the currency trailing: three conventions, none of them the site's, on a number that had been right when it left the tool.

So the division of labour is the ordinary one for a grounded answer — the TOOL owns the figures, the ANSWERER owns the sentence around them. An answerer that rewrites a figure rather than placing it has re-entered the arithmetic that handing it a finished answer exists to keep it out of.

Two forms, deliberately not one. The display form is what a screen renders; the speech form is what a voice reads; one string cannot serve both, and collapsing them is the part an implementer will otherwise merge on the grounds that they look like duplicates. The worked example is Swiss: CHF 19'590 is correct on screen and actively wrong for a text-to-speech engine, which either pronounces the apostrophe or spells out the digits it separates; 19590 Franken is right for the voice and wrong on the page. Neither is a formatting preference. Each is correct for exactly one consumer, and a deployment that publishes a single string for both has only chosen which one to fail.

The spoken form SHOULD be plain digits plus a spoken currency word, placed where the language puts it. Implementations SHOULD NOT spell figures out as words: digits are read correctly by engines in practice, while a word form is a translation problem in every language the deployment serves and a worse failure when it goes wrong — a mispronounced digit string is a mispronunciation, a mistranslated one is a different number.

Both forms are in the COMPUTING party's language, and the protocol carries no language field for them because it needs none: a tool that renders figures for a page already knows which language that page was served in. A per-language map in the payload would only be a second place for the same fact to be wrong.

Because these fields sit inside output, a sealed session seals them with it (§5.3): publishing a card publishes it to the peers, not to the relay.

A tool that publishes none of these fields is CONFORMANT, and gets whatever the answerer makes of its raw fields. This section describes that outcome; it does not forbid it. It is the outcome the tax deployment above already recorded, and a site that finds it acceptable is entitled to it.

Nothing declares in advance that a tool emits any of this. The result is SELF-DESCRIBING — a consumer looks for the fields in the payload it is already holding — and a flag in the catalogue would be a second source of truth for the same fact, on the weaker side: manifests are hints (§9), and a tool that advertises a card and returns none has told a consumer to expect something the payload does not contain. Should a Hub or Companion ever need to know BEFORE the call, an OPTIONAL field on tools.ts#toolDefSchema is an additive minor-version change, and nothing here forecloses it.

10. Confirms

ui.request (confirm/choice/notice) is Hub-initiated and non-bypassable; it may be satisfied from ANY connected peer. ui.response correlates to its ui.request via requestId; actionId only links the confirm to the action it gates. A confirm without a response by expiresAt EXPIRES TO DENY. Every write MUST be preceded by a confirm; a pending Surface-side diff whose Companion dropped stays pending for manual approval — it MUST NOT auto-apply.

10.1 Gates: a decision that waits

mode: 'gate' is for a request that must not answer itself. A professional judgment — a valuation scope, an assumption set, a signature — is wrong when it auto-denies for the same reason it is wrong when it auto-allows: both invent an answer the accountable person never gave, and one of them does it while they are merely away from the screen.

A gate therefore does not resolve on its own, and three rules keep that from weakening §10:

  • A gate MUST NOT carry an actionId. Expiry-to-deny exists partly so a Hub is never left holding a call, and a gate that gated an in-flight action would reintroduce exactly that. A gate dispatches nothing; a caller asks, and acts when the decision arrives.
  • expiresAt bounds the VIEW, not the decision. The peer's copy of a gate lapses and MAY be re-issued — on reconnect, on re-pairing, on a later turn. The decision underneath is unresolved until a human resolves it.
  • Silence still produces no affirmative action. "Expiry-to-deny" is the §14 rule stated for the expiring case; the general rule it protects is that no action ever follows from nobody answering, and an unresolved gate satisfies it by producing no action at all.

A Hub that offers gates MUST persist them across a Companion disconnection and re-attach them on re-pairing, since a decision that evaporates because a phone lost signal is a decision the deployment cannot rely on. A Hub that does not offer them MUST treat mode: 'gate' as 'expire' and MAY say so; it is a degradation the caller can detect from the resolution it gets.

10.2 Resolutions outlive their turn

A confirm may be satisfied from ANY connected peer, so the peer that ASKED is frequently not the peer that answered. A Hub MUST deliver the resolution to the Surface even when the turn that requested it has ended: a Surface that renders approvals is the party that must record them, and a resolution it never hears is a record it cannot keep.

ui.response MAY carry record — a stable id for the resolution the Hub has retained. It is a JOIN, not a payload: the §14 trail records that a confirm resolved, by whom and how, and a deployment that must later cite what the decision MEANT — the evidence on the screen, the reviewer, the document version — keeps that in its own system under this id. Carrying the evidence itself here would put content in the one record built to survive without any.

action.result MAY carry reason, and where a Hub knows the difference it SHOULD: denied is a person saying no, expired is nobody answering. A caller told only that a call failed cannot tell a decision from an absence, and re- asking someone who already refused is its own failure.

In a sealed session the gate splits (sealing.md §9): the Hub still decides that a confirm is required and owns expiry and audit, but it cannot see the arguments, so the Surface composes what the human reads from the action's sealed input, relayed verbatim on the ui.request. A Surface MUST NOT treat the Hub's title as the sole basis for the decision, and a sealed input that does not authenticate MUST expire to deny rather than degrade to the Hub's wording. Otherwise the visitor approves a tool NAME rather than an act, and §10 is satisfied in wording while meaning nothing.

11. Transport & retention

All legs are wss/https. Audio MUST NOT persist beyond the turn. Transcripts are retained per tenant policy, scoped by tier. JWS exp verification allows ±30 s clock skew.

"Per tenant policy" is a fact a visitor's device could not read until it had already sent the question. A deployment SHOULD therefore state its policy where discovery already looks: .well-known/ocp.json MAY carry a deployment object (Companion §1.1; schema: wellknown.ts#deploymentSchema) — answerMode (answerer or relay, §5.1's registry describing the deployment rather than a session), operator, dataResidency, and retention for transcripts and audit. A deployment that declares MUST declare truthfully: a false declaration is nonconformant. A declaration is a CLAIM by the operator, not a property the wire can verify — a Companion MAY display it, and displaying it as verified fact would be the lie the retired sealing profile already taught this spec to refuse (retired/sealing.md; §5.2 holds answeredBy to the same standard).

12. Session state machine

Per peer leg, from the Hub's perspective:

rendering diagram…

Rules the diagram cannot show: a session's clock for ttl_absolute starts at creation and is NEVER extended (token refresh keeps the TOKEN alive, not the session); SUSPENDED applies only to the Companion leg — the Surface leg has no grace; every transition into CLOSED MUST emit session.closed{reason} to every still-connected peer and resolve all pending confirms to deny and all pending actions to error.

13. Canonical flows

Pairing (Companion profile §2):

rendering diagram…

Turn with a gated write tool (§8–§10):

rendering diagram…

14. Security considerations

  • Pairing is delegation with mandatory human sign-off. The QR token is a bearer secret with a ≤90 s life and a single-use nonce burned atomically at claim; possession alone never links — a human MUST approve on the Surface (anti-QRLjacking), and the resulting companion token is cnf-bound to the claiming device key.
  • Proof of possession everywhere it matters. Every companion connect and resume presents a fresh device-key signature over the current token hash with a bounded timestamp (±60 s). A stolen token without the device key is useless; a stolen resume key without both is too.
  • Uniform denial. All pairing failures are PAIR_DENIED with identical status/shape/timing budget — no oracle for expired vs replayed vs forged.
  • Expiry-to-deny. Unanswered confirms deny; dropped sessions deny pending confirms and error pending actions. No affirmative action ever results from silence.
  • Takeover requires consent. A different approved device connecting over a live companion MUST be confirmed by the Surface alone — neither the incoming nor the incumbent device can authorize its own takeover.
  • The client is never the authority. Tier, scopes, and ACLs are re-derived by the Hub per call; tool manifests are hints; context fields are untrusted input to the Provider (prompt-injection surface: providers SHOULD spotlight or delimit context-derived text).
  • Public endpoints are rate-limited. pair/claim (nonce guessing, DoS) strictly; pair/result generously enough for its own poll cadence.
  • Attestation (App Attest / Play Integrity) upgrades trust in the device claim; deployments MAY require it (PAIR_DENIED on absence/failure) and MUST record its verified/unverified status for audit.
  • Audit. pair start/claim/approve/deny/revoke, companion link/takeover, tool invocation, and confirm resolution MUST be auditable events.
  • The relay reads everything that is not sealed. Absent sealing.md, all content crosses the Hub — and whatever terminates TLS in front of it — in the clear. Sealing removes content from that path; it does NOT defend against a Hub that serves the Surface's own widget code, which every Hub does by construction (embed.md, Topology). Implementations MUST state that limit rather than around it, and MUST NOT downgrade a session that offered sealing: an unsealed sealed-eligible frame is SEAL_REQUIRED and closes the session.

Appendix A — registries

Message types (v1.0): error · session.open · session.linked · session.suspended · session.resumed · session.closed · session.heartbeat · session.refresh · session.refreshed · context.set · turn.input · turn.delta · turn.citation · turn.done · turn.cancel · turn.error · tools.advertise · action.request · action.result · ui.request · ui.response · pair.approval_request · pair.approval_response · pair.linked · pair.revoked. Vendor extensions: x-<vendor>.<name>.

Error codes: AUTH_EXPIRED · SCOPE_DENIED · RATE_LIMITED · NONCE_USED · SESSION_GONE · TURN_ABORTED · CONTEXT_TOO_LARGE · PAIR_DENIED · UNSUPPORTED · SEAL_REQUIRED · INTERNAL.

Session settlement fields (session.linked): answerMode (absent ⇒ hub) · sealMode (absent ⇒ none, see sealing.md §4). Both are settled separately from capabilities on purpose: the reference Hubs echo capabilities rather than intersecting them, so a settlement read off the echo would report agreement no one implemented.

Scopes: context.read · qa.ask · tools.invoke · pair.manage. Companion default: context.read qa.ask. Surface (T2): all four.

Additions to any registry are minor-version changes; removals or semantic changes are major.