Contracts and checks
Know which layer guarantees what — the compiler, the definition calls, the boot, the runtime, and your own tests each catch a different failure.
Here is an incident shape that hides behind a green build. A wrapper synthesizes a friendly "something went wrong" message when every provider fails — as a normal, successful stream. Downstream code then tells failure from answer by string-matching English sentences. Every layer type-checked. The bug was never in any one function; it was in believing the compiler guaranteed something it never claimed to.
Keel spreads its guarantees across five layers on purpose, and knowing which layer catches what tells you where to look when something refuses:
- TypeScript checks API shapes while you code,
- definition calls (
defineAgent,asTool, …) check declared relationships when they execute, - boot (
createRuntime) checks the budget manifest before traffic, - the runtime validates every border a live turn crosses,
- your tests assert your application's behavior — the only layer that checks meaning.
What TypeScript checks
The compiler's flagship job in keel is the outcome. A turn settles as one value of a closed union, and the consequence rides along as a field of the variant:
completedpayload · proof · deliverabletruncatedat · continuation {rounds, max}cutby · retryable: truestoppedby: 'user'failedcause: Defect · refunded: true| Contract | What it helps you do |
|---|---|
TurnOutcome union | narrow on kind before reading payload or cause — an unhandled case is a compile error |
DeliveredTurnOutcome<T> | read deliverable.data as T on completed — the close gate minted it, no null check, no cast |
BlockReader / BlockWriter union | check isWriter before calling apply |
| zod-inferred block state | write reducers and readers against the state shape |
TurnBudget | supply the required steps field |
The string-matching from the opening story has no keel equivalent: an
exhausted chain settles as failed with cause: chain-exhausted and
refunded: true — a variant your switch must handle, not a sentence to
grep for.
Know the limits too: apply(event, payload) accepts a string and an
unknown payload, and a tool's execute receives unknown. Those APIs do
not infer event- or tool-specific arguments for you.
What definition calls check
These are executable checks — they run when your code calls the function, which is why the composition root constructs everything at boot. A TypeScript pass alone never executes an unused agent factory.
| API | Rejected when the call executes |
|---|---|
defineBlock | no readers are declared |
defineAgent | a requested reader is missing |
defineAgent | a tool feeds a block outside writes, or names a missing reducer |
defineAgent | tools are declared but any chain tier lacks tool support — one text-only fallback is enough to reject |
defineTool | access is omitted, or a long tool has no liveness declaration |
asTool | the delegation has no step budget |
asTool | the deliverable part is missing from the child's emits, or is ephemeral |
defineChain | there are no tiers |
definePart | a name is duplicated, reserved, or not a valid marker tag |
Fixtures are a test-time concern
definePart does not verify fixtures. Call registry.checkFixtures() in
a test and assert each result's ok — that is the check that keeps old
payloads parseable forever.
What boot and the runtime check
createRuntime refuses to start on a missing budget-manifest entry or a
wall-clock ceiling that does not sit under the declared platform ceiling.
Once a turn runs, every border is validated where it is crossed:
| Boundary | Check |
|---|---|
| request → root turn | envelope schema, size cap, ambient scope against the app's declared schema (ambient-invalid), one active root per thread |
| opening context → model | estimated total plus output reserve fits the window |
| model → tool | tool exists, access is allowed, input parses |
| tool → block | declared write target, schema-valid reducer output |
| marker → custom part | registered name, schema-valid JSON |
| turn → caller | every defect settles as exactly one typed outcome, however execution ended |
Be precise about what "one typed outcome" covers: every production defect
settles as a typed outcome, while framework misuse — a KeelError from a
definition or boot check, an unregistered deliverable part — still throws,
because it is a bug in your composition, not a runtime condition. Paths that
previously escaped as raw exceptions are now typed too: a throwing block
reader, repair, or ambient section settles the turn as failed with the
render-failure defect, and a throwing bus subscriber is contained rather
than propagated.
This is why a perfectly typed tool definition still matters at runtime: the compiler cannot stop a model from inventing an argument. The executor's schema check can — and does, with the reason fed back.
What your application still owns
| Responsibility | How to cover it |
|---|---|
| file naming and import direction | follow project conventions in review |
| truthful prompts and state summaries | review the text; test representative examples |
| trusted identity and tenancy | authenticate requests before constructing ambient context |
| safe repeated external effects | design tool retries for the operation |
| answer quality and completeness | domain-specific tests plus live-model evaluation |
| client compatibility | test fixtures and client renderers when schemas change |
Completed is not correct
A completed outcome proves verifiable work happened — non-empty
substance, a validated deliverable. It does not prove the answer is
factually right. Types and schemas constrain shape; your tests must check
meaning.
Next: Testing — turn expected behavior into executable checks.
