From f91d3035e9b6f6934b254207fad6fd7bf28c2b20 Mon Sep 17 00:00:00 2001 From: Marcel Daake Date: Tue, 22 Sep 2026 12:58:46 +0800 Subject: [PATCH] Spec v0.3 and mock receiver: remove HTTP Basic and 429, require ts on escalation events HTTP Basic was cut from the endpoint scope on 28 Aug 2026 and never implemented; the forwarder has only ever sent X-API-Key. No ciopulse endpoint returns 429 either. Both are removed from the documented contract, keeping the advice to retry any 429 with backoff. Voice is documented as requiring 0.2 or later, since the forwarder sends it on 0.3. The mock receiver no longer accepts HTTP Basic, so it can no longer certify a request the real endpoint would reject, and escalation_to_human events now need an ISO ts, which the actor fallback depends on. Documentation corrections: the wire contract and its version are unchanged. Co-Authored-By: Claude Opus 5 --- CHANGELOG.md | 13 ++++++++ README.md | 2 +- docs/send-a-copy-spec-v0.3.md | 7 ++-- tests/test_handler.py | 52 ++++++++++++++++++++++++++++++ tools/mock-receiver/mock_ingest.py | 26 +++++++-------- 5 files changed, 83 insertions(+), 17 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index ba0bcca..df7abb6 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,19 @@ All notable changes to this project are recorded here. The format follows Keep a ### Documentation - README: turning on analytics in every flow a contact can pass through is now a deployment step (section 3, step 2), not just a prerequisite. The stock transfer-to-queue flow has no analytics block, so without it the leg after an agent transfer is never analysed. Added a check that a test transfer produces two analysis files, and a troubleshooting entry for "I can see the bot's turns but not the human agent's". +### Fixed +- Spec v0.3: removed HTTP Basic auth and the `429` response from the documented + contract. Neither is implemented by any ciopulse endpoint; the forwarder has + only ever used `X-API-Key`. Documentation correction — no payload that was + valid under v0.3 becomes invalid. +- Spec v0.3: voice now correctly documented as requiring contract_version + "0.2" or later, not exactly "0.2". +- Mock receiver: no longer accepts HTTP Basic, so it can no longer certify a + payload the real endpoint would reject. +- Mock receiver: `escalation_to_human` events now require a valid ISO `ts`, + which the bot/human turn-split fallback depends on. +- README: the contract reference no longer lists a `429` response, matching the spec. + ## [0.2.0] - 2026-09-18 Sends send-a-copy contract **v0.3**. Every valid v0.2 payload is also valid v0.3, so a receiver that accepts 0.3 needs no other change to keep working. diff --git a/README.md b/README.md index 593566f..fd0c65a 100644 --- a/README.md +++ b/README.md @@ -190,7 +190,7 @@ The payload follows the **send-a-copy contract v0.3**: v0.2 plus optional `turns - Endpoint: `POST https://app.cio-pulse.com/api/v5/ai-agent/transcripts` - Auth: `X-API-Key` header - Limits: 1 MB, 500 turns, `metadata` ≤ 2 KB -- Response: `202` with a receipt; `400` with field-level problems; `401`; `413`; `429` with `Retry-After` +- Response: `202` with a receipt; `400` with field-level problems; `401`; `413`. Rate limiting is not currently applied; the forwarder still retries any `429` with backoff - Full text: [docs/send-a-copy-spec-v0.3.md](docs/send-a-copy-spec-v0.3.md). The previous version stays at [docs/send-a-copy-spec-v0.2.md](docs/send-a-copy-spec-v0.2.md). ## 9. Design notes diff --git a/docs/send-a-copy-spec-v0.3.md b/docs/send-a-copy-spec-v0.3.md index d30206a..2cb170b 100644 --- a/docs/send-a-copy-spec-v0.3.md +++ b/docs/send-a-copy-spec-v0.3.md @@ -4,6 +4,7 @@ **Status:** Decided · 18 Sep 2026 · Contact: ciopulse **Changes from v0.2:** two optional fields, both decided 18 Sep 2026. `turns[].actor` says whether an `agent` turn came from a bot or a person. `conversation_id` joins the sessions of one conversation that a platform split across a transfer, and is the value the survey `tid` carries. Every valid v0.2 payload is a valid v0.3 payload; receivers accept 0.2 and 0.3 side by side for the life of v0.x. +**Corrections, 21 Sep 2026:** removed HTTP Basic and the `429` response, neither of which any ciopulse endpoint implements; clarified that voice requires `"0.2"` or later. Documentation only — no payload valid under v0.3 becomes invalid. **Changes from v0.1 (v0.2):** `channel` accepts `"voice"`; optional `platform_signals` block. --- @@ -15,7 +16,7 @@ POST https://app.cio-pulse.com/api/v5/ai-agent/transcripts Content-Type: application/json ``` -**Auth** (either): HTTP Basic — username = portal code, password = API key · or `X-API-Key` header. Both supplied by ciopulse at onboarding. HTTPS only. +**Auth:** `X-API-Key` header, supplied by ciopulse at onboarding. HTTPS only. ## Payload @@ -29,7 +30,7 @@ Content-Type: application/json | `started_at`, `ended_at` | ✔ | ISO 8601 with timezone | | `turns[]` | ✔ | Ordered, chronological. Each: `role` (`"user"` \| `"agent"` \| `"system"`), `text`, `ts` (ISO 8601 with timezone), and optionally `actor` (below) | | `turns[].actor` | – | **New in v0.3.** `"bot"` when the turn was produced by an automated agent, `"human"` when by a person; omit when unknown. Only meaningful when `role` is `"agent"`. Receivers that need the split and find it absent treat turns before the first `escalation_to_human` event as bot and later ones as human | -| `channel` | – | `"chat"` (default) or **`"voice"`** (new in v0.2). Voice means the turns are text produced by speech-to-text; ciopulse reads them exactly as chat. Voice requires `contract_version` `"0.2"`. Audio is never sent | +| `channel` | – | `"chat"` (default) or **`"voice"`** (new in v0.2). Voice means the turns are text produced by speech-to-text; ciopulse reads them exactly as chat. Voice requires `contract_version` `"0.2"` or later. Audio is never sent | | `outcome` | – | Your call: `"contained"` \| `"escalated"` \| `"abandoned"` \| `"unknown"` | | `events[]` | – | e.g. `{"type": "escalation_to_human", "ts": "…"}` | | `platform_signals` | – | **New in v0.2.** Measurements your platform already computed about this conversation. See below. Omit the block entirely if you have none | @@ -99,7 +100,7 @@ Unknown fields inside `platform_signals` are ignored, not rejected. - **Response:** `202 Accepted` + a receipt ID. Validation is synchronous (auth, schema, size); everything else is asynchronous. Typical response < 500 ms. - **Duplicates / retries:** re-POSTing the same `session_id` within 30 days replaces the earlier submission — retrying is always safe. -- **Limits:** payload ≤ 1 MB · ≤ 500 turns · `metadata` ≤ 2 KB · per-key rate limit. Errors: `400` (field-level detail), `401`, `413`, `429` (with `Retry-After`). +- **Limits:** payload ≤ 1 MB · ≤ 500 turns · `metadata` ≤ 2 KB. Errors: `400` (field-level detail), `401`, `413`. Rate limiting is not currently applied; senders should still treat any `429` as retryable with backoff. - **When to send:** once, when the conversation ends. Retry on `5xx` with backoff if convenient — or don't; a missed transcript is acceptable by design. - **Voice transcripts** read differently from chat (disfluencies, transcription errors). Send them as they are; do not clean them up. ciopulse's reader accounts for speech-to-text artefacts. diff --git a/tests/test_handler.py b/tests/test_handler.py index e5fff4a..3305098 100644 --- a/tests/test_handler.py +++ b/tests/test_handler.py @@ -372,3 +372,55 @@ def test_mock_rejects_bad_v03_fields(aws, env, mock_ingest): assert exc.code == 400 fields = {p["field"] for p in json.loads(exc.read())["problems"]} assert fields == {"conversation_id", "turns[0].actor"} + + +# ----------------------------------------------------------------------------- mock receiver: v4 corrections + +def _post_to_mock(url, body, headers): + import urllib.request, urllib.error + req = urllib.request.Request(url, data=json.dumps(body).encode(), method="POST", + headers={"Content-Type": "application/json", **headers}) + try: + with urllib.request.urlopen(req) as resp: + return resp.status, json.loads(resp.read()) + except urllib.error.HTTPError as exc: + return exc.code, json.loads(exc.read()) + + +def _valid_payload(**extra): + p = {"contract_version": "0.3", "session_id": "s-v4", "agent": {"id": "a", "version": "1"}, + "started_at": "2026-09-22T00:00:00+00:00", "ended_at": "2026-09-22T00:01:00+00:00", + "turns": [{"role": "user", "text": "hi", "ts": "2026-09-22T00:00:01+00:00"}]} + p.update(extra) + return p + + +def test_mock_rejects_http_basic(mock_ingest): + import base64 + basic = "Basic " + base64.b64encode(b"portal:test-key-123").decode() + status, body = _post_to_mock(mock_ingest["url"], _valid_payload(), {"Authorization": basic}) + assert status == 401 and body["hint"] == "send X-API-Key" + status, _ = _post_to_mock(mock_ingest["url"], _valid_payload(), {"X-API-Key": "test-key-123"}) + assert status == 202 + + +def test_mock_requires_ts_on_escalation_event(mock_ingest): + key = {"X-API-Key": "test-key-123"} + status, body = _post_to_mock(mock_ingest["url"], _valid_payload(events=[{"type": "escalation_to_human"}]), key) + assert status == 400 + [problem] = body["problems"] + assert problem["field"] == "events[0].ts" and "turns[].actor is absent" in problem["error"] + status, body = _post_to_mock(mock_ingest["url"], + _valid_payload(events=[{"type": "escalation_to_human", "ts": "yesterday"}]), key) + assert status == 400 and body["problems"][0]["field"] == "events[0].ts" + status, _ = _post_to_mock(mock_ingest["url"], + _valid_payload(events=[{"type": "escalation_to_human", "ts": "2026-09-22T00:00:30+00:00"}]), key) + assert status == 202 + + +def test_mock_other_events_still_need_only_type(mock_ingest): + key = {"X-API-Key": "test-key-123"} + status, _ = _post_to_mock(mock_ingest["url"], _valid_payload(events=[{"type": "custom_marker"}]), key) + assert status == 202 + status, body = _post_to_mock(mock_ingest["url"], _valid_payload(events=[{"type": "custom_marker", "ts": "soon"}]), key) + assert status == 400 and body["problems"][0]["field"] == "events[0].ts" # a ts, if given, must be ISO diff --git a/tools/mock-receiver/mock_ingest.py b/tools/mock-receiver/mock_ingest.py index 64cb1e4..7b5b6cb 100644 --- a/tools/mock-receiver/mock_ingest.py +++ b/tools/mock-receiver/mock_ingest.py @@ -14,12 +14,12 @@ GET /received what it has accepted, newest first GET /health -Auth: X-API-Key: or HTTP Basic (portal code : key) +Auth: X-API-Key: It enforces the contract strictly and returns field-level errors, so a 202 here means the payload really is conformant — that is the whole point of it. """ -import argparse, base64, json, re, uuid, sys +import argparse, json, re, uuid, sys from datetime import datetime, timezone from http.server import BaseHTTPRequestHandler, HTTPServer from pathlib import Path @@ -33,6 +33,9 @@ OUTCOMES = {"contained", "escalated", "abandoned", "unknown"} ISO = re.compile(r"^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(\.\d+)?([+-]\d{2}:\d{2}|Z)$") +# "seen" is keyed on session_id alone because this mock serves a single sender. The real endpoint keys +# idempotency on (tenant, session_id): two customers can legitimately send the same session_id. Do not +# port this single-key logic into the production endpoint. STATE = {"key": "testkey123", "log": Path("received.jsonl"), "seen": {}} @@ -136,6 +139,12 @@ def bad(f, m): for i, x in enumerate(ev): if not isinstance(x, dict) or "type" not in x: bad(f"events[{i}]", "each event needs a type") + continue + if x["type"] == "escalation_to_human" and "ts" not in x: + bad(f"events[{i}].ts", "escalation_to_human requires ts — the bot/human turn split " + "falls back to this timestamp when turns[].actor is absent") + elif "ts" in x and not iso(x["ts"]): + bad(f"events[{i}].ts", "must be ISO 8601 with timezone") return e @@ -155,16 +164,7 @@ def _send(self, code, body): self.wfile.write(b) def _authed(self): - if self.headers.get("X-API-Key") == STATE["key"]: - return True - a = self.headers.get("Authorization", "") - if a.startswith("Basic "): - try: - _, _, pw = base64.b64decode(a[6:]).decode().partition(":") - return pw == STATE["key"] - except Exception: - return False - return False + return self.headers.get("X-API-Key") == STATE["key"] def do_GET(self): if self.path == "/health": @@ -182,7 +182,7 @@ def do_POST(self): "hint": "POST /api/v5/ai-agent/transcripts"}) if not self._authed(): return self._send(401, {"error": "unauthorized", - "hint": "send X-API-Key, or HTTP Basic with the key as the password"}) + "hint": "send X-API-Key"}) n = int(self.headers.get("Content-Length") or 0) if n > MAX_BYTES: