Skip to content

Changelog

All notable changes to AAT are documented here. The format follows Keep a Changelog; versions follow semver with a 0.x caveat: the graph and plan formats may still change before 1.0.

Unreleased

Added

  • Runs can be replayed with the same pool picks. Every run draws its pool orders and random selections from a seed, and a run that made such a choice prints it; aat run show, the archive, and summary.json record it. aat run plan --seed N and MCP execute_plan's seed repeat those choices, and aat run batch --seed N now fixes each run's picks as well as the shuffle. A step's picks depend on its ID, not on the order steps run in, so parallel steps replay too, and a step's random named selections draw in name order, so several of them replay as well. A --shuffle without --seed picks one seed for the order and the runs and prints it, and a batch reports each run's seed in its --json output and batch.json. --seed 0 picks a new seed in run plan as in run batch. {{uuid}} and {{random N}} stay unique. See Replaying a run's picks.
  • Defaults can draw from the domain file's value pools. poolRef: airportCodes, or poolRef: airportCodes.us for one group, works wherever pool does: graph defaults, layers, and step values, with constraint and poolStrategy. One list then serves every input that takes that kind of value, where each default used to copy it. aat validate checks every name, in the graph, its workflows' inject values, layers, plans, workflow files, and verification values; the archive and web UI record which pool a value came from, and MCP list_value_pools shows each pool's groups and the inputs that use it. See Value Pools.
  • --fuzz tries a step with generated values, inside a real flow. aat run plan smoke --fuzz addItem runs the plan, then sends addItem values its inputs allow, values they forbid, and values they say nothing about, each as a sibling step on its own copy of the steps before it. The cases come from each input's type and constraints and the domain file's types and pools, so nothing about fuzzing goes in the graph; when the node has an OpenAPI operation, a request the spec refuses is judged as negative. A 5xx, no response, or a response that breaks the spec fails the run (--fuzz-fail changes that); a forbidden value accepted or an allowed one refused is a warning. Fuzz steps never stop the run. Case IDs such as quantity.above-max are stable, so --fuzz-case replays one, and the archive, summary.json, --json, and aat run show record each case and its finding. run batch takes the same flags. See Fuzzing.
  • Fuzzing leaves fields out and fuzzes what the template writes itself. Each input the template sends in the body, the query, or a header gets missing (and null in the body), judged as negative for a required input and positive for an optional one. The template's own body values get remove, null, wrong-type, and empty, the body gets an unknown property, and literal query parameters get remove, all as edge cases unless the OpenAPI spec refuses them. They patch the request the template built, so the rest of it is what the plan sends. A new finding, undocumented-status, marks a status the node's operation doesn't list. A case's copy of the setup now includes the earlier steps that build on it, such as the addItem a checkout needs, and a copied step that fails reports its case as not-sent without stopping the run. The shop example's quantity input now declares min: 1.
  • Fuzz cases share their setup while the API refuses them. The new default scope, reuse, runs a target's cases on one copy of the steps it depends on until a case is accepted, fails with a 5xx, or gets no response. Then the next case gets a fresh copy. Refused cases, most of a run, cost one setup, and accepted ones never pile onto one resource (a cart, a reservation with a traveler limit). Read-only setup steps (a GET with no cleanup pairing) aren't copied at all. After three setups in a row fail, the target's remaining cases aren't tried. Copies' progress lines are hidden unless they fail. The fuzz summary says how cases were set up (setup: 2 fresh, 7 reused), and shared scope on a step that changes state is warned about. isolated keeps a fresh copy per case. On the shop, fuzzing addItem and checkout sends 143 requests where it sent 228; on Duffel's test API, ten booking cases took 73 seconds with three searches instead of ten. Fuzz steps and their copies are named like addItem__fuzz_quantity_below_min, so a copied expression such as {{search.firstSliceOrigin}} still parses.
  • Fuzz cases run after the happy path, and a used-up setup is rebuilt. Cases on copies of their target's setup run once the plan's own steps and its verification are done, so a long run of cases no longer holds up the plan until a session it made expires. They are still sent when a later step of the plan fails, and before a --stop-after checkpoint stops the run; shared cases still run right after their target. An errorDetection rule's new stale: true says its error means the state the request worked on is used up (an expired session, a reservation an earlier refused case filled), not that the request was wrong; an HTTP 410 says the same. A case that gets such an answer on a reused setup is sent again, once, on a fresh copy and judged on that answer. The fuzz summary counts it as rebuilt, and the archive and --json keep the answer it replaced as fuzz.stale. See When cases run and Stale Errors.
  • A step's fuzz: block fuzzes it on every run, and --fuzz-save keeps findings as regression plans. The block holds what --fuzz flags would say (mode, inputs, cases, only, scope, fail) plus skip (inputs never to fuzz), accept (statuses no case is faulted for, such as a busy API's 409), and pinned cases sent exactly as written. --fuzz-save DIR writes a plan per failing case with that case pinned, so it fails until the API is fixed; --no-fuzz ignores the blocks. The MCP tool generate_fuzz_cases lists a step's cases as a block to keep. See Fuzzing: the fuzz block.
  • Fuzz verdicts hold up on real APIs and templates. A 429 (or RESOURCE_EXHAUSTED) is a new finding, throttled, rather than a refusal, and a case retries it when its target's retry block would. A request the client refuses before sending, such as a header value with a control character, is not-sent, not a server error. Findings are tried most serious first, so undocumented-status no longer hides accepted-invalid. A spec violation the target's own request has too doesn't make a case negative, and a positive value the step's constraint rules out is judged as edge. Cases the request can't carry as themselves aren't made: "" where the template leaves an empty input out, a value a header can't hold, and length cases past 65,536 characters; positive length cases match the input's pattern. --fuzz <node> skips steps with expectFailure or a rawBody; --fuzz-input names inputs of any target; --fuzz-case and a block's only are checked before the cap; pinned cases follow the mode, input, skip, and cap settings; and a batch fails when a --fuzz name matches in none of its plans. Fuzz copies and cases never stand in for the happy path's resources in verification and cleanup, a knownIssue covers a setup copy as it covers its original, --stop-after keeps a fuzz failure, and aat run show and MCP failure analysis judge a fuzz case by its finding. --fuzz-save keeps the case's accept, scope, and fail, so the saved plan passes once the API is fixed; a run prints the plans it saved with --quiet and in a batch too, and --json lists them in fuzz_saved. A wrong-type value carries JSON quotes only where the template writes the input unquoted in its body. A setup copy leaves out steps that change nothing the target works on (reads and negative steps it doesn't depend on), and a read-only target's cases run on the happy path's own setup under reuse. The step counter numbers the plan's steps and cases, not their setup copies, and a fuzz case's value is archived fewer times over (a tenth smaller on the shop). See Fuzzing.
  • raw: true sends a step value exactly as written, with no expression evaluation or type coercion. Its {{…}} is text that validation doesn't check, and an unquoted YAML date is sent as written, not as a timestamp. See Raw Values.
  • An error a 200's body reports can stand for the status it means. An errorDetection rule takes status and categories (the category its details.category reads, matched without case, to an HTTP status), and the graph's new errorStatus gives every rule that names none the same mapping, so a project whose errors all look alike declares it once. That status is what expectFailure and mutations' expectStatus, status assertions, retries, and fuzz judging read: a validation error given 400 passes expectFailure: {status: [4xx]}, a temporary one given 503 is retried by default, and in a fuzz run an unexpected one given 500 is a server-error rather than a refusal. Without a status, a body error behaves as before. See The Status an Error Stands For.
  • expectFailure takes status classes. status: [4xx] passes on any refusal and still fails on a 5xx or a success; it works in mutation expectStatus and overlay expectFailure too, and a gRPC status matches the class of the HTTP status it maps to. See Negative Testing.

Fixed

  • An error a 200's body reports is read before the step's outputs. Such a body rarely has the shape a success's outputs are read from, so a step whose errorDetection rule matched used to fail as "extract path … not found": an adapter error that hid the API's message and that retry.on: [response_error] never retried. The step now fails with what the body says.
  • Body errors are reported everywhere. The --json summary used to report a step whose body held an error as passed; it now fails it, with a body_error object. The progress line marks it (BODY ERROR VALIDATION as 400) and prints the message, and aat run show --step shows it.

Changed

  • Steps run in the order the plan lists them, wherever dependsOn allows. Of the steps ready to run, the one written first now goes next; before, a step that became ready went to the back of the queue, so a plan could run its steps in an order it never wrote. See Step Execution Order.

0.3.2 - 2026-09-22

A bug-fix release: a gRPC call that outlives its deadline is now always an error, never a response the step asserts on. The docs also lead with what one description of an API does, and say how AAT differs from tools that script the wiring between calls.

Fixed

  • A gRPC call that outlives its deadline is always an error. The server is sent the call's deadline and answers DEADLINE_EXCEEDED when it passes. When that answer arrived a moment before the client's own timer fired, the step treated it as a response: its assertions ran against it, and the archive recorded an exchange that never finished. This happened with the run's own deadline (such as the time budget an aborted run gives its cleanup) and with aat's request timeout. The clock now decides, not the timer, so both are errors, as they are over HTTP. A DEADLINE_EXCEEDED the server reports on its own account is still a response.

Changed

  • The README and the docs home lead with four jobs one description does. They are: testing real flows over REST and gRPC; testing locally without editing anything; knowing when an API you depend on changes; and getting an integration working before handing it to an agent. The Shippo regression a nightly run caught, and the clean-room builds from the launch post, now sit beside the real-API projects as proof.
  • CI/CD gains scheduled runs against a provider: a cron trigger, archive retention, and knownIssue for a defect the provider has confirmed.
  • Why AAT exists says how AAT differs from scripted tools. Bruno, Karate, and Hurl keep the wiring between calls inside each test, and nothing checks it until the requests run. AAT declares it on the operations and checks it first: guardrails at the interface between agents and APIs.

0.3.1 - 2026-09-22

A small release with one new primitive: a failure you already understand can stop turning CI red, but only until a date you name.

Added

  • knownIssue: a failure with a deadline. A step — or a whole plan — can declare a defect it is waiting on:
- id: refund
  node: createRefund
  knownIssue:
    until: 2026-10-06
    reason: >-
      Vendor test-mode defect, confirmed: refunds answer 201 with status ERROR
      where they answered PENDING. Live is unaffected.
    url: https://example.com/tickets/4471

Until that date the failure does not turn the run red. Everything else stays honest: the step still runs, still checks its assertions, and still reads as failed in the progress line, in aat run show, in the archive, in the MCP tools, and in the web UI. Only the run's outcome is forgiving, so the exit code is 0 and CI is green on a fact somebody wrote down. Execution carries on to the next step when the failing step produced a response, so the rest of the plan keeps testing; when it did not — a 500, an unexpected status, an error in the body — there is nothing to carry on from, the run ends there, and it says endedEarly rather than passing off a short run as a complete one.

Then the date passes and the entry stops applying, with the expiry named in the failure: step "refund" failed mechanical validation (knownIssue expired 2026-10-06). That is the whole point of it. Turning a test off is easy; this is the version that turns itself back on, so a suppression cannot quietly outlive the problem it was written for.

