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
errorreply 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 strippedsealedfield 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.opennegotiates capabilities; the Hub answerssession.linkedwith the INTERSECTION. Absent capability ⇒ defined fallback: notools→ Q&A only;stt/ttsnone→ 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.suspendedwith 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
sidis 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) orcompanion(the paired Companion answers, on-device or via a provider under the visitor's own key). It MUST NOT settle oncompanionunless the Companion declaredanswer: true. answerModeis OPTIONAL insession.linkedand absent MUST be read ashub. A Hub that never sends it and a peer that never reads it interoperate exactly as before this section existed.- In
companionmode the Companion MAY originateturn.delta,turn.citation,turn.doneandturn.errorfor a turn it is answering. The Hub relays them to the Surface. The Hub remains the authorization point: tool calls still travel asaction.requestand 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→ streamedturn.delta_ +turn.citation_ →turn.done(echoingcontextRev) orturn.error.- ONE in-flight turn per session. A new
turn.inputwhile a turn streams IMPLIES cancellation: the Hub MUST emitturn.error{TURN_ABORTED}for the superseded turn before streaming the new one (voice barge-in). turn.cancelstops the identified turn; the Hub respondsturn.error{TURN_ABORTED}.- An in-flight turn does NOT survive a disconnect: after
session.resumedthe Hub MUST sendturn.error{TURN_ABORTED}for any turn that was streaming. - Audio input (
audioRef) requires the negotiatedaudiocapability.
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. expiresAtbounds 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:
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):
Turn with a gated write tool (§8–§10):
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_DENIEDwith 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/resultgenerously enough for its own poll cadence. - Attestation (App Attest / Play Integrity) upgrades trust in the device
claim; deployments MAY require it (
PAIR_DENIEDon 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 isSEAL_REQUIREDand 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.