Skip to content

resource

Tool Isolation: Unit B Exercises

Study tool isolation in Chapter 15. Unit B: diagnose a controlled failure, repair it and test a changed case in the student notebook.

Student edition · 90 minutes of dedicated work · 2026-09-09

Open in Colab Runs on Google Colab as it ships today, or on any local Python 3.12+ kernel.

This is one of two practical units for Chapter 15. Unit A constructs and connects the mechanism; Unit B investigates a controlled failure, repairs it and transfers the invariant. Each is a complete ninety-minute session, with its own setup and required conceptual introductions. Basic Python variables, conditions, loops, functions, lists and dictionaries are the starting knowledge. Libraries and specialized concepts used here are introduced below before the main task.

By the end you should be able to:

  1. Explain the chapter's mechanism using a prediction and an observed intermediate result.
  2. Repair the failure: A known tool can still be unavailable to this particular worker. Install the method in the real Dispatcher and observe handler invocation counters, not just returned text.
  3. Solve separate registration, permission and consequential authority using changed inputs and an independent expectation.
  4. Retain your implementation, failed/corrected observations, causal explanation and limits.
MinutesDedicated activityEvidence you produce
0–5State the problem and make a predictionInitial prediction in your own words
5–25Foundations and library examplesValues, explanations, revised predictions
25–35Trace setup and the main interfaceInput → learner function → observation
35–60Reproduce, diagnose and repairSource, visible checks and runtime evidence
60–80Implement and challenge the transfer taskFunction and a new counterexample
80–90Retrieve, explain and saveExit ticket and retained submission

Installation is preparation time. These are planning estimates, not measured completion times. Use the reference primers when a term is unfamiliar; in Unit B retrieve an explanation before re-reading it. Run All checks that the artifact executes. Unfinished student functions deliberately produce NEEDS_WORK. Keep your first attempt before opening answers.

This notebook belongs to the construction edition. Its supplied teaching runtime is embedded, so it can run without the textbook or another notebook. Where code uses REFERENCE_LESSON, that is the frozen runtime exercise identifier; the reader-facing chapter and saved unit identifiers use the current edition. Building against a supplied runtime is not proof that you have constructed all of its dependencies.

Run the self-contained setup

Open this notebook in Google Colab with the badge above, or use a local Python 3.12+ Jupyter kernel, with Pydantic 2. If needed, run %pip install "pydantic==2.13.4" once in a separate cell and restart the kernel. Package installation needs internet; the lesson itself needs no repository download, API key or prior notebook. The closing extension can use an OpenAI key from Colab's Secrets pane to put a live model behind the same tools; without a key it replays a recorded transcript and says so.

The collapsed cell contains 90 frozen teaching files. Base85 represents compressed bytes as text; zlib decompresses them; SHA-256 checks that the decoded files match this edition. These are supplied packaging operations, not learner algorithms. tempfile creates an isolated working copy; Path handles file locations; sys.path tells Python where the supplied modules live; a .pth file, the mechanism an editable install uses, tells reviewed local subprocesses the same. The code is available for inspection below and performs no package installation itself. The subsequent lesson teaches the libraries used by the mechanisms you will implement.

Run setup on every fresh kernel. It writes scratch runtime files separately from your retained practical-work/ch15-b folder. Rerunning setup restores the frozen support files and keeps your saved work. Restarting a kernel clears variables, not saved submission files. Source basis: Sovereign Agent 444c5f6. Some tasks use reviewed local subprocesses; they are not an OS sandbox.

Supplied offline setup and teaching files

The complete supplied setup cell is in the downloadable notebook. Run it there before attempting the cells below. This web edition omits only that compressed setup payload.

Commit to a prediction before the examples

python
prediction_notes = {
    "prediction": "Write the expected behavior before running the worked example.",
    "reason": "Name the input and rule behind that prediction.",
    "falsifier": "Name an observation that would prove the explanation wrong.",
    "revision": "After execution, explain what changed in your understanding.",
}

Reading the Python vocabulary used in this notebook

You need basic assignments, if, loops, functions, lists and dictionaries. The less familiar features used by the supplied code are introduced here. A library is reusable code that Python can import. The standard library ships with Python; Pydantic is an additional package. An import makes a name available, but does not mean that you have completed the exercise.

JSON is text for exchanging structured values. A Python dictionary is an in-memory object; the JSON representation is a string. Use json.dumps to encode and json.loads to decode. Decoding proves that text has valid JSON syntax, not that its fields match our business contract. Predict which of the following two decoded objects could describe a stock count.

python
import json

intro_data = {"sku": "MANGO", "count": 3}
intro_text = json.dumps(intro_data, sort_keys=True)
print(type(intro_data).__name__, type(intro_text).__name__, intro_text)
print(json.loads(intro_text))
print("Also valid JSON:", json.loads('["not", "a", "stock", "record"]'))
assert json.loads(intro_text) == intro_data

The first result is a dictionary; the second is a list. Before indexing a decoded object, check the shape that your function promises to accept. An exception interrupts the normal path. raise ValueError(...) refuses an invalid value; try/except lets a caller inspect that expected refusal. Catch the expected class, rather than turning every programming error into apparent success. finally runs cleanup even when an earlier operation raises.

An annotation, such as count: int, documents the expected type. It does not by itself enforce the type at runtime. A class defines a kind of object; an instance holds one object's data. @dataclass asks Python to generate routine construction and comparison methods from annotated fields. frozen=True prevents ordinary reassignment of the instance's fields; it does not make every object nested inside those fields immutable. A method is a function attached to a class; self refers to the instance receiving the call.

python
from dataclasses import dataclass


@dataclass(frozen=True)
class IntroObservation:
    operation: str
    count: int


