Skip to content

Collection file format

Collections are plain YAML files at the root of your project folder. They are committed to your repository, so the format is a real contract: it is versioned, output is written minimally and in a stable order, and only the fields you actually set are emitted.

This page documents schema v0.10.0, the version shipped in desktop v0.1.0-preview.10.

  • formatVersion for a collection is 2.
  • formatVersion for a chain is 3 — chains version independently and live in their own files under .tinspec/chains/.

v0.10.0 grew a lot of fields, and not one of them bumped a version number. Both format versions are the same as in v0.8.0, every new key is optional, and every one of them is absent by default. A collection written by the previous release round-trips byte-identically through this one: the writer still emits only what is set, so opening and saving an old file produces no diff.

What is new, all of it optional:

WhereNew keys
Requestsettings, scripts, mcp
defaultssettings, scripts
Request.authinject, inline
AuthProvideroauth, aws
Request.grpcdescriptorSource, descriptorSetPath
SpecSourcekind, pollIntervalSecs
Chainsettings
ChainNodeonError, region

Plus two widened vocabularies: assertions went from four sources and seven operators to seven and eighteen, and protocol gained mcp.

formatVersion: 2
name: Example API
requests:
- name: Get user
protocol: http
method: GET
url: "{{baseUrl}}/users/{{id}}"
headers:
Accept: application/json
auth:
provider: keycloak-dev
routeMatcher:
matcher: edge-kong
upstream: users-service
gatewayUrl: "{{gatewayUrl}}"
tests:
- source: status
op: eq
expected: "200"
- source: duration
op: lt
expected: "800"
settings:
timeoutMs: 30000
specSources:
- name: Users API
location: http://localhost:8080/openapi.json
pollIntervalSecs: 300
routeMatchers:
- name: edge-kong
kind: kong
config: |
services:
- name: users-service
url: http://users:8000
routes:
- name: users
paths: [/api/v1]
strip_path: true
environments:
- name: local
variables:
baseUrl: http://localhost:8080
gatewayUrl: https://gw.example.com
id: "1"

The complete set of top-level keys.

KeyTypeRequired
formatVersionnumberyes
namestringyes
requestsRequest[]no
environmentsEnvironment[]no
pipelinesPipeline[]no
specSourcesSpecSource[]no
authProvidersAuthProvider[]no
routeMatchersRouteMatcher[]no
defaultsRequestDefaultsno

Merged into every request in the collection; a request’s own value wins.

FieldTypeRequired
headersmap<string, string>no
querymap<string, string>no
variablesmap<string, string>no — layered under the active environment
authAuthRefno — used when a request specifies none
settingsSendSettingsno — the collection layer; see the layering rules
scriptsRequestScriptsno — inherited field by field

scripts is the one that merges rather than replaces: a collection-wide pre that signs every request coexists with a request’s own post. A request that sets the same hook wins outright.

