← Design · c133

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.

SlotFrom the finding's data
date, timethe finding's observed time: "8 October", "14:09" (UTC)
agent, device, team, personthe finding's subject
sessionthe session's summary, without the agent's name
evidenceqcontrol's evidence line, without its final full stop
policythe policy's name
ownerthe policy's owner (the lab reads the last person who changed it; a real owner on the policy, c121's model, would be honest)
resourcethe thing the finding touched
minutesWordthe training's minutes as a word: "three-minute"
per kindMCP 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)

  • version is 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.
  • contentHash is sha256: and the SHA-256 of the training object exactly as served, serialised with JSON.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 trainingVersion and contentHash (content_hash on 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: true is 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/trainings and each /api/trainings/<id> without an extension, so the deploy crawler prerenders them (CLAUDE.md: extension-bearing paths are skipped).

Qpoint Brand Style Guide