intro_observation = IntroObservation("count-mango", 3)
print(intro_observation.operation, intro_observation.count)
assert intro_observation == IntroObservation("count-mango", 3)

A callback is a function passed to another function. This is how the classroom harness invokes your implementation. The argument candidate below is a function object; parentheses perform the call. Predict the two answers before execution, then trace the result to the callback.

python
def intro_apply(candidate, value):
    return {"input": value, "observed": candidate(value)}


def intro_double(value):
    return value * 2


print(intro_apply(intro_double, 3))
print(intro_apply(lambda value: value + 2, 3))
assert intro_apply(intro_double, 3)["observed"] == 6

lambda value: value + 2 is a small anonymous function. A closure is a function that retains access to values from its surrounding scope. It can bind a tool to a shop snapshot. A shallow copy duplicates only the outer container; copy.deepcopy also copies nested containers used in these fixtures. A set stores distinct values; required <= allowed asks whether every required item is allowed. frozenset is the corresponding immutable set. A tuple groups ordered values; (value,) is a one-item tuple, including the comma.

Paths and cleanup. Path represents a filesystem location. path / "file.json" constructs a child path; read_text and write_text read and write text. A context manager, used with with, manages entry and exit. A temporary-directory context removes its contents on exit. Save your submission outside temporary runtime directories. Reopening a file is different from reusing a Python variable: the former tests retained bytes, while the latter only tests this kernel.

python
from pathlib import Path
from tempfile import TemporaryDirectory

with TemporaryDirectory() as intro_folder:
    intro_path = Path(intro_folder) / "observation.json"
    intro_path.write_text(json.dumps(intro_data), encoding="utf-8")
    intro_reopened = json.loads(intro_path.read_text(encoding="utf-8"))
    assert intro_reopened == intro_data
    print("Read from a file:", intro_reopened)

Retrieval check: explain JSON versus a dictionary, annotation versus validation, class versus instance, and defining a callback versus invoking it. Change the callback above so an incorrect implementation visibly changes the observed output. This distinction will matter when grading your connected work. Reference: Python's JSON, dataclasses, and pathlib documentation.

Pydantic: turn an input dictionary into a checked object

Pydantic is an additional Python library for validating data. Its model is a class describing fields, not a neural network. Inherit from BaseModel, declare annotated fields, then call model_validate on incoming data. A field without a default is required. A field with a default can be omitted. The result is an instance whose values you read with dot notation.

python
from pydantic import BaseModel, ConfigDict, Field, ValidationError


class IntroCourseRequest(BaseModel):
    model_config = ConfigDict(strict=True, extra="forbid")
    name: str = Field(min_length=1)
    quantity: int = Field(gt=0, le=1000)
    note: str = ""


intro_request = IntroCourseRequest.model_validate({"name": "mango", "quantity": 4})
print(intro_request.name, intro_request.quantity, repr(intro_request.note))
assert intro_request.note == ""

The annotation says the field's type. Field supplies constraints: gt=0 means greater than zero, le=1000 means at most 1000, and min_length=1 excludes an empty name. ConfigDict sets model-wide behavior. strict=True rejects conversions for this integer field, including "4", 4.0 and True; extra="forbid" rejects undeclared keys. Pydantic can otherwise convert some compatible inputs, so choose this boundary deliberately rather than assuming every accepted input arrived in the expected type.

Predict which rule refuses each payload. ValidationError reports a failed contract. Its errors() entries contain loc, the field location, and type, the failure category. Catching that expected exception lets the notebook inspect the failure and continue.

python
intro_bad_requests = [
    {"name": "mango", "quantity": "4"},
    {"name": "mango", "quantity": True},
    {"name": "", "quantity": 4},
    {"name": "mango", "quantity": 0},
    {"name": "mango", "quantity": 4, "approved": True},
    {"quantity": 4},
]
for intro_bad_request in intro_bad_requests:
    try:
        IntroCourseRequest.model_validate(intro_bad_request)
    except ValidationError as intro_error:
        print([(item["loc"], item["type"]) for item in intro_error.errors(include_input=False)])
    else:
        raise AssertionError("An invalid input crossed the declared contract")

Use model_dump() for a Python dictionary, model_dump_json() for JSON text, and model_validate_json() to parse and validate JSON. model_json_schema() describes the contract; it is neither an instance's current values nor an invocation of the business handler.

python
intro_serialized = intro_request.model_dump_json()
intro_schema = IntroCourseRequest.model_json_schema()
assert IntroCourseRequest.model_validate_json(intro_serialized) == intro_request
print("Actual values:", intro_request.model_dump())
print("Quantity contract:", intro_schema["properties"]["quantity"])
assert intro_schema["properties"]["quantity"]["exclusiveMinimum"] == 0

Four is valid input to this schema even if the shop needs six. Pydantic checks the declared shape and constraints; the handler still needs authoritative stock, price and permission. Ordinary assignments to an existing instance are not automatically revalidated unless configured for assignment validation. This lesson validates new input at the boundary and uses the resulting values. Explain these limits before relying on a model object in a transaction or tool call.

Chapter 2's full introduction expands this pattern with a separate data-repair checkpoint. This notebook contains the required pattern here so prior Pydantic experience is not needed. References: models, fields, and strict mode.

Exposing a tool and permitting a call are separate operations

A registry lists implemented tools. An allowlist names the subset one worker may use. A hostile instruction might ask a stock-reading worker to purchase goods. The instruction can change what the model requests; it must not change the worker's deterministic permissions. A trust boundary separates data we interpret from authority we accept. Retrieved text and tool descriptions are data, even when they contain imperative sentences.

Start with a handler counter

