|
|
|
|
@@ -0,0 +1,510 @@
|
|
|
|
|
# The Hub Inbound API (v1)
|
|
|
|
|
|
|
|
|
|
**Purpose:** one frozen interface from every way in to the Hub.
|
|
|
|
|
**Applies to:** phone app, ESP32 voice, dsh chat, admin app, console portal.
|
|
|
|
|
**Status:** frozen. Build the Hub server and every client to this document.
|
|
|
|
|
**Decided:** 2026-10-10.
|
|
|
|
|
|
|
|
|
|
`hub_brief.md` is the source of truth for the design. This document freezes the
|
|
|
|
|
wire format. If this document and the brief disagree, the brief wins.
|
|
|
|
|
|
|
|
|
|
---
|
|
|
|
|
|
|
|
|
|
## 1. Service language (decided)
|
|
|
|
|
|
|
|
|
|
**Go 1.25.**
|
|
|
|
|
|
|
|
|
|
**Reason (one line):** The Hub is a small, always-on gateway on the fast path, so
|
|
|
|
|
a single static Go binary gives hard timeouts, no runtime dependency drift,
|
|
|
|
|
instant start, and a tiny container, and nothing in the Hub needs the Python
|
|
|
|
|
data or ML stack because every model call goes through OmniRoute over HTTP.
|
|
|
|
|
|
|
|
|
|
Build notes:
|
|
|
|
|
|
|
|
|
|
- Standard library `net/http` with `context` deadlines. Small dependency list.
|
|
|
|
|
- Multi-stage Docker build. Run on a static or distroless base image.
|
|
|
|
|
- Deploy as one container on `192.168.20.13` (the agreed host).
|
|
|
|
|
- One binary, one port, one folder. Do not merge a destination into the Hub.
|
|
|
|
|
|
|
|
|
|
Go 1.25 is available from nixpkgs (a derivation is already in the local store).
|
|
|
|
|
Docker 29 runs on the build host.
|
|
|
|
|
|
|
|
|
|
## 2. Two different `/ask` endpoints
|
|
|
|
|
|
|
|
|
|
The name `/ask` appears twice. They are different services on different ports.
|
|
|
|
|
Do not confuse them.
|
|
|
|
|
|
|
|
|
|
| Endpoint | Caller | Server | Defined in |
|
|
|
|
|
|---|---|---|---|
|
|
|
|
|
| `POST /ask` | A way in (phone, voice, chat, admin) | **The Hub** | This document |
|
|
|
|
|
| `POST /ask` | The Hub | A data project (email, school, internet control) | `docs/ask-contract.md` |
|
|
|
|
|
|
|
|
|
|
This document freezes only the **Hub** endpoint. The Hub sends the project
|
|
|
|
|
endpoint with the shape in `docs/ask-contract.md`.
|
|
|
|
|
|
|
|
|
|
## 3. Transport and authentication
|
|
|
|
|
|
|
|
|
|
### Base
|
|
|
|
|
|
|
|
|
|
- Base URL: `http://192.168.20.13:8787` (port 8787 is the proposed default,
|
|
|
|
|
`HUB_PORT`). Confirm the port against the house port list before deploy.
|
|
|
|
|
- Plain HTTP on the private LAN for v1. Put a TLS reverse proxy in front later.
|
|
|
|
|
- Request and response body: JSON, `application/json; charset=utf-8`.
|
|
|
|
|
- Maximum request body: 64 KiB. Larger bodies get `413`.
|
|
|
|
|
|
|
|
|
|
### Authentication
|
|
|
|
|
|
|
|
|
|
Every request carries a bearer token, except `GET /health`:
|
|
|
|
|
|
|
|
|
|
```
|
|
|
|
|
Authorization: Bearer <token>
|
|
|
|
|
```
|
|
|
|
|
|
|
|
|
|
- One token per **client**, not per person. Store each token in
|
|
|
|
|
`~/.config/environment.d/10-secrets.conf`.
|
|
|
|
|
- The Hub holds the accepted tokens. It compares them in constant time.
|
|
|
|
|
- The token names the device or channel. The token does **not** name the person.
|
|
|
|
|
|
|
|
|
|
| Env key | Client |
|
|
|
|
|
|---|---|
|
|
|
|
|
| `HUB_TOKEN_PHONE` | Phone app. |
|
|
|
|
|
| `HUB_TOKEN_ESP32` | House voice (ESP32, "Hey Jervis"). |
|
|
|
|
|
| `HUB_TOKEN_DSH` | dsh chat. |
|
|
|
|
|
| `HUB_TOKEN_CONSOLE` | Console portal. |
|
|
|
|
|
| `HUB_TOKEN_ADMIN` | Admin app. The only trusted door. |
|
|
|
|
|
|
|
|
|
|
### Auth matrix
|
|
|
|
|
|
|
|
|
|
| Route | Accepted tokens |
|
|
|
|
|
|---|---|
|
|
|
|
|
| `POST /ask` | Any client token. |
|
|
|
|
|
| `POST /approve` | `HUB_TOKEN_ADMIN` only. |
|
|
|
|
|
| `GET /health` | None. |
|
|
|
|
|
|
|
|
|
|
### Voice is shared, not trusted
|
|
|
|
|
|
|
|
|
|
Voice is open to everyone in the house. The ESP32 token proves the **device**,
|
|
|
|
|
not the speaker. The Hub never treats a voice request as proof of identity. A
|
|
|
|
|
restricted action always needs the admin app.
|
|
|
|
|
|
|
|
|
|
### One identity
|
|
|
|
|
|
|
|
|
|
There is one identity: Sam. The `asker` field must be `"sam"`. Any other value
|
|
|
|
|
gets `422 unsupported_asker`. Keep the field so a second person can be added
|
|
|
|
|
later.
|
|
|
|
|
|
|
|
|
|
## 4. Common response rules
|
|
|
|
|
|
|
|
|
|
### Success and partial success
|
|
|
|
|
|
|
|
|
|
A well-formed, authorized, processed request returns HTTP `200`, even when the
|
|
|
|
|
Hub cannot route the request. The top-level `status` field says what happened.
|
|
|
|
|
|
|
|
|
|
| `status` | Meaning |
|
|
|
|
|
|---|---|
|
|
|
|
|
| `ok` | A reply is ready. |
|
|
|
|
|
| `needs_clarification` | The Hub needs one short answer. `answer` holds the question. |
|
|
|
|
|
| `needs_approval` | A Hub-owned approval is required. `approval` holds the id. |
|
|
|
|
|
| `no_match` | The Hub cannot route the request. |
|
|
|
|
|
| `refused` | The request is a high-consequence control. The Hub does not do it. |
|
|
|
|
|
| `unavailable` | A destination or dependency is down. |
|
|
|
|
|
|
|
|
|
|
### Error envelope
|
|
|
|
|
|
|
|
|
|
An error uses a non-200 status and this shape:
|
|
|
|
|
|
|
|
|
|
```json
|
|
|
|
|
{
|
|
|
|
|
"status": "error",
|
|
|
|
|
"error": {
|
|
|
|
|
"code": "bad_request",
|
|
|
|
|
"message": "Field 'text' is required.",
|
|
|
|
|
"request_id": "01J8Z3K4M5N6P7Q8R9S0T1V2W3"
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
```
|
|
|
|
|
|
|
|
|
|
`message` is one short sentence. It is safe to speak aloud. It holds no secret,
|
|
|
|
|
no internal path, and no stack trace.
|
|
|
|
|
|
|
|
|
|
### HTTP status and error codes
|
|
|
|
|
|
|
|
|
|
| HTTP | `code` | Use |
|
|
|
|
|
|---|---|---|
|
|
|
|
|
| 400 | `bad_request` | Bad JSON, missing field, or bad enum value. |
|
|
|
|
|
| 401 | `unauthorized` | Missing or invalid bearer token. |
|
|
|
|
|
| 403 | `forbidden` | Valid token, wrong route. For example, a non-admin calls `/approve`. |
|
|
|
|
|
| 403 | `source_mismatch` | The `source` field does not match the token. |
|
|
|
|
|
| 404 | `not_found` | Unknown path, or unknown `approval_id`. |
|
|
|
|
|
| 409 | `not_hub_owned` | The approval belongs to another service. |
|
|
|
|
|
| 409 | `already_decided` | The approval was already decided. |
|
|
|
|
|
| 409 | `approval_expired` | The approval passed its `expires` time. |
|
|
|
|
|
| 413 | `payload_too_large` | Body over 64 KiB. |
|
|
|
|
|
| 415 | `unsupported_media_type` | Body is not `application/json`. |
|
|
|
|
|
| 422 | `unprocessable` | Valid JSON, bad content. For example, empty `text`. |
|
|
|
|
|
| 422 | `unsupported_asker` | `asker` is not `"sam"`. |
|
|
|
|
|
| 429 | `rate_limited` | Too many requests. See `Retry-After`. |
|
|
|
|
|
| 500 | `internal` | Unexpected Hub fault. |
|
|
|
|
|
| 502 | `destination_error` | A destination failed to answer. |
|
|
|
|
|
| 503 | `unavailable` | A dependency is down (OmniRoute, MQTT). |
|
|
|
|
|
| 504 | `timeout` | A deadline passed. |
|
|
|
|
|
|
|
|
|
|
### Rate limit
|
|
|
|
|
|
|
|
|
|
Cap each token at 30 requests per minute. On breach, return `429` with a
|
|
|
|
|
`Retry-After` header in seconds. This protects the Hub. It is not a security
|
|
|
|
|
control.
|
|
|
|
|
|
|
|
|
|
### Versioning
|
|
|
|
|
|
|
|
|
|
Add fields. Do not remove or rename a field. Clients must ignore unknown fields.
|
|
|
|
|
A breaking change needs a new path, for example `/ask/v2`, `/approve/v2`, or
|
|
|
|
|
`/health/v2`.
|
|
|
|
|
|
|
|
|
|
---
|
|
|
|
|
|
|
|
|
|
## 5. `POST /ask`
|
|
|
|
|
|
|
|
|
|
A way in sends words to the Hub. The Hub runs the five steps: hear, understand,
|
|
|
|
|
check, route, answer. A way in needs only this route to ask.
|
|
|
|
|
|
|
|
|
|
### Request
|
|
|
|
|
|
|
|
|
|
```json
|
|
|
|
|
{
|
|
|
|
|
"text": "what does Harry have on today?",
|
|
|
|
|
"asker": "sam",
|
|
|
|
|
"source": "esp32",
|
|
|
|
|
"person": "harry",
|
|
|
|
|
"want": "speech",
|
|
|
|
|
"device": "kitchen-esp32",
|
|
|
|
|
"request_id": "01J8Z3K4M5N6P7Q8R9S0T1V2W3"
|
|
|
|
|
}
|
|
|
|
|
```
|
|
|
|
|
|
|
|
|
|
| Field | Required | Type | Meaning |
|
|
|
|
|
|---|---|---|---|
|
|
|
|
|
| `text` | yes | string | The words the user said, or typed. 1 to 1000 characters. |
|
|
|
|
|
| `asker` | yes | string | Who asked. Must be `"sam"` in v1. |
|
|
|
|
|
| `source` | yes | enum | Where it came from. See below. |
|
|
|
|
|
| `person` | no | string | Who the request is about. Defaults to `asker`. |
|
|
|
|
|
| `want` | no | enum | Reply shape the caller prefers: `text`, `speech`, `both`. Default comes from `source`. |
|
|
|
|
|
| `device` | no | string | Device name, for speech routing. |
|
|
|
|
|
| `request_id` | no | string | Client id. The Hub makes one when absent. |
|
|
|
|
|
|
|
|
|
|
`source` values: `esp32`, `phone`, `chat`, `console`, `admin`.
|
|
|
|
|
|
|
|
|
|
The token must belong to the `source`. For example, `HUB_TOKEN_DSH` cannot claim
|
|
|
|
|
`source: "admin"`. A mismatch gets `403 source_mismatch`.
|
|
|
|
|
|
|
|
|
|
### Response — a normal reply
|
|
|
|
|
|
|
|
|
|
```json
|
|
|
|
|
{
|
|
|
|
|
"status": "ok",
|
|
|
|
|
"request_id": "01J8Z3K4M5N6P7Q8R9S0T1V2W3",
|
|
|
|
|
"answer": "Harry has maths and a music lesson today.",
|
|
|
|
|
"shape": "speech",
|
|
|
|
|
"speech": { "text": "Harry has maths and a music lesson today.", "target": "house" },
|
|
|
|
|
"route": { "kind": "project", "project": "compass_nhs" },
|
|
|
|
|
"approval": null,
|
|
|
|
|
"clarify": null,
|
|
|
|
|
"expires": "2026-10-10T09:00:00Z"
|
|
|
|
|
}
|
|
|
|
|
```
|
|
|
|
|
|
|
|
|
|
| Field | Required | Meaning |
|
|
|
|
|
|---|---|---|
|
|
|
|
|
| `status` | yes | See the table in section 4. |
|
|
|
|
|
| `request_id` | yes | Echo of the request id, or a new one. |
|
|
|
|
|
| `answer` | yes | One short sentence. Safe to speak aloud. |
|
|
|
|
|
| `shape` | yes | `text`, `speech`, `notification`, or `none`. |
|
|
|
|
|
| `speech` | no | TTS detail. Present when `shape` is `speech`. |
|
|
|
|
|
| `route` | no | What the Hub did. For logging and debug. |
|
|
|
|
|
| `approval` | no | Present when `status` is `needs_approval`. |
|
|
|
|
|
| `clarify` | no | Present when `status` is `needs_clarification`. |
|
|
|
|
|
| `expires` | no | When the answer stops being true. |
|
|
|
|
|
|
|
|
|
|
`shape` values:
|
|
|
|
|
|
|
|
|
|
- `text` — text only. Chat and console.
|
|
|
|
|
- `speech` — the Hub also speaks the reply.
|
|
|
|
|
- `notification` — a phone or desktop notification.
|
|
|
|
|
- `none` — no reply body. The action ran.
|
|
|
|
|
|
|
|
|
|
`speech.target` values: `house` (house speakers), `phone` (the asking phone),
|
|
|
|
|
`none`.
|
|
|
|
|
|
|
|
|
|
`route.kind` values: `direct` (Home Assistant, speakers), `project` (a data
|
|
|
|
|
project through `/ask`), `fanout` (several projects), `paperclip`, `clarify`,
|
|
|
|
|
`no_match`, `refused`.
|
|
|
|
|
|
|
|
|
|
### Response — the Hub needs a short answer
|
|
|
|
|
|
|
|
|
|
```json
|
|
|
|
|
{
|
|
|
|
|
"status": "needs_clarification",
|
|
|
|
|
"request_id": "01J8Z3K4M5N6P7Q8R9S0T1V2W3",
|
|
|
|
|
"answer": "School or email?",
|
|
|
|
|
"shape": "speech",
|
|
|
|
|
"speech": { "text": "School or email?", "target": "house" },
|
|
|
|
|
"route": { "kind": "clarify" },
|
|
|
|
|
"clarify": { "question": "School or email?", "options": ["school", "email"] }
|
|
|
|
|
}
|
|
|
|
|
```
|
|
|
|
|
|
|
|
|
|
### Response — a Hub-owned approval is required
|
|
|
|
|
|
|
|
|
|
A slow, recorded action (a Paperclip job) needs a yes. The Hub does not run it
|
|
|
|
|
yet. It returns an approval id.
|
|
|
|
|
|
|
|
|
|
```json
|
|
|
|
|
{
|
|
|
|
|
"status": "needs_approval",
|
|
|
|
|
"request_id": "01J8Z3K4M5N6P7Q8R9S0T1V2W3",
|
|
|
|
|
"answer": "I need a yes on the admin app to file that job.",
|
|
|
|
|
"shape": "speech",
|
|
|
|
|
"speech": { "text": "I need a yes on the admin app to file that job.", "target": "house" },
|
|
|
|
|
"route": { "kind": "paperclip" },
|
|
|
|
|
"approval": {
|
|
|
|
|
"approval_id": "ap_01J8Z3K4M5N6P7Q8R9S0T1V2W3",
|
|
|
|
|
"action": "paperclip_job",
|
|
|
|
|
"summary": "File a job: plan the kitchen lights.",
|
|
|
|
|
"reason": "A slow, multi-step job needs a yes.",
|
|
|
|
|
"expires": "2026-10-10T09:10:00Z"
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
```
|
|
|
|
|
|
|
|
|
|
`approval.action` values in v1: `paperclip_job`. Add values later. Never remove
|
|
|
|
|
one.
|
|
|
|
|
|
|
|
|
|
### Response — a high-consequence control
|
|
|
|
|
|
|
|
|
|
The Hub does not route a control action. It refuses and points at the admin app.
|
|
|
|
|
|
|
|
|
|
```json
|
|
|
|
|
{
|
|
|
|
|
"status": "refused",
|
|
|
|
|
"request_id": "01J8Z3K4M5N6P7Q8R9S0T1V2W3",
|
|
|
|
|
"answer": "Use the admin app to change internet limits.",
|
|
|
|
|
"shape": "speech",
|
|
|
|
|
"speech": { "text": "Use the admin app to change internet limits.", "target": "house" },
|
|
|
|
|
"route": { "kind": "refused", "reason": "high_consequence_control" }
|
|
|
|
|
}
|
|
|
|
|
```
|
|
|
|
|
|
|
|
|
|
### Response — no match
|
|
|
|
|
|
|
|
|
|
```json
|
|
|
|
|
{
|
|
|
|
|
"status": "no_match",
|
|
|
|
|
"request_id": "01J8Z3K4M5N6P7Q8R9S0T1V2W3",
|
|
|
|
|
"answer": "I do not know where to send that.",
|
|
|
|
|
"shape": "text",
|
|
|
|
|
"route": { "kind": "no_match" }
|
|
|
|
|
}
|
|
|
|
|
```
|
|
|
|
|
|
|
|
|
|
### Behaviour rules
|
|
|
|
|
|
|
|
|
|
1. The Hub never parses a project's data. It forwards the question.
|
|
|
|
|
2. The Hub never changes the meaning of an answer. It only changes the shape.
|
|
|
|
|
3. The Hub never places an LLM in the path of a fast or consequential action
|
|
|
|
|
(brief rule 4). Routing uses an LLM. The action call does not.
|
|
|
|
|
4. One project down is not an outage. Return `status: "unavailable"` and say so.
|
|
|
|
|
5. Every request is logged. See section 10.
|
|
|
|
|
|
|
|
|
|
## 6. `POST /approve`
|
|
|
|
|
|
|
|
|
|
The admin app answers "Did you ask for this?". This route records one yes or no
|
|
|
|
|
for a **Hub-owned** approval and then runs the approved action.
|
|
|
|
|
|
|
|
|
|
In v1 the only Hub-owned approval is a Paperclip job. The Hub files the job once,
|
|
|
|
|
then returns the result.
|
|
|
|
|
|
|
|
|
|
### Request
|
|
|
|
|
|
|
|
|
|
```json
|
|
|
|
|
{
|
|
|
|
|
"approval_id": "ap_01J8Z3K4M5N6P7Q8R9S0T1V2W3",
|
|
|
|
|
"decision": "approve",
|
|
|
|
|
"asker": "sam",
|
|
|
|
|
"device": "sam-phone"
|
|
|
|
|
}
|
|
|
|
|
```
|
|
|
|
|
|
|
|
|
|
| Field | Required | Type | Meaning |
|
|
|
|
|
|---|---|---|---|
|
|
|
|
|
| `approval_id` | yes | string | The id from the `/ask` response. |
|
|
|
|
|
| `decision` | yes | enum | `approve` or `deny`. |
|
|
|
|
|
| `asker` | yes | string | Who decided. Must be `"sam"` in v1. |
|
|
|
|
|
| `device` | no | string | The admin device. |
|
|
|
|
|
|
|
|
|
|
### Response
|
|
|
|
|
|
|
|
|
|
```json
|
|
|
|
|
{
|
|
|
|
|
"status": "ok",
|
|
|
|
|
"approval_id": "ap_01J8Z3K4M5N6P7Q8R9S0T1V2W3",
|
|
|
|
|
"decision": "approve",
|
|
|
|
|
"result": {
|
|
|
|
|
"kind": "paperclip_job",
|
|
|
|
|
"status": "filed",
|
|
|
|
|
"detail": "Issue filed."
|
|
|
|
|
},
|
|
|
|
|
"answer": "Filed the job."
|
|
|
|
|
}
|
|
|
|
|
```
|
|
|
|
|
|
|
|
|
|
`result.status` values: `filed`, `denied`, `failed`.
|
|
|
|
|
|
|
|
|
|
### Rules
|
|
|
|
|
|
|
|
|
|
1. An approval is single use. A second decision gets `409 already_decided`.
|
|
|
|
|
2. An unknown id gets `404 not_found`.
|
|
|
|
|
3. An id owned by another service gets `409 not_hub_owned`. The admin app must
|
|
|
|
|
call that service directly.
|
|
|
|
|
4. An expired approval gets `409 approval_expired`. The user must ask again.
|
|
|
|
|
5. Only `HUB_TOKEN_ADMIN` may call this route.
|
|
|
|
|
|
|
|
|
|
### Parental controls are not here
|
|
|
|
|
|
|
|
|
|
A parental control change is a high-consequence control. The Hub does **not**
|
|
|
|
|
execute it (brief rule 11). The admin app shows the confirmation and calls the
|
|
|
|
|
internet control service directly. This route is not in that path.
|
|
|
|
|
|
|
|
|
|
## 7. `GET /health`
|
|
|
|
|
|
|
|
|
|
Liveness only. No authentication.
|
|
|
|
|
|
|
|
|
|
### Response — healthy
|
|
|
|
|
|
|
|
|
|
```json
|
|
|
|
|
{
|
|
|
|
|
"ok": true,
|
|
|
|
|
"service": "hub",
|
|
|
|
|
"version": "1",
|
|
|
|
|
"uptime_s": 4321,
|
|
|
|
|
"checks": {
|
|
|
|
|
"omniroute": "ok",
|
|
|
|
|
"mqtt": "ok",
|
|
|
|
|
"log": "ok"
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
```
|
|
|
|
|
|
|
|
|
|
### Response — degraded
|
|
|
|
|
|
|
|
|
|
```json
|
|
|
|
|
{
|
|
|
|
|
"ok": false,
|
|
|
|
|
"service": "hub",
|
|
|
|
|
"version": "1",
|
|
|
|
|
"uptime_s": 4321,
|
|
|
|
|
"checks": {
|
|
|
|
|
"omniroute": "ok",
|
|
|
|
|
"mqtt": "down",
|
|
|
|
|
"log": "ok"
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
```
|
|
|
|
|
|
|
|
|
|
HTTP `200` when `ok` is true. HTTP `503` when `ok` is false.
|
|
|
|
|
|
|
|
|
|
Each `checks` value is `ok`, `down`, or `unknown`. A check never blocks the
|
|
|
|
|
health reply for longer than 1 second. The health reply holds no secret and no
|
|
|
|
|
personal data.
|
|
|
|
|
|
|
|
|
|
## 8. Timeouts
|
|
|
|
|
|
|
|
|
|
Every timeout is a hard cap. The Hub cancels the work when the cap passes.
|
|
|
|
|
|
|
|
|
|
| Step | Cap |
|
|
|
|
|
|---|---|
|
|
|
|
|
| Read the request body | 1 s |
|
|
|
|
|
| Routing LLM call (OmniRoute) | 3 s |
|
|
|
|
|
| One project `/ask` call | 3 s |
|
|
|
|
|
| Fanout (all projects, in parallel) | 4 s |
|
|
|
|
|
| One destination command (Home Assistant, MQTT) | 2 s |
|
|
|
|
|
| `POST /ask` total | 8 s |
|
|
|
|
|
| `POST /approve` total | 5 s |
|
|
|
|
|
| `GET /health` total | 1 s |
|
|
|
|
|
|
|
|
|
|
House TTS is out of band. The Hub starts it and does not wait for the speakers.
|
|
|
|
|
Phone TTS is part of the reply.
|
|
|
|
|
|
|
|
|
|
Data project rules stay as written in `docs/ask-contract.md`: a project answers
|
|
|
|
|
in under 3 seconds, or it returns `answer: null`.
|
|
|
|
|
|
|
|
|
|
## 9. Reply shape and TTS target
|
|
|
|
|
|
|
|
|
|
The `want` field is a preference. The Hub picks the final shape. It uses `source`
|
|
|
|
|
and `want`:
|
|
|
|
|
|
|
|
|
|
| `source` | Default `shape` | Default `speech.target` |
|
|
|
|
|
|---|---|---|
|
|
|
|
|
| `esp32` | `speech` | `house` |
|
|
|
|
|
| `phone` | `speech` | `phone` |
|
|
|
|
|
| `chat` | `text` | `none` |
|
|
|
|
|
| `console` | `text` | `none` |
|
|
|
|
|
| `admin` | `text` | `none` |
|
|
|
|
|
|
|
|
|
|
`want: "both"` returns text and adds speech. `want: "text"` never speaks.
|
|
|
|
|
A spoken reply is always one short sentence.
|
|
|
|
|
|
|
|
|
|
This settles the open question "when does phone TTS get used?": the Hub speaks on
|
|
|
|
|
the asking phone when the source is the phone, and on the house speakers when the
|
|
|
|
|
source is the house voice. The caller may override with `want`. Artemis
|
|
|
|
|
transport (stream or file) is still open. See `hub_brief.md`.
|
|
|
|
|
|
|
|
|
|
## 10. Logging
|
|
|
|
|
|
|
|
|
|
The Hub writes one append-only log (brief rule 13).
|
|
|
|
|
|
|
|
|
|
One line per request. One line holds:
|
|
|
|
|
|
|
|
|
|
| Field | Example |
|
|
|
|
|
|---|---|
|
|
|
|
|
| `time` | `2026-10-10T08:55:03Z` |
|
|
|
|
|
| `request_id` | `01J8Z3K4M5N6P7Q8R9S0T1V2W3` |
|
|
|
|
|
| `source` | `esp32` |
|
|
|
|
|
| `asker` | `sam` |
|
|
|
|
|
| `text` | `what does Harry have on today?` |
|
|
|
|
|
| `route` | `project:compass_nhs` |
|
|
|
|
|
| `status` | `ok` |
|
|
|
|
|
| `latency_ms` | `412` |
|
|
|
|
|
| `shape` | `speech` |
|
|
|
|
|
|
|
|
|
|
Rules:
|
|
|
|
|
|
|
|
|
|
- Append only. Never edit a line.
|
|
|
|
|
- Cap the size. Rotate at a fixed size. Keep a fixed number of days.
|
|
|
|
|
- No secrets. Never log a token.
|
|
|
|
|
- No full personal data. Log the question and the route, not a project's data.
|
|
|
|
|
- Never log the `Authorization` header.
|
|
|
|
|
|
|
|
|
|
## 11. Safety confirmation
|
|
|
|
|
|
|
|
|
|
The three frozen routes are not high-consequence control actions.
|
|
|
|
|
|
|
|
|
|
| Route | High-consequence control? | Why |
|
|
|
|
|
|---|---|---|
|
|
|
|
|
| `POST /ask` | No | Routes a query or a fast command. Refuses a control action with `status: "refused"`. |
|
|
|
|
|
| `POST /approve` | No | Decides a Hub-owned approval only. Files a Paperclip job. Runs no parental control. |
|
|
|
|
|
| `GET /health` | No | Read only. |
|
|
|
|
|
|
|
|
|
|
A parental control change goes: admin app → internet control service, directly.
|
|
|
|
|
The Hub is not in that path. See brief rules 7, 8, and 11.
|
|
|
|
|
|
|
|
|
|
## 12. Not in v1
|
|
|
|
|
|
|
|
|
|
`POST /notify` (a project tells the Hub) is part of the design. Its shape is
|
|
|
|
|
defined in `docs/ask-contract.md`. It is not one of the three frozen Hub-inbound
|
|
|
|
|
routes in this document. Freeze it in a later task.
|
|
|
|
|
|
|
|
|
|
`routing.md` (the human-edited routing rules) is still open. `POST /ask` depends
|
|
|
|
|
on it. See `hub_brief.md`, "What is still open", item 5.
|
|
|
|
|
|
|
|
|
|
The Artemis transport (stream or file) is still open. See `hub_brief.md`,
|
|
|
|
|
"What is still open", item 3.
|