FieldTypeRequiredNotes
namestringyes
protocolhttp | grpc | graphql | websocket | langgraph | mcpnoAbsent means HTTP
methodGET | HEAD | POST | PUT | PATCH | DELETE | OPTIONSyesRequired even for gRPC, GraphQL and MCP — use POST
urlstringyes{{var}} allowed; grpc:// or grpcs:// for gRPC; the server endpoint for MCP
querymap<string, string>noAppended at send time; kept out of the URL string
headersmap<string, string>noHTTP headers, or gRPC call metadata
bodyBodyno
authAuthRefnoA provider reference, or an inline credential that is still a keychain name
grpcGrpcCallnoWhen protocol: grpc
graphqlGraphqlCallnoWhen protocol: graphql
langgraphLangGraphCallnoWhen protocol: langgraph
mcpMcpCallnoWhen protocol: mcp
prerunChainstringnoName of a chain to run first; its outputs merge into the environment
routeMatcherRouteMatcherRefno
useCookieJarbooleannoAbsent or true means the shared jar applies; false opts out
testsAssertion[]noEvaluated against this request’s own response
settingsSendSettingsnoThe most specific transport layer
scriptsRequestScriptsnoPre/post hooks, stored inline
FieldTypeRequiredNotes
contentTypestringyesAdvisory for multipart — the client sets the real boundary
contentstringyesUse "" for multipart and file bodies
kindtext | multipart | filenoAbsent means text
partsBodyPart[]noWhen kind: multipart
filePathstringnoWhen kind: file. Workspace-relative inside the project, absolute otherwise; {{var}} allowed
FieldTypeRequiredNotes
namestringyes
kindtext | filenoAbsent means text
valuestringnoText part; {{var}} allowed
filePathstringnoFile part
contentTypestringnoDefault: none for text, application/octet-stream for file
fileNamestringnoContent-Disposition filename; defaults to the path’s basename
FieldTypeRequiredNotes
descriptorSourceproto | descriptorSet | reflectionnoAbsent means proto
protoPathstringnoWorkspace-relative .proto. Required for descriptorSource: proto; ignored by the other two
descriptorSetPathstringnoWorkspace-relative compiled FileDescriptorSet. Read when descriptorSource: descriptorSet
servicestringyesFully qualified, e.g. acme.users.v1.UserService
methodstringyes
messagestringnoRequest message as JSON; {{var}} allowed
messagesstring[]noFurther messages for client-streaming and bidi, sent in order after message

Streaming mode is not stored — it is read from the descriptor.

descriptorSource is why an existing .proto request writes no new key: absent still means proto, so nothing that already works produces a diff. The three values are two different file formats plus a live one — reflection asks the running server, so the descriptor is re-read rather than frozen into a file that rots. descriptorSetPath is a separate field from protoPath rather than an overloaded one, so you can flip between source, compiled and live without retyping a path.

FieldTypeRequired
querystringyes
variablesstring (JSON-encoded object)no
operationNamestringno

Present when protocol: mcp — one saved request calls one tool on one MCP server.

FieldTypeRequiredNotes
toolstringyesThe tool’s name exactly as the server’s tools/list reports it
argumentsstring (JSON-encoded object)noAbsent means no arguments; {{var}} allowed

There is no server entity and no token field. The server is Request.url, the credential is an ordinary AuthRef, extra headers are Request.headers, and transport is settings — all four already inheritable from defaults. The arguments are a JSON string for the same reason grpc.message is: the argument names are the server’s vocabulary, not ours, and a string diffs line by line in a YAML block scalar.

A stdio server — a command plus its arguments — cannot be spelled as a URL, and the engine does not spawn child processes for MCP. That transport is not expressible in this format, which is the honest position rather than a field promising something the tool does not do.

FieldTypeRequiredNotes
inputstringnoJSON-encoded payload
streamModestream | bufferednoAbsent means stream
eventsVersionstringno
customFields{ name, path }[]noBoth required; path is a dot-path into the raw event’s data. A missing path renders blank
FieldTypeRequired
namestringyes
variablesmap<string, string>no

Secret values never go here.

Used by both Request.tests and a chain’s assert node.

FieldTypeRequiredNotes
sourcesee belowyes
pathstringnoA path expression for body, header name for header, cookie name for cookie, variable name for var. Unused by status/duration/size
opsee belowyes
expectedstringnoUnused for exists/isEmpty; {{var}} allowed

Seven sources. Every one of them yields a string; numeric operators parse it as a float at compare time, so a source is never typed at the format level.

SourceReads
statusThe numeric HTTP status
bodyThe response body at path
headerA response header named by path (case-insensitive)
varA current variable named by path
durationThe wall-clock round trip, in milliseconds
sizeThe response body’s size, in bytes
cookieA cookie’s value by name from the shared jar — so a cookie set by an earlier request is assertable

Eighteen operators.

