Files
compass_teams_nhs/docs/phase-8-hub-notes.md
Sam Rolfe 9cbfa7db9b Phase 8: Hub API (/capabilities, read-only /ask, /notify)
The project side of the frozen Hub contract
(central_ai_llm_master/docs/ask-contract.md), on this service's own port:

  GET  /capabilities  the routing topics the Hub reads, project "compass_nhs"
  POST /ask           one short, read-only answer about a child's school work
  POST /notify        accept the contract's notification shape, deliver via Apprise

All three require the shared bearer token SCHOOL_HUB_TOKEN; with no token they
answer 503. GET /health now also carries "ok": true and "hub_api".

/ask answers deterministically (no model on the path) from the stored Compass
items, reusing dashboard.Classify so the answer and the dashboard never
disagree. It is one short sentence, for example "Harry has 8 school events
today from 8:55am to 6:00pm and 6 overdue tasks", with the buckets in `data`.
It reads only, enforced by a Reader interface with no write method and by
TestHubAskReadsOnly.

Fail loudly (FR-12): a missing or failed scan is never an empty day. /ask says
due work is unknown, and an answer older than 36 hours carries a stale warning.
Permission follows the contract: a parent may ask about any child; anyone else
only about themselves.

/notify follows the house interpretation already used by internet_use_control:
the contract shape is accepted and delivered to the named person, recorded in
notifications with scope "hub". An unknown person is refused, not misdelivered.

Live proof on .13 (deploy/verify-phase8.sh):
  /health        200, ok true
  /capabilities  200, project compass_nhs; unsigned 401
  /ask           200, the sentence above, duration 1ms
  /notify        400 on a bad level (auth proven, nothing sent), then 200 with
                 a real delivery to Sam on email and WhatsApp

The live Hub registration (HUB_PROJECTS + HUB_PROJECT_COMPASS_NHS_TOKEN +
hub restart) is a change to a running family service and is pending approval.

Co-Authored-By: Paperclip <noreply@paperclip.ing>
2026-10-15 19:47:32 +11:00

