Files
compass_teams_nhs/docs/phase-8-hub-notes.md
Sam Rolfe 06f759c37b Phase 8 follow-up: resolve the child named in the question
The Hub forwards `person` equal to the asker (`PersonOrDefault`), so the spoken
question "what does Harry have on today at school?" arrives with person "sam"
and returned "no data for that person" (found by the Hub agent on AUD-116).

resolveChild now falls back to the question text when the requested person is
not one of this project's children: whole-word, case-insensitive match against
the child names this service already publishes in /capabilities. An explicit
person still wins, two named children still return answer:null, and the
permission check still runs on the resolved child. No model, no free text.

Live end-to-end through the Hub, with no person field:

  POST http://192.168.20.13:8788/ask
  {"text":"what does Harry have on today at school?","asker":"sam","source":"console"}
  200
  {"status":"ok",
   "answer":"Harry has 8 school events today from 8:55am to 6:00pm and 6 overdue tasks.",
   "route":{"kind":"project","project":"compass_nhs"}}

Image 0.6.1-phase8; docs/phase-8-hub-notes.md records the fallback and the
end-to-end transcript.

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

240 lines
9.7 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.
### Which child the question is about
The contract makes `person` optional and says it defaults to `asker`. The Hub
(as of AUD-116) forwards `person = asker`, so the spoken question *"what does
Harry have on today at school?"* arrives with `person: "sam"`. Because this
project is the expert on its own data, `resolveChild` falls back to the question
text when the requested person is not one of its children:
- The requested `person` wins when it names a child (so an explicit `person` is
never overridden).
- Otherwise the question is split into whole words and matched,
case-insensitively, against the child names this service already publishes in
`/capabilities`.
- Exactly one match resolves the child; two matches ("what do Harry and Finn
have on?") return `answer: null` + a reason, because the contract answers for
one person at most.
- The permission check still runs on the resolved child.
No model and no free-text guessing are involved, and a name is never matched
inside a longer word. This makes the Hub's current call shape answer correctly;
[AUD-117](/AUD/issues/AUD-117) (the Hub deriving `person` itself) remains a
useful Hub-side improvement, not a dependency of this phase.
## `/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)
### End-to-end through the live Hub (08:56 UTC / 19:56 AEDT)
The literal phase question, sent to the **Hub** on its inbound API with no
`person` field, after [AUD-116](/AUD/issues/AUD-116) registered `compass_nhs`:
```
POST http://192.168.20.13:8788/ask
{"text":"what does Harry have on today at school?","asker":"sam","source":"console"}
200
{
"status": "ok",
"request_id": "01M4ZC9G4TRXJ3MFSMJ66JSVQ1",
"answer": "Harry has 8 school events today from 8:55am to 6:00pm and 6 overdue tasks.",
"shape": "text",
"route": {"kind": "project", "project": "compass_nhs"},
"expires": "2026-10-15T13:00:00Z"
}
```
This is the Phase 8 definition of done: the Hub asks, and gets a correct answer.
### Against the service directly
`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 (done)
The Hub container (`central-ai-hub:local`, port 8788) now has this service
registered. The change was made by the Hub agent on
[AUD-116](/AUD/issues/AUD-116):
1. `~/.config/environment.d/10-secrets.conf` gained
`HUB_PROJECT_COMPASS_NHS_TOKEN` and `compass_nhs=http://192.168.20.13:8792`
in `HUB_PROJECTS` (alongside `email_agent` and `internet_control`).
2. The `hub` container was restarted once, bounded.
3. The transcript above is the result.
`routing.md` was not changed: the `compass_nhs` row already existed and matches
this service's `/capabilities` name.
## 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.