OperatorCompares
eq / neString equality / inequality
contains / notContainsSubstring presence. A missing value passes notContains
startsWith / endsWithPrefix / suffix
gt / gte / lt / lteNumeric. false if either side is not a number
betweenInclusive numeric range, written 1..10
exists / isEmptyPresent-and-non-empty / absent-or-empty
lengthArray items, object keys or string characters against a count (3), a comparison (>0, <10) or a range (1..5)
in / notInMembership in a comma-separated list or a JSON array
matchesRegular expression
schemaThe body matches the endpoint’s own declared OpenAPI response schema for the status it came back with

Two absences are deliberate, not oversights. There is no neq — ne is the canonical spelling, and a second on-disk spelling of one check is poison for a format built on clean git diffs. And there is no negate flag: notContains, notIn and isEmpty are the negations worth having, while a general flag would give you negate + gt (which is lte) and negate + schema (which is nothing).

Several fields hold a path — Assertion.path for source: body, Extraction.path for from: body. They are one grammar, evaluated by one evaluator, so a path means the same thing in a request test, a chain assertion and an extraction.

Two spellings, picked from the expression itself. A leading / is XPath; anything else is JSONPath.

SpellingExamples
JSONPath subsetdata.token · items[0].id · items[*].id · ..id · ['odd key'] · an optional leading $
XPath subset/order/line[2]/@sku · //token · /rss/channel/item/title/text()

The document model is read from the body, not declared in the file: the body is parsed as JSON, and failing that as XML. Neither the format nor you has to say which, and either grammar addresses either model — order.line['@sku'] walks an XML document, /data/token walks a JSON one.

What a path binds when it matches more than once is a rule worth knowing before you write one:

  • An expression that is plural by construction — it contains [*], .*, ..name, ..*, or an XPath // — always binds a JSON array of every match in document order, and [] when nothing matched. Always, not “an array when there are several and a bare value when there is one”: a binding whose shape depended on how many rows the server happened to return is a chain that works on Tuesday.
  • Any other expression binds the single node it addresses. Zero matches is not present — an extraction error, an assertion failure. Two or more is an error, naming the count and the first few concrete locations. Over JSON that cannot happen; over XML it happens the moment a document has two <line> siblings, and picking the first would be a coin flip dressed as an answer.

Evaluation is capped at 20,000 matches and says so when it stops, rather than hanging on a ..* over a 50 MB response.

One thing on screen disagrees with this page, and this page is right. Type a wildcard or a .. into a chain extraction’s path field, or into a source: body assertion, and v0.1.0-preview.10 shows an amber line under it: “Wildcards and .. aren’t evaluated here — the engine’s extractor walks keys and numeric indexes.” That warning is stale. It describes an older extractor that was replaced: at the engine release this app ships, extraction and assertions both go through the evaluator described above, wildcards included. The line is a warning and not an error — it does not stop you typing the path, saving it, or running it, and the path will resolve. Believe the run, not the warning.

Transport settings for a send: timeout, TLS policy, redirects, response cap, HTTP version, proxy and gRPC tuning. Attachable at several points, which is the whole point of it.

FieldTypeNotes
timeoutMsnumber0 means no timeout. For gRPC this is also the call deadline (grpc-timeout)
insecurebooleanSkip TLS certificate verification — the self-signed staging case
followRedirectsboolean
maxRedirectsnumberHops, when followRedirects is on
maxResponseBytesnumber0 = unlimited
httpVersionauto | http1 | http2auto is normal ALPN negotiation; http2 assumes prior knowledge with no downgrade
proxyProxySettings
tlsTlsSettings
grpcGrpcSettings

Every field is optional, and absent means inherit from the next layer out — not “use the default”. Four layers, most specific first:

  1. Request.settings — this one call.
  2. defaults.settings — the collection/folder layer.
  3. The API provider’s settings (in .tinspec/providers/<stem>.yaml).
  4. The app’s global send options, in Settings — the floor.

Resolution is field by field, so a request that sets only timeoutMs still inherits the provider’s proxy. The app shows where each value came from (30 s — from provider billing-staging); that provenance is computed by the engine, which is the only thing that resolves these layers.

