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

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 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 (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

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

  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.