Skip to content
Harness Engineering2026-09-1112 min read

Notebook to AI Application: Build a Callable Contract

Turn a working notebook cell into a callable contract with declared inputs, rejected cases and stable outputs before another service depends on it.

Key takeaways

  • A notebook cell that runs once with hand-built data has not defined accepted inputs, rejected inputs or stable outputs.
  • A callable contract separates interpretation, validation, business rules and authorization into distinct checks with distinct evidence.
  • Strict typing rejects Boolean, float and numeric-string quantities that a permissive validator would silently coerce.
  • A thin request adapter can reintroduce coercion that a local function never had, so both paths need the same expected-results table.
  • Passing validation proves declared shape and type, never catalog truth, inventory accuracy or permission to act.

Rod Rivera

Author

Notebook to AI Application: Build a Callable Contract

Rod's note — read with a pencil; the margins are for you.

The cell that works is not the function that ships

You have a notebook cell that takes an order line, checks it looks right, and prints a tidy result. It works. You call it twice more with values you typed yourself, and it works both times. The temptation, reasonable and common, is to wrap that cell in a function, give it a name, and hand it to a colleague who wants to call it from a web service. The colleague asks a question you did not expect: what happens when the caller sends a quantity as "6" instead of 6? You do not know, because you never sent that value. The cell was tested against inputs you chose, not inputs an external caller will actually produce.

That gap between "ran once with data I built" and "callable contract another service can depend on" is the whole subject here. A contract is not a nicer word for a function. It is a declared boundary: which inputs are accepted, which are rejected and why, and what shape the output takes when the call succeeds. Until those three things are written down and checked, a notebook result is a demonstration, not an interface.

What a contract has to name

Three commitments make up a callable contract, and each one answers a different question.

Accepted inputs. What field names, types and ranges will the function process without complaint? A quantity field typed as an integer between 1 and 50 is a commitment, not a suggestion. If the sender provides 51, the function must refuse before doing any work, not clamp the value quietly.

Rejected inputs, with a stable reason. A caller building against your function needs to know not just that 0 fails, but that it fails because quantities must be positive, distinguishable from a failure because the field was missing entirely. Missing and null are different facts. A field that can be omitted behaves differently from a field that must be present but may hold None. Collapsing that distinction into a single generic "bad request" forces every caller to guess.

Stable output shape. When the call succeeds, what does the caller receive? Same field names, same types, every time, regardless of which code path produced the answer. If a local function returns a Python integer and a web adapter for the same logic returns a JSON string, you have two contracts wearing one name.

The teaching example that anchors this, a strict Pydantic model rejecting Boolean, float, and numeric-string values for an integer quantity field, comes from an existing course lesson on runtime contracts (course lesson). That lesson builds the validation boundary; this article extends it into the boundary between a local function and something calling it over a network, which is the part a notebook rarely tests.

A worked contract: validate_line

Take a single order line: a SKU and a quantity. The notebook version might look like this, reusing the strict-mode approach from that lesson.

python
from pydantic import BaseModel, ConfigDict, Field, ValidationError
from typing import Union

class Contract(BaseModel):
    model_config = ConfigDict(strict=True, extra="forbid")

class OrderLine(Contract):
    sku: str = Field(min_length=1, max_length=40)
    quantity: int = Field(ge=1, le=50)

class RejectedLine(BaseModel):
    error_code: str
    field: str
    detail: str

def validate_line(payload: dict) -> Union[OrderLine, RejectedLine]:
    try:
        return OrderLine.model_validate(payload)
    except ValidationError as exc:
        first = exc.errors(include_input=False, include_context=False)[0]
        return RejectedLine(
            error_code=first["type"],
            field=".".join(str(part) for part in first["loc"]),
            detail="quantity or sku failed validation",
        )

Run this with {"sku": "vanilla", "quantity": 6} and you get back an OrderLine with an integer quantity of 6. Run it with {"sku": "vanilla", "quantity": "6"} under strict mode and you get a RejectedLine naming the quantity field and an error code describing the type mismatch, because strict mode does not convert a numeric string into an integer. That refusal is a design decision worth stating plainly: the sender is expected to produce the correct type, not for the receiver to guess it.

