What changed in PACT, newest first. Each entry says what an author can now write, or what now holds that did not; the commits are listed under it for whoever wants the detail.
Running the new checker over Bud Agent Flow's 168 shipped trees refused 29 of them, all with
loader/repeated-with-no-claim or loader/a-key-that-changes-every-attempt. Read one by one,
three of the rule's readings were wider than law 3, and each is narrowed here with a test:
- A
togetheris not repetition by itself. Its branches are different stages, each run once: an email in one branch and a chat message in the other are two calls. What repeats is the same action called from two branches side by side, and that is still refused (two_branches_calling_the_same_write_do_it_twice). The old reading also replaced the context around atogether, so atogetherinside arepeatlost the repeat; it now keeps it (a_together_inside_a_repeat_still_runs_every_round). - The stock memory tools are not held by the rule.
bud/rememberandbud/forgetin arepeatare how a round leaves what it learned for the next (Reflexion's buffer); the memory is the workspace's own (a_round_may_keep_what_it_learned). - A key bound from the round's own output is a warning. The author wrote a claim, and a
retry of one round produces the same output and so the same key. Whether two different
rounds aim at the same thing is an intent the checker cannot see; a rolling plan whose every
round posts a new update is the other reading, and the corpus has nine of them. A call with
no claim at all inside a
repeator a retry is still an error.
The other trees were real defects (writes retried or repeated with no claim) and were fixed in Bud Agent Flow by giving each action the key its business has.
docs/97-PRIMITIVES.md names seven building blocks (Shape, Origin, Store, Actor, Flow, Check,
Allowance) and eight laws, and shows that every PACT keyword and all 203 patterns at
agentic-patterns.com are made of them. Sixteen of the 76 kernel operations had no keyword and
none of the laws was a checker rule. docs/98-PRIMITIVES-ENGINEERING-PLAN.md is the plan that
closed both; its §9 says where the build differs from the plan.
forks: yeson aneach,together,repeator loop stage: each item, branch or attempt starts from a copy, so three votes are three votes (examples/kernel/self-consistency,tree-search).- A board a team shares:
shared-with: the-teamon aremembersentry, gated byadmitted-when:and anadmitted-by:agent that answers yes or no (kernel/blackboard). - A claim across a team, with a lease:
same-request-key-across: the-team | the-workspaceandclaim-lasts:on an action; the host supplies aClaimStore(kernel/work-queue). keeps-taking: yeson aneach(a growing work list), anddoes: stopwithstops: steps.<stage>.runto cancel a run that was started without waiting.detectors:in a workspace, used aslooks-like:in a condition and as "anything that looks like " in redaction and interceptor sentences;gets-hidden-values: yeson an action, so hidden values travel as placeholders and come back only there (kernel/private-by-placeholder).effect:,in-share:andlooks-like:inwhen-this; a person's yes is bound to the exact request they saw (kernel/canary).carries:on a tool andwrites:on an action (kernel/no-leak);rests-after:withrests-for:on a tool (kernel/circuit-breaker).stream of <shape>as an answer shape;cost-per-request-from:a run input, never abovecost-per-request-under:.limits.unbounded: yes, only on a pathowners:lists withowned-by:. Pydantic AI's own loop is now handed the step ceiling too, eight when the author wrote none (law 2, option A).
- The eight laws as
pact checkrules, among themloader/three-things-that-make-a-leak,loader/repeated-with-no-claim,loader/a-helper-allowed-more-than-its-caller,loader/written-from-a-source-it-refusesandloader/unbounded-without-an-owner. Each part's effect class is computed, never declared, andpact discoverprints it. - A change a learning cycle proposes goes through
pact checkbefore it is scored or applied (law 8,checking.py). - A reference workflow runtime in Python (
workflow_runtime.py) runs a workflow stage by stage, so the new stage lines have run-time meaning outside a framework. - The TypeScript port says it neither forks a loop stage, lifts the step ceiling, nor keeps
a team's shared board, on
RunResult.unenforced; the board is still offered under the same name, so both ports show the model the same tools. scripts/pattern-coverage.py --opsreports no kernel operation without a keyword; fifteen workspaces underexamples/kernel/are cited as the shipped form of 61 patterns.
- An agent run on Pydantic AI's own loop now stops at the default step ceiling where it used
to receive none; on PACT's loop nothing changes. A tree that relied on running unmetered there needs an owner's
unbounded: yes.
Running PACT trees in production on Pydantic AI 2.54, through
Bud Agent Flow, drove these changes. Every
addition is checked by pact check (Rust) and held by the Python harness, and the whole suite
runs offline: 4114 tests, 1445 Rust and 2669 adapter.
model-can.ignores,ignores-while-thinking,thinks-unless-toldon a catalogue row, read from the Pydantic AI code this repository pins and recorded for GPT-5.5, GPT-5.4, GPT-5.4 mini, Claude Opus 5 and Claude Sonnet 5.pact checkrefuses asettings:line the pinned model would throw away (settings.rs:loader/a-setting-the-model-ignores,loader/a-setting-ignored-while-it-thinks) —temperature: 0.2on GPT-5.5 with nothinking: none,top-k:on any OpenAI model, any sampling on Claude Opus 5. Bud Agent Flow'schoose-modeland its bind-time check hold candidates and bindings to the same reading (ModelEntry.ignored_settings).
needs.accept-when:— the figures a model has to reach to be used for an agent, one comparison per line, all of which hold:mmlu.score > 50,terminal-bench.score >= 40,refund-tone.score >= 0.8(any metric of the suite by its newname:, a DeepEval G-Eval included),pass-rate >= 90%,cost < 0.05 USD,input-price,output-price,context-window,first-words-within. Each name has its own unit and is refused where it is written when it does not fit (accept.rs, ten rules);spec/accept-when.yamlholds the Rust checker and the Python resolver to one reading. Catalogue figures are held before anything runs (ModelEntry.satisfies), suite figures after the candidate runs its suite (selection.held_after;CaseOutcome.scoreskeeps every metric score).- Named benchmarks.
models/catalog.yamldeclaresbenchmarks:— seventeen ship, eleven measured by DeepEval (runs: deepeval:MMLU) and six by their own harness (runs: host:terminal-bench) — each with its aliases and scale; a row's score under a name no benchmark has is refused.benchmarks.recordwrites a figure with its provenance, and copies the distribution's row first so recording a score never drops where a model is served. - In Bud Agent Flow:
budflow choose-model --candidate URL#MODEL …runs the suite on each candidate and chooses byaccept-when:andprefer:;budflow benchmark record|runwrites a figure;budflow runrefuses a bound model whose catalogue figures miss a line.
Designed in docs/96-MODALITIES-INTERFACES-TEMPLATES-TURNS.md,
which also lists what is still pending at run time.
- Voice, documents, video, embeddings. Two answer shapes,
documentsandvideo. Six model kinds (language,decision,embedding,transcription,speech,reranking), held per line by a newmodel-kind:attribute (loader/the-wrong-kind-of-model).agent.voice:says how an agent listens and speaks — the pipeline, who may interrupt, the transcriber, the speaker and its voice, the languages, how long a pause ends a turn, whether the sound is kept — each line held against the catalogue (voice.rs). A transcriber and speaker on this machine now keep a caller's voice here behind a hosted language model, needing onlyllminallow-egress:.knowledge.embedded-by:andreranked-by:name the models a set of documents is indexed and ordered with, which makes meaning-search writable on an air-gapped box for the first time. needs.scores:can be satisfied. A catalogue row may now recordbenchmarks:(it was refused, so every score requirement emptied the candidate set), andspeed:.needs.prefer: cheapest | fastest | best-on-the-evalsorders the models that pass.- Interfaces.
interface: yes,implements:andchosen-by:on tools, skills and sets of documents: oneemailcontract, Gmail and Outlook as its implementations, picked per person by the host or per run by a run input. An implementation says only how it reaches its server (calls:,passes:), may ask more of a person and never less, and is written out longhand from its interface by the loader (interfaces.rs, fourteen rules).tool.category:labels a tool for finding. - Jinja.
template-language: jinjalets an agent's words choose and repeat, in the dialect Jinja2 and MiniJinja both render one way — no includes, macros or calls, the shared filters only, names written with_(jinja.rs;holes.fill(language="jinja")renders it sandboxed). Which texts are filled is nowtemplate: yesin the schema. - Turns.
agent.turns:,variant.turns:andcase.turns:, fromsystem,developer,user,agentandtool, with tool calls and attachments; standing instructions only at the start, every call answered (turns.rs). - Tool schemas from the server.
pact-sync <server>records what an MCP server offers intoresources/<server>.yamlaspublished:;tool.schemas-from: the-snapshot | the-serversends the server's own arguments while which actions may be called, and what needs a person, stay written here.pact checkholdsinspects:,same-request-key:,bind:andpasses:to the recorded arguments (snapshots.rs);resource.if-it-changes:says what a run does when the server changed, and a change an approval rests on always stops it (mcp_bridge.when_it_changed).tool.unlisted-actions: each-call-asks-a-personoffers what the server adds behind a person.
- A suite says which runs it is a score of.
population: past-runswithfrom-the-last:replays real runs with every write stubbed;sampled-framewithsample:andreviewed-by:puts a share of finished live runs in front of reviewers;sliced-by:breaks the score down by what each run was given;runs-every:re-runs the checks on a timer port's clock (02W §2.13).pact checkrefuses each line that would load and do nothing: a sample with no share or no reviewer (schema/missing-companion), a line only another population reads (loader/a-line-this-suite-never-reads), a slice no workflow held to the suite accepts (loader/slices-by-what-no-run-is-given), a schedule no clock can keep (loader/every-is-not-a-time).populations.pyreads them, and says which runs a sample takes (by each run's own id, so every runtime samples the same runs) and each slice's verdict. - Two more rule shapes, wherever a rule is written.
program:names a carriedpureprogram that passes or fails the answer with its reason (a program that is not pure isloader/only-a-pure-program-grades; one the workspace lacks isschema/no-such-name, WF-34's program half), andlibrary:one of four ready-made checks (library_checks.py):numbers-come-from-the-source,quotes-are-word-for-word,cites-its-sourcesandonly-goes-up, which hold an answer to what the run was given (RunResult.given, new) and what its calls returned. Both are decided before any judge.tests/trees/workflows-48-invoiceschecks its reader withlibrary: numbers-come-from-the-source, as 02W §5.2 prints it.
pact-import --as mcp-json | pydantic-evals | agent-folder | skills. Anmcp.jsonbecomes oneresources/<server>.yamlper server (a command, a credential or${...}in an address isendpoint: host/<server>;args,env,cwdandheadersare named and never copied); a pydantic-evals dataset file becomesevals/suite.yamlandevals/cases/, each evaluator as the registry's newevaluator.door says and metadata a named loss (the package is never imported); a folder of harness agent definitions becomesagents/; a Skills directory becomesskills/. Each kind meets its reader in one place,importing.read_files, forpact-importand a host alike; a file is read byimporting.read_mapping, whose refusal names the problem and its line, never the text there (anmcp.json's lines hold tokens); a folder's hidden entries and links are named and never read.- Skills out as a directory.
pact-pydantic-ai PATH AGENT --skills DIRwrites the agent's skills as a harness Skills directory, every PACT line kept inSKILL.md's frontmatter andreferences/,scripts/andassets/copied, so importing it back gives the same skills. - A built pydantic-graph
Graphread as a workflow (pydantic_graph_reading.workflow_of), through public attributes and without running a step: steps ascallstages on host actions to write, decisions as rules-rungdecides, maps and broadcasts aseachandtogether, a branch back asrepeat;pydantic_graph_lowering.graph_ofgoes the other way, and export then import gives the workflow's structure again.pydantic-graphis now a declared core dependency (Pydantic AI already brought it). tests/parity/holds the graph rows G1 to G9 (147 rows), and no row waits for an importer.
loader/shows-nothing-can-supplyreads a workflow'spolicy:. A question a workflow's policy puts about a gated call shows that call's own arguments (Question.about_callkeeps the names the call is given), and the check that says ashows:name is dropped in silence walked only agents' parks. So a question only a workflow's policy put could showitem.blast-radius, a name no call carries, and load clean while the person approving was shown one value fewer than written. It is now held the same way, with the action's own names in the fix. Found by Bud Agent Flow's corpus case #103, whose incident commander was to read each containment's blast radius and rollback.
loader/an-empty-phrase. Every answer contains the empty phrase, so a rule with one (must-not-contain: [""], or a hole filled with"") loaded clean and failed every answer: each checked answer was asked for again and every request went to the backup; undermust-contain:ormust-say-one-of:it held for every answer.pact checknow refuses an empty or blank phrase wherever a rule is written, and says that[]checks nothing. Found by Bud Agent Flow's Route to a specialist template, whosenever-sayhole fillsmust-not-contain:.- A question a
decide's person rung asks is the workflow's. Only a stage's, a limit's and a failure plan'sasks:counted, so a workspace question only a rung asked was read as an agent's: it had to sayif-nobody-answers:, and anescalatewritten there loaded and was never read, since a decide's silence stops the run. It is now a workflow's question, so the line is refused (loader/an-agent-only-spelling, which now says what a rung's silence does and that a reminder'shands-over-to:passes the question on first). A question nothing names (a tree begun from a template, before thedecidethat asks it is written) is held to neither reader's lines, since the one an agent would need is the one a workflow refuses:loader/nothing-points-at-it, which reads the same names, alone says it takes no effect. Any other question some line names keepsif-nobody-answers:: a gate reads it when a workflow's call is gated as when an agent's is, whether the gate is a policy'sask-a-personrule or a resource'sasks-to-connect:orasks-to-run:reached through a tool only a workflow calls. Found by Bud Agent Flow's Sort and route an inbox template. - A negator in the field before is that field's (
evals._stated_at). The window looked back across a line break or a quote, so a reply field ending "... we can't" negated thepath: simplewritten after it, and a correct answer graded FAIL (Route to a specialist, recorded live). The window now stops where the value's own field begins: a string closed by,:}or], or a line that starts<key>:. A lone quote or line break is no boundary, so not "approved" is still a refusal, and a negator ends at any space, a line break too. - Tests: 7 Rust (
a_question_a_decide_asks_is_the_workflows.rs(5),an_empty_phrase_is_refused.rs(2)), 2 adapter (the field-before cases intest_a_negation_is_a_word_not_part_of_one.py), each failing without its change.
loader/a-route-back-to-an-earlier-stage. A workflow's stage runs once a run and keeps what it did under its name, so a line that led back to a stage the run had already been through (a declined diff sent back to its dry run) loaded, and the second visit was answered with the first visit's answer: the run went round the same stages for ever.pact checknow refuses such a line where it is written, in onesteps:or any inside it, and says to put the stages that run again inside arepeat. Found by Bud Agent Flow's corpus case #94.loader/an-answer-field-a-stage-word-hides.steps.<stage>.outcome(and.label,.decided-by,.answered-by,.ended,.rounds,.run) reads what every stage has besides its answer, so a callee whose answer has a field of one of those names could never have it read: the binding loaded and read the stage's own word. It is refused where it is read, with the fix to rename the field. Found by Bud Agent Flow's corpus case #102 (a sub-workflow answeringdecided-by), and it caught pattern AP15's workflow reading its agent'soutcome.- A negation is a word, not part of one (
evals._stated_at). The negators were matched as plain substrings, so thenoat the end of Merino negated the expected value written after it and a correct answer graded FAIL (Bud Agent Flow's corpus case #76). Each now begins at a word's start (n'tstill ends one). - Tests: 4 Rust (
a_route_back_to_an_earlier_stage_is_refused.rs,an_answer_field_a_stage_word_hides_is_refused.rs), 2 adapter (test_a_negation_is_a_word_not_part_of_one.py), each failing without its change.
loader/a-stage-named-done.doneis where a route ends: athen:line or a label that saysdoneends the run, the item, the round or the branch there. A stage of that name was accepted and could never be reached (a run compiled from it ended where the stage was meant to run);pact checknow refuses it where its name is written, and says to give it another. Found by Bud Agent Flow's corpus kit, whose test tree named its answer stagedone.- Tests: 2 Rust (
crates/pact-cli/tests/a_stage_named_done_is_refused.rs), failing without the rule.
resource-kind: a2a-agent(N1). A remote agent that speaks A2A is a resource like an MCP server, with no field of its own:endpoint:where it is (held toallow-egress: [tools]when it is off this machine),auth:where its credential is kept,asks-to-connect:the question answered before it is first asked anything, andtool-snapshot-digest:(with its date and maximum age) what its card said when somebody read it. A tool reaches it withconnect:, and that tool'sactions:are the skills of the card the model may ask, each by the skill's id.- Two shapes of such a tool are refused. One that asks it for no skill
(
loader/a-remote-agent-asked-for-no-skill), and an action that saysanswers-with:(loader/a-remote-agent-answers-in-words): what comes back is words a remote agent wrote, fenced off as text nobody here reviewed, never values a run acts on. pact_adapters.a2a_bridge:card_digest(canonical JSON of the card as served, its signatures aside: whattool-snapshot-digest:pins),check_card(a skill the tree asks for that the card does not list, a card that is not the one pinned, a review past its maximum age), andreply(the reply fenced asmcp_bridge.quarantinedfences a server's prose).mcp_bridge.check_pinis the one reader of the threetool-snapshot-*lines for both, andquarantined(what=)says whose words they are.mcp_toolset_fordefers such a resource's tools with a reason naming the A2A client's two calls, rather than as a kind it does not know.
served-by.regionon a catalogue row'sserved-by:entry names the region that runtime serves the model in, as the runtime names it (us-baa-region). A workflow'sdata-stays-in:names a region and a model not served there is refused, and until now no line could say where a model is served. The resolver reads it asModelEntry.regions, each region with the endpoint of the entry that names it, so a call is held to the runtime the host binds; a row that names no region is served in none, never in every one.
used.<ceiling>in adecide's rules. 02W §2.0 readsused.<ceiling>(how much of a limit is used, as a percent) inwhen-thislines, and a decide's rules arewhen-thislines, yet the loader read it only in arepeat'suntil:and refused it in a rule (loader/a-binding-to-nothing, "is read only in a condition"). It is now read in every condition and still refused anywhere else (abind:, anover:, acall:).- A share of a limit nobody writes is refused.
used.<ceiling>reads how much of a pot is spent, so its ceiling is one a pot keeps (cost-per-request-under,tokens-at-most,tool-calls-at-most) written in alimits:group around the stage that reads it: its own, a stage it is inside, or the workflow's. Any other was read as nothing, and a rule over it never fired; it is nowloader/a-share-of-no-limit-around-it, with the line to write.
pact-import --as openapi FILE [--out DIR](decision P10). Each operation of an OpenAPI 3 description becomestools/<operation-id>.yaml: aurl:tool whose address is the API's name (bound by the host to the API's address and its credentials) and the path, one action named for itsmethod:,takes:from the path and query parameters or the JSON body's parts in PACT's shape words, andanswers-with:from the success answer when PACT can say all of it. An object is a named shape, added toworkspace.yaml'sshapes:. Nothing is guessed about an effect: an operation that does not declare it (x-pact-reads-only: trueorfalse) gets noreads-only:and a Needs a look line, so it stays a write that cannot be undone until a person decides. The security schemes are named in a comment, never written as a value. TheImportReportaccounts for every key read, down to each schema keyword: what PACT cannot say (aoneOfof several shapes, a free-form object, a shape that contains itself) is named and left out, never flattened. Held on ten public descriptions undertests/trees/openapi/, each with its licence recorded: every written file passespact check. A list in the address is written as the server reads it:list of ...when OpenAPI's default spelling holds (one pair per item in the query),textwith what joins the items when the server reads one value (explode: false), and any other spelling (deepObject,matrix, ...) reported and listed to write by hand.adapters/python/tests/test_an_openapi_description_imports_as_reviewed_tool_files.py.- A blank in an address is an argument.
url: host/pets/{pet-id}?tag={tag}fills each{name}from the call's argument of that name, in the path or in a query (where it is left out when the call has none); a blank that notakes:orbind:line of the tool's actions fills is refused (loader/a-blank-in-the-address-nothing-fills).method:'s help now says where the other arguments go: the query forgetanddelete, one JSON body forpost,putandpatch.crates/pact-cli/tests/an_address_leaves_blanks_only_for_arguments.rs.
- A decide may ask an agent, then a person (02W §2.5). A rung of
by:writes exactly one ofrules:,agent:(an agent whoseanswers-with:has oneone ofline naming the stage's labels, and may addnot-sure, which passes the decision on) andasks:(the person rung, a question whose answer has aone ofline naming the labels; always last).if-unsure-below:is a decision model's score under which it passes the decision on;if-it-disagrees: go-onpasses it on when this rung picks another label than the rung before. Refused: a rung with none or two ways (loader/one-rung-one-way), a person rung that is not last or a first rung that disagrees with nobody (loader/a-rung-out-of-order), an agent or question whose labels differ (loader/a-label-with-nowhere-to-go, WF-15), a last agent rung that may pass the decision on, bynot-sure,if-unsure-below:orif-it-disagrees: go-on(loader/a-decide-that-can-run-out-of-rungs, WF-16), andif-unsure-below:on an agent with no decision model (loader/sure-only-a-decision-model-can-be, WF-17). Read in Python byconditions.Rung.crates/pact-cli/tests/a_decide_cannot_run_out_of_rungs.rs. - A decision model is a catalogue row with
kind: decision(02W §2.12): a trained classifier that answers a label and a score. An agent on decision models only needs noinstructions:, andpact discoverlists it as runnable.resolve.ModelEntry.kind.tests/trees/workflows-48-invoicesnow sorts its attachments as 02W §5.2 prints it. - Money moves only past a person or a written rule, on a workflow's paths too (WF-23
extended, WF-24, F1). A stage calling a
spends-moneyaction nobody guards is told about (loader/money-moves-with-nobody-asked) when a path reaches it with no wait a person answers and no decide by written rules or a person on the way: a warning on a path from the start, as on an agent, and refused on a path from a decide an agent may choose for. A write that cannot be undone on a path an agent may pick is refused (loader/a-model-chose-what-cannot-be-undone, in place of the noteloader/cannot-be-undone-and-nobody-askedon that call). A call to a workflow is judged by that workflow's own checks.crates/pact-cli/tests/money_moves_only_past_a_person_or_a_written_rule.rs. - A run has one money pot (WF-31, R32).
cost-per-request-under:on a stage is its share of the run's figure; a share with no pot, or shares adding up to more than their pot, isloader/a-share-of-no-pot.limits.counted-per:(run,connection,team, or in a workflow a binding) andlimits.resets-every:say what a ceiling is counted across and when it starts again (loader/counted-across-nothing); the Python harness counts one run and says a wider count onRunResult.unmetered(Limits.across_runs).crates/pact-cli/tests/a_run_has_one_money_pot.rs. - One table of answer shapes. Each shape's spellings, words, example and JSON Schema
fragment are one row of
questions._KINDS, read throughShape.json_schema;pydantic_ai_interop._SHAPE_SCHEMAS, the second copy, is gone.adapters/python/tests/test_one_shape_table_and_the_rungs_are_read.py.
- An empty folder contributes nothing. A folder with no file in it, at any depth, is no part
of the document: not a field, not a note. Git cannot carry an empty folder, so a
questions/left empty read asquestions: {}here and as nothing in a checkout of the same commit, and the two digests differed, so a journal recorded under one would not replay under the other.crates/pact-cli/tests/an_empty_folder_contributes_nothing.rs. - A wait hears only its own events (
question.only-when:, N193). In a question a workflow asks of a port,only-when:lines in the one condition grammar say which events the wait hears: each looks at what the event brought,value: event.<field>, and compares it with a figure or a value the wait can read (is: {value: item.custodian-id}), so the waits of aneachthat all listen at one port each hear their own item's; an event a wait does not hear is kept for the next. Refused:only-when:on a question asked of no port (loader/a-line-this-stage-never-reads), a line that looks at anything but the event (loader/compared-in-the-wrong-shape), a field that nothing arriving at its ports has (loader/a-binding-to-nothing, with the fields that do), and a comparison in another shape than the field's.Question.only_whenandQuestion.hearsread it in Python.crates/pact-cli/tests/a_wait_hears_only_its_own_events.rs,adapters/python/tests/test_a_wait_hears_only_its_own_events.py. - A circle through an agent has its caret on the agent's
uses:entry the fix names, for a circle of any length, and says each hop after ausesas, which calls(loader/a-workflow-its-own-agent-starts).
- What happens after a failure is one record (02W §2.7, F2). A workflow stage writes
if-it-fails:withafter-that:(use-a-backup,carry-on,ask-a-person,wait-for-a-new-version,undo,stop-and-say-so) and the line each choice needs:backup:(called with the stage's ownbind:, held to the backup exactly as acall:is: an input left empty, one it does not take, or one of another shape isloader/a-call-that-does-not-fit),carry-on-with:(fields the stage answers with, elseloader/a-binding-to-nothing; each value a binding read like any other, in the shape of the field it stands in for),asks:(the workflow's question, wherever it is written),new-version-of:,undo:andundo-where:(only whenundo:names aneach).gives-up-after:is a moment. A stage'swhen-it-runs-out:ofuse-a-backup,wait-for-a-new-versionorundotakes its companion from the same record (schema/missing-companion), and a workflow's own ceiling cannot use a backup or a version. An agent's loop may not write it. - A write says how it is undone (02W §2.12).
action.undone-by:(a<tool>/<action>or a workflow: then the action is Changes) andaction.status-check:(a read asked before a retry). An undo — an action's, a stage's or a workflow's — that only reads, or takes an input the write's inputs and answer cannot fill by name and shape, isloader/an-undo-that-does-not-fit(WF-26); a status check that writes, or that the call's own inputs cannot fill, isloader/a-status-check-that-does-not-fit. A workflow stage calling a write with no undo and no Ask before gets the noteloader/cannot-be-undone-and-nobody-asked(WF-25). - Silence never approves, by structure (02W §3 WF-20, WAIT-4, R13, R50). The
nobody-answeredexit of a wait a person answers may not reach, before another wait a person answers, a stage whose call needs a person's yes (needs-a-person:or a rule in the workflow's policy), or a stage making a write (the same<tool>/<action>, agent or workflow) the wait'sansweredexit makes (loader/silence-reaches-a-gated-step, with a quiet place to send it instead). A wait only the clock or events answer has no gate, and silence walks on through every exit of it. - An
answeris held to what its workflow answers with: itsbind:(in the workflow's ownsteps:) names only fields ofanswers-with:, fills every required one, each in its shape (loader/a-call-that-does-not-fit). Avalues.<name>binding is read in the value'sshape:. pact waitslists every workflow wait: besides eachask-someonestage, each park a failure plan or a ceiling asks (if-it-fails.asks:, a stage's or the workflow'slimits.asks:), whose silence stops the run and says so; a park on the workflow's own ceiling names no stage.- A judged check has a judge (WF-34): a workflow's
judged:check with nograded-by:on the checks it names isloader/a-check-nothing-can-run. - The five worked trees of 02W §5 load clean —
tests/trees/workflows-16-trial-booking(wasa-trial-booking-case),workflows-48-invoices,workflows-69-reflexion,workflows-79-supervisor,workflows-60-interconnection— with the agents, tools, programs and values they name. Fixed on the way: ananswerstage hands back itsbind:(it was refused as a call-only line); a port'ssame-conversation-when:may name a part of a named shape (invoice.vendor-tax-id); a repeat'scomes-from:may name a stage some rounds skip (such a round adds nothing); and the trees write retention in days andone ofchoices in block maps. - Conformance tests
every_example_loads_clean,silence_never_approves(a mutation sweep of everynobody-answeredagainst a separate reading of the tree, and the first diagnostic 02W §3 prints),a_failed_item_is_kept_not_droppedandundo_reverses_exactly_what_succeeded(loader halves),a_judged_check_has_a_judge. Python:moments.FailurePlanonir.StageSpec.if_it_fails. 56 kinds.
- A wait is still a question, and the clock and events answer it (02W §2.8, R12 reopened).
In a question a workflow asks,
asked-of:may namethe-clock(a time-only wait whose time isanswer-within:and whose one exit isnobody-answered) or a port (an event);says:andanswer:are required only where a person or a group is asked (loader/a-question-with-nothing-to-say, WF-22).answer-within:is amoment. New lines:answered-by:/enough-is:,asked-in:(only for a channel — written"#name"—,the-requesteror an outside contact:loader/asked-in-for-someone-who-chooses, WF-36),urgency:,not-the-same-as:,needs-signature:,starts-with:,ordered-by:,counts-events-from:andreminds-at:of the newremindergroup (at:and exactly one ofnudges,tells,hands-over-to,runs).question-rule.first-times:.loader/nobody-can-answer(WF-21) also covers a question a workflow asks naming nobody, the clock with no time, a question only the clock or events answer that asks for an answer, and a reminder or milestone with none or two actions. A question a workflow asks has noif-nobody-answers:(loader/an-agent-only-spelling, WF-40, withwhen-it-runs-out: answer-with-what-it-has); one no workflow asks still must. What only a workflow reads, written where an agent reads it — the clock or a port as an answerer, a moment as a deadline, a workflow'swhen-it-runs-out:choice,joinon an agent's port — isloader/a-workflow-only-spelling. - Ports answer workflows (02W §2.9, R23 and R44 reopened).
answers:names an agent or a workflow; newaccepts:,bind:,only-when:(read by the one condition reader),signed-with:,in-time-zone:,if-missed:,per-row-of:(timer lines, refused off a timer),answer-within:,counts-as-the-same-for:,ordered-by:,undo:,then-run:.if-still-running:has six choices on every kind of port and lost its "only a timer" refusal. New rules:loader/an-event-the-wait-will-never-hear(WF-19),loader/a-key-that-is-not-a-field(WF-27),loader/overlap-with-no-key(WF-28), the noteloader/a-port-that-cannot-start-a-run(WF-29). - Time (02W §2.10):
limits.finishes-within:is amoment;starts-from:,paused-during:(loader/a-pause-for-something-that-does-not-wait, WF-32),milestones:,at-once-at-most:, and five workflow choices forwhen-it-runs-out:(use-a-backup,carry-on,wait-for-a-new-version,undo,wait-its-turn). A moment read from a date or counted in business days, or a workflow's timer at a time of day, with no zone on the line or the workspace isloader/a-time-with-no-zone(WF-30). - Value tables (02W §2.12):
value.shape: tablewithrows-are:a named shape; every row is held to it, andworkspace.calendar:andper-row-of:name tables (loader/a-table-that-is-not-one; a calendar's rows have adatepart). A value a run reads by name (the calendar,per-row-of:, avalues.<name>binding, now held to the values there are) stays in the document undervalues:; every other figure is put in place and removed as before. pact waitslists every workflow wait withwaiting-for-a-person,waiting-for-a-timeorwaiting-for-an-event, its stage, where silence goes and a deadline written as a moment.- Python:
pact_adapters.moments(Moment, typed, withReminderandRegionLimits),ir.PortSpec's new lines,ir.values_of/ValueSpec,Question's new lines andQuestion.asked_by(a workflow's own question first),Rule.first_times, and the three reasons insuspension.REASONS. Fixturetests/trees/a-trial-booking-case(#16); conformance teststhe_clock_is_somebody_who_answers,an_event_finds_only_its_own_run,overlap_is_said_once_per_port,a_question_appears_where_its_answerer_choseanda_table_is_rows_in_one_shape. 55 kinds.
- One condition grammar, read by one function on each side (02W §2.6, F1 reopened).
when-thisgainsvalue:(a binding),less-than:,contains-any-of:andis-empty:, so there are six condition words, andtool:is no longer required. The right-hand side is acomparand: a figure written as the value is declared (5000 USD,85%,0.85,2026-10-09,enterprise) or exactly one of{value: <binding>},{now-plus: 14 days},{now-minus: 30 days}(schema/not-a-comparand). An approval rule'swhen:, a routing rule'swhen:and a repeat'suntil:are held bycrates/pact-loader/src/conditions.rs, which tells the same mistake in the same words in every position:loader/compared-in-the-wrong-shape(WF-18, extended) now refuses a value compared with a figure of another shape wherever the shape is declared (a score against money, a date against a number, money againstnow-plus:), a line that looks at bothtool:andvalue:or at neither,tool:outside an approval rule andvalue:inside one, and a{value:}or{now-plus:}comparand in an approval rule (a gate has only the call).loader/threshold-is-not-a-figurecoversless-than:too. There are no combinators; "or" is two rules (or_is_two_rules). - Python's one reader:
pact_adapters.conditions—holds(line, value, read=, now=, zone=)answers every word (Nonewhen a line cannot be told),all_holdandchooseread a rules rung.nowis the caller's, so a replay reads the journalednow; days count on the calendar in the value's zone ({now-plus: 1 day}is 25 hours across the autumn clock change), hours on the clock. The approval gate's comparisons go through it (questions._atom_stops; its lenient twin is gone), so the new words work in a policy. - Decide by written rules (02W §2.5, rules half):
stage.by:takesrungs, a rung itsrules:, eachroute-ruleitswhen:,choose:andbecause:, read intoir.StageSpec.by.loader/a-label-with-nowhere-to-go(WF-15) refuses a label the stage'schooses-between:lacks, offering the nearest;loader/a-decide-that-can-run-out-of-rungs(WF-16) adecidewith no rung, or whose last rung is rules with no "otherwise". - The six combine rules mean one thing (
pact_adapters.combine.Combine): each a fold with an associativejoin, so items combined inside a segment and segments combined by a parent come to the same result, ties included (the earliest item wins), held by a property test. The spellings are read fromspec/schema.yaml's one copy. - Review fixes. A gate stops a call that carries its argument as null (
amount: nullcannot be told against200 USD; only a call that leaves the argument out is not the call the rule is about), as it did before the one reader. A time of day is compared as a time (more-than: 14:30loads, is held to atimevalue, and is read as a time, never by its hour). The gate reads a date and time an argument carries with no offset in the workspace'stime-zone:(Gate.zone).add upsums money exactly, to the most decimal places written (0.004 USDtwice is0.008 USD,1000 JPY + 1 JPYis1001 JPY), and refuses money with a bare number.top <n> ... by <field>ranks dates, moments and times as the condition reader orders them (conditions.ordered_as), not by their year.Rule.fires_on, a twin ofRule.stops_on, is gone. - Fixture
tests/trees/an-invoice-case/(#48's routing and tiers); conformance testsone_grammar_for_every_condition,compared_in_its_own_shape,or_is_two_rulesanda_decide_cannot_run_out_of_rungs; anuntil:oftool:lines is now refused (WF-18) and WF-14's positive half is tested.
- A binding reaches only what ran (
crates/pact-loader/src/bindings.rs).pact checkrefuses a binding that names nothing, offering the nearest three names (WF-4); one to a stage some path reaches the reader without passing, naming that path — unless the input it fills is, optional(WF-5); one to a stage that runs after the reader, or later in a repeat's round (WF-6);item.outside aneach(WF-7); acall:picked from a binding with nomay-call:(WF-9); a repeat's memory with nocomes-from:, or one from outside the round (WF-13); anuntil:that reads nothing the round writes (WF-14); and a call whosebind:leaves a required input of its target empty, fills one it does not take, or binds a value of another shape (WF-39, the direct and auto-map rungs). The third printed example of design 02W §3 prints as shown. - Answer shapes gain
date,timeanddate-and-time, and three ways to build on any shape:list of <shape>, a name from the workspace'sshapes:, and<shape>, optional, read by one Rust reader (pact_schema::shape, behindschema/not-an-answer-shape) and byquestions.Shape.parse(written, shapes, zone). A date and time written without a zone is read in the workspace'stime-zone:, and refused when there is none. A shape made of itself is refused (loader/a-shape-made-of-itself).shaped-like:is held to the same shapes. - Memory between rounds (02W §2.11):
lasts: one-run,comes-from:,combines-by:(a repeat'sremembers:only) andkept-per:(a workflow's or the workspace's), read intoFact. - Python:
ir.AgentSpeccarries the workspace'sshapesandtime_zone; PACT's own Pydantic AI path shows a named shape as an object and leaves an optional line out ofrequired; a question and a suspension carry the named shapes they use across a process boundary. - Review fixes. What a repeat reads when a round ends (
comes-from:,until:) must have run on every way the round can end — a decision can end it first — or WF-5 names the path. A first stage that reads a stage coming round to it again reads from later (WF-6), never "the path through ''". Abind:key written with dots (fills.first-name) is a part of the input's named shape: an unknown part is refused with the nearest three, the part's shape is held, and the input counts as filled only when every required part is (WF-39). A stage'sat:andin-time-zone:are read once, at any depth, and abind:key calledatis never a moment. Untilvalue:can be written in a condition, anuntil:oftool:lines only is not held by WF-14. A question's answer line marked, optionalmay be left out (Question.validate).
- A workspace can hold workflows.
workflows/<name>.yamlis a collection likeagents/: a stage loop that belongs to no agent, withaccepts:,answers-with:,starts-at:, namedsteps:and the five newdoes:valuescall,decide,each,repeatandtogether(design 02W §2.2, §2.3). A stage'sthen:gainsdeclined,nobody-answeredandheard. The workspace gainstime-zone:,calendar:, namedshapes:, sharedremembers:,owners:andrelease:; a bundle may bring all of them. Two field types join the schema:moment(a length of time, or{at:, after:|before:, counted-in:, in-time-zone:}) andcombine-rule(keep-all,keep-the-latest,merge,add-up,vote,top <n> highest|lowest by <field>). pact checkholds a workflow's structure (crates/pact-loader/src/workflows.rs): a workflow with a mind of its own (WF-1), a stage that thinks (WF-2) and, the other way round, an agent's loop that writes a workflow's stage, or a line or an outcome only a workflow's stage reads (limits:,checked-by:,checks-at-most:,undone-by:,then.declined:, ...); a line beside adoes:it does not belong to, an outcome underthen:itsdoes:never ends in (02W §2.4: adecideand ananswerhave nothen:), anditems-at-most:anywhere but aneach's ownlimits:(WF-3); workflows that call each other in a circle (WF-8); aneachwith no ceiling (WF-10) or whose items are told apart by position (WF-11, a write by a member of a called agent'steam:included); arepeatwith no bottom (WF-12); a version of something that has none (WF-35); a path that answers nothing (WF-38); a call to a name that is not here; and a workflow sharing a name with an agent or a program. Reachability walks a workflow and the inside of eacheach,repeatandtogether.forget-after:is nowkept-for:. The old name loads for one more release with the warningloader/forget-after-is-now-kept-for;Fact.kept_forreads either.- A
then:names the stages beside its stage, never the ones inside it:names: ^stepsresolves to the block the value is written in, then the neareststeps:it sits inside. - Python reads a workflow as a typed spec:
ir.WorkflowSpec.from_workflow,ir.StageSpec,ir.Moment, with the workspace lines a workflow is read under resolved once.
replace the answer with what <a program> returnsrewrites a structured answer too. A runtime whose agent answers in a shape (answers-with:) hands the chain the answer's fields, and the program'stakes: content: textrefused them, so the rule left every such answer as it was. The chain now hands the program the answer's JSON and reads its reply back as JSON; a reply that is not JSON leaves the answer exactly as it was and says so inunenforced, as any program's failure does (interceptors._rewritten).
pip install pact-adaptersbrings thepactbinary. A new distribution,pact-loader(pyproject.tomlat the root, maturinbinbindings overcrates/pact-cli), installs the loader into the environment's scripts folder;pact-adaptersdepends on it at exactly its own version. Wheels are manylinux 2.28 for x86_64 and aarch64, linked with zig so a newer build machine asks for no newer glibc; the source distribution carriesspec/andmodels/catalog.yaml, which the binary compiles in.- The loader is found beside the interpreter.
pact_binary()looks, afterPACT_BIN, at a checkout's own build, then at thepactthe wheel installed beside this interpreter (with its folder on noPATH), then on thePATH. A checkout's build now comes before anything on thePATH: a development environment has the wheel installed too, built from the source as it was at the last sync. - One script builds and proves what is published.
scripts/build-wheels.shbuilds the wheels and the loader's source distribution, runstwine check, builds the loader from its source distribution alone, and runspact checkfrom the installed wheels outside the checkout..github/workflows/wheels.ymlruns it on both architectures and publishes on a tag, through PyPI trusted publishing, after the owner approves.
- A collection folder's own file still holds several entries.
watch/watch.yamlandtools/tool.yaml(the folder's name, or the kind the collection holds) are the folder's self file again; only a file named after another kind (tools/catalog.yaml) is one entry (FR-1.1.3 says so now). model: [a, b]is aFallbackModelon PACT's own Pydantic AI path:build_agent(spec, models={id: model, ...})makes the chain from the host's models, in the author's order, falling over on a provider error only.- The completeness test reads every capability in the installed Pydantic AI, from its source,
not only
pydantic_ai.capabilities:OpenAICompaction,AnthropicCompactionandBaseDurabilityCapabilityhave rows now. - Each parity row asserts its whole test column, or
tests/parity/README.mdsays why not; the export report names each ceiling it cannot carry. - A teammate asked twice in one step keeps its answer across a park for another member that failed: the record is keyed by each call's slot.
- A bank account is hidden only when it passes the IBAN check (ISO 13616), so a hex digest, a dashless UUID or a URL segment that merely starts like one is left alone.
tests/parity/: one folder per 02P row Phase 1 compiles, each a fixture and anexpect.yaml, run byadapters/python/tests/test_parity.py: the import accounts for every key, the report a person reads uses plain words, and each row's behaviour holds on PACT's own Pydantic AI path. One completeness test fails on any Pydantic AI name the registry has no row for, through seven doors (capabilities,AgentSpeckeys,ModelSettingskeys, message parts, stream events,ToolDefinitionfields,GraphBuildermembers).ImportReport.in_plain_words()is that report, andpact-importprints it first.- Two calls to one tool in one step are two calls. The harness kept a step's results and
refusals keyed by the tool's name, so a cleared call waited while a sibling of the same tool
waited, and a no to one refused both. They are keyed by the call (
harness.slots_of). - A pattern a bundle contributes is checked as a pattern. Its
more-than: <limit>hole was refused as a finished rule (loader/threshold-is-not-a-figure); it is held to its ownexpects:instead, as a workspace's pattern is. redaction.yamlcan name a social security number, and a bank account in lower case is a bank account.- The "not a shape" refusal names only shapes that parse (it offered descriptions such as
"an amount of money, like
25.00 USD"). tools/catalog.yamlis the toolcatalog. A file named after a kind directly in a collection's folder was read as the folder's own settings.
- Values known only at run time.
{{run-inputs.<name>}}and{{remembers.<name>}}may appear in an agent'sinstructions:andhelp:; the surrounding system fills them before the model reads the words. A hole naming nothing the agent declares is refused;\{{...}}keeps braces meant for the model, and braces that are nearly a hole get a warning. - Fallback models.
model:takes an ordered list; the next model is tried only when a call fails at the provider (never because an answer was wrong). Each model in the list is held toallow-egress:andneeds:exactly as a single one is. - Answers checked before they count.
checked-by:holds a live answer to the same rule shapes asevals:(must-contain:,must-not-contain:,must-say-one-of:,must-call-before:,judged:); a failed check asks again with the reason, up tochecks-at-most:attempts (default 2), then the run stops and names the rule. - Typed action results. An action may say what it hands back with
answers-with:, in the same shapes as an agent's answer. On an action that runs aprogram:, the two must agree. - Governed run-time composition.
teamwork.may-start:(catalogue,narrowed-new) lets an agent bring in others while it works.pact checkrefuses it withoutlimits.starts-at-most:andlimits.nests-at-most:; each start is journaled, counted across levels and paid from the request's pot, and nothing it starts is written into the tree. - Schedules. A port's
every:is held to one grammar where it is written, and an hour is never guessed; a port and its clock are read once, the same way, by every runtime. - Program bodies. A
pythonbody is one.pyfile giveninputs(keyed bytakes:) whose last line is the answer; awasmbody reads its inputs as one JSON object on standard input and writes its answer as one on standard output. A host is handed everything it needs to run one. - Learning. A cycle can be scored by the host's own runner from a kept baseline; "held for a person" is its own outcome; what a host hands the grader reaches the measurement it is for.
feel:is a latency band a report measures against. It stops nothing; a run is stopped by a line with a figure on it.
- Nobody is asked to approve a call that at-most-once would refuse anyway.
- An amount nothing can read as a figure stops at the gate.
- A card number written with no-break spaces or dots between its groups is redacted too.
- A value written without a dash is one value, not its letters.
- A sentence is not something that was sent; an answer is checked before it is the answer.
- A deadline can be applied by a runtime that replays a journal.
- Run on Pydantic AI 2.54, import none of its private names, and never hand it a ceiling nobody wrote.
- One table,
pydantic_ai_registry.yaml, says what every field becomes in Pydantic AI, and a loss row says who holds the line instead. - The core installs with three packages (
pydantic-ai-slim,pyyaml,httpx); every other framework is an extra. The catalogue and the schema travel with the package, and the loader binary is found one way by every command (PACT_BIN, thenpacton thePATH, then the checkout's own build).
- a card number parted by no-break spaces or dots is hidden too (5b53777)
- docs: the test counts are the counts (1139 Rust, 2370 adapter) (6578dd3)
- the test for an approved refund sent again now fails without the look (da506bc)
- what the branch added is held by a test, and three documents say what the code does (8b58ef6)
every:is held to one grammar where it is written, and an hour is never guessed (2aa5da4)- its own harness says what it does not hold, and its own builder holds what it was skipping (18d9d72)
- braces a model is meant to read, braces that are nearly a hole, and a variant's holes (c2ea8bc)
- the build made last answers, no private Pydantic AI name is imported, and a gate that cannot run the second port stops (f3e108b)
- an amount nothing can read as a figure stops at the gate (2b27eb2)
- the catalogue and the schema travel with the package (ae794e5)
- the mutation test breaks a copy, never the source other processes import (0f379b6)
- nobody is asked to approve a call at-most-once will refuse (257ea1e)
feel:is a latency band, and stops nothing (640aed1)- a value written without a dash is one value, not its letters (923be88)
- the Pydantic AI table says what budflow builds, and a loss row says who holds the line (e214203)
- a sentence is not something that was sent (ed471e0)
- held for a person is its own answer on a learning Outcome (2e5951c)
- the learning record is read from where it is written (5e11cdb)
- a port and its clock, read once for every runtime (0205d5f)
- a learning cycle a host scores with its own runner, from a kept baseline (800136c)
- what a host hands the grader reaches the measurement it is for (84dabab)
- governed run-time composition (may-start), and one place a grant becomes a member's ceiling (5f0f88e)
- an agent's answer is checked before it is the answer; a variant holds what it says (7bfef1c)
- a pinned server's schema is read by its current name (c3e47a1)
- what an agent is offered and what it can be sent cross the ir wall (c00f122)
- which stage runs now is the loop's own public answer (Loop.stage_to_run) (711d653)
- a projection is what the model reads, and the whole answer is metadata (1e9f70d)
- a keep-going answer grants every ceiling again, and feel: is a wall clock a runtime can hold (8fcf54a)
- a host is handed everything it needs to run a carried program (1a20541)
- a summary a runtime awaits, the one summary text, a watch line written without a bus (a34c6c6)
- memory terms ride on Fact, a run's own Facts, and a pure at-most-once claim (cbb195f)
- a deadline can be applied by a runtime that replays a journal (f54afb1)
- docs: KNOWN-ON-2.54 gives the suite's count now, beside the one at the move (c882edf)
- a bare
{{brand}}the agent declares is told the one hole it means (32cb4b8) - the importers' and the exporter's field words live in the registry too (2f4d48b)
- three Phase 1 test files sort their imports (d2007da)
- A1, A2 and A4 reach a live model on Pydantic AI 2.54 (d933476)
- a ceiling nobody wrote is not handed to Pydantic AI (4d9810f)
- the adapters run on Pydantic AI 2.54 (a45fbbf)
- the core installs with three packages; every framework is an extra (51166ad)
- one table says what each field becomes in Pydantic AI (02P §4) (ffe6111)
- an action may say what it hands back (A4) (954837f)
model:may name fallbacks, each held to the same lines (A2) (bbedf39)- a value only known when a run starts has a place in the words (A1) (c6c773e)
- the loader binary is found one way, by every command (5b8df06)