Both fields are required. An entry with no reason is one nobody can judge later, and an entry with no until is the thing this feature exists to prevent. A transport error is never covered: a step that could not reach the API at all is infrastructure, not a defect with a date. It is not a way to quiet a flaky step — that is a retry rule — and not expectFailure, which says the API should refuse the call.

A step that passes while an entry is in force is reported under knownIssuesResolved: the entry has outlived the defect and should be deleted. That never fails a run, so good news never breaks a build.

aat validate gains a Known issues section listing every entry in the project with how long it has left, or how long it has been lapsed. It never changes the exit code — whether a covered failure still counts is a question for the run.

--json gains known_issues, known_issues_resolved and ended_early; the archive gains knownIssues, knownIssuesResolved, result.knownIssues and result.endedEarly, and a step gains knownIssue. tools/aat-to-junit.py reports a covered step as <skipped> rather than <failure>. Documented in Plans, and in the AI assistant primer as the fifth negative-testing primitive.

Changed

  • "Why AAT exists" tells the actual origin story. The page opened on the conclusion — a pile of Postman collections — and skipped how it got there. It now opens on the problem that produced them, a service that could not be tested on its own behind a dozen setup calls, and adds the two attempts that failed before the graph existed: a recording proxy that discovered the chained values on its own and generated a Postman collection with the extraction scripts written, which worked but baked the wiring into the recording so any other path to the same goal meant recording again; and handing an LLM a list of operations and a goal, which failed for the mirror-image reason, with nothing written down to guess from. Both kept the connections attached to a run rather than to the operations, which is the move the graph makes. A new section walks the features in the order the problems arrived — local overrides, archives and the web UI, layers, then the MCP server — and a new section makes the case the page never made outright: one description does several jobs. The batch a developer runs while changing something is the batch CI runs on every commit and the batch a release is signed off with; debugging a local build is that same run with one node redirected rather than a fork of the suite; what a pipeline leaves behind is an archive that opens in the web UI instead of scrollback; and with one description instead of a copy per person, a correction lands once and holds for everyone who runs it next. The page also now says why a described API is a Rosetta stone an assistant writes a client from, that aat validate --strict is the guardrail that closes an agent's loop, and that aat prompt, the surviving piece of the second dead end, is the least load-bearing thing in the project. The README's Why and the docs home carry the short version, as before.
  • The docs home lists four real-API projects rather than three; aat-qdrant had been missing from that sentence.

Added

  • A case study, What a nightly run caught. aat-shippo's scheduled run went red on 2026-09-18 because Shippo's test environment began answering POST /refunds with 201 Created and "status": "ERROR", where it had answered PENDING the day before. The page is the diagnosis end to end — the failing batch downloaded as one file, aat run show --step refund naming the predicate that failed, the same command against the last passing run four days earlier, and the two checks that ruled out a label-state race and an intermediate status — followed by what the vendor's own documentation says and what the OpenAPI spec could not say, because ERROR is a legal value in it. Shippo confirmed it as a known test-environment defect; live refunds are unaffected. Linked from the Why page and Real APIs.
  • A blog post for the gRPC release, "gRPC, without a second toolchain": the cross-protocol demo run, how a value crosses the boundary between an HTTP step and a gRPC one, status names against a shared HTTP status, what aat validate catches offline, proto3's 64-bit integers, and where the support was stress-tested.

0.3.0 - 2026-09-18

The gRPC release, and the one aat-qdrant was built on; it needs this version or later.

Highlights. AAT drives gRPC services the way it drives REST ones: a node names a method with proto:, a template declares protocol: grpc with an rpc, metadata, and a message written as JSON, and an environment routes to a grpc:// or grpcs:// target, so one plan can check an order out over HTTP and charge it over gRPC. Protobuf reaches the rest of AAT as JSON, so extraction, predicates, assertions, retries, cleanup, archives, aat run show, the web UI, and the MCP server all work unchanged. Statuses are asserted by name (expect: OK, status: [NOT_FOUND]), in plans, overrides, overlays, and retry rules, because several gRPC statuses share one HTTP status. aat validate checks every gRPC node against a descriptor set offline: unknown methods, streaming methods, inputs where the message puts them, and extract paths, including one spelled by its .proto name. It is tested over TLS and mutual TLS, runs unary methods only, and ships with an offline demo (aat-sandbox serves payments over gRPC; aat-sandbox init --example grpc-payments), the gRPC guide, and a fourth public project that drives all 52 of Qdrant's unary methods with it.

Breaking and noticeable. fieldEquals compares a list or an object by its structure, so ["4"] no longer equals [4], and <, >, <=, and >= compare a decimal string with a number by value. --override NODE=URL keeps the headers and credential the node already had, from its matching overrides: entry, where it used to take the environment's. aat-sandbox serve listens on a third port, :8767 (--grpc-port). aat docs generate and the MCP node tools print a list or object default as JSON, and aat docs generate orders a requirement's satisfiers by name. Ctrl-C ends aat web at once. The binary grows from about 28 MB to about 44 MB with the gRPC and protobuf libraries.

Added

  • Outputs echoed from inputs. A node output can declare fromInput: <input> to be that input as the step sent it instead of something the template extracts. It is for an API where the client names what it creates and the reply doesn't say it back — Qdrant's create-collection answers {"result": true} — so a later step and the node's cleanup can read the name like any other output. aat validate checks that the input exists and that the template doesn't also extract the output.
  • gRPC, for unary methods. A graph node names a gRPC method with proto:, a template declares protocol: grpc and describes the call with rpc:, metadata:, and message:, and an environment routes to it with a grpc:// or grpcs:// target. Descriptors come from a descriptor set — what protoc --descriptor_set_out and buf build -o write — named by proto: in aat-project.yaml or the graph.

