Stateful Flows & Statecharts
Real APIs are stateful. You POST /charges, get back an id, then GET /charges/{id} — and a naive recording replays a 404 because it never saw that exact id. Gostly closes that gap with two cooperating pieces: a resource store that links a created resource to its later reads, and a statechart engine that advances each resource through its lifecycle. This guide explains both, the cascade position they occupy, and how to model multi-step flows.
The problem: POST then GET-by-id
A flat recording matches on method + URI + body. But a freshly-created resource has an id the recording has never seen, so a follow-up read misses every recorded entry and falls through to a 404. That is the single most common reason a recorded mock library "works in the demo and breaks in the test."
POST /charges → 201 { "id": "ch_9fA2", "status": "created", ... }
GET /charges/ch_9fA2 → 404 ← the recording never saw ch_9fA2The resource store fixes the read. The statechart engine fixes the writes that come after it — capture, refund, ship, cancel — so a multi-step flow behaves like the real upstream without anyone hand-authoring a single stub.
Where it sits in the match cascade
In MOCK mode every inbound request walks a fixed, deterministic cascade. The resource store and statechart transitions sit in the middle — after the cheap exact paths, before structural and AI tiers:
Session verbatim
If the request was seen during the active LEARN session, the in-memory capture replays it byte-for-byte. RAM-only, never leaves the box, resets on restart.
Exact match
Method + URI + request-body hash matches a recorded entry exactly. O(1) hash lookup.
Resource store
A GET against a previously-created resource id returns the captured create body as a 200 — the POST→GET fix. Served with X-Ghost-Resource: true.
Statechart transition
A PATCH or POST that names an action advances the bound machine and re-serves the resource with its new state. Served with X-Ghost-Transition: <new-state>.
Smart swap
URI path parameters normalised to templates (/users/{id}) and matched structurally.
AI generation
Last resort. No recorded, session, resource, statechart, or structural match → a fine-tuned model or RAG produces a response. Pro+.
Architectural invariant — no LLM in the hot path
The resource store — linking create to read
When the proxy records a successful POST /collection whose response body carries an id, the store captures that body keyed by (service, collection, id). A later GET /collection/{id} looks up the captured body and serves it back as a 200 instead of a 404.
Id extraction tries an explicit field hint first, then id, _id, and {singular}_id (e.g. charge_id for a charges collection). It recurses into nested objects a few levels deep, so Stripe-style envelopes like data.attributes.id resolve. A string id is used as-is; a numeric id is stringified for autoincrement APIs.
POST /charges → 201 { "id": "ch_9fA2", "status": "created", "amount": 4200 }
GET /charges/ch_9fA2 → 200 { "id": "ch_9fA2", "status": "created", "amount": 4200 }
X-Ghost-Resource: trueCaptures are held in memory and mirrored to an append-only JSONL file under the host data volume so they survive a restart; the file is compacted on load to one line per unique resource (last-write-wins). The original status and Content-Type are preserved so the synthetic GET serves the same shape the upstream did.
Read API — operator visibility
GET /v1/resources/{service_id} and GET /v1/resources/{service_id}/{collection}/{resource_id}, with an admin DELETE on the single-resource path. The dashboard renders these on the per-service detail page. The capture itself is available on every tier — everyone benefits from the POST→GET fix.The statechart engine
A captured resource can be bound to a statechart: a flat, single-region state machine modelling a resource lifecycle. When the collection name matches a known machine id — singular or plural, charges binds to charge— the resource starts in the machine's initial state. State-changing requests then advance it:
POST /collection/{id}/{action} — the action is the last path segment, e.g. POST /charges/ch_9fA2/capture.
PATCH /collection/{id} with a JSON body { "action": "capture" } — the action is read from the body.
A valid action moves the resource to the next state, rewrites the configured status field in the captured body (default status; fixtures can name it stage, phase, etc.), and re-serves the updated body as a 200 tagged with X-Ghost-Transition: <new-state>. An action that isn't valid in the current state is a no-op — the resource keeps its state and the request falls through to the next cascade tier.
# charge starts in "created"
POST /charges/ch_9fA2/capture → 200 { ..., "status": "captured" }
X-Ghost-Transition: captured
POST /charges/ch_9fA2/refund → 200 { ..., "status": "refunded" }
X-Ghost-Transition: refunded
# refund is only valid from "captured" — from "created" it no-ops
PATCH /charges/ch_other { "action": "refund" } → falls throughThe interpreter is pure and immutable — applying a transition never mutates the machine definition, and an undefined transition returns nothing rather than panicking, so a misnamed action can never crash the proxy hot path. v1 scope is flat states only: no hierarchical/compound states, parallel regions, history pseudo-states, or guards.
Bundled fixtures
Gostly ships five lifecycle fixtures built in — no files to load or configure. They cover the common billing/commerce shapes that drive the POST→PATCH→GET failure mode. The bundled engine fires on every tier:
chargecreated → capture → captured → refund → refunded; created → void → voided.
customeractive ⇄ suspend / reactivate ⇄ suspended; either → delete → deleted.
invoicedraft → finalize → open → pay → paid → refund → refunded; void / uncollectible branches.
orderpending → pay → paid → ship → shipped → deliver → delivered; cancel / refund / return branches.
subscriptiontrialing → activate → active ⇄ pause / resume ⇄ paused; any active phase → cancel → cancelled.
A machine is a JSON document: an id, an initial state, an optional status_field, and a states map where each state lists its outgoing action → target transitions. The charge fixture:
{
"id": "charge",
"initial": "created",
"status_field": "status",
"states": {
"created": { "transitions": { "capture": "captured", "void": "voided" } },
"captured": { "transitions": { "refund": "refunded" } },
"refunded": { "transitions": {} },
"voided": { "transitions": {} }
}
}Per-tenant overrides & the editor (Pro+)
The bundled engine is universal, but editing machines — overriding a fixture, authoring a brand-new lifecycle, and the dashboard graph editor — is a Pro+ capability, gated by the sequences feature flag. The management API is mounted at /v1/statecharts:
GET /v1/statechartsMerged catalog: bundled fixtures plus this tenant's overrides and custom machines, each tagged bundled / overridden / custom.
GET /v1/statecharts/{id}Full graph for one machine. A tenant override row wins over the bundled fixture.
PATCH /v1/statecharts/{id}Upsert an override for a bundled id, or update an existing custom machine. Replaces the definition atomically.
POST /v1/statechartsCreate a new custom machine. 409 if the id collides with a bundled fixture (use PATCH to override that) or an existing custom machine.
DELETE /v1/statecharts/{id}Remove an override (revert to the bundled fixture) or delete a custom machine entirely.
GET /v1/statecharts/{id}/resourcesCaptured resources bound to this machine, aggregated by state — the dashboard's heat-map overlay (e.g. 23 in captured, 4 in refunded).
A write validates the graph at the boundary (every transition target must reference a defined state; initial must exist), persists it, and materialises a JSON file under the host data volume at statechart-overrides/<tenant>/<id>.json. The proxy then hot-reloads the change — no restart — merging your overrides on top of the bundled set, last-write-wins on id collision. A malformed override file is logged and skipped, never wedging the proxy.
Editing applies the override on hot reload
Modeling a multi-step flow
To model a lifecycle the bundled fixtures don't cover — say a shipment that moves through ordered → in_transit → delivered and tracks its state in a stagefield — author a custom machine. Name the status field your API actually uses; the engine rewrites only that field, and only when it's already a top-level string, so other body fields stay untouched.
POST /v1/statecharts
X-API-Key: $GHOST_API_KEY
Content-Type: application/json
{
"definition": {
"id": "shipment",
"initial": "ordered",
"status_field": "stage",
"states": {
"ordered": { "transitions": { "ship": "in_transit" } },
"in_transit": { "transitions": { "deliver": "delivered" } },
"delivered": { "transitions": {} }
}
}
}Once it's live, record (or seed) a POST /shipments so the resource store captures a shipment id bound to the machine, then drive the flow:
POST /shipments → 201 { "id": "shp_1", "stage": "ordered" }
POST /shipments/shp_1/ship → 200 { "id": "shp_1", "stage": "in_transit" }
POST /shipments/shp_1/deliver → 200 { "id": "shp_1", "stage": "delivered" }
GET /shipments/shp_1 → 200 { "id": "shp_1", "stage": "delivered" }Cold-start without live traffic
POST /v1/seed/{har,postman,openapi}), then bind the lifecycle with a custom statechart. See the pipeline overview for the LEARN → MOCK flow these fit into.Observability
Every served response carries a header announcing which tier answered, so you can assert on the match path in a test:
X-Ghost-Resource: trueA captured create body was served back on a GET-by-id.
X-Ghost-Transition: <state>A statechart action advanced the resource; the value is the new state.
On the agent's Prometheus endpoint (/metrics), a statechart transition increments ghost_requests_total{match_type="resource_transition"}, and the resource-store path emits its own labelled counter on each lookup outcome. Use these alongside the broader ghost_requests_total match-type series to see how much of your MOCK-mode traffic is being answered statefully.
Scope & limits
- Flat machines only. No hierarchical or parallel states, history pseudo-states, guards, or event payloads in this version.
- Single-tenant per deployment. A licensed deployment serves one tenant (the tenant id defaults to
default). Override files are keyed by tenant directory and database rows carry row-level-security policies as defense-in-depth — but isolation rests on single-tenancy itself, not on cross-tenant policy enforcement. - HTTP and HTTPS. Statecharts operate on recorded/replayed HTTP/1.1 and HTTP/2-over-TLS traffic. WebSocket frames are captured for observability but not replayed; there is no gRPC, async-messaging, or database mocking (roadmap).
- Editing is Pro+. The engine and the five bundled fixtures run on every tier; overrides, custom machines, and the editor are gated by the
sequencesfeature.