FieldTypeRequiredNotes
urlstringyesScheme included: http://, https://, socks5://, socks5h://
noProxystringnoComma-separated hosts, domain suffixes (.internal), IPs or CIDRs — the conventional NO_PROXY syntax
authProxyAuthno
FieldTypeRequired
usernamestringyes
passwordRefstringyes — an OS-keychain key name, never the password
FieldTypeNotes
clientCertRefstringKeychain key holding the client certificate PEM (mTLS) — a reference, not the PEM
clientKeyRefstringKeychain key holding the client private key PEM
caBundlePathstringPath to an extra CA bundle trusted in addition to the built-in roots. A CA certificate is public, so this is a path, not a ref
useOsTrustStorebooleanAlso trust the OS certificate store — how a corporate TLS-inspecting proxy’s CA becomes trusted
FieldTypeNotes
maxDecodingMessageBytesnumberLargest inbound message
maxEncodingMessageBytesnumberLargest outbound message
sendCompressionnone | gzip | zstd
acceptCompressionnone | gzip | zstd

none is an explicit value rather than an absence, so a request can turn off an encoding a lower layer enabled.

These four are carried, resolved and applied by the engine, but v0.1.0-preview.10 has no editor for them — set them by hand in the YAML if you need them.

User JavaScript run immediately before a request goes out and immediately after its response comes back.

FieldTypeNotes
prestringRuns after {{var}} substitution, with the resolved request readable as ts.request. Variables it produces are substituted into the request before it is sent
poststringRuns when the response arrives, readable as ts.response. Variables it produces go back to the host — the scripted counterpart of an Extraction

The code is stored inline, as a YAML block scalar — not a file path. A hook belongs to exactly one request, and an imported Postman collection has nothing but source, so a path would mean inventing a file for every imported request. Block scalars diff cleanly, so the format cost is nil. (An auth provider’s script stays a path, because one login script is shared by many requests.)

requests:
- name: Signed webhook
method: POST
url: "{{baseUrl}}/hooks"
headers:
X-Signature: "{{signature}}"
scripts:
pre: |
const sig = ts.hmac("sha256", ts.secret("WEBHOOK_KEY"), ts.request.body);
ts.setVar("signature", sig);

Both hooks run in the same sandboxed QuickJS runtime as every other user script, under the same interrupt handler, memory cap and wall-clock bound. The available surface is ts.* — the same object auth-provider scripts get; see the auth script contract.

Two limits worth knowing before you write one:

  • There is no module loader and no require, deliberately and permanently. This code is committed, pulled from teammates and executed against real credentials; module resolution would turn “review this collection’s scripts” into “audit a dependency tree fetched at run time”. What a send runs is always the inline hook — exactly the text a reviewer reads in the YAML. The project’s .tinspec/lib and .tinspec/functions are an editing convenience, not an import path.
  • A post hook does not run on a streaming response. An SSE send evaluates no request tests, runs no drift check and runs no post hook; a pre hook still runs, but its console output is not captured. The response pane names the gap rather than hiding it.

What a request may say about auth. There is still no way to express a credential value here — the inline form names a keychain entry.

FieldTypeRequiredNotes
providerstringnoThe AuthProvider name. Omitted only when inline is set
senderstringnoWhich sender to authenticate as
injectAuthInjectnoOverrides the provider’s placement, for this request only
inlineInlineAuthnoA credential authored here instead of a named provider

provider became optional so inline could exist; every file that already had one still writes it. Set both and provider wins.

inject is the per-request override: the provider puts its token in a header everywhere else, and this one request wants it inside the JSON body.

A credential authored on the request rather than obtained by a provider — the API whose authentication is a long-lived key that no token dance produces.

