Changelog

OCP evolves additively within a major version (Core §2). Every entry lists wire-visible changes; anything that would break a golden conformance vector is a major version.

Unreleased

0.10.0 — 2026-08-25

@schwaizer/ocp-protocol 0.9.0 · @schwaizer/ocp-hub 0.10.0 · @schwaizer/ocp-companion 0.8.1 · @schwaizer/ocp-embed 0.9.3. ocp-webmcp is untouched and not republished.

The companion and embed bumps carry NO code change. They exist because ^0.8.0 does not accept protocol 0.9.0, so without them a project installing ocp-hub beside either would resolve two copies of the protocol package.

One behaviour change to read before upgrading a Hub. A confirm's resolution now goes to the peers that did NOT answer, carrying record. Before this it went to BOTH peers and carried no id, so a Surface could not tell its own approval from the phone's and had nothing to cite from what it stored afterwards. If your Surface keys off receiving an echo of its own ui.response, it will stop receiving one — read record instead.

Everything else is additive. Existing peers need no change, and all 61 previous golden vectors pass untouched (69 now).

  • Six changes an implementer asked for, from the first external review this draft has had. A valuation platform adopted the pairing semantics and the per-invocation re-authorisation as guidance without adopting the wire, and said what would make the wire adoptable. All six are additive: no frame changed shape, no schema field became required, and all 61 existing golden vectors pass untouched.

    • Gates (§10.1). ui.request.mode: 'gate' for a decision that must wait for an accountable human. Auto-denying a valuation scope is as wrong as auto-allowing it — both invent an answer nobody gave, and one does it while the person is briefly away. A gate carries no actionId and dispatches nothing, so §10's real property survives intact: silence produces no affirmative action, and now produces no action at all rather than a denial the deployment has to distinguish from a decision.
    • Resolutions outlive their turn (§10.2). A Hub MUST deliver a confirm's resolution to the Surface even after the requesting turn ended — a confirm is satisfiable from any peer, so the asker is frequently not the answerer. ui.response.record carries a stable id the Surface can cite from what it signs later. A JOIN, not a payload: the §14 trail stays content-free and the deployment keeps the evidence under that id.
    • action.result.reason. 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.
    • A principal on the claim (§7). Pairing binds a device, which is right for a kiosk and insufficient where a Surface must record that a named person approved a write. A Hub that accepts one MUST verify it itself and MUST stamp it on session.linked. Optional, and deliberately NOT mandatory for write tools: the anonymous kiosk is a case this protocol exists to serve.
    • Long sessions (§5.4). Requested as a longer TTL; implemented as a moved bound. Four hours exists because refresh does not re-prove possession, so extending it only lengthens a stolen resume key's life. longSession makes four hours the life of an EPOCH instead, crossed only with a fresh device-key PoP, and the sid persists — an audit trail that fragments every four hours records the protocol's bookkeeping rather than the work.
    • SPEC/mcp.md. How a Hub MAY front an MCP server as its hub-scope tool provider, and — the section that matters — what that server does not get to decide. MCP has no confirm gate, no tiers and no per-call re-authorisation, so a Hub that forwards tools/list and calls tools/call on request has removed §9 and §10 from its deployment while leaving the wire identical. write is the Hub's classification, unclassified means write until someone says otherwise, and the server is never called before the confirm resolves.

    One request needed no change: action.request.turnId has carried "which turn caused this" since it was written. They could not find it because no spec prose said so, which is a documentation defect and is now fixed.

    All six are implemented in the reference Hub, not only specified — a contract without one is something nobody can build against. Gates never arm a deadline, are refused if they carry an actionId, survive the session-closing denyAll and are re-issued on re-pairing; resolutions reach the peers that did not answer whatever the turn is doing, carrying record; reason is set from what the Hub actually knows, including unauthorized where policy refused and no human was asked; a principal is verified through a wired policy — dropped, never trusted, when none is wired — and rides the Hub-signed token so it survives a reconnect and a refresh; and the epoch is enforced by the session sweeper, which drops the companion socket at four hours and requires the reconnect that re-proves possession. One limit stated plainly: gates survive a disconnection but not a process restart, and openGates() is exposed so a deployment can persist them. Eight new golden vectors cover the wire-visible fields — 69 now — with a corpus assertion beside them, since a round-trip test cannot notice a field no vector sends.

  • The guides teach what the spec now specifies. Core §9.1 and companion.md §1.1 landed with nothing that showed anyone how to use them, and a specification whose guides stop a release short teaches the version before it. embed-the-widget gains the store links a visitor with no app needs, the events a host can hear while its own tools run, and what a tool should RETURN; build-a-companion gains the deployment declaration — display it, never decide on it — and what to do when a site publishes its own figures; implement-a-hub gains publishing that declaration, including that declaring nothing is conformant and declaring something false is not.

  • implement-a-hub walks through the §14 trail. The guide named audit in its first sentence and in a checklist line, and showed nobody how — the API was reference in a package README. It now has the sink choice, the ten emit points as a table, why the union has no field content would fit in, where the 400-day default comes from, and the one event that is not a call site: session.close belongs on an override of the service, because the sweeper closes sessions your code never sees and a Hub that emits only on its own closes records just the sessions that ended politely.

