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>
203 lines
8.4 KiB
Markdown
203 lines
8.4 KiB
Markdown
# 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.
|