203 lines
8.4 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# Phase 8 — Hub API: `/capabilities`, `/ask` (read-only), `/notify`
Status: **built, deployed and proven against the live service** (2026-10-15).
The live-Hub registration is the one remaining step; see *The Hub wiring* below.
Spec: `AGENTS.md` §4 FR-11, §8 (integrations), §10 rule 5 (`/ask` is read-only),
§11 Phase 8. Contract: `central_ai_llm_master/docs/ask-contract.md` (frozen).
## What the phase does
The service exposes the project side of the frozen Hub contract on its own port
(8792). The Hub reads `GET /capabilities` to route a question, calls `POST /ask`
to get one short answer, and may call `POST /notify` to have a person told
something. All three need the shared bearer token.
| Method | Path | Body / reply | Write? |
|---|---|---|---|
| `GET` | `/capabilities` | `{"project":"compass_nhs","topics":[…],"people":["harry","finn"]}` | no |
| `POST` | `/ask` | request `{"question","asker","person","source"}`; reply `{"answer","data","expires"}` or `{"answer":null,"reason"}` | **no** |
| `POST` | `/notify` | request `{"from","level","message","person"}`; reply `{"ok":true}` | Apprise only |
`GET /health` now also carries `"ok": true` and `"hub_api": true`, so the Hub's
existing health shape and this service's richer shape both work.
## Configuration
| Variable | Meaning |
|---|---|
| `SCHOOL_HUB_TOKEN` | Shared bearer token the Hub sends. Empty turns the three endpoints off with `503`; the dashboard is unaffected. |
The token is a secret. It lives only in `environment.d/school-service.env`
(gitignored). The Hub holds the same value as `HUB_PROJECT_COMPASS_NHS_TOKEN` in
`~/.config/environment.d/10-secrets.conf` and reaches this service through
`HUB_PROJECTS=compass_nhs=http://192.168.20.13:8792`.
## `/ask` — how the answer is built
The answers are deterministic. No model is on the path, so the Hub's 3 second
cap is never the reason an answer is missing. `/ask` reads only:
- `children` (find the person by name, case-insensitively),
- `items` (the child's stored Compass items),
- `scans` (the child's scan health).
It reuses `dashboard.Classify`, the same code that builds the dashboard and the
morning briefs, so the answer and the page never disagree.
The question picks a focus by plain keywords:
| The question mentions | The answer covers |
|---|---|
| "overdue", "not done", "behind" | overdue tasks |
| "this week" | overdue, then due this week |
| "notice", "message", "news" | school notices |
| "today", "timetable", "schedule", "class", "at school" | today's lessons, tasks due today, overdue |
| "due", "homework", "assessment", "exam" or anything else | the whole picture |
The reply is **one short sentence**, for example:
```
Harry has 8 school events today from 8:55am to 6:00pm and 6 overdue tasks.
```
The structured detail (per bucket, with subject, teacher and due time) is in
`data`; the Hub may ignore it. `expires` is the end of the family day.
### Fail loudly (FR-12, AGENTS.md §10 rule 2)
A failed or missing scan is **never** an empty day. `/ask` answers:
- no scan yet — `I have no school data for Harry yet: the Compass scan has not run, so due work is unknown.`
- last scan failed — `I cannot say what Harry has due: the last Compass scan on <time> failed, so due work is unknown.`
- last scan older than 36 hours — the normal answer plus `The last Compass scan for Harry was N days ago, so this may be out of date.`
`data.scan_ok` and `data.scan_stale` carry the same state for a caller that does
not read the sentence.
### Read-only
`internal/hub` depends on a `Reader` interface with three read methods and no
write method, so `/ask` and `/capabilities` cannot write. `TestHubAskReadsOnly`
also asserts that the `items`, `scans` and `notifications` row counts do not
change across `/ask` calls.
### Permission
The contract's rule 2 is enforced: a parent (`SCHOOL_APPRISE_PARENTS`, default
Jo and Sam) may ask about any child, and anyone else may ask about themselves
only. An unknown person returns `answer: null` with a reason; no data is leaked
between the children.
## `/notify` — the interpretation
The contract defines `POST /notify` as project-to-Hub (the *project* posts to the
Hub so the Hub can notify a person). `AGENTS.md` FR-11 places `/notify` on this
service's Hub API. This phase follows the house interpretation already used by
the sibling `internet_use_control` service: this service **accepts** the
contract's notification shape and delivers it through Apprise to the named
person, and records every attempt in `notifications` with `scope = 'hub'`.
- An empty `person` defaults to Sam (the service owner).
- An unknown `person` is refused with `400` rather than sent to the wrong inbox.
- `level` maps to the Apprise class: `info` → info, `important` → warning,
`urgent` → failure.
- A delivery failure answers `502` and is recorded with `ok = 0`, no `sent_at`,
and the redacted error (FR-12).
This is the one write on the Hub surface. It never changes school data and never
touches the school.
## Commands
```bash
# The Phase 8 definition of done: /health, /capabilities, /ask, /notify auth.
make verify-phase8
# Also deliver one real /notify message to Sam.
deploy/verify-phase8.sh --send
```
## Evidence (2026-10-15, 08:46–08:47 UTC / 19:46–19:47 AEDT)
`deploy/verify-phase8.sh` on `.13` against `http://127.0.0.1:8792`:
```
== GET /health ==
HTTP 200
{"ok":true,"status":"ok","service":"compass-teams-nhs","version":"0.6.0-phase8",
"database":"ok","scans":{…,"ok":true},"hub_api":true,"time":"2026-10-15T08:46:56Z"}
PASS /health 200 with ok true
== GET /capabilities without a token ==
PASS /capabilities refused an unsigned request
== GET /capabilities ==
HTTP 200
{"project":"compass_nhs","topics":["school","compass","homework","timetable",
"class","overdue","school notices"],"people":["harry","finn"]}
PASS /capabilities names compass_nhs
== POST /ask: what does Harry have on today at school? ==
HTTP 200
{"answer":"Harry has 8 school events today from 8:55am to 6:00pm and 6 overdue tasks.",
"data":{"child":"Harry","scan_ok":true,"scan_stale":false,"scan_age_hours":0,…},
"expires":"2026-10-15T13:00:00Z"}
PASS /ask returned an answer
== POST /notify: token proven without delivering ==
HTTP 400
{"ok":false,"error":"level must be info, important or urgent"}
PASS /notify rejected an invalid level (auth worked, nothing delivered)
== POST /notify: real delivery to Sam (--send) ==
HTTP 200
{"ok":true}
PASS /notify delivered a real message
```
The delivery log confirms both channels:
```
hub ask ... question="what does Harry have on today at school?" answered=true duration_ms=1
hub notify delivered from=verify-phase8 level=info recipient=Sam channel=email status=200
hub notify delivered from=verify-phase8 level=info recipient=Sam channel=whatsapp status=200
```
## The Hub wiring (remaining)
The Hub container (`central-ai-hub:local`, port 8788) currently runs with
`HUB_PROJECTS` empty, so it has no data project to route to. Registering this
service and restarting the Hub is a change to a running, family-used service and
a change to `~/.config/environment.d/10-secrets.conf`, so it is done only with a
recorded approval.
The change is exactly:
1. Add to `~/.config/environment.d/10-secrets.conf`:
`HUB_PROJECT_COMPASS_NHS_TOKEN=<the value of SCHOOL_HUB_TOKEN in this project's environment.d/school-service.env>`.
2. Set `HUB_PROJECTS` there to include
`compass_nhs=http://192.168.20.13:8792` (keep the existing `email_agent` entry).
3. Restart the `hub` container: `docker compose -f /home/sam/paseo/projects/central_ai_llm_master/docker-compose.yml up -d hub`.
Then the end-to-end check is:
```bash
curl -sS -H "Authorization: Bearer $HUB_TOKEN_CONSOLE" -H 'Content-Type: application/json' \
-d '{"text":"what does Harry have on today at school?","asker":"sam","source":"console"}' \
http://192.168.20.13:8788/ask
# expect route.kind = project, route.project = compass_nhs, status ok
```
## Decisions and notes
1. **The Hub-facing project name is `compass_nhs`** — the row already in the
Hub's human-owned `routing.md`. Changing the name would mean editing the Hub.
2. **Deterministic answers, no LLM.** The contract caps the call at 3 seconds
and a school question must answer on the first try. The sibling project made
the same choice.
3. **One sentence** for the answer, per the contract. The parenthetical stale
warning is the only second sentence, and only when the data is old.
4. **The token is one shared secret per project**, per the contract's
Authentication section.