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>
8.4 KiB
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
persondefaults to Sam (the service owner). - An unknown
personis refused with400rather than sent to the wrong inbox. levelmaps to the Apprise class:info→ info,important→ warning,urgent→ failure.- A delivery failure answers
502and is recorded withok = 0, nosent_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
# 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:
- 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>. - Set
HUB_PROJECTSthere to includecompass_nhs=http://192.168.20.13:8792(keep the existingemail_agententry). - Restart the
hubcontainer:docker compose -f /home/sam/paseo/projects/central_ai_llm_master/docker-compose.yml up -d hub.
Then the end-to-end check is:
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
- The Hub-facing project name is
compass_nhs— the row already in the Hub's human-ownedrouting.md. Changing the name would mean editing the Hub. - 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.
- One sentence for the answer, per the contract. The parenthetical stale warning is the only second sentence, and only when the data is old.
- The token is one shared secret per project, per the contract's Authentication section.