Skip to content

Wraith release notes and API twin conformance progress

Twins get noticeably better at handling a caller the recordings never saw, and wraith check stops being polite about what it cannot explain. Seven rounds of conformance work since v0.25.0. (v0.26.0 was tagged the same day but its macOS release build failed in a unit test before any binaries were published; v0.26.1 is the identical engine with that test fixed — there was never a working v0.26.0 to install.)

Two things are worth knowing before you upgrade. wraith check got stricter on purpose: a response position synthesis could not explain used to be skipped; it is now compared against the values the recordings actually showed there. A twin that passed v0.25.0 can show new findings under v0.26.1 with no change in its own behaviour — the checker is telling you the truth it used to swallow, not reporting a regression. And wraith coverage --corpus (plus the zero-success corpus-integrity gate) now reads the gRPC status trailer, so a recorded gRPC refusal returned under an HTTP 200 counts as a failure, not a success; a gRPC twin will show a lower “successful exchange” count than before, which is the recording, not a regression.

  • An unexplained field is held to what the recordings showed. Where synthesis could not classify a response position, wraith check now compares it against the whole set of values its recordings gave — exact where they agreed on one, membership where they varied — instead of skipping it. Should I do anything? Re-run wraith check. New findings here are honest: the field was never actually verified before.
  • A discriminated listing served the wrong kind of element is a finding. Where a route’s recordings are all of one tagged-union case, wraith check now compares the discriminator on every element the twin serves, not just the recorded ones.
  • A re-minted identity is checked at the spelling a fresh caller would send, not frozen at the recording’s own spelling of an identity the twin re-minted per session.
  • wraith coverage --corpus and the zero-success corpus-integrity gate read the gRPC status trailer. A recorded gRPC call that answered HTTP 200 with a failing gRPC status now counts as a failure, matching what the client actually saw.

Twins get more general on a caller the recordings never exercised:

  • A caller can delete what it just created, addressed by the very name it chose — routes and state now follow a name the API minted for one run, not just a recorded literal.
  • A create files under the request field the API republishesmetadata.name-style and realm-style creates now read, update and delete back.
  • A filtered listing answers the kind it was asked for, instead of rotating over every other kind the collection can hold.
  • A list envelope’s page size, total and page count are computed from the answer — echoing the caller’s own paging parameters and the origin’s recorded default — instead of being frozen from one recording.
  • A created child names the parent this caller created (a queue item, a build) instead of whichever recording the pool happened to pick.
  • A poll loop walks the recorded moments in order, the empty one included, where the recordings show the same repeated read producing a sequence.
  • A membership listing includes a member that existed before the session began, not only ones the session itself created.
  • An absent member under an absent parent gets the origin’s own recorded refusal instead of a generic route-miss.
  • Opaque (non-JSON) documents render the caller’s own request values, and Unicode punctuation now splits words correctly when the twin tokenizes a page for templating.
  • Memorised listings and read-shape donation are read at the element array, not the first array the response happens to list in key order.

Every new field this release added to the persisted model is #[serde(default)]; a model written by v0.25.0 loads unchanged under v0.26.1.

Should I do anything? Upgrade, then re-run wraith synth on each twin to pick up the synthesis-side changes above — the persisted model is only ever as good as the last synth that wrote it. Expect wraith check to report new findings on twins that used to pass; they are honest findings, not new bugs, and every twin that passed before still passes.