0.9.2 — 2026-08-16

  • @schwaizer/ocp-embed 0.9.2: the scroll that first focus causes no longer cancels the lift. Covered on the FIRST focus of a session and correct on every one after it — the shape of the bug named its cause. iOS scrolls a newly focused field into view, visualViewport.offsetTop reports that scroll, and the bottom lift subtracted it, handing the panel back exactly the distance the platform had just moved it. By the second focus the page is already scrolled, there is nothing left to report, and the same arithmetic looked right. The subtraction is still correct when the viewport MEASURED the keyboard, so it stays; while the composer holds the caret the lift now has a floor at whatever the assumption reserves, and a measurement or a scroll can only raise the panel further.

  • SPEC: a tool result says how its number is written (Core §9.1, Embed "Host tools"). tools.advertise has always said what a tool takes and never what it returns, which left "publish a finished answer, not a bare figure" as folklore inside one companion app. The first deployment to run the host-tool path found the cost: a site computed a Swiss tax figure exactly, returned it as raw API fields, and the visitor's phone read it back with comma grouping, a stray rappen and the currency trailing — three conventions, none of them the site's, invented by a model that had no way to know better.

    Core gains §9.1: a tool whose result contains a computed figure SHOULD return card (the deterministic answer in the computing party's own conventions), speech (the same answer for a voice — no grouping characters, currency as a word) and figures (the load-bearing values, so a consumer can check one survived narration). TWO FORMS, NOT ONE, is the part the section argues hardest, because it is the part an implementer merges: CHF 19'590 is correct on screen and unreadable to a TTS engine, which pronounces the apostrophe or spells out the digits it separates, while 19590 Franken is right for the voice and wrong on the page. Spelling figures out as words is discouraged in the same breath — a mispronounced digit string is a mispronunciation, a mistranslated one is a different number. Both forms are in the computing party's language, since the tool rendering figures for a page already knows which language that page was served in.

    Embed's new Host tools section documents the ocp.tools / ocp.action / ocp.actionResult path the schema has carried since it was added, and points at §9.1 for the result: what the host returns becomes the output the Hub relays to whoever asked, and that party is frequently a model about to speak.

    NOT A WIRE CHANGE. action.result.output stays unknown, no schema moved, no golden vector moved, and a tool that publishes none of these fields is conformant — it gets whatever the answerer makes of its raw fields, which is what the paragraph above describes. §9.1 also says why nothing DECLARES that a tool emits cards: the result is self-describing, and a catalogue flag would be a second source of truth on the weaker side, since manifests are hints. An optional field on toolDefSchema remains available as an additive minor-version change if a Hub ever needs to know before the call.

0.9.1 — 2026-08-16

  • @schwaizer/ocp-embed 0.9.1: the keyboard is not only the keys. The composer was still covered on a real iPhone with the suggestion strip showing. Two causes, stacked. The assumed keyboard share was 0.45 — the top of the band for the KEYS, and iOS stacks QuickType above them while Android stacks its own strip, 40-50px that no measurement taken from the key rows includes; it is now 0.52. And the composer inherited the panel's 14px, under the 16px threshold at which iOS Safari ZOOMS THE PAGE on focus — a zoom moves the frame out from under the geometry the host just computed for it, by a route no viewport measurement can observe. The field is pinned at 16px and inherits its family as before.

0.9.0 — 2026-08-16

Two defects a real phone found, an hour apart, both invisible to every test in the repository: a call the Hub dispatched and then would not deliver the answer to, and a keyboard the page was structurally unable to observe.

  • @schwaizer/ocp-hub 0.9.0: a Hub that dispatches a call now delivers the answer. A visitor paired a phone to a tax site and asked what they owed. The Hub authorised the phone's income_tax call and dispatched it — tool.invoke · income_tax · peer: companion · decision: allowed is in the trail — the page ran the site's own tool against its own API and had the number in about a second, and the phone waited out its 30 s action timeout and told the visitor the site had failed. Three live attempts, and not one answered turn behind them.

    The relay routed action.request and returned 'ignored' for action.result. Both halves were defensible in isolation, which is why this survived review: the router listed the result among the frames "the host handles what it handles", and examples/hub-server does handle what the router does not — except that action.result appears nowhere in that file, because every Hub built on the package inherited the request half for free and nobody went looking for the other. examples/hub-answerer forwarded it by hand, which is the whole reason the answering reference worked and the relay reference did not.

    Owning one half of a request/response pair is the defect, so the fix is in the package rather than in the deployment that noticed. The executor addresses its result to the SESSION — it never sees the peer that asked — so the Hub that dispatched the call is the only party that still knows who is waiting, and a Hub that cannot say has not relayed the call, it has absorbed it. dispatchAction now holds the call open under its actionId until the new deliverActionResult closes it, and handle routes action.result through that same seam. The answer goes to the peer that ASKED, by role rather than by socket — either side may ask, and a phone that resumed inside its grace window is on a new socket by the time a confirmed write returns. A result for an id the Hub is not holding open is dropped with no error back, like every other frame a peer is not entitled to send: unsolicited, a second answer to a call already closed, or sent by the peer that asked rather than the one that was asked. What covers an answer that never comes is the asker's own timeout, where it belongs. Payloads are forwarded as they arrived (§4.1), so a vendor-extended or sealed result reaches the phone whole, and a session is capped at 64 open calls so an executor that never answers cannot grow the map for the four hours a session may live.

    FOR ADOPTERS: handle now routes a frame it used to ignore. A host that forwards action.result itself and also calls handle on it will deliver it twice. examples/hub-answerer was that host — it never called handle, so it was never double-delivering — and it now calls deliverActionResult instead of keeping its own copy, which also gets it the correlation its hand-rolled forward never had: it used to hand session.companion any result a Surface sent, for a call or for nothing.

    Why it shipped: every action test in the package stopped at the request reaching the executor. There is now a full round trip through the router — companion asks, surface executes, companion receives the result with its output and its actionId intact — plus a result for an unknown id, a result from the wrong peer, a repeated result, a refused call, and two sessions on one router. examples/hub-answerer's integration suite drives the same trip over real sockets and asserts the phone sees EXACTLY ONE result. examples/hub-server has no test runner; standing one up for this seam would be disproportionate, so the reference relay's own wiring is covered only indirectly — by the package tests and by the single relay.handle call it already routes every frame through.

    This is the third defect this week whose shape is "each layer is individually correct and the pair drops the frame": the context the loader captured and never sent, the tool call the page ran and never reported, and now the result the Hub carried in and never carried out. What they have in common is that no test ever followed one thing across the seam between two components that each had tests of their own.

  • @schwaizer/ocp-embed 0.8.3: a site can see its own tools being run. A paired phone asked a site to run a tool, the Hub authorised and dispatched it, and the page did nothing observable — and nothing in the host's event stream could tell whether the call had arrived and hung, arrived and named a tool the page does not have, or never arrived at all. tool.failed was the only event, so every one of those failures looked identical to a call that was never made, from the only vantage point an adopter has. The host now emits tool.started before the lookup, tool.unknown for a name it does not hold and tool.completed on success; the widget emits action.forwarded as the request leaves the frame, and action.unmatched when a result arrives for a call it is no longer holding — the one path that otherwise ends in the Hub holding a call open while a phone waits out its timeout. Content-free: tool names only, never arguments or output.

0.8.2 — 2026-08-15

The page finally reaches the answerer. Everything below is one defect and the tests that would have caught it.

  • @schwaizer/ocp-embed 0.8.2: the loader sends the page it is standing on. The first real end-to-end turn this project ever served was answered on a paired phone, on device, with the provenance line correct on screen — and the answer was "The provided JSON input is an empty object containing no figures and lacks any specific content to explain." The visitor had asked what they pay in Basel. The model was not wrong about what it had been handed: captureContext had read URL, title, meta description and selection correctly since the beginning, setContext had sent them correctly, and NOTHING CALLED IT. The only call site was the public API passthrough, so SPEC/embed.md's "captured with no host wiring" held for exactly one kind of site — one that wrote window.ocp.setContext() itself, which is the wiring the sentence promises is unnecessary. The adopter declared its tools and never wrote that line, and every visitor got an answerer grounded on nothing.

    The loader now sends the page when the widget reports ocp.ready, through the same queue every other host message uses, and re-reads it on a client-side navigation — popstate, plus wrapped pushState/replaceState, because an adopter on a client router rewrites URL, title and description with no reload and a context captured once at boot grounds the answer on the page the visitor has left. A host's own setContext(extra) still wins field by field and keeps winning until it says something else, so a site that supplies richer context is never overwritten by a re-read. A burst of route changes settles into one send, and a re-read that finds nothing changed sends nothing: the widget spends a context revision on every one it receives, and the Hub reports that number back as what the answer was grounded in.

0.8.1 — 2026-08-15

A security release. Both entries below are defects found by probing the live deployment rather than by a test: the Hub could be ended by one frame, and the widget hid its own text box behind the keyboard.

  • @schwaizer/ocp-embed 0.8.1: the composer survives the on-screen keyboard. On a phone the keyboard covered the text box the visitor had just tapped, and two defects were stacked under it. The keyboard does not shrink the LAYOUT viewport, so 100vh, 100dvh and innerHeight all kept reporting the full page while position:fixed kept measuring from the bottom of it: the panel held its height and the keyboard took the bottom of it, composer last. The loader now follows the host page's visualViewport while the panel is open — inside a cross-origin iframe that object describes a box the PARENT sizes and never observes the keyboard, so the fix cannot live in the widget. Underneath it the panel could not have used the room anyway: its root asked for height:100% in a document the Hub serves with no CSS, where html and body are auto-height and the percentage resolves to auto, so the panel sized to its content; the log kept flex's default min-height:auto and would not shrink; the bar had no flex:none and was what gave way. Listeners attach on open and drop on close and on destroy(); with no visualViewport the geometry is byte-identical to before, pinned by a desktop regression test.

  • SECURITY — one frame from any visitor ended every session on the Hub. An anonymous Surface (the public widget holds no secret and needs no token — that part is correct) sends ONE valid envelope carrying a KNOWN type and an invalid payload — turn.input with {"nope":true} — and the reference relay exits. The sender's socket closes 1006, so does every other visitor's socket on that process, and the container restarts. Unauthenticated, one frame, repeatable by anyone who can load the page it is embedded on. Reproduced against the deployed Hub and then locally, where the collateral close proved it was the process dying rather than the connection.

    Two mistakes stacked, and only together were they fatal. parseMessage reports "known type, malformed payload" by THROWING, which is a reasonable shape for a client with one frame in hand and the wrong one for a relay: createRelayRouter().handle called it from inside an async method that examples/hub-server dispatched with void and never awaited. A rejection at that seam is an unhandled rejection, and Node 22 ends the process on one. The seam is what turned a missing close code into a denial of service.

    • @schwaizer/ocp-protocol 0.8.0 adds classifyMessage — the same three §2 outcomes as a value, accept / ignore / reject, so the decision a socket handler makes cannot escape the handler. parseMessage keeps its throwing shape (the golden vectors read it, and a terminal receiver is a fair place for try/catch) and is now implemented on top of it. Additive: no wire change, no schema change, all 61 vectors untouched.
    • @schwaizer/ocp-hub 0.8.1: handle returns a verdict, and never throws. createRelayRouter().handle now answers 'handled' | 'ignored' | 'malformed' in place of a boolean; a malformed frame is reported and nothing is relayed. BREAKING for anyone who read the old boolean — 'ignored' is truthy, so if (await handle(...)) inverts. The version also carries the audit trail and the .well-known deployment declaration listed below, which were already unreleased.
    • Both example Hubs classify before they dispatch, and catch what the dispatch rejects with. A known type whose payload fails validation closes THAT socket with 4002 — SPEC/core.md §2: rejected, never silently ignored, which is what an unknown type still gets — and any other failure inside the handler closes that socket with 1011, the code that says our fault rather than yours. examples/hub-answerer already parsed at the seam and so already closed 4002, but it carried the same void handle(...): a store failure in the quota check or on a pairing frame ended that process the same way, and now closes one socket.
    • Why it shipped: nothing covered it. Every relay test handed the router frames that validate. There are now tests for a malformed payload per known type, for unknown types still being ignored with the socket open, for a well-formed turn still working after one, for a rejection inside the handler closing one socket while the Hub goes on serving — and the one that names the bug: a second live session survives the first session's bad frame. examples/hub-server has no suite of its own (it has no test runner, and adding one is a dependency change this fix did not want to carry), so its seam is covered by the package tests underneath it and by the identical seam in examples/hub-answerer, which does have one.
  • @schwaizer/ocp-hub: the §14 trail gets one shape. A typed AuditEvent union covering exactly the events core.md §14 says MUST be auditable — content-free by construction: no free-text field exists, there is no turn event at all, and a confirm outcome is capped at allow/deny because a choice's option id is content by another name. JsonlFileSink rotates by UTC day and sweeps past a 400-day default retention (EU AI Act Art. 26 sets a six-month FLOOR; FINMA documentation expectations argue for longer); LogSink adapts a pino-like logger for SIEM-bound deployments. The README maps each event type to the duty it serves, and says plainly that the mapping is an engineering aid, not legal advice.

  • .well-known gains the deployment declaration. An OPTIONAL deployment object — answerMode, operator, dataResidency, retention — makes the relay-or-answerer disclosure machine-readable (SPEC/companion.md §1.1). Two sentences carry the weight: a deployment that declares MUST declare truthfully, and a declaration is a CLAIM the wire cannot 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. The companion client fetches it failure-blind after approval and hands it to the app untouched (@schwaizer/ocp-companion 0.8.0, whose only change this is); both example Hubs serve it from env, each stating the only truthful answerMode for its shape. Every existing document stays valid; all 61 vectors untouched.

  • docs/compliance-mapping.md maps each wire mechanism to the regulatory checkbox it serves — AI Act Arts. 12/14/26/50, FINMA 08/2024, nFADP Art. 22, privatim — dated as of 2026-08, because the CoE-convention bill is due for consultation at the end of the year and the mapping is written against moving law. What it is not: legal advice, a certification, or a claim that the protocol can make a deployment compliant.

  • Both example Hubs emit the §14 trail. Each reference now writes the canonical audit events beside its funnel lines — the same moments, opposite jobs: the funnel measures, the trail evidences. The answerer additionally demonstrates the file sink and its retention sweep: OCP_AUDIT_DIR routes events to UTC day files, OCP_AUDIT_RETENTION_DAYS bounds how long a rolled file survives.

0.8.0 — 2026-08-15

Wire stays 1.0 and every vector is untouched. The widget gains an observable surface and the repository gains a second runnable Hub, so this is a MINOR.

  • @schwaizer/ocp-embed 0.8.0: the widget can offer the store. data-app-ios / data-app-android on the embed tag put an install line under the deeplink control — platform-detected from the UA, both links only when the device cannot be placed, and absent config renders the previous frame byte-for-byte. Validation runs twice on purpose: the loader throws at boot so a site's typo surfaces to the site, and the widget re-checks its own URL because an iframe URL is writable by anyone, and a rendered javascript: href would execute on the Hub's origin. The frame sandbox gains allow-popups only when a link is configured; top-level navigation stays blocked always.

  • examples/hub-answerer — the answering Hub becomes copyable. The relay had a runnable reference and the answerer had only one proprietary deployment, which made the operator-pays shape a reading exercise instead of a terraform apply. This one composes the same @schwaizer/ocp-hub blocks with an OpenAI-compatible streaming provider (three env vars; a Swiss-hosted Apertus endpoint works unmodified), reference-grade grounding, a monthly quota that refuses with RATE_LIMITED and logs the refusal, and the same five funnel events as the relay. answeredBy {mode: 'hub', operator, brainId} rides session.linked and every turn.done: this Hub reads the question, and the wire says so on every frame that matters.

  • The Hub image workflow publishes both shapes per version tag — ocp-hub and ocp-hub-answerer, same tree, same guard, same version — so a deploy can pin either without wondering whether they diverged.

  • packages/ocp-protocol/README.md no longer presents sealing as live. The docs site republishes that README byte-for-byte as its /ocp landing page, and it still listed sealing.md among the normative SPEC files, described the sealed relay in the present tense, and linked the root README by a repo-relative path that 404s off-repo. The listing now points at SPEC/retired/sealing.md with its status, the wire-contract paragraph says registered-for-parsing-only in the past tense, and the link is absolute. Docs only: no wire change, no vector change.

0.7.1 — 2026-08-09

@schwaizer/ocp-hub only. No wire change, no vector change; the spec's §14 cap (≤90 s) is untouched — this release moves the reference Hub to it.

  • The pairing token lives 90 s, up from 15. The 15-second life was tuned for the QR: a camera consumes the code in about a second, so shorter read as safer. But §2.1's spoken code resolves to the SAME token and expires with it, and the widget re-mints at 70% of the token's life — so the code a visitor was reading rotated off the screen every ~10 s. The route that exists for the visitor who CANNOT use the camera was priced for the camera, which made it decorative. Ninety seconds is a pace a person reading eight characters off a kiosk and typing them into a phone can actually beat.
  • What the short life looked like it bought, it did not buy. Replay dies at the atomic nonce burn at any TTL; a captured-but-unclaimed token buys only an approval prompt a human on the Surface still has to accept; and the 40-bit code behind the shared claim limiter is not a search anyone finishes in 15 seconds or 90. The widget needs no code change — it paces itself from expiresIn and now refreshes every ~63 s.

0.7.0 — 2026-08-08

The release where retiring sealing actually happens, rather than being announced. 0.6.0 retired it in ocp-embed's loader, SPEC/ and one guide, and an audit found it still shipping in both published packages, still exported, still taught, and still settleable by the relay. Wire version stays 1.0 and all 61 golden vectors still pass — but a Hub's observable settlement changes, so this is a MINOR bump and ^0.6.x consumers must opt in.

The relay can no longer settle a sealed session

  • settleSealMode now returns 'none' unconditionally. Its three conditions were all correct and all moot: nothing behind them works, because §6.1 requires the claiming device's cnf.jkt in the transcript salt and no registered frame ever carried a thumbprint to the Surface. So a Hub settling 'e2e' told both peers a session was end-to-end sealed while neither had sealed anything — worse than settling 'none', because an unsealed session is merely unsealed and a falsely sealed one ends up in someone's privacy copy. The signature and export stay; only the answer changes.
  • The one relay behaviour that hung off 'e2e' was a seal.clear_content log line, which is now unreachable. Its test is rewritten to assert the half that still matters and always will: whatever the Hub writes down, the visitor's words are not in it.

?seal=require no longer reaches live widget code

  • The loader has refused seal="require" at config time since 0.6.0. The widget is an iframe with a URL, and a stale embed cached by a CDN, a copied link, or a page pinning an old loader could still put ?seal=require on it — where it did what it always did, which is refuse EVERY pairing while telling the visitor sealing is "unavailable". It now reads as off, so both doors agree. offer is unchanged; it never promised anything.
  • The resolution moved out of widget/browser.ts into widget/seal.ts as sealPolicyFromQuery, by browser.ts's own rule that decisions do not live in the untestable DOM seam. Nothing covered this path before — the widget's tests drive sealPolicy straight into the harness, so the URL was never read in a test at all.

Deprecated, not removed

  • @schwaizer/ocp-companion: PairInput.onSealCommit / onSealReveal.

  • @schwaizer/ocp-protocol: the top-level sealing.js / seal-contract.js re-exports.

  • @schwaizer/ocp-hub: settleSealMode.

    All still behave as documented. Removing them is a breaking change, and the message types stay registered in wire 1.0 because the ocp-seal-1 golden vectors verify them — withdrawing a registered type is a major version and a deliberate release, not a side effect of this one.

Also

  • A ~1-in-10 flake in ocp-embed is fixed, and it had the worst possible shape: green alone, green in its own package, red only under pnpm test where the whole monorepo contends. sealed() waited eight fixed macrotasks for a handshake that does ECDH plus five HKDF derivations. It now waits for the SAS choices to exist. Ten consecutive full-suite runs green.
  • SPEC/companion-provider.md §5's RFC-2119 MUST to negotiate sealing is rewritten to require transcript="phone-only", with the old text quoted and explained rather than deleted. The four bounds under it were sealing-specific and are now phone-only's real ones — including the tool path, which sealing framing never had to admit.
  • build-a-companion.md and implement-a-hub.md no longer teach sealing as a live feature with working samples.
  • 40 files pointed at SPEC/sealing.md, which moved in 0.6.0. All repointed, except two CHANGELOG lines that record where the file was at the time.
  • @schwaizer/ocp-companion: a known-answer vector now pins the device-key derivation. The previous tests asserted determinism and agreement with jose, both of which derive fresh on each side and so held for any constants — changing HKDF_INFO or the 48-byte reduction left the suite green while every pairing in the field would have closed 4001. Verified by perturbing each.

0.6.2 — 2026-08-08

@schwaizer/ocp-companion only. Identical code to 0.6.1 — a republish, because 0.6.1's TARBALL is unusable.

  • 0.6.1 shipped "@schwaizer/ocp-protocol": "workspace:^". It was published with npm publish instead of the pnpm publish that PUBLISHING.md documents, and only pnpm rewrites the workspace protocol to a real range on the way out. The package was not partly broken; it could not be installed at all, by anyone. Not only by npm consumers, which is what the error npm prints invites you to assume: npm fails with EUNSUPPORTEDPROTOCOL because it has never heard of the protocol, and pnpm fails with ERR_PNPM_WORKSPACE_PKG_NOT_FOUND because it has — it resolves the specifier against the CONSUMER's workspace, hunting there for a package the publisher meant to name in its own. Both give up before fetching a file.
  • 0.6.2 carries "^0.6.0", verified by unpacking the tarball before publishing rather than after — see pnpm check:publishable, added below, which is now part of pnpm verify.
  • 0.6.1 is deprecated on the registry and points at this version.
  • Every other published version was checked; the leak is confined to 0.6.1.

The gate that missed it

  • pnpm check:publishable packs each publishable package the way a release would and asserts the RESULT — no dependency specifier may reach the registry as workspace:, catalog:, link: or file:. Every existing gate ran against the source tree, where workspace:^ is not merely legal but correct, so no amount of checking the repo could have caught a defect that is created by the publish command itself. This one checks the artefact.

0.6.1 — 2026-08-08 (deprecated — do not use)

@schwaizer/ocp-companion only. No wire change, no vector change — 0.6.0 shipped the answering surface with three gaps that a consumer only finds when it tries to replace its own copy with it.

  • The answering types are exported. emit(), tools(), linked() and callTool() shipped in 0.6.0; CompanionAnswerEvent, CompanionTool, CompanionToolOutcome, LinkedSettlement and MirrorMode did not. A caller could invoke the methods and could not NAME what they deal in — no declared field, no helper signature, no re-export — so the only way to hold one of these values in a typed variable was to redeclare its shape by hand, which is the hand-maintained second copy a shared definition exists to prevent.
  • capabilities may return null. onSocketOpen already treated a falsy return as "declare nothing" and the type forbade saying it, which made the choice a per-CLIENT one. An app builds its client once at launch and the visitor configures a model long afterwards, so the device that has no model yet had to either claim capabilities it cannot honour or hold a second client. Now the callback decides, at each connect.
  • callTool accepts a signal. Between stages an answer loop can check its own; awaiting a Surface tool there is no boundary to check at, so a visitor who pressed stop mid-tool waited out the full 30 s ACTION_TIMEOUT_MS before the loop returned. A signal already aborted also sends no action.request at all — on a write tool that frame is a confirm prompt on the Surface for something nobody wants any more. Polled rather than listened for: the loops that pass one run on Hermes, where an AbortSignal is not reliably an EventTarget.

0.6.0 — 2026-08-08

Breaking in three places, all of them a setting or a signature rather than a frame: no golden vector changes meaning and the wire version stays 1.0.

Sealing is retired (SPEC/retired/sealing.md)

  • seal="require" is refused at config time. It never worked. §6.1 binds the claiming device's cnf.jkt into the transcript salt and calls the binding not optional, and no registered frame ever carried a thumbprint to the Surface — pairApprovalRequestSchema has sealPubKey and via and nothing else. So the widget always read a field that never arrives and always took the unsealable branch: offer ran in the clear and require refused EVERY pairing, from every Companion, always, showing the visitor "unavailable" and the operator nothing. A setting whose only effect is to refuse all traffic is worse than absent, and worst when its name promises a security property.
  • offer still parses and still runs unsealed, which is what it always did — the difference is that offer never promised anything.
  • The WIRE CONTRACT IS UNCHANGED. sealed, sealMode, pair.reveal, SEAL_REQUIRED and the ocp-seal-1 vectors stay registered in 1.0, because removing a message type breaks golden vectors and that is a major version and a deliberate release, not a side effect of this one. 61 vectors still pass.
  • SPEC/sealing.md moves to SPEC/retired/ under a banner stating what happened, what replaced it — transcript="phone-only", which §1 itself named as stronger, and which needs no cryptography — and the one thing NOT replaced: §9's confirm-under-sealing, an untrusted relay gating an authorization decision on content it cannot read, which has no counterpart in MLS, SFrame, Noise or FIDO and is left standing as a named open problem.
  • README and the DPIA template corrected rather than repointed: both offered sealing as a shipped control, and the DPIA listed it as the ONLY control for "relay reads conversation". That row now reads "none".

The pairing rate limit was not one (@schwaizer/ocp-hub)

  • clientIp read the LEFTMOST X-Forwarded-For entry. The header is append-only and client-writable: a caller sends one and a well-behaved proxy APPENDS the real address, so the leftmost entry is whatever the caller typed. Rotating one header bought an unlimited claim budget; sending a victim's address exhausted theirs. That limiter is the documented nonce-guessing control and the entire anti-grinding argument for 0.4.0's 40-bit spoken code. The reference now counts OCP_TRUSTED_PROXIES entries from the RIGHT, and defaults to 0 — a deployment that has not said how it is fronted gets the answer that cannot be spoofed.
  • /ocp/pair/start was unbounded. authorizeStart checks the principal owns the session, and on a public Hub that principal comes from the site key, which ships in the loader tag: it says WHICH site, never WHO. BREAKING: PairStartRequest now requires clientKey, and limits.start is optional in the deps but absent means unbounded. The limiter runs BEFORE authorize, because the path an attacker is on is the one that fails authorization.

Relay: two Core §8/§9 obligations that were not implemented

  • context.set was mirrored from EITHER peer while tools.advertise right above it always required from === 'surface'. It is a full replace of the Surface's context (§6) — it describes the page — and a host is handed session.context while §9 tells it to scope tools to the current resource. A paired phone able to rewrite resource could widen its own authorization.
  • Barge-in never emitted turn.error{TURN_ABORTED} for the superseded turn, which §8 makes a MUST. The old turn got no terminal frame at all, so a Surface keyed on terminal frames waited forever and an answering Companion kept generating an answer nobody would read. The embed profile already did this.

The device-key PoP has a wire format (SPEC/companion.md §3, NEW)

  • Core §14 makes it mandatory on every connect and resume, and it was specified NOWHERE — the only definition was inside one Hub's verifier, so an independent Companion could not build a conformant proof and no vector covered the most security-critical handshake in the protocol. §3 now states the query parameters, the exact {th, ts} payload, the five verification steps in order, why jwk is required and why ±60 s is a replay bound rather than clock sync. Golden vector popConnect pins what a vector can pin: th, the thumbprint binding, and a verifiable sample — ES256 is not reproducible byte for byte.
  • Writing it down found a live fail-open: Math.abs(now - undefined) is NaN and every comparison against NaN is false, so verifyCompanion ACCEPTED a proof carrying no timestamp — a signature that never goes stale. Rejecting a non-finite ts is now normative and implemented.

The reference peers can connect (@schwaizer/ocp-companion)

They ship in this repository at the same version and could not link. Both suites were green throughout, because nothing tested the contract between them.

  • The client sent no jwk, so the Hub was verifying a signature it had no key for: every connect closed 4001. It sent no session.open, so a Hub that links in reply to one never linked it.
  • The resume loop was unbounded. Only 4003 was terminal; every other close scheduled another fixed 1 s retry that re-entered the same path — an endless 1 Hz loop re-signing ES256 each time, against a Hub that had already refused the identity. All 4xxx codes are now terminal and real drops get five attempts across ~31 s, inside the 60 s grace.
  • An END-TO-END TEST now drives the real pairing service, nonce burn, PoP verification and session lifecycle against the real client. Its absence is why these diverged.

The answering layer moved into the package

  • tools(), linked(), emit(), callTool(), onLinked, onCancel, plus tools.advertise / action.result handling. session.open is gated on the caller declaring capabilities: omitted means the frame is not sent, so a consumer that predates the option puts identical bytes on the wire.
  • mirror / phone-only at the SEND boundary, which is the only place it works — it has to cover the turn.input the client writes itself from ask(), and a policy the caller applies is one the caller can forget.
  • @schwaizer/ocp-companion/device-key (NEW subpath): the P-256 implementation behind a {get, set} storage seam, with @noble as an OPTIONAL peer so a consumer injecting its own crypto still installs a dependency-free client. Ten tests pin ocp-device-root-v2, ocp/device-key/v2 and the 48-byte reduction: move any of them and every pairing in the field stops verifying, silently, and every user re-scans.
  • @schwaizer/ocp-companion/deeplink (NEW subpath): parsing with the cap origin check companion.md §2 makes normative and the package never enforced.
  • BREAKING: the package now declares an exports map, so a deep import into src/ that used to resolve no longer does.

@schwaizer/ocp-webmcp is unchanged and stays at 0.5.0.

0.5.0 — 2026-08-07

The stop button works across devices (SPEC/core.md §8)

  • The reference Hub now RELAYS turn.cancel. The frame was defined in Core §8 and sent by both reference peers from the start — and relayed by nobody, so every stop stopped only the device it was pressed on while the other peer kept generating, and paying, for an answer nobody would read. A valid cancel is forwarded to the other peer and the Hub closes the turn for BOTH transcripts with turn.error{TURN_ABORTED}, which also covers a peer too old to know the frame: unknown types are ignored (§2), TURN_ABORTED is not.
  • A cancel for a turn that is not the open one is dropped SILENTLY, the same anti-probing rule answer frames follow — an error back would let a peer discover live turn ids by watching which ones produce TURN_ABORTED.
  • @schwaizer/ocp-companion surfaces the relayed cancel as a { kind: 'cancelled' } turn event, distinct from 'error' on purpose: an answering app must abort generation, not render an apology.

A typed code can seal (SPEC/companion.md §2.2, NEW)

  • 0.4.0 ended honestly: a visitor who cannot see the screen and needs a SEALED session had no path, because sealing's security was the camera — s rides the deeplink client-side and the Hub never sees it. §2.2 carries s through the Hub under COMMIT-THEN-REVEAL: the Surface registers SHA-256(s_pub) when it mints the code; the phone resolves the code and receives that commitment BEFORE disclosing its own key (POST /ocp/pair/resolve, NEW — the split resolve/claim ordering is the entire anti-grinding argument); the Hub marks code-borne claims via: 'code' first-hand; a sealing Surface answers pair.reveal (NEW frame — the one place in the protocol s may travel), once, for code-borne claims only; the phone verifies the hash and fails CLOSED on any mismatch. Same salt preimage, same six digits, same forced choice.
  • The inversion is stated rather than smoothed over: on this path the SAS ceremony IS the authentication — ~20 bits, online-only, one blind 10⁻⁶ guess per pairing — where the camera path keeps its stronger structural guarantee untouched. sealing.md §3's ban on transmitting s now names its single exception and stays absolute for scanned pairings.
  • pair.approval_request gains via: 'qr' | 'code' (absent reads 'qr' — the reveal must never default on); resolution shares the claim rate limiter and its failures are byte-identical to claim failures, or the 40-bit code becomes an oracle; the resolve response carries the token's sid and nonce, the transcript inputs a deeplink would have carried and eight characters cannot.
  • The reference widget now actually RETURNS and DISPLAYS the spoken code — the Hub had been minting one and the widget dropped it on the floor — with the characters spaced for screen readers, and sends its half of the reveal only for claims marked via: 'code' on a pairing where it offered a code.
  • Golden vectors: pair.reveal and a code-marked approval request (61 total).

0.4.0 — 2026-08-06

Pairing without sight (SPEC/companion.md §2.1)

  • A QR is a visual gate, and it was the only way in. A Hub MAY now mint a short spoken code alongside the deeplink; it resolves to the SAME token, so the single-use nonce, the device-key PoP and the mandatory on-Surface approval all stand unchanged between a guess and a paired device. A guessed code buys exactly what a photographed QR buys.
  • pairClaimByCodeSchema (NEW) and POST /ocp/pair/claim-by-code. The route shares the CLAIM rate limiter rather than getting its own — two buckets would double an attacker's budget for the same door — and a resolution failure is indistinguishable from a claim failure, or the code becomes an oracle.
  • PairInput accepts { code, iss }. The scanned path is unchanged.
  • The widget shows the host beside the code. Eight characters say what to send and not where; a companion app that pairs with any Hub needs both.
  • Sealed pairings get no code, and cannot. The Surface's ephemeral key rides on the deeplink appended client-side, where the Hub never sees it. A code is resolved BY the Hub, so a sealed pairing established that way is one whose key exchange the Hub mediates — what SPEC/retired/sealing.md §4 exists to prevent. The residual gap is stated rather than smoothed over: a visitor who cannot see the screen AND needs a sealed session still has no path.
  • The deeplink is also rendered as a real <a href>. That fixed a plain bug wearing an accessibility gap as a disguise: a device cannot photograph its own screen, so a visitor browsing the site ON the handset with the app could not pair at all.

@schwaizer/ocp-webmcp (NEW)

Declares a Surface's tool catalogue to the browser's own agent, so the same-device case the README concedes does not become a second catalogue to maintain. A tool declaring write: true is never registered and there is no option to override that: Core §10 wants a human confirmation that expires to deny, and WebMCP has nowhere to put one.

Tooling

  • ocp-conform — the golden vectors against any implementation, over stdin/stdout. Three verdicts, because §2's accept/ignore/reject distinction is what makes the protocol additive and a binary harness would pass an implementation that rejected every future frame.
  • pnpm check:pack — every re-exported module is inside the published tarball.
  • scripts/no-egress.mjs — the suites are hermetic by enforcement now.
  • docs/dpia-template.md — for deployers, including the claims a deployment may not make.

0.3.0 — 2026-08-06

0.2.0 predates every line below it. That is worth stating because a consumer pinned to ^0.2.0 and writing against this repository's working tree gets a compiler error naming a missing export — SEAL_LABELS, say — which reads as a version mismatch rather than as "the feature was never published". One did, for three days, while its CI stayed red and a hand-patched node_modules kept the local build green. pnpm check:pack (new) catches the adjacent fault, a tarball whose index.ts re-exports a module outside files; nothing but a consumer's own CI catches staleness, so that red is worth more than a local green.

Content sealing (protocol layer)

  • SPEC/sealing.md (NEW, since moved to SPEC/retired/): a negotiated, optional profile under which the Hub routes without reading. answerMode said where INFERENCE happens and never said what the relay could read; the relay — and whatever terminates TLS in front of it — saw every turn.input and turn.delta in plaintext, which is the gap that mattered once the test became key custody rather than residency.
  • Negotiation: capabilities.seal OFFERS, session.linked.sealMode SETTLES. Settlement is a separate top-level field, not a capability, because both reference Hubs echo capabilities back verbatim instead of intersecting them — a seal capability would have made every Hub that merely bumped this package assert a security property with nothing behind it, and a Surface would have committed to sealing against it. Absent sealModenone ⇒ today's behaviour exactly.
  • Wire: sealed{suite, kid, epoch, counter, ct} — is accepted INSTEAD of the cleartext field on turn.input, turn.delta, turn.citation, context.set, action.request, action.result, and on ui.request where it carries the gated action's unit verbatim. A frame carrying BOTH is rejected: that is the shape a downgrade takes.
  • Crypto contract: seal-contract.ts + vectors/sealing.json pin the suite ocp-seal-1 byte for byte — HKDF labels, the transcript salt, the AAD layout, the nonce rule, the padding frame, the SAS reduction — with known-answer vectors so an implementation that differs fails a test rather than merely failing to talk. No primitives here: consumers use WebCrypto or @noble/*.
  • Core §2 (AMENDED, and it was wrong before): a relay that re-serialises a frame MUST carry unknown payload fields through. "Ignore unknown fields" is a rule for terminal receivers; a broker that validates with an older schema and re-emits DELETES the field and forwards a hollow frame. Sealing is therefore not transparently additive at the Hub, and settlement is what says so.
  • Core §5.3, §9, §10, §14 and companion.md §6 reference the profile. companion-provider.md §5 — which said "OCP v1 specifies no payload sealing" — now carries the bounded claim, with the limits attached rather than appended: the Hub serves the Surface's widget code, so a Hub that ships modified code to one visitor can read that visitor's conversation and the digit check cannot detect it. "The Hub cannot see your conversation" remains a sentence implementations MUST NOT use, sealed or not.
  • Error code SEAL_REQUIRED (NEW): sealing negotiated and then not honoured. Distinct from UNSUPPORTED ("never negotiated") because this one is a security event and has to be separable in an audit log.
  • Fields: sealPubKey on pair/claim and pair.approval_request, carrying the Companion's ephemeral key so the Surface can derive the SAS for the approval screen it already shows. The Surface's key is never on the wire — it reaches the phone by camera, which is what makes the offer unstrippable.
  • ADDITIVE. Wire version stays 1.0: no golden vector changes meaning, no reject vector starts passing, and v is not the gate — negotiation is, and a bump would be mistaken for a compatibility control. 13 valid and 12 reject vectors were ADDED.

Content sealing (Hub)

  • @schwaizer/ocp-hub — the relay now FORWARDS THE PAYLOAD IT RECEIVED rather than the one it parsed (server/sealing.ts, forwardedPayload). This is the single change without which nothing else in the profile works: Zod payload objects are strip-mode, so the previous behaviour — rebuild the envelope from the parse — deleted sealed in transit and forwarded a frame that was still valid and no longer carried the content. It reads the validated payload for the metadata it acts on and forwards the raw one, which also fixes the general case: a peer speaking a later minor version no longer has its additions silently removed by a broker in the middle.
  • session.linked.sealMode is settled by relay.negotiate, which now returns it. 'e2e' requires BOTH peers to have offered AND answerMode: 'companion' — a Hub that answers has to read the question, so it cannot honestly settle otherwise. The one-shot negotiate(caps) form never settles: it cannot know both peers offered. Settlement is a compatibility signal and NOT a downgrade control; the reference Hub deliberately has no sealing switch, because a Hub-side one is a downgrade oracle held by the party sealing protects against.
  • Confirms split (sealing §9). The Hub keeps the gate — write, expiresAt, expiry-to-deny, surfaceOnly, the audit record, none of which needs plaintext — and relays the gated action's sealed input onto the ui.request byte-identically, by reference, never re-sealed. Allow <tool>? stays the Hub's title because it is the most it can honestly say; the Surface holds the key and composes what the human actually reads.
  • PendingClaim.sealPubKey carries the Companion's ephemeral sealing key from pair/claim to the host, which puts it on pair.approval_request. The Hub derives nothing from it and never sees the Surface's half.
  • Observability without retention (sealing §8): the relay logs seal.clear_content with the NAMES of cleartext fields on a sealed session and never their values, and relays the frame anyway — detecting the violation and failing closed is the peer's job, and a Hub that dropped the frame would turn a security event into silence. relay.sealModeOf(sid) gives a host a content-free sealed flag for its own events; the reference Hub puts it on pair_scanned and turn_answered.
  • UNCHANGED for anyone not sealing. A session that does not settle behaves exactly as it did before, answerMode: 'hub' deployments are untouched, and no existing frame gained a required field.

Embed — the launcher

  • @schwaizer/ocp-embed mounts a LAUNCHER (src/launcher.ts): the button that opens the panel. Until now the panel was revealed only by a host calling window.ocp.open(), and the loader shipped no control of its own — so on every site that pasted the snippet and wrote nothing else, no visitor ever saw anything. Device-answered turns per 100 impressions read zero for that reason and not for any product one.
  • Spec (Embed, NEW section "The launcher"): mounting one is now a MUST, with the accessibility rules attached rather than assumed — real <button>, aria-expanded + aria-controls, focus into the panel on open and back to the launcher on close, its own focus ring (all:initial removes the UA's), and prefers-reduced-motion honoured.
  • Revealed on ocp.ready, never before: a launcher that opens an empty panel is worse than one that appears a moment later. Where the widget never becomes ready, none appears — an offer that cannot be fulfilled is not made.
  • New OPTIONAL script attributes: data-launcher="off" (a host with its own trigger keeps open() and suppresses ours), data-position (four corners, bottom-right default), data-offset (px, capped so a typo cannot push the button off screen), data-label (launcher text and frame title; the loader's default is English, and the site is the only party that knows better).
  • EmbedHostDeps.onReady (internal, additive): how the browser entry learns the widget is up. Deliberately NOT an EmbedEvent — readiness is a property of the transport, and the host-facing event surface is unchanged.
  • No new wire message, no new host-page analytics event, and no change to what the loader sends the Hub. The loader is 4,097 B min+gzip against its 15,360 B budget (was 2,971 B).

0.2.0 — 2026-08-02

Where answers come from

  • Core §5.1 (NEW): sessions negotiate answerModehub or companion. Speech already negotiated where it runs; answer generation did not, which made "the Hub answers" an assumption of the wire rather than a choice. A peer that can generate answers, not merely render them, declares capabilities.answer; a Hub MUST NOT settle on companion unless the Companion declared it. In that mode the Companion MAY originate turn.delta / turn.citation / turn.done / turn.error for a turn it is answering.
  • Core §5.1: answering does NOT confer acting. Tool calls still travel as action.request and are re-authorised per call (§9); writes are still confirm-gated (§10). A Companion that can talk has not thereby earned the right to delete something.
  • Core §5.2 (NEW): answeredBy{mode, operator, brainId?} — on session.linked and on turn.done. This is disclosure, not routing. The two modes have opposite privacy properties: in hub mode the question reaches the Hub operator and whatever provider it runs; in companion mode it does not leave the visitor's device. A client that cannot tell which mode it is in cannot honestly tell its user, and the two are otherwise indistinguishable. session.linked states what the session settled on; turn.done states what actually answered, and implementations MUST NOT assume they agree — a Companion whose model becomes unavailable mid-session is the ordinary case.
  • ALL OF THE ABOVE IS ADDITIVE. answerMode is optional and absent MUST read as hub, so a Hub that never sends it and a peer that never reads it interoperate exactly as before. Wire version stays 1.0; no golden vector changes meaning. Two vectors were ADDED, covering a companion-answered session.linked and a hub-answered turn.done.

Embed

  • Spec (Embed): the widget document path is now normative — GET {hub}/embed/v1?site=<siteKey>. It was previously unstated, and it is the one contract a one-line <script> snippet cannot express: a site owner pastes a tag and a site key and nothing else, so a Hub serving the document elsewhere is unreachable by a stock loader however correct the rest of it is.

Previously unreleased

  • Spec (Embed): the Hub MUST serve the widget document with CSP frame-ancestors derived from the site key's allowedOrigins, and MUST NOT send X-Frame-Options. Previously unstated — a Hub with ordinary hardening defaults would send XFO: DENY and the widget would render on no site at all. Also makes the origin allowlist browser-enforced, not server-promised.
  • Spec (Embed): storage access MUST be fault-tolerant. In a third-party frame localStorage may be partitioned, blocked, or throw on mere access (Safari/ITP, Chrome partitioning), so the previous "visitor continuity via localStorage" was unimplementable as an unconditional guarantee. The widget MUST fall back to an ephemeral in-memory id and remain fully functional; Hubs MUST NOT assume visitor-id stability.
  • Spec (Embed): the ≤15 kB loader budget is now defined as MINIFIED AND GZIPPED on the published IIFE bundle. Previously unqualified, and raw vs min+gzip differ by roughly 4×, so the budget could not be enforced or even checked.
  • Spec (Embed): client-side allowlist checks (hostAllowed) are explicitly ADVISORY; enforcement is the Hub's (site key + frame-ancestors).
  • Spec (Embed): NEW minimal transport profile — POST {hub}/ocp/v1/turn with a text/event-stream response carrying the same Core envelopes as the WSS leg. The widget exists to be dropped onto someone else's site and pointed at their own backend; requiring them to run a WebSocket to answer one question at a time was a barrier with no protocol justification. A Hub MUST implement at least one profile and MAY implement both. No negotiation is specified: the Hub serves the widget document, so it already chooses.
  • Spec (Embed): the minimal profile pins the details that decide whether two implementations interoperate — Hub-minted sid when absent, client abort counts as turn.cancel, one in-flight turn per sid with Core's barge-in semantics unchanged, context.set carried in the same body so turn.done.contextRev attribution survives, and errors before the stream starts returned as an HTTP status plus a single error envelope so a broken deployment is diagnosable with curl. action.request / action.result, Surface-side tools.advertise and pairing are excluded: they need a back-channel, so host-page tools still require WSS.
  • Spec (Embed): the minimal profile's turn request MUST carry X-OCP-Site. Envelopes have no site-key field and sid is absent on a first turn, so a Hub serving more than one site had no specified way to learn whose configuration to apply — an interoperability hole in the profile as first written. A Hub MUST reject a missing or unknown site key before any stream opens.
  • Spec (Embed): stated that in this profile the POST's Origin header is the HUB'S OWN origin, because the widget document is same-origin with the Hub. It is therefore not evidence about the embedding page and MUST NOT be used as an allowlist control; frame-ancestors plus site-key validation remain the only enforcement. Worth writing down because the obvious header is right there, and a check against it would only ever be the Hub checking itself.
  • Packages: NEW reference Hub for the Embed minimal profile, shipped as the /embed export of @schwaizer/ocp-hub (it was drafted as a separate ocp-hub-ref package and never published under that name). Turns (one terminal frame, sid, contextRev, barge-in), pre-stream refusals as real HTTP statuses, per-visitor/IP quotas, and the widget document with the frame-ancestors header the spec now requires. Adapter-shaped: no model provider bundled, and no web framework, Response or node built-in imported, so it runs on node, Workers and Lambda alike. Unit-tested only, and its barge-in and default quota state are per-process.
  • Packages: @schwaizer/ocp-hub gains a RateLimiter seam with two implementations — createRedisRateLimiter (atomic INCR + conditional PEXPIRE in one script) and createMemoryRateLimiter. The pairing endpoints are unauthenticated and pollable, so their budget is the nonce-guessing control as well as the DoS one, and a budget enforced once per instance is N × the configured ceiling. Both in-memory controls (the limiter and the embed quota store) now REFUSE to construct under NODE_ENV=production without an explicit singleNode: true — the failure they guard against is silent, so it has to be caught at boot rather than discovered from traffic. Behaviour change for an embed Hub that constructs createMemoryQuotaStore in production: add { singleNode: true }, or back quotas with a shared store.
  • Packages: @schwaizer/ocp-embed now builds a browser IIFE bundle. pnpm size reports min+gzip against the 15 kB budget and prints the SRI hash, and it runs in CI, so the budget the spec states is now enforced rather than asserted.
  • No new message types and no schema changes: the minimal profile re-encodes existing envelopes over HTTP, and frame-ancestors reuses the existing allowedOrigins field of embed-config, so no golden vector is affected. Hubs and widgets implementing only WSS remain conformant.

0.1.2 — 2026-07-11

  • npm metadata only (homepage/repository/bugs on all packages). No wire changes.

0.1.1 — 2026-07-11

  • LICENSE texts shipped in tarballs (Apache-2.0 code, CC-BY 4.0 spec prose).
  • Spec: full Provider profile; Core gains the session state machine (§12), canonical flows (§13), Security Considerations (§14), and registries (Appendix A). No wire changes.

0.1.0 — 2026-07-11

  • Initial public release: Core + Companion + Embed profiles.
  • Wire: envelope, sessions (incl. session.refresh/session.refreshed), context, turns, tools, confirms, pairing, embed host↔widget contract.
  • 40 JSON Schemas, golden conformance vectors, reference clients (ocp-companion, ocp-embed).