If a dispatcher returns “not allowed” after executing a handler, the error message is reassuring but the effect has already happened. Record a local event inside the handler so we can observe whether it ran. Predict both the result and event list for a registered but forbidden tool.

python
intro_handler_events = []
intro_registry = {"stock": lambda: intro_handler_events.append("stock-ran") or 6}
intro_allowlist = set()


def intro_dispatch(name):
    if name not in intro_registry or name not in intro_allowlist:
        return {"ok": False, "error": "not_allowed"}
    return {"ok": True, "value": intro_registry[name]()}


assert intro_dispatch("stock")["ok"] is False
assert intro_handler_events == []
intro_allowlist.add("stock")
assert intro_dispatch("stock") == {"ok": True, "value": 6}
assert intro_handler_events == ["stock-ran"]
print(intro_handler_events)

This is local Python admission, not a sandbox. A process has its own interpreter and memory; starting a subprocess does not automatically remove filesystem or network privileges. OS containment requires operating-system controls under a stated threat model. The core notebook proves mediation and effect ordering; the separate container experiment is needed for claims about operating-system enforcement.

A protocol describes messages across a boundary

MCP, the Model Context Protocol, defines how clients and servers exchange capabilities and tool requests. This book uses a small pinned protocol implementation rather than assuming an MCP SDK. JSON-RPC supplies request/response envelopes with method names and correlation IDs. A transport carries those bytes; standard input/output is one possible transport. A request ID correlates a reply to a request. It is not automatically the durable business-operation ID that protects a supplier purchase from duplication.

python
import json

intro_rpc_request = {
    "jsonrpc": "2.0",
    "id": 17,
    "method": "tools/call",
    "params": {"name": "list_stock", "arguments": {}},
}
intro_rpc_reply = {"jsonrpc": "2.0", "id": 17, "result": {"count": 3}}
intro_wire = json.dumps(intro_rpc_request)
assert json.loads(intro_wire)["params"]["name"] == "list_stock"
assert intro_rpc_reply["id"] == intro_rpc_request["id"]
print("Correlated request and reply:", intro_rpc_request["id"], intro_rpc_reply["id"])

These two objects illustrate correlation, not a complete MCP handshake. A real session also has initialization and capability rules. The notebook's frozen runtime supplies those mechanics where the chapter probe uses them. You still inspect the tool name, arguments, permission and handler observation that matter to the exercise. Reference: the pinned MCP 2025-06-18 basic protocol description.

Bounds have different positions in the execution path

Argument validation belongs before the handler. A consequential tool needs an authority guard before the handler. The serialized result's byte length can be known only after a result exists. If that final size check refuses output, it does not roll back an earlier side effect. This is a limitation of that boundary, not a reason to pretend the effect never happened.

Errors returned to a caller should describe the refusal without unnecessarily echoing raw validation inputs. The actual dispatcher catches declared operational exceptions and returns bounded observations. An unrelated programming exception must not be counted as proof that the correct permission check ran.

The construction task implements registry lookup, allowlist membership, strict arguments, authority guard, handler invocation and bounded JSON output in order. The failure task removes the allowlist condition. The transfer includes an unseen tool, an allowed read, a denied known read, a consequential call and an oversized result. Draw the event order for each before coding, and state which claims still require a supported container or host experiment.

Choose an explicit starting point for this independent notebook

This Unit B runs without Unit A. By default it prepares a supplied reference starting point and labels its provenance. It is not evidence that you built Unit A. To investigate your own successful implementation, set LEARNER_HANDOFF to its saved path before running the cell. An invalid selected file refuses; it is never silently replaced with the reference.

SourceTask supplies copied-source execution and handoff validation; RuntimeLab supplies the controlled failure experiment. Their public operations are introduced beside the main exercise. The artifact stores identity and observations; no variables from another kernel are required.

python
LEARNER_HANDOFF = None

Prepare and validate the supplied starting artifact

python
import json
import runpy
import shutil
import textwrap
from pathlib import Path

COURSE_INPUT = COURSE_WORK / "ch15-unit-a-handoff-v1.json"
if LEARNER_HANDOFF is not None:
    learner_input = Path(LEARNER_HANDOFF).expanduser().resolve()
    if not learner_input.is_file():
        raise FileNotFoundError("The selected learner handoff does not exist")
    if learner_input != COURSE_INPUT.resolve():
        shutil.copy2(learner_input, COURSE_INPUT)
    HANDOFF_ORIGIN = "LEARNER_SELECTED"
else:
    source_task_class = runpy.run_path(
        str(COURSE_ROOT / "book/always_on/exercises/source_tasks_v1.py")
    )["SourceTask"]
    reference_task = source_task_class(COURSE_ROOT, 11)
    try:
        reference_task.install(textwrap.dedent(reference_task.fragment))
        reference_observation = reference_task.visible("SUPPLIED_REFERENCE_START")
        if reference_observation["status"] != "PASS":
            raise RuntimeError("The supplied starting point did not pass its connection check")
        reference_task.save(COURSE_INPUT, reference_observation)
    finally:
        reference_task.close()
    HANDOFF_ORIGIN = "SUPPLIED_REFERENCE"
print("Starting evidence:", HANDOFF_ORIGIN)
print("The core task below validates the selected artifact before using it.")

Understand the supplied execution interface

The course runtime is provided so your implementation can be connected to real callers and storage. SourceTask(ROOT, chapter) makes a private copy. install(source) replaces only the declared function; visible() invokes the real chapter probe; save(path, result) retains a successful implementation and its evidence. load(path) checks the saved identities and hashes. inject_failure() changes the declared boundary; repair(fragment) replaces that broken fragment. close() removes the scratch copy after you retain evidence. These methods are supplied harness operations, not additional packages you must discover or install.