FieldTypeRequiredNotes
schemebearer | basic | apiKeyyes
placementheader | query | cookienoFor apiKey; default header. body and variable are rejected by the engine — an inline API key is a transport credential, not a body field
namestringnoFor apiKey — the header, query-parameter or cookie name; default X-API-Key. Unused by bearer/basic, whose placement is fixed to Authorization
usernamestringnoFor basic. Not a secret; it identifies rather than authenticates
valueRefstringyesOS-keychain key naming the credential — the bearer token, the Basic password, or the API key. Never the value

There is no field here that holds a value. That is the point of it: before this existed, the only way to express a static key was a raw Authorization: header typed into the request — a literal credential committed to git, which is the worst possible outcome. The app’s editor reflects that; the value input is write-only and is never read back.

FieldTypeRequiredNotes
namestringyes
kindscript | integration | request | oauth2 | awsSigV4yes
scriptstringnokind: script — workspace-relative path to the JS
integrationkeycloak | clerk | auth0 | firebase | supabasenokind: integration
requestRequestnokind: request — any protocol
extractExtraction (singular)nokind: request — the result is exposed as {{token}}
oauthOAuthConfignokind: oauth2
awsAwsSigV4Confignokind: awsSigV4
configmap<string, string>noNon-secret only: baseUrl, realm, clientId, tokenUrl, scope, domain, grant…
secretRefsstring[]noKeychain key names, never values
sendersAuthSender[]noEmpty means one implicit identity
injectAuthInjectnoDefault when absent: Authorization: Bearer {{token}}

keycloak and auth0 take config.grant of password or client_credentials. firebase and supabase exchange email and password for a token. Those five integrations are the whole list and will stay the whole list — a generic authorization server belongs behind kind: oauth2, not a sixth integration.

A provider’s kind and integration are fixed when it is created and cannot be edited afterwards; changing approach means a new provider.

For kind: oauth2 — the vendor-neutral path, for any conformant OAuth 2.0 / OIDC server.

FieldTypeRequiredNotes
grantauthorizationCode | clientCredentials | deviceCode | refreshTokenyes
clientIdstringyesPublic by specification — not a secret
issuerstringnoEndpoints are discovered from {issuer}/.well-known/openid-configuration
authorizationUrlstringnoExplicit endpoint; overrides discovery
tokenUrlstringnoExplicit endpoint; overrides discovery
deviceAuthorizationUrlstringnoExplicit RFC 8628 endpoint; overrides discovery
userinfoUrlstringnoExplicit endpoint; also the first source of senders (the signed-in subject)
sendersUrlstringnoAn admin/directory endpoint returning users, so a generic provider can populate senders the way the named integrations do
clientSecretRefstringnoKeychain key. Omitted for public clients
refreshTokenRefstringnoKeychain key holding a seed refresh token for grant: refreshToken. A rotated replacement is kept in memory for the session and never written back
scopesstring[]noSent space-joined
audiencestringnoFor APIs that need one to issue a non-opaque access token
redirectPortnumbernoFixed loopback port for authorizationCode, when the server requires an exact registered redirect_uri. Absent or 0 = an ephemeral port on 127.0.0.1
redirectPathstringnoPath of the loopback redirect URI; default /callback
pkcebooleannoPKCE (S256) on the authorization-code flow. Defaults to true
clientAuthbasic | post | nonenoHow the client authenticates at the token endpoint. Default: basic when there is a clientSecretRef, else none
extraParamsmap<string, string>noAppended verbatim to the authorization and token requests (prompt, resource, acr_values, a vendor’s own). An open map — the server’s vocabulary, not ours
tokenFieldstringnoWhich JSON field of the token response carries the credential. Default access_token; id_token for APIs that want the OIDC identity token

The explicit *Url fields exist for servers with no discovery document, or a wrong one. When both are present, the explicit value wins.

For kind: awsSigV4. This kind signs the request; it does not produce a token.

