Use Cases

Each of these is a situation the tools were built for, with the smallest config that handles it. The case studies walk through twelve of them in full, with real commands and real output.

A field was renamed

The service now answers "amount" where your app reads "total". One AfterPatch rule puts the old name back:

rules:
  - id: total-renamed
    when: {path: /orders/*, status: 200, content_type: application/json}
    response:
      json:
        - rename: {from: amount, to: total}

The key keeps its place in the object, and every other byte of the answer stays as it was.

The service now wants a header

A new version answers 400 unless the request carries a version header, and the old client doesn’t send one:

rules:
  - id: send-api-version
    when: {path: /api/**}
    request:
      set_headers: {X-Api-Version: "2026-10"}

An endpoint moved

/v1/rate is gone and the same thing now lives at /v2/rate. The query string goes along exactly as the client wrote it:

rules:
  - id: rate-moved
    when: {method: GET, path: /v1/rate}
    request:
      rewrite_path: {from: "^/v1/rate$", to: "/v2/rate"}

The exchange rate case study combines this with the two cases above.

Something leaks that shouldn’t

An upstream started sending a debug header and an internal field to every client:

rules:
  - id: strip-internals
    when: {content_type: application/json}
    response:
      remove_headers: [X-Debug-Trace]
      json:
        - delete: {path: internal}

A whole API version was retired

When the paths, the body shapes and the status codes all changed, AfterAPI is easier to read than a stack of patch rules. You write each old endpoint in terms of the new one:

endpoints:
  - id: get-customer
    old: GET /api/v1/customer?id={id}
    new: GET /api/v2/customers/{id}
    response:
      body:
        customer_id: $.customer.id
        name: $.customer.full_name
      error:
        message: $.error.message

The status codes changed

The new API answers 201 to a create and 204 to a delete, where the old client expects 200 both times. AfterAPI maps the codes and can add the body the old client looks for:

endpoints:
  - id: delete-customer
    old: DELETE /api/v1/customer?id={id}
    new: DELETE /api/v2/customers/{id}
    response:
      status: {204: 200}
      body: {deleted: true}

The customer API case study moves a whole client this way.

Try a fix on live traffic first

Every run command takes --dry-run. Traffic passes through untouched, and the audit log says what each rule would have changed:

aftermarket patch run --config rules.yaml --upstream http://127.0.0.1:9000 --dry-run

Check a new release before it ships

Record what the current version answers, then send the same requests to both versions:

aftermarket record --upstream http://old:9000 --out traffic.yaml
aftermarket replay --fixture traffic.yaml --a http://old:9000 --b http://new:9000 --ignore created_at

Every difference is listed by field. Key order and number formatting don’t count, and only GET and HEAD requests are sent unless you add --include-writes. The release check case study finds a renamed field this way and proves the repair.

Try the new version on live traffic before the switch

A recording only holds the requests you thought to send. Shadow mode serves every request from the current version and sends a copy to the new one in the background, then lists every difference:

aftermarket shadow --primary http://old:9000 --candidate http://new:9000 \
  --ignore updated_at --sample 0.1 --report shadow-report.md

Clients only ever get the current version’s answers, and a slow or broken candidate can’t hold them up. Run the current version against a copy of itself first to find the fields that differ on their own. The Datasette case study does that before comparing 0.65.5 with the 1.0 alpha, the JSON Server case study copies writes to a candidate with its own data file, and the load case study shows what the report says when the candidate can’t keep up. The shadow mode recipes have short setups for each of these.

Turn real traffic into regression tests

A recording is an ordinary fixture file, with credentials saved as REDACTED. Run it against your rules in CI, and the build fails if a rule starts changing answers it shouldn’t:

aftermarket patch test --config rules.yaml --fixture traffic.yaml

Compare a service with what it used to answer

Without --b, replay compares one service with the answers in the recording. That fits a migration, a dependency upgrade or a config change, where the before picture is only available as a recording:

aftermarket replay --fixture traffic.yaml --a http://127.0.0.1:9000 --ignore created_at

Every upgrade overwrites your config changes

A vendor’s config.json gets replaced on each upgrade, so your edits to it keep disappearing. Keep them in an overlay instead, each guarded by the value it expects:

version: 1
base: /opt/vendor/config.json
changes:
  - set: {path: server.timeout, value: 120, expect: 30}
  - set: {path: telemetry.enabled, value: false, expect: true}

aftermarket config apply --overlay overlay.yaml --out /etc/app/config.json writes the result and never touches the vendor’s file. When an upgrade changes the default timeout, apply stops at that line instead of overriding it. The same works for YAML, TOML and .env files. In a TOML file only the changed lines differ from the vendor’s, comments included.

An old app changes data and tells no one

The app writes order status to a file, or straight into its SQLite database, and has no webhooks. AfterHook checks both and posts a signed event when something changes:

state: hook-state.json
webhooks:
  - id: ops
    url: https://hooks.example.com/orders
    secret_env: HOOK_SECRET
sources:
  - id: order-status
    file: {path: status.json}
    ignore: [updated_at]
  - id: orders-table
    command:
      run: [sqlite3, -json, app.db, "select id, status, total from orders order by id"]
    interval: 15s

A new row arrives as one added row, a paid order as one changed field. A timestamp that moves on its own sends nothing, and while the receiver is down the events wait in the state file. The legacy app case study runs this with a real receiver.

An old app has no login at all

An admin panel from years ago trusts every request, and a debug page prints a password. AfterGuard goes in front, and the panel moves to 127.0.0.1 where only AfterGuard can reach it:

default: deny
clients:
  - id: admin
    basic_user: admin
    basic_password_sha256: 729d88640e95cc2e6a8a188e992f87a3502602dc7ed49543d9454ea1bc0fc429
rules:
  - id: no-debug
    when: {path: /debug/**}
    deny: 404
  - id: admin-office
    when: {path: /admin/**}
    from: [192.168.0.0/16]
    client: [admin]
    allow: true

Five wrong passwords from one address and it waits a minute. A request built to be read two ways gets 400 and never reaches the panel. The legacy admin case study runs this against a stand-in panel.

The database changed and the old reports broke

The app’s 2.0 release renamed customers to accounts, split the name in two and keeps totals in cents. The reporting scripts still ask for customers and orders. A mapping says how the old tables read from the new ones:

version: 1
old: v1/shop.sql
new: v2/shop.sql
views:
  - old: customers
    from: accounts
    columns:
      name: first_name || ' ' || last_name
      fax: null
      created: created_at
    where: deleted_at IS NULL
  - old: orders
    from: purchases
    columns:
      customer_id: account_id
      total: total_cents / 100.0
      status: state
      placed: placed_at

aftermarket schema test --map map.yaml --queries reports.sql runs every old report on both databases and compares the rows exactly, and aftermarket schema compat --map map.yaml --out compat.sql writes the views for you to apply. The shop case study shows what the test catches when the mapping is wrong.

The app only hands its data over as files

The old shop app writes an orders export into a folder every night and keeps its stock in SQLite. The new dashboard wants an API. AfterBridge serves the files as one:

version: 1
sources:
  - id: orders
    csv: {folder: exports, pattern: "orders-*.csv"}
    key: id
    columns: {id: integer, total: number, paid: boolean, placed: date}
    exclude: [internal_note]
endpoints:
  - path: /orders
    source: orders
    filters: [paid, placed]
  - path: /orders/{id}
    source: orders

aftermarket bridge serve --config bridge.yaml answers /orders?paid=false and /orders/1003 from the newest export, and moves to the next night’s file once it is complete. The exports case study shows a half-written and a broken export being kept out.

The full formats are in the AfterPatch, AfterAPI, AfterTest and AfterDiff, AfterConfig, AfterHook, AfterGuard, AfterSchema and AfterBridge references.