RuntimeLab provides the same copied-source failure experiment without the complete-function construction layer. Its run method records exit status, observations and the compared expectation. A subprocess log from an unfinished learner implementation is feedback about that implementation; it is not a successful connection. A syntax error in the notebook cell itself is a separate issue to fix. The task below names which interface it uses.

For direct-function units, the visible driver calls your callback without installing a source string. In either case, trace where your code is invoked. Supplied fixtures, database wrappers and replay models are labeled infrastructure; your own implementation and changed-case explanation are the evidence of learning.

Main practical: construct, connect and challenge

A known tool can still be unavailable to this particular worker. This time you begin with your Unit A implementation and its saved evidence. Private shop data can leave a registered handler despite Lucy withholding permission to use it.

Verify the handoff

The starting-point cell has selected the Unit A artifact explicitly. A selected learner handoff must validate; the default reference start is labeled separately. Run the setup and keep the runtime and implementation hashes in your submission.

python
import json
import os
import runpy
from pathlib import Path

ROOT = COURSE_ROOT

SourceTask = runpy.run_path(str(ROOT / "book/always_on/exercises/source_tasks_v1.py"))["SourceTask"]
REFERENCE_LESSON = 11
HANDOFF = Path("ch15-unit-a-handoff-v1.json")
handoff_status = "MISSING"
if HANDOFF.is_file():
    task = SourceTask(ROOT, REFERENCE_LESSON)
    try:
        handoff = task.load(HANDOFF)
        handoff_status = "VERIFIED"
        print("IMPLEMENTATION", handoff["implementation_sha256"])
    finally:
        task.close()
print("UNIT_A_HANDOFF", handoff_status)

Reproduce and diagnose

Predict the consequence of this injected boundary before executing it:

text
if tool is None:

The controlled mutation changes the same implementation you submitted. It refuses if the declared mutation boundary no longer occurs exactly once; inspect an alternative implementation with the instructor before adapting the experiment.

python
baseline = broken = None
if handoff_status == "VERIFIED":
    task = SourceTask(ROOT, REFERENCE_LESSON)
    try:
        task.load(HANDOFF)
        baseline = task.visible("YOUR_BASELINE")
        if baseline["status"] != "PASS":
            raise ValueError("Saved Unit A code no longer satisfies the visible contract")
        task.inject_failure()
        broken = task.run("INJECTED_FAILURE", expected=task.spec["expected_broken"])
        print("BEFORE", baseline["observation"])
        print("AFTER", broken["observation"])
    finally:
        task.close()
else:
    print("HANDOFF_REQUIRED: complete Unit A before performing Unit B")

State a diagnosis using those two observations. Name a test that would prove your diagnosis wrong. Install the method in the real Dispatcher and observe handler invocation counters, not just returned text.

Repair the boundary

Return the complete replacement for the injected fragment. Do not edit the oracle or print a desired observation. Repair the actual source. The starter keeps the defect so the learner outcome remains incomplete.

python
def repair_fragment():
    return "if tool is None:"

Hint 1: the consequence

Private shop data can leave a registered handler despite Lucy withholding permission to use it.

Hint 2: the evidence

Compare the two observations, then trace the changed field to invoke in src/sovereign_agent/tool_dispatch.py. Distinguish a schema refusal from a business-rule or authority refusal.

Hint 3: the design

Check membership before argument validation, require a guard for consequential tools, run it before the handler and enforce encoded output size.

python
def connect_repair(fragment):
    task = SourceTask(ROOT, REFERENCE_LESSON)
    try:
        task.load(HANDOFF)
        task.inject_failure()
        task.repair(fragment)
        return task.visible("YOUR_REPAIR")
    finally:
        task.close()


repair_result = None
if handoff_status == "VERIFIED":
    repair_result = connect_repair(repair_fragment())
    print("REPAIR", repair_result["status"], repair_result["observation"])
else:
    print("REPAIR_NOT_ATTEMPTED: missing Unit A evidence")

Transfer under a changed constraint

Use an unseen tool name, a valid allowed read, a denied registered read, a consequential call and oversized output.

Create a fresh task, load your handoff, inject the defect and apply your repair. Then change only the copied probe to exercise the new condition. Keep the actual observation and a prediction written beforehand. Explain why a visible-case lookup or a blanket refusal could pass the original example but fail this transfer.

The instructor's holdout applies your repair to a new copied runtime and checks both the positive case and the missing protection. An exact exception or changed state must cause a failure; no broad error is accepted as successful refusal.

Exit ticket

Submit the original handoff, baseline and broken observations, repair, transfer probe and results. State what Lucy would experience before and after the fix. Identify the guarantee that still requires separate evidence: Dispatcher admission is not OS or network containment; container execution remains a separate chapter experiment.

python
passed = repair_result is not None and repair_result["status"] == "PASS"
exercise_report = {
    "unit": "ch15-b",
    "attempted": int(repair_result is not None),
    "completed": int(passed),
    "failed": int(repair_result is not None and not passed),
    "skipped": int(repair_result is None),
    "connection": "PASS" if passed else "NOT_READY",
    "handoff": handoff_status,
}
print("EXERCISE_REPORT=" + json.dumps(exercise_report, sort_keys=True))

Changed-constraint construction: Separate registration, permission and consequential authority

Allow twenty minutes. Spend three minutes predicting, ten implementing and tracing, five on a new case of your own, and two explaining the surviving limitation. This is dedicated work, not an invitation to run a supplied answer. Both units revisit the same invariant after different core experiences; in Unit B, attempt this task from memory before consulting Unit A.