FieldTypeRequiredNotes
regionstringyes{{var}} allowed
servicestringyesexecute-api, s3, lambda, … {{var}} allowed
accessKeyIdRefstringyesKeychain key
secretAccessKeyRefstringyesKeychain key
sessionTokenRefstringnoKeychain key, for temporary credentials obtained elsewhere. Sent as x-amz-security-token
stsEndpointstringnoUsed for assume-role resolution. Default https://sts.amazonaws.com

A SigV4 signature covers the final method, path, canonical query string, a set of headers and a hash of the body, so it cannot be expressed as an AuthInject template — a template is rendered from variables, a signature is a function of the bytes being sent. The engine applies it as the last step of request preparation. Setting inject on an awsSigV4 provider is an error, not a field that gets quietly dropped.

Its senders are assume-role targets, carried in the ordinary AuthSender.vars rather than a new field: roleArn (required), plus optional sessionName, externalId, durationSeconds and a region override. Selecting the sender makes the engine call STS AssumeRole with the provider’s base credentials and sign with the temporary ones — which is how “send as the read-only role” becomes a dropdown. A provider with no senders signs with the base credentials directly. The app relabels that provider’s Senders tab to Roles for the same reason.

FieldTypeRequiredNotes
idstringyes
labelstringno
tenantstringnoAn informational label only, not a selection axis
secretRefstringnoThis identity’s keychain key name
varsmap<string, string>noNon-secret per-sender values, available as {{var}} while resolving the token — and the assume-role fields for an awsSigV4 provider
FieldTypeRequiredNotes
targetheader | query | cookie | body | variableyes
namestringnoHeader, query-parameter or cookie name; for body, a JSON dot-path such as auth.token. Unused by variable
templatestringnoe.g. Bearer {{token}}, or bare {{token}}. Unused by variable

The two newer targets:

  • body sets a dot-path inside the JSON body — the HTTP body, grpc.message, or graphql.variables. An existing value already at that path wins.
  • variable injects nothing at all. It exposes {{token}} so you can place it by hand, which is the honest shape for an API whose credential goes somewhere no target describes.
FieldTypeRequiredNotes
namestringyes
locationstringyesWhat it points at depends on kind
kindopenapi | grpcReflection | graphql | mcpnoAbsent means openapi
pollIntervalSecsnumbernoAbsent or 0 = never poll
routeMatcherRouteMatcherRefno

location by kind: a URL or file path for openapi; the grpc:///grpcs:// target whose reflection service is queried for grpcReflection; the endpoint the introspection query is POSTed to for graphql; the streamable-HTTP endpoint whose tools/list is called for mcp.

pollIntervalSecs is the field that turns reflection and introspection from a one-shot import into a live binding — with it set, the source is re-resolved on a timer and consecutive polls can be diffed.

FieldTypeRequiredNotes
namestringyes
kindkong | nginx | envoyyes
configstringyesThe raw gateway config, stored inline
overrides{ upstream, from, to }[]noAll three required. from is the backend path, to the gateway-facing one
transcodesbooleannogRPC↔JSON transcoding; auto-detected for Envoy
FieldTypeRequiredNotes
matcherstringyes
upstreamstringnoOmit to auto-match the spec’s server URL against route upstreams
gatewayUrlstringnoscheme://host[:port]; {{var}} allowed

The original linear chaining path. It is still in the format, but chains are the chaining surface in the app.

TypeFieldRequired
Pipelinenameyes
stepsno
PipelineSteprequest — a Request name in the same collectionyes
extractno
FieldTypeRequiredNotes
varstringyes
frombody | header | statusyes
pathstringnoA path expression for body, header name for header, unused for status

A separate file — .tinspec/chains/<name>.yaml, formatVersion: 3.

FieldTypeRequired
formatVersionnumberyes
namestringyes
nodesChainNode[]yes
edgesChainEdge[]yes
settingsChainSettingsno

Run-wide policy — what a CI runner needs to say about a whole chain rather than about one node. Absent means every default, which is exactly what chains did before this block existed.

FieldTypeNotes
timeoutMsnumberWall-clock bound on the run. Absent or 0 = unbounded. The teardown region is not counted against it
onErrorabort | continueThe default for a node that does not declare its own. Absent = abort
teardownTimeoutMsnumberBound on the teardown region — its own clock, default 30 s. A chain that timed out after ten minutes must not wait another ten to clean up

The chain timeout is applied per node rather than around the whole region, so the failure names the node that was running when the clock ran out. It is deliberately not catchable: a timeout a node’s onError could swallow would leave the run walking its graph past the deadline.

FieldTypeRequiredApplies to
idstringyesall — edges reference it
typesee belowyesall
position{ x, y }, both requiredyesall
requestChainRequestRefnorequest
extractExtraction[]norequest
utility{ fn, var, args? }nogenerator
rules{ expr?, port, label? }[]norouter — ordered, first match wins; an empty expr always matches
configmap<string, string>noPer-kind settings
scriptstringnoscript — async function run(vars, ctx); returned keys become outputs
assertionsAssertion[]noassert
fields{ var, value }[]noset
portsNodePortsnoall
onErrorabort | continuenoall — overrides settings.onError
regionmain | teardownnoall — absent means main

type is one of the sixteen kinds: request, input, environment, math, transform, generator, set, aggregate, script, assert, condition, router, loop, merge, split, delay.

config keys by kind: input → value; environment → key; math → expr, var; transform → op, expr, var; condition → expr; delay → ms; loop → mode, count, listVar, itemVar, indexVar; aggregate → fromVar, intoVar.

ChainRequestRef is either collection (workspace-relative path) + name, or inline (a whole Request). If both are given, inline wins.

utility.fn is uuid, randomInt (min/max), randomString (length), timestamp (format: unix or iso), sequence (start/step), or list (values, comma-separated).

NodePorts: inputEnabled, outputEnabled (both default true), inputs / outputs as { var, from } mappings (empty means pass everything through), retry, retryDelayMs.

onError is consulted only for an unhandled failure. An outgoing edge whose sourceHandle is error is a declared catch, and a node with one never aborts the run whatever the policy says — declaring where a failure goes is the statement that it is handled.

region: teardown is a marker on a node, not a node kind. The region is computed by forward reachability, so marking the first node of a cleanup branch puts the whole branch in it. Teardown runs after a pass, after a failure and after a timeout, against the main region’s final variable scope, and its own failure never replaces the one it was cleaning up after. It does not survive a killed process — a cancelled CI job leaves fixtures behind.

FieldTypeRequiredNotes
idstringyes
sourcestringyes
targetstringyes
sourceHandlestringnotrue/false for a condition, a rule port for a router, body/done for a loop, error for a catch

formatVersion used to be written and never checked. It is now the seam it was always meant to be, in both directions.

A file from an older Tinspec is migrated up on load. Every version step is listed explicitly in the schema, even when it is the identity, so “that change was additive” is a recorded decision rather than an assumption.

A file from a newer Tinspec is read, not rejected. Fields this build does not model are pruned and reported, so a teammate on a newer version does not hand you a file that simply fails to open. Loading produces a structured warning of one of three kinds:

WarningMeans
migratedThe file declared an older version and was upgraded
unknownFieldsIgnoredFields were dropped, and the warning lists their exact dotted paths
newerFormatVersionThe file came from a newer build than this one

Saving such a file is refused. It was loaded with fields ignored, so writing it back would silently delete a teammate’s data — and re-injecting ignored subtrees by path is unsound once list items move. Refusing leaves the file on disk untouched, which is the only lossless option.

An unknown field in a file at or below the known version is still an error, not a warning: there it is a typo, not a future.

The writer emits only what is set, in a stable order. An empty tests list is dropped rather than written as tests: []; useCookieJar: true is stored as absent because it is the default; an absent grpc.descriptorSource keeps its meaning of proto rather than being written out. A one-header change should be a one-line diff — if it is not, that is a bug worth reporting.