Twin Response Contract
This page is the authoritative reference for how a running twin responds to requests. It covers route and entity dispatch, provenance headers, fidelity mode interactions, and the complete set of X-Wraith-* response headers an agent harness or test suite may observe.
Applies to wraith 0.18.3 and later. See Migration notes if you are upgrading from an older synthesized model.
Cross-referenced from wraith serve --help.
For a practical harness setup that combines these headers with session isolation, fixtures, deterministic clocks, and per-session reset, see Sandboxing agents with Wraith.
Hits and Misses
Section titled “Hits and Misses”Route-level dispatch
Section titled “Route-level dispatch”The twin maintains a route trie built from the synthesized model. Every incoming request is matched against it first.
| Situation | Status | Body | Provenance |
|---|---|---|---|
| Route hit — method + path matched a known template | varies | served response (see entity dispatch below) | recorded, template, handler, fixture, fault, or miss |
| Route miss — no template matched | 501 | structured miss body (see below) | miss |
Structured 501 body (route miss):
{ "error": "no matching route", "wraith": { "twin": "rec-catalog", "routes": ["GET /v3/assets", "GET /v3/assets/:param"], "hint": "route not in recorded coverage; see `wraith coverage`" }}wraith.twin— the served twin name;nullwhen not injected (in-process tests).wraith.routes— route templates inMETHOD /path/:paramform, capped at 20. When the twin has more routes,wraith.routes_totalcarries the full count.wraith.hint— points atwraith coveragefor recorded-coverage analysis.- The
errorfield is preserved for backward compatibility.
Entity-level dispatch (route hit)
Section titled “Entity-level dispatch (route hit)”After a route match, entity-bearing routes (path-parameterized GET/PUT/PATCH/DELETE) look up the requested entity id.
Gate scope. The fail-closed gate is applied before Read/List state-op rendering, so it fires on read routes and parameter-bearing list routes that carry at least one path parameter — the requested id is the route’s last path-parameter value. This deliberately includes parameter-bearing nested-collection routes: GET /orgs/:param/repos gates on the parent :param (the org), so an unknown org returns a fail-closed miss rather than a synthesized repo collection. Only non-parameterized routes (GET /orgs) and non-Read/List routes are never gated.
Multi-param identity. On a re-synthed model the gate keys on the full ordered path-param tuple per route pattern, so a never-recorded (parentB, lastId) whose lastId was only recorded under a sibling parentA fails closed instead of fabricating a 200. Single-param routes are unaffected. Legacy models (synthesized before 0.18.0) carry no tuple index and fall back to the coarser last-param check — re-synth to pick up the stricter behavior.
Known-ID set (per session):
IDs observed with any non-4xx outcome in recordings for that route’s entity type
∪ entities instate/fixtures/for the route’s entity type
∪ entities created through the twin’s own state layer this session
(respectsX-Wraith-Session; client-supplied ids on create are included)
Known-MISSING set: IDs whose only recorded outcome was a 4xx — direct recorded not-found evidence (e.g. a zero-uuid probe that received a 404). This is the disjoint complement of the known-ID set: an id that received a 2xx even once is “known”, never “missing”. In not_found mode the gate consults known-MISSING before the known set, so an id the twin holds recorded not-found evidence for fails closed instead of fabricating a 200. Both sets are consulted only in not_found mode; default synthesize mode reads neither.
| Situation | synthesize mode (default) | not_found mode (--unknown-entity not_found) |
|---|---|---|
| Entity id in known-ID set | 200 — served from state / recording / template | same |
| Entity id in known-MISSING set (recorded 4xx-only) | Synthesized 200, provenance=template (default mode is unchanged) | Fail-closed not-found, provenance=miss (see preference order below) |
| Entity id not in known-ID set, route has no 4xx variant | Synthesized 200, provenance=template (template-clone with the requested id inserted) | Fail-closed not-found (see preference order below) |
| Entity id not in known-ID set, route has a 4xx variant | Already 404/4xx via the state_member guard (the id is not a state member) | Same 4xx — the gate and the guard select the same variant |
Exact-replay first. In both modes the exact-body short-circuit runs before this gate, so a request for the exact recorded URL of a 4xx-only id replays that recorded 404 verbatim with
provenance=recorded. The known-MISSING gate only engages when exact replay is defeated — e.g. an added query param, or a re-synthed model without raw bodies.
Fail-closed not-found preference order (not_found mode):
- The route’s recorded or synthesized 4xx variant — the provider’s own not-found shape (status 404 preferred; any 4xx accepted). Provenance is
miss: a policy-produced coverage decision, not a verbatim recorded 404. - Else the structured 501 route-miss body (same shape as above) with status 501, also
provenance=miss.
Provenance Headers
Section titled “Provenance Headers”Every application response carries provenance headers that describe how the response was produced. They are emitted by default and can be suppressed with --no-provenance-headers (or [serve] provenance_headers = false in wraith.toml).
X-Wraith-Provenance
Section titled “X-Wraith-Provenance”Single per-response value. One of:
| Value | Meaning |
|---|---|
recorded | Verbatim recorded exchange — served by the exact-body short-circuit or exact_read_recording. |
template | Synthesized from the WIR model (template constants + holes, session state, error envelopes). The default when no more-specific branch fired. |
handler | Produced by a Lua handler’s successful return. |
fixture | Served from a seeded fixture entity (state/fixtures/), including a read that overlays fixture-entity fields onto a template. |
fault | A fault or rate-limit injection short-circuited normal serving before the body was rendered (fault Error/Throttle/Drop/Timeout and rate-limit 429). |
miss | A policy-produced fail-closed not-found: the twin had no coverage for this request and the --unknown-entity not_found gate (or a route-level miss) fired. The body was synthesized from the route’s 4xx variant or the structured-501 route-miss envelope. Distinguishes “the twin is telling you it does not have this” from recorded (a verbatim provider 404 replay) and template (a synthesized content answer). |
The first five values (
recorded/template/handler/fixture/fault) are shared with the per-fieldX-Wraith-Provenance-Countsheader.missis a per-response value only — the counts-header vocabulary is unchanged.
X-Wraith-Route
Section titled “X-Wraith-Route”Matched route in METHOD /path form, e.g. GET /v3/assets/:id. Present on every application response where a route matched. Absent on route-miss 501 responses (no route matched).
Under synth fidelity the path is the abstracted route template in wraith :param form. Under strict/permissive fidelity the path is the matched recording’s concrete request path (e.g. GET /v3/assets/42), since strict replay indexes recorded exchanges by concrete (method, path) and has no template abstraction.
X-Wraith-Exchange
Section titled “X-Wraith-Exchange”Source exchange identity in <session_id>/<index> form. Present only when:
X-Wraith-Provenance: recorded, AND- The synthesized model was built with wraith 0.18.0 or later (models built before 0.18.0 omit this header — not an error, just re-synth to enable it).
Maps directly to the WREC file at recordings/sessions/<session_id>/<index>.wrec.zst.
Opt-out
Section titled “Opt-out”# CLI flag (takes precedence over wraith.toml)wraith serve myapi --no-provenance-headers
# wraith.toml [serve] section[serve]provenance_headers = falseSuppresses X-Wraith-Provenance, X-Wraith-Route, and X-Wraith-Exchange together.
X-Wraith-Provenance-Counts (debug only)
Section titled “X-Wraith-Provenance-Counts (debug only)”Enabled by wraith serve --debug (synth fidelity only). Carries coarse per-field origin counts for the response body:
X-Wraith-Provenance-Counts: recorded=12 template=4 fixture=0 handler=0Vocabulary aligns with the per-response X-Wraith-Provenance wire words (template ≈ synthesized, handler ≈ authored/Lua) so a harness reads one vocabulary across both headers.
With --debug and --trace combined, each trace entry also carries the full per-field origin map — GET /__wraith/trace/<id> answers where every field of that response came from (template constant, echo, generated, state, fixture, or Lua handler).
Fidelity Mode Interactions
Section titled “Fidelity Mode Interactions”wraith serve --fidelity <strict|synth|permissive> (or [serve] fidelity in wraith.toml). Default: synth.
| Mode | Route miss | Entity miss (unknown id) | Provenance values seen |
|---|---|---|---|
synth (default) | 501 structured miss (miss) | synthesized 200 (template) or fail-closed 404/501 (miss, not_found mode) | all six |
strict | 501 structured miss (miss, no recordings match) | always fail-closed — strict mode never synthesizes, so an entity miss is inherently a not-found (recorded 4xx → synthesized 4xx → 501) | recorded, fault, miss (no template for normal responses) |
permissive | legacy/unimplemented — behaves like synth today | same as synth | same as synth |
In strict mode the twin serves only verbatim recorded exchanges. A request that matches a route but has no exact recorded response gets the fail-closed treatment regardless of the --unknown-entity flag.
All X-Wraith-* Response Headers
Section titled “All X-Wraith-* Response Headers”Every application response (all fidelity modes) may carry the following control headers. These are wraith control headers, not body PII — they are exempt from the outbound scrub pipeline.
| Header | Default | Description |
|---|---|---|
X-Wraith-Provenance | ON | Per-response provenance word: recorded | template | handler | fixture | fault | miss. Suppressed by --no-provenance-headers. |
X-Wraith-Route | ON | Matched route template (METHOD /path/:param). Suppressed by --no-provenance-headers. |
X-Wraith-Exchange | conditional | <session_id>/<index> source identity; present only for recorded responses on models synthesized with 0.18.0+. Suppressed by --no-provenance-headers. |
X-Wraith-Twin-Age | always | Twin age in whole seconds at server startup (does not tick; divide by 86400 for days). Anchors on the newest recording session; model-only twins fall back to synth_timestamp. |
X-Wraith-Recorded-At | when available | RFC 3339 UTC timestamp of the newest source recording. Omitted when the twin has no recordings on disk. |
X-Wraith-Provenance-Counts | --debug only | Coarse per-field origin counts (see above). |
The same freshness fields (twin_age_seconds, recorded_at, synthesized_at, drifted_routes) appear in the --ready-json envelope and GET /__wraith/info so agents or CI can read freshness without parsing headers.
Quick Reference for Agent Harnesses
Section titled “Quick Reference for Agent Harnesses”# Start the twin and capture the serving URLwraith serve myapi --port 0 --ready-json /tmp/ready.json &URL=$(jq -r .serve.url /tmp/ready.json)
# Check a response's provenancecurl -s -D- "$URL/v1/items/42" | grep -i x-wraith
# X-Wraith-Provenance: recorded# X-Wraith-Route: GET /v1/items/:id# X-Wraith-Exchange: sess_abc123/7# X-Wraith-Twin-Age: 86400# X-Wraith-Recorded-At: 2026-06-01T12:00:00Z
# Route miss — 501 with structured bodycurl -s "$URL/nonexistent" | jq .wraith.hint# "route not in recorded coverage; see `wraith coverage`"
# Enable fail-closed entity semantics for agent sandboxeswraith serve myapi --unknown-entity not_found
# Serve with strict replay (recorded responses only)wraith serve myapi --fidelity strictSee also: wraith inspect <twin> --provenance for static per-route provenance without serving.
Migration Notes
Section titled “Migration Notes”If you are upgrading a twin that was synthesized before wraith 0.18.0, run wraith synth <twin> after upgrading to pick up:
- Known-ID tuple index — the multi-param entity gate uses a full path-param tuple per route, not just the last param. Without re-synth, an entity id shared across different parent contexts passes the gate when it should not.
X-Wraith-Exchangeheader — source exchange identity (<session_id>/<index>) is only emitted for models that carry the identity index from 0.18.0’s synth pass. Without re-synth the header is simply absent; responses are otherwise unaffected.
Twins that do not use multi-param routes and do not need the Exchange header are unaffected and continue to serve correctly without re-synth.