Bidirectional REST field-translation gateway. Two systems that speak different field vocabularies POST to xlate; it renames (and optionally converts) fields per an account's map, forwards to the other side with that side's credentials, and reverse-maps the JSON response — each party only ever sees its own field names.
POST /t/{account}/a2b POST {B.target_url}
System A ─────────────────────────► xlate ─────────────────────────► System B
Bearer A.inbound_token │ Bearer B.outbound_token
│ payment_id → payment_proof
System A ◄─────────────────────────────┘ created_at → created_epoch (date fx)
response reverse-mapped back into A's vocabulary
Return leg (async/webhook style): System B POSTs to /t/{account}/b2a — same
pipeline, mirrored.
Single static Go binary, standard library only. Admin console is one embedded HTML file — no build step. State is one JSON file, written durably (temp → fsync → rename → fsync the directory) with 0600 perms, and rolled back in memory if the write fails. Every security-relevant action lands on a hash-chained audit trail, and a tripwire watches for credential-guessing against any account.
New here? TUTORIAL.md walks you from zero to two systems talking, in about fifteen minutes.
go build -o xlate .
./xlate -listen :8080
# admin token is printed on start; pin it for real use:
XLATE_ADMIN_TOKEN=... ./xlate -state /var/lib/xlate/state.json \
-audit /var/log/xlate/trail.jsonl \
-alert-webhook https://ops.example/hooks/xlate -alert-secret "$SECRET"Open http://localhost:8080/admin/, sign in, create an account, add field pairs (the fx button on a pair adds a date transform), and hand each party its curl snippet from the Integration card. The console opens on a Dashboard of node counters; it ships in a light theme with a toggle in the top right, remembered per browser.
Want it populated? ./demo/demo.ps1 (or demo.sh) brings up the gateway, a mock destination, four dummy accounts and traffic in one command — see demo/README.md.
Every shot below is the embedded console running the demo — no external assets, no build step.
| Flag | Default | Purpose |
|---|---|---|
-listen |
:8080 |
listen address |
-state |
xlate.state.json |
state file path |
-admin-token |
(env XLATE_ADMIN_TOKEN, else generated per run) |
control-plane credential |
-log-size |
500 |
activity ring buffer entries |
-max-body |
2097152 |
request/response body cap (bytes, min 1024) |
-tls-cert / -tls-key |
serve HTTPS directly (both or neither) | |
-audit |
xlate.audit.jsonl |
hash-chained audit trail (empty disables — not recommended) |
-verify-audit FILE |
verify a trail's hash chain and exit | |
-alert-webhook |
URL to POST tripwire alerts to | |
-alert-secret |
(env XLATE_ALERT_SECRET) |
HMAC-SHA256 key for X-Xlate-Signature on alerts |
-trip-threshold |
5 |
refused attempts tolerated per account; fires when exceeded (max 10000) |
-trip-window |
5m |
sliding window for counting refused attempts |
-trip-lock |
15m |
lockout duration in mode lock |
-trip-mode |
lock |
tripwire action: lock | freeze | alert |
-trust-proxy |
false |
trust first X-Forwarded-For hop (only behind a proxy that strips it) |
Flags are validated at startup, so a typo fails fast instead of producing a quietly misconfigured server.
POST /t/{account_id}/{a2b|b2a} with Authorization: Bearer <side's inbound_token> (or X-Api-Key) and a JSON object body.
Pipeline: tripwire gate → authenticate → validate → translate request → POST to destination target_url with its outbound_token → translate JSON object responses back → relay upstream status and body. Idempotency-Key and X-Request-Id pass through. Redirects are returned, never followed. X-Xlate-Applied / X-Xlate-Warnings headers carry translation counts for both legs.
The body must be exactly one JSON object — trailing bytes after it are refused rather than silently dropped, so xlate and the destination can never read the same request two different ways. Correlation headers are checked for length and header-safety before they are forwarded.
Errors are a stable envelope: {"error": "<human>", "code": "<machine>"}.
| Status | code |
Meaning |
|---|---|---|
400 |
bad_json |
body was not a single JSON object |
400 |
bad_body |
body could not be read (connection dropped mid-request) |
400 |
validation_failed |
a correlation header was oversized or not header-safe |
401 |
auth_invalid |
inbound token invalid for that side (feeds the tripwire) |
403 |
account_disabled |
account disabled (manually or by tripwire freeze) |
404 |
not_found / bad_direction |
unknown account / direction not a2b|b2a |
405 |
method_not_allowed |
wrong method; the Allow header lists the right ones |
413 |
payload_too_large |
body exceeds -max-body |
429 |
account_locked |
tripwire lockout; Retry-After says when to return |
502 |
no_target / upstream_failed / upstream_read / upstream_oversize / bad_target |
destination problems (detail in logs only) |
500 |
internal |
encode failure or recovered panic |
Every route answers with this envelope, including unknown paths and wrong methods.
- A pair
{"a": "payment_id", "b": "payment_proof"}is one bidirectional vocabulary entry; direction picks the source column. - Names are dot-paths:
customer.idreaches nested objects; intermediates on the destination path are created. Array indexing and keys containing literal dots are out of scope — a path containing[or]is rejected rather than quietly treated as a literal key. - A path may not be a parent of another path on the same side:
customerandcustomer.idtogether are ambiguous (extracting one shadows the other, and which wins depends on row order), so the pair is refused at config time. - Application is two-phase (extract all sources, then assign all destinations), so swapped pairs within one map cannot clobber each other.
- Extracting the last key of a nested object leaves the empty parent in place — silently pruning containers is more surprising than an empty object.
- Missing source: no-op. Destination collision: overwritten, surfaced as a warning. Unmapped fields pass through unless drop unmapped is set (requests only; responses are never drop-filtered).
- Destination unreachable (an intermediate segment of the destination path exists in the payload but is not an object): the value stays at its source path untranslated, surfaced as a warning. On any per-field failure the original data still flows; nothing is ever dropped.
- Numbers survive verbatim (
json.Number) — 64-bit IDs don't lose precision.
A pair may carry "transform": {"kind": "date", "a_format": ..., "b_format": ...} — the value converts in flight, not just the name. Direction-aware and symmetric: a2b parses with a_format and renders with b_format; the return leg un-converts, so each side keeps both its vocabulary and its representation.
Formats: named (rfc3339, rfc3339nano, date, datetime, rfc1123), epoch (unix, unix_ms — accepted as number or string, rendered as a JSON number), or any raw Go reference layout (02/01/2006 15:04). Zoneless layouts parse as UTC.
Formats are validated when the account is saved, not when traffic arrives. A layout must round-trip a whole calendar date. This rejects the two things people actually type — YYYY-MM-DD (a strptime/moment.js habit; Go's Format treats it as a literal) and iso8601 (Go reads the 1 as a month, so it formats and parses happily while encoding nothing) — along with partial layouts like 2006 or 15:04:05 that would invent a date on the way back. Without this check a typo is accepted at save time and then fails open on every request forever.
Parse failures at request time fail open: the value forwards untransformed and the failure surfaces as a warning (X-Xlate-Warnings + logs), on the request leg and the return leg alike. Rejecting a whole request over one bad date is the worse default for a pass-through gateway; a strict mode is a roadmap knob.
All under Authorization: Bearer <admin token>:
| Route | Purpose |
|---|---|
GET/POST /api/admin/accounts |
list / create |
GET/PUT/DELETE /api/admin/accounts/{id} |
read / replace / remove |
POST /api/admin/accounts/{id}/rotate |
{"side":"a|b","kind":"inbound|outbound"} → new token |
POST /api/admin/accounts/{id}/test |
dry run: translated preview (transforms included), nothing forwarded |
POST /api/admin/accounts/{id}/tripwire/reset |
clear counter, lock, and tripped flag |
GET /api/admin/tripwire |
tripwire state for all accounts |
GET /api/admin/logs?account=&limit= |
activity ring, newest first |
GET /api/admin/stats |
node counters for the dashboard |
GET/POST/DELETE /api/admin/accounts/{id}/discover |
read / open-close / clear field discovery |
GET /healthz |
unauthenticated liveness |
Blank token fields on PUT retain stored values. An omitted enabled retains the stored value too (and enables on create) — the same rule: omission must never silently flip a live account. To intentionally clear an outbound token (forward without Authorization), send the literal string none — unambiguous, since real tokens have a 16-character minimum; the console does this when you empty the field. Account IDs are immutable (^[a-z0-9][a-z0-9_-]{0,62}$); the path is authoritative. All mutations are audited, including rejected ones.
Admin-plane statuses: 400 validation_failed, 401 unauthorized, 404 not_found, 405 method_not_allowed, 409 already_exists.
Bodies are decoded strictly. Unknown fields are rejected — a typo like target_uri or droppunmapped would otherwise be silently ignored and the setting lost, which for a config API is a data-loss bug wearing a 200 OK. Trailing data after the JSON value is rejected, non-JSON Content-Type is rejected when one is supplied, and bodies are capped at 1 MiB.
Validation is exhaustive rather than fail-fast: one PUT of a broken mapping grid returns every bad row at once, each located by a JSON path.
$ curl -sX POST localhost:8080/api/admin/accounts -H "Authorization: Bearer $TOK" -H "Content-Type: application/json" \
-d '{"id":"BAD ID","timeout_sec":-1,
"map":[{"a":"customer","b":"x"},{"a":"customer.id","b":"y"}]}'{
"code": "validation_failed",
"error": "id: must match ^[a-z0-9][a-z0-9_-]{0,62}$; timeout_sec: must be between 1 and 300 seconds (0 selects the default of 15); map[1].a: conflicts with \"customer\" on side a (map[0]): one path is a parent of the other",
"fields": [
{"field": "id", "message": "must match ^[a-z0-9][a-z0-9_-]{0,62}$"},
{"field": "timeout_sec", "message": "must be between 1 and 300 seconds (0 selects the default of 15)"},
{"field": "map[1].a", "message": "conflicts with \"customer\" on side a (map[0]): one path is a parent of the other"}
]
}| Field | Rule |
|---|---|
id |
^[a-z0-9][a-z0-9_-]{0,62}$, trimmed before the uniqueness check |
name |
≤ 200 chars, no control characters |
*.label |
≤ 80 chars, no control characters |
*.target_url |
absolute http(s), host required, ≤ 2048 chars, no embedded credentials, no fragment |
*.inbound_token / *.outbound_token |
16–512 chars, printable non-space ASCII (they are written into an Authorization header) |
timeout_sec |
1–300; 0/omitted takes the default of 15. Out of range is refused, not clamped |
map |
≤ 500 pairs; both sides required per row; no duplicate or parent/child paths per side |
map[].{a,b} |
≤ 256 chars, ≤ 16 segments, no empty segment, no leading/trailing dot, no array indexing |
map[].transform |
kind must be date; both formats must be a known name, an epoch, or a round-tripping Go layout |
Onboarding problem: a partner is already POSTing and you do not yet know their vocabulary, so you cannot write the map. Discovery records the shape of what arrives — dot-paths and JSON types — so you can wire from what they actually send.
curl -X POST localhost:8080/api/admin/accounts/contoso/discover \
-H "Authorization: Bearer $TOK" -H "Content-Type: application/json" -d '{"minutes":60}'
# ...partner sends a request, even one that cannot be forwarded yet...
curl localhost:8080/api/admin/accounts/contoso/discover -H "Authorization: Bearer $TOK"{"path": "customer.accountNo", "types": ["string"], "count": 1, "mappable": true}
{"path": "lineItems", "types": ["array"], "count": 1, "mappable": false}The response also carries suggest, a set of starter mapping rows built from the mappable paths. Shapes appear on the activity log entry too.
In the console, the account editor has a Field discovery card: start or stop the window, see every observed path with its type and how often it arrived, and click Add pair to drop one straight into the field map with the other side left blank to fill in. Paths a map cannot address — containers, arrays, collapsed identifier maps — are listed but marked not mappable.
It records paths and types, never values. That distinction is the whole design: field names are schema, field values are the payment identifiers and PII this gateway exists to pass through without reading. Bodies are never captured stays true. Three further guards, because the input is partner-controlled:
- Off by default and time-boxed (24 h ceiling). It expires on its own, so nobody leaves it on and forgets.
- In memory only. Observations never reach the audit trail or the state file, so they cannot outlive the process or land in a backup. Closing the window forgets them.
- Identifier-keyed objects are collapsed, not enumerated. JSON like
{"balances":{"cust-91@example.com":…}}puts data in the key position; an object with more than 64 sibling keys is recorded asbalances.{*}instead. That threshold is a heuristic and the trade-off is real — set it lower and ordinary wide records (40-field payment objects are common) collapse to nothing useful. The residual case is covered by the other two guards.
The no_target check runs after decoding so an unwired account still yields its schema: a request you cannot forward is still one you can learn field names from. Opening and closing the window is audited (account.discover).
GET /api/admin/stats, and the console's landing tab. Lifetime counters for the node — total requests, delivered, refused (split into unauthorised and locked out), upstream failures, fields translated, warnings, bytes relayed, average and peak latency, uptime — plus a per-account rollup and a rolling per-minute series for the last hour.
The activity ring answers what just happened and holds only the last -log-size requests. These counters answer what has this node done, which the ring cannot: it forgets. They are in-memory and reset on restart, deliberately — durable metrics belong in whatever scrapes the node, not in a gateway trying to stay a single dependency-free binary. Counters only: no payloads, no identifiers.
Append-only JSONL at -audit, opened 0600. Records are hash-chained: each carries prev (the prior record's hash) and hash (SHA-256 over its own canonical serialization). Insertion, deletion, edit, or reorder breaks the chain from that point forward:
xlate -verify-audit /var/log/xlate/trail.jsonl
# audit chain OK: 13 records, tail 5dc072ac0ffb…Restarts extend the chain (the tail is recovered on open), so one file spans process lifetimes. Security-relevant events (tripwire.*, account.*, alert.*, server.*, denials) are fsynced per record; routine ingest.ok records are flushed and rely on the OS.
Events: server.start/stop/panic, account.create/update/delete/rotate/frozen/rejected, ingest.ok/denied/blocked/error, tripwire.trip/reset, alert.sent/failed. Ingest records carry metadata only (account, direction, ip, sizes, applied/warning counts, upstream status). Bodies are never captured — this traffic routinely carries payment identifiers and PII — and token values never appear; rotation logs the slot, not the secret.
account.rejected records a refused admin mutation: method, path, ip, a reason (unknown_field / invalid_field / bad_request) and the offending field names. Never values — an unknown field is very often a typo'd credential field with a live credential in it (inbound_tokenn), and that is exactly what must not reach a log. Names are stripped of control characters and length-bounded so a client-chosen key cannot forge a log line.
Tamper-evidence is not tamper-proofing: an attacker with write access and the code can rebuild the chain. It reliably catches casual edits and truncation; for stronger guarantees, ship the trail off-host (it's line-oriented JSONL — any log shipper works) and compare tails.
Refused attempts (401s) are counted per account over a sliding -trip-window. When the count exceeds -trip-threshold (default: the 6th failure in 5 minutes), the tripwire fires — once per trip:
- Audit —
tripwire.tripis written and fsynced first; the forensic record exists before anything that can fail or block. - Alert — POST to
-alert-webhookwith account, count, window, first/last timestamps, top offending IPs, mode, and action taken. Signed withX-Xlate-Signature: sha256=<hex HMAC>over the raw body when-alert-secretis set. Delivery is async with one retry; outcomes land on the trail (alert.sent/alert.failed). - Containment per
-trip-mode:lock— ingest answers429+Retry-Afterto everyone, valid credentials included, until-trip-lockexpires; then the counter re-arms. The lock is in-memory: a restart clears it.freeze— the account is persisted disabled (403until an admin re-enables). Survives restart. Stays tripped until an admin resets: authentication is checked before the enabled flag, so a bad token still answers401, and re-alarming on it would be noise.alert— notify only. Re-arms once the window drains fully, so a later campaign alerts again rather than the account alerting once per process lifetime.
The cumulative trip count (trip_number in alerts, trips in the admin API) survives a re-arm — an account on its fifth trip is a different conversation from one on its first.
Design notes: the gate sits in front of authentication — during an active credential-guessing burst the safe state is closed. The tripping attempt itself still receives 401; everything after answers 429. Only 401s feed the counter (a disabled account hammered with 403s can't succeed differently, and counting them would re-alarm forever). Trigger counting is per-account, not per-IP, so rotating sources doesn't evade it; per-IP tallies ride along in the alert for attribution. One shared NAT can't false-trip an account — legitimate users don't generate 401 floods.
Retention. Both dimensions of tripwire state are bounded, because both are sized by attacker traffic: at most -trip-threshold+1 timestamps per account (only the newest can change the verdict) and at most 1024 distinct source IPs (past that, known addresses keep counting and new ones are ignored — the tally is forensic colour for the alert's top-5, not a trigger). count therefore saturates at threshold+1. Without these caps a sustained 401 flood grew the state without limit and made every refused attempt re-scan it, which is quadratic work under exactly the attack the tripwire exists to detect.
Admin: GET /api/admin/tripwire (state for every account), POST /api/admin/accounts/{id}/tripwire/reset. The console badges LOCKED/TRIPPED/FROZEN accounts and offers reset in the editor.
Measured on an i9-11900K (8 cores / 16 threads, Windows), xlate and a mock upstream both on loopback. Reproduce with make bench and make load.
| Concurrency | Throughput | p50 | p95 | p99 |
|---|---|---|---|---|
| 1 | 682 req/s | 1.3 ms | 2.3 ms | 2.8 ms |
| 8 | 1 981 req/s | 3.7 ms | 6.7 ms | 8.4 ms |
| 32 | 3 544 req/s | 7.3 ms | 19.4 ms | 27.6 ms |
| 128 | 2 241 req/s | 7.9 ms | 120 ms | 165 ms |
Throughput peaks around 32 in-flight requests and degrades past it, so cap concurrency there rather than letting it grow unbounded.
Translation itself is not the cost. The engine is roughly a microsecond:
| Operation | Cost |
|---|---|
Translate (5 pairs, nested, one date fx) |
1.1 µs |
| JSON decode of a typical payload | 3.8 µs |
| Token comparison (constant-time) | 0.25 µs |
| Tripwire gate / record | 14 ns / 80 ns |
Audit write, buffered (ingest.ok) |
30 µs |
Audit write, fsynced (denials, account.*) |
1.4 ms |
Two consequences worth knowing before you deploy:
- A successful request is dominated by the outbound call, not by xlate. Your real ceiling is the destination's latency and connection pool, and the per-account
timeout_secbounds how long a slow destination can tie up a request. - Refused requests are fsync-bound.
ingest.deniedandingest.blockedare security-relevant, so each is fsynced, andAuditLogserialises on one mutex. That puts the 401 and 429 paths at roughly 700 req/s — slower than the success path — and a burst of them will queue behind the same mutex that successful requests use for their own audit write. Note that a tripwire lockout does not relieve this: it converts fsynced 401s into fsynced 429s at the same cost. If you expect to absorb credential-guessing floods at volume, put a rate limiter in front (the tripwire is an anomaly detector, not a limiter), or move the trail to faster storage. Group-committing the fsync would fix it properly and is not implemented.
- Token comparisons are constant-time over SHA-256 digests.
- Tokens are stored plaintext in the state file (outbound credentials must be reversible; inbound/admin follow for simplicity). Mitigate with file perms (
0600) and host controls; hashing inbound + admin tokens is on the roadmap. - Handler panics are recovered to a clean
500; the stack goes to the server log and audit trail, never the wire. Upstream failure detail (internal topology) likewise stays in logs. - Client IPs come from the socket peer.
-trust-proxyopts into the firstX-Forwarded-Forhop — only enable behind a proxy that strips client-supplied XFF, because this value feeds tripwire forensics. - Unsigned alert webhooks are spoofable; the startup log warns if
-alert-webhookis set without-alert-secret. - Admin UI ships a strict CSP; run behind TLS. Target URLs are admin-supplied and unrestricted — an SSRF primitive if untrusted parties gain admin access; egress-filter the host if that matters.
- Credentials and correlation identifiers are validated to be header-safe before they can reach an outbound
Authorizationor pass-through header, so no admin- or partner-supplied value can smuggle CRLF into a forwarded request. - Unknown accounts answer
404before the tripwire is consulted, so account IDs can be probed without being counted. IDs are handed to integrators in URLs and are not secrets; metering the probe would mean allocating tripwire state for arbitrary attacker-chosen strings, which is a worse trade. Front with a rate limiter if enumeration matters to you. - The fsync cost on refused requests (above) is a disk-I/O amplification vector for an unauthenticated caller. Bounded by whatever rate limiter sits in front.
- Field discovery walks partner-controlled JSON. Depth, path count, key length and sibling count are all bounded; keys are stripped of control characters before they can reach a log line or the console; nothing is written to the trail or the state file. It is off by default and expires on its own. See Field discovery above for the one residual case (a small identifier-keyed object below the sibling threshold).
docker build -t xlate:0.4.0 .
docker run -d --name xlate -p 8080:8080 \
-e XLATE_ADMIN_TOKEN="$(openssl rand -hex 32)" \
-v xlate-data:/var/lib/xlate xlate:0.4.0The image is distroless/static running as nonroot: the binary, CA certs, and nothing else — no shell, no package manager. State and the audit trail live on the mounted volume. A named volume (as above) inherits the correct ownership from the image; with a bind mount instead, chown 65532:65532 the host directory first or the non-root process cannot create its files.
For systemd, deploy/xlate.service and deploy/xlate.env.example ship a hardened unit — dedicated user, ProtectSystem=strict, ReadWritePaths limited to the two directories it writes, UMask=0077, and credentials in an EnvironmentFile so they never appear in ps.
Either way:
- Pin
XLATE_ADMIN_TOKEN. Unset, a token is generated per run and printed at startup — fine for a first look, wrong for anything that restarts. - Terminate TLS, with
-tls-cert/-tls-keyor a proxy. TLS 1.2 is the pinned floor when serving directly. - Back up the state file. It is the whole deployment, credentials included, and it is
0600for that reason. - Ship the audit trail off-host. Line-oriented JSONL; any log shipper works.
SIGTERMdrains in-flight requests, writesserver.stop, and closes the trail cleanly. The drain window is sized to the largest configured account timeout plus 5s (20s with only default-timeout accounts, up to 305s if an account uses the 300s maximum) — give the supervisor at least that long before it escalates to SIGKILL.
Writes are temp file → fsync → rename → fsync the directory. The rename is what makes the swap atomic, but rename alone is not durable: without the fsync a crash can leave the new name pointing at a file whose contents never reached disk.
If a write fails — full disk, read-only mount — the in-memory state is rolled back to match what is actually on disk. Otherwise the API would report an error while the change stayed live: the account would serve traffic, appear in listings, and vanish on the next restart. Rotate gets its own rollback because it edits the account in place; a half-applied rotation would kill the old token without persisting the new one and lock the partner out.
A corrupt or truncated state file stops the process at startup rather than quietly coming up with no accounts and refusing live traffic.
Enum/type value transforms and strict (fail-closed) transform mode, array path addressing, non-POST methods, retries/DLQ for failed forwards, HMAC signing of data-plane requests, per-account rate limits (tripwire is an anomaly response, not a limiter), multi-user admin (OIDC), SQLite backend, off-host audit shipping, group-committed audit fsync.
Flat package main on purpose at this size — translate.go (pure engine), transform.go (date fx), validate.go (request decoding + field rules), discover.go (payload shape capture), store.go (state + persistence), server.go (HTTP), ringlog.go (live ops view), stats.go (node counters), audit.go (chained trail + verifier), alerts.go (tripwire + webhook), main.go, web/admin.html (embedded). demo/ is scaffolding, not shipped code; its mock destination is a separate module.
make check # gofmt + vet + race tests + end-to-end (what CI gates on)
make test # unit, validation and regression tests
make e2e # builds the binary and drives it as a subprocess
make cover # coverage summary
make bench # microbenchmarks
make load # throughput probe at several concurrency levels
make docs-check # replays TUTORIAL.md and prints the real responses
make lint # staticcheck -checks=all
make vuln # govulncheck
make buildFive suites, all in package main:
- Unit (
translate_test.go,feature_test.go) — the pure engine, transforms, audit chain, tripwire. - Validation (
validate_test.go,api_test.go) — every field rule, and the whole REST surface driven in-process throughhttptest. - Regression (
regression_test.go,reject_audit_test.go,coverage_gap_test.go) — one test per defect found in review, plus the paths the others missed, so none of it can come back quietly. - Adversarial (
adversarial_test.go) — the data plane is reachable by a partner, not an operator, and field discovery walks whatever JSON they send. These treat that input as hostile: 5000-deep nesting, unbounded distinct keys across requests, control bytes and markup smuggled through JSON key escapes, auth on every route added late, delete-during-discovery, and the body cap after theno_targetreordering. - End-to-end (
e2e_test.go, build tage2e) — builds the binary, runs it as a real subprocess against a mock upstream, and checks the behaviour this README documents: the error table, audit tamper-evidence, signed alerts, tripwire lockout and re-arm, redirect relaying, and that no payload or credential reaches a log. - Benchmarks (
bench_test.go) — the numbers in Performance.
Plus tutorial_verify_test.go (build tag tutorial), which replays every step of TUTORIAL.md against a real server and prints the actual responses. Run make docs-check after changing anything user-visible; documentation that lies is worse than documentation that is missing.
Statement coverage is ~83%; main() is the only function with none, and the end-to-end suite covers it behaviourally. The e2e suite is tagged e2e so it stays out of make test: it is slower and it shells out to go build.