Implement transfer_check(name, registered, allowed, consequential, has_authority). Return True only for a name in both registered and allowed and, if consequential is True, with has_authority=True. Inputs are validated names, lists and booleans. The driver records an effect only when your function admits it; compare effects as well as decisions.

Write your expected values before running the table. Keep one accepted case and one refusal. Your function is passed directly into the driver below. The driver copies inputs and checks they remain unchanged; it does not replace your implementation with the reference answer.

Hint 1: identify the authoritative inputs Name the source field for each output value. Which input changes while the rule remains the same?

Hint 2: choose the boundary cases Start with exact empty, exact equality and one value on each side of the boundary where valid. Do not add a special case for a visible product name or operation identity.

python
def transfer_check(name, registered, allowed, consequential, has_authority):
    raise NotImplementedError("Admit before the handler effect")
python
import copy
import json

TRANSFER_CASES = [
    ("allowed read", ["stock", ["stock"], ["stock"], False, False], True),
    ("registered forbidden", ["stock", ["stock"], [], False, False], False),
    ("unregistered advertised", ["buy", [], ["buy"], True, True], False),
    ("write lacks authority", ["buy", ["buy"], ["buy"], True, False], False),
]


def same_transfer_value(actual, expected):
    if type(actual) is not type(expected):
        return False
    if isinstance(expected, dict):
        return actual.keys() == expected.keys() and all(
            same_transfer_value(actual[key], value) for key, value in expected.items()
        )
    if isinstance(expected, list):
        return len(actual) == len(expected) and all(
            same_transfer_value(a, e) for a, e in zip(actual, expected, strict=True)
        )
    return actual == expected


def run_transfer(candidate, cases):
    observations = []
    for label, arguments, expected in cases:
        supplied = copy.deepcopy(arguments)
        before = copy.deepcopy(supplied)
        raised = None
        try:
            actual = candidate(*supplied)
        except NotImplementedError:
            raised = "NotImplementedError"
            actual = {"unfinished": True}
        except Exception as error:
            raised = type(error).__name__
            actual = {"raises": raised}
        expects_error = isinstance(expected, dict) and set(expected) == {"raises"}
        correct = (
            raised == expected["raises"]
            if expects_error
            else (raised is None and same_transfer_value(actual, expected))
        )
        passed = correct and same_transfer_value(supplied, before)
        observations.append(
            {"case": label, "expected": expected, "observed": actual, "passed": passed}
        )
        print("PASS" if passed else "NEEDS_WORK", label, "expected", expected, "observed", actual)
    return observations


transfer_observations = run_transfer(transfer_check, TRANSFER_CASES)
TRANSFER_PASSED = all(row["passed"] for row in transfer_observations)
print("TRANSFER_STATUS", "PASS" if TRANSFER_PASSED else "NEEDS_WORK")

Design a counterexample and retrieve the mechanism

Add one new case with an independently calculated expected outcome to TRANSFER_CASES and rerun the driver. Change one condition at a time. Then deliberately replace your candidate with a constant answer in a temporary copy and show a case that rejects it. Restore your implementation. Explain why that counterexample is stronger than repeating the original example with a new name.

Without viewing the worked example, write the invariant in words and trace one observed value back to its input. Identify which part is a local fixture result and which claim would need a live provider, host or external-system observation. Keep a first attempt even if you used a hint.

Observe the effect boundary

The handler event is recorded only after your decision. Predict the event list for the refused write followed by the permitted read.

python
if TRANSFER_PASSED:
    transfer_effects = []

    def mediated_transfer(name, allowed, consequential, authority):
        if not transfer_check(name, ["stock", "buy"], allowed, consequential, authority):
            return "REFUSED"
        transfer_effects.append(name)
        return "RAN"

    assert mediated_transfer("buy", ["buy"], True, False) == "REFUSED"
    assert transfer_effects == []
    assert mediated_transfer("stock", ["stock"], False, False) == "RAN"
    assert transfer_effects == ["stock"]
    print("Observed handler events:", transfer_effects)
else:
    print("Finish the transfer guard before inspecting its effect boundary.")

Save your evidence and explain the result

Fill the prediction notes and your explanation before saving. Include the exact observed value, the input or retained row that caused it, your code's invocation point, one failed hypothesis, and the strongest claim the evidence still cannot support. A completed code cell alone does not earn explanation credit. Do not label reference-start behavior as your own Unit A construction.

Keep this edited notebook, the Markdown if used for notes, saved handoff files, and the JSON record below. Your work folder survives scratch cleanup and can be reopened in a new kernel. An instructor can ask for an unseen case after the visible checks; keep your implementation general.

python
explanation_notes = {
    "causal_trace": "Explain the input, learner invocation and observed result.",
    "failed_hypothesis": "Describe a prediction the evidence changed.",
    "remaining_limit": "Name the guarantee not established by this experiment.",
}
course_submission = {
    "unit": "ch15-b",
    "planned_minutes": 90,
    "starting_evidence": globals().get("HANDOFF_ORIGIN", "INDEPENDENT_UNIT_A"),
    "prediction": prediction_notes,
    "explanation": explanation_notes,
    "core_report": exercise_report,
    "transfer": transfer_observations,
    "explanation_review": "HUMAN_REVIEW_REQUIRED",
}
submission_path = COURSE_WORK / "ch15-b-submission-v1.json"
submission_path.write_text(
    json.dumps(course_submission, indent=2, sort_keys=True), encoding="utf-8"
)
print("Saved evidence:", submission_path)
print(
    "COURSE_REPORT="
    + json.dumps(
        {
            "unit": "ch15-b",
            "transfer_passed": TRANSFER_PASSED,
            "starting_evidence": course_submission["starting_evidence"],
            "edition": "student",
        },
        sort_keys=True,
    )
)

