publish.md
c133 · What the apps read: the training API (Phase 3, 2026-11-11)
For: c134 (the web app's training page and the record), c131 (the native app's notice and, under D5, the content module), Tyler (event-shape.md §6 Q5). Ruling: D21, published trainings are served by the design site's API like the brand API (server/api/brand/*), with the content collection as the source. Status: built in the lab and served by the dev server; the hash follows D26 (ruled 2026-11-11 while Phase 3 was built); the static deploy prerenders these routes through the preview page's links (not yet checked on a deploy).
The two routes
GET /api/trainings every published training, one entry per training and language; ?lang=en narrows to a language.
{
"source": "design site · app/data/trainings (c133 D4, D21)",
"count": 3,
"trainings": [
{
"id": "T-MCP-1", "slug": "unapproved-mcp-servers", "title": "Unapproved MCP servers",
"policyId": "P-MCP", "minutes": 3, "version": 1, "lang": "en",
"contentHash": "sha256:7f6e1737e972ea6a38e72cfa6ef2f75fc58e7d51f0b2e8341644328fc51812b3",
"steps": 4, "questions": 4,
"slots": ["agent", "blocked", "date", "device", "evidence", "…"],
"href": "/api/trainings/T-MCP-1"
}
],
"withheld": [{ "path": "app/data/trainings/en/<slug>.md", "problems": ["…"] }]
}
GET /api/trainings/<id> one training, whole; ?lang= picks the language (default en). 404 when there is no such training in that language; 422 when the file exists but is withheld, with its problems.
{
"training": {
"id": "T-MCP-1", "slug": "…", "title": "…", "policyId": "P-MCP", "minutes": 3, "version": 1, "lang": "en",
"assignment": { "happened": "On {date}, {agent} on {device} …; {blocked}.", "matters": "A {minutesWord} training …" },
"lesson": [
{ "id": "what-happened", "heading": "What happened on {device}", "body": ["…", "…"],
"illustration": { "scene": "connection", "alt": "…", "animated": true } },
{ "id": "instead", "heading": "What to do instead", "instead": true, "body": ["…"], "illustration": { "scene": "ask", "alt": "…" } }
],
"check": [
{ "id": "q-reach", "stepId": "server-reach", "prompt": "…", "explain": "…",
"choices": [{ "id": "a", "text": "…" }, { "id": "b", "text": "…", "right": true }, { "id": "c", "text": "…" }] }
],
"contact": { "owner": "{owner}", "how": "…" },
"library": {
"lesson": [{ "id": "what-happened", "heading": "What happens when an agent connects a server", "body": ["…", "…"],
"illustration": { "scene": "connection", "alt": "…", "animated": true } }],
"check": [{ "id": "q-observe", "stepId": "what-happened", "prompt": "If the policy observes rather than blocks, …", "explain": "…", "choices": ["…"] }]
}
},
"version": 1,
"contentHash": "sha256:7f6e1737e972e…",
"slots": ["…"],
"path": "app/data/trainings/en/unapproved-mcp-servers.md"
}
A training on a topic (Phase 7, D49) carries "topic": { "id": "observe-and-enforce", "label": "Observe and Enforce" } where a policy's training carries policyId, never both; its assignment has matters only, and the list at /api/trainings names the topic beside the policy.
The training object is the Training type in app/data/trainings/types.ts, field for field: the plane's types can be written from it, as qdash's are written from the backend handlers. library (Phase 6, D38) is optional: the training's own words for a reading with no finding, each step or question replacing the one with its id when a person takes it on their own (the library page); an app that has a finding ignores it.
Slots: what the app fills
The text arrives with {slots} in it. The app fills them from the finding it is answering, then shows the result; an unknown slot is shown as written and is a bug to report, not to hide. The lab's fills (app/data/trainings/slots.ts) are the reference.
| Slot | From the finding's data |
|---|---|
date, time | the finding's observed time: "8 October", "14:09" (UTC) |
agent, device, team, person | the finding's subject |
session | the session's summary, without the agent's name |
evidence | qcontrol's evidence line, without its final full stop |
policy | the policy's name |
owner | the policy's owner (the lab reads the last person who changed it; a real owner on the policy, c121's model, would be honest) |
resource | the thing the finding touched |
minutesWord | the training's minutes as a word: "three-minute" |
| per kind | MCP server: server, sanctioned, exceptionTeam, exceptionSentence, outcome, blocked; the Phase 4 kinds are listed in slots.ts and the review notes |
Three of these are thin in the real product (review-1 call 3): the sanctioned list for the person's team, the policy's owner, and an exception's reason, which the lesson quotes as the operator wrote it (D12).
Versions and the hash (D26)
versionis written by the author and bumped when the meaning of a step, a question or a right answer changes (authoring.md). The API serves the current version only; the text of an older version is the file at the commit that carried it.contentHashissha256:and the SHA-256 of thetrainingobject exactly as served, serialised withJSON.stringify(no whitespace, the keys in the order served). An app that reserialises what it received gets the same bytes and the same hash. Any change to the served text moves it, a typo fix included; a change to an author's note in the file does not. One implementation computes it for the API and for the lab's record (app/data/trainings/hash.ts).- The record names both (D26, event-shape.md §8): the lab's record carries
trainingVersionandcontentHash(content_hashon the wire) beside the training's id and title (D25), and its card shows the version and the first twelve hex characters. - The result's name (D25): every completion is one grouped record (Mark's sketch: type training, completed, the training's id and name, proof of understanding true). The lab keeps
result: 'passed', the only value a record can have under D3;proof_of_understanding: trueis the alternative name for the same fact, R3's meaning, and the wire name is Tyler's to pick (event-shape.md §8.4).
Languages (D11)
English for the pilots. A translation is its own file, app/data/trainings/<lang>/<slug>.md, with the same id and version; the list returns every language, /api/trainings/<id>?lang=de returns one. When the person's language has no file the app shows the English one and says so. Operator text filled into a slot is shown as written, never translated by us.
What is not in it
No record, no assignment, no person: the API is content, the plane keeps the rest (event-shape.md). Nothing about a person's progress (R4). The right choice is in the data because the web app grades the check (D3); if the plane should grade instead, the API drops right and adds a grading route (a call for Mark in review-3.md).
Delivery
- The web app (D1, c134): reads
/api/trainings/<id>when it opens the training for a finding, or caches the list at build. - The native app (D5, c131): the same JSON is the payload of a content module fetched from the control plane, so a training updates without an app release.
- The static site: the preview page links
/api/trainingsand each/api/trainings/<id>without an extension, so the deploy crawler prerenders them (CLAUDE.md: extension-bearing paths are skipped).