Skip to content

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.


The twin maintains a route trie built from the synthesized model. Every incoming request is matched against it first.

SituationStatusBodyProvenance
Route hit — method + path matched a known templatevariesserved response (see entity dispatch below)recorded, template, handler, fixture, fault, or miss
Route miss — no template matched501structured 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; null when not injected (in-process tests).
  • wraith.routes — route templates in METHOD /path/:param form, capped at 20. When the twin has more routes, wraith.routes_total carries the full count.
  • wraith.hint — points at wraith coverage for recorded-coverage analysis.
  • The error field is preserved for backward compatibility.

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 in state/fixtures/ for the route’s entity type
∪ entities created through the twin’s own state layer this session
(respects X-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.

Situationsynthesize mode (default)not_found mode (--unknown-entity not_found)
Entity id in known-ID set200 — served from state / recording / templatesame
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 variantSynthesized 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 variantAlready 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):

  1. 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.
  2. Else the structured 501 route-miss body (same shape as above) with status 501, also provenance=miss.

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).

Single per-response value. One of:

ValueMeaning
recordedVerbatim recorded exchange — served by the exact-body short-circuit or exact_read_recording.
templateSynthesized from the WIR model (template constants + holes, session state, error envelopes). The default when no more-specific branch fired.
handlerProduced by a Lua handler’s successful return.
fixtureServed from a seeded fixture entity (state/fixtures/), including a read that overlays fixture-entity fields onto a template.
faultA fault or rate-limit injection short-circuited normal serving before the body was rendered (fault Error/Throttle/Drop/Timeout and rate-limit 429).
missA 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-field X-Wraith-Provenance-Counts header. miss is a per-response value only — the counts-header vocabulary is unchanged.

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.

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.

Terminal window
# CLI flag (takes precedence over wraith.toml)
wraith serve myapi --no-provenance-headers
# wraith.toml [serve] section
[serve]
provenance_headers = false

Suppresses X-Wraith-Provenance, X-Wraith-Route, and X-Wraith-Exchange together.

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=0

Vocabulary 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).


wraith serve --fidelity <strict|synth|permissive> (or [serve] fidelity in wraith.toml). Default: synth.

ModeRoute missEntity 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
strict501 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)
permissivelegacy/unimplemented — behaves like synth todaysame as synthsame 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.


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.

HeaderDefaultDescription
X-Wraith-ProvenanceONPer-response provenance word: recorded | template | handler | fixture | fault | miss. Suppressed by --no-provenance-headers.
X-Wraith-RouteONMatched route template (METHOD /path/:param). Suppressed by --no-provenance-headers.
X-Wraith-Exchangeconditional<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-AgealwaysTwin 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-Atwhen availableRFC 3339 UTC timestamp of the newest source recording. Omitted when the twin has no recordings on disk.
X-Wraith-Provenance-Counts--debug onlyCoarse 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.


Terminal window
# Start the twin and capture the serving URL
wraith serve myapi --port 0 --ready-json /tmp/ready.json &
URL=$(jq -r .serve.url /tmp/ready.json)
# Check a response's provenance
curl -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 body
curl -s "$URL/nonexistent" | jq .wraith.hint
# "route not in recorded coverage; see `wraith coverage`"
# Enable fail-closed entity semantics for agent sandboxes
wraith serve myapi --unknown-entity not_found
# Serve with strict replay (recorded responses only)
wraith serve myapi --fidelity strict

See also: wraith inspect <twin> --provenance for static per-route provenance without serving.


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-Exchange header — 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.