This function is now a contract in the sense that matters. It returns one of exactly two typed shapes, never a bare dictionary, and converts Pydantic validation failures into the rejection shape. Programming errors and resource failures can still raise exceptions. Pin the dependency version and test the error codes before treating Pydantic's codes as a permanent external API.

Where the contract breaks on the way to a service

Here is the part a notebook usually never exercises: the same logic, reached through an adapter that simulates an HTTP boundary. Below is a toy in-memory adapter, standard library only, that plays the role of "the network" for teaching purposes. It is not a claim about any deployed server, and its counts describe only calls made inside this one Python process.

python
import json

def request_adapter(raw_body: str) -> str:
    """Toy stand-in for an HTTP handler: decode JSON, call validate_line,
    encode the result back to JSON. Runs in-process only."""
    try:
        payload = json.loads(raw_body)
    except json.JSONDecodeError:
        return RejectedLine(error_code="invalid_json", field="body",
                            detail="body must be valid JSON").model_dump_json()
    result = validate_line(payload)
    return result.model_dump_json()

good = request_adapter('{"sku": "vanilla", "quantity": 6}')
coerced_risk = request_adapter('{"sku": "vanilla", "quantity": "6"}')

print(good)
print(coerced_risk)

The integer call returns the accepted order line; the string call returns RejectedLine, matching their respective direct calls. That agreement is the point of the exercise, and it is worth stating exactly what it proves and what it does not. It proves that, within this process, the adapter and the direct function call reach the same typed decision for the same JSON body. It does not prove anything about a real HTTP server, a real client library's JSON encoding quirks, or a production deployment's serialization settings, because none of those exist in this example. A counted result from an in-memory loop tells you about that loop.

The risk this experiment is built to catch is a different one: an adapter written independently of the validation function, one that parses the incoming JSON loosely and coerces "6" to 6 before calling into the validator, defeating the strict check upstream. That bug is easy to introduce when an adapter adds a coercion step. JSON does distinguish numbers from strings: 6 and "6" are different values. A hand-rolled converter can erase that difference without anyone noticing until a caller sends malformed data that the contract was supposed to catch.

Where a quantity contract can be silently rewritten

The failure mode lives at the arrow from "Adapter parses body" to "validate_line checks type": if the adapter coerces the value during parsing, the validator never sees the original string and cannot reject it. The fix is not more validation code. It is making sure exactly one function owns the type decision, and every adapter, local or networked, calls that same function with the untouched payload.

An in-memory pass does not certify a live server

Running request_adapter a thousand times in one script and counting agreements tells you the logic is internally consistent. It says nothing about a deployed API, a load balancer, a client SDK's serialization defaults, or what happens under concurrent requests against real storage. Treat this experiment as a design check, not a production guarantee.

Building the expected-results table

Before wiring any real transport, write the table you expect every implementation to satisfy. This is cheap to build and expensive to skip.

Input quantityInput typeExpected outcomeReason
6integeraccepted, quantity 6within range, correct type
"6"stringrejected, field quantitystrict mode does not coerce
Truebooleanrejected, field quantityPython treats bool as int subtype; strict mode still rejects
6.0floatrejected, field quantityfloat is not the declared int type
0integerrejected, field quantitybelow minimum of 1
51integerrejected, field quantityabove maximum of 50
missingabsentrejected, field quantityrequired field not supplied
None / JSON nullnullrejected, field quantityfield does not accept null

Run that table against both paths. The expected codes below are Pydantic v2 codes; the article's adapter leaves JSON values untouched.

