ADR-099: Public contracts, bound to the program
Status: Accepted (2026-09-15, lot 4bis of #1010; harvests #1216 / #1165) Amends: ADR-098 § 16 (the reserved runtime_semantics axis)
Context
A bot has a face the outside reads without opening its prompts: what it takes, what it produces, the files it delivers, the checks that condition what follows, and the effects it has. Today that face is implicit — a parent's subbot guesses it from the child's vars and its last node's schema, the catalogue reads a manifest that repeats it by hand, and the snapshot carries nothing of it. PR #1216 proposed a contract declaration together with a graph of data-flow across nodes, a port_policy, and a runtime_semantics axis to run them under.
ADR-098 § 16 reserved the runtime_semantics axis for that work. Reviewing #1216 against the program found the surface worth keeping and the semantics premature: nothing in the engine runs a data-flow graph, so a graph: would be text the runtime ignores — a contract the program does not keep, displayed as if it did.
The decisive point of the adversarial plan review (31 findings, 0 refuted): a contract that is not checked against the program it describes is worse than none, because every reader — the catalogue, the parent bot, the studio — trusts it.
Decision
One top-level declaration,
contract <name>:, named by the workflow.workflow x:gainscontract: <name>(the property is appended last in the registry so no existing position moves). A contract carriesdisplay_name,responsibility,version,inputs,outputs,criteria,effects— and no prompt, tool or provider setting. A bare header declares an empty contract, like every other named declaration.The contract is bound to the program. The compiler holds it:
- C300 — every input is a declared var of the same name, required exactly when the var has no default and defaulting to what the var defaults to — a port may repeat the var's word, never contradict it; an input is refused a
from:. - C301 — every output names its producer,
from: <node>.<field>, and the field's declared type matches the port's type structurally (a builtin, a schema name,[]cardinality); a file port names its node,from: <node>. - C302 — a criterion names a port that exists (
input.x/output.y), itsparamscompile against the criterion's parameter declaration, and everydefault:andparams:value is one the text can write back (see 4). - C303 (warning) — a criterion whose
kindno shipped evaluator bears is declared and rendered, not evaluated.
- C300 — every input is a declared var of the same name, required exactly when the var has no default and defaulting to what the var defaults to — a port may repeat the var's word, never contradict it; an input is refused a
No
graph, noport_policy, noruntime_semanticsproperty. The axis stays reserved (ADR-098 § 16, amended): its property ships with the semantics that runs under it, and acontract:is legal without it. A future data-flow semantics composes contracts across nodes; it never redefines what a contract is.JSON values are the writable subset. A port's
default:and a criterion'sparams:are JSON, read by the parser and written by the unparser. The text's lexer reads a number as[0-9]+(\.[0-9]+)?— no sign, no exponent — so a value the transport accepts (the studio's JSON carries any number) is refused at C302 byparser.WritableJSONValuebefore the writer ever meets it. Object keys are unique (spec.DecodePublicJSONrefuses a duplicate),nullis explicit and distinct from absence.Criteria are data and a function.
spec.PublicCriteriaships the evaluators (min_length,pattern) with a machine-readable parameter declaration each;CompilePublicCriterionvalidates the parameters (required present, types, no undeclared key) and returns the evaluator. The evaluator set is closed; the declaration surface is open (C303), so a plugin-supplied evaluator is the seam, not a registry edit.The contract travels like every declaration. The JSON mirror (
ast/jsonenc.go) carriescontracts[]and the workflow'scontract, the unparser writes them back, and the AST-level round-trip guard holds text → AST → JSON → AST → text on a witness that uses every property. The port'sfile:block is the fieldFileSpec/ keyfile_spec, becausefileis the provenance key every mirror already carries.A floor, derived.
ContractSinceis the engine version the contract surface ships in; the sites that decide "does this build read this text" derive from the singleRequiredReleasepredicate, so a bundle whoserequires.iterionis older than the floor and that declares a contract is refused by C251, and by nothing else.What a contract is not.
effects:document; they grant nothing — the sandbox, the allow-lists and the verified actions keep the admission. File existence, provenance and immutable publication are the runtime's checks, never an author's assertion. A contract does not replace the manifest; the catalogue reads the contract when one exists.
Consequences
- The
.botgains one declaration kind and one workflow property; no existing text changes meaning (v1 and v2 alike — the profile does not govern it, the floor bounds it). - A contract that lies about its program is a compile error, not a display.
- The dogfood —
feature-devdeclaring its contract — lands in a follow-up PR after the release that carries the floor and the fleet bump, never in the PR that ships the surface (a bot the fleet cannot read is a broken bot, not a demo). - Two bundlelint warnings follow (C254/C255): a manifest that repeats what the contract says, and a
subbotwhosewith:misses a required input of the child's contract. - The runtime trusts the contract too (#1280): a
subbotwhose child keeps one receives the contract's output ports, projected from the child's per-node outputs, on every surface that runs a subbot — C255 holds the parent'soutput:schema to those ports as the runtime truth. A child without a contract keeps the terminal-node output semantics.
Alternatives rejected
- Shipping
graphandport_policyas text only (#1216 as proposed): a declared semantics nothing runs; refused on the "no artificial limitation, no silent promise" stance. - A contract inferred from the program (vars + last node's schema): no place for
responsibility,effects, files or criteria, and the parent still guesses which node's schema is the output. - A contract kept in the manifest (
manifest.yaml): outside the compilation unit, unchecked by the compiler, invisible to the unparser.
