aboutsummaryrefslogtreecommitdiff
path: root/_docs/chat-contract.md
diff options
context:
space:
mode:
authorAgando <roduccomm@gmail.com>2026-10-23 19:29:31 +0200
committerAgando <roduccomm@gmail.com>2026-10-23 19:29:31 +0200
commit313f13955ad9c72895b0914ac828c61b3e469715 (patch)
tree8d46bba5452cfe539348a2f1b3fd963b61b45ec4 /_docs/chat-contract.md
parent1ff8df65ffefbd2eaa9100374fbcfb7828fdb717 (diff)
feat(chat): token-scoped chat endpoints, contract, host CLI, old POST retired (v4.6.71 phase B)
- api/chat/{lib,post,poll}.php: thread = sha256(token)[0:16]; flock read-modify-write; tmp+rename; ms epoch cursor; per-thread + per-IP windows with retry_after_ms; 401/413/507/405 carry a machine code, only rate_limited is retryable - _docs/chat-contract.md: frozen shapes, incl. why the cursor is ms and not ts - scripts/chat.py: list/show/reply, UTC, locked, atomic replace - api/debug/agent-replies.php POST -> 410 Gone with pointers (one write path) Measured: 401 no-token, 201+id, poll 1 msg, poll>since 1 new, data/chat 403, 4001 chars 413 (codepoints), 429 with Retry-After 576, round trip via chat.py.
Diffstat (limited to '_docs/chat-contract.md')
-rw-r--r--_docs/chat-contract.md90
1 files changed, 90 insertions, 0 deletions
diff --git a/_docs/chat-contract.md b/_docs/chat-contract.md
new file mode 100644
index 0000000..ee2d006
--- /dev/null
+++ b/_docs/chat-contract.md
@@ -0,0 +1,90 @@
+# newOS4 ⇄ Hermes chat contract
+
+**Status:** frozen 2026-10-23. Any change here changes both sides.
+**Scope:** one conversation per browser token. No accounts, no cookies, no admin session.
+
+## Identity
+
+| Item | Value |
+|---|---|
+| Token | 64 lowercase hex, generated by the browser (`crypto.getRandomValues`, 32 bytes) |
+| Header | `X-Chat-Token: <64 hex>` |
+| Thread | `substr(sha256(token), 0, 16)` — computed server-side, **never** sent by the client |
+| Storage | `spaces/newos4/data/chat/<thread>.json`, one file per thread, atomic replace (tmp + rename) |
+| Reachability | `data/chat/` is **not** web-reachable. No listing endpoint exists. |
+
+## POST `/spaces/newos4/api/chat/post.php`
+
+Request:
+
+```text
+X-Chat-Token: <64 hex>
+Content-Type: application/json
+
+{"text": "message body", "kind": "operator"|"agent"}
+```
+
+`kind` defaults to `operator`.
+
+Response `201`:
+
+```json
+{"success": true, "id": "MSG-20261023-184000-a1b2c3", "epoch": 1792778400123, "ts": "2026-10-23T18:40:00+00:00", "thread": "a1b2c3d4e5f60718"}
+```
+
+## GET `/spaces/newos4/api/chat/poll.php?since=<epoch>`
+
+`since` is **epoch milliseconds**, an integer. Omitted or `0` returns the full thread (up to 50 newest). Seconds were not enough: two messages in the same second made a strict `>` drop the second one.
+
+Response `200`:
+
+```json
+{"success": true, "thread": "a1b2c3d4e5f60718",
+ "messages": [{"id": "MSG-…", "epoch": 1792778400123, "ts": "2026-10-23T18:40:00+00:00", "author": "operator", "text": "…"}],
+ "awaiting": true,
+ "cursor": 1792778400123}
+```
+
+- `awaiting` = the last message in the thread was **not** written by `hermes`. There is no unread
+ counter: the server keeps no read state, so a counter could never clear.
+- `cursor` = epoch of the newest message in the thread. Clients store it and pass it as `since`.
+
+**The cursor is epoch, never `ts`.** `ts` carries an offset (`+00:00`), and comparing strings with
+mixed offsets silently drops messages in the same second and reorders others.
+
+## Authors
+
+| `author` | Written by |
+|---|---|
+| `operator` | the human in the app (`chat` / `feedback` command) |
+| `agent` | the in-page AI (`chat_hermes` tool) |
+| `hermes` | Hermes, host-side (`scripts/chat.py reply`) |
+
+## Errors — no silent success
+
+| Code | When | Body |
+|---|---|---|
+| `401` | token missing or not 64 hex | `{"success": false, "error": "missing or malformed X-Chat-Token"}` |
+| `413` | body > 32 KB, or text > 4000 chars | `{"success": false, "error": "text too long (max 4000 chars)"}` |
+| `429` | 20 messages per thread / 40 per IP per 10 min | `{"success": false, "error": "rate limit…"}` |
+| `507` | more than 500 threads on the host | `{"success": false, "error": "thread limit reached"}` |
+| `405` | wrong method | `{"success": false, "error": "Method not allowed"}` |
+
+Rules that follow from this table:
+
+- **Auth is checked before anything touches disk.** A `401` means no file was opened.
+- **Text is rejected, never truncated.** A shortened question gets a wrongly scoped answer.
+- Rate limits are two windows, per thread **and** per IP: a token is mintable in a loop, an IP is not.
+
+## Retention
+
+Newest 200 messages per thread, 30 days. Older messages are dropped on append and swept by the
+relay cron. A conversation is not an archive.
+
+## What this replaces
+
+`feedback <msg>` and `chat <msg>` are the **same code path**; `feedback` is a deprecated alias.
+Neither writes to the bug-report intake any more. `chat-poll` = `feedback-inbox`.
+
+`POST /api/debug/agent-replies.php` is retired in favour of this contract: after the acceptance
+round trip it answers `410 Gone` with a pointer to `/api/chat/post.php`. One write path, one truth.