# Antifailure: full text of the public site > Generated from the rendered, indexable pages at build time. > Canonical: https://antifailure.dev > A disposable copy of your production stack for every pull request: masked Postgres, contained third-party APIs, and agents that use your app like people. # Know what happens before you deploy, on a disposable production twin. Give your coding agent a production twin through MCP. Test migrations, user flows, and load on masked data, then use the findings to fix the change before you ship. - Isolated Twin Your stack, isolated for each change. - Safe State Masked Postgres. Relationships intact. - Side-Effect Firewall Test payments and mail. Contain external calls. - Load Your traffic mix. Against the new build. - Migration Safety Locks, rewrites, plans. Before you deploy. ## See Antifailure catch a costly migration. Follow a schema change from pull request to rehearsal. See the lock it holds, the finding it produces, and the environment being removed. - A copy of production, same size and shape - Your change runs there first - Then the copy deletes itself - Migration Safety - Isolated Twin - Safety properties - Load - Side-Effect Firewall ## Catch the migration that stalls your app. Your agent can rehearse it before you deploy. While this lock is held Antifailure finding ### This change makes reads and writes wait. The type change rewrites the orders table under an ACCESS EXCLUSIVE lock. Reads and writes to orders wait until PostgreSQL releases it. **Affected table** orders **Lock type** ACCESS EXCLUSIVE **orders** ≥10.5s **orders_pkey** ≥5.0s **orders_customer_id_idx** ≥2.2s Sampled lower bounds on the demo database. What the agent receives The MCP result carries the finding, measured lock, and evidence. Your agent has something concrete to fix before merge. Original finding from a recorded demo rehearsal. The lock duration is a sampled lower bound; the revised plan is illustrative and has not been measured here. #### Original migration The type change rewrites orders under an ACCESS EXCLUSIVE lock. Reads and writes wait while it is held. The recorded demo measured a lock on orders for at least 10.5 seconds. #### Agent's proposed revision Add a new bigint column, backfill in batches, cut over reads and writes, and remove the old column later. This is a proposal, not a passing result. Rehearse the revision before merge. ## Your stack. Your data shape. One isolated run. Give each change its own environment, then remove it when testing ends. - Environments - Runs - Masking - Network - Audit ### Environments An isolated copy of your app for each change. antifailure/demo-orders | Environment | Branch | State | | demo-orders-default-side-eff-e3aa26 | Branch: default (side-effect baseline) # 1 | Torn down | | demo-orders-default-27b977 | Branch: default # 1 | Torn down | | demo-orders-drop-the-per-mer-faeda1 | Branch: drop-the-per-merchant-scope-on-order-reads | Failed | Antifailure console, adapted for this preview. Selected demo environments. - Isolated networking Each twin has its own network. Gateway policies control which external services it can reach. - Safe credentials Use test credentials and route payments, messages, and other external calls through your test policies. - Cleanup proof Review the teardown record to see which tracked resources were removed. ## Make the call with evidence. Review what ran, what failed, and how to reproduce it. ### Fail closed. Unknown destinations are denied inside the twin, and an unverified golden cannot be branched. env-4c1 reached for an unlisted host No rule for it. Denied, and written to the log. ### Traffic shaped like production's. The route mix out of your own access log, with the worst regression first. ### Pass or fail, with evidence. A gate on the pull request carrying the rows and the trace behind it. ## Test the routes your users rely on. Replay your production traffic mix against the new build. Three commands. af init reads the repository and writes the manifest, af up builds the twin around a branch of the golden, af test runs the workflows and returns verdicts with evidence. With a production database, af golden refresh once before af up. - Read the repository, wrote antifailure.yaml - Branched a verified golden for this change - Contained Stripe and email inside the twin - Ran the declared workflows, attached the report Try for yourself, start proving a change before it ships. Example Example project using the Antifailure CLI. ## Test payments without charging customers. Check emails without sending them to users. Your application tries to reach api.stripe.com POST /v1/charges amount 49.00 USD The request reaches the twin's egress policy before it can leave. Policy decision ### Checkout completes. Nobody gets charged. The app receives a simulated payment response from the local pack. The real processor never receives this test charge. Your agent sees the policy decision and observed counts through inspect_egress_firewall . Illustrative policy decisions, not live customer telemetry. If the decision log cannot be read, Antifailure returns INCONCLUSIVE rather than claiming zero effects. #### Payment mocked A POST to api.stripe.com receives a local simulated response. The test checkout continues without a real processor charge. #### Email captured A POST to api.sendgrid.com is retained in the test inbox. The message can be inspected without sending it to a customer. #### Production API blocked A POST to the unlisted api.prod.internal host matches no rule. The default block policy refuses it before it can touch the live service. ## Realistic tests. A clear boundary. Keep production data in your cloud while your team reviews results in the control plane. - 01 Mask data in your infrastructure Replace customer details and credentials before a test environment uses the database. A signed attestation records the masking checks. - 02 Control external calls Choose sandboxes, mocks, or captured messages for each integration. The gateway blocks destinations you have not configured. - 03 Track the environment to teardown Every created resource is recorded in a journal. Cleanup uses that record to remove the environment and report the outcome. Antifailure + your coding agent ## Test the change. Then make the call. See Antifailure rehearse a change from your stack. Review the findings, then decide what to ship. --- > Canonical: https://antifailure.dev/about > What Antifailure builds, how a run produces evidence, where the project stands, and the limits it states plainly. # Confidence before the change reaches production. Antifailure gives developers a place to test the change they are about to ship, with realistic data, running services, and evidence they can inspect. ## Make production conditions part of the review. Antifailure builds a disposable copy of your production stack for every pull request. It branches a masked, referentially consistent Postgres from a verified golden, builds your services and then runs them inside a sandbox that cannot reach the internet except where you say it can, drives real workflows with agents that use the accessibility tree the way a person does, and rehearses pending migrations for exclusive locks, table rewrites and query plan regressions before any of it reaches production. A pull request can pass its tests and still behave differently on a large database or across several running services. Antifailure brings those conditions into the review, while there is still time to change the code. The implementation, plans, and known limits are public in the source repository . Installation and operating details live in the documentation . ## Build. Test. Review. Clean up. - Build a disposable twin The change gets a temporary copy of the application stack instead of sharing a long-lived staging environment. - Prepare verified state Postgres data is masked inside the customer boundary, scanned, and attested before an environment can use it. - Contain and exercise Egress policy controls external effects while deterministic workflows and exploratory agents use the running application. - Report and remove The run returns evidence and a verdict, then cleanup removes the resources it created and records the outcome. ## Inspectable where trust matters. The repository is MIT licensed except for the separately licensed ee/ directory. The community build excludes that directory rather than hiding it behind a runtime switch. The customer-side engine, masking, containment, and cleanup paths remain inspectable where they handle sensitive state. See the open-core license boundary and the control-plane architecture . ## Understand what each result covers. Review each result alongside the checks that ran and the environment they used. The report identifies missing coverage so your team can decide what to test next. Read the terms and stated limits or the release-security model . - Product The parts of a run and the evidence they produce. Read - Privacy Data boundaries, the processors this site uses, and your controls. Read - Contact Documented routes for private and public questions. Read ## 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. --- > Canonical: https://antifailure.dev/acceptable-use > Where the product may and may not be pointed, and what we will not do to a customer. # It rehearses failure. Point it only at systems you are allowed to break. A short policy, because a long one is a policy nobody reads before doing the thing it prohibits. The product's whole purpose is to drive an application into its worst conditions, which makes where you aim it the only question that matters. ## What the product may not be pointed at. - Pointing the agent at a system you are not authorised to test. The product exists to exercise an application until it breaks, and doing that to somebody else's is an attack however it is labelled. - Naming a production database in source_url_env that you do not have the right to copy, including one holding another company's data under a contract that does not allow it. - Using a golden as a way to move production data somewhere it is not allowed to go. A masked copy is still derived from the original, and masking is a reduction of risk rather than a change of jurisdiction. - Running the load generator against a third party you do not control. Containment holds inside the environment; a host you allow through it is a host you are sending real traffic to. - Feeding the product data you are prohibited from processing, including special category personal data, payment card data, and anything under an export control you have not cleared. - Reselling access, or running the hosted control plane as a service for others, without a written agreement that says you may. ## What we will not do, which is the half worth writing down. - Read your production database. The engine reaches a source only through pg_dump and a read only transaction, and no part of the hosted control plane holds a source connection string. - Use your data to train a model. Nothing in this product sends customer data to a model provider for training, and there is no path that would. - Take a snapshot of production into the hosted control plane. It is not a backup target, and the trust boundary is drawn so that it cannot become one. - Enforce this policy by reading your environments. Enforcement is a conversation, because the alternative is a surveillance capability nobody asked for. ## What happens if this is broken. | How we would find out | A report, a bill, or a provider telling us. There is no monitoring of what an environment contains, and building one to enforce this policy would cost more privacy than the policy protects. | | What we would do | Ask first. An organization can be suspended, which stops new work and leaves the data in place, and that mechanism exists in the control plane today. | | Immediate suspension | Reserved for an active attack on somebody else, or a legal demand we have to act on. Everything else gets a conversation before anything stops. | | Appeal | Write to the contact on this site. There is no formal appeal process yet and pretending otherwise would be worse than saying so. | - Terms of Use Scope, warranty, and liability. Read - Developer policy The API, the MCP surface, and the tokens that reach them. Read - Privacy Notice What we collect and never take. Read ## 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. --- > Canonical: https://antifailure.dev/blog > Notes on shipping schema changes without taking production down: what staging cannot measure, and what a test environment should do with an outbound call. # Notes on shipping schema changes without taking production down. What staging cannot measure, what masking has to prove, and what a test environment should do with an outbound call. Written from the parts of the system that already work. - August 29, 2026 · Postgres Why migration timing changes with your data A migration can pass on staging and still hold a long lock in production. Rehearse it at realistic row counts before you deploy. Read it → - August 28, 2026 · Postgres Check the data after masking it Masking rules can miss new columns and copied personal details. A read-back scan checks the result before a test environment uses it. Read it → - August 27, 2026 · Testing Choose how external services behave in a test Mock payments, capture messages, or use a sandbox. A policy for each host lets your application run complete flows inside a controlled environment. Read it → There is a feed . ## 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. --- > Canonical: https://antifailure.dev/blog/five-answers-to-an-outbound-call > Mock payments, capture messages, or use a sandbox. A policy for each host lets your application run complete flows inside a controlled environment. # Choose how external services behave in a test Mock payments, capture messages, or use a sandbox. A policy for each host lets your application run complete flows inside a controlled environment. Your application still calls its integrations in a test environment. A signup sends email, a subscription flow calls a payment API, and a background worker posts a webhook. Each integration needs an intentional response. Antifailure lets you set a policy per host, so one run can capture email, simulate payments, and block unknown destinations. ## Seven modes for different jobs - BLOCK. Refuse the request and record the gateway decision. This is the default for unlisted hosts. - ALLOW. Permit explicitly configured calls with a rate limit. - SANDBOX. Use test credentials and reject a live key detected on the way out. - CAPTURE. Store email or SMS in a searchable test inbox. An agent can retrieve a magic link and complete a sign-in flow. - MOCK. Return configured responses. The built-in Stripe pack maintains state across supported payment and subscription flows. - EMULATE. Route calls to a service such as LocalStack or Azurite running inside the environment, without changing the application's endpoint. - SYNTH. Generate a response with a model. Checks that depend on a synthesized response remain unverified. ## Use stateful mocks for a complete flow A single response fixture is useful for testing one API call. A subscription lifecycle needs more: creating, changing, renewing, and cancelling must affect what later requests return. The Stripe pack supports checkout, subscriptions, renewals, and cancellations with signed webhooks. This lets a test exercise the application code that handles those events without reaching a live payment processor. ## Keep the policy at the network boundary The running application shares a network namespace with a gateway sidecar. Outbound traffic must pass through the gateway, including calls from libraries that ignore proxy environment variables. When a new integration introduces an unlisted host, the gateway blocks the request. Configure the appropriate mode before testing that flow again. A denied request is recorded; it does not by itself mean every check in the run failed. ## Inspect what happened Use the gateway log to see which requests were allowed, captured, mocked, or refused. Direct connections stopped by network isolation never reach the gateway and do not appear as gateway log entries. Read the egress policy guide for configuration and examples. ## Also here - Why migration timing changes with your data A migration can pass on staging and still hold a long lock in production. Rehearse it at realistic row counts before you deploy. - Check the data after masking it Masking rules can miss new columns and copied personal details. A read-back scan checks the result before a test environment uses it. ## 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. --- > Canonical: https://antifailure.dev/blog/proving-the-masking-worked > Masking rules can miss new columns and copied personal details. A read-back scan checks the result before a test environment uses it. # Check the data after masking it Masking rules can miss new columns and copied personal details. A read-back scan checks the result before a test environment uses it. Replacing an email address in one column does not remove every copy of it. The same detail may appear on an invoice, inside a webhook payload, or in an old support note. Masking needs two stages: transform the data, then inspect the result. Antifailure keeps both stages inside your infrastructure. ## Where personal details can remain - Copied fields. An invoice may preserve the billing address from the time of purchase. - JSON payloads. Stored API responses can contain details several levels deep. - Free text. Notes and messages may contain phone numbers, addresses, or keys. - Audit history. Event records can retain values that have since changed elsewhere. - New columns. Schema changes can add fields that existing masking rules do not cover. ## Preserve relationships while replacing identities If two related values are replaced independently, a join or application workflow can break before the test reaches the code you changed. Deterministic masking maps the same input to the same replacement across tables and refreshes. Antifailure compiles masking rules to SQL and runs them in resumable chunks. Related data stays consistent, and an interrupted masking operation can resume. ## Read back the result After masking, a scanner checks every table and column for values that resemble emails, card numbers, phone numbers, or keys. It samples up to 2,000 rows per column by default. The sample size matters. This check can identify an unmasked column, but it does not prove that every row is free of sensitive data. The signed attestation records the sample size and findings so the scope is visible. ## Verify before branching A reusable snapshot, called a golden, must pass verification before Antifailure can branch it. A failed scan stops environment creation so your team can correct the masking rules first. Keep the attestation with the run evidence. It records what was checked and gives reviewers a concrete basis for deciding whether the data is ready for testing. See the data preparation flow or read the data boundary guide . ## Also here - Why migration timing changes with your data A migration can pass on staging and still hold a long lock in production. Rehearse it at realistic row counts before you deploy. - Choose how external services behave in a test Mock payments, capture messages, or use a sandbox. A policy for each host lets your application run complete flows inside a controlled environment. ## 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. --- > Canonical: https://antifailure.dev/blog/what-staging-misses-about-migrations > A migration can pass on staging and still hold a long lock in production. Rehearse it at realistic row counts before you deploy. # Why migration timing changes with your data A migration can pass on staging and still hold a long lock in production. Rehearse it at realistic row counts before you deploy. A schema change can be valid SQL, pass review, and work on staging while still holding a costly lock in production. The difference often lies in the data the statement has to read or rewrite. A small test database helps check correctness. To understand migration cost, you also need realistic row counts, data distributions, and observations from the database while the statement runs. ## Lock mode and lock duration answer different questions A migration linter can identify statements that request strong locks. The time those locks remain held depends on the work Postgres performs, the available resources, and other activity in the database. That distinction matters for a type change that rewrites a table. The SQL may be a single line, but Postgres has to process the existing rows while holding the required lock. ## What to carry into a rehearsal - Row counts. A rewrite on a large table can take much longer than the same change on a fixture database. - Data distribution. Skew, nulls, and large accounts affect planner choices. Uniform seed data may produce different query plans. - Concurrent activity. Other sessions can wait for a migration or delay its lock acquisition. A quiet branch alone does not reproduce production contention. - Environment differences. Hardware, indexes, and storage conditions affect timing. Treat rehearsal measurements as observations of that environment. ## What Antifailure measures Antifailure applies pending migrations to a disposable branch of a masked database. It records statement durations, samples locks from another connection, observes table rewrites, and compares query plans. Lock sampling records the strongest mode held per table, a lower bound on its duration, and whether another session was observed waiting. The report also includes migration lint findings and suggested changes. In a recorded orders-app demo, changing total_cents to bigint rewrote the orders table. The report recorded an AccessExclusiveLock on that table for at least 10.5 seconds. That is a measurement from the demo branch, not a forecast for another database. ## Plan the path back Reverting application code does not undo every schema or data change. Dropping a column removes data; a backfill may create values an older release does not expect. For a column type change, consider an expand-and-contract sequence: add a new column, backfill in batches, move reads and writes, and remove the old column in a later migration. Rehearse the sequence and check compatibility with the previous release. ## Put the findings in the review A useful report names the statement, the affected table, the observed lock, and the conditions of the test. Your team can then decide whether to change the migration, adjust its timeout, or gather more evidence before deployment. Read the migration rehearsal guide to set up the checks for your repository. ## Also here - Check the data after masking it Masking rules can miss new columns and copied personal details. A read-back scan checks the result before a test environment uses it. - Choose how external services behave in a test Mock payments, capture messages, or use a sandbox. A policy for each host lets your application run complete flows inside a controlled environment. ## 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. --- > Canonical: https://antifailure.dev/careers > Two founding roles. Founding engineer: 0.5% to 2% equity. Founding growth: 0.25% to 2% equity. No salary for either currently. Read the terms and apply on the page. # Build the proof before the deploy. Join as a founding engineer or help developers discover Antifailure. Both roles offer equity and currently have no salary. ## There is no salary for either role currently. Both equity ranges are written down here. Your equity offer will be agreed within the published range. Neither role currently pays a salary. Salary timing, vesting, working location, and benefits have not been finalized. We will discuss these terms before you decide to join. ## Two founding roles. Room to own your work. - Founding engineer 0.5% to 2% equity No salary currently Build the path from a developer's repository to a working production rehearsal. Own the engine, the control plane, or the developer experience, and follow your own work all the way to a result somebody can inspect. Show us something you built, a hard failure you diagnosed, or a system you made dependable. A public repository is welcome and not required. Apply for this role - Founding growth 0.25% to 2% equity No salary currently Help developers find Antifailure, understand what it does, and reach their first useful result. Own the experiments in positioning, distribution and activation, with real conversations behind the numbers. Show us something you helped people discover or adopt. Say what you tried, what changed, and what you learned when it did not work. Apply for this role ## Show us your work. A short introduction is enough. Read by a person, never sold or added to a newsletter. Removed after 180 days, and described in our privacy policy . No account and no resume upload. Please leave out confidential work, credentials, and anything sensitive about you that this does not ask for. Applications are reviewed privately and deleted after 180 days. You can request earlier deletion. - About the project Why we are building Antifailure. Read - How it is built The control plane, the engine, and where the boundary between them falls. Read - What we hold about you How we store, review, and delete application data. Read ## 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. --- > Canonical: https://antifailure.dev/changelog > Every change to the engine, the control plane and the site, written when it was made and dated by the commit that landed it. # What is new in Antifailure. New features, improvements, and fixes, newest first. ## Unreleased Not released yet · 47 entries · landed 27 September to 25 October 2026 ### added 6 entries Something the product could not do before. #### A MySQL datastore can hold a masked, verified golden. engine: mysql with stance: golden copies the production database named by source_url_env , masks it with the same rules and transforms as Postgres, scans it with the same verification, and gives every environment its own branch at AF_DATASTORE__URL . Until now a MySQL beside the primary Postgres could only be declared empty, so that half of the product was blank in every twin. The copy is production at one instant, read in one transaction, and it keeps what a naive dump loses: latin1 and binary columns byte for byte, zero dates, TIMESTAMP values, JSON, foreign keys and each table's AUTO_INCREMENT position. Views, triggers, stored routines and events are not copied, and a refresh names every view it left out. A branch is a full copy rather than shared storage, so it takes longer the larger the golden is. A source on another machine is reached over verified TLS by default, and a server that cannot present a certificate this machine trusts is refused by name with AF-DB-046. ?tls=preferred , ?tls=skip-verify or ?tls=false in the address accepts the risk explicitly, a source on this machine defaults to preferred , and af explain prints which mode each source uses. The source must be a mysql:// URL. Any other form, a mariadb:// URL or a driver DSN, is refused rather than read as no source, and a column no rule covered that the scan cannot read, such as non UTF-8 bytes in a column named like a key, refuses the golden rather than publishing it. af init now proposes a MySQL it finds beside a Postgres as a golden store. The engine runs MySQL 8.4 locally for the goldens and branches, in a container called af-mysql . See the datastores page for the details. a-mysql-datastore-holds-a-masked-golden #### A failed run's video, trace and screenshot can now be opened from the console. The runner has always recorded them, into the environment's artifacts directory, and nothing sent them anywhere. On a CI runner that directory goes when the job does, so the person sent a link to a failed run had a verdict and no way to see why. The run page's artifacts card was there, and empty. When a run reports to a control plane and the organization has turned on artifact storage, the engine now uploads each workflow's recordings after the run finishes and emits artifact.stored for each one the control plane kept. The run page lists them by workflow, with the date each expires and a link to open a video or a screenshot in the browser or download a trace. Settings has an Artifact storage card for the switch and the retention, 1 to 365 days with 30 as the default. AF_ARTIFACTS=local keeps a machine's recordings where they are whatever the organization decided. The bytes are kept in the control plane's Postgres database, behind a store interface that each artifact row names, so an object store can be added as a second backend later without moving the files already stored. Each organization keeps at most 256 MiB, which AF_ARTIFACT_ORG_QUOTA_MIB changes for a self-hosted installation, and a file stops being served the moment it expires rather than when the housekeeping sweep next removes it. Deleting an organization deletes its recordings with it. See run recordings . a-failed-run-could-not-be-seen-after-its-runner-was-gone #### Windows is a platform a release ships to. Every tag now publishes antifailure__windows_amd64.zip and antifailure__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. windows-had-no-release-and-no-installer #### 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. af-update-on-windows #### 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. terminal-never #### 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. local-agent-replay ### changed Behaviour that already existed and now works differently. #### The composite action that a workflow runs with uses: antifailure/antifailure@v1 now installs Node with actions/setup-node v7.1.0, up from v7.0.0, and keeps the workload result with actions/upload-artifact v7.0.2, up from v7.0.1. Both are patch releases of the actions it already used, pinned to their commit, and the action's inputs and outputs are unchanged. composite-action-node-and-upload #### The site's Solutions menu is now called Use cases, in the header, the footer, the docs site's navigation, the page titles and the eyebrow above each page. The addresses under /solutions are unchanged, so existing links keep working. solutions-is-now-use-cases #### 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. dependency-updates-held-by-stale-notices #### 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. homepage-interactive-safety-examples #### 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. homepage-safety-results #### 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. feature-strip-unlinked ### fixed 26 entries Something that claimed to work and did not. #### A waiter for a branch lock on Windows gave up on a lock that was only part way through being let go. The wait for a remove still in progress was a hundred retries a millisecond apart, so on a machine whose sleeps are exact it lasted a tenth of a second, and anything holding the file open a little longer, an antivirus scan or an older af reading the lock, made the waiter fail with "the lock changed hands 100 times" instead of taking it. The wait is now two seconds by the clock, and a lock still refusing after that is reported with what the last look saw. a-lock-being-let-go-is-waited-for-by-time #### A run that uploaded its recordings could show fewer of them on its timeline than it had sent. Each upload emits an artifact.stored event and the run then flushes its events to the control plane, but the flush could happen while the last of those events was still on its way to the part of the engine that sends them, and that event was left behind. The flush now waits for every event emitted before it, so the timeline lists every recording the run uploaded. a-recording-upload-could-go-missing-from-the-timeline #### A browser workflow whose time budget ran out while its browser was still opening could hang the runner for good on Windows. The budget stops the open by closing the browser, and the open was then meant to fail with whatever call it had in flight. One call did not fail: when the stop landed while the first page was being made, as its video recorder started, the browser and the recorder both exited and Playwright's newPage neither resolved nor rejected. Stopping opens every 2 ms between 108 and 132 ms in on a Windows Server 2022 VM, 4 of 13 never came back within a minute, with no browser left running. The workflow's verdict never arrived, and the required Windows check timed out a test after two minutes and then sat until the job's own thirty. A stop now ends the open itself as soon as the browser has closed, so nothing waits on a call the closed browser may never answer. Over the same window, 2 ms apart from 100 to 140 ms and three times over, 9 of 63 opens never came back before and 0 of 63 after, with no browser left running in either. a-browser-stopped-mid-open-never-came-back #### On Windows a process that only looked at a branch lock could leave it held for the rest of the holder's life. Asking who holds a lock opened the lock file without sharing delete, and Windows refuses to remove a file that any handle has open that way, so a holder releasing its lock while somebody else was reading it failed. Every caller drops that error, so the file stayed, it named a process that was still running, and every later attempt to take the lock was refused with AF-RUN-003 until that process exited. For af mcp , which can hold locks for hours, that was a branch nobody could use until the server was restarted. The required Windows check reached it from the replay store's own test, which retires and imports one incident at the same time. The lock is read sharing delete now, a release retries the moment another process is still in the middle of a remove, and a waiter treats that moment as the lock changing hands rather than as an error. Taking a lock also published the lock file before writing its owner into it, so a waiter that looked in between found an empty file, took it for a crash and removed it. On Unix that could remove a lock a live process was still writing; on Windows the remove was refused and the waiter failed instead of queueing. The owner is written to a private file first and linked into place, so the lock never exists without its contents. And on every platform, a lock released between a waiter's attempt and its look used to count as a reclaim, so after two of them the waiter reported "no such file or directory" where it should have gone on waiting. It now tries again. a-process-that-looked-at-the-lock-kept-it-held #### A console path with a broken percent escape was answered as a server failure. A request such as /% or /%zz reached the console's page lookup with an escape nothing could decode, the lookup threw, and the control plane answered 500. Any scanner on the internet could produce those, and to a monitor they read as the control plane failing. Such a path is now the console's own 404 page, like any other page that does not exist. a-broken-escape-in-a-console-path-was-a-500 #### A control plane run from a directory with a space in its path served no console. With AF_CONSOLE_DIR unset, the control plane finds the console build from where its own source sits, and it read that location without decoding it. Under ~/My Projects/antifailure it looked for the build in My%20Projects , found nothing, logged NO CONSOLE BUILD and answered every page by saying so. On Windows the same path began /C:/ . The location is now decoded, so the build beside the source is found. The published image runs from /app and was never affected. a-console-under-a-path-with-a-space-was-never-found #### On Windows, a terminal workflow could miss the screen a program opened on. Windows writes a few setup bytes to a program's terminal before the program has drawn anything, and none of them puts a character on the screen. Antifailure took them for the program's first screen, read a blank grid, and sent the first key while the program was still starting. The program then drew its opening screen and answered the key at once, so the report began on the screen after the first key, as if that key had been pressed on a screen nobody saw. Before the first key, Antifailure now waits until something is on the screen, up to the same three seconds it already gave a program that printed nothing. a-terminal-workflow-on-windows-missed-the-opening-screen #### A plus address written in two cases was masked to two different addresses. The email transform masks Person@Example.com and person@example.com to the same address, because they are the same mailbox. The tag it keeps for a plus address was worked out from the address as written, so Person+News@ and person+news@ came out with the same local part and different tags. A join on email between a table that keeps the address as typed and one that keeps it lowercased stopped matching for every plus address in the masked copy. The tag now comes from the same lowercased, trimmed address as the rest. An address that was already lowercase with no surrounding space masks exactly as it did; a plus address with a capital letter or surrounding space masks to a new value, once, and to the same value as its lowercase form from then on. a-plus-address-in-two-cases-masked-to-two-mailboxes #### Pressing Ctrl-C did not stop install.sh, and what it said next blamed GitHub. The installer's clean-up handler for Ctrl-C removed its temporary directory and then let the script carry on with its next step. Pressed during the download, it asked github.com again about a download that had not failed, then said github.com had not delivered the build, and exited with status 1, so a script that ran the installer could not tell it had been cancelled. Ctrl-C and a termination signal now stop the installer at once, after removing what it downloaded, and it exits by that signal the way a cancelled program does. An upgrade interrupted part way still puts the previous runner and af back. ctrl-c-did-not-stop-the-installer #### The injection check reported a NoSQL operator smuggled into pages that have no database behind them, and quietly skipped most of what it was meant to fuzz. It sends hundreds of requests from one address. Against an application with a per address rate limit it spent the limit itself, and from then on most of its requests were answered 429. Those answers were handed to the checks as if the application had refused the value. When a request was let through just after one that had been refused, the NoSQL check read the pair as "an operator turned a refusal into an answer". Our own nightly run reported exactly that against static Next.js pages on three nights out of five, on a commit that had not changed, naming a different page each night. Measured against the same limit (20 a second, a burst of 120), the old check skipped about 330 of its 486 comparisons and reported between none and six false findings, depending on how long each request took. The check now waits as long as a 429 or a 503 with Retry-After asks before it tries again, and never hands one of those answers to a check. A difference counts only when it shows up a second time with the requests sent in the opposite order, so one caused by timing does not survive. If the limit never lifts, the check says it could not complete rather than reporting a pass. When the check is stopped partway, by the limit or by the run's deadline, the note says how many comparisons it made out of how many it planned. Anything it had already proved is still reported beside that note, so a partial check reads as unfinished, never as clean. a-rate-limit-was-read-as-a-nosql-injection #### A browser workflow's 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. browser-budget-bounds-the-open #### 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. update-names-the-missing-build #### 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. 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. runner-evidence-path-separator #### 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. sidecar-empty-log-is-a-reading #### 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. terminal-output-tail-on-linux #### 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. af-start-told-windows-to-paste-bash #### 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. rehearsal-durations-on-windows #### 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. a-custom-bin-dir-hid-the-runner #### 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. 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. a-dead-lock-holder-read-as-alive-on-windows #### 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. terminal-workflows-on-windows #### 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. the-engine-had-never-run-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-live-view-listened-where-windows-cannot-connect #### 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. ci-egress-credential-verdicts #### 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. oracle-keyless-duplicates #### 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. a-holder-nobody-identified-was-called-another-session #### 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. pages-empty-title ### security 9 entries A defect with a consequence for the safety of your data or your build. #### The production dependency update moves Next.js to 16.4.3 in the console and the public site, and zod to 4.6.7 in the control plane's API. Next.js 16.4.0 and earlier carry two critical advisories, GHSA-2464-fhr4-w9xw and GHSA-673q-mjm6-r5xr, both remote code execution, and one high, a denial of service through Server Actions. None of the three reached anything we run: the console and the site are static exports served as files, so there is no image optimisation endpoint, no cached server function and no Server Action to call. They are fixed anyway, because a static export is a setting, and a setting can change. The zod advisory does reach running code. GHSA-mrhx-c7xc-m3w5 lets an oversized collection exhaust memory while it is validated against a size constraint, and the API validates request bodies with zod. Version 4.6.6 is the first fixed release. Next.js 16.4.3 rather than 16.4.2 is deliberate. Google Fonts sometimes serves a font file at a URL with no extension, and every release before 16.4.3 fails the site's build when it does. Four more packages take the releases that fix advisories published on 22 and 23 October. The public site moves DOMPurify to 3.4.17, which fixes one critical and one high advisory: GHSA-cg2g-xh64-v4q7, a SAFE_FOR_TEMPLATES bypass, and GHSA-mrv2-3pqr-j5jr, an IN_PLACE hook leaving a detached subtree unsanitized, plus a low one. DOMPurify reaches the site only inside the analytics library, which is not fetched at all for a reader who has opted out or whose browser sends Global Privacy Control. The control plane's API moves fast-uri to 3.1.9, which fixes four advisories in how it parses and normalises URIs; the MCP SDK loads it through its JSON schema validator. body-parser moves to 2.4.0 for a denial of service through trailing bytes after a compressed body, and ip-address to 10.7.5 for a zone identifier matched in quadratic time. Both arrive with Express, which the SDK depends on and the API never imports. body-parser 2.4.0 requires qs 6.16.1, so qs moves with it. The documentation site's build tooling takes the same Next.js 16.4.3 and undici 8.12.3, the release after the one that fixed three undici advisories, so the console, the site and the documentation build resolve the same Next.js release. next-and-zod-advisories-in-the-production-bump #### Run recordings can now leave the machine that made them, and only when an owner or an administrator turns that on. A recording shows whatever the application showed the runner, so it is off for every organization, including those that existed before this release. The control plane refuses an upload before reading its body until the switch is on, and every change to the switch or the retention is audited with what it replaced. A path is uploaded only if it resolves, symlinks included, to a regular file inside the environment's artifacts directory, so a runner report that names a file elsewhere cannot turn it into an upload. The control plane checks each file's declared size, its SHA-256 and its first bytes against the type it claims. The bytes are under the same row level security as every tenant table, the operator's read role has its default read grant revoked on them, and the sweeper that expires them can read only the columns it needs. A download needs a signed-in member who can see the run's environment, answers another organization's artifact as not found, and is served with a sandboxing Content-Security-Policy , nosniff and no-store . A file past its expiry is answered as not found from that moment, by the database's clock, rather than served until the sweep removes its bytes, and deleting the organization deletes its recordings. One organization may hold at most half of the memory a process reserves for uploads arriving, in at most two uploads, organizations already uploading cannot take the last half, which is kept for one with nothing arriving, and an upload that stalls or trickles is cut off and gives its memory back, so neither one engine token nor a few organizations together can keep every other organization's uploads refused as busy. The trust boundary page now lists run recordings as opt in rather than as never leaving the machine, and says what a recording can carry. recordings-leave-the-machine-only-when-an-organization-says-so #### A sign-in link could send the person who used it to another site. The address a sign-in returns you to was checked as a string: it had to begin with one slash, not two, and carry no backslash. A URL parser deletes tabs and line breaks before it reads anything, so redirect_to=/%09/evil.test passed that check as //evil.test and was then read by the callback as //evil.test . GitHub sign-in and the emailed sign-in link both set the session cookie and redirected to https://evil.test/ , which put a phishing page at the end of a genuine Antifailure sign-in. A return target holding a control character is now refused, and what is left must resolve to the application's own origin, so a signed-in person lands on the dashboard instead. a-tab-in-a-return-target-sent-a-signed-in-person-off-site #### The control plane's API moves to version 1.32.0 of the MCP TypeScript SDK, which fixes four advisories published against every earlier 1.x release. The one that reaches code the API runs is GHSA-46jr-4wx8-v453: a single HTTP request inside the default size limit could exhaust server memory while the SDK validated it, and the API serves MCP through the SDK's server and its web standard transport. The other three are in parts of the SDK the API does not use: experimental tasks not tied to the session that created them, the bearer authentication helper accepting tokens issued for another service, and the client transports following cross origin redirects with their headers. mcp-sdk-request-validation-memory #### Changing the User-Agent header on every request got around the sign-in rate limit. The limit shared by sign-in, the emailed sign-in link, the GitHub callback, invitations and the operator portal kept one bucket per address and user agent. The user agent is whatever the caller sends, so a new one on each request was a new bucket on each request, the same way a forged X-Forwarded-For entry was before it was fixed. The bucket is now the address alone. Each of those routes also had its own per-address limit, which still held, so this took away the shared limit across them rather than every limit. People who share one address, an office behind one NAT for example, now share that limit too, as they already shared each route's own limit. a-new-user-agent-was-a-new-sign-in-rate-limit #### The Go toolchain moves from go1.26.8 to go1.26.10, and golang.org/x/net from v0.59.0 to v0.60.0, in the engine, the enterprise engine and the tools. Ten advisories published after main last moved were reachable from the binary that ships. Nine of them, published on 2026-10-08, are HTTP/2 and net/http faults in the standard library, eight of which are also present in the copy of the HTTP/2 code that x/net carries. The tenth, GO-2026-6809, published on 2026-10-21, distrusts compromised intermediate certificates in crypto/x509 and is fixed only in go1.26.10. go-1-26-10-stdlib-advisories #### 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. four-advisories-published-overnight #### 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. http-cache-semantics-released-its-fix #### 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 . a-critical-next-advisory-sat-unpatched-for-five-days ## v1.9.0 Released 27 September 2026 This release contains internal changes. See the repository for details. ## v1.8.1 ## v1.8.0 ## v1.7.0 ## v1.6.0 Released 22 September 2026 ## v1.5.5 Released 20 September 2026 ## v1.5.4 ## v1.5.3 Released 18 September 2026 ## v1.5.2 Released 17 September 2026 ## v1.5.1 Released 16 September 2026 ## v1.5.0 ## v1.4.1 Released 14 September 2026 ## v1.4.0 Released 13 September 2026 ## v1.3.5 Released 7 September 2026 ## v1.3.4 Released 6 September 2026 ## v1.3.3 ## v1.3.2 Released 5 September 2026 ## v1.3.1 ## v1.3.0 ## v1.2.1 ## v1.2.0 ## v1.1.1 Released 4 September 2026 ## v1.1.0 Released 3 September 2026 ## v1.0.0 Released 2 September 2026 ## v1 Released 27 September 2026 · 608 entries · landed 26 August to 27 September 2026 174 entries #### 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. website-editor-pages-and-writing #### 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. sitewide-ai-website-editor #### 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. one-workload-on-two-database-builds #### The manifest's own 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. chaos-invariants-after-recovery #### 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. website-editor #### 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. the-sql-workload-could-be-measured-and-not-compared #### 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. contention-that-ended-no-transaction-was-invisible #### 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. 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. disk-fill-can-land #### Fault injection was reachable from 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. fault-injection-was-reachable-from-one-surface #### 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. 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-capability-existed-on-one-surface-and-no-other #### 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. nothing-said-where-production-was-declared #### 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. the-emulators-started-empty #### 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. a-surface-that-could-be-named-and-never-opened #### 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-surface-with-a-driver-and-no-way-to-ask-for-it #### 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:-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-mobile-app-could-not-be-rehearsed-at-all #### 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. the-desktop-surface-was-declared-and-refused-every-run #### 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 , 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. the-terminal-driver-was-built-and-nothing-could-ask-for-it #### 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=, 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-run-page-showed-counts-and-dropped-the-report #### 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. the-run-launcher-could-not-reproduce-a-run-or-cap-a-load #### 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. an-agent-had-no-way-to-reach-the-latest-release #### The authenticated authorization differential had a reader and no producer. The authz family already knew how to decide an idor, a cross-tenant break and a privilege escalation from structured per-persona observations, and canary_leak already knew how to call a planted value in a response a leak. Both read a golden that carried no canaries and observations no runner emitted, so both were live code that could never fire: NewGoldenView had no caller, and every reach the runner made recorded no authorization reading. A build could not tell a held boundary from an unmeasured one, because nothing was ever measured. A manifest now declares the ownership-scoped objects the suite cannot infer from a diff: a concrete object at a route, who owns it, and the canary the application's own seed planted into it. The engine builds the golden's canary view from those fixtures and drives a new access-probe pass: the runner signs in as each persona and once as nobody, reaches each declared object, decides inside the run whether the object's canary came back, and emits one observation per reach. A persona that reached another owner's object and got the canary back is a proven idor or cross-tenant break; the owner reading its own object is the self-access liveness arm, which is what lets a refusal prove a boundary held rather than that the id was invented. The value stays inside the engine throughout; an observation and a finding carry a route template, a class label and a flag, never the token or the body. Off by default in the strongest sense: a manifest with no access block does no probing and pays nothing for it, and an absent access-probe pass is the honest not-measured state the differential fails closed on rather than reading as a clean pass. the-authorization-differential-had-no-producer #### 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. enterprise-leads-landed-where-no-operator-could-see #### 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. the-runner-recorded-no-body-so-two-detectors-never-fired #### 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. a-change-was-rehearsed-but-never-read #### 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. the-side-effect-comparison-had-no-base-to-compare #### 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. a-scheduled-reaper-so-cost-does-not-depend-on-remembering #### 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. an-install-hook-that-downloaded-and-ran-a-binary-passed #### 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. a-run-could-not-be-watched-while-it-happened #### 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. a-migration-could-turn-off-row-level-security-and-pass #### 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. a-secret-could-render-into-a-response-unseen #### 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. personalities-drive-the-run-with-diverse-behavior #### 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. a-security-suite-no-run-ever-exercised #### 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. the-security-suite-had-nowhere-to-land #### 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. the-custom-roles-nothing-could-store #### 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. managed-postgres-for-the-other-two-clouds #### 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: / 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. xata-provider #### 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. explore-steering #### 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 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-sink-a-hosted-customer-could-not-choose #### 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. reaching-aws-without-changing-the-application #### 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. the-audit-log-with-the-hash-chain-reached-no-sink #### 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. an-emulator-you-had-to-change-your-app-for #### 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-mode-that-was-only-a-word #### 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. a-self-hoster-could-see-the-5xx-count-and-never-what-failed #### 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. aurora-was-a-name-in-a-table-with-no-provider-behind-it #### A store declared 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. an-empty-store-nobody-declared #### 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. a-containment-claim-about-azure-nobody-had-checked #### 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. four-extension-points-that-worked-and-were-undocumented #### 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. the-cloud-run-containment-claim-was-a-row-in-a-table #### 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. an-emulator-that-nothing-said-was-not-the-vendors #### 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. a-store-and-a-cloud-the-manifest-never-mentioned #### 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. an-audit-stream-that-forwarded-nothing #### 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. a-scheduler-nobody-called #### 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. ecs-runtime-containment-report #### 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. an-operator-you-invited-could-never-sign-in #### 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. a-guarantee-you-had-our-word-for #### 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. documentation-an-agent-could-not-read #### 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. a-verdict-that-had-never-said-no #### An environment can hold a masked ClickHouse beside its masked Postgres, and 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 ... UPDATE per 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 events and a service called events is 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_proxy and ignores no_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. an-analytics-product-with-no-analytics #### 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. bugs-that-only-appear-at-two-instances #### 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. every-postgres-nobody-wrote-a-provider-for #### 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-twin-with-one-datastore #### 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. providers-that-could-not-be-registered #### 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. a-workflow-that-catches-the-operator-who-is-also-a-customer #### 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-first-screen-was-three-empty-cards-whoever-you-were #### 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. analytics-that-would-have-reached-a-third-party-host #### 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. the-hosted-service-could-not-say-which-tools-anybody-used #### 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. an-agent-could-rehearse-a-migration-and-not-see-the-machine #### 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. app-opens-the-setup-pull-request #### 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. ci-drafts-a-manifest #### 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. eighty-one-commands-and-four-of-them-reachable #### 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. eighty-one-commands-and-four-tools #### 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. github-in-one-file #### 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. mask-init #### 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-site-said-it-used-no-analytics-vendor #### 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