What is new in Antifailure.
New features, improvements, and fixes, newest first.
Unreleased
Not released yet · 27 entries · landed 27 September to 5 October 2026
added
4 entries
Something the product could not do before.
Windows is a platform a release ships to.
Every tag now publishes antifailure_<version>_windows_amd64.zip and antifailure_<version>_windows_arm64.zip, each holding af.exe and the runner, signed through checksums.txt and described by the bill of materials like the other four. They install from PowerShell with irm https://antifailure.dev/install.ps1 | iex, which keeps install.sh's promises: the newest release is found without the rate limited API, a download that does not match its published checksum is never installed, a refusal says what GitHub actually answered, and the install ends with af on the user PATH and in the terminal that ran it. Reinstalling over an af.exe that an editor holds open as its MCP server works, because the running binary is moved aside rather than overwritten.
Before this a Windows user had no build, no installer, and an install.sh that told them, from Git Bash, to compile the product from source. That message now points at install.ps1. af.exe is not code signed yet, and the quickstart says what that means for SmartScreen and Smart App Control.
af update works on Windows.
af update works on Windows.It used to refuse the platform outright, and it could not have worked there as written: it replaced the binary by renaming the new one over it, and Windows refuses to overwrite the image of a running process, which during an update is always the process doing the update.
On Windows the update now fetches the release's zip, checks it against the published checksum exactly as the tarball is checked elsewhere, and applies the same refusals to every entry: no path outside the release, no links, no duplicates, no entry larger than it declares, and a checksum failure in any entry, including ones the update does not install. It then moves the running af.exe aside, which Windows does allow, puts the new one in its place, and puts both the binary and the runner source back if the second step fails. The next af to start deletes the copy that was moved aside; one still running, such as an MCP server an editor keeps open, is left until it exits.
The installation lock uses LockFileEx on Windows, and recovery after an interrupted update closes its journal before removing it, which Windows requires and which had made every recovery there fail at its last step.
added · fixed A terminal workflow can say what the program must never show.
added
never takes strings, matched character for character with case and spacing forgiven, and one appearing fails the workflow even when every expectation was met:
```yaml terminal_workflows:
- name: deploy command: ./bin/deploy expect: ['"Applied 3 changes"'] never: ["rollback started"] screen: {} ```
Without it, a full screen program that showed the expected words and went quiet was accepted on that screen, so a program that went on to print an error was passed on what it said first. That is still the default, because it is what keeps a passing workflow fast. Declaring never keeps the program watched until it exits or its budget is spent, ends the watch the moment a forbidden string appears, and reports a watch the budget cut short with keys still to send as blocked rather than passed. Every byte the program wrote is read as well as every screen it drew, so a warning drawn and erased between two snapshots is still caught. Set budget.duration to the window you mean. An entry that a quoted expectation contains, or that a screen workflow types and so echoes, is refused before anything runs, because either one decides the verdict by itself.
fixed
A program that exited having written more output than its budget could draw held a terminal workflow for as long as drawing it took, past the budget its author declared: at the 46 to 150 KB a second measured on a loaded machine, a 20 MB exit held a thirty second workflow for minutes. The budget now bounds the reading as well as the running, and a workflow that reaches it with output undrawn is blocked, with the bytes written and how many were never drawn, rather than judged on the part that was.
Capture explicit TypeScript agent boundaries locally and save an incident with a verified Postgres golden.
Agent replay reproduces the specified original failure before testing a candidate in an independent environment, with strict cassette matching and an inconclusive verdict for missing evidence or unconfirmed cleanup. Saved scenarios can run as local regression suites. The SDK requires explicit content capture and does not provide historical database recovery or a hosted trace store.
Reports omit database row values and primary keys. Retired incident IDs cannot be imported again. Replay checks the proxy's independent writer status and admitted work against its retained log, including failures writing the final record.
changed
4 entries
Behaviour that already existed and now works differently.
The console, website and docs take Next.js 16.3.8, the Tiptap editor 3.31.4, Starlight 0.42.5 and sharp 0.35.5, and the Go modules take patch releases of the Docker client, OpenTelemetry 1.47.0 and modernc SQLite 1.60.1.
None of these changes behaviour anybody can see. They are recorded because three of the four automatic update requests that carried them could not merge on their own: two changed licensed dependencies without regenerating THIRD_PARTY_NOTICES.md, and two edited the same lines of one workspace manifest.
The homepage Migration Safety and Side-Effect Firewall examples are now interactive.
Visitors can compare a migration with an agent's unverified revised plan, inspect the measured finding, and switch between payment, email, and blocked production-service requests to see what the firewall returns. The examples show the effect on application behavior and where the structured result reaches the coding agent.
The homepage now shows what a migration rehearsal and a side-effect firewall inspection give back to a coding agent.
The migration example puts its table rewrite and measured lock durations in view; the firewall example distinguishes mocked, captured, and refused calls and explains where the containment result appears. Example numbers are labeled so they cannot be mistaken for live customer telemetry.
The five feature diagrams beneath the homepage hero are now a static overview.
Their former standalone product pages have been removed. Existing URLs redirect to the product overview, while navigation and related links lead to the remaining product and documentation pages.
The public site and documentation now use the site text font for labels, diagrams and code instead of the slashed-zero mono face.
fixed
16 entries
Something that claimed to work and did not.
A browser workflow's budget.duration did not bound the time it took to open the browser.
budget.duration did not bound the time it took to open the browser.The budget is raced against the attempt, and when it ran out while Chromium was still starting, the runner waited for the whole open, the launch, the context, the trace and the first page, and only then closed what it had made. A two second budget came back after twelve seconds on a Windows machine starting its first browser, and the required Windows check failed once on a pull request that changed no runner code, at 8332 ms against a test that allows 8000.
The open now carries the budget. The launch is handed what is left of it as Playwright's own launch limit, so a launch that cannot finish in time is stopped by Playwright with nothing left running, and a budget that runs out after the launch closes the browser at once, so the context and the page that were still being made fail instead of finishing. A 50 ms budget that used to take 1.1 to 1.5 s on macOS now ends in under a quarter of a second.
The Windows check could not see a leaked browser at all. The test helper that looks for Chromium below the test process used pgrep and ps, neither of which exists on Windows, and read the failure as "nothing running", so every assertion that a stopped workflow left no browser behind passed there without having looked. It reads the Windows process table now, and refuses rather than answering when it cannot.
af update on a platform the newest release has no build for refused with "no valid SHA256 checksum names this archive", which reads like a tampered download.
af update on a platform the newest release has no build for refused with "no valid SHA256 checksum names this archive", which reads like a tampered download.Every Windows user saw it, because the latest release when Windows support landed shipped no Windows archive. It now says what happened: release v1.9.0 publishes no build for windows/amd64, nothing was downloaded, and the installation is unchanged. A checksum that is named but is not a checksum is still refused in the old words, because that one is a damaged release.
On Windows the path af test printed for each workflow's trace and screenshot used two separators at once, C:\work\app\.antifailure\artifacts\env/flow-1.trace.zip, because the runner glued a forward slash onto the native artifacts directory the engine handed it.
af test printed for each workflow's trace and screenshot used two separators at once, C:\work\app\.antifailure\artifacts\env/flow-1.trace.zip, because the runner glued a forward slash onto the native artifacts directory the engine handed it.Windows opened it anyway, but it was a path in two styles for somebody to copy. It is joined with the platform's own separator now.
An application that made no outbound call was reported as one nobody could observe.
The sidecar's log was read, it held no decision and no captured message, and both parsers answered with nothing at all rather than with an empty list. Every reader of those answers takes nothing to mean the log could not be read, so af ci said the ssrf and side_effect security families could not complete and reached no verdict, and the MCP egress inspection told an agent that no environment was running for the branch while one was, with the verdict INCONCLUSIVE.
A log that was read now answers an empty list, and only a sidecar that is not there answers nothing. On an application whose policy blocks everything and which calls nothing, both families now look and pass, and the MCP inspection reads PASS with zero decisions observed. A run whose logs could not be read is still refused by both families, exactly as before.
On Linux a terminal workflow could fail a program for output it really printed.
When a program exited, the end of its output could be thrown away before Antifailure read it, so a program that printed what was expected as its last line was reported as never having printed it. With a slow reader this happened in 122 of 150 runs. Antifailure now keeps the terminal open until it has read everything the program wrote.
af start told a Windows user to paste bash.
af start told a Windows user to paste bash.When af was installed and not yet on the terminal's PATH, it offered export PATH=..., and when another af came first on PATH it offered which -a af; neither exists in PowerShell, which is where install.ps1 leaves the person. It also looked for the install under the name af, so on Windows, where the file is af.exe, it never found it and called an installed af a development build. It now looks for af.exe and offers $env:Path = "...;" + $env:Path and where.exe af on Windows.
On Windows a migration rehearsal reported fast statements as taking no time.
Go's clock on Windows advances in steps of the system timer, measured at 0.3 to 0.5 ms, so a statement quicker than one step, which is most DDL on a small table, was timed at 0 ms and looked exactly like a statement that was never timed. Statement durations on Windows now come from the performance counter and are accurate to the microsecond, as they already were on Linux and macOS.
AF_BIN_DIR put af where you asked and the runner where af never looks.
AF_BIN_DIR put af where you asked and the runner where af never looks.af finds the runner it shipped with relative to itself, beside its own directory, and the installer placed it under AF_PREFIX whatever AF_BIN_DIR said, so an install into ~/.local/bin reported success and af runner install then answered AF-AGT-004. The runner now goes to share/antifailure/runner beside the bin directory, which for the default layout is the same place as before.
An upgrade that failed part way no longer leaves a new af with no runner. The installer replaced af first and then deleted the old runner before copying the new one, so a copy that failed, or a runner directory that could not be moved because af test was running from it, left the machine with neither. The new runner is now staged beside the old one, and each is put back if anything after it fails. Both installers, install.sh and install.ps1.
On Windows, a lock whose holder had died could read as still held, so af waited on an owner that no longer existed instead of reclaiming the lock.
af waited on an owner that no longer existed instead of reclaiming the lock.Liveness asked only whether the holder's process could be opened, and Windows keeps an exited process openable for as long as anything holds a handle to it, an antivirus scan included. It now asks whether the process has ended, which an exit code cannot answer.
Terminal workflows on Windows reported wrong verdicts about programs that were behaving correctly.
The driver handed the terminal emulator one chunk of output at a time and waited for each to be drawn. Every wait costs a timer tick, which is about a millisecond on Linux and macOS and 15.6 ms on Windows, and ConPTY delivers output a dozen bytes at a time. A ten thousand line program that Linux draws in a second was still 72723 of 108989 bytes behind after twenty seconds, so the workflow was blocked for a screen it could not finish drawing. Output that arrives while a chunk is being drawn is now drawn together with it, and the same program is drawn in under two seconds on Windows.
Arrow keys reached a program in the wrong encoding. ConPTY keeps a program's request for application cursor keys to itself, so the driver always sent the normal encoding and a program that had asked for the other one ignored the key. On Windows an arrow is now sent as a key press and release, which the console turns into the encoding the program asked for.
A never check that saw nothing forbidden now blocks rather than passes on Windows. ConPTY hands over the screen as drawn, and a line a program printed and then overwrote in place was missed in every one of fifteen measured runs, so a pass there would claim something nobody could see. A forbidden string that does arrive still fails the workflow.
A desktop application that exits while starting, and on Windows one whose path does not exist, crashed the runner with no result at all. It is now a blocked workflow that says the application did not start.
On Windows every terminal workflow left its console host and two pipes open until the runner exited. They are now closed when the workflow ends.
The engine compiled for Windows and had never been run there, and running it found a product that broke in ordinary use.
Every replay and incident write failed with "Access is denied", because the store synced its directory after each write and Windows refuses that. af init named a service at the root of a Windows checkout after the whole path, so C:\Users\me\shop became cusersmeshop, and asked every question about it under that name. The fork gate read the base branch's policy through a path git could not find, so on Windows, and for any manifest below the top of a repository, the base branch's fork_policy went unread and label was used instead, which meant a base branch that said never could still be overridden by a maintainer adding the label. The doctor told every Windows user their state directory was readable by others, because Windows reports every directory as mode 0777, and told them to run chmod.
Files that promised to be readable only by their owner were exactly as readable as their folder on Windows, where a 0600 mode means nothing and the folder's access list decides. The credential af login writes when there is no keyring, the encrypted secret store, the state database and the lock now carry an access list naming only the current user, set as the file is created.
An archive unpacked for a baseline comparison now refuses the names that mean something else on Windows than on Linux: a drive relative path such as C:x, which names a place on drive C rather than in the checkout, a device name such as NUL or COM1, which opens a device in every directory, and a path starting with a slash, which Linux already refused. A repository with a symbolic link no longer fails the comparison on a machine that cannot create one; the link is written as a file holding its target, which is what Git for Windows itself does.
database.seed and persona seed commands run in the sh that ships with Git for Windows, found through git rather than through PATH, and af doctor says where they will run or what to install. pg_dump installed by the PostgreSQL installer is found under Program Files. The terminal image query is no longer written on Windows, where its answer could not be read back and was left in the keyboard buffer; set AF_IMAGES to choose instead.
A new required check runs the engine's tests on Windows on every pull request.
af test --watch showed no frames on Windows.
af test --watch showed no frames on Windows.The engine opened the live view on a unix socket file and handed its path to the runner, whose Node net.connect maps a path on Windows to a named pipe and refuses anything else with EACCES. The runner reads an unreachable watcher as nobody watching, so a Windows user got an empty live view and no reason. On Windows the engine now listens on a named pipe with a random name, created first by the engine so nobody can claim the name ahead of it, refusing remote clients, and carrying a security descriptor that admits only the user running the engine. That last part matters because a pipe has no directory to hide in, and the default pipe security admits Everyone to read. Everywhere else the socket and its private directory are unchanged, and a live view that cannot listen now says so at once and names the endpoint.
The proxy refuses live shaped credentials in decoded query parameters and supported HTTP request bodies before forwarding to an allowed destination.
Requests that cannot be inspected are refused; streaming gRPC metadata remains checked without buffering its message stream.
af ci reads complete egress decisions after load and before teardown, and reports an incomplete log instead of treating missing evidence as clean. A chaos setup error now follows policy.chaos_unverified and appears in the chaos report. MCP masking errors say when rows were already committed, distinguish plan refusals, and avoid retrying an interrupted rewrite automatically.
Database snapshots retain each occurrence of a row in a table without a primary key.
Adding an identical row is now visible to the comparison, and repeated rows count toward the capture limit. This also prevents an agent replay from treating a duplicate insert as an unchanged database.
af load sql said a lock was held by another session on the database when nothing had identified the holder at all.
af load sql said a lock was held by another session on the database when nothing had identified the holder at all.A wait pair's holder has three outcomes and the result carried two. It can be one of this run's own clients, it can be another session on the database, or it can be NOT NAMED: pg_blocking_pids returns an empty array for a backend that is genuinely waiting when the holder disconnected between the two reads. The third arrived looking exactly like the second, so the report printed "waited on another session on this database" about a lock whose holder nothing knew, which is a positive claim about whose lock it was made by something that did not know. It is the same defect as reading a null wait count as a zero, one level in: an absence rendered as a finding.
It is also a prepared transaction. pg_blocking_pids reports a two phase commit holder as pid ZERO rather than omitting it, so the holder is named while nothing in pg_stat_activity answers for it. Reported as another session, that sends a reader looking for a session to cancel when there is none: the lock goes when somebody runs COMMIT PREPARED or ROLLBACK PREPARED. A wrong remedy rather than only a wrong label, so it gets its own sentence.
Why it survived review and a merge, which is a shape worth naming: it is RARE exactly where it is tested and CERTAIN exactly where it is shipped. The same assertion failed on one head of another branch and passed on a later one with nothing between them touching this package, because as a superuser it fires only when an unnamed or stateless holder happens to turn up in a sample, which is load dependent. Off superuser, or with a prepared transaction outstanding, it is every run. A defect with that profile passes review, passes CI, and fails at a customer.
The three are now distinct, the run still reports the wait when the holder is unnamed, because the wait is the part that was measured, and a reader is told "a holder the server would not name" instead of being given a stranger.
Also fixed: a test demanded a holder state the server is entitled to withhold. An empty state has three causes and none of them is that the holder was doing nothing. The first is the ordinary case for a customer pointing this product at their own Postgres: a role without superuser and without pg_read_all_stats sees a foreign backend's row while its state, type and query are withheld, so off superuser a named holder has no state every time rather than occasionally. The second is any backend that is not a client, of which an idle server has several. The third is the holder's row being gone by the time the join runs.
The unpublished Pages directory keeps one site name in its browser title.
Previously the empty-state title included Antifailure before the root layout added it again.
security
3 entries
A defect with a consequence for the safety of your data or your build.
Four npm advisories published on 2026-10-05 reached the lockfiles.
The critical one is in proxy-addr, in the open source web workspace, installed there because the MCP SDK depends on Express. Nothing in that workspace imports Express: the api serves MCP through the SDK's web standard transport on Hono, so the vulnerable code was installed rather than running. The other three are build tooling: source-map-js in the console, website, docs and Next.js example, and smol-toml and postcss-selector-parser in the docs. Each now resolves to its fixed release.
postcss-selector-parser needed an override, because the docs reach it through postcss-nested 6, which allows only the 6.x line, and no 6.x release is fixed. The override lifts that one dependency to 7.1.6 and leaves postcss-nested where it is. The docs built with and without it are identical, every page and every stylesheet, and the build was shown to fail when that parser is broken, so the comparison was exercising it.
The one advisory the docs carried with no fix, GHSA-ch52-4w7c-c8xp in http-cache-semantics, now has one.
Version 4.3.0 was published hours after the advisory was accepted in .npmaudit.yaml, so the docs take it and the acceptance is removed. Every lockfile in the repository audits clean again, with nothing accepted.
The console, the website and the Next.js example were built on a release of Next.js with a critical advisory, GHSA-vcvr-r3jv-pc5j, for five days.
The docs lockfile carried the same release too, though the docs build with Astro and never run it: it is installed there only to satisfy a peer dependency of the geist font package. The daily vulnerability scan reported it from 2026-09-29 onwards, and issue #612 said scanning had stopped protecting the repository, but the scan only reports. Nothing it found reached a lockfile.
Next.js moves to 16.3.6 in all four lockfiles. The same pass takes the fixed releases of undici and devalue in the docs, ip-address and fast-uri in the open source web workspace, and DOMPurify in the website, and folds in the open dependency updates that were waiting on a regenerated third party notices file.
One advisory is accepted rather than fixed, because no fixed release exists: http-cache-semantics through 4.2.0, which the docs reach only through Astro's build time cache for remote images. The docs are built to static files, so no request is ever served through that cache. The reason and an expiry are in .npmaudit.yaml.
v1.9.0
Released
This release contains internal changes. See the repository for details.
v1.8.1
Released
This release contains internal changes. See the repository for details.
v1.8.0
Released
This release contains internal changes. See the repository for details.
v1.7.0
Released
This release contains internal changes. See the repository for details.
v1.6.0
Released
This release contains internal changes. See the repository for details.
v1.5.5
Released
This release contains internal changes. See the repository for details.
v1.5.4
Released
This release contains internal changes. See the repository for details.
v1.5.3
Released
This release contains internal changes. See the repository for details.
v1.5.2
Released
This release contains internal changes. See the repository for details.
v1.5.1
Released
This release contains internal changes. See the repository for details.
v1.5.0
Released
This release contains internal changes. See the repository for details.
v1.4.1
Released
This release contains internal changes. See the repository for details.
v1.4.0
Released
This release contains internal changes. See the repository for details.
v1.3.5
Released
This release contains internal changes. See the repository for details.
v1.3.4
Released
This release contains internal changes. See the repository for details.
v1.3.3
Released
This release contains internal changes. See the repository for details.
v1.3.2
Released
This release contains internal changes. See the repository for details.
v1.3.1
Released
This release contains internal changes. See the repository for details.
v1.3.0
Released
This release contains internal changes. See the repository for details.
v1.2.1
Released
This release contains internal changes. See the repository for details.
v1.2.0
Released
This release contains internal changes. See the repository for details.
v1.1.1
Released
This release contains internal changes. See the repository for details.
v1.1.0
Released
This release contains internal changes. See the repository for details.
v1.0.0
Released
This release contains internal changes. See the repository for details.
v1
Released · 608 entries · landed 26 August to 27 September 2026
added
174 entries
Something the product could not do before.
The website editor now creates pages and Writing articles without a source change.
Each new path has an editable title, introduction, summary and rich body, can contain image, video and code sections, and can be previewed before publication. Publishing builds its URL, Markdown version and sitemap entry; articles also appear in the Writing index and RSS feed. Existing articles are listed individually for editing.
Ask AI now starts with the full page, can align label icons with their adjacent copy on desktop, and uses the stronger design model for layout requests while short copy edits stay on the smaller model. Every suggestion still requires review before it enters a draft. It can also propose a new page or article as a private, editable draft, with route collision checks and a publish gate for incomplete content. The network gate's denied-probe test now keeps its port allocated on one loopback address and probes another, so a new positive-control listener cannot accidentally claim the supposedly denied endpoint during a busy CI run.
The Website editor now opens product, solutions, company, writing, legal, and documentation pages from a searchable page list.
Owners can edit page text, images, SEO copy, shared navigation, numbered labels, diagram details, and page-specific blocks. A new Ask AI panel suggests copy, design, and section changes for review in the preview before publication. The Anthropic key stays on the server, and requests have a daily usage limit.
The comparison could vary the application and never the database.
af load compare has always brought a second environment up from the base revision, pinned both sides to one golden, and sent both the same traffic. The one thing it could not vary was the database: database.image lives in the manifest, the baseline deliberately takes everything but the build context from the candidate's manifest, so both sides ran one database build. A person hardening their own storage engine wanted the opposite experiment, the same workload and the same rows on two builds of THEIR database, and there was no way to ask for it.
--image and --baseline-image name the build each side runs, each defaulting to database.image, so a run that names neither is the run that existed before them. When only the images differ the two sides run the same application revision built from the same tree, and a base revision equal to this one is allowed instead of refused: that refusal used to be unconditional, which would have dragged a second application build in alongside the second database and left no difference attributable to either. The report names the axis that differed, revision, image or both, and says plainly that a difference cannot be attributed to either when both moved.
There is still one golden, because two would be two sets of rows. One build wrote that data directory and the other opens it, so a build that CANNOT open it is now its own finding, AF-DB-044, carrying the server's own account: the verdict line is the same sentence for a catalog version, a block size and a write ahead log format, and only the detail beneath it says which. It is noticed when the container stops rather than after the readiness wait, so the most useful verdict in the feature does not arrive as a timeout. A major version mismatch between the two images is refused before either environment is built, by the provider's own comparison against the server rather than against a tag.
A guide walks the whole flow end to end, because the pieces existed on four separate pages and nothing joined them: declare the build, run one workload against both builds on one golden, read the throughput and the latency distribution, then crash the database and read what the recovery kept. Every output block on it is from a run, and the page says which command produced each one.
An environment already running is refused rather than measured. The comparison keeps the environment identifier on purpose, so a database branch already up was adopted for a run that asked for a different build: the copy never happened, the container kept serving the build it was started with, and the report named the one that was asked for. Every number in it was then attributed to a build that did not run. That is now AF-DB-045, which says which build is running and which was asked for. The branch is not replaced, because a branch is copy on write and replacing one destroys everything written since it was made.
And the copy of a golden onto another build is keyed on that build's image ID rather than on its name, because a name is a thing somebody repoints. Rebuilding an image in place and running the comparison again, which is the whole iteration loop for the person this is for, reused the copy made from the previous build while the preflight validated the new one.
The manifest's own invariants are now evaluated against the recovered database, as an arm of the chaos verdict.
invariants are now evaluated against the recovered database, as an arm of the chaos verdict.Until this, the durability proof asserted only over a schema of the engine's own, for the reason engine/internal/pgcrash/workload.go gives: asserting that a table somebody else is writing did not change, while they are writing it, is a claim about a moving target. That reason expires the moment the writers stop and the database answers a query again. Meanwhile af ci ran the invariants early and the chaos run last, so the rules a project states about its own data were never once evaluated against a database that had just been crashed.
Each invariant is asked twice, before the fault and after the recovery, because one answer cannot be read on its own. An invariant that held before and does not hold after is chaos.invariant.broken_by_fault and it fails. One that did not hold before either is chaos.invariant.already_violated, reported and attributed to nothing, because the run inherited it broken. One that could not be asked on either side is chaos.invariant.unevaluated, which a database that never came back is the loudest case of. A project that declares no invariants sees no change at all.
Website owners can edit the homepage and shared navigation from Administration → Website.
The editor previews the real site, saves drafts automatically, and publishes with version history and restoration. Text, media, colors, typography, spacing, placement, and reusable sections have responsive controls. Unedited settings follow the current source defaults.
The SQL workload could be measured on one build and compared against nothing.
af load compare brought a second environment up from the base revision, branched one golden for both sides so they answered over identical rows, and sent both the same traffic under the same seed in interleaved rounds. It could send exactly one kind of traffic: the weighted HTTP mix. So the promise, the same workload on two builds over the same data at the same concurrency, was true of API traffic and false of the workload that reaches the database directly, which is the half somebody changing an index, a lock, a storage parameter or a storage engine is asking about. They could measure their own build with af load sql and had nothing to measure it against.
af load compare --sql compares it. An explicit flag rather than an inference from the manifest, because comparing the SQL workload whenever load.sql happens to be declared would silently change what the command measures in every repository that adds one, and change it inside a pipeline. It is refused with AF-LOD-017 when there is no load.sql block, --scale is refused with it because a SQL workload has no arrival rate to take a fraction of, and a SQL knob typed without it is refused rather than read and ignored.
The unit of comparison is the transaction and the statement inside it, each with p50, p95 and p99 on both sides, because a p95 alone is not a latency distribution and a transaction whose p99 doubled while its p50 held is a lock that no statement row reports. Throughput is committed transactions a second, judged against the same load.comparison.thresholds.throughput_drop: reading the achieved REQUEST rate for a workload that sends no requests would have reported a declared limit as unmeasurable forever, which is a threshold in force that evaluates nothing.
Every knob is settled once, on this build, and handed to both sides, including the mix itself. A derived mix is read from pg_stat_statements on the database it is about to run against, so a side left to build its own would weight the statements by whatever that environment's own startup executed, and the comparison would be differencing two workloads and calling the result a regression.
What a SQL comparison cannot see is written for a SQL comparison rather than copied from the HTTP one. A mix that writes changes the rows, the table size and the index depth it is measuring; a branch is copy on write, so a write heavy round measures the branching on whichever side reached the page first; and autovacuum, the checkpointer and the background writer run on the server's schedule rather than the comparison's.
The lock contention a run was seen to suffer is compared too, because each side's rounds are pooled before they are differenced and the pooling now adds the waits and the waiting time rather than losing them. A pool where some round never read the wait queues says its count is a floor, and one where no round did reports nothing rather than a build that blocked nothing.
The HTTP comparison's report is unchanged, to the character, and a test now holds it that way.
Lock contention, observed while the concurrent SQL workload runs, reported with both statements named, and expressed as two measures a baseline comparison can carry.
The product could already say that a run deadlocked or lost a serialization race. Both of those END a transaction, so the client gets a SQLSTATE and somebody writes it down. The commonest outcome of lock contention ends nothing at all: a transaction queues behind another one, gets its lock, and commits. Nothing is raised, nothing is retried, and a build that takes a lock a little earlier or holds it a little longer moves the percentiles and changes no other number in the result. So a slower run could be reported as slower and could not be reported as slower BECAUSE it blocked, which is the difference between a number and a cause.
The only pg_locks query in the repository sampled the MIGRATION rehearsal, filtered to locktype = 'relation', and answered a different question: what one DDL statement HOLDS and for how long, on a branch nothing else is using. It reports a hold whether or not anybody ever waited behind it, and it is untouched here. pg_blocking_pids had never been called anywhere.
The watching connection that already proves the run's clients overlapped now also asks pg_blocking_pids which of this run's own backends are in a lock queue and which backends stand in front of them. Not a third connection, and never the workload's own clients: an observer that competes with the thing it observes is measuring itself.
af load sql reports how many times one of its clients started waiting, how many backend milliseconds of waiting the samples found, and the pairs. Both sides of a pair are named with the mix's own statement labels rather than with a process id, because each client publishes what it is about to execute and withdraws it when the statement returns. A pid means nothing between two runs; a label is the thing somebody acts on. A holder with no statement against it was idle in transaction, which is to say holding every lock it had taken and running nothing, and that is usually the finding.
The waiter is always one of this run's clients, because a neighbour's wait is never this run's result. The holder may be anything else connected to the same database, and a run blocked from outside itself says so rather than blaming its own mix.
lock_waits and lock_wait_ms reach the workload result, the stored history and af workload compare, so "this build blocked more than the last one" is a sentence the comparison can now make. They are null rather than zero when nothing watched, and that rule matters more here than anywhere else it is already applied: zero lock waits is the most reassuring thing this product can say, so an instrument that did not run must not be able to produce it.
What the sampling cannot see is stated in the output rather than in a document. The wait queues are read every 200 milliseconds, so a wait that began and ended between two samples is missing entirely and the counts are floors rather than totals. Every lock type the server queues on is in scope, including the transaction id waits a row conflict produces, tuple locks and advisory locks, and each pair says which kind it was. Contention that never becomes a wait is out of scope by definition.
added · changed database.data_filesystem.size_bytes gives the branch's data directory a filesystem of its own, which is the layout a disk_fill fault can land on.
database.data_filesystem.size_bytes gives the branch's data directory a filesystem of its own, which is the layout a disk_fill fault can land on.added
Without it the data directory sits on the container's writable layer, the fault is refused before it acts because filling that would fill the machine, and the refusal named a dedicated volume no manifest key provided. The fill now writes until the declared headroom is left, Postgres meets a real No space left on device, and the undo gives the space back.
changed
disk_fill reads the mount at the data directory from the daemon and refuses unless it is a volume this environment created with a size fixed at creation. A mount of its own is no longer enough on its own: a plain named volume has its own device number and is still a slice of the daemon's disk, so it would have passed the old check and taken the machine down having satisfied the guard. AF-CHS-005 now names the declaration that makes the fault possible.
Fault injection was reachable from af chaos and from nowhere else, and the gate that was written for exactly this caught it within hours.
af chaos and from nowhere else, and the gate that was written for exactly this caught it within hours.inject_declared_faults puts it on the MCP server. An agent asks for the manifest's declared faults to be injected into the environment running for its branch, one at a time, each undone before the next begins, and reads back what the system did about each one: whether the fault landed, the evidence the injector recorded at the instant it acted, whether the undo ran, and, around a fault aimed at the database, whether every commit the client was told was committed is still there and whether anything is there that no client ever wrote.
The result carries held and verified separately, and the second is not the negation of the first. Held says nothing was found to be wrong. Verified says the run established what it set out to. A run that is held and not verified has not passed, it has not looked, and it is reported INCONCLUSIVE. A fault that was applied and changed nothing is refused rather than reported as survived.
There is no argument that chooses which faults run, aims one somewhere else, makes one gentler, or turns the durability proof off. A caller that could weaken a fault could make the check easier on itself, which is the one thing this whole surface promises it cannot do.
The decision behind held and verified moved to engine/internal/gate, where the migration evaluator already lives and for the same reason: it was in engine/internal/cli, the MCP package cannot import that, and the alternative was a second implementation of the split this feature rests on. Three callers read one answer now.
This is the second capability the surface gate has named. af load sql was the first, and it had been reachable from one surface for as long as it existed; this one was reachable from one surface for six hours, because two pull requests were each green against a main that did not yet have both of them.
Antifailure could not inject a failure into the system it rehearses, so it had nothing to say about what that system does when a process dies, a node goes away, a disk fills or the network splits.
The nearest thing was engine/chaos, which breaks Antifailure's own engine, and the backup drill, which restores Antifailure's own control plane database. Neither one touches a customer's environment, and a Postgres storage engine developer who asked whether the product could kill a database and validate its crash recovery was asking about something that did not exist.
A new chaos: block declares faults a rehearsal may inject, and af chaos runs them. Seven kinds, all real: SIGKILL to a process inside a container, SIGKILL to a container's main process, a clean stop, a cgroup freeze, a detach from the environment's network, a data directory made read only, and a bounded fill of the filesystem holding it. Each one carries an undo that runs even when the run fails, and each one is aimed at a container resolved from the labels the runtime stamped at create time. A container with no Antifailure label, a container belonging to another environment, and the egress sidecar are refused, and the ownership is read again from the daemon at the instant of the act rather than trusted from the moment the target was resolved.
Around a fault aimed at the database, the run proves the recovery instead of observing that it finished. Concurrent writers commit while the fault lands, and a ledger kept on the client side of the wire records exactly which commits the database acknowledged. Afterwards: every acknowledged commit must still be there, nothing may be there that no client attempted, the write ahead log must have replayed from the position the control file named before the crash to past the last flush a writer saw, and the heap and its index must still agree. The first two claims are about what the database SAID, which the database itself cannot answer, and they are the ones the question was really about.
A fault that was applied and changed nothing is refused rather than reported as survived, because every assertion after it would describe a system that never broke. A process_kill whose pattern matches nothing, a read_only_data fault on a directory whose owner can still write it, a pause the daemon accepted that left the container running: all three are refusals. In the same spirit, findings split across two policy keys. policy.chaos_failure defaults to fail and covers a lost commit, a phantom row, a short replay and a damaged relation. policy.chaos_unverified defaults to warn and covers everything the run could not establish: nothing crashed, no replay is recorded, the control file would not parse, checksums are off so a torn page would not have been seen. A check that found a problem and a check that could not look are different facts.
Network latency and packet loss are not included, and that is a stated limit rather than an omission. Shaping traffic needs tc in the target's network namespace, which the images do not carry and which the environment cannot fetch through a default deny egress policy. A declared fault that silently did nothing would be worse than an absent one.
a-database-that-came-back-was-not-a-database-that-kept-your-commits
The concurrent SQL workload was reachable from af load sql and from nowhere else, and nothing could see it.
af load sql and from nowhere else, and nothing could see it.run_sql_workload puts it on the MCP server. An agent asks for clients on their own connections running whole transactions against the branch's database, and reads back transactions per second, transaction and per statement latency percentiles, deadlocks, serialization failures, retries and the rows the statements actually touched. run_load_test sends HTTP traffic, so the number it reports is the application's latency with the database somewhere inside it, reachable only through whatever the application does on a route the manifest names safe, which is the right measurement for an application change and the wrong one for a change to an index, a lock, a storage parameter or a query.
The gap survived because no instrument in this repository asks whether a capability reaches more than one surface. surfacecheck is about which packages may be imported and whether an export changed shape, wirecheck about whether a documented variable can be delivered by an installation route, routecheck about whether a route the site calls is served. A capability on exactly one surface passes all three, so the instruments were green and could not have been anything else.
tools/paritycheck is the one that asks. The inventory is the exported method set of *env.Orchestrator read with the type checker, and the surfaces are the packages that import it, so neither is a hand written list that drifts. Every capability is reachable from every surface, or carries a row in tools/docs/surface-exemptions.tsv with a kind and a written reason, and each kind has a structural precondition that has to hold: a capability taking a context can never be filed as an accessor, and a row goes stale and fails the moment the gap it excuses is closed. A fourth package importing the capability API fails the gate rather than being ignored, because a surface it cannot classify is "I could not look" and not "I looked and it was fine".
Pointed at the tree before this change it names three: the SQL workload, its thresholds, and af load compare, which landed the same day and is on the command line alone.
A manifest could not say where its infrastructure as code lives.
Every section of the manifest described the copy: what to build, what to run, what it may reach. Nothing described production. So the one thing a twin is supposed to be measured against, the declaration of what production actually is, was a file in the repository nothing in this product had an address for.
infrastructure is that address. It names the stacks that declare production, one entry each, with the workspace and variable files that stack is read through:
```yaml infrastructure: stacks:
- source: terraform path: infra/terraform workspace: production var_files:
- infra/terraform/production.tfvars ```
Nothing in it changes what an environment builds or starts. Every key belongs to one stack, because a workspace is selected inside a single root module and a variable file is passed to a single invocation, and because a repository that declares its cloud in one tool and its workloads in another is the ordinary case rather than the exotic one.
af init drafts source and path by reading the tree. It names root modules rather than every directory with a .tf file in it, because a module a stack calls is a building block: naming it would point a comparison at a library rather than at the thing built from it. It never drafts workspace or var_files, and says so, because which workspace holds production and which of production.tfvars, staging.tfvars and dev.tfvars describes it is stated nowhere in a repository. This is the one section nothing downstream can check, so a guess there would compare a copy against staging and report a number that looked right.
Validation refuses a path that is absolute, that climbs out of the repository, that two stacks name, that is not there, that is a file where a stack is a directory, or that is a real directory holding no file the source can read. Those refusals exist because nothing downstream would report any of them: a directory with nothing in it declares no resources, and no resources is the same answer an application with no infrastructure gives. The last one is the one a deep typo actually lands on, because mistyping the final segment of a long path usually names a directory that really is there.
Everything this engine does to rehearse a change against a twin of production depends on knowing what production is: which services run which images, how much CPU and memory they get, how many replicas, which Postgres version and which server parameters, which buckets and queues exist, what the health probes check, what the network lets out.
All of that had to be written into a manifest by hand, beside the infrastructure as code that actually built production, and the copy went stale the first time somebody edited one and not the other.
A new reader reads the infrastructure instead. Its primary input is terraform show -json plan output, because a plan has already had every variable, local, function, for_each and module evaluated by Terraform, so every number a reader cares about is a literal and nothing is unresolvable. Failing that it reads Terraform and OpenTofu HCL, raw Kubernetes manifests, and the Kustomize image and replica overrides that would otherwise have made the reported image a tag production does not run. Every component says which of those it came from, because a fully resolved description and a partly resolved one must not carry the same authority.
It never executes anything, never reaches a cloud, and never reads a Terraform state file. Those are not conventions. There is one entry point and it takes a directory, so a state file cannot be handed to it in the first place, and a state file is recognised by its content as well as its name, because a state file copied to plan.json is still a state file full of plain text passwords. A test walks the package's real import graph and fails if it can reach os/exec or net/http.
The part that matters most is what it says when it cannot read something. A variable with no default, a value that only exists after an apply, a module from a registry, a Helm chart it will not render: none of those are reported as zero, and none are guessed. Every field is one of three answers, "not declared", "read", or "declared and I could not resolve it", the third carrying a sentence and a line number, and the value of an unresolved field cannot be read at all without asking. A resource behind a count this reader cannot resolve is reported as neither present nor absent, because it is genuinely neither until Terraform runs.
A value the reader worked out and refuses to carry because it is a credential is a fourth answer again, separate from one it could not work out. Both are things it cannot tell you; only one is a hole somebody should go and look at, and a report that counted them together would send somebody hunting a defect that is not there.
Environment variable names, never values. Secret references, never secrets. Four independent controls drop a value and the engine's own redactor catches what they miss, which was measured rather than assumed: terraform show -json writes a sensitive variable's value in the clear in three separate places and marks it in only two of them.
production-was-described-by-hand-beside-the-file-that-built-it
The emulators started empty, so a bucket production has existed nowhere in the twin.
Every emulator an environment starts is configured to keep nothing: LocalStack with PERSISTENCE off, Azurite fresh, and the storage emulator with its backend in memory. That is deliberate, because a twin that inherited the last twin's buckets would be reproducible only by accident. Nothing filled the gap it leaves, so an application that reads its own bucket, queue, table, stream, parameter or secret on startup met an emulator that had none of them, and the failure it reported was a missing bucket, which is a true sentence about the twin and a false one about production.
af up now creates the cloud resources production's infrastructure as code declares, inside the emulators, before any service starts. The requests go through the environment's own sidecar at the provider's own hostname, so what is proved is the route the application has rather than the existence of a bucket somewhere: a host the egress policy does not route is reported refused, exactly as the application would be refused at it.
What an emulator cannot hold is NAMED rather than passed over. Every attribute a declaration carries is accounted for by subtraction, so an attribute nobody thought about is reported rather than dropped: a lifecycle rule that LocalStack stores and never runs, a bucket location the storage emulator answers US-CENTRAL1 for whatever is asked, a SecureString parameter created as a plain String because the surface answers for no key service. A secret and a parameter hold a placeholder and are reported as substituted, never reproduced, because production's value must not be copied into a container a third party image runs. A resource is called reproduced only after it has been read back out of the emulator, and a run in which everything reproduced prints no caveat at all.
A manifest says which desktop application its workflows drive, so the desktop surface can be run rather than only named.
```yaml desktop: kind: electron application: ./node_modules/electron/dist/Electron.app/Contents/MacOS/Electron args: ["./desktop"]
workflows:
- name: sign-in surface: desktop persona: ada description: Sign in to the ledger and confirm you land on the signed in screen. expect: ["Welcome back"] ```
surface: desktop selects the desktop driver, and that driver is handed an application to open or it refuses the run. Nothing in the manifest could name one. So the surface was reachable and every run that could ever have used it was dead one field short of working: the schema accepted the manifest, the validator passed it, the documents were built, an environment was created and paid for, and the runner then refused the job for want of a field no person could have supplied.
kind is electron or macos, and it is stated rather than guessed from the path, because a wrong guess means an application driven the wrong way reports as an application that does not work. application is the Electron binary or the .app bundle, resolved against the directory holding the manifest before the runner is told, because the runner is started from somewhere the manifest never mentions. process is what macOS calls a native application when that is not its bundle's name, derived from the bundle when it is left out, and refused on an Electron application, which is launched directly and never looked up.
The pairing is refused in both directions. A workflow that drives the desktop with no application declared cannot run at all. An application no workflow drives is the quieter mistake and the one that looks like a working setting: the run opens a browser instead, every workflow passes, and nothing anywhere says the application somebody named was never launched.
The workflows themselves are ordinary workflows entries with a surface on them, not a list of their own. A desktop application is driven through its accessibility tree as a persona, which is what a browser workflow already says, so the fields are the same fields and a second list would have been a second spelling of one thing.
The manifest promised a base branch comparison that nothing measured.
schemas/manifest.v1.json said of the whole load block that traffic is "compared between the base branch and this one" and that results "are always deltas", and said of its thresholds that they are "applied to the difference against the base branch, never to absolute numbers". Nothing did that. The one threshold with a baseline, p95_increase, divides a measured p95 by PRODUCTION's own p95 for that route, taken from the traffic source. That is a useful number answering a different question: is this route slower than the fleet serves it, never did this change make it slower. The same file contradicted itself, because the description on the p95_increase key itself has always described the production baseline accurately, and so has the prose documentation. Only the two container descriptions claimed a comparison, and they were the ones a reader met first.
Throughput was the sharper half of the gap. A load run has always recorded the rate it actually achieved, and no threshold anywhere read it, so a build serving half as many requests per second as the base branch passed every check this product had: each request it did complete was fast, and half as many completed.
af load compare is the comparison the schema described. It brings a second environment up from the base revision, branches the SAME golden for both so the two sides answer queries over identical rows, sends both the same weighted mix in the same order under the same seed, and differences the two. It reuses the oracle's baseline mechanism rather than reinventing it, as the side effect family's base twin already does. load.comparison.thresholds carries p95_increase against the base branch, throughput_drop, and error_rate_increase, and the two container descriptions now say which of the two baselines each key is measured against instead of claiming one for all of them.
A threshold that could not be evaluated reports unverified and exits non-zero, never pass. A route present on one side only is the case that matters most: a build that stopped serving a route has no p95 to be slower than, and calling that a clean comparison would hide the loudest result a run can produce.
The comparison says what it cannot control, on every report. Two runs against two environments are not a controlled experiment. They are sequential, because two environments sending traffic at once on one host would contend with each other and measure that instead, and the seed makes the request sequence the same while making nothing else the same. A difference is a difference, and the threshold is what turns one into a verdict.
Separately, af workload compare computed a per route table and printed none of it. The routes were in the JSON and absent from the text a person reads, so a route that doubled in p95, and a route that vanished between two runs, were both invisible to anybody not piping the output through a parser.
the-load-block-promised-a-base-branch-comparison-nothing-measured
A workflow says which surface it drives, and the manifest names all five.
```yaml workflows:
- name: subscribe surface: web ```
web, terminal, desktop, ios and android. All five may be written, including the ones a build carries no driver for, and that is the point: a build registers the drivers it has, so a manifest naming a surface this build cannot drive is refused BY NAME, against the surfaces that build actually carries. A schema that simply refused the value would say only that something is unknown, which reads like a typo and sends somebody looking for a spelling mistake instead of a release.
Without this a driver could be finished, correct and unreachable, because nothing in the manifest could ask for it. That is the same defect the terminal driver had for its whole life, one surface over, and the answer this time is a key that exists before the driver does.
The refusal happens at both layers on purpose. The engine says it while reading the manifest, so the answer arrives before an environment is built, and it names what this build can drive rather than what the schema allows, because those are different lists and only one of them helps. The runner says it again before it drives anything, so a surface nothing drove can never come back green, and it says it as a blocked RESULT rather than by throwing, so one workflow naming a surface nobody built does not take the nine beside it down with it.
surface: terminal on a workflows entry is answered with where it belongs rather than with a list it is missing from: a terminal workflow needs a program to run where a browser workflow needs a persona to sign in as, so it is written in terminal_workflows.
A golden could not carry PostGIS, pgvector, TimescaleDB, or a table stored in an access method that is not the heap.
The docker provider hardcoded postgres:<version>-alpine, which ships the contrib modules and nothing else, and hardcoded shared_preload_libraries=pg_stat_statements. An extension out of that set was not a limitation anybody could work around, it was a refusal: the restore stopped on the first object that needed one, and AF-DB-007 said so and offered only "point the provider at a different service". A Postgres storage engine developer asking whether their tables would survive a rehearsal was being told no.
database.image names the image the provider runs Postgres from, database.extensions is created in the golden before the source is copied into it, and database.preload_libraries is ADDED to shared_preload_libraries rather than replacing it, because dropping the statistics module leaves the insights reading a permanently empty table on every environment. The list is recorded on the golden image and read back when a branch starts, so a branch carries what its golden was built with even after the manifest stops asking: a library like timescaledb is loaded by the postmaster, and a server holding its catalog entries without it does not start degraded, it does not start.
Two things a named image can silently get wrong are now refused instead. An image declaring a volume over the data directory would publish a golden holding no rows at all, because a golden is the container's filesystem committed and a declared volume is not in it. An image on a different major than the manifest declares would give every environment a Postgres the application does not run. Both are checked against the running server before anything is loaded.
A table created USING an access method out of an extension now survives the golden, every branch of it, pg_dump and pg_restore, and a subset's binary COPY, with pg_class.relam intact on the far side. Masking is the one thing that will not touch it, and it now says so at planning time rather than partway through: it addresses a row by the primary key or by ctid, both of which are guarantees of the heap, and against citus columnar on Postgres 17.2 each is refused outright while the table accepts a primary key regardless. The refusal names the table and the access method, and it is narrow enough that a column nothing rewrites goes through untouched, which is what lets a golden hold such a table at all.
a-golden-could-not-carry-an-extension-or-a-custom-access-method
The iOS surface now drives real applications.
It was declared and scaffolded: a job naming it was refused, so an iOS application could not be rehearsed at all. It is now implemented against the iOS Simulator through Appium's XCUITest driver, and proven against one: a workflow that signs in passes, one that presses a deliberately broken control fails with the application's own error quoted back, and one whose expectation the app never shows comes back unverified rather than pretending either way.
The approach is the one every other surface here already uses. An iOS app's accessibility tree is what a screen reader reads, which is what these workflows are written against, so it is normalized into the same snapshot shape the browser produces. The planner, the judgement and the verdict are then the existing ones, unchanged: the same workflow sentence means the same thing on a web page and in an app, and a mobile verdict is decided by the same function that decides a web one.
An android surface is added alongside it, implemented against Appium's UiAutomator2 driver and deliberately NOT marked available. No run has driven an application with it: the emulator on the machine it was written on could not finish booting, and its own system watchdog was restarting the device. Claiming it would be a promise nobody has tested, and the iOS work is the argument against doing that: driving a real device for the first time found three defects that every unit test had passed over.
An Appium server is an external tool, the way the engine binary is. A run that cannot find one BLOCKS every workflow with a reason instead of failing it, because our own tooling being absent is not evidence about the application.
Three things measured against real devices rather than assumed, each of which had already produced a wrong answer:
An empty iOS text field reports its own placeholder as its value, so a field nobody had typed in read as already answered. The planner skipped it, submitted an empty form, and the application's complaint about it would have been reported as the application's fault.
The iOS software keyboard is part of the application's accessibility tree, so opening one text field turned a two control screen into one offering twenty six letter keys, shift, Return, Emoji and Dictate as things to press. The keyboard's window is now left out, because it is system interface and no workflow is about the letter q.
A screen recording stopped before its recorder had started produced no file, and one stopped uncleanly produces a file of plausible size that no player will open. Recordings are now held until they are genuinely capturing, stopped with a signal the recorder handles, and checked for the index that makes them playable; a recording that does not survive is reported as no video rather than as a path to a broken one.
examples/mobile-probe is the application these were found against: the same three controls in SwiftUI and in Java, including a failure path, so a driver can be shown saying no as well as yes. The Java half is built and installable and has not been driven, for the reason above.
A concurrent SQL workload, so a database change is measured as a database change.
Everything this engine could send at a preview environment went over HTTP. A load run sends a weighted mix of requests, a scenario walks a journey, a workflow drives a browser. All three reach the database only through the application, so every number they report is the application's latency with the database somewhere inside it. That is the right measurement for an application change and the wrong one for a change to an index, a lock, a storage parameter or a query: the person making that change wants transactions per second and the cost of one statement, and could only reach either through whatever the application happens to do on a route they can call.
af load sql opens connections to the branch and runs statements on them. N clients, each on its own connection, each running whole transactions inside one BEGIN and COMMIT, with think time between them and a seed that makes two runs execute the same sequence. It reports throughput, transaction latency, the cost of every statement, deadlocks, serialization failures and retries. A deadlock that was retried into a commit is counted as contention rather than as a failure, because that is what a correct application does about a deadlock.
The statements come from a document in the repository, or from pg_stat_statements on the branch, which is the traffic that really ran weighted by how often it ran. The derived path cannot recover the parameter VALUES, because the statistics normalise them away, so it prepares each statement to ask the server for the parameter TYPES and generates values of those types. Two consequences are reported rather than hidden: a write is refused unless the manifest allows one, and every run says how many rows its statements actually touched, so a run that matched nothing cannot be read as a fast one. A statement that will not prepare at all is refused by name with the server's own message, which catches a query against a column the change dropped before a single transaction runs.
The claim that a run was concurrent is measured rather than made. A separate connection samples pg_stat_activity while the run is going and reports how many distinct backends it saw and how many were inside a transaction at one instant. N goroutines are not N database sessions. A run whose observer could not connect reports nothing rather than zero, because "no overlap" is a finding and "nobody looked" is not.
A run that committed no transaction reports no throughput and no latency and exits non-zero. Every threshold it carries passed over an empty measurement, which is not the same as passing.
sql_workload is a fifth workload_kind in the control plane, so a hosted run dispatches it, stores its numbers and reproduces through the same plain af load sql command. Migration 0048 also rebuilds the shape constraint the four kinds already had: it was a CASE with no ELSE, and a CASE with no ELSE returns NULL for an unmatched value, which makes a CHECK pass. A fifth kind would have been entirely unconstrained while the constraint went on claiming otherwise.
a-database-change-could-only-be-measured-through-the-application
The runner can drive a desktop application.
It has named four surfaces for as long as it has had a surface abstraction, and it could drive two. A job that asked for the desktop was refused, correctly, because a scaffolded driver returning an empty result would have reported a green run that tested nothing. It was still a no.
Desktop now drives native macOS applications through AXUIElement, the platform's own accessibility API, and Electron applications, which is where VS Code, Slack and Discord live, through Chromium's accessibility tree. Both reduce to the same snapshot a browser produces, so the planner, the expectation check, the verdict and the live stream are the ones a browser run already uses, and a desktop result counts and reads exactly as a web result does. A workflow written as a sentence, press Continue, expect Welcome, carries across unchanged.
Native accessibility is a permission a person grants in System Settings, and a locked screen withholds every application's tree whether or not it was granted. Both are reported as themselves, with the step to take, never as an application with no controls on it.
Terminal workflows: a manifest can now test a command line program, including a full screen one.
The terminal driver had been written and unit tested for some time, and no customer run had ever reached it. The runner read a terminal list out of the job document, the engine's job document had no such field, and the manifest had no key that could have filled one, so the surface abstraction described four surfaces and the product could ask for exactly one. terminal_workflows is that key, and a terminal result is counted in the verdict and printed in the report exactly like a browser one.
The driver it reaches is a different driver. It was line oriented, which is enough for a program that reads a line and prints lines and is nothing at all for the one kind of command line application nobody can test by hand: a program that takes over the screen will not start without a terminal, reads raw keystrokes rather than lines, and leaves its meaning in a grid of cells rather than in the bytes it wrote. A workflow that declares a screen is now given a real pseudo terminal of that size, sent the bytes a keyboard sends for <down>, <ctrl-c> and the rest, and judged against every screen the program drew. Those screens are the steps in the report.
Arrow keys are sent in the encoding the program asked for rather than a guess, because a program that has taken the screen ignores the other one in silence, and a key that silently did nothing is the worst shape a test failure can take.
Opening a pull request check in the console showed how many workflows passed and nothing about what the run found.
Everything a run gathers past the counts, the findings and their fixes, the migration statements and the locks they take, the invariants and the rows that broke them, the access probe readings, the load percentiles, reaches the control plane through the pull request callback rather than the events stream, so none of it is projected into the runs and verdicts tables the run page read. It lands whole in the generation, as the report the engine already rendered for a person, and until now nothing read it back. A check that said one workflow failed linked to a page that said no run had reported.
The run page reads that report now, at /runs?pr=<n>, and shows it in full under a scannable strip of the counts: the findings and their fixes, the migration rehearsal, the invariants, the load, the reproductions folded one click away. Nothing the run measured is dropped between the engine and the person who has to act on it, and it needs no run row to exist, so a repository reports here the first time its check runs.
The Start a run card dispatched agents and load, and that was all it could say.
The workflow template every customer runs already declares a seed and a concurrency input, and the engine already reads them: a seed makes two runs decide the same way so a before and an after can be compared, and a concurrency ceiling caps requests in flight so a load run stays inside a limit. The console had no field for either, and the dispatch verbs did not forward them even when something set them, so the one place a customer drives runs from could neither reproduce a run nor hold a load down. The card now carries the knobs: a seed on an agents run, and seconds, scale, concurrency and a seed on a load run.
Each knob is optional and a blank one is left out of the dispatch rather than sent as a zero, so it reaches the command's own default. That is not only tidy. A seed or a concurrency the customer never set would reach a workflow file that may predate those inputs, which GitHub answers with a 422 that fails the whole run, so an unset knob must not appear in the request at all.
An agent driving a project through the MCP server could rehearse, review a change and read a verdict without leaving the session, and then had to leave it for the one thing it could not do from there: get onto a newer Antifailure.
The newest engine shipped, the session kept running the old one, and nothing on the tool surface said so or offered to close the gap.
The server serves an upgrade tool now. It installs the latest verified community release of the CLI and its bundled runner in place, downloading the build for this platform, verifying its published SHA256 checksum, and swapping the binary and the runner source atomically; it leaves shell profiles and project files alone. The check option reports the latest release beside the installed version and changes nothing. It is the same in-place update af update performs, handed to the server as a function value rather than reimplemented, so a tool call and the command cannot install different things.
Two honesties come with it. Applying replaces the file this running server was started from, and the running process keeps its own open copy, so the new version does not take effect until the server is restarted; the result says so and reports the versions before and after rather than letting a caller believe the tools changed under it. And a binary the installer does not own, an enterprise build, a platform with no release, a checksum mismatch or a failed download is reported as a refusal that changed nothing, with the reason and the next step, never as an upgrade that found nothing to do.
Enterprise and demo requests were recorded and no operator could see them.
The "talk to us" form writes a row into enterprise_leads, and migration 0035 arranged that table so the anonymous public endpoint can only INSERT and can never read a row back: a serving role that could also SELECT there is one query bug away from publishing every prospect's name, company and message. That boundary was right, and the reader it left was wrong. The only way to see a lead was af-control-plane-backup leads, a command that needs the migration role or the cluster superuser, so in practice a lead landed and nobody with the operator portal open could read it. It was the waitlist failure the table was built to end, one step downstream: a form that consumed what somebody typed.
The operator portal now has an Enterprise Leads page under Administration. It reads through the operator pool, whose role already held SELECT on the table by 0023's default privileges, and a one line migration grants that same role UPDATE so a lead can be marked handled. The serving role is untouched and stays INSERT-only, so the leak boundary is exactly as 0035 drew it. The queue is oldest first, the message opens in a panel rather than a cell, and marking a lead handled records who did it and when so nobody is answered twice or never. It mails nobody, and the page says so.
An agent editing code had no cheap way to ask Antifailure whether the change it had just written contained a bug.
The static, model-backed code reviewer already existed, but the only ways to reach it were to submit a whole rehearsal, wait for an environment that a correctness bug does not need, and then read the finding off a run, or to open a pull request and scrape the comment the reviewer left on it. Both put the fastest, cheapest signal the product has, a model reading the added lines, behind the slowest machinery it owns.
The review_change MCP tool exposes that reviewer on its own. It reads the diff, not the twin, so it opens no session, builds no image and needs no database: mid-edit, an agent asks it and gets the concrete defects the model found on the lines the change added, each with a rule, a category, a level, a bounded description, a fix and a file:line into the agent's own changed code. A location is allowed here, unlike a security finding, because it points at the caller's own diff rather than a copy of production, and the projection still refuses to carry anything shaped like a captured body, response or row, because a diff has none. A change that touches no code is reported as nothing to review rather than a clean pass, and a change with code but no model key configured is reported as skipped with the reason, because a review that did not run is not a review that found nothing. The finding's level is the project's own policy, so it advises by default and never blocks a merge on a model's say-so.
an-agent-mid-edit-could-not-ask-antifailure-to-review-its-change
The runner now captures response bodies, the rendered DOM, and every request it reached, so two detectors that were wired but starved can fire on a real run.
The browser recorded no response body and no DOM, and it logged only the GET pages it navigated to. Two consequences followed, both silent because the engine consumers were already in place and simply handed nothing. canary_leak scans the run's DOM and response bodies for a secret by shape, a Stripe key or a private key that reached a response; those streams were always empty, so even a bundle that shipped a live key to the browser produced no finding. And the injection family fuzzes the routes the run observed reaching; with only GET navigations recorded, a POST, fetch or XHR API route was never sourced, so the write path where most real SQL injection lives was never exercised.
The runner records, per same-origin response, the request that reached the server with its method, so a POST or fetch route is fuzzed as the method it was, and the bounded body of each document, JSON or text response, so a leak family has something to scan. It captures the rendered text of each page it stood on for the same reason. Everything is bounded in count and in bytes, a cross-origin third party's body is never captured, and a binary or oversized response is dropped before it is read. The bodies and the DOM stay inside the engine as evidence: a finding reports a location and a kind, a route carries a method, a path and parameter names, and neither ever carries a captured value.
A static code reviewer that reads the change's added lines for the correctness defects a diff introduces.
Every lane the product had rehearsed a change against a twin: it built the environment, ran the workflows, drove the security families, and reported what the change DID when it ran. Nothing read what the change WAS. A correctness bug that no workflow happened to exercise reached no verdict at all: an off-by-one on the last element, a nil dereference on a path the diff just added, an error that is checked and then dropped, a boundary the new code does not hold, a new function nothing calls, a value decoded as the wrong shape, an ordering hazard. These are the bugs a senior reviewer catches by reading a pull request, and the product could not read one.
af ci now runs the reviewer as one more collector beside migration, egress, masking, load, cleanup and the security families. It reads the diff and not the twin, so it runs before the environment is brought up and is worth having even on a run whose environment never started. It sends the change's added lines, with the line numbers they carry in the new file, to the model the user already configured, and maps what comes back into ordinary findings that ride the same verdict, exit code and pull request comment as everything else. A finding points at a line the author just wrote.
It is advisory by default. The reviewer is an LLM reading a diff and its findings are probabilistic, so a review finding defaults to warn rather than fail: it advises without blocking a merge on a model's say-so, and a project that trusts it raises policy.review to fail. The level comes from the manifest, never from the collector.
It is honest about absence. With no model key configured the reviewer is skipped and the run says so, in the words a person can act on, rather than reporting a clean pass it never earned. A docs-only or configuration-only change touches no code surface, so no reviewer runs, no model is called, and nothing is spent. A change the router cannot read, a diff the reviewer cannot fetch, a model that could not be reached, and a model that answered with something unreadable are all recorded as notes and produce no finding, because a gap in our tooling is a fact about us and must never redden somebody's build.
The call carries the user's own source to the user's own provider with the user's own key, the same BYOK trust model af model test uses, and nothing Antifailure hosts is involved. It goes through the air gap guard under its own site, so an air gapped installation refuses it at the dial rather than reaching a provider it was sealed away from, and the ledger attributes the attempt to the code reviewer by name.
The side_effect security family counts the dangerous external effects a change makes, a PaymentIntent, an email, an outbound webhook, and reports the ones this change added over the base branch: "the base branch made 1 and this change made 3".
It read a base twin's effects to do that, and it handled a missing base twin correctly, leaving the comparison unmade rather than reading an absent base as a base of zero. But af ci never built the base twin, so the missing-base path was the only path there ever was. The increase rule was fully built, correctly consuming a baseline it was never handed, and it sat dormant on every run.
af ci now brings a second environment up from the base revision when the change routes a family that reads a baseline and there is a change to compare. It is the oracle's baseline mechanism reused: the base is resolved as the merge base with the base branch, its images are built from a git archive of that revision, and it is pinned to the candidate's own golden so both sides branch one database and a difference is the code's rather than two databases'. It runs the same exploration and the same workflows the candidate ran, so the two effect counts are of the same drive.
The base twin is stamped ephemeral before it comes up and torn down by a deferred teardown after, so a crashed run's base environment is collected by the reaper within the run's budget rather than living the manifest's day, and a base env left standing is named with the exact af down --branch command to remove it. It fails closed: a base that is the same commit, that will not come up, whose workflows did not run, or whose effect logs could not be read is a note and no comparison, never a base of zero that would read as effects the change did not make. A change that routes no baseline reader builds no second environment.
examples/github-reaper-workflow.yml runs af env reap --yes on a schedule, so cost never depends on a human remembering to sweep.
examples/github-reaper-workflow.yml runs af env reap --yes on a schedule, so cost never depends on a human remembering to sweep.It belongs beside the workflow that creates environments, on the runner or cluster where they live and with the same credentials, and it matters most on the Kubernetes runtime, where a namespace a killed run left up keeps costing money until something removes it.
The dependency checks read the versions and not the shape of the change.
A pull request could add a postinstall hook, point it at a script it downloaded with curl and piped into a shell, or move a dependency's source from the registry to a git URL, and the rehearsal classified the file as a dependency change and looked no further. The advisory scan that runs beside it answers a different question, whether a version is known-vulnerable, and none of these is a version.
There is a supply-chain family now, on the dependency surface. It reads the lines the change ADDS to a manifest or a lockfile, never the branch's absolute dependency tree, so an install hook a lockfile already carried is not counted against this change and one this change adds is. It catches three shapes the advisory scan does not: a lifecycle install hook added to a package manifest, an install step that downloads a payload and runs it, and a dependency whose source moved off the registry to a version-control URL. The last excludes a repository or homepage field, whose value is a URL by design, and requires the scheme to begin a dependency value rather than appear in prose, so a metadata line is not the false positive that gets the rule switched off.
The family refuses a change on policy grounds, so its findings carry the policy-denial exit code, and a project gates each through security.supply_chain.install_script_added, security.supply_chain.binary_download_in_install and security.supply_chain.registry_source_changed. Each defaults to warn, because a dependency change is noisy and a check that reddens every bump is one a team mutes, and a project raises it to fail once its dependency changes are clean. A finding names the file and the risk and never prints the line.
A run could only be read after it was over, and the video it recorded was surfaced nowhere.
The runner is one job in and one document out, which is the right contract for a verdict and the wrong one for watching: by the time the document is written the run has finished, so there was no way to see an agent drive the application as it happened. The browser recorded a video of every session and it reached no screen at all.
There is now a live channel. The runner streams agent state, steps, and frames over a local socket while the run is still going. A new command, af watch, runs the workflows and draws them in the terminal, one pane per agent, switchable with the number keys, the arrows, or tab, with honest states rather than a spinner. The control-plane console gains the same multi-agent watch view, streaming from the runner edge. A run targets a surface: web and terminal are driven for real, and desktop and iOS are declared and refused loudly rather than reported as a green run that tested nothing.
The frames stay on the machine that produced them. They reach the terminal and the console straight from the runner edge and are never written to the control plane, which holds counts, verdicts, and a reference to the durable recording, never a body. A test fails if a frame's bytes ever reach the control-plane shape.
The migration lint could tell you a change would lock a table or rewrite it, and it stayed silent on the change that quietly opened the data.
A migration that disabled row level security, rewrote a policy so it admitted every row, granted a table to PUBLIC or anon or authenticated, dropped the column a table is scoped by across tenants, or handed a role BYPASSRLS, SUPERUSER or CREATEROLE, applied cleanly and reported pass. Every one of those is a migration that succeeds in review and lets one tenant read another's rows, and none of them was what the seventeen availability rules were watching for.
There are now database-security rules on the same migration path, reasoning about the statements the change adds rather than the branch's absolute grant and role catalogue, because a snapshot restored twin inherits roles the change never touched and a rule that read them would fire on inherited state. They carry the security namespace in their finding rule and route to a security.db_security policy key of their own, so a project can gate a broadened grant apart from a non concurrent index and the release gate gives each one a security exit code: a broadened grant is refused on policy grounds, and the rest are verification failures. Each defaults to fail and a manifest can lower any of them. The permissive-policy rule fires only on a tautological clause, never merely on a policy that omits a tenant column, so an admin only policy is not the false positive that gets the check switched off. BYPASSRLS is caught apart from a grant, because it is an attribute on a role that makes every policy stop applying to that role's sessions rather than a privilege on a table, and conflating the two is how a review misses it.
Nothing read the responses the run rendered.
A change could start returning a column it used to withhold, compile a secret into a client bundle, or render a social security number into a page, and the rehearsal drove the twin, captured the pages, and looked at none of it. The secret shipped, green.
There is a sensitive-data leak family now. It reads the DOM and the response bodies the run already captured against the sanitized twin and looks for two things: a value planted in the twin that must never appear in output, which is an exact match with no false positive, and a secret or a piece of personal data by shape, which is what catches the leak nobody could have planted a value for. A Stripe secret key or a private key reaching a response fails the merge; a national id or a bank account number reaching one is reported. It reuses the same detector lexicon that reads a masked database back, so a secret means the same thing in both places, and it inherits that package's iron rule: a finding names the stream and the kind and never the value, which stays inside the engine against a copy of production.
A project gates it through security.canary_leak.secret_in_response and security.canary_leak.pii_in_response, and a secret in a response carries a security exit code from the release gate. The planted-value path is wired for the day a seeder plants canaries in the twin; the shape path works today, against the evidence the run already has.
The security suite could see that a change reached an outbound host, but not that it was coaxed there, not that a payload changed how a query ran, and not that the change now creates three PaymentIntents where it used to create one.
The egress and capture logs counted requests; nothing counted what a request MEANT.
Three security check families now read those same logs and drive the same twin to prove behavior rather than match a string. The injection family fires SQL, command, template, NoSQL, path traversal and dynamic query payloads at the endpoints a change touched and reports only the ones it can prove altered the application: an injected sleep that moved the response time, a database error that surfaced the schema, a template that returned the evaluated product rather than the expression, a traversal that read a file outside the application root, an operator that turned a refusal into an answer. Every payload is measured against a benign control, so a slow endpoint or a reflected value is never mistaken for a proven one, and a finding names the endpoint and the class and never the payload value.
The SSRF family reads the egress decision log for a request the application was coaxed into making against an internal or metadata address, and takes the firewall's refusal of that request as the proof. It is a reader over the existing egress mechanism, not a second firewall: an allowed internal request is the twin talking to its own services and is not a finding, and a refused external host stays the egress layer's surprise to report. A refused reach to a loopback, link local, private, carrier grade or metadata address is the finding, and a refused internal reach a webhook or callback produced is reported as callback drift, because the fix is different.
The side effect family counts dangerous effects by meaning, a charge, a refund, an email, an SMS, a webhook, a cloud create, a cloud delete, a queue publish, and compares the count against the same workflows run on the base branch. An increase over the base is a policy denial the base branch did not incur; a destructive operation fires on its own with no baseline defense, because the base deleting a bucket too is not a reason to allow deleting one. A base twin that could not be built leaves the increase unmeasured rather than reporting every effect as new, because a zero that means "did not measure" is the defect this repository keeps finding in its own instruments.
Each family emits an ordinary security finding in the security namespace, so it rides the verdict, the exit code and the read_security_findings projection the spine already wired, and a finding never carries a response body, a row, or the payload that produced it.
a-security-suite-with-no-injection-ssrf-or-side-effect-family
A change can be exercised by a population of personalities, not one scripted path.
A workflow declares a goal, and until now one agent pursued it one way. A regression that only a different kind of user provokes, the one who reads every label before acting, the one who takes the first prominent control and never scrolls, the one who probes the unusual path, went unseen, because the single agent never behaved like them. A single path through the twin is a single opinion about how the change behaves.
A new top level diversity block turns on a population. It draws agents from a built in catalogue of ten personalities, each a behavioral lens ported from a diversity model: an explorer, a fast actor, a cautious analyst, a skeptic, an edge case prober, and five more. A personality is HOW an agent behaves, kept deliberately separate from the persona it signs in as, which is WHO it is: a personality reaches no provisioning and changes only which of the controls already on the page an agent prefers and how it explains the choice. It can never name a control that is not there, because the action grammar and the exact name refusal below the personality preamble are untouched.
The whole plan is resolved from a seed: the same seed against the same application assigns the same personalities and the same behavioral profiles, step for step, so a personality driven finding replays the way an exploration does. Because each personality's reasoning is part of the model prompt and the cassette keys on the whole prompt, N personalities over one page record N answers and the pipeline stays exactly reproducible and free to replay, with a missing recording blocking loudly rather than reaching the network. Off by default: a manifest with no diversity block, or one that is disabled, runs exactly one neutral agent per workflow as before.
The personalities run through the same key the runner already reads from the environment, the user's own, on the ungated model path. Nothing here gates them behind an edition, and a test refuses the import that would.
A security suite that no rehearsal ever ran.
The contract had shipped: a Family interface, a registry, the policy keys, the exit codes, and read_security_findings. Nothing called Probe. A family could be written, registered, and documented and still never run against a change, because the one place a run assembles its findings, ci's finish, had a collector for the migration, the egress, the masking, the load, and the cleanup and none for security. The whole suite was capability that looked shipped and did nothing, the shape this repository calls a dead, shippable gap.
af ci now routes the change through the family registry, runs each selected family's Probe against the targets the diff touched while the twin is still up, gates each on its edition feature at the probe call site, and folds the findings into the same run.Findings the verdict, the exit code, the pull request comment, and read_security_findings already carry. A family whose surface the change did not touch is not run, an unlicensed one is skipped with a note that names the feature rather than dropped, and the empty registry the spine ships produces no finding and does no work at all. The reader families read what the run captured, the egress and message logs, the browser evidence, and a base twin's counts, through additive accessors on the family Input that report absent rather than empty, so a reader that was handed nothing fails closed instead of passing.
The security check suite had no shared contract to land on, so each family would have reinvented the verdict, the exit code and the data boundary, and the four spellings would have drifted.
The suite fans out into families that each rehearse a change against the sanitized twin: broken access control, injection, a leaked canary, an unexpected side effect, row level security left off, a weakened response header, a dependency worth a second look. Written in parallel with no shared seam, each would have had to decide for itself how a finding becomes a verdict, which exit code it carries, how it renders in the pull request comment, and how it stays inside the data boundary the control plane depends on. Those four decisions made seven times are four contracts that agree until somebody edits one, which is the drift this repository keeps finding in itself.
This is the spine they share, and it carries no family logic of its own. A security finding is an ordinary report finding in a new policy namespace, so it inherits the verdict, the exit code, the comment and the control plane boundary for free. The change router gains an auth surface for who-may-do-what and emits the routed units a family and a browser personality run against, so a check runs at exactly the endpoint or boundary a diff touched rather than across the whole application. The manifest gains a policy.security block keyed by the finding rule, so a project decides what each finding does the same way it decides everything else. A read_security_findings tool projects those findings for a coding agent, carrying a rule, a level, a location and a bounded description and never the offending body, response or row. And the gate turns a security finding into a dynamic security exit code, a verification failure when a family proved a hole and a policy denial when it refused a change on policy grounds.
The registry ships empty and wired: with no family registered, the plan gains no security check and the report is exactly what it was, which is what lets each family land afterwards by registering against a contract that already holds.
database.provider now accepts rds, which branches an Amazon RDS for PostgreSQL instance.
database.provider now accepts rds, which branches an Amazon RDS for PostgreSQL instance.The comparison table in benchmarks/ had a row for RDS and no code behind the name, while most Postgres on AWS runs on plain RDS rather than Aurora.
A branch is RestoreDBInstanceFromDBSnapshot. RDS provisions an instance and hydrates a new volume from the snapshot, and the volume is every byte of the database, so the provider declares CopyOnWrite: false and its branch time is minutes and grows with the data. It refuses an Aurora cluster and names the aurora provider instead.
A restore inherits production's whole role catalog, not just its master password, so the provider closes every inherited login and ends its sessions before a golden is masked, again before it is published, and again on every branch. It rotates the master password to a key derived per instance, turns IAM database authentication off, and places the instance in the source's own subnet group and security groups with no public address. Connections default to verify-full against AWS's published RDS root bundles. Every resource is bound to the source instance's ARN and carries an HMAC receipt under the branch key, so a second source in the same account is never adopted or deleted, and a branch interrupted before its logins were closed is never handed out.
Requests follow AWS's published RDS service model: tags go out as Tags.Tag.N, which is what botocore's query serializer sends, and an instance's subnet group is read as the structure AWS returns. Every request goes through the air gap guard, and the provider is refused under AF_AIR_GAPPED.
After rotating the master password, the provider waits until RDS no longer lists it as pending and the new password actually opens the database, because RDS reports an instance available before a password change is in force. The attestation, the masking rules hash and the provenance are stored in tags as base64, because a tag value refuses the braces, quotes and commas a document contains. Beside the attestation's chunks go a count of them and a digest of the whole, so a golden that lost a chunk, or had one shortened or rewritten, reads as unverified and cannot be branched instead of passing a shorter attestation off as the whole one.
Part of this has run against real AWS. One run in us-east-1 reached a masked, verified candidate over verify-full against RDS's own certificate, with a snapshot in 1 minute 11 seconds and a restore in 5 minutes 4 seconds at 20 GB, and found the two defects above and a third in instance role credentials, all fixed. It stopped before a golden was published, so publishing, branching, isolation and destroy are proved against a fake RDS control plane over a real Postgres only. The benchmark prints UNMEASURED for every wall clock cell, and the copy on write verdict is recorded as unproven, because one restore at one size cannot decide whether branch time grows with the data.
rds-was-a-row-in-the-comparison-table-with-nothing-behind-it
Custom roles: an organisation can define a role, grant it permissions, assign it to a member at a scope, and have that member's requests allowed or refused accordingly.
The enterprise licence sold this, LICENSING.md and ee/README.md listed it, and no organisation could have a custom role. Both halves of the mechanism existed and neither was joined to the other. permits() in the community API has asked an installed permission resolver on every request since it was written, and ee/web/rbac has always carried a role model, a validator, a scope resolver and a reviewable file format that answer in exactly that resolver's shape. Nothing stored a model, so there was nothing to build a resolver from, and nothing installed one outside that package's own tests.
What is new is the join: migration 0046 stores a model per organisation under row level security, the enterprise edition mounts four routes that export, preview and apply it as a reviewed YAML file, and its entry point installs the resolver. The resolver reads the model on every request a built-in role would refuse, per request rather than from a cache, for the reason orgProcedure already re-reads the member's role: a model held in memory is a revocation that does not take effect on the replica holding it.
rbac is now enforced rather than reported. It leaves the unenforced map in ee/engine/license/license.go, which is empty as a result and deleted along with the licensegen warning it fed and the checks that read it, because a warning that cannot fire teaches whoever reads it that this state does not exist.
The four built-in roles are unchanged. A resolver is asked only where the built-in table has already refused, so a custom role can widen a role and can never narrow one. That was the documented rule and not the behaviour: permits returned a resolver's false over the table's true, under a test named "a resolver cannot take away what a built-in role grants" that asserted it had.
members.list now returns each member's user_id. A grant names a person by that id, and no route an organisation can call answered it: the list stripped the field from every row, while nextCursor carried the last row's id to the same caller anyway. So the feature above could be used only by somebody with a database console. The id rather than a GitHub login, because a member who signs in through single sign-on can have no GitHub login at all.
Any member of an organisation can read these ids, viewers included, because members.list needs only environments.view, which all four built-in roles hold. A viewer already sees every member's login, name and role, and an id is not a credential: writing a grant with one needs members.manage. The list is bounded to the caller's own organisation by two row level security policies, one on members and one on users, and either one alone holds. A test proves that with no cursor and with a cursor forged from another organisation's member.
database.provider now accepts cloudsql and azurepg, so a manifest can name the managed Postgres of Google Cloud and Azure rather than only of AWS.
database.provider now accepts cloudsql and azurepg, so a manifest can name the managed Postgres of Google Cloud and Azure rather than only of AWS.Before this the runtimes, the emulators and the secret stores were symmetric across the three clouds and the database was not: aurora was the only managed provider, so "works on AWS, GCP and Azure" was true of three dimensions out of four and the fourth was the one holding the data.
The two are not copies of the Aurora provider and each says where it stops.
cloudsql branches with a Cloud SQL FAST clone, which Google creates from an Instant Snapshot and documents as metadata only, so branch work does not depend on the size of the database. Cloud SQL also has a STANDARD clone whose duration scales with that size, it selects between the two from the shape of the request, and it returns the same operation either way. Three things force the slow one: naming a zone, asking for a point in time, and disk properties that do not match. The first is the trap, because Google documents that re-specifying even the source's own zone falls back, so the request that looks most careful is the one that stops being copy on write. The clone request type here has no field for a zone or a point in time at all, so asking for the slow path does not compile.
azurepg branches with a point in time restore and declares copy on write FALSE. Microsoft's statement that a snapshot restore does not depend on the size of the data reads like the copy on write sentence, and the rest of the same paragraph is why it is not one: log replay follows, and the overall recovery is given as a few minutes up to a few hours. It also does the three things Azure does not do for you, each of which is an outage or an exposure if a provider assumes otherwise: firewall rules are not copied across a restore, so a branch is a server nobody can reach; a restored server keeps the SOURCE's administrator login, so without a reset every preview holds production's credential; and a restore cannot cross between public and private access, which is refused before provisioning rather than after.
Both control plane clients obtain identity tokens before making API calls. Azure restores preserve private subnet and DNS configuration, retain ownership metadata when publishing, and list the full paginated inventory. Invalid rows do not discard valid collection entries. A server with several application databases requires an explicit selection. Cancellation after restore acceptance cleans up with a fresh context instead of leaving an untracked billed server.
cloudsql has not been run against Google Cloud. azurepg has met Azure once, where a golden restore, mask and verification succeeded over verify-full in 392.4 seconds and the branch restore after it failed with an internal error from Azure, so the branch path is not proved there. Both suites drive a fake control plane with a real Postgres behind it, so the behaviours that are claims about bytes are checked against bytes, and neither asserts RealService, so the service owned verdicts report unproven rather than passed. Both carry an entry in the copy on write ledger saying so, recorded from a conformance run that reported UNPROVEN for each, and the database providers page says "expected" and "never timed" of both, as it already did of Aurora.
One question is recorded unanswered rather than guessed: Google's documentation does not say whether a STOPPED Cloud SQL instance can be cloned, in either direction. That decides whether a published golden can drop its compute, so the default keeps goldens running, which costs more and is known to work.
A database provider for Xata, the one managed Postgres vendor whose branches are copy on write snapshots rather than forks restored from a backup.
database.provider: xata with database.project: <organization>/<project> makes a golden a branch of the project's root, masked and verified in place and published by a rename, and makes every environment a branch of that golden. The attestation travels inside the golden, so a branch carries what was scanned.
The provider declares copy on write, because Xata's own branching page says so, and the copy on write ledger records that declaration as unproven. The conformance suite runs in full against a fake Xata control plane over a real Postgres, built from Xata's published API document, and a fake over one Postgres can only copy. The command that settles it against a real account is on the provider's page.
Refreshing now refuses a manifest asking for a Postgres major the project does not run, with AF-DB-003, before anything is loaded. A refusal from Xata keeps Xata's own code and message. The page for this provider names every call it makes, the three API key scopes it needs, and the capabilities it declares false, with the reason for each.
af explore could not be aimed.
af explore could not be aimed.It ran the goals antifailure.yaml declared exactly as they were declared, and --only chose among them without changing any of them. Asking whether a path works for a viewer rather than the owner, whether the billing page offers a way onward when you start on it, or whether the upgrade control exists on a phone meant editing the manifest first and remembering to put it back. The MCP tool had the same reach: an agent could select a goal and replay a seed, and nothing else.
af explore now takes --persona, --start, --viewport, --budget and --focus, and explore_for_friction takes persona, start_path, viewport, budget and focus. Each overrides the goal for one run and writes nothing. phone is a touch screen and a phone's user agent at 390 by 844 rather than a narrow desktop window, because a layout that switches on either never reflows for a size alone. A persona the manifest does not declare is refused with AF-AGT-022 naming the ones it does. A start path that is a URL, a size outside 320 to 3840 a side, or a budget that is neither a step count nor a duration is refused with AF-AGT-023 before anything is asked of the environment.
Every exploration now reports the persona it signed in as, the page it started on and the window it ran in, both on the terminal and in the JSON, and its replay line carries the same flags quoted for a shell.
A goal's budget.duration now bounds it. The manifest has always normalised it to ten minutes and refused one that was not a duration, and then sent it nowhere, so a goal declaring two minutes ran for as long as its steps took. An exploration now stops at whichever of its steps and its time runs out first, and a goal that sets no duration stops after ten minutes.
A hosted organization can point its own audit stream at its own collector.
The control plane's audit log reached a sink from pull request 372, and the sink was one per process, read from AF_AUDIT_STREAM_SINK. That is right for a self hosted installation, where the operator and the customer are the same person. The hosted enterprise plan sells audit_stream to an organization that shares its process with every other organization on the installation, so an entitled hosted customer was told their audit log could reach their own security information and event management system while the only destination in existence was the operator's.
An organization now owns a destination: a Splunk, Event Hubs or signed webhook endpoint, and the credential it needs, sealed under the mechanism a customer's provider key already uses, which never puts the value in Postgres. An owner or an admin sets it through /enterprise/audit-stream, the entitlement is asked on every request and on every pass of the forwarder, and the forwarder resolves the destination per organization so one organization's entries reach its collector and no other. Delivery state is recorded per organization, because a credential the customer's own system revokes produces a refusal that was previously visible only in the operator's container log.
A destination supplied by a customer is untrusted input and is held to a stricter rule than an operator's: HTTPS always with no loopback exception, no credentials in the URL, and no literal address that is not a public one.
The path is served by the enterprise edition of the control plane, like single sign on and directory provisioning. A deployment running the community image answers it with 404, which is what app.antifailure.dev answered when this was written, so the guide names the host as <your-control-plane> rather than showing a command that fails. The enterprise pages were already written that way and no gate read a path after that placeholder, so a new suite beside the enterprise entry point now holds every documented path to the routes a server built by registerEnterprise answers, and refuses an enterprise path named on a page outside the enterprise section.
The AWS surface an environment will answer for, declared, published and proved against the vendor's own SDK with zero endpoint overrides.
Using an emulator normally means changing the program: an endpoint override, an AWS_ENDPOINT_URL, a client constructed one way under test and another way in production. Whatever that run proves, it proves it about code nobody ships. The part worth building is not the emulator, it is removing that change, and this release lands the half of it that can be measured.
Nine services are declared and published as the surface: S3 including virtual hosted bucket addressing, SQS, SNS, DynamoDB, Kinesis, EventBridge, Secrets Manager, Systems Manager Parameter Store, and STS. STS is there because credential chains call it before the first real request, so a surface without it fails at startup with an error naming the wrong thing.
The proof is a suite that runs on every build. The AWS SDK for Go is built from the default configuration chain, told nothing, and it reaches LocalStack anyway. The AWS SDK for JavaScript reads no proxy variable at all, so it is driven the other way, through DNS on an internal Docker network, which is the mechanism that does not depend on a library choosing to honour a variable. Neither application names an endpoint. An AWS host outside the surface is refused rather than answered, with the same 403 the sidecar already writes for a blocked host, because a wrong answer from an emulator is worse than a refusal: it will be trusted.
Read docs/guides/aws.md for the surface and the measurements behind it. An egress rule selects mode emulate and emulator aws; the Docker runtime starts the registered image and routes matching hosts through the sidecar. This change supplies the built-in registration, generated notices, published surface and SDK suite. Separate runtime tests prove application routing, containment and teardown. A test fails when the guide and code disagree.
One fact worth reading before planning around this: LocalStack archived its community edition in March 2026 and the current image refuses to start without an auth token. The image is pinned by digest to the final community build, which starts offline and needs no account, and an organization with a licence can register the supported image under the same name from its own build.
The enterprise control plane can forward organization audit entries to Splunk, Event Hubs, or a signed webhook.
The enterprise edition sold "SIEM streaming with a tamper evident hash chain". Both halves were real and neither was joined to the other. The chain lives in audit_entries and records organization actions including sign on, directory provisioning and administration. The separate global operator log is exported only where its writer also produces an organization entry. The streaming was the engine's, forwarding five actions from a machine with no database. So the log with the chain in it reached no destination at all, and the sentence could point at a real half whenever it was questioned.
The forwarder that carries it had been written, tested and left imported by nothing: not a declared dependency of any package in the workspace, while its own suite ran green on every pull request because the command that runs it runs every workspace. AF_AUDIT_STREAM_SINK now starts a poll loop over audit_entries.seq that batches per organization, signs a manifest over the chain head, and tracks delivery per organization. A late commit in one organization cannot be skipped because another organization committed first. Transient failures remain pending. Permanent collector refusals are logged and skipped; the primary audit log is retained either way.
Two things the wiring found. web/apps/api/src/entitlements.ts had no audit_stream entry, so the per organization gate answered no for every organization on every plan and could never have been passed. And forwarding is a cross tenant read by nature, which now has a policy and a pool scope of its own rather than a reuse of the sweeper's, so the four existing sweeps do not gain the ability to read anybody's audit log.
The receiver verifies the manifest's organization, count, sequence range and chain head against the signed entries. Remote destinations require HTTPS, redirects are refused, requests carry a deadline, and collector response bodies are cancelled rather than buffered or copied into logs. Polling stops through the control plane's shutdown hook.
Event Hubs batches encode each message body as a JSON string and carry the signed manifest in event properties, so it survives delivery to a consumer. Splunk retains the manifest in an indexed field beside each audit event.
Reaching an emulator meant changing the application, so what was verified was a client nobody deploys.
LocalStack, Azurite and the vendors' own emulators carry years of fidelity work nobody here is going to reproduce, and every one of them is reached the same way: an endpoint override, a base URL that only has a value under test, a client constructed one way for tests and another for production. The change is small enough that everybody makes it and large enough that it moves the code under test off the path that ships.
A new egress mode removes the change. A rule set to emulate names a registered emulator, and the sidecar answers for the provider's own hostname with a certificate the environment already trusts, then forwards the request to a container beside the services. Your SDK is configured for production and stays that way.
The mode names a registration rather than an image or an address, so a manifest cannot decide what answers for s3.amazonaws.com. A name this build has not registered refuses the environment before it starts rather than falling through to block, because a rule that silently does nothing is how somebody comes to believe an environment was tested against S3. The emulator container joins the environment's inner network and nothing else, so it has no route to the internet at all.
Two headers are deliberately preserved rather than rewritten, and the reason is in docs/concepts/egress.md. The Host header carries the bucket name in virtual hosted S3 addressing, so rewriting it would rename every bucket. The Authorization header cannot leak to anything from a network with no route out, and replacing it without re-signing would produce a signature that disagrees with its own request. The sidecar records the access key id, never the secret, and the live credential tripwire still refuses a key that works against production.
conformance.RunEmulator checks the nine promises the engine makes about an emulator, none of which is about whether the emulator implements S3 correctly. It ships with a broken emulator and a self test that requires each break to turn exactly one behaviour red, including the one that matters most: an emulator that refuses everything passes every other check and is useless.
The container a registration declares carries three fields that LocalStack did not need and the other two clouds do. Command decides which emulator you get, because Google ships four of them inside one Cloud CLI image whose entrypoint is the CLI. Companions are the containers an emulator does not work without, such as the MSSQL instance Azure's Service Bus emulator refuses to start without, and they join the inner network on exactly the emulator's terms so they have no route out either. Maintainer records who stands behind an image, declared rather than inferred from the registry it sits in, because the de facto GCS emulator is community maintained and Google ships none at all.
Air gapped mode, which the licence had been selling as a word.
air_gapped was one of twelve features an enterprise licence can carry, and grepping the repository for it returned two lines: the constant that declares its own name and the entry in AllFeatures that lists it. Nothing read it. An installation running with air_gapped in its licence checked GitHub for a new release, exported telemetry, pulled the Postgres and ClickHouse images from Docker Hub, built the sidecar image from a base image it fetched, asked a model provider to invent HTTP responses for any rule in synth mode, and forwarded the application's own traffic to the real internet for every rule in allow and sandbox mode. It behaved exactly as an installation without the feature did.
AF_AIR_GAPPED=1 now seals the process. Every outbound client in the engine and the enterprise edition dials through one guard, which refuses any address the operator did not name in AF_AIR_GAPPED_ALLOW, records the attempt, and refuses a hostname before resolving it so the refusal does not leak the name over DNS. Loopback and unix sockets are always permitted, because the sidecar, the local Postgres and the Docker daemon are addressed there. A private range is not permitted implicitly: an internal registry is reachable because it was named, not because 10.0.0.0/8 looked harmless.
The variable set without a licence for the feature stops the binary rather than starting it unsealed, because an installation that believes it is air gapped and makes one call it did not expect is the failure the feature exists to prevent, and the belief is the part that does the damage. Once sealed nothing unseals it, including a licence that lapses overnight.
An environment whose egress rules would reach outside is refused before it is created, naming every rule. allow forwards to the real host, sandbox substitutes a test credential and still forwards to the real host, and synth calls a model provider. block, capture and mock are answered inside the environment and are untouched. Refused rather than quietly downgraded, because an environment switched from allow to block behind the operator's back would report that it tested a code path it never reached.
The deliverable is the measurement, not the flag. A test seals the guard and performs a complete lifecycle on real Docker, bringing an environment up, serving a request through it and tearing it down, then reads the ledger. It asserts zero refusals AND, separately, that the readiness probe is in the ledger, because zero refusals out of zero observations is not a measurement and would read the same on a build where the guard was never on the path.
A self hosted control plane could see its 5xx count go up and never see what had failed.
Both error handlers already caught every unexpected failure and both already wrote a line naming the error class, the driver's code, the method and the route. That line went to standard output. On the hosted control plane something ships standard output somewhere searchable; on a self hosted one it is a line in docker logs, with no count, no first seen and no grouping. The one number an operator could reach, af_http_requests_total{status_class="5xx"}, says how many and never says what. The Logs & Error Explorer said in its own header that this product records no fingerprint and no occurrence counter, which was true of the engine's exceptions and needed nothing from anybody to stop being true of the control plane's own.
So the control plane now groups its own failures into a table it writes to its own Postgres, and the operator portal reads them and updates itself every ten seconds without a refresh. A row is a group rather than an occurrence, so the table's size is a function of the code and not of how badly the day is going: a self hoster's disk filling on the day their control plane starts failing would be an incident caused by the feature meant to help with incidents. The store holds at most five hundred groups and says on the page when it has reached that, because a store that quietly under-reports is worse than none.
It keeps no message, no stack and no payload, for the reason already written on the handler above it: a query failure from this stack renders as the whole statement with its parameters after it, so a message here can carry a tenant's data. It keeps no organization either, so it cannot say how many tenants a failure touched, and the page says that in words rather than showing a zero.
Nothing records a stack trace from a customer's run. That still needs the engine to report one.
Amazon Aurora PostgreSQL is a database provider, in the enterprise edition.
A branch is an Aurora clone, requested copy on write and never any other way. The clone shares the source cluster's storage volume and diverges a page at a time, so branching moves no data and the work it does is the same at a hundred rows and at a terabyte. Nothing connects to production at any point: the copy is made by the storage layer from the cluster the manifest names, so no credential for the production database is ever read or held.
The half of that claim which is not the clone is in the provider's documentation rather than left for somebody to discover. A clone has no instances and nobody can connect to storage, so a preview environment waits for a writer instance, which is minutes. That wait is flat in the size of the database too, and the provider declares its expected branch latency in minutes rather than in the seconds the fast half would support.
Two things it does rather than the obvious nothing. A published golden's writer instance is deleted once the masking and the verification have run, because a cluster's volume is clonable with nothing attached to it, so a golden costs storage and no compute. And every clone's master password is rotated before anything connects, because a clone inherits the source's credential and a provider that left it alone would hand production's database password to every preview environment.
Three things it refuses rather than substitutes. A source cluster that is not Aurora PostgreSQL is refused at startup instead of falling back to a snapshot restore that would copy every byte. Reset and pooled connection strings are declared unsupported instead of being destroy-and-recreate and a second copy of the direct string. And the benchmark prints UNMEASURED for the wall clock of a real Aurora clone, because no test here has an AWS account and a number from somewhere else is not a measurement.
A store declared empty, derived or topics_only had a manifest key and no behaviour.
empty, derived or topics_only had a manifest key and no behaviour.af up printed one line saying that this build brings up the golden stance only, and then the environment either started a container because a service happened to carry the same name or started nothing at all, and nothing afterwards could tell those two apart. The fidelity report called every one of them unmeasured, which holds a component out of the score in both directions, so a twin of a product whose events live in Kafka scored exactly the same whether its broker held the declared topics or was an empty container nobody had touched.
The stance is the feature. Not everything should be cloned, and a plan that copies a cache and calls it fidelity has misunderstood what fidelity is. So the other three stances are now outcomes.
empty is the store's own service and nothing else, said out loud. That is already the state the stance asks for. What was missing was never a container, it was somebody being told that this store is empty because a person decided it should be, and being told the declared reason. The run says it and the report says it again. What is refused is a store declared empty that nothing in the manifest starts, which is a manifest asking the environment to hold a store while nothing brings one up.
derived runs its declared rebuild command to completion inside the environment, in the image and with the variables of the service it names, once every service is up. A non-zero exit fails the environment rather than leaving an index nobody built. A search index cloned from production is stale against the branch the moment the branch is masked, because the documents in it name people who do not exist in the twin's Postgres. An index built from the branch cannot be stale against it.
topics_only creates the declared topics and consumer groups, with no messages. Three things a consumer needs, none of which exists in an empty broker. The topic, because subscribing to a name that is not there reads nothing and reports nothing, and the run goes green having tested a poll loop against a name that will only ever exist in production. The partition count, because ordering is per partition and a group with more members than partitions leaves members idle, so a twin whose topic has one partition where production has twelve cannot reproduce a reordering bug at all. The consumer group, because a consumer joining a group nobody created reads from the END by default, so the twin's own producers write, the consumer joins, and every one of those messages is skipped.
The commands are the broker's own, run in the broker's own image, and they wait for it to answer a metadata request first, because a broker declares no health path in anybody's compose file and there is nothing for the readiness wait to have waited on. A broker this build has no recipe for is REFUSED by name rather than started with nothing in it, which would be the empty stance wearing a different word. This build shapes Kafka.
Both runtimes run the jobs, after every service and before the environment is called up, because a rebuild reads one store and writes another and a topic creation needs a broker that has finished starting.
The report shows all four as distinct positions. empty, derived and topics_only are substituted rather than unmeasured: not reproduced, because none of the three is production's data and the argument for them is that it should not be, and not held out of the number, because that is what made an honest position and an accident score the same. The score goes down for declaring a cache empty. It should, and the declared reason is carried through as written beside it. A store whose service is not running is absent whatever the manifest says, and a store that is running for which this environment's own run recorded no such job stays unmeasured and is told to run af up again: an environment brought up by an older build runs the same services from the same file with a broker that has nothing in it, and the run journal is the only thing that records what a particular run actually did.
The plan's row for Azure Container Apps described the containment as "an internal environment, an NSG that denies, no public ingress except the declared web service".
Two of those three are wrong against Microsoft's own documentation, and the third is not the protection it sounds like.
An internal environment governs INGRESS. Microsoft bills a virtual network integrated environment for "one standard static public IP for egress if you're using an internal or external environment", so an internal environment is provisioned with a public egress address and reaches the internet unless a route sends it somewhere else first.
A network security group that denies cannot exist here either. The Container Apps firewall guidance requires mcr.microsoft.com, *.data.mcr.microsoft.com, packages.aks.azure.com and acs-mirror.azureedge.net under the scenario "All scenarios", and offers the private endpoint escape only for your own registry and key vault, never for those four. That is worse than the AWS answer rather than the same one: on Fargate the registry, layer and log dependencies are all reachable through endpoints inside the VPC, so a task can start in a subnet with no route out, and a Container Apps replica cannot. Microsoft states the rest plainly: "Don't explicitly deny the Azure DNS address 168.63.129.16 in the outgoing NSG rules. If you do, your Container Apps environment doesn't function."
So runtime.provider: aca now reaches a package that enumerates twelve egress paths out of a replica and refuses. Eight are closed by the generated configuration, three are open and named, and one is unproven and is never counted as closed. A manifest naming the runtime exits with the whole report rather than with a list of the two runtimes that do exist.
Every verdict is computed from a configuration that has never been applied to an Azure subscription, and the report says so in a constant that cannot be paraphrased away.
The detector and the sidecar are two lists about the same providers and nothing held them together.
engine/internal/detect decides that a host gets egress mode capture; engine/cmd/af-proxy decides what a captured request is answered with. A provider named in the first with no handler in the second is answered by the generic fallback, which returns 200 and an empty object, so an application reading a provider specific id or status out of the response gets nothing.
The two lists agree today, seven providers for seven handlers. This adds the check that holds them there.
a-detector-that-could-name-a-provider-nothing-could-answer-for
Four of the five extension points worked and only one was documented.
The engine declares five sockets a build outside the repository can implement: database providers, datastore providers, runtimes, golden stores and emulators. All five are reachable, validated, and refused by name when a manifest asks for something that is not there. The Providers section described databases and nothing else, so the other four were the quietest kind of missing feature: they work, and nobody outside can find out that they do.
There are now five pages, one per point, with what ships for each, the mechanism underneath, the capabilities the conformance suite tests against, and the editions rule that says which of them are MIT and why. The check that holds it there reads the socket constants and the schema's provider names out of the code, so a sixth extension point, or a new provider name, reds the build until somebody writes it down.
Two new things ship beside it.
gcs is a golden store, MIT and in the engine beside s3 and azure_blob because those are its peers and anything with an MIT peer in the engine stays MIT. A fleet on Google Cloud previously had two choices: give a runner an AWS or Azure credential it had no other use for, or keep every golden on the one machine that made it and stop being a fleet. It authenticates through the metadata server where there is one and through a service account key where there is not.
cloud_database and cloud_runtime are two licensed features covering the managed cloud providers, gated per call rather than at registration, so a licence that lapses while the process is running stops enforcement without a restart. The gate refuses anything that creates a cloud resource and never refuses a teardown, an inventory or a status: a lapsed licence that stopped somebody removing a database cluster would leave them paying for it.
A manifest naming the Cloud Run runtime was answered by a list of two names.
runtime.provider: cloudrun reached the engine's generic refusal, which says this build has local and kubernetes and stops there. That answer is true and it is useless: somebody writing that line is asking whether their environments can run on Cloud Run, and a list of the runtimes that already exist does not tell them why not.
The plan for this lane described the containment as VPC egress for all traffic through a connector, a firewall that denies, and metadata reachable only to the sidecar. Two of those three lines are wrong, and the third is wrong in the direction that flatters the claim. Google's page on deploying multiple containers states that "All containers within an instance share the same network namespace", the container contract states that Cloud Run writes an /etc/hosts entry mapping container names to 127.0.0.1 so containers reach each other on localhost, and one paragraph of the container contract says the opposite in passing, that containers run under network namespaces "isolating them from each other". The mechanism settles it: communication over localhost is what a shared network namespace is. So there is no arrangement in which the sidecar reaches the metadata server and the application container does not. Cloud Run has no way to run a service with no identity at all, and the metadata server hands an OAuth access token for that identity to anything inside the container, on an address that Google states VPC firewall rules do not apply to. That path is closed on Fargate, where a task definition may carry no task role, and it cannot be closed here.
The runtime is registered and it refuses, carrying the enumeration as the reason: ten egress paths out of a Cloud Run instance, six closed by the generated configuration, three open and one that neither the configuration nor Google's documentation decides. Seven of ten for an environment of exactly one service, which is a shape Fargate cannot reach at all, because there the image pull and the log push hold the equivalent allowance open whatever the environment contains.
Cloud Run is the tighter of the two clouds in the place that decided the ECS lane. Google states that container images "are not pulled from their container repository when a new Cloud Run instance is started" and the platform captures the log streams itself, so an environment here starts under a deny all egress rule, where a Fargate task under the same rule never starts and holds three paths open by necessity.
Every verdict is computed from a configuration that has never been applied to a Google Cloud project, and the report says so in a constant that cannot be paraphrased away.
Google Cloud in an environment, and an honest table of what that means.
Six services are answered inside the environment: Cloud Storage, Pub/Sub, Firestore, Datastore, Bigtable and Spanner. A Google host outside that list is not routed to an emulator at all, so it is refused by the egress policy rather than answered, because a wrong answer from an emulator is worse than a refusal.
Six containers, not one. Google ships no single emulator the way LocalStack is one for AWS: its official emulators are five separate programs, four of them inside the Google Cloud CLI image and selected by the container's command, and Cloud Storage has no official Google emulator at all. So EmulatorContainer grew a Command, without which four of the six are identical containers that run a shell and answer nothing.
The one that is not Google's is the reason the declaration grew an Official field. Cloud Storage is answered by fake-gcs-server, which is a community project with no affiliation to Google, and nothing in the emulator declaration could say so: a reader had to already know which of the project names were vendor names. A guide printing six emulators in one table with nothing distinguishing them would have presented somebody else's software as Google's, and a user who found that out from a failing test was misled by us.
The guide publishes what was measured rather than what was hoped. An unmodified @google-cloud/storage and an unmodified google-cloud-storage both create a bucket, upload, download and list with no endpoint override and no emulator environment variable. The other five services speak gRPC, and the sidecar's inspected path reads HTTP/1.1 out of the connection it terminates, so an unmodified gRPC client spends its whole sixty second deadline retrying. The same client against the same proxy forwarding HTTP/2 instead answers in 456 milliseconds, which is what says the blocker is one property of one file rather than anything about the emulators.
A compose file running ClickHouse and LocalStack produced a manifest that mentioned neither.
Both halves were a silence rather than a wrong answer, which is why nothing caught them. The image classifier recognised ClickHouse, Redis and Elasticsearch, and the merge read postgres, asked about mysql and mongodb and dropped every other classification on the floor; Kafka was not classified at all. So the twin of an analytics product came up holding a masked Postgres and an empty everything else. af init now proposes a stance for every store beside the primary, with the reason written per engine, and a store this build has no provider for is reported with the stance it would have taken instead of being written into a manifest af up would then refuse.
A cloud emulator in that same compose file is the strongest statement a repository makes about which cloud it talks to, because somebody configured it rather than installed it. LocalStack with SERVICES=s3,sqs,sns is read as the roster, and each service on it gets the catalog's rule for the real host.
The catalog itself matched a dependency by its exact name, which works for a package called stripe and stops working the moment a vendor ships one client per service. Twenty two entries are added, covering Lambda, CloudWatch, KMS, Step Functions, Athena, Bedrock, Cognito, API Gateway, Firehose, BigQuery, Cloud Logging, Cloud Monitoring, Bigtable, Spanner, Vertex AI, Cloud Run, the Google and Microsoft token endpoints, Azure OpenAI, Azure AI Search, Azure Monitor and App Configuration. Every host is written out rather than derived from the package name, because AWS spells Step Functions as states and CloudWatch Logs as logs, and a host guessed wrong matches nothing while looking like coverage.
Measured against the probe corpus, which grew from 30 concrete cloud endpoints to 62: before, 30 were named by a rule and 32 were refused with "no rule matches"; after, 62 are named and none is answered by a wildcard. What the catalog still does not cover is named at af init rather than discovered as an unreadable refusal in an environment, and the one endpoint that cannot be covered without reintroducing a wildcard is stated in the entry and held there by a test.
audit_stream was a licensed feature whose sink nothing ever wrote to.
audit_stream was a licensed feature whose sink nothing ever wrote to.extension.AuditSink, Registry.AddAuditSink and Registry.Audit were written, documented and tested, and on the tree this was measured against Registry.Audit had exactly two callers in the whole repository. Both were in engine/pkg/extension/extension_test.go. There was no implementation of the interface anywhere, and no binary registered one. So a customer who bought audit_stream got a licence that granted a feature, a documentation page listing what is audited, and not one entry forwarded to anything. Every piece was present and the behaviour was absent, which is the same shippable gap as a block button that hides nothing.
The engine now records five privileged actions through the socket: an environment refused by organization policy, an environment created, an environment torn down, a golden published to a shared store, and a published golden pulled onto a machine. The refusal is the one a security team came for, because it is the only kind of entry that shows a control holding rather than merely being configured, and it reaches a sink before anything has been created.
The enterprise edition answers with three sinks, each gated on the licence per call rather than at registration so that a licence lapsing mid-process stops forwarding without a restart: syslog over TLS, framed by octet count so a refusal written for a terminal arrives as one entry rather than four corrupt ones; an HTTPS webhook with a signed body, a bounded retry and a dead letter file, because a webhook that posts once loses an entry every time its receiver restarts and loses it silently; and a drop into an object store in the same two shapes the goldens already use, one object per entry so that a retention policy and an object lock can hold over it.
An entry now carries OccurredAt, because a sink stamps when forwarding succeeded and a retry can put that minutes after the action. A stream carrying only the second timestamp reorders itself whenever one destination is slow.
A sink that cannot be reached is reported and does not stop anything. That is the interface's own contract and it matters most on teardown: a forwarding outage that stopped an environment being destroyed would turn a logging problem into a resource leak.
Here is one environment.created entry as each of the three sinks actually puts it on the wire, captured from the sinks themselves rather than described. The webhook and the object store carry the same bytes:
``json {"occurred_at":"2026-09-08T19:04:11.082Z","forwarded_at":"2026-09-08T19:04:11.328Z","org":"acme","actor":"dana@acme.example","action":"environment.created","target_type":"environment","target_id":"env_7c31a8","origin":"engine","detail":{"branch":"add-checkout-retry","outcome":"created","project":"shop","repository":"acme/shop","seconds":41.7}} ``
The two timestamps are the point of carrying both: the action happened at .082 and forwarding succeeded at .328, and only the first is a fact about the environment.
Syslog wraps exactly those bytes in an RFC 5424 header, with the action as the message id so a collector can route on it without parsing the payload, and the byte order mark that declares the payload UTF-8:
`` <110>1 2026-09-08T19:04:11.082Z host antifailure 5799 environment.created - {"occurred_at":"2026-09-08T19:04:11.082Z", ...} ``
The object store writes one object per entry, under a key partitioned by the day the action OCCURRED rather than the day it was forwarded, so a retention rule expires entries by when they happened:
`` s3://acme-audit/antifailure/2026/09/08/190411.082000000-environment.created-cafebabe.json ``
It is sent with If-None-Match: *, so an entry can never replace one already there, and signed with SigV4 over the request the store actually receives.
Multi runtime placement, which was a declared and licensed enterprise feature with no reachable path to it.
engine/internal/scheduler was imported by exactly one file in the repository, its own test. scheduler.Plan had zero production callers. Run.Requires was filled by nothing, because schema.Runtime had no field that could carry a placement requirement. The documentation described requirements matched against runtime tags and an error code was reserved for the refusal. A licence gate in front of any of it would have been an enforcement site that never ran.
runtime.targets declares the places an environment may go and the tags each one offers, and runtime.requires is what a repository needs of one. The engine calls scheduler.Plan to choose, and builds the runtime the chosen target names. A requirement no target satisfies is refused with AF-SCH-001, which names the requirement rather than saying no runtime is available: those are different problems fixed by different people.
Placement is a pure function of the manifest. af up, af status, af logs and af down each decide independently and have to agree, so a cluster's health is deliberately not an input; a placement that varied with it would have af status asking the wrong cluster and reporting that the environment does not exist.
Two consequences beyond the feature itself. A target's region tag fills extension.EnvironmentRequest.Region, which had no writer anywhere, so the enterprise policy hook's allowed_regions residency rule could be configured, described back to the operator at startup, and never fire. And more than one target is gated on an enterprise licence carrying multi_runtime, checked through edition.Permits at the one point every path reaches a target, proved by turning the entitlement off and observing the refusal rather than by reading the check.
runtime.provider: ecs is now answered by a containment report instead of by a list of the two runtimes that exist.
runtime.provider: ecs is now answered by a containment report instead of by a list of the two runtimes that exist.It still refuses to start an environment, and the refusal is the feature: it enumerates thirteen distinct ways out of an ECS task on Fargate, names the ten the generated configuration closes, the one that is open and the two that neither the configuration nor AWS's documentation decides, and says which verdicts are computed from a configuration nobody has applied to an AWS account.
An unproven verdict is not a weaker closed one and is never counted as one. The instance metadata service and the local time sync service are the two, both on link local addresses that no route table or security group reaches, and settling either takes one request from one running task in somebody's own account.
Three things the enumeration found are worth knowing even if you never run on ECS. A security group cannot deny egress on Fargate, because the image pull runs over the task's own interface under that same security group, so denying egress produces a task that never starts. On AWS a security group cannot filter a DNS query at all: the Amazon provided resolver answers recursive lookups for public names from anywhere in the VPC and the VPC user guide states plainly that neither security groups nor network ACLs can filter traffic to it, so closing that path takes a Route 53 Resolver DNS Firewall rule group. And reaching ECR is a different question from reaching every repository, log group and bucket in the region: the first cannot be closed without giving up the ability to start an environment, the second can, by a VPC endpoint policy, and only the second is an exfiltration path.
The honest summary is unchanged and is now written down: Antifailure runs on EKS and not on raw ECS.
An operator invited through the portal could never sign in.
Creating one writes the row with no password, on purpose, and told the reader to "set a password out of band before it is usable". There was no out of band that a person in the portal could reach: the only thing in this repository that had ever written admin_users.password_hash was the set-operator-password command, which runs on a connection string row level security does not apply to. So every account created from that panel sat in the directory reading Not provisioned and Never, and finishing the invitation needed a shell and the admin database URL.
admin.operators.setPassword is the other half, under an operator session and recorded in the platform audit chain at critical severity. The console asks for the password in the panel that created the account and in the account's own drawer, offers to generate one in the browser, and shows it once with the sentence that says nothing on the platform can read it back. The server never chooses a password and still mints none.
Setting your own password needs your current one as well, which nothing else on this router asks for. The danger there is the opposite of the one setRole and suspend guard: an operator cookie lasts twelve hours, and without the check a stolen one buys the account permanently, because the thief sets a password and the revoke below cuts every session the real owner had.
Every live session belonging to that operator is revoked in the same transaction, except the one making the request. A password changed because it leaked, that leaves the sessions it opened alive, is a reset that resets nothing.
The length floor and the refusal of a stray newline now live in one module that both writers ask, because a rule enforced in one of two writers is a rule with a way around it, and the way around it would have been the one reachable from a browser.
The exit condition for a twin with two stores is a sentence said to a customer about their own environment: a masked, verified, attested clone of your Postgres and your ClickHouse, with the same person masked identically in both, and a report that tells you what it did not reproduce.
Six of those seven clauses had a command behind them. af mask plan, af mask verify, af golden verify, af fidelity, af invariants and af insights all exist and are documented. The clause about one person masking to one person across both stores did not. masking.CrossStoreCheck had been written, was tested well, and had zero production callers: every caller was a test or a benchmark, the phrase cross store appeared nowhere in the command reference, and no tool mentioned it.
So the guarantee was true in our continuous integration, on our fixtures, and on a customer's stack it was something they had our word for. That is the sentence this product exists to refuse to say. Determinism across stores is a property of the masking construction, so their twin almost certainly had it, and almost certainly is exactly the standard being rejected here.
af mask crossstore is the surface. It reads each declared datastore's catalog, assigns the one rules file to all of them, finds every identifier that appears in more than one store, masks probe values through each side, and reports the share that come out identical.
It reads schemas and no rows. That is not an implementation detail, it is what makes the command safe to point at production: the probe values it masks are its own, so what it needs from a store is the catalog. The report says how many tables and columns it read and that it read no rows, as a field rather than as a promise in the description.
And the promise is enforced by something that can contradict it. A live test runs the check against a real ClickHouse and then reads the server's OWN system.query_log back, failing if any statement the check sent selected from a data table. A sentence saying no rows are read is something anybody can write. A query log the server keeps is not.
Every store it could not read is named with the reason, every store that names no source_url_env is named as never read at all, and a run that reached one store reports that it proved nothing rather than reporting a hundred percent of one. The two ways of not passing get two different exit codes, because a pair that disagreed is a statement about the data and a run that compared nothing is a statement about what could be reached, and telling somebody their stores disagree when the truth is that nobody looked would be its own defect.
Datastores gain source_url_env, the name of the variable holding a read only connection string for that store. The primary takes it from database.source_url_env and does not repeat it. A connection string pasted into that field is refused, and the refusal does not print it back.
The fidelity report gains the line where two stores are present. Nothing having compared them is unmeasured, which keeps it out of the score in both directions, carries the sentence saying how to check, and does not invent a finding about an environment nobody looked at. Verified identical is reproduced. A pair that disagreed is absent with the pair named, which is the line the whole thing is for: one identity masked into two people is a twin that is confidently wrong, and every report built on it is plausible.
A project that lists datastores in fidelity.require and holds two stores will see that requirement go from met to unmeasurable until each store names a source_url_env. That is not a regression, it is the point: require means every component of the dimension was measured and reproduced, and the dimension now carries a component that nothing had measured. Reporting it as met would be the report asserting a guarantee that nothing checked. Unmeasurable is a distinct outcome from broken and is reported as one, so nothing fails as though the stores had been compared and disagreed.
The same question is the fourth question inspect_data_masking answers, so an agent can ask it and get a verdict of PASS, FAIL or INCONCLUSIVE rather than a percentage it has to interpret.
Two stores agreeing while a third declared store could not be read is not a pass either, and it carries the coverage code rather than the disagreement one. Nobody looked at the third store, and telling somebody their stores disagree when that is the truth would be the same conflation one level up. A store that names no source_url_env is deliberately not counted there: nobody asked for it to be compared, a cache holds no identity to compare, and requiring one would make a pass impossible for every realistic manifest. It is named in the report instead of becoming a verdict.
The catalog readers are the ones that already ship. The ClickHouse half is clickhouse.Catalog, exported from the provider that refreshes, masks and branches the store, and it is scoped to the database the connection string names. A second reader written for this check would have been a second opinion about what a type means and whether a column can be written to, kept in step with the first by nobody, and the failure mode of a stale one is a check comparing a plan that will never run.
An Azure storage account named in an environment was still the production one.
An application reaching Azure Blob, Queue or Table Storage from an environment had two options and both were bad. Leave the host alone and the request went to the real storage account, where an object written from a preview environment is indistinguishable from production data afterwards. Point it at an emulator and the application had to carry a BlobEndpoint= that only exists outside production, so the code under test stopped being the code that ships.
Azurite now answers for *.blob.core.windows.net, *.queue.core.windows.net and *.table.core.windows.net inside the environment, with nothing in the application naming it. The account travels in the first label of the hostname, so the sidecar preserving the Host header is what lets Azurite serve the account the application already asks for.
Service Bus and Cosmos DB are named as outside that surface rather than left for somebody to discover in a failure. The Service Bus emulator needs a SQL Server container beside it, which an emulator declaration cannot yet express, and it refuses to start until a person accepts a EULA, which is not an acceptance a build makes on somebody's behalf.
an-azure-storage-account-in-an-environment-was-still-production
An agent given the MCP server had no way to read the documentation.
Antifailure is new, so no model carries it. An agent handed af mcp could submit a rehearsal and read a verdict while having no way to find out what a stance is, what the topology dimension measures, or why a golden that fails verification cannot be branched. It guessed, and it guessed confidently, because nothing told it otherwise.
Three tools now serve the 92 pages this build ships, and the whole design is about what they refuse to send. search_documentation returns the few lines around a match with the heading path and the anchor that reads that section on its own, never a page. list_documentation names all 92 for 1,416 tokens so an agent can orient before it knows what to search for. read_documentation_page reads one section by anchor, bounded, and a page longer than the budget is cut at a line boundary, marked where it was cut, and reported with the exact characters withheld and the anchors of every section past the cut.
Every response states what it did not return: how many pages matched, how many were shown, which were dropped and why. A tool that silently truncates leaves a caller believing it has seen everything, which is the defect this repository exists to find, and it is worse in a documentation tool than anywhere else, because the caller has no independent way to know the answer was partial.
The pages are compiled into the binary by tools/docsembed, so the answer matches the build the caller is running rather than whatever is on the website, and the server needs no network to give it. just generate writes the generated file and CI fails if it has drifted from docs/src/content/docs.
Measured by engine/internal/docs/benchmark_test.go, which just benchmark runs, and counted with cl100k_base: the question "what does stance mean" is answered in 1,138 tokens end to end, against 262,045 tokens for the documentation set it was drawn from. Reading the one page the best excerpt came from would be 6,657, and reading just that section 649.
Six defect classes, each proved able to make the verdict say no.
Across the 22 most recent merged pull requests here, Antifailure's verdict on itself read "Every check passed" twenty one times and "Nothing was verified" twice. The failed column had never held a number. That is consistent with a product that works and equally consistent with a check that cannot refuse, and this repository has already shipped the second thing twice: a database conformance suite that had never turned down a provider, and a coverage gate that printed a number while skipping the files it could not parse.
So the six things the verdict claims to decide are each broken on purpose now, one at a time, through the real code and against a real Postgres, a real browser and a real inventory. A masking rule that misses the column holding addresses, a row an invariant says cannot exist, a page that shows an error instead of the confirmation, an ALTER TABLE holding ACCESS EXCLUSIVE for three seconds, a column the verifier is not allowed to read, and a manifest declaring a store this build has no golden for. Each case requires the finding by name in the report a person actually reads, then restores the break and requires the same case to pass, because a row that can only ever fail is the same defect wearing the other sign. The suite counts what it proved rather than asserting it, and on a machine with the Postgres it needs a class that did not run is a failure rather than a quiet skip.
Two things it found on the way. A migration that held a lock for three seconds produced four lock findings: one about the customer's table and three about the rehearsal's own bookkeeping table, its primary key and its sequence, each telling the author to split a statement that never touched it. Those are excluded from the sample now. And af golden refresh refused a golden whose only problem was a column nobody could read by printing an empty list of findings above the sentence "add a rule for each column above", with the error naming the column discarded on the way out. It names the column, and it no longer recommends a rule for a problem no rule can fix.
An environment can hold a masked ClickHouse beside its masked Postgres, and af up puts one there.
af up puts one there.A twin of an analytics product held a masked Postgres and zero events. The events live in ClickHouse, the environment started ClickHouse as an empty container, and every query path that mattered ran against no rows while the run went green. A manifest could say datastores: and declare one golden, and nothing read that at run time.
Now af golden refresh makes a golden of every declared store as well as of the database, and af up branches each of them into the environment. A ClickHouse golden is copied from the address source_url_env names, masked, read back by the same verification scanner the Postgres golden is published on, and branched as a database with the golden's partitions attached to it, which ClickHouse hardlinks: a branch of three rows and a branch of a million take about the same few hundred milliseconds. A golden that fails verification is never published and can never be branched, which is the rule the primary database has always had, enforced in the provider rather than in a checklist.
One masking.yaml covers the whole twin. A rule about distinct_id applies to the column wherever it is, so one customer masks to one fake customer in both stores and a join across them still returns one person.
Three things that came out of building it:
- The masking is a value map joined into a shadow table, not the per row statement the dialect describes. One
ALTER TABLE ... UPDATEper row is a ClickHouse mutation, which rewrites every part holding a matching row: measured at 1.81 rows a second on a ten thousand row table, where the shape that shipped did the same ten thousand rows in 0.13 seconds. A test masks the same fixture both ways and requires the results to be identical. - A store the environment provides is no longer also started as a service. Declaring a datastore called
eventsand a service calledeventsis declaring one thing twice, and starting both put two ClickHouses on one network under one name. - A name inside the environment is no longer decided against the egress policy when it arrives through the explicit proxy port. The sidecar's resolver has always treated one as internal and forwarded it; the proxy had never heard of the list, so a client that reads
http_proxyand ignoresno_proxy, which busybox wget does, had its call to another service in its own environment refused as egress.
provider.Datastore gained ListGoldens and DestroyGolden. Without the first the engine had to refresh on every command, because it cannot choose a version it cannot see; without the second a datastore could make goldens and had no way to remove one, which the conformance suite had written down as the one place it could not clean up after itself.
A manifest asking for three instances of a service now gets three.
replicas had been in the schema and in the reference since version one, and nothing read it. Both Kubernetes Deployments hardcoded a single replica and the local runtime never mentioned the field at all, so a service declaring three ran one. The release before this one closed the silence by refusing the key, which was the right answer for exactly as long as the field did nothing.
It does something now. Both runtimes start the number asked for, behind the one name every other service resolves, so requests and lookups spread across the instances. The migration runs once for the service rather than once per instance, the forwarder is one for the service rather than one per instance, and readiness waits for every instance instead of the first one to answer.
The point is not scale. Nobody needs three copies of a worker on a laptop. What more than one instance buys is a class of bug that cannot be reproduced at one: a nightly job with no leader election that sends its email once per instance, a consumer that reads a row and then claims it so two instances do the same work, a session or a cache held in one process's memory that the next request does not reach. Every one of those passes at a single instance, which is why a runtime that quietly ran one was worse than useless to somebody who wrote replicas: 3 because they suspected exactly this: the green run read as the bug being absent.
Two of those classes are now demonstrated rather than described, in tests that pass at one instance and fail at three with nothing changed but the number. The runtime conformance suite gained the instance count as a behavior, and a second behavior that asks the three processes to identify themselves, because a count is a number a runtime writes down and a runtime running one instance while reporting three says exactly what a correct one says.
af status names the count when it is more than one, and the fidelity report says how many instances are running against how many were asked for. A cron service may not ask for more than one, because every instance runs the schedule and three of them send the nightly email three times.
Found while wiring it: health_timeout was validated, defaulted, printed by af explain and reported over MCP, and never assigned into the spec a runtime receives. A service given ten minutes to start was killed after three. Both runtimes now see the number, and the boundary the two fields were lost at has a test on it for the first time.
A Postgres this product could not copy from unless somebody had written a provider for its vendor.
There were four database providers and each one was a provider for one product: a Docker daemon, a Neon project, a Supabase project, a Database Lab Engine. A self hosted cluster, a machine at Hetzner or Scaleway or OVH, an internal server behind a bastion, or any managed Postgres whose vendor had no provider here could not be a source at all. The manifest could name it and the engine would refuse, which was the honest answer to a gap that should not have been there: the copy itself is pg_dump and pg_restore, and neither of those cares who runs the server.
database.provider: pgurl is that provider. It knows nothing about any vendor. It takes the source connection string the manifest already names, and one more variable holding the connection string of the server the goldens and the branches live on, and it keeps a golden as a database on that server and a branch as CREATE DATABASE ... TEMPLATE, which is a server side file copy.
Two things it declares rather than implies. Branching is NOT copy on write, so branch time grows with the database, and the measured seconds per gigabyte are published in benchmarks/ beside the harness that produced them rather than described. And the Postgres major it supports is the one the server actually runs, read from the server, so a manifest asking for a version that server does not have is refused instead of quietly building the golden on something else.
Nothing is dropped or written over on the strength of its name. Every database this provider creates carries a marker in its comment, and a name collision with a database it did not create is refused rather than adopted.
A manifest could declare fifty services, pull ClickHouse and Redis and Kafka as prebuilt images, and start every one of them.
Exactly one of those stores held production's data. database was a single struct and it was Postgres: one golden, one masking pass, one verification scan, one branch. Everything else came up as an empty container, and nothing in the product said so.
For a stack shaped like an analytics product that is the whole product. The twin holds masked Postgres metadata and zero events, because the events live in ClickHouse. Every query path that matters is untested, every migration touching the analytics store is unrehearsed, and a workflow that reads a chart sees nothing. The environment is a production twin of one component and a blank slate for the rest.
The manifest gains datastores, a list, and every entry declares a stance: golden for a masked and verified copy, empty for a store that is correct to start with nothing, derived to rebuild one from another, topics_only for a broker. A datastore that declares no stance is refused. Not every store should be cloned, so the answer differs per store and only the person writing the manifest knows it; a default would choose silently, once per manifest, and choosing silently is how somebody ends up trusting a blank ClickHouse. empty is a legitimate answer and because is required with it, because an empty store somebody decided on and an empty store nobody noticed look identical from inside a running environment.
database does not move and does not change. It normalizes into the entry named primary, so a manifest that declares only database: already has the list, and the component inventory for such a manifest is byte for byte the report it produced before. There is one code path afterwards rather than two.
provider.Datastore is the interface an implementation satisfies, and conformance.RunDatastore is the suite that decides whether one is finished. It ships with a broken fake and a self test in the same commit: every behaviour is shown going red against a store built with one thing wrong, and a test fails if a behaviour is added without a control. Nothing yet refreshes a golden for a second store or branches one. Those are the next lanes, and until they land every declared store is reported unmeasured with the reason rather than counted as either answer.
A provider written outside this repository could be implemented and never selected.
engine/pkg/provider is documented as the main extension point, meant to be written by people outside this repository, with a conformance suite that decides whether an implementation is conformant. All of that was true and none of it was reachable. The engine chose its database provider from a switch in engine/internal/env, its runtime from another, and its golden store from a third, and each ended at a default that refused. Nothing consulted anything a build had registered, because there was nothing to register into: the only way to add a provider was to edit an internal package, which the Go toolchain refuses to let another module import. "Extensible" meant "send us a patch".
engine/pkg/extension now carries five sockets beside the four hooks it already had: DatabaseProvider, DatastoreProvider, RuntimeProvider, GoldenStore and Emulator. The database, runtime and golden store switches consult the registry after their own cases and never before them, so a registration adds a choice and can never take one over, and each refusal now names every provider the build has rather than only the ones that ship. A registration under a built in name is refused at the first command instead of being silently unused, which is the failure that would otherwise be discovered as a build somebody believed replaced the Docker provider.
Two of the five have no lifecycle behind them yet, and each says so in its own documentation rather than leaving somebody to find out. tools/socketcheck is what keeps that honest: every socket is either consulted by the engine or listed there with the reason, both directions fail, and the entry cannot outlive the gap. It was written after finding that Registry.Audit has no caller anywhere in the engine, so audit_stream would forward nothing even once somebody wrote the sinks.
A dogfood workflow that signs in as two people in one browser, so the launch night failure could have been caught before it shipped.
On launch night the founder was signed in to the console and to the operator portal in the same browser, pressed Subscribe to team, and got a 403 naming a header the Plan page has never heard of: the operator transport check was running on a customer mutation because the operator cookie was present. The fix is in the control plane. The question this answers is the one asked next: would rehearsing the change against a twin have found it. It would not have, because this repository's own manifest signed every persona in as one identity, and a bug that needs two live sessions in one browser cannot appear to a workflow that can only hold one.
The manifest gains an operator-owner persona, an operator of the platform who is also a customer of it, which is what everybody on this team is. It is a separate account in admin_users, signed in with a password at the portal, and it is created by deploy/docker/personas.mjs, now run as the seed adapter's command so the password the runner types and the hash the row stores are handed out by one process and agree. The workflow an-operator-who-is-also-a-customer-can-start-checkout signs in to the portal, then to the console, then presses Subscribe to team, and expects the sentence the Plan page shows once the mutation is past the transport gate, which the 403 can never produce. Reverting the fix makes the workflow fail on that 403, which is the proof it can say no.
Two manifest features made it expressible, and both are documented and validated. A workflow may name a list of personas rather than one, and the runner signs in as each in turn in one browser so the sessions accumulate; the last is the identity it acts as. A persona may name a sign_in_path, so the runner finds the operator portal's form at /admin rather than typing an operator's address into the console's own sign-in screen at the workflow's start path.
The mirror image is rehearsed too. The customer transport check had the same defect in the other direction: an operator whose browser also held the console cookie pressed Mark reviewed in the portal and was refused 403 for want of the product token, by a check for a session the request was not using. The check now applies only to a request that names a customer procedure, and a second workflow acts as the operator with both cookies live, marks an application the persona seeder left waiting as reviewed, and expects the queue's own empty state. The preview now carries the operator portal's database credential, so the portal works inside a twin, and the planner without a model presses a control whose whole label the description names, which is how it finds a review queue's buttons on a page with no shared shape.
The console asks, once, how you want to start.
Signing in for the first time landed on the environments list, which is three empty cards explaining that something appears when the engine reports one. Somebody who came for a check on their pull requests, somebody who came to run an environment from a terminal, and somebody who came to point a coding agent at this control plane all saw the same three cards, and the next step for each of them was on a different page, or on no page at all: the hosted MCP address was shown to operators and to nobody else.
The root now asks which of the three you are and shows that one's next step, with the command, the address or the install link, and the permissions it needs in one line. The answer is remembered in the browser for the organization, the other two paths stay a click away, Not now is honoured, and Settings shows the answer with a way to change it.
The marketing site's product analytics goes through the control plane, so a reader's browser never opens a connection to posthog.com.
That is transport and it is not a boundary. It changes the destination the browser connects to, not who receives the data: PostHog, Inc. receives every event, every autocaptured interaction and every session recording either way, in their US cloud. What it buys is that a content blocker's vendor list does not match the request, so the measurement is not silently half missing; that the recorder bundle, the largest and most blockable thing posthog-js fetches, arrives rather than failing while ingestion looks healthy; and that the reader's address is dropped in passing. It does not buy the sentence "no third party sees this", and PostHog is on the published subprocessor list with a row of its own saying so.
The site is a static export on Azure Static Web Apps. It has no server at runtime and its route rules cannot rewrite to an external host, so the site itself cannot forward anything and the alternative was a browser talking straight to a vendor. AF_POSTHOG_REGION now mounts a proxy at /ph on the control plane, which is the one process this product already runs on its own hostname. It is same site rather than same origin, which is why every route answers a CORS preflight and echoes one origin from AF_SITE_ORIGIN.
Three separate things stop it being a general forwarder, and none replaces the others. The upstream host comes from a closed set of two regions, so no request, header or setting can name a different one. The paths that reach PostHog are an allowlist, so a path that is not on it is not a route and is answered 404 rather than forwarded. A redirect from the upstream is refused rather than followed, which is what stops the vendor from steering this process at somebody else's server.
Nothing of the browser's crosses except content-type: no cookie, no authorization header, and not the reader's address. PostHog therefore receives no visitor IP at all. The cost is that its geolocation properties describe the deployment rather than the reader, which is the trade taken on purpose, because forwarding an address is the one direction that cannot be undone afterwards.
The allowlist holds both capture endpoints. posthog-js posts to /e/ by default and overrides it from the remote config, which for this project returns /i/v0/e/. A proxy built from the documented default alone would have forwarded the flag call, served the script, passed every check anybody ran, and lost every single event.
One prefix, two upstreams: the script and remote config paths go to the region's asset host and everything else to its ingestion host. Both are needed, and the asset half is the one that fails quietly: posthog-js sends every target at a custom api_host, so a proxy that forwarded only ingestion would leave the recorder bundle being fetched from the vendor directly, where a blocker kills replay while ingestion goes on looking healthy.
The legal gate that held the site to "no analytics and no third-party script" was rewritten rather than deleted. It now keys on whether posthog-js is actually a dependency and asserts both states: absent, the page must still deny PostHog by name; present, the denial must be gone and the subprocessor list must carry a row for it. Two more gates beside it refuse any posthog.com ingestion host in the site's source, require the configured api_host to be the mount this repository actually serves, and fail if PostHog starts without consulting the measurement flag the published page promises a reader can switch off.
Both settings can be delivered by the supported deploy paths: posthog_region and posthog_project_key on the Terraform module and stack, and config.posthogRegion and config.posthogProjectKey on the Helm chart. They were documented and read by the application before either path could set them, and the gate that holds the reference to the deploy paths is what found it.
The hosted control plane reports its own MCP tool calls and the model calls it brokers, and nothing it reports belongs to a customer.
Two producers, both server side, both off unless AF_POSTHOG_PROJECT_KEY and AF_POSTHOG_REGION are set. A hosted MCP tool call sends the tool name, which is a closed set of the eight tools the surface registers, how it ended, and how long it took. A brokered model call sends PostHog's own $ai_generation shape: model, provider, the token counts the provider itself reported, latency and a trace id, plus the cost when there was usage to price.
What is never sent is the point. Not a tool's arguments and not its results: a tool call carries project identifiers, hostnames, table names, SQL and error text, and inspect_recorded_egress alone would ship a customer's outbound destinations to a vendor. Not a prompt and not a completion, which are the customer's input and the model's answer about it. Not an organization identifier: the pseudonym is the same domain separated HMAC the control plane's own analytics already computes, so PostHog holds the identifier our own store holds and neither is an org id, and with analytics off nothing is reported at all.
$ai_latency is in seconds. Everything in this process measures milliseconds, so sending one into the other is a silent thousand fold error on a dashboard nobody would question. It is converted once, in the sink, and the test holds the reported value against how long the request actually took rather than against a plausible looking bound: the first version asserted the number was under sixty, which a local stub answering in three milliseconds satisfies in either unit.
Nothing of this kind exists in the engine and nothing of this kind may be added to it. af mcp runs on a customer's own machine, inside their network. engine/internal/telemetry already carries the engine's events, requires a redactor before any sink may write, and exports to the customer's own control plane. A path from there to our analytics vendor would be an outbound flow nobody agreed to, out of a product sold on the promise that production data stays in the customer's boundary. That is now written at the top of the telemetry package, where somebody would otherwise be tempted.
A failure cannot reach a caller. Both producers sit on load bearing paths, one being a customer's agent and the other being the proxy that spends their money, so every send is fire and forget, swallows its own errors, and is flushed at shutdown rather than awaited on a request. The generation is reported after the spend is recorded, never before, because the charge is the thing that must not be lost.
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.
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.
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 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.
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.
PostHog on the marketing site, and three published documents that said there was none.
The site counts page views with a first party beacon that sends a channel and a page shape from closed lists and nothing else. That was a deliberate design and it stays, running beside this. What it cannot do is say where somebody gave up: it sends no address, no element and no ordering, on purpose, so it can report that forty people reached the pricing page and never why thirty nine left it. Autocapture and session replay answer that, and PostHog Cloud US now does it for antifailure.dev.
The masking is the whole of the change and it is set explicitly rather than inherited. Every input value is replaced in the browser before anything is sent, by type as well as by the coarse flag, so a recording of the careers form or the enterprise contact form shows fields filling up with asterisks and never the name, work email, company or paragraph typed into them. No cookie is set, and posthog-js is configured onto sessionStorage rather than its default of a cookie plus a year of local storage, because two sentences already published say there is no cookie and that nothing here joins two visits.
Requests go to an endpoint this project runs on its own domain rather than to a posthog.com host, and the copy says in as many words that this is TRANSPORT and not a boundary: it changes where the browser connects, not who receives the data, and PostHog, Inc. receives it either way. What it buys is a request no content blocker's vendor list matches, so the numbers are not silently half missing, and a reader's IP address that is never forwarded, at the cost of any real geography on those dashboards.
Global Privacy Control, Do Not Track, the switch on the privacy page and a browser reporting itself as automated each stop it, and each stops it BEFORE the library is fetched. The import is dynamic and it is behind the gate, so a reader who has said no makes no request for PostHog's code and there is no recorder that read the page and was then told to stop. The switch also reaches PostHog mid visit: the beacon announces every change to the decision and the recorder ends with the queue, rather than each new producer being one more line somebody has to remember at the switch.
www/lib/subprocessors.ts, the privacy page and the data boundary page all said in writing that no third party saw anything and that there was no PostHog. All three now name PostHog, Inc. as a subprocessor, list what a recording holds and what is masked, and keep the claim that is unaffected and different: no account, organization, repository, policy, run, audit entry or piece of a customer's production data reaches PostHog, because nothing that handles any of those calls it. The subprocessor page also used to say this site loaded no script from another origin, which was already untrue: the contact page has been loading cal.com's booking widget. It says so now.
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.
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.
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.
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.
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.
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
46 entries
Behaviour that already existed and now works differently.
On Linux Docker daemons that require a BuildKit session, Antifailure now uses the installed Docker Buildx CLI to build service images.
A recorded dogfood run spent 95 seconds on its first legacy Docker build after the sessionless API request was refused. Buildx keeps the modern builder's layer cache and supports modern Dockerfiles without pulling BuildKit's session dependency graph into the Antifailure binary.
If Buildx is absent, the existing legacy fallback remains. Antifailure checks that the built image is visible to the daemon running the environment before accepting it, and retains the managed labels, content-based image tag, and redacted bounded build log.
A fresh Helm install that does not set an image tag now starts the v1.8.1 control-plane image.
Existing installations and explicitly pinned tags do not change.
A fresh helm install with no image tag, and a fresh Terraform apply with no image_tag, selected v1.5.5 while v1.7.0 was the release being served.
helm install with no image tag, and a fresh Terraform apply with no image_tag, selected v1.5.5 while v1.7.0 was the release being served.Two releases skipped the step that moves those defaults, so the gap was two tags rather than one and anybody who installed the chart without pinning a tag got software two releases older than the release they had just read the notes for.
The three defaults now name v1.7.0: the control plane stack's image_tag, the module's image_tag, and the chart's appVersion, which is what image.tag falls back to. The chart's own version is unchanged, because it moves when the chart changes and the two numbers only agree by coincidence.
Nothing already running changes. A deployment made from the tag's own image is already on v1.7.0, and an installation that pins its own tag is unaffected. What changes is what a fresh install reaches for when it is not told.
The website now leads with the MCP workflow and explains each capability in shorter, more direct language.
Product, solutions, pricing, forms, and articles focus on what a team can test and the findings it can review. The main hero headline and its animated green artwork remain, with a reduced-motion fallback. The hero offers the install command and a demo request, with less empty space above the headline. The demo section plays the owner-provided recording with its original audio and video.
Five compact feature diagrams replace the miniature app windows, with three-line descriptions and aligned baselines. The migration section connects a small schema change to a recorded table rewrite and lock finding in one diagram. Firewall routing shows simulated payments, captured emails, and blocked calls, with a separate composition for narrow screens. The closing panel uses an agent-to-twin diagram instead of the aurora image. The Isolated Twin section shows a focused version of the real console with recorded demo environments. It replaces the animated lifecycle mockup and reflows the environment table into readable records on phones. Mobile comparisons and request ledgers reflow without clipped labels. Form fields use a readable phone size, and menu and video controls retain their tap targets. The demo recording has a new URL so cached older clips do not replace it for returning visitors.
The mobile menu removes repeated links and keeps keyboard focus inside the open menu. The sign-in privacy summary uses an accessible dialog, and the privacy notice distinguishes masked session recordings from submitted form details. Owner-provided business contact details are included. The status page has a main heading and a header that fits narrow screens.
The container image that every golden store suite needs is now pulled from a registry this project controls, and the pin, the mirror and the registry are held to one digest by a gate rather than by a comment.
Between 23:17 and 23:25 UTC on 2026-09-24 a publisher made its own MinIO images private, and the consequence was out of all proportion to the cause: the object storage step of the engine job began failing on every pull request, the test step after it reported skipped, and a required check went red having examined nothing. Six pull requests and the main branch were blocked behind one third party's decision about its own namespace.
The pin moved that day to a different publisher's build of the same source, and both files said in as many words that it was a stopgap, because the replacement namespace is a frozen archive with no retention promise. This finishes the job. The image is copied into ghcr.io/antifailure/minio and the two places that start it, the CI workflow and the stores recipe, name our copy.
The copy is byte identical to what was reviewed, and that is checked rather than asserted. Manifests are moved as bytes with crane copy rather than pulled and pushed, which would collapse a multi architecture index into whichever single platform did the pulling and change its digest, so one declaration still resolves to the right image on an amd64 runner and on an arm64 laptop. The digest is read back off the registry afterwards and a mismatch fails the job. It was also verified by hand for the copy now in use: an anonymous manifest read returning the same digest, both child manifests present for amd64 and arm64, every layer and config blob of both readable with no credential, and the pulled image running and refusing a deliberately wrong signature exactly as the original does.
Anonymous readability is the property that matters rather than ownership. A runner and a fresh clone have no registry login, so a private mirror would have reintroduced the failure it exists to escape. Nothing gained a credential, a scope or a login step.
The new risk a mirror brings is that the registry and the repository drift apart, so the check that used to hold the sites equal has been replaced with one that can still see them. The old one groups references by name, which stopped being able to compare a pin against the mirror's source the moment the two named different registries. The new one refuses a pin the mirror does not copy, a pin left on the upstream at the correct digest, a mirror whose own verification is aimed at something it did not copy, a mirror of a moving tag, a copy published under a version it is not, a mirror nothing consumes, and a mirror workflow it cannot read at all. That last one matters most: a gate that reads a missing declaration as a clean tree is the failure this project keeps finding in its own instruments.
One defect in the mirror workflow itself is fixed here too, and it was found by running it rather than by reading it. Its install step reported success while the step after it died with crane: command not found, because go install writes to a directory that is not on the runner's path. The tool is now called by its full path and the install step runs it once, so a tool that arrived is proved rather than assumed.
A pull request that changed nothing but your infrastructure as code selected no check, and was not rehearsed at all.
The analyser mapped the infrastructure surface to nothing, in the same line as prose, your own test suite and your continuous integration configuration, under one true sentence: the environment is built from antifailure.yaml rather than from your Terraform. Read the fail safe this package is built on and the grouping is the mistake. A path wrongly treated as inert skips work that should have happened and nobody finds out; a path wrongly treated as unknown costs a run nobody needed and is visible in the report. Prose, a test file and a workflow file do not run in production. Your infrastructure as code does. It was the one of the four that is not inert, and it sat in the bucket for the three that are.
The consequence was not a quieter report. The published action gates the whole run on steps.change.outputs.environment, so a change to production's database, its capacity or its firewall wrote environment=false and ran nothing.
An infrastructure change now brings the environment up and drives the application inside it, and the added lines are read for what they say. A database engine version or a server parameter selects the migration rehearsal, because the rehearsal is what applies your migrations to a Postgres and measures what they lock. A replica count, an instance size or an autoscaling bound selects load. A firewall or security group rule selects the egress decisions. Each fact names the file, the line number and the rule behind it, the same as every other conclusion this command draws.
The sharpest of them is new rather than renamed. When an added line sets a database engine version and database.version in the manifest is a different major, the report says which one the rehearsal will actually use, naming both numbers. That pair lives in two files and nothing used to hold both, so a repository could move production to Postgres 18 and go on proving its migrations against 17 with every check green.
Read the claim precisely, because the report now repeats it on every run that touches one of these files: nothing applies your infrastructure as code. The environment stands in for the runtime the change describes, and standing in for it is not being it.
Three limits, stated rather than discovered. A bare version key is not read as a database version, because it is also how every provider pin and every chart states its own, so an Azure Postgres version is in a key this cannot see. A URL inside an infrastructure file is not read as an outbound host, because it is usually a module source, which is a download the build makes rather than a call the application makes. And nothing parses HCL: the added lines are read as text, so Kubernetes manifests, Helm values, Bicep and CloudFormation get the same reading rather than the one format a parser was written for.
The published action also exports an output per check now, rather than the two it happened to name, so a job calling it directly can skip its own work on the same answer the action already computed.
Two smaller things fell out of writing the tables down once. The surface table on the concepts page is now compared against the table the analyser plans from, rather than kept in step by hand, and the first thing that comparison found was that auth had never been published at all: the surface existed, it selected the environment and the workflows, and the page a customer reads did not list it. And a check reads the example on that page back out of the binary, so the worked output there is what af change actually prints rather than what it printed when somebody pasted it.
SECURITY.md said the antifailure.dev domain publishes no mail exchanger, and gave that as the reason there is no security mailbox.
SECURITY.md said the antifailure.dev domain publishes no mail exchanger, and gave that as the reason there is no security mailbox.The domain now has MX records and receives mail, so the sentence had become false, and a false fact in a security policy is worse than a missing one: it invites a reader to test it and to distrust the rest of the page when it does not hold.
The policy itself is unchanged. Reports still go through a private vulnerability report on GitHub, and there is still no security mailbox. What changed is the reason given for it, which is now a choice that is stated plainly rather than a limitation that no longer exists: no address on the domain is monitored for security reports, and one sent there would miss every commitment the page makes. The sender half of the old sentence was and remains true, so it is kept and made specific: the root authorises no sender at all under v=spf1 -all with DMARC p=reject.
The static code reviewer read only the lines a change added, so it was blind to the bug whose cause sits on a line the change did not touch.
A nil dereference on a new line is only a bug because of the line above that left the value nil. A value used as the wrong shape is only wrong because of where the shape was set. A new call is only broken because of the contract defined in code the diff did not add. The reviewer was handed the added lines alone and told to be sure about nothing it would need the rest of the file to judge, which is exactly the class of defect a whole file reader catches and a hunk reader cannot.
The reviewer now sends the model each changed file whole, with the added lines marked and the unchanged lines around them shown as context, and decides on the whole file while still reporting only on the added lines. A file too large to send whole falls back to its added lines with a window of surrounding context, and a change too large for the total budget reviews its highest signal files and names the rest as unreviewed rather than dropping them in silence. A finding the model anchors to a context line is dropped, so seeing more of the file never lets the reviewer report a pre existing bug the change did not introduce.
The injection security family shipped able to fuzz an endpoint and prove a SQL, command, template, NoSQL or path-traversal vulnerability, and then fuzzed nothing.
It reads the routes to exercise from security.Input.Routes, and the collector that builds that Input wired the field to nil, because no producer existed. A nil route source is UNAVAILABLE by the family's own contract, so on every run the family reported blocked and moved on. The capability looked shipped and did nothing.
The producer exists now. The collector sources the routes from the browser exploration the run actually performed: every page it stood on and every navigation it made is a route the run OBSERVED reaching, which is what the family is meant to fuzz, rather than a route a manifest merely declared. A concrete id in a path is templated to {id} and only query parameter names cross into a route, never their values, so a route stays a location and never a row id or a user's input. The three states the family reads as opposite verdicts are kept exactly: a populated slice is fuzzed, an empty slice is the quiet pass of a run that reached routes but none worth fuzzing, and nil stays UNAVAILABLE for a run with no exploration to read, so a missing source never reads as a clean bill of health.
An environment could still be born with no expiry, and the reaper never collects one of those, so it lived until somebody remembered it and read the bill.
Two ways in, both closed now.
runtime.ttl: 0h passed validation: 0h is a duration, so the check that a lifetime is a duration accepted it, and the runtimes stamp the reaper's expiry label only when the lifetime is positive. The result was an environment with no label, invisible to af env reap for ever. A non-positive ttl is now refused at load, with a message that says to set a positive duration or leave it unset for the default. A manifest that states no ttl still inherits the 24h default, so "no lifetime stated" means the default and never means immortal.
A af ci run stamped its throwaway environment with the day-long default, and a run that CRASHED, killed for memory or with its runner pulled out from under it, never reached the teardown that would have removed it. The environment sat for a day carrying that default. af ci now bounds its environment to the run's own budget, its --timeout plus a grace and never more than runtime.ttl, so a crashed run's leaked environment is collected within the hour rather than the next day. The normal path still tears it down at once; only the crash path is changed, and only for the better.
a-crashed-run-and-a-zero-ttl-leaked-environments-that-never-expired
Every "Start free" and "Create an account" button on the marketing site pointed a stranger at a door that does not open.
Self-serve organization creation is gated by AF_SELF_SERVE_SIGNUP, which defaults off, so a GitHub exchange from /signup created nothing: the header, the hero, the closing panel, the pricing page, the solutions pages and the footer all promised an account anybody could make, and the account never came. The page under them described a free plan you land in by signing up, on a plane that admits no one who signs up.
The hosted plane is reached by talking to a person now, and the site says so. A new /request-demo page carries the split shell the sign-in screen uses: the pitch and three claims the product actually makes on the left, and on the right a request form a sales team reads. It asks for the Harvey field set, name, business email, company, job title, an optional phone, an organization type and a country, and a marketing opt-in that is unchecked by default. It posts to the same POST /v1/leads the enterprise form does, so the request lands in the product's own database and is announced over the same Resend path, with a "demo" source so it is told apart from a contact-form lead. The leads table has no column for a job title, an organization type or a country, so those ride in the message as a labelled block a person reading the queue can act on, which is what keeps this off a new table and its migration number. Booking a call stays on /contact, where cal.com runs and reaches a person on a known day; this page is the other half, for somebody not ready to pick a slot.
The form is built to the site's bar, not the happy path alone: it validates every required field and the email format before the network and names the first thing to fix, it keeps everything typed when a submission is refused, it carries a hidden honeypot that answers a bot with the same confirmation and writes nothing, and it renders idle, sending, one human error, and a confirmation that replaces the form. There is no logo wall, because the site carries none anywhere and a strip of borrowed marks is the one thing that would make the page read as generated.
Every self-serve call to action leads there instead, and the copy beside each one is corrected to match rather than left contradicting its own button. /signup 301s to /request-demo, in public/staticwebapp.config.json and as a MovedPage, so a bookmark or an indexed link still lands somewhere true, and it leaves the route registry the way the moved product pages did. The auth screen's sign-up variant is gone rather than left as a dead branch nothing renders; signing in with GitHub stays for operators who already have an organization, and the open-source quickstart stays the path that needs no account at all.
Change 2 in the dispatch, wiring setOperatorPassword to a portal tRPC route, is already on main: admin.operators.setPassword shipped in #349 with the console form, the client wrapper and behavioural tests. It is not rebuilt here.
Correction, 2026-09-24. The first paragraph above is wrong about why, and it is left as it was written rather than rewritten. AF_SELF_SERVE_SIGNUP was not off. It was on in staging and in production when this shipped and it is on now, set as self_serve_signup in each environment's tfvars under infra/terraform/stacks/control-plane since 2 September 2026. The variable declares a default of off and that default is what was read; neither deployment uses it. Sending a stranger to the demo request page is a deliberate choice about how the hosted plane is sold, not a consequence of the flag.
THIRD_PARTY_NOTICES.md now attributes everything Antifailure ships, in a section per artifact: the af binary, the community control plane image, and what the enterprise control plane image adds.
THIRD_PARTY_NOTICES.md now attributes everything Antifailure ships, in a section per artifact: the af binary, the community control plane image, and what the enterprise control plane image adds.It used to list each Go module's path and version and nothing else, and said nothing at all about the npm packages and the console the images carry.
For the binary it names the licence of every Go module it links, as an SPDX expression read from the files the module itself ships, and reproduces the NOTICE files those modules distribute. For the images it replays each image's own npm ci, read out of its Dockerfile with the same flags, and reads each installed package's licence text; the console is attributed from what its static export actually bundles, measured from a build with source maps. A package that ships no licence text is attributed from its declaration and the notice says so.
The licence is recognised, not guessed, and a gap stops the build instead of shipping: the generator fails naming the module or package it cannot identify, and fails naming the cause when npm or the network is not there, rather than falling back to a lockfile. The file travels with each artifact, in the release archives and at /usr/share/doc/antifailure/THIRD_PARTY_NOTICES.md in both images, which also name that path in the dev.antifailure.notices label.
A manifest may declare two services listening on the same port.
The validator refused that pair with "both claim port", so a Next.js application beside PostgREST, which both listen on 3000, was a manifest Antifailure would not load, and af init refused to write one for a compose file that runs them side by side. Neither runtime shares a port between services: the local runtime publishes each service on a host port it allocates for that service, and the Kubernetes runtime gives each its own Deployment, Service and hostname. The port in the manifest is what the service listens on inside its own container, and that is all it has ever been.
A runtime that did put two services into one network namespace would break this, so the runtime conformance suite now checks it: Up_ServicesOnOnePortAreEachReachable starts two web services on one container port and requires each to answer with its own response at its own reported address.
Four places in the shipped tree phrased a deliberate boundary as work somebody would get to later, and a boundary described as deferred reads as a hole.
Each now states the decision it always was, with no behavior touched.
A budgeted key still refuses a streaming request with a 400, but it no longer says streaming is "not supported yet" or that passing the stream through is "worth doing when something actually streams". A budgeted key is non-streaming by design: the budget is a per token spend limit, and a streamed response carries no total token count until it finishes, so there is no figure to meter the spend against while the tokens are on the wire. Refusing is the fail closed answer and it is the correct one.
Per person GDPR erasure showed a red "Not implemented" badge and a rationale that read like a build list, naming the foreign key walk and the audit chain answer a future implementation would need. It now reads "Not offered by architecture", in a neutral tone, because that is what it is: the audit chains hash each entry into the next, so a per person deletion that reached them could not rewrite them without breaking the tamper evidence the compliance record itself depends on. Organization erasure, which is implemented and running, stays the operation this product offers. The disclosure is unchanged in substance and no erasure code path was added or altered.
The control plane's Key Vault module carried a "WHAT WOULD MAKE IT REAL" comment framing an Event Grid expiry alert as a trigger to revisit. It is not a step left for later: Key Vault raises SecretNearExpiry and SecretExpired as Event Grid events, never as the Azure Monitor metrics every alert in modules/alerting is built on, so wiring one would need a whole new subsystem (a system topic, a subscription, and a bridge, because an event subscription cannot target a Monitor action group directly) and would still leave the two harms the block already documents. Rotation of github-client-secret stays an operator procedure, on the operator's calendar, not a date in a file with nothing behind it.
This repository's own manifest now rehearses the pages a customer crosses on the first day, and two defects that stopped it from doing so are fixed.
The manifest had eight workflows on a handful of signed in pages, one exploration goal, no oracle probes, no load scenarios and nobody signed out. compare_with_previous_release refused to run against this repository, af load scenario answered AF-LOD-010, and the sign-in screen, the getting started steps, the plan, the terminal sign-in, the members list, settings and the operator portal were visited by nothing. It now declares a visitor persona with login: none, seventeen workflows, ten exploration goals, an oracle block of eighteen signed out probes and two ordered load scenarios under scenarios/, and every one of them was run against a real environment before it was committed.
A persona with login: none stopped provisioning for every persona. The engine handed it to the authentication adapter like any other, and a seed command written for accounts that sign in refuses one with no address, so af up reported that nobody could sign in and af test and af ci failed before a single workflow. Such a persona is now recorded without the adapter being asked to create anything, since it never signs in.
An exploration could pick a control nobody could press. The snapshot offered elements hidden with display: none, such as a mobile menu button at desktop width, and pressing one timed out and ended the exploration as blocked with nothing explored. Hidden controls are no longer offered.
The burst scenario is sized to the control plane's own limit of twenty page loads a second per address, because every session a scenario sends shares the load generator's one address. Forty sessions measured the limiter refusing 392 of 640 requests rather than the console serving them.
Four legal pages are gone from antifailure.dev: the service levels page at /sla, the Data Processing Agreement at /dpa, the subprocessor list at /subprocessors, and the retention and deletion page at /data-retention.
/sla, the Data Processing Agreement at /dpa, the subprocessor list at /subprocessors, and the retention and deletion page at /data-retention.Their footer links, their route registry entries and every cross reference to them go with them.
The service levels page led with the sentence "There is no service level agreement." Under it sat a list: no uptime target, no measured uptime to quote instead, no support response time, no service credits, and no on-call rotation. Every line of that was true and every line of it was an argument against buying the product, written in the product's own voice on a page nobody asked for.
The disclosure that had to survive moved rather than disappearing. The privacy notice now names PostHog as a processor for this website, says that PostHog, Inc. receives the data, and names the region it is processed in, which was the one fact on the subprocessor list a reader could not work out from the endpoint they can see.
The gates that held the deleted pages to the code were repointed rather than deleted with them. legal-facts.test.ts still proves that the privacy page discloses PostHog exactly when www/package.json depends on posthog-js, in both directions, and that the page naming a measurement switch is a page that renders one. The retention gates went, because the numbers they held are no longer published anywhere. Both repointed assertions were mutation tested: with the "PostHog, Inc. receives" clause removed the suite reports that the page never says who receives the data, and with the region removed it reports that the page does not name the cloud region.
The conformance suite had two answers, and the claim this database category is sold on could honestly be given neither of them.
CopyOnWrite_BranchTimeMatchesTheDeclaration decides by stopwatch, and over a fake cloud control plane on one local Postgres the stopwatch measures the harness. The only way such a fixture can hand back a branch carrying the golden's data is CREATE DATABASE ... TEMPLATE, which copies files, so a truthful CopyOnWrite: true failed and a CopyOnWrite: false passed comfortably. Both answers were about the harness.
There is now a third verdict, UNPROVEN, distinct from pass and from fail and never called a skip. Options.RealService names the actual service a run drives, and LEAVING IT EMPTY is what produces the unproven verdict. It is an assertion of reality rather than an admission of simulation, because a field a fake sets to excuse itself is a field a fake can simply never set. Forgetting this one produces the safe answer.
It is symmetric. A run that asserts nothing is unproven whether the provider declares true or false, because the false side is the one that would otherwise ship: a snapshot restore provider passing comfortably against a copying fake publishes a certified claim about its service, and nobody rereads a green check.
Six cells of the published comparison table carried a verdict beside the words "not measured yet". A ledger now records the verdict per provider, two sweeps check that no declaration lacks one and no published cell disagrees with one, and conformance.CopyOnWriteClaim renders an unproven declaration as the word unproven rather than as the declared value.
Two providers were measured rather than assumed while proving this, both needing no account. pgurl declares false against a real Postgres and holds. docker declares true against a real daemon and holds, so the provider overview, which said its branch time grows with the database, was wrong and now says what the measurement says.
The credential path was written once per secret store and needed by six lanes.
AWS Signature Version 4, the Google metadata server plus the service account JWT exchange, and the Microsoft Entra client credentials flow all lived inside ee/engine/secrets, next to the store that first needed them. None of them is about reading a secret. Every managed database provider and every runtime that reaches a cloud API proves who it is the same way, against a different endpoint, so the next six of those would each have grown their own copy of a signer where one wrong byte is a 403 that reads like a wrong password. Two signers that agree today are indistinguishable from one signer until a fix lands in only one of them.
They now live in ee/engine/cloudauth, with the AWS credential chain, both Google token paths, both Entra token paths and the one bounded HTTP client they share. The secret sources call it and print exactly the messages they printed before, including which of the four places AWS credentials were looked for and which identity a refusal was refused for.
Nothing else about the behaviour moved. The rejected and not-configured sentinels are the same values rather than two declarations with the same words, which is what keeps the one-refresh rule working across the boundary, and the Azure scope is still the vault resource with /.default after it, pinned in a test against the literal string rather than against the constant that derives it.
Masking held one guarantee above all the others: the same customer maps to the same fake customer in every table and every refresh, so a foreign key still joins after the data is rewritten.
It held that guarantee inside one Postgres, and it had never been asked to hold it anywhere else.
Every part of the pipeline that touches an engine was Postgres and said so nowhere. The classifier matched a rule's type: against Postgres type names, so a rule saying type: text could not match a ClickHouse String. Nothing then decided what happened to that column, so it was copied unchanged, while the Postgres column beside it holding the same address was masked. One person, two answers, and no error anywhere: the Postgres store holds a fake address and the analytics store holds the real one. A join across the two returns nothing or returns somebody else, every report built on it is plausible, and the fidelity report calls the environment faithful.
That is worse than the empty ClickHouse the previous change is about. An empty store is visible within a minute of opening a chart. A store masked into a different person is confidently wrong.
So there is now a dialect boundary, and it is deliberately narrow: an engine's type names, its identifier quoting, its statement parameters, the read that takes a chunk, and the rewrite that puts one back. Everything else was already engine independent and stays that way. The transforms are pure functions of a key that never reaches the database, computed in Go, so what a value masks to never depended on which store it came out of.
Each engine's type names map onto the POSTGRES names rather than onto a third vocabulary, so one masking.yaml covers every store and a rule anybody has already written keeps meaning what it meant. A type an engine's dialect does not recognise comes back unchanged, which lands it in the branch that reports a column rather than deciding about it. An engine nobody has a dialect for is refused at planning time rather than treated as Postgres, and a ClickHouse table with no sorting key is refused for the same reason a half masked table is never started: ClickHouse has no physical row identifier, so there is no statement that means one row.
The verification scanner gains the same boundary and keeps its own copy of the type table, because it must not import the thing it checks. A test requires the two to agree, which is how the Postgres lists have been kept in step and is now how the ClickHouse ones are.
And the guarantee is now checked rather than argued. CrossStoreCheck takes two stores' plans, finds every identifier that appears in both, masks probe values through each side, and reports the share that come out identical. A column masked in one store and copied in the other is a leak; two columns masked with different transforms, or under different links, are one identity becoming two people. Anything below 100 percent is a bug, there is deliberately no way to mark a pair exempt, and a report that compared nothing is not a pass.
A control plane that answered every request correctly and slowly paged nobody.
The alerting module watched failures: a 5xx count, a restart loop, replicas below the configured minimum, a database that stopped answering, an availability probe that failed outright. A saturated replica set, a blocked connection pool or one slow query on the hot path produces none of those, and the availability test's thirty second timeout stayed green through all of it.
A twelfth rule, slow-responses, now reads the Container Apps ResponseTime metric and pages at severity 1 when the average across every request stays above a threshold for fifteen minutes. The threshold is response_time_threshold_ms, a new input on the alerting module and on the control plane stack, and production sets it to 2000 with the measurement that chose the number beside it. An operator running this stack picks up the rule on the next full apply of the alerting module; it is outside what the deploy pipeline's targeted configuration apply reaches. Its runbook is at /docs/self-hosting/runbooks/slow-responses.
The hosted control plane scales on a declared concurrency instead of the platform default.
Production ran its first release on six replicas and a scale rule nobody had written: Azure Container Apps adds a replica at ten concurrent requests when no rule is declared, so the whole product could hold sixty requests in flight before queuing, and a slow reader holds one for as long as a response streams. The number is now declared in Terraform, forty per replica in production with a ceiling of twelve replicas, and the connection arithmetic beside it shows the database still has five times the headroom the app can use.
The customer console's sidebar is black, the way the operator portal's has always been.
The two are one product served from one origin and they had two rails, an off-white one with grey text on the customer side and a near-black one with white text on the operator side, so a person with both open saw two generations of the same thing. The customer rail, the mobile bar, the drawer and the reduced header a lapsed plan shows now use the portal's tokens by value: the same black, white on the current entry and a softer white at rest, the same pill behind the current page, and a focus ring drawn in white so keyboard navigation is visible on the dark ground. The content beside it stays light. Every text pair on the rail measures above 7:1.
The first sign-in walk reads as four designed screens rather than four grey lists. The cover pane carries each step, with a headline and a line of its own, the first still being the sign-in screen's sentence. The three places you can use Antifailure are large tiles, three across on a desktop, with the icon, the title, the line, and a clear tick. Each plan is set as a figure: the name large, its six limits as a two row grid of tabular numbers, and how the plan is come by as quiet metadata beside the name, still with no price, because the pricing page marks its bands illustrative and Stripe holds the real one. The setup cards sit on a numbered rail whose numbers turn into checks, Mark done is a check control that ticks and quiets the card, and a line above them counts the cards done. The four benefits are four rows. The words under the progress bars name the steps. One motion exists, a 160ms fade and rise when the step changes, and it does not run for a person who has asked their system for less motion.
A configuration change in the hosted control plane's tfvars now reaches the running container through cd.yml, staging on the merge and production on the tag after the approval, instead of waiting for a person to run a targeted terraform apply and a traffic shift by hand.
cd.yml, staging on the merge and production on the tag after the approval, instead of waiting for a person to run a targeted terraform apply and a traffic shift by hand.On 2026-09-05 three such changes had to reach production: the PostHog region and project key, then the three Stripe variables with their two Key Vault references. Each was a line in production.tfvars, each was merged and released, and each was inert. deploy/cd/deploy.sh runs az containerapp update --image, which carries whatever template the app already has and sets no variable of its own, and cd.yml ran no Terraform at all. Each change got into the container only because somebody ran the apply behind a one-off guard script at a terminal and then moved traffic. In between, the marketing site went live pointing at /ph and the proxy answered 404 for an hour, because the variable that turns it on was in a file and not in the app.
Both deploy jobs now run deploy/cd/apply-config.sh before deploy.sh. It initialises the stack against the environment's own state, plans the tfvars targeted at the container app alone, and hands the plan to tools/configguard, which accepts exactly two shapes: no changes, or one in-place update of that app whose only differences are the container's environment list and the app's secret references. The serving image, the traffic weights, every other attribute and every other resource must be identical before and after. A create, a destroy, a replace, a second resource, an image or traffic change, or any other attribute drift is refused with the reason and the plan summary, nothing is applied, and the job stops there. It never shifts traffic: the revision it makes sits at zero percent, and deploy.sh creates the one that takes traffic from the same template a minute later, behind the migration and both health gates. Production's alert receivers are read back out of state so the action group stays a no-op, and the GitHub OAuth inputs are placeholders because the vault secrets that carry them ignore their value.
What still needs a person is everything in the tfvars that is not the app's configuration, a database SKU, a role assignment, the alerting module, a Key Vault change, and the guard refuses a plan that carries one. The deploy identity needs Key Vault Secrets User on each environment's vault to plan, because refreshing the app's secret references reads their values; ci.tf declares it and the production grant has to be applied once by hand, since the targeted apply cannot create it for itself.
The question after the first sign-in has become a short walk beside the cover pane you signed in through.
The one question asked which of three people you were and showed that one's next step. Somebody who wanted checks on pull requests and a terminal on the same machine had to pick one and lose the other's instructions. The plan was a page found later or never, so the first time most people met a limit was the refusal. And nothing said what the control plane was for once something had been connected, so the environments list stayed the explanation.
The console now walks four steps, one screen at a time, in the column the sign-in button was in, with the sign-in cover beside it. Where you will use Antifailure, ticking every place that applies. Which plan to start on, with the limits each one enforces, read from the control plane's own tables, and the plain fact of whether this control plane takes payment at all, whether you may buy on the organization's behalf, and whether the plan is bought here or arranged with a person. One setup card per place you ticked, with the App install and a live line saying how many repositories it reached and what the App did about each one's workflow, the three terminal commands addressed to this control plane, or the agent endpoint and what a credential on it may do, each with a Done you can mark and none of them blocking. Then four things the control plane gives you, each from the documentation, and the way out: the environments list, or Stripe's checkout when a paid plan was chosen, which opens only now, after setup, so nobody pays before anything is connected.
The one word the console remembered before is still the word it remembers, so the root, Settings and an older console keep reading it. Skip for now and Escape still land on the environments list and are not asked again.
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.
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.
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.
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.
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
349 entries
Something that claimed to work and did not.
The crash-recovery check no longer fails when PostgreSQL recovers between two availability probes.
The probe samples every 100 ms; a logged 69 to 70 ms crash can leave every query answered. The check now preserves that observation instead of requiring an unseen refusal, and compares the report to the queries the probe actually measured rather than demanding exact agreement with PostgreSQL's separate process-recovery timestamps. Reports also carry the actual number of probe attempts, so an empty sampler cannot be presented as a database that answered every query.
A terminal workflow could be told its program never printed something the program was about to print.
The driver waits for a program's last word and accepted a silence of 600 ms as proof it had finished. That silence is measured on DELIVERY, so its clock counts the gap since the DRIVER last read rather than the gap since the PROGRAM last wrote, and those are the same number only while the program is the one deciding. A program merely descheduled on a loaded machine is silent without being finished, and the verdict was then expectation-not-met, which says the program did not print something, about output nobody had waited for. On a developer's own machine that is a wrong red pointing at a bug that does not exist.
It was reachable rather than theoretical. Induced at one position with only the length of the silence varying, 300 ms and 500 ms never failed, 700 ms failed two runs in three and 1000 ms failed three in three, so the transition sat exactly on the constant and raising it would only have moved it. The same defect failed this repository's own runner check once in 333 observations, on pull requests that had not touched the runner.
A silence now ends the wait only when the screen ALREADY shows what the workflow expected, which is the rule both web planners follow: more output can only ever turn an unmet expectation into a met one, because the transcript accumulates. An exit still ends it at once, and the budget still bounds it.
WHAT THIS CHANGES FOR A WORKFLOW YOU ALREADY HAVE, because it is a timing change you will see. A terminal workflow that FAILS, against a program that does not exit on its own, now takes the maxMs it declared before reporting the failure, where it used to report about 600 ms after the program's last byte. With no maxMs that is the 30 s default. A workflow that PASSES is unchanged, and so is one whose program exits: a met expectation still ends the wait one silence after the last byte, measured at 640 ms against a 30 s budget. If a pipeline gets slower after this upgrade, it is a workflow that was failing, and the extra time is the driver making sure the failure is the program's rather than its own.
That is the cost, it falls only on the case that has already gone wrong, and it is the right way round: the alternative spends correctness to buy latency, and what it bought was a fast answer that was sometimes untrue.
Two residuals that were only in a handover are now stated beside the code they constrain: the drain for a program that has exited still has no ceiling, so such a program holds the driver for as long as its backlog takes to parse, and a program whose expectation is already met and which then contradicts itself is still judged on the earlier screen.
A lock finding could name a table nothing had locked, and a contention count could be raised by a run nobody was looking at.
Both readings are taken from pg_locks and pg_stat_activity, and both of those views are cluster wide: they show every backend and every lock on the server, not only the ones in the database you asked from. pg_locks names a relation by object id alone, and the documentation says what that costs in the sentence after it, that joining that column to pg_class "will only work correctly for relations in the current database". Neither reading carried that condition, and the workload's reading scoped its backends by application name alone, which every run of this product shares.
The topology is what turns that from a curiosity into a wrong number. On a host server, which is how a self hosted or managed Postgres is used here, the provider creates a database per golden and a database per environment on ONE server. A branch is CREATE DATABASE ... TEMPLATE, a server side file copy that carries pg_class verbatim, so two branches of one golden agree on the object id of every table in them. A foreign lock therefore resolved against the local catalogue and came back wearing a real local table name rather than an obvious error. Measured on two such copies, with the table renamed in one of them so the misresolution could not hide behind a matching name: a session queued on the second branch's shipments was reported from the first as a wait on orders, a table nothing had waited on.
Three things were wrong, and the quietest is the one that reached stored history.
A migration's lock report could name a relation from another branch, at AccessExclusiveLock and a duration that fails the check, for a change that had touched nothing of the kind. It could also mark a lock as contended because a session in another branch was queued behind a relation that merely shared an object id, and a contended lock is reported even when its duration is below the threshold that would otherwise ignore it, so a foreign queue could promote a harmless hold into a finding and then explain it with a sentence that was not true of anything.
A concurrent SQL workload's contention count could be raised by a neighbouring run. That count is not a label: it is the number the result carries as how many times one of this run's own backends was seen to start waiting, and it is what a two build comparison differences. The backend counts beside it, the peak active backends and the peak open transactions, could count a neighbour's sessions as this run's own.
A migration's lock report also named TOAST tables and temporary tables. The filter excluded two namespaces by name and every TOAST relation lives in a third, so pg_toast_2618 reached a pull request, and a table rewrite put the author's own pg_toast_<oid> above the table they changed at the strongest lock mode there is, under a name they had no way to look up. Reporting a temporary relation is worse than noise, because nothing in production can queue behind one.
All three readings now ask for the database they are about, and the lock report names only relations in a namespace the project owns. Nothing is lost: a lock in another database cannot queue a query in this one, and Postgres refuses to create any schema whose name begins with pg_, which a test measures from the server rather than trusting.
Two failures printed a raw tool error at the reader instead of the sentence the installer composes to explain what happened, and in both cases the message meant for that reader was sitting right there underneath it.
A refused download printed curl: (56) The requested URL returned error: 404, so the one line install answered a mistyped AF_VERSION, a platform a release does not carry, and a rate limited archive with exactly the kind of noise the message below it was written to replace. curl is invoked with -sS, which is silent but shows errors, and of the three places install.sh fetches something only this one failed to discard the tool's own stderr. The number in that line is curl's error code rather than the HTTP status, and the same 404 surfaces as 22 or as 56 depending on how the request failed, so a reader who takes it for a status reads a different number every time and neither one is the status the next line reports from the same URL. BusyBox wget, which is the wget Alpine ships, ignores its quiet flag for its error line and wrote wget: server returned error there too.
An install target that could not be created printed mkdir: /some/path: Permission denied straight after Checksum verified, with none of the installer's own words, and printed it twice whenever the binary directory sat under the prefix, which is the default. The message four lines below it covers a different case: it fires when the binary cannot be written into a directory that already exists, and says nothing about a directory that could not be made. The two directories are created separately now, each reporting its own path and naming AF_PREFIX and AF_BIN_DIR as the way through, so an unwritable binary directory no longer leaves an empty prefix behind on the way out either.
AF_BIN_DIR and AF_PREFIX are documented, which they were not. AF_PREFIX moves the whole installation and AF_BIN_DIR moves only the binary, which is what to reach for when af should land in a directory that is already on the PATH.
The tests could not see either leak, and for one reason. Every check for a raw error lived in the helper that runs a SUCCESSFUL install, so it never ran on the paths that produce one, and the failure path tests each assert that the sentence they care about is present and the sentence it replaced is absent, neither of which can see a line added beside them. The check now runs on every invocation of the installer and covers both the fetchers' voices and the shell's.
a-raw-curl-error-sat-in-front-of-the-sentence-written-to-replace-it
A chaos run's thaw reported that it had left the database frozen, when the daemon had accepted the thaw and had not applied it yet.
ContainerUnpause returning means the request was ACCEPTED, not that the cgroup freezer has been written and the container's state recomputed. Under load those are different instants. The undo read the state back ONCE, immediately, with no wait, so it landed on the state the daemon still had and reported is still frozen after the daemon accepted the thaw about an environment that thawed a moment later.
The read back is why this is a wait rather than a grace. It stays, and it still refuses to call a frozen container thawed, because asking FIRST whether the container was paused is the older defect that turned a stumbled inspect into an undo that reported success and left the database frozen. What changes is that paused and the inspect failed are now read as NOT YET rather than as the answer, for ten seconds, which is longer than the worst Docker control latency measured on the hardware this was found on.
When the window does run out it still says no, and it says which of the two things it saw: a container still frozen after a named duration, or a thaw nobody could confirm after a named number of attempts. A cancelled context ends the wait rather than spending the window on a caller that has already given up.
The cost of the missing wait was paid by unrelated changes. It reddened the edition boundary gate on a pull request whose whole diff was a container registry switch, and tools/editioncheck correctly reported COULD-NOT-LOOK rather than naming a violation, having run the package three times with two different verdicts.
A terminal workflow could wait past its time budget while its emulator was still parsing output.
Active programs now return a blocked result when that budget expires, including when they go quiet with unread output. Programs that have exited still drain their complete output before their result is judged.
The v1.5.0 entry for the demo request page said self serve sign up was gated off.
It was not, and it never had been on either deployment.
AF_SELF_SERVE_SIGNUP comes from self_serve_signup in infra/terraform/stacks/control-plane/staging.tfvars and production.tfvars, and both have set it on since 2 September 2026. The variable declares a default of off, and that default is what was read: the change that moved the site to a demo request took the value from variables.tf rather than from either environment, thirteen days after both had overridden it. So the page is a deliberate choice about how the hosted plane is sold, and the note explained it as a consequence of a closed door.
The published wording stays exactly as it was. A correction is appended to it instead, dated, in both the changelog entry and the release notes, because rewriting what was published would leave no trace that it had ever said something else. Seven comments across six files in the site carried the same claim and those are corrected outright, since a comment has no readership to mislead retrospectively.
Nothing about the product changed and nothing user facing ever carried the claim: no rendered copy on /signin or /request-demo said self serve was off, checked against the live pages rather than the source.
A terminal workflow was judged on the part of a program's output that had arrived.
After the last keystroke the driver gave the program six hundred milliseconds to exit and then read the screen, however much of its output was still coming. A program that answers a key, falls quiet and only THEN prints was therefore judged on a fraction of what it wrote, and the report said the expectation was not met, which is a different fact from "I did not wait for the rest of it". On a loaded machine that read the transcript at line 3892 of 20000 and called a working program broken, and it did so on a tree byte for byte identical to one that had passed, which is how a race reads as a flake.
The wait now ends on a fact about the program rather than on a clock: it exited, or it has been silent long enough that a redraw cannot explain the silence. Only the budget the workflow declared bounds it. A program that reaches that budget still writing is reported BLOCKED, and the report names how many bytes it had written and how far behind the screen was, because "I could not look" and "it was not there" send a reader to different places.
Two ceilings came with it. The emulator drain is bounded, because one without a bound can never catch a program that writes faster than the emulator parses: it hung a run for over four hundred seconds, and a run that never ends reports nothing about anything. And nothing is fed to the emulator once the driver has stopped reading it, so the backlog behind a spent budget is not parsed and a disposed emulator is not written to.
a-terminal-workflow-read-the-answer-before-the-program-finished
curl -fsSL https://antifailure.dev/install.sh | sh told people there is no release when the truth was that it could not ask.
curl -fsSL https://antifailure.dev/install.sh | sh told people there is no release when the truth was that it could not ask.It resolved latest through api.github.com, which allows an unauthenticated caller sixty requests an hour for each IP address and answers 403 afterwards. curl -f turns that 403 into an empty string, the sed | head pipeline discarded the exit status, and the empty version fell into no release was found; set AF_VERSION to install a specific one. Sixty an hour is shared by everybody behind one address, so a corporate NAT, a cloud network or a shared CI runner reaches it without doing anything unusual, and this is the first command a stranger runs. Our own CI reached it: the job that installs the way a customer's workflow does invokes the installer eleven times, and the first invocation was told the product has no releases.
latest now comes from the redirect on github.com/antifailure/antifailure/releases/latest, which lands on the tag GitHub marks as the latest release. That is the website rather than the API, it needs no token and it has no budget for each address, so the case that broke cannot arise. Serving the version from antifailure.dev was considered and refused: the site deploys when a merge lands on main and a release is published when a tag is pushed, so a version file written at site build time would be stale for every release until an unrelated merge rebuilt it, and installing the wrong release quietly is worse than saying the question could not be answered.
Answers that used to arrive as one sentence now arrive as what they are. Nothing answering, an address that has asked GitHub for too much, a repository that does not exist or is private, a repository that has published no release, a redirect naming something that is not a tag, and a redirect to somewhere that is not a release at all, which is what a proxy or a sign-in portal in front of a network answers with, each say what happened and what to do about it. The rate limit names AF_VERSION because that genuinely is the way through, and AF_VERSION is documented now, which it was not. The same collapse is fixed for both downloads. A release with no build for this platform is no longer reported as could not download, a checksums.txt that a network dropped is no longer reported as one that was never published, and a version nobody published, which is what a mistyped AF_VERSION is, is no longer reported as a platform with no build: the release page is asked which of the two a 404 on an asset means, and the reader is pointed either at the builds that release does carry or at the releases that exist. Both implementations are fixed, curl and wget alike.
A machine whose only fetcher is BusyBox wget, which is what Alpine ships and what this script's own header names as a place somebody pipes it into, keeps working. That wget has no option that reports a redirect and refuses every flag GNU wget reads one with, and the lookup this replaced worked there, so the version is read instead out of the newest release's own checksums.txt, which github.com serves under releases/latest/download and which names the version in every archive it lists. That second way of asking is also what answers if the redirect ever stops being a redirect.
None of this was tested and none of it could have been. Every session in tools/installsh set AF_VERSION, so the resolution never ran, and the stub curl could only exit 22, which cannot express a status at all. The stub is gone: the tests now run the real curl and the real wget against a local server that answers real statuses, and each arm asserts both the sentence it should produce and the absence of the sentence it used to produce.
the-installer-said-there-was-no-release-when-it-could-not-ask
A long af load compare filled the terminal with stacked copies of its own status line.
af load compare filled the terminal with stacked copies of its own status line.Each round was printed while the line that counts elapsed time was still on the screen, so "round 4 of 48: sending the mix at this build" was written onto the end of "Ctrl-C to stop and roll back". The pair was wider than a 110 column terminal, it wrapped, and every round left one more fused line and a fragment such as "ld" on a row of its own. af oracle and af scenario print their progress the same way and were exposed to the same thing.
Anything printed during a run now erases the status line first, and the line is drawn again under it at the next second. The status line is also measured against the terminal as it is when it is drawn rather than as it was when the command started, so a pane resized during a run, or one narrower than 40 columns, gets a line that fits. It gives up the interrupt reminder first, then the step timer, and keeps the run timer. Each compare round now also restarts "on this step", which had been counting from the start of the whole compare.
The chaos report's unreachable figure could never be shorter than the settle, however quickly the database came back.
unreachable figure could never be shorter than the settle, however quickly the database came back.It was timed from the fault to the first query answered after the proof had waited out its settle, undone the fault and stopped its writers, and it made no attempt to reach the database before then. A database that was ready 1.66 seconds after its checkpointer was killed was reported unreachable for 3.1 seconds, and the terminal, the pull request comment and the MCP answer all repeated it.
A probe now runs beside the fault from the moment it is injected. It opens a connection and runs SELECT 1 every 100 milliseconds, and the outage runs from the first attempt that went unanswered to the first answer after it. The line prints its resolution, 110ms, probed every 100ms, and says never when every attempt was answered instead of printing a zero. Against a real Postgres it agreed with the database's own log, from the line saying the checkpointer was killed to the line saying it was ready to accept connections, to within 100 milliseconds.
The chaos guide also explains why the first crash after af up can take several seconds longer to recover than later ones: Postgres syncs its whole data directory to disk before replaying, and on the first crash that includes everything written when the branch was created.
A Docker daemon with no address ranges left was told to run af doctor and af down, and neither could free one.
af doctor and af down, and neither could free one.Every environment gets two networks and Docker hands each network one range from a fixed set, about thirty one by default, counted across everything on the machine. When they ran out, af up reported AF-RUN-040, "the environment could not be placed", and pointed at af doctor, which reported the runtime healthy, and af down, which removes only the checked out branch's environment. The daemon that failed held thirty networks, and fourteen of them were Antifailure networks nothing was attached to, left by test runs killed before their teardown ran. Nothing in the product could list them, and af env prune's only selector, age, could not reach them without also reaching the environments in use.
That refusal now has its own code, AF-RUN-052, which counts the daemon's networks and how many of them are Antifailure networks with no container attached, and names af env prune --orphaned. That new selector lists the environments holding networks with nothing attached and nothing running, and removes them with --yes. It waits an hour from an environment's newest resource, so one being brought up is never taken, and it never considers a network without the Antifailure label. af doctor's leftover environments check now counts them too.
Undoing a container pause could report success and leave the database frozen.
The undo asked the daemon whether the container was paused before thawing it, and returned success when the answer was no. The helper it asked also answers no when the inspect itself fails, so one stumble from the daemon at that moment skipped the thaw and still called the fault undone. The undo now always asks for the thaw, then reads the container back: a container still frozen, or one whose state could not be read, is reported as a failed undo. A container that was already thawed or has gone away is still not an error.
A crash recovery that replayed every byte was reported as replaying less than the client saw flushed.
The check compared the flush position a writer read, which is where the flushed log ENDS, with the position Postgres prints in "redo done at", which is where the last record it replayed STARTS. Every supported major, 14 to 18, prints the start. So whenever the last record flushed was the last record replayed, the replay looked short by exactly that record, and the durability proof raised chaos.recovery.replay_short against a run that had lost nothing. It failed main's own test with 729 commits acknowledged and 729 present.
The end of replay is now read from the checkpoint Postgres takes the moment crash recovery finishes: from its own log line on Postgres 16 and later, and from the control file on 14 and 15, where it is accepted only when it has the shape of that checkpoint. When neither is available the run says it could not tell, rather than passing or failing. A replay that really stops short of the flushed log is still reported, and the report's "replayed from X to Y" now names where replay ended rather than where its last record began.
A database freeze declared to hold for 3 seconds held for 5.
The durability proof stopped its writers before it thawed the database, and gives each writer two seconds to finish the statement it is on. Against a frozen database no statement finishes, so every freeze lasted its hold plus those two seconds. The time in place that af chaos now reports showed it, 5.003s against a declared 3s, and the daemon agreed: the container was paused for between 4.9 and 5.3 seconds.
The proof now thaws the database at its declared hold and stops the writers after, and the same freeze measures 3.001s. The durability claim is unchanged and covers more: commits the writers make after the thaw are counted, and each one the client was told was committed must still be there afterwards, like every earlier one.
The run page cut off how to reproduce a failure.
The verdicts table carried the reproduction as a seventh column and needed 860px to hold it, so on any laptop window from 640 to 1279 wide the table scrolled sideways inside its card and the Reproduction column was cut off at the card's edge. On a wider window it fitted and was a narrow box scrolling a long sentence, so the reproduction was cut off inside its own box instead. The steps also printed as a JSON array, in quotes between brackets.
The reproduction now sits on its own full width line under the verdict it belongs to, and wraps, so every step is on screen at every width from 360 to 1600. The steps print as lines, one step per line. A passing workflow, which has nothing to reproduce, no longer carries an empty line saying so.
A desktop or phone workflow could fail against an application that was still loading, and quote the loading screen as what the application showed.
An Electron ledger client with thousands of transfer.posted rows was failed in 3.7 seconds with "transfer.posted" was not found, quoting "Journal Reading the ledger". That was the client's loading screen. It fetches its journal in Electron's main process and draws a skeleton until the answer lands. The desktop driver read the accessibility tree as soon as the document parsed, the planner found nothing to press, and that first look became the final verdict. The phone loop had the same shape, and an iOS run had passed only because the app finished loading before the first read.
The browser surface waits for the network to go quiet. Nothing like that exists here: a main process fetch is invisible to the page, and a tree read through Appium has no network signal at all. So the tree itself is watched. The first read is read again until it holds still. Before any verdict that is not a pass, the screen gets up to ten seconds to change. A changed screen goes back to the planner and a still one is judged as it is. aria-busy and macOS's AXElementBusy keep a screen from counting as still.
This never turns a failure into a pass. A screen that finishes loading without the expectation still fails, and the verdict quotes the loaded screen. A screen that never stops changing is judged on its last read, and the verdict says it was still changing. A passing workflow costs one extra read of its first screen.
A network partition held for its full five seconds was reported as lasting no time at all.
An agent driving inject_declared_faults against a service declared to be cut off from its database for five seconds got back duration_ms: 0, and said the number made it doubt the cut had lasted. The cut had lasted. Measured from the daemon every 200 milliseconds, the service was off its network for between 4.9 and 5.6 seconds. The zero came from the line that records a fault's duration: it was deferred, and it wrote into a local copy after the return statement had already handed the entry back. Every fault outside a durability proof reported zero, in the MCP result and in af chaos -o json, whatever happened.
The duration is now the step's real length, and every fault also reports how long it was measured to be in place, from the moment the injection returned to the moment its undo began, beside the hold the manifest declared. The terminal and the pull request comment say it in words, "It was in place for 5.001s (declared 5s), then undone.", and the MCP result carries in_place_ms, hold_declared_ms and the same sentence as in_place. A measured zero is said as a sentence rather than printed as a number, and a killed process, which has nothing to undo, is described as the wait before the result was read.
The same run showed that after was not honoured either. The manifest describes it as the least time the run waits before a fault, and the code only used it as part of a timeout, which is a ceiling. The partition went in less than half a second into a run that declared after: 5s, and a database freeze did the same. Both now wait the declared time.
The new measurement also shows something the old number hid: a frozen database stays frozen about two seconds past its declared hold, because the durability proof gives its writers that long to finish the statement they were on before the database is thawed. The report now says so, for example "in place for 5.003s (declared 3s)", instead of repeating the manifest.
af load compare failed a build that differed from its base by one comment.
af load compare failed a build that differed from its base by one comment.Measured on one host on 2026-09-21, three comparisons of two such builds each labelled routes as moved beyond resolution, in both directions and by up to four fold, and two of the three failed. Three comparisons of a real regression never once flagged the route the change had slowed, and failed each time on a route it did not touch. A regression check that fails identical code teaches a team to stop reading it.
The resolution band added in the last release was not wrong about what it measures: how far one run's p95 could land from itself if its requests were independent draws from one steady distribution. On a real host latency comes in correlated spikes, and two environments, or two runs a minute apart, differ by far more than that. The comparison was being decided by the noise between runs, and nothing measured it.
Now each side is sent a short warm-up that is thrown away, and then sixteen short rounds, interleaved so that neither side always goes first, with round k sent under the same seed at both. Each route's change is measured round against round, and the interval around it comes from how much the rounds disagreed, so it is as wide as the host's own noise. The intervals hold at ninety percent for every route together rather than for each alone, because a check that fails when any route breaches is making one claim about all of them. The verdict then places each interval against the limit exactly as before: entirely above is a failure, entirely at or below is a pass, and a limit inside it is neither, with the smallest change this host could have resolved on that route printed beside it. On a noisy machine the honest answer is now "too close to say", where it used to be a failure. The JSON report carries every round's p95 per route, so the interval can be recomputed by hand. --rounds and --warmup set the schedule, and --rounds 1 --warmup 0s is the old single pass, whose notes now say what it cannot see.
A second defect went with it. The duration and the scale were left for each side to resolve, and the base side resolves against the base revision's manifest, so a branch that changed load.duration or load.scale compared two different workloads and called the answer a regression. Both are now settled once, from this build's manifest, and sent to both sides as values.
The pull request comment's chaos table said "Heap and index agree: yes" whenever amcheck had answered anything, including when that answer was "bt_index_check reported a problem" or that the amcheck extension was not installed.
The reviewer's page, read before merging, showed an index amcheck had found broken as a clean one. The cell now says yes only when amcheck verified the index. When it did not, the cell is bold and quotes amcheck's own reason, and when the read back never finished, it says not checked.
The table also gains the row the terminal gained in af chaos: whether torn pages in the writers' table were checked, claimed only when the control file was read after the fault, data checksums are on and the read back finished. The terminal and the comment now share one function for both answers, so they cannot drift apart again.
A workflow that failed because an expectation was not found was explained as the application showing an error, whether or not anything on screen was one.
The runner answers "not met" for two different reasons: the screen shows a failure banner, or a quoted expectation is simply absent. Both were explained with the same sentence, "The page shows an error rather than what was expected" on the web, desktop, iOS and Android, and "The screen showed a failure" or "The output showed a failure" for a terminal program. Over a healthy iOS journal and over a terminal menu drawn in full, that sent the reader looking for an error that did not exist.
The detail now names the expectation, or expectations, that were not found and quotes what was showing instead, and says an error was showing only when the runner actually recognised one, which it still quotes. Verdicts, causes and exit codes are unchanged.
af chaos now says what a crash fault established about torn pages.
af chaos now says what a crash fault established about torn pages.After the crash, the durability proof reads the writers' table back in full by sequential scan, where a page that fails its data checksum stops the read, and then asks amcheck to verify that table's primary key index against every row. Both facts were already in the JSON report, and the terminal printed neither: it printed a warning when checksums were off and nothing at all when they were on, so a check that ran and passed looked exactly like one that never ran.
Each crash fault's block now carries an amcheck line, in amcheck's own words or the reason it did not run, and a pages line. That line claims a pass only when the control file was read after the fault, data checksums are on and the read back finished, and it says the claim covers the writers' table and no other. Otherwise it says the pages were not checked and why: checksums off, the control file unread, or the read back unfinished.
The chaos.integrity.checksums_off warning also stopped firing on a control file that could not be parsed. It read the zero checksum version of an unread file as "off", beside the finding saying the file could not be read.
af logs answered every empty result with "Nothing has been written yet" and told the reader to bring the environment up with af up.
af logs answered every empty result with "Nothing has been written yet" and told the reader to bring the environment up with af up.In a project whose manifest declares one service, ledger, and a database: block, af logs database printed exactly that with the environment up and serving. Both lines were wrong. database is not a service, so the command had been asked for something that does not exist, and af up would have changed nothing.
A name the manifest does not declare is now refused with AF-RUN-050, which names it and lists the services the manifest does declare. The database block, under the names people type for it, and any declared datastore are refused with AF-RUN-051, which says it is not a service and points at the output of the services that talk to it.
An empty result now says which of its causes it is. Nothing running for the branch is the only one that offers af up. A running environment whose service has written nothing says so and offers nothing, a service the runtime reports as stopped is named with the runtime's own word for it, and a status that could not be read offers no remedy at all. The MCP tool read_service_logs makes the same distinction: an environment that is not running is reported as such, rather than as services that wrote nothing.
A workflow could say surface: ios and never be driven.
surface: ios and never be driven.The manifest accepted the surface and the engine chose the iOS driver, but nothing in the manifest could say which application to drive, and the runner needs the application's identifier to drive anything. So every iOS run was refused by the runner, after its environment had been built.
A manifest now declares the application in a mobile block: its id, the bundle identifier on iOS, and optionally the built app to install and the device to use. A workflow that drives a phone with no mobile block is refused when the manifest is read, rather than after an environment has been paid for, and so is a mobile block that no workflow drives. af explain shows the application above the workflows it is driven in.
A desktop application under af test was never told where the environment it was being rehearsed against was.
af test was never told where the environment it was being rehearsed against was.Terminal workflows are started with AF_BASE_URL set to the environment's address, so a command line client talks to the rehearsal copy and not to whatever the developer's shell points at. Electron applications got nothing, so a desktop client of a service could only reach its own configured address, which is either nothing or production.
Electron applications are now launched with AF_BASE_URL set to the environment's address, merged over the runner's own environment and written last, so it wins over a stale value exported in the shell. Native macOS applications are unchanged: Launch Services starts them with the session's environment, so a variable set by the runner would be dropped rather than received.
a-desktop-application-was-never-told-where-the-environment-was
af chaos told the reader that a durability proof meant nothing, on the same screen that showed it.
af chaos told the reader that a durability proof meant nothing, on the same screen that showed it.A disk fill the injector refused as unsafe, because the directory shared the daemon's own filesystem, was reported as chaos.fault.refused with the sentence "Nothing measured after it means anything." The crash proof that ran after it, against an environment the refusal never touched, was the best evidence in the run.
A refusal as unsafe is decided before the fault acts, so it now has its own finding, chaos.fault.unsafe. It carries the refusal, says what the fault was declared to establish was not established, and says it changed nothing the other faults measured. It is still a warning, because a declared claim that was not established is not nothing to see. A fault that tried to go in and failed keeps chaos.fault.refused and its sentence unchanged.
The same branch of code hid a worse case. A fault that went in and whose undo failed was reported as "was not applied", and the finding that says the environment is still broken never fired. It fires now, with the reason the undo gave. The MCP tool's summary and its faults_refused count had both defects and are corrected with it.
A read only fault on a directory owned by root now reports AF-CHS-004, applied and changed nothing, rather than AF-CHS-005, which means refused before acting. It changed the mode and put it back, so it acted.
AF-MSK-010 refuses masking on a table stored with an access method that implements neither the ctid a keyless rewrite addresses a row by nor the UPDATE a keyed one runs.
AF-MSK-010 refuses masking on a table stored with an access method that implements neither the ctid a keyless rewrite addresses a row by nor the UPDATE a keyed one runs.The refusal is right and is unchanged. The sentence after it was not: it said to run af mask plan, and from the state the refusal leaves you in there is no branch, so that command answers AF-DB-014 No database branch exists and exits 5. On the other path it was worse, since af mask plan is itself one of the three places that raise AF-MSK-010, so the remedy there was the command that had just run.
The Next line now carries the remedy the message body already carried, which is the one that works from here: give the column a rule that preserves it, or change the table so masking can address a row in it. It still names af mask plan, and now says what that command needs before it can answer.
inject_declared_faults added the database outages of separate faults together and reported the total as one number.
inject_declared_faults added the database outages of separate faults together and reported the total as one number.The faults run one at a time and each is undone before the next begins, so their outages are separate events. Adding them produces a figure that is arithmetically true and describes an outage that never happened: 4000 reads as one four second gap when it was two gaps of two seconds, and those are different facts about a system. It is the mirror of a zero in a field nobody measured, which the same tool is careful to avoid when a durability proof did not run.
It reports the longest single outage now, named for that, with the per fault numbers in the detail. A caller that wants a total can add them; a caller handed a total cannot recover the parts. The commit counts beside it are still summed, because a commit lost under either fault is a commit lost.
Found in review rather than by a test, and extending it turned up three more of the same shape: the multi fault fixture gave values to only one fault, so summing and taking the last value were indistinguishable for phantom rows, in flight commits and the summary's own aggregate.
af load compare returned a confident verdict on a difference it could not see.
af load compare returned a confident verdict on a difference it could not see.An identical build was compared against itself, two commits whose only difference is a comment in one Go file, so every number either run produced was noise. Two samples, minutes apart, on a loaded host: the first called every route better, headlined a p95 57.4 percent lower, and passed; the second called every route worse, headlined 192.3 percent higher, and FAILED six routes against a 60 percent limit. A real regression measured on the same machine headlined at 253.7 percent, which is smaller than the 585.9 percent a comment produced.
Both readings were reported as decisions. A reader of the second opens a pull request about a regression that does not exist; a reader of the first ships a real one believing it an improvement.
The cause was countable and already in the document. Those runs sent 145 requests across seven routes, about twenty per route, and a p95 taken from twenty samples by nearest rank IS the nineteenth of twenty, so it sits one slow request from the maximum. Nothing asked whether twenty samples could support the question being put to them.
Every comparison now measures its own resolution first. A percentile estimated from n samples is an order statistic whose rank is itself random, so the run's own sample count and its own recorded distribution give the distance that number could have landed from itself with nothing changing. Each route prints what it can see beside what it saw, and the verdict is decided by where the declared limit falls relative to that interval: entirely above it is a breach, entirely at or below it holds, and a limit inside the interval is one this run cannot place, which is reported as unverified and never as a pass.
It does not widen anybody's threshold. A limit is a declared tolerance for a real change, and loosening it to silence a false alarm would hide real regressions. A run that CAN see the difference still decides: a genuine regression of 600 percent against a 100 percent limit, on a run whose resolution is 200, is still a failure, because the interval around it sits entirely above the limit.
A direction is withheld on the same evidence. The identical build reported routes better by 86 percent and worse by 586 percent on consecutive samples, and the arrow was as wrong as the number, so a difference smaller than the distance it could have moved now reads "too close to say" rather than better or worse.
The band covers the sampling error a single run can see and does not cover drift between two runs on a busy host, which is larger: the two samples above disagree by more than the band around either. So it is a floor on the uncertainty rather than the whole of it, and the documentation says so.
Two holes in the golden lifecycle, and both of them were on the path most runs actually take.
database.extensions was enforced when a golden was built and never when one was branched. A project builds its first golden once and branches it for the rest of its life, and the golden's identity does not depend on the extension list, so the ordinary sequence was: the golden exists, a branch adds postgis to the manifest beside the migration that needs it, af up selects the golden it already had, and the environment came up green with the extension simply absent. SELECT count(*) FROM pg_extension WHERE extname = 'postgis' answered 0 and nothing anywhere said so, while the same manifest on a project with no golden yet was correctly refused with AF-DB-040. A refusal that fires on the run nobody takes twice, and never on the one everybody takes, is close to no refusal. Every branch now creates what the manifest declares, so an extension the golden predates is created on the branch and an extension the image cannot carry is refused by name, with the image the manifest asked for named beside it, on both paths.
af golden refresh never ran database.seed. A project whose only data comes from that command got a golden holding no tables, a report of "0 rows across 0 tables masked", the words "Verified 0 columns across 0 tables", exit 0, and an invitation to bring an environment up from it. That golden's provenance is the project's own, so the next af up selected it and branched an empty database. The seed command was validated, refused alongside database.source_url_env, printed by af explain, and executed by nothing. Both paths that publish a golden now run it, through the same call.
AF-DB-041 is the second guard, and it is deliberately not the same fix twice. Running the seed closes the one route that produced this. A seed that exits 0 having written nothing, or a source database that turns out to be empty, reach the same published, signed, empty golden by other routes, and the word the product asks people to rely on is verified. A golden that holds no tables at all is now refused rather than attested, when the manifest declares where its contents come from. A project that declares neither database.source_url_env nor database.seed is untouched: an empty golden is what it asked for and what it is documented to get.
af watch carried a picture of every agent and drew none of them.
af watch carried a picture of every agent and drew none of them.The live channel has streamed a base64 JPEG per agent since it was built, once a second, straight from the browser to the terminal. The terminal view threw all of it away. Its own comment said why, and the sentence it printed said it to the reader too: "The image streams to the browser watch view; here is its cast." Underneath that it showed one agent at a time behind a switcher rail, so the other agents in the run were a list of names and a state word. A run of six agents with six different personalities looked, in the terminal, like one agent with five things queued behind it.
The view is now the swarm. Every agent is on screen at once, in a grid that reflows from four columns to one and pages rather than squashing when there are more agents than rows. Each pane is headed by the PERSONALITY driving that agent, because that is the thing that tells four panes running one workflow apart, with the workflow, the account and the surface under it and the behavioural profile in a few words under that. The personality reaches the pane because it now travels on the wire as its own field; it used to be parenthesised into the workflow name, which a layout cannot lay out. Below that is the agent's most recent frame, drawn as a real picture.
The picture needs a terminal that draws inline images, and the terminal is asked rather than guessed at. TERM is set by a shell, survives ssh onto a machine with a different terminal on the other end, and is rewritten by every multiplexer, so it is not evidence. One round trip at startup asks kitty its own graphics question, asks for the cell size in pixels, and asks for the device attributes, and the answers decide between the iTerm2 protocol, the kitty protocol, sixel and nothing. A terminal that draws nothing gets the same panes with the frame's own number and size in place of the picture, which is the evidence that the run is producing pictures and only the display is not showing them.
Three reasons for an empty region are three different facts and each has its own sentence: this terminal draws no pictures, this pane is too short for one, and pictures were switched off. The footer says which one, always, including when there IS a picture. A terminal that speaks sixel and will not report its cell size is treated as one that draws nothing, because a sixel is sized in pixels and a guessed size scrolls every pane under it.
Nothing pulses. A live agent says LIVE in a static neon word and the picture under it changes once a second, which is the run's own motion rather than decoration.
One bug found on the way and fixed here: the capability query could never have worked on macOS. os.File.SetReadDeadline refuses a terminal there with "file type does not support deadline", so the read failed before a byte arrived and every macOS terminal, including the ones that draw pictures, came back undetectable. It was caught because a capability carries the reason it reached its verdict, and the reason said so out loud.
The colours are measured now, and there are two sets of them. An ANSI colour is absolute and a terminal's background is not, so the same round trip asks the terminal what colour it is painted and the view takes the palette that holds against it. That question was worth asking: against the warm paper the launch film's terminal uses, the neon the live mark is drawn in reads 1.78 to 1, which is a word nobody can see, and against a dark terminal the same colour is 9.18 to 1, which is why it went unnoticed. No colour in the 256 colour cube clears the threshold on both.
Measuring that also found two colours failing on the DARK terminal they were already chosen for: the pass green at 3.97 to 1 and the fail red at 3.33. Both are now brighter approximations of the same console tokens, and a test computes the ratio for every colour against its own background rather than trusting the comment beside it. A terminal that will not say what colour it is keeps the dark palette, and nothing is lost either way: every signal these colours carry is also carried by a word and by the weight of a rule.
A pane with no picture in it gives that room to the agent's steps rather than leaving it blank. A terminal surface produces no frames at all, so its steps are its whole content, and a pane that kept the space empty showed seventeen blank lines and one sentence for the surface that needed the space most.
One more defect, found by running the finished thing against a real environment rather than by reading it. af watch had TWO writers on one terminal: building the orchestrator makes a status line that rewrites itself to the same stream once a second, and the dashboard then takes the alternate screen on that writer. Six "elapsed, on this step" lines were painted straight over the panes in a six second run. The lifecycle already had the seam for this, carrying a comment naming this exact case, and af up --live has passed it since the dashboard was built; af watch never did. It does now, when there is a screen to protect and not when the output is a pipe. The verdict still prints, because it goes out after the program has given the terminal back.
The capability query is split by platform because the type system requires it: the syscalls it uses take a different type on Windows, so one file cannot serve both and the package does not compile there if it tries. Windows takes the deadline path, which is shared rather than written twice, so the arm only it runs is exercised by every test run on every machine.
A terminal workflow could fail about output the program certainly wrote, and it failed more often the busier the machine was.
The terminal driver feeds the program's bytes to an emulator that parses them asynchronously, so the writes are chained and the chain is awaited before the grid is read. Awaiting it once is not enough: the chain GROWS while it is awaited, because the program keeps writing and every chunk appends another link. The driver stopped waiting the moment it saw the program exit, so a program that printed a lot and exited at once was judged with its final redraw still queued. The expectation then went unmet about lines that were on their way to the screen, and the verdict depended on how much CPU the parser happened to get.
The driver now waits until the chain stops changing, which is the fixed point that makes a snapshot mean what the code around it already claimed: everything received so far has been drawn. It waits again before the transcript is read, because waiting for the exit waits for the process and not for the emulator.
Forty lines never caught this and ten thousand catch it every time, so the regression test prints ten thousand.
A workflow whose expectation could never match named it where nobody reads.
An expectation that carries no word the matcher can look for can neither pass nor fail, and the run says so. It said so at character 295 of a 489 character sentence, and the report's table cell stops at 120, so the half that named WHICH expectation was at fault reached nobody reading the row. It survived only on the Got: line inside the folded details block, which is where somebody looks once they already suspect something.
The sentence that did lead the cell was worse than incomplete. It said nothing on this page moves the workflow forward, about a page that may be showing exactly what was asked for, so it pointed the reader at their application when the fault was in their manifest. A diagnostic that is merely truncated costs a click. One that points the wrong way costs an afternoon.
The cell now opens with the quoted expectation, then what is wrong with it, then the two ways to write it so it can be checked. The name leads the sentence rather than closing it, so a long expectation is named in full rather than cut off halfway through. Everything that was said before is still said, in the same details block, for anyone who opens it.
Only an expectation that could never be met earns the front of the cell. One that simply was not met is a different fact and reads exactly as it did.
The Android driver's own module declared itself available to drive while the registry the engine reads declared it unavailable, and no run has ever driven an Android application.
Nothing read the module, so the claim was harmless and invisible. A test now compares every driver module against the registry, so the two cannot disagree.
A SQL workload counted its own deadline as failed statements, so a run with nothing wrong printed errors against a query that had never failed.
A transaction interrupted by the run ending has never been counted as a failed transaction, because the caller stopping is not the database failing. The statements inside it were counted anyway. A ten second run against a real database committed 5416 transactions, reported nothing failed and nothing retried, and still listed five errors beside a read that had just run 3640 times and returned a row every time: the five clients that happened to be inside a statement when the duration expired. The summary line above the table said nothing was wrong, so the table was the only thing a reader had, and it was the part that was wrong.
A statement stopped by the run ending is no longer counted as a failure, which is the decision the transaction around it already made. A statement that the database really refused is counted exactly as before.
a-healthy-sql-run-reported-errors-against-a-query-that-never-failed
An expectation containing an underscore could never be met.
A workflow expectation that is not quoted is judged by how many of its meaningful words appear on the page. Pulling those words out stripped every character that was not a letter or a digit, from anywhere in the word, so total_cents was looked for on the page as totalcents. The page is searched for the word as written, and no page shows totalcents, so the word scored zero for the life of the manifest. With nothing hit and no failure banner to read, the answer was unclear, which is reported as UNVERIFIED and exits zero: the expectation could not pass and could not fail, whatever the application did, and nothing anywhere said so. order_id, user-name, v1.2.3 and application/json were all unmatchable the same way, and a sentence carrying one of them could clear the two thirds bar on its other words alone, so an expectation could also PASS on a page that did not contain the identifier it named.
This repository was itself an instance. The dogfood workflow a-visitor-finds-the-operator-door expects "Operator sign-in" against the operator portal, whose title is Operator sign-in, and sign-in became signin, so half its expectation could never hit.
A word is now narrowed rather than rewritten: the punctuation around it is trimmed, which is what the stripping was for, and the characters inside it are kept. Welcome back! still matches a page saying Welcome back, and total_cents now matches the page that shows it. The quoted form, which is what anyone who hit this will have used as a workaround, is untouched and still requires its string character for character.
An expectation left with no word to look for at all, because every word in it is a connective or shorter than three letters, is now named in the report. Silent unmatchability was the defect; the wrong verdict was only its symptom, and the advice that used to be printed instead sent the reader to look at a page that may have been showing exactly what was asked for.
A terminal workflow that failed reported a verdict with nothing under it.
The report skips its "how to see this yourself" block whenever a workflow's steps are empty, and the terminal driver handed it an empty list: the shared classifier cannot write one, because what a person would have to DO to see a result again is a fact about the surface rather than about the verdict, and only the browser driver was filling its own in afterwards. So a red check named a terminal workflow, gave a one line reason, and offered no way whatsoever to reach it.
It now carries the same thing a failed browser workflow carries: the invocation, the size of the terminal it was given, one line per key that was pressed, what was expected, and what happened instead.
The rendered screens stay out of it on purpose. That block is markdown, and markdown collapses the runs of spaces that hold a screen's columns together, so a grid of cells would arrive as something nobody could read. The screens are in the run's own steps, which is what af watch prints live and what the JSON report keeps whole.
Every indexable page on antifailure.dev advertises a machine-readable Markdown twin in its head, a rel="alternate" type="text/markdown" link an assistant follows to read the source instead of parsing the rendered page.
rel="alternate" type="text/markdown" link an assistant follows to read the source instead of parsing the rendered page.Every page except the one most likely to be discovered first. The home page exported no page metadata at all, so it inherited only the site-wide canonical and, alone among the indexable pages, pointed at no twin. The build wrote /index.md and the host answered 200 for it, and an agent reading the home page's metadata to find it never learned it was there.
The home page routes through the same registry every other page uses now, so it carries the twin link like the rest. A second fault sat behind the first: the metadata builder produced the root's twin address as the origin with .md stuck on the end, https://antifailure.dev.md, a host that does not resolve, because the root is the one page whose url is the bare origin rather than a path. The root twin is https://antifailure.dev/index.md now, and every other page's is unchanged.
The SEO check could not have caught either: it walked only the twins a page already advertised, so a page advertising none was invisible to it. It now asserts that every indexable page's head advertises its own twin at the address the build actually wrote, which fails on the home page as it stood and passes once the link is there.
The runner truncated a large result document, so a healthy run read as blocked.
The runner writes one JSON document to standard output and the process exits the instant it is written. Writing to a pipe buffers, the pipe buffer is 64 KiB, and process.exit discards whatever has not drained, so a document larger than that reached the engine cut off in the middle of its JSON. The engine read a document that would not parse, reported "the runner did not return a result document", and called the run blocked. A small run flushed inside the buffer and looked fine, so the defect hid until a run captured enough evidence, a page's rendered text and its response bodies, to cross the buffer. The site smoke, which drives a content heavy page, was the first to cross it and turned main red.
The runner now waits for the document to reach the operating system before it exits, so a result of any size arrives whole. The wait is one function every result document goes through, and a test drives a real process across a real pipe to prove a payload past the buffer is delivered without loss, with a naive control that truncates so the test cannot pass by writing too little.
the-runner-truncated-a-large-result-and-a-healthy-run-read-as-blocked
The request-a-demo page scrolled where the login page it copied held still.
Both pages wear the same split shell, a cover on the left and a column on the right, but the demo column spent its height like a page with two fields when it carries eight. A tall intro, a card padded at p-7 with a gap-y-5 grid, a one-line marketing consent stretched to two, and a two-paragraph footer stacked past the fold, so on the laptop heights people here actually use, 800 through 982 tall, the submit button and the sign-in link sat below the fold and the page scrolled. The login page next to it sits inside the viewport, and the ask was that this one match it.
It now does, without dropping a field the sales team asks for. The intro is tighter, the two-column grid is denser inside a compact card, the consent and the privacy line are terse, and the footer is a single line that keeps the sign-in-with-GitHub link and the quickstart note. Measured over CDP with device emulation at 1280x800, 1440x900 and 1512x982, the document height equals the viewport height at each, so there is no page scroll; the honeycomb cover, the restraint and the field set are unchanged. On a phone, where eight fields cannot share the room two do, it stays a clean single column with no horizontal overflow, the same as the login page does.
Four safety claims said more than the design does, and each is now shorter.
The firewall page said request and response redaction is mandatory. Nothing redacts a request or a response, and the mode that forwards to a live endpoint does not intercept, so it cannot. The transforms reference said the masking key differs across goldens so a mapping could not be reversed; one key is generated and kept, on purpose, because that is what makes two goldens comparable. Uniqueness was claimed for format-preserving replacement, where seven of the twenty six transforms preserve it and the format-preserving pair can collide. One page said tokens and sessions are deleted, where a token becomes a keyed hash and only a live session is dropped.
Four things on the quickstart path said something the software does not do.
af net explain was printed with no method, so the documented line answered with a usage error. The af start sample was missing the masking rules row. The page said only a real failure exits non zero, where a run that never reached a verdict exits on the configuration problem that stopped it. concepts/load said a blocked scenario still exits non-zero, where af ci exits non-zero only on a failure.
af explain printed masking masking.yaml for a project with no such file. The path is a default and the engine reads its built in rules when the file is not there.
The three getting-started pages are 75 lines shorter, with every command, flag, path, version minimum and error code untouched.
Seven figures in the documentation had drifted from the code.
The manifest size limit read 256 KiB against MaxSize = 1 << 20. The stability page said the Helm chart is 1.0.0, where Chart.yaml has moved past it. The production page counted twelve alert rules against ten. The oracle page said "nine others" against fourteen ignored headers. The goldens page said an unset max_age means nothing is refreshed, where it becomes 168h. The route table listed both webhook routes twice, and its own test compares a set of paths, so it could not see the duplicate. The operations page said every alert rule links to a runbook section, where six of ten do. AF-RUN-010 was documented as saying 2.0 GiB is required, where the code fills that slot with the words "the state directory".
Five things the product said about itself that the code does not do.
The list of Postgres failures the insights check finds named connection pool exhaustion. There is no such rule: the catalogue holds seventeen and none of them concerns pools. The other four in that list are real and stay. The diagram beside it carried a pool row for the same reason and no longer does.
The pull request gate was described as carrying the video. The runner does record one, and it never reaches the comment: the report carries the trace.
The egress mode plate showed api.stripe.com in mock. The mode a detected Stripe SDK is actually given is sandbox, which opens a real connection to Stripe's own sandbox with a substituted credential, and mock is the default for nothing. On a page about money that is the wrong direction to be wrong in, so the plate now shows sandbox.
The migration lint's no_lock_timeout rule counted a lock_timeout that was not in effect when the lock was taken.
no_lock_timeout rule counted a lock_timeout that was not in effect when the lock was taken.It asked only whether a SET lock_timeout appeared anywhere in the migrations. So a SET after the ALTER, a RESET or a SET to 0 before it, a SET LOCAL whose transaction had already committed, a ROLLBACK that undid the SET, and an ALTER ROLE or ALTER DATABASE with SET lock_timeout, which reaches only sessions that start later, all silenced the rule while the ALTER they were meant to bound would have queued with no limit.
The rule now follows the migrations in the order one session runs them, one transaction per file, and judges each lock against the timeout in effect at that moment. SELECT set_config('lock_timeout', value, is_local) is recognised as SET, or as SET LOCAL when is_local is true, when both are written out. A value the file does not spell out never counts, and the finding says the timeout could not be read statically. A test runs each of these orderings on a real Postgres session and requires the rule to name exactly the lock that Postgres ran with no timeout.
The audit log's filter matched an action only when you typed the whole dotted name exactly, so typing billing answered "No entries with that action" while billing entries sat in the log.
billing answered "No entries with that action" while billing entries sat in the log.It now matches any action containing what you typed, and a typed % or _ stays a literal character rather than becoming a wildcard.
The same box sent one request per keystroke, so a seven letter word was seven requests and six discarded answers. It now waits for the typing to stop.
The filtered empty state told you to clear the filter and gave you nothing to clear it with. It now carries the same "Clear the filter" control the operator console already offers for the identical case.
Two gates in the engine job reported ok having checked nothing, for two different reasons, and one of them was written specifically for the case in which it could not run.
The leak check is the sharper of the pair. Its own comment says it exists because "a suite crashed before its own assertion ran", and it carried no if: key at all. A step with no condition takes the default success(), so it is skipped the moment an earlier step in the job fails, and a crashed suite is a failed step. The one situation it was written for was the one situation in which it never ran. On a green job it duplicated an assertion the suites had already made, and on a red one it printed nothing, which is a check that can only ever agree with whatever already happened. It now runs on !cancelled() rather than always(), deliberately: a cancelled run has killed the suites part way through, so their cleanup never ran and containers are up by definition, and a gate that reds every concurrency kill and every job timeout is a gate people learn to read past. The two leak checks in the dogfood workflow already used this idiom, so the engine job's was the only one of the three that was silent.
The three golden store round trip suites were the second. They skip when no server answers, which is right on a laptop, and the engine job set AF_REQUIRE_DOCKER, AF_REQUIRE_DATABASE and AF_REQUIRE_RUNNER with no equivalent for object storage. So all three skipped on every pull request, go test printed nothing for a skip as it always does, and the package reported ok. What was going unexamined is the part no fixture can check and this repository writes by hand: Signature Version 4 signing for S3, the account shared access signature for Azure Blob, and the escaped object name, alt=media read, separate upload path root and paging shape for Cloud Storage. TestS3Store's own comment already said a fixture cannot tell a correct signature from a plausible one.
AF_REQUIRE_OBJECT_STORE turns that skip into a failure, and the stores are PROVIDED rather than merely required, because setting the variable with nothing to point it at converts a vacuous pass into a permanent red wall, which is a worse instrument and not a better one. MinIO, Azurite and fake-gcs-server now start in the job and in a just stores recipe at the same three digests, so a developer's run and the runner's run examine the same servers. One variable covers all three suites, named for the resource rather than for a vendor, because three variables is three chances to wire two and forget the third. The three images are 62.2 MB, 113.8 MB and 25.4 MB compressed for the runner's architecture, read from each registry's manifest rather than estimated.
MinIO comes from quay.io and not from Docker Hub. An anonymous pull token for docker.io/minio/minio comes back carrying an empty access list, so the command the old skip message told people to run needs a login that neither a fresh clone nor a runner has.
Pinning those three exposed a third gate that could not say no. TestEveryPinnedImageAgreesOnOneDigest holds every site naming one image to one digest, and its pattern was lowercase across the name, the colon and the tag together. A registry name is lowercase by specification and a tag is not, and MinIO publishes no lowercase tag at all: its releases are named RELEASE.2025-09-07T16-13-09Z. So a MinIO reference matched nothing, the image never entered the map, and a tree naming two different digests for it in the justfile and the workflow would have passed. The tag half now takes uppercase, the grouping moved into a function so the falsification arm drives the gate itself rather than a copy of its pattern, and the new arm checks both directions: two sites at two digests is reported, two sites at one digest is not, and the lowercase case that already worked still does.
The self hosting pages pinned a control plane image that could not create the first organization.
main-b53906a predated create-org, break-glass and the tokens.manage scope, so a self-hoster who followed the four step bring-up got a running server with no organization and no way to make one, which is the exact broken state the page warns about. The pages now pin main-fa6c8aa, whose image carries those commands. The stale "do not run :latest or :v0.1.1, they are the same image" note is gone, because :latest is now a newer image than that note described. The Kubernetes section gains the create-org step the bootstrap Job does not run.
self-hosting-pinned-a-control-plane-image-without-create-org
af init wrote personas that sign in with a password whatever the repository was, so the documented first run could not finish on a service that serves JSON.
af init wrote personas that sign in with a password whatever the repository was, so the documented first run could not finish on a service that serves JSON.A JSON API owns no users table, af up refused with AF-DB-022, "the personas could not be created, so signing in will not work", and af test exited 3 with no verdict. Had it got past that, the agent would have waited for a login form on a service that answers JSON until the workflow's budget was gone, which is what this repository's own examples/go-api says in the comment above the login: none it sets by hand.
Detection now decides how a persona signs in from what the repository shows. A password persona needs somewhere to create the account and a form to type the password into, so the draft keeps login: password when the repository depends on an authentication provider that owns its users, declares a users table in its own SQL or Prisma schema, or renders any markup. Only a repository with none of those gets login: none, where a password persona cannot work at all and a persona that never signs in lets the workflow run and be judged. af init says which it chose, and why, under Assumed.
The data export and account deletion page has never rendered.
It read account.exits, the control plane registers account.context, and nothing in this repository could see the difference: a console path is an ordinary string, so a name with no procedure behind it is a 404 at run time rather than a compile error. Every visit to the one screen a lapsed customer is sent to drew an error card instead, and that screen is also the nav entry, the primary button on the lapsed plan and the wordmark target for anybody who cannot bill.
Behind it, a second defect that fixing the first would have exposed: the page typed sessions as always present and read its count in exactly the branch the server leaves null, which is every member and every viewer.
tools/routecheck now reads console call sites as well as the marketing site, so a console page naming a procedure the control plane does not register is refused before it merges.
The runs list called a run that verified nothing passing.
A verdict is a judgement only when it is conclusive. Pass, fail, flaky and warn are; blocked and unverified are not, because the work either never reached the application or finished with nothing to evaluate. The list coloured a row green and wrote "N passing" whenever no verdict was failing, and a run whose verdicts are all blocked or unverified has nothing failing. So a run that proved nothing drew as a pass, in green, on the surface a customer scans first, while the detail view for the same run already said nothing was verified. The two disagreed about the same run.
The list now reads the counts it needs to be exactly as honest as the detail: the passes, the fails alone rather than fails and blocks together, and the number of conclusive verdicts. A run that proved nothing reads "nothing verified" in amber, a run that passed some but not all of what it proved reads "N of M passed", and only a run that is genuinely all passes reads green. The same correction reaches the operator surfaces, where a complete run that proved nothing was standing as passed and a run of unverified verdicts counted every one of them as passed.
The runs page could not tell a run that proved nothing from a clean run.
Both unverified and flaky are real verdicts the runner returns, and neither was in any of the colour function's three lists, so both were drawn in the grey this console uses for a value nobody needs to react to. A run whose personas all failed to provision therefore filled the verdict table with rows, showed no failures anywhere, and read as a pass.
Those two verdicts are now amber, a run whose verdicts judged nothing carries a banner saying so with the counts beside it, and the empty state no longer tells a finished run it is probably still going.
The same page also never updated once it was open: the only refresh in the file hung off the Start card, which a member cannot see, so a run could not be watched to completion in the console this product is demonstrated in. The list and the detail now refresh themselves while a run is queued or running, and stop the moment it finishes.
And the reproduction recorded for a failing verdict, which the control plane selects and sends and the operator console has printed all along, is now on the page the customer is shown rather than fetched and discarded.
Directory provisioning now refuses an addition past the licence's seat count, so a SCIM sync cannot add members without bound on a licence sold with a seat number.
AF-EE-004 has said "the license covers N seats and they are all in use" since single sign-on took a seat limit, and ee/web/sso refuses the addition rather than making room by removing somebody. Directory provisioning took no seat limit at all. The word "seat" appeared nowhere in ee/web/scim, and the enterprise entry point constructed scimExtension with no such option while handing ssoExtension the licence's own number. So the two provisioning paths disagreed: one enforced the count and the other ignored it, which makes the limit a limit with a documented way around it.
scimExtension now takes the same seat function, supplied from the same licence claims, and asks it per request rather than capturing a number at startup, so a licence that grows or lapses takes effect on the next provisioning call.
The refusal is on the addition, always, and it sits where a directory actually adds a person: creating an active resource, and reactivating a deprovisioned one, which is an addition too and would otherwise be one PATCH away from evading the count. An inactive resource takes no seat, because it is a profile the directory owns rather than somebody who can sign in. No seat count means unlimited, which is what an unmetered licence is and what the engine already reads zero seats as. The refusal is a 403 rather than a conflict: a provider retries a conflict and reconciles it, and there is nothing to reconcile until somebody buys a seat or removes a member.
Organisation policy now evaluates the manifest's egress default, so a repository cannot evade the deny list, the permitted modes or the synth approval requirement by writing no egress rules at all.
egress: {default: synth} with an empty rule list is a valid manifest. The community validator refuses an allow, an emulate and a sandbox default and accepts that one, the policy engine gives every host no rule names the default mode, and the sidecar's synth path then asks a model provider to invent a response for each of them. EgressHosts and EgressModes, which the engine builds from the explicit rules, are both empty for such a manifest, and policyenforce read only those. So denied_hosts, allowed_modes and synth_requires_approval all found nothing to refuse, and three controls an organisation had set were evaded by one line naming no host.
The default is now read as the mode every unnamed host is given. A default that reaches the host it was asked about, meaning allow, sandbox or synth, cannot honour a deny list, because the list is written as patterns precisely so that it covers hosts nobody has thought of yet. A default the organisation does not permit is refused the way a rule using that mode already was. A synth default needs the same approval a synth rule needs.
capture, mock and emulate answer without contacting the host, so a default of one of those breaches no deny list, and block is the engine's own floor rather than an author's choice, so an organisation naming only capture does not have to name it. Both are asserted, because the refusals this adds would otherwise be free to grow into refusing most of the product's own examples.
The core manifest default is unchanged: empty still means block, and the validator's refusals are untouched. engine/pkg/extension documented this blind spot in the past tense while only ee/engine/airgapped had been fixed, and that comment now says which readers evaluate the field and what each does with it.
Masking a table whose primary key has more than one column gave its rows each other's masked values.
The masking run addressed a row by the first column of its primary key alone, compared as text. For a key such as (tenant_id, id), the update that wrote one row's masked values matched every row of that tenant, and the next chunk resumed after the tenant, so its other rows were never masked on their own. On a table of three tenants with seven contacts each, 18 of the 21 rows ended up holding another row's masked email. No row kept its original value, and golden verification reported nothing, because every value it sampled was a valid fake address. Every join on those columns found the wrong person, and a masked column with a unique constraint would have stopped the run halfway through the table.
Rows are now read, paged and rewritten by the whole primary key, compared as the table stores it, so the key's index serves every statement. On 200,000 rows a chunk read went from 78.5 milliseconds to 3.7, and a row update from 13 milliseconds to 2.7. Before the change, the first 20,000 row chunk of that table had not committed after 45 minutes. A table with no primary key is now updated through a TID scan rather than a scan of the whole table for each row. ClickHouse masking was not affected, because it rebuilds a table through a shadow copy rather than rewriting rows one at a time.
A column marked preserve was written back over itself. The masking statement set every preserved column to the value it already held, the primary key included, so every row got a new version and every index on those columns a new entry, for no change. A table whose every column was preserved was rewritten in full. Preserved columns are now recorded as reviewed and found safe, shown that way in af mask plan and its JSON, and never written, and a table with nothing else gets no statement at all. Verification still counts them as covered by a rule. The same reading refused two plans outright, a preserve rule on a generated column and a preserve rule on a ClickHouse table with no sorting key, and neither is refused now.
A policy that requires a column to be masked, such as the enterprise required_masked_columns policy, now refuses a plan whose only rule for that column is preserve. A preserved column holds exactly what production held, and it was listed among the masked columns the policy was given, so such a plan used to be approved. If a golden refresh that passed before is now refused naming a column, give that column a masking transform, or ask an administrator to narrow the policy so it no longer requires it.
Pressing Control C during af golden refresh printed AF-MSK-010 Masking could not run: masking: writing public.customers: unexpected EOF and exited as a configuration error. An interrupted run now reports AF-MSK-016 and exits with status 9. It names the table it was rewriting and how many rows were written and committed, and says the chunk in flight was rolled back. Running a refresh again is safe, because it starts from a fresh copy of the source. af mask apply says its branch is now partly masked and has to be recreated with af down and then af up before masking again, because masking a value that is already masked changes it. The local state directory's README no longer says an interrupted masking run resumes.
Creating a golden or an environment on Xata waits for the new branch to become ready, and that wait can last five minutes.
For all of it the engine printed nothing, so a slow branch and a hung one looked the same, and the only way to tell them apart was to wait and see which it was.
The wait now says what it is doing while it happens, published as engine.progress: a line when a branch is first found not ready, naming the branch and the status Xata reports for it, another every thirty seconds saying how long it has been waiting and what the status is now, and a line when the branch is ready saying how long that took. A branch that is ready on the first check prints nothing, so the common case stays as quiet as it was.
An emulator that had been started was called ready, and the application got a 502 from it.
af up started the emulator containers, then the sidecar, then the application. The daemon reports a container started as soon as its first process runs, and the server inside binds its port later: the Google emulators take between 17.7 and 51.5 seconds to accept their first connection, measured at first bind in guides/gcp.md, and LocalStack spends its own seconds loading providers. So the application started while the port was closed, its first call reached the sidecar, the sidecar forwarded it to an address nothing was listening on, and the application read 502 Bad Gateway from its own SDK.
Nothing about that pointed at the cause. The engine had already printed emulator ready. The 502 is the same status the sidecar returns for an emulator the environment is not running at all, so the one message anybody had said the opposite of what was wrong. And it only happened when the emulator was slow: ten consecutive runs of the end to end test passed on a warm laptop while the same test failed in CI.
af up now dials each emulator from inside the environment until it accepts a connection, before any service is created. The dial has to come from in there, because an emulator joins the inner network and nothing else, so it publishes no port and the host has no route to it; the sidecar is the one container on that network the engine can reach, and the probe is the sidecar's own binary run inside it. Each emulator has three minutes, AF_EMULATOR_READY_TIMEOUT moves that, and one that never binds stops the run with AF-RUN-049 naming the emulator, its address and its image. That environment is torn down rather than left standing, because every call it would answer is the same misleading 502. The progress line now says started where it said ready, and says ready once the emulator has answered.
af logs af-proxy returns the sidecar's log. It returned nothing at all, with no error, because the sidecar is not a service and every kind but service was skipped. The one place a refusal or a 502 is written down was the one place nobody could read, including the end to end test that prints it on failure: on a real 502 it printed the word sidecar: followed by nothing. A request for every service still leaves the sidecar out, because that output is the application's.
Provisioning a persona read every user in the golden to find one.
The SQL adapter addressed a row by its key cast to text, and any key that is not already text becomes an expression no index can be searched by, so reconciling a persona's account, its Supabase identity and its second factor each read the whole table or the whole index. Supabase's uuid keys and a generic scheme's integer keys were all affected; a text key was not. Against a million Supabase users the users update took a median of 185 ms and each identity lookup up to 126 ms, per persona and on every branch, where the same statements searching the index take a few milliseconds or less. The key is now compared as its own type, which matches the same row for integer, uuid, text and character keys and lets Postgres use the index.
The Aurora provider could not refresh a golden against real AWS.
Its first live run cloned the source and brought up a writer. It then connected with the rotated master password one second after asking for it. Aurora accepts that change before applying it, and the cluster reads available the whole time, so Postgres refused the login and nothing was published. The provider now waits until the new password actually logs in.
Teardown was broken as well. Five of the fault codes the provider recognised carry a Fault suffix that RDS does not send, including the one AWS answers when an instance is already being deleted. So cleaning up a writer that was already going away failed instead of succeeding, and a missing instance was reported as an error instead of as gone. A failed refresh also gave its cleanup five minutes, and AWS took twelve. The codes now match what RDS sends, and the cleanup waits as long as an instance is allowed to take to come up.
The enterprise compliance report called every real masking attestation forged.
Each golden's attestation showed "SIGNATURE DOES NOT VERIFY" in the SOC 2 and HIPAA evidence, although nothing had been changed.
An attestation is signed over its own fields, and the compliance report checks the signature by rebuilding those fields and hashing them again. Since 2026-09-01, before 1.0, the engine has signed four fields the compliance report did not know about: the project a golden was made for, which datastore the scan read, the columns the scanner could not read, and the columns masking copied unchanged because no rule named them. The report dropped all four when it rebuilt the document, hashed different bytes, and failed every signature. Its own tests passed, because they checked attestations written before those fields existed.
The report now reads every field the engine signs, and real attestations verify. The engine's test suite signs an attestation with every field set and a fixed key, and the compliance suite must verify that same file, so a field added to one side without the other now fails a test rather than a customer's audit evidence.
Two billing owners of one organization who pressed Subscribe a minute apart each got their own Stripe checkout page, and both pages stayed payable for the next twenty four hours.
If both were paid, the organization held two subscriptions on one Stripe customer and one card was charged twice. Nothing refused the second page: an open checkout creates no subscription, so the check of this database and the check of Stripe both answered honestly that there was nothing there yet. The only thing that tied two requests together was a thirty second window on the idempotency key, which covered a double click and nothing else.
Each organization now holds one purchase attempt, a row claimed under the organization's own lock, and the key sent to Stripe names that attempt rather than a slice of the clock. A second press, a second owner, a second tab or a second replica is given the page that already exists. When the page has to change, because the buyer picked another plan or Stripe expired it, the old page is expired at Stripe before a new one is opened, so two pages are never payable at the same moment. Before any new page is opened, Stripe is asked for the customer's open checkout sessions: a page whose creation response was lost on the way back is found again by the attempt it carries, and every other payable page, including one opened before this release, is expired. A page that has been paid refuses another purchase even when the webhook for it is late or never arrives, until the subscription it created is cancelled for good. If Stripe cannot say what is open, nothing is opened, and the buyer is told that nothing was charged and that pressing again resumes the same purchase. Stripe's list of the customer's subscriptions is now read in full, every page, before a purchase, and a list that fails or holds a subscription that cannot be read refuses the purchase, so a subscription made in the billing portal cannot be missed. A checkout Stripe has no record of is refused, and Refresh from Stripe clears it.
A subscription that Stripe has paused, which happens when a trial ends with no payment method, also counted as nothing and was sold a second plan alongside it. Adding a card later resumed the first, and the organization paid for both. A paused subscription now refuses a new purchase, and the refusal says to add a card in the billing portal instead.
A managed cloud database was reached with sslmode=require, which encrypts the connection and checks nothing about who answers it.
sslmode=require, which encrypts the connection and checks nothing about who answers it.Anything on the path could present a certificate and read a copy of production. The three cloud providers now hand out verify-full or verify-ca connection strings with a pinned public trust bundle: AWS's published RDS roots, Microsoft's published Azure roots, and the Cloud SQL instance's own CA fetched through the authenticated Admin API. require is refused, and plaintext is limited to a loopback test fixture.
The same bundle now reaches the application. The engine installs it inside every service and migration container as its own file, separate from the egress proxy's HTTP inspection authority, because a proxy that can sign any hostname must not be able to vouch for a database.
A cloud branch also sat outside the environment's contained network, so a service either could not reach it or reached it around the proxy. The sidecar now relays each branch through a fixed listener bound to one resolved and validated address. PostgreSQL begins TLS with an SSLRequest that carries no hostname, so the relay copies bytes rather than inspecting them, and nothing a client sends can choose another destination. Metadata, loopback and platform service addresses are refused before any listener opens, and on Kubernetes only the sidecar receives a network policy that reaches the database.
A running container or Deployment was reused whenever its image matched, so a second af up kept the previous database URL and trust file. Both runtimes now compare a fingerprint of the whole configuration.
A branch that failed after its provider created a resource lost the reference, so teardown could not find what it had to remove. The reference is now journalled before the error returns.
The Aurora provider had the same gaps the other two were fixed for. A clone now disables every inherited login and ends its sessions before masking and again before publication, keeps the source's subnet group and security groups, turns IAM database authentication off, and scopes every resource to the source cluster's ARN.
None of this has run against a real cloud. The TLS checks use a real PostgreSQL SSLRequest and handshake against certificate authorities the tests generate, and the relay is proved end to end in Docker against a real PostgreSQL TLS server. No connection has met a certificate issued by AWS, Google or Microsoft.
A page that showed escaped markup as text had a markdown twin that said something else.
The site writes a plain markdown copy of every page for readers and language models, and the text it took from the HTML was decoded in the wrong order: & became & first, so the next step then read the result as a second entity. A page that displayed <b>, which the HTML carries as &lt;b&gt;, came out in its twin as <b>, and a displayed " came out as a bare quote mark. The twin quietly stated markup the page never showed.
The check that compares every page against its twin could not see it, because it decoded with the same order, so both sides were wrong in the same way and agreed. The writer and the check now share one decoder, which turns & back into & last, and a test renders a page carrying exactly those strings and requires that both its twin and the check keep them as shown.
A draft pull request's dogfood check always failed, and marking the pull request ready never replaced the failure.
The workflow ran on GitHub's default pull request events, which are opened, synchronize and reopened. The control plane starts no check on a draft and opens one when the pull request is marked ready, so the draft's run did twenty minutes of work, was refused with HTTP 409 because no check was waiting on its commit, and went red; and marking it ready started the check and no run to answer it, so the red stayed until somebody pushed again. The workflow now runs when a pull request is marked ready for review, skips the check on a draft and says so in its comment instead of reporting a failure, and cancels a run that is still going when a pull request is sent back to draft.
On a Mac, af login could not sign anyone in, and af model set could store a broken key, since v1.3.0.
af login could not sign anyone in, and af model set could store a broken key, since v1.3.0.In a terminal, af login and af model set hung at "password data for new item:" and stored nothing. The keychain write fed the value to the security command's password prompt on stdin, and security reads a prompted password from the terminal whenever there is one.
Without a terminal, the same write kept only the first 128 bytes of the value and reported success. The credential af login stores is longer than that, so af login said "Signed in" and every later command failed with AF-SEC-006, "unexpected end of JSON input". af model set did the same to any key longer than 128 bytes, which includes OpenAI project keys (sk-proj-), so every model call with that key was then refused by the provider. Anthropic keys are shorter than 128 bytes and were stored whole.
The write now goes through security's interactive command stream, which reads stdin with or without a terminal and keeps the value off the process listing. Every write is read back and compared, so a value the keychain did not keep exactly is an error rather than a success. Values of any bytes, line breaks included, are stored whole up to about two kilobytes; a longer value goes to the fallback store and the command says where it went. Reading a keychain value holding a tab or a non-ASCII character no longer returns it as hex.
To recover, upgrade and run the same command again: af login for a credential, af model set for a key. The new write replaces the damaged entry, so nothing needs deleting first.
The release's check that the published egress sidecar image can be fetched could not fail for the reason that mattered.
Every customer's af up pulls ghcr.io/antifailure/af-proxy with no credentials, and a package GitHub creates is private on its first publish. The release pulled it back while still logged in to ghcr.io from the push, so it passed against a package no customer could read, and every first af up would have fallen back to compiling the sidecar for up to 25 minutes with nothing going red.
The pull back now logs out and runs under an empty Docker configuration, the same as a customer, and so does the read of the manifest list. When the package is private it fails with the one time remedy: make the package public on its settings page, then re-run the failed job. The release is not published until that happens. Cutting a release now says so, and says to approve the production deployment only after the release's publish job reads success.
An EC2 instance role could never supply AWS credentials.
The chain asked instance metadata for a version 2 session token and sent it when listing the role, then read the role's credentials without it, and an instance that requires version 2 answers that request 401. The failure was then reported as metadata that did not answer, on an instance where it had answered twice. Every AWS call the enterprise edition makes on an instance role was affected: the Aurora provider and the AWS Secrets Manager backend both refused to start. It was found by running the Aurora provider on a real EC2 instance, and the error now says what metadata answered when the role path fails.
A pull request whose recorded base had fallen behind main failed the dogfood check for reasons that had nothing to do with the change.
The job compared against github.event.pull_request.base.sha, which GitHub leaves where it was when main moves, while the commit the job checks out is rebuilt on the new tip. With two commits in the clone the recorded base was not one of them, so af oracle refused with AF-ORC-003. The load scenarios then failed with AF-LOD-010, because they had been sent at a candidate the refused oracle never brought up. The job now takes the base from the checked out merge commit's first parent, for both the oracle and the staging database it shapes, and the scenarios bring their own environment up, saying so when they cannot rather than failing a second time.
The GitHub Action failed the pull request check of every repository that used the documented workflow, in about fourteen seconds and before af ran, with jq: parse error: Unmatched '}' at line 1, column 3.
af ran, with jq: parse error: Unmatched '}' at line 1, column 3.That has been true of every release since v1.3.0, so it is on antifailure/antifailure@v1.
The action's first step read its dispatch input with ${AF_DISPATCH:-{}}, and a shell reads that as the default { followed by a literal }. Any dispatch that was not empty gained a closing brace. The documented workflow passes toJSON(inputs), which is {} on a pull request, and a workflow that leaves the input out gets the input's declared default, which is also {}. Both reached jq as {}}. So did any other JSON a caller passed. Only a workflow passing the input explicitly as an empty string got past that line. The copies of the example that af init and the GitHub App write pass toJSON(inputs), so they failed too.
The step now reads the input unchanged and treats an empty or null dispatch as none. tools/actioncheck runs that step against every dispatch a caller can send, including an explicit empty string and the value GitHub itself renders for toJSON(inputs).
A workflow pinned to antifailure/antifailure@v1 receives the fix when the v1 tag moves, which happens on the next final release.
The command the key rotation guide gave for its verification step could not start the verification.
It passed the check as a quoted --command, which the Azure CLI reads as a list, so the container would have been asked to run one program whose name contained spaces, under a container name that is not the job's, with no image and none of the sealing keys. That step is the one an operator runs before deleting the old key.
The guide now starts the check from the job's own definition, with its image, its environment and both keys a rotation adds, and changes only the command. It was run that way against staging, where the execution's template read the four arguments, the container exited 0, and every row opened. A test runs the block from the published page against a fake Azure CLI and refuses the old command, a command without the check flag, and one without the job's environment.
A workflow's budget bounded nothing.
budget bounded nothing.budget.steps and budget.duration were normalised to 60 steps and ten minutes and refused when malformed, and the document sent to the runner carried neither. Every workflow ran to the runner's own forty steps and for as long as that took. A page that never answered held a workflow for the browser's thirty second timeout on every attempt, and the workflow was then judged on whatever was on the screen. The documentation quoted AF-AGT-002 Workflow sign-up exhausted its budget of 40 steps, which nothing ever produced, and AF-AGT-002 itself told every failed workflow it had "exhausted its budget of its attempts", so a plain failure read as a budget problem.
Both halves now reach the runner from every command that runs workflows: af test, af ci, the run_browser_workflows tool and the hosted runner. The time budget covers the whole workflow, retries included, and it is a hard cap. A workflow that reaches it is stopped where it is and ends as blocked with the budget named, and no further attempt starts. A workflow that uses every step passes if everything it expected is visible on the page it reached, fails if that page answered with an HTTP error, and otherwise ends as blocked with the step budget named. A blocked workflow is never a partial pass.
A workflow stopped by its budget has its own code, AF-AGT-024, which names the workflow and the budget. af test and af ci raise it when that is why nothing was verified, and af ci shows the workflow as blocked, never as passed or failed. AF-AGT-002 now says a failed workflow failed.
af init recognised a store the environment provides by searching the image name for the store's name, and the images that stand beside a store are named after it.
af init recognised a store the environment provides by searching the image name for the store's name, and the images that stand beside a store are named after it.provectuslabs/kafka-ui, a web console, was removed from the services and reported as a second Kafka store. The same happened to postgrest/postgrest, an API server, which became the application's Postgres, and to each store's exporter, admin console and REST proxy, among them redis-commander, mongo-express, postgres-exporter, opensearch-dashboards and cp-kafka-rest.
A store is now recognised only by its repository name, against a list of the images each store actually publishes, with any registry, tag or digest in front. postgis/postgis, which the substring search missed, is recognised as Postgres.
Once the console is a service, its depends_on is read, so a dependency on a store is now decided by what the named service runs rather than by its name. Confluent's compose files call their broker broker, and guessing from that name would have written a dependency on a service the manifest never declares, which af init then refuses. A service of your own called db that compose builds is no longer dropped from depends_on for being named like a database.
A hosted model budget was charged at a rate the provider no longer charges, so it ran out early.
The control plane's built-in price list charged claude-sonnet-5 at 3 and 15 US dollars per million input and output tokens and claude-opus-5 at 15 and 75. Anthropic publishes 2 and 10 for Sonnet 5, with the rise to 3 and 15 planned for September 1, 2026 cancelled, and 5 and 25 for Opus 5. A customer spending through a brokered key was charged 50 percent too much on Sonnet 5 and three times too much on Opus 5, so a monthly cap was reached at two thirds or one third of what they had set.
Both are corrected. Every built-in price now names the provider page it was read from and the day, and a test refuses a price with no source. Spend already recorded this month is a running total with no per-call token counts behind it, so it is not recalculated.
af init refused the ordinary compose file: an application built from the repository beside an image pulled from a registry, such as a helper, a mail catcher or any container nobody builds.
af init refused the ordinary compose file: an application built from the repository beside an image pulled from a registry, such as a helper, a mail catcher or any container nobody builds.It stopped with AF-DET-004 asking what command starts web, or with AF-DET-005 and three services claiming port 3000 once a command was given, and wrote no manifest. Detection placed the image at the repository root, because a service with no build context looks exactly like one built from the root, so compose appeared to declare two services in one directory and the application was never joined to its own Dockerfile. With no build service beside it, the image was folded into the application and disappeared from the manifest instead.
An image nobody builds is now written as its own service with build: {strategy: image, image: <reference>}: a web service when compose publishes a port for it, and a worker when it publishes none, carrying its compose command when there is one. The application beside it is one service again, and the summary names the image each such service runs.
af oracle no longer reports a manifest's personas as rows the change stopped writing.
af oracle no longer reports a manifest's personas as rows the change stopped writing.Both sides of a comparison provision the same personas, each into its own database, and each database gives the persona's rows its own generated key. Rows were matched only by key, so on an identical build the owner's account read as a row missing on one side and a row extra on the other, along with every membership that pointed at it. Antifailure's own manifest reported six major differences this way on a build that changed nothing.
A row the two sides do not share by key is now matched when it names a persona, by the persona's address or phone number or by a UUID already matched that way. The matched row is still compared column by column, so a persona provisioned under a different name is reported as a changed row. A match is made only when it is the only one on both sides; two rows for one persona on each side are reported as before.
A changed row whose differing columns are named like a digest, with hash, salt, digest or mac as a word of the name, and whose two values look like random values of the same length now carries a hint naming the oracle.ignore.fields entry that would quiet it. A password hashed under each side's own salt, or an audit chain's hash over each side's clock, differs on every build, and the report used to give no sign of that. The finding is still reported; the hint only says what to write if the column is what it looks like.
Re-running the check from the Actions tab reported success having verified nothing.
GitHub has three Re-run buttons. The two on a check send check_run or check_suite rerequested, and the control plane reopened the check for both. The one in the Actions tab re-runs the workflow run, which starts a second attempt of the same run and sends neither of those events, so nothing reopened the check and the second attempt's claim was refused with "the check on a1b2c3d is already passed". The workflow could not tell that refusal from a fork, so it skipped its report step and exited zero: a green job, and a pull request still showing the verdict of the attempt that had been replaced.
A claim from a later attempt of the run already checking a commit now reopens the check, counts the attempt, clears the previous verdict and takes a fresh deadline. A claim from the same or an earlier attempt is refused, because a signed identity outlives the report it bought. A completed check run cannot be moved back out of completed at GitHub, so each attempt gets its own check run and GitHub shows the most recent one.
Both workflow surfaces now say which silence they hit. action.yml fails a customer's job when the control plane refuses a report the run held a credential to send, and warns without failing when it could not be reached at all.
A first af up could sit silent for twenty five minutes and return nothing.
af up could sit silent for twenty five minutes and return nothing.The first thing af up does is obtain the egress sidecar's image, and no release had ever published one, so every machine compiled it. The compile starts by pulling a Go base image, and on a network refusing TLS handshakes that pull stalled. Nothing bounded it and nothing was shown: the build's output was read to the end before any of it was printed. The measured run was killed at 25 minutes 32 seconds on one line, and zero of four stacks came up.
A release now publishes the sidecar to ghcr.io/antifailure/af-proxy for linux/amd64 and linux/arm64, and af up fetches it before it compiles anything. A build from a commit no release covers still compiles, and now shows each step and the base image download as they happen, with a line every fifteen seconds naming how long it has run and what the daemon last said. Fetching is bounded at two minutes and compiling at ten, AF_PROXY_IMAGE_TIMEOUT raises both, and a step that runs out stops with AF-RUN-048 saying what it was doing. AF_PROXY_IMAGE now names your own copy of the image on the local runtime too, as it already did on Kubernetes.
The compile's base image is pinned by digest. The sidecar's name is a digest of its source and its Dockerfile text, and while that text named a moving tag, two machines could hold two different sidecar binaries under one name. Every sidecar image now also declares the source it was built from, and one that declares anything else is refused rather than run, whatever it is called.
Publishing a port no longer builds a second image. The ingress forwarder was built from Alpine with socat added, so the first af up of any web service also pulled a base image and downloaded a package, with the same silence and the same missing bound. The forwarder is now the sidecar image itself, started in forward mode, so it needs nothing the sidecar did not already bring.
a-first-af-up-sat-silent-on-a-base-image-nobody-published-around
af insights blamed a project's migrations for a failure the rehearsal caused itself, because the migration container received every declared secret as an empty string.
af insights blamed a project's migrations for a failure the rehearsal caused itself, because the migration container received every declared secret as an empty string.A project whose migrations are Ruby, Python or JavaScript is rehearsed by running the service's own migrate command in the service's image. That container was given the manifest's literal values and nothing else, so a variable read from a secret source arrived as NAME= with nothing after it, while af up had started the same service with every value. A Rails app with no SECRET_KEY_BASE refuses to boot, so the rehearsal failed before a single migration ran, and the report said AF-DB-030, "Migrations failed on the branch", and told the reader to fix the migration.
The rehearsal now resolves the service's secrets through the same chain and the same per service lookup af up uses, so a value scoped to another service is not handed to it.
The same container also decided which database a rehearsal reached by the order of its variables. The branch's address went first and the service's variables after it, and Docker keeps the last of two entries with one name, so a service declaring DATABASE_URL replaced the branch's address with its own. The environment is now built with exactly one entry per name and the branch's address written last, so a rehearsal only ever reaches the throwaway branch, whatever the service declares.
A failed rehearsal's quote of the tool also carried a stray character in front of the tool's first word, such as 1rehearsal sees, because Docker's frame header was filtered by whether its bytes printed rather than read as a header. The log is demultiplexed properly now, and a message longer than one read arrives whole.
The refusal that told a Heroku or Tiger Cloud user to run a statement their vendor gives nobody the role to run.
AF-DB-035 is what the pgurl provider returns when the host server's role may not create databases, and it ended "Run: ALTER ROLE {role} CREATEDB". That is right on a server you administer. On Heroku Postgres the assigned user may not create or drop databases and there is no superuser to grant it, and a Tiger Cloud service holds exactly one database. A reader there tried the statement, got a permission error, and had been told nothing that works.
What works is already how pgurl is built. The vendor stays as database.source_url_env, which needs read access only, and PGURL_ADMIN_URL points at a Postgres you administer, where the goldens and the branches are made.
AF-DB-037, for a host recognised as a vendor whose own documentation says the grant is unavailable. It names the vendor, gives that remedy, and quotes the page and the date the verdict was read.AF-DB-035now carries the same remedy afterALTER ROLE. A Heroku Postgres host is an EC2 name that no suffix can recognise without claiming every EC2 machine, so a Heroku user still readsAF-DB-035. Without the second remedy the headline case of this defect would not have been fixed.af startnames the vendor on its database rung and blocks on a documented no, without connecting to anything.- Managed Postgres vendors, a new page, places thirteen vendors, each with the page its answer was read from. Four answer
unverifiedrather than a guess, and those are named and never refused.
The server's own answer still decides whether anything is refused. The vendor table only decides which sentence describes the refusal.
The hosted control plane now runs the enterprise edition, so single sign-on, directory provisioning and audit streaming exist where the hosted enterprise plan sells them.
Until this release, continuous deployment built and deployed the community image to both hosted environments. Its entry point mounts none of the three, so every identity provider callback and every SCIM request answered 404, and nothing said so: a community control plane is ready on the right commit and passes every health check. The enterprise image was built and proven on every pull request and deployed nowhere.
Deployment now builds control-plane-enterprise, and two checks stand either side of it. Before the image moves, the app must carry the four variables the enterprise entry point needs, because without the single sign-on sealing key that process exits before it listens; an environment missing any of them is refused with the app untouched. After traffic moves, the public origin must answer a SCIM discovery request with 200 and a sign-in discovery request with 400, so a deploy that is healthy and serves the wrong edition, or a licence that does not permit a feature, fails the release instead of passing it.
The sealing key is generated by Terraform into the environment's vault, and the licence is issued with tools/licensegen and placed by a person. The production guide has the one-time procedure under Turning on the enterprise edition. The hosted signing key's location, and who can read it, is recorded on the issuing page as a measured limit.
af init read any compose image whose name contained the letters moto as the moto AWS emulator.
af init read any compose image whose name contained the letters moto as the moto AWS emulator.An application running acme/promotools or motorola/firmware-sim got AWS egress rules drafted from that container's SERVICES, a wider firewall than the application needs, proposed by the command whose job is to narrow it. moto is recognised now only under the two repository names it publishes, motoserver/moto and ghcr.io/getmoto/motoserver, with any tag, digest or registry in front. A registry host that happens to contain moto no longer counts either.
The list form of a compose environment block, - SERVICES=s3,sqs, is now covered by a test as well as the mapping form. It already worked, but nothing checked it, so a regression would have looked exactly like an emulator that named no services.
Two services that needed different values for one variable name both received the first one, and nothing said which was wrong.
A secret lookup took a name and nothing else. Supabase's published compose file is the case that found it: its storage and supavisor services both read DATABASE_URL with a different connection string in each, and both of those are credentials, so neither can be a literal in a manifest and both have to resolve through the secrets chain. A manifest could already say where each one was stored, because from renames a lookup, but the resolver merged the two declarations by the variable's name, kept the first place it saw and dropped the second without a word. So a twin of that stack would have been silently wrong rather than visibly broken.
Resolution is keyed by the service as well as the variable now. Declarations of one name that read from one place are still one lookup, one audit record and one value shared by every service, so a manifest that needs no per service value resolves exactly as before. Declarations that read from different places are separate lookups and each service receives its own answer, which no other service's lookup reaches.
A manifest can also say so directly with scope: service, which stores the value under the service's name in capitals, two underscores, then the variable, so the storage service's DATABASE_URL is STORAGE__DATABASE_URL. Every source can hold a name of that shape, including the enterprise secret stores, with no adapter change and no change to the order the chain asks in.
A sandbox credential cannot differ between services and cannot be scoped, because the egress proxy holds one value per credential for the whole environment and substitutes it whichever service sent the request. That is refused with AF-SEC-007, naming the variable and the services, rather than resolved to whichever was seen first. Validation also refuses a scope on a literal, and refuses two names that would be stored under one scoped name, which is the same leak read the other way round.
two-services-needing-different-values-for-one-name-got-the-first
tools/docs/forbidden.sh has refused the marker words and the two word promise since it was written, and it reads six paths, all of them Markdown: docs/src/content/docs, examples, and four files at the repository root.
tools/docs/forbidden.sh has refused the marker words and the two word promise since it was written, and it reads six paths, all of them Markdown: docs/src/content/docs, examples, and four files at the repository root.That is 300 of the 2875 tracked files. So a note to the author in any Go file, any SQL migration, any Terraform stack, any workflow, the justfile, the api's or the runner's TypeScript, the console, the marketing site, or any of the seventy gates in tools passed every check this repository runs, and nothing said so. The one instrument with an opinion about unfinished work was pointed at a tenth of the tree.
tools/defercheck asks the same question of the whole git index. Five rules: a note to the author, a promise instead of a capability, work that calls itself short lived, work handed to a later change, and the one no scan for words can reach, a test that skips without saying why. That last rule is the reason this is worth more than a grep. A marker is the weakest form of deferred work because somebody wrote it down; a disabled test is the strongest, because it reports a skip, the suite stays green, and the count in the summary reads as a pass.
Three suites were in that state. admincontrols, adminfleet and adminhealth each registered one empty test with the skip option set to the bare literal true, which states no condition, so a reader could not tell a suite somebody turned off from one whose database was absent, and the summary said one skipped test whatever the file held. Each now names what was measured and the variable that fixes it, and says that the whole suite did not run rather than that one test did not.
The rules are narrow where the tree earned it, and the exemptions say why rather than where. A row quotes a fragment of the line it excuses, so it covers that line and nothing else: the row for the sentence in ci.yml about another project's parser does not cover the next marker somebody writes in that workflow. There are twelve rows and four reasons among them, none of which is that a file is allowed to be unfinished. Three quote another project's own words, one is the shape of an error code, seven are a sibling gate's rule and its fixtures, and one is a fixture for the tool that finds pages like it. The tool's own marker words are assembled from fragments instead, so it is subject to itself rather than exempt from itself. That was tested the hard way: the first commit of the tool and its exemptions was refused by the tool, four times, for a comment spelling the empty reason skip it forbids and two rows quoting the very phrases they excuse. Neither file had been read before, because an untracked file is not in the index this gate reads.
It also says what it did not check, and that is how it found runner/src/cassette.ts. The cassette key is hashed with a NUL separator, because no provider name, model name or prompt can contain one, and the byte was written as the character rather than as an escape. That made the file BINARY to git and to every text tool here at once: git grep -I, tools/prosecheck and tools/docs/forbidden.sh all skip a file holding a NUL, so a tracked TypeScript source was invisible to all of them and nothing reported it. The escape produces the identical byte at run time, proven against String.fromCharCode(0), so every recorded key is unchanged, and the runner's 138 tests pass with none skipped. A tracked file that cannot be read is now a failure unless its name says it is an image or a font, in which case it is named in the report rather than counted as checked.
One capability was defined and never read. FAILED_STATES in web/apps/api/src/admin/operations.ts exists, in its own comment's words, so that nobody counts only failed and forgets timed_out and abandoned. Nothing read it. The failure groups query typed the three states out again inline, so a fourth terminal state added to the constant would have been missing from the operator's failure list with every check green. The query now takes its list from the constant, which drizzle binds as three parameters. Narrowing the constant to failed alone turns the three state test red, one group where it wants three, and restoring it turns it green, against a Postgres of its own.
And one promise reached a user. AF-RUN-001 says a command is not available in this version, and its next step was "See the roadmap for when it lands". There is no roadmap page in the documentation and never has been, so the one thing the sentence asks the reader to do cannot be done. It now names af --help for what the binary carries, af version for which build it is, and af update for replacing it, all of which exist.
Every assertion in the gate's own tests was watched failing against the line it exists to catch and passing once the bytes were restored, nineteen cells in all counting the query above, and one marker was planted in each of eleven language families in the real tree and named by file and line. The cases that must PASS are as important as the ones that must fail and there are seven of them, every one a real line from this repository: an explained skip in both languages, the conditional skip this repository already writes, a sentence about rows not yet written inside a transaction, the reaper's own use of the word deferred, an error's next step, a digest whose letters spell a marker, and a button that offers to skip a step. A gate that fired on any of those would be answered by making the product worse.
Several pages told a buyer more than the evidence behind them supports, and two told a security reviewer less than is now true.
The pricing page's Enterprise card sold "Fleet management and premium connectors" and "Governance, evidence retention, and residency". Fleet is the vendor's own operator surface. "Premium connectors" appeared nowhere else in the repository. The per plan retention number is read by nothing, because one retention setting applies to every organization. And residency is real for environments, through the allowed_regions policy, but not for the hosted control plane, which runs in one Azure region. The card now sells residency for environments, and single sign-on and SCIM in the enterprise edition, and the paragraph above the cards names the control plane's region.
The database providers table said Aurora branch time is flat. Nobody has timed an Aurora clone, and the provider's own benchmark prints those cells as unmeasured. The row now says the time is expected and never timed, and a new section says which providers are proved against their real service on every pull request, which were proved by hand, and which only against a fake.
The licence catalogue said compliance packs cover SOC 2 and ISO 27001. The command builds SOC 2 and HIPAA and nothing else. A test now holds the catalogue's wording to the packs the build carries, in both directions.
LICENSING.md and the enterprise README listed billing, metering and support tooling as enterprise features. The licence verifier records billing as a name with nothing behind it, and operator support access is community code. LICENSING.md also called every database provider MIT, and Aurora is not.
SECURITY.md said there is no adversarial containment suite. There are two, one against a real Docker daemon and one against the sidecar. Neither reaches the Kubernetes runtime, and the page now says that instead. It also said no release carries a signature yet, when every release from v1.0.0 to v1.3.5 does.
Smaller corrections:
- The README's count of database conformance behaviours said 24, and the roster is 26.
- The README said all four platforms are checked for reproducibility in CI, and CI checks one.
- The AKS guide said Antifailure runs on AKS. The Kubernetes runtime has only run against k3s.
- The terms page, the privacy link and two documentation pages still pointed at the deleted service levels and subprocessor pages.
- The egress example named
localstack, which is not a name the engine registers. The AWS surface registers asaws, answered by LocalStack's image. - The Twins page said there is no automatic time to live, and every environment is created with one.
CONTRIBUTING.mdsaid TypeScript is formatted by Biome, which is not installed.
Starting the local ClickHouse could fail for good after one unlucky moment.
The engine picks a free port, then asks Docker to publish the server on it, and anything else on the machine can take that port in between. When that happened the engine gave up rather than trying another port, and it left behind a container that could never start, still holding the fixed name af-clickhouse and the port it had lost. Every later af command that needed ClickHouse found that container, tried to start it, and failed the same way until somebody removed it by hand.
Now a lost port is retried on a different port, up to three times, the way the Postgres provider already did. A container that fails to start is removed before the error is reported, whatever the reason it failed, so the next attempt starts clean. A ClickHouse that already exists and holds your data is never removed by this: only a container the same attempt created and could not start.
CI hit this on main in two of twenty five runs, as ten live ClickHouse tests failing together with "Bind for 127.0.0.1:43000 failed: port is already allocated".
Kubernetes workers could be reported as started when only their Pod objects existed.
A single worker also passed after a pod-list error or while its containment init container was still pending.
Readiness now requires every ordinary init container to exit successfully and the application's container to be running or successfully completed. The wait honors the service's health timeout, including API requests, and preserves a bounded early-crash observation for a newly started single worker. Restartable init sidecars use their startup and readiness state and can stop after a successful job. Init failures include the container's termination message.
Published ingress URLs are checked after pod readiness, so an ingress controller still reconciling its backend no longer produces a ready environment. The check retains authentication redirects without following them and refuses unavailable routes by the declared timeout.
The Kubernetes runtime had no command anybody could type, and the number the site quoted for the Docker one had gone stale by five behaviours.
engine/internal/runtime/k8s/conformance_test.go runs the shared runtime conformance suite against a real cluster, and it is gated behind AF_KUBE_CONTEXT because it creates namespaces, deletes namespaces and runs pods that try to reach the internet. Required rather than defaulted, on purpose. What was missing was the other half: a way to satisfy that requirement without a paragraph of setup nobody repeated, which is why the status row carried numbers from runs that could not be reproduced. just k8s-conformance creates a single node k3d cluster, names that cluster's own context in AF_KUBE_CONTEXT, runs every behaviour and deletes the cluster afterwards. The guard is untouched for everybody else.
The dedicated Kubernetes workflow runs this command on an isolated runner, with pinned cluster tools and a pinned k3s image. It retains the Go test events and refuses a missing, skipped or failed roster entry. A successful command that ran fewer behaviours cannot become a conformance claim. The shared development machine is not used for this cluster.
The count is the second half. Three documents stated how large the runtime conformance roster is: the product page, the plan's status row and the claims ledger. All three were right when they were written and all three said thirty-two. Five behaviours were added to the roster afterwards by three separate pull requests, none of them touched a document, and nothing anywhere compared the two, so a customer facing page kept quoting thirty-two while the suite held thirty-seven. TestEveryQuotedRosterSizeMatchesTheRoster now reads those documents and fails when the number they quote is not the number of behaviours there are. A document that has been reworded past its own sentence fails too, rather than passing while checking nothing.
There were two entitlement systems and they did not know about each other.
The engine has license.Feature, fourteen names a signed license may carry. The control plane has organizations.plan and entitlements.ts, which is real, tested, and about quotas. Nothing reconciled them, so "what does this customer get" had no single answer, and a self hosted license and a hosted plan could disagree in silence.
ee/engine/feature/catalogue.go is now the one answer. Every feature carries what it is, where it is enforced as path:symbol, and, when it is not refused, which reason applies: it is refused by the control plane rather than the engine, it is implemented and deliberately free, it is built and loaded by no binary, or the capability does not exist.
Of the fourteen, ten are refused when a license does not name them, eight by the engine and two by the control plane. Four change nothing whatever when they are absent, and the page says so.
THAT NUMBER MOVED FOUR TIMES WHILE THIS WAS BEING WRITTEN, from five to seven to nine to ten, and every move was the product changing rather than the measurement being wrong. Three features were filed as not built on a measurement that was true when it was taken and that named its own expiry in its own text. audit_stream had a socket in the community engine with nothing plugged into it. multi_runtime had a scheduler with no caller, no way for a manifest to ask for a placement, and no importable home for a check. air_gapped had no reference in any Go code outside the constant and the generator's copy of the name list, and its reason said in so many words that the lane which would build it had not landed. All three shipped afterwards. Then cloud_database and cloud_runtime arrived as two new names in the license with ee/engine/cloudgate already refusing them per call, so they were gated before this catalogue had a row for either.
That last move is the one worth reading, because the rule it broke is written in this same change. A reason may describe the tree and may name a commit it is true of, and it must NOT describe a future: a forward reference is unverifiable when it is written and silently false afterwards. The air_gapped row named a lane that had not landed, the lane landed, and nothing in the repository could tell. The count itself cannot drift, because the sentence on the page is generated from the catalogue rather than restated from the last time somebody counted, so the page now reads ten. The prose beside it is what needed a rule, and now has one.
THE FIRST VERSION OF THIS CATALOGUE PUT FOUR OF THE TWELVE THEN SELLABLE IN THE WRONG PLACE, and the four are worth recording because they failed in two opposite directions with one cause. Every one of them found real code and never asked who that code served.
Two were UNDER claimed, because the measurement read one language. Enforcement lives in Go and in TypeScript; the count was of feature.Enabled call sites, which only the engine has. sso and scim came back zero and were published as features nobody has, paid or not, while the enterprise control plane refuses both by name with a 402. A zero in that count means not enforced by the ENGINE, which is a different fact from not enforced.
Two were OVER claimed, because the code served a different subject. billing read as free because billingRouter is real, mounted and ungated, and that code bills the customer FOR Antifailure, while the licensed feature would meter on the customer's own behalf and does not exist. enterprise_dashboard read as covered by the plan because the console really is refused below the enterprise tier, which is our own funnel enforcing our own pricing rather than anything a license grants. Both are named in notShipped and cannot be sold at all.
Two checks now hold those two failures, and NEITHER COVERS THE OTHER'S CASE, which is stated here so nobody assumes one gate closes the class. A cross language check compares the catalogue's state claims against the TypeScript registry's declarations in both directions, and compares the named site byte for byte against what that registry actually declared; it catches sso and scim and cannot catch billing, because that row was true about the code it named. A second check holds the catalogue to notShipped, whose own comment says it is the only place that has to change when one of these is built; it catches billing and enterprise_dashboard and cannot catch sso and scim, because those are sellable and were merely described wrongly. Nothing automated found billing. A person read the catalogue's own prose and asked who the code was for.
A state meaning "refused on the plan as a whole" has been REMOVED rather than left unoccupied. It was the affordance that made the wrong classification available, and a refusal keyed on our own pricing tiers does not describe anything a license sells.
The rest of the scaffolding stands. A feature in the license and not the catalogue fails. A feature.Declare with no catalogue entry fails. A row claiming enforcement at a file with no call refusing that exact feature fails, which is how compliance_packs was found to have spent its whole life declared at compliance.Pack.Evaluate, a function that takes no context and therefore cannot ask about a license at all. For each feature the catalogue calls enforced in the engine, the real entry point is called twice, once with a license granting everything else and once with everything, and the behaviour has to differ. The control plane's half is checked from the control plane, because a developer renaming orgProcedure does not run a Go module that is deliberately outside the workspace.
The licensing page's feature table and its count are generated from the catalogue and say the number out loud, including when the number is unflattering.
A precondition that read "the daemon did not answer" as "the image is absent".
Two checks in the air gapped runtime tests asked whether the daemon holds an image by inspecting it and treating err == nil as present. Every other outcome took the same branch, so a genuine absence and a daemon too busy to answer became one answer, and the answer was the reassuring one.
It went wrong exactly where it was load bearing. One of those checks exists to refuse to run a test unless a sidecar image is ABSENT, because the code under test returns early when the image is present and would never reach the refusal being measured. On a daemon carrying 98 running containers the inspect came back with something that was not a not found, the check read it as absence and continued, and the assertion failed nine lines later saying an error was expected. The precondition existed to stop precisely that run and waved it through, because it could not tell the two errors apart.
Absence is now only ever a not found, through dockerutil.ImagePresent, and anything else is returned to the caller. The first check fails the test saying the daemon could not answer, so the run reports NOT RUN rather than passing. The second guarded a skip, which is the quieter failure of the two, since a daemon that could not answer would have reported the test as not applicable and a skip reads like a pass in most summary views.
The same test also now REMOVES the image when it finds one, rather than only refusing to run. Restoring a source file does not remove what the broken code built, so a mutation run that deleted the refusal left the image behind and every later run failed for a reason that had nothing to do with the code.
This is a defect in a check and not in the air gap. The production path makes the same two valued read and fails SAFE: when its inspect errors it falls through to the refusal and the build is refused.
Two secret stores reported themselves usable without ever speaking to the store.
The reachability check for Google Secret Manager acquired an OAuth token and stopped there. Google's token endpoint is a different host from Secret Manager, so a project whose Secret Manager API was never enabled, a Service Controls perimeter, or a typo in the project id all handed back a perfectly good token, and the source reported itself available. The lookup chain then listed it as a place a missing variable could have come from, while nothing in it could be read, which is the exact failure the chain's reason reporting exists to prevent.
AWS Secrets Manager had the same fault and worse. Where credentials come from the environment it made no network call at all, so no unreachable store could fail it: not a virtual private cloud endpoint pointed at the wrong place, not a region the account has never enabled, not a mistyped endpoint. Where credentials come from a container or instance role it did make a call, to the credential endpoint, which is a different host from the one secrets are read from.
The argument that had kept the check out of the AWS adapter was that a probe would be a signed, billed call on every run. It is neither. The probe happens at most once per process, because the source guards it with a sync.Once, and it is sent unsigned, which the service answers with a missing authentication error. That answer is the whole proof being sought, since what is in question is whether the host is there. Sending it unsigned is also the safer choice: the address being wrong is the case the check exists to detect, and a signed probe would hand a working credential to whoever owns the mistyped name.
Both adapters now reach the store, and a refusal still counts as reachable, because whether a credential may read one particular variable is a different question answered per variable.
The same defect was found in the Azure adapter by its first run against a real vault and fixed there alone. It survived in these two because neither has ever had a live run, and because no local fake could show it: each fake is one process serving both the credential endpoint and the store, and each harness built its unreachable case by pointing the whole fake at a dead address, so the two failed together and the credential failure hid the other one. The tests added here split the two hosts, which is the only arrangement in which this is visible without an account.
AWS had also never been run through the secret store conformance suite at all, while the other three adapters had. That is why the behaviour named "is unavailable with a reason when the store cannot be reached" had been written and passing for years without anyone discovering that this adapter could not satisfy it. It runs against that adapter now.
The enterprise binary's licence gate over the managed cloud providers now has a test that says no when a provider is registered after it.
The gate wraps what is registered at the moment it runs, so a registration placed below it is served ungated, and nothing else in the tree could tell the two orders apart.
the-order-of-two-statements-decided-whether-a-licence-was-enforced
The manifest schema now publishes the duration pattern on database.volume.max_age and load.traffic.max_age.
database.volume.max_age and load.traffic.max_age.The engine already refused anything that was not a duration in both places, so the reference page was telling readers that any string up to 32 characters would be accepted when it would not.
The enterprise README sold "SIEM streaming with a tamper evident hash chain".
Both halves are real, they are not joined to each other, and the conjunction was false in the way that is hardest to notice, because each half can be pointed at.
What ships is ee/engine/auditsink, registered in ee/engine/cmd/af/main.go, asking the licence per call, forwarding the five actions the audit stream page lists to Splunk, Event Hubs, an object store or a webhook. Its webhook signs an HMAC over the exact bytes posted, so one delivery is tamper evident. Nothing in it chains entries together.
The chain exists twice and neither copy is streamed. audit_entries carries prev_hash and entry_hash and is written by the control plane regardless of any licence, which is MIT and is not sold. ee/web/audit implements the forwarder that would carry that chain to a sink, with a bounded queue, four sinks and signed batch manifests over the chain head, and nothing imports it. It is not a declared dependency of any package in the workspace, including ee/web/server, so it could not be imported without a package.json change, while its own suite runs and passes in CI on every pull request.
So the control plane's audit log reaches no sink. Single sign on logins, directory provisioning, operator impersonation and every admin action are written to audit_entries and forwarded nowhere.
The customer facing page was already right. docs/enterprise/audit-stream.md scopes itself to the engine in its second paragraph and lists exactly the five engine actions. It was the one line summary in ee/README.md, repeated in LICENSING.md, that sold more than the product does, which is where a product oversells itself first.
This corrects the claim rather than building the missing half. The wiring is enterprise side work and is described where the claim used to be, so the paragraph is deletable by whoever lands it.
The SOC 2 and HIPAA packs were proven by a run nobody could repeat.
docs/plan/STATUS.md row 13.12 said proven and described a run against a real control plane. Not one test under ee/engine/compliance opened a database connection, the word compliance appeared in no workflow and in no justfile recipe, and the doc comment on the chain verifier stated in the present tense that a test ran a chain written by the control plane's own code through it. None of that existed. A run that happened once on somebody's laptop and left no re-runnable artifact is the definition that page itself gives for the difference between proven and written, and the row was on the wrong side of its own line.
There is now a suite that creates a database of its own on a real Postgres, applies every migration with the control plane's own runner, appends every audit entry through appendAudit, and runs both packs through the same command the enterprise binary contributes. It publishes the two reports, their JSON forms and a note saying what the run did not check. The enterprise job runs it on every pull request against the Postgres that job already had, refuses to pass when the documents are absent, and keeps them as an artifact. just compliance is the same run on a laptop.
Four negative arms, each pointed at the case it exists to catch: an entry altered with a privileged connection, an entry deleted with one, a table carrying tenant data with row level security switched off, and an application role granted UPDATE on the audit log. A check that cannot say no cannot say yes either.
The number of tables carrying an org_id is not asserted anywhere. It is a property of the schema on the day it runs, which is exactly how the row came to claim seventeen of them after three more migrations had landed. What is asserted is that none of them has row level security disabled.
Installing the control plane's dependencies moved above the Go tests in the enterprise job and in just test-ee. The audit chain drift guard had been sitting behind that line since it was written, so it skipped in the one job on this repository with a Postgres to run it against.
Record the two enterprise features that a license names and nothing gates, and add the check that could have found them.
Twelve features can be named in an enterprise license. Seven are enforced at a real site, two are refused at issue and at evaluation because nothing implements them, and three were neither. Two of those three said so in prose. air_gapped said so nowhere: every occurrence of the name in the repository was a copy of the catalogue, the license vectors, a line of documentation, or a test. A license naming it verified, reported itself active, printed in af license status, and granted nothing, which from outside is what a working feature looks like.
Nothing could have caught it, and that is the more useful half. The control plane's registry test asserts that the features enforced THERE have declared a site, and correctly declines to speak for the engine. The engine's own registry test asserts a site set is empty from a test binary that links none of the enforcing packages, so it cannot fail. Between one check that will not look at the engine and one that cannot look at anything, nobody ever asked whether every feature does something somewhere.
unenforced in ee/engine/license/license.go now records a feature that ships and is gated nowhere, with the reason stored beside the name, the way notShipped already records one that does not ship at all. The two are different answers: absent cannot be sold, a feature we do not gate can be, and only the first is a reason to refuse a request. So tools/licensegen warns rather than refuses, naming the feature beside the key it just signed, and permits nothing new and refuses nothing that used to work.
The check that spans both halves lives in the control plane's catalogue test, which already reads license.go and licensegen as text because no import joins them. It requires every feature to be refused, recorded as unenforced, or enforced at a site it can find in the engine, the control plane, or the community entitlement catalogue. Each of its three scanners carries a positive control naming a feature it must find, so a scanner that has stopped reading its input says so instead of quietly reclassifying a feature nothing gates as enforced.
The 593 constraint schema gate had been measuring nothing, and saying red while it did.
TestEverySchemaConstraintIsEnforced builds a base manifest by filling every field the schema declares, then breaks one cell at a time and requires the validator to refuse each break. #315 added load.traffic.max_age to schemas/manifest.v1.json without a tuning override, so the fixture filled that field with the application's name and the validator refused "web" as a duration. The base manifest was therefore refused before any cell was broken, and in the test's own words every cell measured against it says nothing. The gate reported a failure it had not found.
Separately #327 created engine/internal/manifest/manifest.v1.json, a copy of the published schema read through go:embed, from a base that predated that same field. Neither pull request conflicted with the other and both passed their own gates, because the two changes travelled different paths and git had nothing to compare.
Main carried both for thirteen commits. Every branch inherited the red through the merge ref, and three separate lanes spent time hunting their own code for a failure that was never theirs.
The override is the field's own documented default of 336h, beside the two max_age overrides already present for the same reason.
The pinned constraint count moves from 593 to 600 in the same change, and it is the same defect one layer down. require stops at the first failure, so once the base manifest was refused the count assertion below it was never reached. 593 was written down by a lane that could not have run it, and the seven that were missing are exactly load.traffic: profile and max_age with a type and a maxLength each, plus the block's own type, additionalProperties and required.
The merge tool refused a pull request for four characters inside a code span, on the pull request whose subject was that rule.
tools/prmerge strips fenced blocks and inline code spans before it looks for an attribution trailer, and the comment on those two patterns says both are removed before a text is judged, naming prosecheck's punctuation exemption as the precedent for doing it. Only the attribution check actually did. The punctuation check matched its patterns against the raw text.
So within one file the attribution rule could tell a mention from a use and the punctuation rule could not, and the punctuation rule is the one that blocks the merge. CLAUDE.md permits a command flag inside backticks in as many words, and prosecheck accepts it, so the two gates gave opposite answers about the same four characters and the stricter one was not the documented one.
That comment already records this exact failure happening once before, to the attribution rule, under the heading that the rule could not tell a mention from a use and refused its own pull request. It happened again to the rule two hundred lines below it.
prose now applies the same two replacements. Both directions are pinned by a test and each was mutation tested: removing the stripping refuses the code span case again, and stripping everything stops refusing prose punctuation. A markdown table separator is three hyphens and was never matched by the pattern, which the test records so nobody adds an exemption for it.
The manifest's published schema declared 570 constraints and the engine kept 394 of them.
schemas/manifest.v1.json is not an internal artifact: editors validate against it, it is the document you learn the manifest from, and the reference table on the site is generated out of it, so every bound in it reads as a promise. 168 of those promises were kept by nothing. A maxLength of 40 on a service name accepted a name of any length, a minimum of 1 accepted zero, an enum accepted a value not in it, and the only symptom would have been an editor calling a manifest invalid that the engine ran, or the reverse.
The gap was invisible because every instrument near it answered a nearby question. One test compares the field NAMES on the two sides and says in its own comment that it deliberately compares nothing else. manifestcheck reads the documentation's manifests for unknown keys. fieldsweep asks whether a field is read at all. The validator's own comment stated the gap in one line and read as a note rather than a defect.
The engine now enforces those bounds at parse time, driven by the published schema itself rather than by a second hand written spelling of it, so the two cannot disagree. Every hand written check stays: they carry better messages, and they carry the cross field rules a JSON Schema cannot express, such as a database declaring both a source and a seed. Where both would speak, the hand written message wins, so one mistake is still reported once.
A load test ran through a table being unavailable for thirty seconds and reported that nothing failed.
Measured on this repository on 2026-09-06. A migration was made to hold AccessExclusiveLock on events and its partitions, and pg_locks sampled from a second connection confirmed nine relations locked at once for the whole window. af load smoke ran straight through it and reported 0.0 percent failed, with p95 improving from 41ms to 17ms. The four safe_routes in this repository's own manifest are written by hand and none of them reads that table, so the run was blind to the outage it was running through. It was not a weak result. It was a green one.
The fidelity report said worse than nothing about it. Any shape a source produced was reproduced, so four routes somebody wrote from memory were reported in the same words and with the same verdict as a mix read from a week of production telemetry. On this repository's own manifest the sentence it printed was 4 routes read from , at 5 requests a second, with the source name missing because there was no source.
af traffic record counts what production actually served, from an OpenTelemetry trace export or a combined format access log that a collector or a reverse proxy already wrote, and writes a committed profile: the endpoint mix, the arrival rate, the peak concurrency and the per route p95. It carries no request body, no header, no query string and no identifier. Nothing in it opens a socket, there is no agent and no SDK, and the file is one a team already has, which is what makes it committable and what makes it readable by the pull request check that can reach production not at all.
Declared under load.traffic.profile, three things change. The fidelity report's traffic dimension states the fraction of production's requests a run actually sends and names the heaviest route it never touches, so a run that cannot fail on a route says so instead of reporting a reproduction. A run with no p95 baseline can take one from the profile, which is what load.thresholds.p95_increase needed to be able to fire at all: this repository's own manifest says of that threshold that "it was set to 0.5 here and had never once been able to fire". And a profile older than load.traffic.max_age is refused rather than quoted, the way a stale golden is.
af traffic show prints what production serves, busiest route first, with a mark against every route the run reaches and the safe_routes lines that would cover the ones it does not. It prints them. It does not write them: this measures and states, and the manifest confirms it.
An application that speaks gRPC could not run inside an environment at all.
The sidecar terminated TLS on its inspected path with no ALPN offered and then read HTTP/1.1 out of whatever was inside. gRPC is defined over HTTP/2, and HTTP/2 over TLS is negotiated with ALPN, so a gRPC client closed the connection in the handshake with "missing selected ALPN property" before it wrote a single request. Nothing in the decision log explained it, because nothing had been decided yet: the refusal came from the application's own client library. Five of the six Google Cloud services an SDK reaches by default are gRPC, which is how this surfaced, but the reach of it was every gRPC client anywhere.
Cleartext had the same hole by a different route. A client built with insecure credentials, which is what every emulator's documented setup uses, opens with the HTTP/2 connection preface, and the plain listener's HTTP/1.1 reader turned that into a request whose method was PRI and which carried no Host header, then refused it for naming no host.
Both connections are now read as the protocol they are, and every decision the sidecar makes for an HTTP/1.1 request is made for an HTTP/2 one: the host is taken from the authority the client wrote, the live credential tripwire runs before the mode is acted on, a blocked host is still blocked, and the sandbox substitution still happens. Bodies stream in both directions, because a gRPC call is long lived and a bounded read on it would end the stream early and leave the client reporting a status the server never sent.
an-application-that-speaks-grpc-could-not-run-in-an-environment
The image pin gate read files git has never heard of, and refused a fully pinned repository because of one.
The check added in #338 enumerated what it scans by walking the working tree for shell scripts. A working tree holds more than a repository declares. The harness that mutation tested that very gate is an untracked script in a lane's worktree, it carries a moving tag in one variable and a deliberately wrong digest in another, and the gate read both and reported the tree as unpinned while every real declaration in it was correct.
Every required context stayed green throughout, because a clean checkout has no scratch file. So the only person who could ever see this is somebody running just gate with a scratch script open, and from there it reads as the gate being broken rather than as the gate looking at the wrong thing. That is how a working gate gets switched off.
The enumeration now comes from git ls-files, because what this repository declares is what git tracks. A listing that cannot be obtained fails the test rather than skipping it, since "I could not look" and "there was nothing to find" are different answers and only one of them is a pass.
just generate could not finish the job in one pass, and the tree it left behind failed the generated file gate while every generator in it had just reported success.
just generate could not finish the job in one pass, and the tree it left behind failed the generated file gate while every generator in it had just reported success.docsembed embeds every documentation page into the copy the MCP server serves. Six of those pages are themselves generated: errors.md, the schema reference, cli.md, lint-findings.md, transforms.md and the dashboard guide. docsembed ran sixth, ahead of four of the generators that write them, so one pass embedded the previous wording and a second pass was needed to settle. It was invisible until one of those pages actually changed, which is why it survived: the ordering is only wrong on the day somebody edits a schema.
docsembed now runs last, after every generator that writes under docs. The rule is derived from the ledger that already records which generator owns which path, so a seventh generated page extends it without anybody remembering to.
Two commits reached main on 2026-09-08 with no CI verdict at all, and nothing said so.
Four pull requests merged inside four minutes, and the API records the rest: the run on 09078be0 was in progress and survived; the runs on 39a771ff and f5e2ef68 were cancelled 36 and 33 seconds after they were created, one second after the next run was created in each case, and both carry an empty job list. Not one check ran on either commit.
cancel-in-progress: false protects a run that has STARTED and nothing else. GitHub holds one PENDING run per concurrency group, so each merge took the place of the one queued behind the run in progress. The comment in ci.yml opened by saying a run on main is never cancelled and closed, thirty lines later, by saying a pending one still is and that fixing it would need a merge queue. Both halves were in the file, the first is what people read, and the second was wrong: ci.yml now keys its concurrency group on the commit for a push to main, which gives each one a group of one, so no main run can supersede another. security.yml had already used the same technique for its daily scan three days earlier and nobody carried it back. The cost is runner minutes, because a burst of six merges now runs six full CI runs where two ran before, and past the account's concurrency limit they queue. A queue is a delay. A cancelled pending run was a permanent hole, and a hole cannot be re-run into existence once the next merge has landed on top of it.
The configuration change removes the cause this had. It cannot remove the condition, because a run can still be cancelled by hand, a job can still hit its own timeout-minutes, and the expression can be edited back. So tools/mainverdict reads what CI concluded on each of main's recent commits and refuses a branch carrying one that was never judged, running in .github/workflows/ci-watch.yml on every completed CI run on main and daily, and leaving one issue open until main is whole again. It passes failure along with success, because a red main is already the loudest thing in the repository; what it catches is the silent case, where cancelled and skipped render in a list exactly as a pass does. It has a third answer for the case where the run history it read does not reach the commits it was asked about, because reporting a branch clean on having read the top of it is the defect this repository keeps finding in its own instruments.
tools/cigate already refused a cancelled run, and still does. Its explanation of what cancelled means on main was written against the old setting and now says what is true.
The prose gate refused files that can never ship, so it was red on a working copy and green in CI on the same tree, permanently.
prosecheck selects files by walking the filesystem. CI checks out from git. So a file that exists on a working copy and not in the repository is scanned by one and invisible to the other. PROGRESS.md is exactly that: it sits at the repository root, it is hidden through .git/info/exclude so it never appears in a diff, and earlier handovers wrote commands of the shape git grep "install(" -- ee/web web into it. Those are end of options markers and they are correct as written, which is the collision that already stops the double hyphen rule from being widened past the gated trees.
The result was five findings locally and none in CI, on every checkout, with no way for CI to see the disagreement. Anyone running the gate before pushing got a red that was not theirs, on a file they could not commit, and the two obvious reactions, editing the handover or believing their own change broke it, were both wrong.
The gate now skips what git IGNORES, and that is not the same as what git does not yet track. An ignored file can never be committed, so it can never ship, so styling it is meaningless. A merely untracked file is one git add from shipping and is still read, because skipping it would pass a new page locally and fail it in CI, which is the same disagreement pointing the other way. Both directions are pinned by a test, and each was mutation tested: removing the filter refuses the ignored and excluded files again, and widening it to drop untracked files refuses the untracked one.
Outside a work tree, or when git cannot answer, every file is kept. This gate's own tests drive it with temporary directories, and a check that quietly stopped looking there would be worse than one that occasionally reads a file it need not.
The enterprise image proof asked the wrong server whether the database was ready, and got a yes from one that was about to shut down.
enterprise-image-proof.sh waited with docker exec pg_isready and no host, which asks over the Unix socket. The Postgres image's own entrypoint runs a TEMPORARY server to apply initdb, started with listen_addresses='', so it answers the socket and nothing else. The wait loop therefore broke on the bootstrap server, and the confirming probe landed in the gap while that server was shutting down and the real one had not yet started.
On f065f64c that is exactly what happened: the loop broke and FAIL: the database never became ready was printed 1.4 seconds later, with the container's own log showing received fast shutdown request 9 milliseconds before the probe. The window is about 150 milliseconds against a one second poll, which is why this passed on the pull request and on the two mains before it and failed once.
Both probes now ask over TCP, which the temporary server does not listen on, so the first yes is the server the rest of the proof talks to. Measured rather than reasoned: polling both every 400ms against this digest, the socket answered a full interval before TCP did, socket=UP tcp=down at one sample and both up at the next.
Worth recording that pinning the image in the previous change is likely what made this fire. A digest reference is not served from a cached postgres:17-alpine, so the pull ran fresh and moved where the poll landed. The race was already there; the pin changed the timing that hid it.
The fidelity report said a wildcard capture was a faithful substitution, and for half the hosts such a rule matches the sidecar refuses it.
af fidelity classified every capture rule the same way: substituted, recorded into the inbox and answered with the provider's documented success shape. That sentence is true for a host somebody named that this build has a handler for. It was also printed for a rule covering a domain rather than naming a host, which the sidecar answers with a 403 having recorded nothing whenever it has no handler for whichever host arrives. A delivery path written as *.zapier.com read in the report as captured into the inbox and posted nowhere at run time, and the one document that exists to say what a copy does not reproduce was the thing hiding it.
Calling such a rule refused would have been the same mistake pointing the other way. The guard is !known && !d.NamesHost(), so a request under *.resend.com IS captured and one under *.zapier.com is not, and neither answer can be read off the rule. It is reported unmeasured with the reason, which leaves the score and is named in the exclusions rather than counted as an answer nobody checked. The mock branch of the same function already treats the same rule shape that way.
Half of the same overstatement is left standing and named rather than papered over. Whether a host somebody DID name is answered with the provider's own body or with the generic empty object depends on the sidecar's handler list, which lives in package main and cannot be imported by the report. This build has no Zapier handler, so a named Zapier host is answered {} and a client checking status reads that as a failure. Four new tests in the sidecar pin its actual behaviour for a Zapier hook and a HubSpot contact write, including that one as a stated limit, so a registry both sides can read has something to be checked against when somebody builds it.
af net explain has the same blind spot from the other side: it gives the identical CAPTURE verdict for both rules. Also not fixed here, for the same reason, because a fifth copy of a host matching list is how the four this repository already has drifted apart.
Main went red on a gate that refused two images nobody could pin, and the same gate was right about a third.
TestEveryContainerImageIsPinnedToADigest matches any YAML image: field. Two of them in control-plane-image.yml are strategy.matrix.include values naming the images that workflow BUILDS AND PUSHES, so no digest for them exists until the job runs. The gate landed in one pull request and the second matrix row landed in another; each was green alone and main went red on the pair.
The matcher is narrowed by CONTEXT rather than by shape. The tempting repair is to skip any value carrying no registry, tag or digest, since control-plane carries none. That would also skip a bare image: postgres in a services block, which resolves to latest and is exactly what this gate exists to refuse. A hole opened while closing a false positive is worse than the false positive, so the new test pins both directions: a build matrix row is ignored, and a bare name in a services block is still refused.
The third finding was not a false positive. enterprise-image-proof.sh really did run postgres:17-alpine with no digest, which is the moving tag this gate was built to catch, and it is now pinned to the digest every other site names.
Narrowing by context left one hole, and it is checked rather than merely stated. A matrix row can just as easily name an image the workflow PULLS, read back as image: ${{ matrix.pgversion }}, which is the ordinary way to matrix a database version. That reference was invisible from both ends: skipped in the row for sitting in a matrix, and skipped at the field for being an expression. So every matrix key an image: field interpolates is now checked in the rows that carry it, and only those keys are.
The enterprise entry point ran and no image built it.
ee/web/server is the process that mounts single sign-on and directory provisioning, and the lane that wrote it said so plainly rather than quietly: no image builds it, so it runs and is not shipped. There was one control plane Dockerfile, deploy/docker/control-plane.Dockerfile, and one workflow building it, with no matrix in it at all. So four finished, tested, licensed packages were mounted by a binary that no deployment anywhere could pull, which is this repository's own signature defect, one layer out: something that works when you run it and is absent from the artifact.
Nothing pointed at the missing image, and that is the better half of the news. No compose file, no chart value, no Terraform image reference and no runbook named an enterprise image, so there was no configuration waiting on something that would never exist. The gap was one sided and closing it breaks nothing.
The obstacle was real and it is a boundary rather than an oversight. The repository root .dockerignore excludes ee, and ee/README.md names that exclusion as the fourth of four mechanisms keeping enterprise code out of the community artifact. An enterprise image needs ee in its context. Deleting the line would have traded a real edition boundary for a packaging convenience, and it would have done it silently, because no check downstream would have gone red. The enterprise Dockerfile carries its own ignore file beside it instead, which BuildKit reads in its place, so the community context still cannot see ee and neither list is a compromise. The community build now asserts that its own image contains no enterprise tree, because that is the direction with no other alarm on it.
The image is the community image plus registrations and its layout says so. The enterprise packages reach the community ones through relative symlinks npm wrote from file: dependencies, and a flattened layout leaves those dangling rather than failing, so the image would build clean and the process would die on its first import. The repository's own shape is preserved, and the working directory is where the community image puts it so that node bootstrap.mjs still means what the Helm chart, the Terraform job and the self-hosting page say it means. af-operator hardcoded one of the two layouts; it now finds either and refuses loudly when it is in neither, rather than handing node a path that does not exist.
Built is not shipped and shipped is not working, so the workflow proves the artifact rather than the source. Three containers, one database: the licensed enterprise image answers a SAML metadata request with a document carrying its own assertion consumer URL, the unlicensed one refuses the same request with 402 naming sso and the variable to set, and the community image built from the same tree answers 404. Without the third, 402 and 404 are two numbers and "refused by the gate rather than by absence" is a claim about a code path nobody watched.
A required context reported a verdict about a change it never read, and then healed itself.
npx --yes cspell floated on the dist tag. On 2026-09-08 it resolved a version whose own sibling package had not finished publishing, and the spelling step of the www job died with "No matching version found for cspell-gitignore@10.3.0". Twenty minutes later the identical command passed on another pull request. Nothing was checked in between and nothing said so.
That failure is the worst shaped one this repository has. In the summary view it is indistinguishable from a real spelling mistake, and then the registry catches up, so the next person re-runs the red job, sees green, and learns that re-running a red job is how you fix one.
cspell was pinned in an earlier change. This adds the gate for the class. TestEveryToolIsFetchedAtAPinnedVersion refuses a workflow or a recipe that fetches a package with npx --yes and no version. It reads the justfile as well as the workflow, because an unpinned recipe beside a pinned CI step is exactly the disagreement gatecheck exists to stop: the developer's run and the gate's run would resolve different software.
What it cannot see is written into it rather than implied. apt-get install resolves against Ubuntu's archive every run and is not practically pinnable. And a pin answers which thing, never whether it arrived: a tool pinned to a commit and to a version still failed the same day with exit 22 fetching its own tarball, which is a different failure with a different remedy.
Two implementations of one decision, with a comment claiming they were held together by a file that was not in the tree.
ee/engine/license parses and evaluates licences in Go and runs in the engine. ee/web/server/src/license.ts does the same in TypeScript and runs in the control plane, because single sign-on and provisioning are mounted there and the control plane has no engine in it. That file's own header names the hazard, and then lists three things holding the two sides together. The second was this, in the present tense: ee/license-vectors.json is a corpus of tokens with the verdict each one must produce, one suite here reads it and one in the engine reads the same file.
None of it existed. Not the corpus, not either of the two files it named, not a single occurrence of the string in either suite. ee/README.md records three claims of exactly that shape and the lesson written under them is that a claim resting on an invented mechanism reads identically to a true one until somebody goes looking, and that the person who goes looking is usually the customer.
The corpus exists now, emitted by the Go side because the Go side is the definition, on the same pattern the community tree already uses three times for the policy engine, for webhooks and for mock packs. Twenty one cases, covering every state and every refusal either implementation can produce, checked by a test on both sides that the coverage is complete rather than merely present.
It found two divergences on its first run, which is what the paragraph had been promising to prevent.
The first is the one that reaches a customer. The engine has always refused to permit a feature no build enforces anywhere, filtering them in Evaluate. The control plane did not: it permitted every feature a licence named. So one key made the engine say billing is not permitted and the control plane print that it is, in one deployment, to one buyer, with neither binary aware of the other. It is not reachable through the two gates the enterprise entry point mounts today, and it is reachable through the startup line that reports what the licence permits right now, which is the line an operator reads to check what they bought.
The second is at a boundary. At the exact instant a grace period ends the control plane returned negative zero days, which JavaScript treats as a value distinct from zero, and the engine returned zero, because it is an integer and has no such value. Invisible in a rendered string and not invisible to anything comparing the two.
The corpus also binds two lists that were duplicated across the two languages with a comment saying they matched and nothing checking that they did: every feature a licence can carry, and the features no build ships.
There is no signing key in the repository and this adds none. The corpus carries the public half in the form an operator pastes into a deployment, and the tokens are fixtures, so regenerating recomputes the verdicts and leaves the tokens alone: a semantic change shows up as a diff of verdicts rather than of the whole file.
Four steps in required contexts reported a pass without having looked.
if grep -rn ... engine tools; then reads grep's exit 2, which means it could not search, as no match, so the if is false and the step passes. Rename the search root and the check is green forever. Measured: with engine/ renamed away the old edition boundary step exits 0, and with web/packages renamed away so does the enterprise one. Both refuse now, and both still refuse a real enterprise reference, so neither was loosened to stop it complaining.
for path in $(grep ...) over the error catalog runs its body zero times when the grep stops matching, and exits 0 having checked no page. The step directly above it already guards exactly this, with a count and a comment saying why. Measured: rename the docs: key and the old loop exits 0; the guard now reads 46 paths on this commit and refuses anything under 20.
The step called "The suites did not skip" opened a TCP socket to Postgres and examined nothing else, while the next step's npm test --workspaces --if-present is the actual skip mechanism. It is renamed to the question it answers, and a second step now refuses a workspace whose test script has gone and refuses a workspaces glob that resolves to nothing.
schemas/manifest.v1.json closed runtime.provider to local and kubernetes, so a documentation page could not show the one manifest line a registered cloud runtime exists to answer.
schemas/manifest.v1.json closed runtime.provider to local and kubernetes, so a documentation page could not show the one manifest line a registered cloud runtime exists to answer.manifestcheck refused the page, and the reason it gave was false:
runtime.provider is "ecs", which is not one the manifest accepts. The engine refuses it with AF-MAN-002. It has to be one of: kubernetes, local
The engine does not refuse it. Nothing validates a manifest against the JSON Schema at parse time, and the switch in newRuntime consults the runtime registry before it refuses anything, so a build that carries an ecs runtime accepts that manifest and runs the environment. The gate said no about the right field for a reason a reader would believe and that was not true, which is worse than saying nothing.
runtime.provider now carries no list, the way datastore.engine carries none, and for the reason that field already records: a manifest naming a runtime this build has no provider for is refused by the provider lookup, by name, against the runtimes that build actually has, which says more than an unknown value would. A schema that enumerates providers is a schema somebody has to edit every time a build registers one, which is the opposite of an extension point.
manifestcheck's message now reports what the gate actually read, which is the schema, and stops asserting what a build will do. The enum half of that gate had no test of its own until now.
The coverage gate could not tell a package at 100 percent from one nobody measured.
tools/coverage built its package list from the COVERAGE PROFILE and never from thresholds.yaml, and thresholdFor was called only inside that loop. So a package the thresholds NAME and the profile does not carry was never iterated, never compared to its floor, and never printed even under -all. Nothing compared the two lists.
internal/masking sits in the strict tier at 100 percent, described in the thresholds file as a package where a missed line is a masking, isolation or redaction failure. Measured against a profile with that one package removed and everything else present, the old gate prints
coverage: 19 packages measured against C.5 coverage: every package meets its threshold
and exits 0. The package with the strictest floor in the repository had not been measured at all, and the gate whose entire job is to stop a number being a claim rather than a measurement reported that as success.
Any tier entry the profile does not carry is now refused by name and by tier, in the same terms vulncheck already uses for a suppression that suppresses nothing: it is either tests that stopped running, a package that moved, or dead policy that belongs deleted. The same profile with the package restored still passes, so the refusal cannot be satisfied by refusing everything.
Seven test commands reported success after running no tests.
Every JavaScript suite in this repository is started by handing node a shell glob: node --test test/*.test.ts and six variations of it. When the glob matches nothing, sh passes the pattern through as a literal, node's own runner treats it as a pattern of its own, matches nothing, prints pass 0 and exits 0. Rename runner/test, or rename the files inside it, and the runner required context goes green having executed nothing. The same shape covers www, console, api, and the three workspaces behind the control plane required context.
Measured rather than reasoned about. With runner/test renamed away the old command exits 0 and reports pass 0; the same tree with the guard exits 1.
Each script now requires its own files to exist before node is started. It closes the case where there is nothing to run. It does not close a file that exists and registers no test, and it does not close npm run test --workspaces --if-present, which silently skips a workspace whose test script has gone. Both are named here rather than left to be discovered.
ee/web holds four complete enterprise packages.
ee/web holds four complete enterprise packages.sso is ten source files of SAML 2.0 and OIDC with a Keycloak conformance suite. scim is fourteen routes with an orderings suite. Both are driven end to end over real HTTP against a real Postgres by their own tests, both are documented, and until now neither was reachable by any customer, because no production binary ever called install() on either of them.
Three files in the tree describe the process that would have mounted them. web/apps/api/src/extensions.ts says "the enterprise entry point imports this and calls it". ee/web/sso/src/routes.ts says "Registered by the enterprise entry point". ee/web/sso/test/harness.ts says it registers the extension "the way the enterprise entry point registers it". There was no enterprise entry point.
It could not easily be written, which is why it was not. main.ts was the whole program: six hundred lines of environment reading and server construction at the top level of a module, so a second edition could only be built by editing a file the community build must not be able to see, or by copying every line. The copy is the same class of defect as the one being fixed, because two copies of a configuration drift and the symptom is a bug only a paying customer can reproduce.
The body is now boot.ts and a function with one seam, main.ts is three lines that call it, and the enterprise entry point in ee/web/server adds registrations and nothing else. One reader of every variable, one construction of the server.
Two things that had never been read by anything now are. The control plane has never had a licence reader: ee/engine/license runs in the engine, and ee/web/sso/src/provision.ts has always taken the seat limit from "the host", saying the licence is parsed elsewhere and the number handed over. There was no host, so the seat limit AF-EE-004 sells has never refused a single member.
The gate mounts the routes and refuses them rather than not mounting them. Not registering is cheaper and answers 404, which says the feature does not exist and is indistinguishable from a build that never had it, a renamed route, a proxy that dropped the path, and the state this fixes.
The organization rule requiring a column to be masked was checked against a list the engine never filled.
extension.EnvironmentRequest carried MaskedColumns, the enterprise policy read it, and no code anywhere wrote to it. So required_masked_columns was evaluated against an empty list on every environment. An organization that configured the rule got a licence that granted the feature, a startup line that printed the rule, and no refusal on any repository ever. The only thing that kept it from refusing everything instead is that a policy naming no columns returns before it looks.
It could not be filled where it was read. The policy hook is asked before anything is created, deliberately, so that a refusal leaves nothing behind, and what it has at that point is a manifest. A manifest enumerates services, not columns. Expanding a pattern like *.email against the tables a manifest happens to name would have refused every repository whose masking rules reach a table the manifest does not mention, which is most of them.
So there is now a second socket, extension.MaskingHook, asked during a golden refresh: after the engine has read the database catalogue and assigned its rules to it, and before the executor rewrites the first row. It carries the columns the plan will rewrite and the whole catalogue they were read from. A refusal there means the golden is never published, and an unverified golden cannot be branched, so no environment can hold data the policy refused.
Having the catalogue also makes the rule mean what it says. *.email is now satisfied only when every email column in the database is masked; before, one match anywhere was enough, so a plan that masked users.email and left contacts.email readable passed a policy written to stop exactly that.
A golden published before a policy was tightened is not re-examined. Refresh it, and the new rule decides whether it may be published.
The check that said your generated files were stale was wrong about three of the eleven pull requests it stopped.
One CI step ran thirteen generators and then compared, and it was named "Generated files are current". Four of those generators are go test <package> -update-something, which runs the whole package, so a test in the engine's cli package that has nothing to do with any committed artifact failed under that name. On one morning it said that about three branches whose generated files were fine.
The other eight had really drifted, and there the step printed a diff and stopped. A diff names the file that moved. It does not name which of the thirteen generators owns it, so the only remedy a reader could infer was just generate, the whole set, several minutes of npm and Docker. Six of those eight needed go run ./tools/docsembed alone, which takes about a second.
The generating and the comparing are two steps now, so the name asserts only what the step checked, and the comparison is tools/gendrift, which groups the drifted paths under the command that rewrites each one. It also refuses two things the old comparison could not see: a generated file that is new and therefore untracked, which git diff cannot report at all, and a ledger row pointing at a path no longer in the tree, which would otherwise go on printing a number about a file it had stopped comparing.
The local gate compared a hand written list of paths in the justfile while CI compared the whole tree, so the two asked different questions. Both run this now, and only CI passes -strict, which additionally refuses a changed path no generator claims.
the-step-that-said-generated-files-were-stale-when-a-test-had-failed
The edition boundary check reported a failure it had not tested.
edition boundary check reported a failure it had not tested.The step deleted ee, ran the whole community suite, and called anything that came back red an edition violation. On the night of 2026-09-08 that was 13 of the 25 open pull requests, and not one of them was an edition violation: nine distinct engine test failures accounted for all thirteen, every one of them reproducible with ee present, and the engine job was red on all thirteen for the same reason. Thirteen branches were sent to look for an enterprise import that was never there.
The gate now runs the packages it saw fail a second time with ee back. One that fails both ways is the engine job's finding and is reported as such; one that passes with ee and fails without it is the violation, and is still refused; one that cannot be told apart from a flake is reported as undecided rather than as either.
The spelling gate failed for twenty minutes because npm was mid publish.
npx --yes cspell resolved whatever the tag pointed at, and on 2026-09-08 that was a version whose own sibling package was not published yet. The step died with "No matching version found for cspell-gitignore@10.3.0", the required context www went red on a pull request whose prose was clean, and the same command passed on the next pull request twenty minutes later. Nothing was checked in between and nothing said so.
Every action in this workflow is pinned to a commit and vale is pinned to a version and a checksum. cspell was the one gate left floating, so it was the one that could report a verdict about a change without having read it. It is now pinned to 10.3.0 in the workflow and in the justfile recipe that has to match it.
Single sign-on and directory provisioning are now refused for an organization that is not entitled to them, and an organization can now refuse operator support access to its own account.
All three were built, tested end to end and enforced by nothing: a bearer token naming an organization was the whole of the authorisation for SCIM, a handle in a URL was the whole of it for single sign-on, and an operator could act as a member of any customer with no way for that customer to say no.
Losing the single sign-on entitlement relaxes the requirement to use it rather than closing the account: the enforced flag stays on the connection, GitHub sign-in works again, and restoring the entitlement restores the requirement unchanged.
A licence can no longer be issued for billing or enterprise_dashboard. Nothing in the product enforces either, so a licence naming one verified, reported itself active, printed the feature, and changed nothing. tools/licensegen refuses to sign one and the verifier never permits one.
The scheduled site smoke installed a browser the runner would never launch.
sitesmoke.yml installed the runner with npm --prefix runner ci and then ran npx playwright install from the repository root. There is no package.json at the root, so npx did not resolve the runner's pinned playwright at all. It fetched the newest one from the registry and downloaded browsers for that version instead. The runner then launched its own pinned playwright, looked for a revision nothing had installed, and could not start a browser on either hostname.
The smoke reported COULD NOT TELL and exited non zero rather than passing, which is the behaviour it was built for, so this cost a red rather than a false green. deploy.yml already carried this fix and its explanation, and ci.yml, dogfood.yml and antifailure.yml have run both commands under working-directory: runner since they were written. This file was the one site that still installed from the root.
Caps.CopyOnWrite says a branch shares storage with its golden, and therefore that branch time does not grow with the database.
Caps.CopyOnWrite says a branch shares storage with its golden, and therefore that branch time does not grow with the database.Five providers declared a value for it. Nothing in the repository could tell whether any of them was telling the truth.
The database conformance suite never read the field at all. Its Capabilities_AreSelfConsistent behaviour reads Branching, SupportedVersions, ExpectedBranchLatency, MaxConcurrentBranches and Name, and stops. The datastore suite beside it reads the same field twice, and both reads are about self consistency: copy on write without branching, copy on write without a golden. Neither asks whether the claim is true. So a provider could declare copy on write alongside branching and a golden, copy every byte on every branch, and pass both suites, which is exactly the defect this repository keeps finding in its own instruments.
It is not a small field. Copy on write is the distinguishing commercial claim of the whole cloud database wave, the sentence the product is sold on is a sentence about seconds, and the number a buyer is given comes from it.
CopyOnWrite_BranchTimeMatchesTheDeclaration measures it, in the units the claim is made in. Two goldens are built, one carrying half a gibibyte of ballast the suite writes through the mask callback every provider already calls, and both are branched several times alternately, taking the fastest of each. A provider declaring copy on write must not have grown; a provider declaring it false must have. Those two requirements are complementary, so one of the two possible declarations is refused on every run whatever the stopwatch says, and there is no reading under which both pass.
The boundary is measured rather than fixed: twice the spread the small golden's own branch times showed during that run, floored at a quarter of a second. So the check is sharper on a quiet machine and refuses to accuse an honest provider on a loaded one, and every run prints the copy rate it was actually able to refuse, because a pass whose bound is invisible is a pass nobody can weigh.
The false side is enforced as hard as the true side. A provider that understates its own branching is wrong in the same published table as one that invents it, and a check that only fires in one direction cannot tell a correct declaration from an unexamined one, because both look like the absence of a complaint.
Caps.ExpectedBranchLatency had the same shape of hole and it is closed in the same commit. Its documentation has always said "the suite asserts against it, so a provider that gets slower fails rather than quietly degrading", and what the suite asserted was that the number is greater than zero. A provider could declare eight seconds, take three minutes, and stay green. Branch_IsWithinTheDeclaredLatency makes the sentence true.
Both behaviours ship with the broken fakes that prove they can say no, in the same commit, on both sides of each boundary.
A service asking for resources.cpu and resources.memory is now given them.
resources.cpu and resources.memory is now given them.Both keys had been in the schema and in the reference since version one. The Kubernetes runtime set no ResourceRequirements on any container and the local runtime passed an empty container.Resources, so every environment this engine ever placed asked for nothing. The release before this one closed the silence by refusing both keys, which was the right answer for exactly as long as they did nothing.
They do something now. On Kubernetes each value becomes the container's request and its limit, which puts the pod in the Guaranteed quality of service class. On the local runtime, where there is no scheduler to reserve anything, it is the daemon's own cpu and memory constraint. A dimension the manifest did not name is left out rather than set to zero, so an existing repository gets byte for byte the Deployment and the container it got before.
Requests and limits are the same figure on purpose. The familiar shape, a small request under a larger limit, is where a node gets oversubscribed: every container is placed against its request and then grows into its limit, so a machine that fits ten environments on paper runs eleven and the eleventh takes memory from the others. The symptom is a workflow that reads as flaky, and a twin whose failures belong to the machine rather than to the change under test is worth less than no twin. One number also means environments per node is a division rather than a guess, which is the thing nobody could do while every environment asked for nothing: that was not a large number, it was an undefined one.
Honouring the request creates a second problem, and AF-RUN-047 is the answer to it. A request larger than anything on the cluster is accepted by the API server and then never scheduled: the pod sits Pending with an event nobody is watching, and af up waits out its readiness timeout and reports a service that did not start, which reads as a slow cluster. The sizes are now checked before anything is created, against each schedulable node's allocatable minus what the pods already on it requested, and a refusal names the shortfall. Two necessary conditions and neither sufficient: every instance has to fit on some single node and the total has to fit in what is free. Bin packing is still the scheduler's job, and a cluster that will not let af list its nodes says so on the progress channel rather than reporting a check it never made.
af status reports the size the runtime ACTUALLY applied, read back off the running pod or off the daemon's record of the container. A runtime that accepts a cap and emits none reports exactly what a correct one reports, which is why the runtime conformance suite gained two behaviors rather than one: the size a runtime says it applied, and the memory cap the service's own process is subject to, read out of its cgroup from inside.
Refused rather than dropped: a quantity that will not parse, a CPU share finer than a thousandth of a core, and a memory size with no unit, because Kubernetes reads memory: 512 as 512 bytes and nobody who writes it means that. There is deliberately no ceiling in the manifest. A constant cannot know the machine, so 64Gi is absurd on a laptop and unremarkable on a cluster node, and "this cluster does not have that" is a better answer than "the manifest may not say that".
The three module updates dependabot proposed in #288 could not build, and the reason was a fourth module it did not touch.
github.com/charmbracelet/x/ansi moving from 0.10.1 to 0.11.8 is a breaking change to ansi.Style. github.com/charmbracelet/x/cellbuf was pinned at the pseudo-version v0.0.13-0.20250311204145-2c3ea96c31dd, which is compiled against the old shape, so seven CI jobs failed identically:
cellbuf@v0.0.13-.../cell.go:198:10: b.SlowBlink undefined (type ansi.Style has no field or method SlowBlink)
cellbuf arrives indirectly, which is why the update group did not carry it. The versions are readable from the module proxy rather than guessed: cellbuf v0.0.13 requires ansi v0.8.0, v0.0.14 requires v0.11.0, and v0.0.15 requires v0.11.5. So v0.0.15 is the release that matches, and it is bumped here alongside the three.
ee/engine/go.mod pins all four as indirect and moves with them.
The failure is worth naming rather than absorbing: an automated bump that compiles is not the same as an automated bump that is complete, and a group that updates a module without its dependent produces a tree that no single pull request in the group can fix.
The fidelity report said a twin held no events while it was holding a masked, verified copy of production's.
The datastores dimension was built from the manifest alone. It read the stance, saw golden, and reported the store absent with the four things it did not have named: no golden, no attestation, no tables and no rows. That was the right answer while nothing could branch a second store, and it stopped being the right answer when af golden refresh learned to make a golden of every declared store and af up to branch each one. From then on an environment holding a masked, verified, branched ClickHouse was reported as one that had none. The product documented the defect about itself in the manifest reference, which is better than hiding it and is not a fix: an instrument that understates a twin is better than one that overstates it and it is still an instrument saying something untrue about what it can see, and the report is the thing this product asks you to trust when nothing else is.
A store declared golden that the environment branched is now reported from the branch, in the two components the primary database has had all along: data says how many tables and how many rows it holds and which golden it came from, and provenance says whether that golden's signed attestation still matches its own signature. They are separate for the reason the database's two are separate, and the reason is sharper on the second store because the second store is where the events are: a branch full of production's shape whose provenance nothing can check is not the same result as one whose attestation verifies, and a single verdict over both would hide whichever failed.
The data component is unmeasured rather than reproduced, and it says why. Nothing here records what production's second store holds, so whether the branch reproduces it is unknown, and calling it a reproduction would be the defect the primary database closed one release earlier: a branch of two hundred rows reported as reproducing a production of four billion, in the same words and with the same verdict as a full copy. The committed volume profile that answers this under database.volume has no equivalent for a second store yet. The report names that gap rather than counting the store as a copy of production nobody checked.
Everything else about the dimension is unchanged, deliberately. A store declared golden that nothing branched is still absent with the same four facts named, which is the line that makes the score go down on the stack this was all added for. A store declared empty, derived or topics_only is still unmeasured, because nothing here starts one, rebuilds one or creates a topic in one, and a store reported reproduced on the strength of a declaration would be the report believing a manifest instead of an environment. A store that names no source_url_env has a golden of production's shape with none of its rows, and its branch is a substitution rather than a reproduction, so the fix cannot overstate in the direction the old code understated.
The number, on the same manifest and the same environment, with the only difference being whether af up had branched the store: 89 percent before and 100 after, with the two components that would need production's own row counts excluded and named. The 89 was never a twin missing a ninth of production. It was the report unable to see a store that was there. just benchmark runs the harness, which now prints all three rows.
A twin holding two hundred rows scored as reproducing a production holding four billion.
The fidelity report's database dimension said "12 tables over 184000 rows, branched from gv_20260830120000_abcd1234", called that reproduced, and never compared it against anything. It could not: nothing in the engine had ever been told what production holds. A golden built from a staging database got the same verdict and the same words as a full copy, and the report whose whole job is to say what a twin does not reproduce was silent about the largest thing it can fail to reproduce, which is the size of the data.
That undermines the strongest number the product has. A migration holding an ACCESS EXCLUSIVE lock for five hundred milliseconds over two hundred rows is a real measurement and a worthless prediction, and nothing said which of the two it was.
af volume record now reads production's shape over a read only connection to production or a replica, and writes a committed profile: row counts, table and index sizes, partition counts and how much sits in the largest partition, and the cardinality of every column anything joins on. It reads no row. Every figure comes from pg_class, pg_stats and the partition catalogs, which is what makes it safe to run against production itself and safe to commit beside the manifest, where the check running on a pull request can read it.
Declare it under database.volume.profile. Then:
The database dimension states the fraction, per table. A branch holding less than production is substituted with the share and with the sentence that stops somebody quoting a timing taken against it. A branch with no profile is unmeasured, and says so, rather than being reported as a reproduction nobody checked.
The migration rehearsal states its extrapolation as an extrapolation. "This rehearsal held AccessExclusiveLock on events for 512ms over 1,390 rows. Production holds 4,200,000,000 rows in that table, 3,021,582 times as many. At this rate the lock would be held for roughly 17.9 days." Never silently, and never in place of the measurement.
A profile older than database.volume.max_age, thirty days by default, is refused rather than quoted, the same way a stale golden is refused rather than branched. A stale denominator is not a smaller number, it is an unknown one.
The fidelity report scored a twin holding no events at all as a faithful copy of production.
Two of the report's own blind spots, each of which made the number confident rather than wrong. A datastore the manifest declares golden was reported unmeasured, which kept it out of the score in both directions, so a stack shaped like an analytics product scored 100 percent with a masked Postgres and an empty ClickHouse: every query path that mattered untested, every chart blank, and the one instrument whose job is to say "this is not production" saying nothing. Nothing has to read a ClickHouse to know that nothing built a golden for it, so that store is absent now, counted, with the four missing facts named: no golden, no attestation, no tables and no rows.
The second was the shape of the environment rather than its contents. Nothing measured how many instances of each service were running, so a manifest asking for three and a runtime starting one produced the same report as a correct one, and everything that only appears above one instance stayed invisible: leader election, a queue processed twice, a cache coherent with one instance and not two. A topology dimension counts instances against the count each service asked for. A service that names no count is unmeasured rather than reproduced, because an omitted key is not a statement about how many instances production runs, and a manifest that names no count anywhere excludes the dimension whole with one line saying so, which leaves the score of every manifest written before instance counts exactly where it was.
The number both changes exist for, on one manifest, measured rather than described: the analytics twin scored 100 percent before and 90 after, and the same twin running one instance of each service that asked for several scored 100 percent before and 70 after. just benchmark writes both.
The Kubernetes runtime could not copy any public image into a local cluster.
A cluster's nodes cannot see the Docker daemon that built or pulled an image, so the runtime copies it in. That copy is kind load docker-image, which exports the image and imports it into the node's store with --all-platforms.
A Docker installation whose image store keeps OCI indexes, which is the default once the containerd image store is on, holds every image pulled from a registry as a manifest list: one entry per platform, with the layers for the other platforms absent because nothing ever needed them. The import then refuses the whole thing, naming a content digest it cannot find, and the digest belongs to a platform this machine was never going to run.
It is not intermittent and it is not one image. On such a machine every public image failed, which meant the Kubernetes runtime conformance suite could not run a single behavior: it did not skip, it reported a hard failure in its setup before the first environment existed, in words that read as the runtime being broken. Thirty four behaviors that describe what a runtime has to do, including every containment behavior, went unmeasured for anybody whose Docker is configured that way.
A failed copy now falls back to an archive holding one platform, which is the form a node can import. The platform is linux and this machine's architecture, because a container image is a Linux image and a local cluster's nodes are containers on this same machine.
af insights printed ok nothing to report and exited 0 when the migration rehearsal was asked for and did not run.
af insights printed ok nothing to report and exited 0 when the migration rehearsal was asked for and did not run.The body of the report was honest: the reason sat in Missing, worded plainly, "the migrations were not rehearsed, because no fresh branch was available to rehearse them against". The headline above it and the exit code below it were not, and CI reads the exit code. A branch whose rehearsal could not start was indistinguishable from a branch whose rehearsal found nothing.
There are three outcomes here and there were two codes for them. A run that found nothing exits 0. A run that proved a break exits 5. A check that was asked for and could not run now exits 7, ExitVerification, as AF-DB-033, and prints no ok line at all. --no-rehearsal and insights.migration_rehearsal: false still exit 0, because declining a check is a decision and not a blocked one; the two sides of that distinction have a test each.
Two more doors onto the same room were open and are now shut. af insights -o json returned as soon as it had written the document, so every exit below that line was unreachable and adding -o json reported success on a migration that had failed. And a rehearsal that ran against a repository where no migration tool was recognised timed nothing, linted nothing, produced no findings, and was therefore a pass, both at the command and to an agent through rehearse_migration_safety, whose verdict function had no test at all.
Migration discovery also stopped at one directory below the repository root, and looked for supabase/config.toml, alembic.ini, manage.py and knexfile.js only at the root itself. Against ten layouts taken from real repositories it found one. It now searches to five levels for every marker and finds all ten, including services/api/src/main/resources/db/migration and this repository's own web/packages/db/migrations. Two markers stop being distinctive at depth, so each gained a guard: a manage.py is read for the word django rather than trusted by name, and a drizzle directory has to hold meta/_journal.json before the search settles on it. A dependency's own migrations under node_modules are still never this repository's.
A manifest could ask for three instances of a worker and get one, and nothing said so.
replicas, resources.cpu and resources.memory were in the schema and in the reference, the normalizer filled all three in, and then nothing read any of them: the local runtime never mentions replicas, both Kubernetes Deployments hardcode one, and neither runtime emits a resource requirement at all. The cost is not the missing feature, it is that a run reproducing a bug which only appears at more than one instance went green having never started a second instance. All three are refused at validation now, by name, the way load.thresholds.query_count_increase already was.
just fieldsweep counts the population so a fourth cannot accumulate in silence. Every field in the manifest schema is read by something, refused by validation, or declared as a label the engine deliberately does not read, and a label has to carry a description in the schema, because an undocumented label and a field nobody wired look exactly alike from the outside. workflows[].tags was the field that showed why: it had no description at all.
The inventory could not see the other half of the same defect. There is one golden, one masking pass and one branch, and all of them are Postgres, so a ClickHouse or a Redis declared as a service came up empty and no fidelity dimension mentioned it. An analytics product's twin held masked Postgres metadata and zero events and scored as faithful. A datastores dimension names every store other than the primary as unmeasured, with the reason, so the report says what it did not measure instead of leaving it out.
The deploy's traffic shift is tried again when Azure refuses it for a reason that will pass, and refused immediately when it will not.
On 2026-09-06 a staging deploy printed The containerapp 'afcp-app' does not exist in group 'af-cp-centralus' while shifting traffic. The app existed. The same script had already run three commands against the same name and the same group seconds earlier, all of them successfully, and the app answered a hand query moments later. That is a transient ARM lookup failure reported in the words of a permanent one, and it was the one Azure call in the deploy with no way to try again: the bootstrap job is polled sixty times, the revision readiness is polled sixty times, every deactivate tolerates its own failure, and the traffic shift was a single call under set -euo pipefail.
Staging recovers from that on its own, because the next merge deploys and takes traffic. A production tag has no next merge. The healthy revision sits at zero percent, production keeps serving the previous release, and the only signal is a red check nobody is watching at that hour.
The shift now retries with backoff. It does not decide transient from permanent by reading the message, because the message is identical when the app name is genuinely wrong: it asks the app for its own name between attempts, waits only while the answer is yes, and stops on the first attempt when the answer is no, so a mistyped app name fails in seconds rather than after two and a half minutes of waiting. Throttling and gateway errors name themselves and are retried without the extra read. Anything the classifier does not recognise is not retried, because a loop that treats every unfamiliar failure as weather is a hang with extra steps.
When the retries are exhausted the deploy says which revision is still serving and that the commit is not live, rather than exiting on the raw Azure message. It does not roll anything back, because traffic never moved and a rollback that puts traffic where it already is reports something that did not happen. What is serving is read back rather than assumed, so a call that errors after the weights are already written is still put through the public health gate instead of being reported as a deploy that never happened.
The rollback's own traffic shift is retried the same way. It was a single call too, at the worst possible moment, and a transient failure there ended the run on a raw error with the unhealthy revision still serving.
docs/plan/notes/cd.md said "any failure after step 2 puts traffic back on the revision that was serving". That was never true in either direction. A failure at the shift leaves traffic where it already was, and a failure in the maintenance job update or the connection budget check deliberately leaves the healthy release serving. Both end states are now written down, next to the other three.
Reading what is currently serving no longer kills the deploy when Azure refuses the read, for the same reason: a blip on a read should not end a release before it starts. The cost of that tolerance lands on the reap, which compares each active revision against the one it must keep, and an empty name matches nothing. It now refuses to reap at all when it does not know which revision the deploy superseded, rather than deactivating the one thing a rollback would need.
An S3 write from an environment was answered as a delivered email.
The third party catalog registered *.amazonaws.com under "Amazon SES" in capture mode. Every AWS host an application touched matched a mail rule, so an S3 PUT, an SQS SendMessage, a Secrets Manager read and an STS AssumeRole all fell through to the generic capture handler and came back 200 {} from code that believed it was holding an email. The application had every reason to think the object was stored. Nothing left the environment and nothing said so. The catalog held no GCP or Azure service host at all, so those fell to the default block with no rule naming them.
Twenty two services across the three clouds are named individually now, on twenty seven hosts between them, each with a sentence saying what it costs to reach: a message on a real queue is picked up by production workers, a read from Secrets Manager hands a production credential to unreviewed code, an object write is indistinguishable from production data once it lands. Twenty six of those hosts are refused. SES keeps capture and keeps only the mail endpoint. The SMTP submission endpoint is named and blocked rather than left implicit, because the sidecar speaks HTTP and mail sent that way would reach a real address instead of the inbox.
Naming a service rather than a cloud needs a pattern the engine did not have. Every regional AWS endpoint is <service>.<region>.amazonaws.com, so the only leading wildcard that reaches S3 also reaches SES. A star anywhere but the front of a pattern now stands for exactly one label, so email.*.amazonaws.com reaches SES in every region and reaches nothing else, and *.s3.*.amazonaws.com reaches a virtual hosted bucket. A star must be a whole label, and a pattern of nothing but stars is refused because it matches every host while reading as though it named one.
Capture no longer invents a success for a host nobody named. It answers generically when the rule names the host and refuses otherwise, with a decision saying which rule decided and why an invented success would be believed. Amazon SES and Slack gained real handlers: SES because a mail rule that returns an empty object is not a captured email, and Slack because every Slack client reads ok first, so a captured message read as a failed one.
The database conformance suite had never been shown to fail.
Its own package doc said so. Twenty four behaviors were declared, every provider ran them, every run printed ok, and nobody had ever watched one of them go red. A suite in that state is a list of assertions that might all be vacuous, and the ways an assertion goes vacuous are undramatic: a helper starts skipping, a comparison compares a value against itself, a body sits inside a condition that is never true. Every one of those still prints ok.
Each behavior now has a break that turns it red, and the self test fails if one of them stops going red, if a behavior passes by skipping, or if a run matches no test at all, which exits zero and reads exactly like a pass. Nineteen are proved with no infrastructure, because a negative control that needs a server gets skipped and a skipped negative control is a false green. The other five are about isolation, reset, and what a branch actually holds. Those are claims about bytes, so they run against real databases on a real Postgres, and CI already sets AF_REQUIRE_DATABASE so that its absence fails the job rather than skipping it.
Doing it found two behaviors asserting something other than what they claimed. Cancellation_LeavesNoUntrackedResource had its polarity inverted in both directions: it failed a provider that reported the resource it created, which is the correct thing to do, and it never looked for a resource created and not reported, which is the leak the rule is about. Its message said "that the caller has no identifier for" while its code required the caller to have one. Branch_RefusesAnUnverifiedGolden could not reach the branch side of its own rule, because a provider that correctly refuses to publish an unverified version never produces one to hand to Branch; the fakes can now flag such a version rather than withhold it, which the suite always permitted, and a control proves that affordance alone leaves every behavior green.
A browser offering to darken the site turned the home page's headline white and left the ground under it pale.
Every page of the site, the console and the documentation is authored in one light appearance, and that is a decision written down beside the theme toggle it disables. The site said so in the one way that does not hold: color-scheme: light describes the page, and Chrome's automatic dark theme, the setting an Android reader finds as "Darken websites" and a desktop reader finds behind a flag, repaints a page that says exactly that. Only only light turns it off.
What the repaint does is invert text and flat backgrounds and leave gradients, images and video untouched, and the home page hero is all four of those in one place. The label and the headline over the hero film went white, the film and the fade above it did not move, and the first screen of the site read as white type on a near white ground. The console declared no color scheme at all and took the same treatment on every screen. The documentation carried Starlight's data-theme="dark" and the color-scheme: dark that comes with it while every one of its tokens is defined identically for both themes, so it painted a light page and asked the browser for dark scrollbars and dark form controls around it.
All three now declare only light, in the stylesheet and, on the site, in the meta tag that decides the first paint before the stylesheet arrives.
Five buttons across the home page, the solutions kit and the well-architected page said "Request hosted access" and linked to /signup.
/signup.There is nothing to request: signing up is a GitHub exchange with no card and no invitation, and has been since the wall came down. The label described a door that is open and sat beside a link that already walked through it.
The pricing page had already worked this out and said so in its own source, which is how the five were found: its pair is "Start the quickstart" and "Create an account", with a note that asking for access describes nothing now. The other five say the same thing.
Nothing checked that the repository index detection is built on is sorted, and two things rest on it.
Exists binary searches the slice, so an index left in walk order does not fail loudly: it answers "no" about a file that is indexed. And the analyzer that turns a directory of numbered SQL files into a migrate command reads that index in order, which is the order a database replays the files in.
Every fixture in the suite put its files in one directory, where a walk and a sort produce the identical list, so removing the sort turned nothing red. The new fixture is the minimal pair where the two disagree, a file and a directory whose names differ only after the dot, and removing the sort now fails three assertions: the index order itself, and each of the two lookups.
the-index-every-lookup-binary-searches-was-never-proved-sorted
Two figures on the marketing site printed a command nobody could run.
The twins figure ended on af env prune --before <cutoff> and the load figure opened with af load --manifest load.yml, and neither flag has ever existed: the cutoff is --older-than, and af load reads the manifest in the working directory rather than taking a path.
Both are the first command a reader would copy out of the page, and the shell answers an invented flag with a usage error, which reads as the product being broken rather than the page being wrong. The figures now show what the command reference generated from the command tree says.
af init run fresh against the project's own showcase example, examples/next-app with its manifest deleted, wrote a service block with no migrate key at all.
af init run fresh against the project's own showcase example, examples/next-app with its manifest deleted, wrote a service block with no migrate key at all.The Dockerfile installs postgresql-client specifically because the manifest's migrate command needs it, and migrations/0001_init.sql creates the tables the whole application is built on. Nothing recognised that shape, so af up started the service, its health check ran SELECT 1 against an empty database and passed, and the page itself answered every request with relation "customers" does not exist. A directory of SQL files with a name that looked like a migration directory was already detected, at low confidence, and then thrown away: it became a finding no candidate ever read, reported to nobody, and the manifest that came out the other end looked exactly like one detection had checked and approved.
A directory whose files are numbered now gets a real migrate command: the plain psql $DATABASE_URL -v ON_ERROR_STOP=1 -f ... invocation the numbering itself implies, one -f flag per file in name order, owned by whichever service builds from the same directory. A directory whose files carry no number has no ordering evidence at all, and guessing one would be worse than the blank it replaces, since a migration applied in the wrong order against a real database can corrupt it in a way an empty key never could; that case is still surfaced as a question rather than a silent guess, exactly as before.
The second failure was worse than the first, because it hid the reproduction of it. af test against the broken environment never mentioned the server error. Three different rewrites of the workflow's expectation, including the page's own literal empty state string, all came back unverified, with a note blaming the wording, because judgement ran only on the page's rendered text and the response status the runner already had in hand never reached it. A page that answers with a real HTTP error is not an unread expectation, it is a failed one. The runner now carries the status of the document it navigated to alongside everything else it already reads, and a workflow whose page answered 400 or above is reported failed, naming the status and the page, before any judgement about the expectation's own words runs at all.
A golden carried every role its source named and none of the privileges the source held for them, so a twin ran the application under a privilege shape that was production's in neither direction.
The dump is taken with ownership and privileges dropped, which is right for ownership: the restore's own user has to own what it restores. What the flags throw away with the owner's grants is every other role's too. So the twin arrived with the roles production names, the memberships between them and the row level security policies that mention them, and not one GRANT. This repository's own session sweeper enters antifailure_sweeper and was refused DELETE FROM sessions with SQLSTATE 42501 within the first minute of every environment, because the DELETE and the SELECT on one column that migration 0024 granted were not in the copy and 0024 is in the ledger, so nothing ran it again. Every grant made by a migration the ledger records as applied was gone in every branch, while the connecting superuser owned everything and was refused nothing. Row level security travelled and grants did not, so the twin was half faithful, and the half that failed loudly was the half production keeps tightest.
After the restore the copy now reads the source's ACLs on tables, sequences, views, columns, functions, types, the schemas themselves and the default privileges, restricted to the roles it carries and to PUBLIC, and issues the equivalent GRANT in the target, WITH GRANT OPTION where the source had it. An object whose ACL the source has touched has PUBLIC's privileges revoked first, so a function whose EXECUTE production revoked from everybody stays revoked in the copy instead of being handed back by a fresh CREATE FUNCTION. Default privileges are re-declared for the target's connecting user, because that is who a branch's migrations create tables as, so a table a branch adds gets what production's would. Ownership stays with the connecting user, deliberately, and the owner's own ACL entry stays behind with it. A grant in a schema the copy excludes stays behind with the schema.
Proved on one cluster, where a fresh target database lacks every grant however many roles it shares with the source: after the copy each role is entered and does exactly what the source allowed, and is refused with 42501 what it did not. The two cluster proof carries the sweeper case verbatim: the application's role enters the sweeper's and deletes expired sessions in the copy as it does in production.
The console's render boundary promised a reference that was not always there.
The boundary that catches a page which throws tells the reader that the reference below is what to send us, and shows one only when the error carries a digest. A throw with no digest left the sentence pointing at nothing. It now says the short form in that case and keeps the longer one, with the reference, for the errors that have one. Checked by rendering a page that throws after mount in the built export, at 1440 and 390, in both states.
The first pull request after installing the GitHub App showed a green job named Antifailure beside a check named Antifailure that waited forty five minutes and then said nothing was verified.
The App posts its check the moment a pull request opens, and the check concludes when the workflow reports back through the control plane. The file the App committed reported only when the repository variable AF_CONTROL_PLANE was set, the pull request body called that variable optional, the console never named it, and nothing on the path set it. So the workflow commented for itself, its job went green under the same name as the check, and the check read "Waiting for a runner" until the deadline sweeper wrote timed_out. A branch protection rule requiring "Antifailure" could not tell the two apart.
The file now carries the control plane's address as the variable's default, and the App writes its own address there when it opens the pull request, so a self hosted control plane's file reports to it and the hosted one's file is the example unchanged. The pull request body, the console's onboarding, af github init and the documentation say the address is in the file and there is nothing to set, and that the variable exists to point the run at a self hosted control plane. The reusable workflow's job is now "Antifailure rehearsal", which reaches customers when v1 next moves, so the verdict and the runner no longer share a name. And when the repository's own Antifailure workflow finishes a pull request run without ever having claimed the commit, the check concludes then, with a sentence that names the variable, this control plane's address and the documentation page, rather than saying nothing for forty five minutes. A skipped or cancelled run of that workflow, a dispatched run of it, and any other workflow in the repository still conclude nothing.
the-first-pull-request-showed-a-green-job-and-a-timed-out-check-with-the-same-name
A second af mcp process on the same checkout reported every run the first one had in flight as "The server stopped while this run was in progress", while the first server was still driving the browser through it.
af mcp process on the same checkout reported every run the first one had in flight as "The server stopped while this run was in progress", while the first server was still driving the browser through it.Every af mcp process settled every queued or running run at startup, with no record of which process was running them. Two editor windows, or one shared preview that several people point an agent at, share one state directory, so each new server told the others' callers that a server had stopped and to submit again, and retryable said false. The run store now records the process that owns each run and settles only those whose process has exited, by the branch lock's own liveness rule. A run another live server is running is left to it, and get_rehearsal_run and cancel_rehearsal_run reach it from either server through the shared store.
A tool that could not take the branch because another process holds it answered SAFETY_UNAVAILABLE with a list of usual causes that were not the cause. It answers BRANCH_LOCKED now, naming the holder's process id, its command and when it took the lock, with the same code, message and next step af prints at a terminal. Over MCP a short operation is waited for, up to fifteen seconds, and a long one is refused at once with the holder named; at a terminal nothing changes.
Listing goldens, the fidelity inventory, the invariant check and the masking plan and verification read the branch and write nothing, and no longer take the branch lock at all. af golden list during af up answers instead of printing AF-RUN-003.
The error reference says scripts can branch on the exit codes, that they are stable, and that 4 means authentication or authorization failed.
af whoami and af provider list honoured that when nothing was signed in: AF-CPL-004, a Next line naming the af login command, a docs link, exit 4. af token list, af token create and af token rm did not. The same absent credential produced a bare sentence with no code, no Next line and no link, and exit 1, so a script that branched on 4 to decide whether to sign in treated every token subcommand as a generic failure. The three other reasons a stored sign in is unusable, expired, no longer accepted by the control plane, and short a scope, were bare sentences and exit 1 on every command that reads one.
Every command that reads the stored sign in now refuses with one of four codes, all exit 4: AF-CPL-004 for none, AF-CPL-005 for expired, AF-CPL-006 when the control plane no longer accepts it, AF-CPL-007 when it does not carry the scope the command needs. Each names the af login invocation that fixes it, with the scope the command needs where there is one.
af init on a repository that already had a manifest reused AF-MAN-002, whose next step is to fix the reported line and run af doctor. There was no reported line, the file was usually valid, and af doctor cannot change the one fact that stopped the command. The flag that does was never named. It is now AF-MAN-007, which names the file and af init --force, and says that --force discards every edit rather than merging. The flag's own help text said "instead of merging into it", and nothing merges; it now says what happens.
af env list --output json called the services column name. The key held the comma joined service list, and the identifier was already in env_id. It is now services. The field was never among the documented fields of the command's JSON output, which is what the stability page promises to keep, and nothing in this repository read it, so it is renamed rather than carried as an alias.
The public site's tertiary grey failed the contrast floor on every page.
axe-core counted 263 colour contrast violations across the six public pages, 56 on the home page alone: the gray-new-50 token measured 3.85:1 on the page ground, the black opacity utilities below 55% composited to the same failing greys, and the illustrations carried their own hand-picked greys and greens that were lighter still. The token is retuned to clear 4.5:1 on every ground the site paints, tertiary text uses it instead of an alpha, and the illustration colours are folded onto it. The home page's code panels and the narrow-width card strip can now be reached and scrolled from the keyboard, the product page's verdict list contains only list items, and four decorative icons are hidden from assistive technology.
The header said Docs twice from 1280px up, once in the nav and once with a book icon beside GitHub, both to the same page. The icon copy is gone.
The documentation site's header said Log in and Sign up where every other page says Sign in and Start free. It now says what the rest of the site says.
The console's email sign-in field says up front that new accounts start with GitHub and that the link is for addresses already invited into an organization. A new address used to get the same "check your mail" sentence as an invited one, and no mail, with nothing on the page to say why.
af env prune removed environments on a bare invocation.
af env prune removed environments on a bare invocation.Its help said it "prints what it would do before doing it"; it printed each removal as it performed it, and the only gate was a default cutoff of a day. Typed with no flags in an empty directory on a shared machine, it removed nine environments and eighteen resources belonging to other sessions. Run bare it now lists every environment on the machine older than the cutoff, says the cutoff, says that nothing has been removed, and prints the command that removes exactly that list. Removal needs --yes. --dry-run still works and means the same as running bare. Under -o json the bare run is a plan document with would_remove, and removed stays empty until --yes.
af env reap and af golden gc had the same shape, a removal on the bare command, and now list first and remove with --yes too. A scheduled reap passes --yes. The error remedies that named af env prune --older-than 0 and af golden gc now name the --yes form, and the documentation pages that described the preview that did not exist describe the one that does.
The migration rehearsal answered INCONCLUSIVE for a project that applies its own SQL files.
A project whose migrate command is a script of its own, applying a directory of numbered .sql files and recording each one in a table it made, has no Prisma or Flyway marker for the rehearsal to recognise. It was told "no migration tool was recognised in this repository", the verdict was INCONCLUSIVE, and the lock thresholds in policy.migration_lock never fired. The product's own control plane was such a project, with forty files under web/packages/db/migrations.
database.migrations in the manifest now names the directory, and optionally the ledger table, so the rehearsal replays those files and computes the pending set the way the project's runner does: a file is applied when its name, stem or leading number is in the ledger. Without the declaration, a directory of numbered files anywhere reasonable in the tree is recognised, nearest the service that migrates. A file that carries its own BEGIN and COMMIT is applied as the one transaction its author meant.
Separately, every Postgres the Docker provider starts now preloads pg_stat_statements, and the insights create the extension themselves where the role may, so statement timing is measured on a local branch instead of being reported unavailable. Where it still cannot be, the report says which of three reasons it was, because each has a different fix.
The lint's CREATE INDEX findings named the wrong table when the index name contained the letters "on": environment_usage_org_idx ON environment_usage was reported as locking ment_usage_org_idx, a table that does not exist, and its row count was looked up under that name. The keyword is now matched as a word.
the-rehearsal-could-not-see-a-project-with-its-own-migration-runner
The control plane buffered a request body of any size before deciding whether to refuse it.
This is a security fix.
The node server this process runs on sets no body limit, and the only two places that set one were the PostHog proxy and the MCP endpoint, because those were the two places somebody remembered. Both webhook handlers read the whole body with one call and verified the signature over those bytes afterwards, which is the right order for the signature and the wrong order for memory: a stranger with no secret could post a multi gigabyte body to /webhooks/github or /webhooks/stripe and the 401 came only after every byte had been read into the process. /trpc/* and /v1/events had no bound at all.
Every endpoint now has a body limit, applied before any route runs, from one catalog beside the rate limits: a default of one megabyte, and a named exception with its reason for each route whose honest bodies are a different size. GitHub deliveries are bounded at five megabytes, which admits an installation event listing forty thousand repositories, the largest delivery the events this App subscribes to can produce, and refuses the twenty above it that GitHub itself would still send. Stripe events are bounded at half a megabyte, an order of magnitude above the largest invoice. The bring-your-own-key proxy keeps the provider's own thirty-two megabyte ceiling, because a long prompt is the point of that route. A body over the limit is answered 413 with a sentence naming the number, from the declared content-length without reading a byte when the client sends one and from the count when it streams, and a body under it reaches the handler byte for byte, so the signature still verifies over what was sent.
Two console lists had no upper bound. members.list returned every member and runtimes.list every runtime, when every other list on the console takes a limit capped at two hundred or applies one, and a membership synced from a large GitHub organization is exactly the case that comment warns about. members.list now takes a limit and a cursor and answers two hundred at a time; the cursor names both the timestamp and the member, because a sync writes its whole batch in one transaction and every row shares one timestamp to the microsecond, so a cursor on the timestamp alone could never page past the batch it started in. runtimes.list is capped at two hundred. The Members page follows the cursor until there is none, so the table it renders is still the whole membership.
Renaming or transferring a repository on GitHub forked its history into an orphan row. Every table of consequence points at the repository's id, so the row is the history, and the renamed action went through the same upsert as a new repository, keyed on the organization and the full name: the new name matched nothing, a second row was inserted, and the first stayed live under a name GitHub no longer served, with every environment and verdict still attached to it. A rename now finds the row by the id GitHub keeps stable, or by the old name it sends, and changes the name on that row. A transfer archives the row under the owner it left, and records a fresh one under the owner it reached only when that owner already has an installation here; before this a transfer to an owner this control plane had never seen minted a tenant for them and pointed the delivery's installation at it. The row does not cross tenants, because every table beneath it carries its own tenant and its own policy, and handing one customer another customer's run history because somebody pressed Transfer is not a trade anybody agreed to.
an-unsigned-body-of-any-size-was-read-in-full-before-refusal
A person who hit an error in the console had nothing to quote, and a cross-site refusal left no trace on either side.
The control plane mints an id per request, echoes it on x-request-id and writes it on the log line and into the body of a 500 on its raw routes. The console does not use the raw routes. It talks to /trpc for almost everything, and the tRPC error formatter and the onError log line both dropped the id, so the error card showed a fixed sentence pointing at the logs and nothing to search the logs by. Every tRPC error now carries the id under error.data.requestId, the same id as the header, the onError line carries it beside the code and the path, and the console shows it under the message as "Reference: <id>", the same line the render boundary shows for a page that threw. The fixed sentence for an internal failure is unchanged; the id is the only thing added.
The six cross-site refusals in the control plane answered 403 with a sentence naming a header, no log line and no id, in a file where every other refusal logs. The two cross-site defects found on launch night were found by somebody pasting screenshots of that sentence. They now go through one helper that writes a structured line, which gate, why, the request id and no token, and answers the same fixed shape the other refusals answer, with the request id in it, under a new code, AF-CP-004.
An application that reads its webhook signing secret under a name of its own could not receive a simulated event, and this repository's own rehearsal environment was that application.
The engine derives one signing secret per provider and hands it to every service as STRIPE_WEBHOOK_SECRET or GITHUB_WEBHOOK_SECRET. An application reading AF_STRIPE_WEBHOOK_SECRET had no way to receive that value: a manifest alias, from: STRIPE_WEBHOOK_SECRET, was looked up in the shell, the .env file and the keyring, none of which can hold a value that does not exist until af up, so the variable was reported missing and billing stayed off. The derived secrets are now a source in the secrets chain, in front of every stored one, so the alias resolves to exactly what send_webhook_event and af webhook trigger sign with, and af explain names that source.
The github catalogue now carries installation, the first event a new customer's control plane receives, and every github sample names the account it is about, so a delivery reaches the handler rather than being acknowledged as "the payload names no account". Each github delivery now carries the X-GitHub-Event header, which the sender never set, so every event arrived as "unknown"; and its own delivery identifier, which was a constant, so the second delivery of anything was fenced as a replay of the first.
Three things about send_webhook_event and af webhook trigger that stopped the orderings being read. A field value that parses as JSON is now sent as JSON from the MCP tool, as the CLI already did and the tool's own description claimed, so a subscription's items can be set. The application's answer is kept on an accepted delivery as well as a refused one, because "already handled" and "recorded; no organization holds this customer yet" are both a 200 and the body is the only thing that says which. And event_id pins the provider's event identifier, so the same event can be sent twice and a retry rehearsed on purpose rather than by racing the clock.
This repository's manifest declares webhook_path for Stripe and GitHub, replaces the typed placeholder webhook secret with the alias, and turns the GitHub App on in its own environment with rehearsal values, so the orderings a launch depends on, a checkout completed before the organization exists, the same event twice, a late updated after deleted, an installation created, are rehearsed by the product rather than read for in the code.
the-twin-could-not-receive-the-webhooks-the-launch-depends-on
The Details link on a pull request check opened the runs list instead of the run.
The check the App posts on a pull request linked to the console with only the commit in the address, and the runs page reads a run id and nothing else, so every click from GitHub landed on the generic list and the reader had to find their run by eye. The link now names the pull request, and the runs page opens the newest run for it, or says plainly that none has reported yet and shows the list.
The console also had no error boundary at all. One uncaught exception on any page blanked the whole window with nothing to read and nothing to press. Every page now falls back to a sentence, a reference to quote and a Try again, and the shell stays up around it.
The website said the hosted control plane was invitation only, that Team was bought by booking a call, and that there was no status page. Signups are open, Team is bought from the console with a card at the price the console shows, and the status page is linked from the footer and named on the service levels page.
Six things the product said, or failed to say, about itself, found by evaluators using only the product.
check_prerequisites over MCP returned BLOCKED with "Packet filtering" and "Corporate proxy" in its blocking list while both had result skip and a remediation of "No action needed". Only a failed check is blocking now; a skip is listed among the checks with its reason and decides nothing, so a Mac, where packet filtering is always skipped, can be ready.
An MCP tool failing under a held branch lock returned SAFETY_UNAVAILABLE and "the server log says why", a log no tool on the server can read, while af golden list printed AF-RUN-003 with the holder's process id and what to do. The error now carries cause: the code, the message, the next step and the link, the four lines the CLI prints, and the sentence pointing at the server log is gone from every tool.
af whoami, af provider list and af token list printed a raw invalid character 'K' decode error for a stored credential that did not parse, with no code, no next step and no link. The credential store now returns AF-SEC-006, which names the file or the keyring, keeps the decoder's words as the detail, and says to sign in again with af login.
af doctor printed nothing for over two minutes and then all fifteen checks at once, because a slow Docker daemon held back the checks that had already answered. The checks now run at once and each line prints the moment its check answers. The summary and the JSON form are unchanged.
check_prerequisites and af doctor said nothing about which webhook providers could deliver into the environment, so a reviewer learned that billing and the GitHub App were off only by sending an event, being refused with AF-NET-012, and reading the application's startup log. A new check, "Webhook delivery", reads the manifest and states up front which providers have a webhook_path, which are off, and which have a signing secret in a service but nowhere to deliver to.
run_browser_workflows over MCP omitted the "N requests the page could not make, the first was ..." line af test prints under every workflow, so an agent saw less than a person for the same run. Each workflow in the JSON result now carries the count and the first such request.
the-first-tool-an-agent-calls-said-blocked-about-checks-it-skipped
The status page was live and nothing linked to it, and the ages on it were frozen at the moment it was written.
https://antifailure.github.io/antifailure/ answered 200 for days while no page on antifailure.dev mentioned it, antifailure.dev/status was a 404, and the only reference was a self-hosting document. A person who hit an outage had no way to reach the one page written for that moment. The footer's Connect column now carries a Status link beside GitHub, and /status is a 301 to the page.
The page itself printed "checked 3 seconds ago" beside Operational and kept printing it for the hours until the next probe landed, because every age was computed when the page was generated and served unchanged afterwards. It also said checks arrived "about every 2 seconds": the interval was the median gap between all readings pooled, and seven components probed inside one run sit two seconds apart, so the staleness threshold pinned to its floor and no component could ever read as stale when the page was written. The interval is now measured between consecutive readings of one component, which is the gap between runs, every age carries its epoch, and an inline script restates the ages against the reader's clock and shows how old the page itself is once it passes the threshold.
The probe also runs when a cd workflow completes, on top of a schedule that GitHub delivered 25 times in three days where it was asked for 864.
A buyer who finished Stripe Checkout and came back to the Plan page before Stripe's webhook arrived saw the same Subscribe to team button they had pressed before they paid, with no sign that anything had happened.
Pressing it again opened a second checkout against the same customer, and a second completed checkout was a second live subscription and a second charge for one organization. Nothing on either side of the return could tell the two apart: the page fetched once and never read the parameter Stripe sent it back with, and the control plane's only guard was a read of a table the webhook had not written yet.
Both sides now know. The Plan page reads the return, says the payment was received, keeps the Subscribe controls off, and asks the control plane every two seconds until the plan reads as entitled, then shows the active subscription and drops the parameter from the address. After ninety seconds it says plainly that the plan will activate shortly, that Refresh from Stripe asks Stripe directly, and where to reach support, with the controls still off. The checkout route asks Stripe itself for the customer's subscriptions before it opens a session: an active, trialing, past due or unpaid one is refused with the state named, an incomplete one younger than an hour is refused as a payment still being confirmed, and a cancelled one or an abandoned attempt lets the organization buy again. When the refusal is because Stripe holds a subscription this database does not, the row is written before the answer goes back, so the page catches up on its next read. Every checkout session also carries an idempotency key on the organization, the plan and a thirty second window, so a double click reaches Stripe as one request.
af start reported a machine that could run af up as blocked, named a secret as the next step, and refused to look at the goldens that made the secret unnecessary.
af start reported a machine that could run af up as blocked, named a secret as the next step, and refused to look at the goldens that made the secret unnecessary.The golden step was declined for every provider with one reason, that listing goldens takes the branch's lock. That is true of the orchestrator's listing and was never true of the Docker provider's, which is an image list on the daemon and needs no lock, no .antifailure directory and no state database. So a machine holding five verified goldens for the project was told "whether a golden exists was not checked". The step now lists the daemon directly and selects with the same rule af up uses, verified and made for this project, so it never names another project's golden or an unverified one. It says the version, when it was made and that it is this project's. With none usable it is pending with af golden refresh, or with af up when no source is named, since the first af up builds that golden itself. A hosted provider's step is still declined, with its reason, because that listing needs credentials.
The source step was blocked whenever the variable naming production was unset. When a usable golden exists, af up branches it and the scheduled refresh is skipped until the variable holds something, so the variable is needed by the next refresh rather than by the next command. That step is now a warning that names the golden and the schedule, the Next section says af up, and the exit code is zero. It is blocked only when there is no golden and no source. The masking rules step is demoted the same way: an absent masking.yaml beside a usable golden is a warning about the next refresh, since af up branches the golden as it is, and it stays pending with af mask init only when there is no golden yet.
Every place that states the first run now states one sequence: install, af init, then af golden refresh only when the manifest names a production database, then af up and af test. The README, the documentation index and quickstart, the site, the console's CLI page and the hint af init prints had between them four different sequences, and none of them said which step was conditional or when.
A second af up after an edit kept the previous build serving and printed ready.
af up after an edit kept the previous build serving and printed ready.The local runtime reused any running container with the right name. The image reference carries a digest of the build context, so an edited tree names a new image, but the container from the old one was still running, so it was reported ready and the new image was never started. A fix rehearsed that way was rehearsed against the build it was fixing, and passed. The reused container was also returned early, with no address and without the ingress being looked up, so a repeat af up printed a service with no URL.
af up now compares the running container's image to the one the tree builds, by image ID so that --rebuild under an unchanged tag counts, replaces it when they differ and says so in the progress line, and takes the same path as a fresh container from the ingress onwards, so the address is always printed.
This repository's own rehearsal never ran a pull request's migration inside the twin.
The dogfood job shaped its source database with the head's own seeder, which applies every migration in the head's tree before the golden is made. So the copy carried a ledger that already recorded the pull request's migration as applied, the twin had nothing left to run, and the one ordering a customer's golden always produces, a copy of production that predates the change, was never rehearsed here. Migration 0037 granted to a role the copy did not carry; every environment branched from a real golden died on it for two days, and the job was green on the pull request that merged it.
On a pull request the base commit's own tree now shapes the source, in a worktree outside the workspace, and the head's migrations run for the first time inside the twin. The log names how many. Reproduced before the change with a golden made at 0036 and the release copy tool, which died on 0037 with role "antifailure_admin" does not exist, and again after it with the copy tool that carries the roles, which came up.
An environment branched from a golden no longer fails its first migration with "role does not exist" for a role the source knew only through a GRANT.
Antifailure's own twin had not come up since migration 0037 landed. The copy succeeded, the branch was made, and the migrate step died on GRANT SELECT ON environment_usage TO antifailure_app, antifailure_admin with role "antifailure_admin" does not exist. The golden is made with owners and privileges dropped, so the restore never mentions a role that is only granted to and never fails on one. The migration ledger travels with the copy, so the migration that created the role is recorded as already applied and never runs again. The first later migration to grant to the role then fails in every branch, and it fails with a message that points at the migration rather than at the copy. Nobody saw it because the dogfood workflows do not run in CI.
The copy created only the roles that row level security policies name, on the reasoning that ownership and grants are dropped so the roles they mention are not needed. That is true during the restore and false one step after it. The role that broke the twin is one no policy names on purpose, because it exists to bypass them, so it had no other way to arrive. The same was true of the sweeper's role for any golden made before the migration that created it.
The copy now also creates every role the source names in a grant on a table, sequence, view, function, type, or schema being copied, or in a default privilege. Each arrives as it always did: NOLOGIN, with no attributes, no memberships and no password, because its only job is to be a name a later statement can resolve. A role with BYPASSRLS in the source arrives without it. Owners are still left out, since ownership is dropped and nothing that runs later needs an owner's name the way it needs a grantee's, and grants in a schema the copy excludes do not bring that schema's roles along.
With that fixed, the twin got four migrations further and stopped on 0041 with antifailure_app cannot SET ROLE antifailure_sweeper, so the workflow token sweep cannot run. That migration checks an arrangement 0024 made and the ledger says is done: the application's role is a NOINHERIT member of the sweeper's. The copy carried both roles and none of the membership between them. Memberships now travel too, with their INHERIT, SET and ADMIN options, and only between roles the copy itself creates. A membership between two NOLOGIN shells is not a credential, since neither can be connected as and neither holds a privilege until a migration grants one, but it is the shape a later migration checks. A membership in a role the copy does not carry, such as the source's superuser, stays behind.
Rehearsed against this repository: a golden refreshed from staging, af up, migrations 0037 through 0041 applied in the branch, the api reached ready, and all six declared workflows and four invariants passed.
An operator who was also a customer could not run any customer mutation from the console, and the first one it refused was Subscribe to team.
One browser, two cookies: the operator session from the portal and the product session from the console. The console sends the product token with a product mutation, which is all it can know about. The operator transport check keyed on the operator cookie being PRESENT rather than on the request being an operator request, so it ran on every mutation that browser made and refused each one with 403 "needs the x-antifailure-admin-csrf header", naming a header the Plan page has never heard of. The product session had already been checked against the product token a few lines above.
The operator cookie is the credential for exactly one namespace, admin.*, and the check now applies to a request that names a procedure in it and to nothing else. A batch with one operator procedure in it is an operator request as a whole. The suite that guards this gate gained the two orderings it was missing: both cookies on a customer mutation pass on the customer token alone, and both cookies on an operator mutation are still refused without the operator token.
The console fetched the payload for every page in its sidebar the moment any page rendered.
Next prefetches each link it can see, and the rail holds a dozen of them, so one page load fired twenty two requests for segment files named like /load/__next.!KGFwcCk.load.__PAGE__.txt, each carrying the encoded route group in its path, before the person had touched anything. Somebody reading the network panel on the Plan page saw those and asked what they were doing there, which is a fair question with no good answer.
The rail's links now fetch a page when the pointer or keyboard focus reaches them, which is long enough before the click that navigation still feels instant, and only for the one link about to be used. Measured on the built console behind a stub session: twenty two requests on load before, two after, both of them the current page's own, and hovering one entry fetches that entry alone.
An operator whose browser also held the customer session could not run any operator mutation from the portal, and the first one it refused was Mark reviewed on a job application.
The mirror of the night before. The customer transport check keyed on the customer cookie being present rather than on the request naming a customer procedure, so it ran on the portal's operator mutations, which carry the operator token and nothing else, and refused each one with 403 "needs the x-antifailure-csrf header from GET /auth/session". The operator check had already been narrowed to admin.* requests; this narrows the customer check to everything else. A batch that mixes both namespaces is checked both ways.
The suite gained the operator side of the both-cookies ordering and the customer gate's own refusal and acceptance over HTTP, so switching either gate off, or widening either back to cookie presence, turns it red.
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.
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.
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.
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.
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.
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.
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.
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.
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
39 entries
A defect with a consequence for the safety of your data or your build.
Broken access control ships green because a 200 looks like success.
A pull request adds a route, the handler returns the object, the review sees a working feature, and nobody asks whether the caller was allowed to have it. The regression that follows is the most common one there is: an endpoint reachable without the authorization it needs, an object reachable across a tenant, an admin surface reachable by a member. It passes every static reviewer, because the code that should refuse compiles and the code that does refuse is missing.
The authorization family answers the one question a static reviewer cannot. It exercises the sanitized twin and reads the outcome, and its verdict is never "200 is bad." It is "the base revision refused this and the change allowed it," which is the regression the change introduced, so a pull request that merely touches a pre-existingly open endpoint is not false-red. The load bearing rule is that an outcome is decided by whether the victim's content came back, not by the status code: row level security refuses by dropping the row, so a forbidden object returns an empty 200 or a 404, never a 403, and a check that insisted on 403 would pass every correctly isolated target and fail every one that enforces the boundary the modern way. A refusal is trusted only when a liveness arm proves it is a refusal and not a dead control, because a renamed route, a session that never established, and an app that is down all read denied too. A finding names the rule, the level, the route and a bounded description, and never the request body, the response or the row: those stay in the copy of production the run drove.
A raw invitation token was stored in plain text, beside the hash that exists so that it is not.
An invitation is kept as a sha256 and nothing else, so a copy of the table is not a list of working invitations. The console asked to be returned to /invite?token=<the raw token> after sign-in, and the control plane stores a return target verbatim: oauth_states.redirect_to for the GitHub handshake, and email_signin_tokens.redirect_to for a sign-in link. Both are plain text. So the token sat readable in the database for the ten minutes a handshake lives, and for up to a day when nobody came back to redeem it, which is the case where the token is still valid.
A return target now carries no token parameter, dropped where both writers and every other caller pass through, rather than at either writer. The console keeps the token in the tab it already lives in for the trip to the identity provider and back, so an invitation still opens, is still accepted, and the token still reaches only the person who was sent it.
Hosted persona credentials were derived only from values that are not secret, so they were not secret either, and one environment's af up locked every other environment out of the persona it shared.
af up locked every other environment out of the persona it shared.A persona created through Clerk, Auth0, WorkOS or Supabase's admin API lives in the provider's tenant, and the provider holds one account per address, so every environment that reaches the tenant uses the same account. Its password and second factor differed between environments, so each af up reset the shared account and signed every other environment out. And they were derived only from values that are not secret, so they were not secret either.
A hosted persona's credentials are now derived from the tenant's admin token, which every environment that reaches the tenant already holds. Every environment arrives at the same values, and they are as secret as that token. An empty admin token is refused with AF-DB-025.
What to do: upgrade. Rotate nothing. The next af up, af test or af explore against a hosted tenant replaces every hosted persona's password and second factor with the new values. A person who enrolled an authenticator app by hand against a persona's old second factor has to enrol it again.
Two rules are documented with it. Environments that share a tenant share its admin token, because two environments reaching one tenant with different tokens derive different passwords. And af down does not delete a hosted persona, because another environment may be signed in with it, so one account per persona address stays in the tenant; the personas guide says how to remove it.
The documentation said a pulled golden was checked to be yours.
It is checked against an accident.
A pull reads the attestation beside the dump, compares the project identity recorded in it, and refuses a version made for another project. That check was written for an accidental collision, and it is measured against exactly that: a project with no masking.yaml shared a golden pool with every other one, and nova-shop came up holding acme-billing's customers. The pages said more than that. concepts/goldens said a pulled golden was not trusted to be yours, "signed along with everything else", and providers/stores said the signed statement was what made a dump a golden rather than a file. Both read as an authentication of the publisher, and there is none: the pull compares the provenance field and never checks the signature, and the signature would not answer that question anyway, because the key is generated for each signature and travels inside the document. It proves the document was not changed after signing, not who signed it.
So the pages now say what holds. What protects the data in a pulled golden is the verification scan running again on the machine that pulled it, against the database that actually arrived. What decides who may publish is the store's own access control, which makes the store credentials and the bucket policy the trust boundary, and providers/stores says that in those words. The same correction is in the comments that carried the claim in the code, on verify.Attestation, on the pull's provenance check and on the golden.Store alias. No behaviour changed.
the-golden-store-documentation-promised-an-authentication-it-never-had
The engine no longer depends on github.com/docker/docker, which is the module all eight Dependabot alerts on the default branch were matching.
github.com/docker/docker, which is the module all eight Dependabot alerts on the default branch were matching.They were four Moby advisories counted twice, because engine/go.mod and ee/engine/go.mod both required that module, and none had a fix on that path: it stops at v28.5.2, three of the four cover everything through 28.5.2, and the Go vulnerability database records no fixed version for it at all. The engine now uses github.com/moby/moby/client v0.6.0 and github.com/moby/moby/api v1.56.0, the modules Moby split its client and API types into, which no advisory names. .govulncheck.yaml is empty, because both of its entries named the old path and matched nothing once it was gone.
This changed the module graph and not the daemon. All four advisories are bugs in dockerd, which whoever runs it installs and upgrades, so the residual exposure written up in docs/security/pentest-readiness.md is unchanged, and the engine still sends the same archive upload it always did.
One difference is visible to a user. The new client negotiates no lower than Docker API 1.40, which is Docker Engine 19.03, where the old one fell back as far as 1.24. af doctor now reads the daemon's API version and fails its Docker check below that floor, naming the version it found, instead of leaving an older daemon to fail partway through an environment.
The gate gap this exposed is unchanged: govulncheck asks what is reachable, Dependabot asks what is present, and nothing here reads the second answer.
A connection string with one wrong character in it printed its own password.
The standard library describes an address it cannot parse by quoting the address, and building an HTTP request passes that error back unchanged. The HTTP client replaces a password with *** when a request fails, but an address that never parses never gets that far. So a Vault address with a user and password and a stray letter in its port, a DATABASE_URL whose password held an unescaped slash, or a signed storage URL with a typo put the credential into the error that reported the typo, and from there into a terminal, a CI log or an agent's transcript.
Hiding the address in that error is not enough, because the rest of the error quotes pieces of the address too: a password holding a slash has its first half reported as a port, and a stray percent sign has the two characters after it quoted. The Postgres driver prints exactly that part. So the address is now redacted before anything describes it. secret.ParseURL parses a copy with the user information, any port that is not a number and the query removed, and reports what is wrong with that copy, which still names the fault wherever the fault is not inside the credential.
It is used everywhere an address that can carry a credential is parsed and the failure can be seen: the cloud credential client and the secret stores behind it, the audit webhook and object store sinks, all four golden stores, the production database variable, the database URL a local environment rewrites, the control plane address, the telemetry export endpoint, the Database Lab endpoint, a model gateway's base URL, af net explain including its refusal of a wildcard used as a request host, the egress probe the MCP server answers, a host the manifest refuses in build.allow_hosts or an egress rule, and a start path af explore refuses. The audit webhook also kept the URL it was given rather than the one it checked, so a URL pasted with a trailing newline was accepted and then failed on every delivery.
A host written with a user and password in front of it, deploy:<password>@registry.example.com, is now refused everywhere a host is read. The manifest accepted it, because the port check never asked whether the port was a number. The egress engine refused it by printing the password as the port. The console's engine compiled it. All three refuse it without printing it, and the refusal cases now live in the corpus both engines are tested against, so neither can drift from the other again.
An organization's egress deny list could be walked past by writing the denied host with its port, api.stripe.com:443, which the engine reaches and the list compared as a different host. The list now compares hosts the way the engine reaches them.
The secret that seals every customer's stored provider key could not be rotated.
Replacing it made every stored key stop opening, permanently, and the failure was silent: rows recorded which key version sealed them and nothing read that column, so the application tried every row against the one key it held and reported the same failure for all of them. A value that will not decrypt is indistinguishable from a value somebody altered, so an operator saw authentication failures across every organization and no sentence saying why. The column's own comment said it existed so a rotation could find the rows still needing re-sealing. The rotation it anticipated had not been built.
The control plane now holds a SET of sealing keys addressed by version. AF_PROVIDER_KEY_SECRET is still one key and still means version v1, so an installation that has never rotated sets nothing new and its rows keep opening byte for byte; AF_PROVIDER_KEY_SECRETS takes v2=<32 bytes of base64>, comma separated, in the same grammar as AF_LICENSE_PUBLIC_KEYS and for the same reason. The two are merged rather than one replacing the other, so a rotation adds one value and never reads the old one back out of a vault to compose a combined string. AF_PROVIDER_KEY_VERSION says which version new keys are sealed under, and with several keys configured it is required: guessing which of somebody else's keys to seal their credential with is not a guess to make.
A row whose version this control plane does not hold now raises a different, diagnosable error from a row that fails authentication. It names the missing version, names the versions that are held, says in those words that this is a configuration rather than a damaged row, and carries no key material. The other error now says the key for that version IS configured, so nobody goes looking for a missing one. That distinction is the difference between a silent outage and a message, and it is the half of this change that matters most.
af-control-plane-backup reseal opens every stored credential under its own version and rewrites it under a new one. It is idempotent, resumable, and holds a batch of rows rather than the table. Each row is its own transaction and the UPDATE names the ciphertext it expects to replace, so two runs at once, or one racing a customer saving a key from the console, ends with one write landing and the other reporting the row changed underneath it. The new value is opened again, under the new key and the new associated data, before the UPDATE is composed, so a failure part way through leaves a row unchanged rather than corrupted. --dry-run writes nothing. --check opens every row whatever version it is at, which is the different question and the one to ask before throwing an old key away: "nothing left to re-seal" and "every row is at the new version and none of them open" look identical otherwise.
The rotation is proven end to end against a real Postgres, and the proof is its last step: seal under one key, add a second, confirm the old rows still open, re-seal, then REMOVE the first key and confirm everything still opens. With the old key still configured, a rotation that quietly did nothing and one that completed look the same.
docs/src/content/docs/self-hosting/rotating-secrets.md said not to rotate this one. It now carries the procedure, including how the new key reaches Key Vault, a start-up log line naming the versions a revision actually picked up, and the check to run before the old key is removed. The Terraform holds the new key as a secret it addresses rather than reads, so nothing planning the stack needs vault read access, and a manual container app job runs the tool inside the virtual network, which is the only place Postgres can be reached from.
The enterprise audit stream seals each organization's collector credential under the same secret, and it read only AF_PROVIDER_KEY_SECRET and opened every row without reading its version. So an enterprise installation could not hold the added key during a rotation, and would have refused every organization's destination the moment one began. It now reads the same three variables, opens each credential under the version it records, reports a missing key version as that rather than as an altered credential, and the re-sealing run moves those credentials together with provider keys. docs/enterprise/audit-stream.md said rotating the secret was a one way door with no re-sealing tool, and no longer does.
A rule of host: "*.zapier.com" in allow was accepted and af net explain answered a Zapier catch hook with a clean ALLOW and the sentence an exact host gets.
host: "*.zapier.com" in allow was accepted and af net explain answered a Zapier catch hook with a clean ALLOW and the sentence an exact host gets.The matcher was right, because that rule does let out every name under zapier.com at any depth. Nothing said so, and a catch hook is somebody's live automation posting to real contacts, which is the thing a rehearsal exists not to reach.
Every place a rule is explained now says how far one that lets requests out reaches when it names no host: af net explain, af net policy, the network table af init prints, the MCP egress tool's rules and probes, and the fidelity report. The JSON forms carry it as caution. A wildcard over a suffix a platform hands its customers says that it reaches every customer's names and not only yours, which is true of the catalogue's own *.supabase.co, and af init no longer prints "Nothing reaches the internet by accident" under a table holding one.
Two shapes of the same reach are refused. A star standing where the owner's name goes, as in *.com, *.co.uk or hooks.*.com, is refused outside block, because it reaches names registered by anybody, which is what a bare * does and is refused for. And egress.default: sandbox is refused as default: allow already was: a default names no credential, and a sandbox request with nothing to substitute leaves exactly as the application wrote it, so it reached every host on the internet.
af net explain and the MCP probe refuse a URL whose host is a pattern, which af net policy used to suggest and which was answered ALLOW for a host no request can carry.
The Kubernetes containment preflight used a customer's shell and tools.
Missing utilities could report success without sending a probe, and a successful preflight in one pod did not establish network policy for a newly scheduled application pod.
The engine's own proxy image now runs the preflight and a mandatory init container in every service, replica, migration and datastore stance pod. It requires a reachable sidecar before and after eight independent direct probes, and three consecutive denied rounds before releasing customer code. The probes cover public TCP and UDP DNS, IPv4 metadata, AWS and Google IPv6 metadata, and the actual Kubernetes API service address. Missing configuration, incomplete probes and the ninety second deadline all refuse startup. No metadata payload or credential is fetched.
Socket tests exercise TCP, UDP and IPv6, and an isolated cluster test exercises the first customer command in migrations, replicas and replacement pods. These finite reachability checks do not certify every CNI or detect a deliberately silent one-way UDP receiver, and they cannot prevent an operator changing policy after startup. The cluster still needs an enforcing CNI.
Generated Kubernetes containers now explicitly select the runtime's default seccomp profile. Customer image users are preserved. A namespace enforcing Restricted Pod Security still requires a non-root image and an admission configuration that sets runAsNonRoot; this change does not declare full Restricted compatibility or force a different user onto an application.
An air gapped installation refuses to pull an emulator or companion image from an external registry.
Cached images still work, and an image inspection failure is reported instead of being treated as an absent image that should be pulled.
An environment could carry HTTP and nothing else, and every other protocol failed identically whether its host was allowed or blocked.
The sidecar terminates TLS and speaks HTTP. An application that talks AMQP to Azure Service Bus, Kafka to Confluent Cloud, or the MongoDB wire protocol to Atlas resolved the name, got the sidecar's address back from the environment's resolver, and connected to a port nothing was listening on. On Docker the kernel answered with a reset. On Kubernetes the NetworkPolicy permitted egress to 3128, 80, 443 and 53 and nothing else, so the packet was dropped and the client waited out its own connect timeout. Either way an allowed host and a blocked host produced the same failure, which means the containment was real and the product did not work.
A connection on a port that does not carry HTTP is now decided on the server name in its TLS handshake, by the same policy engine every other path uses. What the sidecar cannot do on that path is read inside, and that limit is refused rather than absorbed: capture, mock, synth and sandbox all need a request, so a rule using one of them on such a port is refused at validation when the rule names the port and at the connection otherwise. Sandbox is the one that would have been worst to accept, because forwarding without replacing the credential sends the application's own key to the real provider.
The set of ports the sidecar opens is the manifest's, not the protocol table's. The first version of this seeded the listeners from the table, so every environment answered on all sixteen ports whether a manifest had asked for one or not. That is not an idle listener. A connection accepted on this path is decided on the name in its TLS handshake, and a rule that spells no port matches every port, so a manifest that allowed one host for HTTP silently carried that host's Redis and its mail as well, forwarded. The listeners are now opened only for ports a rule names, which is the bargain the rest of the manifest already makes.
A listener is shared by destinations, so selecting its ports was not sufficient. A rule granting broker.example.com:5671 also exposed that port to a portless allow rule for website.example.com. The matching rule now has to name the actual destination port. A real recording broker reproduced the unintended connection and now receives none.
Path and method rules also cannot govern bytes the sidecar never reads. The stream path refuses hosts whose applicable rules require inspection, rather than matching them against an invented CONNECT request at /.
On Kubernetes the NetworkPolicy is the union of the table and the ports the rules name, which is wider than the listeners on purpose. Permitting a port there grants nothing, because the packet still arrives at the sidecar and the sidecar still decides; a port permitted with nothing listening is refused in a millisecond, while a port the policy omits is DROPPED and the application hangs until its own connect timeout. Without the rules in that union, declaring a broker on an unusual port would work on Docker and hang on a cluster.
A Route 53 Resolver DNS Firewall rule group that let every query through was reported as closing the resolver.
AWS processes a rule group by order of priority starting from the lowest, and defines ALLOW as permitting the request to go through and ALERT as permitting it and sending metrics and logs. So a group holding ALLOW on every domain at priority 5 and BLOCK on every domain at priority 1000 blocks nothing at all, and the check called it closed because every assertion it made about the terminal rule was true. It reads as configured in a console screenshot. The check now refuses that shape, a terminal rule moved off the end by priority, two rules sharing the last priority, which AWS refuses to create, and a domain with a star anywhere but the front, which a DNS Firewall domain list cannot hold. Each has a mutation aimed at it.
The failure mode of that association was a bool and the AWS field takes three values. The third takes the answer from a setting no plan carries, so a plan holding it has not decided whether a query the firewall cannot evaluate is permitted, and a bool rounded that to whichever answer the zero value gave. It is the AWS value now, and that case reports unproven rather than either answer.
Two paths the first draft of the enumeration counted as open are closed, which is why the report says ten of thirteen and not eight. Both were called open because a Fargate task must reach the registry to start, and that answers the weaker half of the question: reaching ECR cannot be closed and reaching any repository, any log group and any bucket in the region can, by a VPC endpoint policy, and only the second is an exfiltration path. The generated ECR policy would also have denied the login, because ecr:GetAuthorizationToken takes no resource and was named against a repository.
The generated firewall would have broken the environment it protects. Its allow list held four hardcoded AWS names, so a manifest declaring a host the sidecar forwards to would have had that host fail to resolve. It reads the manifest's egress catalogue now, and a declared host it cannot express as a rule is named in the refusal rather than dropped or widened.
Instance metadata still cannot be closed on Fargate, and it can now be answered. A verdict carries the grade of evidence behind it, so a closure computed from a JSON document is counted apart from one recorded by an attempt made inside a task in the customer's own account. No recorded attempt leaves the path unproven, and nothing moves it on an absence.
The credential gate said a tree was clean when it had read no files.
no credentials in the tree is a required context on every pull request, and tools/scanrepo printed one sentence for two different outcomes. Its walk swallowed the callback error, the stat error and the read error, each with a bare return nil, and nothing counted what had been examined, so a root that does not exist, a directory the scan could not enter, and a genuinely clean repository all produced "no live credentials in the tree" and exit 0. A credential inside a directory that would not open was not found, and not finding it was reported as its absence.
Its own suite pinned the gap as correct. A case called TestAMissingRootIsNotASilentPass asserted the silent pass: it required a nil error and no findings from a root that is not there, and closed with a comment saying scanrepo trusts its argument. So the test list carried a green tick under a name promising the hole was closed.
There are three answers now. A credential was found, the tree was read and is clean, or the scan could not look. The last one is refused and says which paths went unread, and the pass now reports how many files it read. The wording is the one fourteen other checks in this repository already use for this, which is the point: the idiom existed and the credential gate was the tool that did not use it.
Thirteen container images were named by a tag, so nothing in the repository recorded what the tests had run against.
postgres:17-alpine appeared in eight places across four workflows, three more in the justfile, and once in the preview script, and quay.io/keycloak/keycloak:26.0 in the single sign on conformance harness. A tag is a name the publisher can repoint. The Postgres tag resolved to 17.11 when this was written and will resolve to 17.12 with no commit here, which means the server that the row level security suites proved things about is whichever build carried the tag that morning. This repository already made the argument against itself, in engine/pkg/emulator/aws.go, where LocalStack is pinned by digest because a tag that moves changes what an environment was tested against without anything in this repository changing. A database is the harder case, not the softer one.
All thirteen now name the multi architecture index digest, so a runner and a developer's Mac resolve one declaration to their own image. Dependabot does not and cannot refresh them: its docker file fetcher accepts a file only when the name matches a Dockerfile, or it is a Helm values file, or its first YAML document carries both apiVersion and kind, and a workflow carries on and jobs. So the refresh is a person's job and the procedure is written beside the pin. Three tests in tools/gatecheck hold it: one refuses a moving tag anywhere a container is started, one refuses a tree where two sites pin the same image to different digests, which is what a half finished bump looks like, and one proves the check still says no.
The apt packages beside them, postgresql-client-17, zsh and libsecret-tools, are deliberately left unpinned, and the reason is recorded with the rest.
This repository's own manifest carried a private key, and the check named "no credentials in the tree" passed over it.
antifailure.yaml declared AF_GITHUB_APP_PRIVATE_KEY with value: set to a 2048 bit RSA key, base64 encoded rather than PEM. The comment beside it gave the reason for the encoding: the manifest validator refuses a literal beginning with BEGIN, and base64 does not begin with BEGIN. The published schema says what a manifest is in as many words, that it declares the name and where the value comes from and never the value itself, and the file held up as the example of that was the one breaking it.
The detector behind the credential gate had a matching hole, and it was a deliberate decision taken one step too far. A bare PEM is not evidence of Google, so reporting one as a service account key would send somebody to rotate a credential they do not have. That was implemented as reporting nothing at all, so any private key with no cloud marker beside it was invisible to the gate, to the proxy tripwire and to the manifest validator. It now reports a private key under its own name, owning no provider, and Google's own marker still promotes the finding to the more specific one. A second pass decodes base64 before deciding, so the encoding that walked past the validator does not walk past this.
The key itself is supplied rather than written down. A manifest declaring a webhook path for GitHub is rehearsing a GitHub App, so the engine now generates GITHUB_APP_PRIVATE_KEY for the life of the environment and offers it to the chain the way it already offers the webhook signing secrets. Nothing is configured outside the manifest and af up needs no setup it did not need before.
The detector that stops a production credential reaching a sandbox could not see Google Cloud or Azure.
It knew seventeen shapes and every one belonged to a vendor that ships a prefix, which is why AWS was in it and the other two large clouds were not: their most dangerous credentials do not have one. A GCP service account key is a JSON file whose private key field holds a PEM block, and there is nothing in the key material that says Google. An Azure storage account key is 86 characters of base64 and nothing else; what says Azure is the field name beside it in the connection string. So an environment holding either one could act on the real project, and the refusal that exists to prevent exactly that let it through.
It now recognises three Google shapes, the API key, the OAuth access token and the service account key, and three Azure ones, the storage account key, the shared access key that Service Bus, Event Hubs, IoT Hub and Relay all sign with, and the Entra client secret. A shape with no prefix of its own is only a finding when a marker naming the provider sits near it, because reporting a bare TLS key as Google's would send somebody to rotate a credential they do not have.
The live versus test rule is unchanged and it now covers a provider that draws no distinction in the credential itself: Azurite's published development key is live shaped and is exactly what a sandbox is supposed to hold, so it is told apart by the account name in the same connection string and is not refused.
af mask verify said clean, with zero findings, about a database in which af mask plan listed 145 columns as copied unchanged, nine of them Stripe identifiers and two of them sealed private key material.
af mask verify said clean, with zero findings, about a database in which af mask plan listed 145 columns as copied unchanged, nine of them Stripe identifiers and two of them sealed private key material.Three things let it.
The scan read six text types and nothing else. A bytea, an array or an enum was not read, not skipped and not counted, so a column holding a sealed private key was invisible to the one check that gates publication, and a comment in the detector file claimed a source value check covered what the shape detectors missed. No such check existed.
The credential detector knew Stripe's secret key prefixes and not its object identifiers, so even the text columns holding cus_ and sub_ values, which the scan did read, tripped nothing. A Stripe customer id is a live pointer into a real account, the same in every environment, and it was copied into every golden.
The 145 count was printed once, at the very end of af mask plan, and nothing downstream carried it: not af mask apply, not af golden refresh, not the attestation, not af golden list, which said verified.
Now the scan reads every column it can read as text, decodes bytea as UTF-8 where it decodes, lists every column it could not read by type, and fails when such a column has no rule and a name that says it holds a secret (AF-MSK-013). A provider-identifier detector recognises Stripe, PostHog and Resend id families. The count of columns copied unchanged with no rule is printed at the top of the plan, beside the verdict of af mask verify, af mask apply, af golden refresh and af golden pull, in a NO RULE column of af golden list, in the attestation, and in inspect_goldens. Two transforms were added for the rules that were missing: prefixed_id, which keeps a cus_ prefix and hashes the body, and repository, which masks an owner/name the way username masks a handle. This repository's own masking.yaml names every Stripe identifier, every bytea column, and repositories.full_name, which was preserved by rule and is the customer's own GitHub organization.
Every rate limit on sign-in could be walked around with one request header, and the sign-in audit trail recorded whatever that header said.
The control plane read the client's address from the FIRST entry of X-Forwarded-For. Every proxy appends the peer it saw to the END of that header and leaves whatever the caller sent in front, and Azure Container Apps ingress, the only proxy in front of the hosted control plane, documents exactly that: only the rightmost entry is its own, and everything else has to be treated as the caller's. So a caller who sent X-Forwarded-For: 10.0.0.1 arrived as 10.0.0.1, <their real address>, the limiter on /auth/github, the OAuth callback, the magic link, the device code, the invitation and the operator sign-in keyed on 10.0.0.1, a new value on each request opened a new bucket each time, and the address written beside the session was the one they typed. The operator sign-in route passed the header to the database raw as well, so a request through two proxies failed the inet cast and answered 500.
The trusted entry is now counted from the right, and both the limiter key and the audited address come from the same selection. An entry that is missing or is not an address puts the request in one shared bucket rather than exempting it, so a header full of garbage is limited harder, never softer.
For an operator there is one new variable, AF_TRUSTED_PROXY_HOPS, the number of proxies every request passes through before the process, default 1, which is the Container Apps ingress alone and the Helm chart's one ingress controller. Set 2 when a Front Door, an Application Gateway or a WAF that also appends to the header sits in front of the ingress. The Terraform module and stack take it as trusted_proxy_hops and the Helm chart as config.trustedProxyHops; a value that is not a whole number from 1 to 16 stops the process at startup, and the startup log says which entry is being read.
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.
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.
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.
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
This release predates the published changelog.
v0.1.0
Released
This release predates the published changelog.
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.
Bring your next pull request.
See how Antifailure would test your stack, what it would check, and the evidence your team would review before merging.