AI Decision System
Replayable, offline-verifiable records for AI-assisted go/no-go decisions: experiment promotions, typed model answers and human-approved actions.
翻译准备中
制作者的介绍仅有上面的一句话。 更多详情请阅读仓库 README。 仓库 README ↗
正在加载完整 README。
查看完整 README ↗Tools that repair, adapt, test and preserve software you can't change.
Aftermarket is one program, aftermarket, with a subcommand per tool. The tools sit between a client and the service it calls, or replay what passed between them. The client and the service stay exactly as they are.
aftermarket patch) fixes traffic with small rules you can read. Rename a JSON field, add or drop a header, move a path.aftermarket adapt) keeps an old client working after its API was replaced. You describe each old endpoint in terms of the new one, and adapt translates the request on the way in and rebuilds the old response on the way out.aftermarket record) saves real traffic as test fixtures, with credentials and any values you list blanked out.aftermarket replay) sends those fixtures to two versions of a service and reports where the answers differ.aftermarket config) changes a vendor's config file through an overlay, without ever editing the file, and stops when an upgrade changed something your overlay relies on.aftermarket hook) watches files, folders, HTTP endpoints and command output that change without telling anyone, and sends a signed webhook for each change.aftermarket guard) stands in front of an app that can't protect itself, such as an old admin panel with no login, and turns away requests that break your rules: no credential, wrong network, too many tries, or a request that could be read two ways.aftermarket schema) keeps an old app's SQL working after its SQLite database moved to a new schema. You describe the old tables in terms of the new ones, and it writes views that show the old tables again, after checking that every old read gives the same rows.aftermarket bridge) serves an old app's CSV exports and SQLite files as a read-only JSON API, so other programs can ask for /orders?paid=false instead of parsing the files.It's a single Go binary with no dependencies, licensed under Apache 2.0. Website: aftermarket.dev.
Use patch when one thing is off: a renamed field, a missing header, an endpoint that moved but otherwise answers the same way. Use adapt when a whole API changed shape, and you'd rather write down what the old one looked like than stack up a dozen small edits. Use record and replay to find out what changed in the first place, and to prove a repair works on real traffic. Use config when the thing you can't change is a vendor's config file that every upgrade overwrites. Use hook when software changes data but never says so, and something else needs to know. Use guard when an app has no login, no rate limits or a page that should never have been public, and you can't change the app. Use schema when the database under an old app was reorganised and the app's queries, reports or scripts still expect the old tables. Use bridge when an app only hands its data over as files, and something else needs it as an API.
An old client calls GET /api/v1/customer?id=7 and expects {"customer_id": 7, "name": ..., "email": ...}. The service it used to call was replaced by one that answers GET /api/v2/customers/7 with {"customer": {"id": 7, "full_name": ..., "email": ..., "customer_since": ...}}. Two rules fix it:
version: 1
rules:
- id: v1-customer-path
when:
method: GET
path: /api/v1/customer
query: {id: "*"}
request:
rewrite_path:
from: "^/api/v1/customer$"
to: "/api/v2/customers/{query.id}"
- id: v1-customer-shape
when:
method: GET
path: /api/v1/customer
status: 200
content_type: application/json
response:
json:
- rename: {from: customer.id, to: customer_id}
- rename: {from: customer.full_name, to: name}
- rename: {from: customer.email, to: email}
- delete: {path: customer}
This exact case is in examples/legacy-customer, and the tests check that the client fails without the rules and works with them. The full rule format is in docs/reference.md.
Now the whole customer API moved to v2. Lists come back wrapped in {"data": [...]}, creating a customer answers 201 where v1 answered 200, deleting answers 204 with no body, and errors arrive as {"error": {"code": ..., "message": ...}} where v1 sent {"message": ...}. One mapping per old endpoint:
version: 1
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
email: $.customer.email
error:
message: $.error.message
- id: list-customers
old: GET /api/v1/customers
new: GET /api/v2/customers
response:
body:
$each: $.data
$item:
customer_id: $.id
name: $.full_name
- id: create-customer
old: POST /api/v1/customers
new: POST /api/v2/customers
request:
body:
full_name: $.name
email: $.email
response:
status: {201: 200, 422: 400}
body:
customer_id: $.customer.id
name: $.customer.full_name
email: $.customer.email
error:
message: $.error.message
- id: delete-customer
old: DELETE /api/v1/customer?id={id}
new: DELETE /api/v2/customers/{id}
response:
status: {204: 200}
body: {deleted: true}
error:
message: $.error.message
{id} captures a value from the old URL and puts it in the new one. In a body template, $.customer.id takes a value from the source body, $params.id takes a captured parameter, $each and $item turn one list into another, and anything else is copied as written. Fields the template doesn't name are left out, so the old client gets exactly the shape it expects.
The full example, with a search endpoint as well, is examples/customer-api. It has the old v1 service, the new v2 service and the old client's test suite: twelve checks of fields, status codes and error messages. The suite passes against v1, fails every check against v2 directly, and passes every check against v2 through adapt. The full mapping format is in docs/adapt.md.
Put record in front of the old service and run the old client's tests through it:
aftermarket record --upstream http://127.0.0.1:9001 --out recorded.yaml --listen 127.0.0.1:8090
Every request and answer passes through unchanged, and each finished exchange is written to recorded.yaml as a fixture case. Authorization, cookies and credential query keys such as api_key are saved as REDACTED; a config file can add more headers, query keys and JSON fields. Then send the recording to the old service and the new one:
aftermarket replay --fixture recorded.yaml --a http://127.0.0.1:9001 --b http://127.0.0.1:8080
replay compares status codes, Content-Type and bodies. JSON is compared by meaning, so key order and number formatting don't count, and each difference is listed by path:
### 1 GET /api/v1/customer
`GET /api/v1/customer?id=7`
- status: 200 vs 404
- customer_id: only in A (7)
- error: only in B (an object)
By default replay sends only GET and HEAD requests, so it can't change data; --include-writes sends the rest. It exits 1 when anything differs, so it fits in CI. In the customer-api example, the old client's twelve checks are recorded against v1. Replayed against v2 directly, all twelve differ. Replayed against v2 through adapt, all twelve answers are the same. The full format is in docs/record.md.
A vendor ships config.json, and every upgrade overwrites it. Your changes go in an overlay instead, each guarded by the value it expects to find:
version: 1
base: 1.0/config.json
changes:
- set: {path: server.timeout, value: 120, expect: 30}
- set: {path: telemetry.enabled, value: false, expect: true}
- set: {path: database.password, value: from-our-vault}
- set: {path: server.max_body, value: 10MiB, expect_absent: true}
aftermarket config diff --overlay overlay.yaml # field by field, passwords REDACTED
aftermarket config apply --overlay overlay.yaml --out /etc/app/config.json
apply writes the result to a separate file, in one step, and never touches the vendor's file. Fields the overlay doesn't name keep their place and exact value. When version 1.1 changes the default timeout to 60, apply stops instead of quietly overriding it:
line 8: set expects server.timeout to be 30, but the base has 60
nothing was written
It reads JSON, YAML, TOML and .env files. In TOML and .env files, changes are made on the file's own lines, so comments and every untouched line stay as they were. The examples are examples/vendor-app and examples/vendor-app-toml, and the full format is in docs/config.md.
A legacy app rewrites status.json when an order moves on, drops CSV exports in a folder, and keeps its orders in SQLite. It tells nobody. One config watches all three:
version: 1
state: hook-state.json
webhooks:
- id: ops
url: https://hooks.example.com/aftermarket
secret_env: HOOK_SECRET
sources:
- id: order-status
file: {path: status.json}
ignore: [updated_at]
- id: exports
folder: {path: exports, pattern: "*.csv"}
- id: orders-table
command:
run: [sqlite3, -json, app.db, "select id, status, total from orders order by id"]
HOOK_SECRET=... aftermarket hook run --config hook.yaml
Each change becomes one signed POST, listed by field:
{"id":"35e1242054585c9525bf477f","source":"orders-table","kind":"command","sequence":1,"time":"2026-10-21T12:25:52Z",
"changes":[{"op":"changed","path":"0.status","old":"open","new":"paid"},
{"op":"added","path":"1","new":{"id":1002,"status":"open","total":12}}]}
The first check only sets a baseline. A file rewritten with the same content, or a change to an ignored field, sends nothing. A file still being written waits for the next check, and one that disappears or shrinks by more than half is only reported when the next check sees the same. While the receiver is down, events wait in the state file and are retried until delivered, in order, across restarts. The example is examples/legacy-app, with a receiver that checks signatures, and the full format is in docs/hook.md.
An old admin panel has no login, a debug page that prints a database password, and nothing against password guessing. AfterGuard goes in front of it:
version: 1
default: deny
clients:
- id: admin
basic_user: admin
basic_password_sha256: 729d88640e95cc2e6a8a188e992f87a3502602dc7ed49543d9454ea1bc0fc429 # aftermarket guard hash
rules:
- id: status
when: {method: GET, path: /status}
allow: true
- id: no-debug
when: {path: /debug/**}
deny: 404
- id: admin-office
when: {path: /admin/**}
from: [192.168.0.0/16]
client: [admin]
allow: true
limit: {rate: 60/m, burst: 10}
response:
remove_headers: [Server, X-Powered-By]
aftermarket guard run --config guard.yaml --upstream http://127.0.0.1:9100 --listen :8080
The first rule that matches decides, and a request no rule allows gets 403. The admin pages need the admin login, from the office network; five wrong passwords from one address and that address waits. A request that could be read two ways, such as /x/..%2fadmin or one with both Content-Length and Transfer-Encoding, gets 400 and never reaches the app. Every decision is logged as one JSON line, without credentials. The app must listen where only AfterGuard can reach it, such as 127.0.0.1. The example is examples/legacy-admin, the full format is in docs/guard.md, and what AfterGuard defends against is in docs/guard-threat-model.md.
Shop 2.0 renamed customers to accounts, split the name in two, kept deleted accounts with a deleted_at date, and stores order totals in whole cents. The old reporting scripts still ask for customers and orders. A mapping describes the old tables in terms of 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 check --queries reports.sql --new v2/shop.sql
1 of 12 queries compile on the new schema
$ aftermarket schema test --map map.yaml --queries reports.sql
12 of 12 reads give the same rows on the new schema with the compatibility views as on the old one
$ aftermarket schema compat --map map.yaml --out compat.sql
wrote compat.sql: 2 view(s): customers, orders
compat.sql holds plain CREATE VIEW statements to review and apply. AfterSchema never writes a database itself. schema diff lists every change between the two schemas and marks the ones that break old queries. The example is examples/legacy-shop, and the full format is in docs/schema.md. AfterSchema needs the sqlite3 command, 3.37 or later.
A legacy shop app writes orders-YYYY-MM-DD.csv into a folder every night and keeps its stock in SQLite. One config serves both:
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]
- id: products
sqlite: {db: inventory.db, table: products}
key: sku
exclude: [photo]
endpoints:
- path: /orders
source: orders
filters: [paid, placed]
- path: /orders/{id}
source: orders
- path: /products/{sku}
source: products
$ aftermarket bridge serve --config bridge.yaml --listen 127.0.0.1:8080
$ curl -s 'http://127.0.0.1:8080/orders?paid=false'
{"items":[{"id":1004,"customer":"Turing, Alan","total":8.5,"paid":false,"placed":"2026-10-21"}],"total":1,"limit":100,"offset":0}
The newest export is picked by name, typed by the declared columns, and read again when it changes. A file that is still being written, cut off or broken is never served; the last good version stays. Only GET and HEAD are answered, and nothing in a request reaches SQL or the file system. The example is examples/legacy-exports, and the full format is in docs/bridge.md.
Download the archive for your system from the latest release, unpack it and put aftermarket on your PATH.
| System | Archive |
|---|---|
| Linux, x86-64 | aftermarket_linux_amd64.tar.gz |
| Linux, ARM64 | aftermarket_linux_arm64.tar.gz |
| macOS, Apple silicon | aftermarket_darwin_arm64.tar.gz |
| macOS, Intel | aftermarket_darwin_amd64.tar.gz |
| Windows, x86-64 | aftermarket_windows_amd64.zip |
Each release also has a SHA256SUMS file. Or build from source with Go 1.24 or newer:
go install github.com/aftermarket-dev/aftermarket/cmd/aftermarket@latest
From a copy of this repository, start the new v2 service, then adapt in front of it:
go run ./examples/customer-api/v2 -listen 127.0.0.1:9002 &
aftermarket adapt run --config examples/customer-api/adapt.yaml \
--upstream http://127.0.0.1:9002 --listen 127.0.0.1:8080
Now run the old client's test suite against each of them:
go run ./examples/customer-api/client -base http://127.0.0.1:9002 # 12 checks, 12 failed
go run ./examples/customer-api/client -base http://127.0.0.1:8080 # 12 checks, 0 failed
The suite creates and deletes a customer, so restart the v2 service before running it again. The patch example works the same way with examples/legacy-customer.
Every change either tool makes is written to the audit log (stderr by default), one JSON line each:
{"time":"...","event":"changed","request":1,"rule":"get-customer","phase":"response","method":"GET","path":"/api/v1/customer","query_keys":["id"],"status":200,"actions":["rebuild body"]}
aftermarket patch run --config rules.yaml --upstream URL [--listen ADDR] [--dry-run]
[--audit-log FILE|-] [--health-path PATH]
aftermarket patch check --config rules.yaml
aftermarket patch test --config rules.yaml --fixture fixture.yaml
aftermarket adapt run --config adapt.yaml --upstream URL [same options as patch run]
aftermarket adapt check --config adapt.yaml
aftermarket adapt test --config adapt.yaml --fixture fixture.yaml
aftermarket record --upstream URL --out fixture.yaml [--config record.yaml] [--listen ADDR]
aftermarket record check --config record.yaml
aftermarket replay --fixture fixture.yaml --a URL [--b URL] [--include-writes]
[--ignore PATH] [--header NAME] [--set-header "Name: value"]
[--report FILE] [--json FILE]
aftermarket config check --overlay overlay.yaml [--base FILE]
aftermarket config diff --overlay overlay.yaml [--base FILE]
aftermarket config apply --overlay overlay.yaml --out FILE [--base FILE]
aftermarket config test --overlay overlay.yaml --fixture fixture.yaml
aftermarket hook check --config hook.yaml
aftermarket hook run --config hook.yaml [--once] [--dry-run] [--log FILE|-]
aftermarket hook test --config hook.yaml --fixture fixture.yaml
aftermarket guard run --config guard.yaml --upstream URL [--listen ADDR] [--dry-run] [--log FILE|-]
aftermarket guard check --config guard.yaml
aftermarket guard test --config guard.yaml --fixture fixture.yaml
aftermarket guard hash < key.txt
aftermarket schema diff --old A --new B [--map map.yaml] [--json]
aftermarket schema compat --map map.yaml --out compat.sql [--new B]
aftermarket schema check --queries old.sql [--map map.yaml] [--new B]
aftermarket schema test --map map.yaml --queries old.sql [--old A] [--new B]
aftermarket bridge serve --config bridge.yaml [--listen ADDR] [--log FILE|-]
aftermarket bridge check --config bridge.yaml
aftermarket bridge convert --config bridge.yaml --source NAME --out FILE
aftermarket bridge test --config bridge.yaml --fixture fixture.yaml
aftermarket version
run starts the proxy. --listen defaults to 127.0.0.1:8080. --audit-log - (the default) writes to stderr, a file name appends to that file, and an empty value turns the log off.--dry-run forwards every request and response untouched and logs what would have changed. Use it to try new rules or mappings on live traffic.check validates the file, lists what's in it, and points out entries that can match the same traffic. For patch that's allowed: rules run in file order. For adapt the first matching endpoint wins, so a later one may never be reached for those requests.test runs saved requests and responses through the proxy against a stand-in upstream and compares the results with what you expect. All the tools use the same fixture format, so a recording from record can be run with patch test or adapt test. It exits 1 if any case fails, so it fits in CI.record never overwrites a file: --out must not exist yet. The file is a valid fixture after every case, so stop it with Ctrl-C whenever you have enough.replay without --b compares one service with the answers in the recording.GET /_aftermarket/healthz itself with 200 ok. It sits under /_aftermarket/ so it never hides an upstream /healthz. Change it with --health-path, or pass an empty value to turn it off.The main promise is that a repair changes what it's meant to change and breaks nothing else. In practice:
max_body (1 MiB by default), compression other than gzip, and responses that have no body.Content-Length is corrected. ETag and digest headers that described the old bytes are removed.... A rule with a path condition, or an adapt endpoint, never matches a path with . or .. segments or an encoded slash. A value that goes into a new path must stay inside one segment: .., . and anything with a slash are refused.502 and a slow one a 504 after upstream_timeout (30 seconds by default). That includes a body the proxy is waiting for. A client whose request body stops arriving for that long gets 408, and one whose body breaks off gets 400; either way its connection is closed, whether or not a rule reads the body; an upload that is slow but keeps moving is never cut off. When the upstream answers before the client has sent its whole body, the answer closes the connection, so nothing more the client sends can be read as a new request. All of these are logged./api/v1/customers/{id}, so path parameters never appear. Credentials (Authorization, Cookie, Set-Cookie and their proxy cousins) are never changed by adapt.Recorded results, including the latency each tool adds, are in test/results.
http:// or https://. Connection upgrades such as WebSocket pass through, but nothing looks inside them.Location headers or links inside bodies, so a redirect from the new API points at a new URL."7", not 7.sqlite3 command, which must be installed. In a folder, a file rewritten with the same size and modification time isn't noticed.limit_table above the number of addresses you expect in a minute or so.[ ] and { } collections, | and > blocks, and comments. Anchors, aliases, tags and multiple documents are rejected with a line number.settle can be read between them; writing to another name and renaming avoids that.when:. Binary bodies aren't recorded at all.go test -race ./... # the whole suite
go run ./tools/record # run everything and write test/results
0.9.0 adds aftermarket bridge (AfterBridge). 0.8.0 added aftermarket schema (AfterSchema), 0.7.0 added TOML to aftermarket config, 0.6.0 added aftermarket guard (AfterGuard), 0.5.0 added aftermarket hook (AfterHook), 0.4.0 added aftermarket config (AfterConfig), 0.3.0 added aftermarket record and replay (AfterTest and AfterDiff), and 0.2.0 added aftermarket adapt (AfterAPI). What comes next is decided one tool at a time; the plan is on aftermarket.dev/roadmap.
Questions and problems: open an issue or write to [email protected].
REDACTED, and so are the headers, query keys and JSON fields you list; the tests send secrets both ways and search the file for them. Values in a URL path and in bodies that aren't JSON are saved as sent. An exchange that can't be stored safely (too big, not text, compressed in a way record can't read, or cut off) still passes through and is logged as skipped.--include-writes.REDACTED, and the signing secret never leaves the environment.--out. A failed apply leaves the old output as it was, and applying twice gives the same bytes. Values under keys such as password or token are never printed.settle (2 seconds by default) and only if it is complete; otherwise the last good version stays. Every answer reads back as exactly the values in the file, types included (a line break inside a quoted CSV value reads as \n).sqlite3 shell's safe mode, and compat writes only its --out file, never one of its inputs. Every read test passes gives the same rows, with the same types, through the views as on the old database.