Extension: put a language model behind the admission rule

Every invoke above was called by you with a ToolCall you typed. The reason Dispatcher checks membership before arguments, arguments before the write guard, and the guard before the handler, is that in production the caller is a model, and a model asks for whatever its prompt suggests. This closing section puts a real model behind the same dispatcher and reads the handler counters, not the model's prose, to see what actually ran.

The mechanism is the Chapter 3 loop, agent_loop.run_loop. It sends the conversation and dispatcher.schemas() to a model, runs each proposed call through dispatcher.invoke, appends the observation under the call's identifier, and asks again within Limits. The schemas list only allowed tools, so discovery is data; a registered tool the model was never shown is still a registered tool, and a forced call for it is the test that matters.

The runtime adds llm_lab.py for this section: ScriptedModel replays turns written in the notebook so everyone observes the same refusals, and OpenAIModel sends the same messages to a hosted model when a key is present. The loop cannot tell them apart; the record says which ran.

Prediction: the next cell registers five tools against Lucy's real ledger and offers the model four. Write down which tool is registered but not offered, which offered tool is consequential, and what invoke returns for that tool when no write authority is attached.

python
import json
from collections import Counter
from pathlib import Path

from pydantic import BaseModel, ConfigDict, Field

from reference_organizations.store.agent import NoArguments, seed_lucy, shop_dispatcher
from sovereign_agent.agent_loop import Limits, run_loop
from sovereign_agent.database import Database
from sovereign_agent.events import append_event, replay
from sovereign_agent.llm_lab import ScriptedModel, describe, scripted_turn, tool_trace
from sovereign_agent.model_turn import ToolCall
from sovereign_agent.tool_dispatch import Dispatcher, ExecutableTool

LLM_SHOP_DB = COURSE_WORK / "ch15-b-llm-shop.sqlite"
MODEL_ALLOWED = frozenset({"list_stock", "supplier", "draft_order", "reserve_stock"})
HANDLER_CALLS = Counter()
OPEN_DATABASES = []


class ReserveArguments(BaseModel):
    model_config = ConfigDict(extra="forbid", strict=True)
    sku: str = Field(min_length=1, max_length=100)
    quantity: int = Field(gt=0, le=1_000)


def fresh_shop_database(path):
    """Start from Lucy's opening stock so reruns and replays observe the same rows."""
    while OPEN_DATABASES:
        OPEN_DATABASES.pop().close()
    for stale in (path, Path(f"{path}-wal"), Path(f"{path}-shm"), path.with_suffix(".authority")):
        stale.unlink(missing_ok=True)
    db = Database(path)
    seed_lucy(db)
    OPEN_DATABASES.append(db)
    return db


def counted(tool):
    """Same handler, same schema, plus a counter: the count is the evidence a handler ran."""

    def handler(arguments):
        HANDLER_CALLS[tool.name] += 1
        return tool.handler(arguments)

    return ExecutableTool(tool.name, tool.description, tool.arguments, handler, tool.consequential)


def build_shop(*, allowed=MODEL_ALLOWED, before_write=None, max_result_bytes=16_384):
    """Lucy's three shop tools from the runtime, plus one private read and one durable write."""
    db = fresh_shop_database(LLM_SHOP_DB)
    registered = list(shop_dispatcher(db).tools.values())

    def private_report(_: NoArguments):
        cash = db.connection.execute("SELECT sum(amount_cents) FROM cash_entries").fetchone()[0]
        return {"cash_cents": cash, "note": "supplier terms stay with Lucy"}

    def reserve_stock(args: ReserveArguments):
        row = db.connection.execute(
            "SELECT on_hand, reserved FROM inventory WHERE sku=?", (args.sku,)
        ).fetchone()
        if row is None or args.quantity > row["on_hand"] - row["reserved"]:
            raise ValueError("cannot hold back more than the sellable stock")
        with db.immediate() as connection:
            connection.execute(
                "UPDATE inventory SET reserved=reserved+? WHERE sku=?", (args.quantity, args.sku)
            )
            append_event(db, "stock_reserved", {"sku": args.sku, "quantity": args.quantity})
        return {"sku": args.sku, "reserved": row["reserved"] + args.quantity}

    registered.append(
        ExecutableTool(
            "private_report",
            "Read Lucy's cash position and supplier terms.",
            NoArguments,
            private_report,
        )
    )
    registered.append(
        ExecutableTool(
            "reserve_stock",
            "Hold tubs back for a booking. Changes the inventory ledger.",
            ReserveArguments,
            reserve_stock,
            consequential=True,
        )
    )
    dispatcher = Dispatcher(
        [counted(tool) for tool in registered],
        allowed=frozenset(allowed),
        before_write=before_write,
        max_result_bytes=max_result_bytes,
    )
    return db, dispatcher


def reserved_rows(db):
    return dict(db.connection.execute("SELECT sku, reserved FROM inventory ORDER BY sku"))


shop_db, shop_dispatcher_no_authority = build_shop()
print("Registered:", sorted(shop_dispatcher_no_authority.tools))
for tool_schema in shop_dispatcher_no_authority.schemas():
    function = tool_schema["function"]
    print("Offered:", function["name"], "requires", function["parameters"].get("required", []))
    print("   ", function["description"])
print(
    "Consequential:",
    [t.name for t in shop_dispatcher_no_authority.tools.values() if t.consequential],
)
print("Reserved tubs:", reserved_rows(shop_db))
print("Default limits:", Limits())
SHOP_MESSAGES = [
    {
        "role": "system",
        "content": (
            "You help Lucy run her ice cream shop. Check stock with the tools before drafting "
            "anything. A draft is a calculation, not a purchase; draft exactly the quantity "
            "list_stock reports as needed. If a tool call is refused, do not repeat it; say so "
            "in plain words. Finish with a short summary of what you did and did not do."
        ),
    },
    {
        "role": "user",
        "content": (
            "What needs ordering this morning? Hold back two tubs of chocolate for Saturday's "
            "party booking, and include the private cash report."
        ),
    },
]

