Everything that has changed, and what it means if you are using it.
Each entry is written when the change is made, by whoever made it, and says what it does rather than that it exists. Newest first.
Unreleased
Not released yet · 13 entries · landed 5 September 2026
added
8 entries
Something the product could not do before.
Installing the GitHub App opens a pull request that adds the workflow file.
Installing the App connected a repository and then nothing happened. The installation was recorded, the repository was listed in the console, and no check ever ran on any pull request, because a check needs a workflow file in the repository and nothing had put one there. The documentation sent people to copy a file of over five hundred lines by hand. Most did not, and a repository that read as connected for a month with zero runs was the ordinary outcome rather than the exception.
The App now opens a pull request titled "Check every pull request with Antifailure" on each repository it is installed on. It commits the same short workflow that af init writes to a branch of its own, antifailure/setup, and never touches the default branch. The body says what happens once it is merged, that nothing runs until then, that pull requests from forks wait for the antifailure:allow label, that no secret is required, which secrets are optional and what each one unlocks, and the one repository variable the hosted control plane needs. A repository that already has the file gets no pull request.
The webhook delivery that records the installation only enqueues the work, because a delivery is answered inside seconds and opening the pull request is four calls to GitHub. A sweeper beside the teardown sweeper does the calls under a lease, retries a failure five times and then says so, and reuses a branch or a pull request a dead process left behind rather than opening a second one. An installation that does not hold Contents write is recorded as needing the permission, with the remedy, and is not retried against a refusal that would answer the same way forever; accepting the permission is what puts it back in the queue.
The console's Environments page shows the repositories that are connected and not yet checked under Getting connected: the pull request as a link, the missing permission with its two steps, or the last error. The card is gone once every repository has the file.
The App's Contents permission is Read and write from here on, and self-hosting/production says why and what to do on an App that already exists.
A repository with only the workflow file got no check.
af ci and af change needed antifailure.yaml, so the first pull request after installing the workflow, or the GitHub App, ended in AF-MAN-001 and a red step. The workflow being something nobody has to write was worth nothing while the next thing they met was a file they had to write.
With no manifest, both commands now draft one in memory the way af init would, take every default, and run on it. The report opens with a bold line saying so and naming af init as the command that makes the file theirs. A service nothing could be guessed for is left out of the draft and named, rather than the whole run refusing. When nothing at all can be drafted, af ci writes a skipped run that says why and exits zero, because nothing was learned about the change.
The draft is never written to disk. A file that appears in a checkout because a CI job ran is a file nobody committed and nobody can explain.
af change also writes two more keys to GITHUB_OUTPUT: source_url_env, the variable naming production, and secrets, every variable the manifest reads a credential from. Names only. The action exports exactly those out of the caller's secrets, which is what lets a reusable workflow pass the whole secret set without the customer naming each one.
Every pull request gets a check from one file of about thirty lines, and the product writes that file.
af init writes it when the checkout has a GitHub remote, af github init writes it into a project that already has a manifest, and installing the GitHub App opens a pull request adding it. The file's one job calls a reusable workflow, .github/workflows/check.yml, which calls the action, antifailure/antifailure@v1, and a moving v1 tag follows every final release so the line never needs editing.
The documented path was a 505 line workflow that every user copied into their repository and then edited, and three things were wrong with it that no gate could see. The runner was never installed by it, so the agents could not drive a browser in CI and every workflow that needed one came back unverified on the page that taught the integration. The production database secret was never mapped: the manifest names the variable under database.source_url_env, the file did not know the name, and so the check ran on an empty schema for anyone who followed the instructions and reported nothing about it. And a file that long is a file every copy diverges in, so a fix to the template reached nobody who had already copied it.
The action installs the runner when the command needs a browser. The reusable workflow sees the caller's secrets through secrets: inherit, runs af change to learn which variables the manifest reads, and passes exactly those to the action by name, so a secret the manifest never mentions is never read. So the production secret reaches the check under whatever name the manifest chose, with no line in the customer's file naming it, and the report says at the top when it ran on an empty database instead.
A repository with no manifest is checked too: af ci drafts one from the repository and the comment says so in its first lines, with af init as what makes it yours.
masking.yaml had to be written by hand, column by column, from a plan that listed what the defaults had decided and what they could not place.
masking.yaml had to be written by hand, column by column, from a plan that listed what the defaults had decided and what they could not place.Most projects never wrote one, so every column nothing recognised was emptied by a default nobody had read, and the plan kept listing the same questions on every run.
af mask init writes the file. It reads the schema of the source, or of this environment's branch when one is up, decides every column the way the built in rules would, and writes one explicit rule per column: the default restated with its reason where a default matched, and a rule that empties the column, with a reason saying it was unrecognised, where nothing did. A column that cannot be emptied, because it is unique or cannot hold null or is a type the masker cannot rewrite, gets the nearest thing that can run and a reason saying which. The result leaves af mask plan with zero problems and zero unclassified columns, which is the property a masking file is for.
It refuses to replace a file that is already there without --force, since the rules somebody edited are the most valuable thing in it. af init runs the same code when the manifest names a production database and the shell holds it, and af start gained a rung for the file, after the database source and before the golden, naming af mask init as the next command when the source is set and the file is not there.
An agent could rehearse a migration and could not find out whether the machine could run one.
af mcp served four tools against eighty one commands. An agent could ask what a migration would do and what the environment reached, and had no way to ask the questions that come before either: is there a container daemon, is there a golden this project can branch, is anything running, did the application send the verification email, is a model key configured. Every one of those had to be answered by a person at a terminal, and the usual way an agent found out was a rehearsal that failed twenty minutes in for a reason that had nothing to do with the code.
Thirteen more tools, named for the question rather than the command. check_prerequisites is the one to call first: it runs af doctor and af runner check and reports ready, blocked or undetermined, where undetermined means a deciding question could not be answered and is never reported as a pass. inspect_environments, inspect_goldens, describe_model_key, describe_control_plane_account, read_captured_messages and list_webhook_events read state and change nothing. extend_environment_lifetime, verify_model_key, send_webhook_event and prepare_golden change something and say what.
The three that DESTROY something say so on the wire. destructiveHint was published as false on every tool, with a comment saying nothing here destroys anything a caller owns, which was true while every tool made a throwaway environment and removed it again. remove_expired_environments and remove_old_goldens break that, so the hint is now declared per tool. Both plan by default and remove nothing; carrying a plan out means naming every environment or version the plan listed, and a set that has changed since is refused rather than swept. Neither accepts a wildcard.
Nothing here reads, returns, stores or removes a credential. There is no tool for af secret, af token, af login, af provider set or af model set, and no argument anywhere that carries a key: what a result carries is a fingerprint, a last four, or a token prefix. A support bundle is not offered as a tool either, because it collects the application's own logs and every outbound request it made. Free form text on its way into a result now passes the engine's redactor as well, so a provider quoting back the key it just rejected does not put that key in a model's context.
The MCP server answered four questions out of an engine that answers eighty one.
An agent connected to af mcp could rehearse a migration and inspect the egress firewall, and that was the whole surface. Everything else the engine knows, whether the environment is close enough to production to be worth trusting, what a failure code means, what the project is actually configured to do, which checks a diff needs, whether the data still holds, whether this change behaves like the release it replaces, and what masking does, was reachable only by a person typing flags into a terminal. Eight tools now cover that ground: assess_environment_fidelity, explain_error, explain_effective_configuration, plan_checks_for_change, check_data_invariants, compare_with_previous_release, inspect_data_masking and apply_data_masking.
They are named for the question rather than for the command, so a model picks one without reading a manual, and the two that cost minutes submit a run and are polled with get_rehearsal_run like every other experiment here.
Three of them touch data that is real until masking removes it, and none of them returns a value. The masking sample reports whether a column CHANGED, not what it changed from, which is what finds the failure somebody is actually hunting: a rule that names a column and then does nothing to it. The verification reports which detector still recognises something and withholds even the redacted excerpt the scanner keeps, because an excerpt of real data is real data. The invariants report which invariant broke, how many rows came back and what the columns are called, and leave the rows themselves to af invariants.
apply_data_masking is the only one that writes, it is irreversible, and it is a separate tool from the read only one for that reason alone. It also requires an acknowledgement with exactly one accepted value, so that a caller which meant to preview cannot reach it by accident.
The MCP server served four tools out of a command surface of eighty one, so an agent could rehearse a migration and read the egress log and could not bring an environment up, drive it, load it, explore it, look at it, read its output or remove it.
Everything past the two rehearsals was reachable only by a person typing a command.
Seven more tools now cover the environment's whole working life. start_environment creates the environment for the checked out branch and teardown_environment destroys it. describe_environment says what is running and read_service_logs says what it wrote. run_load_test sends production's weighted mix, a short smoke of it, or the declared journeys, chosen by one enum rather than by three near identical tools. run_browser_workflows drives the declared workflows and asks the manifest's invariants of the rows they leave. explore_for_friction sends agents at a goal with no script.
The division of authority is unchanged and is a property of the schemas rather than a convention. There is no argument on any of these that names a branch, a base URL, a database, a golden, a safe route, a threshold or a runner executable: the environment comes from the checkout, the limits come from the manifest's policy block, and unknown fields are refused. teardown_environment is the one tool that destroys, so it is not marked read only, it says so in the first word of its description, and it requires the caller to name the branch, which is then checked against the checkout. There is no wildcard and no way to reach another branch's environment.
What a run reports is bounded and stripped of what it does not control. The rows behind a violated invariant are counted and never quoted, because they come out of a branch of a masked copy of production. A verdict word this engine cannot read is blocked rather than repeated. Page text, control names, route names, scenario names and service logs are all output the application or the candidate branch wrote, so every one of them is neutralised and clipped. A container id and an artifact path name this host and are reported as present or absent instead.
The verdicts come from the evaluator af ci uses, so a tool call and a pull request check cannot disagree. A load run that sent nothing, a threshold that was in force and measured nothing, an exploration that did not cover its goals, and a workflow run in which nothing reached a verdict are all INCONCLUSIVE rather than clean, because an experiment that did not happen says nothing about the change.
The launch film on the home page, and a gate that can see a video looping.
Eighty five seconds, under a heading that says what it shows: a copy of production the same size and shape and load with every real name replaced, the change running there first on every pull request whether a person or an agent wrote it, a migration caught holding an exclusive lock on 48,201,338 rows and taking GET /orders from a 240ms p99 to 4,730ms, and the copy deleting itself afterwards. Nothing reached production, which is the line the film ends the check on.
It does not autoplay and it does not loop. tools/motioncheck exists to refuse a piece of the interface that animates forever while the reader does nothing, and its rule has no carve out for a real event, so it has none for a film. This one plays once when the section is scrolled to, settles, and stops, which is what the twin figure on the same page already does, and it does not start at all under prefers-reduced-motion.
motioncheck could not have seen it either way, and now it can. Everything that gate reads is CSS, from a stylesheet or from a style attribute, and <video autoplay loop> is neither. A page could carry two of them and the gate would report "0 animations that never stop" over the file they were in. It reads the built HTML for a video that carries both attributes, names the source file so an exemption can be copied from the failure, and takes each attribute as a whole attribute rather than as a substring, because loop is inside loop-demo.mp4 and a rule that refuses a video which does not loop is a rule somebody deletes.
The film is 9.8 MB rather than the 51 MB it was cut at, and nothing downloads until somebody scrolls to it. preload is none and playback is started by the viewport observer, so a visitor who never reaches the section pays nothing for it. The site is served by a Static Web App whose plan allows 250 MB in one environment and 100 GB of bandwidth a month with no overage available, which is the arithmetic that decides this rather than a preference about page weight.
Three things that were wrong before this landed, all of them found by looking at the render rather than the source. The observer could not START playback, only resume it: its flag began as "playing", so the first time the section came into view it did nothing, and the autoplay attribute was what made the section look like it worked. There was no play control at all once autoplay was removed, only a circular arrow labelled restart, so anybody with reduced motion on was looking at a still image with no way to play it that said so. And the three video controls sat at the bottom right, where the film burns its captions, so on a phone "Last year, developers" ran underneath them. They are 44 pixels now rather than 40, which is the tap target floor this project sets and every video control on the site was under it.
changed
1 entries
Behaviour that already existed and now works differently.
af init stopped one step short of a working check.
af init stopped one step short of a working check.It wrote the manifest, and the pull request integration then needed a workflow file the reader had to find in the documentation and copy by hand, into a path they had to get right, before anything ran. Most people never got that far, and the ones who did were copying a file whose only job is to call another one.
It writes .github/workflows/antifailure.yml now, when the checkout has a github.com remote, and adds github: {mode: actions, comment: true, fork_policy: label} to the manifest so the settings the file depends on are in a line somebody can read. A file that is already there and differs is left alone and said to be. A checkout on some other forge gets one sentence naming af github init, which writes the same file into a project that already has a manifest and prints, by name, the secrets the check can use and the one repository variable a hosted control plane needs.
Two refusals went with it. A port nothing in the repository named was a question with no default, so af init --non-interactive refused with AF-DET-004 on the median containerised repository, a Dockerfile with no EXPOSE. Every question carries a default now, the language's own port and its conventional start command, listed under Assumed. And a run with no terminal used to refuse with AF-MAN-004 and tell the reader to pass --non-interactive, which in a CI job was the only thing they could have done anyway; it takes the defaults and says so instead, and AF-MAN-004 is gone.
When the manifest names a production database and this shell holds it, af init also writes masking.yaml from the schema, and says which of the two conditions was missing when it could not.
fixed
4 entries
Something that claimed to work and did not.
The command line page ended in a table called "Terminals and tokens" with roughly seven hundred rows in it, and the reader's own signed in terminal was near the bottom.
Almost every row was the same thing: a fifteen minute credential issued to a GitHub Actions run, eight per run, three days deep, all of them expired since the minute after they were issued. The page whose job is to walk somebody through installing the command line ended in a wall of dead machine credentials.
Two things caused it and both are fixed.
The table showed every row, ungrouped and unbounded. It now leads with everything that can still act, in full and one row each, with the button that takes it away. Nothing live is ever grouped, counted or folded away, because this page is also how somebody notices a credential they did not expect. Everything that has expired or been revoked is behind a disclosure with its count on the label, and inside it the credentials from one Actions run are one line carrying how many there were and how many of them were ever used.
And nothing had ever removed one. Expired workflow identities are now swept a day after they die, which is ninety six times their own lifetime, so this morning's runs are still on the screen and last week's are gone. The sweep cannot reach a live credential, a revoked one, a person's signed in terminal or an engine token pasted into a build machine: it runs as a role of its own whose policy admits expired workflow identities and nothing else, decided on the database's clock rather than on anything the application passes it. A revoked credential is kept whatever its age, because the revocation is the record of it, and the audit entry written when a credential is issued survives the sweep in every case.
A manifest with no database.source_url_env got an environment that looked exactly like one on a masked copy of production.
database.source_url_env got an environment that looked exactly like one on a masked copy of production.The migrations built the schema, the workflows ran against no rows, the report said which passed, and nothing anywhere said the database was empty. A golden built from nothing and a golden masked from production produced the same progress lines, the same events and the same green comment.
af up now prints one sentence when the golden it branches holds no production data, and af ci writes the same sentence in bold before the workflow table: this ran on an empty database, the migrations built the schema, no production data was masked or branched, set database.source_url_env and add the secret. The answer comes from the provenance the golden was selected by, so a golden reused from an earlier run answers for what it holds rather than for what the manifest says today. The report's JSON carries it as empty_source, and the golden.refreshing event carries it as a field.
A golden with a seed command is not called empty, because the seed put rows in it and the sentence would be false.
Every af process on a CI runner minted an engine token when it started, whether or not it ever sent anything to the control plane.
af process on a CI runner minted an engine token when it started, whether or not it ever sent anything to the control plane.A workflow run minted eight and used three, and the token directory at /cli filled with credentials issued for messages that did not exist, burying the one a person had created under hundreds nobody had asked for.
The credential is now obtained on the first request rather than at startup, through the same path that already renews an expired one. A process that sends nothing mints nothing. A failed first attempt is reported the way it always was, with the exchange's own reason, and is not repeated inside the minute for a batch the sink retries.
The runbook for a stale vulnerability scan named one cause, and the case that happened was the other one.
It said a scan goes stale because GitHub disables a scheduled workflow in a repository nobody has touched for sixty days, and sent you to gh workflow enable. On 2026-09-05 the scan went stale for a different reason entirely: the schedule fired 21 seconds after a merge to main and a concurrency group cancelled it 17 seconds later, before it reached a single job. gh workflow list --all said active, which is where that page runs out of advice.
The two look identical from outside, because the watchdog reports the same sentence for both: the newest completed scheduled run is too old. The page now opens with the query that tells them apart, a gap with no rows in it against a row that says cancelled, and gives the remedy for each. For a cancellation the remedy is re-running that run rather than starting a new one, because only a run of the schedule event counts and gh workflow run produces a workflow_dispatch one the watchdog deliberately ignores.
It also says to check whether a fix is already in the tree and simply was not live yet. security.yml gives the schedule its own concurrency group precisely so activity on main cannot reach it, and that change had landed 83 minutes after the run that was cancelled. A scheduled run cancelled after it is a regression; one cancelled before it is not, and the difference is two commit timestamps rather than a judgement.
The worked example is dated and kept in its failed state, with a note that the run it names now reads completed/success because following this page is what somebody did to it. A runbook whose example shows the healthy state teaches nothing about the sick one.
v1.2.1
Released · 10 entries · landed 5 September 2026
added
2 entries
Something the product could not do before.
The Helm chart can set every configuration variable the control plane reads.
It could set 15 of the 46 the configuration reference documents. The other 31 had no value in the chart and no generic escape hatch either, so a Kubernetes installation could not turn them on at all. That is not a documentation gap. The operator portal, the whole GitHub App and therefore installations and webhooks, all of billing, all of mail, the secret that seals customers' provider keys, the analytics pipeline and the origin a marketing site is allowed to post from were unreachable, and every one of them presents as a broken feature rather than as something nobody configured. The contact form is the clearest case: it tells the visitor to check their connection, when the server refused the request on purpose because it had no origin to allow.
The chart now names all of them, with the argument for each written where an operator sets it, and validates the sets that are all-or-nothing. A GitHub App missing its private key, billing missing its webhook secret, an operator pool with no operator credential and a required plan with billing off are refused at render time with a sentence, rather than installing and failing later or, worse, running half configured. helm install now prints which optional features are off in the release it just created.
extraEnv is there too, for a variable the chart does not name yet and for one that belongs to something else in the pod.
tools/wirecheck was the check that should have caught this and could not: it compared the reference page against the Terraform module only, so a variable could be documented, read by the application, wired for the hosted installation and unreachable for every self-hosted Kubernetes one while the gate stayed green. It now asks the same question of both installation routes, and the exemption file carries the route each reason applies to, because a reason true of one route is routinely false of the other.
A gate that refuses to publish the marketing site when it calls a control plane route the deployed control plane does not serve.
Somebody filled in the careers form on antifailure.dev and was told "Could not reach the server". Nothing was broken in either half: the form posts to POST /v1/applications, the route was registered, and every test of both sides passed. The site publishes on every merge to main and the control plane only moves when a v* tag is promoted, so the careers page went live the moment its pull request merged while the API it posts to was still serving v1.1.1, twenty two commits behind. POST /v1/applications answered 404 and the form's catch turned that into the only sentence a visitor ever saw.
tools/routecheck asks the deployment rather than the tree, because a check comparing the site against main's server.ts passes on exactly this failure. It runs before the publish, and it fails when it cannot establish an answer rather than reporting a pass it did not earn.
This does not by itself restore the careers form. The route reaches production when a v* tag is promoted, as every control plane change does. What changes today is that the site can no longer be published in front of an API that does not serve it.
Every control plane URL the site builds is now declared in www/lib/control-plane-routes.ts, and a call site that builds one anywhere else fails the www gate naming the file and the line.
changed
1 entries
Behaviour that already existed and now works differently.
The homepage's Isolated Twin section draws the twin again, instead of a mock console with a card sitting on top of the numbers it was explaining.
The figure that shipped on 2026-08-31 was a schematic: the twin's DNS, app, workers, state and credentials wired together on the left, production dimmed and dashed on the right, a red cut between them reading "no route", and along the bottom of the twin the three seals the run actually proves, lighting green one at a time as the beats go by. It said what the section's heading says.
Two rewrites later the same slot held a "Deployment safety score" panel: a gauge, four metric rows, and a floating "Release blocked" card pinned to the bottom right of the panel. That card is the reason this is a defect rather than a preference. It sat over the metric rows, so at the end of the film, which is the state a reader arrives to, the "Checkout p99" reading was hidden behind it, the "Safe state restored" label was hidden behind it, and the "Lock duration" value it was there to explain was hidden behind it. The one number left visible was the one the card was not about. On a phone it was worse: the card covered the panel outright and the heading behind it was faded to a fifth of its opacity, which reads as a rendering fault rather than a transition.
The schematic is back, and the one thing wrong with it is fixed rather than carried along. It is a 940 unit sheet, and a 320 pixel phone gives this figure about 220 pixels of width, so it was drawn at under a quarter size and every label in it landed near two pixels tall: not a small drawing, an illegible one, and it shipped that way. PR #188 had already set the rule for the solutions pages, that a wide figure is redrawn below the small breakpoint rather than scaled down. Below 1024 pixels this figure now draws its own narrow version, with the rows and the three seals as real text at 12 and 13 pixels, the deny cut as a rule across the middle, and production listed under it with its state and its keys struck through. The header carried the same fault: one row held the label, four beats and the remaining count, and on a phone the two readings that say what the run is doing were pushed off both edges. It is two rows below 1024 and one above.
Both drawings read one list of seals and one list of rows, because they drew the same three seals from two separate literals before, and two copies of one fact is how the last figure in this file came to disagree with itself.
Nothing here animates on a loop. The film runs once when the section is scrolled into view, settles, and stops, and it does not run at all under prefers-reduced-motion, which is given the finished state directly.
gray-new-50 was measured in this repository at 3.85:1 and is not used for any text in the new drawing; gray-new-40 is 5.9:1 on white and carries the row kickers and the secondary readings. The destroyed count reads #1f7a00 at 5.5:1 rather than the brand green, which is 2.4:1 and unreadable as text.
fixed
6 entries
Something that claimed to work and did not.
The careers form on the deployed site said "Could not reach the server" and nothing anybody had could see it.
The site publishes on every merge to main and the control plane only moves when a version tag is promoted, so the page was live against an API twenty two commits behind it, and every check was green: the tree declared the route, the form posted to it, and the agents that drive the product on every pull request were pointed at a disposable stack built from one commit, where the front end and the API necessarily agree.
tools/sitesmoke drives the real deployed site with the product's own agent and asserts on what a PERSON sees. It fills the careers form in on every hostname the site answers on, presses the button, and requires the page to show the control plane's own answer. When it does not, the failure quotes the sentence the page actually showed rather than saying that something went wrong. It runs on a schedule as well as after a deploy, because the failure that is live today arrived with no deploy at all: a second custom domain was bound to the site and the control plane had never heard of it, so every form on www.antifailure.dev has been refused ever since. It files no job applications: the scheduled workflow answers the optional work link with a URL the control plane's own validation refuses, so the request reaches the handler and is turned away before anything is written.
The agent needed four fixes before it could have found this at any target. A checkbox reports a value whether or not it is ticked, so the snapshot called every required acknowledgment and every radio group already answered and the planner skipped them. A required field whose label matched no known shape was left empty, so the browser refused to submit the form at all. The submit button said "Send application", which is on no list of words that move a workflow forward, and once the document's own submit controls were consulted the site header's "Sign in" link still won, so the agent filled in the whole form and then navigated away from it. And "Could not reach the server" was on no list of failure signals, so the page telling the agent that its request never arrived was judged unreadable, the verdict was unverified, and unverified exits zero.
An expectation may now be quoted, and a quoted one is required on the page character for character. Without that, expectations are judged by how many of their meaningful words appear anywhere on the page, which is right for a sentence about a product and wrong for a sentence a page either renders or does not: the control plane's own refusal scores six of its seven words against the careers page before the form has been touched.
af runner check said the runner was complete about a tree it had just said it could not read.
af runner check said the runner was complete about a tree it had just said it could not read.A runner whose package.json cannot be parsed reports its dependencies as not checked, which is right: an unanswered question is not a proven failure, and reporting one as the other sends somebody to reinstall over the manifest that is the thing wrong with the tree. The verdict underneath was then computed as "nothing proved a blockage, therefore ready". So the command printed the honest line and the wrong conclusion beneath it, answered complete: true, and exited 0. A script reading the exit code was told a tree nobody could inspect was fine.
The verdict now has three values rather than two, because complete and not complete cannot say "I could not tell". Ready exits 0, blocked exits 3, and undetermined exits 9, the code the error reference publishes as "nothing was measured". complete is true only for ready, and the document names the question that went unanswered.
af start had the same gap on the same function and now reports that runner step as not checked, which is the state it already uses everywhere else.
The API's 404 told callers to look somewhere the route was never going to be.
A request to a path with no route answered "No endpoint at this path. GET /openapi.json lists every endpoint this control plane serves." The second half of that was false. The published document describes the endpoints a client can integrate with, and the transport behind the console, the af command line and the engine is deliberately left out of it, route by route and with reasons recorded. Fifty one of the sixty three routes the control plane registers are not in the document.
So somebody who mistyped /v1/applications read that sentence, fetched the document, did not find the route there either, and had been told by us to conclude it does not exist. It does.
The 404 now says what the document is and what it leaves out, so a route missing from it reads as possibly excluded rather than as absent. A test holds the sentence to the register in both directions: the route it names has to be one this process serves, and the disclaimer has to be there exactly while the document is not exhaustive.
Abandoned GitHub sign-ins were kept for good.
oauth_states holds one row per sign-in that has been started and not finished. The row is unusable ten minutes after it is written, and it was deleted only when somebody came back and redeemed it. Nobody swept the table. So every person who pressed "Continue with GitHub" and closed the tab left a row behind permanently, and so did the site deploy gate, which probes that route on every publish. The table had gone without a sweeper since the first migration, while sessions, device authorizations and sign-in links each got one.
The volume was never the problem. An unbounded, security relevant table on an unauthenticated path is.
The control plane now removes states a day past their expiry, from the same housekeeping interval that sweeps the other three. It is housekeeping and not enforcement: expiry is still decided when a callback is redeemed, so a sweep that is late costs table size and cannot end a sign-in that is in flight.
This one needed no new policy, unlike the two sweepers before it. The policy on this table already admits the application role to every row, because a handshake has no tenant and no user to key on, and that is now measured by a test rather than assumed.
Every form and every beacon on the marketing site was refused for anybody who arrived on the www hostname.
www hostname.antifailure.dev and www.antifailure.dev are two custom domains on one Azure Static Web App. Both are Ready, both serve every page, and neither redirects to the other, because a Static Web Apps route rule matches on a PATH and its configuration schema has no hostname condition at all. AF_SITE_ORIGIN held one origin, the apex.
So a visitor who typed www, followed an old link, or was handed the www page by a search engine sent an Origin header naming the www.antifailure.dev hostname, and the control plane compared it against the apex, found no match, and answered 403 to all three routes a page on the site calls:
POST /v1/site/events, the analytics beacon. Invisible to the visitor, and every page view from that hostname was dropped.POST /v1/leads, the enterprise contact form. The page showed "Could not reach the server. Check your connection and press it again; nothing you typed is lost.", which blames the visitor's own network for a refusal the server issued deliberately, and no lead was recorded.POST /v1/applications, the careers form, on something somebody had just filled in.
AF_SITE_ORIGIN and the site_origin Terraform variable now take a comma separated list, and every route that answers a cross origin browser compares through one shared function rather than four copies of the same rule. A single origin with no comma still parses to a list of one, so an installation that already sets it needs no change, and the variable keeps the name and the type docs/reference/stability.md promised. There is still no value meaning "any origin", the comparison is still exact equality on the whole origin rather than a suffix test, and a response still carries the one origin that matched rather than the list.
This does not make www canonical and is not meant to. The site still canonicalises to the apex for indexing, every page still carries a rel=canonical pointing there, and www/scripts/check-seo.mjs still refuses any built file that publishes another spelling. Those two rules govern different things: what the site PUBLISHES, and which page the API will ANSWER. A visitor who typed www exists whether or not a search engine indexes them.
Two new checks, because nothing could have caught this: the apex worked, so every check anybody ran was green while a whole hostname was broken. just origincheck runs on every branch and compares the hostnames the site is served on against the origins each control plane is configured with, in both directions. just check-origins runs after a publish, asks Azure which custom domains are actually bound rather than trusting a list in the repository, and asks the deployed control plane to answer a real preflight from each one. It exits saying NOT CHECKED rather than passing when it cannot reach Azure.
af runner check reported the runner ready while af test ran a different copy and died in node.
af runner check reported the runner ready while af test ran a different copy and died in node.The check read ~/.antifailure/runner, which is where af runner install puts its copy. A run resolves its runner with a search that offers the checkout's own runner/ first, and on a fresh clone that directory is source with no node_modules. Both commands were honest about the directory each looked at, neither said which, and the failure surfaced three commands later as Cannot find package 'playwright' from inside the runner.
The check now reports on the runner a run started in that directory would use, and prints its path. A run takes the nearest runner that can actually run rather than the nearest one that exists, so a runner/ whose dependencies were never installed is passed over and named rather than started and crashed. When nothing anywhere can run, af test refuses with the directory and the missing package instead of a node stack trace. af runner check -o json now exits non zero when it reports the runner incomplete, which it previously did only without -o json.
security
1 entries
A defect with a consequence for the safety of your data or your build.
A burst of merges to main cancelled the day's scheduled vulnerability scan while it was still pending, so the daily scan never completed.
The watchdog that exists to notice a scan has stopped running reported it, correctly, and main went red for a reason no code change caused.
Two halves. The scheduled scan now has its own concurrency group, so activity on main cannot reach it. And the watchdog skips a cancelled run rather than reading it as a failure, because GitHub uses that one word for three unrelated things and none of them is a verdict. The freshness limit still decides, so a schedule that is cancelled every day ages out and alarms anyway, and a scan that genuinely fails still alarms at once.
v1.2.0
Released · 30 entries · landed 4 to 5 September 2026
added
6 entries
Something the product could not do before.
added · fixed The runbook for turning payment on, which production.tfvars was already pointing at.
production.tfvars was already pointing at.added
Setting stripe_price_team made this control plane able to charge; the comment above it sends the reader to the production page for the commands, and that page had no billing section at all. It has one now, in the order that works: which endpoint to create at Stripe and why it has to be first, since the signing secret does not exist until the endpoint does; the nine events HANDLED_EVENTS acts on; the two vault entries; and four checks on the running control plane, where the last one asks the product for the thing the plan was withholding rather than asking the database what it wrote.
A test that walks one organization through the whole path rather than each hop of it. Refused a fourth environment on the free plan, checkout through the tRPC route, a delivery signed over raw bytes at POST /webhooks/stripe, the plan, the subscription row, the SAME refused request now allowed, and what the billing screen shows a signed in browser. Every hop of that was already proven and the JOIN was not, which is the shape this repository keeps finding in itself: a route that answers 200 is not evidence that a plan changed, and a plan column reading team is not evidence that anybody got anything for their money.
Four arrival orderings driven through the two real entry points rather than through the handler, which nothing in production calls. The delivery that beats the customer row is forced from inside the Stripe call, so the webhook lands while checkout is still between its network call and its write, which is not an interleaving that ordering two awaits can reach. Then the same delivery twice, two at once that disagree about the plan, and the delivery that never comes.
fixed
The Azure page told an operator to put both Stripe credentials in the vault with az keyvault secret set --value "$STRIPE_SECRET_KEY", which the rotating secrets page forbids by name for every other credential on this plane: the value lands in shell history and in the argument list of a running process, where ps shows it to anybody else on the machine. production.tfvars already told the reader that the documented commands pass the value on standard input, and they did not. They do now, taken at a prompt and written through a file nothing else can read, with printf '%s' rather than echo, because a signing secret with a trailing newline is a different string and every delivery Stripe makes is then answered 401 while the plan, the deploy and the dashboard all look correct.
The Terraform test proved that both Stripe credentials reach the container and never that the price does. AF_STRIPE_PRICE_TEAM is the third required setting and a partial configuration is a refusal, so a module that delivered both secrets and dropped the price would have satisfied every assertion in that file, deployed cleanly, and taken no money, which is indistinguishable at the infrastructure layer from billing having never been turned on. It is asserted now, along with the price arriving as a value rather than as a reference to a vault entry nobody created, that no Enterprise price is emitted, and that billing off leaves no AF_STRIPE_ setting at all.
Careers
- Founding engineering and growth roles have a careers page with current compensation stated upfront, a private application form, and an operator review queue. Applications expire after 180 days through maintenance.
added · fixed af update, which installs the latest release rather than telling you how to.
af update, which installs the latest release rather than telling you how to.added
Upgrading meant going back to the website and running the install script by hand, and the CLI knew that and said nothing. A command that prints another command is not an upgrade path; it is a note asking somebody to go and find one, and the version they land on is whatever the page happens to serve.
af update reads the published release, downloads the archive for this platform, checks it against the SHA256 in checksums.txt, and replaces the binary and the runner source that shipped with it. What it will not do is as deliberate as what it will. It never writes to a shell profile, to PATH, or to anything inside a project. It refuses a package managed installation and an enterprise binary rather than guessing, because the upgrade for those belongs to the package manager and the enterprise distribution. It refuses to downgrade. af update --check reports the latest release and changes no file.
The order it works in is the part that matters when something goes wrong. The archive is verified before anything is unpacked, unpacked into a staging directory beside the binary before anything is moved, and the binary is replaced last, by a rename, so a failure at any earlier step leaves the installation that was already working. A failure after the runner source has moved restores it, and if that restore fails too, the original is kept and its path is printed instead of being deleted. An interrupted update is recorded and finished on the next run, and the lock means two of them cannot race.
fixed
Doctor now reports whether this binary is current and whether the project's manifest is valid.
It checked what the machine could do, and answered "This machine can run Antifailure" to somebody holding an obsolete binary in a directory whose manifest did not parse. Both were true statements about a machine and neither was true about the installation.
The manifest check uses the same discovery and validation the lifecycle commands use, so it cannot disagree with them: an invalid manifest fails, and an absent one is reported as the ordinary state of a directory that has not been initialized yet. Neither path writes a project file. The version check reads the published release with a three second deadline and fails for an outdated stable release.
What it will not do is claim to know. A development build, a version newer than anything published, and a lookup that could not complete are each reported as unknown, because a version check that cannot reach the network and answers "current" is worse than no check. A run that finishes with warnings no longer signs off with "This machine can run Antifailure" either, which is what it did while carrying a check that had just said it could not tell.
The hosted control plane can take payment.
stripe_price_team is set in production.tfvars, which is the one switch the whole feature hangs from: it turns on the two Key Vault secret references, the three environment variables the process reads, and the checkout and webhook routes that were answering 503 "This control plane is not configured to take payments" until now.
Team is a flat 500 USD per month for the organization rather than per seat, which is why checkout sends quantity exactly 1. There is deliberately no enterprise price. That plan is arranged with a person, and checkout refuses it by name rather than reaching Stripe with an empty identifier.
Two things are deliberately NOT part of this and both would have been easy to include by accident. hosted_required_plan stays empty, because turning payment on so somebody can buy is a different act from requiring everybody already here to buy, and the second one locks out every organization on the plane the moment it applies. operator_sets_plan stays unset, and Terraform refuses the combination anyway: a plan that can be granted by hand is not a plan anybody has to buy.
The API key and the webhook signing secret are not in this change and are not in any file. They are addressed by their versionless Key Vault IDs and read by the Container App identity when Azure creates the revision, so Terraform never sees either value and no plan reads them. That also fixes the order this has to happen in: both secrets go into the vault BEFORE the apply, because a missing one fails when Azure resolves the reference rather than when Terraform plans.
Hosted mcp
- Connect an MCP client to the control plane with explicit browser consent. Hosted tools use the same tenant permissions and execution paths as the console.
The hosted MCP connection shows the requesting client, its permissions, the registered return address and the access lifetime before asking for approval.
Signing in returns to that request without approving it. Declining grants no access, and incomplete or mismatched requests cannot be approved.
changed
1 entries
Behaviour that already existed and now works differently.
The product and solutions pages draw the run they describe.
Six figures on /product and its five feature pages, and the twin lifecycle on the homepage, were redrawn from the shared figure kit: a plan and its safer sequence, the isolated run boundary and the decision loop that closes it, a shaped load run compared against production baselines, the firewall's mock, capture and deny modes, and a subset that expands by foreign-key closure. Each one carries the caption it needs, so a number that was chosen for an illustration reads as chosen rather than measured, and every figure that reads as a measurement now names its source.
Four classes written on those pages set a property another class on the same element had already set, so they did nothing. cn in this repository is a plain join rather than tailwind-merge: a class passed to a component lands beside the component's own and the cascade picks between them, which has nothing to do with which one the author wrote last. Three eyebrows on /product meant to carry the sage signal rendered in the same grey as the ones that carry none, a stopped seal on /product/firewall rendered white instead of the pale red that marks it stopped, and the lead paragraph of all four solutions heroes rendered in the body grey instead of black. Each of those now emits one class for the property, chosen by a ternary or by a prop on the component, so the colour on the page is the colour somebody asked for.
Four figures were also cut off at 320px rather than reflowed. A grid item's default min-width is auto, so a capability card and a route row refused to go narrower than their own content and sat wider than their track; the twin lifecycle strip asked for four columns of "01 Validate" in 228px; and the outbound contract row held one label at full width and left the attestation beside it less room than its longest word needs. Each figure draws itself inside overflow-hidden, so none of this scrolled: it was simply cut, and a page level check could not see it. The lifecycle strip is two columns before sm, the evidence bundle steps its type up at sm instead of changing columns, and the rest let their content shrink.
fixed
22 entries
Something that claimed to work and did not.
The billing decoders read two Stripe field paths that Stripe had removed.
STRIPE_API_VERSION pins 2024-06-20 on outgoing requests, but a webhook is delivered at the version its ENDPOINT was created with, and the Stripe account this control plane talks to offers no version older than 2025-07-30.basil when a destination is created. So every delivered event arrives in a shape 2025-03-31.basil had already changed, and two decoders were reading the shape before it.
Basil moved the billing period off the subscription and onto each subscription item, so subscriptionOf read current_period_start and current_period_end off a level that no longer carries them and produced null for both. That period is what an organization's paid-through date is computed from when its subscription is cancelled during a deletion, so a cancelling customer lost the entitlement window they had already paid for.
Basil also replaced subscription on the invoice with a parent field that names the object which generated it, so invoiceOf produced a null subscriptionId and every invoice row lost its link to the subscription it was billed for.
Neither failure threw. Both decoders read a missing field, coerced it to null, and wrote a row that looked complete. Both now read either shape, preferring the one the pinned outgoing version still sends, and the invoice reader checks parent.type before trusting parent.subscription_details, so an invoice generated by a quote reports no subscription rather than the wrong one.
Enabled exploration goals now run during pull request checks.
Reports retain the browser observations and evidence, and identify goals that could not run instead of presenting missing exploration as a clean result. Exploration-only runs no longer claim to be using a model merely because a model key is configured. One unreadable exploration result no longer discards evidence from other goals.
Hosted policy findings
- The hosted pull-request check ignored policy findings whenever the browser workflows passed. Failing findings now fail the check, and an incomplete load experiment remains inconclusive instead of being reported as a pass.
Load that sends traffic
- A manifest could enable load while CI skipped it, and explicit safe pages filtered away the only default route. CI now honors the manifest, sends a bounded read-only smoke to literal safe routes when no traffic source exists, and reports the request counts. An incomplete load experiment is inconclusive.
- Synthetic smoke requests count missing pages as errors, and load does not follow unapproved redirects.
An installation that recorded analytics and then stopped said exactly what an installation that never recorded says, and only one of them is a fault.
Both render as a dashboard that is not moving, both logged "analytics is NOT recording: AF_ANALYTICS_SURROGATE_SECRET is not set" at start-up, and the page offered the same instruction to switch it on. That sentence is correct and useless for the installation that was recording until Tuesday, because the numbers on the screen are real, they are frozen, and nothing says so.
The way to get there is a rollback. The surrogate secret and the operator organization reach the process as environment variables, and rolling a Container App back goes to an earlier revision with its environment attached, so a rollback to any revision from before analytics was configured switches recording off while everything else keeps working.
The absent variable cannot detect its own absence. A rolled back deployment and a control plane that never wanted analytics present the identical environment, and the second is a legitimate way to run this: staging does it, and so does any self-hosted installation whose operator does not want the numbers. A rule that refused a missing variable would refuse both.
So the question is asked of the database instead, which is the one party a rollback does not move. analytics_rollup_state.last_run_at is null until the rollup first runs and set forever after, and the application role could already read it. Null with recording off means this installation never recorded, and the existing wording is right. Set with recording off means it recorded and stopped, and now the start-up log says so at error level with the timestamp of the last rollup, and the dashboard says the numbers end where they end rather than offering to switch on something that was already on.
This deliberately does not refuse to start. A rollback happens during an incident, the image carries this code into whichever revision is rolled back to, and Container Apps cannot add a variable to a revision that already exists, so a start-up refusal would block the recovery at the moment it was needed and turn lost analytics into a control plane that is down.
The self-hosting guide explained the zero percent revision trap with the wrong mechanism.
It said the new revision comes up at zero percent "because Terraform does not touch traffic weights", and Terraform does touch them.
Ignoring an attribute is not omitting it. Terraform sends a traffic block either way, and ignore_changes decides which one: the value refreshed from Azure rather than the one in the configuration. The configuration asks for latest_revision = true at one hundred percent, which would put every new revision straight into service. Azure, after any deploy has run, holds a pin naming one revision. So the apply puts back the arrangement it just read, and the revision it is creating is not in that arrangement.
That distinction is the difference between a rule and a coincidence. A reader who believes traffic is never sent has no reason to look at what decides the outcome.
The other half was documented nowhere. The stored state file keeps the OLD revision suffix indefinitely, because ignore_changes is exactly what stops anything writing the real weight back. Both environments were stale that way, each by more than one deploy, and that is the intended resting state rather than drift to repair. What it costs is worth knowing, and it turns on a distinction the guide now draws: a plan and an apply REFRESH, so they act on a current value, while terraform state show and state pull read the stored file and do not. On this deployment the stored file named a revision that had already been deactivated while the plan's own view named the one actually serving. So the answer to "what is serving" comes from Azure, and an empty plan is not an answer either, because the attribute that would say so is the ignored one.
Removing that ignore_changes is called out in all three places, with the consequence the stale file misleads people about. The stored suffix is not what would take effect, the configuration is: latest_revision = true wins, traffic follows the newest revision automatically, every apply puts its own revision into service with no chance to probe it first, and each apply undoes the pin the deploy sets. That is a deliberate change to how releases work here, not a tidy-up of a stale field.
The operator MCP page now shows registered clients and issued credentials, including their organization, approving person, expiry and last authentication.
Operators with revocation permission can stop a credential through the existing audited action. Local checkout sessions are not presented as hosted measurements.
A squash merge made without a sign-off stopped deployments, and nothing could have caught it before it landed.
gh pr merge --squash --body "" creates a commit with no Signed-off-by trailer, which fails the required commits are attributed to their author context on main. The deployment workflow waits for CI on the same commit, reads that failure and refuses, so its build, staging and production jobs are all skipped. Six merged pull requests sat undeployed behind one missing line, and staging moved again only when a later merge carried the trailer.
The commit hooks cannot reach this, because a squash commit is created on GitHub's side and no hook runs there. just merge <number> now performs the merge instead. It requires all nine of main's required contexts to report the literal word success on the pull request's exact head sha, confirms that list against branch protection rather than trusting its own copy, reads mergeStateStatus rather than mergeable, signs off in the merging person's own name and refuses to write anybody else's, never passes --delete-branch, which deletes the branch even when the merge is refused and so closes the pull request, and reads the commit back off the remote afterwards to prove the trailer is really there.
An expiry sweep removed its target but reported lifecycle events under the checkout's environment name.
The events now name the environment the sweep actually removed, so it cannot report another branch as torn down.
Final workflow reporting
- The console could show a workflow as passed after the engine had marked it unverified for using a response invented by a model. Workflow verdicts and run totals are now sent after the engine finishes its checks.
A successful HTTP response with a missing tRPC result left console cards loading forever.
Queries and mutations now reject malformed or incomplete responses with a human error and the existing retry action. Valid empty and nullable results are preserved, as are the server's permission refusals.
The console's first-result guide stopped at commands that prepare a manifest and inspect the machine, without executing a test.
It now walks through runner setup, an isolated environment, a workflow run, its result and cleanup. Empty Runs and Masking pages link to the next useful step, and the environment page offers the configured GitHub App installation link when one is available. The pages explain masking, environments and runs in plain language. Optional runtime registration and CI credentials stay available behind labeled disclosures. Long commands can now be focused and scrolled with the keyboard on a narrow screen.
The operator sidebar and mobile menu now label their sections with distinct identifiers.
Both navigations are mounted together, so their shared identifiers previously made accessible labels ambiguous.
Success badges use the card background. Their previous translucent green fill reduced text contrast below 4.5:1 on the overview page background.
The overview now aligns its attention and installation panels in one row, places recent actions across the full width, and gives each directory-card row a shared bottom edge. Narrow screens keep a natural single-column layout.
An image used only to execute isolated snippets was detected as a web server, and a repository's Alembic migration was assigned to it even though the image contained neither Alembic nor the application's schema.
Initialization now requires runtime evidence before treating a Dockerfile as a service. A framework or Compose command can supply that evidence. Images without an established runtime are named in both the summary and JSON report, because a base image can inherit a command detection did not see.
Migration commands outside a unique service directory now require an explicit image owner. An unattended run refuses to guess. Choosing manual setup reports that migrations remain unconfigured; it does not claim the database is ready.
Enabling Stripe no longer makes a production Terraform plan read the payment credentials.
It constructs their Key Vault references and lets the application's managed identity resolve them during deployment. The planning identity can keep its existing permissions, which do not include reading production secrets.
Credentials must still exist before billing is enabled. Azure reports a missing reference during deployment rather than Terraform reading it during planning.
Checkout sends the quantity required by Stripe for a licensed recurring price: exactly one organization subscription.
It never multiplies the price by the number of members, including when an older client sends a seat count. The old request omitted quantity, and its test incorrectly required that omission.
Durable environment usage
- Removing an environment or repository erased its consumption from operator analytics and could reset the rolling cost cap. Usage now retains each environment's recorded interval through cleanup, while organization deletion still erases its history. Scheduled maintenance saves daily UTC totals, and Analytics & Usage displays those measurements as a chart and accessible table. Already deleted history cannot be recovered.
The first operator required a hand-built container job and a database URL on the command line.
just operator-init production now opens secure interactive setup in the exact running deployment, using its existing credential. An existing root operator is never replaced.
Analytics could omit midnight events and assign funnels to the wrong week when PostgreSQL used a local timezone.
Daily counts, active-subject aggregation and funnel computation now use UTC inside their transactions, including across daylight-saving changes, without changing the caller's connection setting.
Billing could remove a paid plan when a newer canceled subscription arrived, choose a plan according to webhook arrival time, or miss a payment event while its customer was being attached.
Plan selection now uses the provider's creation time and the newest known entitling subscription, serializes concurrent writes before reading the deciding rows, and serializes customer attachment with its deliveries. Invoice and payment-method writes follow the same lock order as subscription repair. The current subscription shown on Plan prefers a live purchase over a newer ended one.
The npm advisory gate now preserves the reason an audit endpoint refused a request.
It reports npm's root message, its structured error fields, the process error, and stderr. When npm leaves its structured fields empty, the gate says so instead of printing an empty sentence after npm audit refused.
Audits run with four workers, a five minute deadline per project and an eleven minute deadline for the scan. Each completed or inconclusive project is named with elapsed time before the Security job can exhaust its fifteen minute budget. Unfinished projects fail the gate and never count as audited.
There is no second whole-audit retry. npm already retries registry fetches, and one unreachable request can consume its five minute fetch timeout. The gate also fails if its report cannot be written.
Continuous deployment now moves the maintenance job to the same tested image digest as the serving control plane, after both health checks pass, and reads the job back before reporting success.
Previously only the application and bootstrap job moved. The scheduled process that creates event partitions stayed on the image from the last Terraform apply, so production served v1.1.0 while maintenance still ran v1.0.0. Both Terraform defaults now name the published v1.1.1 image, so an apply repairs that existing job rather than keeping its old release.
A revision whose private address cannot be read now refuses promotion instead of skipping the candidate health check and asking customers to test it first.
security
1 entries
A defect with a consequence for the safety of your data or your build.
The production runbook told an operator to pass two GitHub credentials to az keyvault secret set as --value.
az keyvault secret set as --value.rotating-secrets.md forbids that by name for every other credential on this plane, and the reason is not stylistic: a value passed as an argument is in the shell's history file and in the argument list of a running process, where ps shows it to anybody else on the machine. The Azure page had all three of its uses replaced when billing was documented. These two were in a different file and were missed, so the project had one page saying to do it safely and another saying to do it the way the first page forbids.
Both now use the same afsecret helper, which takes the value at a prompt with the input hidden and writes it with printf '%s' so no trailing newline is added.
The client id in the same block is deliberately NOT changed. It is not a credential, and keyvault.tf says so where it explains what tfsec reports over these secrets: an OAuth client id is in the address bar of every person who signs in. Hiding it would teach the next reader that it is the same kind of thing as the two beside it, which is the sort of quiet miscalibration that makes somebody careless about the ones that matter.
v1.1.1
Released · 6 entries · landed 3 to 4 September 2026
added
1 entries
Something the product could not do before.
How to connect a client to the MCP server.
af mcp was documented in full: the division of authority, every tool, the verdicts, the errors, what project_id asserts and where output goes. The page opened by saying the server is started by an MCP client rather than typed by a person, and then never showed a client configuration. There was no mcpServers block and no claude mcp add line anywhere in the repository, the docs or the README, so a reader who wanted the thing the page described had nowhere to go.
There is now a verified configuration for Claude Code, Codex CLI, Gemini CLI, Cursor, Windsurf, VS Code, Cline, Continue, Claude Desktop, the ChatGPT desktop app, JetBrains AI Assistant and Zed. The page uses each client's current file, schema and working directory support rather than presenting one JSON shape as universal.
It also names the setting people get wrong. The server binds the directory the client starts it in. The page uses cwd or a Working directory field where the client supports one, and an absolute path passed with -C where it does not. Without either, the failure reads as a missing manifest rather than as a missing setting.
It states the current remote boundary without making it permanent: v1.1.1 has no hosted Antifailure MCP endpoint yet. Browser clients cannot start the local process. An authenticated Streamable HTTP bridge is possible, but it operates containers, a checkout and production shaped data, so it needs deliberate identity, network and tenant isolation. The Supergateway example now selects Streamable HTTP explicitly and names its /mcp endpoint instead of silently starting the default SSE transport.
fixed
5 entries
Something that claimed to work and did not.
A build request that ended before Docker opened a log told you to read a log that did not exist.
af up used AF-BLD-001 for every immediate endpoint failure, including a permanent refusal, a lost Docker connection, a cancellation, and a temporary capacity failure. AF-BLD-001 says to read the build log above, but Docker had not opened the stream that could carry one.
Permanent endpoint refusals now use AF-BLD-006 and say to correct Docker's redacted message. Cancellation, connection, capacity, and other immediate failures use retryable AF-BLD-007 with guidance to run af doctor or try af up again. Both say that no build log exists. A secret Antifailure has loaded is removed before the detail reaches normal output, the wrapped cause, JSON output, or an event.
a-build-that-never-started-named-a-log-that-was-never-written
The worked GitHub Actions example silently discarded half its inputs.
examples/github-workflow.yml declared seed and concurrency twice each in its workflow_dispatch inputs. YAML keeps the last definition, so the first pair's descriptions were text nobody would ever see, in the one file this product hands a new user as the example of how to wire it into CI. Nothing failed. The workflow parsed, GitHub accepted it, the example rendered, and two descriptions were simply gone.
tools/keycheck now refuses any YAML file in the repository that defines the same key twice in one mapping, and just keycheck runs it locally.
Helm charts are rendered through three valid profiles before they are read. A chart template is Go template source that no YAML parser can take, so the first version of the gate reported twelve unreadable files, checked the other forty three, and printed a summary naming fifty five. Nothing else covers them either: helm lint, pointed at a chart whose service.yaml defined type twice, returned "0 chart(s) failed". A duplicate survives rendering intact, so helm template output is what the gate parses. With helm missing the command fails rather than skipping, because skipping would restore the same silence one step further away.
The inline, external secret and sparse profiles reach every conditional resource and optional body. Helm's source markers must account for every authored YAML template, so a resource disabled by the defaults cannot disappear from the gate.
The Antifailure check on this repository's own pull requests said "Nothing was verified" on every one of them, and it was right.
Antifailure check on this repository's own pull requests said "Nothing was verified" on every one of them, and it was right.Two things were wrong and each alone was enough for silence.
A GitHub App is delivered a workflow_run event for every workflow in a repository. The control plane bound the first one it saw for a commit and let that run's completion decide the check. One workflow per repository gets away with that; this repository has seventeen. On commit ada5644 the run that ended the generation was Security, green fifty seconds in, while the job running af ci was still building its database. The check was completed and amber before the check had run. A workflow run now has no standing until it says which run it is, by trading a workflow identity GitHub signed for a callback credential, and that identity's run_id is GitHub's own claim rather than anything a job asserts.
And the dogfood workflow never reported at all. It had no id-token: write, so the runner set no identity variables and nothing could prove what the job was; it named no control plane, so the engine's event sink did not try; it never asked af ci for --report-json; and it posted to /v1/pr/report nowhere. It wrote a comment and exited zero, which is the shape of failure this repository names most often, with the product itself doing the naming on every pull request and nobody reading it.
Both workflows now ask for the credential in their second step rather than beside the report at the end, so the check reads as running while a runner is working, a job that dies is a run the control plane can name and cancel, and a dead run is reported in seconds rather than at the deadline.
af ci could not find the agent runner unless the manifest sat at the top of the checkout or one directory below it.
af ci could not find the agent runner unless the manifest sat at the top of the checkout or one directory below it.A project kept in a subdirectory, which is the ordinary shape rather than the exotic one, got AF-AGT-004 naming four paths, none of which was the runner at the top of the checkout the command was running inside. af runner install already walked up to the top of the checkout and the search af ci uses was a second copy that never learned to. There is one search now, so the two cannot disagree again, and a run also looks beside the binary under share/antifailure the way the installer already did.
A run that reached no verdict used to give the same reason whether the manifest declared no workflows or the run died before it reached the workflows it does declare. The first is true, the second is false, and a false reason sends a reader to edit a manifest that was never the problem. The report now says which of the two happened, and when workflows were declared it points at what stopped the run instead of at the manifest.
v1.1.0
Released · 46 entries · landed 2 to 3 September 2026
added
21 entries
Something the product could not do before.
A switch on the privacy page that turns this site's page counting off, and a section that says what leaves the browser and what never does.
The subprocessor page already told readers a preference would be remembered if they switched measurement off. The only way to switch it off was a query parameter documented in one source comment, so the promise had no reachable mechanism behind it.
The control renders the reason rather than a position. Four different things can stop a reader being counted and only one of them is the switch: a browser sending Global Privacy Control is not counted whatever the switch says. Where the decision is not the reader's, there is no switch at all, only the state.
The beacon does not count crawlers that run JavaScript, or driven browsers, and an opt out now lasts longer than the tab.
Opening any page with ?af-analytics=off switches measurement off for that browser, which is how somebody who works here keeps their own reading out of the numbers.
Weekly and monthly distinct counts, a conversion funnel over events with a window, and retention as a cohort grid.
All three were impossible before, and for one reason: the rollup grouped by a subject and then threw it away, so nothing could follow one organization across two days or one session across two events. The rollup now keeps a working set the application has no grant on, and publishes counts computed from it.
added · changed · fixed Anybody can create an account.
added
Signing in with GitHub now lands you in your own organization on the free plan, owned by you, and the free plan's quotas and cost caps are enforced against it from the first environment. There is no card, no invitation and no password anywhere in the flow: GitHub has to report a verified address before a user row is written, which is the whole of the email verification and is stronger than a link this domain could not send.
The organization is named after your GitHub account and carries its login, so installing the GitHub App on that account later adopts the same organization rather than creating a second one beside it. Environments, audit chain and plan survive the step.
AF_SELF_SERVE_SIGNUP=1 turns it on and it is off by default, because what it grants is a tenant with real compute against it and forgetting a variable has to close a door rather than open one. The process says which mode it is in at start-up, beside the line about the sign-in allowlist, because the two settings are one sentence: who may sign in, and whether there is anything on the other side of the door.
changed
The waitlist is gone. It stored one address per person in a table nothing in this repository could read, and mailed nobody, on a domain that publishes no mail exchanger and an SPF policy authorizing no outbound sender at all. Somebody who left an address was waiting for a message with no route to them. The function, its client, its scheduled probe and three error codes that nothing could return any more are gone, and the sign-up page describes what pressing the button actually does instead.
added
A contact form for buying, which is what replaces the waitlist for anybody who needs seats, single sign-on, a security review or an agreement to sign. It writes a row into the control plane's own database, where the role that serves public requests holds insert and no select, so no request to the site can ever return somebody else's contact details. af-control-plane-backup leads reads the queue, oldest first, and marks one handled. The confirmation on the page says which of two things happened, because a deployment with no mailer records the lead and tells nobody, and a form that answered a plain success there would be the waitlist again with better spacing.
fixed
The first operator could not exist. Operator accounts were created by a route that needs an operator session, which needs an operator account, and nothing anywhere ever wrote an operator's password, so the operator portal was unreachable by anybody on every deployment. af-control-plane-backup bootstrap-operator creates the permanent root operator once and refuses to take over one that already exists, and set-operator-password gives a password to an operator the portal created, revoking every session that operator holds. The password is read from the environment or standard input and deliberately never from an argument.
fixed
The sign-in allowlist could not be configured to mean "everybody". Terraform always set the variable, and an empty list rendered it as an empty string, which the control plane reads as naming nobody, so the most open intent and the most closed one produced the same deployment. The variable is nullable now, in Terraform and in the Helm chart, with all three states rendered and checked.
The operator portal can export its audit chain, and say what it holds about a named person.
admin.audit.export had been a declared permission, held by the owner and security roles, with no route behind it. A permission that guards nothing looks like a capability from every angle except the one that counts. It is a route now: a file, in JSON or CSV, carrying each entry's previous and current hash so somebody who does not trust the vendor can check it, and recording its own departure in the chain it came from.
The verifier it calls had the same shape in the other direction. It was written, tested, and called by nothing outside a test, so the chain's tamper evidence was a property of the code rather than of the product. Verification now runs over the range a file actually covers rather than the whole chain, because a slice shipped with a verification of something larger verifies a different document from the one in the reader's hands.
Data Governance answers what is held about one person, and refuses to answer the parts this product cannot. The locations are read from the database catalog when the question is asked, so a table added next month is in the answer, and each one carries its foreign key's own on-delete behaviour, because that is the erasure answer rather than a description of one. What is not built is named instead of drawn: there is no per person erasure, organization erasure leaves the people in it behind, both audit chains keep the actor's name on purpose because the name is hashed into the entry, and there is no retention policy table so nothing expires on a schedule.
Security Center is every standing credential on the installation as one list rather than three, with how each organization signs in and who holds an operator account beside it.
The operator portal can answer a customer's question from their side of the product, and write down what it found.
Three sections behind Customers. Users & Organizations lists every organization and every account, with the sessions one account holds and the two writes that end them. Support & Impersonation keeps operator notes about a customer and is where an operator steps into an account. Billing & Stripe reads a customer's subscription, invoices, charges and credit from the payment provider rather than from a local copy of it, beside this deployment's own record of every administrative money action taken on the account.
Impersonation is bounded rather than trusted. It needs a stated reason of at least eight characters, it lasts minutes rather than the product's thirty days, the operator portal is closed to that session for as long as it lasts, and the customer gets the record in their own audit log at the moment it starts. The audit entry is written before the session exists, structurally: the session row carries the entry's sequence number as a foreign key into the chain, so a session that was never recorded cannot be represented.
Two ways out, and both now work. Ending it revokes the customer session the browser is holding, and so does signing out of the operator portal, which is the only button the portal offers a session that is already inside one.
The operator portal's Overview now says what is wrong with the installation instead of only listing what the portal contains.
It leads with one sentence, backed by real queries: whether an installation switch is engaged, whether a customer deletion stopped part way, whether an organization is suspended, and whether an operator account is waiting to be provisioned. Under it is a work list that is empty exactly when there is no work, and which names the questions it asked so an empty answer cannot be mistaken for a panel that failed to load.
Analytics & Usage measures consumption in environment-hours, the unit every plan cap is already enforced in, per organization and against that organization's own daily cap. It also reads model spend against the budgets somebody set. The page states plainly that there is no usage rollup in this schema, so the figures are computed live, there is no history older than the underlying table, and it draws no trend line over a series nobody stores.
Admins & Permissions can now create an operator, change a role, and suspend or restore an account. Those four routes existed, were guarded, were audited and were enforced by database triggers, and no screen in the console reached any of them. The page also shows what every platform permission grants and which roles hold it.
System Configuration reports what the running control plane actually resolved rather than what its environment intended: which capabilities are configured, the name of the variable behind each, the schema version this database is on, and whether any installation switch is engaged. No credential value appears on it.
The operator portal has an information architecture: six groups and twenty two sections, with an overview above them, declared in one place so the rail, the page headings and the permission each section needs cannot drift apart.
Every route exists and opens something from the first commit. A section nobody has built yet says so, names what will live there and which permission will read it. It is deliberately not an empty dashboard: a page of zeroes on this portal is indistinguishable from an answer, and an operator reading "0 failing runs" off a placeholder during an incident has been lied to by their own tooling.
The overview is a directory rather than a wall of numbers, filtered to what the signed-in operator's role can actually reach, so somebody who has not used the portal in a month can find the section they want without opening five of them.
The three screens that already existed keep their behaviour and move to where the navigation says they are. The operator log gained the half of itself it was missing: every entry records what changed, the table had no column for it and dropped it, so the log showed that a plan was changed and never what it was changed to. That detail is now in a panel beside the entry.
The whole portal works on a phone. The rail becomes a drawer that traps focus, closes on Escape, returns focus to the button that opened it, and locks the page behind it, and every list becomes a stacked record rather than a table scrolling sideways with the two columns you came for out of sight.
The operator portal can see the repositories, credentials and deliveries on an installation, and revoke a credential that has leaked.
Four sections. Repositories & Pull Requests lists every repository connected to the installation and, for one of them, its pull requests with the newest check generation on each, so a customer reporting that a check never appeared is answered from the row rather than from a log. A fork approval that no longer covers the head is reported as a stale approval rather than as an approval.
API Keys lists every credential that can act as a customer, with its prefix, what created it, what it acts as and when it was last used. An operator holding admin.keys.revoke can revoke one, and revoking a GitHub workflow identity binding also revokes every token that binding has minted. There is no rotate: only a hash is stored, so nothing in this product can produce a replacement, and the page says so rather than offering a button that could not work.
Integrations & Webhooks shows the GitHub App installations and every delivery that arrived from GitHub or Stripe, including the ones that resolved to no organization and the ones nothing handled. It is inbound only, because that is what this product records.
MCP Management reports that this control plane holds no MCP record, because af mcp runs on the developer's machine and never speaks to it, and lists the four tools the engine serves with the file and symbol each claim comes from.
The operator portal can see the product: twins, runs, branches and safe state.
Five sections of the platform portal are now built against real tables rather than reserved. Production Twins lists every environment on the installation, with the one filter that is a finding: past the lifetime it was created with, not torn down, and costing somebody money for a branch nobody is looking at. Branches groups those environments by the branch that made them and surfaces the same cost from the other side, a live twin on a branch whose pull request closed a fortnight ago. Safe State reads the golden data versions and the masking rules a scan proposed, and leads with the unconfirmed ones, which are the columns a scanner believes hold personal data on a copy somebody is running tests against.
Runs and Jobs is the page an operator opens during an incident, so it is built for one sequence: find the run, see why it failed, see what it touched. The three run families are a filter rather than one merged table, because an agent run has verdicts and artifacts, a load run has percentiles and a lease, and a pull request check has a head commit; a table true of all three would have to drop the columns somebody came for. Every list is keyset paged and says how many rows it is showing, and the failures filter is applied by the query rather than after the page, because filtering a cut page eventually returns an empty page with a cursor behind it, which reads as the end of a list that has more in it.
A run that finished is not a run that passed, and the pages say so. Every run carries a standing beside its own state: a load run whose state is succeeded can carry a failing verdict, and an agent run that reached complete having reported no verdict at all did nothing and found nothing. The second is shown as its own answer rather than as a pass, which is the exit-code-zero-over-nothing defect this repository has already shipped once.
Experiments and Feature Flags is a flags page, and it says so at the top. Flags are complete: state, rollout, targets, the internal-only bit, and a kill with a reason attached, plus the column that matters most during an incident, whether anything in the build reads the flag at all. Experiments are not built. There is no experiment table, no variant, no assignment, no exposure log and no results, and a rollout percent is a share of traffic rather than an experiment because nothing records which subject fell on which side. The page names that absence and the four things that would end it, rather than drawing a dashboard whose every number would be invented.
Safe State names its absences the same way. There is no record of a customer's live database, no snapshot ledger and no restore history anywhere in the schema, so the page cannot say which database was cloned, when a twin was restored, or how old the copy is. It says which table each answer would need instead of leaving somebody to search for a panel that was never built.
The operator portal has its Operations sections: infrastructure, failures, email and the kill switches.
Infrastructure and Compute and Incidents and Kill Switches are faces for routes that already existed and had none: system health, the fleet of production twins, the teardown ledger, the egress firewall, and the three switches that stop parts of an installation. The switches show what each one refuses AND what keeps working, require a reason to engage, make the operator type the control's own name, and show the way back at all times. A fleet teardown asks the server what it would touch and makes that count the thing you type, so nobody confirms a blast radius they have not read.
Logs and Error Explorer and Email and Notifications are new, and both are built only from what this product records. There is no exception store, so failures are grouped by the failure code a run ended with, and by the workflow a verdict names. There is no delivery record, so the email page reports whether the installation can send at all, which no query over the database can answer, and counts sign-in links by what became of them. A link issued and never used before it expired is the only trace a delivery failure leaves anywhere in this schema, and the page says that rather than calling the column a bounce.
Both pages name what they cannot show and what it would take to change it. Event payloads are never returned: the field names and the byte size say whether ingestion is working without putting a copy of a tenant's data in an operator's browser.
The event stream now keeps the shape it promised, and something checks.
The release notes listed the stream's set of types as explicitly not stable, with the reason "types are added as features land". That says what happens when the catalog grows and nothing at all about what happens when it shrinks, which is the only direction that breaks anybody.
Nothing was stopping it shrinking. schemas/events.v1.json is generated from the Go type and the catalog, so deleting a type or a field from the envelope regenerates cleanly: the diff is green, every test passes, and the consumer filtering on that type finds out on their next upgrade, receiving nothing and unable to tell that from a quiet system.
engine/internal/events/stream.register.json records the fifty five types and the eight envelope fields version 1 promised, and just eventcheck refuses to let any of them go. A type that is gone, a field that is gone, a field whose type changed, a required field that became optional, and a closed set that lost a value are each a failure naming what went and what it costs.
It also closes a direction nothing was watching. A type declared with no entry in typeDocs is absent from AllTypes, and every existing check walks AllTypes, so an event could be emitted while being missing from the schema, from the reference page and from every check at once. That now fails the build.
One published claim was wrong and is fixed. The description in schemas/events.v1.json said the envelope was identical across the engine, the runner and the control plane. It is not: four of the eight names differ on the control plane's side and two have no counterpart there at all. The comment on the Go type had said so for a while; the published artifact, which is the copy somebody would build against, still carried the claim.
The data object is deliberately outside the promise, and the reference now says so. It is the type specific payload, and its keys move with the code that writes them.
A closed analytics stream, separate from the engine's event stream and unable to become a second copy of it.
An event whose name is not in the catalog, or whose payload carries a field the catalog does not declare, is refused and counted rather than stored and filtered later.
The organization is recorded as a keyed hash rather than as an id, so the stream counts organizations and follows one through a funnel without being able to name one. The application role holds INSERT on the stream and no SELECT, so only the rollup, which runs as the schema owner, ever reads it, and only daily aggregates come back out.
A producer for every event in the analytics catalog, and a gate that fails when one loses its call site.
An event nothing emits is a row on a dashboard that reads zero forever, which is indistinguishable from a quiet week.
The marketing site sends page views, waitlist submissions and waitlist dialog opens. It normalizes the referrer into a bounded channel in the browser, so the raw referrer, the URL and the query string never cross the network at all. It sets no cookie, uses no third party, keeps its session identifier in sessionStorage so it dies with the tab, and turns itself off for a reader who has set Global Privacy Control or Do Not Track.
The analytics catalog, which is the whole vocabulary the store may contain: an event name not in it is refused and counted, and so is a payload field it does not declare.
There is no free-text field kind, so a repository name or a query string cannot reach the database even by mistake.
Milestones live in the organization facts table rather than in the stream. "The first time this organization proved something" was an event until the ordering tests showed two concurrent batches could both claim to be the first; as a column set with LEAST it has no race and converges to the same date whatever order events arrive in.
The analytics dashboard in the console, and the one gate on this server that is not a permission.
The page answers about the whole installation rather than one organization, and every organization has an owner who holds every permission in their own, so the route requires membership of the organization named by AF_ANALYTICS_OPERATOR_ORG as well as the analytics.read permission. Unset means nobody, and the route says which variable to set.
Every panel carries where its numbers came from: the window, when the rollup last ran, which days are still moving, and whether recording is switched on at all. Three different things render as zeros and the page says which one it is showing.
A lint finding now carries an identifier that does not move between releases.
The release notes said the stable identifier for a finding was its rule name within a release, which is another way of saying there was no stable identifier at all. Anything filtering, suppressing or counting a finding had to match on a name that the next release was free to rewrite, and rewriting names is what improving a rule looks like: the day index_not_concurrent grew a sibling for the drop and another for the rebuild, a filter written against it was matching a different set of statements than its author meant.
The rule names were also not published anywhere. The insights page describes each rule in a sentence of prose, so somebody reading "rule": "unique_constraint_builds_index" out of --output json had no page to look it up on.
Every finding now carries a LINT-NNN identifier beside the rule name:
{"id": "LINT-010", "rule": "unique_constraint_builds_index", ...}
The report a person reads leads with it too, so the thing to write down is the thing on the page:
What these migrations do to a table this size: LINT-016 table dropped on orders, about 40000000 rows
The identifier is assigned once and keeps its meaning. It is never reused, not even after the rule that earned it is deleted: a retired rule keeps its entry and its number, so a filter written against one never quietly starts matching something else. The rule name, the title, the explanation and the fix all stay free to change, because that is what makes a rule better.
engine/internal/insights/lintcatalog.yaml is the source of truth. The Go code, the new lint findings reference page and the published catalogue at antifailure.dev/lint-findings.v1.json are generated from it, and findings.register.json beside it records every identifier ever handed out. GET antifailure.dev/api lists the catalogue beside the error one, so an agent asking what this host offers a machine is told about it. just lintcheck runs in CI and refuses a rule with no identifier, an entry for a rule that no longer exists, a duplicate, and an identifier that has left the catalogue since it was registered.
Nothing about which findings a release reports has changed.
Operators can now see what the installation is doing across every organization, and stop it.
The portal gains system health, the fleet of running environments, the teardown ledger, the egress firewall, and three emergency switches.
Health is a list of checks rather than a colour, and each one says what was counted, which number would be wrong, and what to do when it is. It reports environments that outlived their expiry and are still holding a database branch and containers, teardowns waiting on a runtime, teardowns abandoned after every attempt failed, the delay between an event happening and arriving, GitHub deliveries accepted and never handled, and pull request checks past their own deadline.
The teardown ledger keeps apart three things a single word hides. A request that was RECORDED with no workflow run and no environment id had nothing to send and will sit until it is abandoned. A request that was DISPATCHED has been asked for and not confirmed, because a cancel GitHub accepts is not a runtime saying the environment is gone. A CONFIRMED request is one the runtime acknowledged. An operator asking for a whole fleet to be torn down is told, per environment, which of those it is.
The firewall view reports one condition as always failing rather than scoring it: an egress rule in sandbox mode with no sandbox credential configured forwards whatever credential the application set, to the real provider. Such a request is identical to a working sandbox call in every column of the request log except that nothing was substituted.
The three switches are maintenance mode, new sign-ups, and new runs. Each is enforced by a named function with a real call site, refuses at a path a test drives, and is released from the same screen that engaged it. Maintenance keeps reads, sign-in and event ingestion working, so the operator who paused the installation can still authenticate to unpause it and no engine loses the record of work that ran anyway. Pausing sign-ups refuses only accounts the installation has never seen, so nobody mid-task is locked out. Freezing runs leaves teardown working, because an operator freezing during an incident still has to be able to stop what is running.
Engaging or releasing a switch writes a high severity audit entry in the same transaction as the change, with the entry first, so a switch cannot take effect without its record and a rollback takes both.
The pricing page publishes what the free plan actually allows, and the numbers come from the code that enforces them.
Three environments live at once, twenty four environment-hours committed by one run, and seventy two environment-hours in any rolling day. All three were enforced in the control plane and printed nowhere a customer could read, so the only way to learn the shape of the free plan was to reach a limit and read the refusal.
They live in www/lib/plan-facts.ts and the page renders them from there. web/apps/api/test/plan-facts.test.ts fails the build if that file and PLAN_QUOTAS or PLAN_COST_CAPS stop agreeing, which is the same gate legal-facts.test.ts puts over the legal pages and for the same reason: every one of the seven published claims that test was written for was true when it was written.
Two quotas are deliberately not published. PLAN_QUOTAS also declares goldens and artifactGigabytes for every plan, and neither is enforced anywhere: both are counted for display and no path refuses a creation over either. Publishing a limit that nothing applies is the same defect pointing the other way, so the test refuses those two by name until somebody wires them.
The page also answers the questions a visitor arrives with, including whether the MCP server is free. It is. af mcp is a command of the engine, the engine is MIT licensed, it runs on your own machine over standard input and output, and the enterprise directory contains no MCP code at all.
The free tier is the page's primary action now rather than an outlined afterthought beside an invitation wall.
af doctor reports the Postgres client, and the newest server version it can read.
af doctor reports the Postgres client, and the newest server version it can read.Doctor's promise is that every problem it names is one you would otherwise meet halfway through a run. Copying production shells out to pg_dump and pg_restore, and it never looked at either. A machine with neither, or with a client older than the source, said "This machine can run Antifailure" and then failed at the step that comes after every other one: the repository read, the manifest written, the images built, and only then does the copy stop.
pg_dump refuses a server newer than itself outright, and there is no flag for it, so the ceiling is worth knowing before the twenty minutes rather than after.
`` ok Postgres client pg_dump 18 at /opt/homebrew/bin/pg_dump, so it can copy a source up to Postgres 18 ``
A warning rather than a failure when nothing is found. A project that fills its golden from database.seed runs neither program, and doctor answers about the machine, so it has no manifest to tell the two apart.
The ceiling is the newest client installed, which is not the one a copy runs. A copy picks the oldest client that still clears the server it just asked, so that a machine with 15, 16 and 18 copies a 16 server with the 16 client. Doctor has connected to nothing and has no bar to clear, and asking the copy's question with no bar returns the oldest install: the first version of this reported a ceiling of Postgres 17 on a machine that copied an 18 source three commands later. The two questions are now two functions and a test holds them apart.
The README says that releases carry a signed bill of materials and signed checksums.
It did not say so before, deliberately. No published release carried either file until v1.0.0, and the releases page disproved the claim in under a minute for exactly the reader it was meant to impress. The workflow steps existed and had never executed.
v1.0.0 ran them for the first time and published both. The sentence is checkable now, and it names the two filenames so a reader can settle it from the release page rather than taking it on trust.
changed
3 entries
Behaviour that already existed and now works differently.
The four solutions pages carried stock schematics that could have illustrated any product.
They now carry sixteen figures drawn as the artifacts the product actually produces: the egress rule table with its per-host mode, the twin ledger beside the live processors it refused to call, the attempted-effect receipt, a query plan that turns an index scan into a sequential one, the lock wait chain, and the buyer and seller rows a referential subset has to restore together. Each one is a specific thing a reader can check rather than a shape that means nothing on inspection.
The wide figures are redrawn below the small breakpoint rather than scaled down. The host mode matrix is the clearest case: four mode columns and a grid of marks on a desktop, and on a phone the same data as one row per host naming only the mode that applies, so nothing sits behind a sideways scroll. Every state on these figures is carried by its shape and its label as well as its colour, so none of them depends on telling green from ochre.
The architecture page and the safety report page have folded into the product overview, which is where their material now lives, and both URLs go there. That follows the six product pages already redirected the same way.
The footer artwork stays the photograph it was. A replacement had been swapped in that was a stock abstract in neon green on black, four and a half times the bytes, and 1024x640 where the srcSet still advertised 1024x768, which is a layout jump when the image lands.
The Helm values and the Terraform variables are stable, and a gate holds them.
Version 1.0.0 listed both as free to change in a minor release. The cost of that sentence falls entirely on the operator: a self hoster's values file and tfvars file are their configuration, kept in their repository and applied by their pipeline, and nobody outside this repository could know whether pool_max still existed in the version they were about to take. Every upgrade was a hand migration that could not be automated.
It also failed quietly rather than loudly, which is what made it worth a gate rather than a rule. Helm accepts a values key no template reads. Terraform only warns about a variable nothing declares. A rename does not stop an apply, it removes a setting while the apply reports success.
The promise is the name, the type, and whether an input is required, for every key in the chart's values file and every variable and output in the Terraform. Outputs are named because a runbook reads them: the Azure guide pipes backend_hcl into a backend configuration and the rotation runbook scopes a role assignment with key_vault_id. Defaults are deliberately excluded and image_tag is why, since it names the release being cut and tools/tagsync exists to make sure it moves.
tools/inputcheck reads every one of them and compares against a snapshot taken at v1.0.0. A removal, a rename, a changed type, an optional input becoming required, and a new input arriving without a default each fail and say which one they are. Adding an optional input is compatible and only asks for the snapshot to record it. It was watched refusing each of those before it was believed, including on the five renames in this release.
Five variables were renamed first, because freezing a name that is wrong means living with it until a 2.0. The foundation module's name is resource_group_name, which is what its own description called it and what the stack already passed to it, while name on the control plane module means a four character resource prefix. Its log_analytics is log_analytics_enabled, a switch that sat one line from an output called log_analytics_id. The alerting module's connection_percent is database_connection_percent, beside the two database thresholds it belongs with. golden_replication and golden_soft_delete_days are goldens_replication and goldens_soft_delete_days, so one family carries one prefix.
The chart is version 1.0.0. A chart at 0.x says in the only language its ecosystem has that its values may be rearranged at any time.
The control plane stack and module now default image_tag to v1.0.0.
image_tag to v1.0.0.They were pinned to v0.1.1, deliberately, until the tag existed. tools/tagsync classifies both as live pins, meaning they are read by an apply from main rather than from a released tree, and azurerm_container_app_job.maintenance reads the value with no ignore_changes. So pointing them at a tag before that tag published would not have produced a stale deployment, it would have produced a failed apply on the stack that runs the product.
v1.0.0 published at 17:55 UTC, so they can name it now.
fixed
21 entries
Something that claimed to work and did not.
The control plane's configuration reference shipped with an unresolved merge in it.
Literal <<<<<<< HEAD, ======= and >>>>>>> around six rows of the variable table, two of them the same variable described twice, on the one page somebody reads to configure this product.
The resolution is the union of both sides rather than either of them, which is why it was not obvious: each side held a row the other did not, and each held a better version of one they shared. AF_GITHUB_APP_INSTALL_URL keeps the newer description, because the console stopped hiding the membership recheck behind the install address and the older text still described the screen before that fix. AF_SIGNUP_URL takes the other side, because the version that survived said a refused visitor would be pointed at "somebody else's waitlist" and the waitlist was removed in this same release. AF_SITE_ORIGIN and AF_LEAD_NOTIFY_EMAIL existed on one side only.
tools/conflictcheck is the gate, and what it is for is narrower than it looks. Every gate this repository has was green about this. Markdown does not fail to parse. prosecheck reads punctuation. varcheck and config-docs.test.ts ask whether a variable is documented, and a row inside a conflict block reads as documented to all three. The only check that did go red went red for a consequence rather than the cause: wirecheck reported a variable as documented with no supported deploy able to set it, which sent two people to look at Terraform, when the cause was four lines of git output in a table.
What it cannot catch is written into the tool rather than left to be discovered. A conflict resolved by keeping one side whole, when the correct resolution was the union, leaves no marker at all and is invisible to this and to git. The instrument for that one is diffing the resolution against both parents.
The site had a catalogued analytics event with nothing left to emit it.
The waitlist was removed and site.waitlist_submitted kept its definition, its export and its catalog entry, so the acquisition funnel ended on a step that could never fill and every gate read that as fine. The event is now site.lead_submitted, produced by the contact form that replaced the waitlist, and a gate asks whether each producer function has a caller rather than whether the event name appears in a file. The contact page also gained a page shape of its own, having been counted as "other" while holding the only form on the site.
The site beacon batches, retries and computes a session.
It sent one request per event into an endpoint built to take twenty, lost every event whose request failed, and treated the browser tab as the session, so a tab left open over a weekend was one visit. A session now ends after thirty minutes idle and after a day, which makes the identifier shorter lived as well as the count truer.
The analytics rollup rides the maintenance pass, every replica runs that pass, and it runs once immediately on start.
Production is configured for two replicas, so on every deploy two rollups began within milliseconds of each other and raced: each day recompute is a delete and an insert, and the second insert failed on the primary key and took the whole maintenance pass down with it. The visible symptom was not a wrong number, it was a dashboard that stopped updating while a line went into a log. Every analytics writer now takes one transaction scoped lock, so a second replica queues instead of colliding.
The hosted deploy path could set 16 of the 45 variables the control plane reads.
The reference documents 45 environment variables and the Terraform module is the only route onto that container: deploy/cd/deploy.sh updates the image and sets no environment at all, and az containerapp update --set-env-vars is drift the next apply removes. So 29 documented variables had no supported way to be set, and every symptom of that reads as a broken feature rather than as an unset variable. The operator portal's 23 routes all refused for want of a database credential. No sign-in link and no invitation could be sent. Billing was off with a real Stripe price behind Team. The marketing site's beacon was refused cross origin as a bare network error, the analytics stream recorded nothing, and both actions were missing from the "No organization yet" screen.
The module now sets 34 of them, the operator credential included: its role is created NOLOGIN by the migrations and the bootstrap job is what gives it a login, inside the VNet, because Terraform cannot reach a server with no public endpoint. The other 10 are exempt with a written reason, and the reasons are real ones: AF_VERSION and AF_COMMIT are stamped into the image at build time, AF_MIGRATE is absent from the serving process on purpose, and Enterprise has no Stripe price because it is arranged with a person.
What it proves is narrow on purpose: a supported deploy path can DELIVER the variable. Not that the feature works. Mail is the live example and the guide now says so, because antifailure.dev publishes v=spf1 -all, a DMARC policy of p=reject with strict alignment, and a revoked Resend DKIM key, so the module can carry the mail configuration and the domain still cannot send. Nothing that was working stopped: sign-in is GitHub and the mailed link is an extra method, invitations return their link to the inviter whether or not mail is configured, and enterprise leads are recorded either way.
tools/wirecheck is the gate. Two checks already covered this ground and both answered a nearby question: tools/varcheck and config-docs.test.ts prove a variable is DOCUMENTED, and a variable that is documented, read by the application, and unreachable by every apply passes both of them cleanly. That is how 29 accumulated in silence with every instrument green.
fixed · changed Checkout sold a per unit seat count that entitled nothing.
fixed
subscriptions.checkout took a seats number between one and a thousand and passed it to Stripe as line_items[0][quantity], so the price multiplied by it. Nothing ever read it back. How many members an organization may hold is a constant per plan in entitlements.ts, enforced by seatVerdict, and it never consulted the subscription at all. An organization that bought three seats on Team got fifty. An organization that bought two hundred seats on Team also got fifty, and paid two hundred times as much for the same limit.
What this product sells is one hosted control plane per organization at a flat fee, so there is no number for a price to multiply. The seats input is gone from the route and from the published OpenAPI contract, and checkout sends Stripe no quantity at all rather than a hardcoded one: Stripe bills a licensed recurring price once when the parameter is absent, and omitting it also keeps the call valid if a price is ever made metered, because Stripe refuses a quantity on a metered price.
The quantity column stays and is now labelled for what it is. Stripe reports a quantity on every subscription object it sends, so the row records it and the admin money screen, the billing summary and the organization export display it, which is what lets an operator reconciling an invoice see what was actually billed. No entitlement and no quota reads it. The organization export called the field seats, which told a customer their subscription had bought them that many members; it is stripeQuantity now, because an export is the document somebody takes to a third party and it should not state a limit the product does not enforce.
Three tests hold the two halves apart, and each was checked by breaking the fix and watching it go red. Checkout's request body must carry no quantity. The same plan must resolve to the same seat limit whatever a subscription row says, in both directions: a recorded quantity of two hundred does not raise a five seat plan, and a recorded quantity of three does not lower a fifty seat one. And entitlements.ts must contain no read of a quantity at all.
A billing ordering that was never covered is now covered: a plan downgrade while an organization holds more members than the lower plan allows. Nothing is removed, and the next invitation is refused naming what the organization is holding. The webhook connection cannot see the members table under row level security, which is the structural reason a plan change has never been able to take somebody's colleague away.
changed
The pricing page describes the two paid plans that exist.
It advertised a "Growth + Enterprise" band and a hero line about "Growth and Enterprise". There is no Growth plan behind it: the control plane sells team and enterprise, PLAN_QUOTAS knows free, team and enterprise, and the only prices an operator can configure are AF_STRIPE_PRICE_TEAM and AF_STRIPE_PRICE_ENTERPRISE. A third name on a pricing page is a plan a reader can ask to buy and nobody can sell. No price number moved; the band that was labelled Growth is labelled Enterprise.
Each paid plan now publishes how many people it holds, and the free plan's number is answered in the questions. That is the question the removed seat picker used to imply, and it was written down nowhere a reader could find it. The numbers come from www/lib/plan-facts.ts and web/apps/api/test/plan-facts.test.ts fails the build if they stop matching ENTITLEMENTS.seats.byPlan, the same way the free plan's three numbers are held against the quota table.
fixed
A control plane that sold one plan self-serve and one by arrangement took no money at all.
AF_STRIPE_PRICE_ENTERPRISE was a required variable. A deployment that set the Stripe secret key, the webhook secret and AF_STRIPE_PRICE_TEAM and nothing else landed in the "partially configured" branch: stripeConfigFrom returned no configuration, billing was entirely off, every billing route answered PRECONDITION_FAILED, and the Team price that did exist could not be sold either. The startup line called it an operator error. That is the shape this product actually sells in, because Enterprise is agreed with a person and has no Stripe price at all.
Measured by calling the function rather than by reading it: secret key, webhook secret and Team price gave config: null and "billing is OFF and partially configured: AF_STRIPE_PRICE_ENTERPRISE not set". It gives a configuration now, and a startup line that names both halves, "team sold self-serve, enterprise has no price and is arranged with a person", so the first Enterprise refusal does not read like an outage to whoever is on call.
The three that are genuinely required are still required, each proved by dropping it on its own. AF_STRIPE_PRICE_ENTERPRISE set by itself is still reported as half configured rather than as untouched.
Checkout for a plan with no price is refused before Stripe is called, with a sentence saying the plan is arranged with a person and where to ask, rather than sending Stripe an empty price identifier and returning a generic "could not open a checkout page" to the buyer with the largest cheque. Nothing reaches Stripe on that path, which is asserted rather than assumed. The admin plan change refuses the same way rather than replacing a paying subscription's item with nothing.
Two smaller things fell out of it. A plan with no price can no longer match a subscription that carries no price identifier: the lookup compared undefined against undefined, matched, and would have moved somebody onto the largest plan for nothing. And AF_HOSTED_REQUIRED_PLAN now stops the process when it names a plan with no price, which is the contradiction its existing billing-off check already exists to prevent, reached through the door this change opened.
Three messages that named AF_STRIPE_PRICE_ENTERPRISE as something to go and set were corrected, because on an installation that agrees Enterprise with a person it is correctly unset forever and each of them sent somebody to fix a thing that was not broken. The billing precondition names the three variables that are actually required. The deletion path's refusal overstated it twice over: it named all four while the only Stripe call on that path is cancelSubscription, which needs the secret key and no price at all, so it now says which variable the cancellation itself uses and which two make the configuration resolve. And the console no longer offers a plan it cannot sell.
The refusal a buyer meets on a plan with no price is written as a route rather than a failure: it says the plan is agreed with a person, why, where to ask, and that nothing was charged. A test asserts those words, and asserts the sentence never reads as an outage.
A start-up refusal named a price variable by writing a prefix and appending the uppercased plan. That put a truncated fragment in the source and nothing else, so config-docs.test.ts, which holds the configuration reference against the variables the process reads, reported that fragment as read and undocumented. It was right and the reference could not have fixed it: a name assembled at run time is not a name anybody can set, and nothing can enumerate the settings a process reads if it builds them. The variable for each paid plan is a full literal in a closed map now, so a plan with no entry is a compile error rather than a lookup that quietly returns undefined.
That suite gained two assertions for the same class: no variable name in the source ends in an underscore, which is the signature of a concatenated name, and every paid plan's price variable is a complete name. Each was checked by making the break it guards.
A person who signed up, signed a terminal in with af login, and ran af up watched an empty environments list forever, and every part of it looked configured.
af login, and ran af up watched an empty environments list forever, and every part of it looked configured.attachControlPlane took a token from AF_CONTROL_PLANE_TOKEN or from a GitHub Actions identity and from nowhere else, so the credential the device grant so carefully protects was read by af env pull and by nothing that reports a run. The CLI now hands the stored credential and the origin it was issued by to the orchestrator, which passes both to telemetry, and af up, af test, af ci and af workload report with it. The environment token still wins, because exporting one is a decision and a credential on the machine is a default. A machine nobody has signed in behaves exactly as it did before.
Nothing in the console had ever mentioned the command line. A new organization landed on an environments list whose three empty cards each explained that something appears when the engine reports one, and no screen anywhere said how to get an engine, sign it in, or run it: the install command was on the marketing site and in the documentation, which is where somebody who has not signed up yet is. There is now a Command line screen carrying the install command, the sign-in command, and the first two commands worth running, and both empty states on the environments page lead to it.
The sign-in command on that screen names the control plane the console is being served from, and says nothing when that is already the hosted instance. A plain af login on a self hosted plane signs a terminal in to somewhere else and stores a credential for an origin nothing that person runs will ever talk to.
tokens.list and tokens.revoke were written, permissioned and audited, and no console called either of them, so a ninety day credential granted by af login could be neither seen nor taken away from any screen in the product. Both are on the new page, telling a terminal apart from an engine token because revoking your own laptop and revoking a build machine are not the same act.
A login whose token could not be stored used to leave that token live. It is minted the moment somebody approves, and on macOS the write that follows can be refused: over ssh or under a launchd agent there is nobody to authorise the keychain prompt. The command returned an error and left a ninety day credential nobody held, nobody could see and nobody could revoke, with another one beside it on every retry. It now revokes what it cannot keep, and says the token is still live, and where to revoke it, when the revocation fails too.
The credential CI needs was the other half of the same silence. POST /v1/tokens mints an engine token, it answers a bearer credential and has no cookie path at all, and that is deliberate: a browser session that could mint would be a credential factory behind a cookie. So no screen can reach it and none should, which leaves telling somebody as the only thing a console can do, and nothing did. The page now carries the two commands, says the scope has to be asked for by name, and says first that a GitHub Actions job needs no token at all, so nobody pastes a permanent secret into a repository to solve a problem the identity exchange already solves.
af --version answered "unknown flag" on a fresh install, which is the first command a lot of people type and the first thing the product said to them.
af --version answered "unknown flag" on a fresh install, which is the first command a lot of people type and the first thing the product said to them.af version was correct and --version was the spelling nobody had bound. Both now run one implementation, so the text, the --output json object and --short are identical whichever way they are asked for.
The obvious fix is the wrong one. cobra's built in --version prints a static template from a package variable, and that variable is never stamped by any build: it is why the enterprise binary once printed "community edition" from af version while its own af license status printed "enterprise". Taking it would have put that defect back, in a second place, on the spelling somebody reaches for first. The edition is asked of the running binary instead, and a test attaches an enterprise status and fails if the answer comes from anywhere else.
-v is unchanged. It is the shorthand for --verbose on every command in the tree, and af -v prints the help because a bare af prints the help, not because the letter was unhandled. --short on its own is now refused by name rather than quietly printing the help.
Any path the control plane has no route for answered 500 instead of 404.
The rate limit gate refuses to serve an endpoint with no declared limit, which is a good rule: an endpoint nobody remembered to bound is the one nobody load tested. But the gate runs in middleware, and middleware runs before routing, so it could not tell a route that exists with no limit from a path that matches no route at all. It answered both the same way. On the deployed control plane GET /v1/health, GET /v1/version, GET /v1/status and anything else made up under /v1/ all returned 500, which told every monitor and load balancer that the server was broken because somebody mistyped a URL, and made a real outage indistinguishable from a typo.
The body was the second half of it. It read "Add it to ENDPOINT_LIMITS with the reason for the number", an instruction to a maintainer of this codebase served to anybody who could reach the port. It described the server's own gate design to a stranger and told the one person who could act on it nothing, because maintainers read logs rather than other people's error bodies.
The gate now asks the router which of the two cases it is looking at, and it asks the router itself rather than a second list of paths that could drift. A path with no route is a 404 that names GET /openapi.json, which this same process serves, so the answer cannot rot. A route that exists with no declared limit is still refused with a 500, still refused before its handler runs, and the sentence naming the catalog key to add moved to the log line beside it.
HEAD was refused on every endpoint the API owns, for a related reason. The framework answers a HEAD by dispatching it again as a GET, and the catalog is keyed by method and holds no HEAD entries, so HEAD /v1/environments/af-1 returned 500 while the GET beside it answered normally. A HEAD now resolves through the GET it will actually be dispatched as.
fixed · added The code of conduct no longer names an address that cannot receive a harassment report.
fixed
CODE_OF_CONDUCT.md named conduct@antifailure.dev as the place to report abusive behaviour. The antifailure.dev domain publishes no mail exchanger, an SPF policy authorising no sender, a DMARC policy of reject with strict alignment, and a DKIM record whose empty p= revokes the key. Nothing addressed there was ever delivered, and the person reporting got a silence they could not tell apart from being ignored.
There is no confidential channel to put in its place, so the section says that instead of naming another mailbox. It lists what does resolve, a public issue or discussion and a booked call with the maintainer, and what each one costs the reporter. It says that GitHub private vulnerability reporting is the wrong queue for this. It names the gap none of them closes: whoever would read a complaint is one of the same few people who maintain the repository, so a complaint about a maintainer has nowhere independent to go inside the project, and it points at GitHub's own abuse route, which is outside it.
The daily status workflow committed as status@antifailure.dev, which reads like a mailbox and is not one. It is the same domain, which has no mail exchanger, so anybody who replied to a status commit was writing into nothing. It commits under GitHub's own no-reply identity for the Actions bot now.
added
A gate that refuses a contact route this project cannot answer.
The site had already been fixed and the repository had not, which is why this exists rather than a third correction. tools/contactcheck reads every text file in the tree and requires a row in tools/docs/contact-routes.tsv for every address, saying whether a person reads it, whether it is a value the software writes rather than an invitation, or whether it is a defect being quoted. An address with no row fails, so a domain nobody has checked is caught by the missing row. A receives row at a domain proven dead is refused outright, and at such a domain no sentence anywhere in the tree may read as an instruction to write there, whatever its row says.
It does not resolve MX at check time. That would make every pull request depend on somebody else's resolver, and it answers the wrong question: an MX record proves a server accepts mail, not that a person reads what lands there. What it cannot catch is written into the tool's own header, including the case it is blindest to, which is a second domain vouched for by a row nobody verified.
Every mutation in the operator portal was refused before it reached its route.
The console's operator client sent no cross-site request token and carried a comment arguing none was needed, because the operator cookie is SameSite=Strict. The control plane disagrees and always has: it refuses every non-GET request to the tRPC surface that carries a valid operator cookie without a matching x-antifailure-admin-csrf header, and its own suite asserts that three ways. Nothing in the console ever fetched the token. Both files were correct about themselves and the product was broken between them, so suspending and resuming an organization were buttons that could not work.
The token is now fetched once and sent, with a single retry when the transport refuses it specifically, so a session replaced while a page is open recovers instead of leaving a button that silently does nothing. A refusal from the route rather than the transport is not retried, because asking twice gets the same answer.
The same helper also unwrapped nothing. It answered the tRPC envelope carrying the type of the value inside it, which the compiler cannot see and no caller had read: a suspend that promised {suspended: boolean} delivered an object whose suspended was undefined. The first caller to read one downloaded a file containing the word "undefined".
Every write in the operator portal was refused, and had been since the portal existed.
Suspending an organization and resuming one were its only two mutations; both were wired to buttons, both answered 403, and both looked correct in review. They work now.
There were two independent causes, and the second is the one that would have cost somebody a night.
The console sent no operator CSRF token. The comment above the code that sent it argued at length that none was needed, because the operator cookie is SameSite=Strict and a browser sends it on no cross-site request of any kind. That reasoning is sound and it does not matter: the control plane refuses every operator mutation that does not carry the header, and its test suite pins that three ways. Two individually correct halves, and nobody ran the pair.
The origin check compared a browser's Origin header against AF_APP_BASE_URL as text. Those are different things. An Origin is a scheme, a host and a port, with no path and no trailing slash, ever; AF_APP_BASE_URL is a base URL. So the check agreed only when the variable happened to be spelled exactly as an origin. Setting it with a trailing slash was refused. Setting it to a URL with a path was refused. Leaving it unset, which is what the shipped configuration does deliberately because the address is allocated at run time, was refused. Three of the four ordinary spellings turned every operator write into "This operator request came from another site", which is a sentence that sends whoever reads it looking for an attacker who was never there.
Nothing was loosened. A request from a genuinely different site is still refused against every spelling, and an Origin header that is not a URL is now refused rather than waved through. When no base URL is configured the check declines to judge rather than refusing, because it has nothing to judge against, and the token remains the thing that fails closed.
The retention page now says what happens when somebody asks to be removed: the personal fields are erased and the account row is kept by choice, not because the database refuses.
The delete would succeed. What it would also do is null a column that sits inside the audit hash chain, so every entry that person wrote would stop hashing to its recorded hash and the organization's audit log would report itself as altered.
The "No organization yet" screen told people to install the GitHub App and then recheck their membership, and on a control plane with no AF_GITHUB_APP_INSTALL_URL set it offered neither action.
AF_GITHUB_APP_INSTALL_URL set it offered neither action.Both buttons were behind the same condition, so an unset address hid the membership recheck as well, even though that action is an ordinary sign-in exchange that never needed an installation address for anything. What was left was two paragraphs of instructions and a Sign out button.
The recheck is now offered either way, and becomes the primary action when there is nothing to install from. The copy changes with the address rather than describing a button that is not there, because prose naming an action the page cannot offer is the failure and not a symptom of one.
Leaving the address unset stays supported. A self-hosted control plane may grant membership its own way and have no App of its own to point at, so refusing to start over a screen it never shows would be wrong. What was missing is the operator being told: the startup log now names the state either way, next to the lines that already do this for the allowlist, the sealing key, model prices, Stripe and the plan gate. The variable was unset on both control planes for weeks precisely because nothing failed and nothing said so.
A value that is not a URL at all is still refused at startup and now says which variable and which shape, instead of the bare Invalid URL that names neither.
Two render gates that claimed to cover the console and never read it.
motioncheck finds its applications on disk, and it used to include one only when something was found there. In CI the console was installed and built after both render gates ran, so an unbuilt directory was dropped from the run without a word and the check reported success over the marketing site alone. Measured on a built tree: 42 files read with the console hidden, 64 with it present, exit zero in both cases. The animation ban is the rule this project reaches for most often, and for the whole life of the gate it was enforced on one of the two applications.
classcheck never claimed the console, and why it did not is the more useful half. It exists because cn is a plain join rather than tailwind-merge, and the console does not import cn anywhere, so it read as exempt. It is not. It composes classes in template literals, and an interpolated string that already carries a margin beside a margin written after it is the same defect with the same cause: the cascade decides, not the order of the literal.
Both gates now read every Next application, find those applications by looking for the config file that makes one rather than by holding a list, walk for prerendered HTML at any depth rather than at three fixed ones, and refuse an application that is not built instead of skipping it. The console build moved above them in CI, which that refusal makes load bearing rather than incidental.
classcheck also judges more than colour now. It began with five colour properties because the four defects that motivated it were colours, which was narrower than the rule it enforces: a height written last and beaten by a height written first is the same defect and just as invisible in review. The widening was measured before it was made rather than after. Across both applications, 61 files and 16661 elements, twenty one properties add exactly one finding.
That finding is real and is fixed here. The baseline card in the hero film asked for 58 percent of the height and rendered at full height, because the component set h-full in its own class string and the call site passed h-[58%] beside it. Every arbitrary value is emitted before every named utility, so the component won and the frame it was drawing was wrong.
The rehearsal's per statement timing test no longer compares two real statements to each other.
It ended by asserting that building an index over 50,000 rows takes longer than adding a nullable column. That is true most of the time and it was never the property the test exists for. It went red in CI on a pull request whose diff was one file mode bit and one markdown file: a contended runner spent 84ms adding the column and 33ms building the index. A check that fails for reasons no diff can explain is worse than one that is missing, because it teaches everybody to rerun a red job without reading it, and that habit is what lets a real failure through.
What the test is named for is per statement attribution. When the migration tool cannot say what it ran, a Rails or Django migration among them, the server's event triggers say, and each statement gets a duration of its own. The fixture now plants a statement whose duration is a fact rather than a measurement, a materialized view over pg_sleep, in the middle of three. The planted second has to land on the statement that slept and on neither neighbour, no two durations may be the same number, none may be zero, and they have to add up to less than the window the applier ran in. Contention can only make a duration longer, so a busy machine cannot push the sleeping statement under its floor.
Both of the sleeping statement's neighbours only add a nullable column, which is a catalogue write and nothing else. That is what makes the ceiling over them honest. The index build over 50,000 rows went last, because real work has no upper bound: it was measured at 454.9ms on a loaded machine against the tens of milliseconds it takes on an idle one, so a ceiling over it would have been the same defect in a new place.
Between them those assertions catch one total copied onto every row, a duration charged to the neighbouring statement, a running total that grows down the list, a single statement reported for a whole file, and a duration read from the transaction clock rather than the wall clock. Each of the five was introduced in the engine on purpose, and each turned the test red.
Every way a real production database can refuse a copy printed the transcript and no next step.
Pointing a golden refresh at a Postgres carrying what a production schema carries, rather than at an example, stops in four ordinary places. None of them is a broken database and none of them said what to do:
A read only role, which is what the generated manifest's own comment tells you to hand this tool, cannot read the sequences. The first one pg_dump reaches ends the run with permission denied for sequence UserProfile_Id_seq.
Row level security is on, so Postgres refuses to dump a table it would have to filter. It is right to refuse, because a dump taken under a policy carries only the rows that role can see and nothing in it says so, but query would be affected by row-level security policy is not a sentence that tells you BYPASSRLS exists.
An extension the source has and a stock Postgres image does not, which is every schema using PostGIS, pgvector, TimescaleDB or pg_cron, failed inside the restore with a control file path.
A typo in the connection string arrived as the driver's account of four failed dial attempts, under the words "read the roles the source's policies name", which describes the query rather than the problem and sends the reader to look at their policies.
Each of these now has a code, a cause and a remedy that was checked by running it:
AF-DB-017 pg_dump was refused when it read sequence UserProfile_Id_seq in the source database. AF-DB-018 Row level security on customers stops pg_dump from reading it as this role. AF-DB-007 The source database uses the extension postgis, and the Postgres the golden is built in does not carry it. AF-DB-023 The source database answered and refused the connection. AF-DB-024 The value of the variable named by database.source_url_env is not a connection string.
A host that is not listening keeps AF-DB-002, which it always should have had. Anything not recognised keeps its whole transcript under AF-DB-019 and is told to run the program itself, rather than being forced into the nearest code.
pg_dump stops at the first object it cannot read and says nothing about the rest, so a read only role missing several grants used to cost one refresh per grant, and each refresh starts a Postgres container before it discovers the next one. Against the schema above that was three rounds: a sequence in one schema, then row level security on a table, then USAGE on another schema.
So when a refusal is about privileges, the source is asked what else this role cannot read, and all of it arrives at once:
AF-DB-017 The role in the connection string cannot read all of the source database: no SELECT on sequence "Mixed Case Schema"."UserProfile_Id_seq"; no USAGE on schema analytics; row level security applies to public.customers
Applying every remedy that message names, in one go, publishes the golden. The question is only asked after pg_dump has already refused, so it cannot stop a copy that would have worked.
The transcript is still there under -v on every path, including the ones where a code has replaced the headline.
AF-DB-007 was in the catalog already, marked as planned, and nothing returned it. Its message described installing the extension on the target, which is not something the docker provider can be asked to do, so it now says what the two real options are.
The variable naming production was read from the shell and nowhere else, and af start called that fine.
af start called that fine.The secrets guide opens with source_url_env: PRODUCTION_DATABASE_URL as its example and then lists the four places a value is looked up: this shell, .env, the encrypted local store, and the system keyring. This one variable read the first and none of the others. A project that put the production URL in .env, beside the STRIPE_SECRET_KEY that af up finds there, was told the variable held nothing. The two places a production credential actually belongs, the encrypted store and the keyring, were unreachable for it, so af secret set PRODUCTION_DATABASE_URL stored a value nothing would read.
It now goes through the same chain as every other name in the manifest, built by the same constructor, so a command that says where a value will come from cannot describe a different chain than the one that fetches it. The order is unchanged and an export still beats a file.
The other half is af start. It reported
ok the database source docker, so it comes from the daemon checked above
for a manifest that named production and a machine that did not hold it. That sentence is true about the provider and says nothing about the variable, and the reader was pointed at the next command. It now says which of the four sources answered, or refuses to call the step finished:
ok the database source docker, copying the database named by PRODUCTION_DATABASE_URL, found in .env
fail the database source docker copies the database named by PRODUCTION_DATABASE_URL, and no configured source has it
AF-DB-016 said the variable held nothing "in this shell", which was accurate about the old behaviour and would now be half the answer. It names the searched sources instead.
af up also names the variable itself now. With no golden and a manifest naming production it returned AF-DB-012, "No golden here was made for this project, and 117 were made for something else", which is the right answer when the source is reachable and a refresh has not run, and a count of other people's goldens in front of somebody whose problem is one unset variable. Its next step, af golden refresh, existed only to produce the right message a command later.
the-variable-naming-production-is-looked-up-where-the-docs-say
Issuing a paid enterprise licence was somebody remembering that a Go program exists, and the addresses a blocked customer was told to write to could not receive mail.
tools/licensegen signs the keys ee/engine/license verifies. Searching the tree for it found the tool, one test comment and a line in .gitignore. No workflow ran it, no page described it, and nothing tied a payment to a key. A command line tool run by hand is a legitimate design for something a vendor does a handful of times a year with a key a pipeline must never hold. A command nobody can find is not, so there is now a runbook: the key, the request, the receipt, what the customer sets, how a reissue works, and what withdrawing one actually amounts to.
Writing it against the verifier rather than against the generator turned up three things the generator did not know.
The verifier's Feature type carried a comment saying a closed set means a typo in an issued licence is caught when the licence is parsed. It is not. "features": ["ssoo"] signed cleanly, parsed cleanly, reported the licence active, and permitted nothing, with no error at either end and a customer sitting on the community behaviour they had paid to leave. Watched happening before it was changed. Parse is right to be permissive, because a licence issued for a newer release names features an older binary has never heard of and refusing the whole licence over one would cost the customer the features they did buy. That leaves issue time as the only place the set can be closed, so licensegen issue closes it, and the comment now says where and why. The feature list is a copy, because the tools module is MIT and the package that owns the list is not, and a test parses license.go and fails if the two ever differ.
seats was an int, so a request that omitted it signed an unlimited licence and said nothing. It is a pointer now and an absent field is refused, because zero means unlimited to the verifier and that has to be written rather than defaulted.
-key-id is a label the program cannot check against the key it signs with. Get it wrong and the customer's engine looks the label up, finds a different public key, and reports the licence as tampered with, which the licensing page tells them almost always means a truncated paste. issue now prints the public key belonging to the key that signed, on standard error so a pipe still carries only the token, and the runbook says to compare it before sending anything.
Then the addresses. antifailure.dev publishes no MX record and its SPF policy is v=spf1 -all, so every address on it is a promise nobody can keep. Eight shipped. AF-EE-004 told a customer who had just hit their seat limit to email licensing@, in the catalogue, in the generated Go, in errors.v1.json, on the errors page, on the licensing page and in the control plane's seat refusal. The enterprise licence text gave the same address for any question, and SECURITY.md gave security@ to a researcher holding a finding, next to a paragraph about nobody being on call for the mailbox. Each now names a route the contact page already settled and that resolves today: GitHub private vulnerability reporting, which is confirmed enabled on the repository, for a security finding, and the contact page for anything commercial.
tools/claimcheck could not have caught any of it, for two reasons worth separating. Five of the eight occurrences were outside every tree it read, and two of the file types were outside its extension list. And every rule it has is settled by a string in a repository file, while whether a domain accepts mail is settled by DNS, which a hermetic build gate cannot ask. The contact page changed that by stating it in the tree, which is exactly the anchor the premise mechanism wants. So the rule exists now, over the error catalogue and the enterprise licence code as well as the site, and it was watched failing on the AF-EE-004 line and watched failing again when the premise was taken away.
Three things the runbook has to say plainly rather than describe. No release builds the enterprise binary or stamps a public key into it, so a signed licence verifies only where AF_LICENSE_PUBLIC_KEYS supplies the key. Nothing records what was issued to whom. And Verifier.Revoke has no caller outside its own test and no list is ever loaded, so the revoked state cannot be reached by any shipped binary and withdrawing a licence means expiry, a key rotation that invalidates every other licence on that key, or asking.
Four published claims did not match the code, and all four were true when they were written.
fixed
The terms page said "Sign-in is for the waitlist. There is no public production control plane yet", and the privacy notice said "Sign-in today is for the waitlist". Installing the GitHub App creates an organization: the installation webhook inserts one and consults no allowlist, because an installation is the moment a tenant begins and there is no earlier point at which to ask somebody to sign up. The row lands on the plan the schema gives a new one.
Both pages now say what happens instead, with the limit that makes it accurate: nothing can be run in that organization until somebody signs in, because creating an environment requires an actor. So an organization can exist for an account nobody admitted, and it can do nothing.
legal-facts gained three assertions keyed on the mechanism rather than on the sentence, so the combination that was published fails: a page denying a public control plane while the webhook still creates organizations. The two guards the corrected wording leans on are held as well, because the sentence is only true while creating an environment requires an actor.
fixed
The CI step named "The rules classify every column" claimed af mask plan refuses a column no rule names. It does not, and never did: the command exits 0 for an unclassified column of any type, because every refusal keys on problems and a column with no transform cannot become one. The step is worth running and the comment now says what it really catches, which is a plan that cannot be built or cannot be run. Corrected in both workflows that carried it.
GitHub reported this repository's license as NOASSERTION.
NOASSERTION.No license in the sidebar, absent from the license filter, and to anyone scanning it, all rights reserved. For an MIT licensed developer tool that is a real loss, and it had been true for as long as the file existed.
The cause was a correct paragraph in the wrong place. LICENSE held the MIT text followed by a note that ee/ is separately licensed. GitHub identifies a license with Licensee, which normalizes the file and compares it against known texts, and appended prose is folded into that comparison, so the extra paragraph dropped the match below the threshold.
LICENSE is now the unmodified MIT text and detects as MIT. The carve out moved to LICENSING.md, which states it in full and explains why it is not in LICENSE. Nothing about the boundary weakened: it is also stated in ee/LICENSE.md, in ee/README.md, in ADR 0002, and now in a header on every source file under ee/ rather than on 55 of 76 of them.
Licensee ignores HTML comments, so wrapping the old paragraph in one would have restored detection with the text still in LICENSE. That was rejected. The tools it would hide the carve out from are the same ones companies run before adopting something, so a scan would have reported the whole repository as MIT and missed the enterprise restriction entirely.
just licensecheck now holds both halves: LICENSE is the MIT text with nothing appended, and the carve out is still stated outside it and in every ee/ source file. Checking only the first would be satisfied by deleting the carve out, which is the worse of the two failures.
security
1 entries
A defect with a consequence for the safety of your data or your build.
The trust boundary page now says that masking and the scan that checks it are one instrument, not two, and what that means for the data a check comment can carry.
Both decide what to look at from information_schema.columns.data_type and both accept the same six values. A column outside that list is not emptied by the fail-closed default and is not read by the verification scan. It is copied. citext, which is the ordinary Postgres type for an email address or a username, reports as USER-DEFINED and is outside the list.
Since #148 the plan does at least name such a column, and says that nothing decided what happens to it and that the scan does not read its type. The page says that too, because a gap that announces itself is a different thing from a silent one, and neither is a gap that has been closed.
That compounds with the one path on which records already crossed the boundary. An invariant that does not hold carries up to five rows into the check comment, and a column the masking default could not see is copied into the branch unchanged, so such a row can carry a real production value.
The page said the scan covers every column. It does not, and the sentence is corrected rather than softened. It also no longer describes the scan as verifying the masking, because a check that shares its subject's blind spot verifies nothing about that blind spot.
Nothing in the masking behaviour changes here. Widening the list is not additive: a column copied today would start being emptied, which changes rules_digest and invalidates every existing golden, so it is a decision with a migration attached rather than a patch. The page says that too.
v1.0.0
Released · 220 entries · landed 26 August to 2 September 2026
added
59 entries
Something the product could not do before.
Capacity can be sold to one customer without moving their plan.
PLAN_QUOTAS and PLAN_COST_CAPS are the price list and they are right: three plans, fixed numbers, enforced. What they could not express is the customer on team who was sold forty environments, the design partner using something nobody else has, and the trial extended by a week because somebody asked on a Friday. Every one of those is ordinary commercial reality, and doing any of them by moving the plan charges the wrong amount.
An override is now a row: which limit, at which of four scopes, to what value, WHY, who granted it, and when it stops. The expiry is not decoration. A grant with no end date is how a one-week trial extension becomes permanent revenue leakage nobody can find, so forever is something a person has to choose rather than something they get by leaving a field blank.
The part that makes it real rather than a table: the three places that can refuse a request now ask it. The environment quota and both cost caps on af up read the resolved value, and there is a seat limit on invitations that did not exist before. Every entry in the catalogue names the call site that reads it and a test greps for it, so an entitlement cannot claim to be enforced and not be; four entries name nothing and say why in prose instead.
The Plan screen shows the value that applies, the plan's own value struck through beside it, an Override badge, the reason somebody typed, who set it and when it ends. Before this, moving the quota under the screen would have left it reporting twenty five while af up refused at forty, which is worse than reporting nothing: somebody acts on it.
A feature can be turned off for everybody, or for one customer, without a deploy.
Feature flags, targeted by user, organization, project, repository, plan or environment, with a percentage rollout and a kill switch. Checkout and every administrative money write are behind one.
Two decisions worth knowing about. A DENY target beats an ON flag, because taking one customer out of a feature that is working for everybody else is the common incident and turning the whole flag off punishes the rest of them. And killing a flag is recorded as a different event from turning it off: the same change, completely different reasons, and the one worth finding six months later is the incident, so a kill demands a reason and stamps who and when.
A kill switch and a rollout default in opposite directions, deliberately. An unknown flag is OFF for a rollout, so a mistyped key leaves an unreleased feature unreleased. An unknown flag is NOT KILLED for a kill switch, because a control plane that has never had that row would otherwise refuse every checkout on every self-hosted installation, none of which has any flag rows at all.
The operator screen says whether anything actually READS each flag. Finding out that a switch has no call site by flipping it during an incident and watching nothing happen is the worst possible moment to learn it.
Operators can move money, and the same button pressed twice moves it once.
Refund, credit, plan change, trial extension, cancel, reactivate, discount, retry payment and resend invoice, on the operator portal, each with the reason recorded beside it.
A refund button that does not refund is a support failure. A refund button that refunds twice is somebody's money, gone, and an apology that does not fix it. Between a double click, a client retry, a load balancer replaying a request and an operator pressing the same button in two tabs, twice is not an edge case.
So every write is claimed in a ledger whose primary key IS the idempotency key, before anything is sent, and the same string goes to the provider in its Idempotency-Key header. The ledger closes the window between the two presses; the header closes the one the ledger cannot, where a process claimed the key, called the provider and died before recording the answer. A key reused with different parameters is refused before anything is sent, rather than answered with the first attempt's result, because reporting success for a refund that never happened is worse than either refunding twice or failing.
The distinction that took two attempts to get right: a provider that REFUSED made a decision, so a deliberate retry is a new request and gets a key of its own. Reusing it there leaves a declined payment un-retryable forever, because the provider replays its own refusal at a customer who has since fixed their card. A request that got NO answer may have been executed with the response lost, so its only safe retry carries the same key. There is no default that is right for both.
A refund larger than what is left on a charge is refused before it is sent, with both amounts and their currency named, so an operator who mistyped has not consumed a key or left a refused refund in the provider's dashboard.
Every one of these is recorded twice: once in the platform's own chain, and once in that customer's audit log, in the same transaction. A record only the vendor can read is a vendor's private note rather than accountability.
An operator portal, on its own mount point, behind a credential rather than behind a claim.
Running a hosted control plane means somebody eventually has to look at a tenant they do not belong to: to answer a support question, to read an audit trail, to suspend an account that is abusing the service. Until now there was no way to do that which was not either a database console or a tenant login borrowed from a customer, and neither of those leaves a record anybody can read afterwards.
The boundary is a separate Postgres role with BYPASSRLS, reached through its own pool, and that is the point rather than an implementation detail. A current_setting predicate is a claim the application makes about itself and is only as good as the code that sets it; a role the server authenticates is a credential, and code that has not been handed it cannot widen its own reach by being wrong. The two are independent mechanisms and this surface uses the second.
Every operator action writes to an audit chain of its own, separate from the tenant audit log, so a tenant cannot see operator activity and an operator cannot quietly edit the record of what they did.
Operator administration refuses to act on the caller. admin.operators.write is held by super_admin as well as by owner, so the dangerous move on this surface is not an operator abusing a customer, it is an operator widening their own privileges: a super_admin could otherwise have set their own role to owner and picked up every owner only permission with it. setRole and suspend refuse when the target is the caller, and the refusal is on self rather than on a list of forbidden roles, because "somebody else decides" needs no list to maintain.
Creating an operator mints no credential. The row lands with a null password hash, so the account exists and cannot be signed into until somebody provisions one out of band.
Five surfaces are here: tenants, users, sessions, operators, and a reader over the operator audit trail. The portal is the mount point and the boundary as much as it is those five: the other operator lanes hang their sub routers off the same object, so there is one place an operator route can exist and one matrix test that walks all of them. Impersonation, the support console, and search have their schema and their permission names and no routes yet.
The four /v1/oidc/bindings routes are in the published OpenAPI document.
/v1/oidc/bindings routes are in the published OpenAPI document.They were served and undocumented. docs/guides/github.md hands a customer a curl line for them, and there is no af command in front of them, so the HTTP call is the surface. A surface a customer is told to call and cannot look up is undocumented rather than internal.
Four rather than three, which is the count everybody including me kept getting wrong. Revoking has two paths and not one: a repository is owner/name and a slash is a path separator, so a single {binding} segment cannot match one.
The document also gains a third security scheme. These take a CLI token from af login, which is neither the browser session nor an engine token, and declaring one bearer scheme for both would have told a reader they are interchangeable.
Goldens on Postgres 18, and a refusal that names an edit somebody can make.
Postgres 18 has been the current release for a year and the docker provider listed 14 through 17. A project whose production is on 18 had two ways to go, and both were bad.
Setting database.version: 18 was refused:
AF-DB-003 The source database is Postgres 18, and this provider supports 14, 15, 16, 17. Next: Use a provider that supports Postgres 18, or upgrade the source.
No provider in the build supported 18, so the first half of that sentence named nothing, and the second half is the wrong direction: a source is not upgraded to reach a version older than the one it is already on.
Leaving it at the default instead copied an 18 source into a 17 golden and said nothing, so the preview ran a Postgres the application does not.
A golden built by the docker provider is the stock postgres image with the data committed into it, so the majors it handles are the ones that image is published for rather than a list this provider keeps. 18 is now in it, verified by refreshing a golden from a Postgres 18 source holding several schemas, an enum, a partitioned table, a materialized view, a generated column and a row level security policy, then branching it and reading the rows back out of the branch.
A major the provider really cannot build now says which key to edit and what it may hold. The other providers are unchanged: they talk to a service and the service decides.
A booking calendar on the contact page.
Every route the contact page offered went to a public tracker or to a list. A private vulnerability report, an issue, a discussion, and a waitlist address are the right destinations for almost every technical question, and none of them is a way to speak to somebody about an evaluation or a design partnership. The domain has no mail exchanger, so there was no address to fall back to either.
The page now opens with a calendar. It shows real openings, and the times come from the calendar rather than from a form that promises a reply. The pricing page's two commercial buttons, on Team and on Growth and Enterprise, now lead to it: both previously pointed at the waitlist, so the highest value action on the site collected an email address.
The embed loads only when the section is close to the viewport, so a widget most visitors scroll past does not cost the page its first paint, and it ships in one route's bundle rather than the shared one. When the frame does not load, which is what happens to anyone blocking third party frames, the card carries the address in full and a button that opens it, because an empty rectangle where a calendar should be is worse than no calendar at all.
The terms of use now carry the three sections they were missing: what the software is allowed to touch, a warranty disclaimer, and the shape of a limitation of liability.
Two new pages join them, an acceptable use policy at /acceptable-use and a developer policy at /developer-policy covering the control plane API and the engine's Model Context Protocol surface.
The liability section publishes no cap. The contracting entity, the registered address, the governing law and the figure itself are rendered as visible blanks, because a cap written before a lawyer has chosen the jurisdiction it will be read in is a number rather than a protection. The page says which exclusions no contract can make, and says plainly that whether any of it is enforceable depends on the jurisdiction, on whether the customer is a business or a consumer, and on whether the harm was caused by our own negligence.
What the terms claim about the engine is now held to the engine. legal-facts gained seven assertions: that the customer's source database is opened in a transaction Postgres has marked read only, that teardown refuses a container Antifailure did not label, that NewRuleSet still appends the default masking rules so an unconfigured project is not an unmasked one, and that a golden failing verification is still never published. Each one was checked by breaking the guard on purpose and watching the assertion go red.
The eighth pins the list of column types the verification scan reads. The terms describe that scan as a check that a masking rule missed a column rather than a proof that no personal data survives, and that wording is exact on purpose: the scan's type list and the masking default's type list are the same six entries, so a column outside them is read by neither. Changing either list now sends whoever changed it to the sentence describing it.
No lawyer has read any of it, and every page says so on its face.
A Load area in the console: the workloads you can run against a twin, every version of each, every run, and what each run actually measured.
Four kinds, kept apart because they measure materially different things. An observed mix has no order, a scenario has no browser, a browser workflow has no request rate, and an exploration has no pass. Each says what a result from it is worth: a scenario replays request for request at the same seed, and an observed mix replays only as a shape.
A workload names things rather than containing them. Everything runnable is declared in your own manifest and selected by name, the same way the command line selects one, so a version is a selection plus the knobs its command actually declares. That is a security property and not a simplification: a scenario is checked against your safe route list before anything is sent, and a control plane able to hand an engine an arbitrary journey could send traffic you never allowed.
Every knob lives in the version rather than on the run, so changing the scale writes a new version and comparing scale 1 against scale 4 is comparing two versions. Versions are immutable and every run records which one it used, which is what makes a run from three weeks ago readable. A form offers a knob only when the kind's command has a flag for it, and says why underneath when it does not.
State and verdict are two separate answers and neither implies the other. A run can do all its work cleanly and fail every threshold in it. abandoned is drawn apart from a failure and said in words: the deadline passed with no engine reporting, which is a defect in the plumbing rather than in the change, and what is missing is the report rather than necessarily the work.
There are five verdicts, not four. flaky is one of them, and a run that came back flaky has found something rather than nothing.
Results are the engine's own rather than a summary of them. Latency is the five percentiles it measures and one it did not record is absent rather than drawn at zero. Errors are broken out by reason, because a thousand timeouts and a thousand refused connections are the same number and completely different problems. Routes are compared against production's own p95 and one with no baseline says so rather than reporting no change. The achieved rate is shown against the rate that was asked for, and a run that fell more than a tenth short says outright that the application did not keep up, because every latency figure under it was then measured behind a queue. A browser run shows five outcome counts, and a run that drove workflows and passed, failed and flaked none of them says that nothing was checked.
Evidence says whether it can still be read. A trace written to a path on a CI runner that no longer exists is shown as the record it is, never as a link.
Stopping a run is a durable command a runtime has to confirm, and the console shows where that request got to. A stop nothing acknowledged before its deadline says it was never confirmed rather than showing a cancelled run that may still be going.
Promoting an exploration compiles what an agent found into a workflow for your manifest, and returns two things in full: everything the compilation could not carry, one sentence each, and the block you have to paste into antifailure.yaml before anything can run it. A control plane that cannot put a file in your repository says so.
The area calls workloads.* on the control plane. Those routes land separately; until they do this ships the screen and not the data behind it.
Workload Studio has a backend.
A workload is a named definition of something to run against a preview environment: observed load, an HTTP scenario, a browser workflow, or an exploration. Versions are immutable, runs carry the version they ran, and what a run measured is stored as an aggregate, per route metrics, threshold verdicts and evidence rather than as one blob.
The four kinds stay four kinds. There is no shared intermediate representation behind them, because a weighted traffic mix has no order, a journey has no browser, a workflow has no request rate, and an exploration has no pass. A constraint refuses a result of one kind wearing another kind's columns.
environments.teardown now reaches a runtime. It used to mark a column and carry a comment saying the engine reads the row; nothing reads the row, so the containers stayed up while the console said the environment was gone. It now writes a durable command, dispatches af down through the customer's own workflow, and is acknowledged by the engine's own env.destroyed event. A teardown nothing confirmed says so instead of expiring in silence.
A job in GitHub Actions can now get an engine token from its own workflow identity, so nothing has to be pasted into a repository secret.
Until now, running Antifailure in CI meant creating an engine token by hand and storing it as a secret. That credential is readable by every workflow in the repository, has to exist before anything works at all, and never expires, which makes it the thing most likely to still be valid a year after the person who created it has left. The job now asks GitHub for a workflow identity, posts it to POST /v1/auth/github-oidc, and gets back a token that expires in fifteen minutes and works on POST /v1/events exactly as a static one does.
The part worth reading is what the control plane does NOT conclude from that identity. A verified token says, truthfully and with a signature nobody can forge, that this job runs in repository R. It says nothing about who R belongs to. Anybody with a GitHub account can create a repository, put id-token: write in a workflow, and mint a genuine token naming it. A verifier that read the repository owner and looked up the organization for that owner would have authenticated a stranger perfectly and then authorized them anyway, one workflow file away from writing events into somebody else's tenant.
So the claim is an identity and never a permission. Access comes from a claim on the repository, and three things make that claim mean something: at most one live claim per repository across the whole installation, so a verified identity resolves to exactly one organization or to none; creating one by hand needs the same role as minting an engine token, because it grants a workflow that ability standing; and the organization has to hold a live GitHub App installation on the repository's owner, checked in the application where it can produce a sentence somebody can act on and again in the database where a bug in the application cannot get past it.
Most customers never make that claim themselves. When a repository has no claim and exactly one organization holds a live installation on its owner, the claim is created on the first exchange and recorded as having come from the installation rather than from a person. That installation is GitHub saying the organization controls the account, checked against a signature on delivery, so it is the same evidence a manual claim is measured against with one step fewer. The manual claim remains for a repository the App is not installed on.
Stated plainly, because it narrows the rule above: a repository is refused when it has no claim AND no installation that could stand in for one. A repository whose owner no organization has installed the App on reaches nobody, and one whose owner two organizations have installed on is refused rather than guessed at, because choosing between them would decide a tenancy question by row order. Every repository still gets its own claim; installing broadly does not hand one organization the whole account.
Revoking a claim revokes its live tokens in the same transaction. A revocation that leaves fifteen minutes of working credentials behind has not revoked anything, and fifteen minutes is exactly the window somebody revoking in a hurry cares about.
A run in GitHub Actions reports its events with no token to paste anywhere.
The engine's control plane sink took its credential from AF_CONTROL_PLANE_TOKEN and from nowhere else, and nothing in any workflow this project ships has ever set it. So on a CI runner the sink was never built: the events that say an environment is coming up, is ready, or has been torn down went to the local log and the terminal and no further, and the dashboard stayed empty for every run anybody had.
A job that GitHub will vouch for now proves what it is instead. It asks the runner for the identity GitHub signs for it, trades that with the control plane for a short lived credential, and reports with that. This is the same exchange the report step already performs, so a workflow needs permissions: id-token: write and nothing else: no secret in the repository, nothing to rotate, and nothing for a customer to store.
The credential is short lived, so the engine renews it when the control plane refuses a batch and re-sends that batch rather than losing it. A refusal that renewing cannot fix is attempted at most once a minute, and the events wait on disk rather than being dropped.
AF_CONTROL_PLANE_TOKEN still works and still wins when it is set. A developer's machine and a self hosted engine have no runner to vouch for them, and somebody who has configured a token has said what they want.
Three things a fork or a misconfiguration used to say badly now say what to do. GitHub declines to mint an identity for a pull request from a fork, which is what stops a fork reporting as the repository it forked, and that now reads as the deliberate refusal it is. A control plane too old to offer the exchange says to upgrade it rather than answering "refused". A repository the control plane has not been told about reads as a setup step rather than as an authentication failure, and a rate limit says how long to wait.
Two fixes came with it. The example workflow set AF_CONTROL_PLANE while the engine reads AF_CONTROL_PLANE_URL, so a self hosted installation would have reported its events to the hosted instance, which is the address the engine falls back to. And an engine token's expires_at was written and never read, so a credential with an expiry authenticated forever; ingestion now enforces it.
reference/api.md is checked against the routes the server registers, in both directions.
reference/api.md is checked against the routes the server registers, in both directions.It was the only reference page in the documentation with no machine coverage, and it is the one that drifted. That is not a coincidence: the command reference, the error reference and the transform reference are all generated or gated, and none of them has ever been wrong about what exists.
Forward: every URL in the documentation that hangs off one of this product's own hosts has to match a route the Hono server registers or a page the console serves. That is what caught /auth/callback in four documents.
Backward: every route the server serves has to be covered by a pattern the API reference names. The page enumerates families rather than routes, /trpc/*, /v1/*, /auth/*, which is the right way to write it, so the check reads it that way and a finding is a family nobody mentioned rather than thirty three lines of noise.
Three families are missing today: /webhooks/, /byok/ and /console/api/, eight routes, including the model proxy that two guides describe in full. That page is another agent's to fix, so those three sit in a register with a second assertion behind them: an entry that stops being missing fails as loudly as a new one appearing. It is a known gap that cannot grow and cannot outlive itself, rather than an exemption that quietly becomes permanent. Both halves were watched failing: emptying the register reports the eight routes, and adding a /byok/* row to the page fails the staleness check until the entry is removed.
Every environment variable the engine names at a user is now checked against the documentation.
af license install tells a paying customer to "Set AF_LICENSE_KEY and AF_ORG where the engine runs", then points them at antifailure.dev/docs/enterprise/licensing, and that page named neither. The product asked for two things and sent the reader to the one page that should have said what they are. af doctor had the same shape: it recommends AF_PORT_RANGE_START to somebody whose ports are busy, and nothing documented it. Both are fixed; this is what stops the next one.
The control plane has had this check since config-docs.test.ts was written, and its page has never drifted. The engine, which is the half a customer runs on their own machine, had nothing.
It parses rather than greps, and that is not fastidiousness. The first version was line oriented and returned a clean zero over AF_PORT_RANGE_START while looking straight at it, because r.Remediation = fmt.Sprintf( and the string that names the variable sit on different lines. A pattern that cannot match looks exactly like a pattern that found nothing. Reading an abstract syntax tree removes the question: a string literal is an argument to a call or it is not, however the source is wrapped. Six shapes are covered and each has a test that watches it fail, including that wrapped one, plus one test in the other direction so a scanner that matched everything would not pass.
Named at a user means printed, or in a Short, Long, Remediation, Example or Next field, or in a flag's usage string, or anywhere in the error catalogue. A variable that is only read is not named at anybody: seven of the engine's variables are spoken and all seven are documented.
tools/docs/variable-exemptions.tsv follows the pattern figure-exemptions.tsv established. A variable may be exempted by a row that states a reason, because an exemption with no argument behind it cannot be told apart from somebody silencing a finding they did not understand, and a row that stops being needed is reported so the file cannot rot. The file is empty today, which is a result rather than an oversight: it exists for the next variable, not for a backlog.
Version 1.0.
The commitment is written down surface by surface in what is stable rather than made as a blanket claim: a manifest declaring version: 1, the commands with their flags and exit codes, the documented --output json fields, the provider interfaces and the error codes. Breaking any of those costs a major version. The page also names what is deliberately not covered, which is the half worth reading before building against something: the Helm chart's values, the Terraform variables, the control plane's internal HTTP API, the event type set, and the lint rule names.
Release notes are written by hand now. CHANGELOG.md holds one section per tag and tools/relnotes emits the section for the tag being published, so the first thing a reader sees is prose about what changed rather than the list of merged pull requests the workflow used to generate.
That command also owns the verification preamble, and that is the part worth knowing about. The release action tries body_path first and falls back to body only when the path cannot be read, so the moment a notes file read successfully the preamble carrying the cosign verify-blob command would have been dropped from every release note, silently, with the step still green. One emitter means there is no second copy to lose.
The gate refuses a section that is empty as well as one that is missing, because a heading with nothing under it reads as finished in a diff. It runs on every pull request, since at tag time the only remedy is deleting a tag people may already have fetched.
tools/tagsync refuses a version pin that names a tag nobody has published. Most version strings are read from a released tree and should name the release being cut, but the Terraform image_tag defaults are not: azurerm_container_app_job.maintenance reads the image with no ignore_changes, unlike the bootstrap job and the application beside it, so an apply from main takes the value and a default naming an unpublished tag fails the apply on the stack that runs the product. Bumping those defaults after the tag is now a gate rather than a sentence somebody has to remember once a release.
https://antifailure.dev/openapi.json and https://antifailure.dev/errors.v1.json are published at the apex, so an agent that guesses at either address finds it.
https://antifailure.dev/openapi.json and https://antifailure.dev/errors.v1.json are published at the apex, so an agent that guesses at either address finds it.The OpenAPI document is a file generated from the router, validated before it can be committed, and pinned to the revision that built it, rather than a request-time proxy of the production control plane. The site deploys on every push to main and the hosted control plane moves on a release promotion, so a proxy would have served the pre-promotion document while the site's own documentation described the new one. A proxy can also only validate what fits in a function: the one written first checked three root keys, and a document whose nested path item was malformed passed and was cached.
The deploy now checks both documents byte for byte against what the run built, because a stale document is a perfectly healthy 200, and fails when the API version the apex publishes differs from the one the control plane serves.
About and Contact, built on the same page components as the other company pages.
Contact names only routes that were checked against the live repository: private vulnerability reporting, the issue chooser, Discussions, and the waitlist. There is no postal address, no telephone number and no email, because the domain has no mail exchanger and its SPF policy authorizes no sender, so an email route on the page would be a channel that silently swallows whatever is sent to it.
af start says where you are on the first run and names the one command that moves you forward.
af start says where you are on the first run and names the one command that moves you forward.The first run is nine commands long and any of them can be interrupted, and until now coming back meant reconstructing the state from memory: af init refuses a repository that already has a manifest, af up on a running environment is a no-op nobody recognises as one, and neither says where you actually are.
It derives every answer from the machine rather than from a record of what it last did, so tearing an environment down by hand or switching branches moves the answer with you. It runs nothing and writes nothing, which is the only way it can be honest: a command that ran the steps would have to report on work it did.
Each step reports one of four states and never collapses one into another: done, not yet, blocked, and not checked. The fourth is the point. Listing goldens takes this branch's lock, so a status command safe to run while af up is in flight cannot ask, and that step says so and names af golden list rather than reporting a golden it never looked for. Exit 0 means every step is either done or not reached yet, which is the normal state of a first run in progress. Exit 3 means something is broken.
A coding agent can now rehearse a change through Antifailure, and cannot make the rehearsal easier.
af mcp serves four tools over the Model Context Protocol: rehearse this branch's pending migrations against a throwaway branch of a sanitized copy of production, inspect what the environment is allowed to reach and what it actually reached, and read or cancel a run that is still going. It is a thin frontend over the same orchestrator af ci and af insights drive, so a tool call and a pull request check cannot disagree about the same change.
The division of authority is a property of the schemas rather than a request. There is no argument on any tool that disables sanitization, widens the egress policy, lowers a threshold, names a database or skips the rehearsal, and a field no schema declares is refused rather than ignored. Thresholds come from the manifest's own policy block. An agent chooses what to test; it does not choose how safely the test runs.
Verdicts are PASS, FAIL and INCONCLUSIVE, and INCONCLUSIVE is never a quieter PASS. A missing golden, an unavailable subsystem, a cancelled run and a server that stopped mid run all report it, because an experiment that did not finish says nothing about the change. Every tool requires project_id: agents usually have one of these servers per repository configured at once, and naming the project turns a misrouted call into a refusal instead of a confident answer about code nobody asked about.
The candidate branch is treated as what it is, which is input written by whoever opened the pull request. Statement text never reaches a result; statements are identified by position and duration, and a name that does not look like a name is replaced rather than repeated, because stripping the line breaks out of an instruction leaves the instruction.
stress_test_pr_branch is not here. It needs a full environment and load cycle, and a tool that appears in the list but cannot run is worse than an absent one, because an agent will call it and spend a cycle finding out.
A database role the admin portal can read every tenant with, and the wall that keeps the application out of it.
Answering "why did this account's run fail" has needed somebody who can look at another organization's rows. Nothing in the schema allowed that, so in practice it would have happened through a shared password at a psql prompt, where nothing is recorded. antifailure_admin is that access made explicit: a separate role with BYPASSRLS. It reads widely and writes narrowly, to exactly the actions the portal offers, and it gets INSERT and SELECT on the audit log and never UPDATE, so an operator cannot rewrite the record of what operators did.
A role rather than a policy because the cost of a policy scales with the number of tables, so it is a boundary somebody eventually forgets to extend, and the forgotten table is silently invisible rather than loudly broken.
It is a role rather than a privilege on purpose. The application cannot be granted its way into it, because reaching it means opening a connection with a password the application process is not given. A test asserts nothing has been granted membership and that SET ROLE into it is refused; granting it to the application breaks that test and the operator-notes isolation test together.
Impersonation is recorded on the session row, where the code that resolves a session on every request cannot miss it, and a check constraint makes the rules structural: the four columns are all or nothing, a blank reason is not a reason, and the row must carry the sequence number of the audit entry that authorised it. An impersonated session that was never audited cannot be represented at all, which is a stronger guarantee than writing the two rows in the right order and trusting nobody reorders them.
Support notes are not tenant data. The application role holds no grant on that table, so an operator's private note about a customer cannot appear in that customer's export or on any page they can open.
added · fixed A hosted workload run now tells the control plane what happened to it.
added
Before this the engine emitted none of workload.started, workload.finished or workload.cancelled and claimed nothing, so a run started from the console was dispatched, appeared, and was recorded as abandoned at its deadline whatever it actually did. The control plane's side of that wire was already built and tested; it was fed by nobody.
af workload run claims the run waiting for its environment, says when it started, says once a minute that it is still going, and reports what it measured. The report is the same document --result writes, so the artifact a job uploads and the numbers a console draws are the same bytes, and a report that cannot be delivered is spooled to disk rather than dropped.
The engine asks for the run rather than being told which it is, because a workflow_dispatch carries only the inputs the workflow declares, GitHub reads that declaration from your default branch, and it refuses an undeclared input with a 422 that is indistinguishable from the file being missing. --run-id still names a run by hand, and claims nothing, so reproducing a hosted run on a laptop cannot take the next queued run away from CI.
A cancel pressed in the console now reaches a run that is already going, and stops it. It arrives on the heartbeat the engine is already making rather than on a poll of its own, so there is no command client in the engine at all. So does a lease taken by another engine, which is what happens after a run has gone quiet long enough for somebody else to pick it up. In that one case the engine stops and then says nothing more, because the control plane's terminal statement is gated on the run's state rather than on who holds it: a report from an engine that has lost the run would end it for whoever now has it, and their measurements would arrive against a finished row and be refused. The result document is still written and uploaded, so nothing is lost on the machine that did the work.
A test reads the control plane's report decoder and checks that every field it reaches for inside the run's aggregate is a field the engine's own struct tags emit. Two green suites had never put a real message on the wire between them, and running one against the other found that the decoder was reading the shape of the engine's internal load result rather than the result document: it would have recorded a run that sent twelve hundred requests as having sent none, with every percentile null and every route beside it decoding perfectly.
fixed
A hosted run that did not pass now says why on the line a person reads first. A load run that breached a threshold reported a fail verdict and an empty detail, so the reason lived only in the threshold rows and the line a console leads with was blank. It now names the breach in the units the manifest declares it in, and says how many more there were. The scenario path beside it had always named its failing scenario, which is why nobody saw this: both read correctly on their own.
A hosted exploration whose goal could not be reached used to FAIL THE JOB. docs/concepts/exploration promises that an exploration cannot fail your build, and that promise held for af explore and broke for af workload run --kind exploration, which is the path the console drives. An unreached goal is now a pass and the unreached goal travels in the detail, where it is a finding. Nothing ran at all is still blocked and still exits non-zero, which is the half of the original reasoning that had to survive: a run that measured nothing must not look like a run that found nothing.
Two siblings had the same gap. An exploration that missed a goal said nothing, on the reasoning that an exploration can never fail so the blank never lands under a red verdict; it lands under unverified, which is not a pass, and the goal that was missed is the one thing worth saying. And a failing scenario or workflow whose own reason was empty rendered as a name, a colon and nothing, which is a worse absence than an empty one because it reads as a sentence that was cut off. Both closed.
The control plane answers every event it stored and could not apply with a sentence saying why, and the engine threw all of them away. The wire type had no field for the note at all, so it was dropped by the decoder, and the only caller of Send discarded the whole result, so the rejections were dropped too. That is the one channel that explains why a run reported and the console still shows nothing, written at one end and discarded at the other. Those sentences now reach the job log, bounded, with duplicates left out because a duplicate is the idempotency key working rather than a fault.
examples/github-workflow.yml never set AF_CONTROL_PLANE_TOKEN, so a repository following the documented workflow sent no engine events at all. Not the workload ones, and not the environment lifecycle either: the console's environment list was fed by nothing on every such repository. The example sets it and the GitHub guide says what happens without it.
af workload runs a hosted workload definition through the command that names it.
af workload runs a hosted workload definition through the command that names it.Four kinds, kept apart because they measure materially different things: an observed_load mix through af load run, an http_scenario through af load scenario, a browser_workflow through af test, and an exploration through af explore. Each executes the same orchestrator call the plain command already makes, so nothing about those commands changes.
Every result carries the plain command that reproduces it, and a knob the plain command has no flag for is refused rather than dropped: a definition setting concurrency on an observed_load fails before anything is sent, because af load run has no such flag and honouring it would be a promise the run cannot keep.
A run that measured nothing gets its own exit code, separate from the one a real failure gets, so a job gating on the exit code can tell "the tests passed" from "nothing was tested".
af workload teardown removes an environment and says what was actually removed and what is still standing. af workload promote compiles an exploration into a workflow definition and lists, one line each, what the compilation could not carry over. af workload compare differences two results of the same kind and states what it cannot control.
A status page that reads like a document, and a Subscribe control that is real.
It watched one target, staging, which is the one environment no customer touches, and rendered it as a run of bars with no components, no uptime, no incidents and no notion of not knowing. It now watches seven components across production, the public site and staging, each one a separate line because each one has its own way of failing while its neighbours are fine: the console is a static export inside the control plane's own image and answers 503 when that directory is empty, the installer is placed by the site assembly and has been missing from a publish, and the waitlist API is a managed function that can be present and refuse every request, as it did for two days behind a green deploy each time. Every static check now asserts a marker in the body as well as the 200, since each of those failures would have read as healthy to a check that looked only at the status line.
The page itself is plain on purpose: an incident banner when there is one, then a component per row with its status in a word and its last ninety days, then the response times behind those checks, then the incident history day by day. No card inside a card, nothing that animates, and no font, stylesheet, script or image request leaving the document, because the one moment it has to render correctly is the moment something else is down.
Every figure is computed from the record. Percentages are described as the share of checks that passed rather than as uptime, a ninety day figure is only called that once the record reaches back ninety days, nothing rounds up, a day with no readings is drawn in the neutral and never counted as a day that was up, and an isolated reading is drawn as a dot rather than joined by a line to one hours away.
State never reaches a reader as colour alone. Amber and red are 0.7 apart in OKLab under deuteranopia and green and red are 4.0 apart, so every component states its status in a word, a day containing a failure is capped in near black and sized by the share that failed, and the neutral is achromatic.
Subscribe is an Atom feed generated from the same data, carrying one entry per incident update and one per run of failed checks the probe detected. A button that did nothing would have been worse than no button.
added · changed A published changelog at https://antifailure.dev/changelog, built from the entries in .changes/ that this repository has been writing since its first week and that nothing had ever rendered.
.changes/ that this repository has been writing since its first week and that nothing had ever rendered.added
There were 125 of them, written by whoever made each change, and the only thing in the whole tree that had ever opened one was a gate that checks documentation paths.
91 are public. The 34 marked internal stay in the repository and are never published: they are real changes with nothing a user of Antifailure could observe, and a changelog full of them teaches a reader that the changelog is not about them.
A date is the day an entry landed on the main branch, read from the commit that brought it there rather than typed. Nothing is backfilled. v0.1.0 and v0.1.1 carry no entries, because both were cut before the convention began and neither tag's tree contains the directory at all; the page says that rather than filling them in with work that plausibly shipped in them.
changed
A change to anything a user can see is now refused by CI unless it says what changed. just changecheck is the gate and it runs the same range CI runs, so the answer arrives locally rather than twenty minutes later. It asks whether anything a user could notice changed, not whether anything changed: a test, a fixture, a documentation page, a workflow, a lock file and a generated file all pass in silence. For the genuine exception, a Changelog-None: trailer carrying a reason exempts the change and leaves the reason in the history.
CONTRIBUTING.md has promised that gate since the first week and there was none. The Developer Certificate of Origin rule in the same document went the same way: unenforced, and 65 of the first 80 commits had no trailer by the time anybody counted.
An organization can now be run without emailing anybody.
Settings holds the display name, the billing contact that decides where invoices go, every live session with a way to sign any of them out, a complete copy of everything this control plane holds, and the way to delete the organization. Members gains invitations, so a finance person or a contractor who is not in your GitHub organization can join through a link, and a way to remove somebody outright rather than waiting for a GitHub membership to change.
Deleting an organization is a state machine rather than a cascade, and it runs in this order: what is running is torn down and nothing new can be started, the Stripe subscription is cancelled at the end of the period you have paid for, nothing else happens until that period ends, credentials and the GitHub App installation are revoked, a complete export is produced and you are given a link to it, and only then is anything deleted. Every step is recorded as it happens, so a deletion that is interrupted picks up where it stopped rather than starting again, and it can be called off at any point before it finishes.
Closing your own account erases your name, address, identity and avatar, removes your memberships and signs you out everywhere. It is called closing rather than deleting because the audit log keeps what you did under the name you had at the time: the log is a hash chain, so an entry cannot be rewritten, and those entries go when the organization does.
Five new permissions: organization.settings, sessions.manage and data.export for an admin, organization.delete for an owner alone, and account.close for every role, because leaving is about you rather than about the organization.
af ci --report-json <path> writes the same run as JSON, for something that has to act on the report rather than display it.
af ci --report-json <path> writes the same run as JSON, for something that has to act on the report rather than display it.--report stays Markdown for a person.
-o json is not that flag. It is the whole terminal's format, so a continuous integration step that shows progress to somebody watching the job and also captures a machine readable result would have to give up one or the other, and redirecting stdout to a file gives up the progress.
The worked example for af ci wrote its Markdown report to a file called report.json, which read as though --report produced JSON. It now names both flags and both file types.
Antifailure now runs on a pull request and reports there itself.
One check run per commit, named Antifailure, so a branch protection rule can require it. The name is stable on purpose: changing it would silently un-require the check on every repository that named it.
Seven states, and blocked and unverified are not passes. GitHub's neutral conclusion reads as "nothing to say" and lets a required check pass, so a pull request whose agents never ran would merge behind a green tick. That is the defect this whole change is downstream of: af ci exits zero on a run that verified nothing, so a green job means the job exited rather than that anything was checked. A run that finishes without reporting is unverified, a run that never reports at all is timed_out, and neither merges.
One comment per pull request, edited in place, and its first line carries the commit it is about. Somebody pushes while a check is running, the first run is cancelled, the cancellation finishes after the second run started: without that fence the comment reports a commit that is no longer the head and nothing says so. A stale result the reader cannot detect is worse than no result. A run whose commit is no longer the head updates its own check, which is correct, and leaves the comment alone.
No repository secret to paste. The job proves who it is with a GitHub Actions workflow identity token and exchanges it for a credential scoped to one commit and one run, expiring within the hour. GitHub does not grant that identity to a pull request job running on a fork, so the fork case is closed by GitHub's own rules rather than by this remembering to check, and it is closed a second time here: a fork's commit gets no credential until a maintainer adds the antifailure:allow label, and the approval covers that exact commit. The next push withdraws it, because a maintainer approved code they read.
af ci --report-json writes the same report as JSON, which is what the job posts.
The whole surface is fenced against the orderings GitHub does not promise: the run event arriving before the pull request event, a job reporting before its check exists, a push during a run, a close during a run, a reopen during a teardown, and the same delivery twice.
added · fixed A hosted control plane can require a plan before it does any work.
added
AF_HOSTED_REQUIRED_PLAN=enterprise refuses every operational procedure until Stripe grants that plan, while leaving authentication, sign-out and the exits reachable. It is unset everywhere except Antifailure's own hosted service, so self-hosting is unchanged. Setting it while billing is off stops the process at startup, since that combination refuses every request and offers no way to pay.
The exits are the part worth stating plainly. A plan gate may restrict what the product DOES for a customer. It may never restrict a customer's ability to leave, to retrieve what is theirs, or to secure their account. So a lapsed plan still permits billing, exporting the organization's data, deleting the organization, closing an account, and listing and revoking sessions. That last one is a security action rather than a convenience: a credential can leak while a subscription has lapsed, and a paywall in front of session revocation would leave somebody unable to contain it.
The gate is enforced in shared tRPC middleware rather than per page, and on the three entrances that do not pass through it: engine ingestion, engine environment reads, and the provider and model proxy calls a terminal makes. billing.set, which exists so a self-hoster can change their own quota, is refused wherever Stripe or the gate is configured, because on a paying deployment it is a caller writing their own entitlement.
The Plan page now calls the subscription routes that already existed, and draws the states nobody builds: never subscribed, subscribed, no invoices yet, a failed call, a cancellation already scheduled, a refresh in flight, and a member who may look but not buy. AF_GITHUB_APP_INSTALL_URL gives a signed-in person with no organization the two actions that resolve it themselves rather than a sentence telling them to wait for somebody else.
fixed
Three ways an organization could exist that nobody could ever enter, each of them rendering the empty state that means "nobody has installed the App" to somebody whose App is installed.
Signing in and installing the App are two events with no guaranteed order, and only one order worked. Sign-in reads the installation table on its way through, so installing first was fine; signing in first arrived after the only writer of membership had already run, and nothing reconsidered it. The flow the product recommends produces exactly that order. The installation delivery now adopts its own sender when a user row for that GitHub id already exists, through the same membership writer sign-in uses, and rotates the session they are holding so the tab left open on the empty state resolves itself. A session already inside an organization is never moved.
An App installed on a personal account created an organization keyed on the holder's login, and /user/orgs never returns your own account, so sign-in asked about every organization except that one. GitHub also has no membership record to consult for a personal account, so the holder would have arrived as a plain member of their own tenant with nobody holding billing.manage.
/user/orgs was read one page deep, and it defaults to thirty. That list decides which organizations somebody may enter, so truncating it withheld the tenant they came for rather than shortening something they read.
af doctor now counts the environments this machine is still holding and names af env prune when any of them are older than a day.
af doctor now counts the environments this machine is still holding and names af env prune when any of them are older than a day.The command existed and the only thing that named it was af env list, which nobody is pointed at either, so in practice the way to learn that leftovers accumulate was to read the whole command reference. An environment somebody is working in is not reported as a problem; only ones past the cutoff af env prune itself uses.
af token create, af token list and af token rm mint, list and revoke the engine tokens a CI job or a self-hosted engine presents as AF_CONTROL_PLANE_TOKEN.
af token create, af token list and af token rm mint, list and revoke the engine tokens a CI job or a self-hosted engine presents as AF_CONTROL_PLANE_TOKEN.Three places told people to create one in the control plane and nothing anywhere could: the console has no page for them and the API could only list and revoke, so an engine could authenticate against a row that had no producer outside the test fixtures.
af-control-plane-backup create-org creates the first organization on a control plane nobody has installed the GitHub App on yet. A tenant otherwise begins only when an installation arrives, so a self-hoster's every sign-in landed with no organization and nothing in the console could be reached. It creates no account and grants no role: sign in through GitHub, then use break-glass as before.
af model brings your own model key without a control plane.
af model brings your own model key without a control plane.There was one way to give the engine a key, which was to export a variable, and no way at all to find out what it would do with one: nothing said which key was configured, where it came from, or whether it worked, and af doctor said nothing about the model.
af model set stores a key in the system keyring, or in the encrypted local store on a machine without one, and never takes it as an argument. af model show reports the provider, the model, the endpoint, the source and a fingerprint, and never the key. af model test makes one cheap call and tells a revoked key, an exhausted balance, an unknown model name, a rate limit, an outage and an unreachable endpoint apart, because each has a different fix. af doctor now reports the model key, and reports having none as a pass rather than a warning, because running with the deterministic planner is a supported mode.
A key resolves through the same chain and in the same precedence order as every other secret, so an export still beats a stored key, and af model show says when the key you stored is not the one runs will use. It now reaches the two processes that spend it: the runner subprocess, which inherited only the engine's own environment and so could never see a stored key, and the egress sidecar's synth path, which read the environment directly and would have told somebody who had stored a key to set the variable.
af model rm reported a removal that had not happened. Every keyring here returns success for deleting an entry that is not there, so somebody rotating a leaked key was told "Removed the anthropic key from the system keyring" by a keyring that had never held it, and stopped there. It now reads before it deletes and reports only what it can show happened.
A local key and a key capped by af provider on a control plane can both be configured. The local one wins, because nothing routes a run through a control plane unless the base URL says so, which means a monthly cap somebody set is silently not in force. af model show and af doctor now say that, from a local credential read rather than a request, and a control plane gateway is named as one rather than as an anonymous custom endpoint.
Custom endpoints are a first class path for a local model or a gateway, with their own failure advice. A manifest's egress policy does not govern the model call and does not have to name the provider, which is now covered by a test so a refactor cannot quietly turn synth mode off under default: block.
added · fixed Every command shows a worked example.
added
af secret set --help now shows what a real invocation looks like rather than only what its switches are called, and the same examples appear on the generated command reference page.
fixed
An argument typed wrong now says what the command takes and offers one line to copy, instead of cobra's "accepts 2 arg(s), received 1" printed on its own.
A long run now shows how long it has been running and how to stop it.
af up, af ci, af test and everything else that builds an environment draw a status line under the step in progress carrying the elapsed time for the run and for the current step, and a reminder that the first Ctrl-C rolls back rather than abandons.
The line is drawn only where there is a terminal to erase it on. Piped output, a file, and a CI log receive exactly the bytes they received before, because text output stays byte stable for the same input. Nothing spins and nothing loops: the line is rewritten once a second because the number on it changed.
just gate now refuses a node_modules that is not what its lockfile says, through a new tools/installcheck, and every recipe that uses an installed tree repairs a stale one instead of only creating an absent one.
just gate now refuses a node_modules that is not what its lockfile says, through a new tools/installcheck, and every recipe that uses an installed tree repairs a stale one instead of only creating an absent one.The failure it exists for cost most of an evening. A week of www work was verified with www/node_modules holding Next 15.5.23 against a lockfile pinning 16.3.3. Every build, every SEO assertion and a whole prose sweep ran against a different Next major from the one CI uses, and every one of them reported success in good faith. It also explains an inconsistency several people chased separately: next build rewriting www/tsconfig.json is Next 16 behaviour and a stale 15 install does not do it, so the same command dirtied one worktree and not another. It was never flakiness. It was who had last run npm ci. The same trap produced a bogus "Invalid config passed to starlight integration" in docs and an ERR_MODULE_NOT_FOUND that read like somebody else's branch being broken.
It compares rather than installs. npm ci in every recipe that builds would be correct and would make every local run pay for a full reinstall of a tree that is almost always already right. This reads package-lock.json against node_modules/.package-lock.json, which is npm's own record of what it materialised, so it answers in milliseconds with no network and can run at the front of just gate and inside recipes that install nothing.
Four situations, four answers. A drifted tree fails, because everything checked against it answered about the wrong versions. A half installed tree fails for the same reason, and so does one installed before the workspace it links into. A workspace with no node_modules is reported and does not fail the gate, because it cannot have answered anything and every recipe now installs what it uses; the recipes themselves do treat it as a reason to install.
Workspaces are found rather than listed. There are eight lockfiles here and the two places that named them by hand each named a different subset: just deps installed two of the eight, so a fresh clone left six uninstalled and said nothing. It now installs all of them, and derives the ORDER from the lockfiles: a workspace that resolves dependencies out of another with file: links goes last. ee/web is the only one today. npm ci there before web exists succeeds and leaves a tree that does not work, and npm run typecheck then reports five implicit-any errors inside web/packages/db/src/schema.ts, in a file nobody touched, on a branch that is fine. That is now a fourth thing the guard reports.
added · fixed The control plane can take money.
added
Stripe customers, subscriptions, invoices and payment method metadata, a checkout session, the customer portal, and the webhook that moves organizations.plan, which is what the quota enforcement in PLAN_QUOTAS and checkQuota has always been pointed at and never been able to reach. Billing is off unless AF_STRIPE_SECRET_KEY, AF_STRIPE_WEBHOOK_SECRET and both price variables are set; a partly configured one is reported as off with the missing names, because the one an operator misses is usually the webhook secret and it fails only when a real customer pays.
Every billing table has row-level security enabled and forced. A Stripe delivery has no tenant, so it declares the customer its verified payload named and the policies tie the row it writes to the organization that already owns that customer. Signatures are checked over the raw body, timestamp included, before anything is parsed.
The whole integration is built and tested against the engine's own Stripe mock pack, offline, with no Stripe account and no network.
fixed
Five defects in the shipped Stripe mock pack, every one of them found by building a real integration against it and invisible from the pack file.
A checkout session's url named a different session from the session's own id, so an application that redirected a browser to it and read the session back was told no such session exists; a payment intent's client_secret named an intent that had never been created. Numeric fields came back as JSON strings, next to a current_period_end that was a real number in the same object, so a typed client rejected a response that a curl and a grep called fine. Cancelling a subscription replaced the stored object with the five fields the cancel route names, losing the customer, the period and the items. A subscription's items were always empty, so the one question a billing integration asks a subscription, which plan is this, had no answer. And there was no route for POST /v1/subscriptions/{id} at all, so a pack that says it runs a billing flow could not run a plan change.
The engine's webhook simulator gave every event signed in the same second the same event id. A Stripe webhook handler must be idempotent on the event id because Stripe retries, so a correct handler treated a subscription created and an invoice paid in one second as one event and delivered only the first.
Both are now compared against the control plane's own implementations through checked-in corpora, schemas/mockpack-vectors.json and schemas/webhook-vectors.json, so neither side can drift without a test going red in both languages.
Continuous deployment can reach production.
cd.yml updates the container app, starts the bootstrap job and shifts ingress traffic, and every one of those is a write the deploying identity held no permission for, so the production job would have failed at its first call. The grant is an azurerm_role_assignment in the stack rather than a command somebody ran once: Contributor on the production resource group and nothing else, reviewable in the same place as everything else about the environment, and present in state so a rebuild does not silently leave it behind.
added · fixed Sign-in and webhook deliveries work on the hosted control plane.
added
github_app_id is set in production.tfvars, so the stack reads the App's private key and webhook secret from Key Vault and hands them to the container app: the sign-in redirect now carries the real OAuth client id instead of a placeholder, and POST /webhooks/github verifies a signature instead of answering 503 to everything.
fixed
The installation.created delivery GitHub sent when the App was installed had been refused with a 503, because the control plane had no App configured at that moment and GitHub does not retry a webhook. github_installations was therefore empty, which is the state in which everybody who signs in lands with no organization and an empty screen. The delivery was redelivered and accepted, and the standing-up guide now says to check the delivery log rather than assume the install wrote a row.
The hosted console can act, not only report.
An egress rule now waits for approval instead of enforcing the moment a member proposes it, and the console carries the queue and the approve control. Create environment, Run agents and Run load dispatch your own workflow in your own repository, so the work still happens in your CI and your data stays there; they need the GitHub App to have Actions write. Runtimes can be registered, tagged and removed, and the registry now shows what environments are actually running on beside what somebody registered. The plan can be read and set, which is what the quota enforcement has always needed and never had. Setting the plan takes no payment.
Environments now have a lifetime that is enforced.
runtime.ttl had been declared, validated, defaulted and printed by af explain since the manifest existed, and read by nothing: every environment lived until somebody remembered it, holding a database branch, a network and a container per service the whole time.
af env reap removes the environments whose lifetime has ended, and nothing else. The expiry is stamped on the resources when they are created and read back off them, never taken from the manifest the sweep was run with, so a repository with a two hour lifetime cannot remove another project's week long environment on the same machine. An environment whose resources state no lifetime is never removed, because reading "states nothing" as "already over" would turn an upgrade into a machine wipe; use af env prune --older-than for those. An environment something is running against is deferred to the next sweep rather than pulled out from under a running command.
af env extend keeps an environment you are still using. It is bounded by the new runtime.max_ttl, measured from when the environment was created rather than from now, so extending repeatedly cannot walk the limit forward.
runtime.ttl now defaults to 24h rather than 168h. The week was chosen when nothing read the field, so it expired nothing and cost nothing; it is now the default runtime.max_ttl, which means an environment that genuinely needs a week can still have one by asking for it.
The control plane refuses a run that would exceed a per-run or a rolling daily cap on environment-hours, naming the cap, the usage, and that an owner of the organization can change the plan. Reaching a cap refuses the next creation and never removes anything that exists.
See docs/reference/environment-lifetime.
added · changed af change reads the diff of a pull request and says which checks will exercise what it touched.
af change reads the diff of a pull request and says which checks will exercise what it touched.added
Every changed path is classified by a rule that names it, every check is reported as selected or not alongside whether the manifest configures it at all, and the report says what reading a diff cannot see. It opens no environment and touches no database, so it is the one thing the product can say before it has spent anything.
The rule that decides how much to trust it: a path no rule recognises selects every check rather than none, and so does a diff too large to classify and a diff with no files in it. The two mistakes are not symmetric. A path wrongly called documentation skips work nobody finds out about; a path wrongly called unknown costs a run that shows up in the report.
It never grades the change. There is no score and no risk word in the output, because both would be a judgement made from a file listing, and a tool that calls a change safe is making a promise the terms of this product refuse to make. What it produces is one shape of sentence: this file is X, and X is exercised by check Y, and here is the rule that decided.
An added line naming an outbound host is checked against the egress policy using the same code that decides real traffic, so a pull request that starts calling something the manifest does not mention says so before the run rather than after it.
change.rules in the manifest teaches the classifier a layout the built in rules do not predict. A rule says what a path is and never which checks to run, it cannot claim a surface the manifest already derives, and a pattern matching every path is refused, because one would classify everything and the fail safe above would never fire again.
changed
The example GitHub Actions workflow runs af change first and gates af ci on its output, so a pull request that touches nothing any check exercises no longer gets an environment. It checks out with fetch-depth: 0, because a one commit deep clone shares no history with the base branch and there is no merge base to diff against.
added · changed af fidelity takes an inventory of what an environment reproduces and what it does not, one component at a time, across six dimensions: the services, the data, the third party hosts, the personas, the runtime and the traffic.
af fidelity takes an inventory of what an environment reproduces and what it does not, one component at a time, across six dimensions: the services, the data, the third party hosts, the personas, the runtime and the traffic.added
Every line comes from something the engine already knew and was not telling anybody. The runtime says what is running. The database provider says which golden the branch came from and whether its signed attestation still matches its own signature. The branch says how many tables and rows it holds and whether each declared persona actually has a row in it. The manifest says which hosts the policy names, and internal/mockpack says which pack answers for each one in mock mode and whether that pack keeps state.
Nothing is estimated. A component whose state could not be determined is reported as unmeasured, excluded from the headline, and named with the reason, which is the same discipline the insights report applies when it says what it could not read. A dimension the manifest never asked for is excluded whole, because an environment that sends no traffic has not reproduced traffic perfectly. When nothing could be measured there is no score, and that is never rendered as nought percent.
The headline is defined every time it is printed: how many of the measured components are production's own thing rather than a substitution, a refusal or an absence. The per dimension verdict comes first, because the one dimension a change touches is exactly what an average hides.
fidelity.require in the manifest names dimensions that must be fully reproduced. A required dimension that was measured and found wanting exits 6 with AF-FID-001; one that could not be measured exits 1 with AF-FID-002, because a gap in what we can see is not the same result as a fact about the environment, and reporting the first as the second is how a check stops being believed.
changed
internal/mockpack answers two questions it always knew and never exposed: which pack would answer for a host, and whether a pack keeps what was created. The second is the difference between a mock of a provider and a list of canned answers, and the inventory reports them differently rather than calling both "mocked".
The reason a golden could not be identified no longer says "the migrations were not rehearsed" regardless of who asked. Two callers want that fact for different things, and the rehearsal's wording read as nonsense under a database heading in the inventory.
just figurecheck refuses a number on the marketing site that reads as a measurement, a percentage or an "N of M", unless tools/docs/figure-exemptions.tsv says where it came from.
just figurecheck refuses a number on the marketing site that reads as a measurement, a percentage or an "N of M", unless tools/docs/figure-exemptions.tsv says where it came from.A reason is required and an entry that stops being needed fails the gate, so the list cannot rot into permissions nobody remembers granting.
It reads source rather than built HTML, because the defect it was written for was drawn client side: the site rendered an invented fid 87% fidelity score on two product pages, and curl on either one found no "87" anywhere, so every audit over the rendered output came back clean.
load.source: otel reads the traffic mix out of an OpenTelemetry trace export on disk, in OTLP/JSON, either one document or the one per line a collector's file exporter writes.
load.source: otel reads the traffic mix out of an OpenTelemetry trace export on disk, in OTLP/JSON, either one document or the one per line a collector's file exporter writes.Only server spans become traffic, both the current and the pre-1.21 attribute names are read, and because a span carries a duration the shape arrives with production's own p95 for each route in it. That is the baseline p95_increase compares against, and until now nothing could provide one: a combined format log line has no duration, so the threshold was real and unreachable.
af load scenario runs declared journeys.
af load scenario runs declared journeys.A scenario is an ordered list of requests with waits between them, parallel blocks so the second submit arrives while the first is still in flight, and assertions over what came back. Sessions walk it at once and start_after lets one journey burst while another is already running, which is the load that actually breaks things and which a flat mix cannot express. It is deterministic per seed, it answers in the verdicts a run already uses, and every step is checked against safe_routes before anything is sent.
af insights now runs the previous release against the migrated branch and reports whether its workflows still pass, which is the invariant a rolling deploy depends on and the one the rehearsal never checked.
af insights now runs the previous release against the migrated branch and reports whether its workflows still pass, which is the invariant a rolling deploy depends on and the one the rehearsal never checked.A failure is confirmed against a second branch of the same golden with the migrations left off, so a workflow that fails on that release either way is reported as unverified rather than blamed on the change, and anything the check itself could not do is blocked rather than failed. The finding names the object when the previous release's own output supports it: "the previous release still reads customers.email, and this migration dropped customers.email", with the Postgres error and the statement beside it. It exits AF-DB-031 on a proven break.
Configured by insights.rolling_compatibility. when defaults to risky, which runs it only when the migrations take something away, and against defaults to the merge base with the base branch.
added · fixed af ci now runs the Postgres native checks and puts what they found in the pull request comment.
af ci now runs the Postgres native checks and puts what they found in the pull request comment.added
The engine has rehearsed migrations on a throwaway branch of the environment's own golden, sampled pg_locks every 250ms and diffed query plans since phase 3, and none of it reached a pull request: af insights was a separate command somebody ran by hand. A migration that holds an exclusive lock past policy.migration_lock.fail_ms now fails the check and names the table.
added
The Safety Report carries sanitization status and cleanup proof. report.Verification had existed with no producer, so the report said nothing about masking; af ci now reads the environment's own branch back before the workflows run and reports what it covered. Teardown moved ahead of the report, so a run that left a resource behind says so and, by default, does not ship.
fixed
A run that tried to reach a host the manifest does not mention no longer reports pass. The request was always refused, but the attempt reached the comment and changed nothing about the verdict.
A run can now come back warn: a real finding about the change that does not fail the check.
warn: a real finding about the change that does not fail the check.The engine had five verdicts and none of them was the middle one the product has always described, so anything the environment noticed either failed the build or was printed and forgotten. A new policy block in the manifest decides which class of finding warns and which fails, with ignore for the ones a project does not want. blocked keeps its meaning exactly: the runner could not evaluate this, it exits zero, and it never counts against the change.
af oracle runs a change beside the version it replaces.
af oracle runs a change beside the version it replaces.It brings a second environment up from a baseline revision, branches the same golden for both so they start from identical rows, sends both the same requests in the same order, and reports every difference in what came back and in what ended up in the database. Responses and database contents are compared completely; events, outbound effects, traces and query plans are not compared at all, because two comparisons done properly are worth more than six done shallowly.
Values that no two runs can agree on are normalised before they are compared: two timestamps within an hour, two UUIDs, two numbers within a relative tolerance. Sequence identifiers and numeric epochs are compared exactly on purpose, and everything the comparison declined to look at is printed on every run, defaults included.
af explore sends agents at a goal with no declared workflow.
af explore sends agents at a goal with no declared workflow.They read each page through the accessibility tree, choose where to go, and report where the application cost somebody effort without failing: a control that did nothing, a page with nothing left to try, a route that loops back, an interactive element with no accessible name, a step slower than the goal allows, and a goal never reached. Every finding names the page, the control and the step.
Every choice comes from the goal's seed and every duration from the injected clock, so the same seed takes the same path and each result carries the command that replays it. An exploration reports pass and exits zero even when it finds things, because nobody declared what should happen on the pages it wandered onto and a red mark on a pull request that is fine is a check people mute. A run that could not start is blocked, which means nobody looked rather than nothing was found.
af explore --emit-workflow prints the workflows: block that replays what was explored, so a discovery becomes a check that runs on every pull request.
A runbook for every secret in the control plane's Key Vault, at /docs/self-hosting/rotating-secrets/.
/docs/self-hosting/rotating-secrets/.There are eight rather than the six Terraform writes, because the GitHub App's private key and webhook secret live there too and are read rather than managed.
Each says what breaks while the secret is being replaced, and two carry a warning that is not about rehearsal. Rotating the provider key sealing secret destroys every stored provider key and there is no re-sealing tool. Rotating database-url needs an ALTER ROLE that nothing in this repository runs for you: the bootstrap job creates the application role only when it is absent, so changing the vault value alone hands the application a password Postgres has never seen.
engine/cmd/loadcp points engine/internal/load, the traffic generator customer environments use, at the hosted control plane's own API for the first time.
engine/cmd/loadcp points engine/internal/load, the traffic generator customer environments use, at the hosted control plane's own API for the first time.Its bundled profile weights each route by its own declared rate limit from web/apps/api/src/limits.ts, labelled declared_limits rather than production because no real production traffic has been captured yet. See docs/self-hosting/operations#load-testing-the-control-plane-itself for how to run it and what a real local run found: the database connection pool, not the rate limiter, was the first thing to saturate.
A status page for the hosted control plane, checked from GitHub Actions rather than from the control plane itself, so an outage of the control plane cannot also silence the page reporting it.
deploy/status/probe.sh and deploy/status/render.sh do the work; .github/workflows/status.yml runs it every five minutes and publishes to the status-data branch. See docs/self-hosting/status-page for the design and the one manual step (enabling GitHub Pages on that branch) left for a human.
An on-call page (docs/self-hosting/on-call) covering the rotation, what an acknowledgement means, when to wake somebody, and what to do first by class of page, written for a team of one as much as a team of several.
docs/self-hosting/on-call) covering the rotation, what an acknowledgement means, when to wake somebody, and what to do first by class of page, written for a team of one as much as a team of several.The Azure page gains a full manual rollback procedure (docs/self-hosting/azure#upgrade-and-rollback-the-manual-path) for the case deploy.sh's own automatic rollback does not fire: how to find the last good revision, move traffic back to it, verify with health-gate.sh, and reason about a migration that already applied, including the case where it was not backward compatible and a code rollback alone would make things worse.
The first member of an organization becomes its owner when GitHub confirms they administer it.
Every organization here is created by an installation webhook before anybody signs in, and sign-in mapped a GitHub administrator to admin, so no organization had an owner and nothing held billing.manage. GitHub still has to say admin: a first sign-in during an outage is still a member, because guessing upward would hand out a tenant on a timeout.
af-control-plane-backup break-glass sets a role directly in the database, for when the GitHub App is gone and nobody inside the organization can act. It writes a member.break_glass audit entry carrying the reason, it refuses to leave an organization with no owner, it cannot create an account, and it grants no session. --dry-run reports what would change and writes nothing. The runbook has it under "Nobody can sign in".
The hosted control plane can now be alerted on and stood up in production.
Eleven Azure Monitor rules and one action group where there were none: an availability test against /readyz from three locations outside the stack, server errors, restart loops, replicas below minimum, database storage, connections, CPU and reachability, one rule per container app job, and certificate expiry. Each rule names its runbook in the notification it sends, and every runbook is a page under Self-hosting.
production.tfvars sits beside staging.tfvars and explains every value that differs, including the recovery point objective next to the retention that is constantly mistaken for it. app.antifailure.dev is Terraform's now: the DNS records, the managed certificate and the custom domain binding. Standing up production has a page with the eight things Terraform cannot do, in the order that works.
The four legal documents a security review asks for by name are now published on antifailure.dev: a Data Processing Agreement at /dpa, the subprocessor list at /subprocessors, a statement that there is no service level agreement at /sla, and retention and deletion commitments at /data-retention.
/dpa, the subprocessor list at /subprocessors, a statement that there is no service level agreement at /sla, and retention and deletion commitments at /data-retention.All four are linked from the footer.
Every subprocessor was established by reading the code that talks to the vendor: Microsoft and GitHub for every organization, Anthropic and OpenAI only when an organization stores a model provider key, and no payment, email, or analytics vendor at all. Every retention period is one the software already enforces, including the two that are not exact.
The privacy notice claimed the control plane holds billing data, which no payment processor exists to produce, and did not mention the waitlist address it stores or the address and user agent a session row carries. All three are fixed.
No lawyer has read any of it, and every page says so on its face.
added · changed The disaster recovery drill runs weekly against a scratch database in .github/workflows/drill.yml and fails the run when the restore is not one.
.github/workflows/drill.yml and fails the run when the restore is not one.added
af-control-plane-backup had no caller anywhere in this repository until now: the suite exercised the library, and nothing exercised the command an operator is told to type. just drill runs the same thing on a laptop, and the workflow invokes that recipe rather than a second copy of it.
The drill reports the recovery time it measured, writes it to a JSON report and holds it against a budget, exiting 4 when the restore is sound and slow so that a busy runner never reads as a broken backup.
changed
The drill now finishes with the behavioural check the module's header always described: the restored database is asked, through the unprivileged application role, to read another tenant's rows in every tenant table, and it has to refuse. That check lived only in the test suite, so the drill an operator runs compared catalogue text against catalogue text and stopped. It is the only check in the module that is absolute rather than relative to the manifest, and it is the only one that notices a source database whose isolation was already broken before the backup was taken. A drill that cannot attempt it now fails rather than passing quietly.
The operations page records a recovery point objective: five minutes inside the region, a fourteen day recovery window, and up to an hour with geo-redundant backup, which is off by default and can only be turned on when the server is created.
Eleven more DDL lint rules on the migration rehearsal, seventeen in total.
The one that matters most is lock_timeout: a migration that waits for a lock does not merely wait, it queues every subsequent query on that table behind its own lock request, so a four millisecond ALTER TABLE blocked behind one long running transaction stops all writes for as long as that transaction runs. The rule reads current_setting('lock_timeout') from the branch before it fires, so a project that sets the timeout on the role or on the database rather than in the migration file is not told it has none, and where the rehearsal saw the lock the finding carries how long it was really held on a table with production's row counts.
The rest are SET NOT NULL on a column that already exists, a CHECK constraint added without NOT VALID, ADD CONSTRAINT ... UNIQUE building its index in place, a backfill in the same transaction as the schema change it belongs to, and DROP INDEX, REINDEX, VACUUM FULL, CLUSTER, DROP TABLE and TRUNCATE. Each names the lock mode, the real row count of the table on the branch, and the multi deploy sequence that avoids the problem.
The public website lives in www/, the documentation site in docs/, and the waitlist endpoint in api/.
www/, the documentation site in docs/, and the waitlist endpoint in api/.All three are served from antifailure.dev.
changed
16 entries
Behaviour that already existed and now works differently.
changed · fixed The changelog page and the release notes are something you can scan rather than something you have to sit down to.
changed
/changelog rendered every entry open on one list, and v1.0.0 carries a bit over two hundred public entries, so the page stood 136,766 pixels tall at 1440 and 264,772 at 320. That is 152 screens of scrolling on a desktop and 259 on a phone, with one entry fully on screen in the whole of it. Entries are grouped by category now, each is a single line headed by its author's own opening sentence, and opening one is a details element. The same page is 19,635 pixels at 1440 and 26,386 at 320, with eight entries fully on a screen of the list and twenty two screens to the bottom. A release states its size, the days its work landed between, and how many entries of each kind it holds, as four links that scroll to them. The search reads every word of every entry, including the ones that are collapsed, because a collapsed entry is in the page rather than absent from it. With JavaScript off the whole changelog is still there and a details still opens.
The release notes were the entire v1.0.0 section of CHANGELOG.md, 69,932 bytes of it, which is under GitHub's limit and far past what anybody reads. A section can mark part of itself as detail now, between <!-- relnotes:omit --> and <!-- relnotes:end -->, and the published notes carry a link to the changelog where that part stood. The verification commands, what 1.0 promises and what it does not, what pushing the tag moves, how to install it, everything that behaves differently under an existing manifest, and every security entry are all still in the body, which is 23,170 bytes. CHANGELOG.md keeps all of it, because a changelog file is a reference document and people search it.
just relnotes grew with the feature rather than being loosened by it. It refuses an unbalanced marker, a second region in one section, an empty region, and a section that omits every line of itself, and its emptiness check reads what a tag would publish rather than what is in the file.
fixed
Two shapes of markdown reached the changelog page as their own punctuation. Bold around a run containing inline code printed the backticks, and a word between single asterisks printed the asterisks: four backticks and two pairs of asterisks, on a page a prospective customer reads. Bold and italic carry spans now rather than text, so what is inside them is rendered rather than shown.
af token list lets STATE give up width alongside NAME when the terminal is too narrow for the table.
af token list lets STATE give up width alongside NAME when the terminal is too narrow for the table.PREFIX now keeps its full width in every terminal, because it is exactly what af token rm takes as an argument, and a shortened prefix prints something that does not work when it is pasted back. STATE reads revoked 12 Mar or active, so losing the date still leaves the word that decides anything.
The self hosted control plane image now runs on Node 26.
All three stages of deploy/docker/control-plane.Dockerfile move together, the dependency install, the console build and the runtime, so an operator who pulls the new tag gets one runtime rather than a mixture of two.
The three examples move with it, because an example is the first Dockerfile most people copy. The Next.js example builds on node:26-alpine, the Go example on golang:1.27-alpine over alpine:3.24, and the Django example on python:3.14-slim.
One of the two examples that run a migration changes the psql it runs, and it is worth saying because both of their comments used to name the old numbers. The Go example's runtime moves from Alpine 3.20 to 3.24, which carries psql 18 under the unversioned postgresql-client package where 3.20 carried psql 16. A client newer than the server is the direction libpq supports, so that migration behaves as it did.
The Next.js example does not move. Its old base, node:22-alpine, is itself built on Alpine 3.24 and was already giving it psql 18, so only the comment there was out of date.
The site now says one thing about signing in, everywhere.
It had said four. The /signin and /signup browser tabs both read "Join the waitlist" and their descriptions both said there was no hosted control plane, which a crawler, a bookmark and a shared link all carried while the page underneath offered a working GitHub button. The buttons leading there said "Get started", "Sign up", "Log in" and "Join the waitlist" depending on where you found them, and the home page sent more people to an invitation wall than anything else without saying so; only the pricing page disclosed it.
Now every route to that door carries its own name, "Sign in" or "Request access", the titles and descriptions match the page they open, and the home page says the control plane is invitation only and the engine is not.
An unreachable copy of the sign-in modal is also gone. Nothing had ever opened it, and it went on shipping "There is no hosted control plane to sign in to yet" in the bundle long after there was one.
A scenario assertion now reports what it measured as well as what it concluded.
af load scenario -o json carries measure, scope, threshold and observed on every assertion result beside the sentence it already printed. The sentence is for a person; a dashboard cannot chart "served a p95 of 240ms, over 200ms" without parsing English, and cannot tell that measure from another one. An assertion whose requests were never sent reports no observation at all rather than zero, because zero reads as a perfect application and means a question nobody asked.
af workload is hidden from af --help.
af workload is hidden from af --help.The commands a person runs are af load run, af load scenario, af test and af explore; this is what a hosted control plane calls on their behalf, and a top level command introducing a noun that appears nowhere else in the product would be one more word to learn for something nobody types. Its flags are documented under Workloads instead.
The documented safe_routes and unsafe_routes examples now use ** rather than *. A single star covers exactly one path segment, so DELETE /* blocks DELETE /orders and not DELETE /orders/42, and a delete almost always carries an id. Under a safe list permissive across methods, that made the entry somebody copies to stop deletes send the realistic ones and say nothing. The matcher is unchanged, because making a single star span segments would change what every deployed manifest already means.
The dispatch workflow template calls af workload run instead of assembling flags in a shell case statement.
af workload run instead of assembling flags in a shell case statement.The command input now names a workload kind rather than a verb, so the console, the workflow and the engine share one vocabulary; agents and load still resolve, so a copy of the file taken before this keeps working. It gained seed, concurrency and run_id inputs, and it uploads the result document as an artifact.
An input the case statement had no flag for was dropped without a word. It is now refused by name.
The trust band at the foot of the home page was a flat, saturated olive.
It was the only ground on the site that nothing else used, it sat heavier than the black panel below it, and it failed contrast: the grey half of its headline, its two captions and its attribution all measured 4.10:1 on that fill, under the 4.5:1 a reader is owed. The ground is now the same pale sage every other section band uses, which puts those three at 5.10:1, with one soft light high on the right so the band still has depth rather than reading as a flat fill.
The eyebrow that opens each column was drawing its glyph in brand green, which measures 2.10:1 on that ground and effectively was not there. It is a solid triangle in the site's warm accent at 4.36:1, over the 3:1 a meaningful graphic needs, and its label is set in near black rather than grey so the column has something that leads it. That accent is a token now, --color-ochre, rather than a hex repeated per call site.
The two marks beside "Fail closed" and "Customer-hosted" were 36px diagrams that did not resolve into anything at the size they render: an arrow, a green bar and three ghosted lines for one, a square inside a square for the other, at different visual weights. They are pictograms now, one subject each at one stroke weight and one colour, large enough to read without the label under them.
The quote is no longer top aligned against a column of nothing. It sits at the foot of its column, sharing a baseline with the two claims opposite, and the one sentence that says what the product does instead of the four it declines to claim is marked rather than set at the same weight as the refusals.
The manifest reference now says which of github.mode, github.comment, github.fork_policy and github.teardown_on anything reads, which is none of them.
github.mode, github.comment, github.fork_policy and github.teardown_on anything reads, which is none of them.They are validated, defaulted and printed by af explain, and setting fork_policy: always does not make a fork run.
The reason is architectural rather than an oversight and is written down beside the table: the hosted control plane never reads your manifest, because a control plane that read it would have to fetch your repository. What happens instead is in the same section, so nobody has to find out by setting one.
A test fails if one of those fields gains a reader without the table being corrected, and if a field is added to the block without being classified. The direction that matters is the first one: somebody wiring a setting up and leaving a page that says it does nothing is the more dangerous half.
The console was built one page at a time and the seams showed.
Six full-window screens carried four heading sizes and four different primary buttons between them; four radii were in use where there is a scale of three; selects were styled three ways at two heights. That is one vocabulary now, held in console/components/ui.tsx and console/app/globals.css rather than repeated per page.
The tertiary grey the console used for column headings, field hints, card descriptions and placeholder text was 3.2:1 on the page background, so a good deal of its prose sat under the readable threshold. The three greys are now measured against every surface they appear on and the lightest of them is 4.6:1.
Every list was a table that scrolled sideways on a phone, which hid the two columns a reader came for: the environments list showed an id, a repository and half a branch, and put the state and the age behind a horizontal scroll. Below sm those tables stack into one record per row with each field beside its column heading, so nothing is off screen. Controls are 44px with 16px text at that width, which also stops iOS zooming the page when a field takes focus.
The rows in the environments and runs lists opened on click and were invisible to a keyboard: not focusable, Enter did nothing. Their first cell is a real link now. Two screens loaded behind animate-pulse; both use a static placeholder shaped like the content instead. An environment reported as provisioning was toned the same neutral as one torn down, and is now marked as in progress. The provider keys screen showed a refusal on the bare page background, which read as a page that had half rendered, and now frames it like every other answer.
Three things on the marketing site animated forever while the reader did nothing: the caret in the shared typewriter component, a second caret written out again inside the report scene, and a rotating ring beside the word "running" in the IDE panel.
A caret is a block at the end of a line whether or not it blinks, and "running" is the state the ring was repeating, so all three are static now and the keyframes they were the only users of are gone.
Body copy across the marketing pages was set in text-black/45, which is 3.3:1 on the page background. That was 48 places, including the sentence explaining what happens to an address left on the sign-in page. The real page copy is a step darker and now measures above 4.5:1; the light greys inside the drawings of a terminal and an editor are left alone, because there they are depicting a screen rather than addressing the reader.
Four more places were below the threshold and are reader-facing rather than part of a drawing: the two mono kickers that introduce the worked example on the safety report page, the numeral in the homepage table of contents, and the placeholder in the sign-in form. The greys inside the depicted GitHub merge box on the same report page, its timestamps, and the row the safe-state table dims to mean "deleted" are unchanged, because there the grey is drawing a screen rather than talking to a reader.
load.source no longer offers Datadog or New Relic.
load.source no longer offers Datadog or New Relic.They were in the schema, so you could set them, and refused when a run reached them, so they could never work. Anything unrecognised is now refused by name with the sources that do work, at validation time as well as at run time. The arrival rate for an access log is also counted from the log's own timestamps rather than assumed, and when no line carries a readable timestamp the run says the rate was assumed instead of presenting a guess as production's number.
The public site no longer describes five subsystems that have no implementation.
/product/oracle, /product/fidelity and /product/change-intelligence are gone, /product/workload and /product/exploratory-users are now one page about what af load really does, and all six paths 301 to the page that answers what they came for.
The site's verdict words are the engine's: pass or fail, with unverified for a run that could not answer. It used to say "pass, warning, or block", and the engine has no warning state while its blocked means the opposite of what the site's BLOCK claimed. Every panel whose numbers were written rather than measured now says so.
The public website Product pages use the same cream Linear language as the homepage: left-aligned heroes, hairline panels, and a pass, warning, or block gate.
fixed
128 entries
Something that claimed to work and did not.
A provider could not be written outside this repository, which is the one thing providers are for.
provider.Database.ConnString returned secrets.Value from engine/internal/secrets. An implementation has to name the return type, and there is no spelling of that type an outside package is allowed to use: the Go toolchain refuses an import of an internal path from outside the subtree rooted at its parent, so naming it fails one way and importing it fails the other. The interface the release notes call an integration surface compiled here, reviewed as correct, and would have failed on the first line of the first real provider anybody wrote. The 1.0.0 tag would have held it there for the whole of version 1.
The type is now engine/pkg/secret.Value, which is public for exactly this reason. engine/internal/secrets.Value is an alias for it, so nothing inside the module changed, and CI compiles a provider from outside the engine module to prove the promise rather than restate it.
Two carve-outs in the 1.0.0 notes now have mechanisms behind them instead of sentences. tools/surfacecheck refuses a Go package that becomes importable and is classified nowhere, a change to a stable package that version 1 does not allow, and a stable signature naming a type from a package that is not stable. web/apps/api/src/boundary.ts classifies every route the control plane's router serves as published contract or deliberately excluded, and route-boundary.test.ts holds the router's own route table against the published document in both directions. Before that, a route missing from the document could mean nobody outside could call it or that somebody forgot, and four live routes under /v1/oidc/bindings were the second.
A release tag no longer publishes from a commit CI has not passed.
The release workflow triggered on a v* tag and on nothing else, and a tag is one command anybody with push access can run on any commit. Pushed onto a red commit it built four binaries, wrote a checksum file, generated a bill of materials, signed both with cosign and published the lot, green the whole way. The signature was even honest. It says the release workflow in this repository produced those bytes, which was true, and it says nothing about whether the commit worked. Nothing else did either.
A gate job now runs before anything is built and waits for CI's conclusion on the commit the tag names, the same rule cd.yml has applied to deployment since the beginning and with the same budget behind it. Green publishes. Red refuses. Still running waits, because a busy queue is not a broken commit.
Two conclusions that read like a pass and are not: a run GitHub reports as cancelled, which is the same word it uses for a job that hit its own time limit, for a run somebody stopped by hand and for a run a newer push superseded, and a run reported as skipped, which looks in a list exactly like one that passed and means nothing ran. Both refuse. So does a conclusion nobody has invented yet.
If your tag is refused with cancelled, re-run CI on that commit, wait for green, then re-run the release.
The masking default and the verification scan that backstops it were keyed off the same six element list of column types, so they were never two layers.
fixed
A column of any other type was not masked, was not listed among the columns no rule covers, and was not read by the scan. It shipped into every preview environment and the golden's attestation said it had been verified.
The types are not exotic. information_schema reports citext as USER-DEFINED and text[] as ARRAY, checked against a real PostgreSQL 17, and citext is what an application uses for an email column precisely because a person types into it. A column called email was saved by the name based rule, which ignores type. A column called handle on citext was not.
looksSensitive was a known-yes list with no known-no list beside it, so an unrecognised type was silently treated as structural, as though somebody had decided it was safe. There is now a knownStructural list of the types whose text form cannot carry a sentence, and a type in neither list is reported as a question. Nothing new is masked: masking a column that is copied today would change what existing goldens hold and blank a column an environment may need, which is a decision for whoever owns the product rather than a bug fix.
af mask plan also no longer tells you that every column it lists ships unchanged. Some are emptied by the default and some genuinely ship, and a person reading the list could not tell which of their columns had leaked.
fixed
A verification scan that could not read a column reported itself clean. The sentence "a column nobody could read is not a column that passed" was written twice in scan.go and implemented nowhere, so Clean() counted findings and ignored skips, and the golden was published on the strength of it. That was the one way a golden could pass verification without having been verified.
Fixing it exposed a second defect immediately. The refusal path read report.Findings[0] with no length check, which was safe only because every path into it had a finding. A report whose only problem was an unreadable column now reaches that line, so the fix for a silent pass would have shipped as a panic on the golden refresh. Findings are reported first, and a skip now returns AF-MSK-011, which says the column could not be read rather than claiming a detector found something in it.
A shell script that a workflow or a recipe runs by path is now refused unless git records it as executable.
tools/site/check-tls.sh was committed at mode 100644 and both of the places that run it name it as a bare relative path, the certificate step in the deploy workflow and the check-tls recipe, so the kernel refused to exec it and the step died with "Permission denied" and status 126. It had never run anywhere: the only job that calls it fires on a push to main and not on a pull request, so the first push that reached the deploy job was also the first time anybody learned that the certificate check had never checked a certificate.
The new gate reads the mode from the git index rather than from the disk, which is what makes it a gate rather than a check on whoever ran it. A chmod +x without a git update-index --chmod=+x leaves a working tree that runs the script and a commit that does not, so a check of the filesystem answers yes on the machine where the mistake was made and CI, which checks the index out into a fresh tree, answers no.
Two rules, because either alone leaves a hole. Every tracked file named *.sh that opens with a shebang carries the bit, which catches a script committed wrong before anything runs it. And every path that a run: block or a justfile recipe puts in command position, and that names a tracked file, carries the bit, which catches a program whose name does not end in .sh. Both refuse to report green over an empty result, because a pattern that has stopped matching is silent and silence here reads exactly like a clean repository.
Two entries in the v1.0.0 release notes described a repository that does not exist.
tools/relnotes publishes that section verbatim as the GitHub release body, so both would have shipped as the product's own account of itself.
The first told an operator that the documentation runs the control plane image as latest. No page does, and no page can: tools/claimcheck refuses any pin that is not a main-<sha> or cd-<sha> tag, because a tag that names no commit tells the gate nothing it can check without a network. Both self hosting procedures pin main-b53906a. An operator following the sentence would have pulled an image nothing had proved could complete the procedure beside it.
The second said the dispatch workflow template calls af workload run rather than assembling flags in a shell case statement, and that a knob with no flag behind it is now refused by name. True of the workload kinds and not of scenario or explore, which are the two verbs the same entry singles out as needing the new file. examples/github-workflow.yml still answers both with steps of their own, so a duration or a scale sent to either is still dropped in silence. The entry now says which half holds.
Reading the template for the second one turned up a defect of its own, fixed here. examples/github-workflow.yml declared down) twice in one case, and bash takes the first match, so the second arm was unreachable. It carried the comment explaining that teardown exits 10 when something could not be removed and that this has to fail the job, which is a justification attached to an arm that never runs: it tells the next reader a behaviour is handled when nothing there handles it. The reasoning is true of the arm that does run, because af workload teardown returns AF-RUN-030 when resources are still pending and that code carries exit 10, so the comment moves onto the live arm and the dead one is gone.
Neither release note was caught by anything, and the reason is one sentence: CHANGELOG.md is in no gate's document list. tools/claimcheck reads README.md, CONTRIBUTING.md and SECURITY.md for path claims and a fixed set of site trees for sentence claims, and the file the release body is cut from is in neither. Its sentence rules are a hand curated list of claims that had already shipped false, so a novel one has no rule to fire.
tools/site/check-tls.sh was committed without its executable bit, and both the deploy.yml step and the just tls recipe invoke it directly as tools/site/check-tls.sh rather than through bash.
tools/site/check-tls.sh was committed without its executable bit, and both the deploy.yml step and the just tls recipe invoke it directly as tools/site/check-tls.sh rather than through bash.So the certificate check that was added to watch two hostnames could not run at all. It exited 126, "Permission denied", the first time a push to main reached the deploy job.
Every other tracked shell script in the tree was already 100755. This one was 100644 and nothing noticed, because the step that runs it only runs on a deploy and never on a pull request.
The legal pages no longer publish an email address that cannot receive mail.
The data processing addendum and the data retention page both named security@antifailure.dev as the destination for a security report, and both said the address reaches a person who can act on it. The contact page of the same website said the opposite, under the heading "Email is not a contact route", and the contact page was right: the domain publishes no mail exchanger, so mail sent there is delivered nowhere. Both pages now name GitHub private vulnerability reporting, which is the private route that works today.
A refused sign-in now gets a page rather than a raw JSON body in the address bar.
Pressing Get started on the website, then Continue with GitHub, then authorising the OAuth application, ended at {"error":"This installation is not open for sign-ups..."} rendered as plain text, with no heading, no explanation and no link back to the waitlist the visitor had been standing on. It is the most prominent button on the site and it ended in punctuation.
Three things changed.
An installation whose allowlist names nobody is now refused before the browser is sent to GitHub. That answer never depended on who was asking, so asking somebody to authorise an application in order to hear it was asking for something in exchange for nothing. An allowlist that names some accounts still has to be decided at the callback, because the list is keyed on the GitHub login and nothing before the redirect carries it. What happens there instead is that the authorization the refused person just granted is withdrawn again, so being turned away no longer leaves a third party application on their GitHub account.
Every address a person opens directly now answers a browser with a page and every script with the JSON body it already got: sign-in, the GitHub and email callbacks, the deleted-organization export link, and the rate limiter's answer on all of them. The page names what happened in a sentence and, on a deployment that sets AF_SIGNUP_URL, offers the one link that is worth offering.
The refusal is now 403 rather than 400. The request was understood, and there was nothing about it the caller could have changed.
The v1.0.0 release notes said "Nothing else moves on its own.
No deployment is triggered by this tag, and no existing environment is upgraded." Both halves were wrong, and a reader could check them faster than we could.
.github/workflows/cd.yml triggers on push: tags: ['v*'] as well as on a push to main. Its staging job's condition excludes only a manual dispatch aimed at something other than staging, so a tag push satisfies it and staging deploys. Its production job's condition is literally startsWith(github.ref, 'refs/tags/v'), gated behind the production environment and its required reviewers, so production is queued for a human rather than skipped.
So the tag does deploy, it just deploys ours rather than the reader's. That distinction is the thing the section was trying to make and it made it by saying something false instead. The section now says what the tag does to our infrastructure and what it does to the reader's, separately.
The Plan page told an organization that plan changes only change local quotas, on a control plane where the plan could not be changed at all.
It is the sentence a page carries when it was written for one configuration and then a second one appeared under it. The card said "This self-hosted installation does not take payment. Plan changes only change local quotas." whenever Stripe was off, which after the plan grant fix includes every installation that has not set AF_OPERATOR_SETS_PLAN. Two cards below it, the same screen said the plan can only be changed by the operator. Found by rendering the page rather than by reading the code.
The card now says where the plan comes from on this particular installation, in one place, and the table below it goes back to saying only what a plan allows.
The release runbook told maintainers to cut a signed tag, and no signed tag has ever been cut.
Both runbooks said git tag -s. There is no signing key on the machine that cuts releases, no user.signingkey, and no tag.gpgsign, so the command fails where it is not already impossible, and git verify-tag on v0.1.0 or v0.1.1 reports no signature found. A reader who trusted the page and checked would find the check failing and reasonably wonder what else on the page was wishful.
The runbooks now say git tag -a, which is what happens, and say plainly that the tag carries no signature and that checksums.txt and the bill of materials do. What is signed did not change: cosign keyless signing by the publish job, verifiable against the workflow identity, is the guarantee and always was. A maintainer who wants signed tags as well now has the four setup steps written down, stated as something they choose to do rather than something already true.
af golden refresh published an empty golden when the variable naming production held nothing, and exited 0.
af golden refresh published an empty golden when the variable naming production held nothing, and exited 0.A manifest that sets database.source_url_env is saying where production is. When the variable it names was unset, the refresh read that as "no source configured", which is the shape a project with no production yet has. It started a candidate, copied nothing into it, masked nothing, verified nothing, and committed a golden carrying this project's own provenance. The last line it printed was Bring an environment up from it with: af up.
af up already refuses this, with AF-DB-012, when a manifest names a source and no golden exists. That guard then passed, because a golden for this project now did exist. The environment came up, the migrations ran, and it held none of production's shape or volume while looking entirely correct. There was nothing to read that said otherwise: af golden list showed the empty version as verified and made for this project, which it was.
An exported but empty variable is the same case and was equally silent, which is how a pull request from a fork reaches it. Forks get no secrets, so the variable is empty in exactly the runs nobody watches.
A refresh whose named source holds nothing now stops before a candidate database is started:
AF-DB-016 database.source_url_env names PRODUCTION_DATABASE_URL, and PRODUCTION_DATABASE_URL holds nothing in this shell. Next: Export PRODUCTION_DATABASE_URL with the read only connection string of the database to copy, then refresh again. To build a golden with no production behind it, remove database.source_url_env and set database.seed instead.
A manifest with no source_url_env is unaffected: the empty golden it gets is what it asked for.
A hosted load run recorded as having sent no requests at all, and a run whose engine lost it could be ended by that engine.
The control plane read the run-wide aggregate of a report using the field names of the engine's internal load result rather than the names of the result document the engine actually sends. Two of the four workload kinds were affected, and it failed silently in the worst available direction: the request count falls back to zero rather than refusing, because the column requires one, so a run that sent twelve hundred requests recorded as having sent none with every percentile empty, while every route, threshold and piece of evidence beside it read perfectly. A console draws that as a strange run rather than as a broken reader. It survived because the engine's tests checked a document the engine wrote itself and the control plane's tests checked a document the control plane wrote itself, so the wire between them had never carried a real message. It is now read from documents a real engine produced, one per kind, and the check fails in both directions: a name the reader looks for that no engine sends, and a number an engine sends that arrives as something else.
Separately, an engine holds a run under a lease and extends it by heartbeat. Miss enough heartbeats and a second engine may claim the run and start doing the work. The statement that ends a run asked only whether the run was still open, not who was holding it, so the first engine's final event ended the run and the second engine's report then arrived against a closed run and was kept only as a note. The measurements of the engine that did the work were lost. A final event is now accepted only from the engine that holds the run, or from any engine when nothing holds it, which is the ordinary case for a run started by hand.
A run that changed hands and a run whose only engine died used to read identically once they timed out: both said nobody reported. A run now records when its lease was taken by another engine and when an engine that no longer holds it tried to end it, and says which of the four it was in its own words.
The control plane read the wrong shape for a load result and stored a run that sent twelve hundred requests as one that sent none.
The decoder read sent, rate and a nested overall object, which are the names on the engine's internal Go type. The document that actually crosses the wire is the projected result the engine writes, and it spells them requests, achieved_rate and flat percentiles. Both spellings are accepted now.
It failed silently, which is why a green suite on each side of the seam missed it: the request count falls back to zero because a load result must carry one, so the count read as zero, every percentile read as absent, the decoder's own "some of this could not be read" note never fired, and every per route measurement decoded perfectly. A console would have drawn per route latency over a run that appeared to have sent nothing.
Found by dumping the bytes the engine puts on the wire and running the decoder over them, which is the only thing that could have found it. Neither suite crossed the seam.
A load run that hit its own timeout was recorded as having succeeded.
The projection read only the engine's outcome, which cannot say more than failed or not, so timed_out was a value in the state enum that nothing could ever reach and an interrupted run answered green. The engine's own terminal state is read first now.
An engine's heartbeat also answers whether a cancel is waiting. Without it the only way a cancel reached a run already going was a poll of the command queue, which cost a minute of latency and took a lease on unrelated commands on the way past. The column was already on the row the heartbeat updates.
af runner install now finds the runner from anywhere inside a checkout, rather than only from its root.
af runner install now finds the runner from anywhere inside a checkout, rather than only from its root.It searched the working directory and its parent, which assumed the working directory was the root of the checkout. That is usually true, so it held until something ran from a subdirectory. Run from examples/go-api, it searched examples/go-api/runner and examples/runner, found neither, and reported that no runner source existed while standing two levels below a checkout that had one. The remedy it printed told the reader to install a runner they already had.
The search now climbs to the directory holding .git and stops there. It stops at the checkout rather than the filesystem root because a runner outside it belongs to something else, and copying that one would succeed, which is the worse failure: the wrong runner stays invisible until a test will not run. Outside a checkout the two nearest directories are searched, which is the pair this looked in before, so nothing that already worked has changed.
The sign-in and sign-up pages advertised a markdown file that 404'd.
Every page on the marketing site carried a <link rel="alternate" type="text/markdown"> pointing at its own address with .md on the end, so an assistant could read 800 words of prose instead of parsing 300KB of HTML. The generator that writes those files does not write one for every page: it skips anything carrying noindex, because a page crawlers were asked to ignore should not be republished in a machine-readable form.
/signin and /signup are the only two pages the site marks noindex, so they were the only two advertising a file the build never produced. Both addresses answered 404. The tag is now emitted only for indexable pages, which is the rule the generator already followed, and the SEO check asserts that every twin a page advertises is a file the build actually wrote.
af explore now runs.
af explore now runs.It never had.
The engine built the runner's job document with a nil Go slice for workflows, which marshals as null, and the runner read doc.workflows.length with no guard. Every exploration, on every application, died with a TypeError before the browser opened, so nothing downstream of it had ever run either, --emit-workflow included.
Both sides compiled and both typechecked. They disagreed only on the wire, and it was found by running the command rather than by reading either side. The engine now sends [], which is strict on the write, and the runner tolerates a null or an absent list, which is tolerant on the read: a runner keeps working against an engine that predates the fix, and one bad field on a boundary does not take a whole run with it.
Expired sessions are now actually deleted.
The sweeper had removed zero rows, on every instance, for as long as it existed.
It ran on a connection with no tenant, and every policy on that table keys on a declared value, so its DELETE matched nothing and reported success. A statement that matches nothing does not raise.
The fix could not be a policy on the application's own role: permissive policies are OR'd, so one naming no tenant widens every other policy on the table, and a session row names a user and an organization. A per-tenant sweep cannot work either, because sessions.org_id is nullable and an abandoned sign-in leaves exactly the row a sweeper exists for.
So housekeeping gets a role of its own, entered for one transaction. Inside it the sweeper reaches rows the DATABASE's clock calls expired and reads two of their columns; a cutoff passed in can only narrow that, never widen it, so no argument can make the sweep reach a live session. Reading a session token is refused outright rather than returning nothing.
tools/release/build.sh packaged a release archive with no af in it, and exited 0.
tools/release/build.sh packaged a release archive with no af in it, and exited 0.Both callers pass the output directories relatively. just build-release and .github/workflows/release.yml each end in dist stage, resolved against the repository root. The script builds the binary from $root/engine, in a subshell, and handed the linker the same relative path, so -o resolved against engine/ rather than against the directory the script had just made. The binary landed in engine/stage/<name>/af, the staged directory was archived without it, and the archive, its checksum and the script's exit code were all exactly what a working build produces. Four platforms of that is the whole release: LICENSE, README.md and the runner's source, and nothing to run.
tools/relpack exists to assert what is inside the archive rather than to treat it as an opaque blob, and it could not see this, because it passed absolute paths. That is the one shape neither caller uses, and an absolute path makes the defect impossible. It builds from a directory inside the tree now, named relatively, the way both callers do. A temporary directory outside the tree does not reproduce it either: the relative path back out resolves to the same place from engine/ as from the root whenever the two sit at the same depth below it, which on macOS they do, so the obvious version of this fix went on passing over the broken script.
The script resolves both directories to absolute paths before anything uses them. install.sh would have refused the result rather than installing half of it, since it checks the archive for af before placing anything, so what a reader would have met is an installer that cannot install rather than a broken af on their PATH.
An organization deletion claimed its step after doing the step's work, so a second caller did all of it before being told it had lost.
stopWork ran four statements in one transaction: tear down the environments, cancel the runs, suspend the organization, and only then mark the record with WHERE work_stopped_at IS NULL, returning false if that matched nothing. The comment above advanceDeletion says every step's write carries WHERE <its timestamp> IS NULL so that two callers arriving at once do not both do it. That was true of the bookkeeping row and false of the three statements that do the work.
This was not reachable as data loss, and saying so precisely is the point. Each work statement locks the rows it touches and re-evaluates its WHERE after the winner commits, so the loser found nothing left to change and the counts stayed right. That is a real protection, it is not the one the comment describes, it is written down nowhere, and it holds only for as long as every step happens to touch a row the other caller also locks.
The claim now comes first. The loser blocks on it, re-reads after the winner commits, matches nothing, and returns before it has torn down an environment.
The ledger moves with it. environments_stopped and runs_cancelled were taken from whichever caller won the final statement, which is not necessarily the caller that did the work, so a deletion that stopped one environment could record that it stopped none. They are now written by the caller holding the claim, which is now necessarily the one that did the work.
The documentation offered no way to sign up on a tablet.
Between 800px and 1023px wide, no documentation page carried a Log in link, a Sign up button or a link to the repository. The header hid all three at lg, and the mobile menu that holds their only other copies exists only below 50rem, so for 224 pixels of viewport width they were hidden with nowhere to go. The footer of a documentation page carries Privacy and Terms and nothing else. iPad portrait is 810 to 834 pixels.
Three different collapse boundaries caused it: the navigation at md, the buttons at lg, the menu at 50rem, in a file whose comment said the navigation collapsed at 50rem while its class said md. Both are 50rem now, matching the menu, and only the GitHub link still waits for lg, because measured at 800px the row is 890 pixels wide with it and 749 without.
Three smaller things in the same bar. The Docs link claimed aria-current="page" on all 82 pages, so a screen reader reading the command reference was told it was on the Docs page; it now says page on the documentation home and true everywhere else. It pointed at /docs/ while the marketing header points at /docs, so the site's own Docs link was the one piece of navigation that cost a redirect. And the GitHub link stood 18 pixels tall, under the 24 pixel minimum.
The focus ring on those links faded in rather than appearing. Tailwind v4 added outline-color to what transition-colors animates, so the ring interpolated from each link's own colour over 200ms: on Sign up that start colour is white on a white header, so the indicator was invisible for the first hundred milliseconds of every focus. Measured at 0, 50, 150 and 300ms before and after. Colour is out of that transition now and hover still animates.
Starlight names its sidebar landmark "Main" and the header above it has a nav named "Main", so from 800px up a landmark list held two identical entries. The sidebar is "Documentation".
Four places where the documentation described a command the product does not have.
The operations runbook told an on-call engineer, in bold, in a list of things not to do during an incident, not to run af down --all. There is no --all flag; af down takes --branch and nothing else. The warning was real and the command was not: the thing that removes every environment on a machine is af env prune --older-than 0, which is what the page says now, along with the --dry-run that shows what would go and the af env reap that removes only what has already expired.
The provider keys guide showed a nine line af provider list session and every line of it was wrong. Rendered by calling the real table with the real column definitions and the real cell strings, the headers are upper case, the key is masked with eight ASCII asterisks, the two money columns are right aligned, and a provider with no budget reports not tracked. The page showed title case headers, bullet characters, left alignment, and an em dash. The code carries a comment saying bullets were rejected because this output is pasted into pull request comments, and another saying a dash was rejected because it reads as unlimited and means the opposite. The page was showing both rejected forms, on the screen somebody reads before typing their own API key.
The model keys guide invented the message that fires when an exported variable shadows the key you just stored, and dropped the clause naming where the other key is set, which is the only part of it that tells you what to do. It also never showed the message you get when nothing shadows it, which is the common case.
Two links carried another page's exact title. The quickstart's last section offered "An environment per pull request" and went to the GitHub reference, while the page with that title sits next to it in Getting started and the quickstart never linked to it at all. A page about load offered "invariants" and went to the manifest schema rather than to the invariants guide.
The API reference named eight routes fewer than the server serves.
reference/api.md is the page a reader takes as the API surface, and three whole families were absent from it: /webhooks/github and /webhooks/stripe, the two /byok model proxy endpoints, and the four /console/api/providers routes. The model proxy is the mechanism two guides describe in full, so the page that lists the surface omitted the thing those pages tell you to point your client library at.
It also said "Everything below is authenticated, apart from the four unauthenticated routes at the top", which was a counted claim that the webhooks break in a way counting cannot fix. They accept a signature rather than a credential, which is a third thing: nobody signs in to them, anybody can reach them, and what protects them is that the body must have been produced by somebody holding the shared secret. The page says that now instead of a number.
Each row was written from the source rather than from the route table: the console routes take a session cookie plus a CSRF header from GET /auth/session and exist because /v1/providers authenticates a Bearer token for af provider, the proxy takes an engine token in whichever header that provider's own client already sends, and both webhooks verify over the exact bytes received before anything is parsed.
The gate's backward direction now runs unguarded, because its register emptied itself: the assertion that fails when a known gap stops being a gap forced these three families out of the list in the same change that documented them.
And the scanner behind it had a blind spot worth naming, found by going looking for one rather than by it failing. It matched app.get and friends, and the tRPC router is mounted with app.use('/trpc/*', ...), so /trpc was never in the route set at all. The backward check was passing over a family it could not see. It reads mounted families now, and removing the /trpc row from the page fails as loudly as removing any other.
The enterprise licensing page told a customer to install a license with a command that stores nothing.
Its "Installing a license" section was three commands, af license install <token>, af license status, af license remove, and neither binary installs anything. On the enterprise binary af license install prints "This binary reads its license key from AF_LICENSE_KEY and stores nothing" and returns. On the community binary it refuses. There is no override in ee/: the enterprise entry point reads AF_LICENSE_KEY and AF_ORG from the environment and that is the whole mechanism.
Neither variable appeared on any published page. So the command told the reader to set two variables, pointed them at https://antifailure.dev/docs/enterprise/licensing, and that page could not answer the question the message raised. A closed loop, on the paid path.
The page now says there is nothing to install, shows the two variables, and says why storing a key on disk was rejected. It also documents AF_LICENSE_PUBLIC_KEYS for an installation that mints its own licences, and that those keys are merged with the build's rather than replacing them, because trusting your own key must not stop the vendor's from working.
Separately, af doctor tells somebody with a busy port range to "set AF_PORT_RANGE_START to a range that is free", and that variable was documented nowhere either. It is in the local runtime guide's Ports section now, beside the error it follows.
Both were found the same way, by diffing every AF_ variable named in a user-facing string against every AF_ variable any published page mentions. The first version of that diff was line oriented and returned a clean zero over AF_PORT_RANGE_START while looking straight at it, because r.Remediation = fmt.Sprintf( and the string that names the variable are on different lines. It reports correctly over a window, and it was watched failing by taking the new mention back out.
Four documents told a self-hoster to point GitHub at a URL this product does not serve.
AF_GITHUB_REDIRECT_URI=https://cp.example.com/auth/callback is in the third page of the getting started path, the self-hosting control plane page, the Azure page and the README. There is no /auth/callback. The route is /auth/github/callback, registered at server.ts:411, and it is what production.tfvars and staging.tfvars both configure, so the product's own deployment disagreed with its own instructions.
That value goes straight to GitHub as redirect_uri, and nothing validates its path at start up. So the failure lands at the END of the first sign in, after the operator has registered an OAuth App with the same wrong URL. They then compare the variable against the App, find them identical, and go looking somewhere else entirely.
A new test reads the routes the server registers and the pages the console serves, and refuses any URL in the documentation that hangs off one of this product's own hosts and matches neither.
The first version of that test matched a bare path under /auth/, /v1/, /trpc/, /byok/ or /console/api/, which sounds specific and is not. It produced ten findings and nine were false: /v1/ is what Stripe, Anthropic and OpenAI all use, so /v1/charges, /v1/messages and /v1/chat/completions came back as missing routes on this server, and it pulled /auth/github.ts out of the middle of a source file path. One real finding under nine false ones is a gate somebody deletes, and the real one dies with them. Requiring a host of ours drops every false one and keeps all four real occurrences, because each was written as a complete URL an operator pastes.
Separately, both pages that tell a maintainer to add a label to run a fork's pull request now say which label. It is antifailure:allow, and it was named only in the generated schema reference, so a reader following the getting started path was told to add a label nobody had told them the name of.
And just docexamples, the gate that keeps documented commands honest, was serving cached passes over the documentation. It reads docs/src/content/docs, which is outside the engine module, so nothing it depends on is anything Go's test cache watches. Measured rather than reasoned about: a page was edited to read af init --wat, a flag that does not exist, and the recipe answered ok (cached). The same test with -count=1 failed on it at once. CI already passes -count=1 through go test ./..., so this was local only, which is the worst place for it: CONTRIBUTING promises that a green just gate means a green CI.
The GitHub workflow template every new user is told to copy contained a command that exits with a usage error.
af ci --output report.md is in examples/github-workflow.yml and in the getting started page that tells a reader to copy it. af ci used to carry a local --output meaning "a file to write", which shadowed the persistent -o, --output that means text or json everywhere else. That was renamed to --report, and the note recording the rename says the example workflow moved with it. It did not. The generated command reference did, so the two descriptions of the same command disagreed and only the one nobody copies was right.
What that cost, run rather than reasoned about: af ci --output report.md exits 2 with "the output format \"report.md\" is not recognised; use text or json". The step after it in the template is if: always() && hashFiles ('report.md') != '', and report.md is not empty, because the previous step wrote the change analysis into it. So the job goes red on a usage error and then posts the change analysis to the pull request as though it were the run's report.
just docexamples did not see either one, for three separate reasons, and all three are now closed.
It only read docs/src/content/docs. The example workflows are documentation that happens not to be markdown, and they are the version most people actually run, because the page says to copy the file rather than type the commands.
Its pattern was anchored on a line beginning with af, and a workflow puts the command in a YAML value, so run: af ci --output report.md could not match. A pattern that cannot match looks exactly like a pattern that found nothing.
And it checked that a flag exists rather than that its value is one the command takes. --output exists. --output report.md does not run.
The value check is scoped to the persistent flag by pointer identity, because the first version of it reported af oracle --keep -o oracle.md as broken. That one is correct: af oracle still defines its own local --output, the same shadowing that was called a defect when af ci had it. A gate is worth having only while every finding is real.
The documentation build warned on every run that /404 was declared twice, and Astro's own message says a route collision becomes a hard error in a later version.
/404 was declared twice, and Astro's own message says a route collision becomes a hard error in a later version.This site ships its own 404 at src/pages/404.astro, and Starlight injects one at the same pattern unless told not to. disable404Route: true tells it not to.
Ours is the one that should win, and not because it was there first. Somebody who lands on it arrived from a path printed at the end of an engine error message rather than from a link, so the page names the four references they were most likely reaching for and tells them the address in their terminal is the wrong half. Starlight's default is a generic page with none of that.
What this does NOT change is what gets served: the built 404.html was already ours before and after, byte for byte the same page. The warning was about which of two declarations Astro would honour being undefined, not about it having picked the wrong one. That is worth stating plainly, because "we were already getting the right answer" is exactly the reasoning that leaves a collision in place until the version that turns it into an error.
Route collision warnings in a full docs build: 2 before, 0 after. The six remaining vite warnings are about self-hosted font files resolved at runtime, are unrelated, and are untouched.
The runs list, the verdicts view, the goldens quota, the masking attestation table and the compliance pack's masking control were blank for every real customer.
All five read golden_versions, runs and verdicts, and the only INSERTs into those three tables anywhere in the repository were the test harness and the staging seeder. The seeder fills all of them, so every one of these looked correct in development and had nothing to show in production. The compliance pack was the worst of it: it reported that the check ran and found nothing to show, for an organization that produced a signed attestation every night.
The engine emitted the events, the sink mapped them and ingest accepted the types, so nothing anywhere reported a problem; the projections were simply never written, because the earlier fix for the same defect on environments was written as a case for environment.* rather than as a rule about projections.
golden.published could never have been recorded even had it been projected, because the event carried a phase, a version and a boolean while golden_versions is keyed through the repository. It now carries the identity every environment event carries, plus the rules digest, the size, the attestation and the golden's own creation time. af golden refresh and the pinned golden path emitted no event at all, so the two commands that most need a compliance record produced none.
af net log left via blank on tunnelled CONNECT, which is most of the traffic, so a reader could not tell "arrived transparently" from "nobody filled this in".
af net log left via blank on tunnelled CONNECT, which is most of the traffic, so a reader could not tell "arrived transparently" from "nobody filled this in".The proxy has four paths that record a decision, and three of them said how the request reached it: serveHTTP says proxy, inspectTLS says inspect, and both transparent paths say transparent. serveConnect said nothing. That is the tunnel every HTTPS request opens when a client honours its proxy environment variables toward a host the policy does not read inside, so the field was empty on the majority of records while being populated on the minority. An empty field that looks like a value is worse than a missing one, because it reads as data rather than as an omission.
host_only was missing from the same record for the same reason. A tunnel nobody reads inside is decided from the host and the port and nothing else, exactly as the transparent path is, and the transparent path has always set it. An inspected tunnel is still not marked, because the requests inside it are decided on their paths and carry their own records.
Anybody reading af net log -o json, or building on the record shape, now gets the same four fields on every decision rather than on three paths out of four.
The hero shader ran on the CPU on any machine whose GPU driver is blocklisted.
The component guarded against WebGL being unavailable, and that guard is binary: the context is refused, or it works. A blocklisted driver is neither. The browser grants a context backed by a software rasteriser instead of refusing one, so new Renderer succeeds, nothing throws, and a full bleed fragment shader animates on the CPU for as long as the page is open. That is the normal state of a cheap or out of support Chromebook, and it was reported as the site crashing them.
failIfMajorPerformanceCaveat is the context attribute that turns that third case back into the refusal the component already handles well, and ogl builds its own attribute object without forwarding it, so the question is now asked separately before the real context is built. A machine with a working GPU answers yes and loses nothing.
The loop also never stopped. Frame throttling applies to a hidden tab, not to an element that has scrolled out of a visible one, so a reader who scrolled past the hero kept paying for the shader for the rest of the visit. It now runs only while the canvas is on screen in a tab somebody is looking at.
The cross platform lint refused internal/mcp/paths_test.go because syscall.Mkfifo does not exist on Windows.
internal/mcp/paths_test.go because syscall.Mkfifo does not exist on Windows.The test carried a runtime.GOOS == "windows" skip, which reads as though the platform had been handled and cannot help: the skip runs at run time and the missing symbol is a compile error, so the whole package failed to typecheck.
The fifo test moves to paths_fifo_unix_test.go behind //go:build !windows, matching how the package already separates open_unix.go from open_windows.go. It still runs on every platform that has mkfifo.
af version reported the wrong edition in the enterprise binary.
af version reported the wrong edition in the enterprise binary.It printed a package variable that no build has ever stamped, so it said community edition while af license status, in that same binary and from the same startup, said enterprise. The command an auditor runs to record what they are running was the one that was wrong, and both were green in every test because a test of the community command tree correctly attaches nothing.
The edition is not a build time string. It is what the running binary declares about itself, which the enterprise entry point has always done and which af license status has always read. There is one reader for it now and both commands ask it.
tools/ldcheck could not have caught this, because it validated the linker flags it was handed and a variable nobody stamps is not in that list. It now also refuses a string variable declared in the same var group as a stamped one and left unstamped. A variable that is not a release stamp belongs in its own declaration, which the failure message says.
The run's result document read that same variable and is fixed with it. Every workload an enterprise binary ran recorded community in the engine.edition field, and that document is the artifact an auditor keeps after the run itself is gone. It asks the running binary now, the way af version does.
The manual deploy to production could never have run.
cd.yml offers two ways into the production job, a version tag or a manual run asking for production, and the second was skipped every time by a rule nobody had reason to think about: a job whose needed job was skipped is skipped too, and staging skips itself on exactly that manual run. The condition now names each dependency's result, so the tag path behaves as it always did and the escape hatch works.
There is also a release runbook now, at docs/src/content/docs/self-hosting/releasing.md: what a v* tag sets off in both workflows, what green looks like at every stage, and what to do when a stage goes red.
just tagsync now holds the four version literals in the release verification page to the release being cut, so the worked example cannot go on naming a tag whose assets do not match the instructions.
just tagsync now holds the four version literals in the release verification page to the release being cut, so the worked example cannot go on naming a tag whose assets do not match the instructions. The markdown twin of every page, which is what an answer engine reads instead of 300KB of markup, dropped every table cell and every definition list.
So the masking table on the safe-state page and the manifest walkthrough on the overview page, which are the most specific claims either page makes, were in the HTML and absent from the twin: an assistant could see that the page discusses masking and could not see a single rule. The corpus grew from 90KB to 109KB. The SEO gate now reads the built pages for cells and fails on one that its twin does not carry.
The control plane's OpenAPI document described an API a generated client could not call.
Every query said its input was optional, including the routes that answer 400 without one, and every mutation demanded a body shaped exactly {} even when the route reads no input at all. Both are now taken from the validators the router actually executes: whether an input exists at all, and whether the validator refuses an absent one.
The refusal schemas did not match the wire either. One Error shape stood for three different bodies, and validated none of them: the readiness 503 carries no error member, so a client parsing it as an error read undefined and reported the service healthy. Ingestion could answer 400 and 403 and the document listed neither. There are three schemas now, one per real body, and a test drives real requests through the real HTTP boundary and checks each answer against the schema declared for that status.
The event type was published as a closed enum while the server deliberately accepts and stores a type it has never seen, which is what lets an older control plane ingest a newer engine's events. A generated client would have refused at the boundary exactly what the server was built to take. The known values are published as examples and as x-antifailure-event-types instead.
An unexpected control-plane failure told the caller to find their request in the logs and gave them nothing to find it with.
There was no identifier in the body, none on the response, and deliberately no query, no parameters and no payload in the log, so there was nothing on either side to match. Every request now carries an x-request-id, the 500 body repeats it, and the log line carries it beside the error's class and the driver's code.
The property that made the resolution unusable is the one worth keeping: Drizzle writes a query failure as the whole statement with its parameters after it, so the error and its message stay out of the log. A test drives a real query failure through the real HTTP boundary and asserts the statement reaches neither the caller nor the log, and it was written after a first version passed while the handler logged the whole error object, because an Error stringifies to {}.
Every documentation address the product prints or publishes now names the URL the site actually serves, rather than a spelling it answers with a 301.
The host serves antifailure.dev with no trailing slash. The marketing site agreed; the documentation build did not, so https://antifailure.dev/docs/reference/cli/ redirected to https://antifailure.dev/docs/reference/cli on the way to every page. That affected the More link under all 131 error codes, which is the address the engine prints when something has already gone wrong for you, the 82 documentation pages' own canonical tags, the 81 URLs in the documentation sitemap, and the links between the pages themselves.
Nothing about which page an address resolves to has changed, and the old spelling still works, because the redirect that was always there is still there. It is simply no longer on the path a reader or a search engine takes to reach the page.
af init wrote a header saying "Every value here came from a file: a package manifest, a Dockerfile, a compose file, or a dependency list", and directly beneath it two personas at example.test and a sign-up workflow describing a form.
af init wrote a header saying "Every value here came from a file: a package manifest, a Dockerfile, a compose file, or a dependency list", and directly beneath it two personas at example.test and a sign-up workflow describing a form.None of those came from anything: defaultPersonas returns the same two accounts for every repository and suggestedWorkflows emits sign-up whatever the dependencies say. So a first time reader was told the file described their repository, ran the two commands the tool printed, and the second failed on a users table their JSON API does not have.
The mechanism built to prevent exactly this could not see it. assumed was fed only through resolveQuestions, and personas and workflows never become questions, so the only guess af init ever disclosed was database.present. It is now seeded from what the draft holds before any question is asked, so a value nothing asked about can still be disclosed.
The header says what is true, the two guessed blocks carry a comment where they appear rather than only in a summary printed once, and the rendered bytes are still parsed before they are written, so a note that broke the document would fail the command rather than reach a file.
They are still written, and deliberately. Nothing in a repository proves the absence of authentication: the auth detector recognises five frameworks, so a hand rolled sign in produces no finding and looks exactly like no sign in at all. Dropping the personas on that evidence would trade a loud failure for a silent wrong answer.
The README told you to install Antifailure and then run af init, and never mentioned af runner install.
af init, and never mentioned af runner install.The runner drives the browser that produces every verdict, it is a separate program in a separate language, and it needs node, so the one dependency a reader had to know about was the one the front page left out. The quickstart had the same shape one level deeper: it went from install to a running environment and stopped, so the page that walks a new user through the product never ran a workflow, never mentioned the runner, and never showed a verdict or the evidence behind one.
Both now cover the whole path, and tools/walkthrough walks it: af start, af doctor, af runner install, af runner check, af explain, af up, the URL it printed, af status, af test, the artifacts on disk, af start again, af down. The af test step does not assert on the exit code, because af test exits 0 on unverified and blocked does not count against a run, so a run that reached no workflow at all exits 0 exactly like one where everything passed. It asserts that a verdict came back for at least one workflow and that at least one of those verdicts is about the application rather than about the harness.
af oracle -o json wrote the comparison to a file called json, and af support bundle -o json wrote a zip archive to a file called json.
af oracle -o json wrote the comparison to a file called json, and af support bundle -o json wrote a zip archive to a file called json.Both exited 0 and neither said anything. Each command declared a local --output with the -o shorthand, meaning a file to write, and a local flag silently wins over the persistent one that means text or json everywhere else.
The JSON was not missing. oracle.Result is fully tagged and both commands have always carried a FormatJSON branch. Those branches were unreachable, because nothing could ever set the format on these two commands: a written, wired, tested feature with no path to it. af oracle in particular is the one command whose output exists to be read by something other than a person.
The local flags are now af oracle --report, matching af ci which carried the identical defect and was renamed, and af support bundle --archive. Neither takes a shorthand. TestNoLocalFlagShadowsAPersistentOne walks the whole command tree and compares by pointer rather than by name, because cobra reports inherited flags alongside local ones and a check written on names would report every command in the tree.
An error remedy told the user to run a command that does not exist.
AF-DB-004 said to run af golden list and then af up --golden <version>, and af up has --branch, --hud and --rebuild. The catalog is what the engine prints, so this was the product telling somebody, at the moment they were already stuck, to run something that would fail. The remedy was also incoherent even had the flag existed: it named a version the error had just said no longer exists. It now says to look at what does exist and to make one if nothing does.
Two more of the same class in the documentation. self-hosting/operations.md told an operator in bold, during an incident, not to run af down --all, which is not a flag; the command that removes every environment on the machine is af env prune --older-than 0. guides/dashboard.md said the dashboard draws the same stream af events reads, and there is no af events; the stream is the NDJSON log under .antifailure/logs.
TestEveryCommandInTheDocsExists existed for exactly this class and was green over all three. Its pattern is anchored to the start of a line, which is true of a command in a fenced block and false of one in a sentence, so every one of the 127 remedies on the generated errors reference was outside what it could see. It reads inline code spans and quoted commands now, and a new sweep checks catalog.yaml itself, so the next one fails where it is written rather than one page downstream.
The runner's dependencies were resolved fresh on every machine.
Release archives shipped runner/package.json and no runner/package-lock.json, established by downloading the published antifailure_0.1.1_darwin_arm64.tar.gz and listing it, and af runner install ran npm install. So playwright's ^1.49.0 became whatever the registry served that day, two people installing one release got two different browsers driving their tests, and a failure one of them saw was not reproducible by the other.
Release archives now carry the lockfile, af runner install runs npm ci when one is present and says the tree is pinned, and af runner check reports an unpinned tree as a warning rather than as the same ok a pinned one gets. A source with no lockfile still installs, because refusing would strand somebody pointing --from at a checkout, but it says plainly that the tree is not pinned. tools/relpack runs the real release build and asserts what is inside the archive, which nothing did before: every release gate checked signing, publication and reproducibility, and all three are properties of the archive as an opaque blob.
af init writes a README into .antifailure telling the reader that if they delete the journal while an environment is running they should run af down --all.
af init writes a README into .antifailure telling the reader that if they delete the journal while an environment is running they should run af down --all.There is no --all. That is the third instance of that exact flag and the most durable, because it is written to a file on disk rather than printed once. It now names af env prune --older-than 0, which inventories the provider rather than reading the journal, so it can still find what the journal no longer names.
Neither the catalog sweep nor the documentation sweep could see it, because it is a Go string literal. A third sweep now reads every string constant the engine and its error package hold, through go/parser rather than a pattern, and checks the flags against the real command tree.
Scoped to flags, and the limit is recorded beside the assertion rather than in a report. A bare invented command name cannot be told apart from prose in a string: "run af down again", "af golden refresh has nothing to compile" and "af down clears it" are ordinary sentences beginning with a real command, and a rule that read the next word as a subcommand reported about a third of its findings falsely when that was measured. A flag is unambiguous, because prose does not contain double-dashed words, and the flags are where all three live defects were.
The refusal a new user is most likely to meet carried no code.
af test on a freshly initialised repository stopped with "no users table could be found, so there is nowhere to create a persona", with no AF- number, no next step and no docs link, from a binary whose refusal one command earlier is AF-MAN-001 with all three.
It is AF-DB-022 now, and it names all three ways out rather than two: auth.table for a table under a name detection did not recognise, auth.adapter: seed, and login: none for a persona that never signs in, in which case no account is needed at all. It is wrapped rather than replaced, so the sentinel survives and a manifest where nobody signs in still carries on instead of being refused over accounts it never wanted.
af net log -o json could not say whether a sandbox credential was actually swapped.
af net log -o json could not say whether a sandbox credential was actually swapped.The sidecar has always recorded via, host_only, substituted, waited_ms and limit on every decision, and the runtime has always decoded them, but the JSON view dropped all five. So a program reading the decision log could see that a request to a payment provider was allowed and could not see whether the credential was replaced on the way out, which is the question the log exists to answer. The text table already showed the rate limit, so the machine readable output was strictly less informative than the prose beside it.
The five fields are now carried, and the mapping lives in a function with a test through the encoder. It had neither, which is why five fields went missing on this surface without anything noticing.
The console's Ask for an environment, Run agents and Run load controls put GitHub's raw JSON on the screen when a dispatch was refused, and the sentence beside it named three possible causes at once.
A refused dispatch now says which one it is: the App was not granted Actions write, the App was never given that repository, there is no repository of that name it can see, there is no workflow file at that path on the default branch, the branch does not exist, the workflow declares no workflow_dispatch trigger, or it does not declare the inputs the console sends. Each carries its own remedy and none of them carries a status code or a JSON body.
The same check now runs when a repository is chosen rather than when the button is pressed, so a missing permission is visible before the form is filled in.
The permission behaviour was documented backwards: a missing actions: write is a 403 Resource not accessible by integration, not the 404 the documentation claimed, and it is checked before the workflow file is looked for, so it hides a missing file behind it.
InstallationTokens.forget had no callers anywhere in the tree. It is wired now: a call that GitHub answers 401 drops the cached token and retries once with a fresh one. That is not defensive programming, it is the state a person is in the instant after they accept a permission on GitHub, because accepting one invalidates every outstanding installation token while the cache holds the old one for up to an hour. Without it the diagnosis above gives a confident wrong answer, since a 401 makes every lookup report nothing wrong.
.github/workflows/antifailure.yml is added, so the console's controls have a workflow to dispatch in this repository. It is dispatch only, because dogfood.yml already runs the product against every pull request here.
examples/github-workflow.yml no longer cancels a dispatch when a second one arrives. The console's three buttons run one after another against one branch, so pressing Run agents used to cancel the run that was building the environment Ask for an environment had just asked for.
ListGoldens reported every golden as verified, including the ones that were not.
ListGoldens reported every golden as verified, including the ones that were not.RefreshGolden records Verified: spec.Verify != nil, which is a real value: a refresh with no verifier commits an image and says so. ListGoldens then built every version with Verified: true unconditionally, under a comment reasoning that a committed image only exists because verification passed. Half of that is true, since a verification that FAILS never commits. The half that is not is that a refresh with no verifier commits too.
pickGolden reads the listing, not the refresh. So the honestly recorded false was overwritten by the read, and af up branched an unverified golden exactly as if it had been checked, with nothing printed anywhere.
The verified state is now read from the attestation label rather than assumed. That label is written at commit time and is only ever produced by a verifier that ran and returned, so it is the durable record the read was missing: no new label, no migration, and a golden that was verified stays verified. One that never was now says so, and costs its owner the single refresh pickGolden's own comment describes as the price of refusing.
The case that hid this had no test. The round trip test published a golden WITH a verifier and asserted Verified was true, which an unconditional true satisfies perfectly. There is now a test for a refresh with no verifier, which is the only shape that can tell an honest read from a hardcoded one.
Pressing control C twice did nothing.
Both binaries set up signal handling by calling WithSignals, whose comment said in the present tense that the second interrupt forces an exit with the journal intact. Both then discarded the return value that said a second one had arrived: ctx, _, stop := cli.WithSignals(...). The function computed the answer, closed the channel, and no line in either binary ever asked. A user holding control C on a pull the size of a database got exactly as far as the first interrupt did, which for a command that does not check its context is nowhere.
The shape was the reason. WithSignals returned a function reporting whether a second signal had been seen, and a poll can only be answered by code that is still running. A second interrupt matters in precisely the case where the command is not coming back to ask anything. It is a channel now, and cli.Run waits on it: the exit code is produced out from under a command that is still running, and stopping there is safe because every resource is journaled before it is created, so af down still knows what to remove.
afcli.Run takes the same channel, so the enterprise binary, which carried an identical comment and an identical discarded value, stops too.
af net log -o json could not say whether the sandbox swapped the credential.
af net log -o json could not say whether the sandbox swapped the credential.The sidecar records a decision, the engine decodes it, and af net log shows it. Three structs, and until now nothing made their json tags agree. Seven fields the proxy wrote on every request reached no surface at all: substituted, host_only, via, duration, seq, and waited_ms and limit, which reached the table and not the JSON.
substituted is the one that matters most. It is the answer to the question the sandbox exists to answer, and a row that reached a real service and a row that reached a sandbox were the same row. It is now reported on every decision, as false rather than absent when the swap did not happen, because a key that disappears makes "the credential was not swapped" and "this build cannot report swaps" the same document. The table says sandbox credential and, for an invented response, the table already said so.
The fix that keeps it fixed is a test rather than this list. One compares the json tags of the sidecar's record, the decoded decision and the published document and fails on any fact recorded with nowhere to go; the other fills every decoded field and fails on any published key nothing assigns. A field added to the sidecar and forgotten in the other two now fails on the commit that adds it.
af explore could not produce a report on any machine.
af explore could not produce a report on any machine.The engine marshals the runner's job document from Go, where a nil slice becomes null, and the exploration path never sets the workflows field. The runner read doc.workflows.length before it looked at the goals it was given, so every run exited AF-AGT-003 with a TypeError and no output.
Nothing caught it because nothing anywhere drove the runner's entry point. The runner's own suite tests explore() directly, and the one Go test that reaches a real subprocess replaces node with a shell script. Both halves worked and the document between them was never sent by a test.
Fixed on both sides. The engine sends empty lists rather than nulls, so the shape it promises is the shape it sends. The runner tolerates an absent or null list, because it is the side of a boundary it does not control. Either fix alone prevents the crash, and each has a test that fails when its own half is reverted.
The dispatch workflow's command input keeps its verbs.
command input keeps its verbs.An earlier change renamed them to the control plane's kind names to remove a second vocabulary, and that would have broken every copy of the file already in the wild: GitHub reads the trigger definition from your default branch and answers a dispatch carrying an undeclared value with a 422, which is indistinguishable from the file being missing. So the two values that work today would have started failing in order to make two new ones work.
The list grows instead. up, down, agents and load work on an older copy of the file; scenario and explore need this one. af workload run accepts both spellings and its result says which kind a verb resolved to.
down is new and runs the real teardown with an acknowledgement of what was removed and what is still standing.
load.safe_routes and load.unsafe_routes entries carrying an HTTP method matched nothing.
load.safe_routes and load.unsafe_routes entries carrying an HTTP method matched nothing.Normalisation prefixed anything not starting with a slash with one, so DELETE /* became /DELETE /*, and the matcher compares the method exactly. Every example in the load documentation is written that way, so every one of them was inert.
The safe list failing like this is loud: a run that may send nothing refuses everything and says so. The unsafe list failing like this is silent, and that is the reason this matters. A list that matches nothing refuses nothing, so a manifest with a permissive safe list and unsafe_routes: ["DELETE /**"] sent the deletes the author wrote that entry to prevent, at production's rate.
A method the matcher would compare is now kept and upper cased. A path carrying a space is not a method and stays whole.
A run in which no workflow reached a verdict about the application exited zero, which told a pipeline the application had been checked and found fine when it had never been driven.
Both of this repository's own answers were that shape: the control plane's check reported "6 workflows could not be carried through" and went green on every run, and the whole example corpus reported "Nothing ran" and went green too.
af test and af ci now exit 9 when no workflow reached a verdict, which is a different code from the 8 a real failure exits with, so a pipeline reading the number can tell "your change broke something" from "nothing was tested". Individual verdicts are unchanged: one blocked workflow beside one that passed is still a passing run, because a gap in the tooling must not read as a broken application. A project with no workflows yet can set the new policy.workflows_unverified to warn and have that choice recorded in its manifest rather than assumed from silence.
A persona whose login is none no longer requires a users table. It never signs in, so there is no account to create, and the engine refused it anyway for having nowhere to create a user it would never use. examples/go-api sets login: none because the service serves JSON and has no sign in page, and it could not be run at all until now.
One outcome no longer carries two names. The planner's own explanation for a page it cannot act on said the workflow was "reported as blocked" while the verdict it produced was decided later and printed as unverified, so a single report disagreed with itself about the same workflow.
The home page was built for a phone and for a wide desktop, and rendered as neither in between.
Every layout decision on it switched at xl, so a 1100px browser window, which is a laptop rather than an edge case, got the phone treatment at desktop measurements.
The hero's five service cards were a horizontal scroller below xl with its scrollbar hidden. At 1100px only three of the five were on screen, the fourth was cut mid-word at the right edge, and nothing said the other two existed: 372px of the row sat outside the viewport with no affordance at all, and a sideways trackpad swipe over it moved the row, which reads as the page scrolling sideways. It reflows now: five columns above xl unchanged, three from 1024px to 1279px, two from 768px to 1023px, and the scroller only below sm, where a card is 78vw so the next one always peeks and a sideways swipe is the gesture a phone expects anyway. All five cards are fully on screen at every width from 640px up, measured, and the row's hidden scroll is zero.
The aurora behind the hero ended in a torn horizontal edge. Its frame is a pixel height per breakpoint while the hero's height moves continuously with how the headline wraps, so the two only agreed at the widths the art was tuned at. Between 1100px and 1279px the headline drops from three lines to two, the service row rises 68px, and the frame's bottom edge landed inside those paragraphs: body text lay across a single-row luminance step of 36 out of 255. The same edge cut the "Get started" button in half at 375px, where the step was 100. The hero's own bottom gradient could not cover either, because it is anchored to the section's bottom and the frame ends where that gradient is still transparent. A second fade is anchored to the art's own bottom edge now, on both the desktop frame and the phone copy, so the band dissolves into the page ground wherever the edge falls. Measured at ten widths from 320px to 1440px: the step across that edge was 23 to 100 before and is 0 to 1 after.
Section headings were sliced in half between 1024px and 1279px. The sticky section rail pinned itself 64px from the top while the header is 56px tall below xl, not below lg, leaving an 8px slot between the two that every heading scrolled through. The rail is xl:hidden, so it only ever exists where the header is 56px and the breakpoint on its offset was wrong in both directions; the constant behind its scroll-to targets carried the same mistake and was two pixels short. Gap measured at 0 from 320px to 1100px.
The logo was the last control on a phone under 44px, at 32px beside a menu button already at 44px. Its contents are centred, so the rendered header is byte-identical at 375px, 1100px and 1440px and only the hit area moved.
Two more controls were smaller on a phone than on a desktop, which is the wrong way round. The primary button carried max-lg:h-9 max-lg:text-sm, so below 1024px the site's main call to action shrank from 44px to 36px and its label from 16px to 14px: Get started, Read the docs, Join the waitlist and every other one, on every page, only ever under 44px on the viewport where a thumb has to hit it. The height and the type are the same at every width now and the narrower horizontal padding below lg stays, because that is what keeps two buttons on one line. Measured 44px tall with 16px type at 320, 375, 414, 640, 768, 900, 1023, 1024, 1100, 1280 and 1440.
No page on the site scrolls horizontally at any width: all 35 exported routes times ten widths, 350 combinations, checked against the static export. The check was proved able to fail rather than assumed to work: injecting an element wider than the viewport makes it report the overflow and name the element.
Everything above was measured against the static export rather than next dev, and that distinction is not pedantry. optimize-images.mjs runs only in build, so the development server answers 404 for every piece of the hero art and the hero renders on flat ground. A measurement of the aurora's edge taken there reads 0 whether the edge is torn or not, which is how the phone half of this was nearly dismissed as already fine when the real step was 100 out of 255.
There is no dark theme to check, which is a different claim from having checked one. The site sets colorScheme: "light" and carries no dark: variant and no prefers-color-scheme rule anywhere, and full page screenshots under an emulated dark preference are byte-identical to the light ones at all ten widths. That is the evidence that the page ignores the preference, not evidence that a dark palette renders correctly.
Two things on the status page at phone widths.
The component rows had two different heights and nothing about a component decided which it got. .comp-h wraps, so at 390px a row was 24px tall when the name was short enough to leave the status and the timestamp room beside it and 54px when it was not: five of seven rows one height and two the other, down a list whose whole job is to be scanned in one pass. Every row is stacked below 640px now, name on the first line and status on the second, so all seven are 48px and the rhythm is a decision rather than a consequence of how long somebody's component name is. Above 640px every row fits on one line and nothing changes.
The Day, Week and Month control was 40px tall, which is neither the 24px WCAG 2.5.8 floor nor the 44px this page already gives its subscribe button. It was the only control on the page between the two. It is 44 now. The help circles stay at 24 on purpose: they sit on the heading line beside the component name, and 44 would either push that name off its baseline or reach into the row above.
Measured at 390px under touch emulation: all seven rows 48px, and every control either at 44 or at the 24 floor with the reason recorded beside it.
The assistant panel on the home page's Migration Safety card was a drawing of an application that a keyboard could walk into.
Its reply box was tab stop 51 of 119 with no label of any kind, only a placeholder, and no focus ring: it was one of three controls on the whole page a keyboard user could reach and not see. Its Send button rendered 9px square, its Skills button 22 by 13, and the three window-chrome buttons 6.9px square at 768 and never more than 11px at any width, all of them under the 24px WCAG floor and far under the 44px this project sets for itself. Typing into the box appended your own words to a fake transcript, so there was no honest label to give it.
The parts that only depicted an assistant are drawn rather than operated now. The reply form is gone, with the state and handlers it fed. The window chrome is three glyphs a screen reader does not see and a pointer cannot press. What stays interactive is the part that is a real demonstration: the plan toggle that switches the whole card between the migration as written and the safer path, and the finding rows that open to show the line the run measured. Those already carried labels and focus rings. The toggle's target now clears 24px at every width through an invisible extension rather than by growing the ink, because the drawing is rendered at 0.6 to 1.0 scale and a 44px control inside it would be a real button in a picture.
At 320 and 390 the panel floated over a document cropped to its left 43 percent, so what showed beside it was a column of empty circles, half an avatar and six lines of text cut mid-word. It read as clipped content rather than as a floating panel. Below md the card is the report alone, full width, with its own type at 14px instead of 10px, and the report fades at its foot rather than being cut through a sentence.
The three claims under Isolated Twin on the home page, the ones that say the twin has no route out, that production secrets are replaced and that nothing outlives the run, were set in a grey that measures 3.85:1 on the page ground and stepped down to 14px below 1024.
Two floors this project sets for itself, 4.5:1 and 16px of body text on a phone, missed on the same line. They are 5.53:1 now and 16px on a phone.
Every h2 in the documentation pushed its anchor link onto a line of its own, 67px below the heading, on all 81 pages.
Starlight renders a linkable heading as a wrapper div holding the heading and the anchor link as siblings, and its own anchor-links.css sets the heading to display: inline so the link sits at the end of the last line. This site's stylesheet set display: block on the h2 to make the rule above it span the column rather than the width of the heading text, which it did, and which also turned the anchor into the next block in the flow.
Cascade layers are why the override was silent. Starlight ships its CSS in @layer starlight.content and this stylesheet is unlayered, so a plain .sl-markdown-content h2 beats a layered rule whatever its specificity, and the usual reading of a diff for "does this win" gives the wrong answer.
The rule now sits on the wrapper, which is already a block, so it stays the full column width and the heading stays inline. No margin-top was moved with it, deliberately: Starlight gives the wrapper 1.5em, which is what renders today because the heading's own 2.75rem collapses into it and loses, and restating 2.75rem on the wrapper would have won and tightened every heading on the site by 8.5px. The h3 rule was deleted rather than relocated for the same reason.
Measured before and after against the same build, at 1280 and 390 pixels on the manifest reference, the agents concept and the quickstart: the gap above every h2 is 52.5px and 43.5px in both, the rule is 656px and 358px wide in both, the gap above every h3 is 43.5px and 36px in both, and the anchor moved from 24 of 29 headings off their line to none of them.
Runs, Environments and Audit showed the first page of a list and presented it as the whole list.
All three routes paginate. runs.recent takes before and returns a nextCursor, environments.list takes cursor and returns one, and audit.list pages by seq under the name before and returns a bare array. Each console page declared the cursor in its type and read none of it: the word nextCursor appeared once per file, in the generic, and nowhere else.
So an organization with 200 runs saw 50, in a table that looked complete. That is worse than a screen that looks broken, because the reader acts on it: somebody checking whether a run happened, or whether an environment was torn down, got a confident wrong answer.
Each list now pages, and its footer says which of the two things is true. It renders in both states rather than hiding itself when there is no more, because "All 24 runs." is the only place the screen ever says a list is complete.
Refreshing a screen in the console blanked it to a skeleton, and a refresh that failed replaced correct data with a full page error.
useApi's reload() reset the hook to {status: "loading", data: null}, which is right when the dependencies change, because another environment's rows have nothing to do with this one's, and wrong when the same question is being asked again. The reader lost what they were reading for the length of the round trip, and if the second answer failed they lost it permanently: Loaded renders the error branch over the whole screen, so a correct table became "That did not load" because a retry went unanswered.
Where it lands is the Plan page's plan change, which reloads immediately after the mutation and is pressed by somebody who has just been told their quota changed. It does not reproduce on a fast local control plane, which is why it survived. usePages had the same reset, and all three of its callers reload after a mutation.
A reload now keeps what is on screen, reports refreshing while it is in flight, and puts a failure in refreshError instead of error. Loaded renders that as a strip above the content saying the rows are the last answer, with the same Try again, so every screen in the console gets the behaviour without a per page change. A dependency change still resets, because then the held data really does belong to a different question, and the two are told apart by comparing the dependencies themselves rather than by trusting the reload counter. Both hooks also carry a request sequence now, so of two reloads in flight the older answer cannot overwrite the newer.
Measured against the built console with the control plane stubbed at 400ms, twenty samples of a plan change on each side. Before: the table blanked during the reload in 20 of 20, and with a failing reload the full page error appeared in 20 of 20 and nothing was left on screen. After: no blank in 20 of 20, and with a failing reload the table survived in 20 of 20 with the strip above it.
Every loading skeleton in the console was zero pixels wide on a phone.
The stacked table layout put justify-items: start on the cell, which makes its one child shrink to fit, in the grid branch and, because Chromium aligns block level children the same way, in the unlabelled branch too. A percentage width inside a shrink to fit box has no basis to resolve against, so it computes to zero and the box shrinks to that zero. Every bar in TableSkeleton is a percentage, so all 22 of them, on seven pages, rendered as a stack of empty boxes: 33 bars on /runs, every one 0px wide at 390px, against 54.8 to 168.2px at 1280px where nothing stacks.
The declaration was there to stop a badge stretching to the full width of the value column. It never did that, because the wrapper that made it unnecessary landed in the same commit: the badge is inline-flex inside the wrapper, and the four state badges on /runs measure 51.6, 57.8, 65.9 and 80.8px wide with the declaration and without it. Removing it leaves 768px and 1280px identical to the pixel and gives the phone back a skeleton shaped like the rows that replace it.
Every route in the console had the same document title.
All ten rendered <title>Antifailure</title> while their headings read Environments, Runs, Audit, Provider keys, Masking, Members, Network, Plan and Approve a terminal. This is a tool people keep open in several tabs while a run goes, so every tab, every history entry and every tab search result was the identical word.
Each route now carries its own name in a layout.tsx beside its page, and the root supplies the suffix. A layout rather than the page, because every page in the console is a client component and cannot export metadata; static metadata rather than a title written after hydration, because the console is a static export and the title belongs in the HTML.
The runtimes table on Environments, the approval queue on Network and the plan comparison showed no column headings on a phone.
A table stacks into one record per row below 640px and repeats each column's name from the label its cell was given. Eleven of fourteen tables pass one. These three passed none, so at 390px the approval queue read api.stripe.com, MOCK, acme/checkout, ada-490360, 12m ago, Approve: six bare values on the screen where somebody approves an egress rule. The plan comparison read free current, 3, 2, 1 GiB, ROOM FOR MORE, --, on the screen where somebody decides to pay. In all three cases a correctly labelled table sits inches away on the same page.
A workflow whose Stripe call was invented by a model reported PASSED.
The promise is made in five places, including the product page and the comment at the top of the proxy's own synth handler: a workflow that touches a synthesized response reports unverified rather than passed, because what it saw came from a model rather than from the thing under test. It was kept nowhere.
Three separate breaks in one chain, and each one alone was enough.
The sidecar wrote synthesized: true on the decision and set an X-Antifailure-Synthesized response header. local.Decision had no field for it, so json.Unmarshal dropped it silently, and every consumer downstream saw a synthesized call as an ordinary allowed one. pack and fixture were dropped the same way, against the sidecar's own comment that a mock which cannot name its fixture is a mock nobody can debug.
The runner's synthesized-response cause did map to unverified. Its only producer fired when nothing on the page confirmed or contradicted an expectation, which is a page nobody could read and has nothing to do with a synthesized response. So the one cause that produces unverified was already spoken for, and the real case had a mapping, a test, and no producer at all. That branch is page-unreadable now, which is what it always was, and the two have different remedies: a model key for one, a sandbox credential or a fixture for the other.
And nothing connected the two halves. The runner drives a browser; a synthesized call is made by the APPLICATION, server side, and never appears in anything a browser can see. Only the proxy knows and only the engine reads the proxy, so the engine is where the verdict is now decided. The runner emits the window a workflow ran in, and a synthesized decision inside it downgrades that workflow's pass to unverified, naming the hosts.
Attribution is by window and honest about its limit: a synthesized call inside no workflow's run is reported as a note on the run rather than pinned on whichever workflow was nearest. A failure is never downgraded, because the application doing the wrong thing with an invented answer is a real finding and hiding it behind our own escape hatch would be worse than the bug.
af net log says which responses were invented and which fixture answered a mock, both of which it had been recording and not showing.
A change that failed 100 percent of requests under load merged green.
af ci --load read both thresholds out of the manifest and passed one of them on: p95, _ := o.Thresholds() and then res.Breaches(p95, 0). Breaches short circuits on errorRate > 0, so a zero limit builds no error rate breach at all. The list of regressions came back empty, policy.load_regression was never consulted, and the check passed, while af load run on the same manifest and the same result exited non zero. Two commands, one manifest, opposite answers.
It was invisible for a specific reason. p95_increase is refused under the access_log and none sources, so those projects called Breaches(0, 0) and got nil from a comparison with nothing to compare. This repository's own manifest is source: none with error_rate: 0.02, so the dogfooding that would have caught it could not.
af ci also says when a p95 threshold was in force and no route had a baseline to compare against. af load run has reported that for a while, on the argument that a threshold which measured nothing is not a threshold that held, and af ci said nothing, so a report that could not compare read exactly like one that compared and was happy.
And the third thing that block dropped: the second return of o.Load, the routes the generator would not send because nothing in the manifest named them safe. It was discarded at the call site, so af ci --load said the same thing whether the safe list let through every route or one out of forty. The request count cannot show it, because 500 requests at one route looks like 500 across forty. af load run has always reported it.
Nothing checked the values in the github block, so fork_policy: nevr, mode: sideways and teardown_on: [closed, merged] all loaded without a word.
github block, so fork_policy: nevr, mode: sideways and teardown_on: [closed, merged] all loaded without a word.The JSON Schema has carried those enumerations from the start, and the manifest reference is generated from it, so the page told a reader the values and nothing held them to it. normalize fills an EMPTY fork policy and leaves a misspelt one alone, and everything downstream reads an unrecognised policy as label. That is the safe direction and it is silent: somebody who writes nevr gets label behaviour, believes forks are refused outright, and is running a stranger's code behind one label instead. A typo in a security control has to be an error.
Two pages in this repository were shipping teardown_on: [closed, merged], and one of them told the reader to add it so that "a merged pull request gives its branch back", which is wrong twice: the key is read by nothing, and teardown happens anyway.
manifestcheck could not see any of it. The gate exists precisely because a documented manifest the engine refuses is invisible to vale, cspell, lychee and claimcheck, and it only ever asked whether a KEY exists. It reads enumerated values now: against the tree as it was, it reports four problems on two pages where it used to report none.
Accepting new permissions on the GitHub App installation left the control plane using the token it had already minted, which still carried the OLD scopes.
An installation token is cached for its full hour and GitHub invalidates the outstanding ones the moment a grant changes, so the App went on refusing writes it had just been granted. On 2026-08-31 an Actions write grant accepted at 00:38:54Z was answered with 403 until roughly 01:36Z, complaining about a permission that was already in place.
InstallationTokens.forget had existed since the App client was written and had no callers anywhere in the tree. An installation delivery now drops the cached token before it handles anything, for every action rather than for new_permissions_accepted alone: suspend, unsuspend and deleted each change what a token is worth, and dropping one that did not need dropping costs a single mint.
The published retention numbers are now read from one place and compared to the Terraform that sets them by a test, rather than kept in step by hand.
Seven legal claims were found false in one night and every one of them was true when it was written, so the fix is a gate rather than seven edits.
A data subject reading the retention page was told their data is gone after fourteen days of point-in-time recovery. Production runs thirty-five.
The privacy and subprocessor pages no longer say there is no billing and that nothing can send mail. Both were true before the billing and sign-in-link work landed. They now separate what the software contains from what a deployment switches on, because "we do not use Stripe" and "Stripe cannot be used" are different promises and the pages were making the stronger one.
The retention page now says what happens when somebody asks to be removed: the personal fields are erased and the account row is kept by choice, not because the database refuses. Removing a provider key is revocation rather than deletion, waitlist removal is carried out by hand, and operational log retention is published for the first time.
Following a link into the console while signed out lost the page you were going to.
The sign-in screen sent you to the dashboard, so every deep link this product publishes, including the environment link in a pull request comment, was a link to the front door. The device approval page had always done this correctly and the rest of the console had not.
Both ways in now carry the return path, query string included: /environments?env=af-1234 and /environments are different pages to somebody who followed a link to one of them.
The console's teardown button set a column and nothing anywhere read it.
environments.teardown marked the row torn_down and returned, with a comment saying the engine holding the containers reads this and does the removing. Nothing read it: not the engine, not a sweeper, not anything. The containers kept running and the console said they were gone, which is worse than the button not existing, because somebody who saw "torn down" stopped looking.
Teardown is now a durable request with a lease, an attempt count and an acknowledgement. The environment's row moves only when the runtime confirms it: the workflow run holding it reached a terminal state at GitHub, or the engine reported the teardown itself. Cancelling that run is the only route this control plane has into the machine holding the environment, and it is enough because af ci tears down on every exit including a cancelled one.
Where there is no route at all, the request is given up on after its attempts and says so, naming af down, rather than reporting a cleanup that never happened.
examples/github-workflow.yml, the file the documentation tells you to copy to .github/workflows/antifailure.yml, ran af ci --output report.md.
examples/github-workflow.yml, the file the documentation tells you to copy to .github/workflows/antifailure.yml, ran af ci --output report.md.That flag was renamed to --report when --output became the persistent format flag, and the example was not updated with it, so the copied workflow stopped at the output format "report.md" is not recognised before af ci did any work. Nothing brought an environment up and no comment was ever left on the pull request.
The gate that checks documented commands against the real command tree could not have caught it twice over: it reads only docs/src/content/docs, and it asked whether a flag exists rather than whether the command accepts the value. --output does exist. It now also reads the workflow files under examples/ and .github/workflows/, and it parses each invocation and runs the same validation the binary runs, so a flag that exists and refuses its value is a finding rather than a pass.
A port that was free when the local runtime reserved it and taken by the time the daemon bound it failed the whole of af up with a message about a bind.
af up with a message about a bind.The allocator's comment had described the retry that closes that race since it was written, and only the database provider ever had one; the forwarder that publishes a service now retries on a fresh port too, up to three times, and still reports a permission refusal or an unreachable daemon on the first attempt rather than retrying it.
AF_PORT_RANGE_START moves the range both allocators start from. af doctor had named that variable in its remediation since it was written and nothing anywhere read it, so a machine that had run out of ports had no way to move them. af doctor also probes the range services are published on rather than only the range databases use, and AF-RUN-009 no longer suggests a manifest field that does not exist and would fail validation.
A load manifest could set source: access_log together with thresholds.p95_increase and the engine accepted both.
source: access_log together with thresholds.p95_increase and the engine accepted both.A combined format log line carries no duration, so every route read from one arrives with no baseline, and the threshold evaluation skips every route without one. The headline check never fired, never errored and never said anything, and the run went green having compared nothing.
The manifest now refuses p95_increase under access_log and under none, naming otel as the source that carries a baseline. The engine no longer applies its own 0.25 default under those sources either, because filling in a threshold it cannot evaluate is the same defect with the engine as the author. What remains is a trace export whose routes were all seen too few times to earn a baseline: af load reports that as AF-LOD-016 and exits non-zero, the way a scenario that proved nothing already does.
thresholds.query_count_increase is refused outright. It reached the schema, the Go type and the normalizer, and nothing anywhere counts statements for a load run, so it never affected a verdict. insights.query_regression is the check it describes, judged by insights.regression_factor.
The headline capability had never once run end to end, and three separate defects each stopped it on their own.
A persona whose login is none never signs in, so it has no account to create, but persona provisioning insisted on somewhere to write one and refused an application that has no users table. examples/go-api is exactly that shape, a JSON API with a schema of customers and orders, so its one workflow had never executed. A missing users table is fatal now only when some persona actually needs an account.
A magic link and a one time code are both single use, and the runner read the inbox with no floor. waitFor looks at what already arrived before it waits, which is right for a message that need only exist and wrong for one that has to be new, so a second attempt matched the first attempt's message and followed a token the application had already spent. The inbox is watermarked before the button is pressed now. It was a race rather than a certainty, which is how it survived: driving this repository's own six workflows produced two that signed in and four that did not, out of one code path in one run.
An application that renders on the client has nothing on it when the document finishes parsing, and the runner snapshotted it there. This repository's own console shows the single word "Loading" for about a second and a half after the sign-in callback lands, so two workflows signed in successfully and were then reported as having proved nothing, on a page a second away from showing everything they were asked to look for. Every read of a page now waits for it to stop fetching first.
examples/go-api, examples/django-api and examples/next-app carry workflows a browser can actually drive, with expectations that name words the page actually shows. The two Next and Django examples declared no workflows at all, and go-api asked an agent to place an order through a page that does not exist.
Four of this repository's own six workflows passed against a page whose every table had failed to load.
Their expectations named card titles, form labels and words out of the page's own lede, all of which render whether or not the data arrived. Renaming the table each page reads out from under it left every expectation satisfied and the run reported six green over four cards reading "That did not load". Each workflow now also names something that exists only once the rows rendered, and each was watched to fail with its own table taken away.
examples/django-api and examples/next-app declared no workflows at all.
examples/django-api and examples/next-app declared no workflows at all.Each reported "Antifailure: Nothing ran" while the check went green, because a run that never reaches an agent still exits zero. Both now carry a workflow that reads the page the application exists to serve, and both have been run against a real browser over a real branched database rather than written and assumed.
a-viewer-cannot-edit-policy, in this repository's own manifest, expected two strings the owner also sees. A viewer who could propose a rule and approve every pending one would have passed a workflow named for not being able to. It names the sentence the console shows exactly when the propose form and the approve column are withheld, so the two network workflows now fail when driven by each other's persona.
THIRD_PARTY_NOTICES.md in the repository listed 40 Go modules where the engine links 89.
THIRD_PARTY_NOTICES.md in the repository listed 40 Go modules where the engine links 89.Its generator ran in exactly one place, the release workflow, which writes the file into the published artifact rather than comparing it against the committed copy, so the committed one was free to drift and did, by 49 modules. It is regenerated, and just _generated and the CI job that runs it now regenerate it on every pull request and fail on a difference.
The generator also answered for whichever platform it happened to run on. A release publishes darwin and linux archives, and modernc.org/libc links github.com/ncruces/go-strftime on darwin and not on linux, so the notice built on the release runner attributed 88 modules while the two darwin binaries in the same release linked 89. It now takes the union over the platforms in the release build matrix, read from the workflow so that adding an architecture cannot leave the attribution behind.
The verdict card on the home page no longer scrolls its log rows forever, and just motioncheck refuses any page whose built stylesheet or HTML animates without stopping, including animate-pulse and animate-ping.
just motioncheck refuses any page whose built stylesheet or HTML animates without stopping, including animate-pulse and animate-ping. af login with no arguments now signs in to https://app.antifailure.dev.
af login with no arguments now signs in to https://app.antifailure.dev.It had its own spelling of the hosted instance and it had drifted to app.dev.antifailure.dev, which is the staging deployment, so a plain af login signed a terminal in to staging while everything that sends events went to production.
af env pull now uses the credential af login stored when no AF_CONTROL_PLANE_URL is set. It resolved the origin before the default was filled in, so somebody holding a perfectly good credential was told AF-CPL-001 No control plane token is configured and sent off to create a second one.
af up no longer panics when it fails before it has anything to report.
af up no longer panics when it fails before it has anything to report.Every failure inside the session open, which is the state directory, the branch lock and the journal, returned a nil result that the failure path then read a field off, so a Go stack trace replaced the error that had just been diagnosed and the exit code a script reads.
A failed af up also now says what it left standing and how to remove it. The environment is deliberately not torn down on failure, so that there is something to look at rather than destroyed evidence, but nothing said so: the next step on each of the forty eight codes af up can exit with points at the failure, so somebody was told to read a log while containers and a database branch sat there, without yet knowing af down exists. The count is read from the inventory, so a run that created nothing stays silent.
A manifest with several problems reports them as a list again.
The wrapping introduced with the terminal width work collapsed the newlines the validator had already put between them, so two independent problems ran together into what looked like one sentence, at the moment somebody is working out what is wrong with their file.
A service whose name is wider than af explain's gutter now puts the name on its own line with its facts hanging underneath, instead of pushing them off the right margin and leaving the row misaligned against its own continuation.
af golden list says "not recorded" where a golden carries no rules hash. A blank cell under that heading reads as none, and the truth is that nobody knows whether the rules have changed since.
af down wraps the reason a resource could not be removed, which carries a provider's own error text and is the longest line on the page.
af explain no longer runs past the right margin.
af explain no longer runs past the right margin.A migrate command, a seed command, a rule's note, an invariant's description and the resolved secret sources all wrap under their own column, and the whole page fits at 40, 80, 120 and 200 columns.
A support bundle renders at the default width whatever terminal produced it, so two bundles of the same manifest are the same file.
Help text now fits the terminal it is printed on.
Every command's description, its flag table and its list of subcommands were printed at whatever width they were written at, so at 40 columns every help page in the tool ran past the margin and the terminal broke it mid word, and at 200 it was a ribbon down the left of an empty screen.
af status says which branch it is reporting on, not only the environment identifier, which is that branch with the punctuation taken out and a hash on the end.
The command line now measures the terminal it is writing to.
Text that used to run past the right margin, or wrap wherever the terminal happened to run out of room, now breaks at a word boundary with a hanging indent: error messages and their next steps, doctor's remediations, and every status line's detail.
Tables no longer misalign when colour is on. Widths were measured in bytes, so the nine bytes of escape sequence around a coloured cell counted as nine columns and pushed every heading to the right of the values beneath it.
Numbers in tables are right aligned, one column per table absorbs a terminal too narrow for the whole thing, and a table that still cannot fit is stacked as one labelled block per row rather than being left to the terminal's own wrapping.
af down --help said it deletes "every resource the environment created" and then named four kinds.
af down --help said it deletes "every resource the environment created" and then named four kinds.It removes what the journal recorded, which is not the same set, and anything this build has no way to delete is left recorded and reported rather than removed. The help says that now, in a form that cannot fall behind the code, and points at af status and the pending list for the answer rather than at a sentence.
plan_regression is described as three plan regressions rather than two. cost_increase was named nowhere a user could read: not in the manifest schema, not in the generated reference, not in the verdicts guide.
af init reported one application as two services and then refused to write anything, which stopped people at the third command of the install path.
af init reported one application as two services and then refused to write anything, which stopped people at the third command of the install path.A Dockerfile beside a package.json whose name is not the directory name is the median containerised Node repository. Detection keyed services by name and every source spells the name differently, so dashboard/ produced a service called dashboard from the Dockerfile and one called bonfire-dashboard from the package, both on port 3100. The manifest validator correctly refused two services claiming one port, and the refusal told the user to fix a line in a file af init had declined to write. Running it again could not help.
A service is now identified by the directory it is built and run from plus its role, and the evidence from every source in that directory merges into it. One source declaring two services in a directory still means two services, so a compose file with a web and an admin container on one build context is left alone.
Four errors on the same path were dead ends and are not any more.
af init --non-interactiveanswered a question that has no default with AF-MAN-004, whose next step is to pass--non-interactive. AF-DET-004 names the question and the exact--answerflag that settles it.- A draft that fails validation now returns AF-DET-005, which says plainly that nothing was written, rather than the validator's own AF-MAN-002 pointing at a file that does not exist.
--answeronly ever reached a question, so the override AF-DET-005 tells you to reach for did nothing when detection had read the value with confidence. It now overrides a detected value too, and an id that names nothing is refused with the ids that would have worked.af init < /dev/nullasked every question into a stream nobody was reading and took the defaults in silence, because the terminal test read the character device bit, which/dev/nullhas.
Separately, af init wrote target: build for the common multi stage Dockerfile whose builder is named and whose final stage is not, so af up would have built the stage that compiles the application instead of the one that runs it. That one is worth reading twice, because nothing failed: the manifest validated, af init succeeded, af up succeeded, and the environment quietly ran the build stage.
Two service names change as a result of the fold, and only for a repository being set up for the first time. A service name is a hostname inside the environment and it also prefixes the environment name, so it is what you see in af env list and af down, not just an internal identifier. Nobody with a committed antifailure.yaml is renamed under them: af init refuses to touch an existing manifest without --force.
af init also now works out which directory a Dockerfile is built from, rather than always using the repository root. A Dockerfile at dashboard/Dockerfile is conventionally built with dashboard as its context, which is what docker build dashboard does, and building it from the root either fails on a path that is not there or, with COPY . ., succeeds and produces an image assembled from the wrong directory so the failure moves to startup. Its COPY lines say which context it expects, so those are read: a path that exists beside the Dockerfile and not at the root means the directory, one that exists only at the root means the root, and anything else is asked rather than guessed. A monorepo image that reaches a lockfile at the top of the tree is unchanged, which is the case that made a new default the wrong answer.
Where the COPY lines settle nothing there are two different ambiguities and they do not get the same answer. When every source resolves in both places a build from either works, so a repository doing that today is building from the root and succeeding, and the root stays what an unattended run takes. When nothing resolves anywhere, which in practice means the only instruction reading the context is COPY . ., there is no evidence for the root at all and the root is the case that succeeds while assembling the image from the wrong directory, so the default is the Dockerfile's own directory. Both are asked on a terminal and both are reported as assumptions otherwise.
Where that inference is wrong, or where the manifest was written by hand, a failed build now returns AF-BLD-005 naming build.context instead of leaving the reader with a path that does not exist inside a container.
AF-DET-003 is deleted. It was reserved for a port collision and never emitted by anything. The sentence it would have printed is produced by the manifest validator, which reaches the user as AF-MAN-002 from af doctor and af up, and as the detail inside AF-DET-005 from af init.
The installer decided af was not on your PATH, printed the export line that would fix that, and then printed three bare af commands to run next anyway.
af was not on your PATH, printed the export line that would fix that, and then printed three bare af commands to run next anyway.So the first three things a new user was told to do were af doctor, af runner install and af init, and all three answered command not found. The export it printed was session only besides, so a reader who pasted it lost af again on closing the terminal, with nothing having said that would happen.
The installer now puts af on the PATH rather than explaining how to. It appends one line to the startup file the login shell actually reads, prints that line and names the file, so the change is visible and deleting the line undoes it. AF_NO_MODIFY_PATH=1 declines it in advance, which is the only way to ask when a script piped into sh has no stdin left to prompt on. Running the installer again does not add the line twice.
The terminal that ran the installer cannot see a file written a second ago, so it ends with one line to paste that puts af on that shell's PATH and runs the first of the next steps. Every branch ends in commands that work: bare where PATH was set up, and the full path where it was declined, could not be written, or the login shell is one the installer cannot name a startup file for.
zsh gets .zshrc under ZDOTDIR, bash gets .bash_profile on macOS and .bashrc on Linux, fish gets fish_add_path, and an unrecognised shell is told so rather than having a file guessed for it.
af runner install, the second command the installer prints, could not succeed on any machine installed this way. It searches for a runner source beside the binary, at $PREFIX/bin/runner and $PREFIX/share/antifailure/runner. The installer put it at $PREFIX/runner, which is where af looks for an installed runner, so the command answered AF-AGT-004 and advised running itself. The same placement made af runner check report ok runner on a tree with no node_modules, so the real breakage surfaced later inside af test. The source now lands where the engine was already looking, and a half installed tree left by an earlier installer is cleaned up. The installer also names node and the version it needs when node is missing, rather than leaving that for af runner install to discover.
af runner check reported ok runner on that tree, because it stat'd src/main.ts and stopped there. It now reads the runner's own package.json and reports the source, every declared dependency against node_modules, the node version against the engines.node range, and the browser, each with a remedy that fits it rather than one "run af runner install" printed under every failure including a missing node. It still does not claim the runner executes, because knowing that means starting node and launching a browser, and anything it cannot determine reports as not checked rather than as ok.
In GitHub Actions the installer writes to GITHUB_PATH and touches no profile. The documented workflow needed that and did not have it: every step gets a fresh PATH, so af ci in the step after the install was never going to be found.
The gate protecting all of this could go green without running, and it took a deliberate break to find out.
just test-tools is go test ./... with no -count=1. Go's test cache keys on the test binary, its arguments, the environment it reads, and the files it opens under its own module. tools/installsh executes install.sh through sh, so nothing in the package ever opens it, and install.sh lives at the repository root, outside tools/, so it would be invisible to the cache even if something did. Adding an os.ReadFile of the script to the test does not fix it, which was the first thing tried: the read is outside the module and the cache ignores it.
The observed failure: install.sh was edited so it never wrote a shell profile at all, and just test-tools reported ok (cached) for tools/installsh. A gate that goes green on a broken subject is worse than no gate, because it actively certifies the thing it cannot see.
-count=1 in the recipe and in the CI step that has to match it. Any test that shells out to a file outside its own module needs -count=1, or it will cache against a subject it never watched change. There is no way to express that dependency to the cache from inside the test.
Shell coverage, so nobody reads the test names and concludes more than was proven: zsh, bash, ksh and an empty SHELL are proven by running the installer and then starting a genuinely new shell of that kind. fish is the one shell not proven by execution, because fish is not installed on the machine this was built on. Its branch is proven by running the installer under SHELL=fish and asserting that ~/.config/fish/config.fish is written with the fish_add_path line, which is a weaker claim than the others on this list.
The site navigation did not mark the page you were on.
On the pricing page the word Pricing rendered exactly like Docs and Writing beside it, and the same on every other page with a plain nav link. It has been that way for as long as the header has existed, and it went unreported because moving the mouse over a link darkened it correctly, so the navigation looked right the moment anyone touched it and was wrong only at rest.
Five more of the same kind are fixed with it. A live environment card in the opening animation rendered its live state and its idle state in the same grey. Two pills that mark a problem got their red text and their red outline but kept a neutral grey fill, so they sat next to a neutral pill looking almost the same as it. A card that is meant to sit back rendered at full strength. One of those pills also carried a red used nowhere else on the site and now uses the shared one.
All six had the same cause. A colour written on an element does not replace a colour the component already set: both land on the element and the browser picks between them by the order the stylesheet happens to be written in, which has nothing to do with which one the author meant. Each is now written as a choice between colours rather than one laid over another, so there is nothing to pick between.
A new check reads the built pages and refuses a class that another class on the same element beats, so the next one fails on the pull request that adds it rather than shipping and going unnoticed for a year.
The small mono labels on the product pages measured 3.32:1 against the page and 3.27:1 against the sage bands, under the 4.5:1 minimum, so the kickers over the copy, the comparison column headings and the trust boundary annotations were harder to read than the text they introduced.
The label now carries a tone. Labels that draw keep the light grey they had, because inside a mock terminal or a simulated report frame the grey depicts a screen rather than talking to anybody, and darkening it flattens the picture. Labels that address a reader take a darker grey that measures at least 5.49:1 on every surface the site puts one on. Twenty-one of the seventy-six labels talk to a reader; the other fifty-five are part of a drawing and look exactly as they did.
Six labels carried a colour that never reached the page, because the class was written beside the component's own colour rather than in place of it and lost the order the stylesheet is emitted in. Three asked to be lighter and were not, and are left looking as they always have. The other three asked to be green: on the masking panel, the heading of the sanitized column, the note that uniqueness survived, and the note that the subset's joins are valid were the only markers on that panel not carrying the signal they were written to carry. They are green now.
Two things on the architecture page that the darker labels exposed are fixed with them. The rule between the isolation minimums in force today and the ones that are only designed was never painted, so the two lists ran together. The trust boundary marker on the plane divider covered the first word on each side of it, and the data plane's first item read "rovisioning" at every desktop width.
The agents could not sign in to an application whose sign-in form is not at /login.
/login.The runner navigated straight there, and the signInPath option that looked like a way to change it had no caller anywhere, so the path was in effect hardcoded. It now searches for the form, starting with the workflow's own start path, which is where an application that answers every protected route with its sign-in screen actually shows it, and a run that finds no form anywhere reports which addresses it tried rather than the regular expression it gave up on.
Two more, found underneath it. A field whose label carries its own hint text has one accessible name made of both, so the anchored patterns the runner matches fields by could not see it; the exact name is still preferred and the looser one is now the fallback. And the vocabulary for the button that asks for a sign-in link did not include "Send a sign-in link".
In the console, a Field's hint and error moved out of the <label> and became its description. They were part of the field's name, so every screen reader announced the sign-in field as "Email address We send a link that signs you in. No password."
Each of the three would have blocked every workflow on its own. All six workflows in this repository's own Dogfood run had been blocked by them since the console was folded into the API.
fixed · changed Two prefers-reduced-motion blocks in the marketing site's stylesheet named fifteen selectors between them and eleven of those matched no element on any page.
prefers-reduced-motion blocks in the marketing site's stylesheet named fifteen selectors between them and eleven of those matched no element on any page.fixed
The first block, which was the whole of the sign-in screen's motion guard, matched nothing at all. Accessibility work that has silently stopped running reads exactly like accessibility work that still runs, which is what makes this worth more than the bytes it saves: the file said the site respected a reduced-motion preference in places where there was nothing left to respect it for.
The guards are now the four selectors that still reach an element.
changed
Eighteen classes and ten keyframes in the same stylesheet rendered nowhere. They were leftovers rather than anybody's next step, and the history says so rather than the file: nine of them lost their consumer in commits whose subject lines are about deletion, one of them "delete the half of the site nothing renders". The four that never had a consumer at all are led by the sign-in screen's animated bars, and that screen shipped with a honeycomb behind it instead, so a different treatment was chosen and built rather than deferred. The background-pattern family they belong to has one survivor. Removing them is recoverable from history; leaving them meant the next person reading the file could not tell which half of it was live.
caret-live was the inverse case, a class name applied to an element in the caret component and never styled anywhere, since the day the site was first committed.
One keyframe that looked equally dead is not, and it is worth naming because the search that found the others could not see it: wt-sheen is reached from an inline style in components/IdeSection.tsx, so nothing in the stylesheet mentions it. It plays once and stops, and it stays.
The documentation said we catch six classes of unsafe migration.
We catch seventeen. Every rule added since that sentence was written landed in the code and in the lint rules table and in no sentence that counts them, so the reference, the verdicts page and the manifest schema all understated what the migration lint does by eleven rules. Detection is thirteen analyzers, not twelve, and nine migration tools are rehearsed, not seven.
A new gate, just constcheck, reads each of these sets out of the Go source and fails the build when prose states a count that is not its real size. It also reads the three documentation tables that are the reference for a set and checks them row for row, because the page that lists all seventeen rules correctly states no number anywhere, so a count rule alone would never reach it.
Four claims the site made that the engine does not.
The flagship migration finding said "adding a column with a default rewrites the table". Postgres stopped rewriting for a constant default in version 11, af init writes Postgres 17, and the engine correctly reports no rewrite and emits no lint finding for that statement. The example is now a type widening, ALTER TABLE subscriptions ALTER COLUMN plan_id TYPE bigint, which does rewrite and which alter_column_type names, so every beat of the film is something the product measures.
"84 statements queued behind it" is gone from the home page, /product, /product/migrations and /solutions/devtools. insights.LockHold records one boolean, Blocking, whether another session was ever seen waiting. There is no count of waiters and no list of their statements anywhere in the engine.
The load page had its two shape sources inverted: it disclaimed OpenTelemetry, which is connected and is the only source that carries a p95 baseline, and credited that baseline to an access log, which carries no duration at all. It also said the manifest accepts Datadog and New Relic, which the schema enum refuses at parse time.
/product/twins named thirteen orchestrator states that existed in that component and nowhere else in the repository. It now names the six in the control plane's environment_state enum, and each phase is named by an event the engine emits. The teardown row cited AF-RUN-045, which only the Kubernetes runtime raises, and now names the label-scoped teardown the Docker conformance suite proves.
The analyzer and lint-rule counts, twelve and six against a real thirteen and seventeen, are replaced by the property rather than by a new number.
The lifecycle rail on /product/twins named five events the engine emits rather than six database states, two of which are unreachable.
/product/twins named five events the engine emits rather than six database states, two of which are unreachable.The panel first listed thirteen invented state names. Correcting them to the six values of the control plane's environment_state enum was still one level of the same mistake: queued has no entry in the engine's controlplane.typeMap at all, and sleeping maps from events.EnvSleeping, which is declared, described, and emitted by nothing. An environment can be observed in four of those six.
The rail now names env.creating, env.ready, env.destroying, env.destroyed and env.failed, all five of which internal/env/env.go emits, so a reader can run af up and af down and watch each arrive.
The four phases carry no identifier. Labelling one per phase with an event was the obvious fix and it was wrong: the Run phase's would have been agent.verdict, and the whole agent.* family has no emitter either, so it would have put a fourth invented identifier on the page while removing thirteen.
The migration lint claim is also rewritten rather than merely uncounted. It said six rules where seventeen ship, and cli/gate.go turns every one of them into a pull request finding under its own rule name with its fix attached, so the sentence understated the product by eleven classes of defect.
"Continue with GitHub" on the marketing site pointed at app.dev.antifailure.dev, the staging control plane, which carries a different OAuth application and a different database.
app.dev.antifailure.dev, the staging control plane, which carries a different OAuth application and a different database.Every invited person who signed in from antifailure.dev landed on staging. The origin now lives beside SITE_URL in www/lib/site.ts, reads NEXT_PUBLIC_CONTROL_PLANE_URL, and falls back to https://app.antifailure.dev, so a build with nothing configured is a production build.
Six more claims the site made that the running system does not.
api_key was drawn as deleted. The default rule for a key-shaped column is hash_hex, not nullify: the value is replaced by a keyed hash of the same length. Only session_token is nullified. The free-text panel showed surgical redaction inside a sentence; free_text replaces the whole field with synthetic prose of the same length, which is safer and is now what the panel shows. The masked addresses were at invented domains that anybody could register, where the transform always writes example.test, which is reserved.
Subsetting was described as the default on /product/safe-state and as a built cost control "by default" on /product/architecture. subset.enabled defaults to false and af explain prints "subset off, the whole database is masked".
/product/firewall illustrated a direct-IP attempt as a row in the decision log. A connection to a public address never reaches the gateway, because the twin has no route to one, so it is blocked more strongly than a rule and leaves no row. The ledger row is now a CONNECT to an unlisted name, which the gateway does refuse and does log, and the bypass panel says what actually happens.
Four pages said the control plane was not deployed. It is, at app.antifailure.dev, invitation only, and /sla now describes both environments and what production is configured for rather than describing staging alone.
The site described the egress modes as a set it does not have.
The firewall product page's section title read "Simulate, capture, mock, or deny", where the schema's modes are block, allow, capture, mock, sandbox and synth: two of those four words are not modes and four real ones were missing, on a page in the sitemap. Separately, synth had reached the schema and the proxy and nothing that describes them, so the README, llms.txt, the product FAQ, the route metadata behind the site's JSON-LD and a blog post had each settled on five. The homepage firewall film labelled its last rule *:deny, and the validator refuses a * rule in any mode but block, so the label a reader was most likely to copy could not work. The manifest reference gave egress.default as block or allow where the schema gives all six.
The manifest reference had the same defect for the same reason. It gave egress.default as block or allow where the schema gives six, build.strategy as three of four while documenting the fourth key four rows further down, and database.provider as two of four while the engine constructs all four. The documentation index said every reference page is generated from the thing it documents and gated against drift, and named that page as one of them. Three of the four are genuinely checked; that one is written by hand and nothing reads it, which is why the rows drifted under a claim that they could not. The index now says which three are checked and points at the generated schema page as the one to trust.
tools/modecheck is the new gate. It reads the schema rather than keeping a second copy of anything. In prose it fails a document that states a count that is not the real one, names something that is not a mode alongside things that are, or promises the whole set and then lists part of it. In the reference tables it fails a cell that claims to be listing a key's allowed values and lists them short, matched to the schema by the heading nesting. It does not check every closed set the schema declares, which is not reliable when the values are ordinary English, and its output says so. Run against the tree as it stood, it finds all thirteen.
A migration could wait forever for a lock, and waiting is worse than failing.
Migration 0018 takes ACCESS EXCLUSIVE on network_rules and SHARE ROW EXCLUSIVE on users, and the revision still serving traffic writes to both. A lock request that cannot be granted queues, and every later request queues behind the request rather than behind the table, so one ordinary transaction that happens to be open on users at the wrong moment turns a two second migration into a sign-in outage lasting as long as that transaction does. Nothing bounded it: lock_timeout, statement_timeout and idle_in_transaction_session_timeout are all zero on the flexible server.
The migration runner now gives up after three seconds of waiting. Three seconds because the number separates two populations rather than measuring one: an uncontended deploy is granted its locks in milliseconds, and a blocked one is blocked by something unbounded. It is not tied to how long a migration takes, because lock_timeout bounds the wait and not the work.
statement_timeout is set beside it at five minutes, for the same outage from the other end: a migration whose own statement never finishes holds the lock itself, and a lock timeout is blind to that because the lock was granted. Five minutes is above 0018's measured 2.7 seconds against 200,000 rows by a hundredfold, and below the bootstrap job's own 900 second timeout, which matters because a job killed by its timeout leaves the lock held on a connection Postgres has not yet noticed is gone.
Both are set on the migration runner's reserved connection rather than on the role or the bootstrap job, because migrate has seven callers and a guard in the job would leave the path the server itself runs under AF_MIGRATE=1 unprotected. Both are set after the advisory lock rather than before: lock_timeout applies to pg_advisory_lock too, so setting it earlier would turn two deploys racing each other into a failed deploy when waiting is the correct answer to that race. Both are reset before the connection goes back to the pool, because a session setting survives that and the next borrower would otherwise inherit a five minute statement budget it never asked for.
A timeout failure now says which migration, that it was waiting for a lock, that nothing was applied so the retry is safe, and the query to find the holder if it happens twice.
The staging control plane stopped answering /readyz because Postgres had no connection slots left: remaining connection slots are reserved for roles with privileges of the "pg_use_reserved_connections" role.
/readyz because Postgres had no connection slots left: remaining connection slots are reserved for roles with privileges of the "pg_use_reserved_connections" role.The deploy script left every superseded revision active, on the written theory that a revision at zero traffic costs nothing. It does not. In Multiple revision mode an active revision keeps min_replicas running, and each of those replicas is a whole control plane process holding a pool of AF_POOL_MAX connections and sweeping the database every five minutes for as long as it lives. Forty six deploys had left forty six active revisions and forty six running replicas against a burstable server that hands an ordinary role thirty five connections.
A deploy now deactivates the revisions it superseded, keeping the one a rollback would shift onto, and then checks the arithmetic it just changed: replicas actually running, times the pool each one holds, against what the server will actually hand out. A shape that cannot fit fails the deploy loudly instead of becoming a 503 at the next traffic peak. The same sum is checked at plan time in Terraform, where pool_max is now set per environment rather than inherited: staging holds five connections per replica against its thirty five, production ten against its eight hundred and forty four.
The connection alert could not have warned about any of this. Its threshold was eighty percent of max_connections, which ignores the fifteen slots Postgres reserves, so on the burstable SKU it sat at forty when the server had already started refusing the application at thirty five. It also averaged over fifteen minutes, and the exhaustion is a burst: every replica sweeps on the same five minute timer, so the measured profile was six to eleven connections for four minutes out of five and thirty three to thirty nine in the fifth, which averages to twelve. The threshold now comes from the connections the server will really hand out, and the criterion reads the peak.
Environments now appear in the hosted console.
Nothing in the control plane had ever inserted a row into the environments table: the projection that events feed was an UPDATE, and the only INSERTs anywhere were the test harness, the staging seeder and the backup drill. So the console's environment list was empty for every real customer no matter what they ran, the expiry it shows was permanently blank, and anything computed from those rows was arithmetic over an empty table.
The row is now created the first time the control plane hears about the environment, from whichever lifecycle event arrives first, and updated by the ones that follow. Every environment.* event the engine sends carries the repository, the branch, the pull request number when there is one, and the lifetime the manifest declared in runtime.ttl, so the console shows a real expiry and a real pull request link rather than empty fields.
Usage is measured from when the environment came up rather than from when the control plane heard about it. Every event that a run emits carries the instant the work began, so an environment whose creating event was lost still bills from before its build rather than after it.
The per-day spend cap and the cost attribution read these rows, so both answered zero for every customer and a cap that computes zero can never trip. They now compute over real environments.
An engine older than this release can still advance an environment but cannot create one, because it does not say which repository it is running against. Rather than dropping such an event, the control plane stores it, counts it as af_ingest_events_total{outcome="unprojected"}, and returns a note on the event saying what is missing.
Four defects in the production Terraform, each of which only an apply could find, and three of which looked like success.
Every terraform plan of the control plane stack exited non-zero, on staging as well as production. The stack's custom_domain_verification_id output returns a value the Azure provider marks sensitive, and Terraform refuses to evaluate a root module output carrying one unless the output declares it. Outputs are evaluated after the resource diff is printed, so the run showed the whole plan, ended with its own "0 to destroy" line, and then failed. The output is now declared sensitive; read it with terraform output -raw.
The custom domain and its managed certificate were ordered the way it reads rather than the way Azure accepts it. Azure will not issue a managed certificate for a hostname that is not already bound to an app in the environment, and says so with RequireCustomHostnameInEnvironment. The hostname is now added first with no certificate, the certificate is issued against it, and the guide has the one command that binds the two, which is the part Terraform cannot own because naming the certificate on the binding is a dependency cycle rather than a mistake.
The certificate expiry probe was created disabled and nothing said so. The provider defaults a standard web test to enabled = false, the availability test set it and this one did not, so the rule watching it was enabled, wired to the action group, and permanently healthy over a probe that never ran. It is the alert guarding the one failure that is otherwise silent, a managed certificate that stops renewing.
The standby's availability zone is now ignored the way the primary's already was. Azure assigns it and this configuration never will, so every plan after the first apply proposed one in-place change forever, and a plan that is never empty is one people stop reading.
backend.production.hcl is git-ignored, which the guide to standing up production already said it was. Only backend.hcl was, so the name of the Terraform state storage account, which this repository deliberately carries nowhere, was one git add away from being published. The plan files that the same guide and the infra workflow both write inside the stack directory are ignored too, because a plan file carries the values of sensitive input variables and one of them is the GitHub OAuth client secret.
The site no longer publishes a second copy of its 404 page.
Next writes the not-found route twice under output: "export": /404.html, which is the one the host serves for a mistyped URL, and /_not-found.html, which nothing routes to, nothing links to, and no sitemap lists. The second was answering 200 with 84KB at an address only a crawler guessing at framework internals would find. The assembly drops it, verified in a browser rather than assumed: with the files gone the 404 page renders and a client-side navigation off it completes, with no request for the removed address and no console error.
just links now assembles the site with tools/site/assemble.sh instead of its own copy of that logic, so the checks the assembly makes can fail on a developer's machine rather than first in CI. The link count is unchanged by the switch.
Five route entries in staticwebapp.config.json declared a content-type that Static Web Apps ignores.
staticwebapp.config.json declared a content-type that Static Web Apps ignores.The platform sets content type from the file extension and a route header gets no say, so /robots.txt, /llms.txt and /llms-full.txt went out as text/plain with no charset and /sitemap.xml as text/xml, while the cache-control sitting in the same headers object was applied correctly every time. The /*.md rule was dead in the same way; it only looked like it worked because mimeTypes already carried .md and quietly did the job the route rule was being credited for.
Nothing was broken for a reader. llms.txt is pure ASCII today, so a missing charset changes nothing, and no crawler distinguishes text/xml from application/xml. What was wrong is that a file said one thing and the server did another, which is a trap set for whoever first puts a curly quote into llms.txt and then goes looking four steps away from the change that broke it.
.txt and .xml move into mimeTypes, which is the mechanism that demonstrably works, and the five dead content-type keys are gone while their cache-control stays. Verified by HEAD against a real deployment rather than by reading the file back: all four now carry charset=utf-8, /index.md is still text/markdown after losing its route header, and every cache-control is unchanged. This also covers the several hundred other .txt files the static export writes, which are the markdown twins and the prefetch payloads, and /blog/rss.xml.
just gate now runs the marketing site's own checks, through a new just seo, and tools/gatecheck can see the family of gates that hid it.
just gate now runs the marketing site's own checks, through a new just seo, and tools/gatecheck can see the family of gates that hid it.npm run check:seo asserts that the shipped site has the sitemap, robots, canonicals, OpenGraph, structured data, markdown twins and skip link it claims to have. Every one of those was absent from production when the check was written, and none of them breaks a build or fails a type check when it goes missing. It has run in ci.yml on every pull request since. Nothing in the justfile ran it, so just gate was green on a tree CI refused, on the surface a customer sees first.
tools/gatecheck could not say so. Its npm pattern wanted test or tsc, so every npm run in every workflow matched nothing at all: not one gate seen three times, but npm run build in www, in docs and in console seen never, and check:seo with them. npm run is now a gate family, every script of it. npm test and npm run test are one gate, because they are one command.
One script it now sees is not a gate: npm run seed writes the fixture rows a dogfood run is then driven against, and asserts nothing about the tree. It is exempt by name in exemptFromGate with that reason recorded, rather than excluded by a pattern written around it, because the next npm run somebody adds to CI has to be reported rather than skipped in silence.
It makes seven npm run gates visible that nothing was looking at: four builds, a typecheck, check:seo, and seed. Five were already covered, one is the gap this fixes, and one is the exemption. just seo runs 34 assertions and passes; with www/public/og.png moved aside it reports 33 of 34 and the recipe exits 1.
The production runbook prescribed a GitHub App permission and four webhook events that nothing in this repository uses.
Checks read and write is gone, because no code calls the Checks API. Pull request, Push, Member and Membership are gone from the subscription list, because the webhook handler answers handled: false for all four and an event nobody consumes only makes a real failed delivery harder to find. Actions read and write is added, with what needs it and why to grant it when the App is created rather than later. The page is also unambiguous that Device Flow stays off, and says what it would be for.
The documentation shipped two of the eight head tags it was written to carry, on all 76 pages, and nothing in the repository was in a position to notice.
PR #47 ported the marketing half of the social card and entity graph work and left the docs half behind. og:image:width, og:image:height, og:image:alt, twitter:image, robots and the TechArticle JSON-LD were all missing. The JSON-LD is the one that mattered most: without it the documentation declared no relationship to the marketing site's entity graph, so 76 of the site's roughly 90 pages resolved as a separate thing that happened to share a domain. All six are now emitted, verified present on all 76 built pages rather than on the one page somebody opened.
The reason it went unnoticed for that long is the more useful half of this change. www/scripts/check-seo.mjs asserts the SEO surfaces against www/out and never opens docs/dist, so the largest part of the site had no gate with an opinion about its output at all. just docscheck is that gate. It reads every built documentation page, requires the eight head tags on each, and resolves the three @id values the JSON-LD references against the constants www/lib/jsonld.tsx actually declares, rebuilding them from SITE_URL the way the TypeScript does. A reference to an entity nothing declares is not a small error, it is the tag silently doing nothing, and it now fails loudly and names the page. A missing docs/dist fails rather than skipping, because a gate that is green about nothing is the gap this closes.
The documentation looked like a different product from the site around it, and its code blocks were close to invisible on a phone.
A code block was painted #f2f2f0 on a #ffffff page: a contrast ratio of 1.12:1 for the fill and 1.12:1 for the border against that fill, so it had neither a visible surface nor a visible edge. Code is now dark, on the site's own #18191b, which measures 16.4:1 against the page; every token in the theme is checked against that background and the lowest, the comment colour, is 6.87:1. Wide lines still scroll inside the block and now say so, with the same scroll shadow the reference tables use.
The header was a copy of the site's that had drifted from it. The logo mark was 18px against the site's 24px and the wordmark 15px against 16px; the bar stood 64px tall from 1024px up while the site drops to 56px below 1280px, so at 1100px the two headers were different heights; "Writing" was missing from the navigation; the container changed its side padding at a different breakpoint; and the leading gap had the wide value at the narrow end, which is the bug the site itself fixed and left a comment about. The header now matches at every width measured: 56px below xl and 64px above it, same mark, same type, same padding, same links. The page also takes the site's #f7f7f5 ground instead of pure white, and the selection colour matches.
On a phone every control is now at least 44px. The sidebar toggle was 32px, the search button 40px, each table-of-contents row 34px, the anchor link beside a heading 24px wide, and the footer links 30px tall. The toggle also lost the white circle and drop shadow that made it read as a button floating over the page rather than a control in the bar, and the search control fills its row instead of collapsing to one square in an otherwise empty band. Below 50rem the site's own navigation moves into the sidebar menu, so no destination is unreachable when the header collapses.
The rule above every h2 was display: inline and therefore only as wide as its own text: 146px in a 343px column. It now spans the column, which is what it was for. The footer's copyright measured 4.13:1 and is now 5.9:1, the page carries two corner radii instead of six, and no page scrolls sideways at 375px.
Two local traps cost time on this change and are recorded so they cost nobody else any. Both produce an error that points at the wrong file.
An install that is stale against its own lockfile lies about the cause. docs/ had Starlight 0.36.3 against a lockfile pinning 0.41.10, and the build failed with "Invalid config passed to starlight integration" naming every sidebar group. The config was correct and npm ci fixed it. www/ had Next 15.5.23 against a lockfile pinning 16.3.3, which is the more dangerous direction: a stale major masks a real break rather than causing one. Run npm ci in any workspace before concluding anything about its source.
Astro keeps a stale reference to the Expressive Code stylesheet across a change to the expressiveCode config, so the HTML links a hashed CSS file the build no longer emits. The 404 is silent, and because the frame's own overflow-x lives in that stylesheet, code blocks render completely unstyled and one wide line pushes the whole page sideways on a phone. It is indistinguishable from a theme that was never applied. Any change there needs rm -rf dist .astro node_modules/.astro, and the way to confirm it is to compare the ec.<hash>.css the HTML asks for against what is in dist/_astro. There is a comment saying so above the config itself.
A tinted section on the product pages painted its background 10% short of the page on each side and then ran its own text flush against that edge, so the green band stopped exactly where the heading, the verdict chips and the paragraph began, with several hundred pixels of empty colour left over on the other side.
On /product/architecture at 1920 the band ran from 192 to 1728 and the text started at 192 with nothing between it and the boundary.
The cause was a shared shell that was not shared. PageShell took an inset prop; the eight product pages passed it and the six legal pages, the four solutions pages and pricing did not. It put a 10% margin on the whole page and then reached back into the shared container with !max-w-none !px-0 to remove the measure and the gutter that container exists to provide. A transparent section survives that because the missing gutter is invisible. A tinted one does not, because the tint is what makes the gutter visible.
The two measures were the same number where it mattered: a 10% margin at 1920 leaves 1536, and max-w-[1600px] px-8 at 1920 also leaves 1536. So the prop bought nothing at the wide end and only narrowed the page below it. It is gone, every auxiliary page takes the one measure the shell owns, and the tinted band is full bleed with the standard gutter at every width, the same as the homepage. The band's colour is the existing --color-sage token rather than the hex that had been typed into the section by hand.
The gate that forbids em dashes only ever read Markdown, so the marketing site and the console had never been scanned once.
It reads .ts, .tsx and .mjs under www and console too, and finds the character written as an escape or an HTML entity, which a scan for the character itself cannot see.
Seventy-one em and en dashes came off the site as a result. A page's title now joins its own name to the site's with a middle dot, built and read back in one place instead of thirty, and where a table has no value it says so in a word rather than drawing a dash at it. Go-to-market vocabulary that had leaked onto the public pages, "the migration wedge" and "ideal customer", says what it means.
The invented 87 percent clone score was still rendering on two product pages, in a scene that had been missed when the same number was taken out of the other one. It is gone, and /product/report now says what the fidelity inventory actually reports: a measurement per dimension, a component nobody could measure named and excluded rather than counted as a pass, the count carried alongside the percentage, and no score at all when nothing could be measured.
The waitlist form on antifailure.dev dropped every address for two days.
deploy.yml passed no api_location and set skip_api_build, so every deploy published a site with no API at all, and the platform removed the managed function a hand deploy had put there. Every path under /api answered 500 and www/lib/waitlist.ts turned that into "Something went wrong on our side", which is a form telling somebody it is our fault while saying nothing about it anywhere else. Three addresses reached the table on 27 August, none since. The deploy now installs and publishes api/, sets the runtime the platform needs to start it, and refuses to finish green unless GET /api answers and POST /api/waitlist rejects an invalid address.
/api no longer answers a request it has nothing for with a 500 and an empty body. GET /api returns the one endpoint this host serves and says where the product's API actually is, and anything else under /api is a 404 that says so. The new reference page at /docs/reference/api is the longer version of the same answer.
This changes live behaviour on antifailure.dev, which is worth saying plainly so that a header is not blamed for something unrelated three weeks from now. Every security header the site's own Static Web Apps configuration declares was being thrown away before publishing. tools/site/assemble.sh generated a configuration file over the top of www/public/staticwebapp.config.json, so antifailure.dev served the platform's default 126 day HSTS instead of the two year one with preload, no permissions policy, no cross origin opener policy, none of the cache headers on hashed assets, and answered /product/crowdi with 200 instead of the redirect that file asks for. The generated parts are merged onto that file now rather than replacing it, and the assembly fails if the result loses any of them.
After the merge the site starts sending cross-origin-opener-policy: same-origin, a permissions policy, HSTS with preload, and immutable cache headers on the hashed assets, and /product/crowdi starts redirecting. Nothing in www opens a popup, so nothing should notice the opener policy.
Nothing would have told anybody about any of this, so waitlist.yml runs every morning and signs the same address up twice. alreadyJoined is read back out of the table rather than computed, so it can only be true on the second attempt if the first attempt's write landed. A 200 from an endpoint that writes nowhere is exactly the state this was in, which is why the check asserts on that field rather than on the status code.
The waitlist endpoint had no tests, in a file whose last line said its exports were there for the tests. It has 24 now, over what counts as an address, when the rate limiter trips, and what each storage failure answers. A caller that is over its per address or per IP allowance is refused before the endpoint does any work rather than after.
AF-EE-004 and AF-EE-010 are on the errors reference page.
Both ship, both are tested, and both were marked as reserved for a feature this version does not have, so somebody whose single sign-on was refused or whose environment was turned down by organization policy searched the reference for the code they had just been shown and found nothing.
The transform reference page said int_fpe and string_fpe preserve uniqueness and left out preserve, contradicting the generated table two lines above it.
int_fpe and string_fpe preserve uniqueness and left out preserve, contradicting the generated table two lines above it.That sentence sits under the AF-MSK-007 example, so a reader whose golden refresh had just failed on a unique index was being told to choose one of the two transforms that would fail it again. The sentence is generated from the registry now.
A run holding a workflow verdict the engine could not read reported the whole run as passed and exited zero, while the table beside that line printed the same workflow as unverified.
An unreadable verdict is now blocked, which is what an outcome that says something about us rather than about the change has always meant here. The runner, the engine and the control plane declared those five words in five places and nothing compared them; a test now reads the runner's union and the control plane's enum and fails when either drifts.
The prose readability gate had never been able to fail.
Both just readability and the CI step passed the path before the flag, and Go's flag package stops parsing at the first argument that is not a flag, so the threshold was never read and the enforcement branch never ran. The command now refuses an argument that appears after the path instead of measuring and passing, so the next person gets an error rather than a green run.
The GitHub guide documented three values the manifest schema does not accept: fork_policy: none and all where the schema has never and always, and teardown_on: [closed, merged] where it has close, merge and ttl.
fork_policy: none and all where the schema has never and always, and teardown_on: [closed, merged] where it has close, merge and ttl.Copying the example produced a manifest that disagreed with the schema the reference page is generated from.
A GitHub delivery that arrived out of order could restore access somebody had taken away.
Every webhook handler wrote the installation row through one function that cleared suspended_at unconditionally, so a repository or installation_repositories delivery retried after a suspend or an uninstall put the installation back to live, and sign-in grants membership on exactly suspended_at IS NULL. Only the installation event, which is the one that means the App is installed right now, clears it.
af ci said nothing when teardown failed. A teardown that could not reach the daemon printed no line at all, which on a green run reads as --keep, and one that ran and left containers behind printed only how many it removed. It now names each resource that stayed and what refused it, the way af down always has, and says to run af down where the job ran. The exit code is unchanged: it is the verdict on the change under test, and G10 is what gates a leak.
Model spend that crossed a month boundary was charged to nothing. A budget row is keyed on the month, and the two halves of one call read the month separately, so a completion that started at 23:59 on the last day of a month was checked against that month's cap and charged to the next one, which has no row until somebody sets a cap. The UPDATE matched nothing and the charge vanished without an error. The spend is charged to the month that authorised it, and a charge with no row to land in is now an error rather than a silent zero.
Refusing a device login that had already been approved answered "declined" and declined nothing. The console now shows what happened and points at where to revoke the token the terminal already holds.
A golden refresh now says what it is doing on the event stream.
mask.planned, mask.progress, mask.applied, mask.verifying, mask.verified and mask.finding were in the event catalog and on the generated reference page, and nothing ever emitted one: masking reported its work to the terminal only, and dashboard mode silences the terminal, so the longest part of a refresh drew an empty pane that said unverified. A failed verification now puts every finding on the stream at error level rather than only the first one the refusal names.
The enterprise binary now registers the organization policy hook, which it never did.
policyenforce.Hook was written, tested and property tested, and no binary ever constructed one, so an installation licensed for policy_enforcement refused no environment at all and its compliance report said no policy was configured. Point AF_ORG_POLICY_FILE at a YAML policy and the engine says which rules are in force at startup and refuses an environment that violates one. A policy file that cannot be read stops the engine rather than starting with the policy silently off.
The marketing site builds and publishes again: #13 was branched before the site became a static export and its merge took output: "export" and a deleted auth.ts back with it, and the published tree now carries /install.sh and the JSON schemas that the README and every event envelope point at.
output: "export" and a deleted auth.ts back with it, and the published tree now carries /install.sh and the JSON schemas that the README and every event envelope point at.Five links from the marketing pages into the documentation pointed at pages that do not exist and now point at ones that do: the egress, load, journal and invariants concepts, and the provider authoring guide.
security
17 entries
A defect with a consequence for the safety of your data or your build.
The workflow identity exchange issued a credential to a suspended organization.
The exchange checked that the GitHub App installation was not suspended, which answers whether GitHub still says the account is ours. It never asked the other question: whether we have stopped the customer. A suspended organization was handed a working fifteen minute engine token and refused later at /v1/events, which points somebody at their continuous integration when the answer is their billing state.
The refusal now happens before the token is minted, with its own reason, organization_suspended, so a caller can tell it apart from a suspended installation. The two have different remedies. Work that is already running is untouched.
A documentation page that says exactly what crosses from a customer's boundary to a control plane, with the file behind every line of it.
The claim on the site is that production data stays inside the customer boundary and that the control plane receives evidence rather than records. Nothing in the repository showed a reviewer how to check that, so the page is built the way the vendor list is built: read the code that talks to the far end, then write down what it sends.
Most of the claim survives contact with the code. Production rows are read from the machine the engine runs on, the golden is an image on that machine's Docker daemon and nothing pushes it, artifacts are never uploaded, every connection string is registered with the redactor where it is obtained, and the engine dials out with nothing dialling in. A hosted run starts as a request to GitHub to dispatch the customer's own workflow, and even a cancel arrives as the answer to a heartbeat.
Four things are weaker than the sentence sounds and the page names all four. An invariant that does not hold carries up to five rows of the twin's database into the check comment, and the control plane stores that comment, so records do cross on that one path. Table names, column names and per-table row counts ride the masking events. Every line of build output is an event, redacted for credentials and for nothing else. And an event type the control plane does not recognise is forwarded rather than dropped, so what crosses is bounded by what the engine emits rather than by a published list.
The page also states what it cannot prove: it is a reading of the source and not a statement about how any deployment is configured or what it retains.
security · changed An organization owner on a control plane that takes no payment could set their own plan, including enterprise, and take the quota that goes with it.
enterprise, and take the quota that goes with it.security
billing.set exists so that somebody self-hosting can change their own quota, and it refused the call wherever Stripe or AF_HOSTED_REQUIRED_PLAN was configured. That guard asks whether billing was set up, and the dangerous installation is precisely the one where it was not: a control plane serving people who are not its operator, whose operator has not reached Stripe yet, configures neither, so nothing refused. The first person into an organization becomes its owner and an owner holds billing.manage, so it was every signed-in tenant rather than an administrator.
The question is now the one that actually decides it. AF_OPERATOR_SETS_PLAN=1 is an operator saying that whoever runs this installation also decides each organization's plan, and without it the plan can only come from a signed Stripe delivery. Off is the default because the configuration at risk is the one nobody has configured, so a flag meaning "this is hosted" would have to be remembered by exactly the operator who has not thought about it yet, and forgetting this one closes the hole instead of opening it.
Setting it alongside any Stripe variable or the hosted plan gate stops the process at startup rather than being refused per request, because a plan that can be granted by hand is not a plan anybody has to buy, and a start-up refusal covers whatever writes the plan next rather than the one route that carries the check today.
changed
Self-hosted installations that change plans from the Plan page need AF_OPERATOR_SETS_PLAN=1 set on the control plane. Without it the page shows what each plan allows and offers no control, rather than offering one that is always refused. Nothing else changes: the plan a control plane is already on stays exactly where it is, and an operator has always been able to write the column directly.
An expired token was refused by /v1/whoami and accepted by /v1/events, which is the surface that writes.
/v1/whoami and accepted by /v1/events, which is the surface that writes.authenticateEngine read revoked_at and never read expires_at. That was correct for exactly as long as every row in engine_tokens was immortal, and it stopped being true when the device grant landed: af login mints a ninety day CLI token with an expiry, and the device sign-in path honours it. So a CLI token that had aged out was correctly turned away from the route that only tells you who you are, and was still accepted by the route that ingests events.
The expiry is now read where the token is resolved, so every door checks it. Anybody whose CLI token is older than ninety days will find event submission refused where it previously succeeded, and the same for reading an environment back and for the model proxy that spends against a provider budget. That is the point: the token had expired and only the door that answers who you are was looking.
Revoking a claim on a repository was also unreachable by that repository's name. owner/name contains a slash and one path parameter matches one segment, so the delete route never matched and answered with a refusal about a missing rate limit declaration rather than doing the work. It is two routes now, and the two-segment one is the one a repository name reaches.
The run report could not say whether a sandbox credential was actually replaced.
A sandbox rule substitutes a sandbox credential on the way out, but only when a value was configured for the name the rule refers to. When none was, the sidecar forwarded whatever the application sent, and in every column the report showed, that request was identical to a working sandbox call: allowed, mode sandbox, the rule named, a normal status. So af ci could say "4 requests allowed" and could not say whether those four carried a sandbox credential or a live one.
The report now says. It states the substituted count either way, because a line that only appears when something is wrong teaches a reader nothing by its absence, and it names the hosts that a request reached carrying the application's own credential.
The counts were already in the decision log and had been all along. Nothing read them, which is the more general shape: a safety property whose only evidence is a number nobody computes is a safety property nobody can check.
On macOS every secret this product stores went through a child process's argv, where any other user on the machine could read it in ps.
ps.A control plane bearer token was read that way on this project's own machine, by accident, by somebody looking at something else. af login, af model set, af secret set and af provider all reach it.
The product had already decided this matters. cli/model.go says in capitals that the key is never an argument, because a secret on a command line is in the shell history, is visible in ps, and is in any recording of the terminal. af model set refuses a --key flag and reads without echo for exactly those reasons, and then handed the key to a function that put it on a command line one process deeper.
Both halves of the comment that justified it were false, and each was settled by running something rather than by reading. It said the value goes through a flag "because security takes it that way": security reads the value from stdin when -w is given no argument, prompting twice, and the value round-trips byte for byte. It said the exposure was acceptable because "writing is only done by 'af secret set' on a workstation, never in CI": af login and af model set are both first-run workstation commands and both reach that line.
The value now goes in on stdin. A value containing a line break is refused and pointed at the encrypted local store, because the prompt protocol is line based, which matches what the Windows implementation does with a PEM key too long for its blob limit. Every call to security is now bounded by a timeout: without one a keychain that blocks rather than failing has no upper bound, and the fallback to a file cannot fire, because a hang is not an error.
Linux was already correct and Windows starts no child process at all, so macOS was the only affected platform.
install.sh verified the download on the happy path and passed on every unhappy one.
install.sh verified the download on the happy path and passed on every unhappy one.A checksums.txt that did not download printed a warning and installed anyway; an archive not named inside one printed nothing at all and installed anyway; a machine with no shasum or sha256sum printed a warning and installed anyway. Only a mismatch stopped. Each of those now refuses, naming what was missing, and openssl is accepted as a third hashing tool so refusing costs almost no machine anything.
Placement was worse than a fail open. install -m 0755 ... || { cp ... && chmod ...; } is an AND-OR list, and set -e does not apply to one, so an archive assembled without af in it printed cp: No such file or directory, then printed Installed <version> to <dir>/af, wrote the PATH line, and exited 0. The archive is now checked for af, the runner entry point and the runner package.json before anything is placed, an archive that will not unpack is reported as damaged rather than as gzip: unexpected end of file, and every placement step reports its own failure. A missing runner/package-lock.json is said out loud and does not refuse, because this script deploys on every push to main and runs against releases built before it existed: every archive up to and including v0.1.1 shipped none, and requiring one would have turned a dependency pinning defect into an installer that installs nothing.
A sandbox rule whose credential was never configured sent the application's own credential to the provider.
Substitution only happens when a value exists for the name the rule refers to. When none did, the sidecar forwarded whatever the application sent, and in every other column that request was indistinguishable from a working sandbox call: allowed, mode sandbox, the rule named, a normal status. The only evidence was a count that said zero, and nothing computed that count.
inspect_egress_firewall now reports it as sandbox_credential_not_substituted, and it always fails the check. It has no manifest level and there is deliberately no way to turn it down: a threshold expresses how much of something a project will tolerate, and there is no tolerable quantity of a live credential leaving an environment that is running unreviewed code against a copy of production data.
github.fork_policy was sold as a security control and refused nothing.
github.fork_policy was sold as a security control and refused nothing.The manifest schema said the default requires "a maintainer to add antifailure:allow first, which is the only safe default: a fork's code would otherwise run with the environment's credentials", and the pull request guide said "nothing runs until a maintainer adds the label, which is a person deciding". Nothing anywhere read the setting. af explain printed "forks never, no environment is created for a fork" and af up on a fork's pull request answered "Bringing up forkrepro-main-0cd221" and went to the Docker daemon. The label appeared in two descriptions, one test asserting af explain prints it, and the string af explain prints.
What customers actually had was GitHub's own default, which withholds secrets from a fork's pull_request job on a GitHub-hosted runner. That is real and it is not this control. It does nothing on a self-hosted runner, where the Docker daemon, the registry login and the network are already on the machine, and self-hosted is the ordinary shape here because an environment needs a daemon and a golden. It does nothing under pull_request_target, which hands the base repository's secrets to a job checking out a stranger's code on purpose.
af ci, af up, af test and af load run now refuse, before an environment is named and before the daemon is touched, with AF-GH-003. af ci writes a report saying the check did not run rather than exiting non zero, because a fork waiting on a maintainer is not a finding about the change and never would otherwise leave every fork pull request permanently red.
The policy is read from the BASE branch, not from the checkout. The manifest is a file in the repository, so on a fork's pull request the checked out antifailure.yaml is the fork's own copy, and reading the setting from there would have let anybody add fork_policy: always to their pull request and walk through the control. A checkout that does not carry the base branch falls back to label and says so.
The example workflow now subscribes to labeled, because adding the label is an event and a workflow that does not listen for it does not notice the approval until the next push. Without that line the instruction to add a label was itself a claim nothing acted on.
Two more settings in the same block, found in the same pass. github.comment: false was never consulted, so turning comments off left them on. Both af change and af ci now write comment=false to GITHUB_OUTPUT and the workflow's comment step is gated on it. The report files are still written: the setting means do not comment, not do not produce a report, and the same report.md is the job summary and the payload a control plane is sent. And nothing reads github.teardown_on, which cannot be fixed rather than stated: teardown is unconditional in a workflow, and the control plane never reads your manifest. af explain, the guide and the reference now say so against the setting instead of printing it as though it were in force.
af up branched another project's golden.
af up branched another project's golden.A golden pool is shared: the Docker provider keeps goldens as images on a machine wide daemon, and a published store is shared by a fleet on purpose. Selection filtered on the masking rules digest, which says how a golden was masked and not whose it is. A project with no masking.yaml declares no rules, so every project on a machine without one hashed to the same value and drew from one pool.
Reproduced with two ordinary Express repositories. One declared a production database and refreshed a golden from it. The other declared none, printed in its own generated manifest that "branches will start empty", and af up brought up a database holding the first project's tables and its rows.
A golden now records the project it was made for, along with the variable naming production, the seed command, the masking digest, the subset and the Postgres major, and an environment branches only a golden whose record equals its own. af golden pull refuses a published golden made for another project, checking the claim in the signed attestation before restoring anything. af golden gc collects only this project's versions, where running it in one repository used to delete another repository's goldens. af golden list says which project each version belongs to.
Every run now says where its data came from, rather than printing a version identifier alone:
branching the database from gv_20260901033741_74234e98, made for acme-billing from the database named by PRODUCTION_DATABASE_URL
Expect one golden refresh per project the first time a command runs after this change, because no existing golden carries the record.
A verified GitHub delivery is now handled exactly once, however many times it arrives.
The HMAC over the raw body says a delivery is genuine. It says nothing at all about it being new, so a delivery captured off the wire, or replayed out of GitHub's own redelivery log, verified exactly as well the thousandth time as the first. Every handler downstream of that endpoint writes something.
Each delivery is claimed by its x-github-delivery identifier before it is handled and stamped after. A second copy is answered without the handler running, a copy arriving while the first is still being handled is answered 503 with a Retry-After rather than a success it has not earned, and a handler that throws gives its claim back so its own retry can take it. A delivery with no identifier is refused rather than handled unfenced.
The control plane sent the failing SQL statement to the browser.
tRPC's error formatter withheld the stack, with a comment saying why: a stack names internal paths and table names to anyone who can provoke an error. The message beside it was not withheld, and the database driver writes a query failure as "Failed query: <the whole statement>" followed by the bound parameters. A renamed table put the schema, the joins, the WHERE clause and the source comments inside the SQL onto the console's error card, for any signed-in viewer to read. An INTERNAL_SERVER_ERROR now carries a fixed sentence and the real cause is logged where the operator can read it instead.
Every other tRPC code still carries the message somebody wrote for the reader, because blanking those would turn "your role cannot see this" into a shrug.
The eight open Dependabot alerts on the default branch are one dependency, and SECURITY.md now says so along with what can and cannot be done about them.
SECURITY.md now says so along with what can and cannot be done about them.They are github.com/docker/docker v28.5.1, four Moby advisories counted twice because engine/go.mod and ee/engine/go.mod both require it. None has a fix to take: no version of github.com/docker/docker or github.com/moby/moby above v28.5.2 exists, three of the four cover everything through 28.5.2, and the fourth names a Docker Engine release rather than a module version. All four are daemon-side bugs reached through one function, copyInto, which copies into a container that has been created and not started.
The gate gap this exposed is not the npm one. govulncheck asks what is reachable, Dependabot asks what is present, and nothing here reads the second answer, so a green daily scan sat alongside eight open alerts. The per advisory analysis is in docs/security/pentest-readiness.md so a penetration tester is not paid to redo it.
npm audit now runs against every lockfile in the repository, beside govulncheck in security.yml, on every pull request and every morning.
npm audit now runs against every lockfile in the repository, beside govulncheck in security.yml, on every pull request and every morning.Advisories are held to .npmaudit.yaml under the same three rules as .govulncheck.yaml: an advisory with no written decision fails, so does a decision past its expiry, and so does one that matches nothing.
govulncheck reads Go modules and stops there, and every npm ci in CI passes --no-audit, so the seven lockfiles present when this gate landed, one of which builds the control plane, had no advisory check at all. They were clean with dev dependencies included. The runner was reported as uncovered then; it now has the repository's eighth lockfile and is audited too.
SECURITY.md stops claiming three things the evidence does not support.
SECURITY.md stops claiming three things the evidence does not support.The credential scan runs on every pull request and every push to main, not on every push. The SPDX bill of materials and the cosign signing are written and have never run, because both existing tags predate the steps that do them. And the reproducibility gap it named is now half closed: just reproducible builds twice and compares, though it is not a CI job and it compares a local build.
The disclosure section says what happens when a target is missed, where to escalate after a week of silence, what a reporter can expect for a finding below high, our 90 day default for publication, and that there is no bug bounty.
docs/plan/STATUS.md loses two claims that were not true. G1 listed Biome as an enforced linter; it is not installed and not run, and no TypeScript here is linted or formatted by anything. The row-level security row said that disabling one policy makes the suite name the table and the row count, which describes an experiment nothing in the repository repeats.
The egress sidecar enforced the whole policy on two of its three request paths.
A plain HTTP request through the explicit proxy port, which is what every client that reads http_proxy sends, skipped the live credential tripwire, forwarded the application's own credential in sandbox mode, and refused capture, mock and synth instead of serving them. All three paths now run the same decision. The sidecar also refuses to open a loopback, link local, private or carrier grade address on the environment's behalf unless a rule names it, which closes the instance metadata endpoint under default: allow; it answers non-address DNS queries for external names itself rather than forwarding them out; it takes a transparent connection's port from the listener rather than from the client's Host header; and egress.allow_ipv6, which nothing in the sidecar read, is now enforced.
Release archives are reproducible and now proven to be.
Two builds of one commit produced four different archives every time, because tar takes each entry's timestamp from the filesystem and gzip writes another into its own header. The binaries always matched, which is why the old check passed: it compared bin/af. Archives are written by tools/reltar now, with a fixed modification time from the commit, no ownership and sorted entries, and the check builds twice in two directories and compares the archive, on every pull request.
The bill of materials described nothing. It was generated from a directory of .tar.gz files, which the generator does not open, so every release would have carried a valid SPDX document listing one package instead of the 363 in the binaries. It reads the binaries now, and tools/sbomcheck validates it against the SPDX 2.3 schema and requires it to record the SHA256 of every binary that ships.
Signing could not have worked at all. The pinned installer supplies cosign v3, where sign-blob --output-signature is deprecated and the command refuses to run without --bundle. Releases sign a bundle now, the cosign version is pinned, and the workflow verifies both signatures and requires a copy with one byte changed to be rejected before it publishes anything.
There is a new page on verifying a release, including how to rebuild one yourself and compare it to the published hash.
v0.1.1
Released
Cut before this repository started writing a fragment for every change. Nothing was recorded at the time, and nothing has been invented for it since.
v0.1.0
Released
Cut before this repository started writing a fragment for every change. Nothing was recorded at the time, and nothing has been invented for it since.
How this page is made
A date here is the day the entry landed on the main branch, read from the commit that brought it there. Nothing on this page is typed by hand or backfilled.
Changes with nothing a user of Antifailure could observe are recorded in the repository and left off this page: a lockfile drift, a test that was measuring nothing, a build artifact that needed ignoring. Every change to anything you can see is here, and CI refuses one that arrives without an entry.
Know what happens before you deploy.
Create a disposable production twin for every risky change. Catch migration failures before they reach customers.