A twin that refuses can no longer pass by coincidence, and wraith record captures traffic it could not see before. Fourteen rounds of conformance work since v0.24.1. Two things are worth knowing before you upgrade. wraith check now reports an exchange the twin answered with wraith’s own coverage-miss envelope as a finding of its own, so runs that used to score those as passes will show findings — the twin did not get worse, the check got more honest. And wraith record now reads the twin’s synthesized model, so run wraith synth <twin> on v0.25.0 before you re-record an existing twin; a model built by an older version has no route evidence for the recorder to read, and the recorder will treat every route as never measured.

  • wraith check reports a synthesized refusal as its own finding. Whenever the twin answered with wraith’s own coverage-miss envelope instead of a response modelled on the API’s, the exchange is now reported under the synthesized_refusal category — whatever status that envelope happened to carry. Before this, an envelope that happened to carry the status the recording expected scored as a pass, so a twin could look conformant precisely where it had nothing to say. Should I do anything? Re-run wraith check. New synthesized_refusal findings are exchanges your twin never covered and were always failing silently; the fix is to record the route, not to suppress the category.
  • Engine-invented envelopes are stamped X-Wraith-Provenance: miss. The no-variant-matched 404 and the malformed-body 400s used to report template, claiming a synthesized content answer they were not. They now report miss, joining the fail-closed not-found and the structured route-miss 501. If your client branches on the header, read miss as “the twin is telling you it has no answer for this”. The full table is on the twin response contract page.
  • A header-guarded route keeps its catch-all. Where a twin’s recordings split one route by request header, a caller whose headers match neither recording now gets the route’s real success response instead of a synthesized 404. In the same family: a success guard is withdrawn wherever guarding every success would leave an unrecorded request answered by a 404, and a path position the API only ever refused becomes a parameter, so probes at it get the recorded 404 rather than a 501 route miss.
  • A guard built from one recorded row is no longer installed. A variant recorded exactly once can no longer have its discriminator picked by alphabetical tie-break, and a guard whose value identifies a single recorded moment rather than a class of requests is dropped — so a route whose recordings span two eras stops answering a synthesized 404 to its own exchanges.
  • A route that recorded a credential-less challenge asserts it. A request carrying no authorization used to be answered from a memorised index with a body recorded under an admin token; it now gets the recorded challenge. The guard is over the whole credential family rather than one header name, and there is deliberately no “credential present” rule — a different valid credential is never challenged where the route’s recordings establish only a credential-absence fact.
  • Creates the twin used to refuse are answered. A 2xx PUT into an empty address is treated as a creation, and a create addressed at a caller-chosen name is answered rather than 404’d where every recorded success is a 201 and no earlier response published the name. Relatedly, a file the caller named no longer gets a route of its own — percent-escaped and dotted path segments now qualify as caller-minted names.
  • A create’s uniqueness key can be spelled anywhere the API spells it. A nested body path or a query parameter both work now, not only a top-level body field, and a create remembers the identity it asked for. The uniqueness refusal status is read from the recordings instead of assumed to be 409 — another 4xx is admitted only where every exchange carrying it repeats a value an earlier success created. Models synthesized by earlier versions still load.
  • An RPC method name states its subject as well as its verb. On gRPC and JSON-RPC twins the entity type came from the whole method name, so five verbs of one entity addressed five different stores and no read ever saw what a write committed. They now share one store.
  • A write whose success answers nothing still lands. A gRPC Empty or a REST 204 takes its entity identity from the request value an earlier recorded read published, so a delete or an ack reaches the session store instead of evaporating.
  • A write can end the address it names. Where the recordings show it, a write that binds a path parameter terminates the address its prefix names, and the reads that straddle it are answered from the session’s own ledger instead of whichever recorded moment iteration order happened to pick.
  • A read resolves the address the API itself spells. Where a read route’s trailing parameter equals one entity field in every 2xx recording, that field decides the store key — so GET /orgs/{name} answers the caller’s own org rather than a template clone.
  • A per-parent ordinal continues its parent’s count. A create under a known parent no longer restarts its numbering in a new session. Where a recording interleaves parents, the twin refuses the classification out loud rather than pooling children silently, and wraith check republishes those refusals beside the score.
  • A gRPC refusal is a refusal. Status is now the (HTTP status, grpc-status) pair everywhere it matters — variant partitioning, the success predicate, identity inference — so a trailers-only error is its own variant instead of being folded into the success variant and served back to refused requests. Such a refusal reaches the client as the spec’s trailers-only response, with the gRPC status headers no longer stripped. HTTP behaviour is unchanged.
  • A route serves the recorded document, in the recorded content type. A non-JSON variant beside a JSON one now carries its own recorded bytes, so a route answering a JSON success next to an HTML or XML error stops serving null under the recording’s content type; an index miss on an opaque route renders that route’s recorded document rather than a JSON null; and a URL-keyed recording now beats the route-level blob, so where the query string selects the operation you stop getting one wrong document for every request.
  • A response body that is a single token is a value, not a blob. It gets hole classification and symbolization like any other leaf: serve emits its bytes verbatim, wraith check names the value that moved instead of reporting an opaque body, and wraith export writes it as a text body.
  • A twin stops handing clients absolute URLs at the recorded upstream. The set of authorities rewritten to your local address is now the declared base URL plus every authority the recordings attest this twin itself answered — so a self-URL your wraith.toml never named is rewritten too, while a genuinely third-party host is never touched. wraith doctor discloses the set it derived.
  • A composed string need not start with http://. The token-template derivation reaches any shared delimiter skeleton, so a composed identifier gets each varying position from the request instead of being pinned to a recorded constant.
  • Value-shape fixes. A number or boolean landing where every recording showed a string is written as a string. Charset detection picks the narrowest covering class, so UUID columns serve canonical UUIDs, and a drawn UUID can no longer come out shaped like a credit-card number. An empty repeated field in a decoded protobuf request no longer wipes an entity’s lists, and a null map value is skipped on encode.
  • wraith record reads the twin’s own model. When a twin already has a synthesized model, recording applies the same per-route column evidence that wraith recordings rescrub applies, so a later session is never scrubbed with less evidence than the rescrub that follows it. A first recording of a brand-new twin behaves exactly as before. Should I do anything? Yes. Run wraith synth <twin> on v0.25.0 before re-recording an existing twin — a model synthesized by an earlier version carries no route evidence, and the recorder reads those routes as never measured.
  • A CONNECT tunnel is recorded as what it carries. Forward-mode recording classifies the tunnel’s first bytes instead of assuming TLS, so plaintext HTTP/1.1 and h2c clients now record. A tunnel whose protocol is not recognized is relayed byte for byte and not recorded — and is refused outright under strict capture, so nothing passes through unseen.
  • gRPC records in forward mode, streams included. The intercepting proxy advertises ALPN h2 alongside http/1.1 and serves HTTP/2 through the same per-exchange capture, streams and trailers included, so a gRPC client behind an HTTPS proxy records the calls that only reverse-mode recording could capture before. A call whose request is a stream is now recorded too — the request half streams as the response half already did — and a trailers-only upstream error reaches the client with the upstream’s status instead of a stream closed with no trailers. Strict capture still fails closed.
  • An unsized request body is forwarded as it arrives. Both proxies stop collecting a request body whose length was never declared, so a peer that waits for the response before sending its last frame no longer deadlocks.
  • Recordings carry the id of the run that produced them. The recorder stamps one opaque random id per system-under-test lifetime — no wall clock, no hostname, nothing a twin could memorise — and replay treats one such run as a single state scope. An exercise script marks a new run by sending POST /__wraith/new-epoch to the recording proxy. No existing recording is back-stamped.
  • Manifests are stamped from evidence, not from a clock. A session’s recorded end is the newest byte it observed, stamped apart from the idle clock and clamped so it cannot precede the start; wraith refresh stamps its bounds from the exchanges it actually persisted. wraith doctor gained an Info finding naming manifests their own exchanges contradict — a recorded manifest is evidence and is never silently rewritten — and the end-time audit now names the sessions it could not audit instead of averaging them in.
  • wraith gc selects by session age only, never by order, so a recording still in flight survives collection. The behaviour is now documented and pinned by tests.
  • The served arm sends the recorded request. wraith check --target’s HTTP client seeded an accept header into every request that had none, and followed redirects, so routes that split on that header and recorded 3xx responses were scored against an outcome that was never recorded. Findings are also filed under the model route now, the way the in-memory driver already did, so one finding spelled two ways stops being counted twice.
  • Check, serve and strict replay read the same route evidence. All three now answer whether a route’s evidence measured a given column in exactly the same way, so a twin is no longer scored against a recording interpreted differently from the twin serving it. This is the other half of the “re-synth before you re-record” note above: a twin installed before route evidence existed reads as unmeasured, consistently, everywhere.
  • check publishes two new disclosures beside the score. index_masked states what the model asserts at positions where the exact-body index answers, and minority_constants names a URL constant the synthesizer refused for lack of support, with both population sizes. Neither is scored; both are there so a passing twin cannot hide what it declined to claim.
  • A refusal under HTTP 200 stops counting as evidence. wraith coverage --corpus reads the protocol’s own error discriminator, so JSON-RPC and GraphQL refusals returned under a 200 no longer inflate coverage, and recording health reads the body’s verdict too — a stated refusal demotes a 200, while a stated success never rescues a 4xx.
  • wraith doctor states the per-route stated-refusal rate. Body-level refusals under HTTP 200 are reported so you can see which routes your exercise script only ever provoked into errors. Reported, not gated.
  • serve --capture writes every served exchange as a scrubbed WREC. Off by default, keyed by the caller’s session header, and written outside recordings/ and model/ so no synth, check or export ever reads it back as evidence. Use it to see what your test suite actually asked the twin for.
  • serve --trace now records in every fidelity mode. Tracing moved to the fallback that all handlers share, so strict replay, gRPC and Lua handler responses are no longer invisible to it.
  • Captures are readable while the twin runs and after it exits. /__wraith/capture/* serves a filtered log, one exchange in full, and a reset, over HTTP; wraith capture list and wraith capture show read the same data off disk once the twin has exited.
  • wraith capture expect asserts over captured traffic. It adds body and header matchers and a count bar to capture list’s selectors, exits 2 on a failed assertion, and names the selector that excluded the traffic so a failure tells you which filter was wrong.
  • A dotted identifier is no longer scrubbed as a JWT. The record-time and outbound nets rewrite a three-segment dotted value only when its first segment really is a JOSE header, so a namespaced error code — or any other dotted string — reaches the wire exactly as the recording spells it.
  • A query scrub rewrites only the value it matched, and matches by name family. Tokenizing one query parameter no longer re-spells the others, so the rest of the query string stays byte-identical, a rescrub is stable, and a URL-keyed memorised answer still matches what the client sends. Query parameter names are now matched by family — the credential family, plus a new signature family for request-signing parameters such as X-Amz-Signature and X-Amz-Credential — the way header scope already was, and the match is value-gated so the public half of a signing envelope survives. Should I do anything? Re-run wraith doctor --security-audit; if it reports signature parameters in existing recordings, wraith recordings rescrub repairs them in place.
  • The auth scheme word survives the scrub; the credentials do not. A credential-family header that parses as RFC 7235 credentials with an IANA-registered scheme keeps Bearer or Basic verbatim and tokenizes only what follows. This is a write-path change — only re-recording restores the scheme on recordings you already have. In the same pass, the echo net learns the bare credential, so a bearer token quoted bare in a response body is tokenized instead of stored.
  • A scrub pass that cannot read a body no longer rewrites it. The free-text fallback runs only over a body whose media type is defined over characters and whose bytes decode as UTF-8. It had been substituting into raw protobuf frames and leaving the length prefixes stating the old size — a corrupted recording that looked fine on disk. A body the pass refuses is persisted byte-identical and the refusal is declared, so you can see what was skipped.
  • A recorded frame is scrubbed under its own direction’s rules. Client-direction frames are parsed with the request’s content type, so person data in a streamed request no longer lands on disk in cleartext.
  • wraith recordings rescrub gained a name arm. Audit and repair share one decision, so every rung of the scrub’s precedence chain applies unchanged, and top-level array leaves are re-rooted correctly. Dry run by default, as before.
  • The dual-spelling repair reaches inside a URL, one segment at a time. Where one identity is spelled cleartext in a request path and tokenized in a response body, the rescrub now rewrites the path region only — one segment moves, the rest of the URL stays verbatim — and only under a proof: some exchange addressed the value and its own response answered with that value’s token. A name-family value that joins two identities with /, : or @ is tokenized part-wise, so a part’s token matches the one at its own leaf and in every URL that spells it out. The request arm runs first, so the rescrub converges in one pass.
  • The key classifier reads keys as words. camelCase and dotted spellings now reach the name and email families the way snake_case already did, while hostname and filename still refuse. A generic content key becomes a credential when the last segment of its request URL is a credential name. And a cursor is not an authenticator: a key whose words name a position in a result set is refused when its only credential word is token, so a pagination handle is no longer tokenized and propagated across your recordings.
  • The audit sees more of what was already on disk. Credential-named body fields are visible to audit, rescrub and ratchet; a key ending in path is treated as holding identity slugs where the body already tokenized them; and each run prints how many exchanges the active HMAC key speaks for, plus the key’s fingerprint and nothing else. Should I do anything? Re-run wraith doctor --security-audit. Findings in these classes are pre-existing recorded values now visible, not new leaks, and wraith recordings rescrub repairs them.
  • wraith doctor names a scrub.toml that only repeats the built-ins. Each redundant rule is reported at warning severity, compared by parsed regex meaning rather than by text, with the note that a copy double-tokenizes. In the same family, the UUID guard is now a property of the pattern rather than of the rule’s provenance — an operator’s copy of the built-in card rule used to redact the middle of an all-digit UUID, and same-regex rules now inherit the guard the built-in has.
  • request_preserve_values sees a recorded request’s query string. The pass split a path on ?, which a served request holds as one string and a recorded one does not, so audit and rescrub never saw a recorded query. The preserve list is also unioned over the whole session now, rather than read per exchange.
  • A value the twin minted at a create is exempt at its own draw site. A create knows its id before the renderer runs, and that path reached no sink, so the outbound safety net could still rewrite the twin’s own invention. The v0.24.0 mint exemption now covers it. The exemption remains by provenance, never by shape.

wraith check reads your state schema again. Reported by a consumer within hours of v0.24.0 — with a diagnosis so precise we could go straight to the fix. Thank you.

  • A handler that writes state no longer passes live and fails under check. A Lua handler calling state.put on an entity type declared in the twin’s state/schema.json served 200 under wraith serve but answered 500 under both wraith check --in-memory and --wire — with an error message telling you to declare the type in the very file that already declared it. The replay harness loaded the [serve.lua] settings but never the twin’s on-disk state declarations; serve did. All four replay paths (check --in-memory, check --wire, the upstream comparison, and wraith diff) now read state/schema.json and state/fixtures/ exactly as serve does. Should I do anything? If wraith check started reporting failures on a state-writing handler twin after v0.24.0, re-run it on v0.24.1 — the twin was never the problem. Worth knowing: v0.23.1 had the same defect but scored the failing replay as a PASS; v0.24.0 made the check honest, which is what surfaced this.
  • Strict fidelity keeps a body consistent with itself. Two refinements to the outbound policy at strict fidelity: a field the model knows is spelled from one of its own siblings (like a full_name built from a name) is re-spelled after the scrub rewrites the sibling, and a value the recordings show to be the API’s own vocabulary on that route is served verbatim instead of tokenized — the same judgment the security audit already makes. And in every fidelity mode, a value the scrub tokenizes at one field no longer survives verbatim inside another field of the same response. Should I do anything? No — responses become more internally consistent, and nothing that was protected becomes less protected.

Strict mode gets a precise meaning, and twins answer writes like the API they were recorded from. This is the largest release yet: twenty-one rounds of conformance work in which the checker was made to assert far more than it used to, and every gap it exposed was fixed in the engine rather than excused. If you re-run wraith check after upgrading, expect findings it used to miss — your twin did not get worse; the check got more honest.

  • Strict mode now applies the twin’s outbound policy. “Strict serves only recorded exchanges” was always a statement about where the body comes from — but the bytes previously left exactly as recorded, so a legacy recording holding real PII served that PII verbatim in strict mode while the default synth mode tokenized it. Strict responses now pass through the same self-URL rewrite, header allowlist, and outbound scrub as synth responses. Every pass is value-injective — two different recorded values never become one served value — so a strict response stays comparable to its recording position by position. Should I do anything? If you diff recordings against strict responses yourself, put the recording through the same policy first; wraith check --target already does. If something depended on strict serving a raw recorded value the scrub now tokenizes, add that field to [pii] allowlist in the twin’s scrub.toml.
  • Responses that are exact replays now say so. A new X-Wraith-Replay header carries the twin’s exactness claim: series-moment;v=1 means the body is a verbatim recorded moment; session-bound-moment;v=1 means that moment with your own session’s values substituted at declared fields; either may add ;outbound-policy=applied when the declared outbound policy rewrote some bytes on the way out. No header — or an unrecognized value or parameter — means no claim is being made: treat the body as synthesized. The full grammar is on the twin response contract page.
  • Values the twin mints are no longer mangled by the outbound scrub. A fresh id or token the twin invented for this response has no recording behind it and nothing to leak, but the scrub treated it like any other value — measured before the change, a stock rule was rewriting the middle out of every minted UUID, so consumers saw ids that failed their own format checks. Minted values now pass untouched. The exemption is by provenance, never by shape: only a value the twin drew end to end qualifies, and “looks like a UUID” is not an exemption, so recorded values are scrubbed exactly as before.
  • Create endpoints on RPC-style APIs allocate fresh ids. A JSON-RPC create previously answered every request with the same recorded id, so two creates in one test run appeared to produce one entity. The twin now allocates a fresh per-session id above the highest id in the recordings.
  • An update with no id in its path answers with what it wrote. RPC-style updates that name their target in the body used to answer from the template while the write landed silently; the response now reflects the entity the request just changed.
  • A recorded refusal keeps refusing. A route whose recording answered an error keeps replaying that refusal even after your session writes related entities — a recorded “gone” or validation error is never silently overturned into a success by session state.
  • GraphQL mutations write to state. An entity created through a GraphQL mutation is now visible to later queries in the same session, and list queries compose recorded pages with your session’s writes instead of ignoring them.
  • Pagination follows the request. Cursor- and page-parameterized list requests serve successive windows of the collection; previously, once a session had writes, successive pages could come back identical.
  • scrub.toml gained a record-time [pii] allowlist. Fields you list are exempted from PII tokenization at record time, for twins whose name-keyed fields are genuinely structural vocabulary rather than people. Values inside an allowlisted position still go through the free-text pass, so a real secret hiding there is still caught.
  • The security audit asks the scrub itself. Two new doctor --security-audit classes report recorded values that the twin’s own outbound scrub would tokenize on serve but that the audit previously could not see — including identity handles and name fields. The audit calls the same decision functions the scrub uses, so what it reports and what the scrub does cannot drift apart. Should I do anything? Re-run wraith doctor --security-audit; new warnings there are pre-existing recorded values now visible, not new leaks.

Two silent state traps closed. Both were reported by a consumer agent rechecking their findings against 0.23.0, and both had the same shape: a call that looked like it worked and quietly did the wrong thing.

  • A state call with a missing argument now fails instead of guessing. state.query(entity_type, field) — the value argument left off — silently became a “field equals null” filter and returned an empty array, which is indistinguishable from an honest “no rows matched”. That empty result got read as a broken query engine twice, by two different people, and the engine was fine both times. state.put(entity_type, id) was worse: it returned true and stored a null record that state.count then counted. Both now raise and name the expected arguments. Should I do anything? If a handler passes the wrong number of arguments, it will now fail loudly where it previously returned nothing or wrote a null — that call was already not doing what it looked like it was doing. Passing nil explicitly as a query value is still a valid filter for a JSON null.
  • state.query matches nested fields and compares scalars across types. state.query(t, "nest.k", v) looked for a key spelled literally nest.k, found nothing, and returned an empty array; it now walks the path. A stored number 7 no longer fails to match the string "7", or the reverse — the same leniency the query-string filters on list routes have always used, so the two filter paths in a twin no longer answer the same question differently. A key that genuinely contains a dot still matches exactly, and arrays and objects still require an exact match.

Handlers stop failing silently. Another report from someone building twins by hand, and nearly every item had the same shape: a handler did something wrong, wraith answered 200, and nothing anywhere said so. A write that vanished reported success. A response body that got thrown away came back as {}. A “reset everything” endpoint answered 200 and reset nothing. None of these produced a log line, so the only way to find them was to notice much later that the data was wrong.

If you write Lua handlers, read this section. Three things that used to appear to succeed now fail the handler, and they fail deliberately — each one was discarding your data or your output quietly.

  • Writing to an entity type that isn’t in state/schema.json now fails the handler. It used to return true and drop the write, so every later read came back empty for no visible reason. Should I do anything? Declare your entity types in the twin’s state/schema.json. A twin recorded read-only starts with none, so this is usually the first thing a hand-written handler needs. The error names the missing type.
  • emit.json with a value that cannot be JSON now fails the handler. A single function, coroutine, or caught error anywhere in the table used to discard the entire response body and answer 200 {} — indistinguishable from a legitimately empty response, and nothing was logged. Should I do anything? Convert such values with tostring() before emitting them.
  • state.delete returns false for an id that was not there. It previously returned true no matter what. Should I do anything? Only if you branch on its return value.
  • Errors from state.* and wraith.* are now plain strings. They used to arrive as userdata, so type(err) was not "string" and "failed: " .. err raised a second error inside the code meant to report the first. pcall now behaves the way Lua code expects.
  • [serve.lua] max_memory_kb sets the per-invocation memory ceiling. It was fixed at 1024 KiB with no way to change it, and the failure said only “exceeded memory limit” — no ceiling, no usage, no knob to reach for. The message now names all three, including when a very tight ceiling is exhausted while the handler environment is still being built. Worth knowing: the ceiling covers everything the VM allocates, and intermediate Lua tables cost far more than the JSON they become, so a handler assembling a few thousand small records can exceed 1024 KiB while producing barely 100 KB of output. Zero is rejected rather than accepted as “unlimited”.
  • wraith.import caches within an invocation. Two imports of one library returned two different module tables and re-ran the source each time, which also spent the memory budget above. The same library now returns the same table, including a library that returns nothing at all. The cache never outlives the request, so module state cannot leak between requests or sessions.
  • emit.body preserves bytes. It is the supported path for non-JSON and binary responses, and the result is no longer forced through lossy UTF-8 replacement.
  • Handlers receive the request body exactly as sent. This retires the limitation noted in v0.22.0: a malformed or non-JSON body reached the handler as {}, so the usual “reject bad input with 400” guard could not fire. Plain text, XML, CSV, and binary bodies now arrive intact, and a request with no body is nil rather than {}. Should I do anything? If you worked around this by treating {} as “bad body”, revisit that check — and note that a served twin does not reject malformed JSON for you, so validate in the handler.
  • POST /__wraith/reset actually resets a twin. In the default synth mode it answered 200 and cleared nothing — a silent no-op. Everything that resets a twin between runs goes through it, including wraith up’s reset_on_start and conformance runs against a live twin, so state leaked across all of them. It now also restarts deterministic fault schedules and latency sequences, so a reset twin replays the same timing and failure pattern it did on the first run — previously those kept advancing even when the reset worked. Per-session reset was never affected and has always worked. Should I do anything? If you built a workaround — restarting the twin between test runs, or using per-session reset because the global one seemed broken — you can drop it.
  • Conformance replays handlers under the twin’s own Lua settings. wraith check, generate, refresh, and diff ignored [serve.lua], so a handler that falls back to the template under wraith serve turned into a 500 during a conformance run, and a handler given extra memory was still replayed against the default ceiling. Replay and serve now mean the same thing.
  • A per-route latency override with reversed bounds no longer crashes wraith serve. Writing a minimum above the maximum in --latency-config panicked the process; the bounds are now normalized.
  • wraith serve and wraith run accept every twin name the other commands do. A twin whose name starts with something registry-shaped, like ghcr.io/acme/api, could be created by wraith init and passed by wraith lint, then refused by serve as an unpinned image reference. A local twin now always wins over that reading. Genuine image references are still refused without a digest pin.
  • Conformance decodes gRPC request bodies before replaying them. The check handed raw wire frames to a twin that expected decoded fields, so echoed values and body-based routing saw an empty request during replay.

Handler authoring, and tools that stop crying wolf. Most of this release came from one user’s report after building a twin by hand. The theme: the tools were technically correct and practically useless — a handler that bound to the wrong route without saying so, a linter that failed every healthy twin, warnings that buried the output you asked for, and a bind error that named no culprit.

  • A singular handler no longer swallows the collection route. get_asset.lua bound to GET /v3/assets/:id and to GET /v3/assets, silently replacing the recorded list response with the handler’s output. The twin looked healthy — the reporter found it only by deleting the file and watching the list come back. A read request ending at a collection now binds the list-shaped names only. Genuine sub-resources like get_invoice.lua on GET /orders/:id/invoice are unaffected. Should I do anything? If you relied on a singular handler answering a collection, rename it to the plural form.
  • Every handler file → route binding is logged at startup. Previously you got a count and nothing else, so a wrong binding was invisible.
  • JSON decoding is built in. wraith.json_decode and wraith.json_encode replace vendoring a Lua JSON parser, which every handler that reads a request body previously had to do. One current limitation: handlers receive only bodies that parse as JSON, so a malformed body arrives as {} and the usual “reject bad input with 400” guard cannot fire yet. That is a known defect and is being fixed.
  • A twin can declare a route no recording covers. A handler binds to routes the twin already has, so a twin recorded read-only — the safe way to record someone else’s API — had no write routes to bind to, which is exactly when you want to author one. wraith route add <twin> --method PATCH --path /v3/assets/:id writes a lua/routes.toml sidecar that survives re-synth. Authored routes are marked as authored, never counted as recorded evidence, and never written into the model. If a recording later covers the same route, the recorded one wins.
  • wraith lint no longer fails every healthy twin. It demanded a digest that only wraith compose ever writes, so a freshly synthesized twin failed by construction — reported as 51 failures on a twin that served perfectly. A rule that fires on every healthy twin carries no signal and teaches you to ignore the real findings beside it. Should I do anything? Re-run wraith lint; any remaining finding is real.
  • Dead config keys warn once instead of on every load. A wraith.toml from an older version produced 72 warning lines on a single command, scrolling the output you wanted off the screen — including serve’s startup lines. They now collapse to one line, visible to --format json consumers for the first time, with the full list behind --explain. Should I do anything? Run wraith migrate <twin> to remove the dead keys; it preserves every live key, comment, and your formatting.
  • A bind failure names the process holding the port. “Address already in use” now tells you which process holds it, whether it is another wraith, which twin it serves, and how to stop it. A bare wraith serve also registers itself, so wraith status and wraith down can see twins that were not started by wraith up — the case that let four abandoned servers hold ports for days while every command reported nothing running.
  • wraith record warns when a session recorded nothing but failures. A twin recorded against an unroutable endpoint produced 650 exchanges, every one an error, and passed conformance for four months because reproducing an error faithfully is easy.
  • wraith diff compares against the twin it claims to diagnose. It previously replayed through a path that bypassed the model, Lua handlers, and state entirely.
  • wraith check says when the model it scored no longer matches your recordings.
  • Nested fields inside list elements are checked. The element check stopped at the first level, so anything deeper produced no finding at all — not a warning, nothing. It now descends two levels, which surfaced a real missing field that had been invisible.
  • 32-character hex ids parameterize again, so routes that mint them stop fragmenting into one route per id.

Twins stop giving you empty answers and merged routes. This release is mostly about a twin telling you the truth about what it recorded. Several long-standing defects had the same shape: the twin held a perfectly good recording and served something emptier or vaguer instead — an empty list, a merged route, a 404 on an endpoint that exists. Conformance scored all of it as passing, because it wasn’t asking.

  • Endpoints with verbs in the path no longer collapse into one route. A path segment carrying a hyphen, digit, or capital was treated as an identifier, so sibling endpoints like /assets/actions/search-v1, /vector-search and /get-association-graph merged into a single route — and the twin then answered 404 {"error":"actions/search-v1 not found"}, reading the action name as an entity id. A segment is now only treated as an identifier when the recordings show the value actually flowing through the API as data. Thanks to the user who reported this with a complete reproduction bundle.
  • API version prefixes are never treated as identifiers. /v1/ used to survive only because it is two characters long, which is why /v10/ did not: any API that reached version 10 could silently start merging unrelated routes. Version prefixes are now recognized as such.
  • gRPC services keep one route per RPC. A service whose method names looked alike could collapse into a single route whose response was a blend of every RPC’s shape.
  • Names you invent are still parameterized. Filenames and create-time slugs — values the API never hands back before you use them — are recognized by other evidence, so those routes still generalize.
  • A URL with a recording no longer answers []. Two separate faults: recordings were filed against a route the router never reaches, and a collection that shrank while being recorded was flattened to its emptiest moment. Both are fixed, and a collection that changed over time now replays in order.
  • gRPC search and query calls return their results. These are HTTP POSTs that are semantically reads, so they missed the recorded-response index entirely and served empty result arrays.
  • One create no longer discards a list route’s recorded collection. Creating a single entity could replace a 33-item recorded list with a 1-item one — or with [] if your query filtered the new entity out. Your session’s own writes are still merged into the recorded collection.
  • Nested collection routes are treated as lists, not single-entity reads. /repos/:owner/:repo/issues, /orgs/:org/repos and 19 other routes across the reference corpus were misclassified.
  • Echoed request fields that are sometimes absent no longer render as null.
  • List responses are now checked. A result-set array was skipped entirely, so a twin could answer {"messages": []} for a route whose every recording carried 20 messages and still score 100%. The array must now still be an array, must be non-empty when every recording was populated, and its elements must carry the recorded keys and types.
  • wraith record --mode forward|mitm works. The forward proxy with TLS interception shipped in v0.3.0 but the CLI rejected it as “not yet implemented” — which was never true, and was hiding a startup crash that is now fixed.
  • [pii] default_action is enforced, all three values. It had been parsed and ignored since v0.6.0 while the docs said it shipped; every capture tokenized regardless of the setting. tokenize is unchanged; reject and the other value now do what they say.
  • 25 config keys that did nothing have been removed. Each was documented and read by no code — setting one changed nothing, silently. Three whole sections are gone: [generate.route_normalization], [generate.type_inference] and [refresh].

Should I do anything? Re-run wraith synth on your twins to pick up the routing fixes — several twins will end up with different (usually fewer, more general) routes, and twins with verb-in-path or v10+ endpoints will change the most. Existing configs still load: removed keys now produce a warning per key telling you what to use instead, so you can clean them up at your own pace. If you set [pii] default_action to anything other than tokenize, check that the now-enforced behavior is what you intended before your next capture.

Worker jobs now complete correctly against the hosted index. This patch fixes the result-summary compatibility break in v0.20.0, removes a duplicate completion request, and hardens the boundary so client and server wire shapes cannot drift unnoticed again.

  • Scan, drift-check, and rebase-check results include their lease identity. Signed summaries now carry the job ID, fence, and generation required by the index. Fresh jobs of all three kinds complete on their first attempt.
  • Workers complete each job once. Result ingestion already completes a job atomically; the worker no longer follows it with a second completion call that could report a stale-state error after successful work.
  • Client/server compatibility is now checked byte-for-byte. Release checks cover the full signed payload and the exact canonical bytes used for signature verification across all three result types.
  • Security hardening from the July audit is included. API-key headers and form-encoded PII are scrubbed more thoroughly; inline LLM credentials cannot be packed; upstream redirects and OCI token realms have stronger SSRF controls; LLM repair output is constrained; control tokens can rotate and expire; and live Twilio phone output is masked.

Should I do anything? Upgrade any v0.20.0 worker to v0.20.1 before relying on scan, drift-check, or rebase-check jobs. No config migration is required. If you do not run a worker, upgrade for the security fixes.

Twins now keep themselves honest: wraith worker run turns any machine with registry access into a drift-checking, rebase-validating worker for a wraith-index control plane. The worker claims jobs from an index, executes them inside your boundary — next to the registry, where the credentials live — and pushes back signed result summaries. The index coordinates everything while holding zero registry credentials and never receiving payload bytes. Everything here is opt-in: without a worker.toml, nothing changes.

  • wraith worker run polls a wraith-index for jobs, executes them, signs the results with your Ed25519 key, and pushes them back before marking each job complete. Leases are fenced and heartbeated — every job in a claimed batch is kept alive from the moment it’s claimed, so one slow job can’t cost the rest of the batch their leases. --once runs a single claim cycle for cron or CI.
  • Three job kinds. Scan a registry source and report which twins and tags exist; drift-check a pinned twin by replaying its recordings against the live upstream API and scoring conformance; rebase-check an overlay against a newly published base to catch silent forks.
  • Drift verdicts use the real conformance bar. A drift-check passes or fails on the same exchange-weighted score wraith check uses — a handful of cosmetic field drifts won’t fail a twin that’s still faithful where it matters, and raw divergence counts still come along for trending.
  • wraith worker policy publish and show. An operator signs a policy document saying what workers may do — which job kinds, which repos, which services — and publishes it to the registry. Workers verify it against a pinned operator key at startup and before every claim cycle.
  • Fail-closed by design. If the policy is missing, forged, expired, or rolled back to an older version, a policy-configured worker refuses to claim anything and says so loudly. The signed policy can only narrow what a worker’s local configuration allows, never widen it.
  • Credential boundaries fail closed. A job that names a credential source the worker doesn’t have — or one bound to a different registry — is refused outright instead of silently falling back to ambient credentials. Job inputs must be digest-pinned; mutable tag references are rejected.
  • A round of post-release bug-hunting passes tightened the worker path throughout: strict trust-state loading, stricter policy parsing (a misspelled grant is an error, not silently ignored), and more.
  • New worker operations guide (docs/worker-operations.md in the repo): a worker.toml walkthrough and a calibration recipe — how to run a self-replay drift check that passes by construction, so you can validate a new worker deployment before pointing it at a real upstream.

Should I do anything? Only if you want the worker: mint a worker token on your wraith-index, write a worker.toml, and run wraith worker run. If you don’t run an index, nothing in this release affects you — upgrade freely.

Twins can now be published, discovered, and cryptographically verified across a team — not just recorded and served locally. The largest release since the runtime itself: a full path for pushing a twin to any OCI-compatible registry (GHCR, Google Artifact Registry, Zot, and others), pinning and pulling it like any other dependency, and proving where it came from and who signed off on it. All of it is opt-in — a twin that only ever lives on your laptop keeps working exactly as before.

  • wraith publish packs a twin reproducibly (byte-identical output for identical inputs), pushes it to your configured registry, and moves the channel tag (e.g. stable) to the new digest — plus an immutable <service>.<date>.<digest> build tag so older builds stay addressable. wraith push is the lower-level pack-to-registry primitive if you want to drive tagging policy yourself.
  • wraith pull and wraith install fetch a digest-pinned twin or overlay artifact from a registry into a local cache and extract it into a workspace, the same shape as installing a local .wraith archive — plus a re-scrub pass over the extracted model as defense in depth.
  • wraith serve and wraith run now accept OCI references directly. Point either one at registry/repo@sha256:... instead of a local twin directory and it pulls, caches, and serves it; re-running against the same digest reuses the already-materialized workspace instead of re-extracting.
  • Dependency pinning: wraith resolve, wraith pin, wraith deps, and a new wraith.lock file. Resolve a mutable channel tag to an immutable digest, pin it into the lock file, and use wraith deps update / wraith deps verify to move pins forward or gate CI on “everything’s pinned, every digest exists, the local cache matches.” wraith up --ci resolves OCI-referenced twins through the lock file and refuses to start on an unpinned, tag-only reference.
  • wraith oci init is the guided setup for all of the above: it probes your registry’s capabilities, writes a safe [oci] section into wraith-project.toml, and flags anything the registry doesn’t support before you try to publish against it.
  • wraith key gen generates the Ed25519 signing keypair the rest of this workflow runs on.
  • wraith attach adds trust evidence to a published twin as OCI referrers: a signed provenance attestation, a conformance report, a scrub-audit summary, or a publication event — so a consumer can inspect what’s actually known about an artifact instead of taking the registry’s word for it.
  • wraith verify checks a local .wraith package or a digest-pinned OCI reference: signature, digest integrity, an attached attestation, a cached pack, and — with --service plus a pinned root key — a provider’s full signed trust chain (authorization policy, service pointer, checkpoint). PII findings inside a package can be escalated from a warning to a hard failure with --strict.
  • wraith oci verify-pointer fetches and verifies a provider’s signed service pointer against a root key you’ve pinned out-of-band, so you always know which digest a channel currently blesses and can catch a rolled-back or compromised pointer.
  • Optional cosign interoperability lets wraith verify check signatures produced by cosign alongside wraith’s own, if that’s already part of your supply chain.

Discover twins without standing up a service

Section titled “Discover twins without standing up a service”
  • wraith search and wraith stale query a signed catalog artifact stored right in the registry — no separate index service required. Search by service, repo, or owner, or by route (wraith search route 'GET /v3/assets'); filter by trust rung, freshness, channel, or team. wraith catalog update (cron-friendly) rebuilds that signed catalog from a repo scan.
  • wraith lineage and wraith dependents walk the ancestry/fan-out graph between a base twin and the overlays built on it — “what did this come from” and “what depends on this,” both digest-pinned.
  • wraith can-i-publish checks whether a candidate build is clear to publish under your gating policy (blocking or advisory) against a hosted index when one is configured, falling back gracefully to the offline signed catalog when it isn’t.

A signed audit trail for consumer intent contracts

Section titled “A signed audit trail for consumer intent contracts”
  • wraith contract propose now emits a signed proposal event, and the provider-side lifecycle verbs (accept, reject, promote, demote, quarantine, retire, set-status) each emit a provider-signed event of their own. The full propose → accept/reject → promote/retire history of a consumer contract is now a verifiable, append-only chain attached to the twin, not just local state on someone’s disk.
  • Pre-provision an isolated agent session. POST /__wraith/session sets up a session — fixture set, seed, clock — before your test traffic starts, instead of only provisioning lazily on first request. The readiness JSON reports whether a running twin supports this; if not, you get a clear 501 instead of a silently ignored request.
  • List, reset, and tear down sessions individually. GET /__wraith/session, POST /__wraith/session/{id}/reset, and DELETE /__wraith/session/{id} manage one agent’s session namespace without restarting the twin or disturbing anyone else’s. /__wraith/reset is now explicitly documented as resetting every session at once.
  • Deterministic clocks for serve. wraith serve --clock, --clock-epoch, and --seed control the clock that synthesized and exact-recorded Date headers use in deterministic or fixed modes.
  • Debug session-state dumpsGET /__wraith/session/{id}/state — expose an existing session’s state for inspection without creating it if it’s missing.
  • Trace reads scoped to a session. GET /__wraith/trace/log?session=<id>&last=<n> filters by session before applying the tail limit, so parallel agent harnesses each see only their own recent activity.
  • wraith up forwards session settings to every child. Fixture, clock, clock-epoch, and control-token settings in your project manifest now reach each wraith serve child it starts, and [startup] reset_on_start = true resets every twin to a known baseline right after it comes up.
  • Freshness banners are more informative, reporting drift findings even when some age data is unavailable.
  • See the agent sandboxing guide for the full per-agent session pattern.
  • The authored-output gate covers more response paths — Lua-handler and fixture-sourced responses are now checked in both in-memory and wire-mode conformance runs. Declared deviations only exempt scoring when they actually match, and nested-array evidence is reported more precisely.
  • More accurate provenance accounting in check reports: authored-only routes are called out separately, rendered response leaves are counted after array expansion, cached provenance survives idempotency replay, and generated list-envelope metadata is marked computed instead of recorded.
  • wraith doctor now reports a provenance/deviation summary — an advisory block built from your latest check report, declared deviations, and unbound handler stems. Informational only; it doesn’t change doctor’s exit code.
  • Fixture and Lua vocabulary linting. wraith lint flags fixture fields that differ from the synthesized model only in case (error), unknown fixture fields (warning), and near-miss casing in Lua handler string literals (warning).
  • Credential-shaped response values are tokenized more consistently — including nested and list responses, and JWT-shaped strings — across both capture-time and outbound scrubbing. Short, obviously-structural fixture values are left alone.
  • JSON-aware PII scanning. wraith pack and wraith doctor now inspect JSON structure, not just raw text, for credential keys, JWTs, vendor token prefixes, and avatar-hash identifiers, with per-variant evidence. Accept a specific finding with a reasoned [[pii.allow]] rule.
  • Scrub-report wording is more honest. wraith inspect <archive>.wraith --scrub-report reports signature_status as ok / invalid / unverifiable / unsigned instead of a boolean that overclaimed “signed.”
  • Set-Cookie scrubbing hardened for legacy comma-folded values — every cookie value is scrubbed while Expires commas are preserved — and generated scrub.toml files no longer duplicate built-in rules.
  • wraith explore --record cleanup. Recording sessions now validate headers up front, remove empty zero-exchange sessions instead of finalizing them as real recordings, warn on lossy non-UTF-8 header capture, and share tag parsing with wraith record.

No migration needed — everything above is additive and opt-in, and a twin that never touches a registry behaves exactly as before. To try publishing: wraith oci init against your registry, wraith key gen, then wraith publish <twin>. To consume a twin someone else publishes: wraith search, or wraith pull / wraith verify against the digest they hand you.

The twin no longer contradicts its own recorded not-found evidence. Closes the last residual from the external re-validation: fixes only, one small additive header-contract change.

  • Fail-closed honors recorded 404s. In --unknown-entity not_found mode, an id whose only recorded outcome was a 4xx used to count as “known” and get a synthesized 200 — a fabricated success exactly where the twin held direct not-found evidence. Those ids now live in a known-missing index built at synth time, and the gate serves the provider’s own 404 shape for them. Cross-route aware (an id with a 2xx anywhere is never flagged missing); exact recorded URLs still replay the verbatim recorded 404 first. Default mode unchanged. Re-synth required.
  • New X-Wraith-Provenance: miss value. Policy-produced not-founds — fail-closed entity misses and route-level 501s — now carry miss instead of template, so a harness can tell apart: recorded (the provider really said 404), miss (the twin has no coverage and told you so), and template (a synthesized answer). Contract note: if your harness matches on template to detect fail-closed misses, update it to miss. The Twin Response Contract page is updated.
  • Release gate extended. The read-only fixture now permanently asserts the known-missing case, including the query-varied request that previously fabricated a 200.

Re-run wraith synth (the known-missing index is built at synth time), and switch any template-based fail-closed-miss detection to miss.

Fail-closed now works on the twins that need it most. An external consumer re-validated 0.18.1 against a real recorded, read-only provider corpus and found both marquee features silently inert on that shape. Both are fixed and that corpus profile is now a permanent release gate. Fixes only, no new surfaces.

  • Fail-closed engages on read-only corpora. --unknown-entity not_found used to depend on CRUD evidence a read-only recorded corpus never has, so it silently did nothing on exactly the flagship shape. It now keys on path-parameterized routes plus the recorded known-id index, independent of CRUD classification. Re-run wraith synth for it to take effect.
  • No more silent no-op on a safety flag. If you request not_found and no route can honor it (e.g. a pre-0.18 model), wraith serve warns loudly at startup — stderr, --ready-json advice, and /__wraith/info — instead of quietly serving synthesized 200s.
  • Recorded 404s with empty bodies replay. Empty and non-JSON recorded responses were dropped from the exact-match replay index, so a recorded not-found could come back as a fabricated 200. They now replay verbatim in every mode. Re-synth required.
  • [REDACTED] never reaches consumers. Placeholders — baked into recordings at record time or introduced at serve time by the outbound scrub — are substituted with deterministic, shape-plausible values on every synthesized response path. Strict fidelity stays byte-faithful.
  • A read-only recorded corpus is now a release gate. The exact profile that found these bugs (recorded, read-only, scrubbed, zero CRUD evidence) is fresh-synthed, served, and asserted on every build.

Re-run wraith synth on your twins — the fail-closed and empty-body-404 fixes both live in the re-synthesized model indexes. If you serve agent-facing twins, set --unknown-entity not_found and watch the startup output: any warning means your model needs the re-synth.

The fail-closed guarantee now holds on nested routes. A patch release hardening what v0.18.0 shipped: fixes only, no new surfaces.

  • Fail-closed holds on nested routes. The known-id gate now keys on the full path-parameter tuple, not just the last segment — /orgs/:org/repos/:repo no longer treats a never-seen repo under a known org as known. This closes the multi-param limitation called out in the v0.18.0 notes. Re-run wraith synth for it to take effect (the tuple index is built at synth time).
  • Parameterized-list misses fail closed too. Entity misses on parameterized list routes now honor --unknown-entity not_found instead of falling through to a synthesized 200.
  • Outbound scrub preserves echoes. Query-parameter, form-field, and text-response echoes round-trip verbatim instead of coming back tokenized, matching the existing JSON-body echo behavior.
  • X-Wraith-Route under strict fidelity now reports the concrete METHOD /path of the matched recording (synth mode keeps the :param template form).
  • DELETE provenance accuracy. DELETE responses no longer report a phantom field in the per-field origin map, and deleting a fixture-seeded entity reports provenance fixture.
  • Freshness session-id parsing hardened.
  • New reference page: the Twin Response Contract — the authoritative hits-and-misses table, every X-Wraith-* header, and fidelity-mode interactions.

Re-run wraith synth on twins you serve with --unknown-entity not_found — the stronger tuple-keyed index only exists in models synthesized on 0.18.1+. Everything else applies immediately.

A twin can no longer invent an entity without telling you — and you can make it refuse outright. Until now, a twin under default fidelity would answer a request for an entity that never existed with a confident, fully-shaped 200, indistinguishable from recorded truth. A human notices eventually; an agent builds on the fabrication and ships. This release stamps every response with where it came from, and adds an opt-in mode where unknown entities fail closed instead of being synthesized. Additive — existing twins behave identically unless you opt in.

  • Every response carries a provenance header. X-Wraith-Provenance: recorded | template | handler | fixture | fault tells you — or your agent harness — whether the body was replayed from a captured exchange, synthesized from the model beyond any recording, produced by a Lua handler, seeded from fixtures, or injected as a fault. Two companions: X-Wraith-Route names the matched route template, and X-Wraith-Exchange: <session>/<index> points back at the exact recorded exchange when provenance is recorded. On by default; opt out with --no-provenance-headers or [serve] provenance_headers = false. (The per-field summary header from v0.17.0’s --debug mode is renamed X-Wraith-Provenance-Counts.)
  • Opt-in fail-closed entity lookups. With wraith serve --unknown-entity not_found (or [serve] unknown_entity = "not_found"), a path-parameterized read of an id the twin has never seen — not in its recordings, its fixtures, or state created this session — returns the provider’s own recorded 404 shape when one was captured, else a structured 501. No more fabricated 200s. Recommended for agent-facing sandboxes; the default stays synthesize, so human dev loops are unchanged.
  • Route misses are machine-actionable. A request that matches no route still gets a 501, but the body now says why: the twin’s name, its route templates (up to 20, with a total count), and a hint pointing at wraith coverage. An agent harness can report the coverage gap without shelling into the workspace.
  • [REDACTED] no longer leaks into responses. Fields destroyed by record-time redaction used to serve the literal placeholder. They now render deterministic, shape-plausible values (a UUID for id-shaped fields, hex tokens otherwise); tokenized wraith_tok_* values are untouched.
  • The response contract is documented. See the new Twin Response Contract reference page — the full hits-and-misses table, route hit/miss, entity hit/miss per mode, every X-Wraith-* header, and how fidelity modes interact with all of it. wraith serve --help points to it under RESPONSE CONTRACT.

Re-run wraith synth on existing twins when you want the new features at full fidelity — models synthesized before 0.18.0 lack the known-id index (fail-closed mode falls back to coarser misses) and the exchange-id sources behind X-Wraith-Exchange. Everything else works immediately. If a client middleware chokes on unexpected response headers, --no-provenance-headers restores the old wire shape. One known limitation: on routes with multiple path parameters, the known-id index keys on the last segment only.

Twins can no longer lie to you quietly. Hand-written handler output is now checked against recorded evidence, every served field can tell you whether it came from a recording or from someone’s keyboard, and a stale twin can be made to fail CI. Plus: record a twin from synthetic traffic alone, an auditable scrub report inside every pack, and session cookies scrubbed by default. Additive — existing twins behave identically unless you opt in, with one deliberate exception below.

  • Lua/fixture output is now conformance-gated. wraith check compares what your Lua handlers actually serve against the shape of the recorded responses. A structural slip — a mis-cased field name, a missing key, a wrong type — used to ship silently at a perfect score; now it fails check (exit 2) with a named authored_deviation finding telling you the exact path. When a deviation is intentional (your handler deliberately serves an empty collection, say), declare it in wraith.toml and it passes:

    [[deviations]]
    route = "GET /assets/:id"
    path = "$.comparisonSegments"
    reason = "segments unused in this workflow"

    Not ready to fail builds on this? Set [handlers] deviation_policy = "warn" to report without failing. See the Lua handlers guide for details.

  • Every served field has provenance. Fields are classified recorded | synthesized | echoed | authored | fixture. wraith check reports per-route counts and an overall fiction ratio — how much of your twin is authored fiction vs recorded evidence — so a reviewer can judge how much to trust a twin at a glance. wraith serve --debug adds an X-Wraith-Provenance summary header; with --trace --debug the trace endpoints carry full per-field origin maps.

  • Staleness is loud, and can be a gate. wraith serve now tells you how old a twin is — a startup banner, X-Wraith-Twin-Age / X-Wraith-Recorded-At headers on every response, and the same fields in --ready-json and /__wraith/info. To enforce freshness, add an SLA to wraith.toml:

    [freshness]
    max_age = "30d" # check fails (exit 2) past this
    warn_age = "14d" # advisory warning past this

    wraith check fails with twin-stale advice when violated and wraith doctor reports the same verdict — point your nightly CI at it and a rotting twin can’t pass silently. See Conformance & drift.

  • Twin a service from synthetic traffic: wraith explore --record. Scenario runs generated from an OpenAPI spec are now captured as normal scrubbed recording sessions (tagged synthetic), ready for wraith synth. For services where you can’t record real traffic for data-safety reasons, this sidesteps the problem entirely — no real data ever touches the twin. See OpenAPI seed mode.

  • Session cookies are scrubbed by default. set-cookie header values (session tokens, CSRF tokens) — previously the most common way a live credential survived into a recording — are now tokenized on write, with cookie names and attributes preserved. Conformance is unaffected.

  • Packs carry an auditable scrub report. wraith pack embeds a human-readable report of what was scrubbed (per-rule match counts, affected fields and routes, the PII-scan verdict, the scrub-policy hash), covered by the pack’s signature. Render it with wraith inspect <pack> --scrub-report — a security reviewer can now assess a twin’s data-safety without unpacking it.

  • Truer state replay. Create requests keep a client-supplied primary key instead of always generating one; PATCH deep-merges nested objects (arrays replace, null deletes) instead of replacing them wholesale; values you POST are no longer tokenized when the response echoes them back; list routes apply equality filters from query params (?status=active now actually filters); and bare-array list routes serve created state.

  • verify-pack no longer calls a signed archive “unsigned”. A signed pack checked without a trust key now reports signature=unverifiable with a hint (--trust-store / WRAITH_VERIFY_KEY), distinct from a genuinely unsigned pack.

  • Small paper cuts. wraith contract verify / rebase-check accept --policy as an alias for --overlay-policy (matching wraith compose --policy), and wraith init --base’s next-steps hint no longer recommends a flag that doesn’t exist.

If you use Lua handlers or fixtures: run wraith check after upgrading. A previously silent shape mismatch in authored output will now fail — that’s the feature. Fix the handler, or declare the deviation with a [[deviations]] entry, or set [handlers] deviation_policy = "warn" while you triage. Everything else is opt-in or automatic.

You can now sign and verify an intent contract locally, end-to-end. v0.15.0 made authoring a .wic great; this release makes verifying one work without guesswork — generate a signing key, sign your twin packs, hand verify a trust policy, and read the real reason when a check fails. Additive — existing twins, packs, and contracts are unaffected.

  • Make a signing keypair: wraith key gen. Prints a base64 secret (to sign with) and the matching public key (for a provider’s trust store); --seed gives a reproducible key for tests.
  • Sign twin packs with a flag: wraith pack --key <KEY|PATH>. Twin (.wraith) packs now sign the same way contract packs do — previously this was only possible via an undocumented WRAITH_SIGN_KEY env var (now documented too). Signing never changes the content digest, so it can’t invalidate a pin.
  • Hand verify your trust policy: wraith contract verify --overlay-policy <file>. An overlay that ships its own scrub policy used to be unverifiable locally — verify always ran the built-in default. Now you supply a policy that allowlists it (or drop it at <base>/.wraith/overlay-policy.toml and verify finds it). An overlay that inherits the base’s scrub policy unchanged is allowlisted automatically.
  • Real failure reasons. verify and rebase-check now tell you why composition failed — a stale base pin (base-digest-mismatch, with both digests), a missing allowlist entry, an unsigned overlay — instead of a generic error.
  • Editor validation for the policy file. wraith schema now emits overlay-policy.schema.json for the .wraith/overlay-policy.toml format.
  • Friendlier compose & init. wraith compose --base/--overlay accept a bare twin name like other commands, and a freshly init --base’d overlay composes immediately (as a no-op) before you’ve recorded anything.
  • Reproducible pins. wraith pack --help now explains that stable [base].digest pins need a persistent WRAITH_HMAC_KEY — without one, every re-pack changes the digest.

Nothing required. To verify a contract locally the loop is now: wraith key gen → sign your base and overlay packs (wraith pack --key, with a persistent WRAITH_HMAC_KEY) → allowlist the overlay’s scrub policy → wraith contract verify --overlay-policy. See the Intent contracts guide for the full sign → allowlist → verify walkthrough.

You can now generate an intent contract straight from a twin’s recordings — no hand-writing Lua — and wraith serve binds both IPv4 and IPv6 loopback by default. The contract building blocks shipped earlier; this release turns them into commands, so going from recorded traffic to a packable .wic is a single step. Everything is additive — existing twins, packs, and contracts are unaffected.

  • Generate a contract from recordings: wraith contract propose <twin>. Point it at a twin you’ve recorded and it writes a ready-to-pack contract — one scenario per distinct workflow it sees across your sessions, with the request flow and inferred value round-trips (e.g. an id created in one call and reused in the next) turned into checks. Those inferred checks are advisory (sigil.check): they record pass/fail but never fail a run, so you review and promote the ones you actually want to enforce to expect() before they gate anything. Narrow the evidence with --tag or --from-session, and the output is byte-identical run to run. Then pack it as usual:

    Terminal window
    wraith contract propose billing --out ./staged \
    --consumer checkout-service --provider billing-api --owner checkout-team \
    --base billing-api@sha256:… --overlay checkout-billing@sha256:…
    wraith contract pack ./staged --output checkout.wic --key ./signing.key
  • Start a contract by hand: wraith contract scaffold <dir>. Writes a ready-to-edit skeleton (manifest, the pinned helper, one placeholder scenario) for when you’d rather author scenarios yourself than generate them.

  • Manage the helper: wraith contract helper. Emit or verify the canonical lib/wraith.lua helper that every contract embeds.

  • Contract manifest schema published. wraith schema now also emits the contract package (.wic) manifest schema.

  • Truer generated contracts. Scenarios drafted from recordings now keep request query parameters and explicit null body fields instead of dropping them, and wraith contract inspect now reports the correct live/candidate check breakdown for a packed contract (it previously listed every check as unparsed).

  • wraith serve is dual-stack by default. It now binds both 127.0.0.1 and ::1, so localhost reaches the twin on either address family, and --listen accepts IPv6 literals and host:port (e.g. [::1]:8181). Binding a non-loopback address such as 0.0.0.0 now requires --control-token-env — without it wraith serve refuses with a security-violation exit — and serve warns when another process already holds the port on the other address family.

Nothing required. If you write intent contracts, try wraith contract propose <twin> to draft one from your recordings, then review and promote its advisory checks before packing. If you bind wraith serve to a non-loopback address, you’ll now need to pass --control-token-env.

Editor autocomplete for wraith.toml and scrub.toml, reliability fixes across conformance and serving, and an experimental opt-in way to model collections whose element shape depends on a sibling field. New twins get config autocomplete for free; existing twins re-synth and re-serve unchanged.

  • Config autocomplete and validation. wraith.toml and scrub.toml now have published JSON Schemas, and wraith init writes a #:schema line at the top of each new config file. Editors with TOML support (anything backed by Taplo — VS Code’s Even Better TOML, Zed, Neovim, …) pick it up automatically for key completion, inline docs, and error highlighting. For an existing config, copy the #:schema header from a freshly wraith init’d file.
  • Fewer false conformance failures and cleaner replays. HEAD requests no longer return a body; state-backed reads keep their dynamic response headers; reading an object from unseeded state returns the recorded success response instead of a spurious 404; and structural IDs in path segments are turned into route parameters more reliably. Credit-card scrubbing no longer mangles UUIDs, and Lua-handler output is passed through untouched.
  • Clearer Lua handler binding. Handler files named with hyphens or camelCase now match their routes (normalized to snake_case), wraith init drops in a naming-convention README, and wraith serve warns when a handler you loaded binds to zero routes — so a misnamed file no longer fails silently.
  • (Experimental) dependent-case modeling. For collections where one field’s shape depends on a sibling discriminator — e.g. a Stripe event’s data shape depends on its type, or a GitHub event’s payload on its type — Wraith can now model each case separately. It is off by default and only applies to the route + field pairs you explicitly list under [generate.dependent_cases] in wraith.toml, so nothing changes unless you opt in.

Nothing required. Re-run wraith init (or copy the #:schema header) to get config autocomplete in your editor, and re-run wraith synth / wraith generate to pick up the conformance fixes. The dependent-case feature is opt-in only.

Wraith now models APIs that return mixed collections of different object types — like Stripe’s event stream — instead of flattening them into one lossy shape. When an array or nested field holds different resource types selected by a discriminator (e.g. each Stripe event’s data.object is a customer, a price, a product, …), Wraith learns a separate schema for each case. Type-specific fields that used to vanish because they were rare across the whole collection are preserved, and conformance compares each element against its own type. On the Stripe events endpoint this removes about 555 false “missing field” failures. The feature turns on automatically only where it applies — homogeneous lists and existing twins are unchanged.

  • Heterogeneous collections are modeled per type. Wraith detects when a collection’s elements (or a nested object) vary by a discriminator field (object, type, kind, __typename, …) and synthesizes a schema for each observed type. A field that only appears on one type — say unit_amount on a price — is no longer dropped just because it’s rare across the whole list.

  • More accurate conformance on mixed lists. wraith check matches each element to its type before comparing, so a reordered or differently-sampled collection no longer produces spurious missing/extra-field noise. Clearer divergences now call out an unknown or missing type tag instead of a wall of field mismatches.

  • Control over types you haven’t recorded. Real APIs keep adding new event and resource types. By default Wraith flags an unrecorded type as a divergence (fail-closed), and wraith check tells you which type it saw. You can relax this per route in wraith.toml:

    [[diff.tagged_union_policy]]
    route = "GET /v1/events"
    policy = "fallback_common_schema" # or "replay_representative"

Re-run wraith synth <twin> (and wraith generate <twin>) to pick up per-type modeling for any heterogeneous collections in your API. Nothing else changes — recordings, models, and wraith.toml are all compatible, and homogeneous APIs are unaffected.

wraith generate is now free and deterministic by default — no LLM, no API keys, no tokens — and twins reproduce far more of an API’s real responses out of the box. Running wraith generate <twin> now repairs your twin using only the recordings it already has; the optional LLM pass is opt-in behind --llm. Across Wraith’s 23-API test corpus, every twin now passes conformance from wraith synth + wraith generate alone, with no LLM in the loop. No config or wire-format changes — existing twins re-synth and re-serve unchanged.

  • wraith generate no longer needs an LLM provider. By default it runs only the deterministic repair pass — instant, free, and reproducible, with nothing to set up. If you relied on the model-fixing LLM pass, add --llm (and configure a provider) to turn it back on; when divergences remain after the free pass, the output tells you to re-run with --llm. Old flags still work: --agentic now implies --llm, and --cegis-only is simply the new default.
  • Twins reproduce derived URLs and IDs correctly. Fields built from other values — a resource URL that splices in a name from your request, or one that embeds the response’s own generated ID (e.g. a Notion page URL like …/Title-Slug-<id>) — are now reproduced as live derived values instead of a frozen recorded string. These used to show up as drift on every replay; now they track the request and the generated ID, including for URLs inside list elements.
  • Fewer false conformance failures. wraith check now recognizes values a twin legitimately can’t reproduce byte-for-byte and compares them by shape: server-side hash tokens inside URLs (avatar hashes, signed-URL tokens, content hashes) and timestamps (any two valid RFC-3339 datetimes). It also preserves server-added fields when you read back an object you created with a sparser request, and compares list/query result arrays without tripping on length.
  • Smaller models for path-heavy APIs. Synthesis now folds slug and name path segments (like /repos/:owner/:repo/…) into route parameters instead of creating one route per value, so models for large APIs are dramatically smaller.

Re-run wraith generate <twin> to pick up the new deterministic repairs — it’s free now. If your workflow set --agentic or expected the LLM pass by default, switch to --llm to keep it. Nothing else changes: recordings, models, and wraith.toml are all compatible.

Patch release. wraith up now stops cleanly on Ctrl+C everywhere. On macOS and BSD, pressing Ctrl+C on wraith up left the twins running and the foreground hung — the shutdown path keyed off a Linux-only liveness check, so it never signalled the child twins. Shutdown is now portable: a single SIGINT stops every twin and up exits in about a tenth of a second with no orphaned processes. Linux behavior is unchanged (and a rare hang on a recycled PID is gone too). No config or wire-format changes.

Live request logs in wraith up. Running wraith up used to print a status table and then sit silent while it streamed every twin’s output to a log file — you had no idea what was hitting your twins until you went digging. Now the foreground shows a live, color-coded request log, docker compose up-style, with one line per request. Purely additive — no wire formats change, and the log is default-on but easy to silence.

  • See every request as it happens. wraith serve emits one access line per request — method, path, status, duration, and the matched route template — across synth, strict, and fuzzy modes. On a terminal you get a compact human line like 200 GET /v1/customers/cus_123 4ms (route: /v1/customers/:id); piped into CI you get one JSON object per line. Control it with --access-log compact|json|off (default-on, independent of --trace; auto-picks compact for a TTY and JSON for a pipe).
  • Watch all your twins at once. wraith up interleaves each twin’s requests into one stream with an aligned, color-coded <twin> | … prefix, while still teeing the raw lines to .wraith/logs/<twin>.log for wraith down and post-mortems. A terminal gets compact lines, CI gets JSON — decided once, for every twin.
  • Read a clean foreground. wraith serve’s own startup and shutdown logs now render in the same compact, color-coded format on a terminal instead of a wall of JSON, so the request lines stand out. Piped output stays all-JSON for both tracing and access lines.

Intent contracts. A consumer can now hand a provider an executable statement of what they depend on — packaged as a signed .wic archive that pins the twin by digest and carries runnable scenarios — and the provider verifies it against a freshly composed twin in CI. Purely additive: every v0.9.x pack, composite, and wraith.toml re-verifies and re-serves unchanged, and root and overlay twins are untouched. The whole surface is the new wraith contract command group. See the Intent contracts guide for the full workflow.

  • Author a contract. wraith contract pack ./staged --output checkout-refund.wic --key ./signing.key assembles a deterministic, signed .wic: it pins the base (and any overlay) twin by digest, bundles your sigil Lua scenarios plus the canonical lib/wraith.lua helper, and PII-scans the bundle before sealing it (override knowingly with --override-pii <reason>). Pack twice → byte-identical.
  • Inspect before you trust. wraith contract inspect <pkg>.wic summarizes a package; --strict runs the trust-gate lint and rejects anything whose PII scan isn’t clean.
  • Verify against your own twin. wraith contract verify <pkg>.wic --base-pack base.wraith --overlay-pack overlay.wraith resolves the pinned artifacts to your local packs, composes and serves the twin, runs the scenarios through sigil, and reports a CI-ready envelope. Structured exit codes: 0 pass, 2 contract failed, 3 digest/signature, 4 runtime. --pack-dir <dir> auto-resolves pins by digest; an unmet pin names the digest it needs.
  • Accept through a trust gate. wraith contract accept <pkg>.wic --trust-store ./trusted-signers checks the signature against keys you trust, materializes the runnable scenarios, and records the decision in a <name>.status.toml sidecar you commit with your code. Non-default evidence modes, extra capabilities, and non-self-contained packages each need an explicit --allow-… flag. Re-accepting a contract that already has a decision is refused unless you pass --force, so a re-accept can never silently undo a reject or suspend.
  • Decide how a contract gates CI. set-status advisory|accepted|blocking is the dial — advisory runs but never fails CI, accepted warns, blocking makes a violation a hard failure. reject and suspend record the two “not installed” decisions. status and list read back where everything stands (both default to the installed view).
  • Check a contract against a base bump. wraith contract rebase-check classifies a contract against a new base digest (compatible / additive-safe / conflict) so you can tell whether a base change breaks a downstream expectation without re-recording.

A contract carries three independent dials so the consumer’s intent, each scenario’s maturity, and the provider’s gating decision never collide: consumer_status (observed / proposed / deprecated, in the signed .wic), each scenario’s lifecycle_state (draftcanaryactivequarantinedretired), and the provider’s provider_status dial (advisory / accepted / blocking, plus the not-installed rejected / quarantined, in the unsigned sidecar). The sidecar keeps an honest audit trail: accepted_by is the original accepter (set once), while decided_by names whoever made the latest decision — so a rejected contract shows both who first accepted it and who rejected it.

The default evidence mode is reference_only — a contract carries scenarios and digests, not your captured traffic. Scrubbed-excerpt and full-recording modes exist but must be admitted explicitly at accept time, and the choice is recorded in the sidecar.

Overlay flow hardening. Patch release shaking out the v0.9.0 overlay workflow against real-corpus twins. No wire-format changes — v0.9.0 packs and composites re-verify and re-serve unmodified.

  • wraith serve --overlay <ovl.wraith> --overlay-policy <path> — local workspace overlays compose against a relaxed policy without forcing you to sign every iteration. Pass a policy file that allows unsigned overlays and adds your overlay’s scrub-policy hash to allowed_scrub_policy_hashes.
  • wraith compose outputs survive wraith doctor. The composite now carries scrub.toml and a populated schema_version so doctor stops rejecting freshly-composed workspaces as “missing security policy.”
  • wraith init --base provider@sha256:<hex> — the embedded artifact = "name@sha256:..." form that --help and the Overlays guide already documented now actually parses out of [base] in wraith.toml. The legacy split digest = "..." field still works.
  • wraith doctor <path-to-workspace> accepts literal workspace paths the same way wraith serve does, instead of demanding a twin name resolvable under twins/.
  • wraith serve --fixture <overlay-name> accepts the bare overlay suffix as a fallback when the namespaced form is omitted, matching what the help text already promised.

Reporting that doesn’t contradict itself

Section titled “Reporting that doesn’t contradict itself”
  • wraith synth --delta JSON summary now carries new_routes, synthesized_overlay_exchanges, and a per-reason unreplayable_by_reason map alongside the existing fields. Pre-fix, the headline delta stayed at 0 when an overlay introduced brand-new routes the base never observed — CI scripts read that as “no overlay material” even when synth emitted seven new routes. The new synthesized_overlay_exchanges count matches the emitted route count regardless of whether the routes are existing-route deltas or brand-new surfaces.
  • wraith compose advice no longer says lint-clean while also reporting 20 lint warning(s). Round-trip success is still reported (because the round-trip itself succeeded), but the message now reflects the warning count and points at the warning-bearing advice entry.
  • wraith compose --check now distinguishes “perfect conformance” from “no replay evidence.” A composite with no recorded sessions surfaces verify-check-no-evidence instead of an unconditional 10000-bp score.
  • compose-report.json variant counts at the top level now agree with per-overlay counts for overlay-added routes.

Strict pack verification is usable on public APIs again

Section titled “Strict pack verification is usable on public APIs again”
  • wraith verify-pack --strict stops flagging every name JSON-key literal in an anti-unified model template as PII. On synth-emitted model artifacts (model/symbols.json, the WIR), single-token enum values that look like slugs (bulbasaur, machine, egg, platinum) are now treated the way the runtime scrubber’s cardinality filter would — suppressed structurally. Multi-token person names (“Alice Smith”) still fire. Email-keyed leaves are unconditionally counted regardless of artifact because @ is a strong self-contained shape signal. Concrete effect: on a packed PokéAPI twin, strict verify drops from ~14k findings (false positives) to 0.
  • wraith pack --format json and the JSON envelope carry content_digest so overlay authors can read the base package digest required by init --base <ref>@sha256:... directly from the envelope instead of cracking the archive open.
  • Pack and verify-pack surface the overlay’s scrub policy hash, so consumers can satisfy strict compose’s allowed_scrub_policy_hashes gate without unpacking.
  • verify-pack JSON envelope surfaces composition provenance (base + overlay digests) on composite archives so you can read what a composite was made of without unpacking it.
  • verify-pack no longer false-positives on Wraith-authored structural provenance keys (artifact_name, twin_name) in freshly-packed archives.
  • Synth-handler variant guards return a route-no-match error when every guard misses, instead of silently dispatching to the first variant. Eliminates a class of confusing zero-divergence false-positive routes you might have hit when authoring guarded overlays.
  • serve --overlay no longer leaves composite workspaces behind outside the tempdir when overlay paths resolve through unexpected basename shapes. --keep-composite still works for debugging.
  • If you scripted around delta_filter.delta to decide whether an overlay produced material, switch to delta_filter.synthesized_overlay_exchanges (or synthesis.routes.length). The legacy delta field is preserved for backward compatibility but only counts variants on existing base routes — it doesn’t include brand-new routes the overlay contributed.
  • If wraith verify-pack --strict was failing in CI on public-API twins because of name PII findings, you can drop any [diff.suppress] workarounds — strict should now pass cleanly on those.

Overlays. A consumer team can layer their own routes, variants, fixtures, and fault profiles onto a provider-owned base twin without forking the base, and ship that layer as its own .wraith artifact. Pre-existing root twins are completely unaffected — overlays are inert without a [base] section in wraith.toml.

  • You need a behavior the provider hasn’t recorded (a webhook replay path, an error scenario, a specific edge case in CI).
  • Your test environment needs different fixture data than the base ships.
  • You want to add fault injection or latency profiles without touching the shared twin.

If you’d otherwise vendor and edit a copy of someone else’s twin, you want an overlay.

Terminal window
wraith init checkout-billing --base billing-api@sha256:abc --owner checkout
wraith record checkout-billing --tag happy-path
wraith synth checkout-billing # --delta is the default
wraith compose --base billing-api.wraith \
--overlay checkout-billing.wraith \
--output composite
wraith serve composite

See Overlays for the full workflow, configuration reference, and v0 scope notes.

  • wraith compose — merge a base plus one or more overlays into a materialized composite twin (a workspace or .wraith archive). Deterministic: same inputs in the same order produce byte-identical outputs.
  • wraith rebase-check — when the base advances, classify whether your overlay still applies cleanly against the new digest without having to re-record. Emits compatible, additive-safe, or conflict with evidence.
  • wraith promote — gated publication of an overlay artifact. Requires policy pass plus evidence sufficiency. Evidence-light overlays can still be checked, but they can’t be promoted.
  • wraith init --base <ref> --owner <team> — initialize a twin as an overlay against a digest-pinned base.
  • wraith synth --delta | --full | --base-path <path>--delta (the default for overlay twins) synthesizes only the routes that diverge from the base; --full synthesizes the entire twin. Root twins always synth full.
  • wraith serve --overlay <ovl.wraith> [--keep-composite] [--fixture <name>] — convenience for “compose then serve” without writing a composite to disk first. --keep-composite retains the materialized workspace for debugging.
  • wraith check --fixture <name> — pick which overlay’s fixture set seeds the default namespace during conformance.
  • wraith pack --include-diagnostics — ship compose-phase diagnostics inside the packed archive’s reports/ tree.

Overlay policy uses the existing exit-code discipline:

  • 0 — composes cleanly.
  • 1 — user error (bad config, missing artifact).
  • 3 — policy violation (weaker scrub posture, base-route deletion, Lua handler shadowing, etc.).
  • 4 — runtime error during composition.
  • compose output is fully self-contained. Composite workspaces now carry the merged state/fixtures/ and recordings rather than referring back to the input artifacts.
  • compose rejects archive entries with traversal-shaped paths (.., absolute paths, symlinks pointing outside the input root). Defense-in-depth on the unpack stage.
  • Same inputs to compose produce byte-identical artifacts. Set SOURCE_DATE_EPOCH to pin timestamps further. Useful for CI that diffs .wraith archives.
  • wraith synth --delta writes build/delta-report.json with a per-route breakdown of covered_by_base / delta / unreplayable and structured advice (overlay-is-redundant, many-unreplayable, base-route-missing) — gives a clear signal about whether an overlay is doing anything new or whether you should re-record.
  • wraith lint catches overlay misconfigurations. Missing [overlay].owner, invalid base digest, mismatched capability flags, and overlay twins that try to enable passthrough are flagged with the same surface wraith doctor already used.

Patch. Search and query POST routes no longer mint phantom entities in state.

POSTs like POST /v1/assets/actions/search were being classified as resource Create operations, so every search call left a junk entity behind in the per-session state store. After v0.8.3 wired seeded fixtures through serve, this caused three visible problems: search-shaped fixture-name collisions, faster-than-expected exhaustion of serve.limits.max_entities_per_type, and state snapshots polluted with synthetic search responses.

Action POSTs are now detected by two signals — the last URL segment (search, query, count, aggregate, summarize, lookup, and the Stripe-style /actions/<verb> shape) and the response body shape (single-array bodies or paginated {results: [], next_cursor: …} shapes). Routes matching either signal dispatch without state mutation.

If you have a twin where this heuristic now applies (e.g. POST /v1/customers/search), re-run wraith synth to pick up the fix.

Patch. state/fixtures/ is now actually loaded at serve time.

The state/fixtures/<entity>.json shape has been documented since v0.1, but wraith serve never read those files — every state-backed Read or List started with an empty store regardless of what was on disk. This is now wired through end-to-end.

  • Per-session seeding. state/fixtures/<entity_type>.json is loaded once per X-Wraith-Session namespace on first use. A delete then persists for the rest of the session; no re-seeding mid-session.
  • Default namespace too. Requests without an X-Wraith-Session header still get seeded.
  • state/schema.json declarations merge into the route-derived schema. Route-derived wins on conflict, so an empty entity_types: {} (the wraith init default) is fully inert.
  • Fail-safe. Missing or malformed files warn-log and proceed rather than crash serve. A twin with no state/ directory behaves exactly as it did pre-v0.8.3.

Use case. Multi-twin demos and shared-entity test scenarios — e.g. customer cus_123 referenced consistently across a CRM twin, a billing twin, and an orders twin — can now be set up by authoring one fixture per twin rather than driving a POST sequence at the start of every session.

Heads up — outbound scrub still runs on seeded fixtures. A fixture entity with a name field will be tokenized on the wire by the default PII rules ("alpha""name_<base62>"). This is the v0.6.0 PII behavior, not a regression — but it surprises fixture authors. Workarounds: add the field to your [pii] allowlist in scrub.toml, or set [pii] detect = false for twins where seeded values aren’t real PII.

Patch. Closes the remaining $arr_N placeholder leak on List and Read routes.

v0.7.1 fixed Create dispatch, but nested array placeholders — e.g. {"data":{"items":["$arr_0"]}} or a sibling meta / facets array — still leaked through List and Read because those handlers only rewrote the top-level collection array. They’re now expanded everywhere variants surface a body, so no literal $arr_N markers reach the wire.

If your synthesized responses include nested arrays and you saw ["$arr_0"] in serve output before, upgrade and they’re gone. No re-synth required.

Patch. Completes the v0.8.0 CORS preflight fix under the default config.

v0.8.0 correctly synthesized access-control-allow-{methods,headers} and vary on the OPTIONS variant, but the default strip_headers = true config (created by wraith init) then stripped those exact headers on the way out because they weren’t in the response-header allowlist. The v0.8.0 fix was therefore inert for most twins.

access-control-allow-methods, access-control-allow-headers, access-control-max-age, and vary are now in the default allowlist. Cross-origin clients hitting a synth twin behave correctly under strip_headers = true. Conformance scoring is unaffected.

Feature release. Closes three rough edges that came up in real-corpus use: dropped CORS preflight headers, repetitive array elements, and routes whose response depends on a request field. Every new behavior is opt-in or a strict bugfix; pre-existing twins keep their current bytes unless you opt in.

wraith serve --fidelity synth returned a bare 204 for cross-origin OPTIONS preflights, dropping access-control-allow-{origin,methods,headers} and vary. Every browser request was therefore blocked at the preflight stage. The synthesized OPTIONS variant now carries the recorded CORS headers, body-less status groups (204 / 304) included. Strict-mode replay was already correct; synth now matches.

array_length = "p90" (v0.7.2) recovered a ~500-long array but anti-unification still capped the distinct elements at 8 and tiled them to length — list UIs showed 8 rows repeated ~62×.

[generate.anti_unification]
max_array_representatives = "all" # or a bound like 200

Default stays at 8 so existing twins are byte-unchanged. Catalog or search APIs whose recordings carry many distinct rows are the main beneficiaries.

Some routes return different bodies depending on a request field — a parent id, a useCase scope, a search filter. Without help, synth collapses every input to one global representative, and every variation in the request returns the same canned response. The new request-keying machinery synthesizes one response per request-field bucket and routes the right one back.

[generate.request_keying]
mode = "manual" # or "auto" for conservative auto-detection
[[generate.request_keying.route]]
route = "POST /v1/assets/actions/search"
fields = ["$.input.filter.parentId"]

Default is mode = "off", fully inert. Use manual to declare keys per-route, or auto to let synth try to detect a key for unruled routes when one strongly predicts the response.

For catalog / search-shaped APIs that combine bimodal arrays with request-keyed responses:

[generate.anti_unification]
array_length = "p90"
drop_empty_array_responses = true
max_array_representatives = "all"
[generate.request_keying]
mode = "manual"

Feature release. Adds two knobs so synth handles bimodal / search corpora correctly. Both default to pre-v0.7.2 behavior exactly — existing twins are byte-unchanged unless you opt in.

A debounced search endpoint records a flood of empty no-match responses interleaved with a few real catalog loads. Synth’s default median-length array policy then collapsed such routes to ~1-element arrays even though the data was right there in the recordings.

Two new knobs, both under [generate.anti_unification]:

  • array_length"median" (default), "p75", "p90", or "max". Pick the length statistic that matches your corpus shape.
  • drop_empty_array_responsesfalse (default). When true, all-empty responses are excluded from anti-unification per status group, but only when at least one non-empty response exists for that group, so error variants and scalar responses are never dropped.

wraith synth now prints the active policy in its fidelity warning and, on collapse-prone defaults, suggests the exact stanza to add.

Section titled “Recommended config for bimodal / search APIs”
[generate.anti_unification]
array_length = "p90" # or "max"
drop_empty_array_responses = true

Patch. Fixes a placeholder leak in synth-mode Create responses.

wraith serve --fidelity synth was returning literal ["$arr_0"] strings in POST responses for routes classified as Create whose variants used variable-length array placeholders. The same string was also being persisted into state, so subsequent Read / List requests for that entity kept emitting it indefinitely.

Fixed at write time — expanded entities go into state, and expanded bodies go to clients. Re-pack any twin whose recordings include Create routes with variable-length arrays to flush the bad state from earlier serve runs.

The related nested-placeholder leak on List / Read routes is fixed in v0.8.2.

wraith generate hardening release. Four review passes on generate alone surfaced 11 fixable bugs — budgets that didn’t enforce, audits that didn’t write, scores that disagreed with wraith check, rejection reasons that hid the real cause. All fixed. The agentic and single-shot loops are now trustworthy enough to drive in CI.

  • --time-budget cancels in-flight LLM calls. Was advisory — a stalled call ran until external SIGKILL. Now each provider call is wrapped against the run-level deadline; on expiry the in-flight HTTP future is dropped and the process exits within time_budget + 5s grace. Covers ollama, openai, openrouter, and command providers, in agentic and single-shot modes.
  • --token-budget enforced per-call. The LLM’s completion is capped at min(8192, tokens_remaining) so a single response can’t push wildly over budget. Prompt tokens are also accounted now — estimate_prompt_tokens() (chars/4) subtracts from the budget before max_tokens is computed, and the call is skipped entirely when the prompt alone would exceed the budget. Stripe-sized prompts (~28k tokens) overshoot dropped from ~22% to ~0%.
  • Generate’s score matches wraith check. Previously generate called the conformance engine with lua_dir=None, so on twins with Lua handlers (orderledger has 7) the engine returned 501s the diff engine saw as 233 phantom divergences. The Lua directory is now threaded through every call site; generate’s reported score equals wraith check --in-memory.
  • generate-audit-*.json written on every run. Previously the audit directory was empty after every run (wrong write path). A new RAII writer atomically rewrites the file at start, after each round, on success, on error, and on panic-unwind. Schema: timestamps, twin/provider/model, budgets, initial + final conformance, per-route patches with reasons, per-round agentic transcripts, token spend, exhaustion reason.
  • SIGKILL-safe audits. A new started exhaustion-reason marker is written at construction so SIGKILL’d runs leave a meaningful marker on disk — readers can distinguish “still running” from “completed cleanly” instead of seeing null.
  • Unified exhaustion_reason across envelope and audit. Was two separate enums with different precedence — the same run could report iterations in the envelope and budget_exhausted in the audit. Now a single enum with documented precedence (error > panic > killed > time_exhausted > budget_exhausted > iterations_exhausted > completed); the two surfaces always agree.
  • Token-vs-time precedence is honest. A pre-call gate previously set a generic budget_hit flag that mapped to time_exhausted always — so a token-budget run reported time_exhausted. A typed BudgetHitCause carries the specific cause and routes each variant to the right ExhaustionReason.
  • Real rejection reasons. Rejected patches no longer all report "no edits made". Each rejection site emits a specific rejection_reason: budget-exhausted | parse-failure | regression-rejected | empty-edits | protocol-failure | llm-error | user-declined.
  • --interactive now actually prompts. Was declared and documented but never read. Now: before applying each accepted patch, a unified diff of {status, headers, template} is printed to stderr followed by apply this patch? [y/N]:. y / yes accepts; anything else (including EOF / empty line) rejects with rejection_reason: user-declined. Stdout JSON envelope stays clean. Works in both agentic and --no-agentic modes.
  • Lib tests: 2890 → 2953 (+63 across the release).
  • 11 generate-related bones closed across 4 review passes; zero open bugs at cut.

Brutal-review shakedown. 14 review passes, 70+ fixes, zero open bugs at cut. New wire-mode conformance, new wraith install, principled PII machinery.

  • wraith install <pack.wraith> — inverse of wraith pack. Extracts a packaged twin into a usable workspace. Verifies per-artifact digests before writing any files. Defense-in-depth PII rescrub on extraction. --name, --into, --force, --no-verify, --rescrub.
  • wraith check --wire — wire-mode conformance. Spawns the real serve on a loopback port and replays recorded requests through it. Catches protocol-level bugs the in-memory check is blind to (header stripping, scrub layer mismatch, status code drift). Emits a separate wire_fidelity_bp score with the same partial-credit formula as the replay score.
  • wraith check --upstream without --target or --in-memory now defaults to in-memory replay (previously silently no-op’d). Emits info advice noting the implicit choice.
  • wraith reduce strategies are distinct. coverage uses greedy set cover; diversity uses farthest-point-first by Jaccard distance; recency ranks by timestamp. Invalid --target-size (e.g. abc, bare 50 without %) now exits non-zero with a hint instead of silently no-op’ing.
  • Error-severity divergences count against the score. wraith check no longer reports 100% conformance while emitting thousands of severity=error divergences. Any error-severity divergence on an exchange zeros the affected component score.
  • drift_type classifier refined. New numeric_drift, host_rewrite, url_drift, value_drift. enum_expansion reserved for real string-enum cases.
  • upstream_fidelity_bp — separate score answering “does the twin look like the live upstream right now?” Network failures degrade gracefully.
  • 404 on unknown IDs for Read endpoints when both 2xx and 4xx variants are present. GET /v1/customers/cus_FAKE → 404 instead of 200 with empty body.
  • POST /:id classified as Update, not Create. Matches Stripe convention. Sub-resource POSTs (/cancel, /capture) still classify as Action.
  • DELETE preserves pre-mutation membership — first delete returns 200, second returns 404. Was: first delete returned 404 with deleted:true body (status/body mismatch).
  • List endpoints honor pagination?limit, ?offset, ?page+per_page, ?starting_after, ?ending_before, ?cursor. has_more is set when the template carries the field. Stripe, PostgREST, page-style, and Google-style conventions covered.
  • List handler is O(limit), not O(N). ?limit=10 against 10k entities: 70ms → 7ms. 1000 parallel ?limit=10: 66s → 0.7s.
  • Idempotency-Key honored on POST (opt-in via [serve.idempotency]). Per-namespace (route, key) → cached response.
  • REST and GraphQL malformed bodies return 400. Empty body, primitives, shape-mismatched arrays all rejected with a structured invalid_request_error envelope. Default fallback when no recorded 4xx variant exists.
  • URL normalization at request entry. /v1/customers/. and /v1/customers/.. are rejected with 400; /v1/customers// collapses to the list route. RFC 3986 dot-segment handling.
  • Seen IDs serve recordings verbatim. When the request path matches a recorded URL exactly, serve the recorded body bit-for-bit. The new hash-based variation only fires for unseen IDs.
  • Path collapser preserves collection roots. /v1/balance, /v1/charges, /v1/payment_intents, etc. stay as specific routes; only ID-shaped segments become :param. No more spurious /v1/:param catch-alls.
  • Numeric path segments collapse to :param after N distinct values (was N=∞). /pokemon/{1,4,25}/pokemon/:param. Was: 3 separate routes; unseen IDs returned 501.
  • Array length distribution preserved. Synthesized responses render arrays at the median observed length, cycling through up to 8 representative elements, instead of folding to a single placeholder.
  • Cardinality-detected per-twin enum_paths. A new synth-time analyzer marks low-cardinality high-repetition kebab/snake-case fields as enum. The PII walker skips them. No more hardcoded list of “pokeapi.ability.name” / etc. entries in source — a new API (Discord, Salesforce, anything) gets the same treatment automatically.
  • Per-request hash-seeded representative selection. Same path → same response (deterministic). Different paths → different response content drawn from observed representatives.
  • Lua handler errors return 500. Previously silently fell through to template rendering with a random muxemwxu-shaped id, making test failures invisible.
  • Lua handlers resolve by filename convention when no explicit hook is set in the model. Was: synthesis never populated vm.lua_hook, so handlers loaded but never ran; template rendering clobbered computed values (total: 134.34 template constant).
  • Form-encoded numeric scalars coerce to recorded type. Stripe amount=8888 now renders as Value::Number(8888) (was "8888").
  • Clock holes resolve per-request. New [serve.clock] mode = real | deterministic | fixed. Default is real wallclock; deterministic uses a seeded monotonic counter.
  • URL rewrite on outbound responses. Absolute URLs at the recorded upstream host are rewritten to point at the twin. Third-party URLs (GitHub raw, CDNs) preserved verbatim (was being replaced with UUID placeholders).
  • Vendor headers stripped on serve by default (Cf-Ray, X-Cache, Server, etc.). Configurable via [serve] strip_headers.
  • Default scrub rules cover email, phone, name, SSN, git author blobs. Git commit metadata in GitHub recordings is tokenized at write time.
  • Doctor scans recordings + model bodies for PII. New --allow-pii flag downgrades findings to info. wraith export openapi github and wraith pack both re-scrub before emit so legacy twins don’t ship raw PII.
  • [pii] scrub.toml section. detect toggle, allowlist for legitimate non-PII paths, default_action, fields.always for explicit overrides. Suffix-matching on *_name / *_email catches customer_name, employee_name, author_email.
  • pseudonymize scrub action — deterministic user_<base62> replacement keyed by HMAC. Stable across recordings/exports/packs for the same input.
  • wraith pack archives are byte-stable with [serve.clock] mode = "deterministic". Two consecutive packs produce identical sha256 hashes.
  • wraith verify-pack reports PII findings alongside the digest check. --strict flips warnings to failures.
  • Confidence-based outbound scrub on live serve. Enum values (bulbasaur, grass, razor-wind) preserved; real person names (including short ones like bob) tokenized. Cardinality detection distinguishes thing-with-a-label-name entities (preserve .name) from person-with-a-personal-name entities (scrub).
  • grpc-status in HTTP/2 trailers for non-empty bodies. Was in initial headers — a spec violation that grpcurl, tonic, gRPC-Go, gRPC-Java, and official Python gRPC all reject. Empty-body errors still use the spec-permitted Trailers-Only form.
  • UTF-8-safe common_prefix. Synthesis no longer panics on multi-byte UTF-8 (Japanese, Cyrillic, accented Latin, emoji). API twins for internationalized APIs (anything with localized strings) build successfully.
  • Lib test count: 2403 → 2890 (+487).
  • 14 brutal-review passes; ended with zero open bugs.
  • 70+ feature/fix commits since v0.5.2.

Streaming and capture fidelity. Three new fixture twins.

  • wraith record survives SIGTERM mid-stream. Long SSE/gRPC streams cut by SIGTERM (or wraith record stop, vessel, systemd) now persist their WREC and session manifest with truncated=true instead of vanishing silently. The forward proxy now also handles SIGTERM; previously only Ctrl-C was caught.
  • In-flight streams pin sessions against the idle timeout. A long SSE stream (e.g. an LLM streaming for >30s on CPU) no longer fragments surrounding exchanges into separate sessions in wraith inspect. Sessions close when the activity actually stops, not when the next exchange happens to start.
  • gRPC replay is byte-faithful for fixed-length arrays. Fixed-position event slots in a recorded stream now render with the correct per-slot template instead of position 0’s. No more ghost proto3 default values on the wire.
  • Synthesized 429 bodies match the route’s recorded 4xx shape. Stripe gets {error: {type, code, message}}, GitHub gets {message, documentation_url}, Twilio and GraphQL likewise. Fallback when no 4xx is recorded is a structured {status, code, message, retry_after} - friendlier to clients deserializing into typed error structs.
  • Volatile response headers freshly emitted at serve time. Date, Server, X-Request-Id, Cf-Ray, Etag are dropped at synth time and synthesized at serve time so 200s and 429s carry the same wallclock Date source - important for HMAC signers and freshness checks.
  • Header presence as a guard. When a single route records both authed (200) and unauthed (401) shapes, wraith synth infers HeaderPresent / HeaderAbsent guards on the discriminating header (e.g. Authorization). At serve and check time, requests route to the matching variant. Header-name-agnostic - any consistently-present-vs-absent header qualifies.

twin.wir.json is the documented portable twin artifact. It used to silently drop several pieces of metadata that wraith serve already supported via the in-memory model. Now round-tripped:

  • Per-route binary content type and body (HTML, plain text, opaque binary endpoints)
  • Per-route gRPC marker
  • Per-variant Lua hook handler
  • Per-route symbol table
  • Per-variant header programs and optional-field lists

All additions are backward-compatible - existing twin.wir.json files load unchanged.

  • Exercise scripts force a session boundary (POST /__wraith/new-session) between recording iterations. Multi-session runs now produce real session boundaries instead of one giant session.
  • wraith inspect surfaces refresh probe recordings (recordings/refresh/<run_id>/sessions/) alongside regular ones.

Three streaming-fixture twins for contributors to replay end to end:

  • mercure - pure SSE hub. Infinite-stream regression target.
  • caddy-sse - minimal controlled SSE fixture with configurable event count, cadence, and payload shape.
  • qdrant - vector DB gRPC twin. Validates the unary gRPC + protobuf-descriptor pipeline.

v0.4 shakedown follow-ups. Twin-quality fixes + lifecycle commands.

  • DELETE replay matches recorded shape. wraith serve now renders the variant body template on DELETE instead of substituting a hardcoded {deleted, id} body. Literal fields like object: "coupon" survive.
  • Numeric epoch fields stay numeric. Fields like Stripe’s created (Unix epoch seconds, integer) are no longer overlaid with ISO 8601 strings. The classified clock unit (epoch_sec / epoch_ms / iso_string) drives output, not the field name.
  • No more $hole_* placeholder leaks. Unfilled holes can never reach the wire under any classification. The hole classifier learns ID shape from observations: prefix, length, and character class. Stripe-shaped IDs (cus_<14 base62>) and short token fields (e.g. 7-char uppercase alnum) are generated correctly.
  • /__wraith/ready returns 200 once the listener is bound. Previously it returned 503 forever, breaking wraith up’s ready poll and wraith status’s ready probe.
  • wraith coverage reports real session counts. Previously every route showed sessions=0.
  • Trace ring buffer captures non-200 responses. --trace now records 429s, fault-injected 5xx, throttle, drop, and timeout responses - exactly the responses you want with --chaos-seed --trace.
  • wraith down: stops twins started by wraith up. SIGTERM with SIGKILL escalation. Idempotent.
  • wraith status: per-twin alive + ready report. Polls /__wraith/ready for each running twin.
  • wraith env: emits WRAITH_<NAME>_PORT and WRAITH_<NAME>_BASE_URL for each twin in the project manifest. Pasteable into a shell or consumed via --format json.

Manifest plumbs simulation flags through wraith up

Section titled “Manifest plumbs simulation flags through wraith up”

Project manifests can now drive the v0.4 simulation layers per twin:

[twins.stripe]
path = "twins/stripe"
port = 8181
chaos_seed = 42
latency_mode = "auto"
trace = true
trace_capacity = 500
rate_limit = true
rate_limit_override = ["GET /v1/foo=5/1sec"]
debug = false
listen = "0.0.0.0:8181"
fidelity = "synth"

All fields optional; existing manifests parse unchanged.

SSE and gRPC server-streaming. Record, synthesize, serve, and conformance-check streaming APIs end to end. See the Streaming guide.

  • SSE (text/event-stream): wraith record captures live without buffering - long-lived streams no longer deadlock the recorder. wraith serve emits realistic streams with per-event timing and rotating per-event content (an LLM twin emits the recorded token sequence, not one repeated character).
  • gRPC server-streaming: wraith record forwards frames live with HTTP/2 trailers preserved. wraith serve emits frame-correct length-prefixed protobuf with grpc-status trailers - gRPC clients connect and stream without Internal: missing trailers.
  • Long-lived bidi streams (cancelled by client deadline, no trailers received) classify as truncated; replay matches.

wraith check now scores streaming exchanges under dedicated PASS criteria:

  • Event count must match the recording.
  • Per-event structural shape (keys, types, constants) must match.
  • Hole-marked fields (variable LLM token text, etcd event keys) tolerate value variance.
  • Termination shape and gRPC trailers must match.

Previously, streaming exchanges rolled up into the unary scorer where streaming-specific divergences could be diluted into a passing score. New behavior: a streaming Error-severity divergence fails the session.

Suppression rules in wraith.toml are applied before scoring, so a suppressed divergence no longer counts against the conformance score. Previously [[diff.suppress]] filtered the report only.

wraith synth infers body-field guards on routes whose variants are discriminated by request-body string fields. Glob paths like messages[*].content are supported. At serve time, when multiple variants’ guards match a request, wraith serve picks the most-specific variant - so a request that matches both a loose 200 catch-all and a tight 404 error variant routes to the 404.

A single route can mix streaming and non-streaming variants. The 200 SSE variant serves a stream; the sibling 404 invalid-model JSON variant serves a normal response.

  • ollama - twins the OpenAI-compat /v1/chat/completions endpoint with stream: true for any local Ollama model.
  • etcd-streaming - extends the etcd twin with KV.Watch, the canonical server-streaming RPC.

Both ship with podman fixtures so contributors can replay end-to-end.

Faulty-service simulation + OpenAPI seed + trace endpoints. Six orphan subsystems wired into the CLI.

See the Simulation guide for the fault/latency/rate-limit story end to end.

  • Fault injection (--fault-profile <path>, --chaos-seed <u64>): six fault types (Error / Delay / Timeout / Drop / Throttle / Partial), deterministic seeded RNG, route globs, header matching, percentage rolls, per-rule trigger caps. generate_chaos_profile builds a realistic mix from the loaded WIR when given just a seed.
  • Latency simulation (--latency-mode <fixed|uniform|recorded|normal|percentile> + aux flags): per-route overrides, seeded ChaCha RNG for deterministic replay. When a fault Delay rule fires, it replaces the latency simulator’s contribution for that request (no compounding).
  • Rate-limit simulation (--rate-limit, --rate-limit-override "METHOD /path=N/Wsec"): FixedWindow and SlidingWindow algorithms, standard X-RateLimit-* + Retry-After headers, shared 429-response builder for fault Throttle and the rate-limit gate.
  • Evaluation order: rate-limit -> fault -> latency -> dispatch. All three layers are Option<Arc<...>> - zero overhead when their flags are absent.

Trace endpoints (--trace [--trace-capacity N])

Section titled “Trace endpoints (--trace [--trace-capacity N])”
  • GET /__wraith/trace/log returns the ring buffer in reverse-chronological order.
  • GET /__wraith/trace/<id> fetches a single trace by id.
  • POST /__wraith/trace/reset clears the buffer.
  • Bounded ring buffer with FIFO eviction. Same control-plane auth policy as the existing /__wraith/* surface. Disabled by default.
  • Each divergence gets a stable drift_id (fingerprint) and a DriftType classification (schema-change / field-removed / status-shift / etc.).
  • JSON envelope adds a drifts[] summary grouping divergences by drift_id, and per-divergence drift_id + drift_type. Additive only - existing consumers see the old shape when skip_serializing_if suppresses empty fields.
  • twins/<name>/drift.toml (sibling of scrub.toml) supports [[suppress]] and [[reclassify]] rules matched by glob on drift_id / route / path / drift_type. Absent file is a silent no-op.
  • Refresh integration deferred until refresh’s probe-execution path lands.
  • New wraith explore --from-openapi <spec.yaml> [--against <url>]: parses OpenAPI 3.x (YAML or JSON), generates scenario plans, optionally executes them against a live URL and reports per-step match/mismatch/error counts. Auth via repeated --header flags.
  • wraith coverage --openapi <spec> extends coverage to report spec-vs-recordings gaps (covered_count, total_count, uncovered_operations).
  • Additive JSON envelope fields - no breaking changes to existing coverage consumers.
  • Router backtracking: literal subtrees with wrong-method no longer block backtracking to param subtrees.
  • Scrub null handling: null JSON values no longer get tokenized.
  • Header allowlist: user with_extra_compare_headers opt-ins no longer overridden by blanket x-* filter.
  • Sync conformance replay: query params now carried through.
  • VCR base64 handling: case-insensitive base64 detection.
  • Async CRUD handlers: error-variant short-circuit restored across Update / Delete.
  • Async handle_list: array-key detection + totalItems / totalPages parity with sync path.
  • Async/sync drift eliminated: async CRUD handlers now delegate to sync dispatch (-561 LOC of duplicate logic).
  • Clock holes carry unit info: ClockUnit::{EpochSec, EpochMs, IsoString} with serde-compatible migration.
  • 1991 lib tests passing (+43 vs v0.3.0). 40+ new integration tests across e2e_fault, e2e_latency, e2e_rate_limit, e2e_serve trace suite, explore_openapi.
  • cli/up.rs, cli/refresh.rs, synth-side rate-limit / latency auto-population remain TODO for v0.4.x or v0.5.

18 twins (REST + GraphQL + gRPC). All PASS. Honest conformance with granular suppression.

  • Protobuf codec: decode (wire->JSON) and encode (JSON->wire) via prost-reflect. 14 tests.
  • gRPC framing: detect, parse, encode length-prefixed frames, extract trailers. 21 tests.
  • HTTP/2 proxy: h2c listener (auto-detects h1/h2), hyper-based upstream client with trailer forwarding, GrpcProxyBody for proper trailer delivery.
  • Synth detection: is_grpc_endpoint(), method-name state op inference (Create/Get/List/Update/Delete), grpc flag on RouteModel. 22 tests.
  • Serve handler: GrpcConfig loads proto descriptors, decodes protobuf requests, encodes protobuf responses. Trailers-only format for unary RPCs.
  • Codec wired into pipeline: synth decodes protobuf bodies to JSON before anti-unification; check decodes recorded protobuf before diffing. Real templates, not echo fallback.
  • X-Wraith-Format: json: debug header bypasses protobuf encoding, returns raw JSON from synth handler.
  • X-Wraith-* headers stripped before forwarding to upstream during recording.
  • Go test service: 6 RPCs (CRUD + streaming), all proto types (nested, enum, oneof, map, repeated, timestamps). Dockerfile for podman.
  • Validated on etcd: real-world gRPC KV service, 3 routes, 0 divergences.
  • Granular list-body suppression: suppress only array contents, not entire envelope. Scalar envelope fields (count, summary, pagination) compared normally.
  • Numeric value comparison: 50 and 50.0 treated as equal (f64 comparison).
  • Empty-string ID mapping fix: prevented path corruption during conformance replay. Fixed Stripe (95->0) and PocketBase (168->0, FAIL->PASS).
  • User field classifications override all auto-detection, including list-body suppression.
  • check --in-memory loads Lua with state: handle_request_sync now calls invoke_handler_with_state. Lua handlers get full state.* and clock.* access.
  • OrderLedger stress test: 5 patterns (computed totals, conditional shapes, list aggregates, state machine, cross-entity joins). 7 handlers. 2 divergences with Lua vs 185 without.
  • POST /__wraith/new-session: force recording session boundary without restarting proxy.
  • Cross-session re-recording: Cloudflare, GitHub, Odoo, Stripe, Linear re-recorded with 2+ sessions each.
  • GitHub GraphQL v4: 16 operations (fragments, anonymous queries, inline fragments, deep nesting, mutations).
  • Updated docs: twin-lifecycle.md rewritten, configuration.md expanded, quickstart updated.

15 APIs at zero divergences. 53/53 sessions passing.

REST (13): Cloudflare, Forgejo, Gitea, GitHub, GitLab, Keycloak, Mattermost, Notion, Odoo, PocketBase, Stripe, Supabase, Twilio. GraphQL (2): Linear (19 ops), Saleor (16 ops, anonymous queries).

  • GraphQL operation routing: Detects GraphQL endpoints, splits single POST /graphql route into per-operation variants with guards. Handles both named operations (operationName field) and anonymous queries (parsed root field). New QueryRootField guard predicate.
  • Header allowlist: Replaced 40+ entry blocklist with 3-entry allowlist (content-type, www-authenticate, proxy-authenticate). Opt-in via with_extra_compare_headers().
  • Divergence suppression: [[diff.suppress]] in wraith.toml for user-declared suppression rules with glob patterns. --show-suppressed flag lists distinct suppressed paths with reasons.
  • Transparent heuristics: Hex color normalization, search/list-like body classification, scalar clobber guard - all reported as suppressed, not hidden.
  • Session tagging: wraith record --tag + wraith synth --tag for selective synthesis.
  • Recording control plane: /__wraith/health, /__wraith/ready, /__wraith/info endpoints during recording.
  • Agentic route fixer: 5 modules, 12 tools, text-based TOOL_CALL protocol. Verified end-to-end.
  • Lua handler sandbox: Full state API (get/put/delete/list/query/count/counter + clock), hot reload, doctor validation.
  • Synth default changed to synth fidelity (was strict).
  • Scalar clobber guard: don’t overlay entity scalar onto template compound type
  • Search/list-like classification: POST search + bare array -> Generated body
  • Hex color heuristic: #e11d48 vs e11d48 suppressed
  • Variant routing guards (PathSegmentEquals, PathSegmentPrefix, FieldEquals, QueryRootField)
  • Dynamic-key object map suppression
  • Order-independent array matching
  • Heuristic timestamp/counter suppression
  • Empty-body response handling
  • Non-JSON content echo (binary/HTML/text strict replay)
  • Gzip decompression in conformance normalizer
  • 30+ additional deterministic fixes across 5 days