Messages cross into the rest of AAT as JSON, so extract rules, predicates, assertions, error detection, archives, and aat run show work exactly as they do for HTTP; metadata: carries what headers carry, credentials included, so oauth2, apikey, and bearer need no new configuration. expectFailure (a step's, or an override's or overlay's), expectStatus, a status assertion, and retry.on/retry.failOn accept gRPC status names — expect: OK, status: [NOT_FOUND] — which match more precisely than a code can, because several gRPC statuses share one HTTP status. Numbers still work, so a plan can read against either protocol. aat validate checks gRPC nodes against the descriptors offline, catching an unknown or misspelled method, a streaming method, and an extract path written with a field's proto name when the response encodes it under its JSON name. An input is checked where the template's message: places it, however deep — vectorsConfig.params.size, a oneof member, a map value — rather than required to name a top-level field. It reads an extract path the way extraction does — an escaped dot stays in its key, a #(...) query is one step, a map field's next step is any key, a well-known type is walked by its JSON form (a Timestamp is a string, a Struct any JSON), and a modifier or a pipe ends what it checks — and a path that reads nothing says where it went wrong: a list read without an index, or a scalar read into. A field spelled by its proto name is caught at any depth, with the path to read instead.

Streaming methods are rejected: a step is one request and one response. Server-streaming may come later; bidirectional will not.

Every surface reads a gRPC run in its own terms. The web UI shows the method and the service it went to in place of a verb and a URL, names the status code the server sent rather than the HTTP status AAT maps it to, labels request and response metadata as metadata, gives trailers a pane of their own, and offers Copy as grpcurl in place of Copy as cURL. aat run show, the run progress lines, and the MCP archive tools do the same, and the MCP template and node tools describe a gRPC operation by its service, method, metadata, message, and the inputs the message and metadata need; aat docs generate names each node's method. aat validate reports an optional input with no default that a message or metadata always sends, as it does for a body, rather than leaving it to fail at run time. aat generate does not understand gRPC yet.

This adds google.golang.org/grpc and google.golang.org/protobuf, which take a go install build from about 28 MB to about 44 MB.

  • A gRPC demo, offline. aat-sandbox serve now serves shop.v1.Payments on :8767 beside the HTTP shop (:8765) and HTTP payments (:8766), against the same orders. The new examples/grpc-payments project runs one plan across both protocols: a cart opened and checked out over HTTP, then charged and refunded over gRPC, with the order id crossing the boundary untouched. make example-grpc runs it, and CI does too, so nothing about gRPC is unexercised.

The service is a façade over the payments HTTP handler rather than a second implementation, so the order state machine, the declined card, and the region's currency rules behave identically whichever protocol reached them. It is served dynamically from the same descriptor set the project reads, so the two cannot describe different APIs. make proto regenerates it.

aat-sandbox init --example grpc-payments <dir> extracts the project from the sandbox binary, so an installed aat-sandbox runs the gRPC demo with no checkout of the repository; init with no flag still extracts the shop.

  • A fourth public project, aat-qdrant, is listed in the README, on the Examples, Real APIs, and Why pages, and in the AI assistant primer as the reference for gRPC idioms: all 52 of Qdrant's public unary gRPC methods, against a pinned local container. It is what the gRPC support was stress-tested against.

  • docs/user/grpc.md, a guide covering descriptor sets, the template and graph shape, routing and auth, status names, and the proto3 JSON encoding rules most likely to surprise a plan author — starting with 64-bit integers, which encode as strings. The AI assistant primer gained the same ground so agents author gRPC plans correctly.

Fixed

  • A predicate orders a number sent as a string against a number. size > 0 failed with "cannot compare string with float64" when size was "147456" — which is how protobuf's 64-bit integers, and many APIs' amounts, arrive — although the gRPC guide promised it worked. <, >, <=, and >= now compare a decimal string with a number by value. == and != stay strict, and the error says to quote the number.
  • aat docs generate writes the same file every time. Where several nodes satisfied one requirement, the diagram listed their arrows in map order, so regenerating an unchanged graph could reorder lines and fail a "docs are current" check. A requirement's satisfiers are now in name order, for the diagram and for backward chaining alike.
  • A list or an object default reads as JSON. The MCP node tools and aat docs generate printed an input's default with Go's %v, so a list of objects came out as [map[id:map[num:1]]], unreadable, and with the quotes gone that made "1" a string, which an assistant copying it would get wrong. They now print JSON; generated docs shorten a long list to its first element and a count.
  • fieldEquals compares a list or an object by its structure. Anything but a scalar used to be compared by its printed form, so {population: 3645000} failed against the JSON {"population": 3645000} (a float prints as 3.645e+06), and ["4"] passed against [4]. Lists and objects now match element by element and key by key, numbers by value and strings only as strings, at any depth; a failure shows both sides as JSON. errorDetection rules compare the same way.
  • gRPC rough edges, found by reviewing the feature against its own docs. A descriptor set named by proto: in aat-project.yaml — the form the guide teaches first — was parsed and read by nothing, so every gRPC node went unvalidated and the run then failed telling the user to do what they had already done. It works now, for aat validate, aat run, aat prompt and the MCP server alike, and a project that names a descriptor set its nodes do not use, or uses descriptor sets it does not name, is told so rather than passed over in silence.

A node's proto: is now checked against its template. Pointing a gRPC node at an HTTP template, the natural mistake of forgetting protocol: grpc, used to pass aat validate --strict and fail three steps into a run, after a cart and an order already existed; it fails validation, and fails a run before its first step. A node whose proto: and template rpc: name different methods is caught too — validation was checking one method while the wire called another.

A gRPC call had no deadline at all, so a server that accepted and never replied held the step for as long as the run lasted; it now gives up after the same 30 seconds an HTTP request does, and says so in the same words. pathRewrite beside a grpc:// baseUrl is rejected, as the documentation always said it was. A status assertion written as a gRPC status name can no longer contradict expectFailure unnoticed.

A gRPC target with a path after its port — grpc://localhost:8767/shop.v1.Payments, the natural thing to write — passed every check and then failed the step as UNAVAILABLE; only a target with no port was checked for a path. And grpc://localhost, with no port, dialled 443 in plaintext. Both are now errors that say what is wrong, from aat validate as well as from a run; grpcs://host means 443.

A gRPC step interrupted with Ctrl-C, or cut off by the time an aborted run allows its cleanup, was recorded as a CANCELLED or DEADLINE_EXCEEDED response: a client error with a body, which the step's assertions then ran against. It is an error on the step, as it is over HTTP. The details a server attaches to a failure — google.rpc.BadRequest and its field violations, ErrorInfo, RetryInfo — were archived as a bare type URL unless the project's descriptor set happened to include error_details.proto; they are now always read. And a reply over 4 MiB, gRPC's default limit, failed as RESOURCE_EXHAUSTED and was retried as transient; a reply is now read whatever its size.

The MCP server's tool and resource descriptions — the text an assistant chooses a tool by — still called every template an HTTP template with a method and a path, though the tools themselves had learned gRPC. They now say what the tools return for either protocol. aat://api/overview listed a gRPC node with no method at all, and get_oas_operation told an assistant that a gRPC node was missing its OAS reference; the overview names the method, and the tool says what the node is.

Copy as grpcurl produced a command that failed against any server without reflection, the sandbox included, with nothing to say why; it now opens with a comment naming the -protoset flag to add.

No call had ever been made over TLS: grpcs://, a private CA, mutual TLS, serverName, and insecureSkipVerify were tested only as far as building their settings. Each is now tested against a server that needs it, from env.yaml to the handshake, and a run against a public grpcs:// endpoint verified the system roots. Doing it found that a failed handshake came back as UNAVAILABLE, which is transient and so retried, though an untrusted certificate fails the same way every time; it is now an error on the step that says what TLS objected to and names the grpc.tls settings. Binary metadata, a key ending in -bin, was encoded twice on the way out and written into archives as bytes JSON cannot hold; a template now writes it as base64, as grpcurl -H takes it, and an archive records it the same way.

The demo's gRPC replies carried an empty payment id. The HTTP payments API calls it paymentId and payments.proto called it id, and the sandbox's gRPC service discarded the field it could not place, so examples/grpc-payments extracted "" and nothing asserted otherwise; a refund came back as a half-empty Payment, and an amount of 0 refunded the whole order. The proto now mirrors the HTTP API field for field — a refund is a RefundRecord with its own id and the payment's — a field the two disagree on fails the call instead of vanishing, and the example and make example-grpc assert the id.

The web UI shows the gRPC status a plan wrote rather than the HTTP status it maps to — several gRPC statuses share one, so the number could not say which — and so do the CLI's failure summary and the MCP archive tools. Copy as grpcurl always emits -d, without which grpcurl reads the message from stdin and the pasted command appears to hang. The progress lines keep their columns aligned when a step reports a status name instead of three digits. gRPC metadata is archived with the lowercase keys the wire uses, so a key grepped for in an archive is the key the server sent.

  • Ctrl-C ends aat web at once, and without an error. A browser keeps spare connections open to a server it is showing, and Go will not close a connection that has sent nothing until it is five seconds old; aat web allowed its shutdown five seconds. So stopping it with a browser tab open could wait out the whole of that and then exit with context deadline exceeded. A connection that never sent a request is now closed when the shutdown starts. It also made one of AAT's own tests fail intermittently, which is how it was found.
  • --override NODE=URL keeps the node's own credential. The flag took the environment's top-level auth even when an overrides: entry gave the node another, so --override paymentCharge=URL sent the shop's bearer token to the payments host in place of its API key, and the call failed as unauthenticated. A flag now changes the URL and nothing else: the node keeps the headers, the credential, and the pathRewrite of the entry that matches it, or the environment's when none does.
  • The Homebrew cask clears the macOS quarantine attribute with a declarative postflight_steps stanza, so brew no longer warns that postflight is deprecated and asks users to report it to the tap. The published cask in gburgyan/homebrew-tap was updated in place, so the warning is gone without waiting for the next release.

0.2.0 - 2026-09-16

The public launch release, and the one the three real-API projects (aat-duffel, aat-stripe, and aat-shippo) were built on; they need this version or later.

Highlights. Form-encoded request bodies end to end, including Stripe-style bracketed keys, validated against the spec and shown as fields in aat run show, the web UI, and MCP. aat run show reads a run or a whole batch from the terminal: the steps, or one step's request, response, inputs, outputs, resolutions, or response shape. repeat polls and pages with until and next, with request pacing (minRequestInterval), Retry-After, and a retry ceiling. Values and assertions reach across steps: {{step.output}} in predicates, offsets on references, uuid, random, now, lists and maps as step values, and header extraction. Cleanup chains, when, releasedBy, and guard plans. OpenAPI specs that used to be rejected (circular references, 3.1 type lists, allOf/oneOf/anyOf bodies) now load, validate, and scaffold. Credentials are redacted from --dump-state by default and from every archive. Validation and composition are stricter: literal value shapes, leftover AUTOWIRE markers, unknown assertion types, and bad expressions fail before a request is sent. The MCP server keeps parity with the CLI. The docs gain Why AAT exists, Real APIs, and the AI assistant primer as llms-full.txt.

Breaking and noticeable. aat run plan --dump-state redacts credentials; --dump-state-secrets keeps them. aat run show --step --json names a step's assertion results validation, not assertions; summary.json records OpenAPI results under oas; --resolutions reports a graph default's literal as graph_default, and plan_default now means a value the plan sets. A node's second cleanup step is deleteCart_2 in archives and --json output. Exit code 2 for aat generate over existing files (use --force), for --oas-validate strict when a spec fails to load, and for a plan that ran as several runs. A list in a URL or form body is sent as repeated pairs or a comma-joined value, not as JSON text, and a form-encoded body: | no longer sends its final newline. <, >, <=, and >= compare two decimal strings as numbers; uuid, now, and unixtime are reserved words in expressions. A registered cleanup is skipped when a later main step already released the resource.

Added

  • An apikey auth can write a scheme of its own before the key with valuePrefix: headerName: Authorization with valuePrefix: "ShippoToken " sends Authorization: ShippoToken <key>, so an API that defines its own scheme doesn't force the scheme into the secret. The prefix is written exactly as given, takes ${var} substitution, and is inherited through extends. It is refused on any other auth type, and bearer is told that it already sends Bearer before its token.
  • An expression can put an offset on a reference, and a step value can read an earlier step's output: amountToCapture: "{{authorize.amount - 500}}", "{{order.totalAmount - 10.50}}" on a decimal written as text, and "{{clock.frozenTime + 32 days}}" on Unix seconds. A number offset keeps whole numbers whole and decimal text at its places, and a time offset moves Unix seconds by its unit or a date by whole days. Assertions, repeat.until, and selection filters take the same offsets, and a value's reference implies dependsOn and is checked by aat validate.
  • A form-encoded body reads as what was sent, rather than as one escaped string. aat run show --request prints its decoded fields, one name=value per line; --compact prints the body as it went over the wire; and --path and --shape read the value its bracketed keys describe, so --path metadata.source selects from metadata[source]=aat-stripe, and a key ending in [] or sent twice reads as an array.
  • The step view notes the body as 91 bytes (form, 5 fields), and its --json adds request_body_form.
  • The web UI's Request and Response tabs show a form body as its fields, and the API adds formFields to each.
  • The MCP server's inspect_archive prints form bodies as fields too.
  • aat run show --step ID lists a repeated step's requests: each one's status, time, and whether until held, the inputs it sent that differ from the step's when it pages, and its outputs. --iteration N shows one of them, and with --request, --response, --inputs, --outputs, --path, or --shape prints that request's part.
  • --json gives the list as the step's iterations, and --iteration N --json gives the request.
  • The web UI's step detail has a Requests tab. Selecting a request loads its bodies, headers, inputs, outputs, and OpenAPI validation from GET /api/runs/{id}/steps/{stepId}/iterations/{index}.
  • The MCP server's inspect_archive lists each repeated step's requests.
  • A selection filter can read an earlier step's output, as an assertion can: filter: 'objectId == "{{create.customerId}}"' picks the element about the object an earlier step made. The reference implies dependsOn, aat validate checks it, and the step's selection record shows the filter with the value it compared.
  • A fieldAbsent assertion passes when its path is missing or null, the counterpart of fieldExists. A plan can assert that a response leaves a field out, such as an error body without a code, which a predicate can't name.
  • An assertion can compare with an earlier step's output, written {{step.output}}: amount == "{{checkout.total}}" in a predicate, value: "{{checkout.total}}" in fieldEquals, and a quoted literal in repeat.until.
  • A main step reads the main steps before it, and a verification step reads the main steps. A reference implies dependsOn, and addon composition and isolated mutation clones rename the steps it names.
  • An extracted number compares as a number. A null or list output, a step that stored no outputs, or an output the response left out fails the assertion, with a message saying which.
  • aat validate reports a reference to an unknown step or output, to the step's own output, to a list output, or to a step that expects failure, and one in a step value, where from: reads an earlier step.
  • The shop's full-lifecycle plan checks that checkout keeps the cart's subtotal and that the refund and the returned order match checkout's total.
  • A step can read every page of a listing with repeat.next, which sends each response's cursor as the next request's input, as in repeat: {next: {after: nextCursor}, collect: [orders, orderCount], max: 50}.
  • The step passes when every cursor comes back missing, null, or "", and until becomes optional. It fails when max or timeout comes with a cursor left, so an audit can't pass on a listing that was cut off, and when a response gives cursors an earlier request already sent.
  • Pages are requested without a wait unless interval is set, and a retry resends that page's cursor.
  • The step's inputs are its first page's. Each request under the archive's iterations records the inputs it sent, and repeatStop can be exhausted or loop.
  • aat validate checks that next maps the node's inputs to its string or integer outputs.
  • The shop sandbox lists orders a page at a time with GET /orders (limit, after, customerEmail), which the shop graph reads as listOrders. The state-machine plan's verification pages through its customer's orders and asserts that none is left open.
  • A visualizer can match a nested field with match.bodyPath, a gjson path that must reach a value other than null, such as data.receipt_number. It tells apart the responses of an API that wraps every body in the same envelope, where bodyContains sees only the shared top-level key.
  • A step can repeat its request until a condition over the response holds, as when polling a background job: repeat: {until: 'status == "complete"', interval: 2s, max: 30, timeout: 2m}.
  • Every request sends the inputs resolved for the first, and each is retried under the step's retry: block. A response's Retry-After lengthens the wait, up to 60 s.
  • collect gathers outputs across the responses: a list's items are appended, and an integer or float is added. The step's assertions run once, on the last response's outputs with the collected ones.
  • Reaching max (default 50, at most 1000) or timeout before until holds fails the step with a repeat assertion result.
  • aat validate checks that until reads the node's outputs, that collect names lists or numbers, and that the step isn't an expectFailure step or on a node with a cleanup pairing.
  • The archive records each request under iterations and why the step stopped under repeatStop. OpenAPI validation counts every request. The progress line and aat run show give the number of requests, and the MCP server's step details do too.
  • An extract rule can read a response header, as in requestId: {header: X-Request-Id}, and Lua transforms get header(name).
  • Names match in any case, and a header sent more than once gives its values joined with ,.
  • A header value is converted to its graph output's integer, float, or boolean type, so a predicate such as rateLimitRemaining > 5 compares numbers.
  • optional: and default: work as for a path. A template whose rules all read headers needs no JSON body, so a 204 No Content still gives outputs.
  • The MCP server shows a header rule as header X-Request-Id where it shows extract paths.
  • The shop sandbox sends RateLimit-Limit, RateLimit-Remaining, and RateLimit-Reset on its API responses. It counts each bearer token's requests per minute and never refuses one. The shop example reads the first two on listProducts, and Quick Purchase asserts that its request was counted.
  • An extract rule can take default:, the output's value when the path is missing or holds null, as in nextCursor: {path: meta.after, default: ""}. A rule takes default: or optional: true, not both, and a rule with fields takes a list default. aat validate reports a default whose shape doesn't fit the output's type.
  • The not found in response error names both remedies.
  • The template docs and the AI assistant primer show gjson counts and queries in extract paths. They include ==~null for a null or missing field, since ==null compares a string and never matches a JSON null.
  • A step value can be a YAML list, which is the list itself, as it already was in a slot's inject: skus: [SKU-1004, SKU-1006], or a list of maps for a template's {{#lineItems}}…{{.sku}}…{{/lineItems}}. Before, a bare list was an error, and a list had to be written {default: [...]}, which still works.
  • {value: …} in a step value means the same as {default: …}, so the literal form of graph defaults, layers, and inject works in a plan too. A step value with both keys is an error.
  • Strings inside a list or map value are evaluated as expressions, at any depth, wherever expressions are evaluated. A new list or map is built each run, so the plan keeps its expressions.
  • aat validate checks expression syntax inside lists and maps in step values, pools, and fieldEquals values, and names the item, as in invalid expression for "skus": item 1: random takes a length from 1 to 64.
  • aat run show batch-ID/PLAN names a plan's run in a batch by the plan name the batch view's PLAN column shows, such as batch-…/negative/state-machine. Before, only batch-ID/run-ID worked.
  • A run ID of the batch wins over a plan of the same name, and a permutation skipped as a duplicate doesn't count.
  • A plan that ran as several runs, such as its layer permutations, exits with code 2 and lists each run as batch-ID/run-ID with its layers.
  • The MCP server's run lookups accept the same, in get_sample_response, inspect_archive, analyze_failure, and diff_archives.
  • summary.json records a run's OpenAPI validation under oas whenever the graph references a spec, a clean run included: mode, validatedRequests and validatedResponses (cleanup steps included), and violations. Before, only violations were recorded, in issues.
  • The archive records the mode as metadata.oasValidation.
  • aat run show prints an oas: line under a run's header, and a batch's totals across its runs under its totals line. --json prints them as oas.
  • The web server's run list passes oas on.
  • A predicate assertion that names an output the step didn't produce says so, as in unknown field "trackingNumber": the step produced no output "trackingNumber", since an optional output the response didn't hold is absent.
  • aat run show <batch-id>, or a path to a batch directory or its batch.json, shows the batch. Before, a batch reference was an error.
  • It shows the totals, and one row per permutation with its layers, outcome, and step counts.
  • It shows what cleanup did across the runs: per cleanup node, the steps that ran and failed, and the pairings skipped as released or by when, plus each failed cleanup step.
  • --json prints the same.
  • Without --step, a part flag or --path prints that part of every step that has it, cleanup steps included, as in aat run show latest --response --path error.code. It prints one line each, or a JSON array with --json.
  • A predicate assertion's message shows the expressions in its literals expanded, as in predicate "amountReceived == 1000" is true. The result's expr keeps the predicate as written.
  • Under --oas-validate, cleanup exchanges are checked against the spec and recorded in oasValidation. Under strict, an invalid one fails the cleanup step, and its cleanup chain still runs.
  • Form bodies can be written as form:, a mapping of field names to values, in place of a body: query string.
  • The request is sent as application/x-www-form-urlencoded, and every key and value is URL-encoded, keeping the brackets of a key.
  • A field whose whole value is one placeholder is left out when that input has no value (absent, null, "", or an empty list or map), so optional fields need no conditional blocks.
  • Nested mappings write bracketed keys: metadata: {source: web} sends metadata[source]=web. An input whose value is a map does the same, a list repeats its key as written (tags[]), and a map in a list writes items[0][sku].
  • Other text is rendered as one value. Iteration blocks, placeholders in field names, duplicate fields, and fields that nesting would send twice are errors when the template loads.
  • A credential or overlay header that changes the Content-Type fails the request.
  • The MCP server's template view shows the form.
  • aat validate matches an input that is the whole value of a form: field to that field, so a graph can name its inputs its own way: order_id: "{{orderId}}" counts as the spec's order_id, in both the unknown-input and the required-field checks.
  • aat validate matches path and query parameters the same way: /orders/{{orderId}} fills the spec's /orders/{order}, and status={{orderStatus}} counts as status, inside a conditional block too. Paths line up from their last segment, and a segment with other text around its placeholder still needs the spec's name.
  • aat validate and aat run report a header or form: field whose whole value names no input of the node, which would never be sent.
  • Verification steps take values, as main steps do, to read a particular step: values: {orderId: {from: firstCheckout.orderId}}. A reference names a main step, and aat validate checks the input names, the references, and the values' shapes.
  • --resolutions and aat run show --step name the layer that set a value. A layer's literal value reads layer, and the record carries layer: shipping-express for any value a layer set. The MCP server's inspect_archive shows it too.
  • settings.minRequestInterval paces requests for APIs with rate limits: the starts of any two requests are at least that far apart, such as 250ms. One interval covers everything a command sends, including the plans of a parallel batch, retries, verification, and cleanup. aat prompt and the MCP server's execute_plan honor it too. A value without a unit, such as 250, is rejected when the environment file loads.
  • Step retries honor the server's Retry-After header, and on a 429 without one, RateLimit-Reset. A retry waits at least as long as the failed response asks, in seconds or until an HTTP date, instead of only the backoff.
  • Cleanup chains: a cleanup node can declare its own cleanup. It runs right after that cleanup step succeeds and takes its outputs first, so a resource released in two calls (request, then confirm) is cleaned up completely. Chained outputs stay inside the chain. Archive cleanup records gain cleanupFor (cleanup_for in --json), naming the step each one cleans up after.
  • A cleanup pairing can say when it isn't needed: cleanup: {node: voidPayment, when: 'status == "authorized"', releasedBy: [capturePayment]}.
  • releasedBy lists nodes whose success, after the creating step and with the same values for the inputs they share with the cleanup, releases the resource.
  • when is a predicate over the creating step's outputs, or the previous cleanup step's in a chain. The cleanup is skipped when it's false. A when that can't be evaluated runs the cleanup and records whenError.
  • A skipped cleanup's chain doesn't run. Skips print as skipped: lines under cleanup:, and are recorded in the archive's cleanupSkipped, in cleanup_skipped in --json, in a cleanup skipped: table in aat run show, and in the MCP server's inspect_archive.
  • Loading a graph checks that releasedBy names other nodes and that when parses and names the node's outputs, so aat validate, aat run, and the MCP server all reject a bad pairing. aat docs generate adds When and Released By columns when a pairing uses them.
  • A min or max selection whose candidates tie prints a warning under its step, as in warning: selection "cheapest": 3 of 8 elements from listProducts.products tie for min price at 19.99; picked index 0.
  • The warning is also in the step's warnings in --json, in aat run show --step, and in the MCP server's inspect_archive.
  • Archive selection records gain field, sortField, sortValue, ties, and onTie.
  • onTie: fail on a selection fails the step on a tie instead, and onTie: first takes the first without a warning. Plan validation accepts onTie only on min and max.
  • aat run show --step ID --resolutions prints how each of the step's inputs got its value, as JSON.
  • Each entry gives the source, the upstream step and output, the expression, pool pick, or constraint, and the selection that picked the value.
  • --step lists each input with its source, and --step --json includes resolutions.
  • A step that fails before its request is sent keeps the resolutions made up to that point, with an error record for the input that failed. That covers an input that can't be resolved, a missing adapter, a request that can't be built, and a send error. Before, such a step recorded none.
  • The MCP server's inspect_archive shows each input's selection and error.
  • aat run show --compact prints JSON on one line: a step part, with or without --path, and with --json the step list, the step, or the shape. --compact where the output is text, such as --shape without --json, is an error.
  • aat validate and aat run check that literal values fit their input's shape: a list for an array input, and no map for a single-value input. A list for a single-value input is still allowed, since a template can send it as repeated pairs.
  • It covers step values and pools, graph defaults, layers, and slot inject values.
  • A step expected to fail, such as a mutation, isn't checked, since a negative test may send the wrong shape on purpose.
  • A slot inject value fails only when no input with its name takes it, since composition decides which steps it reaches. The composed plan's steps are then checked like any others.
  • A pool entry for an array input gets a hint: a bare list in a graph default or a layer is a pool, so write one list as {value: [...]}.
  • Expressions and custom types aren't checked.
  • Expressions in assertions: a fieldEquals value and a quoted string in a predicate expr can hold {{…}} expressions that name the step's inputs, such as value: "{{today + 3 days}}" or expr: 'quantity == "{{quantity}}"'.
  • A quoted expression that yields a number or a boolean compares as one.
  • An expression that can't be evaluated fails its assertion.
  • today, now, and unixtime read the time the step's inputs were resolved, so a retry compares with what it resent.
  • Selection filters and cleanup when conditions stay literal.
  • AUTOWIRE? in workflow templates marks an optional input that only some compositions feed. It is wired when a step produces the output, such as one an addon adds, and left unset otherwise.
  • aat generate scaffolds HEAD, OPTIONS, and TRACE operations, form-encoded request bodies, and cookie parameters, which it sends as one Cookie header. Body and response properties from allOf branches become inputs and outputs, and an OpenAPI 3.1 type list such as ["null", integer] maps to its non-null type. The MCP server's operation search lists operations of every method too.
  • aat run show <run> prints what an archive recorded, without a browser.
  • Without --step, it lists the steps with their node, HTTP status, result, duration, and output names, then the verification and cleanup steps.
  • --step shows one step. --request, --response, --inputs, and --outputs print that part as JSON.
  • --path narrows the part with a gjson path.
  • --shape prints the part's structure: each path with its type, array sizes, how many objects hold a key, and a sample value.
  • The run is latest (runs inside batches included), a run ID, a batch ID and run ID joined by a slash, or a path to a run directory, an archive file, or an .aar export.
  • The MCP server's get_sample_response takes path and shape, as aat run show does.
  • Its run_id accepts latest and a batch ID with a run ID, and finds runs inside saved batches.
  • The same goes for inspect_archive, analyze_failure, and diff_archives.
  • The AI assistant primer is published as raw Markdown, for tools that fetch pages.
  • llms-full.txt at the docs site's root holds the whole primer, and llms.txt indexes it and the reference pages.
  • aat docs primer prints the same primer from the binary, in the version that matches it.
  • The primer links to docs pages by URL, so its links also work outside the site.
  • OAS validation checks form-encoded request bodies (application/x-www-form-urlencoded) against the operation's schema. Bracketed keys such as items[0][sku]=… and tags[]=… are read as nested objects and arrays, and values take the types the schema allows. Before, only JSON request bodies were validated, and a form body was not checked.
  • {{@index}} in an iteration block is the element's position, counting from 0, for keys such as items[{{@index}}][sku].
  • aat generate --operation and --path scaffold only some of a spec's operations: the operationIds named, or the operations under a path, matched by whole segments. An operationId the spec doesn't have is an error that suggests similar ones, and so is a path that matches nothing.
  • Expressions for generated values and timestamps: {{uuid}} (a random version 4 UUID), {{random N}} (N random digits and lowercase letters), {{now}} (UTC, RFC 3339), and {{unixtime}} (Unix seconds, an integer). now and unixtime take offsets in seconds, minutes, hours, or days, such as {{unixtime - 1 hours}}. Each occurrence generates its own value, and a retried step resends the values its first attempt generated.

Changed

  • The docs describe the three real-API projects on one page, Real APIs, the README says what people use AAT for, and the guide pages open with why each feature exists before how it works.
  • The documentation says why AAT exists, not only what it does. A new "Why AAT exists" page (docs/user/why.md) opens the Getting started section with the tool's origin — a pile of shared Postman collections that had stopped being trustworthy — maps each problem to what replaced it, and then covers what else turned out to be able to read the graph once it existed: the MCP server, run archives as evidence you can send someone, generated reference docs, and handing off mid-run to CI or another tool. It also says plainly what an on-ramp from an existing OpenAPI spec or Postman collection looks like. The README's Why and the docs home carry the short version.
  • The README, the examples index, and ROADMAP.md list the three projects built against real, public APIs in test mode — aat-duffel, aat-stripe, and aat-shippo — with their coverage and batch results. They were listed as planned work before.
  • A predicate's <, >, <=, and >= compare two strings that are both decimal numbers as numbers, so "1000.00" > "999.50" holds; before, they compared as text. == and != still compare text, and dates and other strings still order as text. This applies wherever predicates are read: assertions, selection filters, constraints, and cleanup when.
  • aat run show --step --json names a step's assertion results validation ({passed, results}), as archive.json does, where it said assertions.
  • aat validate notes a workflows, layers, or plans directory that the manifest names but that doesn't exist yet. It reads as empty, where before it failed the manifest check.
  • Manifest messages print paths relative to the manifest.
  • A missing layers directory loads no layers. A run that asks for a layer says the directory doesn't exist.
  • A header whose whole value is one placeholder, such as Idempotency-Key: "{{requestKey}}", isn't sent when that input has no value, instead of failing with unresolved placeholders. The {{?requestKey}}…{{/requestKey}} wrapper still works, and a placeholder inside other text still needs a value.
  • aat generate scaffolds a form body as form:, one field per property with no Content-Type header, and writes an array property the spec encodes as a deepObject as name[]. An object property no longer warns, since a map value is sent as bracketed keys. An optional header parameter is a plain placeholder.
  • A from, a fromInput, or a named selection's from adds the step it reads to dependsOn when the plan is instantiated, as graph defaults and composition already did. Plan validation no longer reports a reference whose step is missing from dependsOn, and a cycle message names the value or selection that implies a dependency. An isolated mutation checks its clone IDs against the dependencies references imply.
  • A graph default's from: node.output reads the nearest earlier step on that node that isn't expected to fail, instead of the node's first step. On a verification step it reads the last such step, chosen before mutations expand.
  • Before, a verification after a refused request on the node read the refused step and failed with no outputs, and one after two successful steps read the first.
  • AUTOWIRE passes over steps expected to fail as well.
  • A graph default's literal value reads graph_default in --resolutions, where it read plan_default, which now means a value the plan sets.
  • Composition wires the AUTOWIRE markers that the slot and addon passes leave, once the plan is complete: a base or slot step can take an output that only an addon produces, and a base workflow without slots or addons resolves its markers. The final pass takes the nearest earlier producer that does not depend on the step. Markers the earlier passes already wired are unchanged.
  • A plan that still holds an AUTOWIRE marker after composition fails validation, naming the step and input, instead of sending the word AUTOWIRE. aat prompt asks the model for those inputs, and aat validate plan composes standalone base workflows before validating them.
  • Workflow compatibility checks cover base workflows and slot options. A plain AUTOWIRE that the base and its slots cannot feed is a warning that names any addon producing the output. AUTOWIRE? on a required input with no graph default is also a warning. AUTOWIRE? in an addon never warns as unfed.
  • aat generate no longer replaces an existing graph file or template: it lists them, writes nothing, and exits 2 unless given --force. OperationIds that differ only in case are an error, since their templates would be one file on a case-insensitive file system.
  • A generated template sets Content-Type only when it has a body, to that body's media type; before, every operation with a request body got application/json. aat generate warns about what a template leaves to write by hand: multipart and other bodies, a body schema with oneOf, anyOf, or no properties, and a parameter with a non-default style or explode: false. OAS validation reads request body properties from allOf branches too.
  • A list value in a request URL or form body is no longer sent as JSON text. Right after key= in a query or form body it repeats the pair (tags=a&tags=b); anywhere else its elements are encoded one by one and joined with commas (/items/1,2).
  • A step whose failed response asks for a wait longer than 60 seconds stops retrying (failed_fast), and the error detail says how long the server asked for. Before, the retries went out after the backoff regardless.
  • Docs: the Homebrew cask is documented for Linux as well as macOS. brew install gburgyan/tap/aat installs aat and aat-sandbox with Homebrew on Linux.
  • min and max selection compare numbers sent as strings by value: a sortField of "99.10" sorts below "1000.00", so an API that returns prices or totals as decimal strings selects the cheapest element. A string that is not a number still fails the selection, naming the element.
  • Graph validation rejects cleanup pairings that loop back, such as a node cleaned up by b whose own cleanup is that node again. The error names the cycle once, as a → b → a.
  • Cleanup step IDs are unique within a run: a node's second cleanup step is deleteCart_2 in archives and --json output, as the web UI already named it. Cleanup responses are checked against the graph's error detection rules. A flagged response records responseBodyError and ends its chain; the run outcome is unchanged.
  • Breaking: aat run plan --dump-state redacts credentials by default, as run archives do.
  • Credential headers such as Authorization and X-API-Key read [REDACTED], at the top level and in every step.
  • Known secrets are replaced wherever they appear, and the export gains "redacted": true.
  • --dump-state-secrets keeps live credentials, for a harness that sends requests as the run's session. It warns on stderr when the dump goes to stdout, and it is an error without --dump-state.
  • A harness that reads auth.headers to send requests needs the new flag.
  • Docs: the AI assistant primer covers more ground:
  • layers and batches
  • step value forms: pools, literal lists, and {}
  • expressions and assertion details
  • retries and selection ties
  • Lua transforms, and reading results with aat run show
  • Docs: two pages disagreed with the code and are corrected:
  • plans.md no longer says {} skips graph defaults for required inputs.
  • value-flow.md says an inline min or max can use field alone, and how ties break.
  • --oas-validate strict stops before the first request, with exit code 2, when a spec the graph references fails to load. Before, the run printed a warning and continued without validating, and --quiet and --json hid the warning. In auto mode the warning now goes to stderr, where --quiet and --json keep it visible. aat prompt behaves the same way.
  • In a form body, or in a path after its ?, an iteration block whose body writes a whole key=value pair joins its copies with & instead of commas, so {{#tags}}tags[]={{.}}{{/tags}} sends tags[]=a&tags[]=b. A block whose body starts or ends with & is repeated with nothing between the copies. Blocks elsewhere, including JSON bodies and blocks inside a pair, still join with commas.
  • aat generate writes each optional form body field as one conditional block that brings its own &, so a body of many optional fields grows with their number rather than its square. A request body the spec declares empty (no properties and additionalProperties: false) gets no body and no warning. A form body property that takes an object, or an array of objects, gets a warning to write its keys by hand as bracketed pairs.
  • A run that stops on a step's error names the step, as in step "addSocks" (addItem): executing HTTP request: …, so the aat: line and the --json error say which step failed.
  • A retried step sends the same inputs on every attempt. Its values are resolved once, before the first attempt, so a random pool pick, a today date, and an overlay value no longer change between attempts. A plan-level --retries rerun still resolves them again.
  • uuid, now, and unixtime are reserved words in expressions, so {{now}} no longer refers to an input named now. An offset in hours or minutes on today, or on a reference, is an error that suggests now or unixtime.
  • Plan validation lists each distinct problem once, aat validate prints an error repeated word for word once, and a step's values and selections are checked in name order, so the output is the same on every run.
  • A registered cleanup no longer runs when a later main step on the cleanup node already released the resource with the same inputs, such as an explicit cancel of the order the pairing would cancel. Before, the cleanup ran again and usually failed with a 4xx. The skip is recorded, as for a pairing's releasedBy.
  • A slot option's inject values decode like graph input defaults.
  • {value: …}, {pool: …}, {from: …}, and expressions work. Any other key is an error, so inject: {ages: {default: [35]}} fails validation instead of sending a map. A bare list is still the literal list.
  • An injected value no longer replaces a step's pool, constraint, or fromInput.
  • inject on a base workflow or an addon, where it was ignored, is a validation error.
  • Validation reports an assertion of an unknown type, such as fieldEqual, and bad expression syntax in step values, pools, and assertions. The checks run in aat validate, when a workflow template loads, and when a recipe's override assertions are applied. Before, an unknown type failed only at run time, a recipe override of one was dropped silently, and a bad expression failed only when its step ran.
  • An optional input that takes from: an output the earlier step didn't return, directly or through a named selection, is left out, as AUTOWIRE? leaves one unset, instead of failing the step. Its resolution records optional_skip. A required input still fails, and aat validate --strict warns when a required input takes from: an optional output, in a graph default or in a plan or workflow file, or reads one through a named selection. The MCP server's validate_plan and save_plan list the warnings for a plan file.
  • Docs: the OAS validation pages no longer claim checks that don't run (the HTTP method and input types in aat validate, and every request at run time). The AI assistant primer covers starting from an OpenAPI spec, form bodies and query strings, headers and idempotency keys, lists and pagination, the request timeout, and reaching an object after an expected failure.

Fixed

  • aat docs generate's diagram draws the wiring between nodes. It read requires/satisfies tokens and cleanup pairings only, so a graph that wires its nodes the usual way — an input defaulting from: otherNode.output — came out as a wall of unconnected boxes. An input's from: is now an edge too, after the tokens and the cleanup arrows, so an explicit pairing keeps its own style. On the example projects the diagrams go from 0 edges to 13, 39, and 63.
  • An input sent as a JSON body property the spec names otherwise counts as that property, as one sent as a form field, a query parameter, or a path segment already did. "mass_unit": "{{massUnit}}" now matches the spec's mass_unit, quoted or bare and inside a conditional block, in both the unknown-input and the required-property checks. Until now only form-bodied APIs could name inputs for the package rather than the spec; a JSON-bodied one had to spell every input the spec's way or carry a warning for each.
  • aat validate --strict knows the inputs of a node whose request body is a oneOf or anyOf, or whose inputs name properties of an object nested inside it. A template routinely flattens a nested body into one input per leaf, and every one of those was reported as unknown. Only the schema and its allOf were read, so a body written by hand for a composed schema had every one of its inputs reported as input "weight" not found in OAS parameters or request body. aat generate declines to write such a body and says so, so the operations it hands over were exactly the ones that then warned — six of them in Shippo's spec. Property names are now collected from every branch the body can take; an input no branch declares is still reported, and what counts as required is unchanged, since a property required in one branch is not required of the body.
  • A block key may end in [], so an input named for an array query parameter can gate its own block: {{?status[]}}?status[]={{status[]}}{{/status[]}}. Block tags matched letters, digits, underscores, and hyphens only, so the tags were never recognized: aat validate --strict rejected the template with template requires placeholder "status[]" but graph input is optional with no default, and a request failed with unresolved placeholders: ?status[], /status[]. aat generate writes exactly that shape for a parameter whose name ends in [], so its own output failed validation for any spec that has one.
  • A conditional block around an iteration block of the same list renders, as in the templates guide: {{?ids}}, "ids": [{{#ids}}"{{.}}"{{/ids}}]{{/ids}}. Both blocks close with {{/ids}}, and each block took the first one, so an absent list failed with unresolved placeholders and a present list rendered malformed JSON, such as ["p"],"q"], without an error. A closing tag now closes the innermost open block with its name.
  • A plan with a list or map step value reads back after it's saved, as by aat prompt --save or the MCP server's save_plan. A list was written bare, which the parser rejected, and a map was written as the step value's own keys; a map is now written under default:.
  • OpenAPI specs with circular references load. A schema that refers back to itself, directly or through another schema, used to fail with infinite circular reference detected, so aat generate, aat validate, and the MCP server rejected the spec, and aat run skipped OAS validation. Large published specs have such cycles. Other errors in a spec still fail it, and libopenapi's log lines no longer reach stdout.
  • Runs with OAS validation start quickly on a spec with hundreds of operations. The validator is built for the operations the graph's nodes name, not for the whole spec, which could take most of a minute before the first request.
  • OAS validation accepts null for an OpenAPI 3.0 schema marked nullable: true that is built with anyOf or oneOf, such as a field that holds either an ID or an expanded object. The validator added null only to a schema's own type, so a response with such a field set to null failed with got null, want object.
  • A request body that is neither JSON nor form-encoded, or a schema the validator can't compile, is reported as not validated. The archive marks the payload skipped with a reason, the step line shows OAS: request not validated or OAS: response not validated, and a schema assertion is skipped with the reason. Before, the step line could read OAS: 0 warning(s), and a schema assertion failed with an empty message.
  • A form-encoded body no longer sends the final newline of a body: | block, which the server read as part of the last value. Whitespace around the body is removed for form bodies only.
  • The static OAS check no longer reports a required form field or query parameter as missing when the template writes it. The keys of a form-encoded body count as supplied, and a bracketed key such as metadata[source] supplies metadata.
  • The static OAS check no longer reports an input that the template sends only in request headers, such as an idempotency key, as missing from the operation's parameters and request body. The template names the header, and specs often leave such headers undeclared, so aat validate --strict failed on it.
  • Two inputs that pick from the same array with min or max by different fields no longer get the same element. The selection cache left out the compared field, so the second input got the element chosen for the first. A match selection's filteredSize is now the number of matching elements, not the array's size.
  • A required input marked {} falls back to its plain default with layers applied, and evaluates the expressions in it, so postalCode: {} with a default of {{env.postalCode}} sends the variable's value, not the text. A layer's value for the input wins over the graph default, as it does without {}.
  • A request that times out says so, naming aat's 30-second request timeout, instead of giving only Go's context deadline exceeded (Client.Timeout exceeded while awaiting headers). The limit is named only when the whole limit passed, so a shorter timeout or an interrupted run isn't blamed on it.
  • aat generate writes the graph's oas: reference relative to the graph file's directory, so a spec kept elsewhere resolves. It used to write only the spec's file name.
  • aat generate types object body properties object and inserts them as JSON literals instead of quoted strings, and no longer writes a node-level name: line.
  • aat run plan and aat run batch redact the access token they authenticated with, such as an OAuth2 token, wherever it appears in an archive. Before, it was masked only in credential headers.

0.1.0 - 2026-09-12

The first release with release archives, a Homebrew cask, and a Docker image. v0.0.1 through v0.0.4 are retracted, so go install and go get skip them.

Added

  • aat-sandbox, a second binary that serves an offline e-commerce demo API (aat-sandbox serve: shop API on :8765 with OAuth2 tokens, payments API on :8766 with an API key, us/eu regions with their own currency, tax, tiers, and coupons, an order state machine, simulated latency, and two chaos hooks for retry demos) and extracts the examples/shop project (aat-sandbox init). The contract lives in examples/shop/openapi.yaml; the server tests validate every response against it. make build builds both binaries; make sandbox builds only the demo server. The sandbox binds 127.0.0.1 unless --host says otherwise.
  • examples/shop, the offline quick-start project for aat-sandbox: a 17-operation graph, Quick Purchase and Checkout workflows with slots and addons, 12 layers, 7 plans (full order lifecycle, retries, a negative state-machine walk, addItem mutations), a declined-card overlay, a receipt visualizer, us/eu environments with payments routed to their own host and credential, and MCP configuration for AI coding tools. Its graph describes the API for the AI tools that read it through MCP: each operation lists its error codes in the order they are checked, and each input and output says what it must be, where it comes from, and what it means.
  • The shop example ships an integration kit, the part of an AAT project that an API's integrators get. aat-kit.yaml names the graph, templates, OpenAPI spec, domain file, workflows, sandbox environment, and three reference plans. sh package-kit.sh copies them into a directory and a tarball, with the kit manifest as its aat-project.yaml. The shop-api server in .mcp.json reads the kit manifest, so it shows what an integrator's AI tool would see. make example-shop packages the kit, then validates and runs the unpacked copy.
  • Multi-environment files: one environment file holds named environments with shared settings, inheritance (extends), ${var} substitution in every string, abstract environments (names starting with _) to inherit from, and include: to split out files such as secrets. --env NAME, AAT_ENV_NAME, an overlay's environment:, or the manifest's defaultEnvironment selects one, in that order. defaultEnvironment applies only to the manifest's own environment file, and a single-environment file still loads, with --env an error for it. aat env list lists the environments, and aat validate checks each one.
  • --var KEY=VALUE (repeatable) on aat run plan, aat run batch, aat prompt, aat validate, aat env list, and aat mcp serve sets a var of a multi-environment file for one invocation, for example to point examples/shop at a sandbox on other ports or in a container. A key the file never declares or references is an error.
  • An environment's values: map holds per-environment data that plan values, graph input defaults, and layers read with {{env.KEY}}; an OS environment variable KEY wins over it.
  • Overlays (--overlay FILE, .aat-overrides.yaml, and now aat-overrides.yaml) can set auth and headers for every request, and environment: to choose the environment. Overlay headers win over the credential on every route. overrides: entries in environment files and overlays accept values: and expectFailure:, so a happy-path plan reruns as a negative test without edits; the archive records each override value as the input's resolution, with the source override_value.
  • OAuth2 auth accepts grantType and extraParams for providers such as Auth0. --verbose-auth logs token requests and responses to stderr, with the access token masked and at most half of a short password or client secret shown.
  • Step mutations: expand a step into independent sibling steps when the plan is instantiated, each with set: input overrides or a rawBody: for a malformed payload. mutationScope: isolated gives each sibling its own copy of the steps it depends on, and --no-mutations drops mutations for a quick run. The schema assertion type validates a response against the OpenAPI spec; it was a stub.
  • Checkpoints: aat run plan --stop-after STEP stops after a step and skips cleanup, so what the run created stays alive, and --dump-state FILE (- for stdout) exports the live session for another tool: the environment's base URL and auth headers, each step's base URL and request headers, unredacted, and the step outputs. The file is written with mode 0600. With --json, the summary nests the state under state and reports stopped_at.
  • Ctrl+C ends aat run plan and aat run batch as aborted (exit code 130), even during a request or a retry wait. Cleanup still runs, within a 30-second budget, and the archive is written. The web UI styles the aborted and stopped outcomes.
  • aat mcp serve --http serves MCP over Streamable HTTP, with a /healthz endpoint. --log logs each tool call as JSON to stderr.
  • --host on aat web, aat web view, aat web viewtrace, and aat mcp serve chooses the interface to bind (default 127.0.0.1; the AAT_HOST environment variable sets it when the flag is absent). The Docker image sets AAT_HOST=0.0.0.0.
  • Plan-level execution.cleanup steps execute after the main flow, honoring runOn: always|success|failure. A step that names a node paired with a graph cleanup: does not run on its own: its runOn decides whether the pairings run, once per created resource, newest first, and never for a create step that failed. Other cleanup steps run first, in declaration order.
  • Plan execution.verification steps execute after the main flow and before cleanup, with their assertions counting toward the run outcome. They appear in archives as verify_<node> steps.
  • Workflow slot options and addons can declare verification:. Composition merges it into the base workflow's, and a node they verify replaces the base's verification of that node.
  • A step that succeeds after retrying shows retried Nx: <category> in run output and on the web UI's timeline and step page. Archives record the category of each retried attempt in retriedOn, and the --json step summary in retried_on.
  • Run output lists each failed assertion under its step (status: expected status 200, got 201), and the --json step summary includes them as failed_assertions.
  • status assertions accept a status class such as expect: 2xx or expect: 4xx.
  • retry.on and retry.failOn accept HTTP status codes (on: [503]) alongside category names; aat validate plan rejects unknown rules.
  • --oas-validate strict (and settings.oasValidation: strict) now fails a step whose request or response violates the OpenAPI spec; it previously behaved like auto. expectFailure steps are exempt, and skipped validations or schema compilation warnings never fail a step.
  • aat validate checks the layers directory: parse errors, duplicate layer names, and layer input keys that match no node input (which layers silently ignored).
  • aat validate checks the domain file and visualizers.yaml (Domain and Visualizers sections), and names each error's file relative to the working directory.
  • Template conditional and iteration blocks accept keys with hyphens, such as {{?X-Request-Id}}…{{/X-Request-Id}} for a header parameter.
  • The web UI's step request view copies the request as a curl command.
  • aat run clean deletes auto-generated run and batch archives and keeps named ones. tools/aat-to-junit.py converts a run archive to JUnit XML for CI test reports.
  • Workflows accept a selectionHint that aat prompt shows the model when it chooses a workflow. The trace viewer's Copy Conversation button copies a planning call's model settings and conversation as JSON.
  • aat web reports a clear error (exit code 2) when the frontend bundle is not embedded, such as a plain go install build; the CLI, MCP server, and CI features still work in that build.
  • aat --version reports the module version for go install …@vX.Y.Z builds.
  • Release pipeline: versionless archive names for stable releases/latest/download/… URLs, a Homebrew cask in a tap (brew install gburgyan/tap/aat), and a multi-arch image at ghcr.io/gburgyan/aat. Release archives and the Homebrew cask include aat-sandbox next to aat, and the release notes are this file's section for the version.
  • make example-shop runs examples/shop against a local sandbox the way the CI example-shop job does, and make cli builds aat without rebuilding the web UI.
  • A documentation site built from docs/user with Material for MkDocs (mkdocs.yml), deployed to GitHub Pages by a new Docs workflow that fails on broken links, broken anchors, and pages missing from the navigation. make docs runs the same strict build locally.
  • Documentation pages for installing, checkpoints, archives, aat generate, aat docs generate, the examples, and the airline case study; the Lua transforms page is written out. The quickstart (on the public Petstore API) and the tutorial (built by hand against aat-sandbox) are rewritten and were run verbatim; the previous versions did not load.
  • Docs: Share Your API with Integrators describes an integration kit for any API: what to ship and what to keep, a kit manifest beside the project manifest, internal environments that include: the shipped file, packaging and checking the kit in CI, and what an integrator's AI tool can read through the api persona. It publishes the prompt, the setup, and the results of the shop's single-prompt Python and Go client runs, so the claim that a kit lets an AI tool write a working client from one prompt can be checked. The docs home page, the examples index, Project Setup, and MCP Server link to it.
  • make demos regenerates the docs site's recordings and screenshots against a fresh aat-sandbox: VHS recordings of aat run plan full-lifecycle and a parallel layer-group batch, Playwright screenshots of the run timeline, a step's request with Copy as cURL, and the batch matrix, plus an MP4 of the plan run and the repository's social preview (demos/). It checks that both recorded runs passed and that each GIF stays within its size budget before writing anything. The docs site's home, matrix-testing, and web UI pages show the results.
  • Repository scaffolding: issue and pull request templates, SECURITY.md, and ROADMAP.md.

Changed

  • BREAKING: the environment file flag --env FILE is now --env-config FILE, and --env NAME selects a named environment. --env-overlay is now --overlay.
  • BREAKING: exit codes follow one rule on every command:
  • 0 passed.
  • 1 a test or validation ran and found a failure.
  • 2 AAT could not do what was asked. That covers an unknown flag, argument, or subcommand, and a project, environment, or --var error. Most commands exited 1 for these.
  • 130 aborted.

Command by command: - aat validate exits 1 when it finds a problem, and 2 when there is no manifest to validate or --var is bad. - aat prompt exits with its run's outcome code, instead of 1 for any run that did not pass. - aat import, aat generate, and aat docs generate exit 2 on an error. - BREAKING: project YAML is decoded strictly. A key that no field accepts — in the manifest, environment files and their includes, overlays, the graph, templates, the domain file, visualizers, workflows, layers, plans, recipes, and plan YAML given to the MCP plan tools — is an error naming the file, the line, and the likely intended key (plans/smoke.yaml: line 12: unknown key "fromSelecton" in step value (did you mean "fromSelection"?)). Such keys were silently ignored, so a typo produced a plan that loaded and did something else. A second YAML document in a file (--- followed by content) is an error too. aat prompt model output (JSON) is unaffected. To migrate, run aat validate and fix what it lists. - BREAKING: a manifest that exists but fails to load is an error for every command that discovers it; it was skipped, so commands fell back to a lower-priority project or to none. A missing manifest is still skipped, and a higher-priority manifest that loads still wins. - BREAKING: request templates escape each substituted value for where it lands. Values used to go in raw. - Path: a value is URL-encoded as one path segment before the first ?, and as a query component after it. a/b stays one segment, and & or # in a value can no longer add a parameter or cut the URL. - JSON body: a value inside quotes is JSON-escaped, so a quote, backslash, or newline in a field such as notes no longer breaks the body. A value outside quotes is written as JSON: arrays and objects as JSON, numbers in plain digits, and null. - Form body: values are URL-encoded. - Headers and other bodies: unchanged.

To send a malformed payload on purpose, use a step's rawBody. - BREAKING: JSON keys follow the convention of the document they are in: - The aat run batch --json summary's batchId is now batch_id. It is left out when the batch stopped before it started. - In run archives, a step's duration_ms is now durationMs. AAT and tools/aat-to-junit.py still read archives that use the old key. - A plan's auth in metadata.plan and metadata.instantiatedPlan uses camelCase keys (tokenUrl, credentials) instead of Go field names (TokenURL, Credentials).

The state object that --json nests with --dump-state - keeps the camelCase keys of the --dump-state file. - BREAKING: a recipe's overrides must name steps of the composed plan. An override for any other step ID, such as an addon step without its inc0_ prefix, used to be ignored; now the recipe fails to load, and the error lists the plan's steps. A value override on an input that the workflow template wires with from, fromSelection, or fromInput now replaces that wiring and sends the override. It used to have no effect. - BREAKING: Lua transforms can no longer load code or reach the host process. The package library is gone, and so are the base library's dofile, loadfile, load, loadstring, require, module, getfenv, setfenv, collectgarbage, and newproxy. An integration kit's templates run on its users' machines, so a transform must not read their files. Also, dofile() and loadfile() with no argument read stdin, which under aat mcp serve is the MCP connection. - BREAKING: aat run batch <filter> selects plans by whole path segments. orders selects orders.yaml and every plan under orders/, and orders/refund selects one plan. The filter matched the start of each plan's path, so smoke also ran smoke-eu.yaml. - BREAKING: a batch that finds no plans exits 2, and the message names the filter and the plan directories. It used to pass with nothing run, so a mistyped filter passed in CI. An absolute path that does not exist gets the same error. - BREAKING: aat run batch --json reports an error that stops the batch before any plan runs in a top-level error field, with an empty runs array. It used a run entry with no plan name. - BREAKING: only aat run plan and aat run batch take the execution flags: --env, --env-config, --graph, --templates, --domain, --override, --overlay, --var, --retries, --layer, --no-auto-overrides, --oas-validate, --verbose-auth, and --no-mutations. aat run clean and aat run rebuild-summaries accepted and ignored them; they now reject them. - Template headers no longer replace the auth credential, an override's own headers, or overlay headers. They still replace environment and plan headers, such as a per-operation Content-Type. Header names compare case-insensitively when headers merge. - Run output names each step by its step ID, which --stop-after, dependsOn, the archive, and the web UI use, with the node in parentheses when the two differ and the column has room (addProduct (addItem)); it printed the node, so two steps on one node looked alike. Engine errors do the same (step "addSocks" (addItem) returned status 409), and so do the parallel batch display and the MCP execute_plan table. - Durations are wall-clock: a retried step's duration runs from its first attempt to the end of its last, so retry waits count, and a run's duration (the PASSED line, the --json summary.duration_ms, batch.json run entries, the web UI, and MCP) is the time the run took, recorded in the archive as result.durationMs. Both were sums of the last attempt of each step, so full-lifecycle printed 955ms for a three-second run. Archives written before keep showing the sum. Step durations of a second or more read 1.4s. - A step that fails after retrying prints its error followed by the same retried Nx: <category> note as a step that recovers; the note used to take one of two other forms depending on the terminal width. - aat run progress output marks OpenAPI violations on each step (OAS: 1 warning(s)) and totals them after the outcome, as documented; only an unused summary path printed them before. - aat validate and aat validate workflow show OpenAPI and workflow-compatibility warnings as a WARN section without --strict instead of reporting OK, and counts read "1 file" rather than "1 files". - The sequential batch header no longer prints mode=strict, a leftover of the runtime modes removed in 0.0.2. - aat web and aat mcp serve --http listen on 127.0.0.1 by default instead of every interface. Pass --host 0.0.0.0 (or set AAT_HOST) to accept connections from other machines. - Steps composed from workflow templates (recipes, aat prompt) get a default status: 2xx assertion instead of status: 200, and none when they declare expectFailure. On a step with expectFailure (including one added by an overlay), a status assertion that expects success is reported as skipped, since the expected-failure status list is the status check; one that agrees with it, such as 409 or 4xx, is evaluated. - The --json step summary's name is the step ID, as documented, instead of the node name, so mutation siblings and repeated nodes are distinguishable. - aat generate --oas places optional query parameters, headers, and body properties in conditional blocks, orders body properties as the spec does, and writes integer, number, boolean, and array body values as JSON literals. With --output-graph - it writes no files unless --output-templates is given. A template header that resolves to an empty conditional is not sent. - A layer that sets a value source (value, pool, from, fromResolved) replaces the graph default's source instead of merging with it, so a layer value is no longer shadowed by a default's from. - Requesting layers (--layer, --layer-group, or a recipe's selection.layers) without a layers: directory in the manifest is an error; the layers were silently ignored before. - An override that declares its own auth no longer sends the inherited credential header (Authorization, or the top-level API key header) to its host. - Override precedence: among glob (and among exact) overrides the last registered match now wins, so .aat-overrides.yaml, --overlay, and --override take precedence over env.yaml overrides as documented. Exact names still beat globs. - Cleanup input matching scans earlier steps in execution order (it was map order). - MCP operation details and explain_field label a graph input's default "Test default": it is data AAT sends in tests, not a value the API fills in. Read as a plain default, the shop's quantity: 1 and method: card made required fields look optional. - CLI description and --help text describe AAT as graph-based API workflow testing; the LLM is optional and authoring-time only, and execution never calls one. - Documentation covers features that had none: the Ctrl+C aborted outcome, --oas-validate, the batch matrix view, archive import and export, and aat run rebuild-summaries. - libopenapi-validator v0.14.0 and libopenapi v0.38.7. - Minimum Go version is 1.25.7 (the OpenAPI libraries require it).

Removed

  • BREAKING: YAML keys that nothing read, which strict decoding now rejects: step fallback, assertions.semantic, the environment settings maxRunDuration, defaultRetries, and archiveFormat (retry with a step's retry: or --retries), template response.validate, the prompt field of selections and graph default select, and recipe overrides.descriptions.
  • BREAKING: the llm selection strategy, which plan validation already rejected but aat prompt accepted from the model, and the warn OpenAPI validation mode, which behaved exactly like auto.
  • The MCP execute_plan tool no longer accepts the obsolete mode parameter (the runtime strict/lean/adaptive modes were removed in 0.0.2).
  • Repository leftovers from the private airline project (setup.sh, a root-level plan, IDE run configurations, an empty case-study stub).

Fixed

  • Building the web UI (make frontend, make build) no longer rewrites a tracked file, and make clean no longer breaks go build by deleting one. Vite writes the bundle to server/web/dist/app, which git ignores, and the embed is satisfied by the tracked server/web/dist/placeholder.txt.
  • An errorDetection equals rule with a number (value: 0) matches the JSON number. The YAML integer and the JSON number used to compare as different types, so the rule never matched. A map or list value is now a validation error; at run time it crashed aat run and the MCP server.
  • A request path value with an encoded / (%2F) keeps it inside its segment; the executor used to decode it into a real /. The archive records the URL as the executor joins it, instead of concatenating the base URL and the path.
  • A number of a million or more fills a placeholder in plain digits instead of exponent form (1.2e+06), and a map fills one as JSON instead of Go syntax (map[k:v]).
  • Archive references stay inside the archive directory:
  • aat import --name must be a single directory name. A name that starts with run- or batch- gets the ! prefix, as a name derived from the file does. --name ../x used to import outside the archive directory.
  • The web server answers 404 for a run, batch, or trace ID that is not a single directory name. An ID of .. read the parent directory's archive.json. PUT /api/runs/{id}/name renamed any directory in the archive directory; it now renames only runs and batches.
  • The MCP archive tools reject a run_id that is not a directory name. The plan tools reject absolute plan names and names that climb out of the plans directory.
  • An unknown subcommand is an error (exit 2), with a suggestion when the name is close. This covers aat run bogus and unknown subcommands of aat plan, aat env, aat mcp, and aat docs. They printed help and exited 0, so a mistyped subcommand passed in CI. Commands that take no arguments, such as aat validate and aat web, now reject stray arguments instead of ignoring them.
  • aat mcp serve reports a manifest that fails to load with the load error; it said the manifest was not found. aat import fails on such a manifest instead of importing into _output/runs.
  • aat run plan --json and aat run batch --json print the error document for every error that stops them before a plan runs. Before, a manifest that failed to load, a bad --var, or an overlay environment that could not be resolved left stdout empty.
  • Graph-level cleanup deletes the resource each step created. Cleanup looked up the creating node's outputs by node name, but outputs are stored by step ID, so a step with its own id fell through to the first step with an output of that name: two createCart steps with their own IDs deleted the first cart twice and left the second. Cleanup now reads the registering step's outputs, then the most recent step with a matching output.
  • A request that fails before any response reports executing HTTP request: … once, not executing request: executing HTTP request: ….
  • aat mcp serve starts when a relative --manifest names an oas: spec. The spec path was joined onto the graph's directory a second time (examples/shop/examples/shop/openapi.yaml), so a project loaded from another directory failed with "no such file or directory".
  • The MCP get_sample_response tool returns the newest successful response for an operation, and a failed one, marked as such, only when no run succeeded. It took the newest response of any status, so a negative test's 409 could pass for the sample. It also searches the runs inside batch directories, and a manifest without archives gets the expected output shape instead of an error. The archive tools accept the ID of a run inside a batch.
  • A run archive that cannot be redacted is not written. aat run, batch runs, and the MCP execute_plan tool report the error; before, the error was ignored and the archive was written with its secrets in place. Redaction fails only on a value JSON cannot hold, such as a NaN from a Lua transform.
  • aat mcp serve without --persona registers get_data_flow, get_response_shape, and explain_field, which only the api persona had, so it has every tool: 39 with an OpenAPI spec, 32 without.
  • The web UI's run timeline shows a step's assertion count only when the step has assertions, not 0 / 0 on every step.
  • Run archives redact known secrets from every string they hold: request URLs and query parameters, request and response bodies, outputs and display outputs, error, assertion, and OpenAPI messages, and plan step values, as well as headers, inputs, and resolved values; batch.json entries too. In JSON bodies only string values change. Before, a credential used as an input was redacted in inputs but kept in the body of the same request.
  • Archive redaction no longer mangles ordinary data: the oauth2 username and clientId are not treated as secrets, and a secret shorter than eight characters is redacted only where a whole value equals it. The shop sandbox's demo credentials had turned demo@example.com into [REDACTED]@example.com in inputs. Overlapping secrets are redacted completely; map order could leave part of one visible.
  • Run archives redact an API key sent under a custom auth.headerName, the credentials of host overrides and overlay overrides (they were never collected as secrets), and the literal credentials and credential headers of the plan stored in metadata.plan and metadata.instantiatedPlan; aat prompt archives also collect the credentials of .aat-overrides.yaml.
  • aat generate --oas marks a response property the schema does not list as required as an optional output with an optional extract rule, so a scaffolded step no longer fails when the API omits it.
  • A select with a filter and no strategy works like match instead of failing at run time with unknown selection strategy.
  • --manifest naming a file that does not exist is an error instead of silently falling back to manifest discovery.
  • aat run plan no longer prints a failed or errored run's message a second time on stderr.
  • A project's manifest no longer inherits fields it leaves out (such as domain, layers, or defaultEnvironment) from a lower-priority project named by AAT_PROJECT or the user config's default_project; the highest-priority manifest found describes the whole project.
  • aat validate, aat validate plan, and the MCP plan tools resolve recipe layers, so a misspelled layer is reported and layer-supplied inputs no longer fail validation.
  • MCP execute_plan applies override values, expectFailure, and recipe layers like aat run plan.
  • aat plan list summarizes recipes instead of reporting a parse error for each.
  • Workflow compatibility checking (aat validate) accounts for slots: an addon AUTOWIRE input that every option of a slot produces is no longer reported as unfed, an addon that attaches after a slot option's node is checked instead of skipped, and slot options are no longer checked as bases of their own.
  • The static OpenAPI output check looks each output up at its template extract path, through nested objects and array items, instead of requiring a top-level response property named after the output.
  • settings.oasValidation and --oas-validate reject unknown values instead of treating them as auto.
  • A workflow template whose verification: names a node missing from the graph fails to load, as cleanup entries already did.
  • aat run batch --parallel N with runtime OpenAPI validation no longer has a data race: parallel runs share one loaded spec, and libopenapi-validator v0.13.1 wrote into the schema model while rendering a response schema behind a $ref. The upgrade to v0.14.0 removes the race, so validations still run concurrently.
  • Workflow composition fills slots in declaration order, so merged cleanup, slot verification, and slot inject values no longer vary between runs, and neither can batch dedup fingerprints.
  • The sequential batch display prints a run's OAS: N warning(s) total, as the plan display does.
  • Cleanup steps carry a step ID and a start time in archives, so the web UI places them on the timeline; their start time was empty.
  • The batch By Test matrix no longer clips its rotated permutation labels: the header grows to fit the longest, and a wide matrix uses the space beside the page column. The run timeline shows a step's node only when it differs from the step ID.
  • --override NODE=URL routes keep the environment headers, plan headers, overlay headers, and the credential, like an overrides: entry with that match and baseUrl; they used to send no headers.
  • aat prompt rejects layers when the manifest sets no layers directory instead of silently running without them.
  • Lua transforms: print() writes to stderr instead of stdout (where it corrupted --json and --dump-state - output), return {} is a valid empty set of outputs, and a template with a transform but no extract rules runs its transform; outputs a transform computes no longer fail the adapter output check.
  • aat generate --oas extracts an array property of an object response by its name instead of @this.
  • The static OpenAPI check accepts a required parameter or body property that the template sends itself (such as a literal "photoUrls": []), which made examples/petstore fail aat validate --strict.
  • aat validate checks workflow templates in subdirectories of the workflows directory (such as workflows/slots/), which it skipped.
  • A layer's fromResolved applies over an existing graph default instead of being dropped.
  • The plan summary resolves intent.goal as a step ID; aat plan list truncates long goals on character boundaries.
  • Visualizers receive --color-text-secondary, --color-danger, and --color-warning as documented; the web UI sent variable names it does not define.
  • aat validate, aat generate --oas, and the MCP OpenAPI operation details include parameters declared on an OpenAPI path item (such as a shared {cartId}), not only those on the operation.

0.0.4 - 2026-03-04

Changed

  • CONTRIBUTING.md describes what a pull request needs and the project's policy on AI-assisted contributions.

0.0.3 - 2026-03-03

Added

  • MCP server personas: --persona api for integrating with an API and --persona test for writing and running tests. New tools browse the OpenAPI spec, return a sample response from past runs, and show templates, and tool errors say what to try next.
  • Runtime OpenAPI validation of requests and responses (--oas-validate), with schema compilation problems reported as warnings and request and response errors shown separately.
  • Layer-group batches detect plans that come out identical across layer permutations and run each once. The web UI's batch page hides the skipped runs, dims their representative outcome, and adds a By Test matrix with a filter for each dimension.
  • Visualizer plugins (visualizers.yaml): custom views of a step, rendered in a sandboxed iframe in the web UI.
  • Run and batch summaries and the web UI group issues by category.
  • fromInput fills an input from another step's input, and an output can be optional when a response may omit it.
  • .aat-overrides.yaml is discovered automatically, for local development overrides.
  • Terminal output uses color and fits the terminal width, batch runs show progress and accept --shuffle, and progress output shows retries.
  • A raw flag on mechanical assertions evaluates the whole response body.
  • Archives and the web UI record when an override routed a step to another URL.
  • Archives and the MCP server report the build's version.

Changed

  • The project is renamed from Adaptive API Testing to Adaptive API Toolkit.
  • Plan composition has a single entry point, and aat prompt's planning pipeline is simpler, with a plan summary written for the user.

Fixed

  • Inputs with graph defaults are no longer classified as supplied by a layer.
  • Layers embedded in a recipe load during layer-group batch runs.
  • Batch runs no longer get duplicate run group IDs (#1).
  • A batch run that errored no longer shows "run not found" in the web UI.
  • aat web view latest opens a batch when it is the most recent archive.
  • Duplicate cleanup step IDs no longer leave the web UI spinning.
  • Dependencies from requires and satisfies tokens stay within an addon or the base workflow, so composed plans no longer get dependency cycles across them.

0.0.2 - 2026-02-25

Added

  • aat web view <file> opens an archive file without a project.
  • GitHub Actions CI runs the tests with the race detector, golangci-lint, and a gofmt check; make check runs the same checks locally.

Fixed

  • make clean test build works.
  • Data races found by the race detector.

0.0.1 - 2026-02-24

The first tagged version.

Added

  • API graphs in YAML: nodes with typed inputs and outputs, auto-wiring, constraints, and value pools from a domain file.
  • Request templates with conditional ({{?a|b}}) and iteration blocks, response extraction, and Lua transforms.
  • The execution engine: dependency-aware scheduling; values wired from earlier steps, with array selection strategies and named selections; constraint-aware fallback; retries by error category; expectFailure negative tests; response-body error detection; mechanical assertions; and graph-level cleanup.
  • Workflow templates composed from slots and addons; plans, compact recipes, and data layers, with --layer-group permutation matrices.
  • aat run plan and aat run batch (with --parallel), plan-level retries, --json and --quiet output, and exit codes for CI.
  • aat prompt: an LLM drafts a plan from a sentence, with opt-in planning traces.
  • OpenAPI support: aat generate --oas scaffolds a graph and templates, and aat validate checks them against the spec.
  • aat validate for the graph, plans, and workflows, with project manifest discovery (aat-project.yaml).
  • The MCP server (aat mcp serve): tools for the graph, templates, domain, OpenAPI spec, plans, execution, and archives, plus resources and prompts.
  • aat docs generate: Markdown and Mermaid documentation from the graph.
  • Run archives with the full decision trail and redacted secrets; export and import, saved names, and fast run listing.
  • The web UI (aat web): run and batch lists, a run timeline, step detail with audit tabs, and a planning trace viewer (aat web viewtrace).
  • Per-node routing to other base URLs (--override and environment overrides:), plan-level auth and headers, and OAuth2 token caching.
  • The Petstore example, the user documentation, and the Apache 2.0 license.