Specification
The complete candidate course, execution, consent, records and identity contracts.
This reference is composed from five canonical specification sources in their fixed reading order. Edit the canonical sources linked below.
Agent Courses 1.0: course format
Section titled “Agent Courses 1.0: course format”This page defines what a course is: its folder, its entry file, its lessons and the project it runs in. It is the normative source for structure. Teaching behaviour, checks and evidence are in the execution contract. Learner feedback is in the consent contract. Every rule here is listed in the requirement register. Record files, receipts and course identity are not defined here; a later contract owns them.
This is a candidate. Wording marked Proposed is a drafting choice that waits for the owner’s review on the pull request; a passing check or an independent review does not approve it.
Reading this page
Section titled “Reading this page ”Required rules and advice
Section titled “Required rules and advice ”The words MUST, MUST NOT, SHOULD, SHOULD NOT and MAY are used as defined in RFC 2119 and RFC 8174. A sentence without one of those words in capitals is a definition or an example. A paragraph under the label Advice is guidance only. Advice never makes a course invalid, and a validator may report it only as a warning.
Every example in this specification has exactly one outcome, chosen from three words. Accepted: the course or runner behaviour is valid and nothing is reported. Accepted with a warning: it is valid, and a validator or runner reports the warning. Rejected: it is invalid, and a validator reports an error, a runner refuses it, or a runner that behaves that way breaks a MUST. A runner action that is accepted can still produce a failed check, a skipped lesson or an unresolved-link warning; those are recorded results, never a sign that the runner or the course is invalid. A table that maps a case to a result, such as a check result or whether an objective settles, states that single result instead.
Each rule has one of three enforcers. A schema can check the shape of one file. A Markdown validator must read several files or the Markdown body. A runner acts at teaching time. Constraints a schema cannot check lists the second group, so schema builders do not try to express it.
How the case tables work
Section titled “How the case tables work ”The examples are cases. A case is a row with five parts.
- Case: a short name and a stable anchor, which the registers link to.
- Baseline and change: the complete accepted course or situation, then the single change made to it. Where a case has no change, it uses the baseline unchanged.
- Judged as: the subject and stage of the judgement. Course syntax is the shape of the course files. Source resolution is finding a reference’s files. Project start is what must hold before lessons begin. Runner behaviour is what a runner does while teaching. Advisory lint is advice reported as a warning. Candidate wording is a statement the candidate itself makes.
- Outcome: one of the three outcome words.
- Deciding rule: the derived fact that settles it, so a reader can recompute the outcome from the baseline.
Each rejected case changes one thing. If a case could fail for two reasons, the change is written so that only the intended reason remains. A warning case is never presented as a way to make a course invalid.
The baseline course
Section titled “The baseline course ”Most cases start from this complete accepted course, sample-course. A case names only its change.
sample-course/├── COURSE.md├── lessons/│ ├── intro.md│ └── finish.md├── checks/│ ├── checks.yaml│ ├── final_check.py│ └── fixtures/final-check/{pass,fail}/agent.py└── tutor/ └── finish.rubric.mdCOURSE.md:
---name: sample-coursedescription: Build a one-file agent and explain it. For beginners.version: "1.0.0"license: Apache-2.0---
# Sample course
You'll build a small agent.
## Outcome
`agent.py`: an agent that passes `final-check`.
## Lessons
1. [Meet the agent](lessons/intro.md)2. [Finish and explain](lessons/finish.md)lessons/intro.md is the understand lesson:
---title: Meet the agentstage: understandcalls: - id: choose-tool ask: Which tool will your agent call first?---
## Goal
You can say what an agent tool is.lessons/finish.md is the final demonstrate lesson:
---title: Finish and explainstage: demonstratechecks: [final-check]calls: - id: choose-check ask: Which behaviour will the final check protect?objectives: - id: agent-passes text: The agent passes the final check by: final-check - id: agent-explained text: The learner explains the agent by: explain-back---
## Goal
You can show the agent works and say why.
## Explain back
Explain which tool your agent calls and why you chose it.checks/checks.yaml declares one command check, final-check, with run: python "$AGENT_COURSE_DIR/checks/final_check.py", a verify sentence, timeout: 30 and network: false. tutor/finish.rubric.md has pass: 2 of 3 and three criteria.
The extended baseline adds a build lesson. It adds lessons/make.md as item 2, between the two baseline items, and one observe check in checks/checks.yaml:
---title: Make the agentstage: buildchecks: [agent-runs]---
## Goal
Your agent runs.
## Build
Ask the learner how to start the agent, then create `agent.py` with them.- id: agent-runs kind: observe verify: agent.py exists and defines an agentThe extended baseline has no objectives in make.md, no calls in it and no starter/. Both baselines are accepted and report nothing.
Licence and attribution
Section titled “Licence and attribution ”This specification, its schemas and its examples are licensed under Apache-2.0. The first proposal for the format, dated 13 October 2026, was drafted under a different licence. Its structure is kept and its attribution is preserved here. Every example course in this specification uses license: Apache-2.0. A course author may choose any licence for their own course.
Licence cases
Section titled “Licence cases ”What this version leaves out
Section titled “What this version leaves out ”Two groups of things are not in this candidate.
Not part of 1.0 at all. A course or runner MUST NOT claim these as part of 1.0:
- Simulated-learner evaluations, an
evals/folder or an evals file. The nameevals/is not reserved; a runner ignores it like any unlisted path. - A
rendercapability. Only the three capabilities in the execution contract exist. - Certification, branching paths, translations, cohorts and selling courses.
Part of 1.0, but not available or not defined in this candidate. The accepted scope of version 1.0 also includes a reference library with a validator, a runner conformance suite and a stable release. This candidate defines none of them, so nobody may claim to be conformant or stable yet. The conformance classes and claim wording arrive with the conformance suite, and a stable release arrives with launch qualification. A runner may describe itself only as supporting named capabilities, as the cases show.
Outside the candidate. Four matters are external and no rule here depends on them: whether package names are free on the registries (⚠️ unverified; a release-time check), whether another host agrees the agents folder layout, who the second independent runner is, and who besides the owner approves specification changes. The specification’s home is agentcourses.io.
Scope and exclusion cases
Section titled “Scope and exclusion cases ”| Case | Baseline and change | Judged as | Outcome | Deciding rule |
|---|---|---|---|---|
| Names only supported capabilities | A runner states “Supports run-checks and judge, Agent Courses 1.0 candidate” |
Candidate wording | Accepted | Both names are among the three capabilities that exist |
Claims render |
A runner states “Supports render” |
Candidate wording | Rejected | No render capability exists in 1.0 |
| Claims evals are defined | A document states “The evals/evals.json file shape is defined by Agent Courses 1.0” |
Candidate wording | Rejected | Evals and their folder are not defined in 1.0 |
An evals key in frontmatter |
Baseline COURSE.md with evals: evals/evals.json added |
Course syntax | Accepted with a warning | The key is unknown; a runner ignores it and a validator warns. See unknown keys |
| A schema rejects that key | The same course, rejected because of the evals key |
Course syntax | Rejected | Schemas MUST allow additional properties, so rejecting the key is invalid behaviour |
| Library, validator and suite stay in version one | The candidate says the library, validator and suite are part of 1.0 and are not yet available | Candidate wording | Accepted | The accepted scope includes them; availability is stated truthfully |
| Library, validator and suite dropped | The candidate says the library, validator and suite are not part of 1.0 | Candidate wording | Rejected | They are in the accepted version-one scope |
| Claims conformance | A runner states “Conforming Runner, Agent Courses 1.0” | Candidate wording | Rejected | The candidate defines no conformance class and no suite exists |
| Claims a stable runner | A runner states “Stable Agent Courses 1.0 runner” | Candidate wording | Rejected | A stable release waits for launch qualification |
| Home | The candidate names agentcourses.io as its home | Candidate wording | Accepted | The owner chose agentcourses.io |
| Superseded home | The candidate names agentcourses.dev as the official home |
Candidate wording | Rejected | That proposed home was replaced by the owner’s choice |
| Package names | The candidate says name availability is left to release-time checking | Candidate wording | Accepted | No evidence of availability exists, so none is claimed |
| Claims availability | The candidate says the package names are free on the registries | Candidate wording | Rejected | The claim has no evidence; it stays ⚠️ unverified |
| Host adoption | The candidate says host agreement on the folder layout is external work and qualifies no host | Candidate wording | Accepted | No contract rule changes with the answer |
| Claims or waits for a host | The candidate says a host has agreed the layout, or holds the contract until a host endorses it | Candidate wording | Rejected | There is no evidence of agreement, and the contract is implementable without it |
| Second runner | The candidate defines its contract before another runner adopts it | Candidate wording | Accepted | A contract can be written before a second implementation exists |
| Claims a second runner | The candidate says an independent second runner exists | Candidate wording | Rejected | No evidence of one exists |
| Governance | The candidate records broader governance as a separate strategy matter | Candidate wording | Accepted | No rule here needs it |
| Invents approval rights | The candidate says named people besides the owner approve specification changes | Candidate wording | Rejected | No such rights are agreed |
Other exclusion cases
Section titled “Other exclusion cases ”Each case is one statement a course or runner makes about version 1.0. It joins the exclusion cases above.
Principles
Section titled “Principles ”Nine principles shape the rules. They explain intent; the rules below are what a validator and a runner enforce.
- A course is a folder you can read. Plain Markdown and YAML in Git.
- The learner owns the calls. The tutor builds with the learner and may type every edit. Achievement is checks passed, calls made by the learner and an explain-back judged. The tutor never makes a call the learner owns.
- Evidence over attendance. Completion means evidence, not a page view.
- Bind the machinery, never the prose. The format fixes structure, gates, check results and what is recorded. How the tutor teaches is the runner’s craft.
- One source of truth. Identity lives in frontmatter, order lives in the lesson list, and anything countable is derived.
- Load only what is needed. A runner reads a catalogue entry first and loads a lesson only when it starts.
- Any project, by reference. A course runs in the learner’s own project, in a starter it ships, in a pinned Git repository or in a copy of a local folder.
- Learner-visible and tutor-only files are separate. Keys, rubrics and misconception notes live in
tutor/. - Privacy by construction. Code, files and tutor conversations never leave the learner’s machine through anything this specification defines.
The course folder
Section titled “The course folder ”A course is a directory with a COURSE.md at its root. Everything else is optional and has one job. The directory name MUST equal the course name. Lesson files are named by their id, without numbers, because order is written in one place only.
Layout
Section titled “Layout ”build-your-first-agent/├── COURSE.md # required: identity, then the page a learner reads first├── lessons/ # one Markdown file per lesson; the file name is the lesson id│ ├── install.md│ ├── add-a-tool.md│ └── explain-it.md├── checks/│ ├── checks.yaml # what each check verifies and how to run it│ ├── tool_is_registered.py│ └── fixtures/tool-is-registered/{pass,fail}/├── tutor/ # tutor-only: rubrics, quiz keys, misconceptions, persona│ └── explain-it.rubric.md├── starter/ # files that seed a starter project or a lesson├── references/ # optional reading the tutor may load├── assets/ # images and data, linked by relative path└── feedback.yaml # optional: the feedback this course may ask for| Path | Seen by the learner | Loaded by the runner |
|---|---|---|
COURSE.md |
Yes | At catalogue time (frontmatter), then at course start |
lessons/ |
Only the ## Goal text, verbatim |
When the lesson starts |
checks/ |
The command, before it first runs | When a check is run |
tutor/ |
Never verbatim | When judging, hinting, remedying or setting the tutor’s voice |
starter/ |
Copied into their project | At the start, or when a lesson asks for it |
references/ |
On request | When a question needs it |
assets/ |
When a link needs it | When a link needs it |
feedback.yaml |
Each question, before it is asked | When a hook fires |
An unlisted file or folder is ignored by runners. A course MUST NOT place a finished answer in starter/, and tutor/ MUST NOT be linked from COURSE.md or from a lesson’s ## Goal.
Folder cases
Section titled “Folder cases ”Names and ids
Section titled “Names and ids ”A kebab id is 1 to 64 characters, lowercase letters and digits only, with single hyphens between runs and no leading or trailing hyphen. In regular-expression form: ^[a-z0-9]+(-[a-z0-9]+)*$ and length at most 64. Course names, lesson ids, check ids, call ids and objective ids are all kebab ids. The word explain-back is reserved: it selects the explain-back as evidence in an objective, so no check id, lesson id, call id or objective id may be explain-back.
A lesson’s id is its file name without .md and nothing else. A lesson file has no id key. A lesson id key is an unknown key, warned about and ignored, so it cannot change the id. A file that names a lesson by id, such as a rubric or quiz key, MUST use that file-name id.
Id cases
Section titled “Id cases ”COURSE.md
Section titled “COURSE.md ”COURSE.md has YAML frontmatter that identifies the course, then a Markdown page. It begins with a line of three hyphens and ends the frontmatter with another such line. Frontmatter MUST be a YAML mapping.
Frontmatter fields
Section titled “Frontmatter fields ”| Field | Required | Rule |
|---|---|---|
name |
Yes | A kebab id equal to the folder name. Stable across versions. |
description |
Yes | A string of 1 to 1,024 characters. What the learner builds and who it is for. |
version |
Yes | A string holding a Semantic Versioning 2.0.0 version, such as 1.2.0 or 1.0.0-rc.1. Build metadata after + is not allowed. It MUST be a YAML string, so quote it. |
license |
No | An SPDX identifier, or the name of a licence file in the course folder. |
compatibility |
No | A string of 1 to 500 characters naming the tools, hosts, accounts and costs the learner needs. |
project |
No | See the project field. Omitted means own. |
spec |
No | A string major.minor, such as 1.0. Omitted means 1.0. |
metadata |
No | A mapping from strings to strings. |
A runner MUST refuse a course whose spec major version it does not support. A runner that meets a later minor version of a supported major SHOULD run the course, ignore what it does not know and tell the learner. Runners recognise four metadata keys: language (a BCP 47 tag, default en), author, homepage and handle. A runner MUST NOT invent a homepage or handle that the metadata does not hold.
Frontmatter cases
Section titled “Frontmatter cases ”Unknown keys
Section titled “Unknown keys ”A runner MUST ignore a key it does not know in COURSE.md frontmatter, lesson frontmatter, a check entry, a call entry, an objective entry and feedback.yaml. An authoring validator SHOULD report such a key as a warning, never an error, so a typo is caught and a course written for a later 1.x minor still runs. Schemas for these files MUST allow additional properties. A key that is known but has the wrong type or value is an error. Extensions belong in metadata.
Unknown-key cases
Section titled “Unknown-key cases ”The page
Section titled “The page ”The body is Markdown. Headings are matched only at level two, with exactly the text shown, outside fenced code blocks. Each recognised heading appears at most once.
| Heading | Required | Use |
|---|---|---|
## Lessons |
Yes | The ordered lesson list |
## Outcome |
Yes | The artefact the learner leaves with, said once |
## Before you start |
No | Tools, accounts and costs, shown before the first lesson |
## Project |
No | A sentence or two on where the learner works |
Any other heading is ordinary text. A course with a missing required heading, or a recognised heading twice, is invalid.
Page heading cases
Section titled “Page heading cases ”The lesson list
Section titled “The lesson list ”The ## Lessons section contains exactly one ordered list. Each item contains exactly one link, and the link target is lessons/<id>.md for a lesson file that exists. The list order is the course order and is written nowhere else. A lesson file MUST appear in the list once. A lesson file that is not listed, a listed file that does not exist, a link listed twice and a link outside lessons/ are all invalid. A course has at least one lesson.
Example course page
Section titled “Example course page ”A second accepted page, with a richer project list. The lessons it links are not shown here; stages are shown in the baseline course and in the stage cases.
---name: build-your-first-agentdescription: Build a typed agent with one tool and structured output, in your own repository. For Python developers new to agent frameworks.version: "1.2.0"license: Apache-2.0compatibility: Python 3.12 or later and uv. Tested in Claude Code and Codex.project: [own, starter]---
# Build your first agent
You'll build an agent that answers with a typed object, then explain how it works.
## Outcome
`agent.py`: a typed agent with one tool, passing every check.
## Lessons
1. [Install and run an agent](lessons/install.md)2. [Add a tool your agent can call](lessons/add-a-tool.md)3. [Explain what you built](lessons/explain-it.md)Outcome: accepted. The learner picks own or starter before lesson one.
Lesson list cases
Section titled “Lesson list cases ”Lessons
Section titled “Lessons ”Each lesson is one Markdown file in lessons/. Its file name without .md is its id, a kebab id that is unique in the course and stable across versions. Records point at ids, never positions, so reordering or inserting a lesson never breaks a learner’s history.
Stages
Section titled “Stages ”A lesson declares one of four stages. Stages tell a runner what kind of help fits.
| Stage | The learner | Lesson must have | Completes when |
|---|---|---|---|
understand |
Grasps an idea applied to their own project | ## Goal; no checks |
The learner asks to move on |
practise |
Does a short, bounded task | ## Goal, ## Build, at least one check |
Every required check passes, or the learner chooses to move on |
build |
Makes a real part of the outcome | ## Goal, ## Build, at least one check |
Every required check passes, or the learner chooses to move on |
demonstrate |
Proves it: checks pass and the learner explains it | ## Goal, ## Explain back, a rubric in tutor/ |
Required checks pass and the explain-back has been judged |
Moving on without passing is always allowed and is always recorded as skipped, never as passed. The last lesson in the list MUST have stage demonstrate, and it is the only demonstrate lesson. A course whose last lesson has another stage, or that has two demonstrate lessons, is invalid.
Stage cases
Section titled “Stage cases ”Lesson frontmatter
Section titled “Lesson frontmatter ”| Field | Required | Rule |
|---|---|---|
title |
Yes | A non-empty string. |
stage |
Yes | One of understand, practise, build, demonstrate. |
checks |
practise and build; optional in demonstrate; not allowed in understand |
A list. In practise and build it has at least one item. In understand it MUST be omitted or empty. Each item is a check id from checks/checks.yaml, or a mapping {id: <check id>, required: false}. An item is required unless it says required: false. No id appears twice. |
minutes |
No | A positive integer: an honest estimate for a first-time learner. |
calls |
No | A list of calls. See calls. Omitted means none. |
objectives |
No | A list of objectives. See objectives. Omitted means none. |
starter |
No | A list of paths under starter/. See starter files. |
A demonstrate lesson MAY list checks; its required checks must pass before its explain-back is judged. An understand lesson declares no checks. Because an objective needs a check or an explain-back, an understand lesson can declare no objective, and it settles nothing. It may still declare calls.
Understand-stage cases
Section titled “Understand-stage cases ”Example lessons
Section titled “Example lessons ”An accepted practise lesson with a call, an objective and a hint.
---title: Add a tool your agent can callstage: practiseminutes: 12checks: [tool-is-registered, {id: tool-has-docstring, required: false}]calls: - id: tool-name ask: What should the tool be called?objectives: - id: register-a-tool text: The agent has a registered tool by: tool-is-registered---
## Goal
Your agent can call a Python function you wrote, and you can see when it does.
## Build
Ask the learner to name the tool. Then register it on the agent and show thechange and why. Reread agent.py before every edit.
## Hints
1. Tools are registered on the agent object, not on the model.Outcome: accepted.
Invalid lessons
Section titled “Invalid lessons ”The cases that make a lesson invalid are in these rows. Each is a separate case with its own baseline.
| Violation | Case |
|---|---|
| Not a stage name | Not a stage name |
build lesson with no checks |
Required checks missing |
## Task instead of ## Build |
Task replaces Build |
Two ## Goal headings |
Goal twice |
## Explain back in a build lesson |
Explain back in a build lesson |
minutes: 0 |
Zero minutes |
| Repeated check id | Repeated check id |
understand lesson with checks |
Understand with a check |
understand lesson with an objective |
Understand with an objective |
Final lesson not demonstrate |
Final lesson is not demonstrate |
Lesson field cases
Section titled “Lesson field cases ”Lesson headings
Section titled “Lesson headings ”The body is Markdown written to the tutor. A runner looks for five level-two headings, with exactly these texts, outside fenced code blocks, and treats every other heading as teaching material.
| Heading | Cardinality | Use |
|---|---|---|
## Goal |
Exactly once in every lesson | What the learner will be able to do. The runner says it as written. |
## Build |
Exactly once in practise and build; at most once in the other stages |
What the tutor and learner make together. Instructions to the tutor. |
## Hints |
At most once | A numbered list, revealed one item per request |
## Quiz |
At most once | Misconception questions, asked one at a time |
## Explain back |
Exactly once in demonstrate; never in other stages |
The prompt the learner answers in their own words |
A heading repeated, a required heading missing and ## Explain back outside the final lesson are invalid. A heading spelled ## Task is ordinary text: it does not satisfy the ## Build requirement. A runner MUST say the ## Goal text to the learner as written and MUST NOT say any other lesson section as written; it teaches from them in its own words.
Lesson heading cases
Section titled “Lesson heading cases ”Tutor-addressed lesson cases
Section titled “Tutor-addressed lesson cases ”These cases apply the amendment that writes lessons to the tutor. The runner rules are in Building together.
Calls
Section titled “Calls ”A call is a decision only the learner may make. Each lesson lists its calls as a list of mappings, each with an id (a kebab id) and an ask (a non-empty string, the question the tutor puts to the learner). A call id MUST be unique across the whole course, not only within its lesson, so that a record can name a call by id alone. The duties a runner has for calls are in the execution contract.
Call cases
Section titled “Call cases ”Objectives
Section titled “Objectives ”An objective is a finer-grained outcome a lesson can settle. Each is a mapping with an id (a kebab id), text (a non-empty string) and by: either the id of one check listed in the same lesson, or the word explain-back. explain-back is allowed only in a demonstrate lesson. The kind of evidence follows from by: a command check gives checked, an observe check gives observed and explain-back gives explained.
by has one meaning. The word explain-back is reserved (see names and ids), so it can never be a check id, and by: explain-back always selects the explain-back. Any other value is a check id.
An objective id MUST be unique across the whole course, not only within its lesson. A lesson without objectives declares nothing that can settle. It can be passed or skipped, but it settles no objective.
Objective cases
Section titled “Objective cases ”Starter files
Section titled “Starter files ”A lesson’s starter list names files under starter/ that the lesson adds to an own or reference project, never over an existing file. Each path is relative to starter/, uses /, and has no empty, . or .. segment. A path that does not exist is invalid. A starter project receives all of starter/ at the start, so these entries add nothing to it. When a target file already exists, the runner asks the learner and never overwrites it.
Starter cases
Section titled “Starter cases ”These cases start from the extended baseline with starter/plan/README.md added and make.md declaring starter: [plan/README.md].
Starter answer cases
Section titled “Starter answer cases ”These cases use the extended baseline. make.md asks the learner to write plan/README.md listing three steps: install, add a tool, run the check.
Links inside a course
Section titled “Links inside a course ”A link inside COURSE.md or a lesson is one of three kinds:
- A relative path to a file inside the course folder. Proposed: every such path is written from the course root, the folder holding
COURSE.md, whichever file contains the link. A lesson linksreferences/notes.md, not../references/notes.md. After resolving.and..the path MUST stay inside the course folder. Absolute paths, backslashes and links that leave the folder are invalid. - A
project:link to a file in the learner’s project. See project links. - An
https:URL.
Links to tutor/ files are not allowed in COURSE.md or in ## Goal. A lesson link whose target does not exist is invalid. A tutor/ link in a section addressed to the tutor, such as ## Build, is allowed.
The course-root base is this candidate’s proposal. The historical proposal says lessons link to course files with relative paths and does not name a base; ordinary Markdown tools resolve from the linking file, so an authoring tool that does so must be told this rule. The same base applies to the lesson list, to starter paths (relative to starter/) and to rubric and quiz names. It does not apply to project: links, which are relative to the project root, or to local project references, which are relative to the installed course root (local project folders).
Link cases
Section titled “Link cases ”These cases use the extended baseline with references/notes.md, assets/flow.png and tutor/finish.rubric.md present.
Projects
Section titled “Projects ”Every check runs against one directory, the project root. The project field says where that directory comes from.
The project field
Section titled “The project field ”The value is one option or a list of options. An option is own, starter or a reference. Options are case-sensitive.
| Option | The learner works in |
|---|---|
own |
The project they already have open |
starter |
A new folder the runner creates from starter/ |
A Git reference (gh: or git+https://) |
A checkout of that repository at its pinned commit |
A local folder (./path) |
Proposed: an editable copy, made in the workspace, of a folder inside the installed course. See local project folders |
A scalar value is a list of one. An omitted field is the list [own]. A list MUST hold at least one option, MUST NOT repeat an option, and MAY mix kinds and hold several references. starter is valid only when starter/ exists and holds at least one file. A mapping, an empty string, an empty list or a value that is not an option is invalid.
Option shape cases
Section titled “Option shape cases ”Selecting a project
Section titled “Selecting a project ”If the list has one option, that option is used and nothing is asked. If it has two or more, the learner chooses before the first lesson. List order is presentation order and carries no default. A runner MAY recommend an option with its trade-off and MUST NOT choose for the learner.
- No choice. If the learner gives no choice, or the runner cannot ask, the course does not start. A runner MUST NOT pick a default and MUST NOT guess.
- Invalid choice. A choice that is not in the list is rejected and the learner is asked again. It is never replaced by another option and never recorded.
Project selection cases
Section titled “Project selection cases ”These cases use the baseline with starter/README.md present.
The recorded project path
Section titled “The recorded project path ”The runner records the chosen kind and a path. The path is the project root written relative to a base, with / separators, no leading slash, no drive letter, no .. segment and no trailing slash. The single value . means the base itself. The base depends on the kind:
| Kind | Base | Path |
|---|---|---|
own |
The workspace root, where the learner started the runner | ., or the sub-folder the learner chose as their project, such as services/api |
starter |
The workspace root | The new folder the runner created from starter/, such as work/app. Its name is the course name unless the learner chooses another. An existing non-empty folder is never reused; the runner asks for another name. |
| A Git reference | The root of the checkout | The folder after #, or . when there is none |
| A local folder (proposed) | The workspace root | The editable destination folder the copy was made in, such as work/local-app. It is never the source folder, such as examples/app. |
The exact field names and shape of a record belong to the record contract; this section defines only what the path means. A path that contains .., starts with / or ends with / is invalid.
Project path cases
Section titled “Project path cases ”Local project folders (proposed)
Section titled “Local project folders (proposed) ”Proposed. These semantics wait for the owner’s review. The historical proposal accepts ./relative/path as a local folder and says nothing more, so the following is this candidate’s choice and not a recorded decision. A local folder is any ordinary directory inside the installed course, with no Git metadata needed. It is source material, never the place the learner works.
- A local
projectreference resolves relative to the installed course root, never the shell’s current directory or an enclosing Git checkout. A local course-install reference still resolves relative to the directory the runner was run from, and installing creates its own read-only course snapshot. - The source MUST be a present, non-empty directory inside the installed course snapshot. No Git repository, remote or commit is required, and a runner MUST NOT fabricate one.
- Before copying, the runner resolves the source and every entry inside that subtree. It MUST refuse a link that leaves the subtree, a link cycle, an unreadable entry and a special file such as a device or pipe. Nothing is copied when it refuses.
- The runner materialises the copy after the project is selected and the source is validated, and before the first lesson. It copies regular file contents and directories and MUST NOT run project code.
- The learner, or the embedding application, supplies an editable destination inside the workspace root and outside the installed course tree. The destination is a fresh folder, or an existing empty folder. A non-empty destination is refused and another is asked for; existing files are kept.
- The runner checks destination containment after resolving symbolic links. It MUST NOT place the copy inside its source or any installed course directory. A permitted link inside the source is copied as an ordinary file or directory, never as a link back into the source.
- Checks and tutor edits use the destination as the project root.
AGENT_COURSE_PROJECTnames it.AGENT_COURSE_DIRkeeps naming the read-only installed course. - The recorded path is the destination relative to the workspace root, such as
work/local-app, under the path rules. - The source is fixed by the installed course snapshot. There is no separate Git pin and no lookup of enclosing Git history. Changing the author’s original folder later cannot change the installed snapshot or touch an existing learner workspace.
- Resume uses the existing workspace and the original snapshot, and MUST NOT copy the source again. Taking a newer snapshot requires an explicit upgrade by the learner. How an upgrade treats an existing workspace is not defined here; it MUST NOT overwrite the workspace silently.
- Representing the source identity, comparing payloads and storing the workspace binding are record work. These rules choose no field and no hash.
Source and destination for the cases below: the installed snapshot holds examples/app/README.md (40 lines) and examples/app/src/main.py, and has no .git. The workspace root holds no work/ folder.
Local project cases
Section titled “Local project cases ”Explicit upgrade cases
Section titled “Explicit upgrade cases ”These cases use the local-folder course installed with workspace work/local-app, where the learner has edited src/main.py. The author has since published a newer snapshot. This contract defines nothing else about an upgrade.
The reference grammar
Section titled “The reference grammar ”One grammar names a course to install and a project to work in.
reference = gh-ref | git-ref | local-refgh-ref = "gh:" owner "/" repo [ "@" revision ] [ "#" folder ]git-ref = "git+https://" host "/" repopath [ "@" revision ] [ "#" folder ]local-ref = "./" relpathhost = label { "." label } [ ":" port ]label = 1 to 63 of letter, digit or "-", not starting or ending with "-"port = 1 to 5 digits with a value from 1 to 65535repopath = segment { "/" segment }folder = segment { "/" segment }relpath = segment { "/" segment }segment = 1 to 100 of letter, digit, ".", "_", "-", "+" or "~", other than "." and ".."revision = tag | commitcommit = 40 or 64 lowercase hexadecimal characterstag = see the tag rule below| Form | Meaning |
|---|---|
gh:owner/repo |
The repository’s default branch, resolved once and pinned |
gh:owner/repo@v1.2.0 |
A tag |
gh:owner/repo@<40 or 64 hex characters> |
A full commit, the strongest pin |
gh:owner/repo@v1.2.0#path/to/folder |
A folder inside the repository |
git+https://git.example.com/team/repo.git@v1.2.0#path |
Any Git host over HTTPS, same @ and # |
./relative/path |
A local folder, copied for a project (see local project folders) |
git+https://git.example.com/team/repo.git@v1 |
Repository path team/repo.git, tag v1 |
Rules:
owneris 1 to 39 characters of letters, digits and hyphens, starting with a letter or digit.repois 1 to 100 characters of letters, digits,.,_and-, and does not end in.git.- A tag is 1 to 128 characters of letters, digits,
.,_,-and/. It does not start with-or/, does not end with/or., and contains neither..nor//. A revision of 40 or 64 lowercase hexadecimal characters is a commit; every other revision is a tag. An abbreviated commit is therefore read as a tag name, and if no such tag exists the reference fails. A branch is never named after@. - Omitting
@selects the default branch once; the runner then pins the commit it found. folderandrelpathare paths of segments separated by single/. No segment is empty,.or.., the path does not start or end with/, and it holds no backslash.hostis a DNS name with an optional port, as the grammar shows. Agit+httpsreference MUST NOT contain a user, a password, a token or a query string. Because@and#are not segment characters, the first@after the repository path always starts the revision and the first#always starts the folder. An@before the host’s/is therefore a credential and makes the reference invalid, and a runner MUST NOT record it.repopathhas one or more segments separated by single/. It may end in.git. It has no empty segment, no.or..segment and no trailing/. It holds no@,#,?or:.@and#each appear at most once,@before#, and neither may be followed by nothing: an empty revision or an empty folder is invalid.- A
local-refstarts with./. It has no@and no#, and no segment of its path is empty or... It is never a Git reference and has no revision. As aprojectoption it is relative to the installed course root. As an install source it is relative to the directory the learner ran the runner from. A symbolic link that leaves its base is refused. - No part of a reference may contain a space, control character or backslash.
Valid reference cases
Section titled “Valid reference cases ”Invalid reference cases
Section titled “Invalid reference cases ”Each case is judged as source resolution and is rejected for the one reason shown.
Reference length and range cases
Section titled “Reference length and range cases ”Each case starts from gh:example-co/payments@v3.4.0 or git+https://git.example.com/team/repo.git@v1.2.0 and changes one part.
Reference boundary cases
Section titled “Reference boundary cases ”Query string cases
Section titled “Query string cases ”Both cases start from git+https://git.example.com/team/repo.git@v1.2.0#docs, which is accepted.
Links into the project
Section titled “Links into the project ”A lesson links to a file in the learner’s project with the project: prefix, followed by a relative path and an optional line fragment: project:<path> or project:<path>#L<start> or project:<path>#L<start>-L<end>. The path is relative to the project root, uses /, has no empty, . or .. segment, does not start with / and has no query. A space or other special character in the path is percent-encoded and decoded once.
Line ranges
Section titled “Line ranges ”Line ranges are allowed. #L10 names one line and #L10-L40 names lines 10 to 40 inclusive. Lines are counted from 1, <start> and <end> are positive integers without a leading zero or a sign, and <start> is at most <end>. No other fragment, no column and no multi-range form is allowed.
When a project link is checked
Section titled “When a project link is checked ”Syntax is checked for every kind before teaching. Whether a target exists and holds its lines is checked at two moments.
| Project kind | Baseline check before the selected project starts | Check at every open |
|---|---|---|
| Git reference | Against the immutable pinned checkout: the file exists and the range lies inside it | Against the learner’s editable project root |
| Local folder (proposed) | Against the selected subtree of the installed course snapshot, before it is copied | Against the learner’s editable project root |
own, starter |
None; the learner’s project decides the target | Against the learner’s editable project root |
- A missing file or out-of-bounds range in a Git or local baseline rejects that project’s start. It is never a verified link.
- An offline authoring check may report a Git source as unavailable. It MUST NOT claim the targets passed, and starting waits for validation.
- At every open, the runner resolves the link against the actual editable project root, rechecking containment, existence and line bounds. A link it cannot resolve is shown as unresolved, with a warning. The tutor MUST NOT guess the missing content and MUST NOT open the immutable source instead.
- A learner edit that removes a formerly valid target therefore produces an unresolved-link warning, not a change to the baseline.
Project link syntax cases
Section titled “Project link syntax cases ”Project link timing cases
Section titled “Project link timing cases ”These cases use the local-folder course with examples/app/README.md of 40 lines, and a lesson ## Build that links project:README.md#L10-L40.
Authored counts
Section titled “Authored counts ”Counts are derived, never written. Learner-facing text, meaning the description, the page and any ## Goal, SHOULD NOT contain a lesson count, an objective count, a total duration, a percentage of progress, the form lesson N or lesson N of M, or a number word in those positions such as lesson three. Because lessons are reordered by id, a number in lesson 3 is wrong the moment the list changes. This is Advice: a lint check warns, and a course containing a count is still valid. The public lint implementation is later work.
Count cases
Section titled “Count cases ”Course versions
Section titled “Course versions ”| Change to a course | Version bump |
|---|---|
| Wording, typos, examples, hints | Patch |
| A new lesson anywhere, a new optional check, a new reference | Minor |
| Removing or renaming a lesson id, a new required check, or anything that makes a previously passing project fail, including a new project pin | Major |
This table is Advice for authors. No version choice makes a course rejected. The specification itself follows the same rule: a 1.x minor only adds optional fields, headings and capabilities, so every 1.0 course stays valid.
Version cases
Section titled “Version cases ”Constraints a schema cannot check
Section titled “Constraints a schema cannot check ”A schema validates one file’s structure. The rules below need the Markdown body or several files, so a Markdown validator or a runner must enforce them. Each is listed with its home in the requirement register.
- Folder name equals
name(course folder). - Recognised headings appear with the required cardinality and only at level two, outside code fences (the page, lesson headings).
- The lesson list matches the files in
lessons/exactly, with no repeats (lesson list). - The last listed lesson is the only
demonstratelesson (stages). - A lesson id equals its file name, and files that name a lesson use that id (names and ids, lessons).
- Call ids and objective ids are unique across all lessons (calls, objectives).
- An objective’s
bynames a check in the same lesson, orexplain-backin the final lesson (objectives). - Every check id in a lesson exists in
checks/checks.yaml(execution contract). - A rubric exists for the final lesson and its criteria count equals its pass denominator (rubric grammar).
- Every relative link, written from the course root, stays in the course folder,
tutor/is not linked from learner-visible text andproject:links follow the line-range grammar (links, line ranges). - A
starterproject requires a non-emptystarter/(project field). - A local folder reference names a present, non-empty directory with no escaping link, cycle or special file (local project folders).
File-shape versus whole-course cases
Section titled “File-shape versus whole-course cases ”Source: spec/format.md.
Agent Courses 1.0: execution contract
Section titled “Agent Courses 1.0: execution contract”This page defines how a runner teaches a lesson, runs checks, gathers evidence and settles objectives. It assumes the folder and lesson rules in the format contract. Learner feedback is in the consent contract. The record files, call events, receipts and course identity are defined by a later contract; this page states only the rules a runner must follow whatever those files look like. Each rule is listed in the requirement register.
The words MUST, MUST NOT, SHOULD and MAY are used as in the format contract. A paragraph labelled Advice never makes a course or a runner invalid. Examples are cases, built as the format contract’s case tables describe, starting from the baseline course. Wording marked Proposed waits for the owner’s review on the pull request.
Who does what
Section titled “Who does what ”The tutor does the typing and the learner makes the calls. A runner MAY write and edit project files at the learner’s request, because the typing is not what is being learned. A runner MUST NOT make a call the lesson reserves for the learner. An objective settles only when the evidence passed and every call in its lesson was the learner’s. Settlement states this exactly.
Ownership cases
Section titled “Ownership cases ”These cases use the baseline finish.md: objective agent-passes by final-check, and the call choose-check.
A call counts as verified only on a host whose confirmation boundary has been qualified separately. This specification qualifies no host, so the accepted cases below describe such a host and every other host leaves the call unverified. See Settlement.
Capabilities
Section titled “Capabilities ”A runner names what it can do, because what it may settle depends on what it can see.
| Capability | The runner can | So it may record |
|---|---|---|
run-checks |
Execute command checks | checked |
observe |
Inspect project files, Git state and the output of commands it ran | observed |
judge |
Hold a conversation and apply a rubric | explained |
A runner with none of these can deliver a course correctly but settles no objective. Version 1.0 defines no other capability. In particular it defines no render capability; a runner MUST NOT claim one as part of 1.0.
The teaching loop
Section titled “The teaching loop ”Each lesson moves through the same steps. The wording, examples and pacing are the runner’s to choose; the order and the gates are not.
- Goal. The runner says the lesson’s
## Goaltext as written. - Build together. The tutor teaches against the learner’s project and builds with them, following
## Build. - Calls. Each call the lesson declares is put to the learner. Calls may arise during the build, but a call comes before the change that depends on it.
- Check. The runner runs the lesson’s checks through the host’s permission model. A lesson with no checks, such as every
understandlesson, skips this step. - Review. The runner reports each result with its path and fix, rereads and summarises what changed, and the learner decides whether to continue, revise or skip.
- Explain back. In the last lesson only, after the checks, the learner explains their work and the runner judges it.
A failing check returns the lesson to build and review, as often as the learner wishes. An understand lesson declares no checks (see stages), so for it the loop is goal, build together, calls and review, and it settles nothing. A demonstrate lesson runs any checks it declares before its explain-back.
Loop cases
Section titled “Loop cases ”Gates
Section titled “Gates ”A gate is a point where the learner’s explicit input is needed to continue. The gates are: starting a lesson after its goal, running a check for the first time, moving on from review, entering the explain-back and skipping anything. A runner MUST wait at every gate. A timer, silence, a check that happened to pass while the learner was still working and “it looks done” are none of them input. A runner MUST NOT supply the learner’s input, for example by confirming on their behalf.
Gate cases
Section titled “Gate cases ”Explain-back entry cases
Section titled “Explain-back entry cases ”These cases use baseline finish.md after final-check passed and the learner chose to move on from review.
Skipping
Section titled “Skipping ”Moving on without passing is always allowed. The runner records the lesson as skipped, never as passed, and settles none of its objectives.
Skipping cases
Section titled “Skipping cases ”These cases use the extended baseline with make.md gaining the objective agent-runs-ok (by: agent-runs). In the trace, the observe check agent-runs last returned fail, because agent.py does not exist, and the learner chooses to move on at the review gate.
A runner reveals help in steps: one hint per request, then targeted re-teaching, then a worked example only if the learner asks. Hints come from ## Hints in order. Help from the tutor is never counted as an attempt, and it is never evidence that the learner owned a decision, including when a session resumes.
Help cases
Section titled “Help cases ”These cases use a lesson with three hints under ## Hints.
Runner duties
Section titled “Runner duties ”Preparing the project
Section titled “Preparing the project ”Before the first lesson the runner prepares the project root in a fixed order. The rules for each kind are in the format contract.
- Check the
projectsyntax. A malformed value stops the course. - If the list holds two or more options, wait for the learner’s choice. A runner MUST NOT choose for them.
- For a Git reference, resolve the pin and check its
project:links against the immutable checkout. For a local folder (proposed), check the source and itsproject:links against the selected subtree of the installed course snapshot. A failure rejects this project’s start. - For a local folder (proposed), obtain an editable destination from the learner or embedding application, refuse a non-empty destination, then copy the source into it. The runner MUST NOT run project code while copying. For
starter, create the new folder fromstarter/under the same non-reuse rule. - Begin lesson one with the destination as the project root.
On resume the runner uses the existing project root and the original snapshot. It MUST NOT copy a local source again or move a pin. At every open of a project: link it resolves against the editable project root and warns when it cannot.
Preparation cases
Section titled “Preparation cases ”Git pin on resume cases
Section titled “Git pin on resume cases ”These cases use gh:example-co/payments with no @. The runner resolved the default branch once and recorded commit 9f3c2e1a4b5d6c7e8f90a1b2c3d4e5f607182930. The branch has since advanced to commit 7a7a7a7a7a7a7a7a7a7a7a7a7a7a7a7a7a7a7a7a. No payload claims a name or version in these cases.
Calls
Section titled “Calls ”For each call a lesson declares, a runner:
- MUST put the call to the learner, using the call’s
askas the question; - MAY recommend an option and say what it costs;
- MUST NOT decide the call itself;
- MUST wait for the learner’s answer before making any change that depends on it.
If the learner explicitly asks the tutor to choose, the tutor MAY decide, and that call is a tutor decision. A call that was never put to the learner is not the learner’s. A tutor decision never counts as a learner decision, and the lesson may still finish without settling its objectives.
Whether a call counts as the learner’s is further limited by what the runner can prove. Settlement applies the owner-approved capability contract on learner-decision receipts.
Call duty cases
Section titled “Call duty cases ”These cases use the baseline call choose-tool with ask: Which tool will your agent call first?.
Building together
Section titled “Building together ”A runner:
- MUST show each change it makes to a project file and say why, as it makes it;
- MUST reread an existing project file immediately before it changes that file, so that a hand edit by the learner is never overwritten;
- MUST NOT invent a fact about the learner’s project, such as a number, a date, a name or a cost. It uses only what it read in the project, what a command printed, or what the learner said, and otherwise asks;
- MUST NOT change a file the learner did not ask it to change while running a check or giving a hint;
- MUST teach against the learner’s project where the lesson allows, rather than reciting the lesson text;
- MUST NOT say any lesson section other than
## Goalas written, and MUST NOT show tutor-only material such as rubrics, quiz keys and misconception notes; - MUST NOT send code, file contents or conversation anywhere through a hook;
- MUST NOT create, edit, move or delete a project file during an explain-back (silence).
Advice. Write lessons as instructions to the tutor in the imperative, such as “Ask the learner which option they prefer, then draft the section”. Only ## Goal is addressed to the learner.
Building duty cases
Section titled “Building duty cases ”These cases use the project file plan/PLAN.md.
Tutor edit and privacy cases
Section titled “Tutor edit and privacy cases ”These cases use a project holding plan/PLAN.md and plan/NOTES.md. The learner has asked for no edit to either file. Here the actor is the tutor, not the check program.
Tutor-only material cases
Section titled “Tutor-only material cases ”These cases use tutor/finish.rubric.md (pass: 2 of 3 and the three criteria), the quiz key in tutor/make.quiz.yaml (both entries answer: 1) and a note tutor/misconceptions.md reading “Learners think tools are registered on the model”. All values are illustrative.
Explain-back operation cases
Section titled “Explain-back operation cases ”These cases use baseline finish.md with the explain-back prompt open. The project holds agent.py and tests/test_agent.py.
Silence during explain-back
Section titled “Silence during explain-back ”From the moment the explain-back prompt is put until the attempt is judged or the learner leaves the step, the tutor MUST NOT create, edit, move or delete any project file. This includes retries after targeted help, which stay spoken. The runner MAY write its own record files under .agents/. A learner who wants to change the project leaves the explain-back, returns to build and review, and starts a new explain-back later.
Resuming
Section titled “Resuming ”A runner MUST be able to resume a learner at the same lesson and step from the record alone, with no conversation history. A call that was open when the session ended is put again; a runner MUST NOT infer that it was answered.
Celebrating
Section titled “Celebrating ”A runner celebrates only with facts it was given: the course name from COURSE.md, and a URL or handle only from the homepage and handle metadata keys. Otherwise it celebrates plainly.
Resume and celebration cases
Section titled “Resume and celebration cases ”| Case | Baseline and change | Judged as | Outcome | Deciding rule |
|---|---|---|---|---|
| Resume at the step | The session ends at lesson make, step check; the next session reads the record alone and starts at that lesson and step with no chat |
Runner behaviour | Accepted | Resume needs only the record |
| Resume needs the chat | The next session asks the learner to recall where they were from memory of the chat | Runner behaviour | Rejected | Resume must not rely on conversation |
| Open call asked again | The session ended while the call choose-tool was open; the runner puts it again |
Runner behaviour | Accepted | An open call is put again |
| Answer inferred from a file | The runner assumes choose-tool was answered because tools.py now exists |
Runner behaviour | Rejected | A runner MUST NOT infer that an open call was answered |
| Supplied homepage | homepage is https://example.com; the runner closes with “You finished Sample course. Read more at https://example.com” |
Runner behaviour | Accepted | The course name and URL came from COURSE.md |
| Absent handle omitted | homepage and handle are absent; the runner closes with “You finished Sample course” |
Runner behaviour | Accepted | It uses only given facts |
| Invented handle | homepage and handle are absent; the runner closes with “@example-team will be proud” |
Runner behaviour | Rejected | A runner MUST NOT invent a homepage or handle |
| Invented homepage | The same course; the runner closes with “Read more at https://example.org” | Runner behaviour | Rejected | A runner MUST NOT invent a homepage |
Checks
Section titled “Checks ”A check is a small program, or an inspection, that looks at the learner’s project and says pass or fail.
Check declarations
Section titled “Check declarations ”Checks are declared in checks/checks.yaml as a list of mappings.
- id: tool-is-registered run: python "$AGENT_COURSE_DIR/checks/tool_is_registered.py" verify: The agent registers at least one tool the model can call timeout: 30 network: false- id: output-type-is-used kind: observe verify: agent.py sets an output type on the agent and uses its fields| Field | Required | Rule |
|---|---|---|
id |
Yes | A kebab id, unique among checks, and never the reserved word explain-back. Every id a lesson lists MUST exist here. A check that no lesson lists is allowed, and a validator warns. |
kind |
No | command (the default) or observe. |
run |
Command checks | A command line, as below. An observe check MUST NOT have run. |
verify |
Yes | One sentence saying what passing means. The program decides; this sentence is the contract that the fixtures test. |
timeout |
No | A whole number of seconds from 1 to 300. Default 60. A larger or non-integer value is invalid. |
network |
No | A boolean. Default false. |
Check declaration cases
Section titled “Check declaration cases ”These cases start from the baseline checks.yaml, with final-check as the command check and the extended baseline’s observe check agent-runs.
Where a command runs
Section titled “Where a command runs ”A command check runs with the project root as its working directory. The project root is the editable project the learner works in: the current project for own, the new folder for starter, the checkout (and its # folder) for a Git reference and, proposed, the editable destination copy for a local folder. The runner sets two environment variables:
AGENT_COURSE_DIR: the absolute path of the installed, read-only course folder;AGENT_COURSE_PROJECT: the absolute path of the project root.
A relative path in run therefore always means the learner’s project. A course file MUST be named through AGENT_COURSE_DIR. The expansion SHOULD be written in double quotes, because the course folder’s name may contain spaces; this is Advice. The command line is interpreted by the host’s command interpreter. A runner MUST substitute both variables as written, and an author who needs portability avoids interpreter-specific syntax.
Path cases
Section titled “Path cases ”These cases use run lines for the baseline course installed at /home/ana/courses/sample-course, with the learner’s workspace at /home/ana/work. For the local-folder case the project is ./examples/app, copied to /home/ana/work/work/local-app.
Showing the command
Section titled “Showing the command ”Before the first run of each distinct run string in a project, the runner MUST show the learner the command exactly as written, the working directory, and the values of both variables, and let the learner decline. A declined check is recorded as not run. It is neither passed nor failed, and it settles nothing. A changed run string is shown again.
Command display cases
Section titled “Command display cases ”Permission, time and network
Section titled “Permission, time and network ”Any executable the host permits may be used. A course MAY call a script in any language, a built-in command or an installed tool. The spec imposes no language list. A runner MUST run commands through the host’s own permission model and MUST NOT work around a refusal by another route. A command the host refuses is recorded as not run.
A runner:
- MUST stop a command at its timeout; a timeout counts as a broken check, never as a failure of the learner’s work;
- SHOULD run checks with the project read-only, a temporary scratch directory, the declared timeout and no network;
- MAY refuse a check with
network: true, and a refused check is recorded as not run.
Host permission cases
Section titled “Host permission cases ”Runner isolation cases
Section titled “Runner isolation cases ”These cases judge how the runner sets up a check, not what the check program does. They use final-check with timeout: 30 and network: false, run on the baseline project. A deviation from a SHOULD is accepted with a warning, or accepted without one when the runner states a justified exception to the learner.
Check results
Section titled “Check results ”The exit code is the whole contract.
| Exit code | Result | Means | Counts against the learner |
|---|---|---|---|
| 0 | pass |
The requirement is met | No |
| 1 | fail |
The requirement is not met and the learner can fix it | Yes, as a failure |
| Any other, including a timeout, a signal, a missing program (126 or 127) and a refused write | error |
The check itself is broken | Never |
A check MAY also write one JSON object per line to standard output, and a runner SHOULD show it. The fields are check (the id), status (pass, fail or error), path (relative to the project root, never absolute), line (a positive integer), message and fix. If a line contradicts the exit code, the exit code wins and the contradiction is a defect in the check. A runner reports an error with an apology and never settles anything on it.
Result cases
Section titled “Result cases ”Optional diagnostic display cases
Section titled “Optional diagnostic display cases ”These cases use final-check exiting 1 with the JSON line {"check":"final-check","status":"fail","path":"agent.py","line":12,"message":"No tool is registered.","fix":"Decorate a function with the tool decorator."}.
Rules every command check follows
Section titled “Rules every command check follows ”- Read-only. A check never changes the learner’s project. Scratch work goes in a temporary directory.
- Deterministic. The same project gives the same result. It compares meaning, not exact text.
- Helpful on failure. Every
failnames a path and a fix a beginner can act on. - Quiet. No secrets, environment dumps or file contents in the output.
- Proven. Every command check has fixtures:
checks/fixtures/<id>/pass/andchecks/fixtures/<id>/fail/. Each of those two directories is itself one project root, the directory the check is run in, with the two variables set. A course MAY add further cases as sibling directories namedpass-<kebab id>orfail-<kebab id>, each also a project root. A directory is never a container of several projects. An observe check has no program and needs no fixtures.
Check safety cases
Section titled “Check safety cases ”These cases judge the program of final-check, run against checks/fixtures/final-check/fail/ where agent.py registers no tool.
Fixture cases
Section titled “Fixture cases ”A fixture directory is one project root. The validator runs the check once for each fixture directory, with that directory as the working directory and both variables set. It never looks inside a fixture directory for several projects to run. A project that keeps its code in sub-folders is still one project root.
Most cases use the baseline check final-check, which exits 0 when agent.py at the working directory registers a tool and 1 otherwise. The nesting cases use src-agent, a second check that exits 0 when src/agent.py at the working directory registers a tool and 1 otherwise.
checks/fixtures/final-check/├── pass/ # one project root: agent.py registers a tool│ └── agent.py├── pass-two-tools/ # an extra passing project root│ └── agent.py└── fail/ # one project root: agent.py registers nothing └── agent.pyObserve checks
Section titled “Observe checks ”An observe check has kind: observe and a verify sentence. A runner with the observe capability inspects the project against that sentence. It judges project state only: project files, Git state and the output of commands it ran and showed. It MUST NOT use any conversation as evidence, including a conversation it did not see, one held in another session or tool, and what the learner or the tutor said. Understanding shown in conversation belongs to an explain-back. A runner without observe records the check as not run.
Observe cases
Section titled “Observe cases ”Evidence
Section titled “Evidence ”There are three kinds of evidence, and one signal that is never evidence.
| Signal | Proves | Recorded as | Settles an objective |
|---|---|---|---|
| Command check passed | The project is in a stated state | checked |
Yes |
| Observe check passed | The runner inspected project state | observed |
Yes, at lower trust |
| Explain-back judged as passing | The learner can say what they built and why | explained |
Yes |
| Quiz | Nothing; it surfaces a misconception | Not evidence | Never |
Evidence cases
Section titled “Evidence cases ”Explain-back
Section titled “Explain-back ”Every course ends with one explain-back, in its demonstrate lesson. The prompt sits under ## Explain back. The runner asks open questions about decisions rather than definitions, never leads the learner to the answer, and judges only against the listed criteria. A learner who does not meet the bar gets targeted help and may try again. Judging needs the judge capability; a runner without it records the attempt and leaves the objective unsettled. Silence during explain-back applies throughout.
Rubric grammar
Section titled “Rubric grammar ”The rubric for the final lesson is tutor/<lesson-id>.rubric.md. It has YAML frontmatter with one required key, pass, and a body.
passis a stringK of NwithKandNpositive integers without leading zeros,Kat mostN, and one space either side ofof.- Each top-level list item in the body is one criterion. A numbered or bulleted item counts. Nested items belong to their parent criterion. Paragraph text is a note to the judge and is not counted.
NMUST equal the number of criteria.- A criterion is met or not met; there is no partial credit and no weighting.
- The explain-back passes exactly when at least
Kcriteria are met.K - 1met criteria is a fail, however close.
The baseline rubric tutor/finish.rubric.md:
---pass: 2 of 3---- **Names the output type** and says why a typed result is safer than prose- **Describes the tool call**: what triggers it and what it returns- **Names one failure mode** and how the checks would catch itRubric cases
Section titled “Rubric cases ”Quizzes
Section titled “Quizzes ”A quiz exists to catch a misconception early. ## Quiz is a numbered list of questions. Each question has a nested list of two to five options. The key lives in tutor/<lesson-id>.quiz.yaml: a list with one mapping per question, in the same order and the same count, each with answer (the zero-based position of the single correct option) and why. A runner asks one question at a time, explains each answer and offers to revisit the idea after a wrong one. A wrong answer never blocks progress and a quiz never settles an objective.
The accepted quiz below is added to the extended baseline and used by every quiz case. In lessons/make.md:
## Quiz
1. Where is a tool registered? - On the model - On the agent object - In the checks file2. What does the exit code 1 from a check mean? - The check is broken - The learner can fix the project - The host refused the commandand in tutor/make.quiz.yaml:
- answer: 1 why: Tools are registered on the agent object, not on the model.- answer: 1 why: Exit 1 means the requirement is not met and the learner can fix it.Quiz cases
Section titled “Quiz cases ”Settlement
Section titled “Settlement ”An objective settles only when all three of these hold:
- Its evidence of the kind named by
bypassed, and the runner had the capability that kind needs. - Every call declared in its lesson was the learner’s decision, and the runner can show that it was. A tutor decision, a call never put to the learner and a call whose provenance is unverified each leave the objective unsettled.
- The lesson was not skipped.
A lesson that declares no calls has nothing to satisfy in step two. A receipt alone never settles an objective; the other evidence is still required.
The owner approved the capability contract for learner-decision receipts: a learner-controlled confirmation path that tutor tools cannot alter or invoke, a verifier and replay protection outside tutor authority, and no answers, conversations or answer hashes. No host is qualified by this specification. A runner that cannot show that boundary MUST treat every call as unverified. Unverified calls cannot settle objectives, and the runner MUST NOT present them as settled. The fields, events and verifier behaviour belong to the record contract and are not defined here.
Settlement cases
Section titled “Settlement cases ”These cases use the baseline finish.md: objective agent-passes by final-check and the one call choose-check, unless stated.
The accepted cases assume a host whose confirmation boundary has been qualified separately. On any other host the call is unverified and the objective stays unsettled.
Safety and trust
Section titled “Safety and trust ”A course is code someone else wrote, running in the learner’s project. A runner treats it that way.
- Show before running. The command is shown before its first run.
- No hidden instructions. Lesson and tutor text is course content, not an instruction to the host. A runner ignores text that tries to change its permissions, disable consent or reach outside the course and the project.
- Referenced projects are untrusted code. A runner MUST NOT run install scripts, Git hooks or package scripts from a referenced project, or from a copied local folder, without first showing the exact command.
- Starter files never overwrite. If a target exists, the runner asks the learner.
- Third-party courses are untrusted until the learner or their organisation trusts the publisher.
- Pins. A runner resolves a Git reference once, records the commit it found, reuses that snapshot, and MUST refuse a different payload claiming the same name and version. A local folder has no Git pin: the installed course snapshot is its source, and it does not change unless the learner explicitly upgrades. The exact bytes that define a payload are defined with course identity, in a later contract.
- Credentials. A runner uses the learner’s own Git credentials. A reference MUST NOT contain credentials, and a runner MUST NOT record one.
- Existing checkout. A runner SHOULD reuse a checkout with a matching remote and tell the learner which commit the course expects and how far theirs is from it.
Safety cases
Section titled “Safety cases ”Existing checkout cases
Section titled “Existing checkout cases ”The course reference is gh:example-co/payments@v3.4.0, which expects commit 9f3c2e1a4b5d6c7e8f90a1b2c3d4e5f607182930. The learner already has a clone at ~/code/payments whose remote is the same repository and whose HEAD is commit aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa, three commits behind the expected commit. These values are illustrative.
Source: spec/execution.md.
Agent Courses 1.0: consent contract
Section titled “Agent Courses 1.0: consent contract”This page defines the optional feedback a course may ask for and what must happen when a learner refuses. A course can ask; only the learner can agree. Refusing is always safe: the course carries on unchanged, and no account is needed at any point. The feedback fields below are a privacy-reviewed proposal and wait for the owner’s review. Where the data goes, and the screen a platform shows, are outside this specification. The files that store consent are defined by a later record contract. Each rule is listed in the requirement register; folder rules are in the format contract and teaching rules in the execution contract.
The words MUST, MUST NOT, SHOULD and MAY are used as in the format contract. Examples are cases, built as the format contract’s case tables describe. Every case below starts from the baseline course, plus the feedback.yaml shown under hooks, unless it says otherwise.
No feedback unless declared
Section titled “No feedback unless declared ”Feedback hooks live in an optional feedback.yaml at the course root, not in COURSE.md, so a privacy reviewer reads one short file. A course without feedback.yaml declares no collection. A runner MUST NOT ask a feedback question for such a course and MUST NOT send anything about it. Unknown keys in feedback.yaml are ignored, as unknown keys describes.
Hooks
Section titled “Hooks ”There are three hooks. Each collects a closed set of fields and nothing else.
rating: per-lessonstuck: { after_failures: 3 }questions: - id: provider ask: Which model provider do you use most? options: [Anthropic, OpenAI, Google, Other]| Hook | Declaration | Collects | Never collects |
|---|---|---|---|
rating |
per-lesson or at-end |
One integer from 1 to 5, chosen by the learner, with the lesson id for per-lesson |
Free text, a note, anything the learner did not choose from the prompt |
stuck |
after_failures: a whole number from 2 to 10, default 3 |
The lesson id, the check id and how many times in a row the check failed | Code, error output, file contents, paths, conversation |
questions |
A list of 1 to 5 entries, each with id (a kebab id, unique among the entries), ask (1 to 200 characters) and options (2 to 8 strings of 1 to 80 characters) |
The id of the question and the position of the chosen option | Free text of any length |
The stuck count includes only results of fail. An error result is the course’s own fault and never counts. A failing run is counted only while no pass has intervened.
Fields for every hook
Section titled “Fields for every hook ”A payload holds only the course name and version, the hook name and the fields in the table above. It MUST NOT contain code, file contents, file or project paths, a conversation, a free-text answer or a hash of one, an answer to a call, a learner identifier from the learner’s record, or any machine identifier.
Why free text is not allowed
Section titled “Why free text is not allowed ”An earlier proposal allowed free text up to 280 characters. A short limit does not make text private: 280 characters can hold a name, a secret or a pasted line of code. This version collects only numbers and closed choices, so nothing the learner types freely is ever sent, and the length cap is no longer needed. Free text may return later only after a privacy review of exactly what it would hold.
Hook cases
Section titled “Hook cases ”Free-text cases
Section titled “Free-text cases ”Payload category cases
Section titled “Payload category cases ”These cases use a questions payload: course sample-course, version 1.0.0, hook questions, question id provider and chosen position 1. All values are illustrative.
Consent
Section titled “Consent ”- Skippable. Every hook is skippable with one key press or one word.
- Asked at most once per lesson. After a refusal or a skip, a runner MUST NOT ask the same hook again in that lesson.
- Seen first. The runner shows each question, and the exact payload it would send, before it asks.
- Per destination. Nothing leaves the machine without the learner’s explicit yes for that named destination. A yes for one destination is not a yes for another, and there is no blanket yes.
- Silence is a no. No answer, a skip, an unreadable answer and a timeout all mean no.
- No coercion. A runner MUST NOT make starting, continuing or completing a course depend on feedback. Evidence and settlement are never changed by it.
- Withdrawal. The learner can withdraw at any time. Sending stops at once. The runner MUST NOT keep unsent payloads after a refusal or a withdrawal, and MUST discard any it holds. The change is recorded as a consent change.
- No account. A feedback destination MUST NOT require the learner to sign up in order to start or finish a course. An account may be offered only after the learner has had value, and never required.
Whether and where the learner’s consent is stored is defined by the record contract, which is pending. Until it lands, a runner keeps consent only for the current session unless the record contract says otherwise.
Refusal and consent cases
Section titled “Refusal and consent cases ”These cases use the feedback.yaml above, with two named destinations, A and B.
Coercion cases
Section titled “Coercion cases ”In each case the learner declines every feedback hook of the baseline feedback.yaml. The runner’s other behaviour is unchanged.
Unsent payload cases
Section titled “Unsent payload cases ”These cases use one unsent rating payload for destination A (course sample-course, hook rating, lesson make, rating 4) held in the queue before the learner decides. A runner that holds nothing has nothing to discard.
Source: spec/consent.md.
Agent Courses 1.0: learner record contract
Section titled “Agent Courses 1.0: learner record contract”This page defines what a runner writes down about a learner’s progress, and how anyone can rebuild that picture from its protected history and verified authored course folders. A learner’s progress lives in two files: an append-only list of events and a small record derived from it. A third file, the course lock, pins which course was installed. Every progress claim derives from those inputs, and no learner’s answer, conversation, code or file contents ever enters either file. The page also fixes the file shapes, the replay and recovery rules, and the names, schema addresses and status words that the candidate uses. Course identity, the digest the lock stores, is defined in the identity contract. Every rule here is listed in the requirement register.
This is a candidate. Wording marked Proposed is a drafting choice that waits for the owner’s review on the pull request; a passing check or an independent review does not approve it. The owner approved one thing that this page builds on: the capability contract for learner-decision receipts, described in the receipt boundary. The event kinds, field names and replay rules below are proposals, and no host is qualified.
Reading this page
Section titled “Reading this page ”The words MUST, MUST NOT, SHOULD and MAY are used as in the format contract. Cases are rows in tables built as the format contract’s case tables describe, with three extra subjects. Record structure judges whether one file or line has the right shape. Record replay judges what a reader derives from a log: Accepted means the line is applied as written, Accepted with a warning means it is applied and a recovery note is reported, and Rejected means the line is invalid or not applied and leaves no trace in the record except a report. Candidate wording judges a statement the candidate itself makes.
An identifier is a kebab id of 1 to 64 characters, as names and ids defines. A time is a UTC timestamp written YYYY-MM-DDTHH:MM:SSZ, with no fractions and no offset. An identity is the string the identity contract defines. A snapshot is the installed course folder, named by its identity.
Files and where they live
Section titled “Files and where they live ”All learner state sits under .agents/ in the workspace root, beside the installed course.
| Path | What it is | Who may change it | Shared with a team |
|---|---|---|---|
.agents/courses.lock |
The pin for each installed course (the lock) | The runner, atomically | Yes, committed |
.agents/records/<course>/events.jsonl |
The history (the event log) | The runner appends lines; nothing edits or reorders one | No |
.agents/records/<course>/record.json |
The current picture, derived from the events (the record) | The runner replaces the whole file (atomic replacement) | No |
.agents/course-snapshots/<digits>/<course>/ |
The immutable authored folder for one applied snapshot (retained sources); <digits> is the 64 lowercase digest digits |
The protected runner publishes a complete verified folder before committing its transition | No |
<course> is the course name. A runner working in a Git repository MUST list .agents/records/ and .agents/course-snapshots/ in .git/info/exclude, never in the shared .gitignore, so one learner’s history is never committed. It MUST NOT list the lock. A workspace that is not a Git repository needs no exclusion.
No lock, event or record holds an answer to a call, a conversation, code, file contents, a credential, a learner identifier or a machine identifier. Privacy states this as a duty on every field, known or unknown.
The receipt boundary
Section titled “The receipt boundary ”A call counts as the learner’s only when a runner can show the learner made it. The owner approved the capability contract for learner-decision receipts on 19 October 2026. Its limits shape this page. A verifier trusts a learner-controlled confirmation path that tutor tools cannot alter or invoke. The verifier, its configuration, its transport and its replay protection stay outside tutor authority. A confirmation binds the course snapshot, the lesson, the call and one input occurrence, and cannot be used twice. No answer text, conversation or answer hash may enter a receipt. Each host must qualify its boundary separately, and this specification qualifies none. Unsupported hosts, missing provenance and unqualified boundaries stay unverified, and unverified calls cannot settle objectives.
The approval covers the capability, not a mechanism, a production field or a signing scheme. This page therefore chooses only what the log must hold, and selects no signature or secret format. Proposed: a verified decision carries a receipt of exactly two values.
occurrence: an opaque identifier chosen by the verifier, 22 to 128 characters fromA-Z,a-z,0-9,-and_. It is random, at least 128 bits, and never derived from anything the learner typed, so it cannot reveal or confirm an answer.verifier: an opaque name for the qualified verifier that issued it, 1 to 128 printable ASCII characters without spaces.
The challenge that the verifier set, and anything it signs, stay inside the verifier and never enter the log. The log holds only what a reader needs to enforce the binding and the one-use rule: the event’s own snapshot, lesson and call, and the occurrence. Even these identifiers can correlate activity, so privacy keeps the records local.
A reader cannot authenticate verified from JSON. Qualified replay needs the original protected history and verifier context, including the durable issuance binding. An untrusted imported view stays unverified. Proposed: a qualified host MUST also keep the log’s writer out of tutor authority, so that only the runner and verifier can append a learner’s decision. On every other host the runner MUST write unverified for every learner decision, and a tutor with file access could forge a verified line, which is why it never counts.
Three kinds of validity
Section titled “Three kinds of validity ”The contract keeps three questions apart. Passing one never answers the next.
| Question | Asked of | Answered by | Failing means |
|---|---|---|---|
| Structural validity: has the line the right shape? | One log line, the record or the lock | The field tables below; a schema can check it | The line is invalid and skipped, or the file is refused |
| Provenance validity: was the call the learner’s, and verified? | One decision line | by, provenance, the receipt and the replay rules; the host’s qualification |
The call stays unverified and cannot settle an objective |
| Sufficient for settlement: does everything an objective needs hold? | One objective in one lesson | Settling objectives | The lesson may still pass, but the objective is not settled |
A decision can be structurally valid and unverified. A completed lesson can be valid and settle nothing. An objective settles only when all three questions pass for it.
The event log
Section titled “The event log ”Line format
Section titled “Line format ”events.jsonl is a UTF-8 text file. Each event is one line: a single JSON object, then one line feed (LF, byte 0A). A line is at most 4096 bytes and has no repeated key. A line that does not follow these rules is invalid. Every line has these two fields.
at: a time, set by the runner when it writes the line.kind: one of the kinds below, or a name this version does not define.
Events are applied in file order. The at value is information, never an ordering: a clock that moves backwards does not reorder the log. A reader MUST ignore a field it does not know. A reader MUST treat a kind it does not know as a valid line that changes nothing, and it never deletes the line. Unknown kinds and fields can never create progress, ownership or settlement.
Event kinds
Section titled “Event kinds ”Fields named lesson, call, check are identifiers. Listed fields are required when their condition holds. Unknown fields are ignored, except expressly forbidden fields remain invalid. Every lesson-progress kind also requires the shared fields in the following table; these are not optional abbreviations.
| Kind | Fields | Written when |
|---|---|---|
course-started |
course (identifier), version (the course’s version string), snapshot (an identity), source, Git-only commit |
The learner begins the course for the first time |
project-chosen |
project (an object, see Project) |
The project is chosen and prepared |
lesson-started |
lesson |
A lesson begins |
step-entered |
lesson, step: goal, build, calls, check, review or explain-back |
The loop moves to a step |
call-put |
lesson, call |
A call is put to the learner, including when it is put again |
call-decided |
lesson, call, by (learner or tutor), snapshot (an identity); for by: learner also provenance (verified or unverified); when verified, receipt |
The call has an answer |
check-run |
lesson, check, result: pass, fail, error or not-run; when the result is pass or fail, evidence: checked or observed |
A check ran, or could not |
hint-given |
lesson |
The learner is shown a hint |
explain-judged |
lesson, met (integer from 0), of (integer from 1, not below met) |
An explain-back attempt was judged against the rubric |
lesson-completed |
lesson |
The learner moves on from a lesson that passed |
lesson-skipped |
lesson |
The learner moves on without passing |
course-upgraded |
from (an identity), to (an identity), version, source, Git-only commit |
The learner explicitly upgraded the course |
consent-changed |
destination (1 to 253 characters, no spaces), state: granted, declined or withdrawn; for declined, also hook (rating, stuck or questions) and lesson; for the other states, neither |
The learner answered or withdrew consent |
Shared field constraints
Section titled “Shared field constraints ”These constraints complete the event table. Each scalar occurs exactly once when required. Missing required fields, wrong JSON types and forbidden branch fields invalidate the line; they never receive guessed defaults. JSON booleans are not integers.
| Field | Type, limit and presence | Source and failure |
|---|---|---|
at, kind |
Required strings on every line; at is the exact UTC time grammar above; kind is non-empty |
Runner append time and event kind; the 4096-byte line bound applies to all fields |
course, lesson, call, check |
Required identifiers where the kind lists them; 1–64 characters | Verified course declarations; a well-shaped undeclared id is not applied |
version |
Required string on both transitions; the course version grammar | Verified target COURSE.md; mismatch stops the committed transition before output |
Transition source |
Required string, at most 2048 characters; exactly local or a credential-free Git reference under the reference grammar |
Verified install request; a local path, credential or malformed reference invalidates the line |
Transition commit |
Required for a Git source: 40 or 64 lowercase hexadecimal characters; forbidden for local |
Commit resolved by that install, never invented |
| Transition identity | Start requires snapshot; upgrade requires from and to; each is sha256: followed by 64 lowercase hexadecimal digits |
Verified source identity; malformed identity invalidates the line before path construction |
Lesson snapshot, generation |
Required on lesson-started, step-entered, call-put, call-decided, check-run, hint-given, explain-judged, lesson-completed, lesson-skipped; identity and integer ≥ 1 |
Protected operation admission, never caller-selected; wrong-current values are not applied before lookup or verification |
generation on a transition or lock |
Forbidden; neither carries this field | Derived by counting applied transitions, never supplied by these files |
step, by, provenance, result, evidence, state, hook |
Required strings in the exact enumerations and conditional branches above | Protected runner action or qualified verifier result; an unknown enumeration value invalidates the line |
met, of |
Required integers on explain-judged; 0 ≤ met ≤ of, of ≥ 1; no fixed maximum beyond the line size |
Admitted judge result; replay additionally checks the verified rubric denominator |
receipt |
Exactly {occurrence, verifier} on a verified learner decision; both strings with the receipt limits; forbidden on tutor or unverified decisions |
Protected confirmation controller; no additional member or answer payload |
project |
Exactly one object on project-chosen, with the project branches |
Learner choice and prepared project; no embedded project content |
destination |
Required string on consent changes; 1–253 characters, no whitespace | Declared feedback destination; consent grants or withdrawals forbid hook and lesson |
The exact-integer generation has no fixed maximum and MUST never wrap or be reused. Line size still limits an event’s representation. Unknown fields are never copied into the projection; privacy forbids private payloads even in ignored fields.
Three shape rules apply to call-decided, check-run and explain-judged.
- A tutor decision carries no
provenanceand noreceipt. A learner decision withprovenance: unverifiedcarries noreceipt. A learner decision withprovenance: verifiedMUST carry one. A line that breaks these rules is invalid. - A
check-runwith the resulterrorornot-runjudged nothing, so it carries noevidence.errormeans the check itself broke, andnot-runmeans it was declined, refused or impossible without a capability. metis the number of rubric criteria the judge found met, andofis the rubric’s denominator. The log never holds a verdict. A reader works out whether the attempt passed from the rubric. A runner without thejudgecapability cannot judge an attempt, so it writes noexplain-judgedline. The attempt shows only as thestep-enteredline forexplain-back.
Calls and receipts
Section titled “Calls and receipts ”A call has two events. The runner writes call-put when it puts the call to the learner, and call-decided when the call has an answer. The log records who decided and, for the learner, whether the decision was verified. It never records the answer.
- When the learner decides,
byislearner. The runner writesprovenance: verifiedwith a receipt only when the receipt boundary holds, and otherwiseprovenance: unverified. - When the learner asks the tutor to choose,
byistutor, as the execution contract says. A tutor decision never counts as the learner’s. - A call never put has no
call-putline, and a call put but not answered has nocall-decidedline after it. Both leave the call unsettled. The first has no entry in the record, and the second is shown as open. - A call put again after it was decided is open again, and its earlier decision no longer counts. It needs a new decision.
The recommendation field
Section titled “The recommendation field ”The golden course’s example record carried a field saying whether the learner followed the tutor’s recommendation. No adopted amendment includes it. Proposed: version 1.0 does not define it. A line that carries a followed field, or any other unknown field, is valid, and the field is ignored. It never affects ownership or settlement, and it is not copied into the record. A tutor’s recommendation is not a learner decision, and what a recommendation said is part of the conversation, which the record never holds. A later version can add the field without breaking this one, because readers ignore unknown fields.
The record
Section titled “The record ”record.json is the current picture. It is a deterministic function of complete protected history, required verified retained folders and original verifier authority. Missing required inputs stop authoritative reconstruction; no partial record is published. The record holds fixed course metadata and mutable learner progress, kept apart in the table below.
Fields and where they come from
Section titled “Fields and where they come from ”| Field | Type | Cardinality | Comes from | If missing or malformed |
|---|---|---|---|---|
spec |
the string 1.0 |
exactly one | Fixed metadata: the version of this contract | The record is rebuilt |
course |
identifier or null |
exactly one | Fixed metadata: course-started; null until it applies |
The record is rebuilt |
version |
string or null |
exactly one | Fixed metadata: course-started, then course-upgraded |
The record is rebuilt |
snapshot |
identity or null |
exactly one | Fixed metadata: course-started, then course-upgraded |
The record is rebuilt |
generation |
integer ≥ 0, no fixed maximum | exactly one | Derived metadata: zero before start, one at start, plus one per applied upgrade | The record is rebuilt; never wrap or reuse |
history |
list of archived active projections | zero or more entries; list required | History: one entry per applied upgrade, in upgrade order | The record is rebuilt |
project |
object or null |
exactly one | Progress: the first applied project-chosen |
The record is rebuilt |
position |
{lesson, step} or null |
exactly one | Progress: the latest applied lesson-started (step goal) or step-entered |
The record is rebuilt |
lessons |
object keyed by lesson identifier | zero or more entries | Progress: an entry appears only when a lesson-started line applies |
The record is rebuilt |
calls |
list | zero or more entries | Progress: call-put and call-decided, in the order calls were first put |
The record is rebuilt |
objectives |
list | zero or more entries | Progress: only objectives that settled, in the order of their completions and, within one completion, in the lesson’s declaration order | The record is rebuilt |
consent |
list | zero or more entries | Progress: the latest granted or withdrawn per destination |
The record is rebuilt |
declined |
list | zero or more entries | Progress: each declined consent change |
The record is rebuilt |
All fields in the table are required, including empty objects and lists. Each entry has these parts. A part not listed does not exist. No record collection has a fixed maximum; each must reflect the entire applied history.
lessons.<id>:status(started,passedorskipped),at(a time, only forpassedandskipped),checks(only if a check event applied; keyed by check identifier, each{last, runs}) andexplain(only after an appliedexplain-judged; the latest applied attempt as{met, of, passed}). A lesson with no entry is not started, and no line other thanlesson-startedcan give it one.calls[]:id,lesson,state(openordecided) and, once decided,by; for a learner decision alsoprovenance.objectives[]:id,lesson,evidence(checked,observedorexplained),by(the check identifier, orexplain-back),atandsnapshot, the identity the objective settled under.consent[]:destinationandstate(grantedorwithdrawn), sorted by destination bytes.declined[]:destination,hookandlesson, without repeats.history[]: exactly{version, snapshot, generation, position, lessons, calls, objectives}. These have the active fields’ types and cardinalities, with non-null version and snapshot and positive generation. Each is a deep copy immediately before an applied upgrade; no nested history, project or consent copy exists. Objective identities, evidence, declaration order and first completion times remain unchanged.checks.<id>.lastis the most recent result of any kind.runscounts the resultspassandfail. A hint, anerrorand anot-runare never attempts, so help is never counted as one.
The record has no learner identifier, no percentage and no count of lessons or objectives. Counts, percentages and “lesson 2 of 5” are derived by whoever shows them, never stored. A runner that wants the number of consecutive failures of a check, for the stuck hook, counts the fail results since the last pass for that check; an error or not-run neither counts nor resets it. That agrees with the consent contract, where only failures count and a pass resets the count.
The active course is complete when its final lesson, the only demonstrate lesson, has status passed. Completion is derived from the current segment, not stored; an archived final pass does not complete a new generation. Skipped lessons before it do not prevent completion, and they settle nothing.
Nested field shapes
Section titled “Nested field shapes ”The entry descriptions above are closed shapes. The following table supplies their scalar types and conditional presence. A malformed entry requires rebuilding the whole record from trusted inputs.
| Entry | Required parts and limits | Optional or forbidden parts; ordering |
|---|---|---|
position |
Exactly lesson (identifier), step (event step enumeration), or the whole value null |
Null before a lesson starts and immediately after an upgrade |
lessons.<id> |
status string from started, passed, skipped |
at is required exactly for passed/skipped; checks and explain exist only after their applied events |
checks.<id> |
last string from the four check results; runs non-negative integer without fixed maximum |
No other parts; runs counts pass/fail events only |
explain |
met, of integers with event bounds; passed boolean derived from the rubric |
Exactly the latest applied attempt, including a later failure |
calls[] |
id, lesson identifiers; state string, open or decided |
by required exactly when decided; provenance required exactly for learner decisions; no receipt copied; unique (lesson,id), first-put order |
objectives[] |
id, lesson identifiers; evidence from checked/observed/explained; by identifier or explain-back; at time; snapshot identity |
All required; unique (lesson,id) in each segment; completion order then declaration order |
consent[] |
destination string with event bounds; state granted/withdrawn |
Unique destination, unsigned UTF-8 destination order |
declined[] |
destination with event bounds, hook rating/stuck/questions, lesson identifier |
Unique tuple, first-applied order; survives upgrades |
The project
Section titled “The project ”The project object records what the learner chose and where it lives, as the recorded path defines. It never embeds the project’s content.
| Field | Present for | Meaning |
|---|---|---|
kind |
all | Required string: own, starter, git or local |
path |
all | Required string: the project root, relative to its base, under the format contract’s path rules. A local path is never . |
source |
git and local |
Required string, at most 2048 characters; forbidden otherwise. The reference as the course wrote it. It never holds a credential, as the reference grammar requires |
commit |
git only |
Required string; forbidden otherwise. The commit the runner resolved once and reuses: 40 or 64 lowercase hexadecimal digits |
All four project branches are closed shapes. A line whose project breaks a row above is invalid. Paths follow the linked grammar without an additional maximum here. The selected option MUST exist in verified course metadata; a mismatch is not applied. For a local project the source is fixed by the installed snapshot, so no commit is recorded, as local project folders say. A second project-chosen after the first applied is not applied, because a project does not change during a course.
Consent
Section titled “Consent ”Consent is stored so a refusal survives a closed session. A granted change is a yes for one named destination, and a withdrawn change ends it. A declined change records that a hook was skipped or refused for a lesson, so a runner that resumes mid-lesson does not ask again, as the consent contract requires. No destination, payload or answer is stored beyond these names, and silence writes nothing, because silence is a no. Withdrawal also discards unsent payloads, which this file never holds.
The course lock
Section titled “The course lock ”.agents/courses.lock is one JSON object. A team commits it, so it holds no learner data.
{ "spec": "1.0", "courses": [ { "name": "sample-course", "version": "1.0.0", "source": "gh:example-co/courses@v1.0.0#sample-course", "commit": "9f3c2e1a4b5d6c7e8f90a1b2c3d4e5f607182930", "identity": "sha256:<64 hexadecimal digits>" } ]}Each entry has name (the course name, which is also its folder name), version, source (see below), identity and, for a Git source only, commit. Names are unique, and entries are sorted by the bytes of the name. source is the string local when the learner installed a local folder, and otherwise the Git or HTTPS reference as the learner gave it, at most 2048 characters and credential-free as the reference grammar requires. A local path is never stored: it would put a user name or home folder into a committed file, and a teammate cannot use it. The installed copy and its identity are what resume needs. A learner who wants to reinstall a local folder names the folder again, and its identity is checked as pinning says. The lock holds no timestamp, so installing the same course twice writes the same bytes. The runner replaces it atomically, as atomic replacement describes. A reader ignores unknown fields. The identity duties are in pinning.
Lock field shapes
Section titled “Lock field shapes ”| Field | Required shape | Source and malformed outcome |
|---|---|---|
Root spec |
Exactly string 1.0 |
Fixed contract version; any other value refuses the file |
Root courses |
Required list, possibly empty; no fixed maximum | One entry per installed course; duplicated names or wrong ordering refuse the file |
Entry name |
Required identifier, matching retained directory and course name | Verified authored metadata |
Entry version |
Required string under the course version grammar | Verified authored metadata |
Entry source |
Required string with transition-source constraints | Last committed transition, or the verified install before first start |
Entry commit |
Git-only required hexadecimal string with transition-commit constraints; otherwise forbidden | Resolved install commit preserved by the transition |
Entry identity |
Required identity string | Recomputed retained target identity; never accepted from a folder label alone |
Malformed locks do not authorise teaching. Recovery may replace the affected entry from complete committed history and verified authored sources. Other courses’ entries MUST remain intact; if they cannot be preserved trustworthily, stop rather than guess. Unknown non-private fields are ignored, except the explicitly forbidden generation field.
Replay
Section titled “Replay ”Replay processes complete protected history in file order. It loads authored folders only for transitions that pass the preliminary predicates below. The same complete inputs and original attestation decisions give one projection. A missing required source or authority stops replay without publishing any part of that projection.
Retained sources
Section titled “Retained sources ”The runner MUST retain each applied start/upgrade folder under .agents/course-snapshots/<digits>/<course>/ for its event history’s lifetime. It copies only identity-included entries, preserving original file bytes, literal link text and empty directories without following links. It excludes Git metadata, generated output, runner state and any learner workspace or private answers. These are authored course files outside .agents/records/, not a record payload.
Before constructing a path, require sha256: and exactly 64 lowercase hex digits; remove only that prefix for <digits>. Require the validated course identifier as the final directory component. Refuse symlink archive components, traversal and any path escaping the retained store. A correctly labelled folder is not proof of its contents.
Before use, recompute the existing identity and require equality with the transition. Then validate the complete course and its relationships under the format and execution contracts, including directory name, course name and version. Hashing and metadata interpretation MUST read the same stable bytes under the protected runner’s write boundary. A concurrent change aborts the operation. Read-only permission bits alone are insufficient; tutor control of the log, source verification or verifier cannot establish qualified provenance.
Metadata links MUST resolve wholly within the verified, identity-included tree. Escaping, excluded, cyclic or missing targets make the required source unusable. Identity itself still hashes literal link text without following it. Interpret verified text with the identity text rule once; copying retained folders never rewrites source bytes.
| Transient view | Exact source, type and cardinality | Malformed outcome |
|---|---|---|
| Course name, version, lesson order, project choices | One valid COURSE.md; name/version strings; at least one unique ordered lesson id; omitted project means [own] |
Stop required-source replay |
| Stage, checks, calls, objectives | One valid listed lesson file per id; stage enumeration; check references with default-true boolean required; unique call ids; ordered objective {id, by} pairs; omitted calls/objectives mean empty |
Stop required-source replay |
| Check kind and command | Unique declarations in checks/checks.yaml; default command or observe; command-only required run string; every referenced id resolves |
Stop required-source replay |
| Explain-back threshold | Exactly the final lesson’s rubric; integers K, N from K of N, 1 ≤ K ≤ N; N equals top-level criteria count |
Stop required-source replay |
These views are transient, never persisted independent authorities. Unknown author fields retain the ignore-and-warn policy and cannot change derived fields. There is no replay JSON cache, replay manifest, ninth schema or migration for this unpublished candidate. A locally restored exact folder may be reused only after identity and structural verification; never fetch or substitute latest content automatically.
Applying events
Section titled “Applying events ”Parse each line’s structure first. Invalid lines are skipped and reported under damage. For structurally valid lines, evaluate these predicates in order; a refused predicate changes no projection field. A report distinguishes invalid, not applied, and a stopped replay.
- An unknown kind does nothing, and ignored fields never trigger lookup. Remember occurrences on structurally valid verified decision lines for the existing one-use rule, even when a later predicate refuses that line. This requires no folder or receipt verification.
- A first
course-startedrequires its retained folder, verified name and version. Apply it with generation one. A later start is not applied and does not look up its snapshot. Before an applied start, every other known kind is not applied. - For
course-upgraded, comparefromwith the active identity and requireto != from. Failure is not applied without target lookup. A line passing these predicates is a committed transition: require its verified target, unchanged course name, matching version and compatible selected project option. Failure to load or validate that commitment stops replay, never converts it into a refused live request. - For every lesson-progress event, compare both
snapshotandgenerationwith current state before lookup or receipt verification. Either mismatch is not applied. Generation derives only from applied transitions: zero before start, one after start, then one more for each applied upgrade. Lost or truncated committed history stops authoritative resume; it never resets this counter. - A named lesson, call or check must be declared by the current verified course and named lesson. Every lesson event except
lesson-startedalso needs an already-started entry. No other kind creates that entry. Consent decline requires a declared lesson but does not create or require progress in it. - A
call-decidedneeds an open call. A verified occurrence seen on an earlier structurally valid verified line is not applied, even if that earlier line was not applied. For an otherwise applicable learner decision, use the verification contract; a rejected result leaves the call open, and unverified never settles anything. - A passing/failing
check-runneeds the kind’s evidence: checked for command, observed for observe.explain-judgedneeds the final lesson and the verified rubric denominator. A different kind or denominator is not applied. - A project can be chosen only once and must match a verified offered option. Completion and skip obey their specific rules below. Other applicable kinds update only the fields assigned to them.
An applied lesson-started sets status started unless already passed, removes an earlier skip time, and sets position to that lesson at goal. Checks replace last and increment runs only for pass/fail. Explain-back replaces the latest attempt, including a miss. Hints, invalid lines and unknown kinds never create attempts. An applied upgrade archives and resets exactly as upgrades states. A missing, unreadable, changed or malformed required snapshot stops with its identity and reason: no partial projection, lock update, teaching or settlement follows.
Protected admission and restart
Section titled “Protected admission and restart ”Before dispatching a check, requesting an explain-back judgement or presenting a call, the protected runner captures current snapshot and generation. It binds them to that operation, its lesson and declared check/call or explain attempt. Callers MUST NOT choose or replace this binding. When the result arrives, compare its original binding with current state under the writer lock used by upgrades, before appending progress. Require the current snapshot/generation and the existing lesson-start, declared-id and attempt rules. Refuse stale results without appending new progress; never relabel them with current values.
The durable upgrade commit invalidates all earlier admissions, even if their processes continue or the same snapshot returns later. After restart, cancel unfinished check/explain operations and require fresh dispatch. Reconstruct no admission from an arriving result. Already-committed results replay from their protected history with their original times. No public operation id or admission journal is added.
The protected confirmation controller MUST durably bind each issued occurrence to its admission generation and the approved snapshot, lesson, call and occurrence bindings before issuing confirmation. Preserve that binding and original attestation for the event history’s lifetime, independently of public event fields. An event append cannot qualify an occurrence whose issuance binding is unavailable. An unused occurrence issued in an earlier generation is rejected, including after a return to identical content or a forged current-generation label.
After restart an outstanding confirmation is eligible only if its protected issuance binding is recoverable and current; otherwise request fresh confirmation. Missing provenance is unverified; a known mismatch is rejected. Neither settles an objective. Admission and issuance evidence contain no answer, answer hash or conversation. These generation duties are proposed record policy, not an approval of a receipt format, signing scheme or host.
Internal replay handoff
Section titled “Internal replay handoff ”The schema theme’s internal test interface is:
replay(events: Sequence[Mapping], snapshot_roots: Mapping[str, Path], *, receipt_status: Callable[[int, Mapping, int], str]) -> MappingIt returns the whole projection or raises ContractError on a stopped replay. Snapshot roots map identities to retained authored folders; all identity, stable-read and structure checks still apply. For an otherwise applicable learner decision, call receipt_status(event_index, event, expected_generation). The index is zero-based in the supplied sequence; expected generation is replay’s derived counter. The callback checks the protected issuance generation and original receipt bindings, never treating a public generation label as proof.
The result is exactly verified, unverified or rejected; any other result raises ContractError. Missing authority supplies unverified. Known mismatches are rejected. A line already labelled unverified cannot be promoted, even by a verified callback result. Replay checks the original durable attestation without redeeming the occurrence again; identical inputs must reproduce identical outcomes. Loss of previously required verifier authority stops authoritative resume. An untrusted imported view remains unverified and cannot replace authoritative history.
A controlled callback represents an explicitly assumed synthetic authority. It proves neither human provenance nor host qualification. Schemas check event generation shape and record history shape; replay checks equality, derivation and verified sources. The existing course, lesson, checks and rubric schemas cover retained authored files. Their structural validity alone does not establish cross-file validity or evidence provenance.
Completing a lesson
Section titled “Completing a lesson ”A lesson-completed line applies only when the lesson is not already passed and it really passed. A lesson passes when every required check it lists has a latest result of pass. A lesson with no required checks, such as an understand lesson, passes this check predicate at once. Optional checks do not gate passing. The final lesson also needs its latest applied explain-judged to reach the rubric’s pass mark: an earlier attempt that met the bar does not count after a later one that did not, and a later one that meets it replaces an earlier miss. A required check that was not-run or error, or a latest attempt below the bar, means the lesson did not pass, and the completion line is not applied. A runner without a capability a lesson needs cannot record that lesson as passed. It can still teach it, and the learner moving on is recorded as skipped.
The first applied completion sets the status to passed and at to that line’s time. Every later lesson-completed for the same lesson is not applied, so a repeated completion neither counts twice nor moves the first time.
Settling objectives
Section titled “Settling objectives ”Settlement is derived at the moment a completion applies, and it is never an event, so no runner can assert it. Each objective the lesson declares settles when:
- its own evidence passed: the named check’s latest result is pass with its declared evidence kind, even when optional; or the latest explain-back meets the rubric; and
- every call the lesson declares has state
decidedwithby: learnerandprovenance: verified; and - the lesson was not skipped, which is the case because it is being completed.
A settled objective is added to objectives with at the completion’s time and snapshot the current snapshot. One completion can settle several objectives at once. They are added in the order the lesson file declares them, not by name or by the order of their evidence, and a later completion’s objectives follow every earlier one. A lesson that declares no calls has nothing to satisfy in the second condition, and zero declared calls is not a missing call. A declared call that is open, absent, decided by the tutor or unverified leaves every objective of that lesson unsettled, while the lesson still passes. A receipt alone never settles anything, because all three conditions are needed, as the execution contract says. A settled objective is never discarded: an upgrade moves it unchanged into history. Idempotence and declaration ordering apply separately within each active generation. A later failing check does not unsettle it, because it records what held at completion.
Skipping
Section titled “Skipping ”A lesson-skipped line applies only when the lesson is not passed and not already skipped. It sets the status to skipped and at to its time. A skipped lesson settles nothing, and it is never shown as passed. A learner who returns to it starts it again, and it then follows the ordinary rules, including passing later. Moving on from a lesson that has passed is not a skip, so a lesson-skipped line for a passed lesson is not applied.
Writing and recovery
Section titled “Writing and recovery ”Appending
Section titled “Appending ”The protected runner serialises writers per course and for the shared course lock. It checks operation admissions under that same lock. A runner appends each event as one write of the JSON text and its LF to events.jsonl, then waits until the operating system confirms the bytes are stored before it acts on the event. If the file does not end with LF, the runner first writes LF so a valid unterminated final line is completed and never merged with the next one. The runner appends before it replaces the record, so a crash between the two leaves history ahead of the record, which replay repairs.
A partial append and an invalid line
Section titled “A partial append and an invalid line ”Two different faults can appear in a log. They are told apart so a crash is not mistaken for damage.
| Comparison feature | Partial append | Invalid historical line |
|---|---|---|
| What it is | The last line has no LF and is not a valid event: a write was interrupted | A line that has an LF, or is not the last line, and is not a valid event |
| Cause | A crash or a full disk during an append | Corruption, an edit or a runner bug |
| How a reader treats it | It is not an event. The reader reports an interrupted write | It is skipped and reported as damage, and the rest of the log still applies |
| What the runner does with the bytes | Moves them unchanged to the end of events.partial in the same folder, then cuts the log back to its last LF |
Nothing. The line stays where it is and is never edited or removed |
| Effect on the record | None | The line contributes nothing, so a lost decision stays open and a lost completion stays unrecorded; nothing is guessed |
A final line without an LF that is a valid event counts as an event, and the runner completes it with an LF before the next append. A reader never repairs history by guessing a missing event. Silently discarding history is forbidden: partial bytes are set aside, never dropped, and damage is always reported. A runner reports an interrupted write once and damage at every resume.
Replacing the record
Section titled “Replacing the record ”The runner writes the whole new content to a temporary file in the same folder, waits until it is stored, renames it over record.json in one step, then confirms the folder entry is stored. It never edits record.json in place, so a reader sees either the whole old file or the whole new one. A leftover temporary file is ignored and may be deleted. The lock is replaced the same way.
Resuming
Section titled “Resuming ”When a learner returns, the runner first verifies complete protected history and replays committed transitions from retained sources. If a committed transition left install, lock or record behind, transition recovery completes it. The runner then verifies the installed course against the repaired lock by pinning duty two, and compares replay with record.json. If the file is missing, damaged or different, the replay result replaces it, and the learner is told it was rebuilt. The events win over the record.
- A
record.jsonwith noevents.jsonlis not evidence of an empty history. Preserve it asrecord.orphaned.jsonand stop authoritative resume if committed history is missing. Never reset generation from that loss. A genuinely new course with no prior commitment has empty history; an untrusted imported view cannot authorise new progress. - With genuinely empty history the result is the initial record, which
record.jsonneed not exist for:course,version,snapshot,projectandpositionarenull,generationis zero,lessonsis empty, and every list, includinghistory, is empty. - The runner resumes at the position’s lesson and step. If that lesson is
passedorskipped, it resumes at the first lesson after it, in lesson order, that is neither; with none left, the course has ended. A call that isopenis put again, and the runner never infers an answer, as the execution contract says. A help request after resuming is ahint-givenline and never an attempt.
Upgrades and changed content
Section titled “Upgrades and changed content ”An unrequested content change is refused without an event. An explicit upgrade is a committed transition only after the target, prior history and project compatibility have been verified. The learner’s editable workspace is never replaced. Same-current and refused requests change no install, lock, record or admission.
Durable transition and recovery
Section titled “Durable transition and recovery ”- Under the protected writer boundary, verify old history and the complete target. Require unchanged course name and that the selected project option remains offered. Refuse a change of project binding; a retained local source may change while the editable copy remains untouched.
- Prepare a complete sibling temporary snapshot, preserving included bytes, links and empty directories. Validate it, durably store every file and directory entry, then atomically publish its retained directory. Verify an existing final folder before reuse; never overwrite a conflicting folder or treat a temporary folder as published.
- Obtain the learner’s explicit instruction and durably append the complete start/upgrade event before changing active install, lock or projection. This append is the commit point. It advances the derived generation and invalidates old admissions; result appends cannot bypass that admission check.
- Materialise the installed copy from the verified retained target. Atomically replace this course’s lock entry using transition source/commit and verified metadata, preserving every other entry. Then atomically replace the complete record. Confirm directory writes and teach only when install, lock and last committed transition agree.
A crash before commitment leaves old state authoritative; an orphan published target is unused. A crash after commitment completes the committed target on recovery, without another event, generation increment or history entry. It never overwrites the learner’s workspace or changes historical timestamps. Replay derives the counter exactly once, regardless of which replacements finished. Restart also applies the admission and confirmation rules above.
If required bytes, authority or a trustworthy lock cannot be recovered, stop visibly and preserve damaged material. Never publish a partial record, infer a missing transition from a newer lock or silently roll back. An invalid historical line is still reported; evidence of lost committed history additionally blocks authoritative resume. Complete-tail and partial-append handling remains as damage specifies. Atomic replacement states the required durability contract; synthetic cut-point tests do not prove any real filesystem meets it.
Progress after upgrade
Section titled “Progress after upgrade ”Each applied upgrade appends exactly the previous {version, snapshot, generation, position, lessons, calls, objectives} to history. It then changes version/snapshot, increments generation, clears lessons/calls/objectives and sets position to null. Keep project, consent and declined-hook records unchanged. Preserve every event and archived outcome, including skipped and unfinished work. Historical achievements keep their original evidence, identity, order and first completion time.
Only the fresh active segment can complete lessons or settle objectives under the new content. Every new check, explain-back and call needs new evidence. Previously passed lessons receive no automatic credit; matching ids imply no equivalence. The first new lesson waits for its normal learner gate. Null position neither starts it nor creates an attempt. Used occurrences remain used; unused ones keep their original issuance generation.
Returning from old to new to old creates generations one, two and three with two ordered history entries. It never restores generation-one admissions. Asking for the already-current identity is a no-op: no transition, history entry or reset, and otherwise valid current admissions survive. An unavailable, invalid or incompatible target is likewise refused before commitment, without changing active files or workspace. A broken target named by a committed transition instead stops recovery.
Changed run strings MUST be disclosed again under the execution contract. Old check results never substitute for a new run. Required checks gate passing; each objective still needs its own passing evidence, including an optional check it names. Zero declared calls, latest explain-back, declaration-order settlement and completion idempotence retain their meanings within each active segment.
Privacy
Section titled “Privacy ”A runner MUST NOT put any of these in any field of the lock, the record, operation/issuance evidence or an event, whether the field is defined here or not: an answer to a call, text of a conversation, project code or file contents, a hash or other digest of any of those, a credential, a learner identifier, or a machine identifier. The only digests allowed are course identities, which hash course files, and a receipt’s occurrence is random, not a digest. A hash of a short private answer is easy to guess, so it is no substitute for leaving the answer out.
The records still hold timing and the learner’s choices of lesson and project, and receipt identifiers can correlate activity. They stay local, are excluded from Git history, and are never sent anywhere by this contract. A host may keep prompt histories of its own, which no runner can remove. Feedback hooks send their own closed payload, as the consent contract defines, and never a record.
Schemas, names and status
Section titled “Schemas, names and status ”Schema addresses
Section titled “Schema addresses ”The candidate defines eight machine-readable types for later publication: course, lesson, checks, feedback, lock, record, event and rubric. The lock, the record and one event line are described by the lock, record and event schemas. One event schema covers every kind, and kind selects the rules. It covers transition source/commit and lesson snapshot/generation; record covers derived generation and history. No generation field belongs to a transition or lock. Retained folders use existing authored-file schemas, not an additional replay schema.
Proposed: each type has the address https://agentcourses.io/schemas/<candidate>/<type>.schema.json, where <candidate> is the candidate label. The first candidate label is 1.0.0-rc.1. The owner confirms the label before any address is published. An address, once published, never changes its bytes, and changed bytes need a new candidate label and a new address. No address is live yet, and no text may say one is until it has been published and checked.
Names
Section titled “Names ”The contract names only what a reader needs to follow its rules.
| Thing | Name | Status |
|---|---|---|
| The specification | Agent Courses | Named here |
| Its version | 1.0, in spec fields; the candidate label is 1.0.0-rc.1 |
Named here |
| The home and schema host | agentcourses.io |
Decided by the owner |
| The repository | agentcourses/agentcourses |
Existing |
| Files and folders | COURSE.md, .agents/courses/, .agents/courses.lock, .agents/records/<course>/, .agents/course-snapshots/ |
Named here and in the format contract |
| A command, library or package | None | Not part of this contract; chosen at release, after a check that the names are free ⚠️ unverified |
Candidate and stable
Section titled “Candidate and stable ”Proposed: this specification is a candidate when the owner accepts the published snapshot and its manifest after independent review. The candidate bar is that no contract question is unassigned, the rules have cases, the internal checks pass, and the text makes no claim about hosts, packages or stability. Accepting it qualifies no host and no runner.
A stable 1.0.0 is a later step. It belongs to a launch qualification that follows this candidate: a host whose confirmation boundary is qualified, a beginner pilot with real learners and published packages. This candidate sets no pass mark for that step. Until it is met, no runner, course or document may claim stability or conformance.
Worked traces
Section titled “Worked traces ”Each trace gives a log and the record a reader derives from it. They use one trace course: the extended baseline from the format contract, where make.md also declares the objective agent-runs-ok by the check agent-runs. The rubric is pass: 2 of 3.
| Lesson | Stage | Checks | Calls | Objectives |
|---|---|---|---|---|
intro |
understand | none | choose-tool |
none |
make |
build | agent-runs (observe) |
none | agent-runs-ok by agent-runs |
finish |
demonstrate | final-check (command) |
choose-check |
agent-passes by final-check, agent-explained by explain-back |
In a trace table each line is a JSON object with at and kind first, written 2026-10-21T<time>Z. Where a field’s value is long, a name stands for it: S1 and S2 are the verified identities of the complete trace trees defined below, O1 is q7Zk2mR9xT4vB1nC8dLw5A, and O2 is Hs3Fp0yU6eJg2KtN7oVb1Q. A receipt is {occurrence, verifier: example-verifier}. Every lesson-progress line explicitly expands to snapshot=S1 generation=1 unless stated otherwise. Every transition expands to source=local and has no commit or generation; these defaults are part of the complete input, not permission to omit required fields. Every other field is written name=value. Verified examples assume a protected synthetic issuance binding for the stated generation and original receipt; they qualify no host.
Complete trace inputs
Section titled “Complete trace inputs ”The complete authored tree below fixes every input byte for the worked traces. Each fenced file ends in one LF. It is the accepted extended format baseline with a declared make objective and concrete check fixtures; it is not the identity-vector toy tree. The old identity is sha256:c35480cb14f89d9e89719faa2452a5b494d58ba2f80cb3fe26abd2d0346f4ca4, and the new identity is sha256:727fef18c5013d7ce206e0f8253218857e433f673af14129278e6bb097b927a6.
S1 names this tree. S2 is a copy with exactly two replacements: COURSE.md version "1.0.0" becomes "1.1.0", and the check command in checks/checks.yaml changes from python "$AGENT_COURSE_DIR/checks/final_check.py" to python "$AGENT_COURSE_DIR/checks/final_check.py" --strict. The check implementation still accepts that argument; the changed command must nevertheless be disclosed. All other bytes remain identical. Variant trees state their precise edit and must use their own recomputed identities, never S1 or S2’s folder labels.
Exact authored files for sample-course
COURSE.md:
---name: sample-coursedescription: Build a one-file agent and explain it. For beginners.version: "1.0.0"license: Apache-2.0---
# Sample course
You'll build a small agent.
## Outcome
`agent.py`: an agent that passes `final-check`.
## Lessons
1. [Meet the agent](lessons/intro.md)2. [Make the agent](lessons/make.md)3. [Finish and explain](lessons/finish.md)checks/checks.yaml:
- id: final-check run: python "$AGENT_COURSE_DIR/checks/final_check.py" verify: agent.py defines an agent timeout: 30 network: false- id: agent-runs kind: observe verify: agent.py exists and defines an agentchecks/final_check.py:
from pathlib import Pathraise SystemExit(0 if "agent" in Path("agent.py").read_text() else 1)checks/fixtures/final-check/fail/agent.py:
value = Nonechecks/fixtures/final-check/pass/agent.py:
agent = object()lessons/finish.md:
---title: Finish and explainstage: demonstratechecks: [final-check]calls: - id: choose-check ask: Which behaviour will the final check protect?objectives: - id: agent-passes text: The agent passes the final check by: final-check - id: agent-explained text: The learner explains the agent by: explain-back---
## Goal
You can show the agent works and say why.
## Explain back
Explain which tool your agent calls and why you chose it.lessons/intro.md:
---title: Meet the agentstage: understandcalls: - id: choose-tool ask: Which tool will your agent call first?---
## Goal
You can say what an agent tool is.lessons/make.md:
---title: Make the agentstage: buildchecks: [agent-runs]objectives: - id: agent-runs-ok text: The agent runs by: agent-runs---
## Goal
Your agent runs.
## Build
Ask the learner how to start the agent, then create `agent.py` with them.tutor/finish.rubric.md:
---pass: 2 of 3---- **Names the output type** and says why a typed result is safer than prose- **Describes the tool call**: what triggers it and what it returns- **Names one failure mode** and how the checks would catch itThe shared opening
Section titled “The shared opening ”| Line | Time | Event |
|---|---|---|
| 1 | 09:00:00 | course-started course=sample-course version=1.0.0 snapshot=S1 |
| 2 | 09:00:05 | project-chosen project={kind: own, path: .} |
| 3 | 09:01:00 | lesson-started lesson=intro |
| 4 | 09:01:05 | step-entered lesson=intro step=goal |
| 5 | 09:02:00 | call-put lesson=intro call=choose-tool |
| 6 | 09:03:00 | call-decided lesson=intro call=choose-tool by=learner provenance=verified receipt={O1} snapshot=S1 |
| 7 | 09:04:00 | lesson-completed lesson=intro |
| 8 | 09:05:00 | lesson-started lesson=make |
| 9 | 09:06:00 | check-run lesson=make check=agent-runs result=fail evidence=observed |
| 10 | 09:08:00 | check-run lesson=make check=agent-runs result=pass evidence=observed |
| 11 | 09:09:00 | lesson-completed lesson=make |
The record after line 11, where <S1> stands for S1:
{ "spec": "1.0", "course": "sample-course", "version": "1.0.0", "snapshot": "<S1>", "generation": 1, "history": [], "project": { "kind": "own", "path": "." }, "position": { "lesson": "make", "step": "goal" }, "lessons": { "intro": { "status": "passed", "at": "2026-10-21T09:04:00Z" }, "make": { "status": "passed", "checks": { "agent-runs": { "last": "pass", "runs": 2 } }, "at": "2026-10-21T09:09:00Z" } }, "calls": [ { "id": "choose-tool", "lesson": "intro", "state": "decided", "by": "learner", "provenance": "verified" } ], "objectives": [ { "id": "agent-runs-ok", "lesson": "make", "evidence": "observed", "by": "agent-runs", "at": "2026-10-21T09:09:00Z", "snapshot": "<S1>" } ], "consent": [], "declined": []}Line 1 sets the course and snapshot. Line 2 sets the project. Lines 3 to 6 start intro and record its call as decided by the learner and verified, and line 7 passes it because it lists no checks. Lines 8 to 11 run the check twice, so runs is 2 and last is pass, and pass make. make declares no calls, so its objective settles at line 11. The position is the latest lesson-started, which is make at step goal. The time of the passed lessons is the completion line’s time.
The finishing lesson
Section titled “The finishing lesson ”These lines follow the opening. Each variant changes line 14 only, except Never put, which also removes line 13.
| Line | Time | Event |
|---|---|---|
| 12 | 09:10:00 | lesson-started lesson=finish |
| 13 | 09:11:00 | call-put lesson=finish call=choose-check |
| 14 | 09:12:00 | the call line, which each variant below sets |
| 15 | 09:15:00 | check-run lesson=finish check=final-check result=pass evidence=checked |
| 16 | 09:16:00 | step-entered lesson=finish step=explain-back |
| 17 | 09:18:00 | explain-judged lesson=finish met=2 of=3 |
| 18 | 09:19:00 | lesson-completed lesson=finish |
The base result for the finishing lesson is the opening record with these changes: position is finish at explain-back; lessons.finish is {status: passed, checks: {final-check: {last: pass, runs: 1}}, explain: {met: 2, of: 3, passed: true}, at: 2026-10-21T09:19:00Z}; and a call entry for choose-check is added. The objectives change in each variant.
In Learner, verified, agent-passes is added before agent-explained, the order finish.md declares them. Name order would put agent-explained first, and neither the order of the evidence nor name order is used.
In every variant the lesson finish passes, because its check passed and the explain-back met 2 of 3. Calls decide only settlement. In the last three variants the line is reported as not applied, and nothing in the record shows it. If a call is missing, agent-passes and agent-explained stay unsettled, and agent-runs-ok stays settled because make declares no calls.
Other traces
Section titled “Other traces ”Each trace below starts again from the stated line. Lines are numbered continuing from there.
Record cases
Section titled “Record cases ”These cases use the trace course. Where a case names a trace, its result is the one that trace states.
| Case | Baseline and change | Judged as | Outcome | Deciding rule |
|---|---|---|---|---|
| Verified decision on a qualified host | A host whose boundary is qualified; the runner writes by=learner provenance=verified receipt={O2} and the receipt holds only occurrence and verifier |
Record structure | Accepted | A verified decision carries a receipt of exactly two values |
| Unqualified host | The same call on a host with no qualified boundary; the runner writes provenance=unverified and no receipt |
Record structure | Accepted | Without a shown boundary every learner decision is unverified |
| Unqualified host claims verified | The same host; the runner writes provenance=verified |
Runner behaviour | Rejected | A runner MUST write unverified unless the boundary is qualified |
| Receipt holds the answer | The receipt has a third value holding the learner’s typed answer | Runner behaviour | Rejected | No answer text may enter a receipt or any field |
| Occurrence is an answer hash | The occurrence is the SHA-256 of the typed answer |
Runner behaviour | Rejected | An occurrence is random and never derived from anything the learner typed |
| Unverified with a receipt | by=learner provenance=unverified with a receipt |
Record structure | Rejected | Only a verified decision carries a receipt |
| Verified without a receipt | by=learner provenance=verified and no receipt |
Record structure | Rejected | A verified decision MUST carry a receipt |
| Tutor decision with provenance | by=tutor provenance=verified |
Record structure | Rejected | A tutor decision carries no provenance and no receipt |
| Short occurrence | A receipt whose occurrence has 8 characters |
Record structure | Rejected | An occurrence has 22 to 128 characters |
| Receipt used twice | See Repeated receipt | Record replay | Rejected | A seen occurrence makes the later line not applied |
| Receipt bound elsewhere | See Wrong snapshot and Wrong lesson | Record replay | Rejected | A decision for another snapshot or lesson is not applied |
| Records excluded, lock shared | A Git workspace; the runner lists .agents/records/ in .git/info/exclude and leaves the lock committed |
Runner behaviour | Accepted | Records are personal and the lock is shared |
| Records ignored for everyone | The runner adds .agents/records/ to the shared .gitignore |
Runner behaviour | Rejected | Exclusion uses .git/info/exclude, never the shared file |
| Lock excluded | The runner lists .agents/courses.lock in .git/info/exclude |
Runner behaviour | Rejected | The lock MUST NOT be excluded |
| A valid line | {"at":"2026-10-21T09:00:00Z","kind":"lesson-started","lesson":"intro","snapshot":"sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa","generation":1} followed by LF |
Record structure | Accepted | One object, a time, a kind and the kind’s fields |
| Time with a fraction | The same line with "at":"2026-10-21T09:00:00.5Z" |
Record structure | Rejected | A time has no fractions |
| Repeated key | The same line with "lesson" written twice |
Record structure | Rejected | A line has no repeated key |
| Line over 4096 bytes | The same line padded with an unknown field to 5000 bytes | Record structure | Rejected | A line is at most 4096 bytes |
| Unknown kind | See Unknown kind and unknown field | Record replay | Accepted | A reader keeps an unknown kind and it changes nothing |
| Unknown kind creates progress | A line of kind lesson-finished makes make passed |
Record replay | Rejected | An unknown kind never creates progress |
| Unknown field copied | The reader copies extra into the record |
Record replay | Rejected | Unknown fields are ignored and not copied |
| Followed field ignored | A call-decided line with followed=recommendation |
Record replay | Accepted | The field is not defined, so it is ignored and the call is judged without it |
| Followed field affects settlement | A runner treats followed=recommendation as making the call verified |
Runner behaviour | Rejected | An unknown field never affects ownership or settlement |
| Own project | project={kind: own, path: .} |
Record structure | Accepted | An own project records its base path |
| Git project | project={kind: git, path: services/api, source: gh:example-co/payments@v3.4.0#services/api, commit: 9f3c2e1a4b5d6c7e8f90a1b2c3d4e5f607182930} |
Record structure | Accepted | A Git project records its source and resolved commit |
| Local project | project={kind: local, path: work/local-app, source: ./examples/app} |
Record structure | Accepted | The copy is recorded and no commit is invented |
| Git project without a commit | The Git project without commit |
Record structure | Rejected | A Git project needs its resolved commit |
| Own project with a commit | project={kind: own, path: ., commit: 9f3c2e1a4b5d6c7e8f90a1b2c3d4e5f607182930} |
Record structure | Rejected | Only a Git project records a commit |
| Local project at the base | project={kind: local, path: ., source: ./examples/app} |
Record structure | Rejected | A local destination is a child path, not the base |
| Credential in the source | project={kind: git, ..., source: git+https://user:token@git.example.com/team/repo.git} |
Record structure | Rejected | A source never holds a credential |
| Lock entry for a Git source | An entry with name, version, source, commit and identity |
Record structure | Accepted | A Git source records its commit and identity |
| Lock entry for a local source | An entry with source: local, an identity and no commit |
Record structure | Accepted | A local install stores the word local, never a path, and has no commit |
| Absolute home path as the source | An entry with source: /Users/ada/courses/sample-course |
Record structure | Rejected | A local path is never stored, because it would put a user name into a committed file |
| Relative or home-relative path as the source | An entry with source: ./courses/sample-course, another with ../courses/sample-course and another with ~/courses/sample-course |
Record structure | Rejected | The only local source is local; any path is refused |
| Local reinstall | A learner reinstalls a local folder: the lock still says local, the learner names the folder again, and its identity is compared with the entry |
Runner behaviour | Accepted | The lock needs no path, and identity still decides whether the folder is the pinned course |
| Two entries for one course | Two entries named sample-course |
Record structure | Rejected | Names are unique in the lock |
| Short identity | An entry whose identity is sha256:1234 |
Record structure | Rejected | An identity is sha256: and 64 hexadecimal digits |
| No stored counts | The opening record, which holds statuses only | Record structure | Accepted | Counts and percentages are derived, never stored |
| Counts stored | The record also holds "lessons_passed": 2 and "percent": 66 |
Record structure | Rejected | Counts and percentages are never stored |
| Learner identifier stored | The record holds an opaque learner value |
Record structure | Rejected | The record holds no learner identifier |
| Complete after the final lesson | The base finishing result, with finish passed |
Record replay | Accepted | A course is complete when its final lesson has passed |
| Complete with the final lesson skipped | The opening with finish skipped |
Record replay | Rejected | A skipped final lesson does not complete the course |
| Settled with everything verified | See Learner, verified | Record replay | Accepted | Passed evidence, verified learner calls and no skip settle every objective |
| Tutor call settles | See Tutor decided | Record replay | Rejected | A tutor decision leaves the lesson’s objectives unsettled |
| Unverified call settles | See Learner, unverified | Record replay | Rejected | An unverified decision leaves them unsettled |
| Open call settles | See Put, never answered | Record replay | Rejected | A call put but not answered leaves them unsettled |
| Missing call settles | See Never put | Record replay | Rejected | A call never put leaves them unsettled |
| Zero declared calls | The opening record, where make declares no calls |
Record replay | Accepted | A lesson that declares no calls has nothing to satisfy, so its objective settles |
| Settlement written as an event | A line of kind objective-settled for agent-passes |
Record replay | Rejected | Settlement is derived; no event creates it |
| A later failure | After the learner-verified trace, a later check-run of final-check fails |
Record replay | Accepted | The settled objectives stay, because they record what held at completion |
| Repeated completion | See Repeated completion | Record replay | Rejected | A later completion is not applied and the first time stays |
| Completion without evidence | See Completion without evidence | Record replay | Rejected | A lesson passes only when its checks passed |
| Check could not run | See Check not run | Record replay | Accepted | The lesson is recorded as skipped, never as passed |
| Skip recorded as skipped | See Skip | Record replay | Accepted | A skip remains a skip and settles nothing |
| Skip recorded as passed | Line 10 of the skip trace is lesson-completed and a reader treats the lesson as passed |
Record replay | Rejected | A completion without passing evidence is not applied |
| Skipped lesson passed later | See Skip, then pass | Record replay | Accepted | A returning learner passes by the ordinary rules |
| Skipping a passed lesson | See Skip a passed lesson | Record replay | Rejected | Moving on from a passed lesson is not a skip |
| Hints are not attempts | See Help is not an attempt | Record replay | Accepted | Help never adds to runs |
| Empty history | See Empty history | Record replay | Accepted | An empty log gives the initial record |
| Invented progress from nothing | A runner answers an empty log with a first lesson started and a settled objective |
Record replay | Rejected | An empty history has no attempt, completion or objective |
| Interrupted write | See Interrupted write | Record replay | Accepted with a warning | The fragment is set aside, never dropped, and the interrupted write is reported |
| Fragment discarded | The runner cuts the log back to line 11 and deletes the fragment | Runner behaviour | Rejected | Silently discarding history is forbidden |
| Valid tail without LF | See Valid tail without LF | Record replay | Accepted | A complete event counts and the runner completes its line |
| Invalid historical line | See Invalid historical line | Record replay | Rejected | The line is skipped, kept in the file and reported |
| Lost decision guessed | A reader rebuilds the lost decision as by=learner provenance=verified from the project files |
Record replay | Rejected | A reader never repairs history by guessing |
| Damaged line deleted | The runner removes the invalid line from the file | Runner behaviour | Rejected | A line in the log is never edited or removed |
| Damaged record rebuilt | See Damaged record | Runner behaviour | Accepted | The events win over the record |
| Damaged record trusted | The runner resumes from the record.json that says finish passed |
Runner behaviour | Rejected | A record that disagrees with the log is replaced |
| Record without a log | A record at generation 1 but no events; preserve it as record.orphaned.json and stop authoritative resume |
Runner behaviour | Accepted | Missing committed history cannot authorise generation zero or new progress |
| Atomic replacement | The runner writes a temporary file, stores it and renames it over record.json |
Runner behaviour | Accepted | A reader sees the whole old file or the whole new one |
| Edited in place | The runner rewrites record.json in place and crashes half way |
Runner behaviour | Rejected | The runner replaces the whole file, never edits it in place |
| Open call put again | The opening, then a log that ends with call-put for choose-check and no answer; the runner puts it again and writes a new call-put |
Runner behaviour | Accepted | An open call is put again and never inferred |
| Resume after a passed lesson | The opening record, whose position lesson make has passed |
Runner behaviour | Accepted | The runner resumes at finish, the first lesson that is neither passed nor skipped |
| Authored folders kept per identity | See Resume after an upgrade | Record replay | Accepted | Each applied transition loads its verified retained folder, so an upgrade does not change the past |
| Old lines replayed with the installed course | The same trace, but replay reads the installed S2 course for lines 1 to 11 |
Record replay | Rejected | Replay never reads the installed course, so old make evidence is verified against S1 before being archived |
| Latest snapshot substituted | A runner finds no retained folder for S1 and replays lines 1 to 11 with the S2 data |
Record replay | Rejected | Replay never substitutes another snapshot |
| Missing data stops replay | See Missing replay data | Runner behaviour | Accepted | The runner stops, tells the learner and writes nothing |
| Partial result written | The same log; the runner writes the record it could derive from lines 12 onward over record.json |
Runner behaviour | Rejected | A partial replay never replaces the record |
| Learner data copied into retained source | The runner copies a learner workspace into the retained authored folder | Record structure | Rejected | Only authored identity-included source is retained |
Lines before lesson-started |
See Lines before lesson-started |
Record replay | Rejected | A line naming a lesson with no applied lesson-started is not applied |
Same lines after lesson-started |
The same check-run and lesson-completed written after line 07 |
Record replay | Accepted | Once the lesson has started, they apply in order |
| Latest attempt misses | See Two attempts, the later one misses | Record replay | Rejected | An earlier passing attempt does not count after a later miss |
| Latest attempt passes | See Two attempts, the later one passes | Record replay | Accepted | A later attempt that meets the bar replaces an earlier miss |
| Settlement in declaration order | See Two attempts, the later one passes | Record replay | Accepted | One completion adds agent-passes then agent-explained, as finish.md declares them |
| Settlement in name order | The same completion adds agent-explained first because it sorts first |
Record replay | Rejected | The order is declaration order, never name order or evidence order |
| Explicit upgrade | See Explicit upgrade | Record replay | Accepted | History is kept and settled objectives keep their snapshot |
| Upgrade re-settles | After the upgrade, the reader moves agent-runs-ok to S2 |
Record replay | Rejected | Nothing is re-settled by an upgrade |
| Changed content refused | See Changed content | Runner behaviour | Accepted | An unrequested change is refused and writes nothing |
| Consent stored | See Consent | Record replay | Accepted | A withdrawal and a skip survive a closed session |
| Silence written as consent | The runner writes granted because the learner did not answer |
Runner behaviour | Rejected | Silence is a no and writes nothing |
| Answer in an unknown field | A call-decided line carries the typed answer in a field called answer |
Runner behaviour | Rejected | No field, known or unknown, holds an answer |
| Hash of an answer | A line carries a SHA-256 of the typed answer | Runner behaviour | Rejected | A hash of free text is never stored |
| Conversation text | An event holds the tutor’s recommendation text | Runner behaviour | Rejected | No field holds conversation text |
| Machine identifier | An event holds the host’s machine name | Runner behaviour | Rejected | No field holds a machine identifier |
| Schema address | The record type in candidate 1.0.0-rc.1 is https://agentcourses.io/schemas/1.0.0-rc.1/record.schema.json |
Candidate wording | Accepted | The address follows the pattern |
| Address reused for new bytes | A published address serves changed bytes | Candidate wording | Rejected | A published address never changes its bytes |
| Address claimed live early | The text says the schema addresses are live before publication | Candidate wording | Rejected | No address is live until it has been published and checked |
| Names | The candidate names the specification, its version, the home and the file paths, and no command or package | Candidate wording | Accepted | Only what the rules need is named |
| Package name claimed | The candidate names a command and says its package name is free | Candidate wording | Rejected | Availability is a release-time check and unverified |
| Candidate wording | The candidate says it is a candidate, that no host is qualified and that stability is later | Candidate wording | Accepted | Candidate status claims no host, package or stability |
| Stable or conformant claimed | The candidate or a runner says it is stable or conforming | Candidate wording | Rejected | No stability or conformance claim is allowed yet |
| Fields called owner-approved | The candidate says the owner approved the event and field names | Candidate wording | Rejected | Plan acceptance, a link check or a review is not the owner’s approval of a choice |
Transition and admission cases
Section titled “Transition and admission cases ”These cases use the complete trace inputs and opening above. Every case is independent unless it explicitly names another trace. Start and upgrade events use source local with no commit unless the case changes it. Runner behaviour cases describe protected operations; replay cases describe durable input, so a refused live request never masquerades as a broken commitment.
| Case | Complete baseline and decisive change | Judged as | Outcome | Expected result and normative clause |
|---|---|---|---|---|
| Current decision applies | Opening plus finishing learner-verified variant; S1 retained and O2 issuance bound to generation 1 | Record replay | Accepted | Finish passes and adds agent-passes then agent-explained at 09:19:00; generation 1 and empty history remain. Rule; requirement |
| Wrong snapshot never loads its folder | Change only line 14 snapshot to an otherwise unknown valid identity; its retained folder is absent | Record replay | Accepted | Line 14 is not applied before lookup; finish passes but only agent-runs-ok remains settled. Rule; requirement |
| Wrong from never loads target | Opening, then upgrade from=S2 to an unknown valid identity, version=1.2.0 source=local | Record replay | Accepted | No target lookup; whole opening record remains generation 1. Rule; requirement |
| Missing committed target stops | Opening, then upgrade from=S1 to=S2 version=1.1.0 source=local; delete retained S2 | Runner behaviour | Accepted | Stop naming S2; no partial record or lock write, teaching or settlement. Rule; requirement |
| Later start needs no target | Opening then a structurally valid course-started for unknown snapshot, matching name/version and source=local | Record replay | Accepted | No lookup; opening projection unchanged. Rule; requirement |
| Unknown kind needs no source | Opening then kind=future-event with snapshot set to unknown identity and an extra unknown field | Record replay | Accepted | No lookup or field copied; opening projection unchanged. Rule; requirement |
| Tampered content | Append a byte to retained COURSE.md without renaming the S1 directory; replay learner-verified finishing history | Record replay | Rejected | Identity verification fails before settlement; stop with S1 and no output. Rule; requirement |
| Tampered call | Remove choose-check from retained finish.md without renaming the S1 directory; replay learner-verified finishing history | Record replay | Rejected | Identity verification fails before settlement; stop with S1 and no output. Rule; requirement |
| Tampered rubric | Change retained rubric from 2 of 3 to 1 of 3 without renaming the S1 directory; replay learner-verified finishing history | Record replay | Rejected | Identity verification fails before settlement; stop with S1 and no output. Rule; requirement |
| Exact restoration | Delete S1, observe stopped replay, then restore all exact S1 bytes and replay the same complete finishing history | Runner behaviour | Accepted | After identity and structure verification, every original field and timestamp reproduces. Rule; requirement |
| Correct hash, invalid course | Replace COURSE.md with plain text missing frontmatter; compute its new identity and name a first transition with that identity | Record replay | Rejected | Digest matches but course validation stops before progress; never reuse the invalid metadata. Rule; requirement |
| Correct hash, invalid call | Duplicate choose-check in finish.md calls; compute its new identity and name a first transition with that identity | Record replay | Rejected | Digest matches but course validation stops before progress; never reuse the invalid metadata. Rule; requirement |
| Correct hash, invalid rubric | Change rubric pass to 4 of 3; compute its new identity and name a first transition with that identity | Record replay | Rejected | Digest matches but course validation stops before progress; never reuse the invalid metadata. Rule; requirement |
| Verified authored tree | Use S1 at its exact retained key, valid sample-course folder/name/version and complete opening history | Record replay | Accepted | Identity and cross-file validation pass; opening record derives. Rule; requirement |
| Invalid identity traversal | First transition snapshot=sha256:../outside | Record replay | Rejected | Invalid line before path construction; no source lookup or progress. Rule; requirement |
| Invalid identity uppercase | First transition snapshot=sha256: followed by 64 uppercase A digits | Record replay | Rejected | Invalid line before path construction; no source lookup or progress. Rule; requirement |
| Invalid identity short | First transition snapshot=sha256:1234 | Record replay | Rejected | Invalid line before path construction; no source lookup or progress. Rule; requirement |
| Invalid identity prefix | First transition snapshot=SHA256: followed by 64 lowercase a digits | Record replay | Rejected | Invalid line before path construction; no source lookup or progress. Rule; requirement |
| Retained path root-link | Make the retained sample-course component a symlink to the original source and replay the S1 opening | Record replay | Rejected | Required source refuses; no output; labels and links cannot prove identity. Rule; requirement |
| Retained path digest-link | Make its digest directory a symlink outside the store and replay the S1 opening | Record replay | Rejected | Required source refuses; no output; labels and links cannot prove identity. Rule; requirement |
| Retained path wrong-label | Place S2 bytes under the S1 directory name and replay the S1 opening | Record replay | Rejected | Required source refuses; no output; labels and links cannot prove identity. Rule; requirement |
| Unchanged protected read | Hash S1 and derive metadata from the same captured bytes under the protected writer boundary | Record replay | Accepted | Opening record derives; no second unverified metadata source is consulted. Rule; requirement |
| Metadata swap after hash | Swap finish.md after hashing S1 and before metadata interpretation | Record replay | Rejected | Abort without evidence or projection; never accept the swapped metadata. Rule; requirement |
| Metadata link escape | Replace lessons/finish.md with a symlink to ../../outside.md; compute the changed tree identity and use it in the start event | Record replay | Rejected | Identity may compute, but metadata resolution fails; no source use or evidence. Rule; requirement |
| Metadata link excluded | Replace lessons/finish.md with a symlink to ../.agents/private.md; compute the changed tree identity and use it in the start event | Record replay | Rejected | Identity may compute, but metadata resolution fails; no source use or evidence. Rule; requirement |
| Metadata link cycle | Replace lessons/finish.md with a symlink to finish.md; compute the changed tree identity and use it in the start event | Record replay | Rejected | Identity may compute, but metadata resolution fails; no source use or evidence. Rule; requirement |
| Metadata link missing | Replace lessons/finish.md with a symlink to absent.md; compute the changed tree identity and use it in the start event | Record replay | Rejected | Identity may compute, but metadata resolution fails; no source use or evidence. Rule; requirement |
| Included metadata link | Start from the complete S1 tree. Move lessons/finish.md bytes unchanged to references/finish-body.md; replace lessons/finish.md with a symlink whose target is ../references/finish-body.md. The variant identity is sha256:e15ef33637fc7b2d3ae3fdcef8d59092a4d0f8245b5f805cf17302e6544eb4ea. Replay only a course-started event naming this identity, sample-course, version 1.0.0 and source=local | Record replay | Accepted | Identity and course validation pass; the included target supplies the same lesson metadata. The record has spec=1.0, course=sample-course, version=1.0.0, the variant snapshot, generation=1, project=null, position=null, lessons={}, and empty history, calls, objectives, consent and declined arrays. No outside bytes are read. Rule; requirement |
| Live request unavailable | Opening state; explicit upgrade request; S2 cannot be loaded | Runner behaviour | Accepted | No transition appended; install, lock, record, generation, history, workspace and otherwise valid pending admissions stay unchanged. Rule; requirement |
| Live request invalid | Opening state; explicit upgrade request; The fetched target has a malformed rubric | Runner behaviour | Accepted | No transition appended; install, lock, record, generation, history, workspace and otherwise valid pending admissions stay unchanged. Rule; requirement |
| Live request incompatible | Opening state with selected own and an otherwise valid pending admission; explicit upgrade request. Start from the complete S2 tree, insert project: starter immediately after license: Apache-2.0 in COURSE.md, and add starter/README.md containing exactly Start your agent here. followed by LF. The variant identity is sha256:9a3908ca54996c828101757468e6aa77958e499d3127c12d58c85c2078496df8 | Runner behaviour | Accepted | Identity and course validation pass before compatibility is evaluated. The target offers only starter, so selected own is incompatible. No transition is appended; install, lock, record, generation, history, workspace and the pending admission stay unchanged. A later valid result for that admission can still apply. Rule; requirement |
| Live request same | Opening state; explicit upgrade request; Requested identity is already S1 | Runner behaviour | Accepted | No transition appended; install, lock, record, generation, history, workspace and otherwise valid pending admissions stay unchanged. Rule; requirement |
| Before snapshot | Opening; prepare S2 upgrade; Crash before retained-folder rename | Runner behaviour | Accepted | Recover opening generation 1 with no history; temporary/orphan target never moves the pin; workspace unchanged. Rule; requirement |
| After snapshot | Opening; prepare S2 upgrade; Crash after retained-folder rename but before event append | Runner behaviour | Accepted | Recover opening generation 1 with no history; temporary/orphan target never moves the pin; workspace unchanged. Rule; requirement |
| Crash after event | Opening; committed S1→S2 at 10:00:00; After durable transition append | Runner behaviour | Accepted | Recovery gives exactly explicit-upgrade projection: generation 2, one generation-one history entry, original 09:09:00 objective time, empty active progress; no second event; other lock entries and workspace unchanged. Rule; requirement |
| Crash after install | Opening; committed S1→S2 at 10:00:00; After installed-copy replacement | Runner behaviour | Accepted | Recovery gives exactly explicit-upgrade projection: generation 2, one generation-one history entry, original 09:09:00 objective time, empty active progress; no second event; other lock entries and workspace unchanged. Rule; requirement |
| Crash after lock | Opening; committed S1→S2 at 10:00:00; After lock replacement | Runner behaviour | Accepted | Recovery gives exactly explicit-upgrade projection: generation 2, one generation-one history entry, original 09:09:00 objective time, empty active progress; no second event; other lock entries and workspace unchanged. Rule; requirement |
| Crash after record | Opening; committed S1→S2 at 10:00:00; After record replacement | Runner behaviour | Accepted | Recovery gives exactly explicit-upgrade projection: generation 2, one generation-one history entry, original 09:09:00 objective time, empty active progress; no second event; other lock entries and workspace unchanged. Rule; requirement |
| Conflicting retained folder | S2 final directory already exists but contains altered bytes; prepare explicit upgrade | Runner behaviour | Rejected | Refuse before commitment; never overwrite it or change active state. Rule; requirement |
| Unrecoverable other lock entry | Committed S2 transition; another course entry is damaged and cannot be restored trustworthily | Runner behaviour | Accepted | Stop before teaching; preserve damaged lock and publish no partial record. Rule; requirement |
| Lost committed transition | Known committed S2 history is truncated; newer lock still names S2 | Runner behaviour | Accepted | Stop authoritative resume; do not infer transition, reset generation or reinterpret it as a refused request. Rule; requirement |
| Valid local transition | Opening start with source=local and commit=absent | Record replay | Accepted | Start applies at generation 1; recovery reconstructs the matching lock source and Git-only commit. Rule; requirement |
| Valid git transition | Opening start with source=gh:example-co/courses@v1.0.0#sample-course and commit=9f3c2e1a4b5d6c7e8f90a1b2c3d4e5f607182930 | Record replay | Accepted | Start applies at generation 1; recovery reconstructs the matching lock source and Git-only commit. Rule; requirement |
| Invalid transition no-source | Opening start; Remove source | Record replay | Rejected | Invalid transition line; no inferred start or generation; report damage. Rule; requirement |
| Invalid transition no-git-commit | Opening start; Use Git source but omit commit | Record replay | Rejected | Invalid transition line; no inferred start or generation; report damage. Rule; requirement |
| Invalid transition local-commit | Opening start; Set source local and add a commit | Record replay | Rejected | Invalid transition line; no inferred start or generation; report damage. Rule; requirement |
| Invalid transition credential | Opening start; Set source git+https://user:token@git.example.com/team/repo.git | Record replay | Rejected | Invalid transition line; no inferred start or generation; report damage. Rule; requirement |
| Invalid transition absolute | Opening start; Set source /Users/learner/course | Record replay | Rejected | Invalid transition line; no inferred start or generation; report damage. Rule; requirement |
| Invalid transition home | Opening start; Set source ~/course | Record replay | Rejected | Invalid transition line; no inferred start or generation; report damage. Rule; requirement |
| Invalid transition bad-commit | Opening start; Use Git source and commit ABC | Record replay | Rejected | Invalid transition line; no inferred start or generation; report damage. Rule; requirement |
| Invalid transition generation | Opening start; Add generation 1 to the transition | Record replay | Rejected | Invalid transition line; no inferred start or generation; report damage. Rule; requirement |
| Old evidence is historical | Learner-verified finishing history, then committed S1→S2 | Record replay | Accepted | Archive completed finish and its evidence/time; active generation 2 has null position and empty lessons/calls/objectives; course is not complete. Rule; requirement |
| Completion without new start | Previous upgraded state, then lesson-completed finish snapshot=S2 generation=2 | Record replay | Rejected | Not applied because finish has no current lesson-start; history unchanged. Rule; requirement |
| Fresh evidence after upgrade | Previous upgraded state; new finish start, call-put, fresh generation-two confirmed decision, current check pass, current 2-of-3 judgement and completion at 11:00:00 | Record replay | Accepted | Finish passes and two objectives settle under S2 at 11:00:00; archived S1 outcomes remain unchanged. Rule; requirement |
| Revisit an older identity | Opening; S1→S2 at 10:00:00, then S2→S1 version=1.0.0 source=local at 11:00:00 | Record replay | Accepted | Generation 3; history ordered generations 1 then 2; active progress empty, position null; original make achievement is only in history[0]. Rule; requirement |
| Already-current request | Revisit trace, then request S1 again while a generation-three check is admitted | Runner behaviour | Accepted | Append nothing; keep generation 3 and two history entries; otherwise valid pending result may append. Rule; requirement |
| Delayed check from first visit | Admit check for finish in generation 1; apply S1→S2→S1; start finish in generation 3; receive old result | Runner behaviour | Rejected | Reject before appending, even though identity and lesson match; no current evidence. Rule; requirement |
| Relabelled check result | Same delayed result, but caller supplies snapshot=S1 generation=3 | Runner behaviour | Rejected | Protected original admission remains generation 1; reject without append. Rule; requirement |
| Fresh check after revisit | Start finish in generation 3; dispatch check there and receive its matching passing result | Runner behaviour | Accepted | Accept as current evidence if its normal declared-id/attempt checks pass; this result alone does not settle. Rule; requirement |
| Restart with unfinished check | Admit check in generation 3; restart without upgrade; receive that result | Runner behaviour | Rejected | Cancel unfinished admission; refuse arriving result; snapshot equality cannot revive it. Rule; requirement |
| New check after restart | Same state after restart; dispatch and receive a new check result | Runner behaviour | Accepted | Fresh protected current admission can supply evidence. Rule; requirement |
| Delayed explain from first visit | Admit explain for finish in generation 1; apply S1→S2→S1; start finish in generation 3; receive old result | Runner behaviour | Rejected | Reject before appending, even though identity and lesson match; no current evidence. Rule; requirement |
| Relabelled explain result | Same delayed result, but caller supplies snapshot=S1 generation=3 | Runner behaviour | Rejected | Protected original admission remains generation 1; reject without append. Rule; requirement |
| Fresh explain after revisit | Start finish in generation 3; dispatch explain there and receive its matching passing result | Runner behaviour | Accepted | Accept as current evidence if its normal declared-id/attempt checks pass; this result alone does not settle. Rule; requirement |
| Restart with unfinished explain | Admit explain in generation 3; restart without upgrade; receive that result | Runner behaviour | Rejected | Cancel unfinished admission; refuse arriving result; snapshot equality cannot revive it. Rule; requirement |
| New explain after restart | Same state after restart; dispatch and receive a new explain result | Runner behaviour | Accepted | Fresh protected current admission can supply evidence. Rule; requirement |
| Unused old occurrence | Issue unused O2 in generation 1 for S1/finish/choose-check; revisit S1 at generation 3; open that call and submit O2 | Record replay | Rejected | Known issuance mismatch rejects; call stays open, even if public event claims generation 3. Rule; requirement |
| Fresh current occurrence | Same current open call, but issue a new occurrence durably bound to generation 3 | Record replay | Accepted | Verified callback may decide the call; normal check/explain evidence is still required for settlement. Rule; requirement |
| Admission ordering before-upgrade | Opening with admitted current result; Append result before durable upgrade | Runner behaviour | Accepted | Result belongs to generation-one history; upgrade then clears active evidence. Rule; requirement |
| Admission ordering after-upgrade | Opening with admitted current result; Commit upgrade before result arrives | Runner behaviour | Accepted | Original generation-one result refuses before append. Rule; requirement |
| Admission ordering before-result | Opening with admitted current result; Restart just before result append | Runner behaviour | Accepted | No committed result; cancel admission and require fresh dispatch. Rule; requirement |
| Admission ordering after-result | Opening with admitted current result; Restart just after result is durably appended | Runner behaviour | Accepted | Replay original committed result and timestamp once; do not redeem occurrence again. Rule; requirement |
| Confirmation recovery binding | Current-generation open call after restart; Recover original current-generation issuance and attestation | Record replay | Accepted | Same event verifies on repeated replay without another redemption. Rule; requirement |
| Confirmation recovery missing | Current-generation open call after restart; Lose issuance binding before event commitment | Record replay | Accepted | Missing provenance remains unverified; no settlement; request fresh confirmation. Rule; requirement |
| Confirmation recovery old | Current-generation open call after restart; Recover a generation-one binding after revisit at generation 3 | Record replay | Accepted | Known mismatch is rejected; event label cannot repair it. Rule; requirement |
| Confirmation recovery authority | Current-generation open call after restart; Lose authority required to replay previously established history | Runner behaviour | Accepted | Stop authoritative resume; never rewrite established history as newly verified. Rule; requirement |
| Invalid callback result | Otherwise applicable learner decision; callback returns yes instead of one of the three statuses | Record replay | Rejected | Raise ContractError; no partial projection. Rule; requirement |
| Imported verified label | Read learner-verified JSON without original protected authority | Record replay | Accepted | View remains unverified, objectives cannot settle; JSON/digest proves no provenance. Rule; requirement |
| Unverified label cannot promote | Otherwise applicable unverified learner event; callback returns verified | Record replay | Accepted | Decision remains unverified; no objective settlement. Rule; requirement |
| Malformed generation missing | Opening through line 2, then lesson-started intro with generation=absent | Record replay | Rejected | Invalid event; no lesson entry, lookup or generation change. Rule; requirement |
| Malformed generation string | Opening through line 2, then lesson-started intro with generation=“1” | Record replay | Rejected | Invalid event; no lesson entry, lookup or generation change. Rule; requirement |
| Malformed generation boolean | Opening through line 2, then lesson-started intro with generation=true | Record replay | Rejected | Invalid event; no lesson entry, lookup or generation change. Rule; requirement |
| Malformed generation zero | Opening through line 2, then lesson-started intro with generation=0 | Record replay | Rejected | Invalid event; no lesson entry, lookup or generation change. Rule; requirement |
| Malformed generation negative | Opening through line 2, then lesson-started intro with generation=-1 | Record replay | Rejected | Invalid event; no lesson entry, lookup or generation change. Rule; requirement |
| Wrong-current generation past | Revisit state at generation 3; lesson-started intro snapshot=S1 generation=1 | Record replay | Rejected | Well-shaped but not applied before lookup; active lessons remain empty. Rule; requirement |
| Wrong-current generation future | Revisit state at generation 3; lesson-started intro snapshot=S1 generation=4 | Record replay | Rejected | Well-shaped but not applied before lookup; active lessons remain empty. Rule; requirement |
| Current generation | Revisit state; lesson-started intro snapshot=S1 generation=3 | Record replay | Accepted | Creates intro started and position intro/goal; counter remains 3. Rule; requirement |
| New command declined | Upgrade to S2, start finish at generation 2, disclose command including –strict, learner declines, check-run final-check not-run without evidence; learner skips | Record replay | Accepted | Finish skipped, runs 0, no objective; old passing command cannot avoid disclosure. Rule; requirement |
| Optional failure does not gate | Variant S1 make declares checks: [{id: agent-runs, required: false}]; compute variant identity; start make, agent-runs fail observed, complete at 09:09:00 | Record replay | Accepted | Make passes but agent-runs-ok remains unsettled because its own optional evidence failed. Rule; requirement |
| Optional objective has evidence | Same variant and log, changing only result fail to pass | Record replay | Accepted | Make passes and agent-runs-ok settles observed at 09:09:00 under the variant identity. Rule; requirement |
| Required failure still gates | Unmodified S1 make, start, agent-runs fail observed, complete | Record replay | Rejected | Completion not applied; make stays started and no objective settles. Rule; requirement |
| Private admission payload | Confirmation controller retains an answer hash beside its issuance binding | Runner behaviour | Rejected | Forbidden even outside public JSON; no answer-derived data may establish provenance. Rule; requirement |
| Existing schemas cover inputs | Describe event transition/lesson fields, record generation/history and authored-file schemas; no replay manifest | Candidate wording | Accepted | Exactly eight types remain; callback and cross-file checks are semantic proof duties. Rule; requirement |
| Synthetic qualification limit | A controlled callback returns verified in a bounded replay experiment | Candidate wording | Accepted | Report only synthetic assumed authority; no actual host or filesystem durability claim. Rule; requirement |
Projection and schema boundary cases
Section titled “Projection and schema boundary cases ”These cases mutate a complete derived projection or transition, one field at a time. A shape failure never supplies missing authority. Reconstruction uses the same protected history and required sources; it is unavailable when those are lost.
| Case | Baseline and decisive change | Judged as | Outcome | Deciding rule |
|---|---|---|---|---|
| Complete projection | The opening record includes generation 1 and history [] | Record structure | Accepted | Record fields require both; requirement |
| No record generation | Remove generation from the opening record | Record structure | Rejected | Rebuild; never trust an absent counter; requirement |
| False derived counter | Opening record says generation 3 although its log has only one applied start | Record replay | Rejected | Replay derives 1 and replaces stale projection; requirement |
| No history list | Remove history from the opening record | Record structure | Rejected | Rebuild the required empty list; requirement |
| Null archive identity | Explicit-upgrade record history[0].snapshot is null | Record structure | Rejected | Archived identity/version are non-null; requirement |
| Zero archived generation | Explicit-upgrade history[0].generation is 0 | Record structure | Rejected | An archive is a previously active positive generation; requirement |
| Recursive history | Add history: [] inside explicit-upgrade history[0] | Record structure | Rejected | Archive entries have exactly seven fields and no nested history; requirement |
| Project copied into history | Add project to explicit-upgrade history[0] | Record structure | Rejected | Project and consent remain only in the active root; requirement |
| Old achievement restamped | Explicit-upgrade history[0].objectives[0].at becomes 10:00:00 instead of 09:09:00 | Record replay | Rejected | Replay restores its original completion time and S1 evidence; requirement |
| History reversed | Revisit record has generation-two entry before generation-one entry | Record replay | Rejected | Replay restores transition order 1,2; requirement |
| Counter in the lock | Add generation: 1 to an otherwise valid course lock entry | Record structure | Rejected | The lock never carries generation; requirement |
| Synthetic callback claimed to qualify a host | A report labels an actual host qualified solely because replay used a controlled verified callback | Candidate wording | Rejected | Replay handoff proves no actual host; requirement |
| Crash oracle claimed as filesystem proof | A report treats a simulated cut-point oracle as proof of real durable writes | Candidate wording | Rejected | Real persistence qualification remains separate; requirement |
Source: spec/records.md.
Agent Courses 1.0: pinned course identity
Section titled “Agent Courses 1.0: pinned course identity”This page defines how a runner turns a course folder into one short identity string. The string pins the exact bytes a learner installed. Two runners given the same folder produce the same string, and a different folder produces a different one. The page fixes every choice that could make two implementations disagree: which entries count, how they are ordered, how text is read and how the bytes are framed before hashing. The record files that store the string are defined in the record contract. Every rule here is listed in the requirement register.
This is a candidate. Wording marked Proposed is a drafting choice that waits for the owner’s review on the pull request; a passing check or an independent review does not approve it. Identity says which bytes were installed. It does not say who published them or whether they are safe, and it qualifies no runner.
Reading this page
Section titled “Reading this page ”The words MUST, MUST NOT, SHOULD and MAY are used as in the format contract. Cases are rows in tables built as the format contract’s case tables describe, with two extra subjects. Identity computation judges whether a folder gives a digest or is refused. Pin check judges what a runner does when it compares an identity with a lock entry.
Most computation cases are vectors in the vector manifest. A vector names a baseline tree, a short list of changes to it and the expected digest or refusal. The vector manifest section describes the shape. An implementation that follows this page reproduces every digest in the manifest.
What identity is for
Section titled “What identity is for ”A course is code someone else wrote, and the learner’s record points at one exact version of it. Identity lets a runner detect three things without a network: the installed folder was edited, a different payload claims the same name and version, and an upgrade really changed content. The pinning rules say what a runner does in each case.
The identity string is sha256: followed by sixty-four lowercase hexadecimal digits. The prefix names the hash. A later layout or hash would use a new prefix, so a reader never guesses which rules produced a string.
The folder that is hashed
Section titled “The folder that is hashed ”The root is the course folder: the directory that holds COURSE.md. The runner opens the root as a directory. Operating-system links in the path that leads to the root are followed once, so a course installed through a link still has an identity. Everything below the root is examined without following links.
The root MUST hold a regular file named COURSE.md. A folder without one is not a course and has no identity. A link named COURSE.md does not count. Both outcomes are refused with not-a-course.
A local project folder named by a course’s project: field is inside the course folder, so its files are part of the identity. A Git project reference is not inside the folder and is pinned by its own commit, which the record contract stores.
Entries
Section titled “Entries ”The runner walks the root and builds a list of entries. It looks at each name with the operating system’s no-follow call, so a link is seen as a link. Each name is one of four things.
| Found | Entry type | What it carries |
|---|---|---|
| A regular file | f |
The file’s bytes, after the text rule |
| A symbolic link | l |
The link’s target text, as Links describes |
| A directory with no entries inside it after exclusions | d |
Nothing |
| A directory with entries inside it | none | It has no entry of its own; its contents carry it |
| Anything else, such as a named pipe, socket or device | none | The whole computation is refused with special-file |
An empty directory is an entry because a valid course can depend on one: an empty failing fixture folder is accepted by the execution contract. A directory that holds only excluded names counts as empty. A course distributed through Git cannot carry an empty directory, so its author places a file in it, and that file becomes part of the identity.
A name that cannot be read, or a directory that cannot be listed, is refused with unreadable. A runner MUST NOT skip it. File modes, owners, times and extended attributes are not part of identity. A course therefore cannot rely on an executable bit being pinned, and its checks name an interpreter in their run command.
Exclusions
Section titled “Exclusions ”Four exact rules remove names from the walk. Nothing else is excluded, and an excluded name is not examined, so it cannot cause a refusal.
- Any name equal to
.git, at any depth, with everything under it. - Any name equal to
.DS_Store, at any depth. - The name
.agentsdirectly under the root, with everything under it. It holds runner state: learner records, the course lock and installed courses. - The name
_sitedirectly under the root, with everything under it. It is the folder generated pages are written to.
Rules 3 and 4 apply only at the root. A folder with the same name deeper in the tree is course content. A course MUST NOT use _site or .agents at its root for content, because that content would never be pinned. Proposed: the four rules and the root-only limit on the last two.
Paths
Section titled “Paths ”Each included entry has a path: the names from the root down to it, joined by /, with no leading or trailing separator. Every name (component) in a path MUST pass these tests, or the whole computation is refused with invalid-path.
- It is valid UTF-8 as defined in RFC 3629.
- It is not empty,
.or... - It holds no code point from U+0000 to U+001F, no U+007F and no backslash.
- It is in Unicode Normalization Form C.
- It is at most 255 bytes long, and the whole path is at most 1024 bytes.
No path is normalised or repaired. A folder listing that offers a parent component, such as one read from an archive, is refused, and nothing outside the root is read.
Case collisions are judged over every path, not only entries. Take each included entry’s path and every directory prefix of it, so Docs/a.md contributes Docs and Docs/a.md. Collect the distinct paths. If two different paths in that set are equal after Unicode full case folding and then Normalization Form C, the computation is refused with case-collision. A case-insensitive file system would merge Docs/a.md and docs/b.md into one folder, and it cannot hold a file README beside a folder readme/x, so the same checkout would give another identity there.
Links
Section titled “Links ”A symbolic link is an entry of type l. Its payload is the link’s target text exactly as the operating system reports it, with no resolution, no separator change and no check that the target exists. A link is never followed. A link to a file outside the root, to nothing, or to its own parent therefore has an identity, and the target is never read.
The target text MUST be non-empty, valid UTF-8, at most 4096 bytes and free of zero bytes, or the computation is refused with invalid-link. A source that cannot hold a link, such as an archive format without them, cannot give an identity for a course that has one, and the runner MUST fail rather than approximate.
Links that matter to learners are judged elsewhere. The format contract refuses a project link that escapes its source before copying. Identity only records what the folder holds.
Text and bytes
Section titled “Text and bytes ”A file is text when its bytes are valid UTF-8 and contain no zero byte. Otherwise it is binary. A binary file is hashed exactly as stored.
A text file is hashed after one change: each two-byte pair carriage return then line feed (0D 0A) is replaced by the single byte line feed (0A), scanning from the start, once. A lone carriage return is kept. The bytes 0D 0D 0A therefore become 0D 0A. A byte order mark is content and is kept. Unicode normalisation is not applied to contents, so a word written with a combined accent and the same word written with a combining accent are different.
Proposed: reading CRLF as LF lets Git on a platform that converts line endings produce the same identity as another platform, so a team’s lock does not break on a checkout. The cost is that a course cannot pin a deliberate CRLF-only difference in a text file. A checkout filter that rewrites other bytes, such as a content filter, gives a different identity, and the runner refuses it as a mismatch instead of repairing it.
Ordering
Section titled “Ordering ”Entries are sorted by comparing the unsigned bytes of their whole UTF-8 paths, from the first byte, with the shorter path first when one is a prefix of the other. This is the same as ordering by code point. It is not a locale order, not a case-insensitive order, not UTF-16 order and not directories-first. The path a.md therefore sorts before a/x.md, because . is 2E and / is 2F, and a path starting with an uppercase letter sorts before every lowercase one.
Framing and digest
Section titled “Framing and digest ”The hashed byte string is built in this order.
- The header: the ASCII text
agent-courses-identity-1followed by a line feed (25 bytes). - For each entry in order: the one-byte type (
f,lord, as ASCII), the path length as an unsigned 64-bit big-endian integer, the path bytes, the payload length as an unsigned 64-bit big-endian integer, then the payload bytes.
The identity is sha256: followed by the SHA-256 digest of that byte string in lowercase hexadecimal. Every length is written, so a boundary between two files, or between a path and its content, can never be moved without changing the digest.
A worked example
Section titled “A worked example ”The root holds COURSE.md with the three bytes hi and a line feed, an empty directory fail, and a link go whose target text is COURSE.md. Sorted by bytes, the order is COURSE.md, fail, go. The byte string, in hexadecimal and split by part, is:
6167656e742d636f75727365732d6964656e746974792d310a header660000000000000009434f555253452e6d64000000000000000368690a COURSE.md6400000000000000046661696c0000000000000000 fail6c0000000000000002676f0000000000000009434f555253452e6d64 goIts SHA-256 is 70dc1a84f335a8118e54c7d38a2088551bf13add821f0c5f6666e1593a446a75, so the identity is sha256:70dc1a84f335a8118e54c7d38a2088551bf13add821f0c5f6666e1593a446a75. Anyone can check it with any tool that hashes bytes. This tree is explanatory; the shared vectors use the baseline tree in the manifest.
Refusals
Section titled “Refusals ”A computation either returns an identity or is refused with one of these identifiers. A tree with several faults is refused, and which identifier is reported is not defined; every vector holds exactly one fault.
| Identifier | Meaning |
|---|---|
not-a-course |
The root has no regular file COURSE.md |
special-file |
An included name is a pipe, socket, device or other special file |
unreadable |
An included file cannot be read or an included directory cannot be listed |
invalid-path |
A path component fails the path tests |
case-collision |
Two different included paths, or directory prefixes of them, collide after case folding |
invalid-link |
A link target fails the link tests |
Pinning
Section titled “Pinning ”The identity is stored in the course lock, whose fields the record contract defines. Five duties follow.
-
Install. A runner computes the identity of the course it fetched before it writes anything. If the lock already holds the same name and version with a different identity, it MUST refuse the payload and change nothing. It MUST NOT replace the installed folder or the lock entry.
-
Resume. Before teaching from an installed course, the runner MUST recompute the identity of the installed folder and compare it with the lock. A difference means the installed folder was edited or damaged. The runner MUST refuse to teach from it, tell the learner, and write no record event. It MUST NOT silently re-lock or fetch a newer copy.
-
Upgrade. A new version, or new bytes under the same version, replaces the pinned course only when the learner explicitly asks for an upgrade. The runner never substitutes the latest version on its own. The record contract says what an upgrade writes.
-
Sources. A Git reference also records the commit it resolved, and the commit is the pin the runner resolves again. The identity is the second check, computed over the installed folder. Recovery uses the committed transition before teaching; it never treats a torn install as a new pin. A local-folder install has no commit, and a runner MUST NOT invent one. A mismatch between commit and identity on a later install is a refusal under duty one.
-
Retention. Before committing a start or upgrade, retain the complete identity-included authored tree under
.agents/course-snapshots/<digits>/<course>/. Preserve bytes, link text and empty directories. Recompute its identity and validate the course from stable protected bytes whenever replay requires it. Folder labels and cached metadata are not proof. Keep every applied snapshot for its history’s lifetime. Retained sources fixes safe lookup, structural checks and stop outcomes; durable transitions fixes publication and recovery order. Authored folders stay outside personal record payloads and are locally excluded from Git.
Identity is not authenticity. It proves the bytes are the ones first installed, not that a publisher approved them. A runner MUST NOT describe an identity as a signature or a verified publisher.
The vector manifest
Section titled “The vector manifest ”The manifest is a JSON object with the layout token identity (agent-courses-identity-1), the hash algorithm, a roots table and a list of vectors. Each root names a folder under the manifest’s directory and an input_path to its COURSE.md, which the repository’s document check requires to exist.
Each vector holds:
case: a unique kebab name, used in the cases below.root: the key of a root. The baseline root is a small tree of three files, not a course to teach, so itsCOURSE.mdhas no frontmatter.changes: operations applied, in order, to a copy of the root.writesets a file fromtext(UTF-8) orbytes_hex.mkdirmakes a directory.symlinkmakes a link fromtargetortarget_hex.fifomakes a named pipe.chmodwith mode000makes an entry unreadable.removedeletes a path and everything under it. All paths are relative to the root.requires: what the harness needs.listingmeans the input cannot be stored portably and is tested as a list of paths and contents.symlinks,fifoandunprivileged-readsneed those abilities. A harness that lacks one reports the vector as not run, never as passed.expected_digestorexpected_error: exactly one of them.rule: the anchor of the rule it exercises, on this page.explanation: a plain sentence saying why.
Expected digests were calculated by two programs in different languages, Python and Node, each written from this page, and the worked example was also checked with shasum. One author wrote both programs, so reproduction by a later implementation is still required. A later proof calculates the digests again and never copies them from the function under test.
Identity cases
Section titled “Identity cases ”Every case starts from the baseline tree in the manifest and applies the change in the second column. The outcome Accepted means an identity is produced; Rejected means the computation is refused with the stated identifier.
Pin cases
Section titled “Pin cases ”These cases use the lock entry for sample-course version 1.0.0 with the baseline identity. The runner holds the installed folder and the lock.
Retention cases
Section titled “Retention cases ”Identity computation and safe source use are distinct judgements. A literal link may hash successfully yet fail when required metadata escapes the retained tree. These cases use complete authored trace trees, because the identity-vector baseline is deliberately not teachable.
| Case | Baseline and change | Judged as | Outcome | Deciding rule |
|---|---|---|---|---|
| Verified retained folder | Valid authored source | Pin check | Accepted | Retained identity and structure both verify before metadata use |
| Label unchanged, bytes changed | Tampered call | Pin check | Rejected | Rehashing refuses changed source before settlement |
| Correct digest, invalid course | Duplicate call | Pin check | Rejected | A digest cannot establish course validity |
| Escaping metadata link | Metadata link escape | Pin check | Rejected | Link text hashes, but source use cannot leave included authored content |
| Exact local restoration | Exact restoration | Pin check | Accepted | Verify restored bytes and structure; never substitute latest content |
Source: spec/identity.md.