# 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.
