Guides

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_9fA2

The 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 deterministic tiers above the AI tier never call a model. When generation is enabled, the request hot path is still served from cache — generation runs on a background worker behind a bounded queue, and the response is replayed from the cache once produced. There is no synchronous LLM call on the request path, ever. The resource store and statechart engine are pure, in-memory, and sub-microsecond per traversal.

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: true

Captures 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

The control plane exposes captured resources read-only at 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:

Form A

POST /collection/{id}/{action} — the action is the last path segment, e.g. POST /charges/ch_9fA2/capture.

Form B

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 through

The 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:

charge

created → capture → captured → refund → refunded; created → void → voided.

customer

active ⇄ suspend / reactivate ⇄ suspended; either → delete → deleted.

invoice

draft → finalize → open → pay → paid → refund → refunded; void / uncollectible branches.

order

pending → pay → paid → ship → shipped → deliver → delivered; cancel / refund / return branches.

subscription

trialing → 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/statecharts

Merged 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/statecharts

Create 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}/resources

Captured 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

The swap is atomic, so a successful edit takes effect on the next request without dropping in-flight traffic. The dashboard editor supports rename, add/remove transition, delete state, set the initial state, and authoring entirely new machines.

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

You don't need to record against a live upstream first. Drag a HAR, Postman collection, or OpenAPI spec onto the dashboard to seed a service (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: true

A 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 sequences feature.

Next steps