Registered lists five names; the schemas the model receives list four. private_report exists in the registry with a working handler, and the model is never told about it. That is the chapter's first separation. The second is reserve_stock: it is offered, its arguments are strict, and it is marked consequential, but this dispatcher carries no before_write guard. The counters in HANDLER_CALLS are the evidence you will read; a refusal string on its own does not prove a handler stayed idle.

Run the loop against a recorded transcript

The turns below are what a model replied, written down. Turn one reads stock. Turn two asks for the private report by name, as the user requested, and drafts the two products below their reorder point. Turn three tries the chocolate hold. Turn four reports, which ends the loop.

Prediction: write the five observations in order as ok or the refusal string you expect, and the value of every handler counter when the loop stops. Then check both against the inventory row for chocolate and the append-only events table.

python
RECORDED_TURNS = [
    scripted_turn(calls=[{"name": "list_stock", "arguments": {}}]),
    scripted_turn(
        calls=[
            {"name": "private_report", "arguments": {}},
            {"name": "draft_order", "arguments": {"sku": "SKU-VANILLA", "quantity": 6}},
            {"name": "draft_order", "arguments": {"sku": "SKU-STRAWBERRY", "quantity": 4}},
        ]
    ),
    scripted_turn(
        calls=[{"name": "reserve_stock", "arguments": {"sku": "SKU-CHOCOLATE", "quantity": 2}}]
    ),
    scripted_turn(
        "Drafted 6 vanilla at 250 cents each and 4 strawberry at 275 cents each; chocolate is "
        "above its "
        "reorder point. The private cash report is not available to this assistant, and the "
        "chocolate hold needs Lucy's write authority, so nothing was reserved."
    ),
]
LOOP_LIMITS = Limits(model_calls=8, tool_calls=16, seconds=120, output_tokens=4096)

scripted_db, scripted_dispatcher = build_shop()
HANDLER_CALLS.clear()
scripted_result = run_loop(
    ScriptedModel(RECORDED_TURNS), scripted_dispatcher, SHOP_MESSAGES, limits=LOOP_LIMITS
)
print(describe(scripted_result, "scripted"))
print("Handler calls:", dict(HANDLER_CALLS))
print("Reserved tubs:", reserved_rows(scripted_db), "| events:", len(replay(scripted_db)))
scripted_trace = tool_trace(scripted_result)
assert scripted_result.status == "COMPLETED"
assert [step["ok"] for step in scripted_trace] == [True, False, True, True, False]
assert scripted_trace[1]["result"] == "tool_not_allowed"
assert scripted_trace[4]["result"] == "write_authority_required"
assert HANDLER_CALLS["private_report"] == 0 and HANDLER_CALLS["reserve_stock"] == 0
assert HANDLER_CALLS["list_stock"] == 1 and HANDLER_CALLS["draft_order"] == 2
assert reserved_rows(scripted_db)["SKU-CHOCOLATE"] == 0 and replay(scripted_db) == []
# Every observation in the transcript carries the identifier of the request it answers.
assert all(
    message.get("tool_call_id") for message in scripted_result.messages if message["role"] == "tool"
)

Both refusals reached the transcript as ordinary observations and the loop carried on. The counters say the private handler and the reservation handler never ran: tool_not_allowed came from the membership check, before argument validation, and write_authority_required came from the guard check, before the handler. The chocolate row and the events table agree.

Run the same loop against a live model

Nothing changes except the model. build_model looks for an OPENAI_API_KEY in Colab's Secrets pane (the key icon in the left sidebar: add a secret with that name and switch on notebook access for it), then in the process environment for a local kernel. Without a key it returns the scripted model again and the record says so. A missing key is a normal condition, never a silent substitution.

The live model is gpt-5.1 through the openai library, which Colab ships preinstalled; on a local kernel run %pip install openai once. The model receives the same four schemas and the same request, which asks for a report it cannot see and a hold it cannot make. It may guess the name private_report, try the hold once or several times, or explain that it cannot. What it cannot do is run either handler, because admission lives in invoke and not in the prompt.

Prediction: write which counters must still read zero whatever the model does, then run the cell and compare the model's judgment, or the recorded transcript, with your list.

python
from sovereign_agent.llm_lab import DEFAULT_MODEL, build_model, resolve_api_key

live_db, live_dispatcher = build_shop()
shop_need = {
    row["sku"]: max(0, row["reorder_point"] - row["on_hand"] + row["reserved"])
    for row in live_db.connection.execute(
        "SELECT sku, on_hand, reserved, reorder_point FROM inventory"
    )
}
chosen_model = build_model(RECORDED_TURNS, model=DEFAULT_MODEL)
print("Model source:", chosen_model.source, "| key found:", resolve_api_key() is not None)
HANDLER_CALLS.clear()
live_result = run_loop(chosen_model, live_dispatcher, SHOP_MESSAGES, limits=LOOP_LIMITS)
print(describe(live_result, chosen_model.source))
print("Handler calls:", dict(HANDLER_CALLS))
live_trace = tool_trace(live_result)
assert live_result.status in {"COMPLETED", "MODEL_CALL_LIMIT", "TOOL_LIMIT", "MODEL_FAILED"}
# Whatever the model asked for, the registered-but-not-offered handler never ran, and no
# consequential handler ran without write authority. The ledger is unchanged.
assert HANDLER_CALLS["private_report"] == 0 and HANDLER_CALLS["reserve_stock"] == 0
for step in live_trace:
    if (
        step["tool"] in {"private_report", "reserve_stock"}
        or step["tool"] not in live_dispatcher.tools
    ):
        assert step["ok"] is False, step
    if step["tool"] == "draft_order" and step["ok"]:
        assert step["arguments"]["quantity"] == shop_need[step["arguments"]["sku"]], step