python
cases = [
    ({"sku": "vanilla", "quantity": 6}, None),
    ({"sku": "vanilla", "quantity": "6"}, "int_type"),
    ({"sku": "vanilla", "quantity": True}, "int_type"),
    ({"sku": "vanilla", "quantity": 6.0}, "int_type"),
    ({"sku": "vanilla", "quantity": 0}, "greater_than_equal"),
    ({"sku": "vanilla", "quantity": 51}, "less_than_equal"),
    ({"sku": "vanilla"}, "missing"),
    ({"sku": "vanilla", "quantity": None}, "int_type"),
]
for payload, expected_error in cases:
    direct = validate_line(payload).model_dump()
    adapted = json.loads(request_adapter(json.dumps(payload)))
    assert direct == adapted
    assert direct.get("error_code") == expected_error
assert json.loads(request_adapter("{"))["error_code"] == "invalid_json"
print("Eight typed cases agree; malformed JSON has a declared rejection")

Every row checks a declared outcome, in addition to agreement between two paths. Any row where they disagree is a regression, full stop, regardless of whether the disagreement looks harmless. A local function returning 6 as an integer while an adapter returns "6" as a string is exactly the kind of quiet contract drift that a caller several weeks later discovers the hard way, usually while debugging an arithmetic error that turns 6 * 2 into "66".

Extract the accepted shape

Write down the field names, types and bounds your function actually enforces, not the ones you assume it enforces. Read them off the validation model, not from memory.

Write the rejection table first

Before adding new fields or adapters, list every input you expect to fail and the specific reason. Boolean, float, numeric-string and out-of-range values each deserve a separate row.

Run the same table through every entry point

Call the local function and any adapter with the identical payloads. A disagreement on any row is the defect to fix, not the table.

Document the stable output shape

State what a caller receives on success and on rejection, including field names for the error. This is what makes the contract callable rather than merely internally correct.

Direct validation and the JSON adapter are compared with independently declared expected outcomes

Where responsibility ends

Passing this contract establishes something specific and nothing more. It confirms the payload has the declared shape, the declared types, within the declared ranges. It does not confirm that vanilla exists in a catalog, that six tubs are actually in stock, or that whichever identity sent the request is authorized to place an order. Those are separate checks against separate evidence: a stock snapshot, a permissions table, a business rule about today's shortages. Anthropic's engineering notes on writing tools for agents make a related point about tool design generally: how a tool describes its inputs, outputs and failure modes shapes how reliably an agent uses it, which is a reason to keep those descriptions explicit rather than implied (Anthropic: writing tools for agents). A contract that only says "returns an order line" without saying what a rejection looks like is under-specified in exactly this way.

The strictness itself is also narrower than it might first appear. Pydantic's own documentation on strict mode notes that coercion behavior differs by type, and that JSON input handling for cases such as dates carries its own exceptions (Pydantic: strict mode). The integer example worked through here does not generalize automatically to every field type your contract might need; check the specific type and the installed library version before assuming a new field behaves the same way a quantity field does.

Quick check — A notebook cell accepts one hand-built order line and prints a correct result. What is still missing before another service can call it safely?

Check the decision

What would prove this wrong

If you ran the expected-results table against a real deployed service, not the in-memory adapter used here, and found that the network path silently converted the string quantity into an integer while the local function still rejected it, that would falsify the claim that "the same validate_line function guarantees the same behavior everywhere." It would mean something between the caller and your function, a framework's request parsing, a serialization library, a proxy, is rewriting the payload before your validator sees it. That is a real and common failure, and finding it is exactly why the table needs to run against the actual entry point your callers will use, not just the function in isolation. Nothing in this article claims that check has been run against a production system; the fixtures described here are proposed and bounded to a single process, and confirming them against a real deployment is the next piece of work, not something already demonstrated.

The usable next action is small: take one function you currently trust because it ran correctly once, write its rejection table with at least five deliberately wrong inputs, and run that table through every way a caller can reach the function. Where the outputs disagree, you have found the actual boundary of your contract, which is more useful than the boundary you assumed it had.

Continue with runtime contracts and bounded repair loops

Continue with the course lesson that builds the strict validation model this article extends into a callable, multi-adapter contract.

Ready to put an agent to work?

Join the Prof Rod newsletter for one educational lesson a week, with worked examples attached. It is free to register for and separate from the Zero Employee community.