Decisions, Deviations, and Version Pins
This file records every place where the delivered repository deviates from the project brief, makes a judgement call the brief left open, or pins a version that was verified against current documentation. Entries are grouped by the phase in which they were made. Dates are absolute.
Phase 1 — scaffold (2026-10-06)
- Repository root. The brief’s layout shows a top-level
agent-evals-aws-workshop/folder. The working directory is itself the repository root (agent_systems_harnesses_aws), so the layout is applied directly at the root without a nested folder. - GitHub org/repo. The target repository was not decided during the build, so a
<GITHUB_ORG>/<GITHUB_REPO>placeholder was used indocs/aws/oidc_trust_policy.json, the README clone commands, the notebook setup cells (REPO_URL) and the docs. On 2026-10-07 it was replaced everywhere withgenial-labs-ai/system_agent_harness_aws(the local checkout keeps its original directory nameagent_systems_harnesses_aws). - OpenTelemetry version range instead of a single pin.
arize-phoenix-otel==0.17.2requiresopentelemetry-exporter-otlp-proto-http>=1.45, whileaws-opentelemetry-distro==0.21.0(ADOT, needed for CloudWatch GenAI observability in live mode) pinsopentelemetry-sdk==1.44.0. The two cannot be installed together. The core dependencies therefore allow>=1.44.0,<1.46, and thephoenixandcloudwatchextras are declared as conflicting extras in[tool.uv]. Local development installs--extra phoenix(resolves OTEL 1.45.1); a live-mode runner that wants CloudWatch installs--extra cloudwatchinstead (resolves OTEL 1.44.0). Nothing insrc/depends on the difference. langchain-communityconstraint.ragas==0.4.3importslangchain_community.chat_models.vertexaiat import time, whichlangchain-community>=0.4removed, so a uvconstraint-dependenciesentry holds it at>=0.3.27,<0.4. We do not use any LangChain functionality ourselves (see the RAGAS decision in phase 3).- Prices come from the public AWS Price List bulk API, not from the pricing web page. The pricing page is rendered client-side and cannot be scraped reliably, whereas
https://pricing.us-east-1.amazonaws.com/offers/v1.0/aws/AmazonBedrock/current/us-east-1/index.jsonis a public, credential-free feed. It contains Amazon Nova on-demand token prices but not third-party models billed through AWS Marketplace (Anthropic Claude 4.x and 5.x). Those entries arenullinpricing.yaml; the harness prints “cost unknown” for them unlessAGENT_PRICE_INPUT_PER_1K/AGENT_PRICE_OUTPUT_PER_1K(and theJUDGE_PRICE_*twins) are set. No price is ever guessed.scripts/fetch_pricing.pyregenerates the file (the only non-live network call in the repository; never run in CI). actis not used for the local workflow check. It is not installed on the authoring machine;make ciruns the same steps as.github/workflows/agent_eval_ci.ymlin the same order instead.- Phase-1 verified versions (installed and import-tested): deepeval 4.2.8, ragas 0.4.3, mcp 2.3.0, boto3 1.43.108 / botocore 1.43.108, opentelemetry-sdk 1.45.1, opentelemetry-semantic-conventions 0.66b1, openinference-semantic-conventions 0.1.41, arize-phoenix 20.19.0, arize-phoenix-otel 0.17.2, pydantic 2.13.5, jsonschema 4.26.0, pytest 9.1.1, ruff 0.16.10, jupytext 1.19.6, nbmake 1.5.5, ipykernel 7.4.0; npm promptfoo 0.124.0, @marp-team/marp-cli 4.5.1. GitHub Actions: actions/checkout v7, astral-sh/setup-uv v10.2.0, actions/setup-node v7, actions/upload-artifact v7, actions/github-script v9, aws-actions/configure-aws-credentials v6.3.0. The full transitive set is in
uv.lock.
Phase 3 — core code (2026-10-06)
- The mock “model” is a deterministic simulator of failure classes, not a language model.
FakeBedrockClientreplays scripted turns for golden cases that have amock_script(schema drift, multi-step) and otherwise usesHeuristicPlanner, which selects tools only by matching an intent phrase against tool descriptions (so an ambiguous description really captures calls), re-issues an identical call after a transient error (so a missing guard really loops), and follows instruction-like lines in tool output unless the harness quarantined them. Scripted replays alone could not show metric movement when a description or a guard changes, which the Day 3 lab depends on. Mock-mode scores are therefore an upper bound; the nightly live run reports real numbers. - Seeded weaknesses are feature flags (
STOCKROOM_WEAKNESSES), default off, so the shipped repository passes its own gate while Day 3 can switch each failure on. Thenaive_retryflag disables both the tool’s bounded internal retry and the harnessRepeatedCallGuard(the “retry path that can loop”); with the flag off the legacy shard still fails its first attempt per order so the fix is visible in traces. - Tool-output quarantine as the injection defence. With
injection_unguardedoff the harness removes whole paragraphs that match instruction-like patterns from tool results and marks them; this is a demonstrable, testable mitigation, not a complete defence, and the Day 3 lecture says so. - Token estimates are
len(text) // 4in mock mode so budgets and compaction trigger deterministically; live mode uses theusageblock returned by Converse. - DeepEval is run through plain
pytest, notdeepeval test run. The docs recommend the latter, but the workshop needs@pytest.mark.parametrizeover the golden set, a session-scoped results collector and no Confident AI account. Metrics are measured (metric.measure) and recorded; only hard invariants assert, so one regressed case yields a metrics table rather than 50 red tests.DEEPEVAL_TELEMETRY_OPT_OUT=1is set everywhere. - RAGAS via a custom
InstructorBaseRagasLLM(StockroomRagasLLM) rather thanllm_factory/LangChain/litellm wrappers: the collections metrics only needgenerate(prompt, response_model); this keeps the Bedrock judge and the deterministic fake on one code path and avoids an OpenAI dependency. OnlyFaithfulness,ContextPrecisionandContextRecallare used because they need no embeddings model. - OpenTelemetry GenAI semantic conventions are “Development” status. Attribute names are centralised in
evals/semconv.pyand cross-checked intests/test_tracer.pyagainst the installedopentelemetry-semantic-conventionsincubating module when present. Spans also carryopeninference.span.kindso older Phoenix versions render them; Phoenix ≥ 15.10 convertsgen_ai.*automatically. - MCP Python SDK v2 (
mcp==2.3.0). The brief predates the rename ofFastMCPtoMCPServer; the server usesmcp.server.MCPServer, the clientmcp.Client, and tests use the in-memoryClient(server)connection. A hiddenreset_session_statetool resets the per-run ledger across the process boundary; it is filtered out of the tool list the model sees. - The harness advertises whatever the tool server describes. Over MCP, the ambiguous description flag set on the server process leaks through the boundary even when the harness config is clean (
tests/test_mcp_server.py::test_executor_over_stdio_subprocess); this is deliberate and is a Day 3 talking point.
Phase 4 — eval suite and CI (2026-10-06)
- Red-team cases are hand-written Promptfoo tests with deterministic assertions.
promptfoo redteam generateneeds Promptfoo’s remote generation service or an OpenAI key, and the brief limits red teaming to the Stockroom agent; eight cases cover the four categories (jailbreak resistance, indirect injection via tool output, system-prompt leakage, unsafe tool use). Nollm-rubricassertions are used, so the Promptfoo step needs no model key at all. - The Promptfoo provider returns a JSON envelope (
answer,tools,termination,quarantined,steps) so assertions can check trajectories, not just text;scripts/promptfoo_asserts.pyholds the assertion functions. - Amazon Bedrock Evaluations has no agent/trajectory job type (job types today: automatic, LLM-as-a-judge, human, RAG —
applicationTypeisModelEvaluationorRagEvaluation). The Day 4 lab therefore uses it for final answers through the bring-your-own-inference-responses dataset format and shows AgentCore Evaluations’ on-demandevaluateAPI for trajectories. Jobs are only submitted whenSTOCKROOM_MODE=liveandSTOCKROOM_CONFIRM_AWS_SPEND=1. - Thresholds. The gate keeps the brief’s 0.85 for tool-selection accuracy and answer correctness, adds hard invariants (
must_not_call_ok_rate1.0,loop_rate≤ 0.10,termination_match_rate≥ 0.90), a red-team gate (0 successful attacks) and a regression check against the committedreports/baseline/main.json(max drop 0.05). Mock mode is deterministic (variance 0), so variance-based thresholds only make sense for the nightly live run, which reports 95% confidence intervals fromSTOCKROOM_EVAL_REPEATSinstead of gating. - CI runs the regression suite over the MCP streamable-HTTP transport (server started as a background step and smoke-tested) so the process boundary is exercised on every PR;
make cimirrors the same steps locally becauseactis unavailable. - Nightly workflow guard. It is skipped unless the repository variable
AWS_OIDC_ROLE_ARNis set, so forks and the un-configured upstream never fail; it never blocks PRs. (Since entry 37 it also has no schedule.) Model/bucket IDs come from repository variables, never from the workflow file.
Phase 6 — teaching materials (2026-10-06/07)
- Lectures and slides quote mock-mode numbers only, labelled as upper bounds. Every figure in
lectures/andslides/(tool selection 1.00 → 0.64, kappa 0.25 → 0.83, 9.4k vs 1.9k input tokens, …) was reproduced by running the repository in mock mode; live numbers come from the nightly report. No external incidents, company names or statistics are used. - The deck’s cost example uses Amazon Nova Pro’s published price as a labelled illustration because the Claude entries in
pricing.yamlarenull(Marketplace-billed models are absent from the Price List API); the slide states this explicitly. - Bias probes ship one pair per probe group.
data/judge_calibrationcontains one position pair (both orders), one verbosity pair and one self-preference pair: enough to demonstrate the mechanism and to show the deterministic fake judge is unbiased by construction, not enough to characterise a live judge. The Day 2 lecture says so and asks participants to add pairs before trusting the probe on Bedrock. - Node tooling is pinned in
package.json+package-lock.json(promptfoo 0.124.0, @marp-team/marp-cli 4.5.1) and installed withnpm cibymake setupand CI (with thesetup-nodenpm cache) instead of relying on an ad-hocnpxdownload: the first Promptfoo download is several minutes on a slow connection and the lockfile makes the tool versions reproducible.make promptfoo/make slidesstill work with the npx cache alone. scripts/check_lecture_refs.pyresolves symbols generously. Besides functions, classes and methods found by AST, it accepts attribute, parameter and keyword names used anywhere in the code base, identifier-shaped string constants, the vocabulary ofeval_thresholds.yamlandpromptfooconfig.yaml, bare file names in the repo, and an explicit ignore list for shell commands, IAM actions, model/profile IDs, case IDs and environment-variable names. This keeps the phase-6 check (“every code reference in the lectures points to a real file and function”) strict for paths andname()references without flagging prose; missing notebooks are warnings untilmake build-notebookshas run.
Phase 5 — notebooks (2026-10-07)
Notebook sources are jupytext percent files under
notebooks/src/; the.ipynbfiles are generated byscripts/build_notebooks.py. Cell tags drive the two outputs:exercisecells go to the student notebook only,solutioncells tonotebooks/solutions/only (same position),checkcells to both and are written to print “not solved yet” rather than fail in the student variant (since entry 44 they report throughstockroom.exercises, and the solution notebooks run in strict mode). Generated notebooks carry no outputs, kernelspecpython3, and content-hash cell ids (co-<sha1[:8]>) so rebuilds are byte-identical and diffs stay clean (nbformat would otherwise assign random ids). The participant marker is referenced only in the builder, as"TO" + "DO", so a repository-wide grep hits exercise cells only.Notebook sources are not importable modules by design. They contain IPython magics (
%pip) and top-levelawait, because the RAGAS collections metrics are coroutine-only andasyncio.runcannot be used inside the kernel’s running loop.ruffexcludesnotebooks/.Setup cell for Colab / SageMaker.
%pip installis guarded byimportlib.util.find_spec("stockroom")(a no-op when the repo is installed), pins the versions frompyproject.toml, and addslangchain-community>=0.3.27,<0.4explicitly because uv’sconstraint-dependenciesdo not apply to plain pip (ragas 0.4.3 fails to import with langchain-community 0.4).REPO_URLpoints atgenial-labs-ai/system_agent_harness_aws(entry 2).Phoenix export in notebooks is opt-in via
PHOENIX_COLLECTOR_ENDPOINT, not via importability: an OTLP exporter with no collector would stall nbmake on export timeouts. The Day 2 cell prints how to start Phoenix (make phoenix) when the variable is unset.MaxToolPayloadGuard(Day 3 exercise) is aToolExecutorwrapper, not a harness guard, becauseHarnessexposes no guard-registration hook; it replaces any single tool result over N characters with a structuredpayload_too_largeerror, which turns G049 underoversized_payloadfromTOKEN_BUDGETintoCOMPLETED(14,093 → 2,777 input tokens). Entry 45 added a seam for calls (Harness(run_guards=...)); payload limits stay executor wrappers because a run guard does not see results.Bedrock Evaluations payloads are validated offline against botocore’s service model (
botocore.validate.validate_parameterson theCreateEvaluationJobinput shape shipped with botocore 1.43.108), so the Day 4 notebook proves the request is well-formed without an AWS call.taskTypeisQuestionAndAnswer, metricsBuiltin.Correctness/Completeness/Faithfulness/Helpfulness,jobNameuses1-0-0because the pattern forbids dots. Submission is triple-gated (STOCKROOM_MODE=live,STOCKROOM_CONFIRM_AWS_SPEND=1,EVAL_ROLE_ARN). The AgentCoreevaluate()cell additionally needsAGENTCORE_SESSION_ID, since the mock harness does not run on AgentCore Runtime.Day 4 Exercise 2 uses an explicitly illustrative variance table (labelled made-up) as input for setting a threshold with the rule “mean − 2·pstdev, rounded down”, presented as one reasonable rule rather than a standard. Superseded by entry 43: the rule conflated the floor with the noise; the illustrative table is kept (still labelled synthetic) for Exercise 3.
make slidesredirects stdin from/dev/null. marp-cli treats a non-TTY stdin as an additional markdown input; undermake ci(and on CI runners) stdin is an open pipe, so the render hung indefinitely waiting for it, and with a closed pipe it fails with “Output path cannot specify with processing multiple files”. Redirecting stdin makes the render deterministic in both environments.astral-sh/setup-uvis pinned to the exact tagv10.2.0. The first GitHub-hosted run ofagent_eval_ci.yml(after publishing togenial-labs-ai/system_agent_harness_awson 2026-10-07) failed in “Set up job” with “unable to find versionv10”: that action publishes release tags only (v10.2.0), not a movingv10major tag, unlike theactions/*actions. It never surfaced locally becausemake cireplacesact(DECISIONS phase 4).The live workflow is
workflow_dispatch-only; the daily cron was removed (user decision, 2026-10-07) so Bedrock spend happens only when an instructor asks for a run, e.g. before a cohort. The file nameagent_eval_nightly.yml, the workflow name and the “nightly tier” wording in the teaching materials are kept: “nightly” names the scheduled-live layer of the offline / nightly / online model taught on Day 4, and the materials now say explicitly that this repository dispatches that layer by hand.Repository hygiene files added on 2026-10-07 (session 3).
LICENSEis MIT with “Genial Labs” as holder, matching thelicense = MITalready declared inpyproject.tomlandpackage.json(there was no LICENSE file, so GitHub showed no licence).CITATION.cfflists the git author as the only author; add co-authors there, not in the README. Dependabot runs monthly and grouped rather than weekly because every pin is recorded in the README table anddocs/DECISIONS.md, and because a library bump that moves a metric also needsmake baselineplus an explanation; a weekly stream of ungrouped PRs would make that discipline impossible. The labeler workflow usespull_request_target(the documented pattern foractions/labeler, so it can label PRs from forks) and checks out nothing, so it never runs fork code with write permissions.SECURITY.mdpoints at GitHub private vulnerability reporting, which was enabled on the repository the same day; it states explicitly that the seeded weaknesses and the planted injection are lab features.The website is built with Quarto 1.6.40 and deployed to GitHub Pages from
pages.yml(user decision, 2026-10-07). Quarto was chosen over MkDocs Material and Jupyter Book because the sources are Markdown lectures with many Mermaid diagrams, executed-notebook pages and a reveal.js deck, all of which Quarto renders without plugins. Three choices follow from the repo’s rules: (a) the lectures and docs stay GitHub-flavoured Markdown and are rendered unchanged throughsite/filters/gfm.lua, which promotes the first H1 to the page title and turns ``mermaid fences intoblocks rendered by Mermaid 11 from jsDelivr at view time (Quarto's native diagram engine only handles{mermaid}cells in.qmdfiles, and adding YAML front matter to the lectures would render as a table on GitHub); (b) the committed notebooks carry no outputs, sonotebooks/_metadata.ymlmakes the site build **execute them in mock mode** with the project virtualenv (QUARTO_PYTHON), which is the same offline runmake notebooksperforms, andmake sitethen regenerates the output-free notebooks fromnotebooks/srcbecause Quarto writes executed outputs back into the.ipynb; (c) the site build is **not part of the PR gate**: Quarto is a binary outsideuv/npm, somake siteis opt-in locally and runs in CI only on pushes tomain(entry 41:pages.ymlnow also builds, without deploying, on pull requests).quarto-dev/quarto-actions/setupis pinned to the exact tagv2.2.0because, likesetup-uv, it publishes no moving major tag. Pages was switched to the "GitHub Actions" build type on the repository the same day. The kickoff deckslides/intro.qmdis Quarto reveal.js rather than Marp so it lives inside the site; the Day 1 Marp deck is still built bymake slidesand copied in as a static resource. Bothslides/*.qmdandindex.qmdare now scanned byscripts/check_lecture_refs.py`.The website has its own design, shared with the sister workshop site rather than a stock Bootswatch theme (user request, 2026-10-07: the default-looking site was to be brought up to the standard of other workshop sites).
site/_tokens.scssdefines every colour as a token,site/theme.scssimports it and holds every rule (and publishes the tokens as--ws-*custom properties for the Mermaid script, so the diagram palette is not a second copy);site/theme-dark.scssoverrides only the tokens, so one set of rules serves both themes and the dark theme iscosmoplus the overrides instead of a second Bootswatch theme (darkly, which fought the custom rules). Inter and Source Serif 4 are vendored as latin-subset variable fonts undersite/fonts/(SIL OFL, from Fontsource) so the site depends on no font CDN; Mermaid 11 is the one remaining CDN script. The landing page usespage-layout: fullwith alandingbody class and no sidebar: the sidebar now has anid, and Quarto shows an id’d sidebar only on the pages it lists.site/filters/gfm.luasplits a lecture or notebook H1 of the form “Day N — rest” (for notebooks, thetitleQuarto’s jupyter engine has already taken from that heading) into the title (“rest”) and a subtitle (“Day N · lecture notes” / “lab notebook”) that the theme sets as an eyebrow above the title; the Markdown sources are still unchanged for GitHub. The Mermaid script reads the palette from the--ws-*custom properties once the stylesheet Quarto’s own script swaps in has applied (Quarto sets the body class and swaps the stylesheet after the module has run), and redraws the diagrams when the navbar toggle changes the body class. The site’s numbers (4 days, 8 notebooks, 5 tools, 4 weaknesses, 50 golden cases, the 1.0 → 0.64 tool-selection drop) are the repo’s own:data/golden/manifest.json,README.mdand the committed baseline.
Review follow-up (2026-10-08)
The source review in suggestions.md (same date) was triaged; these entries record what changed. Not done, and why, is in docs/TASKS.md under follow-ups.
- Titles follow one style guide, kept in one file and checked in CI.
docs/STYLE_GUIDE.mdsets Title Case for the names of things — the workshop, day/lecture/lab/deck/page titles, menu labels and slide titles — and sentence case for headings inside a page, with a canonical vocabulary (OpenTelemetry in prose and OTel as a short label, Bedrock Evaluations, AgentCore, red teaming / red-team). The review proposed Title Case for H1–H3; H2+ stay sentence case because they are not names, and recasing every section heading would churn anchors for no reader benefit.docs/workshop.ymlis the single source for every canonical title and short label, the banned spellings, the Node versions (22 in CI, 20+ allowed) and an exemption list that can only shrink (empty at the end of this change).scripts/check_style.pychecks every copy (H1s,_quarto.yml, README,index.qmd, the instructor guide, the decks,CITATION.cff) plus Title Case, prose spelling, Node mentions and relative links/anchors, and runs inmake ciand the PR workflow. Notebook and lecture file names are identifiers and keep their spelling (…_OTEL_Traces.ipynb). The Days 1–2 lectures now share the Days 3–4 numbering and the guide’s 90-minute morning lab.pages.ymlbuilds the site on pull requests (no deploy; Pages permissions only on the deploy job), so a failed render fails the PR. - The gate checks its evidence and its baseline before any threshold (
check_evidence(),baseline_problems()inscripts/check_thresholds.py). Every golden case must have run the recorded number of repeats against the dataset hash in this checkout, metrics must be finite, and Promptfoo results must contain every casepromptfooconfig.yamldefines; an input passed on the command line that does not exist is a failure. The baseline (reports/baseline/main.json) now records mode, agent model, judge, transport, repeats, weaknesses and per-case outcomes; a baseline whose mode, dataset hash, judge or agent model differs is an explicit “not comparable” failure instead of a delta, and--write-baselinerefuses runs with weakness flags or missing cases. The summary pairs each case with its baseline outcome. Two seeded regressions passed the old gate (naive_retry,oversized_payload: each breaks one two-case category and adds 15–18 % input tokens, which moves no aggregate past its floor or the 0.05 drop), soeval_thresholds.yaml(version 2) addscategory_gates.termination_match_rate: 1.0and acostrule (mean input tokens +10 % vs baseline);test_pr_gate_rejects_every_seeded_weakness()keeps the property. The live workflow no longer passes the committed baseline: it is a mock baseline, so every live report would read “not comparable” and show no regression or cost delta. Live reports carry the floors, the run-to-run spread and the intervals until a live baseline exists. Baseline diff: metrics unchanged; provenance fields and per-case outcomes added. - Uncertainty is reported as run-to-run spread plus a case bootstrap; thresholds are taught as three separate numbers.
_confidence_interval()pooled every case×repeat score into one normal interval, which treats correlated repeats as independent and answers neither “how noisy is a PR run?” nor “how well do 50 cases estimate the rate?”.src/stockroom/evals/stats.pyreplaces it:run_to_run()(per-repeat suite values, sample sd),case_bootstrap_interval()(cases averaged over repeats, 2000 resamples, seed 0, bounded to [0, 1]),required_max_drop()(2·√2·sd, rounded up) andfloor_is_safe()(mean − 2·sd ≥ floor).src/stockroom/evals/report.pybuilds the results document once for the suite, the notebook and the tests. The Day 4 lecture (2.3) and notebook (Exercise 3) use one procedure and the same labelled synthetic numbers, the gate’snoise_notes()prints the same verdicts, and the lecture diagram’s reversed subtraction (metric − baseline) now readsbaseline − metric. Day 4 is restructured around a required offline path with four exercises — label validation against the data before any agent run (the agent’s result only classifies accepted cases), a red-team case with a paraphrase and a non-vacuous trajectory assertion, the threshold procedure, and a capstone in which participants make the aggregate-only gate reject the regressions it missed and write the review (artefacts inreports/day4/) — and optional Bedrock/AgentCore/OIDC extensions. The AgentCore Logs Insights poll now has a deadline, stops on every terminal status the Logs API defines (Cancelled,TimeoutandUnknownpreviously looped forever; checked against the botocore service model offline) and needsSTOCKROOM_CONFIRM_AWS_SPEND=1, like the Bedrock job. - Exercise completion is observable. Check cells report through
stockroom.exercises(exercise_pending()/exercise_passed()with stable idsdayN.exM), and each notebook ends with anexercise_summary()checklist.STOCKROOM_STRICT_EXERCISES=1turns an unsolved exercise into an error;make notebooksruns the student notebooks permissively and the solution notebooks strictly, so a broken solution can no longer pass by printing “not solved yet”.scripts/build_notebooks.pyenforces one id per check cell, in order, matching the checklist. - The harness has one extension seam,
Harness(run_guards=...), for actions; payload limits stay executor wrappers (follow-up to entry 32). ARunGuardsees each schema-valid tool call before execution with aToolCallContext(query, step, calls so far) and returnsNone,BlockCall(refuse the call with a structuredblocked_by_guarderror;ToolCallRecord.blocked_byis set and the run continues) orGuardVerdict(halt with a typedTerminationReason; no new enum value). With no run guards the loop is unchanged and the golden metrics equal the committed baseline. Blocked calls count as not executed, soinvalid_call_raterises when a guard blocks;RunResult.blocked_tool_callsseparates them. A blocked call the model resends unchanged counts towards the repeated-call guard, so a model stuck on a refused write stops afterREPEAT_CALL_WINDOWrequests rather than atMAX_STEPS; the invalid-calls stop counts only schema-invalid calls. A guard that returns anything else is aTypeError. Day 3 is now a construction lab on this seam withinjection_unguardedleft on.TokenBudgetGuardreservesMAX_TOKENS(used + estimated input- max output ≤ budget; the config therefore requires
TOKEN_BUDGET>MAX_TOKENS), so the budget is a ceiling onchars/4estimates — exact in mock mode, not a billing cap in live mode. The Day 3 notebook andtests/test_harness_guards.pypin the limits of the sanitizer (a paraphrased injection it misses, a legitimate sentence it quarantines) and of compaction (a fact beyond the retained prefix). No golden metric moved.
- max output ≤ budget; the config therefore requires
- Promptfoo and Marp run from
node_modules/.bin, never through npx.npx --prefer-offlinestill falls back to the network when the cache misses, which the offline-first rule forbids.make promptfoo/make slidesnow call the binariesnpm ciinstalled frompackage-lock.jsonand stop with “run make setup” when they are missing; thePROMPTFOO_VER/MARP_VERMakefile variables are gone (the versions live inpackage.json).
Follow-ups (2026-10-08, after PR #2)
- A call a run guard blocks is counted apart from an invalid call (supersedes the metric part of entry 45).
RunResult.invalid_tool_callsnow lists schema-invalid calls and unknown tools only;ToolCallReport.blocked_calls,CaseScores.blocked_callsand the aggregateblocked_call_ratecount guard refusals, andToolCallReport.healthyno longer turns False when a guard does its job. Both code reviews of PR #2 flagged the old reading, and the Day 3 table had to explain why a working defence raisedinvalid_call_rate. Baseline diff: one new metric,blocked_call_rate: 0.0(no run guards in the golden suite); every other value unchanged.ToolCallEvaluator.from_spans()reads each tool span’serror.type(invalid_arguments,blocked_by_guard), so a report from a trace counts invalid and blocked calls as a report from the run does. Before, it counted every call as executed. - Days 2–4 get Marp teaching decks in the Day 1 format (
suggestions.md§11):slides/DAY2_TEACHING_SLIDES.md,slides/DAY3_TEACHING_SLIDES.mdandslides/DAY4_TEACHING_SLIDES.md, with the Day 1 deck’s front matter keys, the gaia theme, a lead title slide and a speaker-notes comment on every slide (19, 18 and 19 slides). Marp rather than Quarto reveal.js (the kickoff deck’s format) keeps one authoring format for the daily decks, keeps presenter notes in Marp’s presenter view, and adds nothing to the site build:make slidesrenders all four Marp decks in one pinnedmarpcall (MARP_DECKSin the Makefile) and_quarto.ymlcopies the HTML as resources. Titles and short labels are indocs/workshop.ymlunderdecks:; the generated HTML stays uncommitted. Each deck follows one structure: how the day runs (lecture sections read beforehand, what the 90-minute block teaches, what the lab practises), a prerequisite recap, one motivating failure taken from a seeded weakness or the calibration set and labelled as such, objectives mapped to exercises, worked examples, a prediction checkpoint before a reveal, the lab task, the expected evidence, review questions from the lecture, and a bridge to the next day (Day 4: a close-out). Every figure was reproduced in mock mode ataee3567; each note names the test, notebook cell oruv run python -ccommand that reproduces it. The Day 4 checkpoint uses the lecture’s illustrative nightly runs, labelled synthetic on the slide. Deviations from the suggestion: the decks also carry the 09:00 recap, the lab briefing and the 16:00 review, not only the 90 minutes; the “one timetable source” part is not done (decks quotedocs/INSTRUCTOR_GUIDE.mdand nothing validates the copies); notes cite notebook exercises by number and cells by topic, not by section number, because the notebooks were being revised at the same time; and two figures come from solution cells (the Day 2 rubric v3 result and the Day 3 run-guard table), so a change to those solutions must be checked against the decks. - Days 1–3 hand their artefacts to the Day 4 capstone through
reports/participant/(suggestions.md§12).stockroom.handoffhas one pydantic model per day (Day1Handoff: the Exercise 1 golden case;Day2Handoff: agreement and kappa per rubric plus the Exercise 2 calibration item;Day3Handoff: the golden cases the Exercise 1 assertion fails and the Exercise 2 run guard blocks, per build),save_handoff()/load_handoff(), and a reference artefact per day indata/handoff/, written by running the solution notebooks. Each Day 1–3 notebook ends with a save cell; Day 4 section 6 loads the three files, runs the Day 1 case through the label validator and on all five builds, grades the Day 2 item with the gate’s judge, shows the Day 3 verdicts beside the aggregate gate, and the Exercise 4 check appends a provenance table (participant or reference, file, time, mode) and that evidence table toreports/day4/capstone_review.md. Choices:- Data, not code. The Day 3 assertion and guard are functions in the participant’s kernel. Rather than pickling them or executing saved source on Day 4, the Day 3 save cell runs them over the golden set on the five builds the Day 4 gate compares and saves what each caught. In mock mode the builds are identical on both days; in live mode the verdicts are a Day 3 measurement, and the provenance table says when it was taken.
- Only what passed is saved (
exercise_status(), new instockroom.exercises). An unsolved scaffold would otherwise reach Day 4 as an assertion that “catches nothing”, labelled as the participant’s. - The participant’s case runs beside the gate, not in it. Adding it to the gate’s suite changes the dataset hash, and the gate rightly refuses the baseline; the review is where adding it (new manifest version, regenerated baseline) gets argued.
- A missing file falls back and says so; a malformed one stops with
HandoffError. A silent fallback would drop the participant’s work from the review unnoticed. reports/participant/stays gitignored with the rest ofreports/: the artefacts are per person and would conflict on everygit pull, and the reference files make a fresh clone work.STOCKROOM_HANDOFF_DIRmoves it (Colab: a mounted Google Drive folder). The module reads it, asstockroom.exercisesreadsSTOCKROOM_STRICT_EXERCISES, because it is a notebook setting, not an agent one.make notebooksuses a scratch hand-off directory (reports/notebooks_handoff/, emptied first).make ciin a participant’s checkout therefore never overwrites their artefacts with the solutions’, the student pass runs Day 4 on the reference fallbacks and the solution pass on what Days 1–3 just saved.make siteexecutes the notebooks with the same scratch directory, so a local site build never writes intoreports/participant/either.- A save cell saves only what the check passed.
exercise_passed(..., checked=...)keeps the object a check validated. A check whoseassertfails records nothing, so “passed” survives a failed re-check; the save cells therefore save only whilestill_checked()holds, meaning the case, rubric report, item, assertion or guard in the notebook now is the one that passed. Otherwise they say to re-run the check. The Day 3 sweep (the golden set on five builds, twice) runs in live mode only withSTOCKROOM_CONFIRM_AWS_SPEND=1. Day 4’s “fixed” build pinsweaknesses="", as Day 3’s does. An unreadable hand-off file raisesHandoffErrorwith the same advice as a malformed one. - No exercise was added, and no metric, rubric, tool description or golden case changed, so the baseline is unchanged.
- The rendered site is checked on every pull request, in HTML and in a browser (
suggestions.md§10).scripts/check_site.pyreads_site/without a browser: every pagedocs/workshop.ymlnames was rendered, its H1 and browser-tab title are canonical, and every local link and#anchorresolves (about 5,500 links on the current site).scripts/site_layout.mjsserves_siteand opens the landing page, the site pages and every day’s lecture, lab and solutions in Chrome at 1280 and 390 px, light and dark. It checks for content past the right edge (outside a scroll container), undrawn Mermaid diagrams, the wrong colour scheme and uncaught page errors, and saves a screenshot of each view.make site-checkruns both.pages.ymlruns it on pull requests (Chrome is preinstalled on the runner) and uploads the screenshots as an artefact. It needs the network (Mermaid comes from jsDelivr), so it stays out of the offlinemake ci.puppeteer-core24.43.1 was already installed through Marp; it is now a direct devDependency at the same version, so no new package is downloaded. It found these defects on the live site, all fixed here:- every notebook page threw
Se.default.extend is not a function. Mermaid’s bundle breaks when an AMD loader is present, and notebook pages load require.js for Jupyter output (reproduced by definingdefine.amdon a lecture page). Pages now load Mermaid only when they contain a diagram. - at desktop width, a long
OTEL_EXPORTER_OTLP_LOGS_HEADERS=…setting (Day 2 lecture) ran off the page, and the Day 3 metrics table, whose headers are metric names, was 1,179 px wide in an 802 px column. Quarto sets inline code towhite-space: pre. - at phone width, nine pages (every lecture, the Day 1 and Day 4 labs, the instructor guide, the style guide) scrolled sideways by 28–395 px: Markdown tables wider than the screen, and bare AWS documentation URLs. The fixes are all in
site/theme.scss: - inline code may wrap (
pre-wrap) and break anywhere; - prose breaks long words and links break anywhere;
- below the desktop layout (992 px), tables scroll inside their own box. The first version of the check missed the phone overflow because it compared against
window.innerWidth, which on a phone grows to fit content that is too wide. It now measures against the layout width (documentElement.clientWidth), and it also flags a page that scrolls sideways and text cut off by a clipping box. Mermaid is pinned to 11.17.2 instead of the movingmermaid@11, so a release cannot change the diagrams without a reviewed PR.
- every notebook page threw
- Dependency alerts: one fixed, the rest recorded (the failing “Dependabot Updates” runs).
speech-rule-engine4.1.4 (Marp → marp-core → mathjax-full) pins@xmldom/xmldomto exactly 0.9.10, which 11 open alerts cover; Dependabot cannot raise it past a parent’s exact pin. An npmoverridesentry sets 0.9.12, a patch release in the same line.make slidesand Promptfoo (18/18) pass with it.- Not changed, because none has a fix inside its parent’s range and none is reachable from the workshop:
katex(low; marp-core needs ^0.17, the fix is 0.18.2, and no deck uses math);node-forgeandbasic-ftp(via Promptfoo; JKS keystores, FTP PAC files);extract-zip(via puppeteer’s browser download, which nothing here uses);diskcache(via RAGAS; no patched release);- RAGAS’s own SSRF advisory (the multimodal collections module, unused; no patched release);
pydantic-ai-slim(optionalphoenixextra only: versions after 2.51 needopenai>=3.19, which the pinnedragas==0.4.3excludes, and moving RAGAS changes the API the Day 1 lab teaches).
- Revisit when Marp, Promptfoo or RAGAS release.
- Duplicated titles stay validated, not generated (
suggestions.md§2 proposed either). The copies live in hand-edited files (_quarto.ymlmenus, README tables,index.qmdcards, the instructor guide,CITATION.cff, the decks), where a generator would have to rewrite regions of prose and layout.scripts/check_style.pyalready fails CI when a rename misses any copy, which is the suggestion’s acceptance criterion.scripts/check_site.pyextends the same check to the rendered pages and browser tabs.