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: