Skip to content

Type to search pages.

View .md

What is stable

Antifailure follows semantic versioning. A major version is the only thing that may break a surface named as stable below, and the release notes for it say what changed and what to do.

This page is the promise itself rather than a summary of it. It is deliberately a list of named surfaces and not a sentence about “the API”, because a blanket claim is one nobody can hold us to and one we cannot check ourselves against.

Breaking any of these costs a major version.

A manifest declaring version: 1 keeps working. Within version 1:

  • Keys may be added, and an existing key may gain a new accepted value.
  • A key will not be removed, renamed, or given a different meaning.
  • A default will not change in a way that changes what an existing manifest does.

The promise runs backwards, not forwards. An older manifest works on a newer af; a manifest using a key added in 1.4 does not work on 1.2, because the parser refuses a key it does not know rather than ignoring it. That refusal is deliberate: a silently ignored key is a setting somebody believes is in force.

schemas/manifest.v1.json is the source of truth, the Go types mirror it, and a test walks both structurally so the two cannot drift apart in a release. A manifest written today parses in every 1.x that follows.

If a version 2 ever exists, version 1 manifests keep being accepted for the whole of the major version that introduces it. You will not be asked to rewrite a manifest to take a patch release.

The commands in the command reference, their flags, and their exit codes. A command will not be removed or renamed and a flag will not change what it means. New commands and new flags arrive in minor releases.

The documented fields of each command’s JSON output. Fields may be added, so parse for the fields you want rather than refusing a document that carries one you have not seen. A documented field will not be removed or change type.

engine/pkg/provider declares the database and runtime interfaces, and it is meant to be implemented outside this repository: each ships with a conformance suite an implementation runs, so conformant is something a test says.

Four packages are stable, and they are stable together because an interface is only as usable as the types its signatures name.

Package What it is
engine/pkg/provider The database and runtime interfaces themselves.
engine/pkg/schema The manifest types those interfaces carry across the boundary.
engine/pkg/secret The Value type that carries a credential without printing it. Database.ConnString returns one and EnvSpec holds several.
engine/conformance The suite that decides whether an implementation is conformant.

engine/pkg/secret is new in 1.0.0 and it is the fix for a promise that was not true. The type lived in engine/internal/secrets until the release, and Database.ConnString returned it, so writing that method outside this module was impossible: naming the return type needed an import the Go toolchain refuses by path. The interface compiled here, reviewed as correct, and would have failed on the first line of the first provider anybody wrote. Moving the type is the only change to these interfaces, it is source compatible inside the module because the old name is an alias, and tools/surfacecheck is what stops the next one happening quietly.

A code in the error reference keeps its meaning. The code is the stable identifier for a refusal; the sentence printed beside it is not, and it is reworded whenever a clearer one exists. Match on the code.

Every migration lint finding carries an identifier of the form LINT-NNN, and the lint findings reference lists them. An identifier is assigned once and keeps its meaning. It is never reused, not even after the rule that earned it is deleted, because a number handed out twice is worse than one that changed: the first breaks a filter silently and the second breaks it loudly.

What stays free to move is everything else about a finding, and deliberately so. The rule name, the title, the sentence saying what will happen and the suggested fix are prose. Rules are sharpened, split and renamed as they get better at their job, and a name that cannot be improved is a rule that cannot be improved. So the identifier is what a filter or a suppression should match on, and the rule name is what a person should read.

engine/internal/insights/lintcatalog.yaml is the source of truth, and findings.register.json beside it records every identifier ever handed out. tools/lintcheck refuses a rule with no identifier, an identifier for a rule that no longer exists, and an identifier that has left the catalogue since it was registered.

Every key in the Helm chart’s values.yaml, and every variable and output in the Terraform under infra/terraform. Within version 1:

  • A key or a variable will not be removed or renamed.
  • Its type will not change.
  • An optional input will not become required, and a new input arrives with a default rather than without one.

The reason this is a promise and not a preference is that the values file and the tfvars file somebody self hosting writes are their configuration. They are written once, kept in that operator’s own repository, and applied by that operator’s own pipeline. A rename does not fail that pipeline loudly, it fails it silently: Helm accepts a key no template reads, and Terraform only warns about a variable nothing declares. The setting stops being in force and the apply still says it succeeded.

Terraform outputs are on the list by name, because a runbook reads them. Standing up on Azure pipes backend_hcl into a backend configuration and rotating secrets scopes a role assignment with key_vault_id, and an output missing under the name a command asks for prints nothing rather than failing.

What is promised is the input, not the value it carries. Defaults move, and one of them has to: image_tag names the release being cut, and tools/tagsync exists to make sure it does.

tools/inputcheck holds the tree to a snapshot of this surface taken at v1.0.0, so a rename fails in the pull request that proposes it rather than in somebody’s upgrade.

The chart carries its own version, and it is 1.0.0 for this reason. A chart at 0.x says in the only language its ecosystem has that its values may be rearranged at any time.

The types in the event envelope reference and the envelope around them. A type is not removed and does not change what it means. A field of the envelope is not removed, does not change type, and does not become optional, and a field holding a closed set does not lose a value from it.

Types are added as features land and fields may be added, so read the stream the way you read --output json: take what you want and ignore what you have not seen, rather than refusing an event carrying something new.

Two things are deliberately outside that. The data object is the type specific payload, it is documented as an object and nothing further, and its keys move with the code that writes them. And some types on that page are reserved rather than live: the engine does not emit all of them yet, and engine/internal/events/emitters_test.go carries the reason for each one. A reserved type is stable in the sense above, and it may start being emitted in any release.

schemas/events.v1.json is the published artifact, engine/internal/events/stream.register.json is what version 1 promised, and tools/eventcheck fails the build on a type that has gone, a field that has changed shape, and a type the engine can emit that nothing documents.

These are free to change in a minor release, and saying so plainly is more useful than a promise that quietly bends.

  • The defaults and validation rules on the self-hosting inputs. The names and the types are promised above. A default moves with a release, and a validation tightens as a cloud teaches us what it refuses at apply time that it accepted at plan time. Set the values that matter to you rather than inheriting them.
  • What the Terraform actually creates. The inputs are a contract; the resources behind them are not. A module may reach the same outcome with different resources, and the Azure guide says which changes force a replace.
  • Most of the control plane’s HTTP API. It is mostly how the console and the engine speak to each other rather than a published integration surface, and the part that is published is named rather than described. Every route the router serves is classified in web/apps/api/src/boundary.ts as either part of the published contract, which means it appears in the OpenAPI document, or as deliberately excluded on one of seven recorded grounds, with a sentence saying which case it is. A route that is neither fails the build. Before that existed, a route missing from the document could equally mean “nobody outside could call it” or “somebody forgot”, and four live routes under /v1/oidc/bindings were the second. The prose form of the same boundary is the HTTP endpoints reference.
  • Every Go package except the four named above. engine/pkg/afcli, engine/pkg/edition and engine/pkg/extension are the sockets the enterprise binary plugs into and are deliberately narrow rather than a general embedding API. engine/pkg/livekey and engine/chaos are ours. Every importable package is listed with its classification and a reason in engine/api/packages.txt, and a new one that is listed nowhere fails the build rather than arriving public by default. Nothing outside this module can import engine/internal at all: the Go toolchain refuses an import of an internal path from outside the subtree rooted at its parent, so that half needs nothing from us and gets nothing.
  • Lint rule names, and which findings a release reports. A rule is renamed when a clearer name exists, and a release may find something in a migration an earlier one passed. That is the product working, and it is why the identifier above is the thing to match on rather than the name.
  • Anything under docs/plan/. Working notes, not documentation.

Each of the two carve-outs above is checked rather than described, and both checks run in CI and in just gate.

tools/surfacecheck reads the Go tree and refuses:

  • a Go module in the repository that nothing says anything about, and an importable package inside a shipped one that nothing classifies;
  • a change to a stable package that version 1 does not allow, measured against engine/api/v1.0.0.txt, which records the exported surface as it stood at the tag. Adding an export passes. Removing one, changing a signature, changing an exported constant’s value, and adding a method to an interface published for implementing do not;
  • an exported signature in a stable package naming a type from a package that is not stable, which is the one that was already broken.

web/apps/api/test/route-boundary.test.ts asks the control plane’s router for its own route table and holds the answer against the published document both ways: a route classified as contract that the document does not carry fails, and a route classified as excluded that it does carry fails too. The check before it compared the published file to what the generator declares, which is the file against itself, so a route the generator never mentioned was missing from both sides and the comparison stayed green.

A stable surface that is going away is deprecated first, not removed. A deprecated flag or key keeps working for the rest of the major version, the release notes name what to use instead, and removal waits for the next major version. Nothing is deprecated today.

Released versions are the git tags in this repository, and the version a binary reports is stamped into it at release time. af version prints it, with the commit and the build date, and af version --output json is the machine readable form.

Every release is signed and carries a bill of materials. Releases and reproducibility has the commands to verify one and to rebuild the archives yourself.