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>
9.7 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.
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
personwins when it names a child (so an explicitpersonis 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 (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
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)
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 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:
~/.config/environment.d/10-secrets.confgainedHUB_PROJECT_COMPASS_NHS_TOKENandcompass_nhs=http://192.168.20.13:8792inHUB_PROJECTS(alongsideemail_agentandinternet_control).- The
hubcontainer was restarted once, bounded. - 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
- 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.