Skip to content

Rule schema

A rule is the unit of dependency control: a when matcher plus a then action. This page is the normative reference for the on-disk / on-the-wire shape.

Canonical source: spec/schemas/rule.schema.json. A rule document posted to /v1/sessions/{id}/rules follows spec/schemas/session-rules.request.schema.json.

Most users never write raw rules — the mockOutbound helper in each SDK compiles to this shape for you. Read this page when you need to:

  • hand-author a rule pack (softprobe session rules apply --file …),
  • embed default rules in a case file (case.rules[]),
  • or build a contract test that validates the wire payload.

Top-level shape

json
{
  "id": "string",
  "priority": 100,
  "consume": "once",
  "when":  { /* matcher */ },
  "then":  { /* action */ }
}
FieldTypeRequiredPurpose
idstringnoStable identifier for diffs, logs, and codegen. Autogenerated by SDKs when omitted.
priorityintegernoHigher wins on conflict. Default: SDK assigns 100.
consume"once" | "many"nov1 caveat: see Consume semantics.
whenobjectyesMatcher — see when.
thenobjectyesAction — see then. Exactly one action per rule.

additionalProperties is false — unknown top-level keys are a validation error.

The when matcher

All predicates are AND-ed. A rule matches only if every specified predicate is true. Unspecified predicates match anything.

FieldTypeMatches
direction"inbound" | "outbound"Which leg of the proxy
servicestringsp.service.name attribute equals
hoststringurl.host attribute equals (exact, case-insensitive)
notHostSuffixarray of strings (min 1)Inverted match — used for "block everything except these" policies
methodstringHTTP method, case-insensitive
pathstringurl.path attribute equals (exact)
pathPrefixstringurl.path starts with this prefix
headersobject → string | string[]Header name → required value(s); multiple values AND-ed
bodyJsonPathstringJSONPath expression evaluated against the request body; matches if non-empty
traceTagsobject → primitiveOTLP span tag name → required value

additionalProperties inside when is false.

SDK-only shorthands

The SDK mockOutbound / findInCase APIs accept a few additional predicates that the SDK expands into the schema above before sending:

SDK shorthandCompiles to
hostSuffix: "stripe.com"Concrete host list or a gateway-side test — SDK-specific
pathSuffix: ".html"Not in wire schema; SDK may reject

If you are writing the wire payload directly (YAML / JSON files), use the normative fields only.

Worked when examples

Exact outbound call:

json
{ "direction": "outbound", "host": "api.stripe.com", "method": "POST", "pathPrefix": "/v1/payment_intents" }

Anything in a suffix pattern (inverted):

json
{ "direction": "outbound", "notHostSuffix": [".internal", "localhost", "127.0.0.1"] }

Header-aware:

json
{
  "direction": "outbound",
  "host": "auth.example.com",
  "headers": { "authorization": "Bearer test-token" }
}

Body-aware:

json
{
  "direction": "outbound",
  "host": "api.example.com",
  "method": "POST",
  "bodyJsonPath": "$.amount"
}

The then action

Exactly one of five actions. additionalProperties is false — unknown keys are a validation error.

then.action: "mock"

Synthesize a response without calling the upstream.

json
{
  "action": "mock",
  "response": {
    "status": 200,
    "headers": { "content-type": "application/json" },
    "body": "{\"id\":\"pi_test\",\"status\":\"succeeded\"}"
  }
}
FieldTypeRequiredPurpose
action"mock"yesAction discriminator
responseobjectno (recommended)Response to return. Free-form object — typically status, headers, body.

Body can be a UTF-8 string or base64-encoded binary. The proxy decodes based on Content-Type.

then.action: "error"

Return an error response without calling the upstream.

json
{
  "action": "error",
  "error": {
    "status": 503,
    "body": { "error": "simulated outage" }
  }
}
FieldTypeRequiredPurpose
action"error"yesAction discriminator
error.statusintegeryesStatus code to return
error.bodyanynoResponse body

then.action: "passthrough"

Force the request through to the real upstream, even if policy would normally block it.

json
{ "action": "passthrough" }

No payload fields. Typically used with a higher priority to carve an exception out of a strict policy.

then.action: "capture_only"

Forward the request, let it reach the real upstream, but also record it via POST /v1/traces. Useful when you want observation without replacement.

json
{ "action": "capture_only" }

No payload fields in the schema. SDK mockOutbound does not emit capture_only rules — apply them via softprobe session rules apply or as case.rules[] in a case file.

then.action: "replay" (deprecated)

Still present in rule.schema.json for compatibility. Do not use — the runtime no longer walks captured traces on the hot path. Use findInCase + mockOutbound instead (see Rules and policy and design v0.5 notes).

Session rules request envelope

The body of POST /v1/sessions/{id}/rules is:

json
{
  "version": 1,
  "rules": [
    { /* one or more Rule objects as above */ }
  ]
}

version must be 1 for v1 of the protocol. The runtime replaces the entire session rules document on each call — see Replace vs merge.

Replace vs merge semantics

Critical behavior:

  • The runtime replaces the entire rules document on every POST …/rules. Whatever rules you send becomes the only rules on the session.
  • The SDKs merge on the client side: calling mockOutbound() twice in a row in the same test process appends a second rule and sends both in the next POST.
  • session.clearRules() resets the SDK-side merged list and sends { "version": 1, "rules": [] } — so subsequent mockOutbound() calls start fresh.

Consequence: if you mix SDK mockOutbound with raw softprobe session rules apply, the last writer wins at the runtime level. Pick one channel for a given session.

Precedence and composition

The runtime evaluates rules in layers. On each /v1/inject:

  1. Session rules (via mockOutbound / rules apply) are checked first.
  2. Case-embedded rules (case.rules[]) are checked second.
  3. Policy-synthesized catch-all is last (strict miss, default-on-miss).

Within each layer:

  1. Highest priority wins.
  2. On tie: later entry in the array wins.

Across layers on a tie, later layer wins (session > case-embedded > policy). Full details in Rules and policy → precedence.

Consume semantics (v1 caveat)

The schema defines consume: once | many, but v1 inject does not dequeue rules from the session document. Treat consume as documentation of author intent, not as a runtime-enforced flag. To get "use once" semantics in v1:

  • Author a fresh rule per invocation via mockOutbound and call clearRules() between scenarios, or
  • Use a priority hierarchy so a later rule shadows an earlier one.

This will be revisited in v1.0. See docs/design.md §8.2 for the design rationale.

Validation

Validate a rule document with ajv:

bash
npm install -g ajv-cli
ajv validate \
  -s spec/schemas/rule.schema.json \
  -d rules/my-rule.json

Or use the CLI:

bash
softprobe validate rules rules/my-rule.yaml

Complete worked example

yaml
version: 1
rules:
  - id: strict-block-external
    priority: 10
    when:
      direction: outbound
      notHostSuffix: [.internal, localhost, 127.0.0.1]
    then:
      action: error
      error:
        status: 599
        body: { error: "external call blocked in strict mode" }

  - id: stripe-payment-ok
    priority: 100
    when:
      direction: outbound
      host: api.stripe.com
      method: POST
      pathPrefix: /v1/payment_intents
    then:
      action: mock
      response:
        status: 200
        headers:
          content-type: application/json
        body: '{"id":"pi_mock","object":"payment_intent","status":"succeeded"}'

  - id: audit-calls-to-partner
    priority: 500
    when:
      direction: outbound
      host: partner.example.com
    then:
      action: capture_only

See also