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
{
"id": "string",
"priority": 100,
"consume": "once",
"when": { /* matcher */ },
"then": { /* action */ }
}| Field | Type | Required | Purpose |
|---|---|---|---|
id | string | no | Stable identifier for diffs, logs, and codegen. Autogenerated by SDKs when omitted. |
priority | integer | no | Higher wins on conflict. Default: SDK assigns 100. |
consume | "once" | "many" | no | v1 caveat: see Consume semantics. |
when | object | yes | Matcher — see when. |
then | object | yes | Action — 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.
| Field | Type | Matches |
|---|---|---|
direction | "inbound" | "outbound" | Which leg of the proxy |
service | string | sp.service.name attribute equals |
host | string | url.host attribute equals (exact, case-insensitive) |
notHostSuffix | array of strings (min 1) | Inverted match — used for "block everything except these" policies |
method | string | HTTP method, case-insensitive |
path | string | url.path attribute equals (exact) |
pathPrefix | string | url.path starts with this prefix |
headers | object → string | string[] | Header name → required value(s); multiple values AND-ed |
bodyJsonPath | string | JSONPath expression evaluated against the request body; matches if non-empty |
traceTags | object → primitive | OTLP 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 shorthand | Compiles 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:
{ "direction": "outbound", "host": "api.stripe.com", "method": "POST", "pathPrefix": "/v1/payment_intents" }Anything in a suffix pattern (inverted):
{ "direction": "outbound", "notHostSuffix": [".internal", "localhost", "127.0.0.1"] }Header-aware:
{
"direction": "outbound",
"host": "auth.example.com",
"headers": { "authorization": "Bearer test-token" }
}Body-aware:
{
"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.
{
"action": "mock",
"response": {
"status": 200,
"headers": { "content-type": "application/json" },
"body": "{\"id\":\"pi_test\",\"status\":\"succeeded\"}"
}
}| Field | Type | Required | Purpose |
|---|---|---|---|
action | "mock" | yes | Action discriminator |
response | object | no (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.
{
"action": "error",
"error": {
"status": 503,
"body": { "error": "simulated outage" }
}
}| Field | Type | Required | Purpose |
|---|---|---|---|
action | "error" | yes | Action discriminator |
error.status | integer | yes | Status code to return |
error.body | any | no | Response body |
then.action: "passthrough"
Force the request through to the real upstream, even if policy would normally block it.
{ "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.
{ "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:
{
"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 subsequentmockOutbound()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:
- Session rules (via
mockOutbound/rules apply) are checked first. - Case-embedded rules (
case.rules[]) are checked second. - Policy-synthesized catch-all is last (strict miss, default-on-miss).
Within each layer:
- Highest
prioritywins. - 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
mockOutboundand callclearRules()between scenarios, or - Use a
priorityhierarchy 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:
npm install -g ajv-cli
ajv validate \
-s spec/schemas/rule.schema.json \
-d rules/my-rule.jsonOr use the CLI:
softprobe validate rules rules/my-rule.yamlComplete worked example
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_onlySee also
- Rules and policy — conceptual overview and common patterns.
- Mock an external dependency — typical rule-writing workflows.
- HTTP control API → rules — posting rule documents.
- Case file schema — embedding rules in cases.