18 KiB
Outbound Contract-Driven Mocking Spec
Status: Proposed Date: 2026-04-27
This document supersedes Arbiter's local draft at ~/Business/workspace/Arbiter/docs/APOPHIS_OUTBOUND_MOCK_PROPOSAL.md and its interim adapter at ~/Business/workspace/Arbiter/src/server/server/services/StripeFetchAdapter.js.
The direction in that proposal is correct: routes should be able to declare the contracts and expectations of their outbound dependencies, and APOPHIS should use those declarations to generate mocks, inject dependency-layer chaos, and support both contract testing and imperative E2E testing.
This spec keeps that idea small and consistent with the runtime paths APOPHIS already has.
Goals
- Let routes declare outbound dependency contracts once and reuse them anywhere.
- Generate contract-conformant outbound mock responses from JSON Schema.
- Apply chaos at the dependency layer, before application code receives the response.
- Record outbound calls so tests and contracts can inspect them.
- Work in both APOPHIS contract tests and imperative E2E tests.
- Reuse existing chaos, fast-check, and flake-detection infrastructure.
- Avoid service-specific adapters and avoid a second testing engine.
Non-Goals
- No Stripe-specific or service-specific code in APOPHIS.
- No second DSL for outbound expectations.
- No new backward-compatibility layer for old chaos config.
- No static JS analysis in this cut. The design must enable it later, not implement it now.
Parsimony Rules
- One schema annotation:
x-outbound. - One shared registry:
outboundContracts. - One runtime owner:
OutboundMockRuntime. - One fetch interception path: reuse
wrapFetch()andcreateOutboundInterceptor()instead of inventing another chaos stack. - One property-generation engine: reuse
convertSchema()for dependency responses instead of creating a second generator pipeline. - One seeded randomness model: derive outbound mock randomness from the same test seed via sub-seeds, never
Date.now(). - No service adapters in core. If an application wants an adapter, it should be a thin local wrapper over fetch, not the core abstraction.
Core Design
1. Shared outbound contracts are registered once
Add a plugin-level registry:
await fastify.register(apophis, {
outboundContracts: {
'stripe.paymentIntents.create': {
target: 'https://api.stripe.com/v1/payment_intents',
method: 'POST',
request: { ...json schema... },
response: {
200: { ...json schema... },
402: { ...json schema... },
429: { ...json schema... }
},
chaos: {
error: {
probability: 0.02,
responses: [
{ statusCode: 429, headers: { 'retry-after': '60' } },
{ statusCode: 503, body: { error: { type: 'api_error' } } }
]
}
}
}
}
})
2. Routes reference or inline outbound contracts with one annotation
Do not add x-outbound-uses and x-outbound-contracts as two separate concepts. Use one annotation:
schema: {
'x-outbound': [
'stripe.paymentIntents.create',
{
ref: 'stripe.customers.retrieve',
chaos: {
error: {
probability: 1,
responses: [{ statusCode: 404, body: { error: { type: 'invalid_request_error' } } }]
}
}
},
{
name: 'audit.events.write',
target: 'https://audit.internal/v1/events',
method: 'POST',
request: { ...json schema... },
response: { 202: { ...json schema... } }
}
],
'x-ensures': [
'if response_code(this) == 200 then response_body(this).paid == true else true'
]
}
Why this shape:
- One-off dependencies can be inline.
- Shared dependencies can be referenced by name.
- Route-local chaos overrides are possible without duplicating the shared contract.
- We do not create a second metadata system just to support references.
3. APOPHIS owns the outbound runtime in test mode
Contract tests and stateful tests should automatically install outbound mocking when a route declares x-outbound.
Imperative E2E tests should be able to opt in manually:
await fastify.apophis.enableOutboundMocks({
contracts: ['stripe.paymentIntents.create'],
mode: 'property',
overrides: {
'stripe.paymentIntents.create': {
forceStatus: 402,
body: { error: { type: 'card_error', code: 'card_declined' } }
}
}
})
// normal app-level test code here
const calls = fastify.apophis.getOutboundCalls('stripe.paymentIntents.create')
await fastify.apophis.disableOutboundMocks()
This is the right place for E2E support. It keeps imperative tests imperative while letting APOPHIS provide deterministic dependency behavior.
4. Outbound expectations should reuse APOSTL, not invent a second DSL
Do not add a new outbound assertion language.
Expose outbound call facts through a built-in extension surface so existing x-ensures and x-requires can talk about dependency behavior.
Target shape:
'outbound_calls(this).stripe.paymentIntents.create.count == 1'
'outbound_last(this).stripe.paymentIntents.create.response.statusCode == 402'
'if outbound_last(this).stripe.paymentIntents.create.response.statusCode == 402 then response_code == 400 else true'
This keeps all behavioral expectations in APOSTL.
Implementation note: contract names should be dot-separated identifiers so they naturally project into accessor paths.
5. Property-based testing should run on both sides
Today we generate route inputs from request schemas. We should also be able to generate dependency outputs from outbound response schemas.
That means APOPHIS can test:
- many valid caller requests to the route
- many valid dependency responses allowed by the outbound contract
- whether the route still satisfies its own postconditions
This is not a second property engine. It is an augmentation of the existing command generation and execution flow.
Runtime Model
For a single route execution under contract testing:
- runner resolves the route's
x-outboundbindings - bindings are normalized against the shared registry
- an
OutboundMockRuntimeis installed for the duration of that request execution globalThis.fetchis wrapped in test mode for the duration of execution- outbound calls matching a resolved contract return generated or overridden responses
- the existing outbound chaos layer decorates those responses
- call traces are recorded
- after the request completes, fetch is restored
- the recorded outbound facts are attached to the eval context for formulas and diagnostics
For imperative E2E:
- test calls
enableOutboundMocks() - runtime installs fetch patch once
- test drives the app normally
- test inspects
getOutboundCalls() - test calls
disableOutboundMocks()
Public API Changes
ApophisOptions
Add shared outbound contract registration:
readonly outboundContracts?: Record<string, OutboundContractSpec>
TestConfig
Add runner-level outbound mock control:
readonly outboundMocks?: false | {
readonly mode?: 'example' | 'property'
readonly contracts?: readonly string[]
readonly overrides?: Record<string, {
readonly forceStatus?: number
readonly headers?: Record<string, string>
readonly body?: unknown
}>
readonly unmatched?: 'error' | 'passthrough'
}
Notes:
falsedisables outbound mocking even if routes declarex-outbound.mode: 'example'returns one contract-conformant response per dependency and is the default.mode: 'property'samples across documented dependency responses.unmatched: 'error'should be the default in test mode to prevent accidental real network access.
ApophisDecorations
Add imperative test helpers:
readonly registerOutboundContracts: (contracts: Record<string, OutboundContractSpec>) => void
readonly enableOutboundMocks: (opts?: TestConfig['outboundMocks']) => Promise<void>
readonly disableOutboundMocks: () => Promise<void>
readonly getOutboundCalls: (name?: string) => ReadonlyArray<OutboundCallRecord>
Concrete File Plan
Line numbers below are current as of 2026-04-27 and should be rechecked before editing.
1. src/types.ts
Current anchors:
RouteContract: lines 28-42TestConfig: lines 256-264ChaosConfig: lines 308-355ApophisOptions: lines 498-519ApophisDecorations: lines 612-627
Modify:
- Add
outbound?: readonly OutboundBinding[]toRouteContractat lines 28-42. - Add
OutboundContractSpec,OutboundBinding,ResolvedOutboundContract, andOutboundCallRecordnear existing outbound chaos types at lines 266-356. - Add
outboundMocks?: false | { ... }toTestConfigat lines 256-264. - Add
outboundContracts?: Record<string, OutboundContractSpec>toApophisOptionsat lines 498-519. - Add
registerOutboundContracts,enableOutboundMocks,disableOutboundMocks, andgetOutboundCallstoApophisDecorationsat lines 612-627.
Keep it parsimonious:
- Do not add a second chaos config type.
- Do not add separate inline-vs-reference types if a discriminated union on
x-outboundhandles both. - Keep
OutboundContractSpecJSON-Schema-centric soconvertSchema()can reuse it directly.
2. src/domain/contract.ts
Current anchor: lines 35-95 extract x-category, x-requires, x-ensures, and x-timeout.
Modify:
- Parse
schema['x-outbound']. - Normalize string refs and inline objects into
RouteContract.outbound. - Preserve current caching behavior in
contractCache.
Keep it parsimonious:
- Normalize shape here once.
- Do not resolve references here because this module does not own plugin-level registries.
- Do not add outbound-specific execution logic here.
3. src/plugin/index.ts
Current anchor: lines 29-102 own plugin setup, registries, route capture, and decorations.
Modify:
- Instantiate an
OutboundContractRegistryandOutboundMockRuntimenext to existing registries. - Register
opts.outboundContractsduring plugin setup. - Add imperative outbound mock decorations.
- Register a built-in outbound extension that exposes
outbound_calls(this)andoutbound_last(this).
Keep it parsimonious:
- Do not create a separate plugin or extension package for outbound support.
- Reuse existing plugin lifecycle instead of bolting on a second orchestrator.
4. src/plugin/contract-builder.ts
Current anchor: lines 14-29 build the config passed into runPetitTests().
Modify:
- Pass through
opts.outboundMocks.
Keep it parsimonious:
- No logic here beyond forwarding config.
5. src/plugin/stateful-builder.ts
Current anchor: lines 13-29 build the config passed into runStatefulTests().
Modify:
- Pass through
opts.outboundMocks. - Keep parity with
contract-builder.ts.
6. src/test/petit-runner.ts
Current anchors:
- lines 237-243 function signature
- line 322 constructs
EnhancedChaosEngine - lines 342-430 build request and execute the route
- lines 497-512 attach chaos diagnostics
Modify:
- Resolve
route.outboundagainst the shared registry before execution. - Install
OutboundMockRuntimearound the single route execution. - If
chaosEngine.executeWithChaos()returnsoutboundInterceptor, compose it into the runtime instead of inventing a second path. - Attach outbound call trace into diagnostics and eval context.
- In property mode, expand outbound response scenarios using
convertSchema(..., { context: 'response' })and deterministic seeds.
Keep it parsimonious:
- Do not create a second runner.
- Do not fork request generation logic; augment the existing execution loop.
- Reuse the runner seed and
SeededRnginstead of introducing local randomness.
7. src/test/stateful-runner.ts
Current anchors:
- line 25 imports legacy
ChaosEnginefrom../quality/chaos.js - line 62 stores
chaosEngine?: ChaosEngine - lines 272-279 execute with chaos
- line 394 constructs
new ChaosEngine(config.chaos, config.seed)
Modify:
- Migrate stateful testing to
EnhancedChaosEnginefromsrc/quality/chaos-v2.ts. - Install the same outbound mock runtime used by
petit-runner.ts. - Use the same outbound scenario generation rules so stateful and contract runners do not diverge.
Keep it parsimonious:
- Do not maintain two chaos stacks.
- Do not implement outbound mocking twice.
8. src/quality/chaos-v2.ts
Current anchors:
- lines 52-125
wrapFetch() - lines 148-188 seed management and outbound interceptor construction
- lines 214-274 route execution and outbound interceptor attachment
Modify:
- Keep
wrapFetch()as the only fetch interception primitive. - Add a small composition helper if needed so
OutboundMockRuntimecan runmock response -> outbound chaos overlay -> Response. - Keep per-route chaos resolution in
buildOutboundInterceptor().
Keep it parsimonious:
- Do not move mock generation into chaos-v2.
- Chaos owns chaos, not contract generation.
9. src/quality/chaos-outbound.ts
Current anchor: lines 36-105 create the pure outbound interceptor.
Modify:
- No structural redesign required.
- Ensure the interceptor remains transport-agnostic and can wrap both real fetch and mock responders.
Keep it parsimonious:
- This file should stay pure.
- Do not add runtime registry logic here.
10. src/domain/schema-to-arbitrary.ts
Current anchor: lines 214-217 export convertSchema().
Modify:
- Reuse
convertSchema(responseSchema, { context: 'response' })for generated dependency responses. - Add a tiny helper for weighted status-code sampling if needed, but do not fork the schema conversion logic.
Keep it parsimonious:
- No second schema generator.
- No outbound-specific arbitrary builder unless it is only a thin composition over
convertSchema().
11. src/quality/flake.ts
Current anchor: lines 56-97 derive reruns from a seed.
Modify:
- Public API can stay unchanged if outbound runtime derives every sub-seed from the rerun seed.
- If diagnostics are added, include outbound scenario seed in rerun metadata, but do not add a separate flake engine.
Keep it parsimonious:
- Flake support should come from determinism, not from more feature flags.
12. New files
Add only these new files:
src/domain/outbound-contracts.ts- normalize, resolve, and validate
x-outboundbindings against the shared registry
- normalize, resolve, and validate
src/infrastructure/outbound-mock-runtime.ts- install and restore fetch patch
- record calls
- resolve overrides
- return generated or overridden responses
src/extensions/outbound.ts- built-in extension exposing outbound call facts to APOSTL
Do not add service-specific adapters, provider-specific modules, or a separate outbound runner.
Interaction With Existing Chaos
The order must be:
- route declares outbound contract
- runtime resolves contract
- runtime generates or overrides mock response
- existing outbound chaos interceptor applies delay/error/dropout if configured
- application code receives the final dependency response
This keeps chaos at the correct layer and reuses the current outbound chaos implementation.
Do not invert the order by making chaos choose a response before the contract mock runtime runs. Outbound mocking must generate the dependency response first; chaos then mutates or delays that response.
Interaction With flake detection
This feature must be deterministic under a single seed.
Rules:
- route command generation already depends on
config.seed - outbound response generation must derive from that same seed
- per-contract sampling must use stable sub-seeds, e.g.
hashCombine(seed, stableHash(contractName)) - route-local chaos and outbound mock generation must not perturb each other beyond their dedicated sub-streams
If these rules hold, FlakeDetector needs no public redesign.
Interaction With property-based testing
Phase 1:
mode: 'example'uses one generated success response per dependency plus explicit override cases.- This is enough to support contract tests and imperative E2E immediately.
Phase 2:
mode: 'property'samples across every documented outbound status code.- For each sampled dependency response, APOPHIS executes the route and checks route postconditions.
- This gives property-based testing on both sides of the integration boundary.
We should not block phase 1 on phase 2, but the types and runtime must be designed so phase 2 is an additive change, not a rewrite.
Suggested Test Plan
Add tests in these areas:
src/test/domain.test.tsextractContract()parsesx-outboundstring refsextractContract()parses inline outbound contracts- route-local chaos overrides are preserved
src/test/outbound-runtime.test.ts- generated success response matches schema shape
- override response takes precedence
- unmatched fetch throws by default in test mode
- call recording works
- fetch patch restore is correct
src/test/outbound-interceptor.test.ts- existing outbound chaos still works when wrapping a mock executor
src/test/integration.test.tsfastify.apophis.contract()withx-outboundexercises dependency-layer failuresenableOutboundMocks()supports imperative E2E style
src/test/stateful-runner.test.tsor new stateful integration tests- stateful runner uses the same outbound runtime and chaos path
src/test/flake.test.tsnew- same seed gives same outbound responses and same call trace
- different seeds explore different dependency outputs without nondeterministic drift
Migration Guidance
For Arbiter:
- move the Stripe contract definitions out of
src/server/server/services/StripeFetchAdapter.js - register them once via
outboundContracts - change route schemas to use
x-outbound - delete the local adapter after APOPHIS fetch instrumentation is in place
The long-term target is that applications declare outbound behavior through outboundContracts and x-outbound; provider-specific fetch wrappers remain application-local.
Deferred, But Enabled By This Design
- static analysis of whether a route contract can be satisfied for all permitted dependency responses
- detection of impossible route postconditions before running property tests
- contract coverage reports across inbound and outbound boundaries
Those features should be built later on top of the normalized outbound contract registry, not by expanding the runtime surface prematurely.