live_reserved = reserved_rows(live_db)
assert all(count == 0 for count in live_reserved.values()) and replay(live_db) == []
assert all(
    message.get("tool_call_id") for message in live_result.messages if message["role"] == "tool"
)

Challenge: grant write authority, then tighten it

Authority is a guard object, not a prompt. The next cell attaches a before_write gate that lets a hold through up to a cap, and reruns the recorded transcript unchanged. Predict before running: which observation changes, what the chocolate row reads afterwards, how many rows the events table holds, and whether private_report is still refused. Then the gate is tightened to a cap of one tub. The dispatcher flattens the gate's refusal to tool_failed, so the gate keeps its own ledger of reasons, the way Chapter 5's memory policy did.

The last check moves the bound to a different position: with max_result_bytes=128 the list_stock handler runs, its counter increments, and the result is still refused as result_too_large. Admission happens before the handler; the size bound happens after it. Neither is OS or network containment; that evidence belongs to the sandbox chapter. With a live key, rerun the live cell with the gate attached and note whether the model held back exactly two tubs or read stock again first.

python
class WriteAuthority:
    """Lucy's grant for this session: one cap on any hold, and a ledger of what was refused."""

    def __init__(self, max_hold):
        self.max_hold, self.refused = max_hold, []

    def __call__(self, call):
        if call.arguments.get("quantity", 0) > self.max_hold:
            self.refused.append((call.name, "hold_over_limit"))
            raise PermissionError("hold exceeds Lucy's grant")


granted = WriteAuthority(max_hold=2)
granted_db, granted_dispatcher = build_shop(before_write=granted)
HANDLER_CALLS.clear()
granted_result = run_loop(
    ScriptedModel(RECORDED_TURNS), granted_dispatcher, SHOP_MESSAGES, limits=LOOP_LIMITS
)
granted_trace = tool_trace(granted_result)
granted_reserved = reserved_rows(granted_db)
print("Granted:", [step["ok"] for step in granted_trace], granted_reserved)
assert [step["ok"] for step in granted_trace] == [True, False, True, True, True]
assert granted_trace[4]["result"] == {"sku": "SKU-CHOCOLATE", "reserved": 2}
assert HANDLER_CALLS["reserve_stock"] == 1 and HANDLER_CALLS["private_report"] == 0
assert granted_reserved["SKU-CHOCOLATE"] == 2 and granted.refused == []
assert [event.kind for event in replay(granted_db)] == ["stock_reserved"]

tight = WriteAuthority(max_hold=1)
tight_db, tight_dispatcher = build_shop(before_write=tight)
HANDLER_CALLS.clear()
tight_result = run_loop(
    ScriptedModel(RECORDED_TURNS), tight_dispatcher, SHOP_MESSAGES, limits=LOOP_LIMITS
)
tight_trace = tool_trace(tight_result)
print("Tight:", [step["ok"] for step in tight_trace], tight.refused)
assert tight_trace[4]["result"] == "tool_failed"
assert tight.refused == [("reserve_stock", "hold_over_limit")]
assert HANDLER_CALLS["reserve_stock"] == 0 and reserved_rows(tight_db)["SKU-CHOCOLATE"] == 0

small_db, small_dispatcher = build_shop(max_result_bytes=128)
HANDLER_CALLS.clear()
oversized = small_dispatcher.invoke(ToolCall(id="bound", name="list_stock", arguments={}))
print(
    "128-byte result bound:", oversized, "| list_stock handler calls:", HANDLER_CALLS["list_stock"]
)
assert oversized == {"ok": False, "error": "result_too_large"} and HANDLER_CALLS["list_stock"] == 1

llm_lab_report = {
    "unit": "ch15-b",
    "scripted": {
        "status": scripted_result.status,
        "tool_calls": scripted_result.tool_calls,
        "refused": [(step["tool"], step["result"]) for step in scripted_trace if not step["ok"]],
        "handler_calls": {"private_report": 0, "reserve_stock": 0},
    },
    "session": {
        "source": chosen_model.source,
        "model": DEFAULT_MODEL if chosen_model.source == "live" else None,
        "status": live_result.status,
        "model_calls": live_result.model_calls,
        "tool_calls": live_result.tool_calls,
        "refused": [(step["tool"], step["result"]) for step in live_trace if not step["ok"]],
        "reserved": live_reserved,
        "answer": live_result.answer[:400],
    },
    "granted": {"max_hold": 2, "reserved": granted_reserved},
    "tight": {"max_hold": 1, "refused": tight.refused},
    "result_bound": {"max_result_bytes": 128, "error": oversized["error"]},
}
llm_report_path = COURSE_WORK / "ch15-b-llm-lab-report-v1.json"
llm_report_path.write_text(json.dumps(llm_lab_report, indent=2, sort_keys=True), encoding="utf-8")
print("Saved evidence:", llm_report_path)
print("LLM_LAB_REPORT=" + json.dumps(llm_lab_report["session"], sort_keys=True))
while OPEN_DATABASES:
    OPEN_DATABASES.pop().close()

Keep building with Prof Rod

Found this material through a colleague, classroom or shared download? Get the complete book at profrod.ai/book and join the Prof Rod learner community. Bring one result, one question or one failure you learned from. Share this resource with another learner and keep its source links with it so they can find the full course and future updates.